Skip to content
Closed
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ webreel record --watch
webreel record --verbose
```

In headless recording mode, `webreel record --frames` saves PNG source frames in `.webreel/frames/`.

### Preview

Run a video in a visible browser window without recording:
Expand Down
163 changes: 163 additions & 0 deletions packages/@webreel/core/src/__tests__/recorder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";

const spawnState = vi.hoisted(() => ({
stdinWrites: [] as Buffer[],
spawnArgs: [] as string[],
ensureFfmpegMock: vi.fn(async () => "ffmpeg"),
finalizeMp4Mock: vi.fn(),
finalizeWebmMock: vi.fn(),
finalizeGifMock: vi.fn(),
writeFileSyncMock: vi.fn(),
}));

vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
return {
...actual,
writeFileSync: spawnState.writeFileSyncMock,
};
});

vi.mock("../ffmpeg.js", () => ({
ensureFfmpeg: spawnState.ensureFfmpegMock,
}));

vi.mock("../media.js", () => ({
finalizeMp4: spawnState.finalizeMp4Mock,
finalizeWebm: spawnState.finalizeWebmMock,
finalizeGif: spawnState.finalizeGifMock,
}));

vi.mock("node:child_process", () => ({
spawn: vi.fn((_command: string, args: string[]) => {
spawnState.spawnArgs = args;

const proc = new EventEmitter() as EventEmitter & {
stdin: EventEmitter & {
writable: boolean;
write: (chunk: Buffer) => boolean;
end: () => void;
};
stdout: EventEmitter;
stderr: EventEmitter;
exitCode: number | null;
kill: () => void;
};

const stdin = new EventEmitter() as EventEmitter & {
writable: boolean;
write: (chunk: Buffer) => boolean;
end: () => void;
};
stdin.writable = true;
stdin.write = (chunk: Buffer) => {
spawnState.stdinWrites.push(chunk);
return true;
};
stdin.end = () => {
proc.exitCode = 0;
proc.emit("close", 0);
};

proc.stdin = stdin;
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.exitCode = null;
proc.kill = () => {
proc.exitCode = 0;
proc.emit("close", 0);
};

return proc;
}),
}));

import { Recorder } from "../recorder.js";
import type { CDPClient } from "../types.js";

describe("Recorder", () => {
beforeEach(() => {
spawnState.stdinWrites = [];
spawnState.spawnArgs = [];
spawnState.ensureFfmpegMock.mockClear();
spawnState.finalizeMp4Mock.mockClear();
spawnState.finalizeWebmMock.mockClear();
spawnState.finalizeGifMock.mockClear();
spawnState.writeFileSyncMock.mockClear();
});

it("captures recording frames with HeadlessExperimental.beginFrame PNG screenshots", async () => {
const screenshotData = Buffer.from("png-frame").toString("base64");
const beginFrame = vi
.fn<CDPClient["HeadlessExperimental"]["beginFrame"]>()
.mockResolvedValueOnce({ hasDamage: true, screenshotData })
.mockImplementationOnce(() => new Promise(() => undefined));
const captureScreenshot = vi.fn();

const client = {
Runtime: { evaluate: vi.fn().mockResolvedValue({ result: {} }) },
Page: { captureScreenshot },
HeadlessExperimental: { beginFrame },
} as unknown as CDPClient;

const recorder = new Recorder(1080, 1080, { fps: 30, framesDir: "/tmp/frames" });
recorder.setTimeline({ tick: vi.fn(), toJSON: vi.fn(() => null) } as never);

await recorder.start(client, "/tmp/out.mp4");
await vi.waitFor(() => {
expect(beginFrame).toHaveBeenCalledWith({
screenshot: { format: "png", optimizeForSpeed: true },
});
});
await recorder.stop();

expect(captureScreenshot).not.toHaveBeenCalled();
expect(spawnState.spawnArgs).toContain("png");
expect(spawnState.writeFileSyncMock).toHaveBeenCalledWith(
expect.stringMatching(/frame-00001\.png$/),
expect.any(Buffer),
);
});

it("reuses the previous frame when beginFrame returns no screenshotData", async () => {
const firstFrame = Buffer.from("png-frame-1");
const beginFrame = vi
.fn<CDPClient["HeadlessExperimental"]["beginFrame"]>()
.mockResolvedValueOnce({
hasDamage: true,
screenshotData: firstFrame.toString("base64"),
})
.mockResolvedValueOnce({ hasDamage: false })
.mockImplementationOnce(() => new Promise(() => undefined));

const client = {
Runtime: { evaluate: vi.fn().mockResolvedValue({ result: {} }) },
Page: { captureScreenshot: vi.fn() },
HeadlessExperimental: { beginFrame },
} as unknown as CDPClient;

const recorder = new Recorder(1080, 1080, { fps: 30, framesDir: "/tmp/frames" });
recorder.setTimeline({ tick: vi.fn(), toJSON: vi.fn(() => null) } as never);

await recorder.start(client, "/tmp/out.mp4");
await vi.waitFor(() => {
expect(spawnState.stdinWrites).toHaveLength(2);
});
await recorder.stop();

expect(spawnState.stdinWrites).toHaveLength(2);
expect(spawnState.stdinWrites[0]).toEqual(firstFrame);
expect(spawnState.stdinWrites[1]).toEqual(firstFrame);
expect(spawnState.writeFileSyncMock).toHaveBeenNthCalledWith(
1,
expect.stringMatching(/frame-00001\.png$/),
firstFrame,
);
expect(spawnState.writeFileSyncMock).toHaveBeenNthCalledWith(
2,
expect.stringMatching(/frame-00002\.png$/),
firstFrame,
);
});
});
37 changes: 13 additions & 24 deletions packages/@webreel/core/src/recorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ export class Recorder {
private outputHeight: number;
private sfx: SfxConfig | undefined;
private fps: number;
private frameMs: number;
private crf: number;
private ffmpegPath = "ffmpeg";
private ffmpegProcess: ChildProcess | null = null;
Expand All @@ -31,6 +30,7 @@ export class Recorder {
private framesDir: string | null = null;
private stopResolve: (() => void) | null = null;
private stoppedPromise: Promise<void> | null = null;
private lastFrameBuffer: Buffer | null = null;

constructor(
outputWidth = DEFAULT_VIEWPORT_SIZE,
Expand All @@ -41,7 +41,6 @@ export class Recorder {
this.outputHeight = outputHeight;
this.sfx = options?.sfx;
this.fps = options?.fps ?? TARGET_FPS;
this.frameMs = 1000 / this.fps;
this.crf = options?.crf ?? 18;
if (options?.framesDir) {
this.framesDir = options.framesDir;
Expand Down Expand Up @@ -74,6 +73,7 @@ export class Recorder {
this.frameCount = 0;
this.droppedFrames = 0;
this.running = true;
this.lastFrameBuffer = null;
this.events = [];
this.ctx = ctx ?? null;
if (this.ctx) this.ctx.setRecorder(this);
Expand All @@ -91,7 +91,7 @@ export class Recorder {
"-framerate",
String(this.fps),
"-c:v",
"mjpeg",
"png",
"-i",
"pipe:0",
"-c:v",
Expand Down Expand Up @@ -159,7 +159,6 @@ export class Recorder {
}

private async captureLoop(client: CDPClient) {
let lastFrameTime = Date.now();
let consecutiveErrors = 0;

while (this.running) {
Expand All @@ -174,37 +173,27 @@ export class Recorder {
);
if (!evalResult) break;
}
const screenshotResult = await this.raceStop(
client.Page.captureScreenshot({
format: "jpeg",
quality: 60,
optimizeForSpeed: true,
const frameResult = await this.raceStop(
client.HeadlessExperimental.beginFrame({
screenshot: { format: "png", optimizeForSpeed: true },
}),
);
if (!screenshotResult) break;
if (!frameResult) break;

const buffer = Buffer.from(screenshotResult.data, "base64");
const now = Date.now();
const elapsed = now - lastFrameTime;
const frameSlots = Math.min(3, Math.max(1, Math.round(elapsed / this.frameMs)));

if (frameSlots > 1) {
for (let i = 0; i < frameSlots - 1; i++) {
if (this.timeline) this.timeline.tickDuplicate();
await this.writeFrame(buffer);
this.frameCount++;
}
}
const buffer = frameResult.screenshotData
? Buffer.from(frameResult.screenshotData, "base64")
: this.lastFrameBuffer;
if (!buffer) continue;
this.lastFrameBuffer = buffer;

await this.writeFrame(buffer);
this.frameCount++;

if (this.framesDir) {
const padded = String(this.frameCount).padStart(5, "0");
writeFileSync(resolve(this.framesDir, `frame-${padded}.jpg`), buffer);
writeFileSync(resolve(this.framesDir, `frame-${padded}.png`), buffer);
}

lastFrameTime = now;
consecutiveErrors = 0;
} catch (err) {
if (!this.running) break;
Expand Down
13 changes: 13 additions & 0 deletions packages/@webreel/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,19 @@ export type CDPClient = {
mobile: boolean;
}) => Promise<void>;
};
HeadlessExperimental: {
enable: () => Promise<void>;
beginFrame: (params?: {
frameTimeTicks?: number;
interval?: number;
noDisplayUpdates?: boolean;
screenshot?: {
format?: "jpeg" | "png" | "webp";
quality?: number;
optimizeForSpeed?: boolean;
};
}) => Promise<{ hasDamage: boolean; screenshotData?: string }>;
};
DOM: {
enable: () => Promise<void>;
};
Expand Down
2 changes: 2 additions & 0 deletions packages/webreel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ webreel record -c custom.config.json

When run without arguments, webreel reads `webreel.config.json` from the current directory and records all videos. Provide video names to record specific videos only.

In headless recording mode, `webreel record --frames` saves PNG source frames in `.webreel/frames/`.

### `webreel preview`

Run a video in a visible browser window without recording.
Expand Down
2 changes: 1 addition & 1 deletion packages/webreel/src/commands/record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export const recordCommand = new Command("record")
.option("--verbose", "Log each step as it executes")
.option("--watch", "Re-record when config files change")
.option("--dry-run", "Print the resolved config and step list without recording")
.option("--frames", "Save raw frames as JPEGs in .webreel/frames/")
.option("--frames", "Save raw frames as PNGs in .webreel/frames/")
.action(
async (
videoNames: string[],
Expand Down
Loading