demobite 1.4.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,20 @@
1
1
  #!/usr/bin/env node
2
- // DemoBites ending — STAGE the take and hand the word to the product.
2
+ // DemoBites ending — STAGE the take and DELIVER it.
3
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.
4
+ // DIRECT DELIVERY (1.5.0, founder ruling 2026-09-25): this script zips
5
+ // clean.mp4, stages it together with a playable MP4, uploads both, then calls
6
+ // the uploaded door. The take becomes a bite by itself; the person watches it
7
+ // come in on their Demos grid (<base>/demos), never on the preview page. When
8
+ // the account has no recording minutes left the server KEEPS the take and it
9
+ // waits on the same grid, next to the upgrade door.
10
10
  //
11
11
  // Contracts (fixed, coded verbatim):
12
12
  // PUT <base>/api/recorder/stage (Bearer)
13
13
  // { filename, sizeBytes, previewSizeBytes, manifest }
14
14
  // -> { stagingId, uploadUrl, previewUploadUrl, videoKey, previewUrl }
15
+ // PUT <base>/api/recorder/stage/<stagingId>/uploaded (Bearer) after both uploads, EVERY take
16
+ // -> 202 { delivered: true, biteId, studioUrl, dashboardUrl }
17
+ // | 403 { kept: true, waiting: "minutes", resetsAt, dashboardUrl, stagingId }
15
18
  // GET <base>/api/recorder/stage?id=<stagingId> (Bearer)
16
19
  // -> { status, biteId, biteUKey, biteStatus, studioUrl }
17
20
  //
@@ -31,7 +34,7 @@ if (!dir) {
31
34
  }
32
35
  // RE-TAKE (2026-09-02): `--retake-of <biteId>` stages this take as a NEW RECORDING
33
36
  // of an existing bite instead of a new bite. The server keeps the bite's current
34
- // narration text, voice, intro/outro and look; the human approves in-app.
37
+ // narration text, voice, intro/outro and look; the take is delivered into it.
35
38
  const retakeIdx = process.argv.indexOf("--retake-of");
36
39
  const retakeOfBiteId = retakeIdx >= 0 ? Number(process.argv[retakeIdx + 1]) : null;
37
40
  if (retakeIdx >= 0 && !(Number.isInteger(retakeOfBiteId) && retakeOfBiteId > 0)) { console.error("--retake-of needs a bite id"); process.exit(2); }
@@ -47,11 +50,9 @@ const retakeNote = noteIdx >= 0 ? String(process.argv[noteIdx + 1] ?? "").trim()
47
50
  // opts out. `--stage-only` returns right after the two uploads (the batch
48
51
  // waits with status.mjs); `--supersede` replaces a stage already pinned to
49
52
  // this attempt (the server refuses a second one otherwise).
50
- // DELIVERY (1.3.0, founder ruling 2026-09-14): a take with an attempt becomes
51
- // a bite by itself. After both uploads this script PUTs the delivery route
52
- // (the claim's api.uploaded, fallback <base>/api/recorder/stage/<id>/uploaded)
53
- // and prints "delivered: bite <id>". No Approve click, no review queue for
54
- // brief batches. Free-prompt takes (no attempt) still stage for review.
53
+ // DELIVERY (1.3.0 for brief takes, 1.5.0 for every take): after both uploads
54
+ // this script PUTs the delivery route (the claim's api.uploaded when there is
55
+ // one, fallback <base>/api/recorder/stage/<id>/uploaded). No Approve click.
55
56
  const stageOnly = process.argv.includes("--stage-only");
56
57
  const supersede = process.argv.includes("--supersede");
57
58
  const attemptIdx = process.argv.indexOf("--attempt");
@@ -77,13 +78,18 @@ if (!cfg.api_key || !cfg.base) {
77
78
  process.exit(1);
78
79
  }
79
80
  const base = cfg.base.replace(/\/+$/, "");
