cozyclay 2.0.2 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -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 +2 -3
- 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-B41FmPYI.js +1 -0
- package/dist/assets/app-Do-Og5Pu.css +1 -0
- package/dist/assets/app-DwP-tl2x.js +4920 -0
- package/dist/assets/{demo-ByeYp-dY.js → demo-Dt0qZfS7.js} +1 -1
- package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-BlT33CGf.js} +1 -1
- package/dist/assets/{landing-BaXkpFip.js → landing-BSesrCpL.js} +1 -1
- package/dist/assets/scene-asset-sync-BCWDJB1d.js +19 -0
- package/dist/assets/{shot-prompt-CbqWcWYi.css → scene-asset-sync-DjJ_cJ2R.css} +1 -1
- package/dist/assets/{ticket-BcmsnHn5.js → ticket-jqMhqL88.js} +1 -1
- package/dist/assets/workflow-Br-K7Srs.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +328 -222
- package/dist/sitemap.xml +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 +789 -263
- 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 +113 -28
- 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 +58 -14
- package/src/shell/DetailsSlot.jsx +26 -6
- package/src/shell/MenuBar.jsx +78 -6
- package/src/shell/OutlinerSlot.jsx +3 -1
- package/src/shell/TopBar.jsx +17 -3
- package/src/shell/ViewportToolbar.jsx +18 -3
- package/src/shell/dock.css +239 -0
- package/src/shell/glass-regions.css +3 -2
- package/src/shell/log-store.js +4 -4
- package/src/shell/mode.css +20 -0
- package/src/shell/studio-shell-context.js +4 -1
- package/src/shot-authoring.js +69 -6
- package/src/stable-items.js +1 -1
- package/src/stage-attach-frames.js +169 -0
- package/src/stage-canvas-rigs.jsx +402 -0
- package/src/stage-capture.jsx +216 -0
- package/src/stage-characters.jsx +302 -0
- package/src/stage-editor-gizmos.jsx +1229 -0
- package/src/stage-labels-ko.js +204 -0
- package/src/stage-startup-defaults.js +182 -0
- package/src/starter-scenes.js +1 -1
- package/src/storyboard.js +24 -5
- package/src/studio-actions.js +42 -9
- package/src/studio-agent-commands.js +186 -23
- package/src/studio-agent-context.js +11 -9
- package/src/studio-agent-motion.js +2 -1
- package/src/studio-agent-protocol.js +56 -23
- package/src/studio-app-binding.js +52 -23
- package/src/studio-contact-sheet.js +3 -3
- package/src/studio-elements.js +11 -2
- package/src/studio-geometry-facts.js +114 -0
- package/src/styles.css +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/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 +94 -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
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { Euler, Matrix4, Quaternion, Vector3 } from "three";
|
|
2
|
+
import { objectTransformAt } from "./object-path.js";
|
|
3
|
+
import { routeOwnerFor } from "./route-owner.js";
|
|
4
|
+
import { EMPTY_KIND } from "./scene-objects.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Travel through the grouping hierarchy.
|
|
8
|
+
*
|
|
9
|
+
* A route belongs to one record, but a group is one body: a car built from a
|
|
10
|
+
* chassis with its cabin, fenders and wheels parented under it has to drive as
|
|
11
|
+
* a whole when the chassis gets the route. Records stay flat and world-space
|
|
12
|
+
* (a parent only carries its children while authoring), so the carry happens
|
|
13
|
+
* here at sample time: every routed ancestor's motion — where its route puts
|
|
14
|
+
* it, relative to where it was authored — is applied rigidly to everything
|
|
15
|
+
* under it. Scale never rides along: the motion is the routed record's own
|
|
16
|
+
* route sample against its own authored pose, both at its own scale.
|
|
17
|
+
*
|
|
18
|
+
* The inverse holds for an Empty, which is a pure group node: when it has no
|
|
19
|
+
* route of its own but a routed descendant owns the group's travel (a car
|
|
20
|
+
* Empty over its Chassis), it is drawn carried by that descendant's motion, so
|
|
21
|
+
* its marker and gizmo stay on the car. Ordinary meshes keep their authored
|
|
22
|
+
* place — only ancestors carry what is below them.
|
|
23
|
+
*
|
|
24
|
+
* Carried props are left to the attach frame that already places them: a
|
|
25
|
+
* record that rides a character is not walked, and neither is an ancestor
|
|
26
|
+
* that does.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const DEG = Math.PI / 180;
|
|
30
|
+
|
|
31
|
+
const scratchPos = new Vector3();
|
|
32
|
+
const scratchQuat = new Quaternion();
|
|
33
|
+
const scratchScale = new Vector3();
|
|
34
|
+
const scratchEuler = new Euler();
|
|
35
|
+
const scratchAuthored = new Matrix4();
|
|
36
|
+
const scratchOwn = new Matrix4();
|
|
37
|
+
const scratchMotion = new Matrix4();
|
|
38
|
+
|
|
39
|
+
function compose(out, x, y, z, rotX, rot, rotZ, object, order = "XYZ") {
|
|
40
|
+
scratchPos.set(x, y, z);
|
|
41
|
+
scratchQuat.setFromEuler(scratchEuler.set(rotX * DEG, rot * DEG, rotZ * DEG, order));
|
|
42
|
+
scratchScale.set(object.scaleX ?? 1, object.scaleY ?? 1, object.scaleZ ?? 1);
|
|
43
|
+
return out.compose(scratchPos, scratchQuat, scratchScale);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function authoredMatrix(object, out) {
|
|
47
|
+
return compose(out, object.x ?? 0, object.y ?? 0, object.z ?? 0, object.rotX ?? 0, object.rot ?? 0, object.rotZ ?? 0, object);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The pose a route sample gives a record, as the Euler triple of
|
|
52
|
+
* R = Ry(yaw) · Rx(pitch) · Rz(bank), applied as three.js's "YXZ" order: the
|
|
53
|
+
* yaw turns the body in the world, the pitch tilts it about its own lateral
|
|
54
|
+
* axis, the bank rolls it about its own forward axis. An authored rotX/rotZ is
|
|
55
|
+
* the body's own pitch and roll, so it stays about the body's axes however the
|
|
56
|
+
* heading turns (in the plain XYZ order rotX stayed about world X, and a pitch
|
|
57
|
+
* became a roll once the route turned). The route's lean rides on top:
|
|
58
|
+
* `bank` positive = right side down, `pitch` positive = nose up, which is
|
|
59
|
+
* the opposite sign to rotX (positive rotX tips the nose down).
|
|
60
|
+
*/
|
|
61
|
+
export function travelPose(object, at) {
|
|
62
|
+
return {
|
|
63
|
+
rotX: (object.rotX ?? 0) - (at.pitch ?? 0),
|
|
64
|
+
rot: at.rot ?? object.rot ?? 0,
|
|
65
|
+
rotZ: (object.rotZ ?? 0) + (at.bank ?? 0),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The record's own route sample as a matrix, or null when it does not travel. */
|
|
70
|
+
function ownTravelMatrix(object, frame, take, out) {
|
|
71
|
+
if (!object.path) return null;
|
|
72
|
+
const at = objectTransformAt(object, frame, take);
|
|
73
|
+
if (!at) return null;
|
|
74
|
+
const pose = travelPose(object, at);
|
|
75
|
+
return compose(out, at.x, at.y, at.z, pose.rotX, pose.rot, pose.rotZ, object, "YXZ");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const asLookup = (objects) => {
|
|
79
|
+
if (objects instanceof Map) return objects;
|
|
80
|
+
const lookup = new Map();
|
|
81
|
+
for (const object of objects ?? []) if (object && typeof object.id === "string") lookup.set(object.id, object);
|
|
82
|
+
return lookup;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The world matrix a record is drawn with at `frame`, or null when neither it
|
|
87
|
+
* nor any ancestor travels (its authored transform is then the answer).
|
|
88
|
+
*
|
|
89
|
+
* @param {Map<string, object>|object[]} objects authored records, by id or as the list
|
|
90
|
+
*/
|
|
91
|
+
/** Parent first, up to the root; stops at a cycle, a missing record or a carried one. */
|
|
92
|
+
function ancestorsOf(lookup, object) {
|
|
93
|
+
const chain = [];
|
|
94
|
+
const seen = new Set([object.id]);
|
|
95
|
+
let parentId = typeof object.parent === "string" ? object.parent : null;
|
|
96
|
+
while (parentId && !seen.has(parentId)) {
|
|
97
|
+
seen.add(parentId);
|
|
98
|
+
const parent = lookup.get(parentId);
|
|
99
|
+
if (!parent || parent.attach) break;
|
|
100
|
+
chain.push(parent);
|
|
101
|
+
parentId = typeof parent.parent === "string" ? parent.parent : null;
|
|
102
|
+
}
|
|
103
|
+
return chain;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const scratchRide = new Matrix4();
|
|
107
|
+
|
|
108
|
+
/** The routed descendant an Empty rides, or null for anything else. */
|
|
109
|
+
function ridden(lookup, object) {
|
|
110
|
+
if (object.path || object.attach || object.renderer !== EMPTY_KIND) return null;
|
|
111
|
+
const owner = routeOwnerFor(lookup, object.id);
|
|
112
|
+
return owner && owner !== object ? owner : null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function sceneObjectTravelMatrixAt(objects, id, frame, take = {}, out = new Matrix4()) {
|
|
116
|
+
const lookup = asLookup(objects);
|
|
117
|
+
const object = lookup.get(id);
|
|
118
|
+
if (!object || object.attach) return null;
|
|
119
|
+
const owner = ridden(lookup, object);
|
|
120
|
+
// The descendant's whole carry (its own ancestors' motion included, which
|
|
121
|
+
// the Empty shares), applied to where the Empty was authored. Its own scale
|
|
122
|
+
// never enters: the carry is rigid.
|
|
123
|
+
if (owner && sceneObjectCarryMatrixAt(lookup, owner.id, frame, take, scratchRide)) {
|
|
124
|
+
return out.multiplyMatrices(scratchRide, authoredMatrix(object, scratchAuthored));
|
|
125
|
+
}
|
|
126
|
+
const chain = ancestorsOf(lookup, object);
|
|
127
|
+
let moved = false;
|
|
128
|
+
scratchMotion.identity();
|
|
129
|
+
for (let index = chain.length - 1; index >= 0; index -= 1) {
|
|
130
|
+
const ancestor = chain[index];
|
|
131
|
+
if (!ownTravelMatrix(ancestor, frame, take, scratchOwn)) continue;
|
|
132
|
+
scratchMotion.multiply(scratchOwn).multiply(authoredMatrix(ancestor, scratchAuthored).invert());
|
|
133
|
+
moved = true;
|
|
134
|
+
}
|
|
135
|
+
const own = ownTravelMatrix(object, frame, take, scratchOwn);
|
|
136
|
+
if (!moved && !own) return null;
|
|
137
|
+
return out.multiplyMatrices(scratchMotion, own ?? authoredMatrix(object, scratchAuthored));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const scratchCarry = new Matrix4();
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The rigid motion a record's travel hands to whatever rides it at `frame`:
|
|
144
|
+
* where it is drawn against where it was authored. Its own scale cancels out,
|
|
145
|
+
* so a rider keeps its size under a scaled chassis. Null when the record does
|
|
146
|
+
* not travel (nor anything above it), is missing, or is carried by a character.
|
|
147
|
+
*/
|
|
148
|
+
export function sceneObjectCarryMatrixAt(objects, id, frame, take = {}, out = new Matrix4()) {
|
|
149
|
+
const lookup = asLookup(objects);
|
|
150
|
+
const object = lookup.get(id);
|
|
151
|
+
if (!object || object.attach) return null;
|
|
152
|
+
if (!sceneObjectTravelMatrixAt(lookup, id, frame, take, out)) return null;
|
|
153
|
+
return out.multiply(authoredMatrix(object, scratchCarry).invert());
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const scratchCarried = new Matrix4();
|
|
157
|
+
const scratchPoint = new Vector3();
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Where a point riding `objectId` stands at `frame`: the point (a grouped
|
|
161
|
+
* character's played root) through the object's carry. The point comes back
|
|
162
|
+
* unchanged when there is no object or it does not travel; `y` is used for
|
|
163
|
+
* the carry and returned only when the point had one.
|
|
164
|
+
*/
|
|
165
|
+
export function carriedPointAt(objects, objectId, point, frame, take = {}) {
|
|
166
|
+
if (!point || !objectId) return point;
|
|
167
|
+
const carry = sceneObjectCarryMatrixAt(objects, objectId, frame, take, scratchCarried);
|
|
168
|
+
if (!carry) return point;
|
|
169
|
+
scratchPoint.set(point.x, point.y ?? 0, point.z).applyMatrix4(carry);
|
|
170
|
+
return point.y === undefined ? { x: scratchPoint.x, z: scratchPoint.z } : { x: scratchPoint.x, y: scratchPoint.y, z: scratchPoint.z };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const wrapDegrees = (value) => {
|
|
174
|
+
const wrapped = ((((value + 180) % 360) + 360) % 360) - 180;
|
|
175
|
+
return wrapped === -180 ? 180 : wrapped;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The records as they stand at `frame`: x/y/z and rotation replaced for every
|
|
180
|
+
* record that travels on its own route or under a routed ancestor. Untouched
|
|
181
|
+
* records are returned as-is, and so is the list when nothing moves.
|
|
182
|
+
*/
|
|
183
|
+
export function sceneObjectsAt(objects, frame, take = {}) {
|
|
184
|
+
if (!objects.some((object) => object.path)) return objects;
|
|
185
|
+
const lookup = asLookup(objects);
|
|
186
|
+
const matrix = new Matrix4();
|
|
187
|
+
const position = new Vector3();
|
|
188
|
+
const quaternion = new Quaternion();
|
|
189
|
+
const scale = new Vector3();
|
|
190
|
+
const euler = new Euler();
|
|
191
|
+
/** The record for a pose given as a matrix: x/y/z plus the XYZ Euler the records speak. */
|
|
192
|
+
const recordAt = (object, pose) => {
|
|
193
|
+
pose.decompose(position, quaternion, scale);
|
|
194
|
+
const next = { ...object, x: position.x, y: position.y, z: position.z };
|
|
195
|
+
// A pure turn about the vertical keeps the authored zero pitch/roll and
|
|
196
|
+
// reads back as one yaw, instead of the 180/x/180 Euler a decompose of a
|
|
197
|
+
// large yaw gives.
|
|
198
|
+
if (!(object.rotX ?? 0) && !(object.rotZ ?? 0) && Math.abs(quaternion.x) < 1e-9 && Math.abs(quaternion.z) < 1e-9) {
|
|
199
|
+
next.rot = wrapDegrees((2 * Math.atan2(quaternion.y, quaternion.w)) / DEG);
|
|
200
|
+
} else {
|
|
201
|
+
euler.setFromQuaternion(quaternion, "XYZ");
|
|
202
|
+
next.rotX = euler.x / DEG;
|
|
203
|
+
next.rot = euler.y / DEG;
|
|
204
|
+
next.rotZ = euler.z / DEG;
|
|
205
|
+
}
|
|
206
|
+
return next;
|
|
207
|
+
};
|
|
208
|
+
return objects.map((object) => {
|
|
209
|
+
if (object.attach || (!ancestorsOf(lookup, object).some((ancestor) => ancestor.path) && !ridden(lookup, object))) {
|
|
210
|
+
// Nothing above it travels (and nothing below carries it): the route
|
|
211
|
+
// sample alone, exactly as before the hierarchy carry — for a carried
|
|
212
|
+
// prop, in the frame its numbers already live in.
|
|
213
|
+
const at = objectTransformAt(object, frame, take);
|
|
214
|
+
if (!at) return object;
|
|
215
|
+
// A level, upright route is a yaw and a place. One that leans, or a
|
|
216
|
+
// body with its own pitch/roll, is an orientation (travelPose).
|
|
217
|
+
if (!at.bank && !at.pitch && !(object.rotX ?? 0) && !(object.rotZ ?? 0)) return { ...object, x: at.x, y: at.y, z: at.z, rot: at.rot ?? object.rot };
|
|
218
|
+
const pose = travelPose(object, at);
|
|
219
|
+
return recordAt(object, compose(matrix, at.x, at.y, at.z, pose.rotX, pose.rot, pose.rotZ, object, "YXZ"));
|
|
220
|
+
}
|
|
221
|
+
if (!sceneObjectTravelMatrixAt(lookup, object.id, frame, take, matrix)) return object;
|
|
222
|
+
return recordAt(object, matrix);
|
|
223
|
+
});
|
|
224
|
+
}
|
package/src/otio.js
CHANGED
|
@@ -5,9 +5,10 @@ import { isEffectivelyHidden } from "./scene-objects.js";
|
|
|
5
5
|
import { sampleAt } from "./sample-at.js";
|
|
6
6
|
import { TIMELINE_FRAME_FPS } from "./scenes.js";
|
|
7
7
|
import { usedSensorHeightMm } from "./shot.js";
|
|
8
|
+
import { shotHold } from "./shot-authoring.js";
|
|
8
9
|
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
const OTIO_TIMELINE_FPS = TIMELINE_FRAME_FPS;
|
|
11
|
+
const OTIO_START_TIMECODE = "00:00:00:00";
|
|
11
12
|
|
|
12
13
|
const finiteOr = (value, fallback = 0) => Number.isFinite(value) ? value : fallback;
|
|
13
14
|
|
|
@@ -143,6 +144,10 @@ function gap(startFrame, durationFrames) {
|
|
|
143
144
|
|
|
144
145
|
function clip(scene, shot) {
|
|
145
146
|
const metadata = shotMetadata(scene, shot);
|
|
147
|
+
// A still (storyboard panel) is one picture held for its hold: the clip
|
|
148
|
+
// runs exactly that many frames and says so.
|
|
149
|
+
const still = shot.kind === "still";
|
|
150
|
+
if (still) Object.assign(metadata, { kind: "still", hold: shotHold(shot), caption: typeof shot.caption === "string" ? shot.caption : "" });
|
|
146
151
|
return {
|
|
147
152
|
OTIO_SCHEMA: "Clip.1",
|
|
148
153
|
effects: [],
|
|
@@ -156,7 +161,7 @@ function clip(scene, shot) {
|
|
|
156
161
|
},
|
|
157
162
|
metadata: { cozyclay: metadata },
|
|
158
163
|
name: typeof shot.name === "string" && shot.name.trim() ? shot.name.trim() : "Shot",
|
|
159
|
-
source_range: timeRange(0, shot.endFrame - shot.startFrame + 1),
|
|
164
|
+
source_range: timeRange(0, still ? shotHold(shot) : shot.endFrame - shot.startFrame + 1),
|
|
160
165
|
};
|
|
161
166
|
}
|
|
162
167
|
|
|
@@ -2,9 +2,17 @@ import Foldout from "./Foldout.jsx";
|
|
|
2
2
|
import { ko } from "../locale.js";
|
|
3
3
|
import { Vector3Row, Slider } from "../ui.jsx";
|
|
4
4
|
import { useCastTransaction } from '../domains/cast.js';
|
|
5
|
+
import { useContext } from "react";
|
|
6
|
+
import { AppContext } from "../app-context.js";
|
|
7
|
+
import { shotAtFrame } from "../cuts.js";
|
|
5
8
|
|
|
6
9
|
export default function CharacterTransformPanel({ workflowMode, isCharacterSelection, activeChar }) {
|
|
7
10
|
const { run, begin, commit } = useCastTransaction();
|
|
11
|
+
const app = useContext(AppContext);
|
|
12
|
+
const live = app?.ports.read?.();
|
|
13
|
+
const still = live ? shotAtFrame(live.shots, live.view.frame) : null;
|
|
14
|
+
const shown = live?.targets?.get?.(activeChar.id)?.motion || activeChar.layer?.waypoints?.length
|
|
15
|
+
? activeChar : { ...activeChar, ...(still?.cast?.[activeChar.id] ?? {}) };
|
|
8
16
|
return (
|
|
9
17
|
<Foldout
|
|
10
18
|
key={workflowMode === "motion" ? "placement" : "transform"}
|
|
@@ -20,11 +28,16 @@ export default function CharacterTransformPanel({ workflowMode, isCharacterSelec
|
|
|
20
28
|
<Vector3Row
|
|
21
29
|
label={ko("Position", "위치")}
|
|
22
30
|
fields={[
|
|
23
|
-
{ axis: "X", value:
|
|
24
|
-
{ axis: "Z", value:
|
|
31
|
+
{ axis: "X", value: shown.x, step: 0.05, precision: 2, scrubRange: 5, onChange: (x) => run('character.update', { characterId: activeChar.id, patch: { x } }), onScrubStart: begin, onScrubEnd: commit },
|
|
32
|
+
{ axis: "Z", value: shown.z, step: 0.05, precision: 2, scrubRange: 5, onChange: (z) => run('character.update', { characterId: activeChar.id, patch: { z } }), onScrubStart: begin, onScrubEnd: commit },
|
|
25
33
|
]}
|
|
26
34
|
/>
|
|
27
|
-
<Slider compact label={ko("Rotation", "회전")} min={-180} max={180} step={1} value={
|
|
35
|
+
<Slider compact label={ko("Rotation", "회전")} min={-180} max={180} step={1} value={shown.rot ?? 0} unit="°" onChange={(rot) => run('character.update', { characterId: activeChar.id, patch: { rot } })} />
|
|
36
|
+
{still?.kind === "still" && (
|
|
37
|
+
<button type="button" className="secondary-button" onClick={() => run("shot.setCastOverride", { shotId: still.id, characterId: activeChar.id, override: null })}>
|
|
38
|
+
{ko("Use stage placement", "무대 배치 사용")}
|
|
39
|
+
</button>
|
|
40
|
+
)}
|
|
28
41
|
</div>
|
|
29
42
|
) : (
|
|
30
43
|
<>
|
|
@@ -34,13 +47,18 @@ export default function CharacterTransformPanel({ workflowMode, isCharacterSelec
|
|
|
34
47
|
<Vector3Row
|
|
35
48
|
label={ko("Position", "위치")}
|
|
36
49
|
fields={[
|
|
37
|
-
{ axis: "X", value:
|
|
50
|
+
{ axis: "X", value: shown.x, step: 0.05, precision: 2, scrubRange: 5, onChange: (x) => run('character.update', { characterId: activeChar.id, patch: { x } }), onScrubStart: begin, onScrubEnd: commit },
|
|
38
51
|
{ axis: "Y", value: activeChar.y ?? 0, step: 0.05, precision: 2, scrubRange: 5, onChange: (y) => run('character.update', { characterId: activeChar.id, patch: { y } }), onScrubStart: begin, onScrubEnd: commit },
|
|
39
|
-
{ axis: "Z", value:
|
|
52
|
+
{ axis: "Z", value: shown.z, step: 0.05, precision: 2, scrubRange: 5, onChange: (z) => run('character.update', { characterId: activeChar.id, patch: { z } }), onScrubStart: begin, onScrubEnd: commit },
|
|
40
53
|
]}
|
|
41
54
|
/>
|
|
42
|
-
<Slider compact label={ko("Rotation", "회전")} min={-180} max={180} step={1} value={
|
|
55
|
+
<Slider compact label={ko("Rotation", "회전")} min={-180} max={180} step={1} value={shown.rot ?? 0} unit="°" onChange={(rot) => run('character.update', { characterId: activeChar.id, patch: { rot } })} />
|
|
43
56
|
<Slider compact label={ko("Scale", "크기")} min={0.2} max={3} step={0.05} value={activeChar.scale ?? 1} unit="×" onChange={(scale) => run('character.update', { characterId: activeChar.id, patch: { scale } })} />
|
|
57
|
+
{still?.kind === "still" && (
|
|
58
|
+
<button type="button" className="secondary-button" onClick={() => run("shot.setCastOverride", { shotId: still.id, characterId: activeChar.id, override: null })}>
|
|
59
|
+
{ko("Use stage placement", "무대 배치 사용")}
|
|
60
|
+
</button>
|
|
61
|
+
)}
|
|
44
62
|
</>
|
|
45
63
|
)}
|
|
46
64
|
</Foldout>
|
|
@@ -4,9 +4,10 @@ import Foldout from "./Foldout.jsx";
|
|
|
4
4
|
import { ko, isKo } from "../locale.js";
|
|
5
5
|
import { Field, Vector3Row } from "../ui.jsx";
|
|
6
6
|
import { sceneObjectNameDisplayKo } from "../app-stage.jsx";
|
|
7
|
-
import { MESH_KIND, CUTOUT_KIND, CUTOUT_DEFAULT_HEIGHT, OBJECT_COLORS, normalizeObjectColor } from "../scene-objects.js";
|
|
7
|
+
import { MESH_KIND, CUTOUT_KIND, EMPTY_KIND, SCULPT_KIND, CUTOUT_DEFAULT_HEIGHT, OBJECT_COLORS, normalizeObjectColor } from "../scene-objects.js";
|
|
8
8
|
import { MESH_HEIGHT_MIN } from "../scene-mesh.js";
|
|
9
9
|
import { autoColorHex } from "../auto-color.js";
|
|
10
|
+
import { isImeComposing } from "../ime.js";
|
|
10
11
|
|
|
11
12
|
export default function ObjectTransformPanel({
|
|
12
13
|
selectedSceneObject, snapEnabled, setSnapEnabled, attachTargetLabel,
|
|
@@ -39,7 +40,7 @@ export default function ObjectTransformPanel({
|
|
|
39
40
|
? ['run.update', { txId, args: { id: selectedSceneObject.id, patch } }]
|
|
40
41
|
: ['object.update', { id: selectedSceneObject.id, patch }];
|
|
41
42
|
const endKey = event => {
|
|
42
|
-
if (event.key === 'Enter' || event.key === 'Escape') {
|
|
43
|
+
if ((event.key === 'Enter' && !isImeComposing(event)) || event.key === 'Escape') {
|
|
43
44
|
event.stopPropagation(); finish(event.key === 'Enter'); setObjectColorDraft(null);
|
|
44
45
|
}
|
|
45
46
|
};
|
|
@@ -452,7 +453,7 @@ export default function ObjectTransformPanel({
|
|
|
452
453
|
</p>
|
|
453
454
|
</>
|
|
454
455
|
)}
|
|
455
|
-
{selectedSceneObject.renderer !== CUTOUT_KIND && (selectedSceneObject.renderer !== MESH_KIND || selectedSceneObject.clay) && (
|
|
456
|
+
{selectedSceneObject.renderer !== CUTOUT_KIND && selectedSceneObject.renderer !== EMPTY_KIND && selectedSceneObject.renderer !== SCULPT_KIND && (selectedSceneObject.renderer !== MESH_KIND || selectedSceneObject.clay) && (
|
|
456
457
|
// One swatch shows the colour; the row opens only when you want
|
|
457
458
|
// to change it, instead of six chips sitting there all day.
|
|
458
459
|
<details className="object-colors-pop">
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { useEffect, useReducer, useRef, useState } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
import Foldout from "./Foldout.jsx";
|
|
4
|
+
import { ko, isKo } from "../locale.js";
|
|
5
|
+
import { readReferenceImage } from "./ReferenceImageField.jsx";
|
|
6
|
+
import { createHttpTransport } from "../workflow/agent-client.js";
|
|
7
|
+
import "./pose-image.css";
|
|
8
|
+
|
|
9
|
+
// The sidecar accepts at most 6 references per request, shared with the scene's identity and
|
|
10
|
+
// environment sheets.
|
|
11
|
+
const REFERENCES_MAX = 6;
|
|
12
|
+
const USER_REFERENCES_MAX = 4;
|
|
13
|
+
const RESULTS_KEPT = 8;
|
|
14
|
+
|
|
15
|
+
// Module-level so leaving Pose mode (which unmounts the Details pane) keeps the references, the
|
|
16
|
+
// prompt, the results and an in-flight generation.
|
|
17
|
+
// `shot` is the pinned capture ({ dataUrl, references }): Generate sends exactly what the panel shows.
|
|
18
|
+
const store = { source: "view", refs: [], prompt: "", shot: null, results: [], selected: 0, busySince: null, error: "", listeners: new Set() };
|
|
19
|
+
function setPoseImageState(patch) {
|
|
20
|
+
Object.assign(store, patch);
|
|
21
|
+
for (const listener of store.listeners) listener();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function poseImageErrorMessage(error) {
|
|
25
|
+
if (error?.status === 401 || error?.code === "auth") return ko("Sign in with ChatGPT in the Agent panel first.", "먼저 Agent 패널에서 ChatGPT로 로그인하세요.");
|
|
26
|
+
return isKo ? `생성 실패 — ${error?.message || "알 수 없는 오류"}` : `Generation failed — ${error?.message || "unknown error"}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function generatePoseImage(capturePoseImage) {
|
|
30
|
+
const prompt = store.prompt.trim();
|
|
31
|
+
if (!prompt || store.busySince) return;
|
|
32
|
+
const shot = store.shot ?? capturePoseImage?.(store.source);
|
|
33
|
+
if (!shot) { setPoseImageState({ error: ko("The shot renderer is not ready yet.", "샷 렌더러가 아직 준비되지 않았어요.") }); return; }
|
|
34
|
+
const references = [
|
|
35
|
+
...shot.references,
|
|
36
|
+
...store.refs.map((dataUrl, index) => ({ role: "reference", name: String(index + 1), dataUrl })),
|
|
37
|
+
];
|
|
38
|
+
if (references.length > REFERENCES_MAX) {
|
|
39
|
+
setPoseImageState({ shot, error: isKo
|
|
40
|
+
? `참조 이미지는 최대 ${REFERENCES_MAX}장이에요 (인물·환경 참조 ${shot.references.length}장 포함).`
|
|
41
|
+
: `At most ${REFERENCES_MAX} reference images (${shot.references.length} identity/environment included).` });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
setPoseImageState({ shot, busySince: Date.now(), error: "" });
|
|
45
|
+
try {
|
|
46
|
+
const output = await createHttpTransport().image({ prompt, imageDataUrl: shot.dataUrl, references, composition: "frame", quality: "auto" });
|
|
47
|
+
setPoseImageState({ results: [{ dataUrl: output.dataUrl, prompt, at: Date.now() }, ...store.results].slice(0, RESULTS_KEPT), selected: 0 });
|
|
48
|
+
} catch (error) {
|
|
49
|
+
setPoseImageState({ error: poseImageErrorMessage(error) });
|
|
50
|
+
} finally {
|
|
51
|
+
setPoseImageState({ busySince: null });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default function PoseImagePanel({ hidden, capturePoseImage }) {
|
|
56
|
+
const [, rerender] = useReducer((tick) => tick + 1, 0);
|
|
57
|
+
const [viewing, setViewing] = useState(false);
|
|
58
|
+
const fileRef = useRef(null);
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
store.listeners.add(rerender);
|
|
61
|
+
return () => { store.listeners.delete(rerender); };
|
|
62
|
+
}, []);
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!store.busySince) return undefined;
|
|
65
|
+
const timer = setInterval(rerender, 1000);
|
|
66
|
+
return () => clearInterval(timer);
|
|
67
|
+
}, [store.busySince]);
|
|
68
|
+
|
|
69
|
+
const { source, refs, prompt, shot, results, selected, busySince, error } = store;
|
|
70
|
+
const capture = (next = source) => setPoseImageState({ source: next, shot: capturePoseImage?.(next) ?? null });
|
|
71
|
+
const result = results[selected] ?? null;
|
|
72
|
+
const elapsed = busySince ? Math.max(0, Math.round((Date.now() - busySince) / 1000)) : 0;
|
|
73
|
+
const addFiles = async (files) => {
|
|
74
|
+
const room = USER_REFERENCES_MAX - store.refs.length;
|
|
75
|
+
const picked = [...files].filter((file) => file.type.startsWith("image/")).slice(0, room);
|
|
76
|
+
try {
|
|
77
|
+
const read = await Promise.all(picked.map((file) => readReferenceImage(file)));
|
|
78
|
+
setPoseImageState({ refs: [...store.refs, ...read].slice(0, USER_REFERENCES_MAX), error: "" });
|
|
79
|
+
} catch (failure) {
|
|
80
|
+
setPoseImageState({ error: isKo ? `이미지를 불러오지 못했어요 — ${failure.message}` : `Could not load that image — ${failure.message}` });
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<Foldout hidden={hidden} title={ko("Image from pose", "포즈로 이미지 생성")}>
|
|
86
|
+
<div className="pose-image" data-testid="pose-image">
|
|
87
|
+
<div className="pose-image-frame">
|
|
88
|
+
<div className="pose-image-source" role="radiogroup" aria-label={ko("Frame source", "프레임 시점")}>
|
|
89
|
+
{[["view", ko("Current view", "지금 보는 화면")], ["shot", ko("Shot camera", "샷 카메라")]].map(([id, label]) => (
|
|
90
|
+
<button key={id} type="button" role="radio" aria-checked={source === id} data-active={source === id ? "true" : undefined}
|
|
91
|
+
data-testid={`pose-image-source-${id}`} onClick={() => capture(id)}>{label}</button>
|
|
92
|
+
))}
|
|
93
|
+
</div>
|
|
94
|
+
<div className="pose-image-label">
|
|
95
|
+
<span>{shot ? ko("This frame is sent", "이 프레임으로 생성해요") : source === "view" ? ko("Frame the viewport, then capture", "뷰포트를 맞춘 뒤 캡처") : ko("The shot camera's view", "샷 카메라 시점")}</span>
|
|
96
|
+
<button type="button" className="btn ghost small" data-testid="pose-image-capture" onClick={() => capture()}>
|
|
97
|
+
{shot ? ko("Recapture", "다시 캡처") : ko("Capture", "캡처")}
|
|
98
|
+
</button>
|
|
99
|
+
</div>
|
|
100
|
+
{shot
|
|
101
|
+
? <img className="pose-image-shot" src={shot.dataUrl} alt={ko("The frame sent to the image model", "이미지 모델에 보낼 프레임")} />
|
|
102
|
+
: <p className="inspector-hint">{ko("Not captured yet — Generate captures this view.", "아직 캡처 전 — 생성하면 지금 화면을 캡처해요.")}</p>}
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div className="pose-image-label"><span>{ko("References", "참조 이미지")}</span><small>{refs.length}/{USER_REFERENCES_MAX}</small></div>
|
|
106
|
+
<div className="pose-image-refs"
|
|
107
|
+
onDragOver={(event) => { if (event.dataTransfer?.types?.includes("Files")) event.preventDefault(); }}
|
|
108
|
+
onDrop={(event) => { event.preventDefault(); addFiles(event.dataTransfer?.files ?? []); }}>
|
|
109
|
+
{refs.map((dataUrl, index) => (
|
|
110
|
+
<div key={index} className="pose-image-ref">
|
|
111
|
+
<img src={dataUrl} alt={isKo ? `참조 ${index + 1}` : `Reference ${index + 1}`} />
|
|
112
|
+
<button type="button" className="pose-image-ref-remove" aria-label={ko("Remove reference", "참조 삭제")}
|
|
113
|
+
onClick={() => setPoseImageState({ refs: store.refs.filter((_, at) => at !== index) })}>×</button>
|
|
114
|
+
</div>
|
|
115
|
+
))}
|
|
116
|
+
{refs.length < USER_REFERENCES_MAX && (
|
|
117
|
+
<button type="button" className="pose-image-ref pose-image-ref-add" data-testid="pose-image-add-ref"
|
|
118
|
+
title={ko("Add a reference picture (or drop one here)", "참조 이미지 추가 (여기로 끌어놓아도 돼요)")}
|
|
119
|
+
onClick={() => fileRef.current?.click()}>+</button>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
<input ref={fileRef} type="file" accept="image/*" multiple className="multimodel-file-input" data-testid="pose-image-ref-input"
|
|
123
|
+
onChange={(event) => { const files = [...(event.target.files ?? [])]; event.target.value = ""; addFiles(files); }} />
|
|
124
|
+
|
|
125
|
+
<textarea
|
|
126
|
+
className="pose-image-prompt"
|
|
127
|
+
data-testid="pose-image-prompt"
|
|
128
|
+
rows={3}
|
|
129
|
+
value={prompt}
|
|
130
|
+
placeholder={ko("Describe the image — e.g. a knight in silver armour, dusk light, cinematic", "어떤 이미지인지 적어 주세요 — 예: 은빛 갑옷의 기사, 해질녘 빛, 영화 같은 톤")}
|
|
131
|
+
onChange={(event) => setPoseImageState({ prompt: event.target.value })}
|
|
132
|
+
onKeyDown={(event) => { if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { event.preventDefault(); generatePoseImage(capturePoseImage); } }}
|
|
133
|
+
/>
|
|
134
|
+
<button type="button" className="btn primary full" data-testid="pose-image-generate"
|
|
135
|
+
disabled={!prompt.trim() || Boolean(busySince)}
|
|
136
|
+
onClick={() => generatePoseImage(capturePoseImage)}>
|
|
137
|
+
{busySince ? (isKo ? `생성 중… ${elapsed}초` : `Generating… ${elapsed}s`) : ko("Generate image", "이미지 생성")}
|
|
138
|
+
</button>
|
|
139
|
+
{error && <p className="inspector-hint pose-image-error" role="status" data-testid="pose-image-error">{error}</p>}
|
|
140
|
+
|
|
141
|
+
{result && (
|
|
142
|
+
<div className="pose-image-result" data-testid="pose-image-result">
|
|
143
|
+
<button type="button" className="pose-image-result-open" data-testid="pose-image-open"
|
|
144
|
+
title={ko("View larger", "크게 보기")} onClick={() => setViewing(true)}>
|
|
145
|
+
<img src={result.dataUrl} alt={result.prompt} />
|
|
146
|
+
</button>
|
|
147
|
+
<div className="pose-image-result-actions">
|
|
148
|
+
<a className="btn ghost small" href={result.dataUrl} download={`cozyclay-pose-${result.at}.png`}>{ko("Download", "다운로드")}</a>
|
|
149
|
+
<button type="button" className="btn ghost small" disabled={refs.length >= USER_REFERENCES_MAX}
|
|
150
|
+
onClick={() => setPoseImageState({ refs: [...store.refs, result.dataUrl].slice(0, USER_REFERENCES_MAX) })}>
|
|
151
|
+
{ko("Use as reference", "참조로 쓰기")}
|
|
152
|
+
</button>
|
|
153
|
+
</div>
|
|
154
|
+
{results.length > 1 && (
|
|
155
|
+
<div className="pose-image-history">
|
|
156
|
+
{results.map((entry, index) => (
|
|
157
|
+
<button key={entry.at} type="button" data-active={index === selected ? "true" : undefined}
|
|
158
|
+
title={entry.prompt} onClick={() => setPoseImageState({ selected: index })}>
|
|
159
|
+
<img src={entry.dataUrl} alt="" />
|
|
160
|
+
</button>
|
|
161
|
+
))}
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
</div>
|
|
167
|
+
{viewing && result && createPortal(
|
|
168
|
+
<PoseImageViewer results={results} selected={selected} onSelect={(index) => setPoseImageState({ selected: index })} onClose={() => setViewing(false)} />,
|
|
169
|
+
document.body,
|
|
170
|
+
)}
|
|
171
|
+
</Foldout>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function PoseImageViewer({ results, selected, onSelect, onClose }) {
|
|
176
|
+
const entry = results[selected];
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
const onKey = (event) => {
|
|
179
|
+
if (event.key === "Escape") onClose();
|
|
180
|
+
else if (event.key === "ArrowLeft" && selected < results.length - 1) onSelect(selected + 1);
|
|
181
|
+
else if (event.key === "ArrowRight" && selected > 0) onSelect(selected - 1);
|
|
182
|
+
else return;
|
|
183
|
+
event.preventDefault();
|
|
184
|
+
event.stopPropagation();
|
|
185
|
+
};
|
|
186
|
+
window.addEventListener("keydown", onKey, true);
|
|
187
|
+
return () => window.removeEventListener("keydown", onKey, true);
|
|
188
|
+
}, [results.length, selected, onSelect, onClose]);
|
|
189
|
+
return (
|
|
190
|
+
<div className="pose-image-viewer" role="dialog" aria-modal="true" aria-label={ko("Generated image", "생성된 이미지")} data-testid="pose-image-viewer"
|
|
191
|
+
onClick={(event) => { if (event.target === event.currentTarget) onClose(); }}>
|
|
192
|
+
<figure className="pose-image-viewer-body">
|
|
193
|
+
<img src={entry.dataUrl} alt={entry.prompt} />
|
|
194
|
+
<figcaption>
|
|
195
|
+
<span className="pose-image-viewer-prompt">{entry.prompt}</span>
|
|
196
|
+
{results.length > 1 && <span className="pose-image-viewer-count">{results.length - selected}/{results.length}</span>}
|
|
197
|
+
<a className="btn ghost small" href={entry.dataUrl} download={`cozyclay-pose-${entry.at}.png`}>{ko("Download", "다운로드")}</a>
|
|
198
|
+
<button type="button" className="btn ghost small" data-testid="pose-image-viewer-close" onClick={onClose}>{ko("Close", "닫기")}</button>
|
|
199
|
+
</figcaption>
|
|
200
|
+
</figure>
|
|
201
|
+
{selected < results.length - 1 && <button type="button" className="pose-image-viewer-nav prev" aria-label={ko("Older", "이전")} onClick={() => onSelect(selected + 1)}>‹</button>}
|
|
202
|
+
{selected > 0 && <button type="button" className="pose-image-viewer-nav next" aria-label={ko("Newer", "다음")} onClick={() => onSelect(selected - 1)}>›</button>}
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
@@ -21,9 +21,11 @@ export default function PromptBlocksPanel({
|
|
|
21
21
|
readinessState, ardyRunning, cancelArdy, ardyStatus, ardyOutcome,
|
|
22
22
|
}) {
|
|
23
23
|
const { run, begin, commit, characterId } = useCastTransaction();
|
|
24
|
-
const { refineDisabledReason, workflowMode } = useStudioShell();
|
|
24
|
+
const { refineDisabledReason, workflowMode, previsMode } = useStudioShell();
|
|
25
25
|
const selectedClip = promptClips.find((clip) => clip.id === selectedPromptId) ?? null;
|
|
26
26
|
const refineReason = refineDisabledReason();
|
|
27
|
+
// A storyboard has no prompt blocks to author or edit.
|
|
28
|
+
if (previsMode === "storyboard") return null;
|
|
27
29
|
// Generate, Start over, Take it again and Add block live in the top bar's
|
|
28
30
|
// Generate Motion button and its caret menu, their one home (R1).
|
|
29
31
|
return (
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import Foldout from "./Foldout.jsx";
|
|
2
|
+
import { ko } from "../locale.js";
|
|
3
|
+
import { POSTURES, isProxyFigure } from "../scenes.js";
|
|
4
|
+
import { useBus, AppContext } from "../app-context.js";
|
|
5
|
+
import { useContext } from "react";
|
|
6
|
+
import { shotAtFrame } from "../cuts.js";
|
|
7
|
+
|
|
8
|
+
const POSTURE_LABELS = {
|
|
9
|
+
stand: () => ko("Stand", "서기"),
|
|
10
|
+
sit: () => ko("Sit", "앉기"),
|
|
11
|
+
lie: () => ko("Lie", "눕기"),
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Side-on silhouettes on a 24 grid, the stroke family of the shell's icons.
|
|
15
|
+
const POSTURE_GLYPHS = {
|
|
16
|
+
stand: "M12 3.5a2 2 0 1 1 0 4 2 2 0 1 1 0-4z M12 9.5v11",
|
|
17
|
+
sit: "M10 4a2 2 0 1 1 0 4 2 2 0 1 1 0-4z M10 10v6h6v4.5",
|
|
18
|
+
lie: "M4 13a2 2 0 1 1 0 4 2 2 0 1 1 0-4z M8 15h12.5",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Details for a capsule figure: it has no rig and no bones, so the one body
|
|
22
|
+
* choice is its posture. Rig figures never see this panel. */
|
|
23
|
+
export default function ProxyFigurePanel({ isCharacterSelection, activeChar }) {
|
|
24
|
+
const { run } = useBus();
|
|
25
|
+
const app = useContext(AppContext);
|
|
26
|
+
const live = app?.ports.read?.();
|
|
27
|
+
const shot = live ? shotAtFrame(live.shots, live.view.frame) : null;
|
|
28
|
+
const selectedPosture = live?.targets?.get?.(activeChar.id)?.motion || activeChar.layer?.waypoints?.length
|
|
29
|
+
? activeChar.posture : shot?.cast?.[activeChar.id]?.posture ?? activeChar.posture;
|
|
30
|
+
if (!isProxyFigure(activeChar)) return null;
|
|
31
|
+
return (
|
|
32
|
+
<Foldout hidden={!isCharacterSelection} title={ko("Capsule figure", "캡슐 인물")}>
|
|
33
|
+
<div className="rig-picker" role="radiogroup" aria-label={ko("Posture", "자세")} data-testid="proxy-posture" style={{ gridTemplateColumns: "repeat(3, minmax(0, 1fr))" }}>
|
|
34
|
+
{POSTURES.map((posture) => (
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
key={posture}
|
|
38
|
+
role="radio"
|
|
39
|
+
aria-checked={selectedPosture === posture}
|
|
40
|
+
className={"rig-option" + (selectedPosture === posture ? " active" : "")}
|
|
41
|
+
data-posture={posture}
|
|
42
|
+
onClick={() => {
|
|
43
|
+
if (selectedPosture === posture) return;
|
|
44
|
+
run("character.update", { characterId: activeChar.id, patch: { posture } });
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
<svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
|
48
|
+
<path d={POSTURE_GLYPHS[posture]} />
|
|
49
|
+
</svg>
|
|
50
|
+
<span>{POSTURE_LABELS[posture]()}</span>
|
|
51
|
+
</button>
|
|
52
|
+
))}
|
|
53
|
+
</div>
|
|
54
|
+
</Foldout>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -4,7 +4,7 @@ import { ASSET_IMAGE_TYPES, downscaleTarget } from "../scene-assets.js";
|
|
|
4
4
|
|
|
5
5
|
const REFERENCE_IMAGE_MAX_DIMENSION = 1024;
|
|
6
6
|
|
|
7
|
-
async function readReferenceImage(file, { maxDimension = REFERENCE_IMAGE_MAX_DIMENSION } = {}) {
|
|
7
|
+
export async function readReferenceImage(file, { maxDimension = REFERENCE_IMAGE_MAX_DIMENSION } = {}) {
|
|
8
8
|
if (!file) throw new Error("No file");
|
|
9
9
|
if (!ASSET_IMAGE_TYPES.includes(String(file.type).toLowerCase())) {
|
|
10
10
|
throw new Error("unsupported image type");
|