storytellurx/scripts/product-demo/live-capture.ts

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 };
};
}