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 && (