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
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
// Studio composites prepare private domain drafts; the App owns publication,
|
|
2
2
|
// history, gesture fences and semantic revision/telemetry. No UI callbacks here.
|
|
3
|
-
import { Euler, Vector3, PerspectiveCamera } from 'three';
|
|
3
|
+
import { Euler, Vector3, Quaternion, PerspectiveCamera } from 'three';
|
|
4
4
|
import { createSceneObject, updateSceneObject, removeSceneObject, setSceneObjectParent, descendantsOf, isEffectivelyHidden, supportHeightForObject, OBJECT_LIBRARY } from './scene-objects.js';
|
|
5
5
|
import { createCharacterEntry } from './scenes.js';
|
|
6
6
|
import { createShot, shotAtFrame } from './cuts.js';
|
|
7
7
|
import { captureFraming } from './camera-move.js';
|
|
8
8
|
import { createStableItemId } from './stable-items.js';
|
|
9
9
|
import { focalMmToFov, SENSOR_FORMATS } from './shot.js';
|
|
10
|
+
import { coplanarConflicts } from './coplanar-depth.js';
|
|
11
|
+
import { clampWarning } from './receipt-findings.js';
|
|
12
|
+
import { occludersOf, primarySubjects, sideOfAxis } from './studio-geometry-facts.js';
|
|
10
13
|
import { StudioProtocolError, StudioSchemas, STUDIO_PATCH_KINDS, STUDIO_PATCH_DESCRIPTORS, validateStudioSchema, validateStudioIdentity, validateReceipt, freezeStudioData } from './studio-agent-protocol.js';
|
|
11
14
|
|
|
12
15
|
const DEG = Math.PI / 180, EPS = 1e-8, CHARACTER_SUPPORT_TOLERANCE = 5e-3;
|
|
@@ -20,6 +23,39 @@ const entityById = (state, id) => {
|
|
|
20
23
|
if (!entity) fail('AMBIGUOUS_TARGET', 'Target ID is not present in the admitted scene.');
|
|
21
24
|
return entity;
|
|
22
25
|
};
|
|
26
|
+
/** Resolve a placement reference against the evolving batch draft: an id first,
|
|
27
|
+
* else the unique name of an object or character already in it. Parts created
|
|
28
|
+
* earlier in the same batch are in the draft, so they resolve by name. */
|
|
29
|
+
function resolveRef(ref, state, field) {
|
|
30
|
+
const all = [...state.objects, ...state.characters];
|
|
31
|
+
if (all.some(e => e.id === ref)) return ref;
|
|
32
|
+
const named = all.filter(e => normalizedName(e.name ?? e.subject ?? '') === normalizedName(ref));
|
|
33
|
+
if (named.length === 1) return named[0].id;
|
|
34
|
+
if (named.length) fail('AMBIGUOUS_TARGET', `${field} '${ref}' matches ${named.length} objects by name (${named.map(e => e.id).join(', ')}); reference one by id.`);
|
|
35
|
+
fail('AMBIGUOUS_TARGET', `${field} '${ref}' not found; reference an existing id, or a part created EARLIER in this batch by its name (create it before it is referenced).`);
|
|
36
|
+
}
|
|
37
|
+
/** The op with every reference field (relativeTo, onObject, between, support,
|
|
38
|
+
* facing targets) resolved to an id in the current draft. */
|
|
39
|
+
function resolveOpRefs(op, state, selfId) {
|
|
40
|
+
const { position: p, facing: f } = op;
|
|
41
|
+
if (!p && !f) return op;
|
|
42
|
+
const r = (value, field) => {
|
|
43
|
+
const resolved = resolveRef(value, state, field);
|
|
44
|
+
if (resolved === selfId) fail('INVALID_ARGUMENT', `${field} '${value}' is the object being placed; reference a different part.`);
|
|
45
|
+
return resolved;
|
|
46
|
+
};
|
|
47
|
+
const out = { ...op };
|
|
48
|
+
if (p) out.position = { ...p,
|
|
49
|
+
...(p.relativeTo !== undefined ? { relativeTo: r(p.relativeTo, 'relativeTo') } : {}),
|
|
50
|
+
...(p.onObject !== undefined ? { onObject: r(p.onObject, 'onObject') } : {}),
|
|
51
|
+
...(p.between ? { between: p.between.map(value => r(value, 'between')) } : {}),
|
|
52
|
+
...(p.support && typeof p.support === 'object' ? { support: { objectId: r(p.support.objectId, 'support.objectId') } } : {}) };
|
|
53
|
+
if (f) {
|
|
54
|
+
const key = ['towardId', 'awayFromId', 'sameAsId'].find(k => f[k] !== undefined);
|
|
55
|
+
if (key) out.facing = { [key]: r(f[key], `facing.${key}`) };
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
23
59
|
function boxPoints(bounds) {
|
|
24
60
|
if (!bounds || ['x', 'y', 'z'].some(a => !Number.isFinite(bounds.min?.[a]) || !Number.isFinite(bounds.max?.[a]) || bounds.min[a] > bounds.max[a])) fail('TARGET_NOT_READY', 'Evaluated bounds are unavailable.');
|
|
25
61
|
return [bounds.min.x, bounds.max.x].flatMap(x => [bounds.min.y, bounds.max.y].flatMap(y => [bounds.min.z, bounds.max.z].map(z => new Vector3(x, y, z))));
|
|
@@ -55,7 +91,7 @@ function support(spec, state) {
|
|
|
55
91
|
const id = spec.onObject ?? (typeof spec.support === 'object' ? spec.support.objectId : null);
|
|
56
92
|
if (!id) return { y: state.floorY, label: 'floor' };
|
|
57
93
|
const object = entityById(state, id);
|
|
58
|
-
if (!object.renderer || object.renderer === 'cutout' || ['sphere', 'capsule', 'cone', 'car', 'small-plane'].includes(object.renderer) || object.path || object.attach || Math.abs(object.rotX) > EPS || Math.abs(object.rotZ) > EPS || isEffectivelyHidden(object, state.objects, state.characters)) fail('TARGET_NOT_READY', 'Support must be a stationary solid upright surface.');
|
|
94
|
+
if (!object.renderer || object.renderer === 'cutout' || ['sphere', 'capsule', 'cone', 'car', 'small-plane', 'empty', 'sculpt'].includes(object.renderer) || object.path || object.attach || Math.abs(object.rotX) > EPS || Math.abs(object.rotZ) > EPS || isEffectivelyHidden(object, state.objects, state.characters)) fail('TARGET_NOT_READY', 'Support must be a stationary solid upright surface.');
|
|
59
95
|
return { y: object.y + supportHeightForObject(object) * object.scaleY, label: `object:${id}`, object };
|
|
60
96
|
}
|
|
61
97
|
function facingYaw(spec, entity, state) {
|
|
@@ -106,6 +142,9 @@ function place(entity, op, state, ports) {
|
|
|
106
142
|
if (Math.hypot(result.x - previous.x, result.z - previous.z, result.rot - previous.rot) < EPS) { converged = true; break; }
|
|
107
143
|
}
|
|
108
144
|
if (!converged) fail('AMBIGUOUS_BASIS', 'Facing and placement cannot satisfy the requested relation.');
|
|
145
|
+
// A pitched/rolled part rests its lowest corner on the support.
|
|
146
|
+
if (result.renderer && !result.attach && !result.path && (Math.abs(result.rotX) > EPS || Math.abs(result.rotZ) > EPS))
|
|
147
|
+
result = patchEntity(result, { y: result.y + surface.y - Math.min(...geometry(result, state, ports).map(p => p.y)) });
|
|
109
148
|
const points = geometry(result, state, ports);
|
|
110
149
|
const supportTolerance = !result.renderer && surface.label === 'floor' ? CHARACTER_SUPPORT_TOLERANCE : EPS;
|
|
111
150
|
if (Math.abs(Math.min(...points.map(p => p.y)) - surface.y) > supportTolerance) fail('TARGET_NOT_READY', 'Tilted or offset bounds cannot rest on the requested support.');
|
|
@@ -116,14 +155,90 @@ function place(entity, op, state, ports) {
|
|
|
116
155
|
}
|
|
117
156
|
return { entity: result, relation: { id: result.id, spec, axis, support: surface.label, baseY: surface.y, ...(axis ? { actualGapM, requestedGapM: spec.gapM, basis: spec.basis } : {}) } };
|
|
118
157
|
}
|
|
158
|
+
// Parent-relative (local) space. Object rows are flat world-space; `parent` only
|
|
159
|
+
// groups. A parent's frame is its stored pivot (base, y = bottom) and Euler XYZ
|
|
160
|
+
// degrees; parent SCALE is deliberately not part of the frame.
|
|
161
|
+
const objectQuat = o => new Quaternion().setFromEuler(new Euler((o.rotX ?? 0) * DEG, (o.rot ?? 0) * DEG, (o.rotZ ?? 0) * DEG, 'XYZ'));
|
|
162
|
+
const wrapDeg = a => ((a + 540) % 360 + 360) % 360 - 180;
|
|
163
|
+
// XYZ Euler angles come in two equal-orientation spellings, (x,y,z) and
|
|
164
|
+
// (x+180, 180-y, z+180). Report the one with the least pitch/roll, so a pure
|
|
165
|
+
// yaw of 135 reads back as yaw 135 rather than (180, 45, 180).
|
|
166
|
+
function eulerDegOf(q) {
|
|
167
|
+
const e = new Euler().setFromQuaternion(q, 'XYZ'), a = { x: e.x / DEG, y: e.y / DEG, z: e.z / DEG };
|
|
168
|
+
const b = { x: wrapDeg(a.x + 180), y: wrapDeg(180 - a.y), z: wrapDeg(a.z + 180) };
|
|
169
|
+
return Math.abs(b.x) + Math.abs(b.z) < Math.abs(a.x) + Math.abs(a.z) - 1e-9 ? b : a;
|
|
170
|
+
}
|
|
171
|
+
const eulerQuat = r => new Quaternion().setFromEuler(new Euler(r.x * DEG, r.y * DEG, r.z * DEG, 'XYZ'));
|
|
172
|
+
/** world = parentPivot + R_parent * local; orientation = R_parent * R_local. */
|
|
173
|
+
export function localToWorld(parent, local) {
|
|
174
|
+
const p = vector(local.position ?? { x: 0, y: 0, z: 0 }).applyQuaternion(objectQuat(parent)).add(vector(pos(parent)));
|
|
175
|
+
const q = objectQuat(parent).multiply(eulerQuat(local.rotationDeg ?? { x: 0, y: 0, z: 0 }));
|
|
176
|
+
return { position: { x: p.x, y: p.y, z: p.z }, rotationDeg: eulerDegOf(q) };
|
|
177
|
+
}
|
|
178
|
+
/** Inverse of localToWorld for an object row under its parent row. */
|
|
179
|
+
export function worldToLocal(parent, object) {
|
|
180
|
+
const inverse = objectQuat(parent).invert();
|
|
181
|
+
const p = vector(pos(object)).sub(vector(pos(parent))).applyQuaternion(inverse);
|
|
182
|
+
return { position: { x: p.x, y: p.y, z: p.z }, rotationDeg: eulerDegOf(inverse.multiply(objectQuat(object))) };
|
|
183
|
+
}
|
|
184
|
+
const tidy = v => Math.round(v * 1e4) / 1e4 + 0;
|
|
185
|
+
/** Compact readback: the object's transform in its parent's frame, or null for
|
|
186
|
+
* a root, a missing parent or a parent whose stored pose is not its world pose. */
|
|
187
|
+
export function localTransformReadback(object, objects) {
|
|
188
|
+
const parent = object.parent ? objects.find(o => o.id === object.parent) : null;
|
|
189
|
+
if (!parent || parent.attach || parent.path || object.attach || object.path) return null;
|
|
190
|
+
const { position, rotationDeg } = worldToLocal(parent, object);
|
|
191
|
+
const round = v => ({ x: tidy(v.x), y: tidy(v.y), z: tidy(v.z) });
|
|
192
|
+
return { position: round(position), rotationDeg: round(rotationDeg) };
|
|
193
|
+
}
|
|
194
|
+
const resolveParentRef = (ref, rows, createdByName) => rows.some(e => e.id === ref) ? ref : createdByName.get(normalizedName(ref));
|
|
195
|
+
/** Rewrites an op given in the parent's local frame into the equivalent world op. */
|
|
196
|
+
function parentFrameOp(op, entity, rows, createdByName) {
|
|
197
|
+
const create = op.op === 'create';
|
|
198
|
+
if (create && op.parent === undefined) fail('INVALID_ARGUMENT', 'space "parent" needs `parent` on a create: an existing object id, or the name of an object created earlier in this batch.');
|
|
199
|
+
if (!create && !entity.parent) fail('INVALID_ARGUMENT', 'space "parent" needs an object that already has a parent; group it under one first, or omit space for world coordinates.');
|
|
200
|
+
const parentId = create ? resolveParentRef(op.parent, rows, createdByName) : entity.parent;
|
|
201
|
+
const parent = rows.find(e => e.id === parentId);
|
|
202
|
+
if (!parent) fail('AMBIGUOUS_TARGET', 'space "parent": the parent is neither an existing object id nor an object created earlier in this batch.');
|
|
203
|
+
if (parent.attach || parent.path) fail('CAPABILITY_MISSING', 'space "parent": an attached or routed parent has no fixed pose to place against.');
|
|
204
|
+
if (!op.position && !op.rotationDeg && !op.facing) fail('INVALID_ARGUMENT', 'space "parent" applies to position, rotationDeg or facing; none was given.');
|
|
205
|
+
if (op.position && !op.position.world) fail('INVALID_ARGUMENT', 'space "parent" takes position {world:{x,y,z}} as local metres from the parent pivot; relativeTo, between and onObject resolve in world space, so omit space for them.');
|
|
206
|
+
if (op.facing && !('yawDeg' in op.facing)) fail('INVALID_ARGUMENT', 'space "parent" takes facing {yawDeg} only (local yaw); towardId, awayFromId and sameAsId are world-space.');
|
|
207
|
+
const current = create ? { position: { x: 0, y: 0, z: 0 }, rotationDeg: { x: 0, y: 0, z: 0 } } : worldToLocal(parent, entity);
|
|
208
|
+
// Local rotation: rotationDeg, else the current local one with its yaw set by
|
|
209
|
+
// facing; a new child with neither takes the parent's orientation (local zero).
|
|
210
|
+
const rotation = op.rotationDeg ?? (op.facing ? { ...current.rotationDeg, y: op.facing.yawDeg } : create ? current.rotationDeg : null);
|
|
211
|
+
const world = localToWorld(parent, { position: op.position ? op.position.world : current.position, rotationDeg: rotation ?? current.rotationDeg });
|
|
212
|
+
const { space, facing, ...rest } = op;
|
|
213
|
+
return { ...rest, ...(op.position ? { position: { world: world.position } } : {}), ...(rotation ? { rotationDeg: world.rotationDeg } : {}) };
|
|
214
|
+
}
|
|
215
|
+
function groupRootOf(id, objects) {
|
|
216
|
+
const seen = new Set();
|
|
217
|
+
let current = objects.find(o => o.id === id);
|
|
218
|
+
while (current?.parent && !seen.has(current.id)) {
|
|
219
|
+
seen.add(current.id);
|
|
220
|
+
current = objects.find(o => o.id === current.parent) ?? current;
|
|
221
|
+
}
|
|
222
|
+
return current?.id ?? id;
|
|
223
|
+
}
|
|
119
224
|
function overlapsFor(entity, state, ports) {
|
|
120
225
|
const own = aabb(geometry(entity, state, ports));
|
|
121
|
-
|
|
226
|
+
// Parts of one grouped object are built to touch; only other bodies count.
|
|
227
|
+
const root = groupRootOf(entity.id, state.objects);
|
|
228
|
+
return [...state.objects, ...state.characters].filter(e => e.id !== entity.id && !isEffectivelyHidden(e, state.objects, state.characters)
|
|
229
|
+
&& groupRootOf(e.id, state.objects) !== root).map(other => ({ id: other.id, bounds: aabb(geometry(other, state, ports)) })).map(other => ({ ...other, of: entity.id, depth: overlap(own, other.bounds) })).filter(o => o.depth > EPS);
|
|
122
230
|
}
|
|
123
231
|
function overlapsOf(ids, state, ports) {
|
|
124
232
|
const entities = [...state.objects, ...state.characters];
|
|
125
233
|
return ids.flatMap(id => { const e = entities.find(row => row.id === id); return e ? overlapsFor(e, state, ports) : []; });
|
|
126
234
|
}
|
|
235
|
+
/** World AABB of one entity as {min,max}, or null when its evaluated bounds are unavailable (a prop whose renderer is not mounted). */
|
|
236
|
+
export function entityWorldBounds(entity, state, ports) {
|
|
237
|
+
try {
|
|
238
|
+
const box = aabb(geometry(entity, state, ports)), round = v => Number(v.toFixed(4));
|
|
239
|
+
return { min: Object.fromEntries(['x', 'y', 'z'].map(a => [a, round(box[a].min)])), max: Object.fromEntries(['x', 'y', 'z'].map(a => [a, round(box[a].max)])) };
|
|
240
|
+
} catch { return null; }
|
|
241
|
+
}
|
|
127
242
|
const overlapEvidence = overlaps => ({ coverage: 'same-frame-world-AABB-proxies', overlapIds: [...new Set(overlaps.map(o => o.id))].slice(0, 100), maximumFootprintOverlapM: Math.max(0, ...overlaps.map(o => o.depth)) });
|
|
128
243
|
/** Placement evidence for entity ids in the given scene: the same-frame AABB
|
|
129
244
|
* overlap an arrangement receipt reports for the entities it moved. */
|
|
@@ -152,32 +267,49 @@ function transformPatch(op, isObject) {
|
|
|
152
267
|
export function arrangement(command, before, ports) {
|
|
153
268
|
const isObject = command.name === 'arrange_objects', key = isObject ? 'objects' : 'characters';
|
|
154
269
|
let rows = before[key];
|
|
155
|
-
const relations = [], warnings = [];
|
|
156
|
-
|
|
157
|
-
|
|
270
|
+
const relations = [], warnings = [], clamps = [], createdByName = new Map();
|
|
271
|
+
// References resolve against the evolving draft (`rows`), in op order.
|
|
272
|
+
const draft = () => ({ ...before, [key]: rows });
|
|
273
|
+
for (const rawOp of command.args.ops) {
|
|
274
|
+
const id = rawOp.id ?? rawOp.characterId;
|
|
158
275
|
let entity = id ? rows.find(e => e.id === id) : null;
|
|
159
|
-
if (id && !entity) fail('AMBIGUOUS_TARGET',
|
|
276
|
+
if (id && !entity) fail('AMBIGUOUS_TARGET', `Edited target '${id}' is not present in the draft; ids of parts created in this batch are not known yet, so use the part name in position references and create it before it is referenced.`);
|
|
277
|
+
let op = resolveOpRefs(rawOp, draft(), id);
|
|
160
278
|
if (op.op === 'create') {
|
|
161
279
|
if (op.name && rows.some(e => normalizedName(e.name ?? e.subject) === normalizedName(op.name))) fail('DUPLICATE_NAME', 'Create name already exists in the domain.');
|
|
280
|
+
let parentId;
|
|
281
|
+
if (isObject && op.parent !== undefined) {
|
|
282
|
+
parentId = rows.some(e => e.id === op.parent) ? op.parent : createdByName.get(normalizedName(op.parent));
|
|
283
|
+
if (!parentId) fail('AMBIGUOUS_TARGET', `Create parent '${op.parent}' is neither an existing object id nor an object created earlier in this batch; create the parent first.`);
|
|
284
|
+
}
|
|
162
285
|
entity = isObject ? createSceneObject(op.source.kind, rows) : createCharacterEntry({ id: createStableItemId('character'), subject: normalizedName(op.name) });
|
|
163
286
|
if (!entity) fail('INVALID_ARGUMENT', 'Unsupported object library kind.');
|
|
164
287
|
rows = [...rows, entity];
|
|
288
|
+
// Parent before placing, so avoidance and overlap checks see one body.
|
|
289
|
+
if (parentId) { rows = setSceneObjectParent(rows, entity.id, parentId); entity = rows.find(e => e.id === entity.id); }
|
|
165
290
|
}
|
|
166
291
|
if (['create', 'update'].includes(op.op)) {
|
|
167
292
|
if (entity.attach) fail('CAPABILITY_MISSING', 'Attached transforms require a world-preserving attachment adapter.');
|
|
293
|
+
if (op.space === 'parent') op = parentFrameOp(op, entity, rows, createdByName);
|
|
168
294
|
entity = patchEntity(entity, transformPatch(op, isObject));
|
|
169
|
-
const placed = place(entity, op,
|
|
295
|
+
const placed = place(entity, op, draft(), ports);
|
|
170
296
|
entity = placed.entity;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
297
|
+
// What was asked against what the domain kept: scale and position limits move values silently.
|
|
298
|
+
const asked = isObject ? { ...(op.position?.world ?? {}), ...(op.scale ? { scaleX: op.scale.x, scaleY: op.scale.y, scaleZ: op.scale.z } : {}) }
|
|
299
|
+
: { ...(op.position?.world ?? {}), ...(op.scale === undefined ? {} : { scale: op.scale }) };
|
|
300
|
+
const clamp = clampWarning(entity.id, Object.fromEntries(Object.entries(asked).filter(([k]) => ['x', 'y', 'z', 'scale', 'scaleX', 'scaleY', 'scaleZ'].includes(k))), entity);
|
|
301
|
+
if (clamp) clamps.push(clamp);
|
|
302
|
+
if (command.args.collisionPolicy === 'avoid' && placed.relation?.axis) {
|
|
303
|
+
entity = avoid(entity, placed.relation, draft(), ports);
|
|
304
|
+
entity = patchEntity(entity, { rot: facingYaw(op.facing, entity, draft()) });
|
|
305
|
+
const reference = entityById(draft(), op.position.relativeTo);
|
|
306
|
+
placed.relation.actualGapM = interval(geometry(entity, draft(), ports), placed.relation.axis).min - interval(geometry(reference, draft(), ports), placed.relation.axis).max;
|
|
176
307
|
if (placed.relation.actualGapM < op.position.gapM - EPS) fail('VERIFICATION_FAILED', 'Avoidance cannot preserve the requested facing and minimum clearance.');
|
|
177
308
|
}
|
|
178
309
|
const patch = Object.fromEntries(Object.entries(entity).filter(([k, v]) => !equal(v, rows.find(e => e.id === entity.id)[k])));
|
|
179
310
|
rows = isObject ? updateSceneObject(rows, entity.id, patch) : rows.map(e => e.id === entity.id ? entity : e);
|
|
180
311
|
if (placed.relation) relations.push(placed.relation);
|
|
312
|
+
if (op.op === 'create' && op.name) createdByName.set(normalizedName(op.name), entity.id);
|
|
181
313
|
} else if (op.op === 'remove') {
|
|
182
314
|
if (!isObject && (rows.length <= 1 || entity.id === before.activeCharacterId)) fail('INVALID_ARGUMENT', 'Cannot remove the final or active character without a separate selection operation.');
|
|
183
315
|
rows = isObject ? removeSceneObject(rows, id) : rows.filter(e => e.id !== id);
|
|
@@ -205,7 +337,24 @@ export function arrangement(command, before, ports) {
|
|
|
205
337
|
relation.actualGapM = actualGapM;
|
|
206
338
|
}
|
|
207
339
|
if (command.args.collisionPolicy === 'avoid' && overlaps.length) fail('VERIFICATION_FAILED', 'The final batch is still blocked after its outward adjustments.');
|
|
208
|
-
|
|
340
|
+
// Every finding the receipt carries: a value moved to a limit first, then what the final scene shows wrong.
|
|
341
|
+
const nameOf = id => { const e = [...after.objects, ...after.characters].find(row => row.id === id); return e?.name ?? e?.subject ?? id; };
|
|
342
|
+
warnings.push(...clamps.slice(0, 3));
|
|
343
|
+
for (const item of overlaps.slice(0, 5)) warnings.push({ code: 'FOOTPRINT_OVERLAP', id: item.id, message: [...`${nameOf(item.of)} overlaps ${nameOf(item.id)} by ${Number(item.depth.toFixed(2))} m`].slice(0, 120).join('') });
|
|
344
|
+
if (overlaps.length > 5) warnings.push({ code: 'FOOTPRINT_OVERLAP', count: overlaps.length - 5, message: `${overlaps.length - 5} more footprint overlaps omitted` });
|
|
345
|
+
if (isObject) {
|
|
346
|
+
// Parts of one assembly touch on purpose, but faces sharing one plane flicker as the camera moves.
|
|
347
|
+
const touched = new Set(affectedIds), groups = new Map();
|
|
348
|
+
for (const row of rows) if (touched.has(row.id)) { const root = groupRootOf(row.id, rows); if (root !== row.id || rows.some(o => o.parent === row.id)) groups.set(root, null); }
|
|
349
|
+
const coplanar = [];
|
|
350
|
+
for (const root of groups.keys()) for (const pair of coplanarConflicts(rows.filter(row => groupRootOf(row.id, rows) === root))) {
|
|
351
|
+
if (!touched.has(pair.a) && !touched.has(pair.b)) continue;
|
|
352
|
+
const planes = pair.planes.map(plane => `${plane.face} coincide at ${plane.axis ? `${plane.axis}=${plane.at}` : `offset ${plane.at}`}`).join(', ');
|
|
353
|
+
coplanar.push({ code: 'COPLANAR_FACES', id: pair.b, message: [...`${nameOf(pair.a)} and ${nameOf(pair.b)} ${planes}`].slice(0, 120).join('') });
|
|
354
|
+
}
|
|
355
|
+
warnings.push(...coplanar.slice(0, 2));
|
|
356
|
+
if (coplanar.length > 2) warnings.push({ code: 'COPLANAR_FACES', count: coplanar.length - 2, message: `${coplanar.length - 2} more coplanar face pairs omitted` });
|
|
357
|
+
}
|
|
209
358
|
if (relations.some(r => r.adjustmentM)) warnings.push({ code: 'OUTWARD_ADJUSTMENT', count: relations.filter(r => r.adjustmentM).length });
|
|
210
359
|
const relation = relations.length === 1 ? relations[0] : null;
|
|
211
360
|
return { domain: isObject ? 'objects' : 'cast', draft: equal(rows, before[key]) ? before[key] : rows, affectedIds,
|
|
@@ -232,12 +381,23 @@ function projectSubject(points, camera, fov, aspectRatio) {
|
|
|
232
381
|
const derivedSize = [['extreme close-up', 2.8], ['close-up', 1.6], ['medium close-up', 1.15], ['medium shot', 0.8], ['medium-wide shot', 0.52], ['wide shot', 0.3], ['extreme wide shot', 0]].find(([, threshold]) => screenFraction >= threshold)[0];
|
|
233
382
|
return { screenBounds, checks: { coverage: 'same-frame-subject-bounds-projection', screenFraction, derivedSize, behindCamera, clipped: behindCamera || screen.some(p => Math.abs(p.x) > 1 || Math.abs(p.y) > 1 || p.z < -1 || p.z > 1) } };
|
|
234
383
|
}
|
|
384
|
+
const boundsOf = points => { const box = aabb(points); return { min: { x: box.x.min, y: box.y.min, z: box.z.min }, max: { x: box.x.max, y: box.y.max, z: box.z.max } }; };
|
|
385
|
+
/** What stands in the way of the camera and where it stands: occluded when a prop
|
|
386
|
+
* crosses the sight line to any subject, and the camera's side of the line between
|
|
387
|
+
* the two leading characters (the subjects, else the ones nearest the camera). */
|
|
388
|
+
function sightChecks(subjects, boxes, cameraPosition, state, ports) {
|
|
389
|
+
const occluders = subjects.flatMap((subject, index) => occludersOf(subject, cameraPosition, state, ports, boundsOf(boxes[index])));
|
|
390
|
+
const pair = primarySubjects(subjects.map(subject => subject.id), cameraPosition, state);
|
|
391
|
+
const cameraSide = pair.length === 2 ? sideOfAxis(pair[0], pair[1], cameraPosition) : null;
|
|
392
|
+
return { occluders, checks: { occluded: occluders.length > 0, ...(cameraSide ? { cameraSide } : {}) } };
|
|
393
|
+
}
|
|
235
394
|
/** Framing evidence for entity ids through the current shot camera at the
|
|
236
|
-
* current frame: the same subject-bounds projection frame_shot reports. */
|
|
395
|
+
* current frame: the same subject-bounds projection and sight line frame_shot reports. */
|
|
237
396
|
export function framingChecks(ids, state, ports) {
|
|
238
397
|
if (!state.camera || !SENSOR_FORMATS[state.filmback?.sensorId] || !(state.filmback.aspectRatio > 0)) fail('TARGET_NOT_READY', 'The shot camera and filmback must be available.');
|
|
239
|
-
const
|
|
240
|
-
|
|
398
|
+
const subjects = ids.map(id => entityById(state, id)), boxes = subjects.map(entity => geometry(entity, state, ports));
|
|
399
|
+
const projected = projectSubject(boxes.flat(), state.camera, focalMmToFov(state.camera.focalMm, state.filmback.sensorId, state.filmback.aspectRatio), state.filmback.aspectRatio).checks;
|
|
400
|
+
return { ...projected, ...sightChecks(subjects, boxes, state.camera.position, state, ports).checks };
|
|
241
401
|
}
|
|
242
402
|
export function frameDraft(command, state, ports) {
|
|
243
403
|
if (state.frameCount <= 0) fail('TARGET_NOT_READY', 'A nonempty timeline is required.');
|
|
@@ -249,7 +409,9 @@ export function frameDraft(command, state, ports) {
|
|
|
249
409
|
const created = !shot && !state.shotDocument.shots.length && !command.args.shotId;
|
|
250
410
|
if (created) shot = createShot('Shot 1', 0, state.frameCount - 1);
|
|
251
411
|
if (!shot) fail('AMBIGUOUS_TARGET', 'No shot owns this frame; select an existing shot explicitly.');
|
|
252
|
-
const
|
|
412
|
+
const still = shot.kind === 'still';
|
|
413
|
+
const existingKey = shot.cameraKeys[0];
|
|
414
|
+
const frame = still ? (existingKey?.frame ?? shot.startFrame) : command.args.keyAtFrame ?? state.frame;
|
|
253
415
|
if (frame < shot.startFrame || frame > shot.endFrame || shot.endFrame >= state.frameCount) fail('INVALID_RANGE', 'Framing/key frame must be inside the shot range.');
|
|
254
416
|
const intent = command.args.framing.intent;
|
|
255
417
|
let camera = command.args.framing.exact;
|
|
@@ -270,18 +432,19 @@ export function frameDraft(command, state, ports) {
|
|
|
270
432
|
const framing = captureFraming({ pos: camera.position, yaw: Math.atan2(-direction.x, -direction.z), pitch: Math.atan2(direction.y, Math.hypot(direction.x, direction.z)), fovDeg: fov / DEG });
|
|
271
433
|
let keyId;
|
|
272
434
|
let keys = shot.cameraKeys;
|
|
273
|
-
if (command.args.keyAtFrame !== undefined) {
|
|
274
|
-
keyId = keys.find(k => k.frame === frame)?.id ?? createStableItemId('camera-key');
|
|
275
|
-
keys = [...keys.filter(k => k.frame !== frame), { id: keyId, frame, framing }].sort((a, b) => a.frame - b.frame);
|
|
435
|
+
if (still || command.args.keyAtFrame !== undefined) {
|
|
436
|
+
keyId = (still ? existingKey?.id : keys.find(k => k.frame === frame)?.id) ?? createStableItemId('camera-key');
|
|
437
|
+
keys = still ? [{ id: keyId, frame, framing }] : [...keys.filter(k => k.frame !== frame), { id: keyId, frame, framing }].sort((a, b) => a.frame - b.frame);
|
|
276
438
|
}
|
|
277
439
|
const nextShot = { ...shot, cameraKeys: keys, camera: { ...shot.camera, mode: 'keys' } };
|
|
278
440
|
const shots = created ? [nextShot] : state.shotDocument.shots.map(s => s.id === shot.id ? nextShot : s);
|
|
279
|
-
const { screenBounds, checks } = projectSubject(points, camera, fov, state.filmback.aspectRatio), derivedSize =
|
|
441
|
+
const { screenBounds, checks: projected } = projectSubject(points, camera, fov, state.filmback.aspectRatio), derivedSize = projected.derivedSize;
|
|
442
|
+
const sight = sightChecks([subject], [points], camera.position, state, ports), checks = { ...projected, ...sight.checks };
|
|
280
443
|
const resolvedCamera = { ...camera, sensorId: state.filmback.sensorId, slate: derivedSize };
|
|
281
444
|
const draft = { shotDocument: { ...state.shotDocument, shots }, camera: resolvedCamera, manual: true };
|
|
282
445
|
return { domain: 'shot', draft, affectedIds: [shot.id, ...(keyId ? [keyId] : [])],
|
|
283
446
|
checks,
|
|
284
|
-
warnings:
|
|
447
|
+
warnings: sight.occluders.slice(0, 5).map(item => ({ code: 'OCCLUDED', id: item.byObjectId })), details: { created, shotId: shot.id, keyId, frame, framing, screenBounds, subjectIds: [subject.id] } };
|
|
285
448
|
}
|
|
286
449
|
|
|
287
450
|
/** Local journal. Unsettled/protected records are never evicted. Call prune on
|
|
@@ -10,10 +10,6 @@ export function encodeStudioContext(context) {
|
|
|
10
10
|
if (utf8ByteLength(encoded) > STUDIO_CONTEXT_MAX_BYTES) fail("CONTEXT_TOO_LARGE", `Escaped Studio context exceeds ${STUDIO_CONTEXT_MAX_BYTES / 1024} KiB.`);
|
|
11
11
|
return encoded;
|
|
12
12
|
}
|
|
13
|
-
export function buildStudioHistoryItem(context, userText) {
|
|
14
|
-
if (typeof userText !== "string") fail("INVALID_REQUEST", "User text must be a string.");
|
|
15
|
-
return { role: "user", content: [{ type: "input_text", text: `<studio-context>\n${encodeStudioContext(context)}\n</studio-context>` }, { type: "input_text", text: userText }] };
|
|
16
|
-
}
|
|
17
13
|
export function studioCacheKey(context, projectionKind = "scene") {
|
|
18
14
|
const c = validateStudioContext(context);
|
|
19
15
|
if (typeof projectionKind !== "string" || !projectionKind || projectionKind.length > 120) fail("INVALID_ARGUMENT", "Projection kind must be a bounded string.");
|
|
@@ -38,8 +34,9 @@ export function validateStudioCursor(cursor, context) {
|
|
|
38
34
|
/** The compact command index the turn carries: id and label for every
|
|
39
35
|
* registered command, plus the declared generation and hub timeout. The
|
|
40
36
|
* description and input schema stay out; the agent reads them on request. */
|
|
41
|
-
export function studioActionIndex(actions) {
|
|
42
|
-
|
|
37
|
+
export function studioActionIndex(actions, { previsMode } = {}) {
|
|
38
|
+
const available = actions.filter(action => !action.agentHidden && (previsMode !== "storyboard" || (action.generation !== "motion" && action.domain !== "motion")));
|
|
39
|
+
return available.map(({ id, label, generation, timeoutMs }) => ({ id, ...(label ? { label: [...label].slice(0, 120).join("") } : {}),
|
|
43
40
|
...(generation ? { generation } : {}), ...(timeoutMs === undefined ? {} : { timeoutMs }) }));
|
|
44
41
|
}
|
|
45
42
|
|
|
@@ -49,7 +46,7 @@ export function studioActionIndex(actions) {
|
|
|
49
46
|
// truncation so forbidden data cannot hide in an omitted row.
|
|
50
47
|
export function buildStudioContext({ actions, ...input }) {
|
|
51
48
|
const c = structuredClone(input);
|
|
52
|
-
if (actions) c.actionIndex = studioActionIndex(actions);
|
|
49
|
+
if (actions) c.actionIndex = studioActionIndex(actions, { previsMode: c.scene?.previsMode });
|
|
53
50
|
const boundDisplay = value => {
|
|
54
51
|
if (!value || typeof value !== "object") return;
|
|
55
52
|
for (const [key, child] of Object.entries(value)) {
|
|
@@ -117,13 +114,18 @@ const object = fields => ({ type: "object", properties: fields, required: Object
|
|
|
117
114
|
const array = (items, maxItems = 10000, minItems = 0) => ({ type: "array", items, minItems, maxItems });
|
|
118
115
|
const vec = StudioSchemas.Vec3;
|
|
119
116
|
const timing = object({ cuts: array(object({ t: nonnegative, d: nonnegative }), 64), envelopes: array(array(nonnegative, 24, 24), 65, 1) });
|
|
120
|
-
|
|
117
|
+
// `marks` (the editor's grab dots, arc fractions) is optional: it is UI state on
|
|
118
|
+
// the route, and older fixtures and agent-written routes carry none.
|
|
119
|
+
const pathFields = { points: array(vec, 64, 2), speed: nonnegative, faceTravel: bool, loop: bool, extend: bool, timing: nullable(timing) };
|
|
120
|
+
// A mark is an arc fraction, or { t, bank, pitch } (degrees) when it carries a lean.
|
|
121
|
+
const routeMark = { oneOf: [nonnegative, { type: "object", properties: { t: nonnegative, bank: num, pitch: num }, required: ["t"], additionalProperties: false }] };
|
|
122
|
+
const path = { ...object({ ...pathFields, marks: array(routeMark, 6) }), required: Object.keys(pathFields) };
|
|
121
123
|
const physicalObject = object({ id, renderer: id, position: vec, rotationDeg: vec, scale: object({ x: positive, y: positive, z: positive }),
|
|
122
124
|
footprint: object({ width: nonnegative, depth: nonnegative }), height: nonnegative, supportY: num, parentId: nullable(id), attachment: nullable(object({ characterId: id, bone: nullable(id) })), path: nullable(path), hidden: bool });
|
|
123
125
|
const physicalCharacter = object({ id, incarnation: id, modelId: nullable(id), rigId: nullable(id), rigReady: bool, hidden: bool, position: vec, yawDeg: num, scale: positive,
|
|
124
126
|
takeId: nullable(id), sessionMotionId: nullable(id), motionRevision: int, calibrationRevision: int, ikRevision: int,
|
|
125
127
|
waypoints: array(object({ frame: int, position: vec }), 10000) });
|
|
126
|
-
|
|
128
|
+
const PHYSICS_FINGERPRINT_SCHEMA = freezeStudioData(object({ objects: array(physicalObject), characters: array(physicalCharacter), floor: object({ model: id, y: num }), frameCount: int }));
|
|
127
129
|
export function physicsFingerprintInput(input) {
|
|
128
130
|
// Ignore explicitly nonphysical source fields. Nested physical shapes remain
|
|
129
131
|
// closed and validated, including path timing and every inactive actor stamp.
|
|
@@ -14,6 +14,7 @@ import { createGroundSampler } from './ardy/ground.js';
|
|
|
14
14
|
import { OBJECT_LIBRARY } from './scene-objects.js';
|
|
15
15
|
import { objectTransformAt } from './object-path.js';
|
|
16
16
|
import { sampleAt } from './sample-at.js';
|
|
17
|
+
import { isProxyFigure } from './scenes.js';
|
|
17
18
|
|
|
18
19
|
const PROFILE = 'studio-motion-v1', BLEND = 6;
|
|
19
20
|
const fail = (code, message) => { throw new StudioProtocolError(code, message); };
|
|
@@ -76,7 +77,7 @@ const copyEnvironment = env => ({ ...env, host: structuredClone(env.host), floor
|
|
|
76
77
|
* refused rig leaves every evaluator made before it disposable. */
|
|
77
78
|
function stageCast(env, characterId, cast) {
|
|
78
79
|
for (const member of env.cast) {
|
|
79
|
-
if (member.character.id === characterId || member.character.hidden) continue;
|
|
80
|
+
if (member.character.id === characterId || member.character.hidden || isProxyFigure(member.character)) continue;
|
|
80
81
|
// Unsupported bystanders remain explicit missing coverage, never vanish
|
|
81
82
|
// into characterBlockers' otherwise legitimate best-effort skip path.
|
|
82
83
|
const entry = { character: structuredClone(member.character), motion: member.motion ? structuredClone(member.motion) : null, layer: copyLayer(member.ikState), evaluator: null };
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// Shared by the browser and sidecar. No Node, React, renderer or provider imports.
|
|
2
2
|
import { STUDIO_ELEMENTS, isSettableElement } from "./studio-elements.js";
|
|
3
|
+
// Mirrors PREVIS_MODES (project.js) and POSTURES (scenes.js). Neither is imported:
|
|
4
|
+
// both pull in three.js, which the agent sidecar must not load. The protocol test pins the lists to their owners.
|
|
5
|
+
const PREVIS_MODES = ["storyboard", "animation"], POSTURES = ["stand", "sit", "lie"];
|
|
3
6
|
|
|
4
7
|
export const STUDIO_PROTOCOL_VERSION = "studio-agent-v1";
|
|
5
8
|
// Sized for the compact index of up to 400 entities (~100 bytes each) beside
|
|
@@ -76,6 +79,8 @@ const positiveVec3 = object({ x: positive, y: positive, z: positive });
|
|
|
76
79
|
const range = { ...object({ startFrame: integer(), endFrameExclusive: integer(1) }), "x-studio-range": true };
|
|
77
80
|
const ids = (max = 100, min = 1) => array(id, max, min, true);
|
|
78
81
|
const name = text(120);
|
|
82
|
+
// A placement reference: an id, or the name of a part created earlier in the same ops list.
|
|
83
|
+
const ref = { ...text(120), description: "Object id, or the name of a part created EARLIER in this ops list (create it first)." };
|
|
79
84
|
const identityFields = { workspaceId: id, documentEpoch: id, sceneId: id, sceneEpoch: id };
|
|
80
85
|
const identity = object(identityFields);
|
|
81
86
|
const host = object({ surface: literal("studio"), ...identityFields, workspaceHandle: nullable(id) });
|
|
@@ -84,18 +89,22 @@ const selection = nullable(object({ kind: choices(STUDIO_VARIANTS.selectionKinds
|
|
|
84
89
|
const view = object({ mode: choices(STUDIO_VARIANTS.modes), frame: integer(), playing: bool, lookThrough: bool, grid: bool, autoColor: bool });
|
|
85
90
|
const position = union(
|
|
86
91
|
object({ world: vec3 }),
|
|
87
|
-
object({ relativeTo:
|
|
88
|
-
object({ between:
|
|
89
|
-
object({ onObject:
|
|
92
|
+
object({ relativeTo: ref, basis: choices(STUDIO_VARIANTS.positionBases), side: choices(STUDIO_VARIANTS.positionSides), gapM: number(0), support: union(literal("floor"), object({ objectId: ref })) }),
|
|
93
|
+
object({ between: array(ref, 2, 2, true), fraction: number(0, 1), support: literal("floor") }),
|
|
94
|
+
object({ onObject: ref }, { offsetXZ: object({ x: number(), z: number() }) }),
|
|
90
95
|
);
|
|
91
|
-
const facing = union(object({ yawDeg: number() }), object({ towardId:
|
|
96
|
+
const facing = union(object({ yawDeg: number() }), object({ towardId: ref }), object({ sameAsId: ref }), object({ awayFromId: ref }));
|
|
92
97
|
const framing = union(
|
|
93
98
|
object({ intent: object({ size: choices(STUDIO_VARIANTS.framingSizes), view: choices(STUDIO_VARIANTS.framingViews), level: choices(STUDIO_VARIANTS.framingLevels), side: choices(STUDIO_VARIANTS.framingSides) }, { focalMm: positive }) }),
|
|
94
99
|
object({ exact: object({ position: vec3, lookAt: vec3, focalMm: positive }) }),
|
|
95
100
|
);
|
|
101
|
+
const localSpace = { ...choices(["parent"]), description: "\"parent\" reads position.world, rotationDeg and facing.yawDeg in the parent's local frame (its pivot and rotation, not its scale); omit for world space." };
|
|
96
102
|
const objectOp = union(
|
|
97
|
-
object({ op: literal("create"), source: object({ kind: id }), position }, { name, facing, scale: positiveVec3
|
|
98
|
-
|
|
103
|
+
object({ op: literal("create"), source: object({ kind: id }), position }, { name, facing, scale: positiveVec3,
|
|
104
|
+
rotationDeg: { ...vec3, description: "Euler XYZ degrees (x pitch, y yaw, z roll), as in update. Exclusive with facing. A pitched/rolled part rests its lowest corner on its support." },
|
|
105
|
+
parent: { ...name, description: "Group this new object under a parent: an existing object id, or the name of an object created earlier in this same ops list. Moving the parent then moves it too." },
|
|
106
|
+
space: localSpace }),
|
|
107
|
+
object({ op: literal("update"), id }, { position, facing, rotationDeg: vec3, scale: positiveVec3, color: text(32), name, hidden: bool, space: localSpace }),
|
|
99
108
|
object({ op: literal("remove"), id }),
|
|
100
109
|
object({ op: literal("group"), parentId: id, childIds: ids() }),
|
|
101
110
|
object({ op: literal("ungroup"), childIds: ids() }),
|
|
@@ -105,7 +114,8 @@ const characterOp = union(
|
|
|
105
114
|
object({ op: literal("update"), characterId: id }, { position, facing, scale: positive, name, hidden: bool }),
|
|
106
115
|
object({ op: literal("remove"), characterId: id }),
|
|
107
116
|
);
|
|
108
|
-
const
|
|
117
|
+
export const BEAT_TEXT_MAX = 500;
|
|
118
|
+
const generateSource = object({ kind: literal("generate"), beats: array(object({ text: { ...text(BEAT_TEXT_MAX), description: 'An English description of the body movement starting with "A person". The editor refuses prompts over 500 characters.' } }, { seconds: number(0.5, 60) }), 8, 1) }, { durationSeconds: number(2, 60), seed: integer(-2147483648, 2147483647) });
|
|
109
119
|
const source = union(generateSource, object({ kind: literal("reuse"), artifactId: id }));
|
|
110
120
|
|
|
111
121
|
/* ----------------------------------------------- element patches ----
|
|
@@ -118,7 +128,11 @@ const source = union(generateSource, object({ kind: literal("reuse"), artifactId
|
|
|
118
128
|
const dataImage = { ...text(2 * 1024 * 1024), pattern: "^data:image/[A-Za-z0-9.+-]+[;,]" };
|
|
119
129
|
const promptBlock = object({ startFrame: integer(), endFrame: integer(1), text: text(2000) }, { id });
|
|
120
130
|
const cameraKey = object({ frame: integer(), framing: object({ pos: vec3, yaw: number(), pitch: number(), fovDeg: number(1, 179) }) }, { id });
|
|
121
|
-
|
|
131
|
+
// A mark is an arc fraction, or { t, bank, pitch } when the object should lean
|
|
132
|
+
// there (degrees, +-90). The route normaliser (src/object-path.js) owns the
|
|
133
|
+
// rules; this only carries the shape through untouched.
|
|
134
|
+
const routeMark = union(number(0, 1), object({ t: number(0, 1) }, { bank: number(-90, 90), pitch: number(-90, 90) }));
|
|
135
|
+
const objectRoute = nullable(object({ points: array(vec3, 64, 2) }, { speed: number(0, 50), faceTravel: bool, loop: bool, extend: bool, marks: array(routeMark, 6) }));
|
|
122
136
|
const PATCH_VALUE_SCHEMAS = {
|
|
123
137
|
"character.pose": nullable(id), "character.identityImage": nullable(dataImage), "character.promptBlocks": array(promptBlock, 64),
|
|
124
138
|
"object.parent": nullable(id), "object.path": objectRoute, "stage.environmentImage": nullable(dataImage),
|
|
@@ -186,12 +200,12 @@ const patchOp = union(
|
|
|
186
200
|
const toolSchemas = {
|
|
187
201
|
inspect_studio: object({ scope: choices(STUDIO_VARIANTS.inspectionScopes) }, { ids: ids(32), select: ids(32), query: name, cursor: text(512), limit: { ...integer(1, 32), default: 12 } }),
|
|
188
202
|
operate_studio: object({}, { selection, shotId: id, frame: integer(), playing: bool, mode: choices(STUDIO_VARIANTS.modes), view: object({}, { lookThrough: bool, grid: bool, autoColor: bool }) }),
|
|
189
|
-
arrange_objects: object({ ops: array(objectOp, 100, 1) }, { collisionPolicy: { ...choices(STUDIO_VARIANTS.collisionPolicies), default: "report" } }),
|
|
203
|
+
arrange_objects: object({ ops: { ...array(objectOp, 100, 1), description: "Applied in order, as one undo step. Each op sees the parts created by earlier ops, so build an assembly in one call: create the main part, then place the rest with position.onObject / relativeTo(+side,gapM) / between naming earlier parts (e.g. relativeTo \"Hood\"). Reference parts created in this call by name, never by id." } }, { collisionPolicy: { ...choices(STUDIO_VARIANTS.collisionPolicies), default: "report" } }),
|
|
190
204
|
arrange_characters: object({ ops: array(characterOp, 8, 1) }),
|
|
191
205
|
patch_elements: object({ ops: array(patchOp, 32, 1) }),
|
|
192
|
-
frame_shot: object({ subjectIds: ids(1), framing }, { shotId: id, keyAtFrame: integer() }),
|
|
206
|
+
frame_shot: object({ subjectIds: ids(1), framing }, { shotId: id, caption: { type: "string", maxLength: 500 }, keyAtFrame: integer() }),
|
|
193
207
|
generate_motion: object({ characterId: id, source }, { repair: { ...choices(["bounded", "none"]), default: "bounded" } }),
|
|
194
|
-
verify_result: object({ checks: array(choices(["placement", "framing", "motion"]), 3, 1, true) }, { receiptId: id, targets: ids(), range: union(literal("whole_clip"), range), visual: { ...choices(["none", "frame", "contact_sheet"]), default: "none" } }),
|
|
208
|
+
verify_result: object({ checks: array(choices(["placement", "framing", "motion"]), 3, 1, true) }, { receiptId: id, targets: ids(), range: union(literal("whole_clip"), range), visual: { ...choices(["none", "frame", "contact_sheet", "plan"]), default: "none" } }),
|
|
195
209
|
undo_edit: object({ receiptId: id }),
|
|
196
210
|
run_action: object({ action: id }, { args: openObject, confirmationToken: id }),
|
|
197
211
|
};
|
|
@@ -203,9 +217,13 @@ export const STUDIO_CATALOGUE = freezeStudioData(STUDIO_TOOLS.map(name => ({ nam
|
|
|
203
217
|
const bounds = object({ min: vec3, max: vec3 });
|
|
204
218
|
const entity = object({ id, kind: choices(["object", "character", "rig"]), token: id }, {
|
|
205
219
|
name, detailsOmitted: bool, position: vec3, yawDeg: number(), rotationDeg: vec3, scale: union(positive, positiveVec3), bounds: nullable(bounds),
|
|
206
|
-
libraryKind: id, renderer: id, color: nullable(text(32)), tint: nullable(text(32)), modelId: nullable(text(120)), assetId: id, parentId: nullable(id), attachment: nullable(object({ characterId: id, bone: nullable(id) })), pathPointCount: integer(0, 64),
|
|
220
|
+
libraryKind: id, renderer: id, color: nullable(text(32)), tint: nullable(text(32)), modelId: nullable(text(120)), assetId: id, parentId: nullable(id), attachment: nullable(object({ characterId: id, bone: nullable(id) })), pathPointCount: integer(0, 64), pathMarks: array(union(number(0, 1), object({ t: number(0, 1) }, { bank: number(-90, 90), pitch: number(-90, 90) })), 6),
|
|
221
|
+
local: object({ position: vec3, rotationDeg: vec3 }),
|
|
207
222
|
motion: object({ takeId: nullable(id), frames: integer(), ikKeyCount: integer(), promptBlockCount: integer() }, { poseId: nullable(id), keyIds: ids(8, 0) }),
|
|
208
223
|
capabilities: object({ rigReady: bool, ik: bool, measuredFeet: bool }),
|
|
224
|
+
// `kind` above already names the entity class ("character"); a character's own
|
|
225
|
+
// kind is `characterKind`: a rigged model, or a rig-free capsule figure.
|
|
226
|
+
characterKind: choices(["rig", "proxy"]), posture: choices(POSTURES),
|
|
209
227
|
});
|
|
210
228
|
// One compact row per entity, so the model sees the whole scene even when
|
|
211
229
|
// only 24 rows carry full detail.
|
|
@@ -214,8 +232,8 @@ const indexRow = object({ id, kind: choices(["object", "character", "rig"]) }, {
|
|
|
214
232
|
// Its schema is on request (inspect_studio scope "actions" with ids); the
|
|
215
233
|
// declared generation and hub timeout ride along for the sidecar's gate.
|
|
216
234
|
const actionIndexRow = object({ id }, { label: name, generation: literal("motion"), timeoutMs: integer(1, 300_000) });
|
|
217
|
-
const shotSummary = object({ id, name, range, keyCount: integer() }, { subjectIds: ids(24, 0) });
|
|
218
|
-
const currentShot = object({ id, name, range, mode: choices(STUDIO_VARIANTS.shotModes) }, { subjectIds: ids(24, 0) });
|
|
235
|
+
const shotSummary = object({ id, name, range, keyCount: integer() }, { subjectIds: ids(24, 0), kind: choices(["clip", "still"]), caption: text(500) });
|
|
236
|
+
const currentShot = object({ id, name, range, mode: choices(STUDIO_VARIANTS.shotModes) }, { subjectIds: ids(24, 0), kind: choices(["clip", "still"]), caption: text(500) });
|
|
219
237
|
const camera = object({ position: vec3, lookAt: vec3, focalMm: positive, sensorId: id, slate: name });
|
|
220
238
|
// What the agent can place: a catalogue kind, or an asset imported into the scene.
|
|
221
239
|
const assetTypes = choices(["primitive", "set-piece", "image", "mesh"]);
|
|
@@ -224,12 +242,12 @@ const jobSummary = object({ id, characterId: id, state: choices(STUDIO_VARIANTS.
|
|
|
224
242
|
const contextSchema = object({
|
|
225
243
|
schema: literal("studio-context-v1"), host, revision,
|
|
226
244
|
units: object({ distance: literal("m"), angle: literal("deg"), up: literal("+Y"), yawZero: literal("+Z"), yawPositiveToward: literal("+X"), pivot: literal("base"), fps: literal(24), rangeEnd: literal("exclusive") }),
|
|
227
|
-
scene: object({ name, aspect: text(40), floorY: number(), frameCount: integer(), objectCount: integer(), characterCount: integer() }),
|
|
245
|
+
scene: object({ name, aspect: text(40), floorY: number(), frameCount: integer(), objectCount: integer(), characterCount: integer() }, { previsMode: choices(PREVIS_MODES) }),
|
|
228
246
|
selection, activeCharacterId: nullable(id), view, shot: nullable(currentShot), camera: nullable(camera),
|
|
229
247
|
entities: array(entity, 24), entityPage: object({ returned: integer(0, 24), total: integer(), truncated: bool, nextCursor: nullable(text(512)) }),
|
|
230
248
|
shots: array(shotSummary, 8), shotsTruncated: bool, assets: array(assetSummary, STUDIO_CONTEXT_LIMITS.assets),
|
|
231
249
|
recentReceipts: array(object({ id, summary: name, canUndoDirect: bool }), 3), jobs: array(jobSummary, 8),
|
|
232
|
-
capabilities: object({ profile: literal("studio-slice-1"), tools: array(choices(STUDIO_TOOLS), STUDIO_TOOLS.length, 0, true) }, { rigReady: bool, cameraReady: bool, bridgeReady: bool }),
|
|
250
|
+
capabilities: object({ profile: literal("studio-slice-1"), tools: array(choices(STUDIO_TOOLS), STUDIO_TOOLS.length, 0, true) }, { rigReady: bool, cameraReady: bool, bridgeReady: bool, storyboard: bool }),
|
|
233
251
|
}, { entityIndex: array(indexRow, STUDIO_CONTEXT_LIMITS.entityIndex), actionIndex: array(actionIndexRow, 512) });
|
|
234
252
|
const guardSchema = object({ ...identityFields, targetId: id, token: id });
|
|
235
253
|
const efforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
|
|
@@ -238,7 +256,7 @@ const efforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ul
|
|
|
238
256
|
// and the three inline types are the ones the model actually reads. Four, and
|
|
239
257
|
// six megabytes of base64 each, is what one turn may carry.
|
|
240
258
|
const attachment = object({ dataUrl: { ...text(6_000_000), pattern: "^data:image/(png|jpeg|webp);base64," } }, { name: text(120) });
|
|
241
|
-
const turnSchema = object({ surface: literal("studio"), sessionId: uuidSchema, turnId: uuidSchema, text: text(16000), context: contextSchema }, { model: text(120), effort: choices(efforts), attachFrame: bool, attachments: array(attachment, 4, 1) });
|
|
259
|
+
const turnSchema = object({ surface: literal("studio"), sessionId: uuidSchema, turnId: uuidSchema, text: text(16000), context: contextSchema }, { model: text(120), effort: choices(efforts), attachFrame: bool, attachments: array(attachment, 4, 1), supervisor: bool });
|
|
242
260
|
const stopSchema = object({ surface: literal("studio"), sessionId: uuidSchema, turnId: uuidSchema }, { jobId: id });
|
|
243
261
|
const revisions = object({ before: integer(), after: integer() });
|
|
244
262
|
const undo = object({ historyEntryId: id, entries: literal(1), canUndoDirect: bool });
|
|
@@ -254,10 +272,21 @@ const verification = object({ id, status: choices(["verified", "unverified"]), p
|
|
|
254
272
|
// not a missing member.
|
|
255
273
|
const patchedValue = object({ path: text(120) }, { number: number(), text: nullable(text(512)), flag: bool, vec: vec3, count: integer(), bytes: integer() });
|
|
256
274
|
const readback = object({}, { position: vec3, yawDeg: number(), rotationDeg: vec3, scale: union(positive, positiveVec3), name, color: text(32), hidden: bool, modelId: id, renderer: id,
|
|
257
|
-
parentId: nullable(id), childIds: ids(100, 0), removed: bool, range, camera, keyId: id, frame: integer(), subjectIds: ids(24, 0), selection, activeCharacterId: nullable(id), shotId: nullable(id), view, token: id, takeId: nullable(id), statureM: positive,
|
|
275
|
+
parentId: nullable(id), childIds: ids(100, 0), childCount: integer(0), bounds: nullable(bounds), removed: bool, range, camera, keyId: id, frame: integer(), subjectIds: ids(24, 0), selection, activeCharacterId: nullable(id), shotId: nullable(id), view, token: id, takeId: nullable(id), statureM: positive,
|
|
258
276
|
patched: array(patchedValue, 32, 1) });
|
|
277
|
+
const CAMERA_SIDES = ["left", "right", "on-axis"];
|
|
259
278
|
const checks = object({ coverage: name }, { relationSatisfied: bool, overlapIds: ids(100, 0), actualGapM: number(), requestedGapM: number(0), maximumFootprintOverlapM: number(0),
|
|
260
|
-
basis: choices(STUDIO_VARIANTS.positionBases), clipped: bool, occluded: bool, behindCamera: bool, screenFraction: number(0), derivedSize: choices(STUDIO_VARIANTS.framingSizes), support: name, baseY: number(), facesTargetId: id
|
|
279
|
+
basis: choices(STUDIO_VARIANTS.positionBases), clipped: bool, occluded: bool, behindCamera: bool, screenFraction: number(0), derivedSize: choices(STUDIO_VARIANTS.framingSizes), support: name, baseY: number(), facesTargetId: id,
|
|
280
|
+
cameraSide: choices(CAMERA_SIDES), axisConsistent: bool });
|
|
281
|
+
// Code-computed shot geometry verify_result carries beside a framing check
|
|
282
|
+
// (src/studio-geometry-facts.js): bounded to 8 shots and 24 subjects.
|
|
283
|
+
const geometryFacts = object({
|
|
284
|
+
subjects: array(object({ id, position: vec3, yawDeg: number() }), 24),
|
|
285
|
+
pairs: array(object({ a: id, b: id, distanceM: number(0) }), 24),
|
|
286
|
+
shots: array(object({ shotId: id, subjectIds: ids(2, 0), camera: nullable(object({ position: vec3, lookAt: vec3 })), cameraSide: nullable(choices(CAMERA_SIDES)),
|
|
287
|
+
occluders: array(object({ subjectId: id, byObjectId: id }), 8) }), 8),
|
|
288
|
+
axisConsistent: nullable(bool),
|
|
289
|
+
});
|
|
261
290
|
const warning = object({ code: id }, { id, message: name, suggestedOutwardDeltaM: number(), count: integer() });
|
|
262
291
|
const receiptBase = { ok: literal(true), commandId: id, receiptId: id, host: identity, status: choices(STUDIO_VARIANTS.receiptStatuses), authored: bool, revision: revisions, affectedIds: ids(100, 0),
|
|
263
292
|
delta: array(object({ id, after: readback }), 8), checks, undo: nullable(undo), warnings: array(warning, 12) };
|
|
@@ -293,7 +322,7 @@ const failureSchema = object({ ok: literal(false), commandId: id, host: identity
|
|
|
293
322
|
// x-studio-range is the sole relational schema annotation: end > start.
|
|
294
323
|
export const StudioSchemas = freezeStudioData({ catalogue: STUDIO_CATALOGUE, variants: STUDIO_VARIANTS, Vec3: vec3, FrameRange: range, PositionSpec: position, FacingSpec: facing,
|
|
295
324
|
ObjectOp: objectOp, CharacterOp: characterOp, Framing: framing, GenerateSource: generateSource, Source: source, Identity: identity, Host: host, TargetGuard: guardSchema,
|
|
296
|
-
Entity: entity, ShotSummary: shotSummary, StudioContextV1: contextSchema, StudioTurn: turnSchema, StudioStop: stopSchema, Receipt: receiptSchema, ReceiptVariants: receiptVariants, Failure: failureSchema });
|
|
325
|
+
Entity: entity, ShotSummary: shotSummary, StudioContextV1: contextSchema, StudioTurn: turnSchema, StudioStop: stopSchema, Receipt: receiptSchema, ReceiptVariants: receiptVariants, Failure: failureSchema, GeometryFacts: geometryFacts });
|
|
297
326
|
|
|
298
327
|
/** Validate the closed JSON Schema subset used above; return a detached value
|
|
299
328
|
* with declared defaults. Errors contain schema paths, never payload values. */
|
|
@@ -341,7 +370,7 @@ export function validateStudioSchema(schema, value, code = "INVALID_ARGUMENT", p
|
|
|
341
370
|
return result;
|
|
342
371
|
}
|
|
343
372
|
if (schema.type === "string") {
|
|
344
|
-
if (typeof value !== "string" || !value.trim() || (schema.maxLength !== undefined && [...value].length > schema.maxLength) || (schema.pattern && !new RegExp(schema.pattern).test(value))) fail(code, "Invalid or oversized string.", path);
|
|
373
|
+
if (typeof value !== "string" || !value.trim() || (schema.maxLength !== undefined && [...value].length > schema.maxLength) || (schema.pattern && !new RegExp(schema.pattern).test(value))) fail(code, typeof value === "string" && schema.description && [...value].length > schema.maxLength ? `Over ${schema.maxLength} characters. ${schema.description}` : "Invalid or oversized string.", path);
|
|
345
374
|
if (schema.enum && !schema.enum.includes(value)) fail(code, "Unsupported enum value.", path);
|
|
346
375
|
return value;
|
|
347
376
|
}
|
|
@@ -463,10 +492,14 @@ export function validateStudioCommand(command) {
|
|
|
463
492
|
if (new Set(names).size !== names.length) fail("DUPLICATE_NAME", "Create names must be unique; resolve existing targets by ID.");
|
|
464
493
|
for (const op of args.ops) {
|
|
465
494
|
if (op.op === "update" && Object.keys(op).length === 2) fail("INVALID_ARGUMENT", "Update has no fields.");
|
|
466
|
-
if (op.facing && op.rotationDeg) fail("INVALID_ARGUMENT", "Facing and
|
|
495
|
+
if (op.facing && op.rotationDeg) fail("INVALID_ARGUMENT", "Facing and rotationDeg are exclusive: facing sets yaw only (toward/away/same-as/yawDeg); rotationDeg {x,y,z} sets the full rotation, with y as the yaw.");
|
|
467
496
|
if (op.op === "group" && op.childIds.includes(op.parentId)) fail("INVALID_ARGUMENT", "Cannot group an object under itself.");
|
|
468
|
-
if (
|
|
497
|
+
if (op.op === "create" && op.parent !== undefined && op.name !== undefined && op.parent.normalize("NFC").trim() === op.name.normalize("NFC").trim()) fail("INVALID_ARGUMENT", "Cannot group an object under itself.");
|
|
469
498
|
}
|
|
499
|
+
// Avoidance nudges outward from a relativeTo reference, so at least one op needs one;
|
|
500
|
+
// the others (world/onObject/between, remove, group) are placed as given and the
|
|
501
|
+
// final batch is still checked for overlap.
|
|
502
|
+
if (args.collisionPolicy === "avoid" && !args.ops.some(op => ["create", "update"].includes(op.op) && op.position?.relativeTo)) fail("INVALID_ARGUMENT", "collisionPolicy avoid needs at least one op with position.relativeTo (it nudges outward from that reference); other ops are placed as given.");
|
|
470
503
|
}
|
|
471
504
|
if (command.name === "generate_motion" && args.source.kind === "generate") {
|
|
472
505
|
args.source.beats = args.source.beats.map(beat => ({ ...beat, text: beat.text.trim().replace(/\s+/g, " ") }));
|