retake-demos 0.2.2 → 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 +58 -20
- 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 +111 -6
- 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 +36 -4
- package/dist/render.js +179 -48
- package/dist/ui/chat.html +82 -3
- package/dist/ui/guide.html +2 -1
- 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 +6 -3
- package/site/index.html +141 -39
- package/site/logos/avexjets.png +0 -0
- package/skill/SKILL.md +124 -6
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
|
@@ -191,6 +191,10 @@ export async function record(m, opts) {
|
|
|
191
191
|
const stateFresh = !!statePath && fs.existsSync(statePath) && (Date.now() - fs.statSync(statePath).mtimeMs) / 3.6e6 < (m.auth?.maxAgeHours ?? 72);
|
|
192
192
|
if (statePath)
|
|
193
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`);
|
|
194
198
|
const context = await browser.newContext({
|
|
195
199
|
viewport: size,
|
|
196
200
|
colorScheme: m.colorScheme,
|
|
@@ -332,6 +336,10 @@ export async function record(m, opts) {
|
|
|
332
336
|
if (statePath && !stateFresh && authOk) {
|
|
333
337
|
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
334
338
|
await context.storageState({ path: statePath });
|
|
339
|
+
try {
|
|
340
|
+
fs.chmodSync(statePath, 0o600);
|
|
341
|
+
}
|
|
342
|
+
catch { /* windows */ }
|
|
335
343
|
log(`auth: saved session → ${path.relative(process.cwd(), statePath)}`);
|
|
336
344
|
}
|
|
337
345
|
else if (statePath && !stateFresh) {
|
|
@@ -366,6 +374,15 @@ export async function record(m, opts) {
|
|
|
366
374
|
end: 0,
|
|
367
375
|
ok: true,
|
|
368
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
|
+
}
|
|
369
386
|
if (step.action === "scene") {
|
|
370
387
|
entry.label = step.label;
|
|
371
388
|
entry.caption = step.caption;
|
|
@@ -481,10 +498,18 @@ export async function record(m, opts) {
|
|
|
481
498
|
// calibrated to within ~0.2s of the first video frame on a warm route). The
|
|
482
499
|
// real file length is authoritative for the end.
|
|
483
500
|
const trimBefore = sec(setupEnd - t0);
|
|
484
|
-
|
|
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);
|
|
485
507
|
const last = timeline.at(-1);
|
|
486
|
-
|
|
487
|
-
|
|
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`);
|
|
488
513
|
}
|
|
489
514
|
const finishedAt = new Date().toISOString();
|
|
490
515
|
const take = {
|
|
@@ -615,7 +640,7 @@ async function runStep(rec, page, step, m, ctx) {
|
|
|
615
640
|
await rec.click(step.selector, { timeout, zoom: step.zoom });
|
|
616
641
|
break;
|
|
617
642
|
case "type":
|
|
618
|
-
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 });
|
|
619
644
|
break;
|
|
620
645
|
case "fill":
|
|
621
646
|
await rec.fill(step.selector, expandEnv(step.text), { timeout });
|
|
@@ -695,6 +720,11 @@ async function runStep(rec, page, step, m, ctx) {
|
|
|
695
720
|
case "stub":
|
|
696
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" });
|
|
697
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;
|
|
698
728
|
case "scene":
|
|
699
729
|
// A marker only. Its timestamp is the point of the step.
|
|
700
730
|
break;
|
|
@@ -743,6 +773,8 @@ export function describe(step) {
|
|
|
743
773
|
return `evaluate (${step.script.length} chars)`;
|
|
744
774
|
case "stub":
|
|
745
775
|
return `stub ${step.method ? step.method + " " : ""}${step.url}`;
|
|
776
|
+
case "callout":
|
|
777
|
+
return `callout ${step.selector ?? pointName(step.at)}${step.label ? ` “${step.label}”` : ""}`;
|
|
746
778
|
case "scene":
|
|
747
779
|
return `scene: ${step.label}`;
|
|
748
780
|
}
|
package/dist/render.js
CHANGED
|
@@ -18,9 +18,13 @@ import path from "node:path";
|
|
|
18
18
|
import { createHash } from "node:crypto";
|
|
19
19
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
20
20
|
import { capSecondsFor } from "./record.js";
|
|
21
|
+
import { bandHeightFor, maxCharsFor, wrap } from "./captions.js";
|
|
22
|
+
import { renderCard, renderCalloutOverlay, renderTitledCover } from "./cards.js";
|
|
23
|
+
import { describeIdle, planIdle, warpFilterArgs, warpTake } from "./pace.js";
|
|
24
|
+
import { DEFAULT_VOICE, audioSeconds, synthesize } from "./voice.js";
|
|
21
25
|
import { createRequire } from "node:module";
|
|
22
26
|
import { chromium } from "playwright";
|
|
23
|
-
import { resolve } from "./manifest.js";
|
|
27
|
+
import { expandEnv, resolve } from "./manifest.js";
|
|
24
28
|
/** What the render depends on: the raw take + everything in the manifest that
|
|
25
29
|
shapes the picture. Same hash → same output → skip. */
|
|
26
30
|
export function renderHash(m, take) {
|
|
@@ -28,7 +32,14 @@ export function renderHash(m, take) {
|
|
|
28
32
|
const src = take.video && fs.existsSync(take.video) ? fs.statSync(take.video) : null;
|
|
29
33
|
const h = createHash("sha1");
|
|
30
34
|
const merged = applyManifest(m, take);
|
|
31
|
-
h.update(JSON.stringify({ q, tempo: m.tempo, cap: m.captions, theme: m.theme, camera: m.camera,
|
|
35
|
+
h.update(JSON.stringify({ q, tempo: m.tempo, cap: m.captions, theme: m.theme, camera: m.camera, cards: [m.intro, m.outro], idle: m.compressIdle, vo: m.voiceover, music: m.music ? [m.music, (() => { try {
|
|
36
|
+
const f = typeof m.music === "string" ? m.music : m.music.file;
|
|
37
|
+
const st = fs.statSync(path.resolve(f));
|
|
38
|
+
return [st.size, Math.round(st.mtimeMs)];
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
} })()] : null, gif: m.outputs.gif, thumb: m.outputs.thumbnail, stills: m.outputs.stills, video: src ? [src.size, Math.round(src.mtimeMs)] : null, tl: merged.timeline.map((t) => [t.start, t.end, t.label, t.caption, t.holdMs, t.camera, t.callout]), trim: [merged.trimBefore, merged.duration] }));
|
|
32
43
|
return h.digest("hex").slice(0, 12);
|
|
33
44
|
}
|
|
34
45
|
const require = createRequire(import.meta.url);
|
|
@@ -79,38 +90,6 @@ export function probe(file) {
|
|
|
79
90
|
};
|
|
80
91
|
}
|
|
81
92
|
export const videoDuration = (file) => probe(file).duration;
|
|
82
|
-
/** Word-wrap long captions; two-line captions break near the middle so no
|
|
83
|
-
word is left orphaned on the second line. */
|
|
84
|
-
function wrap(text, max) {
|
|
85
|
-
if (text.length <= max)
|
|
86
|
-
return text;
|
|
87
|
-
const words = text.split(/\s+/);
|
|
88
|
-
if (text.length <= max * 2) {
|
|
89
|
-
let best = 1, bestDiff = Infinity;
|
|
90
|
-
for (let i = 1; i < words.length; i++) {
|
|
91
|
-
const a = words.slice(0, i).join(" ").length;
|
|
92
|
-
const b = words.slice(i).join(" ").length;
|
|
93
|
-
if (a <= max && b <= max && Math.abs(a - b) < bestDiff) {
|
|
94
|
-
best = i;
|
|
95
|
-
bestDiff = Math.abs(a - b);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
return words.slice(0, best).join(" ") + "\n" + words.slice(best).join(" ");
|
|
99
|
-
}
|
|
100
|
-
const out = [];
|
|
101
|
-
let line = "";
|
|
102
|
-
for (const w of words) {
|
|
103
|
-
if (line && (line + " " + w).length > max) {
|
|
104
|
-
out.push(line);
|
|
105
|
-
line = w;
|
|
106
|
-
}
|
|
107
|
-
else
|
|
108
|
-
line = line ? line + " " + w : w;
|
|
109
|
-
}
|
|
110
|
-
if (line)
|
|
111
|
-
out.push(line);
|
|
112
|
-
return out.join("\n");
|
|
113
|
-
}
|
|
114
93
|
/** ffmpeg drawtext needs a few characters escaped inside the filter graph. */
|
|
115
94
|
function esc(s) {
|
|
116
95
|
return s.replace(/\\/g, "\\\\").replace(/'/g, "\u2019").replace(/:/g, "\\:").replace(/%/g, "\\%").replace(/,/g, "\\,");
|
|
@@ -296,6 +275,21 @@ export async function render(m, take, outDir, opts = {}) {
|
|
|
296
275
|
}
|
|
297
276
|
// Captions, holds, camera zooms and trim come from the manifest as it is now.
|
|
298
277
|
take = applyManifest(m, take);
|
|
278
|
+
// Idle compression: re-cut the source so the app's dead waits are shown
|
|
279
|
+
// short, and remap every clock in the take through the same warp.
|
|
280
|
+
let idleLines = [];
|
|
281
|
+
if (m.compressIdle && take.video) {
|
|
282
|
+
const keep = m.compressIdle === true ? 1.5 : m.compressIdle.keepSeconds;
|
|
283
|
+
const segs = planIdle(take, keep);
|
|
284
|
+
if (segs.length) {
|
|
285
|
+
const paced = path.join(outDir, ".paced.mp4");
|
|
286
|
+
ff([...warpFilterArgs(take.video, segs, take.duration, paced).slice(0, -1), "-r", String(resolve(m).fps), "-c:v", "libx264", "-crf", "14", "-preset", "veryfast", "-pix_fmt", "yuv420p", paced], log);
|
|
287
|
+
idleLines = describeIdle(take, segs);
|
|
288
|
+
for (const l of idleLines)
|
|
289
|
+
log?.(`idle: ${l}`);
|
|
290
|
+
take = { ...warpTake(take, segs), video: paced };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
299
293
|
// Tempo: the source is cut in its own time (-ss/-t below), then every
|
|
300
294
|
// clock downstream — captions, camera keys, stills — runs in output time.
|
|
301
295
|
const srcTake = take;
|
|
@@ -316,7 +310,7 @@ export async function render(m, take, outDir, opts = {}) {
|
|
|
316
310
|
const to = sc[i + 1] ? sc[i + 1].start : take.duration;
|
|
317
311
|
take = { ...take, trimBefore: from, duration: to };
|
|
318
312
|
}
|
|
319
|
-
const src = path.resolve(srcVideo);
|
|
313
|
+
const src = path.resolve(take.video ?? srcVideo); // take.video may be the idle-warped re-cut
|
|
320
314
|
// Source (recorded viewport) vs canvas (preset output). They differ when a
|
|
321
315
|
// narrow app was recorded at its own aspect and gets centred on the canvas.
|
|
322
316
|
const SW = take.quality?.width ?? q.viewport.width;
|
|
@@ -344,13 +338,17 @@ export async function render(m, take, outDir, opts = {}) {
|
|
|
344
338
|
let frameInput = null;
|
|
345
339
|
let geom = null;
|
|
346
340
|
const capSize = q.captions ? q.captions.fontSize : 0;
|
|
341
|
+
// One band height for the whole take, from its captions (see captions.ts).
|
|
342
|
+
const bandH = bandHeightFor(q, W, captionWindows(take).map((w) => w.text), H);
|
|
347
343
|
if (layout === "band") {
|
|
348
|
-
// Site fills the frame; caption strip below it.
|
|
349
|
-
|
|
350
|
-
|
|
344
|
+
// Site fills the frame; caption strip below it — none at all if captions are off.
|
|
345
|
+
if (bandH) {
|
|
346
|
+
filters.push(`pad=${W}:${H + bandH}:0:0:color=${bg}`);
|
|
347
|
+
capY = `${H}+${bandH / 2}-th/2`;
|
|
348
|
+
}
|
|
351
349
|
}
|
|
352
350
|
else if (layout === "card") {
|
|
353
|
-
geom = cardGeometry(W, H, SW, SH,
|
|
351
|
+
geom = cardGeometry(W, H, SW, SH, bandH);
|
|
354
352
|
frameInput = path.join(outDir, ".frame.png");
|
|
355
353
|
await renderCardFrame(frameInput, W, H, geom, bg);
|
|
356
354
|
filters.push(`scale=${geom.vw}:${geom.vh}:flags=lanczos`, `pad=${W}:${H}:${geom.vx}:${geom.vy}:color=${bg}`);
|
|
@@ -364,16 +362,40 @@ export async function render(m, take, outDir, opts = {}) {
|
|
|
364
362
|
filters.push("format=yuv420p");
|
|
365
363
|
const captionFilters = [];
|
|
366
364
|
if (q.captions && layout !== "none") {
|
|
367
|
-
const maxChars =
|
|
365
|
+
const maxChars = maxCharsFor(W, capSize);
|
|
368
366
|
for (const w of captionWindows(take)) {
|
|
369
367
|
captionFilters.push(`drawtext=${font}text='${esc(wrap(w.text, maxChars))}':fontsize=${capSize}:fontcolor=${capColor}${capBox}:line_spacing=${Math.round(capSize * 0.25)}` +
|
|
370
368
|
`:x=(w-tw)/2:y=${capY}:enable='between(t,${w.from.toFixed(2)},${w.to.toFixed(2)})'`);
|
|
371
369
|
}
|
|
372
370
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const
|
|
371
|
+
// Callouts: recorded boxes become animated overlays, applied in SOURCE
|
|
372
|
+
// coordinates (before the camera filter) so a moving camera carries them.
|
|
373
|
+
const callouts = take.timeline.filter((e) => e.callout && e.ok);
|
|
374
|
+
const calloutInputs = [];
|
|
375
|
+
let preCam = "";
|
|
376
|
+
if (callouts.length && !o.scene && !frameInput) {
|
|
377
|
+
const camIdx = cam.filter ? filters.indexOf(cam.filter) : filters.length;
|
|
378
|
+
const before = filters.slice(0, camIdx), after = filters.slice(camIdx);
|
|
379
|
+
let label = "b0";
|
|
380
|
+
let g = `[0:v]${before.join(",")}[${label}]`;
|
|
381
|
+
for (let i = 0; i < callouts.length; i++) {
|
|
382
|
+
const e = callouts[i];
|
|
383
|
+
const from = Math.max(0, e.start - take.trimBefore);
|
|
384
|
+
const webm = await renderCalloutOverlay(ffmpegBin(), outDir, i, e.callout, SW, SH, fps, Math.max(15, Math.round(q.captions ? q.captions.fontSize * 0.55 : 20)), ink);
|
|
385
|
+
calloutInputs.push(webm);
|
|
386
|
+
const inIdx = 1 + (frameInput ? 1 : 0) + i;
|
|
387
|
+
const next = `b${i + 1}`;
|
|
388
|
+
g += `;[${inIdx}:v]setpts=PTS+${from.toFixed(3)}/TB[c${i}];[${label}][c${i}]overlay=0:0:eof_action=pass[${next}]`;
|
|
389
|
+
label = next;
|
|
390
|
+
}
|
|
391
|
+
preCam = `${g};[${label}]${after.length ? after.join(",") : "null"}`;
|
|
392
|
+
}
|
|
393
|
+
const graph = preCam
|
|
394
|
+
? `${preCam}${captionFilters.length ? "," + captionFilters.join(",") : ""}[out]`
|
|
395
|
+
: frameInput
|
|
396
|
+
? `[0:v]${filters.join(",")}[v];[v][1:v]overlay=0:0:format=auto${captionFilters.length ? "," + captionFilters.join(",") : ""}[out]`
|
|
397
|
+
: `[0:v]${[...filters, ...captionFilters].join(",")}[out]`;
|
|
398
|
+
const inputs = ["-ss", srcTake.trimBefore.toFixed(3), "-t", (srcTake.duration - srcTake.trimBefore).toFixed(3), "-i", src, ...(frameInput ? ["-i", frameInput] : []), ...calloutInputs.flatMap((f) => ["-c:v", "libvpx-vp9", "-i", f])];
|
|
377
399
|
const compose = ["-filter_complex", graph, "-map", "[out]", "-r", String(fps), "-an", "-pix_fmt", "yuv420p", "-movflags", "+faststart"];
|
|
378
400
|
const x264 = (crf, preset) => ["-c:v", "libx264", "-crf", String(crf), "-preset", preset];
|
|
379
401
|
// Hardware encode is a macOS thing; elsewhere the "fast" presets are a fast x264.
|
|
@@ -401,6 +423,98 @@ export async function render(m, take, outDir, opts = {}) {
|
|
|
401
423
|
}
|
|
402
424
|
if (frameInput)
|
|
403
425
|
fs.rmSync(frameInput, { force: true });
|
|
426
|
+
for (const f of calloutInputs)
|
|
427
|
+
fs.rmSync(f, { force: true });
|
|
428
|
+
// Title cards: rendered at the finished frame size, spliced on. The intro's
|
|
429
|
+
// settled frame becomes cover.png — the poster.
|
|
430
|
+
let cover;
|
|
431
|
+
if ((m.intro || m.outro) && !o.scene) {
|
|
432
|
+
const dims = probe(mp4);
|
|
433
|
+
const segs = [];
|
|
434
|
+
if (m.intro) {
|
|
435
|
+
const c = await renderCard(ffmpegBin(), outDir, "intro", m.intro, dims.width, dims.height, fps, q.theme);
|
|
436
|
+
segs.push(c.mp4);
|
|
437
|
+
cover = c.cover;
|
|
438
|
+
}
|
|
439
|
+
segs.push(mp4);
|
|
440
|
+
if (m.outro)
|
|
441
|
+
segs.push((await renderCard(ffmpegBin(), outDir, "outro", m.outro, dims.width, dims.height, fps, q.theme)).mp4);
|
|
442
|
+
if (segs.length > 1) {
|
|
443
|
+
const joined = path.join(outDir, ".joined.mp4");
|
|
444
|
+
ff([...segs.flatMap((f) => ["-i", f]), "-filter_complex", `${segs.map((_, i) => `[${i}:v]`).join("")}concat=n=${segs.length}:v=1:a=0[out]`, "-map", "[out]", "-r", String(fps), "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-an", ...x264(q.crf, "medium"), joined], log);
|
|
445
|
+
fs.renameSync(joined, mp4);
|
|
446
|
+
for (const f of segs)
|
|
447
|
+
if (f !== mp4)
|
|
448
|
+
fs.rmSync(f, { force: true });
|
|
449
|
+
}
|
|
450
|
+
mark("cards");
|
|
451
|
+
}
|
|
452
|
+
// Audio: the music bed and/or the captions read aloud, mixed in one pass.
|
|
453
|
+
// The deliverable gets it; the master stays a clean video-only source.
|
|
454
|
+
if ((m.music || m.voiceover) && !o.scene) {
|
|
455
|
+
const dur = probe(mp4).duration;
|
|
456
|
+
// Cards shift every scene: narration is placed in FINAL time.
|
|
457
|
+
const introShift = m.intro ? m.intro.ms / 1000 : 0;
|
|
458
|
+
const audioIn = [];
|
|
459
|
+
const chains = [];
|
|
460
|
+
const mixIns = [];
|
|
461
|
+
let idx = 1; // 0 is the video
|
|
462
|
+
if (m.music) {
|
|
463
|
+
const spec = typeof m.music === "string" ? { file: m.music, gainDb: -14, fadeOutMs: 1800 } : m.music;
|
|
464
|
+
const musicFile = path.resolve(expandEnv(spec.file));
|
|
465
|
+
if (!fs.existsSync(musicFile))
|
|
466
|
+
throw new Error(`music: no such file ${musicFile} — the track ships inside the video, so it must be one the person may use (CC0 or licensed)`);
|
|
467
|
+
const fadeStart = Math.max(0, dur - spec.fadeOutMs / 1000);
|
|
468
|
+
// Ducked a step further when a voice speaks over it.
|
|
469
|
+
const gain = spec.gainDb - (m.voiceover ? 7 : 0);
|
|
470
|
+
audioIn.push("-stream_loop", "-1", "-i", musicFile);
|
|
471
|
+
chains.push(`[${idx}:a]volume=${gain}dB,afade=t=in:st=0:d=0.6,afade=t=out:st=${fadeStart.toFixed(2)}:d=${(spec.fadeOutMs / 1000).toFixed(2)}[mus]`);
|
|
472
|
+
mixIns.push("[mus]");
|
|
473
|
+
idx++;
|
|
474
|
+
}
|
|
475
|
+
if (m.voiceover) {
|
|
476
|
+
const spec = m.voiceover === true ? { voice: DEFAULT_VOICE, gainDb: 0, script: undefined, fragments: true } : m.voiceover;
|
|
477
|
+
if (spec.script) {
|
|
478
|
+
// One script, one performance: prosody carries across the whole
|
|
479
|
+
// video the way a person reads. Starts after the title card.
|
|
480
|
+
const clip = path.join(outDir, ".vo-0.mp3");
|
|
481
|
+
synthesize(spec.script, spec.voice, clip);
|
|
482
|
+
const spoken = audioSeconds(ffmpegBin(), clip);
|
|
483
|
+
if (spoken > dur - introShift + 0.5)
|
|
484
|
+
log?.(`voiceover: the script reads ${spoken.toFixed(1)}s but there are ${(dur - introShift).toFixed(1)}s of video after the title card — cut the script or lengthen the scenes, then re-render`);
|
|
485
|
+
audioIn.push("-i", clip);
|
|
486
|
+
chains.push(`[${idx}:a]volume=${spec.gainDb}dB,adelay=${Math.round(introShift * 1000)}:all=1[vo0]`);
|
|
487
|
+
mixIns.push("[vo0]");
|
|
488
|
+
idx++;
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
// Explicitly-asked-for fragments: one clip per caption. Known to
|
|
492
|
+
// reset prosody at every line; the schema refuses it by default.
|
|
493
|
+
const wins = captionWindows(take);
|
|
494
|
+
for (let i = 0; i < wins.length; i++) {
|
|
495
|
+
const w = wins[i];
|
|
496
|
+
const clip = path.join(outDir, `.vo-${i}.mp3`);
|
|
497
|
+
synthesize(w.text, spec.voice, clip);
|
|
498
|
+
const spoken = audioSeconds(ffmpegBin(), clip);
|
|
499
|
+
const at = w.from + introShift;
|
|
500
|
+
if (spoken > w.to - w.from + 0.4)
|
|
501
|
+
log?.(`voiceover: “${w.text.slice(0, 40)}…” runs ${spoken.toFixed(1)}s but its scene holds ${(w.to - w.from).toFixed(1)}s — lengthen the scene's holdMs or trailing wait and re-render`);
|
|
502
|
+
audioIn.push("-i", clip);
|
|
503
|
+
chains.push(`[${idx}:a]volume=${spec.gainDb}dB,adelay=${Math.round(at * 1000)}:all=1[vo${i}]`);
|
|
504
|
+
mixIns.push(`[vo${i}]`);
|
|
505
|
+
idx++;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const mixed = path.join(outDir, ".with-audio.mp4");
|
|
510
|
+
const graph = `${chains.join(";")};${mixIns.join("")}amix=inputs=${mixIns.length}:duration=longest:normalize=0,atrim=0:${dur.toFixed(2)}[a]`;
|
|
511
|
+
ff(["-i", mp4, ...audioIn, "-filter_complex", graph, "-map", "0:v", "-map", "[a]", "-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-movflags", "+faststart", "-shortest", mixed], log);
|
|
512
|
+
fs.renameSync(mixed, mp4);
|
|
513
|
+
for (const f of fs.readdirSync(outDir))
|
|
514
|
+
if (/^\.vo-\d+\.mp3$/.test(f))
|
|
515
|
+
fs.rmSync(path.join(outDir, f), { force: true });
|
|
516
|
+
mark("audio");
|
|
517
|
+
}
|
|
404
518
|
if (o.scene)
|
|
405
519
|
return { mp4, proofLog };
|
|
406
520
|
// --- 3. GIF (secondary, opt-in) --------------------------------------------
|
|
@@ -429,9 +543,19 @@ export async function render(m, take, outDir, opts = {}) {
|
|
|
429
543
|
}
|
|
430
544
|
mark("gif");
|
|
431
545
|
}
|
|
546
|
+
// Cards are spliced onto the front of the deliverable, so every timestamp
|
|
547
|
+
// in the take is that much later in the file frames are pulled from.
|
|
548
|
+
const cardShift = !o.scene && m.intro ? m.intro.ms / 1000 : 0;
|
|
432
549
|
// --- 4. thumbnail ----------------------------------------------------------
|
|
550
|
+
// A person's chosen poster (the window's "Use this frame" / a still / an
|
|
551
|
+
// upload) outlives re-renders: the choice writes a .poster marker, and the
|
|
552
|
+
// scene-based default never overwrites a deliberate choice.
|
|
433
553
|
let thumbnail;
|
|
434
|
-
if (m.outputs.thumbnail) {
|
|
554
|
+
if (m.outputs.thumbnail && fs.existsSync(path.join(outDir, ".poster")) && fs.existsSync(path.join(outDir, "thumbnail.png"))) {
|
|
555
|
+
thumbnail = path.join(outDir, "thumbnail.png");
|
|
556
|
+
log?.("thumbnail: keeping the poster you chose (delete .poster to go back to the scene default)");
|
|
557
|
+
}
|
|
558
|
+
else if (m.outputs.thumbnail) {
|
|
435
559
|
const sc = scenes(take);
|
|
436
560
|
let at;
|
|
437
561
|
if (typeof m.outputs.thumbnail === "object") {
|
|
@@ -449,7 +573,14 @@ export async function render(m, take, outDir, opts = {}) {
|
|
|
449
573
|
at = last ? last.start - take.trimBefore + 1.0 : Math.max(0, take.duration - take.trimBefore - 0.5);
|
|
450
574
|
}
|
|
451
575
|
thumbnail = path.join(outDir, "thumbnail.png");
|
|
452
|
-
ff(["-ss", Math.max(0, at).toFixed(2), "-i", mp4, "-frames:v", "1", thumbnail], log);
|
|
576
|
+
ff(["-ss", Math.max(0, at + cardShift).toFixed(2), "-i", mp4, "-frames:v", "1", thumbnail], log);
|
|
577
|
+
// A launch cut gets a second candidate: this frame with the title on it.
|
|
578
|
+
// The person picks between them (or any still, or their own file) in the
|
|
579
|
+
// window; both are just PNGs, and the poster is whichever is thumbnail.png.
|
|
580
|
+
if (m.mode === "launch" && m.intro) {
|
|
581
|
+
const d = probe(mp4);
|
|
582
|
+
await renderTitledCover(ffmpegBin(), thumbnail, path.join(outDir, "cover-titled.png"), m.intro, d.width, d.height, q.theme);
|
|
583
|
+
}
|
|
453
584
|
mark("thumbnail");
|
|
454
585
|
}
|
|
455
586
|
// --- 5. scene stills -------------------------------------------------------
|
|
@@ -470,14 +601,14 @@ export async function render(m, take, outDir, opts = {}) {
|
|
|
470
601
|
const at = Math.min(Math.max(0, from + Math.max(0.8, (next - from) / 2)), Math.max(0, end - 0.2));
|
|
471
602
|
const base = `${String(i + 1).padStart(2, "0")}-${(sce.label || "scene").replace(/[^a-z0-9-]+/gi, "-")}`;
|
|
472
603
|
const file = path.join(dir, `${base}.png`);
|
|
473
|
-
ff(["-ss", at.toFixed(2), "-i", mp4, "-frames:v", "1", file], log);
|
|
604
|
+
ff(["-ss", (at + cardShift).toFixed(2), "-i", mp4, "-frames:v", "1", file], log);
|
|
474
605
|
stills.push(file);
|
|
475
606
|
// And the scene's last moment: the caption names what the scene
|
|
476
607
|
// achieves, and that is usually on screen at the end, not the middle.
|
|
477
608
|
const atEnd = Math.min(Math.max(from + 0.4, next - 0.6), Math.max(0, end - 0.2));
|
|
478
609
|
if (atEnd - at > 0.5) {
|
|
479
610
|
const fe = path.join(dir, `${base}-end.png`);
|
|
480
|
-
ff(["-ss", atEnd.toFixed(2), "-i", mp4, "-frames:v", "1", fe], log);
|
|
611
|
+
ff(["-ss", (atEnd + cardShift).toFixed(2), "-i", mp4, "-frames:v", "1", fe], log);
|
|
481
612
|
stills.push(fe);
|
|
482
613
|
}
|
|
483
614
|
}
|