mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-25 23:35:43 +00:00
feat(import): add cancellable media processing with shared Cancel styling
This commit is contained in:
Vendored
+1
@@ -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[]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user