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/operator/tools.js
CHANGED
|
@@ -27,6 +27,7 @@ import { scout, draftManifest, suggestIdeas, pickProvider, loadDotenv } from "..
|
|
|
27
27
|
import { digest } from "../digest.js";
|
|
28
28
|
import { startApp as reallyStartApp, listeningPorts as ports, waitForUrl as waitUrl } from "../appserver.js";
|
|
29
29
|
import { applyEdits, receiptsFor } from "../edits.js";
|
|
30
|
+
import { SECRET_NAME, missingSecrets, readEnvFile } from "../env.js";
|
|
30
31
|
const ROOT = process.env.RETAKE_ROOT || process.cwd();
|
|
31
32
|
const DEMOS = path.join(ROOT, "demos");
|
|
32
33
|
const OUT = process.env.RETAKE_OUT ? path.resolve(process.env.RETAKE_OUT.replace(/^~/, os.homedir())) : path.join(ROOT, "outputs");
|
|
@@ -129,7 +130,63 @@ server.registerTool("start_app", {
|
|
|
129
130
|
await tell(`Could not start it: ${r.why}`);
|
|
130
131
|
return text(`FAILED: ${r.why}\nlog tail:\n${r.log.slice(-800)}`);
|
|
131
132
|
});
|
|
132
|
-
server.registerTool("
|
|
133
|
+
server.registerTool("secrets", {
|
|
134
|
+
description: [
|
|
135
|
+
"Get the person's demo-account credentials into place WITHOUT ever seeing them. Call this the moment a demo needs a login (read_project found sign-in fields, scout found a password field, or they said so): pass the variable NAMES you will use (APP_USER, APP_PASSWORD, and APP_TOTP_SECRET if the site asks for an authenticator code).",
|
|
136
|
+
"It returns at once with what to tell the person — ALWAYS relay it in full, with the link: assume they have never seen the Retake window and do not know it exists. They type the values into a form there (or into their terminal); the values go straight into the workspace .env; you only ever learn 'set'. When they say they are done, call this tool again with the same names.",
|
|
137
|
+
"Then the manifest references them as ${APP_USER} / ${APP_PASSWORD} with `secret: true`, inside auth.setup. Never ask for a password in chat, never put a literal credential in a manifest, never invent values. For logins a script cannot do (SMS code, SSO, captcha): `retake signin demos/<name>.yaml` — the person signs in once in a real window and Retake keeps the session.",
|
|
138
|
+
].join(" "),
|
|
139
|
+
inputSchema: {
|
|
140
|
+
names: z.array(z.string().regex(SECRET_NAME, "UPPER_SNAKE names like APP_PASSWORD")).min(1).describe("the environment variable names the manifest will reference"),
|
|
141
|
+
why: z.string().describe("one sentence the person will read: what the login is for, e.g. 'The dashboard demo starts behind the admin sign-in.'"),
|
|
142
|
+
},
|
|
143
|
+
annotations: RETAKE_WRITE,
|
|
144
|
+
}, async ({ names, why }) => {
|
|
145
|
+
const reload = () => { for (const [k, v] of Object.entries(readEnvFile(ROOT)))
|
|
146
|
+
if (v.trim())
|
|
147
|
+
process.env[k] = v; };
|
|
148
|
+
const how = `Reference them as ${names.map((n) => "${" + n + "}").join(", ")} with \`secret: true\` inside auth.setup${names.some((n) => /TOTP/.test(n)) ? ` (the authenticator one as ${"${TOTP:" + names.find((n) => /TOTP/.test(n)) + "}"} — Retake computes the current code)` : ""}. The values stay in .env; you never see them, and they are never on camera.`;
|
|
149
|
+
reload();
|
|
150
|
+
let missing = missingSecrets(ROOT, names);
|
|
151
|
+
if (!missing.length)
|
|
152
|
+
return text(`All set: ${names.join(", ")} are in the workspace .env. ${how} Continue.`);
|
|
153
|
+
const link = UI || "http://localhost:4310";
|
|
154
|
+
const list = missing.join(" and ");
|
|
155
|
+
// Is the window actually there? A dead URL must not leave the person
|
|
156
|
+
// staring at a link that does nothing.
|
|
157
|
+
const windowUp = UI ? await fetch(`${UI}/api/settings`).then((r) => r.ok).catch(() => false) : false;
|
|
158
|
+
if (windowUp) {
|
|
159
|
+
// One open form per set of names — calling again must not stack forms.
|
|
160
|
+
const pending = await fetch(`${UI}/api/secrets/pending`).then((r) => r.json()).catch(() => []);
|
|
161
|
+
let req = pending.find((r) => r.names.join() === missing.join());
|
|
162
|
+
if (req) {
|
|
163
|
+
// Second call: they said "done" (or we are checking). Give the form a
|
|
164
|
+
// short moment, then report — never sit here in silence.
|
|
165
|
+
const deadline = Date.now() + 20_000;
|
|
166
|
+
while (Date.now() < deadline) {
|
|
167
|
+
const st = await fetch(`${UI}/api/secrets/${req.id}`).then((x) => x.json()).catch(() => null);
|
|
168
|
+
if (st?.filled)
|
|
169
|
+
break;
|
|
170
|
+
await new Promise((ok) => setTimeout(ok, 2000));
|
|
171
|
+
}
|
|
172
|
+
reload();
|
|
173
|
+
missing = missingSecrets(ROOT, names);
|
|
174
|
+
if (!missing.length)
|
|
175
|
+
return text(`Set: ${names.join(", ")} — saved to the workspace .env from the Retake window. ${how} Continue.`);
|
|
176
|
+
return text(`Not yet — ${missing.join(", ")} still empty. The form is still open in the Retake window. Tell them again, with the link: “Open ${link} — there is a form waiting for ${list}. Type the demo account's values, press Save to .env, and tell me when it's done.” Then call secrets again.`);
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const r = await fetch(`${UI}/api/secrets/request`, { method: "POST", body: JSON.stringify({ names: missing, why }) });
|
|
180
|
+
if (!r.ok)
|
|
181
|
+
throw new Error(String(r.status));
|
|
182
|
+
await tell(`Waiting for you: ${missing.join(", ")} — a form is open in the window.`);
|
|
183
|
+
return text(`A form is now open in the Retake window. Tell the person this, including the link: “Open ${link} in your browser — a form is waiting there for ${list}. Use a demo account for the app (the result is a video). What you type is saved to one local file on your computer and never sent to me or anywhere else. Press Save to .env, then tell me it's done.” When they say done, call secrets again with the same names.`);
|
|
184
|
+
}
|
|
185
|
+
catch { /* fall through to the terminal route */ }
|
|
186
|
+
}
|
|
187
|
+
return text(`The Retake window is not open. Tell the person this, exactly: “Retake needs a demo account for this app. Either run \`retake ui\` and open ${link} — I'll ask you there — or run \`retake secret ${missing.join(" ")}\` in your Retake workspace folder and type the values into the terminal (hidden, kept on your machine). If the sign-in needs a code from your phone, SSO or a captcha, run \`retake signin demos/<name>.yaml\` instead and log in once by hand. Tell me when it's done.” Then call secrets again with the same names.`);
|
|
188
|
+
});
|
|
189
|
+
server.registerTool("ask", { description: "Ask the person ONE question and wait for the answer. Use only when genuinely blocked (which app, a choice between two things). Keep it one sentence, with the evidence. NEVER for passwords or tokens — that is what `secrets` is for.", inputSchema: { question: z.string() }, annotations: RETAKE_WRITE }, async ({ question }) => {
|
|
133
190
|
try {
|
|
134
191
|
const a = await waitForHuman("question", question);
|
|
135
192
|
return text(`They answered: ${a}`);
|
|
@@ -249,7 +306,7 @@ server.registerTool("read_manifest", { description: "The YAML of a demo.", input
|
|
|
249
306
|
return text(`no demo "${name}"`);
|
|
250
307
|
return text(fs.readFileSync(manifestPath(name), "utf8"));
|
|
251
308
|
});
|
|
252
|
-
server.registerTool("write_manifest", { description: "Create or replace a demo's YAML. It is validated; errors come back instead of being written. Prefer `edit` for small changes to an existing demo.", inputSchema: { name: z.string(), yaml: z.string() }, annotations: RETAKE_WRITE }, async ({ name, yaml }) => {
|
|
309
|
+
server.registerTool("write_manifest", { description: "Create or replace a demo's YAML. It is validated; errors come back instead of being written. Prefer `edit` for small changes to an existing demo. ALWAYS set `mode:`. `mode: demo` (the default) proves how something works and keeps everything but the product out of the frame — a client walkthrough, a PR clip, a bug repro, a lesson, anything for someone who already wants to know how it works. `mode: launch` presents the product in public (a launch, a post, an ad, a landing page) and is the ONLY mode where intro/outro cards, music, callouts and a branded ending belong — and a launch cut ends with THEIR product, never with Retake. Decide from the AUDIENCE, not the subject, and never upgrade a demo to a launch yourself: if you are unsure, use demo and ask.", inputSchema: { name: z.string(), yaml: z.string() }, annotations: RETAKE_WRITE }, async ({ name, yaml }) => {
|
|
253
310
|
LAST_DEMO = name;
|
|
254
311
|
if (!safe(name))
|
|
255
312
|
return text("name must be kebab-case");
|
|
@@ -269,7 +326,7 @@ server.registerTool("write_manifest", { description: "Create or replace a demo's
|
|
|
269
326
|
await tell(`Wrote demos/${name}.yaml — ${r.data.steps.length} steps, ${r.data.steps.filter((s) => s.action === "scene").length} scenes`);
|
|
270
327
|
return text(`written: demos/${name}.yaml (${r.data.steps.length} steps)`);
|
|
271
328
|
});
|
|
272
|
-
server.registerTool("draft", { description: "Let Retake draft a manifest from a sentence: scouts the URL (and reads the project if given) and writes demos/<name>.yaml. Then dry-run it.", inputSchema: { name: z.string(), url: z.string().url(), describe: z.string(), project: z.string().optional() }, annotations: RETAKE_WRITE }, async ({ name, url, describe, project }) => {
|
|
329
|
+
server.registerTool("draft", { description: "Let Retake draft a manifest from a sentence: scouts the URL (and reads the project if given) and writes demos/<name>.yaml. Then dry-run it. Drafts are always `mode: demo` — nothing in the frame but the product. If this is a launch or a post, say so afterwards with edit (see write_manifest for what launch mode allows).", inputSchema: { name: z.string(), url: z.string().url(), describe: z.string(), project: z.string().optional() }, annotations: RETAKE_WRITE }, async ({ name, url, describe, project }) => {
|
|
273
330
|
LAST_DEMO = name;
|
|
274
331
|
if (!safe(name))
|
|
275
332
|
return text("name must be kebab-case");
|
|
@@ -284,7 +341,7 @@ server.registerTool("draft", { description: "Let Retake draft a manifest from a
|
|
|
284
341
|
await tell(`Drafted demos/${name}.yaml — ${m.steps.length} steps, ${m.steps.filter((s) => s.action === "scene").length} scenes`);
|
|
285
342
|
return text(`drafted demos/${name}.yaml (${m.steps.length} steps). Next: dry.\n\n${d.yaml}`);
|
|
286
343
|
});
|
|
287
|
-
server.registerTool("edit", { description: "Make small structured changes to a demo without rewriting it: captions, camera, holds, trim, waits, selectors, text; delete_step; insert_step {after, step:{action,…}} to add one step; set_step {step, value:{action,…}} to replace one. Steps are validated. Keeps comments. Returns what changed and whether it needs re-recording. Prefer this over write_manifest — one verb costs a line, a rewrite costs the file.", inputSchema: { name: z.string(), edits: z.array(z.object({ op: z.string() }).passthrough()) }, annotations: RETAKE_WRITE }, async ({ name, edits }) => {
|
|
344
|
+
server.registerTool("edit", { description: "Make small structured changes to a demo without rewriting it: captions, camera, holds, trim, waits, selectors, text; delete_step; insert_step {after, step:{action,…}} to add one step; set_step {step, value:{action,…}} to replace one. Steps are validated. Keeps comments. Returns what changed and whether it needs re-recording. Prefer this over write_manifest — one verb costs a line, a rewrite costs the file. Turning a demo into a launch cut is edits too: set mode: launch, then intro/outro cards, one callout at most, music (their file), compressIdle. Never do that unasked — a demo is the default and a client walkthrough is a demo.", inputSchema: { name: z.string(), edits: z.array(z.object({ op: z.string() }).passthrough()) }, annotations: RETAKE_WRITE }, async ({ name, edits }) => {
|
|
288
345
|
LAST_DEMO = name;
|
|
289
346
|
if (!safe(name) || !fs.existsSync(manifestPath(name)))
|
|
290
347
|
return text(`no demo "${name}"`);
|
|
@@ -303,7 +360,7 @@ server.registerTool("dry", { description: "Run a demo with no camera: every sele
|
|
|
303
360
|
await tell(r.ok ? `Checked: all ${manifest.steps.length} steps resolve` : `${r.failures} step(s) would fail`);
|
|
304
361
|
return text(r.ok ? `ok: all ${manifest.steps.length} steps resolve` : r.lines.join("\n"));
|
|
305
362
|
});
|
|
306
|
-
server.registerTool("run", { description: "Record the demo and render it. Slow (the demo is performed in real time). preview=true for a fast low-cost render to check the story. Returns the receipts.", inputSchema: { name: z.string(), preview: z.boolean().default(true), until: z.string().optional().describe("record up to the end of this scene label, then stop — for iterating on one beat of a long demo") }, annotations: RETAKE_WRITE }, async ({ name, preview, until }) => {
|
|
363
|
+
server.registerTool("run", { description: "Record the demo and render it. Slow (the demo is performed in real time). preview=true for a fast low-cost render to check the story. Returns the receipts. `check: pass` and healthy audio prove the FILE is sound, not that the demo is good — call `look` at the payoff scene and judge it: can a stranger state the promise from that frame, is the result legible at playback size, is any beat longer than what it tells you? If you cannot answer yes, fix the cut before calling done.", inputSchema: { name: z.string(), preview: z.boolean().default(true), until: z.string().optional().describe("record up to the end of this scene label, then stop — for iterating on one beat of a long demo") }, annotations: RETAKE_WRITE }, async ({ name, preview, until }) => {
|
|
307
364
|
LAST_DEMO = name;
|
|
308
365
|
if (!safe(name) || !fs.existsSync(manifestPath(name)))
|
|
309
366
|
return text(`no demo "${name}"`);
|
package/dist/pace.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idle compression: the app's dead time, shortened at render.
|
|
3
|
+
*
|
|
4
|
+
* The take's own timeline is the honest signal — a waitFor or navigate step
|
|
5
|
+
* records exactly how long the viewer sat looking at nothing changing. Each
|
|
6
|
+
* such stretch is shown as ~keepSeconds: the real start at 1×, then the rest
|
|
7
|
+
* fast-forwarded. The author's `wait` steps are pacing and are never touched;
|
|
8
|
+
* scene holds stay exactly as written.
|
|
9
|
+
*
|
|
10
|
+
* Implemented as a pre-pass: the source video is re-cut into a warped source
|
|
11
|
+
* and every clock in the take (timeline, trim, duration) is remapped through
|
|
12
|
+
* the same piecewise-linear warp, so captions, camera keys and stills land
|
|
13
|
+
* where they always did — the rest of the render pipeline never knows.
|
|
14
|
+
*/
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
/** The app made the viewer wait: show the start, fast-forward the rest. */
|
|
17
|
+
const WAITING = new Set(["waitFor", "navigate"]);
|
|
18
|
+
/** Only stretches meaningfully longer than what we would keep are worth a cut. */
|
|
19
|
+
const SLACK = 0.6;
|
|
20
|
+
/** Typing is not dead time — it is the person speaking — so it keeps its
|
|
21
|
+
start AND its finish at real speed and only accelerates the middle. Every
|
|
22
|
+
keystroke is a round-trip to the browser (~57ms whatever `delay` says), so
|
|
23
|
+
a real sentence costs five seconds nobody watches. */
|
|
24
|
+
const TYPE_BUDGET = 2.4;
|
|
25
|
+
const TYPE_HEAD = 0.8;
|
|
26
|
+
const TYPE_TAIL = 0.7;
|
|
27
|
+
/** Plan the warp: 1× everywhere except the tail of each long app-wait and the
|
|
28
|
+
middle of each long typing run. */
|
|
29
|
+
export function planIdle(take, keepSeconds) {
|
|
30
|
+
const segs = [];
|
|
31
|
+
for (const e of take.timeline) {
|
|
32
|
+
if (!e.ok)
|
|
33
|
+
continue;
|
|
34
|
+
const dur = e.end - e.start;
|
|
35
|
+
if (WAITING.has(e.action)) {
|
|
36
|
+
if (dur < keepSeconds + SLACK)
|
|
37
|
+
continue;
|
|
38
|
+
// Head at real speed, tail fast-forwarded into the remaining budget.
|
|
39
|
+
const head = keepSeconds * 0.6;
|
|
40
|
+
const shownTail = keepSeconds * 0.4;
|
|
41
|
+
segs.push({ from: e.start + head, to: e.end, rate: (dur - head) / shownTail });
|
|
42
|
+
}
|
|
43
|
+
else if (e.action === "type" || e.action === "fill") {
|
|
44
|
+
if (dur < TYPE_BUDGET + SLACK)
|
|
45
|
+
continue;
|
|
46
|
+
const middle = dur - TYPE_HEAD - TYPE_TAIL;
|
|
47
|
+
const shownMiddle = Math.max(0.3, TYPE_BUDGET - TYPE_HEAD - TYPE_TAIL);
|
|
48
|
+
segs.push({ from: e.start + TYPE_HEAD, to: e.end - TYPE_TAIL, rate: middle / shownMiddle });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return segs.sort((a, b) => a.from - b.from);
|
|
52
|
+
}
|
|
53
|
+
/** Source time → warped time. */
|
|
54
|
+
export function warpTime(segs, t) {
|
|
55
|
+
let out = t;
|
|
56
|
+
for (const s of segs) {
|
|
57
|
+
if (t <= s.from)
|
|
58
|
+
break;
|
|
59
|
+
const inSeg = Math.min(t, s.to) - s.from;
|
|
60
|
+
out -= inSeg - inSeg / s.rate;
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/** The take with every clock remapped. */
|
|
65
|
+
export function warpTake(take, segs) {
|
|
66
|
+
const w = (t) => warpTime(segs, t);
|
|
67
|
+
return {
|
|
68
|
+
...take,
|
|
69
|
+
trimBefore: w(take.trimBefore),
|
|
70
|
+
duration: w(take.duration),
|
|
71
|
+
timeline: take.timeline.map((e) => ({ ...e, start: w(e.start), end: w(e.end) })),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** ffmpeg args to cut the raw source into the warped source. */
|
|
75
|
+
export function warpFilterArgs(src, segs, duration, out) {
|
|
76
|
+
// Alternating 1× and fast segments covering [0, duration].
|
|
77
|
+
const pieces = [];
|
|
78
|
+
let cur = 0;
|
|
79
|
+
for (const s of segs) {
|
|
80
|
+
if (s.from > cur)
|
|
81
|
+
pieces.push({ a: cur, b: s.from, rate: 1 });
|
|
82
|
+
pieces.push({ a: s.from, b: s.to, rate: s.rate });
|
|
83
|
+
cur = s.to;
|
|
84
|
+
}
|
|
85
|
+
if (cur < duration)
|
|
86
|
+
pieces.push({ a: cur, b: duration, rate: 1 });
|
|
87
|
+
const graph = pieces.map((p, i) => `[0:v]trim=start=${p.a.toFixed(3)}:end=${p.b.toFixed(3)},setpts=(PTS-STARTPTS)/${p.rate.toFixed(4)}[s${i}]`).join(";") +
|
|
88
|
+
";" + pieces.map((_, i) => `[s${i}]`).join("") + `concat=n=${pieces.length}:v=1:a=0[out]`;
|
|
89
|
+
return ["-i", path.resolve(src), "-filter_complex", graph, "-map", "[out]", "-an", out];
|
|
90
|
+
}
|
|
91
|
+
/** Human line for the proof log. */
|
|
92
|
+
export function describeIdle(take, segs) {
|
|
93
|
+
return segs.map((s) => {
|
|
94
|
+
const e = take.timeline.find((x) => x.start <= s.from && x.end >= s.to);
|
|
95
|
+
const real = e ? e.end - e.start : s.to - s.from;
|
|
96
|
+
const shown = real - (s.to - s.from) + (s.to - s.from) / s.rate;
|
|
97
|
+
return `${e ? e.summary.slice(0, 52) : "wait"}: ${real.toFixed(1)}s shown as ${shown.toFixed(1)}s`;
|
|
98
|
+
});
|
|
99
|
+
}
|
package/dist/presets.js
CHANGED
|
@@ -8,7 +8,7 @@ export const PRESETS = {
|
|
|
8
8
|
name: "draft",
|
|
9
9
|
width: 960, height: 540, scale: 1, fps: 15, crf: 0,
|
|
10
10
|
gif: false, layout: "band", cameraZoom: 1.2,
|
|
11
|
-
cursorSize: 36, captionFontSize: 24, bandHeight: 76,
|
|
11
|
+
cursorSize: 36, captionFontSize: 24, bandHeight: 76, bandFit: "text",
|
|
12
12
|
encoder: "videotoolbox", master: false,
|
|
13
13
|
description: "Fastest. Same layout as the final at a quarter of the pixels — for iterating, never for shipping.",
|
|
14
14
|
},
|
|
@@ -16,7 +16,7 @@ export const PRESETS = {
|
|
|
16
16
|
name: "preview-fast",
|
|
17
17
|
width: 1920, height: 1080, scale: 2, fps: 24, crf: 0,
|
|
18
18
|
gif: false, layout: "band", cameraZoom: 1.25,
|
|
19
|
-
cursorSize: 72, captionFontSize: 46, bandHeight: 150,
|
|
19
|
+
cursorSize: 72, captionFontSize: 46, bandHeight: 150, bandFit: "text",
|
|
20
20
|
encoder: "videotoolbox", master: false,
|
|
21
21
|
description: "Same framing as post, hardware-encoded MP4 only. For checking timing and framing fast.",
|
|
22
22
|
},
|
|
@@ -24,7 +24,7 @@ export const PRESETS = {
|
|
|
24
24
|
name: "proof-fast",
|
|
25
25
|
width: 960, height: 720, scale: 1, fps: 24, crf: 24,
|
|
26
26
|
gif: false, layout: "band", cameraZoom: 1.2,
|
|
27
|
-
cursorSize: 40, captionFontSize: 26, bandHeight: 84,
|
|
27
|
+
cursorSize: 40, captionFontSize: 26, bandHeight: 84, bandFit: "text",
|
|
28
28
|
encoder: "videotoolbox", master: false,
|
|
29
29
|
description: "Small, quick internal check.",
|
|
30
30
|
},
|
|
@@ -32,7 +32,7 @@ export const PRESETS = {
|
|
|
32
32
|
name: "post-landscape",
|
|
33
33
|
width: 1920, height: 1080, scale: 2, fps: 30, crf: 17,
|
|
34
34
|
gif: false, layout: "band", cameraZoom: 1.25,
|
|
35
|
-
cursorSize: 72, captionFontSize: 46, bandHeight: 150,
|
|
35
|
+
cursorSize: 72, captionFontSize: 46, bandHeight: 150, bandFit: "text",
|
|
36
36
|
encoder: "x264", master: true,
|
|
37
37
|
description: "1080p, 30fps, CRF 17. The general-purpose demo. GIF on request.",
|
|
38
38
|
},
|
|
@@ -41,25 +41,25 @@ export const PRESETS = {
|
|
|
41
41
|
/* The page area is the canvas minus the caption band, so the file really
|
|
42
42
|
is 1080×1080 — a square preset that produced 1080×1210 got letterboxed
|
|
43
43
|
by every feed. Landscape keeps its full-height page on purpose. */
|
|
44
|
-
width: 1080, height:
|
|
44
|
+
width: 1080, height: 960, scale: 1.6, fps: 30, crf: 17,
|
|
45
45
|
gif: false, layout: "band", cameraZoom: 1.25,
|
|
46
|
-
cursorSize: 60, captionFontSize:
|
|
46
|
+
cursorSize: 60, captionFontSize: 36, bandHeight: 120, bandFit: "fill",
|
|
47
47
|
encoder: "x264", master: true,
|
|
48
|
-
description: "A true 1080×1080 (page 1080×
|
|
48
|
+
description: "A true 1080×1080 (page 1080×960 + a fixed 120px caption band), feed-friendly.",
|
|
49
49
|
},
|
|
50
50
|
"post-vertical": {
|
|
51
51
|
name: "post-vertical",
|
|
52
|
-
width: 1080, height:
|
|
52
|
+
width: 1080, height: 1800, scale: 2, fps: 30, crf: 17,
|
|
53
53
|
gif: false, layout: "band", cameraZoom: 1.3,
|
|
54
|
-
cursorSize: 60, captionFontSize:
|
|
54
|
+
cursorSize: 60, captionFontSize: 40, bandHeight: 120, bandFit: "fill",
|
|
55
55
|
encoder: "x264", master: true,
|
|
56
|
-
description: "A true 1080×1920 (page 1080×
|
|
56
|
+
description: "A true 1080×1920 (page 1080×1800 + a fixed 120px caption band) for shorts/reels. Narrow-column apps suit it best.",
|
|
57
57
|
},
|
|
58
58
|
"docs-gif": {
|
|
59
59
|
name: "docs-gif",
|
|
60
60
|
width: 1440, height: 900, scale: 1.5, fps: 24, crf: 20,
|
|
61
61
|
gif: { width: 900, fps: 15 }, layout: "band", cameraZoom: 1.2,
|
|
62
|
-
cursorSize: 48, captionFontSize: 32, bandHeight: 100,
|
|
62
|
+
cursorSize: 48, captionFontSize: 32, bandHeight: 100, bandFit: "text",
|
|
63
63
|
encoder: "x264", master: false,
|
|
64
64
|
description: "README/docs GIF, 900px wide.",
|
|
65
65
|
},
|
|
@@ -67,7 +67,7 @@ export const PRESETS = {
|
|
|
67
67
|
name: "master",
|
|
68
68
|
width: 1920, height: 1080, scale: 2, fps: 30, crf: 12,
|
|
69
69
|
gif: false, layout: "band", cameraZoom: 1.25,
|
|
70
|
-
cursorSize: 72, captionFontSize: 46, bandHeight: 150,
|
|
70
|
+
cursorSize: 72, captionFontSize: 46, bandHeight: 150, bandFit: "text",
|
|
71
71
|
encoder: "x264", master: true,
|
|
72
72
|
description: "Highest quality, slowest. Archive/source render.",
|
|
73
73
|
},
|
package/dist/record.js
CHANGED
|
@@ -44,7 +44,11 @@ export function capSecondsFor(m) {
|
|
|
44
44
|
return Math.min(3600, Math.max(240, Math.round(m.steps.length * 10 + waits)));
|
|
45
45
|
}
|
|
46
46
|
/** Roughly how many times the cursor will move on camera. */
|
|
47
|
-
|
|
47
|
+
/** Cursor moves per take. testreel's nested-if overlay died at ~45 (ffmpeg's
|
|
48
|
+
parser stops at 98 levels); scripts/patch-testreel.mjs flattens those
|
|
49
|
+
expressions, so the ceiling is now the size of the filter graph as one
|
|
50
|
+
argv string — ~630 bytes a move against Linux's 128 KB per-argument cap. */
|
|
51
|
+
export const CURSOR_MOVE_LIMIT = 180;
|
|
48
52
|
export function cursorMoves(m) {
|
|
49
53
|
return m.steps.filter((s) => ["click", "type", "fill", "hover", "scroll", "upload"].includes(s.action)).length;
|
|
50
54
|
}
|
|
@@ -187,6 +191,10 @@ export async function record(m, opts) {
|
|
|
187
191
|
const stateFresh = !!statePath && fs.existsSync(statePath) && (Date.now() - fs.statSync(statePath).mtimeMs) / 3.6e6 < (m.auth?.maxAgeHours ?? 72);
|
|
188
192
|
if (statePath)
|
|
189
193
|
log(stateFresh ? `auth: reusing session ${path.relative(process.cwd(), statePath)}` : `auth: no fresh session — running setup to sign in`);
|
|
194
|
+
// No session and nothing that could create one: stop here rather than record
|
|
195
|
+
// a logged-out take that passes every check.
|
|
196
|
+
if (statePath && !stateFresh && !(m.auth?.setup?.length))
|
|
197
|
+
throw new Error(`no fresh session at ${path.relative(process.cwd(), statePath)} and no auth.setup to sign in with — run \`retake signin ${path.relative(process.cwd(), path.join(opts.manifestDir, m.name + ".yaml"))}\` and log in once by hand, or add the sign-in steps under auth.setup`);
|
|
190
198
|
const context = await browser.newContext({
|
|
191
199
|
viewport: size,
|
|
192
200
|
colorScheme: m.colorScheme,
|
|
@@ -293,10 +301,7 @@ export async function record(m, opts) {
|
|
|
293
301
|
// past roughly sixty steps the nesting exceeds ffmpeg's parser and the
|
|
294
302
|
// composite fails. Long demos get an always-visible cursor unless told
|
|
295
303
|
// otherwise — the fade was never the point of the video.
|
|
296
|
-
const
|
|
297
|
-
const cursorIdleHide = q.cursor === false ? undefined : q.cursor.idleHide ?? (m.steps.length <= LONG);
|
|
298
|
-
if (q.cursor !== false && q.cursor.idleHide === undefined && m.steps.length > LONG)
|
|
299
|
-
log(`cursor: ${m.steps.length} steps — idle-hide fades off (ffmpeg nesting limit); set cursor.idleHide to override`);
|
|
304
|
+
const cursorIdleHide = q.cursor === false ? undefined : q.cursor.idleHide;
|
|
300
305
|
const rec = await recordPage(page, {
|
|
301
306
|
outputDir: opts.outDir,
|
|
302
307
|
name: m.name,
|
|
@@ -331,6 +336,10 @@ export async function record(m, opts) {
|
|
|
331
336
|
if (statePath && !stateFresh && authOk) {
|
|
332
337
|
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
333
338
|
await context.storageState({ path: statePath });
|
|
339
|
+
try {
|
|
340
|
+
fs.chmodSync(statePath, 0o600);
|
|
341
|
+
}
|
|
342
|
+
catch { /* windows */ }
|
|
334
343
|
log(`auth: saved session → ${path.relative(process.cwd(), statePath)}`);
|
|
335
344
|
}
|
|
336
345
|
else if (statePath && !stateFresh) {
|
|
@@ -343,7 +352,7 @@ export async function record(m, opts) {
|
|
|
343
352
|
// overlay silently fails and the video ships with no cursor at all.
|
|
344
353
|
const moves = cursorMoves(m);
|
|
345
354
|
if (q.cursor !== false && moves > CURSOR_MOVE_LIMIT)
|
|
346
|
-
log(`cursor: ~${moves} cursor moves —
|
|
355
|
+
log(`cursor: ~${moves} cursor moves — past ~${CURSOR_MOVE_LIMIT} the cursor filter no longer fits in one ffmpeg argument and the overlay may be MISSING. Split the demo (a viewer wants chapters anyway), or set cursor: false to be honest about it.`);
|
|
347
356
|
let pastUntil = false;
|
|
348
357
|
for (const [i, step] of m.steps.entries()) {
|
|
349
358
|
// --until <scene>: record that scene in full, stop at the next one.
|
|
@@ -365,6 +374,15 @@ export async function record(m, opts) {
|
|
|
365
374
|
end: 0,
|
|
366
375
|
ok: true,
|
|
367
376
|
};
|
|
377
|
+
if (step.action === "callout") {
|
|
378
|
+
// Resolve the box now; the ring itself is drawn at render time.
|
|
379
|
+
const target = step.selector ?? step.at;
|
|
380
|
+
const c = await resolvePoint(page, target, step.timeout ?? 10_000);
|
|
381
|
+
const half = typeof target === "string" || !("x" in target)
|
|
382
|
+
? await page.locator((typeof target === "string" ? target : target.selector)).first().evaluate((el) => { const r = el.getBoundingClientRect(); return { w: r.width, h: r.height }; }).catch(() => ({ w: 48, h: 48 }))
|
|
383
|
+
: { w: 48, h: 48 };
|
|
384
|
+
entry.callout = { box: { x: c.x - half.w / 2, y: c.y - half.h / 2, width: half.w, height: half.h }, label: step.label, ms: step.ms };
|
|
385
|
+
}
|
|
368
386
|
if (step.action === "scene") {
|
|
369
387
|
entry.label = step.label;
|
|
370
388
|
entry.caption = step.caption;
|
|
@@ -430,7 +448,7 @@ export async function record(m, opts) {
|
|
|
430
448
|
screenshots = result.screenshots;
|
|
431
449
|
const joined = compositeLog.join("");
|
|
432
450
|
if (/Error initializing filters|Missing '\)' or too many args/.test(joined)) {
|
|
433
|
-
const why = `cursor overlay failed in testreel's ffmpeg pass (
|
|
451
|
+
const why = `cursor overlay failed in testreel's ffmpeg pass (~${moves} moves; the filter is too large past ~${CURSOR_MOVE_LIMIT}); the video has NO CURSOR. Split the demo or set cursor: false.`;
|
|
434
452
|
partial = partial ? `${partial}; ${why}` : why;
|
|
435
453
|
ok = false;
|
|
436
454
|
log(`✗ ${why}`);
|
|
@@ -480,10 +498,18 @@ export async function record(m, opts) {
|
|
|
480
498
|
// calibrated to within ~0.2s of the first video frame on a warm route). The
|
|
481
499
|
// real file length is authoritative for the end.
|
|
482
500
|
const trimBefore = sec(setupEnd - t0);
|
|
483
|
-
|
|
501
|
+
// The recording keeps rolling while the browser is torn down, so the raw
|
|
502
|
+
// video always has a tail of nothing after the last step. The take ends
|
|
503
|
+
// where the demo ends (plus a breath), never where the recorder stopped —
|
|
504
|
+
// otherwise every video ships with dead seconds at the end.
|
|
505
|
+
const GRACE = 0.4;
|
|
506
|
+
const videoSec = video ? videoDuration(video) : sec(endMs - t0);
|
|
484
507
|
const last = timeline.at(-1);
|
|
485
|
-
|
|
486
|
-
|
|
508
|
+
const duration = last && videoSec > last.end + GRACE + 0.1 ? last.end + GRACE : videoSec;
|
|
509
|
+
if (last && duration < videoSec - 0.05)
|
|
510
|
+
log(`trim: ${(videoSec - duration).toFixed(1)}s of tail after the last step dropped`);
|
|
511
|
+
if (last && videoSec < last.end - 0.5) {
|
|
512
|
+
log(`note: video is ${videoSec.toFixed(1)}s but the last step ended at ${last.end.toFixed(1)}s — check anchoring`);
|
|
487
513
|
}
|
|
488
514
|
const finishedAt = new Date().toISOString();
|
|
489
515
|
const take = {
|
|
@@ -614,7 +640,7 @@ async function runStep(rec, page, step, m, ctx) {
|
|
|
614
640
|
await rec.click(step.selector, { timeout, zoom: step.zoom });
|
|
615
641
|
break;
|
|
616
642
|
case "type":
|
|
617
|
-
await rec.type(step.selector, expandEnv(step.text), { delay: step.delay ?? 70, clear: step.clear, timeout });
|
|
643
|
+
await rec.type(step.selector, expandEnv(step.text), { delay: step.delay ?? (m.typing === "brisk" ? 22 : 70), clear: step.clear, timeout });
|
|
618
644
|
break;
|
|
619
645
|
case "fill":
|
|
620
646
|
await rec.fill(step.selector, expandEnv(step.text), { timeout });
|
|
@@ -694,6 +720,11 @@ async function runStep(rec, page, step, m, ctx) {
|
|
|
694
720
|
case "stub":
|
|
695
721
|
await ctx.stub({ url: step.url, method: step.method, status: step.status ?? 200, json: step.json, from: step.from, contentType: "application/json; charset=utf-8" });
|
|
696
722
|
break;
|
|
723
|
+
case "callout":
|
|
724
|
+
// The box was resolved in the caller; on camera this is a hold while
|
|
725
|
+
// the (render-time) ring has the viewer's eye.
|
|
726
|
+
await page.waitForTimeout(step.ms / m.speed);
|
|
727
|
+
break;
|
|
697
728
|
case "scene":
|
|
698
729
|
// A marker only. Its timestamp is the point of the step.
|
|
699
730
|
break;
|
|
@@ -742,6 +773,8 @@ export function describe(step) {
|
|
|
742
773
|
return `evaluate (${step.script.length} chars)`;
|
|
743
774
|
case "stub":
|
|
744
775
|
return `stub ${step.method ? step.method + " " : ""}${step.url}`;
|
|
776
|
+
case "callout":
|
|
777
|
+
return `callout ${step.selector ?? pointName(step.at)}${step.label ? ` “${step.label}”` : ""}`;
|
|
745
778
|
case "scene":
|
|
746
779
|
return `scene: ${step.label}`;
|
|
747
780
|
}
|