cozyclay 3.0.3 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (246) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/INSTALL_FOR_AGENTS.md +146 -0
  3. package/README.md +74 -192
  4. package/THIRD_PARTY_NOTICES.md +0 -21
  5. package/bin/agent/agent-routes.mjs +29 -62
  6. package/bin/agent/agent-runner.mjs +5 -3
  7. package/bin/agent/main-agent.mjs +70 -0
  8. package/bin/agent/main-prompt.mjs +17 -0
  9. package/bin/agent/plan-tool.mjs +114 -0
  10. package/bin/agent/script-tool.mjs +2 -0
  11. package/bin/agent/sculpt-sketch.mjs +190 -0
  12. package/bin/agent/session-store.mjs +4 -3
  13. package/bin/agent/sketch-code.mjs +151 -0
  14. package/bin/agent/sketch-sandbox.mjs +102 -0
  15. package/bin/agent/studio-prompt.mjs +1 -1
  16. package/bin/agent/studio-task-routes.mjs +185 -0
  17. package/bin/agent/studio-tasks.mjs +156 -0
  18. package/bin/agent/studio-tools.mjs +3 -2
  19. package/bin/agent/studio-turn-transaction.mjs +76 -0
  20. package/bin/agent/turn-frames.mjs +4 -0
  21. package/bin/cozyclay.mjs +5 -7
  22. package/dist/app/index.html +7 -6
  23. package/dist/assets/TaskBoard-ngdqiu7u.js +1 -0
  24. package/dist/assets/app-BlwOpest.css +1 -0
  25. package/dist/assets/app-DmR7jBkz.js +5144 -0
  26. package/dist/assets/compile-worker-D0pKioa0.js +5 -0
  27. package/dist/assets/landing-y3EqDgSZ.js +1 -0
  28. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +1 -0
  29. package/dist/assets/motion3-compile-BB3_1-EB.js +1 -0
  30. package/dist/assets/scene-asset-sync-CBx7XRti.css +1 -0
  31. package/dist/assets/scene-asset-sync-Cgdtr1Yw.js +13 -0
  32. package/dist/assets/task-board-ErQvfoaz.js +14 -0
  33. package/dist/assets/workflow-zQM-EKW1.js +179 -0
  34. package/dist/assets/x-bot-tpose-body-BusyR3eg.json +1 -0
  35. package/dist/assets/y-bot-tpose-body-Dy1VKChZ.json +1 -0
  36. package/dist/cozyclay-package.json +1 -1
  37. package/dist/greybox-to-video/index.html +2 -3
  38. package/dist/index.html +7 -57
  39. package/dist/llms.txt +2 -1
  40. package/dist/previs-software/index.html +2 -2
  41. package/dist/privacy/index.html +3 -7
  42. package/dist/seedance-camera-control/index.html +2 -2
  43. package/dist/sitemap.xml +5 -5
  44. package/dist/sw.js +5 -1
  45. package/dist/thumbs/blank-stage-dark.webp +0 -0
  46. package/dist/thumbs/blank-stage-light.webp +0 -0
  47. package/dist/workflow/index.html +5 -4
  48. package/mcp/mesh-file.mjs +3 -1
  49. package/mcp/server.mjs +3 -0
  50. package/mcp/tool-handlers.mjs +2 -2
  51. package/package.json +5 -3
  52. package/src/App.jsx +51 -282
  53. package/src/analytics.js +3 -148
  54. package/src/app-stage.jsx +1 -1
  55. package/src/asset-pane.jsx +5 -10
  56. package/src/auto-flat.js +10 -0
  57. package/src/bus-tasks.js +11 -5
  58. package/src/command-bus.js +1 -1
  59. package/src/commands/elements.js +4 -3
  60. package/src/commands/motion3-compile.js +51 -27
  61. package/src/commands/motion3.js +3 -3
  62. package/src/commands/objects.js +1 -1
  63. package/src/commands/project.js +1 -1
  64. package/src/commands/shot.js +16 -4
  65. package/src/controls.jsx +0 -11
  66. package/src/cuts.js +11 -0
  67. package/src/domains/cast.js +3 -2
  68. package/src/domains/motion.js +3 -10
  69. package/src/domains/playback.js +32 -0
  70. package/src/domains/scenes.js +3 -22
  71. package/src/domains/shots.js +5 -5
  72. package/src/dualview.jsx +11 -15
  73. package/src/hierarchy-panel.css +18 -4
  74. package/src/hierarchy-panel.jsx +12 -11
  75. package/src/kits/held.js +3 -2
  76. package/src/light-blockers.js +2 -2
  77. package/src/main.jsx +5 -0
  78. package/src/material-look.js +189 -0
  79. package/src/material-presets.js +78 -0
  80. package/src/material-textures.js +157 -0
  81. package/src/motion/ground.js +5 -14
  82. package/src/motion/playback-frame-store.js +79 -0
  83. package/src/motion/playback-probe.js +16 -0
  84. package/src/motion/playback-subject.js +7 -0
  85. package/src/motion/timeline.css +43 -5
  86. package/src/motion/timeline.jsx +4 -3
  87. package/src/motion3/body.js +37 -5
  88. package/src/motion3/carried.js +11 -3
  89. package/src/motion3/compile-job.js +1 -1
  90. package/src/motion3/compiler/compile.js +7 -1
  91. package/src/motion3/compiler/finger-close.js +137 -0
  92. package/src/motion3/compiler/limbs.js +18 -3
  93. package/src/motion3/compiler/pose.js +6 -0
  94. package/src/motion3/compiler/validate-objects.js +17 -2
  95. package/src/motion3/compiler/validate-parts.js +2 -1
  96. package/src/motion3/compiler/vocab.js +4 -4
  97. package/src/motion3/describe.js +2 -2
  98. package/src/motion3/fix/index.js +5 -3
  99. package/src/motion3/flight.js +38 -0
  100. package/src/motion3/free-motion.js +10 -7
  101. package/src/motion3/load.js +125 -0
  102. package/src/motion3/mass.js +23 -0
  103. package/src/motion3/object-keys.js +11 -6
  104. package/src/motion3/object-pose.js +11 -3
  105. package/src/motion3/pipeline.js +3 -0
  106. package/src/motion3/qa/explain.js +3 -1
  107. package/src/motion3/qa/grasp-checks.js +123 -0
  108. package/src/motion3/qa/hints-grasp.js +71 -0
  109. package/src/motion3/qa/index.js +3 -1
  110. package/src/motion3/qa/object-checks.js +7 -2
  111. package/src/motion3/qa/patch.js +2 -1
  112. package/src/motion3/qa/spec-checks.js +10 -1
  113. package/src/motion3/schema/fields.js +3 -3
  114. package/src/motion3/schema/topics.js +9 -8
  115. package/src/motion3/schema/types.js +4 -1
  116. package/src/motion3/seat.js +84 -0
  117. package/src/motion3/settle.js +11 -6
  118. package/src/motion3/shapes.js +70 -0
  119. package/src/motion3/skills/catch.js +71 -0
  120. package/src/motion3/skills/index.js +10 -3
  121. package/src/motion3/skills/info.js +13 -0
  122. package/src/motion3/skills/throw.js +72 -0
  123. package/src/object-catalog.jsx +0 -4
  124. package/src/object-gizmo.jsx +2 -1
  125. package/src/object-look.js +9 -5
  126. package/src/object-path.js +1 -1
  127. package/src/panels/ProjectPanel.jsx +42 -21
  128. package/src/panels/details.css +15 -4
  129. package/src/popover.js +37 -0
  130. package/src/posestudio.jsx +15 -10
  131. package/src/primitive-look.js +38 -0
  132. package/src/project-browser-art.jsx +19 -0
  133. package/src/project-browser.css +74 -121
  134. package/src/project-browser.jsx +36 -60
  135. package/src/props.jsx +24 -35
  136. package/src/render-passes.js +7 -1
  137. package/src/retired-kinds.js +37 -0
  138. package/src/scene-objects.js +5 -9
  139. package/src/scenes.js +4 -5
  140. package/src/sculpt-colours.js +65 -0
  141. package/src/sculpt-compare.js +13 -50
  142. package/src/sculpt-csg.js +112 -0
  143. package/src/sculpt-curve.js +123 -0
  144. package/src/sculpt-cutters.js +43 -0
  145. package/src/sculpt-detail.js +207 -0
  146. package/src/sculpt-geometry-loft-convex.js +100 -0
  147. package/src/sculpt-geometry-shapes.js +250 -0
  148. package/src/sculpt-geometry.js +33 -15
  149. package/src/sculpt-loft-rings.js +225 -0
  150. package/src/sculpt-noise.js +65 -0
  151. package/src/sculpt-part-fields.js +27 -0
  152. package/src/sculpt-recipe-read.js +65 -0
  153. package/src/sculpt-recipe.js +33 -75
  154. package/src/sculpt-run.js +1 -1
  155. package/src/sculpt-scatter-items.js +183 -0
  156. package/src/sculpt-scatter-shape.js +57 -0
  157. package/src/sculpt-scatter.js +124 -0
  158. package/src/sculpt-shapes-curved.js +211 -0
  159. package/src/sculpt-shapes-loft-convex.js +218 -0
  160. package/src/sculpt-shapes.js +138 -0
  161. package/src/sculpt-sketch-state.js +76 -0
  162. package/src/sculpt-sketch.jsx +124 -0
  163. package/src/sculpt-surface-fields.js +65 -0
  164. package/src/settings-menu.jsx +1 -20
  165. package/src/shell/BottomDock.jsx +9 -9
  166. package/src/shell/MenuBar.jsx +1 -9
  167. package/src/shell/PreferencesDialog.jsx +3 -4
  168. package/src/shell/ViewportToolbar.jsx +3 -3
  169. package/src/shell/add-camera-shot.js +11 -0
  170. package/src/shell/glass-regions.css +3 -2
  171. package/src/shell/glass.css +0 -5
  172. package/src/shell/lens-scrub-math.js +51 -0
  173. package/src/shell/lens-scrub.css +73 -0
  174. package/src/shell/lens-scrub.jsx +98 -0
  175. package/src/shell/mode.css +6 -5
  176. package/src/shell/preferences.css +1 -32
  177. package/src/shell/shell.css +18 -1
  178. package/src/shell/studio-shell-context.js +5 -0
  179. package/src/shot-frame.js +26 -0
  180. package/src/stage-canvas-rigs.jsx +7 -8
  181. package/src/stage-characters.jsx +26 -0
  182. package/src/stage-gizmo.js +43 -0
  183. package/src/stage-labels-ko.js +0 -6
  184. package/src/stage-startup-defaults.js +3 -9
  185. package/src/starter-scenes.js +5 -11
  186. package/src/studio-agent-commands.js +2 -2
  187. package/src/studio-agent-protocol.js +2 -7
  188. package/src/studio-app-binding.js +1 -1
  189. package/src/studio-elements.js +6 -2
  190. package/src/styles/themes.css +13 -0
  191. package/src/styles.css +6 -674
  192. package/src/text/text-object.jsx +1 -1
  193. package/src/ui.jsx +8 -16
  194. package/src/use-embed-bridge.js +3 -6
  195. package/src/use-render-activity.js +3 -1
  196. package/src/view-ghosts.jsx +5 -3
  197. package/src/viewport-visible-rect.js +37 -0
  198. package/src/workflow/AgentPanel.jsx +79 -46
  199. package/src/workflow/AgentPlanCard.jsx +41 -0
  200. package/src/workflow/AgentProgressCard.jsx +1 -1
  201. package/src/workflow/TaskBoard.jsx +102 -0
  202. package/src/workflow/TaskInline.jsx +114 -0
  203. package/src/workflow/agent-client.js +25 -36
  204. package/src/workflow/agent-panel.css +14 -1
  205. package/src/workflow/agent-plan.css +93 -0
  206. package/src/workflow/agent-plan.js +59 -0
  207. package/src/workflow/attachment-image.js +10 -3
  208. package/src/workflow/attachment-video.js +91 -0
  209. package/src/workflow/task-board.css +98 -0
  210. package/src/workflow/task-board.js +169 -0
  211. package/tools/check-eslint.mjs +2 -1
  212. package/tools/check-knip.mjs +2 -1
  213. package/tools/dev/pages/greybox-to-video.html +2 -3
  214. package/tools/dev/pages/previs-software.html +2 -2
  215. package/tools/dev/pages/privacy.html +3 -7
  216. package/tools/dev/pages/seedance-camera-control.html +2 -2
  217. package/tools/dev-full.mjs +11 -2
  218. package/tools/eslint-baseline.json +6 -6
  219. package/tools/knip-baseline.json +0 -11
  220. package/tools/loc-baseline.json +30 -31
  221. package/tools/metrics-snapshot.mjs +43 -5
  222. package/tools/render-start-thumbs.mjs +175 -0
  223. package/tools/run-tests.mjs +10 -17
  224. package/tools/sitemap.mjs +1 -1
  225. package/dist/assets/analytics-BibyE5ar.js +0 -6
  226. package/dist/assets/app-DhRJ-PBz.js +0 -5088
  227. package/dist/assets/app-f-fkV5uP.css +0 -1
  228. package/dist/assets/compile-worker-DqiA4AaN.js +0 -5
  229. package/dist/assets/first-shot-handoff-Qx7SKl38.js +0 -1
  230. package/dist/assets/landing-DYg87MDY.js +0 -1
  231. package/dist/assets/scene-asset-sync-DBYQBR0T.js +0 -20
  232. package/dist/assets/scene-asset-sync-_S8NQl5B.css +0 -1
  233. package/dist/assets/workflow-BSA22Ofr.js +0 -179
  234. package/dist/scenes/city-block.cclayproject +0 -1
  235. package/src/burn-in.js +0 -91
  236. package/src/camera-tutorial.jsx +0 -520
  237. package/src/facing-marks.js +0 -88
  238. package/src/first-shot-handoff.js +0 -44
  239. package/src/panels/motion.css +0 -197
  240. package/src/set-pieces.jsx +0 -183
  241. package/src/tutorial-analytics.js +0 -86
  242. package/src/tutorial-walk.js +0 -17
  243. package/src/use-camera-tutorial.js +0 -50
  244. package/src/use-semantic-state.js +0 -12
  245. package/tools/metrics/archive-traffic.mjs +0 -138
  246. package/tools/perf/ik-camera-drag.mjs +0 -138
