demobite 1.8.3 → 1.9.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/launcher/index.mjs +97 -0
- package/package.json +1 -1
- package/scripts/record.mjs +5 -1
- package/skill/SKILL.md +10 -0
- package/skill/scripts/listen-event.mjs +27 -0
- package/skill/scripts/signin.mjs +28 -0
- package/skill/scripts/stage-wait.mjs +6 -0
- package/skill/scripts/storyboard.mjs +33 -2
package/launcher/index.mjs
CHANGED
|
@@ -178,6 +178,103 @@ if (!haveChrome) {
|
|
|
178
178
|
if (r.status !== 0) warn("Recording will fail until ffmpeg and ffprobe both work. Re-run this command, or install them on your system.");
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
|
|
182
|
+
// ── LISTENING (founder 2026-10-14 night, "YES!!!!"): `npx demobite listen`.
|
|
183
|
+
// The machine checks in to the app with the person's recorder key, pulls
|
|
184
|
+
// the jobs the Capture tab created for this owner, runs the person's own
|
|
185
|
+
// coding agent headless with the recorder skill, and posts every moment
|
|
186
|
+
// back. One verb — record — for the owner only; never "remote control".
|
|
187
|
+
// Closing the terminal ends it. `--base <url>` points at dev or a preview.
|
|
188
|
+
if (arg === "listen") {
|
|
189
|
+
const argvRest = process.argv.slice(3);
|
|
190
|
+
const baseFlag = argvRest[argvRest.indexOf("--base") + 1];
|
|
191
|
+
let cfg = readCfg();
|
|
192
|
+
const base = (argvRest.includes("--base") && baseFlag ? baseFlag : cfg?.base || "https://app.demobites.com").replace(/\/+$/, "");
|
|
193
|
+
if (!cfg?.api_key || (argvRest.includes("--base") && cfg?.base && cfg.base.replace(/\/+$/, "") !== base)) {
|
|
194
|
+
console.log("\n Connecting this project to your DemoBites account first…\n");
|
|
195
|
+
const r = spawnSync("node", [path.join(dest, "scripts", "login.mjs"), base], { stdio: "inherit", cwd: process.cwd() });
|
|
196
|
+
if (r.status !== 0) process.exit(r.status ?? 1);
|
|
197
|
+
cfg = readCfg();
|
|
198
|
+
}
|
|
199
|
+
if (!cfg?.api_key) { warn("Not connected — run: npx demobite login"); process.exit(1); }
|
|
200
|
+
const listenFile = path.join(process.cwd(), ".recorder", "listen.json");
|
|
201
|
+
let listen = {};
|
|
202
|
+
try { listen = JSON.parse(fs.readFileSync(listenFile, "utf8")); } catch { /* first time */ }
|
|
203
|
+
if (!listen.machine_id) {
|
|
204
|
+
listen.machine_id = (await import("node:crypto")).randomBytes(12).toString("hex");
|
|
205
|
+
fs.mkdirSync(path.dirname(listenFile), { recursive: true });
|
|
206
|
+
fs.writeFileSync(listenFile, JSON.stringify(listen, null, 2) + "\n");
|
|
207
|
+
}
|
|
208
|
+
const name = (argvRest[argvRest.indexOf("--name") + 1] && argvRest.includes("--name")) ? argvRest[argvRest.indexOf("--name") + 1] : os.hostname().replace(/\.local$/, "");
|
|
209
|
+
const agentBin = (() => { const r = spawnSync(process.platform === "win32" ? "where" : "which", ["claude"], { encoding: "utf8" }); return r.status === 0 ? r.stdout.trim().split("\n")[0] : null; })();
|
|
210
|
+
const readiness = () => {
|
|
211
|
+
let signins = [];
|
|
212
|
+
try { signins = Object.keys(JSON.parse(fs.readFileSync(path.join(process.cwd(), ".recorder", "signins.json"), "utf8")).origins || {}); } catch { /* none */ }
|
|
213
|
+
return { agent: agentBin ? "Claude Code" : null, chrome: haveChrome, recorder: pkg.version, signins, cwd: process.cwd(), platform: process.platform };
|
|
214
|
+
};
|
|
215
|
+
const api = async (body) => {
|
|
216
|
+
try {
|
|
217
|
+
const res = await fetch(`${base}/api/recorder/listen`, { method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${cfg.api_key}` }, body: JSON.stringify(body) });
|
|
218
|
+
if (res.status === 401) { console.error("\n The recorder key was refused. Run: npx demobite login"); process.exit(1); }
|
|
219
|
+
return res.ok ? await res.json() : null;
|
|
220
|
+
} catch { return null; }
|
|
221
|
+
};
|
|
222
|
+
const event = (job_id, kind, payload) => api({ action: "event", job_id, kind, payload });
|
|
223
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
224
|
+
|
|
225
|
+
console.log(`\n Listening as "${name}" for ${cfg.workspace?.name ?? "your workspace"} on ${base.replace(/^https?:\/\//, "")}.`);
|
|
226
|
+
console.log(` Ask for a demo in the app (Create → Agentic Capture). ${agentBin ? "Claude Code found." : "! Claude Code was not found on this machine — install it and sign in, it does the thinking."}`);
|
|
227
|
+
console.log(" Leave this terminal open; closing it stops the listening.\n");
|
|
228
|
+
|
|
229
|
+
const runJob = async (job) => {
|
|
230
|
+
console.log(`\n ▶ Job ${job.id}: ${job.brief.slice(0, 100)}${job.brief.length > 100 ? "…" : ""}`);
|
|
231
|
+
if (!agentBin) { await event(job.id, "failed", { message: "Claude Code is not installed on this machine" }); return; }
|
|
232
|
+
await event(job.id, "started", { machine: name });
|
|
233
|
+
const prompt = [
|
|
234
|
+
"Film a demo with the agentic-recorder skill. Read ~/.claude/skills/agentic-recorder/SKILL.md first and follow it exactly, in LISTENING MODE (its section \"Listening mode\"): the person is watching in the DemoBites app, not here — never ask anything in this conversation; the scripts carry every question to the app and bring the answer back.",
|
|
235
|
+
`Brief: ${job.brief}`,
|
|
236
|
+
job.url ? `Address to start from: ${job.url}` : "Address: work it out from this project (the saved config, the codebase).",
|
|
237
|
+
`Show the storyboard before filming: ${job.review_storyboard ? "yes — run storyboard.mjs await and obey its answer" : "no — storyboard.mjs await returns film at once"}.`,
|
|
238
|
+
].join("\n");
|
|
239
|
+
const child = (await import("node:child_process")).spawn(agentBin, ["-p", prompt, "--output-format", "stream-json", "--verbose", "--dangerously-skip-permissions"], {
|
|
240
|
+
cwd: process.cwd(),
|
|
241
|
+
env: { ...process.env, RECORDER_JOB: job.id, RECORDER_BASE: base, RECORDER_LISTEN: "1" },
|
|
242
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
243
|
+
});
|
|
244
|
+
let buf = "";
|
|
245
|
+
child.stdout.on("data", (chunk) => {
|
|
246
|
+
buf += chunk.toString();
|
|
247
|
+
let nl;
|
|
248
|
+
while ((nl = buf.indexOf("\n")) >= 0) {
|
|
249
|
+
const line = buf.slice(0, nl); buf = buf.slice(nl + 1);
|
|
250
|
+
if (!line.trim()) continue;
|
|
251
|
+
try {
|
|
252
|
+
const m = JSON.parse(line);
|
|
253
|
+
if (m.type === "assistant") {
|
|
254
|
+
for (const c of m.message?.content ?? []) {
|
|
255
|
+
if (c.type === "text" && c.text?.trim()) { console.log(` · ${c.text.trim().split("\n")[0].slice(0, 140)}`); void event(job.id, "agent", { text: c.text.trim().slice(0, 1200) }); }
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
} catch { /* not a JSON line */ }
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
child.stderr.on("data", (chunk) => { const t = chunk.toString().trim(); if (t) console.error(` ! ${t.split("\n")[0].slice(0, 160)}`); });
|
|
262
|
+
const code = await new Promise((resolve) => child.on("close", resolve));
|
|
263
|
+
const st = await api({ action: "poll", job_id: job.id });
|
|
264
|
+
if (st && !["ready", "failed", "cancelled"].includes(st.status)) {
|
|
265
|
+
if (code === 0) await event(job.id, "note", { text: "The agent finished without a ready signal." });
|
|
266
|
+
await event(job.id, "failed", { message: code === 0 ? "The demo did not come through — try again" : `The agent stopped (exit ${code})` });
|
|
267
|
+
}
|
|
268
|
+
console.log(` ■ Job ${job.id} ${st?.status ?? "ended"}.`);
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
for (;;) {
|
|
272
|
+
const r = await api({ action: "checkin", machine_id: listen.machine_id, name, readiness: readiness() });
|
|
273
|
+
if (r?.job) await runJob(r.job);
|
|
274
|
+
else await sleep(5000);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
181
278
|
// ── 3. Subcommands ─────────────────────────────────────────────────────────
|
|
182
279
|
if (arg === "login" || arg === "logout") {
|
|
183
280
|
// `login <base>` forwards the base (dev, a preview) to login.mjs, which
|
package/package.json
CHANGED
package/scripts/record.mjs
CHANGED
|
@@ -12,6 +12,10 @@
|
|
|
12
12
|
import { chromium } from "playwright";
|
|
13
13
|
import fs from "node:fs";
|
|
14
14
|
import path from "node:path";
|
|
15
|
+
// LISTENING (2026-10-14): the app's mailbox, when `npx demobite listen` runs
|
|
16
|
+
// this take; a no-op everywhere else (the terminal flow, the cloud sandbox
|
|
17
|
+
// copy that may not ship listen-event.mjs).
|
|
18
|
+
const postEvent = await import("./listen-event.mjs").then((m) => m.postEvent).catch(() => async () => null);
|
|
15
19
|
|
|
16
20
|
const [, , outArg, storyArg] = process.argv;
|
|
17
21
|
if (!outArg || !storyArg) {
|
|
@@ -775,7 +779,7 @@ try {
|
|
|
775
779
|
process.stdout.write(`step ${rec.n} ${step.action} ${step.label ?? ""}\n`);
|
|
776
780
|
// The customer's line (founder 2026-10-14): one bar, step n of N. The
|
|
777
781
|
// agent relays this, never the step/action lines above.
|
|
778
|
-
{ const N = STORYBOARD.steps.length, filled = Math.round((rec.n / N) * 20); process.stdout.write(`Filming ${"█".repeat(filled)}${"░".repeat(20 - filled)} step ${rec.n} of ${N}\n`); }
|
|
782
|
+
{ const N = STORYBOARD.steps.length, filled = Math.round((rec.n / N) * 20); process.stdout.write(`Filming ${"█".repeat(filled)}${"░".repeat(20 - filled)} step ${rec.n} of ${N}\n`); void postEvent("filming", { n: rec.n, N }); }
|
|
779
783
|
if (step.action === "goto") {
|
|
780
784
|
const navIssuedAt = t();
|
|
781
785
|
// domcontentloaded, not load: a heavy site behind a proxy (the cloud
|
package/skill/SKILL.md
CHANGED
|
@@ -26,6 +26,16 @@ The plugin is a product. Every line you say is read by the person who asked for
|
|
|
26
26
|
- **Demo, never recording.** The thing the person gets is a demo; the act is filming.
|
|
27
27
|
- **Findings go to us, never to the person.** Anything you learned about the recorder, the installer, the camera or DemoBites itself (a title that got cut, a merge rule, a missing encoder) is written to `.recorder/notes.md` in the project — dated, one paragraph each — and never said in the chat. The person asked for a demo, not a report on our product. (Founder 2026-10-14: "all this nonsense and all this explanation is not relevant.")
|
|
28
28
|
|
|
29
|
+
## Listening mode — the person is in the app, not here
|
|
30
|
+
|
|
31
|
+
When `RECORDER_JOB` is set (the take was asked for in the DemoBites app and `npx demobite listen` is running you), the person watches the Capture tab, not this conversation. Everything you would have said to them, the scripts carry to the app; everything you would have asked, the app answers:
|
|
32
|
+
|
|
33
|
+
- **Never ask anything here.** No sign-in question, no "1 / 2 / 3", no "say yes". Nobody reads this conversation.
|
|
34
|
+
- **The sign-in:** when `signin.mjs --check <url>` says "none", run `signin.mjs <url>` at once — it tells the app "sign in on your machine" and the window opens there.
|
|
35
|
+
- **The storyboard:** after writing `storyboard.json`, run `node scripts/storyboard.mjs await <takeDir>/storyboard.json` instead of printing the box. It sends the box to the app and prints the person's answer: `film` → Phase 5; `change: <note>` → rewrite the storyboard, run `await` again; `cancelled` → stop, say nothing.
|
|
36
|
+
- **Filming, delivery, ready:** `record.mjs`, `upload.mjs` and `status.mjs` post their moments by themselves. Run them exactly as in the phases; the app shows the bar and the editor link.
|
|
37
|
+
- **Findings** still go to `.recorder/notes.md`.
|
|
38
|
+
|
|
29
39
|
## Phase 0: Auth gate, ALWAYS FIRST — announced, never silent
|
|
30
40
|
|
|
31
41
|
This skill does not start unauthenticated — exactly like a CLI that requires
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// AGENTIC CAPTURE — LISTENING (founder 2026-10-14): the recorder's moments,
|
|
2
|
+
// posted to the app's mailbox when a job is running (`npx demobite listen`
|
|
3
|
+
// sets RECORDER_JOB and RECORDER_BASE). Outside listening mode every call is
|
|
4
|
+
// a no-op, so the terminal flow is untouched. Never throws: a lost event
|
|
5
|
+
// never stops a take.
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
const job = process.env.RECORDER_JOB || "";
|
|
10
|
+
const base = (process.env.RECORDER_BASE || "").replace(/\/+$/, "");
|
|
11
|
+
export const listening = Boolean(job && base);
|
|
12
|
+
|
|
13
|
+
const apiKey = () => {
|
|
14
|
+
try { return JSON.parse(fs.readFileSync(path.resolve(process.cwd(), ".recorder", "config.json"), "utf8")).api_key || ""; } catch { return ""; }
|
|
15
|
+
};
|
|
16
|
+
const call = async (body) => {
|
|
17
|
+
const key = apiKey();
|
|
18
|
+
if (!listening || !key) return null;
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch(`${base}/api/recorder/listen`, { method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${key}` }, body: JSON.stringify({ job_id: job, ...body }) });
|
|
21
|
+
return res.ok ? await res.json() : null;
|
|
22
|
+
} catch { return null; }
|
|
23
|
+
};
|
|
24
|
+
/** One moment of the job: started | signin_wait | signed_in | storyboard | filming | delivered | ready | failed | note | agent */
|
|
25
|
+
export const postEvent = (kind, payload) => call({ action: "event", kind, payload });
|
|
26
|
+
/** The job's status and the person's answer at a stopping point. */
|
|
27
|
+
export const poll = () => call({ action: "poll" });
|
package/skill/scripts/signin.mjs
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
// recorder's own health check, no human needed.
|
|
27
27
|
import fs from "node:fs";
|
|
28
28
|
import path from "node:path";
|
|
29
|
+
import { postEvent } from "./listen-event.mjs";
|
|
29
30
|
|
|
30
31
|
const argv = process.argv.slice(2);
|
|
31
32
|
const ledgerPath = path.resolve(process.cwd(), ".recorder", "signins.json");
|
|
@@ -85,7 +86,33 @@ if (probe) {
|
|
|
85
86
|
process.exit(state === "failed" ? 1 : 0);
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
// NO WALL? (founder 2026-10-14: "open the page and look"; the first listening
|
|
90
|
+
// run waited four minutes on a public site). Give the page a few seconds; if
|
|
91
|
+
// nothing on it asks for a sign-in — no password field, no sign-in path, no
|
|
92
|
+
// bot check — remember the address as open and leave without a word.
|
|
93
|
+
const wallCheck = async () => {
|
|
94
|
+
try {
|
|
95
|
+
return await page.evaluate(() => {
|
|
96
|
+
const pw = document.querySelector('input[type="password"]');
|
|
97
|
+
const path = location.pathname.toLowerCase();
|
|
98
|
+
const loginPath = /sign-?in|log-?in|login|auth\b|sso|signup|register/.test(path);
|
|
99
|
+
const bot = !!document.querySelector('input[name="cf-turnstile-response"], iframe[src*="challenges.cloudflare"], iframe[src*="recaptcha"], iframe[src*="hcaptcha"]');
|
|
100
|
+
return { wall: !!pw || loginPath || bot };
|
|
101
|
+
});
|
|
102
|
+
} catch { return { wall: true }; }
|
|
103
|
+
};
|
|
104
|
+
await page.waitForTimeout(4000);
|
|
105
|
+
const first = await wallCheck();
|
|
106
|
+
if (!first.wall) {
|
|
107
|
+
const origin = new URL(page.url()).origin;
|
|
108
|
+
console.log(`✓ No sign-in needed for ${origin}.`);
|
|
109
|
+
try { const l = readLedger(); l.origins = { ...(l.origins ?? {}), [origin]: new Date().toISOString() }; fs.mkdirSync(path.dirname(ledgerPath), { recursive: true }); fs.writeFileSync(ledgerPath, JSON.stringify(l, null, 2) + "\n"); } catch { /* fine */ }
|
|
110
|
+
await postEvent("signed_in", { origin, none: true });
|
|
111
|
+
await ctx.close();
|
|
112
|
+
process.exit(0);
|
|
113
|
+
}
|
|
88
114
|
console.log("⏳ Waiting for you — sign in in the Chrome window that just opened. Your hands only; nothing you type is seen.");
|
|
115
|
+
await postEvent("signin_wait", { url });
|
|
89
116
|
const started = Date.now();
|
|
90
117
|
let lastCheck = "none";
|
|
91
118
|
while (Date.now() - started < timeoutMs) {
|
|
@@ -94,6 +121,7 @@ while (Date.now() - started < timeoutMs) {
|
|
|
94
121
|
try { here = new URL(page.url()); } catch { continue; }
|
|
95
122
|
if (here.pathname !== startPath && !/sign-?in|log-?in|auth|sso|oauth|callback/i.test(here.pathname)) {
|
|
96
123
|
console.log(`✓ Signed in. The session is saved for ${here.origin}; next time there is no question and no window.`);
|
|
124
|
+
await postEvent("signed_in", { origin: here.origin });
|
|
97
125
|
try { const l = readLedger(); l.origins = { ...(l.origins ?? {}), [here.origin]: new Date().toISOString() }; fs.mkdirSync(path.dirname(ledgerPath), { recursive: true }); fs.writeFileSync(ledgerPath, JSON.stringify(l, null, 2) + "\n"); } catch { /* the session still lives in the profile */ }
|
|
98
126
|
await ctx.close();
|
|
99
127
|
process.exit(0);
|
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
// waiting: "minutes", resetsAt, dashboardUrl, stagingId }) and waits on the
|
|
19
19
|
// same grid; the stage status reads "waiting" until minutes are back.
|
|
20
20
|
|
|
21
|
+
// LISTENING (2026-10-14): the app's mailbox, when `npx demobite listen` runs
|
|
22
|
+
// this take; a no-op everywhere else (the terminal flow, the cloud sandbox
|
|
23
|
+
// copy that may not ship listen-event.mjs).
|
|
24
|
+
const postEvent = await import("./listen-event.mjs").then((m) => m.postEvent).catch(() => async () => null);
|
|
21
25
|
/** Where the person watches their takes: the Demos grid. `dashboardUrl` from
|
|
22
26
|
* the server wins (relative or absolute), else <base>/demos. */
|
|
23
27
|
export function gridUrl(base, dashboardUrl) {
|
|
@@ -106,6 +110,7 @@ export async function waitForDecision({ base, apiKey, stagingId, pageUrl, delive
|
|
|
106
110
|
if (!announced) {
|
|
107
111
|
process.stdout.write("\n");
|
|
108
112
|
console.log(st.status === "delivered" ? `delivered: bite ${st.biteId} is being created` : `Approved — bite ${st.biteId} is being created`);
|
|
113
|
+
void postEvent("delivered", { biteId: st.biteId });
|
|
109
114
|
announced = true;
|
|
110
115
|
approvedBiteId = st.biteId;
|
|
111
116
|
finalStudioUrl = st.studioUrl ? new URL(st.studioUrl, base).toString() : null;
|
|
@@ -146,6 +151,7 @@ export async function waitForDecision({ base, apiKey, stagingId, pageUrl, delive
|
|
|
146
151
|
if (last.zooms === 0) console.error("WARNING: no camera shots landed.");
|
|
147
152
|
}
|
|
148
153
|
if (finalStudioUrl) console.log(`Studio: ${finalStudioUrl}`);
|
|
154
|
+
await postEvent("ready", { biteId: approvedBiteId, studioUrl: finalStudioUrl ?? null });
|
|
149
155
|
return { exitCode: 0, status: "completed", biteId: approvedBiteId, studioUrl: finalStudioUrl };
|
|
150
156
|
}
|
|
151
157
|
|
|
@@ -6,15 +6,19 @@
|
|
|
6
6
|
//
|
|
7
7
|
// node scripts/storyboard.mjs print <storyboard.json> the box: number, time, one line per step, the total
|
|
8
8
|
// node scripts/storyboard.mjs words <storyboard.json> every step's narration, word for word (option 3)
|
|
9
|
+
// node scripts/storyboard.mjs await <storyboard.json> LISTENING MODE: posts the box to the app and waits for the
|
|
10
|
+
// person's answer there — prints "film" or "change: <note>"
|
|
11
|
+
// (or "cancelled"); never asks anything in the chat
|
|
9
12
|
//
|
|
10
13
|
// Times are estimates from the steps themselves: a step lasts its dwell, or
|
|
11
14
|
// as long as its narration takes to say (about 2.5 words a second), with a
|
|
12
15
|
// floor per action. The real cut is fitted by DemoBites afterwards.
|
|
13
16
|
import fs from "node:fs";
|
|
17
|
+
import { listening, postEvent, poll } from "./listen-event.mjs";
|
|
14
18
|
|
|
15
19
|
const [mode, file] = process.argv.slice(2);
|
|
16
|
-
if (!mode || !file || !["print", "words"].includes(mode)) {
|
|
17
|
-
console.error("Usage: node scripts/storyboard.mjs print|words <storyboard.json>");
|
|
20
|
+
if (!mode || !file || !["print", "words", "await"].includes(mode)) {
|
|
21
|
+
console.error("Usage: node scripts/storyboard.mjs print|words|await <storyboard.json>");
|
|
18
22
|
process.exit(2);
|
|
19
23
|
}
|
|
20
24
|
const sb = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
@@ -45,6 +49,33 @@ if (mode === "words") {
|
|
|
45
49
|
process.exit(0);
|
|
46
50
|
}
|
|
47
51
|
|
|
52
|
+
if (mode === "await") {
|
|
53
|
+
// The box goes to the app; the answer comes back from the app. With the
|
|
54
|
+
// person's "show me the storyboard" box off, the app moves the job on at
|
|
55
|
+
// once and this prints "film" without waiting.
|
|
56
|
+
const boxOut = [];
|
|
57
|
+
const W2 = 76;
|
|
58
|
+
const cap2 = (t, n) => (t.length > n ? t.slice(0, n - 1) + "…" : t);
|
|
59
|
+
const head2 = `─ STORYBOARD · ${cap2(title, 40)} `; const tail2 = ` ${mmss(total)} ─`;
|
|
60
|
+
boxOut.push(`┌${head2}${"─".repeat(Math.max(1, W2 - head2.length - tail2.length))}${tail2}┐`); boxOut.push(`│ ${"".padEnd(W2 - 2)} │`);
|
|
61
|
+
rows.forEach((r, i) => boxOut.push(`│ ${`${String(i + 1).padStart(2)} ${mmss(r.at)} ${cap2(r.text + (r.pointed ? " — pointed at, not pressed" : ""), W2 - 14)}`.padEnd(W2 - 2)} │`));
|
|
62
|
+
boxOut.push(`│ ${"".padEnd(W2 - 2)} │`); boxOut.push(`└${"─".repeat(W2)}┘`);
|
|
63
|
+
if (!listening) { console.log(boxOut.join("\n")); console.log("film"); process.exit(0); }
|
|
64
|
+
await postEvent("storyboard", { box: boxOut.join("\n"), total: mmss(total), steps: rows.map((r, i) => ({ n: i + 1, at: mmss(r.at), text: r.text, pointed: r.pointed })) });
|
|
65
|
+
const started = Date.now();
|
|
66
|
+
while (Date.now() - started < 30 * 60 * 1000) {
|
|
67
|
+
const st = await poll();
|
|
68
|
+
if (st?.status === "cancelled" || st?.status === "failed") { console.log("cancelled"); process.exit(2); }
|
|
69
|
+
if (st && st.status !== "waiting_storyboard") {
|
|
70
|
+
const a = st.answer && typeof st.answer === "object" ? st.answer : null;
|
|
71
|
+
if (a?.action === "change") { console.log(`change: ${a.note ?? ""}`); process.exit(0); }
|
|
72
|
+
console.log("film"); process.exit(0);
|
|
73
|
+
}
|
|
74
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
75
|
+
}
|
|
76
|
+
console.log("cancelled"); process.exit(2);
|
|
77
|
+
}
|
|
78
|
+
|
|
48
79
|
const W = 76;
|
|
49
80
|
const cap = (t, n) => (t.length > n ? t.slice(0, n - 1) + "…" : t);
|
|
50
81
|
const head = `─ STORYBOARD · ${cap(title, 40)} `;
|