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.
Files changed (138) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +91 -25
  3. package/THIRD_PARTY_NOTICES.md +68 -1
  4. package/bin/cozyclay.mjs +71 -8
  5. package/dist/CNAME +1 -0
  6. package/dist/ai-camera-control/index.html +406 -0
  7. package/dist/app/index.html +57 -0
  8. package/dist/assets/app-B3U5aut1.js +4811 -0
  9. package/dist/assets/app-BrRF0wso.css +1 -0
  10. package/dist/assets/vision_bundle-jFkh-fIS.js +41 -0
  11. package/dist/favicon.ico +0 -0
  12. package/dist/fonts/InstrumentSerif-OFL.txt +93 -0
  13. package/dist/fonts/Inter-OFL.txt +92 -0
  14. package/dist/fonts/README.md +15 -0
  15. package/dist/icons/apple-touch-icon.png +0 -0
  16. package/dist/icons/icon-192.png +0 -0
  17. package/dist/icons/icon-32.png +0 -0
  18. package/dist/icons/icon-512.png +0 -0
  19. package/dist/icons/icon-maskable-512.png +0 -0
  20. package/dist/index.html +450 -5
  21. package/dist/manifest.webmanifest +42 -0
  22. package/dist/media/cozyclay-demo-poster.jpg +0 -0
  23. package/dist/media/cozyclay-logo.png +0 -0
  24. package/dist/robots.txt +4 -0
  25. package/dist/sitemap.xml +15 -0
  26. package/dist/sw.js +127 -0
  27. package/mcp/LIVE-PROTOCOL.md +63 -0
  28. package/mcp/README.md +142 -0
  29. package/mcp/ardy-prompts.mjs +170 -0
  30. package/mcp/live-hub.mjs +105 -0
  31. package/mcp/package.json +24 -0
  32. package/mcp/server.mjs +1394 -0
  33. package/package.json +122 -78
  34. package/src/App.jsx +4235 -1047
  35. package/src/ardy/client.js +5 -4
  36. package/src/ardy/cskel27.js +7 -2
  37. package/src/ardy/ik.js +25 -15
  38. package/src/ardy/mp4-duration.js +102 -0
  39. package/src/ardy/npz.js +64 -3
  40. package/src/ardy/playback.js +31 -1
  41. package/src/ardy/prompt-clips.js +7 -2
  42. package/src/ardy/retime.js +211 -0
  43. package/src/ardy/timeline-coordinates.js +24 -0
  44. package/src/ardy/timeline.jsx +650 -70
  45. package/src/ardy/to-cskel27.js +34 -12
  46. package/src/ardy/trim.js +33 -0
  47. package/src/ardy/waypoints.js +14 -9
  48. package/src/asset-pane.jsx +36 -0
  49. package/src/camera-block.js +60 -0
  50. package/src/camera-follow.js +49 -10
  51. package/src/camera-rail-schedule.js +176 -0
  52. package/src/controls.jsx +16 -2
  53. package/src/cuts.js +184 -0
  54. package/src/dualview.jsx +14 -8
  55. package/src/generation/client.js +56 -0
  56. package/src/generation/session.js +47 -0
  57. package/src/generation/shot-spec.js +40 -0
  58. package/src/generation/use-generation.js +55 -0
  59. package/src/hierarchy-model.js +104 -30
  60. package/src/hierarchy-panel.jsx +306 -26
  61. package/src/live-control.js +122 -0
  62. package/src/locale-toggle.jsx +18 -0
  63. package/src/locale.js +34 -0
  64. package/src/main.jsx +5 -0
  65. package/src/matte-editor.js +543 -0
  66. package/src/matte.js +503 -0
  67. package/src/multimodel-ingest.js +344 -0
  68. package/src/object-catalog.jsx +32 -3
  69. package/src/object-gizmo.jsx +43 -15
  70. package/src/planview.jsx +208 -89
  71. package/src/pose-extract/browser.js +42 -0
  72. package/src/pose-extract/detector.js +75 -0
  73. package/src/pose-extract/fit.js +194 -0
  74. package/src/pose-extract/index.js +56 -0
  75. package/src/pose-extract/keyframes.js +74 -0
  76. package/src/pose-extract/landmarks.js +162 -0
  77. package/src/pose-extract/take.js +87 -0
  78. package/src/pose-extract/video-frames.js +91 -0
  79. package/src/pose-thumbs.js +152 -0
  80. package/src/poses.js +114 -0
  81. package/src/posestudio.jsx +434 -22
  82. package/src/project-browser.jsx +135 -0
  83. package/src/project.js +289 -0
  84. package/src/props.jsx +69 -3
  85. package/src/pwa.js +42 -0
  86. package/src/result-modal.jsx +124 -0
  87. package/src/room.jsx +14 -35
  88. package/src/scene-asset-cache.js +125 -0
  89. package/src/scene-assets.js +288 -0
  90. package/src/scene-objects.js +245 -7
  91. package/src/scenes.js +404 -0
  92. package/src/shot-authoring.js +246 -77
  93. package/src/shot.js +8 -4
  94. package/src/styles.css +2151 -183
  95. package/src/ui.jsx +54 -5
  96. package/tools/ardy/BRIDGE.md +73 -6
  97. package/tools/ardy/README.md +64 -5
  98. package/tools/ardy/bridge.mjs +146 -390
  99. package/tools/ardy/bvh-cskel27.mjs +1209 -0
  100. package/tools/ardy/cclay_constrained_generate.py +561 -0
  101. package/tools/ardy/cclay_motion_edit.py +74 -9
  102. package/tools/ardy/cclay_sequence_generate.py +117 -7
  103. package/tools/ardy/cclay_worker.py +304 -0
  104. package/tools/ardy/extract.mjs +367 -0
  105. package/tools/ardy/footage.mjs +462 -0
  106. package/tools/ardy/merge-text-encoder.py +160 -0
  107. package/tools/ardy/npz.mjs +74 -9
  108. package/tools/ardy/run-local.mjs +233 -0
  109. package/tools/ardy/run-on-box.sh +25 -0
  110. package/tools/ardy/run-sequence-on-box.sh +15 -0
  111. package/tools/ardy/runners/index.mjs +23 -0
  112. package/tools/ardy/runners/local.mjs +452 -0
  113. package/tools/ardy/runners/proc.mjs +125 -0
  114. package/tools/ardy/runners/remote.mjs +252 -0
  115. package/tools/ardy/setup-local.mjs +189 -0
  116. package/tools/ardy/setup-text-encoder-on-box.sh +93 -0
  117. package/tools/ardy/setup-text-encoder.py +296 -0
  118. package/tools/generation/README.md +29 -0
  119. package/tools/generation/bridge.mjs +149 -0
  120. package/tools/generation/job-store.mjs +51 -0
  121. package/tools/generation/providers/kling.mjs +76 -0
  122. package/tools/generation/providers/runway.mjs +101 -0
  123. package/tools/generation/providers/seedance.mjs +87 -0
  124. package/tools/generation/providers/shared.mjs +49 -0
  125. package/tools/generation/providers/veo.mjs +84 -0
  126. package/tools/metrics/archive-traffic.mjs +138 -0
  127. package/dist/assets/index-BW_S1YRy.js +0 -4395
  128. package/dist/assets/index-nRmmJgap.css +0 -1
  129. package/tools/ardy/__pycache__/cclay_sequence_generate.cpython-313.pyc +0 -0
  130. package/tools/ardy/out/gen-1786443326924-c6019e-generated.npz +0 -0
  131. package/tools/ardy/out/gen-1786443609325-0053db-generated.npz +0 -0
  132. package/tools/ardy/out/gen-1786443835628-749ed1-generated.npz +0 -0
  133. package/tools/ardy/out/gen-1786462605247-03cb19-generated.npz +0 -0
  134. package/tools/qa-browser.mjs +0 -84
  135. package/tools/qa-crop3.mjs +0 -22
  136. package/tools/qa-playview.mjs +0 -49
  137. package/tools/qa-screenshot.mjs +0 -24
  138. 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
+ }
@@ -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
+ }
@@ -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
+ }