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