@osmo.inc/cli-linux-arm64 0.1.19 → 0.1.20

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 (152) hide show
  1. package/dist/studio.js +920 -329
  2. package/package.json +1 -1
  3. package/release.json +32 -32
  4. package/runtime/apps/native/tools/compile.mjs +10 -2
  5. package/runtime/packages/scene/src/assets.ts +27 -0
  6. package/runtime/packages/scene/src/elements.ts +29 -13
  7. package/runtime/packages/scene/src/evaluate.ts +58 -21
  8. package/runtime/packages/scene/src/index.ts +4 -0
  9. package/runtime/packages/scene/src/jsx-runtime.ts +9 -3
  10. package/runtime/packages/scene/src/knob-schema.ts +5 -2
  11. package/runtime/packages/scene/src/knobs.ts +50 -10
  12. package/runtime/packages/scene/src/motion-sampling.d.mts +1 -0
  13. package/runtime/packages/scene/src/motion-sampling.mjs +33 -0
  14. package/runtime/packages/scene/src/motion.ts +5 -5
  15. package/runtime/packages/scene/src/text-animation.ts +239 -0
  16. package/studio/src/app.css +1 -1
  17. package/studio/src/app.js +207 -198
  18. package/studio/src/engine_bg.wasm +0 -0
  19. package/third-party/inventory.json +1061 -78
  20. package/third-party/npm/@phosphor-icons__react@2.1.10/LICENSE +21 -0
  21. package/third-party/npm/@phosphor-icons__react@2.1.10/package.json +113 -0
  22. package/third-party/npm/@radix-ui__number@1.1.1/package.json +62 -0
  23. package/third-party/npm/@radix-ui__number@1.1.1/radix-ui-primitives-ee4e3197bffe-LICENSE +21 -0
  24. package/third-party/npm/@radix-ui__primitive@1.1.3/LICENSE +21 -0
  25. package/third-party/npm/@radix-ui__primitive@1.1.3/package.json +47 -0
  26. package/third-party/npm/@radix-ui__react-accessible-icon@1.1.7/LICENSE +21 -0
  27. package/third-party/npm/@radix-ui__react-accessible-icon@1.1.7/package.json +68 -0
  28. package/third-party/npm/@radix-ui__react-accordion@1.2.12/LICENSE +21 -0
  29. package/third-party/npm/@radix-ui__react-accordion@1.2.12/package.json +76 -0
  30. package/third-party/npm/@radix-ui__react-alert-dialog@1.1.15/LICENSE +21 -0
  31. package/third-party/npm/@radix-ui__react-alert-dialog@1.1.15/package.json +73 -0
  32. package/third-party/npm/@radix-ui__react-arrow@1.1.7/LICENSE +21 -0
  33. package/third-party/npm/@radix-ui__react-arrow@1.1.7/package.json +68 -0
  34. package/third-party/npm/@radix-ui__react-aspect-ratio@1.1.7/LICENSE +21 -0
  35. package/third-party/npm/@radix-ui__react-aspect-ratio@1.1.7/package.json +68 -0
  36. package/third-party/npm/@radix-ui__react-avatar@1.1.10/LICENSE +21 -0
  37. package/third-party/npm/@radix-ui__react-avatar@1.1.10/package.json +72 -0
  38. package/third-party/npm/@radix-ui__react-checkbox@1.3.3/LICENSE +21 -0
  39. package/third-party/npm/@radix-ui__react-checkbox@1.3.3/package.json +75 -0
  40. package/third-party/npm/@radix-ui__react-collapsible@1.1.12/LICENSE +21 -0
  41. package/third-party/npm/@radix-ui__react-collapsible@1.1.12/package.json +75 -0
  42. package/third-party/npm/@radix-ui__react-collection@1.1.7/LICENSE +21 -0
  43. package/third-party/npm/@radix-ui__react-collection@1.1.7/package.json +71 -0
  44. package/third-party/npm/@radix-ui__react-compose-refs@1.1.2/package.json +75 -0
  45. package/third-party/npm/@radix-ui__react-compose-refs@1.1.2/radix-ui-primitives-ee4e3197bffe-LICENSE +21 -0
  46. package/third-party/npm/@radix-ui__react-context-menu@2.2.16/LICENSE +21 -0
  47. package/third-party/npm/@radix-ui__react-context-menu@2.2.16/package.json +74 -0
  48. package/third-party/npm/@radix-ui__react-context@1.1.2/package.json +75 -0
  49. package/third-party/npm/@radix-ui__react-context@1.1.2/radix-ui-primitives-ee4e3197bffe-LICENSE +21 -0
  50. package/third-party/npm/@radix-ui__react-dialog@1.1.15/LICENSE +21 -0
  51. package/third-party/npm/@radix-ui__react-dialog@1.1.15/package.json +81 -0
  52. package/third-party/npm/@radix-ui__react-direction@1.1.1/package.json +75 -0
  53. package/third-party/npm/@radix-ui__react-direction@1.1.1/radix-ui-primitives-ee4e3197bffe-LICENSE +21 -0
  54. package/third-party/npm/@radix-ui__react-dismissable-layer@1.1.11/LICENSE +21 -0
  55. package/third-party/npm/@radix-ui__react-dismissable-layer@1.1.11/package.json +73 -0
  56. package/third-party/npm/@radix-ui__react-dropdown-menu@2.1.16/LICENSE +21 -0
  57. package/third-party/npm/@radix-ui__react-dropdown-menu@2.1.16/package.json +75 -0
  58. package/third-party/npm/@radix-ui__react-focus-guards@1.1.3/LICENSE +21 -0
  59. package/third-party/npm/@radix-ui__react-focus-guards@1.1.3/package.json +60 -0
  60. package/third-party/npm/@radix-ui__react-focus-scope@1.1.7/LICENSE +21 -0
  61. package/third-party/npm/@radix-ui__react-focus-scope@1.1.7/package.json +70 -0
  62. package/third-party/npm/@radix-ui__react-form@0.1.8/LICENSE +21 -0
  63. package/third-party/npm/@radix-ui__react-form@0.1.8/package.json +73 -0
  64. package/third-party/npm/@radix-ui__react-hover-card@1.1.15/LICENSE +21 -0
  65. package/third-party/npm/@radix-ui__react-hover-card@1.1.15/package.json +76 -0
  66. package/third-party/npm/@radix-ui__react-id@1.1.1/package.json +78 -0
  67. package/third-party/npm/@radix-ui__react-id@1.1.1/radix-ui-primitives-ee4e3197bffe-LICENSE +21 -0
  68. package/third-party/npm/@radix-ui__react-label@2.1.7/LICENSE +21 -0
  69. package/third-party/npm/@radix-ui__react-label@2.1.7/package.json +68 -0
  70. package/third-party/npm/@radix-ui__react-menu@2.1.16/LICENSE +21 -0
  71. package/third-party/npm/@radix-ui__react-menu@2.1.16/package.json +86 -0
  72. package/third-party/npm/@radix-ui__react-menubar@1.1.16/LICENSE +21 -0
  73. package/third-party/npm/@radix-ui__react-menubar@1.1.16/package.json +78 -0
  74. package/third-party/npm/@radix-ui__react-navigation-menu@1.2.14/LICENSE +21 -0
  75. package/third-party/npm/@radix-ui__react-navigation-menu@1.2.14/package.json +81 -0
  76. package/third-party/npm/@radix-ui__react-one-time-password-field@0.1.8/LICENSE +21 -0
  77. package/third-party/npm/@radix-ui__react-one-time-password-field@0.1.8/package.json +80 -0
  78. package/third-party/npm/@radix-ui__react-password-toggle-field@0.1.3/LICENSE +21 -0
  79. package/third-party/npm/@radix-ui__react-password-toggle-field@0.1.3/package.json +76 -0
  80. package/third-party/npm/@radix-ui__react-popover@1.1.15/LICENSE +21 -0
  81. package/third-party/npm/@radix-ui__react-popover@1.1.15/package.json +82 -0
  82. package/third-party/npm/@radix-ui__react-popper@1.2.8/LICENSE +21 -0
  83. package/third-party/npm/@radix-ui__react-popper@1.2.8/package.json +77 -0
  84. package/third-party/npm/@radix-ui__react-portal@1.1.9/LICENSE +21 -0
  85. package/third-party/npm/@radix-ui__react-portal@1.1.9/package.json +69 -0
  86. package/third-party/npm/@radix-ui__react-presence@1.1.5/LICENSE +21 -0
  87. package/third-party/npm/@radix-ui__react-presence@1.1.5/package.json +69 -0
  88. package/third-party/npm/@radix-ui__react-primitive@2.1.3/LICENSE +21 -0
  89. package/third-party/npm/@radix-ui__react-primitive@2.1.3/package.json +68 -0
  90. package/third-party/npm/@radix-ui__react-progress@1.1.7/LICENSE +21 -0
  91. package/third-party/npm/@radix-ui__react-progress@1.1.7/package.json +69 -0
  92. package/third-party/npm/@radix-ui__react-radio-group@1.3.8/LICENSE +21 -0
  93. package/third-party/npm/@radix-ui__react-radio-group@1.3.8/package.json +77 -0
  94. package/third-party/npm/@radix-ui__react-roving-focus@1.1.11/LICENSE +21 -0
  95. package/third-party/npm/@radix-ui__react-roving-focus@1.1.11/package.json +76 -0
  96. package/third-party/npm/@radix-ui__react-scroll-area@1.2.10/LICENSE +21 -0
  97. package/third-party/npm/@radix-ui__react-scroll-area@1.2.10/package.json +76 -0
  98. package/third-party/npm/@radix-ui__react-select@2.2.6/LICENSE +21 -0
  99. package/third-party/npm/@radix-ui__react-select@2.2.6/package.json +89 -0
  100. package/third-party/npm/@radix-ui__react-separator@1.1.7/LICENSE +21 -0
  101. package/third-party/npm/@radix-ui__react-separator@1.1.7/package.json +68 -0
  102. package/third-party/npm/@radix-ui__react-slider@1.3.6/LICENSE +21 -0
  103. package/third-party/npm/@radix-ui__react-slider@1.3.6/package.json +78 -0
  104. package/third-party/npm/@radix-ui__react-slot@1.2.3/LICENSE +21 -0
  105. package/third-party/npm/@radix-ui__react-slot@1.2.3/package.json +63 -0
  106. package/third-party/npm/@radix-ui__react-switch@1.2.6/LICENSE +21 -0
  107. package/third-party/npm/@radix-ui__react-switch@1.2.6/package.json +74 -0
  108. package/third-party/npm/@radix-ui__react-tabs@1.1.13/LICENSE +21 -0
  109. package/third-party/npm/@radix-ui__react-tabs@1.1.13/package.json +75 -0
  110. package/third-party/npm/@radix-ui__react-toast@1.2.15/LICENSE +21 -0
  111. package/third-party/npm/@radix-ui__react-toast@1.2.15/package.json +79 -0
  112. package/third-party/npm/@radix-ui__react-toggle-group@1.1.11/LICENSE +21 -0
  113. package/third-party/npm/@radix-ui__react-toggle-group@1.1.11/package.json +74 -0
  114. package/third-party/npm/@radix-ui__react-toggle@1.1.10/LICENSE +21 -0
  115. package/third-party/npm/@radix-ui__react-toggle@1.1.10/package.json +70 -0
  116. package/third-party/npm/@radix-ui__react-toolbar@1.1.11/LICENSE +21 -0
  117. package/third-party/npm/@radix-ui__react-toolbar@1.1.11/package.json +74 -0
  118. package/third-party/npm/@radix-ui__react-tooltip@1.2.8/LICENSE +21 -0
  119. package/third-party/npm/@radix-ui__react-tooltip@1.2.8/package.json +79 -0
  120. package/third-party/npm/@radix-ui__react-use-callback-ref@1.1.1/package.json +75 -0
  121. package/third-party/npm/@radix-ui__react-use-callback-ref@1.1.1/radix-ui-primitives-ee4e3197bffe-LICENSE +21 -0
  122. package/third-party/npm/@radix-ui__react-use-controllable-state@1.2.2/LICENSE +21 -0
  123. package/third-party/npm/@radix-ui__react-use-controllable-state@1.2.2/package.json +64 -0
  124. package/third-party/npm/@radix-ui__react-use-effect-event@0.0.2/LICENSE +21 -0
  125. package/third-party/npm/@radix-ui__react-use-effect-event@0.0.2/package.json +65 -0
  126. package/third-party/npm/@radix-ui__react-use-escape-keydown@1.1.1/package.json +78 -0
  127. package/third-party/npm/@radix-ui__react-use-escape-keydown@1.1.1/radix-ui-primitives-ee4e3197bffe-LICENSE +21 -0
  128. package/third-party/npm/@radix-ui__react-use-is-hydrated@0.1.0/LICENSE +21 -0
  129. package/third-party/npm/@radix-ui__react-use-is-hydrated@0.1.0/package.json +65 -0
  130. package/third-party/npm/@radix-ui__react-use-layout-effect@1.1.1/package.json +75 -0
  131. package/third-party/npm/@radix-ui__react-use-layout-effect@1.1.1/radix-ui-primitives-ee4e3197bffe-LICENSE +21 -0
  132. package/third-party/npm/@radix-ui__react-use-previous@1.1.1/package.json +75 -0
  133. package/third-party/npm/@radix-ui__react-use-previous@1.1.1/radix-ui-primitives-ee4e3197bffe-LICENSE +21 -0
  134. package/third-party/npm/@radix-ui__react-use-size@1.1.1/package.json +79 -0
  135. package/third-party/npm/@radix-ui__react-use-size@1.1.1/radix-ui-primitives-ee4e3197bffe-LICENSE +21 -0
  136. package/third-party/npm/@radix-ui__react-visually-hidden@1.2.3/LICENSE +21 -0
  137. package/third-party/npm/@radix-ui__react-visually-hidden@1.2.3/package.json +68 -0
  138. package/third-party/npm/color-convert@3.1.3/LICENSE +22 -0
  139. package/third-party/npm/color-convert@3.1.3/package.json +58 -0
  140. package/third-party/npm/color-name@2.1.0/LICENSE +8 -0
  141. package/third-party/npm/color-name@2.1.0/package.json +32 -0
  142. package/third-party/npm/color-string@2.1.4/LICENSE +21 -0
  143. package/third-party/npm/color-string@2.1.4/package.json +47 -0
  144. package/third-party/npm/color@5.0.3/LICENSE +21 -0
  145. package/third-party/npm/color@5.0.3/package.json +52 -0
  146. package/third-party/npm/radix-ui@1.4.3/LICENSE +21 -0
  147. package/third-party/npm/radix-ui@1.4.3/package.json +136 -0
  148. package/third-party/npm/use-sync-external-store@1.6.0/LICENSE +21 -0
  149. package/third-party/npm/use-sync-external-store@1.6.0/package.json +43 -0
  150. package/vendor/osmo-lite +0 -0
  151. package/vendor/osmo-scene-0.1.0.tgz +0 -0
  152. package/vendor/platform.json +1 -1
package/dist/studio.js CHANGED
@@ -215,10 +215,170 @@ var init_labels = __esm({
215
215
  }
216
216
  });
217
217
 
218
- // src/comments.ts
219
- import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile2, rename as rename2, rm as rm3 } from "node:fs/promises";
218
+ // src/local-attachment-picker.ts
219
+ import { execFile } from "node:child_process";
220
+ import { promisify } from "node:util";
221
+ import { realpath, stat } from "node:fs/promises";
220
222
  import { randomUUID as randomUUID2 } from "node:crypto";
221
223
  import path7 from "node:path";
