retake-demos 0.2.2 → 0.4.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 +10 -0
- package/AGENTS.md +277 -0
- package/README.md +63 -22
- package/dist/captions.js +55 -0
- package/dist/cards.js +177 -0
- package/dist/cli.js +300 -15
- package/dist/describe.js +30 -10
- package/dist/dryrun.js +185 -2
- package/dist/env.js +76 -0
- package/dist/manifest.js +178 -10
- package/dist/operator/run.js +4 -0
- package/dist/operator/tools.js +111 -13
- package/dist/pace.js +99 -0
- package/dist/presets.js +7 -7
- package/dist/record.js +475 -46
- package/dist/render.js +427 -64
- package/dist/tidy.js +103 -0
- package/dist/ui/chat.html +301 -43
- package/dist/ui/guide.html +2 -1
- package/dist/ui/server.js +172 -36
- package/dist/voice.js +36 -0
- package/llms.txt +11 -0
- package/package.json +5 -3
- package/site/releases.json +45 -0
- package/skill/SKILL.md +272 -8
- package/demos/example.yaml +0 -65
- package/dist/ui/index.html +0 -724
- package/site/index.html +0 -158
package/dist/render.js
CHANGED
|
@@ -16,11 +16,16 @@ import fs from "node:fs";
|
|
|
16
16
|
import os from "node:os";
|
|
17
17
|
import path from "node:path";
|
|
18
18
|
import { createHash } from "node:crypto";
|
|
19
|
-
import
|
|
20
|
-
import {
|
|
19
|
+
import YAML from "yaml";
|
|
20
|
+
import { execFile, execFileSync, spawnSync } from "node:child_process";
|
|
21
|
+
import { capSecondsFor, restorePrevious } from "./record.js";
|
|
22
|
+
import { bandHeightFor, maxCharsFor, wrap } from "./captions.js";
|
|
23
|
+
import { renderCard, renderCalloutOverlay, renderTitledCover } from "./cards.js";
|
|
24
|
+
import { describeIdle, planIdle, warpFilterArgs, warpTake } from "./pace.js";
|
|
25
|
+
import { DEFAULT_VOICE, audioSeconds, synthesize } from "./voice.js";
|
|
21
26
|
import { createRequire } from "node:module";
|
|
22
27
|
import { chromium } from "playwright";
|
|
23
|
-
import { resolve } from "./manifest.js";
|
|
28
|
+
import { expandEnv, resolve } from "./manifest.js";
|
|
24
29
|
/** What the render depends on: the raw take + everything in the manifest that
|
|
25
30
|
shapes the picture. Same hash → same output → skip. */
|
|
26
31
|
export function renderHash(m, take) {
|
|
@@ -28,7 +33,14 @@ export function renderHash(m, take) {
|
|
|
28
33
|
const src = take.video && fs.existsSync(take.video) ? fs.statSync(take.video) : null;
|
|
29
34
|
const h = createHash("sha1");
|
|
30
35
|
const merged = applyManifest(m, take);
|
|
31
|
-
h.update(JSON.stringify({ q, tempo: m.tempo, cap: m.captions, theme: m.theme, camera: m.camera,
|
|
36
|
+
h.update(JSON.stringify({ q, tempo: m.tempo, cap: m.captions, theme: m.theme, camera: m.camera, cards: [m.intro, m.outro], idle: m.compressIdle, vo: m.voiceover, music: m.music ? [m.music, (() => { try {
|
|
37
|
+
const f = typeof m.music === "string" ? m.music : m.music.file;
|
|
38
|
+
const st = fs.statSync(path.resolve(f));
|
|
39
|
+
return [st.size, Math.round(st.mtimeMs)];
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
} })()] : null, 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, t.callout]), trim: [merged.trimBefore, merged.duration] }));
|
|
32
44
|
return h.digest("hex").slice(0, 12);
|
|
33
45
|
}
|
|
34
46
|
const require = createRequire(import.meta.url);
|
|
@@ -79,38 +91,6 @@ export function probe(file) {
|
|
|
79
91
|
};
|
|
80
92
|
}
|
|
81
93
|
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
94
|
/** ffmpeg drawtext needs a few characters escaped inside the filter graph. */
|
|
115
95
|
function esc(s) {
|
|
116
96
|
return s.replace(/\\/g, "\\\\").replace(/'/g, "\u2019").replace(/:/g, "\\:").replace(/%/g, "\\%").replace(/,/g, "\\,");
|
|
@@ -148,6 +128,45 @@ export function applyManifest(m, take) {
|
|
|
148
128
|
const duration = Math.max(trimBefore + 1, take.duration - tail);
|
|
149
129
|
return { ...take, timeline, trimBefore, duration };
|
|
150
130
|
}
|
|
131
|
+
/** Apply the manifest's render-time scene nudges to a take.
|
|
132
|
+
*
|
|
133
|
+
* Scene markers come out of the recording, so getting one in the wrong place
|
|
134
|
+
* used to mean recording the whole thing again — with a live model in the
|
|
135
|
+
* middle of it, at 2.3x the video's length. A nudge moves the marker instead,
|
|
136
|
+
* and everything downstream (captions, stills, the thumbnail) reads scene
|
|
137
|
+
* times through here, so all of it moves together for the price of a render.
|
|
138
|
+
*
|
|
139
|
+
* Order is preserved by clamping: a scene can never be pushed past its
|
|
140
|
+
* neighbours, because a timeline that crosses over itself would produce
|
|
141
|
+
* captions in the wrong sequence rather than an error anyone could see.
|
|
142
|
+
*/
|
|
143
|
+
export function nudgeScenes(take, m) {
|
|
144
|
+
const wanted = new Map();
|
|
145
|
+
for (const st of m?.steps ?? []) {
|
|
146
|
+
if (st.action === "scene" && st.nudge)
|
|
147
|
+
wanted.set(st.label, st.nudge);
|
|
148
|
+
}
|
|
149
|
+
if (!wanted.size)
|
|
150
|
+
return { take, applied: [] };
|
|
151
|
+
const applied = [];
|
|
152
|
+
const GAP = 0.1; // scenes stay strictly ordered
|
|
153
|
+
const sceneIdx = take.timeline.map((t, i) => (t.action === "scene" ? i : -1)).filter((i) => i >= 0);
|
|
154
|
+
const timeline = take.timeline.map((t) => ({ ...t }));
|
|
155
|
+
for (const [n, i] of sceneIdx.entries()) {
|
|
156
|
+
const want = wanted.get(timeline[i].label ?? "");
|
|
157
|
+
if (!want)
|
|
158
|
+
continue;
|
|
159
|
+
const prev = n > 0 ? timeline[sceneIdx[n - 1]].start + GAP : 0;
|
|
160
|
+
const next = n < sceneIdx.length - 1 ? timeline[sceneIdx[n + 1]].start - GAP : take.duration;
|
|
161
|
+
const target = timeline[i].start + want / 1000;
|
|
162
|
+
const to = Math.min(Math.max(target, prev), Math.max(prev, next));
|
|
163
|
+
const span = timeline[i].end - timeline[i].start;
|
|
164
|
+
applied.push({ label: timeline[i].label ?? "?", ms: want, clamped: Math.abs(to - target) > 0.01 });
|
|
165
|
+
timeline[i].start = to;
|
|
166
|
+
timeline[i].end = to + span;
|
|
167
|
+
}
|
|
168
|
+
return { take: { ...take, timeline }, applied };
|
|
169
|
+
}
|
|
151
170
|
export function scenes(take) {
|
|
152
171
|
return take.timeline.filter((t) => t.action === "scene");
|
|
153
172
|
}
|
|
@@ -265,12 +284,31 @@ async function renderCardFrame(file, W, H, g, bg) {
|
|
|
265
284
|
}
|
|
266
285
|
}
|
|
267
286
|
// --- render ------------------------------------------------------------------
|
|
268
|
-
export async function render(m,
|
|
287
|
+
export async function render(m, takeIn, outDir, opts = {}) {
|
|
288
|
+
let take = takeIn;
|
|
269
289
|
const o = typeof opts === "function" ? { log: opts } : opts;
|
|
270
290
|
const log = o.log;
|
|
271
291
|
const q = resolve(m);
|
|
292
|
+
// Before anything reads a scene time: markers move at render, so captions,
|
|
293
|
+
// stills and the thumbnail all shift together and none of it costs a take.
|
|
294
|
+
{
|
|
295
|
+
const n = nudgeScenes(take, m);
|
|
296
|
+
take = n.take;
|
|
297
|
+
for (const a of n.applied) {
|
|
298
|
+
log?.(`scene "${a.label}" moved ${a.ms > 0 ? "+" : ""}${(a.ms / 1000).toFixed(1)}s${a.clamped ? " (clamped — it would have crossed a neighbouring scene)" : ""}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
272
301
|
const proofLog = path.join(outDir, "proof-log.md");
|
|
273
302
|
const writeProof = (a) => fs.writeFileSync(proofLog, renderProof(m, take, q, a));
|
|
303
|
+
// An output directory that cannot say what produced it makes an audit
|
|
304
|
+
// guesswork: the manifest on disk today is not necessarily the one this
|
|
305
|
+
// was rendered from. Keep a copy beside the take.
|
|
306
|
+
if (!o.scene) {
|
|
307
|
+
try {
|
|
308
|
+
fs.writeFileSync(path.join(outDir, "manifest.used.yaml"), YAML.stringify(m));
|
|
309
|
+
}
|
|
310
|
+
catch { /* not fatal */ }
|
|
311
|
+
}
|
|
274
312
|
if (!o.scene)
|
|
275
313
|
writeProof({});
|
|
276
314
|
if (!take.video)
|
|
@@ -296,6 +334,21 @@ export async function render(m, take, outDir, opts = {}) {
|
|
|
296
334
|
}
|
|
297
335
|
// Captions, holds, camera zooms and trim come from the manifest as it is now.
|
|
298
336
|
take = applyManifest(m, take);
|
|
337
|
+
// Idle compression: re-cut the source so the app's dead waits are shown
|
|
338
|
+
// short, and remap every clock in the take through the same warp.
|
|
339
|
+
let idleLines = [];
|
|
340
|
+
if (m.compressIdle && take.video) {
|
|
341
|
+
const keep = m.compressIdle === true ? 1.5 : m.compressIdle.keepSeconds;
|
|
342
|
+
const segs = planIdle(take, keep);
|
|
343
|
+
if (segs.length) {
|
|
344
|
+
const paced = path.join(outDir, ".paced.mp4");
|
|
345
|
+
ff([...warpFilterArgs(take.video, segs, take.duration, paced).slice(0, -1), "-r", String(resolve(m).fps), "-c:v", "libx264", "-crf", "14", "-preset", "veryfast", "-pix_fmt", "yuv420p", paced], log);
|
|
346
|
+
idleLines = describeIdle(take, segs);
|
|
347
|
+
for (const l of idleLines)
|
|
348
|
+
log?.(`idle: ${l}`);
|
|
349
|
+
take = { ...warpTake(take, segs), video: paced };
|
|
350
|
+
}
|
|
351
|
+
}
|
|
299
352
|
// Tempo: the source is cut in its own time (-ss/-t below), then every
|
|
300
353
|
// clock downstream — captions, camera keys, stills — runs in output time.
|
|
301
354
|
const srcTake = take;
|
|
@@ -316,15 +369,18 @@ export async function render(m, take, outDir, opts = {}) {
|
|
|
316
369
|
const to = sc[i + 1] ? sc[i + 1].start : take.duration;
|
|
317
370
|
take = { ...take, trimBefore: from, duration: to };
|
|
318
371
|
}
|
|
319
|
-
const src = path.resolve(srcVideo);
|
|
372
|
+
const src = path.resolve(take.video ?? srcVideo); // take.video may be the idle-warped re-cut
|
|
320
373
|
// Source (recorded viewport) vs canvas (preset output). They differ when a
|
|
321
374
|
// narrow app was recorded at its own aspect and gets centred on the canvas.
|
|
322
375
|
const SW = take.quality?.width ?? q.viewport.width;
|
|
323
376
|
const SH = take.quality?.height ?? q.viewport.height;
|
|
324
377
|
// band/overlay/none: the canvas IS the recording (site fills the frame).
|
|
325
378
|
// card: the preset canvas, with the recording framed inside it.
|
|
326
|
-
|
|
327
|
-
|
|
379
|
+
// The finished frame is ALWAYS the preset's canvas. Whatever page size the
|
|
380
|
+
// take was recorded at gets fitted into it below — so a size change is a
|
|
381
|
+
// re-render, never a re-record, and one preset always means one shape.
|
|
382
|
+
const W = q.width;
|
|
383
|
+
const H = q.height;
|
|
328
384
|
const fps = q.fps;
|
|
329
385
|
const bg = q.theme.background;
|
|
330
386
|
const ink = q.theme.ink;
|
|
@@ -344,13 +400,22 @@ export async function render(m, take, outDir, opts = {}) {
|
|
|
344
400
|
let frameInput = null;
|
|
345
401
|
let geom = null;
|
|
346
402
|
const capSize = q.captions ? q.captions.fontSize : 0;
|
|
403
|
+
// One band height for the whole take, from its captions (see captions.ts).
|
|
404
|
+
const bandH = bandHeightFor(q, captionWindows(take).map((w) => w.text));
|
|
347
405
|
if (layout === "band") {
|
|
348
|
-
// Site fills the frame; caption strip below it.
|
|
349
|
-
|
|
350
|
-
|
|
406
|
+
// Site fills the frame; caption strip below it — none at all if captions are off.
|
|
407
|
+
// Fit the recording into the page area, letterboxing on the theme colour
|
|
408
|
+
// rather than black, then the caption strip underneath. A take recorded
|
|
409
|
+
// at 1440×1080 becomes a true 1920×1080, a 1080×1920, whatever is asked.
|
|
410
|
+
const pageH = H - bandH;
|
|
411
|
+
filters.push(`scale=${W}:${pageH}:force_original_aspect_ratio=decrease:flags=lanczos`, `pad=${W}:${pageH}:(ow-iw)/2:(oh-ih)/2:color=${bg}`);
|
|
412
|
+
if (bandH) {
|
|
413
|
+
filters.push(`pad=${W}:${H}:0:0:color=${bg}`);
|
|
414
|
+
capY = `${pageH}+${bandH / 2}-th/2`;
|
|
415
|
+
}
|
|
351
416
|
}
|
|
352
417
|
else if (layout === "card") {
|
|
353
|
-
geom = cardGeometry(W, H, SW, SH,
|
|
418
|
+
geom = cardGeometry(W, H, SW, SH, bandH);
|
|
354
419
|
frameInput = path.join(outDir, ".frame.png");
|
|
355
420
|
await renderCardFrame(frameInput, W, H, geom, bg);
|
|
356
421
|
filters.push(`scale=${geom.vw}:${geom.vh}:flags=lanczos`, `pad=${W}:${H}:${geom.vx}:${geom.vy}:color=${bg}`);
|
|
@@ -361,19 +426,46 @@ export async function render(m, take, outDir, opts = {}) {
|
|
|
361
426
|
capColor = q.captions ? q.captions.color ?? "white" : "white";
|
|
362
427
|
capBox = ":box=1:boxcolor=black@0.55:boxborderw=18";
|
|
363
428
|
}
|
|
364
|
-
|
|
429
|
+
// Square pixels, explicitly. Playwright's webm arrives with a SAR of
|
|
430
|
+
// 1216:1215 — a half-percent stretch nobody notices until concat refuses
|
|
431
|
+
// to join it to a title card that is honestly 1:1.
|
|
432
|
+
filters.push("setsar=1", "format=yuv420p");
|
|
365
433
|
const captionFilters = [];
|
|
366
434
|
if (q.captions && layout !== "none") {
|
|
367
|
-
const maxChars =
|
|
435
|
+
const maxChars = maxCharsFor(W, capSize);
|
|
368
436
|
for (const w of captionWindows(take)) {
|
|
369
437
|
captionFilters.push(`drawtext=${font}text='${esc(wrap(w.text, maxChars))}':fontsize=${capSize}:fontcolor=${capColor}${capBox}:line_spacing=${Math.round(capSize * 0.25)}` +
|
|
370
438
|
`:x=(w-tw)/2:y=${capY}:enable='between(t,${w.from.toFixed(2)},${w.to.toFixed(2)})'`);
|
|
371
439
|
}
|
|
372
440
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const
|
|
441
|
+
// Callouts: recorded boxes become animated overlays, applied in SOURCE
|
|
442
|
+
// coordinates (before the camera filter) so a moving camera carries them.
|
|
443
|
+
const callouts = take.timeline.filter((e) => e.callout && e.ok);
|
|
444
|
+
const calloutInputs = [];
|
|
445
|
+
let preCam = "";
|
|
446
|
+
if (callouts.length && !o.scene && !frameInput) {
|
|
447
|
+
const camIdx = cam.filter ? filters.indexOf(cam.filter) : filters.length;
|
|
448
|
+
const before = filters.slice(0, camIdx), after = filters.slice(camIdx);
|
|
449
|
+
let label = "b0";
|
|
450
|
+
let g = `[0:v]${before.join(",")}[${label}]`;
|
|
451
|
+
for (let i = 0; i < callouts.length; i++) {
|
|
452
|
+
const e = callouts[i];
|
|
453
|
+
const from = Math.max(0, e.start - take.trimBefore);
|
|
454
|
+
const webm = await renderCalloutOverlay(ffmpegBin(), outDir, i, e.callout, SW, SH, fps, Math.max(15, Math.round(q.captions ? q.captions.fontSize * 0.55 : 20)), ink);
|
|
455
|
+
calloutInputs.push(webm);
|
|
456
|
+
const inIdx = 1 + (frameInput ? 1 : 0) + i;
|
|
457
|
+
const next = `b${i + 1}`;
|
|
458
|
+
g += `;[${inIdx}:v]setpts=PTS+${from.toFixed(3)}/TB[c${i}];[${label}][c${i}]overlay=0:0:eof_action=pass[${next}]`;
|
|
459
|
+
label = next;
|
|
460
|
+
}
|
|
461
|
+
preCam = `${g};[${label}]${after.length ? after.join(",") : "null"}`;
|
|
462
|
+
}
|
|
463
|
+
const graph = preCam
|
|
464
|
+
? `${preCam}${captionFilters.length ? "," + captionFilters.join(",") : ""}[out]`
|
|
465
|
+
: frameInput
|
|
466
|
+
? `[0:v]${filters.join(",")}[v];[v][1:v]overlay=0:0:format=auto${captionFilters.length ? "," + captionFilters.join(",") : ""}[out]`
|
|
467
|
+
: `[0:v]${[...filters, ...captionFilters].join(",")}[out]`;
|
|
468
|
+
const inputs = ["-ss", srcTake.trimBefore.toFixed(3), "-t", (srcTake.duration - srcTake.trimBefore).toFixed(3), "-i", src, ...(frameInput ? ["-i", frameInput] : []), ...calloutInputs.flatMap((f) => ["-c:v", "libvpx-vp9", "-i", f])];
|
|
377
469
|
const compose = ["-filter_complex", graph, "-map", "[out]", "-r", String(fps), "-an", "-pix_fmt", "yuv420p", "-movflags", "+faststart"];
|
|
378
470
|
const x264 = (crf, preset) => ["-c:v", "libx264", "-crf", String(crf), "-preset", preset];
|
|
379
471
|
// Hardware encode is a macOS thing; elsewhere the "fast" presets are a fast x264.
|
|
@@ -401,6 +493,98 @@ export async function render(m, take, outDir, opts = {}) {
|
|
|
401
493
|
}
|
|
402
494
|
if (frameInput)
|
|
403
495
|
fs.rmSync(frameInput, { force: true });
|
|
496
|
+
for (const f of calloutInputs)
|
|
497
|
+
fs.rmSync(f, { force: true });
|
|
498
|
+
// Title cards: rendered at the finished frame size, spliced on. The intro's
|
|
499
|
+
// settled frame becomes cover.png — the poster.
|
|
500
|
+
let cover;
|
|
501
|
+
if ((m.intro || m.outro) && !o.scene) {
|
|
502
|
+
const dims = probe(mp4);
|
|
503
|
+
const segs = [];
|
|
504
|
+
if (m.intro) {
|
|
505
|
+
const c = await renderCard(ffmpegBin(), outDir, "intro", m.intro, dims.width, dims.height, fps, q.theme);
|
|
506
|
+
segs.push(c.mp4);
|
|
507
|
+
cover = c.cover;
|
|
508
|
+
}
|
|
509
|
+
segs.push(mp4);
|
|
510
|
+
if (m.outro)
|
|
511
|
+
segs.push((await renderCard(ffmpegBin(), outDir, "outro", m.outro, dims.width, dims.height, fps, q.theme)).mp4);
|
|
512
|
+
if (segs.length > 1) {
|
|
513
|
+
const joined = path.join(outDir, ".joined.mp4");
|
|
514
|
+
ff([...segs.flatMap((f) => ["-i", f]), "-filter_complex", `${segs.map((_, i) => `[${i}:v]`).join("")}concat=n=${segs.length}:v=1:a=0[out]`, "-map", "[out]", "-r", String(fps), "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-an", ...x264(q.crf, "medium"), joined], log);
|
|
515
|
+
fs.renameSync(joined, mp4);
|
|
516
|
+
for (const f of segs)
|
|
517
|
+
if (f !== mp4)
|
|
518
|
+
fs.rmSync(f, { force: true });
|
|
519
|
+
}
|
|
520
|
+
mark("cards");
|
|
521
|
+
}
|
|
522
|
+
// Audio: the music bed and/or the captions read aloud, mixed in one pass.
|
|
523
|
+
// The deliverable gets it; the master stays a clean video-only source.
|
|
524
|
+
if ((m.music || m.voiceover) && !o.scene) {
|
|
525
|
+
const dur = probe(mp4).duration;
|
|
526
|
+
// Cards shift every scene: narration is placed in FINAL time.
|
|
527
|
+
const introShift = m.intro ? m.intro.ms / 1000 : 0;
|
|
528
|
+
const audioIn = [];
|
|
529
|
+
const chains = [];
|
|
530
|
+
const mixIns = [];
|
|
531
|
+
let idx = 1; // 0 is the video
|
|
532
|
+
if (m.music) {
|
|
533
|
+
const spec = typeof m.music === "string" ? { file: m.music, gainDb: -14, fadeOutMs: 1800 } : m.music;
|
|
534
|
+
const musicFile = path.resolve(expandEnv(spec.file));
|
|
535
|
+
if (!fs.existsSync(musicFile))
|
|
536
|
+
throw new Error(`music: no such file ${musicFile} — the track ships inside the video, so it must be one the person may use (CC0 or licensed)`);
|
|
537
|
+
const fadeStart = Math.max(0, dur - spec.fadeOutMs / 1000);
|
|
538
|
+
// Ducked a step further when a voice speaks over it.
|
|
539
|
+
const gain = spec.gainDb - (m.voiceover ? 7 : 0);
|
|
540
|
+
audioIn.push("-stream_loop", "-1", "-i", musicFile);
|
|
541
|
+
chains.push(`[${idx}:a]volume=${gain}dB,afade=t=in:st=0:d=0.6,afade=t=out:st=${fadeStart.toFixed(2)}:d=${(spec.fadeOutMs / 1000).toFixed(2)}[mus]`);
|
|
542
|
+
mixIns.push("[mus]");
|
|
543
|
+
idx++;
|
|
544
|
+
}
|
|
545
|
+
if (m.voiceover) {
|
|
546
|
+
const spec = m.voiceover === true ? { voice: DEFAULT_VOICE, gainDb: 0, script: undefined, fragments: true } : m.voiceover;
|
|
547
|
+
if (spec.script) {
|
|
548
|
+
// One script, one performance: prosody carries across the whole
|
|
549
|
+
// video the way a person reads. Starts after the title card.
|
|
550
|
+
const clip = path.join(outDir, ".vo-0.mp3");
|
|
551
|
+
synthesize(spec.script, spec.voice, clip);
|
|
552
|
+
const spoken = audioSeconds(ffmpegBin(), clip);
|
|
553
|
+
if (spoken > dur - introShift + 0.5)
|
|
554
|
+
log?.(`voiceover: the script reads ${spoken.toFixed(1)}s but there are ${(dur - introShift).toFixed(1)}s of video after the title card — cut the script or lengthen the scenes, then re-render`);
|
|
555
|
+
audioIn.push("-i", clip);
|
|
556
|
+
chains.push(`[${idx}:a]volume=${spec.gainDb}dB,adelay=${Math.round(introShift * 1000)}:all=1[vo0]`);
|
|
557
|
+
mixIns.push("[vo0]");
|
|
558
|
+
idx++;
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
// Explicitly-asked-for fragments: one clip per caption. Known to
|
|
562
|
+
// reset prosody at every line; the schema refuses it by default.
|
|
563
|
+
const wins = captionWindows(take);
|
|
564
|
+
for (let i = 0; i < wins.length; i++) {
|
|
565
|
+
const w = wins[i];
|
|
566
|
+
const clip = path.join(outDir, `.vo-${i}.mp3`);
|
|
567
|
+
synthesize(w.text, spec.voice, clip);
|
|
568
|
+
const spoken = audioSeconds(ffmpegBin(), clip);
|
|
569
|
+
const at = w.from + introShift;
|
|
570
|
+
if (spoken > w.to - w.from + 0.4)
|
|
571
|
+
log?.(`voiceover: “${w.text.slice(0, 40)}…” runs ${spoken.toFixed(1)}s but its scene holds ${(w.to - w.from).toFixed(1)}s — lengthen the scene's holdMs or trailing wait and re-render`);
|
|
572
|
+
audioIn.push("-i", clip);
|
|
573
|
+
chains.push(`[${idx}:a]volume=${spec.gainDb}dB,adelay=${Math.round(at * 1000)}:all=1[vo${i}]`);
|
|
574
|
+
mixIns.push(`[vo${i}]`);
|
|
575
|
+
idx++;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
const mixed = path.join(outDir, ".with-audio.mp4");
|
|
580
|
+
const graph = `${chains.join(";")};${mixIns.join("")}amix=inputs=${mixIns.length}:duration=longest:normalize=0,atrim=0:${dur.toFixed(2)}[a]`;
|
|
581
|
+
ff(["-i", mp4, ...audioIn, "-filter_complex", graph, "-map", "0:v", "-map", "[a]", "-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-movflags", "+faststart", "-shortest", mixed], log);
|
|
582
|
+
fs.renameSync(mixed, mp4);
|
|
583
|
+
for (const f of fs.readdirSync(outDir))
|
|
584
|
+
if (/^\.vo-\d+\.mp3$/.test(f))
|
|
585
|
+
fs.rmSync(path.join(outDir, f), { force: true });
|
|
586
|
+
mark("audio");
|
|
587
|
+
}
|
|
404
588
|
if (o.scene)
|
|
405
589
|
return { mp4, proofLog };
|
|
406
590
|
// --- 3. GIF (secondary, opt-in) --------------------------------------------
|
|
@@ -429,9 +613,19 @@ export async function render(m, take, outDir, opts = {}) {
|
|
|
429
613
|
}
|
|
430
614
|
mark("gif");
|
|
431
615
|
}
|
|
616
|
+
// Cards are spliced onto the front of the deliverable, so every timestamp
|
|
617
|
+
// in the take is that much later in the file frames are pulled from.
|
|
618
|
+
const cardShift = !o.scene && m.intro ? m.intro.ms / 1000 : 0;
|
|
432
619
|
// --- 4. thumbnail ----------------------------------------------------------
|
|
620
|
+
// A person's chosen poster (the window's "Use this frame" / a still / an
|
|
621
|
+
// upload) outlives re-renders: the choice writes a .poster marker, and the
|
|
622
|
+
// scene-based default never overwrites a deliberate choice.
|
|
433
623
|
let thumbnail;
|
|
434
|
-
if (m.outputs.thumbnail) {
|
|
624
|
+
if (m.outputs.thumbnail && fs.existsSync(path.join(outDir, ".poster")) && fs.existsSync(path.join(outDir, "thumbnail.png"))) {
|
|
625
|
+
thumbnail = path.join(outDir, "thumbnail.png");
|
|
626
|
+
log?.("thumbnail: keeping the poster you chose (delete .poster to go back to the scene default)");
|
|
627
|
+
}
|
|
628
|
+
else if (m.outputs.thumbnail) {
|
|
435
629
|
const sc = scenes(take);
|
|
436
630
|
let at;
|
|
437
631
|
if (typeof m.outputs.thumbnail === "object") {
|
|
@@ -449,13 +643,48 @@ export async function render(m, take, outDir, opts = {}) {
|
|
|
449
643
|
at = last ? last.start - take.trimBefore + 1.0 : Math.max(0, take.duration - take.trimBefore - 0.5);
|
|
450
644
|
}
|
|
451
645
|
thumbnail = path.join(outDir, "thumbnail.png");
|
|
452
|
-
|
|
646
|
+
// Clamp inside the file. The default is "last scene + 1s", which lands
|
|
647
|
+
// PAST the end whenever a take stops soon after its final scene — ffmpeg
|
|
648
|
+
// then writes nothing and the render died on the missing file, reported
|
|
649
|
+
// in the field as "all steps ok, but no video".
|
|
650
|
+
const seekable = Math.max(0, probe(mp4).duration - 0.25);
|
|
651
|
+
const seek = Math.min(Math.max(0, at + cardShift), seekable);
|
|
652
|
+
ff(["-ss", seek.toFixed(2), "-i", mp4, "-frames:v", "1", thumbnail], log);
|
|
653
|
+
if (!fs.existsSync(thumbnail)) {
|
|
654
|
+
log?.(`thumbnail: nothing at ${seek.toFixed(1)}s — taking the last frame instead`);
|
|
655
|
+
ff(["-sseof", "-0.3", "-i", mp4, "-frames:v", "1", thumbnail], log);
|
|
656
|
+
}
|
|
657
|
+
if (!fs.existsSync(thumbnail)) {
|
|
658
|
+
log?.("thumbnail: could not be taken — the video is fine, the poster is not");
|
|
659
|
+
thumbnail = undefined;
|
|
660
|
+
}
|
|
661
|
+
// A launch cut gets a second candidate: this frame with the title on it.
|
|
662
|
+
// The person picks between them (or any still, or their own file) in the
|
|
663
|
+
// window; both are just PNGs, and the poster is whichever is thumbnail.png.
|
|
664
|
+
if (m.mode === "launch" && m.intro && thumbnail) {
|
|
665
|
+
const d = probe(mp4);
|
|
666
|
+
await renderTitledCover(ffmpegBin(), thumbnail, path.join(outDir, "cover-titled.png"), m.intro, d.width, d.height, q.theme);
|
|
667
|
+
}
|
|
453
668
|
mark("thumbnail");
|
|
454
669
|
}
|
|
670
|
+
// A launch cut is a thing someone signs off, so give them the artifact
|
|
671
|
+
// that makes signing off possible without ffmpeg by hand.
|
|
672
|
+
if (m.mode === "launch" && !o.scene && fs.existsSync(path.join(outDir, "demo.mp4"))) {
|
|
673
|
+
try {
|
|
674
|
+
contactSheet(outDir, 2, 420, undefined);
|
|
675
|
+
mark("contact");
|
|
676
|
+
}
|
|
677
|
+
catch (e) {
|
|
678
|
+
log?.(`contact sheet: ${e.message}`);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
455
681
|
// --- 5. scene stills -------------------------------------------------------
|
|
456
682
|
// Every scene already carries its real timestamp, so one crisp frame per
|
|
457
683
|
// scene is free — and a video usually ships with a picture next to it.
|
|
458
684
|
let stills = [];
|
|
685
|
+
// Every still in ONE ffmpeg pass: a process launch and a fresh decode per
|
|
686
|
+
// frame cost more than the frames do (7 stills was 3s of pure overhead).
|
|
687
|
+
const shots = [];
|
|
459
688
|
if (m.outputs.stills) {
|
|
460
689
|
const dir = path.join(outDir, "stills");
|
|
461
690
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
@@ -470,17 +699,29 @@ export async function render(m, take, outDir, opts = {}) {
|
|
|
470
699
|
const at = Math.min(Math.max(0, from + Math.max(0.8, (next - from) / 2)), Math.max(0, end - 0.2));
|
|
471
700
|
const base = `${String(i + 1).padStart(2, "0")}-${(sce.label || "scene").replace(/[^a-z0-9-]+/gi, "-")}`;
|
|
472
701
|
const file = path.join(dir, `${base}.png`);
|
|
473
|
-
|
|
702
|
+
shots.push({ at: at + cardShift, file });
|
|
474
703
|
stills.push(file);
|
|
475
704
|
// And the scene's last moment: the caption names what the scene
|
|
476
705
|
// achieves, and that is usually on screen at the end, not the middle.
|
|
477
706
|
const atEnd = Math.min(Math.max(from + 0.4, next - 0.6), Math.max(0, end - 0.2));
|
|
478
707
|
if (atEnd - at > 0.5) {
|
|
479
708
|
const fe = path.join(dir, `${base}-end.png`);
|
|
480
|
-
|
|
709
|
+
shots.push({ at: atEnd + cardShift, file: fe });
|
|
481
710
|
stills.push(fe);
|
|
482
711
|
}
|
|
483
712
|
}
|
|
713
|
+
if (shots.length) {
|
|
714
|
+
// Each still is its own ffmpeg with an INPUT-side seek: `-ss` before
|
|
715
|
+
// `-i` jumps by keyframe, while one batched process with `-ss` after
|
|
716
|
+
// `-i` decodes sequentially to every timestamp and is far slower (it
|
|
717
|
+
// turned 3s of stills into 18s — measured, then reverted). The launches
|
|
718
|
+
// are independent, so they run at once instead of in a queue.
|
|
719
|
+
const bin = ffmpegBin();
|
|
720
|
+
await Promise.all(shots.map((s) => new Promise((done) => {
|
|
721
|
+
execFile(bin, ["-hide_banner", "-loglevel", "error", "-y", "-ss", s.at.toFixed(2), "-i", mp4, "-frames:v", "1", s.file], () => done());
|
|
722
|
+
})));
|
|
723
|
+
stills = stills.filter((f) => fs.existsSync(f));
|
|
724
|
+
}
|
|
484
725
|
if (sc.length)
|
|
485
726
|
mark(`stills ×${sc.length}`);
|
|
486
727
|
}
|
|
@@ -489,8 +730,9 @@ export async function render(m, take, outDir, opts = {}) {
|
|
|
489
730
|
const p = probe(mp4);
|
|
490
731
|
const facts = {
|
|
491
732
|
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])),
|
|
733
|
+
sizes: Object.fromEntries([master, mp4, gif, thumbnail].filter((f) => !!f && fs.existsSync(f)).map((f) => [path.basename(f), fs.statSync(f).size])),
|
|
493
734
|
gifTool, layout, cameraScenes: cam.count, encoder: q.encoder, timings: t.marks, renderHash: hash, preset: q.name,
|
|
735
|
+
mode: m.mode,
|
|
494
736
|
};
|
|
495
737
|
const a = { master, mp4, gif, thumbnail, proofLog, facts };
|
|
496
738
|
writeProof(a);
|
|
@@ -498,6 +740,38 @@ export async function render(m, take, outDir, opts = {}) {
|
|
|
498
740
|
return a;
|
|
499
741
|
}
|
|
500
742
|
/** GIF straight from the finished demo.mp4 — no re-render, no browser. */
|
|
743
|
+
/** One timestamped contact sheet of the finished video.
|
|
744
|
+
*
|
|
745
|
+
* Reviewing a take meant pulling frames out with ffmpeg by hand, a dozen
|
|
746
|
+
* timestamps at a time, eight videos deep. This is that, once: a grid with
|
|
747
|
+
* the playback time burned into every cell, so a still can be reasoned about
|
|
748
|
+
* as a moment rather than a picture. Per-scene stills cannot do this — they
|
|
749
|
+
* cannot show a caption going false *inside* a scene, and they carry no clock.
|
|
750
|
+
*/
|
|
751
|
+
export function contactSheet(outDir, everySec = 2, width = 420, log) {
|
|
752
|
+
const mp4 = path.join(outDir, "demo.mp4");
|
|
753
|
+
if (!fs.existsSync(mp4))
|
|
754
|
+
throw new Error(`no demo.mp4 in ${outDir} — render it first`);
|
|
755
|
+
const dur = probe(mp4).duration;
|
|
756
|
+
const cells = Math.max(1, Math.ceil(dur / everySec));
|
|
757
|
+
const cols = Math.min(4, Math.max(1, Math.ceil(Math.sqrt(cells))));
|
|
758
|
+
const rows = Math.ceil(cells / cols);
|
|
759
|
+
const fontFile = DEFAULT_FONTS.find((f) => fs.existsSync(f));
|
|
760
|
+
const font = fontFile ? `fontfile='${fontFile}':` : "";
|
|
761
|
+
const out = path.join(outDir, "contact.png");
|
|
762
|
+
// drawtext runs before tile, so each cell carries its own playback time.
|
|
763
|
+
const vf = [
|
|
764
|
+
`fps=1/${everySec}`,
|
|
765
|
+
`scale=${width}:-2`,
|
|
766
|
+
`drawtext=${font}text='%{pts\\:hms}':x=8:y=8:fontsize=${Math.round(width / 20)}:fontcolor=white:box=1:boxcolor=black@0.55:boxborderw=5`,
|
|
767
|
+
`tile=${cols}x${rows}:margin=8:padding=6:color=#e6e9e2`,
|
|
768
|
+
].join(",");
|
|
769
|
+
ff(["-i", mp4, "-vf", vf, "-frames:v", "1", out], log);
|
|
770
|
+
if (!fs.existsSync(out))
|
|
771
|
+
throw new Error("ffmpeg produced no contact sheet");
|
|
772
|
+
log?.(`contact: ${cells} frames every ${everySec}s, ${cols}×${rows} → ${path.relative(process.cwd(), out)}`);
|
|
773
|
+
return out;
|
|
774
|
+
}
|
|
501
775
|
export function makeGif(outDir, width = 900, fps = 18, log) {
|
|
502
776
|
const mp4 = path.join(outDir, "demo.mp4");
|
|
503
777
|
if (!fs.existsSync(mp4))
|
|
@@ -537,9 +811,17 @@ export function check(outDir, m) {
|
|
|
537
811
|
const say = (pass, text) => { lines.push(`${pass ? "pass" : "FAIL"} ${text}`); if (!pass)
|
|
538
812
|
ok = false; };
|
|
539
813
|
const takePath = path.join(outDir, "take.json");
|
|
814
|
+
// A run killed outright (a signal, a closed pipe) leaves its predecessor in
|
|
815
|
+
// the stash with no catch block ever running. Anything that READS the folder
|
|
816
|
+
// puts it back, so a person looking at it never sees an empty directory and
|
|
817
|
+
// concludes their take is gone.
|
|
818
|
+
if (!fs.existsSync(takePath))
|
|
819
|
+
restorePrevious(outDir);
|
|
540
820
|
if (!fs.existsSync(takePath))
|
|
541
821
|
return { ok: false, lines: ["FAIL no take.json"] };
|
|
542
|
-
|
|
822
|
+
// The same shift render applied, so a nudged scene is not reported as a
|
|
823
|
+
// stall or a caption in the wrong place by a check reading raw times.
|
|
824
|
+
const take = nudgeScenes(JSON.parse(fs.readFileSync(takePath, "utf8")), m).take;
|
|
543
825
|
// The take knows which preset it was recorded with; a `--preset` on the run
|
|
544
826
|
// must win over the manifest's default, or a draft fails for lacking a master.
|
|
545
827
|
const q = m ? resolve(take.quality?.preset && take.quality.preset !== m.preset ? { ...m, preset: take.quality.preset } : m) : null;
|
|
@@ -547,17 +829,23 @@ export function check(outDir, m) {
|
|
|
547
829
|
if (!fs.existsSync(mp4))
|
|
548
830
|
return { ok: false, lines: ["FAIL demo.mp4 missing"] };
|
|
549
831
|
const p = probe(mp4);
|
|
550
|
-
const wantW = q
|
|
832
|
+
const wantW = q?.width;
|
|
551
833
|
say(true, `resolution: ${p.width}×${p.height}`);
|
|
552
834
|
if (wantW)
|
|
553
|
-
say(p.width === wantW, `matches
|
|
835
|
+
say(p.width === wantW, `matches ${q?.name}: ${wantW}px wide`);
|
|
554
836
|
say(p.width >= 960, `at least 960px wide`);
|
|
555
837
|
say(true, `fps: ${p.fps}`);
|
|
556
838
|
/* A take may legitimately run long — a full product walkthrough does.
|
|
557
839
|
The manifest's own maxSeconds is the author's stated bound; a take
|
|
558
840
|
inside it is not a runaway, so do not fail it for length. */
|
|
559
841
|
const longest = m ? capSecondsFor(m) : 300;
|
|
560
|
-
|
|
842
|
+
// Whole-video judgements (is it long enough, does it have every scene) do
|
|
843
|
+
// not apply to a deliberate fragment.
|
|
844
|
+
const fragment = /\((?:from|until)\)/.test(take.partial ?? "");
|
|
845
|
+
if (fragment)
|
|
846
|
+
lines.push(`— duration: ${p.duration.toFixed(1)}s (not judged — this is a fragment)`);
|
|
847
|
+
else
|
|
848
|
+
say(p.duration >= 5 && p.duration <= longest, `duration: ${p.duration.toFixed(1)}s`);
|
|
561
849
|
if (p.duration > 60)
|
|
562
850
|
lines.push(`— ${p.duration.toFixed(0)}s is long for a social post (aim for 15–45s); fine for a walkthrough`);
|
|
563
851
|
/* A step that stalls is not a failed step, so nothing above notices it — but
|
|
@@ -566,23 +854,76 @@ export function check(outDir, m) {
|
|
|
566
854
|
other seven navigations took under four seconds; every check passed.
|
|
567
855
|
`wait` and `scene` are excluded because their whole job is to take time. */
|
|
568
856
|
const PATIENCE = 8;
|
|
569
|
-
|
|
857
|
+
// Judge what shipped. compressIdle rewrites the timeline at render, so
|
|
858
|
+
// reading raw step durations here failed good videos for a stall the
|
|
859
|
+
// viewer never sees — and a check that cries wolf teaches you to skim
|
|
860
|
+
// past the one that matters.
|
|
861
|
+
const idleSegs = m?.compressIdle && take.video
|
|
862
|
+
? planIdle(take, m.compressIdle === true ? 1.5 : m.compressIdle.keepSeconds)
|
|
863
|
+
: [];
|
|
864
|
+
const shownTake = idleSegs.length ? warpTake(take, idleSegs) : take;
|
|
865
|
+
const stalled = (shownTake.timeline || [])
|
|
570
866
|
.filter((t) => t.action !== "wait" && t.action !== "scene" && t.action !== "stub")
|
|
571
867
|
.map((t) => ({ ...t, took: t.end - t.start }))
|
|
572
868
|
.filter((t) => t.took > PATIENCE)
|
|
573
869
|
.sort((a, b) => b.took - a.took);
|
|
870
|
+
// Did the app break, and was it still broken when the video stopped? A
|
|
871
|
+
// take can pass every step and end on an error screen; one did, and only
|
|
872
|
+
// frame-by-frame review found it. An error in the last stretch is the
|
|
873
|
+
// shape that matters — earlier ones are usually recovered from.
|
|
874
|
+
const errs = take.pageErrors ?? [];
|
|
875
|
+
if (errs.length) {
|
|
876
|
+
const endsAt = take.duration;
|
|
877
|
+
const late = errs.filter((e) => e.at >= Math.max(0, endsAt - 6));
|
|
878
|
+
if (late.length) {
|
|
879
|
+
say(false, `the app reported an error in the last seconds and the video ends on it: “${late[0].text.slice(0, 120)}” at ${late[0].at.toFixed(1)}s — watch the final frames before shipping this`);
|
|
880
|
+
}
|
|
881
|
+
else {
|
|
882
|
+
lines.push(`— the app reported ${errs.length} error${errs.length > 1 ? "s" : ""} during the take (earliest “${errs[0].text.slice(0, 80)}”), none in the last 6s`);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
574
885
|
if (stalled.length) {
|
|
575
886
|
const worst = stalled[0];
|
|
887
|
+
// A timestamp says a stall happened; it cannot say why. The frame can —
|
|
888
|
+
// a spinner, a modal, an error, or the app simply sitting there are four
|
|
889
|
+
// different problems and "25.5s at 0:56" is the same sentence for all of
|
|
890
|
+
// them. Pulled from the finished video, so it costs a seek, not a take.
|
|
891
|
+
const shots = [];
|
|
892
|
+
const trim = shownTake.trimBefore ?? 0;
|
|
893
|
+
for (const [i, t] of stalled.slice(0, 3).entries()) {
|
|
894
|
+
// A third of the way in: past whatever triggered it, still inside the
|
|
895
|
+
// held stretch rather than on the frame that ends it.
|
|
896
|
+
const at = Math.max(0, t.start - trim + Math.min(t.took / 3, 4));
|
|
897
|
+
const file = path.join(outDir, `stalled-${String(i + 1).padStart(2, "0")}.png`);
|
|
898
|
+
try {
|
|
899
|
+
ff(["-ss", at.toFixed(2), "-i", mp4, "-frames:v", "1", "-y", file]);
|
|
900
|
+
if (fs.existsSync(file))
|
|
901
|
+
shots.push(path.relative(process.cwd(), file));
|
|
902
|
+
}
|
|
903
|
+
catch { /* a picture is a bonus, never the reason a check fails */ }
|
|
904
|
+
}
|
|
576
905
|
say(false, `${stalled.length} step${stalled.length > 1 ? "s" : ""} stalled over ${PATIENCE}s — ` +
|
|
577
906
|
`the take holds a still frame there. Worst: ${worst.took.toFixed(1)}s at ` +
|
|
578
907
|
`${Math.floor(worst.start / 60)}:${String(Math.floor(worst.start % 60)).padStart(2, "0")} — ${worst.summary}`);
|
|
579
|
-
|
|
580
|
-
lines.push(`—
|
|
908
|
+
if (shots[0])
|
|
909
|
+
lines.push(`— what the frame shows there: ${shots[0]} — open it; a spinner, a modal and an idle app are three different fixes`);
|
|
910
|
+
for (const [i, t] of stalled.slice(1, 4).entries()) {
|
|
911
|
+
lines.push(`— also ${t.took.toFixed(1)}s: ${t.summary}${shots[i + 1] ? ` (${shots[i + 1]})` : ""}`);
|
|
581
912
|
}
|
|
582
913
|
}
|
|
583
914
|
else {
|
|
584
915
|
say(true, `no step stalled (nothing over ${PATIENCE}s)`);
|
|
585
916
|
}
|
|
917
|
+
// A stub nobody asked for is a silent lie: the take passes and the screen
|
|
918
|
+
// shows live data where the demo promised canned. Worth a failed check, not
|
|
919
|
+
// a footnote — this is the one the reporter spent a morning on.
|
|
920
|
+
const dead = Object.entries(take.stubHits ?? {}).filter(([, n]) => !n).map(([k]) => k);
|
|
921
|
+
if (take.stubbed?.length) {
|
|
922
|
+
if (dead.length)
|
|
923
|
+
say(false, `${dead.length} stub${dead.length > 1 ? "s" : ""} never answered a request: ${dead.join(", ")} — those screens showed the app's real data. Check the URL pattern matches, and that the page actually re-fetched (a navigate that changes only the #fragment does not reload an SPA).`);
|
|
924
|
+
else
|
|
925
|
+
say(true, `all ${take.stubbed.length} stub${take.stubbed.length > 1 ? "s" : ""} answered at least one request`);
|
|
926
|
+
}
|
|
586
927
|
const mb = (f) => (fs.statSync(f).size / 1e6).toFixed(1) + " MB";
|
|
587
928
|
say(true, `demo.mp4: ${mb(mp4)}`);
|
|
588
929
|
const master = path.join(outDir, "master.mp4");
|
|
@@ -601,7 +942,13 @@ export function check(outDir, m) {
|
|
|
601
942
|
lines.push("— thumbnail: not requested");
|
|
602
943
|
say(fs.existsSync(path.join(outDir, "proof-log.md")), "proof log exists");
|
|
603
944
|
say(take.ok, take.ok ? "all steps passed" : "some steps failed");
|
|
604
|
-
|
|
945
|
+
// A fragment recorded on purpose (--from / --until) is not a defect. Saying
|
|
946
|
+
// FAIL about work that did exactly what was asked is how people learn to
|
|
947
|
+
// skim past checks, including the ones that matter.
|
|
948
|
+
if (fragment)
|
|
949
|
+
say(true, `fragment — ${take.partial}`);
|
|
950
|
+
else
|
|
951
|
+
say(!take.partial, take.partial ? `partial: ${take.partial}` : "polished render (not fallback)");
|
|
605
952
|
if (take.partial && /NO CURSOR/.test(take.partial))
|
|
606
953
|
say(false, "cursor overlay failed — the video has no cursor (split the demo or set cursor: false)");
|
|
607
954
|
const sc = scenes(m ? applyManifest(m, take) : take);
|
|
@@ -632,11 +979,27 @@ function renderProof(m, take, q, a) {
|
|
|
632
979
|
"",
|
|
633
980
|
`- URL: ${m.url}`,
|
|
634
981
|
`- 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}`,
|
|
982
|
+
// Measured during the take, not computed from the preset: `zoom` does not
|
|
983
|
+
// move an app's media queries, so width÷scale would be a plausible lie.
|
|
984
|
+
// Named outright because a responsive app serves a different layout below
|
|
985
|
+
// a breakpoint, and this is the number that explains which one you filmed.
|
|
986
|
+
...(take.contentWidth && take.layoutWidth && take.contentWidth !== take.layoutWidth
|
|
987
|
+
? [`- Page width: content had **${take.contentWidth} CSS px** of room (viewport ${take.layoutWidth} ÷ ${q.scale}× scale), while media queries read **${take.layoutWidth} px**. A responsive app therefore renders its DESKTOP layout squeezed into ${take.contentWidth}px rather than switching to mobile — if this take looks cramped, that is why, and \`scale: 1\` is the fix.`]
|
|
988
|
+
: take.layoutWidth ? [`- Page laid out at: **${take.layoutWidth} CSS px** wide.`] : []),
|
|
635
989
|
`- Started: ${take.startedAt}`,
|
|
636
990
|
`- Finished: ${take.finishedAt}`,
|
|
637
991
|
`- Result: **${take.ok ? "all steps passed" : "some steps failed"}**${take.partial ? ` · **partial** — ${take.partial}` : ""}`,
|
|
638
992
|
`- Setup trimmed: ${take.trimBefore.toFixed(1)}s · final length ≈ ${(take.duration - take.trimBefore).toFixed(1)}s`,
|
|
639
|
-
|
|
993
|
+
// A nudged demo no longer matches raw step timing; say so, or the next
|
|
994
|
+
// person reads the timeline against the video and thinks one is wrong.
|
|
995
|
+
...(() => {
|
|
996
|
+
const n = (m.steps ?? []).filter((st) => st.action === "scene" && st.nudge).map((st) => `${st.label} ${st.nudge > 0 ? "+" : ""}${(st.nudge / 1000).toFixed(1)}s`);
|
|
997
|
+
return n.length ? [`- **Scene markers moved at render**: ${n.join(", ")} — the captions and stills follow these, not the raw step times below.`] : [];
|
|
998
|
+
})(),
|
|
999
|
+
...(take.stubbed?.length ? [`- **Stubbed data**: ${take.stubbed.map((k) => `${k}${take.stubHits ? ` (${take.stubHits[k] ?? 0} request${(take.stubHits[k] ?? 0) === 1 ? "" : "s"})` : ""}`).join(", ")} — these screens were fed canned responses, not a live backend.`] : []),
|
|
1000
|
+
...(take.stubHits && Object.entries(take.stubHits).some(([, n]) => !n)
|
|
1001
|
+
? [`- **Stubs that never fired**: ${Object.entries(take.stubHits).filter(([, n]) => !n).map(([k]) => k).join(", ")} — armed, but no request matched, so those screens showed whatever the app really returned. Usually the pattern does not match the real URL, or the page never re-fetched (a navigation that only changes the #fragment does not reload an SPA).`]
|
|
1002
|
+
: []),
|
|
640
1003
|
"",
|
|
641
1004
|
"## Artifacts",
|
|
642
1005
|
"",
|