@@ -0,0 +1,114 @@
1
+ // Task mode inside the chat (task-board.js; the headless TaskBoard.jsx feeds taskView). A task is one quiet line
2
+ // under the turn that dispatched it; a status line under the header counts them; a line opens the task's own view
3
+ // (its steps, cost, cancel / undo, and an input that talks to that task only), and the chat is one click back.
4
+ import { useEffect, useState, useSyncExternalStore } from "react";
5
+ import { FiChevronDown, FiChevronLeft } from "react-icons/fi";
6
+ import { ko } from "../locale.js";
7
+ import { ACTIVE_STATUSES, TASK_STATUS_COPY, boardGroups, compileState, costLabel, dispatchedTaskIds, progressLine, scopeLabel, taskLine, taskReason, taskView } from "./task-board.js";
8
+ import "./task-board.css";
9
+ export { readStoredTaskMode, storeTaskMode } from "./task-board.js";
10
+
11
+ const useTaskView = () => useSyncExternalStore(taskView.subscribe, taskView.get, taskView.get);
12
+ const openTask = taskId => taskView.set({ open: taskId });
13
+
14
+ function TaskRow({ task, activity }) {
15
+ const line = task.status === "running" && compileState(activity, task.taskId).waiting > 0 ? ko("Compile waits for a free worker", "컴파일 차례 기다리는 중") : taskLine(task);
16
+ const status = TASK_STATUS_COPY[task.status] ?? task.status;
17
+ return <button type="button" className="task-row" data-status={task.status} data-task-id={task.taskId} onClick={() => openTask(task.taskId)}
18
+ title={`${task.title} — ${line}`} aria-label={`${task.title}, ${status}: ${line}`}>
19
+ <span className="task-dot" aria-hidden="true" />
20
+ <span className="task-row-title">{task.title}</span>
21
+ <span className="task-row-line">{line}</span>
22
+ </button>;
23
+ }
24
+
25
+ /** The tasks a turn dispatched (`steps`), or with `items` every task no turn in the chat dispatched (a restored chat). */
26
+ export function TaskRows({ steps, items }) {
27
+ const { board, activity } = useTaskView();
28
+ const placed = items ? new Set(dispatchedTaskIds(items)) : null;
29
+ const ids = placed ? board.order.filter(id => !placed.has(id)) : [...new Set(dispatchedTaskIds(steps))].filter(id => board.tasks[id]);
30
+ if (!ids.length) return null;
31
+ return <div className="task-rows">{ids.map(id => <TaskRow key={id} task={board.tasks[id]} activity={activity} />)}</div>;
32
+ }
33
+
34
+ const GROUPS = [["failed", ko("need a look", "확인 필요")], ["running", ko("running", "실행 중")], ["waiting", ko("waiting", "대기")], ["done", ko("done", "완료")]];
35
+
36
+ export function TaskStrip() {
37
+ const { board, activity } = useTaskView();
38
+ const [expanded, setExpanded] = useState(false);
39
+ if (!board.order.length) return null;
40
+ const groups = boardGroups(board);
41
+ const shown = GROUPS.filter(([key]) => groups[key].length);
42
+ return <div className="task-strip" data-expanded={expanded ? "true" : "false"}>
43
+ <button type="button" className="task-strip-head" aria-expanded={expanded} onClick={() => setExpanded(value => !value)}>
44
+ {shown.map(([key, label]) => <span key={key} className="task-strip-count" data-status={key}><span className="task-dot" aria-hidden="true" />{groups[key].length} {label}</span>)}
45
+ <FiChevronDown className="task-strip-caret" size={12} aria-hidden="true" />
46
+ </button>
47
+ {expanded && <div className="task-rows task-strip-list">{shown.flatMap(([key]) => groups[key]).map(task => <TaskRow key={task.taskId} task={task} activity={activity} />)}</div>}
48
+ </div>;
49
+ }
50
+
51
+ function TaskFrames({ client, turnId, labelOf }) {
52
+ const [lines, setLines] = useState([]);
53
+ useEffect(() => {
54
+ if (!turnId || !client) return undefined;
55
+ const controller = new AbortController();
56
+ let reply = "";
57
+ client.frames(turnId, frame => {
58
+ if (frame.type === "text.delta") { reply += frame.text ?? ""; setLines(list => [...list.filter(line => line.kind !== "reply"), { kind: "reply", text: reply }]); return; }
59
+ const label = frame.type === "tool.done" ? null : progressLine(frame, labelOf);
60
+ if (frame.type === "tool.start") reply = "";
61
+ if (label) setLines(list => [...list.filter(line => line.kind !== "reply"), ...(reply ? [{ kind: "said", text: reply }] : []), { kind: frame.type === "error" ? "error" : "tool", text: label }].slice(-40));
62
+ }, controller.signal).catch(() => {});
63
+ return () => controller.abort();
64
+ }, [client, turnId, labelOf]);
65
+ if (!lines.length) return <p className="task-frames-empty">{ko("No steps yet.", "아직 단계가 없어요.")}</p>;
66
+ return <ol className="task-frames">{lines.map((line, index) => <li key={index} data-kind={line.kind}>{line.text}</li>)}</ol>;
67
+ }
68
+
69
+ export function TaskDetail() {
70
+ const { board, open, client, undo, labelOf } = useTaskView();
71
+ const task = open ? board.tasks[open] : null;
72
+ return task ? <OpenTask key={task.taskId} task={task} client={client} undo={undo} labelOf={labelOf} /> : null;
73
+ }
74
+
75
+ function OpenTask({ task, client, undo, labelOf }) {
76
+ const [notice, setNotice] = useState("");
77
+ const [steering, setSteering] = useState("");
78
+ useEffect(() => {
79
+ const onKey = event => { if (event.key === "Escape") openTask(null); };
80
+ window.addEventListener("keydown", onKey);
81
+ return () => window.removeEventListener("keydown", onKey);
82
+ }, []);
83
+ const active = ACTIVE_STATUSES.has(task.status);
84
+ const cost = costLabel(task.usage);
85
+ const act = async (run, done) => { setNotice(""); try { await run(); setNotice(done); } catch (error) { setNotice(String(error?.message ?? error)); } };
86
+ const steer = event => {
87
+ event.preventDefault();
88
+ const text = steering.trim();
89
+ if (text) act(() => client.steer(task.taskId, text), ko("Sent to this task.", "이 작업에 전달함.")).then(() => setSteering(""));
90
+ };
91
+ return <section className="task-detail" data-status={task.status} data-task-id={task.taskId} aria-label={task.title}>
92
+ <header className="task-detail-head">
93
+ <button type="button" className="agent-ghost-button task-detail-back" onClick={() => openTask(null)} autoFocus><FiChevronLeft size={13} aria-hidden="true" />{ko("Chat", "대화")}</button>
94
+ <span className="task-detail-title">{task.title}</span>
95
+ </header>
96
+ <div className="task-detail-body">
97
+ <div className="task-detail-meta" data-status={task.status}>
98
+ <span className="task-dot" aria-hidden="true" />
99
+ <span>{TASK_STATUS_COPY[task.status] ?? task.status}{cost ? ` · ${cost}` : ""}</span>
100
+ {active && <button type="button" className="agent-ghost-button task-detail-cancel" onClick={() => act(() => client.cancel(task.taskId), ko("Cancelled; its changes go back.", "취소함: 바뀐 것은 되돌아가요."))}>{ko("Cancel", "취소")}</button>}
101
+ {task.status === "done" && !task.undone && <button type="button" className="agent-ghost-button task-detail-undo" onClick={() => act(() => undo(task.taskId), ko("Undone.", "되돌림."))}>{ko("Undo task", "작업 되돌리기")}</button>}
102
+ </div>
103
+ {task.scope?.length > 0 && <p className="task-detail-scope" title={task.scope.join(", ")}>{ko("Touches", "건드리는 것")}: {[...new Set(task.scope.map(scopeLabel))].join(" · ")}</p>}
104
+ {task.reason && task.status !== "done" && <p className="task-detail-reason">{taskReason(task.reason)}</p>}
105
+ {task.notes?.map((text, index) => <p key={index} className="task-detail-note">{text}</p>)}
106
+ {task.summary?.reply && !active && <p className="task-detail-summary">{task.summary.reply}</p>}
107
+ <TaskFrames key={task.turnId} client={client} turnId={task.turnId} labelOf={labelOf} />
108
+ {notice && <p className="task-detail-notice" role="status">{notice}</p>}
109
+ </div>
110
+ {task.status === "running" && <form className="task-detail-steer" onSubmit={steer}>
111
+ <input value={steering} onChange={event => setSteering(event.target.value)} placeholder={ko("Tell this task…", "이 작업에게 지시…")} aria-label={ko("Tell this task", "이 작업에게 지시")} />
112
+ </form>}
113
+ </section>;
114
+ }
@@ -28,8 +28,9 @@ import { appendAttachments, ATTACHMENT_MAX_COUNT } from "./attachment-image.js";
28
28
  import { ko } from "../locale.js";
