@1agh/maude 0.51.1 → 0.52.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) 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 +824 -121
  6. package/apps/studio/client/panels/ClipInspector.jsx +409 -0
  7. package/apps/studio/client/panels/CloudBar.jsx +12 -2
  8. package/apps/studio/client/panels/TimelinePanel.jsx +1775 -430
  9. package/apps/studio/client/panels/timeline-media-cache.js +274 -0
  10. package/apps/studio/client/panels/timeline-parse.js +46 -1
  11. package/apps/studio/client/panels/timeline-scale.js +64 -0
  12. package/apps/studio/client/report-bug.jsx +15 -15
  13. package/apps/studio/client/styles/3-shell-maude.css +446 -3
  14. package/apps/studio/client/styles/4-components-maude.css +55 -0
  15. package/apps/studio/client/styles/4-components.css +0 -83
  16. package/apps/studio/clip-ops.ts +1980 -0
  17. package/apps/studio/cloud/endpoints.ts +88 -17
  18. package/apps/studio/credential-grammar.ts +100 -0
  19. package/apps/studio/debug-bundle.ts +12 -0
  20. package/apps/studio/dist/client.bundle.js +2048 -2048
  21. package/apps/studio/dist/styles.css +1 -1
  22. package/apps/studio/exporters/video.ts +49 -10
  23. package/apps/studio/http.ts +113 -6
  24. package/apps/studio/ripple.ts +335 -0
  25. package/apps/studio/sync/hub-link.ts +3 -3
  26. package/apps/studio/sync/hubs-config.ts +32 -0
  27. package/apps/studio/sync/workspace-signin.ts +8 -4
  28. package/apps/studio/test/_helpers.ts +5 -3
  29. package/apps/studio/test/canvas-create-api.test.ts +8 -2
  30. package/apps/studio/test/canvas-origin-gate.test.ts +20 -0
  31. package/apps/studio/test/clip-addressing.test.ts +7 -6
  32. package/apps/studio/test/clip-ops.test.ts +836 -0
  33. package/apps/studio/test/cloud-endpoints.test.ts +37 -2
  34. package/apps/studio/test/comments-api.test.ts +49 -0
  35. package/apps/studio/test/credential-grammar.test.ts +107 -0
  36. package/apps/studio/test/read-only-role.test.ts +95 -0
  37. package/apps/studio/test/ripple.test.ts +197 -0
  38. package/apps/studio/test/timeline-scale.test.ts +65 -0
  39. package/apps/studio/test/workspace-signin.test.ts +4 -1
  40. package/apps/studio/whats-new.json +35 -0
  41. package/cli/commands/share.mjs +8 -4
  42. package/cli/lib/ddr-to-kgai.mjs +6 -1
  43. package/cli/lib/ddr-to-kgai.test.mjs +1 -2
  44. package/cli/lib/hubs-config.mjs +15 -1
  45. package/cli/lib/hubs-config.test.mjs +19 -0
  46. package/cli/lib/share-plan.mjs +5 -1
  47. package/cli/lib/share-plan.test.mjs +11 -2
  48. package/package.json +8 -8
