@1agh/maude 0.39.0 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +1 -1
  2. package/apps/studio/acp/bridge.ts +144 -5
  3. package/apps/studio/acp/index.ts +11 -1
  4. package/apps/studio/annotations-context-toolbar.tsx +44 -1
  5. package/apps/studio/annotations-layer.tsx +253 -3
  6. package/apps/studio/annotations-model.ts +107 -6
  7. package/apps/studio/api.ts +812 -64
  8. package/apps/studio/bin/_html-playwright.mjs +9 -1
  9. package/apps/studio/bin/_pdf-playwright.mjs +8 -1
  10. package/apps/studio/bin/_png-playwright.mjs +8 -1
  11. package/apps/studio/bin/_pw-launch.mjs +54 -0
  12. package/apps/studio/bin/_svg-playwright.mjs +8 -1
  13. package/apps/studio/bin/_video-playwright.mjs +452 -0
  14. package/apps/studio/bin/prep.sh +8 -1
  15. package/apps/studio/canvas-edit.ts +1885 -104
  16. package/apps/studio/canvas-lib.tsx +19 -0
  17. package/apps/studio/canvas-list-watch.ts +6 -2
  18. package/apps/studio/canvas-shell.tsx +27 -0
  19. package/apps/studio/client/app.jsx +1029 -30
  20. package/apps/studio/client/github.js +7 -0
  21. package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
  22. package/apps/studio/client/panels/timeline-parse.js +229 -0
  23. package/apps/studio/client/panels/timeline-snap.js +55 -0
  24. package/apps/studio/client/styles/3-shell-maude.css +107 -0
  25. package/apps/studio/config.schema.json +14 -0
  26. package/apps/studio/context-menu.tsx +1 -1
  27. package/apps/studio/context.ts +113 -1
  28. package/apps/studio/dist/client.bundle.js +88 -16
  29. package/apps/studio/dist/comment-mount.js +1 -1
  30. package/apps/studio/dist/runtime/.min-sizes.json +11 -1
  31. package/apps/studio/dist/runtime/@remotion_media.js +491 -0
  32. package/apps/studio/dist/runtime/@remotion_player.js +56 -0
  33. package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
  34. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
  35. package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
  36. package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
  37. package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
  38. package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
  39. package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
  40. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
  41. package/apps/studio/dist/runtime/remotion.js +42 -0
  42. package/apps/studio/dist/styles.css +1 -1
  43. package/apps/studio/exporters/_browser-bundles.ts +117 -0
  44. package/apps/studio/exporters/_runtime.ts +69 -0
  45. package/apps/studio/exporters/html.ts +4 -3
  46. package/apps/studio/exporters/index.ts +28 -2
  47. package/apps/studio/exporters/pdf.ts +4 -3
  48. package/apps/studio/exporters/png.ts +5 -3
  49. package/apps/studio/exporters/pptx.ts +6 -4
  50. package/apps/studio/exporters/svg.ts +9 -5
  51. package/apps/studio/exporters/video-encode-lib.ts +200 -0
  52. package/apps/studio/exporters/video-render-lib.ts +108 -0
  53. package/apps/studio/exporters/video.ts +184 -0
  54. package/apps/studio/http.ts +535 -27
  55. package/apps/studio/input-router.tsx +7 -1
  56. package/apps/studio/runtime-bundle.ts +30 -0
  57. package/apps/studio/server.ts +34 -9
  58. package/apps/studio/test/acp-bridge.test.ts +132 -0
  59. package/apps/studio/test/acp-commands.test.ts +8 -3
  60. package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
  61. package/apps/studio/test/canvas-create-api.test.ts +76 -0
  62. package/apps/studio/test/canvas-edit.test.ts +90 -0
  63. package/apps/studio/test/canvas-list-watch.test.ts +49 -0
  64. package/apps/studio/test/canvas-media-drop.test.ts +30 -1
  65. package/apps/studio/test/canvas-origin-gate.test.ts +36 -0
  66. package/apps/studio/test/clip-addressing.test.ts +732 -0
  67. package/apps/studio/test/config-reload.test.ts +230 -0
  68. package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
  69. package/apps/studio/test/edit-persistence.test.ts +91 -0
  70. package/apps/studio/test/exporters/runtime.test.ts +59 -0
  71. package/apps/studio/test/file-lock.test.ts +84 -0
  72. package/apps/studio/test/fixtures/mock-acp-agent.mjs +24 -1
  73. package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
  74. package/apps/studio/test/timeline-parse.test.ts +127 -0
  75. package/apps/studio/test/timeline-snap.test.ts +85 -0
  76. package/apps/studio/test/video-asset.test.ts +163 -0
  77. package/apps/studio/test/video-comp-fixture.test.ts +50 -0
  78. package/apps/studio/test/video-comp.test.ts +168 -0
  79. package/apps/studio/test/video-render-bridge.test.ts +149 -0
  80. package/apps/studio/use-annotation-resize.tsx +6 -3
  81. package/apps/studio/use-canvas-media-drop.tsx +66 -4
  82. package/apps/studio/video-comp.tsx +444 -0
  83. package/apps/studio/whats-new.json +36 -0
  84. package/apps/studio/ws.ts +5 -0
  85. package/package.json +8 -8
  86. package/plugins/design/templates/_shell.html +25 -2
  87. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
@@ -8,12 +8,23 @@ import path from 'node:path';
8
8
 
9
9
  import { renderBriefBoard, validateCanvasName } from './canvas-create.ts';
10
10
  import {
11
+ type AssembleClip,
12
+ assembleCompSource,
11
13
  CanvasEditError,
14
+ type ClipInfo,
15
+ editArrayElementString,
12
16
  editAttribute,
17
+ enumerateClips,
18
+ insertClip,
13
19
  type MovePosition,
14
20
  moveElement,
15
21
  removeAttribute,
22
+ removeClip,
23
+ reorderClip,
24
+ retimeSequence,
25
+ retimeSequenceByClip,
16
26
  editText as runEditText,
27
+ toggleClipHidden,
17
28
  } from './canvas-edit.ts';
18
29
  import type { Context } from './context.ts';
19
30
  import { createHistory } from './history.ts';
@@ -180,7 +191,7 @@ export type DeleteCanvasResult =
180
191
 
181
192
  /** Phase 12 — result of an in-canvas direct edit (`editCss` / `editText`). */
182
193
  export type EditOpResult =
183
- | { ok: true; delta: number }
194
+ | { ok: true; delta: number; seq?: number }
184
195
  | { ok: false; status: number; error: string };
185
196
 
