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
|
@@ -3,22 +3,23 @@ import Foldout from "./Foldout.jsx";
|
|
|
3
3
|
import { useStudioShell } from "../shell/studio-shell-context.js";
|
|
4
4
|
import { useMotionCommands } from "../domains/motion.js";
|
|
5
5
|
import { ko, isKo } from "../locale.js";
|
|
6
|
-
import { HIERARCHY_INSPECTOR_TITLES } from "../app-stage.jsx";
|
|
6
|
+
import { HIERARCHY_INSPECTOR_TITLES, lineTrackLabel } from "../app-stage.jsx";
|
|
7
7
|
import { PhysicsPanel } from "../ardy/physics-panel.jsx";
|
|
8
8
|
import { Field } from "../ui.jsx";
|
|
9
9
|
import { MotionReadiness } from "../motion-readiness-ui.jsx";
|
|
10
10
|
import { RangePinPanel } from "../range-pin-panel.jsx";
|
|
11
|
+
import { TRAIL_TRACKS } from "../motion-trail.js";
|
|
11
12
|
import { PlatformFitPanel } from "../ardy/platform-fit-panel.jsx";
|
|
12
13
|
import "../ardy/auto-fix-panel.css";
|
|
13
14
|
import "./pose.css";
|
|
14
15
|
|
|
15
16
|
export default function RigControlPanel({
|
|
16
|
-
isRigSelection, rigSelection, ikChains, ikFocus, footSnap, ikMode,
|
|
17
|
+
isRigSelection, rigSelection, ikChains, ikFocus, footSnap, ikMode, poseObjectOpacity = 0.2, setPoseObjectOpacity,
|
|
17
18
|
collisionCleanupSupported, motion, autoPhysicsRunning, physicsProgress, physicsPreview,
|
|
18
19
|
physicsShow, physicsOptions, tlFrame, changePhysicsOptions, runAutoPhysics, showPhysicsPreview,
|
|
19
20
|
applyPhysicsPreview, cancelPhysicsPreview, setTlFrame, platformFitRunning, platformFitProgress,
|
|
20
21
|
platformFitLast, platformFitApplied, ikEditTool, setIkEditTool, showTrails, setShowTrails,
|
|
21
|
-
trailFalloffS, setTrailFalloffS, trailEdit, generationBusy, bridgeChecking, bridge,
|
|
22
|
+
trailFalloffS, setTrailFalloffS, trailEdit, trailTrackFocus = null, selectTrailTrack, clearTrailTrackFocus, pendingIkEdit = null, applyPendingIkEdit, cancelPendingIkEdit, generationBusy, bridgeChecking, bridge,
|
|
22
23
|
runTrailRegeneration, trailReadinessState, openMotionSetup, recheckMotionHealth,
|
|
23
24
|
rangePins = [], rangePinResiduals = new Map(), rangePinSelection = null, rangePinPartPick = null,
|
|
24
25
|
rangePinPreview = null, objects = [], setRangePinSelection, setRangePinPartPick,
|
|
@@ -49,6 +50,13 @@ export default function RigControlPanel({
|
|
|
49
50
|
<div className="pose-row"><span>{ko("Foot lock", "발 고정")}</span><b>{footSnap ? ko("On", "켜짐") : ko("Off", "꺼짐")}</b></div>
|
|
50
51
|
<div className="pose-row"><span>{ko("Body contact", "몸 접촉")}</span><b>{ikChains ? ko("Ready", "준비됨") : ko("Unavailable", "사용 불가")}</b></div>
|
|
51
52
|
<div className="pose-row"><span>{ko("Influence", "영향")}</span><b>{ikFocus ?? ko("All", "전체")}</b></div>
|
|
53
|
+
{setPoseObjectOpacity && <div className="pose-row pose-object-opacity">
|
|
54
|
+
<span>{ko("Object opacity", "오브젝트 투명도")}</span>
|
|
55
|
+
<input type="range" min={0} max={100} step={5} value={Math.round(poseObjectOpacity * 100)}
|
|
56
|
+
data-testid="pose-object-opacity" aria-label={ko("Scene object opacity while posing", "포즈 편집 중 오브젝트 불투명도")}
|
|
57
|
+
onChange={(event) => setPoseObjectOpacity(Number(event.target.value) / 100)} />
|
|
58
|
+
<b>{Math.round(poseObjectOpacity * 100)}%</b>
|
|
59
|
+
</div>}
|
|
52
60
|
<p className="inspector-hint">{rigSelection && rigSelection.token !== "rig" ? (isKo ? `${HIERARCHY_INSPECTOR_TITLES[rigSelection.token]}이 활성 제어 그룹입니다.` : `${HIERARCHY_INSPECTOR_TITLES[rigSelection.token]} is the active control group.`) : ko("Select a body group to pose it in the viewport.", "몸 그룹을 선택해 뷰포트에서 포즈를 잡으세요.")}</p>
|
|
53
61
|
</section>
|
|
54
62
|
{ikMode && <section className="pose-section" data-testid="pose-active-tool">
|
|
@@ -58,9 +66,22 @@ export default function RigControlPanel({
|
|
|
58
66
|
<button type="button" className={"btn" + (ikEditTool === "trail" ? " primary" : "")} aria-pressed={ikEditTool === "trail"} disabled={!showTrails} onClick={() => setIkEditTool("trail")}>{ko("Path fix", "경로 수정")}</button>
|
|
59
67
|
<button type="button" data-testid="range-pin-tool" className={"btn" + (ikEditTool === "pin" ? " primary" : "")} aria-pressed={ikEditTool === "pin"} disabled={!motion} onClick={() => { setIkEditTool("pin"); setRangePinPartPick?.(null); }}>{ko("Pin", "고정")}</button>
|
|
60
68
|
</div>
|
|
69
|
+
{pendingIkEdit && <div className="pose-section" data-testid="ik-pending-edit">
|
|
70
|
+
<div className="pose-row"><span>{ko("IK preview", "IK 미리보기")}</span><b>{pendingIkEdit.startFrame}–{pendingIkEdit.endFrame}</b></div>
|
|
71
|
+
<p className="inspector-hint">{ko("The drag is previewed. Apply it to the selected motion block or cancel it.", "드래그를 미리 보고 있어요. 선택한 모션 블록에 적용하거나 취소하세요.")}</p>
|
|
72
|
+
<div className="pose-actions">
|
|
73
|
+
<button type="button" className="btn primary" data-testid="ik-apply-range" onClick={applyPendingIkEdit}>{ko("Apply range", "구간 적용")}</button>
|
|
74
|
+
<button type="button" className="btn" data-testid="ik-cancel-preview" onClick={cancelPendingIkEdit}>{ko("Cancel", "취소")}</button>
|
|
75
|
+
</div>
|
|
76
|
+
</div>}
|
|
61
77
|
{ikEditTool === "pin" && <RangePinPanel active motion={motion} frame={tlFrame} frameCount={motion?.frames ?? 1} fps={motion?.fps ?? 24} pins={rangePins} residuals={rangePinResiduals} objects={objects} selectedPinId={rangePinSelection} partPick={rangePinPartPick} conflictFrames={rangePinPreview?.conflictFrames ?? []} overlapError={rangePinPreview?.overlapError ?? ""} onSelectPin={(id) => { setRangePinSelection?.(id); if (id) setIkEditTool("pin"); }} onApply={applyRangePinDraft} onCancel={() => { setRangePinSelection?.(null); setIkEditTool("ik"); }} onDelete={deleteRangePin} onPreviewTarget={previewRangePinDraft} />}
|
|
62
78
|
{ikEditTool === "trail" && <>
|
|
63
79
|
<button type="button" className="btn full" aria-pressed={showTrails} onClick={() => setShowTrails((value) => !value)}>{ko(`Trails ${showTrails ? "on" : "off"}`, `궤적선 ${showTrails ? "표시" : "숨김"}`)}</button>
|
|
80
|
+
<label className="pose-row" data-testid="trail-selection"><span>{ko("Shown line", "표시 라인")}</span><select data-testid="trail-track-select" value={trailTrackFocus ?? ""} onChange={(event) => selectTrailTrack(event.target.value)}>
|
|
81
|
+
<option value="" disabled>{ko("All lines", "모든 라인")}</option>
|
|
82
|
+
{TRAIL_TRACKS.map((track) => <option key={track.id} value={track.id}>{lineTrackLabel(track.id)}</option>)}
|
|
83
|
+
</select></label>
|
|
84
|
+
{trailTrackFocus && <button type="button" className="btn full" data-testid="trail-show-all" onClick={clearTrailTrackFocus}>{ko("Show all lines", "모든 라인 보기")}</button>}
|
|
64
85
|
<Field label={ko("Falloff", "영향 범위")}><div className="trail-falloff-row"><input type="range" min={0.1} max={2} step={0.1} value={trailFalloffS} onChange={(event) => setTrailFalloffS(Number(event.target.value))} /><span className="trail-falloff-value">{trailFalloffS.toFixed(1)}s</span></div></Field>
|
|
65
86
|
<button type="button" className="btn primary full" disabled={!trailEdit || !motion?.url || generationBusy || bridgeChecking || bridge === null} onClick={runTrailRegeneration}>{ko("Regenerate", "재생성")}</button>
|
|
66
87
|
<MotionReadiness state={trailReadinessState} checking={bridgeChecking} onSetup={() => openMotionSetup("trail")} onRetry={recheckMotionHealth} />
|
package/src/panels/RigPanel.jsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import Foldout from "./Foldout.jsx";
|
|
2
2
|
import { ko } from "../locale.js";
|
|
3
|
-
import { CHARACTER_MODEL_IDS } from "../scenes.js";
|
|
3
|
+
import { CHARACTER_MODEL_IDS, isProxyFigure } from "../scenes.js";
|
|
4
4
|
import { PoseThumbPreview } from "../posestudio.jsx";
|
|
5
5
|
import { DEFAULT_POSE } from "../poses.js";
|
|
6
6
|
import { CHARACTER_MODEL_LABELS } from "../app-stage.jsx";
|
|
@@ -8,6 +8,7 @@ import { useBus } from '../app-context.js';
|
|
|
8
8
|
|
|
9
9
|
export default function RigPanel({ isCharacterSelection, activeChar }) {
|
|
10
10
|
const { run } = useBus();
|
|
11
|
+
if (isProxyFigure(activeChar)) return null;
|
|
11
12
|
return (
|
|
12
13
|
<Foldout hidden={!isCharacterSelection} defaultOpen={false} title={ko("Rig", "리그")}>
|
|
13
14
|
{/* The rig is a property of the character, and swapping it is a
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import Foldout from "./Foldout.jsx";
|
|
3
|
+
import { ko, isKo } from "../locale.js";
|
|
4
|
+
import { effortOptions } from "../workflow/agent-client.js";
|
|
5
|
+
import "./sculpt.css";
|
|
6
|
+
|
|
7
|
+
// Sculpt mode side panel (#739). Pure view: the run controller owns the run state and the
|
|
8
|
+
// reference; this panel only renders it and reports the operator's intent through callbacks.
|
|
9
|
+
|
|
10
|
+
const STAGES = [
|
|
11
|
+
["measure", ko("Measure", "측정")],
|
|
12
|
+
["draft", ko("Draft", "초안")],
|
|
13
|
+
["score", ko("Score", "채점")],
|
|
14
|
+
["refine", ko("Refine", "다듬기")],
|
|
15
|
+
];
|
|
16
|
+
const RUNNING = new Set(["measure", "draft", "score", "refine"]);
|
|
17
|
+
const IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]);
|
|
18
|
+
|
|
19
|
+
function pickImage(files) {
|
|
20
|
+
return [...(files ?? [])].find((file) => IMAGE_TYPES.has(file.type)) ?? null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function formatElapsed(ms) {
|
|
24
|
+
const seconds = Math.max(0, Math.round((ms || 0) / 1000));
|
|
25
|
+
const minutes = Math.floor(seconds / 60);
|
|
26
|
+
return minutes ? `${minutes}:${String(seconds % 60).padStart(2, "0")}` : `${seconds}s`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function formatIou(iou) {
|
|
30
|
+
return Number.isFinite(iou) ? iou.toFixed(3) : "—";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
function formatClock(ms) {
|
|
35
|
+
const seconds = Math.max(0, Math.floor((ms || 0) / 1000));
|
|
36
|
+
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Re-render every half second while something is awaited, so the wait counts up live. */
|
|
40
|
+
function useTicking(active) {
|
|
41
|
+
const [, setTick] = useState(0);
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!active) return undefined;
|
|
44
|
+
const timer = setInterval(() => setTick((value) => value + 1), 500);
|
|
45
|
+
return () => clearInterval(timer);
|
|
46
|
+
}, [active]);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What the run is waiting on right now, how long it has waited and how long that usually takes. */
|
|
50
|
+
function NowLine({ waiting, now = Date.now() }) {
|
|
51
|
+
if (!waiting) return null;
|
|
52
|
+
const waited = Math.max(0, now - (waiting.since ?? now));
|
|
53
|
+
const expect = Number.isFinite(waiting.expectMs) && waiting.expectMs > 0 ? waiting.expectMs : null;
|
|
54
|
+
const over = expect !== null && waited > expect * 1.5;
|
|
55
|
+
return (
|
|
56
|
+
<div className="sculpt-now" data-testid="sculpt-now" data-what={waiting.what} data-over={over ? "true" : undefined} role="status" aria-live="polite">
|
|
57
|
+
<span className="sculpt-clay" aria-hidden="true" />
|
|
58
|
+
<span className="sculpt-now-label">{waiting.label}</span>
|
|
59
|
+
<span className="sculpt-now-time" data-testid="sculpt-now-time">{Math.floor(waited / 1000)}s</span>
|
|
60
|
+
{expect !== null && (
|
|
61
|
+
<span className="sculpt-now-expect">
|
|
62
|
+
{over ? ko("taking longer than usual", "평소보다 오래 걸려요") : isKo ? `보통 ~${Math.round(expect / 1000)}초` : `usually ~${Math.round(expect / 1000)}s`}
|
|
63
|
+
</span>
|
|
64
|
+
)}
|
|
65
|
+
{expect !== null && <span className="sculpt-now-bar" aria-hidden="true"><span style={{ "--sculpt-wait": Math.min(1, waited / expect) }} /></span>}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** IoU after each scored round, against the target line; the newest point pulses. */
|
|
71
|
+
function IouTrail({ history, target }) {
|
|
72
|
+
if (!history?.length) return null;
|
|
73
|
+
const values = history.map((row) => row.iou);
|
|
74
|
+
const low = Math.max(0, Math.min(...values, target ?? 1) - 0.05);
|
|
75
|
+
const y = (iou) => 30 - ((iou - low) / Math.max(0.01, 1 - low)) * 26;
|
|
76
|
+
const x = (index) => (history.length === 1 ? 50 : 6 + (index / (history.length - 1)) * 88);
|
|
77
|
+
const last = history.at(-1).iou;
|
|
78
|
+
const delta = history.length > 1 ? last - history.at(-2).iou : null;
|
|
79
|
+
return (
|
|
80
|
+
<div className="sculpt-trail" data-testid="sculpt-trail">
|
|
81
|
+
<svg viewBox="0 0 100 34" preserveAspectRatio="none" aria-label={isKo ? `라운드별 IoU: ${values.map((v) => v.toFixed(3)).join(", ")}` : `IoU by round: ${values.map((v) => v.toFixed(3)).join(", ")}`} role="img">
|
|
82
|
+
{target !== null && <line className="sculpt-trail-target" x1="0" x2="100" y1={y(target)} y2={y(target)} />}
|
|
83
|
+
{history.length > 1 && <polyline className="sculpt-trail-line" points={history.map((row, index) => `${x(index)},${y(row.iou)}`).join(" ")} />}
|
|
84
|
+
{history.map((row, index) => <circle key={index} className="sculpt-trail-dot" data-last={index === history.length - 1 ? "true" : undefined} cx={x(index)} cy={y(row.iou)} r="2.2" />)}
|
|
85
|
+
</svg>
|
|
86
|
+
{delta !== null && <span className="sculpt-delta" key={history.length} data-testid="sculpt-delta" data-sign={delta >= 0 ? "up" : "down"}>{delta >= 0 ? "+" : ""}{delta.toFixed(3)}</span>}
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The latest candidate ranking as a race: each fix's IoU as a bar that grows in, the leader marked. */
|
|
92
|
+
function CandidateRace({ candidates }) {
|
|
93
|
+
if (!candidates?.length) return null;
|
|
94
|
+
const scored = candidates.filter((row) => Number.isFinite(row.iou));
|
|
95
|
+
const lo = scored.length ? Math.min(...scored.map((row) => row.iou)) - 0.02 : 0;
|
|
96
|
+
const hi = scored.length ? Math.max(...scored.map((row) => row.iou)) + 0.01 : 1;
|
|
97
|
+
const leader = candidates.find((row) => !row.refused && Number.isFinite(row.iou));
|
|
98
|
+
return (
|
|
99
|
+
<ol className="sculpt-race" data-testid="sculpt-race" aria-label={ko("Candidate fixes, best first", "후보 수정안 (좋은 순)")}>
|
|
100
|
+
{candidates.map((row, index) => (
|
|
101
|
+
<li key={`${row.label}-${index}`} className="sculpt-race-row" data-leader={row === leader ? "true" : undefined} data-refused={row.refused ? "true" : undefined}
|
|
102
|
+
style={{ "--sculpt-race": Number.isFinite(row.iou) ? Math.max(0.04, (row.iou - lo) / Math.max(0.001, hi - lo)) : 0, "--sculpt-race-delay": `${index * 90}ms` }}>
|
|
103
|
+
<span className="sculpt-race-label" title={row.label}>{row.label}</span>
|
|
104
|
+
<span className="sculpt-race-bar" aria-hidden="true"><span /></span>
|
|
105
|
+
<span className="sculpt-race-score">{row.refused ? ko("refused", "거부") : Number.isFinite(row.delta) ? `${row.delta >= 0 ? "+" : ""}${row.delta.toFixed(3)}` : formatIou(row.iou)}</span>
|
|
106
|
+
</li>
|
|
107
|
+
))}
|
|
108
|
+
</ol>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The run's own account of itself, newest at the bottom and kept in view. */
|
|
113
|
+
function ActivityLog({ activity }) {
|
|
114
|
+
const logRef = useRef(null);
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
const logList = logRef.current;
|
|
117
|
+
if (logList) logList.scrollTop = logList.scrollHeight;
|
|
118
|
+
}, [activity?.length]);
|
|
119
|
+
if (!activity?.length) return null;
|
|
120
|
+
return (
|
|
121
|
+
<ol className="sculpt-log" data-testid="sculpt-log" ref={logRef} aria-label={ko("Sculpt activity", "조형 진행 기록")}>
|
|
122
|
+
{activity.map((row) => (
|
|
123
|
+
<li key={row.id} className="sculpt-log-row" data-kind={row.kind}>
|
|
124
|
+
<span className="sculpt-log-time">{formatClock(row.atMs)}</span>
|
|
125
|
+
<span className="sculpt-log-dot" aria-hidden="true" />
|
|
126
|
+
<span className="sculpt-log-text">{row.text}</span>
|
|
127
|
+
</li>
|
|
128
|
+
))}
|
|
129
|
+
</ol>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The measured slices drawn over the reference: a scan line while measuring, then each slice's spans. */
|
|
134
|
+
function OutlineScan({ outline, scanning }) {
|
|
135
|
+
const box = outline?.box;
|
|
136
|
+
return (
|
|
137
|
+
<>
|
|
138
|
+
{scanning && <span className="sculpt-scanline" data-testid="sculpt-scanline" aria-hidden="true" />}
|
|
139
|
+
{box && (
|
|
140
|
+
<svg className="sculpt-scan" data-testid="sculpt-scan" viewBox="0 0 1 1" preserveAspectRatio="none" aria-hidden="true">
|
|
141
|
+
{outline.rows.flatMap((row, index) => row.spans.map(([from, to], spanIndex) => {
|
|
142
|
+
const centre = box.left + box.width / 2;
|
|
143
|
+
const scale = box.height * (box.aspect ?? 1);
|
|
144
|
+
return (
|
|
145
|
+
<rect key={`${index}-${spanIndex}`} className="sculpt-scan-span" style={{ animationDelay: `${index * 70}ms` }}
|
|
146
|
+
x={centre + from * scale} width={Math.max(0.004, (to - from) * scale)} y={box.top + row.y * box.height - 0.006} height="0.012" />
|
|
147
|
+
);
|
|
148
|
+
}))}
|
|
149
|
+
</svg>
|
|
150
|
+
)}
|
|
151
|
+
</>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Which model the run uses, and its effort. The list is the Agent panel's
|
|
157
|
+
* (/agent/models): grouped by provider, a provider without a credential lists
|
|
158
|
+
* its models disabled. Locked while a run is out, since every turn of one run
|
|
159
|
+
* uses the same model.
|
|
160
|
+
*/
|
|
161
|
+
function ModelPicker({ picker, locked }) {
|
|
162
|
+
if (!picker) return null;
|
|
163
|
+
const { state, providers = [], models = [], model, effort, onModel, onEffort } = picker;
|
|
164
|
+
const efforts = effortOptions(models.find((entry) => entry.id === model));
|
|
165
|
+
const disabled = locked || !models.length;
|
|
166
|
+
return (
|
|
167
|
+
<div className="sculpt-model" data-testid="sculpt-model">
|
|
168
|
+
<div className="sculpt-label"><span>{ko("Model", "모델")}</span>{locked && <small>{ko("locked while running", "실행 중엔 고정")}</small>}</div>
|
|
169
|
+
<div className="sculpt-model-row">
|
|
170
|
+
<select className="sculpt-select" data-testid="sculpt-model-select" aria-label={ko("Sculpt model", "조형 모델")} value={model || ""} disabled={disabled}
|
|
171
|
+
onChange={(event) => onModel?.(event.target.value)}>
|
|
172
|
+
{providers.length
|
|
173
|
+
? providers.map((provider) => (
|
|
174
|
+
<optgroup key={provider.id} label={provider.label ?? provider.id}>
|
|
175
|
+
{(provider.models ?? []).map((entry) => {
|
|
176
|
+
const key = entry.key ?? `${provider.id}/${entry.id}`;
|
|
177
|
+
const label = models.find((row) => row.id === key)?.label ?? entry.label ?? key;
|
|
178
|
+
return <option key={key} value={key} disabled={!provider.signedIn}>{provider.signedIn ? label : `${label} — ${ko("add key", "키 필요")}`}</option>;
|
|
179
|
+
})}
|
|
180
|
+
</optgroup>
|
|
181
|
+
))
|
|
182
|
+
: models.length
|
|
183
|
+
? models.map((entry) => <option key={entry.id} value={entry.id}>{entry.label ?? entry.id}</option>)
|
|
184
|
+
: <option value="">{state === "failed" ? ko("No model available", "사용할 모델 없음") : ko("Loading models…", "모델 불러오는 중…")}</option>}
|
|
185
|
+
</select>
|
|
186
|
+
{efforts.length > 0 && (
|
|
187
|
+
<select className="sculpt-select sculpt-effort" data-testid="sculpt-effort-select" aria-label={ko("Effort", "추론 강도")} value={effort ?? ""} disabled={locked}
|
|
188
|
+
onChange={(event) => onEffort?.(event.target.value || null)}>
|
|
189
|
+
<option value="">{isKo ? `기본 (${efforts[0]})` : `Default (${efforts[0]})`}</option>
|
|
190
|
+
{efforts.map((value) => <option key={value} value={value}>{value}</option>)}
|
|
191
|
+
</select>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The blueprint step: one image request turns the reference into a four-view
|
|
200
|
+
* orthographic model sheet. Pure view: App owns the request and its result.
|
|
201
|
+
*/
|
|
202
|
+
function BlueprintSection({ blueprint, disabled }) {
|
|
203
|
+
if (!blueprint) return null;
|
|
204
|
+
const { state = "idle", pose = "keep", notes = "", prompt = "", sheet = null, error = null, startedAt = null, refusal = null, onPose, onNotes, onGenerate, onCancel, layout = [] } = blueprint;
|
|
205
|
+
const busy = state === "generating";
|
|
206
|
+
const waited = busy && startedAt ? Math.floor((Date.now() - startedAt) / 1000) : 0;
|
|
207
|
+
return (
|
|
208
|
+
<div className="sculpt-blueprint" data-testid="sculpt-blueprint" data-state={state}>
|
|
209
|
+
<div className="sculpt-label"><span>{ko("Blueprint (model sheet)", "도면 (모델 시트)")}</span><small>{ko("front · side · back · top", "정면 · 측면 · 뒷면 · 윗면")}</small></div>
|
|
210
|
+
<div className="sculpt-blueprint-row">
|
|
211
|
+
<select className="sculpt-select" data-testid="sculpt-blueprint-pose" aria-label={ko("Pose on the sheet", "도면 포즈")} value={pose} disabled={busy || disabled} onChange={(event) => onPose?.(event.target.value)}>
|
|
212
|
+
<option value="keep">{ko("Pose: as in the reference", "포즈: 원본 그대로")}</option>
|
|
213
|
+
<option value="neutral">{ko("Pose: neutral rest pose", "포즈: 중립 자세")}</option>
|
|
214
|
+
</select>
|
|
215
|
+
</div>
|
|
216
|
+
<textarea className="sculpt-notes" data-testid="sculpt-blueprint-notes" rows={2} maxLength={600} disabled={busy || disabled}
|
|
217
|
+
placeholder={ko("Notes (optional): what it is, what to keep or fix", "메모 (선택): 무엇인지, 유지하거나 고칠 점")}
|
|
218
|
+
aria-label={ko("Blueprint notes", "도면 메모")} value={notes} onChange={(event) => onNotes?.(event.target.value)} />
|
|
219
|
+
{busy ? (
|
|
220
|
+
<div className="sculpt-now" data-testid="sculpt-blueprint-wait" role="status" aria-live="polite">
|
|
221
|
+
<span className="sculpt-clay" aria-hidden="true" />
|
|
222
|
+
<span className="sculpt-now-label">{ko("Drawing the four-view sheet", "4면 도면 그리는 중")}</span>
|
|
223
|
+
<span className="sculpt-now-time">{waited}s</span>
|
|
224
|
+
<span className="sculpt-now-expect">{ko("usually ~40–90s", "보통 ~40–90초")}</span>
|
|
225
|
+
<button type="button" className="btn ghost small" data-testid="sculpt-blueprint-cancel" onClick={() => onCancel?.()}>{ko("Cancel", "취소")}</button>
|
|
226
|
+
</div>
|
|
227
|
+
) : (
|
|
228
|
+
<button type="button" className="btn full" data-testid="sculpt-blueprint-generate" disabled={disabled || Boolean(refusal)} onClick={() => onGenerate?.()}>
|
|
229
|
+
{sheet ? ko("Generate again", "다시 생성") : ko("Generate blueprint", "도면 생성")}
|
|
230
|
+
</button>
|
|
231
|
+
)}
|
|
232
|
+
{refusal && !busy && <p className="inspector-hint" role="status" data-testid="sculpt-blueprint-refusal">{refusal}</p>}
|
|
233
|
+
{error && !busy && <p className="inspector-hint sculpt-error" role="alert" data-testid="sculpt-blueprint-error">{error}</p>}
|
|
234
|
+
{sheet && (
|
|
235
|
+
<figure className="sculpt-sheet-figure" data-testid="sculpt-blueprint-sheet">
|
|
236
|
+
<a href={sheet.dataUrl} target="_blank" rel="noreferrer" title={ko("Open full size", "원본 크기로 열기")}>
|
|
237
|
+
<img src={sheet.dataUrl} alt={ko("Generated four-view model sheet", "생성된 4면 도면")} />
|
|
238
|
+
{layout.map((panel) => (
|
|
239
|
+
<span key={panel.view} className="sculpt-sheet-tag" style={{ left: `${panel.x * 100}%`, top: `${panel.y * 100}%` }}>{panel.label}</span>
|
|
240
|
+
))}
|
|
241
|
+
</a>
|
|
242
|
+
<figcaption>
|
|
243
|
+
<span>{sheet.width}×{sheet.height}{Number.isFinite(sheet.ms) ? ` · ${Math.round(sheet.ms / 1000)}s` : ""}</span>
|
|
244
|
+
<a href={sheet.dataUrl} download="sculpt-blueprint.png" data-testid="sculpt-blueprint-download">{ko("Download", "내려받기")}</a>
|
|
245
|
+
</figcaption>
|
|
246
|
+
</figure>
|
|
247
|
+
)}
|
|
248
|
+
{prompt && (
|
|
249
|
+
<details className="sculpt-prompt" data-testid="sculpt-blueprint-prompt">
|
|
250
|
+
<summary>{ko("Request sent to the image model", "이미지 모델에 보내는 요청")}</summary>
|
|
251
|
+
<pre>{prompt}</pre>
|
|
252
|
+
</details>
|
|
253
|
+
)}
|
|
254
|
+
</div>
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export default function SculptPanel({ run, reference, onReference, onStart, onCancel, onPlaceInScene, canStart, startRefusal, sculptName, hidden, modelPicker = null, blueprint = null }) {
|
|
259
|
+
const fileRef = useRef(null);
|
|
260
|
+
const [dragging, setDragging] = useState(false);
|
|
261
|
+
// The sheet is the clearest picture of what is off, so it starts open.
|
|
262
|
+
const [showSheet, setShowSheet] = useState(true);
|
|
263
|
+
|
|
264
|
+
const stage = run?.stage ?? "idle";
|
|
265
|
+
const running = RUNNING.has(stage);
|
|
266
|
+
const stageIndex = STAGES.findIndex(([id]) => id === stage);
|
|
267
|
+
const iou = Number.isFinite(run?.iou) ? run.iou : null;
|
|
268
|
+
const target = Number.isFinite(run?.targetIou) ? run.targetIou : null;
|
|
269
|
+
const showProgress = stage !== "idle";
|
|
270
|
+
const startDisabled = !canStart || Boolean(startRefusal);
|
|
271
|
+
const hasLog = Boolean(run?.activity?.length);
|
|
272
|
+
useTicking((running && Boolean(run?.waiting)) || blueprint?.state === "generating");
|
|
273
|
+
|
|
274
|
+
const take = (files) => {
|
|
275
|
+
const file = pickImage(files);
|
|
276
|
+
if (file) onReference?.(file);
|
|
277
|
+
};
|
|
278
|
+
const openPicker = () => fileRef.current?.click();
|
|
279
|
+
|
|
280
|
+
return (
|
|
281
|
+
<Foldout hidden={hidden} title={ko("Sculpt from image", "이미지로 조형")}>
|
|
282
|
+
<div className="sculpt-panel" data-testid="sculpt-panel" data-stage={stage}
|
|
283
|
+
onPaste={(event) => {
|
|
284
|
+
const file = pickImage(event.clipboardData?.files);
|
|
285
|
+
if (!file) return;
|
|
286
|
+
event.preventDefault();
|
|
287
|
+
onReference?.(file);
|
|
288
|
+
}}>
|
|
289
|
+
|
|
290
|
+
<div className="sculpt-label"><span>{ko("Reference", "참조 이미지")}</span>{reference?.name && <small title={reference.name}>{reference.name}</small>}</div>
|
|
291
|
+
{reference ? (
|
|
292
|
+
<div className="sculpt-well sculpt-well-filled" data-testid="sculpt-reference"
|
|
293
|
+
onDragOver={(event) => { if (event.dataTransfer?.types?.includes("Files")) event.preventDefault(); }}
|
|
294
|
+
onDrop={(event) => { event.preventDefault(); take(event.dataTransfer?.files); }}>
|
|
295
|
+
<div className="sculpt-ref-frame">
|
|
296
|
+
<img src={reference.dataUrl} alt={isKo ? `참조 이미지: ${reference.name}` : `Reference: ${reference.name}`} />
|
|
297
|
+
<OutlineScan outline={run?.outline} scanning={stage === "measure" && !run?.outline} />
|
|
298
|
+
</div>
|
|
299
|
+
<button type="button" className="btn ghost small sculpt-well-replace" data-testid="sculpt-reference-replace"
|
|
300
|
+
disabled={running} aria-label={ko("Replace reference image", "참조 이미지 바꾸기")} onClick={openPicker}>
|
|
301
|
+
{ko("Replace", "바꾸기")}
|
|
302
|
+
</button>
|
|
303
|
+
</div>
|
|
304
|
+
) : (
|
|
305
|
+
<div className="sculpt-well sculpt-well-empty" data-testid="sculpt-dropzone" data-dragging={dragging ? "true" : undefined}
|
|
306
|
+
role="button" tabIndex={0} aria-label={ko("Drop or paste a reference image, or press Enter to choose a file", "참조 이미지를 끌어놓거나 붙여넣기, 또는 Enter로 파일 선택")}
|
|
307
|
+
onClick={openPicker}
|
|
308
|
+
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openPicker(); } }}
|
|
309
|
+
onDragOver={(event) => { if (event.dataTransfer?.types?.includes("Files")) { event.preventDefault(); setDragging(true); } }}
|
|
310
|
+
onDragLeave={() => setDragging(false)}
|
|
311
|
+
onDrop={(event) => { event.preventDefault(); setDragging(false); take(event.dataTransfer?.files); }}>
|
|
312
|
+
<span className="sculpt-well-title">{ko("Drop or paste a reference image", "참조 이미지를 끌어놓거나 붙여넣으세요")}</span>
|
|
313
|
+
<span className="sculpt-well-sub">PNG · JPEG · WebP</span>
|
|
314
|
+
<span className="btn ghost small" aria-hidden="true">{ko("Choose file", "파일 선택")}</span>
|
|
315
|
+
</div>
|
|
316
|
+
)}
|
|
317
|
+
<input ref={fileRef} type="file" accept="image/png,image/jpeg,image/webp" className="multimodel-file-input" data-testid="sculpt-reference-input"
|
|
318
|
+
aria-label={ko("Reference image file", "참조 이미지 파일")}
|
|
319
|
+
onChange={(event) => { const files = [...(event.target.files ?? [])]; event.target.value = ""; take(files); }} />
|
|
320
|
+
|
|
321
|
+
{reference && <BlueprintSection blueprint={blueprint} disabled={running} />}
|
|
322
|
+
|
|
323
|
+
<ModelPicker picker={modelPicker} locked={running} />
|
|
324
|
+
|
|
325
|
+
{running ? (
|
|
326
|
+
<button type="button" className="btn full sculpt-cancel" data-testid="sculpt-cancel" onClick={() => onCancel?.()}>
|
|
327
|
+
{ko("Cancel", "취소")}
|
|
328
|
+
</button>
|
|
329
|
+
) : (
|
|
330
|
+
<button type="button" className="btn primary full" data-testid="sculpt-start" disabled={startDisabled}
|
|
331
|
+
aria-describedby={startRefusal ? "sculpt-start-refusal" : undefined} onClick={() => onStart?.()}>
|
|
332
|
+
{ko("Start (fast draft)", "시작 (빠른 초안)")}
|
|
333
|
+
</button>
|
|
334
|
+
)}
|
|
335
|
+
{!running && startRefusal && <p className="inspector-hint sculpt-refusal" id="sculpt-start-refusal" role="status" data-testid="sculpt-start-refusal">{startRefusal}</p>}
|
|
336
|
+
|
|
337
|
+
{showProgress && (
|
|
338
|
+
<div className="sculpt-progress" data-testid="sculpt-progress">
|
|
339
|
+
<ol className="sculpt-stepper" aria-label={ko("Sculpt stages", "조형 단계")}>
|
|
340
|
+
{STAGES.map(([id, label], index) => {
|
|
341
|
+
const state = stage === "done" || (stageIndex >= 0 && index < stageIndex) ? "complete" : index === stageIndex ? "current" : "pending";
|
|
342
|
+
return (
|
|
343
|
+
<li key={id} className="sculpt-step" data-state={state} data-stage={id} aria-current={state === "current" ? "step" : undefined}>
|
|
344
|
+
<span className="sculpt-step-dot" aria-hidden="true" />
|
|
345
|
+
<span className="sculpt-step-label">{label}</span>
|
|
346
|
+
</li>
|
|
347
|
+
);
|
|
348
|
+
})}
|
|
349
|
+
</ol>
|
|
350
|
+
|
|
351
|
+
{running && <NowLine waiting={run.waiting} />}
|
|
352
|
+
|
|
353
|
+
<div className="sculpt-stats">
|
|
354
|
+
<span data-testid="sculpt-round">{isKo ? `라운드 ${run.round} / ${run.maxRounds}` : `Round ${run.round} / ${run.maxRounds}`}</span>
|
|
355
|
+
<span data-testid="sculpt-elapsed">{formatElapsed(run.elapsedMs)}</span>
|
|
356
|
+
</div>
|
|
357
|
+
|
|
358
|
+
<div className="sculpt-iou">
|
|
359
|
+
<div className="sculpt-label">
|
|
360
|
+
<span>{stage === "done" ? ko("Final IoU", "최종 IoU") : "IoU"}</span>
|
|
361
|
+
<small data-testid="sculpt-iou">{formatIou(iou)}{target !== null && <span className="sculpt-iou-target"> / {formatIou(target)}</span>}</small>
|
|
362
|
+
</div>
|
|
363
|
+
<div className="sculpt-iou-bar" role="meter" aria-label={ko("Silhouette match (IoU)", "실루엣 일치도 (IoU)")}
|
|
364
|
+
aria-valuemin={0} aria-valuemax={1} aria-valuenow={iou ?? undefined}
|
|
365
|
+
data-reached={iou !== null && target !== null && iou >= target ? "true" : undefined}
|
|
366
|
+
style={{ "--sculpt-iou": iou ?? 0, "--sculpt-target": target ?? 0 }}>
|
|
367
|
+
<span className="sculpt-iou-fill" />
|
|
368
|
+
{target !== null && <span className="sculpt-iou-marker" data-testid="sculpt-iou-target" title={isKo ? `목표 ${formatIou(target)}` : `Target ${formatIou(target)}`} />}
|
|
369
|
+
</div>
|
|
370
|
+
<IouTrail history={run.history} target={target} />
|
|
371
|
+
</div>
|
|
372
|
+
|
|
373
|
+
<CandidateRace candidates={run.candidates} />
|
|
374
|
+
<ActivityLog activity={run.activity} />
|
|
375
|
+
|
|
376
|
+
{run.message && !hasLog && <p className="inspector-hint sculpt-message" role="status">{run.message}</p>}
|
|
377
|
+
{stage === "done" && <p className="inspector-hint sculpt-done" role="status" data-testid="sculpt-done">{isKo ? `완료 — IoU ${formatIou(iou)}` : `Done — IoU ${formatIou(iou)}`}</p>}
|
|
378
|
+
{stage === "cancelled" && <p className="inspector-hint" role="status" data-testid="sculpt-cancelled">{ko("Cancelled.", "취소했어요.")}</p>}
|
|
379
|
+
{stage === "failed" && <p className="inspector-hint sculpt-error" role="alert" data-testid="sculpt-error">{run.error || ko("The sculpt run failed.", "조형에 실패했어요.")}</p>}
|
|
380
|
+
</div>
|
|
381
|
+
)}
|
|
382
|
+
|
|
383
|
+
{run?.sheetDataUrl && (
|
|
384
|
+
<div className="sculpt-sheet">
|
|
385
|
+
<button type="button" className="btn ghost small full" data-testid="sculpt-sheet-toggle" aria-pressed={showSheet}
|
|
386
|
+
onClick={() => setShowSheet((value) => !value)}>
|
|
387
|
+
{showSheet ? ko("Hide overlay sheet", "오버레이 시트 숨기기") : ko("Show overlay sheet", "오버레이 시트 보기")}
|
|
388
|
+
</button>
|
|
389
|
+
{showSheet && <img className="sculpt-sheet-image" data-testid="sculpt-sheet" src={run.sheetDataUrl} alt={ko("Reference beside the sculpt overlay", "참조 이미지와 조형 오버레이")} />}
|
|
390
|
+
</div>
|
|
391
|
+
)}
|
|
392
|
+
|
|
393
|
+
<button type="button" className="btn full" data-testid="sculpt-place" disabled={!sculptName || running}
|
|
394
|
+
aria-label={sculptName ? (isKo ? `${sculptName} 장면에 배치` : `Place ${sculptName} in scene`) : ko("Place in scene", "장면에 배치")}
|
|
395
|
+
onClick={() => onPlaceInScene?.()}>
|
|
396
|
+
{ko("Place in scene", "장면에 배치")}
|
|
397
|
+
</button>
|
|
398
|
+
|
|
399
|
+
<p className="inspector-hint sculpt-hint" data-testid="sculpt-hint">
|
|
400
|
+
{ko("Fix details by chatting with the Agent (⌘B) while the sculpt is selected.", "조형물을 선택한 채 에이전트(⌘B)와 대화해 디테일을 고치세요.")}
|
|
401
|
+
</p>
|
|
402
|
+
</div>
|
|
403
|
+
</Foldout>
|
|
404
|
+
);
|
|
405
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ko, isKo } from "../locale.js";
|
|
2
2
|
|
|
3
|
-
export default function SubjectBox({ label,
|
|
3
|
+
export default function SubjectBox({ label, onRemove, onPose, posing, color, onColorChange, onColorEditStart }) {
|
|
4
4
|
return (
|
|
5
5
|
<div className="subject-box v2-subject-box">
|
|
6
6
|
<div className="subject-box-head">
|
|
@@ -15,8 +15,6 @@ export default function SubjectsPanel({
|
|
|
15
15
|
<SubjectBox
|
|
16
16
|
key={entry.id}
|
|
17
17
|
label={ko(`Subject ${index + 1}`, `인물 ${index + 1}`)}
|
|
18
|
-
value={entry}
|
|
19
|
-
onChange={(patch) => run('character.update', { characterId: entry.id, patch })}
|
|
20
18
|
onPose={() => openStudio(entry.id)}
|
|
21
19
|
posing={posing === entry.id}
|
|
22
20
|
onRemove={index > 0 ? () => run('character.remove', { characterId: entry.id }) : undefined}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import Foldout from "./Foldout.jsx";
|
|
2
2
|
import { ko, isKo } from "../locale.js";
|
|
3
3
|
import { footageSummary, trajectoryReceipt, segmentationReceipt } from "../multimodel-ingest.js";
|
|
4
|
+
import { isImeComposing } from "../ime.js";
|
|
4
5
|
|
|
5
6
|
export default function VideoCapturePanel({
|
|
6
7
|
isCharacterSelection, multiModelStatus, multiModelStage, multiModelFileRef, chooseMultiModelFile,
|
|
@@ -49,7 +50,7 @@ export default function VideoCapturePanel({
|
|
|
49
50
|
type="text"
|
|
50
51
|
value={multiModelUrl}
|
|
51
52
|
onChange={(event) => setMultiModelUrl(event.target.value)}
|
|
52
|
-
onKeyDown={(event) => { if (event.key === "Enter") useMultiModelUrl(); }}
|
|
53
|
+
onKeyDown={(event) => { if (event.key === "Enter" && !isImeComposing(event)) useMultiModelUrl(); }}
|
|
53
54
|
placeholder={ko("https://…/boxing.mp4", "https://…/boxing.mp4")}
|
|
54
55
|
aria-label={ko("Multi-Model video URL", "멀티 모델 영상 URL")}
|
|
55
56
|
spellCheck={false}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
@layer regions {
|
|
2
|
+
.pose-image { display: grid; gap: 8px; padding: 4px 12px 12px; }
|
|
3
|
+
.pose-image-label { white-space: nowrap; display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--text-2, #9b9ba1); font-size: 11px; font-weight: 500; }
|
|
4
|
+
.pose-image-label small { font-size: 11px; font-variant-numeric: tabular-nums; }
|
|
5
|
+
.pose-image-frame { display: grid; gap: 6px; }
|
|
6
|
+
.pose-image-source { display: grid; grid-template-columns: 1fr 1fr; padding: 2px; gap: 2px; border-radius: 8px; background: var(--surface-raised, #1e1e21); border: 1px solid var(--row-divider, #2a2a2e); }
|
|
7
|
+
.pose-image-source button { padding: 4px 6px; border: 0; border-radius: 6px; background: none; color: var(--text-2, #9b9ba1); font: inherit; font-size: 12px; cursor: pointer; }
|
|
8
|
+
.pose-image-source button[data-active="true"] { background: var(--surface, #fff); color: var(--text-1, #18181b); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); font-weight: 600; }
|
|
9
|
+
.pose-image-shot { display: block; width: 100%; border-radius: var(--r-1, 4px); border: 1px solid var(--row-divider, #2a2a2e); background: #000; }
|
|
10
|
+
.pose-image-refs { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; }
|
|
11
|
+
.pose-image-ref { position: relative; aspect-ratio: 1; border-radius: var(--r-1, 4px); overflow: hidden; border: 1px solid var(--row-divider, #2a2a2e); background: var(--surface-raised, #1e1e21); }
|
|
12
|
+
.pose-image-ref img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
13
|
+
.pose-image-ref-add { display: grid; place-items: center; padding: 0; color: var(--text-2, #9b9ba1); font-size: 18px; cursor: pointer; border-style: dashed; }
|
|
14
|
+
.pose-image-ref-add:hover { color: var(--text-1, #e4e4e7); border-color: var(--text-2, #9b9ba1); }
|
|
15
|
+
.pose-image-ref-remove { position: absolute; top: 2px; right: 2px; width: 18px; height: 18px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 50%; background: rgba(0, 0, 0, 0.6); color: #fff; font-size: 12px; line-height: 1; cursor: pointer; opacity: 0; transition: opacity 120ms; }
|
|
16
|
+
.pose-image-ref:hover .pose-image-ref-remove, .pose-image-ref-remove:focus-visible { opacity: 1; }
|
|
17
|
+
.pose-image-prompt { width: 100%; box-sizing: border-box; resize: vertical; min-height: 60px; padding: 6px 8px; font: inherit; font-size: 12px; color: var(--text-1, #e4e4e7); background: var(--surface-raised, #1e1e21); border: 1px solid var(--row-divider, #2a2a2e); border-radius: var(--r-1, 4px); }
|
|
18
|
+
.pose-image-prompt:focus { outline: none; border-color: var(--select, #e8a33d); }
|
|
19
|
+
.pose-image .pose-image-error { color: var(--danger, #e5484d); margin: 0; }
|
|
20
|
+
.pose-image-result { display: grid; gap: 6px; }
|
|
21
|
+
.pose-image-result-open { display: block; padding: 0; border: 0; background: none; cursor: zoom-in; }
|
|
22
|
+
.pose-image-result-open img { display: block; width: 100%; border-radius: var(--r-1, 4px); border: 1px solid var(--row-divider, #2a2a2e); }
|
|
23
|
+
.pose-image-result-open:hover img { border-color: var(--select, #e8a33d); }
|
|
24
|
+
.pose-image-result-actions { display: flex; gap: 4px; }
|
|
25
|
+
.pose-image-result-actions > * { flex: 1; justify-content: center; text-align: center; text-decoration: none; }
|
|
26
|
+
.pose-image-history { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 4px; }
|
|
27
|
+
.pose-image-history button { padding: 0; aspect-ratio: 16 / 9; border: 1px solid var(--row-divider, #2a2a2e); border-radius: var(--r-1, 4px); overflow: hidden; background: none; cursor: pointer; opacity: 0.6; }
|
|
28
|
+
.pose-image-history button[data-active="true"] { opacity: 1; border-color: var(--select, #e8a33d); }
|
|
29
|
+
.pose-image-history img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.pose-image-viewer { position: fixed; inset: 0; z-index: 10000; display: grid; place-items: center; padding: 32px 72px; background: rgba(8, 8, 10, 0.82); backdrop-filter: blur(6px); cursor: zoom-out; }
|
|
33
|
+
.pose-image-viewer-body { margin: 0; display: grid; gap: 10px; max-width: 100%; max-height: 100%; cursor: default; }
|
|
34
|
+
.pose-image-viewer-body img { display: block; max-width: calc(100vw - 144px); max-height: calc(100vh - 120px); object-fit: contain; border-radius: 8px; box-shadow: 0 12px 48px rgba(0, 0, 0, 0.5); }
|
|
35
|
+
.pose-image-viewer-body figcaption { display: flex; align-items: center; gap: 8px; color: rgba(255, 255, 255, 0.8); font-size: 12px; }
|
|
36
|
+
.pose-image-viewer-prompt { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
37
|
+
.pose-image-viewer-count { font-variant-numeric: tabular-nums; color: rgba(255, 255, 255, 0.55); }
|
|
38
|
+
.pose-image-viewer-body .btn { flex: none; width: auto; color: #fff; background: rgba(255, 255, 255, 0.12); border-color: transparent; text-decoration: none; }
|
|
39
|
+
.pose-image-viewer-nav { position: absolute; top: 50%; transform: translateY(-50%); width: 44px; height: 44px; border: 0; border-radius: 50%; background: rgba(255, 255, 255, 0.12); color: #fff; font-size: 26px; line-height: 1; cursor: pointer; }
|
|
40
|
+
.pose-image-viewer-nav:hover { background: rgba(255, 255, 255, 0.22); }
|
|
41
|
+
.pose-image-viewer-nav.prev { left: 16px; }
|
|
42
|
+
.pose-image-viewer-nav.next { right: 16px; }
|
package/src/panels/pose.css
CHANGED
|
@@ -21,6 +21,9 @@
|
|
|
21
21
|
}
|
|
22
22
|
.pose-details .pose-row { min-height: 24px; display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 8px; font-size: 12px; }
|
|
23
23
|
.pose-details .pose-row + .pose-row { border-top: 1px solid var(--row-divider, #2a2a2e); }
|
|
24
|
+
.pose-details .pose-object-opacity { grid-template-columns: auto 1fr 3.5em; }
|
|
25
|
+
.pose-details .pose-object-opacity input { width: 100%; min-width: 0; }
|
|
26
|
+
.pose-details .pose-object-opacity b { text-align: right; }
|
|
24
27
|
.pose-details .pose-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; }
|
|
25
28
|
.pose-details .pose-preview {
|
|
26
29
|
display: grid;
|