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.
Files changed (110) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/bin/agent/agent-routes.mjs +4 -4
  3. package/bin/agent/main-agent.mjs +21 -7
  4. package/bin/agent/main-prompt.mjs +7 -6
  5. package/bin/agent/script-tool.mjs +2 -1
  6. package/bin/agent/studio-prompt.mjs +1 -1
  7. package/bin/agent/studio-rebase.mjs +5 -4
  8. package/bin/agent/studio-task-routes.mjs +1 -1
  9. package/bin/agent/studio-tasks.mjs +116 -25
  10. package/bin/agent/studio-tools.mjs +3 -2
  11. package/bin/cozyclay.mjs +4 -2
  12. package/bin/live-endpoint.mjs +2 -0
  13. package/dist/app/index.html +6 -6
  14. package/dist/assets/TaskBoard-GR8xZm5c.js +1 -0
  15. package/dist/assets/{app-BlwOpest.css → app-Bhe0TpGz.css} +1 -1
  16. package/dist/assets/app-C4QTcDcb.js +5144 -0
  17. package/dist/assets/compile-worker-oy_P9HHh.js +6 -0
  18. package/dist/assets/motion3-compile-dgGhrvPK.js +1 -0
  19. package/dist/assets/scene-asset-sync-BEjjhNEo.css +1 -0
  20. package/dist/assets/scene-asset-sync-qoxcdrhj.js +13 -0
  21. package/dist/assets/task-board-BqReHzOi.js +15 -0
  22. package/dist/assets/workflow-C87p6E1E.js +179 -0
  23. package/dist/cozyclay-package.json +1 -1
  24. package/dist/index.html +1 -1
  25. package/dist/sitemap.xml +1 -1
  26. package/dist/workflow/index.html +4 -4
  27. package/mcp/README.md +1 -1
  28. package/mcp/live-hub.mjs +5 -3
  29. package/mcp/tool-handlers.mjs +5 -5
  30. package/package.json +1 -1
  31. package/src/App.jsx +42 -64
  32. package/src/bus-task-scopes.js +59 -11
  33. package/src/bus-task-targets.js +35 -9
  34. package/src/bus-tasks.js +23 -10
  35. package/src/command-bus.js +7 -4
  36. package/src/commands/ai.js +1 -1
  37. package/src/commands/export.js +1 -1
  38. package/src/commands/motion3.js +27 -7
  39. package/src/export-workspace.js +244 -0
  40. package/src/keyframe-pack.js +5 -1
  41. package/src/live-control.js +14 -7
  42. package/src/motion3/compile-job.js +15 -3
  43. package/src/motion3/compiler/channels.js +74 -24
  44. package/src/motion3/compiler/compile.js +2 -1
  45. package/src/motion3/compiler/idle.js +2 -0
  46. package/src/motion3/compiler/overlap.js +180 -0
  47. package/src/motion3/compiler/pose.js +6 -4
  48. package/src/motion3/compiler/validate-common.js +1 -0
  49. package/src/motion3/compiler/validate-parts.js +5 -1
  50. package/src/motion3/compiler/validate.js +30 -10
  51. package/src/motion3/correct.js +236 -0
  52. package/src/motion3/docs.js +8 -6
  53. package/src/motion3/face-lean.js +31 -0
  54. package/src/motion3/fix/index.js +5 -1
  55. package/src/motion3/fix/reach-body.js +172 -0
  56. package/src/motion3/hint-check.js +44 -0
  57. package/src/motion3/json-patch.js +120 -12
  58. package/src/motion3/lenient.js +46 -4
  59. package/src/motion3/object-keys.js +17 -5
  60. package/src/motion3/pipeline.js +46 -9
  61. package/src/motion3/qa/arm-checks.js +96 -0
  62. package/src/motion3/qa/asked.js +113 -0
  63. package/src/motion3/qa/goals.js +37 -8
  64. package/src/motion3/qa/hints-goal.js +6 -5
  65. package/src/motion3/qa/index.js +5 -2
  66. package/src/motion3/qa/key-checks.js +4 -3
  67. package/src/motion3/qa/motion-life.js +243 -0
  68. package/src/motion3/qa/spec-checks.js +15 -9
  69. package/src/motion3/qa/verdict.js +50 -0
  70. package/src/motion3/refs.js +38 -14
  71. package/src/motion3/repair-watch.js +132 -0
  72. package/src/motion3/repair.js +52 -19
  73. package/src/motion3/report.js +50 -9
  74. package/src/motion3/schema/topics.js +6 -4
  75. package/src/motion3/schema/types.js +1 -1
  76. package/src/motion3/skills/basic.js +15 -8
  77. package/src/motion3/skills/index.js +52 -18
  78. package/src/motion3/skills/info.js +1 -1
  79. package/src/motion3/skills/stance.js +6 -0
  80. package/src/motion3/skills/walk-fit.js +170 -0
  81. package/src/panels/PoseImagePanel.jsx +3 -2
  82. package/src/panels/SculptPanel.jsx +2 -2
  83. package/src/result-modal.jsx +3 -3
  84. package/src/shell/ExportFolderPreference.jsx +103 -0
  85. package/src/shell/PreferencesDialog.jsx +3 -166
  86. package/src/shell/StatusBar.jsx +1 -1
  87. package/src/shell/preference-controls.jsx +114 -0
  88. package/src/shell/preference-pages.jsx +72 -0
  89. package/src/shell/preferences.css +46 -0
  90. package/src/shot-prompt.js +29 -9
  91. package/src/studio-actions.js +2 -2
  92. package/src/studio-agent-motion.js +19 -9
  93. package/src/studio-agent-protocol.js +1 -1
  94. package/src/studio-app-binding.js +3 -2
  95. package/src/workflow/AgentPanel.jsx +2 -2
  96. package/src/workflow/CozySceneNode.jsx +1 -1
  97. package/src/workflow/WorkflowBuilder.jsx +2 -2
  98. package/src/workflow/keyframe-pack-request.js +3 -10
  99. package/src/workflow/task-board.css +1 -1
  100. package/src/workflow/task-board.js +7 -5
  101. package/tools/loc-baseline.json +1 -2
  102. package/tools/run-tests.mjs +5 -5
  103. package/dist/assets/TaskBoard-ngdqiu7u.js +0 -1
  104. package/dist/assets/app-DmR7jBkz.js +0 -5144
  105. package/dist/assets/compile-worker-D0pKioa0.js +0 -5
  106. package/dist/assets/motion3-compile-BB3_1-EB.js +0 -1
  107. package/dist/assets/scene-asset-sync-CBx7XRti.css +0 -1
  108. package/dist/assets/scene-asset-sync-Cgdtr1Yw.js +0 -13
  109. package/dist/assets/task-board-ErQvfoaz.js +0 -14
  110. package/dist/assets/workflow-zQM-EKW1.js +0 -179
