cozyclay 2.0.0 → 2.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 +112 -0
- package/README.md +17 -2
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/bin/agent/agent-routes.mjs +219 -38
- package/bin/agent/agent-runner.mjs +98 -4
- package/bin/agent/codex-client.mjs +2 -1
- package/bin/agent/fal-contract.mjs +0 -17
- package/bin/agent/h3-preservation.mjs +6 -8
- package/bin/agent/provider-keys.mjs +1 -1
- package/bin/agent/providers.mjs +124 -3
- package/bin/agent/script-tool.mjs +74 -0
- package/bin/agent/session-store.mjs +5 -5
- package/bin/agent/studio-prompt.mjs +2 -1
- package/bin/agent/studio-rebase.mjs +314 -0
- package/bin/agent/studio-tools.mjs +21 -14
- package/bin/agent/supervisor.mjs +311 -0
- package/bin/agent/video-adapters.mjs +0 -7
- package/bin/codex-auth.mjs +6 -5
- package/bin/cozyclay.mjs +12 -3
- package/bin/live/cli.mjs +2 -3
- package/bin/live/client.mjs +1 -1
- package/bin/live/studio.mjs +5 -2
- package/bin/mcp-runtime.mjs +12 -2
- package/bin/telemetry-state.mjs +3 -3
- package/dist/app/index.html +6 -6
- package/dist/assets/analytics-B41FmPYI.js +1 -0
- package/dist/assets/app-Do-Og5Pu.css +1 -0
- package/dist/assets/app-DwP-tl2x.js +4920 -0
- package/dist/assets/{demo-ByeYp-dY.js → demo-Dt0qZfS7.js} +1 -1
- package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-BlT33CGf.js} +1 -1
- package/dist/assets/{landing-BaXkpFip.js → landing-BSesrCpL.js} +1 -1
- package/dist/assets/scene-asset-sync-BCWDJB1d.js +19 -0
- package/dist/assets/{shot-prompt-CbqWcWYi.css → scene-asset-sync-DjJ_cJ2R.css} +1 -1
- package/dist/assets/{ticket-BcmsnHn5.js → ticket-jqMhqL88.js} +1 -1
- package/dist/assets/workflow-Br-K7Srs.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +328 -222
- package/dist/sitemap.xml +5 -5
- package/dist/workflow/index.html +4 -4
- package/mcp/LIVE-PROTOCOL.md +23 -2
- package/mcp/README.md +37 -2
- package/mcp/ardy-prompts.mjs +1 -1
- package/mcp/image-file.mjs +132 -0
- package/mcp/live-hub.mjs +2 -2
- package/mcp/package.json +1 -0
- package/mcp/runtime/package-lock.json +7 -0
- package/mcp/runtime/package.json +1 -0
- package/mcp/server.mjs +0 -6
- package/mcp/tool-handlers.mjs +289 -87
- package/package.json +11 -2
- package/src/App.jsx +801 -259
- package/src/agent-image-client.js +94 -0
- package/src/analytics.js +47 -3
- package/src/app-stage.jsx +129 -2745
- package/src/ardy/auto-physics.js +6 -16
- package/src/ardy/collision-blockers.js +3 -7
- package/src/ardy/fix-collisions.js +5 -35
- package/src/ardy/ik-drag.js +86 -5
- package/src/ardy/ik-key-json.js +3 -1
- package/src/ardy/ik.js +15 -13
- package/src/ardy/motion-calibration.js +1 -1
- package/src/ardy/npz.js +0 -8
- package/src/ardy/physics-panel.jsx +3 -2
- package/src/ardy/physics-review.js +3 -2
- package/src/ardy/physics-support.js +1 -1
- package/src/ardy/physics-surface.js +1 -1
- package/src/ardy/platform-fit-panel.jsx +2 -1
- package/src/ardy/playback.js +1 -5
- package/src/ardy/range-pin.js +2 -2
- package/src/ardy/root-drop.js +0 -1
- package/src/ardy/timeline.css +196 -3
- package/src/ardy/timeline.jsx +117 -29
- package/src/ardy/to-cskel27.js +0 -2
- package/src/ardy/waypoints.js +1 -4
- package/src/ardy-bridge-contract.js +281 -0
- package/src/asset-pane.jsx +19 -9
- package/src/auto-color.js +1 -1
- package/src/burn-in.js +1 -1
- package/src/camera-block.js +1 -1
- package/src/camera-follow.js +75 -32
- package/src/camera-move.js +0 -6
- package/src/camera-tutorial.jsx +43 -24
- package/src/character-kind.js +79 -0
- package/src/command-bus.js +76 -17
- package/src/commands/cast.js +31 -7
- package/src/commands/elements/object.js +1 -1
- package/src/commands/elements/shot.js +1 -1
- package/src/commands/elements.js +1 -1
- package/src/commands/export.js +3 -3
- package/src/commands/motion.js +2 -2
- package/src/commands/objects.js +105 -10
- package/src/commands/project.js +16 -3
- package/src/commands/sculpt-images.js +53 -0
- package/src/commands/shot.js +88 -10
- package/src/commands/view.js +10 -4
- package/src/coplanar-depth.js +216 -0
- package/src/cuts.js +7 -6
- package/src/domains/cast.js +60 -43
- package/src/domains/motion/motion-intent-codec.js +50 -0
- package/src/domains/motion/motion-range-pin-rules.js +23 -0
- package/src/domains/motion/motion-video-capture.js +664 -0
- package/src/domains/motion.js +253 -742
- package/src/domains/objects.js +106 -5
- package/src/domains/scenes.js +89 -32
- package/src/domains/shots.js +33 -7
- package/src/error-boundary.jsx +1 -1
- package/src/execution-telemetry.js +5 -5
- package/src/facing-marks.js +14 -1
- package/src/first-success-guide.jsx +35 -4
- package/src/gizmo-claim.js +3 -1
- package/src/grid-floor.jsx +1 -4
- package/src/hierarchy-model.js +48 -35
- package/src/hierarchy-panel.css +1 -0
- package/src/hierarchy-panel.jsx +54 -102
- package/src/history.js +0 -2
- package/src/i2v-motion-client.js +1 -4
- package/src/ime.js +6 -0
- package/src/keyframe-pack.js +15 -6
- package/src/line-edit.js +4 -4
- package/src/live-control.js +6 -6
- package/src/matte-editor.js +2 -8
- package/src/matte.js +0 -19
- package/src/mesh-graph-clone.js +16 -2
- package/src/mesh-sniff.js +0 -1
- package/src/monotone-cubic.js +40 -0
- package/src/motion/generation.js +1 -1
- package/src/motion-store.js +4 -4
- package/src/motion-trail.js +5 -11
- package/src/multimodel-ingest.js +1 -3
- package/src/object-catalog.jsx +7 -5
- package/src/object-gizmo.jsx +45 -31
- package/src/object-path.js +310 -67
- package/src/object-travel.js +224 -0
- package/src/otio.js +8 -3
- package/src/panels/CharacterTransformPanel.jsx +24 -6
- package/src/panels/ObjectTransformPanel.jsx +4 -3
- package/src/panels/PoseImagePanel.jsx +205 -0
- package/src/panels/PromptBlocksPanel.jsx +3 -1
- package/src/panels/ProxyFigurePanel.jsx +56 -0
- package/src/panels/ReferenceImageField.jsx +1 -1
- package/src/panels/RigControlPanel.jsx +24 -3
- package/src/panels/RigPanel.jsx +2 -1
- package/src/panels/SculptPanel.jsx +405 -0
- package/src/panels/SubjectBox.jsx +1 -1
- package/src/panels/SubjectsPanel.jsx +0 -2
- package/src/panels/VideoCapturePanel.jsx +2 -1
- package/src/panels/pose-image.css +42 -0
- package/src/panels/pose.css +3 -0
- package/src/panels/sculpt.css +162 -0
- package/src/plan-diagram.js +184 -0
- package/src/planview.jsx +127 -80
- package/src/playground.js +3 -5
- package/src/pose-fade.js +77 -0
- package/src/pose-gizmo.css +23 -0
- package/src/pose-gizmo.js +96 -0
- package/src/posestudio.jsx +134 -83
- package/src/project-browser.css +53 -0
- package/src/project-browser.jsx +52 -3
- package/src/project.js +55 -24
- package/src/props.jsx +177 -36
- package/src/proxy-figure.jsx +69 -0
- package/src/range-pin-panel.jsx +0 -1
- package/src/receipt-findings.js +20 -0
- package/src/render-passes.js +0 -1
- package/src/resource-status.jsx +1 -1
- package/src/root-path.js +48 -0
- package/src/route-owner.js +52 -0
- package/src/scene-asset-cache.js +1 -2
- package/src/scene-assets.js +12 -4
- package/src/scene-fbx.js +1 -1
- package/src/scene-mesh-cache.js +0 -1
- package/src/scene-objects.js +319 -19
- package/src/scenes.js +62 -17
- package/src/sculpt-blueprint.js +65 -0
- package/src/sculpt-compare.js +384 -0
- package/src/sculpt-geometry.js +141 -0
- package/src/sculpt-mode.js +168 -0
- package/src/sculpt-recipe.js +385 -0
- package/src/sculpt-run.js +351 -0
- package/src/sculpt-stage.jsx +221 -0
- package/src/settings-menu.jsx +1 -1
- package/src/shell/BoardSlot.jsx +341 -0
- package/src/shell/BottomDock.jsx +58 -14
- package/src/shell/DetailsSlot.jsx +26 -6
- package/src/shell/MenuBar.jsx +78 -6
- package/src/shell/OutlinerSlot.jsx +3 -1
- package/src/shell/TopBar.jsx +17 -3
- package/src/shell/ViewportToolbar.jsx +18 -3
- package/src/shell/dock.css +239 -0
- package/src/shell/glass-regions.css +3 -2
- package/src/shell/log-store.js +4 -4
- package/src/shell/mode.css +20 -0
- package/src/shell/studio-shell-context.js +4 -1
- package/src/shot-authoring.js +69 -6
- package/src/stable-items.js +1 -1
- package/src/stage-attach-frames.js +169 -0
- package/src/stage-canvas-rigs.jsx +402 -0
- package/src/stage-capture.jsx +216 -0
- package/src/stage-characters.jsx +302 -0
- package/src/stage-editor-gizmos.jsx +1229 -0
- package/src/stage-labels-ko.js +204 -0
- package/src/stage-startup-defaults.js +182 -0
- package/src/starter-scenes.js +1 -1
- package/src/storyboard.js +24 -5
- package/src/studio-actions.js +42 -9
- package/src/studio-agent-commands.js +186 -23
- package/src/studio-agent-context.js +11 -9
- package/src/studio-agent-motion.js +2 -1
- package/src/studio-agent-protocol.js +56 -23
- package/src/studio-app-binding.js +52 -23
- package/src/studio-contact-sheet.js +3 -3
- package/src/studio-elements.js +11 -2
- package/src/studio-geometry-facts.js +114 -0
- package/src/styles.css +111 -10
- package/src/theme.js +1 -1
- package/src/timeline-fps.js +13 -0
- package/src/ui-scrub.js +4 -4
- package/src/ui.jsx +6 -4
- package/src/use-asset-shelf.js +60 -0
- package/src/use-camera-tutorial.js +50 -0
- package/src/use-embed-bridge.js +12 -0
- package/src/use-first-success-prompt.js +7 -0
- package/src/use-i2v-capture-modal.js +15 -0
- package/src/use-sculpt-mode.js +95 -0
- package/src/workflow/AgentPanel.jsx +110 -20
- package/src/workflow/WorkflowBuilder.jsx +1 -1
- package/src/workflow/agent-client.js +113 -45
- package/src/workflow/agent-panel.css +35 -0
- package/src/workflow/attachment-image.js +1 -1
- package/src/workflow/canvas-commands.js +3 -3
- package/src/workflow/cozy-scene-node.js +3 -3
- package/src/workflow/local-workflow.js +2 -2
- package/src/workflow/motion-input.js +3 -3
- package/src/workflow/node-schema.js +1 -1
- package/src/workflow/scene-asset-sync.js +2 -9
- package/src/workflow/shot-prompt-node.js +1 -1
- package/src/workflow/video-contract.js +1 -1
- package/src/workflow/workflow-resources.js +0 -8
- package/src/workflow-send.js +87 -0
- package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
- package/tools/check-eslint.mjs +57 -0
- package/tools/check-knip.mjs +60 -0
- package/tools/check-loc.mjs +54 -0
- package/tools/demo-worker/api-client.mjs +7 -13
- package/tools/demo-worker/generate.mjs +2 -4
- package/tools/eslint-baseline.json +23 -0
- package/tools/kimodo/effector-constraints.mjs +4 -4
- package/tools/kimodo/generate.mjs +1 -1
- package/tools/knip-baseline.json +331 -0
- package/tools/loc-baseline.json +126 -0
- package/tools/process-supervisor.mjs +1 -1
- package/tools/projflow/replay.mjs +4 -4
- package/tools/projflow/runner.mjs +3 -3
- package/tools/qa/studio-control-count.mjs +2 -0
- package/tools/run-tests.mjs +96 -7
- package/dist/assets/analytics-MVZOEW8O.js +0 -1
- package/dist/assets/app-C1sdVbg4.css +0 -1
- package/dist/assets/app-CA55tcI3.js +0 -4876
- package/dist/assets/shot-prompt-Yrt99wZx.js +0 -17
- package/dist/assets/workflow-dNPGY9xz.js +0 -179
package/src/commands/objects.js
CHANGED
|
@@ -3,7 +3,11 @@ import { studioActionDeclaration } from "../studio-actions.js";
|
|
|
3
3
|
import { characterOf, fail } from "./shared.js";
|
|
4
4
|
import { elementSetSchema, registerElementSet } from './elements.js';
|
|
5
5
|
import './elements/object.js';
|
|
6
|
-
import { createSceneObject, updateSceneObject, removeSceneObject, setSceneObjectParent, descendantsOf, normalizeSceneObject } from '../scene-objects.js';
|
|
6
|
+
import { createSceneObject, updateSceneObject, removeSceneObject, setSceneObjectParent, descendantsOf, normalizeSceneObject, groupUnderNewEmpty, objectPatchFields, createSculptObject, SCULPT_KIND } from '../scene-objects.js';
|
|
7
|
+
import { expandSculptParts, normalizeSculptRecipe, patchSculptRecipe } from '../sculpt-recipe.js';
|
|
8
|
+
import { rankingRow, scoreSculptAgainstReference } from '../sculpt-compare.js';
|
|
9
|
+
import { browserSculptImages, shrinkRgba } from './sculpt-images.js';
|
|
10
|
+
import { clampWarning } from '../receipt-findings.js';
|
|
7
11
|
import { STUDIO_TOOL_SCHEMAS, StudioSchemas } from '../studio-agent-protocol.js';
|
|
8
12
|
|
|
9
13
|
const id = StudioSchemas.TargetGuard.properties.targetId;
|
|
@@ -14,15 +18,24 @@ const placement = input({ x: number, y: number, z: number, rot: number }, []);
|
|
|
14
18
|
const setInput = elementSetSchema('object');
|
|
15
19
|
const scale = setInput.properties.set.properties.scale;
|
|
16
20
|
setInput.properties.set.properties.scale = { oneOf: [number, scale] };
|
|
21
|
+
const PATCH_HINTS = { scale: 'use scaleX, scaleY, scaleZ', rotY: 'use rot for yaw', yaw: 'use rot for yaw', rotation: 'use rot, rotX, rotZ', position: 'use x, y, z', parent: 'use object.group or object.set parent', parentId: 'use object.group or object.set parent' };
|
|
17
22
|
const mutation = (id, label, input) => ({ id, label, description: label, kind: 'mutation', undoDomain: 'objects', input });
|
|
18
23
|
const semantic = [
|
|
19
24
|
mutation('object.set', 'Set object fields', setInput),
|
|
20
|
-
mutation('object.add', 'Add object', input({ kind: { type: 'string' }, placement, name: { type: 'string' }, parent: id }, ['kind'])),
|
|
25
|
+
mutation('object.add', 'Add object', input({ kind: { type: 'string' }, placement, name: { type: 'string' }, parent: id, first: { type: 'boolean' } }, ['kind'])),
|
|
26
|
+
// A clay prop from a recipe of soft parts (docs/sculpt-from-image.md). With `id` it re-sculpts that
|
|
27
|
+
// sculpt in place (one undo); without, it creates one. The recipe is checked by sculpt-recipe.js.
|
|
28
|
+
{ ...mutation('object.sculpt', 'Sculpt a clay prop from a recipe', input({ recipe: { type: 'object', properties: {}, additionalProperties: true }, edits: { type: 'array', minItems: 1, maxItems: 48, items: { type: 'object', properties: {}, additionalProperties: true } }, id, name: { type: 'string', maxLength: 240 }, placement, parent: id }, [])),
|
|
29
|
+
description: 'Create a sculpt (or re-sculpt one by id) from a recipe, or patch a sculpt by id with edits [{ set: partId, ...fields } | { add: part } | { remove: partId }]. Recipe { version: 1, parts: [{ id, shape: blob|box|cylinder|torus|frame, size: [x,y,z] m, position, rotation (deg), color, parent, mirror, roundness, taper, border }] }. Floor is y=0, front +z, the subject\'s left +x; mirror reflects across x=0.' },
|
|
21
30
|
mutation('object.remove', 'Remove objects', input({ ids })),
|
|
22
31
|
mutation('object.rename', 'Rename object', input({ id, name: { type: 'string', maxLength: 240 } })),
|
|
23
32
|
mutation('object.group', 'Group objects', input({ parent: id, children: ids })),
|
|
24
33
|
mutation('object.ungroup', 'Ungroup objects', input({ children: ids })),
|
|
25
|
-
|
|
34
|
+
// The Outliner's "Group under new Empty": the Empty and the reparent are one write, so one undo.
|
|
35
|
+
{ ...mutation('object.groupUnderEmpty', 'Group object under a new Empty', input({ id })), exposure: 'ui-only' },
|
|
36
|
+
{ ...mutation('object.update', 'Update object', input({ id, patch: { type: 'object', properties: {}, additionalProperties: true } }, [])), agentHidden: true },
|
|
37
|
+
// agentHidden: object.update silently ignores unknown keys, so the agent's action index and inspect_studio leave it out
|
|
38
|
+
// and the prompt points at object.set / arrange_objects; MCP, the CLI and the UI keep it.
|
|
26
39
|
mutation('objects.arrange', 'Arrange objects', STUDIO_TOOL_SCHEMAS.arrange_objects),
|
|
27
40
|
];
|
|
28
41
|
const batch = { id: 'objects.batch', label: 'Batch objects', description: 'Apply legacy object operations in one retained entry.', kind: 'job', domain: 'objects',
|
|
@@ -37,8 +50,18 @@ const assetImport = { ...studioActionDeclaration('asset.import'), kind: 'job', d
|
|
|
37
50
|
input({ assetId: id, placement, placeAs: assetProperties.placeAs }, ['assetId', 'placeAs']),
|
|
38
51
|
input({ fileToken: id, placeAs: { type: 'string', enum: ['mesh', 'cutout'] } }, ['fileToken', 'placeAs']),
|
|
39
52
|
] } };
|
|
53
|
+
// Read-only: a job that never commits, so its receipt is `completed` with the score as output,
|
|
54
|
+
// authored false and no undo. The scoring is MCP compare_sculpt's (sculpt-compare.js).
|
|
55
|
+
const openObject = { type: 'object', properties: {}, additionalProperties: true };
|
|
56
|
+
const sculptCompare = { id: 'sculpt.compare', label: 'Compare a sculpt with its reference', kind: 'job', timeoutMs: 60000,
|
|
57
|
+
description: 'Score a sculpt\'s silhouette against a reference image without changing anything: IoU, width-to-height delta, missing area by height band (top, middle, bottom), parts that stick out, and a reference | overlay sheet (grey both, red missing, blue extra) as sheetDataUrl. reference is { assetId } of a project image or { dataUrl }. yaw is the reference camera azimuth in degrees (0 in front, positive toward the subject\'s own left); omitted tries -45..45 and keeps the best. candidates (up to 12 { label, edits }) are scored from the same view and ranked against the current sculpt; none is applied. Apply the winner with object.sculpt { id, edits }.',
|
|
58
|
+
input: input({ id, reference: { oneOf: [input({ assetId: id }), input({ dataUrl: { type: 'string', pattern: '^data:image/(png|jpeg|webp|gif);base64,', maxLength: 6000000 } })] },
|
|
59
|
+
yaw: { type: 'number', minimum: -180, maximum: 180 }, elevation: { type: 'number', minimum: -60, maximum: 60 },
|
|
60
|
+
candidates: { type: 'array', minItems: 1, maxItems: 12, items: input({ label: { type: 'string', maxLength: 60 }, edits: { type: 'array', minItems: 1, maxItems: 48, items: openObject } }) } }, ['id', 'reference']) };
|
|
61
|
+
// A receipt is at most 8 KiB, so the sheet is shrunk until the whole output fits this.
|
|
62
|
+
const SCULPT_COMPARE_OUTPUT_BYTES = 6500;
|
|
40
63
|
const matte = { id: 'object.matte', label: 'Apply object matte', description: 'Prepare derived assets, then publish one fenced object edit.', kind: 'job', domain: 'objects', exposure: 'ui-only', input: input({ objectId: id }) };
|
|
41
|
-
export const declarations = Object.freeze([...semantic, replace, batch, ...['object.attach', 'object.detach', 'object.duplicate'].map(studioActionDeclaration), matte, assetImport]);
|
|
64
|
+
export const declarations = Object.freeze([...semantic, replace, batch, ...['object.attach', 'object.detach', 'object.duplicate'].map(studioActionDeclaration), matte, sculptCompare, assetImport]);
|
|
42
65
|
|
|
43
66
|
export function register(registry, ports) {
|
|
44
67
|
const objectOf = objectId => ports.state().objects.find(object => object.id === objectId)
|
|
@@ -53,10 +76,43 @@ export function register(registry, ports) {
|
|
|
53
76
|
const object = createSceneObject(args.kind, before, args.placement);
|
|
54
77
|
if (!object) fail('INVALID_ARGUMENT', `Unknown object kind: ${args.kind}`);
|
|
55
78
|
const placed = updateSceneObject([object], object.id, { ...(args.placement ?? {}), ...(args.name === undefined ? {} : { name: args.name }) })[0];
|
|
56
|
-
|
|
79
|
+
// `first`: the Outliner lists Props in record order, so a UI-made object
|
|
80
|
+
// goes to the top where it is seen, not below a long expanded group.
|
|
81
|
+
const next = args.first ? [placed, ...before] : [...before, placed];
|
|
57
82
|
owned().write(args.parent === undefined ? next : setSceneObjectParent(next, placed.id, args.parent));
|
|
58
83
|
return result(before, 'Added object.');
|
|
59
84
|
},
|
|
85
|
+
'object.sculpt': args => {
|
|
86
|
+
const before = owned().read();
|
|
87
|
+
if ((args.recipe === undefined) === (args.edits === undefined)) fail('INVALID_ARGUMENT', 'Give exactly one of recipe (whole) or edits (part by part).');
|
|
88
|
+
if (args.edits !== undefined && args.id === undefined) fail('INVALID_ARGUMENT', 'edits patch an existing sculpt: give its id.');
|
|
89
|
+
let refused, changedParts = null;
|
|
90
|
+
if (args.edits !== undefined) {
|
|
91
|
+
const target = objectOf(args.id);
|
|
92
|
+
if (target.renderer !== SCULPT_KIND) fail('INVALID_ARGUMENT', `${args.id} is a ${target.renderer}, not a sculpt.`);
|
|
93
|
+
refused = patchSculptRecipe(target.recipe, args.edits);
|
|
94
|
+
changedParts = refused.changed;
|
|
95
|
+
} else refused = normalizeSculptRecipe(args.recipe);
|
|
96
|
+
if (!refused.ok) fail('INVALID_ARGUMENT', `Recipe refused at ${refused.error}`);
|
|
97
|
+
const patch = { ...(args.placement ?? {}), ...(args.name === undefined ? {} : { name: args.name }) };
|
|
98
|
+
let objectId = args.id;
|
|
99
|
+
if (objectId !== undefined) {
|
|
100
|
+
const target = objectOf(objectId);
|
|
101
|
+
if (target.renderer !== SCULPT_KIND) fail('INVALID_ARGUMENT', `${objectId} is a ${target.renderer}, not a sculpt; omit id to create a new sculpt.`);
|
|
102
|
+
owned().write(updateSceneObject(before, objectId, { ...patch, recipe: refused.recipe }));
|
|
103
|
+
} else {
|
|
104
|
+
if (args.parent !== undefined) objectOf(args.parent);
|
|
105
|
+
const made = createSculptObject({ recipe: refused.recipe, name: args.name }, before, args.placement);
|
|
106
|
+
objectId = made.object.id;
|
|
107
|
+
const next = [...before, updateSceneObject([made.object], objectId, patch)[0]];
|
|
108
|
+
owned().write(args.parent === undefined ? next : setSceneObjectParent(next, objectId, args.parent));
|
|
109
|
+
}
|
|
110
|
+
const sculpted = owned().read().find(row => row.id === objectId);
|
|
111
|
+
const parts = expandSculptParts(sculpted.recipe).length;
|
|
112
|
+
const size = `${sculpted.footprint.width} × ${sculpted.footprint.depth} m, ${sculpted.height} m tall`;
|
|
113
|
+
return { affectedIds: [objectId, ...result(before, '').affectedIds.filter(row => row !== objectId)],
|
|
114
|
+
summary: `${args.id === undefined ? 'Sculpted' : changedParts ? `Patched ${changedParts.join(', ')} on` : 'Re-sculpted'} ${sculpted.name} (${objectId}): ${parts} parts, ${size}.` };
|
|
115
|
+
},
|
|
60
116
|
'object.remove': ({ ids }) => {
|
|
61
117
|
const before = owned().read(); ids.forEach(objectOf);
|
|
62
118
|
owned().write(ids.reduce((rows, id) => removeSceneObject(rows, id), before));
|
|
@@ -67,10 +123,16 @@ export function register(registry, ports) {
|
|
|
67
123
|
owned().write(updateSceneObject(before, id, { name }));
|
|
68
124
|
return result(before, 'Renamed object.');
|
|
69
125
|
},
|
|
70
|
-
'object.update': ({ id, patch }) => {
|
|
71
|
-
objectOf(id)
|
|
72
|
-
|
|
73
|
-
|
|
126
|
+
'object.update': ({ id, patch }, context) => {
|
|
127
|
+
const target = objectOf(id), before = owned().read();
|
|
128
|
+
// Wire callers get an error for a key nothing reads; the UI's own patches stay lenient.
|
|
129
|
+
const unknown = Object.keys(patch ?? {}).filter(key => !objectPatchFields(target).includes(key));
|
|
130
|
+
if (unknown.length && context?.origin && context.origin !== 'ui') fail('INVALID_ARGUMENT', `object.update cannot set ${unknown.join(', ')}${unknown.some(key => PATCH_HINTS[key]) ? ` (${unknown.filter(key => PATCH_HINTS[key]).map(key => `${key}: ${PATCH_HINTS[key]}`).join('; ')})` : ''}. Supported fields: ${objectPatchFields(target).join(', ')}.`);
|
|
131
|
+
const next = updateSceneObject(before, id, patch);
|
|
132
|
+
owned().write(next);
|
|
133
|
+
const kept = next.find(row => row.id === id);
|
|
134
|
+
const warning = clampWarning(id, Object.fromEntries(['x', 'y', 'z', 'scaleX', 'scaleY', 'scaleZ'].filter(key => Object.hasOwn(patch ?? {}, key)).map(key => [key, Number(patch[key])])), kept);
|
|
135
|
+
return { ...result(before, 'Updated object.'), ...(warning ? { warnings: [warning] } : {}) };
|
|
74
136
|
},
|
|
75
137
|
'object.group': ({ parent, children }) => {
|
|
76
138
|
objectOf(parent); children.forEach(objectOf);
|
|
@@ -84,9 +146,17 @@ export function register(registry, ports) {
|
|
|
84
146
|
owned().write(children.reduce((rows, id) => setSceneObjectParent(rows, id, null), before));
|
|
85
147
|
return result(before, 'Ungrouped objects.');
|
|
86
148
|
},
|
|
149
|
+
'object.groupUnderEmpty': ({ id }) => {
|
|
150
|
+
const object = objectOf(id); const before = owned().read();
|
|
151
|
+
if (object.attach) fail('CAPABILITY_MISSING', 'Detach the object from its character before grouping it.');
|
|
152
|
+
const grouped = groupUnderNewEmpty(before, id);
|
|
153
|
+
if (!grouped) fail('INVALID_ARGUMENT', 'Cannot group this object.');
|
|
154
|
+
owned().write(grouped.objects);
|
|
155
|
+
return { affectedIds: [grouped.emptyId, id], summary: 'Grouped the object under a new Empty.' };
|
|
156
|
+
},
|
|
87
157
|
'objects.arrange': args => {
|
|
88
158
|
const plan = owned().arrange(args);
|
|
89
|
-
return { affectedIds: plan.affectedIds, summary: 'Arranged objects.' };
|
|
159
|
+
return { affectedIds: plan.affectedIds, summary: 'Arranged objects.', checks: plan.checks, warnings: plan.warnings };
|
|
90
160
|
},
|
|
91
161
|
};
|
|
92
162
|
// Scalar scale is a convenience at the command boundary; the registry still
|
|
@@ -157,6 +227,31 @@ export function register(registry, ports) {
|
|
|
157
227
|
await owned().applyMatte(objectId, context);
|
|
158
228
|
return { affectedIds: [objectId], summary: 'Applied the object matte.' };
|
|
159
229
|
} });
|
|
230
|
+
registry.register({ ...sculptCompare, available, run: async ({ id: objectId, reference, yaw, elevation = 0, candidates }, context) => {
|
|
231
|
+
const target = objectOf(objectId);
|
|
232
|
+
if (target.renderer !== SCULPT_KIND || !target.recipe) fail('INVALID_ARGUMENT', `${objectId} is a ${target.renderer}, not a sculpt.`);
|
|
233
|
+
const images = ports.sculptImages ?? browserSculptImages;
|
|
234
|
+
let image, scored;
|
|
235
|
+
try { image = await images.decode(reference); }
|
|
236
|
+
catch (error) { fail('INVALID_ARGUMENT', `Could not read the reference image: ${error?.message || error}`); }
|
|
237
|
+
context?.signal?.throwIfAborted();
|
|
238
|
+
try { scored = scoreSculptAgainstReference(target.recipe, image, { yaw, elevation, candidates }); }
|
|
239
|
+
catch (error) { fail('INVALID_ARGUMENT', error?.message || String(error)); }
|
|
240
|
+
const { result, ranked, refused } = scored;
|
|
241
|
+
const output = { id: objectId, ...result.report };
|
|
242
|
+
if (ranked) Object.assign(output, { ranking: [...ranked.map(row => rankingRow(row, result)), ...refused.map(({ label, error }) => ({ label, refused: error }))],
|
|
243
|
+
sheetLabel: ranked[0].label, note: `Nothing was applied. Apply the winner with object.sculpt { id: "${objectId}", edits }.` });
|
|
244
|
+
const sheet = (ranked ? ranked[0].result : result).sheet;
|
|
245
|
+
const room = SCULPT_COMPARE_OUTPUT_BYTES - JSON.stringify(output).length;
|
|
246
|
+
// The full-size sheet is always tried, however narrow; shrinking stops at 64 px.
|
|
247
|
+
for (let factor = 1; factor === 1 || sheet.width / factor >= 64; factor *= 2) {
|
|
248
|
+
const dataUrl = await images.encodePng(factor === 1 ? sheet : shrinkRgba(sheet, factor));
|
|
249
|
+
if (dataUrl.length + 40 <= room) { Object.assign(output, { sheetDataUrl: dataUrl, ...(factor > 1 ? { sheetScale: 1 / factor } : {}) }); break; }
|
|
250
|
+
}
|
|
251
|
+
if (!output.sheetDataUrl) output.sheetOmitted = 'The overlay sheet does not fit in a receipt at a readable size; read missingByBand and extraByPart.';
|
|
252
|
+
const lead = ranked ? `best ${ranked[0].label} at IoU ${ranked[0].result.report.iou} (current ${result.report.iou}), ${candidates.length} candidates scored, none applied` : `IoU ${result.report.iou}`;
|
|
253
|
+
return { affectedIds: [], output, summary: `Compared ${target.name || objectId} with the reference at yaw ${result.report.view.yaw}: ${lead}.` };
|
|
254
|
+
} });
|
|
160
255
|
registry.register({ ...assetImport, available: () => true,
|
|
161
256
|
run: async (args, context) => {
|
|
162
257
|
const { source, ...options } = args;
|
package/src/commands/project.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { studioActionDeclaration } from "../studio-actions.js";
|
|
2
2
|
import { fail } from "./shared.js";
|
|
3
|
+
import { PREVIS_MODES } from "../project.js";
|
|
3
4
|
|
|
4
5
|
const input = (properties = {}, required = []) => ({ type: "object", properties, required, additionalProperties: false });
|
|
5
6
|
const name = { type: "string", minLength: 1, maxLength: 240 };
|
|
@@ -8,12 +9,13 @@ const document = (id, label, properties = {}) => ({ id, label, description: labe
|
|
|
8
9
|
const rename = { id: "project.rename", label: "Rename project", description: "Rename the project in one retained undo entry.", kind: "mutation", undoDomain: "scenes", input: input({ name }, ["name"]) };
|
|
9
10
|
const save = { ...studioActionDeclaration("project.save"), input: input({ name }) };
|
|
10
11
|
const saveAs = { ...save, id: "project.saveAs", label: "Save project as" };
|
|
12
|
+
const exportAsAnimation = studioActionDeclaration("project.exportAsAnimation");
|
|
11
13
|
const opening = document("project.open", "Open a project file", { serialized: { type: "string", maxLength: 400_000_000 }, handleToken: token, projectToken: token });
|
|
12
|
-
const fresh = document("project.new", "Start a new project", { name });
|
|
13
|
-
const starter = document("project.openStarter", "Open a starter project", { id: token, source: { type: "string", enum: ["starter", "tutorial", "launch"] } });
|
|
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 });
|
|
14
16
|
const restore = { ...document("project.restore", "Restore the remembered project", { handleToken: token }), exposure: "ui-only" };
|
|
15
17
|
const browse = { id: "project.browse", label: "Browse projects", description: "Show the project browser.", kind: "transient", exposure: "ui-only", input: input() };
|
|
16
|
-
export const declarations = Object.freeze([rename, save, saveAs, opening, fresh, starter, restore, browse]);
|
|
18
|
+
export const declarations = Object.freeze([rename, save, saveAs, exportAsAnimation, opening, fresh, starter, restore, browse]);
|
|
17
19
|
|
|
18
20
|
export function register(registry, ports) {
|
|
19
21
|
const owner = () => ports.storeDomain?.("scenes");
|
|
@@ -44,6 +46,17 @@ export function register(registry, ports) {
|
|
|
44
46
|
? `This browser has no file access, so the project ${saved.name} was downloaded as ${saved.fileName}.`
|
|
45
47
|
: `Saved the project ${saved.name} to ${saved.fileName}.` };
|
|
46
48
|
} });
|
|
49
|
+
registry.register({ ...exportAsAnimation, available: () => true,
|
|
50
|
+
run: async (_args, context) => {
|
|
51
|
+
const saved = await owner().exportAsAnimationProject(context);
|
|
52
|
+
if (saved?.cancelled) fail("TARGET_NOT_READY", "Not exported: the user closed the file picker.");
|
|
53
|
+
if (!saved?.saved) fail("TARGET_NOT_READY", saved?.failure === "not-storyboard"
|
|
54
|
+
? "Export as Animation project is available only for storyboard projects."
|
|
55
|
+
: "Not exported: writing the animation project file failed.");
|
|
56
|
+
return { affectedIds: [], output: { fileName: saved.fileName }, summary: saved.downloaded
|
|
57
|
+
? `This browser has no file access, so the animation project ${saved.name} was downloaded as ${saved.fileName}.`
|
|
58
|
+
: `Exported the storyboard as ${saved.name} to ${saved.fileName}.` };
|
|
59
|
+
} });
|
|
47
60
|
for (const declaration of [opening, fresh, starter, restore, browse]) registry.register({ ...declaration,
|
|
48
61
|
available, run: async (args, context) => {
|
|
49
62
|
const result = await owner().projectAction(declaration.id, args, context);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The image port behind sculpt.compare: a reference ({ assetId } from the asset
|
|
2
|
+
// store, or { dataUrl }) decoded to RGBA, and an RGBA sheet encoded as a PNG data
|
|
3
|
+
// URL. The editor uses the browser one below; a Node test hands in its own as
|
|
4
|
+
// `ports.sculptImages`, so the scoring runs on the same pixels in both.
|
|
5
|
+
import { getAsset, openAssetDb } from "../scene-assets.js";
|
|
6
|
+
|
|
7
|
+
function canvas(width, height) {
|
|
8
|
+
if (typeof OffscreenCanvas === "function") return new OffscreenCanvas(width, height);
|
|
9
|
+
if (globalThis.document?.createElement) return Object.assign(document.createElement("canvas"), { width, height });
|
|
10
|
+
throw new Error("this browser cannot draw images: no canvas is available");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async function referenceBlob(reference) {
|
|
14
|
+
if (reference.dataUrl !== undefined) return (await fetch(reference.dataUrl)).blob();
|
|
15
|
+
const asset = await getAsset(await openAssetDb(), reference.assetId);
|
|
16
|
+
if (!asset || asset.width === undefined) throw new Error(`no image asset ${reference.assetId} in this project`);
|
|
17
|
+
return new Blob([asset.bytes], { type: asset.type });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const browserSculptImages = Object.freeze({
|
|
21
|
+
async decode(reference) {
|
|
22
|
+
if (typeof createImageBitmap !== "function") throw new Error("this browser cannot decode images: createImageBitmap is unavailable");
|
|
23
|
+
const bitmap = await createImageBitmap(await referenceBlob(reference));
|
|
24
|
+
try {
|
|
25
|
+
const context = canvas(bitmap.width, bitmap.height).getContext("2d", { willReadFrequently: true });
|
|
26
|
+
context.drawImage(bitmap, 0, 0);
|
|
27
|
+
const { width, height, data } = context.getImageData(0, 0, bitmap.width, bitmap.height);
|
|
28
|
+
return { width, height, data };
|
|
29
|
+
} finally {
|
|
30
|
+
bitmap.close?.();
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
async encodePng({ width, height, data }) {
|
|
34
|
+
const surface = canvas(width, height);
|
|
35
|
+
surface.getContext("2d").putImageData(new ImageData(new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength), width, height), 0, 0);
|
|
36
|
+
if (!surface.convertToBlob) return surface.toDataURL("image/png");
|
|
37
|
+
const bytes = new Uint8Array(await (await surface.convertToBlob({ type: "image/png" })).arrayBuffer());
|
|
38
|
+
let binary = "";
|
|
39
|
+
for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
40
|
+
return `data:image/png;base64,${btoa(binary)}`;
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/** Nearest-neighbour shrink of an RGBA image by an integer factor. */
|
|
45
|
+
export function shrinkRgba({ width, height, data }, factor) {
|
|
46
|
+
const w = Math.max(1, Math.floor(width / factor)), h = Math.max(1, Math.floor(height / factor));
|
|
47
|
+
const out = new Uint8Array(w * h * 4);
|
|
48
|
+
for (let y = 0; y < h; y += 1) for (let x = 0; x < w; x += 1) {
|
|
49
|
+
const s = ((y * factor) * width + x * factor) * 4;
|
|
50
|
+
out.set(data.subarray(s, s + 4), (y * w + x) * 4);
|
|
51
|
+
}
|
|
52
|
+
return { width: w, height: h, data: out };
|
|
53
|
+
}
|
package/src/commands/shot.js
CHANGED
|
@@ -1,20 +1,25 @@
|
|
|
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, cutAtFrame, duplicateShot, removeShot, reorderShot, resizeShot, renameShot, moveCameraKey, removeCameraKey } from '../cuts.js';
|
|
3
|
+
import { addShotAtFrame, createShot, cutAtFrame, duplicateShot, removeShot, reorderShot, resizeShot, renameShot, moveCameraKey, removeCameraKey } from '../cuts.js';
|
|
4
|
+
import { reflowStillShots, stillFrameCount, STILL_FRAME_COUNT_MAX, STILL_HOLD_DEFAULT } from '../shot-authoring.js';
|
|
5
|
+
import { ko } from '../locale.js';
|
|
6
|
+
import { track } from '../analytics.js';
|
|
7
|
+
import { isImageAssetId } from '../scene-assets.js';
|
|
4
8
|
import { createStableItemId, updateStableItem } from '../stable-items.js';
|
|
5
9
|
import { railFollowForNewGeometry } from '../camera-rail-schedule.js';
|
|
6
|
-
import { studioActionDeclaration } from '../studio-actions.js';
|
|
10
|
+
import { studioActionDeclaration, studioActionRefusal } from '../studio-actions.js';
|
|
7
11
|
import { STUDIO_TOOL_SCHEMAS, StudioSchemas } from '../studio-agent-protocol.js';
|
|
8
12
|
import { elementSetSchema, registerElementSet } from './elements.js';
|
|
9
13
|
import './elements/shot.js';
|
|
10
14
|
import { changedIds, fail, shotLabel } from './shared.js';
|
|
11
15
|
|
|
12
16
|
const id = StudioSchemas.TargetGuard.properties.targetId;
|
|
17
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
13
18
|
const number = { type: 'number' };
|
|
14
19
|
const frame = { type: 'integer', minimum: 0 };
|
|
15
20
|
const input = (properties, required = Object.keys(properties)) => ({ type: 'object', properties, required, additionalProperties: false });
|
|
16
21
|
const mutation = (id, label, schema) => ({ id, label, description: label, kind: 'mutation', undoDomain: 'shot', input: schema });
|
|
17
|
-
const existing = ['shot.create', 'shot.split', 'shot.duplicate', 'shot.remove', 'shot.setRange', 'shot.setCameraRail', 'shot.clearCameraRail', 'shot.reorder'].map(studioActionDeclaration);
|
|
22
|
+
const existing = ['shot.create', 'shot.createStill', 'shot.setCaption', 'shot.setCastOverride', 'shot.setHold', 'shot.split', 'shot.duplicate', 'shot.remove', 'shot.setRange', 'shot.setCameraRail', 'shot.clearCameraRail', 'shot.reorder'].map(studioActionDeclaration);
|
|
18
23
|
const extra = [
|
|
19
24
|
mutation('shot.set', 'Set shot fields', elementSetSchema('shot')),
|
|
20
25
|
mutation('shot.rename', 'Rename shot', input({ shotId: id, name: { type: 'string', maxLength: 240 } })),
|
|
@@ -28,6 +33,9 @@ const extra = [
|
|
|
28
33
|
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' } })] }),
|
|
29
34
|
{ ...mutation('shot.replace', 'Replace shot authoring', input({ shots: { type: 'array', items: { type: 'object', properties: {}, additionalProperties: true } } })), exposure: 'ui-only' },
|
|
30
35
|
{ ...mutation('shot.captureCamera', 'Capture camera framing', input({ shotId: id }, [])), exposure: 'ui-only' },
|
|
36
|
+
// The Board's Stylize result (or null to clear it). UI-only: the picture has
|
|
37
|
+
// to be in the asset store first, which only the Stylize flow does.
|
|
38
|
+
{ ...mutation('shot.setStylized', 'Set stylized panel image', input({ shotId: id, assetId: { oneOf: [{ type: 'string' }, { type: 'null' }] } })), exposure: 'ui-only' },
|
|
31
39
|
{ ...mutation('shot.placeCamera', 'Place camera', input(Object.fromEntries(['x', 'y', 'z', 'lookAtX', 'lookAtY', 'lookAtZ', 'focalMm'].map(key => [key, number])), [])), exposure: 'ui-only' },
|
|
32
40
|
];
|
|
33
41
|
export const declarations = Object.freeze([extra[0], ...existing, ...extra.slice(1).map(entry => entry.id === 'shot.frame' ? entry : { ...entry, exposure: 'ui-only' })]);
|
|
@@ -37,16 +45,75 @@ export function register(registry, ports) {
|
|
|
37
45
|
const mounted = () => Boolean(ports.storeDomain?.('shot')) || 'The shots document owner is not mounted.';
|
|
38
46
|
const shotOf = shotId => owner().read().find(shot => shot.id === shotId) ?? fail('STALE_TARGET', `Shot ${shotId} is not in this scene.`);
|
|
39
47
|
const patchShot = (shotId, update) => { shotOf(shotId); owner().write(current => updateStableItem(current, shotId, update, 'shots')); };
|
|
48
|
+
const storyboard = () => ports.storeDomain?.('scenes')?.metadata?.().previsMode === 'storyboard';
|
|
49
|
+
const stillOf = shotId => {
|
|
50
|
+
const shot = shotOf(shotId);
|
|
51
|
+
return shot.kind === 'still' ? shot : fail('INVALID_ARGUMENT', `${shot.name} is a clip shot; only stills have a hold and a place in the still order.`);
|
|
52
|
+
};
|
|
53
|
+
// Every still edit is one write: the stills (in their new array order) are
|
|
54
|
+
// laid end to end and the timeline is sized to them, or nothing changes.
|
|
55
|
+
function writeStills(shots) {
|
|
56
|
+
const next = reflowStillShots(shots), frameCount = stillFrameCount(next);
|
|
57
|
+
if (frameCount > STILL_FRAME_COUNT_MAX) {
|
|
58
|
+
const en = `Storyboard is full (${STILL_FRAME_COUNT_MAX} frames); shorten a hold or remove a panel.`;
|
|
59
|
+
throw studioActionRefusal('INVALID_ARGUMENT', en, ko(en, `스토리보드가 가득 찼습니다 (${STILL_FRAME_COUNT_MAX}프레임). 홀드를 줄이거나 패널을 지우세요.`));
|
|
60
|
+
}
|
|
61
|
+
const clip = next.find(shot => shot.kind !== 'still' && next.some(still => still.kind === 'still' && still.startFrame <= shot.endFrame && still.endFrame >= shot.startFrame));
|
|
62
|
+
if (clip) fail('TARGET_NOT_READY', `The stills would overlap the clip shot ${clip.name} (frames ${clip.startFrame}-${clip.endFrame}); move or remove it first.`);
|
|
63
|
+
owner().writeState(before => ({ ...before, shots: next, frameCount }));
|
|
64
|
+
return next;
|
|
65
|
+
}
|
|
66
|
+
function createStill({ caption = '', hold = STILL_HOLD_DEFAULT } = {}) {
|
|
67
|
+
const current = owner().read(), framing = owner().capture(), last = current.findLastIndex(shot => shot.kind === 'still');
|
|
68
|
+
const keys = framing ? [{ id: createStableItemId('camera-key'), frame: 0, framing }] : [];
|
|
69
|
+
const still = { ...createShot(`Shot ${current.length + 1}`, 0, hold - 1, keys), kind: 'still', caption };
|
|
70
|
+
const written = writeStills([...current.slice(0, last + 1), still, ...current.slice(last + 1)]);
|
|
71
|
+
// Land the playhead in the new panel (the timeline.seek path) so the placement
|
|
72
|
+
// actions that follow write this panel's cast override, not the previous one's.
|
|
73
|
+
const created = written.find(shot => shot.id === still.id), { selection, view } = ports.readView();
|
|
74
|
+
ports.publishView({ selection, view: { ...view, frame: created.startFrame }, shotId: created.id });
|
|
75
|
+
}
|
|
76
|
+
function duplicate(shotId) {
|
|
77
|
+
const source = shotOf(shotId), copied = { kind: source.kind, caption: source.caption, cast: structuredClone(source.cast), stylizedAssetId: source.stylizedAssetId };
|
|
78
|
+
if (source.kind !== 'still') {
|
|
79
|
+
const before = owner().read(), next = duplicateShot(before, shotId, owner().state().frameCount);
|
|
80
|
+
return owner().write(next.map(shot => before.includes(shot) ? shot : { ...shot, ...copied }));
|
|
81
|
+
}
|
|
82
|
+
const current = owner().read(), index = current.indexOf(source);
|
|
83
|
+
const copy = { ...createShot(`${source.name} copy`, source.startFrame, source.endFrame,
|
|
84
|
+
source.cameraKeys.map(key => ({ ...key, id: createStableItemId('camera-key') })), source.camera), ...copied };
|
|
85
|
+
writeStills([...current.slice(0, index + 1), copy, ...current.slice(index + 1)]);
|
|
86
|
+
}
|
|
87
|
+
function reorder({ shotId, startFrame, index }) {
|
|
88
|
+
if ((startFrame === undefined) === (index === undefined)) fail('INVALID_ARGUMENT', 'shot.reorder takes exactly one of startFrame or index.');
|
|
89
|
+
const shot = shotOf(shotId);
|
|
90
|
+
if (shot.kind !== 'still') {
|
|
91
|
+
if (index !== undefined) stillOf(shotId);
|
|
92
|
+
return owner().write(current => reorderShot(current, shotId, startFrame, owner().state().frameCount));
|
|
93
|
+
}
|
|
94
|
+
const current = owner().read(), others = current.filter(row => row.kind === 'still' && row.id !== shotId);
|
|
95
|
+
const target = index ?? others.filter(row => row.startFrame < startFrame).length;
|
|
96
|
+
const order = [...others.slice(0, target), shot, ...others.slice(target)];
|
|
97
|
+
let next = 0;
|
|
98
|
+
writeStills(current.map(row => row.kind === 'still' ? order[next++] : row));
|
|
99
|
+
}
|
|
40
100
|
const methods = {
|
|
41
|
-
'shot.create': () => owner().write(current => addShotAtFrame(current, ports.state().frame, owner().state().frameCount, owner().capture())),
|
|
101
|
+
'shot.create': () => storyboard() ? createStill() : owner().write(current => addShotAtFrame(current, ports.state().frame, owner().state().frameCount, owner().capture())),
|
|
102
|
+
'shot.createStill': args => createStill(args),
|
|
103
|
+
'shot.setCaption': ({ shotId, caption }) => patchShot(shotId, shot => ({ ...shot, caption })),
|
|
104
|
+
'shot.setCastOverride': ({ shotId, characterId, override }) => owner().setCastOverride(shotId, characterId, override),
|
|
105
|
+
'shot.setHold': ({ shotId, hold }) => {
|
|
106
|
+
const shot = stillOf(shotId), endFrame = shot.startFrame + hold - 1;
|
|
107
|
+
writeStills(owner().read().map(row => row === shot ? { ...row, endFrame, cameraKeys: row.cameraKeys.filter(key => key.frame <= endFrame) } : row));
|
|
108
|
+
},
|
|
42
109
|
'shot.split': ({ shotId }) => {
|
|
43
110
|
const shot = shotOf(shotId), { frame } = ports.state();
|
|
44
111
|
if (frame <= shot.startFrame || frame > shot.endFrame) fail('TARGET_NOT_READY', `The playhead (frame ${frame}) is not inside ${shot.name} after its first frame.`);
|
|
45
112
|
owner().write(current => cutAtFrame(current, shotId, frame, owner().capture()));
|
|
46
113
|
},
|
|
47
|
-
'shot.duplicate': ({ shotId }) =>
|
|
114
|
+
'shot.duplicate': ({ shotId }) => duplicate(shotId),
|
|
48
115
|
'shot.remove': ({ shotId }) => { shotOf(shotId); owner().write(current => removeShot(current, shotId)); },
|
|
49
|
-
'shot.reorder':
|
|
116
|
+
'shot.reorder': args => reorder(args),
|
|
50
117
|
'shot.setRange': ({ shotId, range }) => {
|
|
51
118
|
const shot = shotOf(shotId), startFrame = range.startFrame, endFrame = range.endFrameExclusive - 1;
|
|
52
119
|
const edges = startFrame > shot.endFrame ? [['end', endFrame], ['start', startFrame]] : [['start', startFrame], ['end', endFrame]];
|
|
@@ -77,24 +144,35 @@ export function register(registry, ports) {
|
|
|
77
144
|
'shot.setLens': ({ fovDeg }) => owner().setLens(fovDeg),
|
|
78
145
|
'shot.replace': ({ shots }) => owner().write(shots),
|
|
79
146
|
'shot.captureCamera': args => owner().captureCamera(args.shotId),
|
|
147
|
+
'shot.setStylized': ({ shotId, assetId }) => {
|
|
148
|
+
if (assetId !== null && !isImageAssetId(assetId)) fail('INVALID_ARGUMENT', `${assetId} is not an image asset id.`);
|
|
149
|
+
patchShot(shotId, shot => ({ ...shot, stylizedAssetId: assetId }));
|
|
150
|
+
},
|
|
80
151
|
'shot.placeCamera': args => owner().placeCamera(args),
|
|
81
152
|
};
|
|
82
153
|
const hasShots = state => state.shots.length > 0 || 'There are no shots yet; add one with shot.create.';
|
|
83
154
|
const availability = {
|
|
84
|
-
'shot.
|
|
155
|
+
'shot.createStill': () => true,
|
|
156
|
+
'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.`,
|
|
85
157
|
'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 }.`,
|
|
86
158
|
'shot.clearCameraRail': state => state.shots.some(shot => createCameraBlock(shot.camera).cameraRail) || 'No shot has a camera rail; lay one with shot.setCameraRail.',
|
|
87
159
|
};
|
|
88
160
|
registerElementSet(registry, ports, extra[0]);
|
|
89
161
|
for (const declaration of declarations.filter(row => row.id !== 'shot.set')) registry.register({ ...declaration,
|
|
90
162
|
available: state => mounted() === true ? (availability[declaration.id] ?? (existing.includes(declaration) ? hasShots : () => true))(state) : mounted(),
|
|
91
|
-
run(args) {
|
|
163
|
+
run(args, context) {
|
|
92
164
|
if (declaration.id === 'shot.frame') {
|
|
93
165
|
const plan = owner().frame(args);
|
|
94
166
|
return { affectedIds: plan.affectedIds, summary: 'Framed the shot.' };
|
|
95
167
|
}
|
|
96
|
-
const before = owner().read()
|
|
97
|
-
|
|
168
|
+
const before = owner().read(), newPanel = declaration.id === 'shot.createStill' || (declaration.id === 'shot.create' && storyboard());
|
|
169
|
+
methods[declaration.id](args);
|
|
170
|
+
// Only the origin travels: never the caption, name or id of the panel.
|
|
171
|
+
if (newPanel) track('storyboard:panel_created', { source: context?.origin && context.origin !== 'ui' ? 'agent' : 'manual' });
|
|
172
|
+
// The owner re-normalizes every shot on write, so compare content, not
|
|
173
|
+
// identity; a storyboard reflow can still move more shots than a receipt
|
|
174
|
+
// names (100), and the first 100 stand for the batch.
|
|
175
|
+
const after = owner().read(), changed = changedIds(before.map(row => after.find(next => next.id === row.id && same(next, row)) ?? row), after).slice(0, 100);
|
|
98
176
|
const affectedIds = changed.length ? changed : [args.shotId ?? ports.state().activeSceneId];
|
|
99
177
|
return { affectedIds, summary: changed.length ? `${declaration.label}: ${changed.map(id => after.find(row => row.id === id)).filter(Boolean).map(shotLabel).join('; ')}.` : `${declaration.label}.` };
|
|
100
178
|
},
|
package/src/commands/view.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// Viewer preferences: transient, like the View menu they mirror.
|
|
2
|
-
import { studioActionDeclaration } from "../studio-actions.js";
|
|
3
|
-
import {
|
|
2
|
+
import { studioActionDeclaration, studioActionRefusal } from "../studio-actions.js";
|
|
3
|
+
import { STUDIO_TOOL_SCHEMAS } from '../studio-agent-protocol.js';
|
|
4
|
+
import { ko } from '../locale.js';
|
|
4
5
|
import { shotAtFrame } from '../cuts.js';
|
|
6
|
+
import { fail } from './shared.js';
|
|
5
7
|
|
|
6
8
|
const fields = STUDIO_TOOL_SCHEMAS.operate_studio.properties;
|
|
7
9
|
const groups = { 'view.select': ['selection', 'shotId'], 'timeline.seek': ['frame'], 'timeline.play': ['playing'], 'view.setMode': ['mode', 'view'] };
|
|
@@ -10,16 +12,18 @@ const operations = Object.entries({ ...groups, 'view.update': Object.keys(fields
|
|
|
10
12
|
input: { type: 'object', properties: Object.fromEntries(keys.map(key => [key, fields[key]])), required: [], additionalProperties: false },
|
|
11
13
|
}));
|
|
12
14
|
export const declarations = Object.freeze([...operations, ...["view.setPartColours", "view.setGuideMode", "view.setInset"].map(studioActionDeclaration)]);
|
|
15
|
+
// A storyboard project has no Motion department; every door says so the same way.
|
|
16
|
+
export const STORYBOARD_MOTION_REFUSAL = Object.freeze({ en: 'Motion tools are not part of a Storyboard project.', ko: '모션 도구는 스토리보드 프로젝트에 없어요.' });
|
|
13
17
|
export const viewCommand = args => Object.entries(groups).find(([, keys]) => Object.keys(args).every(key => keys.includes(key)))?.[0] ?? 'view.update';
|
|
14
18
|
|
|
15
19
|
export function register(registry, ports) {
|
|
16
20
|
registry.registerToolAlias('operate_studio', viewCommand);
|
|
17
21
|
for (const declaration of operations) registry.register({ ...declaration, available: () => true, run(args) {
|
|
18
|
-
const fail = (code, message) => { throw new StudioProtocolError(code, message); };
|
|
19
22
|
if (!Object.keys(args).length || (args.view && !Object.keys(args.view).length)) fail('INVALID_ARGUMENT', 'Specify a view operation.');
|
|
20
23
|
const state = ports.readView();
|
|
21
24
|
// Pose mode is IK editing: without a rig to solve there is nothing to pose.
|
|
22
25
|
if (args.mode === 'pose' && args.mode !== state.view.mode && !ports.canPose()) fail('TARGET_NOT_READY', 'Pose mode needs a loaded character rig to edit with IK.');
|
|
26
|
+
if (args.mode === 'motion' && state.previsMode === 'storyboard') throw studioActionRefusal('TARGET_NOT_READY', STORYBOARD_MOTION_REFUSAL.en, ko(STORYBOARD_MOTION_REFUSAL.en, STORYBOARD_MOTION_REFUSAL.ko));
|
|
23
27
|
const selection = args.selection === undefined ? state.selection : args.selection;
|
|
24
28
|
if (selection) {
|
|
25
29
|
const found = selection.kind === 'scene' ? selection.id === state.host.sceneId : selection.kind === 'camera' ? selection.id === 'camera'
|
|
@@ -31,7 +35,9 @@ export function register(registry, ports) {
|
|
|
31
35
|
const frame = args.frame ?? shot?.startFrame ?? state.view.frame;
|
|
32
36
|
if (frame >= Math.max(1, state.frameCount)) fail('INVALID_RANGE', 'Frame is outside the timeline.');
|
|
33
37
|
const view = { ...state.view, ...args.view, frame, mode: args.mode ?? state.view.mode, playing: args.playing ?? state.view.playing };
|
|
34
|
-
|
|
38
|
+
// modeSet: only a command that names a mode moves the editor out of a
|
|
39
|
+
// UI-only mode (Sculpt, #739), which the published view reads as Stage.
|
|
40
|
+
ports.publishView({ selection, view, shotId: args.shotId ?? shotAtFrame(state.shots, frame)?.id ?? null, modeSet: args.mode !== undefined });
|
|
35
41
|
return { affectedIds: [state.host.sceneId], summary: 'Updated editor view.' };
|
|
36
42
|
} });
|
|
37
43
|
const viewAction = (id, run) => registry.register({ ...studioActionDeclaration(id), available: () => true,
|