paper-motion 0.3.0 → 0.4.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/CHANGELOG.md +108 -0
- package/bin/pamo.mjs +221 -20
- package/examples/breath/stills/contact-sheet.jpg +0 -0
- package/examples/breath/stills/t000.00.png +0 -0
- package/examples/breath/stills/t002.00.png +0 -0
- package/examples/breath/stills/t004.00.png +0 -0
- package/examples/breath/stills/t006.00.png +0 -0
- package/examples/breath/stills/t008.00.png +0 -0
- package/examples/breath/stills/t010.00.png +0 -0
- package/examples/chrome-endcard/stills/contact-sheet.jpg +0 -0
- package/examples/chrome-endcard/stills/t000.00.png +0 -0
- package/examples/chrome-endcard/stills/t001.33.png +0 -0
- package/examples/chrome-endcard/stills/t002.67.png +0 -0
- package/examples/chrome-endcard/stills/t004.00.png +0 -0
- package/examples/chrome-endcard/stills/t005.33.png +0 -0
- package/examples/chrome-endcard/stills/t006.67.png +0 -0
- package/examples/halo-explainer/stills/contact-sheet.jpg +0 -0
- package/examples/halo-explainer/stills/t000.00.png +0 -0
- package/examples/halo-explainer/stills/t006.00.png +0 -0
- package/examples/halo-explainer/stills/t012.00.png +0 -0
- package/examples/halo-explainer/stills/t018.00.png +0 -0
- package/examples/halo-explainer/stills/t024.00.png +0 -0
- package/examples/halo-explainer/stills/t030.00.png +0 -0
- package/examples/install-loop/stills/contact-sheet.jpg +0 -0
- package/examples/install-loop/stills/t000.00.png +0 -0
- package/examples/install-loop/stills/t001.00.png +0 -0
- package/examples/install-loop/stills/t002.00.png +0 -0
- package/examples/install-loop/stills/t003.00.png +0 -0
- package/examples/install-loop/stills/t004.00.png +0 -0
- package/examples/install-loop/stills/t005.00.png +0 -0
- package/examples/orbit-keynote/stills/contact-sheet.jpg +0 -0
- package/examples/orbit-keynote/stills/t000.00.png +0 -0
- package/examples/orbit-keynote/stills/t006.50.png +0 -0
- package/examples/orbit-keynote/stills/t013.00.png +0 -0
- package/examples/orbit-keynote/stills/t019.50.png +0 -0
- package/examples/orbit-keynote/stills/t026.00.png +0 -0
- package/examples/orbit-keynote/stills/t032.50.png +0 -0
- package/examples/signal/stills/contact-sheet.jpg +0 -0
- package/examples/signal/stills/t000.00.png +0 -0
- package/examples/signal/stills/t001.67.png +0 -0
- package/examples/signal/stills/t003.33.png +0 -0
- package/examples/signal/stills/t005.00.png +0 -0
- package/examples/signal/stills/t006.67.png +0 -0
- package/examples/signal/stills/t008.33.png +0 -0
- package/lib/chain.mjs +75 -7
- package/lib/customize.mjs +40 -2
- package/lib/edit.mjs +326 -0
- package/lib/gather.mjs +241 -0
- package/lib/host.mjs +25 -0
- package/lib/inventory.mjs +523 -0
- package/lib/piece.mjs +93 -17
- package/lib/registry.mjs +22 -6
- package/lib/studio.mjs +347 -33
- package/package.json +1 -1
- package/runtime/player.js +6 -3
- package/runtime/swatch.html +21 -0
- package/studio/fonts/GeistPixel-Square.woff2 +0 -0
- package/studio/fonts/GeistSerif-Regular.woff2 +0 -0
- package/studio/index.html +439 -327
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,114 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the `paper-motion` npm package. Dates are release dates on npm.
|
|
4
4
|
|
|
5
|
+
## 0.4.0 · 2026-11-12
|
|
6
|
+
|
|
7
|
+
A piece is built from what the user has to show, not from a mood.
|
|
8
|
+
|
|
9
|
+
**The studio in three steps, and the user edits the video themselves.**
|
|
10
|
+
|
|
11
|
+
- **What it says** (the brief and the inventory), **Your video** and **Export** replace the seven stages
|
|
12
|
+
(`STAGES` is now brief, inventory, video, final; a piece started before is moved on by itself: look, structure,
|
|
13
|
+
parts and assembly all become video).
|
|
14
|
+
- **Make my video** builds the first version from what the user kept, in the studio, with no agent turn.
|
|
15
|
+
- **Your video** plays the selected part live (its real scene, with their content, in their brand) and edits it in
|
|
16
|
+
place: Words (sentences, numbers, lists, the picture), Motion (how a sentence arrives, by watching each), Background
|
|
17
|
+
(the brand's, live on hover, or a new one from the library; on one part or every part), Tune (the block's own
|
|
18
|
+
settings and the length). Between parts, the transition, chosen by watching each (or the same everywhere). Parts
|
|
19
|
+
are reordered by dragging, added by intent (say something, show the product, prove it, how it works, end) with
|
|
20
|
+
something kept already in them when they can show it, shown another way (the same words, another block),
|
|
21
|
+
duplicated and removed.
|
|
22
|
+
- **Style** changes the whole video's colours (a palette, or each colour), type (a font pair) and pace, in `brand/`.
|
|
23
|
+
- **Undo** (and ⌘Z) for every change; **Ask your agent** for what the controls can't do.
|
|
24
|
+
- **Export** renders the final from the studio, full quality, and lists the files.
|
|
25
|
+
- `lib/edit.mjs`: the edits, readable by any agent too.
|
|
26
|
+
- **A project with no look yet gets one before its first video**, named after the piece (`graphite`, `geist-serif`
|
|
27
|
+
and `mesh-graphite`, changed in Style), when the studio builds it or `chain init --plan` fills it. It used to
|
|
28
|
+
render grey on grey and call itself "brand".
|
|
29
|
+
- `dither-dissolve` dissolves into the brand's name by default, not "paper-motion"; `focus-pull` shrinks a long
|
|
30
|
+
detail ("12,000") to fit its card instead of cutting it.
|
|
31
|
+
- `piece --help` and the steps after `chain init` name the new stages.
|
|
32
|
+
|
|
33
|
+
- **`inventory <piece> --from <url|file.md|folder|brand>`** gathers the inventory from what the user already has:
|
|
34
|
+
a page (its headline, description, figures and commands, plus screenshots on a desktop and a phone saved to
|
|
35
|
+
`brand/assets/`), a markdown file, a folder of files, or the brand (IDENTITY.md's Facts and its assets). What
|
|
36
|
+
it finds is written as proposals with their source; running it again never proposes twice what is there or
|
|
37
|
+
what the user removed. Figures that count up on load are read once they stop. Only sentences a screen can
|
|
38
|
+
hold and commands that run the product are proposed; `--plain` leaves commands and code out, for a brand whose
|
|
39
|
+
audience does not code.
|
|
40
|
+
- **`inventory <piece> --output <id>`** takes what a command really printed from stdin (`<the command> 2>&1 |
|
|
41
|
+
…`), tidied to a few lines; `--set id.field=value`, `--keep`, `--remove` and `--must` are the agent's edits.
|
|
42
|
+
`piece review <piece> inventory` refuses while an item is incomplete, and says how to finish each one.
|
|
43
|
+
- **Content** (the piece's inventory, the second half of What it says), written for people who do not code: items are sorted into what we say, what we show, what proves it and how it works, each named in
|
|
44
|
+
everyday words with its source and a sample of how it could look; no kind, field or block names. There the
|
|
45
|
+
user keeps or removes each proposal, marks what must appear, corrects a value or a list by hand and adds a
|
|
46
|
+
line or a figure of their own; it cannot be approved while a proposal waits or a kept item is incomplete.
|
|
47
|
+
Every edit reaches the agent. A piece started before gets it as already approved.
|
|
48
|
+
- **How each thing is shown is the user's to choose.** In Content, the samples of the blocks that can show an item
|
|
49
|
+
play when pointed at, and a sheet per item plays the chosen block live with the user's own content in their
|
|
50
|
+
brand (the scene itself, served by the studio, not a render). There they pick another block, tune its settings
|
|
51
|
+
(a direction, a ratio, how close the camera stops) and choose the background behind it, from the brand's set or
|
|
52
|
+
a new one from the library, each playing live on hover. The pick and the tuning are saved on the item (`show`,
|
|
53
|
+
`tune`); the plan uses that block for it and `chain init --plan` writes the settings into the part.
|
|
54
|
+
- **A new block, `line-in` (A sentence):** one sentence in the brand's display type, arriving in one of the
|
|
55
|
+
library's eight text entrances (rise, focus, scramble, type, track, flip, pop, bar), the marked words in the
|
|
56
|
+
accent. Its `repeat` field lets a piece use it several times, each with a different entrance: the planner gives
|
|
57
|
+
every sentence its own unless the user chose one, and spreads extra sentences through the piece instead of
|
|
58
|
+
stacking them. So three kept sentences are three different moments, not one.
|
|
59
|
+
- `plan` also fills the length asked with what the user kept and no slot took, while it fits.
|
|
60
|
+
- In Content, the ways a kind of thing can be shown appear once per group, not under every row; each row only
|
|
61
|
+
says how it is shown, and the page says how many moments the brief's length holds. A setting whose choices
|
|
62
|
+
have samples (a sentence's entrance) is chosen in the sheet by watching each one.
|
|
63
|
+
- Scenes opened with `?embed` play and loop without the player's controls, for showing one inside another page.
|
|
64
|
+
- Items carry a `status` (`proposed`, `removed`, or none for kept). `plan` uses only what was kept and says how
|
|
65
|
+
many proposals are still waiting.
|
|
66
|
+
- **`needs` on every block and shot**: the content it shows, by kind (a figure, a change, a screen, a command, a
|
|
67
|
+
line…), and how that content maps onto its CONFIG. Published in `registry.json` and each resource's file.
|
|
68
|
+
- **`inventory <piece>`** checks `pieces/<piece>/inventory.json` (the list of what the piece can really show, each
|
|
69
|
+
item with its kind and its source) and says which blocks show each item and what is missing: nothing shows the
|
|
70
|
+
product, nothing proves it, a kind no block shows yet. `inventory kinds` lists the kinds and their fields.
|
|
71
|
+
- **`plan <piece> [--seconds n]`** proposes up to three structures from the inventory, the same content in three
|
|
72
|
+
orders (show first, problem first, proof first). No item is shown twice, no block plays twice, every `must` is
|
|
73
|
+
in, no beat runs shorter than its land and none longer than its scene. It writes `plan.json` and prints the
|
|
74
|
+
structure board to share.
|
|
75
|
+
- **`chain init <piece> --plan <letter>`** installs the chosen structure with the inventory's content already in
|
|
76
|
+
each part's CONFIG, and records in `chain.json` which items each part shows.
|
|
77
|
+
|
|
78
|
+
The studio always says whose turn it is, and never plays a video that is out of date.
|
|
79
|
+
|
|
80
|
+
- **`piece ask <piece> "question"`**: run before asking the user something in the chat. The studio shows the
|
|
81
|
+
question in a bar across the top with a button back to the agent's app (found from the CLI's own process tree
|
|
82
|
+
on macOS; `--return <url>` or `PAPER_MOTION_RETURN` for an agent in a browser). Cleared by the next `piece
|
|
83
|
+
review` or `piece working`, or `--done`.
|
|
84
|
+
- **A turn bar in the studio**, also in the tab's title: your turn (and whether the agent is waiting for it), the
|
|
85
|
+
agent working and for how long, a board waiting, a join in progress, the agent not having read the last
|
|
86
|
+
changes, the studio itself stopped. Commands that wait (`piece review`, `board`) say so while they wait.
|
|
87
|
+
- **Stale videos are held.** Each joined render records what it was made from. When the piece changes (a text, a
|
|
88
|
+
length, the order, a join, a scene the agent rewrote), Your video or the final stops, dims and says
|
|
89
|
+
"Updating" with the part being rendered; Approve waits for the new one.
|
|
90
|
+
- **The studio joins the draft again by itself** after an edit by hand, with the sound the draft was made with.
|
|
91
|
+
The agent is only called when the join fails. An edit after the final render sends Final back to the agent.
|
|
92
|
+
- Fixed: editing a text while in Assembly left the old film in place and told no one; clearing a part's length
|
|
93
|
+
set it to 0.5 s; a part edited by hand in a piece past Parts had to be approved again.
|
|
94
|
+
|
|
95
|
+
From a test where an agent with no context made a video from `llms.txt` alone:
|
|
96
|
+
|
|
97
|
+
- `board --url`: option and look values may hold several keys and lists (`graphite:accent=#B8F03C,ramp=["#0A0A0B","#B8F03C"]`, `--look "acid-night:figure=#FFF,muted=#7E8079,geist"`); `--look brand` uses the palette, colors and font pair the project installed.
|
|
98
|
+
- `board --await <id> [--wait minutes]` waits (again) for the choice on a board already shared, for shells that cap how long a command runs.
|
|
99
|
+
- The studio lists the boards shared while a piece is open, in Your video, with a button to open each and the choice once made (`board --piece <name>` picks the piece when there are several).
|
|
100
|
+
- A chosen transition can be the join between two parts of a chain: `"join": "wipe"` (also crossfade, push, zoom-through, iris, slices, flash, scan-reveal, mask-reveal, glitch, cells, flip-3d).
|
|
101
|
+
- A tuned palette retunes its ramp (the steps between ground and accent follow the new accent); a background recolored into a palette takes the brand's ground behind everything.
|
|
102
|
+
- `chain --check` warns when the total drifts from the brief's length.
|
|
103
|
+
|
|
104
|
+
From a second test, an agent following the site's "For agents" page alone:
|
|
105
|
+
|
|
106
|
+
- `piece review` refuses while an earlier stage is not approved, and names the one to review first. It used to mark the earlier stages approved on its own. `--skip` moves on without them, for when the user agreed.
|
|
107
|
+
- `chain init --board <link>` looks the board up on the server of the link it is given (a bare id still uses `--site`, or the default site); the error says what to pass.
|
|
108
|
+
- After `chain init`, the next steps printed are the real ones in order: check each part's CONFIG, `--check`, `--drafts`, `--draft`, the full render, and `piece review <piece> video` when a piece is open.
|
|
109
|
+
- `chain.json` takes the open piece's title, not the board option's.
|
|
110
|
+
- `piece --help`, `chain --help` and `board --help` list every flag of their commands.
|
|
111
|
+
- `pamo --version`. Help says `--wait` is in minutes; the docs say `pamo` means `npx paper-motion`.
|
|
112
|
+
|
|
5
113
|
## 0.3.0 · 2026-11-11
|
|
6
114
|
|
|
7
115
|
- **Studio**: `pamo studio` serves a local page where the user follows a piece stage by stage (brief, look, structure, parts, assembly, final), watches each stage's renders, approves or asks for changes, and edits the brief, the words on screen (a part's CONFIG texts, re-rendered on the spot), lengths, joins and order (drag and drop). `pieces/<piece>/piece.json` holds the stages and what the user did; `pamo piece init | review | working | status` is the agent's side (`review` hands a stage over and waits for the decision).
|
package/bin/pamo.mjs
CHANGED
|
@@ -14,6 +14,7 @@ const HELP = `paper-motion — minimal, creative-directed video from code + Pape
|
|
|
14
14
|
Usage: pamo <command> [options] (pamo = npx paper-motion, or node packages/engine/bin/pamo.mjs in the repo)
|
|
15
15
|
|
|
16
16
|
setup install engine deps, check ffmpeg + browser
|
|
17
|
+
--version the engine's version
|
|
17
18
|
doctor only check the environment
|
|
18
19
|
new <name> [--dir works] [--format 16:9|9:16|1:1 | --size 1920x1080] [--fps 30] [--duration 10]
|
|
19
20
|
[--from <example> | --piece (wired to brand/) | --still |
|
|
@@ -33,8 +34,9 @@ Usage: pamo <command> [options] (pamo = npx paper-motion, or node packages/eng
|
|
|
33
34
|
(sound effects declared with s.sfx() are mixed in)
|
|
34
35
|
sfx <scene.html> [--out file.wav] [--from s] [--to s]
|
|
35
36
|
export only the synthesized sound effects
|
|
36
|
-
board --url --title "…" [--ask "…"] [--reply name] [--look palette,font]
|
|
37
|
-
--option id[:key=value,…] [--note "why"] … [--kit] [--site url]
|
|
37
|
+
board --url --title "…" [--ask "…"] [--reply name] [--look palette[:key=v,…],font | --look brand]
|
|
38
|
+
--option id[:key=value,…] [--note "why"] … [--kit] [--wait minutes | --no-wait] [--site url]
|
|
39
|
+
board --await <id> [--wait minutes] wait (again) for the choice on a board already shared
|
|
38
40
|
a live board: a paper-motion.app link the user opens to see
|
|
39
41
|
2–4 options in their look, tune one and choose; with --kit each
|
|
40
42
|
option is a space-separated list of ids (a whole kit)
|
|
@@ -45,7 +47,14 @@ Usage: pamo <command> [options] (pamo = npx paper-motion, or node packages/eng
|
|
|
45
47
|
--cols 1 stacks options full width (judge type at real size)
|
|
46
48
|
--sequence plays the clips one after another, with their sound
|
|
47
49
|
(for choices you hear: sound palettes, typing)
|
|
48
|
-
|
|
50
|
+
inventory <piece> [--from <url|file.md|folder|brand> …] | inventory kinds
|
|
51
|
+
what a piece can really show (pieces/<piece>/inventory.json: figures, screens,
|
|
52
|
+
commands, lines…), checked, with the blocks that show each item; --from reads
|
|
53
|
+
a site (and screenshots it), a README, a folder or the brand's Facts and assets
|
|
54
|
+
and proposes items the user keeps or removes in the studio
|
|
55
|
+
plan <piece> [--seconds n] structures proposed from the inventory: each beat a block that shows one of
|
|
56
|
+
its items, in three orders; prints the options for a structure board
|
|
57
|
+
chain init <piece> (--board <id|link> [--option D] [--site url] | --plan <letter> | --parts id:sec,id:sec,…) [--format 1920x1080]
|
|
49
58
|
[--join whip|cut|dissolve|dip] [--title "…"] [--force]
|
|
50
59
|
a piece made of blocks: installs each part as its own scene under
|
|
51
60
|
pieces/<piece>/<nn>-<id>/ (edit each CONFIG) and writes chain.json;
|
|
@@ -60,11 +69,14 @@ Usage: pamo <command> [options] (pamo = npx paper-motion, or node packages/eng
|
|
|
60
69
|
piece init <piece> [--title "…"] [--what "…"] [--who "…"] [--format "…"] [--tone "…"] [--action "…"]
|
|
61
70
|
[--no-studio] start tracking a piece stage by stage (pieces/<piece>/piece.json)
|
|
62
71
|
and start this project's studio in the background
|
|
63
|
-
piece review <piece> <stage> [--wait 30] [--no-wait] [--no-open]
|
|
64
|
-
hand a stage to the user in the studio (brief,
|
|
65
|
-
|
|
72
|
+
piece review <piece> <stage> [--wait 30 (minutes)] [--no-wait] [--no-open] [--skip]
|
|
73
|
+
hand a stage to the user in the studio (brief, inventory, video,
|
|
74
|
+
final) and wait for their decision; prints it.
|
|
66
75
|
Starts the studio if needed; the first time, opens it for them
|
|
67
|
-
piece working <piece> <stage> mark a stage as yours
|
|
76
|
+
piece working <piece> <stage> mark a stage as yours (when you start it, or after changes were asked)
|
|
77
|
+
piece ask <piece> "question" | --done
|
|
78
|
+
run right before asking the user something in the chat: the studio
|
|
79
|
+
tells them you are waiting there, with a way back to you
|
|
68
80
|
piece status <piece> where the piece is, and what the user did that you haven't read
|
|
69
81
|
studio [--port 4317] [--open] [--stop]
|
|
70
82
|
the local studio: the user follows each piece, approves, edits
|
|
@@ -181,6 +193,7 @@ const workDir = (file) => path.dirname(path.resolve(file));
|
|
|
181
193
|
const fmtTime = (s) => (s < 60 ? `${s.toFixed(1)}s` : `${Math.floor(s / 60)}m${Math.round(s % 60)}s`);
|
|
182
194
|
|
|
183
195
|
async function main() {
|
|
196
|
+
if (args.version || cmd === 'version') { console.log(JSON.parse(fs.readFileSync(path.join(ENGINE, 'package.json'), 'utf8')).version); return; }
|
|
184
197
|
switch (cmd) {
|
|
185
198
|
case 'setup': {
|
|
186
199
|
await ensureDeps();
|
|
@@ -351,8 +364,25 @@ async function main() {
|
|
|
351
364
|
const { parseSet, setArgs } = await import('../lib/customize.mjs');
|
|
352
365
|
const reg = resolveRegistry(args.registry), { items } = await getIndex(reg), index = Object.fromEntries(items.map((i) => [i.id, i]));
|
|
353
366
|
const opts = process.argv.flatMap((a, i, all) => (a === '--option' ? [all[i + 1]] : a.startsWith('--option=') ? [a.slice(9)] : [])).filter(Boolean);
|
|
354
|
-
if (!opts.length || !args.title || args.help) { console.error('Usage: board --url --title "…" [--ask "…"] [--reply name] [--look palette,font] --option id[:key=value,…] [--note "why"] … [--kit] [--choose 1-3] [--wait minutes | --no-wait] [--long] [--site url]\n board --forget <id>\n On a recipe board an option may be a structure of your own: --option "@Title | 18 | Hook 0-2 [word-swap] one line; Proof 2-12 [browser-frame] three shots; Close 12-18 [svg-logo] the action" (a [block-id] makes the board play that clip for the beat).\n Prints a live board link: short (/b/<id>, stored on the site for 90 days) when the site answers, long (the board in the URL fragment) otherwise or with --long. Values after the colon are your adjustments; the reply line carries every non-default value.\n With a short link the command keeps running until the user chooses in the browser (10 min; --wait 3 changes it, --no-wait returns at once) and prints the reply line, the same one they could paste. --choose lets them pick several options (a count, or min-max). --forget deletes a stored board early.'); process.exit(args.help ? 0 : 1); }
|
|
367
|
+
if (!opts.length || !args.title || args.help) { console.error('Usage: board --url --title "…" [--ask "…"] [--reply name] [--look palette[:key=value,…],font | --look brand] --option id[:key=value,…] [--note "why"] … [--kit] [--choose 1-3] [--wait minutes | --no-wait] [--long] [--site url]\n board --await <id|link> [--wait minutes] (wait again for a board already shared)\n board --forget <id>\n On a recipe board an option may be a structure of your own: --option "@Title | 18 | Hook 0-2 [word-swap] one line; Proof 2-12 [browser-frame] three shots; Close 12-18 [svg-logo] the action" (a [block-id] makes the board play that clip for the beat).\n Prints a live board link: short (/b/<id>, stored on the site for 90 days) when the site answers, long (the board in the URL fragment) otherwise or with --long. Values after the colon are your adjustments; the reply line carries every non-default value.\n With a short link the command keeps running until the user chooses in the browser (10 min; --wait 3 changes it, in minutes; --no-wait returns at once) and prints the reply line, the same one they could paste. If your shell caps how long a command runs, share with --no-wait and then wait in short steps: board --await <id> --wait 2, again until it prints the line (exit code 3 means not yet). Values take lists and several keys: --option graphite:accent=#B8F03C,ramp=["#0A0A0B","#B8F03C"] (quote it for the shell); --look brand uses the palette, colors and font pair this project installed. --choose lets them pick several options (a count, or min-max). --forget deletes a stored board early.'); process.exit(args.help ? 0 : 1); }
|
|
355
368
|
const LABELS = ['A', 'B', 'C', 'D'];
|
|
369
|
+
// commas only separate at the top level (not inside [..], {..} or quotes), and only before a new "key=":
|
|
370
|
+
// so ramp=["#000","#111"] and multi-value colors survive
|
|
371
|
+
const splitTop = (str, startsNext) => {
|
|
372
|
+
const out = []; let depth = 0, q = null, cur = '';
|
|
373
|
+
for (let i = 0; i < str.length; i++) {
|
|
374
|
+
const c = str[i];
|
|
375
|
+
if (q) { if (c === q && str[i - 1] !== '\\') q = null; cur += c; continue; }
|
|
376
|
+
if (c === '"' || c === "'") q = c; else if ('[{'.includes(c)) depth++; else if (']}'.includes(c)) depth--;
|
|
377
|
+
if (c === ',' && depth === 0 && startsNext(str.slice(i + 1))) { out.push(cur); cur = ''; continue; }
|
|
378
|
+
cur += c;
|
|
379
|
+
}
|
|
380
|
+
out.push(cur);
|
|
381
|
+
return out.map((x) => x.trim()).filter(Boolean);
|
|
382
|
+
};
|
|
383
|
+
const splitPairs = (kv) => splitTop(kv, (rest) => /^\s*[\w-]+=/.test(rest));
|
|
384
|
+
// --look "palette:key=v,key=v,font-pair": a new item starts at a comma followed by an id (no "=" before the next , or :)
|
|
385
|
+
const splitLook = (spec) => splitTop(spec, (rest) => /^\s*[\w-]+\s*(?:[,:]|$)/.test(rest) && !/^\s*[\w-]+=/.test(rest));
|
|
356
386
|
const parseOne = async (spec) => {
|
|
357
387
|
if (spec.startsWith('@')) {
|
|
358
388
|
// a structure of the agent's own: "@Title | 18 | Hook 0-2 one line; Proof 2-12 three shots; Close 12-18 the action"
|
|
@@ -360,10 +390,10 @@ async function main() {
|
|
|
360
390
|
const beats = (beatsSpec ?? '').split(';').map((x) => x.trim()).filter(Boolean).map((x) => { const m = x.match(/^(.+?)\s+(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)\s+(.+)$/); if (!m) throw new Error(`structure beat "${x}" must read "Name from-to [block-id] what"`); const sh = m[4].match(/^\[([\w-]+)\]\s*(.+)$/); return { name: m[1], from: Number(m[2]), to: Number(m[3]), what: sh ? sh[2] : m[4], ...(sh ? { id: sh[1] } : {}) }; });
|
|
361
391
|
return { structure: { title, duration: Number(dur), beats } };
|
|
362
392
|
}
|
|
363
|
-
const
|
|
393
|
+
const at = spec.indexOf(':'), id = (at < 0 ? spec : spec.slice(0, at)).trim(), kv = at < 0 ? '' : spec.slice(at + 1), it = index[id];
|
|
364
394
|
if (!it) throw new Error(`"${id}" is not in the registry (try pamo search)`);
|
|
365
395
|
if (!kv) return { id, values: {} };
|
|
366
|
-
const full = await getItem(id, reg), values = parseSet(full, kv
|
|
396
|
+
const full = await getItem(id, reg), values = parseSet(full, splitPairs(kv));
|
|
367
397
|
// keep only what differs from the defaults, so the link stays small and the reply line honest
|
|
368
398
|
const keep = Object.fromEntries(setArgs(full, values).map((a) => { const m = a.match(/^--set '?(\w+)=(.*?)'?$/); return [m[1], values[m[1]]]; }));
|
|
369
399
|
return { id, values: keep };
|
|
@@ -375,7 +405,19 @@ async function main() {
|
|
|
375
405
|
else options.push({ label: LABELS[i], ...(await parseOne(spec)), ...(notes[i] ? { note: notes[i] } : {}) });
|
|
376
406
|
}
|
|
377
407
|
const look = {};
|
|
378
|
-
if (
|
|
408
|
+
if (args.look === 'brand') {
|
|
409
|
+
// the look this project installed: its palette and font pair (paper-motion.json), with brand/tokens.json's colors
|
|
410
|
+
const lock = (() => { try { return JSON.parse(fs.readFileSync('paper-motion.json', 'utf8')).items ?? {}; } catch { return {}; } })();
|
|
411
|
+
const tokens = (() => { try { return JSON.parse(fs.readFileSync('brand/tokens.json', 'utf8')); } catch { return {}; } })();
|
|
412
|
+
const pal = Object.keys(lock).reverse().find((id) => index[id]?.type === 'palette'), font = Object.keys(lock).reverse().find((id) => index[id]?.type === 'font-pair');
|
|
413
|
+
if (!pal && !font) throw new Error('--look brand: no palette or font pair installed here yet (pamo add <palette> <font-pair>)');
|
|
414
|
+
if (pal) {
|
|
415
|
+
const full = await getItem(pal, reg), names = new Set((full.params ?? []).map((x) => x.name));
|
|
416
|
+
const pairs = Object.entries(tokens.colors ?? {}).filter(([k]) => names.has(k)).map(([k, v]) => `${k}=${v?.hex ?? v}`);
|
|
417
|
+
look.palette = await parseOne(pairs.length ? `${pal}:${pairs.join(',')}` : pal);
|
|
418
|
+
}
|
|
419
|
+
if (font) look.font = await parseOne(font);
|
|
420
|
+
} else if (typeof args.look === 'string') for (const spec of splitLook(args.look)) { const o = await parseOne(spec); const t = index[o.id]?.type; if (t === 'palette') look.palette = o; else if (t === 'font-pair') look.font = o; else throw new Error(`--look takes a palette and a font pair (or "brand"), not "${o.id}"`); }
|
|
379
421
|
const choose = args.choose === undefined ? undefined : /^\d+-\d+$/.test(String(args.choose)) ? String(args.choose).split('-').map(Number) : Number(args.choose);
|
|
380
422
|
const board = { v: 1, kind: args.kit ? 'kit' : 'resource', title: String(args.title), ...(args.ask ? { ask: String(args.ask) } : {}), ...(args.reply ? { reply: String(args.reply) } : {}), ...(Object.keys(look).length ? { look } : {}), ...(choose !== undefined ? { choose } : {}), options };
|
|
381
423
|
const site = typeof args.site === 'string' ? args.site : SITE;
|
|
@@ -384,16 +426,37 @@ async function main() {
|
|
|
384
426
|
// the short link when the site stores it; the long one (the board in the fragment) when it cannot
|
|
385
427
|
if (!args.long) { try { short = await shortenBoard(site, board); } catch (e) { console.error(`(${e.message}; sharing the long link)`); } }
|
|
386
428
|
console.log(short ? short.url : boardUrl(site, frag));
|
|
429
|
+
// the board also shows in the studio, in the stage it decides (--piece names the piece; else the one in progress)
|
|
430
|
+
const PC = await import('../lib/piece.mjs'), pdir = PC.activePiece(process.cwd(), typeof args.piece === 'string' ? args.piece : undefined);
|
|
431
|
+
if (pdir) PC.attachBoard(pdir, { url: short ? short.url : boardUrl(site, frag), id: short?.id, title: String(args.title), kind: board.kind });
|
|
387
432
|
const wait = !args['no-wait']; // waiting is the default: the choice comes back without the user pasting anything
|
|
388
433
|
if (short && wait) {
|
|
389
434
|
const minutes = Number.isFinite(Number(args.wait)) && args.wait !== undefined && args.wait !== true ? Number(args.wait) : 10;
|
|
390
435
|
console.error(`waiting up to ${minutes} min for the choice on ${short.id} (the user can still paste the line)…`);
|
|
391
|
-
|
|
392
|
-
|
|
436
|
+
// while this waits, the studio shows the agent as listening
|
|
437
|
+
const pulse = pdir ? setInterval(() => PC.beat(pdir, { waiting: 'board' }), 3000) : null;
|
|
438
|
+
if (pdir) PC.beat(pdir, { waiting: 'board' });
|
|
439
|
+
const choice = await awaitChoice(site, short.id, { timeout: minutes * 60 * 1000 }).finally(() => { if (pulse) { clearInterval(pulse); PC.unbeat(pdir); } });
|
|
440
|
+
if (!choice) { console.error('no choice yet: wait again with board --await ' + short.id); process.exit(3); }
|
|
441
|
+
if (pdir) PC.markBoard(pdir, short.id, choice.line);
|
|
393
442
|
console.log(choice.line);
|
|
394
443
|
} else if (!short && wait) console.error('(no short link, so no waiting: the user will paste the line from the board)');
|
|
395
444
|
break;
|
|
396
445
|
}
|
|
446
|
+
if (args.await) {
|
|
447
|
+
// wait (again) for the choice on a board already shared: for shells that cap how long one command may run
|
|
448
|
+
const { awaitChoice } = await import('../lib/live-board.js');
|
|
449
|
+
const { SITE } = await import('../lib/registry.mjs');
|
|
450
|
+
const id = String(args.await).replace(/\/$/, '').split('/').pop(), site = typeof args.site === 'string' ? args.site : SITE;
|
|
451
|
+
const minutes = Number.isFinite(Number(args.wait)) && args.wait !== undefined && args.wait !== true ? Number(args.wait) : 10;
|
|
452
|
+
console.error(`waiting up to ${minutes} min for the choice on ${id}…`);
|
|
453
|
+
const choice = await awaitChoice(site, id, { timeout: minutes * 60 * 1000 });
|
|
454
|
+
if (!choice) { console.error('no choice yet: run it again'); process.exit(3); }
|
|
455
|
+
const PC = await import('../lib/piece.mjs'), pdir = PC.activePiece(process.cwd(), typeof args.piece === 'string' ? args.piece : undefined);
|
|
456
|
+
if (pdir) PC.markBoard(pdir, id, choice.line);
|
|
457
|
+
console.log(choice.line);
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
397
460
|
if (args.forget) {
|
|
398
461
|
const { forgetBoard } = await import('../lib/live-board.js');
|
|
399
462
|
const { SITE } = await import('../lib/registry.mjs');
|
|
@@ -404,7 +467,7 @@ async function main() {
|
|
|
404
467
|
}
|
|
405
468
|
await ensureDeps();
|
|
406
469
|
const files = args._.slice(1);
|
|
407
|
-
if (files.length < 2 || files.length > 6 || args.help) { console.error('Usage: board <a.html> <b.html> [... up to 6] [--t 2 | --clip 5] [--labels "A · x,B · y"] [--out board.png] (a rendered board)\n board --url --title "…" --option id[:key=value,…] … [--look palette,font] [--kit]
|
|
470
|
+
if (files.length < 2 || files.length > 6 || args.help) { console.error('Usage: board <a.html> <b.html> [... up to 6] [--t 2 | --clip 5] [--labels "A · x,B · y"] [--out board.png] (a rendered board)\n board --url --title "…" --option id[:key=value,…] … [--look palette,font | --look brand] [--kit] [--choose 1-3] [--wait minutes | --no-wait] [--long] [--site url] (a live board link)\n board --await <id|link> [--wait minutes] [--site url] (wait again for a board already shared)\n board --forget <id>\n board --url --help explains the live board in full.'); process.exit(args.help ? 0 : 1); }
|
|
408
471
|
const { renderStills, renderVideo } = await import('../lib/render.mjs');
|
|
409
472
|
const { composeBoard } = await import('../lib/board.mjs');
|
|
410
473
|
const os = await import('node:os');
|
|
@@ -444,19 +507,44 @@ async function main() {
|
|
|
444
507
|
case 'chain': {
|
|
445
508
|
const sub = args._[1] === 'init' ? 'init' : 'render';
|
|
446
509
|
const piece = sub === 'init' ? args._[2] : args._[1];
|
|
447
|
-
if (!piece || args.help) { console.error('Usage: chain init <piece> [--board <id|link> [--option D] | --parts id:sec,…] [--format 1920x1080] [--join whip|cut|dissolve|dip] [--force]\n chain <piece> [--out file.mp4] [--motion-blur 5] [--audio music.wav] [--no-sfx] [--
|
|
510
|
+
if (!piece || args.help) { console.error('Usage: chain init <piece> [--board <id|link> [--option D] [--site url] | --plan <letter> | --parts id:sec,…] [--title "…"] [--format 1920x1080] [--join whip|cut|dissolve|dip] [--force]\n chain <piece> [--check] [--drafts [--only dir,…]] [--draft] [--out file.mp4] [--motion-blur 5] [--audio music.wav] [--no-sfx] [--dpr 1]\n --board takes the board\'s link (its id is the last part of a /b/<id> link) and the letter the user chose. In order: --check plans and warns without rendering; --drafts renders each part alone (parts stage); --draft joins a quick version (assembly stage); --motion-blur 5 is the full render (final stage).'); process.exit(args.help ? 0 : 1); }
|
|
448
511
|
const chainLib = await import('../lib/chain.mjs');
|
|
449
512
|
if (sub === 'init') {
|
|
450
513
|
const { SITE } = await import('../lib/registry.mjs');
|
|
451
514
|
let src;
|
|
452
515
|
if (args.board) src = await chainLib.structureFromBoard(String(args.board), typeof args.option === 'string' ? args.option : undefined, { site: typeof args.site === 'string' ? args.site : SITE });
|
|
516
|
+
else if (typeof args.plan === 'string') src = (await import('../lib/inventory.mjs')).planParts(chainLib.pieceDir(piece), args.plan); // a structure proposed from the inventory
|
|
453
517
|
else if (typeof args.parts === 'string') src = { parts: chainLib.partsFromList(args.parts) };
|
|
454
518
|
else src = {}; // no source: sync an existing chain.json (install parts added since)
|
|
455
|
-
|
|
519
|
+
// an open piece already has its title; a board option's title ("Six blocks") names the structure, not the film
|
|
520
|
+
const open = (await import('../lib/piece.mjs')).readPiece(chainLib.pieceDir(piece));
|
|
521
|
+
if (src.fills) { // filled with the user's content: never render it grey on grey with no name
|
|
522
|
+
const look = await (await import('../lib/edit.mjs')).ensureLook(process.cwd(), open?.title ?? (typeof args.title === 'string' ? args.title : undefined));
|
|
523
|
+
if (look) console.log(`! the project had no look yet: installed ${look.join(', ')} (the user changes it in the studio's Style, or settle the brand on boards first)`);
|
|
524
|
+
}
|
|
525
|
+
const r = await chainLib.initChain({ piece, title: typeof args.title === 'string' ? args.title : open?.title ?? src.title, parts: src.parts, format: typeof args.format === 'string' ? args.format : undefined, join: typeof args.join === 'string' ? args.join : undefined, registry: args.registry, force: !!args.force });
|
|
456
526
|
console.log(`✓ ${path.relative(process.cwd(), r.dir)}/chain.json · "${r.chain.title}" · ${r.chain.parts.length} parts, ${r.chain.format}`);
|
|
457
527
|
for (const p of r.chain.parts) console.log(` ${p.dir ?? '(no block)'}${p.name ? ` · ${p.name}` : ''}${p.seconds ? ` · ${p.seconds} s` : ''}${p.what ? ` — ${p.what}` : ''}`);
|
|
458
528
|
for (const n of r.notes) console.log(` ! ${n}`);
|
|
459
|
-
|
|
529
|
+
if (src.fills) {
|
|
530
|
+
// the parts arrive with the inventory's content in their CONFIG: what is left is judgment, not typing
|
|
531
|
+
const { fillConfig } = await import('../lib/inventory.mjs');
|
|
532
|
+
r.chain.parts.forEach((part, i) => {
|
|
533
|
+
const fill = src.fills[i]; if (!part.dir || !fill || !Object.keys(fill).length) return;
|
|
534
|
+
const file = path.join(r.dir, part.dir, 'scene.html');
|
|
535
|
+
fs.writeFileSync(file, fillConfig(fs.readFileSync(file, 'utf8'), fill));
|
|
536
|
+
console.log(` ${part.dir} · filled ${Object.keys(fill).join(', ')}`);
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
console.log([
|
|
540
|
+
'', 'Next:',
|
|
541
|
+
...(open ? [` npx paper-motion piece review ${piece} video (the user edits and approves the video in the studio)`] : []),
|
|
542
|
+
src.fills ? ` check each part's CONFIG: the inventory's content is in; adjust what needs judgment` : ` write each part's text and data into its CONFIG (the "what" above is the brief)`,
|
|
543
|
+
` npx paper-motion chain ${piece} --check (plans and warns, renders nothing)`,
|
|
544
|
+
` npx paper-motion chain ${piece} --drafts (each part alone, quick)`,
|
|
545
|
+
` npx paper-motion chain ${piece} --draft (joined, quick)`,
|
|
546
|
+
` npx paper-motion chain ${piece} --motion-blur 5 (the full render)`,
|
|
547
|
+
].join('\n'));
|
|
460
548
|
break;
|
|
461
549
|
}
|
|
462
550
|
await ensureDeps();
|
|
@@ -480,17 +568,107 @@ async function main() {
|
|
|
480
568
|
break;
|
|
481
569
|
}
|
|
482
570
|
|
|
571
|
+
case 'inventory': {
|
|
572
|
+
const inv = await import('../lib/inventory.mjs');
|
|
573
|
+
const { getIndex } = await import('../lib/registry.mjs');
|
|
574
|
+
const scenes = inv.scenesOf(await getIndex());
|
|
575
|
+
if (args._[1] === 'kinds') {
|
|
576
|
+
// what an inventory can hold, and what shows each kind
|
|
577
|
+
for (const [k, K] of Object.entries(inv.KINDS)) {
|
|
578
|
+
const shows = scenes.filter((b) => b.needs.some((n) => n.kind === k)).map((b) => b.id);
|
|
579
|
+
console.log(`${k.padEnd(9)} ${K.is}\n${' '.repeat(10)}fields: ${[...K.needs, ...(K.opt ?? []).map((f) => `${f}?`)].join(', ') || '—'}\n${' '.repeat(10)}shown by: ${shows.join(', ') || 'nothing yet'}`);
|
|
580
|
+
}
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
const piece = args._[1];
|
|
584
|
+
if (!piece || args.help) { console.error('Usage: inventory <piece> [--from <url|file.md|folder|brand> … [--plain (no commands or code: the audience does not code)]] [--keep id,…] [--remove id,…] [--must id,…] [--set id.field=value] [--output id < what the command printed]\n check pieces/<piece>/inventory.json and say what can show each item;\n --from reads a site (and screenshots it), a README, a folder of files or the brand (its Facts and assets)\n and adds what it finds as proposals for the user to keep or remove in the studio\n inventory kinds the kinds of content an inventory holds, their fields, and the blocks that show them'); process.exit(piece ? 0 : 1); }
|
|
585
|
+
const dir = (await import('../lib/chain.mjs')).pieceDir(piece);
|
|
586
|
+
const froms = process.argv.flatMap((a, i, all) => (a === '--from' && all[i + 1] && !all[i + 1].startsWith('--') ? [all[i + 1]] : a.startsWith('--from=') ? [a.slice(7)] : []));
|
|
587
|
+
if (froms.length) {
|
|
588
|
+
if (froms.some((f) => /^https?:/i.test(f))) await ensureDeps();
|
|
589
|
+
const { gather } = await import('../lib/gather.mjs');
|
|
590
|
+
const existing = inv.loadRaw(dir);
|
|
591
|
+
const { fresh, notes } = await gather(froms, { existing, plain: !!args.plain, log: (m) => console.log(m) });
|
|
592
|
+
inv.saveRaw(dir, [...existing, ...fresh]);
|
|
593
|
+
for (const n of notes) console.log(` ${n}`);
|
|
594
|
+
console.log(`${fresh.length} new proposal${fresh.length === 1 ? '' : 's'}${existing.length ? ` (${existing.length} already there, untouched)` : ''}`);
|
|
595
|
+
}
|
|
596
|
+
// the agent's own edits to an item, the same ones the user makes in the studio
|
|
597
|
+
const ids = (v) => String(v).split(',').map((x) => x.trim()).filter(Boolean);
|
|
598
|
+
try {
|
|
599
|
+
if (typeof args.keep === 'string') for (const id of ids(args.keep)) inv.applyEdit(dir, { type: 'inv-status', id, value: 'kept' });
|
|
600
|
+
if (typeof args.remove === 'string') for (const id of ids(args.remove)) inv.applyEdit(dir, { type: 'inv-status', id, value: 'removed' });
|
|
601
|
+
if (typeof args.must === 'string') for (const id of ids(args.must)) inv.applyEdit(dir, { type: 'inv-must', id, value: true });
|
|
602
|
+
if (typeof args.set === 'string') {
|
|
603
|
+
const m = args.set.match(/^([^.=]+)\.([^=]+)=([\s\S]*)$/); if (!m) throw new Error('--set takes id.field=value');
|
|
604
|
+
inv.applyEdit(dir, { type: 'inv-field', id: m[1], key: m[2], value: m[3].replace(/\\n/g, '\n') });
|
|
605
|
+
}
|
|
606
|
+
if (typeof args.output === 'string') {
|
|
607
|
+
// what a command really printed, piped in: `<the command> 2>&1 | pamo inventory <piece> --output <id>`
|
|
608
|
+
if (process.stdin.isTTY) throw new Error('--output reads what the command printed from stdin: <the command> 2>&1 | pamo inventory <piece> --output <id>');
|
|
609
|
+
let piped = ''; for await (const c of process.stdin) piped += c; // read as it comes: the command may still be running
|
|
610
|
+
const lines = inv.tidyOutput(piped);
|
|
611
|
+
if (!lines.length) throw new Error('--output: nothing came in on stdin (did the command print anything?)');
|
|
612
|
+
inv.editInventory(dir, (list) => { const it = list.find((i) => i.id === args.output); if (!it) throw new Error(`no item "${args.output}"`); if (it.kind !== 'command') throw new Error(`${it.id} is a ${it.kind}, not a command`); it.output = lines; });
|
|
613
|
+
console.log(`${args.output}: output set, ${lines.length} line${lines.length === 1 ? '' : 's'}`);
|
|
614
|
+
}
|
|
615
|
+
} catch (e) { console.error(`✗ ${e.message}`); process.exit(1); }
|
|
616
|
+
const { items, waiting, problems } = inv.readInventory(dir);
|
|
617
|
+
const { rows, notes } = inv.report(items, scenes);
|
|
618
|
+
console.log(`${problems.length ? '✗' : '✓'} ${path.relative(process.cwd(), inv.inventoryFile(dir))} · ${items.length} in${waiting.length ? `, ${waiting.length} proposed` : ''}`);
|
|
619
|
+
const w = Math.max(...[...items, ...waiting].map((i) => i.id.length), 4);
|
|
620
|
+
for (const { it, blocks } of rows) console.log(` ${it.must ? '*' : ' '} ${it.id.padEnd(w)} ${it.kind.padEnd(8)} ${inv.describe(it)}\n ${' '.repeat(w)}→ ${blocks.join(', ') || (it.kind === 'music' ? 'the bed under the piece' : it.kind === 'logo' ? 'the close' : 'nothing yet')}`);
|
|
621
|
+
// proposals: gathered, not yet the user's; a plan does not use them
|
|
622
|
+
for (const it of waiting) console.log(` ? ${it.id.padEnd(w)} ${it.kind.padEnd(8)} ${inv.describe(it)}\n ${' '.repeat(w)} from ${it.source ?? '?'}${it.missing.length ? ` · still needs ${it.missing.join(', ')}` : ''}`);
|
|
623
|
+
for (const p of problems) console.log(` ✗ ${p}`);
|
|
624
|
+
if (items.length) for (const n of notes) console.log(` ! ${n}`);
|
|
625
|
+
const open = (await import('../lib/piece.mjs')).readPiece(dir);
|
|
626
|
+
if (waiting.length) console.log(`\n${waiting.length} proposal${waiting.length === 1 ? ' waits' : 's wait'} for the user: ${open ? `npx paper-motion piece review ${piece} inventory (they keep or remove each in the studio)` : `set "status" on each in the file (delete it to keep, "removed" to drop), or open a piece first (piece init) so they decide in the studio`}`);
|
|
627
|
+
if (problems.length) process.exit(1);
|
|
628
|
+
if (!waiting.length) console.log(`\nNext: npx paper-motion plan ${piece} [--seconds 20] (structures made from this content)`);
|
|
629
|
+
break;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
case 'plan': {
|
|
633
|
+
const piece = args._[1];
|
|
634
|
+
if (!piece || args.help) { console.error('Usage: plan <piece> [--seconds n] proposes structures from pieces/<piece>/inventory.json: each beat a block that shows\n one of its items; writes plan.json and prints the options for a structure board'); process.exit(piece ? 0 : 1); }
|
|
635
|
+
const inv = await import('../lib/inventory.mjs');
|
|
636
|
+
const { getIndex } = await import('../lib/registry.mjs');
|
|
637
|
+
const dir = (await import('../lib/chain.mjs')).pieceDir(piece);
|
|
638
|
+
const open = (await import('../lib/piece.mjs')).readPiece(dir);
|
|
639
|
+
const { items, waiting, problems } = inv.readInventory(dir);
|
|
640
|
+
if (problems.length) { for (const p of problems) console.log(` ✗ ${p}`); console.error(`fix the inventory first (npx paper-motion inventory ${piece})`); process.exit(1); }
|
|
641
|
+
if (waiting.length) console.log(`! ${waiting.length} proposal${waiting.length === 1 ? ' is' : 's are'} not in this plan: the user has not kept ${waiting.length === 1 ? 'it' : 'them'} yet (npx paper-motion piece review ${piece} inventory)`);
|
|
642
|
+
const scenes = inv.scenesOf(await getIndex());
|
|
643
|
+
const seconds = args.seconds ? Number(args.seconds) : undefined;
|
|
644
|
+
const options = inv.propose(items, scenes, { seconds, brief: open?.brief ?? {} });
|
|
645
|
+
if (!options.length) { console.error('nothing in the inventory can be shown by a block yet (npx paper-motion inventory kinds)'); process.exit(1); }
|
|
646
|
+
inv.writePlan(dir, options, { seconds });
|
|
647
|
+
for (const o of options) {
|
|
648
|
+
console.log(`\n${o.letter} · ${o.title} · ${o.seconds} s — ${o.why}`);
|
|
649
|
+
for (const b of o.beats) console.log(` ${String(b.from).padStart(4)}–${String(b.to).padEnd(4)} ${b.name.padEnd(10)} ${`${b.id}${b.variant ? ` (${b.variant})` : ''}`.padEnd(22)} ${b.what}`);
|
|
650
|
+
if (o.left.length) console.log(` left out: ${o.left.join(', ')}`);
|
|
651
|
+
for (const n of o.notes) console.log(` ! ${n}`);
|
|
652
|
+
}
|
|
653
|
+
for (const n of inv.report(items, scenes).notes) console.log(`\n! ${n}`);
|
|
654
|
+
console.log(`\n✓ ${path.relative(process.cwd(), inv.planFile(dir))}\n\nShow them on a structure board (each plays its blocks in order):\n npx paper-motion board --url --title "Structure" --ask "Which order tells it best?" --reply structure-1 --look brand \\\n${options.map((o) => ` --option '${inv.boardOption(o).replace(/'/g, `'\\''`)}'`).join(' \\\n')}\nor install one as it is, with its content in every part:\n npx paper-motion chain init ${piece} --plan ${options[0].letter}`);
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
|
|
483
658
|
case 'piece': {
|
|
484
659
|
const sub = args._[1], piece = args._[2];
|
|
485
|
-
if (!['init', 'review', 'working', 'status'].includes(sub) || !piece || args.help) { console.error('Usage: piece init <piece> [--title "…"] [--what …] [--who …] [--format …] [--tone …] [--action …]\n piece review <piece> <stage> [--wait 30] [--no-wait]\n piece working <piece> <stage>\n piece status <piece>'); process.exit(args.help ? 0 : 1); }
|
|
660
|
+
if (!['init', 'review', 'working', 'status', 'ask'].includes(sub) || !piece || args.help) { console.error('Usage: piece init <piece> [--title "…"] [--what …] [--who …] [--format …] [--tone …] [--action …] [--no-studio] [--force]\n piece review <piece> <stage> [--wait 30 (minutes)] [--no-wait] [--no-open] [--no-studio] [--skip]\n piece working <piece> <stage>\n piece ask <piece> "the question you are asking in the chat" | --done\n piece status <piece>\n ask: run it right before you ask the user anything in the chat, so the studio sends them there (the next review or working clears it, or --done). --return <url> on any of these gives the studio a link back to you when you run in a browser.\n Stages: brief, inventory (what the video can show), video (they edit it in the studio), final. review hands a stage to the user in the studio and prints what they did, one line each; it refuses while an earlier stage is not approved (--skip moves on without it, only when the user agreed). --no-wait returns at once with what is unread; --no-open does not open their browser; --no-studio runs without the studio.'); process.exit(args.help ? 0 : 1); }
|
|
486
661
|
const P = await import('../lib/piece.mjs');
|
|
487
662
|
const { pieceDir } = await import('../lib/chain.mjs');
|
|
488
663
|
const dir = pieceDir(piece), studioLib = await import('../lib/studio.mjs'), q = `?p=${encodeURIComponent(path.basename(dir))}`;
|
|
489
664
|
const studio = async () => (args.studio === false || args['no-studio'] ? null : studioLib.ensureStudio(process.cwd(), { bin: fileURLToPath(import.meta.url) }));
|
|
665
|
+
// where this agent runs, so the studio can take the user back to it
|
|
666
|
+
const host = async () => { if (sub !== 'status') P.setHost(dir, (await import('../lib/host.mjs')).hostApp({ url: typeof args.return === 'string' ? args.return : undefined })); };
|
|
490
667
|
if (sub === 'init') {
|
|
491
668
|
const brief = Object.fromEntries(P.BRIEF_KEYS.filter((k) => typeof args[k] === 'string').map((k) => [k, args[k]]));
|
|
492
669
|
const p = P.initPiece({ piece, title: typeof args.title === 'string' ? args.title : undefined, brief, force: !!args.force });
|
|
493
670
|
console.log(`✓ ${path.relative(process.cwd(), dir)}/piece.json · "${p.title}" · stage ${p.stage}`);
|
|
671
|
+
await host();
|
|
494
672
|
const missing = P.BRIEF_KEYS.filter((k) => !p.brief[k]);
|
|
495
673
|
if (missing.length) console.log(` brief still empty: ${missing.join(', ')} (pass them, or write piece.json)`);
|
|
496
674
|
const s = await studio().catch((e) => { console.log(` ! ${e.message}`); return null; });
|
|
@@ -506,11 +684,34 @@ async function main() {
|
|
|
506
684
|
if (fresh.length) { console.log(`\n${fresh.length} unread:`); for (const e of fresh) console.log(P.eventLine(p, e)); }
|
|
507
685
|
break;
|
|
508
686
|
}
|
|
687
|
+
if (sub === 'ask') {
|
|
688
|
+
if (!P.readPiece(dir)) { console.error(`no piece.json in ${path.relative(process.cwd(), dir)}`); process.exit(1); }
|
|
689
|
+
await host();
|
|
690
|
+
if (args.done) { P.clearAsk(dir); console.log('✓ cleared: the studio no longer sends the user to the chat'); break; }
|
|
691
|
+
const question = typeof args._[3] === 'string' ? args._[3].trim() : '';
|
|
692
|
+
if (!question) { console.error('piece ask: give the question in one line (the one you are asking in the chat), or --done'); process.exit(1); }
|
|
693
|
+
P.setAsk(dir, question);
|
|
694
|
+
console.log(`✓ the studio now tells the user you asked this in the chat${P.readPiece(dir).agent ? ', with a way back to you' : ''}. Ask it now; the next piece review or piece working clears it (or piece ask ${piece} --done).`);
|
|
695
|
+
break;
|
|
696
|
+
}
|
|
509
697
|
const stage = args._[3];
|
|
698
|
+
await host();
|
|
510
699
|
if (!P.STAGES.includes(stage)) { console.error(`piece ${sub}: give a stage (${P.STAGES.join(', ')})`); process.exit(1); }
|
|
511
|
-
if (sub === 'working') { P.updatePiece(dir, (p) => { p.stage = stage; p.stages[stage] = { ...p.stages[stage], status: 'working' }; }); console.log(`✓ ${stage}: working`); break; }
|
|
700
|
+
if (sub === 'working') { P.updatePiece(dir, (p) => { delete p.ask; p.stage = stage; p.stages[stage] = { ...p.stages[stage], status: 'working', since: new Date().toISOString() }; }); console.log(`✓ ${stage}: working`); break; }
|
|
512
701
|
// review: hand the stage over and wait for the user's decision in the studio
|
|
513
|
-
|
|
702
|
+
if (stage === 'inventory') {
|
|
703
|
+
// the user is asked whether each thing is true and worth showing: they cannot judge half an item
|
|
704
|
+
const inv = await import('../lib/inventory.mjs');
|
|
705
|
+
let r; try { r = inv.readInventory(dir); } catch (e) { console.error(`✗ ${e.message}`); process.exit(1); }
|
|
706
|
+
const short = [...r.items, ...r.waiting].filter((i) => i.missing.length);
|
|
707
|
+
if (!r.items.length && !r.waiting.length) { console.error(`✗ the inventory is empty: gather it first (inventory ${piece} --from <url|README.md|brand>)`); process.exit(1); }
|
|
708
|
+
if (short.length) {
|
|
709
|
+
console.error(`✗ ${short.length} item${short.length === 1 ? ' is' : 's are'} not complete yet: finish or remove ${short.length === 1 ? 'it' : 'them'} before the user sees the inventory`);
|
|
710
|
+
for (const i of short) console.error(` ${i.id} (${i.kind}) needs ${i.missing.join(', ')}${i.kind === 'command' && i.missing.includes('output') ? ` → ${i.command} 2>&1 | npx paper-motion inventory ${piece} --output ${i.id} or --remove ${i.id}` : ` → --set ${i.id}.${i.missing[0]}=… or --remove ${i.id}`}`);
|
|
711
|
+
process.exit(1);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
try { P.toReview(dir, stage, { skip: !!args.skip }); } catch (e) { console.error(`✗ ${e.message}`); process.exit(1); }
|
|
514
715
|
const s = await studio();
|
|
515
716
|
const url = s ? `${s.url}${q}` : null;
|
|
516
717
|
// the first hand-over opens the studio in the user's browser; after that the open page follows the piece
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|