@@ -1,185 +1,22 @@
1
1
  import { useEffect, useMemo, useRef, useState } from "react";
2
2
  import { createPortal } from "react-dom";
3
- import { getAnalyticsOptOut, setAnalyticsOptOut } from "../analytics.js";
4
- import { loadAutoColor, saveAutoColor } from "../auto-color.js";
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);
@@ -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 downloads.`, `파일 ${handedOff}개는 이미 전달했어요. 다시 시도할 때 해당 다운로드는 건너뛰어요.`),
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
+ }
@@ -0,0 +1,72 @@
1
+ import { getAnalyticsOptOut, setAnalyticsOptOut } from "../analytics.js";
2
+ import { loadAutoColor, saveAutoColor } from "../auto-color.js";
3
+ import { readStoredGridView, writeStoredGridView } from "../grid-view.js";
4
+ import { ko } from "../locale.js";
5
+ import ExportFolderPreference from "./ExportFolderPreference.jsx";
6
+ import {
7
+ LanguageControls,
8
+ PreferenceRow,
9
+ PreferenceSection,
10
+ ReadonlyHotkeys,
11
+ ThemeControls,
12
+ ToggleControl,
13
+ } from "./preference-controls.jsx";
14
+
15
+ export function preferenceSection(section, props) {
16
+ const { autoColor, setAutoColor, analyticsOptOut, setAnalyticsOptOutState, gridView, setGridView } = props;
17
+
18
+ switch (section) {
19
+ case "Appearance":
20
+ return (
21
+ <>
22
+ <PreferenceSection title={ko("Display", "표시")}>
23
+ <PreferenceRow title={ko("Language", "언어")} description={ko("Choose the labels used throughout the editor.", "에디터에서 사용할 언어를 선택하세요.")}>
24
+ <LanguageControls />
25
+ </PreferenceRow>
26
+ <PreferenceRow title={ko("Theme", "테마")} description={ko("Dark keeps the studio dim around the stage; Light uses bright glass panels.", "다크는 무대 주변을 어둡게, 라이트는 밝은 유리 패널을 써요.")}>
27
+ <ThemeControls />
28
+ </PreferenceRow>
29
+ </PreferenceSection>
30
+ <PreferenceSection title={ko("Editor", "에디터")}>
31
+ <PreferenceRow title={ko("Auto colour", "자동 색")} description={ko("Give scene objects stable display colours.", "씬 오브젝트에 고정된 표시 색을 사용해요.")}>
32
+ <ToggleControl pressed={autoColor} onChange={(value) => { setAutoColor(value); saveAutoColor(value); }} label={ko("Auto colour", "자동 색")} testid="preferences-auto-color" />
33
+ </PreferenceRow>
34
+ <PreferenceRow title={ko("Anonymous analytics", "익명 사용 통계")} description={ko("Help improve CozyClay with anonymous product events.", "익명 제품 이벤트로 CozyClay 개선을 도와요.")}>
35
+ <ToggleControl
36
+ pressed={!analyticsOptOut}
37
+ onChange={async (value) => setAnalyticsOptOutState(await setAnalyticsOptOut(!value))}
38
+ label={ko("Anonymous analytics", "익명 사용 통계")}
39
+ testid="settings-analytics"
40
+ />
41
+ </PreferenceRow>
42
+ </PreferenceSection>
43
+ </>
44
+ );
45
+ case "Viewport":
46
+ return (
47
+ <PreferenceSection>
48
+ <PreferenceRow title={ko("Reference grid", "기준 그리드")} description={ko("Use a dark reference grid instead of the clay deck.", "클레이 데크 대신 어두운 기준 그리드를 사용해요.")}>
49
+ <ToggleControl pressed={gridView} onChange={(value) => { setGridView(value); writeStoredGridView(globalThis.localStorage, value); }} label={ko("Reference grid", "기준 그리드")} testid="preferences-grid" />
50
+ </PreferenceRow>
51
+ </PreferenceSection>
52
+ );
53
+ case "Export":
54
+ return (
55
+ <PreferenceSection>
56
+ <ExportFolderPreference />
57
+ </PreferenceSection>
58
+ );
59
+ case "Hotkeys":
60
+ return (
61
+ <PreferenceSection>
62
+ <p className="v2-preferences__section-note">{ko("Shortcuts are read-only here. They stay active whenever focus is outside a text field.", "단축키는 여기서 읽기 전용이에요. 텍스트 입력란 밖에서 항상 사용할 수 있어요.")}</p>
63
+ <ReadonlyHotkeys />
64
+ </PreferenceSection>
65
+ );
66
+ default:
67
+ return null;
68
+ }
69
+ }
70
+
71
+ // Kept here so the dialog can load a new setting's initial value lazily.
72
+ export { getAnalyticsOptOut, loadAutoColor, readStoredGridView };
@@ -623,3 +623,49 @@
623
623
  grid-template-columns: 1fr;
624
624
  }
625
625
  }
626
+
627
+ /* Export folder: full-width row because folder names and three controls do not
628
+ fit the 240px control column used by toggles and language pairs. */
629
+ .v2-preferences .v2-preferences__row--full .v2-preferences__row-copy {
630
+ margin-bottom: var(--sp-2, 12px);
631
+ }
632
+
633
+ .v2-preferences .v2-preferences__row--full .v2-preferences__row-control {
634
+ justify-content: flex-start;
635
+ flex-wrap: wrap;
636
+ width: 100%;
637
+ }
638
+
639
+ .v2-preferences .v2-preferences__export-folder {
640
+ display: flex;
641
+ flex-direction: column;
642
+ align-items: flex-start;
643
+ gap: var(--sp-1, 8px);
644
+ min-width: 0;
645
+ width: 100%;
646
+ }
647
+
648
+ .v2-preferences .v2-preferences__export-folder .v2-preferences__language-controls {
649
+ justify-content: flex-start;
650
+ }
651
+
652
+ .v2-preferences .v2-preferences__export-folder-name {
653
+ color: var(--text-1, #e4e4e7);
654
+ font-size: var(--prefs-ui-size, 12px);
655
+ line-height: 1.35;
656
+ overflow-wrap: anywhere;
657
+ }
658
+
659
+ .v2-preferences .v2-preferences__export-folder-status {
660
+ color: var(--select, #e8a33d);
661
+ font-size: var(--prefs-secondary-size, 11px);
662
+ line-height: 1.35;
663
+ }
664
+
665
+ .v2-preferences .v2-preferences__export-folder-note {
666
+ display: block;
667
+ margin-top: var(--sp-1, 8px);
668
+ color: var(--text-3, #636369);
669
+ font-size: var(--prefs-secondary-size, 11px);
670
+ line-height: 1.35;
671
+ }
@@ -1,20 +1,40 @@
1
1
  // Builds the labelled text prompt sent alongside a reference clip when a shot
2
- // goes to an image or video model. Pure string building: no DOM, no imports.
2
+ // goes to an image or video model. Pure string building: no DOM; the only
3
+ // import is the editor's own lens math, so the prompt and the viewport share
4
+ // one focal <-> fov calibration.
5
+ import { fovToFocalMm, shotAspectRatio } from "./shot.js";
3
6
 
4
7
  const CAMERA_FOLLOWS_REFERENCE = "follow the reference clip's framing and camera motion exactly";
5
8
  const NEUTRAL_LOOK = "clay previs, neutral look";
6
9
  const MOTION_LINE = "motion and blocking come from the reference; keep timing";
7
10
 
8
- // FOV degrees -> 35mm-equivalent focal length -> shot-size word.
9
- // A wide field of view reads as a wide shot; a narrow one as a long shot.
10
- export function shotSizeFromFov(fovDeg) {
11
- if (!Number.isFinite(fovDeg) || fovDeg <= 0) return "normal";
12
- const equivalentFocalMm = 18 / Math.tan((fovDeg * Math.PI) / 360);
13
- if (equivalentFocalMm <= 20) return "wide";
14
- if (equivalentFocalMm <= 40) return "normal";
11
+ // Focal length (on the editor's filmback) -> lens word, in crew terms:
12
+ // under 35 mm reads wide, 35-50 mm normal, longer than 50 mm long.
13
+ export function lensWordFromFocalMm(focalMm) {
14
+ if (!Number.isFinite(focalMm) || focalMm <= 0) return "normal";
15
+ if (focalMm < 35) return "wide";
16
+ if (focalMm <= 50) return "normal";
15
17
  return "long";
16
18
  }
17
19
 
20
+ // FOV degrees -> focal length through the editor's own calibration
21
+ // (shot.js fovToFocalMm on the given filmback) -> lens word. No second,
22
+ // independently calibrated conversion: the word matches the lens the editor
23
+ // reports for that field of view.
24
+ export function shotSizeFromFov(fovDeg, filmback = {}) {
25
+ if (!Number.isFinite(fovDeg) || fovDeg <= 0) return "normal";
26
+ const focalMm = fovToFocalMm((fovDeg * Math.PI) / 180, filmback.sensorId, filmback.aspectRatio);
27
+ // Round to 0.1 mm so a lens that round-trips through fov keeps its word.
28
+ return lensWordFromFocalMm(Math.round(focalMm * 10) / 10);
29
+ }
30
+
31
+ // The editor already reports the focal length it framed with; only a meta
32
+ // without one falls back to converting its fov on the meta's filmback.
33
+ function lensWord(meta) {
34
+ if (Number.isFinite(meta.focalMm) && meta.focalMm > 0) return lensWordFromFocalMm(meta.focalMm);
35
+ return shotSizeFromFov(meta.fovDeg, { sensorId: meta.sensorId, aspectRatio: shotAspectRatio(meta.aspect) });
36
+ }
37
+
18
38
  function secondsText(seconds) {
19
39
  return `${Number(seconds.toFixed(2))}s`;
20
40
  }
@@ -68,7 +88,7 @@ export function buildShotPrompt(meta, options = {}) {
68
88
 
69
89
  const lines = [
70
90
  `SHOT: ${meta.shotTitle || "untitled shot"}, ${secondsText(seconds)}, ${meta.aspect || ""}`.trimEnd(),
71
- `LENS: ${meta.focalMm}mm, ${shotSizeFromFov(meta.fovDeg)} shot`,
91
+ `LENS: ${meta.focalMm}mm, ${lensWord(meta)} shot`,
72
92
  referenceOwnsCamera ? `CAMERA: ${CAMERA_FOLLOWS_REFERENCE}` : `CAMERA: ${cameraWords(meta.cameraMove)}`,
73
93
  `LIGHT: ${lightWords(meta.keyLight)}`,
74
94
  `CAST: ${(meta.cast || []).map((member) => member.name).join(", ") || "none"}`,
@@ -130,7 +130,7 @@ export const STUDIO_ACTIONS = freezeStudioData([
130
130
  { id: "view.setGhosts", label: "Ghosts", kind: "transient", input: input({ mode: { type: "string", enum: ["off", "step", "keys"] } }, { count: { type: "integer", minimum: 1, maximum: 6 }, step: { type: "integer", minimum: 1, maximum: 24 } }),
131
131
  description: "Onion skin in the editor viewport, like the View menu's Ghosts: translucent poses of the active character's installed take, count (1-6, default 3) before the playhead in blue and as many after it in orange, fading with distance in time; props the take carries (a kit's bat) ghost with the body. mode step spaces them every step frames (1-24, default 4), keys puts them on the spec's key frames, off hides them; e.g. { mode: \"step\", count: 3, step: 4 }. An editor overlay: never in capture_frame, exports or contact sheets (verify_result ghosts: true draws them on a views sheet). A viewer setting: never saved or undone." },
132
132
  { id: "export.shotVideo", exposure: "confirm", confirmationReason: "Exporting writes a video file outside the Studio.", label: "Export video (mp4)", kind: "job", timeoutMs: 300_000, input: input({}, { ...shotId, scope: { type: "string", enum: ["timeline", "shot"], description: "timeline: the whole timeline, every shot back to back; shot: one shot (shotId, or the one under the playhead)." } }),
133
- description: "Record a shot to an MP4 (camera move and character motion, no editor chrome), like the Export menu's Video (mp4), and ask the browser to download it. With shotId it records that shot's own range; without it, what the menu records: the whole take when there is motion, else the shot under the playhead (else the first shot), keyed from the current camera if it has no keys. Waits until the file is encoded (up to 5 minutes) and answers status \"completed\" with output.fileName and output.frameCount. One export at a time; not a motion generation." },
133
+ description: "Record a shot to an MP4 (camera move and character motion, no editor chrome), like the Export menu's Video (mp4), and hand it to the configured export folder or, when none is usable, the browser download. With shotId it records that shot's own range; without it, what the menu records: the whole take when there is motion, else the shot under the playhead (else the first shot), keyed from the current camera if it has no keys. Waits until the file is encoded (up to 5 minutes) and answers status \"completed\" with output.fileName and output.frameCount. One export at a time; not a motion generation." },
134
134
  { id: "scene.create", label: "New scene", kind: "document", input: input(),
135
135
  description: `Add an empty scene (named SCENE 01, SCENE 02, ...) after the others and open it, like the scene menu's New scene. ${SCENE_MOVES} ${NOT_UNDOABLE}` },
