@1agh/maude 0.51.0 → 0.52.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 (52) hide show
  1. package/apps/studio/api.ts +389 -14
  2. package/apps/studio/bin/_video-playwright.mjs +15 -6
  3. package/apps/studio/canvas-edit.ts +141 -20
  4. package/apps/studio/canvas-lib.tsx +61 -0
  5. package/apps/studio/client/app.jsx +849 -121
  6. package/apps/studio/client/github.js +2 -0
  7. package/apps/studio/client/panels/ClipInspector.jsx +409 -0
  8. package/apps/studio/client/panels/CloudBar.jsx +12 -2
  9. package/apps/studio/client/panels/TimelinePanel.jsx +1775 -430
  10. package/apps/studio/client/panels/timeline-media-cache.js +274 -0
  11. package/apps/studio/client/panels/timeline-parse.js +46 -1
  12. package/apps/studio/client/panels/timeline-scale.js +64 -0
  13. package/apps/studio/client/report-bug.jsx +590 -0
  14. package/apps/studio/client/styles/3-shell-maude.css +446 -3
  15. package/apps/studio/client/styles/4-components-maude.css +55 -0
  16. package/apps/studio/client/styles/4-components.css +1 -0
  17. package/apps/studio/clip-ops.ts +1980 -0
  18. package/apps/studio/cloud/endpoints.ts +88 -17
  19. package/apps/studio/credential-grammar.ts +100 -0
  20. package/apps/studio/debug-bundle.ts +12 -0
  21. package/apps/studio/dist/client.bundle.js +2048 -2048
  22. package/apps/studio/dist/styles.css +1 -1
  23. package/apps/studio/exporters/video.ts +49 -10
  24. package/apps/studio/git/service.ts +1 -1
  25. package/apps/studio/http.ts +113 -6
  26. package/apps/studio/ripple.ts +335 -0
  27. package/apps/studio/server.ts +7 -0
  28. package/apps/studio/sync/hub-link.ts +3 -3
  29. package/apps/studio/sync/hubs-config.ts +32 -0
  30. package/apps/studio/sync/workspace-signin.ts +8 -4
  31. package/apps/studio/test/_helpers.ts +5 -3
  32. package/apps/studio/test/canvas-create-api.test.ts +8 -2
  33. package/apps/studio/test/canvas-origin-gate.test.ts +28 -0
  34. package/apps/studio/test/clip-addressing.test.ts +7 -6
  35. package/apps/studio/test/clip-ops.test.ts +836 -0
  36. package/apps/studio/test/cloud-endpoints.test.ts +37 -2
  37. package/apps/studio/test/comments-api.test.ts +49 -0
  38. package/apps/studio/test/credential-grammar.test.ts +107 -0
  39. package/apps/studio/test/read-only-role.test.ts +95 -0
  40. package/apps/studio/test/ripple.test.ts +197 -0
  41. package/apps/studio/test/timeline-scale.test.ts +65 -0
  42. package/apps/studio/test/workspace-signin.test.ts +4 -1
  43. package/apps/studio/whats-new.json +35 -0
  44. package/cli/commands/share.mjs +8 -4
  45. package/cli/lib/ddr-to-kgai.mjs +6 -1
  46. package/cli/lib/ddr-to-kgai.test.mjs +1 -2
  47. package/cli/lib/gitignore-block.mjs +1 -0
  48. package/cli/lib/hubs-config.mjs +15 -1
  49. package/cli/lib/hubs-config.test.mjs +19 -0
  50. package/cli/lib/share-plan.mjs +5 -1
  51. package/cli/lib/share-plan.test.mjs +11 -2
  52. package/package.json +8 -8
