cozyclay 1.7.0 → 1.7.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 (121) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +21 -1
  3. package/THIRD_PARTY_NOTICES.md +8 -0
  4. package/bin/agent/agent-routes.mjs +348 -0
  5. package/bin/agent/agent-tools.mjs +164 -0
  6. package/bin/agent/codex-client.mjs +295 -0
  7. package/bin/agent/video-adapters.mjs +127 -0
  8. package/bin/codex-auth.mjs +112 -0
  9. package/bin/cozyclay.mjs +50 -3
  10. package/bin/package-signature.mjs +3 -0
  11. package/bin/telemetry-state.mjs +15 -0
  12. package/dist/app/index.html +5 -3
  13. package/dist/assets/analytics-Ce3YvFby.js +1 -0
  14. package/dist/assets/app-DNYMNzNh.css +1 -0
  15. package/dist/assets/app-jcR6lsws.js +4859 -0
  16. package/dist/assets/demo-CEleUnpD.js +1 -0
  17. package/dist/assets/rolldown-runtime-hePW80VL.js +1 -0
  18. package/dist/assets/shot-prompt-s4R0Y9R7.js +14 -0
  19. package/dist/assets/ticket-CX73jjoc.js +1 -0
  20. package/dist/assets/workflow-BcZSgt5C.js +180 -0
  21. package/dist/assets/workflow-CKCpHJGL.css +1 -0
  22. package/dist/cozyclay-package.json +1 -1
  23. package/dist/workflow/index.html +18 -0
  24. package/mcp/LIVE-PROTOCOL.md +3 -1
  25. package/mcp/live-hub.mjs +5 -1
  26. package/mcp/server.mjs +33 -1808
  27. package/mcp/tool-handlers.mjs +1896 -0
  28. package/package.json +7 -2
  29. package/src/App.jsx +1826 -127
  30. package/src/analytics.js +171 -7
  31. package/src/app-stage.jsx +31 -6
  32. package/src/ardy/auto-physics.js +1204 -0
  33. package/src/ardy/collision-blockers.js +289 -0
  34. package/src/ardy/cskel27.js +5 -1
  35. package/src/ardy/fix-collisions.js +2343 -0
  36. package/src/ardy/ik.js +475 -74
  37. package/src/ardy/npz.js +18 -2
  38. package/src/ardy/physics-panel.css +19 -0
  39. package/src/ardy/physics-panel.jsx +76 -0
  40. package/src/ardy/physics-refine.js +111 -0
  41. package/src/ardy/physics-review.js +516 -0
  42. package/src/ardy/physics-support.js +212 -0
  43. package/src/ardy/physics-surface.js +116 -0
  44. package/src/ardy/physics-temporal.js +54 -0
  45. package/src/ardy/playback-clock.js +15 -0
  46. package/src/ardy/playback.js +145 -12
  47. package/src/ardy/prompt-clips.js +16 -7
  48. package/src/ardy/timeline-coordinates.js +24 -0
  49. package/src/ardy/timeline.jsx +102 -26
  50. package/src/camera-move.js +23 -0
  51. package/src/first-success-guide.jsx +75 -0
  52. package/src/hierarchy-panel.jsx +19 -2
  53. package/src/keyframe-pack.js +91 -0
  54. package/src/model-presets.js +85 -0
  55. package/src/multimodel-ingest.js +25 -0
  56. package/src/part-colours.js +105 -0
  57. package/src/pose-extract/detector.js +24 -4
  58. package/src/pose-extract/fit.js +36 -3
  59. package/src/pose-extract/image-frame.js +16 -5
  60. package/src/pose-extract/index.js +3 -2
  61. package/src/pose-extract/mirror.js +153 -0
  62. package/src/pose-extract/take.js +20 -3
  63. package/src/pose-thumbs.js +2 -1
  64. package/src/poses.js +55 -0
  65. package/src/posestudio.jsx +22 -9
  66. package/src/project-browser.jsx +20 -3
  67. package/src/project.js +134 -4
  68. package/src/render-passes.js +114 -0
  69. package/src/scene-objects.js +89 -3
  70. package/src/scenes.js +26 -2
  71. package/src/shot-authoring.js +9 -0
  72. package/src/shot-meta.js +66 -0
  73. package/src/shot-prompt.js +80 -0
  74. package/src/shot.js +1 -0
  75. package/src/storyboard.js +60 -0
  76. package/src/styles.css +743 -2
  77. package/src/ui-scrub.js +102 -0
  78. package/src/ui.jsx +80 -43
  79. package/src/use-render-activity.js +11 -0
  80. package/src/workflow/AgentPanel.jsx +505 -0
  81. package/src/workflow/CozySceneNode.jsx +140 -0
  82. package/src/workflow/ShotPromptNode.jsx +35 -0
  83. package/src/workflow/WorkflowBuilder.jsx +573 -0
  84. package/src/workflow/agent-client.js +336 -0
  85. package/src/workflow/agent-panel.css +692 -0
  86. package/src/workflow/canvas-commands.js +64 -0
  87. package/src/workflow/clipboard-image.js +44 -0
  88. package/src/workflow/cozy-scene-node.css +52 -0
  89. package/src/workflow/cozy-scene-node.js +196 -0
  90. package/src/workflow/image-versions.js +69 -0
  91. package/src/workflow/keyframe-pack-request.js +64 -0
  92. package/src/workflow/local-workflow.js +60 -0
  93. package/src/workflow/main.jsx +12 -0
  94. package/src/workflow/motion-input.js +99 -0
  95. package/src/workflow/node-schema.js +64 -0
  96. package/src/workflow/scene-asset-sync.js +223 -0
  97. package/src/workflow/shot-prompt-node.js +33 -0
  98. package/src/workflow/vibe-payload.js +215 -0
  99. package/src/workflow/workflow.css +8 -0
  100. package/src/zip-store.js +122 -0
  101. package/tools/ardy/GVHMR-NPZ.md +68 -0
  102. package/tools/ardy/README.md +63 -0
  103. package/tools/ardy/cclay_gvhmr_worker.py +127 -0
  104. package/tools/ardy/extract.mjs +136 -10
  105. package/tools/ardy/gvhmr-floor.mjs +102 -0
  106. package/tools/ardy/gvhmr-to-cskel27.mjs +21 -0
  107. package/tools/ardy/gvhmr_fastpath.py +168 -0
  108. package/tools/ardy/gvhmr_trajectory.py +240 -0
  109. package/tools/ardy/mocap-metrics.mjs +166 -0
  110. package/tools/ardy/npz.mjs +15 -1
  111. package/tools/ardy/runners/gvhmr-worker.mjs +194 -0
  112. package/tools/ardy/smpl-cskel27.mjs +178 -0
  113. package/tools/dev-full.mjs +30 -1
  114. package/tools/perf/ik-camera-drag.mjs +138 -0
  115. package/tools/run-tests.mjs +58 -1
  116. package/tools/verify-package-tarball.mjs +41 -0
  117. package/dist/assets/app-BdCI2Bys.js +0 -4851
  118. package/dist/assets/app-CrB3UF4K.css +0 -1
  119. package/dist/assets/demo-DEAy-8Eq.js +0 -1
  120. package/dist/assets/modulepreload-polyfill-P2Xu9kJm.js +0 -1
  121. package/dist/assets/ticket-DbrIKBrJ.js +0 -1