186
197
  /**
@@ -230,6 +241,10 @@ export interface Api {
230
241
  saveCanvasState(file: string, state: Record<string, unknown>): Promise<void>;
231
242
  // Canvas meta sidecar (Phase 4 T5 — .design/ui/<slug>.meta.json)
232
243
  loadCanvasMeta(file: string): Promise<Record<string, unknown> | null>;
244
+ /** DDR-148 — raw .tsx source for the Timeline sequence/keyframe parser. */
245
+ loadCanvasSource(
246
+ file: unknown
247
+ ): Promise<{ ok: true; source: string } | { ok: false; status: number; error: string }>;
233
248
  patchCanvasMeta(
234
249
  file: string,
235
250
  patch: Record<string, unknown>
@@ -239,17 +254,25 @@ export interface Api {
239
254
  saveAnnotations(file: string, svg: string): Promise<boolean>;
240
255
  // Phase 23 — content-addressed binary image write (drag-drop / paste / picker)
241
256
  saveAsset(bytes: Uint8Array): Promise<SaveAssetResult>;
257
+ /** DDR-148 — streaming variant for the HTTP route (100 MB video without a
258
+ * full in-RAM buffer). Sniffs + caps + content-addresses like saveAsset. */
259
+ saveAssetFromStream(stream: ReadableStream<Uint8Array>): Promise<SaveAssetResult>;
242
260
  // Persist a clipboard-pasted ACP composer image → runtime `_chat/attachments/`,
243
261
  // returns an absolute path (Phase 31 follow-up — POST /_api/acp/attachment).
244
262
  saveChatAttachment(bytes: Uint8Array): Promise<SaveAssetResult>;
245
263
  // Resolve a content-addressed attachment name (`<sha8>.<ext>`) to its absolute
246
264
  // path, or null (GET /_api/acp/attachment — the read side of the pair above).
247
265
  resolveChatAttachment(name: unknown): Promise<string | null>;
248
- // Create a blank brief board from the browser (Phase 22 POST /_api/canvas)
266
+ // Create a blank brief board OR an assembled video-comp from the browser
267
+ // (Phase 22 — POST /_api/canvas; DDR-150 P4 Task 12 adds kind "video-comp").
249
268
  createCanvas(input: {
250
269
  name?: unknown;
251
270
  kind?: unknown;
252
271
  group?: unknown;
272
+ clips?: unknown;
273
+ fps?: unknown;
274
+ width?: unknown;
275
+ height?: unknown;
253
276
  }): Promise<CreateCanvasResult>;
254
277
  // Soft-delete a canvas from the browser (Phase 22 — DELETE /_api/canvas)
255
278
  deleteCanvas(input: { file?: unknown }): Promise<DeleteCanvasResult>;
@@ -281,6 +304,85 @@ export interface Api {
281
304
  refId?: unknown;
282
305
  position?: unknown;
283
306
  }): Promise<ReorderOpResult>;
307
+ /** DDR-148 — Timeline drag-to-retime a sequence's durationInFrames / from. */
308
+ retimeSequenceOp(input: {
309
+ canvas?: unknown;
310
+ // DDR-150 P2 — prefer stableId (comp-scoped, multi-comp-safe) over index.
311
+ stableId?: unknown;
312
+ artboardId?: unknown;
313
+ contentHash?: unknown;
314
+ index?: unknown;
315
+ durationInFrames?: unknown;
316
+ from?: unknown;
317
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
318
+ // DDR-150 P3 — remove a clip addressed by stableId (fingerprint + semantic
319
+ // gate; refuses the only clip; drops an adjacent transition in a series).
320
+ removeSequenceOp(input: {
321
+ canvas?: unknown;
322
+ stableId?: unknown;
323
+ artboardId?: unknown;
324
+ contentHash?: unknown;
325
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
326
+ // DDR-150 P4 — insert a new <Sequence> (optionally with media) after a comp's
327
+ // last clip. Returns the new clip's stableId.
328
+ insertSequenceOp(input: {
329
+ canvas?: unknown;
330
+ artboardId?: unknown;
331
+ from?: unknown;
332
+ durationInFrames?: unknown;
333
+ mediaTag?: unknown;
334
+ src?: unknown;
335
+ }): Promise<
336
+ | { ok: true; stableId: string | null; seq?: number }
337
+ | { ok: false; status: number; error: string }
338
+ >;
339
+ // DDR-150 P5 — z-order reorder: move a standalone <Sequence> before/after a
340
+ // sibling (render stacking), reusing moveElement + the semantic gate. Both
341
+ // clips are fingerprint-checked. Returns the moved clip's (re-settled) stableId.
342
+ reorderSequenceOp(input: {
343
+ canvas?: unknown;
344
+ artboardId?: unknown;
345
+ stableId?: unknown;
346
+ contentHash?: unknown;
347
+ refStableId?: unknown;
348
+ refContentHash?: unknown;
349
+ position?: unknown;
350
+ }): Promise<
351
+ | { ok: true; stableId: string | null; seq?: number }
352
+ | { ok: false; status: number; error: string }
353
+ >;
354
+ // DDR-150 dogfood — replace a media src that lives in an array literal
355
+ // (the showreel `CLIPS[i].src` pattern), addressed by mediaArrayRef.
356
+ editArraySrcOp(input: {
357
+ canvas?: unknown;
358
+ arrayName?: unknown;
359
+ index?: unknown;
360
+ field?: unknown;
361
+ value?: unknown;
362
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
363
+ // DDR-150 dogfood — hide/show a clip (gates its body behind {false && …}).
364
+ toggleHideOp(input: {
365
+ canvas?: unknown;
366
+ stableId?: unknown;
367
+ artboardId?: unknown;
368
+ contentHash?: unknown;
369
+ }): Promise<
370
+ { ok: true; hidden: boolean; seq?: number } | { ok: false; status: number; error: string }
371
+ >;
372
+ // DDR-150 P2 — the single authoritative clip enumerator for a video-comp.
373
+ // Read-only; the Timeline addresses every op by the returned `stableId`
374
+ // (never a regex document-order index — the multi-comp mis-hit defect).
375
+ compClips(input: { canvas?: unknown; artboardId?: unknown }): Promise<
376
+ | {
377
+ ok: true;
378
+ compName: string | null;
379
+ artboardId: string | null;
380
+ fps: number | null;
381
+ durationInFrames: number | null;
382
+ clips: Array<Omit<ClipInfo, 'start' | 'end'>>;
383
+ }
384
+ | { ok: false; status: number; error: string }
385
+ >;
284
386
  // Undo/redo a prior reorder by seq (Cmd+Z from the canvas undo stack). Whole-
285
387
  // file content swap from the in-memory revert log — immune to the positional
286
388
  // data-cd-id churn a reorder causes (inverse-descriptor undo would go stale).
@@ -327,7 +429,10 @@ export const ASSET_MAX_BYTES = 10 * 1024 * 1024;
327
429
  */
328
430
  export const ASSET_SESSION_BUDGET = (() => {
329
431
  const env = Number(process.env.MAUDE_ASSET_SESSION_BUDGET);
330
- return Number.isFinite(env) && env > 0 ? env : 256 * 1024 * 1024;
432
+ // DDR-148 raised 256 MB 1 GB now that the route accepts video/audio (one
433
+ // 100 MB clip would blow a 256 MB budget after a couple of drops). Still an
434
+ // aggregate per-server-instance disk-fill bound; env-overridable.
435
+ return Number.isFinite(env) && env > 0 ? env : 1024 * 1024 * 1024;
331
436
  })();
332
437
 
333
438
  /**
@@ -392,6 +497,110 @@ export interface SaveAssetResult {
392
497
  path?: string;
393
498
  }
394
499
 
500
+ /** DDR-148 — media category, decides which per-file cap applies. */
501
+ export type AssetCategory = 'image' | 'video' | 'audio';
502
+
503
+ export interface AssetTypeInfo {
504
+ /** Stored file extension (bytes decide it, never the upload name). */
505
+ ext: string;
506
+ category: AssetCategory;
507
+ }
508
+
509
+ /**
510
+ * DDR-148 — per-file ceiling for time-based media (video + audio). Images keep
511
+ * the tighter {@link ASSET_MAX_BYTES} 10 MB cap. Overridable via
512
+ * `MAUDE_ASSET_MAX_VIDEO_BYTES` (bytes) for power users. The route lives on the
513
+ * (untrusted) canvas origin, so this cap + the session budget + the streamed
514
+ * write are the trust mitigation, exactly like the image caps (DDR-088).
515
+ */
516
+ export const ASSET_MAX_VIDEO_BYTES = (() => {
517
+ const env = Number(process.env.MAUDE_ASSET_MAX_VIDEO_BYTES);
518
+ return Number.isFinite(env) && env > 0 ? env : 100 * 1024 * 1024;
519
+ })();
520
+
521
+ /** The byte cap for a category. */
522
+ export function assetCapForCategory(category: AssetCategory): number {
523
+ return category === 'image' ? ASSET_MAX_BYTES : ASSET_MAX_VIDEO_BYTES;
524
+ }
525
+
526
+ const UNSUPPORTED_ASSET_MSG =
527
+ 'unsupported media type — png/jpeg/gif/webp images or mp4/mov/webm/mp3/wav/m4a media only (SVG/script rejected)';
528
+
529
+ function capError(category?: AssetCategory): string {
530
+ if (category === 'image') return 'image exceeds the 10 MB cap';
531
+ const mb = Math.round(ASSET_MAX_VIDEO_BYTES / (1024 * 1024));
532
+ return `media exceeds the ${mb} MB cap`;
533
+ }
534
+
535
+ /** Concatenate a small list of chunks (used only for the ≤ few-KB sniff head). */
536
+ function concatBytes(chunks: readonly Uint8Array[]): Uint8Array {
537
+ let len = 0;
538
+ for (const c of chunks) len += c.length;
539
+ const out = new Uint8Array(len);
540
+ let off = 0;
541
+ for (const c of chunks) {
542
+ out.set(c, off);
543
+ off += c.length;
544
+ }
545
+ return out;
546
+ }
547
+
548
+ /**
549
+ * DDR-148 — magic-byte type sniff for the WIDENED asset route: images (via
550
+ * {@link sniffImageType}) PLUS time-based media. The declared name / extension
551
+ * / Content-Type is NEVER trusted — the bytes decide the stored extension AND
552
+ * the category (→ which cap applies). The server only sniffs; it never PARSES a
553
+ * container (parsing happens in the sandboxed capture browser). Anything that
554
+ * isn't a recognised raster/video/audio magic number (SVG, HTML, arbitrary
555
+ * script) → null → rejected (415). Needs ≥ 12 bytes for the ISO-BMFF brands.
556
+ */
557
+ export function sniffAssetType(bytes: Uint8Array): AssetTypeInfo | null {
558
+ const img = sniffImageType(bytes);
559
+ if (img) return { ext: img, category: 'image' };
560
+ const b = bytes;
561
+ // ISO-BMFF (mp4 / mov / m4a): "ftyp" box at offset 4, brand at offset 8.
562
+ if (
563
+ b.length >= 12 &&
564
+ b[4] === 0x66 && // f
565
+ b[5] === 0x74 && // t
566
+ b[6] === 0x79 && // y
567
+ b[7] === 0x70 // p
568
+ ) {
569
+ const brand = String.fromCharCode(b[8] ?? 0, b[9] ?? 0, b[10] ?? 0, b[11] ?? 0);
570
+ if (brand === 'qt ') return { ext: 'mov', category: 'video' };
571
+ if (brand.startsWith('M4A')) return { ext: 'm4a', category: 'audio' };
572
+ if (brand.startsWith('M4V')) return { ext: 'm4v', category: 'video' };
573
+ // isom / mp41 / mp42 / avc1 / iso2 / iso5 / dash / mp4v / … → treat as mp4.
574
+ return { ext: 'mp4', category: 'video' };
575
+ }
576
+ // Matroska / WebM — EBML header 1A 45 DF A3.
577
+ if (b.length >= 4 && b[0] === 0x1a && b[1] === 0x45 && b[2] === 0xdf && b[3] === 0xa3) {
578
+ return { ext: 'webm', category: 'video' };
579
+ }
580
+ // MP3 — "ID3" tag OR a frame-sync (0xFF followed by 0b111xxxxx).
581
+ if (b.length >= 3 && b[0] === 0x49 && b[1] === 0x44 && b[2] === 0x33) {
582
+ return { ext: 'mp3', category: 'audio' };
583
+ }
584
+ if (b.length >= 2 && b[0] === 0xff && ((b[1] ?? 0) & 0xe0) === 0xe0) {
585
+ return { ext: 'mp3', category: 'audio' };
586
+ }
587
+ // WAV — "RIFF"????"WAVE".
588
+ if (
589
+ b.length >= 12 &&
590
+ b[0] === 0x52 &&
591
+ b[1] === 0x49 &&
592
+ b[2] === 0x46 &&
593
+ b[3] === 0x46 &&
594
+ b[8] === 0x57 &&
595
+ b[9] === 0x41 &&
596
+ b[10] === 0x56 &&
597
+ b[11] === 0x45
598
+ ) {
599
+ return { ext: 'wav', category: 'audio' };
600
+ }
601
+ return null;
602
+ }
603
+
395
604
  export function createApi(ctx: Context, hooks: ApiHooks): Api {
396
605
  const onCommentsChanged = hooks.onCommentsChanged;
397
606
  const onAnnotationsChanged = hooks.onAnnotationsChanged;
@@ -405,6 +614,22 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
405
614
  const reorderLog = new Map<number, { abs: string; before: string; after: string }>();
406
615
  let reorderSeq = 0;
407
616
 
617
+ // DDR-150 dogfood #1 — the SAME whole-file log backs the Timeline clip ops
618
+ // (retime / remove / insert / z-reorder / replace-src): every successful op
619
+ // registers its {before, after} and returns the seq; the shell keeps a
620
+ // per-canvas undo/redo stack of seqs and replays them through
621
+ // /_api/reorder-revert (guarded whole-file swap, 409 on divergence).
622
+ function logUndo(abs: string, before: string, after: string): number {
623
+ const seq = ++reorderSeq;
624
+ reorderLog.set(seq, { abs, before, after });
625
+ while (reorderLog.size > REORDER_LOG_CAP) {
626
+ const oldest = reorderLog.keys().next().value;
627
+ if (oldest === undefined) break;
628
+ reorderLog.delete(oldest);
629
+ }
630
+ return seq;
631
+ }
632
+
408
633
  function fileSlug(file: string): string {
409
634
  return canvasSlugFromRel(file, paths.designRel);
410
635
  }
@@ -987,51 +1212,153 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
987
1212
  // user input, but a poisoned designRoot must still not escape).
988
1213
  // Running total of bytes this server instance has actually written (post-dedupe).
989
1214
  let assetBytesWritten = 0;
990
- async function saveAsset(bytes: Uint8Array): Promise<SaveAssetResult> {
991
- if (!bytes || bytes.length === 0) return { ok: false, status: 400, error: 'empty body' };
992
- if (bytes.length > ASSET_MAX_BYTES) {
993
- return { ok: false, status: 413, error: 'asset exceeds the 10 MB cap' };
994
- }
995
- const kind = sniffImageType(bytes);
996
- if (!kind) {
997
- return {
998
- ok: false,
999
- status: 415,
1000
- error: 'unsupported image type png/jpeg/gif/webp only (SVG rejected)',
1001
- };
1002
- }
1003
- const sha8 = crypto.createHash('sha256').update(bytes).digest('hex').slice(0, 8);
1004
- const name = `${sha8}.${kind}`;
1215
+
1216
+ /**
1217
+ * DDR-148 the streaming write path behind `POST /_api/asset`. Reads the
1218
+ * request body chunk-by-chunk so a 100 MB video never lands as a single
1219
+ * ArrayBuffer in RAM (the memory-amplification vector DDR-088 §review flagged
1220
+ * for the untrusted canvas origin — worse now that the per-file cap is 100 MB,
1221
+ * not 10 MB). Sniffs the type from the head, applies the CATEGORY cap while
1222
+ * streaming (image 10 MB · video/audio {@link ASSET_MAX_VIDEO_BYTES}), writes
1223
+ * to a temp file, then content-addresses (sha8) → dedupe-rename. Nothing is
1224
+ * ever parsed — only magic bytes are read. `saveAsset(bytes)` wraps this so
1225
+ * both the route and any programmatic caller share ONE tested path.
1226
+ */
1227
+ async function saveAssetFromStream(stream: ReadableStream<Uint8Array>): Promise<SaveAssetResult> {
1005
1228
  const assetsDir = path.join(paths.designRoot, 'assets');
1006
- const fileAbs = path.join(assetsDir, name);
1007
- // Containment backstop — the name is content-addressed (sha8 hex + sniffed
1008
- // ext), so there is no user-controlled path segment, but assert anyway.
1009
- const resolved = path.resolve(fileAbs);
1010
- const assetsResolved = path.resolve(assetsDir);
1011
- if (resolved !== path.join(assetsResolved, name)) {
1012
- return { ok: false, status: 400, error: 'resolved asset path escapes assets dir' };
1013
- }
1229
+ const tmpName = `.tmp-${crypto.randomBytes(8).toString('hex')}`;
1230
+ const tmpAbs = path.join(assetsDir, tmpName);
1231
+ const hash = crypto.createHash('sha256');
1232
+ let sink: Bun.FileSink | null = null;
1233
+ let typeInfo: AssetTypeInfo | null = null;
1234
+ let cap = ASSET_MAX_VIDEO_BYTES; // provisional max until the head is sniffed
1235
+ let total = 0;
1236
+ const headChunks: Uint8Array[] = [];
1237
+ let headLen = 0;
1238
+
1239
+ const cleanup = async () => {
1240
+ try {
1241
+ if (sink) await sink.end();
1242
+ } catch {
1243
+ /* sink already closed */
1244
+ }
1245
+ try {
1246
+ await rm(tmpAbs, { force: true });
1247
+ } catch {
1248
+ /* temp already gone */
1249
+ }
1250
+ };
1251
+ const flushHead = () => {
1252
+ for (const c of headChunks) {
1253
+ hash.update(c);
1254
+ sink?.write(c);
1255
+ }
1256
+ headChunks.length = 0;
1257
+ headLen = 0;
1258
+ };
1259
+
1260
+ // Explicit reader (NOT `for await` — Bun's HTTP `req.body` ReadableStream
1261
+ // does not implement Symbol.asyncIterator, so async-iteration throws
1262
+ // "undefined is not a function"; getReader().read() works for every stream).
1263
+ const reader = stream.getReader();
1014
1264
  try {
1015
- // Dedupe identical bytes hash to the same name; skip the write if present.
1016
- if (!(await Bun.file(fileAbs).exists())) {
1017
- // Aggregate write budget (DDR-088 follow-up) bounds a scripted
1018
- // one-byte-mutation disk-fill loop from the untrusted canvas origin.
1019
- // Only a genuinely NEW file counts (a dedupe hit is free).
1020
- if (assetBytesWritten + bytes.length > ASSET_SESSION_BUDGET) {
1021
- return {
1022
- ok: false,
1023
- status: 429,
1024
- error: 'asset write budget exceeded for this server session',
1025
- };
1265
+ await mkdir(assetsDir, { recursive: true });
1266
+ for (;;) {
1267
+ const { done, value } = await reader.read();
1268
+ if (done) break;
1269
+ const chunk = value;
1270
+ if (!chunk || chunk.length === 0) continue;
1271
+ total += chunk.length;
1272
+ if (total > cap) {
1273
+ await cleanup();
1274
+ return { ok: false, status: 413, error: capError(typeInfo?.category) };
1275
+ }
1276
+ if (!typeInfo) {
1277
+ headChunks.push(chunk);
1278
+ headLen += chunk.length;
1279
+ if (headLen >= 12) {
1280
+ typeInfo = sniffAssetType(concatBytes(headChunks));
1281
+ if (!typeInfo) {
1282
+ await cleanup(); // nothing written yet — no temp file to remove
1283
+ return { ok: false, status: 415, error: UNSUPPORTED_ASSET_MSG };
1284
+ }
1285
+ cap = assetCapForCategory(typeInfo.category);
1286
+ if (total > cap) {
1287
+ await cleanup();
1288
+ return { ok: false, status: 413, error: capError(typeInfo.category) };
1289
+ }
1290
+ sink = Bun.file(tmpAbs).writer();
1291
+ flushHead();
1292
+ }
1293
+ } else {
1294
+ hash.update(chunk);
1295
+ sink?.write(chunk);
1026
1296
  }
1027
- await mkdir(assetsDir, { recursive: true });
1028
- await Bun.write(fileAbs, bytes);
1029
- assetBytesWritten += bytes.length;
1030
1297
  }
1298
+
1299
+ if (total === 0) {
1300
+ await cleanup();
1301
+ return { ok: false, status: 400, error: 'empty body' };
1302
+ }
1303
+ // A body shorter than 12 bytes never triggered the mid-stream sniff.
1304
+ if (!typeInfo) {
1305
+ typeInfo = sniffAssetType(concatBytes(headChunks));
1306
+ if (!typeInfo) {
1307
+ await cleanup();
1308
+ return { ok: false, status: 415, error: UNSUPPORTED_ASSET_MSG };
1309
+ }
1310
+ cap = assetCapForCategory(typeInfo.category);
1311
+ if (total > cap) {
1312
+ await cleanup();
1313
+ return { ok: false, status: 413, error: capError(typeInfo.category) };
1314
+ }
1315
+ sink = Bun.file(tmpAbs).writer();
1316
+ flushHead();
1317
+ }
1318
+ if (sink) await sink.end();
1319
+
1320
+ const sha8 = hash.digest('hex').slice(0, 8);
1321
+ const name = `${sha8}.${typeInfo.ext}`;
1322
+ const fileAbs = path.join(assetsDir, name);
1323
+ // Containment backstop — the name is content-addressed (sha8 hex + sniffed
1324
+ // ext), no user-controlled segment, but assert anyway (poisoned designRoot).
1325
+ if (path.resolve(fileAbs) !== path.join(path.resolve(assetsDir), name)) {
1326
+ await rm(tmpAbs, { force: true });
1327
+ return { ok: false, status: 400, error: 'resolved asset path escapes assets dir' };
1328
+ }
1329
+ // Dedupe — identical bytes hash to the same name; drop the temp copy.
1330
+ if (await Bun.file(fileAbs).exists()) {
1331
+ await rm(tmpAbs, { force: true });
1332
+ return { ok: true, path: `assets/${name}` };
1333
+ }
1334
+ // Aggregate write budget — bounds a scripted disk-fill loop from the
1335
+ // untrusted canvas origin. Only a genuinely NEW file counts.
1336
+ if (assetBytesWritten + total > ASSET_SESSION_BUDGET) {
1337
+ await rm(tmpAbs, { force: true });
1338
+ return {
1339
+ ok: false,
1340
+ status: 429,
1341
+ error: 'asset write budget exceeded for this server session',
1342
+ };
1343
+ }
1344
+ await rename(tmpAbs, fileAbs);
1345
+ assetBytesWritten += total;
1346
+ return { ok: true, path: `assets/${name}` };
1031
1347
  } catch (err) {
1348
+ await cleanup();
1032
1349
  return { ok: false, status: 500, error: err instanceof Error ? err.message : 'write failed' };
1350
+ } finally {
1351
+ try {
1352
+ reader.releaseLock();
1353
+ } catch {
1354
+ /* already released */
1355
+ }
1033
1356
  }
1034
- return { ok: true, path: `assets/${name}` };
1357
+ }
1358
+
1359
+ async function saveAsset(bytes: Uint8Array): Promise<SaveAssetResult> {
1360
+ if (!bytes || bytes.length === 0) return { ok: false, status: 400, error: 'empty body' };
1361
+ return saveAssetFromStream(new Response(bytes).body as ReadableStream<Uint8Array>);
1035
1362
  }
1036
1363
 
1037
1364
  // Phase 31 follow-up — persist an image pasted straight from the clipboard into
@@ -1112,11 +1439,37 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1112
1439
  name?: unknown;
1113
1440
  kind?: unknown;
1114
1441
  group?: unknown;
1442
+ // DDR-150 P4 Task 12 — video-comp assemble payload (kind === 'video-comp').
1443
+ clips?: unknown;
1444
+ fps?: unknown;
1445
+ width?: unknown;
1446
+ height?: unknown;
1115
1447
  }): Promise<CreateCanvasResult> {
1116
- // v1 only stamps blank boards generation stays with `/design:new` (Claude).
1448
+ // v1 stamps blank brief-boards + assembled video-comps richer generation
1449
+ // stays with `/design:new` (Claude).
1117
1450
  const kind = input.kind == null || input.kind === '' ? 'brief-board' : input.kind;
1118
- if (kind !== 'brief-board') {
1119
- return { ok: false, status: 400, error: 'only kind "brief-board" is supported' };
1451
+ if (kind !== 'brief-board' && kind !== 'video-comp') {
1452
+ return { ok: false, status: 400, error: 'kind must be "brief-board" or "video-comp"' };
1453
+ }
1454
+ // Parse + validate the assemble clip list up front (video-comp only).
1455
+ const assembleClips: AssembleClip[] = [];
1456
+ if (kind === 'video-comp') {
1457
+ if (!Array.isArray(input.clips) || input.clips.length === 0) {
1458
+ return { ok: false, status: 400, error: 'video-comp needs a non-empty clips[]' };
1459
+ }
1460
+ if (input.clips.length > 60) {
1461
+ return { ok: false, status: 400, error: 'too many clips (max 60)' };
1462
+ }
1463
+ for (const raw of input.clips) {
1464
+ const c = raw as { src?: unknown; mediaKind?: unknown; durationInFrames?: unknown };
1465
+ const src = typeof c?.src === 'string' ? c.src : '';
1466
+ if (!src) return { ok: false, status: 400, error: 'each clip needs a src' };
1467
+ const mediaKind = c?.mediaKind === 'audio' ? 'audio' : 'video';
1468
+ const durationInFrames = Number.isFinite(Number(c?.durationInFrames))
1469
+ ? Math.max(1, Math.min(100000, Math.round(Number(c.durationInFrames))))
1470
+ : null;
1471
+ assembleClips.push({ src, mediaKind, durationInFrames });
1472
+ }
1120
1473
  }
1121
1474
  const v = validateCanvasName(input.name);
1122
1475
  if (!v.ok || !v.name || !v.componentName) {
@@ -1169,25 +1522,55 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1169
1522
  const slug = fileSlug(rel);
1170
1523
  const dsName = cfg.defaultDesignSystem || 'project';
1171
1524
  const platform = 'desktop';
1172
- const tsx = renderBriefBoard({
1173
- name: v.name,
1174
- componentName: v.componentName,
1175
- dsName,
1176
- platform,
1177
- seedHint: 'Empty brief board annotate me',
1178
- historyDir: path.posix.join(paths.designRel, '_history', slug),
1179
- });
1525
+ let tsx: string;
1526
+ if (kind === 'video-comp') {
1527
+ try {
1528
+ tsx = assembleCompSource(v.componentName, assembleClips, {
1529
+ fps: Number.isFinite(Number(input.fps)) ? Number(input.fps) : undefined,
1530
+ width: Number.isFinite(Number(input.width)) ? Number(input.width) : undefined,
1531
+ height: Number.isFinite(Number(input.height)) ? Number(input.height) : undefined,
1532
+ });
1533
+ } catch (err) {
1534
+ return {
1535
+ ok: false,
1536
+ status: 400,
1537
+ error: err instanceof Error ? err.message : 'assemble failed',
1538
+ };
1539
+ }
1540
+ } else {
1541
+ tsx = renderBriefBoard({
1542
+ name: v.name,
1543
+ componentName: v.componentName,
1544
+ dsName,
1545
+ platform,
1546
+ seedHint: 'Empty brief board — annotate me',
1547
+ historyDir: path.posix.join(paths.designRel, '_history', slug),
1548
+ });
1549
+ }
1180
1550
  const now = new Date().toISOString();
1181
- const meta = {
1182
- kind: 'brief-board',
1183
- title: v.name,
1184
- subtitle: 'brief board',
1185
- brief: v.name,
1186
- designSystem: dsName,
1187
- platform,
1188
- created: now,
1189
- last_modified: now,
1190
- };
1551
+ const meta =
1552
+ kind === 'video-comp'
1553
+ ? {
1554
+ kind: 'video-comp',
1555
+ title: v.name,
1556
+ subtitle: 'assembled reel',
1557
+ brief: v.name,
1558
+ tags: ['video'],
1559
+ designSystem: dsName,
1560
+ platform,
1561
+ created: now,
1562
+ last_modified: now,
1563
+ }
1564
+ : {
1565
+ kind: 'brief-board',
1566
+ title: v.name,
1567
+ subtitle: 'brief board',
1568
+ brief: v.name,
1569
+ designSystem: dsName,
1570
+ platform,
1571
+ created: now,
1572
+ last_modified: now,
1573
+ };
1191
1574
  await Bun.write(fileAbs, tsx);
1192
1575
  await Bun.write(
1193
1576
  path.join(groupAbs, `${v.name}.meta.json`),
@@ -1356,6 +1739,27 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1356
1739
  return { ok: true, abs };
1357
1740
  }
1358
1741
 
1742
+ /**
1743
+ * DDR-148 — read a canvas's RAW .tsx source (containment via resolveCanvasAbs).
1744
+ * The Timeline panel parses `<Sequence>`/`<TransitionSeries.Sequence>` blocks
1745
+ * + `interpolate` windows from it to draw the sequence/keyframe rows. Read-only,
1746
+ * MAIN-ORIGIN ONLY at the route layer (never the untrusted canvas iframe).
1747
+ */
1748
+ async function loadCanvasSource(
1749
+ file: unknown
1750
+ ): Promise<{ ok: true; source: string } | { ok: false; status: number; error: string }> {
1751
+ const r = resolveCanvasAbs(file);
1752
+ if (!r.ok) return r;
1753
+ const f = Bun.file(r.abs);
1754
+ if (!(await f.exists())) return { ok: false, status: 404, error: 'canvas not found' };
1755
+ const source = await f.text();
1756
+ // A comp is small; never stream a huge blob into the panel.
1757
+ if (source.length > 512 * 1024) {
1758
+ return { ok: false, status: 413, error: 'canvas source too large for the timeline parser' };
1759
+ }
1760
+ return { ok: true, source };
1761
+ }
1762
+
1359
1763
  // 8-hex lowercase, the shape `computeId` (canvas-edit.ts) stamps on data-cd-id.
1360
1764
  const CD_ID_RE = /^[0-9a-f]{8}$/;
1361
1765
 
@@ -1374,11 +1778,20 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1374
1778
  const rel = path.relative(paths.designRoot, abs);
1375
1779
  ctx.bus.emit('activity:suppress', rel);
1376
1780
  try {
1781
+ // DDR-150 dogfood #1 — capture before/after so the edit registers in the
1782
+ // whole-file undo log (the Timeline replace-src path rides this; other
1783
+ // inspector edits get an undoable seq for free).
1784
+ const before = await Bun.file(abs).text();
1377
1785
  const res = await run();
1378
1786
  // `changed === false` = the op collapsed to a no-op and nothing hit disk —
1379
1787
  // NOT `delta === 0`, which an equal-length replacement also produces.
1380
- if (res.changed === false) ctx.bus.emit('activity:unsuppress', rel);
1381
- return { ok: true, delta: res.delta };
1788
+ if (res.changed === false) {
1789
+ ctx.bus.emit('activity:unsuppress', rel);
1790
+ return { ok: true, delta: res.delta };
1791
+ }
1792
+ const after = await Bun.file(abs).text();
1793
+ const seq = after !== before ? logUndo(abs, before, after) : undefined;
1794
+ return { ok: true, delta: res.delta, seq };
1382
1795
  } catch (err) {
1383
1796
  ctx.bus.emit('activity:unsuppress', rel);
1384
1797
  return {
@@ -1478,6 +1891,18 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1478
1891
  const value = typeof input.value === 'string' ? input.value : '';
1479
1892
  if (!value.trim()) return { ok: false, status: 400, error: 'value required' };
1480
1893
  if (value.length > 256) return { ok: false, status: 413, error: 'value too long' };
1894
+ // DDR-150 P3 — a media/image `src` edit (the clip-replace path) must stay a
1895
+ // contained asset reference: no path traversal, no script/file/data schemes.
1896
+ if (
1897
+ attr === 'src' &&
1898
+ (/\.\./.test(value) || /^\s*(javascript|vbscript|file|data):/i.test(value))
1899
+ ) {
1900
+ return {
1901
+ ok: false,
1902
+ status: 400,
1903
+ error: 'src must be a contained asset path (no ../ or javascript:/file:/data: schemes)',
1904
+ };
1905
+ }
1481
1906
  // Non-`style.` attr name → editAttribute writes a plain quoted JSX attribute.
1482
1907
  // Pass the value RAW: editStringAttr quotes/escapes it itself (JSON.stringify
1483
1908
  // on replace, escapeAttr on insert) — pre-stringifying here double-encoded
@@ -1566,6 +1991,320 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1566
1991
  }
1567
1992
  }
1568
1993
 
1994
+ /**
1995
+ * DDR-148 — Timeline drag-to-retime. Rewrites the `durationInFrames` / `from`
1996
+ * of the `index`-th sequence (document order). MAIN-ORIGIN ONLY at the route
1997
+ * layer. Snapshots pre-retime for /design:rollback; suppresses the agent-rim.
1998
+ */
1999
+ async function retimeSequenceOp(input: {
2000
+ canvas?: unknown;
2001
+ stableId?: unknown;
2002
+ artboardId?: unknown;
2003
+ contentHash?: unknown;
2004
+ index?: unknown;
2005
+ durationInFrames?: unknown;
2006
+ from?: unknown;
2007
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
2008
+ const r = resolveCanvasAbs(input.canvas);
2009
+ if (!r.ok) return r;
2010
+ // DDR-150 P2 — prefer the comp-scoped stableId (multi-comp-safe) over the
2011
+ // legacy whole-file index. The index path stays for back-compat.
2012
+ const stableId = typeof input.stableId === 'string' ? input.stableId : null;
2013
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId : undefined;
2014
+ const contentHash = typeof input.contentHash === 'string' ? input.contentHash : undefined;
2015
+ const index = Number.isInteger(input.index) ? (input.index as number) : -1;
2016
+ if (!stableId && (index < 0 || index > 500))
2017
+ return { ok: false, status: 400, error: 'invalid sequence index' };
2018
+ const frames = (v: unknown, lo: number): number | undefined => {
2019
+ const n = Math.round(Number(v));
2020
+ return Number.isFinite(n) ? Math.max(lo, Math.min(100000, n)) : undefined;
2021
+ };
2022
+ const patch: { durationInFrames?: number; from?: number } = {};
2023
+ if (input.durationInFrames != null) patch.durationInFrames = frames(input.durationInFrames, 1);
2024
+ if (input.from != null) patch.from = frames(input.from, 0);
2025
+ if (patch.durationInFrames == null && patch.from == null) {
2026
+ return { ok: false, status: 400, error: 'nothing to retime' };
2027
+ }
2028
+ const rel = path.relative(paths.designRoot, r.abs);
2029
+ ctx.bus.emit('activity:suppress', rel);
2030
+ try {
2031
+ const before = await Bun.file(r.abs).text();
2032
+ if (stableId) await retimeSequenceByClip(r.abs, artboardId, stableId, contentHash, patch);
2033
+ else await retimeSequence(r.abs, index, patch);
2034
+ const after = await Bun.file(r.abs).text();
2035
+ if (after === before) {
2036
+ ctx.bus.emit('activity:unsuppress', rel);
2037
+ return { ok: true };
2038
+ }
2039
+ try {
2040
+ await history.writeSnapshot(rel, before, 'pre-retime');
2041
+ } catch {
2042
+ /* snapshot best-effort */
2043
+ }
2044
+ return { ok: true, seq: logUndo(r.abs, before, after) };
2045
+ } catch (err) {
2046
+ ctx.bus.emit('activity:unsuppress', rel);
2047
+ return {
2048
+ ok: false,
2049
+ status: err instanceof CanvasEditError ? 422 : 500,
2050
+ error: err instanceof Error ? err.message : 'retime failed',
2051
+ };
2052
+ }
2053
+ }
2054
+
2055
+ async function removeSequenceOp(input: {
2056
+ canvas?: unknown;
2057
+ stableId?: unknown;
2058
+ artboardId?: unknown;
2059
+ contentHash?: unknown;
2060
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
2061
+ const r = resolveCanvasAbs(input.canvas);
2062
+ if (!r.ok) return r;
2063
+ const stableId = typeof input.stableId === 'string' ? input.stableId : null;
2064
+ if (!stableId) return { ok: false, status: 400, error: 'stableId required' };
2065
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId : undefined;
2066
+ const contentHash = typeof input.contentHash === 'string' ? input.contentHash : undefined;
2067
+ const rel = path.relative(paths.designRoot, r.abs);
2068
+ ctx.bus.emit('activity:suppress', rel);
2069
+ try {
2070
+ const before = await Bun.file(r.abs).text();
2071
+ await removeClip(r.abs, artboardId, stableId, contentHash);
2072
+ const after = await Bun.file(r.abs).text();
2073
+ if (after === before) {
2074
+ ctx.bus.emit('activity:unsuppress', rel);
2075
+ return { ok: true };
2076
+ }
2077
+ try {
2078
+ await history.writeSnapshot(rel, before, 'pre-remove-clip');
2079
+ } catch {
2080
+ /* snapshot best-effort */
2081
+ }
2082
+ return { ok: true, seq: logUndo(r.abs, before, after) };
2083
+ } catch (err) {
2084
+ ctx.bus.emit('activity:unsuppress', rel);
2085
+ return {
2086
+ ok: false,
2087
+ status: err instanceof CanvasEditError ? 422 : 500,
2088
+ error: err instanceof Error ? err.message : 'remove failed',
2089
+ };
2090
+ }
2091
+ }
2092
+
2093
+ async function insertSequenceOp(input: {
2094
+ canvas?: unknown;
2095
+ artboardId?: unknown;
2096
+ from?: unknown;
2097
+ durationInFrames?: unknown;
2098
+ mediaTag?: unknown;
2099
+ src?: unknown;
2100
+ }): Promise<
2101
+ | { ok: true; stableId: string | null; seq?: number }
2102
+ | { ok: false; status: number; error: string }
2103
+ > {
2104
+ const r = resolveCanvasAbs(input.canvas);
2105
+ if (!r.ok) return r;
2106
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId : undefined;
2107
+ const from = Number.isFinite(Number(input.from))
2108
+ ? Math.max(0, Math.round(Number(input.from)))
2109
+ : 0;
2110
+ const durationInFrames = Number.isFinite(Number(input.durationInFrames))
2111
+ ? Math.max(1, Math.min(100000, Math.round(Number(input.durationInFrames))))
2112
+ : 90;
2113
+ const mediaTag = typeof input.mediaTag === 'string' ? input.mediaTag : null;
2114
+ const src = typeof input.src === 'string' ? input.src : null;
2115
+ const rel = path.relative(paths.designRoot, r.abs);
2116
+ ctx.bus.emit('activity:suppress', rel);
2117
+ try {
2118
+ const before = await Bun.file(r.abs).text();
2119
+ const res = await insertClip(r.abs, artboardId, { from, durationInFrames, mediaTag, src });
2120
+ const after = await Bun.file(r.abs).text();
2121
+ let seq: number | undefined;
2122
+ if (after === before) {
2123
+ ctx.bus.emit('activity:unsuppress', rel);
2124
+ } else {
2125
+ try {
2126
+ await history.writeSnapshot(rel, before, 'pre-insert-clip');
2127
+ } catch {
2128
+ /* snapshot best-effort */
2129
+ }
2130
+ seq = logUndo(r.abs, before, after);
2131
+ }
2132
+ return { ok: true, stableId: res.stableId, seq };
2133
+ } catch (err) {
2134
+ ctx.bus.emit('activity:unsuppress', rel);
2135
+ return {
2136
+ ok: false,
2137
+ status: err instanceof CanvasEditError ? 422 : 500,
2138
+ error: err instanceof Error ? err.message : 'insert failed',
2139
+ };
2140
+ }
2141
+ }
2142
+
2143
+ async function toggleHideOp(input: {
2144
+ canvas?: unknown;
2145
+ stableId?: unknown;
2146
+ artboardId?: unknown;
2147
+ contentHash?: unknown;
2148
+ }): Promise<
2149
+ { ok: true; hidden: boolean; seq?: number } | { ok: false; status: number; error: string }
2150
+ > {
2151
+ const r = resolveCanvasAbs(input.canvas);
2152
+ if (!r.ok) return r;
2153
+ const stableId = typeof input.stableId === 'string' ? input.stableId : null;
2154
+ if (!stableId) return { ok: false, status: 400, error: 'stableId required' };
2155
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId : undefined;
2156
+ const contentHash = typeof input.contentHash === 'string' ? input.contentHash : undefined;
2157
+ const rel = path.relative(paths.designRoot, r.abs);
2158
+ ctx.bus.emit('activity:suppress', rel);
2159
+ try {
2160
+ const before = await Bun.file(r.abs).text();
2161
+ const res = await toggleClipHidden(r.abs, artboardId, stableId, contentHash);
2162
+ const after = await Bun.file(r.abs).text();
2163
+ if (after === before) {
2164
+ ctx.bus.emit('activity:unsuppress', rel);
2165
+ return { ok: true, hidden: res.hidden };
2166
+ }
2167
+ return { ok: true, hidden: res.hidden, seq: logUndo(r.abs, before, after) };
2168
+ } catch (err) {
2169
+ ctx.bus.emit('activity:unsuppress', rel);
2170
+ return {
2171
+ ok: false,
2172
+ status: err instanceof CanvasEditError ? 422 : 500,
2173
+ error: err instanceof Error ? err.message : 'hide failed',
2174
+ };
2175
+ }
2176
+ }
2177
+
2178
+ async function editArraySrcOp(input: {
2179
+ canvas?: unknown;
2180
+ arrayName?: unknown;
2181
+ index?: unknown;
2182
+ field?: unknown;
2183
+ value?: unknown;
2184
+ }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }> {
2185
+ const r = resolveCanvasAbs(input.canvas);
2186
+ if (!r.ok) return r;
2187
+ const arrayName = typeof input.arrayName === 'string' ? input.arrayName : '';
2188
+ const field = typeof input.field === 'string' ? input.field : 'src';
2189
+ const index = Number.isInteger(input.index) ? (input.index as number) : -1;
2190
+ const value = typeof input.value === 'string' ? input.value : '';
2191
+ if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(arrayName)) {
2192
+ return { ok: false, status: 400, error: 'invalid array name' };
2193
+ }
2194
+ if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(field)) {
2195
+ return { ok: false, status: 400, error: 'invalid field' };
2196
+ }
2197
+ if (index < 0 || index > 500) return { ok: false, status: 400, error: 'invalid index' };
2198
+ if (!value.trim()) return { ok: false, status: 400, error: 'value required' };
2199
+ const rel = path.relative(paths.designRoot, r.abs);
2200
+ ctx.bus.emit('activity:suppress', rel);
2201
+ try {
2202
+ const before = await Bun.file(r.abs).text();
2203
+ await editArrayElementString(r.abs, arrayName, index, field, value);
2204
+ const after = await Bun.file(r.abs).text();
2205
+ if (after === before) {
2206
+ ctx.bus.emit('activity:unsuppress', rel);
2207
+ return { ok: true };
2208
+ }
2209
+ return { ok: true, seq: logUndo(r.abs, before, after) };
2210
+ } catch (err) {
2211
+ ctx.bus.emit('activity:unsuppress', rel);
2212
+ return {
2213
+ ok: false,
2214
+ status: err instanceof CanvasEditError ? 422 : 500,
2215
+ error: err instanceof Error ? err.message : 'edit failed',
2216
+ };
2217
+ }
2218
+ }
2219
+
2220
+ async function reorderSequenceOp(input: {
2221
+ canvas?: unknown;
2222
+ artboardId?: unknown;
2223
+ stableId?: unknown;
2224
+ contentHash?: unknown;
2225
+ refStableId?: unknown;
2226
+ refContentHash?: unknown;
2227
+ position?: unknown;
2228
+ }): Promise<
2229
+ | { ok: true; stableId: string | null; seq?: number }
2230
+ | { ok: false; status: number; error: string }
2231
+ > {
2232
+ const r = resolveCanvasAbs(input.canvas);
2233
+ if (!r.ok) return r;
2234
+ const stableId = typeof input.stableId === 'string' ? input.stableId : null;
2235
+ const refStableId = typeof input.refStableId === 'string' ? input.refStableId : null;
2236
+ if (!stableId || !refStableId) {
2237
+ return { ok: false, status: 400, error: 'stableId and refStableId required' };
2238
+ }
2239
+ const position: MovePosition = input.position === 'before' ? 'before' : 'after';
2240
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId : undefined;
2241
+ const contentHash = typeof input.contentHash === 'string' ? input.contentHash : undefined;
2242
+ const refContentHash =
2243
+ typeof input.refContentHash === 'string' ? input.refContentHash : undefined;
2244
+ const rel = path.relative(paths.designRoot, r.abs);
2245
+ ctx.bus.emit('activity:suppress', rel);
2246
+ try {
2247
+ const before = await Bun.file(r.abs).text();
2248
+ const res = await reorderClip(
2249
+ r.abs,
2250
+ artboardId,
2251
+ stableId,
2252
+ contentHash,
2253
+ refStableId,
2254
+ refContentHash,
2255
+ position
2256
+ );
2257
+ const after = await Bun.file(r.abs).text();
2258
+ let seq: number | undefined;
2259
+ if (after === before) {
2260
+ ctx.bus.emit('activity:unsuppress', rel);
2261
+ } else {
2262
+ try {
2263
+ await history.writeSnapshot(rel, before, 'pre-reorder-clip');
2264
+ } catch {
2265
+ /* snapshot best-effort */
2266
+ }
2267
+ seq = logUndo(r.abs, before, after);
2268
+ }
2269
+ return { ok: true, stableId: res.stableId, seq };
2270
+ } catch (err) {
2271
+ ctx.bus.emit('activity:unsuppress', rel);
2272
+ return {
2273
+ ok: false,
2274
+ status: err instanceof CanvasEditError ? 422 : 500,
2275
+ error: err instanceof Error ? err.message : 'reorder failed',
2276
+ };
2277
+ }
2278
+ }
2279
+
2280
+ async function compClips(input: { canvas?: unknown; artboardId?: unknown }) {
2281
+ const r = resolveCanvasAbs(input.canvas);
2282
+ if (!r.ok) return r;
2283
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId : undefined;
2284
+ try {
2285
+ const source = await Bun.file(r.abs).text();
2286
+ const result = enumerateClips(r.abs, source, artboardId);
2287
+ // Strip the internal source offsets — the client addresses by stableId.
2288
+ const clips = result.clips.map(({ start: _s, end: _e, ...c }) => c);
2289
+ return {
2290
+ ok: true as const,
2291
+ compName: result.compName,
2292
+ artboardId: result.artboardId,
2293
+ fps: result.fps,
2294
+ durationInFrames: result.durationInFrames,
2295
+ clips,
2296
+ // DDR-150 dogfood #5 — loose media beds (audio/bg video) for replace.
2297
+ media: result.media,
2298
+ };
2299
+ } catch (err) {
2300
+ return {
2301
+ ok: false as const,
2302
+ status: err instanceof CanvasEditError ? 422 : 500,
2303
+ error: err instanceof Error ? err.message : 'enumerate failed',
2304
+ };
2305
+ }
2306
+ }
2307
+
1569
2308
  async function reorderRevert(input: {
1570
2309
  canvas?: unknown;
1571
2310
  seq?: unknown;
@@ -2009,10 +2748,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2009
2748
  loadCanvasState,
2010
2749
  saveCanvasState,
2011
2750
  loadCanvasMeta,
2751
+ loadCanvasSource,
2012
2752
  patchCanvasMeta,
2013
2753
  loadAnnotations,
2014
2754
  saveAnnotations,
2015
2755
  saveAsset,
2756
+ saveAssetFromStream,
2016
2757
  saveChatAttachment,
2017
2758
  resolveChatAttachment,
2018
2759
  createCanvas,
@@ -2020,7 +2761,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2020
2761
  editCss,
2021
2762
  editText,
2022
2763
  editAttr,
2764
+ removeSequenceOp,
2765
+ insertSequenceOp,
2766
+ reorderSequenceOp,
2767
+ editArraySrcOp,
2768
+ toggleHideOp,
2769
+ compClips,
2023
2770
  reorder,
2771
+ retimeSequenceOp,
2024
2772
  reorderRevert,
2025
2773
  buildIndexData,
2026
2774
  buildSystemData,