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.
- package/CHANGELOG.md +67 -0
- package/INSTALL_FOR_AGENTS.md +146 -0
- package/README.md +74 -192
- package/THIRD_PARTY_NOTICES.md +0 -21
- package/bin/agent/agent-routes.mjs +22 -56
- package/bin/agent/agent-runner.mjs +5 -3
- package/bin/agent/main-agent.mjs +70 -0
- package/bin/agent/main-prompt.mjs +17 -0
- package/bin/agent/session-store.mjs +2 -2
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/studio-task-routes.mjs +185 -0
- package/bin/agent/studio-tasks.mjs +156 -0
- package/bin/agent/studio-tools.mjs +3 -2
- package/bin/agent/studio-turn-transaction.mjs +76 -0
- package/bin/cozyclay.mjs +5 -7
- package/dist/app/index.html +7 -6
- package/dist/assets/TaskBoard-DdDXMgZm.js +1 -0
- package/dist/assets/app-B2BpfCl2.js +5088 -0
- package/dist/assets/app-BlwOpest.css +1 -0
- package/dist/assets/compile-worker-D0pKioa0.js +5 -0
- package/dist/assets/landing-y3EqDgSZ.js +1 -0
- package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
- package/dist/assets/motion3-compile-B887n77J.js +1 -0
- package/dist/assets/scene-asset-sync-COGOlF5T.css +1 -0
- package/dist/assets/scene-asset-sync-CQzEdsqu.js +13 -0
- package/dist/assets/task-board-BKaM7d-9.js +14 -0
- package/dist/assets/workflow-B8Dhzexj.js +179 -0
- package/dist/assets/x-bot-tpose-body-BusyR3eg.json +1 -0
- package/dist/assets/y-bot-tpose-body-Dy1VKChZ.json +1 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/greybox-to-video/index.html +2 -3
- package/dist/index.html +7 -57
- package/dist/llms.txt +2 -1
- package/dist/previs-software/index.html +2 -2
- package/dist/privacy/index.html +3 -7
- package/dist/seedance-camera-control/index.html +2 -2
- package/dist/sitemap.xml +5 -5
- package/dist/sw.js +5 -1
- package/dist/thumbs/blank-stage-dark.webp +0 -0
- package/dist/thumbs/blank-stage-light.webp +0 -0
- package/dist/workflow/index.html +5 -4
- package/mcp/mesh-file.mjs +3 -1
- package/mcp/server.mjs +3 -0
- package/package.json +3 -3
- package/src/App.jsx +50 -281
- package/src/analytics.js +3 -148
- package/src/app-stage.jsx +1 -1
- package/src/asset-pane.jsx +5 -10
- package/src/auto-flat.js +10 -0
- package/src/bus-tasks.js +11 -5
- package/src/command-bus.js +1 -1
- package/src/commands/motion3-compile.js +51 -27
- package/src/commands/motion3.js +3 -3
- package/src/commands/project.js +1 -1
- package/src/commands/shot.js +16 -4
- package/src/controls.jsx +0 -11
- package/src/cuts.js +11 -0
- package/src/domains/cast.js +3 -2
- package/src/domains/motion.js +3 -10
- package/src/domains/playback.js +32 -0
- package/src/domains/scenes.js +3 -22
- package/src/domains/shots.js +5 -5
- package/src/dualview.jsx +11 -15
- package/src/hierarchy-panel.css +18 -4
- package/src/hierarchy-panel.jsx +12 -11
- package/src/kits/held.js +3 -2
- package/src/light-blockers.js +2 -2
- package/src/main.jsx +5 -0
- package/src/motion/ground.js +5 -14
- package/src/motion/playback-frame-store.js +79 -0
- package/src/motion/playback-probe.js +16 -0
- package/src/motion/playback-subject.js +7 -0
- package/src/motion/timeline.css +43 -5
- package/src/motion/timeline.jsx +4 -3
- package/src/motion3/body.js +37 -5
- package/src/motion3/carried.js +11 -3
- package/src/motion3/compile-job.js +1 -1
- package/src/motion3/compiler/compile.js +7 -1
- package/src/motion3/compiler/finger-close.js +137 -0
- package/src/motion3/compiler/limbs.js +18 -3
- package/src/motion3/compiler/pose.js +6 -0
- package/src/motion3/compiler/validate-objects.js +17 -2
- package/src/motion3/compiler/validate-parts.js +2 -1
- package/src/motion3/compiler/vocab.js +4 -4
- package/src/motion3/describe.js +2 -2
- package/src/motion3/fix/index.js +5 -3
- package/src/motion3/flight.js +38 -0
- package/src/motion3/free-motion.js +10 -7
- package/src/motion3/load.js +125 -0
- package/src/motion3/mass.js +23 -0
- package/src/motion3/object-keys.js +11 -6
- package/src/motion3/object-pose.js +11 -3
- package/src/motion3/pipeline.js +3 -0
- package/src/motion3/qa/explain.js +3 -1
- package/src/motion3/qa/grasp-checks.js +123 -0
- package/src/motion3/qa/hints-grasp.js +71 -0
- package/src/motion3/qa/index.js +3 -1
- package/src/motion3/qa/object-checks.js +7 -2
- package/src/motion3/qa/patch.js +2 -1
- package/src/motion3/qa/spec-checks.js +10 -1
- package/src/motion3/schema/fields.js +3 -3
- package/src/motion3/schema/topics.js +9 -8
- package/src/motion3/schema/types.js +4 -1
- package/src/motion3/seat.js +84 -0
- package/src/motion3/settle.js +11 -6
- package/src/motion3/shapes.js +70 -0
- package/src/motion3/skills/catch.js +71 -0
- package/src/motion3/skills/index.js +10 -3
- package/src/motion3/skills/info.js +13 -0
- package/src/motion3/skills/throw.js +72 -0
- package/src/object-catalog.jsx +0 -4
- package/src/object-gizmo.jsx +2 -1
- package/src/object-path.js +1 -1
- package/src/panels/ProjectPanel.jsx +42 -21
- package/src/panels/details.css +15 -4
- package/src/popover.js +37 -0
- package/src/posestudio.jsx +15 -10
- package/src/project-browser-art.jsx +19 -0
- package/src/project-browser.css +74 -121
- package/src/project-browser.jsx +36 -60
- package/src/props.jsx +4 -7
- package/src/retired-kinds.js +37 -0
- package/src/scene-objects.js +3 -7
- package/src/scenes.js +4 -5
- package/src/settings-menu.jsx +1 -20
- package/src/shell/BottomDock.jsx +9 -9
- package/src/shell/MenuBar.jsx +1 -9
- package/src/shell/PreferencesDialog.jsx +3 -4
- package/src/shell/ViewportToolbar.jsx +3 -3
- package/src/shell/add-camera-shot.js +11 -0
- package/src/shell/glass-regions.css +3 -2
- package/src/shell/glass.css +0 -5
- package/src/shell/lens-scrub-math.js +51 -0
- package/src/shell/lens-scrub.css +73 -0
- package/src/shell/lens-scrub.jsx +98 -0
- package/src/shell/mode.css +6 -5
- package/src/shell/preferences.css +1 -32
- package/src/shell/shell.css +18 -1
- package/src/shell/studio-shell-context.js +5 -0
- package/src/shot-frame.js +26 -0
- package/src/stage-canvas-rigs.jsx +7 -8
- package/src/stage-characters.jsx +26 -0
- package/src/stage-gizmo.js +43 -0
- package/src/stage-labels-ko.js +0 -6
- package/src/stage-startup-defaults.js +3 -9
- package/src/starter-scenes.js +5 -11
- package/src/studio-agent-commands.js +2 -2
- package/src/studio-agent-protocol.js +2 -7
- package/src/studio-app-binding.js +1 -1
- package/src/studio-elements.js +1 -1
- package/src/styles/themes.css +13 -0
- package/src/styles.css +6 -674
- package/src/text/text-object.jsx +1 -1
- package/src/ui.jsx +8 -16
- package/src/use-embed-bridge.js +3 -6
- package/src/use-render-activity.js +3 -1
- package/src/view-ghosts.jsx +5 -3
- package/src/viewport-visible-rect.js +37 -0
- package/src/workflow/AgentPanel.jsx +75 -42
- package/src/workflow/AgentProgressCard.jsx +1 -1
- package/src/workflow/TaskBoard.jsx +102 -0
- package/src/workflow/TaskInline.jsx +114 -0
- package/src/workflow/agent-client.js +11 -25
- package/src/workflow/agent-panel.css +14 -1
- package/src/workflow/attachment-image.js +10 -3
- package/src/workflow/attachment-video.js +91 -0
- package/src/workflow/task-board.css +98 -0
- package/src/workflow/task-board.js +169 -0
- package/tools/check-eslint.mjs +2 -1
- package/tools/check-knip.mjs +2 -1
- package/tools/dev/pages/greybox-to-video.html +2 -3
- package/tools/dev/pages/previs-software.html +2 -2
- package/tools/dev/pages/privacy.html +3 -7
- package/tools/dev/pages/seedance-camera-control.html +2 -2
- package/tools/dev-full.mjs +11 -2
- package/tools/eslint-baseline.json +6 -6
- package/tools/knip-baseline.json +0 -11
- package/tools/loc-baseline.json +30 -31
- package/tools/metrics-snapshot.mjs +43 -5
- package/tools/render-start-thumbs.mjs +175 -0
- package/tools/run-tests.mjs +7 -14
- package/tools/sitemap.mjs +1 -1
- package/dist/assets/analytics-BibyE5ar.js +0 -6
- package/dist/assets/app-DhRJ-PBz.js +0 -5088
- package/dist/assets/app-f-fkV5uP.css +0 -1
- package/dist/assets/compile-worker-DqiA4AaN.js +0 -5
- package/dist/assets/first-shot-handoff-Qx7SKl38.js +0 -1
- package/dist/assets/landing-DYg87MDY.js +0 -1
- package/dist/assets/scene-asset-sync-DBYQBR0T.js +0 -20
- package/dist/assets/scene-asset-sync-_S8NQl5B.css +0 -1
- package/dist/assets/workflow-BSA22Ofr.js +0 -179
- package/dist/scenes/city-block.cclayproject +0 -1
- package/src/burn-in.js +0 -91
- package/src/camera-tutorial.jsx +0 -520
- package/src/facing-marks.js +0 -88
- package/src/first-shot-handoff.js +0 -44
- package/src/panels/motion.css +0 -197
- package/src/set-pieces.jsx +0 -183
- package/src/tutorial-analytics.js +0 -86
- package/src/tutorial-walk.js +0 -17
- package/src/use-camera-tutorial.js +0 -50
- package/src/use-semantic-state.js +0 -12
- package/tools/metrics/archive-traffic.mjs +0 -138
- 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",
|
|
151
|
-
"mcp_connected", "auto_color", "plan_view",
|
|
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
|
-
"
|
|
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
package/src/asset-pane.jsx
CHANGED
|
@@ -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
|
|
372
|
-
*
|
|
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={
|
|
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"
|
|
412
|
-
const group = folder === "basic" ? "Primitives" : "Set pieces";
|
|
407
|
+
if (folder === "basic") {
|
|
413
408
|
tiles = OBJECT_LIBRARY
|
|
414
|
-
.filter((entry) => entry.group ===
|
|
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) => {
|
package/src/auto-flat.js
ADDED
|
@@ -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
|
-
|
|
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 &&
|
|
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 });
|
package/src/command-bus.js
CHANGED
|
@@ -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:
|
|
2
|
-
// on
|
|
3
|
-
// and never blocks the viewport (the main thread runs the compiler ~4x slower and freezes
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
|
30
|
-
const
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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 =
|
|
45
|
-
if (!job) return;
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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; },
|
package/src/commands/motion3.js
CHANGED
|
@@ -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;
|
package/src/commands/project.js
CHANGED
|
@@ -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", "
|
|
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]);
|
package/src/commands/shot.js
CHANGED
|
@@ -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:
|
|
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() :
|
|
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 ||
|
|
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;
|
package/src/domains/cast.js
CHANGED
|
@@ -546,7 +546,7 @@ export function useCast(appContext) {
|
|
|
546
546
|
return current.length;
|
|
547
547
|
}
|
|
548
548
|
|
|
549
|
-
/**
|
|
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
|
-
|
|
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() {
|