demobite 1.0.9 → 1.2.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 +125 -79
- package/launcher/index.mjs +52 -9
- package/package.json +7 -3
- package/recorder/scripts/mux.mjs +4 -11
- package/recorder/scripts/post.sh +6 -3
- package/recorder/scripts/tts.mjs +3 -9
- package/scripts/calibrate.mjs +4 -2
- package/scripts/check-aliases.mjs +42 -0
- package/scripts/media-tools.mjs +93 -0
- package/scripts/trim.mjs +28 -13
- package/skill/SKILL.md +84 -3
- package/skill/scripts/briefs.mjs +164 -0
- package/skill/scripts/cleanup.mjs +104 -0
- package/skill/scripts/manifest.mjs +3 -2
- package/skill/scripts/stage-wait.mjs +87 -0
- package/skill/scripts/status.mjs +72 -0
- package/skill/scripts/upload.mjs +75 -79
- package/skill/scripts/vocab.mjs +88 -0
package/scripts/trim.mjs
CHANGED
|
@@ -15,15 +15,8 @@ if (!dir) {
|
|
|
15
15
|
console.error("Usage: node trim.mjs <takeDir>");
|
|
16
16
|
process.exit(2);
|
|
17
17
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
catch {
|
|
21
|
-
console.error(`${tool} is required on PATH. Install it (macOS: brew install ffmpeg) and rerun.`);
|
|
22
|
-
process.exit(1);
|
|
23
|
-
}
|
|
24
|
-
};
|
|
25
|
-
requireTool("ffmpeg");
|
|
26
|
-
requireTool("ffprobe");
|
|
18
|
+
import { ffmpeg as FFMPEG, ffprobe as FFPROBE, requireMediaTools } from "./media-tools.mjs";
|
|
19
|
+
requireMediaTools();
|
|
27
20
|
|
|
28
21
|
const raw = path.join(dir, "raw.webm");
|
|
29
22
|
const manPath = path.join(dir, "manifest.json");
|
|
@@ -61,7 +54,7 @@ if ((man.beacon?.flips?.length ?? 0) >= 3) {
|
|
|
61
54
|
// The flashes are the first big whole-frame changes in the head. Search a
|
|
62
55
|
// window generous enough for seconds of anchor error in either direction.
|
|
63
56
|
const searchEnd = Math.min(Math.max(r0, flips[flips.length - 1].wall) + 10, 45);
|
|
64
|
-
const res = spawnSync(
|
|
57
|
+
const res = spawnSync(FFMPEG(), [
|
|
65
58
|
"-loglevel", "info", "-t", String(searchEnd), "-i", raw,
|
|
66
59
|
"-vf", "select='gt(scene,0.3)',showinfo", "-f", "null", "-",
|
|
67
60
|
], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
@@ -111,6 +104,28 @@ if ((man.beacon?.flips?.length ?? 0) >= 3) {
|
|
|
111
104
|
}
|
|
112
105
|
|
|
113
106
|
const cutAt = Math.max(0, r0 - beaconDelta);
|
|
107
|
+
|
|
108
|
+
// TAIL law (vanilla-Linux smoke 2026-09-09): raw.webm keeps receiving frames
|
|
109
|
+
// while the browser context tears down — on a headless shell that was a
|
|
110
|
+
// ~10 s FROZEN tail after the last beat, and the agent had to cut it by hand.
|
|
111
|
+
// The story ends where the recorder's last step ended; keep one breath after
|
|
112
|
+
// it and cut the rest. Clean time = wall - record_from (identity law above),
|
|
113
|
+
// so the story's end in raw time is cutAt + (lastStepEnd - r0). Only applied
|
|
114
|
+
// when the raw file actually runs on past that point.
|
|
115
|
+
const TAIL_BEAT_S = 0.8;
|
|
116
|
+
const lastStepEnd = Math.max(0, ...(man.steps ?? []).map((st) => Number(st.t_end) || 0));
|
|
117
|
+
let tailCut = 0;
|
|
118
|
+
if (lastStepEnd > r0) {
|
|
119
|
+
const storyEndRaw = cutAt + (lastStepEnd - r0) + TAIL_BEAT_S;
|
|
120
|
+
let rawDur = 0;
|
|
121
|
+
try {
|
|
122
|
+
rawDur = parseFloat(execFileSync(FFPROBE(), ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", raw]).toString().trim()) || 0;
|
|
123
|
+
} catch { /* webm without a duration header — fall through, no tail cut */ }
|
|
124
|
+
if (rawDur && rawDur - storyEndRaw > 1.0) {
|
|
125
|
+
tailCut = Math.round(storyEndRaw * 100) / 100;
|
|
126
|
+
console.log(`tail: raw runs ${(rawDur - storyEndRaw).toFixed(1)}s past the last beat — cutting at ${tailCut.toFixed(2)}s (last step ended at ${(lastStepEnd - r0).toFixed(2)}s of clean time)`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
114
129
|
if (cutAt !== r0 - beaconDelta) console.log("beacon: corrected cut clamped at 0 — head shorter than the anchor error");
|
|
115
130
|
man.timebase = {
|
|
116
131
|
a: 1,
|
|
@@ -123,17 +138,17 @@ man.timebase = {
|
|
|
123
138
|
fs.writeFileSync(manPath, JSON.stringify(man, null, 2));
|
|
124
139
|
console.log(`timebase: identity, video = wall - ${r0.toFixed(3)}${beaconMethod ? ` (cut at raw ${cutAt.toFixed(3)}s)` : ""}`);
|
|
125
140
|
|
|
126
|
-
execFileSync(
|
|
141
|
+
execFileSync(FFMPEG(), [
|
|
127
142
|
"-y", "-loglevel", "error",
|
|
128
143
|
"-i", raw,
|
|
129
|
-
"-filter_complex", `[0:v]trim
|
|
144
|
+
"-filter_complex", `[0:v]trim=${tailCut ? `start=${cutAt}:end=${tailCut}` : `start=${cutAt}`},setpts=PTS-STARTPTS,fps=30,format=yuv420p[out]`,
|
|
130
145
|
"-map", "[out]",
|
|
131
146
|
"-c:v", "libx264", "-preset", "medium", "-crf", "19",
|
|
132
147
|
"-movflags", "+faststart",
|
|
133
148
|
clean,
|
|
134
149
|
], { stdio: "inherit" });
|
|
135
150
|
|
|
136
|
-
const dur = execFileSync(
|
|
151
|
+
const dur = execFileSync(FFPROBE(), [
|
|
137
152
|
"-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", clean,
|
|
138
153
|
]).toString().trim();
|
|
139
154
|
const duration = parseFloat(dur);
|
package/skill/SKILL.md
CHANGED
|
@@ -7,7 +7,7 @@ description: Film a product demo by driving a real browser from a storyboard and
|
|
|
7
7
|
|
|
8
8
|
You are the camera operator, the director, and the editor. You film a real browser doing a real flow, narrate it, and deliver a clean take into DemoBites, where everything — voice, camera, cursor, look — becomes editable. There is ONE delivery: a DemoBites bite. Never ask how the demo should be delivered.
|
|
9
9
|
|
|
10
|
-
All scripts live in `scripts/` beside this file. They are plain Node ESM. Requirements: Node 18
|
|
10
|
+
All scripts live in `scripts/` beside this file. They are plain Node ESM. Requirements: Node 18+. `npx demobite` installs Playwright, ffmpeg and ffprobe beside the skill; every script resolves the media tools through `scripts/media-tools.mjs` (a compatible system build first, then the packaged one). Never call `ffmpeg` or `ffprobe` by bare name in a new script. Run every script from the project directory so `.recorder/` lands next to the project.
|
|
11
11
|
|
|
12
12
|
Follow the phases in order. Never skip the storyboard approval. Never ingest before the human's word — for DemoBites, Approve on the in-app preview page IS the word.
|
|
13
13
|
|
|
@@ -82,6 +82,29 @@ When a page you need shows a login wall (login form, auth redirect, checkpoint p
|
|
|
82
82
|
|
|
83
83
|
Write the storyboard as JSON before touching the camera.
|
|
84
84
|
|
|
85
|
+
### Phase 3a: Harvest the product's vocabulary FIRST
|
|
86
|
+
|
|
87
|
+
The demo speaks the product's CURRENT words, never the brief's, never the pull request's, never your memory's. Products get renamed between the moment a brief is written and the moment you film (founder, 2026-09-14: a take said "Release Readiness" while the app's rail said "Assignments").
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
node scripts/vocab.mjs <takeDir> <url of every screen the take visits>
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
It opens each screen on the recorder profile, without video, hovers the rail so tooltips render, and writes `<takeDir>/vocab.json`: nav labels with their tooltips and aria labels, page headings, button and link labels, dialog titles. **Every noun in `narration` and `on_screen` must appear in vocab.json.** The brief's and the PR's words are hints about WHAT changed and where to look; when a brief's noun is missing from the app, say so in the storyboard presentation ("the brief says allowlist, the app says Who can enter") and use the app's word.
|
|
94
|
+
|
|
95
|
+
### LAW: the camera shows an action to its end
|
|
96
|
+
|
|
97
|
+
The agent sits on the running product with a signed-in account. It knows the flow. It performs it. A take that walks into an empty page and narrates "if there were something here" is forbidden; so is "here you would see" (founder, 2026-09-14).
|
|
98
|
+
|
|
99
|
+
- **a. Reversible actions are performed for real.** Create the briefing, add the bites, create the assignment with safe people, move the zoom, press Save. Reversible means you can return the workspace to its prior state after the cut.
|
|
100
|
+
- **b. Every take returns the workspace to its initial state, after the camera stops.** The storyboard declares the plan in `cleanup[]` (steps, same schema, run headless by `cleanup.mjs` after `record.mjs`, before `upload.mjs`) and in `cleanup_plan[]` (plain sentences the human reads: "After the cut: delete briefing X, remove assignment Y"). `checks.after[]` proves it (expect / absent selectors). A step you cannot revert is not performed.
|
|
101
|
+
- **c. Irreversible actions are not performed.** An export that spends minutes, an email to real people, a payment, a publish to a real customer's live page, deleting existing content. The cursor goes to the control, the narration names what it does, the button is not pressed. That is the 99 percent rule: bring the viewer to the last click and name it. Mark these beats in the storyboard with `"pointed": true` and in the presentation with "pointed at, not pressed".
|
|
102
|
+
- **d. Cancel is never a beat.** Never say "we cancel because this is a demo", never zoom on a Cancel button, never make the escape a scene. A dialog that must close without committing closes through the X, the backdrop or Escape, off narration, without a zoom, in the gap between beats. When the dialog's confirm IS reversible, press it (rule a).
|
|
103
|
+
- **e. Empty states are a failure of preparation, not a scene.** If the flow needs data, `prep[]` creates it before the camera (`node scripts/cleanup.mjs <takeDir> --prep`, checked by `checks.before[]`), the take shows the flow, `cleanup[]` removes it.
|
|
104
|
+
- **f. Never present a screen you did not reach.**
|
|
105
|
+
|
|
106
|
+
`upload.mjs` refuses to stage a take whose storyboard declares `cleanup[]` until `cleanup.json` says the cleanup ran and its checks passed (`--allow-uncleaned` overrides, and prints that it did).
|
|
107
|
+
|
|
85
108
|
### LAW: the video is the metronome, not the script
|
|
86
109
|
|
|
87
110
|
**The narration is INTENT, never final copy.** In the DemoBites ending it is handed to the ingestion, which rescripts it and refits it to the video exactly as it does for a customer's own uploaded voice. So never stretch a shot to cover a sentence. A shot is as long as the ACTION needs, and the words get fitted to it afterwards.
|
|
@@ -132,6 +155,34 @@ Storyboard schema:
|
|
|
132
155
|
|
|
133
156
|
Step fields: `action` is one of `goto | settle | scroll | click | hover | type | expect`. **Durations (`dwell`, `after`, settle `ms`, scroll `ms`) are milliseconds; a value under 60 is read as seconds** (write `"dwell": 3400` or `"dwell": 3.4`, never `"dwell": 3` meaning 3 ms). `goto` needs `url`. `settle` takes `ms` and an optional `focus` selector. `scroll` needs `dy` and takes `ms`. `click`/`hover` need `selector` and take `minY` (minimum Y for the visible instance pick), `dwell`, `after`, `waitLoad`. Every step takes `label` and `narration`.
|
|
134
157
|
|
|
158
|
+
Beyond `steps`, a storyboard may carry the off-camera blocks (Phase 3a/law above); `cleanup.mjs` runs them on the same profile without video:
|
|
159
|
+
|
|
160
|
+
```json
|
|
161
|
+
{
|
|
162
|
+
"prep": [ { "action": "goto", "url": "https://app.acme.com/briefings" }, { "action": "click", "selector": "button:has-text('Create')", "after": 2000 } ],
|
|
163
|
+
"checks": { "before": [ { "action": "expect", "selector": "text=Demo briefing" } ], "after": [ { "action": "absent", "selector": "text=Demo briefing" } ] },
|
|
164
|
+
"cleanup": [ { "action": "goto", "url": "https://app.acme.com/briefings" }, { "action": "click", "selector": "button:has-text('Delete')", "after": 1500 } ],
|
|
165
|
+
"cleanup_plan": [ "After the cut: delete the briefing 'Demo briefing' created for this take.", "After the cut: remove the assignment for demo@acme.com." ]
|
|
166
|
+
}
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Off-camera steps add `press` (`"key": "Escape"`), `wait` (`"ms"`), `expect` and `absent` (checks).
|
|
170
|
+
|
|
171
|
+
A real cleanup, taken from a filmed take (DemoBites, the Enablement Center list): the row menu is a button with `title="Briefing options"`, Delete opens a dialog that asks the name to be typed, then "Permanently Delete".
|
|
172
|
+
|
|
173
|
+
```json
|
|
174
|
+
"cleanup": [
|
|
175
|
+
{ "action": "goto", "url": "https://app.demobites.com/enablement-center", "after": 4000 },
|
|
176
|
+
{ "action": "click", "selector": "div.group:has-text('Onboarding briefing (demo)') button[title='Briefing options']", "after": 800 },
|
|
177
|
+
{ "action": "click", "selector": "[role='menu'] [role='menuitem']:has-text('Delete')", "after": 1200 },
|
|
178
|
+
{ "action": "type", "selector": "[role='dialog'] input", "text": "Onboarding briefing (demo)" },
|
|
179
|
+
{ "action": "click", "selector": "[role='dialog'] button:has-text('Permanently Delete')", "after": 4000 }
|
|
180
|
+
],
|
|
181
|
+
"checks": { "after": [ { "action": "absent", "selector": "text=Onboarding briefing (demo)" } ] }
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
A step with `"required": false` may fail without stopping the rest. A camera step with `"pointed": true` is an irreversible action the cursor reaches and names but never presses (law c).
|
|
185
|
+
|
|
135
186
|
Two fields carry the whole advantage of this lane, so fill them in:
|
|
136
187
|
|
|
137
188
|
- **`on_screen`** describes what the viewer is looking at during the beat. It rides into the ingestion's rescripting stage, so the model writes narration while KNOWING the cursor is on the degree badge and the menu just opened. A microphone can never supply this. Write it for every narrated beat.
|
|
@@ -141,6 +192,8 @@ Use `hideCss` for chat widgets and cookie banners that would pollute the picture
|
|
|
141
192
|
|
|
142
193
|
**Show the storyboard inline and get approval before filming.** Present it as a numbered shot list, not raw JSON. Say the target length out loud so the human can push back on pacing before you burn a take. Iterate until they say go.
|
|
143
194
|
|
|
195
|
+
The presentation has three blocks, always: the shot list (irreversible beats marked "pointed at, not pressed"), **"Before the camera"** (what `prep[]` creates) and **"After the cut"** (the `cleanup_plan[]` sentences). A storyboard whose flow needs data and has no prep, or creates anything and has no cleanup plan, is not ready to show.
|
|
196
|
+
|
|
144
197
|
## LAW: bot walls — one human checkpoint, never a disguise
|
|
145
198
|
|
|
146
199
|
Some sites challenge automated browsers. The protocol, in order, no
|
|
@@ -192,7 +245,9 @@ Only come back to the human when a PRODUCT question remains that you cannot deci
|
|
|
192
245
|
## Phase 5: The take
|
|
193
246
|
|
|
194
247
|
```bash
|
|
248
|
+
node scripts/cleanup.mjs <takeDir> --prep # only when the storyboard has prep[]: creates the data, runs checks.before
|
|
195
249
|
node scripts/record.mjs <takeDir> <storyboard.json>
|
|
250
|
+
node scripts/cleanup.mjs <takeDir> # only when the storyboard has cleanup[]: reverts, runs checks.after, writes cleanup.json
|
|
196
251
|
```
|
|
197
252
|
|
|
198
253
|
Outputs `raw.webm` and `manifest.json` (internal schema, absolute times) into `<takeDir>`. The recorder stamps `record_from`: the moment the first page was FULLY loaded (networkidle plus a beat). Everything before it gets trimmed in both endings, so the published cut always opens on a loaded page.
|
|
@@ -218,7 +273,7 @@ Send the TRIMMED CLEAN take into DemoBites. The studio owns the look: NO backdro
|
|
|
218
273
|
node scripts/trim.mjs <takeDir> # raw.webm -> clean.mp4, trim from record_from ONLY
|
|
219
274
|
node scripts/calibrate.mjs <takeDir> # anchor-measure the clock against the footage
|
|
220
275
|
node scripts/manifest.mjs <takeDir> # internal manifest -> manifest.demobites.json (wire schema)
|
|
221
|
-
node scripts/upload.mjs <takeDir> # STAGE the take + open the in-app preview
|
|
276
|
+
node scripts/upload.mjs <takeDir> # STAGE the take + open the in-app preview (refuses an uncleaned take)
|
|
222
277
|
```
|
|
223
278
|
|
|
224
279
|
**The human word lives in the product now.** `upload.mjs` stages the take (the
|
|
@@ -260,6 +315,32 @@ Laws for a re-take:
|
|
|
260
315
|
- **The human approves in-app.** The preview page says "Re-take of <bite>". Approve replaces the recording in
|
|
261
316
|
that bite; the previous recording is kept for rollback, never overwritten.
|
|
262
317
|
|
|
318
|
+
## Batch of briefs (GitHub PR → demos)
|
|
319
|
+
|
|
320
|
+
The human pastes a bundle of approved briefs into the chat: a header (batchId, workspaceId, the target URL, the 90 second rule) and one block per brief (briefId, revision, contentHash, title, audience, outcome, flowIntent). Up to five briefs. The pasted text is a copy; the server holds the truth.
|
|
321
|
+
|
|
322
|
+
```bash
|
|
323
|
+
node scripts/briefs.mjs list <batchId> [--paste bundle.txt] # the approved briefs; warns when the paste drifted
|
|
324
|
+
node scripts/briefs.mjs claim <batchId> <briefId> # mints an attempt, creates take-<briefId>-r<revision>/brief.json
|
|
325
|
+
node scripts/briefs.mjs event <takeDir> planning|awaiting_storyboard_approval|recording|uploading|failed|cancelled [--note "..."]
|
|
326
|
+
node scripts/briefs.mjs release <takeDir> # give the brief back (cancelled)
|
|
327
|
+
node scripts/upload.mjs <takeDir> --stage-only --no-open # stage with the attempt riding along, do not wait
|
|
328
|
+
node scripts/status.mjs <takeDir> # later: wait for the word, then for the bite
|
|
329
|
+
node scripts/status.mjs --all # one look at every staged take here
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
The procedure, in order:
|
|
333
|
+
|
|
334
|
+
1. `list` first, always, with `--paste` when the human pasted text. Work from the server's briefs, never from the paste, and say so when they differ.
|
|
335
|
+
2. Claim the briefs you are about to film, one `claim` each. A claim answers "active attempt" when another agent or an earlier run holds the brief: show the human the attempt reference and its start time, and only with their word claim again with `--force`.
|
|
336
|
+
3. Run `vocab.mjs` over the screens each brief visits, then write every storyboard (Phase 3) with the brief as the spec and vocab.json as the only dictionary: the flowIntent lines are the beats, the outcome is the last beat, the exclusions are things the camera never shows, and the take stays under the brief's `maxSeconds` (90). Send `event <takeDir> planning` when you start a storyboard and `event <takeDir> awaiting_storyboard_approval` when it is ready.
|
|
337
|
+
4. **Show the storyboards together, get a word on each one.** One message can carry all of them, but every brief gets its own yes or no. Never take one yes as a yes for the batch. A brief the human declines gets `release`.
|
|
338
|
+
5. Film sequentially, never in parallel: one Chrome on the profile. Per take: `event recording` → Phase 4 dry run → `cleanup.mjs --prep` when declared → Phase 5 take → `cleanup.mjs` (revert, checks.after) → trim, calibrate, manifest → `upload.mjs <takeDir> --stage-only --no-open`. Report, per take, what was created and what was reverted, with the before/after checks. `upload.mjs` reads `brief.json`, moves the attempt to uploading and stages with the attempt on the payload; it writes `staged.json` with the staging id.
|
|
339
|
+
6. **A failed brief never stops the others.** On a failure send `event <takeDir> failed --note "<what happened>"`, keep the take directory for diagnosis, and continue with the next brief. Report every failure plainly at the end.
|
|
340
|
+
7. When all takes are staged, tell the human: N takes are waiting in the review queue (the `queueUrl` printed by the last stage), one Approve or Discard each. Then `status.mjs --all` shows where each stands; `status.mjs <takeDir>` waits for one.
|
|
341
|
+
|
|
342
|
+
Resume after an interruption from what is on disk and on the server: a `take-*` directory with `brief.json` is claimed; with `raw.webm` it was filmed; with `clean.mp4` and `manifest.demobites.json` it is ready to stage; with `staged.json` it is staged (check it with `status.mjs --no-wait`). `list` shows the server's view of every attempt. Never re-claim a brief that already has your own live attempt; never re-stage one that `staged.json` says is staged unless the human asked for a new take (`upload.mjs --supersede`).
|
|
343
|
+
|
|
263
344
|
## The wire manifest (fixed contract, version 2)
|
|
264
345
|
|
|
265
346
|
`manifest.mjs` produces exactly this shape. All times are relative to the UPLOADED file (record_from already subtracted, clamped at 0). `duration` is the duration of the uploaded clean.mp4.
|
|
@@ -329,4 +410,4 @@ The upload zip contains exactly one file: `clean.mp4` stored as `recording.mp4`.
|
|
|
329
410
|
- Anything the human sees (storyboard presentation, review page, questions) uses commas and periods only, no dashes, and real action words. Never orphan a single word on its own line in a heading.
|
|
330
411
|
- Never touch credentials. Never print the api_key. Config and key files are chmod 600.
|
|
331
412
|
- Never INGEST without the human's explicit word. For the DemoBites ending, staging for the in-app preview is HOW the word is asked — the take becomes a bite only when the human clicks Approve on that page.
|
|
332
|
-
- One take directory per take, keep failed takes for diagnosis, name them `take-<slug>`, `take-<slug>2`, and so on.
|
|
413
|
+
- One take directory per take, keep failed takes for diagnosis, name them `take-<slug>`, `take-<slug>2`, and so on. A take claimed from a brief is `take-<briefId>-r<revision>`.
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// Batch of briefs (GitHub PR → demos, 2026-09-13). The human pastes a bundle
|
|
2
|
+
// of approved briefs into the agent; this script is the agent's hands:
|
|
3
|
+
//
|
|
4
|
+
// node briefs.mjs list <batchId> [--paste <file>] the authenticated truth for the batch (warns when a pasted bundle drifted)
|
|
5
|
+
// node briefs.mjs claim <batchId> <briefId> [--force] claim one brief → mints an attempt, creates take-<briefId>-r<revision>/brief.json
|
|
6
|
+
// node briefs.mjs event <takeDir|attemptRef> <event> [--note "..."]
|
|
7
|
+
// planning | awaiting_storyboard_approval | recording | uploading | failed | cancelled
|
|
8
|
+
// node briefs.mjs release <takeDir|attemptRef> [--note "..."] = event cancelled (give the brief back)
|
|
9
|
+
//
|
|
10
|
+
// Server contract (dbrec_ key, scope record):
|
|
11
|
+
// GET /api/recorder/briefs?batch=<batchId>
|
|
12
|
+
// PUT /api/recorder/briefs/claim { briefId, revision, contentHash, idempotencyKey, force? }
|
|
13
|
+
// PUT /api/recorder/briefs/attempts/<attemptRef> { event, note? }
|
|
14
|
+
// The stage call (upload.mjs) sends the attempt from <takeDir>/brief.json.
|
|
15
|
+
//
|
|
16
|
+
// Laws: one storyboard approval per brief, never one word for the batch.
|
|
17
|
+
// Sequential takes, one Chrome on the profile. A failed brief never stops the
|
|
18
|
+
// others. Never print the api_key.
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import crypto from "node:crypto";
|
|
22
|
+
|
|
23
|
+
const [, , cmd, ...rest] = process.argv;
|
|
24
|
+
const flag = (name) => rest.includes(name);
|
|
25
|
+
const opt = (name) => { const i = rest.indexOf(name); return i >= 0 ? String(rest[i + 1] ?? "") : null; };
|
|
26
|
+
const positional = rest.filter((a, i) => !a.startsWith("--") && !(i > 0 && ["--paste", "--note"].includes(rest[i - 1])));
|
|
27
|
+
|
|
28
|
+
function usage(code = 2) {
|
|
29
|
+
console.error(`Usage:
|
|
30
|
+
node briefs.mjs list <batchId> [--paste <file>]
|
|
31
|
+
node briefs.mjs claim <batchId> <briefId> [--force]
|
|
32
|
+
node briefs.mjs event <takeDir|attemptRef> <event> [--note "..."]
|
|
33
|
+
node briefs.mjs release <takeDir|attemptRef> [--note "..."]`);
|
|
34
|
+
process.exit(code);
|
|
35
|
+
}
|
|
36
|
+
if (!cmd || !["list", "claim", "event", "release"].includes(cmd)) usage();
|
|
37
|
+
|
|
38
|
+
const cfgPath = path.resolve(".recorder", "config.json");
|
|
39
|
+
let cfg = {};
|
|
40
|
+
try { cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8")); } catch {}
|
|
41
|
+
if (!cfg.api_key || !cfg.base) { console.error("No recorder key. Run: node scripts/login.mjs"); process.exit(1); }
|
|
42
|
+
const base = cfg.base.replace(/\/+$/, "");
|
|
43
|
+
const headers = { Authorization: `Bearer ${cfg.api_key}`, "Content-Type": "application/json" };
|
|
44
|
+
|
|
45
|
+
async function api(method, p, body) {
|
|
46
|
+
let res;
|
|
47
|
+
try {
|
|
48
|
+
res = await fetch(`${base}${p}`, { method, headers, body: body ? JSON.stringify(body) : undefined });
|
|
49
|
+
} catch (e) { console.error(`DemoBites unreachable (${e.message}).`); process.exit(1); }
|
|
50
|
+
const json = await res.json().catch(() => null);
|
|
51
|
+
if (res.status === 401) { console.error("The recorder key was refused. Run: node scripts/login.mjs"); process.exit(1); }
|
|
52
|
+
return { status: res.status, ok: res.ok, json };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function takeDirFor(briefId, revision) {
|
|
56
|
+
const safe = String(briefId).replace(/[^A-Za-z0-9._-]+/g, "-");
|
|
57
|
+
return `take-${safe}-r${revision}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The pasted bundle: one block per brief with briefId / revision / contentHash lines. */
|
|
61
|
+
function parsePaste(text) {
|
|
62
|
+
const out = new Map();
|
|
63
|
+
const blocks = text.split(/\n(?=\s*briefId\s*[:=])/i);
|
|
64
|
+
for (const b of blocks) {
|
|
65
|
+
const id = b.match(/briefId\s*[:=]\s*([A-Za-z0-9._-]+)/i)?.[1];
|
|
66
|
+
if (!id) continue;
|
|
67
|
+
const revision = b.match(/revision\s*[:=]\s*(\d+)/i)?.[1];
|
|
68
|
+
const hash = b.match(/contentHash\s*[:=]\s*([A-Za-z0-9:_-]+)/i)?.[1];
|
|
69
|
+
out.set(id, { revision: revision !== undefined ? Number(revision) : null, contentHash: hash ?? null });
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function printBatch(data) {
|
|
75
|
+
const { batch, briefs } = data;
|
|
76
|
+
const src = batch?.source ? `${batch.source.repo}#${batch.source.prNumber}` : "";
|
|
77
|
+
console.log(`Batch ${batch?.id ?? "?"} ${src} target ${batch?.target?.url ?? "?"}${batch?.target?.environment ? ` (${batch.target.environment})` : ""}`);
|
|
78
|
+
console.log(`${briefs.length} brief${briefs.length === 1 ? "" : "s"}, each at most 90 seconds. One storyboard approval per brief.`);
|
|
79
|
+
for (const b of briefs) {
|
|
80
|
+
const att = b.attempt ? ` · attempt ${b.attempt.ref} (${b.attempt.state})` : "";
|
|
81
|
+
console.log(`\n ${b.briefId} r${b.revision} ${b.status}${att}\n ${b.title}\n audience: ${b.audience}\n outcome: ${b.outcome}${b.estimatedDurationSec ? `\n about ${b.estimatedDurationSec}s` : ""}`);
|
|
82
|
+
for (const f of b.flowIntent ?? []) console.log(` · ${f}`);
|
|
83
|
+
if (b.prerequisites?.length) console.log(` needs: ${b.prerequisites.join("; ")}`);
|
|
84
|
+
if (b.exclusions?.length) console.log(` never: ${b.exclusions.join("; ")}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function fetchBatch(batchId) {
|
|
89
|
+
const r = await api("GET", `/api/recorder/briefs?batch=${encodeURIComponent(batchId)}`);
|
|
90
|
+
if (r.status === 403 && r.json?.error === "workspace_mismatch") {
|
|
91
|
+
console.error("This key's workspace is not the batch's workspace. Log in to the right workspace (node scripts/login.mjs) and try again.");
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
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); }
|
|
95
|
+
return r.json;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (cmd === "list") {
|
|
99
|
+
const batchId = positional[0];
|
|
100
|
+
if (!batchId) usage();
|
|
101
|
+
const data = await fetchBatch(batchId);
|
|
102
|
+
printBatch(data);
|
|
103
|
+
const pasteFile = opt("--paste");
|
|
104
|
+
if (pasteFile) {
|
|
105
|
+
let text = "";
|
|
106
|
+
try { text = fs.readFileSync(pasteFile, "utf8"); } catch (e) { console.error(`--paste ${pasteFile}: ${e.message}`); process.exit(2); }
|
|
107
|
+
const pasted = parsePaste(text);
|
|
108
|
+
let drift = 0;
|
|
109
|
+
for (const b of data.briefs) {
|
|
110
|
+
const p = pasted.get(String(b.briefId));
|
|
111
|
+
if (!p) { console.error(`\nWARNING: brief ${b.briefId} is in the batch but not in the pasted text.`); drift++; continue; }
|
|
112
|
+
if (p.contentHash && p.contentHash !== b.contentHash) { console.error(`\nWARNING: brief ${b.briefId}: the pasted contentHash differs from the approved revision r${b.revision}. Work from the approved text above, not the paste.`); drift++; }
|
|
113
|
+
if (p.revision !== null && p.revision !== b.revision) { console.error(`\nWARNING: brief ${b.briefId}: pasted revision r${p.revision}, approved revision r${b.revision}.`); drift++; }
|
|
114
|
+
}
|
|
115
|
+
for (const id of pasted.keys()) if (!data.briefs.some((b) => String(b.briefId) === id)) { console.error(`\nWARNING: pasted brief ${id} is not in batch ${batchId}.`); drift++; }
|
|
116
|
+
if (drift === 0) console.log("\nThe pasted bundle matches the approved briefs.");
|
|
117
|
+
else process.exitCode = 3;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (cmd === "claim") {
|
|
122
|
+
const [batchId, briefId] = positional;
|
|
123
|
+
if (!batchId || !briefId) usage();
|
|
124
|
+
const data = await fetchBatch(batchId);
|
|
125
|
+
const brief = data.briefs.find((b) => String(b.briefId) === String(briefId));
|
|
126
|
+
if (!brief) { console.error(`Brief ${briefId} is not in batch ${batchId}.`); process.exit(1); }
|
|
127
|
+
const idempotencyKey = crypto.createHash("sha256").update(`${batchId}:${brief.briefId}:${brief.revision}:${brief.contentHash}`).digest("hex").slice(0, 32);
|
|
128
|
+
const body = { briefId: brief.briefId, revision: brief.revision, contentHash: brief.contentHash, idempotencyKey, ...(flag("--force") ? { force: true } : {}) };
|
|
129
|
+
const r = await api("PUT", "/api/recorder/briefs/claim", body);
|
|
130
|
+
if (r.status === 409 && r.json?.error === "active_attempt") {
|
|
131
|
+
const a = r.json.active_attempt ?? r.json;
|
|
132
|
+
console.error(`Brief ${briefId} already has a live attempt (${a.attemptRef ?? "?"}${a.since ? `, since ${a.since}` : ""}). Show this to the human; with their word, claim again with --force to supersede it.`);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
if (r.status === 409 && r.json?.error === "hash_mismatch") { console.error(`Brief ${briefId}: the content hash does not match the approved revision. Run list again and work from the approved text.`); process.exit(1); }
|
|
136
|
+
if (r.status === 410) { console.error(`Brief ${briefId} r${brief.revision} was superseded by a newer revision. Run list again.`); process.exit(1); }
|
|
137
|
+
if (!r.ok || !r.json?.attemptRef) { console.error(`Claim failed: ${r.status} ${r.json ? JSON.stringify(r.json).slice(0, 200) : ""}`); process.exit(1); }
|
|
138
|
+
const dir = takeDirFor(brief.briefId, brief.revision);
|
|
139
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
140
|
+
const record = {
|
|
141
|
+
batchId, briefId: brief.briefId, revision: brief.revision, contentHash: brief.contentHash, attemptRef: r.json.attemptRef,
|
|
142
|
+
brief: r.json.brief ?? brief, target: r.json.target ?? data.batch?.target ?? null, rules: r.json.rules ?? { maxSeconds: 90 },
|
|
143
|
+
source: data.batch?.source ?? null, claimedAt: new Date().toISOString(),
|
|
144
|
+
};
|
|
145
|
+
fs.writeFileSync(path.join(dir, "brief.json"), JSON.stringify(record, null, 2) + "\n");
|
|
146
|
+
console.log(`Claimed ${brief.briefId} r${brief.revision} → ${dir}/brief.json (attempt ${r.json.attemptRef}, at most ${record.rules.maxSeconds}s)`);
|
|
147
|
+
console.log(`Title: ${record.brief.title}\nTarget: ${record.target?.url ?? "?"}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (cmd === "event" || cmd === "release") {
|
|
151
|
+
const ref = positional[0];
|
|
152
|
+
const event = cmd === "release" ? "cancelled" : positional[1];
|
|
153
|
+
const EVENTS = ["planning", "awaiting_storyboard_approval", "recording", "uploading", "failed", "cancelled"];
|
|
154
|
+
if (!ref || !EVENTS.includes(event)) usage();
|
|
155
|
+
let attemptRef = ref;
|
|
156
|
+
if (fs.existsSync(ref) && fs.statSync(ref).isDirectory()) {
|
|
157
|
+
try { attemptRef = JSON.parse(fs.readFileSync(path.join(ref, "brief.json"), "utf8")).attemptRef; } catch { console.error(`${ref}/brief.json not found or unreadable.`); process.exit(1); }
|
|
158
|
+
}
|
|
159
|
+
const note = opt("--note");
|
|
160
|
+
const r = await api("PUT", `/api/recorder/briefs/attempts/${encodeURIComponent(attemptRef)}`, { event, ...(note ? { note: note.slice(0, 600) } : {}) });
|
|
161
|
+
if (r.status === 409) { console.error(`Event "${event}" refused for ${attemptRef}: ${r.json?.message ?? r.json?.error ?? "state does not allow it"} (current: ${r.json?.state ?? "?"}).`); process.exit(1); }
|
|
162
|
+
if (!r.ok) { console.error(`Event failed: ${r.status} ${r.json ? JSON.stringify(r.json).slice(0, 200) : ""}`); process.exit(1); }
|
|
163
|
+
console.log(`${attemptRef}: ${r.json?.state ?? event}`);
|
|
164
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Prep and cleanup around a take, off camera (law: the camera shows an action
|
|
2
|
+
// to its end, and every take returns the workspace to its initial state).
|
|
3
|
+
//
|
|
4
|
+
// node cleanup.mjs <takeDir> --prep run storyboard.prep[] then storyboard.checks.before[]
|
|
5
|
+
// node cleanup.mjs <takeDir> run storyboard.cleanup[] then storyboard.checks.after[]
|
|
6
|
+
//
|
|
7
|
+
// Steps use the storyboard's own step schema, no video, headless on the
|
|
8
|
+
// recorder profile: goto | click | type | press | hover | wait | expect | absent.
|
|
9
|
+
// { "action": "press", "key": "Escape" } close a dialog through the keyboard
|
|
10
|
+
// { "action": "wait", "ms": 1500 }
|
|
11
|
+
// { "action": "expect", "selector": "text=Foo" } must be visible (a check)
|
|
12
|
+
// { "action": "absent", "selector": "text=Foo" } must NOT be visible (a check)
|
|
13
|
+
// `checks.before` runs after prep, `checks.after` runs after cleanup; both are
|
|
14
|
+
// lists of expect/absent steps. Writes <takeDir>/prep.json or cleanup.json
|
|
15
|
+
// { ran, checks, ok, at }. upload.mjs refuses to stage a take whose storyboard
|
|
16
|
+
// declares cleanup[] until cleanup.json says ok:true (--allow-uncleaned to
|
|
17
|
+
// override, loudly).
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { chromium } from "playwright";
|
|
21
|
+
|
|
22
|
+
const dir = process.argv[2];
|
|
23
|
+
const prep = process.argv.includes("--prep");
|
|
24
|
+
if (!dir) { console.error("Usage: node cleanup.mjs <takeDir> [--prep]"); process.exit(2); }
|
|
25
|
+
const sb = JSON.parse(fs.readFileSync(path.join(dir, "storyboard.json"), "utf8"));
|
|
26
|
+
const steps = prep ? (sb.prep ?? []) : (sb.cleanup ?? []);
|
|
27
|
+
const checks = prep ? (sb.checks?.before ?? []) : (sb.checks?.after ?? []);
|
|
28
|
+
const outName = prep ? "prep.json" : "cleanup.json";
|
|
29
|
+
if (steps.length === 0 && checks.length === 0) {
|
|
30
|
+
console.log(`${prep ? "prep" : "cleanup"}: nothing declared in storyboard.json`);
|
|
31
|
+
fs.writeFileSync(path.join(dir, outName), JSON.stringify({ ran: [], checks: [], ok: true, nothingDeclared: true, at: new Date().toISOString() }, null, 2) + "\n");
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const profileDir = path.resolve(".recorder", "profile");
|
|
36
|
+
const ctx = await chromium.launchPersistentContext(profileDir, { channel: "chrome", headless: true, viewport: { width: 1920, height: 1080 } }).catch(async () =>
|
|
37
|
+
chromium.launchPersistentContext(profileDir, { headless: true, viewport: { width: 1920, height: 1080 } }),
|
|
38
|
+
);
|
|
39
|
+
const page = await ctx.newPage();
|
|
40
|
+
if (sb.hideCss) page.on("load", () => page.addStyleTag({ content: sb.hideCss }).catch(() => {}));
|
|
41
|
+
|
|
42
|
+
async function visibleTarget(selector, minY = 0) {
|
|
43
|
+
const els = page.locator(selector);
|
|
44
|
+
const deadline = Date.now() + 15000;
|
|
45
|
+
while (Date.now() < deadline) {
|
|
46
|
+
const n = await els.count();
|
|
47
|
+
for (let i = 0; i < n; i++) {
|
|
48
|
+
const el = els.nth(i);
|
|
49
|
+
if (await el.isVisible().catch(() => false)) {
|
|
50
|
+
const box = await el.boundingBox();
|
|
51
|
+
if (box && box.y >= minY) return el;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
await page.waitForTimeout(250);
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const report = { ran: [], checks: [], ok: true, at: new Date().toISOString() };
|
|
60
|
+
async function run(step, isCheck) {
|
|
61
|
+
const label = step.label || `${step.action} ${step.selector || step.url || step.key || ""}`.trim();
|
|
62
|
+
const rec = { label, action: step.action, ok: true };
|
|
63
|
+
try {
|
|
64
|
+
if (step.action === "goto") { await page.goto(step.url, { waitUntil: "load", timeout: 60000 }); await page.waitForTimeout(step.after ?? 1500); }
|
|
65
|
+
else if (step.action === "wait") { await page.waitForTimeout(step.ms ?? 1000); }
|
|
66
|
+
else if (step.action === "press") { await page.keyboard.press(step.key || "Escape"); await page.waitForTimeout(step.after ?? 600); }
|
|
67
|
+
else if (step.action === "expect" || step.action === "absent") {
|
|
68
|
+
const el = await visibleTarget(step.selector, step.minY ?? 0);
|
|
69
|
+
const present = !!el;
|
|
70
|
+
rec.ok = step.action === "expect" ? present : !present;
|
|
71
|
+
rec.detail = present ? "visible" : "not visible";
|
|
72
|
+
}
|
|
73
|
+
else if (step.action === "click" || step.action === "hover" || step.action === "type") {
|
|
74
|
+
const el = await visibleTarget(step.selector, step.minY ?? 0);
|
|
75
|
+
if (!el) throw new Error(`no visible target for ${step.selector}`);
|
|
76
|
+
if (step.action === "hover") await el.hover();
|
|
77
|
+
if (step.action === "click") { await el.click(); await page.waitForTimeout(step.after ?? 1200); }
|
|
78
|
+
if (step.action === "type") { await el.click(); if (step.clear) { await el.fill("").catch(async () => { await page.keyboard.press("ControlOrMeta+A"); await page.keyboard.press("Backspace"); }); } await page.keyboard.type(String(step.text ?? ""), { delay: 20 }); if (step.enter) await page.keyboard.press("Enter"); await page.waitForTimeout(step.after ?? 500); }
|
|
79
|
+
}
|
|
80
|
+
else throw new Error(`unknown action ${step.action}`);
|
|
81
|
+
} catch (e) { rec.ok = false; rec.error = e.message; }
|
|
82
|
+
rec.url = page.url(); // where the step left the page (a failed prep leaves its draft's url here)
|
|
83
|
+
(isCheck ? report.checks : report.ran).push(rec);
|
|
84
|
+
// An optional step (required:false) may fail without failing the report:
|
|
85
|
+
// the checks decide. Required steps and checks decide the verdict.
|
|
86
|
+
if (!rec.ok && (isCheck || step.required !== false)) report.ok = false;
|
|
87
|
+
console.log(`${rec.ok ? "✓" : "✗"} ${isCheck ? "check " : ""}${label}${rec.detail ? ` (${rec.detail})` : ""}${rec.error ? ` — ${rec.error}` : ""}`);
|
|
88
|
+
return rec.ok;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const step of steps) { if (!(await run(step, false)) && step.required !== false) { console.error(`stopping: "${step.label || step.action}" failed and is required`); break; } }
|
|
92
|
+
// Checks need a page under them: when nothing navigated yet, open the
|
|
93
|
+
// storyboard's own url first (a prep with only checks, or a cleanup whose
|
|
94
|
+
// steps never left the blank tab).
|
|
95
|
+
if (checks.length > 0 && page.url() === "about:blank" && sb.url) { await page.goto(sb.url, { waitUntil: "load", timeout: 60000 }).catch(() => {}); await page.waitForTimeout(3500); }
|
|
96
|
+
for (const step of checks) await run(step, true);
|
|
97
|
+
await ctx.close();
|
|
98
|
+
// The verdict: when checks are declared they ARE the proof (a rerun after a
|
|
99
|
+
// partial revert legitimately finds nothing left to do); without checks, every
|
|
100
|
+
// required step must have passed.
|
|
101
|
+
if (checks.length > 0) report.ok = report.checks.every((c) => c.ok);
|
|
102
|
+
fs.writeFileSync(path.join(dir, outName), JSON.stringify(report, null, 2) + "\n");
|
|
103
|
+
console.log(`${outName} written: ${report.ok ? "ok" : "NOT ok"} (${report.ran.length} steps, ${report.checks.length} checks)`);
|
|
104
|
+
process.exit(report.ok ? 0 : 1);
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import fs from "node:fs";
|
|
30
30
|
import path from "node:path";
|
|
31
31
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
32
|
+
import { ffmpeg as FFMPEG, ffprobe as FFPROBE } from "./media-tools.mjs";
|
|
32
33
|
|
|
33
34
|
const args = process.argv.slice(2);
|
|
34
35
|
const dir = args[0];
|
|
@@ -73,7 +74,7 @@ let duration = round2(Math.max(0, A * (man.duration ?? 0) + B));
|
|
|
73
74
|
const cleanPath = path.join(dir, "clean.mp4");
|
|
74
75
|
if (fs.existsSync(cleanPath)) {
|
|
75
76
|
try {
|
|
76
|
-
duration = round2(parseFloat(execFileSync(
|
|
77
|
+
duration = round2(parseFloat(execFileSync(FFPROBE(), [
|
|
77
78
|
"-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", cleanPath,
|
|
78
79
|
]).toString().trim()));
|
|
79
80
|
} catch {
|
|
@@ -215,7 +216,7 @@ if (fs.existsSync(cleanPath)) {
|
|
|
215
216
|
// count exists because the bbox alone lies: two tiny unrelated changes
|
|
216
217
|
// far apart (caret + spinner) span a huge, nearly-empty bbox (review
|
|
217
218
|
// finding, 2026-08-09).
|
|
218
|
-
const res = spawnSync(
|
|
219
|
+
const res = spawnSync(FFMPEG(), [
|
|
219
220
|
"-loglevel", "info",
|
|
220
221
|
"-ss", String(Math.max(0, tPre)), "-i", cleanPath,
|
|
221
222
|
"-ss", String(Math.min(duration - 0.05, tPost)), "-i", cleanPath,
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Shared wait for a staged take: poll while the human decides in the app,
|
|
2
|
+
// then until the bite is READY, then print the receipt. Used by upload.mjs
|
|
3
|
+
// (single take) and status.mjs (a batch resumes here per take).
|
|
4
|
+
//
|
|
5
|
+
// LAW (founder 2026-08-08): never hand a human a studio link before the bite
|
|
6
|
+
// is finished. Approve only STARTS the pipeline; transcode, rescript, fit,
|
|
7
|
+
// synthesize and finalize happen after. The link exists ONLY behind a
|
|
8
|
+
// confirmed "completed".
|
|
9
|
+
//
|
|
10
|
+
// Returns { exitCode, status, biteId, studioUrl } and never throws.
|
|
11
|
+
|
|
12
|
+
export async function waitForDecision({ base, apiKey, stagingId, pageUrl, decisionTimeoutMs = 30 * 60 * 1000, pollMs = 4000 }) {
|
|
13
|
+
const headers = { Authorization: `Bearer ${apiKey}` };
|
|
14
|
+
const deadline = Date.now() + decisionTimeoutMs;
|
|
15
|
+
let announced = false;
|
|
16
|
+
let completed = false;
|
|
17
|
+
let approvedBiteId = null;
|
|
18
|
+
let finalStudioUrl = null;
|
|
19
|
+
process.stdout.write("Waiting for your word in the browser");
|
|
20
|
+
while (Date.now() < deadline) {
|
|
21
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
22
|
+
let res;
|
|
23
|
+
try {
|
|
24
|
+
res = await fetch(`${base}/api/recorder/stage?id=${encodeURIComponent(stagingId)}`, { headers });
|
|
25
|
+
} catch { process.stdout.write("."); continue; }
|
|
26
|
+
if (!res.ok) { process.stdout.write("."); continue; }
|
|
27
|
+
const st = await res.json().catch(() => null);
|
|
28
|
+
if (!st) { process.stdout.write("."); continue; }
|
|
29
|
+
if (st.status === "rejected") {
|
|
30
|
+
process.stdout.write("\n");
|
|
31
|
+
console.error("Discarded in the app. Adjust the storyboard and film again.");
|
|
32
|
+
return { exitCode: 1, status: "rejected", biteId: null, studioUrl: null };
|
|
33
|
+
}
|
|
34
|
+
if (st.status === "approved") {
|
|
35
|
+
if (!announced) {
|
|
36
|
+
process.stdout.write("\n");
|
|
37
|
+
console.log(`Approved — bite ${st.biteId} is being created`);
|
|
38
|
+
announced = true;
|
|
39
|
+
approvedBiteId = st.biteId;
|
|
40
|
+
finalStudioUrl = st.studioUrl ? new URL(st.studioUrl, base).toString() : null;
|
|
41
|
+
process.stdout.write("Waiting for the bite to finish");
|
|
42
|
+
}
|
|
43
|
+
if (st.biteStatus === "completed") { completed = true; process.stdout.write("\n"); break; }
|
|
44
|
+
if (st.biteStatus === "failed") {
|
|
45
|
+
process.stdout.write("\n");
|
|
46
|
+
console.error("The pipeline FAILED for this bite. Do not hand over any link — investigate.");
|
|
47
|
+
return { exitCode: 1, status: "failed", biteId: approvedBiteId, studioUrl: null };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
process.stdout.write(".");
|
|
51
|
+
}
|
|
52
|
+
if (!announced) {
|
|
53
|
+
process.stdout.write("\n");
|
|
54
|
+
console.error(`No decision yet. The preview stays available at:\n ${pageUrl}`);
|
|
55
|
+
return { exitCode: 1, status: "pending", biteId: null, studioUrl: null };
|
|
56
|
+
}
|
|
57
|
+
if (!completed) {
|
|
58
|
+
console.error("Approved, but the bite did not finish within the wait window. Do not share the link yet — poll /api/recorder/status or reload the preview page.");
|
|
59
|
+
return { exitCode: 1, status: "approved", biteId: approvedBiteId, studioUrl: null };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Final receipt via the status endpoint (same gate as before).
|
|
63
|
+
let last = null;
|
|
64
|
+
try {
|
|
65
|
+
const res = await fetch(`${base}/api/recorder/status?biteId=${approvedBiteId}`, { headers });
|
|
66
|
+
if (res.ok) last = await res.json().catch(() => null);
|
|
67
|
+
} catch { /* summary is best-effort; readiness was confirmed above */ }
|
|
68
|
+
if (last && last.status === "completed") {
|
|
69
|
+
console.log(
|
|
70
|
+
`Ready: "${last.title}" — ${last.durationSec ? last.durationSec.toFixed(1) + "s, " : ""}` +
|
|
71
|
+
`${last.narrationReady}/${last.narrationTotal} narration segments with audio, ${last.zooms} camera shots`,
|
|
72
|
+
);
|
|
73
|
+
if (last.narrationTotal === 0) console.error("WARNING: no narration segments landed. The voice will be silent.");
|
|
74
|
+
else if (last.narrationReady < last.narrationTotal) console.error(`WARNING: ${last.narrationTotal - last.narrationReady} segment(s) have no audio behind them.`);
|
|
75
|
+
if (last.zooms === 0) console.error("WARNING: no camera shots landed.");
|
|
76
|
+
}
|
|
77
|
+
if (finalStudioUrl) console.log(`Studio: ${finalStudioUrl}`);
|
|
78
|
+
return { exitCode: 0, status: "completed", biteId: approvedBiteId, studioUrl: finalStudioUrl };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One look, no waiting: the staged take's current status as the server sees it. */
|
|
82
|
+
export async function peekStaged({ base, apiKey, stagingId }) {
|
|
83
|
+
const res = await fetch(`${base}/api/recorder/stage?id=${encodeURIComponent(stagingId)}`, { headers: { Authorization: `Bearer ${apiKey}` } });
|
|
84
|
+
if (!res.ok) return { ok: false, httpStatus: res.status };
|
|
85
|
+
const st = await res.json().catch(() => null);
|
|
86
|
+
return st ? { ok: true, ...st } : { ok: false, httpStatus: res.status };
|
|
87
|
+
}
|