cozyclay 3.0.3 → 3.1.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 (204) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/INSTALL_FOR_AGENTS.md +146 -0
  3. package/README.md +74 -192
  4. package/THIRD_PARTY_NOTICES.md +0 -21
  5. package/bin/agent/agent-routes.mjs +22 -56
  6. package/bin/agent/agent-runner.mjs +5 -3
  7. package/bin/agent/main-agent.mjs +70 -0
  8. package/bin/agent/main-prompt.mjs +17 -0
  9. package/bin/agent/session-store.mjs +2 -2
  10. package/bin/agent/studio-prompt.mjs +1 -1
  11. package/bin/agent/studio-task-routes.mjs +185 -0
  12. package/bin/agent/studio-tasks.mjs +156 -0
  13. package/bin/agent/studio-tools.mjs +3 -2
  14. package/bin/agent/studio-turn-transaction.mjs +76 -0
  15. package/bin/cozyclay.mjs +5 -7
  16. package/dist/app/index.html +7 -6
  17. package/dist/assets/TaskBoard-DdDXMgZm.js +1 -0
  18. package/dist/assets/app-B2BpfCl2.js +5088 -0
  19. package/dist/assets/app-BlwOpest.css +1 -0
  20. package/dist/assets/compile-worker-D0pKioa0.js +5 -0
  21. package/dist/assets/landing-y3EqDgSZ.js +1 -0
  22. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  23. package/dist/assets/motion3-compile-B887n77J.js +1 -0
  24. package/dist/assets/scene-asset-sync-COGOlF5T.css +1 -0
  25. package/dist/assets/scene-asset-sync-CQzEdsqu.js +13 -0
  26. package/dist/assets/task-board-BKaM7d-9.js +14 -0
  27. package/dist/assets/workflow-B8Dhzexj.js +179 -0
  28. package/dist/assets/x-bot-tpose-body-BusyR3eg.json +1 -0
  29. package/dist/assets/y-bot-tpose-body-Dy1VKChZ.json +1 -0
  30. package/dist/cozyclay-package.json +1 -1
  31. package/dist/greybox-to-video/index.html +2 -3
  32. package/dist/index.html +7 -57
  33. package/dist/llms.txt +2 -1
  34. package/dist/previs-software/index.html +2 -2
  35. package/dist/privacy/index.html +3 -7
  36. package/dist/seedance-camera-control/index.html +2 -2
  37. package/dist/sitemap.xml +5 -5
  38. package/dist/sw.js +5 -1
  39. package/dist/thumbs/blank-stage-dark.webp +0 -0
  40. package/dist/thumbs/blank-stage-light.webp +0 -0
  41. package/dist/workflow/index.html +5 -4
  42. package/mcp/mesh-file.mjs +3 -1
  43. package/mcp/server.mjs +3 -0
  44. package/package.json +3 -3
  45. package/src/App.jsx +50 -281
  46. package/src/analytics.js +3 -148
  47. package/src/app-stage.jsx +1 -1
  48. package/src/asset-pane.jsx +5 -10
  49. package/src/auto-flat.js +10 -0
  50. package/src/bus-tasks.js +11 -5
  51. package/src/command-bus.js +1 -1
  52. package/src/commands/motion3-compile.js +51 -27
  53. package/src/commands/motion3.js +3 -3
  54. package/src/commands/project.js +1 -1
  55. package/src/commands/shot.js +16 -4
  56. package/src/controls.jsx +0 -11
  57. package/src/cuts.js +11 -0
  58. package/src/domains/cast.js +3 -2
  59. package/src/domains/motion.js +3 -10
  60. package/src/domains/playback.js +32 -0
  61. package/src/domains/scenes.js +3 -22
  62. package/src/domains/shots.js +5 -5
  63. package/src/dualview.jsx +11 -15
  64. package/src/hierarchy-panel.css +18 -4
  65. package/src/hierarchy-panel.jsx +12 -11
  66. package/src/kits/held.js +3 -2
  67. package/src/light-blockers.js +2 -2
  68. package/src/main.jsx +5 -0
  69. package/src/motion/ground.js +5 -14
  70. package/src/motion/playback-frame-store.js +79 -0
  71. package/src/motion/playback-probe.js +16 -0
  72. package/src/motion/playback-subject.js +7 -0
  73. package/src/motion/timeline.css +43 -5
  74. package/src/motion/timeline.jsx +4 -3
  75. package/src/motion3/body.js +37 -5
  76. package/src/motion3/carried.js +11 -3
  77. package/src/motion3/compile-job.js +1 -1
  78. package/src/motion3/compiler/compile.js +7 -1
  79. package/src/motion3/compiler/finger-close.js +137 -0
  80. package/src/motion3/compiler/limbs.js +18 -3
  81. package/src/motion3/compiler/pose.js +6 -0
  82. package/src/motion3/compiler/validate-objects.js +17 -2
  83. package/src/motion3/compiler/validate-parts.js +2 -1
  84. package/src/motion3/compiler/vocab.js +4 -4
  85. package/src/motion3/describe.js +2 -2
  86. package/src/motion3/fix/index.js +5 -3
  87. package/src/motion3/flight.js +38 -0
  88. package/src/motion3/free-motion.js +10 -7
  89. package/src/motion3/load.js +125 -0
  90. package/src/motion3/mass.js +23 -0
  91. package/src/motion3/object-keys.js +11 -6
  92. package/src/motion3/object-pose.js +11 -3
  93. package/src/motion3/pipeline.js +3 -0
  94. package/src/motion3/qa/explain.js +3 -1
  95. package/src/motion3/qa/grasp-checks.js +123 -0
  96. package/src/motion3/qa/hints-grasp.js +71 -0
  97. package/src/motion3/qa/index.js +3 -1
  98. package/src/motion3/qa/object-checks.js +7 -2
  99. package/src/motion3/qa/patch.js +2 -1
  100. package/src/motion3/qa/spec-checks.js +10 -1
  101. package/src/motion3/schema/fields.js +3 -3
  102. package/src/motion3/schema/topics.js +9 -8
  103. package/src/motion3/schema/types.js +4 -1
  104. package/src/motion3/seat.js +84 -0
  105. package/src/motion3/settle.js +11 -6
  106. package/src/motion3/shapes.js +70 -0
  107. package/src/motion3/skills/catch.js +71 -0
  108. package/src/motion3/skills/index.js +10 -3
  109. package/src/motion3/skills/info.js +13 -0
  110. package/src/motion3/skills/throw.js +72 -0
  111. package/src/object-catalog.jsx +0 -4
  112. package/src/object-gizmo.jsx +2 -1
  113. package/src/object-path.js +1 -1
  114. package/src/panels/ProjectPanel.jsx +42 -21
  115. package/src/panels/details.css +15 -4
  116. package/src/popover.js +37 -0
  117. package/src/posestudio.jsx +15 -10
  118. package/src/project-browser-art.jsx +19 -0
  119. package/src/project-browser.css +74 -121
  120. package/src/project-browser.jsx +36 -60
  121. package/src/props.jsx +4 -7
  122. package/src/retired-kinds.js +37 -0
  123. package/src/scene-objects.js +3 -7
  124. package/src/scenes.js +4 -5
  125. package/src/settings-menu.jsx +1 -20
  126. package/src/shell/BottomDock.jsx +9 -9
  127. package/src/shell/MenuBar.jsx +1 -9
  128. package/src/shell/PreferencesDialog.jsx +3 -4
  129. package/src/shell/ViewportToolbar.jsx +3 -3
  130. package/src/shell/add-camera-shot.js +11 -0
  131. package/src/shell/glass-regions.css +3 -2
  132. package/src/shell/glass.css +0 -5
  133. package/src/shell/lens-scrub-math.js +51 -0
  134. package/src/shell/lens-scrub.css +73 -0
  135. package/src/shell/lens-scrub.jsx +98 -0
  136. package/src/shell/mode.css +6 -5
  137. package/src/shell/preferences.css +1 -32
  138. package/src/shell/shell.css +18 -1
  139. package/src/shell/studio-shell-context.js +5 -0
  140. package/src/shot-frame.js +26 -0
  141. package/src/stage-canvas-rigs.jsx +7 -8
  142. package/src/stage-characters.jsx +26 -0
  143. package/src/stage-gizmo.js +43 -0
  144. package/src/stage-labels-ko.js +0 -6
  145. package/src/stage-startup-defaults.js +3 -9
  146. package/src/starter-scenes.js +5 -11
  147. package/src/studio-agent-commands.js +2 -2
  148. package/src/studio-agent-protocol.js +2 -7
  149. package/src/studio-app-binding.js +1 -1
  150. package/src/studio-elements.js +1 -1
  151. package/src/styles/themes.css +13 -0
  152. package/src/styles.css +6 -674
  153. package/src/text/text-object.jsx +1 -1
  154. package/src/ui.jsx +8 -16
  155. package/src/use-embed-bridge.js +3 -6
  156. package/src/use-render-activity.js +3 -1
  157. package/src/view-ghosts.jsx +5 -3
  158. package/src/viewport-visible-rect.js +37 -0
  159. package/src/workflow/AgentPanel.jsx +75 -42
  160. package/src/workflow/AgentProgressCard.jsx +1 -1
  161. package/src/workflow/TaskBoard.jsx +102 -0
  162. package/src/workflow/TaskInline.jsx +114 -0
  163. package/src/workflow/agent-client.js +11 -25
  164. package/src/workflow/agent-panel.css +14 -1
  165. package/src/workflow/attachment-image.js +10 -3
  166. package/src/workflow/attachment-video.js +91 -0
  167. package/src/workflow/task-board.css +98 -0
  168. package/src/workflow/task-board.js +169 -0
  169. package/tools/check-eslint.mjs +2 -1
  170. package/tools/check-knip.mjs +2 -1
  171. package/tools/dev/pages/greybox-to-video.html +2 -3
  172. package/tools/dev/pages/previs-software.html +2 -2
  173. package/tools/dev/pages/privacy.html +3 -7
  174. package/tools/dev/pages/seedance-camera-control.html +2 -2
  175. package/tools/dev-full.mjs +11 -2
  176. package/tools/eslint-baseline.json +6 -6
  177. package/tools/knip-baseline.json +0 -11
  178. package/tools/loc-baseline.json +30 -31
  179. package/tools/metrics-snapshot.mjs +43 -5
  180. package/tools/render-start-thumbs.mjs +175 -0
  181. package/tools/run-tests.mjs +7 -14
  182. package/tools/sitemap.mjs +1 -1
  183. package/dist/assets/analytics-BibyE5ar.js +0 -6
  184. package/dist/assets/app-DhRJ-PBz.js +0 -5088
  185. package/dist/assets/app-f-fkV5uP.css +0 -1
  186. package/dist/assets/compile-worker-DqiA4AaN.js +0 -5
  187. package/dist/assets/first-shot-handoff-Qx7SKl38.js +0 -1
  188. package/dist/assets/landing-DYg87MDY.js +0 -1
  189. package/dist/assets/scene-asset-sync-DBYQBR0T.js +0 -20
  190. package/dist/assets/scene-asset-sync-_S8NQl5B.css +0 -1
  191. package/dist/assets/workflow-BSA22Ofr.js +0 -179
  192. package/dist/scenes/city-block.cclayproject +0 -1
  193. package/src/burn-in.js +0 -91
  194. package/src/camera-tutorial.jsx +0 -520
  195. package/src/facing-marks.js +0 -88
  196. package/src/first-shot-handoff.js +0 -44
  197. package/src/panels/motion.css +0 -197
  198. package/src/set-pieces.jsx +0 -183
  199. package/src/tutorial-analytics.js +0 -86
  200. package/src/tutorial-walk.js +0 -17
  201. package/src/use-camera-tutorial.js +0 -50
  202. package/src/use-semantic-state.js +0 -12
  203. package/tools/metrics/archive-traffic.mjs +0 -138
  204. package/tools/perf/ik-camera-drag.mjs +0 -138
