retake-demos 0.3.0 → 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/dist/render.js CHANGED
@@ -16,8 +16,9 @@ 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 { execFileSync, spawnSync } from "node:child_process";
20
- import { capSecondsFor } from "./record.js";
19
+ import YAML from "yaml";
20
+ import { execFile, execFileSync, spawnSync } from "node:child_process";
21
+ import { capSecondsFor, restorePrevious } from "./record.js";
21
22
  import { bandHeightFor, maxCharsFor, wrap } from "./captions.js";
22
23
  import { renderCard, renderCalloutOverlay, renderTitledCover } from "./cards.js";
23
24
  import { describeIdle, planIdle, warpFilterArgs, warpTake } from "./pace.js";
@@ -127,6 +128,45 @@ export function applyManifest(m, take) {
127
128
  const duration = Math.max(trimBefore + 1, take.duration - tail);
128
129
  return { ...take, timeline, trimBefore, duration };
129
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
+ }
130
170
  export function scenes(take) {
131
171
  return take.timeline.filter((t) => t.action === "scene");
132
172
  }
@@ -244,12 +284,31 @@ async function renderCardFrame(file, W, H, g, bg) {
244
284
  }
245
285
  }
246
286
  // --- render ------------------------------------------------------------------
247
- export async function render(m, take, outDir, opts = {}) {
287
+ export async function render(m, takeIn, outDir, opts = {}) {
288
+ let take = takeIn;
248
289
  const o = typeof opts === "function" ? { log: opts } : opts;
249
290
  const log = o.log;
250
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
+ }
251
301
  const proofLog = path.join(outDir, "proof-log.md");
252
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
+ }
253
312
  if (!o.scene)
254
313
  writeProof({});
255
314
  if (!take.video)
