cozyclay 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +112 -0
- package/README.md +17 -2
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/bin/agent/agent-routes.mjs +219 -38
- package/bin/agent/agent-runner.mjs +98 -4
- package/bin/agent/codex-client.mjs +2 -1
- package/bin/agent/fal-contract.mjs +0 -17
- package/bin/agent/h3-preservation.mjs +6 -8
- package/bin/agent/provider-keys.mjs +1 -1
- package/bin/agent/providers.mjs +124 -3
- package/bin/agent/script-tool.mjs +74 -0
- package/bin/agent/session-store.mjs +5 -5
- package/bin/agent/studio-prompt.mjs +2 -1
- package/bin/agent/studio-rebase.mjs +314 -0
- package/bin/agent/studio-tools.mjs +21 -14
- package/bin/agent/supervisor.mjs +311 -0
- package/bin/agent/video-adapters.mjs +0 -7
- package/bin/codex-auth.mjs +6 -5
- package/bin/cozyclay.mjs +12 -3
- package/bin/live/cli.mjs +2 -3
- package/bin/live/client.mjs +1 -1
- package/bin/live/studio.mjs +5 -2
- package/bin/mcp-runtime.mjs +12 -2
- package/bin/telemetry-state.mjs +3 -3
- package/dist/app/index.html +6 -6
- package/dist/assets/analytics-B41FmPYI.js +1 -0
- package/dist/assets/app-Do-Og5Pu.css +1 -0
- package/dist/assets/app-DwP-tl2x.js +4920 -0
- package/dist/assets/{demo-ByeYp-dY.js → demo-Dt0qZfS7.js} +1 -1
- package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-BlT33CGf.js} +1 -1
- package/dist/assets/{landing-BaXkpFip.js → landing-BSesrCpL.js} +1 -1
- package/dist/assets/scene-asset-sync-BCWDJB1d.js +19 -0
- package/dist/assets/{shot-prompt-CbqWcWYi.css → scene-asset-sync-DjJ_cJ2R.css} +1 -1
- package/dist/assets/{ticket-BcmsnHn5.js → ticket-jqMhqL88.js} +1 -1
- package/dist/assets/workflow-Br-K7Srs.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +328 -222
- package/dist/sitemap.xml +5 -5
- package/dist/workflow/index.html +4 -4
- package/mcp/LIVE-PROTOCOL.md +23 -2
- package/mcp/README.md +37 -2
- package/mcp/ardy-prompts.mjs +1 -1
- package/mcp/image-file.mjs +132 -0
- package/mcp/live-hub.mjs +2 -2
- package/mcp/package.json +1 -0
- package/mcp/runtime/package-lock.json +7 -0
- package/mcp/runtime/package.json +1 -0
- package/mcp/server.mjs +0 -6
- package/mcp/tool-handlers.mjs +289 -87
- package/package.json +11 -2
- package/src/App.jsx +801 -259
- package/src/agent-image-client.js +94 -0
- package/src/analytics.js +47 -3
- package/src/app-stage.jsx +129 -2745
- package/src/ardy/auto-physics.js +6 -16
- package/src/ardy/collision-blockers.js +3 -7
- package/src/ardy/fix-collisions.js +5 -35
- package/src/ardy/ik-drag.js +86 -5
- package/src/ardy/ik-key-json.js +3 -1
- package/src/ardy/ik.js +15 -13
- package/src/ardy/motion-calibration.js +1 -1
- package/src/ardy/npz.js +0 -8
- package/src/ardy/physics-panel.jsx +3 -2
- package/src/ardy/physics-review.js +3 -2
- package/src/ardy/physics-support.js +1 -1
- package/src/ardy/physics-surface.js +1 -1
- package/src/ardy/platform-fit-panel.jsx +2 -1
- package/src/ardy/playback.js +1 -5
- package/src/ardy/range-pin.js +2 -2
- package/src/ardy/root-drop.js +0 -1
- package/src/ardy/timeline.css +196 -3
- package/src/ardy/timeline.jsx +117 -29
- package/src/ardy/to-cskel27.js +0 -2
- package/src/ardy/waypoints.js +1 -4
- package/src/ardy-bridge-contract.js +281 -0
- package/src/asset-pane.jsx +19 -9
- package/src/auto-color.js +1 -1
- package/src/burn-in.js +1 -1
- package/src/camera-block.js +1 -1
- package/src/camera-follow.js +75 -32
- package/src/camera-move.js +0 -6
- package/src/camera-tutorial.jsx +43 -24
- package/src/character-kind.js +79 -0
- package/src/command-bus.js +76 -17
- package/src/commands/cast.js +31 -7
- package/src/commands/elements/object.js +1 -1
- package/src/commands/elements/shot.js +1 -1
- package/src/commands/elements.js +1 -1
- package/src/commands/export.js +3 -3
- package/src/commands/motion.js +2 -2
- package/src/commands/objects.js +105 -10
- package/src/commands/project.js +16 -3
- package/src/commands/sculpt-images.js +53 -0
- package/src/commands/shot.js +88 -10
- package/src/commands/view.js +10 -4
- package/src/coplanar-depth.js +216 -0
- package/src/cuts.js +7 -6
- package/src/domains/cast.js +60 -43
- package/src/domains/motion/motion-intent-codec.js +50 -0
- package/src/domains/motion/motion-range-pin-rules.js +23 -0
- package/src/domains/motion/motion-video-capture.js +664 -0
- package/src/domains/motion.js +253 -742
- package/src/domains/objects.js +106 -5
- package/src/domains/scenes.js +89 -32
- package/src/domains/shots.js +33 -7
- package/src/error-boundary.jsx +1 -1
- package/src/execution-telemetry.js +5 -5
- package/src/facing-marks.js +14 -1
- package/src/first-success-guide.jsx +35 -4
- package/src/gizmo-claim.js +3 -1
- package/src/grid-floor.jsx +1 -4
- package/src/hierarchy-model.js +48 -35
- package/src/hierarchy-panel.css +1 -0
- package/src/hierarchy-panel.jsx +54 -102
- package/src/history.js +0 -2
- package/src/i2v-motion-client.js +1 -4
- package/src/ime.js +6 -0
- package/src/keyframe-pack.js +15 -6
- package/src/line-edit.js +4 -4
- package/src/live-control.js +6 -6
- package/src/matte-editor.js +2 -8
- package/src/matte.js +0 -19
- package/src/mesh-graph-clone.js +16 -2
- package/src/mesh-sniff.js +0 -1
- package/src/monotone-cubic.js +40 -0
- package/src/motion/generation.js +1 -1
- package/src/motion-store.js +4 -4
- package/src/motion-trail.js +5 -11
- package/src/multimodel-ingest.js +1 -3
- package/src/object-catalog.jsx +7 -5
- package/src/object-gizmo.jsx +45 -31
- package/src/object-path.js +310 -67
- package/src/object-travel.js +224 -0
- package/src/otio.js +8 -3
- package/src/panels/CharacterTransformPanel.jsx +24 -6
- package/src/panels/ObjectTransformPanel.jsx +4 -3
- package/src/panels/PoseImagePanel.jsx +205 -0
- package/src/panels/PromptBlocksPanel.jsx +3 -1
- package/src/panels/ProxyFigurePanel.jsx +56 -0
- package/src/panels/ReferenceImageField.jsx +1 -1
- package/src/panels/RigControlPanel.jsx +24 -3
- package/src/panels/RigPanel.jsx +2 -1
- package/src/panels/SculptPanel.jsx +405 -0
- package/src/panels/SubjectBox.jsx +1 -1
- package/src/panels/SubjectsPanel.jsx +0 -2
- package/src/panels/VideoCapturePanel.jsx +2 -1
- package/src/panels/pose-image.css +42 -0
- package/src/panels/pose.css +3 -0
- package/src/panels/sculpt.css +162 -0
- package/src/plan-diagram.js +184 -0
- package/src/planview.jsx +127 -80
- package/src/playground.js +3 -5
- package/src/pose-fade.js +77 -0
- package/src/pose-gizmo.css +23 -0
- package/src/pose-gizmo.js +96 -0
- package/src/posestudio.jsx +134 -83
- package/src/project-browser.css +53 -0
- package/src/project-browser.jsx +52 -3
- package/src/project.js +55 -24
- package/src/props.jsx +177 -36
- package/src/proxy-figure.jsx +69 -0
- package/src/range-pin-panel.jsx +0 -1
- package/src/receipt-findings.js +20 -0
- package/src/render-passes.js +0 -1
- package/src/resource-status.jsx +1 -1
- package/src/root-path.js +48 -0
- package/src/route-owner.js +52 -0
- package/src/scene-asset-cache.js +1 -2
- package/src/scene-assets.js +12 -4
- package/src/scene-fbx.js +1 -1
- package/src/scene-mesh-cache.js +0 -1
- package/src/scene-objects.js +319 -19
- package/src/scenes.js +62 -17
- package/src/sculpt-blueprint.js +65 -0
- package/src/sculpt-compare.js +384 -0
- package/src/sculpt-geometry.js +141 -0
- package/src/sculpt-mode.js +168 -0
- package/src/sculpt-recipe.js +385 -0
- package/src/sculpt-run.js +351 -0
- package/src/sculpt-stage.jsx +221 -0
- package/src/settings-menu.jsx +1 -1
- package/src/shell/BoardSlot.jsx +341 -0
- package/src/shell/BottomDock.jsx +58 -14
- package/src/shell/DetailsSlot.jsx +26 -6
- package/src/shell/MenuBar.jsx +78 -6
- package/src/shell/OutlinerSlot.jsx +3 -1
- package/src/shell/TopBar.jsx +17 -3
- package/src/shell/ViewportToolbar.jsx +18 -3
- package/src/shell/dock.css +239 -0
- package/src/shell/glass-regions.css +3 -2
- package/src/shell/log-store.js +4 -4
- package/src/shell/mode.css +20 -0
- package/src/shell/studio-shell-context.js +4 -1
- package/src/shot-authoring.js +69 -6
- package/src/stable-items.js +1 -1
- package/src/stage-attach-frames.js +169 -0
- package/src/stage-canvas-rigs.jsx +402 -0
- package/src/stage-capture.jsx +216 -0
- package/src/stage-characters.jsx +302 -0
- package/src/stage-editor-gizmos.jsx +1229 -0
- package/src/stage-labels-ko.js +204 -0
- package/src/stage-startup-defaults.js +182 -0
- package/src/starter-scenes.js +1 -1
- package/src/storyboard.js +24 -5
- package/src/studio-actions.js +42 -9
- package/src/studio-agent-commands.js +186 -23
- package/src/studio-agent-context.js +11 -9
- package/src/studio-agent-motion.js +2 -1
- package/src/studio-agent-protocol.js +56 -23
- package/src/studio-app-binding.js +52 -23
- package/src/studio-contact-sheet.js +3 -3
- package/src/studio-elements.js +11 -2
- package/src/studio-geometry-facts.js +114 -0
- package/src/styles.css +111 -10
- package/src/theme.js +1 -1
- package/src/timeline-fps.js +13 -0
- package/src/ui-scrub.js +4 -4
- package/src/ui.jsx +6 -4
- package/src/use-asset-shelf.js +60 -0
- package/src/use-camera-tutorial.js +50 -0
- package/src/use-embed-bridge.js +12 -0
- package/src/use-first-success-prompt.js +7 -0
- package/src/use-i2v-capture-modal.js +15 -0
- package/src/use-sculpt-mode.js +95 -0
- package/src/workflow/AgentPanel.jsx +110 -20
- package/src/workflow/WorkflowBuilder.jsx +1 -1
- package/src/workflow/agent-client.js +113 -45
- package/src/workflow/agent-panel.css +35 -0
- package/src/workflow/attachment-image.js +1 -1
- package/src/workflow/canvas-commands.js +3 -3
- package/src/workflow/cozy-scene-node.js +3 -3
- package/src/workflow/local-workflow.js +2 -2
- package/src/workflow/motion-input.js +3 -3
- package/src/workflow/node-schema.js +1 -1
- package/src/workflow/scene-asset-sync.js +2 -9
- package/src/workflow/shot-prompt-node.js +1 -1
- package/src/workflow/video-contract.js +1 -1
- package/src/workflow/workflow-resources.js +0 -8
- package/src/workflow-send.js +87 -0
- package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
- package/tools/check-eslint.mjs +57 -0
- package/tools/check-knip.mjs +60 -0
- package/tools/check-loc.mjs +54 -0
- package/tools/demo-worker/api-client.mjs +7 -13
- package/tools/demo-worker/generate.mjs +2 -4
- package/tools/eslint-baseline.json +23 -0
- package/tools/kimodo/effector-constraints.mjs +4 -4
- package/tools/kimodo/generate.mjs +1 -1
- package/tools/knip-baseline.json +331 -0
- package/tools/loc-baseline.json +126 -0
- package/tools/process-supervisor.mjs +1 -1
- package/tools/projflow/replay.mjs +4 -4
- package/tools/projflow/runner.mjs +3 -3
- package/tools/qa/studio-control-count.mjs +2 -0
- package/tools/run-tests.mjs +96 -7
- package/dist/assets/analytics-MVZOEW8O.js +0 -1
- package/dist/assets/app-C1sdVbg4.css +0 -1
- package/dist/assets/app-CA55tcI3.js +0 -4876
- package/dist/assets/shot-prompt-Yrt99wZx.js +0 -17
- package/dist/assets/workflow-dNPGY9xz.js +0 -179
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { useFrame, useThree } from "@react-three/fiber";
|
|
3
|
+
import * as THREE from "three";
|
|
4
|
+
import { aimAt } from "./controls.jsx";
|
|
5
|
+
import { SUBJECT_HEIGHT_M } from "./shot.js";
|
|
6
|
+
import { shotAtFrame } from "./cuts.js";
|
|
7
|
+
import { sampleAt } from "./sample-at.js";
|
|
8
|
+
import { RENDER_ACTIVITY_EVENT } from "./use-render-activity.js";
|
|
9
|
+
import { PRESETS, editorFrameForSubject } from "./stage-startup-defaults.js";
|
|
10
|
+
|
|
11
|
+
/** Selection marker for the picked cast member: a Unity-style XYZ tripod
|
|
12
|
+
* plus a ground ring at the feet. X/Z arrows also drag the character on the
|
|
13
|
+
* floor (Y is display-only — characters stand on the deck). */
|
|
14
|
+
export function ShotRig({ preset, nonce, fovDeg, charA, charB, showB, probeX, probeZ, camRef, look, onMetrics, appliedPresetRef, lastPosRef }) {
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
const cam = camRef.current;
|
|
17
|
+
if (!cam) return;
|
|
18
|
+
// A preset is applied once per (preset, nonce), not once per mount. The
|
|
19
|
+
// Canvas has no Suspense boundary of its own, so a cast member whose FBX
|
|
20
|
+
// is still downloading suspends the whole scene graph; when it resolves,
|
|
21
|
+
// React remounts the siblings — this rig included, on a fresh camera
|
|
22
|
+
// object — and a per-mount seed would snap the shot back to the preset,
|
|
23
|
+
// discarding a frame_shot or a manual move that landed in the meantime
|
|
24
|
+
// (#86 on slow CI runners). Both refs live in App so they survive the
|
|
25
|
+
// remount: the stamp says whether this preset was already applied, the
|
|
26
|
+
// position ref (with look.current) says where the camera last was.
|
|
27
|
+
const stamp = `${preset}:${nonce}`;
|
|
28
|
+
if (appliedPresetRef && appliedPresetRef.current === stamp && lastPosRef?.current) {
|
|
29
|
+
const last = lastPosRef.current;
|
|
30
|
+
cam.position.set(last.x, last.y, last.z);
|
|
31
|
+
cam.rotation.order = "YXZ";
|
|
32
|
+
cam.rotation.set(look.current.pitch, look.current.yaw, 0);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (appliedPresetRef) appliedPresetRef.current = stamp;
|
|
36
|
+
const p = PRESETS[preset];
|
|
37
|
+
const az = (p.azimuth * Math.PI) / 180;
|
|
38
|
+
const el = (p.elevation * Math.PI) / 180;
|
|
39
|
+
const aim =
|
|
40
|
+
p.aimMid && showB ? { x: (charA.x + charB.x) / 2, z: (charA.z + charB.z) / 2 } : { x: charA.x, z: charA.z };
|
|
41
|
+
const horizontal = p.distance * Math.cos(el);
|
|
42
|
+
cam.position.set(
|
|
43
|
+
aim.x + horizontal * Math.sin(az),
|
|
44
|
+
p.height ?? Math.max(p.targetY + p.distance * Math.sin(el), 0.15),
|
|
45
|
+
aim.z + horizontal * Math.cos(az),
|
|
46
|
+
);
|
|
47
|
+
const angles = aimAt(cam.position, { x: aim.x, y: p.targetY, z: aim.z });
|
|
48
|
+
look.current.yaw = angles.yaw;
|
|
49
|
+
look.current.pitch = angles.pitch;
|
|
50
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
51
|
+
}, [preset, nonce]);
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
const cam = camRef.current;
|
|
56
|
+
if (!cam) return;
|
|
57
|
+
cam.fov = fovDeg;
|
|
58
|
+
cam.updateProjectionMatrix();
|
|
59
|
+
}, [fovDeg, camRef]);
|
|
60
|
+
|
|
61
|
+
// A blocking tool must not claim a framing it is not holding, so the subject's
|
|
62
|
+
// feet and eyes are projected into the frame every tick.
|
|
63
|
+
const probe = useRef(new THREE.Vector3());
|
|
64
|
+
const lastMetrics = useRef({ x: NaN, y: NaN, z: NaN, visible: null });
|
|
65
|
+
useFrame(() => {
|
|
66
|
+
const cam = camRef.current;
|
|
67
|
+
if (!cam) return;
|
|
68
|
+
let visible = false;
|
|
69
|
+
for (const y of [0.1, SUBJECT_HEIGHT_M * 0.94]) {
|
|
70
|
+
// During motion playback the probe follows the played root, so the
|
|
71
|
+
// "subject out of frame" caption stays honest.
|
|
72
|
+
probe.current.set(probeX ?? charA.x, y, probeZ ?? charA.z).project(cam);
|
|
73
|
+
if (probe.current.z < 1 && Math.abs(probe.current.x) <= 1 && Math.abs(probe.current.y) <= 1) visible = true;
|
|
74
|
+
}
|
|
75
|
+
const previous = lastMetrics.current;
|
|
76
|
+
if (
|
|
77
|
+
Math.abs(previous.x - cam.position.x) +
|
|
78
|
+
Math.abs(previous.y - cam.position.y) +
|
|
79
|
+
Math.abs(previous.z - cam.position.z) >
|
|
80
|
+
1e-4 ||
|
|
81
|
+
previous.visible !== visible
|
|
82
|
+
) {
|
|
83
|
+
lastMetrics.current = {
|
|
84
|
+
x: cam.position.x,
|
|
85
|
+
y: cam.position.y,
|
|
86
|
+
z: cam.position.z,
|
|
87
|
+
visible,
|
|
88
|
+
};
|
|
89
|
+
onMetrics(cam.position, visible);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Plays an authored A→B camera move by driving the shot camera exactly the
|
|
97
|
+
way a fly-drag does — position on the camera, orientation through the look
|
|
98
|
+
ref — so ShotRig's metrics, the slate, and the inset all stay honest for
|
|
99
|
+
free. A right-drag interrupts the dolly: the user always outranks it.
|
|
100
|
+
|
|
101
|
+
Two frame sources can drive the move. Standalone preview advances authored
|
|
102
|
+
frames at production cadence; follow mode reads the timeline playhead, so the camera and
|
|
103
|
+
the character motion are two views of the same time axis — playing or
|
|
104
|
+
scrubbing frame 72 at 24 fps puts the camera exactly 3 s into its move.
|
|
105
|
+
|
|
106
|
+
frameRef is the imperative playhead routed props and riders already read in
|
|
107
|
+
their own useFrame. The followFrame prop reaches this Canvas subtree one
|
|
108
|
+
commit later, so alone it would pose the camera a paint behind them. */
|
|
109
|
+
export function MoveRig({ playing, following, followFrame, frameRef, fps, keys, shots, scene, camRef, look, isInterrupted, onDone }) {
|
|
110
|
+
const invalidate = useThree((state) => state.invalidate);
|
|
111
|
+
const preview = useRef({ frame: 0, finished: false, notified: false });
|
|
112
|
+
const handlers = useRef({ isInterrupted, onDone });
|
|
113
|
+
handlers.current = { isInterrupted, onDone };
|
|
114
|
+
// The follow branch only re-applies when its time changes; in demand mode
|
|
115
|
+
// each apply needs one more frame so ShotRig's metrics see the new pose.
|
|
116
|
+
const appliedFrame = useRef(null);
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
// Arming or reshaping the move must schedule a frame by hand: this
|
|
119
|
+
// component owns no three objects, so in demand mode nothing else will.
|
|
120
|
+
appliedFrame.current = null;
|
|
121
|
+
if (following) invalidate();
|
|
122
|
+
}, [keys, shots, scene, fps, following, invalidate]);
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
// A paused scrub changes the playhead without starting the render loop.
|
|
125
|
+
if (following && !playing) invalidate();
|
|
126
|
+
}, [followFrame, following, playing, invalidate]);
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
if (!playing || keys.length < 1) return undefined;
|
|
129
|
+
const firstFrame = keys[0].frame;
|
|
130
|
+
const lastFrame = keys[keys.length - 1].frame;
|
|
131
|
+
const spanFrames = Math.max(lastFrame - firstFrame, 1);
|
|
132
|
+
const spanSeconds = Math.max(spanFrames / Math.max(fps, 1), 0.1);
|
|
133
|
+
const tickMs = (spanSeconds * 1000) / spanFrames;
|
|
134
|
+
let tick = 0;
|
|
135
|
+
preview.current = { frame: firstFrame, finished: false, notified: false };
|
|
136
|
+
invalidate();
|
|
137
|
+
const timer = window.setInterval(() => {
|
|
138
|
+
if (preview.current.finished) return;
|
|
139
|
+
if (handlers.current.isInterrupted?.()) {
|
|
140
|
+
preview.current.finished = true;
|
|
141
|
+
invalidate();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
tick += 1;
|
|
145
|
+
preview.current = {
|
|
146
|
+
frame: firstFrame + (lastFrame - firstFrame) * Math.min(tick / spanFrames, 1),
|
|
147
|
+
finished: tick >= spanFrames,
|
|
148
|
+
notified: false,
|
|
149
|
+
};
|
|
150
|
+
invalidate();
|
|
151
|
+
}, tickMs);
|
|
152
|
+
return () => window.clearInterval(timer);
|
|
153
|
+
}, [playing, keys, fps, invalidate]);
|
|
154
|
+
useFrame(() => {
|
|
155
|
+
if ((playing && keys.length < 1) || (!playing && following && !shots.some((shot) => shot.cameraKeys.length))) return;
|
|
156
|
+
const cam = camRef.current;
|
|
157
|
+
if (!cam) return;
|
|
158
|
+
const apply = (frame) => {
|
|
159
|
+
// Timeline/PlayView sampling follows the editorial strips. Standalone
|
|
160
|
+
// preview stays scoped to the selected strip's keys. Entering a Shot
|
|
161
|
+
// applies its authored camera immediately (a hard cut). Leaving into a
|
|
162
|
+
// gap returns null and deliberately applies nothing: the last physical
|
|
163
|
+
// camera pose stays put and FlyControls regain ownership, so we do not
|
|
164
|
+
// invent a second cut to an arbitrary "free camera" preset.
|
|
165
|
+
const timelineShot = following ? shotAtFrame(shots, frame) : null;
|
|
166
|
+
const sampledShot = timelineShot
|
|
167
|
+
? { ...timelineShot, camera: { mode: "keys" } }
|
|
168
|
+
: following ? null : { camera: { mode: "keys" }, cameraKeys: keys };
|
|
169
|
+
const f = sampleAt(scene, sampledShot, frame).camera;
|
|
170
|
+
if (!f) return null;
|
|
171
|
+
cam.position.set(f.pos.x, f.pos.y, f.pos.z);
|
|
172
|
+
look.current.yaw = f.yaw;
|
|
173
|
+
look.current.pitch = f.pitch;
|
|
174
|
+
if (Math.abs(cam.fov - f.fovDeg) > 1e-3) {
|
|
175
|
+
cam.fov = f.fovDeg;
|
|
176
|
+
cam.updateProjectionMatrix();
|
|
177
|
+
}
|
|
178
|
+
return f;
|
|
179
|
+
};
|
|
180
|
+
if (playing) {
|
|
181
|
+
const f = apply(preview.current.frame);
|
|
182
|
+
if (preview.current.finished && !preview.current.notified) {
|
|
183
|
+
preview.current.notified = true;
|
|
184
|
+
handlers.current.onDone(f ? f.fovDeg : cam.fov);
|
|
185
|
+
}
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
if (!following) return;
|
|
189
|
+
if (isInterrupted?.()) return; // manual viewport framing owns the paused camera
|
|
190
|
+
const frame = frameRef?.current ?? followFrame;
|
|
191
|
+
if (appliedFrame.current === frame) return;
|
|
192
|
+
appliedFrame.current = frame;
|
|
193
|
+
apply(frame);
|
|
194
|
+
invalidate();
|
|
195
|
+
});
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Applies the precomputed follow-camera track to the shot camera. The track
|
|
201
|
+
* is derived offline from the subject trajectory (camera-follow.js), so this
|
|
202
|
+
* rig only samples it at the playhead — scrub, play, PlayView and Record all
|
|
203
|
+
* replay the identical deterministic move. Null-rendering, so every apply
|
|
204
|
+
* must invalidate() by hand in demand mode, like MoveRig. Like MoveRig it
|
|
205
|
+
* prefers frameRef over the frame prop so the camera lands in the same paint
|
|
206
|
+
* as the routed props and riders it follows.
|
|
207
|
+
*/
|
|
208
|
+
export function FollowCamRig({ enabled, frame, frameRef, scene, shot, camRef, look, isInterrupted }) {
|
|
209
|
+
const invalidate = useThree((state) => state.invalidate);
|
|
210
|
+
const applied = useRef(null);
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
applied.current = null;
|
|
213
|
+
if (enabled) invalidate();
|
|
214
|
+
}, [scene, shot, enabled, invalidate]);
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
if (enabled) invalidate();
|
|
217
|
+
}, [frame, enabled, invalidate]);
|
|
218
|
+
useFrame(() => {
|
|
219
|
+
if (!enabled) return;
|
|
220
|
+
if (isInterrupted?.()) return; // the user is flying; yield until released
|
|
221
|
+
const cam = camRef.current;
|
|
222
|
+
if (!cam) return;
|
|
223
|
+
const playhead = frameRef?.current ?? frame;
|
|
224
|
+
const sample = sampleAt(scene, shot, playhead).camera;
|
|
225
|
+
if (!sample || applied.current === playhead) return;
|
|
226
|
+
applied.current = playhead;
|
|
227
|
+
cam.position.set(sample.pos.x, sample.pos.y, sample.pos.z);
|
|
228
|
+
look.current.yaw = sample.yaw;
|
|
229
|
+
look.current.pitch = sample.pitch;
|
|
230
|
+
invalidate();
|
|
231
|
+
});
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Applies the shot camera's yaw/pitch from its look ref every frame. Before
|
|
237
|
+
* the camera split, FlyControls owned the shot camera and performed this
|
|
238
|
+
* apply as a side effect of flying; with the fly controls bound to the
|
|
239
|
+
* editor camera, the recording camera still needs its aim (ShotRig, follow,
|
|
240
|
+
* frame-selection) reflected in its actual rotation — the preview and the
|
|
241
|
+
* capture read the camera object, not the ref.
|
|
242
|
+
*/
|
|
243
|
+
export function ShotLookApplier({ camRef, look }) {
|
|
244
|
+
useFrame(() => {
|
|
245
|
+
const cam = camRef.current;
|
|
246
|
+
if (!cam) return;
|
|
247
|
+
if (cam.rotation.order !== "YXZ") cam.rotation.order = "YXZ";
|
|
248
|
+
if (cam.rotation.x !== look.current.pitch || cam.rotation.y !== look.current.yaw || cam.rotation.z !== 0) {
|
|
249
|
+
cam.rotation.set(look.current.pitch, look.current.yaw, 0);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Seeds the editor camera once, inside the Canvas root — App-level effects
|
|
257
|
+
* run before R3F mounts its children, so the ref is still null there. The
|
|
258
|
+
* seed frames both the subject and the shot camera: the first editor frame
|
|
259
|
+
* must read as "the set with the camera in it".
|
|
260
|
+
*/
|
|
261
|
+
/**
|
|
262
|
+
* A short editor-camera glide: 260 ms ease toward a target orientation (and
|
|
263
|
+
* optionally a position), instead of the teleport that made "look at the
|
|
264
|
+
* light" and F-framing feel like a cut. Any user press cancels it — the
|
|
265
|
+
* operator always outranks the tour guide.
|
|
266
|
+
*/
|
|
267
|
+
export function CameraGlide({ glide, camRef, lookRef, onDone }) {
|
|
268
|
+
const { gl } = useThree();
|
|
269
|
+
const invalidate = useThree((state) => state.invalidate);
|
|
270
|
+
const animRef = useRef(null);
|
|
271
|
+
useEffect(() => {
|
|
272
|
+
if (!glide) {
|
|
273
|
+
animRef.current = null;
|
|
274
|
+
return undefined;
|
|
275
|
+
}
|
|
276
|
+
const cam = camRef.current;
|
|
277
|
+
if (!cam) return undefined;
|
|
278
|
+
const fromPos = cam.position.clone();
|
|
279
|
+
const toPos = glide.position ? new THREE.Vector3(glide.position.x, glide.position.y, glide.position.z) : fromPos.clone();
|
|
280
|
+
const angles = aimAt(toPos, glide.target);
|
|
281
|
+
const from = { yaw: lookRef.current.yaw, pitch: lookRef.current.pitch };
|
|
282
|
+
let dYaw = angles.yaw - from.yaw;
|
|
283
|
+
while (dYaw > Math.PI) dYaw -= 2 * Math.PI;
|
|
284
|
+
while (dYaw < -Math.PI) dYaw += 2 * Math.PI;
|
|
285
|
+
// elapsed accumulates render-loop deltas: the glide needs no wall clock
|
|
286
|
+
animRef.current = { fromPos, toPos, from, dYaw, dPitch: angles.pitch - from.pitch, elapsed: 0 };
|
|
287
|
+
const cancel = () => {
|
|
288
|
+
animRef.current = null;
|
|
289
|
+
onDone?.();
|
|
290
|
+
};
|
|
291
|
+
gl.domElement.addEventListener("pointerdown", cancel, true);
|
|
292
|
+
invalidate();
|
|
293
|
+
return () => gl.domElement.removeEventListener("pointerdown", cancel, true);
|
|
294
|
+
// camRef/lookRef are stable refs; onDone identity is per-glide noise
|
|
295
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
296
|
+
}, [glide, gl, invalidate]);
|
|
297
|
+
useFrame((_, delta) => {
|
|
298
|
+
const anim = animRef.current;
|
|
299
|
+
const cam = camRef.current;
|
|
300
|
+
if (!anim || !cam) return;
|
|
301
|
+
anim.elapsed += Math.min(delta, 0.1) * 1000;
|
|
302
|
+
const t = Math.min(1, anim.elapsed / 260);
|
|
303
|
+
const eased = 1 - (1 - t) ** 3;
|
|
304
|
+
cam.position.lerpVectors(anim.fromPos, anim.toPos, eased);
|
|
305
|
+
const yaw = anim.from.yaw + anim.dYaw * eased;
|
|
306
|
+
const pitch = anim.from.pitch + anim.dPitch * eased;
|
|
307
|
+
lookRef.current = { yaw, pitch };
|
|
308
|
+
cam.rotation.order = "YXZ";
|
|
309
|
+
cam.rotation.set(pitch, yaw, 0);
|
|
310
|
+
if (t >= 1) {
|
|
311
|
+
animRef.current = null;
|
|
312
|
+
onDone?.();
|
|
313
|
+
} else {
|
|
314
|
+
invalidate();
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function EditorCamSeed({ camRef, lookRef, shotCamRef, subject }) {
|
|
321
|
+
const seeded = useRef(false);
|
|
322
|
+
const invalidate = useThree((state) => state.invalidate);
|
|
323
|
+
useEffect(() => {
|
|
324
|
+
if (seeded.current) return;
|
|
325
|
+
const cam = camRef.current;
|
|
326
|
+
if (!cam) return;
|
|
327
|
+
seeded.current = true;
|
|
328
|
+
// The selected character owns the first read. The shot camera remains a
|
|
329
|
+
// useful reference in the inset; mixing its position into this seed pushed
|
|
330
|
+
// the editor eye several metres back and made the mannequin look tiny.
|
|
331
|
+
const frame = editorFrameForSubject(subject);
|
|
332
|
+
const { target, position } = frame;
|
|
333
|
+
cam.position.set(position.x, position.y, position.z);
|
|
334
|
+
const angles = aimAt(cam.position, target);
|
|
335
|
+
lookRef.current = { yaw: angles.yaw, pitch: angles.pitch };
|
|
336
|
+
cam.rotation.order = "YXZ";
|
|
337
|
+
cam.rotation.set(angles.pitch, angles.yaw, 0);
|
|
338
|
+
invalidate();
|
|
339
|
+
});
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function RenderLoopController({ stageRef }) {
|
|
344
|
+
const frameloop = useThree((state) => state.frameloop);
|
|
345
|
+
const setFrameloop = useThree((state) => state.setFrameloop);
|
|
346
|
+
const invalidate = useThree((state) => state.invalidate);
|
|
347
|
+
useEffect(() => {
|
|
348
|
+
const apply = (next) => {
|
|
349
|
+
setFrameloop(next ? "always" : "demand");
|
|
350
|
+
if (next) invalidate();
|
|
351
|
+
};
|
|
352
|
+
const onActivity = (event) => apply(event.detail);
|
|
353
|
+
window.addEventListener(RENDER_ACTIVITY_EVENT, onActivity);
|
|
354
|
+
return () => window.removeEventListener(RENDER_ACTIVITY_EVENT, onActivity);
|
|
355
|
+
}, [invalidate, setFrameloop]);
|
|
356
|
+
useEffect(() => {
|
|
357
|
+
if (stageRef.current) stageRef.current.dataset.actualRenderLoop = frameloop;
|
|
358
|
+
}, [frameloop, stageRef]);
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** GPU resets — sleep/wake, a driver restart, VRAM pressure from an export —
|
|
363
|
+
* kill the WebGL context, and without listeners the stage goes black and
|
|
364
|
+
* stays black with no message. preventDefault on webglcontextlost is the
|
|
365
|
+
* documented opt-in for browser-driven restoration; on restore, kick the
|
|
366
|
+
* demand loop so the first frame actually paints. */
|
|
367
|
+
export function ContextLossGuard({ onLostChange }) {
|
|
368
|
+
const gl = useThree((state) => state.gl);
|
|
369
|
+
const invalidate = useThree((state) => state.invalidate);
|
|
370
|
+
useEffect(() => {
|
|
371
|
+
const canvas = gl.domElement;
|
|
372
|
+
const onLost = (event) => {
|
|
373
|
+
event.preventDefault();
|
|
374
|
+
onLostChange(true);
|
|
375
|
+
};
|
|
376
|
+
const onRestored = () => {
|
|
377
|
+
onLostChange(false);
|
|
378
|
+
invalidate();
|
|
379
|
+
};
|
|
380
|
+
canvas.addEventListener("webglcontextlost", onLost);
|
|
381
|
+
canvas.addEventListener("webglcontextrestored", onRestored);
|
|
382
|
+
return () => {
|
|
383
|
+
canvas.removeEventListener("webglcontextlost", onLost);
|
|
384
|
+
canvas.removeEventListener("webglcontextrestored", onRestored);
|
|
385
|
+
};
|
|
386
|
+
}, [gl, invalidate, onLostChange]);
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function ViewportLayoutInvalidator({ insetX, insetY, insetWidth, insetHeight, hierarchyWidth, sidebarWidth, timelineHeight, planZoom }) {
|
|
391
|
+
const invalidate = useThree((state) => state.invalidate);
|
|
392
|
+
useEffect(() => {
|
|
393
|
+
// The pane frame is regular DOM while its picture is a scissored WebGL
|
|
394
|
+
// viewport. In demand mode a drag can commit its final DOM position just
|
|
395
|
+
// after the continuous loop stops, leaving the picture at the previous
|
|
396
|
+
// coordinates. Redraw now and once more after layout has settled.
|
|
397
|
+
invalidate();
|
|
398
|
+
const frame = requestAnimationFrame(invalidate);
|
|
399
|
+
return () => cancelAnimationFrame(frame);
|
|
400
|
+
}, [invalidate, insetX, insetY, insetWidth, insetHeight, hierarchyWidth, sidebarWidth, timelineHeight, planZoom]);
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { useThree } from "@react-three/fiber";
|
|
3
|
+
import * as THREE from "three";
|
|
4
|
+
import { GIZMO_LAYER } from "./dualview.jsx";
|
|
5
|
+
|
|
6
|
+
export const CAPTURE_W = 1920;
|
|
7
|
+
export const CAPTURE_H = 1080;
|
|
8
|
+
export const MCP_CAPTURE_W = 640;
|
|
9
|
+
export const MCP_CAPTURE_H = 360;
|
|
10
|
+
export const MCP_CAPTURE_SAMPLE_STEP = 32;
|
|
11
|
+
// How far the deck runs in an EXPORTED frame. The viewport fades it out at
|
|
12
|
+
// 18..54 m to keep the working view clean; a blocking frame needs the far edge
|
|
13
|
+
// to survive, so the falloff starts later and ends further away, letting the
|
|
14
|
+
// floor resolve into a horizon instead of dissolving into the background.
|
|
15
|
+
//
|
|
16
|
+
// Both values must stay inside the shot camera's far plane (100 m) — geometry
|
|
17
|
+
// past it is clipped outright, so a fog range that ended beyond it would cut
|
|
18
|
+
// the deck off mid-fade and take the horizon with it.
|
|
19
|
+
export const CAPTURE_FOG_NEAR = 55;
|
|
20
|
+
export const CAPTURE_FOG_FAR = 95;
|
|
21
|
+
|
|
22
|
+
/** Offscreen aspect-aware read-back, always from the shot camera. */
|
|
23
|
+
export function CaptureRig({ apiRef, camRef, width = CAPTURE_W, height = CAPTURE_H }) {
|
|
24
|
+
const { gl, scene } = useThree();
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
// Each attempt owns its target independently of the live output size.
|
|
27
|
+
// Resizing the editor cannot dispose an in-flight export's resources.
|
|
28
|
+
function createExportCapture({ width, height }) {
|
|
29
|
+
const target = new THREE.WebGLRenderTarget(width, height, {
|
|
30
|
+
colorSpace: THREE.SRGBColorSpace,
|
|
31
|
+
samples: 4,
|
|
32
|
+
});
|
|
33
|
+
const buffer = new Uint8Array(width * height * 4);
|
|
34
|
+
return {
|
|
35
|
+
scene,
|
|
36
|
+
dispose: () => target.dispose(),
|
|
37
|
+
render() {
|
|
38
|
+
const source = camRef.current;
|
|
39
|
+
if (!source) return null;
|
|
40
|
+
const cam = source.clone();
|
|
41
|
+
// the transform gizmo is UI: it never reaches an exported frame
|
|
42
|
+
cam.layers.disable(GIZMO_LAYER);
|
|
43
|
+
// QA hook: the layer mask the export camera actually renders
|
|
44
|
+
// with — the browser suite asserts GIZMO_LAYER (the gizmo AND
|
|
45
|
+
// the selection cage) is never in it.
|
|
46
|
+
window.__captureCameraMask = cam.layers.mask;
|
|
47
|
+
// A view offset (a crop of a wider frustum) already fixes the aspect.
|
|
48
|
+
if (!cam.view?.enabled) cam.aspect = width / height;
|
|
49
|
+
cam.updateProjectionMatrix();
|
|
50
|
+
const previous = gl.getRenderTarget();
|
|
51
|
+
// The viewport's fog dissolves the deck into the background by ~54 m
|
|
52
|
+
// so the working view has no horizon to distract from blocking. An
|
|
53
|
+
// exported frame wants the opposite: the horizon IS the vanishing
|
|
54
|
+
// point, and without it the floor has no far edge to read depth
|
|
55
|
+
// against. Push the falloff back for this draw only, then restore
|
|
56
|
+
// it so the viewport is untouched.
|
|
57
|
+
const fog = scene.fog;
|
|
58
|
+
const fogNear = fog?.near;
|
|
59
|
+
const fogFar = fog?.far;
|
|
60
|
+
if (fog) {
|
|
61
|
+
fog.near = CAPTURE_FOG_NEAR;
|
|
62
|
+
fog.far = CAPTURE_FOG_FAR;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
gl.setRenderTarget(target);
|
|
66
|
+
gl.render(scene, cam);
|
|
67
|
+
gl.readRenderTargetPixels(target, 0, 0, width, height, buffer);
|
|
68
|
+
} finally {
|
|
69
|
+
gl.setRenderTarget(previous);
|
|
70
|
+
if (fog) {
|
|
71
|
+
fog.near = fogNear;
|
|
72
|
+
fog.far = fogFar;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return buffer;
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
const api = { ...createExportCapture({ width, height }), createExportCapture };
|
|
80
|
+
apiRef.current = api;
|
|
81
|
+
if (width === MCP_CAPTURE_W && height === MCP_CAPTURE_H) {
|
|
82
|
+
window.__cozyclayMcpCaptureReady = true;
|
|
83
|
+
window.dispatchEvent(new Event("cozyclay:mcp-capture-ready"));
|
|
84
|
+
}
|
|
85
|
+
// QA hook: run one real export render and report what the capture
|
|
86
|
+
// camera saw — the layer mask plus an amber scan of the output
|
|
87
|
+
// frame (the selection cage's warm tone). Lets the suite prove the
|
|
88
|
+
// deliverable frame stays free of editor furniture without driving
|
|
89
|
+
// the whole generation form.
|
|
90
|
+
window.__captureFrame = () => {
|
|
91
|
+
const buffer = apiRef.current?.render() ?? null;
|
|
92
|
+
if (!buffer) return null;
|
|
93
|
+
let amber = 0;
|
|
94
|
+
for (let i = 0; i < buffer.length; i += 4) {
|
|
95
|
+
const r = buffer[i];
|
|
96
|
+
const g = buffer[i + 1];
|
|
97
|
+
const b = buffer[i + 2];
|
|
98
|
+
if (r >= 180 && g >= 165 && g - b >= 35) amber++;
|
|
99
|
+
}
|
|
100
|
+
return { layersMask: window.__captureCameraMask ?? 0, amber, pixels: buffer.length / 4 };
|
|
101
|
+
};
|
|
102
|
+
return () => {
|
|
103
|
+
if (apiRef.current === api) apiRef.current = null;
|
|
104
|
+
if (width === MCP_CAPTURE_W && height === MCP_CAPTURE_H) window.__cozyclayMcpCaptureReady = false;
|
|
105
|
+
api.dispose();
|
|
106
|
+
};
|
|
107
|
+
}, [gl, scene, camRef, apiRef, width, height]);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function captureMcpFrame({ capture, camera, characters, activeCharacterId, objects, rigs, readAuthoredState, partColours = null }) {
|
|
112
|
+
if (!capture || !camera) throw new Error("No renderable shot camera is available for capture_frame.");
|
|
113
|
+
const authoredStateBefore = JSON.stringify(readAuthoredState());
|
|
114
|
+
const buffer = capture.render();
|
|
115
|
+
if (!buffer) throw new Error("No renderable shot camera is available for capture_frame.");
|
|
116
|
+
let nonBlackPixels = 0;
|
|
117
|
+
for (let offset = 0; offset < buffer.length; offset += 4) {
|
|
118
|
+
if (buffer[offset] > 5 || buffer[offset + 1] > 5 || buffer[offset + 2] > 5) nonBlackPixels += 1;
|
|
119
|
+
}
|
|
120
|
+
if (nonBlackPixels === 0) throw new Error("capture_frame rejected a black rendered frame.");
|
|
121
|
+
camera.updateMatrixWorld();
|
|
122
|
+
const raycaster = new THREE.Raycaster();
|
|
123
|
+
const roots = [];
|
|
124
|
+
capture.scene.traverse((node) => {
|
|
125
|
+
const id = node.userData?.sceneObjectId;
|
|
126
|
+
if (typeof id === "string" && objects.some((object) => object.id === id)) roots.push({ id, root: node });
|
|
127
|
+
});
|
|
128
|
+
const assertions = characters.map((character) => {
|
|
129
|
+
const rig = rigs[character.id];
|
|
130
|
+
if (!rig || character.hidden) return { id: character.id, renderable: false, behindCameraPlane: null, fartherAlongCameraForward: null, distanceToFloor: null, occludedBy: null, visiblePixelCount: 0 };
|
|
131
|
+
rig.updateWorldMatrix(true, true);
|
|
132
|
+
const bounds = new THREE.Box3().setFromObject(rig);
|
|
133
|
+
const center = bounds.getCenter(new THREE.Vector3());
|
|
134
|
+
const cameraSpace = camera.worldToLocal(center.clone());
|
|
135
|
+
const behindCameraPlane = cameraSpace.z >= 0;
|
|
136
|
+
const distanceToFloor = Math.max(0, bounds.min.y);
|
|
137
|
+
if (behindCameraPlane || bounds.isEmpty()) {
|
|
138
|
+
return { id: character.id, renderable: true, behindCameraPlane, fartherAlongCameraForward: -cameraSpace.z, distanceToFloor, occludedBy: null, visiblePixelCount: 0 };
|
|
139
|
+
}
|
|
140
|
+
const corners = [
|
|
141
|
+
new THREE.Vector3(bounds.min.x, bounds.min.y, bounds.min.z), new THREE.Vector3(bounds.min.x, bounds.min.y, bounds.max.z),
|
|
142
|
+
new THREE.Vector3(bounds.min.x, bounds.max.y, bounds.min.z), new THREE.Vector3(bounds.min.x, bounds.max.y, bounds.max.z),
|
|
143
|
+
new THREE.Vector3(bounds.max.x, bounds.min.y, bounds.min.z), new THREE.Vector3(bounds.max.x, bounds.min.y, bounds.max.z),
|
|
144
|
+
new THREE.Vector3(bounds.max.x, bounds.max.y, bounds.min.z), new THREE.Vector3(bounds.max.x, bounds.max.y, bounds.max.z),
|
|
145
|
+
].map((corner) => corner.project(camera));
|
|
146
|
+
const minX = Math.max(0, Math.floor(((Math.min(...corners.map((corner) => corner.x)) + 1) * 0.5) * MCP_CAPTURE_W));
|
|
147
|
+
const maxX = Math.min(MCP_CAPTURE_W - 1, Math.ceil(((Math.max(...corners.map((corner) => corner.x)) + 1) * 0.5) * MCP_CAPTURE_W));
|
|
148
|
+
const minY = Math.max(0, Math.floor(((1 - Math.max(...corners.map((corner) => corner.y))) * 0.5) * MCP_CAPTURE_H));
|
|
149
|
+
const maxY = Math.min(MCP_CAPTURE_H - 1, Math.ceil(((1 - Math.min(...corners.map((corner) => corner.y))) * 0.5) * MCP_CAPTURE_H));
|
|
150
|
+
const occluders = new Map();
|
|
151
|
+
let visiblePixelCount = 0;
|
|
152
|
+
for (let y = minY; y <= maxY; y += MCP_CAPTURE_SAMPLE_STEP) {
|
|
153
|
+
for (let x = minX; x <= maxX; x += MCP_CAPTURE_SAMPLE_STEP) {
|
|
154
|
+
raycaster.setFromCamera({ x: (x / MCP_CAPTURE_W) * 2 - 1, y: 1 - (y / MCP_CAPTURE_H) * 2 }, camera);
|
|
155
|
+
const characterHit = raycaster.intersectObject(rig, true)[0];
|
|
156
|
+
if (!characterHit) continue;
|
|
157
|
+
const objectHit = raycaster.intersectObjects(roots.map((entry) => entry.root), true)[0];
|
|
158
|
+
const pixels = MCP_CAPTURE_SAMPLE_STEP * MCP_CAPTURE_SAMPLE_STEP;
|
|
159
|
+
if (!objectHit || objectHit.distance >= characterHit.distance - 1e-4) {
|
|
160
|
+
visiblePixelCount += pixels;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
const occluder = roots.find((entry) => {
|
|
164
|
+
let node = objectHit.object;
|
|
165
|
+
while (node) {
|
|
166
|
+
if (node === entry.root) return true;
|
|
167
|
+
node = node.parent;
|
|
168
|
+
}
|
|
169
|
+
return false;
|
|
170
|
+
});
|
|
171
|
+
if (occluder) occluders.set(occluder.id, (occluders.get(occluder.id) ?? 0) + pixels);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const occludedBy = [...occluders.entries()].sort((left, right) => right[1] - left[1])[0]?.[0] ?? null;
|
|
175
|
+
return { id: character.id, renderable: true, behindCameraPlane, fartherAlongCameraForward: -cameraSpace.z, distanceToFloor, occludedBy, visiblePixelCount };
|
|
176
|
+
});
|
|
177
|
+
const active = assertions.find((entry) => entry.id === activeCharacterId) ?? assertions[0];
|
|
178
|
+
const authoredStateAfter = JSON.stringify(readAuthoredState());
|
|
179
|
+
const canvas = document.createElement("canvas");
|
|
180
|
+
canvas.width = MCP_CAPTURE_W;
|
|
181
|
+
canvas.height = MCP_CAPTURE_H;
|
|
182
|
+
const context = canvas.getContext("2d");
|
|
183
|
+
if (!context) throw new Error("capture_frame could not encode the rendered image.");
|
|
184
|
+
const image = context.createImageData(MCP_CAPTURE_W, MCP_CAPTURE_H);
|
|
185
|
+
for (let row = 0; row < MCP_CAPTURE_H; row += 1) {
|
|
186
|
+
image.data.set(buffer.subarray((MCP_CAPTURE_H - 1 - row) * MCP_CAPTURE_W * 4, (MCP_CAPTURE_H - row) * MCP_CAPTURE_W * 4), row * MCP_CAPTURE_W * 4);
|
|
187
|
+
}
|
|
188
|
+
context.putImageData(image, 0, 0);
|
|
189
|
+
const blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
|
|
190
|
+
if (!blob) throw new Error("capture_frame could not compress the rendered image.");
|
|
191
|
+
const bytes = new Uint8Array(await blob.arrayBuffer());
|
|
192
|
+
let binary = "";
|
|
193
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
194
|
+
return {
|
|
195
|
+
width: MCP_CAPTURE_W,
|
|
196
|
+
height: MCP_CAPTURE_H,
|
|
197
|
+
mimeType: "image/png",
|
|
198
|
+
encoding: "base64",
|
|
199
|
+
byteSize: bytes.length,
|
|
200
|
+
data: btoa(binary),
|
|
201
|
+
partColours,
|
|
202
|
+
authoredStateBefore,
|
|
203
|
+
authoredStateAfter,
|
|
204
|
+
assertions: {
|
|
205
|
+
renderable: true,
|
|
206
|
+
blackFrame: false,
|
|
207
|
+
nonBlackPixels,
|
|
208
|
+
behindCameraPlane: active?.behindCameraPlane ?? null,
|
|
209
|
+
fartherAlongCameraForward: active?.fartherAlongCameraForward ?? null,
|
|
210
|
+
distanceToFloor: active?.distanceToFloor ?? null,
|
|
211
|
+
occludedBy: active?.occludedBy ?? null,
|
|
212
|
+
visiblePixelCount: active?.visiblePixelCount ?? 0,
|
|
213
|
+
characters: assertions,
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
}
|