29
29
  import { AGENT_TOOL_CATEGORIES, EXECUTION_TELEMETRY_VALUES } from "../execution-telemetry.js";
30
30
  import { STUDIO_VARIANTS, validateReceipt } from "../studio-agent-protocol.js";
31
- import { requestAgentImage, sidecarRequest, sidecarUrl } from "../agent-image-client.js";
31
+ import { requestAgentImage, sidecarRequest, sidecarUrl } from "../agent-image-client.js"; import { clearSculptSketch, sculptSketchFrame } from "../sculpt-sketch-state.js";
32
32
  import { actionLabelsOf, changedLine, scriptNow, stepFields, thinkingLine, withCompose, withThinking, withToolStart, withTurnEnd } from "./agent-turn.js";
33
+ import { isPlanFrame, restoredPlans, withPlanEnd, withPlanFrame } from "./agent-plan.js";
33
34
 
34
35
  const AGENT_PANEL_WIDTH_KEY = "cozyclay.workflow.agentPanel.width";
35
36
  export const AGENT_PANEL_WIDTH_DEFAULT = 360;
@@ -266,27 +267,6 @@ export function storeModel(key, storage = globalThis.localStorage) {
266
267
  }
267
268
  }
268
269
 
269
- /** The supervisor switch (#728): off unless the author turned it on. It rides
270
- * on every Studio turn as `supervisor: true|false`; the sidecar reviews only a
271
- * turn sent with `true`, and its own COZYCLAY_SUPERVISOR=off still wins. */
272
- const AGENT_SUPERVISOR_KEY = "cozyclay.agent.supervisor";
273
-
274
- export function readStoredSupervisor(storage = globalThis.localStorage) {
275
- try {
276
- return storage?.getItem(AGENT_SUPERVISOR_KEY) === "on";
277
- } catch {
278
- return false;
279
- }
280
- }
281
-
282
- export function storeSupervisor(enabled, storage = globalThis.localStorage) {
283
- try {
284
- storage?.setItem(AGENT_SUPERVISOR_KEY, enabled ? "on" : "off");
285
- } catch {
286
- // Private-mode storage denial must never break the toggle.
287
- }
288
- }
289
-
290
270
  /** The model to open with: the remembered key when it is still advertised,
291
271
  * otherwise the first model the sidecar listed. */
