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.
- package/README.md +14 -13
- package/launcher/index.mjs +72 -7
- package/package.json +1 -1
- package/scripts/calibrate.mjs +17 -3
- package/scripts/record.mjs +144 -15
- package/skill/SKILL.md +126 -33
- 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 +73 -15
- package/skill/scripts/stage-wait.mjs +172 -8
- package/skill/scripts/status.mjs +44 -18
- package/skill/scripts/upload.mjs +72 -67
- package/skill/scripts/vocab.mjs +2 -1
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
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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(
|
|
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); }
|
|
@@ -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.
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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 {
|
|
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" : "
|
|
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.
|
|
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 —
|
|
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}` } });
|
package/skill/scripts/status.mjs
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
10
|
-
// agent (or the human, later) comes back here per take. The
|
|
11
|
-
//
|
|
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)}
|
|
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.
|
|
73
|
+
pageUrl = staged.dashboardUrl ?? null;
|
|
64
74
|
delivered = staged.delivered === true;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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(`
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
console.log(
|
|
76
|
-
} else console.error(`Not delivered: ${d.error}.
|
|
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 =
|
|
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 =
|
|
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);
|