224
+ async function registerLocalAttachment(filename2) {
225
+ const sourcePath = await realpath(filename2), info = await stat(sourcePath);
226
+ if (!info.isFile()) throw new Error("Choose a file, not a folder.");
227
+ const localRef = randomUUID2();
228
+ const file = {
229
+ name: path7.basename(sourcePath),
230
+ type: types[path7.extname(sourcePath).toLowerCase()] ?? "application/octet-stream",
231
+ size: info.size,
232
+ sourcePath
233
+ };
234
+ selections.set(localRef, file);
235
+ return { localRef, name: file.name, type: file.type, size: file.size };
236
+ }
237
+ function selectedLocalAttachment(token) {
238
+ const file = selections.get(token);
239
+ if (!file)
240
+ throw new Error("Select this file again; its local selection expired.");
241
+ return file;
242
+ }
243
+ async function pickLocalAttachments() {
244
+ if (picking) throw new Error("The file picker is already open.");
245
+ if (process.platform !== "darwin")
246
+ throw new Error("Local reference selection currently requires macOS.");
247
+ picking = true;
248
+ try {
249
+ const script = 'var app=Application.currentApplication(); app.includeStandardAdditions=true; try {var files=app.chooseFile({withPrompt:"Attach original files (no copies)",multipleSelectionsAllowed:true}); JSON.stringify(files.map(function(f){return f.toString()}));} catch(e) {if(e.errorNumber===-128) JSON.stringify([]); else throw e;}';
250
+ const { stdout } = await exec(
251
+ "/usr/bin/osascript",
252
+ ["-l", "JavaScript", "-e", script],
253
+ { maxBuffer: 8 * 1024 * 1024 }
254
+ );
255
+ const paths = JSON.parse(stdout);
256
+ return Promise.all(
257
+ paths.map((file) => registerLocalAttachment(file))
258
+ );
259
+ } finally {
260
+ picking = false;
261
+ }
262
+ }
263
+ var exec, selections, types, picking;
264
+ var init_local_attachment_picker = __esm({
265
+ "src/local-attachment-picker.ts"() {
266
+ "use strict";
267
+ exec = promisify(execFile);
268
+ selections = /* @__PURE__ */ new Map();
269
+ types = {
270
+ ".svg": "image/svg+xml",
271
+ ".png": "image/png",
272
+ ".jpg": "image/jpeg",
273
+ ".jpeg": "image/jpeg",
274
+ ".webp": "image/webp",
275
+ ".gif": "image/gif",
276
+ ".avif": "image/avif",
277
+ ".mp4": "video/mp4",
278
+ ".mov": "video/quicktime",
279
+ ".pdf": "application/pdf",
280
+ ".txt": "text/plain"
281
+ };
282
+ picking = false;
283
+ }
284
+ });
285
+
286
+ // src/comment-attachments.ts
287
+ import { mkdir as mkdir3, writeFile as writeFile2, rm as rm3, realpath as realpath2, readFile as readFile3 } from "node:fs/promises";
288
+ import path8 from "node:path";
289
+ import { randomUUID as randomUUID3 } from "node:crypto";
290
+ function validateUploads(value) {
291
+ if (value === void 0) return [];
292
+ if (!Array.isArray(value)) throw new Error("Invalid attachments.");
293
+ if (value.some((f) => f?.localRef !== void 0 && (typeof f.localRef !== "string" || !f.localRef)))
294
+ throw new Error("Invalid local attachment reference.");
295
+ if (value.filter((f) => typeof f?.localRef !== "string").length > 5)
296
+ throw new Error("Choose up to 5 cloud files.");
297
+ let total = 0;
298
+ return value.map((f) => {
299
+ if (f && typeof f.localRef === "string")
300
+ return { ...selectedLocalAttachment(f.localRef) };
301
+ if (!f || typeof f.name !== "string" || !f.name.trim() || f.name.length > 255 || typeof f.type !== "string" || f.type.length > 255 || typeof f.data !== "string" || f.data.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(f.data))
302
+ throw new Error("Invalid attachment.");
303
+ if (f.data.length > 4 * Math.ceil(15 * 1024 * 1024 / 3))
304
+ throw new Error("Each file must be 15 MB or smaller.");
305
+ const bytes = Buffer.from(f.data, "base64");
306
+ if (bytes.length > 15 * 1024 * 1024)
307
+ throw new Error("Each file must be 15 MB or smaller.");
308
+ total += bytes.length;
309
+ if (total > 50 * 1024 * 1024)
310
+ throw new Error("Files must total 50 MB or less.");
311
+ return { name: f.name, type: f.type, bytes };
312
+ });
313
+ }
314
+ async function storeAttachments(root, id, uploads, saved) {
315
+ const project = await realpath2(root);
316
+ for (const file of uploads) {
317
+ if (file.sourcePath) {
318
+ const { stat: stat4 } = await import("node:fs/promises");
319
+ const info = await stat4(file.sourcePath);
320
+ if (!info.isFile())
321
+ throw new Error("The attached file is no longer available.");
322
+ saved.push({
323
+ name: file.name,
324
+ path: file.sourcePath,
325
+ type: file.type,
326
+ size: info.size,
327
+ local: true
328
+ });
329
+ continue;
330
+ }
331
+ const bytes = file.bytes;
332
+ const folder = path8.join(root, ".osmo", "attachments", id, randomUUID3());
333
+ await mkdir3(folder, { recursive: true });
334
+ const resolved = await realpath2(folder);
335
+ if (!resolved.startsWith(project + path8.sep))
336
+ throw new Error("Attachment folder is outside the project.");
337
+ const name = file.name.replace(/[\\/\x00-\x1f\x7f]/g, "_").replace(/^\.+$/, "file");
338
+ const relative = path8.relative(root, path8.join(folder, name));
339
+ saved.push({
340
+ name: file.name,
341
+ path: relative,
342
+ type: file.type,
343
+ size: bytes.length
344
+ });
345
+ await writeFile2(path8.join(folder, name), bytes, { flag: "wx" });
346
+ }
347
+ }
348
+ async function cleanupAttachments(root, files) {
349
+ for (const file of files.filter((file2) => !file2.local))
350
+ await rm3(path8.dirname(path8.join(root, file.path)), {
351
+ recursive: true,
352
+ force: true
353
+ });
354
+ }
355
+ async function attachmentPath(root, file) {
356
+ if (file.local) {
357
+ if (!path8.isAbsolute(file.path))
358
+ throw new Error("Invalid local attachment reference.");
359
+ try {
360
+ return await realpath2(file.path);
361
+ } catch {
362
+ throw new Error("Original file was moved or removed. Attach it again.");
363
+ }
364
+ }
365
+ const base = await realpath2(path8.join(root, ".osmo", "attachments"));
366
+ const resolved = await realpath2(path8.resolve(root, file.path));
367
+ if (!resolved.startsWith(base + path8.sep))
368
+ throw new Error("Invalid attachment path.");
369
+ return resolved;
370
+ }
371
+ var init_comment_attachments = __esm({
372
+ "src/comment-attachments.ts"() {
373
+ "use strict";
374
+ init_local_attachment_picker();
375
+ }
376
+ });
377
+
378
+ // src/comments.ts
379
+ import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile3, rename as rename2, rm as rm4 } from "node:fs/promises";
380
+ import { randomUUID as randomUUID4 } from "node:crypto";
381
+ import path9 from "node:path";
222
382
  function validBounds(value) {
223
383
  return !!value && ["x", "y", "width", "height"].every((key) => Number.isFinite(value[key])) && value.width >= 0 && value.height >= 0;
224
384
  }
@@ -242,7 +402,7 @@ function validAgentResponse(value) {
242
402
  }
