@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.
- 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 +849 -121
- package/apps/studio/client/github.js +2 -0
- 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 +590 -0
- 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 +1 -0
- 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/git/service.ts +1 -1
- package/apps/studio/http.ts +113 -6
- package/apps/studio/ripple.ts +335 -0
- package/apps/studio/server.ts +7 -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 +28 -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/gitignore-block.mjs +1 -0
- 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
|
@@ -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
|
-
|
|
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
|
|
9856
|
-
|
|
9857
|
-
|
|
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={(
|
|
14098
|
+
onRetime={(clipRef, patch) => {
|
|
13765
14099
|
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
13766
|
-
// DDR-150 P2 —
|
|
13767
|
-
//
|
|
13768
|
-
//
|
|
13769
|
-
//
|
|
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
|
|
13778
|
-
|
|
13779
|
-
|
|
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
|
-
|
|
13802
|
-
} else
|
|
13803
|
-
|
|
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={
|
|
13811
|
-
|
|
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
|
|
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={(
|
|
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[
|
|
14184
|
+
timelineSequences[rowIndex]?.layers?.[layerIndex]?.kind === 'audio'
|
|
13894
14185
|
? 'audio/*'
|
|
13895
|
-
: timelineSequences[
|
|
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
|
|
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={(
|
|
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 =
|
|
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
|
-
|
|
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={(
|
|
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
|
|
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)
|
|
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
|
-
|
|
14280
|
+
onReorderMove={(movedRef2, targetRef, position) => {
|
|
13997
14281
|
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
13998
|
-
//
|
|
13999
|
-
//
|
|
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
|
|
14003
|
-
|
|
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((
|
|
14017
|
-
|
|
14018
|
-
|
|
14019
|
-
|
|
14020
|
-
|
|
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
|
-
|
|
14029
|
-
|
|
14030
|
-
|
|
14031
|
-
|
|
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('[
|
|
14039
|
-
|
|
14040
|
-
} else if (j
|
|
14041
|
-
shellToast('Clip
|
|
14042
|
-
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');
|
|
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)} />
|