From 4efbe20af8e083f22752b4fae89c8e0865edfeb7 Mon Sep 17 00:00:00 2001 From: joao Date: Thu, 11 Jun 2026 18:09:56 -0300 Subject: [PATCH] feat(recording): recover crash-orphaned recordings into Recent recordings Streaming-to-disk protects the bytes of an interrupted recording, but the take stayed invisible: the .session.json manifest is only written on stop, so a crashed recording never appears anywhere, and its WebM lacks the Duration header (patched only at finalize), breaking seeking. - list-recording-sessions adopts orphans: any recording-.webm/.mp4 with no manifest and no active writer (stream registry, native capture targets, current session) gets a synthesized manifest with recovered: true; webcam sidecar attached when present on disk. - open-recording-session repairs the WebM Duration header once on first open, then drops the recovered flag so later opens skip the full-file read. Estimation parses the raw EBML tail (last Cluster Timecode + largest SimpleBlock offset) since the fix-webm-duration parser deliberately skips Cluster internals; handles unknown-size clusters from streamed MediaRecorder output. Unit-tested. - Recovered takes show an amber badge in Recent recordings. Co-Authored-By: Claude Fable 5 --- electron/ipc/handlers.ts | 71 +++++++++ electron/recording/recoverRecording.test.ts | 54 +++++++ electron/recording/recoverRecording.ts | 144 ++++++++++++++++++ .../video-editor/EditorEmptyState.tsx | 8 + src/i18n/locales/ar/editor.json | 4 +- src/i18n/locales/en/editor.json | 4 +- src/i18n/locales/es/editor.json | 4 +- src/i18n/locales/fr/editor.json | 4 +- src/i18n/locales/it/editor.json | 4 +- src/i18n/locales/ja-JP/editor.json | 4 +- src/i18n/locales/ko-KR/editor.json | 4 +- src/i18n/locales/pt-BR/editor.json | 4 +- src/i18n/locales/ru/editor.json | 4 +- src/i18n/locales/tr/editor.json | 4 +- src/i18n/locales/vi/editor.json | 4 +- src/i18n/locales/zh-CN/editor.json | 4 +- src/i18n/locales/zh-TW/editor.json | 4 +- src/lib/recordingSession.ts | 3 + 18 files changed, 319 insertions(+), 13 deletions(-) create mode 100644 electron/recording/recoverRecording.test.ts create mode 100644 electron/recording/recoverRecording.ts diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 35cd8bcd5..6d6b14d96 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -40,6 +40,7 @@ import { RECORDINGS_DIR } from "../main"; import { createCursorRecordingSession } from "../native-bridge/cursor/recording/factory"; import { requestMacCursorAccessibilityAccess } from "../native-bridge/cursor/recording/macNativeCursorRecordingSession"; import type { CursorRecordingSession } from "../native-bridge/cursor/recording/session"; +import { repairWebmDurationOnDisk } from "../recording/recoverRecording"; import { patchWebmDurationOnDisk } from "../recording/webm-duration"; import { registerNativeBridgeHandlers } from "./nativeBridge"; import { RecordingStreamRegistry, registerRecordingStreamHandlers } from "./recordingStream"; @@ -2365,6 +2366,57 @@ export function registerIpcHandlers( // Malformed manifest — skip it. } } + // Adopt crash-orphaned recordings: a video on disk whose writer never + // finalized has no manifest (it's written on stop), so it would be + // invisible here even though the streamed bytes survived. Synthesize a + // manifest for any recording file that has none and isn't being written + // right now; the duration header is repaired lazily on first open. + const manifestNames = new Set(manifests); + const videoFiles = files.filter((file) => /^recording-\d+\.(webm|mp4)$/.test(file)); + for (const name of videoFiles) { + const manifestName = name.replace(/\.(webm|mp4)$/, RECORDING_SESSION_SUFFIX); + if (manifestNames.has(manifestName)) continue; + if (recordingStreams.has(name)) continue; + const screenVideoPath = path.join(RECORDINGS_DIR, name); + if ( + screenVideoPath === nativeWindowsCaptureTargetPath || + screenVideoPath === nativeMacCaptureTargetPath || + currentRecordingSession?.screenVideoPath === screenVideoPath + ) { + continue; + } + const stat = await fs.stat(screenVideoPath).catch(() => null); + if (!stat || stat.size === 0) continue; + + const webcamName = name.replace(/\.(webm|mp4)$/, "-webcam.webm"); + const webcamPath = path.join(RECORDINGS_DIR, webcamName); + const hasWebcam = + !recordingStreams.has(webcamName) && + (await fs + .access(webcamPath, fsConstants.R_OK) + .then(() => true) + .catch(() => false)); + + const idMatch = /^recording-(\d+)\./.exec(name); + const createdAt = idMatch ? Number(idMatch[1]) : Math.round(stat.mtimeMs); + const session: RecordingSession = { + screenVideoPath, + ...(hasWebcam ? { webcamVideoPath: webcamPath } : {}), + createdAt, + recovered: true, + }; + try { + await fs.writeFile( + path.join(RECORDINGS_DIR, manifestName), + JSON.stringify(session, null, 2), + "utf-8", + ); + } catch { + continue; + } + sessions.push({ ...session, sizeBytes: stat.size }); + } + sessions.sort((a, b) => b.createdAt - a.createdAt); return { success: true, sessions }; } catch (error) { @@ -2393,6 +2445,25 @@ export function registerIpcHandlers( session.webcamVideoPath = undefined; } } + if (session.recovered) { + // Crash-orphaned files miss the WebM Duration header (patched only at + // finalize). Repair once, then drop the flag so later opens skip the + // full-file read. Best-effort: an unrepairable file still opens. + if (session.screenVideoPath.endsWith(".webm")) { + await repairWebmDurationOnDisk(session.screenVideoPath); + } + if (session.webcamVideoPath?.endsWith(".webm")) { + await repairWebmDurationOnDisk(session.webcamVideoPath); + } + session.recovered = undefined; + const manifestPath = path.join( + RECORDINGS_DIR, + `${path.parse(session.screenVideoPath).name}${RECORDING_SESSION_SUFFIX}`, + ); + await fs + .writeFile(manifestPath, JSON.stringify(session, null, 2), "utf-8") + .catch(() => undefined); + } setCurrentRecordingSessionState(session); currentProjectPath = null; return { success: true, session }; diff --git a/electron/recording/recoverRecording.test.ts b/electron/recording/recoverRecording.test.ts new file mode 100644 index 000000000..fcc92c35c --- /dev/null +++ b/electron/recording/recoverRecording.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { estimateWebmDurationMs } from "./recoverRecording"; + +/** Builds an EBML element: 1-byte id, 1-byte size, payload. */ +function el(id: number, payload: number[]): number[] { + return [id, 0x80 | payload.length, ...payload]; +} + +/** Builds a Cluster with the given absolute timecode and SimpleBlock relative offsets. */ +function cluster(timecodeMs: number, blockRelMs: number[], unknownSize = false): number[] { + const children = [ + ...el(0xe7, [(timecodeMs >> 8) & 0xff, timecodeMs & 0xff]), + ...blockRelMs.flatMap((rel) => + // SimpleBlock payload: track vint (0x81), int16BE relative time, flags, fake frame data + el(0xa3, [0x81, (rel >> 8) & 0xff, rel & 0xff, 0x80, 0xde, 0xad]), + ), + ]; + const sizeBytes = unknownSize + ? [0xff] // 1-byte unknown-size sentinel + : [0x80 | children.length]; + return [0x1f, 0x43, 0xb6, 0x75, ...sizeBytes, ...children]; +} + +describe("estimateWebmDurationMs", () => { + it("returns last cluster timecode plus the largest block offset", () => { + const buf = Buffer.from([ + ...cluster(0, [100, 500, 900]), + ...cluster(1000, [200, 800]), + ...cluster(2000, [350]), + ]); + expect(estimateWebmDurationMs(buf)).toBe(2350); + }); + + it("handles unknown-size clusters from streamed MediaRecorder output", () => { + const buf = Buffer.from([...cluster(0, [500], true), ...cluster(3000, [250, 750], true)]); + expect(estimateWebmDurationMs(buf)).toBe(3750); + }); + + it("falls back to an earlier cluster when trailing bytes mimic a cluster id", () => { + const buf = Buffer.from([ + ...cluster(5000, [400]), + // Truncated garbage that contains the cluster ID pattern but no parsable children. + 0x1f, + 0x43, + 0xb6, + 0x75, + ]); + expect(estimateWebmDurationMs(buf)).toBe(5400); + }); + + it("returns null when no cluster exists", () => { + expect(estimateWebmDurationMs(Buffer.from([0x1a, 0x45, 0xdf, 0xa3, 0x80]))).toBeNull(); + }); +}); diff --git a/electron/recording/recoverRecording.ts b/electron/recording/recoverRecording.ts new file mode 100644 index 000000000..debebebbe --- /dev/null +++ b/electron/recording/recoverRecording.ts @@ -0,0 +1,144 @@ +import fs from "node:fs/promises"; +import { patchWebmDurationOnDisk } from "./webm-duration"; + +/** + * Recovery for recordings whose writer never finalized (renderer crash, power + * loss): the streamed bytes are on disk, but the WebM has no Duration header, + * so the editor's seek bar and timeline get `Infinity`/`N/A`. + * + * The duration is estimated by scanning the raw EBML tail: the last Cluster's + * absolute Timecode plus the largest SimpleBlock relative timestamp inside it. + * MediaRecorder writes 1 ms timecode scale and ~1 s clusters, so the estimate + * is within one block of the true duration. @fix-webm-duration/parser can't do + * this walk — it deliberately skips Cluster internals (`sections.js` comments + * the Cluster ID out for performance). + */ + +const CLUSTER_ID = Buffer.from([0x1f, 0x43, 0xb6, 0x75]); +const TIMECODE_ID = 0xe7; +const SIMPLE_BLOCK_ID = 0xa3; +const BLOCK_GROUP_ID = 0xa0; + +/** Reads an EBML vint at `pos`; returns its value and width, or null at EOF/invalid. */ +function readVint( + buf: Buffer, + pos: number, + keepMarker: boolean, +): { value: number; width: number } | null { + if (pos >= buf.length) return null; + const first = buf[pos]; + if (first === 0) return null; + let width = 1; + for (let mask = 0x80; !(first & mask); mask >>= 1) { + width++; + if (width > 8) return null; + } + if (pos + width > buf.length) return null; + let value = keepMarker ? first : first & (0xff >> width); + for (let i = 1; i < width; i++) { + value = value * 256 + buf[pos + i]; + } + return { value, width }; +} + +/** True when an element size vint is the EBML "unknown size" sentinel (all value bits set). */ +function isUnknownSize(size: { value: number; width: number }): boolean { + // 1-byte 0xFF → value 127, 2-byte 0x01FF… etc. All value bits set. + return size.value === 2 ** (7 * size.width) - 1; +} + +/** + * Walks the children of the cluster starting at `clusterPayloadStart`, returning the + * cluster's absolute Timecode plus the largest SimpleBlock relative timestamp, or + * null when the bytes don't parse as a cluster (e.g. a false-positive ID match + * inside media payload). + */ +function parseClusterDurationMs(buf: Buffer, clusterPayloadStart: number, end: number) { + let pos = clusterPayloadStart; + let timecode: number | null = null; + let maxRelative = 0; + + while (pos < end) { + const id = readVint(buf, pos, true); + if (!id) break; + pos += id.width; + const size = readVint(buf, pos, false); + if (!size) break; + pos += size.width; + const payloadEnd = isUnknownSize(size) ? end : pos + size.value; + if (payloadEnd > buf.length) break; + + if (id.value === TIMECODE_ID) { + let v = 0; + for (let i = pos; i < payloadEnd; i++) v = v * 256 + buf[i]; + timecode = v; + } else if (id.value === SIMPLE_BLOCK_ID || id.value === BLOCK_GROUP_ID) { + // SimpleBlock payload: track vint, then int16BE relative timestamp. + // BlockGroup nests a Block with the same prefix — close enough to scan the + // first bytes the same way for a tail estimate. + const track = readVint(buf, pos, false); + if (track && pos + track.width + 2 <= payloadEnd) { + const rel = buf.readInt16BE(pos + track.width); + if (rel > maxRelative) maxRelative = rel; + } + } else if (timecode !== null) { + // Unknown element after the timecode — likely we ran into the next + // top-level element of an unknown-size cluster. Stop here. + break; + } + + pos = payloadEnd; + } + + return timecode === null ? null : timecode + maxRelative; +} + +/** + * Estimates the playable duration of a (possibly truncated) WebM by parsing its + * tail clusters. Returns null when no cluster parses — a file too damaged to + * estimate, which the caller should surface as-is rather than mis-patch. + */ +export function estimateWebmDurationMs(buf: Buffer): number | null { + // Walk cluster ID matches from the end; payload bytes can contain the ID + // pattern by chance, so fall back to earlier matches until one parses. + let searchEnd = buf.length; + for (let attempt = 0; attempt < 8; attempt++) { + const idx = buf.lastIndexOf(CLUSTER_ID, searchEnd - 1); + if (idx < 0) return null; + const size = readVint(buf, idx + CLUSTER_ID.length, false); + if (size) { + const payloadStart = idx + CLUSTER_ID.length + size.width; + const end = isUnknownSize(size) + ? buf.length + : Math.min(buf.length, payloadStart + size.value); + const duration = parseClusterDurationMs(buf, payloadStart, end); + if (duration !== null && duration > 0) return duration; + } + searchEnd = idx; + } + return null; +} + +/** + * One-shot duration repair for a crash-orphaned WebM: estimate from the cluster + * tail, then patch the header on disk. Best-effort — failures leave the file + * untouched (it still plays; only seeking misbehaves). + */ +export async function repairWebmDurationOnDisk( + filePath: string, +): Promise<{ repaired: boolean; durationMs?: number }> { + try { + const bytes = await fs.readFile(filePath); + const durationMs = estimateWebmDurationMs(bytes); + if (!durationMs) { + console.warn(`[recover-recording] could not estimate duration for ${filePath}`); + return { repaired: false }; + } + const result = await patchWebmDurationOnDisk(filePath, durationMs); + // "already-valid" means a previous repair (or clean finalize) got there first. + return { repaired: result.patched, durationMs }; + } catch (error) { + console.error(`[recover-recording] failed to repair ${filePath}:`, error); + return { repaired: false }; + } +} diff --git a/src/components/video-editor/EditorEmptyState.tsx b/src/components/video-editor/EditorEmptyState.tsx index a85f6e285..626da7657 100644 --- a/src/components/video-editor/EditorEmptyState.tsx +++ b/src/components/video-editor/EditorEmptyState.tsx @@ -261,6 +261,14 @@ export function EditorEmptyState({ {new Date(recording.createdAt).toLocaleString()} + {recording.recovered && ( + + {te("emptyState.recentRecordings.recoveredBadge")} + + )} {recording.webcamVideoPath && (