Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 };
Expand Down
54 changes: 54 additions & 0 deletions electron/recording/recoverRecording.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
144 changes: 144 additions & 0 deletions electron/recording/recoverRecording.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
}
8 changes: 8 additions & 0 deletions src/components/video-editor/EditorEmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,14 @@ export function EditorEmptyState({
<span className="min-w-0 flex-1 truncate text-xs text-slate-300">
{new Date(recording.createdAt).toLocaleString()}
</span>
{recording.recovered && (
<span
className="flex-shrink-0 rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] text-amber-300"
title={te("emptyState.recentRecordings.recoveredBadgeHint")}
>
{te("emptyState.recentRecordings.recoveredBadge")}
</span>
)}
{recording.webcamVideoPath && (
<span
className="flex items-center gap-1 rounded bg-[#6366f1]/15 px-1.5 py-0.5 text-[10px] text-[#a5b4fc]"
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/ar/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@
"recentRecordings": {
"openFailed": "Could not open this recording. Its files may have been moved or deleted.",
"title": "Recent recordings",
"webcamBadge": "Includes webcam"
"webcamBadge": "Includes webcam",
"recoveredBadge": "Recovered",
"recoveredBadgeHint": "This recording was recovered after an interrupted session; its duration was repaired on open."
}
}
}
4 changes: 3 additions & 1 deletion src/i18n/locales/en/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@
"recentRecordings": {
"title": "Recent recordings",
"webcamBadge": "Includes webcam",
"openFailed": "Could not open this recording. Its files may have been moved or deleted."
"openFailed": "Could not open this recording. Its files may have been moved or deleted.",
"recoveredBadge": "Recovered",
"recoveredBadgeHint": "This recording was recovered after an interrupted session; its duration was repaired on open."
}
}
}
4 changes: 3 additions & 1 deletion src/i18n/locales/es/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@
"recentRecordings": {
"openFailed": "Could not open this recording. Its files may have been moved or deleted.",
"title": "Recent recordings",
"webcamBadge": "Includes webcam"
"webcamBadge": "Includes webcam",
"recoveredBadge": "Recovered",
"recoveredBadgeHint": "This recording was recovered after an interrupted session; its duration was repaired on open."
}
}
}
4 changes: 3 additions & 1 deletion src/i18n/locales/fr/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@
"recentRecordings": {
"openFailed": "Could not open this recording. Its files may have been moved or deleted.",
"title": "Recent recordings",
"webcamBadge": "Includes webcam"
"webcamBadge": "Includes webcam",
"recoveredBadge": "Recovered",
"recoveredBadgeHint": "This recording was recovered after an interrupted session; its duration was repaired on open."
}
}
}
4 changes: 3 additions & 1 deletion src/i18n/locales/it/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@
"recentRecordings": {
"openFailed": "Could not open this recording. Its files may have been moved or deleted.",
"title": "Recent recordings",
"webcamBadge": "Includes webcam"
"webcamBadge": "Includes webcam",
"recoveredBadge": "Recovered",
"recoveredBadgeHint": "This recording was recovered after an interrupted session; its duration was repaired on open."
}
}
}
4 changes: 3 additions & 1 deletion src/i18n/locales/ja-JP/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@
"recentRecordings": {
"openFailed": "Could not open this recording. Its files may have been moved or deleted.",
"title": "Recent recordings",
"webcamBadge": "Includes webcam"
"webcamBadge": "Includes webcam",
"recoveredBadge": "Recovered",
"recoveredBadgeHint": "This recording was recovered after an interrupted session; its duration was repaired on open."
}
}
}
4 changes: 3 additions & 1 deletion src/i18n/locales/ko-KR/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@
"recentRecordings": {
"openFailed": "Could not open this recording. Its files may have been moved or deleted.",
"title": "Recent recordings",
"webcamBadge": "Includes webcam"
"webcamBadge": "Includes webcam",
"recoveredBadge": "Recovered",
"recoveredBadgeHint": "This recording was recovered after an interrupted session; its duration was repaired on open."
}
}
}
4 changes: 3 additions & 1 deletion src/i18n/locales/pt-BR/editor.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@
"recentRecordings": {
"openFailed": "Could not open this recording. Its files may have been moved or deleted.",
"title": "Recent recordings",
"webcamBadge": "Includes webcam"
"webcamBadge": "Includes webcam",
"recoveredBadge": "Recovered",
"recoveredBadgeHint": "This recording was recovered after an interrupted session; its duration was repaired on open."
}
}
}
Loading
Loading