cozyclay 1.9.0 → 2.0.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 +171 -0
- package/bin/agent/agent-routes.mjs +74 -70
- package/bin/agent/agent-runner.mjs +10 -3
- package/bin/agent/motion-runtime.mjs +32 -260
- package/bin/agent/providers.mjs +46 -3
- package/bin/agent/session-store.mjs +41 -5
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/studio-tools.mjs +48 -11
- package/bin/cozyclay.mjs +26 -8
- package/bin/live/cli.mjs +22 -3
- package/bin/mcp-runtime.mjs +35 -20
- package/bin/telemetry-state.mjs +5 -2
- package/bin/update-check.mjs +15 -0
- package/dist/app/index.html +6 -6
- package/dist/assets/analytics-MVZOEW8O.js +1 -0
- package/dist/assets/app-C1sdVbg4.css +1 -0
- package/dist/assets/app-CA55tcI3.js +4876 -0
- package/dist/assets/{demo-B-eg1CT8.js → demo-ByeYp-dY.js} +1 -1
- package/dist/assets/{first-shot-handoff-CGgnvNu8.js → first-shot-handoff-DgT28ZiV.js} +1 -1
- package/dist/assets/{landing-IbRp3FnQ.js → landing-BaXkpFip.js} +1 -1
- package/dist/assets/shot-prompt-CbqWcWYi.css +1 -0
- package/dist/assets/shot-prompt-Yrt99wZx.js +17 -0
- package/dist/assets/{ticket-BgIzOBOf.js → ticket-BcmsnHn5.js} +1 -1
- package/dist/assets/workflow-DRdwoDBT.css +1 -0
- package/dist/assets/workflow-dNPGY9xz.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/fonts/IBM-Plex-Sans-OFL.txt +92 -0
- package/dist/fonts/JetBrains-Mono-OFL.txt +93 -0
- package/dist/fonts/README.md +13 -9
- package/dist/fonts/ibm-plex-sans-400-latin.woff2 +0 -0
- package/dist/fonts/ibm-plex-sans-500-latin.woff2 +0 -0
- package/dist/fonts/ibm-plex-sans-600-latin.woff2 +0 -0
- package/dist/fonts/jetbrains-mono-400-latin.woff2 +0 -0
- package/dist/fonts/jetbrains-mono-500-latin.woff2 +0 -0
- package/dist/index.html +7 -7
- package/dist/privacy/index.html +13 -8
- package/dist/sitemap.xml +6 -6
- package/dist/workflow/index.html +5 -5
- package/mcp/LIVE-PROTOCOL.md +1 -1
- package/mcp/live-hub.mjs +35 -93
- package/mcp/mesh-file.mjs +48 -0
- package/mcp/server.mjs +6 -77
- package/mcp/tool-handlers.mjs +224 -377
- package/package.json +2 -1
- package/src/App.jsx +1273 -9075
- package/src/analytics.js +393 -9
- package/src/app-context.js +211 -0
- package/src/app-stage.jsx +25 -24
- package/src/ardy/auto-fix-panel.css +108 -0
- package/src/ardy/collision-blockers.js +9 -3
- package/src/ardy/fix-collisions.js +10 -66
- package/src/ardy/ground.js +4 -4
- package/src/ardy/ik-drag.js +101 -0
- package/src/ardy/ik-key-json.js +43 -0
- package/src/ardy/ik.js +122 -76
- package/src/ardy/physics-panel.css +2 -0
- package/src/ardy/physics-panel.jsx +17 -5
- package/src/ardy/platform-fit-panel.css +64 -0
- package/src/ardy/platform-fit-panel.jsx +40 -0
- package/src/ardy/platform-fit.js +326 -0
- package/src/ardy/playback.js +166 -20
- package/src/ardy/range-pin.js +299 -0
- package/src/ardy/timeline-coordinates.js +26 -0
- package/src/ardy/timeline.css +1199 -0
- package/src/ardy/timeline.jsx +149 -41
- package/src/ardy/waypoints.js +2 -2
- package/src/asset-pane.css +462 -0
- package/src/asset-pane.jsx +461 -232
- package/src/command-bus.js +349 -0
- package/src/commands/ai.js +31 -0
- package/src/commands/cast.js +140 -0
- package/src/commands/elements/character.js +13 -0
- package/src/commands/elements/motion.js +14 -0
- package/src/commands/elements/object.js +22 -0
- package/src/commands/elements/scene.js +16 -0
- package/src/commands/elements/shot.js +9 -0
- package/src/commands/elements/stage.js +11 -0
- package/src/commands/elements.js +155 -0
- package/src/commands/export.js +20 -0
- package/src/commands/index.js +40 -0
- package/src/commands/motion.js +231 -0
- package/src/commands/objects.js +174 -0
- package/src/commands/project.js +53 -0
- package/src/commands/scene.js +81 -0
- package/src/commands/shared.js +19 -0
- package/src/commands/shot.js +103 -0
- package/src/commands/stage.js +17 -0
- package/src/commands/view.js +42 -0
- package/src/document-store.js +144 -0
- package/src/domains/cast.js +1007 -0
- package/src/domains/motion.js +3219 -0
- package/src/domains/objects.js +932 -0
- package/src/domains/scenes.js +817 -0
- package/src/domains/shots.js +404 -0
- package/src/domains/stage.js +93 -0
- package/src/dualview.jsx +113 -57
- package/src/facing-marks.js +3 -0
- package/src/first-success-guide.jsx +14 -14
- package/src/grid-view.js +18 -5
- package/src/hierarchy-model.js +24 -16
- package/src/hierarchy-panel.css +386 -0
- package/src/hierarchy-panel.jsx +150 -21
- package/src/{fal-motion-client.js → i2v-motion-client.js} +13 -13
- package/src/i2v-motion-studio.jsx +180 -0
- package/src/ik-camera.js +3 -0
- package/src/main.jsx +6 -0
- package/src/motion/generation.js +105 -0
- package/src/motion-readiness-ui.jsx +4 -4
- package/src/motion-readiness.js +11 -0
- package/src/motion-trail.js +366 -9
- package/src/object-gizmo.jsx +7 -1
- package/src/otio.js +2 -1
- package/src/panels/CameraPanel.jsx +60 -0
- package/src/panels/CharacterTransformPanel.jsx +48 -0
- package/src/panels/EnvironmentPanel.jsx +40 -0
- package/src/panels/Foldout.jsx +32 -0
- package/src/panels/LightPanel.jsx +24 -0
- package/src/panels/ObjectTransformPanel.jsx +554 -0
- package/src/panels/PosePanel.jsx +97 -0
- package/src/panels/ProjectPanel.jsx +33 -0
- package/src/panels/PromptBlocksPanel.jsx +442 -0
- package/src/panels/PropsPanel.jsx +70 -0
- package/src/panels/ReferenceImageField.jsx +91 -0
- package/src/panels/RigControlPanel.jsx +78 -0
- package/src/panels/RigPanel.jsx +37 -0
- package/src/panels/SubjectBox.jsx +47 -0
- package/src/panels/SubjectsPanel.jsx +39 -0
- package/src/panels/VideoCapturePanel.jsx +180 -0
- package/src/panels/details.css +1171 -0
- package/src/panels/motion.css +197 -0
- package/src/panels/pose.css +39 -0
- package/src/planview.jsx +60 -31
- package/src/posestudio.jsx +59 -4
- package/src/project-browser.css +1073 -0
- package/src/project-browser.jsx +284 -123
- package/src/range-pin-object-transform.js +19 -0
- package/src/range-pin-panel.css +518 -0
- package/src/range-pin-panel.jsx +344 -0
- package/src/result-modal.jsx +5 -5
- package/src/room.jsx +79 -51
- package/src/scene-objects.js +37 -1
- package/src/scenes.js +5 -0
- package/src/semantic-edit.js +2 -0
- package/src/settings-menu.jsx +39 -105
- package/src/shell/BottomDock.jsx +450 -0
- package/src/shell/DetailsSlot.jsx +513 -0
- package/src/shell/LibrarySlot.jsx +86 -0
- package/src/shell/MenuBar.jsx +601 -0
- package/src/shell/OutlinerSlot.jsx +50 -0
- package/src/shell/PreferencesDialog.jsx +538 -0
- package/src/shell/PreferencesSlot.jsx +40 -0
- package/src/shell/StatusBar.jsx +125 -0
- package/src/shell/StudioShell.jsx +92 -0
- package/src/shell/TopBar.jsx +129 -0
- package/src/shell/ViewportToolbar.jsx +656 -0
- package/src/shell/agent-glass.css +296 -0
- package/src/shell/dock.css +93 -0
- package/src/shell/glass-regions.css +985 -0
- package/src/shell/glass.css +687 -0
- package/src/shell/log-store.js +52 -0
- package/src/shell/mode.css +9 -0
- package/src/shell/preferences.css +651 -0
- package/src/shell/shell.css +282 -0
- package/src/shell/studio-shell-context.js +19 -0
- package/src/shell/topbar.css +435 -0
- package/src/shell/viewport.css +451 -0
- package/src/store/authored-intent.js +22 -0
- package/src/store/runtime-adapters.js +92 -0
- package/src/store/scene-stage.js +21 -0
- package/src/store/use-document-store.js +14 -0
- package/src/studio-actions.js +210 -0
- package/src/studio-agent-commands.js +41 -271
- package/src/studio-agent-context.js +39 -10
- package/src/studio-agent-motion.js +122 -373
- package/src/studio-agent-protocol.js +67 -31
- package/src/studio-app-binding.js +379 -0
- package/src/studio-contact-sheet.js +73 -0
- package/src/studio-elements.js +58 -51
- package/src/styles/themes.css +200 -0
- package/src/styles/tokens.css +69 -0
- package/src/styles.css +460 -1742
- package/src/theme.js +44 -0
- package/src/timeline-extent.js +16 -0
- package/src/trail-key-conflicts.js +39 -0
- package/src/trail-pick.js +59 -0
- package/src/ui.jsx +8 -8
- package/src/use-case-question.jsx +66 -0
- package/src/workflow/AgentPanel.jsx +82 -69
- package/src/workflow/agent-client.js +12 -2
- package/src/workflow/agent-panel.css +34 -32
- package/src/workflow/cozy-scene-node.css +2 -0
- package/src/workflow/workflow.css +2 -0
- package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
- package/tools/ardy/bridge.mjs +164 -146
- package/tools/ardy/visual-qa.mjs +5 -5
- package/tools/bench/EXP3.md +61 -0
- package/tools/bench/cclay_bench_extract_incam.py +125 -0
- package/tools/bench/cclay_bench_extract_obs.py +204 -0
- package/tools/bench/cclay_bench_runner.py +42 -0
- package/tools/bench/cube-contact.mjs +162 -0
- package/tools/bench/exp3.mjs +152 -0
- package/tools/bench/extract-bench-lib.mjs +216 -0
- package/tools/bench/extract-bench.mjs +302 -0
- package/tools/bench/fal-generate.mjs +88 -0
- package/tools/bench/fit/README.md +189 -0
- package/tools/bench/fit/camera.mjs +59 -0
- package/tools/bench/fit/contact.mjs +419 -0
- package/tools/bench/fit/footlock.mjs +234 -0
- package/tools/bench/fit/motion.mjs +85 -0
- package/tools/bench/fit/pin.mjs +27 -0
- package/tools/bench/fit/remote.mjs +105 -0
- package/tools/bench/fit-bench.mjs +126 -0
- package/tools/bench/fit-sanity.mjs +45 -0
- package/tools/bench/metrics.mjs +313 -0
- package/tools/bench/obs/depth.mjs +258 -0
- package/tools/bench/obs/extrinsics.mjs +175 -0
- package/tools/bench/obs/fit_mannequin_betas.py +340 -0
- package/tools/bench/obs/ground.mjs +294 -0
- package/tools/bench/obs/heading.mjs +151 -0
- package/tools/bench/obs/ladder.mjs +555 -0
- package/tools/bench/obs/mannequin-betas.json +124 -0
- package/tools/bench/obs/remote.mjs +83 -0
- package/tools/bench/obs/rest_joints.py +52 -0
- package/tools/bench/obs/ybot-targets.mjs +49 -0
- package/tools/bench/obs-bench.mjs +520 -0
- package/tools/bench/score.mjs +343 -0
- package/tools/bench/summarize.mjs +86 -0
- package/tools/dev/pages/privacy.html +13 -8
- package/tools/gt-render/browser.mjs +159 -0
- package/tools/gt-render/camera-math.mjs +276 -0
- package/tools/gt-render/page.mjs +267 -0
- package/tools/gt-render/render.mjs +514 -0
- package/tools/gt-render/scene-box.mjs +31 -0
- package/tools/gt-render/take-transform.mjs +87 -0
- package/tools/morphgs/assets/gen_truth.py +39 -0
- package/tools/morphgs/assets/mesh_ori_rig.txt +27 -0
- package/tools/morphgs/assets/playback-check.mjs +30 -0
- package/tools/morphgs/demo-gate.sh +26 -0
- package/tools/morphgs/fbx2morphgs.mjs +68 -0
- package/tools/morphgs/morphgs-to-cskel27.mjs +105 -0
- package/tools/morphgs/patches/preprocess_src-none-mode.patch +76 -0
- package/tools/morphgs/setup-on-cluster.sh +126 -0
- package/tools/qa/css-rule-usage.mjs +283 -0
- package/tools/qa/studio-control-count.mjs +70 -32
- package/tools/run-tests.mjs +134 -12
- package/tools/track/DESIGN.md +513 -0
- package/tools/track/backfill-provenance.mjs +116 -0
- package/tools/track/budget.mjs +8 -0
- package/tools/track/check-rig.mjs +209 -0
- package/tools/track/diagnostics.schema.json +60 -0
- package/tools/track/export-rig.mjs +256 -0
- package/tools/track/fallback.mjs +7 -0
- package/tools/track/fk-parity-fixture.mjs +161 -0
- package/tools/track/gate.mjs +349 -0
- package/tools/track/masks.mjs +318 -0
- package/tools/track/metrics.mjs +201 -0
- package/tools/track/publish-obs.mjs +108 -0
- package/tools/track/py/check_env.py +61 -0
- package/tools/track/py/eval_lr.py +229 -0
- package/tools/track/py/lr_viterbi.py +294 -0
- package/tools/track/py/masks.py +405 -0
- package/tools/track/py/objective.py +382 -0
- package/tools/track/py/rig.py +508 -0
- package/tools/track/py/scene.py +326 -0
- package/tools/track/py/test_joint_indices.py +123 -0
- package/tools/track/py/test_lr_viterbi.py +195 -0
- package/tools/track/py/test_masks.py +229 -0
- package/tools/track/py/test_rig.py +288 -0
- package/tools/track/py/test_scene.py +354 -0
- package/tools/track/py/test_track.py +328 -0
- package/tools/track/py/track.py +411 -0
- package/tools/track/remote.mjs +140 -0
- package/tools/track/rig-dump.mjs +227 -0
- package/tools/track/run-box-tests.mjs +14 -0
- package/tools/track/run-box.mjs +168 -0
- package/tools/track/setup-box.sh +59 -0
- package/tools/track/study-2d.mjs +728 -0
- package/dist/assets/analytics-B1hnH66c.js +0 -1
- package/dist/assets/app-BWusbqgO.js +0 -4861
- package/dist/assets/app-qKDo4PBX.css +0 -1
- package/dist/assets/shot-prompt-CHUtw6af.js +0 -17
- package/dist/assets/shot-prompt-C_g2BHVc.css +0 -1
- package/dist/assets/workflow-CVwzMhz2.js +0 -179
- package/dist/assets/workflow-DsudxKHF.css +0 -1
- package/src/fal-motion-studio.jsx +0 -180
- package/src/scene-history.js +0 -129
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { ko } from "./locale.js";
|
|
3
|
+
import "./range-pin-panel.css";
|
|
4
|
+
|
|
5
|
+
export const RANGE_PIN_TRACKS = Object.freeze(["leftHand", "rightHand", "leftFoot", "rightFoot"]);
|
|
6
|
+
|
|
7
|
+
const PARTS = Object.freeze([
|
|
8
|
+
{ id: "leftHand", label: "Left Hand", ko: "왼손", color: "var(--range-pin-hand)" },
|
|
9
|
+
{ id: "rightHand", label: "Right Hand", ko: "오른손", color: "var(--range-pin-hand)" },
|
|
10
|
+
{ id: "leftFoot", label: "Left Foot", ko: "왼발", color: "var(--range-pin-foot)" },
|
|
11
|
+
{ id: "rightFoot", label: "Right Foot", ko: "오른발", color: "var(--range-pin-foot)" },
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
const PIN_ID_PREFIX = "pin";
|
|
15
|
+
const DEFAULT_BLEND_SECONDS = 0.25;
|
|
16
|
+
const BLEND_MIN_SECONDS = 0.1;
|
|
17
|
+
const BLEND_MAX_SECONDS = 2;
|
|
18
|
+
const BLEND_STEP_SECONDS = 0.05;
|
|
19
|
+
|
|
20
|
+
function pinId() {
|
|
21
|
+
if (globalThis.crypto?.randomUUID) return `${PIN_ID_PREFIX}-${globalThis.crypto.randomUUID()}`;
|
|
22
|
+
return `${PIN_ID_PREFIX}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function clampFrame(value, frameCount) {
|
|
26
|
+
const max = Math.max(0, Number(frameCount || 1) - 1);
|
|
27
|
+
return Math.max(0, Math.min(max, Math.round(Number(value) || 0)));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function partFor(track) {
|
|
31
|
+
return PARTS.find((part) => part.id === track) ?? PARTS[1];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function objectName(object) {
|
|
35
|
+
return object?.name || object?.id || ko("Missing object", "없는 오브젝트");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function blendFramesForSeconds(seconds, fps) {
|
|
39
|
+
return Math.max(1, Math.round(Number(seconds || DEFAULT_BLEND_SECONDS) * Math.max(1, fps || 24)));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function secondsForBlend(blend, fps) {
|
|
43
|
+
return Math.max(BLEND_MIN_SECONDS, Math.min(BLEND_MAX_SECONDS, Number(blend || 1) / Math.max(1, fps || 24)));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function freshDraft(frame, fps) {
|
|
47
|
+
return {
|
|
48
|
+
id: pinId(),
|
|
49
|
+
track: "rightHand",
|
|
50
|
+
startFrame: clampFrame(frame, Number.MAX_SAFE_INTEGER),
|
|
51
|
+
endFrame: clampFrame(frame, Number.MAX_SAFE_INTEGER),
|
|
52
|
+
blend: blendFramesForSeconds(DEFAULT_BLEND_SECONDS, fps),
|
|
53
|
+
reach: "body",
|
|
54
|
+
targetKind: "hold",
|
|
55
|
+
objectId: "",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function draftFromPin(pin, fps) {
|
|
60
|
+
return {
|
|
61
|
+
id: pin.id,
|
|
62
|
+
track: pin.track,
|
|
63
|
+
startFrame: pin.startFrame,
|
|
64
|
+
endFrame: pin.endFrame,
|
|
65
|
+
blend: pin.blend,
|
|
66
|
+
reach: pin.reach ?? "limb",
|
|
67
|
+
targetKind: pin.target.space === "object" ? "object" : "hold",
|
|
68
|
+
objectId: pin.target.space === "object" ? pin.target.objectId : "",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function residualWarning(residuals) {
|
|
73
|
+
const feet = (residuals ?? []).filter((entry) => Number(entry?.feetErrorM) > 0.01).map((entry) => entry.frame);
|
|
74
|
+
const frames = (residuals ?? []).filter((entry) => Number(entry?.errorM) > 0.01 || Number(entry?.feetErrorM) > 0.01).map((entry) => entry.frame);
|
|
75
|
+
if (!frames.length) return null;
|
|
76
|
+
return {
|
|
77
|
+
frames,
|
|
78
|
+
label: ko(
|
|
79
|
+
`Can't reach on ${frames.length} frame${frames.length === 1 ? "" : "s"}`,
|
|
80
|
+
`${frames.length}개 프레임에 닿지 않음`,
|
|
81
|
+
),
|
|
82
|
+
title: ko(`Residual over 1 cm on frames ${frames.join(", ")}`, `1cm를 넘는 잔여 오차: ${frames.join(", ")}프레임`)
|
|
83
|
+
+ (feet.length ? ko(`; feet cannot stay planted on frames ${feet.join(", ")}`, `; 발 위치를 유지할 수 없는 프레임: ${feet.join(", ")}`) : ""),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function validationFor(draft, { motion, frameCount, objects }) {
|
|
88
|
+
if (!motion) return ko("Load a motion before creating a pin.", "핀을 만들기 전에 모션을 불러오세요.");
|
|
89
|
+
if (!RANGE_PIN_TRACKS.includes(draft.track)) return ko("Choose a hand or foot.", "손이나 발을 선택하세요.");
|
|
90
|
+
if (!Number.isInteger(Number(draft.startFrame)) || !Number.isInteger(Number(draft.endFrame))) return ko("In and Out must be whole frames.", "In과 Out은 정수 프레임이어야 해요.");
|
|
91
|
+
if (draft.startFrame < 0 || draft.endFrame >= frameCount) return ko(`Use frames 0–${Math.max(0, frameCount - 1)}.`, `${Math.max(0, frameCount - 1)}프레임 안에서 선택하세요.`);
|
|
92
|
+
if (draft.endFrame < draft.startFrame) return ko("Out must be on or after In.", "Out은 In 이후여야 해요.");
|
|
93
|
+
if (draft.targetKind === "object" && !draft.objectId) return ko("Choose a scene object.", "씬 오브젝트를 선택하세요.");
|
|
94
|
+
if (draft.targetKind === "object" && !objects.some((object) => object.id === draft.objectId)) return ko("That scene object is no longer available.", "씬 오브젝트를 찾을 수 없어요.");
|
|
95
|
+
if (!Number.isInteger(Number(draft.blend)) || draft.blend < 1) return ko("Blend must be at least one frame.", "Blend는 한 프레임 이상이어야 해요.");
|
|
96
|
+
return "";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function targetLabel(pin, objects) {
|
|
100
|
+
if (pin.target.space === "object") {
|
|
101
|
+
return ko(`Object · ${objectName(objects.find((object) => object.id === pin.target.objectId))}`, `오브젝트 · ${objectName(objects.find((object) => object.id === pin.target.objectId))}`);
|
|
102
|
+
}
|
|
103
|
+
return ko("Hold at In", "In 위치 고정");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function RangePinPanel({
|
|
107
|
+
active = false,
|
|
108
|
+
motion = null,
|
|
109
|
+
frame = 0,
|
|
110
|
+
frameCount = 1,
|
|
111
|
+
fps = 24,
|
|
112
|
+
pins = [],
|
|
113
|
+
residuals = new Map(),
|
|
114
|
+
objects = [],
|
|
115
|
+
selectedPinId = null,
|
|
116
|
+
partPick = null,
|
|
117
|
+
conflictFrames = [],
|
|
118
|
+
overlapError = "",
|
|
119
|
+
onSelectPin,
|
|
120
|
+
onApply,
|
|
121
|
+
onCancel,
|
|
122
|
+
onDelete,
|
|
123
|
+
onPreviewTarget,
|
|
124
|
+
}) {
|
|
125
|
+
const [draft, setDraft] = useState(() => freshDraft(frame, fps));
|
|
126
|
+
const [editingId, setEditingId] = useState(null);
|
|
127
|
+
const [replaceConfirmed, setReplaceConfirmed] = useState(false);
|
|
128
|
+
|
|
129
|
+
const selectedPin = useMemo(() => pins.find((pin) => pin.id === selectedPinId) ?? null, [pins, selectedPinId]);
|
|
130
|
+
const validation = validationFor(draft, { motion, frameCount, objects });
|
|
131
|
+
const selectedObject = objects.find((object) => object.id === draft.objectId) ?? null;
|
|
132
|
+
const needsReplace = conflictFrames.length > 0;
|
|
133
|
+
const canApply = !validation && !overlapError && (!needsReplace || replaceConfirmed);
|
|
134
|
+
|
|
135
|
+
// React may run state updaters during render (and replay them). Keep every
|
|
136
|
+
// updater pure; notify App only after the draft has committed.
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
if (active) onPreviewTarget?.(draft);
|
|
139
|
+
// The parent callback is a render-time command bridge. It is intentionally
|
|
140
|
+
// omitted from the dependencies so a new closure cannot retrigger preview
|
|
141
|
+
// state forever while the draft itself is unchanged.
|
|
142
|
+
}, [active, draft, frame]);
|
|
143
|
+
|
|
144
|
+
const updateDraft = useCallback((patch) => {
|
|
145
|
+
setDraft((current) => ({ ...current, ...patch }));
|
|
146
|
+
setReplaceConfirmed(false);
|
|
147
|
+
}, []);
|
|
148
|
+
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (!active) return;
|
|
151
|
+
if (selectedPin) {
|
|
152
|
+
setEditingId(selectedPin.id);
|
|
153
|
+
setReplaceConfirmed(false);
|
|
154
|
+
const next = draftFromPin(selectedPin, fps);
|
|
155
|
+
setDraft(next);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (editingId && !pins.some((pin) => pin.id === editingId)) setEditingId(null);
|
|
159
|
+
if (!editingId) {
|
|
160
|
+
const next = freshDraft(frame, fps);
|
|
161
|
+
setReplaceConfirmed(false);
|
|
162
|
+
setDraft(next);
|
|
163
|
+
}
|
|
164
|
+
}, [active, editingId, fps, pins, selectedPin]);
|
|
165
|
+
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
if (!active || !partPick || !RANGE_PIN_TRACKS.includes(partPick)) return;
|
|
168
|
+
updateDraft({ track: partPick });
|
|
169
|
+
}, [active, partPick, updateDraft]);
|
|
170
|
+
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
if (!active) return undefined;
|
|
173
|
+
const onKeyDown = (event) => {
|
|
174
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
175
|
+
if (event.code === "KeyI") {
|
|
176
|
+
event.preventDefault();
|
|
177
|
+
updateDraft({ startFrame: clampFrame(frame, frameCount) });
|
|
178
|
+
} else if (event.code === "KeyO") {
|
|
179
|
+
event.preventDefault();
|
|
180
|
+
updateDraft({ endFrame: clampFrame(frame, frameCount) });
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
window.addEventListener("keydown", onKeyDown);
|
|
184
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
185
|
+
}, [active, frame, frameCount, updateDraft]);
|
|
186
|
+
|
|
187
|
+
const startNew = () => {
|
|
188
|
+
const next = freshDraft(frame, fps);
|
|
189
|
+
setEditingId(null);
|
|
190
|
+
setReplaceConfirmed(false);
|
|
191
|
+
onSelectPin?.(null);
|
|
192
|
+
setDraft(next);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const editPin = (pin) => {
|
|
196
|
+
const next = draftFromPin(pin, fps);
|
|
197
|
+
setEditingId(pin.id);
|
|
198
|
+
setReplaceConfirmed(false);
|
|
199
|
+
onSelectPin?.(pin.id);
|
|
200
|
+
setDraft(next);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const submit = () => {
|
|
204
|
+
if (!canApply) return;
|
|
205
|
+
onApply?.({ ...draft, id: editingId ?? draft.id, replaceExisting: replaceConfirmed });
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const setBlendSeconds = (seconds) => updateDraft({ blend: blendFramesForSeconds(seconds, fps) });
|
|
209
|
+
const blendSeconds = secondsForBlend(draft.blend, fps);
|
|
210
|
+
const sortedPins = [...pins].sort((a, b) => a.startFrame - b.startFrame || a.endFrame - b.endFrame || a.id.localeCompare(b.id));
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<section className="range-pin-panel" data-testid="range-pin-panel" aria-label={ko("Range pins", "범위 고정") }>
|
|
214
|
+
<div className="range-pin-panel-head">
|
|
215
|
+
<div>
|
|
216
|
+
<strong>{ko("Pin", "고정")}</strong>
|
|
217
|
+
<span>{ko("Hold a hand or foot across a frame range", "프레임 범위 동안 손이나 발을 고정합니다")}</span>
|
|
218
|
+
</div>
|
|
219
|
+
{editingId && <button type="button" className="btn ghost small" onClick={startNew}>{ko("New", "새로 만들기")}</button>}
|
|
220
|
+
</div>
|
|
221
|
+
{!motion && <p className="inspector-hint range-pin-disabled-hint">{ko("Load a motion to enable range pins.", "범위 고정을 사용하려면 모션을 불러오세요.")}</p>}
|
|
222
|
+
<div className="range-pin-form" aria-disabled={!motion}>
|
|
223
|
+
<div className="range-pin-form-label">{ko("Part", "파츠")}</div>
|
|
224
|
+
<div className="range-pin-part-grid" role="radiogroup" aria-label={ko("Pin part", "고정할 파츠")}>
|
|
225
|
+
{PARTS.map((part) => (
|
|
226
|
+
<button
|
|
227
|
+
type="button"
|
|
228
|
+
key={part.id}
|
|
229
|
+
className={"range-pin-part" + (draft.track === part.id ? " selected" : "")}
|
|
230
|
+
style={{ "--range-pin-color": part.color }}
|
|
231
|
+
aria-pressed={draft.track === part.id}
|
|
232
|
+
disabled={!motion}
|
|
233
|
+
onClick={() => updateDraft({ track: part.id })}
|
|
234
|
+
>
|
|
235
|
+
<span className="range-pin-part-dot" aria-hidden="true" />
|
|
236
|
+
{ko(part.label, part.ko)}
|
|
237
|
+
</button>
|
|
238
|
+
))}
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
<div className="range-pin-form-label">{ko("Range", "범위")}</div>
|
|
242
|
+
<div className="range-pin-frame-row">
|
|
243
|
+
<label className="range-pin-frame-field">
|
|
244
|
+
<span>{ko("In", "시작")}</span>
|
|
245
|
+
<input data-testid="range-pin-in" type="number" min="0" max={Math.max(0, frameCount - 1)} step="1" value={draft.startFrame} disabled={!motion} onChange={(event) => updateDraft({ startFrame: clampFrame(event.target.value, frameCount) })} />
|
|
246
|
+
</label>
|
|
247
|
+
<span className="range-pin-frame-dash" aria-hidden="true">–</span>
|
|
248
|
+
<label className="range-pin-frame-field">
|
|
249
|
+
<span>{ko("Out", "끝")}</span>
|
|
250
|
+
<input data-testid="range-pin-out" type="number" min="0" max={Math.max(0, frameCount - 1)} step="1" value={draft.endFrame} disabled={!motion} onChange={(event) => updateDraft({ endFrame: clampFrame(event.target.value, frameCount) })} />
|
|
251
|
+
</label>
|
|
252
|
+
</div>
|
|
253
|
+
<div className="range-pin-frame-actions">
|
|
254
|
+
<button type="button" className="btn ghost small" disabled={!motion} onClick={() => updateDraft({ startFrame: clampFrame(frame, frameCount) })}>{ko("Set In to playhead", "In을 현재 프레임으로")}</button>
|
|
255
|
+
<button type="button" className="btn ghost small" disabled={!motion} onClick={() => updateDraft({ endFrame: clampFrame(frame, frameCount) })}>{ko("Set Out to playhead", "Out을 현재 프레임으로")}</button>
|
|
256
|
+
</div>
|
|
257
|
+
<p className="range-pin-shortcut-hint">{ko("Keyboard: I sets In · O sets Out", "키보드: I는 In · O는 Out")}</p>
|
|
258
|
+
|
|
259
|
+
<div className="range-pin-form-label">{ko("Target", "대상")}</div>
|
|
260
|
+
<div className="range-pin-targets" role="radiogroup" aria-label={ko("Pin target", "고정 대상")}>
|
|
261
|
+
<label className="range-pin-target-option">
|
|
262
|
+
<input type="radio" name="range-pin-target" value="hold" checked={draft.targetKind === "hold"} disabled={!motion} onChange={() => updateDraft({ targetKind: "hold", objectId: "" })} />
|
|
263
|
+
<span>
|
|
264
|
+
<b>{ko("Hold where it is at In", "In 위치에 고정")}</b>
|
|
265
|
+
<small>{ko("Capture the part's world position at the In frame.", "In 프레임의 파츠 월드 위치를 사용합니다.")}</small>
|
|
266
|
+
</span>
|
|
267
|
+
</label>
|
|
268
|
+
<label className="range-pin-target-option">
|
|
269
|
+
<input type="radio" name="range-pin-target" value="object" checked={draft.targetKind === "object"} disabled={!motion || objects.length === 0} onChange={() => updateDraft({ targetKind: "object" })} />
|
|
270
|
+
<span>
|
|
271
|
+
<b>{ko("Stick to object", "오브젝트에 붙이기")}</b>
|
|
272
|
+
<small>{ko("Follow a scene object's transform and travel path.", "씬 오브젝트의 변환과 이동 경로를 따라갑니다.")}</small>
|
|
273
|
+
</span>
|
|
274
|
+
</label>
|
|
275
|
+
</div>
|
|
276
|
+
{objects.length === 0 && <p className="range-pin-empty-hint">{ko("Add a scene object to use an object target.", "오브젝트 대상을 사용하려면 씬에 오브젝트를 추가하세요.")}</p>}
|
|
277
|
+
{draft.targetKind === "object" && objects.length > 0 && (
|
|
278
|
+
<label className="range-pin-object-select">
|
|
279
|
+
<span>{ko("Scene object", "씬 오브젝트")}</span>
|
|
280
|
+
<select data-testid="range-pin-object" value={draft.objectId} disabled={!motion} onChange={(event) => updateDraft({ objectId: event.target.value })}>
|
|
281
|
+
<option value="">{ko("Choose an object", "오브젝트 선택")}</option>
|
|
282
|
+
{objects.map((object) => <option key={object.id} value={object.id}>{objectName(object)}</option>)}
|
|
283
|
+
</select>
|
|
284
|
+
</label>
|
|
285
|
+
)}
|
|
286
|
+
{selectedObject && <p className="range-pin-object-readout">{ko("Following", "따라가는 대상")}: {objectName(selectedObject)}</p>}
|
|
287
|
+
|
|
288
|
+
<label className="range-pin-target-option">
|
|
289
|
+
<input data-testid="range-pin-reach" type="checkbox" checked={draft.reach === "body"} disabled={!motion} onChange={(event) => updateDraft({ reach: event.target.checked ? "body" : "limb" })} />
|
|
290
|
+
<span><b>{ko("Body follows when out of reach", "손이 안 닿으면 몸도 따라가기")}</b></span>
|
|
291
|
+
</label>
|
|
292
|
+
|
|
293
|
+
<div className="range-pin-form-label">{ko("Blend", "블렌드")}</div>
|
|
294
|
+
<div className="range-pin-blend-row">
|
|
295
|
+
<input data-testid="range-pin-blend" type="range" min={BLEND_MIN_SECONDS} max={BLEND_MAX_SECONDS} step={BLEND_STEP_SECONDS} value={blendSeconds} disabled={!motion} onChange={(event) => setBlendSeconds(event.target.value)} />
|
|
296
|
+
<span className="trail-falloff-value">{blendSeconds.toFixed(2)}s ({draft.blend}f)</span>
|
|
297
|
+
</div>
|
|
298
|
+
|
|
299
|
+
{overlapError && <p className="range-pin-validation" role="alert">{overlapError}</p>}
|
|
300
|
+
{validation && <p className="range-pin-validation" role="alert">{validation}</p>}
|
|
301
|
+
{needsReplace && (
|
|
302
|
+
<div className="range-pin-replace-warning" role="alert">
|
|
303
|
+
<span>{ko(`Existing IK keys on ${conflictFrames.length} frame${conflictFrames.length === 1 ? "" : "s"}: ${conflictFrames.join(", ")}`, `기존 IK 키가 ${conflictFrames.length}개 프레임에 있어요: ${conflictFrames.join(", ")}`)}</span>
|
|
304
|
+
<button type="button" className="btn ghost small" onClick={() => setReplaceConfirmed((value) => !value)} aria-pressed={replaceConfirmed}>
|
|
305
|
+
{replaceConfirmed ? ko("Replace confirmed", "교체 확인됨") : ko("Replace existing keys", "기존 키 교체")}
|
|
306
|
+
</button>
|
|
307
|
+
</div>
|
|
308
|
+
)}
|
|
309
|
+
<div className="range-pin-actions">
|
|
310
|
+
<button type="button" className="btn ghost" disabled={!motion} onClick={onCancel}>{ko("Cancel", "취소")}</button>
|
|
311
|
+
<button type="button" className="btn primary" data-testid="range-pin-apply" disabled={!canApply} onClick={submit}>{editingId ? ko("Update pin", "고정 업데이트") : ko("Apply pin", "고정 적용")}</button>
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
|
|
315
|
+
<div className="range-pin-list-head">
|
|
316
|
+
<span>{ko("Pins", "고정 목록")}</span>
|
|
317
|
+
<small>{sortedPins.length}</small>
|
|
318
|
+
</div>
|
|
319
|
+
{sortedPins.length === 0 ? (
|
|
320
|
+
<p className="range-pin-empty-hint">{ko("No pins yet. Choose a part and apply a range.", "아직 고정이 없어요. 파츠와 범위를 정한 뒤 적용하세요.")}</p>
|
|
321
|
+
) : (
|
|
322
|
+
<div className="range-pin-list">
|
|
323
|
+
{sortedPins.map((pin) => {
|
|
324
|
+
const part = partFor(pin.track);
|
|
325
|
+
const warning = residualWarning(residuals instanceof Map ? residuals.get(pin.id) : residuals?.[pin.id]);
|
|
326
|
+
const missingObject = pin.target.space === "object" && !objects.some((object) => object.id === pin.target.objectId);
|
|
327
|
+
return (
|
|
328
|
+
<div key={pin.id} className={"range-pin-item" + (selectedPinId === pin.id ? " selected" : "")} style={{ "--range-pin-color": part.color }}>
|
|
329
|
+
<button type="button" className="range-pin-item-main" onClick={() => editPin(pin)} aria-pressed={selectedPinId === pin.id}>
|
|
330
|
+
<span className="range-pin-item-top"><span className="range-pin-part-dot" aria-hidden="true" />{ko(part.label, part.ko)} <b>{pin.startFrame}–{pin.endFrame}</b></span>
|
|
331
|
+
<span className="range-pin-item-target">{missingObject ? ko("Missing object target", "오브젝트 대상 없음") : targetLabel(pin, objects)}</span>
|
|
332
|
+
{(warning || missingObject) && <span className="range-pin-warning" title={missingObject ? ko("The referenced object was deleted.", "참조한 오브젝트가 삭제됐어요.") : warning.title}>{missingObject ? ko("Invalid target", "대상 없음") : warning.label}</span>}
|
|
333
|
+
</button>
|
|
334
|
+
<button type="button" className="range-pin-delete" data-testid={`range-pin-delete-${pin.id}`} aria-label={ko(`Delete ${part.label} pin`, `${part.ko} 고정 삭제`)} title={ko("Delete pin", "고정 삭제")} onClick={() => onDelete?.(pin.id)}><span aria-hidden="true">×</span></button>
|
|
335
|
+
</div>
|
|
336
|
+
);
|
|
337
|
+
})}
|
|
338
|
+
</div>
|
|
339
|
+
)}
|
|
340
|
+
</section>
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export { PARTS as RANGE_PIN_PARTS, partFor as rangePinPartFor, freshDraft as createRangePinDraft };
|
package/src/result-modal.jsx
CHANGED
|
@@ -3,7 +3,7 @@ import { ko, isKo } from "./locale.js";
|
|
|
3
3
|
export default function ResultModal({ result, copied, recordedVideoName, onClose, onCopy, onDownload, downloadDisabled = false, exportFeedback = null }) {
|
|
4
4
|
const isVideo = result.mode === "video";
|
|
5
5
|
const motionVideo = result.motion?.videoUrl ?? null;
|
|
6
|
-
const
|
|
6
|
+
const isI2vMotion = Boolean(motionVideo);
|
|
7
7
|
const modelLabel = result.modelLabel ?? (isVideo ? ko("your AI video tool", "AI 영상 도구") : ko("your selected image model", "선택한 이미지 모델"));
|
|
8
8
|
const hasFrame = Boolean(result.frame);
|
|
9
9
|
|
|
@@ -42,12 +42,12 @@ export default function ResultModal({ result, copied, recordedVideoName, onClose
|
|
|
42
42
|
<div className="modal-overlay" onClick={onClose}>
|
|
43
43
|
<div className="modal result-modal" role="dialog" aria-modal="true" aria-labelledby="result-title" onClick={(event) => event.stopPropagation()}>
|
|
44
44
|
<div className="modal-head">
|
|
45
|
-
<h3 id="result-title">{
|
|
45
|
+
<h3 id="result-title">{isI2vMotion ? ko("Your motion is ready", "모션이 준비됐어요") : ko("Your shot is ready", "장면이 준비됐어요")}</h3>
|
|
46
46
|
<button type="button" className="x" onClick={onClose} aria-label={ko("Close the result", "결과 닫기")}>
|
|
47
47
|
✕
|
|
48
48
|
</button>
|
|
49
49
|
</div>
|
|
50
|
-
{
|
|
50
|
+
{isI2vMotion ? (
|
|
51
51
|
<video className="preview result-motion-video" src={motionVideo} controls playsInline preload="metadata" />
|
|
52
52
|
) : result.frameB ? (
|
|
53
53
|
<div className="move-frames">
|
|
@@ -69,7 +69,7 @@ export default function ResultModal({ result, copied, recordedVideoName, onClose
|
|
|
69
69
|
<small>{ko("Camera move made from timeline keyframes", "타임라인 키프레임으로 만든 카메라 움직임")}</small>
|
|
70
70
|
</div>
|
|
71
71
|
)}
|
|
72
|
-
{
|
|
72
|
+
{isI2vMotion && result.motion && <div className="result-motion-meta">
|
|
73
73
|
<span>{result.motion.resolution || "480P"}</span>
|
|
74
74
|
{result.motion.width && result.motion.height && <span>{result.motion.width}×{result.motion.height}</span>}
|
|
75
75
|
{result.motion.fps && <span>{result.motion.fps} fps</span>}
|
|
@@ -90,7 +90,7 @@ export default function ResultModal({ result, copied, recordedVideoName, onClose
|
|
|
90
90
|
</div>
|
|
91
91
|
|
|
92
92
|
{exportFeedback}
|
|
93
|
-
{
|
|
93
|
+
{isI2vMotion ? <section className="result-next" aria-labelledby="result-next-title">
|
|
94
94
|
<span className="result-next-kicker">{ko("Next · CozyClay motion source", "다음 · CozyClay 모션 소스")}</span>
|
|
95
95
|
<h4 id="result-next-title">{ko("Use this video for mocap extraction", "이 영상을 모캡 추출에 사용하세요")}</h4>
|
|
96
96
|
<div className="result-next-intro"><p>{ko("The fixed-camera H3 Max Turbo video is attached to the extraction panel as a motion source. Run GVHMR there, then review the resulting take in the timeline.", "고정 카메라 H3 Max Turbo 영상이 추출 패널의 모션 소스로 연결됐어요. 거기서 GVHMR을 실행한 뒤 타임라인의 테이크를 확인하세요.")}</p></div>
|
package/src/room.jsx
CHANGED
|
@@ -15,8 +15,9 @@ import * as THREE from "three";
|
|
|
15
15
|
* never meets an unbounded surface.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
//
|
|
19
|
-
//
|
|
18
|
+
// The deck is the grey workbench (the same neutral family as STAGE_BACKGROUND
|
|
19
|
+
// in grid-view.js), a step lighter than the sky so the horizon still reads,
|
|
20
|
+
// with the grid lines drawn ON it in a lighter grey.
|
|
20
21
|
//
|
|
21
22
|
// Lit, not unlit. An unlit deck renders one flat colour across its whole 500 m,
|
|
22
23
|
// which costs an exported blocking frame two depth cues at once: the falloff
|
|
@@ -24,60 +25,85 @@ import * as THREE from "three";
|
|
|
24
25
|
// subject is standing ON it rather than floating. The colour is lifted to
|
|
25
26
|
// compensate for the shading the lambert term now applies, so the deck keeps
|
|
26
27
|
// the same on-screen brightness it had while unlit.
|
|
27
|
-
const FLOOR = "#
|
|
28
|
+
const FLOOR = "#5b5d63";
|
|
29
|
+
// The light theme's deck: the cyclorama floor. Tone mapping caps a lit
|
|
30
|
+
// white deck near #d4, a dull grey under a white sky, so it also glows a
|
|
31
|
+
// little (FLOOR_LIGHT_GLOW): it renders about #dedede near the camera and
|
|
32
|
+
// fades into the sky (grid-view.js STAGE_BACKGROUND_LIGHT) with distance.
|
|
33
|
+
// The key's contact shadow still reads, since the glow is only a fifth.
|
|
34
|
+
const FLOOR_LIGHT = "#ffffff";
|
|
35
|
+
const FLOOR_LIGHT_GLOW = 0.2;
|
|
28
36
|
|
|
29
37
|
export const STAGE_SIZE = 500;
|
|
30
38
|
|
|
31
|
-
/** Metres
|
|
39
|
+
/** Metres between the heavy lines; minor lines every metre. */
|
|
32
40
|
const TILE_M = 10;
|
|
33
41
|
|
|
42
|
+
// Line ink per theme: how far a line darkens the lit deck. The light deck
|
|
43
|
+
// needs as much ink as the dark one to read: its lines are thin dark marks on
|
|
44
|
+
// a bright floor, and anything fainter vanished into it.
|
|
45
|
+
const GRID_INK = Object.freeze({
|
|
46
|
+
dark: { minor: 0.22, major: 0.42 },
|
|
47
|
+
light: { minor: 0.38, major: 0.5 },
|
|
48
|
+
});
|
|
49
|
+
|
|
34
50
|
/**
|
|
35
51
|
* The measuring grid, drawn INTO the deck rather than floating above it.
|
|
36
52
|
*
|
|
37
53
|
* A GridHelper is a separate object a couple of millimetres over a 500 m
|
|
38
54
|
* plane, and at that separation it loses to the floor across almost the whole
|
|
39
55
|
* frame — it survives only where the surface is grazed near the horizon, which
|
|
40
|
-
* is exactly where it is useless.
|
|
56
|
+
* is exactly where it is useless. Drawing the lines in the floor's own shader
|
|
41
57
|
* ends the contest: the marks ARE the surface, so they cannot z-fight with it,
|
|
42
|
-
* cannot be sorted behind it, and shade and fog exactly as the deck
|
|
58
|
+
* cannot be sorted behind it, and shade, shadow and fog exactly as the deck
|
|
59
|
+
* does.
|
|
60
|
+
*
|
|
61
|
+
* Computed per pixel from the world position, not baked into a tiled texture:
|
|
62
|
+
* a texture blurred its lines up close and mip-averaged them into a grey
|
|
63
|
+
* moire toward the horizon. Here every line is ~1 px wide at any distance
|
|
64
|
+
* (fwidth), and a line family fades out once its spacing drops under a few
|
|
65
|
+
* pixels, so the far deck is clean floor instead of a band of noise.
|
|
43
66
|
*/
|
|
44
|
-
function
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
67
|
+
function makeDeckMaterial(light) {
|
|
68
|
+
const ink = light ? GRID_INK.light : GRID_INK.dark;
|
|
69
|
+
const material = new THREE.MeshLambertMaterial({
|
|
70
|
+
color: light ? FLOOR_LIGHT : FLOOR,
|
|
71
|
+
emissive: light ? FLOOR_LIGHT : "#000000",
|
|
72
|
+
emissiveIntensity: light ? FLOOR_LIGHT_GLOW : 0,
|
|
73
|
+
});
|
|
74
|
+
material.onBeforeCompile = (shader) => {
|
|
75
|
+
shader.uniforms.gridInk = { value: new THREE.Vector2(ink.minor, ink.major) };
|
|
76
|
+
shader.vertexShader = shader.vertexShader
|
|
77
|
+
.replace("#include <common>", "#include <common>\nvarying vec2 vGridXZ;")
|
|
78
|
+
.replace("#include <begin_vertex>", "#include <begin_vertex>\nvGridXZ = (modelMatrix * vec4(transformed, 1.0)).xz;");
|
|
79
|
+
shader.fragmentShader = shader.fragmentShader
|
|
80
|
+
.replace("#include <common>", `#include <common>
|
|
81
|
+
varying vec2 vGridXZ;
|
|
82
|
+
uniform vec2 gridInk;
|
|
83
|
+
// 0..1 coverage of the lines every \`spacing\` metres, \`width\` px wide.
|
|
84
|
+
float gridLines(float spacing, float width) {
|
|
85
|
+
vec2 cell = vGridXZ / spacing;
|
|
86
|
+
vec2 perPx = fwidth(cell);
|
|
87
|
+
vec2 dist = abs(fract(cell - 0.5) - 0.5) / max(perPx, vec2(1e-5));
|
|
88
|
+
float line = 1.0 - clamp(min(dist.x, dist.y) - (width - 1.0) * 0.5, 0.0, 1.0);
|
|
89
|
+
// Cells under ~5 px would alias: fade the family out before they do.
|
|
90
|
+
float fade = 1.0 - smoothstep(0.08, 0.2, max(perPx.x, perPx.y));
|
|
91
|
+
return line * fade;
|
|
92
|
+
}`)
|
|
93
|
+
.replace("#include <opaque_fragment>", `outgoingLight *= 1.0 - max(gridLines(1.0, 1.0) * gridInk.x, gridLines(${TILE_M.toFixed(1)}, 1.6) * gridInk.y);
|
|
94
|
+
#include <opaque_fragment>`);
|
|
95
|
+
};
|
|
96
|
+
material.customProgramCacheKey = () => `deck-grid-${light ? "light" : "dark"}`;
|
|
97
|
+
return material;
|
|
71
98
|
}
|
|
72
99
|
|
|
73
|
-
export function Room() {
|
|
74
|
-
const
|
|
75
|
-
useEffect(() => () =>
|
|
100
|
+
export function Room({ light = false }) {
|
|
101
|
+
const material = useMemo(() => makeDeckMaterial(light), [light]);
|
|
102
|
+
useEffect(() => () => material.dispose(), [material]);
|
|
76
103
|
return (
|
|
77
104
|
<group>
|
|
78
|
-
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]} receiveShadow>
|
|
105
|
+
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0, 0]} receiveShadow userData={{ noInk: true }} material={material}>
|
|
79
106
|
<planeGeometry args={[STAGE_SIZE, STAGE_SIZE]} />
|
|
80
|
-
<meshLambertMaterial color={FLOOR} map={grid} />
|
|
81
107
|
</mesh>
|
|
82
108
|
</group>
|
|
83
109
|
);
|
|
@@ -87,32 +113,34 @@ export function Room() {
|
|
|
87
113
|
* The key is the USER'S light: `keyLight` carries its grabbable position and
|
|
88
114
|
* the rig's master brightness — fill and rim ride the same dimmer so turning
|
|
89
115
|
* the key down darkens the whole stage instead of flattening it. */
|
|
90
|
-
// Warm/cool slider → light colour. 0.5 is the tuned default (
|
|
91
|
-
// 0 pulls toward cool daylight,
|
|
116
|
+
// Warm/cool slider → light colour. 0.5 is the tuned default (neutral white,
|
|
117
|
+
// so the grey set and the grey clay stay grey); 0 pulls toward cool daylight,
|
|
118
|
+
// 1 toward sunset amber.
|
|
92
119
|
export function keyLightColor(warmth = 0.5) {
|
|
93
120
|
const w = Math.max(0, Math.min(1, warmth ?? 0.5));
|
|
94
|
-
const base = new THREE.Color("#
|
|
121
|
+
const base = new THREE.Color("#ffffff");
|
|
95
122
|
if (w < 0.5) return base.clone().lerp(new THREE.Color("#e8f0ff"), (0.5 - w) * 2).getStyle();
|
|
96
123
|
if (w > 0.5) return base.clone().lerp(new THREE.Color("#ffc27a"), (w - 0.5) * 2).getStyle();
|
|
97
|
-
return "#
|
|
124
|
+
return "#ffffff";
|
|
98
125
|
}
|
|
99
126
|
|
|
100
127
|
/**
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* authored colours stay saturated without any pretend floor.
|
|
128
|
+
* One neutral rig (Blender's workbench studio light idea): a white sky over
|
|
129
|
+
* a grey ground bounce. `neutral` is the grid view, where the deck is gone
|
|
130
|
+
* and the ambient is lifted so authored colours stay saturated without any
|
|
131
|
+
* pretend floor.
|
|
106
132
|
*/
|
|
107
|
-
export function StageLights({ keyLight = { x: 6, y: 9, z: 4, intensity: 1.12, warmth: 0.5 }, neutral = false }) {
|
|
133
|
+
export function StageLights({ keyLight = { x: 6, y: 9, z: 4, intensity: 1.12, warmth: 0.5 }, neutral = false, light = false }) {
|
|
108
134
|
const dim = keyLight.intensity / 1.12;
|
|
109
135
|
return (
|
|
110
136
|
<>
|
|
111
137
|
<hemisphereLight
|
|
112
|
-
args={neutral ? ["#ffffff", "#3a3d42", 0.9] : ["#
|
|
138
|
+
args={neutral ? ["#ffffff", "#3a3d42", 0.9] : ["#ffffff", light ? "#d6d8de" : "#5a5d64", 0.9]}
|
|
113
139
|
intensity={0.9 * Math.min(1, 0.35 + 0.65 * dim)}
|
|
114
140
|
/>
|
|
115
|
-
|
|
141
|
+
{/* The light stage is a bright room: more ambient, so the clay's shaded
|
|
142
|
+
side stays soft grey instead of going charcoal against white. */}
|
|
143
|
+
<ambientLight intensity={(neutral ? 0.34 : light ? 0.32 : 0.18) * Math.min(1, 0.35 + 0.65 * dim)} />
|
|
116
144
|
{/* Only the key casts: one soft, unambiguous contact shadow reads as
|
|
117
145
|
ground contact, while three overlapping shadows read as noise. The
|
|
118
146
|
map covers the blocking area rather than the whole 500 m deck — a
|
|
@@ -136,7 +164,7 @@ export function StageLights({ keyLight = { x: 6, y: 9, z: 4, intensity: 1.12, wa
|
|
|
136
164
|
shadow-bias={-0.0006}
|
|
137
165
|
shadow-normalBias={0.02}
|
|
138
166
|
/>
|
|
139
|
-
<directionalLight color="#
|
|
167
|
+
<directionalLight color="#eef2f4" position={[-6, 4, -4]} intensity={0.36 * dim} />
|
|
140
168
|
<directionalLight color="#ffffff" position={[2, 3, 9]} intensity={0.22 * dim} />
|
|
141
169
|
</>
|
|
142
170
|
);
|