retake-demos 0.2.1 → 0.3.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 +213 -0
- package/README.md +60 -23
- package/demos/example.yaml +1 -1
- package/dist/captions.js +56 -0
- package/dist/cards.js +134 -0
- package/dist/cli.js +114 -6
- package/dist/describe.js +28 -9
- package/dist/dryrun.js +3 -0
- package/dist/env.js +76 -0
- package/dist/manifest.js +124 -13
- package/dist/operator/run.js +4 -0
- package/dist/operator/tools.js +62 -5
- package/dist/pace.js +99 -0
- package/dist/presets.js +12 -12
- package/dist/record.js +44 -11
- package/dist/render.js +179 -48
- package/dist/ui/chat.html +86 -8
- package/dist/ui/guide.html +3 -2
- package/dist/ui/index.html +2 -2
- package/dist/ui/server.js +109 -26
- package/dist/voice.js +36 -0
- package/llms.txt +11 -0
- package/package.json +9 -4
- package/scripts/patch-testreel.mjs +74 -0
- package/site/index.html +141 -39
- package/site/logos/avexjets.png +0 -0
- package/skill/SKILL.md +127 -9
package/dist/cli.js
CHANGED
|
@@ -29,6 +29,7 @@ import { acquireLock, captureHash, record, releaseLock } from "./record.js";
|
|
|
29
29
|
import { check, render } from "./render.js";
|
|
30
30
|
import { presetNames } from "./presets.js";
|
|
31
31
|
import { PKG_ROOT, VERSION, entry } from "./paths.js";
|
|
32
|
+
import { SECRET_NAME, writeEnvFile } from "./env.js";
|
|
32
33
|
// The manifest may reference ${VARS}; load .env before any command reads one.
|
|
33
34
|
// (`describe` and the UI used to do this individually, so a plain `run` from
|
|
34
35
|
// the terminal silently saw empty values — which is how a whole take can fail
|
|
@@ -316,6 +317,108 @@ program
|
|
|
316
317
|
say(`Keep ${o.ui} open while it works — the plan, the run and the video appear there.`);
|
|
317
318
|
say("Retake will not start your app from outside its own window unless you add RETAKE_ALLOW_START=1 above.");
|
|
318
319
|
});
|
|
320
|
+
/** Read one line from stdin. On a terminal, `hide` switches to raw mode so the
|
|
321
|
+
characters are not echoed; on a pipe, lines are simply consumed in order. */
|
|
322
|
+
let stdinBuf = "";
|
|
323
|
+
function promptLine(label, hide) {
|
|
324
|
+
process.stdout.write(label);
|
|
325
|
+
const tty = !!process.stdin.isTTY;
|
|
326
|
+
const raw = hide && tty;
|
|
327
|
+
if (raw)
|
|
328
|
+
process.stdin.setRawMode(true);
|
|
329
|
+
process.stdin.resume();
|
|
330
|
+
return new Promise((resolve) => {
|
|
331
|
+
const finish = (line) => {
|
|
332
|
+
process.stdin.off("data", onData);
|
|
333
|
+
if (raw)
|
|
334
|
+
process.stdin.setRawMode(false);
|
|
335
|
+
process.stdin.pause();
|
|
336
|
+
if (raw)
|
|
337
|
+
process.stdout.write("\n");
|
|
338
|
+
resolve(line.trim());
|
|
339
|
+
};
|
|
340
|
+
const take = () => {
|
|
341
|
+
const i = stdinBuf.search(/\r|\n/);
|
|
342
|
+
if (i < 0)
|
|
343
|
+
return false;
|
|
344
|
+
const line = stdinBuf.slice(0, i);
|
|
345
|
+
stdinBuf = stdinBuf.slice(i + 1).replace(/^\n/, "");
|
|
346
|
+
finish(line);
|
|
347
|
+
return true;
|
|
348
|
+
};
|
|
349
|
+
const onData = (d) => {
|
|
350
|
+
const sIn = d.toString();
|
|
351
|
+
if (raw) {
|
|
352
|
+
for (const ch of sIn) {
|
|
353
|
+
if (ch === "\u0003") {
|
|
354
|
+
process.stdout.write("\n");
|
|
355
|
+
process.exit(130);
|
|
356
|
+
}
|
|
357
|
+
if (ch === "\u007f" || ch === "\b") {
|
|
358
|
+
stdinBuf = stdinBuf.slice(0, -1);
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
stdinBuf += ch;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
else
|
|
365
|
+
stdinBuf += sIn;
|
|
366
|
+
take();
|
|
367
|
+
};
|
|
368
|
+
if (take())
|
|
369
|
+
return;
|
|
370
|
+
process.stdin.on("data", onData);
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
program
|
|
374
|
+
.command("secret")
|
|
375
|
+
.description("put a demo account into this workspace's .env without opening the file — typed here, hidden, kept on this machine only")
|
|
376
|
+
.argument("<names...>", "variable names, e.g. APP_USER APP_PASSWORD APP_TOTP_SECRET")
|
|
377
|
+
.action(async (names) => {
|
|
378
|
+
const bad = names.filter((n) => !SECRET_NAME.test(n));
|
|
379
|
+
if (bad.length)
|
|
380
|
+
throw new Error(`names look like APP_PASSWORD — not: ${bad.join(", ")}`);
|
|
381
|
+
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.`);
|
|
382
|
+
const values = {};
|
|
383
|
+
for (const n of names) {
|
|
384
|
+
const v = await promptLine(`${n}${/PASS|SECRET|TOKEN|KEY|PIN|OTP/.test(n) ? " (hidden)" : ""}: `, /PASS|SECRET|TOKEN|KEY|PIN|OTP/.test(n));
|
|
385
|
+
if (v)
|
|
386
|
+
values[n] = v;
|
|
387
|
+
}
|
|
388
|
+
const set = writeEnvFile(process.cwd(), values);
|
|
389
|
+
if (!set.length) {
|
|
390
|
+
say("nothing saved");
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
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.`);
|
|
394
|
+
});
|
|
395
|
+
program
|
|
396
|
+
.command("signin")
|
|
397
|
+
.description("sign in by hand once — 2FA, SSO, captcha, anything — and keep the session for every later take")
|
|
398
|
+
.argument("<manifest>", "the demo whose auth.storageState should be saved")
|
|
399
|
+
.action(async (file) => {
|
|
400
|
+
const { manifest, dir } = loadManifest(file);
|
|
401
|
+
if (!manifest.auth?.storageState)
|
|
402
|
+
throw new Error(`${file} has no auth.storageState — add:\n auth:\n storageState: .auth/${manifest.name}.json\n maxAgeHours: 24`);
|
|
403
|
+
const { resolve, expandEnv } = await import("./manifest.js");
|
|
404
|
+
const { chromium } = await import("playwright");
|
|
405
|
+
const q = resolve(manifest);
|
|
406
|
+
const statePath = path.resolve(dir, expandEnv(manifest.auth.storageState));
|
|
407
|
+
const browser = await chromium.launch({ headless: false });
|
|
408
|
+
const context = await browser.newContext({ viewport: q.viewport });
|
|
409
|
+
const page = await context.newPage();
|
|
410
|
+
await page.goto(manifest.url, { waitUntil: "domcontentloaded" }).catch(() => { });
|
|
411
|
+
say(`A browser is open at ${manifest.url}.`);
|
|
412
|
+
say(`Sign in there — codes, SSO, captcha, whatever it asks. When you can see the signed-in app, come back here and press Enter.`);
|
|
413
|
+
say(`(Nothing is recorded. Only the session is kept, in ${path.relative(process.cwd(), statePath)}, good for ${manifest.auth.maxAgeHours}h.)`);
|
|
414
|
+
await new Promise((ok) => { process.stdin.resume(); process.stdin.once("data", () => ok()); });
|
|
415
|
+
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
416
|
+
await context.storageState({ path: statePath });
|
|
417
|
+
fs.chmodSync(statePath, 0o600);
|
|
418
|
+
await browser.close();
|
|
419
|
+
say(`✓ session saved → ${path.relative(process.cwd(), statePath)}. Takes of ${manifest.name} now start signed in; run this again when it goes stale.`);
|
|
420
|
+
process.exit(0);
|
|
421
|
+
});
|
|
319
422
|
program
|
|
320
423
|
.command("dry")
|
|
321
424
|
.description("run the manifest with no camera and report what would fail")
|
|
@@ -384,12 +487,15 @@ program
|
|
|
384
487
|
.description("list quality presets")
|
|
385
488
|
.action(async () => {
|
|
386
489
|
const { PRESETS } = await import("./presets.js");
|
|
387
|
-
|
|
490
|
+
const { bandHeightFor } = await import("./captions.js");
|
|
491
|
+
say(`${"preset".padEnd(16)} ${"video".padEnd(12)} ${"page".padEnd(11)} fps layout`);
|
|
388
492
|
for (const p of Object.values(PRESETS)) {
|
|
389
|
-
const
|
|
390
|
-
|
|
493
|
+
const q = { captions: { fontSize: p.captionFontSize }, layout: p.layout, bandHeight: p.bandHeight, bandFit: p.bandFit, height: p.height };
|
|
494
|
+
const one = bandHeightFor(q, p.width, ["one line"]), two = bandHeightFor(q, p.width, ["a caption long enough to wrap ".repeat(6)]);
|
|
495
|
+
const video = p.layout === "card" ? `${p.width}×${p.height}` : p.layout !== "band" ? `${p.width}×${p.height}` : one === two ? `${p.width}×${p.height + one}` : `${p.width}×${p.height + one}–${p.height + two}`;
|
|
496
|
+
say(`${p.name.padEnd(16)} ${video.padEnd(12)} ${String(p.width + "×" + p.height).padEnd(11)} ${String(p.fps).padEnd(4)} ${p.layout.padEnd(5)} — ${p.description}`);
|
|
391
497
|
}
|
|
392
|
-
say(`\nvideo = page + caption band. A manifest's \`viewport\` replaces the page size (the video follows it).`);
|
|
498
|
+
say(`\nvideo = page + caption band. On landscape presets the band fits the captions (one line, or two); square and vertical keep a fixed band so the canvas stays exact. A manifest's \`viewport\` replaces the page size (the video follows it). \`captions: false\` → no band.`);
|
|
393
499
|
});
|
|
394
500
|
program
|
|
395
501
|
.command("validate")
|
|
@@ -400,10 +506,12 @@ program
|
|
|
400
506
|
const q = resolve(manifest);
|
|
401
507
|
// The video is the page area plus the caption band, so say the real number
|
|
402
508
|
// here rather than letting it surprise someone after a two-minute take.
|
|
509
|
+
const { bandHeightFor } = await import("./captions.js");
|
|
403
510
|
const outW = q.layout === "card" ? q.width : q.viewport.width;
|
|
404
|
-
const
|
|
511
|
+
const band = bandHeightFor(q, outW, manifest.steps.flatMap((s) => (s.action === "scene" && s.caption ? [s.caption] : [])), q.viewport.height);
|
|
512
|
+
const outH = (q.layout === "card" ? q.height : q.viewport.height) + (q.layout === "band" ? band : 0);
|
|
405
513
|
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} + ${
|
|
514
|
+
say(` ${q.name} · video ${outW}×${outH}${q.layout === "band" ? (band ? ` (page ${q.viewport.width}×${q.viewport.height} + ${band}px caption band${q.bandFit === "text" ? ", sized to the captions" : ""})` : " (captions off, no band)") : ""} @ ${q.fps}fps${q.gif ? ` · gif ${q.gif.width}px` : ""}`);
|
|
407
515
|
for (const w of warnings(manifest))
|
|
408
516
|
say(`⚠ ${w}`);
|
|
409
517
|
});
|
package/dist/describe.js
CHANGED
|
@@ -25,16 +25,34 @@ import YAML from "yaml";
|
|
|
25
25
|
import { Manifest } from "./manifest.js";
|
|
26
26
|
import { digest } from "./digest.js";
|
|
27
27
|
export function loadDotenv(root, files = [".env"]) {
|
|
28
|
+
// One parser for every reader of .env (see env.ts), so a password written
|
|
29
|
+
// by the window or `retake secret` is typed back exactly as entered.
|
|
28
30
|
for (const name of files) {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
+
for (const [k, v] of Object.entries(parseEnvText(root, name)))
|
|
32
|
+
if (process.env[k] === undefined)
|
|
33
|
+
process.env[k] = v;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function parseEnvText(root, name) {
|
|
37
|
+
const f = path.join(root, name);
|
|
38
|
+
if (!fs.existsSync(f))
|
|
39
|
+
return {};
|
|
40
|
+
const out = {};
|
|
41
|
+
for (const raw of fs.readFileSync(f, "utf8").split(/\r?\n/)) {
|
|
42
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(raw);
|
|
43
|
+
if (!m)
|
|
31
44
|
continue;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
45
|
+
const v = m[2].trim();
|
|
46
|
+
if (v.startsWith('"') && v.endsWith('"')) {
|
|
47
|
+
try {
|
|
48
|
+
out[m[1]] = JSON.parse(v);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
catch { /* literal */ }
|
|
36
52
|
}
|
|
53
|
+
out[m[1]] = v.replace(/^(["'])(.*)\1$/, "$2");
|
|
37
54
|
}
|
|
55
|
+
return out;
|
|
38
56
|
}
|
|
39
57
|
function cliPath(bin) {
|
|
40
58
|
const home = os.homedir();
|
|
@@ -293,9 +311,10 @@ export async function proposeEdits(input) {
|
|
|
293
311
|
// --- drafting ---------------------------------------------------------------
|
|
294
312
|
const SCHEMA_DOC = `
|
|
295
313
|
Manifest YAML fields:
|
|
296
|
-
name (kebab-case), title, url, preset: post-landscape, waitForSelector (selector present when page is ready),
|
|
297
|
-
camera:
|
|
314
|
+
name (kebab-case), title, url, mode: demo, preset: post-landscape, waitForSelector (selector present when page is ready),
|
|
315
|
+
camera: static, setup: [steps run before recording], steps: [...], outputs: {mp4: true, gif: true, thumbnail: {scene: <label>}}
|
|
298
316
|
Do NOT set viewport, cursor, chrome, background, fps, crf or captions — the preset handles quality.
|
|
317
|
+
Always set mode: demo — this is a demo, so nothing goes in the frame but the product. Never emit intro or outro cards, music, callouts or voiceover: those belong to mode: launch, which only a person can ask for.
|
|
299
318
|
Steps (each may have pauseAfter ms, waitFor selector, timeout ms):
|
|
300
319
|
{action: scene, label, caption, camera?: static | {focus: selector, zoom: 1.25}} a named beat; caption is burned in until the next scene (under 60 chars). Set camera: static on every scene unless the thing being shown is genuinely too small to read at full frame — a calm, still demo reads as more real than a zooming one. If the person asked for zooms (or stillness), that wins.
|
|
301
320
|
{action: wait, ms}
|
|
@@ -307,7 +326,7 @@ Steps (each may have pauseAfter ms, waitFor selector, timeout ms):
|
|
|
307
326
|
{action: keyboard, key}
|
|
308
327
|
{action: waitFor, selector, timeout: 30000} wait for something to appear (use after actions that trigger async work)
|
|
309
328
|
{action: evaluate, script}
|
|
310
|
-
Rules: never invent field names or add "?" to keys; 15–35 seconds total; 3–6 scenes; open with a scene; every click uses a selector from the scouted list verbatim (selectors ending in \">> nth=N\" are already disambiguated — keep them exactly); after any action that loads or computes something, waitFor an element that only appears as a result (never one that is already on the page) — if you cannot know it from the scout, use {action: wait, ms: 3000} instead; end with a wait of ~2000ms on the payoff; do not use zoom steps — scenes carry the camera.
|
|
329
|
+
Rules: never invent field names or add "?" to keys; write mode: demo; no cards, music, callouts or voiceover; 15–35 seconds total; 3–6 scenes; open with a scene; every click uses a selector from the scouted list verbatim (selectors ending in \">> nth=N\" are already disambiguated — keep them exactly); after any action that loads or computes something, waitFor an element that only appears as a result (never one that is already on the page) — if you cannot know it from the scout, use {action: wait, ms: 3000} instead; end with a wait of ~2000ms on the payoff; do not use zoom steps — scenes carry the camera; write a caption ONLY where it says something the screen cannot (never narrate a visible click), and hold the payoff in ONE frame rather than moving between tabs or views to show its parts separately.
|
|
311
330
|
`;
|
|
312
331
|
/** The person's standing taste — demos/style.md, written once, read by every
|
|
313
332
|
draft. "No zooms, cursor visible, captions plain" should not need repeating. */
|
package/dist/dryrun.js
CHANGED
|
@@ -160,6 +160,9 @@ export async function dryRun(m, manifestDir, log, opts = {}) {
|
|
|
160
160
|
case "wait":
|
|
161
161
|
await page.waitForTimeout(isSetup ? step.ms : Math.min(step.ms, 400));
|
|
162
162
|
break;
|
|
163
|
+
case "callout":
|
|
164
|
+
await point(step.selector ?? step.at);
|
|
165
|
+
break;
|
|
163
166
|
default: break; // scene, screenshot, zoom, download: nothing to verify
|
|
164
167
|
}
|
|
165
168
|
if (step.pauseAfter)
|
package/dist/env.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The workspace `.env` — where an app's demo-account credentials live.
|
|
3
|
+
*
|
|
4
|
+
* Three things write it: the person (by hand, or `retake secret NAME`), the
|
|
5
|
+
* window (when an agent asks for a secret by name), and Settings. None of
|
|
6
|
+
* them ever hands a value to a model: the agent learns that `APP_PASSWORD`
|
|
7
|
+
* is *set*, and references it as `${APP_PASSWORD}` with `secret: true`.
|
|
8
|
+
*/
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
export const SECRET_NAME = /^[A-Z][A-Z0-9_]{1,63}$/;
|
|
12
|
+
/** Parse KEY=value lines (quotes stripped, comments and blanks ignored). */
|
|
13
|
+
export function readEnvFile(root) {
|
|
14
|
+
const file = path.join(root, ".env");
|
|
15
|
+
if (!fs.existsSync(file))
|
|
16
|
+
return {};
|
|
17
|
+
const out = {};
|
|
18
|
+
for (const raw of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
19
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(raw);
|
|
20
|
+
if (!m)
|
|
21
|
+
continue;
|
|
22
|
+
const v = m[2].trim();
|
|
23
|
+
// Double-quoted values are JSON strings (that is how writeEnvFile writes
|
|
24
|
+
// anything awkward); single-quoted are literal; bare is bare.
|
|
25
|
+
if (v.startsWith('"') && v.endsWith('"')) {
|
|
26
|
+
try {
|
|
27
|
+
out[m[1]] = JSON.parse(v);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
catch { /* fall through */ }
|
|
31
|
+
}
|
|
32
|
+
out[m[1]] = v.replace(/^(["'])(.*)\1$/, "$2");
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
/** Set (or, with "", remove) keys in .env, leaving every other line alone.
|
|
37
|
+
Returns the keys actually written. Also updates process.env so the
|
|
38
|
+
calling process sees the new values without a restart. */
|
|
39
|
+
export function writeEnvFile(root, set) {
|
|
40
|
+
const file = path.join(root, ".env");
|
|
41
|
+
const lines = fs.existsSync(file) ? fs.readFileSync(file, "utf8").split("\n") : [];
|
|
42
|
+
const written = [];
|
|
43
|
+
for (const [k, v] of Object.entries(set)) {
|
|
44
|
+
if (v === undefined)
|
|
45
|
+
continue;
|
|
46
|
+
const i = lines.findIndex((l) => l.startsWith(k + "="));
|
|
47
|
+
// Quote when the value has anything a shell-ish parser would trip on.
|
|
48
|
+
const line = `${k}=${/[\s#"']/.test(v) ? JSON.stringify(v) : v}`;
|
|
49
|
+
if (v === "") {
|
|
50
|
+
if (i >= 0)
|
|
51
|
+
lines.splice(i, 1);
|
|
52
|
+
delete process.env[k];
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
if (i >= 0)
|
|
56
|
+
lines[i] = line;
|
|
57
|
+
else
|
|
58
|
+
lines.push(line);
|
|
59
|
+
process.env[k] = v;
|
|
60
|
+
}
|
|
61
|
+
written.push(k);
|
|
62
|
+
}
|
|
63
|
+
fs.writeFileSync(file, lines.filter((l, i, a) => l !== "" || i < a.length - 1).join("\n").replace(/\n*$/, "\n"), { mode: 0o600 });
|
|
64
|
+
// `mode` above only applies to a new file; an existing .env keeps whatever
|
|
65
|
+
// it had. Credentials went in, so: owner-only, every time.
|
|
66
|
+
try {
|
|
67
|
+
fs.chmodSync(file, 0o600);
|
|
68
|
+
}
|
|
69
|
+
catch { /* windows */ }
|
|
70
|
+
return written;
|
|
71
|
+
}
|
|
72
|
+
/** Which of these names are not set (missing or blank) in .env + the environment. */
|
|
73
|
+
export function missingSecrets(root, names) {
|
|
74
|
+
const file = readEnvFile(root);
|
|
75
|
+
return names.filter((n) => !(process.env[n]?.trim() || file[n]?.trim()));
|
|
76
|
+
}
|
package/dist/manifest.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* - `outputs` which artifacts to produce and how
|
|
9
9
|
*/
|
|
10
10
|
import fs from "node:fs";
|
|
11
|
+
import { createHmac } from "node:crypto";
|
|
11
12
|
import path from "node:path";
|
|
12
13
|
import YAML from "yaml";
|
|
13
14
|
import { z } from "zod";
|
|
@@ -110,6 +111,16 @@ export const Step = z.discriminatedUnion("action", [
|
|
|
110
111
|
json: z.unknown().optional(),
|
|
111
112
|
from: z.string().optional(),
|
|
112
113
|
}),
|
|
114
|
+
/** Point at something while the take holds: an animated ring and an optional
|
|
115
|
+
label, drawn at render time from the box recorded here. Sparingly — one
|
|
116
|
+
per scene at most; the captions carry the story. */
|
|
117
|
+
Base.extend({
|
|
118
|
+
action: z.literal("callout"),
|
|
119
|
+
selector: Selector.optional(),
|
|
120
|
+
at: Point.optional(),
|
|
121
|
+
label: z.string().max(60).optional(),
|
|
122
|
+
ms: z.number().positive().max(8000).default(2200),
|
|
123
|
+
}),
|
|
113
124
|
/** A named beat. Timestamps go into the proof log; `caption` gets burned into
|
|
114
125
|
the video from this moment until the next scene (or `holdMs`). */
|
|
115
126
|
Base.extend({
|
|
@@ -124,7 +135,7 @@ export const Step = z.discriminatedUnion("action", [
|
|
|
124
135
|
.optional(),
|
|
125
136
|
}),
|
|
126
137
|
]).superRefine((st, ctx) => {
|
|
127
|
-
if ((st.action === "click" || st.action === "hover") && !st.selector && !st.at) {
|
|
138
|
+
if ((st.action === "click" || st.action === "hover" || st.action === "callout") && !st.selector && !st.at) {
|
|
128
139
|
ctx.addIssue({ code: "custom", message: `${st.action} needs either a selector or an \`at\` point` });
|
|
129
140
|
}
|
|
130
141
|
});
|
|
@@ -177,7 +188,60 @@ const ManifestShape = z.object({
|
|
|
177
188
|
/** band (default): the site fills the frame, caption strip below · card: the recording framed on a soft canvas · overlay-*: caption over the video · none */
|
|
178
189
|
layout: z.enum(["band", "card", "overlay-bottom", "overlay-top", "none"]).optional(),
|
|
179
190
|
/** auto: each scene zooms toward the last element the demo touched · static: no camera moves. */
|
|
180
|
-
|
|
191
|
+
/** Compress at render what nobody watches. A waitFor/navigate that made
|
|
192
|
+
the viewer sit through N seconds is shown as ~keepSeconds (real start,
|
|
193
|
+
then a fast-forward); a long typing run keeps its start and its finish
|
|
194
|
+
at real speed and accelerates the middle (every keystroke is a
|
|
195
|
+
round-trip, so a real sentence costs ~5s of video). The author's own
|
|
196
|
+
`wait` steps are pacing and are never touched. Off by default. */
|
|
197
|
+
compressIdle: z.union([z.boolean(), z.object({ keepSeconds: z.number().min(0.8).max(5).default(1.5) })]).default(false),
|
|
198
|
+
/** What this take is FOR, and it changes what is allowed to be in it.
|
|
199
|
+
`demo` (the default) proves an interaction: direct, legible, minimally
|
|
200
|
+
framed — a title card or music in a demo is noise. `launch` presents
|
|
201
|
+
the product, so cards, music, emphasis and a branded ending are
|
|
202
|
+
legitimate, and each one still has to earn the second it costs.
|
|
203
|
+
Retake never upgrades a demo to a launch on its own. */
|
|
204
|
+
mode: z.enum(["demo", "launch"]).default("demo"),
|
|
205
|
+
/** How typed text lands. `natural` is the per-step default (~80ms a key);
|
|
206
|
+
`brisk` is the launch rhythm — fast keys (~22ms), the pauses carrying
|
|
207
|
+
the meaning instead. A step's own `delay` always wins. */
|
|
208
|
+
typing: z.enum(["natural", "brisk"]).default("natural"),
|
|
209
|
+
/** The captions, read aloud (edge-tts). Each scene's caption is synthesized
|
|
210
|
+
and placed at the scene's start; music ducks under it. The captions are
|
|
211
|
+
the script — there is nothing separate to write. */
|
|
212
|
+
voiceover: z
|
|
213
|
+
.union([
|
|
214
|
+
z.boolean(),
|
|
215
|
+
z.object({
|
|
216
|
+
voice: z.string().default("en-US-JennyNeural"),
|
|
217
|
+
gainDb: z.number().min(-20).max(10).default(0),
|
|
218
|
+
/** One continuous script, synthesized as a single performance and
|
|
219
|
+
laid under the whole video — the only shape that keeps prosody.
|
|
220
|
+
Without it, narration is per-scene fragments: prosody resets at
|
|
221
|
+
every line and the read/wait rhythm follows scene boundaries
|
|
222
|
+
instead of speech. Fragments are refused unless `fragments: true`
|
|
223
|
+
says you know. */
|
|
224
|
+
script: z.string().optional(),
|
|
225
|
+
fragments: z.boolean().default(false),
|
|
226
|
+
/** A person has heard this voice and this script and approved them.
|
|
227
|
+
Synthesis succeeding is not approval. */
|
|
228
|
+
approved: z.boolean().default(false),
|
|
229
|
+
}),
|
|
230
|
+
])
|
|
231
|
+
.default(false),
|
|
232
|
+
/** A music bed under the whole video, mixed at render time: looped or
|
|
233
|
+
trimmed to fit, faded out over the last moments. The person supplies the
|
|
234
|
+
file (CC0/licensed — it ships inside their video); Retake bundles none. */
|
|
235
|
+
music: z.union([z.string(), z.object({ file: z.string(), gainDb: z.number().min(-40).max(6).default(-14), fadeOutMs: z.number().positive().max(8000).default(1800) })]).optional(),
|
|
236
|
+
/** Title cards, rendered at render time (change them, re-render in seconds).
|
|
237
|
+
The intro's settled frame is also written as cover.png — the poster. */
|
|
238
|
+
intro: z.object({ title: z.string().max(60), subtitle: z.string().max(90).optional(), ms: z.number().positive().max(6000).default(2400) }).optional(),
|
|
239
|
+
outro: z.object({ title: z.string().max(60), subtitle: z.string().max(90).optional(), ms: z.number().positive().max(6000).default(2000) }).optional(),
|
|
240
|
+
/** Still by default. A zoom is something a person asks for after seeing a
|
|
241
|
+
take, never something that happens to them: a calm demo reads as real
|
|
242
|
+
and can never crop the wrong thing. `auto` eases toward the last thing
|
|
243
|
+
touched; a scene can override either way. */
|
|
244
|
+
camera: z.union([z.literal("auto"), z.literal("static"), z.object({ zoom: z.number().min(1).max(3) })]).default("static"),
|
|
181
245
|
/** Seconds cut from the head/tail of the finished video. Render-only: change
|
|
182
246
|
it and re-render, no re-recording. */
|
|
183
247
|
trim: z.object({ head: z.number().nonnegative().default(0), tail: z.number().nonnegative().default(0) }).prefault({}),
|
|
@@ -203,10 +267,9 @@ const ManifestShape = z.object({
|
|
|
203
267
|
waitForSelector: Selector.optional(),
|
|
204
268
|
speed: z.number().positive().default(1),
|
|
205
269
|
/** Cursor overlay (testreel). false → none; {style: touch} → tap dot. Size is preset-scaled unless set. */
|
|
206
|
-
/** cursor.idleHide: fade the cursor out when idle (testreel default).
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
automatically above 60 steps; set it explicitly either way. */
|
|
270
|
+
/** cursor.idleHide: fade the cursor out when idle (testreel default). Long
|
|
271
|
+
demos used to lose it (nested-if depth in ffmpeg); the flat cursor
|
|
272
|
+
expressions lifted that, so it is simply on unless you say otherwise. */
|
|
210
273
|
cursor: z.union([z.boolean(), z.object({ style: z.enum(["default", "pointer", "text", "touch"]).optional(), size: z.number().optional(), idleHideMs: z.number().optional(), idleHide: z.boolean().optional() })]).default(true),
|
|
211
274
|
seed: z.array(Seed).default([]),
|
|
212
275
|
/** What a failed step does to the take. "stop" (default): the camera stops
|
|
@@ -249,6 +312,20 @@ const ManifestShape = z.object({
|
|
|
249
312
|
.prefault({}),
|
|
250
313
|
});
|
|
251
314
|
export const Manifest = ManifestShape.superRefine((m, ctx) => {
|
|
315
|
+
// A synthetic voice is a claim on the person's taste. Retake will not put
|
|
316
|
+
// one in a deliverable until they have heard it and said yes — and will
|
|
317
|
+
// not read the captions as fragments at all, because that is the defect,
|
|
318
|
+
// not the voice: prosody resets every line and the rhythm follows scene
|
|
319
|
+
// boundaries instead of speech.
|
|
320
|
+
if (m.voiceover) {
|
|
321
|
+
const vo = m.voiceover === true ? { approved: false, script: undefined, fragments: false } : m.voiceover;
|
|
322
|
+
if (!vo.approved) {
|
|
323
|
+
ctx.addIssue({ code: "custom", path: ["voiceover"], message: "voiceover is not approved. Synthesize it, LISTEN to it with the person, and only then set `approved: true`. Successful synthesis is not approval; a silent cut beats an unapproved synthetic voice." });
|
|
324
|
+
}
|
|
325
|
+
if (!vo.script && !vo.fragments) {
|
|
326
|
+
ctx.addIssue({ code: "custom", path: ["voiceover"], message: "voiceover needs `script:` — one continuous script, synthesized as a single performance. Reading the captions line by line resets prosody at every scene and paces the speech off scene boundaries. Set `fragments: true` only if you have heard the fragmented read and want it." });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
252
329
|
[...m.steps, ...m.setup].forEach((st, i) => {
|
|
253
330
|
if ((st.action === "click" || st.action === "hover") && !st.selector && !st.at) {
|
|
254
331
|
ctx.addIssue({ code: "custom", path: ["steps", i], message: `${st.action} needs either a selector or an \`at\` point` });
|
|
@@ -294,11 +371,25 @@ export function loadManifest(file) {
|
|
|
294
371
|
}).join("\n");
|
|
295
372
|
throw new Error(`Invalid manifest ${file}:\n${issues}`);
|
|
296
373
|
}
|
|
297
|
-
|
|
374
|
+
const dir = path.dirname(abs);
|
|
375
|
+
// A fixture page can live next to its manifest: `url: file://./page.html`
|
|
376
|
+
// resolves against the manifest's folder, so a cloned repo's demos work.
|
|
377
|
+
const m = parsed.data;
|
|
378
|
+
const rel = /^file:\/\/(\.{1,2}\/.*)$/.exec(m.url);
|
|
379
|
+
if (rel)
|
|
380
|
+
m.url = "file://" + path.resolve(dir, rel[1]);
|
|
381
|
+
return { manifest: m, file: abs, dir };
|
|
298
382
|
}
|
|
299
383
|
/** Things worth saying out loud before a run. Not errors — the run proceeds. */
|
|
300
384
|
export function warnings(m) {
|
|
301
385
|
const w = [];
|
|
386
|
+
// A demo proves an interaction; a launch presents the product. Launch
|
|
387
|
+
// furniture in a demo is noise the viewer has to look past.
|
|
388
|
+
if (m.mode === "demo") {
|
|
389
|
+
const launchy = [m.intro && "intro card", m.outro && "outro card", m.music && "music", m.steps.some((s) => s.action === "callout") && "callouts"].filter(Boolean);
|
|
390
|
+
if (launchy.length)
|
|
391
|
+
w.push(`this is \`mode: demo\` but carries ${launchy.join(", ")} — a demo proves the interaction and wants nothing else in the frame. Set \`mode: launch\` if it is a launch video, or drop them.`);
|
|
392
|
+
}
|
|
302
393
|
// Secrets typed during recorded steps end up in the video. In `setup` they do
|
|
303
394
|
// not: setup runs before the camera and is trimmed off the front.
|
|
304
395
|
const onCamera = m.steps.filter((s) => s.secret).length;
|
|
@@ -312,8 +403,8 @@ export function warnings(m) {
|
|
|
312
403
|
if (m.auth && !m.auth.setup.length && !m.setup.length)
|
|
313
404
|
w.push("`auth.storageState` is set but there are no sign-in steps under `auth.setup` — there is nothing to save a session from.");
|
|
314
405
|
const moves = m.steps.filter((st) => ["click", "type", "fill", "hover", "scroll", "upload"].includes(st.action)).length;
|
|
315
|
-
if (m.cursor !== false && moves >
|
|
316
|
-
w.push(`~${moves} cursor moves — the cursor
|
|
406
|
+
if (m.cursor !== false && moves > 180)
|
|
407
|
+
w.push(`~${moves} cursor moves — past ~180 in one take the cursor filter no longer fits in one ffmpeg argument and the overlay will be MISSING. Split the demo into chapters, or set \`cursor: false\`.`);
|
|
317
408
|
m.steps.forEach((st, i) => {
|
|
318
409
|
if (st.action !== "scene")
|
|
319
410
|
return;
|
|
@@ -328,13 +419,33 @@ export function warnings(m) {
|
|
|
328
419
|
}
|
|
329
420
|
/** `${VAR}` substitution against process.env, like testreel. */
|
|
330
421
|
export function expandEnv(s) {
|
|
331
|
-
return s.replace(/\$\{([A-Z0-9_]+)\}/g, (_, k) => {
|
|
422
|
+
return s.replace(/\$\{(TOTP:)?([A-Z0-9_]+)\}/g, (_, fn, k) => {
|
|
332
423
|
const v = process.env[k];
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
424
|
+
// `APP_USER=` with nothing after it is the template, not a value — a blank
|
|
425
|
+
// login that "succeeds" is worse than a loud stop.
|
|
426
|
+
if (v === undefined || v.trim() === "")
|
|
427
|
+
throw new Error(`manifest references \${${k}} but it is not set — add it to .env (a demo account, never a real one)`);
|
|
428
|
+
// ${TOTP:APP_TOTP_SECRET}: the six-digit code for right now, from an
|
|
429
|
+
// authenticator enrolment secret. Computed at fill time, so it is valid.
|
|
430
|
+
return fn ? totp(v) : v;
|
|
336
431
|
});
|
|
337
432
|
}
|
|
433
|
+
/** RFC 6238 TOTP (SHA-1, 6 digits, 30s) from a base32 secret — what every
|
|
434
|
+
authenticator app enrols with. */
|
|
435
|
+
export function totp(base32Secret, nowMs = Date.now()) {
|
|
436
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
437
|
+
const clean = base32Secret.toUpperCase().replace(/[^A-Z2-7]/g, "");
|
|
438
|
+
let bits = "";
|
|
439
|
+
for (const c of clean)
|
|
440
|
+
bits += alphabet.indexOf(c).toString(2).padStart(5, "0");
|
|
441
|
+
const key = Buffer.from((bits.match(/.{8}/g) ?? []).map((b) => parseInt(b, 2)));
|
|
442
|
+
const counter = Buffer.alloc(8);
|
|
443
|
+
counter.writeBigUInt64BE(BigInt(Math.floor(nowMs / 30000)));
|
|
444
|
+
const h = createHmac("sha1", key).update(counter).digest();
|
|
445
|
+
const off = h[h.length - 1] & 0xf;
|
|
446
|
+
const code = ((h[off] & 0x7f) << 24 | h[off + 1] << 16 | h[off + 2] << 8 | h[off + 3]) % 1_000_000;
|
|
447
|
+
return String(code).padStart(6, "0");
|
|
448
|
+
}
|
|
338
449
|
export function resolve(m) {
|
|
339
450
|
const p = PRESETS[m.preset] ?? PRESETS[DEFAULT_PRESET];
|
|
340
451
|
const viewport = { width: m.viewport?.width ?? p.width, height: m.viewport?.height ?? p.height };
|
package/dist/operator/run.js
CHANGED
|
@@ -44,6 +44,10 @@ export function brief(input) {
|
|
|
44
44
|
"6. When it is good, run with preview=false once for the real quality, then done with one sentence.",
|
|
45
45
|
"",
|
|
46
46
|
"Rules:",
|
|
47
|
+
"- Every manifest carries `mode:`. `demo` is the default and covers almost everything — it proves how the thing works and keeps everything but the product out of the frame. `mode: launch` is only for a video that presents the product in public (a launch, a post, an ad); it is the one mode where title cards, music, a callout and a branded ending belong, and it must end with THEIR product, never Retake. Decide from the audience, not the subject: a client walkthrough is a demo. Never upgrade a demo to a launch on your own.",
|
|
48
|
+
"- A caption earns its place only by saying what the screen cannot. Do not narrate a visible click. Hold the payoff in ONE frame instead of touring its parts.",
|
|
49
|
+
"- `check: pass` proves the file is sound, not that the demo is good. Use `look` on the payoff and judge it as a viewer before `done`.",
|
|
50
|
+
"- Never add voiceover. It needs a continuous script and a person who has heard it.",
|
|
47
51
|
"- Never type real passwords into a manifest; use ${ENV_VAR} and secret: true, and tell the person which variable to set.",
|
|
48
52
|
"- Never invent selectors; use what scout/read_project returned.",
|
|
49
53
|
"- Never accept a static manifest for an interactive request. It must perform the requested actions after setup, not merely describe them with scenes and waits.",
|