| @@ -1,23 +1,25 @@ |
| 1 | | −// Film a run: turn a chat-theater run's two real recordings into ONE mp4 |
| 2 | | −// that plays like a screen recording of a developer tabbing between |
| 3 | | −// full-screen windows — terminal, browser, terminal. No virtual desktop, |
| 4 | | −// no splice of anything synthetic: both segments are the real recordings, |
| 5 | | −// cut at the moment the session actually moved between them. |
| 1 | +// Film a run: turn a chat-theater run's real recordings into ONE mp4 that |
| 2 | +// plays like a screen recording of a developer tabbing between full-screen |
| 3 | +// windows — terminal, browser, terminal. Both segments are the genuine |
| 4 | +// recordings; the only editorial act is the cut, exactly like real tabbing. |
| 6 | 5 | // |
| 7 | 6 | // bun scripts/film.ts runs/<target>/<slug> |
| 8 | 7 | // |
| 9 | | −// Mechanics: the terminal.cast contains dead air while the "user" was in |
| 10 | | −// the browser (the chat was genuinely idle). We render the cast to video |
| 11 | | −// with NO idle compression (so cast time == video time), cut it at the |
| 12 | | −// browser hop, and put the browser session.mp4 in the gap. The hop is |
| 13 | | −// located by the scenario's narrator line ("…in the browser…"), falling |
| 14 | | −// back to the largest output gap. Output: film.mp4 next to the inputs — |
| 15 | | −// the viewer plays it as the session when present. |
| 8 | +// The cut list comes from the run's focus timeline (src/timeline.ts): |
| 9 | +// surfaces mark focus as a side effect of acting — a Playwright step |
| 10 | +// focuses the browser, a chat event focuses the terminal — so the |
| 11 | +// operations themselves decide where the film cuts, for any number of |
| 12 | +// hops. Runs without a timeline fall back to the narrator-line / largest- |
| 13 | +// gap heuristic. The cast renders with NO idle compression so cast time |
| 14 | +// equals video time. Output: film.mp4, registered in result.json — the |
| 15 | +// viewer plays it as the session when present. |
| 16 | 16 | import { execFileSync } from "node:child_process"; |
| 17 | | −import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; |
| 17 | +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; |
| 18 | 18 | import { tmpdir } from "node:os"; |
| 19 | 19 | import { join, resolve } from "node:path"; |
| 20 | 20 | |
| 21 | +import { readTimeline } from "../src/timeline"; |
| 22 | + |
| 21 | 23 | const runDir = resolve(process.argv[2] ?? ""); |
| 22 | 24 | const castPath = join(runDir, "terminal.cast"); |
| 23 | 25 | const browserPath = join(runDir, "session.mp4"); |
| @@ -38,7 +40,7 @@ const probeSeconds = (file: string): number => · 14 unmodified lines |
| 38 | 40 | ); |
| 39 | 41 | |
| 40 | 42 | // --------------------------------------------------------------------------- |
| 41 | | −// Locate the browser hop in cast time. |
| 43 | +// Build the cut list: acts of { source, from, to } in each source's clock. |
| 42 | 44 | // --------------------------------------------------------------------------- |
| 43 | 45 | |
| 44 | 46 | interface CastEvent { |
| @@ -56,51 +58,87 @@ const events: CastEvent[] = readFileSync(castPath, "utf8") · 11 unmodified lines |
| 56 | 58 | |
| 57 | 59 | const castEnd = events.at(-1)?.at ?? 0; |
| 58 | 60 | |
| 59 | | −/** Hop start: the narrator line the scenario prints as it moves to the |
| 60 | | − * browser; hop end: the next visible output after it. Falls back to the |
| 61 | | − * largest inter-event gap (the hop is the only long silence). */ |
| 62 | | −const findHop = (): { start: number; end: number } => { |
| 63 | | − const markerIndex = events.findIndex((event) => event.text.includes("in the browser")); |
| 64 | | − if (markerIndex !== -1) { |
| 65 | | − const start = events[markerIndex]; |
| 66 | | − const after = events |
| 67 | | − .slice(markerIndex + 1) |
| 68 | | − .find((event) => event.at > (start?.at ?? 0) + 1 && event.text.trim().length > 0); |
| 69 | | − if (start && after) return { start: start.at + 0.8, end: after.at }; |
| 61 | +interface Act { |
| 62 | + readonly source: "terminal" | "browser"; |
| 63 | + readonly from: number; |
| 64 | + readonly to: number; |
| 65 | +} |
| 66 | + |
| 67 | +/** Acts from the focus timeline: each contiguous focus run becomes a cut of |
| 68 | + * that window's recording, mapped via the recording-start anchors. */ |
| 69 | +const actsFromTimeline = (): Act[] | null => { |
| 70 | + const timeline = readTimeline(runDir); |
| 71 | + if (!timeline || timeline.focus.length < 2) return null; |
| 72 | + const terminalAnchor = timeline.anchors.terminal; |
| 73 | + const browserAnchor = timeline.anchors.browser; |
| 74 | + if (terminalAnchor === undefined || browserAnchor === undefined) return null; |
| 75 | + |
| 76 | + const browserEnd = probeSeconds(browserPath); |
| 77 | + const toMedia = (window: Act["source"], wallMs: number) => |
| 78 | + Math.max(0, (wallMs - (window === "terminal" ? terminalAnchor : browserAnchor)) / 1000); |
| 79 | + |
| 80 | + const acts: Act[] = []; |
| 81 | + for (let i = 0; i < timeline.focus.length; i += 1) { |
| 82 | + const current = timeline.focus[i]; |
| 83 | + if (!current) continue; |
| 84 | + const nextAt = timeline.focus[i + 1]?.at; |
| 85 | + const from = i === 0 && current.window === "terminal" ? 0 : toMedia(current.window, current.at); |
| 86 | + const mediaEnd = current.window === "terminal" ? castEnd : browserEnd; |
| 87 | + const to = |
| 88 | + nextAt === undefined ? mediaEnd : Math.min(toMedia(current.window, nextAt), mediaEnd); |
| 89 | + if (to - from > 0.5) acts.push({ source: current.window, from, to }); |
| 70 | 90 | } |
| 71 | | − let best = { start: 0, end: 0 }; |
| 72 | | − for (let i = 1; i < events.length; i += 1) { |
| 73 | | − const previous = events[i - 1]; |
| 74 | | − const current = events[i]; |
| 75 | | − if (previous && current && current.at - previous.at > best.end - best.start) { |
| 76 | | − best = { start: previous.at, end: current.at }; |
| 91 | + return acts.length >= 2 ? acts : null; |
| 92 | +}; |
| 93 | + |
| 94 | +/** Fallback for runs without a timeline: one browser hop located by the |
| 95 | + * narrator line, or the largest output gap in the cast. */ |
| 96 | +const actsFromHeuristic = (): Act[] => { |
| 97 | + const findHop = (): { start: number; end: number } => { |
| 98 | + const markerIndex = events.findIndex((event) => event.text.includes("in the browser")); |
| 99 | + if (markerIndex !== -1) { |
| 100 | + const start = events[markerIndex]; |
| 101 | + const after = events |
| 102 | + .slice(markerIndex + 1) |
| 103 | + .find((event) => event.at > (start?.at ?? 0) + 1 && event.text.trim().length > 0); |
| 104 | + if (start && after) return { start: start.at + 0.8, end: after.at }; |
| 105 | + } |
| 106 | + let best = { start: 0, end: 0 }; |
| 107 | + for (let i = 1; i < events.length; i += 1) { |
| 108 | + const previous = events[i - 1]; |
| 109 | + const current = events[i]; |
| 110 | + if (previous && current && current.at - previous.at > best.end - best.start) { |
| 111 | + best = { start: previous.at, end: current.at }; |
| 112 | + } |
| 77 | 113 | } |
| 114 | + return best; |
| 115 | + }; |
| 116 | + const hop = findHop(); |
| 117 | + if (hop.end - hop.start < 2) { |
| 118 | + console.error(`film: no browser hop found in the cast (gap ${hop.end - hop.start}s)`); |
| 119 | + process.exit(1); |
| 78 | 120 | } |
| 79 | | − return best; |
| 121 | + return [ |
| 122 | + { source: "terminal", from: 0, to: hop.start }, |
| 123 | + { source: "browser", from: 0, to: probeSeconds(browserPath) }, |
| 124 | + { source: "terminal", from: hop.end, to: castEnd }, |
| 125 | + ]; |
| 80 | 126 | }; |
| 81 | 127 | |
| 82 | | −const hop = findHop(); |
| 83 | | −if (hop.end - hop.start < 2) { |
| 84 | | − console.error(`film: no browser hop found in the cast (gap ${hop.end - hop.start}s)`); |
| 85 | | − process.exit(1); |
| 86 | | −} |
| 128 | +const acts = actsFromTimeline() ?? actsFromHeuristic(); |
| 87 | 129 | |
| 88 | 130 | // --------------------------------------------------------------------------- |
| 89 | | −// Render + cut + concatenate. One canvas (the browser video's size), every |
| 90 | | −// segment scaled/padded onto it — full-screen cuts, like tabbing. |
| 131 | +// Render + cut + concatenate onto one canvas — full-screen cuts, like |
| 132 | +// tabbing. |
| 91 | 133 | // --------------------------------------------------------------------------- |
| 92 | 134 | |
| 93 | 135 | const work = mkdtempSync(join(tmpdir(), "e2e-film-")); |
| 94 | 136 | const castGif = join(work, "cast.gif"); |
| 95 | 137 | const castVideo = join(work, "cast.mp4"); |
| 96 | 138 | |
| 97 | | −// No idle compression: cast video time must equal cast event time so the |
| 98 | | −// cut points land exactly. |
| 99 | 139 | run("agg", [ |
| 100 | | − "--idle-time-limit", |
| 101 | | − String(Math.ceil(castEnd) + 60), |
| 102 | | − "--font-size", |
| 103 | | − "16", |
| 140 | + ...["--idle-time-limit", String(Math.ceil(castEnd) + 60)], |
| 141 | + ...["--font-size", "16"], |
| 104 | 142 | castPath, |
| 105 | 143 | castGif, |
| 106 | 144 | ]); |
| @@ -111,21 +149,23 @@ run("ffmpeg", [ · 4 unmodified lines |
| 111 | 149 | ...["-pix_fmt", "yuv420p", "-r", "24", castVideo], |
| 112 | 150 | ]); |
| 113 | 151 | |
| 114 | | −const browserSeconds = probeSeconds(browserPath); |
| 115 | 152 | const FIT = |
| 116 | 153 | "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"; |
| 117 | 154 | |
| 118 | 155 | const filmPath = join(runDir, "film.mp4"); |
| 156 | +const inputIndex = { terminal: 0, browser: 1 } as const; |
| 157 | +const filters = acts.map( |
| 158 | + (act, index) => |
| 159 | + `[${inputIndex[act.source]}:v]trim=${act.from.toFixed(2)}:${act.to.toFixed(2)},setpts=PTS-STARTPTS,${FIT}[act${index}]`, |
| 160 | +); |
| 119 | 161 | run("ffmpeg", [ |
| 120 | 162 | "-y", |
| 121 | 163 | ...["-i", castVideo], |
| 122 | 164 | ...["-i", browserPath], |
| 123 | 165 | "-filter_complex", |
| 124 | 166 | [ |
| 125 | | − `[0:v]trim=0:${hop.start.toFixed(2)},setpts=PTS-STARTPTS,${FIT}[act1]`, |
| 126 | | − `[1:v]${FIT}[act2]`, |
| 127 | | − `[0:v]trim=${hop.end.toFixed(2)},setpts=PTS-STARTPTS,${FIT}[act3]`, |
| 128 | | − `[act1][act2][act3]concat=n=3:v=1:a=0[out]`, |
| 167 | + ...filters, |
| 168 | + `${acts.map((_, index) => `[act${index}]`).join("")}concat=n=${acts.length}:v=1:a=0[out]`, |
| 129 | 169 | ].join(";"), |
| 130 | 170 | ...["-map", "[out]"], |
| 131 | 171 | ...["-c:v", "libx264", "-preset", "veryfast", "-crf", "24", "-movflags", "+faststart"], |
| @@ -139,11 +179,15 @@ if (existsSync(resultPath)) { · 7 unmodified lines |
| 139 | 179 | const result = JSON.parse(readFileSync(resultPath, "utf8")) as { artifacts?: string[] }; |
| 140 | 180 | if (Array.isArray(result.artifacts) && !result.artifacts.includes("film.mp4")) { |
| 141 | 181 | result.artifacts.push("film.mp4"); |
| 142 | | − const { writeFileSync } = await import("node:fs"); |
| 143 | 182 | writeFileSync(resultPath, JSON.stringify(result, null, 1)); |
| 144 | 183 | } |
| 145 | 184 | } |
| 146 | 185 | |
| 147 | 186 | console.log( |
| 148 | | − `film: ${filmPath}\n act 1 terminal 0–${hop.start.toFixed(1)}s · act 2 browser ${browserSeconds.toFixed(1)}s · act 3 terminal ${(castEnd - hop.end).toFixed(1)}s`, |
| 187 | + `film: ${filmPath}\n${acts |
| 188 | + .map( |
| 189 | + (act, index) => |
| 190 | + ` act ${index + 1} ${act.source} ${act.from.toFixed(1)}–${act.to.toFixed(1)}s`, |
| 191 | + ) |
| 192 | + .join("\n")}`, |
| 149 | 193 | ); |