demobites 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.
@@ -5,16 +5,28 @@
5
5
  // is fetched from DemoBites; the server keeps the bite's CURRENT narration
6
6
  // text, voice, intro/outro and look, and refits camera, cuts and audio.
7
7
  //
8
- // Usage: node retake.mjs <biteId> [--note "what changed"] [--take <dir>]
8
+ // Usage: node retake.mjs <biteId> [--note "what changed"] [--take <dir>] [--fresh]
9
9
  //
10
- // Flow: recipe -> storyboard.json -> record -> trim -> calibrate (gate) ->
11
- // manifest -> upload --retake-of <biteId> (stages; the human approves in-app,
12
- // where the preview says "Re-take of <bite>").
10
+ // --take <dir> film into an existing take dir. Its storyboard.json is the
11
+ // SOURCE OF TRUTH and is never overwritten: fix a selector in
12
+ // it and run again with --take, the fix stays. The recipe is
13
+ // written only when the file is absent.
14
+ // --fresh with --take: discard the dir's storyboard.json and write
15
+ // the bite's recipe again.
16
+ //
17
+ // Flow (the same legs, the same scripts, as a normal take — see batch.mjs):
18
+ // recipe -> storyboard.json -> cleanup.mjs --prep (when the storyboard
19
+ // declares prep[] / checks.before[]) -> record -> cleanup.mjs (when it
20
+ // declares cleanup[] / checks.after[]) -> trim -> calibrate (gate) ->
21
+ // manifest -> upload --retake-of <biteId> (stages and delivers into the bite;
22
+ // the person watches it on the Demos grid). upload.mjs refuses a take whose
23
+ // storyboard declares cleanup[] until cleanup.json passes, so the cleanup leg
24
+ // is not optional here.
13
25
  //
14
26
  // A step that no longer resolves makes record.mjs FAIL LOUDLY at that step.
15
27
  // That is the moment for the agent to look at the live page, fix the
16
28
  // storyboard (or drop the beat AND its line, and tell the human what is gone),
17
- // and run again. Nothing is staged until every step resolves.
29
+ // and run again with --take. Nothing is staged until every step resolves.
18
30
  import fs from "node:fs";
19
31
  import path from "node:path";
20
32
  import { fileURLToPath } from "node:url";
@@ -22,19 +34,34 @@ import { spawnSync } from "node:child_process";
22
34
 
23
35
  const here = path.dirname(fileURLToPath(import.meta.url));
24
36
  const args = process.argv.slice(2);
37
+ const USAGE = 'Usage: node retake.mjs <biteId> [--note "what changed"] [--take <dir>] [--fresh]';
38
+ if (args.includes("--help") || args.includes("-h")) { console.log(USAGE); process.exit(0); }
25
39
  const biteId = Number(args[0]);
26
40
  if (!Number.isInteger(biteId) || biteId <= 0) {
27
- console.error('Usage: node retake.mjs <biteId> [--note "what changed"] [--take <dir>]');
41
+ console.error(USAGE);
28
42
  process.exit(2);
29
43
  }
30
44
  const opt = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
31
45
  const note = opt("--note") ?? "";
46
+ const fresh = args.includes("--fresh");
32
47
  const cfgPath = path.resolve(".recorder", "config.json");
33
48
  let cfg = {};
