// Film a run: turn a chat-theater run's real recordings into ONE mp4 that// plays like a screen recording of a developer tabbing between full-screen// windows — terminal, browser, terminal. Both segments are the genuine// recordings; the only editorial act is the cut, exactly like real tabbing.//// bun scripts/film.ts runs/<target>/<slug>//// The cut list comes from the run's focus timeline (src/timeline.ts):// surfaces mark focus as a side effect of acting — a Playwright step// focuses the browser, a chat event focuses the terminal — so the// operations themselves decide where the film cuts, for any number of// hops. Runs without a timeline fall back to the narrator-line / largest-// gap heuristic. The cast renders with NO idle compression so cast time// equals video time. Output: film.mp4, registered in result.json — the// viewer plays it as the session when present.import { execFileSync } from "node:child_process";import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";import { tmpdir } from "node:os";import { join, resolve } from "node:path"; import { readTimeline } from "../src/timeline"; const runDir = resolve(process.argv[2] ?? "");const castPath = join(runDir, "terminal.cast");const browserPath = join(runDir, "session.mp4");if (!existsSync(castPath) || !existsSync(browserPath)) { console.error(`film: need terminal.cast + session.mp4 in ${runDir}`); process.exit(1);} const run = (cmd: string, args: string[]) => execFileSync(cmd, args, { stdio: "pipe" });const probeSeconds = (file: string): number => Number( execFileSync("ffprobe", [ ...["-v", "quiet", "-show_entries", "format=duration"], ...["-of", "csv=p=0", file], ]) .toString() .trim(), ); // ---------------------------------------------------------------------------// Build the cut list: acts of { source, from, to } in each source's clock.// --------------------------------------------------------------------------- interface CastEvent { readonly at: number; readonly text: string;} const events: CastEvent[] = readFileSync(castPath, "utf8") .split("\n") .slice(1) .filter(Boolean) .map((line) => JSON.parse(line) as [number, string, string]) .filter((event) => event[1] === "o") .map((event) => ({ at: event[0], text: event[2] })); const castEnd = events.at(-1)?.at ?? 0; interface Act { readonly source: "terminal" | "browser"; readonly from: number; readonly to: number;} /** Acts from the focus timeline: each contiguous focus run becomes a cut of * that window's recording, mapped via the recording-start anchors. */const actsFromTimeline = (): Act[] | null => { const timeline = readTimeline(runDir); if (!timeline || timeline.focus.length < 2) return null; const terminalAnchor = timeline.anchors.terminal; const browserAnchor = timeline.anchors.browser; if (terminalAnchor === undefined || browserAnchor === undefined) return null; const browserEnd = probeSeconds(browserPath); const toMedia = (window: Act["source"], wallMs: number) => Math.max(0, (wallMs - (window === "terminal" ? terminalAnchor : browserAnchor)) / 1000); const acts: Act[] = []; for (let i = 0; i < timeline.focus.length; i += 1) { const current = timeline.focus[i]; if (!current) continue; const nextAt = timeline.focus[i + 1]?.at; const from = i === 0 && current.window === "terminal" ? 0 : toMedia(current.window, current.at); const mediaEnd = current.window === "terminal" ? castEnd : browserEnd; const to = nextAt === undefined ? mediaEnd : Math.min(toMedia(current.window, nextAt), mediaEnd); if (to - from > 0.5) acts.push({ source: current.window, from, to }); } return acts.length >= 2 ? acts : null;}; /** Fallback for runs without a timeline: one browser hop located by the * narrator line, or the largest output gap in the cast. */const actsFromHeuristic = (): Act[] => { const findHop = (): { start: number; end: number } => { const markerIndex = events.findIndex((event) => event.text.includes("in the browser")); if (markerIndex !== -1) { const start = events[markerIndex]; const after = events .slice(markerIndex + 1) .find((event) => event.at > (start?.at ?? 0) + 1 && event.text.trim().length > 0); if (start && after) return { start: start.at + 0.8, end: after.at }; } let best = { start: 0, end: 0 }; for (let i = 1; i < events.length; i += 1) { const previous = events[i - 1]; const current = events[i]; if (previous && current && current.at - previous.at > best.end - best.start) { best = { start: previous.at, end: current.at }; } } return best; }; const hop = findHop(); if (hop.end - hop.start < 2) { console.error(`film: no browser hop found in the cast (gap ${hop.end - hop.start}s)`); process.exit(1); } return [ { source: "terminal", from: 0, to: hop.start }, { source: "browser", from: 0, to: probeSeconds(browserPath) }, { source: "terminal", from: hop.end, to: castEnd }, ];}; const acts = actsFromTimeline() ?? actsFromHeuristic(); // ---------------------------------------------------------------------------// Render + cut + concatenate onto one canvas — full-screen cuts, like// tabbing.// --------------------------------------------------------------------------- const work = mkdtempSync(join(tmpdir(), "e2e-film-"));const castGif = join(work, "cast.gif");const castVideo = join(work, "cast.mp4"); run("agg", [ ...["--idle-time-limit", String(Math.ceil(castEnd) + 60)], ...["--font-size", "16"], castPath, castGif,]);run("ffmpeg", [ ...["-y", "-i", castGif], // agg's gif can have odd dimensions; libx264 requires even. ...["-vf", "scale=ceil(iw/2)*2:ceil(ih/2)*2"], ...["-pix_fmt", "yuv420p", "-r", "24", castVideo],]); const FIT = "scale=1280:800:force_original_aspect_ratio=decrease,pad=1280:800:(ow-iw)/2:(oh-ih)/2:color=0x0b0b10,setsar=1,fps=24,format=yuv420p"; const filmPath = join(runDir, "film.mp4");const inputIndex = { terminal: 0, browser: 1 } as const;const filters = acts.map( (act, index) => `[${inputIndex[act.source]}:v]trim=${act.from.toFixed(2)}:${act.to.toFixed(2)},setpts=PTS-STARTPTS,${FIT}[act${index}]`,);run("ffmpeg", [ "-y", ...["-i", castVideo], ...["-i", browserPath], "-filter_complex", [ ...filters, `${acts.map((_, index) => `[act${index}]`).join("")}concat=n=${acts.length}:v=1:a=0[out]`, ].join(";"), ...["-map", "[out]"], ...["-c:v", "libx264", "-preset", "veryfast", "-crf", "24", "-movflags", "+faststart"], filmPath,]);rmSync(work, { recursive: true, force: true }); // Register the film in the run's artifact list so the viewer offers it.const resultPath = join(runDir, "result.json");if (existsSync(resultPath)) { const result = JSON.parse(readFileSync(resultPath, "utf8")) as { artifacts?: string[] }; if (Array.isArray(result.artifacts) && !result.artifacts.includes("film.mp4")) { result.artifacts.push("film.mp4"); writeFileSync(resultPath, JSON.stringify(result, null, 1)); }} console.log( `film: ${filmPath}\n${acts .map( (act, index) => ` act ${index + 1} ${act.source} ${act.from.toFixed(1)}–${act.to.toFixed(1)}s`, ) .join("\n")}`,);
// Film a run: turn a chat-theater run's real recordings into ONE mp4 that
// plays like a screen recording of a developer tabbing between full-screen
// windows — terminal, browser, terminal. Both segments are the genuine
// recordings; the only editorial act is the cut, exactly like real tabbing.
//
// bun scripts/film.ts runs/<target>/<slug>
//
// The cut list comes from the run's focus timeline (src/timeline.ts):
// surfaces mark focus as a side effect of acting — a Playwright step
// focuses the browser, a chat event focuses the terminal — so the
// operations themselves decide where the film cuts, for any number of
// hops. Runs without a timeline fall back to the narrator-line / largest-
// gap heuristic. The cast renders with NO idle compression so cast time
// equals video time. Output: film.mp4, registered in result.json — the
// viewer plays it as the session when present.
import { execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { readTimeline } from "../src/timeline";
const runDir = resolve(process.argv[2] ?? "");
const castPath = join(runDir, "terminal.cast");
const browserPath = join(runDir, "session.mp4");
if (!existsSync(castPath) || !existsSync(browserPath)) {
console.error(`film: need terminal.cast + session.mp4 in ${runDir}`);
process.exit(1);
}
const run = (cmd: string, args: string[]) => execFileSync(cmd, args, { stdio: "pipe" });
const probeSeconds = (file: string): number =>
Number(
execFileSync("ffprobe", [
...["-v", "quiet", "-show_entries", "format=duration"],
...["-of", "csv=p=0", file],
])
.toString()
.trim(),
);
// ---------------------------------------------------------------------------
// Build the cut list: acts of { source, from, to } in each source's clock.
// ---------------------------------------------------------------------------
interface CastEvent {
readonly at: number;
readonly text: string;
}
const events: CastEvent[] = readFileSync(castPath, "utf8")
.split("\n")
.slice(1)
.filter(Boolean)
.map((line) => JSON.parse(line) as [number, string, string])
.filter((event) => event[1] === "o")
.map((event) => ({ at: event[0], text: event[2] }));
const castEnd = events.at(-1)?.at ?? 0;
interface Act {
readonly source: "terminal" | "browser";
readonly from: number;
readonly to: number;
}
/** Acts from the focus timeline: each contiguous focus run becomes a cut of
* that window's recording, mapped via the recording-start anchors. */
const actsFromTimeline = (): Act[] | null => {
const timeline = readTimeline(runDir);
if (!timeline || timeline.focus.length < 2) return null;
const terminalAnchor = timeline.anchors.terminal;
const browserAnchor = timeline.anchors.browser;
if (terminalAnchor === undefined || browserAnchor === undefined) return null;
const browserEnd = probeSeconds(browserPath);
const toMedia = (window: Act["source"], wallMs: number) =>
Math.max(0, (wallMs - (window === "terminal" ? terminalAnchor : browserAnchor)) / 1000);
const acts: Act[] = [];
for (let i = 0; i < timeline.focus.length; i += 1) {
const current = timeline.focus[i];
if (!current) continue;
const nextAt = timeline.focus[i + 1]?.at;
const from = i === 0 && current.window === "terminal" ? 0 : toMedia(current.window, current.at);
const mediaEnd = current.window === "terminal" ? castEnd : browserEnd;
const to =
nextAt === undefined ? mediaEnd : Math.min(toMedia(current.window, nextAt), mediaEnd);
if (to - from > 0.5) acts.push({ source: current.window, from, to });
}
return acts.length >= 2 ? acts : null;
};
/** Fallback for runs without a timeline: one browser hop located by the
* narrator line, or the largest output gap in the cast. */
const actsFromHeuristic = (): Act[] => {
const findHop = (): { start: number; end: number } => {
const markerIndex = events.findIndex((event) => event.text.includes("in the browser"));
if (markerIndex !== -1) {
const start = events[markerIndex];
const after = events
.slice(markerIndex + 1)
.find((event) => event.at > (start?.at ?? 0) + 1 && event.text.trim().length > 0);
if (start && after) return { start: start.at + 0.8, end: after.at };
}
let best = { start: 0, end: 0 };
for (let i = 1; i < events.length; i += 1) {
const previous = events[i - 1];
const current = events[i];
if (previous && current && current.at - previous.at > best.end - best.start) {
best = { start: previous.at, end: current.at };
}
}
return best;
};
const hop = findHop();
if (hop.end - hop.start < 2) {
console.error(`film: no browser hop found in the cast (gap ${hop.end - hop.start}s)`);
process.exit(1);
}
return [
{ source: "terminal", from: 0, to: hop.start },
{ source: "browser", from: 0, to: probeSeconds(browserPath) },
{ source: "terminal", from: hop.end, to: castEnd },
];
};
const acts = actsFromTimeline() ?? actsFromHeuristic();
// ---------------------------------------------------------------------------
// Render + cut + concatenate onto one canvas — full-screen cuts, like
// tabbing.
// ---------------------------------------------------------------------------
const work = mkdtempSync(join(tmpdir(), "e2e-film-"));
const castGif = join(work, "cast.gif");
const castVideo = join(work, "cast.mp4");
run("agg", [
...["--idle-time-limit", String(Math.ceil(castEnd) + 60)],
...["--font-size", "16"],
castPath,
castGif,
]);
run("ffmpeg", [
...["-y", "-i", castGif],
// agg's gif can have odd dimensions; libx264 requires even.
...["-vf", "scale=ceil(iw/2)*2:ceil(ih/2)*2"],
...["-pix_fmt", "yuv420p", "-r", "24", castVideo],
]);
const FIT =
"scale=1280:800:force_original_aspect_ratio=decrease,pad=1280:800:(ow-iw)/2:(oh-ih)/2:color=0x0b0b10,setsar=1,fps=24,format=yuv420p";
const filmPath = join(runDir, "film.mp4");
const inputIndex = { terminal: 0, browser: 1 } as const;
const filters = acts.map(
(act, index) =>
`[${inputIndex[act.source]}:v]trim=${act.from.toFixed(2)}:${act.to.toFixed(2)},setpts=PTS-STARTPTS,${FIT}[act${index}]`,
);
run("ffmpeg", [
"-y",
...["-i", castVideo],
...["-i", browserPath],
"-filter_complex",
[
...filters,
`${acts.map((_, index) => `[act${index}]`).join("")}concat=n=${acts.length}:v=1:a=0[out]`,
].join(";"),
...["-map", "[out]"],
...["-c:v", "libx264", "-preset", "veryfast", "-crf", "24", "-movflags", "+faststart"],
filmPath,
]);
rmSync(work, { recursive: true, force: true });
// Register the film in the run's artifact list so the viewer offers it.
const resultPath = join(runDir, "result.json");
if (existsSync(resultPath)) {
const result = JSON.parse(readFileSync(resultPath, "utf8")) as { artifacts?: string[] };
if (Array.isArray(result.artifacts) && !result.artifacts.includes("film.mp4")) {
result.artifacts.push("film.mp4");
writeFileSync(resultPath, JSON.stringify(result, null, 1));
}
}
console.log(
`film: ${filmPath}\n${acts
.map(
(act, index) =>
` act ${index + 1} ${act.source} ${act.from.toFixed(1)}–${act.to.toFixed(1)}s`,
)
.join("\n")}`,
);