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/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";
|
|
@@ -78,6 +79,14 @@ export const Step = z.discriminatedUnion("action", [
|
|
|
78
79
|
slow it down and the viewer can follow what went past. */
|
|
79
80
|
speed: z.number().positive().max(4).optional(),
|
|
80
81
|
}),
|
|
82
|
+
/** Choose an option in a <select>. A form-heavy app is mostly dropdowns, and
|
|
83
|
+
without this a demo of one has to fake them with evaluate. */
|
|
84
|
+
Base.extend({
|
|
85
|
+
action: z.literal("select"),
|
|
86
|
+
selector: Selector,
|
|
87
|
+
/** The option's value, or its visible label — whichever the page uses. */
|
|
88
|
+
value: z.string(),
|
|
89
|
+
}),
|
|
81
90
|
Base.extend({
|
|
82
91
|
action: z.literal("zoom"),
|
|
83
92
|
selector: Selector.optional(),
|
|
@@ -96,7 +105,23 @@ export const Step = z.discriminatedUnion("action", [
|
|
|
96
105
|
Base.extend({ action: z.literal("download"), selector: Selector.optional(), saveAs: z.string().optional(), expect: z.string().optional() }),
|
|
97
106
|
/** Wait for a selector to appear (Retake extra; the thing testreel's JSON lacks
|
|
98
107
|
for AI-driven apps whose next screen arrives whenever the model answers). */
|
|
99
|
-
|
|
108
|
+
/** Wait for a real result. A bare selector resolves the instant the element
|
|
109
|
+
exists, which is the wrong moment three ways, all of them seen in the
|
|
110
|
+
field: the element is a shell that streams its text in afterwards; the
|
|
111
|
+
element is the PREVIOUS run\'s banner still on screen; the element is
|
|
112
|
+
there but the app is still busy. Hence: */
|
|
113
|
+
Base.extend({
|
|
114
|
+
action: z.literal("waitFor"),
|
|
115
|
+
selector: Selector,
|
|
116
|
+
/** Wait for it to go AWAY instead of appear — the fix for waiting on a
|
|
117
|
+
stale success banner from the previous action. */
|
|
118
|
+
gone: z.boolean().default(false),
|
|
119
|
+
/** Resolve only once the subtree has stopped changing for this long.
|
|
120
|
+
This is how you wait for a streamed reply to finish. */
|
|
121
|
+
stableMs: z.number().min(50).max(30_000).optional(),
|
|
122
|
+
/** Resolve only once it holds at least this much text. */
|
|
123
|
+
minChars: z.number().int().positive().optional(),
|
|
124
|
+
}),
|
|
100
125
|
/** Run JS in the page (Retake extra). Used for seeding, hiding chrome, etc. */
|
|
101
126
|
Base.extend({ action: z.literal("evaluate"), script: z.string().min(1) }),
|
|
102
127
|
/** Change what a stubbed endpoint answers, mid-take. This is how a demo shows
|
|
@@ -110,11 +135,26 @@ export const Step = z.discriminatedUnion("action", [
|
|
|
110
135
|
json: z.unknown().optional(),
|
|
111
136
|
from: z.string().optional(),
|
|
112
137
|
}),
|
|
138
|
+
/** Point at something while the take holds: an animated ring and an optional
|
|
139
|
+
label, drawn at render time from the box recorded here. Sparingly — one
|
|
140
|
+
per scene at most; the captions carry the story. */
|
|
141
|
+
Base.extend({
|
|
142
|
+
action: z.literal("callout"),
|
|
143
|
+
selector: Selector.optional(),
|
|
144
|
+
at: Point.optional(),
|
|
145
|
+
label: z.string().max(60).optional(),
|
|
146
|
+
ms: z.number().positive().max(8000).default(2200),
|
|
147
|
+
}),
|
|
113
148
|
/** A named beat. Timestamps go into the proof log; `caption` gets burned into
|
|
114
149
|
the video from this moment until the next scene (or `holdMs`). */
|
|
115
150
|
Base.extend({
|
|
116
151
|
action: z.literal("scene"),
|
|
117
152
|
label: z.string().min(1),
|
|
153
|
+
/** Move this scene's marker in the finished video, in milliseconds
|
|
154
|
+
(negative = earlier). RENDER-TIME: it shifts where the caption, the
|
|
155
|
+
still and the thumbnail land, and never costs a new recording. A
|
|
156
|
+
caption that arrives a beat late is a re-render, not a re-take. */
|
|
157
|
+
nudge: z.number().int().min(-60_000).max(60_000).optional(),
|
|
118
158
|
caption: z.string().optional(),
|
|
119
159
|
holdMs: z.number().positive().optional(),
|
|
120
160
|
/** Where the camera looks during this scene. Omitted → the manifest's
|
|
@@ -124,7 +164,7 @@ export const Step = z.discriminatedUnion("action", [
|
|
|
124
164
|
.optional(),
|
|
125
165
|
}),
|
|
126
166
|
]).superRefine((st, ctx) => {
|
|
127
|
-
if ((st.action === "click" || st.action === "hover") && !st.selector && !st.at) {
|
|
167
|
+
if ((st.action === "click" || st.action === "hover" || st.action === "callout") && !st.selector && !st.at) {
|
|
128
168
|
ctx.addIssue({ code: "custom", message: `${st.action} needs either a selector or an \`at\` point` });
|
|
129
169
|
}
|
|
130
170
|
});
|
|
@@ -177,7 +217,60 @@ const ManifestShape = z.object({
|
|
|
177
217
|
/** 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
218
|
layout: z.enum(["band", "card", "overlay-bottom", "overlay-top", "none"]).optional(),
|
|
179
219
|
/** auto: each scene zooms toward the last element the demo touched · static: no camera moves. */
|
|
180
|
-
|
|
220
|
+
/** Compress at render what nobody watches. A waitFor/navigate that made
|
|
221
|
+
the viewer sit through N seconds is shown as ~keepSeconds (real start,
|
|
222
|
+
then a fast-forward); a long typing run keeps its start and its finish
|
|
223
|
+
at real speed and accelerates the middle (every keystroke is a
|
|
224
|
+
round-trip, so a real sentence costs ~5s of video). The author's own
|
|
225
|
+
`wait` steps are pacing and are never touched. Off by default. */
|
|
226
|
+
compressIdle: z.union([z.boolean(), z.object({ keepSeconds: z.number().min(0.8).max(5).default(1.5) })]).default(false),
|
|
227
|
+
/** What this take is FOR, and it changes what is allowed to be in it.
|
|
228
|
+
`demo` (the default) proves an interaction: direct, legible, minimally
|
|
229
|
+
framed — a title card or music in a demo is noise. `launch` presents
|
|
230
|
+
the product, so cards, music, emphasis and a branded ending are
|
|
231
|
+
legitimate, and each one still has to earn the second it costs.
|
|
232
|
+
Retake never upgrades a demo to a launch on its own. */
|
|
233
|
+
mode: z.enum(["demo", "launch"]).default("demo"),
|
|
234
|
+
/** How typed text lands. `natural` is the per-step default (~80ms a key);
|
|
235
|
+
`brisk` is the launch rhythm — fast keys (~22ms), the pauses carrying
|
|
236
|
+
the meaning instead. A step's own `delay` always wins. */
|
|
237
|
+
typing: z.enum(["natural", "brisk"]).default("natural"),
|
|
238
|
+
/** The captions, read aloud (edge-tts). Each scene's caption is synthesized
|
|
239
|
+
and placed at the scene's start; music ducks under it. The captions are
|
|
240
|
+
the script — there is nothing separate to write. */
|
|
241
|
+
voiceover: z
|
|
242
|
+
.union([
|
|
243
|
+
z.boolean(),
|
|
244
|
+
z.object({
|
|
245
|
+
voice: z.string().default("en-US-JennyNeural"),
|
|
246
|
+
gainDb: z.number().min(-20).max(10).default(0),
|
|
247
|
+
/** One continuous script, synthesized as a single performance and
|
|
248
|
+
laid under the whole video — the only shape that keeps prosody.
|
|
249
|
+
Without it, narration is per-scene fragments: prosody resets at
|
|
250
|
+
every line and the read/wait rhythm follows scene boundaries
|
|
251
|
+
instead of speech. Fragments are refused unless `fragments: true`
|
|
252
|
+
says you know. */
|
|
253
|
+
script: z.string().optional(),
|
|
254
|
+
fragments: z.boolean().default(false),
|
|
255
|
+
/** A person has heard this voice and this script and approved them.
|
|
256
|
+
Synthesis succeeding is not approval. */
|
|
257
|
+
approved: z.boolean().default(false),
|
|
258
|
+
}),
|
|
259
|
+
])
|
|
260
|
+
.default(false),
|
|
261
|
+
/** A music bed under the whole video, mixed at render time: looped or
|
|
262
|
+
trimmed to fit, faded out over the last moments. The person supplies the
|
|
263
|
+
file (CC0/licensed — it ships inside their video); Retake bundles none. */
|
|
264
|
+
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(),
|
|
265
|
+
/** Title cards, rendered at render time (change them, re-render in seconds).
|
|
266
|
+
The intro's settled frame is also written as cover.png — the poster. */
|
|
267
|
+
intro: z.object({ title: z.string().max(60), subtitle: z.string().max(90).optional(), ms: z.number().positive().max(6000).default(2400) }).optional(),
|
|
268
|
+
outro: z.object({ title: z.string().max(60), subtitle: z.string().max(90).optional(), ms: z.number().positive().max(6000).default(2000) }).optional(),
|
|
269
|
+
/** Still by default. A zoom is something a person asks for after seeing a
|
|
270
|
+
take, never something that happens to them: a calm demo reads as real
|
|
271
|
+
and can never crop the wrong thing. `auto` eases toward the last thing
|
|
272
|
+
touched; a scene can override either way. */
|
|
273
|
+
camera: z.union([z.literal("auto"), z.literal("static"), z.object({ zoom: z.number().min(1).max(3) })]).default("static"),
|
|
181
274
|
/** Seconds cut from the head/tail of the finished video. Render-only: change
|
|
182
275
|
it and re-render, no re-recording. */
|
|
183
276
|
trim: z.object({ head: z.number().nonnegative().default(0), tail: z.number().nonnegative().default(0) }).prefault({}),
|
|
@@ -248,6 +341,20 @@ const ManifestShape = z.object({
|
|
|
248
341
|
.prefault({}),
|
|
249
342
|
});
|
|
250
343
|
export const Manifest = ManifestShape.superRefine((m, ctx) => {
|
|
344
|
+
// A synthetic voice is a claim on the person's taste. Retake will not put
|
|
345
|
+
// one in a deliverable until they have heard it and said yes — and will
|
|
346
|
+
// not read the captions as fragments at all, because that is the defect,
|
|
347
|
+
// not the voice: prosody resets every line and the rhythm follows scene
|
|
348
|
+
// boundaries instead of speech.
|
|
349
|
+
if (m.voiceover) {
|
|
350
|
+
const vo = m.voiceover === true ? { approved: false, script: undefined, fragments: false } : m.voiceover;
|
|
351
|
+
if (!vo.approved) {
|
|
352
|
+
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." });
|
|
353
|
+
}
|
|
354
|
+
if (!vo.script && !vo.fragments) {
|
|
355
|
+
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." });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
251
358
|
[...m.steps, ...m.setup].forEach((st, i) => {
|
|
252
359
|
if ((st.action === "click" || st.action === "hover") && !st.selector && !st.at) {
|
|
253
360
|
ctx.addIssue({ code: "custom", path: ["steps", i], message: `${st.action} needs either a selector or an \`at\` point` });
|
|
@@ -305,6 +412,36 @@ export function loadManifest(file) {
|
|
|
305
412
|
/** Things worth saying out loud before a run. Not errors — the run proceeds. */
|
|
306
413
|
export function warnings(m) {
|
|
307
414
|
const w = [];
|
|
415
|
+
// A demo proves an interaction; a launch presents the product. Launch
|
|
416
|
+
// furniture in a demo is noise the viewer has to look past.
|
|
417
|
+
// A held opening frame is the point of a launch cut, not a mistake.
|
|
418
|
+
const launch = m.mode === "launch";
|
|
419
|
+
if (m.viewport) {
|
|
420
|
+
const p = PRESETS[m.preset] ?? PRESETS[DEFAULT_PRESET];
|
|
421
|
+
const band = m.captions === false ? 0 : p.bandHeight;
|
|
422
|
+
if (m.viewport.width !== p.width || m.viewport.height !== p.height - band) {
|
|
423
|
+
w.push(`\`viewport\` overrides the preset, so this video will be ${m.viewport.width}×${m.viewport.height + band} instead of ${p.name}'s ${p.width}×${p.height}. Two demos of the same app then come out different shapes and players letterbox them. Drop \`viewport\` unless the app genuinely needs it.`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
// A navigation that only changes the #fragment does not reload the document.
|
|
427
|
+
// An SPA keeps every bit of in-memory state, so a stub armed just before it
|
|
428
|
+
// is never fetched — and the screen looks plausible, just stale, which is
|
|
429
|
+
// the worst kind of wrong to debug. Knowable from the manifest alone.
|
|
430
|
+
{
|
|
431
|
+
const hops = [m.url, ...m.steps.flatMap((st) => (st.action === "navigate" ? [st.url] : []))];
|
|
432
|
+
for (let i = 1; i < hops.length; i++) {
|
|
433
|
+
const [a, b] = [hops[i - 1], hops[i]];
|
|
434
|
+
const strip = (u) => u.split("#")[0];
|
|
435
|
+
if (a !== b && strip(a) === strip(b) && b.includes("#")) {
|
|
436
|
+
w.push(`step ${i} navigates to \`${b}\` which differs from the previous URL ONLY by its #fragment — the browser will not reload the document. A single-page app keeps all of its in-memory state, so anything you armed just before this (a \`stub\`, a seed) is never re-fetched and the screen looks right but stale. Add a throwaway query param (\`?r=${i}\`) before the fragment to force a real load, or drive the change by clicking the app's own link.`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if (m.mode === "demo") {
|
|
441
|
+
const launchy = [m.intro && "intro card", m.outro && "outro card", m.music && "music", m.steps.some((s) => s.action === "callout") && "callouts"].filter(Boolean);
|
|
442
|
+
if (launchy.length)
|
|
443
|
+
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.`);
|
|
444
|
+
}
|
|
308
445
|
// Secrets typed during recorded steps end up in the video. In `setup` they do
|
|
309
446
|
// not: setup runs before the camera and is trimmed off the front.
|
|
310
447
|
const onCamera = m.steps.filter((s) => s.secret).length;
|
|
@@ -327,23 +464,53 @@ export function warnings(m) {
|
|
|
327
464
|
const nextScene = rest.findIndex((x) => x.action === "scene");
|
|
328
465
|
const between = nextScene === -1 ? rest : rest.slice(0, nextScene);
|
|
329
466
|
// A closing hold on the payoff is normal; only a waits-only scene *followed by another* is empty.
|
|
330
|
-
|
|
467
|
+
// In a launch cut the opening scene IS a held frame — that is the shot.
|
|
468
|
+
const isFirstScene = m.steps.findIndex((x) => x.action === "scene") === i;
|
|
469
|
+
if (nextScene !== -1 && between.length && between.every((x) => x.action === "wait") && !(launch && isFirstScene))
|
|
331
470
|
w.push(`scene "${st.label}" has only waits before the next scene — nothing happens on camera in it.`);
|
|
332
471
|
});
|
|
333
472
|
return w;
|
|
334
473
|
}
|
|
335
474
|
/** `${VAR}` substitution against process.env, like testreel. */
|
|
336
475
|
export function expandEnv(s) {
|
|
337
|
-
return s.replace(/\$\{([A-Z0-9_]+)\}/g, (_, k) => {
|
|
476
|
+
return s.replace(/\$\{(TOTP:)?([A-Z0-9_]+)\}/g, (_, fn, k) => {
|
|
338
477
|
const v = process.env[k];
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
478
|
+
// `APP_USER=` with nothing after it is the template, not a value — a blank
|
|
479
|
+
// login that "succeeds" is worse than a loud stop.
|
|
480
|
+
if (v === undefined || v.trim() === "")
|
|
481
|
+
throw new Error(`manifest references \${${k}} but it is not set — add it to .env (a demo account, never a real one)`);
|
|
482
|
+
// ${TOTP:APP_TOTP_SECRET}: the six-digit code for right now, from an
|
|
483
|
+
// authenticator enrolment secret. Computed at fill time, so it is valid.
|
|
484
|
+
return fn ? totp(v) : v;
|
|
342
485
|
});
|
|
343
486
|
}
|
|
487
|
+
/** RFC 6238 TOTP (SHA-1, 6 digits, 30s) from a base32 secret — what every
|
|
488
|
+
authenticator app enrols with. */
|
|
489
|
+
export function totp(base32Secret, nowMs = Date.now()) {
|
|
490
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
491
|
+
const clean = base32Secret.toUpperCase().replace(/[^A-Z2-7]/g, "");
|
|
492
|
+
let bits = "";
|
|
493
|
+
for (const c of clean)
|
|
494
|
+
bits += alphabet.indexOf(c).toString(2).padStart(5, "0");
|
|
495
|
+
const key = Buffer.from((bits.match(/.{8}/g) ?? []).map((b) => parseInt(b, 2)));
|
|
496
|
+
const counter = Buffer.alloc(8);
|
|
497
|
+
counter.writeBigUInt64BE(BigInt(Math.floor(nowMs / 30000)));
|
|
498
|
+
const h = createHmac("sha1", key).update(counter).digest();
|
|
499
|
+
const off = h[h.length - 1] & 0xf;
|
|
500
|
+
const code = ((h[off] & 0x7f) << 24 | h[off + 1] << 16 | h[off + 2] << 8 | h[off + 3]) % 1_000_000;
|
|
501
|
+
return String(code).padStart(6, "0");
|
|
502
|
+
}
|
|
344
503
|
export function resolve(m) {
|
|
345
504
|
const p = PRESETS[m.preset] ?? PRESETS[DEFAULT_PRESET];
|
|
346
|
-
|
|
505
|
+
// The page is the canvas minus the caption strip, so the finished video is
|
|
506
|
+
// exactly the preset's size. A manifest may override it, and `warnings()`
|
|
507
|
+
// says out loud that doing so changes the output size.
|
|
508
|
+
// No captions anywhere (switched off, or no scene carries one) → no band
|
|
509
|
+
// to reserve: the page records at the full canvas and fills the frame.
|
|
510
|
+
const anyCaption = m.captions !== false && m.steps.some((st) => st.action === "scene" && Boolean(st.caption?.trim()));
|
|
511
|
+
const layout = m.layout ?? p.layout;
|
|
512
|
+
const band = anyCaption && (layout === "band" || layout === "card") ? p.bandHeight : 0;
|
|
513
|
+
const viewport = { width: m.viewport?.width ?? p.width, height: m.viewport?.height ?? p.height - band };
|
|
347
514
|
const gifOverride = m.outputs.gif;
|
|
348
515
|
/* `gif: true` means "yes, a GIF" on any preset. Only docs-gif carries GIF
|
|
349
516
|
settings, so asking for one anywhere else used to resolve to the preset's
|
|
@@ -361,7 +528,8 @@ export function resolve(m) {
|
|
|
361
528
|
scale: m.scale ?? p.scale,
|
|
362
529
|
fps: m.fps ?? p.fps,
|
|
363
530
|
crf: m.crf ?? p.crf,
|
|
364
|
-
layout
|
|
531
|
+
layout,
|
|
532
|
+
bandHeight: band,
|
|
365
533
|
cameraZoom: typeof m.camera === "object" ? m.camera.zoom : p.cameraZoom,
|
|
366
534
|
gif,
|
|
367
535
|
cursor,
|
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.",
|
package/dist/operator/tools.js
CHANGED
|
@@ -19,7 +19,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
|
|
|
19
19
|
import { z } from "zod";
|
|
20
20
|
import YAML from "yaml";
|
|
21
21
|
import { loadManifest } from "../manifest.js";
|
|
22
|
-
import { record, captureHash, acquireLock, releaseLock } from "../record.js";
|
|
22
|
+
import { record, captureHash, acquireLock, releaseLock, keepPrevious, restorePrevious, stashPrevious, EXPENSIVE_TAKE_SECONDS } from "../record.js";
|
|
23
23
|
import { render, check, ffmpegBin } from "../render.js";
|
|
24
24
|
import { execFileSync } from "node:child_process";
|
|
25
25
|
import { dryRun } from "../dryrun.js";
|
|
@@ -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,20 +360,35 @@ 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 — ALWAYS start there, and on a long demo show that draft to the person before spending the full take. Returns the receipts. SIZE IS NOT RECORDED: any take renders at any preset later (`render` with a preset), so never re-record to change the shape and never set `viewport` in a manifest — the preset owns the size and every take at a preset is identical. `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"), from: z.string().optional().describe("start the take AT this scene: earlier steps still run, at full speed and off camera. When only the ending changed, this is the difference between re-recording 90 seconds and re-recording seven minutes") }, annotations: RETAKE_WRITE }, async ({ name, preview, until, from }) => {
|
|
307
364
|
LAST_DEMO = name;
|
|
308
365
|
if (!safe(name) || !fs.existsSync(manifestPath(name)))
|
|
309
366
|
return text(`no demo "${name}"`);
|
|
310
367
|
const loaded = loadManifest(manifestPath(name));
|
|
311
|
-
|
|
368
|
+
// preview means "the fast shape", so a manifest that pins its own viewport
|
|
369
|
+
// must not win silently — that is how a draft ends up costing what the real
|
|
370
|
+
// take costs.
|
|
371
|
+
const { viewport: pinned, ...rest } = loaded.manifest;
|
|
372
|
+
const manifest = preview ? { ...rest, preset: "preview-fast" } : loaded.manifest;
|
|
373
|
+
if (preview && pinned)
|
|
374
|
+
await tell(`preview overrides the pinned ${pinned.width}×${pinned.height} viewport`);
|
|
312
375
|
const outDir = path.join(OUT, name);
|
|
313
376
|
acquireLock(outDir);
|
|
314
377
|
try {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
378
|
+
// Moved aside, not deleted: a failed recording must not cost the person
|
|
379
|
+
// the take they already had.
|
|
380
|
+
stashPrevious(outDir);
|
|
318
381
|
await tell(`Recording ${name}${preview ? " (preview)" : ""}…`);
|
|
319
|
-
|
|
382
|
+
let take;
|
|
383
|
+
try {
|
|
384
|
+
take = await record(manifest, { outDir, manifestDir: DEMOS, locked: true, until, from, log: () => { } });
|
|
385
|
+
}
|
|
386
|
+
catch (e) {
|
|
387
|
+
if (restorePrevious(outDir))
|
|
388
|
+
await tell("Recording failed — your previous take has been put back.");
|
|
389
|
+
throw e;
|
|
390
|
+
}
|
|
391
|
+
keepPrevious(outDir, (l) => void tell(l));
|
|
320
392
|
await tell(`Rendering…`);
|
|
321
393
|
const a = await render(manifest, take, outDir, {});
|
|
322
394
|
const c = check(outDir, manifest);
|
|
@@ -326,13 +398,24 @@ server.registerTool("run", { description: "Record the demo and render it. Slow (
|
|
|
326
398
|
// the page showed, so even a small model gets a one-line repair.
|
|
327
399
|
if (failed)
|
|
328
400
|
return text(`FAILED at step ${failed.index} — ${failed.summary}: ${failed.error ?? "error"}${failed.screen ? `\nOn screen at that moment: “${failed.screen}”` : ""}\nThe camera stopped there (${(take.duration - take.trimBefore).toFixed(0)}s recorded). This is not a demo yet: edit the manifest, dry, run again. Do NOT call done on a failed take.\n\n${summariseTake(take)}`);
|
|
329
|
-
|
|
401
|
+
// The gate: a person's eyes are the only check for whether this is the
|
|
402
|
+
// RIGHT video, and they are cheapest to apply to a draft. Say so here,
|
|
403
|
+
// where the agent is deciding what to do next, rather than after the
|
|
404
|
+
// expensive take has already been spent.
|
|
405
|
+
// Measured, not estimated: this draft just performed the same demo against
|
|
406
|
+
// the same app, so its own capture time is what the full take will cost.
|
|
407
|
+
const took = take.captureSec ?? 0;
|
|
408
|
+
const mins = took >= 120 ? `${Math.round(took / 60)} min` : `${Math.round(took)}s`;
|
|
409
|
+
const gate = preview && took >= EXPENSIVE_TAKE_SECONDS && c.ok
|
|
410
|
+
? `\n\nTHIS DRAFT TOOK ${mins} TO RECORD, and the full take costs about the same again. Do not run it at full quality yet. Show this draft to the person — \`ask\` them, say how long it is and what it shows, and point them at the Retake window. Their answer is the one thing no check here can give you: whether this is the right video. If they cannot be reached, stop and say what is ready and what the full take would cost; do not spend it on their behalf.`
|
|
411
|
+
: "";
|
|
412
|
+
return text(`${summariseTake(take)}\nvideo: ${a.mp4 ?? "none"}\ncheck: ${c.ok ? "pass" : "FAIL"}\n${c.lines.filter((l) => l.startsWith("FAIL")).join("\n")}${gate}`);
|
|
330
413
|
}
|
|
331
414
|
finally {
|
|
332
415
|
releaseLock(outDir);
|
|
333
416
|
}
|
|
334
417
|
});
|
|
335
|
-
server.registerTool("render", { description: "Re-render the last recording
|
|
418
|
+
server.registerTool("render", { description: "Re-render the last recording with the current manifest. No browser, seconds. THIS, NOT `run`, IS THE ANSWER TO ALMOST EVERY CHANGE: size and shape and format, a caption's words, speed, zoom, cards, music, the poster, and WHERE a caption sits (`nudge: <ms>` on the scene moves its marker, and the still and thumbnail follow). Only a change to what the demo DOES needs the camera again — and even then, `run` with `from`/`until` records one end of it rather than all of it.", inputSchema: { name: z.string() }, annotations: RETAKE_WRITE }, async ({ name }) => {
|
|
336
419
|
LAST_DEMO = name;
|
|
337
420
|
const outDir = path.join(OUT, name);
|
|
338
421
|
const tp = path.join(outDir, "take.json");
|
|
@@ -409,4 +492,19 @@ server.registerTool("done", { description: "Call when the demo is recorded and a
|
|
|
409
492
|
return text("ok");
|
|
410
493
|
});
|
|
411
494
|
const transport = new StdioServerTransport();
|
|
495
|
+
// Leave when the client does. Without this the server sat on a closed stdin
|
|
496
|
+
// forever — a day of agent sessions left a row of them running, each holding a
|
|
497
|
+
// Node runtime for a client that had long since exited.
|
|
498
|
+
let leaving = false;
|
|
499
|
+
const leave = () => {
|
|
500
|
+
if (leaving)
|
|
501
|
+
return;
|
|
502
|
+
leaving = true;
|
|
503
|
+
void server.close().catch(() => { }).finally(() => process.exit(0));
|
|
504
|
+
};
|
|
505
|
+
transport.onclose = leave;
|
|
506
|
+
process.stdin.once("end", leave);
|
|
507
|
+
process.stdin.once("close", leave);
|
|
508
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"])
|
|
509
|
+
process.once(sig, leave);
|
|
412
510
|
await server.connect(transport);
|
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
|
@@ -34,26 +34,26 @@ export const PRESETS = {
|
|
|
34
34
|
gif: false, layout: "band", cameraZoom: 1.25,
|
|
35
35
|
cursorSize: 72, captionFontSize: 46, bandHeight: 150,
|
|
36
36
|
encoder: "x264", master: true,
|
|
37
|
-
description: "
|
|
37
|
+
description: "A true 1920×1080, 30fps, CRF 17. The general-purpose demo. GIF on request.",
|
|
38
38
|
},
|
|
39
39
|
"post-square": {
|
|
40
40
|
name: "post-square",
|
|
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: 1080, 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,
|
|
47
47
|
encoder: "x264", master: true,
|
|
48
|
-
description: "
|
|
48
|
+
description: "1080×1080 for feeds. Page 1080×960 with a 120px caption band.",
|
|
49
49
|
},
|
|
50
50
|
"post-vertical": {
|
|
51
51
|
name: "post-vertical",
|
|
52
|
-
width: 1080, height:
|
|
52
|
+
width: 1080, height: 1920, 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,
|
|
55
55
|
encoder: "x264", master: true,
|
|
56
|
-
description: "
|
|
56
|
+
description: "1080×1920 for shorts and reels. Narrow-column apps suit it best.",
|
|
57
57
|
},
|
|
58
58
|
"docs-gif": {
|
|
59
59
|
name: "docs-gif",
|