@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
@@ -78,6 +78,7 @@ import { PhotoKnobs } from './photo-knobs.jsx';
78
78
  import {
79
79
  appIsFirstRun,
80
80
  isNativeApp,
81
+ onMenuReportBug,
81
82
  onUpdateReady,
82
83
  pickMediaFile,
83
84
  pickMediaFiles,
@@ -88,6 +89,7 @@ import { TourOverlay } from './tour/overlay.jsx';
88
89
  import { QUICK_SETUP_TOUR } from './tour/quick-setup-tour.js';
89
90
  import { USAGE_TOUR } from './tour/usage-tour.js';
90
91
  import { ExportBadge, ExportPanel, ExportToast, useExportCenter } from './export-center.jsx';
92
+ import { ReportBugDialog } from './report-bug.jsx';
91
93
  import { useWhatsNew, WhatsNewPanel, WhatsNewToast } from './whats-new.jsx';
92
94
 
93
95
  const USAGE_TOUR_STORE = 'mdcc-usage-tour-seen';
@@ -1464,6 +1466,7 @@ function ExportDialog({
1464
1466
  initialScope,
1465
1467
  activePath,
1466
1468
  hasComps = false,
1469
+ comps = [],
1467
1470
  activeArtboardId = null,
1468
1471
  selection = null,
1469
1472
  onClose,
@@ -1495,6 +1498,19 @@ function ExportDialog({
1495
1498
  const [recent, setRecent] = useState([]);
1496
1499
  const card = EXPORT_CARDS.find((c) => c.id === sel) || EXPORT_CARDS[0];
1497
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;
1498
1514
 
1499
1515
  // Keep the scope valid for the chosen format (pptx/zip etc. only allow a
1500
1516
  // subset) — mirrors VALID_SCOPES_PER_FORMAT in the in-canvas dialog.
@@ -1547,6 +1563,8 @@ function ExportDialog({
1547
1563
  // Export-with-audio (DDR-148 addendum) — mp4/webm only; gif is silent by
1548
1564
  // format, so the checkbox never renders for it (see hasAudioToggle below).
1549
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;
1550
1568
  // feature-2-print-artboards T5/T6 — always sent on a PDF export; the
1551
1569
  // adapter no-ops includeBleed/marks for a non-print artboard (T5).
1552
1570
  if (card.format === 'pdf') {
@@ -1696,6 +1714,21 @@ function ExportDialog({
1696
1714
  {scale}× the artboard's native resolution (e.g. 960×540 → {960 * scale}×{540 * scale}).
1697
1715
  </div>
1698
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
+ )}
1699
1732
  {!card.handoff && card.format === 'pdf' && (
1700
1733
  <>
1701
1734
  <div className="st-dialog-row">
@@ -3408,6 +3441,7 @@ function HelpDropdown({ onAction, onClose }) {
3408
3441
  items={[
3409
3442
  { id: 'shortcuts', label: 'Keyboard shortcuts', shortcut: '?' },
3410
3443
  { id: 'help', label: 'Help · commands & flows', shortcut: 'F1' },
3444
+ { id: 'report-bug', label: 'Report a bug…' },
3411
3445
  { sep: true },
3412
3446
  { id: 'tour', label: 'Take the tour' },
3413
3447
  { id: 'watch-intro', label: 'Watch the intro' },
@@ -3552,6 +3586,7 @@ function Menubar({
3552
3586
  onToggleShowHidden,
3553
3587
  onOpenHelp,
3554
3588
  onOpenShortcuts,
3589
+ onReportBug,
3555
3590
  onStartTour,
3556
3591
  onStartCollabTour,
3557
3592
  annotationsVisible,
@@ -3878,6 +3913,7 @@ function Menubar({
3878
3913
  onAction={(id) => {
3879
3914
  if (id === 'shortcuts') onOpenShortcuts?.();
3880
3915
  else if (id === 'help') onOpenHelp?.();
3916
+ else if (id === 'report-bug') onReportBug?.();
3881
3917
  else if (id === 'tour') onStartTour?.();
3882
3918
  else if (id === 'collab-tour') onStartCollabTour?.();
3883
3919
  else if (id === 'quick-setup') onOpenQuickSetup?.();
@@ -9158,6 +9194,17 @@ function App() {
9158
9194
  const [showHidden, setShowHidden] = useState(() => readBoolStore(SHOW_HIDDEN_STORE, false));
9159
9195
  const [sectionsExpanded, setSectionsExpanded] = useState(() => readJsonStore(SECTIONS_STORE, {}));
9160
9196
  const [helpOpen, setHelpOpen] = useState(false);
9197
+ const [reportBugOpen, setReportBugOpen] = useState(false);
9198
+
9199
+ // Native Help ▸ Report a Bug… (menu.rs emits `menu://report-bug`) — same
9200
+ // lane as IdentityBar's File ▸ New Project… subscription.
9201
+ useEffect(() => {
9202
+ if (!isNativeApp()) return;
9203
+ const p = onMenuReportBug(() => setReportBugOpen(true));
9204
+ return () => {
9205
+ p.then((un) => un()).catch(() => {});
9206
+ };
9207
+ }, []);
9161
9208
  const [readinessOpen, setReadinessOpen] = useState(false);
9162
9209
  const [introOpen, setIntroOpen] = useState(false);
9163
9210
  const [quickSetupOpen, setQuickSetupOpen] = useState(false);
@@ -9212,6 +9259,8 @@ function App() {
9212
9259
  // DDR-148 — parsed sequence/keyframe rows for the Timeline (from raw .tsx).
9213
9260
  const [timelineSequences, setTimelineSequences] = useState([]);
9214
9261
  const [timelineAudio, setTimelineAudio] = useState([]);
9262
+ // Task 5 — seam transitions between series beats ({ afterIndex, dur }).
9263
+ const [timelineTransitions, setTimelineTransitions] = useState([]);
9215
9264
  const [timelineTotal, setTimelineTotal] = useState(0);
9216
9265
  // On a multi-comp canvas the Timeline drives ONE comp (the parser scopes to it
9217
9266
  // + reports its total). Match that total back to the announced comp id so the
@@ -9244,6 +9293,21 @@ function App() {
9244
9293
  useEffect(() => {
9245
9294
  timelineArtboardIdRef.current = timelineArtboardId;
9246
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
+ }, []);
9247
9311
  // Phase 31 (DDR-123) — the native ACP chat sidepanel (right dock, native-only).
9248
9312
  const [assistantOpen, setAssistantOpen] = useState(false);
9249
9313
  const [assistantBusy, setAssistantBusy] = useState(false);
@@ -9724,13 +9788,181 @@ function App() {
9724
9788
  })
9725
9789
  .catch(() => shellToast(`${dir === 'undo' ? 'Undo' : 'Redo'} failed: network error`));
9726
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
+
9727
9955
  useEffect(() => {
9728
9956
  const onKey = (e) => {
9729
9957
  const s = tlKeyRef.current;
9730
9958
  if (!s.open || !s.comps?.length) return;
9731
9959
  const t = e.target;
9732
9960
  const tag = t?.tagName;
9733
- 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;
9734
9966
  const fps = s.comps[0]?.fps || 30;
9735
9967
  const total = Math.max(1, s.total);
9736
9968
  const doSeek = (f) => {
@@ -9758,6 +9990,39 @@ function App() {
9758
9990
  tlUndoRedo(e.shiftKey ? 'redo' : 'undo');
9759
9991
  return;
9760
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
+ }
9761
10026
  if (e.key === ' ' || e.code === 'Space') {
9762
10027
  e.preventDefault();
9763
10028
  if (s.playing) {
@@ -9805,6 +10070,10 @@ function App() {
9805
10070
  setActiveComps([]);
9806
10071
  setTimelineFrame(0);
9807
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);
9808
10077
  const t = setTimeout(() => postToActiveCanvas({ dgn: 'timeline-request-comps' }), 60);
9809
10078
  return () => clearTimeout(t);
9810
10079
  }, [activePath, postToActiveCanvas]);
@@ -9816,6 +10085,9 @@ function App() {
9816
10085
  // wrapper components + array-fed src the regex parser can't see), merged into
9817
10086
  // the rows for the kind badge + replace addressing.
9818
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([]);
9819
10091
  useEffect(() => {
9820
10092
  if (!timelineOpen || activeComps.length === 0 || !activePath || activePath === SYSTEM_TAB) {
9821
10093
  setTimelineSource('');
@@ -9832,7 +10104,7 @@ function App() {
9832
10104
  return () => {
9833
10105
  alive = false;
9834
10106
  };
9835
- }, [timelineOpen, activeComps, activePath]);
10107
+ }, [timelineOpen, activeComps, activePath, timelineRefresh]);
9836
10108
 
9837
10109
  // Authoritative per-clip media (comp-clips) — a SEPARATE effect keyed on the
9838
10110
  // RESOLVED artboard id, which the parser only knows AFTER timelineSource
@@ -9843,6 +10115,7 @@ function App() {
9843
10115
  useEffect(() => {
9844
10116
  if (!timelineOpen || !activePath || activePath === SYSTEM_TAB || !timelineSource) {
9845
10117
  setTimelineClipMedia([]);
10118
+ setTimelineTransClips([]);
9846
10119
  return undefined;
9847
10120
  }
9848
10121
  let alive = true;
@@ -9852,15 +10125,15 @@ function App() {
9852
10125
  .then((r) => (r.ok ? r.json() : null))
9853
10126
  .then((cc) => {
9854
10127
  if (!alive) return;
9855
- const seqs =
9856
- cc?.ok && Array.isArray(cc.clips) ? cc.clips.filter((c) => c.kind === 'sequence') : [];
9857
- 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'));
9858
10131
  })
9859
10132
  .catch(() => {});
9860
10133
  return () => {
9861
10134
  alive = false;
9862
10135
  };
9863
- }, [timelineOpen, activePath, timelineSource, timelineArtboardId]);
10136
+ }, [timelineOpen, activePath, timelineSource, timelineArtboardId, timelineRefresh]);
9864
10137
 
9865
10138
  // Parse (cheap) — re-runs on source change AND on selection change, so the
9866
10139
  // Timeline REDRAWS to whichever artboard the user just selected/moved to.
@@ -9868,6 +10141,7 @@ function App() {
9868
10141
  if (!timelineSource) {
9869
10142
  setTimelineSequences([]);
9870
10143
  setTimelineAudio([]);
10144
+ setTimelineTransitions([]);
9871
10145
  setTimelineTotal(0);
9872
10146
  return;
9873
10147
  }
@@ -9883,6 +10157,14 @@ function App() {
9883
10157
  if (!cm) return s;
9884
10158
  return {
9885
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,
9886
10168
  mediaTag: cm.mediaTag ?? s.mediaTag,
9887
10169
  mediaSrc: cm.mediaSrc ?? s.mediaSrc,
9888
10170
  // Replaceable when the enumerator found an addressable media target:
@@ -9895,6 +10177,7 @@ function App() {
9895
10177
  });
9896
10178
  setTimelineSequences(merged);
9897
10179
  setTimelineAudio(parsed.audio || []);
10180
+ setTimelineTransitions(parsed.transitions || []);
9898
10181
  setTimelineTotal(parsed.total);
9899
10182
  }, [timelineSource, timelineClipMedia, selected, activeComps, canvasActiveArtboard]);
9900
10183
 
@@ -10690,6 +10973,41 @@ function App() {
10690
10973
  [loadTree, openTab]
10691
10974
  );
10692
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
+
10693
11011
  // DDR-150 P4 Task 12 — one-click "udělej z toho video". Reads the ACTIVE
10694
11012
  // canvas's annotation sidecar (main-origin — no cross-origin round-trip),
10695
11013
  // gathers every dropped media-reference chip's src in document order, and POSTs
@@ -13200,6 +13518,13 @@ function App() {
13200
13518
  );
13201
13519
  },
13202
13520
  },
13521
+ {
13522
+ id: 'new-video',
13523
+ group: 'Canvas',
13524
+ label: 'New video…',
13525
+ icon: 'plus',
13526
+ run: () => createVideo(),
13527
+ },
13203
13528
  {
13204
13529
  id: 'export',
13205
13530
  group: 'Canvas',
@@ -13309,8 +13634,15 @@ function App() {
13309
13634
  kbd: 'F1',
13310
13635
  run: () => setHelpOpen(true),
13311
13636
  },
13637
+ {
13638
+ id: 'report-bug',
13639
+ group: 'Help',
13640
+ label: 'Report a bug…',
13641
+ icon: 'help',
13642
+ run: () => setReportBugOpen(true),
13643
+ },
13312
13644
  ],
13313
- [openSystem, toggleTheme, reloadActive, whatsNew]
13645
+ [openSystem, toggleTheme, reloadActive, whatsNew, createVideo]
13314
13646
  );
13315
13647
 
13316
13648
  // feature-configurable-panel-docking — resolve, for each slot, the panels
@@ -13522,6 +13854,7 @@ function App() {
13522
13854
  onToggleShowHidden={() => setShowHidden((v) => !v)}
13523
13855
  onOpenHelp={() => setHelpOpen(true)}
13524
13856
  onOpenShortcuts={() => setShortcutsOpen(true)}
13857
+ onReportBug={() => setReportBugOpen(true)}
13525
13858
  onStartTour={() => startTour(USAGE_TOUR)}
13526
13859
  onStartCollabTour={() => startTour(COLLAB_TOUR)}
13527
13860
  annotationsVisible={annotationsVisible}
@@ -13715,6 +14048,7 @@ function App() {
13715
14048
  comps={activeComps}
13716
14049
  sequences={timelineSequences}
13717
14050
  audio={timelineAudio}
14051
+ transitions={timelineTransitions}
13718
14052
  total={timelineTotal}
13719
14053
  frame={timelineFrame}
13720
14054
  playing={timelinePlaying}
@@ -13761,24 +14095,20 @@ function App() {
13761
14095
  }
13762
14096
  postToActiveCanvas({ dgn: 'timeline-volume', volume: v, id: timelineCompId });
13763
14097
  }}
13764
- onRetime={(index, patch) => {
14098
+ onRetime={(clipRef, patch) => {
13765
14099
  if (!activePath || activePath === SYSTEM_TAB) return;
13766
- // DDR-150 P2 — address the clip by its comp-scoped stableId (from
13767
- // /_api/comp-clips), NOT the raw row index, so a retime on a
13768
- // multi-comp canvas can never mis-hit a clip in another comp. The
13769
- // row index counts sequence rows only, so map it to the index-th
13770
- // sequence clip (transitions filtered). Fall back to the legacy
13771
- // 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.
13772
14104
  const artboardId = timelineArtboardId || undefined;
13773
14105
  const ccUrl = `/_api/comp-clips?canvas=${encodeURIComponent(activePath)}${artboardId ? `&artboardId=${encodeURIComponent(artboardId)}` : ''}`;
13774
14106
  fetch(ccUrl)
13775
14107
  .then((r) => r.json().catch(() => ({})))
13776
14108
  .then((cc) => {
13777
- const seqs =
13778
- cc?.ok && Array.isArray(cc.clips)
13779
- ? cc.clips.filter((c) => c.kind === 'sequence')
13780
- : [];
13781
- const clip = seqs[index] || null;
14109
+ const clip = resolveClipRef(cc, clipRef);
14110
+ const legacyIndex =
14111
+ clipRef && typeof clipRef === 'object' ? clipRef.index : clipRef;
13782
14112
  const body = clip?.stableId
13783
14113
  ? {
13784
14114
  canvas: activePath,
@@ -13787,7 +14117,7 @@ function App() {
13787
14117
  contentHash: clip.contentHash,
13788
14118
  ...patch,
13789
14119
  }
13790
- : { canvas: activePath, index, ...patch };
14120
+ : { canvas: activePath, index: legacyIndex, ...patch };
13791
14121
  return fetch('/_api/retime-sequence', {
13792
14122
  method: 'POST',
13793
14123
  headers: { 'content-type': 'application/json' },
@@ -13798,55 +14128,19 @@ function App() {
13798
14128
  .then((j) => {
13799
14129
  if (!j?.ok) {
13800
14130
  console.warn('[retime]', j?.error || 'failed');
13801
- shellToast(`Retime refused: ${j?.error || 'failed'}`);
13802
- } else if (j?.seq != null) {
13803
- 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');
13804
14136
  }
13805
14137
  // The file watcher reloads the canvas → re-announce → the
13806
14138
  // source-fetch effect re-parses the new timing.
13807
14139
  })
13808
14140
  .catch(() => {});
13809
14141
  }}
13810
- onRemove={(index) => {
13811
- if (!activePath || activePath === SYSTEM_TAB) return;
13812
- // DDR-150 P3 — remove the clip addressed by its comp-scoped stableId
13813
- // (the sequence-row index → the index-th sequence clip). The engine
13814
- // fingerprint + semantic gate refuse a stale/raced or series-breaking
13815
- // removal; the file watcher reloads the canvas after.
13816
- const artboardId = timelineArtboardId || undefined;
13817
- const ccUrl = `/_api/comp-clips?canvas=${encodeURIComponent(activePath)}${artboardId ? `&artboardId=${encodeURIComponent(artboardId)}` : ''}`;
13818
- fetch(ccUrl)
13819
- .then((r) => r.json().catch(() => ({})))
13820
- .then((cc) => {
13821
- const seqs =
13822
- cc?.ok && Array.isArray(cc.clips)
13823
- ? cc.clips.filter((c) => c.kind === 'sequence')
13824
- : [];
13825
- const clip = seqs[index] || null;
13826
- if (!clip?.stableId) return null;
13827
- return fetch('/_api/remove-sequence', {
13828
- method: 'POST',
13829
- headers: { 'content-type': 'application/json' },
13830
- body: JSON.stringify({
13831
- canvas: activePath,
13832
- artboardId,
13833
- stableId: clip.stableId,
13834
- contentHash: clip.contentHash,
13835
- }),
13836
- });
13837
- })
13838
- .then((r) => (r ? r.json() : null))
13839
- .then((j) => {
13840
- if (j && !j.ok) {
13841
- console.warn('[remove-clip]', j.error || 'failed');
13842
- shellToast(`Remove refused: ${j.error || 'failed'}`);
13843
- } else if (j?.seq != null) {
13844
- pushTlUndo(activePath, j.seq, 'remove clip');
13845
- }
13846
- })
13847
- .catch(() => {});
13848
- }}
13849
- onReplace={(index) => {
14142
+ onRemove={timelineRemoveClip}
14143
+ onReplace={(clipRef) => {
13850
14144
  if (!activePath || activePath === SYSTEM_TAB) return;
13851
14145
  // DDR-150 P3 + dogfood #5 — replace a clip's media. The file picker
13852
14146
  // MUST open synchronously inside the click gesture: browsers revoke
@@ -13857,11 +14151,7 @@ function App() {
13857
14151
  replaceMediaViaPicker({
13858
14152
  accept: 'video/*,image/*',
13859
14153
  resolveTarget: (cc) => {
13860
- const seqs =
13861
- cc?.ok && Array.isArray(cc.clips)
13862
- ? cc.clips.filter((c) => c.kind === 'sequence')
13863
- : [];
13864
- const clip = seqs[index];
14154
+ const clip = resolveClipRef(cc, clipRef);
13865
14155
  if (clip?.mediaArrayRef) return { arrayRef: clip.mediaArrayRef };
13866
14156
  if (clip?.mediaCdId) return { cdId: clip.mediaCdId };
13867
14157
  return null;
@@ -13884,32 +14174,29 @@ function App() {
13884
14174
  },
13885
14175
  });
13886
14176
  }}
13887
- onReplaceLayer={(clipIndex, layerIndex) => {
14177
+ onReplaceLayer={(clipRef, layerIndex) => {
13888
14178
  if (!activePath || activePath === SYSTEM_TAB) return;
13889
14179
  // DDR-150 dogfood — replace a SPECIFIC layer inside an expanded clip
13890
14180
  // (the mp4 background separately from the title layer). Targets the
13891
14181
  // layer's own media (array-fed or literal-src) from the enumerator.
14182
+ const rowIndex = clipRef && typeof clipRef === 'object' ? clipRef.index : clipRef;
13892
14183
  const kind =
13893
- timelineSequences[clipIndex]?.layers?.[layerIndex]?.kind === 'audio'
14184
+ timelineSequences[rowIndex]?.layers?.[layerIndex]?.kind === 'audio'
13894
14185
  ? 'audio/*'
13895
- : timelineSequences[clipIndex]?.layers?.[layerIndex]?.kind === 'image'
14186
+ : timelineSequences[rowIndex]?.layers?.[layerIndex]?.kind === 'image'
13896
14187
  ? 'image/*'
13897
14188
  : 'video/*';
13898
14189
  replaceMediaViaPicker({
13899
14190
  accept: kind,
13900
14191
  resolveTarget: (cc) => {
13901
- const seqs =
13902
- cc?.ok && Array.isArray(cc.clips)
13903
- ? cc.clips.filter((c) => c.kind === 'sequence')
13904
- : [];
13905
- const ly = seqs[clipIndex]?.layers?.[layerIndex];
14192
+ const ly = resolveClipRef(cc, clipRef)?.layers?.[layerIndex];
13906
14193
  if (ly?.mediaArrayRef) return { arrayRef: ly.mediaArrayRef };
13907
14194
  if (ly?.mediaCdId) return { cdId: ly.mediaCdId };
13908
14195
  return null;
13909
14196
  },
13910
14197
  });
13911
14198
  }}
13912
- onReorder={(index, direction) => {
14199
+ onReorder={(clipRef, direction) => {
13913
14200
  if (!activePath || activePath === SYSTEM_TAB) return;
13914
14201
  // DDR-150 P5 — z-order reorder: move a standalone <Sequence> before/
13915
14202
  // after a sibling (render stacking; later sibling paints on top).
@@ -13926,9 +14213,10 @@ function App() {
13926
14213
  cc?.ok && Array.isArray(cc.clips)
13927
14214
  ? cc.clips.filter((c) => c.kind === 'sequence')
13928
14215
  : [];
13929
- const moved = seqs[index] || null;
14216
+ const moved = resolveClipRef(cc, clipRef);
14217
+ const index = moved ? seqs.indexOf(moved) : -1;
13930
14218
  const refIdx = direction === 'forward' ? index + 1 : index - 1;
13931
- const ref = seqs[refIdx] || null;
14219
+ const ref = index >= 0 ? seqs[refIdx] || null : null;
13932
14220
  const position = direction === 'forward' ? 'after' : 'before';
13933
14221
  if (!moved?.stableId || !ref?.stableId) return null;
13934
14222
  return fetch('/_api/reorder-sequence', {
@@ -13949,14 +14237,14 @@ function App() {
13949
14237
  .then((j) => {
13950
14238
  if (j && !j.ok) {
13951
14239
  console.warn('[reorder-clip]', j.error || 'failed');
13952
- shellToast(`Reorder refused: ${j.error || 'failed'}`);
14240
+ timelineOpFailed('Reorder refused', j.error);
13953
14241
  } else if (j?.seq != null) {
13954
14242
  pushTlUndo(activePath, j.seq, 'reorder clip');
13955
14243
  }
13956
14244
  })
13957
14245
  .catch(() => {});
13958
14246
  }}
13959
- onToggleHide={(index) => {
14247
+ onToggleHide={(clipRef) => {
13960
14248
  if (!activePath || activePath === SYSTEM_TAB) return;
13961
14249
  // DDR-150 dogfood — hide/show a clip (gates its body behind
13962
14250
  // {false && …}; the tag + time slot stay). Addressed by comp-scoped
@@ -13966,11 +14254,7 @@ function App() {
13966
14254
  fetch(ccUrl)
13967
14255
  .then((r) => r.json().catch(() => ({})))
13968
14256
  .then((cc) => {
13969
- const seqs =
13970
- cc?.ok && Array.isArray(cc.clips)
13971
- ? cc.clips.filter((c) => c.kind === 'sequence')
13972
- : [];
13973
- const clip = seqs[index] || null;
14257
+ const clip = resolveClipRef(cc, clipRef);
13974
14258
  if (!clip?.stableId) return null;
13975
14259
  return fetch('/_api/toggle-hide', {
13976
14260
  method: 'POST',
@@ -13985,7 +14269,7 @@ function App() {
13985
14269
  })
13986
14270
  .then((r) => (r ? r.json() : null))
13987
14271
  .then((j) => {
13988
- if (j && !j.ok) shellToast(`Hide refused: ${j.error || 'failed'}`);
14272
+ if (j && !j.ok) timelineOpFailed('Hide refused', j.error);
13989
14273
  else if (j && j.ok) {
13990
14274
  shellToast(j.hidden ? 'Clip hidden.' : 'Clip shown.', true);
13991
14275
  if (j.seq != null) pushTlUndo(activePath, j.seq, j.hidden ? 'hide clip' : 'show clip');
@@ -13993,60 +14277,457 @@ function App() {
13993
14277
  })
13994
14278
  .catch(() => shellToast('Hide failed: network error'));
13995
14279
  }}
13996
- onDropMedia={(file) => {
14280
+ onReorderMove={(movedRef2, targetRef, position) => {
13997
14281
  if (!activePath || activePath === SYSTEM_TAB) return;
13998
- // DDR-150 P4 Task 11 — drop a media file onto the timeline to
13999
- // insert a clip: upload to assets/, then append a <Sequence> with
14000
- // 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.
14001
14284
  const artboardId = timelineArtboardId || undefined;
14002
- const mediaTag = file.type.startsWith('video/')
14003
- ? 'Video'
14004
- : file.type.startsWith('audio/')
14005
- ? 'Audio'
14006
- : file.type.startsWith('image/')
14007
- ? 'Img'
14008
- : null;
14009
- const fps = activeComps[0]?.fps || 30;
14010
- fetch('/_api/asset', {
14011
- method: 'POST',
14012
- headers: { 'Content-Type': file.type || 'application/octet-stream' },
14013
- body: file,
14014
- })
14285
+ const ccUrl = `/_api/comp-clips?canvas=${encodeURIComponent(activePath)}${artboardId ? `&artboardId=${encodeURIComponent(artboardId)}` : ''}`;
14286
+ fetch(ccUrl)
14015
14287
  .then((r) => r.json().catch(() => ({})))
14016
- .then((up) => {
14017
- if (!up?.path) {
14018
- console.warn('[insert] upload failed', up?.error);
14019
- shellToast(`Upload failed: ${up?.error || 'unknown error'}`);
14020
- return null;
14021
- }
14022
- 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', {
14023
14293
  method: 'POST',
14024
14294
  headers: { 'content-type': 'application/json' },
14025
14295
  body: JSON.stringify({
14026
14296
  canvas: activePath,
14027
14297
  artboardId,
14028
- from: Math.max(0, timelineTotal),
14029
- durationInFrames: Math.round(fps * 3),
14030
- mediaTag,
14031
- src: up.path,
14298
+ stableId: moved.stableId,
14299
+ contentHash: moved.contentHash,
14300
+ refStableId: ref.stableId,
14301
+ refContentHash: ref.contentHash,
14302
+ position,
14303
+ mode: 'move',
14032
14304
  }),
14033
14305
  });
14034
14306
  })
14035
14307
  .then((r) => (r ? r.json() : null))
14036
14308
  .then((j) => {
14037
14309
  if (j && !j.ok) {
14038
- console.warn('[insert-clip]', j.error || 'failed');
14039
- shellToast(`Insert refused: ${j.error || 'failed'}`);
14040
- } else if (j && j.ok) {
14041
- shellToast('Clip added to the timeline.', true);
14042
- 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');
14043
14315
  }
14044
14316
  })
14045
- .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
+ })();
14046
14497
  }}
14047
14498
  height={timelineHeight}
14048
14499
  onResize={setTimelineHeight}
14049
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
+ }
14050
14731
  />
14051
14732
  )}
14052
14733
  <StatusBar
@@ -14089,12 +14770,58 @@ function App() {
14089
14770
  onClose={() => setPaletteOpen(false)}
14090
14771
  actions={paletteActions}
14091
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
+ )}
14092
14818
  {exportDialog && (
14093
14819
  <ExportDialog
14094
14820
  mode={exportDialog.mode}
14095
14821
  initialScope={exportDialog.scope}
14096
14822
  activePath={activePath}
14097
14823
  hasComps={activeComps.length > 0}
14824
+ comps={activeComps}
14098
14825
  // Which artboard "Active artboard" scope targets. The shell can't query
14099
14826
  // the cross-origin canvas DOM (the in-canvas dialog's activeArtboardId),
14100
14827
  // so use the tracked signals: an explicit selection wins, else the
@@ -14182,6 +14909,7 @@ function App() {
14182
14909
  startTour(USAGE_TOUR);
14183
14910
  }}
14184
14911
  />
14912
+ <ReportBugDialog open={reportBugOpen} onClose={() => setReportBugOpen(false)} />
14185
14913
  <WhatsNewPanel wn={whatsNew} onStartTour={startTour} />
14186
14914
  <ExportPanel center={exportCenter} />
14187
14915
  <ReadinessDialog open={readinessOpen} onClose={() => setReadinessOpen(false)} />