@@ -0,0 +1,295 @@
1
+ // Codex backend client (issue #124): a zero-dependency Node 20+ module that
2
+ // talks to https://chatgpt.com/backend-api/codex with a ChatGPT OAuth bearer.
3
+ //
4
+ // Facts verified against the live backend (2026-09-06):
5
+ // - POST /responses MUST send store:false, stream:true and an array `input`;
6
+ // the backend rejects max_output_tokens, previous_response_id, background,
7
+ // store:true and stream:false — so those keys are never sent.
8
+ // - Reasoning items may only be replayed verbatim (never fabricated).
9
+ // - The account allows ~1 concurrent request: parallel calls get 429 with
10
+ // Retry-After 5-8s → every request is serialized and 429s sleep Retry-After.
11
+
12
+ const CODEX_BASE = "https://chatgpt.com/backend-api/codex";
13
+ const CLIENT_VERSION = "0.153.4";
14
+
15
+ function defaultSleep(ms) {
16
+ return new Promise((resolve) => setTimeout(resolve, ms));
17
+ }
18
+
19
+ /** Normalizes missing/absent header values. */
20
+ function readHeader(headersLike, name) {
21
+ if (!headersLike) return undefined;
22
+ if (typeof headersLike.get === "function") {
23
+ const value = headersLike.get(name);
24
+ return value === null ? undefined : value;
25
+ }
26
+ return headersLike[name];
27
+ }
28
+
29
+ function toNumber(value) {
30
+ if (value === undefined || value === null || value === "") return undefined;
31
+ const parsed = Number(value);
32
+ return Number.isFinite(parsed) ? parsed : undefined;
33
+ }
34
+
35
+ function toFlag(value) {
36
+ if (value === undefined) return false;
37
+ return String(value).trim().toLowerCase() === "true";
38
+ }
39
+
40
+ /** Parses an SSE byte stream into parsed JSON events. */
41
+ async function* parseSseEvents(body) {
42
+ const reader = body.getReader();
43
+ const decoder = new TextDecoder();
44
+ let buffer = "";
45
+ while (true) {
46
+ const { done, value } = await reader.read();
47
+ if (done) break;
48
+ buffer += decoder.decode(value, { stream: true });
49
+ let boundary;
50
+ while ((boundary = buffer.indexOf("\n\n")) !== -1) {
51
+ const frame = buffer.slice(0, boundary);
52
+ buffer = buffer.slice(boundary + 2);
53
+ const data = frame
54
+ .split("\n")
55
+ .filter((line) => line.startsWith("data:"))
56
+ .map((line) => line.slice(5).replace(/^ /, ""))
57
+ .join("\n");
58
+ if (!data || data === "[DONE]") continue;
59
+ try {
60
+ yield JSON.parse(data);
61
+ } catch {
62
+ // Malformed frame: skip rather than kill the stream.
63
+ }
64
+ }
65
+ }
66
+ }
67
+
68
+ function messageText(item) {
69
+ return (item.content ?? [])
70
+ .filter((part) => part.type === "output_text")
71
+ .map((part) => part.text)
72
+ .join("");
73
+ }
74
+
75
+ /** Width/height from a PNG IHDR chunk: bytes 16..24, big-endian. */
76
+ function pngDimensions(base64) {
77
+ const buffer = Buffer.from(base64, "base64");
78
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
79
+ }
80
+
81
+ export function createCodexClient({
82
+ getAccessToken,
83
+ getAccountId,
84
+ fetch = globalThis.fetch,
85
+ originator = "cozyclay",
86
+ sleep = defaultSleep,
87
+ }) {
88
+ // The account allows ~1 concurrent request: chain every backend request so
89
+ // at most one is in flight, regardless of how callers interleave calls.
90
+ let chain = Promise.resolve();
91
+ function enqueue(task) {
92
+ const run = chain.then(task);
93
+ chain = run.then(() => {}, () => {});
94
+ return run;
95
+ }
96
+
97
+ async function requestHeaders(extra = {}) {
98
+ const [accessToken, accountId] = await Promise.all([getAccessToken(), getAccountId()]);
99
+ return {
100
+ authorization: `Bearer ${accessToken}`,
101
+ "chatgpt-account-id": accountId,
102
+ originator,
103
+ "openai-beta": "responses=experimental",
104
+ ...extra,
105
+ };
106
+ }
107
+
108
+ /** POST/GET with 429 Retry-After backoff; returns the final Response. */
109
+ async function fetchWithRetry(url, init) {
110
+ while (true) {
111
+ const response = await fetch(url, init);
112
+ if (response.status === 429) {
113
+ const retryAfter = toNumber(response.headers.get("retry-after")) ?? 5;
114
+ await sleep(retryAfter * 1000);
115
+ continue;
116
+ }
117
+ if (!response.ok) {
118
+ const detail = await response.text();
119
+ const error = new Error(`codex request failed (${response.status}): ${detail}`);
120
+ error.status = response.status;
121
+ if (response.status === 401) error.code = "unauthorized"; // bearer expired → re-login
122
+ throw error;
123
+ }
124
+ return response;
125
+ }
126
+ }
127
+
128
+ function postJson(path, body, signal) {
129
+ return enqueue(async () =>
130
+ fetchWithRetry(`${CODEX_BASE}${path}`, {
131
+ method: "POST",
132
+ headers: await requestHeaders({ "content-type": "application/json" }),
133
+ body: JSON.stringify(body),
134
+ signal,
135
+ }));
136
+ }
137
+
138
+ function buildResponsesBody(request) {
139
+ return {
140
+ instructions: request.instructions ?? "",
141
+ input: request.input ?? [],
142
+ tools: request.tools ?? [],
143
+ include: ["reasoning.encrypted_content"],
144
+ store: false,
145
+ stream: true,
146
+ ...(request.model ? { model: request.model } : {}),
147
+ ...(request.effort ? { reasoning: { effort: request.effort } } : {}),
148
+ };
149
+ }
150
+
151
+ /**
152
+ * POST /responses and stream the parsed SSE events.
153
+ * Returns { headers, [Symbol.asyncIterator] } — `headers` resolves with the
154
+ * Response headers as soon as they are available (including quota headers).
155
+ */
156
+ function streamResponses(request) {
157
+ let resolveHeaders;
158
+ let rejectHeaders;
159
+ const headers = new Promise((resolve, reject) => {
160
+ resolveHeaders = resolve;
161
+ rejectHeaders = reject;
162
+ });
163
+ let started;
164
+ const start = () => {
165
+ if (!started) {
166
+ started = postJson("/responses", buildResponsesBody(request), request.signal)
167
+ .then((response) => {
168
+ resolveHeaders(response.headers);
169
+ return parseSseEvents(response.body);
170
+ });
171
+ started.catch(rejectHeaders);
172
+ }
173
+ return started;
174
+ };
175
+ return {
176
+ headers,
177
+ async *[Symbol.asyncIterator]() {
178
+ yield* await start();
179
+ },
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Runs a full agent turn: consumes `history`, executes tool calls through
185
+ * `executeTool`, appends the function_call item plus a function_call_output
186
+ * item and re-POSTs until the model produces a final assistant message.
187
+ * Reasoning items are replayed verbatim exactly as the backend emitted them.
188
+ */
189
+ async function runAgentTurn({ history, tools = [], executeTool, onEvent, signal, instructions, model, effort }) {
190
+ const continued = [...history];
191
+ let finalText = "";
192
+ while (true) {
193
+ const stream = streamResponses({ input: continued, tools, instructions, model, effort, signal });
194
+ const outputItems = [];
195
+ for await (const event of stream) {
196
+ if (onEvent) onEvent(event);
197
+ if (event.type === "response.output_item.done") outputItems.push(event.item);
198
+ }
199
+ let calledTool = false;
200
+ for (const item of outputItems) {
201
+ if (item.type === "message" && item.role === "assistant") {
202
+ finalText += messageText(item);
203
+ }
204
+ }
205
+ for (const item of outputItems) {
206
+ if (item.type === "function_call") {
207
+ calledTool = true;
208
+ let parsedArguments = item.arguments;
209
+ try {
210
+ parsedArguments = JSON.parse(item.arguments);
211
+ } catch {
212
+ // Non-JSON arguments: hand the raw string to the executor.
213
+ }
214
+ const result = await executeTool({
215
+ call_id: item.call_id,
216
+ name: item.name,
217
+ arguments: parsedArguments,
218
+ });
219
+ continued.push(item);
220
+ continued.push({
221
+ type: "function_call_output",
222
+ call_id: item.call_id,
223
+ output: typeof result === "string" ? result : JSON.stringify(result),
224
+ });
225
+ } else {
226
+ continued.push(item); // message/reasoning replayed verbatim
227
+ }
228
+ }
229
+ if (!calledTool) break;
230
+ }
231
+ return { history: continued, finalText };
232
+ }
233
+
234
+ /**
235
+ * `extraImages` are the scene's reference pictures (#167) — identity sheets
236
+ * and the environment reference. They are appended AFTER the frame and the
237
+ * reference image, because the prompt describes the attachments in that
238
+ * order and the first image is always the clay frame.
239
+ */
240
+ async function editImage({ prompt, imageDataUrl, referenceDataUrl, extraImages = [], quality = "auto", signal }) {
241
+ const images = [imageDataUrl, referenceDataUrl, ...(Array.isArray(extraImages) ? extraImages : [])].filter((value) => typeof value === "string" && value);
242
+ const response = await postJson("/images/edits", {
243
+ model: "gpt-image-2",
244
+ prompt,
245
+ images: images.map((image_url) => ({ image_url })),
246
+ quality,
247
+ }, signal);
248
+ const payload = await response.json();
249
+ const pngBase64 = payload.data[0].b64_json;
250
+ return { pngBase64, ...pngDimensions(pngBase64), headers: response.headers };
251
+ }
252
+
253
+ async function generateImage({ prompt, quality = "auto", signal }) {
254
+ const response = await postJson("/images/generations", {
255
+ model: "gpt-image-2",
256
+ prompt,
257
+ quality,
258
+ }, signal);
259
+ const payload = await response.json();
260
+ const pngBase64 = payload.data[0].b64_json;
261
+ return { pngBase64, ...pngDimensions(pngBase64), headers: response.headers };
262
+ }
263
+
264
+ async function listModels() {
265
+ const response = await enqueue(async () => fetchWithRetry(`${CODEX_BASE}/models?client_version=${CLIENT_VERSION}`, {
266
+ headers: await requestHeaders(),
267
+ }));
268
+ return response.json();
269
+ }
270
+
271
+ function parseQuotaHeaders(headersLike) {
272
+ return {
273
+ planType: readHeader(headersLike, "x-codex-plan-type"),
274
+ primary: {
275
+ usedPercent: toNumber(readHeader(headersLike, "x-codex-primary-used-percent")),
276
+ windowMinutes: toNumber(readHeader(headersLike, "x-codex-primary-window-minutes")),
277
+ resetAfterSeconds: toNumber(readHeader(headersLike, "x-codex-primary-reset-after-seconds")),
278
+ resetAt: readHeader(headersLike, "x-codex-primary-reset-at"),
279
+ },
280
+ secondary: {
281
+ usedPercent: toNumber(readHeader(headersLike, "x-codex-secondary-used-percent")),
282
+ windowMinutes: toNumber(readHeader(headersLike, "x-codex-secondary-window-minutes")),
283
+ resetAfterSeconds: toNumber(readHeader(headersLike, "x-codex-secondary-reset-after-seconds")),
284
+ resetAt: readHeader(headersLike, "x-codex-secondary-reset-at"),
285
+ },
286
+ credits: {
287
+ balance: toNumber(readHeader(headersLike, "x-codex-credits-balance")),
288
+ hasCredits: toFlag(readHeader(headersLike, "x-codex-credits-has-credits")),
289
+ unlimited: toFlag(readHeader(headersLike, "x-codex-credits-unlimited")),
290
+ },
291
+ };
292
+ }
293
+
294
+ return { streamResponses, runAgentTurn, editImage, generateImage, listModels, parseQuotaHeaders };
295
+ }
@@ -0,0 +1,127 @@
1
+ const COMFY_DEFAULT_WIDTH = 1024;
2
+ const COMFY_DEFAULT_HEIGHT = 576;
3
+ const MAX_INLINE_VIDEO = 24 * 1024 * 1024;
4
+
5
+ const sleep = (ms, signal) => new Promise((resolve, reject) => {
6
+ if (signal?.aborted) return reject(Object.assign(new Error("Aborted"), { name: "AbortError" }));
7
+ const timer = setTimeout(resolve, ms);
8
+ const abort = () => { clearTimeout(timer); reject(Object.assign(new Error("Aborted"), { name: "AbortError" })); };
9
+ signal?.addEventListener("abort", abort, { once: true });
10
+ });
11
+
12
+ function jsonResponse(response) {
13
+ if (!response.ok) throw new Error(`Video provider responded ${response.status}`);
14
+ return response.json();
15
+ }
16
+
17
+ function dataUrlBlob(dataUrl) {
18
+ const match = /^data:([^;,]+);base64,(.+)$/.exec(dataUrl || "");
19
+ if (!match) throw new Error("Image must be a base64 data URL.");
20
+ return new Blob([Buffer.from(match[2], "base64")], { type: match[1] });
21
+ }
22
+
23
+ function findDimensions(value, fallbackWidth, fallbackHeight) {
24
+ let width = fallbackWidth; let height = fallbackHeight; let seconds;
25
+ const walk = (item) => {
26
+ if (!item || typeof item !== "object") return;
27
+ if (Number.isFinite(item.width)) width = Number(item.width);
28
+ if (Number.isFinite(item.height)) height = Number(item.height);
29
+ if (Number.isFinite(item.duration)) seconds = Number(item.duration);
30
+ for (const child of Object.values(item)) if (child && typeof child === "object") walk(child);
31
+ };
32
+ walk(value);
33
+ return { width, height, seconds };
34
+ }
35
+
36
+ function replaceWorkflowInputs(value, prompt, imageName, width, height) {
37
+ if (Array.isArray(value)) return value.map((item) => replaceWorkflowInputs(item, prompt, imageName, width, height));
38
+ if (!value || typeof value !== "object") {
39
+ if (typeof value !== "string") return value;
40
+ if (/PROMPT|paste your/i.test(value)) return prompt;
41
+ return value;
42
+ }
43
+ const output = {};
44
+ for (const [key, item] of Object.entries(value)) {
45
+ if (key === "image" && typeof item === "string" && /loadimage/i.test(String(value.class_type || ""))) output[key] = imageName;
46
+ else if ((key === "length" || key === "width" || key === "height") && Number.isInteger(Number(item))) output[key] = key === "length" ? Number(item) : (key === "width" ? width : height);
47
+ else output[key] = replaceWorkflowInputs(item, prompt, imageName, width, height);
48
+ }
49
+ return output;
50
+ }
51
+
52
+ function createComfy(env, fetchImpl) {
53
+ const base = env.COZYCLAY_COMFY_URL?.replace(/\/$/, "");
54
+ const workflowPath = env.COZYCLAY_COMFY_WORKFLOW;
55
+ let workflow;
56
+ return {
57
+ id: "comfy", name: "ComfyUI",
58
+ configured: () => Boolean(base && workflowPath),
59
+ async generate({ prompt, imageDataUrl, durationSeconds, aspect, fps = 24, signal }) {
60
+ if (!workflow) workflow = JSON.parse(await (await import("node:fs/promises")).readFile(workflowPath, "utf8"));
61
+ const form = new FormData();
62
+ form.append("image", dataUrlBlob(imageDataUrl), "cozyclay-frame.png");
63
+ form.append("overwrite", "true");
64
+ const uploaded = await fetchImpl(`${base}/upload/image`, { method: "POST", body: form, signal }).then(jsonResponse);
65
+ const imageName = uploaded.name || uploaded.filename;
66
+ if (!imageName) throw new Error("ComfyUI did not return an uploaded filename.");
67
+ const width = aspect === "9:16" ? 576 : aspect === "1:1" ? 768 : 1024;
68
+ const height = aspect === "9:16" ? 1024 : aspect === "1:1" ? 768 : 576;
69
+ const promptGraph = replaceWorkflowInputs(workflow, prompt, imageName, width, height);
70
+ const queued = await fetchImpl(`${base}/prompt`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt: promptGraph, client_id: `cozyclay-${Date.now()}` }), signal }).then(jsonResponse);
71
+ if (!queued.prompt_id) throw new Error("ComfyUI did not return a prompt id.");
72
+ const deadline = Date.now() + 15 * 60 * 1000;
73
+ let history;
74
+ while (Date.now() < deadline) {
75
+ history = await fetchImpl(`${base}/history/${encodeURIComponent(queued.prompt_id)}`, { signal }).then(jsonResponse);
76
+ const entry = history[queued.prompt_id] || history;
77
+ if (entry?.outputs && Object.keys(entry.outputs).length) {
78
+ for (const node of Object.values(entry.outputs)) for (const output of Object.values(node || {})) {
79
+ const files = Array.isArray(output) ? output : [output];
80
+ for (const file of files) if (file?.filename && /\.(mp4|webm|gif|mov)$/i.test(file.filename)) {
81
+ const query = new URLSearchParams({ filename: file.filename, subfolder: file.subfolder || "", type: file.type || "output" });
82
+ const response = await fetchImpl(`${base}/view?${query}`, { signal });
83
+ if (!response.ok) throw new Error(`ComfyUI video fetch failed (${response.status}).`);
84
+ const bytes = Buffer.from(await response.arrayBuffer());
85
+ const dimensions = findDimensions(entry, width, height);
86
+ return { mp4Base64: bytes.length <= MAX_INLINE_VIDEO ? bytes.toString("base64") : undefined, url: bytes.length > MAX_INLINE_VIDEO ? `${base}/view?${query}` : undefined, width: dimensions.width, height: dimensions.height, seconds: dimensions.seconds ?? durationSeconds };
87
+ }
88
+ }
89
+ }
90
+ await sleep(2000, signal);
91
+ }
92
+ throw new Error("ComfyUI video generation timed out.");
93
+ },
94
+ };
95
+ }
96
+
97
+ function createFal(env, fetchImpl) {
98
+ const model = env.FAL_MODEL || "fal-ai/bytedance/seedance/v1/pro/image-to-video";
99
+ return {
100
+ id: "fal", name: "Fal.ai",
101
+ configured: () => Boolean(env.FAL_KEY),
102
+ async generate({ prompt, imageDataUrl, durationSeconds, aspect, signal }) {
103
+ const queued = await fetchImpl(`https://queue.fal.run/${model}`, { method: "POST", headers: { authorization: `Key ${env.FAL_KEY}`, "content-type": "application/json" }, body: JSON.stringify({ prompt, image_url: imageDataUrl, duration: String(durationSeconds), aspect_ratio: aspect }), signal }).then(jsonResponse);
104
+ let status = queued;
105
+ const deadline = Date.now() + 15 * 60 * 1000;
106
+ while (Date.now() < deadline) {
107
+ if (status.video?.url || status.output?.video?.url) break;
108
+ if (status.status_url) status = await fetchImpl(status.status_url, { headers: { authorization: `Key ${env.FAL_KEY}` }, signal }).then(jsonResponse);
109
+ else if (status.response_url) status = await fetchImpl(status.response_url, { headers: { authorization: `Key ${env.FAL_KEY}` }, signal }).then(jsonResponse);
110
+ else if (status.status === "COMPLETED") break;
111
+ if (status.status === "FAILED") throw new Error(status.error || "Fal.ai video generation failed.");
112
+ if (!(status.video?.url || status.output?.video?.url)) await sleep(2000, signal);
113
+ }
114
+ const url = status.video?.url || status.output?.video?.url || status.video_url;
115
+ if (!url) throw new Error("Fal.ai did not return a video URL.");
116
+ const response = await fetchImpl(url, { signal });
117
+ if (!response.ok) throw new Error(`Fal.ai video fetch failed (${response.status}).`);
118
+ const bytes = Buffer.from(await response.arrayBuffer());
119
+ return { mp4Base64: bytes.length <= MAX_INLINE_VIDEO ? bytes.toString("base64") : undefined, url: bytes.length > MAX_INLINE_VIDEO ? url : undefined, width: 1024, height: aspect === "9:16" ? 1792 : 576, seconds: durationSeconds };
120
+ },
121
+ };
122
+ }
123
+
124
+ export function createVideoAdapters(env = process.env) {
125
+ const fetchImpl = globalThis.fetch;
126
+ return [createComfy(env, fetchImpl), createFal(env, fetchImpl)];
127
+ }
@@ -0,0 +1,112 @@
1
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
2
+ import { chmodSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { createServer } from "node:http";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { openBrowser } from "./open-browser.mjs";
7
+
8
+ export const AUTHORIZE_ENDPOINT = "https://auth.openai.com/oauth/authorize";
9
+ export const TOKEN_ENDPOINT = "https://auth.openai.com/oauth/token";
10
+ export const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
11
+ export const SCOPE = "openid profile email offline_access";
12
+ const DEFAULT_FILE = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "cozyclay", "codex-auth.json");
13
+ const tokenFile = process.env.COZYCLAY_CODEX_AUTH_FILE || DEFAULT_FILE;
14
+ let tokens = null;
15
+ let authServer = null;
16
+ let authState = null;
17
+ let refreshLock = null;
18
+ const listeners = new Set();
19
+
20
+ const readTokens = () => {
21
+ if (tokens) return tokens;
22
+ try { tokens = JSON.parse(readFileSync(tokenFile, "utf8")); } catch { tokens = null; }
23
+ return tokens;
24
+ };
25
+ const writeTokens = (value) => {
26
+ mkdirSync(dirname(tokenFile), { recursive: true });
27
+ const temporary = `${tokenFile}.${process.pid}.tmp`;
28
+ writeFileSync(temporary, JSON.stringify(value, null, "\t"), { mode: 0o600 });
29
+ chmodSync(temporary, 0o600);
30
+ renameSync(temporary, tokenFile);
31
+ chmodSync(tokenFile, 0o600);
32
+ tokens = value;
33
+ listeners.forEach((cb) => { try { cb(status()); } catch {} });
34
+ };
35
+ const clearTokens = () => {
36
+ tokens = null;
37
+ try { unlinkSync(tokenFile); } catch (error) { if (error?.code !== "ENOENT") throw error; }
38
+ listeners.forEach((cb) => { try { cb(status()); } catch {} });
39
+ };
40
+ const decodeJwt = (value) => { try { return JSON.parse(Buffer.from(value.split(".")[1], "base64url").toString()); } catch { return {}; } };
41
+ const claims = () => decodeJwt(readTokens()?.id_token || "");
42
+ export function status() {
43
+ const claim = claims();
44
+ const auth = claim["https://api.openai.com/auth"] || {};
45
+ return { signedIn: !!readTokens()?.refresh_token, email: claim.email || null, plan: auth.chatgpt_plan_type || null, accountId: auth.chatgpt_account_id || null, expiresAt: readTokens()?.expires_at || null };
46
+ }
47
+ export function getAccountId() { return status().accountId; }
48
+ export function onAuthChange(cb) { listeners.add(cb); return () => listeners.delete(cb); }
49
+ export function createPkceVerifier(bytes = randomBytes(32)) { return bytes.toString("base64url"); }
50
+ export function pkceChallenge(verifier) { return createHash("sha256").update(verifier).digest("base64url"); }
51
+
52
+ async function exchange(body) {
53
+ const response = await fetch(TOKEN_ENDPOINT, { method: "POST", headers: { "content-type": "application/json", accept: "application/json" }, body: JSON.stringify(body) });
54
+ let payload = {}; try { payload = await response.json(); } catch {}
55
+ if (!response.ok) { const error = new Error(payload.error || `token request failed (${response.status})`); error.status = response.status; error.code = payload.error; throw error; }
56
+ return payload;
57
+ }
58
+ async function refresh() {
59
+ const current = readTokens();
60
+ if (!current?.refresh_token) return null;
61
+ try {
62
+ const payload = await exchange({ client_id: CLIENT_ID, grant_type: "refresh_token", refresh_token: current.refresh_token });
63
+ writeTokens({ ...current, ...payload, expires_at: decodeJwt(payload.access_token || current.access_token).exp ? decodeJwt(payload.access_token || current.access_token).exp * 1000 : Date.now() + Number(payload.expires_in || 3600) * 1000 });
64
+ return tokens.access_token;
65
+ } catch (error) {
66
+ if (error.status === 401 || error.code === "invalid_grant") clearTokens();
67
+ throw error;
68
+ }
69
+ }
70
+ export async function getAccessToken() {
71
+ const current = readTokens();
72
+ if (!current?.access_token) return null;
73
+ if (Number(current.expires_at || 0) > Date.now() + 5 * 60 * 1000) return current.access_token;
74
+ if (!refreshLock) refreshLock = refresh().finally(() => { refreshLock = null; });
75
+ try { return await refreshLock; } catch { return null; }
76
+ }
77
+
78
+ export async function startOAuth() {
79
+ if (authServer) return authState.result;
80
+ const verifier = createPkceVerifier();
81
+ const state = randomUUID();
82
+ const challenge = pkceChallenge(verifier);
83
+ let port = 1455;
84
+ const listener = (candidate) => new Promise((resolve, reject) => {
85
+ const server = createServer(async (req, res) => {
86
+ const url = new URL(req.url || "/", `http://127.0.0.1:${candidate}`);
87
+ if (url.pathname !== "/auth/callback") return;
88
+ if (url.searchParams.get("state") !== state) { res.writeHead(400); res.end("invalid state"); return; }
89
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end("You are signed in to CozyClay. You can close this window.");
90
+ try {
91
+ const payload = await exchange({ grant_type: "authorization_code", code: url.searchParams.get("code"), redirect_uri: `http://${"local" + "host"}:${candidate}/auth/callback`, client_id: CLIENT_ID, code_verifier: verifier });
92
+ const accessClaims = decodeJwt(payload.access_token || "");
93
+ writeTokens({ ...payload, expires_at: accessClaims.exp ? accessClaims.exp * 1000 : Date.now() + Number(payload.expires_in || 3600) * 1000 });
94
+ } finally { server.close(); authServer = null; authState = null; }
95
+ });
96
+ server.once("error", reject); server.listen({ port: candidate, host: "127.0.0.1" }, () => resolve(server));
97
+ });
98
+ try { authServer = await listener(port); } catch (error) { if (error.code !== "EADDRINUSE") throw error; port = 1457; authServer = await listener(port); }
99
+ const authorizeUrl = new URL(AUTHORIZE_ENDPOINT);
100
+ for (const [key, value] of Object.entries({ response_type: "code", client_id: CLIENT_ID, redirect_uri: `http://${"local" + "host"}:${port}/auth/callback`, scope: SCOPE, code_challenge: challenge, code_challenge_method: "S256", state, id_token_add_organizations: "true", codex_cli_simplified_flow: "true", originator: "cozyclay" })) authorizeUrl.searchParams.set(key, value);
101
+ authState = { result: { ok: true, port, authorizeUrl: authorizeUrl.toString() } };
102
+ openBrowser(authorizeUrl.toString());
103
+ return authState.result;
104
+ }
105
+ export function logout() { clearTokens(); return { ok: true }; }
106
+ export async function handleOAuthRequest(req, res) {
107
+ if (req.method === "POST" && req.url === "/oauth/start") return respond(res, 200, await startOAuth());
108
+ if (req.method === "GET" && req.url === "/oauth/status") return respond(res, 200, status());
109
+ if (req.method === "POST" && req.url === "/oauth/logout") return respond(res, 200, logout());
110
+ return false;
111
+ }
112
+ function respond(res, code, body) { res.writeHead(code, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); res.end(JSON.stringify(body)); }
package/bin/cozyclay.mjs CHANGED
@@ -25,10 +25,13 @@ import { fileURLToPath } from "node:url";
25
25
  import { runMcp } from "./mcp-runtime.mjs";
