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
132 changes: 132 additions & 0 deletions electron/recording/webm-duration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { WebmBase, WebmContainer, WebmFile, WebmString, WebmUint } from "@fix-webm-duration/parser";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { patchWebmDurationOnDisk } from "./webm-duration";

interface WebmElementMock {
getSectionById: (id: number) => WebmElementMock;
getValue: () => number;
}

describe("webm-duration patching", () => {
let dir: string;
const pathFor = (name: string) => path.join(dir, name);

beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), "openscreen-duration-"));
});

afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});

function createDummyWebm(includeCluster = true, clusterSize = 100): Uint8Array {
const ebml = new WebmContainer("EBML");
ebml.data = [];
const docType = new WebmString("DocType");
docType.setValue("webm");
ebml.data.push({ id: 0x282, idHex: "282", data: docType });
ebml.updateByData();

const segment = new WebmContainer("Segment");
segment.data = [];
segment.isInfinite = true;

const info = new WebmContainer("Info");
info.data = [];
const timecodeScale = new WebmUint("TimecodeScale");
timecodeScale.setValue(1000000);
info.data.push({ id: 0xad7b1, idHex: "ad7b1", data: timecodeScale });
info.updateByData();
segment.data.push({ id: 0x549a966, idHex: "549a966", data: info });

if (includeCluster) {
const cluster = new WebmBase("Cluster");
// A valid EBML element for Cluster: ID 0x1f43b675 (stripped as 0xf43b675)
// Followed by a VINT for length (e.g. clusterSize bytes)
const header = Buffer.from([0x1f, 0x43, 0xb6, 0x75, 0x01, 0x00]); // 6 bytes header (id + length)
const body = Buffer.alloc(clusterSize, 0x42); // cluster data filled with 0x42
const clusterBytes = Buffer.concat([header, body]);

cluster.setSource(new Uint8Array(clusterBytes));
segment.data.push({ id: 0xf43b675, idHex: "f43b675", data: cluster });
}

segment.updateByData();

const file = new WebmContainer("File");
file.data = [];
file.data.push({ id: 0xa45dfa3, idHex: "a45dfa3", data: ebml });
file.data.push({ id: 0x8538067, idHex: "8538067", data: segment });

file.updateByData();
return file.source;
}

it("patches small WebM files under 2MB in memory successfully", async () => {
const webmBytes = createDummyWebm(true, 100);
const filePath = pathFor("small.webm");
await writeFile(filePath, webmBytes);

const result = await patchWebmDurationOnDisk(filePath, 5000);
expect(result.patched).toBe(true);

const patchedBytes = await readFile(filePath);
const webm = new WebmFile(new Uint8Array(patchedBytes));

const segment = webm.getSectionById(0x8538067) as unknown as WebmElementMock;
const info = segment.getSectionById(0x549a966);
const duration = info.getSectionById(0x489);

expect(duration).toBeDefined();
expect(duration.getValue()).toBe(5000);
});

it("patches large WebM files over 2MB using the optimized streaming method successfully", async () => {
// Create a file larger than 2MB (e.g. 2.5MB cluster data) to trigger the optimized method
const webmBytes = createDummyWebm(true, 2.5 * 1024 * 1024);
const filePath = pathFor("large.webm");
await writeFile(filePath, webmBytes);

const result = await patchWebmDurationOnDisk(filePath, 12000);
expect(result.patched).toBe(true);

const patchedBytes = await readFile(filePath);
const webm = new WebmFile(new Uint8Array(patchedBytes));
const segment = webm.getSectionById(0x8538067) as unknown as WebmElementMock;
const info = segment.getSectionById(0x549a966);
const duration = info.getSectionById(0x489);

expect(duration).toBeDefined();
expect(duration.getValue()).toBe(12000);

// Verify the Cluster data (filled with 0x42) is intact at the end
const lastBytes = patchedBytes.subarray(patchedBytes.length - 100);
expect(lastBytes.every((b) => b === 0x42)).toBe(true);
});