80
- // A delivered take is a bite already; staging it again would make a second one.
81
+ // A delivered take is a bite already, and a kept take waits on the server;
82
+ // staging either again would make a second one.
81
83
  try {
82
84
  const prev = JSON.parse(fs.readFileSync(path.join(dir, "staged.json"), "utf8"));
83
85
  if (prev?.delivered === true && prev.biteId && !supersede) {
84
86
  console.error(`${dir} was already delivered as bite ${prev.biteId}. See where it stands: node scripts/status.mjs ${dir}. For a new take of it, stage again with --supersede.`);
85
87
  process.exit(1);
86
88
  }
89
+ if (prev?.waiting && !supersede) {
90
+ console.error(`${dir} is already kept in DemoBites, waiting for recording minutes. See where it stands: node scripts/status.mjs ${dir}. For a new take of it, stage again with --supersede.`);
91
+ process.exit(1);
92
+ }
87
93
  } catch { /* not staged yet */ }
88
94
 
89
95
  const cleanPath = path.join(dir, "clean.mp4");
@@ -193,7 +199,7 @@ if (!zipped) {
193
199
  fs.rmSync(staging, { recursive: true, force: true });
194
200
  const sizeBytes = fs.statSync(zipPath).size;
195
201
  console.log(`take.zip ready (${(sizeBytes / 1024 / 1024).toFixed(1)} MB, ${zipped ? "system zip" : "store method"})`);
196
- if (retakeOfBiteId) console.log(`Staging as a RE-TAKE of bite ${retakeOfBiteId} — the new recording replaces the current one inside that bite ${attempt ? "by itself once delivered" : "once approved"}.`);
202
+ if (retakeOfBiteId) console.log(`Staging as a RE-TAKE of bite ${retakeOfBiteId} — the new recording replaces the current one inside that bite by itself once delivered.`);
197
203
 
198
204
  // ── stage ──────────────────────────────────────────────────────────────────
199
205
  const authHeaders = { Authorization: `Bearer ${cfg.api_key}`, "Content-Type": "application/json" };
@@ -208,6 +214,11 @@ if (attempt) {
208
214
  } catch (e) { console.error(`attempt event "uploading" failed: ${e.message} (continuing)`); }
209
215
  }
210
216
  const previewSizeBytes = fs.statSync(cleanPath).size;
217
+ const { deliverStaged, waitForDecision, waitForRetake, readLiveRecipeFingerprint, gridUrl, waitingLine } = await import("./stage-wait.mjs");
218
+ // RE-TAKE receipt (2026-09-27): the bite stays `completed` while the new
219
+ // version is ingested, so the wait must watch the version, not the bite. The
220
+ // live recipe's fingerprint BEFORE staging is the baseline it must move from.
221
+ const retakeBefore = retakeOfBiteId ? await readLiveRecipeFingerprint({ base, apiKey: cfg.api_key, biteId: retakeOfBiteId }) : null;
211
222
  let stageRes;
