Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/features/media-library/components/media-library.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { MissingMediaDialog } from './missing-media-dialog';
import { OrphanedClipsDialog } from './orphaned-clips-dialog';
import { UnsupportedAudioCodecDialog } from './unsupported-audio-codec-dialog';
import { useMediaLibraryStore } from '../stores/media-library-store';
import { useAutoImportStore } from '../stores/auto-import-store';
import {
useTimelineStore,
useCompositionNavigationStore,
Expand Down Expand Up @@ -111,6 +112,12 @@ export const MediaLibrary = memo(function MediaLibrary({ onMediaSelect }: MediaL
const proxyStatus = useMediaLibraryStore((s) => s.proxyStatus);
const proxyProgress = useMediaLibraryStore((s) => s.proxyProgress);

// Auto-import state
const autoImportActive = useAutoImportStore((s) => s.active);
const autoImportFolderName = useAutoImportStore((s) => s.folderName);
const enableAutoImport = useAutoImportStore((s) => s.enable);
const disableAutoImport = useAutoImportStore((s) => s.disable);

// Composition navigation — show banner when inside a sub-comp
const activeCompositionId = useCompositionNavigationStore((s) => s.activeCompositionId);
const breadcrumbs = useCompositionNavigationStore((s) => s.breadcrumbs);
Expand Down Expand Up @@ -394,6 +401,25 @@ export const MediaLibrary = memo(function MediaLibrary({ onMediaSelect }: MediaL
{/* Header toolbar */}
<div className="px-3 py-2 border-b border-border flex-shrink-0">
<div className="flex items-center gap-2 text-xs">
{/* Auto-import toggle */}
<button
onClick={autoImportActive ? disableAutoImport : enableAutoImport}
disabled={!currentProjectId}
className={`flex items-center gap-1.5 h-7 px-2.5 rounded-md
transition-colors duration-150
disabled:opacity-40 disabled:cursor-not-allowed
${autoImportActive
? 'bg-green-600 text-white hover:bg-red-600'
: 'bg-secondary text-foreground hover:bg-secondary/80 border border-border'
}`}
title={autoImportActive
? `Watching "${autoImportFolderName}" — click to disable`
: 'Watch a folder for new files and auto-add to timeline'}
>
<Upload className="w-3.5 h-3.5" />
<span>{autoImportActive ? 'Disable Auto' : 'Auto Import'}</span>
</button>

{/* Import action */}
<button
onClick={handleImport}
Expand Down
1 change: 1 addition & 0 deletions src/features/media-library/deps/timeline-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export {
removeItems,
updateItem,
removeItemsFromItemsActions,
addItem,
} from './timeline-contract';
5 changes: 5 additions & 0 deletions src/features/media-library/deps/timeline-utils.ts
Original file line number Diff line number Diff line change
@@ -1 +1,6 @@
export { autoMatchOrphanedClips } from './timeline-contract';
export {
buildDroppedMediaTimelineItem,
type DroppableMediaType,
getDroppedMediaDurationInFrames,
} from './timeline-contract';
272 changes: 272 additions & 0 deletions src/features/media-library/stores/auto-import-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,272 @@
import { create } from 'zustand';
import { useMediaLibraryStore } from './media-library-store';
import { useItemsStore } from '@/features/media-library/deps/timeline-stores';
import { useTimelineSettingsStore } from '@/features/media-library/deps/timeline-stores';
import { useProjectStore } from '@/features/media-library/deps/projects';
import {
buildDroppedMediaTimelineItem,
type DroppableMediaType,
getDroppedMediaDurationInFrames,
} from '@/features/media-library/deps/timeline-utils';
import { addItem } from '@/features/media-library/deps/timeline-actions';
import { resolveMediaUrl } from '@/features/media-library/utils/media-resolver';
import { mediaLibraryService } from '@/features/media-library/services/media-library-service';
import { getMediaType } from '@/features/media-library/utils/validation';
import { createLogger } from '@/shared/logging/logger';

const logger = createLogger('AutoImport');

const POLL_INTERVAL_MS = 3000;

const VIDEO_EXTENSIONS = new Set(['.mp4', '.webm', '.mov', '.avi', '.mkv']);
const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.ogg', '.m4a', '.aac']);
const IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg']);
Comment on lines +22 to +23

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Auto-import extension filter is missing .opus.

Line 22 excludes .opus, so supported Opus files are never detected by watched-folder auto-import even though validation supports them.

💡 Suggested fix
-const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.ogg', '.m4a', '.aac']);
+const AUDIO_EXTENSIONS = new Set(['.mp3', '.wav', '.ogg', '.opus', '.m4a', '.aac']);
Prefer sourcing supported extensions from a shared validation constant to avoid future drift.

Also applies to: 25-29

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/media-library/stores/auto-import-store.ts` around lines 22 - 23,
The audio extension set AUDIO_EXTENSIONS currently omits ".opus" so
watched-folder auto-import never detects Opus files; update the AUDIO_EXTENSIONS
constant to include '.opus' (and any other missing audio extensions) or,
preferably, import the canonical list of supported audio file extensions from
the shared validation constant used elsewhere to avoid drift; apply the same
sourcing approach for image/audio extension lists referenced near
IMAGE_EXTENSIONS and the related sets on lines 25-29 so all extension checks use
the single shared source of truth.


function isSupportedMediaFile(name: string): boolean {
const ext = name.toLowerCase().match(/\.[^.]+$/)?.[0];
if (!ext) return false;
return VIDEO_EXTENSIONS.has(ext) || AUDIO_EXTENSIONS.has(ext) || IMAGE_EXTENSIONS.has(ext);
}

/**
* Tracks files that were detected but may still be written to (e.g. OBS recordings).
* Maps filename -> last observed file size. A file is considered stable when its
* size is >0 and unchanged between two consecutive polls.
*/
const pendingFiles = new Map<string, number>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Module-level mutable state lives outside the store

pendingFiles is declared at module scope, making it invisible to Zustand devtools, not serialisable/rehydratable, and — most practically — not cleared on a hot-module-reload (HMR) cycle. If the module is re-evaluated, pendingFiles is reset but the Zustand store may still be active, silently leaking stale file-size entries. There's also a subtle edge case: if disable() is called while pollForNewFiles is executing between two await points, the pendingFiles.clear() on line 113 runs, but the already-captured for...of iterator over pendingFiles (line 165) will stop iterating mid-loop because Map iterators reflect live deletions — meaning some stable files may never get imported even though they were detected.

Consider moving pendingFiles into the Zustand state object (as a Map<string, number>), so its lifecycle is fully controlled by enable/disable.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/features/media-library/stores/auto-import-store.ts
Line: 36

Comment:
**Module-level mutable state lives outside the store**

`pendingFiles` is declared at module scope, making it invisible to Zustand devtools, not serialisable/rehydratable, and — most practically — not cleared on a hot-module-reload (HMR) cycle. If the module is re-evaluated, `pendingFiles` is reset but the Zustand store may still be active, silently leaking stale file-size entries. There's also a subtle edge case: if `disable()` is called while `pollForNewFiles` is executing between two `await` points, the `pendingFiles.clear()` on line 113 runs, but the already-captured `for...of` iterator over `pendingFiles` (line 165) will stop iterating mid-loop because Map iterators reflect live deletions — meaning some stable files may never get imported even though they were detected.

Consider moving `pendingFiles` into the Zustand state object (as a `Map<string, number>`), so its lifecycle is fully controlled by `enable`/`disable`.

How can I resolve this? If you propose a fix, please make it concise.


interface AutoImportState {
active: boolean;
directoryHandle: FileSystemDirectoryHandle | null;
folderName: string | null;
knownFiles: Set<string>;
timerId: ReturnType<typeof setInterval> | null;
}

interface AutoImportActions {
enable: () => Promise<void>;
disable: () => void;
}

export const useAutoImportStore = create<AutoImportState & AutoImportActions>()(
(set, get) => ({
active: false,
directoryHandle: null,
folderName: null,
knownFiles: new Set(),
timerId: null,

enable: async () => {
if (get().active) return;

if (!('showDirectoryPicker' in window)) {
useMediaLibraryStore.getState().showNotification({
type: 'error',
message: 'Directory picker not supported. Please use Google Chrome.',
});
return;
}

try {
const dirHandle = await window.showDirectoryPicker({ mode: 'read' });

// Snapshot existing files so we only import new ones
const knownFiles = new Set<string>();
for await (const entry of dirHandle.values()) {
if (entry.kind === 'file' && isSupportedMediaFile(entry.name)) {
knownFiles.add(entry.name);
}
}

const timerId = setInterval(() => pollForNewFiles(), POLL_INTERVAL_MS);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 No concurrency guard — overlapping polls can cause duplicate imports

setInterval fires pollForNewFiles every 3 seconds regardless of whether the previous invocation has completed. A single import of a large video file can easily exceed 3 seconds, causing two (or more) polls to run simultaneously. When concurrent polls both observe the same stable file in pendingFiles before either one has had a chance to delete it, both polls can each:

  1. Call pendingFiles.delete(fileName) — only one will "win" the deletion, but the iteration snapshot for the other may already hold the entry.
  2. Both call addItem(timelineItem) — resulting in a duplicate clip on the timeline.

Add a simple boolean guard to serialise poll invocations:

let pollRunning = false;

async function pollForNewFiles(): Promise<void> {
  if (pollRunning) return;
  pollRunning = true;
  try {
    // ... existing body ...
  } finally {
    pollRunning = false;
  }
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/features/media-library/stores/auto-import-store.ts
Line: 81

Comment:
**No concurrency guard — overlapping polls can cause duplicate imports**

`setInterval` fires `pollForNewFiles` every 3 seconds regardless of whether the previous invocation has completed. A single import of a large video file can easily exceed 3 seconds, causing two (or more) polls to run simultaneously. When concurrent polls both observe the same stable file in `pendingFiles` before either one has had a chance to delete it, both polls can each:
1. Call `pendingFiles.delete(fileName)` — only one will "win" the deletion, but the iteration snapshot for the other may already hold the entry.
2. Both call `addItem(timelineItem)` — resulting in a duplicate clip on the timeline.

Add a simple boolean guard to serialise poll invocations:

```
let pollRunning = false;

async function pollForNewFiles(): Promise<void> {
  if (pollRunning) return;
  pollRunning = true;
  try {
    // ... existing body ...
  } finally {
    pollRunning = false;
  }
}
```

How can I resolve this? If you propose a fix, please make it concise.


Comment on lines +81 to +82

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prevent poll re-entrancy to avoid overlapping imports.

Line 81 starts an async poll on an interval, but interval ticks can overlap while a previous poll is still running. This can race on pendingFiles/knownFiles and duplicate processing.

💡 Suggested fix
 const POLL_INTERVAL_MS = 3000;
+let pollInProgress = false;
@@
 async function pollForNewFiles(): Promise<void> {
+  if (pollInProgress) return;
+  pollInProgress = true;
+
   const state = useAutoImportStore.getState();
-  if (!state.active || !state.directoryHandle) return;
+  if (!state.active || !state.directoryHandle) {
+    pollInProgress = false;
+    return;
+  }
@@
-  try {
+  try {
@@
   } catch (error) {
     logger.error('Auto-import poll error:', error);
+  } finally {
+    pollInProgress = false;
   }
 }

Also applies to: 126-272

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/media-library/stores/auto-import-store.ts` around lines 81 - 82,
The interval starts async work via pollForNewFiles() which can re-enter if a
tick fires while a previous poll is still running, causing races on
pendingFiles/knownFiles and duplicate processing; fix by making pollForNewFiles
non-reentrant: add a local running flag or mutex (e.g., isPolling) checked at
the top of pollForNewFiles and set/unset around the async work (or switch from
setInterval to a self-scheduling setTimeout that awaits completion before
scheduling the next run), and apply the same change to the other
polling/interval usages in this file (the other timer blocks that reference
timerId/pollForNewFiles/pendingFiles/knownFiles).

set({
active: true,
directoryHandle: dirHandle,
folderName: dirHandle.name,
knownFiles,
timerId,
});

useMediaLibraryStore.getState().showNotification({
type: 'success',
message: `Auto-import watching "${dirHandle.name}" for new files`,
});

logger.info(`Auto-import enabled for "${dirHandle.name}", ${knownFiles.size} existing files`);
} catch (error) {
if (error instanceof Error && error.name !== 'AbortError') {
logger.error('Failed to enable auto-import:', error);
useMediaLibraryStore.getState().showNotification({
type: 'error',
message: `Auto-import failed: ${error.message}`,
});
}
}
},

disable: () => {
const { timerId } = get();
if (timerId) {
clearInterval(timerId);
}
pendingFiles.clear();
set({
active: false,
directoryHandle: null,
folderName: null,
knownFiles: new Set(),
timerId: null,
});
logger.info('Auto-import disabled');
},
})
);

