fix: preserve caption timing and media lifecycle across failures

This commit is contained in:
webadderall
2026-09-21 20:33:13 +10:00
parent fc3c69c2a9
commit f43d137ef5
8 changed files with 103 additions and 6 deletions
+20
View File
@@ -369,3 +369,23 @@ describe("segmentCuesIntoPhrases", () => {
expect(segmentCuesIntoPhrases([], [])).toEqual([]);
});
});
it("keeps the last timed word when an adjacent untimed cue overlaps padded edges", () => {
const result = segmentCuesIntoPhrases(
[
{
id: "timed",
startMs: 0,
endMs: 1200,
text: "Hello.",
words: [{ text: "Hello.", startMs: 0, endMs: 1000 }],
},
{ id: "untimed", startMs: 950, endMs: 2000, text: "Next sentence." },
],
[],
{ minCaptionMs: 0, edgePadMs: 100 },
);
const timed = result.find((cue) => cue.words?.length)!;
expect(timed.endMs).toBeGreaterThanOrEqual(1000);
expect(result.some((cue) => cue.text === "Next sentence.")).toBe(true);
});
+7 -2
View File
@@ -351,7 +351,7 @@ function renumberCues(cues: CaptionCuePayload[]): CaptionCuePayload[] {
/**
* Re-segment Whisper cues into one caption per sentence/phrase, then merge rapid-fire short
* sentences back together. Returns sorted, non-overlapping cues with fresh ids. Falls back to
* sentences back together. Returns sorted cues with fresh ids, preserving overlaps required by word timing. Falls back to
* silence-only re-segmentation (plus sentence splitting) when the transcript has no word timings.
*/
export function segmentCuesIntoPhrases(
@@ -380,7 +380,12 @@ export function segmentCuesIntoPhrases(
const segmented = cues.flatMap((cue) => segmentCuesIntoPhrases([cue], silences, options));
segmented.sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
for (let index = 0; index < segmented.length - 1; index++) {
segmented[index].endMs = Math.min(segmented[index].endMs, segmented[index + 1].startMs);
const cue = segmented[index];
const lastWordEnd = Math.max(
cue.startMs,
...(cue.words ?? []).map((word) => word.endMs),
);
cue.endMs = Math.max(lastWordEnd, Math.min(cue.endMs, segmented[index + 1].startMs));
}
return renumberCues(segmented.filter((cue) => cue.endMs > cue.startMs));
}
+15
View File
@@ -312,3 +312,18 @@ it("cancels an active import and removes partial outputs without changing origin
expect(await fs.readFile(base)).toEqual(original);
expect(await fs.readFile(added)).toEqual(original);
});
it("restores on volumes without hard links and preserves conflicts", async () => {
const file = path.join(state.root, "recording-exfat.mp4");
await fs.writeFile(file, "recording");
await setRecordingsRemoved([file], true);
const link = vi
.spyOn(fs, "link")
.mockRejectedValue(Object.assign(new Error("unsupported"), { code: "ENOTSUP" }));
try {
await setRecordingsRemoved([file], false);
expect(await fs.readFile(file, "utf8")).toBe("recording");
} finally {
link.mockRestore();
}
});
+13 -1
View File
@@ -1,4 +1,5 @@
import fs from "node:fs/promises";
import { constants } from "node:fs";
import path from "node:path";
import { shell } from "electron";
import { buildMediaUrl, getMediaServerBaseUrl } from "../../mediaServer";
@@ -109,7 +110,18 @@ export function setRecordingsRemoved(paths: string[], removed: boolean): Promise
const restored: string[] = [];
try {
for (const file of batch.files) {
await fs.link(path.join(batch.bundle, path.basename(file)), file);
const staged = path.join(batch.bundle, path.basename(file));
try {
await fs.link(staged, file);
} catch (error) {
if (
!["EPERM", "ENOTSUP", "EOPNOTSUPP", "EXDEV"].includes(
(error as NodeJS.ErrnoException).code ?? "",
)
)
throw error;
await fs.copyFile(staged, file, constants.COPYFILE_EXCL);
}
restored.push(file);
}
} catch (error) {
+28 -1
View File
@@ -216,13 +216,28 @@ async function ensureNamedProjectSaveDoesNotOverwriteDifferentProject(
export function registerProjectHandlers() {
const imports = new Map<number, AbortController>();
const pendingImports = new Map<number, Set<string>>();
const watchedImportSenders = new WeakSet<Electron.WebContents>();
const abandonImports = (owner: number) => {
imports.get(owner)?.abort();
const outputs = pendingImports.get(owner);
pendingImports.delete(owner);
void Promise.allSettled([...(outputs ?? [])].map(discardRecordingImport)).then(
(results) => {
for (const result of results)
if (result.status === "rejected")
console.warn("Could not discard abandoned import", result.reason);
},
);
};
ipcMain.handle("finish-recording-import", async (event, keepPath: string) => {
if (imports.has(event.sender.id))
return { success: false, error: "Import is still running" };
const outputs = pendingImports.get(event.sender.id);
// Transfer the final source before async cleanup can race renderer teardown.
outputs?.delete(keepPath);
try {
for (const output of outputs ?? []) {
if (output !== keepPath) await discardRecordingImport(output);
await discardRecordingImport(output);
outputs?.delete(output);
}
pendingImports.delete(event.sender.id);
@@ -266,6 +281,14 @@ export function registerProjectHandlers() {
webcam?: import("../../../src/types/recordingLibrary").RecordingWebcamSource,
) => {
const owner = event.sender.id;
if (!watchedImportSenders.has(event.sender)) {
watchedImportSenders.add(event.sender);
event.sender.once("destroyed", () => abandonImports(owner));
event.sender.on("render-process-gone", () => abandonImports(owner));
event.sender.on("did-start-navigation", (_event, _url, isInPlace, isMainFrame) => {
if (isMainFrame && !isInPlace) abandonImports(owner);
});
}
if (imports.has(owner))
return { success: false, error: "An import is already running" };
const controller = new AbortController();
@@ -277,6 +300,10 @@ export function registerProjectHandlers() {
webcam,
controller.signal,
);
if (controller.signal.aborted || event.sender.isDestroyed()) {
await discardRecordingImport(value.path);
throw new Error("Import cancelled because its editor closed or reloaded");
}
const outputs = pendingImports.get(owner) ?? new Set<string>();
outputs.add(value.path);
pendingImports.set(owner, outputs);
@@ -184,6 +184,12 @@ export function useRecordingLibrary(
const { project, timeline, ui, appearance } = current.current;
if (project.videoSourcePath !== source)
throw new Error("The project changed while importing. Add the recordings again.");
// Commit ownership before exposing the source to project saves or renderer teardown.
const committed = await window.electronAPI.finishRecordingImport(media.path);
if (!committed.success)
throw new Error(committed.error || "Could not finalize imported media");
if (current.current.project.videoSourcePath !== source)
throw new Error("The project changed while importing. Add the recordings again.");
ui.clipInitializedRef.current = true;
ui.autoFullTrackClipIdRef.current = null;
ui.autoFullTrackClipEndMsRef.current = null;
+10
View File
@@ -53,6 +53,16 @@ describe("resolveMediaElementSource", () => {
expect(result.src).toBe("http://127.0.0.1:4321/video?path=%2Ftmp%2Fexample%20clip.mp4");
});
it.each([
"failure",
"exception",
])("keeps the existing media URL after refresh %s", async (mode) => {
if (mode === "failure") getLocalMediaUrl.mockResolvedValueOnce({ success: false, url: "" });
else getLocalMediaUrl.mockRejectedValueOnce(new Error("Server unavailable"));
const resource = "http://127.0.0.1:43123/video?path=%2Ftmp%2Fexample.mp4";
expect((await resolveMediaElementSource(resource)).src).toBe(resource);
});
it("leaves remote URLs untouched", async () => {
const result = await resolveMediaElementSource("https://example.com/video.mp4");
+4 -2
View File
@@ -87,11 +87,13 @@ export async function resolveMediaResourceUrl(resource: string): Promise<string>
return result.url;
}
} catch {
// Fall through to a file URL when the local media server is unavailable.
// Preserve an existing media URL if refreshing the server URL fails.
}
}
return /^file:\/\//i.test(resource) ? resource : toFileUrl(localFilePath);
return /^file:\/\//i.test(resource) || isLocalMediaServerUrl(resource)
? resource
: toFileUrl(localFilePath);
}
async function createReadableMediaResourceFile(resource: string): Promise<File> {