@@ -51,7 +51,6 @@ import {
51
51
  type MovePosition,
52
52
  moveElement,
53
53
  removeAttribute,
54
- removeClip,
55
54
  reorderClip,
56
55
  resizeArtboard,
57
56
  resolveEditScope,
@@ -65,6 +64,29 @@ import {
65
64
  setArtboardStyle,
66
65
  toggleClipHidden,
67
66
  } from './canvas-edit.ts';
67
+ import {
68
+ applyClipAudio,
69
+ applyClipFraming,
70
+ applyClipGrade,
71
+ applyDetachAudio,
72
+ applyEditTransition,
73
+ applyFitTotalToContent,
74
+ applyInsertTransition,
75
+ applyMoveClipToOverlay,
76
+ applyMoveClipToStoryline,
77
+ applyOnDisk,
78
+ applyRemoveTransition,
79
+ applyReorderOverlayLayer,
80
+ applyResolvePlaceholder,
81
+ applySetClipText,
82
+ applySetPlaybackRate,
83
+ applySplitClip,
84
+ applyTrimIn,
85
+ type GradeParams,
86
+ insertClipAt,
87
+ removeClipRippled,
88
+ seriesMove,
89
+ } from './clip-ops.ts';
68
90
  import type { Context } from './context.ts';
69
91
  import {
70
92
  type AudioMatch,
@@ -201,6 +223,12 @@ export interface Comment {
201
223
  author: string;
202
224
  thread: Reply[];
203
225
  mentions: string[];
226
+ /** enhanced-video-editing (Task 23) — a TIMELINE anchor: preferred
227
+ * `{ clipStableId, frameOffset }` (survives reorder/ripple), fallback
228
+ * `{ frame }` (track-level). Absent on ordinary element comments. Comment
229
+ * text is untrusted user/peer text (DDR-054) — rendered as text, never
230
+ * into TSX. */
231
+ timeline?: { clipStableId?: string; frameOffset?: number; frame?: number; lane?: string };
204
232
  }
205
233
 
206
234
  export interface GitCommitter {
@@ -276,6 +304,8 @@ export interface Api {
276
304
  // Canvas state
277
305
  loadCanvasState(file: string): Promise<Record<string, unknown> | null>;
278
306
  saveCanvasState(file: string, state: Record<string, unknown>): Promise<void>;
307
+ timelineMediaLoad(key: string): Promise<Record<string, unknown> | null>;
308
+ timelineMediaSave(key: string, data: Record<string, unknown>): Promise<boolean>;
279
309
  // Canvas meta sidecar (Phase 4 T5 — .design/ui/<slug>.meta.json)
280
310
  loadCanvasMeta(file: string): Promise<Record<string, unknown> | null>;
281
311
  /** DDR-148 — raw .tsx source for the Timeline sequence/keyframe parser. */
@@ -394,6 +424,31 @@ export interface Api {
394
424
  durationInFrames?: unknown;
395
425
  from?: unknown;
396
426
  }): Promise<{ ok: true; seq?: number } | { ok: false; status: number; error: string }>;
427
+ // feature-enhanced-video-editing (Phase 2) — parametric clip verbs (speed ·
428
+ // trim-in · audio · detach-audio · framing · grade · transition).
429
+ clipEditOp(input: {
430
+ canvas?: unknown;
431
+ artboardId?: unknown;
432
+ stableId?: unknown;
433
+ contentHash?: unknown;
434
+ verb?: unknown;
435
+ rate?: unknown;
436
+ deltaFrames?: unknown;
437
+ muted?: unknown;
438
+ volume?: unknown;
439
+ framing?: unknown;
440
+ grade?: unknown;
441
+ presentation?: unknown;
442
+ durationInFrames?: unknown;
443
+ atFrame?: unknown;
444
+ src?: unknown;
445
+ mediaKind?: unknown;
446
+ text?: unknown;
447
+ toIndex?: unknown;
448
+ }): Promise<
449
+ | { ok: true; seq?: number; extra?: Record<string, unknown> }
450
+ | { ok: false; status: number; error: string }
451
+ >;
397
452
  // DDR-150 P3 — remove a clip addressed by stableId (fingerprint + semantic
398
453
  // gate; refuses the only clip; drops an adjacent transition in a series).
399
454
  removeSequenceOp(input: {
@@ -983,13 +1038,36 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
983
1038
  }
984
1039
  }
985
1040
 
1041
+ // Security (adversarial review 2026-07-30): the timeline anchor is read by
1042
+ // the /design:edit agent (edit.md §0.6b treats `lane` as navigation), and a
1043
+ // comment can arrive from an UNTRUSTED hub peer (DDR-054) via the sync-persist
1044
+ // path — which does NOT go through commentsAdd's validation. So re-clamp the
1045
+ // anchor HERE, at the read boundary every consumer (incl. /_comments served to
1046
+ // the agent) passes through: bound `lane`/`clipStableId` length, coerce frame
1047
+ // ints, and drop unknown fields — a poisoned over-long `lane` can't smuggle an
1048
+ // instruction past the 40-char label the feature is documented to carry.
1049
+ function sanitizeTimelineAnchor(raw: unknown): Comment['timeline'] | undefined {
1050
+ if (!raw || typeof raw !== 'object') return undefined;
1051
+ const t = raw as Record<string, unknown>;
1052
+ const anchor: NonNullable<Comment['timeline']> = {};
1053
+ if (typeof t.clipStableId === 'string' && t.clipStableId.length <= 200)
1054
+ anchor.clipStableId = t.clipStableId;
1055
+ if (Number.isFinite(Number(t.frameOffset)))
1056
+ anchor.frameOffset = Math.max(0, Math.round(Number(t.frameOffset)));
1057
+ if (Number.isFinite(Number(t.frame))) anchor.frame = Math.max(0, Math.round(Number(t.frame)));
1058
+ if (typeof t.lane === 'string' && t.lane.length <= 40) anchor.lane = t.lane;
1059
+ return anchor.clipStableId != null || anchor.frame != null ? anchor : undefined;
1060
+ }
1061
+
986
1062
  function backfillComment(raw: unknown): Comment {
987
1063
  const c = (raw ?? {}) as Partial<Comment>;
1064
+ const timeline = sanitizeTimelineAnchor((c as { timeline?: unknown }).timeline);
988
1065
  return {
989
1066
  ...(c as Comment),
990
1067
  author: typeof c.author === 'string' ? c.author : '',
991
1068
  thread: Array.isArray(c.thread) ? c.thread : [],
992
1069
  mentions: Array.isArray(c.mentions) ? c.mentions : [],
1070
+ ...(timeline ? { timeline } : { timeline: undefined }),
993
1071
  };
994
1072
  }
995
1073
 
@@ -1148,6 +1226,30 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1148
1226
  thread: [],
1149
1227
  mentions: parseMentions(text),
1150
1228
  };
1229
+ // Task 23 — timeline anchor pass-through (validated shape only).
1230
+ const tl = payload.timeline;
1231
+ if (tl && typeof tl === 'object') {
1232
+ const anchor: NonNullable<Comment['timeline']> = {};
1233
+ if (typeof tl.clipStableId === 'string' && tl.clipStableId.length <= 200) {
1234
+ anchor.clipStableId = tl.clipStableId;
1235
+ }
1236
+ if (Number.isFinite(Number(tl.frameOffset))) {
1237
+ anchor.frameOffset = Math.max(0, Math.round(Number(tl.frameOffset)));
1238
+ }
1239
+ if (Number.isFinite(Number(tl.frame))) {
1240
+ anchor.frame = Math.max(0, Math.round(Number(tl.frame)));
1241
+ }
1242
+ // Dogfood (2026-07-30) — the C-tool records WHICH lane the click landed
1243
+ // on (storyline · V<n> overlay · A<n> audio) so an agent reading the
1244
+ // comment knows exactly where to look.
1245
+ if (
1246
+ typeof (tl as { lane?: unknown }).lane === 'string' &&
1247
+ (tl as { lane: string }).lane.length <= 40
1248
+ ) {
1249
+ anchor.lane = (tl as { lane: string }).lane;
1250
+ }
1251
+ if (anchor.clipStableId != null || anchor.frame != null) c.timeline = anchor;
1252
+ }
1151
1253
  list.push(c);
1152
1254
  await saveCommentsForFile(payload.file, list);
1153
1255
  onCommentsChanged(payload.file);
@@ -1236,6 +1338,60 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1236
1338
  }
1237
1339
  }
