cozyclay 2.0.2 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/README.md +17 -2
- package/THIRD_PARTY_NOTICES.md +8 -0
- package/bin/agent/agent-routes.mjs +211 -36
- package/bin/agent/agent-runner.mjs +98 -4
- package/bin/agent/codex-client.mjs +2 -1
- package/bin/agent/fal-contract.mjs +0 -17
- package/bin/agent/h3-preservation.mjs +6 -8
- package/bin/agent/provider-keys.mjs +1 -1
- package/bin/agent/providers.mjs +124 -3
- package/bin/agent/script-tool.mjs +74 -0
- package/bin/agent/session-store.mjs +5 -5
- package/bin/agent/studio-prompt.mjs +2 -1
- package/bin/agent/studio-rebase.mjs +314 -0
- package/bin/agent/studio-tools.mjs +21 -14
- package/bin/agent/supervisor.mjs +311 -0
- package/bin/agent/video-adapters.mjs +0 -7
- package/bin/codex-auth.mjs +4 -4
- package/bin/live/cli.mjs +23 -8
- package/bin/live/client.mjs +1 -1
- package/bin/live/studio.mjs +5 -2
- package/bin/mcp-runtime.mjs +2 -2
- package/bin/telemetry-state.mjs +3 -3
- package/dist/app/index.html +6 -6
- package/dist/assets/analytics-C9pMpW7s.js +1 -0
- package/dist/assets/app-B_2XB9rw.css +1 -0
- package/dist/assets/app-DAuvKPP8.js +4920 -0
- package/dist/assets/{demo-ByeYp-dY.js → demo-CvKuBgy5.js} +1 -1
- package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-msLI71g1.js} +1 -1
- package/dist/assets/{landing-BaXkpFip.js → landing-ChUvB_zL.js} +1 -1
- package/dist/assets/scene-asset-sync-DKkpjKBV.js +19 -0
- package/dist/assets/{shot-prompt-CbqWcWYi.css → scene-asset-sync-DjJ_cJ2R.css} +1 -1
- package/dist/assets/{ticket-BcmsnHn5.js → ticket-BPk2Ni95.js} +1 -1
- package/dist/assets/workflow-jJRBR94Z.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +328 -222
- package/dist/llms.txt +14 -0
- package/dist/sitemap.xml +1 -1
- package/dist/workflow/index.html +4 -4
- package/mcp/LIVE-PROTOCOL.md +23 -2
- package/mcp/README.md +37 -2
- package/mcp/ardy-prompts.mjs +1 -1
- package/mcp/image-file.mjs +132 -0
- package/mcp/live-hub.mjs +2 -2
- package/mcp/package.json +1 -0
- package/mcp/runtime/package-lock.json +7 -0
- package/mcp/runtime/package.json +1 -0
- package/mcp/server.mjs +0 -6
- package/mcp/tool-handlers.mjs +289 -87
- package/package.json +11 -2
- package/src/App.jsx +813 -278
- package/src/agent-image-client.js +94 -0
- package/src/analytics.js +47 -3
- package/src/app-stage.jsx +129 -2745
- package/src/ardy/auto-physics.js +6 -16
- package/src/ardy/collision-blockers.js +3 -7
- package/src/ardy/fix-collisions.js +5 -35
- package/src/ardy/ik-drag.js +86 -5
- package/src/ardy/ik-key-json.js +3 -1
- package/src/ardy/ik.js +15 -13
- package/src/ardy/motion-calibration.js +1 -1
- package/src/ardy/npz.js +0 -8
- package/src/ardy/physics-panel.jsx +3 -2
- package/src/ardy/physics-review.js +3 -2
- package/src/ardy/physics-support.js +1 -1
- package/src/ardy/physics-surface.js +1 -1
- package/src/ardy/platform-fit-panel.jsx +2 -1
- package/src/ardy/playback.js +1 -5
- package/src/ardy/range-pin.js +2 -2
- package/src/ardy/root-drop.js +0 -1
- package/src/ardy/timeline.css +184 -6
- package/src/ardy/timeline.jsx +121 -36
- package/src/ardy/to-cskel27.js +0 -2
- package/src/ardy/waypoints.js +0 -3
- package/src/ardy-bridge-contract.js +281 -0
- package/src/asset-pane.jsx +19 -9
- package/src/auto-color.js +1 -1
- package/src/burn-in.js +1 -1
- package/src/camera-block.js +1 -1
- package/src/camera-follow.js +75 -32
- package/src/camera-move.js +0 -6
- package/src/camera-tutorial.jsx +6 -7
- package/src/character-kind.js +79 -0
- package/src/command-bus.js +76 -17
- package/src/commands/cast.js +31 -7
- package/src/commands/elements/object.js +1 -1
- package/src/commands/elements/shot.js +1 -1
- package/src/commands/elements.js +1 -1
- package/src/commands/export.js +3 -3
- package/src/commands/motion.js +2 -2
- package/src/commands/objects.js +105 -10
- package/src/commands/project.js +15 -2
- package/src/commands/sculpt-images.js +53 -0
- package/src/commands/shot.js +88 -10
- package/src/commands/view.js +10 -4
- package/src/coplanar-depth.js +216 -0
- package/src/cuts.js +7 -6
- package/src/domains/cast.js +60 -43
- package/src/domains/motion/motion-intent-codec.js +50 -0
- package/src/domains/motion/motion-range-pin-rules.js +23 -0
- package/src/domains/motion/motion-video-capture.js +664 -0
- package/src/domains/motion.js +253 -742
- package/src/domains/objects.js +106 -5
- package/src/domains/scenes.js +79 -28
- package/src/domains/shots.js +33 -7
- package/src/error-boundary.jsx +1 -1
- package/src/execution-telemetry.js +5 -5
- package/src/facing-marks.js +14 -1
- package/src/first-success-guide.jsx +35 -4
- package/src/gizmo-claim.js +3 -1
- package/src/grid-floor.jsx +1 -4
- package/src/hierarchy-model.js +48 -35
- package/src/hierarchy-panel.css +1 -0
- package/src/hierarchy-panel.jsx +54 -102
- package/src/history.js +0 -2
- package/src/i2v-motion-client.js +1 -4
- package/src/ime.js +6 -0
- package/src/keyframe-pack.js +15 -6
- package/src/line-edit.js +4 -4
- package/src/live-control.js +6 -6
- package/src/matte-editor.js +2 -8
- package/src/matte.js +0 -19
- package/src/mesh-graph-clone.js +16 -2
- package/src/mesh-sniff.js +0 -1
- package/src/monotone-cubic.js +40 -0
- package/src/motion-store.js +4 -4
- package/src/motion-trail.js +5 -11
- package/src/multimodel-ingest.js +1 -3
- package/src/object-catalog.jsx +7 -5
- package/src/object-gizmo.jsx +45 -31
- package/src/object-path.js +310 -67
- package/src/object-travel.js +224 -0
- package/src/otio.js +8 -3
- package/src/panels/CharacterTransformPanel.jsx +24 -6
- package/src/panels/ObjectTransformPanel.jsx +4 -3
- package/src/panels/PoseImagePanel.jsx +205 -0
- package/src/panels/PromptBlocksPanel.jsx +3 -1
- package/src/panels/ProxyFigurePanel.jsx +56 -0
- package/src/panels/ReferenceImageField.jsx +1 -1
- package/src/panels/RigControlPanel.jsx +24 -3
- package/src/panels/RigPanel.jsx +2 -1
- package/src/panels/SculptPanel.jsx +405 -0
- package/src/panels/SubjectBox.jsx +1 -1
- package/src/panels/SubjectsPanel.jsx +0 -2
- package/src/panels/VideoCapturePanel.jsx +2 -1
- package/src/panels/pose-image.css +42 -0
- package/src/panels/pose.css +3 -0
- package/src/panels/sculpt.css +162 -0
- package/src/plan-diagram.js +184 -0
- package/src/planview.jsx +127 -80
- package/src/playground.js +3 -5
- package/src/pose-fade.js +77 -0
- package/src/pose-gizmo.css +23 -0
- package/src/pose-gizmo.js +96 -0
- package/src/posestudio.jsx +134 -83
- package/src/project-browser.css +53 -0
- package/src/project-browser.jsx +49 -4
- package/src/project.js +55 -24
- package/src/props.jsx +177 -36
- package/src/proxy-figure.jsx +69 -0
- package/src/range-pin-panel.jsx +0 -1
- package/src/receipt-findings.js +20 -0
- package/src/render-passes.js +0 -1
- package/src/resource-status.jsx +1 -1
- package/src/root-path.js +48 -0
- package/src/route-owner.js +52 -0
- package/src/scene-asset-cache.js +1 -2
- package/src/scene-assets.js +12 -4
- package/src/scene-fbx.js +1 -1
- package/src/scene-mesh-cache.js +0 -1
- package/src/scene-objects.js +319 -19
- package/src/scenes.js +58 -9
- package/src/sculpt-blueprint.js +65 -0
- package/src/sculpt-compare.js +384 -0
- package/src/sculpt-geometry.js +141 -0
- package/src/sculpt-mode.js +168 -0
- package/src/sculpt-recipe.js +385 -0
- package/src/sculpt-run.js +351 -0
- package/src/sculpt-stage.jsx +221 -0
- package/src/settings-menu.jsx +1 -1
- package/src/shell/BoardSlot.jsx +341 -0
- package/src/shell/BottomDock.jsx +60 -16
- package/src/shell/CompositionGuideButton.jsx +20 -0
- package/src/shell/DetailsSlot.jsx +26 -6
- package/src/shell/MenuBar.jsx +78 -6
- package/src/shell/OutlinerSlot.jsx +3 -1
- package/src/shell/ShotPreviewHeader.jsx +17 -0
- package/src/shell/TopBar.jsx +18 -3
- package/src/shell/ViewportToolbar.jsx +36 -31
- package/src/shell/dock.css +239 -0
- package/src/shell/glass-regions.css +3 -2
- package/src/shell/log-store.js +4 -4
- package/src/shell/mode.css +20 -0
- package/src/shell/studio-shell-context.js +4 -1
- package/src/shell/viewport.css +12 -0
- package/src/shot-authoring.js +69 -6
- package/src/stable-items.js +1 -1
- package/src/stage-attach-frames.js +169 -0
- package/src/stage-canvas-rigs.jsx +402 -0
- package/src/stage-capture.jsx +216 -0
- package/src/stage-characters.jsx +302 -0
- package/src/stage-editor-gizmos.jsx +1229 -0
- package/src/stage-labels-ko.js +204 -0
- package/src/stage-startup-defaults.js +182 -0
- package/src/starter-scenes.js +1 -1
- package/src/storyboard.js +24 -5
- package/src/studio-actions.js +42 -9
- package/src/studio-agent-commands.js +213 -23
- package/src/studio-agent-context.js +11 -9
- package/src/studio-agent-motion.js +2 -1
- package/src/studio-agent-protocol.js +60 -24
- package/src/studio-app-binding.js +56 -23
- package/src/studio-contact-sheet.js +3 -3
- package/src/studio-elements.js +11 -2
- package/src/studio-geometry-facts.js +114 -0
- package/src/styles.css +92 -9
- package/src/ui-scrub.js +4 -4
- package/src/ui.jsx +6 -4
- package/src/use-asset-shelf.js +60 -0
- package/src/use-camera-tutorial.js +50 -0
- package/src/use-embed-bridge.js +12 -0
- package/src/use-first-success-prompt.js +7 -0
- package/src/use-i2v-capture-modal.js +15 -0
- package/src/use-sculpt-mode.js +95 -0
- package/src/workflow/AgentPanel.jsx +106 -18
- package/src/workflow/WorkflowBuilder.jsx +1 -1
- package/src/workflow/agent-client.js +113 -45
- package/src/workflow/agent-panel.css +35 -0
- package/src/workflow/attachment-image.js +1 -1
- package/src/workflow/canvas-commands.js +3 -3
- package/src/workflow/cozy-scene-node.js +3 -3
- package/src/workflow/local-workflow.js +2 -2
- package/src/workflow/motion-input.js +3 -3
- package/src/workflow/node-schema.js +1 -1
- package/src/workflow/scene-asset-sync.js +2 -9
- package/src/workflow/shot-prompt-node.js +1 -1
- package/src/workflow/video-contract.js +1 -1
- package/src/workflow/workflow-resources.js +0 -8
- package/src/workflow-send.js +87 -0
- package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
- package/tools/check-eslint.mjs +57 -0
- package/tools/check-knip.mjs +60 -0
- package/tools/check-loc.mjs +54 -0
- package/tools/demo-worker/api-client.mjs +7 -13
- package/tools/demo-worker/generate.mjs +2 -4
- package/tools/eslint-baseline.json +23 -0
- package/tools/gt-render/browser.mjs +23 -0
- package/tools/headless-studio.mjs +85 -0
- package/tools/kimodo/effector-constraints.mjs +4 -4
- package/tools/kimodo/generate.mjs +1 -1
- package/tools/knip-baseline.json +331 -0
- package/tools/loc-baseline.json +126 -0
- package/tools/process-supervisor.mjs +1 -1
- package/tools/projflow/replay.mjs +4 -4
- package/tools/projflow/runner.mjs +3 -3
- package/tools/qa/studio-control-count.mjs +2 -0
- package/tools/run-tests.mjs +95 -7
- package/dist/assets/analytics-MVZOEW8O.js +0 -1
- package/dist/assets/app-BxBLC6GT.js +0 -4876
- package/dist/assets/app-CJdxvnrl.css +0 -1
- package/dist/assets/shot-prompt-hN0j_O-p.js +0 -17
- package/dist/assets/workflow-B5XZwVfe.js +0 -179
package/src/App.jsx
CHANGED
|
@@ -6,6 +6,7 @@ import { useScenes } from "./domains/scenes.js";
|
|
|
6
6
|
import { useStage } from "./domains/stage.js";
|
|
7
7
|
import StudioShell from "./shell/StudioShell.jsx";
|
|
8
8
|
import ViewportToolbar from "./shell/ViewportToolbar.jsx";
|
|
9
|
+
import CompositionGuideButton from "./shell/CompositionGuideButton.jsx";
|
|
9
10
|
import { StudioShellContext } from "./shell/studio-shell-context.js";
|
|
10
11
|
|
|
11
12
|
import {
|
|
@@ -48,18 +49,35 @@ import { createLiveControl, loadLiveWorkspaceId, mintLiveWorkspaceId } from "./l
|
|
|
48
49
|
import { createFirstEditTracker, semanticEditKind } from "./semantic-edit.js";
|
|
49
50
|
|
|
50
51
|
import AgentPanel from "./workflow/AgentPanel.jsx";
|
|
52
|
+
import SculptPanel from "./panels/SculptPanel.jsx";
|
|
53
|
+
import { createAgentPort, createSculptRun } from "./sculpt-run.js";
|
|
54
|
+
import { SculptTurntable } from "./sculpt-stage.jsx";
|
|
55
|
+
import { measureReferenceOutline } from "./sculpt-compare.js";
|
|
56
|
+
import { BLUEPRINT_LAYOUT, buildBlueprintPrompt } from "./sculpt-blueprint.js";
|
|
57
|
+
import { browserSculptImages } from "./commands/sculpt-images.js";
|
|
58
|
+
import { sculptAgentClient, sculptModelLabel } from "./sculpt-mode.js";
|
|
59
|
+
import { useSculptMode } from "./use-sculpt-mode.js";
|
|
60
|
+
import { useFirstSuccessPrompt } from "./use-first-success-prompt.js";
|
|
61
|
+
import { useCameraTutorial } from "./use-camera-tutorial.js";
|
|
62
|
+
import { useEmbedBridge } from "./use-embed-bridge.js";
|
|
63
|
+
import { useAssetShelf } from "./use-asset-shelf.js";
|
|
64
|
+
import { useI2vCaptureModal } from "./use-i2v-capture-modal.js";
|
|
51
65
|
import { StudioProtocolError } from "./studio-agent-protocol.js";
|
|
52
66
|
import { elementByPath } from "./studio-elements.js";
|
|
53
67
|
import {
|
|
54
68
|
resolveStudioToast,
|
|
55
69
|
} from "./studio-actions.js";
|
|
56
70
|
import { createStudioAppActions } from "./commands/index.js";
|
|
71
|
+
import { STORYBOARD_MOTION_REFUSAL } from "./commands/view.js";
|
|
57
72
|
|
|
58
73
|
import { createStudioAppBinding } from "./studio-app-binding.js";
|
|
59
74
|
import { AppContext, createAppContext } from "./app-context.js";
|
|
60
75
|
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
|
|
61
76
|
|
|
62
77
|
import { PlanBoard } from "./planview.jsx";
|
|
78
|
+
import { drawPlanDiagram } from "./plan-diagram.js";
|
|
79
|
+
import { placementAt } from "./root-path.js";
|
|
80
|
+
import { proxyYaw } from "./proxy-figure.jsx";
|
|
63
81
|
import { autoColorHex, loadAutoColor } from "./auto-color.js";
|
|
64
82
|
import { DualRender, fitAspect, GIZMO_LAYER } from "./dualview.jsx";
|
|
65
83
|
import { GridFloor } from "./grid-floor.jsx";
|
|
@@ -114,10 +132,9 @@ import {
|
|
|
114
132
|
RAIL_SCHEDULE_RANGE,
|
|
115
133
|
resolveRailSchedule,
|
|
116
134
|
} from "./camera-rail-schedule.js";
|
|
117
|
-
import { SetProps } from "./props.jsx";
|
|
135
|
+
import { ObjectCarrier, SetProps } from "./props.jsx";
|
|
118
136
|
import {
|
|
119
137
|
CUTOUT_KIND,
|
|
120
|
-
MESH_KIND,
|
|
121
138
|
isEffectivelyHidden,
|
|
122
139
|
objectSize,
|
|
123
140
|
sceneObjectIdFromHierarchy,
|
|
@@ -147,10 +164,10 @@ import {
|
|
|
147
164
|
} from "./matte.js";
|
|
148
165
|
import { createMatteEditor } from "./matte-editor.js";
|
|
149
166
|
import {
|
|
150
|
-
SCENES_STORAGE_KEY,
|
|
151
167
|
SCENES_VERSION,
|
|
152
168
|
activeSceneIndex,
|
|
153
169
|
createSceneStage,
|
|
170
|
+
isProxyFigure,
|
|
154
171
|
normalizeReferenceImage,
|
|
155
172
|
} from "./scenes.js";
|
|
156
173
|
import {
|
|
@@ -184,16 +201,19 @@ import { demoSeedGate, hasLineEditCapability, motionReadiness } from "./motion-r
|
|
|
184
201
|
import { PWA_UPDATE_EVENT } from "./pwa.js";
|
|
185
202
|
import {
|
|
186
203
|
createObjectPath,
|
|
204
|
+
insertPathMark,
|
|
187
205
|
objectTransformAt,
|
|
188
206
|
pathMetrics,
|
|
189
207
|
strokeToPathPoints,
|
|
190
|
-
MAX_PATH_POINTS,
|
|
191
208
|
} from "./object-path.js";
|
|
209
|
+
import { carriedPointAt, sceneObjectsAt } from "./object-travel.js";
|
|
210
|
+
import { routeOwnerFor } from "./route-owner.js";
|
|
192
211
|
import {
|
|
193
212
|
analyticsActive,
|
|
194
213
|
exportFailureCode,
|
|
195
214
|
recordSemanticEdit,
|
|
196
215
|
recordUseCase,
|
|
216
|
+
sculptRunTelemetry,
|
|
197
217
|
shouldAskUseCase,
|
|
198
218
|
startExportAttempt,
|
|
199
219
|
track,
|
|
@@ -201,13 +221,13 @@ import {
|
|
|
201
221
|
trackFeature,
|
|
202
222
|
} from "./analytics.js";
|
|
203
223
|
import { ko, isKo } from "./locale.js";
|
|
204
|
-
import {
|
|
205
|
-
isPlaygroundEmbed,
|
|
206
|
-
} from "./playground.js";
|
|
224
|
+
import { characterCapabilities, kindRefusal, proxyFigureBounds } from "./character-kind.js";
|
|
207
225
|
import { STARTER_SCENES } from "./starter-scenes.js";
|
|
208
226
|
import { PART_COLOURS } from "./part-colours.js";
|
|
209
227
|
import {
|
|
210
228
|
DEFAULT_POSE,
|
|
229
|
+
applyHipsOffset,
|
|
230
|
+
applyPose,
|
|
211
231
|
} from "./poses.js";
|
|
212
232
|
import {
|
|
213
233
|
IkHandles,
|
|
@@ -256,15 +276,13 @@ import { exportOffscreenVideo } from "./offscreen-export.js";
|
|
|
256
276
|
import { parseRigNodeId } from "./hierarchy-model.js";
|
|
257
277
|
import { timelineContentExtent } from "./timeline-extent.js";
|
|
258
278
|
import {
|
|
259
|
-
GUIDE_LABELS,
|
|
260
279
|
guideGeometry,
|
|
261
|
-
nextGuideMode,
|
|
262
280
|
readStoredGuideMode,
|
|
263
281
|
writeStoredGuideMode,
|
|
264
282
|
} from "./shot-guides.js";
|
|
265
283
|
import { shotCaptureMeta } from "./shot-meta.js";
|
|
266
284
|
import { buildShotPrompt } from "./shot-prompt.js";
|
|
267
|
-
import { keyframePackEntries, keyframePackName } from "./keyframe-pack.js";
|
|
285
|
+
import { buildKeyframePack, keyframePackEntries, keyframePackName, slugifyTitle } from "./keyframe-pack.js";
|
|
268
286
|
import { buildZip } from "./zip-store.js";
|
|
269
287
|
import { composeStoryboard } from "./storyboard.js";
|
|
270
288
|
import { DEPTH_RANGE_M, depthRangeFromFrames, passFileName, renderPass } from "./render-passes.js";
|
|
@@ -272,12 +290,12 @@ import { DEPTH_RANGE_M, depthRangeFromFrames, passFileName, renderPass } from ".
|
|
|
272
290
|
import { motionApiOrigin, I2V_MOTION_SHOT_ASPECT } from "./i2v-motion-client.js";
|
|
273
291
|
import { serializeOtio } from "./otio.js";
|
|
274
292
|
import "./shell/mode.css";
|
|
293
|
+
import { readPoseFadeOpacity, withPoseFadeLifted, writePoseFadeOpacity } from "./pose-fade.js";
|
|
275
294
|
import {
|
|
276
295
|
shotAtFrame,
|
|
277
296
|
shotIndexAtFrame,
|
|
278
297
|
} from "./cuts.js";
|
|
279
298
|
import {
|
|
280
|
-
ASSET_DELETE_UNDO_MS,
|
|
281
299
|
BRIDGE_RECHECK_MS,
|
|
282
300
|
CameraGlide,
|
|
283
301
|
CameraRailScenePreview,
|
|
@@ -437,11 +455,14 @@ function koSubjectParticle(word) {
|
|
|
437
455
|
* A missing rig, a member with no clip and a layer with no keys are all
|
|
438
456
|
* no-ops rather than errors: characters without a take keep their pose.
|
|
439
457
|
*/
|
|
440
|
-
function poseMemberAtFrame(rig, clip, ikState, frame, blendFrames = 0) {
|
|
458
|
+
function poseMemberAtFrame(rig, clip, ikState, frame, blendFrames = 0, pose = null) {
|
|
441
459
|
if (!rig) return;
|
|
442
460
|
if (clip) {
|
|
443
461
|
const sampled = sampleAt({ frameCount: clip.frames, motion: clip }, null, frame);
|
|
444
462
|
applyMotionFrame(rig, clip, sampled.motionFrame);
|
|
463
|
+
} else if (pose) {
|
|
464
|
+
applyPose(rig, pose);
|
|
465
|
+
applyHipsOffset(rig, pose.rootY ?? 0);
|
|
445
466
|
}
|
|
446
467
|
if (ikState && ikState.keys.size > 0 && ikState.chains && ikState.rig === rig) {
|
|
447
468
|
ikEvaluate(ikState.chains, ikState, frame, ikState.fkJoints, clip ? blendFrames : 0);
|
|
@@ -483,56 +504,12 @@ export default function App() {
|
|
|
483
504
|
getBus: () => studioBindingRef.current.bus,
|
|
484
505
|
notify: (...args) => setToast(...args),
|
|
485
506
|
}));
|
|
486
|
-
const embedMode
|
|
487
|
-
|
|
488
|
-
// scene with the project chrome hidden and saving off (see playground.js).
|
|
489
|
-
const playgroundMode = isPlaygroundEmbed(globalThis.location?.search);
|
|
490
|
-
const [playgroundHint, setPlaygroundHint] = useState(null);
|
|
491
|
-
const playgroundExportRef = useRef(null);
|
|
492
|
-
// The camera tutorial (#206): the landing page's seven steps, run against
|
|
493
|
-
// the real studio instead of the playground iframe. It opens from
|
|
494
|
-
// /app/?tutorial=camera or from Settings ▾, and it is not offered inside an
|
|
495
|
-
// embed.
|
|
496
|
-
|
|
497
|
-
// Both doors go through startCameraTutorial (#209), which puts the studio in
|
|
498
|
-
// the state the landing page teaches these steps in — the city-block starter
|
|
499
|
-
// scene with the walk take on its character — so Shot / Rail / Play always
|
|
500
|
-
// have something to frame. That function is declared with the project
|
|
501
|
-
// actions further down; the listeners here reach it through a ref so they
|
|
502
|
-
// always call the current render's closure (the confirm reads projectDirty).
|
|
503
|
-
const [cameraTutorialEntry] = useState(() => {
|
|
504
|
-
const params = new URLSearchParams(globalThis.location?.search || "");
|
|
505
|
-
const query = params.get("tutorial") === "camera";
|
|
506
|
-
let firstRun = false;
|
|
507
|
-
try {
|
|
508
|
-
firstRun = !query && !params.get("scene") && !cameraTutorialSuppressed()
|
|
509
|
-
&& !loadProjectSession()?.name && !globalThis.localStorage?.getItem(SCENES_STORAGE_KEY);
|
|
510
|
-
} catch { firstRun = false; }
|
|
511
|
-
return !embedMode && !playgroundMode && !cameraTutorialSuppressed() && (query ? "query" : firstRun ? "first-run" : null);
|
|
512
|
-
});
|
|
513
|
-
const cameraTutorialQuery = Boolean(cameraTutorialEntry);
|
|
514
|
-
const [cameraTutorial, setCameraTutorial] = useState(false);
|
|
515
|
-
const cameraTutorialAnalytics = useRef(null);
|
|
516
|
-
const [cameraTutorialAttempt, setCameraTutorialAttempt] = useState(0);
|
|
517
|
-
const [cameraTutorialHandoff, setCameraTutorialHandoff] = useState(null);
|
|
518
|
-
const cameraTutorialCompletedRef = useRef(false);
|
|
519
|
-
const exportMenuTriggerRef = useRef(null);
|
|
520
|
-
const exportShotIdRef = useRef(null);
|
|
521
|
-
// The step the tutorial is on, published on the .app root so styles.css can
|
|
522
|
-
// spotlight the one control that step needs (#211).
|
|
523
|
-
const [cameraTutorialStep, setCameraTutorialStep] = useState(null);
|
|
524
|
-
const startCameraTutorialRef = useRef(null);
|
|
525
|
-
const cameraTutorialStarted = useRef(false);
|
|
526
|
-
// A tutorial restart never reloads the starter over the user's edits.
|
|
527
|
-
const tutorialStarterRef = useRef(false);
|
|
528
|
-
const tutorialLoadingRef = useRef(false);
|
|
529
|
-
const tutorialProjectEpochRef = useRef(0);
|
|
530
|
-
const tutorialSeedEpochRef = useRef(null);
|
|
531
|
-
// Armed once the starter is applied, consumed by the seed effect next to the
|
|
532
|
-
// hosted-demo seed as soon as the new character's rig exists.
|
|
533
|
-
const [tutorialSeedPending, setTutorialSeedPending] = useState(false);
|
|
507
|
+
const { embedMode, playgroundMode, playgroundHint, setPlaygroundHint, playgroundExportRef } = useEmbedBridge();
|
|
508
|
+
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 });
|
|
534
509
|
useEffect(() => {
|
|
535
510
|
if (!cameraTutorialQuery || cameraTutorialStarted.current) return;
|
|
511
|
+
// Storyboard projects never auto-open the seven-step camera tutorial.
|
|
512
|
+
if (scenesDomain.previsMode === "storyboard") return;
|
|
536
513
|
cameraTutorialStarted.current = true;
|
|
537
514
|
void startCameraTutorialRef.current?.({ source: cameraTutorialEntry });
|
|
538
515
|
}, [cameraTutorialQuery, cameraTutorialEntry]);
|
|
@@ -547,6 +524,7 @@ export default function App() {
|
|
|
547
524
|
setCameraTutorialHandoff(null);
|
|
548
525
|
return;
|
|
549
526
|
}
|
|
527
|
+
if (scenesDomain.previsMode === "storyboard") return;
|
|
550
528
|
void startCameraTutorialRef.current?.({ source: event.detail?.source ?? "settings" });
|
|
551
529
|
};
|
|
552
530
|
window.addEventListener("cozyclay:camera-tutorial", onTutorial);
|
|
@@ -795,6 +773,23 @@ export default function App() {
|
|
|
795
773
|
// bridge read this global, and the render path is still PlayView's.
|
|
796
774
|
globalThis.playMode = preview;
|
|
797
775
|
const playMode = preview;
|
|
776
|
+
// #441 moved shot authoring into commands and dropped the Shot and Rail
|
|
777
|
+
// signals the landing checklist waits for. Read them from the authored state,
|
|
778
|
+
// as the studio tutorial does (#586): a shot added here, and a rail drawn.
|
|
779
|
+
// Declared before the play signal: a drawn rail starts playback in the same
|
|
780
|
+
// commit, and the checklist expects Rail before Play.
|
|
781
|
+
const playgroundShotCount = useRef(null);
|
|
782
|
+
useEffect(() => {
|
|
783
|
+
if (!playgroundMode) return;
|
|
784
|
+
if (playgroundShotCount.current !== null && shots.length > playgroundShotCount.current) {
|
|
785
|
+
window.parent?.postMessage({ type: "cozyclay:playground-nav", kind: "shot" }, "*");
|
|
786
|
+
}
|
|
787
|
+
playgroundShotCount.current = shots.length;
|
|
788
|
+
}, [playgroundMode, shots.length]);
|
|
789
|
+
const playgroundRailReady = playgroundMode && shots.some((shot) => Array.isArray(shot.camera?.cameraRail) && shot.camera.cameraRail.length >= 2);
|
|
790
|
+
useEffect(() => {
|
|
791
|
+
if (playgroundRailReady) window.parent?.postMessage({ type: "cozyclay:playground-nav", kind: "rail" }, "*");
|
|
792
|
+
}, [playgroundRailReady]);
|
|
798
793
|
// Preview is the player for the finished motion: entering starts playback,
|
|
799
794
|
// leaving pauses it. The editor view stays the manipulation surface.
|
|
800
795
|
const [tlPlaying, setTlPlaying] = useState(false);
|
|
@@ -1177,7 +1172,7 @@ export default function App() {
|
|
|
1177
1172
|
setPromptBlocksReveal, revealPromptBlocks, waypointMode, setWaypointMode, waypoints, setWaypoints,
|
|
1178
1173
|
activeWaypointId, setActiveWaypointId, pendingWaypointFrame, setPendingWaypointFrame, promptClips,
|
|
1179
1174
|
setPromptClips, editPromptClips, selectedPromptId, setSelectedPromptId, photoPoseState, photoPoseError,
|
|
1180
|
-
setPhotoPoseError, allPoses, selectablePoses, posingIndex, posingChar, posedRig, setPosed, rootStart,
|
|
1175
|
+
setPhotoPoseError, allPoses, selectablePoses, posingIndex, posingChar, posedRig, setPosed, rootStart, commitPosedPose,
|
|
1181
1176
|
queueRootWaypointFrame, castMemberOf, addCharacterWaypoint, moveCharacterWaypoint,
|
|
1182
1177
|
removeCharacterWaypoint, clearCharacterWaypoints, addFloorWaypoint, moveWaypoint, removeWaypoint,
|
|
1183
1178
|
toggleWaypointMode, openStudio, closeStudio, saveCurrentPose, savePose, posePhotoFile, removePose,
|
|
@@ -1190,9 +1185,14 @@ export default function App() {
|
|
|
1190
1185
|
// not the one its closure captured.
|
|
1191
1186
|
appContext.publishCharacters(characters);
|
|
1192
1187
|
|
|
1193
|
-
const
|
|
1194
|
-
|
|
1195
|
-
|
|
1188
|
+
const {
|
|
1189
|
+
i2vMotionEnabled,
|
|
1190
|
+
setI2vMotionEnabled,
|
|
1191
|
+
i2vMotionMode,
|
|
1192
|
+
setI2vMotionMode,
|
|
1193
|
+
i2vMotionCameraUnlocked,
|
|
1194
|
+
setI2vMotionCameraUnlocked,
|
|
1195
|
+
} = useI2vCaptureModal();
|
|
1196
1196
|
|
|
1197
1197
|
useEffect(() => {
|
|
1198
1198
|
let cancelled = false;
|
|
@@ -1369,7 +1369,8 @@ export default function App() {
|
|
|
1369
1369
|
bodyContact, setBodyContact, IK_CORRECTION_BLEND_FRAMES, autoPhysicsRunning, setAutoPhysicsRunning,
|
|
1370
1370
|
physicsPreview, setPhysicsPreview, physicsShow, physicsProgress, physicsOptions, setPhysicsOptions,
|
|
1371
1371
|
ikTick, setIkTick, committedIkEdits, setCommittedIkEdits, trailFalloffS, setTrailFalloffS, showTrails,
|
|
1372
|
-
setShowTrails, ikEditTool, setIkEditTool, trailEdit, trailFalloffFrames, focusIkHandle, snapshotIkKeys,
|
|
1372
|
+
setShowTrails, ikEditTool, setIkEditTool, trailEdit, trailTrackFocus, selectTrailTrack, clearTrailTrackFocus, trailFalloffFrames, focusIkHandle, snapshotIkKeys,
|
|
1373
|
+
pendingIkEdit, applyPendingIkEdit, cancelPendingIkEdit,
|
|
1373
1374
|
rangePins, rangePinResiduals, rangePinSelection, setRangePinSelection, rangePinPartPick, setRangePinPartPick, rangePinPreview, previewRangePinDraft, applyRangePinDraft, deleteRangePin,
|
|
1374
1375
|
setCharacterIkKey, removeCharacterIkKey, clearCharacterIkKeys, bridge, setBridge, bridgeChecking,
|
|
1375
1376
|
motionSetupReveal, motionSetupKind, setArdyPrompt, setArdyDuration, ardySeed, preserveStrength,
|
|
@@ -1399,14 +1400,24 @@ export default function App() {
|
|
|
1399
1400
|
// Keep the underlying selection model intact, but use this small workflow
|
|
1400
1401
|
// state to surface only the tools that belong to the current job.
|
|
1401
1402
|
const [workflowMode, setWorkflowMode] = useState("scene");
|
|
1403
|
+
const [poseObjectOpacity, setPoseObjectOpacityState] = useState(readPoseFadeOpacity);
|
|
1404
|
+
const setPoseObjectOpacity = (value) => { setPoseObjectOpacityState(value); writePoseFadeOpacity(value); };
|
|
1402
1405
|
// Pose mode IS IK editing (#521): with no rig to solve there is nothing to
|
|
1403
1406
|
// pose, so the mode refuses with the reason instead of opening empty.
|
|
1404
|
-
|
|
1407
|
+
// A capsule figure has no rig at all, so it gets its own reason rather than
|
|
1408
|
+
// the "load a character" one, which would read as a loading problem.
|
|
1409
|
+
const activeCapabilities = characterCapabilities(activeChar);
|
|
1410
|
+
const poseRefusal = !activeCapabilities.pose ? kindRefusal("pose", isKo) : ikChains ? null : ko("Load a character to pose it — Pose mode edits its rig with IK", "캐릭터를 불러와야 포즈를 잡을 수 있어요 — 포즈 모드는 IK로 리그를 편집합니다");
|
|
1405
1411
|
function selectWorkflowMode(next) {
|
|
1406
1412
|
if (next === "pose" && poseRefusal) {
|
|
1407
1413
|
setToast(poseRefusal);
|
|
1408
1414
|
return;
|
|
1409
1415
|
}
|
|
1416
|
+
// A storyboard is stills: there is no Motion department to open.
|
|
1417
|
+
if (next === "motion" && storyboardProject) {
|
|
1418
|
+
setToast(ko(STORYBOARD_MOTION_REFUSAL.en, STORYBOARD_MOTION_REFUSAL.ko));
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1410
1421
|
setWorkflowMode(next);
|
|
1411
1422
|
// Picking a department leaves the chrome-free player. Shot-look is
|
|
1412
1423
|
// camera work, so it only yields when the operator leaves Camera.
|
|
@@ -1425,7 +1436,8 @@ export default function App() {
|
|
|
1425
1436
|
// Pose lands on the active character's rig: its Rig Control panel holds
|
|
1426
1437
|
// the IK tools that W/E/R switch between.
|
|
1427
1438
|
else if (next === "pose") setSelectedHierarchyId(`${rowIdForCharIndex(activeCharIndex)}.rig`);
|
|
1428
|
-
|
|
1439
|
+
// Sculpt keeps the selection: a selected sculpt is the one it opens on.
|
|
1440
|
+
else if (next !== "sculpt") setSelectedHierarchyId("shot");
|
|
1429
1441
|
}
|
|
1430
1442
|
// Every door into the mode (tab, 1-4 keys, view.setMode on the bus) moves
|
|
1431
1443
|
// IK with it through the motion domain's own entry/exit path...
|
|
@@ -1435,7 +1447,7 @@ export default function App() {
|
|
|
1435
1447
|
// ...and whatever else ends IK (a take starting playback, the rig going
|
|
1436
1448
|
// away) hands the operator back to Motion instead of a dead Pose mode.
|
|
1437
1449
|
useEffect(() => {
|
|
1438
|
-
if (!ikMode && workflowMode === "pose") setWorkflowMode("motion");
|
|
1450
|
+
if (!ikMode && workflowMode === "pose") setWorkflowMode(storyboardProject ? "scene" : "motion");
|
|
1439
1451
|
}, [ikMode]);
|
|
1440
1452
|
|
|
1441
1453
|
const toggleHierarchyHidden = (hierarchyId) => {
|
|
@@ -1496,10 +1508,75 @@ export default function App() {
|
|
|
1496
1508
|
matteShrink, setMatteShrink, matteFeather, setMatteFeather, matteMode, setMatteMode, matteStats,
|
|
1497
1509
|
setMatteStats, matteBusy, gizmoMode, setGizmoMode, snapEnabled, setSnapEnabled, addSceneObject,
|
|
1498
1510
|
importCutout, importCutouts, spawnCutoutAt, persistMeshAsset, importMesh, importMeshes, spawnMeshAt,
|
|
1499
|
-
applyMatte, duplicateSelectedSceneObject, frameSelection, renameSceneObject, sceneObjectWorldMatrix,
|
|
1511
|
+
applyMatte, duplicateSelectedSceneObject, frameSelection, groupObjectUnderNewEmpty, renameRequest, renameSceneObject, sceneObjectWorldMatrix,
|
|
1500
1512
|
attachTargetForRow, attachTargetLabel, attachSceneObject,
|
|
1501
1513
|
} = objectsDomain;
|
|
1502
1514
|
|
|
1515
|
+
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 });
|
|
1516
|
+
async function chooseSculptReference(file) {
|
|
1517
|
+
try {
|
|
1518
|
+
// A new reference makes the old sheet a drawing of something else.
|
|
1519
|
+
cancelSculptBlueprint();
|
|
1520
|
+
setSculptBlueprint((current) => ({ ...current, state: "idle", sheet: null, error: null }));
|
|
1521
|
+
setSculptReference(await objectsDomain.storeSculptReference(file));
|
|
1522
|
+
} catch (error) {
|
|
1523
|
+
setToast(ko(`Could not read that image: ${error.message}`, `이미지를 읽지 못했어요: ${error.message}`));
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
function startSculptRun() {
|
|
1527
|
+
if (!sculptReference) return;
|
|
1528
|
+
sculptRunRef.current?.cancel();
|
|
1529
|
+
const run = createSculptRun({
|
|
1530
|
+
bus: { subscribe: (listener) => sculptTap.subscribe(listener, studioBindingRef.current?.bus) },
|
|
1531
|
+
agent: createAgentPort(sculptAgentClient(sculptTransport, () => buildStudioAgentContext())),
|
|
1532
|
+
reference: sculptReference,
|
|
1533
|
+
sculptId: sculptTarget?.id ?? null,
|
|
1534
|
+
score: (args) => appContext.bus.run("sculpt.compare", args, { origin: "ui" }),
|
|
1535
|
+
measure: async (reference) => measureReferenceOutline(await browserSculptImages.decode(reference)),
|
|
1536
|
+
model: sculptModelPick.model || undefined,
|
|
1537
|
+
effort: sculptModelPick.effort || undefined,
|
|
1538
|
+
modelLabel: sculptModelLabel(sculptModels, sculptModelPick.model) || undefined,
|
|
1539
|
+
});
|
|
1540
|
+
sculptRunRef.current = run;
|
|
1541
|
+
let shownId = sculptTarget?.id ?? null;
|
|
1542
|
+
const hadTarget = Boolean(sculptTarget);
|
|
1543
|
+
let reported = false;
|
|
1544
|
+
run.subscribe((state) => {
|
|
1545
|
+
// Before the current-run gate: a run replaced by a new Start still ends (cancelled) once.
|
|
1546
|
+
const telemetry = reported ? null : sculptRunTelemetry(state, { hadTarget });
|
|
1547
|
+
if (telemetry) { reported = true; track("sculpt_run", telemetry); }
|
|
1548
|
+
// A finished run's bus listener outlives it; only the current run speaks.
|
|
1549
|
+
if (sculptRunRef.current !== run) return;
|
|
1550
|
+
setSculptRun(state);
|
|
1551
|
+
if (state.sculptId && state.sculptId !== shownId) {
|
|
1552
|
+
shownId = state.sculptId;
|
|
1553
|
+
setSculptTargetId(state.sculptId);
|
|
1554
|
+
setSelectedHierarchyId(`object:${state.sculptId}`);
|
|
1555
|
+
}
|
|
1556
|
+
});
|
|
1557
|
+
setSculptRun(run.getState());
|
|
1558
|
+
void run.start();
|
|
1559
|
+
}
|
|
1560
|
+
/** Place in scene: on the ground in front of the scene camera, selected,
|
|
1561
|
+
* back on Stage. The move is one object.update, so one undo. */
|
|
1562
|
+
function placeSculptInScene() {
|
|
1563
|
+
if (!sculptTarget) return;
|
|
1564
|
+
try {
|
|
1565
|
+
selectWorkflowMode("scene");
|
|
1566
|
+
objectsDomain.placeSculptInScene(sculptTarget.id);
|
|
1567
|
+
} catch (error) {
|
|
1568
|
+
if (!(error instanceof StudioProtocolError)) throw error;
|
|
1569
|
+
setToast(error.uiMessage ?? error.message);
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// The record whose route the studio shows for the selection: the selected
|
|
1574
|
+
// record itself, or the routed record inside the group it stands for (a car
|
|
1575
|
+
// Empty over its Chassis). Selection stays on the group; only the route's
|
|
1576
|
+
// source moves. With no route anywhere a new stroke lands on the selection.
|
|
1577
|
+
const routeOwner = useMemo(() => routeOwnerFor(sceneObjects, selectedSceneObjectId), [sceneObjects, selectedSceneObjectId]);
|
|
1578
|
+
const pathTarget = routeOwner ?? selectedSceneObject;
|
|
1579
|
+
|
|
1503
1580
|
// An undo offer is an offer, not a banner: without a window it sits on the
|
|
1504
1581
|
// screen for the rest of the session. Long enough to notice and reach, then
|
|
1505
1582
|
// gone — the deletion is still reversible through Undo history afterwards.
|
|
@@ -1566,7 +1643,7 @@ export default function App() {
|
|
|
1566
1643
|
get objectsDomain() { return objectsDomain; },
|
|
1567
1644
|
}));
|
|
1568
1645
|
const {
|
|
1569
|
-
scenes, activeSceneId, sceneSaveError, snapshotActiveScene, persistScenes, projectName, projectDirty,
|
|
1646
|
+
scenes, activeSceneId, sceneSaveError, snapshotActiveScene, persistScenes, projectName, previsMode, projectDirty,
|
|
1570
1647
|
setProjectDirty, projectSaveState, setProjectSaveState, projectMenuOpen, setProjectMenuOpen,
|
|
1571
1648
|
projectBrowserOpen, setProjectBrowserOpen, projectNameDialog, setProjectNameDialog, projectStartupOpen,
|
|
1572
1649
|
setProjectStartupOpen, projectManifest, setProjectManifest, saveBlockedReasons, setSaveBlockedReasons,
|
|
@@ -1577,6 +1654,18 @@ export default function App() {
|
|
|
1577
1654
|
renameSceneDocumentFromUi, deleteSceneDocumentFromUi, switchSceneDocument, addSceneDocument,
|
|
1578
1655
|
duplicateSceneDocument, renameSceneDocument, deleteSceneDocument,
|
|
1579
1656
|
} = scenesDomain;
|
|
1657
|
+
// A sculpt run belongs to Sculpt mode and the scene it started in: leaving
|
|
1658
|
+
// either cancels it, so no paid turn runs unseen and nothing reselects.
|
|
1659
|
+
useEffect(() => {
|
|
1660
|
+
if (!sculptMode) sculptRunRef.current?.cancel();
|
|
1661
|
+
}, [sculptMode]);
|
|
1662
|
+
useEffect(() => () => sculptRunRef.current?.cancel(), [activeSceneId]);
|
|
1663
|
+
// A storyboard project has no Motion department: a project opened while
|
|
1664
|
+
// Motion was up lands on Stage instead.
|
|
1665
|
+
const storyboardProject = scenesDomain.previsMode === "storyboard";
|
|
1666
|
+
useEffect(() => {
|
|
1667
|
+
if (storyboardProject && workflowMode === "motion") setWorkflowMode("scene");
|
|
1668
|
+
}, [storyboardProject, workflowMode]);
|
|
1580
1669
|
|
|
1581
1670
|
const saveBlockedRef = useRef(startup.saveBlocked);
|
|
1582
1671
|
const dirtyRef = useRef(false);
|
|
@@ -1638,6 +1727,8 @@ export default function App() {
|
|
|
1638
1727
|
// the gesture is "paste into the studio", not "paste into this box".
|
|
1639
1728
|
useEffect(() => {
|
|
1640
1729
|
const onPaste = (event) => {
|
|
1730
|
+
// The Agent composer takes a picture pasted anywhere on it first.
|
|
1731
|
+
if (event.defaultPrevented) return;
|
|
1641
1732
|
const target = event.target;
|
|
1642
1733
|
// Never steal a paste aimed at somewhere text goes.
|
|
1643
1734
|
if (target instanceof HTMLElement) {
|
|
@@ -1819,12 +1910,20 @@ export default function App() {
|
|
|
1819
1910
|
}
|
|
1820
1911
|
// 1-4 pick the workflow mode; pose is refused (with its reason) when
|
|
1821
1912
|
// there is no rig to solve. The embedded player has no modes to pick.
|
|
1822
|
-
const modeKey = { Digit1: "scene", Digit2: "pose", Digit3: "camera", Digit4: "motion" }[event.code];
|
|
1913
|
+
const modeKey = { Digit1: "scene", Digit2: "pose", Digit3: "camera", Digit4: "motion", Digit5: "sculpt" }[event.code];
|
|
1823
1914
|
if (modeKey && !embedMode && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
1915
|
+
// A storyboard has no key 4: it does nothing, not even a refusal.
|
|
1916
|
+
if (modeKey === "motion" && storyboardProject) return;
|
|
1824
1917
|
event.preventDefault();
|
|
1825
1918
|
if (modeKey !== workflowMode) selectWorkflowMode(modeKey);
|
|
1826
1919
|
return;
|
|
1827
1920
|
}
|
|
1921
|
+
// Sculpt mode's W/E/R swing the turntable to front, side and back.
|
|
1922
|
+
if (workflowMode === "sculpt" && GIZMO_HOTKEYS[event.code]) {
|
|
1923
|
+
event.preventDefault();
|
|
1924
|
+
requestSculptView({ KeyW: "front", KeyE: "side", KeyR: "back" }[event.code]);
|
|
1925
|
+
return;
|
|
1926
|
+
}
|
|
1828
1927
|
// Pose mode's W/E/R are the IK tools, gated like their Rig Control
|
|
1829
1928
|
// buttons: the trail tool needs visible trails, the range pin a take.
|
|
1830
1929
|
if (workflowMode === "pose" && GIZMO_HOTKEYS[event.code]) {
|
|
@@ -1912,7 +2011,7 @@ export default function App() {
|
|
|
1912
2011
|
// selection would point the gizmo at a stale dot.
|
|
1913
2012
|
useEffect(() => {
|
|
1914
2013
|
setPathPointIndex(null);
|
|
1915
|
-
}, [selectedSceneObjectId]);
|
|
2014
|
+
}, [selectedSceneObjectId, routeOwner?.id]);
|
|
1916
2015
|
|
|
1917
2016
|
useEffect(() => {
|
|
1918
2017
|
if (!inspectorActionsOpen) return undefined;
|
|
@@ -1942,6 +2041,13 @@ export default function App() {
|
|
|
1942
2041
|
// Object travel-path drawing: the same Top-View stroke gesture as the rail,
|
|
1943
2042
|
// aimed at the selected object instead of the shot camera.
|
|
1944
2043
|
const [pathDraw, setPathDraw] = useState(false);
|
|
2044
|
+
// Root-path (waypoint) authoring and Top-View path drawing are motion
|
|
2045
|
+
// tooling; a storyboard never leaves either switched on.
|
|
2046
|
+
useEffect(() => {
|
|
2047
|
+
if (!storyboardProject) return;
|
|
2048
|
+
if (waypointMode) setWaypointMode(false);
|
|
2049
|
+
if (pathDraw) setPathDraw(false);
|
|
2050
|
+
}, [storyboardProject, waypointMode, pathDraw]);
|
|
1945
2051
|
const [pathPointIndex, setPathPointIndex] = useState(null);
|
|
1946
2052
|
const pathDragTokenRef = useRef(null);
|
|
1947
2053
|
const planPathTokenRef = useRef(null);
|
|
@@ -1975,6 +2081,16 @@ export default function App() {
|
|
|
1975
2081
|
if (typeof toast.uiMessage === "string" && toast.uiMessage) for (const sink of toastSinkRef.current) sink(toast);
|
|
1976
2082
|
showToast(toast.uiMessage);
|
|
1977
2083
|
}, []);
|
|
2084
|
+
// The dots on a route are found by double-clicking the line, a gesture
|
|
2085
|
+
// nothing hints at: say it once, the first time a route is selected.
|
|
2086
|
+
const routeHintShownRef = useRef(false);
|
|
2087
|
+
// The route shown may belong to a routed record inside the selected group.
|
|
2088
|
+
const selectedRouteId = routeOwner?.path ? routeOwner.id : null;
|
|
2089
|
+
useEffect(() => {
|
|
2090
|
+
if (!selectedRouteId || routeHintShownRef.current) return;
|
|
2091
|
+
routeHintShownRef.current = true;
|
|
2092
|
+
setToast(ko("Double-click the line to add a dot", "선을 더블클릭하면 점이 추가돼요"));
|
|
2093
|
+
}, [selectedRouteId, setToast]);
|
|
1978
2094
|
// The PWA's "a newer studio is waiting" registration, once one arrives.
|
|
1979
2095
|
const [pwaUpdate, setPwaUpdate] = useState(null);
|
|
1980
2096
|
useEffect(() => {
|
|
@@ -1993,58 +2109,7 @@ export default function App() {
|
|
|
1993
2109
|
const takeRecipeRef = useRef(null);
|
|
1994
2110
|
|
|
1995
2111
|
const [bottomTab, setBottomTab] = useState("timeline");
|
|
1996
|
-
|
|
1997
|
-
// never resolved (the pane shows skeletons, NEVER the empty message); an
|
|
1998
|
-
// array = the SOURCE ids to show, mattes and cut renders already filtered
|
|
1999
|
-
// out (asset-shelf.js). Scans run only while the shelf is visible, and
|
|
2000
|
-
// re-run when a cutout's lineage changes — not on every transform tick, so
|
|
2001
|
-
// a gizmo drag never hammers IndexedDB.
|
|
2002
|
-
const [shelfImageIds, setShelfImageIds] = useState(null);
|
|
2003
|
-
const [shelfMeshIds, setShelfMeshIds] = useState(null);
|
|
2004
|
-
const [manageAssetStorage, setManageAssetStorage] = useState(false);
|
|
2005
|
-
// A separate scan preserves the source-only placement shelf while the
|
|
2006
|
-
// manager exposes every unreachable stored record, including matte and cut
|
|
2007
|
-
// derivatives orphaned with a deleted card.
|
|
2008
|
-
const [unusedAssetIds, setUnusedAssetIds] = useState(null);
|
|
2009
|
-
const [usedAssetIds, setUsedAssetIds] = useState(null);
|
|
2010
|
-
const [usageCounts, setUsageCounts] = useState(new Map());
|
|
2011
|
-
const assetShelfScanTokenRef = useRef(0);
|
|
2012
|
-
const legacyDerivedIdsRef = useRef(new Set());
|
|
2013
|
-
// This is deliberately session-only. Each entry is a complete IndexedDB
|
|
2014
|
-
// record, so Undo can put it back byte-for-byte until the page is reloaded.
|
|
2015
|
-
const [assetTrash, setAssetTrash] = useState([]);
|
|
2016
|
-
// Same rule as the object deletion offer: the toast is a window, not a
|
|
2017
|
-
// banner. Only the OFFER expires — the trashed record itself is the restore
|
|
2018
|
-
// data, so it is deliberately kept for the session and never timed out.
|
|
2019
|
-
const [assetUndoOffered, setAssetUndoOffered] = useState(false);
|
|
2020
|
-
useEffect(() => {
|
|
2021
|
-
if (!assetUndoOffered) return undefined;
|
|
2022
|
-
const timer = setTimeout(() => setAssetUndoOffered(false), ASSET_DELETE_UNDO_MS);
|
|
2023
|
-
return () => clearTimeout(timer);
|
|
2024
|
-
}, [assetUndoOffered]);
|
|
2025
|
-
const [deletingAssetId, setDeletingAssetId] = useState(null);
|
|
2026
|
-
const cutoutLineage = useMemo(
|
|
2027
|
-
() => JSON.stringify(sceneObjects.flatMap((object) => (object.renderer === CUTOUT_KIND ? [[object.assetId, object.sourceAssetId, object.matteAssetId]] : []))),
|
|
2028
|
-
[sceneObjects],
|
|
2029
|
-
);
|
|
2030
|
-
const meshLineage = useMemo(
|
|
2031
|
-
() => JSON.stringify(sceneObjects.flatMap((object) => (object.renderer === MESH_KIND ? [object.assetId] : []))),
|
|
2032
|
-
[sceneObjects],
|
|
2033
|
-
);
|
|
2034
|
-
const projectCutoutLineage = useMemo(() => {
|
|
2035
|
-
const allScenes = scenes.map((scene) => (scene.id === activeSceneId ? { ...scene, objects: sceneObjects } : scene));
|
|
2036
|
-
return JSON.stringify(
|
|
2037
|
-
allScenes.flatMap((scene) =>
|
|
2038
|
-
(Array.isArray(scene.objects) ? scene.objects : []).flatMap((object) =>
|
|
2039
|
-
object.renderer === CUTOUT_KIND ? [[object.assetId, object.sourceAssetId, object.matteAssetId]] : [],
|
|
2040
|
-
),
|
|
2041
|
-
),
|
|
2042
|
-
);
|
|
2043
|
-
}, [scenes, activeSceneId, sceneObjects]);
|
|
2044
|
-
const projectAssetGraphSignature = useMemo(() => {
|
|
2045
|
-
const allScenes = scenes.map((scene) => (scene.id === activeSceneId ? { ...scene, objects: sceneObjects } : scene));
|
|
2046
|
-
return assetGraphSignature(allScenes);
|
|
2047
|
-
}, [scenes, activeSceneId, sceneObjects]);
|
|
2112
|
+
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 });
|
|
2048
2113
|
useEffect(() => {
|
|
2049
2114
|
const { scenes: latestScenes, activeSceneId: latestActiveSceneId, sceneObjects: latestObjects } = projectStateRef.current;
|
|
2050
2115
|
const allScenes = latestScenes.map((scene) => (scene.id === latestActiveSceneId ? { ...scene, objects: latestObjects } : scene));
|
|
@@ -2217,15 +2282,21 @@ export default function App() {
|
|
|
2217
2282
|
// the export overwrites it per captured frame and restores nothing, which
|
|
2218
2283
|
// is correct — the next render puts it back.
|
|
2219
2284
|
propFrameRef.current = tlFrame;
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2285
|
+
// Routed records and everything grouped under them (object-travel.js).
|
|
2286
|
+
const animatedSceneObjects = useMemo(
|
|
2287
|
+
() => sceneObjectsAt(sceneObjects, tlFrame, { frameCount: tlFrameCount, fps: tlFps }),
|
|
2288
|
+
[sceneObjects, tlFrame, tlFrameCount, tlFps],
|
|
2289
|
+
);
|
|
2290
|
+
|
|
2291
|
+
// The selection as it is DRAWN at this frame, for the transform gizmo to sit
|
|
2292
|
+
// on: a routed record, the group under one, and the Empty that rides its
|
|
2293
|
+
// routed descendant all stand away from their authored numbers while the
|
|
2294
|
+
// playhead is off frame 0. Drags still read the authored records (the gizmo
|
|
2295
|
+
// is handed those separately), so they stay delta-based on authored numbers.
|
|
2296
|
+
const gizmoSceneObject = useMemo(
|
|
2297
|
+
() => (selectedSceneObject ? animatedSceneObjects.find((object) => object.id === selectedSceneObject.id) ?? selectedSceneObject : null),
|
|
2298
|
+
[selectedSceneObject, animatedSceneObjects],
|
|
2299
|
+
);
|
|
2229
2300
|
|
|
2230
2301
|
// Auto color: Blender's viewport "Random" mode. A DISPLAY-ONLY marker rides
|
|
2231
2302
|
// each non-cutout object into the renderers; the authored `color`, the scene
|
|
@@ -2257,6 +2328,14 @@ export default function App() {
|
|
|
2257
2328
|
// r3f frame loop — so it asks the set for one placement pass itself, right
|
|
2258
2329
|
// after it has written that frame's bones.
|
|
2259
2330
|
const propSyncRef = useRef(null);
|
|
2331
|
+
// Characters grouped under a scene object ride it inside an ObjectCarrier.
|
|
2332
|
+
// The carriers read the AUTHORED records by id, and register a placement
|
|
2333
|
+
// pass the recorder runs before the props' (a prop carried on a riding
|
|
2334
|
+
// character's hand needs the carrier placed first).
|
|
2335
|
+
const carrierRegistryRef = useRef(new Set());
|
|
2336
|
+
const sceneObjectLookupRef = useRef(null);
|
|
2337
|
+
sceneObjectLookupRef.current = useMemo(() => new Map(sceneObjects.map((object) => [object.id, object])), [sceneObjects]);
|
|
2338
|
+
const syncCarriers = () => { for (const place of carrierRegistryRef.current) place(); };
|
|
2260
2339
|
// Where a prop actually IS, read off its live group: the one authority on
|
|
2261
2340
|
// the transform currently on screen, and so the only honest starting point
|
|
2262
2341
|
// for a no-jump conversion.
|
|
@@ -2265,9 +2344,30 @@ export default function App() {
|
|
|
2265
2344
|
/**
|
|
2266
2345
|
* Hierarchy row drag policy (the panel holds none). An object row dropped on
|
|
2267
2346
|
* another object GROUPS; on a character or one of its bone rows it ATTACHES;
|
|
2268
|
-
* on Props it comes back to the world.
|
|
2347
|
+
* on Props it comes back to the world. A character row dropped on an object
|
|
2348
|
+
* row is GROUPED under it and rides its travel; dropped on the scene root it
|
|
2349
|
+
* stands in the world again. Anything else is not a drop.
|
|
2269
2350
|
*/
|
|
2270
|
-
|
|
2351
|
+
function characterGroupMove(sourceRowId, targetRowId) {
|
|
2352
|
+
const characterId = charIdFromHierarchyId(sourceRowId);
|
|
2353
|
+
const entry = characterId ? characters.find((item) => item.id === characterId) : null;
|
|
2354
|
+
if (!entry) return null;
|
|
2355
|
+
if (targetRowId === "shot") return (entry.parent ?? null) !== null ? { characterId, parent: null } : null;
|
|
2356
|
+
const objectId = sceneObjectIdFromHierarchy(String(targetRowId ?? ""));
|
|
2357
|
+
const object = objectId ? sceneObjects.find((item) => item.id === objectId) : null;
|
|
2358
|
+
if (!object || object.attach || entry.parent === objectId) return null;
|
|
2359
|
+
return { characterId, parent: objectId };
|
|
2360
|
+
}
|
|
2361
|
+
const hierarchyReparent = {
|
|
2362
|
+
canDrop: (source, target) => (charIdFromHierarchyId(source)
|
|
2363
|
+
? characterGroupMove(source, target) !== null
|
|
2364
|
+
: objectsDomain.canReparentSceneObject(source, target)),
|
|
2365
|
+
onDrop: (source, target) => {
|
|
2366
|
+
if (!charIdFromHierarchyId(source)) return objectsDomain.reparentSceneObject(source, target);
|
|
2367
|
+
const move = characterGroupMove(source, target);
|
|
2368
|
+
if (move) runStudioAction("character.update", { characterId: move.characterId, patch: { parent: move.parent } });
|
|
2369
|
+
},
|
|
2370
|
+
};
|
|
2271
2371
|
|
|
2272
2372
|
useEffect(() => {
|
|
2273
2373
|
setCraneSelectedIndex(null);
|
|
@@ -2453,10 +2553,20 @@ export default function App() {
|
|
|
2453
2553
|
appContext.publishScenes(scenes);
|
|
2454
2554
|
activeSceneIdRef.current = activeSceneId;
|
|
2455
2555
|
shotDocumentRef.current = createShotAuthoringDocument({ shots, waypoints, frameCount: tlFrameCount });
|
|
2556
|
+
// The motion layers: their IK edits (Pose mode, IK keys, range pins) ride
|
|
2557
|
+
// into the saved stage below with each character, and a change to them is
|
|
2558
|
+
// a reason to autosave.
|
|
2559
|
+
const motionLayers = appContext.storeDomain("motion")?.read() ?? null;
|
|
2560
|
+
const ikEditsOf = (id) => {
|
|
2561
|
+
const row = motionLayers?.find((layer) => layer.id === id);
|
|
2562
|
+
if (!row || (!row.ikKeys?.length && !row.ikPins?.length)) return null;
|
|
2563
|
+
return { keys: row.ikKeys, pins: row.ikPins ?? [], pinResiduals: row.ikPinResiduals ?? [] };
|
|
2564
|
+
};
|
|
2565
|
+
// sessionMotion is stripped: generated clips are session-only and far too
|
|
2566
|
+
// heavy for the stage envelope; paths, prompt blocks and IK edits persist.
|
|
2567
|
+
const stageCharacters = characters.map(({ sessionMotion, ...entry }) => ({ ...entry, ikEdits: ikEditsOf(entry.id) }));
|
|
2456
2568
|
actorStageRef.current = {
|
|
2457
|
-
|
|
2458
|
-
// too heavy for the stage envelope; paths and prompt blocks persist.
|
|
2459
|
-
characters: characters.map(({ sessionMotion, ...entry }) => entry),
|
|
2569
|
+
characters: stageCharacters,
|
|
2460
2570
|
hasCharSheet,
|
|
2461
2571
|
environmentImage,
|
|
2462
2572
|
shotAspect: shotAspectKey,
|
|
@@ -2470,8 +2580,7 @@ export default function App() {
|
|
|
2470
2580
|
hasEnvSheet,
|
|
2471
2581
|
};
|
|
2472
2582
|
|
|
2473
|
-
const
|
|
2474
|
-
const [useCaseAskOpen, setUseCaseAskOpen] = useState(false);
|
|
2583
|
+
const { firstSuccessGuideOpen, setFirstSuccessGuideOpen, useCaseAskOpen, setUseCaseAskOpen } = useFirstSuccessPrompt();
|
|
2475
2584
|
|
|
2476
2585
|
// Dismissal mirrors the inspector-actions menu: only listen while open,
|
|
2477
2586
|
// ignore presses inside the wrap (the trigger's own click keeps toggling),
|
|
@@ -2804,6 +2913,8 @@ export default function App() {
|
|
|
2804
2913
|
activeShotId: activeShot?.id ?? null,
|
|
2805
2914
|
captureCurrentFraming,
|
|
2806
2915
|
captureFramingPng,
|
|
2916
|
+
// The Top View pull (verify_result visual "plan", capture_plan_png).
|
|
2917
|
+
capturePlanPng,
|
|
2807
2918
|
captureShotMeta,
|
|
2808
2919
|
// The identity / environment reference pictures a capture carries (#167).
|
|
2809
2920
|
captureShotReferences,
|
|
@@ -2839,6 +2950,7 @@ export default function App() {
|
|
|
2839
2950
|
aspectRatio: live.filmback.aspectRatio,
|
|
2840
2951
|
},
|
|
2841
2952
|
stage: live.stage,
|
|
2953
|
+
previsMode: scenesDomain.metadata().previsMode,
|
|
2842
2954
|
timeline: live.timeline,
|
|
2843
2955
|
activeCharacterId: live.activeCharacterId,
|
|
2844
2956
|
characters: castDomain.read(),
|
|
@@ -2874,10 +2986,7 @@ export default function App() {
|
|
|
2874
2986
|
// not capture_frame's 640x360 preview, which stays exactly as it is.
|
|
2875
2987
|
capture_framing_png: (args = {}) => {
|
|
2876
2988
|
const live = appContext.live.state;
|
|
2877
|
-
const
|
|
2878
|
-
const output = Number.isFinite(requested?.width) && Number.isFinite(requested?.height)
|
|
2879
|
-
? { width: Math.round(requested.width), height: Math.round(requested.height) }
|
|
2880
|
-
: SHOT_ASPECT_PRESETS[live.stage.shotAspect] ?? SHOT_ASPECT_PRESETS["16:9"];
|
|
2989
|
+
const output = requestedCaptureOutput(args) ?? SHOT_ASPECT_PRESETS[live.stage.shotAspect] ?? SHOT_ASPECT_PRESETS["16:9"];
|
|
2881
2990
|
const dataUrl = live.captureFramingPng(live.captureCurrentFraming(), output);
|
|
2882
2991
|
if (!dataUrl) throw new Error("The shot renderer is not ready");
|
|
2883
2992
|
return {
|
|
@@ -2894,6 +3003,9 @@ export default function App() {
|
|
|
2894
3003
|
references: live.captureShotReferences(),
|
|
2895
3004
|
};
|
|
2896
3005
|
},
|
|
3006
|
+
// The Top View as the agent's plan picture: a 2D diagram of the cast,
|
|
3007
|
+
// footprints and every shot camera drawn from state (src/plan-diagram.js).
|
|
3008
|
+
capture_plan_png: (args = {}) => appContext.live.state.capturePlanPng(requestedCaptureOutput(args) ?? undefined),
|
|
2897
3009
|
};
|
|
2898
3010
|
|
|
2899
3011
|
useEffect(() => {
|
|
@@ -2904,7 +3016,8 @@ export default function App() {
|
|
|
2904
3016
|
const timer = setTimeout(() => {
|
|
2905
3017
|
if (!liveControlRef.current) {
|
|
2906
3018
|
liveControlRef.current = createLiveControl({
|
|
2907
|
-
|
|
3019
|
+
// Tapped so a sculpt run hears the agent's object.sculpt / sculpt.compare.
|
|
3020
|
+
handlers: sculptTap.wrap({ ...liveQueries, ...studioBindingRef.current.handlers }),
|
|
2908
3021
|
workspaceId: liveWorkspaceIdRef.current,
|
|
2909
3022
|
meta: {
|
|
2910
3023
|
project: projectName ?? "Untitled",
|
|
@@ -2965,7 +3078,7 @@ export default function App() {
|
|
|
2965
3078
|
dirtyRef.current = true;
|
|
2966
3079
|
const timer = setTimeout(flushScenes, 400);
|
|
2967
3080
|
return () => clearTimeout(timer);
|
|
2968
|
-
}, [sceneObjects, shots, waypoints, tlFrameCount, charA, charB, showB, poseA, poseB, hasCharSheet, environmentImage, environment, style, hasEnvSheet, subject, subject2, shotAspectKey, sensorId, keyLight, scenes, activeSceneId]);
|
|
3081
|
+
}, [sceneObjects, shots, waypoints, tlFrameCount, charA, charB, showB, poseA, poseB, hasCharSheet, environmentImage, environment, style, hasEnvSheet, subject, subject2, shotAspectKey, sensorId, keyLight, scenes, activeSceneId, motionLayers]);
|
|
2969
3082
|
useEffect(() => {
|
|
2970
3083
|
const onPageHide = () => flushScenes();
|
|
2971
3084
|
const onVisibility = () => {
|
|
@@ -3028,25 +3141,33 @@ export default function App() {
|
|
|
3028
3141
|
// props did not change must not re-render its subtree.
|
|
3029
3142
|
const characterViews = useMemo(() => characters.flatMap((entry, index) => {
|
|
3030
3143
|
if (entry.hidden) return [];
|
|
3144
|
+
const pickId = index === 0 ? "A" : index === 1 ? "B" : entry.id;
|
|
3031
3145
|
// Each cast member is driven by ITS OWN clip: the active one reads
|
|
3032
3146
|
// the editing buffer, the others their stored session motion.
|
|
3033
3147
|
const clip = entry.id === activeChar.id ? motion : entry.sessionMotion;
|
|
3148
|
+
const placement = placementAt(entry, tlFrame, {
|
|
3149
|
+
shotAt: frame => shotAtFrame(shots, frame),
|
|
3150
|
+
takeRoot: clip ? sampleAt({ frameCount: clip.frames, motion: clip }, null, tlFrame).subject : null,
|
|
3151
|
+
});
|
|
3034
3152
|
return [{
|
|
3035
3153
|
id: entry.id,
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3154
|
+
model: entry.model,
|
|
3155
|
+
...(isProxyFigure(entry) ? {} : { url: characterModelUrl(entry.model) }),
|
|
3156
|
+
position: [placement.x, entry.y ?? 0, placement.z],
|
|
3157
|
+
rot: placement.rot,
|
|
3039
3158
|
tint: entry.tint ?? defaultCharacterTint(entry, index),
|
|
3040
3159
|
partColoursEnabled,
|
|
3041
3160
|
partColoursMode,
|
|
3042
|
-
pose: clip ? null : (
|
|
3161
|
+
pose: clip ? null : (placement.pose ?? DEFAULT_POSE),
|
|
3162
|
+
posture: placement.posture,
|
|
3043
3163
|
// The stature the entry's take was extracted at. It rides with the
|
|
3044
3164
|
// clip, never separately — see Character for why.
|
|
3045
3165
|
scale: entry.scale ?? 1,
|
|
3046
|
-
onRig: reportRig(entry.id),
|
|
3047
|
-
pickId
|
|
3166
|
+
...(isProxyFigure(entry) ? {} : { onRig: reportRig(entry.id) }),
|
|
3167
|
+
pickId,
|
|
3168
|
+
parent: entry.parent ?? null,
|
|
3048
3169
|
}];
|
|
3049
|
-
}), [characters, activeChar.id, motion, partColoursEnabled, partColoursMode]);
|
|
3170
|
+
}), [characters, activeChar.id, motion, shots, tlFrame, partColoursEnabled, partColoursMode]);
|
|
3050
3171
|
// Where the selection gizmo stands: same driving rules as the render,
|
|
3051
3172
|
// for the active (selected) cast member only. Gated on the HIERARCHY
|
|
3052
3173
|
// selection, not the sticky active layer — the layer stays on the last
|
|
@@ -3057,8 +3178,10 @@ export default function App() {
|
|
|
3057
3178
|
if (!charIdFromHierarchyId(selectedHierarchyId)) return null;
|
|
3058
3179
|
const entry = characters.find((item) => item.id === activeChar.id);
|
|
3059
3180
|
if (!entry || entry.hidden) return null;
|
|
3060
|
-
|
|
3061
|
-
|
|
3181
|
+
const hasTake = activeChar.id === activeCharacterId ? Boolean(motion) : Boolean(activeChar.sessionMotion);
|
|
3182
|
+
const placement = hasTake ? entry : placementAt(entry, tlFrame, { shotAt: frame => shotAtFrame(shots, frame) });
|
|
3183
|
+
return { position: [placement.x, entry.y ?? 0, placement.z], rot: placement.rot };
|
|
3184
|
+
}, [characters, activeChar.id, motion, shots, tlFrame, selectedHierarchyId]);
|
|
3062
3185
|
// The cast rides the SAME gizmo as scene objects — one movement grammar
|
|
3063
3186
|
// for everything on stage. The proxy hands ObjectGizmo the object shape
|
|
3064
3187
|
// it expects; `height` puts the pivot at the hips like a prop's centre.
|
|
@@ -3072,7 +3195,7 @@ export default function App() {
|
|
|
3072
3195
|
z: gizmoView.position[2],
|
|
3073
3196
|
height: 1.15,
|
|
3074
3197
|
footprint: { width: 0.6, depth: 0.6 },
|
|
3075
|
-
rotY: entry?.rot ?? 0,
|
|
3198
|
+
rotY: gizmoView.rot ?? entry?.rot ?? 0,
|
|
3076
3199
|
scaleX: entry?.scale ?? 1,
|
|
3077
3200
|
scaleY: entry?.scale ?? 1,
|
|
3078
3201
|
scaleZ: entry?.scale ?? 1,
|
|
@@ -3235,7 +3358,7 @@ export default function App() {
|
|
|
3235
3358
|
ikStates: new Map([...ikStatesRef.current].map(([id, state]) => [id, copyIk(state)])),
|
|
3236
3359
|
rigStates: Object.values(rigs).filter(Boolean).map(snapshotExportRig),
|
|
3237
3360
|
};
|
|
3238
|
-
return Object.freeze({ kind, format: kind === "frame" ? "png" : kind === "keyframe_pack" ? "zip" : "mp4",
|
|
3361
|
+
return Object.freeze({ kind, format: kind === "frame" || kind === "contact_sheet" ? "png" : kind === "keyframe_pack" || kind === "panel_pack" ? "zip" : "mp4",
|
|
3239
3362
|
context, run, external, download, handedOff: new Set() });
|
|
3240
3363
|
}
|
|
3241
3364
|
|
|
@@ -3335,16 +3458,23 @@ export default function App() {
|
|
|
3335
3458
|
propFrameRef.current = frame;
|
|
3336
3459
|
const context = recRef.current?.request.context;
|
|
3337
3460
|
for (const entry of context?.characters ?? characters) {
|
|
3461
|
+
if (isProxyFigure(entry)) continue;
|
|
3338
3462
|
const activeId = context?.activeId ?? activeChar.id;
|
|
3339
3463
|
const clip = entry.id === activeId ? (context ? context.motion : motion) : entry.sessionMotion;
|
|
3340
3464
|
const state = context
|
|
3341
3465
|
? (entry.id === activeId ? context.ikState : context.ikStates.get(entry.id))
|
|
3342
3466
|
: (entry.id === activeChar.id ? ikStateRef.current : ikStatesRef.current.get(entry.id));
|
|
3343
|
-
|
|
3467
|
+
const placement = placementAt(entry, frame, {
|
|
3468
|
+
shotAt: at => shotAtFrame(context?.shots ?? shots, at),
|
|
3469
|
+
takeRoot: clip ? sampleAt({ frameCount: clip.frames, motion: clip }, null, frame).subject : null,
|
|
3470
|
+
});
|
|
3471
|
+
poseMemberAtFrame(rigs[entry.id], clip, state, frame, IK_CORRECTION_BLEND_FRAMES, placement.pose ?? null);
|
|
3344
3472
|
}
|
|
3345
3473
|
// The bones for this frame are now written, so a carried prop can take
|
|
3346
3474
|
// its place on them. gl.render() never runs the r3f frame loop, so this
|
|
3347
3475
|
// pass is the recorder's stand-in for the useFrame the preview gets.
|
|
3476
|
+
// Riding characters move with their object first, then the props.
|
|
3477
|
+
syncCarriers();
|
|
3348
3478
|
propSyncRef.current?.();
|
|
3349
3479
|
const sampled = sampleAt(context?.playbackScene ?? playbackScene, shotAtFrame(context?.shots ?? shots, frame), frame);
|
|
3350
3480
|
const framing = sampled.camera ?? context?.framing;
|
|
@@ -3384,18 +3514,43 @@ export default function App() {
|
|
|
3384
3514
|
const cameraSnapshot = { position: cam.position.clone(), quaternion: cam.quaternion.clone(),
|
|
3385
3515
|
rotationOrder: cam.rotation.order, fov: cam.fov, yaw: look.current.yaw, pitch: look.current.pitch };
|
|
3386
3516
|
const rigSnapshots = Object.values(rigs).filter(Boolean).map(snapshotExportRig);
|
|
3517
|
+
const proxySnapshots = [];
|
|
3518
|
+
const context = recRef.current?.request.context;
|
|
3519
|
+
const cast = context?.characters ?? characters;
|
|
3520
|
+
const scene = (recRef.current?.capture ?? captureRef.current)?.scene;
|
|
3521
|
+
const pickIdByCharacter = new Map(characterViews.map(view => [view.id, view.pickId]));
|
|
3522
|
+
if (typeof scene?.traverse === "function") scene.traverse(node => {
|
|
3523
|
+
const index = cast.findIndex(entry => isProxyFigure(entry) && node.userData.characterPick === pickIdByCharacter.get(entry.id));
|
|
3524
|
+
if (index < 0) return;
|
|
3525
|
+
proxySnapshots.push({ node, entry: cast[index], position: node.position.clone(), quaternion: node.quaternion.clone() });
|
|
3526
|
+
});
|
|
3387
3527
|
const propFrame = propFrameRef.current;
|
|
3388
3528
|
try {
|
|
3389
3529
|
// Character stature lives on the rig and placement/yaw on its parent,
|
|
3390
3530
|
// not in playback bones. Retry temporarily restores both, plus the
|
|
3391
3531
|
// original held pose, then puts the CURRENT editor transforms back.
|
|
3392
3532
|
for (const snapshot of recRef.current?.request.context?.rigStates ?? []) restoreExportRig(snapshot);
|
|
3533
|
+
for (const { node, entry } of proxySnapshots) {
|
|
3534
|
+
const clip = entry.id === (context?.activeId ?? activeChar.id) ? (context ? context.motion : motion) : entry.sessionMotion;
|
|
3535
|
+
const placement = placementAt(entry, frame, {
|
|
3536
|
+
shotAt: at => shotAtFrame(context?.shots ?? shots, at),
|
|
3537
|
+
takeRoot: clip ? sampleAt({ frameCount: clip.frames, motion: clip }, null, frame).subject : null,
|
|
3538
|
+
});
|
|
3539
|
+
node.position.set(placement.x, entry.y ?? 0, placement.z);
|
|
3540
|
+
node.rotation.y = proxyYaw(placement.rot, placement.posture);
|
|
3541
|
+
node.updateMatrixWorld(true);
|
|
3542
|
+
}
|
|
3393
3543
|
const plate = applyExportFrame(frame);
|
|
3394
3544
|
return render ? render() : plate;
|
|
3395
3545
|
} catch (error) {
|
|
3396
3546
|
throw Object.assign(new Error(error?.message || String(error), { cause: error }), { exportFailureCode: exportFailureCode(error, "render_failed") });
|
|
3397
3547
|
} finally {
|
|
3398
3548
|
for (const snapshot of rigSnapshots) restoreExportRig(snapshot);
|
|
3549
|
+
for (const { node, position, quaternion } of proxySnapshots) {
|
|
3550
|
+
node.position.copy(position);
|
|
3551
|
+
node.quaternion.copy(quaternion);
|
|
3552
|
+
node.updateMatrixWorld(true);
|
|
3553
|
+
}
|
|
3399
3554
|
cam.position.copy(cameraSnapshot.position);
|
|
3400
3555
|
cam.rotation.order = cameraSnapshot.rotationOrder;
|
|
3401
3556
|
cam.quaternion.copy(cameraSnapshot.quaternion);
|
|
@@ -3404,6 +3559,7 @@ export default function App() {
|
|
|
3404
3559
|
look.current.yaw = cameraSnapshot.yaw;
|
|
3405
3560
|
look.current.pitch = cameraSnapshot.pitch;
|
|
3406
3561
|
propFrameRef.current = propFrame;
|
|
3562
|
+
syncCarriers();
|
|
3407
3563
|
propSyncRef.current?.();
|
|
3408
3564
|
}
|
|
3409
3565
|
}
|
|
@@ -3762,6 +3918,13 @@ export default function App() {
|
|
|
3762
3918
|
return output;
|
|
3763
3919
|
}
|
|
3764
3920
|
|
|
3921
|
+
// One storyboard panel as a picture: the shot's first frame through the
|
|
3922
|
+
// offscreen capture rig, as a PNG data URL (null while the renderer has no
|
|
3923
|
+
// frame). The contact sheet and the Board dock tab share it.
|
|
3924
|
+
function capturePanelThumbnail(entry) {
|
|
3925
|
+
return captureShotFramePng(entry.startFrame);
|
|
3926
|
+
}
|
|
3927
|
+
|
|
3765
3928
|
/** Contact sheet of the whole cut: one thumbnail and prompt per shot. */
|
|
3766
3929
|
async function exportStoryboard() {
|
|
3767
3930
|
try {
|
|
@@ -3769,7 +3932,7 @@ export default function App() {
|
|
|
3769
3932
|
setToast(ko("Composing the storyboard…", "스토리보드 구성 중…"));
|
|
3770
3933
|
const cells = [];
|
|
3771
3934
|
for (const [index, entry] of shots.entries()) {
|
|
3772
|
-
const dataUrl =
|
|
3935
|
+
const dataUrl = capturePanelThumbnail(entry);
|
|
3773
3936
|
const meta = packMetaForShot(entry, index);
|
|
3774
3937
|
cells.push({
|
|
3775
3938
|
title: storyboardLine(`${index + 1}. ${entry.name}`),
|
|
@@ -3789,18 +3952,7 @@ export default function App() {
|
|
|
3789
3952
|
// the fit, so the frame is scaled to height) and a caption block wide
|
|
3790
3953
|
// enough for the 90 characters the composer draws at this size.
|
|
3791
3954
|
cell: { width: 480, height: 300 },
|
|
3792
|
-
createCanvas:
|
|
3793
|
-
const element = document.createElement("canvas");
|
|
3794
|
-
element.width = width;
|
|
3795
|
-
element.height = height;
|
|
3796
|
-
const ctx = element.getContext("2d");
|
|
3797
|
-
// The sheet is a deliverable, so it uses the studio's own type
|
|
3798
|
-
// stack rather than the canvas default (10px sans-serif).
|
|
3799
|
-
ctx.font = STORYBOARD_FONT;
|
|
3800
|
-
ctx.textAlign = "left";
|
|
3801
|
-
ctx.textBaseline = "top";
|
|
3802
|
-
return element;
|
|
3803
|
-
},
|
|
3955
|
+
createCanvas: storyboardCanvas,
|
|
3804
3956
|
});
|
|
3805
3957
|
saveDownload(canvas.toDataURL("image/png"), "cozyclay-storyboard.png");
|
|
3806
3958
|
setToast(isKo ? `스토리보드 저장됨 · ${cells.length}샷` : `Storyboard saved · ${cells.length} shots`);
|
|
@@ -3810,6 +3962,162 @@ export default function App() {
|
|
|
3810
3962
|
}
|
|
3811
3963
|
}
|
|
3812
3964
|
|
|
3965
|
+
function storyboardCanvas(width, height) {
|
|
3966
|
+
const element = document.createElement("canvas");
|
|
3967
|
+
element.width = width;
|
|
3968
|
+
element.height = height;
|
|
3969
|
+
const ctx = element.getContext("2d");
|
|
3970
|
+
// The sheet is a deliverable, so it uses the studio's own type
|
|
3971
|
+
// stack rather than the canvas default (10px sans-serif).
|
|
3972
|
+
ctx.font = STORYBOARD_FONT;
|
|
3973
|
+
ctx.textAlign = "left";
|
|
3974
|
+
ctx.textBaseline = "top";
|
|
3975
|
+
return element;
|
|
3976
|
+
}
|
|
3977
|
+
|
|
3978
|
+
/* ===================== storyboard exports (#644) =======================
|
|
3979
|
+
* A storyboard project's panels are its stills, in still order. Each
|
|
3980
|
+
* export runs as one export job (status panel, cancel, retry, analytics
|
|
3981
|
+
* export_kind contact_sheet | panel_pack | animatic) and draws its frames
|
|
3982
|
+
* through withExportFrame, so per-panel cast overrides and proxy paths
|
|
3983
|
+
* land exactly as the preview shows them.
|
|
3984
|
+
*/
|
|
3985
|
+
|
|
3986
|
+
function storyboardPanels(list = shots) {
|
|
3987
|
+
return list.filter((entry) => entry.kind === "still").sort((a, b) => a.startFrame - b.startFrame);
|
|
3988
|
+
}
|
|
3989
|
+
|
|
3990
|
+
const noPanelsMessage = () => ko("Add a panel first — storyboard exports describe panels", "패널을 먼저 추가하세요 — 스토리보드 내보내기는 패널을 담습니다");
|
|
3991
|
+
|
|
3992
|
+
// The panel's stylized picture as PNG bytes, or null when it has none (or
|
|
3993
|
+
// its asset is gone from this browser).
|
|
3994
|
+
async function panelStylizedPng(entry) {
|
|
3995
|
+
if (!entry.stylizedAssetId) return null;
|
|
3996
|
+
const record = await assetRecord(entry.stylizedAssetId);
|
|
3997
|
+
if (!record) {
|
|
3998
|
+
console.warn(`[cozyclay] stylized panel ${entry.stylizedAssetId} is missing; exporting the greybox only`);
|
|
3999
|
+
return null;
|
|
4000
|
+
}
|
|
4001
|
+
const bytes = new Uint8Array(record.bytes);
|
|
4002
|
+
if (record.type === "image/png") return bytes;
|
|
4003
|
+
const bitmap = await createImageBitmap(new Blob([bytes], { type: record.type }));
|
|
4004
|
+
try {
|
|
4005
|
+
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
|
|
4006
|
+
canvas.getContext("2d").drawImage(bitmap, 0, 0);
|
|
4007
|
+
return new Uint8Array(await (await canvas.convertToBlob({ type: "image/png" })).arrayBuffer());
|
|
4008
|
+
} finally {
|
|
4009
|
+
bitmap.close();
|
|
4010
|
+
}
|
|
4011
|
+
}
|
|
4012
|
+
|
|
4013
|
+
async function loadPngBytes(bytes) {
|
|
4014
|
+
const url = URL.createObjectURL(new Blob([bytes], { type: "image/png" }));
|
|
4015
|
+
try { return await loadImage(url); }
|
|
4016
|
+
finally { URL.revokeObjectURL(url); }
|
|
4017
|
+
}
|
|
4018
|
+
|
|
4019
|
+
const panelFolder = (order) => `panel-${String(order + 1).padStart(2, "0")}`;
|
|
4020
|
+
|
|
4021
|
+
/** Contact sheet of the storyboard: the stylized picture with the greybox
|
|
4022
|
+
* inset when a panel has one, and the panel caption under it. */
|
|
4023
|
+
async function exportContactSheet() {
|
|
4024
|
+
if (recRef.current) return null;
|
|
4025
|
+
if (!storyboardPanels().length) { setToast(noPanelsMessage()); return null; }
|
|
4026
|
+
return executeExportRequest(exportRequest("contact_sheet", async (job) => {
|
|
4027
|
+
const all = job.request.context.shots;
|
|
4028
|
+
const panels = storyboardPanels(all);
|
|
4029
|
+
updateExportStatus(job, "preparing", { stage: "frames", cancellable: true });
|
|
4030
|
+
const cells = [];
|
|
4031
|
+
for (const [order, entry] of panels.entries()) {
|
|
4032
|
+
const dataUrl = captureShotFramePng(entry.startFrame);
|
|
4033
|
+
if (!dataUrl) throw Object.assign(new Error(ko("The shot renderer is not ready", "샷 렌더러가 아직 준비되지 않았어요")), { exportFailureCode: "render_failed" });
|
|
4034
|
+
const stylized = await panelStylizedPng(entry);
|
|
4035
|
+
const meta = packMetaForShot(entry, all.indexOf(entry));
|
|
4036
|
+
cells.push({
|
|
4037
|
+
title: storyboardLine(`${order + 1}. ${entry.name}`),
|
|
4038
|
+
durationSeconds: Number(((entry.endFrame - entry.startFrame + 1) / tlFps).toFixed(2)),
|
|
4039
|
+
prompt: storyboardCaption(buildShotPrompt(meta, { target: "image" })),
|
|
4040
|
+
caption: storyboardLine((entry.caption ?? "").trim()),
|
|
4041
|
+
image: await loadImage(dataUrl),
|
|
4042
|
+
stylized: stylized ? await loadPngBytes(stylized) : null,
|
|
4043
|
+
});
|
|
4044
|
+
await exportBoundary(job);
|
|
4045
|
+
}
|
|
4046
|
+
const canvas = composeStoryboard({ shots: cells, columns: Math.min(3, cells.length), cell: { width: 480, height: 300 }, createCanvas: storyboardCanvas });
|
|
4047
|
+
job.controller.signal.throwIfAborted();
|
|
4048
|
+
saveDownload(canvas.toDataURL("image/png"), "cozyclay-storyboard.png");
|
|
4049
|
+
setToast(isKo ? `콘택트 시트 다운로드 요청 · 패널 ${cells.length}개` : `Download requested: contact sheet · ${cells.length} panels`);
|
|
4050
|
+
return { fileName: "cozyclay-storyboard.png", panelCount: cells.length };
|
|
4051
|
+
}));
|
|
4052
|
+
}
|
|
4053
|
+
|
|
4054
|
+
// One panel's pack: the greybox as first.png, the stylized picture when
|
|
4055
|
+
// there is one, the still's camera and hold, and the image prompt plus caption.
|
|
4056
|
+
async function panelPackArgs(entry, order, all) {
|
|
4057
|
+
const firstUrl = captureShotFramePng(entry.startFrame);
|
|
4058
|
+
if (!firstUrl) throw Object.assign(new Error(ko("The shot renderer is not ready", "샷 렌더러가 아직 준비되지 않았어요")), { exportFailureCode: "render_failed" });
|
|
4059
|
+
const meta = packMetaForShot(entry, all.indexOf(entry));
|
|
4060
|
+
const caption = (entry.caption ?? "").trim();
|
|
4061
|
+
return {
|
|
4062
|
+
shot: { title: entry.name, index: order + 1, startFrame: entry.startFrame, endFrame: entry.endFrame, kind: "still" },
|
|
4063
|
+
fps: tlFps,
|
|
4064
|
+
firstFramePng: dataUrlToBytes(firstUrl),
|
|
4065
|
+
lastFramePng: null,
|
|
4066
|
+
clip: null,
|
|
4067
|
+
stylizedPng: await panelStylizedPng(entry),
|
|
4068
|
+
camera: { ...meta, framing: { start: shotFramingAtFrame(entry, entry.startFrame) } },
|
|
4069
|
+
prompt: buildShotPrompt(meta, { target: "image" }) + (caption ? `\nCAPTION: ${caption}` : ""),
|
|
4070
|
+
folder: panelFolder(order),
|
|
4071
|
+
};
|
|
4072
|
+
}
|
|
4073
|
+
|
|
4074
|
+
/** Panel pack (zip) for one panel — the given one, else the selected one,
|
|
4075
|
+
* else the one under the playhead, else the first — or, with `every`, one
|
|
4076
|
+
* zip holding a panel-NN/ folder per panel. */
|
|
4077
|
+
async function exportPanelPacks(every = false, shotId = null) {
|
|
4078
|
+
if (recRef.current) return null;
|
|
4079
|
+
const panels = storyboardPanels();
|
|
4080
|
+
if (!panels.length) { setToast(noPanelsMessage()); return null; }
|
|
4081
|
+
const target = shotId ? panels.find((entry) => entry.id === shotId)
|
|
4082
|
+
: panels.find((entry) => entry.id === activeShot?.id)
|
|
4083
|
+
?? panels.find((entry) => tlFrame >= entry.startFrame && tlFrame <= entry.endFrame) ?? panels[0];
|
|
4084
|
+
if (!target) return null;
|
|
4085
|
+
return executeExportRequest(exportRequest("panel_pack", async (job) => {
|
|
4086
|
+
const all = job.request.context.shots;
|
|
4087
|
+
const contextPanels = storyboardPanels(all);
|
|
4088
|
+
const targets = every ? contextPanels : contextPanels.filter((entry) => entry.id === target.id);
|
|
4089
|
+
updateExportStatus(job, "preparing", { stage: "frames", cancellable: true });
|
|
4090
|
+
const packs = [];
|
|
4091
|
+
for (const [step, entry] of targets.entries()) {
|
|
4092
|
+
job.label = ko(`Panel ${step + 1} of ${targets.length}`, `패널 ${step + 1} / ${targets.length}`);
|
|
4093
|
+
packs.push(await panelPackArgs(entry, contextPanels.indexOf(entry), all));
|
|
4094
|
+
await exportBoundary(job);
|
|
4095
|
+
}
|
|
4096
|
+
updateExportStatus(job, "finalizing", { stage: "archive", cancellable: true });
|
|
4097
|
+
await exportBoundary(job);
|
|
4098
|
+
const entries = packs.flatMap((args) => keyframePackEntries(args));
|
|
4099
|
+
const bytes = every ? buildZip(entries) : buildKeyframePack(packs[0]);
|
|
4100
|
+
const name = every ? "cozyclay-panels.zip" : `cozyclay-${packs[0].folder}-${slugifyTitle(target.name)}.zip`;
|
|
4101
|
+
await exportBoundary(job);
|
|
4102
|
+
const url = URL.createObjectURL(new Blob([bytes], { type: "application/zip" }));
|
|
4103
|
+
try { saveDownload(url, name); }
|
|
4104
|
+
finally { setTimeout(() => URL.revokeObjectURL(url), 10_000); }
|
|
4105
|
+
setToast(isKo ? `${name} 다운로드 요청 · 파일 ${entries.length}개` : `Download requested: ${name} · ${entries.length} files`);
|
|
4106
|
+
return { fileName: name, entries: entries.map((entry) => entry.name) };
|
|
4107
|
+
}));
|
|
4108
|
+
}
|
|
4109
|
+
|
|
4110
|
+
/** Animatic (mp4): every panel held for its hold, from frame 0 to the last
|
|
4111
|
+
* still's end, at the production fps. */
|
|
4112
|
+
async function exportAnimatic() {
|
|
4113
|
+
if (recRef.current) return null;
|
|
4114
|
+
const panels = storyboardPanels();
|
|
4115
|
+
if (!panels.length) { setToast(noPanelsMessage()); return null; }
|
|
4116
|
+
const endFrame = panels.at(-1).endFrame;
|
|
4117
|
+
return executeExportRequest(exportRequest("animatic",
|
|
4118
|
+
(job) => runShotExport({ startFrame: 0, endFrame, fileName: "cozyclay-animatic.mp4" }, job)));
|
|
4119
|
+
}
|
|
4120
|
+
|
|
3813
4121
|
function captureCurrentFraming() {
|
|
3814
4122
|
const cam = shotCamRef.current;
|
|
3815
4123
|
const pos = cam ? cam.position : cameraPos;
|
|
@@ -3910,11 +4218,11 @@ export default function App() {
|
|
|
3910
4218
|
generate: (kind) => void generateI2vMotion(kind),
|
|
3911
4219
|
enableShaded: () => runStudioAction("view.setPartColours", { mode: "shaded" }),
|
|
3912
4220
|
};
|
|
3913
|
-
const viewLooksActive = gridView || autoColor || partColoursEnabled;
|
|
4221
|
+
const viewLooksActive = gridView || autoColor || partColoursEnabled || guideMode !== "off";
|
|
3914
4222
|
const rigSelection = parseRigNodeId(selectedHierarchyId);
|
|
3915
4223
|
const isRigSelection = rigSelection !== null;
|
|
3916
4224
|
const inspectorHasContent = isSceneSelection || isCameraSelection || isCharacterSelection || isRigSelection
|
|
3917
|
-
|| selectedHierarchyId === "environment" || selectedHierarchyId === "props" || selectedHierarchyId === "light" || Boolean(selectedSceneObject);
|
|
4225
|
+
|| selectedHierarchyId === "environment" || selectedHierarchyId === "props" || selectedHierarchyId === "light" || Boolean(selectedSceneObject) || sculptMode;
|
|
3918
4226
|
|
|
3919
4227
|
useEffect(() => () => {
|
|
3920
4228
|
if (multiModelObjectUrlRef.current) URL.revokeObjectURL(multiModelObjectUrlRef.current);
|
|
@@ -4005,7 +4313,7 @@ export default function App() {
|
|
|
4005
4313
|
useEffect(() => {
|
|
4006
4314
|
// The ACTIVE member's clip only: its own corrections are applied by the
|
|
4007
4315
|
// evaluate effect below, after its editing state settles.
|
|
4008
|
-
poseMemberAtFrame(rigs[activeChar.id], motion, null, tlFrame);
|
|
4316
|
+
if (activeCapabilities.rig) poseMemberAtFrame(rigs[activeChar.id], motion, null, tlFrame);
|
|
4009
4317
|
poseOtherCastMembers(tlFrame);
|
|
4010
4318
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
4011
4319
|
}, [characters, activeChar.id, motion, rigs, tlFrame, ikTick]);
|
|
@@ -4022,7 +4330,7 @@ export default function App() {
|
|
|
4022
4330
|
// character's rig (re)loads. A rig missing any bone resolves to null and
|
|
4023
4331
|
// IK mode stays unavailable.
|
|
4024
4332
|
useEffect(() => {
|
|
4025
|
-
const resolved = resolveIkRig(activeRig);
|
|
4333
|
+
const resolved = activeCapabilities.ik ? resolveIkRig(activeRig) : null;
|
|
4026
4334
|
const chains = resolved ? resolved.chains : null;
|
|
4027
4335
|
setIkChains(chains);
|
|
4028
4336
|
setIkFkJoints(resolved ? resolved.fkJoints : null);
|
|
@@ -4032,7 +4340,7 @@ export default function App() {
|
|
|
4032
4340
|
ikStateRef.current.fkJoints = resolved ? resolved.fkJoints : null;
|
|
4033
4341
|
ikStateRef.current.rig = chains ? activeRig : null;
|
|
4034
4342
|
if (!chains) leaveIkMode();
|
|
4035
|
-
}, [activeRig]);
|
|
4343
|
+
}, [activeRig, activeCapabilities.ik]);
|
|
4036
4344
|
|
|
4037
4345
|
// Whether the collision capsules can be built for this rig at all. Bone
|
|
4038
4346
|
// lookups only — no mesh measurement — so it is cheap enough to hang off
|
|
@@ -4041,7 +4349,7 @@ export default function App() {
|
|
|
4041
4349
|
// a SEPARATE question from `ikChains`: the two collision buttons disable
|
|
4042
4350
|
// on it rather than offering a click whose only possible answer is "not
|
|
4043
4351
|
// supported".
|
|
4044
|
-
const collisionCleanupSupported = useMemo(() => supportsCollisionCleanup(activeRig), [activeRig]);
|
|
4352
|
+
const collisionCleanupSupported = useMemo(() => activeCapabilities.physics && supportsCollisionCleanup(activeRig), [activeRig, activeCapabilities.physics]);
|
|
4045
4353
|
|
|
4046
4354
|
//
|
|
4047
4355
|
|
|
@@ -4078,11 +4386,12 @@ export default function App() {
|
|
|
4078
4386
|
// Preview changes may re-run this effect without a playhead change.
|
|
4079
4387
|
// Always re-establish the motion base before adding a correction.
|
|
4080
4388
|
if (motion) poseMemberAtFrame(activeRig, motion, null, tlFrame);
|
|
4081
|
-
const layer =
|
|
4389
|
+
const layer = pendingIkEdit?.characterId === activeChar.id ? pendingIkEdit.candidate
|
|
4390
|
+
: physicsPreview && physicsShow ? physicsPreview.candidate : ikStateRef.current;
|
|
4082
4391
|
if (ikMode || layer.keys.size > 0) {
|
|
4083
4392
|
ikEvaluate(ikChains, layer, tlFrame, ikFkJoints, motion ? IK_CORRECTION_BLEND_FRAMES : 0);
|
|
4084
4393
|
}
|
|
4085
|
-
}, [ikMode, ikChains, activeRig, motion, posing, tlFrame, ikTick, ikFkJoints, physicsPreview, physicsShow]);
|
|
4394
|
+
}, [ikMode, ikChains, activeRig, motion, posing, tlFrame, ikTick, ikFkJoints, physicsPreview, physicsShow, pendingIkEdit, activeChar.id]);
|
|
4086
4395
|
|
|
4087
4396
|
// Re-seat the handles on the keyed pose when the FRAME changes with IK
|
|
4088
4397
|
// on — scrubbing to frame 39 shows that frame's interpolated pose AND
|
|
@@ -4103,6 +4412,7 @@ export default function App() {
|
|
|
4103
4412
|
// (tools/ardy/visual-qa.mjs). Harmless in normal use.
|
|
4104
4413
|
useEffect(() => {
|
|
4105
4414
|
window.__cozyclay = {
|
|
4415
|
+
runStudioAction,
|
|
4106
4416
|
runArdy: (options) => appContext.live.state.runArdy(options),
|
|
4107
4417
|
rigA: activeRig, motion, tlFrame, frameCount: tlFrameCount, playing: tlPlaying, ikMode, ikChains, ikFocus, contactRadii: ikChains?.values().next().value?.contactRadii ?? null, ik: ikStateRef.current,
|
|
4108
4418
|
rangePins, rangePinResiduals,
|
|
@@ -4209,22 +4519,33 @@ export default function App() {
|
|
|
4209
4519
|
insetPane: insetPaneRef.current,
|
|
4210
4520
|
mainPane: mainPaneRef.current,
|
|
4211
4521
|
// the selected prop's route, so QA can aim a gesture at the line
|
|
4212
|
-
objectPath:
|
|
4522
|
+
objectPath: routeOwner?.path ?? null,
|
|
4523
|
+
objectPathOwnerId: routeOwner?.id ?? null,
|
|
4213
4524
|
pathPointIndex,
|
|
4214
|
-
pathHandlesEnabled: !preview && !lookThroughShot && !ikMode && !posing && !!
|
|
4525
|
+
pathHandlesEnabled: !preview && !lookThroughShot && !ikMode && !posing && !!routeOwner?.path,
|
|
4215
4526
|
addSceneObject: (kind, placement = {}) => addSceneObject(kind, placement),
|
|
4216
4527
|
scrub: (frame) => setTlFrame(Math.max(0, Math.min(tlFrameCount - 1, Math.round(frame)))),
|
|
4217
4528
|
pause: () => setTlPlaying(false),
|
|
4218
4529
|
// Motion-trail QA surface: read the current trail policy and drive the
|
|
4219
4530
|
// same drag -> preview -> pending-edit path headless checks cannot reach
|
|
4220
4531
|
// through synthetic pointers reliably.
|
|
4221
|
-
trail: { falloffFrames: trailFalloffFrames, falloffS: trailFalloffS, edit: trailEdit, tool: ikEditTool, visible: showTrails },
|
|
4532
|
+
trail: { falloffFrames: trailFalloffFrames, falloffS: trailFalloffS, edit: trailEdit, tool: ikEditTool, visible: showTrails, activeTrackId: trailTrackFocus, visibleTracks: trailTrackFocus ? [trailTrackFocus] : null },
|
|
4222
4533
|
trailPoints: (jointName = "Hips") => jointTrailPoints(motion, jointName, { baseY: activeChar.y ?? 0, scale: activeChar.scale ?? 1 }),
|
|
4223
4534
|
trailEditApply: (grabFrame, delta) => {
|
|
4224
|
-
onTrailDragStart({ grabFrame });
|
|
4535
|
+
onTrailDragStart({ track: "hips", grabFrame });
|
|
4225
4536
|
onTrailDragPreview({ track: "hips", grabFrame, delta });
|
|
4226
4537
|
onTrailDragEnd({ track: "hips", grabFrame, delta });
|
|
4227
4538
|
},
|
|
4539
|
+
// QA-only IK drag seam: resolves the same production solve/end path as
|
|
4540
|
+
// the viewport manipulator, with a deterministic world-space offset.
|
|
4541
|
+
ikPreviewApply: (track = "leftHand", offset = { x: 0.02, y: 0, z: 0 }) => {
|
|
4542
|
+
const chain = ikChains?.get(track), bone = chain?.bones?.[2];
|
|
4543
|
+
if (!bone) return false;
|
|
4544
|
+
const target = bone.getWorldPosition(new THREE.Vector3()).add(new THREE.Vector3(offset.x ?? 0, offset.y ?? 0, offset.z ?? 0));
|
|
4545
|
+
ikSolve("chain", track, target);
|
|
4546
|
+
ikDragEnd();
|
|
4547
|
+
return true;
|
|
4548
|
+
},
|
|
4228
4549
|
trailRegenerate: runTrailRegeneration,
|
|
4229
4550
|
// Fix-collisions QA surface: live penetration readout for headless
|
|
4230
4551
|
// checks; the fix itself runs through the buttons / runFixCollisions.
|
|
@@ -4271,7 +4592,7 @@ export default function App() {
|
|
|
4271
4592
|
// close over them: a stale closure would report the set as it was two
|
|
4272
4593
|
// edits ago — and, after an undo that removes a subject, would keep
|
|
4273
4594
|
// reporting the ghost's capsules.
|
|
4274
|
-
}, [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]);
|
|
4595
|
+
}, [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]);
|
|
4275
4596
|
// QA hook (plan §6.5): exposes history depth and the present === objects
|
|
4276
4597
|
// invariant so the browser suite can assert undo entry counts directly.
|
|
4277
4598
|
// Reads live store state at call time; re-registered after every render.
|
|
@@ -4320,19 +4641,37 @@ export default function App() {
|
|
|
4320
4641
|
fovDeg,
|
|
4321
4642
|
filmback,
|
|
4322
4643
|
}), [tlFrameCount, tlFps, charA, motion, fovDeg, filmback]);
|
|
4323
|
-
|
|
4324
|
-
|
|
4325
|
-
|
|
4644
|
+
// The camera's subject grouped under a routed object (#655) stands where
|
|
4645
|
+
// that object carries it: its played root through the object's carry,
|
|
4646
|
+
// frame by frame — the same carry the renderer gives the body. Null when it
|
|
4647
|
+
// rides nothing, so every reader below keeps the plain played root.
|
|
4648
|
+
const subjectCarry = useMemo(() => {
|
|
4649
|
+
if (!charA.parent) return null;
|
|
4650
|
+
const objects = new Map(sceneObjects.map((object) => [object.id, object]));
|
|
4651
|
+
const take = { frameCount: tlFrameCount, fps: tlFps };
|
|
4652
|
+
return (point, frame) => {
|
|
4653
|
+
if (!point) return point;
|
|
4654
|
+
const at = carriedPointAt(objects, charA.parent, { x: point.x, y: charA.y ?? 0, z: point.z }, frame, take);
|
|
4655
|
+
return { x: at.x, z: at.z };
|
|
4656
|
+
};
|
|
4657
|
+
}, [charA.parent, charA.y, sceneObjects, tlFrameCount, tlFps]);
|
|
4658
|
+
const motionPos = useMemo(() => {
|
|
4659
|
+
if (!motion && !subjectCarry) return null;
|
|
4660
|
+
const subject = sampleAt(playbackSceneBase, null, tlFrame).subject;
|
|
4661
|
+
return subjectCarry ? subjectCarry(subject, tlFrame) : subject;
|
|
4662
|
+
}, [motion, subjectCarry, playbackSceneBase, tlFrame]);
|
|
4326
4663
|
|
|
4327
4664
|
// The subject's full per-frame scene trajectory — what the follow camera
|
|
4328
|
-
// is derived from. Without a loaded motion
|
|
4329
|
-
// the follow camera simply composes a static frame.
|
|
4665
|
+
// is derived from. Without a loaded motion, and riding nothing, the subject
|
|
4666
|
+
// stands still and the follow camera simply composes a static frame.
|
|
4330
4667
|
const subjectTrack = useMemo(() => {
|
|
4331
4668
|
if (!shots.some((shot) => shot.camera?.mode === "follow" || shot.camera?.mode === "rail")) return null;
|
|
4332
4669
|
const frames = Math.max(tlFrameCount, 1);
|
|
4333
|
-
return Array.from({ length: frames }, (_, frame) =>
|
|
4334
|
-
sampleAt(playbackSceneBase, null, frame).subject
|
|
4335
|
-
|
|
4670
|
+
return Array.from({ length: frames }, (_, frame) => {
|
|
4671
|
+
const subject = sampleAt(playbackSceneBase, null, frame).subject;
|
|
4672
|
+
return subjectCarry ? subjectCarry(subject, frame) : subject;
|
|
4673
|
+
});
|
|
4674
|
+
}, [shots, playbackSceneBase, tlFrameCount, subjectCarry]);
|
|
4336
4675
|
|
|
4337
4676
|
// The dense rail (spline through the drawn control points) — shared by
|
|
4338
4677
|
// the follow controller and the Top-View display.
|
|
@@ -4443,7 +4782,7 @@ export default function App() {
|
|
|
4443
4782
|
? { label: ko("ROOT PATH", "루트 경로"), kind: "root" }
|
|
4444
4783
|
: null;
|
|
4445
4784
|
|
|
4446
|
-
function bufferToPng(buffer, output = shotOutput) {
|
|
4785
|
+
function bufferToPng(buffer, output = shotOutput, decorate = null) {
|
|
4447
4786
|
const canvas = document.createElement("canvas");
|
|
4448
4787
|
if (output === shotOutput) {
|
|
4449
4788
|
canvas.width = shotOutput.width;
|
|
@@ -4463,6 +4802,7 @@ export default function App() {
|
|
|
4463
4802
|
);
|
|
4464
4803
|
}
|
|
4465
4804
|
ctx.putImageData(image, 0, 0);
|
|
4805
|
+
decorate?.(ctx);
|
|
4466
4806
|
return canvas.toDataURL("image/png");
|
|
4467
4807
|
}
|
|
4468
4808
|
|
|
@@ -4476,6 +4816,12 @@ export default function App() {
|
|
|
4476
4816
|
cam.rotation.order = "YXZ";
|
|
4477
4817
|
cam.rotation.set(framing.pitch, framing.yaw, 0);
|
|
4478
4818
|
cam.fov = framing.fovDeg;
|
|
4819
|
+
const prevAspect = cam.aspect;
|
|
4820
|
+
const offset = framing.viewOffset;
|
|
4821
|
+
if (offset) {
|
|
4822
|
+
cam.aspect = offset.fullWidth / offset.fullHeight;
|
|
4823
|
+
cam.setViewOffset(offset.fullWidth, offset.fullHeight, offset.x, offset.y, offset.width, offset.height);
|
|
4824
|
+
}
|
|
4479
4825
|
cam.updateProjectionMatrix();
|
|
4480
4826
|
const needsOwnTarget = output.width !== shotOutput.width || output.height !== shotOutput.height;
|
|
4481
4827
|
const capture = needsOwnTarget && typeof captureRef.current.createExportCapture === "function"
|
|
@@ -4491,10 +4837,85 @@ export default function App() {
|
|
|
4491
4837
|
look.current.yaw = prev.yaw;
|
|
4492
4838
|
look.current.pitch = prev.pitch;
|
|
4493
4839
|
cam.fov = prev.fov;
|
|
4840
|
+
if (offset) {
|
|
4841
|
+
cam.clearViewOffset();
|
|
4842
|
+
cam.aspect = prevAspect;
|
|
4843
|
+
}
|
|
4494
4844
|
cam.updateProjectionMatrix();
|
|
4495
4845
|
}
|
|
4496
4846
|
}
|
|
4497
4847
|
|
|
4848
|
+
/** A live capture command's requested `output` size, or null to use its default. */
|
|
4849
|
+
function requestedCaptureOutput(args) {
|
|
4850
|
+
const requested = args?.output;
|
|
4851
|
+
return Number.isFinite(requested?.width) && Number.isFinite(requested?.height)
|
|
4852
|
+
? { width: Math.round(requested.width), height: Math.round(requested.height) }
|
|
4853
|
+
: null;
|
|
4854
|
+
}
|
|
4855
|
+
|
|
4856
|
+
/** The Top View as a PNG: a 2D data diagram drawn from editor state by
|
|
4857
|
+
src/plan-diagram.js (grid, footprints, cast, every shot camera, the current
|
|
4858
|
+
shot's action line and blocked sight lines). No render is involved. */
|
|
4859
|
+
function capturePlanPng(output = { width: 1280, height: 720 }) {
|
|
4860
|
+
const live = appContext.live.state, s = readStudioState();
|
|
4861
|
+
const canvas = document.createElement("canvas");
|
|
4862
|
+
canvas.width = output.width;
|
|
4863
|
+
canvas.height = output.height;
|
|
4864
|
+
drawPlanDiagram(canvas.getContext("2d"), { frame: s.view.frame, frameCount: s.frameCount, objects: s.objects, characters: s.characters, shotDocument: { shots: s.shots }, camera: s.camera },
|
|
4865
|
+
{ width: output.width, height: output.height, ports: { bounds: studioBounds }, currentShotId: live.activeShotId, aspect: shotOutput.width / shotOutput.height });
|
|
4866
|
+
return { dataUrl: canvas.toDataURL("image/png"), width: output.width, height: output.height, frame: live.timeline.currentFrame, shotId: live.activeShotId };
|
|
4867
|
+
}
|
|
4868
|
+
|
|
4869
|
+
/** The canvas runs under the floating panels, so "what I see" is only the uncovered middle.
|
|
4870
|
+
Returns the largest centred `aspect` rectangle inside that region as a camera view offset. */
|
|
4871
|
+
function visibleViewOffset(aspect) {
|
|
4872
|
+
const canvas = document.querySelector(".viewport canvas");
|
|
4873
|
+
if (!canvas) return null;
|
|
4874
|
+
const box = canvas.getBoundingClientRect();
|
|
4875
|
+
const edge = (selector, side) => {
|
|
4876
|
+
const rect = document.querySelector(selector)?.getBoundingClientRect();
|
|
4877
|
+
return rect && rect.width > 0 && rect.height > 0 ? rect[side] : null;
|
|
4878
|
+
};
|
|
4879
|
+
const left = Math.max(box.left, edge(".studio-left-column", "right") ?? box.left);
|
|
4880
|
+
const right = Math.min(box.right, edge(".studio-right-column", "left") ?? box.right);
|
|
4881
|
+
const top = Math.max(box.top, edge(".topbar", "bottom") ?? box.top);
|
|
4882
|
+
const bottom = Math.min(box.bottom, edge(".studio-dock-slot", "top") ?? box.bottom);
|
|
4883
|
+
if (right - left < 50 || bottom - top < 50) return null;
|
|
4884
|
+
const width = Math.min(right - left, (bottom - top) * aspect);
|
|
4885
|
+
const height = width / aspect;
|
|
4886
|
+
return {
|
|
4887
|
+
fullWidth: box.width, fullHeight: box.height,
|
|
4888
|
+
x: (left + right) / 2 - width / 2 - box.left, y: (top + bottom) / 2 - height / 2 - box.top,
|
|
4889
|
+
width, height,
|
|
4890
|
+
};
|
|
4891
|
+
}
|
|
4892
|
+
|
|
4893
|
+
/** Pose mode's image generation: the shot camera's view of the pose as it stands, at the
|
|
4894
|
+
delivery size, plus the scene's identity/environment references. The pose-mode object
|
|
4895
|
+
fade is lifted for this draw only (props are re-synced right after, so a demand frame loop keeps it), and editor
|
|
4896
|
+
overlays such as motion trails are hidden around it. */
|
|
4897
|
+
function capturePoseImage(source = "view") {
|
|
4898
|
+
const viewCam = source === "view" ? poserCamRef.current : null;
|
|
4899
|
+
let framing = captureCurrentFraming();
|
|
4900
|
+
if (viewCam) {
|
|
4901
|
+
const dir = viewCam.getWorldDirection(new THREE.Vector3());
|
|
4902
|
+
const pos = viewCam.getWorldPosition(new THREE.Vector3());
|
|
4903
|
+
framing = captureFraming({ pos, yaw: Math.atan2(-dir.x, -dir.z), pitch: Math.asin(THREE.MathUtils.clamp(dir.y, -1, 1)), fovDeg: viewCam.fov });
|
|
4904
|
+
framing.viewOffset = visibleViewOffset(shotOutput.width / shotOutput.height);
|
|
4905
|
+
}
|
|
4906
|
+
const scene = captureRef.current?.scene;
|
|
4907
|
+
const overlays = [];
|
|
4908
|
+
scene?.traverse((node) => {
|
|
4909
|
+
if (node.userData.editorOverlay && node.visible) { node.visible = false; overlays.push(node); }
|
|
4910
|
+
});
|
|
4911
|
+
try {
|
|
4912
|
+
const dataUrl = withPoseFadeLifted(propSyncRef.current, () => captureFramingPng(framing, shotOutput));
|
|
4913
|
+
return dataUrl ? { dataUrl, references: captureShotReferences() } : null;
|
|
4914
|
+
} finally {
|
|
4915
|
+
for (const node of overlays) node.visible = true;
|
|
4916
|
+
}
|
|
4917
|
+
}
|
|
4918
|
+
|
|
4498
4919
|
function copyPrompt(prompt) {
|
|
4499
4920
|
const write = navigator.clipboard?.writeText(prompt);
|
|
4500
4921
|
if (!write) return;
|
|
@@ -6428,13 +6849,32 @@ export default function App() {
|
|
|
6428
6849
|
preserveAuthoredMotion: Boolean(c.layer?.waypoints?.length) }]));
|
|
6429
6850
|
return { host: { workspaceId: liveWorkspaceIdRef.current, documentEpoch: studioDocumentEpochRef.current,
|
|
6430
6851
|
sceneId: activeSceneIdRef.current, sceneEpoch: studioSceneEpochRef.current }, workspaceHandle: liveWorkspaceHandleRef.current,
|
|
6431
|
-
sceneName: live.scenes.find(s => s.id === activeSceneIdRef.current)?.name ?? "Untitled Scene", aspect: live.stage.shotAspect, stage: live.stage,
|
|
6852
|
+
sceneName: live.scenes.find(s => s.id === activeSceneIdRef.current)?.name ?? "Untitled Scene", previsMode: scenesDomain.previsMode, aspect: live.stage.shotAspect, stage: live.stage,
|
|
6432
6853
|
objects: storeRef.current.objects, characters: list, targets, shots: live.shots, frameCount: live.timeline.frameCount,
|
|
6433
6854
|
selection: live.studioSelection, activeCharacterId: live.activeCharacterId, selectedShotId: live.studioShotId,
|
|
6434
6855
|
view: live.studioView, camera: live.studioCamera ?? readStudioCamera(), filmback: live.filmback, manual: manualCameraOverrideRef.current,
|
|
6435
6856
|
bridgeReady: Boolean(bridge?.ok), busy: storeRef.current.present() !== storeRef.current.objects || Boolean(studioGestureRef.current ||
|
|
6436
6857
|
ikBodyDragRef.current || lineDragRef.current || lineDrawRef.current || linePinDragRef.current || live.studioPhysicsRunning || recRef.current) };
|
|
6437
6858
|
}
|
|
6859
|
+
function sampleStudioStateAt(frame, current = readStudioState()) {
|
|
6860
|
+
const shot = shotAtFrame(current.shots ?? current.shotDocument?.shots ?? [], frame);
|
|
6861
|
+
const sampledPlayback = sampleAt(playbackScene, shot, frame);
|
|
6862
|
+
const sampledSubject = sampledPlayback.subject;
|
|
6863
|
+
const primaryId = current.activeCharacterId ?? current.characters[0]?.id;
|
|
6864
|
+
const characters = sampledSubject && primaryId
|
|
6865
|
+
? current.characters.map(character => character.id === primaryId ? { ...character, x: sampledSubject.x, z: sampledSubject.z } : character)
|
|
6866
|
+
: current.characters;
|
|
6867
|
+
const sampled = sampledPlayback.camera;
|
|
6868
|
+
if (!sampled) return { ...current, frame, characters, view: { ...current.view, frame } };
|
|
6869
|
+
const position = { x: sampled.pos.x, y: sampled.pos.y, z: sampled.pos.z };
|
|
6870
|
+
const direction = forwardFrom(sampled.yaw, sampled.pitch);
|
|
6871
|
+
return { ...current, frame, characters, view: { ...current.view, frame }, camera: {
|
|
6872
|
+
position,
|
|
6873
|
+
lookAt: { x: position.x + direction.x, y: position.y + direction.y, z: position.z + direction.z },
|
|
6874
|
+
focalMm: fovToFocalMm(sampled.fovDeg * Math.PI / 180, current.filmback.sensorId, current.filmback.aspectRatio),
|
|
6875
|
+
sensorId: current.filmback.sensorId,
|
|
6876
|
+
} };
|
|
6877
|
+
}
|
|
6438
6878
|
|
|
6439
6879
|
/** The authored stage envelope (key light, environment, filmback) published
|
|
6440
6880
|
* as one body: the live read model first, so the next synchronous read sees
|
|
@@ -6502,6 +6942,14 @@ export default function App() {
|
|
|
6502
6942
|
const raw = readStudioState();
|
|
6503
6943
|
const original = raw.characters.find(c => c.id === entity.id) ?? raw.characters.find(c => c.model === entity.model);
|
|
6504
6944
|
const target = original && raw.targets.get(original.id);
|
|
6945
|
+
if (isProxyFigure(original)) {
|
|
6946
|
+
const clip = target?.motion;
|
|
6947
|
+
const placement = placementAt(original, frame, {
|
|
6948
|
+
shotAt: at => shotAtFrame(raw.shots, at),
|
|
6949
|
+
takeRoot: clip ? sampleAt({ frameCount: clip.frames, motion: clip }, null, frame).subject : null,
|
|
6950
|
+
});
|
|
6951
|
+
return proxyFigureBounds(original, placement);
|
|
6952
|
+
}
|
|
6505
6953
|
if (!target?.rig) throw new StudioProtocolError("TARGET_NOT_READY", "Character bounds require its loaded rig.");
|
|
6506
6954
|
const rig = cloneSkeleton(target.rig), parent = new THREE.Group();
|
|
6507
6955
|
const originals = [], copies = [];
|
|
@@ -6530,13 +6978,15 @@ export default function App() {
|
|
|
6530
6978
|
for (const skeleton of skeletons) skeleton.dispose(); parent.remove(rig);
|
|
6531
6979
|
}
|
|
6532
6980
|
}
|
|
6533
|
-
function operateStudio({ selection, view, shotId }) {
|
|
6981
|
+
function operateStudio({ selection, view, shotId, modeSet = true }) {
|
|
6534
6982
|
const { frame } = view;
|
|
6535
6983
|
appContext.patchLive({ studioSelection: selection, studioView: view, studioShotId: shotId });
|
|
6536
6984
|
appContext.patchTimeline({ currentFrame: frame }); tlFrameRef.current = frame;
|
|
6537
6985
|
if (selection && ["character", "rig"].includes(selection.kind)) { appContext.patchLive({ activeCharacterId: selection.id }); setActiveCharacterId(selection.id); }
|
|
6538
6986
|
setSelectedHierarchyId(selection ? selection.kind === "object" ? `object:${selection.id}` : ["character", "rig"].includes(selection.kind) ? `character:${selection.id}` : selection.kind === "camera" ? "camera" : "shot" : "");
|
|
6539
|
-
|
|
6987
|
+
// Sculpt is UI-only and publishes as Stage (below): a view write that
|
|
6988
|
+
// names no mode keeps the editor in it.
|
|
6989
|
+
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);
|
|
6540
6990
|
}
|
|
6541
6991
|
const studioGestureRef = useRef(false);
|
|
6542
6992
|
// The Studio turn authors the scene through its own families and never
|
|
@@ -6555,13 +7005,16 @@ export default function App() {
|
|
|
6555
7005
|
selectedStudioChar ? { kind: "character", id: selectedStudioChar, hierarchyId: selectedHierarchyId } : selectedHierarchyId === "camera" ? { kind: "camera", id: "camera" } : { kind: "scene", id: activeSceneId },
|
|
6556
7006
|
studioShotId: activeShot?.id ?? null,
|
|
6557
7007
|
studioPhysicsRunning: autoPhysicsRunning,
|
|
6558
|
-
|
|
7008
|
+
// The agent's mode vocabulary is four (studio-agent-protocol.js): the
|
|
7009
|
+
// sculpt turntable is the Stage's scene seen in isolation.
|
|
7010
|
+
studioView: { mode: sculptMode ? "scene" : workflowMode, frame: tlFrame, playing: tlPlaying, lookThrough: lookThroughShot, grid: gridView, autoColor },
|
|
6559
7011
|
});
|
|
6560
7012
|
appContext.updatePorts({
|
|
6561
7013
|
read: readStudioState, revision: sceneRevisionRef, bounds: studioBounds, commit: commitStudioDraft,
|
|
6562
7014
|
operate: operateStudio, undo: () => stepStudioHistory(false), redo: () => stepStudioHistory(true),
|
|
7015
|
+
sampleStateAt: sampleStudioStateAt,
|
|
6563
7016
|
history: redo => appContext.historyEntry(redo), finishHistoryGesture: finishStudioHistoryGesture,
|
|
6564
|
-
stepHistory: stepStudioHistory, capture: () => liveQueries.capture_framing_png({}),
|
|
7017
|
+
stepHistory: stepStudioHistory, capture: () => liveQueries.capture_framing_png({}), capturePlan: () => liveQueries.capture_plan_png({}),
|
|
6565
7018
|
// One shot frame as raw read-back pixels (rows bottom-up), from the export
|
|
6566
7019
|
// path captureShotFramePng uses; an export in flight renders at its output.
|
|
6567
7020
|
renderFrameBuffer: frame => {
|
|
@@ -6605,7 +7058,7 @@ export default function App() {
|
|
|
6605
7058
|
scenes: appContext.live.scenes.map(({ id, name }) => ({ id, name })), activeSceneId: activeSceneIdRef.current,
|
|
6606
7059
|
// What a save needs: a name, a file this session, and (to pick or
|
|
6607
7060
|
// re-grant a file) the user's click still active.
|
|
6608
|
-
project: { name: projectName, hasFile: Boolean(projectHandleRef.current), fileAccess: hasFileSystemAccess(),
|
|
7061
|
+
project: { name: projectName, previsMode, hasFile: Boolean(projectHandleRef.current), fileAccess: hasFileSystemAccess(),
|
|
6609
7062
|
gesture: globalThis.navigator?.userActivation?.isActive === true },
|
|
6610
7063
|
// What generate() reads for the Send-to-AI package, as this render has it.
|
|
6611
7064
|
aiShot: { mode, imageModel },
|
|
@@ -6670,25 +7123,68 @@ export default function App() {
|
|
|
6670
7123
|
? ko("Unsaved changes", "저장되지 않은 변경사항")
|
|
6671
7124
|
: ko("Saved", "저장됨");
|
|
6672
7125
|
|
|
7126
|
+
// Sculpt mode's panel heads the Details column (DetailsSlot mounts it).
|
|
7127
|
+
const sculptPanel = sculptMode && !embedMode ? (
|
|
7128
|
+
<SculptPanel
|
|
7129
|
+
run={sculptRun}
|
|
7130
|
+
reference={sculptReference}
|
|
7131
|
+
onReference={(file) => void chooseSculptReference(file)}
|
|
7132
|
+
onStart={startSculptRun}
|
|
7133
|
+
onCancel={() => sculptRunRef.current?.cancel()}
|
|
7134
|
+
onPlaceInScene={placeSculptInScene}
|
|
7135
|
+
canStart={Boolean(sculptReference) && sculptAgentStatus === "ready" && Boolean(liveWorkspaceHandle) && Boolean(sculptModelPick.model)}
|
|
7136
|
+
blueprint={{
|
|
7137
|
+
...sculptBlueprint,
|
|
7138
|
+
prompt: buildBlueprintPrompt({ pose: sculptBlueprint.pose, notes: sculptBlueprint.notes }),
|
|
7139
|
+
layout: BLUEPRINT_LAYOUT,
|
|
7140
|
+
// The image model is ChatGPT-only (the sidecar refuses others with a reason).
|
|
7141
|
+
refusal: sculptAgentStatus === "signed-out" ? ko("Sign in with ChatGPT in the Agent panel to generate a blueprint.", "도면을 생성하려면 에이전트 패널에서 ChatGPT로 로그인하세요.") : null,
|
|
7142
|
+
onPose: (pose) => setSculptBlueprint((current) => ({ ...current, pose })),
|
|
7143
|
+
onNotes: (notes) => setSculptBlueprint((current) => ({ ...current, notes })),
|
|
7144
|
+
onGenerate: () => void generateSculptBlueprint(),
|
|
7145
|
+
onCancel: cancelSculptBlueprint,
|
|
7146
|
+
}}
|
|
7147
|
+
modelPicker={{
|
|
7148
|
+
...sculptModels,
|
|
7149
|
+
model: sculptModelPick.model,
|
|
7150
|
+
effort: sculptModelPick.effort,
|
|
7151
|
+
// A new model starts at its own default effort.
|
|
7152
|
+
onModel: (model) => chooseSculptModel({ model, effort: null }),
|
|
7153
|
+
onEffort: (effort) => chooseSculptModel({ effort }),
|
|
7154
|
+
}}
|
|
7155
|
+
startRefusal={sculptAgentStatus === "signed-out"
|
|
7156
|
+
? ko("Sign in to the Agent (⌘B) to sculpt from an image.", "이미지로 조형하려면 에이전트(⌘B)에 로그인하세요.")
|
|
7157
|
+
: sculptAgentStatus === "ready" && !liveWorkspaceHandle
|
|
7158
|
+
? ko("The live editor is disconnected — reconnect to sculpt.", "라이브 편집기가 연결되지 않았어요 — 연결 후 조형하세요.")
|
|
7159
|
+
: sculptAgentStatus === "ready" && sculptModels.state === "failed"
|
|
7160
|
+
? ko("No model is available — add a provider key in the Agent panel.", "사용할 모델이 없어요 — 에이전트 패널에서 제공자 키를 추가하세요.")
|
|
7161
|
+
: null}
|
|
7162
|
+
sculptName={sculptTarget?.name ?? null}
|
|
7163
|
+
/>
|
|
7164
|
+
) : null;
|
|
6673
7165
|
// The single shell snapshot keeps subsequent region work out of App.
|
|
6674
7166
|
const shellContext = {
|
|
6675
|
-
|
|
7167
|
+
sculptPanel,
|
|
7168
|
+
projectMenuOpen, setProjectMenuOpen, projectDirty, projectName, previsMode, projectStartupOpen,
|
|
6676
7169
|
requestNewProject, setProjectStartupOpen, setProjectBrowserOpen, runStudioAction, saveProject,
|
|
6677
7170
|
projectManifest, projectSaveState, recState, exportMenuTriggerRef, exportMenuOpen,
|
|
6678
7171
|
exportShotIdRef, setExportMenuAnchor, setExportMenuOpen, exportPhaseLabel,
|
|
6679
7172
|
exportMenuAnchor, resultOpen, exportFeedback, shots, exportKeyframePacks,
|
|
6680
7173
|
hasCameraKeys, motion, exportRenderPasses, exportDepthVideo, exportStoryboard,
|
|
7174
|
+
exportContactSheet, exportPanelPacks, exportAnimatic,
|
|
7175
|
+
capturePanelThumbnail, packMetaForShot, previsMode: scenesDomain.previsMode, sceneRevision: sceneRevisionRef.current,
|
|
6681
7176
|
downloadOtioCutList, projectStatus, liveWorkspaceHandle, selectedHierarchyId, selectHierarchy,
|
|
6682
7177
|
aimEditorAtKeyLight, characters, showB, ikFrames, ikMode,
|
|
6683
7178
|
rowIdForCharIndex, activeCharIndex, waypoints, sceneObjects, scenes,
|
|
6684
7179
|
activeSceneId, selectSceneDocument, createSceneDocumentFromUi, duplicateSceneDocumentFromUi, renameSceneDocumentFromUi,
|
|
6685
|
-
deleteSceneDocumentFromUi, addSceneObject, renameSceneObject, deleteSceneObject, frameSelection,
|
|
7180
|
+
deleteSceneDocumentFromUi, addSceneObject, renameSceneObject, deleteSceneObject, frameSelection, groupObjectUnderNewEmpty, renameRequest,
|
|
6686
7181
|
toggleHierarchyHidden, propsDrop, hierarchyReparent, agentTouchedRows, workflowMode,
|
|
6687
|
-
selectWorkflowMode, gizmoMode, setGizmoMode, snapEnabled, setSnapEnabled,
|
|
7182
|
+
selectWorkflowMode, gizmoMode, setGizmoMode, snapEnabled, setSnapEnabled, sculptView, requestSculptView,
|
|
6688
7183
|
preset, applyPreset, cameraPresetId, i2vMotionCameraLocked, shotAspectKey,
|
|
6689
7184
|
fovDeg, shotsDomain, shot, setNonce, workspaceLayout,
|
|
6690
7185
|
viewMenuTriggerRef, viewMenuOpen, setViewMenuAnchor, setViewMenuOpen, viewLooksActive,
|
|
6691
7186
|
viewMenuAnchor, gridView, setGridView, autoColor, setAutoColor,
|
|
7187
|
+
guideMode,
|
|
6692
7188
|
isCharacterSelection, partColoursChoice, embedMode, agentCollapsed, sceneSaveError,
|
|
6693
7189
|
studioAgentError, studioAgentMode, setStudioAgentMode, AgentPanel, setAgentCollapsed,
|
|
6694
7190
|
buildStudioAgentContext, highlightAgentTargets, generateI2vMotionFromUi, selectedSceneObject, rigSelection,
|
|
@@ -6712,12 +7208,13 @@ export default function App() {
|
|
|
6712
7208
|
linePreviewError, generationBusy, bridgeChecking, lineReadinessState, runLineEdit,
|
|
6713
7209
|
openMotionSetup, recheckMotionHealth, resetLineCurve, exitLineEditMode, readinessState,
|
|
6714
7210
|
ardyRunning, cancelArdy, ardyStatus, ardyOutcome, addPromptClip,
|
|
6715
|
-
tlFrame, isRigSelection, ikChains, ikFocus, footSnap,
|
|
7211
|
+
tlFrame, isRigSelection, ikChains, ikFocus, footSnap, poseObjectOpacity, setPoseObjectOpacity, capturePoseImage,
|
|
6716
7212
|
collisionCleanupSupported, runFixCollisions, runFixCollisionsRange, autoPhysicsRunning, physicsProgress,
|
|
6717
7213
|
physicsPreview, physicsShow, physicsOptions, platformFitRunning, platformFitProgress,
|
|
6718
7214
|
platformFitLast, platformFitApplied, changePhysicsOptions, runAutoPhysics, showPhysicsPreview,
|
|
6719
7215
|
applyPhysicsPreview, cancelPhysicsPreview, ikEditTool, setIkEditTool, showTrails,
|
|
6720
|
-
setShowTrails, trailFalloffS, setTrailFalloffS, trailEdit, runTrailRegeneration,
|
|
7216
|
+
setShowTrails, trailFalloffS, setTrailFalloffS, trailEdit, trailTrackFocus, selectTrailTrack, clearTrailTrackFocus, runTrailRegeneration,
|
|
7217
|
+
pendingIkEdit, applyPendingIkEdit, cancelPendingIkEdit,
|
|
6721
7218
|
trailReadinessState, rangePins, rangePinResiduals, rangePinSelection, rangePinPartPick,
|
|
6722
7219
|
rangePinPreview, setRangePinSelection, setRangePinPartPick, previewRangePinDraft, applyRangePinDraft,
|
|
6723
7220
|
deleteRangePin, hasEnvSheet, environment, style, environmentImage,
|
|
@@ -6739,10 +7236,11 @@ export default function App() {
|
|
|
6739
7236
|
addActiveCranePoint, deleteSelectedCranePoint, setCraneSelectedIndex, tlFps, ghostLayers,
|
|
6740
7237
|
pathSpeed, tlPlaying, pendingWaypointFrame, stateBadge, applyMotionTrim,
|
|
6741
7238
|
resetMotionTrim, cutMotionAtPlayhead, changeMotionSegmentSpeed, removeMotionSegmentById, bodyContact,
|
|
6742
|
-
activeShotIdx, railDraw, pathDraw, setPathDraw, setRailDraw,
|
|
6743
|
-
setWorkspaceLayout, timingTokenRef, railCurve, ikAddKeyframe, ikDeleteKeyframe,
|
|
7239
|
+
activeShotIdx, railDraw, pathDraw, pathTarget, setPathDraw, setRailDraw,
|
|
7240
|
+
setWorkspaceLayout, timingTokenRef, railCurve, ikAddKeyframe, ikDeleteKeyframe, pathPointIndex, setPathPointIndex,
|
|
6744
7241
|
setBodyContact, setFootSnap, advanceFrame, stepFrame, cameraPreviewEndRef,
|
|
6745
|
-
manualCameraOverrideRef, setTlPlaying,
|
|
7242
|
+
manualCameraOverrideRef, setTlPlaying, setWaypointMode, selectActiveCharacterInHierarchy,
|
|
7243
|
+
toggleWaypointMode: storyboardProject ? undefined : toggleWaypointMode,
|
|
6746
7244
|
setActiveWaypointId, setPendingWaypointFrame, removeWaypoint, queueRootWaypointFrame, revealPromptBlocks,
|
|
6747
7245
|
resizePromptClip, movePromptClip, removePromptClip, setSelectedHierarchyId, addCameraKeyframe,
|
|
6748
7246
|
moveCameraKeyframe, removeCameraKeyframe, syncActiveCameraFraming, activeCamera, activeShotDuration,
|
|
@@ -6764,7 +7262,7 @@ export default function App() {
|
|
|
6764
7262
|
return (
|
|
6765
7263
|
<AppContext.Provider value={appContext}>
|
|
6766
7264
|
<StudioShellContext.Provider value={shellContext}>
|
|
6767
|
-
<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}
|
|
7265
|
+
<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}
|
|
6768
7266
|
viewport={
|
|
6769
7267
|
<div className="viewport" data-drop={viewportDrop.over ? "over" : undefined} {...viewportDrop.handlers}
|
|
6770
7268
|
onWheel={(event) => {
|
|
@@ -6775,6 +7273,13 @@ export default function App() {
|
|
|
6775
7273
|
}
|
|
6776
7274
|
}}>
|
|
6777
7275
|
<ViewportToolbar />
|
|
7276
|
+
{(embedMode || playgroundMode) && <CompositionGuideButton guideMode={guideMode} onCycle={(mode) => runStudioAction("view.setGuideMode", { mode })} />}
|
|
7277
|
+
{sculptMode && !sculptTarget && (
|
|
7278
|
+
<div className="vp-sculpt-hint" role="status" data-testid="sculpt-empty">
|
|
7279
|
+
<strong>{ko("New sculpt", "새 조형물")}</strong>
|
|
7280
|
+
<span>{ko("Add a reference image in the Sculpt panel and press Start — or select a sculpt and press 5.", "조형 패널에 참조 이미지를 넣고 시작을 누르세요 — 또는 조형물을 선택하고 5를 누르세요.")}</span>
|
|
7281
|
+
</div>
|
|
7282
|
+
)}
|
|
6778
7283
|
|
|
6779
7284
|
{/* Sits under the mode tabs and left of the Top-View inset, over the
|
|
6780
7285
|
stage it is teaching. The overlay itself never takes the pointer
|
|
@@ -6784,6 +7289,7 @@ export default function App() {
|
|
|
6784
7289
|
key={cameraTutorialAttempt}
|
|
6785
7290
|
analytics={cameraTutorialAnalytics.current}
|
|
6786
7291
|
previewing={lookThroughShot}
|
|
7292
|
+
previsMode={scenesDomain.previsMode}
|
|
6787
7293
|
shotCount={shots.length}
|
|
6788
7294
|
railReady={shots.some((shot) => Array.isArray(shot.camera?.cameraRail) && shot.camera.cameraRail.length >= 2)}
|
|
6789
7295
|
onStepChange={setCameraTutorialStep}
|
|
@@ -6850,7 +7356,7 @@ export default function App() {
|
|
|
6850
7356
|
<KeyLightPuck
|
|
6851
7357
|
keyLight={keyLight}
|
|
6852
7358
|
selected={keyLightSelected}
|
|
6853
|
-
visible={!preview && !lookThroughShot}
|
|
7359
|
+
visible={!preview && !lookThroughShot && !sculptMode}
|
|
6854
7360
|
paneRef={mainPaneRef}
|
|
6855
7361
|
camRef={editorCamRef}
|
|
6856
7362
|
onSelect={() => selectHierarchy("light")}
|
|
@@ -6862,12 +7368,14 @@ export default function App() {
|
|
|
6862
7368
|
{gridView ? <GridFloor layer={GIZMO_LAYER} /> : <Room light={uiTheme === "light"} />}
|
|
6863
7369
|
<SetProps
|
|
6864
7370
|
objects={stageSceneObjects}
|
|
7371
|
+
authoredObjects={sceneObjects}
|
|
6865
7372
|
selectedId={selectedSceneObjectId}
|
|
6866
7373
|
frameRef={propFrameRef}
|
|
6867
7374
|
take={{ frameCount: tlFrameCount, fps: tlFps }}
|
|
6868
7375
|
attachFrameRef={attachFrameRef}
|
|
6869
7376
|
syncRef={propSyncRef}
|
|
6870
7377
|
worldRef={propWorldRef}
|
|
7378
|
+
fadeOpacity={workflowMode === "pose" ? poseObjectOpacity : null}
|
|
6871
7379
|
/>
|
|
6872
7380
|
|
|
6873
7381
|
<PerspectiveCamera
|
|
@@ -6883,7 +7391,7 @@ export default function App() {
|
|
|
6883
7391
|
operator's own view of the set. */}
|
|
6884
7392
|
<PerspectiveCamera
|
|
6885
7393
|
ref={editorCamRef}
|
|
6886
|
-
makeDefault={!ikMode && !lookThroughShot}
|
|
7394
|
+
makeDefault={!ikMode && !lookThroughShot && !sculptMode}
|
|
6887
7395
|
fov={55}
|
|
6888
7396
|
near={0.1}
|
|
6889
7397
|
far={100}
|
|
@@ -6908,21 +7416,47 @@ export default function App() {
|
|
|
6908
7416
|
position={[0, 24, 0]}
|
|
6909
7417
|
rotation={[-Math.PI / 2, 0, 0]}
|
|
6910
7418
|
/>
|
|
7419
|
+
{/* Sculpt mode's own orbit camera and turntable; it owns the main
|
|
7420
|
+
pane (DualRender's editor slot) while the shot preview keeps
|
|
7421
|
+
drawing the whole scene. Unmounting restores normal rendering. */}
|
|
7422
|
+
{sculptMode && (
|
|
7423
|
+
<SculptTurntable
|
|
7424
|
+
camRef={sculptCamRef}
|
|
7425
|
+
objects={sceneObjects}
|
|
7426
|
+
target={sculptTarget}
|
|
7427
|
+
size={sculptTarget ? objectSize(sculptTarget) : null}
|
|
7428
|
+
view={sculptView}
|
|
7429
|
+
light={uiTheme === "light"}
|
|
7430
|
+
busy={["measure", "draft", "score", "refine"].includes(sculptRun?.stage)}
|
|
7431
|
+
changedParts={sculptRun?.changedParts ?? null}
|
|
7432
|
+
/>
|
|
7433
|
+
)}
|
|
6911
7434
|
|
|
6912
7435
|
{characterViews.map((view) => (
|
|
6913
|
-
|
|
7436
|
+
// Always wrapped, grouped or not: giving or taking a parent
|
|
7437
|
+
// must not remount the rig the playback effects hold.
|
|
7438
|
+
<ObjectCarrier
|
|
6914
7439
|
key={`${view.id}:${rigMountEpoch}`}
|
|
6915
|
-
|
|
6916
|
-
|
|
6917
|
-
|
|
6918
|
-
|
|
6919
|
-
|
|
6920
|
-
|
|
6921
|
-
|
|
6922
|
-
|
|
6923
|
-
|
|
6924
|
-
|
|
6925
|
-
|
|
7440
|
+
objectId={view.parent}
|
|
7441
|
+
objectsRef={sceneObjectLookupRef}
|
|
7442
|
+
frameRef={propFrameRef}
|
|
7443
|
+
take={{ frameCount: tlFrameCount, fps: tlFps }}
|
|
7444
|
+
registryRef={carrierRegistryRef}
|
|
7445
|
+
>
|
|
7446
|
+
<Character
|
|
7447
|
+
url={view.url}
|
|
7448
|
+
{...{ model: view.model, posture: view.posture }}
|
|
7449
|
+
position={view.position}
|
|
7450
|
+
rot={view.rot}
|
|
7451
|
+
tint={view.tint}
|
|
7452
|
+
partColoursEnabled={view.partColoursEnabled}
|
|
7453
|
+
partColoursMode={view.partColoursMode}
|
|
7454
|
+
pose={view.pose}
|
|
7455
|
+
scale={view.scale}
|
|
7456
|
+
onRig={view.onRig}
|
|
7457
|
+
pickId={view.pickId}
|
|
7458
|
+
/>
|
|
7459
|
+
</ObjectCarrier>
|
|
6926
7460
|
))}
|
|
6927
7461
|
|
|
6928
7462
|
{/* Selection marker: XYZ tripod + ring on the picked cast
|
|
@@ -6935,7 +7469,7 @@ export default function App() {
|
|
|
6935
7469
|
objects={characterGizmoObject ? [characterGizmoObject] : []}
|
|
6936
7470
|
mode={gizmoMode}
|
|
6937
7471
|
snap={snapEnabled}
|
|
6938
|
-
enabled={!planIsMain && !posing && !ikMode && !playMode && !!characterGizmoObject}
|
|
7472
|
+
enabled={!planIsMain && !posing && !ikMode && !playMode && !sculptMode && !!characterGizmoObject}
|
|
6939
7473
|
paneRef={mainPaneRef}
|
|
6940
7474
|
camRef={lookThroughShot ? shotCamRef : editorCamRef}
|
|
6941
7475
|
shotAspect={lookThroughShot ? shotOutput.aspect : null}
|
|
@@ -6994,6 +7528,7 @@ export default function App() {
|
|
|
6994
7528
|
// editorial camera keys resume when the block returns to Keys mode.
|
|
6995
7529
|
following={!followCamActive && hasCameraKeys && (preview || (moveFollow && !ikMode && !waypointMode && !posing))}
|
|
6996
7530
|
followFrame={tlFrame}
|
|
7531
|
+
frameRef={propFrameRef}
|
|
6997
7532
|
fps={tlFps}
|
|
6998
7533
|
keys={cameraKeys}
|
|
6999
7534
|
shots={shots}
|
|
@@ -7006,6 +7541,7 @@ export default function App() {
|
|
|
7006
7541
|
<FollowCamRig
|
|
7007
7542
|
enabled={followCamActive && !movePlaying}
|
|
7008
7543
|
frame={tlFrame}
|
|
7544
|
+
frameRef={propFrameRef}
|
|
7009
7545
|
scene={playbackScene}
|
|
7010
7546
|
shot={activeShot}
|
|
7011
7547
|
camRef={shotCamRef}
|
|
@@ -7017,7 +7553,7 @@ export default function App() {
|
|
|
7017
7553
|
pointerdowns that hit a handle, so empty-space drags orbit
|
|
7018
7554
|
and the wheel dollies without wrecking the framing. */}
|
|
7019
7555
|
<FlyControls
|
|
7020
|
-
enabled={!posing && !playMode}
|
|
7556
|
+
enabled={!posing && !playMode && !sculptMode}
|
|
7021
7557
|
cameraLocked={lookThroughShot && i2vMotionCameraLocked}
|
|
7022
7558
|
camRef={ikMode ? poserCamRef : lookThroughShot ? shotCamRef : editorCamRef}
|
|
7023
7559
|
look={ikMode ? poserLook : lookThroughShot ? look : editorLook}
|
|
@@ -7039,6 +7575,7 @@ export default function App() {
|
|
|
7039
7575
|
markSemanticEdit("pose", before, after);
|
|
7040
7576
|
setPoseTick((n) => n + 1);
|
|
7041
7577
|
}}
|
|
7578
|
+
onCommit={commitPosedPose}
|
|
7042
7579
|
/>
|
|
7043
7580
|
<IkHandles
|
|
7044
7581
|
chains={ikChains}
|
|
@@ -7069,6 +7606,7 @@ export default function App() {
|
|
|
7069
7606
|
look={look}
|
|
7070
7607
|
fovDeg={fovDeg}
|
|
7071
7608
|
characters={characters}
|
|
7609
|
+
characterPlacements={characterViews}
|
|
7072
7610
|
onMoveCharacter={moveCharacter}
|
|
7073
7611
|
onCameraGestureStart={beginCameraFramingGesture}
|
|
7074
7612
|
pathStart={activeChar}
|
|
@@ -7092,33 +7630,30 @@ export default function App() {
|
|
|
7092
7630
|
cameraRailPoints={railCurve ? railCurve.points : null}
|
|
7093
7631
|
railDraw={railDraw}
|
|
7094
7632
|
pathDraw={pathDraw}
|
|
7095
|
-
|
|
7633
|
+
objectPath={routeOwner?.path ?? null}
|
|
7096
7634
|
objectPathSelectedIndex={pathPointIndex}
|
|
7097
7635
|
onObjectPathPointSelect={setPathPointIndex}
|
|
7098
|
-
|
|
7099
|
-
const path =
|
|
7636
|
+
onObjectPathChange={(patch) => {
|
|
7637
|
+
const path = routeOwner?.path;
|
|
7100
7638
|
if (!path) return;
|
|
7101
|
-
//
|
|
7102
|
-
// the
|
|
7103
|
-
|
|
7104
|
-
changeSceneObject(
|
|
7639
|
+
// Inside the gesture's own transaction (planPathTokenRef), like
|
|
7640
|
+
// the 3D view. The board bends the floor route only; a point's
|
|
7641
|
+
// height is the scene's business, so y rides through untouched.
|
|
7642
|
+
changeSceneObject(routeOwner.id, { path: { ...path, ...patch } }, planPathTokenRef.current);
|
|
7105
7643
|
}}
|
|
7106
|
-
|
|
7107
|
-
const path =
|
|
7108
|
-
if (!path
|
|
7109
|
-
|
|
7110
|
-
|
|
7111
|
-
|
|
7112
|
-
|
|
7113
|
-
|
|
7114
|
-
z: a.z + (b.z - a.z) * t,
|
|
7115
|
-
};
|
|
7116
|
-
const points = [...path.points.slice(0, index + 1), inserted, ...path.points.slice(index + 1)];
|
|
7644
|
+
onObjectPathMarkInsert={(t) => {
|
|
7645
|
+
const path = routeOwner?.path;
|
|
7646
|
+
if (!path) return;
|
|
7647
|
+
// The new dot is born with the lean the route already has there,
|
|
7648
|
+
// and it goes to the record that owns the route (the selected
|
|
7649
|
+
// group may only contain it).
|
|
7650
|
+
const added = insertPathMark(path, t);
|
|
7651
|
+
if (!added) return;
|
|
7117
7652
|
const token = beginSceneTransaction({ owner: "object-path", cancel: () => {} });
|
|
7118
|
-
changeSceneObject(
|
|
7653
|
+
changeSceneObject(routeOwner.id, { path: { ...path, marks: added.marks } }, token);
|
|
7119
7654
|
endSceneTransaction(token, { commit: true });
|
|
7120
|
-
setPathPointIndex(index
|
|
7121
|
-
setToast(ko("
|
|
7655
|
+
setPathPointIndex(added.index);
|
|
7656
|
+
setToast(ko("Dot added — drag it to bend the route", "점을 추가했어요 — 끌어서 경로를 휘세요"));
|
|
7122
7657
|
}}
|
|
7123
7658
|
onObjectPathGestureStart={() => {
|
|
7124
7659
|
planPathTokenRef.current = beginSceneTransaction({ owner: "object-path", cancel: () => { planPathTokenRef.current = null; } });
|
|
@@ -7131,16 +7666,22 @@ export default function App() {
|
|
|
7131
7666
|
keyLight={keyLight}
|
|
7132
7667
|
onRailStroke={shotsDomain.drawCameraRail}
|
|
7133
7668
|
onPathStroke={(stroke) => {
|
|
7134
|
-
if (!
|
|
7135
|
-
//
|
|
7136
|
-
// operator adds the
|
|
7137
|
-
//
|
|
7669
|
+
if (!pathTarget) return;
|
|
7670
|
+
// The stroke keeps its shape, as the camera rail's does; the
|
|
7671
|
+
// operator adds the dots they want to hold by double-clicking
|
|
7672
|
+
// the line. The route starts at the height
|
|
7673
|
+
// the object stands at, so a raised body (a chassis on its
|
|
7674
|
+
// wheels, a prop on a table) travels where it is instead of
|
|
7675
|
+
// dropping to the floor; changing height comes later, from
|
|
7138
7676
|
// dragging a point in the scene.
|
|
7139
|
-
const
|
|
7677
|
+
const height = pathTarget.attach ? 0 : pathTarget.y ?? 0;
|
|
7678
|
+
const points = strokeToPathPoints(stroke, simplifyStroke).map((point) => ({ ...point, y: height }));
|
|
7140
7679
|
if (points.length < 2) return;
|
|
7141
7680
|
const token = beginSceneTransaction({ owner: "object-path", cancel: () => {} });
|
|
7142
|
-
|
|
7681
|
+
// A new stroke is a new shape: marks belong to the old one.
|
|
7682
|
+
changeSceneObject(pathTarget.id, { path: { ...(pathTarget.path ?? {}), points, marks: [] } }, token);
|
|
7143
7683
|
endSceneTransaction(token, { commit: true });
|
|
7684
|
+
setPathPointIndex(null);
|
|
7144
7685
|
setPathDraw(false);
|
|
7145
7686
|
const metrics = pathMetrics(createObjectPath({ points }));
|
|
7146
7687
|
setToast(isKo
|
|
@@ -7153,11 +7694,11 @@ export default function App() {
|
|
|
7153
7694
|
the plan owns the big pane (the pucks are the handles there)
|
|
7154
7695
|
and while posing/IK owns the pointer. */}
|
|
7155
7696
|
<ObjectGizmo
|
|
7156
|
-
object={cameraGizmoObject ?? lightGizmoObject ?? (selectedSceneObject && !isEffectivelyHidden(selectedSceneObject, sceneObjects, characters) ?
|
|
7697
|
+
object={cameraGizmoObject ?? lightGizmoObject ?? (selectedSceneObject && !isEffectivelyHidden(selectedSceneObject, sceneObjects, characters) ? gizmoSceneObject : null)}
|
|
7157
7698
|
objects={sceneObjects}
|
|
7158
7699
|
mode={lightGizmoObject ? "move" : cameraGizmoObject ? (gizmoMode === "scale" ? "move" : gizmoMode) : gizmoMode}
|
|
7159
7700
|
snap={snapEnabled}
|
|
7160
|
-
enabled={!planIsMain && !posing && !ikMode && !playMode}
|
|
7701
|
+
enabled={!planIsMain && !posing && !ikMode && !playMode && !sculptMode}
|
|
7161
7702
|
paneRef={mainPaneRef}
|
|
7162
7703
|
camRef={lookThroughShot ? shotCamRef : editorCamRef}
|
|
7163
7704
|
shotAspect={lookThroughShot ? shotOutput.aspect : null}
|
|
@@ -7187,15 +7728,19 @@ export default function App() {
|
|
|
7187
7728
|
/>
|
|
7188
7729
|
)}
|
|
7189
7730
|
<ObjectPathHandles
|
|
7190
|
-
path={
|
|
7731
|
+
path={routeOwner?.path ?? null}
|
|
7191
7732
|
selectedIndex={pathPointIndex}
|
|
7192
|
-
|
|
7733
|
+
mode={gizmoMode}
|
|
7734
|
+
enabled={!preview && !lookThroughShot && !ikMode && !posing && !!routeOwner?.path}
|
|
7193
7735
|
paneRef={mainPaneRef}
|
|
7194
7736
|
camRef={editorCamRef}
|
|
7195
7737
|
onSelect={setPathPointIndex}
|
|
7196
|
-
|
|
7197
|
-
if (!
|
|
7198
|
-
|
|
7738
|
+
onChangePath={(patch) => {
|
|
7739
|
+
if (!routeOwner) return;
|
|
7740
|
+
// Inside the drag's own transaction, like the Top-View mark
|
|
7741
|
+
// move: a plain update while the transaction is open is
|
|
7742
|
+
// refused, so the dot never moved in the 3D view.
|
|
7743
|
+
changeSceneObject(routeOwner.id, { path: patch === null ? null : { ...routeOwner.path, ...patch } }, pathDragTokenRef.current);
|
|
7199
7744
|
}}
|
|
7200
7745
|
onDragStart={() => {
|
|
7201
7746
|
pathDragTokenRef.current = beginSceneTransaction({ owner: "object-path", cancel: () => {} });
|
|
@@ -7246,6 +7791,7 @@ export default function App() {
|
|
|
7246
7791
|
baseY={activeChar.y ?? 0}
|
|
7247
7792
|
charScale={activeChar.scale ?? 1}
|
|
7248
7793
|
ikFocus={ikFocus}
|
|
7794
|
+
activeTrackId={trailTrackFocus}
|
|
7249
7795
|
falloffFrames={trailFalloffFrames}
|
|
7250
7796
|
playheadFrame={tlFrame}
|
|
7251
7797
|
pendingEdit={trailEdit}
|
|
@@ -7264,10 +7810,11 @@ export default function App() {
|
|
|
7264
7810
|
shotCamRef={shotCamRef}
|
|
7265
7811
|
planCamRef={planCamRef}
|
|
7266
7812
|
poserCamRef={poserCamRef}
|
|
7267
|
-
editorCamRef={editorCamRef}
|
|
7268
|
-
// The studio
|
|
7269
|
-
// scene as exported.
|
|
7270
|
-
|
|
7813
|
+
editorCamRef={sculptMode ? sculptCamRef : editorCamRef}
|
|
7814
|
+
// The studio and the landing playground hide the facing marks on
|
|
7815
|
+
// screen; the Workflow embed shows the scene as exported. Captures
|
|
7816
|
+
// render outside this loop and keep them everywhere.
|
|
7817
|
+
editorLook={!embedMode}
|
|
7271
7818
|
ikMode={ikMode}
|
|
7272
7819
|
planIsMain={planIsMain}
|
|
7273
7820
|
// Preview IS PlayView's render path: DualRender tests this branch
|
|
@@ -7276,7 +7823,7 @@ export default function App() {
|
|
|
7276
7823
|
// shot camera in the editing draw instead.
|
|
7277
7824
|
playMode={preview}
|
|
7278
7825
|
lookThrough={lookThroughShot}
|
|
7279
|
-
insetCollapsed={workspaceLayout.insetCollapsed || workflowMode === "motion" || workflowMode === "pose"}
|
|
7826
|
+
insetCollapsed={workspaceLayout.insetCollapsed || workflowMode === "motion" || workflowMode === "pose" || workflowMode === "sculpt"}
|
|
7280
7827
|
planZoom={workspaceLayout.planZoom}
|
|
7281
7828
|
shotAspect={shotOutput.aspect}
|
|
7282
7829
|
/>
|
|
@@ -7381,18 +7928,6 @@ export default function App() {
|
|
|
7381
7928
|
<span className="vp-inset-tag vp-shot-preview-tag">
|
|
7382
7929
|
<span className="vp-rec-dot" aria-hidden="true" />
|
|
7383
7930
|
{ko("Shot", "샷")}
|
|
7384
|
-
<button
|
|
7385
|
-
type="button"
|
|
7386
|
-
className={"vp-guide-cycle" + (guideMode === "off" ? "" : " on")}
|
|
7387
|
-
aria-label={ko("Cycle composition guides", "구도 가이드 전환")}
|
|
7388
|
-
title={ko(GUIDE_LABELS[guideMode].en, GUIDE_LABELS[guideMode].ko) + ko(" · click to cycle", " · 클릭으로 전환")}
|
|
7389
|
-
onClick={() => runStudioAction("view.setGuideMode", { mode: nextGuideMode(guideMode) })}
|
|
7390
|
-
>
|
|
7391
|
-
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
|
7392
|
-
<path d="M3 3h18v18H3z" />
|
|
7393
|
-
<path d="M9 3v18M15 3v18M3 9h18M3 15h18" />
|
|
7394
|
-
</svg>
|
|
7395
|
-
</button>
|
|
7396
7931
|
<button
|
|
7397
7932
|
type="button"
|
|
7398
7933
|
className="vp-look-through"
|
|
@@ -7475,9 +8010,9 @@ export default function App() {
|
|
|
7475
8010
|
setProjectBrowserOpen(false);
|
|
7476
8011
|
void openStarterScene(id, "starter", name);
|
|
7477
8012
|
}}
|
|
7478
|
-
onNew={() => {
|
|
8013
|
+
onNew={(name, options) => {
|
|
7479
8014
|
setProjectBrowserOpen(false);
|
|
7480
|
-
requestNewProject();
|
|
8015
|
+
requestNewProject(false, options);
|
|
7481
8016
|
}}
|
|
7482
8017
|
onClose={() => {
|
|
7483
8018
|
setProjectBrowserOpen(false);
|
|
@@ -7495,7 +8030,7 @@ export default function App() {
|
|
|
7495
8030
|
else newProject(name);
|
|
7496
8031
|
}}
|
|
7497
8032
|
/>
|
|
7498
|
-
<FirstSuccessGuide open={firstSuccessGuideOpen} onDismiss={() => setFirstSuccessGuideOpen(false)} />
|
|
8033
|
+
<FirstSuccessGuide open={firstSuccessGuideOpen} previsMode={scenesDomain.previsMode} onDismiss={() => setFirstSuccessGuideOpen(false)} />
|
|
7499
8034
|
<UseCaseQuestion
|
|
7500
8035
|
open={useCaseAskOpen}
|
|
7501
8036
|
isKo={isKo}
|