34
49
  try { cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {}
35
50
  if (!cfg.api_key || !cfg.base) { console.error("No recorder key. Run: node scripts/login.mjs"); process.exit(1); }
36
51
  const base = cfg.base.replace(/\/+$/, "");
37
52
 
53
+ // Where the scripts are: installed, every script sits beside this file; in the
54
+ // package repository record/trim/calibrate live in ../../scripts (batch.mjs
55
+ // resolves the same two layouts).
56
+ function scriptPath(name) {
57
+ for (const dir of [here, path.resolve(here, "..", "..", "scripts")]) {
58
+ const p = path.join(dir, name);
59
+ if (fs.existsSync(p)) return p;
60
+ }
61
+ console.error(`${name} not found beside ${here}. Run \`npx demobite\` to refresh the skill.`);
62
+ process.exit(1);
63
+ }
64
+
38
65
  // 1. The recipe — the bite's DNA.
39
66
  const res = await fetch(`${base}/api/recorder/recipe?biteId=${biteId}`, { headers: { Authorization: `Bearer ${cfg.api_key}` } });
40
67
  if (res.status === 404) {
@@ -48,26 +75,57 @@ if (res.status === 402 || res.status === 403) {
48
75
  }
49
76
  if (!res.ok) { console.error(`Could not fetch the recipe (${res.status}).`); process.exit(1); }
50
77
  const recipe = await res.json();
51
- const storyboard = recipe.storyboard;
52
- if (!storyboard?.steps?.length) { console.error("The recipe has no storyboard steps."); process.exit(1); }
78
+ if (!recipe.storyboard?.steps?.length) { console.error("The recipe has no storyboard steps."); process.exit(1); }
53
79
 
54
- // 2. Take dir + files.
80
+ // 2. Take dir + files. With --take, an existing storyboard.json in the dir is
81
+ // the source of truth (the "fix a selector, run again" loop); the recipe is
82
+ // written only when the file is absent, or when --fresh asks for it.
55
83
  let takeDir = opt("--take");
56
84
  if (!takeDir) { let n = 1; while (fs.existsSync(`take-retake-${biteId}${n > 1 ? n : ""}`)) n++; takeDir = `take-retake-${biteId}${n > 1 ? n : ""}`; }
57
85
  fs.mkdirSync(takeDir, { recursive: true });
58
86
  const sbPath = path.join(takeDir, "storyboard.json");
59
- fs.writeFileSync(sbPath, JSON.stringify(storyboard, null, 2));
87
+ let storyboard;
88
+ if (fs.existsSync(sbPath) && !fresh) {
89
+ try { storyboard = JSON.parse(fs.readFileSync(sbPath, "utf8")); } catch (e) { console.error(`${sbPath} is not valid JSON (${e.message}). Fix it, or start over with --fresh.`); process.exit(1); }
90
+ if (!storyboard?.steps?.length) { console.error(`${sbPath} has no steps. Fix it, or start over with --fresh.`); process.exit(1); }
91
+ console.log(`Using the storyboard already in ${sbPath} (kept as is; --fresh replaces it from the recipe).`);
92
+ } else {
93
+ storyboard = recipe.storyboard;
94
+ fs.writeFileSync(sbPath, JSON.stringify(storyboard, null, 2));
95
+ if (fresh) console.log(`${sbPath} replaced from the recipe (--fresh).`);
96
+ }
60
97
  fs.writeFileSync(path.join(takeDir, "retake.json"), JSON.stringify({ biteId, note, engine: recipe.engine ?? null, fetchedAt: new Date().toISOString() }, null, 2));
61
- console.log(`Re-take of bite ${biteId} — ${storyboard.steps.length} steps from the recipe${recipe.engine ? ` (filmed by ${recipe.engine})` : ""}.`);
98
+ console.log(`Re-take of bite ${biteId} — ${storyboard.steps.length} steps${recipe.engine ? ` (filmed by ${recipe.engine})` : ""}.`);
62
99
  if (note) console.log(`Note from the human: ${note}`);
100
+ const hasPrep = (storyboard.prep?.length ?? 0) > 0 || (storyboard.checks?.before?.length ?? 0) > 0;
101
+ const hasCleanup = (storyboard.cleanup?.length ?? 0) > 0 || (storyboard.checks?.after?.length ?? 0) > 0;
63
102
 
64
- // 3. Film -> trim -> calibrate (gate) -> manifest -> stage.
65
- const run = (script, extra) => spawnSync("node", [path.join(here, script), ...extra], { stdio: "inherit", cwd: process.cwd() });
66
- let r = run("record.mjs", [takeDir, sbPath]);
103
+ // 3. Prep (off camera) -> film -> cleanup (off camera) -> trim -> calibrate
104
+ // (gate) -> manifest -> stage. The same child scripts a normal take runs.
105
+ const run = (script, extra) => spawnSync("node", [scriptPath(script), ...extra], { stdio: "inherit", cwd: process.cwd() });
106
+ const again = `node scripts/retake.mjs ${biteId} --take ${takeDir}${note ? ` --note ${JSON.stringify(note)}` : ""}`;
107
+ let r;
108
+ if (hasPrep) {
109
+ console.log("Prep, off camera (storyboard.prep[] then checks.before[])...");
110
+ r = run("cleanup.mjs", [takeDir, "--prep"]);
111
+ if (r.status !== 0) {
112
+ console.error(`\nPrep did not pass (see ${path.join(takeDir, "prep.json")}). Nothing was filmed. Fix prep[] / checks.before[] in ${sbPath}, then run:\n ${again}`);
113
+ process.exit(r.status ?? 1);
114
+ }
115
+ }
116
+ r = run("record.mjs", [takeDir, sbPath]);
67
117
  if (r.status !== 0) {
68
- console.error(`\nThe take stopped at a step that no longer resolves. Look at the live page, fix the storyboard in ${sbPath}\n(or drop the beat and its line, and tell the human what is gone), then run:\n node scripts/retake.mjs ${biteId} --take ${takeDir}${note ? ` --note ${JSON.stringify(note)}` : ""}`);
118
+ console.error(`\nThe take stopped at a step that no longer resolves. Look at the live page, fix the storyboard in ${sbPath}\n(or drop the beat and its line, and tell the human what is gone), then run:\n ${again}`);
69
119
  process.exit(r.status ?? 1);
70
120
  }
121
+ if (hasCleanup) {
122
+ console.log("Cleanup, off camera (storyboard.cleanup[] then checks.after[])...");
123
+ r = run("cleanup.mjs", [takeDir]);
124
+ if (r.status !== 0) {
125
+ console.error(`\nCleanup did not pass (see ${path.join(takeDir, "cleanup.json")}); the workspace may still carry what the take created, and upload.mjs will not stage it until cleanup.json says ok.\nFix cleanup[] / checks.after[] in ${sbPath} if a step no longer resolves, then, without filming again:\n node scripts/cleanup.mjs ${takeDir}\n node scripts/trim.mjs ${takeDir} && node scripts/calibrate.mjs ${takeDir} && node scripts/manifest.mjs ${takeDir}\n node scripts/upload.mjs ${takeDir} --retake-of ${biteId}${note ? ` --note ${JSON.stringify(note)}` : ""}`);
126
+ process.exit(4);
127
+ }
128
+ }
71
129
  r = run("trim.mjs", [takeDir]); if (r.status !== 0) process.exit(r.status ?? 1);
72
130
  r = run("calibrate.mjs", [takeDir]);
73
131
  if (r.status !== 0) { console.error("Calibration failed — do not stage this take. Investigate record_from / anchors and film again."); process.exit(3); }
@@ -10,14 +10,43 @@
10
10
  // Returns { exitCode, status, biteId, studioUrl } and never throws.
11
11
  //
12
12
  // DELIVERY (1.3.0, founder ruling 2026-09-14): a take filmed from a brief
13
- // becomes a bite by itself once its ZIP is uploaded; no Approve click. The
14
- // server answers status "delivered" from then on, and the wait here is only
15
- // for the bite to finish. Free-prompt takes still wait for the word.
13
+ // becomes a bite by itself once its ZIP is uploaded; no Approve click.
14
+ // DIRECT DELIVERY (1.5.0, founder ruling 2026-09-25): EVERY take is delivered
15
+ // that way, plain prompt takes too. The person watches it come in on the
16
+ // Demos grid (<base>/demos), never on the preview page. A take the account
17
+ // has no recording minutes for is KEPT by the server (403 { kept: true,
18
+ // waiting: "minutes", resetsAt, dashboardUrl, stagingId }) and waits on the
19
+ // same grid; the stage status reads "waiting" until minutes are back.
20
+
21
+ /** Where the person watches their takes: the Demos grid. `dashboardUrl` from
22
+ * the server wins (relative or absolute), else <base>/demos. */
23
+ export function gridUrl(base, dashboardUrl) {
24
+ try { return new URL(dashboardUrl || "/demos", base + "/").toString(); } catch { return `${base}/demos`; }
25
+ }
26
+
27
+ /** "October 1, 2026" from an ISO date, or null when there is none. */
28
+ export function formatResetDate(resetsAt) {
29
+ if (!resetsAt) return null;
30
+ const d = new Date(resetsAt);
31
+ if (Number.isNaN(d.getTime())) return null;
32
+ return d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" });
33
+ }
34
+
35
+ /** The one line a person reads when a take is kept for recording minutes. */
36
+ export function waitingLine(grid, resetsAt) {
37
+ const when = formatResetDate(resetsAt);
38
+ return `Kept. The take is waiting for recording minutes${when ? ` (back on ${when})` : ""}. Watch it here: ${grid}`;
39
+ }
16
40
 
17
41
  /** PUT the delivery route for a staged take. `template` is the claim's
18
42
  * api.uploaded ("{origin}/api/recorder/stage/{id}/uploaded", literal {id});
19
43
  * the fallback is the same path under the configured base. Idempotent on the
20
- * server: a repeat returns the same bite. Never throws. */
44
+ * server: a repeat returns the same bite. Never throws.
45
+ * Returns one of:
46
+ * { delivered: true, biteId, videoId, studioUrl, dashboardUrl, queued }
47
+ * { delivered: false, waiting: "minutes", kept: true, resetsAt, dashboardUrl }
48
+ * { delivered: false, pending: true } (older server)
49
+ * { delivered: false, error } */
21
50
  export async function deliverStaged({ base, apiKey, stagingId, template }) {
22
51
  const url = template && template.includes("{id}")
23
52
  ? template.replace("{id}", encodeURIComponent(stagingId))
@@ -28,7 +57,17 @@ export async function deliverStaged({ base, apiKey, stagingId, template }) {
28
57
  } catch (e) { return { delivered: false, httpStatus: 0, error: `DemoBites unreachable (${e.message})` }; }
29
58
  const json = await res.json().catch(() => null);
30
59
  if ((res.status === 200 || res.status === 202) && json?.biteId) {
31
- return { delivered: true, biteId: json.biteId, videoId: json.videoId ?? null, queued: res.status === 202 || json.queued === true, httpStatus: res.status };
60
+ return {
61
+ delivered: true, biteId: json.biteId, videoId: json.videoId ?? null, studioUrl: json.studioUrl ?? null,
62
+ dashboardUrl: json.dashboardUrl ?? null, queued: res.status === 202 || json.queued === true, httpStatus: res.status,
63
+ };
64
+ }
65
+ // No recording minutes left: the server KEEPS the take; it waits on the grid.
66
+ if (res.status === 403 && json?.kept === true) {
67
+ return {
68
+ delivered: false, kept: true, waiting: json.waiting ?? "minutes", resetsAt: json.resetsAt ?? null,
69
+ dashboardUrl: json.dashboardUrl ?? null, httpStatus: res.status,
70
+ };
32
71
  }
33
72
  // An older server: { pending: true }, or no such route at all (a 404 without an error body).
34
73
  if ((res.ok && json?.pending) || (res.status === 404 && !json?.error)) return { delivered: false, pending: true, httpStatus: res.status };
@@ -42,7 +81,7 @@ export async function waitForDecision({ base, apiKey, stagingId, pageUrl, delive
42
81
  let completed = false;
43
82
  let approvedBiteId = null;
44
83
  let finalStudioUrl = null;
45
- process.stdout.write(delivered ? "Waiting for the bite to finish" : "Waiting for your word in the browser");
84
+ process.stdout.write(delivered ? "Waiting for the bite to finish" : "Checking the take in DemoBites");
46
85
  while (Date.now() < deadline) {
47
86
  await new Promise((r) => setTimeout(r, pollMs));
48
87
  let res;
@@ -52,6 +91,12 @@ export async function waitForDecision({ base, apiKey, stagingId, pageUrl, delive
52
91
  if (!res.ok) { process.stdout.write("."); continue; }
53
92
  const st = await res.json().catch(() => null);
54
93
  if (!st) { process.stdout.write("."); continue; }
94
+ if (st.status === "waiting") {
95
+ // Kept for recording minutes: nothing will finish until they are back.
96
+ process.stdout.write("\n");
97
+ console.log(waitingLine(pageUrl, st.resetsAt));
98
+ return { exitCode: 0, status: "waiting", biteId: null, studioUrl: null };
99
+ }
55
100
  if (st.status === "rejected") {
56
101
  process.stdout.write("\n");
57
102
  console.error("Discarded in the app. Adjust the storyboard and film again.");
@@ -77,11 +122,11 @@ export async function waitForDecision({ base, apiKey, stagingId, pageUrl, delive
77
122
  }
78
123
  if (!announced) {
79
124
  process.stdout.write("\n");
80
- console.error(delivered ? `The server does not show the delivered bite yet. Look again later: ${pageUrl}` : `No decision yet. The preview stays available at:\n ${pageUrl}`);
125
+ console.error(delivered ? `The server does not show the delivered bite yet. Watch it here: ${pageUrl}` : `No decision yet. The take stays on your Demos grid:\n ${pageUrl}`);
81
126
  return { exitCode: 1, status: "pending", biteId: null, studioUrl: null };
82
127
  }
83
128
  if (!completed) {
84
- console.error(`${delivered ? "Delivered" : "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.`);
129
+ console.error(`${delivered ? "Delivered" : "Approved"}, but the bite did not finish within the wait window. Do not share the link yet — watch it on your Demos grid: ${pageUrl}`);
85
130
  return { exitCode: 1, status: delivered ? "delivered" : "approved", biteId: approvedBiteId, studioUrl: null };
86
131
  }
87
132
 
@@ -104,6 +149,125 @@ export async function waitForDecision({ base, apiKey, stagingId, pageUrl, delive
104
149
  return { exitCode: 0, status: "completed", biteId: approvedBiteId, studioUrl: finalStudioUrl };
105
150
  }
106
151
 
152
+ // ── RE-TAKE: wait for the NEW version, not the bite ────────────────────────
153
+ // A re-take (`upload.mjs --retake-of <biteId>`) lands INSIDE a bite that is
154
+ // already `completed`: the server ingests the new recording as a new
155
+ // uploaded_videos row + a 'processing' recipe row, and finalize's atomic swap
156
+ // repoints the bite and flips that row to 'live'. The bite's status never
157
+ // leaves `completed`, so waitForDecision returned on its first poll and
158
+ // printed the PREVIOUS version's receipt (bite 1110 read 45.1s where the new
159
+ // take was 44.6s, 2026-09-27). The marker that moves exactly at the swap and
160
+ // that a recorder key can read is the LIVE recipe (GET /api/recorder/recipe):
161
+ // after the swap its manifest is the one this take staged. The ingest
162
+ // rewrites only steps[].narration.text (the bite's current lines), so the
163
+ // manifest's duration and step windows survive verbatim and identify THIS
164
+ // take. When the status route ever reports the bite's video id
165
+ // (`videoId`), it is compared to the delivery's videoId first.
166
+
167
+ /** What a staged manifest leaves on the live recipe: duration + step windows. */
168
+ export function manifestFingerprint(manifest) {
169
+ if (!manifest || typeof manifest !== "object") return null;
170
+ const steps = Array.isArray(manifest.steps) ? manifest.steps : [];
171
+ if (!Number.isFinite(Number(manifest.duration)) && steps.length === 0) return null;
172
+ return JSON.stringify({
173
+ d: manifest.duration ?? null,
174
+ s: steps.map((s, i) => [s?.n ?? i + 1, s?.t_start ?? null, s?.t_end ?? null]),
175
+ });
176
+ }
177
+
178
+ /** The live recipe's fingerprint as the server sees it now, or null when it cannot be read. */
179
+ export async function readLiveRecipeFingerprint({ base, apiKey, biteId }) {
180
+ try {
181
+ const res = await fetch(`${base}/api/recorder/recipe?biteId=${encodeURIComponent(biteId)}`, { headers: { Authorization: `Bearer ${apiKey}` } });
182
+ if (!res.ok) return null;
183
+ const recipe = await res.json().catch(() => null);
184
+ return manifestFingerprint(recipe?.manifest);
185
+ } catch { return null; }
186
+ }
187
+
188
+ /** GET /api/recorder/status for a bite, or null when it cannot be read. */
189
+ export async function readBiteStatus({ base, apiKey, biteId }) {
190
+ try {
191
+ const res = await fetch(`${base}/api/recorder/status?biteId=${encodeURIComponent(biteId)}`, { headers: { Authorization: `Bearer ${apiKey}` } });
192
+ if (!res.ok) return null;
193
+ return await res.json().catch(() => null);
194
+ } catch { return null; }
195
+ }
196
+
197
+ /** The numbers the receipt prints; two equal reads in a row mean the post-swap manifest application has settled. */
198
+ function receiptKey(st) {
199
+ return st ? JSON.stringify([st.status, st.durationSec ?? null, st.zooms ?? null, st.narrationTotal ?? null, st.narrationReady ?? null, st.title ?? null]) : null;
200
+ }
201
+
202
+ /** Wait until the re-take is the bite's live recording, then print ITS receipt.
203
+ * `manifest` is the staged wire manifest, `before` the live fingerprint read
204
+ * BEFORE staging (null when unknown), `videoId` the delivery's new video id.
205
+ * Returns { exitCode, status, biteId, studioUrl } and never throws. */
206
+ export async function waitForRetake({ base, apiKey, biteId, videoId = null, manifest = null, before = null, pageUrl, studioUrl = null, timeoutMs = 30 * 60 * 1000, pollMs = 4000, settleMs = 30 * 1000 }) {
207
+ const ours = manifestFingerprint(manifest);
208
+ if (ours && before && ours === before) {
209
+ console.error("Note: the bite's live recording already carries this take's manifest; the receipt below may describe it rather than a newer version.");
210
+ }
211
+ const deadline = Date.now() + timeoutMs;
212
+ let landed = false;
213
+ let how = null;
214
+ process.stdout.write(`Waiting for the new recording to land in bite ${biteId}`);
215
+ while (Date.now() < deadline) {
216
+ await new Promise((r) => setTimeout(r, pollMs));
217
+ const st = await readBiteStatus({ base, apiKey, biteId });
218
+ if (st?.status === "failed") {
219
+ process.stdout.write("\n");
220
+ console.error(`The pipeline FAILED while re-taking bite ${biteId}. The bite keeps its previous recording. Do not hand over any link — investigate.`);
221
+ return { exitCode: 1, status: "failed", biteId, studioUrl: null };
222
+ }
223
+ // 1. The server names the bite's video: exact.
224
+ if (videoId && st && Number.isFinite(Number(st.videoId))) {
225
+ if (Number(st.videoId) === Number(videoId)) { landed = true; how = "video"; break; }
226
+ process.stdout.write("."); continue;
227
+ }
228
+ // 2. The live recipe carries this take's manifest: exact. Or it moved
229
+ // away from what was live before staging: this take, or a newer one.
230
+ const fp = await readLiveRecipeFingerprint({ base, apiKey, biteId });
231
+ if (fp && ours && fp === ours) { landed = true; how = "recipe"; break; }
232
+ if (fp && before && fp !== before) { landed = true; how = "moved"; break; }
233
+ process.stdout.write(".");
234
+ }
235
+ process.stdout.write("\n");
236
+ if (!landed) {
237
+ console.error(`The new recording has not landed in bite ${biteId} within the wait window; the bite still plays its previous version. No receipt for the new one yet — watch it on your Demos grid: ${pageUrl}\n(wait again later with: node scripts/status.mjs <takeDir>)`);
238
+ return { exitCode: 1, status: "delivered", biteId, studioUrl: null };
239
+ }
240
+ console.log(`New recording landed in bite ${biteId}${how === "moved" ? " (the live recording changed after staging)" : ""}.`);
241
+
242
+ // The swap is followed by the manifest application (camera shots, narration
243
+ // rows) on the new version; read until the numbers hold still.
244
+ let last = await readBiteStatus({ base, apiKey, biteId });
245
+ const settleDeadline = Date.now() + settleMs;
246
+ let prevKey = receiptKey(last);
247
+ while (Date.now() < settleDeadline) {
248
+ await new Promise((r) => setTimeout(r, Math.min(pollMs, 3000)));
249
+ const st = await readBiteStatus({ base, apiKey, biteId });
250
+ const key = receiptKey(st);
251
+ if (st && key === prevKey && st.status === "completed" && (st.narrationTotal ?? 0) === (st.narrationReady ?? 0)) { last = st; break; }
252
+ if (st) { last = st; prevKey = key; }
253
+ }
254
+ if (last && last.status === "completed") {
255
+ console.log(
256
+ `Ready (new recording): "${last.title}" — ${last.durationSec ? last.durationSec.toFixed(1) + "s, " : ""}` +
257
+ `${last.narrationReady}/${last.narrationTotal} narration segments with audio, ${last.zooms} camera shots`,
258
+ );
259
+ if (last.narrationTotal === 0) console.error("WARNING: no narration segments landed. The voice will be silent.");
260
+ else if (last.narrationReady < last.narrationTotal) console.error(`WARNING: ${last.narrationTotal - last.narrationReady} segment(s) have no audio behind them.`);
261
+ if (last.zooms === 0) console.error("WARNING: no camera shots landed.");
262
+ } else {
263
+ console.error(`The new recording landed but the status door did not answer with a completed bite${last?.status ? ` (${last.status})` : ""}. Watch it here: ${pageUrl}`);
264
+ return { exitCode: 1, status: last?.status ?? "unknown", biteId, studioUrl: null };
265
+ }
266
+ const finalStudioUrl = studioUrl ? new URL(studioUrl, base).toString() : null;
267
+ if (finalStudioUrl) console.log(`Studio: ${finalStudioUrl}`);
268
+ return { exitCode: 0, status: "completed", biteId, studioUrl: finalStudioUrl };
269
+ }
270
+
107
271
  /** One look, no waiting: the staged take's current status as the server sees it. */
108
272
  export async function peekStaged({ base, apiKey, stagingId }) {
109
273
  const res = await fetch(`${base}/api/recorder/stage?id=${encodeURIComponent(stagingId)}`, { headers: { Authorization: `Bearer ${apiKey}` } });
@@ -1,17 +1,18 @@
1
1
  // Where a staged take stands, and the wait for its word.
2
2
  //
3
- // node status.mjs <takeDir> wait for the decision (or, delivered, for the bite to finish); reads <takeDir>/staged.json
3
+ // node status.mjs <takeDir> wait for the delivered bite to finish; reads <takeDir>/staged.json
4
4
  // a take whose delivery failed is delivered again here first (idempotent on the server)
5
5
  // node status.mjs <stagingId> same, by id
6
- // node status.mjs <takeDir> --no-wait one look, no waiting: pending | delivered (+bite status) | approved (+bite status) | rejected
6
+ // node status.mjs <takeDir> --no-wait one look, no waiting: delivered (+bite status) | waiting for recording minutes | pending | rejected
7
7
  // node status.mjs --all one look at every take-*/staged.json under the current directory
8
8
  //
9
- // A batch stages every take with `upload.mjs --stage-only --no-open`, then the
10
- // agent (or the human, later) comes back here per take. The same law as
11
- // upload.mjs: no studio link before the bite is completed.
9
+ // A batch delivers every take with `upload.mjs --stage-only --no-open`, then
10
+ // the agent (or the human, later) comes back here per take. The page the
11
+ // person opens is the Demos grid (<base>/demos), never the preview page. The
12
+ // same law as upload.mjs: no studio link before the bite is completed.
12
13
  import fs from "node:fs";
13
14
  import path from "node:path";
14
- import { waitForDecision, peekStaged, deliverStaged } from "./stage-wait.mjs";
15
+ import { waitForDecision, waitForRetake, peekStaged, deliverStaged, gridUrl, formatResetDate, waitingLine } from "./stage-wait.mjs";
15
16
 
16
17
  const args = process.argv.slice(2);
17
18
  const noWait = args.includes("--no-wait");
@@ -27,6 +28,7 @@ let cfg = {};
27
28
  try { cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {}
28
29
  if (!cfg.api_key || !cfg.base) { console.error("No recorder key. Run: node scripts/login.mjs"); process.exit(1); }
29
30
  const base = cfg.base.replace(/\/+$/, "");
31
+ const defaultGrid = gridUrl(base, null);
30
32
 
31
33
  function readStaged(dir) {
32
34
  const p = path.join(dir, "staged.json");
@@ -39,6 +41,10 @@ function describe(st) {
39
41
  if (st.status === "rejected") return "discarded in the app";
40
42
  if (st.status === "delivered") return `delivered, bite ${st.biteId ?? "?"} ${st.biteStatus ?? "processing"}`;
41
43
  if (st.status === "approved") return `approved, bite ${st.biteId ?? "?"} ${st.biteStatus ?? "processing"}`;
44
+ if (st.status === "waiting") {
45
+ const when = formatResetDate(st.resetsAt);
46
+ return `waiting for recording minutes${when ? ` (back on ${when})` : ""}`;
47
+ }
42
48
  return "waiting for the word in the app";
43
49
  }
44
50
 
@@ -48,42 +54,62 @@ if (all) {
48
54
  for (const d of dirs) {
49
55
  const staged = readStaged(d);
50
56
  const st = staged?.stagingId ? await peekStaged({ base, apiKey: cfg.api_key, stagingId: staged.stagingId }) : { ok: false, httpStatus: 0 };
51
- console.log(`${d.padEnd(40)} ${describe(st)}${staged?.previewUrl ? `\n${"".padEnd(40)} ${staged.previewUrl}` : ""}`);
57
+ console.log(`${d.padEnd(40)} ${describe(st)}`);
52
58
  }
59
+ console.log(`Watch them here: ${defaultGrid}`);
53
60
  process.exit(0);
54
61
  }
55
62
 
56
63
  let stagingId = target;
57
64
  let pageUrl = null;
58
65
  let delivered = false;
66
+ // A re-take (staged with --retake-of) waits for its NEW version, not the bite,
67
+ // which is completed already; upload.mjs left the baseline in staged.json.
68
+ let retake = null;
59
69
  if (fs.existsSync(target) && fs.statSync(target).isDirectory()) {
60
70
  const staged = readStaged(target);
61
71
  if (!staged?.stagingId) { console.error(`${target} has no staged.json. Stage it first: node scripts/upload.mjs ${target} --stage-only`); process.exit(1); }
62
72
  stagingId = staged.stagingId;
63
- pageUrl = staged.previewUrl ?? null;
73
+ pageUrl = staged.dashboardUrl ?? null;
64
74
  delivered = staged.delivered === true;
65
- // A brief take whose delivery failed (network, 409) is delivered again here;
66
- // the server is idempotent. An older server (pending) is left alone.
67
- if (staged.attemptRef && staged.delivered === false && staged.pending !== true) {
75
+ if (Number.isInteger(staged.retakeOfBiteId) && staged.retakeOfBiteId > 0) {
76
+ let manifest = null;
77
+ try { manifest = JSON.parse(fs.readFileSync(path.join(target, "manifest.demobites.json"), "utf8")); } catch {}
78
+ retake = { biteId: staged.retakeOfBiteId, before: staged.retakeBefore ?? null, videoId: staged.videoId ?? null, studioUrl: staged.studioUrl ?? null, manifest };
79
+ }
80
+ // A take whose delivery failed (network, 409) is delivered again here; the
81
+ // server is idempotent. An older server (pending) and a kept take (waiting
82
+ // for minutes, the server holds it) are left alone.
83
+ if (staged.delivered === false && staged.pending !== true && !staged.waiting) {
68
84
  const d = await deliverStaged({ base, apiKey: cfg.api_key, stagingId, template: staged.api?.uploaded ?? null });
85
+ const grid = gridUrl(base, d.dashboardUrl ?? staged.dashboardUrl);
86
+ pageUrl = grid;
87
+ const write = (patch) => { try { fs.writeFileSync(path.join(target, "staged.json"), JSON.stringify({ ...staged, dashboardUrl: grid, ...patch }, null, 2) + "\n"); } catch {} };
69
88
  if (d.delivered) {
70
89
  delivered = true;
71
- console.log(`delivered: bite ${d.biteId}${d.queued ? " (ingest queued)" : ""}. It becomes a bite in DemoBites by itself.`);
72
- try { fs.writeFileSync(path.join(target, "staged.json"), JSON.stringify({ ...staged, delivered: true, biteId: d.biteId, videoId: d.videoId ?? null, queued: d.queued ?? null, deliveryError: null }, null, 2) + "\n"); } catch {}
90
+ console.log(`Delivered. Watch it come in: ${grid}`);
91
+ write({ delivered: true, waiting: null, biteId: d.biteId, videoId: d.videoId ?? null, studioUrl: d.studioUrl ?? null, queued: d.queued ?? null, deliveryError: null });
92
+ } else if (d.waiting) {
93
+ console.log(waitingLine(grid, d.resetsAt));
94
+ write({ waiting: d.waiting, resetsAt: d.resetsAt ?? null, deliveryError: null });
95
+ process.exit(0);
73
96
  } else if (d.pending) {
74
- try { fs.writeFileSync(path.join(target, "staged.json"), JSON.stringify({ ...staged, pending: true }, null, 2) + "\n"); } catch {}
75
- console.log("This DemoBites does not deliver by itself yet; the take waits for the word in the app.");
76
- } else console.error(`Not delivered: ${d.error}. The take waits in the review queue.`);
97
+ write({ pending: true });
98
+ console.log(`This DemoBites does not deliver by itself yet; the take waits for the word in the app. Watch it here: ${grid}`);
99
+ } else console.error(`Not delivered: ${d.error}. Try again later with: node scripts/status.mjs ${target}\nYour demos: ${grid}`);
77
100
  }
78
101
  }
79
- if (!pageUrl) pageUrl = `${base}/recording-preview/agentic/${encodeURIComponent(stagingId)}`;
102
+ if (!pageUrl) pageUrl = defaultGrid;
80
103
 
81
104
  if (noWait) {
82
105
  const st = await peekStaged({ base, apiKey: cfg.api_key, stagingId });
83
106
  console.log(`${stagingId}: ${describe(st)}`);
107
+ console.log(`Watch it here: ${pageUrl}`);
84
108
  if (st.ok && (st.status === "approved" || st.status === "delivered") && st.biteStatus === "completed" && st.studioUrl) console.log(`Studio: ${new URL(st.studioUrl, base).toString()}`);
85
109
  process.exit(st.ok ? 0 : 1);
86
110
  }
87
111
 
88
- const outcome = await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl, delivered });
112
+ const outcome = retake && delivered
113
+ ? await waitForRetake({ base, apiKey: cfg.api_key, biteId: retake.biteId, videoId: retake.videoId, manifest: retake.manifest, before: retake.before, pageUrl, studioUrl: retake.studioUrl })
114
+ : await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl, delivered });
89
115
  process.exit(outcome.exitCode);