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/cards.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Motion pieces rendered from HTML — title cards and callout overlays.
|
|
3
|
+
*
|
|
4
|
+
* Chromium is the motion renderer: a template page defines `__seek(tMs)`
|
|
5
|
+
* which lays the frame out for that exact moment, and Node steps through
|
|
6
|
+
* the timeline one screenshot per video frame. Deterministic to the pixel —
|
|
7
|
+
* the same take renders the same twice — and styled with the same theme as
|
|
8
|
+
* the caption band, so cards look like Retake, not like a slide deck.
|
|
9
|
+
*
|
|
10
|
+
* All of it is render-time: change a title, `retake render`, seconds.
|
|
11
|
+
*/
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { chromium } from "playwright";
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
17
|
+
const AMBER = "#e8b04b";
|
|
18
|
+
const ease = "t<0?0:t>1?1:t*t*(3-2*t)"; // smoothstep, in the page
|
|
19
|
+
function cardHtml(w, h, theme, title, subtitle, ms) {
|
|
20
|
+
const titleSize = Math.round(Math.min(w * 0.055, h * 0.14));
|
|
21
|
+
const subSize = Math.round(titleSize * 0.4);
|
|
22
|
+
return `<!doctype html><meta charset="utf-8"><style>
|
|
23
|
+
html,body{margin:0;width:${w}px;height:${h}px;overflow:hidden;background:${theme.background}}
|
|
24
|
+
.wrap{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","Helvetica Neue",Arial,sans-serif}
|
|
25
|
+
.title{font-size:${titleSize}px;font-weight:700;letter-spacing:-.02em;color:${theme.ink};opacity:0}
|
|
26
|
+
.rule{height:${Math.max(3, Math.round(titleSize * 0.07))}px;background:${AMBER};border-radius:99px;margin:${Math.round(titleSize * 0.35)}px 0;width:0}
|
|
27
|
+
.sub{font-size:${subSize}px;color:${theme.ink};opacity:0;font-weight:400}
|
|
28
|
+
</style>
|
|
29
|
+
<div class="wrap"><div class="title"></div><div class="rule"></div><div class="sub"></div></div>
|
|
30
|
+
<script>
|
|
31
|
+
const T = document.querySelector(".title"), R = document.querySelector(".rule"), S = document.querySelector(".sub");
|
|
32
|
+
T.textContent = ${JSON.stringify(title)}; S.textContent = ${JSON.stringify(subtitle ?? "")};
|
|
33
|
+
const DUR = ${ms}, FADE = Math.min(420, DUR * 0.22), OUT = Math.min(320, DUR * 0.18);
|
|
34
|
+
const ez = (t) => (${ease});
|
|
35
|
+
// The window in which nothing moves. The rule keeps growing until FADE*2 —
|
|
36
|
+
// well past the title's own fade — so this cannot be guessed from the
|
|
37
|
+
// outside, and the renderer asks the template rather than assuming.
|
|
38
|
+
window.__still = [FADE * 2, DUR - OUT];
|
|
39
|
+
window.__seek = (ms) => {
|
|
40
|
+
const a = ez(ms / FADE); // entrance
|
|
41
|
+
const o = ez((DUR - ms) / OUT); // exit
|
|
42
|
+
const v = Math.min(a, o);
|
|
43
|
+
T.style.opacity = v; T.style.transform = "translateY(" + (1 - a) * 14 + "px)";
|
|
44
|
+
R.style.width = (ez((ms - FADE * 0.4) / (FADE * 1.6)) * ${Math.round(titleSize * 3.2)}) * Math.min(1, o + 0.0) + "px";
|
|
45
|
+
R.style.opacity = Math.min(1, o + 0.2);
|
|
46
|
+
S.style.opacity = v; S.style.transform = "translateY(" + (1 - a) * 10 + "px)";
|
|
47
|
+
return true;
|
|
48
|
+
};
|
|
49
|
+
</script>`;
|
|
50
|
+
}
|
|
51
|
+
function calloutHtml(w, h, box, label, ms, fontSize, ink) {
|
|
52
|
+
const pad = 10;
|
|
53
|
+
const r = { x: box.x - pad, y: box.y - pad, w: box.width + 2 * pad, h: box.height + 2 * pad };
|
|
54
|
+
const below = r.y + r.h + 18 + fontSize * 2 < h; // label under the ring when there is room
|
|
55
|
+
return `<!doctype html><meta charset="utf-8"><style>
|
|
56
|
+
html,body{margin:0;width:${w}px;height:${h}px;overflow:hidden;background:transparent}
|
|
57
|
+
.ring{position:absolute;left:${r.x}px;top:${r.y}px;width:${r.w}px;height:${r.h}px;border:3px solid ${AMBER};border-radius:14px;box-shadow:0 0 0 3px rgba(0,0,0,.06);opacity:0}
|
|
58
|
+
.tag{position:absolute;left:${Math.max(8, Math.min(w - 8, r.x))}px;top:${below ? r.y + r.h + 12 : Math.max(8, r.y - fontSize * 1.9 - 12)}px;background:${ink};color:#f4f4ef;font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text",Arial,sans-serif;font-size:${fontSize}px;font-weight:600;line-height:1;padding:${Math.round(fontSize * 0.55)}px ${Math.round(fontSize * 0.8)}px;border-radius:10px;opacity:0;max-width:${Math.round(w * 0.6)}px;white-space:nowrap}
|
|
59
|
+
</style>
|
|
60
|
+
<div class="ring"></div>${label ? '<div class="tag"></div>' : ""}
|
|
61
|
+
<script>
|
|
62
|
+
const ring = document.querySelector(".ring"), tag = document.querySelector(".tag");
|
|
63
|
+
if (tag) tag.textContent = ${JSON.stringify(label ?? "")};
|
|
64
|
+
const DUR = ${ms}, IN = Math.min(360, DUR * 0.25), OUT = Math.min(280, DUR * 0.2);
|
|
65
|
+
const ez = (t) => (${ease});
|
|
66
|
+
// The tag lands last, at IN*1.6 — later than the ring's own entrance.
|
|
67
|
+
window.__still = [IN * 1.6, DUR - OUT];
|
|
68
|
+
window.__seek = (ms) => {
|
|
69
|
+
const a = ez(ms / IN), o = ez((DUR - ms) / OUT), v = Math.min(a, o);
|
|
70
|
+
ring.style.opacity = v;
|
|
71
|
+
ring.style.transform = "scale(" + (1.12 - 0.12 * a) + ")";
|
|
72
|
+
if (tag) { tag.style.opacity = Math.min(ez((ms - IN * 0.6) / IN), o); tag.style.transform = "translateY(" + (1 - a) * 6 + "px)"; }
|
|
73
|
+
return true;
|
|
74
|
+
};
|
|
75
|
+
</script>`;
|
|
76
|
+
}
|
|
77
|
+
/** Screenshot a __seek()-driven page at every frame; returns the frame dir.
|
|
78
|
+
*
|
|
79
|
+
* A card only MOVES at its ends: it eases in, holds, eases out. Photographing
|
|
80
|
+
* the held middle was paying ~250ms a frame for an unchanging picture — 30s of
|
|
81
|
+
* a launch cut's render, for two cards. So the held frames are copies of the
|
|
82
|
+
* settled one, at a millisecond each.
|
|
83
|
+
*
|
|
84
|
+
* Which frames those are is the template's business, not this function's. It
|
|
85
|
+
* publishes `__still = [from, to]`, because the answer is not guessable from
|
|
86
|
+
* outside: the card's rule keeps growing until twice the title's fade, and a
|
|
87
|
+
* renderer that assumed otherwise froze it half-drawn. A template with no
|
|
88
|
+
* `__still` gets every frame shot — the slow path is the safe default. */
|
|
89
|
+
async function renderFrames(html, w, h, ms, fps, transparent) {
|
|
90
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "retake-frames-"));
|
|
91
|
+
const browser = await chromium.launch();
|
|
92
|
+
try {
|
|
93
|
+
const page = await browser.newPage({ viewport: { width: w, height: h } });
|
|
94
|
+
await page.setContent(html, { waitUntil: "load" });
|
|
95
|
+
const frames = Math.max(2, Math.round((ms / 1000) * fps));
|
|
96
|
+
const still = await page.evaluate(() => window.__still);
|
|
97
|
+
// One frame of margin at each end, so rounding never lands the copied
|
|
98
|
+
// frame on a moment the template was still moving.
|
|
99
|
+
const margin = 1000 / fps;
|
|
100
|
+
const held = (t) => !!still && still[1] - still[0] > 2 * margin && t * 1000 >= still[0] + margin && t * 1000 <= still[1] - margin;
|
|
101
|
+
let settled = "";
|
|
102
|
+
for (let i = 0; i < frames; i++) {
|
|
103
|
+
const t = i / fps;
|
|
104
|
+
const file = path.join(dir, `f${String(i).padStart(5, "0")}.png`);
|
|
105
|
+
if (!held(t) || !settled) {
|
|
106
|
+
await page.evaluate((ms2) => window.__seek(ms2), t * 1000);
|
|
107
|
+
await page.screenshot({ path: file, omitBackground: transparent });
|
|
108
|
+
if (held(t) && !settled)
|
|
109
|
+
settled = file;
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
fs.copyFileSync(settled, file);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return dir;
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
await browser.close();
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** A title card as an H.264 segment at the final canvas size, plus (for the
|
|
122
|
+
intro) the settled middle frame as cover.png — the poster. */
|
|
123
|
+
export async function renderCard(ffmpeg, outDir, kind, spec, w, h, fps, theme) {
|
|
124
|
+
const dir = await renderFrames(cardHtml(w, h, theme, spec.title, spec.subtitle, spec.ms), w, h, spec.ms, fps, false);
|
|
125
|
+
const mp4 = path.join(outDir, `.card-${kind}.mp4`);
|
|
126
|
+
execFileSync(ffmpeg, ["-y", "-loglevel", "error", "-framerate", String(fps), "-i", path.join(dir, "f%05d.png"), "-c:v", "libx264", "-crf", "17", "-preset", "veryfast", "-pix_fmt", "yuv420p", mp4], { stdio: "inherit" });
|
|
127
|
+
let cover;
|
|
128
|
+
if (kind === "intro") {
|
|
129
|
+
const frames = fs.readdirSync(dir).sort();
|
|
130
|
+
const settled = frames[Math.min(frames.length - 1, Math.round(frames.length * 0.55))];
|
|
131
|
+
cover = path.join(outDir, "cover.png");
|
|
132
|
+
fs.copyFileSync(path.join(dir, settled), cover);
|
|
133
|
+
}
|
|
134
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
135
|
+
return { mp4, cover };
|
|
136
|
+
}
|
|
137
|
+
/** A callout as a VP9 segment with alpha, overlaid onto the take at render. */
|
|
138
|
+
export async function renderCalloutOverlay(ffmpeg, outDir, index, c, w, h, fps, fontSize, ink) {
|
|
139
|
+
const dir = await renderFrames(calloutHtml(w, h, c.box, c.label, c.ms, fontSize, ink), w, h, c.ms, fps, true);
|
|
140
|
+
const webm = path.join(outDir, `.callout-${index}.webm`);
|
|
141
|
+
execFileSync(ffmpeg, ["-y", "-loglevel", "error", "-framerate", String(fps), "-i", path.join(dir, "f%05d.png"), "-c:v", "libvpx-vp9", "-pix_fmt", "yuva420p", "-b:v", "0", "-crf", "28", webm], { stdio: "inherit" });
|
|
142
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
143
|
+
return webm;
|
|
144
|
+
}
|
|
145
|
+
/** A cover built from a real frame of the video with the title laid over it —
|
|
146
|
+
a screenshot plus a few words, which is what actually stops a scroll. Not
|
|
147
|
+
a skin of the centred card: the product is the picture. */
|
|
148
|
+
export async function renderTitledCover(ffmpeg, frame, out, spec, w, h, theme) {
|
|
149
|
+
const b64 = fs.readFileSync(frame).toString("base64");
|
|
150
|
+
const titleSize = Math.round(Math.min(w * 0.045, h * 0.1));
|
|
151
|
+
const subSize = Math.round(titleSize * 0.42);
|
|
152
|
+
const html = `<!doctype html><meta charset="utf-8"><style>
|
|
153
|
+
html,body{margin:0;width:${w}px;height:${h}px;overflow:hidden;background:${theme.background};font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","Helvetica Neue",Arial,sans-serif}
|
|
154
|
+
.shot{position:absolute;inset:0;background:url(data:image/png;base64,${b64}) center/cover no-repeat}
|
|
155
|
+
.veil{position:absolute;inset:0;background:linear-gradient(to bottom, rgba(0,0,0,0) 38%, rgba(0,0,0,.14) 58%, ${theme.background} 78%)}
|
|
156
|
+
.txt{position:absolute;left:0;right:0;bottom:${Math.round(h * 0.055)}px;text-align:center;padding:0 ${Math.round(w * 0.08)}px}
|
|
157
|
+
.t{font-size:${titleSize}px;font-weight:700;letter-spacing:-.02em;color:${theme.ink};margin:0}
|
|
158
|
+
.r{height:${Math.max(3, Math.round(titleSize * 0.08))}px;width:${Math.round(titleSize * 2.6)}px;background:${AMBER};border-radius:99px;margin:${Math.round(titleSize * 0.3)}px auto}
|
|
159
|
+
.s{font-size:${subSize}px;color:${theme.ink};margin:0;opacity:.75}
|
|
160
|
+
</style>
|
|
161
|
+
<div class="shot"></div><div class="veil"></div>
|
|
162
|
+
<div class="txt"><p class="t">${escapeHtml(spec.title)}</p><div class="r"></div>${spec.subtitle ? `<p class="s">${escapeHtml(spec.subtitle)}</p>` : ""}</div>`;
|
|
163
|
+
const browser = await chromium.launch();
|
|
164
|
+
try {
|
|
165
|
+
const page = await browser.newPage({ viewport: { width: w, height: h } });
|
|
166
|
+
await page.setContent(html, { waitUntil: "load" });
|
|
167
|
+
await page.screenshot({ path: out });
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
await browser.close();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const escapeHtml = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
175
|
+
/** @internal The card template, exported so test/cards.test.ts can prove the
|
|
176
|
+
frame-skipping is byte-identical to shooting every frame. */
|
|
177
|
+
export const __test_cardHtml = cardHtml;
|
package/dist/cli.js
CHANGED
|
@@ -24,11 +24,13 @@ import path from "node:path";
|
|
|
24
24
|
import { createRequire } from "node:module";
|
|
25
25
|
import { spawnSync } from "node:child_process";
|
|
26
26
|
import { Command } from "commander";
|
|
27
|
-
import { loadManifest, warnings } from "./manifest.js";
|
|
28
|
-
import { acquireLock, captureHash, record, releaseLock } from "./record.js";
|
|
27
|
+
import { loadManifest, resolve, warnings } from "./manifest.js";
|
|
28
|
+
import { acquireLock, captureHash, EXPENSIVE_TAKE_SECONDS, keepPrevious, keptTakes, lastCaptureSeconds, record, releaseLock, restoreKept, restorePrevious, stashPrevious } from "./record.js";
|
|
29
29
|
import { check, render } from "./render.js";
|
|
30
30
|
import { presetNames } from "./presets.js";
|
|
31
|
+
import { applyTidy, mb, planTidy } from "./tidy.js";
|
|
31
32
|
import { PKG_ROOT, VERSION, entry } from "./paths.js";
|
|
33
|
+
import { SECRET_NAME, writeEnvFile } from "./env.js";
|
|
32
34
|
// The manifest may reference ${VARS}; load .env before any command reads one.
|
|
33
35
|
// (`describe` and the UI used to do this individually, so a plain `run` from
|
|
34
36
|
// the terminal silently saw empty values — which is how a whole take can fail
|
|
@@ -41,6 +43,35 @@ catch { /* .env is optional */ }
|
|
|
41
43
|
const program = new Command();
|
|
42
44
|
program.name("retake").description("Rerun the demo instead of re-recording it.").version(VERSION);
|
|
43
45
|
const say = (l) => process.stdout.write(l + "\n");
|
|
46
|
+
/** Said once, before the camera rolls, when a take is long enough that the
|
|
47
|
+
cheaper ways of getting the same answer are worth knowing about.
|
|
48
|
+
|
|
49
|
+
A person who has already spent eight full-quality captures learning the
|
|
50
|
+
shape of a demo has paid for something `--preset draft` would have given
|
|
51
|
+
them in a quarter of the time. The tools existed; nothing said so at the
|
|
52
|
+
moment the cost was being incurred. This is that moment. */
|
|
53
|
+
export function costHints(m, outDir, opts) {
|
|
54
|
+
const q = resolve(m);
|
|
55
|
+
// Only says a number it has actually seen. A demo nobody has recorded yet
|
|
56
|
+
// gets no estimate, because the honest one does not exist: step count
|
|
57
|
+
// predicts capture time with ~33% error over 31 real takes, and a confident
|
|
58
|
+
// wrong number is worse than none.
|
|
59
|
+
const last = lastCaptureSeconds(outDir);
|
|
60
|
+
if (last === null || last < EXPENSIVE_TAKE_SECONDS)
|
|
61
|
+
return [];
|
|
62
|
+
const draft = q.name === "draft" || opts.preset === "draft";
|
|
63
|
+
const mins = (n) => (n >= 120 ? `${Math.round(n / 60)} min` : `${Math.round(n)}s`);
|
|
64
|
+
const out = [`ℹ the last take of this demo took ${mins(last)} to record.`];
|
|
65
|
+
const ways = [];
|
|
66
|
+
if (!draft)
|
|
67
|
+
ways.push("`--preset draft` records the same layout and timing at a quarter of the pixels — use it while the demo is still changing, then run the real preset once");
|
|
68
|
+
if (!opts.until)
|
|
69
|
+
ways.push("`--until <scene>` stops after one beat, so fixing the ending does not re-record the beginning");
|
|
70
|
+
ways.push("captions, speed, zoom, cards, poster and SIZE are all re-renders — `retake render outputs/" + m.name + " --preset <other>` never needs the camera again");
|
|
71
|
+
for (const w of ways)
|
|
72
|
+
out.push(` · ${w}`);
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
44
75
|
program
|
|
45
76
|
.command("run")
|
|
46
77
|
.argument("<manifest>", "demo .yaml / .json")
|
|
@@ -53,15 +84,30 @@ program
|
|
|
53
84
|
.option("--reuse", "reuse the last raw recording if nothing that shapes it changed (re-render only)", false)
|
|
54
85
|
.option("--gif", "also produce a GIF (overrides the manifest)", false)
|
|
55
86
|
.option("--until <scene>", "record up to the end of this scene, then stop (iterate on one beat without paying for the whole take)")
|
|
87
|
+
.option("--from <scene>", "start the take at this scene — earlier steps still run, at full speed and off camera, so a demo whose ENDING changed stops costing its beginning")
|
|
88
|
+
.option("--name <name>", "write to outputs/<name> instead of the manifest's name — revise a cut without overwriting the published one")
|
|
56
89
|
.action(async (file, opts) => {
|
|
57
90
|
const loaded = loadManifest(file);
|
|
58
|
-
|
|
91
|
+
// Asking for a preset means asking for its SHAPE. A manifest that pins its
|
|
92
|
+
// own `viewport` used to win silently, so `--preset draft` recorded at
|
|
93
|
+
// full size and saved nothing — on the one demo where iterating cheaply
|
|
94
|
+
// mattered most, and with a failing "matches draft" check as the only
|
|
95
|
+
// hint. An explicit --preset now drops the pin.
|
|
96
|
+
let manifest = loaded.manifest;
|
|
97
|
+
if (opts.preset) {
|
|
98
|
+
const { viewport: pinned, ...rest } = loaded.manifest;
|
|
99
|
+
manifest = { ...rest, preset: opts.preset };
|
|
100
|
+
if (pinned)
|
|
101
|
+
say(`ℹ --preset ${opts.preset} overrides this manifest's viewport (${pinned.width}×${pinned.height}); recording at the preset's own size`);
|
|
102
|
+
}
|
|
59
103
|
if (opts.gif)
|
|
60
104
|
manifest = { ...manifest, outputs: { ...manifest.outputs, gif: true } };
|
|
61
105
|
if (opts.preset && !presetNames().includes(opts.preset))
|
|
62
106
|
throw new Error(`unknown preset ${opts.preset} — one of ${presetNames().join(", ")}`);
|
|
63
107
|
const dir = loaded.dir;
|
|
64
|
-
|
|
108
|
+
if (opts.name && !/^[a-z0-9-]+$/.test(opts.name))
|
|
109
|
+
throw new Error("--name must be kebab-case");
|
|
110
|
+
const outDir = path.resolve(opts.out, opts.name ?? manifest.name);
|
|
65
111
|
acquireLock(outDir); // throws if another run owns this folder
|
|
66
112
|
// Reuse: same capture hash + raw video present → skip the browser.
|
|
67
113
|
let take;
|
|
@@ -76,21 +122,26 @@ program
|
|
|
76
122
|
}
|
|
77
123
|
for (const w of warnings(manifest))
|
|
78
124
|
say(`⚠ ${w}`);
|
|
125
|
+
if (!take)
|
|
126
|
+
for (const l of costHints(manifest, outDir, opts))
|
|
127
|
+
say(l);
|
|
79
128
|
say(`▶ ${manifest.title ?? manifest.name} → ${path.relative(process.cwd(), outDir)}${take ? " · reusing last recording" : ""}`);
|
|
80
129
|
say(`[stage] capture ${take ? "skip" : "start"}`);
|
|
81
130
|
const t0 = Date.now();
|
|
82
131
|
if (!take) {
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
fs.rmSync(path.join(outDir, f), { recursive: true, force: true });
|
|
132
|
+
// The previous take is moved aside, not deleted: if this recording dies
|
|
133
|
+
// the person still has the one that worked. See stashPrevious.
|
|
134
|
+
stashPrevious(outDir);
|
|
87
135
|
try {
|
|
88
|
-
take = await record(manifest, { until: opts.until, outDir, headed: opts.headed, skipSeed: opts.skipSeed, manifestDir: dir, log: say, locked: true });
|
|
136
|
+
take = await record(manifest, { until: opts.until, from: opts.from, outDir, headed: opts.headed, skipSeed: opts.skipSeed, manifestDir: dir, log: say, locked: true });
|
|
89
137
|
}
|
|
90
138
|
catch (e) {
|
|
139
|
+
if (restorePrevious(outDir))
|
|
140
|
+
say("↩ recording failed — your previous take has been put back");
|
|
91
141
|
releaseLock(outDir);
|
|
92
142
|
throw e;
|
|
93
143
|
}
|
|
144
|
+
keepPrevious(outDir, say);
|
|
94
145
|
}
|
|
95
146
|
say(`■ take done in ${((Date.now() - t0) / 1000).toFixed(1)}s · ${take.ok ? "all steps ok" : "SOME STEPS FAILED"} · ${take.video ?? "no video"}`);
|
|
96
147
|
try {
|
|
@@ -119,6 +170,83 @@ program
|
|
|
119
170
|
if (!take.ok || !take.video)
|
|
120
171
|
process.exitCode = 2;
|
|
121
172
|
});
|
|
173
|
+
program
|
|
174
|
+
.command("takes")
|
|
175
|
+
.description("the kept versions of a demo — previous recordings you can go back to")
|
|
176
|
+
.argument("<dir>", "an outputs/<name> dir")
|
|
177
|
+
.action((dir) => {
|
|
178
|
+
const kept = keptTakes(path.resolve(dir));
|
|
179
|
+
if (!kept.length) {
|
|
180
|
+
say(`no kept versions of ${path.basename(dir)} — the current take is the only one.`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
say(`kept versions of ${path.basename(dir)} (newest first). Only the recording is stored; everything else re-renders.`);
|
|
184
|
+
for (const k of kept)
|
|
185
|
+
say(` ${k.id} ${k.seconds}s · ${k.steps} steps · ${k.mb} MB`);
|
|
186
|
+
say(`\nGo back to one: retake restore ${path.relative(process.cwd(), dir)} <id>`);
|
|
187
|
+
});
|
|
188
|
+
program
|
|
189
|
+
.command("restore")
|
|
190
|
+
.description("make a kept version the current take again, and re-render it")
|
|
191
|
+
.argument("<dir>", "an outputs/<name> dir")
|
|
192
|
+
.argument("<id>", "a version id from `retake takes`")
|
|
193
|
+
.argument("[manifest]", "manifest to render with (default demos/<name>.yaml)")
|
|
194
|
+
.action(async (dir, id, manifestFile) => {
|
|
195
|
+
const outDir = path.resolve(dir);
|
|
196
|
+
const name = path.basename(outDir);
|
|
197
|
+
acquireLock(outDir);
|
|
198
|
+
try {
|
|
199
|
+
if (!restoreKept(outDir, id))
|
|
200
|
+
throw new Error(`no version "${id}" of ${name} — \`retake takes ${path.relative(process.cwd(), outDir)}\` lists them`);
|
|
201
|
+
say(`restored ${id} as the current take`);
|
|
202
|
+
const file = manifestFile ?? path.join("demos", `${name}.yaml`);
|
|
203
|
+
if (!fs.existsSync(file)) {
|
|
204
|
+
say(`re-render it with: retake render ${path.relative(process.cwd(), outDir)} <manifest>`);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const { manifest } = loadManifest(file);
|
|
208
|
+
const take = JSON.parse(fs.readFileSync(path.join(outDir, "take.json"), "utf8"));
|
|
209
|
+
const a = await render(manifest, take, outDir, { log: (l) => { if (!l.startsWith("$"))
|
|
210
|
+
say(l); } });
|
|
211
|
+
for (const f of [a.master, a.mp4, a.thumbnail, a.proofLog])
|
|
212
|
+
if (f)
|
|
213
|
+
say(`✓ ${path.relative(process.cwd(), f)}`);
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
releaseLock(outDir);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
program
|
|
220
|
+
.command("tidy")
|
|
221
|
+
.description("reclaim disk from outputs — shows what it would remove; nothing goes without --apply")
|
|
222
|
+
.option("-o, --out <dir>", "output root", "outputs")
|
|
223
|
+
.option("--apply", "actually remove them", false)
|
|
224
|
+
.option("--deep", "also remove demo.mp4 (still re-renderable, but it is the deliverable)", false)
|
|
225
|
+
.option("--orphans", "also remove whole folders whose manifest is gone", false)
|
|
226
|
+
.action((opts) => {
|
|
227
|
+
const root = path.resolve(opts.out);
|
|
228
|
+
const plan = planTidy(root, { deep: opts.deep, orphans: opts.orphans, demosDir: path.resolve("demos") });
|
|
229
|
+
if (!plan.bytes) {
|
|
230
|
+
say(`nothing to reclaim in ${path.relative(process.cwd(), root)} — ${mb(plan.keptBytes)} of recordings and deliverables, all of it worth keeping.`);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
for (const g of plan.groups) {
|
|
234
|
+
say(`${mb(g.bytes).padStart(8)} ${g.what} — ${g.why}`);
|
|
235
|
+
for (const f of g.files.slice(0, 4))
|
|
236
|
+
say(` ${path.relative(root, f)}`);
|
|
237
|
+
if (g.files.length > 4)
|
|
238
|
+
say(` … and ${g.files.length - 4} more`);
|
|
239
|
+
}
|
|
240
|
+
say(`${mb(plan.bytes).padStart(8)} TOTAL reclaimable · ${mb(plan.keptBytes)} kept (raw recordings and take.json — the only things a re-render cannot rebuild)`);
|
|
241
|
+
if (!opts.apply) {
|
|
242
|
+
say(`\nNothing removed. Run again with --apply.`);
|
|
243
|
+
if (!opts.deep)
|
|
244
|
+
say(`--deep also drops demo.mp4 · --orphans also drops folders whose manifest is gone`);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const freed = applyTidy(plan);
|
|
248
|
+
say(`\nfreed ${mb(freed)}. Every demo can still be rebuilt with \`retake render outputs/<name>\`.`);
|
|
249
|
+
});
|
|
122
250
|
program
|
|
123
251
|
.command("render")
|
|
124
252
|
.argument("<dir>", "an outputs/<name> dir containing take.json")
|
|
@@ -127,9 +255,39 @@ program
|
|
|
127
255
|
.option("--scene <label>", "render just one scene to scene-<label>.mp4 (fast, hardware encode)")
|
|
128
256
|
.option("--gif", "also produce a GIF", false)
|
|
129
257
|
.option("--force", "ignore the render cache", false)
|
|
258
|
+
.option("--out <dir>", "write the render into a NEW directory, leaving the original untouched")
|
|
130
259
|
.action(async (dir, manifestFile, opts) => {
|
|
131
260
|
const takePath = path.join(dir, "take.json");
|
|
132
261
|
const take = JSON.parse(fs.readFileSync(takePath, "utf8"));
|
|
262
|
+
// Revising a published cut should not destroy it. Copy the take (and the
|
|
263
|
+
// raw video it points at) into the new directory and render there.
|
|
264
|
+
if (opts.out) {
|
|
265
|
+
const dest = path.resolve(opts.out);
|
|
266
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
267
|
+
for (const f of fs.readdirSync(path.resolve(dir))) {
|
|
268
|
+
if (/^(demo|master)\.mp4$|^demo\.gif$|\.png$|^proof-log\.md$|^facts\.json$|^stills$/.test(f))
|
|
269
|
+
continue;
|
|
270
|
+
const from = path.join(path.resolve(dir), f);
|
|
271
|
+
if (fs.statSync(from).isDirectory())
|
|
272
|
+
continue;
|
|
273
|
+
fs.copyFileSync(from, path.join(dest, f));
|
|
274
|
+
}
|
|
275
|
+
if (take.video) {
|
|
276
|
+
// The take points at its raw video by absolute path; a copied
|
|
277
|
+
// directory has to point at its own copy or it renders the original.
|
|
278
|
+
const base = path.basename(take.video);
|
|
279
|
+
const copied = path.join(dest, base);
|
|
280
|
+
if (fs.existsSync(copied))
|
|
281
|
+
take.video = copied;
|
|
282
|
+
else {
|
|
283
|
+
fs.copyFileSync(take.video, copied);
|
|
284
|
+
take.video = copied;
|
|
285
|
+
}
|
|
286
|
+
fs.writeFileSync(path.join(dest, "take.json"), JSON.stringify(take, null, 2));
|
|
287
|
+
}
|
|
288
|
+
say(`▶ rendering into ${path.relative(process.cwd(), dest)} — ${path.relative(process.cwd(), path.resolve(dir))} is untouched`);
|
|
289
|
+
dir = dest;
|
|
290
|
+
}
|
|
133
291
|
const name = path.basename(path.resolve(dir));
|
|
134
292
|
const mf = manifestFile ?? guessManifest(name);
|
|
135
293
|
const loaded = loadManifest(mf);
|
|
@@ -316,6 +474,118 @@ program
|
|
|
316
474
|
say(`Keep ${o.ui} open while it works — the plan, the run and the video appear there.`);
|
|
317
475
|
say("Retake will not start your app from outside its own window unless you add RETAKE_ALLOW_START=1 above.");
|
|
318
476
|
});
|
|
477
|
+
/** Read one line from stdin. On a terminal, `hide` switches to raw mode so the
|
|
478
|
+
characters are not echoed; on a pipe, lines are simply consumed in order. */
|
|
479
|
+
let stdinBuf = "";
|
|
480
|
+
function promptLine(label, hide) {
|
|
481
|
+
process.stdout.write(label);
|
|
482
|
+
const tty = !!process.stdin.isTTY;
|
|
483
|
+
const raw = hide && tty;
|
|
484
|
+
if (raw)
|
|
485
|
+
process.stdin.setRawMode(true);
|
|
486
|
+
process.stdin.resume();
|
|
487
|
+
return new Promise((resolve) => {
|
|
488
|
+
const finish = (line) => {
|
|
489
|
+
process.stdin.off("data", onData);
|
|
490
|
+
if (raw)
|
|
491
|
+
process.stdin.setRawMode(false);
|
|
492
|
+
process.stdin.pause();
|
|
493
|
+
if (raw)
|
|
494
|
+
process.stdout.write("\n");
|
|
495
|
+
resolve(line.trim());
|
|
496
|
+
};
|
|
497
|
+
const take = () => {
|
|
498
|
+
const i = stdinBuf.search(/\r|\n/);
|
|
499
|
+
if (i < 0)
|
|
500
|
+
return false;
|
|
501
|
+
const line = stdinBuf.slice(0, i);
|
|
502
|
+
stdinBuf = stdinBuf.slice(i + 1).replace(/^\n/, "");
|
|
503
|
+
finish(line);
|
|
504
|
+
return true;
|
|
505
|
+
};
|
|
506
|
+
const onData = (d) => {
|
|
507
|
+
const sIn = d.toString();
|
|
508
|
+
if (raw) {
|
|
509
|
+
for (const ch of sIn) {
|
|
510
|
+
if (ch === "\u0003") {
|
|
511
|
+
process.stdout.write("\n");
|
|
512
|
+
process.exit(130);
|
|
513
|
+
}
|
|
514
|
+
if (ch === "\u007f" || ch === "\b") {
|
|
515
|
+
stdinBuf = stdinBuf.slice(0, -1);
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
stdinBuf += ch;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
else
|
|
522
|
+
stdinBuf += sIn;
|
|
523
|
+
take();
|
|
524
|
+
};
|
|
525
|
+
if (take())
|
|
526
|
+
return;
|
|
527
|
+
process.stdin.on("data", onData);
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
program
|
|
531
|
+
.command("secret")
|
|
532
|
+
.description("put a demo account into this workspace's .env without opening the file — typed here, hidden, kept on this machine only")
|
|
533
|
+
.argument("<names...>", "variable names, e.g. APP_USER APP_PASSWORD APP_TOTP_SECRET")
|
|
534
|
+
.action(async (names) => {
|
|
535
|
+
const bad = names.filter((n) => !SECRET_NAME.test(n));
|
|
536
|
+
if (bad.length)
|
|
537
|
+
throw new Error(`names look like APP_PASSWORD — not: ${bad.join(", ")}`);
|
|
538
|
+
say(`Stays on this computer: written to ${path.join(process.cwd(), ".env")} (readable by you only), never sent anywhere, never shown to an agent. Use a demo account.`);
|
|
539
|
+
const values = {};
|
|
540
|
+
for (const n of names) {
|
|
541
|
+
const v = await promptLine(`${n}${/PASS|SECRET|TOKEN|KEY|PIN|OTP/.test(n) ? " (hidden)" : ""}: `, /PASS|SECRET|TOKEN|KEY|PIN|OTP/.test(n));
|
|
542
|
+
if (v)
|
|
543
|
+
values[n] = v;
|
|
544
|
+
}
|
|
545
|
+
const set = writeEnvFile(process.cwd(), values);
|
|
546
|
+
if (!set.length) {
|
|
547
|
+
say("nothing saved");
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
say(`✓ ${set.join(", ")} saved to ${path.join(process.cwd(), ".env")} (mode 600). A manifest references them as \${NAME} with secret: true — the values never go on camera.`);
|
|
551
|
+
});
|
|
552
|
+
program
|
|
553
|
+
.command("signin")
|
|
554
|
+
.description("sign in by hand once — 2FA, SSO, captcha, anything — and keep the session for every later take")
|
|
555
|
+
.argument("<manifest>", "the demo whose auth.storageState should be saved")
|
|
556
|
+
.action(async (file) => {
|
|
557
|
+
const { manifest, dir } = loadManifest(file);
|
|
558
|
+
if (!manifest.auth?.storageState)
|
|
559
|
+
throw new Error(`${file} has no auth.storageState — add:\n auth:\n storageState: .auth/${manifest.name}.json\n maxAgeHours: 24`);
|
|
560
|
+
const { resolve, expandEnv } = await import("./manifest.js");
|
|
561
|
+
const { chromium } = await import("playwright");
|
|
562
|
+
const q = resolve(manifest);
|
|
563
|
+
const statePath = path.resolve(dir, expandEnv(manifest.auth.storageState));
|
|
564
|
+
const browser = await chromium.launch({ headless: false });
|
|
565
|
+
const context = await browser.newContext({ viewport: q.viewport });
|
|
566
|
+
const page = await context.newPage();
|
|
567
|
+
await page.goto(manifest.url, { waitUntil: "domcontentloaded" }).catch(() => { });
|
|
568
|
+
say(`A browser is open at ${manifest.url}.`);
|
|
569
|
+
say(`Sign in there — codes, SSO, captcha, whatever it asks. When you can see the signed-in app, come back here and press Enter.`);
|
|
570
|
+
say(`(Nothing is recorded. Only the session is kept, in ${path.relative(process.cwd(), statePath)}, good for ${manifest.auth.maxAgeHours}h.)`);
|
|
571
|
+
await new Promise((ok) => { process.stdin.resume(); process.stdin.once("data", () => ok()); });
|
|
572
|
+
try {
|
|
573
|
+
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
574
|
+
await context.storageState({ path: statePath });
|
|
575
|
+
fs.chmodSync(statePath, 0o600);
|
|
576
|
+
}
|
|
577
|
+
catch (e) {
|
|
578
|
+
// Closing the window instead of pressing Enter is the usual way here:
|
|
579
|
+
// say so plainly rather than showing a stack, and leave no orphan.
|
|
580
|
+
const why = page.isClosed() ? "the browser window was closed before the session could be saved" : String(e.message ?? e);
|
|
581
|
+
throw new Error(`Could not save the session — ${why}.\nRun \`retake signin ${file}\` again, and this time leave the window open and press Enter here.`);
|
|
582
|
+
}
|
|
583
|
+
finally {
|
|
584
|
+
await browser.close().catch(() => { });
|
|
585
|
+
}
|
|
586
|
+
say(`✓ session saved → ${path.relative(process.cwd(), statePath)}. Takes of ${manifest.name} now start signed in; run this again when it goes stale.`);
|
|
587
|
+
process.exit(0);
|
|
588
|
+
});
|
|
319
589
|
program
|
|
320
590
|
.command("dry")
|
|
321
591
|
.description("run the manifest with no camera and report what would fail")
|
|
@@ -368,6 +638,17 @@ program
|
|
|
368
638
|
say(`· ${i.title}${i.length ? ` (${i.length})` : ""}`);
|
|
369
639
|
say(`✓ ${path.relative(process.cwd(), file)}`);
|
|
370
640
|
});
|
|
641
|
+
program
|
|
642
|
+
.command("contact")
|
|
643
|
+
.argument("<dir>", "an outputs/<name> dir")
|
|
644
|
+
.option("--every <seconds>", "seconds between frames", "2")
|
|
645
|
+
.option("-w, --width <px>", "width of each cell", "420")
|
|
646
|
+
.description("one timestamped contact sheet of the finished video — the artifact for signing a cut off")
|
|
647
|
+
.action(async (dir, opts) => {
|
|
648
|
+
const { contactSheet } = await import("./render.js");
|
|
649
|
+
const out = contactSheet(path.resolve(dir), Number(opts.every), Number(opts.width), say);
|
|
650
|
+
say(`✓ ${path.relative(process.cwd(), out)}`);
|
|
651
|
+
});
|
|
371
652
|
program
|
|
372
653
|
.command("gif")
|
|
373
654
|
.argument("<dir>", "an outputs/<name> dir")
|
|
@@ -384,12 +665,14 @@ program
|
|
|
384
665
|
.description("list quality presets")
|
|
385
666
|
.action(async () => {
|
|
386
667
|
const { PRESETS } = await import("./presets.js");
|
|
387
|
-
|
|
668
|
+
const { bandHeightFor } = await import("./captions.js");
|
|
669
|
+
say(`${"preset".padEnd(16)} ${"video".padEnd(12)} ${"page".padEnd(11)} fps layout`);
|
|
388
670
|
for (const p of Object.values(PRESETS)) {
|
|
389
|
-
const
|
|
390
|
-
|
|
671
|
+
const band = p.layout === "band" || p.layout === "card" ? p.bandHeight : 0;
|
|
672
|
+
const page = `${p.width}×${p.height - band}`;
|
|
673
|
+
say(`${p.name.padEnd(16)} ${String(p.width + "×" + p.height).padEnd(12)} ${page.padEnd(11)} ${String(p.fps).padEnd(4)} ${p.layout.padEnd(5)} — ${p.description}`);
|
|
391
674
|
}
|
|
392
|
-
say(`\
|
|
675
|
+
say(`\nOne preset, one size: the caption band sits inside the canvas, so every take at a preset comes out identical and no player letterboxes it. A manifest's \`viewport\` overrides the page (validate warns). \`captions: false\` → the page fills the frame.`);
|
|
393
676
|
});
|
|
394
677
|
program
|
|
395
678
|
.command("validate")
|
|
@@ -400,10 +683,12 @@ program
|
|
|
400
683
|
const q = resolve(manifest);
|
|
401
684
|
// The video is the page area plus the caption band, so say the real number
|
|
402
685
|
// here rather than letting it surprise someone after a two-minute take.
|
|
686
|
+
const { bandHeightFor } = await import("./captions.js");
|
|
403
687
|
const outW = q.layout === "card" ? q.width : q.viewport.width;
|
|
404
|
-
const
|
|
688
|
+
const band = bandHeightFor(q, manifest.steps.flatMap((s) => (s.action === "scene" && s.caption ? [s.caption] : [])));
|
|
689
|
+
const outH = (q.layout === "card" ? q.height : q.viewport.height) + (q.layout === "band" ? band : 0);
|
|
405
690
|
say(`ok: ${manifest.name} · ${manifest.steps.length} steps · ${manifest.steps.filter((s) => s.action === "scene").length} scenes`);
|
|
406
|
-
say(` ${q.name} · video ${outW}×${outH}${q.layout === "band" ? ` (page ${q.viewport.width}×${q.viewport.height} + ${
|
|
691
|
+
say(` ${q.name} · video ${outW}×${outH}${q.layout === "band" ? (band ? ` (page ${q.viewport.width}×${q.viewport.height} + a ${band}px caption band)` : " (captions off — the page fills the frame)") : ""} @ ${q.fps}fps${q.gif ? ` · gif ${q.gif.width}px` : ""}`);
|
|
407
692
|
for (const w of warnings(manifest))
|
|
408
693
|
say(`⚠ ${w}`);
|
|
409
694
|
});
|