136
136
  { id: "scene.duplicate", label: "Duplicate scene", kind: "document", input: input(sceneId),
@@ -152,7 +152,7 @@ export const STUDIO_ACTIONS = freezeStudioData([
152
152
  { id: "object.duplicate", label: "Duplicate object", kind: "mutation", undoDomain: "objects", input: input({}, { objectId: idSchema }),
153
153
  description: "Copy a scene object (the selected one when objectId is omitted) and place the copy half a metre beside it." },
154
154
  { id: "ai.prepareShot", label: "Send to AI", kind: "job", input: input({}, { mode: { type: "string", enum: ["image", "video"] }, model: { type: "string", enum: AI_IMAGE_MODELS } }),
155
- description: "Prepare the Studio's Send-to-AI package for the shot under the playhead (else the current camera): the prompt for an external image or video model, written from the shot's camera, cast, poses, environment and style, plus reference frames rendered from the shot camera (its first and last camera key when it has keys). No AI service is called. mode \"image\" is a still, \"video\" a shot with its camera move; model is the image model the prompt is written for (image mode only). Each omitted choice keeps the Studio's current one; a given one becomes it. The Studio opens its result panel, where the user copies the prompt and downloads the frames; the frame images stay there and are not in the answer. Answers status \"completed\" with output.prompt, mode, modelLabel, shot (id, name, range), aspectRatio, cameraMode and referenceFrames (how many frames the panel shows). Changes nothing in the scene; not a motion generation." },
155
+ description: "Prepare the Studio's Send-to-AI package for the shot under the playhead (else the current camera): the prompt for an external image or video model, written from the shot's camera, cast, poses, environment and style, plus reference frames rendered from the shot camera (its first and last camera key when it has keys). No AI service is called. mode \"image\" is a still, \"video\" a shot with its camera move; model is the image model the prompt is written for (image mode only). Each omitted choice keeps the Studio's current one; a given one becomes it. The Studio opens its result panel, where the user copies the prompt and exports the frames to the configured export folder or browser downloads; the frame images stay there and are not in the answer. Answers status \"completed\" with output.prompt, mode, modelLabel, shot (id, name, range), aspectRatio, cameraMode and referenceFrames (how many frames the panel shows). Changes nothing in the scene; not a motion generation." },
156
156
  // The instruction leads the H3 prompt, which the motion API caps at 4000
157
157
  // characters with its camera-lock text (about 225) appended.
158
158
  ]);
