mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-24 14:55:37 +00:00
fix: preserve caption timing and media lifecycle across failures
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user