it("falls back to in-memory patching if no Cluster section is found in the large file header chunk", async () => {
// File is over 2MB, but has no Cluster (very large header or weird file)
const webmBytes = createDummyWebm(false);
// Pad to 2.5MB without Cluster
const padding = Buffer.alloc(2.5 * 1024 * 1024, 0);
const largeNoClusterBytes = Buffer.concat([Buffer.from(webmBytes), padding]);

const filePath = pathFor("large_no_cluster.webm");
await writeFile(filePath, largeNoClusterBytes);

const result = await patchWebmDurationOnDisk(filePath, 8000);
expect(result.patched).toBe(true);

const patchedBytes = await readFile(filePath);
const webm = new WebmFile(new Uint8Array(patchedBytes));
const segment = webm.getSectionById(0x8538067) as unknown as WebmElementMock;
const info = segment.getSectionById(0x549a966);
const duration = info.getSectionById(0x489);

expect(duration).toBeDefined();
expect(duration.getValue()).toBe(8000);
});
});
162 changes: 145 additions & 17 deletions electron/recording/webm-duration.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,171 @@
import { createReadStream, createWriteStream } from "node:fs";
import fs from "node:fs/promises";
import { pipeline } from "node:stream/promises";
import { fixParsedWebmDuration } from "@fix-webm-duration/fix";
import { WebmFile } from "@fix-webm-duration/parser";

export type DurationPatchResult =
| { patched: true }
| { patched: false; reason: "no-section" | "already-valid" | "io-error" | "internal" };

// Read 2MB from the start of the file. Headers for WebM recordings are typically well under 100KB.
const HEADER_CHUNK_SIZE = 2 * 1024 * 1024;