@@ -12,7 +12,8 @@ import { buildCollisionCapsules, detectPenetrations, supportsCollisionCleanup }
12
12
  import { collisionBlockers, blockerSummary } from './motion/collision-blockers.js';
13
13
  import { createGroundSampler } from './motion/ground.js';
14
14
  import { OBJECT_LIBRARY } from './scene-objects.js';
15
- import { objectTransformAt } from './object-path.js';
15
+ import { sceneObjectsAt } from './object-travel.js';
16
+ import { objectTracksOf } from './motion3/object-tracks.js';
16
17
  import { sampleAt } from './sample-at.js';
17
18
  import { isProxyFigure } from './scenes.js';
18
19
 
@@ -88,7 +89,7 @@ function stageCast(env, characterId, cast) {
88
89
 
89
90
  /* One take under evaluation, `t`: {evaluator, motion, env, cast, characterId,
90
91
  * character, protectedFrames, range, poseCast?, yieldTask, checkpoint}. */
91
- function blockersAt(t, frame) {
92
+ function blockersAt(t, frame, objects) {
92
93
  t.checkpoint();
93
94
  const rigs = {};
94
95
  for (const member of t.cast) {
@@ -99,18 +100,19 @@ function blockersAt(t, frame) {
99
100
  rigs[member.character.id] = member.evaluator.rig;
100
101
  }
101
102
  const cast = t.cast.map((member) => member.character).filter(Boolean);
102
- return collisionBlockers({ rigs, activeId: t.characterId, characterIds: cast, sceneObjects: t.env.objects, library: OBJECT_LIBRARY, frame, take: { frameCount: t.env.frameCount, fps: 24 } });
103
+ // `objects` already stand where this frame renders them (routes and take tracks), so no re-sampling here.
104
+ return collisionBlockers({ rigs, activeId: t.characterId, characterIds: cast, sceneObjects: objects, library: OBJECT_LIBRARY });
103
105
  }
104
106
  async function samples(t, layer) {
105
107
  const rows = [], poses = [], collisions = [], blockers = [];
106
108
  const cast = t.cast.map((member) => member.character).filter(Boolean);
107
109
  for (let frame = t.range.startFrame; frame < t.range.endFrameExclusive; frame++) {
108
110
  t.checkpoint(); poseFrame(t.evaluator, t.motion, layer, frame);
109
- const shapes = blockersAt(t, frame);
110
- const objects = t.env.objects.map(object => {
111
- const at = objectTransformAt(object, frame, { frameCount: t.env.frameCount, fps: 24 });
112
- return at ? { ...object, x: at.x, y: at.y, z: at.z, rot: at.rot ?? object.rot } : object;
113
- });
111
+ // Objects as the editor renders them at this frame: travel routes plus the
112
+ // object tracks the installed takes bake (object-travel.js sceneObjectsAt).
113
+ const objects = sceneObjectsAt(t.env.objects, frame, t.objectTake);
114
+ for (const o of objects) if (!o.hidden && (o.rotX || o.rotZ)) t.tilted.add(o.id);
115
+ const shapes = blockersAt(t, frame, objects);
114
116
  rows.push(readSample(t.evaluator, createGroundSampler(objects, { floorY: t.env.floor.y, characters: cast })));
115
117
  poses.push(poseValues(t.evaluator.rest));
116
118
  const capsules = buildCollisionCapsules(t.evaluator.rig);
@@ -136,7 +138,7 @@ function judge(t, before, after) {
136
138
  }
137
139
  const supportedCollisionFrames = after.collisions.filter(p => p.length).length;
138
140
  const elevatedFrames = after.rows.flatMap((row, f) => Object.values(row.ground).some(y => y !== t.env.floor.y) ? [t.range.startFrame + f] : []);
139
- const unsupportedObjects = t.env.objects.filter(o => o.attach || o.rotX || o.rotZ);
141
+ const unsupportedObjects = t.env.objects.filter(o => o.attach || o.rotX || o.rotZ || t.tilted.has(o.id));
140
142
  const missingCast = t.cast.filter(m => !m.evaluator || ((m.character.layer?.waypoints?.length ?? 0) > 0 && !t.poseCast));
141
143
  const measuredSupportFrames = after.support.frames.filter(f => f.measured).length;
142
144
  const flat = t.env.floor.model === 'flat' && elevatedFrames.length === 0 && Math.abs(t.character.y ?? 0) < 1e-8;
@@ -164,6 +166,13 @@ function receiptVerification(v) {
164
166
  limitations: v.limitations, surfaceMeasured: m.surfaceMeasured, maxFloorPenetrationM: m.maxFloorPenetrationM, maxContactSlipM: m.maxContactSlipM, maxContactFloatM: m.maxContactFloatM,
165
167
  unsupportedFrames: m.unsupportedFrames, supportedCollisionFrames: m.supportedCollisionFrames, continuityRegressed: m.continuityRegressed, semanticStatus: 'unavailable' };
166
168
  }
169
+ /** Object tracks the installed takes bake (this take and the other cast's), keyed by object id. */
170
+ function installedObjectTracks(target, environment) {
171
+ const takes = new Map((environment.cast ?? []).map(member => [member.character?.id, member.motion]));
172
+ takes.set(target.character.id, target.motion);
173
+ const characters = [...takes.keys()].filter(id => typeof id === 'string').map(id => ({ id }));
174
+ return objectTracksOf({ motionFor: id => takes.get(id) }, characters);
175
+ }
167
176
  /** Motion verification of an installed take, on a private rig clone over
168
177
  * `range` (default and upper bound: the whole take). target: {character, rig,
169
178
  * motion, ikState?, protectedFrames?}; environment has readEnvironment()'s
@@ -177,6 +186,7 @@ export async function verifyInstalledTake({ target, environment, range, poseCast
177
186
  const deadline = now() + verificationBudget(endFrameExclusive - startFrame, verificationMs);
178
187
  const t = { evaluator: isolatedRig(target.rig), motion, env, cast: [], characterId: target.character.id, character: target.character,
179
188
  protectedFrames: target.protectedFrames ?? [], range: { startFrame, endFrameExclusive }, poseCast, yieldTask,
189
+ objectTake: { frameCount: env.frameCount, fps: 24, objectTracks: installedObjectTracks(target, environment) }, tilted: new Set(),
180
190
  checkpoint: () => { if (now() > deadline) fail('VERIFICATION_FAILED', 'Private verification budget exceeded.'); } };
181
191
  try {
182
192
  stageCast(environment, t.characterId, t.cast);
@@ -307,7 +307,7 @@ const receiptSchema = union(...Object.values(receiptVariants));
307
307
  const failureSchema = object({ ok: literal(false), commandId: id, host: identity, code: choices(STUDIO_ERROR_CODES), phase: choices(["admission", "execution", "prepare", "verify", "repair", "commit", "reconcile", "undo"]),
308
308
  affectedIds: ids(100, 0), expectedTargets: array(guardSchema, 24), currentTargets: array(guardSchema, 24), mutated: union(bool, literal("unknown")),
309
309
  preserved: object({ authoredState: choices(["unchanged", "changed", "unknown"]) }), recovery: object({ action: choices(["none", "inspect", "retry", "new_intent", "reconcile", "sign_in"]) }, { retryAllowed: bool }),
310
- }, { message: text(500), jobId: id, txId: id, candidates: array(object({ id, kind: choices(["object", "character", "rig"]), position: nullable(vec3) }), 5) });
310
+ }, { message: text(2000), jobId: id, txId: id, candidates: array(object({ id, kind: choices(["object", "character", "rig"]), position: nullable(vec3) }), 5) });
311
311
 
312
312
  // These are JSON Schema data, not validators with hidden browser dependencies.
313
313
  // x-studio-range is the sole relational schema annotation: end > start.
@@ -196,9 +196,10 @@ export function createStudioAppBinding(ports) {
196
196
  ...(childIds.length ? { childIds: childIds.slice(0, 12), ...(childIds.length > 12 ? { childCount: childIds.length } : {}) } : {}) };
197
197
  };
198
198
  if (patched.length) return { patched, ...extras() };
199
- const shot = s.shots.find(row => row.id === id);
199
+ // A camera key lives inside its shot: still present there means retained, not removed (#863).
200
+ const shot = s.shots.find(row => row.id === id), key = !shot && !entity && s.shots.flatMap(row => row.cameraKeys?.map(k => ({ shotId: row.id, frame: k.frame, id: k.id })) ?? []).find(k => k.id === id);
200
201
  if (shot) return { name: shot.name || shot.id, range: { startFrame: shot.startFrame, endFrameExclusive: shot.endFrame + 1 } };
201
- if (!entity) return { removed: true };
202
+ if (!entity) return key ? { shotId: key.shotId, frame: key.frame } : { removed: true };
202
203
  const brief = { name: entity.name || entity.subject || entity.id, position: { x: entity.x, y: entity.y ?? 0, z: entity.z } };
203
204
  if (compact) return brief;
204
205
  if (!object) return { ...brief, yawDeg: entity.rot ?? 0, scale: entity.scale ?? 1, ...extras() };
@@ -3,7 +3,7 @@ import { FiAlertTriangle, FiArrowUp, FiCamera, FiCheck, FiChevronRight, FiClock,
3
3
  import { ATTACHMENT_ACCEPT, ATTACHMENT_FAILED_NOTICE, ATTACHMENT_LIMIT_NOTICE, ATTACHMENT_TYPE_NOTICE, attachmentFilesFromTransfer, attachmentFromFile, transferCarriesFiles } from "./attachment-image.js";
4
4
  import { isImeComposing } from "../ime.js";
5
5
  import { useStudioShell } from "../shell/studio-shell-context.js";
6
- import { ko } from "../locale.js";
6
+ import { ko } from "../locale.js"; import { saveExport } from "../export-workspace.js";
7
7
  import {
8
8
  AGENT_PANEL_OVERLAY_BREAKPOINT,
9
9
  AGENT_PANEL_RAIL_WIDTH,
@@ -73,7 +73,7 @@ function ImageResultCard({ image, onUse, onUndo, onRegenerate, onOpen }) {
73
73
  ? <div className="agent-placed"><StatusDot tone="ok" />Placed<button type="button" className="agent-image-undo" disabled={applying} onClick={() => onUndo(image)}>{applying ? "Removing…" : "Undo"}</button></div>
74
74
  : <div className="agent-image-actions">
75
75
  <button type="button" className="primary agent-image-use" disabled={applying} onClick={() => onUse(image)}><FiImage size={11} /> {applying ? "Applying…" : "Use in scene"}</button>
76
- <a className="agent-image-download" role="button" href={image.dataUrl} download={`${image.imageId || "agent-image"}.png`}><FiDownload size={11} /> Download</a>
76
+ <a className="agent-image-download" role="button" href={image.dataUrl} download={`${image.imageId || "agent-image"}.png`} onClick={(event) => { event.preventDefault(); void saveExport(image.dataUrl, `${image.imageId || "agent-image"}.png`); }}><FiDownload size={11} /> Download</a>
77
77
  <button type="button" className="agent-image-regenerate" onClick={() => onRegenerate(image)}><FiRotateCw size={11} /> Regenerate</button>
78
78
  </div>}
79
79
  {/* The card states what the editor actually did: an unacknowledged or