@@ -317,8 +376,11 @@ export async function render(m, take, outDir, opts = {}) {
317
376
  const SH = take.quality?.height ?? q.viewport.height;
318
377
  // band/overlay/none: the canvas IS the recording (site fills the frame).
319
378
  // card: the preset canvas, with the recording framed inside it.
320
- const W = q.layout === "card" ? q.width : SW;
321
- const H = q.layout === "card" ? q.height : SH;
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;
322
384
  const fps = q.fps;
323
385
  const bg = q.theme.background;
324
386
  const ink = q.theme.ink;
@@ -339,12 +401,17 @@ export async function render(m, take, outDir, opts = {}) {
339
401
  let geom = null;
340
402
  const capSize = q.captions ? q.captions.fontSize : 0;
341
403
  // One band height for the whole take, from its captions (see captions.ts).
342
- const bandH = bandHeightFor(q, W, captionWindows(take).map((w) => w.text), H);
404
+ const bandH = bandHeightFor(q, captionWindows(take).map((w) => w.text));
343
405
  if (layout === "band") {
344
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}`);
345
412
  if (bandH) {
346
- filters.push(`pad=${W}:${H + bandH}:0:0:color=${bg}`);
347
- capY = `${H}+${bandH / 2}-th/2`;
413
+ filters.push(`pad=${W}:${H}:0:0:color=${bg}`);
414
+ capY = `${pageH}+${bandH / 2}-th/2`;
348
415
  }
349
416
  }
350
417
  else if (layout === "card") {
@@ -359,7 +426,10 @@ export async function render(m, take, outDir, opts = {}) {
359
426
  capColor = q.captions ? q.captions.color ?? "white" : "white";
360
427
  capBox = ":box=1:boxcolor=black@0.55:boxborderw=18";
361
428
  }
362
- filters.push("format=yuv420p");
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");
363
433
  const captionFilters = [];
364
434
  if (q.captions && layout !== "none") {
365
435
  const maxChars = maxCharsFor(W, capSize);
@@ -573,20 +643,48 @@ export async function render(m, take, outDir, opts = {}) {
573
643
  at = last ? last.start - take.trimBefore + 1.0 : Math.max(0, take.duration - take.trimBefore - 0.5);
574
644
  }
575
645
  thumbnail = path.join(outDir, "thumbnail.png");
576
- ff(["-ss", Math.max(0, at + cardShift).toFixed(2), "-i", mp4, "-frames:v", "1", thumbnail], log);
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
+ }
577
661
  // A launch cut gets a second candidate: this frame with the title on it.
578
662
  // The person picks between them (or any still, or their own file) in the
579
663
  // window; both are just PNGs, and the poster is whichever is thumbnail.png.
580
- if (m.mode === "launch" && m.intro) {
664
+ if (m.mode === "launch" && m.intro && thumbnail) {
581
665
  const d = probe(mp4);
582
666
  await renderTitledCover(ffmpegBin(), thumbnail, path.join(outDir, "cover-titled.png"), m.intro, d.width, d.height, q.theme);
583
667
  }
584
668
  mark("thumbnail");
585
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
+ }
586
681
  // --- 5. scene stills -------------------------------------------------------
587
682
  // Every scene already carries its real timestamp, so one crisp frame per
588
683
  // scene is free — and a video usually ships with a picture next to it.
589
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 = [];
590
688
  if (m.outputs.stills) {
591
689
  const dir = path.join(outDir, "stills");
592
690
  fs.rmSync(dir, { recursive: true, force: true });
@@ -601,17 +699,29 @@ export async function render(m, take, outDir, opts = {}) {
601
699
  const at = Math.min(Math.max(0, from + Math.max(0.8, (next - from) / 2)), Math.max(0, end - 0.2));
602
700
  const base = `${String(i + 1).padStart(2, "0")}-${(sce.label || "scene").replace(/[^a-z0-9-]+/gi, "-")}`;
603
701
  const file = path.join(dir, `${base}.png`);
604
- ff(["-ss", (at + cardShift).toFixed(2), "-i", mp4, "-frames:v", "1", file], log);
702
+ shots.push({ at: at + cardShift, file });
605
703
  stills.push(file);
606
704
  // And the scene's last moment: the caption names what the scene
607
705
  // achieves, and that is usually on screen at the end, not the middle.
608
706
  const atEnd = Math.min(Math.max(from + 0.4, next - 0.6), Math.max(0, end - 0.2));
609
707
  if (atEnd - at > 0.5) {
610
708
  const fe = path.join(dir, `${base}-end.png`);
611
- ff(["-ss", (atEnd + cardShift).toFixed(2), "-i", mp4, "-frames:v", "1", fe], log);
709
+ shots.push({ at: atEnd + cardShift, file: fe });
612
710
  stills.push(fe);
613
711
  }
614
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
+ }
615
725
  if (sc.length)
616
726
  mark(`stills ×${sc.length}`);
617
727
  }
@@ -620,8 +730,9 @@ export async function render(m, take, outDir, opts = {}) {
620
730
  const p = probe(mp4);
621
731
  const facts = {
622
732
  width: p.width, height: p.height, fps: p.fps || fps, duration: p.duration,
623
- 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])),
624
734
  gifTool, layout, cameraScenes: cam.count, encoder: q.encoder, timings: t.marks, renderHash: hash, preset: q.name,
735
+ mode: m.mode,
625
736
  };
626
737
  const a = { master, mp4, gif, thumbnail, proofLog, facts };
627
738
  writeProof(a);
@@ -629,6 +740,38 @@ export async function render(m, take, outDir, opts = {}) {
629
740
  return a;
630
741
  }
631
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
+ }
632
775
  export function makeGif(outDir, width = 900, fps = 18, log) {
633
776
  const mp4 = path.join(outDir, "demo.mp4");
634
777
  if (!fs.existsSync(mp4))
@@ -668,9 +811,17 @@ export function check(outDir, m) {
668
811
  const say = (pass, text) => { lines.push(`${pass ? "pass" : "FAIL"} ${text}`); if (!pass)
669
812
  ok = false; };
670
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);
671
820
  if (!fs.existsSync(takePath))
672
821
  return { ok: false, lines: ["FAIL no take.json"] };
673
- const take = JSON.parse(fs.readFileSync(takePath, "utf8"));
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;
674
825
  // The take knows which preset it was recorded with; a `--preset` on the run
675
826
  // must win over the manifest's default, or a draft fails for lacking a master.
676
827
  const q = m ? resolve(take.quality?.preset && take.quality.preset !== m.preset ? { ...m, preset: take.quality.preset } : m) : null;
@@ -678,17 +829,23 @@ export function check(outDir, m) {
678
829
  if (!fs.existsSync(mp4))
679
830
  return { ok: false, lines: ["FAIL demo.mp4 missing"] };
680
831
  const p = probe(mp4);
681
- const wantW = q ? (q.layout === "card" ? q.width : (take.quality?.width ?? q.viewport.width)) : undefined;
832
+ const wantW = q?.width;
682
833
  say(true, `resolution: ${p.width}×${p.height}`);
683
834
  if (wantW)
684
- say(p.width === wantW, `matches expected width ${wantW}`);
835
+ say(p.width === wantW, `matches ${q?.name}: ${wantW}px wide`);
685
836
  say(p.width >= 960, `at least 960px wide`);
686
837
  say(true, `fps: ${p.fps}`);
687
838
  /* A take may legitimately run long — a full product walkthrough does.
688
839
  The manifest's own maxSeconds is the author's stated bound; a take
689
840
  inside it is not a runaway, so do not fail it for length. */
690
841
  const longest = m ? capSecondsFor(m) : 300;
691
- say(p.duration >= 5 && p.duration <= longest, `duration: ${p.duration.toFixed(1)}s`);
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`);
692
849
  if (p.duration > 60)
693
850
  lines.push(`— ${p.duration.toFixed(0)}s is long for a social post (aim for 15–45s); fine for a walkthrough`);
694
851
  /* A step that stalls is not a failed step, so nothing above notices it — but
@@ -697,23 +854,76 @@ export function check(outDir, m) {
697
854
  other seven navigations took under four seconds; every check passed.
698
855
  `wait` and `scene` are excluded because their whole job is to take time. */
699
856
  const PATIENCE = 8;
700
- const stalled = (take.timeline || [])
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 || [])
701
866
  .filter((t) => t.action !== "wait" && t.action !== "scene" && t.action !== "stub")
