demobite 1.5.0 → 1.7.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/README.md +8 -0
- package/launcher/index.mjs +72 -7
- package/package.json +1 -1
- package/scripts/calibrate.mjs +17 -3
- package/scripts/record.mjs +157 -16
- package/skill/SKILL.md +95 -5
- package/skill/scripts/batch.mjs +328 -0
- package/skill/scripts/briefs.mjs +163 -9
- package/skill/scripts/cleanup.mjs +2 -1
- package/skill/scripts/manifest.mjs +13 -1
- package/skill/scripts/retake.mjs +72 -14
- package/skill/scripts/stage-wait.mjs +119 -0
- package/skill/scripts/status.mjs +12 -2
- package/skill/scripts/upload.mjs +11 -3
- package/skill/scripts/vocab.mjs +2 -1
package/skill/scripts/briefs.mjs
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
// of approved briefs into the agent; this script is the agent's hands:
|
|
3
3
|
//
|
|
4
4
|
// node briefs.mjs list <batchId> [--paste <file>] the authenticated truth for the batch (warns when a pasted bundle drifted)
|
|
5
|
+
// node briefs.mjs list --slug <slug> UPDATE RADAR (1.6.0): the batch behind a workflow's record code; writes
|
|
6
|
+
// .recorder/radar/<slug>/bundle.json (+ briefs/<briefId>.json, one draft
|
|
7
|
+
// per brief) and prints the briefs in order with their open questions
|
|
8
|
+
// node briefs.mjs refine <slug> <briefId> [--file <refined.json>] [--note "..."]
|
|
9
|
+
// REFACTOR PASS (1.7.0): post the brief refined on this machine (default file
|
|
10
|
+
// .recorder/radar/<slug>/refined/<briefId>.json) → a new revision; the Radar
|
|
11
|
+
// page shows it as "Refined on your machine"; rewrites the bundle
|
|
5
12
|
// node briefs.mjs claim <batchId> <briefId> [--force] claim one brief → mints an attempt, creates take-<briefId>-r<revision>/brief.json
|
|
6
13
|
// node briefs.mjs event <takeDir|attemptRef> <event> [--note "..."]
|
|
7
14
|
// planning | awaiting_storyboard_approval | recording | uploading | failed | cancelled
|
|
@@ -9,15 +16,23 @@
|
|
|
9
16
|
//
|
|
10
17
|
// Server contract (dbrec_ key, scope record):
|
|
11
18
|
// GET /api/recorder/briefs?batch=<batchId>
|
|
19
|
+
// GET /api/recorder/briefs?slug=<slug> same payload + radar: { slug, name, workflowUrl }
|
|
20
|
+
// 404 { error: "unknown_slug" } · 409 { error: "not_approved" }
|
|
12
21
|
// PUT /api/recorder/briefs/claim { briefId, revision, contentHash, idempotencyKey, force? }
|
|
13
22
|
// PUT /api/recorder/briefs/attempts/<attemptRef> { event, note? }
|
|
23
|
+
// PUT /api/recorder/briefs/<briefId>/refine { revision, contentHash, content, note? }
|
|
24
|
+
// -> { revision, contentHash } (409 hash_mismatch · 410 superseded)
|
|
14
25
|
// The stage call (upload.mjs) sends the attempt from <takeDir>/brief.json, and
|
|
15
26
|
// after the uploads calls the delivery route from the claim's `api.uploaded`
|
|
16
27
|
// (1.3.0): a take filmed from a brief becomes a bite by itself.
|
|
17
28
|
//
|
|
18
|
-
// Laws
|
|
19
|
-
//
|
|
20
|
-
//
|
|
29
|
+
// Laws (founder ruling 2026-09-26, "the fewest edits before Export and Go
|
|
30
|
+
// live"): every brief is refined against the repository and the running app
|
|
31
|
+
// BEFORE anything films, and posted back through `refine`; every open
|
|
32
|
+
// question is asked ONCE for the whole batch; the batch gets ONE approval;
|
|
33
|
+
// then batch.mjs films the takes in the background, in parallel, each on its
|
|
34
|
+
// own profile directory. A failed brief never stops the others. Never print
|
|
35
|
+
// the api_key.
|
|
21
36
|
import fs from "node:fs";
|
|
22
37
|
import path from "node:path";
|
|
23
38
|
import crypto from "node:crypto";
|
|
@@ -25,22 +40,26 @@ import crypto from "node:crypto";
|
|
|
25
40
|
const [, , cmd, ...rest] = process.argv;
|
|
26
41
|
const flag = (name) => rest.includes(name);
|
|
27
42
|
const opt = (name) => { const i = rest.indexOf(name); return i >= 0 ? String(rest[i + 1] ?? "") : null; };
|
|
28
|
-
const positional = rest.filter((a, i) => !a.startsWith("--") && !(i > 0 && ["--paste", "--note"].includes(rest[i - 1])));
|
|
43
|
+
const positional = rest.filter((a, i) => !a.startsWith("--") && !(i > 0 && ["--paste", "--note", "--slug", "--file"].includes(rest[i - 1])));
|
|
44
|
+
// `npx demobite record <slug>` runs this script (DEMOBITE_CLI=1); its login hint is the launcher's command.
|
|
45
|
+
const loginHint = process.env.DEMOBITE_CLI ? "npx demobite login" : "node scripts/login.mjs";
|
|
29
46
|
|
|
30
47
|
function usage(code = 2) {
|
|
31
48
|
console.error(`Usage:
|
|
32
49
|
node briefs.mjs list <batchId> [--paste <file>]
|
|
50
|
+
node briefs.mjs list --slug <slug>
|
|
51
|
+
node briefs.mjs refine <slug> <briefId> [--file <refined.json>] [--note "..."]
|
|
33
52
|
node briefs.mjs claim <batchId> <briefId> [--force]
|
|
34
53
|
node briefs.mjs event <takeDir|attemptRef> <event> [--note "..."]
|
|
35
54
|
node briefs.mjs release <takeDir|attemptRef> [--note "..."]`);
|
|
36
55
|
process.exit(code);
|
|
37
56
|
}
|
|
38
|
-
if (!cmd || !["list", "claim", "event", "release"].includes(cmd)) usage();
|
|
57
|
+
if (!cmd || !["list", "claim", "event", "release", "refine"].includes(cmd)) usage();
|
|
39
58
|
|
|
40
59
|
const cfgPath = path.resolve(".recorder", "config.json");
|
|
41
60
|
let cfg = {};
|
|
42
61
|
try { cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {}
|
|
43
|
-
if (!cfg.api_key || !cfg.base) { console.error(
|
|
62
|
+
if (!cfg.api_key || !cfg.base) { console.error(`Not connected to DemoBites. Run: ${loginHint}`); process.exit(1); }
|
|
44
63
|
const base = cfg.base.replace(/\/+$/, "");
|
|
45
64
|
const headers = { Authorization: `Bearer ${cfg.api_key}`, "Content-Type": "application/json" };
|
|
46
65
|
|
|
@@ -50,7 +69,7 @@ async function api(method, p, body) {
|
|
|
50
69
|
res = await fetch(`${base}${p}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
|
51
70
|
} catch (e) { console.error(`DemoBites unreachable (${e.message}).`); process.exit(1); }
|
|
52
71
|
const json = await res.json().catch(() => null);
|
|
53
|
-
if (res.status === 401) { console.error(
|
|
72
|
+
if (res.status === 401) { console.error(`The recorder key was refused. Run: ${loginHint}`); process.exit(1); }
|
|
54
73
|
return { status: res.status, ok: res.ok, json };
|
|
55
74
|
}
|
|
56
75
|
|
|
@@ -90,14 +109,116 @@ function printBatch(data) {
|
|
|
90
109
|
async function fetchBatch(batchId) {
|
|
91
110
|
const r = await api("GET", `/api/recorder/briefs?batch=${encodeURIComponent(batchId)}`);
|
|
92
111
|
if (r.status === 403 && r.json?.error === "workspace_mismatch") {
|
|
93
|
-
console.error(
|
|
112
|
+
console.error(`This key's workspace is not the batch's workspace. Log in to the right workspace (${loginHint}) and try again.`);
|
|
94
113
|
process.exit(1);
|
|
95
114
|
}
|
|
96
115
|
if (!r.ok || !r.json?.briefs) { console.error(`Could not read batch ${batchId}: ${r.status} ${r.json ? JSON.stringify(r.json).slice(0, 200) : ""}`); process.exit(1); }
|
|
97
116
|
return r.json;
|
|
98
117
|
}
|
|
99
118
|
|
|
100
|
-
|
|
119
|
+
/** UPDATE RADAR (1.6.0): the batch behind a workflow's record code. */
|
|
120
|
+
async function fetchBySlug(slug) {
|
|
121
|
+
const r = await api("GET", `/api/recorder/briefs?slug=${encodeURIComponent(slug)}`);
|
|
122
|
+
if (r.status === 404) { console.error("No batch with that code. Check the command on the Update Radar page."); process.exit(1); }
|
|
123
|
+
if (r.status === 409) { console.error("The briefs are not approved yet. Approve them on the Update Radar page, then run this again."); process.exit(1); }
|
|
124
|
+
if (r.status === 403 && r.json?.error === "workspace_mismatch") {
|
|
125
|
+
console.error(`This key's workspace is not the workflow's workspace. Log in to the right workspace (${loginHint}) and try again.`);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
if (r.status === 403 && r.json?.error === "upgrade_required") { console.error("Update Radar is part of Product Awareness. Upgrade the workspace on the Update Radar page, then run this again."); process.exit(1); }
|
|
129
|
+
if (!r.ok || !r.json?.briefs) { console.error(`Could not read the batch for code ${slug}: ${r.status} ${r.json ? JSON.stringify(r.json).slice(0, 200) : ""}`); process.exit(1); }
|
|
130
|
+
return r.json;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The record-by-code view: the workflow, the batch id, then the briefs in order (position, title, seconds). */
|
|
134
|
+
function printRadar(data, slug) {
|
|
135
|
+
const { batch, briefs, radar } = data;
|
|
136
|
+
const order = [...briefs].sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
|
137
|
+
console.log(`Update Radar: ${radar?.name ?? slug}`);
|
|
138
|
+
// A radar batch has no target address: the agent films where the code
|
|
139
|
+
// already runs (the Record stage says so); print the target only when set.
|
|
140
|
+
console.log(`Batch ${batch?.id ?? "?"}${batch?.target?.url ? ` target ${batch.target.url}${batch.target.environment ? ` (${batch.target.environment})` : ""}` : ""}`);
|
|
141
|
+
console.log(`${briefs.length} brief${briefs.length === 1 ? "" : "s"}, each at most ${batch?.rules?.maxSeconds ?? 90} seconds, in this order:\n`);
|
|
142
|
+
const questions = [];
|
|
143
|
+
order.forEach((b, i) => {
|
|
144
|
+
const pos = String(i + 1).padStart(2, " ");
|
|
145
|
+
const secs = b.estimatedDurationSec ? `about ${b.estimatedDurationSec}s` : "length open";
|
|
146
|
+
const state = b.status && b.status !== "approved" ? ` [${b.status}${b.attempt ? `, attempt ${b.attempt.ref}` : ""}]` : "";
|
|
147
|
+
const refined = b.refinedLocally || b.refined ? " (refined on this machine)" : "";
|
|
148
|
+
console.log(` ${pos}. ${b.title} (${secs}) brief ${b.briefId} r${b.revision}${state}${refined}`);
|
|
149
|
+
for (const q of b.questions ?? []) questions.push({ n: i + 1, title: b.title, q: typeof q === "string" ? q : q?.text ?? JSON.stringify(q) });
|
|
150
|
+
});
|
|
151
|
+
// QUESTIONS ONCE (2026-09-26): the drafts' open questions, gathered here so
|
|
152
|
+
// the agent asks them in one message for the whole batch, never mid-filming.
|
|
153
|
+
if (questions.length) {
|
|
154
|
+
console.log(`\nOpen questions in the drafts (ask them once, for the whole batch, before anything films):`);
|
|
155
|
+
for (const { n, title, q } of questions) console.log(` brief ${n} (${title}): ${q}`);
|
|
156
|
+
}
|
|
157
|
+
if (radar?.workflowUrl) console.log(`\nWorkflow page: ${radar.workflowUrl}`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The radar bundle on disk: bundle.json plus one draft file per brief (what
|
|
161
|
+
* the refactor pass starts from; the refined copy goes to refined/<briefId>.json). */
|
|
162
|
+
function writeRadarBundle(slug, data) {
|
|
163
|
+
const dir = path.resolve(".recorder", "radar", slug);
|
|
164
|
+
fs.mkdirSync(path.join(dir, "briefs"), { recursive: true });
|
|
165
|
+
const bundlePath = path.join(dir, "bundle.json");
|
|
166
|
+
fs.writeFileSync(bundlePath, JSON.stringify({ slug, fetchedAt: new Date().toISOString(), ...data }, null, 2) + "\n");
|
|
167
|
+
for (const b of data.briefs) {
|
|
168
|
+
const safe = String(b.briefId).replace(/[^A-Za-z0-9._-]+/g, "-");
|
|
169
|
+
fs.writeFileSync(path.join(dir, "briefs", `${safe}.json`), JSON.stringify(b, null, 2) + "\n");
|
|
170
|
+
}
|
|
171
|
+
return bundlePath;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The brief's content for the refine door: ONLY the seven content fields
|
|
175
|
+
* the server's strict schema accepts, read from the draft file's top level or
|
|
176
|
+
* its `content` object; every other key a draft carries (identity, state,
|
|
177
|
+
* questions, author, attempts) is dropped, so a draft round-trips as is.
|
|
178
|
+
* The limits are the server's (a refine that breaks one answers 400
|
|
179
|
+
* invalid_refine): title ≤ 80, audience ≤ 60, outcome ≤ 200, flowIntent 2..8
|
|
180
|
+
* lines ≤ 120 each, prerequisites/exclusions ≤ 6 lines ≤ 160 each,
|
|
181
|
+
* estimatedDurationSec 15..90. They are checked here first, with the field
|
|
182
|
+
* named, so the agent trims before posting instead of reading a 400. */
|
|
183
|
+
const CONTENT_LIMITS = { title: 80, audience: 60, outcome: 200 };
|
|
184
|
+
const LIST_LIMITS = { flowIntent: { min: 2, max: 8, line: 120 }, prerequisites: { min: 0, max: 6, line: 160 }, exclusions: { min: 0, max: 6, line: 160 } };
|
|
185
|
+
function refineContent(obj) {
|
|
186
|
+
const src = obj && typeof obj === "object" && obj.content && typeof obj.content === "object" ? obj.content : obj ?? {};
|
|
187
|
+
const pick = (camel, snake) => src[camel] !== undefined ? src[camel] : src[snake];
|
|
188
|
+
const out = {};
|
|
189
|
+
for (const k of ["title", "audience", "outcome"]) if (src[k] !== undefined) out[k] = src[k];
|
|
190
|
+
const fi = pick("flowIntent", "flow_intent"); if (fi !== undefined) out.flowIntent = fi;
|
|
191
|
+
for (const k of ["prerequisites", "exclusions"]) if (src[k] !== undefined) out[k] = src[k];
|
|
192
|
+
const d = pick("estimatedDurationSec", "estimated_duration_sec"); if (d !== undefined) out.estimatedDurationSec = d;
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
/** The server's limits, checked before the PUT; returns the problems as "field: why" lines. */
|
|
196
|
+
function refineProblems(c) {
|
|
197
|
+
const out = [];
|
|
198
|
+
for (const [k, max] of Object.entries(CONTENT_LIMITS)) {
|
|
199
|
+
if (typeof c[k] !== "string" || !c[k].trim()) out.push(`${k}: required, a non-empty line`);
|
|
200
|
+
else if (c[k].trim().length > max) out.push(`${k}: ${c[k].trim().length} characters, the limit is ${max}`);
|
|
201
|
+
}
|
|
202
|
+
for (const [k, lim] of Object.entries(LIST_LIMITS)) {
|
|
203
|
+
const v = c[k] ?? [];
|
|
204
|
+
if (!Array.isArray(v)) { out.push(`${k}: must be a list of lines`); continue; }
|
|
205
|
+
if (v.length < lim.min || v.length > lim.max) out.push(`${k}: ${v.length} lines, allowed ${lim.min}..${lim.max}`);
|
|
206
|
+
v.forEach((line, i) => { if (typeof line !== "string" || !line.trim()) out.push(`${k}[${i}]: an empty line`); else if (line.trim().length > lim.line) out.push(`${k}[${i}]: ${line.trim().length} characters, the limit is ${lim.line}`); });
|
|
207
|
+
}
|
|
208
|
+
const d = c.estimatedDurationSec;
|
|
209
|
+
if (!Number.isInteger(d) || d < 15 || d > 90) out.push(`estimatedDurationSec: ${JSON.stringify(d)}, must be a whole number of seconds 15..90`);
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (cmd === "list" && opt("--slug") !== null) {
|
|
214
|
+
const slug = String(opt("--slug")).trim().toLowerCase();
|
|
215
|
+
if (!/^[a-z0-9-]{1,32}$/.test(slug)) { console.error("The record code is the short code on the Update Radar page, like k3fx9q."); process.exit(2); }
|
|
216
|
+
const data = await fetchBySlug(slug);
|
|
217
|
+
const bundlePath = writeRadarBundle(slug, data);
|
|
218
|
+
printRadar(data, slug);
|
|
219
|
+
const rel = path.relative(process.cwd(), bundlePath) || bundlePath;
|
|
220
|
+
console.log(`Bundle written: ${rel} (one draft per brief in ${path.dirname(rel)}/briefs/, refined copies go to ${path.dirname(rel)}/refined/)`);
|
|
221
|
+
} else if (cmd === "list") {
|
|
101
222
|
const batchId = positional[0];
|
|
102
223
|
if (!batchId) usage();
|
|
103
224
|
const data = await fetchBatch(batchId);
|
|
@@ -120,6 +241,39 @@ if (cmd === "list") {
|
|
|
120
241
|
}
|
|
121
242
|
}
|
|
122
243
|
|
|
244
|
+
// REFACTOR PASS (1.7.0): post a brief refined on this machine. The server
|
|
245
|
+
// mints a new revision + contentHash for it and the Radar page shows the
|
|
246
|
+
// refined text ("Refined on your machine"); the claim then takes the new
|
|
247
|
+
// revision, so the bundle is rewritten from the server right after.
|
|
248
|
+
if (cmd === "refine") {
|
|
249
|
+
const [slugArg, briefId] = positional;
|
|
250
|
+
const slug = String(slugArg ?? "").trim().toLowerCase();
|
|
251
|
+
if (!/^[a-z0-9-]{1,32}$/.test(slug) || !briefId) usage();
|
|
252
|
+
const safe = String(briefId).replace(/[^A-Za-z0-9._-]+/g, "-");
|
|
253
|
+
const file = opt("--file") ?? path.resolve(".recorder", "radar", slug, "refined", `${safe}.json`);
|
|
254
|
+
let refined;
|
|
255
|
+
try { refined = JSON.parse(fs.readFileSync(file, "utf8")); } catch (e) { console.error(`Refined brief not readable at ${file}: ${e.message}\nWrite the refined brief there (copy .recorder/radar/${slug}/briefs/${safe}.json and edit it), or pass --file.`); process.exit(2); }
|
|
256
|
+
const content = refineContent(refined);
|
|
257
|
+
const problems = refineProblems(content);
|
|
258
|
+
if (problems.length) { console.error(`${file}: the refined brief breaks the server's limits — fix these and post again:\n ${problems.join("\n ")}`); process.exit(2); }
|
|
259
|
+
const data = await fetchBySlug(slug);
|
|
260
|
+
const brief = data.briefs.find((b) => String(b.briefId) === String(briefId));
|
|
261
|
+
if (!brief) { console.error(`Brief ${briefId} is not in the batch behind code ${slug}.`); process.exit(1); }
|
|
262
|
+
const note = opt("--note");
|
|
263
|
+
const r = await api("PUT", `/api/recorder/briefs/${encodeURIComponent(brief.briefId)}/refine`, {
|
|
264
|
+
revision: brief.revision, contentHash: brief.contentHash, content, ...(note ? { note: note.slice(0, 600) } : {}),
|
|
265
|
+
});
|
|
266
|
+
if (r.status === 404 && !r.json?.error) { console.error("This DemoBites has no refine route yet (older server). Film from the refined file on this machine; the Radar page keeps the cloud draft."); process.exit(1); }
|
|
267
|
+
if (r.status === 409 && (r.json?.error === "hash_mismatch" || r.json?.error === "active_attempt")) { console.error(`Brief ${briefId}: ${r.json.error === "active_attempt" ? "a live attempt holds it; a refine lands before the claim, never after" : "the server's revision moved; run list --slug again and refine from the current draft"}.`); process.exit(1); }
|
|
268
|
+
if (r.status === 410 && r.json?.error === "take_delivered") { console.error(`Brief ${briefId} is locked: a take was delivered for it, so its text on the Radar page is frozen. Film the re-take from your refined file; the Radar page keeps the delivered wording.`); process.exit(1); }
|
|
269
|
+
if (r.status === 410) { console.error(`Brief ${briefId} r${brief.revision} was superseded by a newer revision. Run list --slug again.`); process.exit(1); }
|
|
270
|
+
if (!r.ok || r.json?.revision === undefined) { console.error(`Refine failed: ${r.status} ${r.json ? JSON.stringify(r.json).slice(0, 200) : ""}`); process.exit(1); }
|
|
271
|
+
console.log(`Refined ${brief.briefId}: r${brief.revision} → r${r.json.revision} (${r.json.contentHash ?? "hash from the server"}). The Radar page shows it as refined on this machine.`);
|
|
272
|
+
const fresh = await fetchBySlug(slug);
|
|
273
|
+
writeRadarBundle(slug, fresh);
|
|
274
|
+
console.log(`Bundle rewritten: .recorder/radar/${slug}/bundle.json`);
|
|
275
|
+
}
|
|
276
|
+
|
|
123
277
|
if (cmd === "claim") {
|
|
124
278
|
const [batchId, briefId] = positional;
|
|
125
279
|
if (!batchId || !briefId) usage();
|
|
@@ -32,7 +32,8 @@ if (steps.length === 0 && checks.length === 0) {
|
|
|
32
32
|
process.exit(0);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
// RECORDER_PROFILE (1.7.0): a batch's parallel takes each prep and clean on their own seeded profile.
|
|
36
|
+
const profileDir = path.resolve(process.env.RECORDER_PROFILE || path.join(".recorder", "profile"));
|
|
36
37
|
const ctx = await chromium.launchPersistentContext(profileDir, { channel: "chrome", headless: true, viewport: { width: 1920, height: 1080 } }).catch(async () =>
|
|
37
38
|
chromium.launchPersistentContext(profileDir, { headless: true, viewport: { width: 1920, height: 1080 } }),
|
|
38
39
|
);
|
|
@@ -194,6 +194,9 @@ const camera = (man.shots ?? [])
|
|
|
194
194
|
label: s.label ?? undefined,
|
|
195
195
|
...(s.n != null ? { n: s.n } : {}),
|
|
196
196
|
...(s.revealed ? { revealed: true } : {}),
|
|
197
|
+
// WIDE (founder 2026-09-27): the camera at 1.0 for the span — the server
|
|
198
|
+
// ends the previous zoom at its start and writes no zoom row for it.
|
|
199
|
+
...(s.wide ? { wide: true } : {}),
|
|
197
200
|
...(s.glide ? { glide: { t_start: norm(s.glide.t_start), t_end: norm(s.glide.t_end) } } : {}),
|
|
198
201
|
}))
|
|
199
202
|
.filter((s) => s.t_end > s.t_start && s.t_start < duration && s.w > 0 && s.h > 0)
|
|
@@ -238,8 +241,15 @@ if (fs.existsSync(cleanPath)) {
|
|
|
238
241
|
const changedPx = yavg ? (parseFloat(yavg[1]) / 255) * FW * FH * SS * SS : 0;
|
|
239
242
|
return { x: x / SS, y: y / SS, w: w / SS, h: h / SS, changedPx: changedPx / (SS * SS) };
|
|
240
243
|
};
|
|
244
|
+
// FRAMING (founder 2026-09-27): a step the director framed `close` keeps its
|
|
245
|
+
// subject box — the page redrawing after an option pick is the landing, and
|
|
246
|
+
// the wide beat that follows frames it. The storyboard copy in the take dir
|
|
247
|
+
// is the recipe, so the frame is read from it by step number.
|
|
248
|
+
let framedClose = new Set();
|
|
249
|
+
try { const sb = JSON.parse(fs.readFileSync(path.join(dir, "storyboard.json"), "utf8")); (sb.steps ?? []).forEach((s, i) => { if (s?.frame === "close") framedClose.add(i + 1); }); } catch {}
|
|
241
250
|
for (const st of steps) {
|
|
242
251
|
if (!st.click) continue;
|
|
252
|
+
if (framedClose.has(st.n)) continue;
|
|
243
253
|
const hasRevealedShot = camera.some((c) => c.n === st.n && c.revealed);
|
|
244
254
|
if (hasRevealedShot) continue;
|
|
245
255
|
const control = camera.find((c) => c.n === st.n && !c.revealed);
|
|
@@ -269,7 +279,9 @@ if (fs.existsSync(cleanPath)) {
|
|
|
269
279
|
const wideStart = round2(st.click.t);
|
|
270
280
|
const wideEnd = round2(Math.min(duration, st.t_end));
|
|
271
281
|
if (wideEnd - wideStart >= 0.6) {
|
|
272
|
-
|
|
282
|
+
// `wide: true` so the server frames it at 1.0 (an untagged full frame
|
|
283
|
+
// used to land on the 1.25 zoom floor — a phantom zoom on the new page).
|
|
284
|
+
camera.push({ t_start: wideStart, t_end: wideEnd, x: 0, y: 0, w: FW, h: FH, n: st.n, wide: true, label: `${st.label || "click"}, new page` });
|
|
273
285
|
camera.sort((a, b) => a.t_start - b.t_start);
|
|
274
286
|
}
|
|
275
287
|
console.log(`consequence: step ${st.n} navigation — reset to wide at ${wideStart}s`);
|
package/skill/scripts/retake.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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) ->
|
|
11
21
|
// manifest -> upload --retake-of <biteId> (stages and delivers into the bite;
|
|
12
|
-
// the person watches it on the Demos grid).
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
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); }
|
|
@@ -149,6 +149,125 @@ export async function waitForDecision({ base, apiKey, stagingId, pageUrl, delive
|
|
|
149
149
|
return { exitCode: 0, status: "completed", biteId: approvedBiteId, studioUrl: finalStudioUrl };
|
|
150
150
|
}
|
|
151
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
|
+
|
|
152
271
|
/** One look, no waiting: the staged take's current status as the server sees it. */
|
|
153
272
|
export async function peekStaged({ base, apiKey, stagingId }) {
|
|
154
273
|
const res = await fetch(`${base}/api/recorder/stage?id=${encodeURIComponent(stagingId)}`, { headers: { Authorization: `Bearer ${apiKey}` } });
|
package/skill/scripts/status.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// same law as upload.mjs: no studio link before the bite is completed.
|
|
13
13
|
import fs from "node:fs";
|
|
14
14
|
import path from "node:path";
|
|
15
|
-
import { waitForDecision, peekStaged, deliverStaged, gridUrl, formatResetDate, waitingLine } from "./stage-wait.mjs";
|
|
15
|
+
import { waitForDecision, waitForRetake, peekStaged, deliverStaged, gridUrl, formatResetDate, waitingLine } from "./stage-wait.mjs";
|
|
16
16
|
|
|
17
17
|
const args = process.argv.slice(2);
|
|
18
18
|
const noWait = args.includes("--no-wait");
|
|
@@ -63,12 +63,20 @@ if (all) {
|
|
|
63
63
|
let stagingId = target;
|
|
64
64
|
let pageUrl = null;
|
|
65
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;
|
|
66
69
|
if (fs.existsSync(target) && fs.statSync(target).isDirectory()) {
|
|
67
70
|
const staged = readStaged(target);
|
|
68
71
|
if (!staged?.stagingId) { console.error(`${target} has no staged.json. Stage it first: node scripts/upload.mjs ${target} --stage-only`); process.exit(1); }
|
|
69
72
|
stagingId = staged.stagingId;
|
|
70
73
|
pageUrl = staged.dashboardUrl ?? null;
|
|
71
74
|
delivered = staged.delivered === 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
|
+
}
|
|
72
80
|
// A take whose delivery failed (network, 409) is delivered again here; the
|
|
73
81
|
// server is idempotent. An older server (pending) and a kept take (waiting
|
|
74
82
|
// for minutes, the server holds it) are left alone.
|
|
@@ -101,5 +109,7 @@ if (noWait) {
|
|
|
101
109
|
process.exit(st.ok ? 0 : 1);
|
|
102
110
|
}
|
|
103
111
|
|
|
104
|
-
const outcome =
|
|
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 });
|
|
105
115
|
process.exit(outcome.exitCode);
|
package/skill/scripts/upload.mjs
CHANGED
|
@@ -214,6 +214,11 @@ if (attempt) {
|
|
|
214
214
|
} catch (e) { console.error(`attempt event "uploading" failed: ${e.message} (continuing)`); }
|
|
215
215
|
}
|
|
216
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;
|
|
217
222
|
let stageRes;
|
|
218
223
|
try {
|
|
219
224
|
stageRes = await fetch(`${base}/api/recorder/stage`, {
|
|
@@ -270,7 +275,6 @@ await putS3(uploadUrl, "application/zip", zipPath, "ZIP");
|
|
|
270
275
|
await putS3(previewUploadUrl, "video/mp4", cleanPath, "Preview");
|
|
271
276
|
|
|
272
277
|
// ── delivery: EVERY take becomes a bite by itself ─────────────────────────
|
|
273
|
-
const { deliverStaged, waitForDecision, gridUrl, waitingLine } = await import("./stage-wait.mjs");
|
|
274
278
|
const delivery = await deliverStaged({ base, apiKey: cfg.api_key, stagingId, template: uploadedTemplate });
|
|
275
279
|
const grid = gridUrl(base, delivery.dashboardUrl);
|
|
276
280
|
// The staging id used to be printed only; a batch resumes from disk, so it is
|
|
@@ -286,6 +290,7 @@ try {
|
|
|
286
290
|
biteId: delivery.biteId ?? null, videoId: delivery.videoId ?? null, studioUrl: delivery.studioUrl ? new URL(delivery.studioUrl, base).toString() : null,
|
|
287
291
|
queued: delivery.queued ?? null, pending: delivery.pending ?? null, deliveryError: delivery.error ?? null,
|
|
288
292
|
api: uploadedTemplate ? { uploaded: uploadedTemplate } : null, at: new Date().toISOString(),
|
|
293
|
+
retakeOfBiteId: retakeOfBiteId ?? null, retakeBefore,
|
|
289
294
|
}, null, 2) + "\n");
|
|
290
295
|
} catch (e) { console.error(`staged.json not written: ${e.message}`); }
|
|
291
296
|
|
|
@@ -304,9 +309,12 @@ function openGrid() {
|
|
|
304
309
|
if (delivery.delivered) {
|
|
305
310
|
console.log(`Delivered. Watch it come in: ${grid}`);
|
|
306
311
|
openGrid();
|
|
307
|
-
if (stageOnly) { console.log(`Wait for the bite to finish later with: node scripts/status.mjs ${dir}`); process.exit(0); }
|
|
312
|
+
if (stageOnly) { console.log(`Wait for the ${retakeOfBiteId ? "new recording" : "bite"} to finish later with: node scripts/status.mjs ${dir}`); process.exit(0); }
|
|
308
313
|
// LAW (founder 2026-08-08): no studio link before the bite is completed.
|
|
309
|
-
|
|
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 });
|
|
310
318
|
process.exit(outcome.exitCode);
|
|
311
319
|
}
|
|
312
320
|
if (delivery.waiting) {
|