diff --git a/electron/ipc/captions/segment.test.ts b/electron/ipc/captions/segment.test.ts index e6ebaf00..57d86dab 100644 --- a/electron/ipc/captions/segment.test.ts +++ b/electron/ipc/captions/segment.test.ts @@ -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); +}); diff --git a/electron/ipc/captions/segment.ts b/electron/ipc/captions/segment.ts index b5528447..f1c889a7 100644 --- a/electron/ipc/captions/segment.ts +++ b/electron/ipc/captions/segment.ts @@ -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)); } diff --git a/electron/ipc/recording/library.test.ts b/electron/ipc/recording/library.test.ts index 5c6560d2..e8dbde70 100644 --- a/electron/ipc/recording/library.test.ts +++ b/electron/ipc/recording/library.test.ts @@ -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(); + } +}); diff --git a/electron/ipc/recording/library.ts b/electron/ipc/recording/library.ts index 0d3e94c1..8d945248 100644 --- a/electron/ipc/recording/library.ts +++ b/electron/ipc/recording/library.ts @@ -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) { diff --git a/electron/ipc/register/project.ts b/electron/ipc/register/project.ts index f4864c0c..5430e154 100644 --- a/electron/ipc/register/project.ts +++ b/electron/ipc/register/project.ts @@ -216,13 +216,28 @@ async function ensureNamedProjectSaveDoesNotOverwriteDifferentProject( export function registerProjectHandlers() { const imports = new Map(); const pendingImports = new Map>(); + const watchedImportSenders = new WeakSet(); + 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(); outputs.add(value.path); pendingImports.set(owner, outputs); diff --git a/src/components/video-editor/library/useRecordingLibrary.ts b/src/components/video-editor/library/useRecordingLibrary.ts index 7b439ab6..78d9bccd 100644 --- a/src/components/video-editor/library/useRecordingLibrary.ts +++ b/src/components/video-editor/library/useRecordingLibrary.ts @@ -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; diff --git a/src/lib/exporter/localMediaSource.test.ts b/src/lib/exporter/localMediaSource.test.ts index 2d850c04..c16d78aa 100644 --- a/src/lib/exporter/localMediaSource.test.ts +++ b/src/lib/exporter/localMediaSource.test.ts @@ -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"); diff --git a/src/lib/exporter/localMediaSource.ts b/src/lib/exporter/localMediaSource.ts index ead9fb24..04b310ab 100644 --- a/src/lib/exporter/localMediaSource.ts +++ b/src/lib/exporter/localMediaSource.ts @@ -87,11 +87,13 @@ export async function resolveMediaResourceUrl(resource: string): Promise 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 {