702
867
  .map((t) => ({ ...t, took: t.end - t.start }))
703
868
  .filter((t) => t.took > PATIENCE)
704
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
+ }
705
885
  if (stalled.length) {
706
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
+ }
707
905
  say(false, `${stalled.length} step${stalled.length > 1 ? "s" : ""} stalled over ${PATIENCE}s — ` +
708
906
  `the take holds a still frame there. Worst: ${worst.took.toFixed(1)}s at ` +
709
907
  `${Math.floor(worst.start / 60)}:${String(Math.floor(worst.start % 60)).padStart(2, "0")} — ${worst.summary}`);
710
- for (const t of stalled.slice(1, 4)) {
711
- lines.push(`— also ${t.took.toFixed(1)}s: ${t.summary}`);
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]})` : ""}`);
712
912
  }
713
913
  }
714
914
  else {
715
915
  say(true, `no step stalled (nothing over ${PATIENCE}s)`);
716
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
+ }
717
927
  const mb = (f) => (fs.statSync(f).size / 1e6).toFixed(1) + " MB";
718
928
  say(true, `demo.mp4: ${mb(mp4)}`);
719
929
  const master = path.join(outDir, "master.mp4");
@@ -732,7 +942,13 @@ export function check(outDir, m) {
732
942
  lines.push("— thumbnail: not requested");
733
943
  say(fs.existsSync(path.join(outDir, "proof-log.md")), "proof log exists");
734
944
  say(take.ok, take.ok ? "all steps passed" : "some steps failed");
735
- say(!take.partial, take.partial ? `partial: ${take.partial}` : "polished render (not fallback)");
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)");
736
952
  if (take.partial && /NO CURSOR/.test(take.partial))
737
953
  say(false, "cursor overlay failed — the video has no cursor (split the demo or set cursor: false)");
738
954
  const sc = scenes(m ? applyManifest(m, take) : take);
