cozyclay 3.1.0 → 3.1.2
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 +65 -0
- package/bin/agent/agent-routes.mjs +12 -11
- package/bin/agent/main-agent.mjs +21 -7
- package/bin/agent/main-prompt.mjs +7 -6
- package/bin/agent/plan-tool.mjs +114 -0
- package/bin/agent/script-tool.mjs +4 -1
- package/bin/agent/sculpt-sketch.mjs +190 -0
- package/bin/agent/session-store.mjs +2 -1
- package/bin/agent/sketch-code.mjs +151 -0
- package/bin/agent/sketch-sandbox.mjs +102 -0
- package/bin/agent/studio-prompt.mjs +1 -1
- package/bin/agent/studio-rebase.mjs +5 -4
- package/bin/agent/studio-task-routes.mjs +1 -1
- package/bin/agent/studio-tasks.mjs +116 -25
- package/bin/agent/studio-tools.mjs +3 -2
- package/bin/agent/turn-frames.mjs +4 -0
- package/bin/cozyclay.mjs +4 -2
- package/bin/live-endpoint.mjs +2 -0
- package/dist/app/index.html +6 -6
- package/dist/assets/{TaskBoard-DdDXMgZm.js → TaskBoard-GR8xZm5c.js} +1 -1
- package/dist/assets/{app-BlwOpest.css → app-Bhe0TpGz.css} +1 -1
- package/dist/assets/app-C4QTcDcb.js +5144 -0
- package/dist/assets/compile-worker-oy_P9HHh.js +6 -0
- package/dist/assets/motion3-compile-dgGhrvPK.js +1 -0
- package/dist/assets/scene-asset-sync-BEjjhNEo.css +1 -0
- package/dist/assets/scene-asset-sync-qoxcdrhj.js +13 -0
- package/dist/assets/task-board-BqReHzOi.js +15 -0
- package/dist/assets/workflow-C87p6E1E.js +179 -0
- package/dist/cozyclay-package.json +1 -1
- package/dist/index.html +1 -1
- package/dist/sitemap.xml +1 -1
- package/dist/workflow/index.html +4 -4
- package/mcp/README.md +1 -1
- package/mcp/live-hub.mjs +5 -3
- package/mcp/tool-handlers.mjs +7 -7
- package/package.json +3 -1
- package/src/App.jsx +44 -66
- package/src/bus-task-scopes.js +59 -11
- package/src/bus-task-targets.js +35 -9
- package/src/bus-tasks.js +23 -10
- package/src/command-bus.js +7 -4
- package/src/commands/ai.js +1 -1
- package/src/commands/elements.js +4 -3
- package/src/commands/export.js +1 -1
- package/src/commands/motion3.js +27 -7
- package/src/commands/objects.js +1 -1
- package/src/export-workspace.js +244 -0
- package/src/keyframe-pack.js +5 -1
- package/src/live-control.js +14 -7
- package/src/material-look.js +189 -0
- package/src/material-presets.js +78 -0
- package/src/material-textures.js +157 -0
- package/src/motion3/compile-job.js +15 -3
- package/src/motion3/compiler/channels.js +74 -24
- package/src/motion3/compiler/compile.js +2 -1
- package/src/motion3/compiler/idle.js +2 -0
- package/src/motion3/compiler/overlap.js +180 -0
- package/src/motion3/compiler/pose.js +6 -4
- package/src/motion3/compiler/validate-common.js +1 -0
- package/src/motion3/compiler/validate-parts.js +5 -1
- package/src/motion3/compiler/validate.js +30 -10
- package/src/motion3/correct.js +236 -0
- package/src/motion3/docs.js +8 -6
- package/src/motion3/face-lean.js +31 -0
- package/src/motion3/fix/index.js +5 -1
- package/src/motion3/fix/reach-body.js +172 -0
- package/src/motion3/hint-check.js +44 -0
- package/src/motion3/json-patch.js +120 -12
- package/src/motion3/lenient.js +46 -4
- package/src/motion3/object-keys.js +17 -5
- package/src/motion3/pipeline.js +46 -9
- package/src/motion3/qa/arm-checks.js +96 -0
- package/src/motion3/qa/asked.js +113 -0
- package/src/motion3/qa/goals.js +37 -8
- package/src/motion3/qa/hints-goal.js +6 -5
- package/src/motion3/qa/index.js +5 -2
- package/src/motion3/qa/key-checks.js +4 -3
- package/src/motion3/qa/motion-life.js +243 -0
- package/src/motion3/qa/spec-checks.js +15 -9
- package/src/motion3/qa/verdict.js +50 -0
- package/src/motion3/refs.js +38 -14
- package/src/motion3/repair-watch.js +132 -0
- package/src/motion3/repair.js +52 -19
- package/src/motion3/report.js +50 -9
- package/src/motion3/schema/topics.js +6 -4
- package/src/motion3/schema/types.js +1 -1
- package/src/motion3/skills/basic.js +15 -8
- package/src/motion3/skills/index.js +52 -18
- package/src/motion3/skills/info.js +1 -1
- package/src/motion3/skills/stance.js +6 -0
- package/src/motion3/skills/walk-fit.js +170 -0
- package/src/object-look.js +9 -5
- package/src/panels/PoseImagePanel.jsx +3 -2
- package/src/panels/SculptPanel.jsx +2 -2
- package/src/primitive-look.js +38 -0
- package/src/props.jsx +20 -28
- package/src/render-passes.js +7 -1
- package/src/result-modal.jsx +3 -3
- package/src/scene-objects.js +2 -2
- package/src/sculpt-colours.js +65 -0
- package/src/sculpt-compare.js +13 -50
- package/src/sculpt-csg.js +112 -0
- package/src/sculpt-curve.js +123 -0
- package/src/sculpt-cutters.js +43 -0
- package/src/sculpt-detail.js +207 -0
- package/src/sculpt-geometry-loft-convex.js +100 -0
- package/src/sculpt-geometry-shapes.js +250 -0
- package/src/sculpt-geometry.js +33 -15
- package/src/sculpt-loft-rings.js +225 -0
- package/src/sculpt-noise.js +65 -0
- package/src/sculpt-part-fields.js +27 -0
- package/src/sculpt-recipe-read.js +65 -0
- package/src/sculpt-recipe.js +33 -75
- package/src/sculpt-run.js +1 -1
- package/src/sculpt-scatter-items.js +183 -0
- package/src/sculpt-scatter-shape.js +57 -0
- package/src/sculpt-scatter.js +124 -0
- package/src/sculpt-shapes-curved.js +211 -0
- package/src/sculpt-shapes-loft-convex.js +218 -0
- package/src/sculpt-shapes.js +138 -0
- package/src/sculpt-sketch-state.js +76 -0
- package/src/sculpt-sketch.jsx +124 -0
- package/src/sculpt-surface-fields.js +65 -0
- package/src/shell/ExportFolderPreference.jsx +103 -0
- package/src/shell/PreferencesDialog.jsx +3 -166
- package/src/shell/StatusBar.jsx +1 -1
- package/src/shell/preference-controls.jsx +114 -0
- package/src/shell/preference-pages.jsx +72 -0
- package/src/shell/preferences.css +46 -0
- package/src/shot-prompt.js +29 -9
- package/src/studio-actions.js +2 -2
- package/src/studio-agent-motion.js +19 -9
- package/src/studio-agent-protocol.js +1 -1
- package/src/studio-app-binding.js +3 -2
- package/src/studio-elements.js +5 -1
- package/src/workflow/AgentPanel.jsx +6 -6
- package/src/workflow/AgentPlanCard.jsx +41 -0
- package/src/workflow/CozySceneNode.jsx +1 -1
- package/src/workflow/WorkflowBuilder.jsx +2 -2
- package/src/workflow/agent-client.js +14 -11
- package/src/workflow/agent-plan.css +93 -0
- package/src/workflow/agent-plan.js +59 -0
- package/src/workflow/keyframe-pack-request.js +3 -10
- package/src/workflow/task-board.css +1 -1
- package/src/workflow/task-board.js +7 -5
- package/tools/loc-baseline.json +1 -2
- package/tools/run-tests.mjs +8 -8
- package/dist/assets/app-B2BpfCl2.js +0 -5088
- package/dist/assets/compile-worker-D0pKioa0.js +0 -5
- package/dist/assets/motion3-compile-B887n77J.js +0 -1
- package/dist/assets/scene-asset-sync-COGOlF5T.css +0 -1
- package/dist/assets/scene-asset-sync-CQzEdsqu.js +0 -13
- package/dist/assets/task-board-BKaM7d-9.js +0 -14
- package/dist/assets/workflow-B8Dhzexj.js +0 -179
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// The live sketch in the working views: while the agent is still writing a call that builds sculpts, the parts it
|
|
2
|
+
// has written so far (sculpt-sketch-state.js) stand on the stage as translucent clay in their own colours, so the
|
|
3
|
+
// scene takes shape while the model types instead of after minutes of an empty stage. Each part grows in briefly,
|
|
4
|
+
// so new ones read as new. Everything sits on GIZMO_LAYER and takes no ray: the shot preview card, PlayView,
|
|
5
|
+
// captures the agent reads (verify_result), contact sheets and exports never draw it, and no pick can select it.
|
|
6
|
+
// A part that did not change keeps its mesh; a removed one is disposed with its geometry and material.
|
|
7
|
+
import { useEffect, useMemo, useSyncExternalStore } from "react";
|
|
8
|
+
import * as THREE from "three";
|
|
9
|
+
import { useFrame, useThree } from "@react-three/fiber";
|
|
10
|
+
import { GIZMO_LAYER } from "./dualview.jsx";
|
|
11
|
+
import { sculptPartGeometry } from "./sculpt-geometry.js";
|
|
12
|
+
import { clearSculptSketch, sculptSketchFrame, sculptSketchSnapshot, sketchDrawnParts, subscribeSculptSketch } from "./sculpt-sketch-state.js";
|
|
13
|
+
|
|
14
|
+
const OPACITY = 0.42;
|
|
15
|
+
/** A scatter is drawn as the soft box it fills: its items are the real sculpt's job. */
|
|
16
|
+
const SCATTER_OPACITY = 0.2;
|
|
17
|
+
const GROW_MS = 420;
|
|
18
|
+
const DEG = Math.PI / 180;
|
|
19
|
+
// After the other see-through things (ghost-set.js draws onion skins at 40).
|
|
20
|
+
const RENDER_ORDER = 44;
|
|
21
|
+
const noRay = () => {};
|
|
22
|
+
const grow = (t) => 1 - (1 - t) ** 3;
|
|
23
|
+
|
|
24
|
+
/** Where a group stands: its object.sculpt placement, else the sculpt it rebuilds, else the origin (where a new sculpt lands). */
|
|
25
|
+
function placementMatrix(group, objects) {
|
|
26
|
+
const target = group.target ? objects?.find((object) => object.id === group.target) : null;
|
|
27
|
+
const at = { x: target?.x ?? 0, y: target?.y ?? 0, z: target?.z ?? 0, rot: target?.rot ?? 0, ...group.placement };
|
|
28
|
+
return new THREE.Matrix4().compose(new THREE.Vector3(at.x, at.y, at.z), new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), at.rot * DEG), new THREE.Vector3(1, 1, 1));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Every part to draw: { identity, part, matrix (world) }. The identity leaves out ids and placement, so a part
|
|
32
|
+
* the model renamed, or a group that just learned where it stands, keeps its mesh. */
|
|
33
|
+
function sketchItems(groups, objects) {
|
|
34
|
+
const items = [];
|
|
35
|
+
for (const group of groups) {
|
|
36
|
+
const place = placementMatrix(group, objects);
|
|
37
|
+
for (const part of sketchDrawnParts(group.parts)) {
|
|
38
|
+
const { id, parent, matrix, ...shape } = part;
|
|
39
|
+
items.push({ identity: JSON.stringify([shape, matrix.map((value) => Math.round(value * 1e4))]), part, matrix: place.clone().multiply(new THREE.Matrix4().fromArray(matrix)) });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return items;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The meshes of the sketch under one root, keyed by identity. */
|
|
46
|
+
class SketchLayer {
|
|
47
|
+
root = new THREE.Group();
|
|
48
|
+
meshes = new Map();
|
|
49
|
+
constructor() {
|
|
50
|
+
this.root.name = "sculpt-sketch";
|
|
51
|
+
this.root.userData.sculptSketch = true;
|
|
52
|
+
}
|
|
53
|
+
make(part, now) {
|
|
54
|
+
const geometry = sculptPartGeometry(part.shape === "scatter" ? { ...part, shape: "blob", roundness: 0.5, taper: 0 } : part);
|
|
55
|
+
const material = new THREE.MeshStandardMaterial({ color: part.color, emissive: part.color, emissiveIntensity: 0.18, roughness: 0.85, metalness: 0, transparent: true, opacity: 0, depthWrite: false });
|
|
56
|
+
const mesh = new THREE.Mesh(geometry, material);
|
|
57
|
+
Object.assign(mesh, { raycast: noRay, castShadow: false, receiveShadow: false, matrixAutoUpdate: false, renderOrder: RENDER_ORDER, frustumCulled: false });
|
|
58
|
+
mesh.layers.set(GIZMO_LAYER);
|
|
59
|
+
mesh.userData.sculptSketch = true;
|
|
60
|
+
this.root.add(mesh);
|
|
61
|
+
return { mesh, base: new THREE.Matrix4(), born: now, opacity: part.shape === "scatter" ? SCATTER_OPACITY : OPACITY };
|
|
62
|
+
}
|
|
63
|
+
drop({ mesh }) {
|
|
64
|
+
this.root.remove(mesh);
|
|
65
|
+
mesh.geometry.dispose();
|
|
66
|
+
mesh.material.dispose();
|
|
67
|
+
}
|
|
68
|
+
/** Keep the meshes whose parts are still there, make the new ones, dispose the rest. */
|
|
69
|
+
update(items, now) {
|
|
70
|
+
const next = new Map(), seen = new Map();
|
|
71
|
+
for (const item of items) {
|
|
72
|
+
const n = (seen.get(item.identity) ?? 0) + 1;
|
|
73
|
+
seen.set(item.identity, n);
|
|
74
|
+
const key = `${item.identity}#${n}`;
|
|
75
|
+
let entry = this.meshes.get(key);
|
|
76
|
+
if (entry) this.meshes.delete(key);
|
|
77
|
+
else {
|
|
78
|
+
try { entry = this.make(item.part, now); } catch { continue; }
|
|
79
|
+
}
|
|
80
|
+
entry.base.copy(item.matrix);
|
|
81
|
+
next.set(key, entry);
|
|
82
|
+
}
|
|
83
|
+
for (const entry of this.meshes.values()) this.drop(entry);
|
|
84
|
+
this.meshes = next;
|
|
85
|
+
this.frame(now);
|
|
86
|
+
}
|
|
87
|
+
/** Grow the new parts in; true while one still grows. */
|
|
88
|
+
frame(now) {
|
|
89
|
+
let growing = false;
|
|
90
|
+
const scale = new THREE.Matrix4();
|
|
91
|
+
for (const entry of this.meshes.values()) {
|
|
92
|
+
const t = Math.min(1, Math.max(0, (now - entry.born) / GROW_MS)), k = grow(t);
|
|
93
|
+
growing ||= t < 1;
|
|
94
|
+
const s = 0.55 + 0.45 * k;
|
|
95
|
+
entry.mesh.matrix.copy(entry.base).multiply(scale.makeScale(s, s, s));
|
|
96
|
+
entry.mesh.material.opacity = entry.opacity * k;
|
|
97
|
+
}
|
|
98
|
+
return growing;
|
|
99
|
+
}
|
|
100
|
+
dispose() {
|
|
101
|
+
for (const entry of this.meshes.values()) this.drop(entry);
|
|
102
|
+
this.meshes.clear();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** objects: the authored records, for the placement of a sculpt the agent rebuilds (object.sculpt { id }). */
|
|
107
|
+
export function SculptSketch({ objects }) {
|
|
108
|
+
const groups = useSyncExternalStore(subscribeSculptSketch, sculptSketchSnapshot, sculptSketchSnapshot);
|
|
109
|
+
const scene = useThree((state) => state.scene), invalidate = useThree((state) => state.invalidate);
|
|
110
|
+
const layer = useMemo(() => new SketchLayer(), []);
|
|
111
|
+
const items = useMemo(() => sketchItems(groups, objects), [groups, objects]);
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
scene.add(layer.root);
|
|
114
|
+
// QA: the browser suite feeds frames and counts what the stage draws.
|
|
115
|
+
if (typeof window !== "undefined") window.__cozyclaySculptSketch = { frame: sculptSketchFrame, clear: clearSculptSketch, meshes: () => layer.meshes.size, root: layer.root };
|
|
116
|
+
return () => { scene.remove(layer.root); layer.dispose(); };
|
|
117
|
+
}, [scene, layer]);
|
|
118
|
+
useEffect(() => {
|
|
119
|
+
layer.update(items, performance.now());
|
|
120
|
+
invalidate();
|
|
121
|
+
}, [layer, items, invalidate]);
|
|
122
|
+
useFrame(() => { if (layer.frame(performance.now())) invalidate(); });
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surface fields any part takes (registered in sculpt-part-fields.js):
|
|
3
|
+
*
|
|
4
|
+
* detail { amount: 0..1, scale?: 1..12, seed? } lumps a smooth part into
|
|
5
|
+
* a cloud, bush, rock, hay bale or loaf. `amount` is the bump
|
|
6
|
+
* height (1 = DETAIL_RELIEF of the part's smallest half-extent),
|
|
7
|
+
* `scale` about how many bumps fit across its longest side.
|
|
8
|
+
* gradient { color, axis?: y|x|z, from?: 0..1, to?: 0..1 } blends the
|
|
9
|
+
* part's colour (at `from`) into `color` (at `to`) along a local
|
|
10
|
+
* axis, 0 at its low end and 1 at its high end: a sky-lit cloud,
|
|
11
|
+
* a ripening fruit, a sunset wall. On a scatter it colours whole
|
|
12
|
+
* items by where they sit.
|
|
13
|
+
*
|
|
14
|
+
* The geometry halves are sculpt-detail.js and sculpt-colours.js. Pure: no three.js.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { RecipeError, readChoice, readHexColor, readSeed, readUnit } from "./sculpt-recipe-read.js";
|
|
18
|
+
|
|
19
|
+
/** Bump height at amount 1, as a fraction of the part's smallest half-extent:
|
|
20
|
+
* past this a rock reads as a crumpled ball. sculpt-detail.js draws the same. */
|
|
21
|
+
export const DETAIL_RELIEF = 0.35;
|
|
22
|
+
const DETAIL_SCALE = { fallback: 4, min: 1, max: 12 };
|
|
23
|
+
const GRADIENT_AXES = ["y", "x", "z"];
|
|
24
|
+
|
|
25
|
+
function readFields(value, path, keys) {
|
|
26
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
27
|
+
throw new RecipeError(path, `must be an object { ${keys.join(", ")} }, got ${JSON.stringify(value)}`);
|
|
28
|
+
}
|
|
29
|
+
for (const key of Object.keys(value)) {
|
|
30
|
+
if (!keys.includes(key)) throw new RecipeError(`${path}.${key}`, `unknown key (allowed: ${keys.join(", ")})`);
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const DETAIL_FIELD = {
|
|
36
|
+
key: "detail",
|
|
37
|
+
read(raw, part, where) {
|
|
38
|
+
const at = `${where}.detail`;
|
|
39
|
+
if (part.shape === "scatter") throw new RecipeError(at, "does not apply to a scatter: its items are already small; lump the part under it instead");
|
|
40
|
+
const detail = readFields(raw.detail, at, ["amount", "scale", "seed"]);
|
|
41
|
+
if (detail.amount === undefined) throw new RecipeError(`${at}.amount`, "is required: the bump height from 0 (smooth) to 1 (very lumpy)");
|
|
42
|
+
part.detail = {
|
|
43
|
+
amount: readUnit(detail.amount, `${at}.amount`, 0, 0, 1),
|
|
44
|
+
scale: readUnit(detail.scale, `${at}.scale`, DETAIL_SCALE.fallback, DETAIL_SCALE.min, DETAIL_SCALE.max),
|
|
45
|
+
seed: readSeed(detail.seed, `${at}.seed`),
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const GRADIENT_FIELD = {
|
|
51
|
+
key: "gradient",
|
|
52
|
+
read(raw, part, where) {
|
|
53
|
+
const at = `${where}.gradient`;
|
|
54
|
+
const gradient = readFields(raw.gradient, at, ["color", "axis", "from", "to"]);
|
|
55
|
+
if (gradient.color === undefined) throw new RecipeError(`${at}.color`, "is required: the #rrggbb colour the part blends into");
|
|
56
|
+
part.gradient = {
|
|
57
|
+
color: readHexColor(gradient.color, `${at}.color`),
|
|
58
|
+
axis: readChoice(gradient.axis, `${at}.axis`, "y", GRADIENT_AXES),
|
|
59
|
+
from: readUnit(gradient.from, `${at}.from`, 0, 0, 1),
|
|
60
|
+
to: readUnit(gradient.to, `${at}.to`, 1, 0, 1),
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const SURFACE_FIELDS = [DETAIL_FIELD, GRADIENT_FIELD];
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { ko } from "../locale.js";
|
|
3
|
+
import {
|
|
4
|
+
canChooseExportWorkspace,
|
|
5
|
+
chooseExportWorkspace,
|
|
6
|
+
clearExportWorkspace,
|
|
7
|
+
loadExportWorkspace,
|
|
8
|
+
requestExportWorkspacePermission,
|
|
9
|
+
} from "../export-workspace.js";
|
|
10
|
+
import { PreferenceRow } from "./preference-controls.jsx";
|
|
11
|
+
|
|
12
|
+
const SUPPORTED_DESCRIPTION = ko(
|
|
13
|
+
"When set, exports are written here instead of your browser's Downloads folder.",
|
|
14
|
+
"설정하면 내보낸 파일이 브라우저 다운로드 폴더 대신 이 폴더에 저장됩니다.",
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
function statusCopy(permission) {
|
|
18
|
+
if (permission === "prompt") return ko("Folder access needs to be re-allowed.", "폴더 접근을 다시 허용해야 해요.");
|
|
19
|
+
if (permission === "denied") return ko("Folder access was denied. Choose another folder or use Downloads.", "폴더 접근이 거부되었어요. 다른 폴더를 선택하거나 다운로드를 사용하세요.");
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default function ExportFolderPreference() {
|
|
24
|
+
const supported = canChooseExportWorkspace();
|
|
25
|
+
const [workspace, setWorkspace] = useState(null);
|
|
26
|
+
const [busy, setBusy] = useState(false);
|
|
27
|
+
const [error, setError] = useState(null);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
let active = true;
|
|
31
|
+
loadExportWorkspace().then((value) => {
|
|
32
|
+
if (active) setWorkspace(value);
|
|
33
|
+
});
|
|
34
|
+
return () => { active = false; };
|
|
35
|
+
}, []);
|
|
36
|
+
|
|
37
|
+
const choose = async () => {
|
|
38
|
+
setBusy(true);
|
|
39
|
+
setError(null);
|
|
40
|
+
try {
|
|
41
|
+
const handle = await chooseExportWorkspace();
|
|
42
|
+
setWorkspace({ handle, permission: "granted" });
|
|
43
|
+
} catch (cause) {
|
|
44
|
+
if (cause?.name !== "AbortError") setError(ko("Could not choose the export folder.", "내보내기 폴더를 선택하지 못했어요."));
|
|
45
|
+
} finally {
|
|
46
|
+
setBusy(false);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const allow = async () => {
|
|
51
|
+
if (!workspace?.handle) return;
|
|
52
|
+
setBusy(true);
|
|
53
|
+
setError(null);
|
|
54
|
+
try {
|
|
55
|
+
const permission = await requestExportWorkspacePermission(workspace.handle);
|
|
56
|
+
setWorkspace({ ...workspace, permission });
|
|
57
|
+
} finally {
|
|
58
|
+
setBusy(false);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const clear = async () => {
|
|
63
|
+
setBusy(true);
|
|
64
|
+
setError(null);
|
|
65
|
+
try {
|
|
66
|
+
const cleared = await clearExportWorkspace();
|
|
67
|
+
if (cleared) setWorkspace(null);
|
|
68
|
+
else setError(ko("Could not clear the export folder.", "내보내기 폴더를 해제하지 못했어요."));
|
|
69
|
+
} finally {
|
|
70
|
+
setBusy(false);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const status = workspace ? statusCopy(workspace.permission) : null;
|
|
75
|
+
const folderName = workspace?.handle?.name ?? ko("Browser downloads", "브라우저 다운로드");
|
|
76
|
+
const description = supported ? SUPPORTED_DESCRIPTION : ko("This browser cannot choose an export folder; exports continue to Downloads.", "이 브라우저에서는 내보내기 폴더를 선택할 수 없어요. 내보내기는 다운로드 폴더로 이어집니다.");
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<PreferenceRow title={ko("Export folder", "내보내기 폴더")} description={description} testid="preferences-export-folder" full>
|
|
80
|
+
<div className="v2-preferences__export-folder">
|
|
81
|
+
<span className="v2-preferences__export-folder-name" data-testid="preferences-export-folder-name">{folderName}</span>
|
|
82
|
+
{status && <span className="v2-preferences__export-folder-status" role="status">{status}</span>}
|
|
83
|
+
{error && <span className="v2-preferences__export-folder-status" role="alert">{error}</span>}
|
|
84
|
+
<div className="v2-preferences__language-controls" role="group" aria-label={ko("Export folder actions", "내보내기 폴더 작업")}>
|
|
85
|
+
{workspace && workspace.permission !== "granted" && (
|
|
86
|
+
<button type="button" className="v2-preferences__language" data-testid="settings-export-folder-allow" disabled={busy} onClick={allow}>
|
|
87
|
+
{ko("Allow", "허용")}
|
|
88
|
+
</button>
|
|
89
|
+
)}
|
|
90
|
+
<button type="button" className="v2-preferences__language" data-testid="settings-export-folder-choose" disabled={!supported || busy} onClick={choose}>
|
|
91
|
+
{busy ? ko("Choosing…", "선택 중…") : ko("Choose folder…", "폴더 선택…")}
|
|
92
|
+
</button>
|
|
93
|
+
{workspace && (
|
|
94
|
+
<button type="button" className="v2-preferences__language" data-testid="settings-export-folder-clear" disabled={busy} onClick={clear}>
|
|
95
|
+
{ko("Use Downloads", "다운로드 사용")}
|
|
96
|
+
</button>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
<span className="v2-preferences__export-folder-note">{ko("Existing files are never overwritten; numbered names are used.", "같은 이름의 파일이 있으면 번호가 붙고 덮어쓰지 않아요.")}</span>
|
|
101
|
+
</PreferenceRow>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -1,185 +1,22 @@
|
|
|
1
1
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
|
-
import {
|
|
4
|
-
import { loadAutoColor,
|
|
5
|
-
import { ko, LOCALE, setLocale } from "../locale.js";
|
|
6
|
-
import { readStoredGridView, writeStoredGridView } from "../grid-view.js";
|
|
7
|
-
import { readTheme, saveTheme } from "../theme.js";
|
|
3
|
+
import { ko } from "../locale.js";
|
|
4
|
+
import { getAnalyticsOptOut, loadAutoColor, preferenceSection, readStoredGridView } from "./preference-pages.jsx";
|
|
8
5
|
import "./preferences.css";
|
|
9
6
|
|
|
10
|
-
const LANGUAGES = [
|
|
11
|
-
{ id: "en", label: "English", action: "Switch to English" },
|
|
12
|
-
{ id: "ko", label: "한국어", action: "한국어로 전환" },
|
|
13
|
-
];
|
|
14
|
-
|
|
15
|
-
// Only pages whose settings reach the editor are listed; a control that
|
|
16
|
-
// changes nothing is not offered.
|
|
17
7
|
const NAV_GROUPS = [
|
|
18
8
|
{
|
|
19
9
|
label: ko("General", "일반"),
|
|
20
10
|
items: [
|
|
21
11
|
{ id: "Appearance", label: ko("Appearance", "모양") },
|
|
22
12
|
{ id: "Viewport", label: ko("Viewport", "뷰포트") },
|
|
13
|
+
{ id: "Export", label: ko("Export", "내보내기") },
|
|
23
14
|
{ id: "Hotkeys", label: ko("Hotkeys", "단축키") },
|
|
24
15
|
],
|
|
25
16
|
},
|
|
26
17
|
];
|
|
27
18
|
const NAV_LABELS = Object.fromEntries(NAV_GROUPS.flatMap((group) => group.items.map((item) => [item.id, item.label])));
|
|
28
19
|
|
|
29
|
-
const MOD = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform ?? "") ? "⌘" : "Ctrl ";
|
|
30
|
-
|
|
31
|
-
const HOTKEYS = [
|
|
32
|
-
["Move", "W", "이동"],
|
|
33
|
-
["Rotate", "E", "회전"],
|
|
34
|
-
["Scale", "R", "크기 조절"],
|
|
35
|
-
["Frame selection", "F", "선택 프레임"],
|
|
36
|
-
["Duplicate", `${MOD}D`, "복제"],
|
|
37
|
-
["Drop to floor", "End", "바닥에 놓기"],
|
|
38
|
-
];
|
|
39
|
-
|
|
40
|
-
function PreferenceRow({ title, description, children, testid }) {
|
|
41
|
-
return (
|
|
42
|
-
<div className="v2-preferences__row" data-testid={testid}>
|
|
43
|
-
<div className="v2-preferences__row-copy">
|
|
44
|
-
<span className="v2-preferences__row-title">{title}</span>
|
|
45
|
-
{description && <span className="v2-preferences__row-description">{description}</span>}
|
|
46
|
-
</div>
|
|
47
|
-
<div className="v2-preferences__row-control">{children}</div>
|
|
48
|
-
</div>
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// A page with one section leaves out its title: the page header already names it.
|
|
53
|
-
function PreferenceSection({ title, children }) {
|
|
54
|
-
return (
|
|
55
|
-
<section className="v2-preferences__section">
|
|
56
|
-
{title && <h2 className="v2-preferences__section-title">{title}</h2>}
|
|
57
|
-
<div className="v2-preferences__section-rows">{children}</div>
|
|
58
|
-
</section>
|
|
59
|
-
);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function ToggleControl({ pressed, onChange, label, testid }) {
|
|
63
|
-
return (
|
|
64
|
-
<button
|
|
65
|
-
type="button"
|
|
66
|
-
className="v2-preferences__toggle"
|
|
67
|
-
role="switch"
|
|
68
|
-
aria-checked={pressed}
|
|
69
|
-
aria-pressed={pressed}
|
|
70
|
-
aria-label={label}
|
|
71
|
-
data-testid={testid}
|
|
72
|
-
onClick={() => onChange(!pressed)}
|
|
73
|
-
>
|
|
74
|
-
<span className="v2-preferences__toggle-thumb" aria-hidden="true" />
|
|
75
|
-
</button>
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function ReadonlyHotkeys() {
|
|
80
|
-
return (
|
|
81
|
-
<div className="v2-preferences__hotkeys" data-testid="preferences-hotkeys">
|
|
82
|
-
{HOTKEYS.map(([english, key, korean]) => (
|
|
83
|
-
<div className="v2-preferences__hotkey" key={english}>
|
|
84
|
-
<span>{ko(english, korean)}</span>
|
|
85
|
-
<kbd>{key}</kbd>
|
|
86
|
-
</div>
|
|
87
|
-
))}
|
|
88
|
-
</div>
|
|
89
|
-
);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function ThemeControls() {
|
|
93
|
-
const [theme, setTheme] = useState(readTheme);
|
|
94
|
-
const options = [
|
|
95
|
-
{ id: "dark", label: ko("Dark", "다크") },
|
|
96
|
-
{ id: "light", label: ko("Light", "라이트") },
|
|
97
|
-
];
|
|
98
|
-
return (
|
|
99
|
-
<div className="v2-preferences__language-controls" role="group" aria-label={ko("Theme", "테마")}>
|
|
100
|
-
{options.map((option) => (
|
|
101
|
-
<button
|
|
102
|
-
type="button"
|
|
103
|
-
key={option.id}
|
|
104
|
-
className={"v2-preferences__language" + (theme === option.id ? " is-selected" : "")}
|
|
105
|
-
data-testid={`settings-theme-${option.id}`}
|
|
106
|
-
aria-pressed={theme === option.id}
|
|
107
|
-
onClick={() => setTheme(saveTheme(option.id))}
|
|
108
|
-
>
|
|
109
|
-
{option.label}
|
|
110
|
-
</button>
|
|
111
|
-
))}
|
|
112
|
-
</div>
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function preferenceSection(section, props) {
|
|
117
|
-
const { autoColor, setAutoColor, analyticsOptOut, setAnalyticsOptOutState, gridView, setGridView } = props;
|
|
118
|
-
const languageControls = (
|
|
119
|
-
<div className="v2-preferences__language-controls">
|
|
120
|
-
{LANGUAGES.map((language) => (
|
|
121
|
-
<button
|
|
122
|
-
type="button"
|
|
123
|
-
key={language.id}
|
|
124
|
-
className={"v2-preferences__language" + (LOCALE === language.id ? " is-selected" : "")}
|
|
125
|
-
data-testid={`settings-locale-${language.id}`}
|
|
126
|
-
aria-pressed={LOCALE === language.id}
|
|
127
|
-
title={language.action}
|
|
128
|
-
onClick={() => setLocale(language.id)}
|
|
129
|
-
>
|
|
130
|
-
{language.label}
|
|
131
|
-
</button>
|
|
132
|
-
))}
|
|
133
|
-
</div>
|
|
134
|
-
);
|
|
135
|
-
|
|
136
|
-
switch (section) {
|
|
137
|
-
case "Appearance":
|
|
138
|
-
return (
|
|
139
|
-
<>
|
|
140
|
-
<PreferenceSection title={ko("Display", "표시")}>
|
|
141
|
-
<PreferenceRow title={ko("Language", "언어")} description={ko("Choose the labels used throughout the editor.", "에디터에서 사용할 언어를 선택하세요.")}>
|
|
142
|
-
{languageControls}
|
|
143
|
-
</PreferenceRow>
|
|
144
|
-
<PreferenceRow title={ko("Theme", "테마")} description={ko("Dark keeps the studio dim around the stage; Light uses bright glass panels.", "다크는 무대 주변을 어둡게, 라이트는 밝은 유리 패널을 써요.")}>
|
|
145
|
-
<ThemeControls />
|
|
146
|
-
</PreferenceRow>
|
|
147
|
-
</PreferenceSection>
|
|
148
|
-
<PreferenceSection title={ko("Editor", "에디터")}>
|
|
149
|
-
<PreferenceRow title={ko("Auto colour", "자동 색")} description={ko("Give scene objects stable display colours.", "씬 오브젝트에 고정된 표시 색을 사용해요.")}>
|
|
150
|
-
<ToggleControl pressed={autoColor} onChange={(value) => { setAutoColor(value); saveAutoColor(value); }} label={ko("Auto colour", "자동 색")} testid="preferences-auto-color" />
|
|
151
|
-
</PreferenceRow>
|
|
152
|
-
<PreferenceRow title={ko("Anonymous analytics", "익명 사용 통계")} description={ko("Help improve CozyClay with anonymous product events.", "익명 제품 이벤트로 CozyClay 개선을 도와요.")}>
|
|
153
|
-
<ToggleControl
|
|
154
|
-
pressed={!analyticsOptOut}
|
|
155
|
-
onChange={async (value) => setAnalyticsOptOutState(await setAnalyticsOptOut(!value))}
|
|
156
|
-
label={ko("Anonymous analytics", "익명 사용 통계")}
|
|
157
|
-
testid="settings-analytics"
|
|
158
|
-
/>
|
|
159
|
-
</PreferenceRow>
|
|
160
|
-
</PreferenceSection>
|
|
161
|
-
</>
|
|
162
|
-
);
|
|
163
|
-
case "Viewport":
|
|
164
|
-
return (
|
|
165
|
-
<PreferenceSection>
|
|
166
|
-
<PreferenceRow title={ko("Reference grid", "기준 그리드")} description={ko("Use a dark reference grid instead of the clay deck.", "클레이 데크 대신 어두운 기준 그리드를 사용해요.")}>
|
|
167
|
-
<ToggleControl pressed={gridView} onChange={(value) => { setGridView(value); writeStoredGridView(globalThis.localStorage, value); }} label={ko("Reference grid", "기준 그리드")} testid="preferences-grid" />
|
|
168
|
-
</PreferenceRow>
|
|
169
|
-
</PreferenceSection>
|
|
170
|
-
);
|
|
171
|
-
case "Hotkeys":
|
|
172
|
-
return (
|
|
173
|
-
<PreferenceSection>
|
|
174
|
-
<p className="v2-preferences__section-note">{ko("Shortcuts are read-only here. They stay active whenever focus is outside a text field.", "단축키는 여기서 읽기 전용이에요. 텍스트 입력란 밖에서 항상 사용할 수 있어요.")}</p>
|
|
175
|
-
<ReadonlyHotkeys />
|
|
176
|
-
</PreferenceSection>
|
|
177
|
-
);
|
|
178
|
-
default:
|
|
179
|
-
return null;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
20
|
export default function PreferencesDialog({ open, onClose, initialSection = "Appearance" }) {
|
|
184
21
|
const dialogRef = useRef(null);
|
|
185
22
|
const searchRef = useRef(null);
|
package/src/shell/StatusBar.jsx
CHANGED
|
@@ -47,7 +47,7 @@ function ExportStatus({ status, label, recording }) {
|
|
|
47
47
|
const detail = [
|
|
48
48
|
message,
|
|
49
49
|
retryable && ko("Retry keeps the original shot, camera, range and settings; it does not change your edits.", "다시 시도하면 원래 샷·카메라·범위·설정을 사용하며 편집 내용은 바꾸지 않아요."),
|
|
50
|
-
handedOff > 0 && ko(`${handedOff} file(s) already handed off. Retry skips those
|
|
50
|
+
handedOff > 0 && ko(`${handedOff} file(s) already handed off. Retry skips those files.`, `파일 ${handedOff}개는 이미 전달했어요. 다시 시도할 때 해당 파일은 건너뛰어요.`),
|
|
51
51
|
busy && stage === "mux" && ko("Finalizing MP4; this stage cannot be interrupted.", "MP4 마무리 중이에요. 이 단계는 중단할 수 없어요."),
|
|
52
52
|
].filter(Boolean).join(" ");
|
|
53
53
|
return (
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { ko, LOCALE, setLocale } from "../locale.js";
|
|
3
|
+
import { readTheme, saveTheme } from "../theme.js";
|
|
4
|
+
|
|
5
|
+
const MOD = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform ?? "") ? "⌘" : "Ctrl ";
|
|
6
|
+
|
|
7
|
+
const HOTKEYS = [
|
|
8
|
+
["Move", "W", "이동"],
|
|
9
|
+
["Rotate", "E", "회전"],
|
|
10
|
+
["Scale", "R", "크기 조절"],
|
|
11
|
+
["Frame selection", "F", "선택 프레임"],
|
|
12
|
+
["Duplicate", `${MOD}D`, "복제"],
|
|
13
|
+
["Drop to floor", "End", "바닥에 놓기"],
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
export function PreferenceRow({ title, description, children, testid, full = false }) {
|
|
17
|
+
return (
|
|
18
|
+
<div className={"v2-preferences__row" + (full ? " v2-preferences__row--full" : "")} data-testid={testid}>
|
|
19
|
+
<div className="v2-preferences__row-copy">
|
|
20
|
+
<span className="v2-preferences__row-title">{title}</span>
|
|
21
|
+
{description && <span className="v2-preferences__row-description">{description}</span>}
|
|
22
|
+
</div>
|
|
23
|
+
<div className="v2-preferences__row-control">{children}</div>
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// A page with one section leaves out its title: the page header already names it.
|
|
29
|
+
export function PreferenceSection({ title, children }) {
|
|
30
|
+
return (
|
|
31
|
+
<section className="v2-preferences__section">
|
|
32
|
+
{title && <h2 className="v2-preferences__section-title">{title}</h2>}
|
|
33
|
+
<div className="v2-preferences__section-rows">{children}</div>
|
|
34
|
+
</section>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function ToggleControl({ pressed, onChange, label, testid }) {
|
|
39
|
+
return (
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
className="v2-preferences__toggle"
|
|
43
|
+
role="switch"
|
|
44
|
+
aria-checked={pressed}
|
|
45
|
+
aria-pressed={pressed}
|
|
46
|
+
aria-label={label}
|
|
47
|
+
data-testid={testid}
|
|
48
|
+
onClick={() => onChange(!pressed)}
|
|
49
|
+
>
|
|
50
|
+
<span className="v2-preferences__toggle-thumb" aria-hidden="true" />
|
|
51
|
+
</button>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function ThemeControls() {
|
|
56
|
+
const [theme, setTheme] = useState(readTheme);
|
|
57
|
+
const options = [
|
|
58
|
+
{ id: "dark", label: ko("Dark", "다크") },
|
|
59
|
+
{ id: "light", label: ko("Light", "라이트") },
|
|
60
|
+
];
|
|
61
|
+
return (
|
|
62
|
+
<div className="v2-preferences__language-controls" role="group" aria-label={ko("Theme", "테마")}>
|
|
63
|
+
{options.map((option) => (
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
key={option.id}
|
|
67
|
+
className={"v2-preferences__language" + (theme === option.id ? " is-selected" : "")}
|
|
68
|
+
data-testid={`settings-theme-${option.id}`}
|
|
69
|
+
aria-pressed={theme === option.id}
|
|
70
|
+
onClick={() => setTheme(saveTheme(option.id))}
|
|
71
|
+
>
|
|
72
|
+
{option.label}
|
|
73
|
+
</button>
|
|
74
|
+
))}
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function LanguageControls() {
|
|
80
|
+
const options = [
|
|
81
|
+
{ id: "en", label: "English", action: "Switch to English" },
|
|
82
|
+
{ id: "ko", label: "한국어", action: "한국어로 전환" },
|
|
83
|
+
];
|
|
84
|
+
return (
|
|
85
|
+
<div className="v2-preferences__language-controls">
|
|
86
|
+
{options.map((language) => (
|
|
87
|
+
<button
|
|
88
|
+
type="button"
|
|
89
|
+
key={language.id}
|
|
90
|
+
className={"v2-preferences__language" + (LOCALE === language.id ? " is-selected" : "")}
|
|
91
|
+
data-testid={`settings-locale-${language.id}`}
|
|
92
|
+
aria-pressed={LOCALE === language.id}
|
|
93
|
+
title={language.action}
|
|
94
|
+
onClick={() => setLocale(language.id)}
|
|
95
|
+
>
|
|
96
|
+
{language.label}
|
|
97
|
+
</button>
|
|
98
|
+
))}
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function ReadonlyHotkeys() {
|
|
104
|
+
return (
|
|
105
|
+
<div className="v2-preferences__hotkeys" data-testid="preferences-hotkeys">
|
|
106
|
+
{HOTKEYS.map(([english, key, korean]) => (
|
|
107
|
+
<div className="v2-preferences__hotkey" key={english}>
|
|
108
|
+
<span>{ko(english, korean)}</span>
|
|
109
|
+
<kbd>{key}</kbd>
|
|
110
|
+
</div>
|
|
111
|
+
))}
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|