cozyclay 3.1.1 → 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 +39 -0
- package/bin/agent/agent-routes.mjs +4 -4
- package/bin/agent/main-agent.mjs +21 -7
- package/bin/agent/main-prompt.mjs +7 -6
- package/bin/agent/script-tool.mjs +2 -1
- 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/cozyclay.mjs +4 -2
- package/bin/live-endpoint.mjs +2 -0
- package/dist/app/index.html +6 -6
- package/dist/assets/TaskBoard-GR8xZm5c.js +1 -0
- 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 +5 -5
- package/package.json +1 -1
- package/src/App.jsx +42 -64
- 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/export.js +1 -1
- package/src/commands/motion3.js +27 -7
- package/src/export-workspace.js +244 -0
- package/src/keyframe-pack.js +5 -1
- package/src/live-control.js +14 -7
- 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/panels/PoseImagePanel.jsx +3 -2
- package/src/panels/SculptPanel.jsx +2 -2
- package/src/result-modal.jsx +3 -3
- 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/workflow/AgentPanel.jsx +2 -2
- package/src/workflow/CozySceneNode.jsx +1 -1
- package/src/workflow/WorkflowBuilder.jsx +2 -2
- 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 +5 -5
- package/dist/assets/TaskBoard-ngdqiu7u.js +0 -1
- package/dist/assets/app-DmR7jBkz.js +0 -5144
- package/dist/assets/compile-worker-D0pKioa0.js +0 -5
- package/dist/assets/motion3-compile-BB3_1-EB.js +0 -1
- package/dist/assets/scene-asset-sync-CBx7XRti.css +0 -1
- package/dist/assets/scene-asset-sync-Cgdtr1Yw.js +0 -13
- package/dist/assets/task-board-ErQvfoaz.js +0 -14
- package/dist/assets/workflow-zQM-EKW1.js +0 -179
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// f0. The agent's own keys come after the skill keys (a key of the agent wins on the same
|
|
5
5
|
// frame), and the agent's own anchors/props/fits replace a skill's of the same name.
|
|
6
6
|
import { stateAt } from "../compiler/walk.js";
|
|
7
|
-
import { didYouMean, suggestField } from "../compiler/vocab.js";
|
|
7
|
+
import { didYouMean, suggestField, fmt } from "../compiler/vocab.js";
|
|
8
8
|
import { USE_FIELDS } from "../schema/fields.js";
|
|
9
9
|
import { SKILL_INFO } from "./info.js";
|
|
10
10
|
import { fixOptions } from "../fix/index.js";
|
|
@@ -38,32 +38,66 @@ function mergeChecks(into, part) {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
const isOpen = v => Boolean(v) && typeof v === "object" && !Array.isArray(v) && (v.range ?? v.search) !== undefined;
|
|
42
|
+
const said = t => (t.description ? ` (${t.description})` : "");
|
|
43
|
+
|
|
44
|
+
/** What is wrong with one skill arg against its schema fragment (skills/info.js): a value of the wrong kind, an enum miss; null when fine or not checked. */
|
|
45
|
+
function argProblem(at, v, t) {
|
|
46
|
+
if (!t || v === undefined || isOpen(v)) return null;
|
|
47
|
+
if (t.enum && !t.enum.includes(v)) return `${at} must be one of ${t.enum.map(e => JSON.stringify(e)).join(", ")}${said(t)}, got ${fmt(v)}${didYouMean(String(v), t.enum.map(String))}`;
|
|
48
|
+
if ((t.type === "number" || t.type === "integer") && !Number.isFinite(v)) return `${at} must be a number${said(t)}, got ${fmt(v)}`;
|
|
49
|
+
if (t.type === "string" && typeof v !== "string") return `${at} must be a string${said(t)}, got ${fmt(v)}`;
|
|
50
|
+
if (t.type === "boolean" && typeof v !== "boolean") return `${at} must be true or false${said(t)}, got ${fmt(v)}`;
|
|
51
|
+
if (t.type === "array" && t.items?.type === "number" && !(Array.isArray(v) && v.length >= (t.minItems ?? 0) && v.every(x => Number.isFinite(x) || isOpen(x))))
|
|
52
|
+
return `${at} must be ${t.minItems === 2 ? "[x, z]" : t.minItems === 3 ? "[x, y, z]" : "an array of numbers"}${said(t)}, got ${fmt(v)}`;
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Every problem of spec.use that needs no expansion, all at once: the entry's shape, the skill name, unknown fields
|
|
58
|
+
* and args, args of the wrong kind, required args, f0. lenient (a saved take) checks only what expansion cannot do without.
|
|
59
|
+
*/
|
|
60
|
+
function usageProblems(spec, lenient = false) {
|
|
61
|
+
if (!Array.isArray(spec.use)) return ["spec.use must be an array of { skill, args, f0 }"];
|
|
62
|
+
const out = [];
|
|
63
|
+
spec.use.forEach((u, i) => {
|
|
64
|
+
if (!u || typeof u !== "object" || Array.isArray(u)) return out.push(`use[${i}] must be { skill, args, f0 }, got ${fmt(u)}`);
|
|
65
|
+
if (!SKILLS[u.skill]) return out.push(`use[${i}].skill "${u.skill}" is not a skill${didYouMean(String(u.skill), SKILL_NAMES)} (skills: ${SKILL_NAMES.join(", ")})`);
|
|
66
|
+
if (!lenient) for (const f of Object.keys(u)) if (!USE_FIELDS.includes(f)) out.push(`use[${i}] has unknown field "${f}"${suggestField(f, USE_FIELDS)}; an entry is { skill, args, f0 }`);
|
|
67
|
+
if (u.args !== undefined && (!u.args || typeof u.args !== "object" || Array.isArray(u.args))) out.push(`use[${i}].args must be an object, got ${JSON.stringify(u.args)}`);
|
|
68
|
+
else if (!lenient) {
|
|
69
|
+
const info = SKILL_INFO[u.skill], known = Object.keys(info.args);
|
|
70
|
+
for (const [a, v] of Object.entries(u.args ?? {})) {
|
|
71
|
+
if (!known.includes(a)) out.push(`use[${i}].args.${a} is not an arg of ${u.skill}${suggestField(a, known)} (args: ${known.join(", ")})`);
|
|
72
|
+
else { const p = argProblem(`use[${i}].args.${a}`, v, info.args[a]); if (p) out.push(p); }
|
|
73
|
+
}
|
|
74
|
+
for (const a of info.required ?? []) if (u.args?.[a] === undefined) out.push(`use[${i}] ${u.skill} needs args.${a}${said(info.args[a])}`);
|
|
75
|
+
}
|
|
76
|
+
// past the last frame is said in order below, beside what an earlier skill needs (set frames >= ...)
|
|
77
|
+
if (!Number.isInteger(u.f0) || u.f0 < 0) out.push(`use[${i}].f0 must be an integer frame within 0..${spec.frames - 1}, got ${JSON.stringify(u.f0)}`);
|
|
78
|
+
});
|
|
79
|
+
return out;
|
|
46
80
|
}
|
|
47
81
|
|
|
48
82
|
/**
|
|
49
|
-
* spec with `use` -> { spec: plain spec (no use), used: [{ skill, f0, end, paced?, cut? }] };
|
|
50
|
-
*
|
|
51
|
-
*
|
|
83
|
+
* spec with `use` -> { spec: plain spec (no use), used: [{ skill, f0, end, paced?, cut?, placed?, adjust? }] }; every
|
|
84
|
+
* problem of the use entries themselves is refused at once (usageProblems); lenient ignores unknown use[i] fields and
|
|
85
|
+
* args. fit ({ refusals: [] }, correct.js fitWalks) lets a skill fit its own args to what they ask for (walkTo: the
|
|
86
|
+
* stride to reach to), as adjust: { args, note }, or refuse into fit.refusals. auto (frames-auto.js) lets a skill
|
|
87
|
+
* that holds to the last frame end where its own motion ends. A skill whose release or settle the last frame cuts ends
|
|
52
88
|
* there holding (cut says so); one that needs frames past the last for its own motion is refused.
|
|
53
89
|
*/
|
|
54
|
-
export function expandUse(spec, { sk, scene, lenient = false, auto = false }) {
|
|
90
|
+
export function expandUse(spec, { sk, scene, lenient = false, auto = false, fit }) {
|
|
55
91
|
if (!spec.use?.length) return { spec, used: [] };
|
|
56
|
-
|
|
92
|
+
const problems = usageProblems(spec, lenient);
|
|
93
|
+
if (problems.length) bad(problems.join("\n"));
|
|
57
94
|
const own = (spec.keys ?? []).map((k, i) => ({ ...k, src: `keys[${i}]` }));
|
|
58
95
|
const merged = { anchors: {}, props: {}, objects: {}, fits: [], checks: {}, channels: [], fills: {}, goals: [] }, skillKeys = [], used = [];
|
|
59
96
|
spec.use.forEach((u, i) => {
|
|
60
|
-
const tag = `use[${i}] ${u
|
|
61
|
-
if (
|
|
62
|
-
for (const f of Object.keys(u)) if (!USE_FIELDS.includes(f) && !lenient) bad(`use[${i}] has unknown field "${f}"${suggestField(f, USE_FIELDS)}; an entry is { skill, args, f0 }`);
|
|
63
|
-
argsOk(u, i, lenient);
|
|
64
|
-
if (!Number.isInteger(u.f0) || u.f0 < 0 || u.f0 > spec.frames - 1) bad(`use[${i}].f0 must be an integer frame within 0..${spec.frames - 1}, got ${JSON.stringify(u.f0)}`);
|
|
97
|
+
const tag = `use[${i}] ${u.skill}`;
|
|
98
|
+
if (u.f0 > spec.frames - 1) bad(`use[${i}].f0 must be an integer frame within 0..${spec.frames - 1}, got ${JSON.stringify(u.f0)}`);
|
|
65
99
|
const keys = [...skillKeys, ...own];
|
|
66
|
-
const ctx = { sk, scene, frames: spec.frames, fps: spec.fps ?? 24, auto, props: { ...merged.props, ...spec.props }, self: spec.fix !== false && spec.fix?.self !== false, f0: u.f0, args: structuredClone(u.args ?? {}), tag, keys,
|
|
100
|
+
const ctx = { sk, scene, frames: spec.frames, fps: spec.fps ?? 24, auto, fit, props: { ...merged.props, ...spec.props }, self: spec.fix !== false && spec.fix?.self !== false, f0: u.f0, args: structuredClone(u.args ?? {}), tag, keys,
|
|
67
101
|
use: spec.use, index: i, place: fixOptions(spec.fix).stance, state: structuredClone(stateAt(keys, u.f0)), anchors: { ...merged.anchors, ...spec.anchors }, objects: { ...merged.objects, ...spec.objects } };
|
|
68
102
|
const part = SKILLS[u.skill](ctx), last = Math.max(part.end ?? u.f0, ...part.keys.map(k => k.f));
|
|
69
103
|
if (last > spec.frames - 1) bad(`${tag} needs ${last - u.f0} frames from f${u.f0} (to f${last}), past the last frame f${spec.frames - 1}; set frames >= ${last + 1} or frames: "auto"`);
|
|
@@ -77,7 +111,7 @@ export function expandUse(spec, { sk, scene, lenient = false, auto = false }) {
|
|
|
77
111
|
// fields a skill fills into an entry when it leaves them out (throwObject: a carry's release and fall)
|
|
78
112
|
for (const [id, f] of Object.entries(part.fills ?? {})) merged.fills[id] = { ...merged.fills[id], ...f };
|
|
79
113
|
merged.goals.push(...(part.goals ?? []));
|
|
80
|
-
used.push({ skill: u.skill, f0: u.f0, end: part.end, ...(part.paced ? { paced: part.paced } : {}), ...(part.cut ? { cut: part.cut } : {}), ...(part.placed ? { placed: part.placed } : {}) });
|
|
114
|
+
used.push({ skill: u.skill, f0: u.f0, end: part.end, ...(part.paced ? { paced: part.paced } : {}), ...(part.cut ? { cut: part.cut } : {}), ...(part.placed ? { placed: part.placed } : {}), ...(part.adjust ? { adjust: part.adjust } : {}) });
|
|
81
115
|
});
|
|
82
116
|
const out = { ...spec, keys: [...skillKeys, ...own], anchors: { ...merged.anchors, ...spec.anchors }, props: { ...merged.props, ...spec.props },
|
|
83
117
|
objects: { ...merged.objects, ...spec.objects } };
|
|
@@ -25,7 +25,7 @@ export const SKILL_INFO = {
|
|
|
25
25
|
place: { at: 0.03 },
|
|
26
26
|
},
|
|
27
27
|
walkTo: {
|
|
28
|
-
args: { to: xz("[x, z] world m: the heading turns toward it and the steps cover the distance"), ...walkArgs,
|
|
28
|
+
args: { to: xz("[x, z] world m: the heading turns toward it and the steps cover the distance (given steps, the stride or the step count and cadence is fitted to it in a natural gait, or the walk is refused)"), ...walkArgs,
|
|
29
29
|
heading: { oneOf: [num(), WALK_TYPES.heading], description: "degrees: h1, or [h0, h1]" },
|
|
30
30
|
arrive: int("with to and no steps/period/step/swing: the frame to end by (feet together); the engine picks the cadence (observations.planned)") },
|
|
31
31
|
needs: "a standing pose keyed before f0", frames: "16 + steps * period + 18",
|
|
@@ -51,6 +51,12 @@ export function clearStance(sk, scene, c, h) {
|
|
|
51
51
|
return null;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
|
|
55
|
+
/** The standing body at c ([x, z]) facing h: its least distance to the bodies (ids not in skip; m, negative inside). */
|
|
56
|
+
export function standClearance(sk, scene, c, h, skip = new Set()) {
|
|
57
|
+
const boxes = obstacles({ objects: Object.fromEntries(Object.entries(scene?.objects ?? {}).filter(([id]) => !skip.has(id))) });
|
|
58
|
+
return boxes.length ? clearance(sk, boxes, c, h) : Infinity;
|
|
59
|
+
}
|
|
54
60
|
/**
|
|
55
61
|
* The world point an anchor definition names on the still scene, as anchors.js resolves it: world p; an object's (or a
|
|
56
62
|
* group's) `local` point, its base pivot without one; with `grasp` and no `local`, the centre of its parts (where a
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// walkTo's fit (C21): to with steps walks where the steps walk, so the steps are fitted to end at to in a natural
|
|
2
|
+
// gait for the rig (skills/basic.js walkTo calls fitTo when correct.js fitWalks asks), or the walk is refused.
|
|
3
|
+
import { len, sub } from "../compiler/math.js";
|
|
4
|
+
import { walk, stateAt } from "../compiler/walk.js";
|
|
5
|
+
import { partsOf } from "../object-pose.js";
|
|
6
|
+
import { standClearance } from "./stance.js";
|
|
7
|
+
|
|
8
|
+
/** The feet's midpoint [x, z] once the walk's keys have played out. */
|
|
9
|
+
export function walkEndAt(keys) {
|
|
10
|
+
const st = stateAt(keys, Infinity);
|
|
11
|
+
return [(st.foot_l[0] + st.foot_r[0]) / 2, (st.foot_l[2] + st.foot_r[2]) / 2];
|
|
12
|
+
}
|
|
13
|
+
export const r2 = v => Math.round(v * 100) / 100, xzText = p => `[${p.map(r2).join(", ")}]`;
|
|
14
|
+
// A natural gait for the rig (fitTo): a step STEP of the leg (hip to ankle: 0.25..0.7 m on the Y Bot), the first of a
|
|
15
|
+
// two-step walk FIRST of it (a short shuffle is a natural reposition), CADENCE steps a second, and the step over the cadence
|
|
16
|
+
// within RATIO of WALK_RATIO legs x s: a person's walk ratio (about 6.5 mm per step a minute, 0.39 m x s on a 0.83 m leg)
|
|
17
|
+
// holds from a slow walk to a brisk one, so longer steps come with quicker ones. A stride fitted at a fixed cadence went to
|
|
18
|
+
// 0.87 m at 1.2 steps a second in the agent sessions (a lunge); the walk generator slides or pops in most cadences from a
|
|
19
|
+
// 0.75 m step, and pops above 1.8 steps a second (31 of 47 fitted walks at period 12-13, none at 14-18; a person's brisk walk
|
|
20
|
+
// reaches 2.1).
|
|
21
|
+
const STEP = [0.3, 0.85], FIRST = [0.18, 0.85], CADENCE = [1, 1.8], WALK_RATIO = 0.47, RATIO = [0.7, 1.4], CHANGE = 0.1, HEADING_TOL = 20;
|
|
22
|
+
export const REACH_TOL = 0.1;
|
|
23
|
+
// A walk whose to lies within CLEAR legs (0.41 m on the Y Bot) of a body ahead of it (inside it, or its nearest point within
|
|
24
|
+
// 60 deg of the way the walk goes) stops before the body instead: a low body (top under LOW legs: a box on the floor, a crate)
|
|
25
|
+
// CLEAR from its near side, the feet's midpoint to its footprint, room to bend over it; a higher one (a table, a counter, a
|
|
26
|
+
// wall) where the standing body (stance.js: legs, feet, hips, arms down) clears it by GAP. The bench replay (n=692): steps
|
|
27
|
+
// fitted onto a to the agent had set 0.22-0.30 m from a box it then picked up walked the body into it (COLLIDE frames +58%,
|
|
28
|
+
// first-compile passes 28 -> 10); the build before the fit had stopped those walks 0.40-0.56 m from the box. One clearance
|
|
29
|
+
// for every body (0.41 m from a table too) cut COLLIDE frames as far but cost 59 goals and 127 REACH groups (a cup on the table
|
|
30
|
+
// out of reach); this split keeps the goals the fit gained (996, as 582d0501) with COLLIDE frames 80,853 -> 63,350 and
|
|
31
|
+
// first-compile passes 10 -> 19. A body taller than HEAD m at its foot (a shelf overhead), lower than SUPPORT (a mat), carried
|
|
32
|
+
// during the walk or sat on next is not in the way.
|
|
33
|
+
const CLEAR = 0.5, SUPPORT = 0.12, HEAD = 1.7, AHEAD = Math.cos(Math.PI / 3), LOW = 0.65, GAP = 0.05;
|
|
34
|
+
const legOf = sk => { const P = sk.restWorldPos, I = sk.index; return len(sub(P[I.LeftLeg], P[I.LeftUpLeg])) + len(sub(P[I.LeftFoot], P[I.LeftLeg])); };
|
|
35
|
+
// the bodies a walk may end against, as the still scene has them: what a hand carries during the walk (spec.objects with
|
|
36
|
+
// a grab before its end, not released before it starts), the seat a later sitInSeat sits on, its back, the car, and
|
|
37
|
+
// every part of their groups
|
|
38
|
+
function passable(ctx, end) {
|
|
39
|
+
const scene = ctx.scene, ids = new Set(), add = id => { const top = scene?.objects?.[id]?.ancestors?.at(-1) ?? id; for (const part of partsOf(scene, top)) ids.add(part); };
|
|
40
|
+
for (const u of (ctx.use ?? []).filter((x, j) => j !== ctx.index && x?.f0 >= ctx.f0 && x?.skill === "sitInSeat"))
|
|
41
|
+
for (const id of [u.args?.seat, u.args?.seatBack, u.args?.car].filter(x => typeof x === "string")) add(id);
|
|
42
|
+
for (const [id, o] of Object.entries(ctx.objects ?? {})) if (Number.isFinite(o?.grab) && o.grab <= end && !(o.release < ctx.f0)) for (const part of partsOf(scene, id)) ids.add(part);
|
|
43
|
+
return ids;
|
|
44
|
+
}
|
|
45
|
+
// the feet's midpoint p = [x, z] to body o's footprint: [distance, the nearest point]
|
|
46
|
+
function footprint(o, p) {
|
|
47
|
+
const c = o.R, dx = p[0] - o.pos[0], dz = p[1] - o.pos[2], lx = c[0][0] * dx + c[2][0] * dz, lz = c[0][2] * dx + c[2][2] * dz;
|
|
48
|
+
const qx = Math.max(-o.half[0], Math.min(o.half[0], lx)), qz = Math.max(-o.half[2], Math.min(o.half[2], lz));
|
|
49
|
+
const q = [o.pos[0] + c[0][0] * qx + c[0][2] * qz, o.pos[2] + c[2][0] * qx + c[2][2] * qz];
|
|
50
|
+
return [Math.hypot(p[0] - q[0], p[1] - q[1]), q];
|
|
51
|
+
}
|
|
52
|
+
// the point of the approach from c0 (unit u) where a walk toward a to (at d) stops clear of the bodies ahead: { t, at, near }, or null
|
|
53
|
+
function clearStop(ctx, c0, u, d, skip, clear, h) {
|
|
54
|
+
const to = [c0[0] + u[0] * d, c0[1] + u[1] * d], leg = legOf(ctx.sk);
|
|
55
|
+
const ahead = Object.entries(ctx.scene?.objects ?? {}).filter(([id, o]) => !skip.has(id) && o.half && o.pos && o.R && o.pos[1] < HEAD && o.pos[1] + 2 * o.half[1] * o.R[1][1] > SUPPORT)
|
|
56
|
+
.map(([id, o]) => { const [dist, q] = footprint(o, to); return { id, o, dist, q, low: o.pos[1] + 2 * o.half[1] * o.R[1][1] < LOW * leg }; })
|
|
57
|
+
.filter(b => b.dist < clear && (b.dist < 1e-6 || ((b.q[0] - to[0]) * u[0] + (b.q[1] - to[1]) * u[1]) >= AHEAD * b.dist)).sort((a, b) => a.dist - b.dist);
|
|
58
|
+
if (!ahead.length) return null;
|
|
59
|
+
const at = s => [c0[0] + u[0] * s, c0[1] + u[1] * s], high = new Set(Object.keys(ctx.scene.objects).filter(id => !ahead.some(b => b.id === id && !b.low)));
|
|
60
|
+
const blocked = s => ahead.some(b => b.low && footprint(b.o, at(s))[0] < clear) || (ahead.some(b => !b.low) && standClearance(ctx.sk, ctx.scene, at(s), h, high) < GAP);
|
|
61
|
+
if (!blocked(d)) return null;
|
|
62
|
+
let t = d;
|
|
63
|
+
while (t > 0 && blocked(t)) t -= 0.01;
|
|
64
|
+
return t > REACH_TOL ? { t, at: at(t), near: ahead[0].id, gap: footprint(ahead[0].o, at(t))[0] } : null;
|
|
65
|
+
}
|
|
66
|
+
// the last frame a walk whose stepping ends at `from` may end at: the first later frame another skill starts at, a key of the
|
|
67
|
+
// spec (or an object key, grab or release: ctx.fit.busy) after its stepping, or the take's last frame (ctx.fit.last)
|
|
68
|
+
const roomAfter = (ctx, from) => Math.min(ctx.fit.last ?? Infinity, ...(ctx.use ?? []).filter((x, j) => j !== ctx.index && x?.f0 > ctx.f0).map(x => x.f0),
|
|
69
|
+
...ctx.keys.filter(k => k.f > from && /^keys\[/.test(k.src ?? "")).map(k => k.f), ...(ctx.fit.busy ?? []).filter(f => f > from));
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* to with steps (C21): the steps walk where they walk, and to only turns the heading, so a walk with both is fitted
|
|
73
|
+
* when ctx.fit asks (correct.js fitWalks, before the spec is stored), in a natural gait (the bands above):
|
|
74
|
+
* the stride (steps >= 3, the first step keeping its share of it) or the first step (steps 2) that makes the steps end
|
|
75
|
+
* at to, when it stays in its band and in the walk ratio band at the walk's cadence;
|
|
76
|
+
* else the step count whose stride is nearest the walk ratio, its stride and cadence in their bands: in the walk's own
|
|
77
|
+
* stepping time (steps x period frames, the period the one that fits, never ending later; fewer steps stand at the end),
|
|
78
|
+
* or at the walk's own period when nothing comes after it in a frames "auto" spec (ctx.fit.stretch: it may end later);
|
|
79
|
+
* else more steps that end later, into frames nothing uses (roomAfter), at the walk's own cadence or a quicker natural one;
|
|
80
|
+
* else the walk's own steps at its band's edge when they still end within REACH_TOL of to (a walk that near is not fitted);
|
|
81
|
+
* else, or for a heading that points away from to, a line in ctx.fit.refusals with what the walk would do and needs.
|
|
82
|
+
* The last two were refusals until the bench (S7, S10: 3 of 7 walkTo refusals in 30 merged runs, 4 walks): each had one in
|
|
83
|
+
* the bands (step 0.7 m at 1.33-1.5 steps a second ending 0.05-0.07 m short, or 4 steps at 1.71 steps a second ending 2
|
|
84
|
+
* frames later into free frames) whose whole take had no foot, knee or float problem in the walk the in-band walks lacked.
|
|
85
|
+
* Deterministic: a walk at a candidate count is estimated (its end is linear in the stride) and the chosen one walked
|
|
86
|
+
* once more to set its stride. -> { gen, adjust } with the fitted gen.
|
|
87
|
+
*/
|
|
88
|
+
export function fitTo(ctx, gen, c0, d, bearing) {
|
|
89
|
+
const { args, tag, sk } = ctx, steps = gen.steps, h = Array.isArray(args.heading) ? args.heading[1] : args.heading;
|
|
90
|
+
const off = h === undefined ? 0 : Math.abs(((h - bearing) % 360 + 540) % 360 - 180);
|
|
91
|
+
if (off > HEADING_TOL) {
|
|
92
|
+
ctx.fit.refusals.push(`${tag}: heading ${h} points ${Math.round(off)} deg away from to ${xzText(args.to)} (it lies at heading ${Math.round(bearing)}), so the walk would not go there; with to the walk turns toward it by itself: leave heading out, or give heading without to to walk that way`);
|
|
93
|
+
return { gen };
|
|
94
|
+
}
|
|
95
|
+
const period = gen.period ?? 20, swing = gen.swing ?? 14, was = gen.step ?? 0.5, first = gen.firstStep ?? 0.4;
|
|
96
|
+
if (args.steps === undefined || args.arrive !== undefined || !(steps >= 2) || ![steps, period, swing, was, first].every(Number.isFinite)) return { gen };
|
|
97
|
+
const u = [Math.sin(bearing * Math.PI / 180), Math.cos(bearing * Math.PI / 180)];
|
|
98
|
+
// to against a body: the steps end at the nearest point of the approach where the body stands clear of it; when no
|
|
99
|
+
// natural gait ends there, the fit to to itself (and its refusal) as before
|
|
100
|
+
// (ctx.fit.noStop: the walks correct.js fitWalks keeps on to, as a stop would make a later walk refuse; ctx.fit.stopped: the walks that stopped)
|
|
101
|
+
const stop = !ctx.fit.noStop?.has(ctx.index) && clearStop(ctx, c0, u, d, passable(ctx, ctx.f0 + 16 + steps * period + 18), CLEAR * legOf(sk), bearing);
|
|
102
|
+
let r = stop && fitSteps(ctx, gen, c0, u, stop.t, stop);
|
|
103
|
+
if (r && !r.refuse) ctx.fit.stopped?.add(ctx.index);
|
|
104
|
+
if (!r || r.refuse) r = fitSteps(ctx, gen, c0, u, d, null);
|
|
105
|
+
if (r.refuse) ctx.fit.refusals.push(r.refuse);
|
|
106
|
+
return { gen: r.gen, ...(r.adjust ? { adjust: r.adjust } : {}) };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** fitTo's stride, step count and cadence for a walk ending d along u from c0 (stop: the clear point it ends at) -> { gen, adjust? } or { gen, refuse } */
|
|
110
|
+
function fitSteps(ctx, gen, c0, u, d, stop) {
|
|
111
|
+
const { args, tag, sk } = ctx, steps = gen.steps, period = gen.period ?? 20, swing = gen.swing ?? 14, was = gen.step ?? 0.5, first = gen.firstStep ?? 0.4;
|
|
112
|
+
const target = stop ? `${xzText(stop.at)}, ${r2(stop.gap)} m before ${stop.near} (to ${xzText(args.to)} is against it)` : `to ${xzText(args.to)}`;
|
|
113
|
+
const reach = g => { const e = walkEndAt(walk(g, ctx.keys, sk, tag)); return { end: e, along: (e[0] - c0[0]) * u[0] + (e[1] - c0[1]) * u[1] }; };
|
|
114
|
+
const { end, along } = reach(gen);
|
|
115
|
+
if (Math.abs(d - along) <= REACH_TOL) return { gen };
|
|
116
|
+
const fps = ctx.fps ?? 24, leg = legOf(sk), nat = WALK_RATIO * leg, span = steps * period;
|
|
117
|
+
const band = n => (n >= 3 ? STEP : FIRST).map(x => r2(x * leg)), pLo = Math.ceil(fps / CADENCE[1]), pHi = Math.floor(fps / CADENCE[0]);
|
|
118
|
+
const stretch = ctx.fit.stretch === true && !(ctx.use ?? []).some((x, j) => j !== ctx.index && x?.f0 > ctx.f0) && !ctx.keys.some(k => k.f > ctx.f0 && /^keys\[/.test(k.src ?? ""));
|
|
119
|
+
// n steps: the period (its own, or the one that keeps the stepping time), the stride that ends them at to (the end moves
|
|
120
|
+
// by the first step and a stride a middle step, rest the closing step's share; with steps 3 and up the first step keeps
|
|
121
|
+
// its share of the stride unless args give it, as a person's first step is a little shorter), whether they stay in their bands
|
|
122
|
+
const rest = along - first - (steps - 2) * was, share = args.firstStep === undefined ? first / was : 0;
|
|
123
|
+
const at = (n, pAt) => {
|
|
124
|
+
const p = pAt ?? (stretch || n === steps ? period : Math.min(Math.max(pHi, period), Math.floor(span / n))), name = n >= 3 ? "step" : "firstStep";
|
|
125
|
+
const value = n >= 3 ? (d - rest - (share ? 0 : first)) / (share + n - 2) : d - rest, lead = n >= 3 && share ? share * value : first;
|
|
126
|
+
const [lo, hi] = band(n), ratio = value * p / fps / nat, inBand = (x, [a, b]) => x >= a && x <= b;
|
|
127
|
+
return { n, p, name, value, ratio, ok: inBand(value, [lo, hi]) && inBand(lead, band(2)) && (p === period || inBand(p, [pLo, pHi])) };
|
|
128
|
+
};
|
|
129
|
+
const own = at(steps), most = Math.min(60, Math.ceil(d / band(3)[0]) + 2);
|
|
130
|
+
let pick = own.ok && own.ratio >= RATIO[0] && own.ratio <= RATIO[1] ? own : null, room, short;
|
|
131
|
+
// nearest the walk ratio, a step count further from the one asked costing CHANGE of it a step
|
|
132
|
+
const cost = c => Math.abs(Math.log(c.ratio)) + CHANGE * Math.abs(c.n - steps), nearest = list => list.filter(c => c.ok).sort((a, b) => cost(a) - cost(b) || a.n - b.n)[0];
|
|
133
|
+
const counts = Array.from({ length: most - 1 }, (_, i) => i + 2);
|
|
134
|
+
if (!pick) pick = nearest(counts.map(n => at(n)));
|
|
135
|
+
if (!pick && !stretch) {
|
|
136
|
+
// more steps, ending later into frames nothing uses: the walk's own period, or the slowest natural one that ends in time
|
|
137
|
+
room = roomAfter(ctx, ctx.f0 + 16 + span);
|
|
138
|
+
pick = nearest(counts.filter(n => n > steps).map(n => at(n, Math.min(period, Math.floor((room - ctx.f0 - 34) / n)))).filter(c => c.p >= pLo && c.n * c.p > span));
|
|
139
|
+
}
|
|
140
|
+
if (!pick) {
|
|
141
|
+
// the walk's own steps at its band's edge, when they end within REACH_TOL of to
|
|
142
|
+
const [lo, hi] = band(steps), value = Math.min(hi, Math.max(lo, own.value)), ratio = value * period / fps / nat, lead = steps >= 3 && share ? share * value : first;
|
|
143
|
+
const g = { ...gen, [own.name]: r2(value), ...(steps >= 3 && share ? { firstStep: r2(lead) } : {}) };
|
|
144
|
+
const left = ratio >= RATIO[0] && ratio <= RATIO[1] && lead >= band(2)[0] && lead <= band(2)[1] ? d - reach(g).along : Infinity;
|
|
145
|
+
if (Math.abs(left) <= REACH_TOL) ({ pick, short } = { pick: { ...own, value: r2(value), ratio }, short: left });
|
|
146
|
+
}
|
|
147
|
+
const [sLo, sHi] = band(3), gait = `a natural gait (step ${sLo}..${sHi} m, ${CADENCE[0]}..${CADENCE[1]} steps a second: period ${pLo}..${pHi} at ${fps} fps`;
|
|
148
|
+
const miss = `${r2(Math.abs(d - along))} m ${along < d ? "short of" : "past"} ${stop ? `the point ${xzText(stop.at)} clear of ${stop.near} before to ${xzText(args.to)}` : `to ${xzText(args.to)}`} (${r2(d)} m away)`;
|
|
149
|
+
if (!pick) {
|
|
150
|
+
// short: the fewest steps at the longest natural step, at the quickest natural cadence (the least stepping time that reaches)
|
|
151
|
+
const n = 2 + Math.ceil((d - rest - first) / sHi - 1e-9), e = ctx.f0 + 16 + n * pLo + 18;
|
|
152
|
+
return { gen, refuse: `${tag}: steps ${steps} at period ${period} (${span} frames of stepping) walk ${r2(along)} m and end at ${xzText(end)}, ${miss}; ${along < d
|
|
153
|
+
? `in ${gait}) that takes ${n} steps and ${n * pLo} frames of stepping or more (the walk would end at f${e} or later, not f${ctx.f0 + 16 + span + 18}): leave steps${args.step === undefined ? "" : " and step"} out (walkTo picks the count), or give steps: ${n} and room for the walk before what follows`
|
|
154
|
+
: `no natural step count ends there: leave steps out (walkTo picks the count)`}` };
|
|
155
|
+
}
|
|
156
|
+
const timed = pick.p === period ? {} : { period: pick.p, swing: Math.max(2, Math.min(pick.p - 1, Math.round(swing * pick.p / period))) };
|
|
157
|
+
const lead = pick.n >= 3 && share > 0, strides = v => ({ [pick.name]: r2(v), ...(lead ? { firstStep: r2(share * v) } : {}) });
|
|
158
|
+
let fitted = { ...gen, steps: pick.n, ...timed, ...strides(pick.value) };
|
|
159
|
+
const left = short ?? d - reach(fitted).along;
|
|
160
|
+
if (short === undefined && Math.abs(left) > 0.02) fitted = { ...fitted, ...strides(pick.value + left / (pick.n >= 3 ? share + pick.n - 2 : 1)) };
|
|
161
|
+
const v = fitted[pick.name], dflt = pick.name === "step" ? was : first, e0 = ctx.f0 + 16 + span + 18, e1 = ctx.f0 + 16 + pick.n * pick.p + 18;
|
|
162
|
+
const changes = [pick.n !== steps && `steps ${steps} -> ${pick.n}`, timed.period && `period ${period} -> ${timed.period} (swing ${swing} -> ${timed.swing})`, `${pick.name} ${dflt} -> ${v} m`,
|
|
163
|
+
lead && fitted.firstStep !== first && `firstStep ${first} -> ${fitted.firstStep} m`].filter(Boolean).join(", ");
|
|
164
|
+
const when = e1 === e0 ? "" : `; it ends f${e1}, not f${e0} (${stretch ? "nothing follows it" : e1 > e0 ? `nothing uses f${e0 + 1}-f${e1}` : `the pose holds to f${e0}`})`;
|
|
165
|
+
const needs = `steps ${steps} would need a ${own.name} of ${r2(own.value)} m at ${r2(fps / period)} steps a second`;
|
|
166
|
+
const why = short !== undefined ? ` (${gait}; ${needs})` : pick.n === steps && !timed.period ? ` (at ${pick.name} ${dflt} they walk ${r2(along)} of ${r2(d)} m)` : ` in ${gait}; ${needs})${when}`;
|
|
167
|
+
const ends = short === undefined ? `end at ${target}` : `end ${r2(Math.abs(short))} m ${short > 0 ? "short of" : "past"} ${target}, the ${short > 0 ? "longest" : "shortest"} natural step`;
|
|
168
|
+
const adjust = { args: { ...(pick.n === steps ? {} : { steps: pick.n }), ...timed, [pick.name]: v, ...(lead && fitted.firstStep !== first ? { firstStep: fitted.firstStep } : {}) }, note: `${tag}: ${changes}, so steps ${pick.n} ${ends}${why}` };
|
|
169
|
+
return { gen: fitted, adjust };
|
|
170
|
+
}
|
|
@@ -4,6 +4,7 @@ import Foldout from "./Foldout.jsx";
|
|
|
4
4
|
import { ko, isKo } from "../locale.js";
|
|
5
5
|
import { readReferenceImage } from "./ReferenceImageField.jsx";
|
|
6
6
|
import { createHttpTransport } from "../workflow/agent-client.js";
|
|
7
|
+
import { saveExport } from "../export-workspace.js";
|
|
7
8
|
import "./pose-image.css";
|
|
8
9
|
|
|
9
10
|
// The sidecar accepts at most 6 references per request, shared with the scene's identity and
|
|
@@ -145,7 +146,7 @@ export default function PoseImagePanel({ hidden, capturePoseImage }) {
|
|
|
145
146
|
<img src={result.dataUrl} alt={result.prompt} />
|
|
146
147
|
</button>
|
|
147
148
|
<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
|
+
<a className="btn ghost small" href={result.dataUrl} download={`cozyclay-pose-${result.at}.png`} onClick={(event) => { event.preventDefault(); void saveExport(result.dataUrl, `cozyclay-pose-${result.at}.png`); }}>{ko("Download", "다운로드")}</a>
|
|
149
150
|
<button type="button" className="btn ghost small" disabled={refs.length >= USER_REFERENCES_MAX}
|
|
150
151
|
onClick={() => setPoseImageState({ refs: [...store.refs, result.dataUrl].slice(0, USER_REFERENCES_MAX) })}>
|
|
151
152
|
{ko("Use as reference", "참조로 쓰기")}
|
|
@@ -194,7 +195,7 @@ function PoseImageViewer({ results, selected, onSelect, onClose }) {
|
|
|
194
195
|
<figcaption>
|
|
195
196
|
<span className="pose-image-viewer-prompt">{entry.prompt}</span>
|
|
196
197
|
{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
|
+
<a className="btn ghost small" href={entry.dataUrl} download={`cozyclay-pose-${entry.at}.png`} onClick={(event) => { event.preventDefault(); void saveExport(entry.dataUrl, `cozyclay-pose-${entry.at}.png`); }}>{ko("Download", "다운로드")}</a>
|
|
198
199
|
<button type="button" className="btn ghost small" data-testid="pose-image-viewer-close" onClick={onClose}>{ko("Close", "닫기")}</button>
|
|
199
200
|
</figcaption>
|
|
200
201
|
</figure>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react";
|
|
2
2
|
import Foldout from "./Foldout.jsx";
|
|
3
3
|
import { ko, isKo } from "../locale.js";
|
|
4
|
-
import { effortOptions } from "../workflow/agent-client.js";
|
|
4
|
+
import { effortOptions } from "../workflow/agent-client.js"; import { saveExport } from "../export-workspace.js";
|
|
5
5
|
import "./sculpt.css";
|
|
6
6
|
|
|
7
7
|
// Sculpt mode side panel (#739). Pure view: the run controller owns the run state and the
|
|
@@ -241,7 +241,7 @@ function BlueprintSection({ blueprint, disabled }) {
|
|
|
241
241
|
</a>
|
|
242
242
|
<figcaption>
|
|
243
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>
|
|
244
|
+
<a href={sheet.dataUrl} download="sculpt-blueprint.png" data-testid="sculpt-blueprint-download" onClick={(event) => { event.preventDefault(); void saveExport(sheet.dataUrl, "sculpt-blueprint.png"); }}>{ko("Download", "내려받기")}</a>
|
|
245
245
|
</figcaption>
|
|
246
246
|
</figure>
|
|
247
247
|
)}
|
package/src/result-modal.jsx
CHANGED
|
@@ -73,7 +73,7 @@ export default function ResultModal({ result, copied, recordedVideoName, onClose
|
|
|
73
73
|
</button>
|
|
74
74
|
{result.frame && (
|
|
75
75
|
<button type="button" className="btn" onClick={onDownload} disabled={downloadDisabled}>
|
|
76
|
-
{result.downloaded ? ko("
|
|
76
|
+
{result.downloaded ? ko("Exported", "내보냄") : result.frameB ? ko("Export start and end frames", "시작·끝 프레임 내보내기") : ko("Export frame", "프레임 내보내기")}
|
|
77
77
|
</button>
|
|
78
78
|
)}
|
|
79
79
|
</div>
|
|
@@ -89,8 +89,8 @@ export default function ResultModal({ result, copied, recordedVideoName, onClose
|
|
|
89
89
|
<span>{copied ? ko("Copied. Paste it into your AI service's prompt box.", "복사됐어요. AI 서비스의 입력창에 붙여 넣으세요.") : ko("Press “Copy prompt” above, then paste it into your AI service.", "위의 ‘프롬프트 복사’를 누른 뒤 AI 서비스 입력창에 붙여 넣으세요.")}</span>
|
|
90
90
|
</li>
|
|
91
91
|
<li>
|
|
92
|
-
<strong>{ko("
|
|
93
|
-
<span>{result.frameB ? ko("Press “
|
|
92
|
+
<strong>{ko("Export the frame", "프레임 내보내기")}</strong>
|
|
93
|
+
<span>{result.frameB ? ko("Press “Export start and end frames” to save both PNGs.", "‘시작·끝 프레임 내보내기’를 눌러 두 PNG를 저장하세요.") : hasFrame ? ko("Press “Export frame” to save the PNG.", "‘프레임 내보내기’를 눌러 PNG를 저장하세요.") : ko("This result has no frame to export.", "이 결과에는 내보낼 프레임이 없어요.")}</span>
|
|
94
94
|
</li>
|
|
95
95
|
<li>
|
|
96
96
|
<strong>{ko("Attach the image to your AI", "AI에 이미지 첨부")}</strong>
|
|
@@ -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
|
+
}
|