async function pollForNewFiles(): Promise<void> {
const state = useAutoImportStore.getState();
if (!state.active || !state.directoryHandle) return;

const { currentProjectId } = useMediaLibraryStore.getState();
if (!currentProjectId) return;

try {
// Verify permission is still granted
const permissionStatus = await state.directoryHandle.queryPermission({ mode: 'read' });
if (permissionStatus !== 'granted') {
logger.warn('Auto-import: directory permission lost, disabling');
useAutoImportStore.getState().disable();
useMediaLibraryStore.getState().showNotification({
type: 'warning',
message: 'Auto-import disabled: directory permission lost',
});
return;
}

// Scan directory for new files
for await (const entry of state.directoryHandle.values()) {
if (entry.kind === 'file' && isSupportedMediaFile(entry.name)) {
if (!state.knownFiles.has(entry.name) && !pendingFiles.has(entry.name)) {
// New file detected — add to pending with current size
const fileHandle = entry as FileSystemFileHandle;
const file = await fileHandle.getFile();
pendingFiles.set(entry.name, file.size);
logger.info(`Auto-import: detected new file "${entry.name}" (${file.size} bytes), waiting for write to finish...`);
}
}
}

if (pendingFiles.size === 0) return;

// Check which pending files are now stable (size > 0 and unchanged since last poll)
const stableHandles: FileSystemFileHandle[] = [];
const currentKnown = new Set(state.knownFiles);

for (const [fileName, lastSize] of pendingFiles) {
try {
const handle = await state.directoryHandle.getFileHandle(fileName);
const file = await handle.getFile();
const currentSize = file.size;

if (currentSize === 0) {
// Still empty, keep waiting
continue;
}

if (currentSize === lastSize) {
// Size unchanged and > 0 — file is done being written
stableHandles.push(handle);
currentKnown.add(fileName);
pendingFiles.delete(fileName);
logger.info(`Auto-import: "${fileName}" stable at ${currentSize} bytes, importing`);
} else {
// Still growing, update the tracked size for next poll
pendingFiles.set(fileName, currentSize);
logger.info(`Auto-import: "${fileName}" still writing (${lastSize} -> ${currentSize} bytes)`);
}
} catch {
// File may have been deleted before we could read it
pendingFiles.delete(fileName);
}
}

if (stableHandles.length === 0) return;

// Update known files immediately to avoid re-processing
useAutoImportStore.setState({ knownFiles: currentKnown });
Comment on lines +163 to +196

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stale knownFiles snapshot causes lost state on concurrent polls

currentKnown is built from state.knownFiles — a snapshot captured at the very top of the function (line 127). Between that snapshot and line 163 several await points occur (permission check, full directory scan, per-file getFile() calls). If another concurrent poll (which can start after 3 seconds while this one is still running) completes its own stable-file cycle and calls useAutoImportStore.setState({ knownFiles: ... }) during that window, its additions will be wiped when this poll subsequently calls setState with currentKnown derived from the stale snapshot.

This means a file that the second poll just marked as "known" (to avoid re-import) will disappear from store state, and the next poll will attempt to import it again — placing a duplicate clip on the timeline.

Fix: re-read the current store state at line 163 instead of using the stale state snapshot:

Suggested change
const currentKnown = new Set(state.knownFiles);
for (const [fileName, lastSize] of pendingFiles) {
try {
const handle = await state.directoryHandle.getFileHandle(fileName);
const file = await handle.getFile();
const currentSize = file.size;
if (currentSize === 0) {
// Still empty, keep waiting
continue;
}
if (currentSize === lastSize) {
// Size unchanged and > 0 — file is done being written
stableHandles.push(handle);
currentKnown.add(fileName);
pendingFiles.delete(fileName);
logger.info(`Auto-import: "${fileName}" stable at ${currentSize} bytes, importing`);
} else {
// Still growing, update the tracked size for next poll
pendingFiles.set(fileName, currentSize);
logger.info(`Auto-import: "${fileName}" still writing (${lastSize} -> ${currentSize} bytes)`);
}
} catch {
// File may have been deleted before we could read it
pendingFiles.delete(fileName);
}
}
if (stableHandles.length === 0) return;
// Update known files immediately to avoid re-processing
useAutoImportStore.setState({ knownFiles: currentKnown });
const stableHandles: FileSystemFileHandle[] = [];
const currentKnown = new Set(useAutoImportStore.getState().knownFiles);
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/features/media-library/stores/auto-import-store.ts
Line: 163-196

Comment:
**Stale `knownFiles` snapshot causes lost state on concurrent polls**

`currentKnown` is built from `state.knownFiles` — a snapshot captured at the very top of the function (line 127). Between that snapshot and line 163 several `await` points occur (permission check, full directory scan, per-file `getFile()` calls). If another concurrent poll (which can start after 3 seconds while this one is still running) completes its own stable-file cycle and calls `useAutoImportStore.setState({ knownFiles: ... })` during that window, its additions will be wiped when this poll subsequently calls `setState` with `currentKnown` derived from the stale snapshot.

This means a file that the second poll just marked as "known" (to avoid re-import) will disappear from store state, and the next poll will attempt to import it again — placing a duplicate clip on the timeline.

Fix: re-read the _current_ store state at line 163 instead of using the stale `state` snapshot:

```suggestion
    const stableHandles: FileSystemFileHandle[] = [];
    const currentKnown = new Set(useAutoImportStore.getState().knownFiles);
```

How can I resolve this? If you propose a fix, please make it concise.


logger.info(`Auto-import: importing ${stableHandles.length} stable file(s)`);

// Import into media library
const importedMedia = await useMediaLibraryStore.getState().importHandles(stableHandles);

if (importedMedia.length === 0) return;

// Append each imported item to end of timeline on track 1
const fps = useTimelineSettingsStore.getState().fps;
const tracks = useItemsStore.getState().tracks;
const project = useProjectStore.getState().currentProject;
const canvasWidth = project?.metadata.width ?? 1920;
const canvasHeight = project?.metadata.height ?? 1080;

// Find first visible, unlocked, non-group track
const targetTrack = tracks.find(
(t: { visible?: boolean; locked?: boolean; isGroup?: boolean }) =>
t.visible !== false && !t.locked && !t.isGroup
);
if (!targetTrack) {
logger.warn('Auto-import: no available track for timeline placement');
return;
}

for (const media of importedMedia) {
const rawMediaType = getMediaType(media.mimeType);
if (rawMediaType === 'unknown') continue;
const mediaType: DroppableMediaType = rawMediaType;

const durationInFrames = getDroppedMediaDurationInFrames(media, mediaType, fps);

// Find end of all items on the target track
const trackItems = useItemsStore.getState().items.filter(
(i: { trackId: string }) => i.trackId === targetTrack.id
);
const endFrame = trackItems.reduce(
(max: number, item: { from: number; durationInFrames: number }) =>
Math.max(max, item.from + item.durationInFrames),
0
);

const blobUrl = await resolveMediaUrl(media.id);
if (!blobUrl) continue;

const thumbnailUrl = await mediaLibraryService.getThumbnailBlobUrl(media.id);

const timelineItem = buildDroppedMediaTimelineItem({
media,
mediaId: media.id,
mediaType,
label: media.fileName,
timelineFps: fps,
blobUrl,
thumbnailUrl,
canvasWidth,
canvasHeight,
placement: {
trackId: targetTrack.id,
from: endFrame,
durationInFrames,
},
});

addItem(timelineItem);
logger.info(`Auto-import: added "${media.fileName}" to timeline at frame ${endFrame}`);
}

useMediaLibraryStore.getState().showNotification({
type: 'success',
message: `Auto-imported ${importedMedia.length} file(s) to timeline`,
});
} catch (error) {
logger.error('Auto-import poll error:', error);
}
}
Loading