cozyclay 3.0.2 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +95 -0
- package/INSTALL_FOR_AGENTS.md +146 -0
- package/README.md +74 -192
- package/THIRD_PARTY_NOTICES.md +0 -21
- package/bin/agent/agent-routes.mjs +22 -56
- package/bin/agent/agent-runner.mjs +5 -3
- package/bin/agent/main-agent.mjs +70 -0
- package/bin/agent/main-prompt.mjs +17 -0
- package/bin/agent/session-store.mjs +2 -2
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/studio-task-routes.mjs +185 -0
- package/bin/agent/studio-tasks.mjs +156 -0
- package/bin/agent/studio-tools.mjs +3 -2
- package/bin/agent/studio-turn-transaction.mjs +76 -0
- package/bin/cozyclay.mjs +5 -7
- package/dist/app/index.html +7 -6
- package/dist/assets/TaskBoard-DdDXMgZm.js +1 -0
- package/dist/assets/app-B2BpfCl2.js +5088 -0
- package/dist/assets/app-BlwOpest.css +1 -0
- package/dist/assets/compile-worker-D0pKioa0.js +5 -0
- package/dist/assets/landing-y3EqDgSZ.js +1 -0
- package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
- package/dist/assets/motion3-compile-B887n77J.js +1 -0
- package/dist/assets/scene-asset-sync-COGOlF5T.css +1 -0
- package/dist/assets/scene-asset-sync-CQzEdsqu.js +13 -0
- package/dist/assets/task-board-BKaM7d-9.js +14 -0
- package/dist/assets/workflow-B8Dhzexj.js +179 -0
- package/dist/assets/x-bot-tpose-body-BusyR3eg.json +1 -0
- package/dist/assets/y-bot-tpose-body-Dy1VKChZ.json +1 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/greybox-to-video/index.html +2 -3
- package/dist/index.html +7 -57
- package/dist/llms.txt +2 -1
- package/dist/previs-software/index.html +2 -2
- package/dist/privacy/index.html +3 -7
- package/dist/seedance-camera-control/index.html +2 -2
- package/dist/sitemap.xml +5 -5
- package/dist/sw.js +5 -1
- package/dist/thumbs/blank-stage-dark.webp +0 -0
- package/dist/thumbs/blank-stage-light.webp +0 -0
- package/dist/workflow/index.html +5 -4
- package/mcp/mesh-file.mjs +3 -1
- package/mcp/server.mjs +3 -0
- package/package.json +3 -3
- package/src/App.jsx +50 -281
- package/src/analytics.js +3 -148
- package/src/app-stage.jsx +1 -1
- package/src/asset-pane.jsx +5 -10
- package/src/auto-flat.js +10 -0
- package/src/bus-task-scopes.js +69 -0
- package/src/bus-task-targets.js +48 -0
- package/src/bus-tasks.js +181 -0
- package/src/command-bus.js +15 -40
- package/src/command-history.js +36 -0
- package/src/commands/cast.js +10 -3
- package/src/commands/motion.js +2 -1
- package/src/commands/motion3-compile.js +51 -27
- package/src/commands/motion3.js +13 -7
- package/src/commands/project.js +1 -1
- package/src/commands/shared.js +9 -0
- package/src/commands/shot.js +16 -4
- package/src/controls.jsx +0 -11
- package/src/cuts.js +11 -0
- package/src/domains/cast.js +3 -2
- package/src/domains/motion.js +3 -10
- package/src/domains/playback.js +32 -0
- package/src/domains/scenes.js +3 -22
- package/src/domains/shots.js +5 -5
- package/src/dualview.jsx +11 -15
- package/src/hierarchy-panel.css +18 -4
- package/src/hierarchy-panel.jsx +12 -11
- package/src/kits/held.js +3 -2
- package/src/light-blockers.js +2 -2
- package/src/main.jsx +5 -0
- package/src/motion/ground.js +5 -14
- package/src/motion/playback-frame-store.js +79 -0
- package/src/motion/playback-probe.js +16 -0
- package/src/motion/playback-subject.js +7 -0
- package/src/motion/timeline.css +43 -5
- package/src/motion/timeline.jsx +4 -3
- package/src/motion3/body.js +37 -5
- package/src/motion3/carried.js +11 -3
- package/src/motion3/compile-job.js +1 -1
- package/src/motion3/compiler/anchors.js +33 -5
- package/src/motion3/compiler/compile.js +27 -4
- package/src/motion3/compiler/finger-close.js +137 -0
- package/src/motion3/compiler/grip.js +9 -3
- package/src/motion3/compiler/limbs.js +18 -3
- package/src/motion3/compiler/pose.js +6 -0
- package/src/motion3/compiler/socket-grip.js +34 -10
- package/src/motion3/compiler/validate-objects.js +17 -2
- package/src/motion3/compiler/validate-parts.js +2 -1
- package/src/motion3/compiler/vocab.js +4 -4
- package/src/motion3/describe.js +2 -2
- package/src/motion3/docs.js +1 -1
- package/src/motion3/fix/index.js +7 -3
- package/src/motion3/flight.js +38 -0
- package/src/motion3/free-motion.js +10 -7
- package/src/motion3/load.js +125 -0
- package/src/motion3/mass.js +23 -0
- package/src/motion3/object-keys.js +11 -6
- package/src/motion3/object-pose.js +11 -3
- package/src/motion3/pipeline.js +5 -0
- package/src/motion3/qa/explain.js +3 -1
- package/src/motion3/qa/grasp-checks.js +123 -0
- package/src/motion3/qa/hints-grasp.js +71 -0
- package/src/motion3/qa/index.js +3 -1
- package/src/motion3/qa/object-checks.js +7 -2
- package/src/motion3/qa/patch.js +2 -1
- package/src/motion3/qa/spec-checks.js +21 -1
- package/src/motion3/schema/fields.js +3 -3
- package/src/motion3/schema/topics.js +11 -10
- package/src/motion3/schema/types.js +5 -1
- package/src/motion3/seat.js +84 -0
- package/src/motion3/settle.js +11 -6
- package/src/motion3/shapes.js +70 -0
- package/src/motion3/skills/basic.js +61 -13
- package/src/motion3/skills/catch.js +71 -0
- package/src/motion3/skills/index.js +13 -4
- package/src/motion3/skills/info.js +14 -1
- package/src/motion3/skills/reach-human.js +139 -0
- package/src/motion3/skills/stance.js +92 -0
- package/src/motion3/skills/throw.js +72 -0
- package/src/motion3/sockets.js +21 -4
- package/src/motion3/views.js +28 -5
- package/src/object-catalog.jsx +0 -4
- package/src/object-gizmo.jsx +2 -1
- package/src/object-path.js +1 -1
- package/src/panels/ProjectPanel.jsx +42 -21
- package/src/panels/details.css +15 -4
- package/src/popover.js +37 -0
- package/src/posestudio.jsx +15 -10
- package/src/project-browser-art.jsx +19 -0
- package/src/project-browser.css +74 -121
- package/src/project-browser.jsx +36 -60
- package/src/props.jsx +4 -7
- package/src/retired-kinds.js +37 -0
- package/src/scene-objects.js +3 -7
- package/src/scenes.js +4 -5
- package/src/settings-menu.jsx +1 -20
- package/src/shell/BottomDock.jsx +9 -9
- package/src/shell/MenuBar.jsx +1 -9
- package/src/shell/PreferencesDialog.jsx +3 -4
- package/src/shell/ViewportToolbar.jsx +3 -3
- package/src/shell/add-camera-shot.js +11 -0
- package/src/shell/glass-regions.css +3 -2
- package/src/shell/glass.css +0 -5
- package/src/shell/lens-scrub-math.js +51 -0
- package/src/shell/lens-scrub.css +73 -0
- package/src/shell/lens-scrub.jsx +98 -0
- package/src/shell/mode.css +6 -5
- package/src/shell/preferences.css +1 -32
- package/src/shell/shell.css +18 -1
- package/src/shell/studio-shell-context.js +5 -0
- package/src/shot-frame.js +26 -0
- package/src/stage-canvas-rigs.jsx +7 -8
- package/src/stage-characters.jsx +26 -0
- package/src/stage-gizmo.js +43 -0
- package/src/stage-labels-ko.js +0 -6
- package/src/stage-startup-defaults.js +3 -9
- package/src/starter-scenes.js +5 -11
- package/src/studio-agent-commands.js +2 -2
- package/src/studio-agent-protocol.js +2 -7
- package/src/studio-app-binding.js +3 -3
- package/src/studio-elements.js +1 -1
- package/src/studio-error-codes.js +1 -1
- package/src/studio-motion-visual.js +4 -4
- package/src/styles/themes.css +13 -0
- package/src/styles.css +6 -674
- package/src/text/text-object.jsx +1 -1
- package/src/ui.jsx +8 -16
- package/src/use-embed-bridge.js +3 -6
- package/src/use-render-activity.js +3 -1
- package/src/view-ghosts.jsx +5 -3
- package/src/viewport-visible-rect.js +37 -0
- package/src/workflow/AgentPanel.jsx +75 -42
- package/src/workflow/AgentProgressCard.jsx +1 -1
- package/src/workflow/TaskBoard.jsx +102 -0
- package/src/workflow/TaskInline.jsx +114 -0
- package/src/workflow/agent-client.js +11 -25
- package/src/workflow/agent-panel.css +14 -1
- package/src/workflow/attachment-image.js +10 -3
- package/src/workflow/attachment-video.js +91 -0
- package/src/workflow/task-board.css +98 -0
- package/src/workflow/task-board.js +169 -0
- package/tools/check-eslint.mjs +2 -1
- package/tools/check-knip.mjs +2 -1
- package/tools/dev/pages/greybox-to-video.html +2 -3
- package/tools/dev/pages/previs-software.html +2 -2
- package/tools/dev/pages/privacy.html +3 -7
- package/tools/dev/pages/seedance-camera-control.html +2 -2
- package/tools/dev-full.mjs +11 -2
- package/tools/eslint-baseline.json +6 -6
- package/tools/knip-baseline.json +0 -11
- package/tools/loc-baseline.json +30 -31
- package/tools/metrics-snapshot.mjs +43 -5
- package/tools/render-start-thumbs.mjs +175 -0
- package/tools/run-tests.mjs +7 -14
- package/tools/sitemap.mjs +1 -1
- package/dist/assets/analytics-DbKG0NgD.js +0 -6
- package/dist/assets/app-34i-iUy1.js +0 -5088
- package/dist/assets/app-f-fkV5uP.css +0 -1
- package/dist/assets/compile-worker-DYpa2nvO.js +0 -5
- package/dist/assets/first-shot-handoff-CrGZ3n_O.js +0 -1
- package/dist/assets/landing-mq5bnqFE.js +0 -1
- package/dist/assets/scene-asset-sync-7J-w5oNh.js +0 -20
- package/dist/assets/scene-asset-sync-_S8NQl5B.css +0 -1
- package/dist/assets/workflow-dUhurYUc.js +0 -179
- package/dist/scenes/city-block.cclayproject +0 -1
- package/src/burn-in.js +0 -91
- package/src/camera-tutorial.jsx +0 -520
- package/src/facing-marks.js +0 -88
- package/src/first-shot-handoff.js +0 -44
- package/src/panels/motion.css +0 -197
- package/src/set-pieces.jsx +0 -183
- package/src/tutorial-analytics.js +0 -86
- package/src/tutorial-walk.js +0 -17
- package/src/use-camera-tutorial.js +0 -50
- package/src/use-semantic-state.js +0 -12
- package/tools/metrics/archive-traffic.mjs +0 -138
- package/tools/perf/ik-camera-drag.mjs +0 -138
package/src/props.jsx
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* Set props: low-poly clay maquettes to block against.
|
|
3
3
|
*
|
|
4
4
|
* The set is a bare room and a figure; a shot that needs "something in the
|
|
5
|
-
* world" (a
|
|
5
|
+
* world" (a wall to lean on, to walk past, to frame behind) has nothing
|
|
6
6
|
* to work with. These props are built from primitives in the same clay style
|
|
7
7
|
* as the Room and the character tint, so the whole frame stays one
|
|
8
|
-
* consistent maquette.
|
|
9
|
-
*
|
|
8
|
+
* consistent maquette. Practical lights and glow live in scene-lights.jsx
|
|
9
|
+
* and object-glow.js.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
@@ -25,7 +25,7 @@ import { EffectMarker, EffectsLayer } from "./effects/EffectsLayer.jsx";
|
|
|
25
25
|
import { EFFECT_KIND } from "./effects/effect-record.js";
|
|
26
26
|
import { applyPoseFade } from "./pose-fade.js";
|
|
27
27
|
import { coplanarDepthRanks } from "./coplanar-depth.js";
|
|
28
|
-
import { autoFlat
|
|
28
|
+
import { autoFlat } from "./auto-flat.js";
|
|
29
29
|
import { LIGHT_KIND, capPracticalShadows } from "./object-look.js";
|
|
30
30
|
import { SceneLight, useLookRefresh } from "./scene-lights.jsx";
|
|
31
31
|
import { placeObjectLook } from "./scene-light-place.js";
|
|
@@ -242,9 +242,6 @@ function SceneObjectContent({ object, depthRank = 0 }) {
|
|
|
242
242
|
if (renderer === MESH_KIND) return <ImportedMesh object={object} />;
|
|
243
243
|
if (renderer === SCULPT_KIND) return <Sculpt object={object} depthRank={depthRank} />;
|
|
244
244
|
if (renderer === TEXT_KIND) return <TextObject object={object} />;
|
|
245
|
-
if (renderer === "car") return <Car color={autoColor ?? color} autoColor={autoColor} />;
|
|
246
|
-
if (renderer === "small-plane") return <SmallPlane autoColor={autoColor} />;
|
|
247
|
-
if (renderer === "chair") return <Chair autoColor={autoColor} />;
|
|
248
245
|
if (PRIMITIVE_KINDS.has(renderer)) return <Primitive kind={renderer} color={color} autoColor={autoColor} depthRank={depthRank} />;
|
|
249
246
|
return null;
|
|
250
247
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Object kinds an old project can carry that this build no longer ships.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Kinds an earlier build shipped and this one does not (hand-built set
|
|
5
|
+
* pieces), with the standing size they had. They exist only so an old project
|
|
6
|
+
* still opens: each record becomes a plain cube with the same transform and
|
|
7
|
+
* the same size, so its blocking, collisions, route and children survive.
|
|
8
|
+
*/
|
|
9
|
+
const RETIRED_KINDS = Object.freeze({
|
|
10
|
+
chair: { width: 0.6, depth: 0.6, height: 1.15 },
|
|
11
|
+
car: { width: 1.8, depth: 4.5, height: 1.4 },
|
|
12
|
+
"small-plane": { width: 3.4, depth: 3.6, height: 1.4 },
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/** A stored record of a retired kind as the cube that stands in for it; any
|
|
16
|
+
* other record is returned untouched. Scale absorbs the retired footprint, the
|
|
17
|
+
* same single-`scale` fallback normalizeSceneObject reads applies first. */
|
|
18
|
+
export function retireObjectKind(record) {
|
|
19
|
+
const size = record && typeof record === "object" ? RETIRED_KINDS[record.renderer] : undefined;
|
|
20
|
+
if (!size) return record;
|
|
21
|
+
const single = record.scaleX === undefined && record.scaleY === undefined && record.scaleZ === undefined ? Number(record.scale) : NaN;
|
|
22
|
+
const axis = (value) => {
|
|
23
|
+
const n = value === undefined ? (Number.isFinite(single) ? single : 1) : Number(value);
|
|
24
|
+
return Number.isFinite(n) ? n : 1;
|
|
25
|
+
};
|
|
26
|
+
const { scale: _scale, ...rest } = record;
|
|
27
|
+
return {
|
|
28
|
+
...rest,
|
|
29
|
+
renderer: "cube",
|
|
30
|
+
scaleX: axis(record.scaleX) * size.width,
|
|
31
|
+
scaleY: axis(record.scaleY) * size.height,
|
|
32
|
+
scaleZ: axis(record.scaleZ) * size.depth,
|
|
33
|
+
footprint: { width: 1, depth: 1 },
|
|
34
|
+
height: 1,
|
|
35
|
+
supportY: 1,
|
|
36
|
+
};
|
|
37
|
+
}
|
package/src/scene-objects.js
CHANGED
|
@@ -24,6 +24,7 @@ import { MESH_DEFAULT_HEIGHT, MESH_HEIGHT_MIN } from "./scene-mesh.js";
|
|
|
24
24
|
import { normalizeSculptRecipe, sculptStandingBox } from "./sculpt-recipe.js";
|
|
25
25
|
import { GREY_BOX } from "./object-colors.js";
|
|
26
26
|
import { isEffectivelyHidden } from "./object-hidden.js";
|
|
27
|
+
import { retireObjectKind } from "./retired-kinds.js";
|
|
27
28
|
import { objectSize } from "./object-size.js";
|
|
28
29
|
import { EFFECT_ENTRY, effectFields } from "./effects/effect-record.js";
|
|
29
30
|
import { TEXT_ENTRY, TEXT_KIND, textFields, textPatch } from "./text/text-record.js";
|
|
@@ -65,8 +66,7 @@ const snapTo = (value, step) => Number((step > 0 ? Math.round(value / step) * st
|
|
|
65
66
|
* object stands (used for the selection box and the gizmo's height).
|
|
66
67
|
*
|
|
67
68
|
* Primitives are grey-box grey on purpose: a blockout stands in for something
|
|
68
|
-
* else, and a tinted maquette reads as a finished prop.
|
|
69
|
-
* pieces keep their clay colours because they ARE the thing they depict.
|
|
69
|
+
* else, and a tinted maquette reads as a finished prop.
|
|
70
70
|
*/
|
|
71
71
|
export const OBJECT_LIBRARY = [
|
|
72
72
|
{ kind: "cube", label: "Cube", group: "Primitives", footprint: { width: 1, depth: 1 }, height: 1, color: GREY_BOX },
|
|
@@ -75,11 +75,6 @@ export const OBJECT_LIBRARY = [
|
|
|
75
75
|
{ kind: "cylinder", label: "Cylinder", group: "Primitives", footprint: { width: 1, depth: 1 }, height: 1, color: GREY_BOX },
|
|
76
76
|
{ kind: "cone", label: "Cone", group: "Primitives", footprint: { width: 1, depth: 1 }, height: 1, color: GREY_BOX },
|
|
77
77
|
{ kind: "plane", label: "Plane", group: "Primitives", footprint: { width: 2, depth: 2 }, height: 0, color: GREY_BOX },
|
|
78
|
-
// supportY is the walkable surface, which can differ from the object's
|
|
79
|
-
// bounding height (a chair's back rises above its seat).
|
|
80
|
-
{ kind: "chair", label: "Chair", group: "Set pieces", footprint: { width: 0.6, depth: 0.6 }, height: 1.15, supportY: 0.495, color: "#b9855d" },
|
|
81
|
-
{ kind: "car", label: "Car", group: "Set pieces", footprint: { width: 1.8, depth: 4.5 }, height: 1.4, color: "#d98770" },
|
|
82
|
-
{ kind: "small-plane", label: "Plane (aircraft)", group: "Set pieces", footprint: { width: 3.4, depth: 3.6 }, height: 1.4, color: "#7896a4" },
|
|
83
78
|
];
|
|
84
79
|
|
|
85
80
|
/**
|
|
@@ -994,6 +989,7 @@ function objectOpacity(value) {
|
|
|
994
989
|
|
|
995
990
|
export function normalizeSceneObject(record) {
|
|
996
991
|
if (!record || typeof record !== "object" || Array.isArray(record)) return null;
|
|
992
|
+
record = retireObjectKind(record);
|
|
997
993
|
const entry = objectLibraryEntry(record.renderer);
|
|
998
994
|
if (!entry) return null;
|
|
999
995
|
if (typeof record.id !== "string" || !record.id) return null;
|
package/src/scenes.js
CHANGED
|
@@ -6,7 +6,7 @@ import { DEFAULT_SENSOR_FORMAT, SENSOR_FORMATS } from "./shot.js";
|
|
|
6
6
|
import { normalizeStableItems } from "./stable-items.js";
|
|
7
7
|
|
|
8
8
|
import { elementByPath } from "./studio-elements.js";
|
|
9
|
-
import { wrapAngle } from "./scene-objects.js";
|
|
9
|
+
import { wrapAngle } from "./scene-objects.js"; import { retireObjectKind } from "./retired-kinds.js";
|
|
10
10
|
import { normalizeTakeSource } from "./domains/motion/take-source.js";
|
|
11
11
|
import { createKeyLight, normalizeLighting } from "./stage-lighting.js";
|
|
12
12
|
// The timeline clock lives in timeline-fps.js (three-free, so the agent
|
|
@@ -115,8 +115,6 @@ export function normalizeReferenceImage(value) {
|
|
|
115
115
|
|
|
116
116
|
/** Stature band for a cast member: the gizmo's scale handles are a deliberate
|
|
117
117
|
* artistic ask, and a previs giant or child is legitimate. */
|
|
118
|
-
export const CHARACTER_SCALE_MIN = CHARACTER_SCALE_LIMITS.min;
|
|
119
|
-
export const CHARACTER_SCALE_MAX = CHARACTER_SCALE_LIMITS.max;
|
|
120
118
|
const clampScale = (value) => (Number.isFinite(value) && value > 0
|
|
121
119
|
? Math.max(CHARACTER_SCALE_LIMITS.min, Math.min(CHARACTER_SCALE_LIMITS.max, value))
|
|
122
120
|
: 1);
|
|
@@ -373,7 +371,8 @@ function repairScene(record, existing, fallbackNumber, fallbackStage, legacyCloc
|
|
|
373
371
|
return {
|
|
374
372
|
id: record.id,
|
|
375
373
|
name: uniqueName(typeof record.name === "string" ? record.name : `SCENE ${String(fallbackNumber).padStart(2, "0")}`, existing),
|
|
376
|
-
|
|
374
|
+
// A kind this build no longer ships opens as the cube that stands in for it.
|
|
375
|
+
objects: record.objects.filter(plainObject).map((object) => retireObjectKind(cloneValue(object))),
|
|
377
376
|
// The shot document is a sealed envelope with its OWN version, and it
|
|
378
377
|
// migrates its own frames when its reader opens it.
|
|
379
378
|
shotDocument: cloneValue(record.shotDocument ?? null),
|
|
@@ -412,7 +411,7 @@ export function readSceneDocument(raw, legacyRaw = null) {
|
|
|
412
411
|
return { status: "corrupt", document: createSceneDocument(), dropped: 0, quarantineRaw: legacyRaw };
|
|
413
412
|
}
|
|
414
413
|
const document = createSceneDocument();
|
|
415
|
-
document.scenes[0].objects = legacy.objects.filter(plainObject).map((object) => cloneValue(object));
|
|
414
|
+
document.scenes[0].objects = legacy.objects.filter(plainObject).map((object) => retireObjectKind(cloneValue(object)));
|
|
416
415
|
return { status: "migrated", document, dropped: legacy.objects.length - document.scenes[0].objects.length };
|
|
417
416
|
}
|
|
418
417
|
return { status: "absent", document: createSceneDocument(), dropped: 0 };
|
package/src/settings-menu.jsx
CHANGED
|
@@ -7,8 +7,7 @@ import PreferencesDialog from "./shell/PreferencesDialog.jsx";
|
|
|
7
7
|
// not belong on the Save/Export row as bare toggles.
|
|
8
8
|
//
|
|
9
9
|
// The trigger now opens the v2 Preferences dialog. The old language strings stay
|
|
10
|
-
// here because the locale contract
|
|
11
|
-
// treat this file as the Settings entry point.
|
|
10
|
+
// here because the locale contract treats this file as the Settings entry point.
|
|
12
11
|
const LANGUAGES = [
|
|
13
12
|
{ id: "en", label: "English", action: "Switch to English" },
|
|
14
13
|
{ id: "ko", label: "한국어", action: "한국어로 전환" },
|
|
@@ -21,23 +20,6 @@ export default function SettingsMenu() {
|
|
|
21
20
|
setOpen(false);
|
|
22
21
|
triggerRef.current?.focus();
|
|
23
22
|
}, []);
|
|
24
|
-
const openCameraTutorial = useCallback(() => {
|
|
25
|
-
window.dispatchEvent(new CustomEvent("cozyclay:camera-tutorial", { detail: { open: true } }));
|
|
26
|
-
setOpen(false);
|
|
27
|
-
}, []);
|
|
28
|
-
const cameraTutorial = (
|
|
29
|
-
<>
|
|
30
|
-
<h4>{ko("Help", "도움말")}</h4>
|
|
31
|
-
<button
|
|
32
|
-
type="button"
|
|
33
|
-
data-testid="settings-camera-tutorial"
|
|
34
|
-
title={ko("Learn the camera in seven steps", "일곱 단계로 카메라 익히기")}
|
|
35
|
-
onClick={openCameraTutorial}
|
|
36
|
-
>
|
|
37
|
-
{ko("Camera tutorial", "카메라 튜토리얼")}
|
|
38
|
-
</button>
|
|
39
|
-
</>
|
|
40
|
-
);
|
|
41
23
|
|
|
42
24
|
// First-run cue: the studio starts in English even on a Korean browser, so
|
|
43
25
|
// the only visible hint that Korean exists used to be the old locale button.
|
|
@@ -64,7 +46,6 @@ export default function SettingsMenu() {
|
|
|
64
46
|
open={open}
|
|
65
47
|
onClose={close}
|
|
66
48
|
initialSection="Appearance"
|
|
67
|
-
cameraTutorial={cameraTutorial}
|
|
68
49
|
/>
|
|
69
50
|
</div>
|
|
70
51
|
);
|
package/src/shell/BottomDock.jsx
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
-
import { useStudioShell } from "./studio-shell-context.js";
|
|
2
|
+
import { useStudioPlaybackFrame, useStudioShell } from "./studio-shell-context.js";
|
|
3
3
|
import { logStore } from "./log-store.js";
|
|
4
4
|
import "./dock.css";
|
|
5
5
|
import LibrarySlot from "./LibrarySlot.jsx";
|
|
6
6
|
import BoardSlot from "./BoardSlot.jsx";
|
|
7
|
+
import LensScrub from "./lens-scrub.jsx";
|
|
7
8
|
import { ko } from "../locale.js";
|
|
8
9
|
import Timeline, { timelineTake } from "../motion/timeline.jsx";
|
|
9
|
-
import { DEFAULT_PLAYBACK_SPEED,
|
|
10
|
+
import { DEFAULT_PLAYBACK_SPEED, sceneObjectNameDisplayKo } from "../app-stage.jsx";
|
|
10
11
|
|
|
11
12
|
import { trackFeature } from "../analytics.js";
|
|
12
13
|
import { defaultRailRange } from "../camera-rail-schedule.js";
|
|
@@ -67,11 +68,11 @@ function dockTabsFor(previsMode) {
|
|
|
67
68
|
* switching keeps the Sequencer's scroll and zoom and the Library's folder
|
|
68
69
|
* and search. Any stored tab the mode does not offer opens its first tab. */
|
|
69
70
|
export default function BottomDock({ tab: requestedTab = "animation", onTabChange, embedded = false }) {
|
|
70
|
-
const { previsMode } = useStudioShell();
|
|
71
|
+
const { previsMode } = useStudioShell(), liveFrame = useStudioPlaybackFrame();
|
|
71
72
|
const tabs = embedded ? dockTabsFor(null) : dockTabsFor(previsMode);
|
|
72
73
|
const tab = tabs.some((entry) => entry.key === requestedTab) ? requestedTab : tabs[0].key;
|
|
73
74
|
const {
|
|
74
|
-
|
|
75
|
+
motion, waypointMode, craneSelectedIndex,
|
|
75
76
|
isCameraSelection, addActiveCranePoint, deleteSelectedCranePoint, setCraneSelectedIndex, tlFrameCount,
|
|
76
77
|
tlFps, characters, activeCharIndex, ghostLayers, pathSpeed,
|
|
77
78
|
tlPlaying, workflowMode, waypoints, pendingWaypointFrame,
|
|
@@ -205,7 +206,7 @@ export default function BottomDock({ tab: requestedTab = "animation", onTabChang
|
|
|
205
206
|
{!embedded && previsMode === "storyboard" && <BoardSlot active={tab === "board"} />}
|
|
206
207
|
<div className="bottom-timeline" hidden={tab !== "animation"}>
|
|
207
208
|
<Timeline
|
|
208
|
-
frame={
|
|
209
|
+
frame={liveFrame}
|
|
209
210
|
craneSelectedIndex={craneSelectedIndex}
|
|
210
211
|
cameraSelected={isCameraSelection}
|
|
211
212
|
onCranePointAdd={addActiveCranePoint}
|
|
@@ -390,11 +391,10 @@ const CARD_RATIOS = [
|
|
|
390
391
|
|
|
391
392
|
/** The shot camera's card at the dock's right end. The camera itself is drawn
|
|
392
393
|
* into the stage canvas under `.vp-shot-preview`, which sits over the card's
|
|
393
|
-
* frame slot (see glass.css); the card holds its title, ratio and look-through. */
|
|
394
|
+
* frame slot (see glass.css); the card holds its title, lens, ratio and look-through. */
|
|
394
395
|
function ShotCard() {
|
|
395
|
-
const { activeShot, shot, shotAspectKey, runStudioAction, enterShotLook, exitShotLook, lookThroughShot, tlFps, embedMode } = useStudioShell();
|
|
396
|
+
const { activeShot, shot, fovDeg, shotsDomain, shotAspectKey, runStudioAction, enterShotLook, exitShotLook, lookThroughShot, tlFps, embedMode } = useStudioShell();
|
|
396
397
|
if (embedMode) return null;
|
|
397
|
-
const ratio = SHOT_ASPECT_PRESETS[shotAspectKey]?.label?.replace(/:1$/, "") ?? shotAspectKey;
|
|
398
398
|
const frames = activeShot && Number.isFinite(activeShot.startFrame) && Number.isFinite(activeShot.endFrame)
|
|
399
399
|
? `${activeShot.startFrame}–${activeShot.endFrame} · ${((activeShot.endFrame - activeShot.startFrame + 1) / (tlFps || 24)).toFixed(1)}s`
|
|
400
400
|
: null;
|
|
@@ -403,7 +403,7 @@ function ShotCard() {
|
|
|
403
403
|
<header className="dock-shot-head">
|
|
404
404
|
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2 5.75A1.5 1.5 0 0 1 3.5 4.25h6A1.5 1.5 0 0 1 11 5.75v4.5a1.5 1.5 0 0 1-1.5 1.5h-6A1.5 1.5 0 0 1 2 10.25z M11 7l3-1.5v5L11 9" /></svg>
|
|
405
405
|
<span className="dock-shot-title">{ko("Shot Camera", "샷 카메라")}</span>
|
|
406
|
-
<
|
|
406
|
+
<LensScrub fovDeg={fovDeg} sensorHeightMm={shot?.usedSensorHeightMm} shotsDomain={shotsDomain} disabled={!shot} />
|
|
407
407
|
</header>
|
|
408
408
|
{/* While the main view looks through this camera the card has nothing
|
|
409
409
|
of its own to show (the preview pass is off): it says where the
|
package/src/shell/MenuBar.jsx
CHANGED
|
@@ -196,7 +196,7 @@ export default function MenuBar({ preferences }) {
|
|
|
196
196
|
? panelCount ? "" : noPanels
|
|
197
197
|
: shots.length ? "" : ko("Add a shot first — a pack describes one cut", "샷을 먼저 추가하세요 — 팩은 컷 하나를 설명합니다");
|
|
198
198
|
|
|
199
|
-
//
|
|
199
|
+
// Export's flyout lives in File, so an open Export menu opens File.
|
|
200
200
|
useEffect(() => {
|
|
201
201
|
if (exportMenuOpen) setOpen("file");
|
|
202
202
|
}, [exportMenuOpen]);
|
|
@@ -534,14 +534,6 @@ export default function MenuBar({ preferences }) {
|
|
|
534
534
|
),
|
|
535
535
|
help: (
|
|
536
536
|
<>
|
|
537
|
-
<button
|
|
538
|
-
type="button"
|
|
539
|
-
role="menuitem"
|
|
540
|
-
className="menubar-item"
|
|
541
|
-
onClick={item(() => window.dispatchEvent(new CustomEvent("cozyclay:camera-tutorial", { detail: { open: true, source: "help" } })))}
|
|
542
|
-
>
|
|
543
|
-
<span className="menubar-item-label">{ko("Tutorial", "튜토리얼")}</span>
|
|
544
|
-
</button>
|
|
545
537
|
<button type="button" role="menuitem" className="menubar-item" onClick={item(() => setDialog("shortcuts"))}>
|
|
546
538
|
<span className="menubar-item-label">{ko("Keyboard shortcuts", "단축키")}</span>
|
|
547
539
|
</button>
|
|
@@ -114,7 +114,7 @@ function ThemeControls() {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
function preferenceSection(section, props) {
|
|
117
|
-
const { autoColor, setAutoColor, analyticsOptOut, setAnalyticsOptOutState, gridView, setGridView
|
|
117
|
+
const { autoColor, setAutoColor, analyticsOptOut, setAnalyticsOptOutState, gridView, setGridView } = props;
|
|
118
118
|
const languageControls = (
|
|
119
119
|
<div className="v2-preferences__language-controls">
|
|
120
120
|
{LANGUAGES.map((language) => (
|
|
@@ -158,7 +158,6 @@ function preferenceSection(section, props) {
|
|
|
158
158
|
/>
|
|
159
159
|
</PreferenceRow>
|
|
160
160
|
</PreferenceSection>
|
|
161
|
-
{cameraTutorial && <div className="v2-preferences__help">{cameraTutorial}</div>}
|
|
162
161
|
</>
|
|
163
162
|
);
|
|
164
163
|
case "Viewport":
|
|
@@ -181,7 +180,7 @@ function preferenceSection(section, props) {
|
|
|
181
180
|
}
|
|
182
181
|
}
|
|
183
182
|
|
|
184
|
-
export default function PreferencesDialog({ open, onClose, initialSection = "Appearance"
|
|
183
|
+
export default function PreferencesDialog({ open, onClose, initialSection = "Appearance" }) {
|
|
185
184
|
const dialogRef = useRef(null);
|
|
186
185
|
const searchRef = useRef(null);
|
|
187
186
|
const closeRef = useRef(onClose);
|
|
@@ -233,7 +232,7 @@ export default function PreferencesDialog({ open, onClose, initialSection = "App
|
|
|
233
232
|
}, [open, initialSection]);
|
|
234
233
|
|
|
235
234
|
if (!open || typeof document === "undefined") return null;
|
|
236
|
-
const currentProps = { autoColor, setAutoColor, analyticsOptOut, setAnalyticsOptOutState, gridView, setGridView
|
|
235
|
+
const currentProps = { autoColor, setAutoColor, analyticsOptOut, setAnalyticsOptOutState, gridView, setGridView };
|
|
237
236
|
|
|
238
237
|
return createPortal(
|
|
239
238
|
<div className="v2-preferences" data-testid="preferences-dialog-backdrop" onPointerDown={(event) => { if (event.target === event.currentTarget) closeRef.current?.(); }}>
|
|
@@ -11,7 +11,7 @@ import { elementByPath } from "../studio-elements.js";
|
|
|
11
11
|
import { saveAutoColor } from "../auto-color.js";
|
|
12
12
|
import { trackFeature } from "../analytics.js";
|
|
13
13
|
import { GUIDE_LABELS, nextGuideMode } from "../shot-guides.js";
|
|
14
|
-
import GhostsMenuGroup from "./GhostsMenuGroup.jsx";
|
|
14
|
+
import GhostsMenuGroup from "./GhostsMenuGroup.jsx"; import { addCameraShot } from "./add-camera-shot.js";
|
|
15
15
|
import "./viewport.css";
|
|
16
16
|
|
|
17
17
|
// G1: the single mode+tool pill. Keys 1-5 and W/E/R are bound in App's
|
|
@@ -122,7 +122,7 @@ export default function ViewportToolbar() {
|
|
|
122
122
|
autoColor, setAutoColor, isCharacterSelection, partColoursChoice, embedMode,
|
|
123
123
|
agentCollapsed, addSceneObject, spawnCharacter, characters, flySpeed,
|
|
124
124
|
ikEditTool, setIkEditTool, motion, setRangePinPartPick,
|
|
125
|
-
previsMode, sculptView, requestSculptView,
|
|
125
|
+
previsMode, sculptView, requestSculptView, appContext, setToast,
|
|
126
126
|
} = useStudioShell();
|
|
127
127
|
const menu = useOverlayMenu();
|
|
128
128
|
|
|
@@ -292,7 +292,7 @@ export default function ViewportToolbar() {
|
|
|
292
292
|
role="menuitem"
|
|
293
293
|
className="add-object-item"
|
|
294
294
|
data-add="camera"
|
|
295
|
-
onClick={() => { runStudioAction
|
|
295
|
+
onClick={() => { addCameraShot({ appContext, runStudioAction, setToast }); menu.close(); }}
|
|
296
296
|
>
|
|
297
297
|
<span className="add-object-swatch vp-add-swatch-camera" aria-hidden="true" />
|
|
298
298
|
<span>{ko("Camera", "카메라")}</span>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Add > Camera names the cut it made (ViewportToolbar). A refusal is already toasted by runStudioAction.
|
|
2
|
+
import { ko } from "../locale.js";
|
|
3
|
+
|
|
4
|
+
export function addCameraShot({ appContext, runStudioAction, setToast }) {
|
|
5
|
+
const shots = appContext.storeDomain("shot"), before = shots.read(), receipt = runStudioAction("shot.create");
|
|
6
|
+
const made = shots.read().find((row) => row.id === receipt?.affectedIds.find((id) => !before.some((old) => old.id === id)));
|
|
7
|
+
if (!made) return;
|
|
8
|
+
setToast(made.kind === "still"
|
|
9
|
+
? ko(`${made.name} added — a storyboard panel at frame ${made.startFrame}`, `${made.name} 추가됨 — ${made.startFrame}프레임의 스토리보드 패널`)
|
|
10
|
+
: ko(`${made.name} added — a camera cut at frame ${made.startFrame}`, `${made.name} 추가됨 — ${made.startFrame}프레임에서 카메라 컷`));
|
|
11
|
+
}
|
|
@@ -800,11 +800,12 @@
|
|
|
800
800
|
|
|
801
801
|
/* ----------------------------------------------- Dock: Animation | Assets */
|
|
802
802
|
/* The switch sits at the head's left end, where the Sequencer's transport
|
|
803
|
-
and the Library's Content | Log row start after it.
|
|
803
|
+
and the Library's Content | Log row start after it. It paints above the
|
|
804
|
+
timeline (z-index 6, timeline.css), which would otherwise take its clicks. */
|
|
804
805
|
.app[data-shell="glass"]:not([data-embed-mode]) .v2-dock { --dock-tabs-w: 176px; }
|
|
805
806
|
.app[data-shell="glass"]:not([data-embed-mode]) .dock-tabs {
|
|
806
807
|
position: absolute;
|
|
807
|
-
z-index:
|
|
808
|
+
z-index: 7;
|
|
808
809
|
top: 9px;
|
|
809
810
|
left: 16px;
|
|
810
811
|
box-sizing: border-box;
|
package/src/shell/glass.css
CHANGED
|
@@ -326,11 +326,6 @@
|
|
|
326
326
|
font-size: 11.5px;
|
|
327
327
|
font-weight: 400;
|
|
328
328
|
}
|
|
329
|
-
.app[data-shell="glass"]:not([data-embed-mode]) .studio-project-head .project-menu {
|
|
330
|
-
top: calc(100% + 6px);
|
|
331
|
-
left: -6px;
|
|
332
|
-
z-index: 40;
|
|
333
|
-
}
|
|
334
329
|
|
|
335
330
|
/* --------------------------------------------- viewport overlay rails */
|
|
336
331
|
/* Overlays fill the open stage between the columns: the mode switch rides
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The Shot Camera card's focal-length scrub (BottomDock ShotCard). The lens is
|
|
2
|
+
// stored as a vertical FOV (shotsDomain.changeLens takes degrees); the card
|
|
3
|
+
// speaks millimetres on the current filmback, the shot.js lens relation.
|
|
4
|
+
import { PRIME_SET } from "../shot.js";
|
|
5
|
+
|
|
6
|
+
// Editor lens range: shotsDomain.changeLens refuses FOVs outside 14-90°.
|
|
7
|
+
const FOV_MIN = 14, FOV_MAX = 90;
|
|
8
|
+
// px of horizontal drag per millimetre, and the Shift / Alt multipliers.
|
|
9
|
+
const PX_PER_MM = 4, FINE = 0.1, COARSE = 4;
|
|
10
|
+
|
|
11
|
+
const round1 = (value) => Math.round(value * 10) / 10;
|
|
12
|
+
|
|
13
|
+
export function fovDegToMm(fovDeg, sensorHeightMm) {
|
|
14
|
+
return sensorHeightMm / (2 * Math.tan((fovDeg * Math.PI) / 360));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function mmToFovDeg(mm, sensorHeightMm) {
|
|
18
|
+
return (2 * Math.atan(sensorHeightMm / (2 * mm)) * 180) / Math.PI;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The millimetre range the editor FOV range allows on this filmback. */
|
|
22
|
+
export function lensRange(sensorHeightMm) {
|
|
23
|
+
return { min: round1(fovDegToMm(FOV_MAX, sensorHeightMm)) + 0.1, max: round1(fovDegToMm(FOV_MIN, sensorHeightMm)) - 0.1 };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function clampMm(mm, range) {
|
|
27
|
+
return round1(Math.min(range.max, Math.max(range.min, mm)));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The lens a horizontal drag of dx px from startMm lands on. */
|
|
31
|
+
export function dragMm(startMm, dx, { fine = false, coarse = false } = {}, range) {
|
|
32
|
+
const scale = fine ? FINE : coarse ? COARSE : 1;
|
|
33
|
+
return clampMm(startMm + (dx / PX_PER_MM) * scale, range);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A click steps to the next real prime in range, wrapping to the widest. */
|
|
37
|
+
export function nextPrime(mm, range) {
|
|
38
|
+
const primes = PRIME_SET.filter((prime) => prime >= range.min && prime <= range.max);
|
|
39
|
+
return primes.find((prime) => prime > mm + 0.05) ?? primes[0];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The left arrow steps back to the previous real prime, wrapping to the longest. */
|
|
43
|
+
export function previousPrime(mm, range) {
|
|
44
|
+
const primes = PRIME_SET.filter((prime) => prime >= range.min && prime <= range.max);
|
|
45
|
+
return [...primes].reverse().find((prime) => prime < mm - 0.05) ?? primes.at(-1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function formatMm(mm) {
|
|
49
|
+
const value = round1(mm);
|
|
50
|
+
return `${Number.isInteger(value) ? value : value.toFixed(1)} mm`;
|
|
51
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/* The Shot Camera card's focal-length scrub (lens-scrub.jsx): a small field, the mm value over a track with a
|
|
2
|
+
thumb, so it reads as a control at rest; the step arrows appear just outside it on hover, taking no width. */
|
|
3
|
+
.dock-lens-scrub {
|
|
4
|
+
position: relative;
|
|
5
|
+
flex: none;
|
|
6
|
+
display: inline-flex;
|
|
7
|
+
align-items: center;
|
|
8
|
+
gap: 0;
|
|
9
|
+
padding: 1px;
|
|
10
|
+
border-radius: 6px;
|
|
11
|
+
background: var(--chip-fill, rgba(127, 127, 127, .1));
|
|
12
|
+
color: var(--text-1, currentColor);
|
|
13
|
+
font: 500 11px var(--sans);
|
|
14
|
+
font-variant-numeric: tabular-nums;
|
|
15
|
+
cursor: ew-resize;
|
|
16
|
+
user-select: none;
|
|
17
|
+
touch-action: none;
|
|
18
|
+
}
|
|
19
|
+
.dock-lens-body { display: flex; flex-direction: column; align-items: stretch; gap: 2px; padding: 2px 4px; }
|
|
20
|
+
.dock-lens-value { line-height: 1; white-space: nowrap; text-align: center; }
|
|
21
|
+
.dock-lens-track {
|
|
22
|
+
position: relative;
|
|
23
|
+
width: 38px;
|
|
24
|
+
height: 2px;
|
|
25
|
+
border-radius: 1px;
|
|
26
|
+
background: var(--hairline, rgba(127, 127, 127, .3));
|
|
27
|
+
}
|
|
28
|
+
.dock-lens-fill {
|
|
29
|
+
position: absolute;
|
|
30
|
+
inset: 0 auto 0 0;
|
|
31
|
+
width: var(--lens-at, 0%);
|
|
32
|
+
border-radius: inherit;
|
|
33
|
+
background: var(--text-3, currentColor);
|
|
34
|
+
}
|
|
35
|
+
.dock-lens-thumb {
|
|
36
|
+
position: absolute;
|
|
37
|
+
top: 50%;
|
|
38
|
+
left: var(--lens-at, 0%);
|
|
39
|
+
width: 5px;
|
|
40
|
+
height: 5px;
|
|
41
|
+
border-radius: 50%;
|
|
42
|
+
background: var(--text-2, currentColor);
|
|
43
|
+
box-shadow: 0 0 0 1px var(--surface-raised, #fff);
|
|
44
|
+
transform: translate(-50%, -50%);
|
|
45
|
+
}
|
|
46
|
+
.dock-lens-step {
|
|
47
|
+
position: absolute;
|
|
48
|
+
top: 50%;
|
|
49
|
+
display: grid;
|
|
50
|
+
place-items: center;
|
|
51
|
+
width: 12px;
|
|
52
|
+
height: 18px;
|
|
53
|
+
border-radius: 3px;
|
|
54
|
+
color: var(--text-3, currentColor);
|
|
55
|
+
cursor: pointer;
|
|
56
|
+
opacity: 0;
|
|
57
|
+
transform: translateY(-50%);
|
|
58
|
+
transition: opacity .12s;
|
|
59
|
+
}
|
|
60
|
+
.dock-lens-step[data-step="back"] { right: 100%; }
|
|
61
|
+
.dock-lens-step[data-step="next"] { left: 100%; }
|
|
62
|
+
.dock-lens-scrub:is(:hover, :focus-visible, .dragging) .dock-lens-step { opacity: 1; }
|
|
63
|
+
.dock-lens-step:hover { color: var(--select); background: var(--hairline, rgba(127, 127, 127, .2)); }
|
|
64
|
+
.dock-lens-scrub:hover,
|
|
65
|
+
.dock-lens-scrub.dragging { color: var(--select); }
|
|
66
|
+
.dock-lens-scrub:hover .dock-lens-fill,
|
|
67
|
+
.dock-lens-scrub.dragging .dock-lens-fill,
|
|
68
|
+
.dock-lens-scrub:hover .dock-lens-thumb,
|
|
69
|
+
.dock-lens-scrub.dragging .dock-lens-thumb { background: var(--select); }
|
|
70
|
+
.dock-lens-scrub.dragging .dock-lens-thumb { transform: translate(-50%, -50%) scale(1.3); }
|
|
71
|
+
.dock-lens-scrub:focus-visible { outline: 2px solid var(--select); outline-offset: 1px; }
|
|
72
|
+
.dock-lens-scrub[aria-disabled="true"] { cursor: default; opacity: .45; background: none; box-shadow: none; color: var(--label, var(--text-2)); }
|
|
73
|
+
.dock-lens-scrub[aria-disabled="true"] :is(.dock-lens-fill, .dock-lens-thumb, .dock-lens-step) { visibility: hidden; }
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { FiChevronLeft, FiChevronRight } from "react-icons/fi";
|
|
3
|
+
import { ko } from "../locale.js";
|
|
4
|
+
import { PRESETS } from "../app-stage.jsx";
|
|
5
|
+
import { clampMm, dragMm, fovDegToMm, formatMm, lensRange, mmToFovDeg, nextPrime, previousPrime } from "./lens-scrub-math.js";
|
|
6
|
+
import "./lens-scrub.css";
|
|
7
|
+
|
|
8
|
+
// A drag starts after this many px; less is a click (the next prime).
|
|
9
|
+
const DRAG_SLOP = 3;
|
|
10
|
+
// A click waits this long so a double-click (reset) does not also step.
|
|
11
|
+
const CLICK_DELAY_MS = 220;
|
|
12
|
+
|
|
13
|
+
/** The Shot Camera card's focal length: drag left/right to change the lens.
|
|
14
|
+
* A drag is one undo step (shotsDomain's gesture commits on pointer-up, Esc
|
|
15
|
+
* cancels); a click steps to the next prime (the left arrow: the previous
|
|
16
|
+
* one); a double-click resets. It is drawn as a field with arrows and a thumb
|
|
17
|
+
* so it reads as something to drag, not as a label. */
|
|
18
|
+
export default function LensScrub({ fovDeg, sensorHeightMm, shotsDomain, disabled = false }) {
|
|
19
|
+
const off = disabled || !shotsDomain || !Number.isFinite(fovDeg) || !(sensorHeightMm > 0);
|
|
20
|
+
const range = off ? { min: 0, max: 0 } : lensRange(sensorHeightMm);
|
|
21
|
+
const mm = off ? 0 : clampMm(fovDegToMm(fovDeg, sensorHeightMm), range);
|
|
22
|
+
const drag = useRef(null);
|
|
23
|
+
const clickTimer = useRef(0);
|
|
24
|
+
const [dragging, setDragging] = useState(false);
|
|
25
|
+
useEffect(() => () => clearTimeout(clickTimer.current), []);
|
|
26
|
+
|
|
27
|
+
const setMm = (value) => shotsDomain.changeLens(mmToFovDeg(clampMm(value, range), sensorHeightMm));
|
|
28
|
+
const onPointerDown = (event) => {
|
|
29
|
+
if (off || event.button !== 0) return;
|
|
30
|
+
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
31
|
+
drag.current = { x: event.clientX, mm, moved: false, back: event.target.closest?.("[data-step]")?.dataset.step === "back" };
|
|
32
|
+
};
|
|
33
|
+
const onPointerMove = (event) => {
|
|
34
|
+
const state = drag.current;
|
|
35
|
+
if (!state) return;
|
|
36
|
+
const dx = event.clientX - state.x;
|
|
37
|
+
if (!state.moved) {
|
|
38
|
+
if (Math.abs(dx) < DRAG_SLOP) return;
|
|
39
|
+
state.moved = true;
|
|
40
|
+
setDragging(true);
|
|
41
|
+
shotsDomain.beginGesture();
|
|
42
|
+
}
|
|
43
|
+
setMm(dragMm(state.mm, dx, { fine: event.shiftKey, coarse: event.altKey }, range));
|
|
44
|
+
};
|
|
45
|
+
const onPointerUp = () => {
|
|
46
|
+
const state = drag.current;
|
|
47
|
+
drag.current = null;
|
|
48
|
+
setDragging(false);
|
|
49
|
+
if (!state || state.moved) return;
|
|
50
|
+
clearTimeout(clickTimer.current);
|
|
51
|
+
clickTimer.current = setTimeout(() => setMm((state.back ? previousPrime : nextPrime)(state.mm, range)), CLICK_DELAY_MS);
|
|
52
|
+
};
|
|
53
|
+
const onDoubleClick = () => {
|
|
54
|
+
if (off) return;
|
|
55
|
+
clearTimeout(clickTimer.current);
|
|
56
|
+
shotsDomain.changeLens(PRESETS.medium.fov);
|
|
57
|
+
};
|
|
58
|
+
const onKeyDown = (event) => {
|
|
59
|
+
if (off || (event.key !== "ArrowLeft" && event.key !== "ArrowRight")) return;
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
event.stopPropagation();
|
|
62
|
+
const step = (event.shiftKey ? 10 : 1) * (event.key === "ArrowRight" ? 1 : -1);
|
|
63
|
+
setMm(Math.round(mm + step));
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<span
|
|
68
|
+
className={"dock-lens-scrub" + (dragging ? " dragging" : "")}
|
|
69
|
+
role="slider"
|
|
70
|
+
tabIndex={off ? -1 : 0}
|
|
71
|
+
aria-label={ko("Focal length", "초점 거리")}
|
|
72
|
+
aria-disabled={off || undefined}
|
|
73
|
+
aria-valuemin={range.min}
|
|
74
|
+
aria-valuemax={range.max}
|
|
75
|
+
aria-valuenow={mm}
|
|
76
|
+
aria-valuetext={off ? ko("No shot camera", "샷 카메라 없음") : formatMm(mm)}
|
|
77
|
+
aria-orientation="horizontal"
|
|
78
|
+
title={ko("Focal length — drag to change, click an arrow for the next lens", "초점 거리 — 드래그해 변경, 화살표를 눌러 다음 렌즈")}
|
|
79
|
+
data-testid="dock-lens-scrub"
|
|
80
|
+
onPointerDown={onPointerDown}
|
|
81
|
+
onPointerMove={onPointerMove}
|
|
82
|
+
onPointerUp={onPointerUp}
|
|
83
|
+
onPointerCancel={() => { drag.current = null; setDragging(false); }}
|
|
84
|
+
onDoubleClick={onDoubleClick}
|
|
85
|
+
onKeyDown={onKeyDown}
|
|
86
|
+
>
|
|
87
|
+
<span className="dock-lens-step" data-step="back" aria-hidden="true"><FiChevronLeft size={9} /></span>
|
|
88
|
+
<span className="dock-lens-body">
|
|
89
|
+
<span className="dock-lens-value">{off ? "— mm" : formatMm(mm)}</span>
|
|
90
|
+
<span className="dock-lens-track" aria-hidden="true" style={{ "--lens-at": `${off ? 0 : ((mm - range.min) / (range.max - range.min)) * 100}%` }}>
|
|
91
|
+
<span className="dock-lens-fill" />
|
|
92
|
+
<span className="dock-lens-thumb" />
|
|
93
|
+
</span>
|
|
94
|
+
</span>
|
|
95
|
+
<span className="dock-lens-step" data-step="next" aria-hidden="true"><FiChevronRight size={9} /></span>
|
|
96
|
+
</span>
|
|
97
|
+
);
|
|
98
|
+
}
|
package/src/shell/mode.css
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/* Pose mode (#521) is IK editing on the motion workspace: it hides what
|
|
2
|
-
Motion hides (scene transform and camera framing chrome, the inset and
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
Motion hides (scene transform and camera framing chrome, the inset) and
|
|
3
|
+
shows the mode hint. The shot preview stays: it is drawn into the dock's
|
|
4
|
+
Shot Camera frame, and hiding it left that frame a hole onto the editor
|
|
5
|
+
view. The timeline's pose-correction tools are hidden only in Scene and
|
|
6
|
+
Camera, so they stay visible here. */
|
|
5
7
|
.app[data-workflow-mode="pose"] .workflow-scene-context,
|
|
6
8
|
.app[data-workflow-mode="pose"] .workflow-camera-context { display: none; }
|
|
7
9
|
.app[data-workflow-mode="pose"] .workflow-toolbar-hint { display: inline-flex; }
|
|
8
|
-
.app[data-workflow-mode="pose"] .vp-inset
|
|
9
|
-
.app[data-workflow-mode="pose"] .vp-shot-preview { display: none !important; }
|
|
10
|
+
.app[data-workflow-mode="pose"] .vp-inset { display: none !important; }
|
|
10
11
|
|
|
11
12
|
/* A storyboard project (#650) is stills: the take, motion and pose-correction
|
|
12
13
|
tools, the root-path (waypoint) switch and a prop's Top-View travel path
|