demobite 0.0.1
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/LICENSE +21 -0
- package/README.md +58 -0
- package/launcher/index.mjs +84 -0
- package/package.json +38 -0
- package/recorder/SKILL.md +30 -0
- package/recorder/STANDALONE-ENDING.md +34 -0
- package/recorder/scripts/frame.mjs +58 -0
- package/recorder/scripts/mux.mjs +81 -0
- package/recorder/scripts/post.sh +26 -0
- package/recorder/scripts/tts.mjs +78 -0
- package/scripts/calibrate.mjs +267 -0
- package/scripts/record.mjs +706 -0
- package/scripts/trim.mjs +72 -0
- package/skill/SKILL.md +289 -0
- package/skill/scripts/login.mjs +150 -0
- package/skill/scripts/manifest.mjs +321 -0
- package/skill/scripts/upload.mjs +259 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// DemoBites ending — convert the recorder's internal manifest into the FIXED
|
|
3
|
+
// wire schema (version 2) that /api/recorder/ingest expects:
|
|
4
|
+
//
|
|
5
|
+
// { version:2, app, title, frame:{width:1920,height:1080},
|
|
6
|
+
// duration, // seconds of the UPLOADED file (clean.mp4)
|
|
7
|
+
// steps:[{ n, action:'goto'|'settle'|'click'|'scroll'|'hover', label,
|
|
8
|
+
// t_start, t_end,
|
|
9
|
+
// on_screen?, // what the viewer sees
|
|
10
|
+
// click?:{x,y,t}, // frame px + seconds
|
|
11
|
+
// narration?:{text,t,estimated_duration} }],
|
|
12
|
+
// camera:[{ t_start, t_end, x, y, w, h, label }] } // focus rectangles
|
|
13
|
+
//
|
|
14
|
+
// EVERY time is relative to the uploaded file: record_from is subtracted and
|
|
15
|
+
// the result clamped at 0.
|
|
16
|
+
//
|
|
17
|
+
// The two v2 additions are the whole point of the lane. `on_screen` rides into
|
|
18
|
+
// the ingestion's rescripting stage so the narration is written knowing what is
|
|
19
|
+
// on screen. `camera` replaces click-derived zooms: the backend derives each
|
|
20
|
+
// factor from the rectangle's size and chains the shots so the camera travels
|
|
21
|
+
// between them instead of pulling out.
|
|
22
|
+
//
|
|
23
|
+
// Narration estimated_duration stays an ESTIMATE and nothing downstream trusts
|
|
24
|
+
// it as final — the ingestion rescripts and refits the script to the video.
|
|
25
|
+
//
|
|
26
|
+
// Usage: node manifest.mjs <takeDir> [--title "My title"] [--srt]
|
|
27
|
+
// Writes <takeDir>/manifest.demobites.json (and captions.srt with --srt).
|
|
28
|
+
import fs from "node:fs";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
31
|
+
|
|
32
|
+
const args = process.argv.slice(2);
|
|
33
|
+
const dir = args[0];
|
|
34
|
+
if (!dir || dir.startsWith("--")) {
|
|
35
|
+
console.error('Usage: node manifest.mjs <takeDir> [--title "My title"] [--srt]');
|
|
36
|
+
process.exit(2);
|
|
37
|
+
}
|
|
38
|
+
let titleArg = null;
|
|
39
|
+
let wantSrt = false;
|
|
40
|
+
for (let i = 1; i < args.length; i++) {
|
|
41
|
+
if (args[i] === "--title") titleArg = args[++i];
|
|
42
|
+
else if (args[i] === "--srt") wantSrt = true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const manPath = path.join(dir, "manifest.json");
|
|
46
|
+
if (!fs.existsSync(manPath)) { console.error(`${manPath} not found. Run record.mjs first.`); process.exit(1); }
|
|
47
|
+
const man = JSON.parse(fs.readFileSync(manPath, "utf8"));
|
|
48
|
+
const round2 = (x) => Math.round(x * 100) / 100;
|
|
49
|
+
|
|
50
|
+
// TIMEBASE — see the law in trim.mjs. Every time in manifest.json is WALL
|
|
51
|
+
// CLOCK; the encoder does not run at real time, so wall times land early and
|
|
52
|
+
// the error GROWS through the take. trim.mjs stamps the calibration it used;
|
|
53
|
+
// reuse that exact number so the trim point and every timestamp share one
|
|
54
|
+
// coordinate system. Recomputing here would risk them disagreeing.
|
|
55
|
+
const tb = man.timebase;
|
|
56
|
+
if (!tb) {
|
|
57
|
+
console.error(
|
|
58
|
+
"manifest.json has no timebase stamp. Run trim.mjs then calibrate.mjs —\n" +
|
|
59
|
+
"calibrate solves wall-to-video against the video itself, and every\n" +
|
|
60
|
+
"timestamp in this file depends on it.",
|
|
61
|
+
);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
// clean_time = a * wall + b, solved by calibrate.mjs against the video itself.
|
|
65
|
+
const A = tb.a ?? tb.k;
|
|
66
|
+
const B = tb.b ?? -(tb.videoRecordFrom ?? 0);
|
|
67
|
+
const norm = (x) => round2(Math.max(0, A * (x ?? 0) + B));
|
|
68
|
+
|
|
69
|
+
// Duration of the UPLOADED file: probe clean.mp4 when it exists (exact),
|
|
70
|
+
// fall back to the internal duration minus the trim.
|
|
71
|
+
let duration = round2(Math.max(0, A * (man.duration ?? 0) + B));
|
|
72
|
+
const cleanPath = path.join(dir, "clean.mp4");
|
|
73
|
+
if (fs.existsSync(cleanPath)) {
|
|
74
|
+
try {
|
|
75
|
+
duration = round2(parseFloat(execFileSync("ffprobe", [
|
|
76
|
+
"-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", cleanPath,
|
|
77
|
+
]).toString().trim()));
|
|
78
|
+
} catch {
|
|
79
|
+
console.error("ffprobe unavailable, using computed duration", duration);
|
|
80
|
+
}
|
|
81
|
+
} else {
|
|
82
|
+
console.error("Note: clean.mp4 not found, using computed duration. Run trim.mjs before uploading.");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Measured narration durations from standalone TTS, when present.
|
|
86
|
+
const measured = new Map();
|
|
87
|
+
const ttsPath = path.join(dir, "tts", "tts.json");
|
|
88
|
+
if (fs.existsSync(ttsPath)) {
|
|
89
|
+
for (const seg of JSON.parse(fs.readFileSync(ttsPath, "utf8"))) {
|
|
90
|
+
if (seg.n != null && seg.duration != null) measured.set(seg.n, seg.duration);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const estimate = (text) =>
|
|
94
|
+
round2(text.trim().split(/\s+/).filter(Boolean).length / 2.6 + 0.4);
|
|
95
|
+
|
|
96
|
+
// A narration line's anchor is when its beat is ESTABLISHED, not when the
|
|
97
|
+
// cursor departs toward it (founder drift analysis, 2026-08-09: "when you
|
|
98
|
+
// talked about upvoting your cursor was already on Visit Website"). For a
|
|
99
|
+
// hover/click the beat is established on ARRIVAL — the shot's measured
|
|
100
|
+
// glide.t_end. For settle/scroll nothing travels, so the step start is right.
|
|
101
|
+
const arrivalByStep = new Map();
|
|
102
|
+
for (const sh of man.shots ?? []) {
|
|
103
|
+
if (sh?.n != null && sh.glide && Number.isFinite(sh.glide.t_end) && !sh.revealed) {
|
|
104
|
+
// Earliest (approach) shot per step carries the true arrival.
|
|
105
|
+
if (!arrivalByStep.has(sh.n)) arrivalByStep.set(sh.n, sh.glide.t_end);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const steps = (man.steps ?? []).map((s) => {
|
|
110
|
+
const out = {
|
|
111
|
+
n: s.n,
|
|
112
|
+
action: s.action,
|
|
113
|
+
label: s.label ?? "",
|
|
114
|
+
t_start: norm(s.t_start),
|
|
115
|
+
t_end: norm(s.t_end),
|
|
116
|
+
};
|
|
117
|
+
if (s.on_screen) out.on_screen = s.on_screen;
|
|
118
|
+
if (s.action === "click" && s.target) {
|
|
119
|
+
out.click = { x: s.target.x, y: s.target.y, t: norm(s.click_at ?? s.t_start) };
|
|
120
|
+
}
|
|
121
|
+
const text = s.narration?.text ?? (typeof s.narration === "string" ? s.narration : null);
|
|
122
|
+
if (text) {
|
|
123
|
+
const arrival = (s.action === "hover" || s.action === "click") && arrivalByStep.has(s.n)
|
|
124
|
+
? arrivalByStep.get(s.n)
|
|
125
|
+
: s.t_start;
|
|
126
|
+
out.narration = {
|
|
127
|
+
text,
|
|
128
|
+
t: norm(arrival),
|
|
129
|
+
estimated_duration: measured.get(s.n) ?? estimate(text),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// ── LAW (breathing room, founder 2026-08-09): narration never speaks over
|
|
136
|
+
// the first or last half second. A voice at t=0 startles; a voice cut by the
|
|
137
|
+
// end reads broken. First line starts at >=0.5s; the last line ENDS at
|
|
138
|
+
// <=duration-0.5s, shifted back as needed but never into its predecessor.
|
|
139
|
+
{
|
|
140
|
+
const narrated = steps.filter((s) => s.narration);
|
|
141
|
+
if (narrated.length > 0) {
|
|
142
|
+
// Bump EVERY narration to >=0.5 and keep ordering — the trim's zero-clamp
|
|
143
|
+
// can pile several early narrations at t~0, and bumping only the first
|
|
144
|
+
// would invert it past the second (review finding, 2026-08-09).
|
|
145
|
+
let prevStart = -1;
|
|
146
|
+
for (const st of narrated) {
|
|
147
|
+
st.narration.t = round2(Math.max(st.narration.t, 0.5, prevStart + 0.1));
|
|
148
|
+
prevStart = st.narration.t;
|
|
149
|
+
}
|
|
150
|
+
const last = narrated[narrated.length - 1];
|
|
151
|
+
const latestStart = round2(duration - 0.5 - last.narration.estimated_duration);
|
|
152
|
+
if (last.narration.t > latestStart) {
|
|
153
|
+
// Floor at the predecessor's END, not its start — shifting the final
|
|
154
|
+
// line back onto still-playing speech breaks the SRT path.
|
|
155
|
+
const prev = narrated.length > 1 ? narrated[narrated.length - 2].narration : null;
|
|
156
|
+
const floor = prev ? prev.t + prev.estimated_duration + 0.1 : 0.5;
|
|
157
|
+
last.narration.t = round2(Math.max(floor, latestStart));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The camera path, normalized onto the uploaded file's timeline. Shots that
|
|
163
|
+
// fall entirely before record_from (the load we trim away) are dropped; a shot
|
|
164
|
+
// straddling it is clipped to the start of the cut. `n` links a shot to its
|
|
165
|
+
// step; `glide` is the MEASURED cursor flight window — the server's camera
|
|
166
|
+
// regime ladder (merge / pan / trombone) plans transitions from it.
|
|
167
|
+
const camera = (man.shots ?? [])
|
|
168
|
+
.map((s) => ({
|
|
169
|
+
t_start: norm(s.t_start),
|
|
170
|
+
t_end: norm(s.t_end),
|
|
171
|
+
x: s.x, y: s.y, w: s.w, h: s.h,
|
|
172
|
+
label: s.label ?? undefined,
|
|
173
|
+
...(s.n != null ? { n: s.n } : {}),
|
|
174
|
+
...(s.revealed ? { revealed: true } : {}),
|
|
175
|
+
...(s.glide ? { glide: { t_start: norm(s.glide.t_start), t_end: norm(s.glide.t_end) } } : {}),
|
|
176
|
+
}))
|
|
177
|
+
.filter((s) => s.t_end > s.t_start && s.t_start < duration && s.w > 0 && s.h > 0)
|
|
178
|
+
.map((s) => ({ ...s, t_end: Math.min(s.t_end, duration) }))
|
|
179
|
+
.sort((a, b) => a.t_start - b.t_start);
|
|
180
|
+
|
|
181
|
+
// ── LAW (the subject is the button plus what the button did, founder
|
|
182
|
+
// 2026-08-09): a click's camera subject must contain its CONSEQUENCE. The
|
|
183
|
+
// overlay detector (revealedBox) catches dialogs and menus; what it misses is
|
|
184
|
+
// an in-place change far from the click — nav item top-left, content pane
|
|
185
|
+
// swaps on the right. The pixels are the truth: diff a frame just before the
|
|
186
|
+
// click against the settled frame after it, bound the changed region with
|
|
187
|
+
// cropdetect, and widen the click's control shot to the union. The server's
|
|
188
|
+
// factor rule then produces the wider framing (3x -> ~1.5x) on its own.
|
|
189
|
+
// Steps that DID reveal an overlay keep the proven tight-shot -> overlay-shot
|
|
190
|
+
// pair; the diff only speaks when the overlay detector was silent.
|
|
191
|
+
if (fs.existsSync(cleanPath)) {
|
|
192
|
+
const SS = man.supersample ?? 1;
|
|
193
|
+
const measure = (tPre, tPost) => {
|
|
194
|
+
// One pass yields both the changed-region bbox (cropdetect) and the
|
|
195
|
+
// actual changed-pixel fraction (threshold + signalstats YAVG). The
|
|
196
|
+
// count exists because the bbox alone lies: two tiny unrelated changes
|
|
197
|
+
// far apart (caret + spinner) span a huge, nearly-empty bbox (review
|
|
198
|
+
// finding, 2026-08-09).
|
|
199
|
+
const res = spawnSync("ffmpeg", [
|
|
200
|
+
"-loglevel", "info",
|
|
201
|
+
"-ss", String(Math.max(0, tPre)), "-i", cleanPath,
|
|
202
|
+
"-ss", String(Math.min(duration - 0.05, tPost)), "-i", cleanPath,
|
|
203
|
+
"-filter_complex", "[0:v]format=rgb24[a];[1:v]format=rgb24[b];[a][b]lut2=c0='abs(x-y)':c1='abs(x-y)':c2='abs(x-y)',format=gray,cropdetect=limit=2:round=2:reset=1,lutyuv=y='if(gt(val,8),255,0)',signalstats,metadata=print",
|
|
204
|
+
"-frames:v", "4", "-f", "null", "-",
|
|
205
|
+
], { encoding: "utf8", timeout: 15000 });
|
|
206
|
+
if (res.error || res.status !== 0) {
|
|
207
|
+
console.error(`consequence: ffmpeg diff failed (${res.error?.message ?? `exit ${res.status}`}) — skipping`);
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
const err = `${res.stderr ?? ""}`;
|
|
211
|
+
const m = [...err.matchAll(/crop=(\d+):(\d+):(\d+):(\d+)/g)].pop();
|
|
212
|
+
const yavg = [...err.matchAll(/lavfi\.signalstats\.YAVG=([0-9.]+)/g)].pop();
|
|
213
|
+
if (!m) return null;
|
|
214
|
+
const [w, h, x, y] = m.slice(1).map(Number);
|
|
215
|
+
if (!(w > 0 && h > 0)) return null;
|
|
216
|
+
const changedPx = yavg ? (parseFloat(yavg[1]) / 255) * 1920 * 1080 * SS * SS : 0;
|
|
217
|
+
return { x: x / SS, y: y / SS, w: w / SS, h: h / SS, changedPx: changedPx / (SS * SS) };
|
|
218
|
+
};
|
|
219
|
+
for (const st of steps) {
|
|
220
|
+
if (!st.click) continue;
|
|
221
|
+
const hasRevealedShot = camera.some((c) => c.n === st.n && c.revealed);
|
|
222
|
+
if (hasRevealedShot) continue;
|
|
223
|
+
const control = camera.find((c) => c.n === st.n && !c.revealed);
|
|
224
|
+
if (!control) continue;
|
|
225
|
+
// Post frame: as late as the step's OWN window allows (just before the
|
|
226
|
+
// next glide starts), so a SLOW navigation that renders a second after
|
|
227
|
+
// the click still registers — Product Hunt renders ~1.5s late. Capped at
|
|
228
|
+
// the step boundary so the next step's hover flashes never pollute it.
|
|
229
|
+
const change = measure(st.click.t - 0.15, Math.max(st.click.t + 0.4, st.t_end - 0.1));
|
|
230
|
+
if (!change) continue;
|
|
231
|
+
// Real consequence, not noise: enough changed pixels, dense enough that
|
|
232
|
+
// the bbox is one coherent region, and not a whole-page swap (navigation
|
|
233
|
+
// wides are the camera ladder's job, not the subject's).
|
|
234
|
+
if (change.changedPx < 1200) continue;
|
|
235
|
+
if (change.changedPx / (change.w * change.h) < 0.04) {
|
|
236
|
+
console.log(`consequence: step ${st.n} change too sparse (${Math.round(change.changedPx)}px over ${Math.round(change.w)}x${Math.round(change.h)}) — ignored as noise`);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (change.w * change.h > 1920 * 1080 * 0.85) {
|
|
240
|
+
// A navigation (the whole page swapped). The tight control shot must
|
|
241
|
+
// NOT linger clamped over the new page — reset to wide, so the camera
|
|
242
|
+
// "zooms back out" at the cut (founder, 2026-08-09: "you never zoomed
|
|
243
|
+
// back"). Shrink the control shot to end at the click, and drop a wide
|
|
244
|
+
// full-frame shot across the arrival of the new page; the ladder's
|
|
245
|
+
// continuity then renders the pull-out as the page changes.
|
|
246
|
+
control.t_end = Math.max(control.t_start + 0.4, Math.min(control.t_end, st.click.t + 0.2));
|
|
247
|
+
const wideStart = round2(st.click.t);
|
|
248
|
+
const wideEnd = round2(Math.min(duration, st.t_end));
|
|
249
|
+
if (wideEnd - wideStart >= 0.6) {
|
|
250
|
+
camera.push({ t_start: wideStart, t_end: wideEnd, x: 0, y: 0, w: 1920, h: 1080, n: st.n, label: `${st.label || "click"}, new page` });
|
|
251
|
+
camera.sort((a, b) => a.t_start - b.t_start);
|
|
252
|
+
}
|
|
253
|
+
console.log(`consequence: step ${st.n} navigation — reset to wide at ${wideStart}s`);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const x1 = Math.min(control.x, change.x);
|
|
257
|
+
const y1 = Math.min(control.y, change.y);
|
|
258
|
+
const x2 = Math.max(control.x + control.w, change.x + change.w);
|
|
259
|
+
const y2 = Math.max(control.y + control.h, change.y + change.h);
|
|
260
|
+
console.log(
|
|
261
|
+
`consequence: step ${st.n} click changed ${change.w}x${change.h}@(${change.x},${change.y}) — ` +
|
|
262
|
+
`subject widened ${control.w}x${control.h} -> ${x2 - x1}x${y2 - y1}`,
|
|
263
|
+
);
|
|
264
|
+
control.x = x1; control.y = y1; control.w = x2 - x1; control.h = y2 - y1;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Cursor track, normalized onto the uploaded file's timeline. Events recorded
|
|
269
|
+
// during the load we trim away are dropped; everything else shifts by the same
|
|
270
|
+
// record_from the camera path uses. Getting this wrong makes the cursor lag the
|
|
271
|
+
// video by the whole trim, which looks like a broken render rather than a
|
|
272
|
+
// timing bug.
|
|
273
|
+
const rawEvents = man.interactions?.mouseEvents ?? [];
|
|
274
|
+
const mouseEvents = rawEvents
|
|
275
|
+
.map((e) => ({ ...e, time: round2(A * (e.time ?? 0) + B) }))
|
|
276
|
+
.filter((e) => e.time >= 0 && e.time <= duration);
|
|
277
|
+
const interactions = mouseEvents.length
|
|
278
|
+
? {
|
|
279
|
+
viewport: man.interactions?.viewport ?? { width: 1920, height: 1080 },
|
|
280
|
+
mouseEvents,
|
|
281
|
+
}
|
|
282
|
+
: null;
|
|
283
|
+
|
|
284
|
+
const wire = {
|
|
285
|
+
version: 2,
|
|
286
|
+
app: man.app ?? "App",
|
|
287
|
+
title: titleArg ?? man.title ?? `${man.app ?? "App"} demo`,
|
|
288
|
+
frame: { width: 1920, height: 1080 },
|
|
289
|
+
duration,
|
|
290
|
+
steps,
|
|
291
|
+
camera,
|
|
292
|
+
...(interactions ? { interactions } : {}),
|
|
293
|
+
};
|
|
294
|
+
const outPath = path.join(dir, "manifest.demobites.json");
|
|
295
|
+
fs.writeFileSync(outPath, JSON.stringify(wire, null, 2));
|
|
296
|
+
console.log(
|
|
297
|
+
`manifest.demobites.json written (v2: ${steps.length} steps, ${camera.length} camera shots, ` +
|
|
298
|
+
`${mouseEvents.length} mouse events, ${duration}s)`,
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
if (wantSrt) {
|
|
302
|
+
const tc = (sec) => {
|
|
303
|
+
const ms = Math.round(sec * 1000);
|
|
304
|
+
const h = String(Math.floor(ms / 3600000)).padStart(2, "0");
|
|
305
|
+
const m = String(Math.floor((ms % 3600000) / 60000)).padStart(2, "0");
|
|
306
|
+
const s = String(Math.floor((ms % 60000) / 1000)).padStart(2, "0");
|
|
307
|
+
const f = String(ms % 1000).padStart(3, "0");
|
|
308
|
+
return `${h}:${m}:${s},${f}`;
|
|
309
|
+
};
|
|
310
|
+
const lines = [];
|
|
311
|
+
let i = 0;
|
|
312
|
+
for (const st of steps) {
|
|
313
|
+
if (!st.narration) continue;
|
|
314
|
+
i += 1;
|
|
315
|
+
const start = st.narration.t;
|
|
316
|
+
const end = Math.min(duration, start + st.narration.estimated_duration);
|
|
317
|
+
lines.push(String(i), `${tc(start)} --> ${tc(end)}`, st.narration.text, "");
|
|
318
|
+
}
|
|
319
|
+
fs.writeFileSync(path.join(dir, "captions.srt"), lines.join("\n"));
|
|
320
|
+
console.log(`captions.srt written (${i} cues)`);
|
|
321
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// DemoBites ending — STAGE the take and hand the word to the product.
|
|
3
|
+
//
|
|
4
|
+
// The human word lives in the app now (founder, 2026-08-09): this script
|
|
5
|
+
// zips clean.mp4, stages it together with a playable preview MP4, opens the
|
|
6
|
+
// in-app preview page, and POLLS while the human decides there. Approve on
|
|
7
|
+
// that page runs the same ingest as the old machine lane; Discard reports
|
|
8
|
+
// back here so the operator adjusts and refilms. No local review.html for
|
|
9
|
+
// this ending anymore.
|
|
10
|
+
//
|
|
11
|
+
// Contracts (fixed, coded verbatim):
|
|
12
|
+
// PUT <base>/api/recorder/stage (Bearer)
|
|
13
|
+
// { filename, sizeBytes, previewSizeBytes, manifest }
|
|
14
|
+
// -> { stagingId, uploadUrl, previewUploadUrl, videoKey, previewUrl }
|
|
15
|
+
// GET <base>/api/recorder/stage?id=<stagingId> (Bearer)
|
|
16
|
+
// -> { status, biteId, biteUKey, biteStatus, studioUrl }
|
|
17
|
+
//
|
|
18
|
+
// Usage: node upload.mjs <takeDir>
|
|
19
|
+
// Requires .recorder/config.json with base + api_key (run login.mjs first),
|
|
20
|
+
// <takeDir>/clean.mp4 (trim.mjs) and <takeDir>/manifest.demobites.json
|
|
21
|
+
// (manifest.mjs).
|
|
22
|
+
import fs from "node:fs";
|
|
23
|
+
import os from "node:os";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { spawnSync } from "node:child_process";
|
|
26
|
+
|
|
27
|
+
const dir = process.argv[2];
|
|
28
|
+
if (!dir) {
|
|
29
|
+
console.error("Usage: node upload.mjs <takeDir>");
|
|
30
|
+
process.exit(2);
|
|
31
|
+
}
|
|
32
|
+
const cfgPath = path.resolve(".recorder", "config.json");
|
|
33
|
+
let cfg = {};
|
|
34
|
+
try { cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {}
|
|
35
|
+
if (!cfg.api_key || !cfg.base) {
|
|
36
|
+
console.error("No recorder key. Run: node scripts/login.mjs");
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
const base = cfg.base.replace(/\/+$/, "");
|
|
40
|
+
|
|
41
|
+
const cleanPath = path.join(dir, "clean.mp4");
|
|
42
|
+
const wirePath = path.join(dir, "manifest.demobites.json");
|
|
43
|
+
if (!fs.existsSync(cleanPath)) { console.error(`${cleanPath} not found. Run: node scripts/trim.mjs ${dir}`); process.exit(1); }
|
|
44
|
+
if (!fs.existsSync(wirePath)) { console.error(`${wirePath} not found. Run: node scripts/manifest.mjs ${dir}`); process.exit(1); }
|
|
45
|
+
const manifest = JSON.parse(fs.readFileSync(wirePath, "utf8"));
|
|
46
|
+
|
|
47
|
+
// ── take.zip: clean.mp4 stored as recording.mp4, and NOTHING else ──────────
|
|
48
|
+
function crc32(buf) {
|
|
49
|
+
let table = crc32.table;
|
|
50
|
+
if (!table) {
|
|
51
|
+
table = crc32.table = new Uint32Array(256);
|
|
52
|
+
for (let n = 0; n < 256; n++) {
|
|
53
|
+
let c = n;
|
|
54
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
55
|
+
table[n] = c >>> 0;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
let crc = 0xffffffff;
|
|
59
|
+
for (let i = 0; i < buf.length; i++) crc = table[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
|
|
60
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
61
|
+
}
|
|
62
|
+
// Archiver-free STORE method zip: one entry, no compression, plain builtins.
|
|
63
|
+
function storeZip(name, data) {
|
|
64
|
+
const nameBuf = Buffer.from(name, "utf8");
|
|
65
|
+
const crc = crc32(data);
|
|
66
|
+
const now = new Date();
|
|
67
|
+
const dosTime = ((now.getHours() << 11) | (now.getMinutes() << 5) | (now.getSeconds() >> 1)) & 0xffff;
|
|
68
|
+
const dosDate = (((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate()) & 0xffff;
|
|
69
|
+
const local = Buffer.alloc(30);
|
|
70
|
+
local.writeUInt32LE(0x04034b50, 0);
|
|
71
|
+
local.writeUInt16LE(20, 4); // version needed
|
|
72
|
+
local.writeUInt16LE(dosTime, 10);
|
|
73
|
+
local.writeUInt16LE(dosDate, 12);
|
|
74
|
+
local.writeUInt32LE(crc, 14);
|
|
75
|
+
local.writeUInt32LE(data.length, 18); // compressed size (store = raw)
|
|
76
|
+
local.writeUInt32LE(data.length, 22); // uncompressed size
|
|
77
|
+
local.writeUInt16LE(nameBuf.length, 26);
|
|
78
|
+
const central = Buffer.alloc(46);
|
|
79
|
+
central.writeUInt32LE(0x02014b50, 0);
|
|
80
|
+
central.writeUInt16LE(20, 4); // version made by
|
|
81
|
+
central.writeUInt16LE(20, 6); // version needed
|
|
82
|
+
central.writeUInt16LE(dosTime, 12);
|
|
83
|
+
central.writeUInt16LE(dosDate, 14);
|
|
84
|
+
central.writeUInt32LE(crc, 16);
|
|
85
|
+
central.writeUInt32LE(data.length, 20);
|
|
86
|
+
central.writeUInt32LE(data.length, 24);
|
|
87
|
+
central.writeUInt16LE(nameBuf.length, 28);
|
|
88
|
+
const cdOffset = 30 + nameBuf.length + data.length;
|
|
89
|
+
const cdSize = 46 + nameBuf.length;
|
|
90
|
+
const eocd = Buffer.alloc(22);
|
|
91
|
+
eocd.writeUInt32LE(0x06054b50, 0);
|
|
92
|
+
eocd.writeUInt16LE(1, 8); // entries on this disk
|
|
93
|
+
eocd.writeUInt16LE(1, 10); // entries total
|
|
94
|
+
eocd.writeUInt32LE(cdSize, 12);
|
|
95
|
+
eocd.writeUInt32LE(cdOffset, 16);
|
|
96
|
+
return Buffer.concat([local, nameBuf, data, central, nameBuf, eocd]);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const zipPath = path.join(dir, "take.zip");
|
|
100
|
+
fs.rmSync(zipPath, { force: true });
|
|
101
|
+
const staging = fs.mkdtempSync(path.join(os.tmpdir(), "rec-zip-"));
|
|
102
|
+
const staged = path.join(staging, "recording.mp4");
|
|
103
|
+
fs.copyFileSync(cleanPath, staged);
|
|
104
|
+
let zipped = false;
|
|
105
|
+
const zipBin = spawnSync("zip", ["-j", "-X", "-q", zipPath, staged], { stdio: "ignore" });
|
|
106
|
+
if (zipBin.status === 0 && fs.existsSync(zipPath)) zipped = true;
|
|
107
|
+
if (!zipped) {
|
|
108
|
+
const data = fs.readFileSync(cleanPath);
|
|
109
|
+
if (data.length >= 0xfffffffe) {
|
|
110
|
+
console.error("clean.mp4 is 4GB or larger. Install the zip binary and rerun.");
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
fs.writeFileSync(zipPath, storeZip("recording.mp4", data));
|
|
114
|
+
}
|
|
115
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
116
|
+
const sizeBytes = fs.statSync(zipPath).size;
|
|
117
|
+
console.log(`take.zip ready (${(sizeBytes / 1024 / 1024).toFixed(1)} MB, ${zipped ? "system zip" : "store method"})`);
|
|
118
|
+
|
|
119
|
+
// ── stage ──────────────────────────────────────────────────────────────────
|
|
120
|
+
const authHeaders = { Authorization: `Bearer ${cfg.api_key}`, "Content-Type": "application/json" };
|
|
121
|
+
const previewSizeBytes = fs.statSync(cleanPath).size;
|
|
122
|
+
let stageRes;
|
|
123
|
+
try {
|
|
124
|
+
stageRes = await fetch(`${base}/api/recorder/stage`, {
|
|
125
|
+
method: "PUT",
|
|
126
|
+
headers: authHeaders,
|
|
127
|
+
body: JSON.stringify({ filename: "take.zip", sizeBytes, previewSizeBytes, manifest }),
|
|
128
|
+
});
|
|
129
|
+
} catch (e) {
|
|
130
|
+
console.error(`Could not reach ${base}: ${e.message}`);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
if (stageRes.status === 401) {
|
|
134
|
+
console.error("Recorder key missing or revoked. Run: node scripts/login.mjs");
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
if (!stageRes.ok) {
|
|
138
|
+
const errBody = await stageRes.json().catch(() => null);
|
|
139
|
+
if (errBody?.error === "quota_exceeded") {
|
|
140
|
+
// Should not happen anymore — staging is quota-free by design. Neutral
|
|
141
|
+
// fallback if an older server answers this way.
|
|
142
|
+
console.error(`DemoBites declined the stage. Check ${base}/bites and try again.`);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
console.error(`Stage failed: ${stageRes.status} ${errBody ? JSON.stringify(errBody) : ""}`);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
const { stagingId, uploadUrl, previewUploadUrl, previewUrl, queueUrl, pendingCount } = await stageRes.json();
|
|
149
|
+
if (!stagingId || !uploadUrl || !previewUploadUrl || !previewUrl) {
|
|
150
|
+
console.error("Stage response missing fields.");
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── S3 PUTs: the ZIP for ingestion, the MP4 for the preview player ─────────
|
|
155
|
+
console.log("Uploading take...");
|
|
156
|
+
async function putS3(url, contentType, filePath, label) {
|
|
157
|
+
let res;
|
|
158
|
+
try {
|
|
159
|
+
res = await fetch(url, { method: "PUT", headers: { "Content-Type": contentType }, body: fs.readFileSync(filePath) });
|
|
160
|
+
} catch (e) {
|
|
161
|
+
console.error(`${label} upload failed mid-transfer: ${e.message}. Check the network and rerun.`);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
if (!res.ok) { console.error(`${label} upload failed: ${res.status}`); process.exit(1); }
|
|
165
|
+
}
|
|
166
|
+
await putS3(uploadUrl, "application/zip", zipPath, "ZIP");
|
|
167
|
+
await putS3(previewUploadUrl, "video/mp4", cleanPath, "Preview");
|
|
168
|
+
|
|
169
|
+
// ── open the in-app preview — the review happens THERE ─────────────────────
|
|
170
|
+
const pageUrl = new URL(previewUrl, base).toString();
|
|
171
|
+
console.log(`Staged. Review and approve in the browser:\n ${pageUrl}`);
|
|
172
|
+
if (typeof pendingCount === "number" && pendingCount > 1 && queueUrl) {
|
|
173
|
+
console.log(`${pendingCount} takes are now waiting for review: ${new URL(queueUrl, base).toString()}`);
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
177
|
+
spawnSync(opener, [pageUrl], { stdio: "ignore" });
|
|
178
|
+
} catch { /* printing the URL above is the fallback */ }
|
|
179
|
+
|
|
180
|
+
// ── poll while the human decides, then until the bite is READY ─────────────
|
|
181
|
+
// LAW (founder 2026-08-08): never hand a human a studio link before the bite
|
|
182
|
+
// is finished. Approve only STARTS the pipeline.
|
|
183
|
+
const POLL_MS = 4000;
|
|
184
|
+
const DECISION_TIMEOUT_MS = 30 * 60 * 1000;
|
|
185
|
+
const deadline = Date.now() + DECISION_TIMEOUT_MS;
|
|
186
|
+
let announced = false;
|
|
187
|
+
let completed = false;
|
|
188
|
+
let approvedBiteId = null;
|
|
189
|
+
let finalStudioUrl = null;
|
|
190
|
+
process.stdout.write("Waiting for your word in the browser");
|
|
191
|
+
while (Date.now() < deadline) {
|
|
192
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
193
|
+
let res;
|
|
194
|
+
try {
|
|
195
|
+
res = await fetch(`${base}/api/recorder/stage?id=${stagingId}`, {
|
|
196
|
+
headers: { Authorization: `Bearer ${cfg.api_key}` },
|
|
197
|
+
});
|
|
198
|
+
} catch { process.stdout.write("."); continue; }
|
|
199
|
+
if (!res.ok) { process.stdout.write("."); continue; }
|
|
200
|
+
const st = await res.json().catch(() => null);
|
|
201
|
+
if (!st) { process.stdout.write("."); continue; }
|
|
202
|
+
if (st.status === "rejected") {
|
|
203
|
+
process.stdout.write("\n");
|
|
204
|
+
console.error("Discarded in the app. Adjust the storyboard and film again.");
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
if (st.status === "approved") {
|
|
208
|
+
if (!announced) {
|
|
209
|
+
process.stdout.write("\n");
|
|
210
|
+
console.log(`Approved — bite ${st.biteId} is being created`);
|
|
211
|
+
announced = true;
|
|
212
|
+
approvedBiteId = st.biteId;
|
|
213
|
+
finalStudioUrl = st.studioUrl ? new URL(st.studioUrl, base).toString() : null;
|
|
214
|
+
process.stdout.write("Waiting for the bite to finish");
|
|
215
|
+
}
|
|
216
|
+
if (st.biteStatus === "completed") {
|
|
217
|
+
completed = true;
|
|
218
|
+
process.stdout.write("\n");
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
if (st.biteStatus === "failed") {
|
|
222
|
+
process.stdout.write("\n");
|
|
223
|
+
console.error("The pipeline FAILED for this bite. Do not hand over any link — investigate.");
|
|
224
|
+
process.exit(1);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
process.stdout.write(".");
|
|
228
|
+
}
|
|
229
|
+
if (!announced) {
|
|
230
|
+
process.stdout.write("\n");
|
|
231
|
+
console.error(`No decision yet. The preview stays available at:\n ${pageUrl}`);
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
// LAW: the studio link exists ONLY behind a confirmed 'completed'. A deadline
|
|
235
|
+
// expiry after approval is NOT completion (review finding: the fallthrough
|
|
236
|
+
// here once printed the link for an unfinished bite).
|
|
237
|
+
if (!completed) {
|
|
238
|
+
console.error("Approved, but the bite did not finish within the wait window. Do not share the link yet — poll /api/recorder/status or reload the preview page.");
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── final receipt via the status endpoint (same gate as before) ────────────
|
|
243
|
+
let last = null;
|
|
244
|
+
try {
|
|
245
|
+
const res = await fetch(`${base}/api/recorder/status?biteId=${approvedBiteId}`, {
|
|
246
|
+
headers: { Authorization: `Bearer ${cfg.api_key}` },
|
|
247
|
+
});
|
|
248
|
+
if (res.ok) last = await res.json().catch(() => null);
|
|
249
|
+
} catch { /* summary is best-effort; readiness was confirmed above */ }
|
|
250
|
+
if (last && last.status === "completed") {
|
|
251
|
+
console.log(
|
|
252
|
+
`Ready: "${last.title}" — ${last.durationSec ? last.durationSec.toFixed(1) + "s, " : ""}` +
|
|
253
|
+
`${last.narrationReady}/${last.narrationTotal} narration segments with audio, ${last.zooms} camera shots`,
|
|
254
|
+
);
|
|
255
|
+
if (last.narrationTotal === 0) console.error("WARNING: no narration segments landed. The voice will be silent.");
|
|
256
|
+
else if (last.narrationReady < last.narrationTotal) console.error(`WARNING: ${last.narrationTotal - last.narrationReady} segment(s) have no audio behind them.`);
|
|
257
|
+
if (last.zooms === 0) console.error("WARNING: no camera shots landed.");
|
|
258
|
+
}
|
|
259
|
+
if (finalStudioUrl) console.log(`Studio: ${finalStudioUrl}`);
|