cozyclay 2.0.2 → 2.1.1
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 +61 -0
- package/README.md +17 -2
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/bin/agent/agent-routes.mjs +211 -36
- 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 +4 -4
- package/bin/live/cli.mjs +23 -8
- package/bin/live/client.mjs +1 -1
- package/bin/live/studio.mjs +5 -2
- package/bin/mcp-runtime.mjs +2 -2
- package/bin/telemetry-state.mjs +3 -3
- package/dist/app/index.html +6 -6
- package/dist/assets/analytics-C9pMpW7s.js +1 -0
- package/dist/assets/app-B_2XB9rw.css +1 -0
- package/dist/assets/app-DAuvKPP8.js +4920 -0
- package/dist/assets/{demo-ByeYp-dY.js → demo-CvKuBgy5.js} +1 -1
- package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-msLI71g1.js} +1 -1
- package/dist/assets/{landing-BaXkpFip.js → landing-ChUvB_zL.js} +1 -1
- package/dist/assets/scene-asset-sync-DKkpjKBV.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-BPk2Ni95.js} +1 -1
- package/dist/assets/workflow-jJRBR94Z.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +328 -222
- package/dist/llms.txt +14 -0
- package/dist/sitemap.xml +1 -1
- 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 +813 -278
- 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 +184 -6
- package/src/ardy/timeline.jsx +121 -36
- package/src/ardy/to-cskel27.js +0 -2
- package/src/ardy/waypoints.js +0 -3
- 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 +6 -7
- 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 +15 -2
- 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 +79 -28
- 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-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 +49 -4
- 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 +58 -9
- 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 +60 -16
- package/src/shell/CompositionGuideButton.jsx +20 -0
- package/src/shell/DetailsSlot.jsx +26 -6
- package/src/shell/MenuBar.jsx +78 -6
- package/src/shell/OutlinerSlot.jsx +3 -1
- package/src/shell/ShotPreviewHeader.jsx +17 -0
- package/src/shell/TopBar.jsx +18 -3
- package/src/shell/ViewportToolbar.jsx +36 -31
- 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/shell/viewport.css +12 -0
- 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 +213 -23
- package/src/studio-agent-context.js +11 -9
- package/src/studio-agent-motion.js +2 -1
- package/src/studio-agent-protocol.js +60 -24
- package/src/studio-app-binding.js +56 -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 +92 -9
- 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 +106 -18
- 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/gt-render/browser.mjs +23 -0
- package/tools/headless-studio.mjs +85 -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 +95 -7
- package/dist/assets/analytics-MVZOEW8O.js +0 -1
- package/dist/assets/app-BxBLC6GT.js +0 -4876
- package/dist/assets/app-CJdxvnrl.css +0 -1
- package/dist/assets/shot-prompt-hN0j_O-p.js +0 -17
- package/dist/assets/workflow-B5XZwVfe.js +0 -179
|
@@ -1,13 +1,17 @@
|
|
|
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';
|
|
14
|
+
import { sampleContactSheetFrames } from './studio-contact-sheet.js';
|
|
11
15
|
|
|
12
16
|
const DEG = Math.PI / 180, EPS = 1e-8, CHARACTER_SUPPORT_TOLERANCE = 5e-3;
|
|
13
17
|
const fail = (code, message) => { throw new StudioProtocolError(code, message); };
|
|
@@ -20,6 +24,39 @@ const entityById = (state, id) => {
|
|
|
20
24
|
if (!entity) fail('AMBIGUOUS_TARGET', 'Target ID is not present in the admitted scene.');
|
|
21
25
|
return entity;
|
|
22
26
|
};
|
|
27
|
+
/** Resolve a placement reference against the evolving batch draft: an id first,
|
|
28
|
+
* else the unique name of an object or character already in it. Parts created
|
|
29
|
+
* earlier in the same batch are in the draft, so they resolve by name. */
|
|
30
|
+
function resolveRef(ref, state, field) {
|
|
31
|
+
const all = [...state.objects, ...state.characters];
|
|
32
|
+
if (all.some(e => e.id === ref)) return ref;
|
|
33
|
+
const named = all.filter(e => normalizedName(e.name ?? e.subject ?? '') === normalizedName(ref));
|
|
34
|
+
if (named.length === 1) return named[0].id;
|
|
35
|
+
if (named.length) fail('AMBIGUOUS_TARGET', `${field} '${ref}' matches ${named.length} objects by name (${named.map(e => e.id).join(', ')}); reference one by id.`);
|
|
36
|
+
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).`);
|
|
37
|
+
}
|
|
38
|
+
/** The op with every reference field (relativeTo, onObject, between, support,
|
|
39
|
+
* facing targets) resolved to an id in the current draft. */
|
|
40
|
+
function resolveOpRefs(op, state, selfId) {
|
|
41
|
+
const { position: p, facing: f } = op;
|
|
42
|
+
if (!p && !f) return op;
|
|
43
|
+
const r = (value, field) => {
|
|
44
|
+
const resolved = resolveRef(value, state, field);
|
|
45
|
+
if (resolved === selfId) fail('INVALID_ARGUMENT', `${field} '${value}' is the object being placed; reference a different part.`);
|
|
46
|
+
return resolved;
|
|
47
|
+
};
|
|
48
|
+
const out = { ...op };
|
|
49
|
+
if (p) out.position = { ...p,
|
|
50
|
+
...(p.relativeTo !== undefined ? { relativeTo: r(p.relativeTo, 'relativeTo') } : {}),
|
|
51
|
+
...(p.onObject !== undefined ? { onObject: r(p.onObject, 'onObject') } : {}),
|
|
52
|
+
...(p.between ? { between: p.between.map(value => r(value, 'between')) } : {}),
|
|
53
|
+
...(p.support && typeof p.support === 'object' ? { support: { objectId: r(p.support.objectId, 'support.objectId') } } : {}) };
|
|
54
|
+
if (f) {
|
|
55
|
+
const key = ['towardId', 'awayFromId', 'sameAsId'].find(k => f[k] !== undefined);
|
|
56
|
+
if (key) out.facing = { [key]: r(f[key], `facing.${key}`) };
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
23
60
|
function boxPoints(bounds) {
|
|
24
61
|
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
62
|
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 +92,7 @@ function support(spec, state) {
|
|
|
55
92
|
const id = spec.onObject ?? (typeof spec.support === 'object' ? spec.support.objectId : null);
|
|
56
93
|
if (!id) return { y: state.floorY, label: 'floor' };
|
|
57
94
|
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.');
|
|
95
|
+
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
96
|
return { y: object.y + supportHeightForObject(object) * object.scaleY, label: `object:${id}`, object };
|
|
60
97
|
}
|
|
61
98
|
function facingYaw(spec, entity, state) {
|
|
@@ -106,6 +143,9 @@ function place(entity, op, state, ports) {
|
|
|
106
143
|
if (Math.hypot(result.x - previous.x, result.z - previous.z, result.rot - previous.rot) < EPS) { converged = true; break; }
|
|
107
144
|
}
|
|
108
145
|
if (!converged) fail('AMBIGUOUS_BASIS', 'Facing and placement cannot satisfy the requested relation.');
|
|
146
|
+
// A pitched/rolled part rests its lowest corner on the support.
|
|
147
|
+
if (result.renderer && !result.attach && !result.path && (Math.abs(result.rotX) > EPS || Math.abs(result.rotZ) > EPS))
|
|
148
|
+
result = patchEntity(result, { y: result.y + surface.y - Math.min(...geometry(result, state, ports).map(p => p.y)) });
|
|
109
149
|
const points = geometry(result, state, ports);
|
|
110
150
|
const supportTolerance = !result.renderer && surface.label === 'floor' ? CHARACTER_SUPPORT_TOLERANCE : EPS;
|
|
111
151
|
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 +156,90 @@ function place(entity, op, state, ports) {
|
|
|
116
156
|
}
|
|
117
157
|
return { entity: result, relation: { id: result.id, spec, axis, support: surface.label, baseY: surface.y, ...(axis ? { actualGapM, requestedGapM: spec.gapM, basis: spec.basis } : {}) } };
|
|
118
158
|
}
|
|
159
|
+
// Parent-relative (local) space. Object rows are flat world-space; `parent` only
|
|
160
|
+
// groups. A parent's frame is its stored pivot (base, y = bottom) and Euler XYZ
|
|
161
|
+
// degrees; parent SCALE is deliberately not part of the frame.
|
|
162
|
+
const objectQuat = o => new Quaternion().setFromEuler(new Euler((o.rotX ?? 0) * DEG, (o.rot ?? 0) * DEG, (o.rotZ ?? 0) * DEG, 'XYZ'));
|
|
163
|
+
const wrapDeg = a => ((a + 540) % 360 + 360) % 360 - 180;
|
|
164
|
+
// XYZ Euler angles come in two equal-orientation spellings, (x,y,z) and
|
|
165
|
+
// (x+180, 180-y, z+180). Report the one with the least pitch/roll, so a pure
|
|
166
|
+
// yaw of 135 reads back as yaw 135 rather than (180, 45, 180).
|
|
167
|
+
function eulerDegOf(q) {
|
|
168
|
+
const e = new Euler().setFromQuaternion(q, 'XYZ'), a = { x: e.x / DEG, y: e.y / DEG, z: e.z / DEG };
|
|
169
|
+
const b = { x: wrapDeg(a.x + 180), y: wrapDeg(180 - a.y), z: wrapDeg(a.z + 180) };
|
|
170
|
+
return Math.abs(b.x) + Math.abs(b.z) < Math.abs(a.x) + Math.abs(a.z) - 1e-9 ? b : a;
|
|
171
|
+
}
|
|
172
|
+
const eulerQuat = r => new Quaternion().setFromEuler(new Euler(r.x * DEG, r.y * DEG, r.z * DEG, 'XYZ'));
|
|
173
|
+
/** world = parentPivot + R_parent * local; orientation = R_parent * R_local. */
|
|
174
|
+
export function localToWorld(parent, local) {
|
|
175
|
+
const p = vector(local.position ?? { x: 0, y: 0, z: 0 }).applyQuaternion(objectQuat(parent)).add(vector(pos(parent)));
|
|
176
|
+
const q = objectQuat(parent).multiply(eulerQuat(local.rotationDeg ?? { x: 0, y: 0, z: 0 }));
|
|
177
|
+
return { position: { x: p.x, y: p.y, z: p.z }, rotationDeg: eulerDegOf(q) };
|
|
178
|
+
}
|
|
179
|
+
/** Inverse of localToWorld for an object row under its parent row. */
|
|
180
|
+
export function worldToLocal(parent, object) {
|
|
181
|
+
const inverse = objectQuat(parent).invert();
|
|
182
|
+
const p = vector(pos(object)).sub(vector(pos(parent))).applyQuaternion(inverse);
|
|
183
|
+
return { position: { x: p.x, y: p.y, z: p.z }, rotationDeg: eulerDegOf(inverse.multiply(objectQuat(object))) };
|
|
184
|
+
}
|
|
185
|
+
const tidy = v => Math.round(v * 1e4) / 1e4 + 0;
|
|
186
|
+
/** Compact readback: the object's transform in its parent's frame, or null for
|
|
187
|
+
* a root, a missing parent or a parent whose stored pose is not its world pose. */
|
|
188
|
+
export function localTransformReadback(object, objects) {
|
|
189
|
+
const parent = object.parent ? objects.find(o => o.id === object.parent) : null;
|
|
190
|
+
if (!parent || parent.attach || parent.path || object.attach || object.path) return null;
|
|
191
|
+
const { position, rotationDeg } = worldToLocal(parent, object);
|
|
192
|
+
const round = v => ({ x: tidy(v.x), y: tidy(v.y), z: tidy(v.z) });
|
|
193
|
+
return { position: round(position), rotationDeg: round(rotationDeg) };
|
|
194
|
+
}
|
|
195
|
+
const resolveParentRef = (ref, rows, createdByName) => rows.some(e => e.id === ref) ? ref : createdByName.get(normalizedName(ref));
|
|
196
|
+
/** Rewrites an op given in the parent's local frame into the equivalent world op. */
|
|
197
|
+
function parentFrameOp(op, entity, rows, createdByName) {
|
|
198
|
+
const create = op.op === 'create';
|
|
199
|
+
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.');
|
|
200
|
+
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.');
|
|
201
|
+
const parentId = create ? resolveParentRef(op.parent, rows, createdByName) : entity.parent;
|
|
202
|
+
const parent = rows.find(e => e.id === parentId);
|
|
203
|
+
if (!parent) fail('AMBIGUOUS_TARGET', 'space "parent": the parent is neither an existing object id nor an object created earlier in this batch.');
|
|
204
|
+
if (parent.attach || parent.path) fail('CAPABILITY_MISSING', 'space "parent": an attached or routed parent has no fixed pose to place against.');
|
|
205
|
+
if (!op.position && !op.rotationDeg && !op.facing) fail('INVALID_ARGUMENT', 'space "parent" applies to position, rotationDeg or facing; none was given.');
|
|
206
|
+
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.');
|
|
207
|
+
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.');
|
|
208
|
+
const current = create ? { position: { x: 0, y: 0, z: 0 }, rotationDeg: { x: 0, y: 0, z: 0 } } : worldToLocal(parent, entity);
|
|
209
|
+
// Local rotation: rotationDeg, else the current local one with its yaw set by
|
|
210
|
+
// facing; a new child with neither takes the parent's orientation (local zero).
|
|
211
|
+
const rotation = op.rotationDeg ?? (op.facing ? { ...current.rotationDeg, y: op.facing.yawDeg } : create ? current.rotationDeg : null);
|
|
212
|
+
const world = localToWorld(parent, { position: op.position ? op.position.world : current.position, rotationDeg: rotation ?? current.rotationDeg });
|
|
213
|
+
const { space, facing, ...rest } = op;
|
|
214
|
+
return { ...rest, ...(op.position ? { position: { world: world.position } } : {}), ...(rotation ? { rotationDeg: world.rotationDeg } : {}) };
|
|
215
|
+
}
|
|
216
|
+
function groupRootOf(id, objects) {
|
|
217
|
+
const seen = new Set();
|
|
218
|
+
let current = objects.find(o => o.id === id);
|
|
219
|
+
while (current?.parent && !seen.has(current.id)) {
|
|
220
|
+
seen.add(current.id);
|
|
221
|
+
current = objects.find(o => o.id === current.parent) ?? current;
|
|
222
|
+
}
|
|
223
|
+
return current?.id ?? id;
|
|
224
|
+
}
|
|
119
225
|
function overlapsFor(entity, state, ports) {
|
|
120
226
|
const own = aabb(geometry(entity, state, ports));
|
|
121
|
-
|
|
227
|
+
// Parts of one grouped object are built to touch; only other bodies count.
|
|
228
|
+
const root = groupRootOf(entity.id, state.objects);
|
|
229
|
+
return [...state.objects, ...state.characters].filter(e => e.id !== entity.id && !isEffectivelyHidden(e, state.objects, state.characters)
|
|
230
|
+
&& 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
231
|
}
|
|
123
232
|
function overlapsOf(ids, state, ports) {
|
|
124
233
|
const entities = [...state.objects, ...state.characters];
|
|
125
234
|
return ids.flatMap(id => { const e = entities.find(row => row.id === id); return e ? overlapsFor(e, state, ports) : []; });
|
|
126
235
|
}
|
|
236
|
+
/** World AABB of one entity as {min,max}, or null when its evaluated bounds are unavailable (a prop whose renderer is not mounted). */
|
|
237
|
+
export function entityWorldBounds(entity, state, ports) {
|
|
238
|
+
try {
|
|
239
|
+
const box = aabb(geometry(entity, state, ports)), round = v => Number(v.toFixed(4));
|
|
240
|
+
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)])) };
|
|
241
|
+
} catch { return null; }
|
|
242
|
+
}
|
|
127
243
|
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
244
|
/** Placement evidence for entity ids in the given scene: the same-frame AABB
|
|
129
245
|
* overlap an arrangement receipt reports for the entities it moved. */
|
|
@@ -152,32 +268,49 @@ function transformPatch(op, isObject) {
|
|
|
152
268
|
export function arrangement(command, before, ports) {
|
|
153
269
|
const isObject = command.name === 'arrange_objects', key = isObject ? 'objects' : 'characters';
|
|
154
270
|
let rows = before[key];
|
|
155
|
-
const relations = [], warnings = [];
|
|
156
|
-
|
|
157
|
-
|
|
271
|
+
const relations = [], warnings = [], clamps = [], createdByName = new Map();
|
|
272
|
+
// References resolve against the evolving draft (`rows`), in op order.
|
|
273
|
+
const draft = () => ({ ...before, [key]: rows });
|
|
274
|
+
for (const rawOp of command.args.ops) {
|
|
275
|
+
const id = rawOp.id ?? rawOp.characterId;
|
|
158
276
|
let entity = id ? rows.find(e => e.id === id) : null;
|
|
159
|
-
if (id && !entity) fail('AMBIGUOUS_TARGET',
|
|
277
|
+
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.`);
|
|
278
|
+
let op = resolveOpRefs(rawOp, draft(), id);
|
|
160
279
|
if (op.op === 'create') {
|
|
161
280
|
if (op.name && rows.some(e => normalizedName(e.name ?? e.subject) === normalizedName(op.name))) fail('DUPLICATE_NAME', 'Create name already exists in the domain.');
|
|
281
|
+
let parentId;
|
|
282
|
+
if (isObject && op.parent !== undefined) {
|
|
283
|
+
parentId = rows.some(e => e.id === op.parent) ? op.parent : createdByName.get(normalizedName(op.parent));
|
|
284
|
+
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.`);
|
|
285
|
+
}
|
|
162
286
|
entity = isObject ? createSceneObject(op.source.kind, rows) : createCharacterEntry({ id: createStableItemId('character'), subject: normalizedName(op.name) });
|
|
163
287
|
if (!entity) fail('INVALID_ARGUMENT', 'Unsupported object library kind.');
|
|
164
288
|
rows = [...rows, entity];
|
|
289
|
+
// Parent before placing, so avoidance and overlap checks see one body.
|
|
290
|
+
if (parentId) { rows = setSceneObjectParent(rows, entity.id, parentId); entity = rows.find(e => e.id === entity.id); }
|
|
165
291
|
}
|
|
166
292
|
if (['create', 'update'].includes(op.op)) {
|
|
167
293
|
if (entity.attach) fail('CAPABILITY_MISSING', 'Attached transforms require a world-preserving attachment adapter.');
|
|
294
|
+
if (op.space === 'parent') op = parentFrameOp(op, entity, rows, createdByName);
|
|
168
295
|
entity = patchEntity(entity, transformPatch(op, isObject));
|
|
169
|
-
const placed = place(entity, op,
|
|
296
|
+
const placed = place(entity, op, draft(), ports);
|
|
170
297
|
entity = placed.entity;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
298
|
+
// What was asked against what the domain kept: scale and position limits move values silently.
|
|
299
|
+
const asked = isObject ? { ...(op.position?.world ?? {}), ...(op.scale ? { scaleX: op.scale.x, scaleY: op.scale.y, scaleZ: op.scale.z } : {}) }
|
|
300
|
+
: { ...(op.position?.world ?? {}), ...(op.scale === undefined ? {} : { scale: op.scale }) };
|
|
301
|
+
const clamp = clampWarning(entity.id, Object.fromEntries(Object.entries(asked).filter(([k]) => ['x', 'y', 'z', 'scale', 'scaleX', 'scaleY', 'scaleZ'].includes(k))), entity);
|
|
302
|
+
if (clamp) clamps.push(clamp);
|
|
303
|
+
if (command.args.collisionPolicy === 'avoid' && placed.relation?.axis) {
|
|
304
|
+
entity = avoid(entity, placed.relation, draft(), ports);
|
|
305
|
+
entity = patchEntity(entity, { rot: facingYaw(op.facing, entity, draft()) });
|
|
306
|
+
const reference = entityById(draft(), op.position.relativeTo);
|
|
307
|
+
placed.relation.actualGapM = interval(geometry(entity, draft(), ports), placed.relation.axis).min - interval(geometry(reference, draft(), ports), placed.relation.axis).max;
|
|
176
308
|
if (placed.relation.actualGapM < op.position.gapM - EPS) fail('VERIFICATION_FAILED', 'Avoidance cannot preserve the requested facing and minimum clearance.');
|
|
177
309
|
}
|
|
178
310
|
const patch = Object.fromEntries(Object.entries(entity).filter(([k, v]) => !equal(v, rows.find(e => e.id === entity.id)[k])));
|
|
179
311
|
rows = isObject ? updateSceneObject(rows, entity.id, patch) : rows.map(e => e.id === entity.id ? entity : e);
|
|
180
312
|
if (placed.relation) relations.push(placed.relation);
|
|
313
|
+
if (op.op === 'create' && op.name) createdByName.set(normalizedName(op.name), entity.id);
|
|
181
314
|
} else if (op.op === 'remove') {
|
|
182
315
|
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
316
|
rows = isObject ? removeSceneObject(rows, id) : rows.filter(e => e.id !== id);
|
|
@@ -205,7 +338,24 @@ export function arrangement(command, before, ports) {
|
|
|
205
338
|
relation.actualGapM = actualGapM;
|
|
206
339
|
}
|
|
207
340
|
if (command.args.collisionPolicy === 'avoid' && overlaps.length) fail('VERIFICATION_FAILED', 'The final batch is still blocked after its outward adjustments.');
|
|
208
|
-
|
|
341
|
+
// Every finding the receipt carries: a value moved to a limit first, then what the final scene shows wrong.
|
|
342
|
+
const nameOf = id => { const e = [...after.objects, ...after.characters].find(row => row.id === id); return e?.name ?? e?.subject ?? id; };
|
|
343
|
+
warnings.push(...clamps.slice(0, 3));
|
|
344
|
+
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('') });
|
|
345
|
+
if (overlaps.length > 5) warnings.push({ code: 'FOOTPRINT_OVERLAP', count: overlaps.length - 5, message: `${overlaps.length - 5} more footprint overlaps omitted` });
|
|
346
|
+
if (isObject) {
|
|
347
|
+
// Parts of one assembly touch on purpose, but faces sharing one plane flicker as the camera moves.
|
|
348
|
+
const touched = new Set(affectedIds), groups = new Map();
|
|
349
|
+
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); }
|
|
350
|
+
const coplanar = [];
|
|
351
|
+
for (const root of groups.keys()) for (const pair of coplanarConflicts(rows.filter(row => groupRootOf(row.id, rows) === root))) {
|
|
352
|
+
if (!touched.has(pair.a) && !touched.has(pair.b)) continue;
|
|
353
|
+
const planes = pair.planes.map(plane => `${plane.face} coincide at ${plane.axis ? `${plane.axis}=${plane.at}` : `offset ${plane.at}`}`).join(', ');
|
|
354
|
+
coplanar.push({ code: 'COPLANAR_FACES', id: pair.b, message: [...`${nameOf(pair.a)} and ${nameOf(pair.b)} ${planes}`].slice(0, 120).join('') });
|
|
355
|
+
}
|
|
356
|
+
warnings.push(...coplanar.slice(0, 2));
|
|
357
|
+
if (coplanar.length > 2) warnings.push({ code: 'COPLANAR_FACES', count: coplanar.length - 2, message: `${coplanar.length - 2} more coplanar face pairs omitted` });
|
|
358
|
+
}
|
|
209
359
|
if (relations.some(r => r.adjustmentM)) warnings.push({ code: 'OUTWARD_ADJUSTMENT', count: relations.filter(r => r.adjustmentM).length });
|
|
210
360
|
const relation = relations.length === 1 ? relations[0] : null;
|
|
211
361
|
return { domain: isObject ? 'objects' : 'cast', draft: equal(rows, before[key]) ? before[key] : rows, affectedIds,
|
|
@@ -232,12 +382,49 @@ function projectSubject(points, camera, fov, aspectRatio) {
|
|
|
232
382
|
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
383
|
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
384
|
}
|
|
385
|
+
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 } }; };
|
|
386
|
+
/** What stands in the way of the camera and where it stands: occluded when a prop
|
|
387
|
+
* crosses the sight line to any subject, and the camera's side of the line between
|
|
388
|
+
* the two leading characters (the subjects, else the ones nearest the camera). */
|
|
389
|
+
function sightChecks(subjects, boxes, cameraPosition, state, ports) {
|
|
390
|
+
const occluders = subjects.flatMap((subject, index) => occludersOf(subject, cameraPosition, state, ports, boundsOf(boxes[index])));
|
|
391
|
+
const pair = primarySubjects(subjects.map(subject => subject.id), cameraPosition, state);
|
|
392
|
+
const cameraSide = pair.length === 2 ? sideOfAxis(pair[0], pair[1], cameraPosition) : null;
|
|
393
|
+
return { occluders, checks: { occluded: occluders.length > 0, ...(cameraSide ? { cameraSide } : {}) } };
|
|
394
|
+
}
|
|
235
395
|
/** Framing evidence for entity ids through the current shot camera at the
|
|
236
|
-
* current frame: the same subject-bounds projection frame_shot reports. */
|
|
396
|
+
* current frame: the same subject-bounds projection and sight line frame_shot reports. */
|
|
237
397
|
export function framingChecks(ids, state, ports) {
|
|
238
398
|
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
|
-
|
|
399
|
+
const subjects = ids.map(id => entityById(state, id)), boxes = subjects.map(entity => geometry(entity, state, ports));
|
|
400
|
+
const projected = projectSubject(boxes.flat(), state.camera, focalMmToFov(state.camera.focalMm, state.filmback.sensorId, state.filmback.aspectRatio), state.filmback.aspectRatio).checks;
|
|
401
|
+
return { ...projected, ...sightChecks(subjects, boxes, state.camera.position, state, ports).checks };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Framing evidence over explicit timeline samples. The caller supplies the
|
|
405
|
+
* sampled state so camera keys and animated subject bounds use the same frame.
|
|
406
|
+
* A sample is evidence for that frame only; it is never advertised as an
|
|
407
|
+
* all-frame proof. */
|
|
408
|
+
export function sampledFramingChecks(ids, state, ports, frames) {
|
|
409
|
+
if (!Array.isArray(frames) || !frames.length) fail('INVALID_RANGE', 'Framing sampling needs at least one timeline frame.');
|
|
410
|
+
if (typeof ports.sampleStateAt !== 'function') fail('CAPABILITY_MISSING', 'This editor cannot sample framing at timeline frames.');
|
|
411
|
+
const samples = frames.map(frame => {
|
|
412
|
+
const sampledState = ports.sampleStateAt(frame, state);
|
|
413
|
+
if (!sampledState) fail('TARGET_NOT_READY', `The editor could not read timeline frame ${frame}.`);
|
|
414
|
+
const checks = framingChecks(ids, sampledState, ports), codes = [checks.clipped ? 'CLIPPED' : null, checks.behindCamera ? 'BEHIND_CAMERA' : null, checks.occluded ? 'OCCLUDED' : null].filter(Boolean);
|
|
415
|
+
return { frame, checks, codes };
|
|
416
|
+
});
|
|
417
|
+
const sampledChecks = samples.map(({ frame, checks }) => ({ frame, screenFraction: checks.screenFraction, clipped: checks.clipped, behindCamera: checks.behindCamera, occluded: checks.occluded }));
|
|
418
|
+
const issueFrames = samples.filter(sample => sample.codes.length).map(({ frame, checks, codes }) => ({ frame, codes, screenFraction: checks.screenFraction }));
|
|
419
|
+
return { ...samples.at(-1).checks, coverage: 'sampled-timeline-subject-bounds-projection', evaluatedFrames: frames.length, sampledFrames: frames, sampledChecks, issueFrames };
|
|
420
|
+
}
|
|
421
|
+
export function sampledFramingResult(args, scene, ports, frameCount, measured, receipt) {
|
|
422
|
+
const range = args.range === 'whole_clip' ? { startFrame: 0, endFrameExclusive: frameCount } : args.range;
|
|
423
|
+
if (range.endFrameExclusive > frameCount) fail('INVALID_RANGE', 'Framing verification range exceeds the scene timeline.');
|
|
424
|
+
const ids = measured ?? (args.targets ?? receipt?.affectedIds ?? []).filter(id => scene.objects.some(o => o.id === id) || scene.characters.some(c => c.id === id));
|
|
425
|
+
if (!ids.length) return { reason: 'No target is available for sampled framing verification.' };
|
|
426
|
+
try { return sampledFramingChecks(ids, scene, ports, sampleContactSheetFrames(args.range, frameCount)); }
|
|
427
|
+
catch (error) { if (!(error instanceof StudioProtocolError)) throw error; return { reason: error.message }; }
|
|
241
428
|
}
|
|
242
429
|
export function frameDraft(command, state, ports) {
|
|
243
430
|
if (state.frameCount <= 0) fail('TARGET_NOT_READY', 'A nonempty timeline is required.');
|
|
@@ -249,7 +436,9 @@ export function frameDraft(command, state, ports) {
|
|
|
249
436
|
const created = !shot && !state.shotDocument.shots.length && !command.args.shotId;
|
|
250
437
|
if (created) shot = createShot('Shot 1', 0, state.frameCount - 1);
|
|
251
438
|
if (!shot) fail('AMBIGUOUS_TARGET', 'No shot owns this frame; select an existing shot explicitly.');
|
|
252
|
-
const
|
|
439
|
+
const still = shot.kind === 'still';
|
|
440
|
+
const existingKey = shot.cameraKeys[0];
|
|
441
|
+
const frame = still ? (existingKey?.frame ?? shot.startFrame) : command.args.keyAtFrame ?? state.frame;
|
|
253
442
|
if (frame < shot.startFrame || frame > shot.endFrame || shot.endFrame >= state.frameCount) fail('INVALID_RANGE', 'Framing/key frame must be inside the shot range.');
|
|
254
443
|
const intent = command.args.framing.intent;
|
|
255
444
|
let camera = command.args.framing.exact;
|
|
@@ -270,18 +459,19 @@ export function frameDraft(command, state, ports) {
|
|
|
270
459
|
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
460
|
let keyId;
|
|
272
461
|
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);
|
|
462
|
+
if (still || command.args.keyAtFrame !== undefined) {
|
|
463
|
+
keyId = (still ? existingKey?.id : keys.find(k => k.frame === frame)?.id) ?? createStableItemId('camera-key');
|
|
464
|
+
keys = still ? [{ id: keyId, frame, framing }] : [...keys.filter(k => k.frame !== frame), { id: keyId, frame, framing }].sort((a, b) => a.frame - b.frame);
|
|
276
465
|
}
|
|
277
466
|
const nextShot = { ...shot, cameraKeys: keys, camera: { ...shot.camera, mode: 'keys' } };
|
|
278
467
|
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 =
|
|
468
|
+
const { screenBounds, checks: projected } = projectSubject(points, camera, fov, state.filmback.aspectRatio), derivedSize = projected.derivedSize;
|
|
469
|
+
const sight = sightChecks([subject], [points], camera.position, state, ports), checks = { ...projected, ...sight.checks };
|
|
280
470
|
const resolvedCamera = { ...camera, sensorId: state.filmback.sensorId, slate: derivedSize };
|
|
281
471
|
const draft = { shotDocument: { ...state.shotDocument, shots }, camera: resolvedCamera, manual: true };
|
|
282
472
|
return { domain: 'shot', draft, affectedIds: [shot.id, ...(keyId ? [keyId] : [])],
|
|
283
473
|
checks,
|
|
284
|
-
warnings:
|
|
474
|
+
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
475
|
}
|
|
286
476
|
|
|
287
477
|
/** 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 };
|