From 8f2ec699663053ee91cd42658affb7eb09c916b5 Mon Sep 17 00:00:00 2001 From: webadderall <131426131+webadderall@users.noreply.github.com> Date: Sun, 20 Sep 2026 21:29:53 +1000 Subject: [PATCH] feat(import): add cancellable media processing with shared Cancel styling --- electron/electron-env.d.ts | 1 + electron/ipc/ffmpeg/metadata.ts | 3 + electron/ipc/recording/importRecording.ts | 125 +++++++++-------- electron/ipc/recording/library.test.ts | 126 +++++++++++++++--- electron/ipc/recording/sequenceWebcam.ts | 77 ++++++----- electron/ipc/register/project.ts | 49 +++++-- electron/preload.ts | 1 + .../video-editor/layout/EditorShell.tsx | 15 ++- .../library/useRecordingLibrary.ts | 21 ++- 9 files changed, 301 insertions(+), 117 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index e780f1a9..6ee89b0e 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -753,6 +753,7 @@ interface Window { getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>; clearCurrentVideoPath: () => Promise<{ success: boolean }>; getRecordingThumbnail: (filePath: string) => Promise>; + cancelRecordingImport: () => Promise<{ success: boolean }>; listRecordings: () => Promise< import("../src/types/recordingLibrary").LibraryResult< import("../src/types/recordingLibrary").RecordingLibraryEntry[] diff --git a/electron/ipc/ffmpeg/metadata.ts b/electron/ipc/ffmpeg/metadata.ts index 10b68f8b..50aaf731 100644 --- a/electron/ipc/ffmpeg/metadata.ts +++ b/electron/ipc/ffmpeg/metadata.ts @@ -100,15 +100,18 @@ export function parseNativeVideoMetadataProbeOutput( export async function probeNativeVideoMetadata( ffmpegPath: string, inputPath: string, + signal?: AbortSignal, ): Promise { let output = ""; try { const result = await execFileAsync(ffmpegPath, ["-hide_banner", "-i", inputPath], { + signal, timeout: 30_000, maxBuffer: 4 * 1024 * 1024, }); output = `${result.stdout}\n${result.stderr}`; } catch (error) { + signal?.throwIfAborted(); const processOutput = error as { stdout?: unknown; stderr?: unknown }; output = [processOutput.stdout, processOutput.stderr] .filter((value): value is string => typeof value === "string") diff --git a/electron/ipc/recording/importRecording.ts b/electron/ipc/recording/importRecording.ts index a12fce58..ce4ab45e 100644 --- a/electron/ipc/recording/importRecording.ts +++ b/electron/ipc/recording/importRecording.ts @@ -19,15 +19,15 @@ import type { } from "../../../src/types/recordingLibrary"; const run = promisify(execFile); -async function ffmpeg(args: string[]) { +async function ffmpeg(args: string[], signal?: AbortSignal) { await run( getFfmpegBinaryPath(), ["-hide_banner", "-loglevel", "error", "-nostdin", "-y", ...args], - { timeout: 60 * 60 * 1000, maxBuffer: 1024 * 1024, windowsHide: true }, + { signal, timeout: 60 * 60 * 1000, maxBuffer: 1024 * 1024, windowsHide: true }, ); } -async function probe(file: string) { - const meta = await probeNativeVideoMetadata(getFfmpegBinaryPath(), file); +async function probe(file: string, signal?: AbortSignal) { + const meta = await probeNativeVideoMetadata(getFfmpegBinaryPath(), file, signal); return { width: meta.width, height: meta.height, @@ -39,8 +39,14 @@ async function probe(file: string) { type Format = { width: number; height: number; fps: number }; /** Normalize new media once. Existing sequence video is copied, avoiding generation loss. */ -async function normalize(file: string, out: string, format: Format, copyVideo: boolean) { - const meta = await probe(file); +async function normalize( + file: string, + out: string, + format: Format, + copyVideo: boolean, + signal?: AbortSignal, +) { + const meta = await probe(file, signal); const candidates = await getUsableCompanionAudioCandidates(file); const companion = candidates[0]; const system = companion?.usablePaths.includes(companion.systemPath) @@ -96,7 +102,7 @@ async function normalize(file: string, out: string, format: Format, copyVideo: b ); if (!copyVideo) args.push("-preset", "fast", "-crf", "18"); args.push("-c:a", "pcm_s16le", "-t", String(meta.duration), out); - await ffmpeg(args); + await ffmpeg(args, signal); } /** The editor and export share one immutable source, with stable offsets across imports. */ @@ -104,6 +110,7 @@ export async function importRecording( currentPath: string, recordingPath: string, currentWebcam?: RecordingWebcamSource, + signal?: AbortSignal, ): Promise { const entry = (await listRecordings()).find((entry) => entry.path === recordingPath); if (!entry) throw new Error("Recording is no longer in Videos"); @@ -113,7 +120,7 @@ export async function importRecording( if (!server) throw new Error("Media server is not ready"); const root = path.join(await getRecordingsDir(), ".recordly-media"); await fs.mkdir(root, { recursive: true }); - const base = await probe(current); + const base = await probe(current, signal); const format = { width: Math.ceil(base.width / 2) * 2, height: Math.ceil(base.height / 2) * 2, @@ -135,59 +142,65 @@ export async function importRecording( const normalizedNew = path.join(work, "new.mkv"); // Only our own normalized source format may be copied across appends. const copyVideo = isLibrarySequenceSource(current); - await normalize(current, normalizedBase, format, copyVideo); - await normalize(entry.path, normalizedNew, format, false); - const baseMeta = await probe(normalizedBase); - const newMeta = await probe(normalizedNew); + await normalize(current, normalizedBase, format, copyVideo, signal); + await normalize(entry.path, normalizedNew, format, false, signal); + const baseMeta = await probe(normalizedBase, signal); + const newMeta = await probe(normalizedNew, signal); await fs.writeFile(path.join(work, "list.txt"), "file 'base.mkv'\nfile 'new.mkv'\n"); const combined = path.join(work, "combined.mkv"); - await ffmpeg([ - "-f", - "concat", - "-safe", - "1", - "-i", - path.join(work, "list.txt"), - "-map", - "0", - "-c", - "copy", - combined, - ]); - await ffmpeg([ - "-i", - combined, - "-filter_complex", - "[0:a:0][0:a:1]amix=inputs=2:normalize=0[mix]", - "-map", - "0:v:0", - "-map", - "[mix]", - "-c:v", - "copy", - "-c:a", - "aac", - "-b:a", - "192k", - "-movflags", - "+faststart", - output, - "-map", - "0:a:0", - "-c:a", - "pcm_s16le", - `${stem}.system.wav`, - "-map", - "0:a:1", - "-c:a", - "pcm_s16le", - `${stem}.mic.wav`, - ]); + await ffmpeg( + [ + "-f", + "concat", + "-safe", + "1", + "-i", + path.join(work, "list.txt"), + "-map", + "0", + "-c", + "copy", + combined, + ], + signal, + ); + await ffmpeg( + [ + "-i", + combined, + "-filter_complex", + "[0:a:0][0:a:1]amix=inputs=2:normalize=0[mix]", + "-map", + "0:v:0", + "-map", + "[mix]", + "-c:v", + "copy", + "-c:a", + "aac", + "-b:a", + "192k", + "-movflags", + "+faststart", + output, + "-map", + "0:a:0", + "-c:a", + "pcm_s16le", + `${stem}.system.wav`, + "-map", + "0:a:1", + "-c:a", + "pcm_s16le", + `${stem}.mic.wav`, + ], + signal, + ); const sourceStartMs = Math.round(baseMeta.duration * 1000); const samples = []; for (const [file, offset, meta] of [ [current, 0, base], - [entry.path, sourceStartMs, await probe(entry.path)], + [entry.path, sourceStartMs, await probe(entry.path, signal)], ] as const) { let points: ReturnType = []; try { @@ -221,7 +234,9 @@ export async function importRecording( sourceStartMs, Math.round(newMeta.duration * 1000), currentWebcam, + signal, ); + signal?.throwIfAborted(); await rememberApprovedLocalReadPath(output); return { path: output, diff --git a/electron/ipc/recording/library.test.ts b/electron/ipc/recording/library.test.ts index 39d547fe..3e240023 100644 --- a/electron/ipc/recording/library.test.ts +++ b/electron/ipc/recording/library.test.ts @@ -9,10 +9,12 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; const state = vi.hoisted(() => ({ root: "", approved: new Set() })); vi.mock("electron", () => ({ app: { getPath: () => state.root }, - shell: { trashItem: vi.fn(async (file: string) => { - await fs.mkdir(path.join(state.root, ".test-trash"), { recursive: true }); - await fs.rename(file, path.join(state.root, ".test-trash", path.basename(file))); - }) }, + shell: { + trashItem: vi.fn(async (file: string) => { + await fs.mkdir(path.join(state.root, ".test-trash"), { recursive: true }); + await fs.rename(file, path.join(state.root, ".test-trash", path.basename(file))); + }), + }, })); vi.mock("../../appPaths", () => ({ USER_DATA_PATH: "/tmp/recordly-test", @@ -81,7 +83,9 @@ it("lists recordings, moves recordings and their companions to Trash with revers await setRecordingsRemoved([first, second], false); expect(await listRecordings()).toHaveLength(2); expect(await fs.readFile(first, "utf8")).toBe("fixture"); - expect(await fs.readFile(path.join(state.root, "recording-new.mic.wav"), "utf8")).toBe("fixture"); + expect(await fs.readFile(path.join(state.root, "recording-new.mic.wav"), "utf8")).toBe( + "fixture", + ); await expect(setRecordingsRemoved(["/tmp/outside.mp4"], true)).rejects.toThrow("outside"); }); @@ -122,13 +126,39 @@ it("imports different-sized recordings with playable video, separate audio, stab added, ]); const webcam = added.replace(".mp4", "-webcam.mp4"); - await run(ffmpeg, ["-v", "error", "-f", "lavfi", "-i", "color=c=lime:s=80x60:r=30:d=0.8", "-c:v", "libx264", "-pix_fmt", "yuv420p", webcam]); - await fs.writeFile(added.replace(".mp4", ".recordly-session.json"), JSON.stringify({ version: 2, webcamFileName: path.basename(webcam), timeOffsetMs: 200 })); - await fs.writeFile(`${added}.cursor.json`, JSON.stringify({ samples: [ - { timeMs: 200, cx: 0.2, cy: 0.4, interactionType: "click", cursorType: "pointer" }, - { timeMs: 260, cx: 0.2, cy: 0.4, interactionType: "mouseup", cursorType: "pointer" }, - { timeMs: 600, cx: 0.4, cy: 0.5, interactionType: "right-click" }, - ] })); + await run(ffmpeg, [ + "-v", + "error", + "-f", + "lavfi", + "-i", + "color=c=lime:s=80x60:r=30:d=0.8", + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + webcam, + ]); + await fs.writeFile( + added.replace(".mp4", ".recordly-session.json"), + JSON.stringify({ version: 2, webcamFileName: path.basename(webcam), timeOffsetMs: 200 }), + ); + await fs.writeFile( + `${added}.cursor.json`, + JSON.stringify({ + samples: [ + { timeMs: 200, cx: 0.2, cy: 0.4, interactionType: "click", cursorType: "pointer" }, + { + timeMs: 260, + cx: 0.2, + cy: 0.4, + interactionType: "mouseup", + cursorType: "pointer", + }, + { timeMs: 600, cx: 0.4, cy: 0.5, interactionType: "right-click" }, + ], + }), + ); const original = await fs.readFile(base); await listRecordings(); const thumbnail = await getRecordingThumbnail(base); @@ -142,10 +172,39 @@ it("imports different-sized recordings with playable video, separate audio, stab expect(result.durationMs).toBeCloseTo(1000, -1); expect(result.webcam?.visibleRanges).toEqual([{ startMs: 1200, endMs: 2000 }]); const cursor = JSON.parse(await fs.readFile(`${result.path}.cursor.json`, "utf8")).samples; - expect(cursor.map((point: { timeMs: number; interactionType: string }) => [point.timeMs, point.interactionType])).toEqual([[1200, "click"], [1260, "mouseup"], [1600, "right-click"]]); + expect( + cursor.map((point: { timeMs: number; interactionType: string }) => [ + point.timeMs, + point.interactionType, + ]), + ).toEqual([ + [1200, "click"], + [1260, "mouseup"], + [1600, "right-click"], + ]); expect(cursor[0].cursorType).toBe("pointer"); expect(cursor[0].cx).toBeCloseTo(0.405078125); - const webcamPixels = await run(ffmpeg, ["-v", "error", "-ss", "1.5", "-i", result.webcam!.sourcePath!, "-vf", "crop=2:2:40:30", "-frames:v", "1", "-f", "rawvideo", "-pix_fmt", "rgb24", "-"], { encoding: "buffer" }); + const webcamPixels = await run( + ffmpeg, + [ + "-v", + "error", + "-ss", + "1.5", + "-i", + result.webcam!.sourcePath!, + "-vf", + "crop=2:2:40:30", + "-frames:v", + "1", + "-f", + "rawvideo", + "-pix_fmt", + "rgb24", + "-", + ], + { encoding: "buffer" }, + ); expect(webcamPixels.stdout[1]).toBeGreaterThan(200); const companions = await getCompanionAudioFallbackInfo(result.path); expect(companions.paths).toHaveLength(2); @@ -175,7 +234,10 @@ it("imports different-sized recordings with playable video, separate audio, stab expect(pixels.stdout[2]).toBeGreaterThan(200); // blue centre survives aspect-ratio fitting const second = await importRecording(result.path, added); expect(second.sourceStartMs).toBeCloseTo(2000, -1); - expect(second.webcam?.visibleRanges).toEqual([{ startMs: 1200, endMs: 2000 }, { startMs: 2200, endMs: 3000 }]); + expect(second.webcam?.visibleRanges).toEqual([ + { startMs: 1200, endMs: 2000 }, + { startMs: 2200, endMs: 3000 }, + ]); expect(await fs.readFile(base)).toEqual(original); expect((await listRecordings()).map((entry) => entry.path).sort()).toEqual( [base, added].sort(), @@ -185,7 +247,6 @@ it("imports different-sized recordings with playable video, separate audio, stab ); }, 60000); - it("restores every original when the OS refuses to trash the bundle", async () => { const { shell } = await import("electron"); const file = path.join(state.root, "recording-failure.mp4"); @@ -203,3 +264,36 @@ it("undo never overwrites a new file at the original location", async () => { await expect(setRecordingsRemoved([file], false)).rejects.toThrow("already exists"); expect(await fs.readFile(file, "utf8")).toBe("new recording"); }); + +it("cancels an active import and removes partial outputs without changing originals", async () => { + const base = path.join(state.root, "recording-base.mp4"); + const added = path.join(state.root, "recording-added.mp4"); + await run(ffmpeg, [ + "-v", + "error", + "-f", + "lavfi", + "-i", + "color=c=red:s=640x480:r=30:d=10", + "-c:v", + "libx264", + "-pix_fmt", + "yuv420p", + base, + ]); + await fs.copyFile(base, added); + state.approved.add(base); + const original = await fs.readFile(base); + const controller = new AbortController(); + const importing = importRecording(base, added, undefined, controller.signal); + const rejected = expect(importing).rejects.toMatchObject({ name: "AbortError" }); + await vi.waitFor(async () => { + const files = await fs.readdir(path.join(state.root, ".recordly-media")); + expect(files.some((name) => name.startsWith("import-"))).toBe(true); + }); + controller.abort(); + await rejected; + expect(await fs.readdir(path.join(state.root, ".recordly-media"))).toEqual([]); + expect(await fs.readFile(base)).toEqual(original); + expect(await fs.readFile(added)).toEqual(original); +}); diff --git a/electron/ipc/recording/sequenceWebcam.ts b/electron/ipc/recording/sequenceWebcam.ts index 81bb1835..a6ed5c9c 100644 --- a/electron/ipc/recording/sequenceWebcam.ts +++ b/electron/ipc/recording/sequenceWebcam.ts @@ -30,11 +30,11 @@ async function linked(video: string): Promise } return { sourcePath: webcamPath, timeOffsetMs: session.timeOffsetMs ?? 0, visibleRanges }; } -async function ffmpeg(args: string[]) { +async function ffmpeg(args: string[], signal?: AbortSignal) { await run( getFfmpegBinaryPath(), ["-hide_banner", "-loglevel", "error", "-nostdin", "-y", ...args], - { timeout: 60 * 60 * 1000, maxBuffer: 1024 * 1024 }, + { signal, timeout: 60 * 60 * 1000, maxBuffer: 1024 * 1024 }, ); } @@ -47,6 +47,7 @@ export async function composeSequenceWebcam( baseDurationMs: number, addedDurationMs: number, currentWebcam?: RecordingWebcamSource, + signal?: AbortSignal, ) { const base = currentWebcam === undefined ? await linked(current) : currentWebcam; if (currentWebcam?.sourcePath) { @@ -57,7 +58,7 @@ export async function composeSequenceWebcam( const next = await linked(added); const firstPath = base?.sourcePath || next?.sourcePath; if (!firstPath) return undefined; - const meta = await probeNativeVideoMetadata(getFfmpegBinaryPath(), firstPath); + const meta = await probeNativeVideoMetadata(getFfmpegBinaryPath(), firstPath, signal); const width = Math.ceil(meta.width / 2) * 2; const height = Math.ceil(meta.height / 2) * 2; const ranges: Range[] = []; @@ -69,7 +70,11 @@ export async function composeSequenceWebcam( const args: string[] = []; let filter = `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30,format=yuv420p`; if (source?.sourcePath) { - const info = await probeNativeVideoMetadata(getFfmpegBinaryPath(), source.sourcePath); + const info = await probeNativeVideoMetadata( + getFfmpegBinaryPath(), + source.sourcePath, + signal, + ); const delayMs = Number.isFinite(source.timeOffsetMs) ? source.timeOffsetMs : 0; args.push("-i", source.sourcePath); filter = `trim=start=${Math.max(0, -delayMs) / 1000},setpts=PTS-STARTPTS,${filter},tpad=start_mode=add:start_duration=${Math.max(0, delayMs) / 1000}:stop_mode=add:stop_duration=${duration},trim=duration=${duration}`; @@ -89,41 +94,47 @@ export async function composeSequenceWebcam( } else { args.push("-f", "lavfi", "-i", `color=c=black:s=${width}x${height}:r=30:d=${duration}`); } - await ffmpeg([ - ...args, - "-an", - "-vf", - filter, - "-t", - String(duration), - "-c:v", - "libx264", - "-preset", - "fast", - "-crf", - "18", - path.join(work, `webcam-${index}.mp4`), - ]); + await ffmpeg( + [ + ...args, + "-an", + "-vf", + filter, + "-t", + String(duration), + "-c:v", + "libx264", + "-preset", + "fast", + "-crf", + "18", + path.join(work, `webcam-${index}.mp4`), + ], + signal, + ); } await fs.writeFile( path.join(work, "webcam-list.txt"), "file 'webcam-0.mp4'\nfile 'webcam-1.mp4'\n", ); const webcamPath = sequenceWebcamOutputs(output)[0]; - await ffmpeg([ - "-f", - "concat", - "-safe", - "1", - "-i", - path.join(work, "webcam-list.txt"), - "-an", - "-c:v", - "copy", - "-movflags", - "+faststart", - webcamPath, - ]); + await ffmpeg( + [ + "-f", + "concat", + "-safe", + "1", + "-i", + path.join(work, "webcam-list.txt"), + "-an", + "-c:v", + "copy", + "-movflags", + "+faststart", + webcamPath, + ], + signal, + ); await persistRecordingSessionManifest({ videoPath: output, webcamPath, timeOffsetMs: 0 }); await fs.writeFile(rangesPath(output), JSON.stringify(ranges)); await rememberApprovedLocalReadPath(webcamPath); diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index d108ae72..52b85b64 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -214,9 +214,17 @@ async function ensureNamedProjectSaveDoesNotOverwriteDifferentProject( } export function registerProjectHandlers() { + const imports = new Map(); + ipcMain.handle("cancel-recording-import", (event) => { + imports.get(event.sender.id)?.abort(); + return { success: true }; + }); ipcMain.handle("get-recording-thumbnail", async (_, file: string) => { - try { return { success: true, value: await getRecordingThumbnail(file) }; } - catch (error) { return { success: false, error: String(error) }; } + try { + return { success: true, value: await getRecordingThumbnail(file) }; + } catch (error) { + return { success: false, error: String(error) }; + } }); ipcMain.handle("list-recordings", async () => { try { @@ -233,13 +241,36 @@ export function registerProjectHandlers() { return { success: false, error: String(error) }; } }); - ipcMain.handle("import-recording", async (_, currentPath: string, recordingPath: string, webcam?: import("../../../src/types/recordingLibrary").RecordingWebcamSource) => { - try { - return { success: true, value: await importRecording(currentPath, recordingPath, webcam) }; - } catch (error) { - return { success: false, error: String(error) }; - } - }); + ipcMain.handle( + "import-recording", + async ( + event, + currentPath: string, + recordingPath: string, + webcam?: import("../../../src/types/recordingLibrary").RecordingWebcamSource, + ) => { + const owner = event.sender.id; + if (imports.has(owner)) + return { success: false, error: "An import is already running" }; + const controller = new AbortController(); + imports.set(owner, controller); + try { + return { + success: true, + value: await importRecording( + currentPath, + recordingPath, + webcam, + controller.signal, + ), + }; + } catch (error) { + return { success: false, error: String(error) }; + } finally { + imports.delete(owner); + } + }, + ); ipcMain.handle("reveal-in-folder", async (_, filePath: string) => { try { // shell.showItemInFolder doesn't return a value, it throws on error diff --git a/electron/preload.ts b/electron/preload.ts index 684b034c..1cf6c5dc 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -788,6 +788,7 @@ contextBridge.exposeInMainWorld("electronAPI", { return ipcRenderer.invoke("clear-current-video-path"); }, getRecordingThumbnail: (filePath: string) => ipcRenderer.invoke("get-recording-thumbnail", filePath), + cancelRecordingImport: () => ipcRenderer.invoke("cancel-recording-import"), listRecordings: () => ipcRenderer.invoke("list-recordings"), setRecordingsRemoved: (paths: string[], removed: boolean) => ipcRenderer.invoke("set-recordings-removed", paths, removed), diff --git a/src/components/video-editor/layout/EditorShell.tsx b/src/components/video-editor/layout/EditorShell.tsx index 55654adf..ed1ad899 100644 --- a/src/components/video-editor/layout/EditorShell.tsx +++ b/src/components/video-editor/layout/EditorShell.tsx @@ -158,7 +158,7 @@ export function EditorShell(props: Props) { setNativeCaptureUnavailableModalOpen={ui.setNativeCaptureUnavailableModalOpen} /> ); - if (project.loading) + if (project.loading && !project.error) return ( <> @@ -179,7 +179,9 @@ export function EditorShell(props: Props) { return (
-
{project.error}
+
+ {project.error} +
)} diff --git a/src/components/video-editor/library/useRecordingLibrary.ts b/src/components/video-editor/library/useRecordingLibrary.ts index 6896a9c2..146a9aee 100644 --- a/src/components/video-editor/library/useRecordingLibrary.ts +++ b/src/components/video-editor/library/useRecordingLibrary.ts @@ -90,10 +90,23 @@ export function useRecordingLibrary( } }; const [importing, setImporting] = useState(false); + const cancelled = useRef(false); + const [cancelling, setCancelling] = useState(false); + const cancelImport = async () => { + cancelled.current = true; + setCancelling(true); + try { + await window.electronAPI.cancelRecordingImport(); + } catch (error) { + toast.error(`Could not stop import processing: ${String(error)}`); + } + }; const addToTimeline = async (paths: string | string[], index?: number) => { const source = current.current.project.videoSourcePath; if (lock.current || !source) return; lock.current = true; + cancelled.current = false; + setCancelling(false); setImporting(true); current.current.ui.videoPlaybackRef.current?.pause(); current.current.ui.setIsPlaying(false); @@ -108,6 +121,7 @@ export function useRecordingLibrary( const addedZooms: ZoomRegion[] = []; let id = ""; for (const path of [...new Set(typeof paths === "string" ? [paths] : paths)]) { + if (cancelled.current) return; const result = await window.electronAPI.importRecording(sourcePath, path, webcam); if (!result.success) throw new Error(result.error); if (current.current.project.videoSourcePath !== source) @@ -160,7 +174,7 @@ export function useRecordingLibrary( ); } } - if (!media) return; + if (!media || cancelled.current) return; const { project, timeline, ui, appearance } = current.current; if (project.videoSourcePath !== source) throw new Error("The project changed while importing. Add the recordings again."); @@ -193,10 +207,11 @@ export function useRecordingLibrary( : `${paths.length} videos added to timeline`, ); } catch (error) { - toast.error(`Could not add video: ${String(error)}`); + if (!cancelled.current) toast.error(`Could not add video: ${String(error)}`); } finally { lock.current = false; setImporting(false); + setCancelling(false); } }; return { @@ -213,6 +228,8 @@ export function useRecordingLibrary( undo, canUndo: removed.length > 0, importing, + cancelling, + cancelImport, addToTimeline, }; }