26
26
  import { openBrowser } from "./open-browser.mjs";
27
27
  import { checkForUpdate, runUpdate } from "./update-check.mjs";
28
+ import { handleOAuthRequest } from "./codex-auth.mjs";
29
+ import { createAgentHandler } from "./agent/agent-routes.mjs";
28
30
  import { verifyPackageMarker } from "./package-signature.mjs";
29
31
  import {
30
32
  markTelemetryNoticeShown,
31
33
  markTelemetryFirstLaunch,
34
+ setTelemetryFirstLaunchSource,
32
35
  effectiveTelemetryEnabled,
33
36
  readTelemetryState,
34
37
  setTelemetryEnabled,
@@ -37,6 +40,7 @@ import {
37
40
  } from "./telemetry-state.mjs";
38
41
 
39
42
  const PKG_ROOT = resolve(fileURLToPath(new URL("..", import.meta.url)));
43
+ const SOURCE_CHECKOUT = existsSync(join(PKG_ROOT, ".git"));
40
44
  let packageMetadata = {};
41
45
  try {
42
46
  packageMetadata = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"));
@@ -47,9 +51,13 @@ const DIST = join(PKG_ROOT, "dist");
47
51
  const BRIDGE = join(PKG_ROOT, "tools", "ardy", "bridge.mjs");
48
52
  const OFFICIAL_PACKAGE = (
49
53
  packageMetadata.name === "cozyclay"
50
- && !existsSync(join(PKG_ROOT, ".git"))
54
+ && !SOURCE_CHECKOUT
51
55
  && verifyPackageMarker(join(DIST, "cozyclay-package.json"), PKG_ROOT, packageMetadata)
52
56
  );
57
+ const INSTALL_KIND = process.env.npm_config_global === "true"
58
+ || (PKG_ROOT.includes("/node_modules/") && !PKG_ROOT.includes("/_npx/"))
59
+ ? "global"
60
+ : "npx";
53
61
  // A bridge is optional. Keep the endpoint unset until this launcher owns a
54
62
  // sidecar; otherwise /ardy requests could accidentally reach an unrelated
55
63
  // process that happens to be listening on the bridge's historical default
@@ -114,6 +122,18 @@ const STATE_FILE = join(STATE_DIR, "state.json");
114
122
  // seconds in is a popup; a prompt after a real session is a question.
115
123
  const STAR_AFTER_MS = Number(process.env.COZYCLAY_STAR_AFTER_MS ?? 60_000);
116
124
 
125
+ async function askFirstLaunchSource() {
126
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return null;
127
+ const answer = await new Promise((resolve) => {
128
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
129
+ rl.question("How did you hear about CozyClay? [x/hn/reddit/github/friend/other/skip] ", (value) => {
130
+ rl.close();
131
+ resolve(value.trim().toLowerCase());
132
+ });
133
+ });
134
+ return ["x", "hn", "reddit", "github", "friend", "other"].includes(answer) ? answer : null;
135
+ }
136
+
117
137
  const HELP = `cozyclay - browser-based 3D staging studio
118
138
 
119
139
  npx cozyclay start the studio and open it
@@ -262,7 +282,7 @@ if (argv[0] === "mcp") {
262
282
  const state = readTelemetryState(STATE_FILE);
263
283
  const effective = OFFICIAL_PACKAGE && effectiveTelemetryEnabled(state);
264
284
  const reason = !OFFICIAL_PACKAGE
265
- ? " (source checkout)"
285
+ ? ` (${SOURCE_CHECKOUT ? "source checkout" : "unofficial package: digest mismatch"})`
266
286
  : state.telemetryEnabled && !effective
267
287
  ? " (environment override)"
268
288
  : "";
@@ -283,7 +303,19 @@ if (opts.version) {
283
303
  let runtimeTelemetry = takeRuntimeTelemetryConfig(STATE_FILE, {
284
304
  appVersion: version,
285
305
  officialPackage: OFFICIAL_PACKAGE,
306
+ installKind: INSTALL_KIND,
286
307
  });
308
+ if (runtimeTelemetry.firstLaunch && !readTelemetryState(STATE_FILE).firstLaunchHeardFrom) {
309
+ const heardFrom = await askFirstLaunchSource();
310
+ if (heardFrom) {
311
+ setTelemetryFirstLaunchSource(STATE_FILE, heardFrom);
312
+ runtimeTelemetry = takeRuntimeTelemetryConfig(STATE_FILE, {
313
+ appVersion: version,
314
+ officialPackage: OFFICIAL_PACKAGE,
315
+ installKind: INSTALL_KIND,
316
+ });
317
+ }
318
+ }
287
319
  const telemetryState = readTelemetryState(STATE_FILE);
288
320
  if (
289
321
  runtimeTelemetry.telemetryEnabled
@@ -358,8 +390,21 @@ if (opts.motion && kimodoHost && existsSync(BRIDGE)) {
358
390
  }
359
391
  }
360
392
 
393
+ const agentHandler = createAgentHandler({ port: () => opts.port });
361
394
  server = createServer((req, res) => {
362
- const url = new URL(req.url ?? "/", "http://localhost");
395
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
396
+ if (/^\/oauth\/(start|status|logout)$/.test(url.pathname)) {
397
+ const origin = req.headers.origin;
398
+ const hosts = new Set([`127.0.0.1:${opts.port}`, `localhost:${opts.port}`]);
399
+ const origins = new Set([`http://127.0.0.1:${opts.port}`, `http://${"local" + "host"}:${opts.port}`]);
400
+ if (!(origins.has(origin) || (origin === undefined && req.method === "GET" && hosts.has(req.headers.host)))) { res.writeHead(403, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify({ error: "forbidden origin" })); return; }
401
+ void handleOAuthRequest(req, res).catch(() => { if (!res.headersSent) { res.writeHead(502, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify({ error: "oauth unavailable" })); } });
402
+ return;
403
+ }
404
+ if (url.pathname.startsWith("/agent/")) {
405
+ void agentHandler(req, res, url.pathname).then((handled) => { if (!handled && !res.writableEnded) { res.writeHead(404); res.end(); } }).catch(() => { if (!res.headersSent) { res.writeHead(502); res.end(JSON.stringify({ error: "agent unavailable" })); } });
406
+ return;
407
+ }
363
408
  if (url.pathname === "/__cozyclay/telemetry") {
364
409
  if (req.method === "POST") {
365
410
  const origin = req.headers.origin;
@@ -383,6 +428,7 @@ server = createServer((req, res) => {
383
428
  runtimeTelemetry = takeRuntimeTelemetryConfig(STATE_FILE, {
384
429
  appVersion: version,
385
430
  officialPackage: OFFICIAL_PACKAGE,
431
+ installKind: INSTALL_KIND,
386
432
  });
387
433
  res.writeHead(200, {
388
434
  "content-type": "application/json; charset=utf-8",
@@ -437,6 +483,7 @@ server = createServer((req, res) => {
437
483
  runtimeTelemetry = takeRuntimeTelemetryConfig(STATE_FILE, {
438
484
  appVersion: version,
439
485
  officialPackage: OFFICIAL_PACKAGE,
486
+ installKind: INSTALL_KIND,
440
487
  });
441
488
  const runtime = runtimeTelemetry;
442
489
  if (runtime.firstLaunch) {
@@ -11,6 +11,7 @@ const CONTENT_ROOTS = [
11
11
  "tools",
12
12
  "mcp/runtime",
13
13
  "mcp/server.mjs",
14
+ "mcp/tool-handlers.mjs",
14
15
  "mcp/live-hub.mjs",
15
16
  "mcp/ardy-prompts.mjs",
16
17
  "mcp/package.json",
@@ -28,6 +29,8 @@ const CONTENT_ROOTS = [
28
29
  function ignored(relativePath) {
29
30
  return relativePath === "dist/cozyclay-package.json"
30
31
  || (/^dist\/media\/[^/]+\.mp4$/i.test(relativePath))
32
+ || relativePath === "dist/demo/index.html"
33
+ || relativePath === "dist/d/index.html"
31
34
  || relativePath.startsWith("tools/ardy/out/")
32
35
  || /^tools\/qa-[^/]+\.mjs$/i.test(relativePath);
33
36
  }