/**
* Patch the WebM Duration header on a finalized recording file.
*
* MediaRecorder writes WebM with no Duration EBML element, and the streaming-to-disk
* path never holds the blob so the old `fixWebmDuration(blob, durationMs)` can't run.
* Patching on disk after `WriteStream.end()` gives the editor a real duration instead of `N/A`.
* MediaRecorder writes WebM with no Duration EBML element.
*
* Atomic: writes to `<filePath>.duration-patch.tmp` and renames in place, so a mid-rewrite
* crash leaves the original intact. Best-effort: any read/parse/write failure logs and returns
* a non-`patched` result rather than throwing; the file still plays without the patch (decoders
* walk frames sequentially), only the seek bar and timeline break.
* Rather than reading the entire multi-gigabyte file into memory (which crashes
* the main process on long recordings), we read only the first 2MB chunk containing
* the metadata headers, patch the Info section, and stream the remaining clusters
* from the original file using Node streams.
*
* Reads the whole file into a main-process Buffer, off the renderer so it dodges V8's heap cap.
* NOTE: This optimization handles the saving/finalization phase. For issues related
* to streaming large files during the editor/export phase, see also PR #74.
*/
export async function patchWebmDurationOnDisk(
filePath: string,
durationMs: number,
): Promise<DurationPatchResult> {
let fileHandle: fs.FileHandle | null = null;
const tmpPath = `${filePath}.duration-patch.tmp`;
try {
const stat = await fs.stat(filePath);
if (stat.size < HEADER_CHUNK_SIZE) {
// Fallback: If file is smaller than 2MB, just use the in-memory method.
return await patchWebmDurationInMemory(filePath, durationMs);
}

fileHandle = await fs.open(filePath, "r");
const buffer = Buffer.alloc(HEADER_CHUNK_SIZE);
const { bytesRead } = await fileHandle.read(buffer, 0, HEADER_CHUNK_SIZE, 0);
await fileHandle.close();
fileHandle = null;

const chunk = buffer.subarray(0, bytesRead);
const webm = new WebmFile(new Uint8Array(chunk));

if (!webm.data || !webm.source) {
console.warn(
`[webm-duration] Segment data or source is missing in chunk for ${filePath}; falling back to whole-file`,
);
return await patchWebmDurationInMemory(filePath, durationMs);
}

// Find Segment in webm.data
const segmentSec = webm.data.find((sec) => sec.id === 0x8538067);
if (!segmentSec || !segmentSec.data) {
console.warn(
`[webm-duration] Segment section is missing in chunk for ${filePath}; falling back to whole-file`,
);
return await patchWebmDurationInMemory(filePath, durationMs);
}

interface WebmContainerShape {
start: number;
isInfinite?: boolean;
data: { id: number; data: unknown }[];
}
const segment = segmentSec.data as WebmContainerShape;

const info = (
segment as unknown as { getSectionById?: (id: number) => unknown }
).getSectionById?.(0x549a966);
if (!info) {
console.warn(
`[webm-duration] Info section is missing in chunk for ${filePath}; falling back to whole-file`,
);
return await patchWebmDurationInMemory(filePath, durationMs);
}

// Calculate the start of the Segment's payload (content) using EBML VINT length rules
const segmentStart = segment.start;
const idByte = webm.source[segmentStart];
const idLen = 9 - idByte.toString(2).length;
const lenByte = webm.source[segmentStart + idLen];
const lenLen = 9 - lenByte.toString(2).length;
const segmentPayloadStart = segmentStart + idLen + lenLen;

// Find the first Cluster section. ID is 0xf43b675.
const segmentData = segment.data;
const clusterIdx = segmentData.findIndex((sec) => sec.id === 0xf43b675);
if (clusterIdx === -1) {
console.warn(
`[webm-duration] No Cluster section found in header chunk for ${filePath}; falling back to whole-file`,
);
return await patchWebmDurationInMemory(filePath, durationMs);
}

const clusterSec = segmentData[clusterIdx];
const clusterData = clusterSec.data as { start: number };
const clusterOffset = segmentPayloadStart + clusterData.start;

// Truncate the segment children to remove the Cluster and everything after it.
// This forces updateByData() to only regenerate/write the metadata headers.
segment.data = segmentData.slice(0, clusterIdx);

// Segment length was likely infinite (-1) or matching the original file. Since we are patching
// headers and appending the original stream, setting Segment length to infinite (-1) is safe and standard.
segment.isInfinite = true;

const patched = fixParsedWebmDuration(webm, durationMs, { logger: false });
if (!patched) {
const reason = inferUnpatchedReason(webm);
return { patched: false, reason };
}

if (!webm.source) {
console.error(`[webm-duration] patched but source missing for ${filePath}`);
return { patched: false, reason: "internal" };
}

const patchedBytes = Buffer.from(
webm.source.buffer,
webm.source.byteOffset,
webm.source.byteLength,
);

// Now write the patched headers and stream append the rest of the original file
const ws = createWriteStream(tmpPath);
const rs = createReadStream(filePath, { start: clusterOffset });

try {
await new Promise<void>((resolve, reject) => {
ws.write(patchedBytes, (err) => {
if (err) reject(err);
else resolve();
});
});
await pipeline(rs, ws);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} finally {
rs.destroy();
ws.destroy();
}

await fs.rename(tmpPath, filePath);
return { patched: true };
} catch (error) {
console.error(`[webm-duration] failed to patch ${filePath} using optimized method:`, error);
if (fileHandle) {
await fileHandle.close().catch(() => undefined);
}
await fs.unlink(tmpPath).catch(() => undefined);
return { patched: false, reason: "io-error" };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

async function patchWebmDurationInMemory(
filePath: string,
durationMs: number,
): Promise<DurationPatchResult> {
const tmpPath = `${filePath}.duration-patch.tmp`;
try {
const fileBytes = await fs.readFile(filePath);
const webm = new WebmFile(new Uint8Array(fileBytes));

const patched = fixParsedWebmDuration(webm, durationMs, { logger: false });
if (!patched) {
// false means missing Segment, missing Info, or an already-valid Duration.
// The first two mean a malformed (likely truncated) file; the third is a no-op.
const reason = inferUnpatchedReason(webm);
if (reason === "no-section") {
console.warn(
Expand All @@ -46,7 +180,6 @@ export async function patchWebmDurationOnDisk(
return { patched: false, reason: "internal" };
}

const tmpPath = `${filePath}.duration-patch.tmp`;
const patchedBytes = Buffer.from(
webm.source.buffer,
webm.source.byteOffset,
Expand All @@ -58,23 +191,18 @@ export async function patchWebmDurationOnDisk(
return { patched: true };
} catch (writeError) {
console.error(`[webm-duration] failed to write patched ${filePath}:`, writeError);
// Clean up the temp file; the original is untouched since the rename never ran.
await fs.unlink(tmpPath).catch(() => undefined);
return { patched: false, reason: "io-error" };
}
} catch (error) {
console.error(`[webm-duration] failed to patch ${filePath}:`, error);
console.error(`[webm-duration] failed to patch ${filePath} in memory:`, error);
return { patched: false, reason: "io-error" };
}
}

/**
* Distinguish "no Segment/Info section" (malformed/truncated file) from "Info present
* but Duration already valid" (patch unnecessary).
*
* The IDs are the length-descriptor-stripped form @fix-webm-duration/parser uses as lookup
* keys (Segment `0x8538067`, Info `0x549a966`), per the parser's `src/lib/sections.js`, not
* the canonical 4-byte EBML IDs (`0x18538067` / `0x1549A966`) that `getSectionById` never matches.
*/
function inferUnpatchedReason(webm: WebmFile): "no-section" | "already-valid" {
const segment = webm.getSectionById?.(0x8538067);
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions src/components/launch/LaunchWindow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const recorderState = vi.hoisted(() => ({
value: {
recording: false,
paused: false,
saving: false,
elapsedSeconds: 0,
toggleRecording: vi.fn(),
togglePaused: vi.fn(),
Expand Down
Loading
Loading