212
223
  try {
213
224
  stageRes = await fetch(`${base}/api/recorder/stage`, {
@@ -228,7 +239,7 @@ if (!stageRes.ok) {
228
239
  if (errBody?.error === "quota_exceeded") {
229
240
  // Should not happen anymore — staging is quota-free by design. Neutral
230
241
  // fallback if an older server answers this way.
231
- console.error(`DemoBites declined the stage. Check ${base}/bites and try again.`);
242
+ console.error(`DemoBites declined the stage. Check ${base}/demos and try again.`);
232
243
  process.exit(1);
233
244
  }
234
245
  if (errBody?.error === "invalid_attempt") {
@@ -236,14 +247,14 @@ if (!stageRes.ok) {
236
247
  process.exit(1);
237
248
  }
238
249
  if (errBody?.error === "attempt_already_staged") {
239
- console.error(`This attempt already has a staged take${errBody.stagingId ? ` (${errBody.stagingId})` : ""}. Review that one, or stage again with --supersede to replace it.`);
250
+ console.error(`This attempt already has a staged take${errBody.stagingId ? ` (${errBody.stagingId})` : ""}. Keep that one, or stage again with --supersede to replace it.`);
240
251
  process.exit(1);
241
252
  }
242
253
  console.error(`Stage failed: ${stageRes.status} ${errBody ? JSON.stringify(errBody) : ""}`);
243
254
  process.exit(1);
244
255
  }
245
256
  const { stagingId, uploadUrl, previewUploadUrl, previewUrl, queueUrl, pendingCount } = await stageRes.json();
246
- if (!stagingId || !uploadUrl || !previewUploadUrl || !previewUrl) {
257
+ if (!stagingId || !uploadUrl || !previewUploadUrl) {
247
258
  console.error("Stage response missing fields.");
248
259
  process.exit(1);
249
260
  }
@@ -263,68 +274,62 @@ async function putS3(url, contentType, filePath, label) {
263
274
  await putS3(uploadUrl, "application/zip", zipPath, "ZIP");
264
275
  await putS3(previewUploadUrl, "video/mp4", cleanPath, "Preview");
265
276
 
266
- // ── delivery (brief batches): the take becomes a bite by itself ────────────
267
- // Only a take carrying an attempt is delivered. Free-prompt takes never call
268
- // this route, so their flow is unchanged: staged, then the word in the app.
269
- let delivery = null;
270
- if (attempt) {
271
- const { deliverStaged } = await import("./stage-wait.mjs");
272
- delivery = await deliverStaged({ base, apiKey: cfg.api_key, stagingId, template: uploadedTemplate });
273
- }
277
+ // ── delivery: EVERY take becomes a bite by itself ─────────────────────────
278
+ const delivery = await deliverStaged({ base, apiKey: cfg.api_key, stagingId, template: uploadedTemplate });
279
+ const grid = gridUrl(base, delivery.dashboardUrl);
274
280
  // The staging id used to be printed only; a batch resumes from disk, so it is
275
281
  // persisted next to the take (status.mjs reads it, and retries a delivery
276
- // that failed).
277
- const pageUrl = new URL(previewUrl, base).toString();
282
+ // that failed). previewUrl stays for older readers; the person's page is
283
+ // dashboardUrl, the Demos grid.
284
+ const previewPage = previewUrl ? new URL(previewUrl, base).toString() : null;
278
285
  try {
279
286
  fs.writeFileSync(path.join(dir, "staged.json"), JSON.stringify({
280
- stagingId, previewUrl: pageUrl, queueUrl: queueUrl ? new URL(queueUrl, base).toString() : null,
287
+ stagingId, previewUrl: previewPage, dashboardUrl: grid, queueUrl: queueUrl ? new URL(queueUrl, base).toString() : null,
281
288
  pendingCount: pendingCount ?? null, attemptRef: attempt?.attemptRef ?? null, briefId: attempt?.briefId ?? null,
282
- delivered: delivery ? delivery.delivered : null, biteId: delivery?.biteId ?? null, videoId: delivery?.videoId ?? null,
283
- queued: delivery?.queued ?? null, pending: delivery?.pending ?? null, deliveryError: delivery?.error ?? null,
289
+ delivered: delivery.delivered === true, waiting: delivery.waiting ?? null, resetsAt: delivery.resetsAt ?? null,
290
+ biteId: delivery.biteId ?? null, videoId: delivery.videoId ?? null, studioUrl: delivery.studioUrl ? new URL(delivery.studioUrl, base).toString() : null,
291
+ queued: delivery.queued ?? null, pending: delivery.pending ?? null, deliveryError: delivery.error ?? null,
284
292
  api: uploadedTemplate ? { uploaded: uploadedTemplate } : null, at: new Date().toISOString(),
293
+ retakeOfBiteId: retakeOfBiteId ?? null, retakeBefore,
285
294
  }, null, 2) + "\n");
286
295
  } catch (e) { console.error(`staged.json not written: ${e.message}`); }
287
296
 
288
- if (delivery?.delivered) {
289
- console.log(`delivered: bite ${delivery.biteId}${delivery.queued ? " (ingest queued)" : ""}. It becomes a bite in DemoBites by itself.\n ${pageUrl}`);
290
- if (stageOnly) { console.log(`Delivered only. Wait for the bite to finish later with: node scripts/status.mjs ${dir}`); process.exit(0); }
291
- const { waitForDecision } = await import("./stage-wait.mjs");
292
- const outcome = await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl, delivered: true });
293
- process.exit(outcome.exitCode);
294
- }
295
- if (delivery && !delivery.pending) {
296
- console.error(`Staged, but not delivered: ${delivery.error}. The take waits in the review queue:\n ${pageUrl}\nTry the delivery again later with: node scripts/status.mjs ${dir}`);
297
- process.exit(1);
298
- }
299
- if (delivery?.pending) console.log("This DemoBites does not deliver by itself yet; the take waits for the word in the app.");
300
-
301
- // ── open the in-app preview — the review happens THERE ─────────────────────
302
- // Batch etiquette (founder, 2026-08-11): when takes are stacked for a later
303
- // review sprint, auto-opening a tab per take is spam. `--no-open` (or
304
- // config.open_preview === false) stages silently — the queue pill and the
305
- // printed URL carry the message. Default stays open: for a single take the
306
- // opened page IS the consent moment.
307
- console.log(`Staged. Review and approve in the browser:\n ${pageUrl}`);
308
- if (typeof pendingCount === "number" && pendingCount > 1 && queueUrl) {
309
- console.log(`${pendingCount} takes are now waiting for review: ${new URL(queueUrl, base).toString()}`);
310
- }
297
+ // Single take: the grid opens in the browser, the person watches the card
298
+ // come in there. A batch passes --no-open (or config.open_preview === false)
299
+ // and the printed link carries the message.
311
300
  const noOpen = process.argv.includes("--no-open") || cfg.open_preview === false;
312
- if (!noOpen) {
301
+ function openGrid() {
302
+ if (noOpen) return;
313
303
  try {
314
304
  const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
315
- spawnSync(opener, [pageUrl], { stdio: "ignore" });
316
- } catch { /* printing the URL above is the fallback */ }
305
+ spawnSync(opener, [grid], { stdio: "ignore" });
306
+ } catch { /* the printed link is the fallback */ }
317
307
  }
318
308
 
319
- if (stageOnly) {
320
- console.log(`Staged only. Wait for the decision later with: node scripts/status.mjs ${dir}`);
309
+ if (delivery.delivered) {
310
+ console.log(`Delivered. Watch it come in: ${grid}`);
311
+ openGrid();
312
+ if (stageOnly) { console.log(`Wait for the ${retakeOfBiteId ? "new recording" : "bite"} to finish later with: node scripts/status.mjs ${dir}`); process.exit(0); }
313
+ // LAW (founder 2026-08-08): no studio link before the bite is completed.
314
+ // A re-take's bite is completed already: wait for the NEW version instead.
315
+ const outcome = retakeOfBiteId
316
+ ? await waitForRetake({ base, apiKey: cfg.api_key, biteId: retakeOfBiteId, videoId: delivery.videoId ?? null, manifest, before: retakeBefore, pageUrl: grid, studioUrl: delivery.studioUrl ?? null })
317
+ : await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl: grid, delivered: true });
318
+ process.exit(outcome.exitCode);
319
+ }
320
+ if (delivery.waiting) {
321
+ console.log(waitingLine(grid, delivery.resetsAt));
322
+ openGrid();
321
323
  process.exit(0);
322
324
  }
323
-
324
- // ── poll while the human decides, then until the bite is READY ─────────────
325
- // Shared with status.mjs (a batch waits there). The law it enforces: never
326
- // hand a human a studio link before the bite is finished; Approve only
327
- // STARTS the pipeline.
328
- const { waitForDecision } = await import("./stage-wait.mjs");
329
- const outcome = await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl });
330
- process.exit(outcome.exitCode);
325
+ if (delivery.pending) {
326
+ // An older DemoBites that does not deliver by itself: the take waits for
327
+ // the word in the app. Still no preview link as the destination.
328
+ console.log(`Staged. This DemoBites does not deliver by itself yet; the take waits for the word in the app. Watch it here: ${grid}`);
329
+ openGrid();
330
+ if (stageOnly) { console.log(`Wait for it later with: node scripts/status.mjs ${dir}`); process.exit(0); }
331
+ const outcome = await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl: grid });
332
+ process.exit(outcome.exitCode);
333
+ }
334
+ console.error(`Staged, but not delivered: ${delivery.error}. Try the delivery again with: node scripts/status.mjs ${dir}\nYour demos: ${grid}`);
335
+ process.exit(1);
@@ -24,7 +24,8 @@ if (!dir || urls.length === 0) {
24
24
  process.exit(2);
25
25
  }
26
26
  fs.mkdirSync(dir, { recursive: true });
27
- const profileDir = path.resolve(".recorder", "profile");
27
+ // RECORDER_PROFILE (1.7.0): a batch's parallel legs harvest on their own seeded profile.
28
+ const profileDir = path.resolve(process.env.RECORDER_PROFILE || path.join(".recorder", "profile"));
28
29
  const headless = !args.includes("--headless=false");
29
30
 
30
31
  const ctx = await chromium.launchPersistentContext(profileDir, { channel: "chrome", headless, viewport: { width: 1920, height: 1080 } }).catch(async () =>