88 lines
3 KiB
TypeScript
88 lines
3 KiB
TypeScript
import type { Page } from "@playwright/test";
|
|
import { mkdtemp, writeFile } from "node:fs/promises";
|
|
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import path from "node:path";
|
|
import { tmpdir } from "node:os";
|
|
import { z } from "zod";
|
|
import { demoFps, phoneViewport } from "./dimensions";
|
|
import { recordingFrameManifest } from "./frame-schedule";
|
|
|
|
const frameSchema = z.object({
|
|
data: z.string(),
|
|
sessionId: z.number(),
|
|
metadata: z.object({ timestamp: z.number() }),
|
|
});
|
|
|
|
/** Keep the browser's frame timestamps so captions and video share the same clock. */
|
|
export async function startLiveCapture(page: Page, directory: string) {
|
|
// Frame writes must stay outside Vite's watched project tree.
|
|
const framesDirectory = await mkdtemp(path.join(tmpdir(), "storytellurx-live-"));
|
|
const client = await page.context().newCDPSession(page);
|
|
const frames: { file: string; timestamp: number }[] = [];
|
|
const writes: Promise<void>[] = [];
|
|
const errors: string[] = [];
|
|
client.on("Page.screencastFrame", (event: unknown) => {
|
|
const frame = frameSchema.parse(event);
|
|
void client.send("Page.screencastFrameAck", { sessionId: frame.sessionId }).catch(() => {});
|
|
const timestamp = frame.metadata.timestamp * 1000;
|
|
// Keep every delivered frame. Arrival jitter must not discard valid animation frames.
|
|
const file = `${frames.length}.jpg`;
|
|
frames.push({ file, timestamp });
|
|
writes.push(
|
|
writeFile(path.join(framesDirectory, file), Buffer.from(frame.data, "base64")).catch(
|
|
(error: unknown) => {
|
|
errors.push(String(error));
|
|
},
|
|
),
|
|
);
|
|
});
|
|
await client.send("Page.startScreencast", {
|
|
format: "jpeg",
|
|
quality: 85,
|
|
maxWidth: phoneViewport.width,
|
|
maxHeight: phoneViewport.height,
|
|
everyNthFrame: 1,
|
|
});
|
|
return async () => {
|
|
const finished = Date.now();
|
|
await client.send("Page.stopScreencast");
|
|
await Promise.all(writes);
|
|
if (errors.length) throw new Error(errors.join("\n"));
|
|
const first = frames[0];
|
|
const last = frames.at(-1);
|
|
if (!first || !last) throw new Error("No browser frames were captured");
|
|
const { manifest, frameCount } = recordingFrameManifest(frames, finished, demoFps);
|
|
await writeFile(path.join(framesDirectory, "frames.txt"), manifest);
|
|
await writeFile(path.join(directory, "frame-timestamps.json"), JSON.stringify(frames));
|
|
const video = path.resolve(directory, "source.mp4");
|
|
await promisify(execFile)("ffmpeg", [
|
|
"-hide_banner",
|
|
"-loglevel",
|
|
"error",
|
|
"-f",
|
|
"concat",
|
|
"-safe",
|
|
"0",
|
|
"-i",
|
|
path.join(framesDirectory, "frames.txt"),
|
|
"-r",
|
|
String(demoFps),
|
|
"-frames:v",
|
|
String(frameCount),
|
|
"-c:v",
|
|
"libx264",
|
|
"-preset",
|
|
"fast",
|
|
"-crf",
|
|
"16",
|
|
"-pix_fmt",
|
|
"yuv420p",
|
|
"-movflags",
|
|
"+faststart",
|
|
"-y",
|
|
video,
|
|
]);
|
|
return { video, videoStarted: first.timestamp, capturedFrames: frames.length, fps: demoFps };
|
|
};
|
|
}
|