demobite 1.7.1 → 1.7.2
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/package.json +1 -1
- package/scripts/trim.mjs +9 -5
- package/skill/SKILL.md +5 -4
- package/skill/scripts/briefs.mjs +4 -4
- package/skill/scripts/camera.mjs +350 -0
- package/skill/scripts/cursors.mjs +7 -0
- package/skill/scripts/finish.mjs +355 -0
package/package.json
CHANGED
package/scripts/trim.mjs
CHANGED
|
@@ -152,12 +152,16 @@ const dur = execFileSync(FFPROBE(), [
|
|
|
152
152
|
"-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", clean,
|
|
153
153
|
]).toString().trim();
|
|
154
154
|
const duration = parseFloat(dur);
|
|
155
|
-
// LAW (founder
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
155
|
+
// LAW (founder 2026-10-10: 3 minutes, was 90 s since 2026-08-09): a Bite is
|
|
156
|
+
// capped at 180 seconds by product concept — a LIMIT, not a target; the
|
|
157
|
+
// sweet spot stays 30 to 45 s. A take that cuts longer than that must not
|
|
158
|
+
// proceed to staging — the story needs a SPLIT, not a trim. Fail loudly here,
|
|
159
|
+
// before any upload machinery runs. The server refuses the same number
|
|
160
|
+
// (MAX_BITE_SECONDS; GET /api/recorder/limits answers it).
|
|
161
|
+
const MAX_BITE_SECONDS = 180;
|
|
162
|
+
if (duration > MAX_BITE_SECONDS) {
|
|
159
163
|
console.error(
|
|
160
|
-
`HARD CAP: this take cuts to ${duration.toFixed(1)}s — a Bite is limited to
|
|
164
|
+
`HARD CAP: this take cuts to ${duration.toFixed(1)}s — a Bite is limited to 3 minutes. ` +
|
|
161
165
|
"Do not upload. Split the scenario into multiple Bites and refilm."
|
|
162
166
|
);
|
|
163
167
|
process.exit(1);
|
package/skill/SKILL.md
CHANGED
|
@@ -144,9 +144,10 @@ Holding shots to cover estimated lines is what produced a 60 second take with th
|
|
|
144
144
|
**Budgets, hold yourself to them:**
|
|
145
145
|
|
|
146
146
|
- **30 to 45 seconds total (35 to 45 when the story crosses pages).** Over 45 is a rewrite, not a trim; under 30 with two pages is rushing, see the linger law below.
|
|
147
|
-
- **
|
|
148
|
-
target
|
|
149
|
-
|
|
147
|
+
- **3 minutes (180 s) is a HARD CAP, by product concept, and it is a limit,
|
|
148
|
+
not a target** (founder 2026-10-10; it was 90 s before). A Bite over 3
|
|
149
|
+
minutes does not exist. Budget the beats BEFORE filming: if the human's
|
|
150
|
+
scenario cannot honestly fit inside 3 minutes, do
|
|
150
151
|
not film-and-trim and do not compress it into uselessness — tell them up
|
|
151
152
|
front and ask them to SPLIT it into multiple Bites, one story per Bite.
|
|
152
153
|
State the estimated duration in every storyboard presentation.
|
|
@@ -363,7 +364,7 @@ Laws for a re-take:
|
|
|
363
364
|
|
|
364
365
|
## Batch of briefs (GitHub PR → demos)
|
|
365
366
|
|
|
366
|
-
The human pastes a bundle of approved briefs into the chat: a header (batchId, workspaceId, the target URL, the
|
|
367
|
+
The human pastes a bundle of approved briefs into the chat: a header (batchId, workspaceId, the target URL, the 3-minute rule) and one block per brief (briefId, revision, contentHash, title, audience, outcome, flowIntent). Up to five briefs. The pasted text is a copy; the server holds the truth.
|
|
367
368
|
|
|
368
369
|
```bash
|
|
369
370
|
node scripts/briefs.mjs list <batchId> [--paste bundle.txt] # the approved briefs; warns when the paste drifted
|
package/skill/scripts/briefs.mjs
CHANGED
|
@@ -96,7 +96,7 @@ function printBatch(data) {
|
|
|
96
96
|
const { batch, briefs } = data;
|
|
97
97
|
const src = batch?.source ? `${batch.source.repo}#${batch.source.prNumber}` : "";
|
|
98
98
|
console.log(`Batch ${batch?.id ?? "?"} ${src} target ${batch?.target?.url ?? "?"}${batch?.target?.environment ? ` (${batch.target.environment})` : ""}`);
|
|
99
|
-
console.log(`${briefs.length} brief${briefs.length === 1 ? "" : "s"}, each at most
|
|
99
|
+
console.log(`${briefs.length} brief${briefs.length === 1 ? "" : "s"}, each at most 180 seconds (3 minutes). One storyboard approval per brief.`);
|
|
100
100
|
for (const b of briefs) {
|
|
101
101
|
const att = b.attempt ? ` · attempt ${b.attempt.ref} (${b.attempt.state})` : "";
|
|
102
102
|
console.log(`\n ${b.briefId} r${b.revision} ${b.status}${att}\n ${b.title}\n audience: ${b.audience}\n outcome: ${b.outcome}${b.estimatedDurationSec ? `\n about ${b.estimatedDurationSec}s` : ""}`);
|
|
@@ -138,7 +138,7 @@ function printRadar(data, slug) {
|
|
|
138
138
|
// A radar batch has no target address: the agent films where the code
|
|
139
139
|
// already runs (the Record stage says so); print the target only when set.
|
|
140
140
|
console.log(`Batch ${batch?.id ?? "?"}${batch?.target?.url ? ` target ${batch.target.url}${batch.target.environment ? ` (${batch.target.environment})` : ""}` : ""}`);
|
|
141
|
-
console.log(`${briefs.length} brief${briefs.length === 1 ? "" : "s"}, each at most ${batch?.rules?.maxSeconds ??
|
|
141
|
+
console.log(`${briefs.length} brief${briefs.length === 1 ? "" : "s"}, each at most ${batch?.rules?.maxSeconds ?? 180} seconds, in this order:\n`);
|
|
142
142
|
const questions = [];
|
|
143
143
|
order.forEach((b, i) => {
|
|
144
144
|
const pos = String(i + 1).padStart(2, " ");
|
|
@@ -178,7 +178,7 @@ function writeRadarBundle(slug, data) {
|
|
|
178
178
|
* The limits are the server's (a refine that breaks one answers 400
|
|
179
179
|
* invalid_refine): title ≤ 80, audience ≤ 60, outcome ≤ 200, flowIntent 2..8
|
|
180
180
|
* lines ≤ 120 each, prerequisites/exclusions ≤ 6 lines ≤ 160 each,
|
|
181
|
-
* estimatedDurationSec 15..
|
|
181
|
+
* estimatedDurationSec 15..180 (3 minutes, founder 2026-10-10). They are checked here first, with the field
|
|
182
182
|
* named, so the agent trims before posting instead of reading a 400. */
|
|
183
183
|
const CONTENT_LIMITS = { title: 80, audience: 60, outcome: 200 };
|
|
184
184
|
const LIST_LIMITS = { flowIntent: { min: 2, max: 8, line: 120 }, prerequisites: { min: 0, max: 6, line: 160 }, exclusions: { min: 0, max: 6, line: 160 } };
|
|
@@ -206,7 +206,7 @@ function refineProblems(c) {
|
|
|
206
206
|
v.forEach((line, i) => { if (typeof line !== "string" || !line.trim()) out.push(`${k}[${i}]: an empty line`); else if (line.trim().length > lim.line) out.push(`${k}[${i}]: ${line.trim().length} characters, the limit is ${lim.line}`); });
|
|
207
207
|
}
|
|
208
208
|
const d = c.estimatedDurationSec;
|
|
209
|
-
if (!Number.isInteger(d) || d < 15 || d >
|
|
209
|
+
if (!Number.isInteger(d) || d < 15 || d > 180) out.push(`estimatedDurationSec: ${JSON.stringify(d)}, must be a whole number of seconds 15..90`);
|
|
210
210
|
return out;
|
|
211
211
|
}
|
|
212
212
|
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// The camera, locally. Every rule the DemoBites cloud uses to turn a take's
|
|
2
|
+
// measured shots into zooms, in one readable file (ported from
|
|
3
|
+
// planCameraPath in the app's recorder-ingest.ts and the export runtime's
|
|
4
|
+
// keyframes.ts / videoMath.ts). Change a constant, re-run finish.mjs, see it.
|
|
5
|
+
//
|
|
6
|
+
// Input: manifest.demobites.json (times in clean.mp4 seconds, pixels in the
|
|
7
|
+
// 1920x1080 take). Output: planShots() -> [{start,end,cx,cy,factor,wide}],
|
|
8
|
+
// and cameraAt(t) -> {cx, cy, factor} for any frame.
|
|
9
|
+
|
|
10
|
+
export const FRAME_W = 1920;
|
|
11
|
+
export const FRAME_H = 1080;
|
|
12
|
+
|
|
13
|
+
// ── How tight a zoom is ──────────────────────────────────────────────────
|
|
14
|
+
// The subject (the button, the field, the chart) should fill this share of
|
|
15
|
+
// the zoomed frame. Tiny targets hit the max; big ones fall to the min.
|
|
16
|
+
export const ZOOM_MIN_FACTOR = 1.25;
|
|
17
|
+
export const ZOOM_MAX_FACTOR = 3.0;
|
|
18
|
+
export const SUBJECT_FILL_W = 0.6;
|
|
19
|
+
export const SUBJECT_FILL_H = 0.75;
|
|
20
|
+
// A box the storyboard drew by hand is the zoom itself, with a little air.
|
|
21
|
+
export const AUTHORED_FILL_W = 0.92;
|
|
22
|
+
export const AUTHORED_FILL_H = 0.9;
|
|
23
|
+
export const AUTHORED_MIN_FACTOR = 1.05;
|
|
24
|
+
|
|
25
|
+
// ── When a zoom is allowed ───────────────────────────────────────────────
|
|
26
|
+
export const ZOOM_MIN_START = 0.5; // never zoom on the very first frames
|
|
27
|
+
export const ZOOM_END_BUFFER = 0.8; // the video always ends wide
|
|
28
|
+
export const ZOOM_MIN_LENGTH = 0.9; // shorter than this is a flicker, dropped
|
|
29
|
+
export const SHOT_OVERLAP_SECONDS = 0.5; // chained shots travel, never pull out
|
|
30
|
+
|
|
31
|
+
// ── The regime ladder (founder laws, 2026-08-09) ─────────────────────────
|
|
32
|
+
// Motion is the cost, tightness is the budget, the union rectangle judges.
|
|
33
|
+
// 1. MERGE: one steady framing that holds two neighbours beats two zooms,
|
|
34
|
+
// if it gives up less than MERGE_TOLERANCE of tightness.
|
|
35
|
+
// 2. PAN: neighbours near each other keep their tightness and the camera
|
|
36
|
+
// travels WITH the cursor's glide.
|
|
37
|
+
// 3. TROMBONE: far apart and both tight: arc out through exactly the union
|
|
38
|
+
// framing (never wider) during the glide, then tighten on arrival.
|
|
39
|
+
export const MERGE_TOLERANCE = 0.75;
|
|
40
|
+
export const MERGE_FLOOR = 1.35;
|
|
41
|
+
export const MERGE_MAX_GAP_SECONDS = 4;
|
|
42
|
+
export const PAN_MAX_TRAVEL = 0.85;
|
|
43
|
+
export const TROMBONE_ANTICIPATION = 0.25;
|
|
44
|
+
export const TROMBONE_SETTLE_LEAD = 0.15;
|
|
45
|
+
|
|
46
|
+
// ── The invariant: the cursor is inside the frame on every frame ─────────
|
|
47
|
+
export const INVARIANT_SLACK_PX = 16;
|
|
48
|
+
export const INVARIANT_WIDEN_STEP = 1.15;
|
|
49
|
+
export const INVARIANT_MAX_PASSES = 4;
|
|
50
|
+
|
|
51
|
+
// ── Motion ───────────────────────────────────────────────────────────────
|
|
52
|
+
// Transition length grows with how far the camera moves: 0.5 s for a nudge,
|
|
53
|
+
// 1.2 s for a full pull-out from max zoom. Every move eases with the
|
|
54
|
+
// material curve bezier(0.4, 0, 0.2, 1).
|
|
55
|
+
export function transitionSeconds(from, to) {
|
|
56
|
+
const scaleMag = Math.abs(to.factor - from.factor) / 2;
|
|
57
|
+
const dx = (remX(to) - remX(from)) / 960;
|
|
58
|
+
const dy = (remY(to) - remY(from)) / 540;
|
|
59
|
+
const posMag = Math.sqrt(dx * dx + dy * dy) / 1.414;
|
|
60
|
+
return 0.5 + Math.min(Math.max(scaleMag, posMag) * 0.5, 0.7);
|
|
61
|
+
}
|
|
62
|
+
// The runtime moves a translate, not a centre: (960 - cx) * factor.
|
|
63
|
+
const remX = (c) => (FRAME_W / 2 - c.cx) * c.factor;
|
|
64
|
+
const remY = (c) => (FRAME_H / 2 - c.cy) * c.factor;
|
|
65
|
+
|
|
66
|
+
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
67
|
+
const round2 = (v) => Math.round(v * 100) / 100;
|
|
68
|
+
|
|
69
|
+
export function factorForSubject(w, h) {
|
|
70
|
+
const raw = Math.min((SUBJECT_FILL_W * FRAME_W) / Math.max(24, w), (SUBJECT_FILL_H * FRAME_H) / Math.max(18, h));
|
|
71
|
+
return round2(clamp(raw, ZOOM_MIN_FACTOR, ZOOM_MAX_FACTOR));
|
|
72
|
+
}
|
|
73
|
+
export function factorForAuthored(w, h) {
|
|
74
|
+
const raw = Math.min((AUTHORED_FILL_W * FRAME_W) / Math.max(24, w), (AUTHORED_FILL_H * FRAME_H) / Math.max(18, h));
|
|
75
|
+
return round2(clamp(raw, AUTHORED_MIN_FACTOR, ZOOM_MAX_FACTOR));
|
|
76
|
+
}
|
|
77
|
+
function transitionFactor(w, h) {
|
|
78
|
+
const raw = Math.min((SUBJECT_FILL_W * FRAME_W) / Math.max(24, w), (SUBJECT_FILL_H * FRAME_H) / Math.max(18, h));
|
|
79
|
+
return round2(clamp(raw, 1.01, ZOOM_MAX_FACTOR));
|
|
80
|
+
}
|
|
81
|
+
// The zoomed viewport never leaves the take: no dead space at an edge.
|
|
82
|
+
export function clampPin(cx, cy, factor) {
|
|
83
|
+
const hw = FRAME_W / (2 * factor), hh = FRAME_H / (2 * factor);
|
|
84
|
+
return { x: clamp(cx, hw, FRAME_W - hw), y: clamp(cy, hh, FRAME_H - hh) };
|
|
85
|
+
}
|
|
86
|
+
function unionRect(a, b) {
|
|
87
|
+
const x1 = Math.min(a.sx, b.sx), y1 = Math.min(a.sy, b.sy);
|
|
88
|
+
const x2 = Math.max(a.sx + a.sw, b.sx + b.sw), y2 = Math.max(a.sy + a.sh, b.sy + b.sh);
|
|
89
|
+
return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };
|
|
90
|
+
}
|
|
91
|
+
function reframe(shot, subj, factor) {
|
|
92
|
+
shot.sx = subj.x; shot.sy = subj.y; shot.sw = subj.w; shot.sh = subj.h;
|
|
93
|
+
shot.factor = factor;
|
|
94
|
+
const pin = clampPin(subj.x + subj.w / 2, subj.y + subj.h / 2, factor);
|
|
95
|
+
shot.cx = pin.x; shot.cy = pin.y;
|
|
96
|
+
}
|
|
97
|
+
// CUT LAW: a navigation cut is a hard camera boundary.
|
|
98
|
+
function crosses(a, b, cuts) { return cuts.some((c) => a < c.end && b > c.start); }
|
|
99
|
+
|
|
100
|
+
export function planShots(manifest, duration, log = () => {}) {
|
|
101
|
+
const lastEnd = duration - ZOOM_END_BUFFER;
|
|
102
|
+
if (lastEnd <= ZOOM_MIN_START) return [];
|
|
103
|
+
const cuts = (manifest.cuts ?? [])
|
|
104
|
+
.map((c) => ({ start: Number(c.t_start), end: Number(c.t_end) }))
|
|
105
|
+
.filter((c) => c.end > c.start);
|
|
106
|
+
|
|
107
|
+
let planned = [];
|
|
108
|
+
for (const s of manifest.camera ?? []) {
|
|
109
|
+
const [t0, t1, x, y, w, h] = [s.t_start, s.t_end, s.x, s.y, s.w, s.h].map(Number);
|
|
110
|
+
if (![t0, t1, x, y, w, h].every(Number.isFinite) || w <= 0 || h <= 0 || t1 <= t0) continue;
|
|
111
|
+
const g = s.glide;
|
|
112
|
+
const glide = g && g.t_end > g.t_start && g.t_end - g.t_start <= 5 && Math.abs(g.t_end - t0) <= 1.5 ? { t_start: g.t_start, t_end: g.t_end } : undefined;
|
|
113
|
+
if (s.wide) {
|
|
114
|
+
// WIDE (founder 2026-09-27): a landing is 1.0, whatever box was measured.
|
|
115
|
+
const start = Math.max(0, t0), end = Math.min(t1, duration);
|
|
116
|
+
if (end - start < ZOOM_MIN_LENGTH * 0.6) continue;
|
|
117
|
+
planned.push({ start, end, cx: FRAME_W / 2, cy: FRAME_H / 2, factor: 1, sx: 0, sy: 0, sw: FRAME_W, sh: FRAME_H, tight: 1, n: s.n, wide: true, label: s.label });
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const factor = s.authored ? factorForAuthored(w, h) : factorForSubject(w, h);
|
|
121
|
+
const pin = clampPin(x + w / 2, y + h / 2, factor);
|
|
122
|
+
const start = Math.max(ZOOM_MIN_START, t0), end = Math.min(t1, lastEnd);
|
|
123
|
+
if (end - start < ZOOM_MIN_LENGTH) continue;
|
|
124
|
+
planned.push({ start, end, cx: pin.x, cy: pin.y, factor, sx: x, sy: y, sw: w, sh: h, tight: factor, n: s.n, revealed: s.revealed === true, glide, authored: s.authored === true, label: s.label });
|
|
125
|
+
}
|
|
126
|
+
planned.sort((a, b) => a.start - b.start);
|
|
127
|
+
if (!planned.length) return [];
|
|
128
|
+
|
|
129
|
+
// Two wides in a row are one wide.
|
|
130
|
+
planned = planned.reduce((out, s) => {
|
|
131
|
+
const last = out[out.length - 1];
|
|
132
|
+
if (last?.wide && s.wide && s.start - last.end <= 1.5 && !crosses(last.start, Math.max(last.end, s.end), cuts)) { last.end = Math.max(last.end, s.end); return out; }
|
|
133
|
+
out.push(s); return out;
|
|
134
|
+
}, []);
|
|
135
|
+
|
|
136
|
+
if (planned.some((s) => s.glide)) {
|
|
137
|
+
// "The button plus what the button did": a revealed shot keeps its control in frame.
|
|
138
|
+
for (const s of planned) {
|
|
139
|
+
if (!s.revealed || s.n == null || s.authored || s.wide) continue;
|
|
140
|
+
const control = planned.find((c) => c.n === s.n && !c.revealed && !c.wide);
|
|
141
|
+
if (!control) continue;
|
|
142
|
+
const u = unionRect(s, control);
|
|
143
|
+
reframe(s, u, factorForSubject(u.w, u.h));
|
|
144
|
+
}
|
|
145
|
+
// 1. MERGE
|
|
146
|
+
const merged = [];
|
|
147
|
+
for (const s of planned) {
|
|
148
|
+
const last = merged[merged.length - 1];
|
|
149
|
+
if (last && !last.authored && !s.authored && !last.wide && !s.wide && s.start - last.end <= MERGE_MAX_GAP_SECONDS && !crosses(last.start, Math.max(last.end, s.end), cuts)) {
|
|
150
|
+
const u = unionRect(last, s);
|
|
151
|
+
const uf = factorForSubject(u.w, u.h);
|
|
152
|
+
const tight = Math.max(last.tight, s.tight);
|
|
153
|
+
if (uf >= tight - MERGE_TOLERANCE && uf >= MERGE_FLOOR) {
|
|
154
|
+
reframe(last, u, uf); last.end = Math.max(last.end, s.end); last.tight = tight;
|
|
155
|
+
last.label = `${last.label} + ${s.label}`;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
merged.push(s);
|
|
160
|
+
}
|
|
161
|
+
// 2/3. PAN or TROMBONE
|
|
162
|
+
const out = [merged[0]];
|
|
163
|
+
for (let i = 1; i < merged.length; i++) {
|
|
164
|
+
const A = out[out.length - 1], B = merged[i];
|
|
165
|
+
if (B.start - A.start < ZOOM_MIN_LENGTH || crosses(A.start, B.start, cuts) || A.wide || B.wide) { out.push(B); continue; }
|
|
166
|
+
const departure = B.glide ? B.glide.t_start : B.start - 1.2;
|
|
167
|
+
const arrival = B.glide ? B.glide.t_end : B.start + 0.3;
|
|
168
|
+
const maxF = Math.max(A.factor, B.factor);
|
|
169
|
+
if (Math.abs(A.cx - B.cx) <= (PAN_MAX_TRAVEL * FRAME_W) / maxF && Math.abs(A.cy - B.cy) <= (PAN_MAX_TRAVEL * FRAME_H) / maxF) {
|
|
170
|
+
B.start = Math.max(A.start + ZOOM_MIN_LENGTH, Math.min(B.start, departure - TROMBONE_SETTLE_LEAD));
|
|
171
|
+
A.end = Math.min(lastEnd, Math.max(A.end, arrival + TROMBONE_SETTLE_LEAD));
|
|
172
|
+
B.how = "pan"; out.push(B); continue;
|
|
173
|
+
}
|
|
174
|
+
const u = unionRect(A, B);
|
|
175
|
+
const uf = transitionFactor(u.w, u.h);
|
|
176
|
+
if (uf < maxF - 0.1) {
|
|
177
|
+
const uStart = Math.max(A.start + ZOOM_MIN_LENGTH, departure - TROMBONE_ANTICIPATION);
|
|
178
|
+
const uEnd = Math.min(lastEnd, Math.max(arrival + TROMBONE_SETTLE_LEAD, uStart + ZOOM_MIN_LENGTH));
|
|
179
|
+
if (uStart < B.start - 0.15 && uEnd - uStart >= ZOOM_MIN_LENGTH * 0.6) {
|
|
180
|
+
const U = { start: uStart, end: uEnd, cx: 0, cy: 0, factor: uf, sx: u.x, sy: u.y, sw: u.w, sh: u.h, tight: uf, label: `${A.label} to ${B.label}`, how: "trombone" };
|
|
181
|
+
reframe(U, u, uf); out.push(U);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
out.push(B);
|
|
185
|
+
}
|
|
186
|
+
planned = out.sort((a, b) => a.start - b.start);
|
|
187
|
+
|
|
188
|
+
// Scroll means breathe out: the camera is wide while the page slides.
|
|
189
|
+
for (const st of manifest.steps ?? []) {
|
|
190
|
+
if (st.action !== "scroll" || !(st.t_end > st.t_start)) continue;
|
|
191
|
+
const w = { start: Math.max(0, st.t_start - 0.3), end: Math.min(lastEnd, Math.min(st.t_end, st.t_start + 2.8) + 0.3) };
|
|
192
|
+
if (w.end - w.start < 0.5) continue;
|
|
193
|
+
for (const s of planned) {
|
|
194
|
+
if (s.factor <= 1.3) continue;
|
|
195
|
+
if (s.start < w.start && s.end > w.start) s.end = w.start;
|
|
196
|
+
if (s.start >= w.start && s.start < w.end) s.start = Math.min(w.end, lastEnd - ZOOM_MIN_LENGTH);
|
|
197
|
+
}
|
|
198
|
+
planned.push({ start: w.start, end: w.end, cx: 960, cy: 540, factor: 1.01, sx: 0, sy: 0, sw: FRAME_W, sh: FRAME_H, tight: 1.01, label: "scroll, wide" });
|
|
199
|
+
}
|
|
200
|
+
planned = planned.filter((s) => s.end - s.start >= ZOOM_MIN_LENGTH * 0.6).sort((a, b) => a.start - b.start);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Continuity: each shot overlaps its successor so the camera travels
|
|
204
|
+
// instead of pulling out between them. A wide is a boundary.
|
|
205
|
+
for (let i = 0; i < planned.length - 1; i++) {
|
|
206
|
+
const cur = planned[i], next = planned[i + 1];
|
|
207
|
+
if (next.start < cur.start + ZOOM_MIN_LENGTH) next.start = cur.start + ZOOM_MIN_LENGTH;
|
|
208
|
+
if (next.end < next.start + ZOOM_MIN_LENGTH) next.end = Math.min(next.start + ZOOM_MIN_LENGTH, lastEnd);
|
|
209
|
+
if (next.wide) {
|
|
210
|
+
if (cur.end < next.start && next.start - cur.end <= 1.0) cur.end = next.start;
|
|
211
|
+
cur.end = Math.min(cur.end, next.start, lastEnd);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (cur.wide) { cur.end = Math.min(cur.end, next.start); continue; }
|
|
215
|
+
if (crosses(cur.start, next.start, cuts)) { continue; }
|
|
216
|
+
cur.end = Math.min(Math.max(cur.end, next.start + SHOT_OVERLAP_SECONDS), lastEnd, next.end);
|
|
217
|
+
}
|
|
218
|
+
let final = planned.filter((s) => s.end - s.start >= ZOOM_MIN_LENGTH * 0.6);
|
|
219
|
+
|
|
220
|
+
// THE INVARIANT: simulate the camera against the real cursor track; any
|
|
221
|
+
// moment the cursor would leave the frame widens the shot there.
|
|
222
|
+
const track = (manifest.interactions?.mouseEvents ?? []).filter((e) => [e.x, e.y, e.time].every(Number.isFinite));
|
|
223
|
+
if (track.length > 1) {
|
|
224
|
+
const zooms = () => final.filter((s) => !s.wide);
|
|
225
|
+
const violations = (slack) => {
|
|
226
|
+
const cam = buildTimeline(zooms(), duration);
|
|
227
|
+
return track.filter((e) => {
|
|
228
|
+
const c = cam(e.time);
|
|
229
|
+
if (c.factor <= 1.001) return false;
|
|
230
|
+
return Math.abs(e.x - c.cx) > FRAME_W / (2 * c.factor) - slack || Math.abs(e.y - c.cy) > FRAME_H / (2 * c.factor) - slack;
|
|
231
|
+
}).map((e) => e.time);
|
|
232
|
+
};
|
|
233
|
+
let v = violations(INVARIANT_SLACK_PX);
|
|
234
|
+
const initial = v.length;
|
|
235
|
+
for (let pass = 0; pass < INVARIANT_MAX_PASSES && v.length; pass++) {
|
|
236
|
+
const widened = new Set();
|
|
237
|
+
for (const t of v) for (const s of zooms()) {
|
|
238
|
+
if (s.start <= t && t <= s.end && !widened.has(s) && s.factor > 1.01) {
|
|
239
|
+
widened.add(s);
|
|
240
|
+
reframe(s, { x: s.sx, y: s.sy, w: s.sw, h: s.sh }, Math.max(1.01, s.factor / INVARIANT_WIDEN_STEP));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (!widened.size) break;
|
|
244
|
+
v = violations(INVARIANT_SLACK_PX);
|
|
245
|
+
}
|
|
246
|
+
log(`camera invariant: ${initial} near-edge samples -> ${v.length} after widening; ${violations(0).length} true cursor losses`);
|
|
247
|
+
}
|
|
248
|
+
return final;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── From shots to a camera at every frame (the export runtime's keyframes) ──
|
|
252
|
+
// Each zoom: travel from wherever the camera is to the shot over
|
|
253
|
+
// transitionSeconds; hold; then either hand over to the next shot (overlap)
|
|
254
|
+
// or pull back to 1.0 so the pull-out FINISHES at the shot's end.
|
|
255
|
+
export function buildTimeline(shots, duration) {
|
|
256
|
+
const zooms = shots.filter((s) => !s.wide).sort((a, b) => a.start - b.start);
|
|
257
|
+
const base = { cx: FRAME_W / 2, cy: FRAME_H / 2, factor: 1 };
|
|
258
|
+
const kf = [{ t: 0, ...base }];
|
|
259
|
+
let cur = base;
|
|
260
|
+
for (let i = 0; i < zooms.length; i++) {
|
|
261
|
+
const z = zooms[i];
|
|
262
|
+
const target = { cx: z.cx, cy: z.cy, factor: z.factor };
|
|
263
|
+
const tin = transitionSeconds(cur, target);
|
|
264
|
+
const tout = transitionSeconds(target, base);
|
|
265
|
+
const next = zooms[i + 1];
|
|
266
|
+
kf.push({ t: z.start, ...cur });
|
|
267
|
+
kf.push({ t: z.start + tin, ...target });
|
|
268
|
+
cur = target;
|
|
269
|
+
if (next && next.start < z.end) {
|
|
270
|
+
if (next.start > z.start + tin) kf.push({ t: next.start, ...target });
|
|
271
|
+
} else {
|
|
272
|
+
kf.push({ t: Math.max(z.start + tin, z.end - tout), ...target });
|
|
273
|
+
kf.push({ t: Math.max(z.start + tin + 0.01, z.end), ...base });
|
|
274
|
+
cur = base;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
kf.push({ t: Math.max(duration, kf[kf.length - 1].t + 0.01), ...cur });
|
|
278
|
+
// Keyframes must be strictly increasing in time.
|
|
279
|
+
const clean = kf.reduce((out, k) => { if (!out.length || k.t > out[out.length - 1].t) out.push(k); else out[out.length - 1] = k; return out; }, []);
|
|
280
|
+
return (t) => {
|
|
281
|
+
if (t <= clean[0].t) return { ...clean[0] };
|
|
282
|
+
for (let i = 1; i < clean.length; i++) {
|
|
283
|
+
const a = clean[i - 1], b = clean[i];
|
|
284
|
+
if (t <= b.t) {
|
|
285
|
+
const p = ease((t - a.t) / (b.t - a.t));
|
|
286
|
+
// Interpolate the translate and the scale, like the runtime does.
|
|
287
|
+
const f = a.factor + (b.factor - a.factor) * p;
|
|
288
|
+
const rx = remX(a) + (remX(b) - remX(a)) * p;
|
|
289
|
+
const ry = remY(a) + (remY(b) - remY(a)) * p;
|
|
290
|
+
return { factor: f, cx: FRAME_W / 2 - rx / f, cy: FRAME_H / 2 - ry / f };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return { ...clean[clean.length - 1] };
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── SPRING motion (measured off a competitor's output, 2026-09-29) ─────────
|
|
298
|
+
// Critically damped: full speed the moment it starts, a long soft landing,
|
|
299
|
+
// never overshoots. Halfway in 0.2 s, settled in ~0.8 s, whatever the
|
|
300
|
+
// distance. A new target mid-flight keeps the velocity, so chained moves
|
|
301
|
+
// (zoom → pan → zoom) flow instead of stopping and restarting.
|
|
302
|
+
export const SPRING = { stiffness: 100, damping: 20, mass: 1 };
|
|
303
|
+
// The spring cannot "finish at" a time, so a pull-out is released this much
|
|
304
|
+
// before the shot ends (94% of the way out by then).
|
|
305
|
+
export const SPRING_RELEASE_LEAD = 0.45;
|
|
306
|
+
|
|
307
|
+
export function buildSpringTimeline(shots, duration, spring = SPRING) {
|
|
308
|
+
const zooms = shots.filter((s) => !s.wide).sort((a, b) => a.start - b.start);
|
|
309
|
+
const base = { cx: FRAME_W / 2, cy: FRAME_H / 2, factor: 1 };
|
|
310
|
+
// Where the camera WANTS to be at time t (a step function).
|
|
311
|
+
const segs = zooms.map((z, i) => {
|
|
312
|
+
const next = zooms[i + 1];
|
|
313
|
+
const release = next && next.start < z.end ? next.start : Math.max(z.start + 0.3, z.end - SPRING_RELEASE_LEAD);
|
|
314
|
+
return { from: z.start, to: release, target: { cx: z.cx, cy: z.cy, factor: z.factor } };
|
|
315
|
+
});
|
|
316
|
+
const targetAt = (t) => { let tg = base; for (const s of segs) if (s.from <= t && t < s.to) tg = s.target; return tg; };
|
|
317
|
+
// Spring the runtime's own variables (translate + scale) so a settled
|
|
318
|
+
// camera is exactly the planned shot and the path never shows dead space.
|
|
319
|
+
const HZ = 600, dt = 1 / HZ, n = Math.ceil((duration + 1) * HZ);
|
|
320
|
+
const { stiffness: k, damping: c, mass: m } = spring;
|
|
321
|
+
const st = { rx: 0, ry: 0, f: 1, vx: 0, vy: 0, vf: 0 };
|
|
322
|
+
const samples = new Float64Array(n * 3);
|
|
323
|
+
for (let i = 0; i < n; i++) {
|
|
324
|
+
const tg = targetAt(i * dt);
|
|
325
|
+
const tx = remX(tg), ty = remY(tg), tf = tg.factor;
|
|
326
|
+
st.vx += ((-k * (st.rx - tx) - c * st.vx) / m) * dt; st.rx += st.vx * dt;
|
|
327
|
+
st.vy += ((-k * (st.ry - ty) - c * st.vy) / m) * dt; st.ry += st.vy * dt;
|
|
328
|
+
st.vf += ((-k * (st.f - tf) - c * st.vf) / m) * dt; st.f += st.vf * dt;
|
|
329
|
+
samples[i * 3] = st.rx; samples[i * 3 + 1] = st.ry; samples[i * 3 + 2] = st.f;
|
|
330
|
+
}
|
|
331
|
+
return (t) => {
|
|
332
|
+
const x = clamp(t * HZ, 0, n - 1.001), i = Math.floor(x), p = x - i;
|
|
333
|
+
const g = (o) => samples[i * 3 + o] + (samples[(i + 1) * 3 + o] - samples[i * 3 + o]) * p;
|
|
334
|
+
const f = Math.max(1, g(2));
|
|
335
|
+
return { factor: f, cx: FRAME_W / 2 - g(0) / f, cy: FRAME_H / 2 - g(1) / f };
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// cubic-bezier(0.4, 0, 0.2, 1), solved for x by Newton iterations.
|
|
340
|
+
function ease(x) {
|
|
341
|
+
if (x <= 0) return 0;
|
|
342
|
+
if (x >= 1) return 1;
|
|
343
|
+
const x1 = 0.4, y1 = 0, x2 = 0.2, y2 = 1;
|
|
344
|
+
const bx = (t) => 3 * (1 - t) * (1 - t) * t * x1 + 3 * (1 - t) * t * t * x2 + t * t * t;
|
|
345
|
+
const by = (t) => 3 * (1 - t) * (1 - t) * t * y1 + 3 * (1 - t) * t * t * y2 + t * t * t;
|
|
346
|
+
const dbx = (t) => 3 * (1 - t) * (1 - t) * x1 + 6 * (1 - t) * t * (x2 - x1) + 3 * t * t * (1 - x2);
|
|
347
|
+
let t = x;
|
|
348
|
+
for (let i = 0; i < 8; i++) { const d = dbx(t); if (Math.abs(d) < 1e-6) break; t = clamp(t - (bx(t) - x) / d, 0, 1); }
|
|
349
|
+
return by(t);
|
|
350
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Cursor glyphs, the same SVGs the DemoBites studio draws (public/cursors),
|
|
2
|
+
// with the studio hotspots and native sizes. Generated; edit the app copies.
|
|
3
|
+
export const CURSORS = {
|
|
4
|
+
arrow: { w: 12, h: 19, hotspot: { x: 1, y: 1 }, svg: "<svg width=\"12\" height=\"19\" viewBox=\"0 0 12 19\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><g clip-path=\"url(#clip0_1_69)\"><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M0 16.015V0L11.591 11.619H4.81L4.399 11.743L0 16.015Z\" fill=\"white\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M9.08449 16.6893L5.47949 18.2243L0.797485 7.1353L4.48349 5.5823L9.08449 16.6893Z\" fill=\"white\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M7.75101 16.0086L5.90701 16.7826L2.80701 9.4086L4.64801 8.6336L7.75101 16.0086Z\" fill=\"black\"/><path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M1 2.4071V13.5951L3.969 10.7291L4.397 10.5901H9.165L1 2.4071Z\" fill=\"black\"/></g><defs><clipPath id=\"clip0_1_69\"><rect width=\"12\" height=\"19\" fill=\"white\"/></clipPath></defs></svg>" },
|
|
5
|
+
pointingHand: { w: 16, h: 17, hotspot: { x: 5, y: 0 }, svg: "<svg width=\"16\" height=\"17\" viewBox=\"0 0 16 17\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"> <g clip-path=\"url(#clip0_1_131)\"> <path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M3.3315 12.3799C3.0475 12.0209 2.7025 11.2869 2.0885 10.3959C1.7405 9.8919 0.877496 8.9429 0.620496 8.4609C0.397496 8.0349 0.421496 7.8439 0.474496 7.4909C0.568496 6.8629 1.2125 6.3739 1.8995 6.4399C2.4185 6.4889 2.8585 6.8319 3.2545 7.1559C3.4935 7.3509 3.7875 7.7299 3.9645 7.9439C4.1275 8.1399 4.1675 8.2209 4.3415 8.4529C4.5715 8.7599 4.6435 8.9119 4.5555 8.5739C4.4845 8.0779 4.3685 7.2309 4.2005 6.4819C4.0725 5.9139 4.0415 5.8249 3.9195 5.3889C3.7905 4.9249 3.7245 4.5999 3.6035 4.1079C3.5195 3.7599 3.3685 3.0489 3.3275 2.6489C3.2705 2.1019 3.2405 1.2099 3.5915 0.799903C3.8665 0.478903 4.4975 0.381903 4.8885 0.579903C5.4005 0.838903 5.6915 1.5829 5.8245 1.8799C6.0635 2.4139 6.2115 3.0309 6.3405 3.8409C6.5045 4.8719 6.8065 6.3029 6.8165 6.6039C6.8405 6.2349 6.7485 5.4579 6.8125 5.1039C6.8705 4.7829 7.1405 4.4099 7.4785 4.3089C7.7645 4.2239 8.0995 4.1929 8.3945 4.2539C8.7075 4.3179 9.03749 4.5419 9.16049 4.7529C9.5225 5.3769 9.5295 6.6519 9.5445 6.5839C9.6305 6.2079 9.61549 5.3549 9.8285 4.9999C9.9685 4.7659 10.3255 4.5549 10.5155 4.5209C10.8095 4.4689 11.1705 4.4529 11.4795 4.5129C11.7285 4.5619 12.0655 4.8579 12.1565 4.9999C12.3745 5.3439 12.4985 6.3169 12.5355 6.6579C12.5505 6.7989 12.6095 6.2659 12.8285 5.9219C13.2345 5.2829 14.6715 5.1589 14.7265 6.5609C14.7515 7.2149 14.7465 7.1849 14.7465 7.6249C14.7465 8.1419 14.7345 8.4529 14.7065 8.8269C14.6755 9.2269 14.5895 10.1309 14.4645 10.5689C14.3785 10.8699 14.0935 11.5469 13.8125 11.9529C13.8125 11.9529 12.7385 13.2029 12.6215 13.7659C12.5035 14.3279 12.5425 14.3319 12.5195 14.7309C12.4965 15.1289 12.6405 15.6529 12.6405 15.6529C12.6405 15.6529 11.8385 15.7569 11.4065 15.6879C11.0155 15.6249 10.5315 14.8469 10.4065 14.6089C10.2345 14.2809 9.86749 14.3439 9.72449 14.5859C9.49949 14.9689 9.0155 15.6559 8.6735 15.6989C8.0055 15.7829 6.6195 15.7299 5.5345 15.7189C5.5345 15.7189 5.7195 14.7079 5.3075 14.3609C5.0025 14.1019 4.4775 13.5769 4.1635 13.3009L3.3315 12.3799Z\" fill=\"white\"/> <path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M3.3315 12.3799C3.0475 12.0209 2.7025 11.2869 2.0885 10.3959C1.7405 9.8919 0.877496 8.9429 0.620496 8.4609C0.397496 8.0349 0.421496 7.8439 0.474496 7.4909C0.568496 6.8629 1.2125 6.3739 1.8995 6.4399C2.4185 6.4889 2.8585 6.8319 3.2545 7.1559C3.4935 7.3509 3.7875 7.7299 3.9645 7.9439C4.1275 8.1399 4.1675 8.2209 4.3415 8.4529C4.5715 8.7599 4.6435 8.9119 4.5555 8.5739C4.4845 8.0779 4.3685 7.2309 4.2005 6.4819C4.0725 5.9139 4.0415 5.8249 3.9195 5.3889C3.7905 4.9249 3.7245 4.5999 3.6035 4.1079C3.5195 3.7599 3.3685 3.0489 3.3275 2.6489C3.2705 2.1019 3.2405 1.2099 3.5915 0.799903C3.8665 0.478903 4.4975 0.381903 4.8885 0.579903C5.4005 0.838903 5.6915 1.5829 5.8245 1.8799C6.0635 2.4139 6.2115 3.0309 6.3405 3.8409C6.5045 4.8719 6.8065 6.3029 6.8165 6.6039C6.8405 6.2349 6.7485 5.4579 6.8125 5.1039C6.8705 4.7829 7.1405 4.4099 7.4785 4.3089C7.7645 4.2239 8.0995 4.1929 8.3945 4.2539C8.7075 4.3179 9.03749 4.5419 9.16049 4.7529C9.52249 5.3769 9.5295 6.6519 9.5445 6.5839C9.6305 6.2079 9.6155 5.3549 9.8285 4.9999C9.9685 4.7659 10.3255 4.5549 10.5155 4.5209C10.8095 4.4689 11.1705 4.4529 11.4795 4.5129C11.7285 4.5619 12.0655 4.8579 12.1565 4.9999C12.3745 5.3439 12.4985 6.3169 12.5355 6.6579C12.5505 6.7989 12.6095 6.2659 12.8285 5.9219C13.2345 5.2829 14.6715 5.1589 14.7265 6.5609C14.7515 7.2149 14.7465 7.1849 14.7465 7.6249C14.7465 8.1419 14.7345 8.4529 14.7065 8.8269C14.6755 9.2269 14.5895 10.1309 14.4645 10.5689C14.3785 10.8699 14.0935 11.5469 13.8125 11.9529C13.8125 11.9529 12.7385 13.2029 12.6215 13.7659C12.5035 14.3279 12.5425 14.3319 12.5195 14.7309C12.4965 15.1289 12.6405 15.6529 12.6405 15.6529C12.6405 15.6529 11.8385 15.7569 11.4065 15.6879C11.0155 15.6249 10.5315 14.8469 10.4065 14.6089C10.2345 14.2809 9.86749 14.3439 9.72449 14.5859C9.49949 14.9689 9.0155 15.6559 8.6735 15.6989C8.0055 15.7829 6.6195 15.7299 5.5345 15.7189C5.5345 15.7189 5.7195 14.7079 5.3075 14.3609C5.0025 14.1019 4.4775 13.5769 4.1635 13.3009L3.3315 12.3799Z\" stroke=\"black\" stroke-width=\"0.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/> <path d=\"M11.5664 12.7344V9.27541\" stroke=\"black\" stroke-width=\"0.75\" stroke-linecap=\"round\"/> <path d=\"M9.55079 12.7461L9.53479 9.2731\" stroke=\"black\" stroke-width=\"0.75\" stroke-linecap=\"round\"/> <path d=\"M7.55469 9.3047L7.57569 12.7307\" stroke=\"black\" stroke-width=\"0.75\" stroke-linecap=\"round\"/> </g> <defs> <clipPath id=\"clip0_1_131\"> <rect width=\"16\" height=\"17\" fill=\"white\"/> </clipPath> </defs> </svg>" },
|
|
6
|
+
iBeam: { w: 10, h: 16, hotspot: { x: 4, y: 8 }, svg: "<svg width=\"9\" height=\"16\" viewBox=\"0 0 9 16\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"> <g clip-path=\"url(#clip0_1_90)\"> <path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M4.01398 2V13.993H4.99998V2H4.01398Z\" fill=\"black\"/> <path fill-rule=\"evenodd\" clip-rule=\"evenodd\" d=\"M3 8V8.984H6V8H3Z\" fill=\"black\"/> <path d=\"M5 2.5C5.19256 2.04209 5.84981 1.33692 6.27645 1.10339C6.68002 0.880845 7.09513 0.691271 7.49178 0.591448C7.97723 0.467813 9 0.505362 9 0.505362\" stroke=\"black\"/> <path d=\"M4 2.5C3.82142 2.01964 3.08491 1.28308 2.75693 1.08271C2.36475 0.844812 1.92938 0.691096 1.52787 0.591364C1.03531 0.467843 0 0.505357 0 0.505357\" stroke=\"black\"/> <path d=\"M5 13.5C5.19256 13.9579 5.84981 14.6631 6.27645 14.8966C6.68002 15.1192 7.09513 15.3087 7.49178 15.4086C7.97723 15.5322 9 15.4946 9 15.4946\" stroke=\"black\"/> <path d=\"M4 13.5C3.82142 13.9804 3.08491 14.7169 2.75693 14.9173C2.36475 15.1552 1.92938 15.3089 1.52787 15.4086C1.03531 15.5322 0 15.4946 0 15.4946\" stroke=\"black\"/> </g> <defs> <clipPath id=\"clip0_1_90\"> <rect width=\"9\" height=\"16\" fill=\"white\"/> </clipPath> </defs> </svg>" },
|
|
7
|
+
};
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// finish.mjs — the LOCAL ending. A filmed take becomes a finished demo
|
|
3
|
+
// recording on this machine, with no DemoBites account:
|
|
4
|
+
//
|
|
5
|
+
// 1. voice — each narration line is spoken with YOUR ElevenLabs key,
|
|
6
|
+
// measured, and placed at its beat (never on top of the last line)
|
|
7
|
+
// 2. camera — the take's measured shots become zooms, by the rules in
|
|
8
|
+
// camera.mjs (the same laws the DemoBites cloud uses)
|
|
9
|
+
// 3. cursor — drawn from the recorded track, crisp at any zoom, with the
|
|
10
|
+
// press the studio uses
|
|
11
|
+
// 4. frame — the take sits centre stage on a background, rounded corners,
|
|
12
|
+
// a soft shadow, 16:9
|
|
13
|
+
//
|
|
14
|
+
// Usage: node finish.mjs <takeDir> [--out=<file.mp4>] [--no-voice]
|
|
15
|
+
// [--voice=<elevenlabs voice id>]
|
|
16
|
+
// [--background=midnight|mist|<#hex>]
|
|
17
|
+
// Reads <takeDir>/clean.mp4 + <takeDir>/manifest.demobites.json
|
|
18
|
+
// Writes <takeDir>/local/{camera.json,voice.json,narration.wav,demo.mp4}
|
|
19
|
+
// Key: ELEVENLABS_API_KEY, or the file .recorder/elevenlabs.key. The key
|
|
20
|
+
// only ever travels to the ElevenLabs API.
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import crypto from "node:crypto";
|
|
24
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
25
|
+
import { createRequire } from "node:module";
|
|
26
|
+
import { ffmpeg as ffmpegPath, ffprobe as ffprobePath, requireMediaTools } from "./media-tools.mjs";
|
|
27
|
+
import { planShots, buildTimeline, buildSpringTimeline, FRAME_W, FRAME_H } from "./camera.mjs";
|
|
28
|
+
import { CURSORS } from "./cursors.mjs";
|
|
29
|
+
|
|
30
|
+
const args = process.argv.slice(2);
|
|
31
|
+
const dir = args.find((a) => !a.startsWith("--"));
|
|
32
|
+
const opt = (k, d) => { const a = args.find((x) => x.startsWith(`--${k}=`)); return a ? a.slice(k.length + 3) : d; };
|
|
33
|
+
const has = (k) => args.includes(`--${k}`);
|
|
34
|
+
if (!dir) { console.error("Usage: node finish.mjs <takeDir> [--out=file.mp4] [--no-voice] [--voice=id] [--background=midnight|mist|#hex]"); process.exit(2); }
|
|
35
|
+
requireMediaTools();
|
|
36
|
+
const FFMPEG = ffmpegPath(), FFPROBE = ffprobePath();
|
|
37
|
+
|
|
38
|
+
const sharp = await (async () => {
|
|
39
|
+
try { return (await import("sharp")).default; } catch {}
|
|
40
|
+
try { return createRequire(path.join(process.env.SHARP_FROM || process.cwd(), "noop.js"))("sharp"); } catch {}
|
|
41
|
+
console.error("finish.mjs needs sharp (npm i sharp)."); process.exit(1);
|
|
42
|
+
})();
|
|
43
|
+
|
|
44
|
+
const cleanPath = path.join(dir, "clean.mp4");
|
|
45
|
+
const manPath = path.join(dir, "manifest.demobites.json");
|
|
46
|
+
for (const p of [cleanPath, manPath]) if (!fs.existsSync(p)) { console.error(`${p} not found — film and trim the take first.`); process.exit(1); }
|
|
47
|
+
const man = JSON.parse(fs.readFileSync(manPath, "utf8"));
|
|
48
|
+
const outDir = path.join(dir, "local");
|
|
49
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
50
|
+
const probe = (f) => parseFloat(execFileSync(FFPROBE, ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", f]).toString());
|
|
51
|
+
const videoDur = probe(cleanPath);
|
|
52
|
+
const FPS = 30;
|
|
53
|
+
const T0 = Date.now();
|
|
54
|
+
const say = (s) => console.log(s);
|
|
55
|
+
|
|
56
|
+
// ── 1. Voice ────────────────────────────────────────────────────────────
|
|
57
|
+
const LINE_GAP = 0.25; // silence between two lines, at least
|
|
58
|
+
const OPENING_BEAT = 0.3; // the first line never fires on frame one
|
|
59
|
+
const lines = (man.steps ?? [])
|
|
60
|
+
.filter((s) => s.narration?.text)
|
|
61
|
+
.map((s, i) => ({ n: s.n, text: s.narration.text.trim(), anchor: Number(s.narration.t ?? s.t_start ?? 0) + (i === 0 ? OPENING_BEAT : 0), stepEnd: Number(s.t_end ?? 0) }));
|
|
62
|
+
let voiceOn = !has("no-voice") && lines.length > 0;
|
|
63
|
+
let KEY = process.env.ELEVENLABS_API_KEY || "";
|
|
64
|
+
if (!KEY) { try { KEY = fs.readFileSync(path.resolve(".recorder", "elevenlabs.key"), "utf8").trim(); } catch {} }
|
|
65
|
+
if (voiceOn && !KEY) { say("No ElevenLabs key (ELEVENLABS_API_KEY or .recorder/elevenlabs.key) — finishing silent, captions still written."); voiceOn = false; }
|
|
66
|
+
let cfg = {};
|
|
67
|
+
try { cfg = JSON.parse(fs.readFileSync(path.resolve(".recorder", "config.json"), "utf8")); } catch {}
|
|
68
|
+
const VOICE = opt("voice", cfg.tts?.voice_id || "Xb7hH8MSUJpSbSDYk0k2"); // Alice (premade)
|
|
69
|
+
const MODEL = cfg.tts?.model_id || "eleven_multilingual_v2";
|
|
70
|
+
|
|
71
|
+
if (voiceOn) {
|
|
72
|
+
const cache = path.join(outDir, "voice");
|
|
73
|
+
fs.mkdirSync(cache, { recursive: true });
|
|
74
|
+
let chars = 0;
|
|
75
|
+
for (const l of lines) {
|
|
76
|
+
const id = crypto.createHash("sha1").update(`${VOICE}|${MODEL}|${l.text}`).digest("hex").slice(0, 16);
|
|
77
|
+
l.file = path.join(cache, `${id}.mp3`);
|
|
78
|
+
if (!fs.existsSync(l.file)) {
|
|
79
|
+
const res = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${VOICE}?output_format=mp3_44100_128`, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { "xi-api-key": KEY, "Content-Type": "application/json" },
|
|
82
|
+
body: JSON.stringify({ text: l.text, model_id: MODEL, voice_settings: { stability: 0.5, similarity_boost: 0.75 } }),
|
|
83
|
+
});
|
|
84
|
+
if (!res.ok) { console.error(`ElevenLabs refused line ${l.n}: ${res.status} ${(await res.text()).slice(0, 200)}`); process.exit(1); }
|
|
85
|
+
fs.writeFileSync(l.file, Buffer.from(await res.arrayBuffer()));
|
|
86
|
+
chars += l.text.length;
|
|
87
|
+
}
|
|
88
|
+
l.dur = probe(l.file);
|
|
89
|
+
}
|
|
90
|
+
// Placement: at its beat, or right after the previous line — never on top.
|
|
91
|
+
let prevEnd = 0;
|
|
92
|
+
for (let i = 0; i < lines.length; i++) {
|
|
93
|
+
const l = lines[i];
|
|
94
|
+
l.at = Math.max(l.anchor, prevEnd + (i ? LINE_GAP : 0));
|
|
95
|
+
l.end = l.at + l.dur;
|
|
96
|
+
prevEnd = l.end;
|
|
97
|
+
const next = lines[i + 1];
|
|
98
|
+
l.late = Math.max(0, l.at - l.anchor);
|
|
99
|
+
l.overrun = next ? Math.max(0, l.end + LINE_GAP - next.anchor) : 0;
|
|
100
|
+
}
|
|
101
|
+
say(`voice: ${lines.length} lines, ${chars} new characters sent to ElevenLabs (voice ${VOICE})`);
|
|
102
|
+
for (const l of lines) say(` ${l.at.toFixed(2).padStart(6)}s ${l.dur.toFixed(2)}s${l.late > 0.05 ? ` (+${l.late.toFixed(2)}s late)` : ""}${l.overrun > 0.05 ? ` ⚠ runs ${l.overrun.toFixed(2)}s into the next beat` : ""} "${l.text}"`);
|
|
103
|
+
fs.writeFileSync(path.join(outDir, "voice.json"), JSON.stringify({ voice: VOICE, model: MODEL, lines }, null, 2));
|
|
104
|
+
}
|
|
105
|
+
// The video ends a breath after the last word — hold the last frame if needed.
|
|
106
|
+
const lastWord = voiceOn ? Math.max(...lines.map((l) => l.end)) : 0;
|
|
107
|
+
const outDur = Math.max(videoDur, lastWord + 0.8);
|
|
108
|
+
const hold = Math.max(0, outDur - videoDur);
|
|
109
|
+
|
|
110
|
+
// Captions beside the video, always (the silent version needs them most).
|
|
111
|
+
{
|
|
112
|
+
const ts = (s) => { const ms = Math.round(s * 1000); const h = Math.floor(ms / 3600000), m = Math.floor(ms / 60000) % 60, sec = Math.floor(ms / 1000) % 60; return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")},${String(ms % 1000).padStart(3, "0")}`; };
|
|
113
|
+
const srt = lines.map((l, i) => { const a = l.at ?? l.anchor; const b = l.end ?? Math.min(outDur, a + Math.max(1.5, l.text.split(/\s+/).length / 2.6)); return `${i + 1}\n${ts(a)} --> ${ts(b)}\n${l.text}\n`; }).join("\n");
|
|
114
|
+
fs.writeFileSync(path.join(outDir, "captions.srt"), srt);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── 2. Camera ───────────────────────────────────────────────────────────
|
|
118
|
+
const shots = planShots(man, videoDur, (m) => say(m));
|
|
119
|
+
// --motion=spring (critically damped, velocity carries across chained moves)
|
|
120
|
+
// or --motion=bezier (the DemoBites runtime today). --blur adds camera motion
|
|
121
|
+
// blur: while the camera moves, each frame averages several sub-frame
|
|
122
|
+
// cameras across the shutter, like a real camera's exposure.
|
|
123
|
+
const MOTION = opt("motion", cfg.look?.motion || "bezier");
|
|
124
|
+
const BLUR = has("blur") || cfg.look?.motionBlur === true;
|
|
125
|
+
const SHUTTER = Number(opt("shutter", "0.5")); // in frames: 0.5 = a 180° shutter, like film
|
|
126
|
+
const camAt = MOTION === "spring" ? buildSpringTimeline(shots, outDur) : buildTimeline(shots, outDur);
|
|
127
|
+
fs.writeFileSync(path.join(outDir, "camera.json"), JSON.stringify(shots.map(({ start, end, cx, cy, factor, wide, how, label }) => ({ start: +start.toFixed(2), end: +end.toFixed(2), factor, cx: Math.round(cx), cy: Math.round(cy), wide: !!wide, how: how ?? (wide ? "wide" : "zoom"), label })), null, 2));
|
|
128
|
+
say(`camera: ${shots.filter((s) => !s.wide).length} zooms, ${shots.filter((s) => s.wide).length} wide holds · motion ${MOTION}${BLUR ? ` + motion blur (shutter ${SHUTTER} frame)` : ""}`);
|
|
129
|
+
for (const s of shots) say(` ${s.start.toFixed(2).padStart(6)}–${s.end.toFixed(2).padEnd(6)} ${s.wide ? "wide " : `${s.factor.toFixed(2)}x`} ${s.how ? `[${s.how}] ` : ""}${s.label ?? ""}`);
|
|
130
|
+
|
|
131
|
+
// ── 3. Cursor ───────────────────────────────────────────────────────────
|
|
132
|
+
const CURSOR_H = 0.02 * FRAME_W; // studio "medium": 2% of the frame width
|
|
133
|
+
// The studio grows the glyph with the zoom (it lives inside the zoomed
|
|
134
|
+
// layer). Locally we let it grow, but not past this: at 3x a full-size hand
|
|
135
|
+
// covers the button it is pressing.
|
|
136
|
+
const CURSOR_MAX_GROWTH = 1.6;
|
|
137
|
+
const PRESS_SCALE = 0.88, PRESS_IN = 0.3, PRESS_WINDOW = 0.4;
|
|
138
|
+
const GAP_HOLD = 0.25, GAP_MOVE = 0.18; // cursor gap law: hold through a hole, then a 0.18 s move
|
|
139
|
+
const events = (man.interactions?.mouseEvents ?? []).filter((e) => [e.x, e.y, e.time].every(Number.isFinite)).sort((a, b) => a.time - b.time);
|
|
140
|
+
const clicks = events.filter((e) => e.type === "click");
|
|
141
|
+
function cursorAt(t) {
|
|
142
|
+
if (!events.length) return null;
|
|
143
|
+
if (t <= events[0].time) return { x: events[0].x, y: events[0].y, kind: CURSORS[events[0].cursor] ? events[0].cursor : "arrow", press: 1 };
|
|
144
|
+
let i = events.length - 1;
|
|
145
|
+
for (let lo = 0, hi = events.length - 1; lo <= hi;) { const mid = (lo + hi) >> 1; if (events[mid].time <= t) { i = mid; lo = mid + 1; } else hi = mid - 1; }
|
|
146
|
+
const a = events[i], b = events[i + 1];
|
|
147
|
+
let x = a.x, y = a.y;
|
|
148
|
+
if (b) {
|
|
149
|
+
const gap = b.time - a.time;
|
|
150
|
+
const from = gap > GAP_HOLD ? b.time - GAP_MOVE : a.time;
|
|
151
|
+
const p = Math.min(1, Math.max(0, (t - from) / Math.max(1e-6, b.time - from)));
|
|
152
|
+
x = a.x + (b.x - a.x) * p; y = a.y + (b.y - a.y) * p;
|
|
153
|
+
}
|
|
154
|
+
const kind = CURSORS[a.cursor] ? a.cursor : "arrow";
|
|
155
|
+
let press = 1;
|
|
156
|
+
const c = [...clicks].reverse().find((k) => k.time <= t);
|
|
157
|
+
if (c && kind === "pointingHand") {
|
|
158
|
+
const f = (t - c.time) / PRESS_WINDOW;
|
|
159
|
+
if (c.press === "down") press = f < PRESS_IN ? 1 - (1 - PRESS_SCALE) * (f / PRESS_IN) : PRESS_SCALE;
|
|
160
|
+
else if (f < 1) press = f < PRESS_IN ? 1 - (1 - PRESS_SCALE) * (f / PRESS_IN) : 1 - (1 - PRESS_SCALE) * (1 - (f - PRESS_IN) / (1 - PRESS_IN));
|
|
161
|
+
}
|
|
162
|
+
return { x, y, kind, press };
|
|
163
|
+
}
|
|
164
|
+
const glyphCache = new Map();
|
|
165
|
+
async function glyph(kind, h) {
|
|
166
|
+
const key = `${kind}@${Math.round(h * 2)}`;
|
|
167
|
+
if (glyphCache.has(key)) return glyphCache.get(key);
|
|
168
|
+
const g = CURSORS[kind];
|
|
169
|
+
const scale = h / g.h;
|
|
170
|
+
const W = Math.max(1, Math.round(g.w * scale)), H = Math.max(1, Math.round(g.h * scale));
|
|
171
|
+
const buf = await sharp(Buffer.from(g.svg), { density: Math.max(72, 72 * scale * 1.5) }).resize(W, H).ensureAlpha().raw().toBuffer();
|
|
172
|
+
const out = { buf, W, H, hx: g.hotspot.x * scale, hy: g.hotspot.y * scale };
|
|
173
|
+
glyphCache.set(key, out);
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── 4. Frame: background, shadow, rounded mask ──────────────────────────
|
|
178
|
+
// The take sits in a browser window: a bar on top (DemoBites geometry:
|
|
179
|
+
// bar = 0.075 x content height, traffic lights 0.2 x bar, gap 8/14 of a dot,
|
|
180
|
+
// left offset 40/1920 of the width), content below at its true 16:9.
|
|
181
|
+
const CONTENT_W = 1600, CONTENT_H = 900;
|
|
182
|
+
const showBar = !has("no-bar") && cfg.look?.browserBar !== false;
|
|
183
|
+
const BAR_H = showBar ? Math.round(0.075 * CONTENT_H) : 0;
|
|
184
|
+
const WIN_W = CONTENT_W, WIN_H = CONTENT_H + BAR_H, WIN_X = (FRAME_W - WIN_W) / 2, WIN_Y = Math.round((FRAME_H - WIN_H) / 2), RADIUS = 24;
|
|
185
|
+
const pageUrl = (() => { try { const m = JSON.parse(fs.readFileSync(path.join(dir, "manifest.json"), "utf8")); const u = new URL(m.url); return (u.host + u.pathname).replace(/\/$/, ""); } catch { return ""; } })();
|
|
186
|
+
const barUrl = opt("url", cfg.look?.url ?? pageUrl);
|
|
187
|
+
const bgName = opt("background", cfg.look?.background || "midnight");
|
|
188
|
+
const BACKGROUNDS = {
|
|
189
|
+
midnight: `<linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#10151c"/><stop offset="1" stop-color="#1d2b33"/></linearGradient><radialGradient id="r" cx="0.18" cy="0.12" r="0.7"><stop offset="0" stop-color="#17b0b8" stop-opacity="0.22"/><stop offset="1" stop-color="#17b0b8" stop-opacity="0"/></radialGradient>`,
|
|
190
|
+
mist: `<linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#eef1f3"/><stop offset="1" stop-color="#d9dfe3"/></linearGradient><radialGradient id="r" cx="0.2" cy="0.1" r="0.8"><stop offset="0" stop-color="#17b0b8" stop-opacity="0.16"/><stop offset="1" stop-color="#17b0b8" stop-opacity="0"/></radialGradient>`,
|
|
191
|
+
};
|
|
192
|
+
const bgSvg = /^#?[0-9a-f]{6}$/i.test(bgName)
|
|
193
|
+
? `<svg xmlns="http://www.w3.org/2000/svg" width="${FRAME_W}" height="${FRAME_H}"><rect width="100%" height="100%" fill="#${bgName.replace("#", "")}"/></svg>`
|
|
194
|
+
: `<svg xmlns="http://www.w3.org/2000/svg" width="${FRAME_W}" height="${FRAME_H}"><defs>${BACKGROUNDS[bgName] ?? BACKGROUNDS.midnight}</defs><rect width="100%" height="100%" fill="url(#g)"/><rect width="100%" height="100%" fill="url(#r)"/></svg>`;
|
|
195
|
+
const bgHex = (bgSvg.match(/stop-color="#([0-9a-f]{6})"/i) || bgSvg.match(/fill="#([0-9a-f]{6})"/i) || [, "10151c"])[1];
|
|
196
|
+
const light = parseInt(bgHex.slice(0, 2), 16) * 0.299 + parseInt(bgHex.slice(2, 4), 16) * 0.587 + parseInt(bgHex.slice(4, 6), 16) * 0.114 > 128;
|
|
197
|
+
const shadowSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${FRAME_W}" height="${FRAME_H}"><defs><filter id="s" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur stdDeviation="${light ? 26 : 34}"/></filter></defs><rect x="${WIN_X}" y="${WIN_Y + (light ? 14 : 18)}" width="${WIN_W}" height="${WIN_H}" rx="${RADIUS}" fill="#000" fill-opacity="${light ? 0.22 : 0.55}" filter="url(#s)"/></svg>`;
|
|
198
|
+
const maskSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${WIN_W}" height="${WIN_H}"><rect width="100%" height="100%" fill="#000"/><rect width="100%" height="100%" rx="${RADIUS}" fill="#fff"/></svg>`;
|
|
199
|
+
const dot = 0.2 * BAR_H, gapDot = dot * 8 / 14, dotX = WIN_W * 40 / 1920 + dot / 2;
|
|
200
|
+
const esc = (t) => String(t).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
201
|
+
const pillW = Math.min(WIN_W * 0.42, Math.max(260, barUrl.length * 11 + 60));
|
|
202
|
+
const barSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="${WIN_W}" height="${BAR_H}"><rect width="100%" height="100%" fill="#eceef1"/><rect y="${BAR_H - 1}" width="100%" height="1" fill="#d7dbe0"/>` +
|
|
203
|
+
["#ff5f57", "#febc2e", "#28c840"].map((c, i) => `<circle cx="${dotX + i * (dot + gapDot)}" cy="${BAR_H / 2}" r="${dot / 2}" fill="${c}"/>`).join("") +
|
|
204
|
+
(barUrl ? `<rect x="${(WIN_W - pillW) / 2}" y="${BAR_H * 0.22}" width="${pillW}" height="${BAR_H * 0.56}" rx="${BAR_H * 0.28}" fill="#ffffff"/><text x="${WIN_W / 2}" y="${BAR_H / 2 + 7}" font-family="-apple-system, Helvetica Neue, Arial, sans-serif" font-size="20" fill="#5b6470" text-anchor="middle">${esc(barUrl)}</text>` : "") + `</svg>`;
|
|
205
|
+
const bgPng = path.join(outDir, "background.png"), shadowPng = path.join(outDir, "shadow.png"), maskPng = path.join(outDir, "mask.png"), barPng = path.join(outDir, "bar.png");
|
|
206
|
+
await sharp(Buffer.from(bgSvg)).png().toFile(bgPng);
|
|
207
|
+
await sharp(Buffer.from(shadowSvg)).png().toFile(shadowPng);
|
|
208
|
+
await sharp(Buffer.from(maskSvg)).png().toFile(maskPng);
|
|
209
|
+
if (showBar) await sharp(Buffer.from(barSvg)).png().toFile(barPng);
|
|
210
|
+
|
|
211
|
+
// ── Narration track ─────────────────────────────────────────────────────
|
|
212
|
+
const wavPath = path.join(outDir, "narration.wav");
|
|
213
|
+
if (voiceOn) {
|
|
214
|
+
const ins = lines.flatMap((l) => ["-i", l.file]);
|
|
215
|
+
const chains = lines.map((l, i) => { const ms = Math.round(l.at * 1000); return `[${i}:a]aresample=48000,adelay=${ms}|${ms}[a${i}]`; }).join(";");
|
|
216
|
+
const mix = `${lines.map((_, i) => `[a${i}]`).join("")}amix=inputs=${lines.length}:normalize=0,apad=whole_dur=${outDur.toFixed(3)},loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000[aout]`;
|
|
217
|
+
execFileSync(FFMPEG, ["-y", "-loglevel", "error", ...ins, "-filter_complex", `${chains};${mix}`, "-map", "[aout]", "-t", outDur.toFixed(3), "-ac", "2", wavPath]);
|
|
218
|
+
} else {
|
|
219
|
+
execFileSync(FFMPEG, ["-y", "-loglevel", "error", "-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo", "-t", outDur.toFixed(3), wavPath]);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Render: decode → camera + cursor per frame → frame + encode ─────────
|
|
223
|
+
const outPath = path.resolve(opt("out", path.join(outDir, "demo.mp4")));
|
|
224
|
+
const FRAME_BYTES = FRAME_W * FRAME_H * 3;
|
|
225
|
+
const totalFrames = Math.round(outDur * FPS);
|
|
226
|
+
const dec = spawn(FFMPEG, ["-loglevel", "error", "-i", cleanPath, "-vf", `fps=${FPS}${hold > 0 ? `,tpad=stop_mode=clone:stop_duration=${hold.toFixed(3)}` : ""}`, "-f", "rawvideo", "-pix_fmt", "rgb24", "pipe:1"], { stdio: ["ignore", "pipe", "inherit"] });
|
|
227
|
+
const enc = spawn(FFMPEG, [
|
|
228
|
+
"-y", "-loglevel", "error",
|
|
229
|
+
"-f", "rawvideo", "-pix_fmt", "rgba", "-s", `${FRAME_W}x${FRAME_H}`, "-r", String(FPS), "-i", "pipe:0",
|
|
230
|
+
"-loop", "1", "-framerate", String(FPS), "-i", bgPng,
|
|
231
|
+
"-i", shadowPng,
|
|
232
|
+
"-i", maskPng,
|
|
233
|
+
"-i", wavPath,
|
|
234
|
+
...(showBar ? ["-loop", "1", "-framerate", String(FPS), "-i", barPng] : []),
|
|
235
|
+
"-filter_complex",
|
|
236
|
+
(showBar
|
|
237
|
+
? `[0:v]format=rgb24,scale=${CONTENT_W}:${CONTENT_H}:flags=lanczos,format=rgba[c];[5:v]format=rgba[bar];[bar][c]vstack=inputs=2:shortest=1[v]`
|
|
238
|
+
: `[0:v]format=rgb24,scale=${CONTENT_W}:${CONTENT_H}:flags=lanczos,format=rgba[v]`) +
|
|
239
|
+
`;[3:v]format=gray[m];[v][m]alphamerge[va];[1:v][2:v]overlay=0:0[b1];[b1][va]overlay=${WIN_X}:${WIN_Y}:shortest=1,format=yuv420p[out]`,
|
|
240
|
+
"-map", "[out]", "-map", "4:a",
|
|
241
|
+
"-c:v", "libx264", "-preset", "medium", "-crf", "18", "-r", String(FPS),
|
|
242
|
+
"-c:a", "aac", "-b:a", "160k", "-t", outDur.toFixed(3), "-movflags", "+faststart",
|
|
243
|
+
outPath,
|
|
244
|
+
], { stdio: ["pipe", "inherit", "inherit"] });
|
|
245
|
+
|
|
246
|
+
const viewport = (cam) => {
|
|
247
|
+
const f = cam.factor, vw = FRAME_W / f, vh = FRAME_H / f;
|
|
248
|
+
return { f, vw, vh, vx: Math.min(FRAME_W - vw, Math.max(0, cam.cx - vw / 2)), vy: Math.min(FRAME_H - vh, Math.max(0, cam.cy - vh / 2)) };
|
|
249
|
+
};
|
|
250
|
+
// The take seen through one camera position, as raw RGB at output size
|
|
251
|
+
// (or at half size: fast sub-frames are blurred anyway).
|
|
252
|
+
async function shoot(raw, v, half = false) {
|
|
253
|
+
if (v.f <= 1.001 && !half) return raw;
|
|
254
|
+
// Sub-pixel camera: cut a whole-pixel region around the viewport, scale
|
|
255
|
+
// it, then trim to the exact viewport at output resolution.
|
|
256
|
+
const { f, vx, vy, vw, vh } = v;
|
|
257
|
+
const ex = Math.floor(vx), ey = Math.floor(vy);
|
|
258
|
+
const ew = Math.min(FRAME_W - ex, Math.ceil(vx + vw) - ex + 1), eh = Math.min(FRAME_H - ey, Math.ceil(vy + vh) - ey + 1);
|
|
259
|
+
const q = half ? 0.5 : 1, OW = FRAME_W * q, OH = FRAME_H * q, fq = f * q;
|
|
260
|
+
const rw = Math.max(OW, Math.round(ew * fq)), rh = Math.max(OH, Math.round(eh * fq));
|
|
261
|
+
const ox = Math.min(rw - OW, Math.round((vx - ex) * fq)), oy = Math.min(rh - OH, Math.round((vy - ey) * fq));
|
|
262
|
+
return sharp(raw, { raw: { width: FRAME_W, height: FRAME_H, channels: 3 } })
|
|
263
|
+
.extract({ left: ex, top: ey, width: ew, height: eh }).resize(rw, rh, { kernel: half ? "cubic" : "lanczos3" })
|
|
264
|
+
.extract({ left: Math.max(0, ox), top: Math.max(0, oy), width: OW, height: OH }).raw().toBuffer();
|
|
265
|
+
}
|
|
266
|
+
// How far (output px) the picture slides between two camera positions:
|
|
267
|
+
// the worst of the two viewport corners.
|
|
268
|
+
function slide(a, b) {
|
|
269
|
+
let worst = 0;
|
|
270
|
+
for (const [px, py] of [[a.vx, a.vy], [a.vx + a.vw, a.vy + a.vh]]) {
|
|
271
|
+
const ax = (px - a.vx) * a.f, ay = (py - a.vy) * a.f, bx = (px - b.vx) * b.f, by = (py - b.vy) * b.f;
|
|
272
|
+
worst = Math.max(worst, Math.hypot(ax - bx, ay - by));
|
|
273
|
+
}
|
|
274
|
+
return worst;
|
|
275
|
+
}
|
|
276
|
+
let blurredFrames = 0;
|
|
277
|
+
async function renderFrame(raw, n) {
|
|
278
|
+
const t = n / FPS;
|
|
279
|
+
const v = viewport(camAt(t));
|
|
280
|
+
let frame;
|
|
281
|
+
const half = (SHUTTER / FPS) / 2;
|
|
282
|
+
const a = BLUR ? viewport(camAt(t - half)) : v, b = BLUR ? viewport(camAt(t + half)) : v;
|
|
283
|
+
const travel = BLUR ? slide(a, b) : 0;
|
|
284
|
+
if (travel < 1.5) {
|
|
285
|
+
frame = await shoot(raw, v);
|
|
286
|
+
} else {
|
|
287
|
+
// One sub-frame every ~2 px of travel (no visible copies), 64 at most,
|
|
288
|
+
// averaged. Past 12 px of smear the sub-frames are shot at half size and
|
|
289
|
+
// the average is scaled back up: the blur is wider than what that loses.
|
|
290
|
+
const k = Math.min(64, Math.max(3, Math.ceil(travel / 2)));
|
|
291
|
+
const small = travel > 12;
|
|
292
|
+
const W = small ? FRAME_W / 2 : FRAME_W, H = small ? FRAME_H / 2 : FRAME_H;
|
|
293
|
+
const acc = new Uint16Array(W * H * 3);
|
|
294
|
+
for (let i = 0; i < k; i++) {
|
|
295
|
+
const sub = await shoot(raw, viewport(camAt(t - half + (2 * half * i) / (k - 1))), small);
|
|
296
|
+
for (let j = 0; j < acc.length; j++) acc[j] += sub[j];
|
|
297
|
+
}
|
|
298
|
+
const avg = Buffer.alloc(acc.length);
|
|
299
|
+
for (let j = 0; j < acc.length; j++) avg[j] = (acc[j] + (k >> 1)) / k;
|
|
300
|
+
frame = small ? await sharp(avg, { raw: { width: W, height: H, channels: 3 } }).resize(FRAME_W, FRAME_H, { kernel: "lanczos3" }).raw().toBuffer() : avg;
|
|
301
|
+
blurredFrames++;
|
|
302
|
+
}
|
|
303
|
+
const { f, vx, vy } = v;
|
|
304
|
+
const img = sharp(frame, { raw: { width: FRAME_W, height: FRAME_H, channels: 3 } });
|
|
305
|
+
const cur = cursorAt(t);
|
|
306
|
+
const layers = [];
|
|
307
|
+
if (cur) {
|
|
308
|
+
const g = await glyph(cur.kind, CURSOR_H * Math.min(f, CURSOR_MAX_GROWTH) * cur.press);
|
|
309
|
+
const left = Math.round((cur.x - vx) * f - g.hx), top = Math.round((cur.y - vy) * f - g.hy);
|
|
310
|
+
// Clip the glyph to the frame (a cursor at the edge is partly outside).
|
|
311
|
+
const cl = Math.max(0, -left), ct = Math.max(0, -top);
|
|
312
|
+
const cw = Math.min(g.W, FRAME_W - left) - cl, ch = Math.min(g.H, FRAME_H - top) - ct;
|
|
313
|
+
if (cw > 0 && ch > 0) {
|
|
314
|
+
const part = cl || ct || cw !== g.W || ch !== g.H
|
|
315
|
+
? await sharp(g.buf, { raw: { width: g.W, height: g.H, channels: 4 } }).extract({ left: cl, top: ct, width: cw, height: ch }).raw().toBuffer()
|
|
316
|
+
: g.buf;
|
|
317
|
+
layers.push({ input: part, raw: { width: cw, height: ch, channels: 4 }, left: left + cl, top: top + ct });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return img.composite(layers).ensureAlpha().raw().toBuffer();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const IN_FLIGHT = 6;
|
|
324
|
+
let chunks = [], have = 0, n = 0, written = 0;
|
|
325
|
+
const queue = new Map();
|
|
326
|
+
let flushing = Promise.resolve();
|
|
327
|
+
const writeInOrder = async () => {
|
|
328
|
+
while (queue.has(written)) {
|
|
329
|
+
const buf = await queue.get(written);
|
|
330
|
+
queue.delete(written);
|
|
331
|
+
if (!enc.stdin.write(buf)) await new Promise((r) => enc.stdin.once("drain", r));
|
|
332
|
+
written++;
|
|
333
|
+
if (written % 150 === 0) process.stdout.write(` rendered ${(written / FPS).toFixed(0)}s / ${outDur.toFixed(0)}s\n`);
|
|
334
|
+
}
|
|
335
|
+
if (queue.size < IN_FLIGHT) dec.stdout.resume();
|
|
336
|
+
};
|
|
337
|
+
dec.stdout.on("data", (chunk) => {
|
|
338
|
+
chunks.push(chunk); have += chunk.length;
|
|
339
|
+
while (have >= FRAME_BYTES) {
|
|
340
|
+
// One copy per frame: join exactly FRAME_BYTES, keep the remainder.
|
|
341
|
+
const all = Buffer.concat(chunks, have);
|
|
342
|
+
const frame = all.subarray(0, FRAME_BYTES);
|
|
343
|
+
const rest = all.subarray(FRAME_BYTES);
|
|
344
|
+
chunks = rest.length ? [rest] : []; have = rest.length;
|
|
345
|
+
if (n < totalFrames) queue.set(n, renderFrame(frame, n));
|
|
346
|
+
n++;
|
|
347
|
+
}
|
|
348
|
+
if (queue.size >= IN_FLIGHT) dec.stdout.pause();
|
|
349
|
+
flushing = flushing.then(writeInOrder);
|
|
350
|
+
});
|
|
351
|
+
await new Promise((res, rej) => { dec.on("close", (c) => (c === 0 ? res() : rej(new Error(`decoder exited ${c}`)))); });
|
|
352
|
+
await flushing.then(writeInOrder);
|
|
353
|
+
enc.stdin.end();
|
|
354
|
+
await new Promise((res, rej) => enc.on("close", (c) => (c === 0 ? res() : rej(new Error(`encoder exited ${c}`)))));
|
|
355
|
+
say(`✓ ${outPath} (${probe(outPath).toFixed(1)}s, ${written} frames${BLUR ? `, ${blurredFrames} motion-blurred` : ""}, rendered in ${((Date.now() - T0) / 1000).toFixed(0)}s)`);
|