@a-company/atelier 0.28.2 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,26 +1,142 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ applyRecipeCommand,
3
4
  assetsCommand,
5
+ captionsCommand,
6
+ carouselCommand,
7
+ exportImageCommand,
4
8
  exportLottieCommand,
5
9
  exportSvgCommand,
6
10
  infoCommand,
11
+ parseAtelier,
12
+ recipeCommand,
7
13
  renderCommand,
14
+ serializeAtelier,
8
15
  stillCommand,
16
+ transcribeCommand,
17
+ transcriptCommand,
18
+ trimCommand,
9
19
  validateCommand,
20
+ validateVideoLayer,
10
21
  variablesCommand
11
- } from "./chunk-LC7ICNMN.js";
12
- import "./chunk-C5DBTHXB.js";
22
+ } from "./chunk-5QQESXI6.js";
23
+ import {
24
+ validateAllDeltas
25
+ } from "./chunk-JPZ4F4PW.js";
13
26
 
14
27
  // src/cli.ts
15
28
  import { createRequire } from "module";
16
29
  import { Command } from "commander";
17
30
 
31
+ // src/commands/lint.ts
32
+ import { readFileSync } from "fs";
33
+ import { resolve } from "path";
34
+ function lintFile(filePath) {
35
+ const absPath = resolve(filePath);
36
+ let content;
37
+ try {
38
+ content = readFileSync(absPath, "utf-8");
39
+ } catch {
40
+ return {
41
+ file: absPath,
42
+ valid: false,
43
+ gates: [
44
+ {
45
+ gate: "^valid-document",
46
+ pass: false,
47
+ errors: [`Cannot read file: ${absPath}`]
48
+ }
49
+ ]
50
+ };
51
+ }
52
+ const gates = [];
53
+ const parseResult = parseAtelier(content);
54
+ if (!parseResult.success) {
55
+ gates.push({
56
+ gate: "^valid-document",
57
+ pass: false,
58
+ errors: parseResult.errors.map((e) => `${e.path}: ${e.message}`)
59
+ });
60
+ return { file: absPath, valid: false, gates };
61
+ }
62
+ gates.push({ gate: "^valid-document", pass: true, errors: [] });
63
+ const doc = parseResult.data;
64
+ const deltaErrors = [];
65
+ for (const [stateName, state] of Object.entries(doc.states)) {
66
+ const overlaps = validateAllDeltas(state.deltas);
67
+ for (const overlap of overlaps) {
68
+ deltaErrors.push(`State "${stateName}": ${overlap.message}`);
69
+ }
70
+ }
71
+ gates.push({
72
+ gate: "^valid-delta",
73
+ pass: deltaErrors.length === 0,
74
+ errors: deltaErrors
75
+ });
76
+ const videoErrors = [];
77
+ for (const layer of doc.layers) {
78
+ if (layer.visual.type !== "video") continue;
79
+ const visual = layer.visual;
80
+ const duration = doc.assets?.[visual.assetId]?.videoMeta?.duration;
81
+ const result = validateVideoLayer(visual, duration);
82
+ if (!result.success) {
83
+ for (const err of result.errors) {
84
+ videoErrors.push(`Layer "${layer.id}" (${err.path}): ${err.message}`);
85
+ }
86
+ }
87
+ }
88
+ gates.push({
89
+ gate: "^valid-video-layer",
90
+ pass: videoErrors.length === 0,
91
+ errors: videoErrors
92
+ });
93
+ const valid = gates.every((g) => g.pass);
94
+ return { file: absPath, valid, gates };
95
+ }
96
+ function formatResult(result) {
97
+ const lines = [];
98
+ const status = result.valid ? "PASS" : "FAIL";
99
+ lines.push(`${status} ${result.file}`);
100
+ for (const gate of result.gates) {
101
+ const gateStatus = gate.pass ? " \u2713" : " \u2717";
102
+ lines.push(`${gateStatus} ${gate.gate}`);
103
+ for (const err of gate.errors) {
104
+ lines.push(` ${err}`);
105
+ }
106
+ }
107
+ return lines.join("\n");
108
+ }
109
+ function lintCommand(program2) {
110
+ program2.command("lint <files...>").description(
111
+ "Lint .atelier files against all gates (^valid-document, ^valid-delta, ^valid-video-layer)"
112
+ ).option("--json", "Output results as JSON array").action((files, opts) => {
113
+ const results = files.map(lintFile);
114
+ if (opts.json) {
115
+ console.log(JSON.stringify(results, null, 2));
116
+ } else {
117
+ for (const result of results) {
118
+ console.log(formatResult(result));
119
+ }
120
+ }
121
+ const allValid = results.every((r) => r.valid);
122
+ if (!allValid) process.exit(1);
123
+ });
124
+ }
125
+
18
126
  // src/commands/studio.ts
19
- import { resolve, join, relative, dirname } from "path";
20
- import { mkdirSync, writeFileSync, rmSync, readFileSync, readdirSync, statSync, realpathSync, symlinkSync, existsSync } from "fs";
127
+ import { resolve as resolve2, join, relative, dirname, sep } from "path";
128
+ import { mkdirSync, writeFileSync, rmSync, readFileSync as readFileSync2, readdirSync, statSync, realpathSync, symlinkSync, existsSync, copyFileSync } from "fs";
21
129
  import { tmpdir } from "os";
