pmx-canvas 0.6.4 → 0.6.5
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 +19 -0
- package/Readme.md +7 -1
- package/dist/canvas/global.css +58 -4
- package/dist/canvas/index.js +92 -92
- package/dist/canvas/mermaid-entry.js +258 -258
- package/dist/types/cli/agent.d.ts +1 -0
- package/dist/types/cli/commands/record.d.ts +12 -0
- package/dist/types/client/canvas/Presentation.d.ts +13 -0
- package/dist/types/client/canvas/use-node-drag.d.ts +2 -1
- package/dist/types/client/nodes/McpAppNode.d.ts +24 -1
- package/dist/types/client/state/presentation.d.ts +3 -0
- package/dist/types/client/types.d.ts +1 -1
- package/dist/types/json-render/catalog.d.ts +2 -2
- package/dist/types/json-render/server.d.ts +2 -0
- package/dist/types/server/canvas-db.d.ts +2 -0
- package/dist/types/server/canvas-operations.d.ts +22 -0
- package/dist/types/server/canvas-schema.d.ts +1 -0
- package/dist/types/server/canvas-state.d.ts +6 -0
- package/dist/types/server/canvas-validation.d.ts +3 -0
- package/dist/types/server/html-surface.d.ts +1 -1
- package/dist/types/server/index.d.ts +4 -0
- package/dist/types/server/operations/ops/app.d.ts +2 -0
- package/dist/types/server/operations/ops/json-render.d.ts +2 -0
- package/dist/types/server/operations/ops/nodes.d.ts +3 -1
- package/dist/types/server/web-artifacts.d.ts +3 -0
- package/dist/types/shared/tour.d.ts +72 -0
- package/docs/cli.md +89 -0
- package/docs/http-api.md +79 -4
- package/docs/mcp.md +25 -1
- package/docs/node-types.md +80 -0
- package/package.json +1 -1
- package/skills/pmx-canvas/SKILL.md +10 -2
- package/skills/pmx-canvas/references/full-reference.md +1 -1
- package/skills/pmx-canvas/references/installing-pmx-canvas.md +4 -4
- package/src/cli/agent.ts +5 -0
- package/src/cli/commands/nodes.ts +10 -0
- package/src/cli/commands/record.ts +218 -0
- package/src/cli/index.ts +2 -0
- package/src/client/App.tsx +7 -1
- package/src/client/canvas/AgentPresenceLayer.tsx +1 -1
- package/src/client/canvas/CanvasNode.tsx +8 -0
- package/src/client/canvas/IntentLayer.tsx +9 -2
- package/src/client/canvas/Presentation.tsx +108 -0
- package/src/client/canvas/TopBar.tsx +28 -2
- package/src/client/canvas/use-node-drag.ts +8 -3
- package/src/client/mermaid-entry.ts +39 -0
- package/src/client/nodes/HtmlNode.tsx +12 -13
- package/src/client/nodes/McpAppNode.tsx +105 -13
- package/src/client/nodes/MermaidNode.tsx +3 -2
- package/src/client/state/intent-store.ts +5 -1
- package/src/client/state/presentation.ts +7 -0
- package/src/client/state/session-store.ts +3 -0
- package/src/client/state/sse-bridge.ts +3 -0
- package/src/client/theme/global.css +58 -4
- package/src/json-render/server.ts +29 -0
- package/src/server/agent-presence.ts +15 -9
- package/src/server/canvas-db.ts +15 -0
- package/src/server/canvas-operations.ts +115 -22
- package/src/server/canvas-schema.ts +10 -0
- package/src/server/canvas-state.ts +39 -4
- package/src/server/canvas-validation.ts +9 -1
- package/src/server/demo-state.json +11 -7
- package/src/server/html-primitives.ts +199 -91
- package/src/server/html-surface.ts +8 -3
- package/src/server/index.ts +12 -0
- package/src/server/operations/composites.ts +3 -1
- package/src/server/operations/ops/app.ts +10 -0
- package/src/server/operations/ops/batch.ts +12 -3
- package/src/server/operations/ops/groups.ts +14 -7
- package/src/server/operations/ops/json-render.ts +37 -5
- package/src/server/operations/ops/nodes.ts +42 -16
- package/src/server/operations/ops/viewport.ts +32 -0
- package/src/server/operations/registry.ts +6 -0
- package/src/server/scope-fence.ts +1 -1
- package/src/server/server.ts +9 -6
- package/src/server/web-artifacts.ts +9 -2
- package/src/server/workboard.ts +3 -3
- package/src/shared/tour.ts +87 -0
package/docs/http-api.md
CHANGED
|
@@ -9,6 +9,40 @@ bodies are treated as an empty request. As of 0.4.0 every error response is a
|
|
|
9
9
|
JSON envelope of that same shape (`{ ok: false, error }`, status unchanged) —
|
|
10
10
|
no plain-text errors remain.
|
|
11
11
|
|
|
12
|
+
Node-create responses include `sizeAdjustment` when a frame dimension was
|
|
13
|
+
defaulted or raised to its node type's readability floor. It contains the
|
|
14
|
+
`requested` dimensions (`null` when omitted), the final `applied` dimensions,
|
|
15
|
+
and a `reason` (`defaulted`, `clamped-to-minimum`, or
|
|
16
|
+
`defaulted-and-clamped-to-minimum`; groups also use `fit-to-children`). `strictSize: true`
|
|
17
|
+
still bypasses the readability clamp, but omitted dimensions are reported as
|
|
18
|
+
defaulted. MCP create actions return the same size report.
|
|
19
|
+
Updates preserve explicit dimensions without applying creation floors; their
|
|
20
|
+
returned node `size` is the applied size. Omitted update dimensions retain the
|
|
21
|
+
existing size and are not reported as defaulted adjustments.
|
|
22
|
+
|
|
23
|
+
Both group-create routes report defaults or final child-derived dimensions, including
|
|
24
|
+
when children override supplied width/height because x/y were omitted. Explicit manual
|
|
25
|
+
frames retain their existing semantics. Group child replacement reports `fit-to-children`
|
|
26
|
+
when it changes the frame; `group.add` reports the previous frame as `requested` and
|
|
27
|
+
the final frame as `applied`. Reports survive compact/full MCP and batch responses.
|
|
28
|
+
External MCP app creation and web-artifact builds that open a node report their default
|
|
29
|
+
frames too; reusing an existing app node or building without opening does not report a
|
|
30
|
+
creation default. These reports describe the synchronous operation, not later browser
|
|
31
|
+
content auto-height or indirect parent-group reflow after editing a child.
|
|
32
|
+
|
|
33
|
+
## Board tours
|
|
34
|
+
|
|
35
|
+
- `GET /api/canvas/tour` → `{ tour: { stops: [...] }, derived: boolean }`.
|
|
36
|
+
- `POST /api/canvas/tour` with `{ "tour": { "stops": [...] } }` persists the tour;
|
|
37
|
+
`{ "tour": null }` resets to derived group reading order. Returns `{ "ok": true }`.
|
|
38
|
+
- `GET /api/canvas/state` includes optional `tour`. Tour writes broadcast a layout
|
|
39
|
+
update and are included in snapshots/undo. They are board-wide writes under a scope fence.
|
|
40
|
+
|
|
41
|
+
The [tour model and recording walkthrough](cli.md#tour-and-presentation) describes
|
|
42
|
+
targets, duration, easing, padding, and pullback. Open `/workbench?present=1` to hide
|
|
43
|
+
chrome; camera movement is local to that viewer. Recording writes local files via
|
|
44
|
+
the CLI, not an HTTP job endpoint.
|
|
45
|
+
|
|
12
46
|
## Canvas state
|
|
13
47
|
|
|
14
48
|
```bash
|
|
@@ -35,6 +69,10 @@ curl -X POST http://localhost:4313/api/canvas/json-render \
|
|
|
35
69
|
-H "Content-Type: application/json" \
|
|
36
70
|
-d '{"title":"Panel","nodeHeight":280,"spec":{"root":"card","elements":{"card":{"type":"Card","props":{"title":"Live"},"children":[]}}}}'
|
|
37
71
|
|
|
72
|
+
# For graph nodes, `height` is the chart's plot height, not the node frame.
|
|
73
|
+
# Set the frame with `nodeHeight` (`heightPx` in the SDK). A graph request that
|
|
74
|
+
# supplies only `height` gets the default frame height and a response warning.
|
|
75
|
+
|
|
38
76
|
# Validate an HTML primitive without creating a node
|
|
39
77
|
curl -X POST http://localhost:4313/api/canvas/schema/validate \
|
|
40
78
|
-H "Content-Type: application/json" \
|
|
@@ -70,6 +108,11 @@ A node creation request must resolve a `type` — pass it in the body (`{ "type"
|
|
|
70
108
|
... }`) or as a `?type=` query param. An empty / type-less body returns `400`
|
|
71
109
|
rather than silently creating a markdown node.
|
|
72
110
|
|
|
111
|
+
Mermaid nodes accept `data: { "fit": "contain" | "none" }` on both
|
|
112
|
+
`POST /api/canvas/node` and `PATCH /api/canvas/node/:id`. The default, `contain`,
|
|
113
|
+
shrinks overflowing diagrams without changing aspect ratio; `none` keeps 100%
|
|
114
|
+
with scrolling. [Mermaid sizing and readability](node-types.md#mermaid-nodes).
|
|
115
|
+
|
|
73
116
|
### File node content and bytes
|
|
74
117
|
|
|
75
118
|
A `file` node reads its file server-side. Text is stored on the node as
|
|
@@ -145,6 +188,31 @@ curl -X DELETE http://localhost:4313/api/canvas/annotation/ann-123
|
|
|
145
188
|
Agent-readable context reports annotation IDs, targets, and bounds. Use WebView
|
|
146
189
|
inspection or screenshots when the drawn shape matters.
|
|
147
190
|
|
|
191
|
+
## Viewport
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
# Set the camera transform (screen = world * scale + {x,y})
|
|
195
|
+
curl -X POST http://localhost:4313/api/canvas/viewport \
|
|
196
|
+
-H "Content-Type: application/json" \
|
|
197
|
+
-d '{"x":64,"y":96,"scale":0.8}'
|
|
198
|
+
|
|
199
|
+
# Fit selected nodes (omit nodeIds to fit the whole canvas)
|
|
200
|
+
curl -X POST http://localhost:4313/api/canvas/fit \
|
|
201
|
+
-H "Content-Type: application/json" \
|
|
202
|
+
-d '{"nodeIds":["node-1"],"width":1200,"height":700}'
|
|
203
|
+
|
|
204
|
+
# Focus without moving the camera
|
|
205
|
+
curl -X POST http://localhost:4313/api/canvas/focus \
|
|
206
|
+
-H "Content-Type: application/json" \
|
|
207
|
+
-d '{"id":"node-1","noPan":true}'
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Viewport `x`/`y`, fit `width`/`height`, and fit padding are interpreted against
|
|
211
|
+
the **canvas area**, excluding the tool rail, top bar, and other surrounding
|
|
212
|
+
page chrome—not the full browser window. Camera, fit, and focus-only requests
|
|
213
|
+
do not create or refresh agent writer presence. Use the explicit presence API
|
|
214
|
+
when an agent should publish a cursor or `focusNodeId`.
|
|
215
|
+
|
|
148
216
|
## Pins
|
|
149
217
|
|
|
150
218
|
```bash
|
|
@@ -341,13 +409,20 @@ When that session ends (`attached: false`, `session-end`, or the idle expiry)
|
|
|
341
409
|
the stream carries one `agent-session-ended` frame:
|
|
342
410
|
|
|
343
411
|
```json
|
|
344
|
-
{ "label": "Copilot", "
|
|
412
|
+
{ "label": "Copilot", "parentAgentId": null, "endedAt": "…", "endedBy": "agent",
|
|
413
|
+
"unchanged": false, "counts": { "items": 4, "done": 3, "cancelled": 1, "rejected": 0, "held": 0 },
|
|
345
414
|
"snapshot": { "id": "…", "name": "Before session · Copilot · 14:00" } }
|
|
346
415
|
```
|
|
347
416
|
|
|
348
|
-
`items`/`done` count the work items on the board;
|
|
349
|
-
|
|
350
|
-
empty at attach
|
|
417
|
+
`items`/`done` count the work items on the board; cancelled items, rejected
|
|
418
|
+
gates, and held gates have separate counts. `snapshot` is null when the board was
|
|
419
|
+
empty at attach or unchanged at end (the redundant snapshot is removed).
|
|
420
|
+
The browser shows a receipt only for a changed top-level session: `unchanged: true`
|
|
421
|
+
and worker endings (`parentAgentId` set) never open or replace a popup. An empty
|
|
422
|
+
board that stays empty is unchanged too. This only filters the popup: session-end
|
|
423
|
+
events, timeline records, and changed-session snapshots remain available.
|
|
424
|
+
There is at most one receipt, replaced by the next qualifying ending and kept until
|
|
425
|
+
dismissed so restore/diff actions do not disappear on a timer. Its *View
|
|
351
426
|
diff* is `GET /api/canvas/snapshots/<id>/diff` against that snapshot, and
|
|
352
427
|
restoring the snapshot undoes the session. Adapters should end their session
|
|
353
428
|
explicitly so the human gets the receipt promptly rather than after the idle
|
package/docs/mcp.md
CHANGED
|
@@ -13,6 +13,15 @@ nodes in the browser, agents are notified immediately.
|
|
|
13
13
|
> snapshot standalones — each step per [`api-stability.md`](api-stability.md)'s
|
|
14
14
|
> deprecate-one-minor-before-removal rule. **Prefer the composites.**
|
|
15
15
|
|
|
16
|
+
## Board tours
|
|
17
|
+
|
|
18
|
+
`canvas_view { action: "get-tour" }` returns `{ tour, derived }`, using group
|
|
19
|
+
reading order when there is no saved tour. `canvas_view { action: "set-tour",
|
|
20
|
+
tour: { stops: [...] } }` persists an ordered tour; `tour: null` resets it.
|
|
21
|
+
See the [tour model and CLI walkthrough](cli.md#tour-and-presentation) for stop
|
|
22
|
+
fields. Recording is a local CLI operation; no remote recording/file-writing
|
|
23
|
+
tool is added.
|
|
24
|
+
|
|
16
25
|
## Connect
|
|
17
26
|
|
|
18
27
|
Add to your agent's MCP config:
|
|
@@ -42,7 +51,7 @@ its `action` to the same operation the legacy tool used, so results are identica
|
|
|
42
51
|
| `canvas_edge` | `add` · `update` · `remove` | `canvas_add_edge`, `canvas_update_edge`, `canvas_remove_edge` |
|
|
43
52
|
| `canvas_group` | `create` · `add` · `ungroup` | `canvas_create_group`, `canvas_group_nodes`, `canvas_ungroup` |
|
|
44
53
|
| `canvas_history` | `undo` · `redo` | `canvas_undo`, `canvas_redo` |
|
|
45
|
-
| `canvas_view` | `arrange` · `focus` · `fit` · `clear` · `remove-annotation` | `canvas_arrange`, `canvas_focus_node`, `canvas_fit_view`, `canvas_clear`, `canvas_remove_annotation` |
|
|
54
|
+
| `canvas_view` | `arrange` · `focus` · `fit` · `clear` · `remove-annotation` · `get-tour` · `set-tour` | `canvas_arrange`, `canvas_focus_node`, `canvas_fit_view`, `canvas_clear`, `canvas_remove_annotation` |
|
|
46
55
|
| `canvas_query` | `search` · `layout` · `validate` | `canvas_search`, `canvas_get_layout`, `canvas_validate` |
|
|
47
56
|
| `canvas_webview` | `status` · `start` · `stop` · `resize` · `evaluate` | `canvas_webview_status`, `canvas_webview_start`, `canvas_webview_stop`, `canvas_resize`, `canvas_evaluate` |
|
|
48
57
|
| `canvas_app` | `open-mcp-app` · `diagram` · `build-artifact` | `canvas_open_mcp_app`, `canvas_add_diagram`, `canvas_build_web_artifact` |
|
|
@@ -54,6 +63,11 @@ its `action` to the same operation the legacy tool used, so results are identica
|
|
|
54
63
|
| `canvas_intent` | `signal` · `update` · `clear` | _(new — Ghost Cursor of Intent; no legacy standalone tool)_ |
|
|
55
64
|
| `canvas_snapshot` | `save` · `list` · `restore` · `delete` · `gc` · `diff` | `canvas_snapshot` (legacy save tool), `canvas_list_snapshots`, `canvas_restore`, `canvas_delete_snapshot`, `canvas_gc_snapshots`, `canvas_diff` — removed in v0.4.0 after one deprecated minor |
|
|
56
65
|
|
|
66
|
+
For Mermaid nodes, `canvas_node` actions `add` and `update` accept
|
|
67
|
+
`data: { fit: "contain" | "none" }`. Contain is the default, shrinking overflow
|
|
68
|
+
uniformly; none keeps 100% with scrolling. Use Open as site for 100% without
|
|
69
|
+
changing the node setting. [Sizing and readability](node-types.md#mermaid-nodes).
|
|
70
|
+
|
|
57
71
|
### `canvas_intent` — Ghost Cursor of Intent
|
|
58
72
|
|
|
59
73
|
Announce the spatial move you are **about** to make so the canvas paints a faint
|
|
@@ -92,6 +106,16 @@ fixing `node.update`'s `formatResult` to surface a FAILED refresh as `isError` +
|
|
|
92
106
|
fold completed in v0.4.0: `canvas_snapshot { action: "save" | "list" | "restore" |
|
|
93
107
|
"delete" | "gc" | "diff" }` replaced the 6 deprecated snapshot standalones.
|
|
94
108
|
|
|
109
|
+
### Viewport coordinates
|
|
110
|
+
|
|
111
|
+
`canvas_view { action: "focus" | "fit" }` controls the camera without counting
|
|
112
|
+
as a writer operation or refreshing agent presence. Viewport translation uses
|
|
113
|
+
screen-space canvas-area coordinates (`screen = world * scale + viewport`). Fit
|
|
114
|
+
dimensions and padding likewise refer to the canvas area after excluding the
|
|
115
|
+
tool rail, top bar, and surrounding page chrome—not the full browser window.
|
|
116
|
+
Use `canvas_ax_state { action: "set-presence", cursor, focusNodeId }` when the
|
|
117
|
+
agent intends to publish its own cursor or attention target.
|
|
118
|
+
|
|
95
119
|
## Standalone tools
|
|
96
120
|
|
|
97
121
|
6 tools that intentionally stay outside the composites — folding them would
|
package/docs/node-types.md
CHANGED
|
@@ -26,6 +26,17 @@ see [MCP tools](mcp.md), [HTTP API](http-api.md), and [SDK](sdk.md).
|
|
|
26
26
|
| `web-artifact` | Bundled React/Tailwind artifact (full single-file app) |
|
|
27
27
|
| `group` | Spatial container/frame around other nodes |
|
|
28
28
|
|
|
29
|
+
Creation applies per-type frame defaults and readability minimums. When a
|
|
30
|
+
dimension is defaulted or clamped, HTTP and MCP create responses include a
|
|
31
|
+
`sizeAdjustment` with requested and applied dimensions plus the reason.
|
|
32
|
+
`strictSize: true` bypasses minimum-size clamping; it does not change defaults
|
|
33
|
+
for omitted dimensions.
|
|
34
|
+
|
|
35
|
+
For `graph` nodes, `height` controls the chart plot inside the viewer.
|
|
36
|
+
`nodeHeight` controls the canvas node frame (`heightPx` is the SDK alias). If
|
|
37
|
+
only `height` is supplied, the frame keeps its default height and the create
|
|
38
|
+
response warns about the distinction.
|
|
39
|
+
|
|
29
40
|
Thread node types `prompt` and `response` exist internally for agent
|
|
30
41
|
conversation rendering and are not created through public APIs.
|
|
31
42
|
|
|
@@ -64,6 +75,34 @@ different type by extension:
|
|
|
64
75
|
- **Text files over 2 MB** render truncated with a banner saying so, so a huge
|
|
65
76
|
log can never exhaust memory.
|
|
66
77
|
|
|
78
|
+
## Mermaid nodes
|
|
79
|
+
|
|
80
|
+
Mermaid diagrams default to `data.fit: "contain"`: when the diagram overflows
|
|
81
|
+
the available body, it shrinks uniformly until the entire diagram fits. Small
|
|
82
|
+
diagrams stay at 100%; aspect ratio, labels, and edges are preserved, in both
|
|
83
|
+
dark and light themes. Resizing a card or expanding it recalculates the fit.
|
|
84
|
+
`strictSize` keeps the requested node geometry while the diagram fits inside it;
|
|
85
|
+
ordinary cards retain their grow-only automatic sizing.
|
|
86
|
+
|
|
87
|
+
Fitting a large diagram makes its text smaller. Use **Expand** for more space,
|
|
88
|
+
or **Open as site** for a 100% natural-size diagram with scrolling. Set
|
|
89
|
+
`data.fit: "none"` to use that natural-size, scrollable view inside the card too.
|
|
90
|
+
This setting is independent of the canvas camera's **Fit view** action.
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
canvas_node({ action: 'add', type: 'mermaid', content: 'flowchart TD; A-->B-->C-->D',
|
|
94
|
+
width: 920, height: 870, strictSize: true, data: { fit: 'contain' } })
|
|
95
|
+
canvas_node({ action: 'update', id: 'node-id', data: { fit: 'none' } })
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
HTTP uses the same `data` object in `POST /api/canvas/node` and
|
|
99
|
+
`PATCH /api/canvas/node/:id`. CLI creation and updates expose `--fit`:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
pmx-canvas node add --type mermaid --content 'flowchart TD; A-->B-->C-->D' --fit contain --width 920 --height 870 --strict-size
|
|
103
|
+
pmx-canvas node update node-id --fit none
|
|
104
|
+
```
|
|
105
|
+
|
|
67
106
|
## Image nodes
|
|
68
107
|
|
|
69
108
|
Image nodes display local paths, remote URLs, and data URIs. File-backed and
|
|
@@ -163,6 +202,40 @@ Specs preserve top-level `state` and element-level `repeat`, `watch`, and
|
|
|
163
202
|
`$state` only reads a value. Nested repeats can use `statePath: { "$item": "tasks" }`
|
|
164
203
|
to iterate the current item's tasks. Watchers run after changes, not on mount.
|
|
165
204
|
|
|
205
|
+
**Seed form values through bindings, not literal `value` props.** In shadcn
|
|
206
|
+
0.21, `Input`, `Textarea`, `Select`, `Radio`, `Slider`, `ToggleGroup`, and
|
|
207
|
+
`Tabs` ignore an unbound `value`; `ButtonGroup` ignores unbound `selected`.
|
|
208
|
+
This includes read-only `$state` expressions. PMX returns a `warnings` array
|
|
209
|
+
from `POST /api/canvas/json-render`, `POST /api/canvas/schema/validate`, and
|
|
210
|
+
`canvas_render` actions `add-json-render` and `validate`. Warnings name the
|
|
211
|
+
element/property; they do not reject or rewrite the spec. PMX does not invent
|
|
212
|
+
state paths because repeated controls and existing actions may need distinct
|
|
213
|
+
or deliberately shared state.
|
|
214
|
+
|
|
215
|
+
```json
|
|
216
|
+
{
|
|
217
|
+
"root": "postcode",
|
|
218
|
+
"state": { "form": { "postcode": "nw1 6xe" } },
|
|
219
|
+
"elements": {
|
|
220
|
+
"postcode": {
|
|
221
|
+
"type": "Input",
|
|
222
|
+
"props": {
|
|
223
|
+
"label": "Postcode",
|
|
224
|
+
"name": "postcode",
|
|
225
|
+
"value": { "$bindState": "/form/postcode" }
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Inside repeats, use `$bindItem` with seeded item data. `Checkbox`/`Switch`
|
|
233
|
+
honor literal `checked`, `Toggle` honors literal `pressed`, and `Tabs` honors
|
|
234
|
+
`defaultValue` for an uncontrolled initial tab. `Pagination` reads `page`
|
|
235
|
+
directly; `DropdownMenu.value` is an output binding for selection, not a
|
|
236
|
+
displayed selection. These do not require the same ignored-value warning.
|
|
237
|
+
Use two-way bindings whenever edits or form checks must participate in state.
|
|
238
|
+
|
|
166
239
|
Cards keep their body in `children` and accept
|
|
167
240
|
`slots: { header: ['headingId'], footer: ['actionsId'] }`. Slot IDs reference
|
|
168
241
|
entries in `elements`; a custom header replaces the title and description props.
|
|
@@ -277,6 +350,13 @@ component gallery, interaction prototype, flowchart, SVG illustration set,
|
|
|
277
350
|
presentation, explainer, status report, incident report, triage board, config
|
|
278
351
|
editor, or prompt tuner.
|
|
279
352
|
|
|
353
|
+
Omitted fields may use the template's examples. Explicit empty arrays, empty
|
|
354
|
+
strings, or `null` mean “show nothing”: the field's section, heading, and
|
|
355
|
+
dependent controls are omitted rather than replaced with sample content.
|
|
356
|
+
Other populated or omitted fields keep their normal behavior. Empty `slides`
|
|
357
|
+
or flow `steps` also omit navigation/run controls; an AX board's live task
|
|
358
|
+
controls remain available when only its optional note is empty.
|
|
359
|
+
|
|
280
360
|
Two kinds are **live AX control surfaces** rather than static documents, and are
|
|
281
361
|
the only primitives created with AX enabled:
|
|
282
362
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pmx-canvas",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
4
4
|
"description": "Spatial canvas workbench for coding agents — infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/server/index.ts",
|
|
@@ -16,9 +16,17 @@ PMX Canvas is a server-authoritative spatial workbench controlled through MCP, H
|
|
|
16
16
|
Humans curate agent context by pinning nodes; agents read that curation through
|
|
17
17
|
`canvas://pinned-context`. State survives browser refresh.
|
|
18
18
|
|
|
19
|
+
For presentations, `canvas_view { action: "set-tour", tour: { stops: [...] } }`
|
|
20
|
+
persists ordered camera stops; `get-tour` reads them (or derived group order).
|
|
21
|
+
Each stop targets `{ nodeId }` (including groups) or `{ viewport: { x, y, scale } }`,
|
|
22
|
+
with optional `duration` seconds, `easing`, screen-pixel `padding`, and `pullback`.
|
|
23
|
+
Open `/workbench?present=1` for chrome-free viewing. Optionally capture that same
|
|
24
|
+
tour with `pmx-canvas record --mode deterministic --present --output tour.mp4`.
|
|
25
|
+
There is no separate recording model. See `docs/cli.md` for the full tour model.
|
|
26
|
+
|
|
19
27
|
## Runtime prerequisites
|
|
20
28
|
|
|
21
|
-
PMX Canvas 0.6.
|
|
29
|
+
PMX Canvas 0.6.5 requires **Bun >=1.4.2**, including when installed through npm.
|
|
22
30
|
Check `bun --version` and ensure Bun is on the MCP host's PATH, not just your shell's;
|
|
23
31
|
use an absolute executable path if needed. Install/configure only when requested.
|
|
24
32
|
For pinned installation, MCP configuration, managed services, and disposable verification,
|
|
@@ -178,7 +186,7 @@ always authoritative, independent of the installed release version.
|
|
|
178
186
|
| `canvas_edge` | `add`, `update`, `remove` |
|
|
179
187
|
| `canvas_group` | `create`, `add`, `ungroup` |
|
|
180
188
|
| `canvas_history` | `undo`, `redo` |
|
|
181
|
-
| `canvas_view` | `arrange`, `focus`, `fit`, `clear`, `remove-annotation` |
|
|
189
|
+
| `canvas_view` | `arrange`, `focus`, `fit`, `clear`, `remove-annotation`, `get-tour`, `set-tour` |
|
|
182
190
|
| `canvas_query` | `search`, `layout`, `validate` |
|
|
183
191
|
| `canvas_webview` | `status`, `start`, `stop`, `resize`, `evaluate` |
|
|
184
192
|
| `canvas_app` | `open-mcp-app`, `diagram`, `build-artifact` |
|
|
@@ -441,7 +441,7 @@ single-purpose tools behind an `action` (and, for `canvas_ax_gate`, a `kind`) di
|
|
|
441
441
|
| `canvas_group` | `create` · `add` · `ungroup` | Manage spatial group containers |
|
|
442
442
|
| `canvas_history` | `undo` · `redo` | Time travel through the mutation ring buffer |
|
|
443
443
|
| `canvas_snapshot` | `save` · `list` · `restore` · `delete` · `gc` · `diff` | Named snapshots: save/list/restore/delete, garbage-collect old ones, diff current canvas vs a snapshot (`diff` takes `snapshot`, not `id`) |
|
|
444
|
-
| `canvas_view` | `arrange` · `focus` · `fit` · `clear` · `remove-annotation` | Auto-arrange, pan-to-node, fit viewport, clear the board, delete
|
|
444
|
+
| `canvas_view` | `arrange` · `focus` · `fit` · `clear` · `remove-annotation` · `get-tour` · `set-tour` | Auto-arrange, pan-to-node, fit viewport, clear the board, delete annotations, read/persist ordered presentation stops (`tour: null` derives group order) |
|
|
445
445
|
| `canvas_query` | `search` · `layout` · `validate` | Find nodes by keyword, read full layout, or **`validate`** the board for node collisions / group-containment / dangling edges |
|
|
446
446
|
| `canvas_app` | `open-mcp-app` · `diagram` · `build-artifact` | Hosted MCP apps, the Excalidraw diagram preset, and bundled web artifacts (folds `canvas_open_mcp_app` / `canvas_add_diagram` / `canvas_build_web_artifact`) |
|
|
447
447
|
| `canvas_webview` | `status` · `start` · `stop` · `resize` · `evaluate` | Headless Bun.WebView automation for the workbench (folds the `canvas_webview_*` / `canvas_resize` / `canvas_evaluate` tools) |
|
|
@@ -4,7 +4,7 @@ Use this reference when the `pmx-canvas` skill is installed but the `pmx-canvas`
|
|
|
4
4
|
|
|
5
5
|
## Prerequisites and version
|
|
6
6
|
|
|
7
|
-
The examples pin **PMX Canvas 0.6.
|
|
7
|
+
The examples pin **PMX Canvas 0.6.5**, which requires **Bun >=1.4.2** even when
|
|
8
8
|
installed through npm: the CLI runs through Bun, not Node. Check `bun --version`
|
|
9
9
|
first. If missing or older, follow the [Bun installation instructions](https://bun.sh/docs/installation)
|
|
10
10
|
with the user's authorization. Ensure Bun is on the MCP host's PATH as well as the
|
|
@@ -17,11 +17,11 @@ in `SKILL.md`. Do not pair newer checkout guidance with an older runtime unknowi
|
|
|
17
17
|
## Install from npm
|
|
18
18
|
|
|
19
19
|
```bash
|
|
20
|
-
npm install -g pmx-canvas@0.6.
|
|
20
|
+
npm install -g pmx-canvas@0.6.5
|
|
21
21
|
pmx-canvas --version
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
Without a global install, use `bunx pmx-canvas@0.6.
|
|
24
|
+
Without a global install, use `bunx pmx-canvas@0.6.5` in place of `pmx-canvas` in
|
|
25
25
|
the commands below. Do not use unpinned `bunx pmx-canvas` for a version-specific check.
|
|
26
26
|
|
|
27
27
|
## Choose the workspace and target
|
|
@@ -91,7 +91,7 @@ discards empty env values, remove `PMX_CANVAS_URL` from its launch environment i
|
|
|
91
91
|
"mcpServers": {
|
|
92
92
|
"canvas": {
|
|
93
93
|
"command": "bunx",
|
|
94
|
-
"args": ["pmx-canvas@0.6.
|
|
94
|
+
"args": ["pmx-canvas@0.6.5", "--mcp"],
|
|
95
95
|
"env": {
|
|
96
96
|
"PMX_CANVAS_WORKSPACE_ROOT": "/absolute/path/to/project",
|
|
97
97
|
"PMX_CANVAS_PORT": "14313",
|
package/src/cli/agent.ts
CHANGED
|
@@ -30,6 +30,7 @@ import './commands/history.js';
|
|
|
30
30
|
import './commands/ax.js';
|
|
31
31
|
import './commands/pump.js';
|
|
32
32
|
import './commands/webview.js';
|
|
33
|
+
import './commands/record.js';
|
|
33
34
|
import './commands/apps.js';
|
|
34
35
|
import './commands/copilot.js';
|
|
35
36
|
import './commands/skills.js';
|
|
@@ -46,6 +47,10 @@ Usage:
|
|
|
46
47
|
pmx-canvas <command> [options]
|
|
47
48
|
pmx-canvas [server-options]
|
|
48
49
|
|
|
50
|
+
Presentation and capture:
|
|
51
|
+
pmx-canvas tour get|set Read or persist ordered camera stops
|
|
52
|
+
pmx-canvas record --help Optionally capture the same board tour or live presentation
|
|
53
|
+
|
|
49
54
|
Server:
|
|
50
55
|
pmx-canvas Start server + open browser
|
|
51
56
|
pmx-canvas --no-open --demo Start server headless with sample data
|
|
@@ -27,6 +27,12 @@ import {
|
|
|
27
27
|
|
|
28
28
|
const TRACE_NODE_FIELDS = ['toolName', 'category', 'status', 'duration', 'resultSummary', 'error'] as const;
|
|
29
29
|
|
|
30
|
+
function applyMermaidFitFlag(body: Record<string, unknown>, flags: Record<string, string | boolean>): void {
|
|
31
|
+
if (flags.fit === undefined) return;
|
|
32
|
+
if (flags.fit !== 'contain' && flags.fit !== 'none') die('Use --fit contain or --fit none (Mermaid nodes).');
|
|
33
|
+
body.data = { ...(isRecord(body.data) ? body.data : {}), fit: flags.fit };
|
|
34
|
+
}
|
|
35
|
+
|
|
30
36
|
interface CanvasSchemaField {
|
|
31
37
|
name: string;
|
|
32
38
|
type: string;
|
|
@@ -483,6 +489,7 @@ cmd(
|
|
|
483
489
|
'pmx-canvas node add --type html --title "Showcase" --content ./report.html (a .html path is read from disk; otherwise --content is raw HTML)',
|
|
484
490
|
'pmx-canvas node add --type html --primitive choice-grid --data-file ./options.json --title "Options"',
|
|
485
491
|
'pmx-canvas node add --type markdown --title "Note" --x 100 --y 200',
|
|
492
|
+
'pmx-canvas node add --type mermaid --content "graph TD; A-->B" --fit contain',
|
|
486
493
|
'pmx-canvas node add --type json-render --title "Ops Dashboard" --spec-file ./dashboard.json',
|
|
487
494
|
'pmx-canvas node add --type graph --graph-type bar --data-file ./metrics.json --x-key label --y-key value',
|
|
488
495
|
'pmx-canvas node add --type web-artifact --title "Dashboard" --app-file ./App.tsx',
|
|
@@ -530,6 +537,7 @@ cmd(
|
|
|
530
537
|
}
|
|
531
538
|
|
|
532
539
|
const body: Record<string, unknown> = { type };
|
|
540
|
+
applyMermaidFitFlag(body, flags);
|
|
533
541
|
if (flags.title) body.title = flags.title;
|
|
534
542
|
const webpageUrl = getStringFlag(flags, 'url');
|
|
535
543
|
const imagePath = getStringFlag(flags, 'path');
|
|
@@ -821,6 +829,7 @@ cmd(
|
|
|
821
829
|
[
|
|
822
830
|
'pmx-canvas node update <node-id> --title "New Title"',
|
|
823
831
|
'pmx-canvas node update <node-id> --content "Updated content"',
|
|
832
|
+
'pmx-canvas node update <mermaid-id> --fit none',
|
|
824
833
|
'pmx-canvas node update <node-id> --title "Moved" --x 500 --y 300',
|
|
825
834
|
'pmx-canvas node update <node-id> --width 840 --height 620',
|
|
826
835
|
'pmx-canvas node update <node-id> --spec-file ./dashboard.json',
|
|
@@ -838,6 +847,7 @@ cmd(
|
|
|
838
847
|
|
|
839
848
|
const body: Record<string, unknown> = {};
|
|
840
849
|
await applyStructuredNodeUpdateFlags(body, flags);
|
|
850
|
+
applyMermaidFitFlag(body, flags);
|
|
841
851
|
if (flags.title && flags.title !== true) body.title = flags.title;
|
|
842
852
|
if (flags.content && flags.content !== true) body.content = flags.content;
|
|
843
853
|
if (flags.stdin) body.content = await readStdin();
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { mkdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { resolve, join } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { isCanvasTheme } from '../../shared/themes.js';
|
|
5
|
+
import { derivedTour, tourSchema, tourFrames, type Camera, type TourNode } from '../../shared/tour.js';
|
|
6
|
+
import {
|
|
7
|
+
startCanvasAutomationWebView,
|
|
8
|
+
evaluateCanvasAutomationWebView,
|
|
9
|
+
screenshotCanvasAutomationWebView,
|
|
10
|
+
resizeCanvasAutomationWebView,
|
|
11
|
+
stopCanvasAutomationWebView,
|
|
12
|
+
} from '../../server/server.js';
|
|
13
|
+
import { cmd, getBaseUrl, invokeOperation, output, parseFlags, showCommandHelp } from '../shared.js';
|
|
14
|
+
|
|
15
|
+
export function parseRecordOptions(flags: Record<string, string | boolean>) {
|
|
16
|
+
const number = (key: string, fallback: number) => {
|
|
17
|
+
if (flags[key] === true || flags[key] === false) throw new Error(`--${key} needs a number`);
|
|
18
|
+
return flags[key] === undefined ? fallback : Number(flags[key]);
|
|
19
|
+
};
|
|
20
|
+
for (const key of ['tour-file', 'chrome-path']) {
|
|
21
|
+
if (flags[key] !== undefined && typeof flags[key] !== 'string') throw new Error(`--${key} needs a path`);
|
|
22
|
+
}
|
|
23
|
+
const resolution = String(flags.resolution ?? '1280x720').match(/^(\d+)x(\d+)$/);
|
|
24
|
+
if (!resolution) throw new Error('Use --resolution WIDTHxHEIGHT');
|
|
25
|
+
const options = z
|
|
26
|
+
.object({
|
|
27
|
+
width: z.number().int().min(64).max(7680),
|
|
28
|
+
height: z.number().int().min(64).max(4320),
|
|
29
|
+
fps: z.number().int().min(1).max(120),
|
|
30
|
+
duration: z.number().positive().max(86400).optional(),
|
|
31
|
+
mode: z.enum(['realtime', 'deterministic']),
|
|
32
|
+
output: z.string().min(1),
|
|
33
|
+
})
|
|
34
|
+
.parse({
|
|
35
|
+
width: Number(resolution[1]),
|
|
36
|
+
height: Number(resolution[2]),
|
|
37
|
+
fps: number('fps', 30),
|
|
38
|
+
duration: flags.duration === undefined ? undefined : number('duration', 10),
|
|
39
|
+
mode: flags.mode ?? 'realtime',
|
|
40
|
+
output: typeof flags.output === 'string' ? flags.output : '',
|
|
41
|
+
});
|
|
42
|
+
if (flags.theme !== undefined && !isCanvasTheme(flags.theme)) throw new Error('Unknown --theme');
|
|
43
|
+
if (options.mode === 'realtime' && !options.duration && flags['stop-on-signal'] !== true) {
|
|
44
|
+
throw new Error('Realtime recording needs --duration SECONDS or --stop-on-signal');
|
|
45
|
+
}
|
|
46
|
+
if (options.mode === 'deterministic' && (flags.duration !== undefined || flags['stop-on-signal'])) {
|
|
47
|
+
throw new Error('Deterministic duration comes from tour stops; omit --duration and --stop-on-signal');
|
|
48
|
+
}
|
|
49
|
+
if (flags['tour-file'] && options.mode !== 'deterministic')
|
|
50
|
+
throw new Error('--tour-file requires --mode deterministic');
|
|
51
|
+
return {
|
|
52
|
+
...options,
|
|
53
|
+
theme: typeof flags.theme === 'string' ? flags.theme : undefined,
|
|
54
|
+
present: flags.present === true,
|
|
55
|
+
tourFile: typeof flags['tour-file'] === 'string' ? flags['tour-file'] : undefined,
|
|
56
|
+
chromePath: typeof flags['chrome-path'] === 'string' ? flags['chrome-path'] : undefined,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
cmd('tour get', 'Read the persisted tour or derived group order', ['pmx-canvas tour get'], async () => {
|
|
61
|
+
output(await invokeOperation('tour.get', {}));
|
|
62
|
+
});
|
|
63
|
+
cmd(
|
|
64
|
+
'tour set',
|
|
65
|
+
'Persist camera stops from JSON (null resets to group order)',
|
|
66
|
+
['pmx-canvas tour set --file tour.json'],
|
|
67
|
+
async (args) => {
|
|
68
|
+
const { flags } = parseFlags(args);
|
|
69
|
+
if (flags.help) return showCommandHelp('tour set');
|
|
70
|
+
if (typeof flags.file !== 'string') throw new Error('Missing --file');
|
|
71
|
+
const tour = tourSchema.nullable().parse(await Bun.file(flags.file).json());
|
|
72
|
+
output(await invokeOperation('tour.set', { tour }));
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
cmd(
|
|
77
|
+
'record',
|
|
78
|
+
'Optionally capture the board tour or live presentation using Bun.WebView Chrome',
|
|
79
|
+
[
|
|
80
|
+
'pmx-canvas record --duration 10 --present --output demo.mp4',
|
|
81
|
+
'pmx-canvas record --mode deterministic --tour-file tour.json --present --fps 30 --output frames',
|
|
82
|
+
'pmx-canvas record --stop-on-signal --resolution 1920x1080 --output live.mp4',
|
|
83
|
+
],
|
|
84
|
+
async (args) => {
|
|
85
|
+
const { flags } = parseFlags(args, { boolFlags: ['present', 'stop-on-signal'] });
|
|
86
|
+
if (flags.help || flags.h) return showCommandHelp('record');
|
|
87
|
+
const options = parseRecordOptions(flags);
|
|
88
|
+
const outputPath = resolve(options.output);
|
|
89
|
+
const video = /\.mp4$/i.test(outputPath);
|
|
90
|
+
const framesPath = video ? `${outputPath}.frames` : outputPath;
|
|
91
|
+
if (existsSync(outputPath) || existsSync(framesPath)) throw new Error('Output already exists; choose a new path');
|
|
92
|
+
const response = await fetch(`${getBaseUrl()}/api/canvas/state`);
|
|
93
|
+
if (!response.ok) throw new Error(`Cannot read board: HTTP ${response.status}`);
|
|
94
|
+
const board = (await response.json()) as { nodes: TourNode[]; viewport: Camera; tour?: unknown };
|
|
95
|
+
const tour =
|
|
96
|
+
options.mode === 'deterministic'
|
|
97
|
+
? tourSchema.parse(
|
|
98
|
+
options.tourFile ? await Bun.file(options.tourFile).json() : (board.tour ?? derivedTour(board.nodes)),
|
|
99
|
+
)
|
|
100
|
+
: undefined;
|
|
101
|
+
if (tour && !tour.stops.length) throw new Error('No tour stops: supply --tour-file or create groups/a tour');
|
|
102
|
+
const url = new URL('/workbench', getBaseUrl());
|
|
103
|
+
if (options.present) url.searchParams.set('present', '1');
|
|
104
|
+
if (tour) url.searchParams.set('capture', '1');
|
|
105
|
+
if (options.theme) url.searchParams.set('theme', options.theme);
|
|
106
|
+
let stopping = false;
|
|
107
|
+
const stop = () => {
|
|
108
|
+
stopping = true;
|
|
109
|
+
};
|
|
110
|
+
process.on('SIGINT', stop);
|
|
111
|
+
process.on('SIGTERM', stop);
|
|
112
|
+
let count = 0;
|
|
113
|
+
let duplicated = 0;
|
|
114
|
+
try {
|
|
115
|
+
await startCanvasAutomationWebView(url.href, {
|
|
116
|
+
backend: 'chrome',
|
|
117
|
+
width: options.width,
|
|
118
|
+
height: options.height,
|
|
119
|
+
chromePath: options.chromePath,
|
|
120
|
+
});
|
|
121
|
+
const deadline = Date.now() + 30_000;
|
|
122
|
+
while (!(await evaluateCanvasAutomationWebView('!!window.pmxCapture?.ready()'))) {
|
|
123
|
+
if (Date.now() > deadline) throw new Error('Workbench did not become capture-ready in 30 seconds');
|
|
124
|
+
await Bun.sleep(50);
|
|
125
|
+
}
|
|
126
|
+
// Chrome launch dimensions describe the outer window. WebView.resize
|
|
127
|
+
// applies viewport emulation, giving screenshots the requested pixels.
|
|
128
|
+
await resizeCanvasAutomationWebView(options.width, options.height);
|
|
129
|
+
await evaluateCanvasAutomationWebView(
|
|
130
|
+
'new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))',
|
|
131
|
+
);
|
|
132
|
+
await evaluateCanvasAutomationWebView('document.fonts.ready.then(() => true)');
|
|
133
|
+
const area = (await evaluateCanvasAutomationWebView('window.pmxCapture.area()')) as {
|
|
134
|
+
width: number;
|
|
135
|
+
height: number;
|
|
136
|
+
};
|
|
137
|
+
const initial = (await evaluateCanvasAutomationWebView('window.pmxCapture.camera()')) as Camera;
|
|
138
|
+
mkdirSync(framesPath, { recursive: true });
|
|
139
|
+
const write = async (bytes: Uint8Array) => {
|
|
140
|
+
await Bun.write(join(framesPath, `frame-${String(count++).padStart(6, '0')}.png`), bytes);
|
|
141
|
+
};
|
|
142
|
+
const started = performance.now();
|
|
143
|
+
if (tour) {
|
|
144
|
+
for (const camera of tourFrames(tour, initial, board.nodes, area.width, area.height, options.fps)) {
|
|
145
|
+
if (stopping) break;
|
|
146
|
+
const applied = (await evaluateCanvasAutomationWebView(
|
|
147
|
+
`window.pmxCapture.frame(${JSON.stringify(camera)})`,
|
|
148
|
+
)) as Camera;
|
|
149
|
+
if (applied.x !== camera.x || applied.y !== camera.y || applied.scale !== camera.scale) {
|
|
150
|
+
throw new Error('Capture camera was changed before paint');
|
|
151
|
+
}
|
|
152
|
+
await write(await screenshotCanvasAutomationWebView({ format: 'png' }));
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
const limit = options.duration ? Math.ceil(options.duration * options.fps) : Infinity;
|
|
156
|
+
while (!stopping && count < limit) {
|
|
157
|
+
await Bun.sleep(Math.max(0, started + (count / options.fps) * 1000 - performance.now()));
|
|
158
|
+
const bytes = await screenshotCanvasAutomationWebView({ format: 'png' });
|
|
159
|
+
await write(bytes);
|
|
160
|
+
// Preserve wall-clock speed if capture is slower than requested fps.
|
|
161
|
+
const elapsedFrames = Math.min(limit, Math.floor(((performance.now() - started) / 1000) * options.fps));
|
|
162
|
+
while (count < elapsedFrames) {
|
|
163
|
+
await write(bytes);
|
|
164
|
+
duplicated++;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
await Bun.write(
|
|
169
|
+
join(framesPath, 'recording.json'),
|
|
170
|
+
JSON.stringify(
|
|
171
|
+
{
|
|
172
|
+
mode: options.mode,
|
|
173
|
+
fps: options.fps,
|
|
174
|
+
width: options.width,
|
|
175
|
+
height: options.height,
|
|
176
|
+
frames: count,
|
|
177
|
+
duplicated,
|
|
178
|
+
seconds: count / options.fps,
|
|
179
|
+
},
|
|
180
|
+
null,
|
|
181
|
+
2,
|
|
182
|
+
),
|
|
183
|
+
);
|
|
184
|
+
} finally {
|
|
185
|
+
process.off('SIGINT', stop);
|
|
186
|
+
process.off('SIGTERM', stop);
|
|
187
|
+
await stopCanvasAutomationWebView();
|
|
188
|
+
}
|
|
189
|
+
let encoded = false;
|
|
190
|
+
if (video && count > 0) {
|
|
191
|
+
const ffmpeg = Bun.which('ffmpeg');
|
|
192
|
+
if (!ffmpeg) console.error(`ffmpeg not found; PNG sequence retained at ${framesPath}`);
|
|
193
|
+
else {
|
|
194
|
+
const process = Bun.spawn(
|
|
195
|
+
[
|
|
196
|
+
ffmpeg,
|
|
197
|
+
'-n',
|
|
198
|
+
'-framerate',
|
|
199
|
+
String(options.fps),
|
|
200
|
+
'-i',
|
|
201
|
+
join(framesPath, 'frame-%06d.png'),
|
|
202
|
+
'-c:v',
|
|
203
|
+
'libx264',
|
|
204
|
+
'-pix_fmt',
|
|
205
|
+
'yuv420p',
|
|
206
|
+
'-vf',
|
|
207
|
+
'pad=ceil(iw/2)*2:ceil(ih/2)*2',
|
|
208
|
+
outputPath,
|
|
209
|
+
],
|
|
210
|
+
{ stdout: 'ignore', stderr: 'inherit' },
|
|
211
|
+
);
|
|
212
|
+
encoded = (await process.exited) === 0;
|
|
213
|
+
if (!encoded) throw new Error(`ffmpeg failed; PNG sequence retained at ${framesPath}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
output({ ok: true, output: encoded ? outputPath : framesPath, frames: count, fps: options.fps, duplicated });
|
|
217
|
+
},
|
|
218
|
+
);
|
package/src/cli/index.ts
CHANGED
|
@@ -32,6 +32,8 @@ if (args.includes('--version') || args.includes('-v')) {
|
|
|
32
32
|
// ── Agent CLI subcommands ────────────────────────────────────
|
|
33
33
|
// If first arg is a known subcommand (not a --flag), route to the agent CLI.
|
|
34
34
|
const AGENT_COMMANDS = new Set([
|
|
35
|
+
'tour',
|
|
36
|
+
'record',
|
|
35
37
|
'node',
|
|
36
38
|
'edge',
|
|
37
39
|
'json-render',
|