@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.
- package/apps/studio/api.ts +389 -14
- package/apps/studio/bin/_video-playwright.mjs +15 -6
- package/apps/studio/canvas-edit.ts +141 -20
- package/apps/studio/canvas-lib.tsx +61 -0
- package/apps/studio/client/app.jsx +824 -121
- package/apps/studio/client/panels/ClipInspector.jsx +409 -0
- package/apps/studio/client/panels/CloudBar.jsx +12 -2
- package/apps/studio/client/panels/TimelinePanel.jsx +1775 -430
- package/apps/studio/client/panels/timeline-media-cache.js +274 -0
- package/apps/studio/client/panels/timeline-parse.js +46 -1
- package/apps/studio/client/panels/timeline-scale.js +64 -0
- package/apps/studio/client/report-bug.jsx +15 -15
- package/apps/studio/client/styles/3-shell-maude.css +446 -3
- package/apps/studio/client/styles/4-components-maude.css +55 -0
- package/apps/studio/client/styles/4-components.css +0 -83
- package/apps/studio/clip-ops.ts +1980 -0
- package/apps/studio/cloud/endpoints.ts +88 -17
- package/apps/studio/credential-grammar.ts +100 -0
- package/apps/studio/debug-bundle.ts +12 -0
- package/apps/studio/dist/client.bundle.js +2048 -2048
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/video.ts +49 -10
- package/apps/studio/http.ts +113 -6
- package/apps/studio/ripple.ts +335 -0
- package/apps/studio/sync/hub-link.ts +3 -3
- package/apps/studio/sync/hubs-config.ts +32 -0
- package/apps/studio/sync/workspace-signin.ts +8 -4
- package/apps/studio/test/_helpers.ts +5 -3
- package/apps/studio/test/canvas-create-api.test.ts +8 -2
- package/apps/studio/test/canvas-origin-gate.test.ts +20 -0
- package/apps/studio/test/clip-addressing.test.ts +7 -6
- package/apps/studio/test/clip-ops.test.ts +836 -0
- package/apps/studio/test/cloud-endpoints.test.ts +37 -2
- package/apps/studio/test/comments-api.test.ts +49 -0
- package/apps/studio/test/credential-grammar.test.ts +107 -0
- package/apps/studio/test/read-only-role.test.ts +95 -0
- package/apps/studio/test/ripple.test.ts +197 -0
- package/apps/studio/test/timeline-scale.test.ts +65 -0
- package/apps/studio/test/workspace-signin.test.ts +4 -1
- package/apps/studio/whats-new.json +35 -0
- package/cli/commands/share.mjs +8 -4
- package/cli/lib/ddr-to-kgai.mjs +6 -1
- package/cli/lib/ddr-to-kgai.test.mjs +1 -2
- package/cli/lib/hubs-config.mjs +15 -1
- package/cli/lib/hubs-config.test.mjs +19 -0
- package/cli/lib/share-plan.mjs +5 -1
- package/cli/lib/share-plan.test.mjs +11 -2
- 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
|
-
|
|
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
|
|
9872
|
-
|
|
9873
|
-
|
|
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={(
|
|
14098
|
+
onRetime={(clipRef, patch) => {
|
|
13789
14099
|
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
13790
|
-
// DDR-150 P2 —
|
|
13791
|
-
//
|
|
13792
|
-
//
|
|
13793
|
-
//
|
|
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
|
|
13802
|
-
|
|
13803
|
-
|
|
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
|
-
|
|
13826
|
-
} else
|
|
13827
|
-
|
|
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={
|
|
13835
|
-
|
|
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
|
|
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={(
|
|
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[
|
|
14184
|
+
timelineSequences[rowIndex]?.layers?.[layerIndex]?.kind === 'audio'
|
|
13918
14185
|
? 'audio/*'
|
|
13919
|
-
: timelineSequences[
|
|
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
|
|
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={(
|
|
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 =
|
|
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
|
-
|
|
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={(
|
|
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
|
|
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)
|
|
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
|
-
|
|
14280
|
+
onReorderMove={(movedRef2, targetRef, position) => {
|
|
14021
14281
|
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
14022
|
-
//
|
|
14023
|
-
//
|
|
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
|
|
14027
|
-
|
|
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((
|
|
14041
|
-
|
|
14042
|
-
|
|
14043
|
-
|
|
14044
|
-
|
|
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
|
-
|
|
14053
|
-
|
|
14054
|
-
|
|
14055
|
-
|
|
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('[
|
|
14063
|
-
|
|
14064
|
-
} else if (j
|
|
14065
|
-
shellToast('Clip
|
|
14066
|
-
if (j.seq != null) pushTlUndo(activePath, j.seq, '
|
|
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
|