1238
1340
 
1341
+ // ---------- Timeline media visuals cache (enhanced-video-editing Task 7) ----
1342
+ //
1343
+ // Filmstrip dataURL strips + waveform peak arrays, keyed `<sha8>:<bucket>`,
1344
+ // persisted under `_canvas-state/timeline-media/` — per-machine runtime state
1345
+ // (DDR-115; `_canvas-state/` is already on all three ignore lists, so a
1346
+ // subdirectory needs no list change).
1347
+
1348
+ const TL_MEDIA_KEY_RE = /^[A-Za-z0-9._:-]{1,160}$/;
1349
+
1350
+ function timelineMediaPath(key: string): string | null {
1351
+ if (!TL_MEDIA_KEY_RE.test(key) || key.includes('..')) return null;
1352
+ return path.join(paths.canvasStateDir, 'timeline-media', `${key.replaceAll(':', '__')}.json`);
1353
+ }
1354
+
1355
+ async function timelineMediaLoad(key: string): Promise<Record<string, unknown> | null> {
1356
+ const p = timelineMediaPath(key);
1357
+ if (!p) return null;
1358
+ try {
1359
+ const obj = JSON.parse(await Bun.file(p).text());
1360
+ return obj && typeof obj === 'object' ? obj : null;
1361
+ } catch {
1362
+ return null;
1363
+ }
1364
+ }
1365
+
1366
+ async function timelineMediaSave(key: string, data: Record<string, unknown>): Promise<boolean> {
1367
+ const p = timelineMediaPath(key);
1368
+ if (!p) return false;
1369
+ // Shape gate (security review 2026-07-30): `strip[]` is later rendered as
1370
+ // `<img src={u}>` in the trusted shell (TimelinePanel), so persist only the
1371
+ // two known shapes — a filmstrip of `data:image/*` URLs and a numeric peak
1372
+ // array. Anything else (a poisoned `http(s)://` beacon URL, a non-array) is
1373
+ // dropped rather than cached. Defense-in-depth over the loopback guard.
1374
+ const clean: Record<string, unknown> = {};
1375
+ if (Array.isArray(data.strip)) {
1376
+ const strip = data.strip.filter(
1377
+ (u): u is string => typeof u === 'string' && /^data:image\//.test(u)
1378
+ );
1379
+ if (strip.length !== data.strip.length) return false; // reject if any entry was rejected
1380
+ clean.strip = strip;
1381
+ }
1382
+ if (Array.isArray(data.peaks)) {
1383
+ if (!data.peaks.every((n) => typeof n === 'number' && Number.isFinite(n))) return false;
1384
+ clean.peaks = data.peaks;
1385
+ }
1386
+ if (Object.keys(clean).length === 0) return false;
1387
+ try {
1388
+ await Bun.write(p, JSON.stringify(clean));
1389
+ return true;
1390
+ } catch {
1391
+ return false;
1392
+ }
1393
+ }
1394
+
1239
1395
  // ---------- Canvas meta sidecar (Phase 4 T5; split DDR-115) ----------