@@ -1466,6 +1466,7 @@ function ExportDialog({
1466
1466
  initialScope,
1467
1467
  activePath,
1468
1468
  hasComps = false,
1469
+ comps = [],
1469
1470
  activeArtboardId = null,
1470
1471
  selection = null,
1471
1472
  onClose,
@@ -1497,6 +1498,19 @@ function ExportDialog({
1497
1498
  const [recent, setRecent] = useState([]);
1498
1499
  const card = EXPORT_CARDS.find((c) => c.id === sel) || EXPORT_CARDS[0];
1499
1500
  const validScopes = card.handoff ? [] : EXPORT_VALID_SCOPES[card.format] || ['artboard'];
1501
+ // Long-comp tiers (feature-enhanced-video-editing Task 1). 900 frames is the
1502
+ // safe tier (the frame-step fallback runs ~2.5 s/frame and 1080p captures
1503
+ // have died from memory pressure above it); 3600 is the server's default cap
1504
+ // (exporters/video.ts DEFAULT_MAX_FRAMES). Above the cap the dialog raises it
1505
+ // EXPLICITLY for this export, with the notice below as the informed consent —
1506
+ // never a silent truncation, never a surprise refusal.
1507
+ const EXPORT_SAFE_FRAMES = 900;
1508
+ const EXPORT_DEFAULT_CAP = 3600;
1509
+ const exportComp = comps[0] || null;
1510
+ const exportCompFrames = exportComp?.durationInFrames || 0;
1511
+ const exportCompFps = exportComp?.fps || 30;
1512
+ const exportIsHeavy = card.temporal && exportCompFrames > EXPORT_SAFE_FRAMES;
1513
+ const exportOverCap = card.temporal && exportCompFrames > EXPORT_DEFAULT_CAP;
1500
1514
 
1501
1515
  // Keep the scope valid for the chosen format (pptx/zip etc. only allow a
1502
1516
  // subset) — mirrors VALID_SCOPES_PER_FORMAT in the in-canvas dialog.
@@ -1549,6 +1563,8 @@ function ExportDialog({
1549
1563
  // Export-with-audio (DDR-148 addendum) — mp4/webm only; gif is silent by
1550
1564
  // format, so the checkbox never renders for it (see hasAudioToggle below).
1551
1565
  if (card.format === 'mp4' || card.format === 'webm') options.audio = audio;
1566
+ // Long-comp cap raise — the visible notice above the button is the consent.
1567
+ if (exportOverCap) options.maxFrames = exportCompFrames;
1552
1568
  // feature-2-print-artboards T5/T6 — always sent on a PDF export; the
1553
1569
  // adapter no-ops includeBleed/marks for a non-print artboard (T5).
1554
1570
  if (card.format === 'pdf') {
@@ -1698,6 +1714,21 @@ function ExportDialog({
1698
1714
  {scale}× the artboard's native resolution (e.g. 960×540 → {960 * scale}×{540 * scale}).
1699
1715
  </div>
1700
1716
  )}
1717
+ {exportIsHeavy && (
1718
+ <div
1719
+ className="st-mono st-export-long-notice"
1720
+ data-testid="export-long-comp-notice"
1721
+ style={{ fontSize: 11, color: 'var(--accent)', lineHeight: 1.5 }}
1722
+ >
1723
+ ⚠ Long comp: {exportCompFrames} frames (≈
1724
+ {Math.round(exportCompFrames / exportCompFps)}s).
1725
+ {exportOverCap
1726
+ ? ` Exceeds the default ${EXPORT_DEFAULT_CAP}-frame export cap — exporting raises the cap to the full length for this run.`
1727
+ : ''}{' '}
1728
+ If the fast renderer falls back to frame-by-frame capture this can take several
1729
+ minutes; 2× resolution (≈720–1080p) is recommended for memory headroom.
1730
+ </div>
1731
+ )}
1701
1732
  {!card.handoff && card.format === 'pdf' && (
1702
1733
  <>
1703
1734
  <div className="st-dialog-row">
@@ -9228,6 +9259,8 @@ function App() {
9228
9259
  // DDR-148 — parsed sequence/keyframe rows for the Timeline (from raw .tsx).
9229
9260
  const [timelineSequences, setTimelineSequences] = useState([]);
9230
9261
  const [timelineAudio, setTimelineAudio] = useState([]);
9262
+ // Task 5 — seam transitions between series beats ({ afterIndex, dur }).
9263
+ const [timelineTransitions, setTimelineTransitions] = useState([]);
9231
9264
  const [timelineTotal, setTimelineTotal] = useState(0);
9232
9265
  // On a multi-comp canvas the Timeline drives ONE comp (the parser scopes to it
9233
9266
  // + reports its total). Match that total back to the announced comp id so the
@@ -9260,6 +9293,21 @@ function App() {
9260
9293
  useEffect(() => {
9261
9294
  timelineArtboardIdRef.current = timelineArtboardId;
9262
9295
  }, [timelineArtboardId]);
9296
+ // feature-enhanced-video-editing (Task 3) — the timeline's single-select model.
9297
+ // Holds the selected clip's stableId (NOT a row index — DDR-150), so the
9298
+ // selection survives comp-clips refetches, reorders, and external file edits.
9299
+ const [timelineSelectedClip, setTimelineSelectedClip] = useState(null);
9300
+ // Task 8 — bumped after a concurrent-edit refusal so the source + comp-clips
9301
+ // effects refetch ("Timeline changed — reloaded, try again").
9302
+ const [timelineRefresh, setTimelineRefresh] = useState(0);
9303
+ const timelineOpFailed = useCallback((prefix, msg) => {
9304
+ if (/concurrent edit|changed since it was read/i.test(msg || '')) {
9305
+ shellToast('Timeline changed — reloaded, try again.');
9306
+ setTimelineRefresh((n) => n + 1);
9307
+ } else {
9308
+ shellToast(`${prefix}: ${msg || 'failed'}`);
9309
+ }
9310
+ }, []);
9263
9311
  // Phase 31 (DDR-123) — the native ACP chat sidepanel (right dock, native-only).
9264
9312
  const [assistantOpen, setAssistantOpen] = useState(false);
9265
9313
  const [assistantBusy, setAssistantBusy] = useState(false);
@@ -9740,13 +9788,181 @@ function App() {
9740
9788
  })
9741
9789
  .catch(() => shellToast(`${dir === 'undo' ? 'Undo' : 'Redo'} failed: network error`));
9742
9790
  }, []);
9791
+ // Task 3 — resolve a `{ stableId, index }` clipRef against a fresh comp-clips
9792
+ // response. stableId wins (multi-comp-safe, survives reorder); the row index
9793
+ // stays as the legacy fallback for rows the enumerator couldn't identify.
9794
+ const resolveClipRef = useCallback((cc, clipRef) => {
9795
+ const seqs =
9796
+ cc?.ok && Array.isArray(cc.clips) ? cc.clips.filter((c) => c.kind === 'sequence') : [];
9797
+ if (clipRef == null) return null;
9798
+ if (typeof clipRef === 'object') {
9799
+ return (
9800
+ (clipRef.stableId ? seqs.find((c) => c.stableId === clipRef.stableId) : null) ||
9801
+ (Number.isInteger(clipRef.index) ? seqs[clipRef.index] : null) ||
9802
+ null
9803
+ );
9804
+ }
9805
+ if (typeof clipRef === 'string') return seqs.find((c) => c.stableId === clipRef) || null;
9806
+ return seqs[clipRef] || null;
9807
+ }, []);
9808
+
9809
+ // Task 3 — remove a clip (with server-side ripple for series beats). Shared by
9810
+ // the panel's ×/context-menu and the Delete/Backspace key on the selection.
9811
+ const timelineRemoveClip = useCallback(
9812
+ (clipRef) => {
9813
+ const canvas = tlKeyRef.current.canvas;
9814
+ if (!canvas || canvas === SYSTEM_TAB) return;
9815
+ const artboardId = timelineArtboardIdRef.current || undefined;
9816
+ const ccUrl = `/_api/comp-clips?canvas=${encodeURIComponent(canvas)}${artboardId ? `&artboardId=${encodeURIComponent(artboardId)}` : ''}`;
9817
+ fetch(ccUrl)
9818
+ .then((r) => r.json().catch(() => ({})))
9819
+ .then((cc) => {
9820
+ const clip = resolveClipRef(cc, clipRef);
9821
+ if (!clip?.stableId) return null;
9822
+ return fetch('/_api/remove-sequence', {
9823
+ method: 'POST',
9824
+ headers: { 'content-type': 'application/json' },
9825
+ body: JSON.stringify({
9826
+ canvas,
9827
+ artboardId,
9828
+ stableId: clip.stableId,
9829
+ contentHash: clip.contentHash,
9830
+ }),
9831
+ });
9832
+ })
9833
+ .then((r) => (r ? r.json() : null))
9834
+ .then((j) => {
9835
+ if (j && !j.ok) {
9836
+ console.warn('[remove-clip]', j.error || 'failed');
9837
+ timelineOpFailed('Remove refused', j.error);
9838
+ } else if (j?.ok) {
9839
+ shellToast('Clip removed.', true);
9840
+ if (j.seq != null) pushTlUndo(canvas, j.seq, 'remove clip');
9841
+ setTimelineSelectedClip(null);
9842
+ }
9843
+ })
9844
+ .catch(() => shellToast('Remove failed: network error'));
9845
+ },
9846
+ [resolveClipRef, pushTlUndo]
9847
+ );
9848
+ // Phase 2/3 — the parametric verb pipe (speed/trim-in/audio/detach-audio/
9849
+ // framing/grade/transition/split/insert-transition/remove-transition), shared
9850
+ // by the inspector popover, context menu, seam chips, and keyboard (⌘B).
9851
+ const timelineClipVerb = useCallback(
9852
+ (clipRef, verb, params) => {
9853
+ const canvas = tlKeyRef.current.canvas;
9854
+ if (!canvas || canvas === SYSTEM_TAB) return;
9855
+ const artboardId = timelineArtboardIdRef.current || undefined;
9856
+ const ccUrl = `/_api/comp-clips?canvas=${encodeURIComponent(canvas)}${artboardId ? `&artboardId=${encodeURIComponent(artboardId)}` : ''}`;
9857
+ fetch(ccUrl)
9858
+ .then((r) => r.json().catch(() => ({})))
9859
+ .then((cc) => {
9860
+ const all = cc?.ok && Array.isArray(cc.clips) ? cc.clips : [];
9861
+ const clip = clipRef?.transition
9862
+ ? all.find((c) => c.kind === 'transition' && c.stableId === clipRef.stableId) || null
9863
+ : resolveClipRef(cc, clipRef);
9864
+ if (!clip?.stableId) return null;
9865
+ return fetch('/_api/clip-edit', {
9866
+ method: 'POST',
9867
+ headers: { 'content-type': 'application/json' },
9868
+ body: JSON.stringify({
9869
+ canvas,
9870
+ artboardId,
9871
+ stableId: clip.stableId,
9872
+ contentHash: clip.contentHash,
9873
+ verb,
9874
+ ...params,
9875
+ }),
9876
+ });
9877
+ })
9878
+ .then((r) => (r ? r.json() : null))
9879
+ .then((j) => {
9880
+ if (j && !j.ok) {
9881
+ console.warn('[clip-edit]', verb, j.error || 'failed');
9882
+ timelineOpFailed(`${verb} refused`, j.error);
9883
+ } else if (j?.ok) {
9884
+ const labels = {
9885
+ speed: 'set speed',
9886
+ 'trim-in': 'trim in-point',
9887
+ audio: 'clip audio',
9888
+ 'detach-audio': 'detach audio',
9889
+ framing: 'crop clip',
9890
+ grade: 'grade clip',
9891
+ transition: 'edit transition',
9892
+ split: 'split clip',
9893
+ 'insert-transition': 'add transition',
9894
+ 'remove-transition': 'remove transition',
9895
+ 'set-text': 'edit text',
9896
+ 'to-overlay': 'move to overlay layer',
9897
+ 'to-storyline': 'move to storyline',
9898
+ 'layer-order': 'reorder layers',
9899
+ };
9900
+ shellToast(`Applied: ${labels[verb] || verb}.`, true);
9901
+ if (j.seq != null) pushTlUndo(canvas, j.seq, labels[verb] || verb);
9902
+ }
9903
+ })
9904
+ .catch(() => shellToast(`${verb} failed: network error`));
9905
+ },
9906
+ [resolveClipRef, pushTlUndo, timelineOpFailed]
9907
+ );
9908
+ // Tauri's WKWebView does NOT implement window.prompt() (returns null
9909
+ // synchronously — the "click does nothing" desktop bug), so every text ask
9910
+ // goes through this promise-based shell modal instead.
9911
+ const [shellPromptState, setShellPromptState] = useState(null); // { title, value, resolve }
9912
+ const askText = useCallback(
9913
+ (title, initial = '') =>
9914
+ new Promise((resolve) => {
9915
+ setShellPromptState({ title, value: initial, resolve });
9916
+ }),
9917
+ []
9918
+ );
9919
+ const settleShellPrompt = useCallback((value) => {
9920
+ setShellPromptState((s) => {
9921
+ s?.resolve?.(value);
9922
+ return null;
9923
+ });
9924
+ }, []);
9925
+
9926
+ // Task 23 — timeline comments ride the EXISTING comment system (same WS
9927
+ // channel + `_comments/` store), with a `timeline` anchor:
9928
+ // { clipStableId, frameOffset } (survives reorder/ripple) or { frame }.
9929
+ const timelineAddComment = useCallback((anchor, text) => {
9930
+ const canvas = tlKeyRef.current.canvas;
9931
+ if (!canvas || canvas === SYSTEM_TAB || !text || !String(text).trim()) return;
9932
+ wsSend({
9933
+ type: 'comments-add',
9934
+ payload: {
9935
+ file: canvas,
9936
+ text: String(text).trim(),
9937
+ selector: '',
9938
+ dom_path: [],
9939
+ tag: '',
9940
+ classes: '',
9941
+ bounds: null,
9942
+ html_excerpt: '',
9943
+ timeline: anchor,
9944
+ },
9945
+ });
9946
+ shellToast('Comment added.', true);
9947
+ }, []);
9948
+ tlKeyRef.current.selected = timelineSelectedClip;
9949
+ tlKeyRef.current.setSelected = setTimelineSelectedClip;
9950
+ tlKeyRef.current.removeClip = timelineRemoveClip;
9951
+ tlKeyRef.current.clipVerb = timelineClipVerb;
9952
+ tlKeyRef.current.addComment = timelineAddComment;
9953
+ tlKeyRef.current.askText = askText;
9954
+
9743
9955
  useEffect(() => {
9744
9956
  const onKey = (e) => {
9745
9957
  const s = tlKeyRef.current;
9746
9958
  if (!s.open || !s.comps?.length) return;
9747
9959
  const t = e.target;
9748
9960
  const tag = t?.tagName;
9749
- if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t?.isContentEditable) return;
9961
+ // Range sliders (zoom / volume) keep focus after a drag — Space must
9962
+ // still play/pause (the "spacebar stopped working" dogfood bug). Text
9963
+ // inputs stay exempt.
9964
+ if (tag === 'INPUT' && t?.type !== 'range') return;
9965
+ if (tag === 'TEXTAREA' || tag === 'SELECT' || t?.isContentEditable) return;
9750
9966
  const fps = s.comps[0]?.fps || 30;
9751
9967
  const total = Math.max(1, s.total);
9752
9968
  const doSeek = (f) => {
@@ -9774,6 +9990,39 @@ function App() {
9774
9990
  tlUndoRedo(e.shiftKey ? 'redo' : 'undo');
9775
9991
  return;
9776
9992
  }
9993
+ // Task 3 — the select → act grammar: Esc deselects, Delete/Backspace
9994
+ // removes the selection (with ripple on the server for series beats).
9995
+ if (e.key === 'Escape') {
9996
+ if (s.selected != null) {
9997
+ e.preventDefault();
9998
+ s.setSelected?.(null);
9999
+ }
10000
+ return;
10001
+ }
10002
+ if ((e.key === 'Delete' || e.key === 'Backspace') && s.selected != null) {
10003
+ e.preventDefault();
10004
+ s.removeClip?.({ stableId: s.selected });
10005
+ return;
10006
+ }
10007
+ // Task 23 + dogfood 2026-07-30 — `C` ARMS the panel's comment tool
10008
+ // (click-to-place, like the artboard's C); the panel's own window
10009
+ // keydown owns the toggle, so the shell must NOT double-handle it.
10010
+ // Task 16 — ⌘B splits the selection at the playhead; with nothing
10011
+ // selected, the clip under the playhead (iMovie behavior).
10012
+ if ((e.metaKey || e.ctrlKey) && (e.key === 'b' || e.key === 'B')) {
10013
+ e.preventDefault();
10014
+ let ref = s.selected != null ? { stableId: s.selected } : null;
10015
+ if (!ref) {
10016
+ const rows = s.sequences || [];
10017
+ const under =
10018
+ rows.find((r2) => r2.series && s.frame >= r2.from && s.frame < r2.from + r2.duration) ||
10019
+ rows.find((r2) => s.frame >= r2.from && s.frame < r2.from + r2.duration);
10020
+ if (under?.stableId) ref = { stableId: under.stableId };
10021
+ }
10022
+ if (ref) s.clipVerb?.(ref, 'split', { atFrame: s.frame });
10023
+ else shellToast('Nothing under the playhead to split.');
10024
+ return;
10025
+ }
9777
10026
  if (e.key === ' ' || e.code === 'Space') {
9778
10027
  e.preventDefault();
9779
10028
  if (s.playing) {
@@ -9821,6 +10070,10 @@ function App() {
9821
10070
  setActiveComps([]);
9822
10071
  setTimelineFrame(0);
9823
10072
  setTimelinePlaying(false);
10073
+ setTimelineSelectedClip(null);
10074
+ // A stale artboard id from the PREVIOUS canvas would mis-scope the first
10075
+ // ops on the next one until its parse lands — clear it with the rest.
10076
+ setTimelineArtboardId(null);
9824
10077
  const t = setTimeout(() => postToActiveCanvas({ dgn: 'timeline-request-comps' }), 60);
9825
10078
  return () => clearTimeout(t);
9826
10079
  }, [activePath, postToActiveCanvas]);
@@ -9832,6 +10085,9 @@ function App() {
9832
10085
  // wrapper components + array-fed src the regex parser can't see), merged into
9833
10086
  // the rows for the kind badge + replace addressing.
9834
10087
  const [timelineClipMedia, setTimelineClipMedia] = useState([]);
10088
+ // Phase 2 — the enumerator's transitions (stableId + contentHash), in seam
10089
+ // order, for the ⧓ chip → Transition tab flow.
10090
+ const [timelineTransClips, setTimelineTransClips] = useState([]);
9835
10091
  useEffect(() => {
9836
10092
  if (!timelineOpen || activeComps.length === 0 || !activePath || activePath === SYSTEM_TAB) {
9837
10093
  setTimelineSource('');
@@ -9848,7 +10104,7 @@ function App() {
9848
10104
  return () => {
9849
10105
  alive = false;
9850
10106
  };
9851
- }, [timelineOpen, activeComps, activePath]);
10107
+ }, [timelineOpen, activeComps, activePath, timelineRefresh]);
9852
10108
 
9853
10109
  // Authoritative per-clip media (comp-clips) — a SEPARATE effect keyed on the
9854
10110
  // RESOLVED artboard id, which the parser only knows AFTER timelineSource
@@ -9859,6 +10115,7 @@ function App() {
9859
10115
  useEffect(() => {
9860
10116
  if (!timelineOpen || !activePath || activePath === SYSTEM_TAB || !timelineSource) {
9861
10117
  setTimelineClipMedia([]);
10118
+ setTimelineTransClips([]);
9862
10119
  return undefined;
9863
10120
  }
9864
10121
  let alive = true;
@@ -9868,15 +10125,15 @@ function App() {
9868
10125
  .then((r) => (r.ok ? r.json() : null))
9869
10126
  .then((cc) => {
9870
10127
  if (!alive) return;
9871
- const seqs =
9872
- cc?.ok && Array.isArray(cc.clips) ? cc.clips.filter((c) => c.kind === 'sequence') : [];
9873
- setTimelineClipMedia(seqs);
10128
+ const all = cc?.ok && Array.isArray(cc.clips) ? cc.clips : [];
10129
+ setTimelineClipMedia(all.filter((c) => c.kind === 'sequence'));
10130
+ setTimelineTransClips(all.filter((c) => c.kind === 'transition'));
9874
10131
  })
9875
10132
  .catch(() => {});
9876
10133
  return () => {
9877
10134
  alive = false;
9878
10135
  };
9879
- }, [timelineOpen, activePath, timelineSource, timelineArtboardId]);
10136
+ }, [timelineOpen, activePath, timelineSource, timelineArtboardId, timelineRefresh]);
9880
10137
 
9881
10138
  // Parse (cheap) — re-runs on source change AND on selection change, so the
9882
10139
  // Timeline REDRAWS to whichever artboard the user just selected/moved to.
@@ -9884,6 +10141,7 @@ function App() {
9884
10141
  if (!timelineSource) {
9885
10142
  setTimelineSequences([]);
9886
10143
  setTimelineAudio([]);
10144
+ setTimelineTransitions([]);
9887
10145
  setTimelineTotal(0);
9888
10146
  return;
9889
10147
  }
@@ -9899,6 +10157,14 @@ function App() {
9899
10157
  if (!cm) return s;
9900
10158
  return {
9901
10159
  ...s,
10160
+ // Task 3 — the enumerator's durable identity rides on every row so the
10161
+ // panel can select + address ops by stableId, never by row index.
10162
+ stableId: cm.stableId,
10163
+ contentHash: cm.contentHash,
10164
+ // Phase 2 — authoritative parametric props for the clip inspector.
10165
+ mediaProps: cm.mediaProps ?? null,
10166
+ // Task 21 — AI placeholder slate (✨ row + Generate flow).
10167
+ placeholder: cm.placeholder ?? null,
9902
10168
  mediaTag: cm.mediaTag ?? s.mediaTag,
9903
10169
  mediaSrc: cm.mediaSrc ?? s.mediaSrc,
9904
10170
  // Replaceable when the enumerator found an addressable media target:
@@ -9911,6 +10177,7 @@ function App() {
9911
10177
  });
9912
10178
  setTimelineSequences(merged);
9913
10179
  setTimelineAudio(parsed.audio || []);
10180
+ setTimelineTransitions(parsed.transitions || []);
9914
10181
  setTimelineTotal(parsed.total);
9915
10182
  }, [timelineSource, timelineClipMedia, selected, activeComps, canvasActiveArtboard]);
9916
10183
 
@@ -10706,6 +10973,41 @@ function App() {
10706
10973
  [loadTree, openTab]
10707
10974
  );
10708
10975
 
10976
+ // Task 20 (enhanced-video-editing) — greenfield "New video": an EMPTY
10977
+ // video-comp canvas (drop-first cut building). Dimensions/fps are
10978
+ // user-settable (presets 1920×1080 / 1080×1920 / 1080×1080 or custom WxH).
10979
+ const createVideo = useCallback(async () => {
10980
+ const name = await askText('New video name');
10981
+ if (!name) return;
10982
+ const dims =
10983
+ (await askText(
10984
+ 'Size — 1920x1080 (landscape), 1080x1920 (portrait), 1080x1080 (square), or custom WxH',
10985
+ '1920x1080'
10986
+ )) || '1920x1080';
10987
+ const m = dims.toLowerCase().match(/(\d{2,5})\s*[x×]\s*(\d{2,5})/);
10988
+ const width = m ? Number(m[1]) : 1920;
10989
+ const height = m ? Number(m[2]) : 1080;
10990
+ const fps = Math.max(1, Math.min(60, Number(await askText('Frames per second', '30')) || 30));
10991
+ try {
10992
+ const r = await fetch('/_api/canvas', {
10993
+ method: 'POST',
10994
+ headers: { 'Content-Type': 'application/json' },
10995
+ body: JSON.stringify({ name, kind: 'video-comp', clips: [], fps, width, height }),
10996
+ });
10997
+ const j = await r.json().catch(() => ({}));
10998
+ if (!r.ok || !j.ok) {
10999
+ shellToast(`New video failed: ${j.error || `error ${r.status}`}`);
11000
+ return;
11001
+ }
11002
+ await loadTree();
11003
+ openTab(j.file);
11004
+ setTimelineOpen(true);
11005
+ shellToast('New video created — drop clips on the timeline to start the cut.', true);
11006
+ } catch (e) {
11007
+ shellToast(`New video failed: ${e instanceof Error ? e.message : 'network error'}`);
11008
+ }
11009
+ }, [loadTree, openTab, askText]);
11010
+
10709
11011
  // DDR-150 P4 Task 12 — one-click "udělej z toho video". Reads the ACTIVE
10710
11012
  // canvas's annotation sidecar (main-origin — no cross-origin round-trip),
10711
11013
  // gathers every dropped media-reference chip's src in document order, and POSTs
@@ -13216,6 +13518,13 @@ function App() {
13216
13518
  );
13217
13519
  },
13218
13520
  },
13521
+ {
13522
+ id: 'new-video',
13523
+ group: 'Canvas',
13524
+ label: 'New video…',
13525
+ icon: 'plus',
13526
+ run: () => createVideo(),
13527
+ },
13219
13528
  {
13220
13529
  id: 'export',
13221
13530
  group: 'Canvas',
@@ -13333,7 +13642,7 @@ function App() {
13333
13642
  run: () => setReportBugOpen(true),
13334
13643
  },
13335
13644
  ],
13336
- [openSystem, toggleTheme, reloadActive, whatsNew]
13645
+ [openSystem, toggleTheme, reloadActive, whatsNew, createVideo]
13337
13646
  );
13338
13647
 
13339
13648
  // feature-configurable-panel-docking — resolve, for each slot, the panels
@@ -13739,6 +14048,7 @@ function App() {
13739
14048
  comps={activeComps}
13740
14049
  sequences={timelineSequences}
13741
14050
  audio={timelineAudio}
14051
+ transitions={timelineTransitions}
13742
14052
  total={timelineTotal}
13743
14053
  frame={timelineFrame}
13744
14054
  playing={timelinePlaying}
@@ -13785,24 +14095,20 @@ function App() {
13785
14095
  }
13786
14096
  postToActiveCanvas({ dgn: 'timeline-volume', volume: v, id: timelineCompId });
13787
14097
  }}
13788
- onRetime={(index, patch) => {
14098
+ onRetime={(clipRef, patch) => {
13789
14099
  if (!activePath || activePath === SYSTEM_TAB) return;
13790
- // DDR-150 P2 — address the clip by its comp-scoped stableId (from
13791
- // /_api/comp-clips), NOT the raw row index, so a retime on a
13792
- // multi-comp canvas can never mis-hit a clip in another comp. The
13793
- // row index counts sequence rows only, so map it to the index-th
13794
- // sequence clip (transitions filtered). Fall back to the legacy
13795
- // index if the enumerator is unavailable.
14100
+ // DDR-150 P2 / Task 3 — the panel hands a { stableId, index }
14101
+ // clipRef; stableId addressing wins (multi-comp-safe), the row
14102
+ // index stays as the legacy fallback when the enumerator is
14103
+ // unavailable.
13796
14104
  const artboardId = timelineArtboardId || undefined;
13797
14105
  const ccUrl = `/_api/comp-clips?canvas=${encodeURIComponent(activePath)}${artboardId ? `&artboardId=${encodeURIComponent(artboardId)}` : ''}`;
13798
14106
  fetch(ccUrl)
13799
14107
  .then((r) => r.json().catch(() => ({})))
13800
14108
  .then((cc) => {
13801
- const seqs =
13802
- cc?.ok && Array.isArray(cc.clips)
13803
- ? cc.clips.filter((c) => c.kind === 'sequence')
13804
- : [];
13805
- const clip = seqs[index] || null;
14109
+ const clip = resolveClipRef(cc, clipRef);
14110
+ const legacyIndex =
14111
+ clipRef && typeof clipRef === 'object' ? clipRef.index : clipRef;
13806
14112
  const body = clip?.stableId
13807
14113
  ? {
13808
14114
  canvas: activePath,
@@ -13811,7 +14117,7 @@ function App() {
13811
14117
  contentHash: clip.contentHash,
13812
14118
  ...patch,
13813
14119
  }
13814
- : { canvas: activePath, index, ...patch };
14120
+ : { canvas: activePath, index: legacyIndex, ...patch };
13815
14121
  return fetch('/_api/retime-sequence', {
13816
14122
  method: 'POST',
13817
14123
  headers: { 'content-type': 'application/json' },
@@ -13822,55 +14128,19 @@ function App() {
13822
14128
  .then((j) => {
13823
14129
  if (!j?.ok) {
13824
14130
  console.warn('[retime]', j?.error || 'failed');
13825
- shellToast(`Retime refused: ${j?.error || 'failed'}`);
13826
- } else if (j?.seq != null) {
13827
- pushTlUndo(activePath, j.seq, patch.from != null ? 'move clip' : 'trim clip');
14131
+ timelineOpFailed('Retime refused', j?.error);
14132
+ } else {
14133
+ shellToast(patch.from != null ? 'Clip moved.' : 'Clip trimmed.', true);
14134
+ if (j?.seq != null)
14135
+ pushTlUndo(activePath, j.seq, patch.from != null ? 'move clip' : 'trim clip');
13828
14136
  }
13829
14137
  // The file watcher reloads the canvas → re-announce → the
13830
14138
  // source-fetch effect re-parses the new timing.
13831
14139
  })
13832
14140
  .catch(() => {});
13833
14141
  }}
13834
- onRemove={(index) => {
13835
- if (!activePath || activePath === SYSTEM_TAB) return;
13836
- // DDR-150 P3 — remove the clip addressed by its comp-scoped stableId
13837
- // (the sequence-row index → the index-th sequence clip). The engine
13838
- // fingerprint + semantic gate refuse a stale/raced or series-breaking
13839
- // removal; the file watcher reloads the canvas after.
13840
- const artboardId = timelineArtboardId || undefined;
13841
- const ccUrl = `/_api/comp-clips?canvas=${encodeURIComponent(activePath)}${artboardId ? `&artboardId=${encodeURIComponent(artboardId)}` : ''}`;
13842
- fetch(ccUrl)
13843
- .then((r) => r.json().catch(() => ({})))
13844
- .then((cc) => {
13845
- const seqs =
13846
- cc?.ok && Array.isArray(cc.clips)
13847
- ? cc.clips.filter((c) => c.kind === 'sequence')
13848
- : [];
13849
- const clip = seqs[index] || null;
13850
- if (!clip?.stableId) return null;
13851
- return fetch('/_api/remove-sequence', {
13852
- method: 'POST',
13853
- headers: { 'content-type': 'application/json' },
13854
- body: JSON.stringify({
13855
- canvas: activePath,
13856
- artboardId,
13857
- stableId: clip.stableId,
13858
- contentHash: clip.contentHash,
13859
- }),
13860
- });
13861
- })
13862
- .then((r) => (r ? r.json() : null))
13863
- .then((j) => {
13864
- if (j && !j.ok) {
13865
- console.warn('[remove-clip]', j.error || 'failed');
13866
- shellToast(`Remove refused: ${j.error || 'failed'}`);
13867
- } else if (j?.seq != null) {
13868
- pushTlUndo(activePath, j.seq, 'remove clip');
13869
- }
13870
- })
13871
- .catch(() => {});
13872
- }}
13873
- onReplace={(index) => {
14142
+ onRemove={timelineRemoveClip}
14143
+ onReplace={(clipRef) => {
13874
14144
  if (!activePath || activePath === SYSTEM_TAB) return;
13875
14145
  // DDR-150 P3 + dogfood #5 — replace a clip's media. The file picker
13876
14146
  // MUST open synchronously inside the click gesture: browsers revoke
@@ -13881,11 +14151,7 @@ function App() {
13881
14151
  replaceMediaViaPicker({
13882
14152
  accept: 'video/*,image/*',
13883
14153
  resolveTarget: (cc) => {
13884
- const seqs =
13885
- cc?.ok && Array.isArray(cc.clips)
13886
- ? cc.clips.filter((c) => c.kind === 'sequence')
13887
- : [];
13888
- const clip = seqs[index];
14154
+ const clip = resolveClipRef(cc, clipRef);
13889
14155
  if (clip?.mediaArrayRef) return { arrayRef: clip.mediaArrayRef };
13890
14156
  if (clip?.mediaCdId) return { cdId: clip.mediaCdId };
13891
14157
  return null;
@@ -13908,32 +14174,29 @@ function App() {
13908
14174
  },
13909
14175
  });
13910
14176
  }}
13911
- onReplaceLayer={(clipIndex, layerIndex) => {
14177
+ onReplaceLayer={(clipRef, layerIndex) => {
13912
14178
  if (!activePath || activePath === SYSTEM_TAB) return;
13913
14179
  // DDR-150 dogfood — replace a SPECIFIC layer inside an expanded clip
13914
14180
  // (the mp4 background separately from the title layer). Targets the
13915
14181
  // layer's own media (array-fed or literal-src) from the enumerator.
14182
+ const rowIndex = clipRef && typeof clipRef === 'object' ? clipRef.index : clipRef;
13916
14183
  const kind =
13917
- timelineSequences[clipIndex]?.layers?.[layerIndex]?.kind === 'audio'
14184
+ timelineSequences[rowIndex]?.layers?.[layerIndex]?.kind === 'audio'
13918
14185
  ? 'audio/*'
13919
- : timelineSequences[clipIndex]?.layers?.[layerIndex]?.kind === 'image'
14186
+ : timelineSequences[rowIndex]?.layers?.[layerIndex]?.kind === 'image'
13920
14187
  ? 'image/*'
13921
14188
  : 'video/*';
13922
14189
  replaceMediaViaPicker({
13923
14190
  accept: kind,
13924
14191
  resolveTarget: (cc) => {
13925
- const seqs =
13926
- cc?.ok && Array.isArray(cc.clips)
13927
- ? cc.clips.filter((c) => c.kind === 'sequence')
13928
- : [];
13929
- const ly = seqs[clipIndex]?.layers?.[layerIndex];
14192
+ const ly = resolveClipRef(cc, clipRef)?.layers?.[layerIndex];
13930
14193
  if (ly?.mediaArrayRef) return { arrayRef: ly.mediaArrayRef };
13931
14194
  if (ly?.mediaCdId) return { cdId: ly.mediaCdId };
13932
14195
  return null;
13933
14196
  },
13934
14197
  });
13935
14198
  }}
13936
- onReorder={(index, direction) => {
14199
+ onReorder={(clipRef, direction) => {
13937
14200
  if (!activePath || activePath === SYSTEM_TAB) return;
13938
14201
  // DDR-150 P5 — z-order reorder: move a standalone <Sequence> before/
13939
14202
  // after a sibling (render stacking; later sibling paints on top).
@@ -13950,9 +14213,10 @@ function App() {
13950
14213
  cc?.ok && Array.isArray(cc.clips)
13951
14214
  ? cc.clips.filter((c) => c.kind === 'sequence')
13952
14215
  : [];
13953
- const moved = seqs[index] || null;
14216
+ const moved = resolveClipRef(cc, clipRef);
14217
+ const index = moved ? seqs.indexOf(moved) : -1;
13954
14218
  const refIdx = direction === 'forward' ? index + 1 : index - 1;
13955
- const ref = seqs[refIdx] || null;
14219
+ const ref = index >= 0 ? seqs[refIdx] || null : null;
13956
14220
  const position = direction === 'forward' ? 'after' : 'before';
13957
14221
  if (!moved?.stableId || !ref?.stableId) return null;
13958
14222
  return fetch('/_api/reorder-sequence', {
@@ -13973,14 +14237,14 @@ function App() {
13973
14237
  .then((j) => {
13974
14238
  if (j && !j.ok) {
13975
14239
  console.warn('[reorder-clip]', j.error || 'failed');
13976
- shellToast(`Reorder refused: ${j.error || 'failed'}`);
14240
+ timelineOpFailed('Reorder refused', j.error);
13977
14241
  } else if (j?.seq != null) {
13978
14242
  pushTlUndo(activePath, j.seq, 'reorder clip');
13979
14243
  }
13980
14244
  })
13981
14245
  .catch(() => {});
13982
14246
  }}
13983
- onToggleHide={(index) => {
14247
+ onToggleHide={(clipRef) => {
13984
14248
  if (!activePath || activePath === SYSTEM_TAB) return;
13985
14249
  // DDR-150 dogfood — hide/show a clip (gates its body behind
13986
14250
  // {false && …}; the tag + time slot stay). Addressed by comp-scoped
@@ -13990,11 +14254,7 @@ function App() {
13990
14254
  fetch(ccUrl)
13991
14255
  .then((r) => r.json().catch(() => ({})))
13992
14256
  .then((cc) => {
13993
- const seqs =
13994
- cc?.ok && Array.isArray(cc.clips)
13995
- ? cc.clips.filter((c) => c.kind === 'sequence')
13996
- : [];
13997
- const clip = seqs[index] || null;
14257
+ const clip = resolveClipRef(cc, clipRef);
13998
14258
  if (!clip?.stableId) return null;
13999
14259
  return fetch('/_api/toggle-hide', {
14000
14260
  method: 'POST',
@@ -14009,7 +14269,7 @@ function App() {
14009
14269
  })
14010
14270
  .then((r) => (r ? r.json() : null))
14011
14271
  .then((j) => {
14012
- if (j && !j.ok) shellToast(`Hide refused: ${j.error || 'failed'}`);
14272
+ if (j && !j.ok) timelineOpFailed('Hide refused', j.error);
14013
14273
  else if (j && j.ok) {
14014
14274
  shellToast(j.hidden ? 'Clip hidden.' : 'Clip shown.', true);
14015
14275
  if (j.seq != null) pushTlUndo(activePath, j.seq, j.hidden ? 'hide clip' : 'show clip');
@@ -14017,60 +14277,457 @@ function App() {
14017
14277
  })
14018
14278
  .catch(() => shellToast('Hide failed: network error'));
14019
14279
  }}
14020
- onDropMedia={(file) => {
14280
+ onReorderMove={(movedRef2, targetRef, position) => {
14021
14281
  if (!activePath || activePath === SYSTEM_TAB) return;
14022
- // DDR-150 P4 Task 11 — drop a media file onto the timeline to
14023
- // insert a clip: upload to assets/, then append a <Sequence> with
14024
- // the media (playing after existing content). fps*3 default length.
14282
+ // Task 6 — the magnetic drag commit: a real series MOVE (any
14283
+ // distance), addressed by stableId pair + fingerprints.
14025
14284
  const artboardId = timelineArtboardId || undefined;
14026
- const mediaTag = file.type.startsWith('video/')
14027
- ? 'Video'
14028
- : file.type.startsWith('audio/')
14029
- ? 'Audio'
14030
- : file.type.startsWith('image/')
14031
- ? 'Img'
14032
- : null;
14033
- const fps = activeComps[0]?.fps || 30;
14034
- fetch('/_api/asset', {
14035
- method: 'POST',
14036
- headers: { 'Content-Type': file.type || 'application/octet-stream' },
14037
- body: file,
14038
- })
14285
+ const ccUrl = `/_api/comp-clips?canvas=${encodeURIComponent(activePath)}${artboardId ? `&artboardId=${encodeURIComponent(artboardId)}` : ''}`;
14286
+ fetch(ccUrl)
14039
14287
  .then((r) => r.json().catch(() => ({})))
14040
- .then((up) => {
14041
- if (!up?.path) {
14042
- console.warn('[insert] upload failed', up?.error);
14043
- shellToast(`Upload failed: ${up?.error || 'unknown error'}`);
14044
- return null;
14045
- }
14046
- return fetch('/_api/insert-sequence', {
14288
+ .then((cc) => {
14289
+ const moved = resolveClipRef(cc, movedRef2);
14290
+ const ref = resolveClipRef(cc, targetRef);
14291
+ if (!moved?.stableId || !ref?.stableId) return null;
14292
+ return fetch('/_api/reorder-sequence', {
14047
14293
  method: 'POST',
14048
14294
  headers: { 'content-type': 'application/json' },
14049
14295
  body: JSON.stringify({
14050
14296
  canvas: activePath,
14051
14297
  artboardId,
14052
- from: Math.max(0, timelineTotal),
14053
- durationInFrames: Math.round(fps * 3),
14054
- mediaTag,
14055
- src: up.path,
14298
+ stableId: moved.stableId,
14299
+ contentHash: moved.contentHash,
14300
+ refStableId: ref.stableId,
14301
+ refContentHash: ref.contentHash,
14302
+ position,
14303
+ mode: 'move',
14056
14304
  }),
14057
14305
  });
14058
14306
  })
14059
14307
  .then((r) => (r ? r.json() : null))
14060
14308
  .then((j) => {
14061
14309
  if (j && !j.ok) {
14062
- console.warn('[insert-clip]', j.error || 'failed');
14063
- shellToast(`Insert refused: ${j.error || 'failed'}`);
14064
- } else if (j && j.ok) {
14065
- shellToast('Clip added to the timeline.', true);
14066
- if (j.seq != null) pushTlUndo(activePath, j.seq, 'add clip');
14310
+ console.warn('[reorder-move]', j.error || 'failed');
14311
+ timelineOpFailed('Reorder refused', j.error);
14312
+ } else if (j?.ok) {
14313
+ shellToast('Clip moved.', true);
14314
+ if (j.seq != null) pushTlUndo(activePath, j.seq, 'move clip');
14067
14315
  }
14068
14316
  })
14069
- .catch(() => {});
14317
+ .catch(() => shellToast('Reorder failed: network error'));
14318
+ }}
14319
+ onDropMedia={(files, pos) => {
14320
+ if (!activePath || activePath === SYSTEM_TAB) return;
14321
+ // DDR-150 P4 + Task 6 — drop media onto the timeline. With a caret
14322
+ // position: index-aware storyline insert / frame-anchored
14323
+ // overlay-audio insert; multiple files insert in order. Audio
14324
+ // files always land in the audio band. Without a position, the
14325
+ // legacy append path.
14326
+ const artboardId = timelineArtboardId || undefined;
14327
+ const fps = activeComps[0]?.fps || 30;
14328
+ const canvas = activePath;
14329
+ const list = (Array.isArray(files) ? files : [files]).filter(Boolean);
14330
+ (async () => {
14331
+ // Dogfood fix — dropping media on the timeline of a canvas with
14332
+ // NO video-comp used to dead-end in "Insert refused: no
14333
+ // video-comp for this artboard". Drop-first means drop-first:
14334
+ // upload the files and spin up a NEW video-comp canvas cut from
14335
+ // them (same engine as File → Assemble), then open it.
14336
+ if (!activeComps.length) {
14337
+ const clips = [];
14338
+ for (const file of list) {
14339
+ const mediaKind = file.type.startsWith('audio/')
14340
+ ? 'audio'
14341
+ : file.type.startsWith('video/')
14342
+ ? 'video'
14343
+ : null;
14344
+ if (!mediaKind) continue;
14345
+ try {
14346
+ const r = await fetch('/_api/asset', {
14347
+ method: 'POST',
14348
+ headers: { 'Content-Type': file.type || 'application/octet-stream' },
14349
+ body: file,
14350
+ });
14351
+ const up = await r.json().catch(() => ({}));
14352
+ if (up?.path) clips.push({ src: up.path, mediaKind });
14353
+ else shellToast(`Upload failed: ${up?.error || `HTTP ${r.status}`}`);
14354
+ } catch {
14355
+ shellToast('Upload failed: network error');
14356
+ }
14357
+ }
14358
+ if (!clips.length) {
14359
+ shellToast('No video/audio files in the drop — nothing to cut.');
14360
+ return;
14361
+ }
14362
+ // 1) In-place upgrade: a `kind="video"` artboard on THIS
14363
+ // canvas gets a VideoComp injected and takes the clips as
14364
+ // storyline beats (server-side ensure). Falls through to a
14365
+ // fresh "New Cut" canvas only when no artboard opted in.
14366
+ let upgraded = false;
14367
+ for (const c of clips) {
14368
+ const body = {
14369
+ canvas,
14370
+ lane: c.mediaKind === 'audio' && upgraded ? 'audio' : 'storyline',
14371
+ durationInFrames: Math.round(fps * 3),
14372
+ mediaTag: c.mediaKind === 'audio' ? 'Audio' : 'Video',
14373
+ src: c.src,
14374
+ };
14375
+ if (body.lane === 'audio') body.from = 0;
14376
+ const r = await fetch('/_api/insert-sequence', {
14377
+ method: 'POST',
14378
+ headers: { 'content-type': 'application/json' },
14379
+ body: JSON.stringify(body),
14380
+ }).catch(() => null);
14381
+ const j = r ? await r.json().catch(() => null) : null;
14382
+ if (j?.ok) {
14383
+ upgraded = true;
14384
+ if (j.seq != null) pushTlUndo(canvas, j.seq, 'add clip');
14385
+ } else if (!upgraded) {
14386
+ break; // no eligible artboard — New Cut fallback below
14387
+ } else {
14388
+ timelineOpFailed('Insert refused', j?.error);
14389
+ }
14390
+ }
14391
+ if (upgraded) {
14392
+ shellToast('Artboard upgraded to a video comp — clips added to the storyline.', true);
14393
+ return;
14394
+ }
14395
+ for (let n = 0; n < 8; n += 1) {
14396
+ const name = n === 0 ? 'New Cut' : `New Cut ${n + 1}`;
14397
+ const r = await fetch('/_api/canvas', {
14398
+ method: 'POST',
14399
+ headers: { 'Content-Type': 'application/json' },
14400
+ body: JSON.stringify({ name, kind: 'video-comp', clips }),
14401
+ }).catch(() => null);
14402
+ const j = r ? await r.json().catch(() => ({})) : {};
14403
+ if (r?.status === 409) continue; // name taken — try the next
14404
+ if (!r?.ok || !j.ok) {
14405
+ shellToast(`New cut failed: ${j.error || 'create refused'}`);
14406
+ return;
14407
+ }
14408
+ await loadTree();
14409
+ openTab(j.file);
14410
+ shellToast(`Started a new cut from ${clips.length} clip${clips.length > 1 ? 's' : ''}.`, true);
14411
+ return;
14412
+ }
14413
+ shellToast('New cut failed: too many "New Cut" canvases — rename some.');
14414
+ return;
14415
+ }
14416
+ let slot = pos?.lane === 'storyline' ? pos.index : undefined;
14417
+ for (const file of list) {
14418
+ const mediaTag = file.type.startsWith('video/')
14419
+ ? 'Video'
14420
+ : file.type.startsWith('audio/')
14421
+ ? 'Audio'
14422
+ : file.type.startsWith('image/')
14423
+ ? 'Img'
14424
+ : null;
14425
+ if (!mediaTag) continue;
14426
+ let up;
14427
+ try {
14428
+ const r = await fetch('/_api/asset', {
14429
+ method: 'POST',
14430
+ headers: { 'Content-Type': file.type || 'application/octet-stream' },
14431
+ body: file,
14432
+ });
14433
+ up = await r.json().catch(() => ({}));
14434
+ } catch {
14435
+ up = null;
14436
+ }
14437
+ if (!up?.path) {
14438
+ shellToast(`Upload failed: ${up?.error || 'server unreachable — retry in a moment'}`);
14439
+ continue;
14440
+ }
14441
+ const isAudio = mediaTag === 'Audio';
14442
+ const body = { canvas, artboardId, mediaTag, src: up.path };
14443
+ if (pos && (isAudio || pos.lane === 'audio')) {
14444
+ body.lane = 'audio';
14445
+ body.from = Math.max(0, pos.frame ?? 0);
14446
+ // Default: stretch toward the end of the cut.
14447
+ body.durationInFrames = Math.max(fps, timelineTotal - body.from);
14448
+ } else if (pos?.lane === 'storyline' && !isAudio) {
14449
+ body.lane = 'storyline';
14450
+ if (slot != null) {
14451
+ body.index = slot;
14452
+ slot += 1; // multiple files insert in order
14453
+ }
14454
+ body.durationInFrames = Math.round(fps * 3);
14455
+ } else if (pos?.lane === 'overlay' && !isAudio) {
14456
+ if ((timelineSequences || []).length === 0) {
14457
+ // First clip of a greenfield comp = the BASE layer →
14458
+ // storyline, wherever it was dropped.
14459
+ body.lane = 'storyline';
14460
+ } else {
14461
+ body.lane = 'overlay';
14462
+ body.from = Math.max(0, pos.frame ?? 0);
14463
+ }
14464
+ body.durationInFrames = Math.round(fps * 3);
14465
+ } else if (!isAudio) {
14466
+ // Default container rule: a video/image drop ANYWHERE on the
14467
+ // timeline lands in the storyline (append = hard cut at the
14468
+ // end; the caret gives it an index). The old lane-less
14469
+ // append refused hard-cut series ("no transition to clone").
14470
+ body.lane = 'storyline';
14471
+ body.durationInFrames = Math.round(fps * 3);
14472
+ } else {
14473
+ // Audio without a caret → audio band, from the start.
14474
+ body.lane = 'audio';
14475
+ body.from = 0;
14476
+ body.durationInFrames = Math.max(fps, timelineTotal);
14477
+ }
14478
+ try {
14479
+ const r = await fetch('/_api/insert-sequence', {
14480
+ method: 'POST',
14481
+ headers: { 'content-type': 'application/json' },
14482
+ body: JSON.stringify(body),
14483
+ });
14484
+ const j = await r.json().catch(() => null);
14485
+ if (j && !j.ok) {
14486
+ console.warn('[insert-clip]', j.error || 'failed');
14487
+ timelineOpFailed('Insert refused', j.error);
14488
+ } else if (j?.ok) {
14489
+ shellToast('Clip added to the timeline.', true);
14490
+ if (j.seq != null) pushTlUndo(canvas, j.seq, 'add clip');
14491
+ }
14492
+ } catch {
14493
+ shellToast('Insert failed: network error');
14494
+ }
14495
+ }
14496
+ })();
14070
14497
  }}
14071
14498
  height={timelineHeight}
14072
14499
  onResize={setTimelineHeight}
14073
14500
  onClose={() => setTimelineOpen(false)}
14501
+ selectedClipId={timelineSelectedClip}
14502
+ onSelect={setTimelineSelectedClip}
14503
+ transitionClips={timelineTransClips}
14504
+ onClipVerb={timelineClipVerb}
14505
+ comments={(commentsByFile[activePath] || []).filter((c) => c && c.timeline)}
14506
+ promptText={askText}
14507
+ onAddComment={timelineAddComment}
14508
+ onResolveComment={(id) =>
14509
+ wsSend({ type: 'comments-patch', id, patch: { status: 'resolved' } })
14510
+ }
14511
+ onDeleteComment={(id) => wsSend({ type: 'comments-delete', id })}
14512
+ onSplitAtPlayhead={() => {
14513
+ const s = tlKeyRef.current;
14514
+ let ref = s.selected != null ? { stableId: s.selected } : null;
14515
+ if (!ref) {
14516
+ const rows = s.sequences || [];
14517
+ const under =
14518
+ rows.find(
14519
+ (r2) => r2.series && s.frame >= r2.from && s.frame < r2.from + r2.duration
14520
+ ) || rows.find((r2) => s.frame >= r2.from && s.frame < r2.from + r2.duration);
14521
+ if (under?.stableId) ref = { stableId: under.stableId };
14522
+ }
14523
+ if (ref) timelineClipVerb(ref, 'split', { atFrame: s.frame });
14524
+ else shellToast('Nothing under the playhead to split.');
14525
+ }}
14526
+ onAddTitle={(frame) => {
14527
+ if (!activePath || activePath === SYSTEM_TAB) return;
14528
+ // Task 19 — "+ Title": inserts immediately with a default text —
14529
+ // edit it via double-click → inspector → Text (the artboard's
14530
+ // Player DOM isn't the canvas edit surface, so inline editing
14531
+ // happens in the timeline inspector).
14532
+ Promise.resolve('Title').then((text) => {
14533
+ if (!text) return;
14534
+ const fps = activeComps[0]?.fps || 30;
14535
+ fetch('/_api/insert-sequence', {
14536
+ method: 'POST',
14537
+ headers: { 'content-type': 'application/json' },
14538
+ body: JSON.stringify({
14539
+ canvas: activePath,
14540
+ artboardId: timelineArtboardId || undefined,
14541
+ lane: 'overlay',
14542
+ from: frame,
14543
+ durationInFrames: Math.round(fps * 3),
14544
+ mediaTag: 'Title',
14545
+ src: text,
14546
+ }),
14547
+ })
14548
+ .then((r) => r.json().catch(() => null))
14549
+ .then((j) => {
14550
+ if (j && !j.ok) timelineOpFailed('Title refused', j.error);
14551
+ else if (j?.ok) {
14552
+ shellToast('Title added.', true);
14553
+ if (j.seq != null) pushTlUndo(activePath, j.seq, 'add title');
14554
+ }
14555
+ })
14556
+ .catch(() => shellToast('Title failed: network error'));
14557
+ });
14558
+ }}
14559
+ onAddAiClip={() => {
14560
+ if (!activePath || activePath === SYSTEM_TAB) return;
14561
+ // Task 22 — "+ AI clip": a prompt-carrying slate beat at the end
14562
+ // of the storyline. NO modal (user steer 2026-07-30) — the slate
14563
+ // lands with a starter prompt the user rewrites IN PLACE
14564
+ // (double-click the slate text in the artboard, or Text tab).
14565
+ (async () => {
14566
+ const prompt = 'Describe this shot — double-click to edit';
14567
+ const kind = 'veo';
14568
+ const fps = activeComps[0]?.fps || 30;
14569
+ fetch('/_api/insert-sequence', {
14570
+ method: 'POST',
14571
+ headers: { 'content-type': 'application/json' },
14572
+ body: JSON.stringify({
14573
+ canvas: activePath,
14574
+ artboardId: timelineArtboardId || undefined,
14575
+ lane: 'storyline',
14576
+ durationInFrames: Math.round(fps * 5),
14577
+ placeholder: { prompt, kind },
14578
+ }),
14579
+ })
14580
+ .then((r) => r.json().catch(() => null))
14581
+ .then((j) => {
14582
+ if (j && !j.ok) timelineOpFailed('AI clip refused', j.error);
14583
+ else if (j?.ok) {
14584
+ shellToast('AI placeholder added — double-click its text to write the prompt, then right-click → Generate ✨.', true);
14585
+ if (j.seq != null) pushTlUndo(activePath, j.seq, 'add AI placeholder');
14586
+ }
14587
+ })
14588
+ .catch(() => shellToast('AI clip failed: network error'));
14589
+ })();
14590
+ }}
14591
+ onGeneratePlaceholder={(clipRef) => {
14592
+ if (!activePath || activePath === SYSTEM_TAB) return;
14593
+ // Task 22 — hand the prompt to the existing generation spine
14594
+ // (DDR-164), then poll the job and swap the slate in place. The
14595
+ // placeholder stays fully editable while the job runs; the final
14596
+ // swap re-resolves by stableId with a FRESH fingerprint.
14597
+ const canvas = activePath;
14598
+ const artboardId = timelineArtboardId || undefined;
14599
+ const ccUrl = `/_api/comp-clips?canvas=${encodeURIComponent(canvas)}${artboardId ? `&artboardId=${encodeURIComponent(artboardId)}` : ''}`;
14600
+ fetch(ccUrl)
14601
+ .then((r) => r.json().catch(() => ({})))
14602
+ .then((cc) => {
14603
+ const clip = resolveClipRef(cc, clipRef);
14604
+ const ph = clip?.placeholder;
14605
+ if (!clip?.stableId || !ph?.prompt) {
14606
+ shellToast('No AI placeholder prompt on this clip.');
14607
+ return;
14608
+ }
14609
+ const modality = ph.kind === 'image' ? 'image' : 'video';
14610
+ const prompt =
14611
+ ph.kind === 'motion'
14612
+ ? `Clean, minimal motion-graphics animation (flat shapes, smooth easing): ${ph.prompt}`
14613
+ : ph.prompt;
14614
+ fetch('/_api/generate-jobs', {
14615
+ method: 'POST',
14616
+ headers: { 'content-type': 'application/json' },
14617
+ body: JSON.stringify({ provider: 'gemini', modality, prompt }),
14618
+ })
14619
+ .then(async (r) => (r.ok ? r.json() : Promise.reject(new Error(await r.text()))))
14620
+ .then((job) => {
14621
+ const jobId = job?.id;
14622
+ if (!jobId) throw new Error('no job id');
14623
+ shellToast(`Generating ${ph.kind}… (runs in the background)`, true);
14624
+ const stableId = clip.stableId;
14625
+ const poll = () => {
14626
+ fetch('/_api/generate-jobs')
14627
+ .then((r) => r.json().catch(() => ({})))
14628
+ .then((d) => {
14629
+ const j2 = (d?.jobs || []).find((x) => x.id === jobId);
14630
+ if (!j2 || j2.status === 'failed') {
14631
+ shellToast(`Generation failed: ${j2?.error || 'job lost'}`);
14632
+ return;
14633
+ }
14634
+ if (j2.status !== 'done') {
14635
+ setTimeout(poll, 4000);
14636
+ return;
14637
+ }
14638
+ const asset = j2.assets?.[0];
14639
+ if (!asset) {
14640
+ shellToast('Generation finished but produced no asset.');
14641
+ return;
14642
+ }
14643
+ // Fresh fingerprint at swap time (the clip may have
14644
+ // been retimed/moved meanwhile — that's fine).
14645
+ fetch(ccUrl)
14646
+ .then((r) => r.json().catch(() => ({})))
14647
+ .then((cc2) => {
14648
+ const live = resolveClipRef(cc2, { stableId });
14649
+ if (!live?.stableId) {
14650
+ shellToast('Placeholder clip is gone — generated asset kept in assets/.');
14651
+ return;
14652
+ }
14653
+ timelineClipVerb({ stableId: live.stableId }, 'resolve-placeholder', {
14654
+ src: asset,
14655
+ mediaKind: modality === 'image' ? 'image' : 'video',
14656
+ });
14657
+ });
14658
+ })
14659
+ .catch(() => setTimeout(poll, 6000));
14660
+ };
14661
+ setTimeout(poll, 3000);
14662
+ })
14663
+ .catch((e) =>
14664
+ shellToast(`Generate failed: ${e?.message || 'provider error'} — is a Gemini key set in Settings?`)
14665
+ );
14666
+ })
14667
+ .catch(() => shellToast('Generate failed: network error'));
14668
+ }}
14669
+ onAddImage={(frame) => {
14670
+ if (!activePath || activePath === SYSTEM_TAB) return;
14671
+ // Task 19 — "+ Image": picker → content-addressed upload →
14672
+ // overlay-lane <Img> at the playhead. WKWebView can't open an
14673
+ // HTML file input, so native goes through the Rust pick dialog.
14674
+ const uploadPicked = (blob, type) => {
14675
+ const fps = activeComps[0]?.fps || 30;
14676
+ fetch('/_api/asset', {
14677
+ method: 'POST',
14678
+ headers: { 'Content-Type': type || 'application/octet-stream' },
14679
+ body: blob,
14680
+ })
14681
+ .then((r) => r.json().catch(() => ({})))
14682
+ .then((up) => {
14683
+ if (!up?.path) {
14684
+ shellToast(`Upload failed: ${up?.error || 'unknown error'}`);
14685
+ return null;
14686
+ }
14687
+ return fetch('/_api/insert-sequence', {
14688
+ method: 'POST',
14689
+ headers: { 'content-type': 'application/json' },
14690
+ body: JSON.stringify({
14691
+ canvas: activePath,
14692
+ artboardId: timelineArtboardId || undefined,
14693
+ lane: 'overlay',
14694
+ from: frame,
14695
+ durationInFrames: Math.round(fps * 3),
14696
+ mediaTag: 'Img',
14697
+ src: up.path,
14698
+ }),
14699
+ });
14700
+ })
14701
+ .then((r) => (r ? r.json() : null))
14702
+ .then((j) => {
14703
+ if (j && !j.ok) timelineOpFailed('Image refused', j.error);
14704
+ else if (j?.ok) {
14705
+ shellToast('Image overlay added.', true);
14706
+ if (j.seq != null) pushTlUndo(activePath, j.seq, 'add image overlay');
14707
+ }
14708
+ })
14709
+ .catch(() => shellToast('Image failed: network error'));
14710
+ };
14711
+ if (isNativeApp()) {
14712
+ pickMediaFile()
14713
+ .then((picked) => {
14714
+ if (picked?.bytes) uploadPicked(new Blob([new Uint8Array(picked.bytes)]), '');
14715
+ })
14716
+ .catch((e2) => shellToast(`Image pick failed: ${e2?.message || 'dialog error'}`));
14717
+ return;
14718
+ }
14719
+ const input = document.createElement('input');
14720
+ input.type = 'file';
14721
+ input.accept = 'image/*';
14722
+ input.addEventListener('change', () => {
14723
+ const file = input.files?.[0];
14724
+ if (file) uploadPicked(file, file.type);
14725
+ });
14726
+ input.click();
14727
+ }}
14728
+ resolveMediaUrl={(p) =>
14729
+ `/${(cfg?.designRel || cfg?.designRoot || '.design').replace(/^\/+|\/+$/g, '')}/${p}`
14730
+ }
14074
14731
  />
14075
14732
  )}
14076
14733
  <StatusBar
@@ -14113,12 +14770,58 @@ function App() {
14113
14770
  onClose={() => setPaletteOpen(false)}
14114
14771
  actions={paletteActions}
14115
14772
  />
14773
+ {shellPromptState && (
14774
+ <div
14775
+ className="st-scrim"
14776
+ role="presentation"
14777
+ onMouseDown={(e) => {
14778
+ if (e.target === e.currentTarget) settleShellPrompt(null);
14779
+ }}
14780
+ >
14781
+ <div className="st-dialog st-prompt" role="dialog" aria-modal="true" aria-label={shellPromptState.title}>
14782
+ <div className="st-dialog-hd">
14783
+ <span className="st-dialog-title">{shellPromptState.title}</span>
14784
+ </div>
14785
+ <div className="st-dialog-bd">
14786
+ <input
14787
+ className="st-input"
14788
+ data-testid="shell-prompt-input"
14789
+ autoFocus
14790
+ defaultValue={shellPromptState.value}
14791
+ onKeyDown={(e) => {
14792
+ e.stopPropagation();
14793
+ if (e.key === 'Enter') settleShellPrompt(e.currentTarget.value);
14794
+ else if (e.key === 'Escape') settleShellPrompt(null);
14795
+ }}
14796
+ />
14797
+ <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 10 }}>
14798
+ <button type="button" className="tlci-btn" onClick={() => settleShellPrompt(null)}>
14799
+ Cancel
14800
+ </button>
14801
+ <button
14802
+ type="button"
14803
+ className="tlci-btn"
14804
+ data-testid="shell-prompt-ok"
14805
+ onClick={(e) =>
14806
+ settleShellPrompt(
14807
+ e.currentTarget.closest('.st-dialog')?.querySelector('input')?.value ?? ''
14808
+ )
14809
+ }
14810
+ >
14811
+ OK
14812
+ </button>
14813
+ </div>
14814
+ </div>
14815
+ </div>
14816
+ </div>
14817
+ )}
14116
14818
  {exportDialog && (
14117
14819
  <ExportDialog
14118
14820
  mode={exportDialog.mode}
14119
14821
  initialScope={exportDialog.scope}
14120
14822
  activePath={activePath}
14121
14823
  hasComps={activeComps.length > 0}
14824
+ comps={activeComps}
14122
14825
  // Which artboard "Active artboard" scope targets. The shell can't query
14123
14826
  // the cross-origin canvas DOM (the in-canvas dialog's activeArtboardId),
14124
14827
  // so use the tracked signals: an explicit selection wins, else the