cozyclay 1.1.0 → 1.3.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 +85 -0
- package/README.md +91 -25
- package/THIRD_PARTY_NOTICES.md +68 -1
- package/bin/cozyclay.mjs +71 -8
- package/dist/CNAME +1 -0
- package/dist/ai-camera-control/index.html +406 -0
- package/dist/app/index.html +57 -0
- package/dist/assets/app-B3U5aut1.js +4811 -0
- package/dist/assets/app-BrRF0wso.css +1 -0
- package/dist/assets/vision_bundle-jFkh-fIS.js +41 -0
- package/dist/favicon.ico +0 -0
- package/dist/fonts/InstrumentSerif-OFL.txt +93 -0
- package/dist/fonts/Inter-OFL.txt +92 -0
- package/dist/fonts/README.md +15 -0
- package/dist/icons/apple-touch-icon.png +0 -0
- package/dist/icons/icon-192.png +0 -0
- package/dist/icons/icon-32.png +0 -0
- package/dist/icons/icon-512.png +0 -0
- package/dist/icons/icon-maskable-512.png +0 -0
- package/dist/index.html +450 -5
- package/dist/manifest.webmanifest +42 -0
- package/dist/media/cozyclay-demo-poster.jpg +0 -0
- package/dist/media/cozyclay-logo.png +0 -0
- package/dist/robots.txt +4 -0
- package/dist/sitemap.xml +15 -0
- package/dist/sw.js +127 -0
- package/mcp/LIVE-PROTOCOL.md +63 -0
- package/mcp/README.md +142 -0
- package/mcp/ardy-prompts.mjs +170 -0
- package/mcp/live-hub.mjs +105 -0
- package/mcp/package.json +24 -0
- package/mcp/server.mjs +1394 -0
- package/package.json +122 -78
- package/src/App.jsx +4235 -1047
- package/src/ardy/client.js +5 -4
- package/src/ardy/cskel27.js +7 -2
- package/src/ardy/ik.js +25 -15
- package/src/ardy/mp4-duration.js +102 -0
- package/src/ardy/npz.js +64 -3
- package/src/ardy/playback.js +31 -1
- package/src/ardy/prompt-clips.js +7 -2
- package/src/ardy/retime.js +211 -0
- package/src/ardy/timeline-coordinates.js +24 -0
- package/src/ardy/timeline.jsx +650 -70
- package/src/ardy/to-cskel27.js +34 -12
- package/src/ardy/trim.js +33 -0
- package/src/ardy/waypoints.js +14 -9
- package/src/asset-pane.jsx +36 -0
- package/src/camera-block.js +60 -0
- package/src/camera-follow.js +49 -10
- package/src/camera-rail-schedule.js +176 -0
- package/src/controls.jsx +16 -2
- package/src/cuts.js +184 -0
- package/src/dualview.jsx +14 -8
- package/src/generation/client.js +56 -0
- package/src/generation/session.js +47 -0
- package/src/generation/shot-spec.js +40 -0
- package/src/generation/use-generation.js +55 -0
- package/src/hierarchy-model.js +104 -30
- package/src/hierarchy-panel.jsx +306 -26
- package/src/live-control.js +122 -0
- package/src/locale-toggle.jsx +18 -0
- package/src/locale.js +34 -0
- package/src/main.jsx +5 -0
- package/src/matte-editor.js +543 -0
- package/src/matte.js +503 -0
- package/src/multimodel-ingest.js +344 -0
- package/src/object-catalog.jsx +32 -3
- package/src/object-gizmo.jsx +43 -15
- package/src/planview.jsx +208 -89
- package/src/pose-extract/browser.js +42 -0
- package/src/pose-extract/detector.js +75 -0
- package/src/pose-extract/fit.js +194 -0
- package/src/pose-extract/index.js +56 -0
- package/src/pose-extract/keyframes.js +74 -0
- package/src/pose-extract/landmarks.js +162 -0
- package/src/pose-extract/take.js +87 -0
- package/src/pose-extract/video-frames.js +91 -0
- package/src/pose-thumbs.js +152 -0
- package/src/poses.js +114 -0
- package/src/posestudio.jsx +434 -22
- package/src/project-browser.jsx +135 -0
- package/src/project.js +289 -0
- package/src/props.jsx +69 -3
- package/src/pwa.js +42 -0
- package/src/result-modal.jsx +124 -0
- package/src/room.jsx +14 -35
- package/src/scene-asset-cache.js +125 -0
- package/src/scene-assets.js +288 -0
- package/src/scene-objects.js +245 -7
- package/src/scenes.js +404 -0
- package/src/shot-authoring.js +246 -77
- package/src/shot.js +8 -4
- package/src/styles.css +2151 -183
- package/src/ui.jsx +54 -5
- package/tools/ardy/BRIDGE.md +73 -6
- package/tools/ardy/README.md +64 -5
- package/tools/ardy/bridge.mjs +146 -390
- package/tools/ardy/bvh-cskel27.mjs +1209 -0
- package/tools/ardy/cclay_constrained_generate.py +561 -0
- package/tools/ardy/cclay_motion_edit.py +74 -9
- package/tools/ardy/cclay_sequence_generate.py +117 -7
- package/tools/ardy/cclay_worker.py +304 -0
- package/tools/ardy/extract.mjs +367 -0
- package/tools/ardy/footage.mjs +462 -0
- package/tools/ardy/merge-text-encoder.py +160 -0
- package/tools/ardy/npz.mjs +74 -9
- package/tools/ardy/run-local.mjs +233 -0
- package/tools/ardy/run-on-box.sh +25 -0
- package/tools/ardy/run-sequence-on-box.sh +15 -0
- package/tools/ardy/runners/index.mjs +23 -0
- package/tools/ardy/runners/local.mjs +452 -0
- package/tools/ardy/runners/proc.mjs +125 -0
- package/tools/ardy/runners/remote.mjs +252 -0
- package/tools/ardy/setup-local.mjs +189 -0
- package/tools/ardy/setup-text-encoder-on-box.sh +93 -0
- package/tools/ardy/setup-text-encoder.py +296 -0
- package/tools/generation/README.md +29 -0
- package/tools/generation/bridge.mjs +149 -0
- package/tools/generation/job-store.mjs +51 -0
- package/tools/generation/providers/kling.mjs +76 -0
- package/tools/generation/providers/runway.mjs +101 -0
- package/tools/generation/providers/seedance.mjs +87 -0
- package/tools/generation/providers/shared.mjs +49 -0
- package/tools/generation/providers/veo.mjs +84 -0
- package/tools/metrics/archive-traffic.mjs +138 -0
- package/dist/assets/index-BW_S1YRy.js +0 -4395
- package/dist/assets/index-nRmmJgap.css +0 -1
- package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -0
- package/tools/ardy/out/gen-1786443326924-c6019e-generated.npz +0 -0
- package/tools/ardy/out/gen-1786443609325-0053db-generated.npz +0 -0
- package/tools/ardy/out/gen-1786443835628-749ed1-generated.npz +0 -0
- package/tools/ardy/out/gen-1786462605247-03cb19-generated.npz +0 -0
- package/tools/qa-browser.mjs +0 -84
- package/tools/qa-crop3.mjs +0 -22
- package/tools/qa-playview.mjs +0 -49
- package/tools/qa-screenshot.mjs +0 -24
- package/tools/qa-visual.mjs +0 -51
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# CozyClay live-control protocol v1
|
|
2
|
+
|
|
3
|
+
One WebSocket, JSON text frames. The **MCP server hosts** the socket
|
|
4
|
+
(`ws://127.0.0.1:5184/live`); the **editor is the client** and reconnects
|
|
5
|
+
every 3 s while the page is open. Either side may be absent: the editor works
|
|
6
|
+
exactly as before when nothing is listening, and the MCP server falls back to
|
|
7
|
+
its in-memory scene when no editor is connected.
|
|
8
|
+
|
|
9
|
+
## Frames
|
|
10
|
+
|
|
11
|
+
editor -> server, once after connect:
|
|
12
|
+
|
|
13
|
+
{ "type": "hello", "role": "editor", "version": 1 }
|
|
14
|
+
|
|
15
|
+
server -> editor, one per command:
|
|
16
|
+
|
|
17
|
+
{ "type": "cmd", "id": "<opaque string>", "name": "<command>", "args": { ... } }
|
|
18
|
+
|
|
19
|
+
editor -> server, one per command, echoing `id`:
|
|
20
|
+
|
|
21
|
+
{ "type": "result", "id": "<same id>", "ok": true, "value": { ... } }
|
|
22
|
+
{ "type": "result", "id": "<same id>", "ok": false, "error": "<human message>" }
|
|
23
|
+
|
|
24
|
+
Unknown `name` MUST answer `ok:false`, never silence. The server times a
|
|
25
|
+
command out after 5 s and treats it as failed.
|
|
26
|
+
|
|
27
|
+
## Commands (v1)
|
|
28
|
+
|
|
29
|
+
All coordinates are metres, rotations are degrees of yaw, ids are the ids the
|
|
30
|
+
scene document already uses.
|
|
31
|
+
|
|
32
|
+
| name | args | value | notes |
|
|
33
|
+
| --- | --- | --- | --- |
|
|
34
|
+
| `ping` | `{}` | `{ "pong": true }` | liveness |
|
|
35
|
+
| `describe` | `{}` | `{ sceneName, camera: { x, y, z, focalMm }, characters: [{ id, subject, x, z, rot, hidden }], objects: [{ id, name, x, y, z, rot, scaleX, scaleY, scaleZ, footprint, height }] }` | read the live scene; object scale and footprint travel too, so sizes are reported from what was actually built |
|
|
36
|
+
| `set_camera` | `{ x?, y?, z?, focalMm? }` | `{ camera }` | omitted fields keep their value; the **viewport must visibly move** |
|
|
37
|
+
| `add_character` | `{ subject, x?, z?, rot? }` | `{ id }` | |
|
|
38
|
+
| `update_character` | `{ ref, x?, z?, rot?, subject?, hidden? }` | `{ id }` | `ref` = id, letter (`"A"`) or 1-based slot |
|
|
39
|
+
| `remove_character` | `{ ref }` | `{ id }` | must refuse to empty the cast |
|
|
40
|
+
| `place_object` | `{ kind, x?, z?, y?, rot? }` | `{ id }` | `kind` from OBJECT_LIBRARY |
|
|
41
|
+
| `update_object` | `{ id, x?, y?, z?, rot?, rotX?, rotZ?, scale?, scaleX?, scaleY?, scaleZ?, color? }` | `{ id }` | `scale` sets all three axes; per-axis values override it |
|
|
42
|
+
| `remove_object` | `{ id }` | `{ id }` | |
|
|
43
|
+
| `load_scenes` | `{ document }` | `{ sceneName }` | replace the whole scene document (same shape `serializeSceneDocument` emits); the big hammer that guarantees parity |
|
|
44
|
+
|
|
45
|
+
## Hard rules for the editor side
|
|
46
|
+
|
|
47
|
+
- Every mutation MUST go through the same React state paths the UI itself
|
|
48
|
+
uses (the setters/reducers the gizmo, inspector and panels call). Mutating
|
|
49
|
+
three.js objects directly is forbidden: anything outside React state is
|
|
50
|
+
overwritten on the next render.
|
|
51
|
+
- Every mutation MUST land in undo history exactly like the equivalent UI
|
|
52
|
+
action would, or be explicitly documented as not undoable.
|
|
53
|
+
- The socket client MUST be a no-op in production builds unless explicitly
|
|
54
|
+
enabled; in dev it may always try. A failed connection must never surface
|
|
55
|
+
an error to the user - silence and retry.
|
|
56
|
+
|
|
57
|
+
## Hard rules for the server side
|
|
58
|
+
|
|
59
|
+
- When an editor is connected, live-capable tools forward to it and answer
|
|
60
|
+
from its `describe`; when none is connected they fall back to the in-memory
|
|
61
|
+
scene exactly as today. The tool surface does not change.
|
|
62
|
+
- One editor at a time: a second `hello` replaces the first (last write wins),
|
|
63
|
+
and the displaced socket is closed.
|
package/mcp/README.md
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# CozyClay MCP server
|
|
2
|
+
|
|
3
|
+
Lets an AI assistant (Claude Desktop, Cursor, any MCP client) block a scene, place the camera,
|
|
4
|
+
generate character motion and read the shot back as film vocabulary — then turn it into an AI
|
|
5
|
+
image/video prompt.
|
|
6
|
+
|
|
7
|
+
Works two ways, with the same tools:
|
|
8
|
+
|
|
9
|
+
- **Editor open** — tool calls drive the visible viewport live: camera, cast, set, motion,
|
|
10
|
+
prompt blocks on the timeline.
|
|
11
|
+
- **No editor** — everything runs headless: no browser, no GPU, no build step.
|
|
12
|
+
|
|
13
|
+
## Run it locally
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
cd mcp
|
|
17
|
+
npm install
|
|
18
|
+
npm start # speaks MCP over stdio and hosts ws://127.0.0.1:5184/live
|
|
19
|
+
npm run verify # drives the no-editor fallback as a client and checks results
|
|
20
|
+
npm run verify:live # drives a fake editor over the live WebSocket protocol
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Then point a client at it. For Claude Desktop, in `claude_desktop_config.json`:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"mcpServers": {
|
|
28
|
+
"cozyclay": {
|
|
29
|
+
"command": "node",
|
|
30
|
+
"args": ["/absolute/path/to/CozyClay/mcp/server.mjs"]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Restart the client; 20 tools appear.
|
|
37
|
+
|
|
38
|
+
Prefer a long-lived endpoint? `node server.mjs --http 5183` serves Streamable HTTP at
|
|
39
|
+
`http://127.0.0.1:5183/mcp` (one isolated session per client), with a plain status page at `/`.
|
|
40
|
+
|
|
41
|
+
## What it does
|
|
42
|
+
|
|
43
|
+
Describe the shot you want. The server does the trigonometry and answers the way a crew would.
|
|
44
|
+
|
|
45
|
+
> "Put a detective and a courier in an alley, then give me a low wide profile shot of the courier."
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
WIDE SHOT · RIGHT PROFILE · KNEE LEVEL · 24MM
|
|
49
|
+
|
|
50
|
+
size wide shot — the subject fills 40% of frame height
|
|
51
|
+
view a right-side profile view
|
|
52
|
+
level a low knee-level angle looking up at the subject
|
|
53
|
+
distance 4.39m
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`render_prompt` turns that same geometry into a prompt that carries the real framing, so the
|
|
57
|
+
generated frame matches the blocking instead of drifting off into a generic shot.
|
|
58
|
+
|
|
59
|
+
## Tools
|
|
60
|
+
|
|
61
|
+
| tool | what it does |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| `describe_scene` | the whole state: camera, framing, cast, set |
|
|
64
|
+
| `live_status` | whether an editor tab is connected for live control |
|
|
65
|
+
| `describe_shot` | current camera geometry as film vocabulary |
|
|
66
|
+
| `set_camera` | move the lens / change focal length directly |
|
|
67
|
+
| `frame_shot` | frame by intent — size, view, level, side |
|
|
68
|
+
| `add_character` / `place_character` / `remove_character` | the cast |
|
|
69
|
+
| `focus_character` | choose who the camera frames |
|
|
70
|
+
| `place_object` / `update_object` / `remove_object` | the set |
|
|
71
|
+
| `render_prompt` | the shot as an AI image or video prompt |
|
|
72
|
+
| `generate_motion` | multi-phase character motion through the ARDY bridge — phases land as prompt blocks |
|
|
73
|
+
| `mark_camera_move` / `describe_camera_move` | name a move between two camera positions |
|
|
74
|
+
| `add_scene` / `switch_scene` | multiple scenes per project |
|
|
75
|
+
| `open_project` / `save_project` | read and write `.cclayproject` files |
|
|
76
|
+
|
|
77
|
+
Coordinates are metres (`x` right, `z` toward the default camera, `y` height above the floor).
|
|
78
|
+
Rotations are degrees of yaw. Characters are addressed by letter (`"A"`), slot (`"2"`) or id
|
|
79
|
+
(`"char-a"`).
|
|
80
|
+
|
|
81
|
+
## Live editor control
|
|
82
|
+
|
|
83
|
+
The server hosts `ws://127.0.0.1:5184/live` by default (`COZYCLAY_LIVE_PORT` or
|
|
84
|
+
`--live-port <port>` to change it). A CozyClay editor tab served by `npm run dev` connects on
|
|
85
|
+
its own; `live_status` tells you whether one is attached. With an editor connected, mutations
|
|
86
|
+
forward to it and reads report its real state — the screen you are looking at is the source of
|
|
87
|
+
truth. Without one, every tool keeps its in-memory behaviour. In `--http` mode each session
|
|
88
|
+
child attempts to bind the live port, so one session owns the editor and the others
|
|
89
|
+
intentionally run memory-only.
|
|
90
|
+
|
|
91
|
+
The wire protocol — one WebSocket, ten commands, editor-side rules — is specified in
|
|
92
|
+
[`LIVE-PROTOCOL.md`](LIVE-PROTOCOL.md).
|
|
93
|
+
|
|
94
|
+
## Motion generation
|
|
95
|
+
|
|
96
|
+
`generate_motion` takes plain-language beats and a length:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
phases: ["seated on a chair, slowly stands up",
|
|
100
|
+
"breaks into a sprint",
|
|
101
|
+
"trips and falls hard to the ground"]
|
|
102
|
+
seconds: 10
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
It tiles them into contiguous ARDY segments, streams the generation through the local bridge
|
|
106
|
+
(`127.0.0.1:5181`, started by `npm run dev`), and — when an editor is connected — loads the
|
|
107
|
+
result onto the active character with one prompt block per phase on the timeline. Pass a
|
|
108
|
+
previous `motion_url` to reload a clip without generating again.
|
|
109
|
+
|
|
110
|
+
## Round-trips with the studio
|
|
111
|
+
|
|
112
|
+
`save_project` writes a real `.cclayproject` file, so a scene blocked here opens in the studio to
|
|
113
|
+
be posed, timed and generated — and a scene built in the studio opens here with `open_project`.
|
|
114
|
+
|
|
115
|
+
## Design
|
|
116
|
+
|
|
117
|
+
This server owns no geometry, no film vocabulary and no prompt text. Every answer is computed by
|
|
118
|
+
the same modules the studio renders with, imported straight from the working tree:
|
|
119
|
+
|
|
120
|
+
| module | responsibility |
|
|
121
|
+
| --- | --- |
|
|
122
|
+
| `../src/shot.js` | geometry → film vocabulary → prompt |
|
|
123
|
+
| `../src/scenes.js` | the scene document and its stage envelope |
|
|
124
|
+
| `../src/scene-objects.js` | the set: create / update / remove, clamped and snapped |
|
|
125
|
+
| `../src/camera-move.js` | two framings → a named camera move |
|
|
126
|
+
| `../src/project.js` | the `.cclayproject` envelope |
|
|
127
|
+
|
|
128
|
+
That is the whole design. Because the imports are relative, the server always speaks the working
|
|
129
|
+
tree's vocabulary: retune a band in `shot.js` and this server reports the new answer on its next
|
|
130
|
+
start, with nothing to publish or reinstall. The studio and the MCP server cannot disagree about
|
|
131
|
+
what a 35mm medium shot is, because there is only one implementation of it.
|
|
132
|
+
|
|
133
|
+
`frame_shot` is the one place that inverts `shot.js` rather than calling it — it solves for the
|
|
134
|
+
camera position that produces a requested size. `npm run verify` checks all 420 combinations the
|
|
135
|
+
schema accepts, so a retune in `shot.js` fails loudly here instead of quietly mis-framing.
|
|
136
|
+
|
|
137
|
+
### Framing conflicts
|
|
138
|
+
|
|
139
|
+
Shot size is treated as the stronger request. An extreme close-up from an overhead lens is
|
|
140
|
+
geometrically impossible on a wide lens — the camera would sit inside the subject — so the server
|
|
141
|
+
lengthens the lens until the requested angle fits and says so, the way a crew swaps glass rather
|
|
142
|
+
than abandoning the close-up.
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writing prompts the way ARDY was trained to read them.
|
|
3
|
+
*
|
|
4
|
+
* ARDY's own examples are the specification (nv-tlabs/ardy, scripts/generate.py):
|
|
5
|
+
*
|
|
6
|
+
* python scripts/generate.py "A person walks in a circle."
|
|
7
|
+
* python scripts/generate.py "A person jumps." --model core --duration 8.0
|
|
8
|
+
* python scripts/generate.py "A person waves." --model g1 --num_samples 4
|
|
9
|
+
*
|
|
10
|
+
* Three properties do the work: an explicit subject ("A person"), exactly one
|
|
11
|
+
* physical action, and a full stop. The demo's preset Prompt List is built the
|
|
12
|
+
* same way, so this is the distribution the text encoder actually saw.
|
|
13
|
+
*
|
|
14
|
+
* The reason one action per prompt matters is architectural, not stylistic:
|
|
15
|
+
* the interactive demo builds its denoiser with `num_text_tokens=1`
|
|
16
|
+
* (scripts/interactive_demo/loading.py), so the entire prompt collapses into a
|
|
17
|
+
* SINGLE conditioning embedding. Two actions in one sentence do not run in
|
|
18
|
+
* sequence — they average into one confused pose. A sequence of actions is
|
|
19
|
+
* expressed as a sequence of prompts (CozyClay's Prompt Blocks), never as a
|
|
20
|
+
* compound sentence.
|
|
21
|
+
*
|
|
22
|
+
* What that rules out, and what this module rewrites:
|
|
23
|
+
* - no subject "runs forward" -> "A person runs forward."
|
|
24
|
+
* - compound actions "stands, then runs" -> split across phases
|
|
25
|
+
* - interior states "in astonishment" -> dropped; ARDY animates
|
|
26
|
+
* bodies, not feelings
|
|
27
|
+
* - camera/scene language "the rocket looms" -> dropped; the prompt
|
|
28
|
+
* describes the BODY only
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Quality policy, mirrored from the studio (App.jsx PROMPT_BLOCK_MAX_FRAMES):
|
|
33
|
+
* one block never spans more than 4 s. ARDY's trained window is 10 s, but a
|
|
34
|
+
* long single block drifts — chained 4 s blocks keep each call inside the
|
|
35
|
+
* model's sweet spot. The studio refuses to generate a longer block, so a tool
|
|
36
|
+
* that produced one would be authoring something the UI would then reject.
|
|
37
|
+
*/
|
|
38
|
+
export const BLOCK_MAX_SECONDS = 4;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Split a beat that runs longer than the cap into consecutive blocks that do
|
|
42
|
+
* not. The wording is kept for each piece: continuing the same action is
|
|
43
|
+
* exactly what the chained-block design is for. Returns whole seconds-ish
|
|
44
|
+
* spans that sum to the original duration.
|
|
45
|
+
*/
|
|
46
|
+
export function splitLongBeat(seconds, max = BLOCK_MAX_SECONDS) {
|
|
47
|
+
if (!(seconds > max)) return [seconds];
|
|
48
|
+
const count = Math.ceil(seconds / max);
|
|
49
|
+
const even = seconds / count;
|
|
50
|
+
return Array.from({ length: count }, () => even);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Shown in the tool description so a caller writes good phases first time. */
|
|
54
|
+
export const PROMPT_GUIDE = [
|
|
55
|
+
"ARDY prompt rules (from nv-tlabs/ardy):",
|
|
56
|
+
' - One action per phase, phrased like ARDY\'s own examples: "A person walks in a circle."',
|
|
57
|
+
" - Subject + single present-tense action + full stop. The prompt becomes ONE embedding",
|
|
58
|
+
" (num_text_tokens=1), so two actions in one phase average together instead of playing in order.",
|
|
59
|
+
" - Sequence = more phases, never a compound sentence.",
|
|
60
|
+
` - A block holds at most ${BLOCK_MAX_SECONDS} s. Longer beats are chained into consecutive blocks,`,
|
|
61
|
+
" because a single long block drifts away from its prompt.",
|
|
62
|
+
" - Describe the BODY: no emotions, no camera, no scenery, no props the model cannot infer.",
|
|
63
|
+
" - WRITE THE AMPLITUDE. A diffusion model regresses toward the mean, so a neutral verb",
|
|
64
|
+
" generates a smaller motion than the words suggest. Pick the strong verb and state the",
|
|
65
|
+
" magnitude: 'strides forward quickly' over 'walks forward', 'stops abruptly' over 'slows",
|
|
66
|
+
" to a stop', 'leans far back and looks straight up' over 'looks up'. Simultaneous detail",
|
|
67
|
+
" describing ONE pose is fine; sequential actions still need separate phases.",
|
|
68
|
+
' - Good: ["A person strides forward quickly.", "A person stops abruptly.", "A person leans far back and looks straight up."]',
|
|
69
|
+
' - Bad: ["walks forward slowly, then stops abruptly", "staggers back in astonishment"]',
|
|
70
|
+
].join("\n");
|
|
71
|
+
|
|
72
|
+
/** Interior states and cinematic language ARDY has no body channel for. */
|
|
73
|
+
const UNRENDERABLE = [
|
|
74
|
+
/\b(in|with)\s+(astonishment|awe|wonder|surprise|fear|joy|excitement|disbelief)\b/gi,
|
|
75
|
+
/\b(astonished|amazed|awestruck|terrified|delighted|confused|nervous|curious)ly?\b/gi,
|
|
76
|
+
/\b(as if|like)\s+[^,.]+/gi,
|
|
77
|
+
/\b(at|toward|towards)\s+(something|the)\s+(enormous|huge|massive|towering|giant)\b/gi,
|
|
78
|
+
/\b(the\s+)?(camera|shot|frame|rocket|spaceship|building)\b[^,.]*/gi,
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
/** Connectives that mean "a second action is hiding in this sentence". */
|
|
82
|
+
const SPLIT_ON = /\s*,?\s*\b(?:and then|then|after that|before|while|as)\b\s*/i;
|
|
83
|
+
|
|
84
|
+
const SUBJECT = /^(a|the)\s+(person|man|woman|character|figure|human)\b/i;
|
|
85
|
+
|
|
86
|
+
const tidy = (s) =>
|
|
87
|
+
s
|
|
88
|
+
.replace(/\s+/g, " ")
|
|
89
|
+
.replace(/\s+([,.])/g, "$1")
|
|
90
|
+
.replace(/[,;]+$/, "")
|
|
91
|
+
.trim();
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Rewrite one caller phrase into ARDY's sentence shape.
|
|
95
|
+
* Returns `{ text, notes }` — notes explain every edit, so a caller can see
|
|
96
|
+
* why their wording changed rather than silently getting something else.
|
|
97
|
+
*/
|
|
98
|
+
export function normalizePhase(raw) {
|
|
99
|
+
const notes = [];
|
|
100
|
+
let s = tidy(String(raw ?? ""));
|
|
101
|
+
if (!s) return { text: "", notes: ["empty phrase"] };
|
|
102
|
+
|
|
103
|
+
for (const pattern of UNRENDERABLE) {
|
|
104
|
+
if (pattern.test(s)) {
|
|
105
|
+
s = tidy(s.replace(pattern, ""));
|
|
106
|
+
notes.push("dropped language ARDY cannot animate (emotion, camera or scenery)");
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Keep the first action; a trailing clause belongs in its own phase.
|
|
112
|
+
const parts = s.split(SPLIT_ON).map(tidy).filter(Boolean);
|
|
113
|
+
if (parts.length > 1) {
|
|
114
|
+
s = parts[0];
|
|
115
|
+
notes.push(`kept the first action; "${parts.slice(1).join(" / ")}" belongs in its own phase`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// A comma usually joins two actions too ("walks forward, stops abruptly").
|
|
119
|
+
const commaParts = s.split(/\s*,\s*/).map(tidy).filter(Boolean);
|
|
120
|
+
if (commaParts.length > 1) {
|
|
121
|
+
s = commaParts[0];
|
|
122
|
+
notes.push("kept one action per prompt");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
s = s.replace(/^(and|then|so)\s+/i, "");
|
|
126
|
+
if (!SUBJECT.test(s)) {
|
|
127
|
+
s = s.replace(/^[A-Z]/, (c) => c.toLowerCase());
|
|
128
|
+
s = `A person ${s}`;
|
|
129
|
+
notes.push('added the subject ARDY expects ("A person ...")');
|
|
130
|
+
} else {
|
|
131
|
+
s = s.replace(/^./, (c) => c.toUpperCase());
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!/[.!?]$/.test(s)) {
|
|
135
|
+
s = `${s}.`;
|
|
136
|
+
notes.push("closed the sentence");
|
|
137
|
+
}
|
|
138
|
+
return { text: tidy(s), notes };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Normalise a whole beat list. A phrase that carried a trailing action gets it
|
|
143
|
+
* back as its own phase, so "stands up then runs" becomes two real beats
|
|
144
|
+
* instead of one averaged embedding — capped so a caller cannot blow past the
|
|
145
|
+
* schema's phase limit.
|
|
146
|
+
*/
|
|
147
|
+
export function normalizePhases(phases, max = 8) {
|
|
148
|
+
const expanded = [];
|
|
149
|
+
const sources = [];
|
|
150
|
+
for (const [index, phase] of phases.entries()) {
|
|
151
|
+
const pieces = String(phase ?? "")
|
|
152
|
+
.split(SPLIT_ON)
|
|
153
|
+
.map(tidy)
|
|
154
|
+
.filter(Boolean);
|
|
155
|
+
const parts = pieces.length ? pieces : [phase];
|
|
156
|
+
expanded.push(...parts);
|
|
157
|
+
// Which input each output came from, so a caller that attached a duration
|
|
158
|
+
// to a beat can split that duration across the pieces it became.
|
|
159
|
+
sources.push(...parts.map(() => index));
|
|
160
|
+
}
|
|
161
|
+
const capped = expanded.slice(0, max);
|
|
162
|
+
const results = capped.map((piece) => normalizePhase(piece));
|
|
163
|
+
return {
|
|
164
|
+
texts: results.map((r) => r.text),
|
|
165
|
+
notes: results.map((r) => r.notes),
|
|
166
|
+
sources: sources.slice(0, max),
|
|
167
|
+
expanded: expanded.length > phases.length,
|
|
168
|
+
dropped: expanded.length - capped.length,
|
|
169
|
+
};
|
|
170
|
+
}
|
package/mcp/live-hub.mjs
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
4
|
+
|
|
5
|
+
const CLOSE_REPLACED = 4000;
|
|
6
|
+
const COMMAND_TIMEOUT_MS = 5_000;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Transport-only implementation of LIVE-PROTOCOL.md. Scene semantics remain
|
|
10
|
+
* with the editor and the server's existing CozyClay imports.
|
|
11
|
+
*/
|
|
12
|
+
export class LiveHub {
|
|
13
|
+
constructor(server = null) {
|
|
14
|
+
this.server = server;
|
|
15
|
+
this.editor = null;
|
|
16
|
+
this.pending = new Map();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
get connected() {
|
|
20
|
+
return this.editor?.readyState === WebSocket.OPEN;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async command(name, args) {
|
|
24
|
+
const socket = this.editor;
|
|
25
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) throw new Error("No live editor is connected.");
|
|
26
|
+
|
|
27
|
+
const id = randomUUID();
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
const timer = setTimeout(() => {
|
|
30
|
+
this.pending.delete(id);
|
|
31
|
+
reject(new Error(`Live editor timed out running ${name}.`));
|
|
32
|
+
}, COMMAND_TIMEOUT_MS);
|
|
33
|
+
this.pending.set(id, { socket, resolve, reject, timer });
|
|
34
|
+
try {
|
|
35
|
+
socket.send(JSON.stringify({ type: "cmd", id, name, args }));
|
|
36
|
+
} catch (error) {
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
this.pending.delete(id);
|
|
39
|
+
reject(new Error(`Could not send ${name} to the live editor: ${error.message}`));
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
accept(socket) {
|
|
45
|
+
let greeted = false;
|
|
46
|
+
socket.on("message", (message, isBinary) => {
|
|
47
|
+
if (isBinary) return;
|
|
48
|
+
let frame;
|
|
49
|
+
try {
|
|
50
|
+
frame = JSON.parse(message.toString());
|
|
51
|
+
} catch {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (!greeted) {
|
|
55
|
+
if (frame?.type !== "hello" || frame.role !== "editor" || frame.version !== 1) {
|
|
56
|
+
socket.close(1002, "Expected editor hello version 1");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
greeted = true;
|
|
60
|
+
const displaced = this.editor;
|
|
61
|
+
this.editor = socket;
|
|
62
|
+
if (displaced && displaced !== socket) displaced.close(CLOSE_REPLACED, "Replaced by a newer editor");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (frame?.type !== "result" || typeof frame.id !== "string") return;
|
|
66
|
+
const pending = this.pending.get(frame.id);
|
|
67
|
+
if (!pending || pending.socket !== socket) return;
|
|
68
|
+
clearTimeout(pending.timer);
|
|
69
|
+
this.pending.delete(frame.id);
|
|
70
|
+
if (frame.ok === true) pending.resolve(frame.value);
|
|
71
|
+
else pending.reject(new Error(typeof frame.error === "string" ? frame.error : "Live editor rejected the command."));
|
|
72
|
+
});
|
|
73
|
+
socket.on("close", () => this.disconnect(socket));
|
|
74
|
+
socket.on("error", () => this.disconnect(socket));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
disconnect(socket) {
|
|
78
|
+
if (this.editor === socket) this.editor = null;
|
|
79
|
+
for (const [id, pending] of this.pending) {
|
|
80
|
+
if (pending.socket !== socket) continue;
|
|
81
|
+
clearTimeout(pending.timer);
|
|
82
|
+
this.pending.delete(id);
|
|
83
|
+
pending.reject(new Error("Live editor disconnected while the command was running."));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Bind only to loopback. A taken port is an intentional memory-only mode. */
|
|
89
|
+
export async function startLiveHub(port) {
|
|
90
|
+
let server;
|
|
91
|
+
try {
|
|
92
|
+
server = new WebSocketServer({ host: "127.0.0.1", port, path: "/live" });
|
|
93
|
+
await new Promise((resolve, reject) => {
|
|
94
|
+
server.once("listening", resolve);
|
|
95
|
+
server.once("error", reject);
|
|
96
|
+
});
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (server) server.close();
|
|
99
|
+
if (error?.code === "EADDRINUSE") return null;
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
const hub = new LiveHub(server);
|
|
103
|
+
server.on("connection", (socket) => hub.accept(socket));
|
|
104
|
+
return hub;
|
|
105
|
+
}
|
package/mcp/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cozyclay/mcp",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "MCP server for CozyClay — block a scene, place the camera, and get film vocabulary and AI prompts back.",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"cozyclay-mcp": "server.mjs"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node server.mjs",
|
|
12
|
+
"verify": "node verify.mjs",
|
|
13
|
+
"verify:prompts": "node verify-prompts.mjs",
|
|
14
|
+
"verify:live": "node verify-live.mjs"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
21
|
+
"ws": "^8.19.0",
|
|
22
|
+
"zod": "^3.25.0"
|
|
23
|
+
}
|
|
24
|
+
}
|