22
130
  import { randomBytes } from "crypto";
23
131
  import { exec } from "child_process";
132
+ import {
133
+ DocumentStore,
134
+ WebSocketServerTransport,
135
+ createServer as createMcpServer,
136
+ BRIDGE_PROTOCOL_VERSION,
137
+ isBridgeEnvelope
138
+ } from "@a-company/atelier-mcp";
139
+ import { WebSocketServer } from "ws";
24
140
  function findAtelierFiles(dir, base = dir) {
25
141
  const results = [];
26
142
  let entries;
@@ -47,9 +163,119 @@ function findAtelierFiles(dir, base = dir) {
47
163
  return results.sort();
48
164
  }
49
165
  function isSafePath(filePath) {
50
- if (!filePath || filePath.includes("..") || filePath.startsWith("/")) return false;
51
- const resolved = resolve(process.cwd(), filePath);
52
- return resolved.startsWith(process.cwd());
166
+ if (!filePath || filePath.startsWith("/")) return false;
167
+ const cwd = process.cwd();
168
+ const resolved = resolve2(cwd, filePath);
169
+ return resolved === cwd || resolved.startsWith(cwd + sep);
170
+ }
171
+ function writeFileEnsuringDir(absPath, body) {
172
+ mkdirSync(dirname(absPath), { recursive: true });
173
+ writeFileSync(absPath, body, "utf-8");
174
+ }
175
+ function isAllowedOrigin(origin, port) {
176
+ if (!origin) return false;
177
+ return origin === `http://localhost:${port}` || origin === `http://127.0.0.1:${port}`;
178
+ }
179
+ function isAllowedMcpOrigin(origin, port) {
180
+ return origin === void 0 || isAllowedOrigin(origin, port);
181
+ }
182
+ function shouldBroadcastMutation(source) {
183
+ return source === "llm";
184
+ }
185
+ function attachBridgeClient(ws, state, clients, loadDocFromDisk, persistHumanPatch) {
186
+ clients.add(ws);
187
+ const clientId = randomBytes(6).toString("hex");
188
+ const send = (env) => {
189
+ if (ws.readyState !== ws.OPEN) return;
190
+ ws.send(JSON.stringify(env));
191
+ };
192
+ send({ type: "hello", clientId, protocolVersion: BRIDGE_PROTOCOL_VERSION });
193
+ if (state.currentDocId) {
194
+ const existing = state.store.get(state.currentDocId);
195
+ if (existing) {
196
+ send({ type: "doc:loaded", documentId: state.currentDocId, doc: existing });
197
+ } else {
198
+ const raw = loadDocFromDisk(state.currentDocId);
199
+ if (raw) {
200
+ const parsed = parseAtelier(raw);
201
+ if (parsed.success) {
202
+ state.store.set(state.currentDocId, parsed.data, "system");
203
+ send({ type: "doc:loaded", documentId: state.currentDocId, doc: parsed.data });
204
+ }
205
+ }
206
+ }
207
+ }
208
+ const onMessage = (data) => {
209
+ let text;
210
+ if (typeof data === "string") text = data;
211
+ else if (data instanceof Buffer) text = data.toString("utf-8");
212
+ else if (Array.isArray(data)) text = Buffer.concat(data).toString("utf-8");
213
+ else text = String(data);
214
+ let env;
215
+ try {
216
+ env = JSON.parse(text);
217
+ } catch {
218
+ send({ type: "error", code: "parse_error", message: "invalid JSON" });
219
+ return;
220
+ }
221
+ if (!isBridgeEnvelope(env)) {
222
+ send({ type: "error", code: "invalid_envelope", message: "unknown envelope shape" });
223
+ return;
224
+ }
225
+ if (env.type === "doc:patch") {
226
+ try {
227
+ state.store.set(env.documentId, env.doc, "human");
228
+ state.currentDocId = env.documentId;
229
+ persistHumanPatch(env.documentId, env.doc);
230
+ } catch (err) {
231
+ const msg = err instanceof Error ? err.message : String(err);
232
+ send({ type: "error", code: "persist_failed", message: msg, opId: env.opId });
233
+ }
234
+ return;
235
+ }
236
+ if (env.type === "doc:load") {
237
+ const existing = state.store.get(env.documentId);
238
+ if (existing) {
239
+ send({ type: "doc:loaded", documentId: env.documentId, doc: existing });
240
+ return;
241
+ }
242
+ const raw = loadDocFromDisk(env.documentId);
243
+ if (raw) {
244
+ const parsed = parseAtelier(raw);
245
+ if (parsed.success) {
246
+ state.store.set(env.documentId, parsed.data, "system");
247
+ send({ type: "doc:loaded", documentId: env.documentId, doc: parsed.data });
248
+ return;
249
+ }
250
+ }
251
+ send({ type: "error", code: "not_found", message: `document ${env.documentId} not found` });
252
+ return;
253
+ }
254
+ };
255
+ const onClose = () => {
256
+ clients.delete(ws);
257
+ };
258
+ ws.on("message", onMessage);
259
+ ws.on("close", onClose);
260
+ ws.on("error", onClose);
261
+ return () => {
262
+ clients.delete(ws);
263
+ try {
264
+ ws.close();
265
+ } catch {
266
+ }
267
+ };
268
+ }
269
+ function broadcastToBridge(clients, envelope) {
270
+ const payload = JSON.stringify(envelope);
271
+ for (const ws of clients) {
272
+ if (ws.readyState === ws.OPEN) {
273
+ try {
274
+ ws.send(payload);
275
+ } catch {
276
+ }
277
+ }
278
+ }
53
279
  }
54
280
  function getInlineHTML() {
55
281
  return `<!DOCTYPE html>
@@ -68,431 +294,59 @@ function getInlineHTML() {
68
294
  </body>
69
295
  </html>`;
70
296
  }
71
- function getInlineApp(initialFile) {
297
+ function getInlineApp(initialFile, cliPackageDir) {
72
298
  const initialFileStr = initialFile ? JSON.stringify(initialFile) : "null";
73
- return `import { AtelierStudio, exportDocument, ImageCache } from "@a-company/atelier-studio";
74
- import "@a-company/atelier-studio/styles.css";
75
- import { parseAtelier, serializeAtelier } from "@a-company/atelier-schema";
76
-
77
- // \u2500\u2500 Types \u2500\u2500
78
- interface FileEntry {
79
- path: string;
80
- name: string;
81
- folder: string;
82
- }
83
-
84
- // \u2500\u2500 State \u2500\u2500
85
- let studio: AtelierStudio | null = null;
86
- let currentFile: string | null = null;
87
- let files: FileEntry[] = [];
88
- let saveTimeout: ReturnType<typeof setTimeout> | null = null;
89
-
90
- // \u2500\u2500 API helpers \u2500\u2500
91
- async function fetchFiles(): Promise<FileEntry[]> {
92
- const res = await fetch("/api/files");
93
- return res.json();
94
- }
95
-
96
- async function fetchFileContent(path: string): Promise<string> {
97
- const res = await fetch("/api/file?path=" + encodeURIComponent(path));
98
- return res.text();
99
- }
100
-
101
- async function saveFileContent(path: string, content: string): Promise<void> {
102
- await fetch("/api/file?path=" + encodeURIComponent(path), {
103
- method: "POST",
104
- headers: { "Content-Type": "text/plain" },
105
- body: content,
106
- });
107
- }
108
-
109
- async function saveExportBlob(path: string, blob: Blob): Promise<void> {
110
- const buf = await blob.arrayBuffer();
111
- await fetch("/api/export?path=" + encodeURIComponent(path), {
112
- method: "POST",
113
- headers: { "Content-Type": "application/octet-stream" },
114
- body: buf,
115
- });
299
+ const appModulePath = join(cliPackageDir, "src", "web", "inline-app.ts").split(sep).join("/");
300
+ return `import { bootStudioApp } from ${JSON.stringify(appModulePath)};
301
+ bootStudioApp({ initialFile: ${initialFileStr} });
302
+ `;
116
303
  }
117
-
118
- async function exportAll(format: "gif" | "mp4" | "webm"): Promise<void> {
119
- if (files.length === 0) return;
120
-
121
- // Create progress overlay
122
- const overlay = document.createElement("div");
123
- overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.75);display:flex;align-items:center;justify-content:center;z-index:10000";
124
- const card = document.createElement("div");
125
- card.style.cssText = "background:#333;border:1px solid #4A4A4A;border-radius:8px;padding:32px 40px;min-width:360px;color:#F5F0EB;font-family:'Cormorant Garamond',Georgia,serif";
126
- overlay.appendChild(card);
127
- document.body.appendChild(overlay);
128
-
129
- const title = document.createElement("div");
130
- title.style.cssText = "font-size:18px;margin-bottom:16px;font-weight:600";
131
- title.textContent = "Exporting All Files\u2026";
132
- card.appendChild(title);
133
-
134
- const fileLabel = document.createElement("div");
135
- fileLabel.style.cssText = "font-size:13px;color:#A89F95;margin-bottom:8px;font-family:'SF Mono','Fira Code',monospace";
136
- card.appendChild(fileLabel);
137
-
138
- const progress = document.createElement("progress");
139
- progress.style.cssText = "width:100%;height:6px;appearance:none;-webkit-appearance:none";
140
- progress.max = files.length;
141
- progress.value = 0;
142
- card.appendChild(progress);
143
-
144
- const statusText = document.createElement("div");
145
- statusText.style.cssText = "font-size:12px;color:#A89F95;margin-top:8px";
146
- card.appendChild(statusText);
147
-
148
- let exported = 0;
149
- let errors = 0;
150
-
151
- for (const file of files) {
152
- fileLabel.textContent = file.path;
153
- statusText.textContent = (exported + errors + 1) + " / " + files.length;
154
-
155
- try {
156
- const content = await fetchFileContent(file.path);
157
- const result = parseAtelier(content);
158
- if (!result.success) {
159
- errors++;
160
- progress.value = exported + errors;
161
- continue;
162
- }
163
-
164
- const doc = result.data;
165
- const w = doc.canvas.width;
166
- const h = doc.canvas.height;
167
- const canvas = document.createElement("canvas");
168
- canvas.width = w;
169
- canvas.height = h;
170
- const imageCache = new ImageCache();
171
-
172
- const exportResult = await exportDocument(doc, canvas, imageCache, {
173
- format,
174
- onProgress: ({ percent }) => {
175
- statusText.textContent = (exported + errors + 1) + " / " + files.length + " \u2014 " + percent + "%";
176
- },
177
- });
178
-
179
- // Save alongside the source file: e.g. "dir/my-anim.atelier" \u2192 "dir/my-anim.gif"
180
- const outPath = file.path.replace(/\\.atelier$/, "." + format);
181
- await saveExportBlob(outPath, exportResult.blob);
182
- exported++;
183
- } catch (e) {
184
- console.error("Export failed:", file.path, e);
185
- errors++;
304
+ function resolveCliPackageDir() {
305
+ const here = dirname(new URL(import.meta.url).pathname);
306
+ const candidates = [
307
+ resolve2(here, ".."),
308
+ resolve2(here, "..", "..")
309
+ ];
310
+ for (const c of candidates) {
311
+ if (existsSync(join(c, "package.json"))) {
312
+ return c;
186
313
  }
187
- progress.value = exported + errors;
188
314
  }
189
-
190
- // Done
191
- title.textContent = "Export Complete";
192
- fileLabel.textContent = "";
193
- statusText.textContent = exported + " exported" + (errors > 0 ? ", " + errors + " failed" : "");
194
- if (errors > 0) console.warn("Export All finished with " + errors + " error(s). Check console for details.");
195
-
196
- const closeBtn = document.createElement("button");
197
- closeBtn.style.cssText = "margin-top:16px;padding:6px 20px;background:#3D3D3D;color:#F5F0EB;border:1px solid #4A4A4A;border-radius:4px;cursor:pointer;font-family:inherit;font-size:13px";
198
- closeBtn.textContent = "Close";
199
- closeBtn.addEventListener("click", () => document.body.removeChild(overlay));
200
- card.appendChild(closeBtn);
315
+ return candidates[0];
201
316
  }
202
-
203
- // \u2500\u2500 Theme (matches branded theme from showcase) \u2500\u2500
204
- const theme = {
205
- bg: "#2C2C2C",
206
- bgSecondary: "#333333",
207
- bgTertiary: "#3D3D3D",
208
- text: "#F5F0EB",
209
- textMuted: "#A89F95",
210
- textAccent: "#F5F0EB",
211
- border: "#4A4A4A",
212
- buttonBg: "#3D3D3D",
213
- buttonHover: "#4A4A4A",
214
- buttonActive: "#555555",
215
- accent: "#C75B39",
216
- accentHover: "#D4724E",
217
- sliderTrack: "#4A4A4A",
218
- sliderThumb: "#C75B39",
219
- fontFamily: "'Cormorant Garamond', Georgia, serif",
220
- fontMono: "'SF Mono', 'Fira Code', monospace",
221
- canvasShadow: "0 4px 60px rgba(199, 91, 57, 0.12), 0 0 40px rgba(0,0,0,0.4)",
222
- };
223
-
224
- // \u2500\u2500 Styles \u2500\u2500
225
- const style = document.createElement("style");
226
- style.textContent = \`
227
- * { margin: 0; padding: 0; box-sizing: border-box; }
228
- html, body { height: 100%; overflow: hidden; background: #2C2C2C; color: #F5F0EB; }
229
- body { font-family: 'Cormorant Garamond', Georgia, serif; }
230
- #studio { display: flex; height: 100vh; width: 100vw; }
231
-
232
- .sidebar {
233
- width: 260px;
234
- min-width: 260px;
235
- background: #333333;
236
- border-right: 1px solid #4A4A4A;
237
- display: flex;
238
- flex-direction: column;
239
- overflow: hidden;
240
- }
241
- .sidebar__header {
242
- padding: 16px 20px;
243
- border-bottom: 1px solid #4A4A4A;
244
- font-size: 11px;
245
- font-weight: 600;
246
- letter-spacing: 2px;
247
- text-transform: uppercase;
248
- color: #A89F95;
249
- display: flex;
250
- align-items: center;
251
- gap: 8px;
252
- }
253
- .sidebar__header span {
254
- color: #C75B39;
255
- font-size: 13px;
256
- }
257
- .sidebar__list {
258
- flex: 1;
259
- overflow-y: auto;
260
- padding: 8px 0;
261
- }
262
- .sidebar__list::-webkit-scrollbar { width: 6px; }
263
- .sidebar__list::-webkit-scrollbar-track { background: transparent; }
264
- .sidebar__list::-webkit-scrollbar-thumb { background: #4A4A4A; border-radius: 3px; }
265
-
266
- .sidebar__folder {
267
- padding: 10px 20px 4px;
268
- font-size: 10px;
269
- font-weight: 600;
270
- letter-spacing: 1.5px;
271
- text-transform: uppercase;
272
- color: #A89F95;
273
- }
274
- .sidebar__item {
275
- padding: 8px 20px 8px 28px;
276
- font-size: 13px;
277
- cursor: pointer;
278
- color: #A89F95;
279
- transition: background 0.15s, color 0.15s;
280
- white-space: nowrap;
281
- overflow: hidden;
282
- text-overflow: ellipsis;
283
- font-family: 'SF Mono', 'Fira Code', monospace;
284
- font-size: 11.5px;
285
- }
286
- .sidebar__item:hover { background: #363636; color: #F5F0EB; }
287
- .sidebar__item--active {
288
- background: rgba(199, 91, 57, 0.12) !important;
289
- color: #C75B39 !important;
290
- }
291
-
292
- .main {
293
- flex: 1;
294
- display: flex;
295
- flex-direction: column;
296
- overflow: hidden;
297
- }
298
- .main__status {
299
- height: 32px;
300
- min-height: 32px;
301
- display: flex;
302
- align-items: center;
303
- padding: 0 16px;
304
- background: #333333;
305
- border-bottom: 1px solid #4A4A4A;
306
- font-size: 11px;
307
- color: #A89F95;
308
- font-family: 'SF Mono', 'Fira Code', monospace;
309
- gap: 12px;
310
- }
311
- .main__status .save-indicator {
312
- display: inline-flex;
313
- align-items: center;
314
- gap: 4px;
315
- margin-left: auto;
316
- transition: opacity 0.3s;
317
- }
318
- .main__status .save-indicator--saving { color: #C75B39; }
319
- .main__status .save-indicator--saved { color: #6B8E6B; }
320
- .main__editor {
321
- flex: 1;
322
- overflow: hidden;
323
- }
324
- .main__empty {
325
- flex: 1;
326
- display: flex;
327
- align-items: center;
328
- justify-content: center;
329
- color: #A89F95;
330
- font-size: 18px;
317
+ function scaffoldWelcomeIfEmpty(cwd, cliPackageDir) {
318
+ const existing = findAtelierFiles(cwd);
319
+ if (existing.length > 0) return null;
320
+ const templatesDir = join(cliPackageDir, "templates");
321
+ const welcomeSrc = join(templatesDir, "welcome.atelier");
322
+ if (!existsSync(welcomeSrc)) return null;
323
+ const welcomeDest = join(cwd, "welcome.atelier");
324
+ if (existsSync(welcomeDest)) return null;
325
+ try {
326
+ copyFileSync(welcomeSrc, welcomeDest);
327
+ } catch {
328
+ return null;
331
329
  }
332
- \`;
333
- document.head.appendChild(style);
334
-
335
- // \u2500\u2500 Build UI \u2500\u2500
336
- const root = document.getElementById("studio")!;
337
- const sidebar = document.createElement("div");
338
- sidebar.className = "sidebar";
339
-
340
- const sidebarHeader = document.createElement("div");
341
- sidebarHeader.className = "sidebar__header";
342
- sidebarHeader.innerHTML = '<span>&#9670;</span> ATELIER STUDIO';
343
- sidebar.appendChild(sidebarHeader);
344
-
345
- const sidebarList = document.createElement("div");
346
- sidebarList.className = "sidebar__list";
347
- sidebar.appendChild(sidebarList);
348
-
349
- const sidebarFooter = document.createElement("div");
350
- sidebarFooter.style.cssText = "padding:12px 16px;border-top:1px solid #4A4A4A;display:flex;gap:8px;align-items:center";
351
- const exportAllSelect = document.createElement("select");
352
- exportAllSelect.style.cssText = "flex:1;background:#3D3D3D;color:#F5F0EB;border:1px solid #4A4A4A;border-radius:4px;padding:4px 8px;font-size:11px;font-family:'SF Mono','Fira Code',monospace;cursor:pointer";
353
- for (const [val, label] of [["gif","GIF"],["mp4","MP4"],["webm","WebM"]] as const) {
354
- const o = document.createElement("option");
355
- o.value = val;
356
- o.textContent = label;
357
- exportAllSelect.appendChild(o);
358
- }
359
- sidebarFooter.appendChild(exportAllSelect);
360
- const exportAllBtn = document.createElement("button");
361
- exportAllBtn.style.cssText = "background:#C75B39;color:#F5F0EB;border:none;border-radius:4px;padding:5px 12px;font-size:11px;font-family:inherit;cursor:pointer;white-space:nowrap";
362
- exportAllBtn.textContent = "Export All";
363
- exportAllBtn.addEventListener("click", () => {
364
- exportAll(exportAllSelect.value as "gif" | "mp4" | "webm");
365
- });
366
- sidebarFooter.appendChild(exportAllBtn);
367
- sidebar.appendChild(sidebarFooter);
368
-
369
- const main = document.createElement("div");
370
- main.className = "main";
371
-
372
- const statusBar = document.createElement("div");
373
- statusBar.className = "main__status";
374
- main.appendChild(statusBar);
375
-
376
- const editorContainer = document.createElement("div");
377
- editorContainer.className = "main__editor";
378
- main.appendChild(editorContainer);
379
-
380
- root.appendChild(sidebar);
381
- root.appendChild(main);
382
-
383
- // \u2500\u2500 File list rendering \u2500\u2500
384
- function renderFileList(): void {
385
- sidebarList.innerHTML = "";
386
- let lastFolder = "";
387
-
388
- for (const file of files) {
389
- if (file.folder && file.folder !== lastFolder) {
390
- lastFolder = file.folder;
391
- const folder = document.createElement("div");
392
- folder.className = "sidebar__folder";
393
- folder.textContent = file.folder;
394
- sidebarList.appendChild(folder);
330
+ const bgSrc = join(templatesDir, "welcome-bg.png");
331
+ if (existsSync(bgSrc)) {
332
+ const bgDest = join(cwd, "welcome-bg.png");
333
+ if (!existsSync(bgDest)) {
334
+ try {
335
+ copyFileSync(bgSrc, bgDest);
336
+ } catch {
337
+ }
395
338
  }
396
-
397
- const item = document.createElement("div");
398
- item.className = "sidebar__item" + (file.path === currentFile ? " sidebar__item--active" : "");
399
- item.textContent = file.name;
400
- item.title = file.path;
401
- item.addEventListener("click", () => loadFile(file.path));
402
- sidebarList.appendChild(item);
403
339
  }
340
+ return "welcome.atelier";
404
341
  }
405
-
406
- // \u2500\u2500 Load a file into the studio \u2500\u2500
407
- async function loadFile(path: string): Promise<void> {
408
- currentFile = path;
409
- renderFileList();
410
-
411
- const content = await fetchFileContent(path);
412
- const result = parseAtelier(content);
413
-
414
- if (!result.success) {
415
- editorContainer.innerHTML = "";
416
- const err = document.createElement("div");
417
- err.className = "main__empty";
418
- err.style.flexDirection = "column";
419
- err.style.gap = "8px";
420
- err.innerHTML = '<div style="color:#C75B39">Parse Error</div><div style="font-size:13px;font-family:monospace">' +
421
- result.errors.map(e => e.path + ": " + e.message).join("<br>") + "</div>";
422
- editorContainer.appendChild(err);
423
- return;
424
- }
425
-
426
- statusBar.innerHTML = '<span>' + path + '</span><span class="save-indicator save-indicator--saved">&#10003; saved</span>';
427
-
428
- if (studio) {
429
- studio.destroy();
430
- studio = null;
431
- }
432
-
433
- // Set filename for export downloads (strip path and .atelier extension)
434
- const baseName = path.split("/").pop()?.replace(/\\.atelier$/, "") || null;
435
-
436
- studio = new AtelierStudio(editorContainer, {
437
- mode: "full",
438
- initialTab: "yaml",
439
- allowSave: true,
440
- onDocumentChange: (doc) => {
441
- // Auto-save with debounce
442
- const indicator = statusBar.querySelector(".save-indicator");
443
- if (indicator) {
444
- indicator.className = "save-indicator save-indicator--saving";
445
- indicator.innerHTML = "&#9679; saving...";
446
- }
447
-
448
- if (saveTimeout) clearTimeout(saveTimeout);
449
- saveTimeout = setTimeout(async () => {
450
- if (!currentFile) return;
451
- const yaml = serializeAtelier(doc);
452
- await saveFileContent(currentFile, yaml);
453
- const ind = statusBar.querySelector(".save-indicator");
454
- if (ind) {
455
- ind.className = "save-indicator save-indicator--saved";
456
- ind.innerHTML = "&#10003; saved";
457
- }
458
- }, 800);
459
- },
460
- });
461
- studio.setTheme(theme);
462
- studio.setFilename(baseName);
463
- studio.loadDocument(result.data);
464
- }
465
-
466
- // \u2500\u2500 Boot \u2500\u2500
467
- async function boot(): Promise<void> {
468
- files = await fetchFiles();
469
-
470
- if (files.length === 0) {
471
- editorContainer.innerHTML = "";
472
- const empty = document.createElement("div");
473
- empty.className = "main__empty";
474
- empty.textContent = "No .atelier files found in this directory";
475
- editorContainer.appendChild(empty);
476
- statusBar.textContent = "No files";
477
- renderFileList();
478
- return;
479
- }
480
-
481
- renderFileList();
482
-
483
- const initialFile = ${initialFileStr};
484
- const target = initialFile
485
- ? files.find(f => f.path === initialFile || f.path.endsWith(initialFile))
486
- : files[0];
487
-
488
- if (target) {
489
- await loadFile(target.path);
342
+ function readCliVersion(cliPackageDir) {
343
+ try {
344
+ const pkg = JSON.parse(readFileSync2(join(cliPackageDir, "package.json"), "utf-8"));
345
+ return typeof pkg.version === "string" ? pkg.version : "unknown";
346
+ } catch {
347
+ return "unknown";
490
348
  }
491
349
  }
492
-
493
- boot();
494
- `;
495
- }
496
350
  function studioCommand(program2) {
497
351
  program2.command("studio [file]").description("Launch the browser-based Atelier editor").option("-p, --port <number>", "Port to serve on", "4321").option("--no-open", "Don't auto-open browser").action(
498
352
  async (file, options) => {
@@ -502,13 +356,20 @@ function studioCommand(program2) {
502
356
  process.exit(1);
503
357
  }
504
358
  const cwd = process.cwd();
505
- const cliPackageDir = resolve(dirname(new URL(import.meta.url).pathname), "..");
359
+ const cliPackageDir = resolveCliPackageDir();
360
+ const version = readCliVersion(cliPackageDir);
361
+ const scaffolded = scaffoldWelcomeIfEmpty(cwd, cliPackageDir);
362
+ console.log("");
363
+ console.log(` Atelier Studio \xB7 v${version}`);
364
+ if (scaffolded) {
365
+ console.log(` Scaffolded ${scaffolded} \u2014 opening\u2026`);
366
+ }
506
367
  const tmpId = randomBytes(4).toString("hex");
507
368
  const tmpDirRaw = join(tmpdir(), `atelier-studio-${tmpId}`);
508
369
  mkdirSync(tmpDirRaw, { recursive: true });
509
370
  const tmpDir = realpathSync(tmpDirRaw);
510
371
  writeFileSync(join(tmpDir, "index.html"), getInlineHTML());
511
- writeFileSync(join(tmpDir, "main.ts"), getInlineApp(file ?? null));
372
+ writeFileSync(join(tmpDir, "main.ts"), getInlineApp(file ?? null, cliPackageDir));
512
373
  const cliNodeModules = join(cliPackageDir, "node_modules");
513
374
  if (existsSync(cliNodeModules)) {
514
375
  try {
@@ -516,7 +377,6 @@ function studioCommand(program2) {
516
377
  } catch {
517
378
  }
518
379
  }
519
- console.log(`Starting Atelier Studio...`);
520
380
  console.log(` Working directory: ${cwd}`);
521
381
  let vite;
522
382
  try {
@@ -527,9 +387,42 @@ function studioCommand(program2) {
527
387
  process.exit(1);
528
388
  return;
529
389
  }
390
+ const HOSTNAME = "127.0.0.1";
391
+ const bridgeState = {
392
+ store: new DocumentStore(),
393
+ currentDocId: null
394
+ };
395
+ const bridgeClients = /* @__PURE__ */ new Set();
396
+ const loadDocFromDisk = (docId) => {
397
+ if (!isSafePath(docId)) return null;
398
+ try {
399
+ return readFileSync2(resolve2(cwd, docId), "utf-8");
400
+ } catch {
401
+ return null;
402
+ }
403
+ };
404
+ const persistHumanPatch = (docId, doc) => {
405
+ if (!isSafePath(docId)) return;
406
+ writeFileEnsuringDir(resolve2(cwd, docId), serializeAtelier(doc));
407
+ };
408
+ bridgeState.store.onChange((id, doc, source) => {
409
+ if (!shouldBroadcastMutation(source)) return;
410
+ if (doc === null) return;
411
+ try {
412
+ persistHumanPatch(id, doc);
413
+ } catch {
414
+ }
415
+ broadcastToBridge(bridgeClients, {
416
+ type: "llm:mutation",
417
+ documentId: id,
418
+ doc,
419
+ source
420
+ });
421
+ });
530
422
  const server = await vite.createServer({
531
423
  root: tmpDir,
532
424
  server: {
425
+ host: HOSTNAME,
533
426
  port,
534
427
  strictPort: false,
535
428
  fs: {
@@ -540,8 +433,21 @@ function studioCommand(program2) {
540
433
  {
541
434
  name: "atelier-api",
542
435
  configureServer(server2) {
436
+ const allowedOrigins = /* @__PURE__ */ new Set([
437
+ `http://localhost:${port}`,
438
+ `http://127.0.0.1:${port}`
439
+ ]);
440
+ const MUTATING = /* @__PURE__ */ new Set(["POST", "PUT", "DELETE", "PATCH"]);
543
441
  server2.middlewares.use((req, res, next) => {
544
- const url2 = new URL(req.url ?? "/", `http://localhost:${port}`);
442
+ const url2 = new URL(req.url ?? "/", `http://${HOSTNAME}:${port}`);
443
+ if (req.method && MUTATING.has(req.method) && url2.pathname.startsWith("/api/")) {
444
+ const origin = req.headers.origin;
445
+ if (!origin || !allowedOrigins.has(origin)) {
446
+ res.statusCode = 403;
447
+ res.end("Forbidden: cross-origin mutating request rejected");
448
+ return;
449
+ }
450
+ }
545
451
  if (url2.pathname === "/api/files") {
546
452
  const atelierFiles2 = findAtelierFiles(cwd);
547
453
  const entries = atelierFiles2.map((p) => {
@@ -563,10 +469,15 @@ function studioCommand(program2) {
563
469
  res.end("Invalid path");
564
470
  return;
565
471
  }
566
- const absPath = resolve(cwd, filePath);
472
+ const absPath = resolve2(cwd, filePath);
567
473
  if (req.method === "GET") {
568
474
  try {
569
- const content = readFileSync(absPath, "utf-8");
475
+ const content = readFileSync2(absPath, "utf-8");
476
+ const parsed = parseAtelier(content);
477
+ if (parsed.success) {
478
+ bridgeState.store.set(filePath, parsed.data, "system");
479
+ bridgeState.currentDocId = filePath;
480
+ }
570
481
  res.setHeader("Content-Type", "text/plain");
571
482
  res.end(content);
572
483
  } catch {
@@ -582,11 +493,17 @@ function studioCommand(program2) {
582
493
  });
583
494
  req.on("end", () => {
584
495
  try {
585
- writeFileSync(absPath, body, "utf-8");
496
+ writeFileEnsuringDir(absPath, body);
497
+ const parsed = parseAtelier(body);
498
+ if (parsed.success) {
499
+ bridgeState.store.set(filePath, parsed.data, "human");
500
+ bridgeState.currentDocId = filePath;
501
+ }
586
502
  res.end("OK");
587
- } catch {
503
+ } catch (e) {
504
+ const msg = e instanceof Error ? e.message : String(e);
588
505
  res.statusCode = 500;
589
- res.end("Write failed");
506
+ res.end(msg);
590
507
  }
591
508
  });
592
509
  return;
@@ -599,7 +516,7 @@ function studioCommand(program2) {
599
516
  res.end("Invalid path");
600
517
  return;
601
518
  }
602
- const absPath = resolve(cwd, filePath);
519
+ const absPath = resolve2(cwd, filePath);
603
520
  const chunks = [];
604
521
  req.on("data", (chunk) => {
605
522
  chunks.push(chunk);
@@ -609,9 +526,10 @@ function studioCommand(program2) {
609
526
  mkdirSync(dirname(absPath), { recursive: true });
610
527
  writeFileSync(absPath, Buffer.concat(chunks));
611
528
  res.end("OK");
612
- } catch {
529
+ } catch (e) {
530
+ const msg = e instanceof Error ? e.message : String(e);
613
531
  res.statusCode = 500;
614
- res.end("Write failed");
532
+ res.end(msg);
615
533
  }
616
534
  });
617
535
  return;
@@ -629,6 +547,51 @@ function studioCommand(program2) {
629
547
  logLevel: "warn"
630
548
  });
631
549
  await server.listen();
550
+ const httpServer = server.httpServer;
551
+ if (httpServer) {
552
+ const wssBridge = new WebSocketServer({ noServer: true });
553
+ const wssMcp = new WebSocketServer({ noServer: true });
554
+ httpServer.on("upgrade", (req, socket, head) => {
555
+ const url2 = new URL(req.url ?? "/", `http://${HOSTNAME}:${port}`);
556
+ if (url2.pathname !== "/bridge" && url2.pathname !== "/mcp") return;
557
+ const origin = req.headers.origin;
558
+ const originOk = url2.pathname === "/mcp" ? isAllowedMcpOrigin(origin, port) : isAllowedOrigin(origin, port);
559
+ if (!originOk) {
560
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
561
+ socket.destroy();
562
+ return;
563
+ }
564
+ if (url2.pathname === "/bridge") {
565
+ wssBridge.handleUpgrade(req, socket, head, (ws) => {
566
+ wssBridge.emit("connection", ws, req);
567
+ });
568
+ return;
569
+ }
570
+ wssMcp.handleUpgrade(req, socket, head, (ws) => {
571
+ wssMcp.emit("connection", ws, req);
572
+ });
573
+ });
574
+ wssBridge.on("connection", (ws) => {
575
+ attachBridgeClient(
576
+ ws,
577
+ bridgeState,
578
+ bridgeClients,
579
+ loadDocFromDisk,
580
+ persistHumanPatch
581
+ );
582
+ });
583
+ wssMcp.on("connection", (ws) => {
584
+ const { server: mcpServer } = createMcpServer(bridgeState.store);
585
+ const transport = new WebSocketServerTransport(ws);
586
+ mcpServer.connect(transport).catch((err) => {
587
+ console.error("MCP-over-WS connect failed:", err);
588
+ try {
589
+ ws.close();
590
+ } catch {
591
+ }
592
+ });
593
+ });
594
+ }
632
595
  const resolvedUrl = server.resolvedUrls?.local[0] ?? `http://localhost:${port}`;
633
596
  const url = resolvedUrl;
634
597
  console.log(` Server running at: ${url}`);
@@ -661,11 +624,20 @@ function studioCommand(program2) {
661
624
  var program = new Command();
662
625
  program.name("atelier").description("Atelier animation CLI").version(createRequire(import.meta.url)("../package.json").version);
663
626
  validateCommand(program);
627
+ lintCommand(program);
628
+ trimCommand(program);
629
+ transcribeCommand(program);
630
+ transcriptCommand(program);
631
+ captionsCommand(program);
632
+ recipeCommand(program);
633
+ applyRecipeCommand(program);
634
+ carouselCommand(program);
664
635
  infoCommand(program);
665
636
  stillCommand(program);
666
637
  renderCommand(program);
667
638
  exportSvgCommand(program);
668
639
  exportLottieCommand(program);
640
+ exportImageCommand(program);
669
641
  assetsCommand(program);
670
642
  variablesCommand(program);
671
643
  studioCommand(program);