1240
1396
  //
1241
1397
  // Each canvas under `<designRoot>/ui/<name>.tsx` has a sibling
@@ -2232,8 +2388,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2232
2388
  // Parse + validate the assemble clip list up front (video-comp only).
2233
2389
  const assembleClips: AssembleClip[] = [];
2234
2390
  if (kind === 'video-comp') {
2235
- if (!Array.isArray(input.clips) || input.clips.length === 0) {
2236
- return { ok: false, status: 400, error: 'video-comp needs a non-empty clips[]' };
2391
+ // Task 20 (enhanced-video-editing) — an EMPTY clips[] is the greenfield
2392
+ // "New video" flow: scaffold an editable empty comp, build the cut
2393
+ // drop-first on the Timeline.
2394
+ if (!Array.isArray(input.clips)) {
2395
+ return { ok: false, status: 400, error: 'video-comp needs a clips[] (may be empty)' };
2237
2396
  }
2238
2397
  if (input.clips.length > 60) {
2239
2398
  return { ok: false, status: 400, error: 'too many clips (max 60)' };
@@ -2307,6 +2466,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
2307
2466
  fps: Number.isFinite(Number(input.fps)) ? Number(input.fps) : undefined,
2308
2467
  width: Number.isFinite(Number(input.width)) ? Number(input.width) : undefined,
2309
2468
  height: Number.isFinite(Number(input.height)) ? Number(input.height) : undefined,
2469
+ allowEmpty: assembleClips.length === 0,
2310
2470
  });
2311
2471
  } catch (err) {
2312
2472
  return {
@@ -3497,6 +3657,13 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3497
3657
  const before = await Bun.file(r.abs).text();
3498
3658
  if (stableId) await retimeSequenceByClip(r.abs, artboardId, stableId, contentHash, patch);
3499
3659
  else await retimeSequence(r.abs, index, patch);
3660
+ // A trim/move that pushes a clip past the comp end stretches the comp
3661
+ // (the timeline always reaches the last clip — dogfood rule).
3662
+ try {
3663
+ await applyOnDisk(r.abs, (src) => applyFitTotalToContent(r.abs, src, artboardId));
3664
+ } catch {
3665
+ /* fit is best-effort — the retime itself already landed */
3666
+ }
3500
3667
  const after = await Bun.file(r.abs).text();
3501
3668
  if (after === before) {
3502
3669
  ctx.bus.emit('activity:unsuppress', rel);
@@ -3534,7 +3701,10 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3534
3701
  ctx.bus.emit('activity:suppress', rel);
3535
3702
  try {
3536
3703
  const before = await Bun.file(r.abs).text();
3537
- await removeClip(r.abs, artboardId, stableId, contentHash);
3704
+ // feature-enhanced-video-editing — the iMovie Delete ripples: a series
3705
+ // beat's removal shrinks the comp TOTAL (duration − transition overlap);
3706
+ // a standalone overlay/audio clip removes without moving the cut.
3707
+ await removeClipRippled(r.abs, artboardId, stableId, contentHash);
3538
3708
  const after = await Bun.file(r.abs).text();
3539
3709
  if (after === before) {
3540
3710
  ctx.bus.emit('activity:unsuppress', rel);
@@ -3563,6 +3733,9 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3563
3733
  durationInFrames?: unknown;
3564
3734
  mediaTag?: unknown;
3565
3735
  src?: unknown;
3736
+ lane?: unknown;
3737
+ index?: unknown;
3738
+ placeholder?: unknown;
3566
3739
  }): Promise<
3567
3740
  | { ok: true; stableId: string | null; seq?: number }
3568
3741
  | { ok: false; status: number; error: string }
@@ -3578,11 +3751,36 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3578
3751
  : 90;
3579
3752
  const mediaTag = typeof input.mediaTag === 'string' ? input.mediaTag : null;
3580
3753
  const src = typeof input.src === 'string' ? input.src : null;
3754
+ // Task 6 — positional insert: lane + (storyline) index route through the
3755
+ // caret-aware engine; the legacy append path stays for lane-less callers.
3756
+ const lane =
3757
+ input.lane === 'storyline' || input.lane === 'overlay' || input.lane === 'audio'
3758
+ ? input.lane
3759
+ : null;
3760
+ const laneIndex = Number.isInteger(Number(input.index))
3761
+ ? Math.max(0, Math.round(Number(input.index)))
3762
+ : undefined;
3763
+ // Task 22 — a prompt-carrying AI slate instead of media.
3764
+ const ph = input.placeholder as { prompt?: unknown; kind?: unknown } | null | undefined;
3765
+ const placeholder =
3766
+ ph && typeof ph === 'object' && typeof ph.prompt === 'string'
3767
+ ? { prompt: ph.prompt, kind: typeof ph.kind === 'string' ? ph.kind : 'veo' }
3768
+ : null;
3581
3769
  const rel = path.relative(paths.designRoot, r.abs);
3582
3770
  ctx.bus.emit('activity:suppress', rel);
3583
3771
  try {
3584
3772
  const before = await Bun.file(r.abs).text();
3585
- const res = await insertClip(r.abs, artboardId, { from, durationInFrames, mediaTag, src });
3773
+ const res = lane
3774
+ ? await insertClipAt(r.abs, artboardId, {
3775
+ lane,
3776
+ index: laneIndex,
3777
+ from,
3778
+ durationInFrames,
3779
+ mediaTag,
3780
+ src,
3781
+ placeholder,
3782
+ })
3783
+ : await insertClip(r.abs, artboardId, { from, durationInFrames, mediaTag, src });
3586
3784
  const after = await Bun.file(r.abs).text();
3587
3785
  let seq: number | undefined;
3588
3786
  if (after === before) {
@@ -3606,6 +3804,166 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
3606
3804
  }
3607
3805
  }
3608
3806
 
3807
+ /**
3808
+ * feature-enhanced-video-editing (Phase 2) — the parametric clip verbs, one
3809
+ * dispatch op: speed · trim-in · audio · detach-audio · framing · grade ·
3810
+ * transition. Every verb is a pure clip-ops fn run under the file lock, with
3811
+ * the same stableId + contentHash optimistic-concurrency discipline, pre-op
3812
+ * snapshot, and whole-file undo seq as the structural clip ops.
3813
+ */
3814
+ async function clipEditOp(input: {
3815
+ canvas?: unknown;
3816
+ artboardId?: unknown;
3817
+ stableId?: unknown;
3818
+ contentHash?: unknown;
3819
+ verb?: unknown;
3820
+ rate?: unknown;
3821
+ deltaFrames?: unknown;
3822
+ muted?: unknown;
3823
+ volume?: unknown;
3824
+ framing?: unknown;
3825
+ grade?: unknown;
3826
+ presentation?: unknown;
3827
+ durationInFrames?: unknown;
3828
+ atFrame?: unknown;
3829
+ src?: unknown;
3830
+ mediaKind?: unknown;
3831
+ text?: unknown;
3832
+ toIndex?: unknown;
3833
+ }): Promise<
3834
+ | { ok: true; seq?: number; extra?: Record<string, unknown> }
3835
+ | { ok: false; status: number; error: string }
3836
+ > {
3837
+ const r = resolveCanvasAbs(input.canvas);
3838
+ if (!r.ok) return r;
3839
+ const stableId = typeof input.stableId === 'string' ? input.stableId : null;
3840
+ if (!stableId) return { ok: false, status: 400, error: 'stableId required' };
3841
+ const artboardId = typeof input.artboardId === 'string' ? input.artboardId : undefined;
3842
+ const hash = typeof input.contentHash === 'string' ? input.contentHash : undefined;
3843
+ const verb = typeof input.verb === 'string' ? input.verb : '';
3844
+ const abs = r.abs;
3845
+ let run: ((source: string) => { source: string } & Record<string, unknown>) | null = null;
3846
+ switch (verb) {
3847
+ case 'speed': {
3848
+ const rate = Number(input.rate);
3849
+ run = (src) => applySetPlaybackRate(abs, src, artboardId, stableId, hash, rate);
3850
+ break;
3851
+ }
3852
+ case 'trim-in': {
3853
+ const delta = Number(input.deltaFrames);
3854
+ if (!Number.isFinite(delta))
3855
+ return { ok: false, status: 400, error: 'deltaFrames required' };
3856
+ run = (src) => applyTrimIn(abs, src, artboardId, stableId, hash, delta);
3857
+ break;
3858
+ }
3859
+ case 'audio': {
3860
+ const opts: { muted?: boolean; volume?: number | null } = {};
3861
+ if (typeof input.muted === 'boolean') opts.muted = input.muted;
3862
+ if (input.volume !== undefined)
3863
+ opts.volume = input.volume == null ? null : Number(input.volume);
3864
+ run = (src) => applyClipAudio(abs, src, artboardId, stableId, hash, opts);
3865
+ break;
3866
+ }
3867
+ case 'detach-audio': {
3868
+ run = (src) => applyDetachAudio(abs, src, artboardId, stableId, hash);
3869
+ break;
3870
+ }
3871
+ case 'framing': {
3872
+ const f = input.framing as { scale?: unknown; x?: unknown; y?: unknown } | null;
3873
+ const framing =
3874
+ f == null
3875
+ ? null
3876
+ : { scale: Number(f.scale) || 1, x: Number(f.x) || 0, y: Number(f.y) || 0 };
3877
+ run = (src) => applyClipFraming(abs, src, artboardId, stableId, hash, framing);
3878
+ break;
3879
+ }
3880
+ case 'grade': {
3881
+ const grade = input.grade == null ? null : (input.grade as GradeParams);
3882
+ run = (src) => applyClipGrade(abs, src, artboardId, stableId, hash, grade);
3883
+ break;
3884
+ }
3885
+ case 'transition': {
3886
+ const opts: { presentation?: string; durationInFrames?: number } = {};
3887
+ if (typeof input.presentation === 'string') opts.presentation = input.presentation;
3888
+ if (input.durationInFrames != null) opts.durationInFrames = Number(input.durationInFrames);
3889
+ run = (src) => applyEditTransition(abs, src, artboardId, stableId, hash, opts);
3890
+ break;
3891
+ }
3892
+ case 'split': {
3893
+ const atFrame = Number(input.atFrame);
3894
+ if (!Number.isFinite(atFrame)) return { ok: false, status: 400, error: 'atFrame required' };
3895
+ run = (src) => applySplitClip(abs, src, artboardId, stableId, hash, atFrame);
3896
+ break;
3897
+ }
3898
+ case 'insert-transition': {
3899
+ const presentation = typeof input.presentation === 'string' ? input.presentation : 'fade';
3900
+ const frames = Number(input.durationInFrames) || 15;
3901
+ run = (src) =>
3902
+ applyInsertTransition(abs, src, artboardId, stableId, hash, presentation, frames);
3903
+ break;
3904
+ }
3905
+ case 'remove-transition': {
3906
+ run = (src) => applyRemoveTransition(abs, src, artboardId, stableId, hash);
3907
+ break;
3908
+ }
3909
+ case 'set-text': {
3910
+ const t = typeof input.text === 'string' ? input.text : '';
3911
+ run = (src) => applySetClipText(abs, src, artboardId, stableId, hash, t);
3912
+ break;
3913
+ }
3914
+ case 'to-overlay': {
3915
+ run = (src) => applyMoveClipToOverlay(abs, src, artboardId, stableId, hash);
3916
+ break;
3917
+ }
3918
+ case 'to-storyline': {
3919
+ run = (src) => applyMoveClipToStoryline(abs, src, artboardId, stableId, hash);
3920
+ break;
3921
+ }
3922
+ case 'layer-order': {
3923
+ const toIndex = Number(input.toIndex);
3924
+ if (!Number.isFinite(toIndex)) return { ok: false, status: 400, error: 'toIndex required' };
3925
+ run = (src) => applyReorderOverlayLayer(abs, src, artboardId, stableId, hash, toIndex);
3926
+ break;
3927
+ }
3928
+ case 'resolve-placeholder': {
3929
+ const src2 = typeof input.src === 'string' ? input.src : '';
3930
+ const mediaKind = input.mediaKind === 'image' ? 'image' : 'video';
3931
+ run = (src) =>
3932
+ applyResolvePlaceholder(abs, src, artboardId, stableId, hash, src2, mediaKind);
3933
+ break;
3934
+ }
3935
+ default:
3936
+ return { ok: false, status: 400, error: `unknown clip verb "${verb}"` };
3937
+ }
3938
+ const rel = path.relative(paths.designRoot, abs);
3939
+ ctx.bus.emit('activity:suppress', rel);
3940
+ try {
3941
+ const before = await Bun.file(abs).text();
3942
+ const result = await applyOnDisk(abs, run);
3943
+ const after = await Bun.file(abs).text();
3944
+ let seq: number | undefined;
3945
+ if (after === before) {
3946
+ ctx.bus.emit('activity:unsuppress', rel);
3947
+ } else {
3948
+ try {
3949
+ await history.writeSnapshot(rel, before, `pre-clip-${verb}`);
3950
+ } catch {
3951
+ /* snapshot best-effort */
3952
+ }
3953
+ seq = logUndo(abs, before, after);
3954
+ }
3955
+ const { source: _src, ...extra } = result;
3956
+ return { ok: true, seq, extra };
3957
+ } catch (err) {
3958
+ ctx.bus.emit('activity:unsuppress', rel);
3959
+ return {
3960
+ ok: false,
3961
+ status: err instanceof CanvasEditError ? 422 : 500,
3962
+ error: err instanceof Error ? err.message : `clip ${verb} failed`,
3963
+ };
3964
+ }
3965
+ }
3966
+
3609
3967
  // Stage I (feature-element-editing-robustness) — general element structural
3610
3968
  // edits (delete / insert / new-artboard) + free-hand artboard resize (D4). All
3611
3969
  // MAIN-ORIGIN ONLY at the route layer; each logs a whole-file {before, after}
@@ -4676,6 +5034,9 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
4676
5034
  refStableId?: unknown;
4677
5035
  refContentHash?: unknown;
4678
5036
  position?: unknown;
5037
+ /** 'move' = the magnetic drag commit: a real series MOVE (any distance)
5038
+ * instead of the legacy adjacent swap. */
5039
+ mode?: unknown;
4679
5040
  }): Promise<
4680
5041
  | { ok: true; stableId: string | null; seq?: number }
4681
5042
  | { ok: false; status: number; error: string }
@@ -4696,15 +5057,26 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
4696
5057
  ctx.bus.emit('activity:suppress', rel);
4697
5058
  try {
4698
5059
  const before = await Bun.file(r.abs).text();
4699
- const res = await reorderClip(
4700
- r.abs,
4701
- artboardId,
4702
- stableId,
4703
- contentHash,
4704
- refStableId,
4705
- refContentHash,
4706
- position
4707
- );
5060
+ const res =
5061
+ input.mode === 'move'
5062
+ ? await seriesMove(
5063
+ r.abs,
5064
+ artboardId,
5065
+ stableId,
5066
+ contentHash,
5067
+ refStableId,
5068
+ refContentHash,
5069
+ position
5070
+ )
5071
+ : await reorderClip(
5072
+ r.abs,
5073
+ artboardId,
5074
+ stableId,
5075
+ contentHash,
5076
+ refStableId,
5077
+ refContentHash,
5078
+ position
5079
+ );
4708
5080
  const after = await Bun.file(r.abs).text();
4709
5081
  let seq: number | undefined;
4710
5082
  if (after === before) {
@@ -5189,6 +5561,8 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
5189
5561
  parseMentions,
5190
5562
  loadCanvasState,
5191
5563
  saveCanvasState,
5564
+ timelineMediaLoad,
5565
+ timelineMediaSave,
5192
5566
  loadCanvasMeta,
5193
5567
  loadCanvasSource,
5194
5568
  patchCanvasMeta,
@@ -5211,6 +5585,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
5211
5585
  editCss,
5212
5586
  editText,
5213
5587
  editAttr,
5588
+ clipEditOp,
5214
5589
  removeSequenceOp,
5215
5590
  insertSequenceOp,
5216
5591
  reorderSequenceOp,
@@ -50,6 +50,7 @@ const {
50
50
  'license-key': licenseKeyArg,
51
51
  timeout = '60',
52
52
  scale = '1',
53
+ 'max-frames': maxFramesArg, // frame ceiling (exporters/video.ts resolveMaxFrames)
53
54
  } = args;
54
55
 
55
56
  if (!url) {
@@ -316,12 +317,20 @@ async function frameStepCapture({
316
317
  }
317
318
  }, compId);
318
319
  const fps = Number(fpsArg) || compMeta?.fps || 30;
319
- // Cap at 900 frames (30 s @ 30 fps) so a runaway/huge comp can't spawn an
320
- // unbounded screenshot loop — matches exporters/video.ts MAX_FRAMES.
321
- const frameCount = Math.min(
322
- 900,
323
- Number(framesArg) || compMeta?.durationInFrames || Math.round(fps * 3) // 3 s default
324
- );
320
+ // Frame ceiling so a runaway/huge comp can't spawn an unbounded screenshot
321
+ // loop — passed by exporters/video.ts (resolveMaxFrames; raisable per-export
322
+ // via options.maxFrames). Clamping is LOUD: a truncated export says so on
323
+ // stderr instead of silently shipping a shorter file.
324
+ const maxFrames = Number(maxFramesArg) > 0 ? Number(maxFramesArg) : 3600;
325
+ const wantedFrames = Number(framesArg) || compMeta?.durationInFrames || Math.round(fps * 3); // 3 s default
326
+ const frameCount = Math.min(maxFrames, wantedFrames);
327
+ if (wantedFrames > maxFrames) {
328
+ console.error(
329
+ `⚠ _video-playwright: comp is ${wantedFrames} frames but the export cap is ` +
330
+ `${maxFrames} — capturing only the first ${maxFrames} frames. Pass ` +
331
+ 'options.maxFrames to raise the cap.'
332
+ );
333
+ }
325
334
  if (!Number.isFinite(fps) || fps <= 0 || !Number.isFinite(frameCount) || frameCount <= 0) {
326
335
  console.error('_video-playwright: could not resolve fps/frameCount (no comp meta, no args)');
327
336
  process.exit(2);