292
272
  export function preferredModel(models, stored = readStoredModel()) {
@@ -653,6 +633,10 @@ export function keyRefusedMessage(raw) {
653
633
  return `The model provider refused the credentials${status || reason ? ` (${[status, reason].filter(Boolean).join(": ")})` : ""}.`;
654
634
  }
655
635
 
636
+ /** True when the agent server itself did not answer: plain `vite` without the sidecar (503/502/404), a dead
637
+ * port (network error, no status), or an HTML fallback where JSON was expected. Any other status is the server's own answer. */
638
+ export const agentServerUnavailable = (error) => !Number.isFinite(error?.status) || [404, 502, 503].includes(error.status);
639
+
656
640
  export function createHttpTransport({ fetchImpl = globalThis.fetch?.bind(globalThis), surface, capture = track, now = () => performance.now() } = {}) {
657
641
  const activeTurns = new Map();
658
642
  const request = (path, init) => sidecarRequest(fetchImpl, path, init);
@@ -744,7 +728,7 @@ export function createHttpTransport({ fetchImpl = globalThis.fetch?.bind(globalT
744
728
  },
745
729
  /** Streams sidecar events to `onEvent`. Resolves when the turn ends. */
746
730
  async turn(turnRequest, onEvent, signal) {
747
- const { sessionId, text, attachFrame, model, effort, attachments, supervisor } = turnRequest;
731
+ const { sessionId, text, attachFrame, model, effort, attachments, supervisor, tasks } = turnRequest;
748
732
  const attached = Array.isArray(attachments) && attachments.length ? { attachments } : {};
749
733
  const studio = turnRequest.surface === "studio";
750
734
  const turnId = studio ? turnRequest.turnId : null;
@@ -803,9 +787,10 @@ export function createHttpTransport({ fetchImpl = globalThis.fetch?.bind(globalT
803
787
  })
804
788
  : JSON.stringify({ sessionId, text, attachFrame, model, ...(effort ? { effort } : {}), ...attached, ...(telemetry.turnId ? { turn_id: telemetry.turnId } : {}) });
805
789
  try {
806
- const response = await fetchImpl(sidecarUrl("/agent/turn"), {
790
+ // Task mode (the panel's Tasks switch): the turn goes to the main agent, which answers at once and runs tasks.
791
+ const response = await fetchImpl(sidecarUrl(studio && tasks ? "/agent/main" : "/agent/turn"), {
807
792
  method: "POST",
808
- headers: { "content-type": "application/json", accept: "text/event-stream" },
793
+ headers: { "content-type": "application/json", accept: "text/event-stream", ...(studio && tasks ? { "x-cozyclay-tasks": "on" } : {}) },
809
794
  body,
810
795
  signal,
811
796
  });
@@ -1341,6 +1326,8 @@ export function createAgentChatStore({
1341
1326
  });
1342
1327
  return;
1343
1328
  }
1329
+ // The turn's step checklist (bin/agent/plan-tool.mjs): one card under the prompt; the plan call itself is no step.
1330
+ if (isPlanFrame(event)) { setItems((items) => withPlanFrame(items, event, newId)); return; }
1344
1331
  if (event?.type === "tool.start") {
1345
1332
  setItems((items) => withToolStart(items, { kind: "tool", id: event.callId || newId(), callId: event.callId, name: event.name, label: event.label, args: event.args, status: "running", startedAt: clock(), ...stepFields(event.name, event.args, actionLabels) }));
1346
1333
  return;
@@ -1422,11 +1409,10 @@ export function createAgentChatStore({
1422
1409
  set({ sessionId: newId(), turnId: null, items: [], pendingAttachments: [], rateLimit: null, lastPrompt: "", turnStartedAt: null, lastTurn: null });
1423
1410
  };
1424
1411
  const restore = (transcript, sessionId = state.sessionId) => {
1425
- abandonSupervisorNotes();
1426
- seenReceipts.clear();
1427
- settledActions.clear();
1412
+ abandonSupervisorNotes(); seenReceipts.clear(); settledActions.clear();
1428
1413
  const restoredReceiptIds = new Set();
1429
- const restored = (Array.isArray(transcript) ? transcript : []).flatMap((item) => {
1414
+ // Each turn's plan card comes back under its prompt, settled by how the turn ended (agent-plan.js).
1415
+ const restored = restoredPlans((Array.isArray(transcript) ? transcript : []).flatMap((item) => {
1430
1416
  if (item?.kind === "user" || item?.kind === "assistant") {
1431
1417
  const attachments = Array.isArray(item.attachments) ? item.attachments.filter((entry) => typeof entry?.dataUrl === "string") : [];
1432
1418
  return [{ kind: item.kind, id: newId(), text: String(item.text ?? ""), ...(attachments.length ? { attachments } : {}) }];
@@ -1441,8 +1427,8 @@ export function createAgentChatStore({
1441
1427
  return [{ kind: "receipt", id: `receipt:${item.receiptId || newId()}`, receiptId: item.receiptId, summary: item.summary || "Receipt", receipt: { status: "applied", warnings: [], verification: { status: "verified" } } }];
1442
1428
  }
1443
1429
  if (item?.kind === "failure") return [{ kind: "failure", id: newId(), failure: { code: item.code || "upstream", message: String(item.message || "The turn failed."), recovery: { action: "retry", retryAllowed: true } } }];
1444
- return [];
1445
- });
1430
+ return item?.kind === "plan" ? [{ kind: "plan", id: newId(), steps: item.steps }] : [];
1431
+ }));
1446
1432
  const lastPrompt = [...restored].reverse().find((item) => item.kind === "user")?.text || "";
1447
1433
  set({ sessionId, turnId: null, items: restored, rateLimit: null, lastPrompt, turnStartedAt: null, lastTurn: null, streaming: false });
1448
1434
  return restored;
@@ -1454,6 +1440,8 @@ export function createAgentChatStore({
1454
1440
  return () => listeners.delete(listener);
1455
1441
  },
1456
1442
  setDraft: (draft) => set({ draft: String(draft ?? "") }),
1443
+ /** A reply the main agent made on its own (task mode: a task ended while nobody was talking to it). */
1444
+ note: (text) => { if (String(text ?? "").trim()) set({ items: [...state.items, { kind: "assistant", id: newId(), text: String(text) }] }); },
1457
1445
  /** Pictures pasted or dropped into the composer. Returns what was taken and
1458
1446
  * what had to be refused, so the composer can say so instead of a picture
1459
1447
  * vanishing on its way in. */
@@ -1511,15 +1499,16 @@ export function createAgentChatStore({
1511
1499
  attachFrame: Boolean(options.attachFrame),
1512
1500
  model: options.model,
1513
1501
  effort: options.effort ?? undefined,
1514
- ...(typeof options.supervisor === "boolean" ? { supervisor: options.supervisor } : {}),
1515
- }, applyEvent, signal);
1502
+ ...(typeof options.supervisor === "boolean" ? { supervisor: options.supervisor } : {}), ...(options.tasks ? { tasks: true } : {}),
1503
+ }, (event) => { sculptSketchFrame(event); applyEvent(event); }, signal);
1516
1504
  } catch (error) {
1517
1505
  if (!signal.aborted) applyEvent({ type: "error", code: "upstream", message: String(error?.message || error) });
1518
1506
  } finally {
1519
1507
  if (controller?.signal === signal) controller = null;
1520
- // A turn the author already replaced owns none of this state.
1508
+ // A turn the author already replaced owns none of this state. Its live sculpt sketch
1509
+ // (the ghost clay of a call still being written) ends with it, whatever ended it.
1521
1510
  if (activeTurn === turn) {
1522
- wireTurnId = null;
1511
+ wireTurnId = null; clearSculptSketch();
1523
1512
  // Ending with nothing on screen is a failure, not a result.
1524
1513
  if (!turn.produced && !turn.failure && !signal.aborted) {
1525
1514
  turn.failure = { code: "no_output", message: "The turn ended without a response.", recovery: { action: "retry", retryAllowed: true } };
@@ -1529,7 +1518,7 @@ export function createAgentChatStore({
1529
1518
  activeTurn = null;
1530
1519
  // The end state stays in the transcript with the turn's steps (agent-turn.js withTurnEnd).
1531
1520
  const lastTurn = { status: signal.aborted ? "stopped" : turn.failure ? "failed" : "done", endedAt, durationMs: endedAt - startedAt, failure: turn.failure };
1532
- set({ streaming: false, turnStartedAt: null, lastTurn, items: withTurnEnd(state.items, lastTurn, newId) });
1521
+ set({ streaming: false, turnStartedAt: null, lastTurn, items: withPlanEnd(withTurnEnd(state.items, lastTurn, newId), lastTurn.status) });
1533
1522
  if (studio && turn.reviewable && options.supervisor === true && !signal.aborted) requestSupervisorNote(turnId);
1534
1523
  }
1535
1524
  }
@@ -287,7 +287,8 @@
287
287
  cursor: pointer;
288
288
  }
289
289
 
290
- .agent-menu button:hover { background: var(--agent-hover, #2b3040); color: var(--agent-text-strong, #f5f7ff); }
290
+ .agent-menu button[disabled] { opacity: 0.45; cursor: not-allowed; }
291
+ .agent-menu button:not([disabled]):hover { background: var(--agent-hover, #2b3040); color: var(--agent-text-strong, #f5f7ff); }
291
292
 
292
293
  /* --- account strip ----------------------------------------------------- */
293
294
 
@@ -869,6 +870,18 @@
869
870
  /* The second way in on the signed-out card: a key or a server of one's own. */
870
871
  .agent-state-card .agent-use-key { align-self: center; }
871
872
 
873
+ /* No agent server behind the page: a quiet status (icon + words), not an alert. */
874
+ .agent-offline-icon { align-self: center; color: var(--agent-text-muted); }
875
+ .agent-offline-card code {
876
+ padding: 0 var(--agent-space-1);
877
+ border-radius: var(--agent-radius-sm);
878
+ background: var(--agent-bg-sunken);
879
+ color: var(--agent-text);
880
+ font-size: var(--agent-font-sm);
881
+ }
882
+ .agent-offline-retry { align-self: center; display: inline-flex; align-items: center; gap: var(--agent-space-2); }
883
+ .agent-signin-error { align-self: center; max-width: 32ch; }
884
+
872
885
  .agent-primary-button {
873
886
  align-self: center;
874
887
  border: 1px solid var(--agent-accent-edge);
@@ -0,0 +1,93 @@
1
+ /* A turn's step checklist (AgentPlanCard.jsx); colours, sizes and motion come from agent-panel.css. */
2
+ .agent-plan-row[data-plan-live="true"] {
3
+ position: sticky;
4
+ top: calc(-1 * var(--agent-space-5));
5
+ z-index: 1;
6
+ /* the transcript's own colour above the card, so steps scrolling under it never show through the gap */
7
+ padding-top: var(--agent-space-2);
8
+ margin-top: calc(-1 * var(--agent-space-2));
9
+ background: var(--agent-bg);
10
+ }
11
+
12
+ .agent-plan {
13
+ --agent-plan-mark: 11px;
14
+ display: flex;
15
+ flex-direction: column;
16
+ }
17
+
18
+ .agent-plan-row[data-plan-live="true"] .agent-plan { box-shadow: 0 6px 14px -8px #000c; }
19
+
20
+ .agent-plan-head {
21
+ display: flex;
22
+ align-items: center;
23
+ gap: var(--agent-space-3);
24
+ padding: var(--agent-space-2) var(--agent-space-4);
25
+ color: var(--agent-text-soft, var(--agent-text));
26
+ font-size: var(--agent-font-md);
27
+ }
28
+
29
+ .agent-plan-title { flex: none; font-weight: 600; }
30
+
31
+ .agent-plan-bar {
32
+ flex: 1;
33
+ height: 3px;
34
+ overflow: hidden;
35
+ border-radius: var(--agent-radius-pill);
36
+ background: var(--agent-track);
37
+ }
38
+
39
+ .agent-plan-fill {
40
+ display: block;
41
+ height: 100%;
42
+ background: var(--agent-ok);
43
+ transform: scaleX(var(--agent-plan-done, 0));
44
+ transform-origin: left;
45
+ transition: transform var(--agent-motion);
46
+ }
47
+
48
+ .agent-plan-count { flex: none; color: var(--agent-text-dim); font: var(--agent-font-sm) ui-monospace, monospace; font-variant-numeric: tabular-nums; }
49
+
50
+ .agent-plan-steps {
51
+ display: flex;
52
+ flex-direction: column;
53
+ gap: 2px;
54
+ margin: 0;
55
+ padding: var(--agent-space-1) 0 var(--agent-space-2);
56
+ border-top: 1px solid var(--agent-border);
57
+ list-style: none;
58
+ }
59
+
60
+ .agent-plan-step {
61
+ display: flex;
62
+ align-items: center;
63
+ gap: var(--agent-space-3);
64
+ min-width: 0;
65
+ padding: 2px var(--agent-space-4);
66
+ color: var(--agent-text-muted);
67
+ font-size: var(--agent-font-md);
68
+ line-height: 1.4;
69
+ }
70
+
71
+ .agent-plan-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
72
+ .agent-plan-step.done { color: var(--agent-text-dim); }
73
+ .agent-plan-step.active { background: color-mix(in srgb, var(--agent-accent) 12%, transparent); color: var(--agent-text); }
74
+ .agent-plan-step.unfinished { color: var(--agent-text-dim); }
75
+ .agent-plan-step.unfinished .agent-plan-text { font-style: italic; }
76
+
77
+ .agent-plan-mark { flex: none; width: var(--agent-plan-mark); height: var(--agent-plan-mark); }
78
+ .agent-plan-mark.done { color: var(--agent-ok); }
79
+ .agent-plan-mark.unfinished { color: var(--agent-warn); }
80
+ .agent-plan-mark.pending { border: 1.5px solid var(--agent-border-strong); border-radius: var(--agent-radius-pill); }
81
+ /* the step under way: an accent ring whose centre breathes while the turn runs */
82
+ .agent-plan-mark.active { position: relative; border: 1.5px solid var(--agent-accent); border-radius: var(--agent-radius-pill); }
83
+ .agent-plan-mark.active::after { content: ""; position: absolute; inset: 2px; border-radius: var(--agent-radius-pill); background: var(--agent-accent); }
84
+ .agent-plan[data-plan-state="live"] .agent-plan-mark.active::after { animation: agent-pulse 1.4s ease-in-out infinite; }
85
+
86
+ /* A finished turn's card is a record: it keeps its rows but no longer pulls the eye. */
87
+ .agent-plan:not([data-plan-state="live"]) .agent-plan-fill { background: var(--agent-text-dim); }
88
+ .agent-plan[data-plan-state="done"] .agent-plan-fill { background: var(--agent-ok); }
89
+
90
+ @media (prefers-reduced-motion: reduce) {
91
+ .agent-plan[data-plan-state="live"] .agent-plan-mark.active::after { animation: none; }
92
+ .agent-plan-fill { transition: none; }
93
+ }
@@ -0,0 +1,59 @@
1
+ // A turn's step checklist as the agent panel keeps it (AgentPlanCard.jsx draws it): the sidecar's plan tool
2
+ // (bin/agent/plan-tool.mjs) sends the whole list in every plan.update frame, and the chat store (agent-client.js) keeps
3
+ // one plan card per turn, right under the turn's prompt. The card never claims more than happened: when the turn ends,
4
+ // the step still under way counts as done only if the turn succeeded, and is left unfinished when it failed or stopped.
5
+ // Pure data, like agent-turn.js.
6
+
7
+ const STATUSES = new Set(["pending", "active", "done"]);
8
+ const MAX_STEPS = 12, TEXT_MAX = 80;
9
+
10
+ /** A frame's steps as the card keeps them, or null when they are not a plan. */
11
+ function planSteps(steps) {
12
+ if (!Array.isArray(steps) || !steps.length || steps.length > MAX_STEPS) return null;
13
+ const kept = steps.map(step => ({ text: String(step?.text ?? "").replace(/\s+/g, " ").trim().slice(0, TEXT_MAX), status: step?.status }));
14
+ return kept.every(step => step.text && STATUSES.has(step.status)) ? kept : null;
15
+ }
16
+
17
+ /** Whether a frame belongs to the plan: its update, or the plan tool's own call (the card shows it, never a step row). */
18
+ export const isPlanFrame = event => event?.type === "plan.update" || (event?.name === "plan" && ["tool.compose", "tool.start", "tool.progress"].includes(event?.type));
19
+
20
+ /** items after a plan frame: the turn's one plan card, under its prompt, takes the frame's list whole. */
21
+ export function withPlanFrame(items, event, newId) {
22
+ const steps = event?.type === "plan.update" ? planSteps(event.steps) : null;
23
+ if (!steps) return items;
24
+ const prompt = items.findLastIndex(item => item.kind === "user" && !item.steered);
25
+ const at = items.findIndex((item, i) => i > prompt && item.kind === "plan");
26
+ if (at !== -1) return items.map((item, i) => i === at ? { ...item, steps } : item);
27
+ return [...items.slice(0, prompt + 1), { kind: "plan", id: newId(), steps }, ...items.slice(prompt + 1)];
28
+ }
29
+
30
+ /** A plan whose turn ended ("done", "failed" or "stopped"): the step under way is done only when the turn succeeded. */
31
+ export function settledPlan(plan, status) {
32
+ const outcome = status === "done" || status === "stopped" ? status : "failed";
33
+ return { ...plan, outcome, steps: plan.steps.map(step => step.status === "active" ? { ...step, status: outcome === "done" ? "done" : "unfinished" } : step) };
34
+ }
35
+
36
+ /** items at the end of a turn: its plan card settles (settledPlan); earlier turns' cards already have. */
37
+ export const withPlanEnd = (items, status) => items.map(item => item.kind === "plan" && !item.outcome ? settledPlan(item, status) : item);
38
+
39
+ /**
40
+ * A restored transcript's plans (session-store.mjs lists one per change): each turn keeps its last list, under its
41
+ * prompt; the plan tool's own calls go; and the card settles by how the turn ended: failed when a failure follows, done
42
+ * when the turn ends on the reply, else stopped (the history ends mid-turn: the panel reloaded while it ran).
43
+ */
44
+ export function restoredPlans(items) {
45
+ const turns = [];
46
+ for (const item of items) {
47
+ if (item.kind === "tool" && item.name === "plan") continue;
48
+ if (item.kind === "user" || !turns.length) turns.push([]);
49
+ turns.at(-1).push(item);
50
+ }
51
+ return turns.flatMap(turn => {
52
+ const rest = turn.filter(item => item.kind !== "plan"), plan = turn.findLast(item => item.kind === "plan");
53
+ const steps = plan ? planSteps(plan.steps) : null;
54
+ if (!steps) return rest;
55
+ const status = rest.some(item => item.kind === "failure") ? "failed" : rest.at(-1)?.kind === "assistant" ? "done" : "stopped";
56
+ const prompt = rest[0]?.kind === "user" ? 1 : 0;
57
+ return [...rest.slice(0, prompt), settledPlan({ ...plan, steps }, status), ...rest.slice(prompt)];
58
+ });
59
+ }
@@ -10,6 +10,7 @@
10
10
  * Anything under it is sent verbatim: no re-encode, no generation loss.
11
11
  */
12
12
  import { fileToDataUrl, imageFileFromTransfer } from "./clipboard-image.js";
13
+ import { isVideoFile, videoContactSheet } from "./attachment-video.js";
13
14
 
14
15
  export const ATTACHMENT_MAX_COUNT = 4;
15
16
  export const ATTACHMENT_MAX_DIMENSION = 1600;
@@ -19,8 +20,9 @@ export const ATTACHMENT_JPEG_QUALITY = 0.85;
19
20
  * rest here rather than letting the sidecar reject a turn the author sent. */
20
21
  const ATTACHMENT_TYPES = Object.freeze(["image/png", "image/jpeg", "image/webp"]);
21
22
  export const ATTACHMENT_LIMIT_NOTICE = "Up to 4 images per message";
22
- export const ATTACHMENT_TYPE_NOTICE = "Attach a PNG, JPEG or WebP image";
23
- export const ATTACHMENT_FAILED_NOTICE = "That image could not be attached";
23
+ export const ATTACHMENT_TYPE_NOTICE = "Attach a PNG, JPEG or WebP image, or a video";
24
+ export const ATTACHMENT_FAILED_NOTICE = "That image or video could not be attached";
25
+ export const ATTACHMENT_ACCEPT = [...ATTACHMENT_TYPES, "video/*"].join(",");
24
26
 
25
27
  const EXTENSION_TYPES = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp" };
26
28
 
@@ -57,7 +59,8 @@ export function attachmentFilesFromTransfer(transfer) {
57
59
  const images = [];
58
60
  let unsupported = 0;
59
61
  for (const file of candidates) {
60
- if (attachmentType(file)) images.push(file);
62
+ // A video travels as a contact sheet image (attachment-video.js).
63
+ if (attachmentType(file) || isVideoFile(file)) images.push(file);
61
64
  else if (String(file?.type ?? "").startsWith("image/")) unsupported += 1;
62
65
  }
63
66
  return { images, unsupported };
@@ -155,6 +158,10 @@ export function attachmentName(file) {
155
158
  * media type is the resolved one: a file dragged in without a type would
156
159
  * otherwise be labelled PNG whatever its bytes are. */
157
160
  export async function attachmentFromFile(file, options = {}) {
161
+ if (isVideoFile(file)) {
162
+ const sheet = await videoContactSheet(file);
163
+ return { dataUrl: await blobToDataUrl(sheet.blob, "image/jpeg"), name: sheet.name };
164
+ }
158
165
  const { readDataUrl = fileToDataUrl, ...shrink } = options;
159
166
  const type = attachmentType(file) ?? "image/png";
160
167
  const read = await readDataUrl(file);
@@ -0,0 +1,91 @@
1
+ /**
2
+ * A video the author attaches to a turn. The turn envelope carries images
3
+ * only, and the models behind it read pictures, not clips, so the composer
4
+ * turns a video into one contact sheet: evenly spaced frames in a grid, each
5
+ * stamped with its time. It costs one attachment slot and keeps the order of
6
+ * the action legible to the model.
7
+ */
8
+
9
+ const VIDEO_EXTENSIONS = new Set(["mp4", "m4v", "mov", "webm", "ogv", "mkv"]);
10
+ const SHEET_FRAMES = 9;
11
+ const SHEET_COLUMNS = 3;
12
+ const SHEET_CELL_WIDTH = 480;
13
+ const SHEET_JPEG_QUALITY = 0.85;
14
+
15
+ export function isVideoFile(file) {
16
+ const declared = String(file?.type ?? "").toLowerCase();
17
+ if (declared) return declared.startsWith("video/");
18
+ const extension = String(file?.name ?? "").toLowerCase().match(/\.([a-z0-9]+)$/)?.[1];
19
+ return VIDEO_EXTENSIONS.has(extension);
20
+ }
21
+
22
+ function sheetTimes(duration, count) {
23
+ return Array.from({ length: count }, (_, index) => (index + 0.5) * duration / count);
24
+ }
25
+
26
+ function formatSheetTime(seconds) {
27
+ const minutes = Math.floor(seconds / 60);
28
+ return `${minutes}:${(seconds - minutes * 60).toFixed(1).padStart(4, "0")}`;
29
+ }
30
+
31
+ function nextEvent(target, name) {
32
+ return new Promise((resolve, reject) => {
33
+ const done = () => { target.removeEventListener(name, done); target.removeEventListener("error", failed); resolve(); };
34
+ const failed = () => { target.removeEventListener(name, done); target.removeEventListener("error", failed); reject(new Error("this browser cannot decode that video")); };
35
+ target.addEventListener(name, done);
36
+ target.addEventListener("error", failed);
37
+ });
38
+ }
39
+
40
+ /**
41
+ * One video file as a JPEG contact sheet `{ blob, name }`. Decoding happens in
42
+ * a detached <video>; a codec the browser cannot play rejects instead of
43
+ * producing a black sheet.
44
+ */
45
+ export async function videoContactSheet(file, { doc = globalThis.document, frames = SHEET_FRAMES } = {}) {
46
+ const url = URL.createObjectURL(file);
47
+ const video = doc.createElement("video");
48
+ video.muted = true;
49
+ video.playsInline = true;
50
+ video.preload = "auto";
51
+ try {
52
+ const loaded = nextEvent(video, "loadeddata");
53
+ video.src = url;
54
+ await loaded;
55
+ const { duration, videoWidth, videoHeight } = video;
56
+ if (!Number.isFinite(duration) || duration <= 0 || !videoWidth || !videoHeight) throw new Error("that video has no usable length or size");
57
+ const cellWidth = Math.min(SHEET_CELL_WIDTH, videoWidth);
58
+ const cellHeight = Math.round(cellWidth * videoHeight / videoWidth);
59
+ const canvas = doc.createElement("canvas");
60
+ canvas.width = cellWidth * SHEET_COLUMNS;
61
+ canvas.height = cellHeight * Math.ceil(frames / SHEET_COLUMNS);
62
+ const context = canvas.getContext("2d");
63
+ if (!context) throw new Error("this browser cannot draw video frames — no 2D context");
64
+ context.fillStyle = "#000";
65
+ context.fillRect(0, 0, canvas.width, canvas.height);
66
+ context.font = `600 ${Math.max(12, Math.round(cellHeight / 12))}px system-ui, sans-serif`;
67
+ context.textBaseline = "top";
68
+ for (const [index, time] of sheetTimes(duration, frames).entries()) {
69
+ const seeked = nextEvent(video, "seeked");
70
+ video.currentTime = time;
71
+ await seeked;
72
+ const x = (index % SHEET_COLUMNS) * cellWidth;
73
+ const y = Math.floor(index / SHEET_COLUMNS) * cellHeight;
74
+ context.drawImage(video, x, y, cellWidth, cellHeight);
75
+ const stamp = `${index + 1} · ${formatSheetTime(time)}`;
76
+ const pad = 6;
77
+ const box = context.measureText(stamp).width + pad * 2;
78
+ context.fillStyle = "rgba(0, 0, 0, 0.6)";
79
+ context.fillRect(x + 4, y + 4, box, Math.round(cellHeight / 12) + pad * 2);
80
+ context.fillStyle = "#fff";
81
+ context.fillText(stamp, x + 4 + pad, y + 4 + pad);
82
+ }
83
+ const blob = await new Promise((resolve, reject) => canvas.toBlob((result) => result ? resolve(result) : reject(new Error("the contact sheet could not be encoded")), "image/jpeg", SHEET_JPEG_QUALITY));
84
+ const base = String(file?.name ?? "").trim() || "video";
85
+ return { blob, name: `${base.slice(0, 70)} (${frames} frames over ${duration.toFixed(1)}s)` };
86
+ } finally {
87
+ video.removeAttribute("src");
88
+ video.load();
89
+ URL.revokeObjectURL(url);
90
+ }
91
+ }