retake-demos 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +19 -0
- package/LICENSE +21 -0
- package/README.md +183 -0
- package/assets/icon-192.png +0 -0
- package/assets/icon-512.png +0 -0
- package/assets/logo.svg +13 -0
- package/demos/example.yaml +65 -0
- package/dist/appserver.js +316 -0
- package/dist/cli.js +467 -0
- package/dist/describe.js +377 -0
- package/dist/digest.js +197 -0
- package/dist/dryrun.js +196 -0
- package/dist/edits.js +229 -0
- package/dist/manifest.js +358 -0
- package/dist/operator/run.js +231 -0
- package/dist/operator/tools.js +412 -0
- package/dist/paths.js +39 -0
- package/dist/presets.js +75 -0
- package/dist/record.js +814 -0
- package/dist/render.js +661 -0
- package/dist/ui/chat.html +385 -0
- package/dist/ui/guide.html +85 -0
- package/dist/ui/index.html +722 -0
- package/dist/ui/server.js +1112 -0
- package/package.json +67 -0
- package/site/index.html +158 -0
- package/skill/SKILL.md +154 -0
package/dist/render.js
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render: the raw take → the artifacts you actually ship.
|
|
3
|
+
*
|
|
4
|
+
* master.mp4 one clean H.264 encode (CRF 14) of the composed video — keep this
|
|
5
|
+
* demo.mp4 the shareable, at the preset's CRF
|
|
6
|
+
* demo.gif gifski if installed (much better), else ffmpeg palette — secondary
|
|
7
|
+
* thumbnail.png the frame at a chosen scene
|
|
8
|
+
* proof-log.md what ran, when, whether it worked, the shot list, and the quality facts
|
|
9
|
+
*
|
|
10
|
+
* Composition happens once, in ffmpeg, from the take's timeline:
|
|
11
|
+
* fps → camera (zoompan toward each scene's focus box) → layout (band / card / overlay) → captions
|
|
12
|
+
* The card frame (rounded corners, shadow, soft background) is a PNG rendered
|
|
13
|
+
* by a headless page, overlaid with a transparent hole where the video shows.
|
|
14
|
+
*/
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { createHash } from "node:crypto";
|
|
19
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
20
|
+
import { capSecondsFor } from "./record.js";
|
|
21
|
+
import { createRequire } from "node:module";
|
|
22
|
+
import { chromium } from "playwright";
|
|
23
|
+
import { resolve } from "./manifest.js";
|
|
24
|
+
/** What the render depends on: the raw take + everything in the manifest that
|
|
25
|
+
shapes the picture. Same hash → same output → skip. */
|
|
26
|
+
export function renderHash(m, take) {
|
|
27
|
+
const q = resolve(m);
|
|
28
|
+
const src = take.video && fs.existsSync(take.video) ? fs.statSync(take.video) : null;
|
|
29
|
+
const h = createHash("sha1");
|
|
30
|
+
const merged = applyManifest(m, take);
|
|
31
|
+
h.update(JSON.stringify({ q, tempo: m.tempo, cap: m.captions, theme: m.theme, camera: m.camera, gif: m.outputs.gif, thumb: m.outputs.thumbnail, stills: m.outputs.stills, video: src ? [src.size, Math.round(src.mtimeMs)] : null, tl: merged.timeline.map((t) => [t.start, t.end, t.label, t.caption, t.holdMs, t.camera]), trim: [merged.trimBefore, merged.duration] }));
|
|
32
|
+
return h.digest("hex").slice(0, 12);
|
|
33
|
+
}
|
|
34
|
+
const require = createRequire(import.meta.url);
|
|
35
|
+
let cachedBin;
|
|
36
|
+
/** The static build first: Homebrew's ffmpeg is often built without freetype (no drawtext). */
|
|
37
|
+
export function ffmpegBin() {
|
|
38
|
+
if (cachedBin)
|
|
39
|
+
return cachedBin;
|
|
40
|
+
try {
|
|
41
|
+
const p = require("ffmpeg-static");
|
|
42
|
+
if (p && fs.existsSync(p))
|
|
43
|
+
return (cachedBin = p);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
/* fall through */
|
|
47
|
+
}
|
|
48
|
+
execFileSync("ffmpeg", ["-version"], { stdio: "ignore" });
|
|
49
|
+
return (cachedBin = "ffmpeg");
|
|
50
|
+
}
|
|
51
|
+
export function gifskiBin() {
|
|
52
|
+
for (const p of ["/opt/homebrew/bin/gifski", "/usr/local/bin/gifski"])
|
|
53
|
+
if (fs.existsSync(p))
|
|
54
|
+
return p;
|
|
55
|
+
try {
|
|
56
|
+
execFileSync("gifski", ["--version"], { stdio: "ignore" });
|
|
57
|
+
return "gifski";
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function ff(args, log) {
|
|
64
|
+
log?.(`$ ffmpeg ${args.join(" ")}`);
|
|
65
|
+
execFileSync(ffmpegBin(), ["-hide_banner", "-loglevel", "error", "-y", ...args], { stdio: "inherit" });
|
|
66
|
+
}
|
|
67
|
+
/** ffmpeg -i probe: duration and stream size, no ffprobe needed. */
|
|
68
|
+
export function probe(file) {
|
|
69
|
+
const out = String(spawnSync(ffmpegBin(), ["-hide_banner", "-i", file], { encoding: "utf8" }).stderr);
|
|
70
|
+
const d = /Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/.exec(out);
|
|
71
|
+
const v = /Video:.*?\s(\d{2,5})x(\d{2,5})[\s,].*?(\d+(?:\.\d+)?)\s*fps/.exec(out) ?? /Video:.*?\s(\d{2,5})x(\d{2,5})/.exec(out);
|
|
72
|
+
if (!d)
|
|
73
|
+
throw new Error(`could not read duration of ${file}`);
|
|
74
|
+
return {
|
|
75
|
+
duration: Number(d[1]) * 3600 + Number(d[2]) * 60 + Number(d[3]),
|
|
76
|
+
width: v ? Number(v[1]) : 0,
|
|
77
|
+
height: v ? Number(v[2]) : 0,
|
|
78
|
+
fps: v && v[3] ? Number(v[3]) : 0,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export const videoDuration = (file) => probe(file).duration;
|
|
82
|
+
/** Word-wrap long captions; two-line captions break near the middle so no
|
|
83
|
+
word is left orphaned on the second line. */
|
|
84
|
+
function wrap(text, max) {
|
|
85
|
+
if (text.length <= max)
|
|
86
|
+
return text;
|
|
87
|
+
const words = text.split(/\s+/);
|
|
88
|
+
if (text.length <= max * 2) {
|
|
89
|
+
let best = 1, bestDiff = Infinity;
|
|
90
|
+
for (let i = 1; i < words.length; i++) {
|
|
91
|
+
const a = words.slice(0, i).join(" ").length;
|
|
92
|
+
const b = words.slice(i).join(" ").length;
|
|
93
|
+
if (a <= max && b <= max && Math.abs(a - b) < bestDiff) {
|
|
94
|
+
best = i;
|
|
95
|
+
bestDiff = Math.abs(a - b);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return words.slice(0, best).join(" ") + "\n" + words.slice(best).join(" ");
|
|
99
|
+
}
|
|
100
|
+
const out = [];
|
|
101
|
+
let line = "";
|
|
102
|
+
for (const w of words) {
|
|
103
|
+
if (line && (line + " " + w).length > max) {
|
|
104
|
+
out.push(line);
|
|
105
|
+
line = w;
|
|
106
|
+
}
|
|
107
|
+
else
|
|
108
|
+
line = line ? line + " " + w : w;
|
|
109
|
+
}
|
|
110
|
+
if (line)
|
|
111
|
+
out.push(line);
|
|
112
|
+
return out.join("\n");
|
|
113
|
+
}
|
|
114
|
+
/** ffmpeg drawtext needs a few characters escaped inside the filter graph. */
|
|
115
|
+
function esc(s) {
|
|
116
|
+
return s.replace(/\\/g, "\\\\").replace(/'/g, "\u2019").replace(/:/g, "\\:").replace(/%/g, "\\%").replace(/,/g, "\\,");
|
|
117
|
+
}
|
|
118
|
+
const DEFAULT_FONTS = [
|
|
119
|
+
"/System/Library/Fonts/Supplemental/Arial.ttf",
|
|
120
|
+
"/System/Library/Fonts/Helvetica.ttc",
|
|
121
|
+
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
|
122
|
+
];
|
|
123
|
+
/** The take as the *current* manifest describes it: captions, holds, camera
|
|
124
|
+
zooms and trim can all be changed after the fact and re-rendered, because
|
|
125
|
+
none of them need the browser again. Scenes are matched by label. */
|
|
126
|
+
export function applyManifest(m, take) {
|
|
127
|
+
const bySceneLabel = new Map();
|
|
128
|
+
for (const st of m.steps)
|
|
129
|
+
if (st.action === "scene")
|
|
130
|
+
bySceneLabel.set(st.label, st);
|
|
131
|
+
const timeline = take.timeline.map((e) => {
|
|
132
|
+
if (e.action !== "scene" || !e.label)
|
|
133
|
+
return e;
|
|
134
|
+
const st = bySceneLabel.get(e.label);
|
|
135
|
+
if (!st)
|
|
136
|
+
return e;
|
|
137
|
+
const out = { ...e, caption: st.caption, holdMs: st.holdMs };
|
|
138
|
+
const cam = st.camera;
|
|
139
|
+
if (cam === "static")
|
|
140
|
+
out.camera = undefined;
|
|
141
|
+
else if (cam && typeof cam === "object" && cam.zoom && e.camera)
|
|
142
|
+
out.camera = { ...e.camera, zoom: cam.zoom };
|
|
143
|
+
return out;
|
|
144
|
+
});
|
|
145
|
+
const head = m.trim.head ?? 0;
|
|
146
|
+
const tail = m.trim.tail ?? 0;
|
|
147
|
+
const trimBefore = take.trimBefore + head;
|
|
148
|
+
const duration = Math.max(trimBefore + 1, take.duration - tail);
|
|
149
|
+
return { ...take, timeline, trimBefore, duration };
|
|
150
|
+
}
|
|
151
|
+
export function scenes(take) {
|
|
152
|
+
return take.timeline.filter((t) => t.action === "scene");
|
|
153
|
+
}
|
|
154
|
+
/** Caption windows in *trimmed* video time. */
|
|
155
|
+
export function captionWindows(take) {
|
|
156
|
+
const sc = scenes(take);
|
|
157
|
+
const end = take.duration - take.trimBefore;
|
|
158
|
+
return sc
|
|
159
|
+
.map((s, i) => {
|
|
160
|
+
if (!s.caption)
|
|
161
|
+
return null;
|
|
162
|
+
const from = Math.max(0, s.start - take.trimBefore);
|
|
163
|
+
const next = sc[i + 1] ? sc[i + 1].start - take.trimBefore : end;
|
|
164
|
+
const to = s.holdMs ? Math.min(next, from + s.holdMs / 1000) : next;
|
|
165
|
+
return { text: s.caption, from, to };
|
|
166
|
+
})
|
|
167
|
+
.filter((x) => !!x);
|
|
168
|
+
}
|
|
169
|
+
/** Piecewise-linear ffmpeg expression over `T` for one field of the keyframes. */
|
|
170
|
+
function pw(keys, f, T = "t") {
|
|
171
|
+
if (!keys.length)
|
|
172
|
+
return "1";
|
|
173
|
+
const v = (k) => k[f].toFixed(4);
|
|
174
|
+
let expr = v(keys[keys.length - 1]);
|
|
175
|
+
for (let i = keys.length - 1; i >= 1; i--) {
|
|
176
|
+
const a = keys[i - 1], b = keys[i];
|
|
177
|
+
const seg = b.t - a.t > 0.001 ? `(${v(a)}+(${v(b)}-${v(a)})*(${T}-${a.t.toFixed(3)})/${(b.t - a.t).toFixed(3)})` : v(b);
|
|
178
|
+
expr = `if(lt(${T},${b.t.toFixed(3)}),${seg},${expr})`;
|
|
179
|
+
}
|
|
180
|
+
return `if(lt(${T},${keys[0].t.toFixed(3)}),${v(keys[0])},${expr})`;
|
|
181
|
+
}
|
|
182
|
+
/** Build the zoompan filter for the scenes that carry a camera. Camera eases in
|
|
183
|
+
over 0.6s at each scene start and holds until the next scene. */
|
|
184
|
+
function cameraFilter(take, W, H, fps) {
|
|
185
|
+
const sc = scenes(take);
|
|
186
|
+
const keys = [];
|
|
187
|
+
const centre = { t: 0, z: 1, cx: W / 2, cy: H / 2 };
|
|
188
|
+
let cur = { ...centre };
|
|
189
|
+
let count = 0;
|
|
190
|
+
const EASE = 0.6;
|
|
191
|
+
for (const s of sc) {
|
|
192
|
+
const t0 = Math.max(0, s.start - take.trimBefore);
|
|
193
|
+
let target;
|
|
194
|
+
// A zoom may never crop the thing it points at: clamp so the focus box
|
|
195
|
+
// (plus a margin) fits the window. Full-width elements get no zoom.
|
|
196
|
+
const MARGIN = 28;
|
|
197
|
+
const fit = s.camera ? Math.min(s.camera.zoom, W / (s.camera.box.width + 2 * MARGIN), H / (s.camera.box.height + 2 * MARGIN)) : 1;
|
|
198
|
+
if (s.camera && fit >= 1.08) {
|
|
199
|
+
count++;
|
|
200
|
+
const b = s.camera.box;
|
|
201
|
+
const z = Math.round(fit * 100) / 100;
|
|
202
|
+
// Centre on the box, then clamp so the crop stays inside the frame.
|
|
203
|
+
const halfW = W / (2 * z), halfH = H / (2 * z);
|
|
204
|
+
const cx = Math.min(Math.max(b.x + b.width / 2, halfW), W - halfW);
|
|
205
|
+
const cy = Math.min(Math.max(b.y + b.height / 2, halfH), H - halfH);
|
|
206
|
+
target = { t: t0 + EASE, z, cx, cy };
|
|
207
|
+
s.camera.zoom = z; // record what was actually used (proof log)
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
if (s.camera)
|
|
211
|
+
s.camera = undefined; // focus fills the frame — no move
|
|
212
|
+
target = { ...centre, t: t0 + EASE };
|
|
213
|
+
}
|
|
214
|
+
keys.push({ ...cur, t: t0 });
|
|
215
|
+
keys.push(target);
|
|
216
|
+
cur = { ...target };
|
|
217
|
+
}
|
|
218
|
+
if (!count)
|
|
219
|
+
return { filter: "", count: 0 };
|
|
220
|
+
const T = `(in/${fps})`;
|
|
221
|
+
const z = pw(keys, "z", T);
|
|
222
|
+
const cx = pw(keys, "cx", T);
|
|
223
|
+
const cy = pw(keys, "cy", T);
|
|
224
|
+
const x = `max(0,min(iw-iw/(${z}),(${cx})-iw/(2*(${z}))))`;
|
|
225
|
+
const y = `max(0,min(ih-ih/(${z}),(${cy})-ih/(2*(${z}))))`;
|
|
226
|
+
return { filter: `zoompan=z='${z}':x='${x}':y='${y}':d=1:s=${W}x${H}:fps=${fps}`, count };
|
|
227
|
+
}
|
|
228
|
+
function cardGeometry(W, H, SW, SH, bandHeight) {
|
|
229
|
+
const ms = Math.round(W * 0.055);
|
|
230
|
+
const mt = Math.round(H * 0.06);
|
|
231
|
+
const mb = Math.round(H * 0.035);
|
|
232
|
+
const aw = W - 2 * ms;
|
|
233
|
+
const ah = H - mt - bandHeight - mb;
|
|
234
|
+
let vw = aw, vh = Math.round((aw * SH) / SW);
|
|
235
|
+
if (vh > ah) {
|
|
236
|
+
vh = ah;
|
|
237
|
+
vw = Math.round((ah * SW) / SH);
|
|
238
|
+
}
|
|
239
|
+
vw -= vw % 2;
|
|
240
|
+
vh -= vh % 2;
|
|
241
|
+
const vx = Math.round((W - vw) / 2), vy = mt;
|
|
242
|
+
return { vx, vy, vw, vh, bandY: vy + vh + mb };
|
|
243
|
+
}
|
|
244
|
+
/** A PNG the size of the canvas: soft background everywhere, a rounded
|
|
245
|
+
transparent hole where the video sits, and a shadow around it. */
|
|
246
|
+
async function renderCardFrame(file, W, H, g, bg) {
|
|
247
|
+
const html = `<!doctype html><body style="margin:0;background:transparent"><canvas id=c width=${W} height=${H}></canvas><script>
|
|
248
|
+
const c=document.getElementById('c'),x=c.getContext('2d');
|
|
249
|
+
const r=${Math.round(Math.min(g.vw, g.vh) * 0.022)};
|
|
250
|
+
const rr=(X,Y,w,h,R)=>{x.beginPath();x.moveTo(X+R,Y);x.arcTo(X+w,Y,X+w,Y+h,R);x.arcTo(X+w,Y+h,X,Y+h,R);x.arcTo(X,Y+h,X,Y,R);x.arcTo(X,Y,X+w,Y,R);x.closePath();};
|
|
251
|
+
x.fillStyle='${bg}';x.fillRect(0,0,${W},${H});
|
|
252
|
+
x.save();x.shadowColor='rgba(20,30,20,0.28)';x.shadowBlur=${Math.round(H * 0.045)};x.shadowOffsetY=${Math.round(H * 0.012)};x.fillStyle='#000';rr(${g.vx},${g.vy},${g.vw},${g.vh},r);x.fill();x.restore();
|
|
253
|
+
x.globalCompositeOperation='destination-out';rr(${g.vx},${g.vy},${g.vw},${g.vh},r);x.fill();
|
|
254
|
+
x.globalCompositeOperation='source-over';x.strokeStyle='rgba(0,0,0,0.10)';x.lineWidth=1;rr(${g.vx}+0.5,${g.vy}+0.5,${g.vw}-1,${g.vh}-1,r);x.stroke();
|
|
255
|
+
</script></body>`;
|
|
256
|
+
const browser = await chromium.launch();
|
|
257
|
+
try {
|
|
258
|
+
const page = await browser.newPage({ viewport: { width: W, height: H } });
|
|
259
|
+
await page.setContent(html, { waitUntil: "load" });
|
|
260
|
+
await page.waitForTimeout(80);
|
|
261
|
+
await page.screenshot({ path: file, omitBackground: true, clip: { x: 0, y: 0, width: W, height: H } });
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
await browser.close();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
// --- render ------------------------------------------------------------------
|
|
268
|
+
export async function render(m, take, outDir, opts = {}) {
|
|
269
|
+
const o = typeof opts === "function" ? { log: opts } : opts;
|
|
270
|
+
const log = o.log;
|
|
271
|
+
const q = resolve(m);
|
|
272
|
+
const proofLog = path.join(outDir, "proof-log.md");
|
|
273
|
+
const writeProof = (a) => fs.writeFileSync(proofLog, renderProof(m, take, q, a));
|
|
274
|
+
if (!o.scene)
|
|
275
|
+
writeProof({});
|
|
276
|
+
if (!take.video)
|
|
277
|
+
return { proofLog };
|
|
278
|
+
const srcVideo = take.video;
|
|
279
|
+
// Cache: nothing that shapes the picture changed → keep what's there.
|
|
280
|
+
const hash = renderHash(m, take);
|
|
281
|
+
const factsPath = path.join(outDir, "facts.json");
|
|
282
|
+
if (!o.force && !o.scene && fs.existsSync(factsPath) && fs.existsSync(path.join(outDir, "demo.mp4"))) {
|
|
283
|
+
try {
|
|
284
|
+
const prev = JSON.parse(fs.readFileSync(factsPath, "utf8"));
|
|
285
|
+
if (prev.renderHash === hash) {
|
|
286
|
+
log?.(`render: unchanged (${hash}) — reusing demo.mp4`);
|
|
287
|
+
const a = { mp4: path.join(outDir, "demo.mp4"), proofLog, facts: { ...prev, cached: true } };
|
|
288
|
+
for (const [k, f] of [["master", "master.mp4"], ["gif", "demo.gif"], ["thumbnail", "thumbnail.png"]])
|
|
289
|
+
if (fs.existsSync(path.join(outDir, f)))
|
|
290
|
+
a[k] = path.join(outDir, f);
|
|
291
|
+
writeProof(a);
|
|
292
|
+
return a;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
catch { /* re-render */ }
|
|
296
|
+
}
|
|
297
|
+
// Captions, holds, camera zooms and trim come from the manifest as it is now.
|
|
298
|
+
take = applyManifest(m, take);
|
|
299
|
+
// Tempo: the source is cut in its own time (-ss/-t below), then every
|
|
300
|
+
// clock downstream — captions, camera keys, stills — runs in output time.
|
|
301
|
+
const srcTake = take;
|
|
302
|
+
const tempo = m.tempo ?? 1;
|
|
303
|
+
if (tempo !== 1) {
|
|
304
|
+
const k = 1 / tempo;
|
|
305
|
+
take = { ...take, trimBefore: take.trimBefore * k, duration: take.duration * k, timeline: take.timeline.map((e) => ({ ...e, start: e.start * k, end: e.end * k })) };
|
|
306
|
+
}
|
|
307
|
+
const t = { start: Date.now(), marks: {} };
|
|
308
|
+
const mark = (k) => { const now = Date.now(); t.marks[k] = Math.round((now - t.start) / 100) / 10; t.start = now; };
|
|
309
|
+
// Scene-only render: trim to that scene's window.
|
|
310
|
+
if (o.scene) {
|
|
311
|
+
const sc = scenes(take);
|
|
312
|
+
const i = sc.findIndex((s) => s.label === o.scene);
|
|
313
|
+
if (i < 0)
|
|
314
|
+
throw new Error(`scene "${o.scene}" not found (${sc.map((s) => s.label).join(", ")})`);
|
|
315
|
+
const from = sc[i].start;
|
|
316
|
+
const to = sc[i + 1] ? sc[i + 1].start : take.duration;
|
|
317
|
+
take = { ...take, trimBefore: from, duration: to };
|
|
318
|
+
}
|
|
319
|
+
const src = path.resolve(srcVideo);
|
|
320
|
+
// Source (recorded viewport) vs canvas (preset output). They differ when a
|
|
321
|
+
// narrow app was recorded at its own aspect and gets centred on the canvas.
|
|
322
|
+
const SW = take.quality?.width ?? q.viewport.width;
|
|
323
|
+
const SH = take.quality?.height ?? q.viewport.height;
|
|
324
|
+
// band/overlay/none: the canvas IS the recording (site fills the frame).
|
|
325
|
+
// card: the preset canvas, with the recording framed inside it.
|
|
326
|
+
const W = q.layout === "card" ? q.width : SW;
|
|
327
|
+
const H = q.layout === "card" ? q.height : SH;
|
|
328
|
+
const fps = q.fps;
|
|
329
|
+
const bg = q.theme.background;
|
|
330
|
+
const ink = q.theme.ink;
|
|
331
|
+
const fontFile = DEFAULT_FONTS.find((f) => fs.existsSync(f));
|
|
332
|
+
const font = fontFile ? `fontfile='${fontFile}':` : "";
|
|
333
|
+
const layout = q.layout;
|
|
334
|
+
// --- 1. compose master.mp4 -----------------------------------------------
|
|
335
|
+
const filters = [...(tempo !== 1 ? [`setpts=PTS/${tempo}`] : []), `fps=${fps}`];
|
|
336
|
+
// Scenes decided their cameras at record time (manifest policy + per-scene
|
|
337
|
+
// overrides); render just honours whatever boxes were captured.
|
|
338
|
+
const cam = cameraFilter(take, SW, SH, fps);
|
|
339
|
+
if (cam.filter)
|
|
340
|
+
filters.push(cam.filter);
|
|
341
|
+
let capY = "";
|
|
342
|
+
let capColor = q.captions ? q.captions.color ?? ink : ink;
|
|
343
|
+
let capBox = "";
|
|
344
|
+
let frameInput = null;
|
|
345
|
+
let geom = null;
|
|
346
|
+
const capSize = q.captions ? q.captions.fontSize : 0;
|
|
347
|
+
if (layout === "band") {
|
|
348
|
+
// Site fills the frame; caption strip below it.
|
|
349
|
+
filters.push(`pad=${W}:${H + q.bandHeight}:0:0:color=${bg}`);
|
|
350
|
+
capY = `${H}+${q.bandHeight / 2}-th/2`;
|
|
351
|
+
}
|
|
352
|
+
else if (layout === "card") {
|
|
353
|
+
geom = cardGeometry(W, H, SW, SH, q.bandHeight);
|
|
354
|
+
frameInput = path.join(outDir, ".frame.png");
|
|
355
|
+
await renderCardFrame(frameInput, W, H, geom, bg);
|
|
356
|
+
filters.push(`scale=${geom.vw}:${geom.vh}:flags=lanczos`, `pad=${W}:${H}:${geom.vx}:${geom.vy}:color=${bg}`);
|
|
357
|
+
capY = `${geom.bandY}+(${H - geom.bandY})/2-th/2`;
|
|
358
|
+
}
|
|
359
|
+
else if (layout === "overlay-bottom" || layout === "overlay-top") {
|
|
360
|
+
capY = layout === "overlay-top" ? "h*0.06" : "h-th-h*0.07";
|
|
361
|
+
capColor = q.captions ? q.captions.color ?? "white" : "white";
|
|
362
|
+
capBox = ":box=1:boxcolor=black@0.55:boxborderw=18";
|
|
363
|
+
}
|
|
364
|
+
filters.push("format=yuv420p");
|
|
365
|
+
const captionFilters = [];
|
|
366
|
+
if (q.captions && layout !== "none") {
|
|
367
|
+
const maxChars = Math.max(28, Math.round((W / capSize) * 1.55));
|
|
368
|
+
for (const w of captionWindows(take)) {
|
|
369
|
+
captionFilters.push(`drawtext=${font}text='${esc(wrap(w.text, maxChars))}':fontsize=${capSize}:fontcolor=${capColor}${capBox}:line_spacing=${Math.round(capSize * 0.25)}` +
|
|
370
|
+
`:x=(w-tw)/2:y=${capY}:enable='between(t,${w.from.toFixed(2)},${w.to.toFixed(2)})'`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const graph = frameInput
|
|
374
|
+
? `[0:v]${filters.join(",")}[v];[v][1:v]overlay=0:0:format=auto${captionFilters.length ? "," + captionFilters.join(",") : ""}[out]`
|
|
375
|
+
: `[0:v]${[...filters, ...captionFilters].join(",")}[out]`;
|
|
376
|
+
const inputs = ["-ss", srcTake.trimBefore.toFixed(3), "-t", (srcTake.duration - srcTake.trimBefore).toFixed(3), "-i", src, ...(frameInput ? ["-i", frameInput] : [])];
|
|
377
|
+
const compose = ["-filter_complex", graph, "-map", "[out]", "-r", String(fps), "-an", "-pix_fmt", "yuv420p", "-movflags", "+faststart"];
|
|
378
|
+
const x264 = (crf, preset) => ["-c:v", "libx264", "-crf", String(crf), "-preset", preset];
|
|
379
|
+
// Hardware encode is a macOS thing; elsewhere the "fast" presets are a fast x264.
|
|
380
|
+
const vt = process.platform === "darwin"
|
|
381
|
+
? ["-c:v", "h264_videotoolbox", "-b:v", `${Math.max(4, Math.round((W * H * fps * 0.15) / 1e6))}M`, "-allow_sw", "1"] // ~0.15 bit/px → ~9 Mbit/s at 1080p30
|
|
382
|
+
: x264(20, "veryfast");
|
|
383
|
+
const mp4 = path.join(outDir, o.scene ? `scene-${o.scene}.mp4` : "demo.mp4");
|
|
384
|
+
let master;
|
|
385
|
+
if (o.scene || q.encoder === "videotoolbox") {
|
|
386
|
+
// One pass, hardware: compose straight into the deliverable.
|
|
387
|
+
ff([...inputs, ...compose, ...vt, mp4], log);
|
|
388
|
+
mark("compose+encode");
|
|
389
|
+
}
|
|
390
|
+
else if (q.master) {
|
|
391
|
+
// Compose once into a CRF-14 master, then derive the deliverable.
|
|
392
|
+
master = path.join(outDir, "master.mp4");
|
|
393
|
+
ff([...inputs, ...compose, ...x264(14, "fast"), master], log);
|
|
394
|
+
mark("compose");
|
|
395
|
+
ff(["-i", master, ...x264(q.crf, "medium"), "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-an", mp4], log);
|
|
396
|
+
mark("encode");
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
ff([...inputs, ...compose, ...x264(q.crf, "medium"), mp4], log);
|
|
400
|
+
mark("compose+encode");
|
|
401
|
+
}
|
|
402
|
+
if (frameInput)
|
|
403
|
+
fs.rmSync(frameInput, { force: true });
|
|
404
|
+
if (o.scene)
|
|
405
|
+
return { mp4, proofLog };
|
|
406
|
+
// --- 3. GIF (secondary, opt-in) --------------------------------------------
|
|
407
|
+
let gif;
|
|
408
|
+
let gifTool = "none";
|
|
409
|
+
fs.rmSync(path.join(outDir, "demo.gif"), { force: true });
|
|
410
|
+
if (q.gif) {
|
|
411
|
+
gif = path.join(outDir, "demo.gif");
|
|
412
|
+
const gk = gifskiBin();
|
|
413
|
+
if (gk) {
|
|
414
|
+
const frames = fs.mkdtempSync(path.join(os.tmpdir(), "retake-gif-"));
|
|
415
|
+
try {
|
|
416
|
+
ff(["-i", mp4, "-vf", `fps=${q.gif.fps},scale=${q.gif.width}:-2:flags=lanczos`, path.join(frames, "f%05d.png")], log);
|
|
417
|
+
log?.(`$ gifski --fps ${q.gif.fps} --quality 90 -o demo.gif frames/*.png`);
|
|
418
|
+
const files = fs.readdirSync(frames).filter((f) => f.endsWith(".png")).sort().map((f) => path.join(frames, f));
|
|
419
|
+
execFileSync(gk, ["--fps", String(q.gif.fps), "--quality", "90", "--width", String(q.gif.width), "-o", gif, ...files], { stdio: "ignore" });
|
|
420
|
+
gifTool = "gifski";
|
|
421
|
+
}
|
|
422
|
+
finally {
|
|
423
|
+
fs.rmSync(frames, { recursive: true, force: true });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
ff(["-i", mp4, "-vf", `fps=${q.gif.fps},scale=${q.gif.width}:-2:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=192:stats_mode=diff[p];[s1][p]paletteuse=dither=sierra2_4a`, "-loop", "0", gif], log);
|
|
428
|
+
gifTool = "ffmpeg";
|
|
429
|
+
}
|
|
430
|
+
mark("gif");
|
|
431
|
+
}
|
|
432
|
+
// --- 4. thumbnail ----------------------------------------------------------
|
|
433
|
+
let thumbnail;
|
|
434
|
+
if (m.outputs.thumbnail) {
|
|
435
|
+
const sc = scenes(take);
|
|
436
|
+
let at;
|
|
437
|
+
if (typeof m.outputs.thumbnail === "object") {
|
|
438
|
+
const want = m.outputs.thumbnail.scene;
|
|
439
|
+
const s = sc.find((x) => x.label === want);
|
|
440
|
+
// On a take that stopped early the chosen scene may never have happened;
|
|
441
|
+
// fall back to the last scene that did rather than failing the render.
|
|
442
|
+
const pick = s ?? sc.at(-1);
|
|
443
|
+
at = pick ? pick.start - take.trimBefore + 1.0 : Math.max(0, take.duration - take.trimBefore - 0.5);
|
|
444
|
+
if (!s)
|
|
445
|
+
log?.(`thumbnail: scene "${want}" never happened — using ${pick ? `"${pick.label}"` : "the end"}`);
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
const last = sc.at(-1);
|
|
449
|
+
at = last ? last.start - take.trimBefore + 1.0 : Math.max(0, take.duration - take.trimBefore - 0.5);
|
|
450
|
+
}
|
|
451
|
+
thumbnail = path.join(outDir, "thumbnail.png");
|
|
452
|
+
ff(["-ss", Math.max(0, at).toFixed(2), "-i", mp4, "-frames:v", "1", thumbnail], log);
|
|
453
|
+
mark("thumbnail");
|
|
454
|
+
}
|
|
455
|
+
// --- 5. scene stills -------------------------------------------------------
|
|
456
|
+
// Every scene already carries its real timestamp, so one crisp frame per
|
|
457
|
+
// scene is free — and a video usually ships with a picture next to it.
|
|
458
|
+
let stills = [];
|
|
459
|
+
if (m.outputs.stills) {
|
|
460
|
+
const dir = path.join(outDir, "stills");
|
|
461
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
462
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
463
|
+
const sc = scenes(take);
|
|
464
|
+
const end = take.duration - take.trimBefore;
|
|
465
|
+
for (const [i, sce] of sc.entries()) {
|
|
466
|
+
// The scene's midpoint: at the start the click hasn't landed, at the
|
|
467
|
+
// end the scene may already be leaving (a save, a reset, a navigation).
|
|
468
|
+
const next = sc[i + 1] ? sc[i + 1].start - take.trimBefore : end;
|
|
469
|
+
const from = sce.start - take.trimBefore;
|
|
470
|
+
const at = Math.min(Math.max(0, from + Math.max(0.8, (next - from) / 2)), Math.max(0, end - 0.2));
|
|
471
|
+
const base = `${String(i + 1).padStart(2, "0")}-${(sce.label || "scene").replace(/[^a-z0-9-]+/gi, "-")}`;
|
|
472
|
+
const file = path.join(dir, `${base}.png`);
|
|
473
|
+
ff(["-ss", at.toFixed(2), "-i", mp4, "-frames:v", "1", file], log);
|
|
474
|
+
stills.push(file);
|
|
475
|
+
// And the scene's last moment: the caption names what the scene
|
|
476
|
+
// achieves, and that is usually on screen at the end, not the middle.
|
|
477
|
+
const atEnd = Math.min(Math.max(from + 0.4, next - 0.6), Math.max(0, end - 0.2));
|
|
478
|
+
if (atEnd - at > 0.5) {
|
|
479
|
+
const fe = path.join(dir, `${base}-end.png`);
|
|
480
|
+
ff(["-ss", atEnd.toFixed(2), "-i", mp4, "-frames:v", "1", fe], log);
|
|
481
|
+
stills.push(fe);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
if (sc.length)
|
|
485
|
+
mark(`stills ×${sc.length}`);
|
|
486
|
+
}
|
|
487
|
+
if (!master)
|
|
488
|
+
fs.rmSync(path.join(outDir, "master.mp4"), { force: true });
|
|
489
|
+
const p = probe(mp4);
|
|
490
|
+
const facts = {
|
|
491
|
+
width: p.width, height: p.height, fps: p.fps || fps, duration: p.duration,
|
|
492
|
+
sizes: Object.fromEntries([master, mp4, gif, thumbnail].filter((f) => !!f).map((f) => [path.basename(f), fs.statSync(f).size])),
|
|
493
|
+
gifTool, layout, cameraScenes: cam.count, encoder: q.encoder, timings: t.marks, renderHash: hash, preset: q.name,
|
|
494
|
+
};
|
|
495
|
+
const a = { master, mp4, gif, thumbnail, proofLog, facts };
|
|
496
|
+
writeProof(a);
|
|
497
|
+
fs.writeFileSync(factsPath, JSON.stringify({ ...facts, check: check(outDir, m) }, null, 2));
|
|
498
|
+
return a;
|
|
499
|
+
}
|
|
500
|
+
/** GIF straight from the finished demo.mp4 — no re-render, no browser. */
|
|
501
|
+
export function makeGif(outDir, width = 900, fps = 18, log) {
|
|
502
|
+
const mp4 = path.join(outDir, "demo.mp4");
|
|
503
|
+
if (!fs.existsSync(mp4))
|
|
504
|
+
throw new Error("no demo.mp4 to make a GIF from — run the demo first");
|
|
505
|
+
const gif = path.join(outDir, "demo.gif");
|
|
506
|
+
const gk = gifskiBin();
|
|
507
|
+
if (gk) {
|
|
508
|
+
const frames = fs.mkdtempSync(path.join(os.tmpdir(), "retake-gif-"));
|
|
509
|
+
try {
|
|
510
|
+
ff(["-i", mp4, "-vf", `fps=${fps},scale=${width}:-2:flags=lanczos`, path.join(frames, "f%05d.png")], log);
|
|
511
|
+
const files = fs.readdirSync(frames).filter((f) => f.endsWith(".png")).sort().map((f) => path.join(frames, f));
|
|
512
|
+
execFileSync(gk, ["--fps", String(fps), "--quality", "90", "--width", String(width), "-o", gif, ...files], { stdio: "ignore" });
|
|
513
|
+
}
|
|
514
|
+
finally {
|
|
515
|
+
fs.rmSync(frames, { recursive: true, force: true });
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
ff(["-i", mp4, "-vf", `fps=${fps},scale=${width}:-2:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=192:stats_mode=diff[p];[s1][p]paletteuse=dither=sierra2_4a`, "-loop", "0", gif], log);
|
|
520
|
+
}
|
|
521
|
+
const factsPath = path.join(outDir, "facts.json");
|
|
522
|
+
if (fs.existsSync(factsPath)) {
|
|
523
|
+
try {
|
|
524
|
+
const f = JSON.parse(fs.readFileSync(factsPath, "utf8"));
|
|
525
|
+
f.sizes = { ...f.sizes, "demo.gif": fs.statSync(gif).size };
|
|
526
|
+
f.gifTool = gk ? "gifski" : "ffmpeg";
|
|
527
|
+
fs.writeFileSync(factsPath, JSON.stringify(f, null, 2));
|
|
528
|
+
}
|
|
529
|
+
catch { /* ignore */ }
|
|
530
|
+
}
|
|
531
|
+
return gif;
|
|
532
|
+
}
|
|
533
|
+
/** Did the folder come out post-worthy? Pass/fail per fact, no opinions. */
|
|
534
|
+
export function check(outDir, m) {
|
|
535
|
+
const lines = [];
|
|
536
|
+
let ok = true;
|
|
537
|
+
const say = (pass, text) => { lines.push(`${pass ? "pass" : "FAIL"} ${text}`); if (!pass)
|
|
538
|
+
ok = false; };
|
|
539
|
+
const takePath = path.join(outDir, "take.json");
|
|
540
|
+
if (!fs.existsSync(takePath))
|
|
541
|
+
return { ok: false, lines: ["FAIL no take.json"] };
|
|
542
|
+
const take = JSON.parse(fs.readFileSync(takePath, "utf8"));
|
|
543
|
+
// The take knows which preset it was recorded with; a `--preset` on the run
|
|
544
|
+
// must win over the manifest's default, or a draft fails for lacking a master.
|
|
545
|
+
const q = m ? resolve(take.quality?.preset && take.quality.preset !== m.preset ? { ...m, preset: take.quality.preset } : m) : null;
|
|
546
|
+
const mp4 = path.join(outDir, "demo.mp4");
|
|
547
|
+
if (!fs.existsSync(mp4))
|
|
548
|
+
return { ok: false, lines: ["FAIL demo.mp4 missing"] };
|
|
549
|
+
const p = probe(mp4);
|
|
550
|
+
const wantW = q ? (q.layout === "card" ? q.width : (take.quality?.width ?? q.viewport.width)) : undefined;
|
|
551
|
+
say(true, `resolution: ${p.width}×${p.height}`);
|
|
552
|
+
if (wantW)
|
|
553
|
+
say(p.width === wantW, `matches expected width ${wantW}`);
|
|
554
|
+
say(p.width >= 960, `at least 960px wide`);
|
|
555
|
+
say(true, `fps: ${p.fps}`);
|
|
556
|
+
/* A take may legitimately run long — a full product walkthrough does.
|
|
557
|
+
The manifest's own maxSeconds is the author's stated bound; a take
|
|
558
|
+
inside it is not a runaway, so do not fail it for length. */
|
|
559
|
+
const longest = m ? capSecondsFor(m) : 300;
|
|
560
|
+
say(p.duration >= 5 && p.duration <= longest, `duration: ${p.duration.toFixed(1)}s`);
|
|
561
|
+
if (p.duration > 60)
|
|
562
|
+
lines.push(`— ${p.duration.toFixed(0)}s is long for a social post (aim for 15–45s); fine for a walkthrough`);
|
|
563
|
+
/* A step that stalls is not a failed step, so nothing above notices it — but
|
|
564
|
+
the camera keeps rolling on the last painted frame and the viewer watches
|
|
565
|
+
a still image for the duration. One 46s navigation shipped in a take whose
|
|
566
|
+
other seven navigations took under four seconds; every check passed.
|
|
567
|
+
`wait` and `scene` are excluded because their whole job is to take time. */
|
|
568
|
+
const PATIENCE = 8;
|
|
569
|
+
const stalled = (take.timeline || [])
|
|
570
|
+
.filter((t) => t.action !== "wait" && t.action !== "scene" && t.action !== "stub")
|
|
571
|
+
.map((t) => ({ ...t, took: t.end - t.start }))
|
|
572
|
+
.filter((t) => t.took > PATIENCE)
|
|
573
|
+
.sort((a, b) => b.took - a.took);
|
|
574
|
+
if (stalled.length) {
|
|
575
|
+
const worst = stalled[0];
|
|
576
|
+
say(false, `${stalled.length} step${stalled.length > 1 ? "s" : ""} stalled over ${PATIENCE}s — ` +
|
|
577
|
+
`the take holds a still frame there. Worst: ${worst.took.toFixed(1)}s at ` +
|
|
578
|
+
`${Math.floor(worst.start / 60)}:${String(Math.floor(worst.start % 60)).padStart(2, "0")} — ${worst.summary}`);
|
|
579
|
+
for (const t of stalled.slice(1, 4)) {
|
|
580
|
+
lines.push(`— also ${t.took.toFixed(1)}s: ${t.summary}`);
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
else {
|
|
584
|
+
say(true, `no step stalled (nothing over ${PATIENCE}s)`);
|
|
585
|
+
}
|
|
586
|
+
const mb = (f) => (fs.statSync(f).size / 1e6).toFixed(1) + " MB";
|
|
587
|
+
say(true, `demo.mp4: ${mb(mp4)}`);
|
|
588
|
+
const master = path.join(outDir, "master.mp4");
|
|
589
|
+
if (q?.master)
|
|
590
|
+
say(fs.existsSync(master), `master.mp4 kept${fs.existsSync(master) ? ` (${mb(master)})` : ""}`);
|
|
591
|
+
else
|
|
592
|
+
lines.push(`— master.mp4: not part of this preset`);
|
|
593
|
+
const gif = path.join(outDir, "demo.gif");
|
|
594
|
+
if (fs.existsSync(gif))
|
|
595
|
+
say(true, `demo.gif: ${mb(gif)} (secondary)`);
|
|
596
|
+
else
|
|
597
|
+
lines.push(`— demo.gif: not requested`);
|
|
598
|
+
if (!m || m.outputs.thumbnail)
|
|
599
|
+
say(fs.existsSync(path.join(outDir, "thumbnail.png")), "thumbnail exists");
|
|
600
|
+
else
|
|
601
|
+
lines.push("— thumbnail: not requested");
|
|
602
|
+
say(fs.existsSync(path.join(outDir, "proof-log.md")), "proof log exists");
|
|
603
|
+
say(take.ok, take.ok ? "all steps passed" : "some steps failed");
|
|
604
|
+
say(!take.partial, take.partial ? `partial: ${take.partial}` : "polished render (not fallback)");
|
|
605
|
+
if (take.partial && /NO CURSOR/.test(take.partial))
|
|
606
|
+
say(false, "cursor overlay failed — the video has no cursor (split the demo or set cursor: false)");
|
|
607
|
+
const sc = scenes(m ? applyManifest(m, take) : take);
|
|
608
|
+
say(sc.length >= 2, `${sc.length} scenes`);
|
|
609
|
+
const cams = sc.filter((s) => s.camera).length;
|
|
610
|
+
lines.push(`— camera on ${cams}/${sc.length} scenes`);
|
|
611
|
+
return { ok, lines };
|
|
612
|
+
}
|
|
613
|
+
// --- proof log ---------------------------------------------------------------
|
|
614
|
+
function fmt(s) {
|
|
615
|
+
const m = Math.floor(s / 60);
|
|
616
|
+
const r = s - m * 60;
|
|
617
|
+
return `${m}:${r.toFixed(1).padStart(4, "0")}`;
|
|
618
|
+
}
|
|
619
|
+
function renderProof(m, take, q, a) {
|
|
620
|
+
const rows = take.timeline.map((t) => `| ${t.index} | ${t.ok ? "✓" : "✗"} | ${fmt(t.start - take.trimBefore)} | ${fmt(t.end - take.trimBefore)} | ${t.summary.replace(/\|/g, "\\|")}${t.camera ? ` · camera ${t.camera.zoom}× → ${t.camera.focus}` : ""}${t.error ? ` — _${t.error}_` : ""} |`);
|
|
621
|
+
const f = a.facts;
|
|
622
|
+
const size = (k) => {
|
|
623
|
+
const file = a[k];
|
|
624
|
+
if (!file)
|
|
625
|
+
return "—";
|
|
626
|
+
const b = path.basename(file);
|
|
627
|
+
const n = f?.sizes[b];
|
|
628
|
+
return `${b}${n ? ` (${(n / 1e6).toFixed(1)} MB)` : ""}`;
|
|
629
|
+
};
|
|
630
|
+
return [
|
|
631
|
+
`# Proof log — ${m.name}`,
|
|
632
|
+
"",
|
|
633
|
+
`- URL: ${m.url}`,
|
|
634
|
+
`- Preset: ${q.name} · canvas ${q.width}×${q.height} · viewport ${take.quality?.width ?? q.viewport.width}×${take.quality?.height ?? q.viewport.height} @ ${q.fps}fps · page scale ${q.scale}× · layout ${q.layout}`,
|
|
635
|
+
`- Started: ${take.startedAt}`,
|
|
636
|
+
`- Finished: ${take.finishedAt}`,
|
|
637
|
+
`- Result: **${take.ok ? "all steps passed" : "some steps failed"}**${take.partial ? ` · **partial** — ${take.partial}` : ""}`,
|
|
638
|
+
`- Setup trimmed: ${take.trimBefore.toFixed(1)}s · final length ≈ ${(take.duration - take.trimBefore).toFixed(1)}s`,
|
|
639
|
+
...(take.stubbed?.length ? [`- **Stubbed data**: ${take.stubbed.join(", ")} — these screens were fed canned responses, not a live backend.`] : []),
|
|
640
|
+
"",
|
|
641
|
+
"## Artifacts",
|
|
642
|
+
"",
|
|
643
|
+
`- master: ${size("master")}`,
|
|
644
|
+
`- mp4: ${size("mp4")}`,
|
|
645
|
+
`- gif: ${size("gif")}`,
|
|
646
|
+
`- thumbnail: ${size("thumbnail")}`,
|
|
647
|
+
`- stills: per scene in stills/ — NN-label.png is the MIDDLE of the scene, NN-label-end.png its last moment (the payoff). Judge 'did it happen' from -end, 'what was it doing' from the middle.`,
|
|
648
|
+
...(f ? [`- output: ${f.width}×${f.height} @ ${f.fps}fps · ${f.duration.toFixed(1)}s · ${f.encoder} · gif via ${f.gifTool} · camera on ${f.cameraScenes} scenes${f.cached ? " · cached" : ""}`, `- render time: ${Object.entries(f.timings ?? {}).map(([k, v]) => `${k} ${v}s`).join(" · ") || "—"}`] : []),
|
|
649
|
+
"",
|
|
650
|
+
"## Shot list",
|
|
651
|
+
"",
|
|
652
|
+
...scenes(take).map((s) => `- **${fmt(s.start - take.trimBefore)}** ${s.label}${s.caption ? ` — “${s.caption}”` : ""}`),
|
|
653
|
+
"",
|
|
654
|
+
"## Timeline",
|
|
655
|
+
"",
|
|
656
|
+
"| # | ok | start | end | step |",
|
|
657
|
+
"|---|----|-------|-----|------|",
|
|
658
|
+
...rows,
|
|
659
|
+
"",
|
|
660
|
+
].join("\n");
|
|
661
|
+
}
|