243
403
  async function readComments(root) {
244
404
  try {
245
- const data = JSON.parse(await readFile3(filename(root), "utf8"));
405
+ const data = JSON.parse(await readFile4(filename(root), "utf8"));
246
406
  if (data.version !== 1 || !Array.isArray(data.comments))
247
407
  throw new Error("Unsupported comments file");
248
408
  const ids = /* @__PURE__ */ new Set();
@@ -270,13 +430,13 @@ function commentText(value) {
270
430
  return value.trim();
271
431
  }
272
432
  async function mutateComments(root, change) {
273
- const folder = path7.join(root, ".osmo");
274
- await mkdir3(folder, { recursive: true });
275
- const lock = path7.join(folder, "comments.lock");
433
+ const folder = path9.join(root, ".osmo");
434
+ await mkdir4(folder, { recursive: true });
435
+ const lock = path9.join(folder, "comments.lock");
276
436
  const start = Date.now();
277
437
  while (true) {
278
438
  try {
279
- await mkdir3(lock);
439
+ await mkdir4(lock);
280
440
  break;
281
441
  } catch (e) {
282
442
  if (e.code !== "EEXIST") throw e;
@@ -287,22 +447,23 @@ async function mutateComments(root, change) {
287
447
  await new Promise((resolve) => setTimeout(resolve, 25));
288
448
  }
289
449
  }
290
- const temporary = path7.join(folder, `comments.${randomUUID2()}.tmp`);
450
+ const temporary = path9.join(folder, `comments.${randomUUID4()}.tmp`);
291
451
  try {
292
452
  const data = await readComments(root);
293
- const result = change(data.comments);
294
- await writeFile2(temporary, JSON.stringify(data, null, 2) + "\n", {
453
+ const result = await change(data.comments);
454
+ await writeFile3(temporary, JSON.stringify(data, null, 2) + "\n", {
295
455
  flag: "wx"
296
456
  });
297
457
  await rename2(temporary, filename(root));
298
458
  return result;
299
459
  } finally {
300
- await rm3(temporary, { force: true });
301
- await rm3(lock, { recursive: true, force: true });
460
+ await rm4(temporary, { force: true });
461
+ await rm4(lock, { recursive: true, force: true });
302
462
  }
303
463
  }
304
464
  async function addComment(root, input) {
305
- const text = commentText(input.text);
465
+ const uploads = validateUploads(input.attachments);
466
+ const text = uploads.length && typeof input.text === "string" && !input.text.trim() ? "" : commentText(input.text);
306
467
  if (typeof input.scene !== "string" || !input.scene || input.scene.startsWith("/") || input.scene.split(/[\\/]/).includes(".."))
307
468
  throw new Error("Invalid comment scene");
308
469
  if (!Number.isFinite(input.time) || input.time < 0 || input.endTime !== void 0 && (!Number.isFinite(input.endTime) || input.endTime < input.time))
@@ -313,42 +474,60 @@ async function addComment(root, input) {
313
474
  throw new Error("Invalid comment target");
314
475
  if (input.author !== void 0 && !validAuthor(input.author))
315
476
  throw new Error("Invalid comment author");
316
- return mutateComments(root, (comments) => {
317
- const comment = {
318
- id: `c_${randomUUID2().slice(0, 8)}`,
319
- scene: input.scene,
320
- time: input.time,
321
- ...input.endTime === void 0 ? {} : { endTime: input.endTime },
322
- ...input.target === void 0 ? {} : { target: input.target },
323
- ...input.author === void 0 ? {} : { author: input.author },
324
- text,
325
- status: "open",
326
- revision: input.revision,
327
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
328
- replies: []
329
- };
330
- comments.push(comment);
331
- return comment;
332
- });
477
+ const saved = [];
478
+ try {
479
+ return await mutateComments(root, async (comments) => {
480
+ const comment = {
481
+ id: `c_${randomUUID4().slice(0, 8)}`,
482
+ scene: input.scene,
483
+ time: input.time,
484
+ ...input.endTime === void 0 ? {} : { endTime: input.endTime },
485
+ ...input.target === void 0 ? {} : { target: input.target },
486
+ ...input.author === void 0 ? {} : { author: input.author },
487
+ text,
488
+ status: "open",
489
+ revision: input.revision,
490
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
491
+ replies: []
492
+ };
493
+ await storeAttachments(root, comment.id, uploads, saved);
494
+ if (saved.length) comment.attachments = saved;
495
+ comments.push(comment);
496
+ return comment;
497
+ });
498
+ } catch (error) {
499
+ await cleanupAttachments(root, saved);
500
+ throw error;
501
+ }
333
502
  }
334
- async function updateComment(root, id, action, text) {
503
+ async function updateComment(root, id, action, text, attachments) {
335
504
  if (!["reply", "resolve", "reopen"].includes(action))
336
505
  throw new Error("Unknown comment action");
337
- if (action === "reply") text = commentText(text);
338
- return mutateComments(root, (comments) => {
339
- const comment = comments.find((c) => c.id === id);
340
- if (!comment) throw new Error(`Comment not found: ${id}`);
341
- if (action === "reply")
342
- comment.replies.push({
343
- id: randomUUID2(),
344
- // commentText returned it above: this is a reply.
345
- text,
346
- createdAt: (/* @__PURE__ */ new Date()).toISOString()
347
- });
348
- else comment.status = action === "resolve" ? "resolved" : "open";
349
- comment.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
350
- return comment;
351
- });
506
+ const uploads = validateUploads(attachments);
507
+ if (action === "reply")
508
+ text = uploads.length && text === "" ? "" : commentText(text);
509
+ const saved = [];
510
+ try {
511
+ return await mutateComments(root, async (comments) => {
512
+ const comment = comments.find((c) => c.id === id);
513
+ if (!comment) throw new Error(`Comment not found: ${id}`);
514
+ if (action === "reply") {
515
+ await storeAttachments(root, id, uploads, saved);
516
+ comment.replies.push({
517
+ ...saved.length ? { attachments: saved } : {},
518
+ id: randomUUID4(),
519
+ // commentText returned it above: this is a reply.
520
+ text,
521
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
522
+ });
523
+ } else comment.status = action === "resolve" ? "resolved" : "open";
524
+ comment.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
525
+ return comment;
526
+ });
527
+ } catch (error) {
528
+ await cleanupAttachments(root, saved);
529
+ throw error;
530
+ }
352
531
  }
353
532
  async function updateCommentRange(root, id, input) {
354
533
  if (!Number.isFinite(input.time) || input.time < 0 || !Number.isFinite(input.endTime) || input.endTime <= input.time)
@@ -397,13 +576,131 @@ var filename, AGENT_REACTION_MIN_MS, graphemes, isoTime;
397
576
  var init_comments = __esm({
398
577
  "src/comments.ts"() {
399
578
  "use strict";
400
- filename = (root) => path7.join(root, ".osmo/comments.json");
579
+ init_comment_attachments();
580
+ filename = (root) => path9.join(root, ".osmo/comments.json");
401
581
  AGENT_REACTION_MIN_MS = 3e4;
402
582
  graphemes = new Intl.Segmenter("en", { granularity: "grapheme" });
403
583
  isoTime = (value) => typeof value === "string" && Number.isFinite(Date.parse(value));
404
584
  }
405
585
  });
406
586
 
587
+ // src/attachment-thumbnail.ts
588
+ import { execFile as execFile2 } from "node:child_process";
589
+ import { promisify as promisify2 } from "node:util";
590
+ import { stat as stat2 } from "node:fs/promises";
591
+ async function attachmentThumbnail(filename2) {
592
+ const info = await stat2(filename2), key = `${filename2}:${info.mtimeMs}:${info.size}`;
593
+ const existing = cache.get(key);
594
+ if (existing) return existing;
595
+ const result = exec2(
596
+ "ffmpeg",
597
+ [
598
+ "-hide_banner",
599
+ "-loglevel",
600
+ "error",
601
+ "-i",
602
+ filename2,
603
+ "-frames:v",
604
+ "1",
605
+ "-vf",
606
+ "scale=96:96:force_original_aspect_ratio=decrease",
607
+ "-f",
608
+ "image2pipe",
609
+ "-vcodec",
610
+ "mjpeg",
611
+ "pipe:1"
612
+ ],
613
+ { encoding: "buffer", maxBuffer: 1024 * 1024, timeout: 15e3 }
614
+ ).then(({ stdout }) => {
615
+ if (!stdout.length) throw new Error("No video preview");
616
+ return stdout;
617
+ }).catch((error) => {
618
+ cache.delete(key);
619
+ throw error;
620
+ });
621
+ if (cache.size >= 64) cache.delete(cache.keys().next().value);
622
+ cache.set(key, result);
623
+ return result;
624
+ }
625
+ var exec2, cache;
626
+ var init_attachment_thumbnail = __esm({
627
+ "src/attachment-thumbnail.ts"() {
628
+ "use strict";
629
+ exec2 = promisify2(execFile2);
630
+ cache = /* @__PURE__ */ new Map();
631
+ }
632
+ });
633
+
634
+ // ../scene/src/assets.ts
635
+ function validAssetPath(kind, value) {
636
+ return Object.hasOwn(assetExtensions, kind) && typeof value === "string" && value.length > 0 && value.length <= 4096 && value === value.trim() && !/^[a-z][a-z0-9+.-]*:|^[/\\]|[\x00-\x1f?#\\]/i.test(value) && assetExtensions[kind].includes(value.split(".").at(-1).toLowerCase());
637
+ }
638
+ var assetExtensions;
639
+ var init_assets = __esm({
640
+ "../scene/src/assets.ts"() {
641
+ "use strict";
642
+ assetExtensions = {
643
+ image: ["png", "jpg", "jpeg", "webp", "gif", "svg"],
644
+ video: ["mp4", "webm", "mov", "m4v"],
645
+ audio: ["wav", "mp3", "m4a", "aac", "ogg", "flac"],
646
+ font: ["ttf", "otf"],
647
+ model: ["glb"]
648
+ };
649
+ }
650
+ });
651
+
652
+ // src/asset-import.ts
653
+ import fs2 from "node:fs/promises";
654
+ import path13 from "node:path";
655
+ import { randomUUID as randomUUID5 } from "node:crypto";
656
+ async function importProjectAsset(root, stream, name, kind, limit = MAX_ASSET_BYTES) {
657
+ if (!Object.hasOwn(assetExtensions, kind))
658
+ throw new Error("Unknown asset kind");
659
+ if (!name || name.length > 255 || /[/\\\x00-\x1f]/.test(name))
660
+ throw new Error("Invalid asset filename");
661
+ const extension = path13.extname(name).slice(1).toLowerCase();
662
+ if (!assetExtensions[kind].includes(extension))
663
+ throw new Error(
664
+ `Expected a ${kind} file (${assetExtensions[kind].join(", ")})`
665
+ );
666
+ const folder = path13.join(root, "assets");
667
+ await fs2.mkdir(folder).catch((error) => {
668
+ if (error.code !== "EEXIST") throw error;
669
+ });
670
+ const directory = sceneInProject(root, "assets").source;
671
+ const stem = path13.basename(name, path13.extname(name)).normalize("NFKC").replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "asset";
672
+ const filename2 = `${stem}-${randomUUID5()}.${extension}`;
673
+ const target = path13.join(directory, filename2);
674
+ const temp = path13.join(directory, `.${filename2}.upload`);
675
+ const file = await fs2.open(temp, "wx", 384);
676
+ let size = 0;
677
+ try {
678
+ for await (const chunk of stream) {
679
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
680
+ size += bytes.length;
681
+ if (size > limit)
682
+ throw new Error(`Asset exceeds ${Math.round(limit / 1024 / 1024)} MB`);
683
+ await file.writeFile(bytes);
684
+ }
685
+ if (!size) throw new Error("Asset file is empty");
686
+ await file.close();
687
+ await fs2.link(temp, target);
688
+ return path13.relative(await fs2.realpath(root), target).split(path13.sep).join("/");
689
+ } finally {
690
+ await file.close();
691
+ await fs2.rm(temp, { force: true });
692
+ }
693
+ }
694
+ var MAX_ASSET_BYTES;
695
+ var init_asset_import = __esm({
696
+ "src/asset-import.ts"() {
697
+ "use strict";
698
+ init_assets();
699
+ init_runtime();
700
+ MAX_ASSET_BYTES = 256 * 1024 * 1024;
701
+ }
702
+ });
703
+
407
704
  // ../studio/src/lib/export-settings.ts
408
705
  function validateExportSettings(input, meta) {
409
706
  const format2 = input.format ?? "h264";
@@ -621,9 +918,9 @@ var init_storyboard = __esm({
621
918
  });
622
919
 
623
920
  // src/storyboard.ts
624
- import fs2 from "node:fs/promises";
625
- import path11 from "node:path";
626
- import { createHash as createHash4, randomUUID as randomUUID3 } from "node:crypto";
921
+ import fs3 from "node:fs/promises";
922
+ import path14 from "node:path";
923
+ import { createHash as createHash4, randomUUID as randomUUID6 } from "node:crypto";
627
924
  import ts4 from "typescript";
628
925
  function literal(n) {
629
926
  if (!n) return void 0;
@@ -640,7 +937,7 @@ function literal(n) {
640
937
  );
641
938
  }
642
939
  async function groupInfo(source, relative) {
643
- const text = await fs2.readFile(source, "utf8"), ast = ts4.createSourceFile(
940
+ const text = await fs3.readFile(source, "utf8"), ast = ts4.createSourceFile(
644
941
  source,
645
942
  text,
646
943
  ts4.ScriptTarget.Latest,
@@ -675,7 +972,7 @@ async function groupInfo(source, relative) {
675
972
  };
676
973
  }
677
974
  function createStoryboard(root) {
678
- const filename2 = path11.join(root, "storyboard.canvas.json"), cache = path11.join(root, ".osmo/cache/storyboard");
975
+ const filename2 = path14.join(root, "storyboard.canvas.json"), cache2 = path14.join(root, ".osmo/cache/storyboard");
679
976
  let queue = Promise.resolve(), undo = [], redo = [], knownRevision, activeRenderer, closed = false;
680
977
  const serial = (fn) => {
681
978
  const result = queue.then(fn);
@@ -686,7 +983,7 @@ function createStoryboard(root) {
686
983
  async function read() {
687
984
  let document;
688
985
  try {
689
- document = validateBoard(JSON.parse(await fs2.readFile(filename2, "utf8")));
986
+ document = validateBoard(JSON.parse(await fs3.readFile(filename2, "utf8")));
690
987
  } catch (error) {
691
988
  if (error.code !== "ENOENT") throw error;
692
989
  document = structuredClone(empty);
@@ -703,17 +1000,17 @@ function createStoryboard(root) {
703
1000
  const result = [];
704
1001
  async function scan(dir, depth = 0) {
705
1002
  if (depth > 8 || result.length >= 100) return;
706
- for (const e of await fs2.readdir(dir, { withFileTypes: true })) {
1003
+ for (const e of await fs3.readdir(dir, { withFileTypes: true })) {
707
1004
  if (e.name.startsWith(".") || ["node_modules", "exports", "dist", "target", "assets"].includes(
708
1005
  e.name
709
1006
  ))
710
1007
  continue;
711
- const absolute = path11.join(dir, e.name);
1008
+ const absolute = path14.join(dir, e.name);
712
1009
  if (e.isDirectory()) await scan(absolute, depth + 1);
713
1010
  else if (e.isFile() && e.name.endsWith(".tsx")) {
714
1011
  const info2 = await groupInfo(
715
1012
  absolute,
716
- path11.relative(root, absolute).split(path11.sep).join("/")
1013
+ path14.relative(root, absolute).split(path14.sep).join("/")
717
1014
  );
718
1015
  if (info2) result.push(info2);
719
1016
  }
@@ -745,12 +1042,12 @@ function createStoryboard(root) {
745
1042
  };
746
1043
  }
747
1044
  async function write(document) {
748
- const temp = `${filename2}.${randomUUID3()}.tmp`;
1045
+ const temp = `${filename2}.${randomUUID6()}.tmp`;
749
1046
  try {
750
- await fs2.writeFile(temp, JSON.stringify(document, null, 2) + "\n");
751
- await fs2.rename(temp, filename2);
1047
+ await fs3.writeFile(temp, JSON.stringify(document, null, 2) + "\n");
1048
+ await fs3.rename(temp, filename2);
752
1049
  } finally {
753
- await fs2.rm(temp, { force: true });
1050
+ await fs3.rm(temp, { force: true });
754
1051
  }
755
1052
  knownRevision = hash(JSON.stringify(document));
756
1053
  }
@@ -802,8 +1099,8 @@ function createStoryboard(root) {
802
1099
  await info(card);
803
1100
  const { source } = sceneInProject(root, card.source), validation = await check(source);
804
1101
  if (!validation.ok) throw new Error(validation.errors.join("\n"));
805
- await fs2.mkdir(cache, { recursive: true });
806
- const name = `${randomUUID3()}.mp4`, destination = path11.join(cache, name);
1102
+ await fs3.mkdir(cache2, { recursive: true });
1103
+ const name = `${randomUUID6()}.mp4`, destination = path14.join(cache2, name);
807
1104
  const renderer = new Renderer({
808
1105
  preview: {
809
1106
  width: card.width,
@@ -819,7 +1116,7 @@ function createStoryboard(root) {
819
1116
  await renderer.request("export", { destination, supersample: 1 });
820
1117
  return { name, revision: current.revision };
821
1118
  } catch (error) {
822
- await fs2.rm(destination, { force: true });
1119
+ await fs3.rm(destination, { force: true });
823
1120
  throw error;
824
1121
  } finally {
825
1122
  renderer.close();
@@ -829,7 +1126,7 @@ function createStoryboard(root) {
829
1126
  video: async (name) => {
830
1127
  if (!/^[0-9a-f-]{36}\.mp4$/.test(name))
831
1128
  throw new Error("Invalid preview");
832
- return fs2.readFile(path11.join(cache, name));
1129
+ return fs3.readFile(path14.join(cache2, name));
833
1130
  },
834
1131
  close() {
835
1132
  closed = true;
@@ -850,6 +1147,198 @@ var init_storyboard2 = __esm({
850
1147
  }
851
1148
  });
852
1149
 
1150
+ // src/asset-metadata.ts
1151
+ import { execFile as execFile3 } from "node:child_process";
1152
+ import { promisify as promisify3 } from "node:util";
1153
+ import path15 from "node:path";
1154
+ async function inspectAsset(file, size) {
1155
+ const metadata = { size };
1156
+ const extension = path15.extname(file).slice(1).toLowerCase();
1157
+ const image = ["png", "jpg", "jpeg", "webp", "gif"].includes(extension);
1158
+ const video = ["mp4", "webm", "mov", "m4v"].includes(extension);
1159
+ const audio = ["wav", "mp3", "m4a", "aac", "ogg", "flac"].includes(extension);
1160
+ if (!image && !video && !audio) return metadata;
1161
+ try {
1162
+ const { stdout } = await exec3(
1163
+ process.env.OSMO_FFPROBE ?? "ffprobe",
1164
+ [
1165
+ "-v",
1166
+ "error",
1167
+ "-protocol_whitelist",
1168
+ "file,pipe",
1169
+ "-show_entries",
1170
+ "stream=codec_type,width,height,avg_frame_rate,duration,sample_rate:stream_disposition=attached_pic:stream_tags=rotate:stream_side_data=rotation:format=duration",
1171
+ "-of",
1172
+ "json",
1173
+ file
1174
+ ],
1175
+ { timeout: 5e3, maxBuffer: 1024 * 1024 }
1176
+ );
1177
+ const data = JSON.parse(stdout);
1178
+ const stream = data.streams?.find(
1179
+ (s) => s.codec_type === (audio ? "audio" : "video") && !s.disposition?.attached_pic
1180
+ );
1181
+ const assign = (key, value) => {
1182
+ const number = Number(value);
1183
+ if (Number.isFinite(number) && number > 0) metadata[key] = number;
1184
+ };
1185
+ if (image || video) {
1186
+ const rotation = Number(
1187
+ stream?.side_data_list?.find(
1188
+ (s) => s.rotation !== void 0
1189
+ )?.rotation ?? stream?.tags?.rotate ?? 0
1190
+ );
1191
+ const portrait = Math.abs(rotation % 180) === 90;
1192
+ assign("width", portrait ? stream?.height : stream?.width);
1193
+ assign("height", portrait ? stream?.width : stream?.height);
1194
+ }
1195
+ if (video) {
1196
+ const [n, d = "1"] = String(stream?.avg_frame_rate).split("/");
1197
+ assign("fps", Number(n) / Number(d));
1198
+ }
1199
+ if (audio || video)
1200
+ assign("duration", stream?.duration ?? data.format?.duration);
1201
+ if (audio) assign("sampleRate", stream?.sample_rate);
1202
+ } catch {
1203
+ }
1204
+ return metadata;
1205
+ }
1206
+ var exec3;
1207
+ var init_asset_metadata = __esm({
1208
+ "src/asset-metadata.ts"() {
1209
+ "use strict";
1210
+ exec3 = promisify3(execFile3);
1211
+ }
1212
+ });
1213
+
1214
+ // ../editor/src/types.ts
1215
+ function nodeKnobs(node) {
1216
+ return node.type === "Composition" && isSceneNode(node.children[0]) ? node.children[0].props.knobs : node.props.knobs;
1217
+ }
1218
+ function nodeValues(node) {
1219
+ return node.type === "Composition" && isSceneNode(node.children[0]) ? { ...node.children[0].props.values, ...node.props.values } : node.props.values;
1220
+ }
1221
+ var isSceneNode;
1222
+ var init_types = __esm({
1223
+ "../editor/src/types.ts"() {
1224
+ "use strict";
1225
+ isSceneNode = (child) => !!child?.type;
1226
+ }
1227
+ });
1228
+
1229
+ // ../editor/src/document-assets.ts
1230
+ function documentAssetReferences(document) {
1231
+ const assets = [];
1232
+ function resolve(value, scope, seen = /* @__PURE__ */ new Set()) {
1233
+ if (!value?.$ref) return [{ value }];
1234
+ const key = `${value.$ref}:${value.name}`;
1235
+ if (seen.has(key)) throw new Error("Cyclic asset binding");
1236
+ const binding = document.bindings[value.$ref];
1237
+ if (binding?.kind === "knobs")
1238
+ return scope.get(value.$ref)?.get(value.name) ?? [];
1239
+ if (binding?.kind === "motion") {
1240
+ const track = binding.definition[value.name];
1241
+ return [
1242
+ track?.default,
1243
+ ...(track?.keyframes ?? []).map((k) => k.value)
1244
+ ].flatMap(
1245
+ (v) => resolve(v, scope, /* @__PURE__ */ new Set([...seen, key])).map((r) => ({
1246
+ ...r,
1247
+ animated: true
1248
+ }))
1249
+ );
1250
+ }
1251
+ return [];
1252
+ }
1253
+ function add(file, value, kind, scope) {
1254
+ for (const v of resolve(value, scope))
1255
+ if (typeof v.value === "string" && !(kind === "font" && v.value === "Inter")) {
1256
+ if (v.kind && v.kind !== kind)
1257
+ throw new Error(
1258
+ `Knob ${v.knob}: cannot use ${v.kind} asset as ${kind}`
1259
+ );
1260
+ assets.push({
1261
+ file: v.file ?? file,
1262
+ value: v.value,
1263
+ kind,
1264
+ ...v.knob ? { knob: v.knob } : {}
1265
+ });
1266
+ }
1267
+ }
1268
+ function walk(n, parent, override) {
1269
+ const scope = n.type === "Scene" ? /* @__PURE__ */ new Map() : new Map(parent), set = nodeKnobs(n)?.$ref;
1270
+ const schema = document.bindings[set];
1271
+ if (n.type === "Composition") {
1272
+ const child = n.children[0];
1273
+ if (isSceneNode(child))
1274
+ walk(child, parent, { values: n.props.values, file: n.source.file });
1275
+ return;
1276
+ }
1277
+ if (schema?.kind === "knobs") {
1278
+ const values = { ...n.props.values, ...override?.values };
1279
+ scope.set(
1280
+ set,
1281
+ new Map(
1282
+ Object.entries(schema.definition).map(([name, raw]) => {
1283
+ const knob = raw, supplied = values[name] !== void 0;
1284
+ const file = !supplied ? schema.source.file : override?.values?.[name] !== void 0 ? override.file : n.valueSources?.[name] ?? n.source.file;
1285
+ const resolved = resolve(
1286
+ supplied ? values[name] : knob.default,
1287
+ parent
1288
+ );
1289
+ if (knob.type === "asset")
1290
+ for (const v of resolved) {
1291
+ if (v.animated)
1292
+ throw new Error(
1293
+ `Knob ${name}: asset bindings must be static`
1294
+ );
1295
+ if (v.kind && v.kind !== knob.kind)
1296
+ throw new Error(
1297
+ `Knob ${name}: cannot bind ${v.kind} to ${knob.kind}`
1298
+ );
1299
+ if (!validAssetPath(knob.kind, v.value))
1300
+ throw new Error(
1301
+ `Knob ${name}: expected a local ${knob.kind} asset path`
1302
+ );
1303
+ }
1304
+ return [
1305
+ name,
1306
+ resolved.map(
1307
+ (v) => knob.type === "asset" ? {
1308
+ ...v,
1309
+ file: v.file ?? v.origin ?? file,
1310
+ knob: v.knob ?? name,
1311
+ kind: knob.kind
1312
+ } : { ...v, origin: v.origin ?? file }
1313
+ )
1314
+ ];
1315
+ })
1316
+ )
1317
+ );
1318
+ }
1319
+ if (n.props.src)
1320
+ add(n.sourceFile, n.props.src, n.type.toLowerCase(), scope);
1321
+ if (n.props.font) add(n.sourceFile, n.props.font, "font", scope);
1322
+ for (const c of n.children) if (isSceneNode(c)) walk(c, scope);
1323
+ }
1324
+ walk(
1325
+ document.root,
1326
+ /* @__PURE__ */ new Map(),
1327
+ document.root.type === "Group" && document.preview?.values ? { values: document.preview.values, file: document.entry } : void 0
1328
+ );
1329
+ for (const b of Object.values(document.bindings))
1330
+ if (b.kind === "effect" && b.definition.src)
1331
+ add(b.source.file, b.definition.src, "shader", /* @__PURE__ */ new Map());
1332
+ return assets;
1333
+ }
1334
+ var init_document_assets = __esm({
1335
+ "../editor/src/document-assets.ts"() {
1336
+ "use strict";
1337
+ init_assets();
1338
+ init_types();
1339
+ }
1340
+ });
1341
+
853
1342
  // ../editor/src/source-path.ts
854
1343
  function normalize(value) {
855
1344
  const parts = [];
@@ -869,15 +1358,6 @@ var init_source_path = __esm({
869
1358
  }
870
1359
  });
871
1360
 
872
- // ../editor/src/types.ts
873
- var isSceneNode;
874
- var init_types = __esm({
875
- "../editor/src/types.ts"() {
876
- "use strict";
877
- isSceneNode = (child) => !!child?.type;
878
- }
879
- });
880
-
881
1361
  // ../editor/src/scene-document.ts
882
1362
  import ts5 from "typescript";
883
1363
  function decodeJsxLiteral(raw, attribute = false) {
@@ -1053,7 +1533,8 @@ function createSceneDocument(entry, files) {
1053
1533
  "opacity",
1054
1534
  "effects",
1055
1535
  "hidden",
1056
- "solo"
1536
+ "solo",
1537
+ "values"
1057
1538
  ]);
1058
1539
  for (const name of Object.keys(props))
1059
1540
  if (!allowed.has(name))
@@ -1088,6 +1569,7 @@ function createSceneDocument(entry, files) {
1088
1569
  ...props.values ? { values: { ...base.props.values, ...props.values } } : {}
1089
1570
  },
1090
1571
  source: location(opening3),
1572
+ valueSources: { ...base.valueSources, ...Object.fromEntries(Object.keys(props.values ?? {}).map((k) => [k, file])) },
1091
1573
  definition: base.source.file,
1092
1574
  identity: props.id ?? null,
1093
1575
  tag
@@ -1221,7 +1703,7 @@ function outlineDocument(document, meta) {
1221
1703
  ...n.props,
1222
1704
  start: 0,
1223
1705
  ...document.preview?.values ? { values: { ...n.props.values, ...document.preview.values } } : {}
1224
- } : n.props, local = new Map(scope), set = p.knobs?.$ref, own = document.bindings[set];
1706
+ } : n.props, local = new Map(scope), set = nodeKnobs(n)?.$ref, own = document.bindings[set];
1225
1707
  if (own)
1226
1708
  local.set(
1227
1709
  set,
@@ -1231,7 +1713,7 @@ function outlineDocument(document, meta) {
1231
1713
  ).map(([k, s]) => [
1232
1714
  k,
1233
1715
  resolve(
1234
- p.values?.[k] ?? s.default,
1716
+ (n.type === "Composition" ? nodeValues(n) : p.values)?.[k] ?? s.default,
1235
1717
  scope,
1236
1718
  clock.offset + (isClipPrimitive2(n.type) && (p.start !== void 0 || p.duration !== void 0 || p.track !== void 0) ? p.start ?? 0 : 0),
1237
1719
  n.id
@@ -1386,12 +1868,13 @@ var init_document_outline = __esm({
1386
1868
  });
1387
1869
 
1388
1870
  // src/scene-document.ts
1389
- import fs3 from "node:fs/promises";
1390
- import path12 from "node:path";
1871
+ import fs4 from "node:fs/promises";
1872
+ import path16 from "node:path";
1391
1873
  import { createHash as createHash5 } from "node:crypto";
1392
1874
  import ts6 from "typescript";
1393
1875
  function createDocumentLoader(root) {
1394
1876
  const assets = /* @__PURE__ */ new Map();
1877
+ const metadata = /* @__PURE__ */ new Map();
1395
1878
  async function load(source, text, preview = {}, inputs = /* @__PURE__ */ new Map(), includeSources = false) {
1396
1879
  const entry = sceneInProject(root, source).scene, files = {};
1397
1880
  async function read(file, body2, stack = []) {
@@ -1399,7 +1882,7 @@ function createDocumentLoader(root) {
1399
1882
  throw new Error("Cyclic animation imports");
1400
1883
  if (Object.hasOwn(files, file)) return;
1401
1884
  const located = sceneInProject(root, file);
1402
- files[file] = body2 ?? inputs.get(located.source) ?? await fs3.readFile(located.source, "utf8");
1885
+ files[file] = body2 ?? inputs.get(located.source) ?? await fs4.readFile(located.source, "utf8");
1403
1886
  const ast = ts6.createSourceFile(
1404
1887
  file,
1405
1888
  files[file],
@@ -1423,49 +1906,24 @@ function createDocumentLoader(root) {
1423
1906
  const document = createSceneDocument(entry, files);
1424
1907
  document.preview = preview;
1425
1908
  document.assets = {};
1426
- async function asset(file, value, kind) {
1427
- if (value?.$ref) {
1428
- const binding = document.bindings[value.$ref]?.definition?.[value.name];
1429
- if (binding)
1430
- for (const v of [
1431
- binding.default,
1432
- ...(binding.keyframes ?? []).map((k) => k.value)
1433
- ])
1434
- await asset(file, v, kind);
1435
- return;
1436
- }
1437
- if (typeof value !== "string" || kind === "font" && value === "Inter")
1438
- return;
1439
- const relative = path12.posix.normalize(
1440
- path12.posix.join(path12.posix.dirname(file), value)
1441
- );
1442
- if (document.assets[relative]) return;
1443
- document.assets[relative] = await registerAsset(relative, kind);
1444
- }
1445
- async function walk(n) {
1446
- try {
1447
- if (n.props.src)
1448
- await asset(
1449
- n.sourceFile,
1450
- n.props.src,
1451
- n.type === "Shader" ? "shader" : n.type.toLowerCase()
1452
- );
1453
- if (n.props.font) await asset(n.sourceFile, n.props.font, "font");
1454
- } catch (error) {
1455
- document.nativeReason = `Asset unavailable for browser preview: ${error.message}`;
1909
+ try {
1910
+ for (const ref of documentAssetReferences(document)) {
1911
+ const relative = path16.posix.normalize(path16.posix.join(path16.posix.dirname(ref.file), ref.value));
1912
+ if (document.assets[relative]) continue;
1913
+ try {
1914
+ document.assets[relative] = await registerAsset(relative, ref.kind);
1915
+ } catch (error) {
1916
+ throw new Error(`${ref.knob ? `Knob ${ref.knob}: ` : ""}${relative}: ${error.message}`);
1917
+ }
1456
1918
  }
1457
- for (const c of n.children)
1458
- if (c?.type) await walk(c);
1919
+ } catch (error) {
1920
+ document.nativeReason = `Asset unavailable for browser preview: ${error.message}`;
1459
1921
  }
1460
- for (const binding of Object.values(document.bindings))
1461
- if (binding.kind === "effect" && binding.definition.src)
1462
- await asset(binding.source.file, binding.definition.src, "shader");
1463
- await walk(document.root);
1464
1922
  while (assets.size > 1e3) assets.delete(assets.keys().next().value);
1465
1923
  return { ...document, ...includeSources ? { sourceFiles: files } : {} };
1466
1924
  }
1467
1925
  async function registerAsset(relative, kind) {
1468
- const located = sceneInProject(root, relative), info = await fs3.stat(located.source);
1926
+ const located = sceneInProject(root, relative), info = await fs4.stat(located.source);
1469
1927
  if (!info.isFile()) throw new Error(`Asset is not a file: ${relative}`);
1470
1928
  const id = createHash5("sha256").update(located.source + "\0" + info.mtimeMs + "\0" + info.size).digest("hex");
1471
1929
  assets.set(id, {
@@ -1476,7 +1934,7 @@ function createDocumentLoader(root) {
1476
1934
  const asset = { id, kind };
1477
1935
  if (kind === "shader") {
1478
1936
  if (info.size > 1024 * 1024) throw new Error("Shader exceeds 1 MB");
1479
- asset.code = await fs3.readFile(located.source, "utf8");
1937
+ asset.code = await fs4.readFile(located.source, "utf8");
1480
1938
  }
1481
1939
  return asset;
1482
1940
  }
@@ -1521,26 +1979,40 @@ function createDocumentLoader(root) {
1521
1979
  }
1522
1980
  return { resources: resources2, errors };
1523
1981
  }
1524
- async function readAsset(id) {
1982
+ async function locateAsset(id) {
1525
1983
  const asset = assets.get(id);
1526
1984
  if (!asset)
1527
1985
  throw Object.assign(new Error("Unknown scene asset"), { status: 404 });
1528
- const current = sceneInProject(root, asset.file), info = await fs3.stat(current.source);
1986
+ const current = sceneInProject(root, asset.file), info = await fs4.stat(current.source);
1529
1987
  if (current.source !== asset.file || info.size !== asset.size || info.mtimeMs !== asset.mtime)
1530
1988
  throw Object.assign(
1531
1989
  new Error("Scene asset changed. Refresh the scene."),
1532
1990
  { status: 409 }
1533
1991
  );
1992
+ return { file: current.source, size: info.size };
1993
+ }
1994
+ async function readAsset(id) {
1995
+ const current = await locateAsset(id);
1534
1996
  return {
1535
- bytes: await fs3.readFile(current.source),
1536
- extension: path12.extname(current.source).toLowerCase()
1997
+ bytes: await fs4.readFile(current.file),
1998
+ extension: path16.extname(current.file).toLowerCase()
1537
1999
  };
1538
2000
  }
1539
- return { load, readAsset, resources };
2001
+ async function assetMetadata(id) {
2002
+ const current = await locateAsset(id);
2003
+ if (!metadata.has(id))
2004
+ metadata.set(id, inspectAsset(current.file, current.size));
2005
+ while (metadata.size > 1e3)
2006
+ metadata.delete(metadata.keys().next().value);
2007
+ return metadata.get(id);
2008
+ }
2009
+ return { load, readAsset, resources, registerAsset, assetMetadata };
1540
2010
  }
1541
2011
  var init_scene_document2 = __esm({
1542
2012
  "src/scene-document.ts"() {
1543
2013
  "use strict";
2014
+ init_asset_metadata();
2015
+ init_document_assets();
1544
2016
  init_runtime();
1545
2017
  init_scene_document();
1546
2018
  init_document_outline();
@@ -1548,30 +2020,30 @@ var init_scene_document2 = __esm({
1548
2020
  });
1549
2021
 
1550
2022
  // src/project-resources.ts
1551
- import fs4 from "node:fs/promises";
1552
- import path13 from "node:path";
1553
- import { execFile } from "node:child_process";
1554
- import { promisify } from "node:util";
2023
+ import fs5 from "node:fs/promises";
2024
+ import path17 from "node:path";
2025
+ import { execFile as execFile4 } from "node:child_process";
2026
+ import { promisify as promisify4 } from "node:util";
1555
2027
  import ts7 from "typescript";
1556
2028
  async function resourceFile(root, relative) {
1557
- if (typeof relative !== "string" || path13.isAbsolute(relative))
2029
+ if (typeof relative !== "string" || path17.isAbsolute(relative))
1558
2030
  throw new Error("Resource paths must be relative.");
1559
- const base = await fs4.realpath(root), file = await fs4.realpath(path13.resolve(base, relative));
1560
- if (!inside(path13.join(base, "resources"), file) || (await fs4.stat(file)).isDirectory())
2031
+ const base = await fs5.realpath(root), file = await fs5.realpath(path17.resolve(base, relative));
2032
+ if (!inside(path17.join(base, "resources"), file) || (await fs5.stat(file)).isDirectory())
1561
2033
  throw new Error("Resource files must stay inside resources/.");
1562
2034
  return file;
1563
2035
  }
1564
2036
  async function resourceFolder(root) {
1565
- const base = await fs4.realpath(root), folder = path13.join(base, "resources");
1566
- await fs4.mkdir(folder, { recursive: true });
1567
- if ((await fs4.lstat(folder)).isSymbolicLink())
2037
+ const base = await fs5.realpath(root), folder = path17.join(base, "resources");
2038
+ await fs5.mkdir(folder, { recursive: true });
2039
+ if ((await fs5.lstat(folder)).isSymbolicLink())
1568
2040
  throw new Error("resources/ must be a real directory.");
1569
2041
  return folder;
1570
2042
  }
1571
2043
  async function listProjectResources(root) {
1572
2044
  let entries;
1573
2045
  try {
1574
- entries = await fs4.readdir(path13.join(root, "resources"), {
2046
+ entries = await fs5.readdir(path17.join(root, "resources"), {
1575
2047
  withFileTypes: true
1576
2048
  });
1577
2049
  } catch (e) {
@@ -1583,12 +2055,12 @@ async function listProjectResources(root) {
1583
2055
  if (item.name.startsWith(".") || !item.isDirectory()) continue;
1584
2056
  try {
1585
2057
  const relative = `resources/${item.name}/resource.json`, manifestFile = await resourceFile(root, relative);
1586
- if ((await fs4.stat(manifestFile)).size > 1024 * 1024)
2058
+ if ((await fs5.stat(manifestFile)).size > 1024 * 1024)
1587
2059
  throw new Error("Manifest exceeds 1 MB");
1588
- const meta = JSON.parse(await fs4.readFile(manifestFile, "utf8"));
2060
+ const meta = JSON.parse(await fs5.readFile(manifestFile, "utf8"));
1589
2061
  const entry = `resources/${item.name}/${meta.entry}`, file = await resourceFile(root, entry);
1590
- const kind = meta.kind ?? (meta.category === "3d" ? "model" : meta.category) ?? kinds[path13.extname(file).toLowerCase()];
1591
- if (!["shader", "image", "video", "model", "scene"].includes(kind) || kinds[path13.extname(file).toLowerCase()] !== kind)
2062
+ const kind = meta.kind ?? (meta.category === "3d" ? "model" : meta.category) ?? kinds[path17.extname(file).toLowerCase()];
2063
+ if (!["shader", "image", "video", "model", "scene"].includes(kind) || kinds[path17.extname(file).toLowerCase()] !== kind)
1592
2064
  throw new Error("Unsupported resource type");
1593
2065
  let effect;
1594
2066
  if (kind === "shader" && meta.effect) {
@@ -1606,7 +2078,7 @@ async function listProjectResources(root) {
1606
2078
  const dimensions = Object.fromEntries(
1607
2079
  ["width", "height", "duration"].filter((k) => Number.isFinite(meta[k]) && meta[k] > 0).map((k) => [k, meta[k]])
1608
2080
  );
1609
- const info = await fs4.stat(file), preview = meta.preview ? `resources/${item.name}/${meta.preview}` : ["image", "video"].includes(kind) ? entry : null;
2081
+ const info = await fs5.stat(file), preview = meta.preview ? `resources/${item.name}/${meta.preview}` : ["image", "video"].includes(kind) ? entry : null;
1610
2082
  if (preview) await resourceFile(root, preview);
1611
2083
  resources.push({
1612
2084
  id: item.name,
@@ -1621,7 +2093,7 @@ async function listProjectResources(root) {
1621
2093
  } : {},
1622
2094
  preview: preview && (meta.preview || info.size < 12 * 1024 * 1024) ? preview : null,
1623
2095
  previewType: meta.preview ? "image" : kind,
1624
- stamp: `${info.mtimeMs}:${info.size}:${(await fs4.stat(manifestFile)).mtimeMs}`
2096
+ stamp: `${info.mtimeMs}:${info.size}:${(await fs5.stat(manifestFile)).mtimeMs}`
1625
2097
  });
1626
2098
  } catch (error) {
1627
2099
  if (error.code !== "ENOENT")
@@ -1630,7 +2102,7 @@ async function listProjectResources(root) {
1630
2102
  }
1631
2103
  async function scenes(folder, depth = 0) {
1632
2104
  if (depth > 16) return;
1633
- for (const item of await fs4.readdir(folder, { withFileTypes: true })) {
2105
+ for (const item of await fs5.readdir(folder, { withFileTypes: true })) {
1634
2106
  if (item.name.startsWith(".") || [
1635
2107
  "node_modules",
1636
2108
  "dist",
@@ -1639,16 +2111,16 @@ async function listProjectResources(root) {
1639
2111
  "video-references"
1640
2112
  ].includes(item.name) || item.isSymbolicLink())
1641
2113
  continue;
1642
- const file = path13.join(folder, item.name);
2114
+ const file = path17.join(folder, item.name);
1643
2115
  if (item.isDirectory()) {
1644
2116
  await scenes(file, depth + 1);
1645
2117
  continue;
1646
2118
  }
1647
- if (!item.isFile() || !item.name.endsWith(".tsx") || (await fs4.stat(file)).size > 1024 * 1024)
2119
+ if (!item.isFile() || !item.name.endsWith(".tsx") || (await fs5.stat(file)).size > 1024 * 1024)
1648
2120
  continue;
1649
- const entry = path13.relative(root, file).split(path13.sep).join("/");
2121
+ const entry = path17.relative(root, file).split(path17.sep).join("/");
1650
2122
  try {
1651
- const text = await fs4.readFile(file, "utf8"), ast = ts7.createSourceFile(
2123
+ const text = await fs5.readFile(file, "utf8"), ast = ts7.createSourceFile(
1652
2124
  entry,
1653
2125
  text,
1654
2126
  ts7.ScriptTarget.Latest,
@@ -1685,12 +2157,12 @@ async function listProjectResources(root) {
1685
2157
  else
1686
2158
  resources.push({
1687
2159
  id: `scene:${entry}`,
1688
- title: item.name === "scene.tsx" ? path13.basename(folder) : item.name.slice(0, -4),
2160
+ title: item.name === "scene.tsx" ? path17.basename(folder) : item.name.slice(0, -4),
1689
2161
  kind: "scene",
1690
2162
  entry,
1691
2163
  ...info,
1692
2164
  preview: null,
1693
- stamp: String((await fs4.stat(file)).mtimeMs)
2165
+ stamp: String((await fs5.stat(file)).mtimeMs)
1694
2166
  });
1695
2167
  } catch (error) {
1696
2168
  errors.push(`${entry}: ${error.message}`);
@@ -1709,44 +2181,44 @@ async function getProjectResource(root, id) {
1709
2181
  async function resourcePreview(root, id) {
1710
2182
  const resource = await getProjectResource(root, id);
1711
2183
  if (!resource.preview) throw new Error("No preview for this resource");
1712
- const file = await resourceFile(root, resource.preview), type = mime[path13.extname(file).toLowerCase()];
2184
+ const file = await resourceFile(root, resource.preview), type = mime[path17.extname(file).toLowerCase()];
1713
2185
  if (!type) throw new Error("Unsupported preview type");
1714
- if ((await fs4.stat(file)).size > 12 * 1024 * 1024)
2186
+ if ((await fs5.stat(file)).size > 12 * 1024 * 1024)
1715
2187
  throw new Error("Preview is too large");
1716
- return { bytes: await fs4.readFile(file), type };
2188
+ return { bytes: await fs5.readFile(file), type };
1717
2189
  }
1718
2190
  async function installResourceExamples(root) {
1719
- const folder = await resourceFolder(root), source = path13.join(packageRoot, "src/resource-examples/");
1720
- for (const item of await fs4.readdir(source, { withFileTypes: true })) {
2191
+ const folder = await resourceFolder(root), source = path17.join(packageRoot, "src/resource-examples/");
2192
+ for (const item of await fs5.readdir(source, { withFileTypes: true })) {
1721
2193
  if (!item.isDirectory()) continue;
1722
- const destination = path13.join(folder, item.name);
2194
+ const destination = path17.join(folder, item.name);
1723
2195
  try {
1724
- await fs4.mkdir(destination);
2196
+ await fs5.mkdir(destination);
1725
2197
  } catch (e) {
1726
2198
  if (e.code === "EEXIST") continue;
1727
2199
  throw e;
1728
2200
  }
1729
2201
  try {
1730
- for (const file of (await fs4.readdir(path13.join(source, item.name))).sort(
2202
+ for (const file of (await fs5.readdir(path17.join(source, item.name))).sort(
1731
2203
  (a, b) => Number(a === "resource.json") - Number(b === "resource.json")
1732
2204
  ))
1733
- await fs4.copyFile(
1734
- path13.join(source, item.name, file),
1735
- path13.join(destination, file)
2205
+ await fs5.copyFile(
2206
+ path17.join(source, item.name, file),
2207
+ path17.join(destination, file)
1736
2208
  );
1737
2209
  } catch (error) {
1738
- await fs4.rm(destination, { recursive: true, force: true });
2210
+ await fs5.rm(destination, { recursive: true, force: true });
1739
2211
  throw error;
1740
2212
  }
1741
2213
  }
1742
2214
  return { status: "installed", ...await listProjectResources(root) };
1743
2215
  }
1744
- var exec, kinds, mime, inside;
2216
+ var exec4, kinds, mime, inside;
1745
2217
  var init_project_resources = __esm({
1746
2218
  "src/project-resources.ts"() {
1747
2219
  "use strict";
1748
2220
  init_runtime();
1749
- exec = promisify(execFile);
2221
+ exec4 = promisify4(execFile4);
1750
2222
  kinds = {
1751
2223
  ".tsx": "scene",
1752
2224
  ".svg": "image",
@@ -1771,8 +2243,8 @@ var init_project_resources = __esm({
1771
2243
  ".mov": "video/quicktime"
1772
2244
  };
1773
2245
  inside = (root, file) => {
1774
- const rel = path13.relative(root, file);
1775
- return rel !== ".." && !rel.startsWith(".." + path13.sep) && !path13.isAbsolute(rel);
2246
+ const rel = path17.relative(root, file);
2247
+ return rel !== ".." && !rel.startsWith(".." + path17.sep) && !path17.isAbsolute(rel);
1776
2248
  };
1777
2249
  }
1778
2250
  });
@@ -2033,7 +2505,7 @@ function validProperty(name, value, spec) {
2033
2505
  if (["position", "origin"].includes(name)) return pair(value);
2034
2506
  if (name === "rotation" && spec.kind === "json")
2035
2507
  return Array.isArray(value) && value.length === 3 && value.every(number);
2036
- if (name === "scale") return number(value) || pair(value);
2508
+ if (name === "scale") return number(value);
2037
2509
  if (["width", "height"].includes(name))
2038
2510
  return value === "fill" || value === "auto" || number(value) && value > 0;
2039
2511
  if (name === "padding")
@@ -2041,6 +2513,7 @@ function validProperty(name, value, spec) {
2041
2513
  return false;
2042
2514
  }
2043
2515
  function validKnob(schema, value) {
2516
+ if (schema.type === "asset") return !!schema.kind && validAssetPath(schema.kind, value);
2044
2517
  if (schema.type === "text")
2045
2518
  return typeof value === "string" && value.length <= 1e4;
2046
2519
  if (schema.type === "color")
@@ -2061,6 +2534,7 @@ function validKnob(schema, value) {
2061
2534
  var init_property_validation = __esm({
2062
2535
  "../editor/src/property-validation.ts"() {
2063
2536
  "use strict";
2537
+ init_assets();
2064
2538
  init_mask();
2065
2539
  }
2066
2540
  });
@@ -2756,6 +3230,30 @@ function applyTimelineCommand(text, command, {
2756
3230
  { start: clip.node.getStart(model.ast), end: clip.node.end, text: "" }
2757
3231
  ]);
2758
3232
  id = null;
3233
+ } else if (command.type === "split") {
3234
+ const at = command.time, cut = at - clip.start, value = (name, fallback) => {
3235
+ const attr = clip.attrs.get(name);
3236
+ if (!attr) return fallback;
3237
+ const read = literal2(attr.initializer);
3238
+ if (!read.known || typeof read.value !== "number")
3239
+ throw new Error(`This clip's ${name} is set in code; edit it there`);
3240
+ return read.value;
3241
+ };
3242
+ const copy = copyClip(
3243
+ clip,
3244
+ model.ast,
3245
+ {
3246
+ start: at,
3247
+ duration: clip.duration - cut,
3248
+ trimIn: value("trimIn", 0) + cut * value("playbackRate", 1)
3249
+ },
3250
+ fresh
3251
+ );
3252
+ id = copy.id;
3253
+ text = patches(text, [
3254
+ { start: clip.node.end, end: clip.node.end, text: "\n " + copy.text }
3255
+ ]);
3256
+ text = setAttrs(text, clip.node, { duration: cut }, model.ast);
2759
3257
  } else if (command.type === "duplicate") {
2760
3258
  let start = clip.start + clip.duration, track = clip.track;
2761
3259
  if (!canPlace(model.clips, null, start, clip.duration, track, model.duration)) {
@@ -2871,7 +3369,7 @@ var init_timeline = __esm({
2871
3369
  position: { kind: "json", default: [0, 0] },
2872
3370
  z: { kind: "number", default: 0 },
2873
3371
  rotation: { kind: "number", default: 0 },
2874
- scale: { kind: "json", default: 1 },
3372
+ scale: { kind: "number", default: 1 },
2875
3373
  origin: { kind: "json", default: [0, 0] },
2876
3374
  width: { kind: "json", default: "fill" },
2877
3375
  height: { kind: "json", default: "fill" },
@@ -3143,10 +3641,10 @@ var init_resource_source = __esm({
3143
3641
  });
3144
3642
 
3145
3643
  // src/timeline-editor.ts
3146
- import fs5 from "node:fs/promises";
3644
+ import fs6 from "node:fs/promises";
3147
3645
  import { realpathSync } from "node:fs";
3148
- import path14 from "node:path";
3149
- import { createHash as createHash6, randomUUID as randomUUID4 } from "node:crypto";
3646
+ import path18 from "node:path";
3647
+ import { createHash as createHash6, randomUUID as randomUUID7 } from "node:crypto";
3150
3648
  import ts10 from "typescript";
3151
3649
  function createTimelineEditor(root, context, {
3152
3650
  nativeValidation = false,
@@ -3164,8 +3662,8 @@ function createTimelineEditor(root, context, {
3164
3662
  };
3165
3663
  async function read() {
3166
3664
  const { source: inputSource, scene } = context();
3167
- const source = await fs5.realpath(inputSource);
3168
- const text = await fs5.readFile(source, "utf8"), definitions = {}, inputs = /* @__PURE__ */ new Map([[source, text]]);
3665
+ const source = await fs6.realpath(inputSource);
3666
+ const text = await fs6.readFile(source, "utf8"), definitions = {}, inputs = /* @__PURE__ */ new Map([[source, text]]);
3169
3667
  async function dependencies(file, body2, depth = 0) {
3170
3668
  if (depth > 32) throw new Error("Too many imported animations");
3171
3669
  const ast = ts10.createSourceFile(
@@ -3184,7 +3682,7 @@ function createTimelineEditor(root, context, {
3184
3682
  );
3185
3683
  let code = inputs.get(next.source);
3186
3684
  if (code === void 0) {
3187
- code = await fs5.readFile(next.source, "utf8");
3685
+ code = await fs6.readFile(next.source, "utf8");
3188
3686
  inputs.set(next.source, code);
3189
3687
  await dependencies(next.source, code, depth + 1);
3190
3688
  }
@@ -3233,7 +3731,7 @@ function createTimelineEditor(root, context, {
3233
3731
  ...data,
3234
3732
  sourceFiles: Object.fromEntries(
3235
3733
  [...inputs].map(([file, body2]) => [
3236
- path14.relative(root, file).split(path14.sep).join("/"),
3734
+ path18.relative(root, file).split(path18.sep).join("/"),
3237
3735
  body2
3238
3736
  ])
3239
3737
  ),
@@ -3244,16 +3742,16 @@ function createTimelineEditor(root, context, {
3244
3742
  };
3245
3743
  }
3246
3744
  async function write(file, text) {
3247
- const temp = path14.join(
3248
- path14.dirname(file),
3249
- `.osmo-edit-${randomUUID4()}.tmp`
3745
+ const temp = path18.join(
3746
+ path18.dirname(file),
3747
+ `.osmo-edit-${randomUUID7()}.tmp`
3250
3748
  );
3251
3749
  try {
3252
- const mode = (await fs5.stat(file)).mode;
3253
- await fs5.writeFile(temp, text, { mode });
3254
- await fs5.rename(temp, file);
3750
+ const mode = (await fs6.stat(file)).mode;
3751
+ await fs6.writeFile(temp, text, { mode });
3752
+ await fs6.rename(temp, file);
3255
3753
  } finally {
3256
- await fs5.rm(temp, { force: true });
3754
+ await fs6.rm(temp, { force: true });
3257
3755
  }
3258
3756
  }
3259
3757
  return {
@@ -3266,27 +3764,27 @@ function createTimelineEditor(root, context, {
3266
3764
  resolveAdd: (cardId) => serial(async () => {
3267
3765
  const board = validateBoard(
3268
3766
  JSON.parse(
3269
- await fs5.readFile(
3270
- path14.join(root, "storyboard.canvas.json"),
3767
+ await fs6.readFile(
3768
+ path18.join(root, "storyboard.canvas.json"),
3271
3769
  "utf8"
3272
3770
  )
3273
3771
  )
3274
3772
  );
3275
3773
  const card = board.cards.find((c) => c.id === cardId);
3276
3774
  if (!card) throw new Error("Storyboard card no longer exists");
3277
- const located = sceneInProject(root, card.source), code = await fs5.readFile(located.source, "utf8");
3775
+ const located = sceneInProject(root, card.source), code = await fs6.readFile(located.source, "utf8");
3278
3776
  const document = await documentLoader.load(located.source, code);
3279
3777
  const sourceFiles = Object.fromEntries(
3280
3778
  await Promise.all(
3281
3779
  document.files.map(
3282
3780
  async (file) => [
3283
3781
  file,
3284
- await fs5.readFile(sceneInProject(root, file).source, "utf8")
3782
+ await fs6.readFile(sceneInProject(root, file).source, "utf8")
3285
3783
  ]
3286
3784
  )
3287
3785
  )
3288
3786
  );
3289
- let importPath = path14.relative(path14.dirname(context().source), located.source).split(path14.sep).join("/");
3787
+ let importPath = path18.relative(path18.dirname(context().source), located.source).split(path18.sep).join("/");
3290
3788
  if (!importPath.startsWith(".")) importPath = "./" + importPath;
3291
3789
  return {
3292
3790
  type: "add",
@@ -3326,7 +3824,7 @@ function createTimelineEditor(root, context, {
3326
3824
  if (typeof change.file !== "string" || !change.file.endsWith(".tsx") || typeof change.after !== "string" || change.after.length > 1024 * 1024)
3327
3825
  throw new Error("Invalid source patch");
3328
3826
  const file = sceneInProject(root, change.file).source;
3329
- const current2 = await fs5.readFile(file, "utf8");
3827
+ const current2 = await fs6.readFile(file, "utf8");
3330
3828
  if (current2 !== change.before)
3331
3829
  throw Object.assign(new Error(`Source changed: ${change.file}`), {
3332
3830
  status: 409
@@ -3360,7 +3858,7 @@ function createTimelineEditor(root, context, {
3360
3858
  status: 409
3361
3859
  });
3362
3860
  for (const change of changed)
3363
- if (await fs5.readFile(change.file, "utf8") !== change.before)
3861
+ if (await fs6.readFile(change.file, "utf8") !== change.before)
3364
3862
  throw Object.assign(new Error("Source changed while validating"), {
3365
3863
  status: 409
3366
3864
  });
@@ -3372,7 +3870,7 @@ function createTimelineEditor(root, context, {
3372
3870
  }
3373
3871
  } catch (error) {
3374
3872
  for (const change of written.reverse())
3375
- if (await fs5.readFile(change.file, "utf8") === change.after)
3873
+ if (await fs6.readFile(change.file, "utf8") === change.after)
3376
3874
  await write(change.file, change.before);
3377
3875
  throw error;
3378
3876
  }
@@ -3435,15 +3933,15 @@ function createTimelineEditor(root, context, {
3435
3933
  if (!before.inputs.has(file))
3436
3934
  throw new Error("Target source is not part of this scene");
3437
3935
  const relative = (value) => {
3438
- let p = path14.relative(path14.dirname(file), sceneInProject(root, value).source).split(path14.sep).join("/");
3936
+ let p = path18.relative(path18.dirname(file), sceneInProject(root, value).source).split(path18.sep).join("/");
3439
3937
  return p.startsWith(".") ? p : "./" + p;
3440
3938
  };
3441
3939
  result = applyResourceSource(before.inputs.get(file), plan, {
3442
- filename: path14.relative(root, file),
3940
+ filename: path18.relative(root, file),
3443
3941
  definitions: before.definitions,
3444
3942
  src: relative(resource.entry),
3445
3943
  effectSrc: resource.effect ? relative(resource.effect.src) : void 0,
3446
- newId: () => `clip-${randomUUID4().slice(0, 8)}`
3944
+ newId: () => `clip-${randomUUID7().slice(0, 8)}`
3447
3945
  });
3448
3946
  } else if (command.type === "group") {
3449
3947
  const target = findGroup(before.document, command.nodeId);
@@ -3463,8 +3961,8 @@ function createTimelineEditor(root, context, {
3463
3961
  if (command.type === "add") {
3464
3962
  const board = validateBoard(
3465
3963
  JSON.parse(
3466
- await fs5.readFile(
3467
- path14.join(root, "storyboard.canvas.json"),
3964
+ await fs6.readFile(
3965
+ path18.join(root, "storyboard.canvas.json"),
3468
3966
  "utf8"
3469
3967
  )
3470
3968
  )
@@ -3473,12 +3971,12 @@ function createTimelineEditor(root, context, {
3473
3971
  const { source: groupSource, scene: groupScene } = sceneInProject(
3474
3972
  root,
3475
3973
  card.source
3476
- ), code = await fs5.readFile(groupSource, "utf8");
3974
+ ), code = await fs6.readFile(groupSource, "utf8");
3477
3975
  const definition = {
3478
3976
  ...describeGroup(code, groupScene),
3479
3977
  source: groupScene
3480
3978
  };
3481
- let importPath = path14.relative(path14.dirname(before.source), groupSource).split(path14.sep).join("/");
3979
+ let importPath = path18.relative(path18.dirname(before.source), groupSource).split(path18.sep).join("/");
3482
3980
  if (!importPath.startsWith(".")) importPath = "./" + importPath;
3483
3981
  resolved = {
3484
3982
  type: "add",
@@ -3491,7 +3989,7 @@ function createTimelineEditor(root, context, {
3491
3989
  result = applyTimelineCommand(before.text, resolved, {
3492
3990
  definitions: before.definitions,
3493
3991
  filename: before.scene,
3494
- newId: () => `clip-${randomUUID4().slice(0, 8)}`
3992
+ newId: () => `clip-${randomUUID7().slice(0, 8)}`
3495
3993
  });
3496
3994
  }
3497
3995
  if (result.text === before.inputs.get(file))
@@ -3590,8 +4088,8 @@ var init_timeline_editor = __esm({
3590
4088
  init_resource_source();
3591
4089
  init_timeline();
3592
4090
  init_group_model();
3593
- resolveImport = (source, spec) => path14.resolve(
3594
- path14.dirname(source),
4091
+ resolveImport = (source, spec) => path18.resolve(
4092
+ path18.dirname(source),
3595
4093
  spec.endsWith(".tsx") ? spec : spec.endsWith(".js") ? spec.slice(0, -3) + ".tsx" : spec + ".tsx"
3596
4094
  );
3597
4095
  hash2 = (value) => createHash6("sha256").update(value).digest("hex");
@@ -3599,17 +4097,17 @@ var init_timeline_editor = __esm({
3599
4097
  });
3600
4098
 
3601
4099
  // src/studio-settings.ts
3602
- import { mkdir as mkdir6, readFile as readFile7, writeFile as writeFile6, rename as rename3, rm as rm6 } from "node:fs/promises";
3603
- import path15 from "node:path";
4100
+ import { mkdir as mkdir7, readFile as readFile8, writeFile as writeFile7, rename as rename3, rm as rm7 } from "node:fs/promises";
4101
+ import path19 from "node:path";
3604
4102
  import { randomBytes } from "node:crypto";
3605
4103
  async function studioSettings(root) {
3606
- const directory = path15.join(root, ".osmo");
3607
- await mkdir6(directory, { recursive: true });
3608
- const lock = path15.join(directory, "studio.lock");
4104
+ const directory = path19.join(root, ".osmo");
4105
+ await mkdir7(directory, { recursive: true });
4106
+ const lock = path19.join(directory, "studio.lock");
3609
4107
  const deadline = Date.now() + 5e3;
3610
4108
  while (true) {
3611
4109
  try {
3612
- await mkdir6(lock);
4110
+ await mkdir7(lock);
3613
4111
  break;
3614
4112
  } catch (error) {
3615
4113
  if (error.code !== "EEXIST") throw error;
@@ -3620,12 +4118,12 @@ async function studioSettings(root) {
3620
4118
  await new Promise((resolve) => setTimeout(resolve, 50));
3621
4119
  }
3622
4120
  }
3623
- const release = () => rm6(lock, { recursive: true, force: true });
4121
+ const release = () => rm7(lock, { recursive: true, force: true });
3624
4122
  try {
3625
- const file = path15.join(directory, "studio.json");
4123
+ const file = path19.join(directory, "studio.json");
3626
4124
  let settings, existing = false;
3627
4125
  try {
3628
- settings = JSON.parse(await readFile7(file, "utf8"));
4126
+ settings = JSON.parse(await readFile8(file, "utf8"));
3629
4127
  existing = true;
3630
4128
  if (settings.version !== 1 || typeof settings.id !== "string" || settings.id.length > 64 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(settings.id) || !/^[a-f0-9]{48}$/.test(settings.token) || !Number.isInteger(settings.port) || settings.port < 1 || settings.port > 65535)
3631
4129
  throw new Error("Invalid settings");
@@ -3634,7 +4132,7 @@ async function studioSettings(root) {
3634
4132
  throw new Error(
3635
4133
  "Cannot read .osmo/studio.json. Fix or remove it to reset the Studio address."
3636
4134
  );
3637
- const name = path15.basename(root).normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/['’]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 48).replace(/-$/, "") || "project";
4135
+ const name = path19.basename(root).normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/['’]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 48).replace(/-$/, "") || "project";
3638
4136
  settings = {
3639
4137
  version: 1,
3640
4138
  id: `${name}-${randomBytes(2).toString("hex")}`,
@@ -3642,10 +4140,10 @@ async function studioSettings(root) {
3642
4140
  token: randomBytes(24).toString("hex")
3643
4141
  };
3644
4142
  }
3645
- const ignorePath = path15.join(root, ".gitignore");
4143
+ const ignorePath = path19.join(root, ".gitignore");
3646
4144
  let ignore = "";
3647
4145
  try {
3648
- ignore = await readFile7(ignorePath, "utf8");
4146
+ ignore = await readFile8(ignorePath, "utf8");
3649
4147
  } catch (error) {
3650
4148
  if (error.code !== "ENOENT") throw error;
3651
4149
  }
@@ -3655,7 +4153,7 @@ async function studioSettings(root) {
3655
4153
  ".osmo/studio.*.tmp"
3656
4154
  ].filter((rule) => !ignore.split("\n").includes(rule));
3657
4155
  if (missing.length)
3658
- await writeFile6(
4156
+ await writeFile7(
3659
4157
  ignorePath,
3660
4158
  ignore + (ignore && !ignore.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n"
3661
4159
  );
@@ -3666,12 +4164,12 @@ async function studioSettings(root) {
3666
4164
  async save() {
3667
4165
  const temp = `${file}.${process.pid}.tmp`;
3668
4166
  try {
3669
- await writeFile6(temp, JSON.stringify(settings, null, 2) + "\n", {
4167
+ await writeFile7(temp, JSON.stringify(settings, null, 2) + "\n", {
3670
4168
  mode: 384
3671
4169
  });
3672
4170
  await rename3(temp, file);
3673
4171
  } finally {
3674
- await rm6(temp, { force: true });
4172
+ await rm7(temp, { force: true });
3675
4173
  }
3676
4174
  }
3677
4175
  };
@@ -3687,14 +4185,14 @@ var init_studio_settings = __esm({
3687
4185
  });
3688
4186
 
3689
4187
  // src/comment-profile.ts
3690
- import { readFile as readFile8, writeFile as writeFile7, rename as rename4, mkdir as mkdir7 } from "node:fs/promises";
4188
+ import { readFile as readFile9, writeFile as writeFile8, rename as rename4, mkdir as mkdir8 } from "node:fs/promises";
3691
4189
  import { userInfo } from "node:os";
3692
- import { randomUUID as randomUUID5 } from "node:crypto";
3693
- import path16 from "node:path";
4190
+ import { randomUUID as randomUUID8 } from "node:crypto";
4191
+ import path20 from "node:path";
3694
4192
  async function commentProfile(root) {
3695
4193
  try {
3696
4194
  const profile = JSON.parse(
3697
- await readFile8(path16.join(root, ".osmo/comment-profile.json"), "utf8")
4195
+ await readFile9(path20.join(root, ".osmo/comment-profile.json"), "utf8")
3698
4196
  );
3699
4197
  if (!validAuthor(profile)) throw new Error("Invalid comment profile");
3700
4198
  return profile;
@@ -3713,11 +4211,11 @@ async function saveCommentProfile(root, input) {
3713
4211
  throw new Error(
3714
4212
  "Provide a name and a PNG, JPEG, or WebP avatar under 110 KB."
3715
4213
  );
3716
- const folder = path16.join(root, ".osmo");
3717
- await mkdir7(folder, { recursive: true });
3718
- const temp = path16.join(folder, `comment-profile.${randomUUID5()}.tmp`);
3719
- await writeFile7(temp, JSON.stringify(profile) + "\n");
3720
- await rename4(temp, path16.join(folder, "comment-profile.json"));
4214
+ const folder = path20.join(root, ".osmo");
4215
+ await mkdir8(folder, { recursive: true });
4216
+ const temp = path20.join(folder, `comment-profile.${randomUUID8()}.tmp`);
4217
+ await writeFile8(temp, JSON.stringify(profile) + "\n");
4218
+ await rename4(temp, path20.join(folder, "comment-profile.json"));
3721
4219
  return profile;
3722
4220
  }
3723
4221
  var init_comment_profile = __esm({
@@ -3733,16 +4231,16 @@ __export(server_exports, {
3733
4231
  startStudio: () => startStudio
3734
4232
  });
3735
4233
  import http from "node:http";
3736
- import fs6 from "node:fs";
3737
- import { readFile as readFile9, stat, realpath, readdir as readdir3 } from "node:fs/promises";
3738
- import path17 from "node:path";
3739
- import { createHash as createHash7, randomUUID as randomUUID6 } from "node:crypto";
4234
+ import fs7 from "node:fs";
4235
+ import { readFile as readFile10, stat as stat3, realpath as realpath3, readdir as readdir3 } from "node:fs/promises";
4236
+ import path21 from "node:path";
4237
+ import { createHash as createHash7, randomUUID as randomUUID9 } from "node:crypto";
3740
4238
  import { spawn as spawn3 } from "node:child_process";
3741
4239
  async function revision(paths) {
3742
4240
  const hash3 = createHash7("sha256");
3743
4241
  for (const file of [...new Set(paths)].sort()) {
3744
4242
  hash3.update(file);
3745
- for await (const chunk of fs6.createReadStream(file)) hash3.update(chunk);
4243
+ for await (const chunk of fs7.createReadStream(file)) hash3.update(chunk);
3746
4244
  }
3747
4245
  return `sha256:${hash3.digest("hex")}`;
3748
4246
  }
@@ -3750,7 +4248,7 @@ async function fingerprint(paths) {
3750
4248
  return Promise.all(
3751
4249
  paths.map(async (p) => {
3752
4250
  try {
3753
- const s = await stat(p);
4251
+ const s = await stat3(p);
3754
4252
  return `${p}:${s.mtimeMs}:${s.size}`;
3755
4253
  } catch {
3756
4254
  return p + ":missing";
@@ -3759,7 +4257,7 @@ async function fingerprint(paths) {
3759
4257
  ).then((v) => v.join("|"));
3760
4258
  }
3761
4259
  async function studioRevision() {
3762
- const files = (await readdir3(studioRoot, { recursive: true })).filter((file) => /\.(html|js|css|svg|woff2|wasm)$/.test(file)).sort().map((file) => path17.join(studioRoot, file));
4260
+ const files = (await readdir3(studioRoot, { recursive: true })).filter((file) => /\.(html|js|css|svg|woff2|wasm)$/.test(file)).sort().map((file) => path21.join(studioRoot, file));
3763
4261
  const stamp = await fingerprint(files);
3764
4262
  if (stamp !== studioStamp) {
3765
4263
  const digest = await revision(files);
@@ -3797,10 +4295,10 @@ async function startStudio({
3797
4295
  preview = {},
3798
4296
  open = true
3799
4297
  }) {
3800
- const studioBuild = path17.join(runtimeRoot, "packages/studio/tools/build.mjs");
3801
- if (fs6.existsSync(studioBuild)) await run(process.execPath, [studioBuild]);
3802
- root = await realpath(root);
3803
- source = await realpath(source);
4298
+ const studioBuild = path21.join(runtimeRoot, "packages/studio/tools/build.mjs");
4299
+ if (fs7.existsSync(studioBuild)) await run(process.execPath, [studioBuild]);
4300
+ root = await realpath3(root);
4301
+ source = await realpath3(source);
3804
4302
  const profile = await studioSettings(root);
3805
4303
  const { settings } = profile, { token } = settings;
3806
4304
  const projectPath = "/" + settings.id;
@@ -3832,7 +4330,7 @@ async function startStudio({
3832
4330
  scene,
3833
4331
  preview,
3834
4332
  version,
3835
- projectName: path17.basename(root),
4333
+ projectName: path21.basename(root),
3836
4334
  projectId: settings.id,
3837
4335
  loading: true,
3838
4336
  error: null,
@@ -3845,10 +4343,10 @@ async function startStudio({
3845
4343
  const watchedStamps = /* @__PURE__ */ new Map();
3846
4344
  watchedStamps.set(source, await fingerprint([source]));
3847
4345
  const geometryCache = /* @__PURE__ */ new Map(), geometryInflight = /* @__PURE__ */ new Map();
3848
- const cache = /* @__PURE__ */ new Map(), inflight = /* @__PURE__ */ new Map();
4346
+ const cache2 = /* @__PURE__ */ new Map(), inflight = /* @__PURE__ */ new Map();
3849
4347
  let cacheBytes = 0;
3850
4348
  function clearFrames() {
3851
- cache.clear();
4349
+ cache2.clear();
3852
4350
  cacheBytes = 0;
3853
4351
  geometryCache.clear();
3854
4352
  }
@@ -4000,16 +4498,68 @@ async function startStudio({
4000
4498
  return json(res, 403, {
4001
4499
  error: "Open the Studio URL printed by the CLI."
4002
4500
  });
4501
+ if (req.method === "POST" && url2.pathname === "/api/attachments/pick")
4502
+ return json(res, 200, await pickLocalAttachments());
4503
+ const selection = url2.pathname.match(
4504
+ /^\/api\/attachments\/selected\/([a-f0-9-]+)$/
4505
+ );
4506
+ if (req.method === "GET" && selection) {
4507
+ const file2 = selectedLocalAttachment(selection[1]);
4508
+ if (url2.searchParams.get("preview") === "1") {
4509
+ const bytes2 = await attachmentThumbnail(file2.sourcePath);
4510
+ res.writeHead(200, {
4511
+ "Content-Type": "image/jpeg",
4512
+ "Cache-Control": "private, max-age=60"
4513
+ });
4514
+ return res.end(bytes2);
4515
+ }
4516
+ res.writeHead(200, {
4517
+ "Content-Type": "application/octet-stream",
4518
+ "X-Content-Type-Options": "nosniff"
4519
+ });
4520
+ const stream = fs7.createReadStream(file2.sourcePath);
4521
+ res.once("close", () => stream.destroy());
4522
+ stream.on("error", () => res.destroy()).pipe(res);
4523
+ return;
4524
+ }
4525
+ if (req.method === "POST" && url2.pathname === "/api/assets/import") {
4526
+ const kind = url2.searchParams.get("kind") ?? "";
4527
+ const relative = await importProjectAsset(
4528
+ root,
4529
+ req,
4530
+ url2.searchParams.get("name") ?? "",
4531
+ kind
4532
+ );
4533
+ return json(res, 200, {
4534
+ path: relative,
4535
+ asset: await documentLoader.registerAsset(relative, kind)
4536
+ });
4537
+ }
4538
+ if (req.method === "GET" && url2.pathname === "/api/assets/metadata")
4539
+ return json(
4540
+ res,
4541
+ 200,
4542
+ await documentLoader.assetMetadata(url2.searchParams.get("id"))
4543
+ );
4003
4544
  if (req.method === "GET" && url2.pathname === "/api/document/asset") {
4004
4545
  const asset = await documentLoader.readAsset(
4005
4546
  url2.searchParams.get("id")
4006
4547
  );
4007
- const types2 = {
4548
+ const types3 = {
4008
4549
  ".svg": "image/svg+xml",
4009
4550
  ".png": "image/png",
4010
4551
  ".jpg": "image/jpeg",
4011
4552
  ".jpeg": "image/jpeg",
4012
4553
  ".webp": "image/webp",
4554
+ ".gif": "image/gif",
4555
+ ".wav": "audio/wav",
4556
+ ".mp3": "audio/mpeg",
4557
+ ".m4a": "audio/mp4",
4558
+ ".aac": "audio/aac",
4559
+ ".ogg": "audio/ogg",
4560
+ ".flac": "audio/flac",
4561
+ ".m4v": "video/mp4",
4562
+ ".glb": "model/gltf-binary",
4013
4563
  ".mp4": "video/mp4",
4014
4564
  ".webm": "video/webm",
4015
4565
  ".mov": "video/quicktime",
@@ -4017,7 +4567,7 @@ async function startStudio({
4017
4567
  ".otf": "font/otf"
4018
4568
  };
4019
4569
  res.writeHead(200, {
4020
- "Content-Type": types2[asset.extension] ?? "application/octet-stream",
4570
+ "Content-Type": types3[asset.extension] ?? "application/octet-stream",
4021
4571
  "Cache-Control": "private, max-age=31536000, immutable"
4022
4572
  });
4023
4573
  return res.end(asset.bytes);
@@ -4138,7 +4688,7 @@ async function startStudio({
4138
4688
  renderer?.close();
4139
4689
  renderer = null;
4140
4690
  nativeRevision = null;
4141
- source = await realpath(next.source);
4691
+ source = await realpath3(next.source);
4142
4692
  scene = next.scene;
4143
4693
  state.scene = scene;
4144
4694
  dependencies = [source];
@@ -4208,14 +4758,14 @@ async function startStudio({
4208
4758
  geometry = {
4209
4759
  revision: rev,
4210
4760
  frame,
4211
- targets: result.targets.filter((t) => rows.has(t.path)).map(({ path: path19, ...t }, order) => ({
4761
+ targets: result.targets.filter((t) => rows.has(t.path)).map(({ path: path23, ...t }, order) => ({
4212
4762
  ...t,
4213
4763
  // Filtered to rows above.
4214
- id: rows.get(path19),
4764
+ id: rows.get(path23),
4215
4765
  order
4216
4766
  })),
4217
4767
  quads: Object.fromEntries(
4218
- Object.entries(result.quads).filter(([path19]) => rows.has(path19)).map(([path19, quad]) => [rows.get(path19), quad])
4768
+ Object.entries(result.quads).filter(([path23]) => rows.has(path23)).map(([path23, quad]) => [rows.get(path23), quad])
4219
4769
  )
4220
4770
  };
4221
4771
  geometryCache.set(key, geometry);
@@ -4234,12 +4784,12 @@ async function startStudio({
4234
4784
  0,
4235
4785
  state.meta.totalFrames - 1
4236
4786
  );
4237
- const selection = url2.searchParams.get("selection");
4238
- const row = selection ? outline?.rows.find((r) => r.id === selection) : null;
4239
- if (selection && !row)
4787
+ const selection2 = url2.searchParams.get("selection");
4788
+ const row = selection2 ? outline?.rows.find((r) => r.id === selection2) : null;
4789
+ if (selection2 && !row)
4240
4790
  return json(res, 404, { error: "Element is not in this preview" });
4241
- const rev = state.revision, key = rev + ":" + frame + ":" + (selection ?? "");
4242
- let result = cache.get(key);
4791
+ const rev = state.revision, key = rev + ":" + frame + ":" + (selection2 ?? "");
4792
+ let result = cache2.get(key);
4243
4793
  if (!result) {
4244
4794
  if (!inflight.has(key)) {
4245
4795
  const pending = withNative(
@@ -4257,14 +4807,14 @@ async function startStudio({
4257
4807
  result = await inflight.get(key);
4258
4808
  if (rev !== state.revision || state.loading)
4259
4809
  return json(res, 409, { error: "Preview changed" });
4260
- if (!cache.has(key)) {
4261
- cache.set(key, result);
4810
+ if (!cache2.has(key)) {
4811
+ cache2.set(key, result);
4262
4812
  cacheBytes += result.bytes.length;
4263
4813
  }
4264
- while (cacheBytes > 32 * 1024 * 1024 && cache.size > 1) {
4265
- const first = cache.keys().next().value;
4266
- cacheBytes -= cache.get(first).bytes.length;
4267
- cache.delete(first);
4814
+ while (cacheBytes > 32 * 1024 * 1024 && cache2.size > 1) {
4815
+ const first = cache2.keys().next().value;
4816
+ cacheBytes -= cache2.get(first).bytes.length;
4817
+ cache2.delete(first);
4268
4818
  }
4269
4819
  }
4270
4820
  res.writeHead(200, {
@@ -4278,7 +4828,7 @@ async function startStudio({
4278
4828
  if (req.method === "GET" && url2.pathname === "/api/audio") {
4279
4829
  if (!state.meta?.audioPath || url2.searchParams.get("revision") !== state.revision)
4280
4830
  return json(res, 404, { error: "Audio unavailable" });
4281
- const bytes2 = await readFile9(state.meta.audioPath);
4831
+ const bytes2 = await readFile10(state.meta.audioPath);
4282
4832
  res.writeHead(200, {
4283
4833
  "Content-Type": "audio/wav",
4284
4834
  "Cache-Control": "no-store"
@@ -4306,7 +4856,7 @@ async function startStudio({
4306
4856
  );
4307
4857
  }
4308
4858
  if (req.method === "POST" && url2.pathname === "/api/comments") {
4309
- const input = await body(req);
4859
+ const input = await body(req, 71 * 1024 * 1024);
4310
4860
  if (!state.meta || input.revision !== state.revision || state.loading || dirty)
4311
4861
  return json(res, 409, {
4312
4862
  error: "Preview changed. Review the current frame before commenting."
@@ -4332,6 +4882,37 @@ async function startStudio({
4332
4882
  })
4333
4883
  );
4334
4884
  }
4885
+ const attachment = url2.pathname.match(
4886
+ /^\/api\/comments\/([a-zA-Z0-9_-]+)\/attachment$/
4887
+ );
4888
+ if (req.method === "GET" && attachment) {
4889
+ const comment = (await readComments(root)).comments.find(
4890
+ (c) => c.id === attachment[1] && c.scene === scene
4891
+ );
4892
+ const file2 = comment && [
4893
+ ...comment.attachments ?? [],
4894
+ ...comment.replies.flatMap((r) => r.attachments ?? [])
4895
+ ].find((f) => f.path === url2.searchParams.get("path"));
4896
+ if (!file2) return json(res, 404, { error: "Attachment not found" });
4897
+ const filename2 = await attachmentPath(root, file2);
4898
+ if (url2.searchParams.get("preview") === "1") {
4899
+ const bytes2 = await attachmentThumbnail(filename2);
4900
+ res.writeHead(200, {
4901
+ "Content-Type": "image/jpeg",
4902
+ "Cache-Control": "private, max-age=60"
4903
+ });
4904
+ return res.end(bytes2);
4905
+ }
4906
+ res.writeHead(200, {
4907
+ "Content-Type": "application/octet-stream",
4908
+ "Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent(file2.name)}`,
4909
+ "X-Content-Type-Options": "nosniff"
4910
+ });
4911
+ const stream = fs7.createReadStream(filename2);
4912
+ res.once("close", () => stream.destroy());
4913
+ stream.on("error", () => res.destroy()).pipe(res);
4914
+ return;
4915
+ }
4335
4916
  const thread = url2.pathname.match(
4336
4917
  /^\/api\/comments\/([a-zA-Z0-9_-]+)\/(reply|resolve|reopen|range|react)$/
4337
4918
  );
@@ -4365,6 +4946,7 @@ async function startStudio({
4365
4946
  (await body(req)).emoji
4366
4947
  )
4367
4948
  );
4949
+ const replyInput = await body(req, 71 * 1024 * 1024);
4368
4950
  return json(
4369
4951
  res,
4370
4952
  200,
@@ -4372,7 +4954,8 @@ async function startStudio({
4372
4954
  root,
4373
4955
  thread[1],
4374
4956
  thread[2],
4375
- (await body(req)).text
4957
+ replyInput.text,
4958
+ replyInput.attachments
4376
4959
  )
4377
4960
  );
4378
4961
  }
@@ -4422,22 +5005,22 @@ async function startStudio({
4422
5005
  if (!state.meta) throw new Error("Load a scene before exporting");
4423
5006
  const settings2 = validateExportSettings(input, state.meta);
4424
5007
  const format2 = exportFormats[settings2.format];
4425
- const filename2 = input.filename ?? `${path17.basename(scene, ".tsx")}-${Date.now()}.${format2.extension}`;
5008
+ const filename2 = input.filename ?? `${path21.basename(scene, ".tsx")}-${Date.now()}.${format2.extension}`;
4426
5009
  if (typeof filename2 !== "string" || !/^[\w .-]+$/.test(filename2) || !filename2.toLowerCase().endsWith(`.${format2.extension}`) || filename2.startsWith("."))
4427
5010
  throw new Error(`Use a filename ending in .${format2.extension}`);
4428
- const folder = path17.join(root, "exports");
4429
- await fs6.promises.mkdir(folder, { recursive: true });
4430
- const canonicalFolder = await realpath(folder);
4431
- if (path17.relative(root, canonicalFolder).startsWith(".."))
5011
+ const folder = path21.join(root, "exports");
5012
+ await fs7.promises.mkdir(folder, { recursive: true });
5013
+ const canonicalFolder = await realpath3(folder);
5014
+ if (path21.relative(root, canonicalFolder).startsWith(".."))
4432
5015
  throw new Error("Export folder must stay inside the project");
4433
- const destination = path17.join(canonicalFolder, filename2);
5016
+ const destination = path21.join(canonicalFolder, filename2);
4434
5017
  if (closed || project !== snapshot || state.loading || editing || dirty)
4435
5018
  throw Object.assign(
4436
5019
  new Error("The scene changed before export. Try again."),
4437
5020
  { status: 409 }
4438
5021
  );
4439
5022
  const job = state.export = {
4440
- id: randomUUID6(),
5023
+ id: randomUUID9(),
4441
5024
  status: "rendering",
4442
5025
  phase: "preparing",
4443
5026
  filename: filename2,
@@ -4484,7 +5067,7 @@ async function startStudio({
4484
5067
  }
4485
5068
  }
4486
5069
  if (req.method === "GET" && url2.pathname === "/api/export/download" && state.export?.status === "complete" && (!url2.searchParams.has("id") || url2.searchParams.get("id") === state.export.id)) {
4487
- const bytes2 = await readFile9(state.export.path);
5070
+ const bytes2 = await readFile10(state.export.path);
4488
5071
  res.writeHead(200, {
4489
5072
  "Content-Type": state.export.filename.toLowerCase().endsWith(".webm") ? "video/webm" : state.export.filename.toLowerCase().endsWith(".mov") ? "video/quicktime" : "video/mp4",
4490
5073
  "Content-Disposition": `attachment; filename="${state.export.filename}"`
@@ -4510,7 +5093,7 @@ async function startStudio({
4510
5093
  file
4511
5094
  ))
4512
5095
  return json(res, 404, { error: "Not found" });
4513
- const types = {
5096
+ const types2 = {
4514
5097
  ".html": "text/html",
4515
5098
  ".wasm": "application/wasm",
4516
5099
  ".js": "text/javascript",
@@ -4518,13 +5101,13 @@ async function startStudio({
4518
5101
  ".woff2": "font/woff2",
4519
5102
  ".svg": "image/svg+xml"
4520
5103
  };
4521
- const bytes = await readFile9(path17.join(studioRoot, file));
5104
+ const bytes = await readFile10(path21.join(studioRoot, file));
4522
5105
  res.setHeader(
4523
5106
  "Content-Security-Policy",
4524
5107
  "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; media-src 'self' blob:; connect-src 'self' blob:; font-src 'self'; frame-ancestors 'none'"
4525
5108
  );
4526
5109
  res.writeHead(200, {
4527
- "Content-Type": types[path17.extname(file)],
5110
+ "Content-Type": types2[path21.extname(file)],
4528
5111
  "Cache-Control": "no-cache"
4529
5112
  });
4530
5113
  res.end(bytes);
@@ -4651,16 +5234,16 @@ Ctrl+C stops the server and renderer.`
4651
5234
  );
4652
5235
  openBrowser(url);
4653
5236
  try {
4654
- watcher = fs6.watch(root, { recursive: true }, async (event, filename2) => {
5237
+ watcher = fs7.watch(root, { recursive: true }, async (event, filename2) => {
4655
5238
  const name = String(filename2 ?? "");
4656
5239
  if (/(^|[/\\])\.osmo-edit-/.test(name)) return;
4657
5240
  if (name === "storyboard.canvas.json" || name.startsWith("storyboard.canvas.json."))
4658
5241
  return;
4659
5242
  if (/(^|[/\\])(node_modules|\.git|\.osmo|exports)([/\\]|$)/.test(name))
4660
5243
  return;
4661
- if (name.startsWith("resources/") && !dependencies.includes(path17.join(root, name)))
5244
+ if ((name.startsWith("resources/") || name === "assets" || name.startsWith("assets/")) && !dependencies.includes(path21.join(root, name)))
4662
5245
  return;
4663
- const file = path17.join(root, name), before = watchedStamps.get(file);
5246
+ const file = path21.join(root, name), before = watchedStamps.get(file);
4664
5247
  const next = await fingerprint([file]);
4665
5248
  watchedStamps.set(file, next);
4666
5249
  if (closed || editing) return;
@@ -4685,6 +5268,10 @@ var studioStamp, studioDigest;
4685
5268
  var init_server = __esm({
4686
5269
  "src/server.ts"() {
4687
5270
  "use strict";
5271
+ init_attachment_thumbnail();
5272
+ init_local_attachment_picker();
5273
+ init_asset_import();
5274
+ init_comment_attachments();
4688
5275
  init_export_settings();
4689
5276
  init_storyboard2();
4690
5277
  init_timeline_editor();
@@ -4702,7 +5289,7 @@ var init_server = __esm({
4702
5289
  // src/studio-cli.ts
4703
5290
  init_check();
4704
5291
  import { spawnSync as spawnSync4 } from "node:child_process";
4705
- import path18 from "node:path";
5292
+ import path22 from "node:path";
4706
5293
  import { parseArgs } from "node:util";
4707
5294
 
4708
5295
  // src/cloud.ts
@@ -4879,8 +5466,8 @@ async function withCredentials(origin, action) {
4879
5466
  }
4880
5467
  try {
4881
5468
  try {
4882
- const stat2 = await lstat(file);
4883
- if (!stat2.isFile()) throw new Error("Not a regular file");
5469
+ const stat4 = await lstat(file);
5470
+ if (!stat4.isFile()) throw new Error("Not a regular file");
4884
5471
  await chmod(file, 384);
4885
5472
  state = JSON.parse(await readFile(file, "utf8"));
4886
5473
  if (state.version !== 1 || state.origin !== origin || state.credentials && !validCredentials(state.credentials) || state.pending && (!validClient(state.pending.client) || typeof state.pending.sessionId !== "string" || typeof state.pending.deviceCode !== "string" || typeof state.pending.code !== "string" || !Number.isFinite(state.pending.expiresAt) || !Number.isFinite(state.pending.nextPollAt) || !Number.isFinite(state.pending.interval) || state.pending.interval < 5 || !validApprovalUrl(state.pending.url, origin)))
@@ -5417,7 +6004,7 @@ function inspectScene(text, filename2, meta, definitions = {}) {
5417
6004
  );
5418
6005
  if (parent === null && type === "Group") props.start = 0;
5419
6006
  const id = typeof props.id === "string" ? `source:${props.id}` : `node-${rows.length}`, local = new Map(scope);
5420
- const ownKnobs = type === "Group" && props.knobs instanceof Reference ? knobs.get(props.knobs.set) : null;
6007
+ const ownKnobs = ["Group", "Scene"].includes(type) && props.knobs instanceof Reference ? knobs.get(props.knobs.set) : null;
5421
6008
  if (ownKnobs)
5422
6009
  local.set(
5423
6010
  props.knobs.set,
@@ -6581,22 +7168,22 @@ init_comments();
6581
7168
 
6582
7169
  // src/init.ts
6583
7170
  import {
6584
- mkdir as mkdir5,
6585
- readFile as readFile6,
6586
- writeFile as writeFile5,
7171
+ mkdir as mkdir6,
7172
+ readFile as readFile7,
7173
+ writeFile as writeFile6,
6587
7174
  copyFile,
6588
7175
  cp as cp2,
6589
7176
  access as access2,
6590
7177
  mkdtemp,
6591
- rm as rm5
7178
+ rm as rm6
6592
7179
  } from "node:fs/promises";
6593
- import path10 from "node:path";
7180
+ import path12 from "node:path";
6594
7181
 
6595
7182
  // src/authoring.ts
6596
7183
  init_runtime();
6597
- import { readFile as readFile4, writeFile as writeFile3, rm as rm4 } from "node:fs/promises";
7184
+ import { readFile as readFile5, writeFile as writeFile4, rm as rm5 } from "node:fs/promises";
6598
7185
  import { createHash as createHash3 } from "node:crypto";
6599
- import path8 from "node:path";
7186
+ import path10 from "node:path";
6600
7187
  var begin = "<!-- osmo:begin -->";
6601
7188
  var end = "<!-- osmo:end -->";
6602
7189
  var legacyPointer = "For Osmo animation tasks, read osmo.md before editing. Use osmo studio check, and read open Studio comments.";
@@ -6661,9 +7248,13 @@ Read relevant declarations in node_modules/@osmo/scene/dist/ (JSDoc explains usa
6661
7248
  - Use explicit JSX, literal values/arrays/objects, and const defineMotion or defineKnobs declarations. Bind tracks/knobs directly to compatible props.
6662
7249
  - No arbitrary expressions, loops, callbacks, spreads, import aliases, custom components, helper calls or computed variables. Only blur({...}) and grain({...}) effect calls are allowed in JSX; pass an explicit object, even for defaults.
6663
7250
  - Shader.src must be a static relative local .wgsl path. Put complete uniform values inline; numeric uniforms can use motion/knob tracks. defineEffect remains unsupported.
6664
- - Keyframe at values are increasing local seconds (scene unless inside a timed clip); easing belongs to the arriving key. Add a key at 0 to define a continuous track's starting value. Tracks do not loop automatically.
7251
+ - Keyframe at values are increasing local seconds (scene unless inside a timed clip); easing belongs to the arriving key. Custom easing uses "cubic-bezier(x1,y1,x2,y2)" with x control points in 0..1; use curves between key poses instead of sampling every frame. Add a key at 0 to define a continuous track's starting value. Tracks do not loop automatically.
6665
7252
  - Group knobs can drive many descendant props; Group.values accepts motion tracks.
7253
+ - Scene also accepts knobs/values. Imported Scene instances accept values to override declared controls; forward parent refs explicitly. Each instance is independent.
7254
+ - Asset knobs: { type: "asset", kind: "image" | "video" | "audio" | "font" | "model", default: "./local-file" }. Bind to matching src/font. Assets are static; paths retain the file that declared/supplied them across nested bindings. Model files are GLB; surfaces are not auto-remapped.
7255
+ - Scale is one number applied equally to both axes; animate width/height to change a shape\u2019s proportions. Two-value scale arrays are not supported.
6666
7256
  - Units: pixels, degrees, seconds; +z toward viewer. Asset/font paths are relative to their declaring file.
7257
+ - Text.animate animates a whole string by character, word, or wrapped line: {unit:"character",stagger:0.05,duration:0.4,easing:"easeOut",from:{opacity:0,position:[0,20]},to:{opacity:1,position:[0,0]}}. Per-unit properties: position, z, rotation, uniform scale, origin, opacity, color, blur. Use keyframes instead of from/to/duration for multiple stages. Keep font/layout properties on Text. Do not create separate letter labels; animate cannot combine with path.
6667
7258
  - Text.path handles the complete string; no width/align/lineHeight or line breaks. SVG geometry is static; progress and ellipse radii can animate.
6668
7259
  - Path draws SVG d in local pixels; use scale to resize. d accepts a literal or a defineMotion string track for baked geometry (discrete switches, not SVG interpolation). Transforms, stroke width and effects can animate. Use fill="transparent" for lines.
6669
7260
 
@@ -6676,14 +7267,14 @@ ${end}
6676
7267
  `;
6677
7268
  async function optional(file) {
6678
7269
  try {
6679
- return await readFile4(file, "utf8");
7270
+ return await readFile5(file, "utf8");
6680
7271
  } catch (error) {
6681
7272
  if (error.code === "ENOENT") return "";
6682
7273
  throw error;
6683
7274
  }
6684
7275
  }
6685
7276
  async function writeAuthoringGuide(root) {
6686
- const file = path8.join(root, "AGENTS.md");
7277
+ const file = path10.join(root, "AGENTS.md");
6687
7278
  const original = await optional(file);
6688
7279
  const hasBegin = original.includes(begin), hasEnd = original.includes(end);
6689
7280
  if (hasBegin !== hasEnd || hasBegin && original.indexOf(end) < original.indexOf(begin))
@@ -6697,10 +7288,10 @@ async function writeAuthoringGuide(root) {
6697
7288
  const rest = original.split("\n").filter((line) => line !== legacyPointer).join("\n");
6698
7289
  text = rest + (rest && !rest.endsWith("\n\n") ? rest.endsWith("\n") ? "\n" : "\n\n" : "") + authoringGuide;
6699
7290
  }
6700
- if (text !== original) await writeFile3(file, text);
6701
- const legacy = await optional(path8.join(root, "osmo.md"));
7291
+ if (text !== original) await writeFile4(file, text);
7292
+ const legacy = await optional(path10.join(root, "osmo.md"));
6702
7293
  if (legacy && createHash3("sha256").update(legacy).digest("hex") === legacyGuideHash)
6703
- await rm4(path8.join(root, "osmo.md"));
7294
+ await rm5(path10.join(root, "osmo.md"));
6704
7295
  else if (legacy)
6705
7296
  console.log(
6706
7297
  "Existing osmo.md was preserved because it differs from the original generated guide. Osmo instructions now live in AGENTS.md."
@@ -6712,38 +7303,38 @@ import os2 from "node:os";
6712
7303
 
6713
7304
  // src/starter.ts
6714
7305
  init_runtime();
6715
- import { access, mkdir as mkdir4, readFile as readFile5, readdir as readdir2, writeFile as writeFile4, cp } from "node:fs/promises";
6716
- import path9 from "node:path";
6717
- var template = path9.join(packageRoot, "src/starter/");
7306
+ import { access, mkdir as mkdir5, readFile as readFile6, readdir as readdir2, writeFile as writeFile5, cp } from "node:fs/promises";
7307
+ import path11 from "node:path";
7308
+ var template = path11.join(packageRoot, "src/starter/");
6718
7309
  async function writeStarter(root) {
6719
- const scene = path9.join(root, "scene.tsx");
7310
+ const scene = path11.join(root, "scene.tsx");
6720
7311
  try {
6721
7312
  await access(scene);
6722
7313
  return;
6723
7314
  } catch (error) {
6724
7315
  if (error.code !== "ENOENT") throw error;
6725
7316
  }
6726
- await mkdir4(path9.join(root, "animations"), { recursive: true });
7317
+ await mkdir5(path11.join(root, "animations"), { recursive: true });
6727
7318
  let folder = "osmo";
6728
7319
  for (let suffix = 1; ; suffix++) {
6729
7320
  try {
6730
- await mkdir4(path9.join(root, "animations", folder));
7321
+ await mkdir5(path11.join(root, "animations", folder));
6731
7322
  break;
6732
7323
  } catch (error) {
6733
7324
  if (error.code !== "EEXIST") throw error;
6734
7325
  folder = `osmo-${suffix}`;
6735
7326
  }
6736
7327
  }
6737
- for (const file of await readdir2(path9.join(template, "animations/osmo"))) {
6738
- await cp(path9.join(template, "animations/osmo", file), path9.join(root, "animations", folder, file), {
7328
+ for (const file of await readdir2(path11.join(template, "animations/osmo"))) {
7329
+ await cp(path11.join(template, "animations/osmo", file), path11.join(root, "animations", folder, file), {
6739
7330
  recursive: true,
6740
7331
  force: false,
6741
7332
  errorOnExist: true
6742
7333
  });
6743
7334
  }
6744
- const source = (await readFile5(path9.join(template, "scene.tsx"), "utf8")).replaceAll("./animations/osmo/", `./animations/${folder}/`);
7335
+ const source = (await readFile6(path11.join(template, "scene.tsx"), "utf8")).replaceAll("./animations/osmo/", `./animations/${folder}/`);
6745
7336
  try {
6746
- await writeFile4(scene, source, {
7337
+ await writeFile5(scene, source, {
6747
7338
  flag: "wx"
6748
7339
  });
6749
7340
  } catch (error) {
@@ -6764,7 +7355,7 @@ async function exists(p) {
6764
7355
  }
6765
7356
  async function create(p, value) {
6766
7357
  try {
6767
- await writeFile5(p, value, { flag: "wx" });
7358
+ await writeFile6(p, value, { flag: "wx" });
6768
7359
  return true;
6769
7360
  } catch (e) {
6770
7361
  if (e.code !== "EEXIST") throw e;
@@ -6773,7 +7364,7 @@ async function create(p, value) {
6773
7364
  }
6774
7365
  async function init(root = process.cwd()) {
6775
7366
  nativeBinary();
6776
- const gitProbe = await mkdtemp(path10.join(os2.tmpdir(), "osmo-git-check-"));
7367
+ const gitProbe = await mkdtemp(path12.join(os2.tmpdir(), "osmo-git-check-"));
6777
7368
  try {
6778
7369
  const result = spawnSync3(
6779
7370
  "git",
@@ -6785,7 +7376,7 @@ async function init(root = process.cwd()) {
6785
7376
  "Osmo projects require Git with SHA-256 support (Git 2.29+). Install or update Git, then retry osmo studio init. Sharing an existing MP4 with osmo share does not require Git."
6786
7377
  );
6787
7378
  } finally {
6788
- await rm5(gitProbe, { recursive: true, force: true });
7379
+ await rm6(gitProbe, { recursive: true, force: true });
6789
7380
  }
6790
7381
  for (const [name, variable] of [
6791
7382
  ["ffmpeg", "OSMO_FFMPEG"],
@@ -6810,15 +7401,15 @@ async function init(root = process.cwd()) {
6810
7401
  );
6811
7402
  }
6812
7403
  }
6813
- root = path10.resolve(root);
6814
- await mkdir5(root, { recursive: true });
7404
+ root = path12.resolve(root);
7405
+ await mkdir6(root, { recursive: true });
6815
7406
  if (hasRepo(root) && objectFormat(root) !== "sha256")
6816
7407
  throw new Error(
6817
7408
  `${root} is already a git repository with ${objectFormat(root) ?? "unknown"} objects. Osmo projects are SHA-256 repositories: run osmo studio init in a new folder.`
6818
7409
  );
6819
- const manifestPath = path10.join(root, "package.json");
6820
- const manifest2 = await exists(manifestPath) ? JSON.parse(await readFile6(manifestPath, "utf8")) : {
6821
- name: path10.basename(root).toLowerCase().replace(/[^a-z0-9-]/g, "-") || "osmo-project",
7410
+ const manifestPath = path12.join(root, "package.json");
7411
+ const manifest2 = await exists(manifestPath) ? JSON.parse(await readFile7(manifestPath, "utf8")) : {
7412
+ name: path12.basename(root).toLowerCase().replace(/[^a-z0-9-]/g, "-") || "osmo-project",
6822
7413
  private: true,
6823
7414
  type: "module"
6824
7415
  };
@@ -6828,58 +7419,58 @@ async function init(root = process.cwd()) {
6828
7419
  throw new Error(
6829
7420
  `Existing @osmo/scene dependency (${previous}) was preserved. Initialize a new folder for this release.`
6830
7421
  );
6831
- const configFile = path10.join(root, ".osmo/project.json");
7422
+ const configFile = path12.join(root, ".osmo/project.json");
6832
7423
  if (await exists(configFile)) {
6833
- const config = JSON.parse(await readFile6(configFile, "utf8"));
7424
+ const config = JSON.parse(await readFile7(configFile, "utf8"));
6834
7425
  assertCompatibleProject(config);
6835
7426
  }
6836
- const vendor = path10.join(root, ".osmo/vendor");
6837
- await mkdir5(vendor, { recursive: true });
6838
- await mkdir5(path10.join(root, "assets"), { recursive: true });
6839
- const target = path10.join(vendor, `osmo-scene-${sceneVersion}.tgz`);
7427
+ const vendor = path12.join(root, ".osmo/vendor");
7428
+ await mkdir6(vendor, { recursive: true });
7429
+ await mkdir6(path12.join(root, "assets"), { recursive: true });
7430
+ const target = path12.join(vendor, `osmo-scene-${sceneVersion}.tgz`);
6840
7431
  let refreshScene = false;
6841
- const bundled = path10.join(
7432
+ const bundled = path12.join(
6842
7433
  packageRoot,
6843
7434
  "vendor",
6844
7435
  `osmo-scene-${sceneVersion}.tgz`
6845
7436
  );
6846
7437
  if (await exists(bundled)) {
6847
- if (!await exists(target) || !(await readFile6(target)).equals(await readFile6(bundled))) {
7438
+ if (!await exists(target) || !(await readFile7(target)).equals(await readFile7(bundled))) {
6848
7439
  await copyFile(bundled, target);
6849
7440
  refreshScene = true;
6850
7441
  }
6851
7442
  } else {
6852
- const scratch = await mkdtemp(path10.join(os2.tmpdir(), "osmo-scene-pack-"));
7443
+ const scratch = await mkdtemp(path12.join(os2.tmpdir(), "osmo-scene-pack-"));
6853
7444
  try {
6854
7445
  await run("npm", ["run", "build"], {
6855
- cwd: path10.join(runtimeRoot, "packages/scene")
7446
+ cwd: path12.join(runtimeRoot, "packages/scene")
6856
7447
  });
6857
- const sceneRoot = path10.join(runtimeRoot, "packages/scene");
7448
+ const sceneRoot = path12.join(runtimeRoot, "packages/scene");
6858
7449
  const { scripts, devDependencies, ...scenePackage } = JSON.parse(
6859
- await readFile6(path10.join(sceneRoot, "package.json"), "utf8")
7450
+ await readFile7(path12.join(sceneRoot, "package.json"), "utf8")
6860
7451
  );
6861
- await writeFile5(
6862
- path10.join(scratch, "package.json"),
7452
+ await writeFile6(
7453
+ path12.join(scratch, "package.json"),
6863
7454
  JSON.stringify({ ...scenePackage, files: ["dist"] }, null, 2)
6864
7455
  );
6865
- await cp2(path10.join(sceneRoot, "dist"), path10.join(scratch, "dist"), {
7456
+ await cp2(path12.join(sceneRoot, "dist"), path12.join(scratch, "dist"), {
6866
7457
  recursive: true
6867
7458
  });
6868
7459
  await run("npm", ["pack", "--pack-destination", scratch], {
6869
7460
  cwd: scratch
6870
7461
  });
6871
- const packed = path10.join(scratch, `osmo-scene-${sceneVersion}.tgz`);
6872
- if (!await exists(target) || !(await readFile6(target)).equals(await readFile6(packed))) {
7462
+ const packed = path12.join(scratch, `osmo-scene-${sceneVersion}.tgz`);
7463
+ if (!await exists(target) || !(await readFile7(target)).equals(await readFile7(packed))) {
6873
7464
  await copyFile(packed, target);
6874
7465
  refreshScene = true;
6875
7466
  }
6876
7467
  } finally {
6877
- await rm5(scratch, { recursive: true, force: true });
7468
+ await rm6(scratch, { recursive: true, force: true });
6878
7469
  }
6879
7470
  }
6880
7471
  if (!previous) {
6881
7472
  manifest2.dependencies = { ...manifest2.dependencies, "@osmo/scene": spec };
6882
- await writeFile5(manifestPath, JSON.stringify(manifest2, null, 2) + "\n");
7473
+ await writeFile6(manifestPath, JSON.stringify(manifest2, null, 2) + "\n");
6883
7474
  }
6884
7475
  const tsconfig = JSON.stringify(
6885
7476
  {
@@ -6898,7 +7489,7 @@ async function init(root = process.cwd()) {
6898
7489
  null,
6899
7490
  2
6900
7491
  ) + "\n";
6901
- await create(path10.join(root, "tsconfig.json"), tsconfig);
7492
+ await create(path12.join(root, "tsconfig.json"), tsconfig);
6902
7493
  await writeStarter(root);
6903
7494
  await create(
6904
7495
  configFile,
@@ -6909,8 +7500,8 @@ async function init(root = process.cwd()) {
6909
7500
  ) + "\n"
6910
7501
  );
6911
7502
  await writeAuthoringGuide(root);
6912
- const ignorePath = path10.join(root, ".gitignore");
6913
- const ignore = await exists(ignorePath) ? await readFile6(ignorePath, "utf8") : "";
7503
+ const ignorePath = path12.join(root, ".gitignore");
7504
+ const ignore = await exists(ignorePath) ? await readFile7(ignorePath, "utf8") : "";
6914
7505
  const rules = [
6915
7506
  "node_modules/",
6916
7507
  "exports/",
@@ -6927,7 +7518,7 @@ async function init(root = process.cwd()) {
6927
7518
  ];
6928
7519
  const missing = rules.filter((rule) => !ignore.split("\n").includes(rule));
6929
7520
  if (missing.length)
6930
- await writeFile5(
7521
+ await writeFile6(
6931
7522
  ignorePath,
6932
7523
  ignore + (ignore && !ignore.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n"
6933
7524
  );
@@ -6985,7 +7576,7 @@ async function studioCommand(argv, help) {
6985
7576
  if (args[0] === "studio" && args[1] === "clone") {
6986
7577
  if (args.length !== 4)
6987
7578
  throw new Error("Usage: osmo studio clone PROJECT_ID folder");
6988
- const result = await clone(args[2], path18.resolve(args[3]));
7579
+ const result = await clone(args[2], path22.resolve(args[3]));
6989
7580
  console.log(
6990
7581
  flags.json ? JSON.stringify(result) : `Cloned ${result.name} (${result.revisions} revision${result.revisions === 1 ? "" : "s"}) into ${args[3]}.
6991
7582
  Next: cd ${args[3]} && osmo studio open scene.tsx`
@@ -7101,7 +7692,7 @@ ${c.text}${c.replies.map((r) => "\n \u21B3 " + r.text).join("")}`
7101
7692
  throw new Error(
7102
7693
  "--frame must be a non-negative integer (frame 0 is the first frame)."
7103
7694
  );
7104
- if (!flags.out || path18.extname(flags.out).toLowerCase() !== (still ? ".png" : ".mp4"))
7695
+ if (!flags.out || path22.extname(flags.out).toLowerCase() !== (still ? ".png" : ".mp4"))
7105
7696
  throw new Error(
7106
7697
  still ? "With --frame, provide --out frame.png" : "Provide --out animation.mp4, or --frame NUMBER --out frame.png"
7107
7698
  );
@@ -7114,7 +7705,7 @@ ${c.text}${c.replies.map((r) => "\n \u21B3 " + r.text).join("")}`
7114
7705
  nativeBinary(),
7115
7706
  [
7116
7707
  source,
7117
- ...still ? ["--snapshot", path18.resolve(flags.out), "--frame", flags.frame] : ["--export", path18.resolve(flags.out)],
7708
+ ...still ? ["--snapshot", path22.resolve(flags.out), "--frame", flags.frame] : ["--export", path22.resolve(flags.out)],
7118
7709
  "--supersample",
7119
7710
  quality
7120
7711
  ],