@@ -763,11 +979,27 @@ function renderProof(m, take, q, a) {
763
979
  "",
764
980
  `- URL: ${m.url}`,
765
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.`] : []),
766
989
  `- Started: ${take.startedAt}`,
767
990
  `- Finished: ${take.finishedAt}`,
768
991
  `- Result: **${take.ok ? "all steps passed" : "some steps failed"}**${take.partial ? ` · **partial** — ${take.partial}` : ""}`,
769
992
  `- Setup trimmed: ${take.trimBefore.toFixed(1)}s · final length ≈ ${(take.duration - take.trimBefore).toFixed(1)}s`,
770
- ...(take.stubbed?.length ? [`- **Stubbed data**: ${take.stubbed.join(", ")} — these screens were fed canned responses, not a live backend.`] : []),
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
+ : []),
771
1003
  "",
772
1004
  "## Artifacts",
773
1005
  "",
package/dist/tidy.js ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Reclaiming disk without losing anything you cannot get back.
3
+ *
4
+ * Measured across 37 real demos: 506 MB of outputs, of which 345 MB was
5
+ * DERIVED — master.mp4, demo.mp4, stills, thumbnails and gifs all re-render
6
+ * from `take.json` plus the raw recording, in seconds. Only the raw `.webm`
7
+ * and `take.json` are irreplaceable: lose those and the demo has to be
8
+ * performed again, which is the one cost this tool exists to avoid.
9
+ *
10
+ * So tidy never touches those two, reports before it removes anything, and
11
+ * separates "safe, re-renders in seconds" from "the whole take is rubbish".
12
+ */
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ const bytesOf = (p) => {
16
+ try {
17
+ const st = fs.statSync(p);
18
+ if (!st.isDirectory())
19
+ return st.size;
20
+ let n = 0;
21
+ for (const f of fs.readdirSync(p))
22
+ n += bytesOf(path.join(p, f));
23
+ return n;
24
+ }
25
+ catch {
26
+ return 0;
27
+ }
28
+ };
29
+ export const mb = (n) => `${(n / 1e6).toFixed(0)} MB`;
30
+ /** Derived files: every one of these comes back from a re-render. */
31
+ const DERIVED = ["master.mp4", "thumbnail.png", "cover.png", "cover-titled.png", "contact.png", "stills"];
32
+ const DERIVED_RE = [/\.gif$/, /^\.card-.*\.mp4$/, /^\.callout-.*\.webm$/, /^\.paced\.mp4$/, /^\.joined\.mp4$/, /^debug-filter\.txt$/, /^stalled-\d+\.png$/, /^dry-failed-\d+\.png$/, /^failed-step\.png$/, /^not-the-app\.png$/];
33
+ export function planTidy(outRoot, o) {
34
+ const groups = [];
35
+ const add = (what, why, files) => {
36
+ if (!files.length)
37
+ return;
38
+ groups.push({ what, why, files, bytes: files.reduce((n, f) => n + bytesOf(f), 0) });
39
+ };
40
+ if (!fs.existsSync(outRoot))
41
+ return { groups: [], bytes: 0, keptBytes: 0 };
42
+ const failed = [], orphaned = [], derived = [], deliverables = [];
43
+ let keptBytes = 0;
44
+ for (const name of fs.readdirSync(outRoot)) {
45
+ const dir = path.join(outRoot, name);
46
+ if (!fs.statSync(dir).isDirectory())
47
+ continue;
48
+ const takePath = path.join(dir, "take.json");
49
+ const hasManifest = fs.existsSync(path.join(o.demosDir, `${name}.yaml`)) || fs.existsSync(path.join(o.demosDir, `${name}.json`));
50
+ // A whole folder with no manifest cannot be re-rendered or re-recorded
51
+ // from anything in this workspace — it is a leftover, not an asset.
52
+ if (!hasManifest) {
53
+ orphaned.push(dir);
54
+ continue;
55
+ }
56
+ let take = null;
57
+ try {
58
+ take = JSON.parse(fs.readFileSync(takePath, "utf8"));
59
+ }
60
+ catch { /* no take */ }
61
+ // A take whose steps failed is not something anyone goes back to.
62
+ if (take && take.ok === false) {
63
+ failed.push(dir);
64
+ continue;
65
+ }
66
+ for (const f of fs.readdirSync(dir)) {
67
+ const full = path.join(dir, f);
68
+ if (f === "take.json" || f.endsWith(".webm") || f === ".retake-lock" || f === ".poster" || f === ".history" || f === "manifest.used.yaml" || f === "proof-log.md" || f === "facts.json") {
69
+ keptBytes += bytesOf(full);
70
+ continue;
71
+ }
72
+ if (f === "demo.mp4") {
73
+ (o.deep ? deliverables : null)?.push(full);
74
+ if (!o.deep)
75
+ keptBytes += bytesOf(full);
76
+ continue;
77
+ }
78
+ if (DERIVED.includes(f) || DERIVED_RE.some((re) => re.test(f))) {
79
+ derived.push(full);
80
+ continue;
81
+ }
82
+ keptBytes += bytesOf(full);
83
+ }
84
+ }
85
+ add("re-renderable", "master, stills, thumbnails, gifs, working files — all come back from `retake render`, in seconds", derived);
86
+ add("failed takes", "their steps did not pass; nobody goes back to a broken recording", failed);
87
+ if (o.orphans)
88
+ add("orphaned folders", "no manifest in demos/ — nothing here can be re-recorded or re-rendered", orphaned);
89
+ if (o.deep)
90
+ add("deliverables", "demo.mp4 — re-renders from the recording, but it IS the file you hand over", deliverables);
91
+ const bytes = groups.reduce((n, g) => n + g.bytes, 0);
92
+ return { groups, bytes, keptBytes };
93
+ }
94
+ export function applyTidy(plan) {
95
+ let freed = 0;
96
+ for (const g of plan.groups) {
97
+ for (const f of g.files) {
98
+ freed += bytesOf(f);
99
+ fs.rmSync(f, { recursive: true, force: true });
100
+ }
101
+ }
102
+ return freed;
103
+ }