feat(import): add cancellable media processing with shared Cancel styling

This commit is contained in:
webadderall
2026-09-20 21:29:53 +10:00
parent fb3d6b1ff6
commit 8f2ec69966
9 changed files with 301 additions and 117 deletions
+1
View File
@@ -753,6 +753,7 @@ interface Window {
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>;
clearCurrentVideoPath: () => Promise<{ success: boolean }>;
getRecordingThumbnail: (filePath: string) => Promise<import("../src/types/recordingLibrary").LibraryResult<string>>;
cancelRecordingImport: () => Promise<{ success: boolean }>;
listRecordings: () => Promise<
import("../src/types/recordingLibrary").LibraryResult<
import("../src/types/recordingLibrary").RecordingLibraryEntry[]
+3
View File
@@ -100,15 +100,18 @@ export function parseNativeVideoMetadataProbeOutput(
export async function probeNativeVideoMetadata(
ffmpegPath: string,
inputPath: string,
signal?: AbortSignal,
): Promise<NativeVideoMetadataProbe> {
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")
+70 -55
View File
@@ -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<RecordingImportResult> {
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<typeof normalizeCursorTelemetrySamples> = [];
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,
+110 -16
View File
@@ -9,10 +9,12 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({ root: "", approved: new Set<string>() }));
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);
});
+44 -33
View File
@@ -30,11 +30,11 @@ async function linked(video: string): Promise<RecordingWebcamSource | undefined>
}
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);
+40 -9
View File
@@ -214,9 +214,17 @@ async function ensureNamedProjectSaveDoesNotOverwriteDifferentProject(
}
export function registerProjectHandlers() {
const imports = new Map<number, AbortController>();
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
+1
View File
@@ -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),
@@ -158,7 +158,7 @@ export function EditorShell(props: Props) {
setNativeCaptureUnavailableModalOpen={ui.setNativeCaptureUnavailableModalOpen}
/>
);
if (project.loading)
if (project.loading && !project.error)
return (
<>
<EditorLoadingSkeleton />
@@ -179,7 +179,9 @@ export function EditorShell(props: Props) {
return (
<div className="flex h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-3">
<div className="text-destructive">{project.error}</div>
<div role="alert" className="max-w-xl break-words text-center text-destructive">
{project.error}
</div>
<Button
variant="ghost"
ref={ui.projectBrowserFallbackTriggerRef}
@@ -379,6 +381,15 @@ export function EditorShell(props: Props) {
<p className="mt-2 text-sm text-muted-foreground">
Preparing footage and audio for your timeline.
</p>
<Button
className="mt-4"
variant="ghost"
size="sm"
disabled={library.cancelling}
onClick={() => void library.cancelImport()}
>
{t("common.actions.cancel", "Cancel")}
</Button>
</div>
</div>
)}
@@ -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,
};
}