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,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, evaluatedFrames: integer(1), sampledFrames: array(integer(), 64, 1), sampledChecks: array(object({ frame: integer(), screenFraction: number(), clipped: bool, behindCamera: bool, occluded: bool }), 64, 1), issueFrames: array(object({ frame: integer(), codes: array(name, 4, 1), screenFraction: number() }), 64, 0) });
|
|
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, " ") }));
|
|
@@ -478,7 +511,10 @@ export function validateStudioCommand(command) {
|
|
|
478
511
|
}
|
|
479
512
|
if (command.name === "verify_result") {
|
|
480
513
|
if (Boolean(args.receiptId) === Boolean(args.targets)) fail("INVALID_ARGUMENT", "Exactly one of receiptId or targets is required.");
|
|
481
|
-
if (args.checks.includes("motion") && args.range === undefined)
|
|
514
|
+
if (args.checks.includes("motion") && args.range === undefined) {
|
|
515
|
+
args.range = "whole_clip";
|
|
516
|
+
Object.defineProperty(args, "rangeWasDefaulted", { value: true, enumerable: false });
|
|
517
|
+
}
|
|
482
518
|
}
|
|
483
519
|
return { name: command.name, args };
|
|
484
520
|
}
|
|
@@ -5,10 +5,12 @@ import { createCommandBus } from "./command-bus.js";
|
|
|
5
5
|
import { readElementDocument, elementReadback, elementPatchArgs, elementTarget, elementPatchReceipt } from "./commands/elements.js";
|
|
6
6
|
import { physicsKeyStamp } from "./ardy/physics-review.js";
|
|
7
7
|
import { shotAtFrame } from "./cuts.js";
|
|
8
|
+
import { isProxyFigure } from "./scenes.js";
|
|
8
9
|
import { CUTOUT_KIND, MESH_KIND, OBJECT_LIBRARY, supportHeightForObject } from "./scene-objects.js";
|
|
9
10
|
import { buildStudioContext, physicsFingerprintInput, studioEntityCursor, validateStudioCursor } from "./studio-agent-context.js";
|
|
10
|
-
import { createStudioCommandJournal, framingChecks, placementChecks, studioObjectCatalogue } from "./studio-agent-commands.js";
|
|
11
|
+
import { createStudioCommandJournal, entityWorldBounds, framingChecks, localTransformReadback, placementChecks, sampledFramingResult, studioObjectCatalogue } from "./studio-agent-commands.js";
|
|
11
12
|
import { verifyInstalledTake } from "./studio-agent-motion.js";
|
|
13
|
+
import { shotGeometryFacts } from "./studio-geometry-facts.js";
|
|
12
14
|
import { STUDIO_TOOL_FAMILIES, StudioProtocolError, validateReceipt, validateStudioCommand, validateStudioIdentity } from "./studio-agent-protocol.js";
|
|
13
15
|
import { CONTACT_SHEET_LAYOUT, buildContactSheet, sampleContactSheetFrames } from "./studio-contact-sheet.js";
|
|
14
16
|
|
|
@@ -120,12 +122,15 @@ export function createStudioAppBinding(ports) {
|
|
|
120
122
|
position: { x: c.x, y: c.y ?? 0, z: c.z }, yawDeg: c.rot ?? 0, scale: c.scale ?? 1, tint: c.tint ?? null, modelId: c.model ?? null,
|
|
121
123
|
motion: { takeId: t?.motion?.studioTakeId ?? null, frames: t?.motion?.frames ?? 0,
|
|
122
124
|
ikKeyCount: t?.ikState?.keys.size ?? 0, promptBlockCount: c.layer?.promptClips?.length ?? 0 },
|
|
123
|
-
capabilities: { rigReady: Boolean(t?.rig), ik: Boolean(t?.rig?.userData?.poseBind), measuredFeet: false }
|
|
124
|
-
|
|
125
|
+
capabilities: { rigReady: Boolean(t?.rig), ik: Boolean(t?.rig?.userData?.poseBind), measuredFeet: false },
|
|
126
|
+
characterKind: isProxyFigure(c) ? "proxy" : "rig", ...(isProxyFigure(c) ? { posture: c.posture ?? "stand" } : {}) };
|
|
127
|
+
}), ...s.objects.map(o => { const local = localTransformReadback(o, s.objects); return { id: o.id, kind: "object", token: tokens.get(o.id).token, name: o.name || o.id,
|
|
125
128
|
position: { x: o.x, y: o.y ?? 0, z: o.z }, yawDeg: o.rot ?? 0,
|
|
126
129
|
rotationDeg: { x: o.rotX ?? 0, y: o.rot ?? 0, z: o.rotZ ?? 0 }, scale: { x: o.scaleX, y: o.scaleY, z: o.scaleZ },
|
|
127
130
|
renderer: o.renderer, color: o.color ?? null, ...(o.assetId ? { assetId: o.assetId } : {}),
|
|
128
|
-
parentId: o.parent ?? null, attachment: o.attach ?? null, pathPointCount: o.path?.points.length ?? 0
|
|
131
|
+
parentId: o.parent ?? null, attachment: o.attach ?? null, pathPointCount: o.path?.points.length ?? 0,
|
|
132
|
+
// the dots on the route, with the lean (degrees) a mark carries
|
|
133
|
+
...(o.path?.marks?.length ? { pathMarks: o.path.marks.map(mark => typeof mark === "number" ? mark : { t: mark.t, ...(mark.bank !== undefined ? { bank: mark.bank } : {}), ...(mark.pitch !== undefined ? { pitch: mark.pitch } : {}) }) } : {}), ...(local ? { local } : {}) }; })];
|
|
129
134
|
}
|
|
130
135
|
const frameRange = row => ({ startFrame: row.startFrame, endFrameExclusive: row.endFrame + 1 });
|
|
131
136
|
function assetList(s) {
|
|
@@ -147,7 +152,7 @@ export function createStudioAppBinding(ports) {
|
|
|
147
152
|
return buildStudioContext({ schema: "studio-context-v1", host: { surface: "studio", ...s.host, workspaceHandle: s.workspaceHandle },
|
|
148
153
|
revision: { scene: s.revision, physics: s.physicsRevision, view: s.viewRevision },
|
|
149
154
|
units: { distance: "m", angle: "deg", up: "+Y", yawZero: "+Z", yawPositiveToward: "+X", pivot: "base", fps: 24, rangeEnd: "exclusive" },
|
|
150
|
-
scene: { name: s.sceneName, aspect: s.aspect, floorY: 0, frameCount: s.frameCount, objectCount: s.objects.length, characterCount: s.characters.length },
|
|
155
|
+
scene: { name: s.sceneName, aspect: s.aspect, floorY: 0, frameCount: s.frameCount, objectCount: s.objects.length, characterCount: s.characters.length, ...(s.previsMode ? { previsMode: s.previsMode } : {}) },
|
|
151
156
|
selection: s.selection, activeCharacterId: s.activeCharacterId, view: s.view,
|
|
152
157
|
shot: shot ? { id: shot.id, name: shot.name, range: range(shot), mode: shot.camera?.mode ?? "keys" } : null, camera: s.camera,
|
|
153
158
|
// buildStudioContext selects the detailed rows and writes the real page.
|
|
@@ -155,7 +160,8 @@ export function createStudioAppBinding(ports) {
|
|
|
155
160
|
shots: s.shots.map(row => ({ id: row.id, name: row.name, range: range(row), keyCount: row.cameraKeys.length })), shotsTruncated: false,
|
|
156
161
|
assets: assetList(s), recentReceipts: [...receipts.values()].filter(r => r.ok).reverse().slice(0, 3).map(r => ({ id: r.receiptId, summary: r.status, canUndoDirect: ports.canUndo(r) })),
|
|
157
162
|
jobs: [], capabilities: { profile: "studio-slice-1", tools: STUDIO_TOOL_FAMILIES,
|
|
158
|
-
rigReady: Boolean(s.targets.get(s.activeCharacterId)?.rig), cameraReady: Boolean(s.camera), bridgeReady: s.bridgeReady
|
|
163
|
+
rigReady: Boolean(s.targets.get(s.activeCharacterId)?.rig), cameraReady: Boolean(s.camera), bridgeReady: s.bridgeReady,
|
|
164
|
+
...(s.previsMode ? { storyboard: s.previsMode === "storyboard" } : {}) },
|
|
159
165
|
// Every registered command; buildStudioContext keeps only its id/label index.
|
|
160
166
|
...(registry ? { actions: registry.list() } : {}) });
|
|
161
167
|
}
|
|
@@ -182,19 +188,32 @@ export function createStudioAppBinding(ports) {
|
|
|
182
188
|
return s;
|
|
183
189
|
}
|
|
184
190
|
/** Actual state of one action target after it ran. */
|
|
185
|
-
function actionReadback(id, s) {
|
|
191
|
+
function actionReadback(id, s, compact = false) {
|
|
186
192
|
const patched = Object.entries(s.document).flatMap(([kind, value]) => {
|
|
187
193
|
const target = elementTarget(kind, value, id, s.host.sceneId);
|
|
188
194
|
return target ? elementReadback(kind, target) : [];
|
|
189
195
|
});
|
|
190
196
|
if (id === s.host.sceneId) return { selection: s.selection, activeCharacterId: s.activeCharacterId, shotId: s.selectedShotId, view: s.view,
|
|
191
197
|
...(patched.length ? { patched } : {}) };
|
|
192
|
-
|
|
198
|
+
const object = s.objects.find(row => row.id === id), entity = object ?? s.characters.find(row => row.id === id);
|
|
199
|
+
// Beyond the transform an object's patched paths already carry: the world box it occupies and, for a
|
|
200
|
+
// group, who hangs off it. `compact` (a receipt near its size limit) drops back to the paths alone.
|
|
201
|
+
// A character's box means cloning and posing its rig, too dear for every receipt: inspect or verify has it.
|
|
202
|
+
const extras = () => {
|
|
203
|
+
if (!object || compact) return {};
|
|
204
|
+
const childIds = s.objects.filter(row => row.parent === id).map(row => row.id);
|
|
205
|
+
return { bounds: entityWorldBounds(object, { ...s, frame: s.view.frame }, { bounds: ports.bounds }),
|
|
206
|
+
...(childIds.length ? { childIds: childIds.slice(0, 12), ...(childIds.length > 12 ? { childCount: childIds.length } : {}) } : {}) };
|
|
207
|
+
};
|
|
208
|
+
if (patched.length) return { patched, ...extras() };
|
|
193
209
|
const shot = s.shots.find(row => row.id === id);
|
|
194
210
|
if (shot) return { name: shot.name || shot.id, range: { startFrame: shot.startFrame, endFrameExclusive: shot.endFrame + 1 } };
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
211
|
+
if (!entity) return { removed: true };
|
|
212
|
+
const brief = { name: entity.name || entity.subject || entity.id, position: { x: entity.x, y: entity.y ?? 0, z: entity.z } };
|
|
213
|
+
if (compact) return brief;
|
|
214
|
+
if (!object) return { ...brief, yawDeg: entity.rot ?? 0, scale: entity.scale ?? 1, ...extras() };
|
|
215
|
+
return { ...brief, rotationDeg: { x: object.rotX ?? 0, y: object.rot ?? 0, z: object.rotZ ?? 0 }, scale: { x: object.scaleX, y: object.scaleY, z: object.scaleZ },
|
|
216
|
+
parentId: object.parent ?? null, ...extras() };
|
|
198
217
|
}
|
|
199
218
|
/** One registered Studio action, run for the agent through the same
|
|
200
219
|
* registry the UI controls call. A mutation is bound to the native history
|
|
@@ -220,7 +239,7 @@ export function createStudioAppBinding(ports) {
|
|
|
220
239
|
}
|
|
221
240
|
function runAction(request, args) {
|
|
222
241
|
const { name: _name, args: _args, ...options } = request;
|
|
223
|
-
const result = commandBus().run(args.action, args.args, { ...options, origin: "agent", confirmationToken: args.confirmationToken ?? request.confirmationToken });
|
|
242
|
+
const result = commandBus().run(args.action, args.args, { ...options, origin: "agent", turnId: request.turnId, confirmationToken: args.confirmationToken ?? request.confirmationToken });
|
|
224
243
|
const answer = receipt => receipt.nextHost ? { ...receipt, host: receipt.nextHost } : receipt;
|
|
225
244
|
return result?.then ? result.then(answer) : answer(result);
|
|
226
245
|
}
|
|
@@ -260,15 +279,24 @@ export function createStudioAppBinding(ports) {
|
|
|
260
279
|
// Only a check nothing could compute is unsupported, and says why; a motion
|
|
261
280
|
// check computed for some characters still names the ones it skipped.
|
|
262
281
|
const scene = readCommand(), entityIds = ids => ids.filter(id => scene.objects.some(o => o.id === id) || scene.characters.some(c => c.id === id));
|
|
263
|
-
|
|
282
|
+
// A receipt's checks are evidence only for what they measured: every receipt carries a coverage tag, but
|
|
283
|
+
// only an arrange/frame receipt carries overlap or framing numbers. Anything else is measured here.
|
|
284
|
+
const measuredBy = { placement: "overlapIds", framing: "screenFraction" }, unmeasured = receipt && args.checks.some(check => measuredBy[check] && receipt.checks?.[measuredBy[check]] === undefined);
|
|
285
|
+
const measured = args.targets ? entityIds(args.targets) : unmeasured ? entityIds(receipt.affectedIds) : null;
|
|
286
|
+
// A framing check also carries the cut's geometry, read from the scene: side of the action line
|
|
287
|
+
// and sight lines per shot, the receipt's or targets' shots first (8 at most).
|
|
264
288
|
const result = { receiptId: receipt?.receiptId ?? null, revision: s.revision, evidenceRevision, stale: evidenceRevision !== s.revision, checks: measured ? { coverage: "current-scene-targets" } : receipt.checks,
|
|
265
|
-
verification: receipt?.verification ?? null, semanticStatus: "unavailable", visualRefs: [], unsupportedChecks: [], unsupportedReasons: {} };
|
|
289
|
+
verification: receipt?.verification ?? null, semanticStatus: "unavailable", visualRefs: [], unsupportedChecks: [], unsupportedReasons: {}, ...(args.checks.includes("framing") ? { geometry: shotGeometryFacts(scene, { first: receipt ? receipt.affectedIds : args.targets, ports: { bounds: ports.bounds } }) } : {}) };
|
|
266
290
|
const reasons = result.unsupportedReasons, verified = [], skipped = [], pending = [];
|
|
267
291
|
for (const check of args.checks.filter(check => check !== "motion" && measured)) {
|
|
268
292
|
if (!measured.length) { reasons[check] = "No target is an object or character in the current scene."; continue; }
|
|
269
293
|
try { result.checks[check] = check === "placement" ? placementChecks(measured, scene, { bounds: ports.bounds }) : framingChecks(measured, scene, { bounds: ports.bounds }); }
|
|
270
294
|
catch (error) { if (!(error instanceof StudioProtocolError)) throw error; reasons[check] = error.message; }
|
|
271
295
|
}
|
|
296
|
+
if (args.checks.includes("framing") && args.range !== undefined && !args.rangeWasDefaulted) {
|
|
297
|
+
const sampled = sampledFramingResult(args, scene, ports, s.frameCount, measured, receipt);
|
|
298
|
+
if (sampled.reason) reasons.framing = sampled.reason; else result.checks.framing = sampled;
|
|
299
|
+
}
|
|
272
300
|
if (args.checks.includes("motion") && !receipt?.verification) {
|
|
273
301
|
const subjects = args.targets ?? receipt.affectedIds.filter(id => s.characters.some(c => c.id === id));
|
|
274
302
|
if (!subjects.length) skipped.push("The receipt affected no character.");
|
|
@@ -281,6 +309,8 @@ export function createStudioAppBinding(ports) {
|
|
|
281
309
|
.then(verification => { verified[index] = verification; }, error => { if (!(error instanceof StudioProtocolError)) throw error; skipped.push(`${name}: ${error.message}`); }));
|
|
282
310
|
});
|
|
283
311
|
}
|
|
312
|
+
// A frame is the shot camera's view; a plan is the Top View, the set from above
|
|
313
|
+
// with names and the shot camera's wedge.
|
|
284
314
|
if (args.visual !== "none") {
|
|
285
315
|
if (args.visual === "contact_sheet") {
|
|
286
316
|
// One image of frames across the range, each rendered through the export
|
|
@@ -288,7 +318,7 @@ export function createStudioAppBinding(ports) {
|
|
|
288
318
|
const frames = sampleContactSheetFrames(args.range, s.frameCount), sheet = buildContactSheet(frames, ports.renderFrameBuffer), imageId = crypto.randomUUID();
|
|
289
319
|
images.set(imageId, { dataUrl: ports.encodePng(sheet.data, sheet), width: sheet.width, height: sheet.height, frames, layout: CONTACT_SHEET_LAYOUT, revision: s.revision, receiptId: result.receiptId });
|
|
290
320
|
result.visualRefs.push({ imageId, frames, layout: CONTACT_SHEET_LAYOUT });
|
|
291
|
-
} else { const capture = ports.capture(); const imageId = crypto.randomUUID(); images.set(imageId, { ...capture, revision: s.revision, receiptId: result.receiptId }); result.visualRefs.push({ imageId }); }
|
|
321
|
+
} else { const capture = args.visual === "plan" ? (ports.capturePlan ?? (() => fail("CAPABILITY_MISSING", "This editor cannot render the top view.")))() : ports.capture(); const imageId = crypto.randomUUID(); images.set(imageId, { ...capture, revision: s.revision, receiptId: result.receiptId }); result.visualRefs.push({ imageId, ...(args.visual === "plan" ? { kind: "plan" } : {}) }); }
|
|
292
322
|
}
|
|
293
323
|
const finish = () => {
|
|
294
324
|
const computed = verified.filter(Boolean);
|
|
@@ -312,17 +342,20 @@ export function createStudioAppBinding(ports) {
|
|
|
312
342
|
read_studio_context(request) { const c = context(); if (!same(validateStudioIdentity(request.host), owner)) fail("STALE_SCENE", "This is not the requested document."); return c; },
|
|
313
343
|
inspect_studio(args) {
|
|
314
344
|
const command = validateStudioCommand({ name: "inspect_studio", args }); const c = context();
|
|
315
|
-
// Every scope carries the
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
345
|
+
// Every scope carries the revision and document identity, not the whole
|
|
346
|
+
// context (the turn already holds that, and resending it per call is what
|
|
347
|
+
// the model pays for): the revision is what the agent's next command is
|
|
348
|
+
// admitted at, so a scope without it leaves that admission stale.
|
|
349
|
+
const head = { revision: c.revision, host: (({ workspaceId, documentEpoch, sceneId, sceneEpoch }) => ({ workspaceId, documentEpoch, sceneId, sceneEpoch }))(c.host) };
|
|
350
|
+
if (command.args.scope === "catalogue") return { ...head, ...studioObjectCatalogue() };
|
|
351
|
+
if (command.args.scope === "document") return { ...head, scope: "document",
|
|
319
352
|
...readElementDocument(refresh().document, command.args, c.host.sceneId) };
|
|
320
353
|
// Discovery for run_action: every registered action with its label, kind,
|
|
321
354
|
// exposure and availability (the reason when unavailable). Schemas are on
|
|
322
355
|
// request: ids answer those actions' full declarations, input included.
|
|
323
356
|
if (command.args.scope === "actions") {
|
|
324
|
-
const actions = ports.actions?.()?.list() ?? [];
|
|
325
|
-
return {
|
|
357
|
+
const actions = (ports.actions?.()?.list() ?? []).filter(row => !row.agentHidden);
|
|
358
|
+
return { ...head, actions: command.args.ids ? actions.filter(row => command.args.ids.includes(row.id)) : actions.map(({ input, description, ...row }) => row) };
|
|
326
359
|
}
|
|
327
360
|
const s = refresh();
|
|
328
361
|
const wanted = row => (!args.ids || args.ids.includes(row.id)) && (!args.query || Boolean(row.name?.includes(args.query)));
|
|
@@ -330,14 +363,14 @@ export function createStudioAppBinding(ports) {
|
|
|
330
363
|
const select = { shot: ["shot"], motion: ["motion", "character"] }[command.args.scope];
|
|
331
364
|
const ids = args.ids ?? (command.args.scope === "selection" ? [s.selection?.id ?? s.host.sceneId]
|
|
332
365
|
: args.query && select ? (command.args.scope === "shot" ? s.shots : entityProjection(s).filter(row => row.kind === "character")).filter(wanted).map(row => row.id) : undefined);
|
|
333
|
-
return {
|
|
366
|
+
return { ...head, scope: "document", ...readElementDocument(s.document, { ...command.args, ids, select }, c.host.sceneId) };
|
|
334
367
|
}
|
|
335
368
|
// Build each page from the same complete authoritative projection; never
|
|
336
369
|
// page by slicing an already-truncated Send context.
|
|
337
370
|
// Stable id order, so an offset cursor survives unrelated edits.
|
|
338
371
|
const filtered = entityProjection(s).filter(wanted).sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0);
|
|
339
372
|
const offset = args.cursor ? validateStudioCursor(args.cursor, c) : 0, limit = command.args.limit;
|
|
340
|
-
return {
|
|
373
|
+
return { ...head, entities: filtered.slice(offset, offset + limit), total: filtered.length,
|
|
341
374
|
nextCursor: offset + limit < filtered.length ? studioEntityCursor(c, offset + limit) : null };
|
|
342
375
|
},
|
|
343
376
|
operate_studio: request => execute({ ...request, name: "operate_studio" }),
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
// shot render shrinks, and where it lands on the one image the agent sees.
|
|
3
3
|
// Pure over RGBA byte buffers; App.jsx supplies the render and the encoder.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
const CONTACT_SHEET_COLUMNS = 3;
|
|
6
|
+
const CONTACT_SHEET_ROWS = 2;
|
|
7
7
|
export const CONTACT_SHEET_LAYOUT = "3x2 row-major";
|
|
8
|
-
|
|
8
|
+
const CONTACT_SHEET_MAX_SIDE = 1600;
|
|
9
9
|
|
|
10
10
|
/** Up to 6 frames spread evenly over a half-open range, first and last
|
|
11
11
|
* included; "whole_clip" (or no range) is timeline frames 0..frameCount-1. */
|
package/src/studio-elements.js
CHANGED
|
@@ -22,7 +22,11 @@ const entries = [
|
|
|
22
22
|
{ path: "character.scale", type: "number", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry", min: 0.2, max: 3 },
|
|
23
23
|
{ path: "character.subject", type: "string", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry" },
|
|
24
24
|
{ path: "character.hidden", type: "boolean", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry" },
|
|
25
|
-
{ path: "character.
|
|
25
|
+
{ path: "character.parent", type: "id", nullable: true, persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry", note: "scene object id the character is grouped under and rides; null stands in the world" },
|
|
26
|
+
// The model and posture enums mirror scenes.js CHARACTER_KIND_IDS and
|
|
27
|
+
// POSTURES; they are literal because scenes.js imports this module.
|
|
28
|
+
{ path: "character.model", type: "enum", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry", enum: ["y-bot-tpose", "x-bot-tpose", "proxy-figure"] },
|
|
29
|
+
{ path: "character.posture", type: "enum", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry", enum: ["stand", "sit", "lie"] },
|
|
26
30
|
{ path: "character.tint", type: "color", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry" },
|
|
27
31
|
{ path: "character.identityImage", type: "image", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry", note: "data:image only" },
|
|
28
32
|
{ path: "character.pose", type: "id", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry" },
|
|
@@ -32,12 +36,13 @@ const entries = [
|
|
|
32
36
|
{ path: "character.motionRef.motionId", type: "id", persisted: true, undoDomain: "cast", normalizer: "createCharacterEntry" },
|
|
33
37
|
{ path: "character.sessionMotion", type: "array", persisted: false, undoDomain: "cast", normalizer: "createCharacterEntry", note: "dropped by createCharacterEntry" },
|
|
34
38
|
{ path: "character.ikKeys", type: "array", persisted: false, undoDomain: "cast", normalizer: "createCharacterEntry", actions: ["character.setIkKey", "character.removeIkKey", "character.clearIkKeys"], note: "dropped by createCharacterEntry; keys travel as JSON quaternions/positions" },
|
|
35
|
-
{ path: "object.renderer", type: "enum", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject", enum: ["cube", "sphere", "capsule", "cylinder", "cone", "plane", "chair", "car", "small-plane"] },
|
|
39
|
+
{ path: "object.renderer", type: "enum", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject", enum: ["cube", "sphere", "capsule", "cylinder", "cone", "plane", "chair", "car", "small-plane", "empty"] },
|
|
36
40
|
{ path: "object.position", type: "vec3", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject", min: { x: -240, y: 0, z: -240 }, max: { x: 240, y: 240, z: 240 }, note: "y is the object's base: y=0 rests on the floor; height/supportY rise from it" },
|
|
37
41
|
{ path: "object.rotation", type: "vec3", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject", min: { x: -180, y: -180, z: -180 }, max: { x: 180, y: 180, z: 180 }, note: "rot/rotX/rotZ degrees, wrapped at the upper bound" },
|
|
38
42
|
{ path: "object.scale", type: "vec3", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject", min: { x: 0.1, y: 0.1, z: 0.1 }, max: { x: 100, y: 100, z: 100 } },
|
|
39
43
|
{ path: "object.name", type: "string", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject" },
|
|
40
44
|
{ path: "object.color", type: "color", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject" },
|
|
45
|
+
{ path: "object.opacity", type: "number", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject", min: 0.05, max: 1, note: "1 is solid; lower is see-through, e.g. 0.3 for clear glass" },
|
|
41
46
|
{ path: "object.parent", type: "id", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject" },
|
|
42
47
|
{ path: "object.attach", type: "id", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject", actions: ["object.attach", "object.detach"], note: "carried by a character's root or bone; channels convert so the prop stays put" },
|
|
43
48
|
{ path: "object.path", type: "array", persisted: true, undoDomain: "objects", normalizer: "normalizeSceneObject" },
|
|
@@ -59,6 +64,10 @@ const entries = [
|
|
|
59
64
|
{ path: "shot.cameraKeys", type: "array", persisted: true, undoDomain: "shot", normalizer: null, frameMin: 0 },
|
|
60
65
|
{ path: "shot.cameraRail", type: "array", persisted: true, undoDomain: "shot", normalizer: "repairCamera", actions: ["shot.setCameraRail", "shot.clearCameraRail"], note: "rail points; crane/dolly timing follow the shot's camera block" },
|
|
61
66
|
{ path: "shot.targetModel", type: "id", persisted: true, undoDomain: "shot", normalizer: "repairCamera" },
|
|
67
|
+
{ path: "shot.kind", type: "enum", persisted: true, undoDomain: "shot", normalizer: null, readOnly: true, enum: ["clip", "still"], note: "clip or still; a still holds one picture for endFrame - startFrame + 1 frames" },
|
|
68
|
+
{ path: "shot.caption", type: "string", persisted: true, undoDomain: "shot", normalizer: null, readOnly: true, note: "trimmed, at most 500 characters" },
|
|
69
|
+
{ path: "shot.cast", type: "array", persisted: true, undoDomain: "shot", normalizer: null, readOnly: true, note: "per-shot placement overrides keyed by character id: { x, z, rot, posture?, pose? }" },
|
|
70
|
+
{ path: "shot.stylizedAssetId", type: "id", nullable: true, persisted: true, undoDomain: "shot", normalizer: null, readOnly: true, note: "image asset id only" },
|
|
62
71
|
{ path: "shot.freeCamera", type: "vec3", persisted: false, undoDomain: "shot", normalizer: null, note: "transient until keyed" },
|
|
63
72
|
{ path: "scenes", type: "array", persisted: true, undoDomain: null, normalizer: null, actions: ["scene.create", "scene.duplicate", "scene.rename", "scene.delete", "scene.switch"], note: "the project's scene list and the open scene; outside the undo history" },
|
|
64
73
|
{ path: "project", type: "string", persisted: true, undoDomain: null, normalizer: null, actions: ["project.save"], note: "save to the current project file; opening one needs the user's file picker" },
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Shot geometry the Studio agent reads as facts instead of judging from a picture:
|
|
2
|
+
// where the cast stands, which side of the line between the two leading
|
|
3
|
+
// characters each shot camera sits on (the 180-degree rule), and which props
|
|
4
|
+
// stand between a camera and its subject. Pure: no renderer, no React.
|
|
5
|
+
import { entityWorldBounds } from './studio-agent-commands.js';
|
|
6
|
+
import { isEffectivelyHidden } from './scene-objects.js';
|
|
7
|
+
import { shotAtFrame } from './cuts.js';
|
|
8
|
+
|
|
9
|
+
export const GEOMETRY_FACTS_LIMITS = Object.freeze({ shots: 8, subjects: 24, pairs: 24, occluders: 8 });
|
|
10
|
+
/** Closer to the action line than this (m) is "on-axis", neither side. */
|
|
11
|
+
const ON_AXIS_M = 0.05;
|
|
12
|
+
const EPS = 1e-9;
|
|
13
|
+
const round = v => Math.round(v * 1000) / 1000 + 0;
|
|
14
|
+
const point = p => ({ x: round(p.x), y: round(p.y ?? 0), z: round(p.z) });
|
|
15
|
+
const visibleCharacters = state => state.characters.filter(c => !isEffectivelyHidden(c, state.objects, state.characters));
|
|
16
|
+
|
|
17
|
+
/** Does the segment p0->p1 pass through the box {min, max}? Slab test over t in [0, 1]. */
|
|
18
|
+
export function segmentIntersectsAabb(p0, p1, box) {
|
|
19
|
+
let t0 = 0, t1 = 1;
|
|
20
|
+
for (const a of ['x', 'y', 'z']) {
|
|
21
|
+
const d = p1[a] - p0[a];
|
|
22
|
+
if (Math.abs(d) < EPS) {
|
|
23
|
+
if (p0[a] < box.min[a] || p0[a] > box.max[a]) return false;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
let near = (box.min[a] - p0[a]) / d, far = (box.max[a] - p0[a]) / d;
|
|
27
|
+
if (near > far) [near, far] = [far, near];
|
|
28
|
+
t0 = Math.max(t0, near); t1 = Math.min(t1, far);
|
|
29
|
+
if (t0 > t1) return false;
|
|
30
|
+
}
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
const contains = (box, p) => ['x', 'y', 'z'].every(a => p[a] >= box.min[a] && p[a] <= box.max[a]);
|
|
34
|
+
|
|
35
|
+
/** Which side of the horizontal line a->b the point p stands on, seen from above
|
|
36
|
+
* (+Y up) looking along a->b: the sign of (axis x (p - a)).y with the axis
|
|
37
|
+
* normalised, so the threshold is metres. Negative is right, positive left;
|
|
38
|
+
* within ON_AXIS_M it is "on-axis". Null when a and b share a spot. */
|
|
39
|
+
export function sideOfAxis(a, b, p) {
|
|
40
|
+
const ax = b.x - a.x, az = b.z - a.z, length = Math.hypot(ax, az);
|
|
41
|
+
if (length < EPS) return null;
|
|
42
|
+
const offset = (az * (p.x - a.x) - ax * (p.z - a.z)) / length;
|
|
43
|
+
return Math.abs(offset) < ON_AXIS_M ? 'on-axis' : offset > 0 ? 'left' : 'right';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The two characters a camera is read against: the given ids that are visible
|
|
47
|
+
* characters, then the visible characters nearest the camera. Ordered by id, so
|
|
48
|
+
* every shot of the same pair reads the line in the same direction. */
|
|
49
|
+
export function primarySubjects(ids, cameraPosition, state) {
|
|
50
|
+
const visible = visibleCharacters(state);
|
|
51
|
+
const chosen = ids.map(id => visible.find(c => c.id === id)).filter(Boolean);
|
|
52
|
+
const near = c => Math.hypot(c.x - cameraPosition.x, c.z - cameraPosition.z);
|
|
53
|
+
const rest = visible.filter(c => !chosen.includes(c)).sort((p, q) => near(p) - near(q));
|
|
54
|
+
return [...chosen, ...rest].slice(0, 2).sort((p, q) => (p.id < q.id ? -1 : p.id > q.id ? 1 : 0));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Props whose world box crosses the sight line from the camera to the centre of
|
|
58
|
+
* the subject's box. The subject itself, props attached to it, hidden props and
|
|
59
|
+
* an enclosure the camera stands inside (a room set) never count. */
|
|
60
|
+
export function occludersOf(subject, cameraPosition, state, ports, subjectBounds = entityWorldBounds(subject, state, ports)) {
|
|
61
|
+
if (!subjectBounds) return [];
|
|
62
|
+
const centre = Object.fromEntries(['x', 'y', 'z'].map(a => [a, (subjectBounds.min[a] + subjectBounds.max[a]) / 2]));
|
|
63
|
+
return state.objects.filter(o => o.id !== subject.id && o.attach?.characterId !== subject.id && !isEffectivelyHidden(o, state.objects, state.characters))
|
|
64
|
+
.flatMap(o => {
|
|
65
|
+
const box = entityWorldBounds(o, state, ports);
|
|
66
|
+
return box && !contains(box, cameraPosition) && segmentIntersectsAabb(cameraPosition, centre, box) ? [{ subjectId: subject.id, byObjectId: o.id }] : [];
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A shot's camera as position + aim: the live camera for the shot under the
|
|
71
|
+
* playhead, else the shot's first camera key; null when it has neither. */
|
|
72
|
+
export function shotCamera(shot, state) {
|
|
73
|
+
if (state.camera && shotAtFrame(state.shotDocument.shots, state.frame)?.id === shot.id) return { position: state.camera.position, lookAt: state.camera.lookAt };
|
|
74
|
+
const framing = shot.cameraKeys?.[0]?.framing;
|
|
75
|
+
if (!framing) return null;
|
|
76
|
+
const { pos, yaw, pitch } = framing;
|
|
77
|
+
return { position: pos, lookAt: { x: pos.x - Math.sin(yaw) * Math.cos(pitch), y: pos.y + Math.sin(pitch), z: pos.z - Math.cos(yaw) * Math.cos(pitch) } };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The 180-degree rule over shots read against the same pair: false when any
|
|
81
|
+
* pair has cameras on both sides, true when some pair has two sided shots and
|
|
82
|
+
* none crosses, null when no pair has two sided shots to compare. */
|
|
83
|
+
function axisConsistency(shots) {
|
|
84
|
+
const byPair = new Map();
|
|
85
|
+
for (const shot of shots) {
|
|
86
|
+
if (shot.cameraSide !== 'left' && shot.cameraSide !== 'right') continue;
|
|
87
|
+
const key = shot.subjectIds.join('|');
|
|
88
|
+
byPair.set(key, [...(byPair.get(key) ?? []), shot.cameraSide]);
|
|
89
|
+
}
|
|
90
|
+
const compared = [...byPair.values()].filter(sides => sides.length >= 2);
|
|
91
|
+
return compared.length ? compared.every(sides => sides.every(side => side === sides[0])) : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Facts for the given shots, bounded by GEOMETRY_FACTS_LIMITS. Without shotIds:
|
|
95
|
+
* the shots named in `first` (other ids are skipped), then the rest of the cut, so
|
|
96
|
+
* axisConsistent compares the named shots with the others. */
|
|
97
|
+
export function shotGeometryFacts(state, { shotIds, first = [], ports } = {}) {
|
|
98
|
+
const cast = visibleCharacters(state).slice(0, GEOMETRY_FACTS_LIMITS.subjects);
|
|
99
|
+
const subjects = cast.map(c => ({ id: c.id, position: point(c), yawDeg: round(c.rot ?? 0) }));
|
|
100
|
+
const pairs = cast.flatMap((a, i) => cast.slice(i + 1).map(b => ({ a: a.id, b: b.id, distanceM: round(Math.hypot(b.x - a.x, b.z - a.z)) })))
|
|
101
|
+
.sort((p, q) => p.distanceM - q.distanceM).slice(0, GEOMETRY_FACTS_LIMITS.pairs);
|
|
102
|
+
const all = state.shotDocument.shots;
|
|
103
|
+
const named = all.filter(s => first.includes(s.id)).sort((p, q) => first.indexOf(p.id) - first.indexOf(q.id));
|
|
104
|
+
const wanted = (shotIds ? shotIds.map(id => all.find(s => s.id === id)) : [...named, ...all.filter(s => !named.includes(s))]).filter(Boolean).slice(0, GEOMETRY_FACTS_LIMITS.shots);
|
|
105
|
+
const shots = wanted.map(shot => {
|
|
106
|
+
const camera = shotCamera(shot, state);
|
|
107
|
+
if (!camera) return { shotId: shot.id, subjectIds: [], camera: null, cameraSide: null, occluders: [] };
|
|
108
|
+
const pair = primarySubjects(shot.subjectIds ?? [], camera.position, state);
|
|
109
|
+
return { shotId: shot.id, subjectIds: pair.map(c => c.id), camera: { position: point(camera.position), lookAt: point(camera.lookAt) },
|
|
110
|
+
cameraSide: pair.length === 2 ? sideOfAxis(pair[0], pair[1], camera.position) : null,
|
|
111
|
+
occluders: pair.flatMap(subject => occludersOf(subject, camera.position, state, ports)).slice(0, GEOMETRY_FACTS_LIMITS.occluders) };
|
|
112
|
+
});
|
|
113
|
+
return { subjects, pairs, shots, axisConsistent: axisConsistency(shots) };
|
|
114
|
+
}
|