demobite 1.4.0 → 1.5.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 +6 -13
- package/package.json +1 -1
- package/skill/SKILL.md +33 -29
- package/skill/scripts/retake.mjs +2 -2
- package/skill/scripts/stage-wait.mjs +53 -8
- package/skill/scripts/status.mjs +33 -17
- package/skill/scripts/upload.mjs +64 -67
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Ask your coding agent to record your product. Edit the result. Download the MP4.**
|
|
4
4
|
|
|
5
|
-
Your agent drives a real browser on your machine
|
|
5
|
+
Your agent drives a real browser on your machine, records the flow you describe, and delivers the take into your free DemoBites account as an editable video, with text-to-speech, zooms, captions, and MP4 export included.
|
|
6
6
|
|
|
7
7
|
**Sign in to your app locally. You do not hand DemoBites your app password or upload your recording browser profile.**
|
|
8
8
|
|
|
@@ -28,7 +28,7 @@ Before installing:
|
|
|
28
28
|
|
|
29
29
|
- **Node.js 18 or newer** and npm.
|
|
30
30
|
- **Google Chrome**, recommended. The recorder also supports Playwright's Chromium.
|
|
31
|
-
- A **free DemoBites account
|
|
31
|
+
- A **free DemoBites account**. The recorder works only when this project is connected to it.
|
|
32
32
|
|
|
33
33
|
Run these commands from your project directory:
|
|
34
34
|
|
|
@@ -61,7 +61,7 @@ For Cursor or Codex, make the [recorder instructions](skill/SKILL.md) available
|
|
|
61
61
|
1. **Describe the flow.** Give your agent the app URL and what you want to demonstrate.
|
|
62
62
|
2. **Approve the storyboard.** Review the steps and estimated length before filming. If your app needs a login, sign in yourself in the recorder's local browser window.
|
|
63
63
|
3. **Let it record.** The agent checks the flow and films it. Recording normally runs in the background.
|
|
64
|
-
4. **
|
|
64
|
+
4. **Watch it arrive.** The take is delivered by itself and becomes a Bite, an editable video project. The terminal prints a link to your Demos grid in DemoBites, where the take's card shows it coming in. If your account is out of recording minutes, the take is kept and waits on the same grid until minutes are back.
|
|
65
65
|
5. **Tweak and download.** In the Studio, change a sentence, generate its voice, adjust the zooms or timing, and export your MP4.
|
|
66
66
|
|
|
67
67
|
The storyboard's narration is a starting point. DemoBites rewrites and fits it to the footage; you can edit the final wording in the Studio.
|
|
@@ -103,24 +103,18 @@ The recorder uses a separate, persistent Chrome profile at `.recorder/profile/`.
|
|
|
103
103
|
| --- | --- |
|
|
104
104
|
| Your app's browser session | Stays in the local recording profile; the standard local workflow does not upload that profile to DemoBites. |
|
|
105
105
|
| DemoBites recorder key | Saved in `.recorder/config.json` and used to authenticate recorder requests to DemoBites. This is separate from your app login. |
|
|
106
|
-
| Footage and recording metadata | Uploaded to DemoBites for
|
|
106
|
+
| Footage and recording metadata | Uploaded to DemoBites for processing, including the storyboard, narration intent, cursor events, and camera targets. |
|
|
107
107
|
|
|
108
108
|
Recording happens locally. Studio processing and editing use DemoBites online. Anything visible or typed into the recorded flow can appear in the footage or recording metadata, so use suitable demo data. Your coding agent's own data handling still follows its provider and your settings.
|
|
109
109
|
|
|
110
110
|
Keep `.recorder/` and take directories out of version control. To disconnect the recorder, run `npx demobite logout`.
|
|
111
111
|
|
|
112
|
-
## Local output without a DemoBites account
|
|
113
|
-
|
|
114
|
-
The repository also contains a [standalone recorder skill](recorder/SKILL.md) and local finishing scripts. These produce a local MP4; optional voiceover uses your own ElevenLabs key.
|
|
115
|
-
|
|
116
|
-
This is an advanced, manual workflow. The default `npx demobite@latest` command installs the Studio-connected skill, not the standalone skill. The standalone finishing reference contains legacy instructions; use the standalone skill as the entry point rather than treating it as a second ending of the installed Studio skill.
|
|
117
|
-
|
|
118
112
|
## A few boundaries
|
|
119
113
|
|
|
120
114
|
- The agentic recorder films **browser workflows**. It does not record native desktop apps or phone screens.
|
|
121
115
|
- The Studio-connected skill is designed for short demos, usually **30–45 seconds**, with a **90-second maximum per Bite**. Split longer stories into separate videos.
|
|
122
116
|
- Some sites block automated browsers. The workflow may need a human sign-in or verification step, and some sites may refuse recording.
|
|
123
|
-
- Filming does not publish anything. You
|
|
117
|
+
- Filming does not publish anything. You approve the storyboard before anything is filmed; the delivered Bite stays in your workspace, and public publishing is a separate action.
|
|
124
118
|
|
|
125
119
|
## Optional: Retake and management
|
|
126
120
|
|
|
@@ -134,7 +128,7 @@ npx demobite retake <biteId> --note "Export moved to the header"
|
|
|
134
128
|
|
|
135
129
|
**Workspace rules.** A workspace admin can write standing rules for the recorder in plain words in the DemoBites settings tab Agentic Recorder Rules, one per line: what to mask, which pages never to open, which words to use. The agent reads them at the start of every take and applies them under its own filming laws; `npx demobite rules` prints them. Every take records the rules version it was filmed under.
|
|
136
130
|
|
|
137
|
-
**Batches of briefs.** When a pull request in DemoBites produces approved briefs, your agent claims them, films one take per brief after you approve each storyboard, and delivers each take.
|
|
131
|
+
**Batches of briefs.** When a pull request in DemoBites produces approved briefs, your agent claims them, films one take per brief after you approve each storyboard, and delivers each take. Like every take, a delivered take becomes a Bite in DemoBites by itself and shows up on your Demos grid. Nothing is published or shared.
|
|
138
132
|
|
|
139
133
|
The package also includes a DemoBites management MCP for releases and centers:
|
|
140
134
|
|
|
@@ -153,7 +147,6 @@ The default installer also attempts to register that MCP with Claude Code when t
|
|
|
153
147
|
| `launcher/` | CLI setup, connection commands, and MCP registration. |
|
|
154
148
|
| `skill/` | Studio-connected recorder instructions, login, upload, and Retake. |
|
|
155
149
|
| `scripts/` | Shared browser recording, trimming, and timing calibration. |
|
|
156
|
-
| `recorder/` | Standalone skill and local video finishing tools. |
|
|
157
150
|
|
|
158
151
|
The same package is published under three names, `demobite`, `agentic-recorder` and `demobites`, always at the same version; `npx agentic-recorder@latest` and `npx demobites@latest` do exactly what `npx demobite@latest` does. Run it again to update the installed skill. For publishing instructions, see [RELEASING.md](RELEASING.md).
|
|
159
152
|
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -9,7 +9,7 @@ You are the camera operator, the director, and the editor. You film a real brows
|
|
|
9
9
|
|
|
10
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
|
-
Follow the phases in order. Never skip the storyboard approval
|
|
12
|
+
Follow the phases in order. Never skip the storyboard approval: the human's yes on the storyboard, in the chat, is the word for the take. After that the take is delivered by itself. It becomes a bite in DemoBites without a second click, and the human watches it come in on their Demos grid (`<base>/demos`). Never send them to a preview page to approve it.
|
|
13
13
|
|
|
14
14
|
## Phase 0: Auth gate, ALWAYS FIRST — with the human's word
|
|
15
15
|
|
|
@@ -39,10 +39,13 @@ surprise the human with a browser page):**
|
|
|
39
39
|
for their word.
|
|
40
40
|
|
|
41
41
|
Gating first is deliberate: fail before minutes of filming and know the
|
|
42
|
-
target workspace up front.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
the
|
|
42
|
+
target workspace up front. Plan limits are NOT your concern and never block
|
|
43
|
+
you: staging always succeeds. When the account has no recording minutes
|
|
44
|
+
left, DemoBites KEEPS the take and it waits on the Demos grid, where a strip
|
|
45
|
+
shows the waiting takes with the upgrade door. `upload.mjs` prints one line
|
|
46
|
+
for that ("Kept. The take is waiting for recording minutes..."); relay it
|
|
47
|
+
as it is. Never add numbers, prices or quota talk of your own, the product
|
|
48
|
+
does the talking.
|
|
46
49
|
To sign out: `node scripts/login.mjs --logout` (revokes the key server-side
|
|
47
50
|
AND strips it locally). "Log me out of DemoBites" means exactly that command.
|
|
48
51
|
|
|
@@ -300,23 +303,23 @@ Send the TRIMMED CLEAN take into DemoBites. The studio owns the look: NO backdro
|
|
|
300
303
|
node scripts/trim.mjs <takeDir> # raw.webm -> clean.mp4, trim from record_from ONLY
|
|
301
304
|
node scripts/calibrate.mjs <takeDir> # anchor-measure the clock against the footage
|
|
302
305
|
node scripts/manifest.mjs <takeDir> # internal manifest -> manifest.demobites.json (wire schema)
|
|
303
|
-
node scripts/upload.mjs <takeDir> # STAGE the take
|
|
306
|
+
node scripts/upload.mjs <takeDir> # STAGE + DELIVER the take, open the Demos grid (refuses an uncleaned take)
|
|
304
307
|
```
|
|
305
308
|
|
|
306
|
-
**
|
|
307
|
-
ZIP for ingestion plus a playable MP4 for the player), opens the DemoBites
|
|
308
|
-
preview page in the human's browser, and polls while they decide THERE.
|
|
309
|
-
Approve on that page runs the ingest; Discard deletes the staged take and this
|
|
310
|
-
script reports it so you adjust and refilm. There is no local review.html for
|
|
311
|
-
this ending — the preview page is the review.
|
|
309
|
+
**Every take is delivered by itself** (founder ruling 2026-09-25). `upload.mjs` stages the take (the ZIP for ingestion plus a playable MP4), uploads both, then calls the delivery door. There is no Approve click and no preview page to send the human to: the word was the storyboard yes in the chat. The script opens the human's Demos grid (`<base>/demos`, `--no-open` skips it) and prints one of two lines:
|
|
312
310
|
|
|
313
|
-
|
|
311
|
+
- **Delivered:** `Delivered. Watch it come in: <base>/demos`. The card for the take shows it ingesting on the grid. Without `--stage-only` the script then waits for the bite to finish and prints the receipt (see the law below).
|
|
312
|
+
- **Kept, waiting for minutes:** `Kept. The take is waiting for recording minutes (back on <date>). Watch it here: <base>/demos`. The account has no recording minutes left. The take is safe on the server and waits on the same grid, next to the upgrade door. Tell the human exactly that, nothing more; do not refilm, do not stage it again.
|
|
313
|
+
|
|
314
|
+
Any other answer is an error: the script says what failed and `node scripts/status.mjs <takeDir>` tries the delivery again (the server is idempotent). `staged.json` in the take directory records `stagingId`, `dashboardUrl`, `delivered`, `waiting`, `resetsAt` and `biteId` (plus `previewUrl`, kept only for older readers; never give it to the human). Tell the human to watch the Demos grid, never the preview link.
|
|
315
|
+
|
|
316
|
+
If the take is wrong (private data on screen, a missing step in the flow), say so, adjust and film again; the human removes the unwanted bite in the app.
|
|
314
317
|
|
|
315
318
|
### LAW: never hand over a studio link before the bite is ready
|
|
316
319
|
|
|
317
320
|
`ingest` only STARTS the pipeline. Transcode, rescript, fit, synthesize and finalize all happen after the call returns, so a link printed at that moment leads to a half built bite with grey silent rows, which is exactly what the founder walked into on 2026-08-08.
|
|
318
321
|
|
|
319
|
-
`upload.mjs`
|
|
322
|
+
After a delivered take, `upload.mjs` polls `/api/recorder/status` until the bite reaches `completed` and prints what actually landed. **Read that line before you say anything to the human.** It reports `narrationReady/narrationTotal` segments with real audio behind them, and the camera shot count. If narration is 0, or ready is below total, or shots are 0, say so plainly and investigate. Do not pass on a link with a warning above it as though it were a success.
|
|
320
323
|
|
|
321
324
|
## Phase 7: Re-take (Launch plan and up)
|
|
322
325
|
|
|
@@ -339,10 +342,9 @@ Laws for a re-take:
|
|
|
339
342
|
- **The narration in the recipe is the ORIGINAL intent.** Do not rewrite it to taste: the server replaces it
|
|
340
343
|
with the bite's current text per step. Only remove lines whose beats you dropped.
|
|
341
344
|
- **Same pacing laws apply** (intro, narrate the path, linger, cut and fade on page transitions).
|
|
342
|
-
- **The
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
the promoted export stays as it is until a version is published.
|
|
345
|
+
- **The re-take is delivered like every take.** The new recording replaces the current one in that bite by
|
|
346
|
+
itself; the previous recording is kept for rollback, never overwritten, and the promoted export stays as it
|
|
347
|
+
is until a version is published. The human watches it on the Demos grid.
|
|
346
348
|
|
|
347
349
|
## Batch of briefs (GitHub PR → demos)
|
|
348
350
|
|
|
@@ -353,9 +355,9 @@ node scripts/briefs.mjs list <batchId> [--paste bundle.txt] # the approve
|
|
|
353
355
|
node scripts/briefs.mjs claim <batchId> <briefId> # mints an attempt, creates take-<briefId>-r<revision>/brief.json
|
|
354
356
|
node scripts/briefs.mjs event <takeDir> planning|awaiting_storyboard_approval|recording|uploading|failed|cancelled [--note "..."]
|
|
355
357
|
node scripts/briefs.mjs release <takeDir> # give the brief back (cancelled)
|
|
356
|
-
node scripts/upload.mjs <takeDir> --stage-only --no-open # deliver: the take becomes a bite by itself, do not wait
|
|
358
|
+
node scripts/upload.mjs <takeDir> --stage-only --no-open # deliver: the take becomes a bite by itself (or waits for minutes), do not wait
|
|
357
359
|
node scripts/status.mjs <takeDir> # later: wait for the bite to finish (retries a failed delivery)
|
|
358
|
-
node scripts/status.mjs --all # one look at every delivered take here
|
|
360
|
+
node scripts/status.mjs --all # one look at every delivered or waiting take here
|
|
359
361
|
```
|
|
360
362
|
|
|
361
363
|
The procedure, in order:
|
|
@@ -364,11 +366,11 @@ The procedure, in order:
|
|
|
364
366
|
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`.
|
|
365
367
|
3. Run `rules.mjs <takeDir>` (Phase 1b; the claim stored the batch's rules snapshot in brief.json as the fallback) and `vocab.mjs` over the screens each brief visits, then write every storyboard (Phase 3) with the brief as the spec, the workspace rules below the laws, 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.
|
|
366
368
|
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`.
|
|
367
|
-
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, stages with the attempt on the payload, and after the two uploads calls the delivery route: the take becomes a bite in DemoBites by itself, no Approve click, and the line reads `
|
|
369
|
+
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, stages with the attempt on the payload, and after the two uploads calls the delivery route, as for every take: the take becomes a bite in DemoBites by itself, no Approve click, and the line reads `Delivered. Watch it come in: <base>/demos`. With no recording minutes left the line reads `Kept. The take is waiting for recording minutes...`; the take waits on the grid, go on with the next brief. It writes `staged.json` with the staging id, the grid link, and the bite id or the waiting state.
|
|
368
370
|
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.
|
|
369
|
-
7. When all takes are delivered, tell the human: N takes were delivered and are becoming bites in DemoBites by themselves, with the bite ids. `status.mjs --all` shows where each stands; `status.mjs <takeDir>` waits for one to finish and prints what landed (the Phase 6 receipt law holds: no studio link before the bite is completed). A take the server would not deliver (upload.mjs printed the error)
|
|
371
|
+
7. When all takes are delivered, tell the human: N takes were delivered and are becoming bites in DemoBites by themselves, with the bite ids, and they can watch them come in on the Demos grid (`<base>/demos`). Name any take that is kept, waiting for recording minutes; it waits on the same grid. `status.mjs --all` shows where each stands; `status.mjs <takeDir>` waits for one to finish and prints what landed (the Phase 6 receipt law holds: no studio link before the bite is completed). A take the server would not deliver (upload.mjs printed the error): `status.mjs <takeDir>` tries the delivery again. Deliver each take; never publish, never share, never send invitations.
|
|
370
372
|
|
|
371
|
-
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 delivered or 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 delivered or staged unless the human asked for a new take (`upload.mjs --supersede`).
|
|
373
|
+
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 delivered, waiting for minutes, or 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 delivered, waiting or staged unless the human asked for a new take (`upload.mjs --supersede`).
|
|
372
374
|
|
|
373
375
|
## The wire manifest (fixed contract, version 2)
|
|
374
376
|
|
|
@@ -426,11 +428,13 @@ PUT <base>/api/recorder/stage (Authorization: Bearer <api_key>)
|
|
|
426
428
|
-> { stagingId, uploadUrl, previewUploadUrl, videoKey, previewUrl }
|
|
427
429
|
|
|
428
430
|
GET <base>/api/recorder/stage?id=<stagingId> (Authorization: Bearer <api_key>)
|
|
429
|
-
-> { status: 'pending'|'approving'|'approved'|'delivered'|'rejected', biteId, biteUKey, biteStatus, studioUrl }
|
|
431
|
+
-> { status: 'pending'|'approving'|'approved'|'delivered'|'waiting'|'rejected', biteId, biteUKey, biteStatus, studioUrl, resetsAt? }
|
|
430
432
|
|
|
431
|
-
PUT <base>/api/recorder/stage/<stagingId>/uploaded (Authorization: Bearer <api_key>) // DELIVERY:
|
|
432
|
-
{} // the url comes from the claim's api.uploaded ("{origin}/api/recorder/stage/{id}/uploaded"); this path is the fallback
|
|
433
|
-
->
|
|
433
|
+
PUT <base>/api/recorder/stage/<stagingId>/uploaded (Authorization: Bearer <api_key>) // DELIVERY: EVERY take, after both uploads (1.5)
|
|
434
|
+
{} // the url comes from the claim's api.uploaded ("{origin}/api/recorder/stage/{id}/uploaded") when there is one; this path is the fallback
|
|
435
|
+
-> 202 { delivered:true, biteId, studioUrl, dashboardUrl } (also 200 { biteId, videoId } from older servers)
|
|
436
|
+
| 403 { kept:true, waiting:'minutes', resetsAt, dashboardUrl:'/demos', stagingId } (no recording minutes: kept, waits on the grid)
|
|
437
|
+
| 200 { pending:true } (older server: waits for the word in the app) | 404/409 { error }
|
|
434
438
|
// idempotent: a repeat returns the same bite
|
|
435
439
|
|
|
436
440
|
GET <base>/api/recorder/status?biteId=<id> (Authorization: Bearer <api_key>)
|
|
@@ -444,7 +448,7 @@ The upload zip contains exactly one file: `clean.mp4` stored as `recording.mp4`.
|
|
|
444
448
|
|
|
445
449
|
## Standing rules
|
|
446
450
|
|
|
447
|
-
- Anything the human sees (storyboard presentation,
|
|
451
|
+
- Anything the human sees (storyboard presentation, questions, reports) uses commas and periods only, no dashes, and real action words. Never orphan a single word on its own line in a heading.
|
|
448
452
|
- Never touch credentials. Never print the api_key. Config and key files are chmod 600.
|
|
449
|
-
- Never
|
|
453
|
+
- Never film without the human's explicit word on the storyboard. That yes, in the chat, is the word for the take: delivery ingests by itself, and the human watches the take on the Demos grid, never on a preview page. For a batch of briefs the word is given on the batch and on each storyboard. Never publish, never share, never send invitations.
|
|
450
454
|
- 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>`.
|
package/skill/scripts/retake.mjs
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
// Usage: node retake.mjs <biteId> [--note "what changed"] [--take <dir>]
|
|
9
9
|
//
|
|
10
10
|
// Flow: recipe -> storyboard.json -> record -> trim -> calibrate (gate) ->
|
|
11
|
-
// manifest -> upload --retake-of <biteId> (stages
|
|
12
|
-
//
|
|
11
|
+
// manifest -> upload --retake-of <biteId> (stages and delivers into the bite;
|
|
12
|
+
// the person watches it on the Demos grid).
|
|
13
13
|
//
|
|
14
14
|
// A step that no longer resolves makes record.mjs FAIL LOUDLY at that step.
|
|
15
15
|
// That is the moment for the agent to look at the live page, fix the
|
|
@@ -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
|
|
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, 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,8 +54,9 @@ 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
|
|
|
@@ -60,27 +67,36 @@ if (fs.existsSync(target) && fs.statSync(target).isDirectory()) {
|
|
|
60
67
|
const staged = readStaged(target);
|
|
61
68
|
if (!staged?.stagingId) { console.error(`${target} has no staged.json. Stage it first: node scripts/upload.mjs ${target} --stage-only`); process.exit(1); }
|
|
62
69
|
stagingId = staged.stagingId;
|
|
63
|
-
pageUrl = staged.
|
|
70
|
+
pageUrl = staged.dashboardUrl ?? null;
|
|
64
71
|
delivered = staged.delivered === true;
|
|
65
|
-
// A
|
|
66
|
-
//
|
|
67
|
-
|
|
72
|
+
// A take whose delivery failed (network, 409) is delivered again here; the
|
|
73
|
+
// server is idempotent. An older server (pending) and a kept take (waiting
|
|
74
|
+
// for minutes, the server holds it) are left alone.
|
|
75
|
+
if (staged.delivered === false && staged.pending !== true && !staged.waiting) {
|
|
68
76
|
const d = await deliverStaged({ base, apiKey: cfg.api_key, stagingId, template: staged.api?.uploaded ?? null });
|
|
77
|
+
const grid = gridUrl(base, d.dashboardUrl ?? staged.dashboardUrl);
|
|
78
|
+
pageUrl = grid;
|
|
79
|
+
const write = (patch) => { try { fs.writeFileSync(path.join(target, "staged.json"), JSON.stringify({ ...staged, dashboardUrl: grid, ...patch }, null, 2) + "\n"); } catch {} };
|
|
69
80
|
if (d.delivered) {
|
|
70
81
|
delivered = true;
|
|
71
|
-
console.log(`
|
|
72
|
-
|
|
82
|
+
console.log(`Delivered. Watch it come in: ${grid}`);
|
|
83
|
+
write({ delivered: true, waiting: null, biteId: d.biteId, videoId: d.videoId ?? null, studioUrl: d.studioUrl ?? null, queued: d.queued ?? null, deliveryError: null });
|
|
84
|
+
} else if (d.waiting) {
|
|
85
|
+
console.log(waitingLine(grid, d.resetsAt));
|
|
86
|
+
write({ waiting: d.waiting, resetsAt: d.resetsAt ?? null, deliveryError: null });
|
|
87
|
+
process.exit(0);
|
|
73
88
|
} else if (d.pending) {
|
|
74
|
-
|
|
75
|
-
console.log(
|
|
76
|
-
} else console.error(`Not delivered: ${d.error}.
|
|
89
|
+
write({ pending: true });
|
|
90
|
+
console.log(`This DemoBites does not deliver by itself yet; the take waits for the word in the app. Watch it here: ${grid}`);
|
|
91
|
+
} else console.error(`Not delivered: ${d.error}. Try again later with: node scripts/status.mjs ${target}\nYour demos: ${grid}`);
|
|
77
92
|
}
|
|
78
93
|
}
|
|
79
|
-
if (!pageUrl) pageUrl =
|
|
94
|
+
if (!pageUrl) pageUrl = defaultGrid;
|
|
80
95
|
|
|
81
96
|
if (noWait) {
|
|
82
97
|
const st = await peekStaged({ base, apiKey: cfg.api_key, stagingId });
|
|
83
98
|
console.log(`${stagingId}: ${describe(st)}`);
|
|
99
|
+
console.log(`Watch it here: ${pageUrl}`);
|
|
84
100
|
if (st.ok && (st.status === "approved" || st.status === "delivered") && st.biteStatus === "completed" && st.studioUrl) console.log(`Studio: ${new URL(st.studioUrl, base).toString()}`);
|
|
85
101
|
process.exit(st.ok ? 0 : 1);
|
|
86
102
|
}
|
package/skill/scripts/upload.mjs
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// DemoBites ending — STAGE the take and
|
|
2
|
+
// DemoBites ending — STAGE the take and DELIVER it.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
4
|
+
// DIRECT DELIVERY (1.5.0, founder ruling 2026-09-25): this script zips
|
|
5
|
+
// clean.mp4, stages it together with a playable MP4, uploads both, then calls
|
|
6
|
+
// the uploaded door. The take becomes a bite by itself; the person watches it
|
|
7
|
+
// come in on their Demos grid (<base>/demos), never on the preview page. When
|
|
8
|
+
// the account has no recording minutes left the server KEEPS the take and it
|
|
9
|
+
// waits on the same grid, next to the upgrade door.
|
|
10
10
|
//
|
|
11
11
|
// Contracts (fixed, coded verbatim):
|
|
12
12
|
// PUT <base>/api/recorder/stage (Bearer)
|
|
13
13
|
// { filename, sizeBytes, previewSizeBytes, manifest }
|
|
14
14
|
// -> { stagingId, uploadUrl, previewUploadUrl, videoKey, previewUrl }
|
|
15
|
+
// PUT <base>/api/recorder/stage/<stagingId>/uploaded (Bearer) after both uploads, EVERY take
|
|
16
|
+
// -> 202 { delivered: true, biteId, studioUrl, dashboardUrl }
|
|
17
|
+
// | 403 { kept: true, waiting: "minutes", resetsAt, dashboardUrl, stagingId }
|
|
15
18
|
// GET <base>/api/recorder/stage?id=<stagingId> (Bearer)
|
|
16
19
|
// -> { status, biteId, biteUKey, biteStatus, studioUrl }
|
|
17
20
|
//
|
|
@@ -31,7 +34,7 @@ if (!dir) {
|
|
|
31
34
|
}
|
|
32
35
|
// RE-TAKE (2026-09-02): `--retake-of <biteId>` stages this take as a NEW RECORDING
|
|
33
36
|
// of an existing bite instead of a new bite. The server keeps the bite's current
|
|
34
|
-
// narration text, voice, intro/outro and look; the
|
|
37
|
+
// narration text, voice, intro/outro and look; the take is delivered into it.
|
|
35
38
|
const retakeIdx = process.argv.indexOf("--retake-of");
|
|
36
39
|
const retakeOfBiteId = retakeIdx >= 0 ? Number(process.argv[retakeIdx + 1]) : null;
|
|
37
40
|
if (retakeIdx >= 0 && !(Number.isInteger(retakeOfBiteId) && retakeOfBiteId > 0)) { console.error("--retake-of needs a bite id"); process.exit(2); }
|
|
@@ -47,11 +50,9 @@ const retakeNote = noteIdx >= 0 ? String(process.argv[noteIdx + 1] ?? "").trim()
|
|
|
47
50
|
// opts out. `--stage-only` returns right after the two uploads (the batch
|
|
48
51
|
// waits with status.mjs); `--supersede` replaces a stage already pinned to
|
|
49
52
|
// this attempt (the server refuses a second one otherwise).
|
|
50
|
-
// DELIVERY (1.3.0,
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
// and prints "delivered: bite <id>". No Approve click, no review queue for
|
|
54
|
-
// brief batches. Free-prompt takes (no attempt) still stage for review.
|
|
53
|
+
// DELIVERY (1.3.0 for brief takes, 1.5.0 for every take): after both uploads
|
|
54
|
+
// this script PUTs the delivery route (the claim's api.uploaded when there is
|
|
55
|
+
// one, fallback <base>/api/recorder/stage/<id>/uploaded). No Approve click.
|
|
55
56
|
const stageOnly = process.argv.includes("--stage-only");
|
|
56
57
|
const supersede = process.argv.includes("--supersede");
|
|
57
58
|
const attemptIdx = process.argv.indexOf("--attempt");
|
|
@@ -77,13 +78,18 @@ if (!cfg.api_key || !cfg.base) {
|
|
|
77
78
|
process.exit(1);
|
|
78
79
|
}
|
|
79
80
|
const base = cfg.base.replace(/\/+$/, "");
|
|
80
|
-
// A delivered take is a bite already
|
|
81
|
+
// A delivered take is a bite already, and a kept take waits on the server;
|
|
82
|
+
// staging either again would make a second one.
|
|
81
83
|
try {
|
|
82
84
|
const prev = JSON.parse(fs.readFileSync(path.join(dir, "staged.json"), "utf8"));
|
|
83
85
|
if (prev?.delivered === true && prev.biteId && !supersede) {
|
|
84
86
|
console.error(`${dir} was already delivered as bite ${prev.biteId}. See where it stands: node scripts/status.mjs ${dir}. For a new take of it, stage again with --supersede.`);
|
|
85
87
|
process.exit(1);
|
|
86
88
|
}
|
|
89
|
+
if (prev?.waiting && !supersede) {
|
|
90
|
+
console.error(`${dir} is already kept in DemoBites, waiting for recording minutes. See where it stands: node scripts/status.mjs ${dir}. For a new take of it, stage again with --supersede.`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
87
93
|
} catch { /* not staged yet */ }
|
|
88
94
|
|
|
89
95
|
const cleanPath = path.join(dir, "clean.mp4");
|
|
@@ -193,7 +199,7 @@ if (!zipped) {
|
|
|
193
199
|
fs.rmSync(staging, { recursive: true, force: true });
|
|
194
200
|
const sizeBytes = fs.statSync(zipPath).size;
|
|
195
201
|
console.log(`take.zip ready (${(sizeBytes / 1024 / 1024).toFixed(1)} MB, ${zipped ? "system zip" : "store method"})`);
|
|
196
|
-
if (retakeOfBiteId) console.log(`Staging as a RE-TAKE of bite ${retakeOfBiteId} — the new recording replaces the current one inside that bite
|
|
202
|
+
if (retakeOfBiteId) console.log(`Staging as a RE-TAKE of bite ${retakeOfBiteId} — the new recording replaces the current one inside that bite by itself once delivered.`);
|
|
197
203
|
|
|
198
204
|
// ── stage ──────────────────────────────────────────────────────────────────
|
|
199
205
|
const authHeaders = { Authorization: `Bearer ${cfg.api_key}`, "Content-Type": "application/json" };
|
|
@@ -228,7 +234,7 @@ if (!stageRes.ok) {
|
|
|
228
234
|
if (errBody?.error === "quota_exceeded") {
|
|
229
235
|
// Should not happen anymore — staging is quota-free by design. Neutral
|
|
230
236
|
// fallback if an older server answers this way.
|
|
231
|
-
console.error(`DemoBites declined the stage. Check ${base}/
|
|
237
|
+
console.error(`DemoBites declined the stage. Check ${base}/demos and try again.`);
|
|
232
238
|
process.exit(1);
|
|
233
239
|
}
|
|
234
240
|
if (errBody?.error === "invalid_attempt") {
|
|
@@ -236,14 +242,14 @@ if (!stageRes.ok) {
|
|
|
236
242
|
process.exit(1);
|
|
237
243
|
}
|
|
238
244
|
if (errBody?.error === "attempt_already_staged") {
|
|
239
|
-
console.error(`This attempt already has a staged take${errBody.stagingId ? ` (${errBody.stagingId})` : ""}.
|
|
245
|
+
console.error(`This attempt already has a staged take${errBody.stagingId ? ` (${errBody.stagingId})` : ""}. Keep that one, or stage again with --supersede to replace it.`);
|
|
240
246
|
process.exit(1);
|
|
241
247
|
}
|
|
242
248
|
console.error(`Stage failed: ${stageRes.status} ${errBody ? JSON.stringify(errBody) : ""}`);
|
|
243
249
|
process.exit(1);
|
|
244
250
|
}
|
|
245
251
|
const { stagingId, uploadUrl, previewUploadUrl, previewUrl, queueUrl, pendingCount } = await stageRes.json();
|
|
246
|
-
if (!stagingId || !uploadUrl || !previewUploadUrl
|
|
252
|
+
if (!stagingId || !uploadUrl || !previewUploadUrl) {
|
|
247
253
|
console.error("Stage response missing fields.");
|
|
248
254
|
process.exit(1);
|
|
249
255
|
}
|
|
@@ -263,68 +269,59 @@ async function putS3(url, contentType, filePath, label) {
|
|
|
263
269
|
await putS3(uploadUrl, "application/zip", zipPath, "ZIP");
|
|
264
270
|
await putS3(previewUploadUrl, "video/mp4", cleanPath, "Preview");
|
|
265
271
|
|
|
266
|
-
// ── delivery
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
if (attempt) {
|
|
271
|
-
const { deliverStaged } = await import("./stage-wait.mjs");
|
|
272
|
-
delivery = await deliverStaged({ base, apiKey: cfg.api_key, stagingId, template: uploadedTemplate });
|
|
273
|
-
}
|
|
272
|
+
// ── delivery: EVERY take becomes a bite by itself ─────────────────────────
|
|
273
|
+
const { deliverStaged, waitForDecision, gridUrl, waitingLine } = await import("./stage-wait.mjs");
|
|
274
|
+
const delivery = await deliverStaged({ base, apiKey: cfg.api_key, stagingId, template: uploadedTemplate });
|
|
275
|
+
const grid = gridUrl(base, delivery.dashboardUrl);
|
|
274
276
|
// The staging id used to be printed only; a batch resumes from disk, so it is
|
|
275
277
|
// persisted next to the take (status.mjs reads it, and retries a delivery
|
|
276
|
-
// that failed).
|
|
277
|
-
|
|
278
|
+
// that failed). previewUrl stays for older readers; the person's page is
|
|
279
|
+
// dashboardUrl, the Demos grid.
|
|
280
|
+
const previewPage = previewUrl ? new URL(previewUrl, base).toString() : null;
|
|
278
281
|
try {
|
|
279
282
|
fs.writeFileSync(path.join(dir, "staged.json"), JSON.stringify({
|
|
280
|
-
stagingId, previewUrl:
|
|
283
|
+
stagingId, previewUrl: previewPage, dashboardUrl: grid, queueUrl: queueUrl ? new URL(queueUrl, base).toString() : null,
|
|
281
284
|
pendingCount: pendingCount ?? null, attemptRef: attempt?.attemptRef ?? null, briefId: attempt?.briefId ?? null,
|
|
282
|
-
delivered: delivery
|
|
283
|
-
|
|
285
|
+
delivered: delivery.delivered === true, waiting: delivery.waiting ?? null, resetsAt: delivery.resetsAt ?? null,
|
|
286
|
+
biteId: delivery.biteId ?? null, videoId: delivery.videoId ?? null, studioUrl: delivery.studioUrl ? new URL(delivery.studioUrl, base).toString() : null,
|
|
287
|
+
queued: delivery.queued ?? null, pending: delivery.pending ?? null, deliveryError: delivery.error ?? null,
|
|
284
288
|
api: uploadedTemplate ? { uploaded: uploadedTemplate } : null, at: new Date().toISOString(),
|
|
285
289
|
}, null, 2) + "\n");
|
|
286
290
|
} catch (e) { console.error(`staged.json not written: ${e.message}`); }
|
|
287
291
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
const { waitForDecision } = await import("./stage-wait.mjs");
|
|
292
|
-
const outcome = await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl, delivered: true });
|
|
293
|
-
process.exit(outcome.exitCode);
|
|
294
|
-
}
|
|
295
|
-
if (delivery && !delivery.pending) {
|
|
296
|
-
console.error(`Staged, but not delivered: ${delivery.error}. The take waits in the review queue:\n ${pageUrl}\nTry the delivery again later with: node scripts/status.mjs ${dir}`);
|
|
297
|
-
process.exit(1);
|
|
298
|
-
}
|
|
299
|
-
if (delivery?.pending) console.log("This DemoBites does not deliver by itself yet; the take waits for the word in the app.");
|
|
300
|
-
|
|
301
|
-
// ── open the in-app preview — the review happens THERE ─────────────────────
|
|
302
|
-
// Batch etiquette (founder, 2026-08-11): when takes are stacked for a later
|
|
303
|
-
// review sprint, auto-opening a tab per take is spam. `--no-open` (or
|
|
304
|
-
// config.open_preview === false) stages silently — the queue pill and the
|
|
305
|
-
// printed URL carry the message. Default stays open: for a single take the
|
|
306
|
-
// opened page IS the consent moment.
|
|
307
|
-
console.log(`Staged. Review and approve in the browser:\n ${pageUrl}`);
|
|
308
|
-
if (typeof pendingCount === "number" && pendingCount > 1 && queueUrl) {
|
|
309
|
-
console.log(`${pendingCount} takes are now waiting for review: ${new URL(queueUrl, base).toString()}`);
|
|
310
|
-
}
|
|
292
|
+
// Single take: the grid opens in the browser, the person watches the card
|
|
293
|
+
// come in there. A batch passes --no-open (or config.open_preview === false)
|
|
294
|
+
// and the printed link carries the message.
|
|
311
295
|
const noOpen = process.argv.includes("--no-open") || cfg.open_preview === false;
|
|
312
|
-
|
|
296
|
+
function openGrid() {
|
|
297
|
+
if (noOpen) return;
|
|
313
298
|
try {
|
|
314
299
|
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
315
|
-
spawnSync(opener, [
|
|
316
|
-
} catch { /*
|
|
300
|
+
spawnSync(opener, [grid], { stdio: "ignore" });
|
|
301
|
+
} catch { /* the printed link is the fallback */ }
|
|
317
302
|
}
|
|
318
303
|
|
|
319
|
-
if (
|
|
320
|
-
console.log(`
|
|
304
|
+
if (delivery.delivered) {
|
|
305
|
+
console.log(`Delivered. Watch it come in: ${grid}`);
|
|
306
|
+
openGrid();
|
|
307
|
+
if (stageOnly) { console.log(`Wait for the bite to finish later with: node scripts/status.mjs ${dir}`); process.exit(0); }
|
|
308
|
+
// LAW (founder 2026-08-08): no studio link before the bite is completed.
|
|
309
|
+
const outcome = await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl: grid, delivered: true });
|
|
310
|
+
process.exit(outcome.exitCode);
|
|
311
|
+
}
|
|
312
|
+
if (delivery.waiting) {
|
|
313
|
+
console.log(waitingLine(grid, delivery.resetsAt));
|
|
314
|
+
openGrid();
|
|
321
315
|
process.exit(0);
|
|
322
316
|
}
|
|
323
|
-
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const outcome = await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl });
|
|
330
|
-
process.exit(outcome.exitCode);
|
|
317
|
+
if (delivery.pending) {
|
|
318
|
+
// An older DemoBites that does not deliver by itself: the take waits for
|
|
319
|
+
// the word in the app. Still no preview link as the destination.
|
|
320
|
+
console.log(`Staged. This DemoBites does not deliver by itself yet; the take waits for the word in the app. Watch it here: ${grid}`);
|
|
321
|
+
openGrid();
|
|
322
|
+
if (stageOnly) { console.log(`Wait for it later with: node scripts/status.mjs ${dir}`); process.exit(0); }
|
|
323
|
+
const outcome = await waitForDecision({ base, apiKey: cfg.api_key, stagingId, pageUrl: grid });
|
|
324
|
+
process.exit(outcome.exitCode);
|
|
325
|
+
}
|
|
326
|
+
console.error(`Staged, but not delivered: ${delivery.error}. Try the delivery again with: node scripts/status.mjs ${dir}\nYour demos: ${grid}`);
|
|
327
|
+
process.exit(1);
|