package/src/analytics.js CHANGED
@@ -116,7 +116,6 @@ const EVENT_PROPERTIES = Object.freeze({
116
116
  "project:opened": ["age_bucket"],
117
117
  "craft:first_action": ["action_kind"],
118
118
  "craft:first_edit": ["edit_kind", "definition_version"],
119
- "motion:backend_state": ["backend", "host_configured"],
120
119
  "motion:generate_requested": ["surface", "input_mode", "request_id"],
121
120
  "motion:preflight_blocked": ["reason", "surface", "request_id"],
122
121
  "motion:preflight_passed": ["backend", "surface", "request_id"],
@@ -136,19 +135,14 @@ const EVENT_PROPERTIES = Object.freeze({
136
135
  "playground:first_action": ["action_kind"],
137
136
  "playground:first_edit": ["edit_kind", "definition_version"],
138
137
  "activation:completed": ["activation_path"],
139
- "tutorial:started": ["surface", "tutorial_version", "start_source"],
140
- "tutorial:step_entered": ["surface", "tutorial_version", "step_kind"],
141
- "tutorial:step_completed": ["surface", "tutorial_version", "step_kind", "elapsed_bucket"],
142
- "tutorial:completed": ["surface", "tutorial_version", "elapsed_bucket"],
143
- "tutorial:dismissed": ["surface", "tutorial_version", "step_kind"],
144
138
  "sculpt_run": ["outcome", "rounds", "final_iou", "duration_ms", "had_target"],
145
139
  ...EXECUTION_TELEMETRY_PROPERTY_KEYS,
146
140
  });
147
141
  const FEATURE_NAMES = new Set([
148
142
  "pose_edit", "pose_save", "camera_fly", "orbit", "dolly_rail", "crane_graph", "timeline_scrub",
149
143
  "prompt_block_add", "shot_add", "shot_cut", "export_pose", "export_frame", "export_video",
150
- "export_depth_video", "export_keyframe_pack", "fal_motion_open",
151
- "mcp_connected", "auto_color", "plan_view", "camera_tutorial",
144
+ "export_depth_video", "export_keyframe_pack",
145
+ "mcp_connected", "auto_color", "plan_view",
152
146
  ]);
153
147
  // `site` and `playground` arrive through `npx cozyclay --via <source>` from the
154
148
  // landing page's copy commands; the others are the first-launch prompt answers.
@@ -159,7 +153,6 @@ const MOTION_ERROR_CODES = new Set(["aborted", "unsupported_route", "generation_
159
153
  const MOTION_PROPERTY_VALUES = Object.freeze({
160
154
  // `fal` is the Studio's AI video route (H3 Max Turbo through api.cozyclay.org).
161
155
  backend: new Set(["none", "local_kimodo", "hosted", "fal"]),
162
- host_configured: new Set([true, false]),
163
156
  surface: new Set(["timeline", "line_edit", "trail", "mcp", "fal_card", "agent"]),
164
157
  input_mode: new Set(["prompt", "pose", "edit", "a_to_b", "still"]),
165
158
  // `locked`: the AI video route is not enabled for this account yet.
@@ -176,13 +169,6 @@ const EXPORT_PROPERTY_VALUES = Object.freeze({
176
169
  duration_bucket: new Set(["lt1s", "1-3s", "3-10s", "10-30s", "gte30s"]),
177
170
  failure_code: EXPORT_FAILURE_CODES,
178
171
  });
179
- const TUTORIAL_PROPERTY_VALUES = Object.freeze({
180
- surface: new Set(["studio", "playground"]),
181
- tutorial_version: new Set([1]),
182
- start_source: new Set(["query", "settings", "landing"]),
183
- step_kind: new Set(["fly", "walk", "dolly", "orbit", "shot", "rail", "play"]),
184
- elapsed_bucket: new Set(["lt1s", "1-3s", "3-10s", "10-30s", "gte30s"]),
185
- });
186
172
  // Sculpt mode (#739): one sculpt_run per finished run. Numbers only: no
187
173
  // reference image, recipe, part name or sculpt id ever rides along.
188
174
  export const SCULPT_RUN_OUTCOMES = Object.freeze(["done", "cancelled", "failed"]);
@@ -242,7 +228,7 @@ let deviceProfileStarted = false;
242
228
  // move action_count_bucket.
243
229
  const PASSIVE_EVENTS = new Set([
244
230
  "app:session_started", "app:session_ended", "install:first_launch",
245
- "motion:backend_state", "app:error", "device:profile",
231
+ "app:error", "device:profile",
246
232
  ]);
247
233
 
248
234
  function storage() {
@@ -320,7 +306,6 @@ export function sanitizeProps(event, props) {
320
306
  if (typeof props[key] !== "string" || !/^[a-f0-9]{32}$/.test(props[key])) continue;
321
307
  } else if (!MOTION_PROPERTY_VALUES[key]?.has(props[key])) continue;
322
308
  }
323
- if (event.startsWith("tutorial:") && !TUTORIAL_PROPERTY_VALUES[key]?.has(props[key])) continue;
324
309
  if (event.startsWith("workflow:") || event.startsWith("agent:") || event.startsWith("mcp:")) {
325
310
  if (key === "run_id" || key === "turn_id" || key === "request_id") {
326
311
  if (typeof props[key] !== "string" || !/^[a-f0-9]{32}$/.test(props[key])) continue;
@@ -372,136 +357,6 @@ export function sanitizeProps(event, props) {
372
357
  return sanitized;
373
358
  }
374
359
 
375
- /**
376
- * Convert the bridge health payload into the analytics contract. The bridge
377
- * only exposes a safe location label; the configured host itself never leaves
378
- * the local process. A missing/unhealthy bridge is the useful `none` bucket.
379
- */
380
- export function motionBackendState(health) {
381
- if (!health || health.ok !== true) return { backend: "none", host_configured: false };
382
- if (typeof health.backend === "string" && ["none", "local_kimodo", "hosted"].includes(health.backend)) {
383
- return {
384
- backend: health.backend,
385
- host_configured: health.host_configured === true
386
- || (typeof health.host === "string" && health.host.trim().length > 0),
387
- };
388
- }
389
- const host = typeof health.host === "string" ? health.host.trim() : "";
390
- return {
391
- // The current /ardy bridge is the local Kimodo integration even when it
392
- // dispatches to a configured GPU box over SSH. A future hosted API can
393
- // opt into the explicit `backend: "hosted"` field above.
394
- backend: "local_kimodo",
395
- host_configured: Boolean(host),
396
- };
397
- }
398
-
399
- /** Read only structured readiness; raw health/error prose never classifies intent. */
400
- export function motionPreflightReason(health, { body = {}, lineEditSupported = false } = {}) {
401
- if (!health || health.backend === "none" || (health.ok !== true && (health.host_configured === false || health.reason === "unconfigured"))) return "unconfigured";
402
- if (health.ok !== true) return "unreachable";
403
- if ((body.lineEdit || body.replay?.length) && !lineEditSupported) return "unsupported_route";
404
- // The #267 local MLX/cpp route supports a single unconstrained prompt.
405
- // ProjFlow line edits have their own capability/runner, even on a local bridge.
406
- if (!body.lineEdit && health.host === "local" && health.device === "local" && (
407
- body.segments?.length > 1 || body.posePin || body.waypoints?.length || body.motionEdit || body.preserve
408
- )) return "unsupported_route";
409
- return null;
410
- }
411
-
412
- export function motionFailureCode(error, fallbackCode) {
413
- try {
414
- if (error?.name === "AbortError") return "aborted";
415
- if (MOTION_ERROR_CODES.has(fallbackCode)) return fallbackCode;
416
- if (error instanceof Error) return "generation_failed";
417
- } catch {
418
- // Cross-realm error objects may have throwing getters.
419
- }
420
- return "unknown";
421
- }
422
-
423
- /** One explicit request, not an authoring draft. All methods are telemetry-only:
424
- * no return value may decide whether generation runs. Duplicate/out-of-order
425
- * callbacks cannot advance the funnel, and application is separate from success.
426
- */
427
- export function startMotionRequest(metadata, dependencies = {}) {
428
- let phase = "requested";
429
- let props = null;
430
- let startedAt = NaN;
431
- let now = () => performance.now();
432
- let capture = track;
433
- const clock = () => { try { return now(); } catch { return NaN; } };
434
- const emit = (event, extra = {}) => {
435
- if (!props) return;
436
- try {
437
- Promise.resolve(capture(event, sanitizeProps(event, { ...props, ...extra }))).catch(() => {
438
- // A rejected transport must not change generation.
439
- });
440
- } catch {
441
- // Telemetry is best effort, including capture/payload failures.
442
- }
443
- };
444
- try {
445
- now = dependencies.now ?? now;
446
- capture = dependencies.capture ?? capture;
447
- const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
448
- const request_id = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
449
- props = { ...sanitizeProps("motion:generate_requested", metadata), request_id };
450
- emit("motion:generate_requested");
451
- } catch {
452
- // Omit telemetry without secure randomness; never derive an ID from content.
453
- props = null;
454
- }
455
- return {
456
- preflight(health, options) {
457
- if (phase !== "requested") return;
458
- try {
459
- const reason = motionPreflightReason(health, options);
460
- phase = reason ? "blocked" : "passed";
461
- if (props) props.backend = motionBackendState(health).backend;
462
- emit(reason ? "motion:preflight_blocked" : "motion:preflight_passed", reason ? { reason } : {});
463
- } catch {
464
- // A malformed telemetry input cannot stop the real request.
465
- }
466
- },
467
- /** A route that decides readiness without the /ardy health payload (the
468
- * Fal card): refuse with one closed reason, such as `locked`. */
469
- block(reason) {
470
- if (phase !== "requested") return;
471
- phase = "blocked";
472
- emit("motion:preflight_blocked", { reason });
473
- },
474
- /** The same route's acceptance, naming the backend that will run it. */
475
- pass(backend) {
476
- if (phase !== "requested") return;
477
- phase = "passed";
478
- if (props) props.backend = backend;
479
- emit("motion:preflight_passed");
480
- },
481
- start() {
482
- if (phase !== "passed") return;
483
- phase = "started";
484
- startedAt = clock();
485
- emit("motion:job_started");
486
- },
487
- succeed() {
488
- if (phase !== "started") return;
489
- phase = "succeeded";
490
- emit("motion:job_succeeded", { duration_bucket: bucketMs(clock() - startedAt) });
491
- },
492
- fail(error, fallbackCode) {
493
- if (phase !== "started") return;
494
- phase = "failed";
495
- emit("motion:job_failed", { duration_bucket: bucketMs(clock() - startedAt), error_code: motionFailureCode(error, fallbackCode) });
496
- },
497
- apply() {
498
- if (phase !== "succeeded") return;
499
- phase = "applied";
500
- emit("motion:result_applied");
501
- },
502
- };
503
- }
504
-
505
360
  export const FEATURE_USAGE_NAMES = Object.freeze([...FEATURE_NAMES]);
506
361
 
507
362
  export function bucketCount(value) {
package/src/app-stage.jsx CHANGED
@@ -62,7 +62,7 @@ export {
62
62
 
63
63
  export * from "./stage-attach-frames.js";
64
64
 
65
- export { Character } from "./stage-characters.jsx";
65
+ export { Character, PlaybackCharacter } from "./stage-characters.jsx";
66
66
 
67
67
  export {
68
68
  ShotRig,
@@ -49,9 +49,6 @@ const OBJECT_GLYPHS = {
49
49
  cylinder: "M5 7c0-1.38 3.13-2.5 7-2.5s7 1.12 7 2.5-3.13 2.5-7 2.5S5 8.38 5 7z M5 7v10c0 1.38 3.13 2.5 7 2.5s7-1.12 7-2.5V7",
50
50
  cone: "M12 3.5 5 17 M12 3.5 19 17 M5 17c0 1.38 3.13 2.5 7 2.5s7-1.12 7-2.5",
51
51
  plane: "M12 7.5 21 12l-9 4.5L3 12z",
52
- chair: "M7 3.5h10v8.5H7z M5.5 12h13v3h-13z M7 15v5.5 M17 15v5.5",
53
- car: "M3 15.5v-3l2.5-.5 2.5-4h8l3 4 2 .5v3z M6 15.5a1.75 1.75 0 1 0 3.5 0 M14.5 15.5a1.75 1.75 0 1 0 3.5 0 M8 12h10",
54
- "small-plane": "M12 3v18 M3 11.5l9-2 9 2v2l-9-1-9 1z M8.5 19.5 12 18.5l3.5 1",
55
52
  };
56
53
 
57
54
  const STORAGE_FOLDER = "__storage";
@@ -368,12 +365,11 @@ function matchesQuery(label, query) {
368
365
  return !query || String(label).toLowerCase().includes(query);
369
366
  }
370
367
 
371
- /** Folder ids in display order. Basic Shapes and Sets split the object
372
- * catalogue by its own groups; Props holds what the user imported. */
368
+ /** Folder ids in display order. Basic Shapes holds the object catalogue's
369
+ * primitives; Props holds what the user imported. */
373
370
  const FOLDERS = [
374
371
  { id: "basic", label: () => ko("Basic Shapes", "기본 도형") },
375
372
  { id: "characters", label: () => ko("Characters", "인물") },
376
- { id: "sets", label: () => ko("Sets", "세트") },
377
373
  { id: "props", label: () => ko("Props", "소품") },
378
374
  { id: "cameras", label: () => ko("Cameras", "카메라") },
379
375
  { id: "motions", label: () => ko("Motions", "모션") },
@@ -389,7 +385,7 @@ function catalogueTile(entry, props) {
389
385
  key={key}
390
386
  assetKey={key}
391
387
  label={label}
392
- type={entry.group === "Primitives" ? ko("Shape", "도형") : ko("Set piece", "세트 소품")}
388
+ type={ko("Shape", "도형")}
393
389
  title={ko(`Drag ${entry.label} into the scene, or double-click to place it at the origin`, `${label}을(를) 씬에 드래그하거나 더블클릭해 원점에 놓으세요`)}
394
390
  preview={<ObjectPreview kind={entry.kind} color={entry.color} />}
395
391
  selected={props.selectedKey === key}
@@ -408,10 +404,9 @@ export function FolderGrid({ folder, query, ...props }) {
408
404
  const { imageAssetIds, meshAssetIds, shots = [], takeVersions = [], poses = [], onShotOpen, onTakeOpen, selectedKey, onSelect } = props;
409
405
  let tiles = [];
410
406
  let empty = null;
411
- if (folder === "basic" || folder === "sets") {
412
- const group = folder === "basic" ? "Primitives" : "Set pieces";
407
+ if (folder === "basic") {
413
408
  tiles = OBJECT_LIBRARY
414
- .filter((entry) => entry.group === group && matchesQuery(displayObjectLabel(entry.label), query))
409
+ .filter((entry) => entry.group === "Primitives" && matchesQuery(displayObjectLabel(entry.label), query))
415
410
  .map((entry) => catalogueTile(entry, props));
416
411
  } else if (folder === "characters") {
417
412
  tiles = CHARACTER_ASSETS.filter((asset) => matchesQuery(asset.label, query)).map((asset) => {
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The viewport's auto-colour material: a solid-view response for
3
+ * auto-coloured surfaces. A neutral studio shade lets the derived hue reach
4
+ * the eye unmultiplied; under our warm key light a plain standard material
5
+ * turns every hue olive. Full roughness kills the specular sheen and a
6
+ * self-light of the same hue lifts the shadow side.
7
+ */
8
+ export function autoFlat(hex) {
9
+ return { color: hex, roughness: 1, metalness: 0, emissive: hex, emissiveIntensity: 0.4 };
10
+ }
package/src/bus-tasks.js CHANGED
@@ -72,7 +72,9 @@ export function createBusTasks({ ports, emit }) {
72
72
  control(id, args, request, before, busy) {
73
73
  if (id === 'task.undo') {
74
74
  const task = finished.get(args.taskId) ?? fail('UNDO_EXPIRED', `Task ${args.taskId} is not a finished task this editor still holds.`);
75
- const moved = [...task.final].filter(([key, token]) => tokenOf(key) !== token).map(([key]) => key);
75
+ // A key moved when its token did and its content did too: a later task undone first puts the same content back
76
+ // under a new token, and that is not a change.
77
+ const moved = [...task.final].filter(([key, { token, value }]) => tokenOf(key) !== token && JSON.stringify(snapshot(key).value) !== value).map(([key]) => key);
76
78
  if (moved.length) fail('UNDO_CONFLICT', `Changed since the task finished: ${listOf(moved)}; undo those first or edit by hand.`);
77
79
  const historyEntryId = restore(task, [...task.pre.values()], 'undone');
78
80
  finished.delete(args.taskId);
@@ -89,7 +91,7 @@ export function createBusTasks({ ports, emit }) {
89
91
  return receipt(id, task, request, before);
90
92
  }
91
93
  const task = open.get(args.taskId) ?? fail('STALE_TARGET', `Task ${args.taskId} is not open.`);
92
- if (id === 'task.requestScope') { grant(task, args.scope); return receipt(id, task, request, before); }
94
+ if (id === 'task.requestScope') { grant(task, args.scope); emit?.({ type: 'task.scope', taskId: task.taskId, scope: [...task.keys] }); return receipt(id, task, request, before); }
93
95
  if (id === 'task.mark') { task.mark = { values: [...task.keys].map(snapshot), keys: new Set(task.keys) }; return receipt(id, task, request, before); }
94
96
  if (id === 'task.rollback') {
95
97
  const mark = task.mark ?? fail('STALE_TARGET', 'The task has no mark to roll back to.');
@@ -104,7 +106,7 @@ export function createBusTasks({ ports, emit }) {
104
106
  const historyEntryId = restore(task, [...task.pre.values()], 'cancelled');
105
107
  return receipt(id, task, request, before, { historyEntryId, affectedIds: task.affectedIds });
106
108
  }
107
- task.final = new Map([...task.pre.keys()].map(key => [key, tokenOf(key)]));
109
+ task.final = new Map([...task.pre.keys()].map(key => [key, { token: tokenOf(key), value: JSON.stringify(snapshot(key).value) }]));
108
110
  finished.set(task.taskId, task);
109
111
  while (finished.size > FINISHED_KEPT) finished.delete(finished.keys().next().value);
110
112
  emit?.({ type: 'task.finished', taskId: task.taskId });
@@ -116,8 +118,10 @@ export function createBusTasks({ ports, emit }) {
116
118
  * task's edit restores to (undo_edit of it), after keeping each key's first pre-image.
117
119
  */
118
120
  admit(task, entry, args, request, before) {
119
- if (!task && (request.origin === 'ui' || !open.size)) return null;
121
+ if (!task && !open.size) return null;
120
122
  const keys = entry?.id ? commandKeys(entry.id, args, before, entry) : [];
123
+ // The user's own edit is never refused; a running task whose scope it touches hears of it (the panel's card).
124
+ if (!task && request.origin === 'ui') { for (const other of open.values()) { const mine = keys.filter(key => other.keys.has(key)); if (mine.length) emit?.({ type: 'task.userEdit', taskId: other.taskId, keys: mine }); } return null; }
121
125
  if (!task) {
122
126
  const held = keys.find(key => scopeConflict(null, [key], open));
123
127
  if (held) fail('SCOPE_BUSY', `${scopeConflict(null, [held], open)}; this command waits for that task.`);
@@ -137,12 +141,14 @@ export function createBusTasks({ ports, emit }) {
137
141
  record(point, recorded) {
138
142
  if (!point || !recorded?.ok) return recorded;
139
143
  const { task } = point, state = ports.read(), warnings = [];
144
+ let grew = false;
140
145
  for (const id of recorded.affectedIds ?? []) {
141
146
  const key = keyOfId(id, state);
142
147
  if (!key || task.keys.has(key)) continue;
143
148
  if (scopeConflict(task.taskId, [key], open)) warnings.push({ code: 'SCOPE_DENIED', message: `${key} changed outside the task's scope.` });
144
- else { task.keys.add(key); if (!point.keys.includes(key)) { task.pre.set(key, { key, value: undefined }); point.values.push({ key, value: undefined }); } }
149
+ else { task.keys.add(key); grew = true; if (!point.keys.includes(key)) { task.pre.set(key, { key, value: undefined }); point.values.push({ key, value: undefined }); } }
145
150
  }
151
+ if (grew) emit?.({ type: 'task.scope', taskId: task.taskId, scope: [...task.keys] });
146
152
  for (const key of task.keys) task.tokens.set(key, tokenOf(key));
147
153
  if (recorded.authored) {
148
154
  task.edits.push({ receiptId: recorded.receiptId, values: point.values, keys: point.keys, mark: point.mark });
@@ -286,7 +286,7 @@ export function createCommandBus({ registry, ports }) {
286
286
  domainRevision = domain ? current.domainRevisions?.[domain] : null;
287
287
  };
288
288
  const nestedIds = new Set();
289
- const context = { origin: request.origin, signal: controller.signal,
289
+ const context = { origin: request.origin, signal: controller.signal, taskId: request.taskId ?? null,
290
290
  check() {
291
291
  controller.signal.throwIfAborted();
292
292
  const current = ports.read();
@@ -1,12 +1,16 @@
1
- // Where a motion compile runs. In the browser: one module Web Worker (motion3/compile-worker.js), started
2
- // on the first compile and reused, jobs answered in arrival order, so a compile costs the worker's time
3
- // and never blocks the viewport (the main thread runs the compiler ~4x slower and freezes while it does).
4
- // Where there is no Worker (Node, tests) or the worker fails: the same job on the calling thread,
5
- // synchronously, so `registry.run("motion.compile", ...)` there still answers at once. The answer is the
6
- // same either way (compile-job.js runs the same code on the same plain inputs).
1
+ // Where a motion compile runs. In the browser: a pool of module Web Workers (motion3/compile-worker.js), at most
2
+ // POOL of them, each started on first need and reused; a job takes an idle worker or waits in arrival order, so a
3
+ // compile costs a worker's time and never blocks the viewport (the main thread runs the compiler ~4x slower and freezes
4
+ // while it does). Two workers let two Studio tasks compile at once (parallel tasks, case 10) without a third
5
+ // starving the editor's cores; the rest wait, and the panel shows which task's compile is waiting (compileActivity).
6
+ // Where there is no Worker (Node, tests) or a worker fails: the same job on the calling thread, synchronously, so
7
+ // `registry.run("motion.compile", ...)` there still answers at once. The answer is the same either way (compile-job.js
8
+ // runs the same code on the same plain inputs), whichever worker runs it.
7
9
  import { runCompileJob } from "../motion3/compile-job.js";
8
10
  import { StudioProtocolError } from "../studio-agent-protocol.js";
9
11
 
12
+ const POOL = 2;
13
+
10
14
  /** A job error (or a worker's serialized one) as the Studio's protocol error when it carries a code. */
11
15
  function rethrow(error) {
12
16
  if (error?.code && !(error instanceof StudioProtocolError)) throw new StudioProtocolError(error.code, error.message);
@@ -21,43 +25,63 @@ function browserWorker() {
21
25
  return new Worker(new URL("../motion3/compile-worker.js", import.meta.url), { type: "module", name: "motion-compile" });
22
26
  }
23
27
 
28
+ // What the editor's compiles are doing, for the panel: { running: [meta], waiting: [meta] } (meta: { taskId, characterId }).
29
+ const watchers = new Set();
30
+ let activity = { running: [], waiting: [] };
31
+ const publish = next => { activity = next; for (const watcher of watchers) watcher(activity); };
32
+ export const compileActivity = () => activity;
33
+ export function watchCompileActivity(watcher) { watchers.add(watcher); return () => watchers.delete(watcher); }
34
+
24
35
  /**
25
- * createCompileRunner({ spawn }) -> { run(job) -> answer | Promise<answer>, takeWarning() -> string | null }
36
+ * createCompileRunner({ spawn, size }) -> { run(job, meta?) -> answer | Promise<answer>, takeWarning() -> string | null }
26
37
  * spawn() -> a Worker-like object (postMessage, onmessage, onerror, terminate) or null for the calling thread.
27
38
  */
28
- export function createCompileRunner({ spawn = browserWorker } = {}) {
29
- let worker, dead = false, warning = null, next = 0;
30
- const pending = new Map();
31
- /** The worker failed (would not start, or crashed): every job from now on, and those in flight, run here. */
39
+ export function createCompileRunner({ spawn = browserWorker, size = POOL } = {}) {
40
+ let dead = false, warning = null, next = 0;
41
+ const slots = [], queue = [];
42
+ const report = () => publish({ running: slots.filter(slot => slot.job).map(slot => slot.job.meta), waiting: queue.map(item => item.meta) });
43
+ /** A worker failed (would not start, or crashed): every job from now on, and those in flight or waiting, run here. */
32
44
  const fallBack = reason => {
33
45
  dead = true;
34
- worker?.terminate?.();
35
- worker = null;
36
46
  warning ??= `The motion compile worker stopped (${reason}); compiling on the main thread, which is slower and blocks the editor while it runs.`;
37
- for (const [id, { job, resolve, reject }] of pending) { pending.delete(id); try { resolve(local(job)); } catch (error) { reject(error); } }
47
+ const orphans = [...slots.flatMap(slot => (slot.job ? [slot.job] : [])), ...queue.splice(0)];
48
+ for (const slot of slots.splice(0)) slot.worker?.terminate?.();
49
+ for (const { job, resolve, reject } of orphans) { try { resolve(local(job)); } catch (error) { reject(error); } }
50
+ report();
38
51
  };
39
- const started = () => {
40
- if (worker || dead) return worker;
52
+ /** One more worker in the pool: its slot, or null when none can start (dead: every job runs here from now on). */
53
+ const grow = () => {
54
+ let worker;
41
55
  try { worker = spawn(); } catch (error) { fallBack(error?.message ?? String(error)); return null; }
42
56
  if (!worker) { dead = true; return null; }
57
+ const slot = { worker, job: null };
43
58
  worker.onmessage = ({ data: { id, value, error } }) => {
44
- const job = pending.get(id);
45
- if (!job) return;
46
- pending.delete(id);
59
+ const job = slot.job;
60
+ if (!job || job.id !== id) return;
61
+ slot.job = null;
47
62
  if (error) { try { rethrow(error); } catch (thrown) { job.reject(thrown); } } else job.resolve(value);
63
+ pump();
48
64
  };
49
65
  worker.onerror = event => { event?.preventDefault?.(); fallBack(event?.message || "worker error"); };
50
66
  worker.onmessageerror = () => fallBack("an answer could not be read");
51
- return worker;
67
+ slots.push(slot);
68
+ return slot;
69
+ };
70
+ const pump = () => {
71
+ while (!dead && queue.length) {
72
+ const slot = slots.find(candidate => !candidate.job) ?? (slots.length < size ? grow() : null);
73
+ if (!slot) break;
74
+ slot.job = queue.shift();
75
+ try { slot.worker.postMessage({ id: slot.job.id, job: slot.job.job }); } catch (error) { fallBack(error?.message ?? String(error)); return; }
76
+ }
77
+ if (dead) for (const item of queue.splice(0)) { try { item.resolve(local(item.job)); } catch (error) { item.reject(error); } }
78
+ report();
52
79
  };
53
80
  return {
54
- run(job) {
55
- if (!started()) return local(job);
56
- return new Promise((resolve, reject) => {
57
- const id = ++next;
58
- pending.set(id, { job, resolve, reject });
59
- try { worker.postMessage({ id, job }); } catch (error) { fallBack(error?.message ?? String(error)); }
60
- });
81
+ run(job, meta = {}) {
82
+ // No worker can start here (Node, tests): answer on this thread at once, as before.
83
+ if (dead || (!slots.length && !grow())) return local(job);
84
+ return new Promise((resolve, reject) => { queue.push({ id: ++next, job, meta, resolve, reject }); pump(); });
61
85
  },
62
86
  /** The fallback note, once (a receipt warning), or null. */
63
87
  takeWarning() { const note = warning; if (note) warning = ""; return note || null; },
@@ -105,10 +105,10 @@ export function register(registry, ports) {
105
105
  * spec on the character's rig and the current scene (compile-job.js) -> { stored (the spec a take keeps and its specId
106
106
  * names), qa, ..., take when bake is given }, at once or as a promise. opts: probes, checks, lenient, repair.
107
107
  */
108
- const compile = (character, spec, opts = {}, bake) => {
108
+ const compile = (character, spec, opts = {}, bake, taskId = null) => {
109
109
  rigOf(character);
110
110
  const placed = bake && { position: [character.x ?? 0, character.y ?? 0, character.z ?? 0], yaw: character.rot ?? 0, ...bake };
111
- return runner.run({ model: character.model, scene: sceneFor(character), spec, ...opts, ...(placed ? { bake: placed } : {}) });
111
+ return runner.run({ model: character.model, scene: sceneFor(character), spec, ...opts, ...(placed ? { bake: placed } : {}) }, { taskId, characterId: character.id });
112
112
  };
113
113
  /** The whole output, kept in the store (motion.report, delta) and cut to the receipt budget (report.js). */
114
114
  const report = (r, head, extra = {}) => {
@@ -145,7 +145,7 @@ export function register(registry, ports) {
145
145
  const installedSpec = () => { const take = owner().motionFor(characterId); return take?.spec && specIdOf(take.spec) === base ? structuredClone(take.spec) : null; };
146
146
  const patched = [], source = spec ?? asArgument(() => applyPatch(store.get(base) ?? installedSpec() ?? unknownSpec("base", base), patch ?? [], patched));
147
147
  // the take is always baked: the user watches every draft, installed or not
148
- return after(compile(character, source, { probes, repair }, {}), r => {
148
+ return after(compile(character, source, { probes, repair }, {}, context?.taskId), r => {
149
149
  const { qa, take } = r, notes = [...patched, ...(r.notes ?? [])];
150
150
  // ifClean: install only a clean final result (qa.ok includes the goals; after repair when asked)
151
151
  const wanted = install === "ifClean" ? qa.ok : install;
@@ -12,7 +12,7 @@ const saveAs = { ...save, id: "project.saveAs", label: "Save project as" };
12
12
  const exportAsAnimation = studioActionDeclaration("project.exportAsAnimation");
13
13
  const opening = document("project.open", "Open a project file", { serialized: { type: "string", maxLength: 400_000_000 }, handleToken: token, projectToken: token });
14
14
  const fresh = document("project.new", "Start a new project", { name, previsMode: { type: "string", enum: [...PREVIS_MODES] } });
15
- const starter = document("project.openStarter", "Open a starter project", { id: token, source: { type: "string", enum: ["starter", "tutorial", "launch"] }, name });
15
+ const starter = document("project.openStarter", "Open a starter project", { id: token, source: { type: "string", enum: ["starter", "launch"] }, name });
16
16
  const restore = { ...document("project.restore", "Restore the remembered project", { handleToken: token }), exposure: "ui-only" };
17
17
  const browse = { id: "project.browse", label: "Browse projects", description: "Show the project browser.", kind: "transient", exposure: "ui-only", input: input() };
18
18
  export const declarations = Object.freeze([rename, save, saveAs, exportAsAnimation, opening, fresh, starter, restore, browse]);
@@ -1,6 +1,6 @@
1
1
  // One semantic entry point for timeline, camera controls and agent framing.
2
2
  import { createCameraBlock, updateCameraBlock, removeCameraRail } from '../camera-block.js';
3
- import { addShotAtFrame, createShot, cutAtFrame, duplicateShot, removeShot, reorderShot, resizeShot, renameShot, moveCameraKey, removeCameraKey } from '../cuts.js';
3
+ import { addShotAtFrame, appendShotGrowingTake, createShot, cutAtFrame, duplicateShot, removeShot, reorderShot, resizeShot, renameShot, moveCameraKey, removeCameraKey } from '../cuts.js';
4
4
  import { reflowStillShots, stillFrameCount, STILL_FRAME_COUNT_MAX, STILL_HOLD_DEFAULT } from '../shot-authoring.js';
5
5
  import { ko } from '../locale.js';
6
6
  import { track } from '../analytics.js';
@@ -17,6 +17,7 @@ import './elements/shot.js';
17
17
  import { changedIds, fail, shotLabel } from './shared.js';
18
18
 
19
19
  const id = StudioSchemas.TargetGuard.properties.targetId;
20
+ const TAKE_FRAME_MAX = 28800;
20
21
  const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
21
22
  const number = { type: 'number' };
22
23
  const frame = { type: 'integer', minimum: 0 };
@@ -30,7 +31,7 @@ const extra = [
30
31
  mutation('shot.moveKey', 'Move camera key', input({ shotId: id, keyId: id, frame })),
31
32
  mutation('shot.removeKey', 'Remove camera key', input({ shotId: id, keyId: id })),
32
33
  mutation('shot.clearKeys', 'Clear camera keys', input({ shotId: id })),
33
- mutation('shot.setTimeline', 'Set shot timeline', input({ frameCount: { type: 'integer', minimum: 24, maximum: 28800 } })),
34
+ mutation('shot.setTimeline', 'Set shot timeline', input({ frameCount: { type: 'integer', minimum: 24, maximum: TAKE_FRAME_MAX } })),
34
35
  mutation('shot.setLens', 'Set camera lens', input({ fovDeg: { ...number, minimum: 14, maximum: 90 } })),
35
36
  mutation('shot.frame', 'Frame the shot', { ...input({ ...STUDIO_TOOL_SCHEMAS.frame_shot.properties, preset: { type: 'string' } }, []), oneOf: [STUDIO_TOOL_SCHEMAS.frame_shot, input({ preset: { type: 'string' } })] },
36
37
  'Frame a shot on one character (the frame_shot tool): framing.intent { size, level, view, side, focalMm } or framing.exact { position, lookAt, focalMm }. Without keyAtFrame a shot that holds one framing is reframed whole: a held shot. keyAtFrame adds a camera key there; every key eases in and out, so the camera slows to a stop at each one: key a pan or zoom from one spot with two keys (its start and end), lay a moving camera as a rail (shot.setCameraRail) and cut to a new shot for a new view. On a rail shot it puts the shot back on keys. Answers checks { derivedSize, screenFraction, clipped, behindCamera, occluded, cameraSide } for the subject at that frame.'),
@@ -84,6 +85,17 @@ export function register(registry, ports) {
84
85
  const created = written.find(shot => shot.id === still.id), { selection, view } = ports.readView();
85
86
  ports.publishView({ selection, view: { ...view, frame: created.startFrame }, shotId: created.id });
86
87
  }
88
+ // A clip shot goes in the free time at (or after) the playhead; with none left the take grows at its end,
89
+ // in the same write so one undo takes both back, and the playhead lands in the new shot.
90
+ function createClip() {
91
+ const current = owner().read(), { frameCount } = owner().state(), framing = owner().capture();
92
+ const placed = addShotAtFrame(current, ports.state().frame, frameCount, framing);
93
+ if (placed !== current) return owner().write(placed);
94
+ const grown = appendShotGrowingTake(current, frameCount, framing, TAKE_FRAME_MAX) ?? fail('INVALID_ARGUMENT', `The take is at its longest (${TAKE_FRAME_MAX} frames); shorten or remove a shot first.`);
95
+ owner().writeState(before => ({ ...before, ...grown }));
96
+ const added = grown.shots.find(shot => !current.some(row => row.id === shot.id)), { selection, view } = ports.readView();
97
+ if (added) ports.publishView({ selection, view: { ...view, frame: added.startFrame }, shotId: added.id });
98
+ }
87
99
  function duplicate(shotId) {
88
100
  const source = shotOf(shotId), copied = { kind: source.kind, caption: source.caption, cast: structuredClone(source.cast), stylizedAssetId: source.stylizedAssetId };
89
101
  if (source.kind !== 'still') {
@@ -130,7 +142,7 @@ export function register(registry, ports) {
130
142
  // aimHeight moves a cast aim off the chest to a world height (a whole body, a face).
131
143
  const aimOf = (aim, aimHeight) => (aim?.ids && aimHeight !== undefined ? { ids: aim.ids, height: aimHeight } : aim);
132
144
  const methods = {
133
- 'shot.create': () => storyboard() ? createStill() : owner().write(current => addShotAtFrame(current, ports.state().frame, owner().state().frameCount, owner().capture())),
145
+ 'shot.create': () => storyboard() ? createStill() : createClip(),
134
146
  'shot.createStill': args => createStill(args),
135
147
  'shot.setCaption': ({ shotId, caption }) => patchShot(shotId, shot => ({ ...shot, caption })),
136
148
  'shot.setCastOverride': ({ shotId, characterId, override }) => owner().setCastOverride(shotId, characterId, override),
@@ -188,7 +200,7 @@ export function register(registry, ports) {
188
200
  const hasShots = state => state.shots.length > 0 || 'There are no shots yet; add one with shot.create.';
189
201
  const availability = {
190
202
  'shot.createStill': () => true,
191
- 'shot.create': state => storyboard() || addShotAtFrame(state.shots, state.frame, state.frameCount, null) !== state.shots || `There is no free room for a new shot at the playhead (frame ${state.frame}); move it with operate_studio { frame } or shorten a shot.`,
203
+ 'shot.create': state => storyboard() || addShotAtFrame(state.shots, state.frame, state.frameCount, null) !== state.shots || appendShotGrowingTake(state.shots, state.frameCount, null, TAKE_FRAME_MAX) !== null || `The take is at its longest (${TAKE_FRAME_MAX} frames); shorten or remove a shot first.`,
192
204
  'shot.split': state => state.shots.some(shot => state.frame > shot.startFrame && state.frame <= shot.endFrame) || `The playhead (frame ${state.frame}) is not inside a shot after its first frame; move it with operate_studio { frame }.`,
193
205
  'shot.clearCameraRail': state => state.shots.some(shot => createCameraBlock(shot.camera).cameraRail) || 'No shot has a camera rail; lay one with shot.setCameraRail.',
194
206
  };
package/src/controls.jsx CHANGED
@@ -53,14 +53,6 @@ export function aimAt(position, target) {
53
53
  * `getPivot()` is optional and returns a world point to orbit; without one,
54
54
  * Alt-drag orbits a point straight ahead of the lens.
55
55
  */
56
- /** Coarse navigation signal for onboarding surfaces (the landing-page
57
- * tutorial listens through the playground bridge). Fires once per gesture
58
- * start, not per frame. */
59
- function announceNav(kind, key = null) {
60
- if (typeof window === "undefined") return;
61
- window.dispatchEvent(new CustomEvent("cozyclay:nav", { detail: { kind, key } }));
62
- }
63
-
64
56
  export function FlyControls({ enabled, cameraLocked = false, camRef, look, getPivot, onFlyStateChange, onCameraChange }) {
65
57
  const { gl, invalidate } = useThree();
66
58
  const keys = useRef(new Set());
@@ -136,7 +128,6 @@ export function FlyControls({ enabled, cameraLocked = false, camRef, look, getPi
136
128
  if (gesture.current?.kind !== "fly") return;
137
129
  const key = KEY_BY_CODE[e.code];
138
130
  if (key) {
139
- if (!keys.current.has(key) && key !== "shift") announceNav("walk", key);
140
131
  keys.current.add(key);
141
132
  e.preventDefault();
142
133
  }
@@ -220,7 +211,6 @@ export function FlyControls({ enabled, cameraLocked = false, camRef, look, getPi
220
211
  lockAttempts = 0;
221
212
  if (e.pointerType !== "touch" && e.pointerType !== "pen") requestNavLock();
222
213
  if (kind === "fly") flyStateRef.current?.(true);
223
- announceNav(kind);
224
214
  scheduleInvalidate();
225
215
  };
226
216
 
@@ -309,7 +299,6 @@ export function FlyControls({ enabled, cameraLocked = false, camRef, look, getPi
309
299
  return;
310
300
  }
311
301
  cam.position.addScaledVector(forwardFrom(look.current.yaw, look.current.pitch), -e.deltaY * DOLLY_STEP);
312
- announceNav("dolly");
313
302
  cameraChangeRef.current?.();
314
303
  };
315
304
 
package/src/cuts.js CHANGED
@@ -127,6 +127,17 @@ export function addShotAtFrame(shots, frame, frameCount, currentFraming, duratio
127
127
  return [...list, createShot(`Shot ${list.length + 1}`, target, endFrame, keys)].sort((a, b) => a.startFrame - b.startFrame);
128
128
  }
129
129
 
130
+ /** Add Shot when the take has no free time left: the take grows by one default
131
+ * shot at its end and the new shot fills that, so the button never silently does
132
+ * nothing. Existing shots are untouched. Null past maxFrameCount. */
133
+ export function appendShotGrowingTake(shots, frameCount, currentFraming, maxFrameCount) {
134
+ const list = Array.isArray(shots) ? shots : [];
135
+ const startFrame = Math.max(frameNumber(frameCount, 1), ...list.map((shot) => shot.endFrame + 1));
136
+ const grownCount = startFrame + DEFAULT_SHOT_FRAMES;
137
+ if (grownCount > maxFrameCount) return null;
138
+ return { shots: addShotAtFrame(list, startFrame, grownCount, currentFraming), frameCount: grownCount };
139
+ }
140
+
130
141
  export function resizeShot(shots, shotId, edge, rawFrame, frameCount) {
131
142
  const shot = shotById(shots, shotId);
132
143
  if (edge !== "start" && edge !== "end") return shots;
@@ -546,7 +546,7 @@ export function useCast(appContext) {
546
546
  return current.length;
547
547
  }
548
548
 
549
- /** ARDY-demo style authoring: each empty-floor press in the Shot view drops
549
+ /** Click-to-place authoring: each empty-floor press in the Shot view drops
550
550
  the next waypoint where it was clicked; the frame gap comes from walking
551
551
  distance. The bird's-eye board selects and drags existing waypoints. */
552
552
  function addFloorWaypoint(point) {
@@ -715,7 +715,8 @@ export function useCast(appContext) {
715
715
  }
716
716
  function undoScene() {
717
717
  const receipt = appContext.bus.run('edit.undo');
718
- if (receipt.status === 'noop') appContext.notify(ko("Nothing to undo", "실행 취소할 작업이 없어요"));
718
+ // A refusal says why: the last edit belongs to a running task (parallel tasks), which its own card undoes.
719
+ if (receipt.status === 'noop' || receipt.ok === false) appContext.notify(receipt.ok === false ? ko(receipt.message ?? "Undo was refused", `실행 취소 불가: ${receipt.message ?? ""}`) : ko("Nothing to undo", "실행 취소할 작업이 없어요"));
719
720
  return receipt;
720
721
  }
721
722
  function redoScene() {