demobite 1.7.2 → 1.7.3
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/README.md +31 -0
- package/launcher/index.mjs +3 -2
- package/package.json +1 -1
- package/skill/SKILL.md +4 -3
- package/skill/scripts/camera.mjs +0 -350
- package/skill/scripts/cursors.mjs +0 -7
- package/skill/scripts/finish.mjs +0 -355
package/README.md
CHANGED
|
@@ -22,6 +22,37 @@ An illustrated walkthrough of the flow, with waiting time compressed.
|
|
|
22
22
|
|
|
23
23
|
## Start with your own agent
|
|
24
24
|
|
|
25
|
+
### Install as a plugin (Claude Code, Codex, Cursor)
|
|
26
|
+
|
|
27
|
+
The recorder ships as a plugin of this repository. Install it once, then run its setup skill: it installs everything (Playwright, ffmpeg, Chromium when needed) and connects your account with one approval in your browser.
|
|
28
|
+
|
|
29
|
+
**Claude Code**
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
/plugin marketplace add demobites/agentic-recorder
|
|
33
|
+
/plugin install demobites@demobites
|
|
34
|
+
/demobites:setup
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Then `/demobites:record`, `/demobites:login`, `/demobites:logout` — or just ask: "record a demo of the new billing page".
|
|
38
|
+
|
|
39
|
+
You can also skip the setup line: ask for a demo right after the install and the record skill sets the recorder up by itself (once per machine, no browser), asks for one "Go" before opening the approval link, and then films what you asked for. Until the project is connected, every new session opens with one line that says so.
|
|
40
|
+
|
|
41
|
+
**Codex**
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
codex plugin marketplace add demobites/agentic-recorder
|
|
45
|
+
codex plugin add demobites@demobites
|
|
46
|
+
$demobites-setup
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**Cursor**
|
|
50
|
+
|
|
51
|
+
Install **DemoBites** from the Cursor marketplace (Customize → Plugins), then `/demobites-setup`. Teams can also add this repository as a team marketplace.
|
|
52
|
+
|
|
53
|
+
### Or one command, any agent
|
|
54
|
+
|
|
55
|
+
|
|
25
56
|
You need a coding agent that can read skill instructions, run terminal commands, and control a local browser. A browser-only chat session cannot run this local workflow.
|
|
26
57
|
|
|
27
58
|
Before installing:
|
package/launcher/index.mjs
CHANGED
|
@@ -75,7 +75,7 @@ if (arg === "record") {
|
|
|
75
75
|
}
|
|
76
76
|
const cfg = readCfg();
|
|
77
77
|
if (!cfg?.api_key) {
|
|
78
|
-
console.log("
|
|
78
|
+
console.log("Before we record, this project needs a DemoBites account.\nRun `npx demobite login` (it prints a link to approve in your browser), then run this command again.");
|
|
79
79
|
process.exit(1);
|
|
80
80
|
}
|
|
81
81
|
const r = spawnSync("node", [briefsScript, "list", "--slug", slug], {
|
|
@@ -269,7 +269,8 @@ Cursor, Codex) in your project and ask:
|
|
|
269
269
|
"Record a demo of <your flow> and upload it to DemoBites"
|
|
270
270
|
"Create a release with my latest bites and add Spanish"
|
|
271
271
|
|
|
272
|
-
|
|
272
|
+
Before the first demo it connects this project to your DemoBites account —
|
|
273
|
+
one link to approve in your browser (or run now: npx demobite login).
|
|
273
274
|
Manage-by-agent needs the MCP: npx demobite mcp (once, after login).
|
|
274
275
|
Only the recorder, no DemoBites? See the open recorder in this package's repo.
|
|
275
276
|
`);
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -20,9 +20,10 @@ storyboard talk: check `.recorder/config.json` for `api_key`.
|
|
|
20
20
|
**The choreography when the key is missing (founder law, 2026-08-09 — never
|
|
21
21
|
surprise the human with a browser page):**
|
|
22
22
|
|
|
23
|
-
1. TELL, don't act: "
|
|
24
|
-
|
|
25
|
-
Nothing opens until the human gives the word.
|
|
23
|
+
1. TELL, don't act: "Before we record, connect this project to your
|
|
24
|
+
DemoBites account: say Go and I'll open the approval link." Then WAIT.
|
|
25
|
+
Nothing opens until the human gives the word. Keep the brief the human
|
|
26
|
+
gave: once connected, go straight on with it, never ask for it again.
|
|
26
27
|
2. On their word, run `node scripts/login.mjs`. It prints the link and code,
|
|
27
28
|
opens the approval page, and polls. The human approves in their own
|
|
28
29
|
signed-in browser session — this script never sees credentials. The key
|
package/skill/scripts/camera.mjs
DELETED
|
@@ -1,350 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,7 +0,0 @@
|
|
|
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
|
-
};
|
package/skill/scripts/finish.mjs
DELETED
|
@@ -1,355 +0,0 @@
|
|
|
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)`);
|