cozyclay 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +112 -0
- package/README.md +17 -2
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/bin/agent/agent-routes.mjs +219 -38
- package/bin/agent/agent-runner.mjs +98 -4
- package/bin/agent/codex-client.mjs +2 -1
- package/bin/agent/fal-contract.mjs +0 -17
- package/bin/agent/h3-preservation.mjs +6 -8
- package/bin/agent/provider-keys.mjs +1 -1
- package/bin/agent/providers.mjs +124 -3
- package/bin/agent/script-tool.mjs +74 -0
- package/bin/agent/session-store.mjs +5 -5
- package/bin/agent/studio-prompt.mjs +2 -1
- package/bin/agent/studio-rebase.mjs +314 -0
- package/bin/agent/studio-tools.mjs +21 -14
- package/bin/agent/supervisor.mjs +311 -0
- package/bin/agent/video-adapters.mjs +0 -7
- package/bin/codex-auth.mjs +6 -5
- package/bin/cozyclay.mjs +12 -3
- package/bin/live/cli.mjs +2 -3
- package/bin/live/client.mjs +1 -1
- package/bin/live/studio.mjs +5 -2
- package/bin/mcp-runtime.mjs +12 -2
- package/bin/telemetry-state.mjs +3 -3
- package/dist/app/index.html +6 -6
- package/dist/assets/analytics-B41FmPYI.js +1 -0
- package/dist/assets/app-Do-Og5Pu.css +1 -0
- package/dist/assets/app-DwP-tl2x.js +4920 -0
- package/dist/assets/{demo-ByeYp-dY.js → demo-Dt0qZfS7.js} +1 -1
- package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-BlT33CGf.js} +1 -1
- package/dist/assets/{landing-BaXkpFip.js → landing-BSesrCpL.js} +1 -1
- package/dist/assets/scene-asset-sync-BCWDJB1d.js +19 -0
- package/dist/assets/{shot-prompt-CbqWcWYi.css → scene-asset-sync-DjJ_cJ2R.css} +1 -1
- package/dist/assets/{ticket-BcmsnHn5.js → ticket-jqMhqL88.js} +1 -1
- package/dist/assets/workflow-Br-K7Srs.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +328 -222
- package/dist/sitemap.xml +5 -5
- package/dist/workflow/index.html +4 -4
- package/mcp/LIVE-PROTOCOL.md +23 -2
- package/mcp/README.md +37 -2
- package/mcp/ardy-prompts.mjs +1 -1
- package/mcp/image-file.mjs +132 -0
- package/mcp/live-hub.mjs +2 -2
- package/mcp/package.json +1 -0
- package/mcp/runtime/package-lock.json +7 -0
- package/mcp/runtime/package.json +1 -0
- package/mcp/server.mjs +0 -6
- package/mcp/tool-handlers.mjs +289 -87
- package/package.json +11 -2
- package/src/App.jsx +801 -259
- package/src/agent-image-client.js +94 -0
- package/src/analytics.js +47 -3
- package/src/app-stage.jsx +129 -2745
- package/src/ardy/auto-physics.js +6 -16
- package/src/ardy/collision-blockers.js +3 -7
- package/src/ardy/fix-collisions.js +5 -35
- package/src/ardy/ik-drag.js +86 -5
- package/src/ardy/ik-key-json.js +3 -1
- package/src/ardy/ik.js +15 -13
- package/src/ardy/motion-calibration.js +1 -1
- package/src/ardy/npz.js +0 -8
- package/src/ardy/physics-panel.jsx +3 -2
- package/src/ardy/physics-review.js +3 -2
- package/src/ardy/physics-support.js +1 -1
- package/src/ardy/physics-surface.js +1 -1
- package/src/ardy/platform-fit-panel.jsx +2 -1
- package/src/ardy/playback.js +1 -5
- package/src/ardy/range-pin.js +2 -2
- package/src/ardy/root-drop.js +0 -1
- package/src/ardy/timeline.css +196 -3
- package/src/ardy/timeline.jsx +117 -29
- package/src/ardy/to-cskel27.js +0 -2
- package/src/ardy/waypoints.js +1 -4
- package/src/ardy-bridge-contract.js +281 -0
- package/src/asset-pane.jsx +19 -9
- package/src/auto-color.js +1 -1
- package/src/burn-in.js +1 -1
- package/src/camera-block.js +1 -1
- package/src/camera-follow.js +75 -32
- package/src/camera-move.js +0 -6
- package/src/camera-tutorial.jsx +43 -24
- package/src/character-kind.js +79 -0
- package/src/command-bus.js +76 -17
- package/src/commands/cast.js +31 -7
- package/src/commands/elements/object.js +1 -1
- package/src/commands/elements/shot.js +1 -1
- package/src/commands/elements.js +1 -1
- package/src/commands/export.js +3 -3
- package/src/commands/motion.js +2 -2
- package/src/commands/objects.js +105 -10
- package/src/commands/project.js +16 -3
- package/src/commands/sculpt-images.js +53 -0
- package/src/commands/shot.js +88 -10
- package/src/commands/view.js +10 -4
- package/src/coplanar-depth.js +216 -0
- package/src/cuts.js +7 -6
- package/src/domains/cast.js +60 -43
- package/src/domains/motion/motion-intent-codec.js +50 -0
- package/src/domains/motion/motion-range-pin-rules.js +23 -0
- package/src/domains/motion/motion-video-capture.js +664 -0
- package/src/domains/motion.js +253 -742
- package/src/domains/objects.js +106 -5
- package/src/domains/scenes.js +89 -32
- package/src/domains/shots.js +33 -7
- package/src/error-boundary.jsx +1 -1
- package/src/execution-telemetry.js +5 -5
- package/src/facing-marks.js +14 -1
- package/src/first-success-guide.jsx +35 -4
- package/src/gizmo-claim.js +3 -1
- package/src/grid-floor.jsx +1 -4
- package/src/hierarchy-model.js +48 -35
- package/src/hierarchy-panel.css +1 -0
- package/src/hierarchy-panel.jsx +54 -102
- package/src/history.js +0 -2
- package/src/i2v-motion-client.js +1 -4
- package/src/ime.js +6 -0
- package/src/keyframe-pack.js +15 -6
- package/src/line-edit.js +4 -4
- package/src/live-control.js +6 -6
- package/src/matte-editor.js +2 -8
- package/src/matte.js +0 -19
- package/src/mesh-graph-clone.js +16 -2
- package/src/mesh-sniff.js +0 -1
- package/src/monotone-cubic.js +40 -0
- package/src/motion/generation.js +1 -1
- package/src/motion-store.js +4 -4
- package/src/motion-trail.js +5 -11
- package/src/multimodel-ingest.js +1 -3
- package/src/object-catalog.jsx +7 -5
- package/src/object-gizmo.jsx +45 -31
- package/src/object-path.js +310 -67
- package/src/object-travel.js +224 -0
- package/src/otio.js +8 -3
- package/src/panels/CharacterTransformPanel.jsx +24 -6
- package/src/panels/ObjectTransformPanel.jsx +4 -3
- package/src/panels/PoseImagePanel.jsx +205 -0
- package/src/panels/PromptBlocksPanel.jsx +3 -1
- package/src/panels/ProxyFigurePanel.jsx +56 -0
- package/src/panels/ReferenceImageField.jsx +1 -1
- package/src/panels/RigControlPanel.jsx +24 -3
- package/src/panels/RigPanel.jsx +2 -1
- package/src/panels/SculptPanel.jsx +405 -0
- package/src/panels/SubjectBox.jsx +1 -1
- package/src/panels/SubjectsPanel.jsx +0 -2
- package/src/panels/VideoCapturePanel.jsx +2 -1
- package/src/panels/pose-image.css +42 -0
- package/src/panels/pose.css +3 -0
- package/src/panels/sculpt.css +162 -0
- package/src/plan-diagram.js +184 -0
- package/src/planview.jsx +127 -80
- package/src/playground.js +3 -5
- package/src/pose-fade.js +77 -0
- package/src/pose-gizmo.css +23 -0
- package/src/pose-gizmo.js +96 -0
- package/src/posestudio.jsx +134 -83
- package/src/project-browser.css +53 -0
- package/src/project-browser.jsx +52 -3
- package/src/project.js +55 -24
- package/src/props.jsx +177 -36
- package/src/proxy-figure.jsx +69 -0
- package/src/range-pin-panel.jsx +0 -1
- package/src/receipt-findings.js +20 -0
- package/src/render-passes.js +0 -1
- package/src/resource-status.jsx +1 -1
- package/src/root-path.js +48 -0
- package/src/route-owner.js +52 -0
- package/src/scene-asset-cache.js +1 -2
- package/src/scene-assets.js +12 -4
- package/src/scene-fbx.js +1 -1
- package/src/scene-mesh-cache.js +0 -1
- package/src/scene-objects.js +319 -19
- package/src/scenes.js +62 -17
- package/src/sculpt-blueprint.js +65 -0
- package/src/sculpt-compare.js +384 -0
- package/src/sculpt-geometry.js +141 -0
- package/src/sculpt-mode.js +168 -0
- package/src/sculpt-recipe.js +385 -0
- package/src/sculpt-run.js +351 -0
- package/src/sculpt-stage.jsx +221 -0
- package/src/settings-menu.jsx +1 -1
- package/src/shell/BoardSlot.jsx +341 -0
- package/src/shell/BottomDock.jsx +58 -14
- package/src/shell/DetailsSlot.jsx +26 -6
- package/src/shell/MenuBar.jsx +78 -6
- package/src/shell/OutlinerSlot.jsx +3 -1
- package/src/shell/TopBar.jsx +17 -3
- package/src/shell/ViewportToolbar.jsx +18 -3
- package/src/shell/dock.css +239 -0
- package/src/shell/glass-regions.css +3 -2
- package/src/shell/log-store.js +4 -4
- package/src/shell/mode.css +20 -0
- package/src/shell/studio-shell-context.js +4 -1
- package/src/shot-authoring.js +69 -6
- package/src/stable-items.js +1 -1
- package/src/stage-attach-frames.js +169 -0
- package/src/stage-canvas-rigs.jsx +402 -0
- package/src/stage-capture.jsx +216 -0
- package/src/stage-characters.jsx +302 -0
- package/src/stage-editor-gizmos.jsx +1229 -0
- package/src/stage-labels-ko.js +204 -0
- package/src/stage-startup-defaults.js +182 -0
- package/src/starter-scenes.js +1 -1
- package/src/storyboard.js +24 -5
- package/src/studio-actions.js +42 -9
- package/src/studio-agent-commands.js +186 -23
- package/src/studio-agent-context.js +11 -9
- package/src/studio-agent-motion.js +2 -1
- package/src/studio-agent-protocol.js +56 -23
- package/src/studio-app-binding.js +52 -23
- package/src/studio-contact-sheet.js +3 -3
- package/src/studio-elements.js +11 -2
- package/src/studio-geometry-facts.js +114 -0
- package/src/styles.css +111 -10
- package/src/theme.js +1 -1
- package/src/timeline-fps.js +13 -0
- package/src/ui-scrub.js +4 -4
- package/src/ui.jsx +6 -4
- package/src/use-asset-shelf.js +60 -0
- package/src/use-camera-tutorial.js +50 -0
- package/src/use-embed-bridge.js +12 -0
- package/src/use-first-success-prompt.js +7 -0
- package/src/use-i2v-capture-modal.js +15 -0
- package/src/use-sculpt-mode.js +95 -0
- package/src/workflow/AgentPanel.jsx +110 -20
- package/src/workflow/WorkflowBuilder.jsx +1 -1
- package/src/workflow/agent-client.js +113 -45
- package/src/workflow/agent-panel.css +35 -0
- package/src/workflow/attachment-image.js +1 -1
- package/src/workflow/canvas-commands.js +3 -3
- package/src/workflow/cozy-scene-node.js +3 -3
- package/src/workflow/local-workflow.js +2 -2
- package/src/workflow/motion-input.js +3 -3
- package/src/workflow/node-schema.js +1 -1
- package/src/workflow/scene-asset-sync.js +2 -9
- package/src/workflow/shot-prompt-node.js +1 -1
- package/src/workflow/video-contract.js +1 -1
- package/src/workflow/workflow-resources.js +0 -8
- package/src/workflow-send.js +87 -0
- package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
- package/tools/check-eslint.mjs +57 -0
- package/tools/check-knip.mjs +60 -0
- package/tools/check-loc.mjs +54 -0
- package/tools/demo-worker/api-client.mjs +7 -13
- package/tools/demo-worker/generate.mjs +2 -4
- package/tools/eslint-baseline.json +23 -0
- package/tools/kimodo/effector-constraints.mjs +4 -4
- package/tools/kimodo/generate.mjs +1 -1
- package/tools/knip-baseline.json +331 -0
- package/tools/loc-baseline.json +126 -0
- package/tools/process-supervisor.mjs +1 -1
- package/tools/projflow/replay.mjs +4 -4
- package/tools/projflow/runner.mjs +3 -3
- package/tools/qa/studio-control-count.mjs +2 -0
- package/tools/run-tests.mjs +96 -7
- package/dist/assets/analytics-MVZOEW8O.js +0 -1
- package/dist/assets/app-C1sdVbg4.css +0 -1
- package/dist/assets/app-CA55tcI3.js +0 -4876
- package/dist/assets/shot-prompt-Yrt99wZx.js +0 -17
- package/dist/assets/workflow-dNPGY9xz.js +0 -179
package/src/App.jsx
CHANGED
|
@@ -48,18 +48,35 @@ import { createLiveControl, loadLiveWorkspaceId, mintLiveWorkspaceId } from "./l
|
|
|
48
48
|
import { createFirstEditTracker, semanticEditKind } from "./semantic-edit.js";
|
|
49
49
|
|
|
50
50
|
import AgentPanel from "./workflow/AgentPanel.jsx";
|
|
51
|
+
import SculptPanel from "./panels/SculptPanel.jsx";
|
|
52
|
+
import { createAgentPort, createSculptRun } from "./sculpt-run.js";
|
|
53
|
+
import { SculptTurntable } from "./sculpt-stage.jsx";
|
|
54
|
+
import { measureReferenceOutline } from "./sculpt-compare.js";
|
|
55
|
+
import { BLUEPRINT_LAYOUT, buildBlueprintPrompt } from "./sculpt-blueprint.js";
|
|
56
|
+
import { browserSculptImages } from "./commands/sculpt-images.js";
|
|
57
|
+
import { sculptAgentClient, sculptModelLabel } from "./sculpt-mode.js";
|
|
58
|
+
import { useSculptMode } from "./use-sculpt-mode.js";
|
|
59
|
+
import { useFirstSuccessPrompt } from "./use-first-success-prompt.js";
|
|
60
|
+
import { useCameraTutorial } from "./use-camera-tutorial.js";
|
|
61
|
+
import { useEmbedBridge } from "./use-embed-bridge.js";
|
|
62
|
+
import { useAssetShelf } from "./use-asset-shelf.js";
|
|
63
|
+
import { useI2vCaptureModal } from "./use-i2v-capture-modal.js";
|
|
51
64
|
import { StudioProtocolError } from "./studio-agent-protocol.js";
|
|
52
65
|
import { elementByPath } from "./studio-elements.js";
|
|
53
66
|
import {
|
|
54
67
|
resolveStudioToast,
|
|
55
68
|
} from "./studio-actions.js";
|
|
56
69
|
import { createStudioAppActions } from "./commands/index.js";
|
|
70
|
+
import { STORYBOARD_MOTION_REFUSAL } from "./commands/view.js";
|
|
57
71
|
|
|
58
72
|
import { createStudioAppBinding } from "./studio-app-binding.js";
|
|
59
73
|
import { AppContext, createAppContext } from "./app-context.js";
|
|
60
74
|
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
|
61
75
|
|
|
62
76
|
import { PlanBoard } from "./planview.jsx";
|
|
77
|
+
import { drawPlanDiagram } from "./plan-diagram.js";
|
|
78
|
+
import { placementAt } from "./root-path.js";
|
|
79
|
+
import { proxyYaw } from "./proxy-figure.jsx";
|
|
63
80
|
import { autoColorHex, loadAutoColor } from "./auto-color.js";
|
|
64
81
|
import { DualRender, fitAspect, GIZMO_LAYER } from "./dualview.jsx";
|
|
65
82
|
import { GridFloor } from "./grid-floor.jsx";
|
|
@@ -114,10 +131,9 @@ import {
|
|
|
114
131
|
RAIL_SCHEDULE_RANGE,
|
|
115
132
|
resolveRailSchedule,
|
|
116
133
|
} from "./camera-rail-schedule.js";
|
|
117
|
-
import { SetProps } from "./props.jsx";
|
|
134
|
+
import { ObjectCarrier, SetProps } from "./props.jsx";
|
|
118
135
|
import {
|
|
119
136
|
CUTOUT_KIND,
|
|
120
|
-
MESH_KIND,
|
|
121
137
|
isEffectivelyHidden,
|
|
122
138
|
objectSize,
|
|
123
139
|
sceneObjectIdFromHierarchy,
|
|
@@ -150,10 +166,12 @@ import {
|
|
|
150
166
|
SCENES_VERSION,
|
|
151
167
|
activeSceneIndex,
|
|
152
168
|
createSceneStage,
|
|
169
|
+
isProxyFigure,
|
|
153
170
|
normalizeReferenceImage,
|
|
154
171
|
} from "./scenes.js";
|
|
155
172
|
import {
|
|
156
173
|
hasFileSystemAccess,
|
|
174
|
+
loadProjectSession,
|
|
157
175
|
loadStoredProjectHandle,
|
|
158
176
|
queryHandlePermission,
|
|
159
177
|
requestHandlePermission,
|
|
@@ -182,16 +200,19 @@ import { demoSeedGate, hasLineEditCapability, motionReadiness } from "./motion-r
|
|
|
182
200
|
import { PWA_UPDATE_EVENT } from "./pwa.js";
|
|
183
201
|
import {
|
|
184
202
|
createObjectPath,
|
|
203
|
+
insertPathMark,
|
|
185
204
|
objectTransformAt,
|
|
186
205
|
pathMetrics,
|
|
187
206
|
strokeToPathPoints,
|
|
188
|
-
MAX_PATH_POINTS,
|
|
189
207
|
} from "./object-path.js";
|
|
208
|
+
import { carriedPointAt, sceneObjectsAt } from "./object-travel.js";
|
|
209
|
+
import { routeOwnerFor } from "./route-owner.js";
|
|
190
210
|
import {
|
|
191
211
|
analyticsActive,
|
|
192
212
|
exportFailureCode,
|
|
193
213
|
recordSemanticEdit,
|
|
194
214
|
recordUseCase,
|
|
215
|
+
sculptRunTelemetry,
|
|
195
216
|
shouldAskUseCase,
|
|
196
217
|
startExportAttempt,
|
|
197
218
|
track,
|
|
@@ -199,13 +220,13 @@ import {
|
|
|
199
220
|
trackFeature,
|
|
200
221
|
} from "./analytics.js";
|
|
201
222
|
import { ko, isKo } from "./locale.js";
|
|
202
|
-
import {
|
|
203
|
-
isPlaygroundEmbed,
|
|
204
|
-
} from "./playground.js";
|
|
223
|
+
import { characterCapabilities, kindRefusal, proxyFigureBounds } from "./character-kind.js";
|
|
205
224
|
import { STARTER_SCENES } from "./starter-scenes.js";
|
|
206
225
|
import { PART_COLOURS } from "./part-colours.js";
|
|
207
226
|
import {
|
|
208
227
|
DEFAULT_POSE,
|
|
228
|
+
applyHipsOffset,
|
|
229
|
+
applyPose,
|
|
209
230
|
} from "./poses.js";
|
|
210
231
|
import {
|
|
211
232
|
IkHandles,
|
|
@@ -262,7 +283,7 @@ import {
|
|
|
262
283
|
} from "./shot-guides.js";
|
|
263
284
|
import { shotCaptureMeta } from "./shot-meta.js";
|
|
264
285
|
import { buildShotPrompt } from "./shot-prompt.js";
|
|
265
|
-
import { keyframePackEntries, keyframePackName } from "./keyframe-pack.js";
|
|
286
|
+
import { buildKeyframePack, keyframePackEntries, keyframePackName, slugifyTitle } from "./keyframe-pack.js";
|
|
266
287
|
import { buildZip } from "./zip-store.js";
|
|
267
288
|
import { composeStoryboard } from "./storyboard.js";
|
|
268
289
|
import { DEPTH_RANGE_M, depthRangeFromFrames, passFileName, renderPass } from "./render-passes.js";
|
|
@@ -270,12 +291,12 @@ import { DEPTH_RANGE_M, depthRangeFromFrames, passFileName, renderPass } from ".
|
|
|
270
291
|
import { motionApiOrigin, I2V_MOTION_SHOT_ASPECT } from "./i2v-motion-client.js";
|
|
271
292
|
import { serializeOtio } from "./otio.js";
|
|
272
293
|
import "./shell/mode.css";
|
|
294
|
+
import { readPoseFadeOpacity, withPoseFadeLifted, writePoseFadeOpacity } from "./pose-fade.js";
|
|
273
295
|
import {
|
|
274
296
|
shotAtFrame,
|
|
275
297
|
shotIndexAtFrame,
|
|
276
298
|
} from "./cuts.js";
|
|
277
299
|
import {
|
|
278
|
-
ASSET_DELETE_UNDO_MS,
|
|
279
300
|
BRIDGE_RECHECK_MS,
|
|
280
301
|
CameraGlide,
|
|
281
302
|
CameraRailScenePreview,
|
|
@@ -435,11 +456,14 @@ function koSubjectParticle(word) {
|
|
|
435
456
|
* A missing rig, a member with no clip and a layer with no keys are all
|
|
436
457
|
* no-ops rather than errors: characters without a take keep their pose.
|
|
437
458
|
*/
|
|
438
|
-
function poseMemberAtFrame(rig, clip, ikState, frame, blendFrames = 0) {
|
|
459
|
+
function poseMemberAtFrame(rig, clip, ikState, frame, blendFrames = 0, pose = null) {
|
|
439
460
|
if (!rig) return;
|
|
440
461
|
if (clip) {
|
|
441
462
|
const sampled = sampleAt({ frameCount: clip.frames, motion: clip }, null, frame);
|
|
442
463
|
applyMotionFrame(rig, clip, sampled.motionFrame);
|
|
464
|
+
} else if (pose) {
|
|
465
|
+
applyPose(rig, pose);
|
|
466
|
+
applyHipsOffset(rig, pose.rootY ?? 0);
|
|
443
467
|
}
|
|
444
468
|
if (ikState && ikState.keys.size > 0 && ikState.chains && ikState.rig === rig) {
|
|
445
469
|
ikEvaluate(ikState.chains, ikState, frame, ikState.fkJoints, clip ? blendFrames : 0);
|
|
@@ -481,51 +505,15 @@ export default function App() {
|
|
|
481
505
|
getBus: () => studioBindingRef.current.bus,
|
|
482
506
|
notify: (...args) => setToast(...args),
|
|
483
507
|
}));
|
|
484
|
-
const embedMode
|
|
485
|
-
|
|
486
|
-
// scene with the project chrome hidden and saving off (see playground.js).
|
|
487
|
-
const playgroundMode = isPlaygroundEmbed(globalThis.location?.search);
|
|
488
|
-
const [playgroundHint, setPlaygroundHint] = useState(null);
|
|
489
|
-
const playgroundExportRef = useRef(null);
|
|
490
|
-
// The camera tutorial (#206): the landing page's seven steps, run against
|
|
491
|
-
// the real studio instead of the playground iframe. It opens from
|
|
492
|
-
// /app/?tutorial=camera or from Settings ▾, and it is not offered inside an
|
|
493
|
-
// embed.
|
|
494
|
-
|
|
495
|
-
// Both doors go through startCameraTutorial (#209), which puts the studio in
|
|
496
|
-
// the state the landing page teaches these steps in — the city-block starter
|
|
497
|
-
// scene with the walk take on its character — so Shot / Rail / Play always
|
|
498
|
-
// have something to frame. That function is declared with the project
|
|
499
|
-
// actions further down; the listeners here reach it through a ref so they
|
|
500
|
-
// always call the current render's closure (the confirm reads projectDirty).
|
|
501
|
-
const [cameraTutorialQuery] = useState(() => !embedMode && !playgroundMode
|
|
502
|
-
&& new URLSearchParams(globalThis.location?.search || "").get("tutorial") === "camera"
|
|
503
|
-
&& !cameraTutorialSuppressed());
|
|
504
|
-
const [cameraTutorial, setCameraTutorial] = useState(false);
|
|
505
|
-
const cameraTutorialAnalytics = useRef(null);
|
|
506
|
-
const [cameraTutorialAttempt, setCameraTutorialAttempt] = useState(0);
|
|
507
|
-
const [cameraTutorialHandoff, setCameraTutorialHandoff] = useState(null);
|
|
508
|
-
const cameraTutorialCompletedRef = useRef(false);
|
|
509
|
-
const exportMenuTriggerRef = useRef(null);
|
|
510
|
-
const exportShotIdRef = useRef(null);
|
|
511
|
-
// The step the tutorial is on, published on the .app root so styles.css can
|
|
512
|
-
// spotlight the one control that step needs (#211).
|
|
513
|
-
const [cameraTutorialStep, setCameraTutorialStep] = useState(null);
|
|
514
|
-
const startCameraTutorialRef = useRef(null);
|
|
515
|
-
const cameraTutorialStarted = useRef(false);
|
|
516
|
-
// A tutorial restart never reloads the starter over the user's edits.
|
|
517
|
-
const tutorialStarterRef = useRef(false);
|
|
518
|
-
const tutorialLoadingRef = useRef(false);
|
|
519
|
-
const tutorialProjectEpochRef = useRef(0);
|
|
520
|
-
const tutorialSeedEpochRef = useRef(null);
|
|
521
|
-
// Armed once the starter is applied, consumed by the seed effect next to the
|
|
522
|
-
// hosted-demo seed as soon as the new character's rig exists.
|
|
523
|
-
const [tutorialSeedPending, setTutorialSeedPending] = useState(false);
|
|
508
|
+
const { embedMode, playgroundMode, playgroundHint, setPlaygroundHint, playgroundExportRef } = useEmbedBridge();
|
|
509
|
+
const { cameraTutorialEntry, cameraTutorialQuery, cameraTutorial, setCameraTutorial, cameraTutorialAnalytics, cameraTutorialAttempt, setCameraTutorialAttempt, cameraTutorialHandoff, setCameraTutorialHandoff, cameraTutorialCompletedRef, exportMenuTriggerRef, exportShotIdRef, cameraTutorialStep, setCameraTutorialStep, startCameraTutorialRef, cameraTutorialStarted, tutorialStarterRef, tutorialLoadingRef, tutorialProjectEpochRef, tutorialSeedEpochRef, tutorialSeedPending, setTutorialSeedPending } = useCameraTutorial({ embedMode, playgroundMode });
|
|
524
510
|
useEffect(() => {
|
|
525
511
|
if (!cameraTutorialQuery || cameraTutorialStarted.current) return;
|
|
512
|
+
// Storyboard projects never auto-open the seven-step camera tutorial.
|
|
513
|
+
if (scenesDomain.previsMode === "storyboard") return;
|
|
526
514
|
cameraTutorialStarted.current = true;
|
|
527
|
-
void startCameraTutorialRef.current?.({ source:
|
|
528
|
-
}, [cameraTutorialQuery]);
|
|
515
|
+
void startCameraTutorialRef.current?.({ source: cameraTutorialEntry });
|
|
516
|
+
}, [cameraTutorialQuery, cameraTutorialEntry]);
|
|
529
517
|
useEffect(() => {
|
|
530
518
|
const onTutorial = (event) => {
|
|
531
519
|
if (event.detail?.open === false) {
|
|
@@ -537,6 +525,7 @@ export default function App() {
|
|
|
537
525
|
setCameraTutorialHandoff(null);
|
|
538
526
|
return;
|
|
539
527
|
}
|
|
528
|
+
if (scenesDomain.previsMode === "storyboard") return;
|
|
540
529
|
void startCameraTutorialRef.current?.({ source: event.detail?.source ?? "settings" });
|
|
541
530
|
};
|
|
542
531
|
window.addEventListener("cozyclay:camera-tutorial", onTutorial);
|
|
@@ -785,6 +774,23 @@ export default function App() {
|
|
|
785
774
|
// bridge read this global, and the render path is still PlayView's.
|
|
786
775
|
globalThis.playMode = preview;
|
|
787
776
|
const playMode = preview;
|
|
777
|
+
// #441 moved shot authoring into commands and dropped the Shot and Rail
|
|
778
|
+
// signals the landing checklist waits for. Read them from the authored state,
|
|
779
|
+
// as the studio tutorial does (#586): a shot added here, and a rail drawn.
|
|
780
|
+
// Declared before the play signal: a drawn rail starts playback in the same
|
|
781
|
+
// commit, and the checklist expects Rail before Play.
|
|
782
|
+
const playgroundShotCount = useRef(null);
|
|
783
|
+
useEffect(() => {
|
|
784
|
+
if (!playgroundMode) return;
|
|
785
|
+
if (playgroundShotCount.current !== null && shots.length > playgroundShotCount.current) {
|
|
786
|
+
window.parent?.postMessage({ type: "cozyclay:playground-nav", kind: "shot" }, "*");
|
|
787
|
+
}
|
|
788
|
+
playgroundShotCount.current = shots.length;
|
|
789
|
+
}, [playgroundMode, shots.length]);
|
|
790
|
+
const playgroundRailReady = playgroundMode && shots.some((shot) => Array.isArray(shot.camera?.cameraRail) && shot.camera.cameraRail.length >= 2);
|
|
791
|
+
useEffect(() => {
|
|
792
|
+
if (playgroundRailReady) window.parent?.postMessage({ type: "cozyclay:playground-nav", kind: "rail" }, "*");
|
|
793
|
+
}, [playgroundRailReady]);
|
|
788
794
|
// Preview is the player for the finished motion: entering starts playback,
|
|
789
795
|
// leaving pauses it. The editor view stays the manipulation surface.
|
|
790
796
|
const [tlPlaying, setTlPlaying] = useState(false);
|
|
@@ -1167,7 +1173,7 @@ export default function App() {
|
|
|
1167
1173
|
setPromptBlocksReveal, revealPromptBlocks, waypointMode, setWaypointMode, waypoints, setWaypoints,
|
|
1168
1174
|
activeWaypointId, setActiveWaypointId, pendingWaypointFrame, setPendingWaypointFrame, promptClips,
|
|
1169
1175
|
setPromptClips, editPromptClips, selectedPromptId, setSelectedPromptId, photoPoseState, photoPoseError,
|
|
1170
|
-
setPhotoPoseError, allPoses, selectablePoses, posingIndex, posingChar, posedRig, setPosed, rootStart,
|
|
1176
|
+
setPhotoPoseError, allPoses, selectablePoses, posingIndex, posingChar, posedRig, setPosed, rootStart, commitPosedPose,
|
|
1171
1177
|
queueRootWaypointFrame, castMemberOf, addCharacterWaypoint, moveCharacterWaypoint,
|
|
1172
1178
|
removeCharacterWaypoint, clearCharacterWaypoints, addFloorWaypoint, moveWaypoint, removeWaypoint,
|
|
1173
1179
|
toggleWaypointMode, openStudio, closeStudio, saveCurrentPose, savePose, posePhotoFile, removePose,
|
|
@@ -1180,9 +1186,14 @@ export default function App() {
|
|
|
1180
1186
|
// not the one its closure captured.
|
|
1181
1187
|
appContext.publishCharacters(characters);
|
|
1182
1188
|
|
|
1183
|
-
const
|
|
1184
|
-
|
|
1185
|
-
|
|
1189
|
+
const {
|
|
1190
|
+
i2vMotionEnabled,
|
|
1191
|
+
setI2vMotionEnabled,
|
|
1192
|
+
i2vMotionMode,
|
|
1193
|
+
setI2vMotionMode,
|
|
1194
|
+
i2vMotionCameraUnlocked,
|
|
1195
|
+
setI2vMotionCameraUnlocked,
|
|
1196
|
+
} = useI2vCaptureModal();
|
|
1186
1197
|
|
|
1187
1198
|
useEffect(() => {
|
|
1188
1199
|
let cancelled = false;
|
|
@@ -1359,7 +1370,8 @@ export default function App() {
|
|
|
1359
1370
|
bodyContact, setBodyContact, IK_CORRECTION_BLEND_FRAMES, autoPhysicsRunning, setAutoPhysicsRunning,
|
|
1360
1371
|
physicsPreview, setPhysicsPreview, physicsShow, physicsProgress, physicsOptions, setPhysicsOptions,
|
|
1361
1372
|
ikTick, setIkTick, committedIkEdits, setCommittedIkEdits, trailFalloffS, setTrailFalloffS, showTrails,
|
|
1362
|
-
setShowTrails, ikEditTool, setIkEditTool, trailEdit, trailFalloffFrames, focusIkHandle, snapshotIkKeys,
|
|
1373
|
+
setShowTrails, ikEditTool, setIkEditTool, trailEdit, trailTrackFocus, selectTrailTrack, clearTrailTrackFocus, trailFalloffFrames, focusIkHandle, snapshotIkKeys,
|
|
1374
|
+
pendingIkEdit, applyPendingIkEdit, cancelPendingIkEdit,
|
|
1363
1375
|
rangePins, rangePinResiduals, rangePinSelection, setRangePinSelection, rangePinPartPick, setRangePinPartPick, rangePinPreview, previewRangePinDraft, applyRangePinDraft, deleteRangePin,
|
|
1364
1376
|
setCharacterIkKey, removeCharacterIkKey, clearCharacterIkKeys, bridge, setBridge, bridgeChecking,
|
|
1365
1377
|
motionSetupReveal, motionSetupKind, setArdyPrompt, setArdyDuration, ardySeed, preserveStrength,
|
|
@@ -1389,14 +1401,24 @@ export default function App() {
|
|
|
1389
1401
|
// Keep the underlying selection model intact, but use this small workflow
|
|
1390
1402
|
// state to surface only the tools that belong to the current job.
|
|
1391
1403
|
const [workflowMode, setWorkflowMode] = useState("scene");
|
|
1404
|
+
const [poseObjectOpacity, setPoseObjectOpacityState] = useState(readPoseFadeOpacity);
|
|
1405
|
+
const setPoseObjectOpacity = (value) => { setPoseObjectOpacityState(value); writePoseFadeOpacity(value); };
|
|
1392
1406
|
// Pose mode IS IK editing (#521): with no rig to solve there is nothing to
|
|
1393
1407
|
// pose, so the mode refuses with the reason instead of opening empty.
|
|
1394
|
-
|
|
1408
|
+
// A capsule figure has no rig at all, so it gets its own reason rather than
|
|
1409
|
+
// the "load a character" one, which would read as a loading problem.
|
|
1410
|
+
const activeCapabilities = characterCapabilities(activeChar);
|
|
1411
|
+
const poseRefusal = !activeCapabilities.pose ? kindRefusal("pose", isKo) : ikChains ? null : ko("Load a character to pose it — Pose mode edits its rig with IK", "캐릭터를 불러와야 포즈를 잡을 수 있어요 — 포즈 모드는 IK로 리그를 편집합니다");
|
|
1395
1412
|
function selectWorkflowMode(next) {
|
|
1396
1413
|
if (next === "pose" && poseRefusal) {
|
|
1397
1414
|
setToast(poseRefusal);
|
|
1398
1415
|
return;
|
|
1399
1416
|
}
|
|
1417
|
+
// A storyboard is stills: there is no Motion department to open.
|
|
1418
|
+
if (next === "motion" && storyboardProject) {
|
|
1419
|
+
setToast(ko(STORYBOARD_MOTION_REFUSAL.en, STORYBOARD_MOTION_REFUSAL.ko));
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1400
1422
|
setWorkflowMode(next);
|
|
1401
1423
|
// Picking a department leaves the chrome-free player. Shot-look is
|
|
1402
1424
|
// camera work, so it only yields when the operator leaves Camera.
|
|
@@ -1415,7 +1437,8 @@ export default function App() {
|
|
|
1415
1437
|
// Pose lands on the active character's rig: its Rig Control panel holds
|
|
1416
1438
|
// the IK tools that W/E/R switch between.
|
|
1417
1439
|
else if (next === "pose") setSelectedHierarchyId(`${rowIdForCharIndex(activeCharIndex)}.rig`);
|
|
1418
|
-
|
|
1440
|
+
// Sculpt keeps the selection: a selected sculpt is the one it opens on.
|
|
1441
|
+
else if (next !== "sculpt") setSelectedHierarchyId("shot");
|
|
1419
1442
|
}
|
|
1420
1443
|
// Every door into the mode (tab, 1-4 keys, view.setMode on the bus) moves
|
|
1421
1444
|
// IK with it through the motion domain's own entry/exit path...
|
|
@@ -1425,7 +1448,7 @@ export default function App() {
|
|
|
1425
1448
|
// ...and whatever else ends IK (a take starting playback, the rig going
|
|
1426
1449
|
// away) hands the operator back to Motion instead of a dead Pose mode.
|
|
1427
1450
|
useEffect(() => {
|
|
1428
|
-
if (!ikMode && workflowMode === "pose") setWorkflowMode("motion");
|
|
1451
|
+
if (!ikMode && workflowMode === "pose") setWorkflowMode(storyboardProject ? "scene" : "motion");
|
|
1429
1452
|
}, [ikMode]);
|
|
1430
1453
|
|
|
1431
1454
|
const toggleHierarchyHidden = (hierarchyId) => {
|
|
@@ -1486,10 +1509,75 @@ export default function App() {
|
|
|
1486
1509
|
matteShrink, setMatteShrink, matteFeather, setMatteFeather, matteMode, setMatteMode, matteStats,
|
|
1487
1510
|
setMatteStats, matteBusy, gizmoMode, setGizmoMode, snapEnabled, setSnapEnabled, addSceneObject,
|
|
1488
1511
|
importCutout, importCutouts, spawnCutoutAt, persistMeshAsset, importMesh, importMeshes, spawnMeshAt,
|
|
1489
|
-
applyMatte, duplicateSelectedSceneObject, frameSelection, renameSceneObject, sceneObjectWorldMatrix,
|
|
1512
|
+
applyMatte, duplicateSelectedSceneObject, frameSelection, groupObjectUnderNewEmpty, renameRequest, renameSceneObject, sceneObjectWorldMatrix,
|
|
1490
1513
|
attachTargetForRow, attachTargetLabel, attachSceneObject,
|
|
1491
1514
|
} = objectsDomain;
|
|
1492
1515
|
|
|
1516
|
+
const { sculptMode, sculptCamRef, sculptView, sculptReference, setSculptReference, sculptRun, setSculptRun, sculptRunRef, sculptAgentStatus, sculptModels, sculptModelPick, chooseSculptModel, sculptTransport, sculptTap, sculptTarget, setSculptTargetId, requestSculptView, sculptBlueprint, setSculptBlueprint, generateSculptBlueprint, cancelSculptBlueprint } = useSculptMode({ workflowMode, sceneObjects, selectedSceneObjectId });
|
|
1517
|
+
async function chooseSculptReference(file) {
|
|
1518
|
+
try {
|
|
1519
|
+
// A new reference makes the old sheet a drawing of something else.
|
|
1520
|
+
cancelSculptBlueprint();
|
|
1521
|
+
setSculptBlueprint((current) => ({ ...current, state: "idle", sheet: null, error: null }));
|
|
1522
|
+
setSculptReference(await objectsDomain.storeSculptReference(file));
|
|
1523
|
+
} catch (error) {
|
|
1524
|
+
setToast(ko(`Could not read that image: ${error.message}`, `이미지를 읽지 못했어요: ${error.message}`));
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
function startSculptRun() {
|
|
1528
|
+
if (!sculptReference) return;
|
|
1529
|
+
sculptRunRef.current?.cancel();
|
|
1530
|
+
const run = createSculptRun({
|
|
1531
|
+
bus: { subscribe: (listener) => sculptTap.subscribe(listener, studioBindingRef.current?.bus) },
|
|
1532
|
+
agent: createAgentPort(sculptAgentClient(sculptTransport, () => buildStudioAgentContext())),
|
|
1533
|
+
reference: sculptReference,
|
|
1534
|
+
sculptId: sculptTarget?.id ?? null,
|
|
1535
|
+
score: (args) => appContext.bus.run("sculpt.compare", args, { origin: "ui" }),
|
|
1536
|
+
measure: async (reference) => measureReferenceOutline(await browserSculptImages.decode(reference)),
|
|
1537
|
+
model: sculptModelPick.model || undefined,
|
|
1538
|
+
effort: sculptModelPick.effort || undefined,
|
|
1539
|
+
modelLabel: sculptModelLabel(sculptModels, sculptModelPick.model) || undefined,
|
|
1540
|
+
});
|
|
1541
|
+
sculptRunRef.current = run;
|
|
1542
|
+
let shownId = sculptTarget?.id ?? null;
|
|
1543
|
+
const hadTarget = Boolean(sculptTarget);
|
|
1544
|
+
let reported = false;
|
|
1545
|
+
run.subscribe((state) => {
|
|
1546
|
+
// Before the current-run gate: a run replaced by a new Start still ends (cancelled) once.
|
|
1547
|
+
const telemetry = reported ? null : sculptRunTelemetry(state, { hadTarget });
|
|
1548
|
+
if (telemetry) { reported = true; track("sculpt_run", telemetry); }
|
|
1549
|
+
// A finished run's bus listener outlives it; only the current run speaks.
|
|
1550
|
+
if (sculptRunRef.current !== run) return;
|
|
1551
|
+
setSculptRun(state);
|
|
1552
|
+
if (state.sculptId && state.sculptId !== shownId) {
|
|
1553
|
+
shownId = state.sculptId;
|
|
1554
|
+
setSculptTargetId(state.sculptId);
|
|
1555
|
+
setSelectedHierarchyId(`object:${state.sculptId}`);
|
|
1556
|
+
}
|
|
1557
|
+
});
|
|
1558
|
+
setSculptRun(run.getState());
|
|
1559
|
+
void run.start();
|
|
1560
|
+
}
|
|
1561
|
+
/** Place in scene: on the ground in front of the scene camera, selected,
|
|
1562
|
+
* back on Stage. The move is one object.update, so one undo. */
|
|
1563
|
+
function placeSculptInScene() {
|
|
1564
|
+
if (!sculptTarget) return;
|
|
1565
|
+
try {
|
|
1566
|
+
selectWorkflowMode("scene");
|
|
1567
|
+
objectsDomain.placeSculptInScene(sculptTarget.id);
|
|
1568
|
+
} catch (error) {
|
|
1569
|
+
if (!(error instanceof StudioProtocolError)) throw error;
|
|
1570
|
+
setToast(error.uiMessage ?? error.message);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// The record whose route the studio shows for the selection: the selected
|
|
1575
|
+
// record itself, or the routed record inside the group it stands for (a car
|
|
1576
|
+
// Empty over its Chassis). Selection stays on the group; only the route's
|
|
1577
|
+
// source moves. With no route anywhere a new stroke lands on the selection.
|
|
1578
|
+
const routeOwner = useMemo(() => routeOwnerFor(sceneObjects, selectedSceneObjectId), [sceneObjects, selectedSceneObjectId]);
|
|
1579
|
+
const pathTarget = routeOwner ?? selectedSceneObject;
|
|
1580
|
+
|
|
1493
1581
|
// An undo offer is an offer, not a banner: without a window it sits on the
|
|
1494
1582
|
// screen for the rest of the session. Long enough to notice and reach, then
|
|
1495
1583
|
// gone — the deletion is still reversible through Undo history afterwards.
|
|
@@ -1556,7 +1644,7 @@ export default function App() {
|
|
|
1556
1644
|
get objectsDomain() { return objectsDomain; },
|
|
1557
1645
|
}));
|
|
1558
1646
|
const {
|
|
1559
|
-
scenes, activeSceneId, sceneSaveError, snapshotActiveScene, persistScenes, projectName, projectDirty,
|
|
1647
|
+
scenes, activeSceneId, sceneSaveError, snapshotActiveScene, persistScenes, projectName, previsMode, projectDirty,
|
|
1560
1648
|
setProjectDirty, projectSaveState, setProjectSaveState, projectMenuOpen, setProjectMenuOpen,
|
|
1561
1649
|
projectBrowserOpen, setProjectBrowserOpen, projectNameDialog, setProjectNameDialog, projectStartupOpen,
|
|
1562
1650
|
setProjectStartupOpen, projectManifest, setProjectManifest, saveBlockedReasons, setSaveBlockedReasons,
|
|
@@ -1567,6 +1655,18 @@ export default function App() {
|
|
|
1567
1655
|
renameSceneDocumentFromUi, deleteSceneDocumentFromUi, switchSceneDocument, addSceneDocument,
|
|
1568
1656
|
duplicateSceneDocument, renameSceneDocument, deleteSceneDocument,
|
|
1569
1657
|
} = scenesDomain;
|
|
1658
|
+
// A sculpt run belongs to Sculpt mode and the scene it started in: leaving
|
|
1659
|
+
// either cancels it, so no paid turn runs unseen and nothing reselects.
|
|
1660
|
+
useEffect(() => {
|
|
1661
|
+
if (!sculptMode) sculptRunRef.current?.cancel();
|
|
1662
|
+
}, [sculptMode]);
|
|
1663
|
+
useEffect(() => () => sculptRunRef.current?.cancel(), [activeSceneId]);
|
|
1664
|
+
// A storyboard project has no Motion department: a project opened while
|
|
1665
|
+
// Motion was up lands on Stage instead.
|
|
1666
|
+
const storyboardProject = scenesDomain.previsMode === "storyboard";
|
|
1667
|
+
useEffect(() => {
|
|
1668
|
+
if (storyboardProject && workflowMode === "motion") setWorkflowMode("scene");
|
|
1669
|
+
}, [storyboardProject, workflowMode]);
|
|
1570
1670
|
|
|
1571
1671
|
const saveBlockedRef = useRef(startup.saveBlocked);
|
|
1572
1672
|
const dirtyRef = useRef(false);
|
|
@@ -1628,6 +1728,8 @@ export default function App() {
|
|
|
1628
1728
|
// the gesture is "paste into the studio", not "paste into this box".
|
|
1629
1729
|
useEffect(() => {
|
|
1630
1730
|
const onPaste = (event) => {
|
|
1731
|
+
// The Agent composer takes a picture pasted anywhere on it first.
|
|
1732
|
+
if (event.defaultPrevented) return;
|
|
1631
1733
|
const target = event.target;
|
|
1632
1734
|
// Never steal a paste aimed at somewhere text goes.
|
|
1633
1735
|
if (target instanceof HTMLElement) {
|
|
@@ -1809,12 +1911,20 @@ export default function App() {
|
|
|
1809
1911
|
}
|
|
1810
1912
|
// 1-4 pick the workflow mode; pose is refused (with its reason) when
|
|
1811
1913
|
// there is no rig to solve. The embedded player has no modes to pick.
|
|
1812
|
-
const modeKey = { Digit1: "scene", Digit2: "pose", Digit3: "camera", Digit4: "motion" }[event.code];
|
|
1914
|
+
const modeKey = { Digit1: "scene", Digit2: "pose", Digit3: "camera", Digit4: "motion", Digit5: "sculpt" }[event.code];
|
|
1813
1915
|
if (modeKey && !embedMode && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
1916
|
+
// A storyboard has no key 4: it does nothing, not even a refusal.
|
|
1917
|
+
if (modeKey === "motion" && storyboardProject) return;
|
|
1814
1918
|
event.preventDefault();
|
|
1815
1919
|
if (modeKey !== workflowMode) selectWorkflowMode(modeKey);
|
|
1816
1920
|
return;
|
|
1817
1921
|
}
|
|
1922
|
+
// Sculpt mode's W/E/R swing the turntable to front, side and back.
|
|
1923
|
+
if (workflowMode === "sculpt" && GIZMO_HOTKEYS[event.code]) {
|
|
1924
|
+
event.preventDefault();
|
|
1925
|
+
requestSculptView({ KeyW: "front", KeyE: "side", KeyR: "back" }[event.code]);
|
|
1926
|
+
return;
|
|
1927
|
+
}
|
|
1818
1928
|
// Pose mode's W/E/R are the IK tools, gated like their Rig Control
|
|
1819
1929
|
// buttons: the trail tool needs visible trails, the range pin a take.
|
|
1820
1930
|
if (workflowMode === "pose" && GIZMO_HOTKEYS[event.code]) {
|
|
@@ -1902,7 +2012,7 @@ export default function App() {
|
|
|
1902
2012
|
// selection would point the gizmo at a stale dot.
|
|
1903
2013
|
useEffect(() => {
|
|
1904
2014
|
setPathPointIndex(null);
|
|
1905
|
-
}, [selectedSceneObjectId]);
|
|
2015
|
+
}, [selectedSceneObjectId, routeOwner?.id]);
|
|
1906
2016
|
|
|
1907
2017
|
useEffect(() => {
|
|
1908
2018
|
if (!inspectorActionsOpen) return undefined;
|
|
@@ -1932,6 +2042,13 @@ export default function App() {
|
|
|
1932
2042
|
// Object travel-path drawing: the same Top-View stroke gesture as the rail,
|
|
1933
2043
|
// aimed at the selected object instead of the shot camera.
|
|
1934
2044
|
const [pathDraw, setPathDraw] = useState(false);
|
|
2045
|
+
// Root-path (waypoint) authoring and Top-View path drawing are motion
|
|
2046
|
+
// tooling; a storyboard never leaves either switched on.
|
|
2047
|
+
useEffect(() => {
|
|
2048
|
+
if (!storyboardProject) return;
|
|
2049
|
+
if (waypointMode) setWaypointMode(false);
|
|
2050
|
+
if (pathDraw) setPathDraw(false);
|
|
2051
|
+
}, [storyboardProject, waypointMode, pathDraw]);
|
|
1935
2052
|
const [pathPointIndex, setPathPointIndex] = useState(null);
|
|
1936
2053
|
const pathDragTokenRef = useRef(null);
|
|
1937
2054
|
const planPathTokenRef = useRef(null);
|
|
@@ -1965,6 +2082,16 @@ export default function App() {
|
|
|
1965
2082
|
if (typeof toast.uiMessage === "string" && toast.uiMessage) for (const sink of toastSinkRef.current) sink(toast);
|
|
1966
2083
|
showToast(toast.uiMessage);
|
|
1967
2084
|
}, []);
|
|
2085
|
+
// The dots on a route are found by double-clicking the line, a gesture
|
|
2086
|
+
// nothing hints at: say it once, the first time a route is selected.
|
|
2087
|
+
const routeHintShownRef = useRef(false);
|
|
2088
|
+
// The route shown may belong to a routed record inside the selected group.
|
|
2089
|
+
const selectedRouteId = routeOwner?.path ? routeOwner.id : null;
|
|
2090
|
+
useEffect(() => {
|
|
2091
|
+
if (!selectedRouteId || routeHintShownRef.current) return;
|
|
2092
|
+
routeHintShownRef.current = true;
|
|
2093
|
+
setToast(ko("Double-click the line to add a dot", "선을 더블클릭하면 점이 추가돼요"));
|
|
2094
|
+
}, [selectedRouteId, setToast]);
|
|
1968
2095
|
// The PWA's "a newer studio is waiting" registration, once one arrives.
|
|
1969
2096
|
const [pwaUpdate, setPwaUpdate] = useState(null);
|
|
1970
2097
|
useEffect(() => {
|
|
@@ -1983,58 +2110,7 @@ export default function App() {
|
|
|
1983
2110
|
const takeRecipeRef = useRef(null);
|
|
1984
2111
|
|
|
1985
2112
|
const [bottomTab, setBottomTab] = useState("timeline");
|
|
1986
|
-
|
|
1987
|
-
// never resolved (the pane shows skeletons, NEVER the empty message); an
|
|
1988
|
-
// array = the SOURCE ids to show, mattes and cut renders already filtered
|
|
1989
|
-
// out (asset-shelf.js). Scans run only while the shelf is visible, and
|
|
1990
|
-
// re-run when a cutout's lineage changes — not on every transform tick, so
|
|
1991
|
-
// a gizmo drag never hammers IndexedDB.
|
|
1992
|
-
const [shelfImageIds, setShelfImageIds] = useState(null);
|
|
1993
|
-
const [shelfMeshIds, setShelfMeshIds] = useState(null);
|
|
1994
|
-
const [manageAssetStorage, setManageAssetStorage] = useState(false);
|
|
1995
|
-
// A separate scan preserves the source-only placement shelf while the
|
|
1996
|
-
// manager exposes every unreachable stored record, including matte and cut
|
|
1997
|
-
// derivatives orphaned with a deleted card.
|
|
1998
|
-
const [unusedAssetIds, setUnusedAssetIds] = useState(null);
|
|
1999
|
-
const [usedAssetIds, setUsedAssetIds] = useState(null);
|
|
2000
|
-
const [usageCounts, setUsageCounts] = useState(new Map());
|
|
2001
|
-
const assetShelfScanTokenRef = useRef(0);
|
|
2002
|
-
const legacyDerivedIdsRef = useRef(new Set());
|
|
2003
|
-
// This is deliberately session-only. Each entry is a complete IndexedDB
|
|
2004
|
-
// record, so Undo can put it back byte-for-byte until the page is reloaded.
|
|
2005
|
-
const [assetTrash, setAssetTrash] = useState([]);
|
|
2006
|
-
// Same rule as the object deletion offer: the toast is a window, not a
|
|
2007
|
-
// banner. Only the OFFER expires — the trashed record itself is the restore
|
|
2008
|
-
// data, so it is deliberately kept for the session and never timed out.
|
|
2009
|
-
const [assetUndoOffered, setAssetUndoOffered] = useState(false);
|
|
2010
|
-
useEffect(() => {
|
|
2011
|
-
if (!assetUndoOffered) return undefined;
|
|
2012
|
-
const timer = setTimeout(() => setAssetUndoOffered(false), ASSET_DELETE_UNDO_MS);
|
|
2013
|
-
return () => clearTimeout(timer);
|
|
2014
|
-
}, [assetUndoOffered]);
|
|
2015
|
-
const [deletingAssetId, setDeletingAssetId] = useState(null);
|
|
2016
|
-
const cutoutLineage = useMemo(
|
|
2017
|
-
() => JSON.stringify(sceneObjects.flatMap((object) => (object.renderer === CUTOUT_KIND ? [[object.assetId, object.sourceAssetId, object.matteAssetId]] : []))),
|
|
2018
|
-
[sceneObjects],
|
|
2019
|
-
);
|
|
2020
|
-
const meshLineage = useMemo(
|
|
2021
|
-
() => JSON.stringify(sceneObjects.flatMap((object) => (object.renderer === MESH_KIND ? [object.assetId] : []))),
|
|
2022
|
-
[sceneObjects],
|
|
2023
|
-
);
|
|
2024
|
-
const projectCutoutLineage = useMemo(() => {
|
|
2025
|
-
const allScenes = scenes.map((scene) => (scene.id === activeSceneId ? { ...scene, objects: sceneObjects } : scene));
|
|
2026
|
-
return JSON.stringify(
|
|
2027
|
-
allScenes.flatMap((scene) =>
|
|
2028
|
-
(Array.isArray(scene.objects) ? scene.objects : []).flatMap((object) =>
|
|
2029
|
-
object.renderer === CUTOUT_KIND ? [[object.assetId, object.sourceAssetId, object.matteAssetId]] : [],
|
|
2030
|
-
),
|
|
2031
|
-
),
|
|
2032
|
-
);
|
|
2033
|
-
}, [scenes, activeSceneId, sceneObjects]);
|
|
2034
|
-
const projectAssetGraphSignature = useMemo(() => {
|
|
2035
|
-
const allScenes = scenes.map((scene) => (scene.id === activeSceneId ? { ...scene, objects: sceneObjects } : scene));
|
|
2036
|
-
return assetGraphSignature(allScenes);
|
|
2037
|
-
}, [scenes, activeSceneId, sceneObjects]);
|
|
2113
|
+
const { shelfImageIds, setShelfImageIds, shelfMeshIds, setShelfMeshIds, manageAssetStorage, setManageAssetStorage, unusedAssetIds, setUnusedAssetIds, usedAssetIds, setUsedAssetIds, usageCounts, setUsageCounts, assetShelfScanTokenRef, legacyDerivedIdsRef, assetTrash, setAssetTrash, assetUndoOffered, setAssetUndoOffered, deletingAssetId, setDeletingAssetId, cutoutLineage, meshLineage, projectCutoutLineage, projectAssetGraphSignature } = useAssetShelf({ scenes, activeSceneId, sceneObjects });
|
|
2038
2114
|
useEffect(() => {
|
|
2039
2115
|
const { scenes: latestScenes, activeSceneId: latestActiveSceneId, sceneObjects: latestObjects } = projectStateRef.current;
|
|
2040
2116
|
const allScenes = latestScenes.map((scene) => (scene.id === latestActiveSceneId ? { ...scene, objects: latestObjects } : scene));
|
|
@@ -2207,15 +2283,21 @@ export default function App() {
|
|
|
2207
2283
|
// the export overwrites it per captured frame and restores nothing, which
|
|
2208
2284
|
// is correct — the next render puts it back.
|
|
2209
2285
|
propFrameRef.current = tlFrame;
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2286
|
+
// Routed records and everything grouped under them (object-travel.js).
|
|
2287
|
+
const animatedSceneObjects = useMemo(
|
|
2288
|
+
() => sceneObjectsAt(sceneObjects, tlFrame, { frameCount: tlFrameCount, fps: tlFps }),
|
|
2289
|
+
[sceneObjects, tlFrame, tlFrameCount, tlFps],
|
|
2290
|
+
);
|
|
2291
|
+
|
|
2292
|
+
// The selection as it is DRAWN at this frame, for the transform gizmo to sit
|
|
2293
|
+
// on: a routed record, the group under one, and the Empty that rides its
|
|
2294
|
+
// routed descendant all stand away from their authored numbers while the
|
|
2295
|
+
// playhead is off frame 0. Drags still read the authored records (the gizmo
|
|
2296
|
+
// is handed those separately), so they stay delta-based on authored numbers.
|
|
2297
|
+
const gizmoSceneObject = useMemo(
|
|
2298
|
+
() => (selectedSceneObject ? animatedSceneObjects.find((object) => object.id === selectedSceneObject.id) ?? selectedSceneObject : null),
|
|
2299
|
+
[selectedSceneObject, animatedSceneObjects],
|
|
2300
|
+
);
|
|
2219
2301
|
|
|
2220
2302
|
// Auto color: Blender's viewport "Random" mode. A DISPLAY-ONLY marker rides
|
|
2221
2303
|
// each non-cutout object into the renderers; the authored `color`, the scene
|
|
@@ -2247,6 +2329,14 @@ export default function App() {
|
|
|
2247
2329
|
// r3f frame loop — so it asks the set for one placement pass itself, right
|
|
2248
2330
|
// after it has written that frame's bones.
|
|
2249
2331
|
const propSyncRef = useRef(null);
|
|
2332
|
+
// Characters grouped under a scene object ride it inside an ObjectCarrier.
|
|
2333
|
+
// The carriers read the AUTHORED records by id, and register a placement
|
|
2334
|
+
// pass the recorder runs before the props' (a prop carried on a riding
|
|
2335
|
+
// character's hand needs the carrier placed first).
|
|
2336
|
+
const carrierRegistryRef = useRef(new Set());
|
|
2337
|
+
const sceneObjectLookupRef = useRef(null);
|
|
2338
|
+
sceneObjectLookupRef.current = useMemo(() => new Map(sceneObjects.map((object) => [object.id, object])), [sceneObjects]);
|
|
2339
|
+
const syncCarriers = () => { for (const place of carrierRegistryRef.current) place(); };
|
|
2250
2340
|
// Where a prop actually IS, read off its live group: the one authority on
|
|
2251
2341
|
// the transform currently on screen, and so the only honest starting point
|
|
2252
2342
|
// for a no-jump conversion.
|
|
@@ -2255,9 +2345,30 @@ export default function App() {
|
|
|
2255
2345
|
/**
|
|
2256
2346
|
* Hierarchy row drag policy (the panel holds none). An object row dropped on
|
|
2257
2347
|
* another object GROUPS; on a character or one of its bone rows it ATTACHES;
|
|
2258
|
-
* on Props it comes back to the world.
|
|
2348
|
+
* on Props it comes back to the world. A character row dropped on an object
|
|
2349
|
+
* row is GROUPED under it and rides its travel; dropped on the scene root it
|
|
2350
|
+
* stands in the world again. Anything else is not a drop.
|
|
2259
2351
|
*/
|
|
2260
|
-
|
|
2352
|
+
function characterGroupMove(sourceRowId, targetRowId) {
|
|
2353
|
+
const characterId = charIdFromHierarchyId(sourceRowId);
|
|
2354
|
+
const entry = characterId ? characters.find((item) => item.id === characterId) : null;
|
|
2355
|
+
if (!entry) return null;
|
|
2356
|
+
if (targetRowId === "shot") return (entry.parent ?? null) !== null ? { characterId, parent: null } : null;
|
|
2357
|
+
const objectId = sceneObjectIdFromHierarchy(String(targetRowId ?? ""));
|
|
2358
|
+
const object = objectId ? sceneObjects.find((item) => item.id === objectId) : null;
|
|
2359
|
+
if (!object || object.attach || entry.parent === objectId) return null;
|
|
2360
|
+
return { characterId, parent: objectId };
|
|
2361
|
+
}
|
|
2362
|
+
const hierarchyReparent = {
|
|
2363
|
+
canDrop: (source, target) => (charIdFromHierarchyId(source)
|
|
2364
|
+
? characterGroupMove(source, target) !== null
|
|
2365
|
+
: objectsDomain.canReparentSceneObject(source, target)),
|
|
2366
|
+
onDrop: (source, target) => {
|
|
2367
|
+
if (!charIdFromHierarchyId(source)) return objectsDomain.reparentSceneObject(source, target);
|
|
2368
|
+
const move = characterGroupMove(source, target);
|
|
2369
|
+
if (move) runStudioAction("character.update", { characterId: move.characterId, patch: { parent: move.parent } });
|
|
2370
|
+
},
|
|
2371
|
+
};
|
|
2261
2372
|
|
|
2262
2373
|
useEffect(() => {
|
|
2263
2374
|
setCraneSelectedIndex(null);
|
|
@@ -2443,10 +2554,20 @@ export default function App() {
|
|
|
2443
2554
|
appContext.publishScenes(scenes);
|
|
2444
2555
|
activeSceneIdRef.current = activeSceneId;
|
|
2445
2556
|
shotDocumentRef.current = createShotAuthoringDocument({ shots, waypoints, frameCount: tlFrameCount });
|
|
2557
|
+
// The motion layers: their IK edits (Pose mode, IK keys, range pins) ride
|
|
2558
|
+
// into the saved stage below with each character, and a change to them is
|
|
2559
|
+
// a reason to autosave.
|
|
2560
|
+
const motionLayers = appContext.storeDomain("motion")?.read() ?? null;
|
|
2561
|
+
const ikEditsOf = (id) => {
|
|
2562
|
+
const row = motionLayers?.find((layer) => layer.id === id);
|
|
2563
|
+
if (!row || (!row.ikKeys?.length && !row.ikPins?.length)) return null;
|
|
2564
|
+
return { keys: row.ikKeys, pins: row.ikPins ?? [], pinResiduals: row.ikPinResiduals ?? [] };
|
|
2565
|
+
};
|
|
2566
|
+
// sessionMotion is stripped: generated clips are session-only and far too
|
|
2567
|
+
// heavy for the stage envelope; paths, prompt blocks and IK edits persist.
|
|
2568
|
+
const stageCharacters = characters.map(({ sessionMotion, ...entry }) => ({ ...entry, ikEdits: ikEditsOf(entry.id) }));
|
|
2446
2569
|
actorStageRef.current = {
|
|
2447
|
-
|
|
2448
|
-
// too heavy for the stage envelope; paths and prompt blocks persist.
|
|
2449
|
-
characters: characters.map(({ sessionMotion, ...entry }) => entry),
|
|
2570
|
+
characters: stageCharacters,
|
|
2450
2571
|
hasCharSheet,
|
|
2451
2572
|
environmentImage,
|
|
2452
2573
|
shotAspect: shotAspectKey,
|
|
@@ -2460,8 +2581,7 @@ export default function App() {
|
|
|
2460
2581
|
hasEnvSheet,
|
|
2461
2582
|
};
|
|
2462
2583
|
|
|
2463
|
-
const
|
|
2464
|
-
const [useCaseAskOpen, setUseCaseAskOpen] = useState(false);
|
|
2584
|
+
const { firstSuccessGuideOpen, setFirstSuccessGuideOpen, useCaseAskOpen, setUseCaseAskOpen } = useFirstSuccessPrompt();
|
|
2465
2585
|
|
|
2466
2586
|
// Dismissal mirrors the inspector-actions menu: only listen while open,
|
|
2467
2587
|
// ignore presses inside the wrap (the trigger's own click keeps toggling),
|
|
@@ -2794,6 +2914,8 @@ export default function App() {
|
|
|
2794
2914
|
activeShotId: activeShot?.id ?? null,
|
|
2795
2915
|
captureCurrentFraming,
|
|
2796
2916
|
captureFramingPng,
|
|
2917
|
+
// The Top View pull (verify_result visual "plan", capture_plan_png).
|
|
2918
|
+
capturePlanPng,
|
|
2797
2919
|
captureShotMeta,
|
|
2798
2920
|
// The identity / environment reference pictures a capture carries (#167).
|
|
2799
2921
|
captureShotReferences,
|
|
@@ -2829,6 +2951,7 @@ export default function App() {
|
|
|
2829
2951
|
aspectRatio: live.filmback.aspectRatio,
|
|
2830
2952
|
},
|
|
2831
2953
|
stage: live.stage,
|
|
2954
|
+
previsMode: scenesDomain.metadata().previsMode,
|
|
2832
2955
|
timeline: live.timeline,
|
|
2833
2956
|
activeCharacterId: live.activeCharacterId,
|
|
2834
2957
|
characters: castDomain.read(),
|
|
@@ -2864,10 +2987,7 @@ export default function App() {
|
|
|
2864
2987
|
// not capture_frame's 640x360 preview, which stays exactly as it is.
|
|
2865
2988
|
capture_framing_png: (args = {}) => {
|
|
2866
2989
|
const live = appContext.live.state;
|
|
2867
|
-
const
|
|
2868
|
-
const output = Number.isFinite(requested?.width) && Number.isFinite(requested?.height)
|
|
2869
|
-
? { width: Math.round(requested.width), height: Math.round(requested.height) }
|
|
2870
|
-
: SHOT_ASPECT_PRESETS[live.stage.shotAspect] ?? SHOT_ASPECT_PRESETS["16:9"];
|
|
2990
|
+
const output = requestedCaptureOutput(args) ?? SHOT_ASPECT_PRESETS[live.stage.shotAspect] ?? SHOT_ASPECT_PRESETS["16:9"];
|
|
2871
2991
|
const dataUrl = live.captureFramingPng(live.captureCurrentFraming(), output);
|
|
2872
2992
|
if (!dataUrl) throw new Error("The shot renderer is not ready");
|
|
2873
2993
|
return {
|
|
@@ -2884,6 +3004,9 @@ export default function App() {
|
|
|
2884
3004
|
references: live.captureShotReferences(),
|
|
2885
3005
|
};
|
|
2886
3006
|
},
|
|
3007
|
+
// The Top View as the agent's plan picture: a 2D diagram of the cast,
|
|
3008
|
+
// footprints and every shot camera drawn from state (src/plan-diagram.js).
|
|
3009
|
+
capture_plan_png: (args = {}) => appContext.live.state.capturePlanPng(requestedCaptureOutput(args) ?? undefined),
|
|
2887
3010
|
};
|
|
2888
3011
|
|
|
2889
3012
|
useEffect(() => {
|
|
@@ -2894,7 +3017,8 @@ export default function App() {
|
|
|
2894
3017
|
const timer = setTimeout(() => {
|
|
2895
3018
|
if (!liveControlRef.current) {
|
|
2896
3019
|
liveControlRef.current = createLiveControl({
|
|
2897
|
-
|
|
3020
|
+
// Tapped so a sculpt run hears the agent's object.sculpt / sculpt.compare.
|
|
3021
|
+
handlers: sculptTap.wrap({ ...liveQueries, ...studioBindingRef.current.handlers }),
|
|
2898
3022
|
workspaceId: liveWorkspaceIdRef.current,
|
|
2899
3023
|
meta: {
|
|
2900
3024
|
project: projectName ?? "Untitled",
|
|
@@ -2955,7 +3079,7 @@ export default function App() {
|
|
|
2955
3079
|
dirtyRef.current = true;
|
|
2956
3080
|
const timer = setTimeout(flushScenes, 400);
|
|
2957
3081
|
return () => clearTimeout(timer);
|
|
2958
|
-
}, [sceneObjects, shots, waypoints, tlFrameCount, charA, charB, showB, poseA, poseB, hasCharSheet, environmentImage, environment, style, hasEnvSheet, subject, subject2, shotAspectKey, sensorId, keyLight, scenes, activeSceneId]);
|
|
3082
|
+
}, [sceneObjects, shots, waypoints, tlFrameCount, charA, charB, showB, poseA, poseB, hasCharSheet, environmentImage, environment, style, hasEnvSheet, subject, subject2, shotAspectKey, sensorId, keyLight, scenes, activeSceneId, motionLayers]);
|
|
2959
3083
|
useEffect(() => {
|
|
2960
3084
|
const onPageHide = () => flushScenes();
|
|
2961
3085
|
const onVisibility = () => {
|
|
@@ -3018,25 +3142,33 @@ export default function App() {
|
|
|
3018
3142
|
// props did not change must not re-render its subtree.
|
|
3019
3143
|
const characterViews = useMemo(() => characters.flatMap((entry, index) => {
|
|
3020
3144
|
if (entry.hidden) return [];
|
|
3145
|
+
const pickId = index === 0 ? "A" : index === 1 ? "B" : entry.id;
|
|
3021
3146
|
// Each cast member is driven by ITS OWN clip: the active one reads
|
|
3022
3147
|
// the editing buffer, the others their stored session motion.
|
|
3023
3148
|
const clip = entry.id === activeChar.id ? motion : entry.sessionMotion;
|
|
3149
|
+
const placement = placementAt(entry, tlFrame, {
|
|
3150
|
+
shotAt: frame => shotAtFrame(shots, frame),
|
|
3151
|
+
takeRoot: clip ? sampleAt({ frameCount: clip.frames, motion: clip }, null, tlFrame).subject : null,
|
|
3152
|
+
});
|
|
3024
3153
|
return [{
|
|
3025
3154
|
id: entry.id,
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3155
|
+
model: entry.model,
|
|
3156
|
+
...(isProxyFigure(entry) ? {} : { url: characterModelUrl(entry.model) }),
|
|
3157
|
+
position: [placement.x, entry.y ?? 0, placement.z],
|
|
3158
|
+
rot: placement.rot,
|
|
3029
3159
|
tint: entry.tint ?? defaultCharacterTint(entry, index),
|
|
3030
3160
|
partColoursEnabled,
|
|
3031
3161
|
partColoursMode,
|
|
3032
|
-
pose: clip ? null : (
|
|
3162
|
+
pose: clip ? null : (placement.pose ?? DEFAULT_POSE),
|
|
3163
|
+
posture: placement.posture,
|
|
3033
3164
|
// The stature the entry's take was extracted at. It rides with the
|
|
3034
3165
|
// clip, never separately — see Character for why.
|
|
3035
3166
|
scale: entry.scale ?? 1,
|
|
3036
|
-
onRig: reportRig(entry.id),
|
|
3037
|
-
pickId
|
|
3167
|
+
...(isProxyFigure(entry) ? {} : { onRig: reportRig(entry.id) }),
|
|
3168
|
+
pickId,
|
|
3169
|
+
parent: entry.parent ?? null,
|
|
3038
3170
|
}];
|
|
3039
|
-
}), [characters, activeChar.id, motion, partColoursEnabled, partColoursMode]);
|
|
3171
|
+
}), [characters, activeChar.id, motion, shots, tlFrame, partColoursEnabled, partColoursMode]);
|
|
3040
3172
|
// Where the selection gizmo stands: same driving rules as the render,
|
|
3041
3173
|
// for the active (selected) cast member only. Gated on the HIERARCHY
|
|
3042
3174
|
// selection, not the sticky active layer — the layer stays on the last
|
|
@@ -3047,9 +3179,10 @@ export default function App() {
|
|
|
3047
3179
|
if (!charIdFromHierarchyId(selectedHierarchyId)) return null;
|
|
3048
3180
|
const entry = characters.find((item) => item.id === activeChar.id);
|
|
3049
3181
|
if (!entry || entry.hidden) return null;
|
|
3050
|
-
const
|
|
3051
|
-
|
|
3052
|
-
|
|
3182
|
+
const hasTake = activeChar.id === activeCharacterId ? Boolean(motion) : Boolean(activeChar.sessionMotion);
|
|
3183
|
+
const placement = hasTake ? entry : placementAt(entry, tlFrame, { shotAt: frame => shotAtFrame(shots, frame) });
|
|
3184
|
+
return { position: [placement.x, entry.y ?? 0, placement.z], rot: placement.rot };
|
|
3185
|
+
}, [characters, activeChar.id, motion, shots, tlFrame, selectedHierarchyId]);
|
|
3053
3186
|
// The cast rides the SAME gizmo as scene objects — one movement grammar
|
|
3054
3187
|
// for everything on stage. The proxy hands ObjectGizmo the object shape
|
|
3055
3188
|
// it expects; `height` puts the pivot at the hips like a prop's centre.
|
|
@@ -3063,7 +3196,7 @@ export default function App() {
|
|
|
3063
3196
|
z: gizmoView.position[2],
|
|
3064
3197
|
height: 1.15,
|
|
3065
3198
|
footprint: { width: 0.6, depth: 0.6 },
|
|
3066
|
-
rotY: entry?.rot ?? 0,
|
|
3199
|
+
rotY: gizmoView.rot ?? entry?.rot ?? 0,
|
|
3067
3200
|
scaleX: entry?.scale ?? 1,
|
|
3068
3201
|
scaleY: entry?.scale ?? 1,
|
|
3069
3202
|
scaleZ: entry?.scale ?? 1,
|
|
@@ -3226,7 +3359,7 @@ export default function App() {
|
|
|
3226
3359
|
ikStates: new Map([...ikStatesRef.current].map(([id, state]) => [id, copyIk(state)])),
|
|
3227
3360
|
rigStates: Object.values(rigs).filter(Boolean).map(snapshotExportRig),
|
|
3228
3361
|
};
|
|
3229
|
-
return Object.freeze({ kind, format: kind === "frame" ? "png" : kind === "keyframe_pack" ? "zip" : "mp4",
|
|
3362
|
+
return Object.freeze({ kind, format: kind === "frame" || kind === "contact_sheet" ? "png" : kind === "keyframe_pack" || kind === "panel_pack" ? "zip" : "mp4",
|
|
3230
3363
|
context, run, external, download, handedOff: new Set() });
|
|
3231
3364
|
}
|
|
3232
3365
|
|
|
@@ -3326,16 +3459,23 @@ export default function App() {
|
|
|
3326
3459
|
propFrameRef.current = frame;
|
|
3327
3460
|
const context = recRef.current?.request.context;
|
|
3328
3461
|
for (const entry of context?.characters ?? characters) {
|
|
3462
|
+
if (isProxyFigure(entry)) continue;
|
|
3329
3463
|
const activeId = context?.activeId ?? activeChar.id;
|
|
3330
3464
|
const clip = entry.id === activeId ? (context ? context.motion : motion) : entry.sessionMotion;
|
|
3331
3465
|
const state = context
|
|
3332
3466
|
? (entry.id === activeId ? context.ikState : context.ikStates.get(entry.id))
|
|
3333
3467
|
: (entry.id === activeChar.id ? ikStateRef.current : ikStatesRef.current.get(entry.id));
|
|
3334
|
-
|
|
3468
|
+
const placement = placementAt(entry, frame, {
|
|
3469
|
+
shotAt: at => shotAtFrame(context?.shots ?? shots, at),
|
|
3470
|
+
takeRoot: clip ? sampleAt({ frameCount: clip.frames, motion: clip }, null, frame).subject : null,
|
|
3471
|
+
});
|
|
3472
|
+
poseMemberAtFrame(rigs[entry.id], clip, state, frame, IK_CORRECTION_BLEND_FRAMES, placement.pose ?? null);
|
|
3335
3473
|
}
|
|
3336
3474
|
// The bones for this frame are now written, so a carried prop can take
|
|
3337
3475
|
// its place on them. gl.render() never runs the r3f frame loop, so this
|
|
3338
3476
|
// pass is the recorder's stand-in for the useFrame the preview gets.
|
|
3477
|
+
// Riding characters move with their object first, then the props.
|
|
3478
|
+
syncCarriers();
|
|
3339
3479
|
propSyncRef.current?.();
|
|
3340
3480
|
const sampled = sampleAt(context?.playbackScene ?? playbackScene, shotAtFrame(context?.shots ?? shots, frame), frame);
|
|
3341
3481
|
const framing = sampled.camera ?? context?.framing;
|
|
@@ -3375,18 +3515,43 @@ export default function App() {
|
|
|
3375
3515
|
const cameraSnapshot = { position: cam.position.clone(), quaternion: cam.quaternion.clone(),
|
|
3376
3516
|
rotationOrder: cam.rotation.order, fov: cam.fov, yaw: look.current.yaw, pitch: look.current.pitch };
|
|
3377
3517
|
const rigSnapshots = Object.values(rigs).filter(Boolean).map(snapshotExportRig);
|
|
3518
|
+
const proxySnapshots = [];
|
|
3519
|
+
const context = recRef.current?.request.context;
|
|
3520
|
+
const cast = context?.characters ?? characters;
|
|
3521
|
+
const scene = (recRef.current?.capture ?? captureRef.current)?.scene;
|
|
3522
|
+
const pickIdByCharacter = new Map(characterViews.map(view => [view.id, view.pickId]));
|
|
3523
|
+
if (typeof scene?.traverse === "function") scene.traverse(node => {
|
|
3524
|
+
const index = cast.findIndex(entry => isProxyFigure(entry) && node.userData.characterPick === pickIdByCharacter.get(entry.id));
|
|
3525
|
+
if (index < 0) return;
|
|
3526
|
+
proxySnapshots.push({ node, entry: cast[index], position: node.position.clone(), quaternion: node.quaternion.clone() });
|
|
3527
|
+
});
|
|
3378
3528
|
const propFrame = propFrameRef.current;
|
|
3379
3529
|
try {
|
|
3380
3530
|
// Character stature lives on the rig and placement/yaw on its parent,
|
|
3381
3531
|
// not in playback bones. Retry temporarily restores both, plus the
|
|
3382
3532
|
// original held pose, then puts the CURRENT editor transforms back.
|
|
3383
3533
|
for (const snapshot of recRef.current?.request.context?.rigStates ?? []) restoreExportRig(snapshot);
|
|
3534
|
+
for (const { node, entry } of proxySnapshots) {
|
|
3535
|
+
const clip = entry.id === (context?.activeId ?? activeChar.id) ? (context ? context.motion : motion) : entry.sessionMotion;
|
|
3536
|
+
const placement = placementAt(entry, frame, {
|
|
3537
|
+
shotAt: at => shotAtFrame(context?.shots ?? shots, at),
|
|
3538
|
+
takeRoot: clip ? sampleAt({ frameCount: clip.frames, motion: clip }, null, frame).subject : null,
|
|
3539
|
+
});
|
|
3540
|
+
node.position.set(placement.x, entry.y ?? 0, placement.z);
|
|
3541
|
+
node.rotation.y = proxyYaw(placement.rot, placement.posture);
|
|
3542
|
+
node.updateMatrixWorld(true);
|
|
3543
|
+
}
|
|
3384
3544
|
const plate = applyExportFrame(frame);
|
|
3385
3545
|
return render ? render() : plate;
|
|
3386
3546
|
} catch (error) {
|
|
3387
3547
|
throw Object.assign(new Error(error?.message || String(error), { cause: error }), { exportFailureCode: exportFailureCode(error, "render_failed") });
|
|
3388
3548
|
} finally {
|
|
3389
3549
|
for (const snapshot of rigSnapshots) restoreExportRig(snapshot);
|
|
3550
|
+
for (const { node, position, quaternion } of proxySnapshots) {
|
|
3551
|
+
node.position.copy(position);
|
|
3552
|
+
node.quaternion.copy(quaternion);
|
|
3553
|
+
node.updateMatrixWorld(true);
|
|
3554
|
+
}
|
|
3390
3555
|
cam.position.copy(cameraSnapshot.position);
|
|
3391
3556
|
cam.rotation.order = cameraSnapshot.rotationOrder;
|
|
3392
3557
|
cam.quaternion.copy(cameraSnapshot.quaternion);
|
|
@@ -3395,6 +3560,7 @@ export default function App() {
|
|
|
3395
3560
|
look.current.yaw = cameraSnapshot.yaw;
|
|
3396
3561
|
look.current.pitch = cameraSnapshot.pitch;
|
|
3397
3562
|
propFrameRef.current = propFrame;
|
|
3563
|
+
syncCarriers();
|
|
3398
3564
|
propSyncRef.current?.();
|
|
3399
3565
|
}
|
|
3400
3566
|
}
|
|
@@ -3753,6 +3919,13 @@ export default function App() {
|
|
|
3753
3919
|
return output;
|
|
3754
3920
|
}
|
|
3755
3921
|
|
|
3922
|
+
// One storyboard panel as a picture: the shot's first frame through the
|
|
3923
|
+
// offscreen capture rig, as a PNG data URL (null while the renderer has no
|
|
3924
|
+
// frame). The contact sheet and the Board dock tab share it.
|
|
3925
|
+
function capturePanelThumbnail(entry) {
|
|
3926
|
+
return captureShotFramePng(entry.startFrame);
|
|
3927
|
+
}
|
|
3928
|
+
|
|
3756
3929
|
/** Contact sheet of the whole cut: one thumbnail and prompt per shot. */
|
|
3757
3930
|
async function exportStoryboard() {
|
|
3758
3931
|
try {
|
|
@@ -3760,7 +3933,7 @@ export default function App() {
|
|
|
3760
3933
|
setToast(ko("Composing the storyboard…", "스토리보드 구성 중…"));
|
|
3761
3934
|
const cells = [];
|
|
3762
3935
|
for (const [index, entry] of shots.entries()) {
|
|
3763
|
-
const dataUrl =
|
|
3936
|
+
const dataUrl = capturePanelThumbnail(entry);
|
|
3764
3937
|
const meta = packMetaForShot(entry, index);
|
|
3765
3938
|
cells.push({
|
|
3766
3939
|
title: storyboardLine(`${index + 1}. ${entry.name}`),
|
|
@@ -3780,18 +3953,7 @@ export default function App() {
|
|
|
3780
3953
|
// the fit, so the frame is scaled to height) and a caption block wide
|
|
3781
3954
|
// enough for the 90 characters the composer draws at this size.
|
|
3782
3955
|
cell: { width: 480, height: 300 },
|
|
3783
|
-
createCanvas:
|
|
3784
|
-
const element = document.createElement("canvas");
|
|
3785
|
-
element.width = width;
|
|
3786
|
-
element.height = height;
|
|
3787
|
-
const ctx = element.getContext("2d");
|
|
3788
|
-
// The sheet is a deliverable, so it uses the studio's own type
|
|
3789
|
-
// stack rather than the canvas default (10px sans-serif).
|
|
3790
|
-
ctx.font = STORYBOARD_FONT;
|
|
3791
|
-
ctx.textAlign = "left";
|
|
3792
|
-
ctx.textBaseline = "top";
|
|
3793
|
-
return element;
|
|
3794
|
-
},
|
|
3956
|
+
createCanvas: storyboardCanvas,
|
|
3795
3957
|
});
|
|
3796
3958
|
saveDownload(canvas.toDataURL("image/png"), "cozyclay-storyboard.png");
|
|
3797
3959
|
setToast(isKo ? `스토리보드 저장됨 · ${cells.length}샷` : `Storyboard saved · ${cells.length} shots`);
|
|
@@ -3801,6 +3963,162 @@ export default function App() {
|
|
|
3801
3963
|
}
|
|
3802
3964
|
}
|
|
3803
3965
|
|
|
3966
|
+
function storyboardCanvas(width, height) {
|
|
3967
|
+
const element = document.createElement("canvas");
|
|
3968
|
+
element.width = width;
|
|
3969
|
+
element.height = height;
|
|
3970
|
+
const ctx = element.getContext("2d");
|
|
3971
|
+
// The sheet is a deliverable, so it uses the studio's own type
|
|
3972
|
+
// stack rather than the canvas default (10px sans-serif).
|
|
3973
|
+
ctx.font = STORYBOARD_FONT;
|
|
3974
|
+
ctx.textAlign = "left";
|
|
3975
|
+
ctx.textBaseline = "top";
|
|
3976
|
+
return element;
|
|
3977
|
+
}
|
|
3978
|
+
|
|
3979
|
+
/* ===================== storyboard exports (#644) =======================
|
|
3980
|
+
* A storyboard project's panels are its stills, in still order. Each
|
|
3981
|
+
* export runs as one export job (status panel, cancel, retry, analytics
|
|
3982
|
+
* export_kind contact_sheet | panel_pack | animatic) and draws its frames
|
|
3983
|
+
* through withExportFrame, so per-panel cast overrides and proxy paths
|
|
3984
|
+
* land exactly as the preview shows them.
|
|
3985
|
+
*/
|
|
3986
|
+
|
|
3987
|
+
function storyboardPanels(list = shots) {
|
|
3988
|
+
return list.filter((entry) => entry.kind === "still").sort((a, b) => a.startFrame - b.startFrame);
|
|
3989
|
+
}
|
|
3990
|
+
|
|
3991
|
+
const noPanelsMessage = () => ko("Add a panel first — storyboard exports describe panels", "패널을 먼저 추가하세요 — 스토리보드 내보내기는 패널을 담습니다");
|
|
3992
|
+
|
|
3993
|
+
// The panel's stylized picture as PNG bytes, or null when it has none (or
|
|
3994
|
+
// its asset is gone from this browser).
|
|
3995
|
+
async function panelStylizedPng(entry) {
|
|
3996
|
+
if (!entry.stylizedAssetId) return null;
|
|
3997
|
+
const record = await assetRecord(entry.stylizedAssetId);
|
|
3998
|
+
if (!record) {
|
|
3999
|
+
console.warn(`[cozyclay] stylized panel ${entry.stylizedAssetId} is missing; exporting the greybox only`);
|
|
4000
|
+
return null;
|
|
4001
|
+
}
|
|
4002
|
+
const bytes = new Uint8Array(record.bytes);
|
|
4003
|
+
if (record.type === "image/png") return bytes;
|
|
4004
|
+
const bitmap = await createImageBitmap(new Blob([bytes], { type: record.type }));
|
|
4005
|
+
try {
|
|
4006
|
+
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
|
4007
|
+
canvas.getContext("2d").drawImage(bitmap, 0, 0);
|
|
4008
|
+
return new Uint8Array(await (await canvas.convertToBlob({ type: "image/png" })).arrayBuffer());
|
|
4009
|
+
} finally {
|
|
4010
|
+
bitmap.close();
|
|
4011
|
+
}
|
|
4012
|
+
}
|
|
4013
|
+
|
|
4014
|
+
async function loadPngBytes(bytes) {
|
|
4015
|
+
const url = URL.createObjectURL(new Blob([bytes], { type: "image/png" }));
|
|
4016
|
+
try { return await loadImage(url); }
|
|
4017
|
+
finally { URL.revokeObjectURL(url); }
|
|
4018
|
+
}
|
|
4019
|
+
|
|
4020
|
+
const panelFolder = (order) => `panel-${String(order + 1).padStart(2, "0")}`;
|
|
4021
|
+
|
|
4022
|
+
/** Contact sheet of the storyboard: the stylized picture with the greybox
|
|
4023
|
+
* inset when a panel has one, and the panel caption under it. */
|
|
4024
|
+
async function exportContactSheet() {
|
|
4025
|
+
if (recRef.current) return null;
|
|
4026
|
+
if (!storyboardPanels().length) { setToast(noPanelsMessage()); return null; }
|
|
4027
|
+
return executeExportRequest(exportRequest("contact_sheet", async (job) => {
|
|
4028
|
+
const all = job.request.context.shots;
|
|
4029
|
+
const panels = storyboardPanels(all);
|
|
4030
|
+
updateExportStatus(job, "preparing", { stage: "frames", cancellable: true });
|
|
4031
|
+
const cells = [];
|
|
4032
|
+
for (const [order, entry] of panels.entries()) {
|
|
4033
|
+
const dataUrl = captureShotFramePng(entry.startFrame);
|
|
4034
|
+
if (!dataUrl) throw Object.assign(new Error(ko("The shot renderer is not ready", "샷 렌더러가 아직 준비되지 않았어요")), { exportFailureCode: "render_failed" });
|
|
4035
|
+
const stylized = await panelStylizedPng(entry);
|
|
4036
|
+
const meta = packMetaForShot(entry, all.indexOf(entry));
|
|
4037
|
+
cells.push({
|
|
4038
|
+
title: storyboardLine(`${order + 1}. ${entry.name}`),
|
|
4039
|
+
durationSeconds: Number(((entry.endFrame - entry.startFrame + 1) / tlFps).toFixed(2)),
|
|
4040
|
+
prompt: storyboardCaption(buildShotPrompt(meta, { target: "image" })),
|
|
4041
|
+
caption: storyboardLine((entry.caption ?? "").trim()),
|
|
4042
|
+
image: await loadImage(dataUrl),
|
|
4043
|
+
stylized: stylized ? await loadPngBytes(stylized) : null,
|
|
4044
|
+
});
|
|
4045
|
+
await exportBoundary(job);
|
|
4046
|
+
}
|
|
4047
|
+
const canvas = composeStoryboard({ shots: cells, columns: Math.min(3, cells.length), cell: { width: 480, height: 300 }, createCanvas: storyboardCanvas });
|
|
4048
|
+
job.controller.signal.throwIfAborted();
|
|
4049
|
+
saveDownload(canvas.toDataURL("image/png"), "cozyclay-storyboard.png");
|
|
4050
|
+
setToast(isKo ? `콘택트 시트 다운로드 요청 · 패널 ${cells.length}개` : `Download requested: contact sheet · ${cells.length} panels`);
|
|
4051
|
+
return { fileName: "cozyclay-storyboard.png", panelCount: cells.length };
|
|
4052
|
+
}));
|
|
4053
|
+
}
|
|
4054
|
+
|
|
4055
|
+
// One panel's pack: the greybox as first.png, the stylized picture when
|
|
4056
|
+
// there is one, the still's camera and hold, and the image prompt plus caption.
|
|
4057
|
+
async function panelPackArgs(entry, order, all) {
|
|
4058
|
+
const firstUrl = captureShotFramePng(entry.startFrame);
|
|
4059
|
+
if (!firstUrl) throw Object.assign(new Error(ko("The shot renderer is not ready", "샷 렌더러가 아직 준비되지 않았어요")), { exportFailureCode: "render_failed" });
|
|
4060
|
+
const meta = packMetaForShot(entry, all.indexOf(entry));
|
|
4061
|
+
const caption = (entry.caption ?? "").trim();
|
|
4062
|
+
return {
|
|
4063
|
+
shot: { title: entry.name, index: order + 1, startFrame: entry.startFrame, endFrame: entry.endFrame, kind: "still" },
|
|
4064
|
+
fps: tlFps,
|
|
4065
|
+
firstFramePng: dataUrlToBytes(firstUrl),
|
|
4066
|
+
lastFramePng: null,
|
|
4067
|
+
clip: null,
|
|
4068
|
+
stylizedPng: await panelStylizedPng(entry),
|
|
4069
|
+
camera: { ...meta, framing: { start: shotFramingAtFrame(entry, entry.startFrame) } },
|
|
4070
|
+
prompt: buildShotPrompt(meta, { target: "image" }) + (caption ? `\nCAPTION: ${caption}` : ""),
|
|
4071
|
+
folder: panelFolder(order),
|
|
4072
|
+
};
|
|
4073
|
+
}
|
|
4074
|
+
|
|
4075
|
+
/** Panel pack (zip) for one panel — the given one, else the selected one,
|
|
4076
|
+
* else the one under the playhead, else the first — or, with `every`, one
|
|
4077
|
+
* zip holding a panel-NN/ folder per panel. */
|
|
4078
|
+
async function exportPanelPacks(every = false, shotId = null) {
|
|
4079
|
+
if (recRef.current) return null;
|
|
4080
|
+
const panels = storyboardPanels();
|
|
4081
|
+
if (!panels.length) { setToast(noPanelsMessage()); return null; }
|
|
4082
|
+
const target = shotId ? panels.find((entry) => entry.id === shotId)
|
|
4083
|
+
: panels.find((entry) => entry.id === activeShot?.id)
|
|
4084
|
+
?? panels.find((entry) => tlFrame >= entry.startFrame && tlFrame <= entry.endFrame) ?? panels[0];
|
|
4085
|
+
if (!target) return null;
|
|
4086
|
+
return executeExportRequest(exportRequest("panel_pack", async (job) => {
|
|
4087
|
+
const all = job.request.context.shots;
|
|
4088
|
+
const contextPanels = storyboardPanels(all);
|
|
4089
|
+
const targets = every ? contextPanels : contextPanels.filter((entry) => entry.id === target.id);
|
|
4090
|
+
updateExportStatus(job, "preparing", { stage: "frames", cancellable: true });
|
|
4091
|
+
const packs = [];
|
|
4092
|
+
for (const [step, entry] of targets.entries()) {
|
|
4093
|
+
job.label = ko(`Panel ${step + 1} of ${targets.length}`, `패널 ${step + 1} / ${targets.length}`);
|
|
4094
|
+
packs.push(await panelPackArgs(entry, contextPanels.indexOf(entry), all));
|
|
4095
|
+
await exportBoundary(job);
|
|
4096
|
+
}
|
|
4097
|
+
updateExportStatus(job, "finalizing", { stage: "archive", cancellable: true });
|
|
4098
|
+
await exportBoundary(job);
|
|
4099
|
+
const entries = packs.flatMap((args) => keyframePackEntries(args));
|
|
4100
|
+
const bytes = every ? buildZip(entries) : buildKeyframePack(packs[0]);
|
|
4101
|
+
const name = every ? "cozyclay-panels.zip" : `cozyclay-${packs[0].folder}-${slugifyTitle(target.name)}.zip`;
|
|
4102
|
+
await exportBoundary(job);
|
|
4103
|
+
const url = URL.createObjectURL(new Blob([bytes], { type: "application/zip" }));
|
|
4104
|
+
try { saveDownload(url, name); }
|
|
4105
|
+
finally { setTimeout(() => URL.revokeObjectURL(url), 10_000); }
|
|
4106
|
+
setToast(isKo ? `${name} 다운로드 요청 · 파일 ${entries.length}개` : `Download requested: ${name} · ${entries.length} files`);
|
|
4107
|
+
return { fileName: name, entries: entries.map((entry) => entry.name) };
|
|
4108
|
+
}));
|
|
4109
|
+
}
|
|
4110
|
+
|
|
4111
|
+
/** Animatic (mp4): every panel held for its hold, from frame 0 to the last
|
|
4112
|
+
* still's end, at the production fps. */
|
|
4113
|
+
async function exportAnimatic() {
|
|
4114
|
+
if (recRef.current) return null;
|
|
4115
|
+
const panels = storyboardPanels();
|
|
4116
|
+
if (!panels.length) { setToast(noPanelsMessage()); return null; }
|
|
4117
|
+
const endFrame = panels.at(-1).endFrame;
|
|
4118
|
+
return executeExportRequest(exportRequest("animatic",
|
|
4119
|
+
(job) => runShotExport({ startFrame: 0, endFrame, fileName: "cozyclay-animatic.mp4" }, job)));
|
|
4120
|
+
}
|
|
4121
|
+
|
|
3804
4122
|
function captureCurrentFraming() {
|
|
3805
4123
|
const cam = shotCamRef.current;
|
|
3806
4124
|
const pos = cam ? cam.position : cameraPos;
|
|
@@ -3905,7 +4223,7 @@ export default function App() {
|
|
|
3905
4223
|
const rigSelection = parseRigNodeId(selectedHierarchyId);
|
|
3906
4224
|
const isRigSelection = rigSelection !== null;
|
|
3907
4225
|
const inspectorHasContent = isSceneSelection || isCameraSelection || isCharacterSelection || isRigSelection
|
|
3908
|
-
|| selectedHierarchyId === "environment" || selectedHierarchyId === "props" || selectedHierarchyId === "light" || Boolean(selectedSceneObject);
|
|
4226
|
+
|| selectedHierarchyId === "environment" || selectedHierarchyId === "props" || selectedHierarchyId === "light" || Boolean(selectedSceneObject) || sculptMode;
|
|
3909
4227
|
|
|
3910
4228
|
useEffect(() => () => {
|
|
3911
4229
|
if (multiModelObjectUrlRef.current) URL.revokeObjectURL(multiModelObjectUrlRef.current);
|
|
@@ -3950,6 +4268,11 @@ export default function App() {
|
|
|
3950
4268
|
});
|
|
3951
4269
|
return;
|
|
3952
4270
|
}
|
|
4271
|
+
// The seed dresses a visitor's first, unnamed stage and the starter set a
|
|
4272
|
+
// `?scene=` launch always re-opens ("one character mid-walk"). A named
|
|
4273
|
+
// project is the author's document: reloading it must never put the demo
|
|
4274
|
+
// walk back on a character they deliberately left without a take.
|
|
4275
|
+
if (loadProjectSession()?.name && !new URLSearchParams(window.location.search).get("scene")) { demoSeeded.current = true; return; }
|
|
3953
4276
|
if (!demoSeed.seed) return;
|
|
3954
4277
|
demoSeeded.current = true;
|
|
3955
4278
|
// Loaded, not played: the clip walks the subject out of the default
|
|
@@ -3991,7 +4314,7 @@ export default function App() {
|
|
|
3991
4314
|
useEffect(() => {
|
|
3992
4315
|
// The ACTIVE member's clip only: its own corrections are applied by the
|
|
3993
4316
|
// evaluate effect below, after its editing state settles.
|
|
3994
|
-
poseMemberAtFrame(rigs[activeChar.id], motion, null, tlFrame);
|
|
4317
|
+
if (activeCapabilities.rig) poseMemberAtFrame(rigs[activeChar.id], motion, null, tlFrame);
|
|
3995
4318
|
poseOtherCastMembers(tlFrame);
|
|
3996
4319
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3997
4320
|
}, [characters, activeChar.id, motion, rigs, tlFrame, ikTick]);
|
|
@@ -4008,7 +4331,7 @@ export default function App() {
|
|
|
4008
4331
|
// character's rig (re)loads. A rig missing any bone resolves to null and
|
|
4009
4332
|
// IK mode stays unavailable.
|
|
4010
4333
|
useEffect(() => {
|
|
4011
|
-
const resolved = resolveIkRig(activeRig);
|
|
4334
|
+
const resolved = activeCapabilities.ik ? resolveIkRig(activeRig) : null;
|
|
4012
4335
|
const chains = resolved ? resolved.chains : null;
|
|
4013
4336
|
setIkChains(chains);
|
|
4014
4337
|
setIkFkJoints(resolved ? resolved.fkJoints : null);
|
|
@@ -4018,7 +4341,7 @@ export default function App() {
|
|
|
4018
4341
|
ikStateRef.current.fkJoints = resolved ? resolved.fkJoints : null;
|
|
4019
4342
|
ikStateRef.current.rig = chains ? activeRig : null;
|
|
4020
4343
|
if (!chains) leaveIkMode();
|
|
4021
|
-
}, [activeRig]);
|
|
4344
|
+
}, [activeRig, activeCapabilities.ik]);
|
|
4022
4345
|
|
|
4023
4346
|
// Whether the collision capsules can be built for this rig at all. Bone
|
|
4024
4347
|
// lookups only — no mesh measurement — so it is cheap enough to hang off
|
|
@@ -4027,7 +4350,7 @@ export default function App() {
|
|
|
4027
4350
|
// a SEPARATE question from `ikChains`: the two collision buttons disable
|
|
4028
4351
|
// on it rather than offering a click whose only possible answer is "not
|
|
4029
4352
|
// supported".
|
|
4030
|
-
const collisionCleanupSupported = useMemo(() => supportsCollisionCleanup(activeRig), [activeRig]);
|
|
4353
|
+
const collisionCleanupSupported = useMemo(() => activeCapabilities.physics && supportsCollisionCleanup(activeRig), [activeRig, activeCapabilities.physics]);
|
|
4031
4354
|
|
|
4032
4355
|
//
|
|
4033
4356
|
|
|
@@ -4064,11 +4387,12 @@ export default function App() {
|
|
|
4064
4387
|
// Preview changes may re-run this effect without a playhead change.
|
|
4065
4388
|
// Always re-establish the motion base before adding a correction.
|
|
4066
4389
|
if (motion) poseMemberAtFrame(activeRig, motion, null, tlFrame);
|
|
4067
|
-
const layer =
|
|
4390
|
+
const layer = pendingIkEdit?.characterId === activeChar.id ? pendingIkEdit.candidate
|
|
4391
|
+
: physicsPreview && physicsShow ? physicsPreview.candidate : ikStateRef.current;
|
|
4068
4392
|
if (ikMode || layer.keys.size > 0) {
|
|
4069
4393
|
ikEvaluate(ikChains, layer, tlFrame, ikFkJoints, motion ? IK_CORRECTION_BLEND_FRAMES : 0);
|
|
4070
4394
|
}
|
|
4071
|
-
}, [ikMode, ikChains, activeRig, motion, posing, tlFrame, ikTick, ikFkJoints, physicsPreview, physicsShow]);
|
|
4395
|
+
}, [ikMode, ikChains, activeRig, motion, posing, tlFrame, ikTick, ikFkJoints, physicsPreview, physicsShow, pendingIkEdit, activeChar.id]);
|
|
4072
4396
|
|
|
4073
4397
|
// Re-seat the handles on the keyed pose when the FRAME changes with IK
|
|
4074
4398
|
// on — scrubbing to frame 39 shows that frame's interpolated pose AND
|
|
@@ -4089,6 +4413,7 @@ export default function App() {
|
|
|
4089
4413
|
// (tools/ardy/visual-qa.mjs). Harmless in normal use.
|
|
4090
4414
|
useEffect(() => {
|
|
4091
4415
|
window.__cozyclay = {
|
|
4416
|
+
runStudioAction,
|
|
4092
4417
|
runArdy: (options) => appContext.live.state.runArdy(options),
|
|
4093
4418
|
rigA: activeRig, motion, tlFrame, frameCount: tlFrameCount, playing: tlPlaying, ikMode, ikChains, ikFocus, contactRadii: ikChains?.values().next().value?.contactRadii ?? null, ik: ikStateRef.current,
|
|
4094
4419
|
rangePins, rangePinResiduals,
|
|
@@ -4195,22 +4520,33 @@ export default function App() {
|
|
|
4195
4520
|
insetPane: insetPaneRef.current,
|
|
4196
4521
|
mainPane: mainPaneRef.current,
|
|
4197
4522
|
// the selected prop's route, so QA can aim a gesture at the line
|
|
4198
|
-
objectPath:
|
|
4523
|
+
objectPath: routeOwner?.path ?? null,
|
|
4524
|
+
objectPathOwnerId: routeOwner?.id ?? null,
|
|
4199
4525
|
pathPointIndex,
|
|
4200
|
-
pathHandlesEnabled: !preview && !lookThroughShot && !ikMode && !posing && !!
|
|
4526
|
+
pathHandlesEnabled: !preview && !lookThroughShot && !ikMode && !posing && !!routeOwner?.path,
|
|
4201
4527
|
addSceneObject: (kind, placement = {}) => addSceneObject(kind, placement),
|
|
4202
4528
|
scrub: (frame) => setTlFrame(Math.max(0, Math.min(tlFrameCount - 1, Math.round(frame)))),
|
|
4203
4529
|
pause: () => setTlPlaying(false),
|
|
4204
4530
|
// Motion-trail QA surface: read the current trail policy and drive the
|
|
4205
4531
|
// same drag -> preview -> pending-edit path headless checks cannot reach
|
|
4206
4532
|
// through synthetic pointers reliably.
|
|
4207
|
-
trail: { falloffFrames: trailFalloffFrames, falloffS: trailFalloffS, edit: trailEdit, tool: ikEditTool, visible: showTrails },
|
|
4533
|
+
trail: { falloffFrames: trailFalloffFrames, falloffS: trailFalloffS, edit: trailEdit, tool: ikEditTool, visible: showTrails, activeTrackId: trailTrackFocus, visibleTracks: trailTrackFocus ? [trailTrackFocus] : null },
|
|
4208
4534
|
trailPoints: (jointName = "Hips") => jointTrailPoints(motion, jointName, { baseY: activeChar.y ?? 0, scale: activeChar.scale ?? 1 }),
|
|
4209
4535
|
trailEditApply: (grabFrame, delta) => {
|
|
4210
|
-
onTrailDragStart({ grabFrame });
|
|
4536
|
+
onTrailDragStart({ track: "hips", grabFrame });
|
|
4211
4537
|
onTrailDragPreview({ track: "hips", grabFrame, delta });
|
|
4212
4538
|
onTrailDragEnd({ track: "hips", grabFrame, delta });
|
|
4213
4539
|
},
|
|
4540
|
+
// QA-only IK drag seam: resolves the same production solve/end path as
|
|
4541
|
+
// the viewport manipulator, with a deterministic world-space offset.
|
|
4542
|
+
ikPreviewApply: (track = "leftHand", offset = { x: 0.02, y: 0, z: 0 }) => {
|
|
4543
|
+
const chain = ikChains?.get(track), bone = chain?.bones?.[2];
|
|
4544
|
+
if (!bone) return false;
|
|
4545
|
+
const target = bone.getWorldPosition(new THREE.Vector3()).add(new THREE.Vector3(offset.x ?? 0, offset.y ?? 0, offset.z ?? 0));
|
|
4546
|
+
ikSolve("chain", track, target);
|
|
4547
|
+
ikDragEnd();
|
|
4548
|
+
return true;
|
|
4549
|
+
},
|
|
4214
4550
|
trailRegenerate: runTrailRegeneration,
|
|
4215
4551
|
// Fix-collisions QA surface: live penetration readout for headless
|
|
4216
4552
|
// checks; the fix itself runs through the buttons / runFixCollisions.
|
|
@@ -4257,7 +4593,7 @@ export default function App() {
|
|
|
4257
4593
|
// close over them: a stale closure would report the set as it was two
|
|
4258
4594
|
// edits ago — and, after an undo that removes a subject, would keep
|
|
4259
4595
|
// reporting the ghost's capsules.
|
|
4260
|
-
}, [activeRig, motion, tlFrame, tlFrameCount, ikMode, ikChains, ikFocus, ikTick, charA, rangePins, rangePinResiduals, committedIkEdits, waypoints, lookThroughShot, selectedSceneObject, sceneObjects, rigs, characters, pathPointIndex, preview, posing, playMode, pathDraw, trailEdit, trailFalloffFrames, trailFalloffS, ikEditTool, showTrails, physicsPreview, physicsShow, physicsOptions, autoPhysicsRunning, platformFitRunning, platformFitLast, platformFitApplied]);
|
|
4596
|
+
}, [activeRig, motion, tlFrame, tlFrameCount, ikMode, ikChains, ikFocus, ikTick, charA, rangePins, rangePinResiduals, committedIkEdits, waypoints, lookThroughShot, selectedSceneObject, sceneObjects, rigs, characters, pathPointIndex, preview, posing, playMode, pathDraw, trailEdit, trailTrackFocus, pendingIkEdit, trailFalloffFrames, trailFalloffS, ikEditTool, showTrails, physicsPreview, physicsShow, physicsOptions, autoPhysicsRunning, platformFitRunning, platformFitLast, platformFitApplied]);
|
|
4261
4597
|
// QA hook (plan §6.5): exposes history depth and the present === objects
|
|
4262
4598
|
// invariant so the browser suite can assert undo entry counts directly.
|
|
4263
4599
|
// Reads live store state at call time; re-registered after every render.
|
|
@@ -4306,19 +4642,37 @@ export default function App() {
|
|
|
4306
4642
|
fovDeg,
|
|
4307
4643
|
filmback,
|
|
4308
4644
|
}), [tlFrameCount, tlFps, charA, motion, fovDeg, filmback]);
|
|
4309
|
-
|
|
4310
|
-
|
|
4311
|
-
|
|
4645
|
+
// The camera's subject grouped under a routed object (#655) stands where
|
|
4646
|
+
// that object carries it: its played root through the object's carry,
|
|
4647
|
+
// frame by frame — the same carry the renderer gives the body. Null when it
|
|
4648
|
+
// rides nothing, so every reader below keeps the plain played root.
|
|
4649
|
+
const subjectCarry = useMemo(() => {
|
|
4650
|
+
if (!charA.parent) return null;
|
|
4651
|
+
const objects = new Map(sceneObjects.map((object) => [object.id, object]));
|
|
4652
|
+
const take = { frameCount: tlFrameCount, fps: tlFps };
|
|
4653
|
+
return (point, frame) => {
|
|
4654
|
+
if (!point) return point;
|
|
4655
|
+
const at = carriedPointAt(objects, charA.parent, { x: point.x, y: charA.y ?? 0, z: point.z }, frame, take);
|
|
4656
|
+
return { x: at.x, z: at.z };
|
|
4657
|
+
};
|
|
4658
|
+
}, [charA.parent, charA.y, sceneObjects, tlFrameCount, tlFps]);
|
|
4659
|
+
const motionPos = useMemo(() => {
|
|
4660
|
+
if (!motion && !subjectCarry) return null;
|
|
4661
|
+
const subject = sampleAt(playbackSceneBase, null, tlFrame).subject;
|
|
4662
|
+
return subjectCarry ? subjectCarry(subject, tlFrame) : subject;
|
|
4663
|
+
}, [motion, subjectCarry, playbackSceneBase, tlFrame]);
|
|
4312
4664
|
|
|
4313
4665
|
// The subject's full per-frame scene trajectory — what the follow camera
|
|
4314
|
-
// is derived from. Without a loaded motion
|
|
4315
|
-
// the follow camera simply composes a static frame.
|
|
4666
|
+
// is derived from. Without a loaded motion, and riding nothing, the subject
|
|
4667
|
+
// stands still and the follow camera simply composes a static frame.
|
|
4316
4668
|
const subjectTrack = useMemo(() => {
|
|
4317
4669
|
if (!shots.some((shot) => shot.camera?.mode === "follow" || shot.camera?.mode === "rail")) return null;
|
|
4318
4670
|
const frames = Math.max(tlFrameCount, 1);
|
|
4319
|
-
return Array.from({ length: frames }, (_, frame) =>
|
|
4320
|
-
sampleAt(playbackSceneBase, null, frame).subject
|
|
4321
|
-
|
|
4671
|
+
return Array.from({ length: frames }, (_, frame) => {
|
|
4672
|
+
const subject = sampleAt(playbackSceneBase, null, frame).subject;
|
|
4673
|
+
return subjectCarry ? subjectCarry(subject, frame) : subject;
|
|
4674
|
+
});
|
|
4675
|
+
}, [shots, playbackSceneBase, tlFrameCount, subjectCarry]);
|
|
4322
4676
|
|
|
4323
4677
|
// The dense rail (spline through the drawn control points) — shared by
|
|
4324
4678
|
// the follow controller and the Top-View display.
|
|
@@ -4429,7 +4783,7 @@ export default function App() {
|
|
|
4429
4783
|
? { label: ko("ROOT PATH", "루트 경로"), kind: "root" }
|
|
4430
4784
|
: null;
|
|
4431
4785
|
|
|
4432
|
-
function bufferToPng(buffer, output = shotOutput) {
|
|
4786
|
+
function bufferToPng(buffer, output = shotOutput, decorate = null) {
|
|
4433
4787
|
const canvas = document.createElement("canvas");
|
|
4434
4788
|
if (output === shotOutput) {
|
|
4435
4789
|
canvas.width = shotOutput.width;
|
|
@@ -4449,6 +4803,7 @@ export default function App() {
|
|
|
4449
4803
|
);
|
|
4450
4804
|
}
|
|
4451
4805
|
ctx.putImageData(image, 0, 0);
|
|
4806
|
+
decorate?.(ctx);
|
|
4452
4807
|
return canvas.toDataURL("image/png");
|
|
4453
4808
|
}
|
|
4454
4809
|
|
|
@@ -4462,6 +4817,12 @@ export default function App() {
|
|
|
4462
4817
|
cam.rotation.order = "YXZ";
|
|
4463
4818
|
cam.rotation.set(framing.pitch, framing.yaw, 0);
|
|
4464
4819
|
cam.fov = framing.fovDeg;
|
|
4820
|
+
const prevAspect = cam.aspect;
|
|
4821
|
+
const offset = framing.viewOffset;
|
|
4822
|
+
if (offset) {
|
|
4823
|
+
cam.aspect = offset.fullWidth / offset.fullHeight;
|
|
4824
|
+
cam.setViewOffset(offset.fullWidth, offset.fullHeight, offset.x, offset.y, offset.width, offset.height);
|
|
4825
|
+
}
|
|
4465
4826
|
cam.updateProjectionMatrix();
|
|
4466
4827
|
const needsOwnTarget = output.width !== shotOutput.width || output.height !== shotOutput.height;
|
|
4467
4828
|
const capture = needsOwnTarget && typeof captureRef.current.createExportCapture === "function"
|
|
@@ -4477,10 +4838,85 @@ export default function App() {
|
|
|
4477
4838
|
look.current.yaw = prev.yaw;
|
|
4478
4839
|
look.current.pitch = prev.pitch;
|
|
4479
4840
|
cam.fov = prev.fov;
|
|
4841
|
+
if (offset) {
|
|
4842
|
+
cam.clearViewOffset();
|
|
4843
|
+
cam.aspect = prevAspect;
|
|
4844
|
+
}
|
|
4480
4845
|
cam.updateProjectionMatrix();
|
|
4481
4846
|
}
|
|
4482
4847
|
}
|
|
4483
4848
|
|
|
4849
|
+
/** A live capture command's requested `output` size, or null to use its default. */
|
|
4850
|
+
function requestedCaptureOutput(args) {
|
|
4851
|
+
const requested = args?.output;
|
|
4852
|
+
return Number.isFinite(requested?.width) && Number.isFinite(requested?.height)
|
|
4853
|
+
? { width: Math.round(requested.width), height: Math.round(requested.height) }
|
|
4854
|
+
: null;
|
|
4855
|
+
}
|
|
4856
|
+
|
|
4857
|
+
/** The Top View as a PNG: a 2D data diagram drawn from editor state by
|
|
4858
|
+
src/plan-diagram.js (grid, footprints, cast, every shot camera, the current
|
|
4859
|
+
shot's action line and blocked sight lines). No render is involved. */
|
|
4860
|
+
function capturePlanPng(output = { width: 1280, height: 720 }) {
|
|
4861
|
+
const live = appContext.live.state, s = readStudioState();
|
|
4862
|
+
const canvas = document.createElement("canvas");
|
|
4863
|
+
canvas.width = output.width;
|
|
4864
|
+
canvas.height = output.height;
|
|
4865
|
+
drawPlanDiagram(canvas.getContext("2d"), { frame: s.view.frame, frameCount: s.frameCount, objects: s.objects, characters: s.characters, shotDocument: { shots: s.shots }, camera: s.camera },
|
|
4866
|
+
{ width: output.width, height: output.height, ports: { bounds: studioBounds }, currentShotId: live.activeShotId, aspect: shotOutput.width / shotOutput.height });
|
|
4867
|
+
return { dataUrl: canvas.toDataURL("image/png"), width: output.width, height: output.height, frame: live.timeline.currentFrame, shotId: live.activeShotId };
|
|
4868
|
+
}
|
|
4869
|
+
|
|
4870
|
+
/** The canvas runs under the floating panels, so "what I see" is only the uncovered middle.
|
|
4871
|
+
Returns the largest centred `aspect` rectangle inside that region as a camera view offset. */
|
|
4872
|
+
function visibleViewOffset(aspect) {
|
|
4873
|
+
const canvas = document.querySelector(".viewport canvas");
|
|
4874
|
+
if (!canvas) return null;
|
|
4875
|
+
const box = canvas.getBoundingClientRect();
|
|
4876
|
+
const edge = (selector, side) => {
|
|
4877
|
+
const rect = document.querySelector(selector)?.getBoundingClientRect();
|
|
4878
|
+
return rect && rect.width > 0 && rect.height > 0 ? rect[side] : null;
|
|
4879
|
+
};
|
|
4880
|
+
const left = Math.max(box.left, edge(".studio-left-column", "right") ?? box.left);
|
|
4881
|
+
const right = Math.min(box.right, edge(".studio-right-column", "left") ?? box.right);
|
|
4882
|
+
const top = Math.max(box.top, edge(".topbar", "bottom") ?? box.top);
|
|
4883
|
+
const bottom = Math.min(box.bottom, edge(".studio-dock-slot", "top") ?? box.bottom);
|
|
4884
|
+
if (right - left < 50 || bottom - top < 50) return null;
|
|
4885
|
+
const width = Math.min(right - left, (bottom - top) * aspect);
|
|
4886
|
+
const height = width / aspect;
|
|
4887
|
+
return {
|
|
4888
|
+
fullWidth: box.width, fullHeight: box.height,
|
|
4889
|
+
x: (left + right) / 2 - width / 2 - box.left, y: (top + bottom) / 2 - height / 2 - box.top,
|
|
4890
|
+
width, height,
|
|
4891
|
+
};
|
|
4892
|
+
}
|
|
4893
|
+
|
|
4894
|
+
/** Pose mode's image generation: the shot camera's view of the pose as it stands, at the
|
|
4895
|
+
delivery size, plus the scene's identity/environment references. The pose-mode object
|
|
4896
|
+
fade is lifted for this draw only (props are re-synced right after, so a demand frame loop keeps it), and editor
|
|
4897
|
+
overlays such as motion trails are hidden around it. */
|
|
4898
|
+
function capturePoseImage(source = "view") {
|
|
4899
|
+
const viewCam = source === "view" ? poserCamRef.current : null;
|
|
4900
|
+
let framing = captureCurrentFraming();
|
|
4901
|
+
if (viewCam) {
|
|
4902
|
+
const dir = viewCam.getWorldDirection(new THREE.Vector3());
|
|
4903
|
+
const pos = viewCam.getWorldPosition(new THREE.Vector3());
|
|
4904
|
+
framing = captureFraming({ pos, yaw: Math.atan2(-dir.x, -dir.z), pitch: Math.asin(THREE.MathUtils.clamp(dir.y, -1, 1)), fovDeg: viewCam.fov });
|
|
4905
|
+
framing.viewOffset = visibleViewOffset(shotOutput.width / shotOutput.height);
|
|
4906
|
+
}
|
|
4907
|
+
const scene = captureRef.current?.scene;
|
|
4908
|
+
const overlays = [];
|
|
4909
|
+
scene?.traverse((node) => {
|
|
4910
|
+
if (node.userData.editorOverlay && node.visible) { node.visible = false; overlays.push(node); }
|
|
4911
|
+
});
|
|
4912
|
+
try {
|
|
4913
|
+
const dataUrl = withPoseFadeLifted(propSyncRef.current, () => captureFramingPng(framing, shotOutput));
|
|
4914
|
+
return dataUrl ? { dataUrl, references: captureShotReferences() } : null;
|
|
4915
|
+
} finally {
|
|
4916
|
+
for (const node of overlays) node.visible = true;
|
|
4917
|
+
}
|
|
4918
|
+
}
|
|
4919
|
+
|
|
4484
4920
|
function copyPrompt(prompt) {
|
|
4485
4921
|
const write = navigator.clipboard?.writeText(prompt);
|
|
4486
4922
|
if (!write) return;
|
|
@@ -6414,7 +6850,7 @@ export default function App() {
|
|
|
6414
6850
|
preserveAuthoredMotion: Boolean(c.layer?.waypoints?.length) }]));
|
|
6415
6851
|
return { host: { workspaceId: liveWorkspaceIdRef.current, documentEpoch: studioDocumentEpochRef.current,
|
|
6416
6852
|
sceneId: activeSceneIdRef.current, sceneEpoch: studioSceneEpochRef.current }, workspaceHandle: liveWorkspaceHandleRef.current,
|
|
6417
|
-
sceneName: live.scenes.find(s => s.id === activeSceneIdRef.current)?.name ?? "Untitled Scene", aspect: live.stage.shotAspect, stage: live.stage,
|
|
6853
|
+
sceneName: live.scenes.find(s => s.id === activeSceneIdRef.current)?.name ?? "Untitled Scene", previsMode: scenesDomain.previsMode, aspect: live.stage.shotAspect, stage: live.stage,
|
|
6418
6854
|
objects: storeRef.current.objects, characters: list, targets, shots: live.shots, frameCount: live.timeline.frameCount,
|
|
6419
6855
|
selection: live.studioSelection, activeCharacterId: live.activeCharacterId, selectedShotId: live.studioShotId,
|
|
6420
6856
|
view: live.studioView, camera: live.studioCamera ?? readStudioCamera(), filmback: live.filmback, manual: manualCameraOverrideRef.current,
|
|
@@ -6488,6 +6924,14 @@ export default function App() {
|
|
|
6488
6924
|
const raw = readStudioState();
|
|
6489
6925
|
const original = raw.characters.find(c => c.id === entity.id) ?? raw.characters.find(c => c.model === entity.model);
|
|
6490
6926
|
const target = original && raw.targets.get(original.id);
|
|
6927
|
+
if (isProxyFigure(original)) {
|
|
6928
|
+
const clip = target?.motion;
|
|
6929
|
+
const placement = placementAt(original, frame, {
|
|
6930
|
+
shotAt: at => shotAtFrame(raw.shots, at),
|
|
6931
|
+
takeRoot: clip ? sampleAt({ frameCount: clip.frames, motion: clip }, null, frame).subject : null,
|
|
6932
|
+
});
|
|
6933
|
+
return proxyFigureBounds(original, placement);
|
|
6934
|
+
}
|
|
6491
6935
|
if (!target?.rig) throw new StudioProtocolError("TARGET_NOT_READY", "Character bounds require its loaded rig.");
|
|
6492
6936
|
const rig = cloneSkeleton(target.rig), parent = new THREE.Group();
|
|
6493
6937
|
const originals = [], copies = [];
|
|
@@ -6516,13 +6960,15 @@ export default function App() {
|
|
|
6516
6960
|
for (const skeleton of skeletons) skeleton.dispose(); parent.remove(rig);
|
|
6517
6961
|
}
|
|
6518
6962
|
}
|
|
6519
|
-
function operateStudio({ selection, view, shotId }) {
|
|
6963
|
+
function operateStudio({ selection, view, shotId, modeSet = true }) {
|
|
6520
6964
|
const { frame } = view;
|
|
6521
6965
|
appContext.patchLive({ studioSelection: selection, studioView: view, studioShotId: shotId });
|
|
6522
6966
|
appContext.patchTimeline({ currentFrame: frame }); tlFrameRef.current = frame;
|
|
6523
6967
|
if (selection && ["character", "rig"].includes(selection.kind)) { appContext.patchLive({ activeCharacterId: selection.id }); setActiveCharacterId(selection.id); }
|
|
6524
6968
|
setSelectedHierarchyId(selection ? selection.kind === "object" ? `object:${selection.id}` : ["character", "rig"].includes(selection.kind) ? `character:${selection.id}` : selection.kind === "camera" ? "camera" : "shot" : "");
|
|
6525
|
-
|
|
6969
|
+
// Sculpt is UI-only and publishes as Stage (below): a view write that
|
|
6970
|
+
// names no mode keeps the editor in it.
|
|
6971
|
+
setTlFrame(frame); setWorkflowMode((current) => (current === "sculpt" && !modeSet && view.mode === "scene" ? current : view.mode)); setLookThroughShot(view.lookThrough); setGridView(view.grid); setAutoColor(view.autoColor); setTlPlaying(view.playing);
|
|
6526
6972
|
}
|
|
6527
6973
|
const studioGestureRef = useRef(false);
|
|
6528
6974
|
// The Studio turn authors the scene through its own families and never
|
|
@@ -6541,13 +6987,15 @@ export default function App() {
|
|
|
6541
6987
|
selectedStudioChar ? { kind: "character", id: selectedStudioChar, hierarchyId: selectedHierarchyId } : selectedHierarchyId === "camera" ? { kind: "camera", id: "camera" } : { kind: "scene", id: activeSceneId },
|
|
6542
6988
|
studioShotId: activeShot?.id ?? null,
|
|
6543
6989
|
studioPhysicsRunning: autoPhysicsRunning,
|
|
6544
|
-
|
|
6990
|
+
// The agent's mode vocabulary is four (studio-agent-protocol.js): the
|
|
6991
|
+
// sculpt turntable is the Stage's scene seen in isolation.
|
|
6992
|
+
studioView: { mode: sculptMode ? "scene" : workflowMode, frame: tlFrame, playing: tlPlaying, lookThrough: lookThroughShot, grid: gridView, autoColor },
|
|
6545
6993
|
});
|
|
6546
6994
|
appContext.updatePorts({
|
|
6547
6995
|
read: readStudioState, revision: sceneRevisionRef, bounds: studioBounds, commit: commitStudioDraft,
|
|
6548
6996
|
operate: operateStudio, undo: () => stepStudioHistory(false), redo: () => stepStudioHistory(true),
|
|
6549
6997
|
history: redo => appContext.historyEntry(redo), finishHistoryGesture: finishStudioHistoryGesture,
|
|
6550
|
-
stepHistory: stepStudioHistory, capture: () => liveQueries.capture_framing_png({}),
|
|
6998
|
+
stepHistory: stepStudioHistory, capture: () => liveQueries.capture_framing_png({}), capturePlan: () => liveQueries.capture_plan_png({}),
|
|
6551
6999
|
// One shot frame as raw read-back pixels (rows bottom-up), from the export
|
|
6552
7000
|
// path captureShotFramePng uses; an export in flight renders at its output.
|
|
6553
7001
|
renderFrameBuffer: frame => {
|
|
@@ -6591,7 +7039,7 @@ export default function App() {
|
|
|
6591
7039
|
scenes: appContext.live.scenes.map(({ id, name }) => ({ id, name })), activeSceneId: activeSceneIdRef.current,
|
|
6592
7040
|
// What a save needs: a name, a file this session, and (to pick or
|
|
6593
7041
|
// re-grant a file) the user's click still active.
|
|
6594
|
-
project: { name: projectName, hasFile: Boolean(projectHandleRef.current), fileAccess: hasFileSystemAccess(),
|
|
7042
|
+
project: { name: projectName, previsMode, hasFile: Boolean(projectHandleRef.current), fileAccess: hasFileSystemAccess(),
|
|
6595
7043
|
gesture: globalThis.navigator?.userActivation?.isActive === true },
|
|
6596
7044
|
// What generate() reads for the Send-to-AI package, as this render has it.
|
|
6597
7045
|
aiShot: { mode, imageModel },
|
|
@@ -6656,21 +7104,63 @@ export default function App() {
|
|
|
6656
7104
|
? ko("Unsaved changes", "저장되지 않은 변경사항")
|
|
6657
7105
|
: ko("Saved", "저장됨");
|
|
6658
7106
|
|
|
7107
|
+
// Sculpt mode's panel heads the Details column (DetailsSlot mounts it).
|
|
7108
|
+
const sculptPanel = sculptMode && !embedMode ? (
|
|
7109
|
+
<SculptPanel
|
|
7110
|
+
run={sculptRun}
|
|
7111
|
+
reference={sculptReference}
|
|
7112
|
+
onReference={(file) => void chooseSculptReference(file)}
|
|
7113
|
+
onStart={startSculptRun}
|
|
7114
|
+
onCancel={() => sculptRunRef.current?.cancel()}
|
|
7115
|
+
onPlaceInScene={placeSculptInScene}
|
|
7116
|
+
canStart={Boolean(sculptReference) && sculptAgentStatus === "ready" && Boolean(liveWorkspaceHandle) && Boolean(sculptModelPick.model)}
|
|
7117
|
+
blueprint={{
|
|
7118
|
+
...sculptBlueprint,
|
|
7119
|
+
prompt: buildBlueprintPrompt({ pose: sculptBlueprint.pose, notes: sculptBlueprint.notes }),
|
|
7120
|
+
layout: BLUEPRINT_LAYOUT,
|
|
7121
|
+
// The image model is ChatGPT-only (the sidecar refuses others with a reason).
|
|
7122
|
+
refusal: sculptAgentStatus === "signed-out" ? ko("Sign in with ChatGPT in the Agent panel to generate a blueprint.", "도면을 생성하려면 에이전트 패널에서 ChatGPT로 로그인하세요.") : null,
|
|
7123
|
+
onPose: (pose) => setSculptBlueprint((current) => ({ ...current, pose })),
|
|
7124
|
+
onNotes: (notes) => setSculptBlueprint((current) => ({ ...current, notes })),
|
|
7125
|
+
onGenerate: () => void generateSculptBlueprint(),
|
|
7126
|
+
onCancel: cancelSculptBlueprint,
|
|
7127
|
+
}}
|
|
7128
|
+
modelPicker={{
|
|
7129
|
+
...sculptModels,
|
|
7130
|
+
model: sculptModelPick.model,
|
|
7131
|
+
effort: sculptModelPick.effort,
|
|
7132
|
+
// A new model starts at its own default effort.
|
|
7133
|
+
onModel: (model) => chooseSculptModel({ model, effort: null }),
|
|
7134
|
+
onEffort: (effort) => chooseSculptModel({ effort }),
|
|
7135
|
+
}}
|
|
7136
|
+
startRefusal={sculptAgentStatus === "signed-out"
|
|
7137
|
+
? ko("Sign in to the Agent (⌘B) to sculpt from an image.", "이미지로 조형하려면 에이전트(⌘B)에 로그인하세요.")
|
|
7138
|
+
: sculptAgentStatus === "ready" && !liveWorkspaceHandle
|
|
7139
|
+
? ko("The live editor is disconnected — reconnect to sculpt.", "라이브 편집기가 연결되지 않았어요 — 연결 후 조형하세요.")
|
|
7140
|
+
: sculptAgentStatus === "ready" && sculptModels.state === "failed"
|
|
7141
|
+
? ko("No model is available — add a provider key in the Agent panel.", "사용할 모델이 없어요 — 에이전트 패널에서 제공자 키를 추가하세요.")
|
|
7142
|
+
: null}
|
|
7143
|
+
sculptName={sculptTarget?.name ?? null}
|
|
7144
|
+
/>
|
|
7145
|
+
) : null;
|
|
6659
7146
|
// The single shell snapshot keeps subsequent region work out of App.
|
|
6660
7147
|
const shellContext = {
|
|
6661
|
-
|
|
7148
|
+
sculptPanel,
|
|
7149
|
+
projectMenuOpen, setProjectMenuOpen, projectDirty, projectName, previsMode, projectStartupOpen,
|
|
6662
7150
|
requestNewProject, setProjectStartupOpen, setProjectBrowserOpen, runStudioAction, saveProject,
|
|
6663
7151
|
projectManifest, projectSaveState, recState, exportMenuTriggerRef, exportMenuOpen,
|
|
6664
7152
|
exportShotIdRef, setExportMenuAnchor, setExportMenuOpen, exportPhaseLabel,
|
|
6665
7153
|
exportMenuAnchor, resultOpen, exportFeedback, shots, exportKeyframePacks,
|
|
6666
7154
|
hasCameraKeys, motion, exportRenderPasses, exportDepthVideo, exportStoryboard,
|
|
7155
|
+
exportContactSheet, exportPanelPacks, exportAnimatic,
|
|
7156
|
+
capturePanelThumbnail, packMetaForShot, previsMode: scenesDomain.previsMode, sceneRevision: sceneRevisionRef.current,
|
|
6667
7157
|
downloadOtioCutList, projectStatus, liveWorkspaceHandle, selectedHierarchyId, selectHierarchy,
|
|
6668
7158
|
aimEditorAtKeyLight, characters, showB, ikFrames, ikMode,
|
|
6669
7159
|
rowIdForCharIndex, activeCharIndex, waypoints, sceneObjects, scenes,
|
|
6670
7160
|
activeSceneId, selectSceneDocument, createSceneDocumentFromUi, duplicateSceneDocumentFromUi, renameSceneDocumentFromUi,
|
|
6671
|
-
deleteSceneDocumentFromUi, addSceneObject, renameSceneObject, deleteSceneObject, frameSelection,
|
|
7161
|
+
deleteSceneDocumentFromUi, addSceneObject, renameSceneObject, deleteSceneObject, frameSelection, groupObjectUnderNewEmpty, renameRequest,
|
|
6672
7162
|
toggleHierarchyHidden, propsDrop, hierarchyReparent, agentTouchedRows, workflowMode,
|
|
6673
|
-
selectWorkflowMode, gizmoMode, setGizmoMode, snapEnabled, setSnapEnabled,
|
|
7163
|
+
selectWorkflowMode, gizmoMode, setGizmoMode, snapEnabled, setSnapEnabled, sculptView, requestSculptView,
|
|
6674
7164
|
preset, applyPreset, cameraPresetId, i2vMotionCameraLocked, shotAspectKey,
|
|
6675
7165
|
fovDeg, shotsDomain, shot, setNonce, workspaceLayout,
|
|
6676
7166
|
viewMenuTriggerRef, viewMenuOpen, setViewMenuAnchor, setViewMenuOpen, viewLooksActive,
|
|
@@ -6698,12 +7188,13 @@ export default function App() {
|
|
|
6698
7188
|
linePreviewError, generationBusy, bridgeChecking, lineReadinessState, runLineEdit,
|
|
6699
7189
|
openMotionSetup, recheckMotionHealth, resetLineCurve, exitLineEditMode, readinessState,
|
|
6700
7190
|
ardyRunning, cancelArdy, ardyStatus, ardyOutcome, addPromptClip,
|
|
6701
|
-
tlFrame, isRigSelection, ikChains, ikFocus, footSnap,
|
|
7191
|
+
tlFrame, isRigSelection, ikChains, ikFocus, footSnap, poseObjectOpacity, setPoseObjectOpacity, capturePoseImage,
|
|
6702
7192
|
collisionCleanupSupported, runFixCollisions, runFixCollisionsRange, autoPhysicsRunning, physicsProgress,
|
|
6703
7193
|
physicsPreview, physicsShow, physicsOptions, platformFitRunning, platformFitProgress,
|
|
6704
7194
|
platformFitLast, platformFitApplied, changePhysicsOptions, runAutoPhysics, showPhysicsPreview,
|
|
6705
7195
|
applyPhysicsPreview, cancelPhysicsPreview, ikEditTool, setIkEditTool, showTrails,
|
|
6706
|
-
setShowTrails, trailFalloffS, setTrailFalloffS, trailEdit, runTrailRegeneration,
|
|
7196
|
+
setShowTrails, trailFalloffS, setTrailFalloffS, trailEdit, trailTrackFocus, selectTrailTrack, clearTrailTrackFocus, runTrailRegeneration,
|
|
7197
|
+
pendingIkEdit, applyPendingIkEdit, cancelPendingIkEdit,
|
|
6707
7198
|
trailReadinessState, rangePins, rangePinResiduals, rangePinSelection, rangePinPartPick,
|
|
6708
7199
|
rangePinPreview, setRangePinSelection, setRangePinPartPick, previewRangePinDraft, applyRangePinDraft,
|
|
6709
7200
|
deleteRangePin, hasEnvSheet, environment, style, environmentImage,
|
|
@@ -6725,10 +7216,11 @@ export default function App() {
|
|
|
6725
7216
|
addActiveCranePoint, deleteSelectedCranePoint, setCraneSelectedIndex, tlFps, ghostLayers,
|
|
6726
7217
|
pathSpeed, tlPlaying, pendingWaypointFrame, stateBadge, applyMotionTrim,
|
|
6727
7218
|
resetMotionTrim, cutMotionAtPlayhead, changeMotionSegmentSpeed, removeMotionSegmentById, bodyContact,
|
|
6728
|
-
activeShotIdx, railDraw, pathDraw, setPathDraw, setRailDraw,
|
|
6729
|
-
setWorkspaceLayout, timingTokenRef, railCurve, ikAddKeyframe, ikDeleteKeyframe,
|
|
7219
|
+
activeShotIdx, railDraw, pathDraw, pathTarget, setPathDraw, setRailDraw,
|
|
7220
|
+
setWorkspaceLayout, timingTokenRef, railCurve, ikAddKeyframe, ikDeleteKeyframe, pathPointIndex, setPathPointIndex,
|
|
6730
7221
|
setBodyContact, setFootSnap, advanceFrame, stepFrame, cameraPreviewEndRef,
|
|
6731
|
-
manualCameraOverrideRef, setTlPlaying,
|
|
7222
|
+
manualCameraOverrideRef, setTlPlaying, setWaypointMode, selectActiveCharacterInHierarchy,
|
|
7223
|
+
toggleWaypointMode: storyboardProject ? undefined : toggleWaypointMode,
|
|
6732
7224
|
setActiveWaypointId, setPendingWaypointFrame, removeWaypoint, queueRootWaypointFrame, revealPromptBlocks,
|
|
6733
7225
|
resizePromptClip, movePromptClip, removePromptClip, setSelectedHierarchyId, addCameraKeyframe,
|
|
6734
7226
|
moveCameraKeyframe, removeCameraKeyframe, syncActiveCameraFraming, activeCamera, activeShotDuration,
|
|
@@ -6750,7 +7242,7 @@ export default function App() {
|
|
|
6750
7242
|
return (
|
|
6751
7243
|
<AppContext.Provider value={appContext}>
|
|
6752
7244
|
<StudioShellContext.Provider value={shellContext}>
|
|
6753
|
-
<StudioShell className={"app" + (renderActive ? "" : " render-idle")} style={workspaceStyle} data-workflow-mode={workflowMode} data-embed-mode={embedMode ? "playview" : playgroundMode ? "playground" : undefined} data-playground-hint={playgroundMode ? playgroundHint ?? undefined : undefined} data-tutorial-step={cameraTutorial ? cameraTutorialStep ?? undefined : undefined} data-rail-draw={railDraw ? 1 : undefined} data-shot-look={lookThroughShot && !embedMode ? 1 : undefined} data-plan-draw={railDraw || pathDraw || waypointMode ? 1 : undefined}
|
|
7245
|
+
<StudioShell className={"app" + (renderActive ? "" : " render-idle")} style={workspaceStyle} data-workflow-mode={workflowMode} data-previs-mode={scenesDomain.previsMode} data-embed-mode={embedMode ? "playview" : playgroundMode ? "playground" : undefined} data-playground-hint={playgroundMode ? playgroundHint ?? undefined : undefined} data-tutorial-step={cameraTutorial ? cameraTutorialStep ?? undefined : undefined} data-rail-draw={railDraw ? 1 : undefined} data-shot-look={lookThroughShot && !embedMode ? 1 : undefined} data-plan-draw={railDraw || pathDraw || waypointMode ? 1 : undefined}
|
|
6754
7246
|
viewport={
|
|
6755
7247
|
<div className="viewport" data-drop={viewportDrop.over ? "over" : undefined} {...viewportDrop.handlers}
|
|
6756
7248
|
onWheel={(event) => {
|
|
@@ -6761,6 +7253,12 @@ export default function App() {
|
|
|
6761
7253
|
}
|
|
6762
7254
|
}}>
|
|
6763
7255
|
<ViewportToolbar />
|
|
7256
|
+
{sculptMode && !sculptTarget && (
|
|
7257
|
+
<div className="vp-sculpt-hint" role="status" data-testid="sculpt-empty">
|
|
7258
|
+
<strong>{ko("New sculpt", "새 조형물")}</strong>
|
|
7259
|
+
<span>{ko("Add a reference image in the Sculpt panel and press Start — or select a sculpt and press 5.", "조형 패널에 참조 이미지를 넣고 시작을 누르세요 — 또는 조형물을 선택하고 5를 누르세요.")}</span>
|
|
7260
|
+
</div>
|
|
7261
|
+
)}
|
|
6764
7262
|
|
|
6765
7263
|
{/* Sits under the mode tabs and left of the Top-View inset, over the
|
|
6766
7264
|
stage it is teaching. The overlay itself never takes the pointer
|
|
@@ -6770,6 +7268,9 @@ export default function App() {
|
|
|
6770
7268
|
key={cameraTutorialAttempt}
|
|
6771
7269
|
analytics={cameraTutorialAnalytics.current}
|
|
6772
7270
|
previewing={lookThroughShot}
|
|
7271
|
+
previsMode={scenesDomain.previsMode}
|
|
7272
|
+
shotCount={shots.length}
|
|
7273
|
+
railReady={shots.some((shot) => Array.isArray(shot.camera?.cameraRail) && shot.camera.cameraRail.length >= 2)}
|
|
6773
7274
|
onStepChange={setCameraTutorialStep}
|
|
6774
7275
|
onComplete={() => { cameraTutorialCompletedRef.current = true; cameraTutorialHandoff?.complete(); }}
|
|
6775
7276
|
onClose={() => closeCameraTutorial()}
|
|
@@ -6834,7 +7335,7 @@ export default function App() {
|
|
|
6834
7335
|
<KeyLightPuck
|
|
6835
7336
|
keyLight={keyLight}
|
|
6836
7337
|
selected={keyLightSelected}
|
|
6837
|
-
visible={!preview && !lookThroughShot}
|
|
7338
|
+
visible={!preview && !lookThroughShot && !sculptMode}
|
|
6838
7339
|
paneRef={mainPaneRef}
|
|
6839
7340
|
camRef={editorCamRef}
|
|
6840
7341
|
onSelect={() => selectHierarchy("light")}
|
|
@@ -6846,12 +7347,14 @@ export default function App() {
|
|
|
6846
7347
|
{gridView ? <GridFloor layer={GIZMO_LAYER} /> : <Room light={uiTheme === "light"} />}
|
|
6847
7348
|
<SetProps
|
|
6848
7349
|
objects={stageSceneObjects}
|
|
7350
|
+
authoredObjects={sceneObjects}
|
|
6849
7351
|
selectedId={selectedSceneObjectId}
|
|
6850
7352
|
frameRef={propFrameRef}
|
|
6851
7353
|
take={{ frameCount: tlFrameCount, fps: tlFps }}
|
|
6852
7354
|
attachFrameRef={attachFrameRef}
|
|
6853
7355
|
syncRef={propSyncRef}
|
|
6854
7356
|
worldRef={propWorldRef}
|
|
7357
|
+
fadeOpacity={workflowMode === "pose" ? poseObjectOpacity : null}
|
|
6855
7358
|
/>
|
|
6856
7359
|
|
|
6857
7360
|
<PerspectiveCamera
|
|
@@ -6867,7 +7370,7 @@ export default function App() {
|
|
|
6867
7370
|
operator's own view of the set. */}
|
|
6868
7371
|
<PerspectiveCamera
|
|
6869
7372
|
ref={editorCamRef}
|
|
6870
|
-
makeDefault={!ikMode && !lookThroughShot}
|
|
7373
|
+
makeDefault={!ikMode && !lookThroughShot && !sculptMode}
|
|
6871
7374
|
fov={55}
|
|
6872
7375
|
near={0.1}
|
|
6873
7376
|
far={100}
|
|
@@ -6892,21 +7395,47 @@ export default function App() {
|
|
|
6892
7395
|
position={[0, 24, 0]}
|
|
6893
7396
|
rotation={[-Math.PI / 2, 0, 0]}
|
|
6894
7397
|
/>
|
|
7398
|
+
{/* Sculpt mode's own orbit camera and turntable; it owns the main
|
|
7399
|
+
pane (DualRender's editor slot) while the shot preview keeps
|
|
7400
|
+
drawing the whole scene. Unmounting restores normal rendering. */}
|
|
7401
|
+
{sculptMode && (
|
|
7402
|
+
<SculptTurntable
|
|
7403
|
+
camRef={sculptCamRef}
|
|
7404
|
+
objects={sceneObjects}
|
|
7405
|
+
target={sculptTarget}
|
|
7406
|
+
size={sculptTarget ? objectSize(sculptTarget) : null}
|
|
7407
|
+
view={sculptView}
|
|
7408
|
+
light={uiTheme === "light"}
|
|
7409
|
+
busy={["measure", "draft", "score", "refine"].includes(sculptRun?.stage)}
|
|
7410
|
+
changedParts={sculptRun?.changedParts ?? null}
|
|
7411
|
+
/>
|
|
7412
|
+
)}
|
|
6895
7413
|
|
|
6896
7414
|
{characterViews.map((view) => (
|
|
6897
|
-
|
|
7415
|
+
// Always wrapped, grouped or not: giving or taking a parent
|
|
7416
|
+
// must not remount the rig the playback effects hold.
|
|
7417
|
+
<ObjectCarrier
|
|
6898
7418
|
key={`${view.id}:${rigMountEpoch}`}
|
|
6899
|
-
|
|
6900
|
-
|
|
6901
|
-
|
|
6902
|
-
|
|
6903
|
-
|
|
6904
|
-
|
|
6905
|
-
|
|
6906
|
-
|
|
6907
|
-
|
|
6908
|
-
|
|
6909
|
-
|
|
7419
|
+
objectId={view.parent}
|
|
7420
|
+
objectsRef={sceneObjectLookupRef}
|
|
7421
|
+
frameRef={propFrameRef}
|
|
7422
|
+
take={{ frameCount: tlFrameCount, fps: tlFps }}
|
|
7423
|
+
registryRef={carrierRegistryRef}
|
|
7424
|
+
>
|
|
7425
|
+
<Character
|
|
7426
|
+
url={view.url}
|
|
7427
|
+
{...{ model: view.model, posture: view.posture }}
|
|
7428
|
+
position={view.position}
|
|
7429
|
+
rot={view.rot}
|
|
7430
|
+
tint={view.tint}
|
|
7431
|
+
partColoursEnabled={view.partColoursEnabled}
|
|
7432
|
+
partColoursMode={view.partColoursMode}
|
|
7433
|
+
pose={view.pose}
|
|
7434
|
+
scale={view.scale}
|
|
7435
|
+
onRig={view.onRig}
|
|
7436
|
+
pickId={view.pickId}
|
|
7437
|
+
/>
|
|
7438
|
+
</ObjectCarrier>
|
|
6910
7439
|
))}
|
|
6911
7440
|
|
|
6912
7441
|
{/* Selection marker: XYZ tripod + ring on the picked cast
|
|
@@ -6919,7 +7448,7 @@ export default function App() {
|
|
|
6919
7448
|
objects={characterGizmoObject ? [characterGizmoObject] : []}
|
|
6920
7449
|
mode={gizmoMode}
|
|
6921
7450
|
snap={snapEnabled}
|
|
6922
|
-
enabled={!planIsMain && !posing && !ikMode && !playMode && !!characterGizmoObject}
|
|
7451
|
+
enabled={!planIsMain && !posing && !ikMode && !playMode && !sculptMode && !!characterGizmoObject}
|
|
6923
7452
|
paneRef={mainPaneRef}
|
|
6924
7453
|
camRef={lookThroughShot ? shotCamRef : editorCamRef}
|
|
6925
7454
|
shotAspect={lookThroughShot ? shotOutput.aspect : null}
|
|
@@ -6978,6 +7507,7 @@ export default function App() {
|
|
|
6978
7507
|
// editorial camera keys resume when the block returns to Keys mode.
|
|
6979
7508
|
following={!followCamActive && hasCameraKeys && (preview || (moveFollow && !ikMode && !waypointMode && !posing))}
|
|
6980
7509
|
followFrame={tlFrame}
|
|
7510
|
+
frameRef={propFrameRef}
|
|
6981
7511
|
fps={tlFps}
|
|
6982
7512
|
keys={cameraKeys}
|
|
6983
7513
|
shots={shots}
|
|
@@ -6990,6 +7520,7 @@ export default function App() {
|
|
|
6990
7520
|
<FollowCamRig
|
|
6991
7521
|
enabled={followCamActive && !movePlaying}
|
|
6992
7522
|
frame={tlFrame}
|
|
7523
|
+
frameRef={propFrameRef}
|
|
6993
7524
|
scene={playbackScene}
|
|
6994
7525
|
shot={activeShot}
|
|
6995
7526
|
camRef={shotCamRef}
|
|
@@ -7001,7 +7532,7 @@ export default function App() {
|
|
|
7001
7532
|
pointerdowns that hit a handle, so empty-space drags orbit
|
|
7002
7533
|
and the wheel dollies without wrecking the framing. */}
|
|
7003
7534
|
<FlyControls
|
|
7004
|
-
enabled={!posing && !playMode}
|
|
7535
|
+
enabled={!posing && !playMode && !sculptMode}
|
|
7005
7536
|
cameraLocked={lookThroughShot && i2vMotionCameraLocked}
|
|
7006
7537
|
camRef={ikMode ? poserCamRef : lookThroughShot ? shotCamRef : editorCamRef}
|
|
7007
7538
|
look={ikMode ? poserLook : lookThroughShot ? look : editorLook}
|
|
@@ -7023,6 +7554,7 @@ export default function App() {
|
|
|
7023
7554
|
markSemanticEdit("pose", before, after);
|
|
7024
7555
|
setPoseTick((n) => n + 1);
|
|
7025
7556
|
}}
|
|
7557
|
+
onCommit={commitPosedPose}
|
|
7026
7558
|
/>
|
|
7027
7559
|
<IkHandles
|
|
7028
7560
|
chains={ikChains}
|
|
@@ -7053,6 +7585,7 @@ export default function App() {
|
|
|
7053
7585
|
look={look}
|
|
7054
7586
|
fovDeg={fovDeg}
|
|
7055
7587
|
characters={characters}
|
|
7588
|
+
characterPlacements={characterViews}
|
|
7056
7589
|
onMoveCharacter={moveCharacter}
|
|
7057
7590
|
onCameraGestureStart={beginCameraFramingGesture}
|
|
7058
7591
|
pathStart={activeChar}
|
|
@@ -7076,33 +7609,30 @@ export default function App() {
|
|
|
7076
7609
|
cameraRailPoints={railCurve ? railCurve.points : null}
|
|
7077
7610
|
railDraw={railDraw}
|
|
7078
7611
|
pathDraw={pathDraw}
|
|
7079
|
-
|
|
7612
|
+
objectPath={routeOwner?.path ?? null}
|
|
7080
7613
|
objectPathSelectedIndex={pathPointIndex}
|
|
7081
7614
|
onObjectPathPointSelect={setPathPointIndex}
|
|
7082
|
-
|
|
7083
|
-
const path =
|
|
7615
|
+
onObjectPathChange={(patch) => {
|
|
7616
|
+
const path = routeOwner?.path;
|
|
7084
7617
|
if (!path) return;
|
|
7085
|
-
//
|
|
7086
|
-
// the
|
|
7087
|
-
|
|
7088
|
-
changeSceneObject(
|
|
7618
|
+
// Inside the gesture's own transaction (planPathTokenRef), like
|
|
7619
|
+
// the 3D view. The board bends the floor route only; a point's
|
|
7620
|
+
// height is the scene's business, so y rides through untouched.
|
|
7621
|
+
changeSceneObject(routeOwner.id, { path: { ...path, ...patch } }, planPathTokenRef.current);
|
|
7089
7622
|
}}
|
|
7090
|
-
|
|
7091
|
-
const path =
|
|
7092
|
-
if (!path
|
|
7093
|
-
|
|
7094
|
-
|
|
7095
|
-
|
|
7096
|
-
|
|
7097
|
-
|
|
7098
|
-
z: a.z + (b.z - a.z) * t,
|
|
7099
|
-
};
|
|
7100
|
-
const points = [...path.points.slice(0, index + 1), inserted, ...path.points.slice(index + 1)];
|
|
7623
|
+
onObjectPathMarkInsert={(t) => {
|
|
7624
|
+
const path = routeOwner?.path;
|
|
7625
|
+
if (!path) return;
|
|
7626
|
+
// The new dot is born with the lean the route already has there,
|
|
7627
|
+
// and it goes to the record that owns the route (the selected
|
|
7628
|
+
// group may only contain it).
|
|
7629
|
+
const added = insertPathMark(path, t);
|
|
7630
|
+
if (!added) return;
|
|
7101
7631
|
const token = beginSceneTransaction({ owner: "object-path", cancel: () => {} });
|
|
7102
|
-
changeSceneObject(
|
|
7632
|
+
changeSceneObject(routeOwner.id, { path: { ...path, marks: added.marks } }, token);
|
|
7103
7633
|
endSceneTransaction(token, { commit: true });
|
|
7104
|
-
setPathPointIndex(index
|
|
7105
|
-
setToast(ko("
|
|
7634
|
+
setPathPointIndex(added.index);
|
|
7635
|
+
setToast(ko("Dot added — drag it to bend the route", "점을 추가했어요 — 끌어서 경로를 휘세요"));
|
|
7106
7636
|
}}
|
|
7107
7637
|
onObjectPathGestureStart={() => {
|
|
7108
7638
|
planPathTokenRef.current = beginSceneTransaction({ owner: "object-path", cancel: () => { planPathTokenRef.current = null; } });
|
|
@@ -7115,16 +7645,22 @@ export default function App() {
|
|
|
7115
7645
|
keyLight={keyLight}
|
|
7116
7646
|
onRailStroke={shotsDomain.drawCameraRail}
|
|
7117
7647
|
onPathStroke={(stroke) => {
|
|
7118
|
-
if (!
|
|
7119
|
-
//
|
|
7120
|
-
// operator adds the
|
|
7121
|
-
//
|
|
7648
|
+
if (!pathTarget) return;
|
|
7649
|
+
// The stroke keeps its shape, as the camera rail's does; the
|
|
7650
|
+
// operator adds the dots they want to hold by double-clicking
|
|
7651
|
+
// the line. The route starts at the height
|
|
7652
|
+
// the object stands at, so a raised body (a chassis on its
|
|
7653
|
+
// wheels, a prop on a table) travels where it is instead of
|
|
7654
|
+
// dropping to the floor; changing height comes later, from
|
|
7122
7655
|
// dragging a point in the scene.
|
|
7123
|
-
const
|
|
7656
|
+
const height = pathTarget.attach ? 0 : pathTarget.y ?? 0;
|
|
7657
|
+
const points = strokeToPathPoints(stroke, simplifyStroke).map((point) => ({ ...point, y: height }));
|
|
7124
7658
|
if (points.length < 2) return;
|
|
7125
7659
|
const token = beginSceneTransaction({ owner: "object-path", cancel: () => {} });
|
|
7126
|
-
|
|
7660
|
+
// A new stroke is a new shape: marks belong to the old one.
|
|
7661
|
+
changeSceneObject(pathTarget.id, { path: { ...(pathTarget.path ?? {}), points, marks: [] } }, token);
|
|
7127
7662
|
endSceneTransaction(token, { commit: true });
|
|
7663
|
+
setPathPointIndex(null);
|
|
7128
7664
|
setPathDraw(false);
|
|
7129
7665
|
const metrics = pathMetrics(createObjectPath({ points }));
|
|
7130
7666
|
setToast(isKo
|
|
@@ -7137,11 +7673,11 @@ export default function App() {
|
|
|
7137
7673
|
the plan owns the big pane (the pucks are the handles there)
|
|
7138
7674
|
and while posing/IK owns the pointer. */}
|
|
7139
7675
|
<ObjectGizmo
|
|
7140
|
-
object={cameraGizmoObject ?? lightGizmoObject ?? (selectedSceneObject && !isEffectivelyHidden(selectedSceneObject, sceneObjects, characters) ?
|
|
7676
|
+
object={cameraGizmoObject ?? lightGizmoObject ?? (selectedSceneObject && !isEffectivelyHidden(selectedSceneObject, sceneObjects, characters) ? gizmoSceneObject : null)}
|
|
7141
7677
|
objects={sceneObjects}
|
|
7142
7678
|
mode={lightGizmoObject ? "move" : cameraGizmoObject ? (gizmoMode === "scale" ? "move" : gizmoMode) : gizmoMode}
|
|
7143
7679
|
snap={snapEnabled}
|
|
7144
|
-
enabled={!planIsMain && !posing && !ikMode && !playMode}
|
|
7680
|
+
enabled={!planIsMain && !posing && !ikMode && !playMode && !sculptMode}
|
|
7145
7681
|
paneRef={mainPaneRef}
|
|
7146
7682
|
camRef={lookThroughShot ? shotCamRef : editorCamRef}
|
|
7147
7683
|
shotAspect={lookThroughShot ? shotOutput.aspect : null}
|
|
@@ -7171,15 +7707,19 @@ export default function App() {
|
|
|
7171
7707
|
/>
|
|
7172
7708
|
)}
|
|
7173
7709
|
<ObjectPathHandles
|
|
7174
|
-
path={
|
|
7710
|
+
path={routeOwner?.path ?? null}
|
|
7175
7711
|
selectedIndex={pathPointIndex}
|
|
7176
|
-
|
|
7712
|
+
mode={gizmoMode}
|
|
7713
|
+
enabled={!preview && !lookThroughShot && !ikMode && !posing && !!routeOwner?.path}
|
|
7177
7714
|
paneRef={mainPaneRef}
|
|
7178
7715
|
camRef={editorCamRef}
|
|
7179
7716
|
onSelect={setPathPointIndex}
|
|
7180
|
-
|
|
7181
|
-
if (!
|
|
7182
|
-
|
|
7717
|
+
onChangePath={(patch) => {
|
|
7718
|
+
if (!routeOwner) return;
|
|
7719
|
+
// Inside the drag's own transaction, like the Top-View mark
|
|
7720
|
+
// move: a plain update while the transaction is open is
|
|
7721
|
+
// refused, so the dot never moved in the 3D view.
|
|
7722
|
+
changeSceneObject(routeOwner.id, { path: patch === null ? null : { ...routeOwner.path, ...patch } }, pathDragTokenRef.current);
|
|
7183
7723
|
}}
|
|
7184
7724
|
onDragStart={() => {
|
|
7185
7725
|
pathDragTokenRef.current = beginSceneTransaction({ owner: "object-path", cancel: () => {} });
|
|
@@ -7230,6 +7770,7 @@ export default function App() {
|
|
|
7230
7770
|
baseY={activeChar.y ?? 0}
|
|
7231
7771
|
charScale={activeChar.scale ?? 1}
|
|
7232
7772
|
ikFocus={ikFocus}
|
|
7773
|
+
activeTrackId={trailTrackFocus}
|
|
7233
7774
|
falloffFrames={trailFalloffFrames}
|
|
7234
7775
|
playheadFrame={tlFrame}
|
|
7235
7776
|
pendingEdit={trailEdit}
|
|
@@ -7248,10 +7789,11 @@ export default function App() {
|
|
|
7248
7789
|
shotCamRef={shotCamRef}
|
|
7249
7790
|
planCamRef={planCamRef}
|
|
7250
7791
|
poserCamRef={poserCamRef}
|
|
7251
|
-
editorCamRef={editorCamRef}
|
|
7252
|
-
// The studio
|
|
7253
|
-
// scene as exported.
|
|
7254
|
-
|
|
7792
|
+
editorCamRef={sculptMode ? sculptCamRef : editorCamRef}
|
|
7793
|
+
// The studio and the landing playground hide the facing marks on
|
|
7794
|
+
// screen; the Workflow embed shows the scene as exported. Captures
|
|
7795
|
+
// render outside this loop and keep them everywhere.
|
|
7796
|
+
editorLook={!embedMode}
|
|
7255
7797
|
ikMode={ikMode}
|
|
7256
7798
|
planIsMain={planIsMain}
|
|
7257
7799
|
// Preview IS PlayView's render path: DualRender tests this branch
|
|
@@ -7260,7 +7802,7 @@ export default function App() {
|
|
|
7260
7802
|
// shot camera in the editing draw instead.
|
|
7261
7803
|
playMode={preview}
|
|
7262
7804
|
lookThrough={lookThroughShot}
|
|
7263
|
-
insetCollapsed={workspaceLayout.insetCollapsed || workflowMode === "motion" || workflowMode === "pose"}
|
|
7805
|
+
insetCollapsed={workspaceLayout.insetCollapsed || workflowMode === "motion" || workflowMode === "pose" || workflowMode === "sculpt"}
|
|
7264
7806
|
planZoom={workspaceLayout.planZoom}
|
|
7265
7807
|
shotAspect={shotOutput.aspect}
|
|
7266
7808
|
/>
|
|
@@ -7455,13 +7997,13 @@ export default function App() {
|
|
|
7455
7997
|
openProject();
|
|
7456
7998
|
}}
|
|
7457
7999
|
starters={STARTER_SCENES}
|
|
7458
|
-
onStarter={(id) => {
|
|
8000
|
+
onStarter={(id, name) => {
|
|
7459
8001
|
setProjectBrowserOpen(false);
|
|
7460
|
-
void openStarterScene(id);
|
|
8002
|
+
void openStarterScene(id, "starter", name);
|
|
7461
8003
|
}}
|
|
7462
|
-
onNew={() => {
|
|
8004
|
+
onNew={(name, options) => {
|
|
7463
8005
|
setProjectBrowserOpen(false);
|
|
7464
|
-
requestNewProject();
|
|
8006
|
+
requestNewProject(false, options);
|
|
7465
8007
|
}}
|
|
7466
8008
|
onClose={() => {
|
|
7467
8009
|
setProjectBrowserOpen(false);
|
|
@@ -7479,7 +8021,7 @@ export default function App() {
|
|
|
7479
8021
|
else newProject(name);
|
|
7480
8022
|
}}
|
|
7481
8023
|
/>
|
|
7482
|
-
<FirstSuccessGuide open={firstSuccessGuideOpen} onDismiss={() => setFirstSuccessGuideOpen(false)} />
|
|
8024
|
+
<FirstSuccessGuide open={firstSuccessGuideOpen} previsMode={scenesDomain.previsMode} onDismiss={() => setFirstSuccessGuideOpen(false)} />
|
|
7483
8025
|
<UseCaseQuestion
|
|
7484
8026
|
open={useCaseAskOpen}
|
|
7485
8027
|
isKo={isKo}
|