retake-demos 0.3.0 → 0.4.1
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/AGENTS.md +64 -0
- package/README.md +14 -11
- package/dist/captions.js +9 -10
- package/dist/cards.js +57 -14
- package/dist/cli.js +197 -20
- package/dist/describe.js +2 -1
- package/dist/dryrun.js +182 -2
- package/dist/manifest.js +67 -4
- package/dist/operator/tools.js +120 -24
- package/dist/presets.js +13 -13
- package/dist/record.js +458 -43
- package/dist/render.js +255 -23
- package/dist/tidy.js +103 -0
- package/dist/ui/chat.html +346 -56
- package/dist/ui/server.js +76 -11
- package/package.json +2 -3
- package/site/releases.json +55 -0
- package/skill/SKILL.md +148 -2
- package/demos/example.yaml +0 -65
- package/dist/ui/index.html +0 -724
- package/site/index.html +0 -260
- package/site/logos/avexjets.png +0 -0
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
|
|
20
|
-
import {
|
|
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,
|
|
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
|
-
|
|
321
|
-
|
|
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,
|
|
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
|
|
347
|
-
capY = `${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
832
|
+
const wantW = q?.width;
|
|
682
833
|
say(true, `resolution: ${p.width}×${p.height}`);
|
|
683
834
|
if (wantW)
|
|
684
|
-
say(p.width === wantW, `matches
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
711
|
-
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]})` : ""}`);
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|