@1agh/maude 0.39.1 → 0.40.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/README.md +1 -1
- package/apps/studio/annotations-context-toolbar.tsx +44 -1
- package/apps/studio/annotations-layer.tsx +253 -3
- package/apps/studio/annotations-model.ts +107 -6
- package/apps/studio/api.ts +812 -64
- package/apps/studio/bin/_html-playwright.mjs +9 -1
- package/apps/studio/bin/_pdf-playwright.mjs +8 -1
- package/apps/studio/bin/_png-playwright.mjs +8 -1
- package/apps/studio/bin/_pw-launch.mjs +54 -0
- package/apps/studio/bin/_svg-playwright.mjs +8 -1
- package/apps/studio/bin/_video-playwright.mjs +452 -0
- package/apps/studio/bin/prep.sh +8 -1
- package/apps/studio/canvas-edit.ts +1885 -104
- package/apps/studio/canvas-lib.tsx +19 -0
- package/apps/studio/canvas-list-watch.ts +6 -2
- package/apps/studio/canvas-shell.tsx +27 -0
- package/apps/studio/client/app.jsx +1029 -30
- package/apps/studio/client/github.js +7 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +860 -0
- package/apps/studio/client/panels/timeline-parse.js +229 -0
- package/apps/studio/client/panels/timeline-snap.js +55 -0
- package/apps/studio/client/styles/3-shell-maude.css +107 -0
- package/apps/studio/config.schema.json +14 -0
- package/apps/studio/context-menu.tsx +1 -1
- package/apps/studio/context.ts +113 -1
- package/apps/studio/dist/client.bundle.js +88 -16
- package/apps/studio/dist/comment-mount.js +1 -1
- package/apps/studio/dist/runtime/.min-sizes.json +11 -1
- package/apps/studio/dist/runtime/@remotion_media.js +491 -0
- package/apps/studio/dist/runtime/@remotion_player.js +56 -0
- package/apps/studio/dist/runtime/@remotion_transitions.js +300 -0
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_fade.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_flip.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_none.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_slide.js +1 -0
- package/apps/studio/dist/runtime/@remotion_transitions_wipe.js +1 -0
- package/apps/studio/dist/runtime/REMOTION-LICENSE.md +28 -0
- package/apps/studio/dist/runtime/remotion.js +42 -0
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +117 -0
- package/apps/studio/exporters/_runtime.ts +69 -0
- package/apps/studio/exporters/html.ts +4 -3
- package/apps/studio/exporters/index.ts +28 -2
- package/apps/studio/exporters/pdf.ts +4 -3
- package/apps/studio/exporters/png.ts +5 -3
- package/apps/studio/exporters/pptx.ts +6 -4
- package/apps/studio/exporters/svg.ts +9 -5
- package/apps/studio/exporters/video-encode-lib.ts +200 -0
- package/apps/studio/exporters/video-render-lib.ts +108 -0
- package/apps/studio/exporters/video.ts +184 -0
- package/apps/studio/http.ts +535 -27
- package/apps/studio/input-router.tsx +7 -1
- package/apps/studio/runtime-bundle.ts +30 -0
- package/apps/studio/server.ts +34 -9
- package/apps/studio/test/annotations-roundtrip.test.ts +47 -0
- package/apps/studio/test/canvas-create-api.test.ts +76 -0
- package/apps/studio/test/canvas-edit.test.ts +90 -0
- package/apps/studio/test/canvas-list-watch.test.ts +49 -0
- package/apps/studio/test/canvas-media-drop.test.ts +30 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +36 -0
- package/apps/studio/test/clip-addressing.test.ts +732 -0
- package/apps/studio/test/config-reload.test.ts +230 -0
- package/apps/studio/test/dns-rebinding-guard.test.ts +74 -0
- package/apps/studio/test/edit-persistence.test.ts +91 -0
- package/apps/studio/test/exporters/runtime.test.ts +59 -0
- package/apps/studio/test/file-lock.test.ts +84 -0
- package/apps/studio/test/fixtures/video-comp-fixture.tsx +82 -0
- package/apps/studio/test/timeline-parse.test.ts +127 -0
- package/apps/studio/test/timeline-snap.test.ts +85 -0
- package/apps/studio/test/video-asset.test.ts +163 -0
- package/apps/studio/test/video-comp-fixture.test.ts +50 -0
- package/apps/studio/test/video-comp.test.ts +168 -0
- package/apps/studio/test/video-render-bridge.test.ts +149 -0
- package/apps/studio/use-annotation-resize.tsx +6 -3
- package/apps/studio/use-canvas-media-drop.tsx +66 -4
- package/apps/studio/video-comp.tsx +444 -0
- package/apps/studio/whats-new.json +27 -0
- package/apps/studio/ws.ts +5 -0
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +25 -2
- package/plugins/design/templates/design-system-inspiration/_MAPPING.md +1 -1
|
@@ -19,8 +19,16 @@ import GitPanel from './panels/GitPanel.jsx';
|
|
|
19
19
|
import IdentityBar from './panels/IdentityBar.jsx';
|
|
20
20
|
import OnboardingWizard from './panels/OnboardingWizard.jsx';
|
|
21
21
|
import { ReadinessDialog } from './panels/ReadinessList.jsx';
|
|
22
|
+
import TimelinePanel from './panels/TimelinePanel.jsx';
|
|
23
|
+
import { parseCompTimeline } from './panels/timeline-parse.js';
|
|
22
24
|
import RepoBranchSwitcher from './panels/RepoBranchSwitcher.jsx';
|
|
23
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
appIsFirstRun,
|
|
27
|
+
isNativeApp,
|
|
28
|
+
onUpdateReady,
|
|
29
|
+
restartToUpdate,
|
|
30
|
+
saveExport,
|
|
31
|
+
} from './github.js';
|
|
24
32
|
import { COLLAB_TOUR } from './tour/collab-tour.js';
|
|
25
33
|
import { TourOverlay } from './tour/overlay.jsx';
|
|
26
34
|
import { USAGE_TOUR } from './tour/usage-tour.js';
|
|
@@ -92,6 +100,33 @@ function basename(p) {
|
|
|
92
100
|
return p.split('/').pop();
|
|
93
101
|
}
|
|
94
102
|
|
|
103
|
+
// DDR-150 dogfood — a transient shell toast for timeline/inline-edit op results.
|
|
104
|
+
// The clip ops used to console.warn their failures (a 422 "series clip can't
|
|
105
|
+
// move" looked like "the button does nothing"). Self-contained DOM (no React
|
|
106
|
+
// state), mirrors the canvas-side showCanvasToast.
|
|
107
|
+
function shellToast(message, ok = false) {
|
|
108
|
+
if (typeof document === 'undefined') return;
|
|
109
|
+
let el = document.getElementById('st-op-toast');
|
|
110
|
+
if (!el) {
|
|
111
|
+
el = document.createElement('div');
|
|
112
|
+
el.id = 'st-op-toast';
|
|
113
|
+
el.setAttribute('role', 'status');
|
|
114
|
+
el.style.cssText =
|
|
115
|
+
'position:fixed;left:50%;bottom:64px;transform:translateX(-50%);z-index:80;' +
|
|
116
|
+
'max-width:440px;padding:8px 14px;border-radius:8px;font:12px/1.45 var(--font-mono,monospace);' +
|
|
117
|
+
'box-shadow:0 8px 28px rgba(0,0,0,.34);pointer-events:none;opacity:0;transition:opacity 140ms ease;';
|
|
118
|
+
document.body.appendChild(el);
|
|
119
|
+
}
|
|
120
|
+
el.style.background = ok ? '#1d3524' : '#3a1d1d';
|
|
121
|
+
el.style.color = ok ? '#b7e4c0' : '#f1b8b8';
|
|
122
|
+
el.textContent = message;
|
|
123
|
+
el.style.opacity = '1';
|
|
124
|
+
clearTimeout(shellToast._t);
|
|
125
|
+
shellToast._t = setTimeout(() => {
|
|
126
|
+
el.style.opacity = '0';
|
|
127
|
+
}, 3200);
|
|
128
|
+
}
|
|
129
|
+
|
|
95
130
|
// Strip canvas extensions for display. `Canvas Viewport.tsx` → `Canvas Viewport`.
|
|
96
131
|
// Sidecars (`.meta.json`, `.css`, `.registry.json`) keep their extensions so
|
|
97
132
|
// the file type stays unambiguous.
|
|
@@ -768,6 +803,11 @@ const EXPORT_CARDS = [
|
|
|
768
803
|
{ id: 'svg', label: 'SVG', sub: 'per artboard', icon: 'vector', format: 'svg' },
|
|
769
804
|
{ id: 'html', label: 'HTML', sub: 'self-contained', icon: 'code', format: 'html' },
|
|
770
805
|
{ id: 'pptx', label: 'PPTX', sub: 'slides', icon: 'presentation', format: 'pptx' },
|
|
806
|
+
// DDR-148 — temporal formats. Shown only when the active canvas has a
|
|
807
|
+
// video-comp (`temporal: true` + the hasComps gate in ExportDialog); the
|
|
808
|
+
// capture engine renders the artboard frame-by-frame.
|
|
809
|
+
{ id: 'mp4', label: 'MP4', sub: 'video · H.264', icon: 'presentation', format: 'mp4', temporal: true },
|
|
810
|
+
{ id: 'gif', label: 'GIF', sub: 'animated', icon: 'image', format: 'gif', temporal: true },
|
|
771
811
|
{ id: 'canva', label: 'Canva', sub: 'handoff bundle', icon: 'external', format: 'canva' },
|
|
772
812
|
{ id: 'zip', label: 'ZIP', sub: 'project bundle', icon: 'archive', format: 'zip' },
|
|
773
813
|
{ id: 'shadcn', label: 'AI handoff', sub: 'production drop', icon: 'sparkle', handoff: true },
|
|
@@ -787,6 +827,9 @@ const EXPORT_VALID_SCOPES = {
|
|
|
787
827
|
svg: ['selection', 'artboard', 'canvas-as-separate'],
|
|
788
828
|
html: ['artboard', 'canvas-as-separate'],
|
|
789
829
|
pptx: ['canvas-as-separate'],
|
|
830
|
+
mp4: ['artboard'],
|
|
831
|
+
gif: ['artboard'],
|
|
832
|
+
webm: ['artboard'],
|
|
790
833
|
canva: ['canvas-as-separate'],
|
|
791
834
|
zip: ['project-raw'],
|
|
792
835
|
};
|
|
@@ -796,12 +839,25 @@ const PNG_SCALES = [
|
|
|
796
839
|
{ value: 3, label: '3× (max)' },
|
|
797
840
|
];
|
|
798
841
|
|
|
799
|
-
function ExportDialog({
|
|
842
|
+
function ExportDialog({
|
|
843
|
+
mode,
|
|
844
|
+
initialScope,
|
|
845
|
+
activePath,
|
|
846
|
+
hasComps = false,
|
|
847
|
+
activeArtboardId = null,
|
|
848
|
+
selection = null,
|
|
849
|
+
onClose,
|
|
850
|
+
}) {
|
|
800
851
|
const [sel, setSel] = useState(mode === 'handoff' ? 'shadcn' : 'png');
|
|
801
852
|
const [scope, setScope] = useState(
|
|
802
853
|
initialScope && EXPORT_SCOPE_LABELS[initialScope] ? initialScope : 'artboard'
|
|
803
854
|
);
|
|
804
855
|
const [scale, setScale] = useState(2);
|
|
856
|
+
// DDR-148 addendum — mp4/webm of a registered video-comp render through
|
|
857
|
+
// renderMediaOnWeb, which produces real audio (Remotion owns the
|
|
858
|
+
// TransitionSeries/volume-closure timeline). gif has no audio track at all
|
|
859
|
+
// (format limitation, not a toggle), so this only applies to mp4/webm.
|
|
860
|
+
const [audio, setAudio] = useState(true);
|
|
805
861
|
const [busy, setBusy] = useState(false);
|
|
806
862
|
const [status, setStatus] = useState(null); // { ok, msg }
|
|
807
863
|
const [recent, setRecent] = useState([]);
|
|
@@ -847,7 +903,18 @@ function ExportDialog({ mode, initialScope, activePath, onClose }) {
|
|
|
847
903
|
}
|
|
848
904
|
setBusy(true);
|
|
849
905
|
setStatus(null);
|
|
850
|
-
|
|
906
|
+
// `scale` drives PNG size AND video resolution (deviceScaleFactor → encoder
|
|
907
|
+
// dims); temporal formats were previously fixed at the tiny native size.
|
|
908
|
+
const options = card.format === 'png' || card.temporal ? { scale } : {};
|
|
909
|
+
// Export-with-audio (DDR-148 addendum) — mp4/webm only; gif is silent by
|
|
910
|
+
// format, so the checkbox never renders for it (see hasAudioToggle below).
|
|
911
|
+
if (card.format === 'mp4' || card.format === 'webm') options.audio = audio;
|
|
912
|
+
// Scope targeting hints (resolveScope reads these): `artboardId` makes
|
|
913
|
+
// "Active artboard" export the right screen instead of `:first-of-type`;
|
|
914
|
+
// `selection` makes "Current selection" export the selected element. Mirrors
|
|
915
|
+
// the in-canvas dialog's captureScopeHints.
|
|
916
|
+
if (activeArtboardId) options.artboardId = activeArtboardId;
|
|
917
|
+
if (selection?.selector) options.selection = selection;
|
|
851
918
|
try {
|
|
852
919
|
const r = await fetch('/_api/export', {
|
|
853
920
|
method: 'POST',
|
|
@@ -863,16 +930,30 @@ function ExportDialog({ mode, initialScope, activePath, onClose }) {
|
|
|
863
930
|
const fn = /filename="([^"]+)"/.exec(disp);
|
|
864
931
|
const filename = (fn && fn[1]) || `export.${card.format}`;
|
|
865
932
|
const blob = await r.blob();
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
933
|
+
if (isNativeApp()) {
|
|
934
|
+
// Native: WKWebView swallows the `<a download>` blob (the file lands in
|
|
935
|
+
// an unknown default with no prompt). Route through a real OS save dialog
|
|
936
|
+
// so the export OFFERS a location. Cancel → soft no-op.
|
|
937
|
+
const bytes = Array.from(new Uint8Array(await blob.arrayBuffer()));
|
|
938
|
+
const savedPath = await saveExport(filename, bytes);
|
|
939
|
+
if (savedPath) {
|
|
940
|
+
setStatus({ ok: true, msg: `Saved to ${savedPath}` });
|
|
941
|
+
loadRecent();
|
|
942
|
+
} else {
|
|
943
|
+
setStatus({ ok: true, msg: 'Save cancelled' });
|
|
944
|
+
}
|
|
945
|
+
} else {
|
|
946
|
+
const url = URL.createObjectURL(blob);
|
|
947
|
+
const a = document.createElement('a');
|
|
948
|
+
a.href = url;
|
|
949
|
+
a.download = filename;
|
|
950
|
+
document.body.appendChild(a);
|
|
951
|
+
a.click();
|
|
952
|
+
a.remove();
|
|
953
|
+
URL.revokeObjectURL(url);
|
|
954
|
+
setStatus({ ok: true, msg: `Exported ${filename}` });
|
|
955
|
+
loadRecent();
|
|
956
|
+
}
|
|
876
957
|
} catch (err) {
|
|
877
958
|
setStatus({ ok: false, msg: err && err.message ? err.message : String(err) });
|
|
878
959
|
}
|
|
@@ -901,7 +982,7 @@ function ExportDialog({ mode, initialScope, activePath, onClose }) {
|
|
|
901
982
|
: 'Format'}
|
|
902
983
|
</div>
|
|
903
984
|
<div className="st-fmt-grid">
|
|
904
|
-
{EXPORT_CARDS.map((c) => (
|
|
985
|
+
{EXPORT_CARDS.filter((c) => !c.temporal || hasComps).map((c) => (
|
|
905
986
|
<button
|
|
906
987
|
type="button"
|
|
907
988
|
key={c.id}
|
|
@@ -936,10 +1017,10 @@ function ExportDialog({ mode, initialScope, activePath, onClose }) {
|
|
|
936
1017
|
</select>
|
|
937
1018
|
</div>
|
|
938
1019
|
)}
|
|
939
|
-
{!card.handoff && card.format === 'png' && (
|
|
1020
|
+
{!card.handoff && (card.format === 'png' || card.temporal) && (
|
|
940
1021
|
<div className="st-dialog-row">
|
|
941
1022
|
<label className="st-dialog-lbl" htmlFor="st-export-size">
|
|
942
|
-
Size
|
|
1023
|
+
{card.temporal ? 'Resolution' : 'Size'}
|
|
943
1024
|
</label>
|
|
944
1025
|
<select
|
|
945
1026
|
id="st-export-size"
|
|
@@ -960,6 +1041,28 @@ function ExportDialog({ mode, initialScope, activePath, onClose }) {
|
|
|
960
1041
|
Resolution multiplier — {scale}× ≈ {1440 * scale}×{900 * scale} for a 1440×900 artboard.
|
|
961
1042
|
</div>
|
|
962
1043
|
)}
|
|
1044
|
+
{!card.handoff && card.temporal && (
|
|
1045
|
+
<div className="st-mono" style={{ fontSize: 11, color: 'var(--fg-3)' }}>
|
|
1046
|
+
{scale}× the artboard's native resolution (e.g. 960×540 → {960 * scale}×{540 * scale}).
|
|
1047
|
+
</div>
|
|
1048
|
+
)}
|
|
1049
|
+
{!card.handoff && (card.format === 'mp4' || card.format === 'webm') && (
|
|
1050
|
+
<div className="st-dialog-row">
|
|
1051
|
+
<label
|
|
1052
|
+
className="st-dialog-lbl"
|
|
1053
|
+
htmlFor="st-export-audio"
|
|
1054
|
+
style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}
|
|
1055
|
+
>
|
|
1056
|
+
<input
|
|
1057
|
+
id="st-export-audio"
|
|
1058
|
+
type="checkbox"
|
|
1059
|
+
checked={audio}
|
|
1060
|
+
onChange={(e) => setAudio(e.target.checked)}
|
|
1061
|
+
/>
|
|
1062
|
+
Export with audio
|
|
1063
|
+
</label>
|
|
1064
|
+
</div>
|
|
1065
|
+
)}
|
|
963
1066
|
{card.handoff && (
|
|
964
1067
|
<div className="callout callout--info" style={{ fontSize: 12 }}>
|
|
965
1068
|
Hands the active canvas off to production. Copies{' '}
|
|
@@ -2340,6 +2443,9 @@ function FileDropdown({ onAction, onClose, hasCanvas }) {
|
|
|
2340
2443
|
items={[
|
|
2341
2444
|
// Bare N — the browser reserves ⌘N (New Window) and never delivers it.
|
|
2342
2445
|
{ id: 'new', label: 'New canvas…', shortcut: 'N' },
|
|
2446
|
+
// DDR-150 P4 Task 12 — one-click "udělej z toho video" from the clips
|
|
2447
|
+
// dropped as reference chips on the active canvas.
|
|
2448
|
+
{ id: 'assemble', label: 'Assemble dropped clips → video', disabled: !hasCanvas },
|
|
2343
2449
|
{ id: 'export', label: 'Export…', shortcut: '⇧⌘E' },
|
|
2344
2450
|
{ id: 'handoff', label: 'Handoff to production', shortcut: '⇧⌘H' },
|
|
2345
2451
|
{ sep: true },
|
|
@@ -2406,11 +2512,15 @@ function Menubar({
|
|
|
2406
2512
|
inspectorTab,
|
|
2407
2513
|
onToggleInspector,
|
|
2408
2514
|
onOpenLayers,
|
|
2515
|
+
timelineOpen,
|
|
2516
|
+
onToggleTimeline,
|
|
2517
|
+
hasComps = false,
|
|
2409
2518
|
assistantOpen,
|
|
2410
2519
|
onToggleAssistant,
|
|
2411
2520
|
assistantBusy,
|
|
2412
2521
|
assistantUnseen,
|
|
2413
2522
|
onNewCanvas,
|
|
2523
|
+
onAssembleVideo,
|
|
2414
2524
|
onOpenExport,
|
|
2415
2525
|
onReload,
|
|
2416
2526
|
onCloseCanvas,
|
|
@@ -2464,6 +2574,15 @@ function Menubar({
|
|
|
2464
2574
|
checked: inspectorOpen,
|
|
2465
2575
|
disabled: false,
|
|
2466
2576
|
},
|
|
2577
|
+
// DDR-148 — Timeline (video-comp scrub). Phase-tag hints when the active
|
|
2578
|
+
// canvas actually has a comp; the panel itself shows an empty state otherwise.
|
|
2579
|
+
{
|
|
2580
|
+
id: 'timeline',
|
|
2581
|
+
label: 'Timeline',
|
|
2582
|
+
phase: hasComps ? 'video' : undefined,
|
|
2583
|
+
checked: timelineOpen,
|
|
2584
|
+
disabled: false,
|
|
2585
|
+
},
|
|
2467
2586
|
// Phase 31 (DDR-123) — native-only ACP chat sidepanel.
|
|
2468
2587
|
...(isNativeApp()
|
|
2469
2588
|
? [
|
|
@@ -2598,6 +2717,7 @@ function Menubar({
|
|
|
2598
2717
|
hasCanvas={!!activePath}
|
|
2599
2718
|
onAction={(id) => {
|
|
2600
2719
|
if (id === 'new') onNewCanvas?.();
|
|
2720
|
+
else if (id === 'assemble') onAssembleVideo?.();
|
|
2601
2721
|
else if (id === 'export') onOpenExport?.('export');
|
|
2602
2722
|
else if (id === 'handoff') onOpenExport?.('handoff');
|
|
2603
2723
|
else if (id === 'reload') onReload?.();
|
|
@@ -2628,6 +2748,7 @@ function Menubar({
|
|
|
2628
2748
|
else if (id === 'hidden') onToggleShowHidden();
|
|
2629
2749
|
else if (id === 'annotate') onToggleAnnotations();
|
|
2630
2750
|
else if (id === 'inspector') onToggleInspector();
|
|
2751
|
+
else if (id === 'timeline') onToggleTimeline?.();
|
|
2631
2752
|
else if (id === 'assistant') onToggleAssistant?.();
|
|
2632
2753
|
else if (id === 'layers') onOpenLayers?.();
|
|
2633
2754
|
else if (id === 'minimap') onToggleMinimap?.();
|
|
@@ -5993,15 +6114,15 @@ function App() {
|
|
|
5993
6114
|
const cap = setTimeout(() => setLoadingPath(null), 15000);
|
|
5994
6115
|
return () => clearTimeout(cap);
|
|
5995
6116
|
}, [loadingPath]);
|
|
5996
|
-
// Loaded
|
|
5997
|
-
//
|
|
6117
|
+
// Loaded at boot from /_config and re-fetched on the server's
|
|
6118
|
+
// `config-updated` push (config.json hot-reload — /design:setup-ds rewrites
|
|
6119
|
+
// it mid-session) — informs canvasUrl() so TSX iframes can pass the right
|
|
6120
|
+
// ?designRel + ?tokens query to the canvas mount shell.
|
|
5998
6121
|
const [cfg, setCfg] = useState({ designRel: '.design' });
|
|
5999
|
-
|
|
6000
|
-
let cancelled = false;
|
|
6122
|
+
const loadServerConfig = useCallback(() => {
|
|
6001
6123
|
fetch('/_config')
|
|
6002
6124
|
.then((r) => r.json())
|
|
6003
6125
|
.then((data) => {
|
|
6004
|
-
if (cancelled) return;
|
|
6005
6126
|
const designRel = (data.designRoot || '.design').replace(/^\/+|\/+$/g, '');
|
|
6006
6127
|
// Functional merge — the `/_config` and `/_index-data` fetches race, and
|
|
6007
6128
|
// the latter contributes `canvasDesignSystems` (DDR-093). A full-replace
|
|
@@ -6023,10 +6144,10 @@ function App() {
|
|
|
6023
6144
|
}));
|
|
6024
6145
|
})
|
|
6025
6146
|
.catch(() => {});
|
|
6026
|
-
return () => {
|
|
6027
|
-
cancelled = true;
|
|
6028
|
-
};
|
|
6029
6147
|
}, []);
|
|
6148
|
+
useEffect(() => {
|
|
6149
|
+
loadServerConfig();
|
|
6150
|
+
}, [loadServerConfig]);
|
|
6030
6151
|
// Backfill the sync banner on mount from /_sync-status. The 'sync:status' WS
|
|
6031
6152
|
// broadcast is one-shot for the zero-syncable case (DDR-060 / 9.1-D), so a
|
|
6032
6153
|
// tab that connects after boot would otherwise miss it. {linked:false} (solo)
|
|
@@ -6079,6 +6200,46 @@ function App() {
|
|
|
6079
6200
|
// The palette (T4) drives them; the dialog (T5) + panel (T6) consume them.
|
|
6080
6201
|
const [exportDialog, setExportDialog] = useState(null); // null | { mode: 'export'|'handoff', scope? }
|
|
6081
6202
|
const [inspectorOpen, setInspectorOpen] = useState(false);
|
|
6203
|
+
// DDR-148 — Timeline panel (right dock) for scrubbing a video-comp. `activeComps`
|
|
6204
|
+
// is populated from the iframe's `timeline-comps` announce; `timelineFrame` from
|
|
6205
|
+
// its live `timeline-frame`. Empty comps ⇒ the panel shows its empty state.
|
|
6206
|
+
const [timelineOpen, setTimelineOpen] = useState(false);
|
|
6207
|
+
const [activeComps, setActiveComps] = useState([]);
|
|
6208
|
+
const [timelineFrame, setTimelineFrame] = useState(0);
|
|
6209
|
+
const [timelinePlaying, setTimelinePlaying] = useState(false);
|
|
6210
|
+
const [timelineLoop, setTimelineLoop] = useState(true);
|
|
6211
|
+
const [timelineMuted, setTimelineMuted] = useState(false);
|
|
6212
|
+
const [timelineVolume, setTimelineVolume] = useState(1);
|
|
6213
|
+
const [timelineHeight, setTimelineHeight] = useState(216);
|
|
6214
|
+
// The artboard nearest the viewport centre (reported by canvas-lib on pan) —
|
|
6215
|
+
// the Timeline follows THIS, so it redraws as you move across the canvas.
|
|
6216
|
+
const [canvasActiveArtboard, setCanvasActiveArtboard] = useState(null);
|
|
6217
|
+
// DDR-148 — parsed sequence/keyframe rows for the Timeline (from raw .tsx).
|
|
6218
|
+
const [timelineSequences, setTimelineSequences] = useState([]);
|
|
6219
|
+
const [timelineAudio, setTimelineAudio] = useState([]);
|
|
6220
|
+
const [timelineTotal, setTimelineTotal] = useState(0);
|
|
6221
|
+
// On a multi-comp canvas the Timeline drives ONE comp (the parser scopes to it
|
|
6222
|
+
// + reports its total). Match that total back to the announced comp id so the
|
|
6223
|
+
// transport/scrub targets ONLY that comp — not every artboard on the canvas.
|
|
6224
|
+
const timelineCompId = useMemo(() => {
|
|
6225
|
+
if (!activeComps.length) return null;
|
|
6226
|
+
const match = activeComps.find((c) => c.durationInFrames === timelineTotal);
|
|
6227
|
+
return (match ?? activeComps[0]).id;
|
|
6228
|
+
}, [activeComps, timelineTotal]);
|
|
6229
|
+
const timelineCompIdRef = useRef(null);
|
|
6230
|
+
useEffect(() => {
|
|
6231
|
+
timelineCompIdRef.current = timelineCompId;
|
|
6232
|
+
}, [timelineCompId]);
|
|
6233
|
+
// The DCArtboard id the timeline scoped to (from parseCompTimeline). Used for
|
|
6234
|
+
// /_api/comp-clips + every clip op so the enumerator targets the SAME comp the
|
|
6235
|
+
// rows came from — `timelineCompId` is the Player's `videocomp-N` id, which
|
|
6236
|
+
// never matches a DCArtboard id and made the enumerator fall back to the wrong
|
|
6237
|
+
// comp on a multi-comp canvas (the showreel badge/replace/op mis-scope).
|
|
6238
|
+
const [timelineArtboardId, setTimelineArtboardId] = useState(null);
|
|
6239
|
+
const timelineArtboardIdRef = useRef(null);
|
|
6240
|
+
useEffect(() => {
|
|
6241
|
+
timelineArtboardIdRef.current = timelineArtboardId;
|
|
6242
|
+
}, [timelineArtboardId]);
|
|
6082
6243
|
// Phase 31 (DDR-123) — the native ACP chat sidepanel (right dock, native-only).
|
|
6083
6244
|
const [assistantOpen, setAssistantOpen] = useState(false);
|
|
6084
6245
|
const [assistantBusy, setAssistantBusy] = useState(false);
|
|
@@ -6128,6 +6289,8 @@ function App() {
|
|
|
6128
6289
|
setInspectorOpen(which === 'inspector');
|
|
6129
6290
|
setCommentsPanelOpen(which === 'comments');
|
|
6130
6291
|
setAssistantOpen(which === 'assistant');
|
|
6292
|
+
// NOTE: the Timeline is a BOTTOM dock (DDR-148), NOT part of the right-rail
|
|
6293
|
+
// mutual-exclusion — it stays open when a right panel opens.
|
|
6131
6294
|
}, []);
|
|
6132
6295
|
// Functional updates so this is stale-closure-safe inside the keydown /
|
|
6133
6296
|
// postMessage listeners; opening always clears the sibling panels.
|
|
@@ -6170,6 +6333,9 @@ function App() {
|
|
|
6170
6333
|
});
|
|
6171
6334
|
}
|
|
6172
6335
|
}, []);
|
|
6336
|
+
// DDR-148 — the Timeline is a BOTTOM dock, independent of the right rail: it
|
|
6337
|
+
// toggles on its own and coexists with Inspector/Changes/Comments/Chat.
|
|
6338
|
+
const toggleTimeline = useCallback(() => setTimelineOpen((v) => !v), []);
|
|
6173
6339
|
const whatsNew = useWhatsNew(MDCC_VERSION);
|
|
6174
6340
|
// Phase 29 (E4) — first-run onboarding wizard. The native shell boots a minimal
|
|
6175
6341
|
// "welcome" project on first launch; we ask it whether this is a first run and, if
|
|
@@ -6288,6 +6454,269 @@ function App() {
|
|
|
6288
6454
|
[activePath]
|
|
6289
6455
|
);
|
|
6290
6456
|
|
|
6457
|
+
// DDR-150 dogfood #7 — a file dragged from Finder and dropped on a shell area
|
|
6458
|
+
// with no drop target used to make the browser NAVIGATE AWAY to the file
|
|
6459
|
+
// (file:// replaces the app). Document-level guard: always cancel the default
|
|
6460
|
+
// for file drags; the TimelinePanel's own onDrop (bubbling before this) still
|
|
6461
|
+
// receives the drop first — this only blocks the browser fallback.
|
|
6462
|
+
useEffect(() => {
|
|
6463
|
+
const isFileDrag = (e) => Array.from(e.dataTransfer?.types ?? []).includes('Files');
|
|
6464
|
+
const onDocDragOver = (e) => {
|
|
6465
|
+
if (isFileDrag(e)) e.preventDefault();
|
|
6466
|
+
};
|
|
6467
|
+
const onDocDrop = (e) => {
|
|
6468
|
+
if (isFileDrag(e)) e.preventDefault();
|
|
6469
|
+
};
|
|
6470
|
+
document.addEventListener('dragover', onDocDragOver);
|
|
6471
|
+
document.addEventListener('drop', onDocDrop);
|
|
6472
|
+
return () => {
|
|
6473
|
+
document.removeEventListener('dragover', onDocDragOver);
|
|
6474
|
+
document.removeEventListener('drop', onDocDrop);
|
|
6475
|
+
};
|
|
6476
|
+
}, []);
|
|
6477
|
+
|
|
6478
|
+
// DDR-150 P3 Task 9 — timeline keyboard shortcuts. Read live state through a
|
|
6479
|
+
// ref so the listener attaches once. Gated on: timeline open + a comp active +
|
|
6480
|
+
// focus NOT in a text field. Space doesn't steal the canvas PAN chord because
|
|
6481
|
+
// that keydown fires inside the focused canvas iframe, never reaching this
|
|
6482
|
+
// top-document listener. Space = play/pause · ←/→ = ±1 frame (Shift = ±1s) ·
|
|
6483
|
+
// Home/End = start/end · ,/. = prev/next keyframe boundary.
|
|
6484
|
+
const tlKeyRef = useRef({});
|
|
6485
|
+
tlKeyRef.current = {
|
|
6486
|
+
open: timelineOpen,
|
|
6487
|
+
comps: activeComps,
|
|
6488
|
+
frame: timelineFrame,
|
|
6489
|
+
total: timelineTotal,
|
|
6490
|
+
playing: timelinePlaying,
|
|
6491
|
+
compId: timelineCompId,
|
|
6492
|
+
muted: timelineMuted,
|
|
6493
|
+
loop: timelineLoop,
|
|
6494
|
+
sequences: timelineSequences,
|
|
6495
|
+
post: postToActiveCanvas,
|
|
6496
|
+
canvas: activePath,
|
|
6497
|
+
};
|
|
6498
|
+
|
|
6499
|
+
// DDR-150 dogfood #1 — Timeline undo/redo. Every successful clip op (move /
|
|
6500
|
+
// trim / remove / insert / z-reorder / replace-src) returns a server `seq`
|
|
6501
|
+
// registered in the whole-file undo log; Cmd+Z / Shift+Cmd+Z replay it via
|
|
6502
|
+
// /_api/reorder-revert (guarded swap — 409s honestly if the canvas diverged).
|
|
6503
|
+
const tlUndoRef = useRef({ undo: [], redo: [] });
|
|
6504
|
+
const pushTlUndo = useCallback((canvas, seq, label) => {
|
|
6505
|
+
if (typeof seq !== 'number') return;
|
|
6506
|
+
tlUndoRef.current.undo.push({ canvas, seq, label });
|
|
6507
|
+
tlUndoRef.current.redo = []; // a new edit invalidates the redo branch
|
|
6508
|
+
if (tlUndoRef.current.undo.length > 50) tlUndoRef.current.undo.shift();
|
|
6509
|
+
}, []);
|
|
6510
|
+
const tlUndoRedo = useCallback((dir) => {
|
|
6511
|
+
const s = tlUndoRef.current;
|
|
6512
|
+
const src = dir === 'undo' ? s.undo : s.redo;
|
|
6513
|
+
const dst = dir === 'undo' ? s.redo : s.undo;
|
|
6514
|
+
const canvas = tlKeyRef.current.canvas;
|
|
6515
|
+
// Last entry for THIS canvas (stacks are global; ops are per-canvas).
|
|
6516
|
+
let idx = -1;
|
|
6517
|
+
for (let i = src.length - 1; i >= 0; i--) {
|
|
6518
|
+
if (src[i].canvas === canvas) {
|
|
6519
|
+
idx = i;
|
|
6520
|
+
break;
|
|
6521
|
+
}
|
|
6522
|
+
}
|
|
6523
|
+
if (idx < 0) {
|
|
6524
|
+
shellToast(dir === 'undo' ? 'Nothing to undo on this timeline.' : 'Nothing to redo.');
|
|
6525
|
+
return;
|
|
6526
|
+
}
|
|
6527
|
+
const entry = src[idx];
|
|
6528
|
+
fetch('/_api/reorder-revert', {
|
|
6529
|
+
method: 'POST',
|
|
6530
|
+
headers: { 'content-type': 'application/json' },
|
|
6531
|
+
body: JSON.stringify({ canvas: entry.canvas, seq: entry.seq, dir }),
|
|
6532
|
+
})
|
|
6533
|
+
.then((r) => r.json().catch(() => ({})))
|
|
6534
|
+
.then((j) => {
|
|
6535
|
+
src.splice(idx, 1);
|
|
6536
|
+
if (j?.ok) {
|
|
6537
|
+
dst.push(entry);
|
|
6538
|
+
shellToast(`${dir === 'undo' ? 'Undid' : 'Redid'}: ${entry.label}`, true);
|
|
6539
|
+
} else {
|
|
6540
|
+
// 409 canvas diverged / 404 server restarted — entry is dead, drop it.
|
|
6541
|
+
shellToast(`${dir === 'undo' ? 'Undo' : 'Redo'} skipped: ${j?.error || 'failed'}`);
|
|
6542
|
+
}
|
|
6543
|
+
})
|
|
6544
|
+
.catch(() => shellToast(`${dir === 'undo' ? 'Undo' : 'Redo'} failed: network error`));
|
|
6545
|
+
}, []);
|
|
6546
|
+
useEffect(() => {
|
|
6547
|
+
const onKey = (e) => {
|
|
6548
|
+
const s = tlKeyRef.current;
|
|
6549
|
+
if (!s.open || !s.comps?.length) return;
|
|
6550
|
+
const t = e.target;
|
|
6551
|
+
const tag = t?.tagName;
|
|
6552
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t?.isContentEditable) return;
|
|
6553
|
+
const fps = s.comps[0]?.fps || 30;
|
|
6554
|
+
const total = Math.max(1, s.total);
|
|
6555
|
+
const doSeek = (f) => {
|
|
6556
|
+
const nf = Math.max(0, Math.min(total - 1, Math.round(f)));
|
|
6557
|
+
setTimelineFrame(nf);
|
|
6558
|
+
setTimelinePlaying(false);
|
|
6559
|
+
s.post({ dgn: 'timeline-seek', frame: nf, id: s.compId });
|
|
6560
|
+
};
|
|
6561
|
+
const snapFrames = () => {
|
|
6562
|
+
const pts = new Set([0, total - 1]);
|
|
6563
|
+
for (const seq of s.sequences || []) {
|
|
6564
|
+
pts.add(seq.from);
|
|
6565
|
+
for (const kf of seq.keyframes || []) {
|
|
6566
|
+
pts.add(kf.from);
|
|
6567
|
+
pts.add(kf.to);
|
|
6568
|
+
}
|
|
6569
|
+
}
|
|
6570
|
+
return [...pts].filter((n) => n >= 0 && n < total).sort((a, b) => a - b);
|
|
6571
|
+
};
|
|
6572
|
+
if ((e.metaKey || e.ctrlKey) && (e.key === 'z' || e.key === 'Z')) {
|
|
6573
|
+
// Cmd+Z / Shift+Cmd+Z — undo/redo the last timeline clip op. Only when
|
|
6574
|
+
// the shell (not the canvas iframe) has focus + the timeline is active,
|
|
6575
|
+
// so the canvas's own annotation undo is untouched.
|
|
6576
|
+
e.preventDefault();
|
|
6577
|
+
tlUndoRedo(e.shiftKey ? 'redo' : 'undo');
|
|
6578
|
+
return;
|
|
6579
|
+
}
|
|
6580
|
+
if (e.key === ' ' || e.code === 'Space') {
|
|
6581
|
+
e.preventDefault();
|
|
6582
|
+
if (s.playing) {
|
|
6583
|
+
setTimelinePlaying(false);
|
|
6584
|
+
s.post({ dgn: 'timeline-pause', id: s.compId });
|
|
6585
|
+
} else {
|
|
6586
|
+
setTimelinePlaying(true);
|
|
6587
|
+
s.post({ dgn: 'timeline-mute', muted: s.muted, id: s.compId });
|
|
6588
|
+
s.post({ dgn: 'timeline-loop', loop: s.loop, id: s.compId });
|
|
6589
|
+
s.post({ dgn: 'timeline-play', id: s.compId });
|
|
6590
|
+
}
|
|
6591
|
+
} else if (e.key === 'ArrowRight') {
|
|
6592
|
+
e.preventDefault();
|
|
6593
|
+
doSeek(s.frame + (e.shiftKey ? fps : 1));
|
|
6594
|
+
} else if (e.key === 'ArrowLeft') {
|
|
6595
|
+
e.preventDefault();
|
|
6596
|
+
doSeek(s.frame - (e.shiftKey ? fps : 1));
|
|
6597
|
+
} else if (e.key === 'Home') {
|
|
6598
|
+
e.preventDefault();
|
|
6599
|
+
doSeek(0);
|
|
6600
|
+
} else if (e.key === 'End') {
|
|
6601
|
+
e.preventDefault();
|
|
6602
|
+
doSeek(total - 1);
|
|
6603
|
+
} else if (e.key === '.') {
|
|
6604
|
+
e.preventDefault();
|
|
6605
|
+
const next = snapFrames().find((n) => n > s.frame);
|
|
6606
|
+
if (next != null) doSeek(next);
|
|
6607
|
+
} else if (e.key === ',') {
|
|
6608
|
+
e.preventDefault();
|
|
6609
|
+
const prev = snapFrames()
|
|
6610
|
+
.reverse()
|
|
6611
|
+
.find((n) => n < s.frame);
|
|
6612
|
+
if (prev != null) doSeek(prev);
|
|
6613
|
+
}
|
|
6614
|
+
};
|
|
6615
|
+
window.addEventListener('keydown', onKey);
|
|
6616
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
6617
|
+
}, []);
|
|
6618
|
+
|
|
6619
|
+
// DDR-148 — reset the Timeline's comp meta when the active canvas changes (a
|
|
6620
|
+
// non-comp canvas fires no announce, so stale comps would linger), then ask
|
|
6621
|
+
// the (possibly already-mounted) active canvas to re-announce. video-comp.tsx
|
|
6622
|
+
// answers `timeline-request-comps` with a fresh `timeline-comps`.
|
|
6623
|
+
useEffect(() => {
|
|
6624
|
+
setActiveComps([]);
|
|
6625
|
+
setTimelineFrame(0);
|
|
6626
|
+
setTimelinePlaying(false);
|
|
6627
|
+
const t = setTimeout(() => postToActiveCanvas({ dgn: 'timeline-request-comps' }), 60);
|
|
6628
|
+
return () => clearTimeout(t);
|
|
6629
|
+
}, [activePath, postToActiveCanvas]);
|
|
6630
|
+
|
|
6631
|
+
// DDR-148 — fetch the active canvas's raw source when the Timeline is open.
|
|
6632
|
+
// Re-runs when a comp is (re)announced, so it refreshes after a canvas edit.
|
|
6633
|
+
const [timelineSource, setTimelineSource] = useState('');
|
|
6634
|
+
// DDR-150 dogfood — authoritative per-clip media from the enumerator (handles
|
|
6635
|
+
// wrapper components + array-fed src the regex parser can't see), merged into
|
|
6636
|
+
// the rows for the kind badge + replace addressing.
|
|
6637
|
+
const [timelineClipMedia, setTimelineClipMedia] = useState([]);
|
|
6638
|
+
useEffect(() => {
|
|
6639
|
+
if (!timelineOpen || activeComps.length === 0 || !activePath || activePath === SYSTEM_TAB) {
|
|
6640
|
+
setTimelineSource('');
|
|
6641
|
+
setTimelineClipMedia([]);
|
|
6642
|
+
return undefined;
|
|
6643
|
+
}
|
|
6644
|
+
let alive = true;
|
|
6645
|
+
fetch(`/_api/canvas-source?file=${encodeURIComponent(activePath)}`)
|
|
6646
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
6647
|
+
.then((j) => {
|
|
6648
|
+
if (alive && j?.ok && typeof j.source === 'string') setTimelineSource(j.source);
|
|
6649
|
+
})
|
|
6650
|
+
.catch(() => {});
|
|
6651
|
+
return () => {
|
|
6652
|
+
alive = false;
|
|
6653
|
+
};
|
|
6654
|
+
}, [timelineOpen, activeComps, activePath]);
|
|
6655
|
+
|
|
6656
|
+
// Authoritative per-clip media (comp-clips) — a SEPARATE effect keyed on the
|
|
6657
|
+
// RESOLVED artboard id, which the parser only knows AFTER timelineSource
|
|
6658
|
+
// arrives. Fetching it here (not in the source effect) means it re-runs once
|
|
6659
|
+
// the parser scopes to the right comp — so a multi-comp canvas (the showreel:
|
|
6660
|
+
// Movie + Reel) overlays the CORRECT comp's media, not the enumerator's own
|
|
6661
|
+
// fallback pick.
|
|
6662
|
+
useEffect(() => {
|
|
6663
|
+
if (!timelineOpen || !activePath || activePath === SYSTEM_TAB || !timelineSource) {
|
|
6664
|
+
setTimelineClipMedia([]);
|
|
6665
|
+
return undefined;
|
|
6666
|
+
}
|
|
6667
|
+
let alive = true;
|
|
6668
|
+
const artboardId = timelineArtboardId || undefined;
|
|
6669
|
+
const ccUrl = `/_api/comp-clips?canvas=${encodeURIComponent(activePath)}${artboardId ? `&artboardId=${encodeURIComponent(artboardId)}` : ''}`;
|
|
6670
|
+
fetch(ccUrl)
|
|
6671
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
6672
|
+
.then((cc) => {
|
|
6673
|
+
if (!alive) return;
|
|
6674
|
+
const seqs =
|
|
6675
|
+
cc?.ok && Array.isArray(cc.clips) ? cc.clips.filter((c) => c.kind === 'sequence') : [];
|
|
6676
|
+
setTimelineClipMedia(seqs);
|
|
6677
|
+
})
|
|
6678
|
+
.catch(() => {});
|
|
6679
|
+
return () => {
|
|
6680
|
+
alive = false;
|
|
6681
|
+
};
|
|
6682
|
+
}, [timelineOpen, activePath, timelineSource, timelineArtboardId]);
|
|
6683
|
+
|
|
6684
|
+
// Parse (cheap) — re-runs on source change AND on selection change, so the
|
|
6685
|
+
// Timeline REDRAWS to whichever artboard the user just selected/moved to.
|
|
6686
|
+
useEffect(() => {
|
|
6687
|
+
if (!timelineSource) {
|
|
6688
|
+
setTimelineSequences([]);
|
|
6689
|
+
setTimelineAudio([]);
|
|
6690
|
+
setTimelineTotal(0);
|
|
6691
|
+
return;
|
|
6692
|
+
}
|
|
6693
|
+
const total = activeComps[0]?.durationInFrames || 0;
|
|
6694
|
+
const artboard = canvasActiveArtboard ?? selected?.artboardId ?? null;
|
|
6695
|
+
const parsed = parseCompTimeline(timelineSource, total, artboard);
|
|
6696
|
+
setTimelineArtboardId(parsed.artboardId ?? artboard ?? null);
|
|
6697
|
+
// Overlay the enumerator's authoritative media (kind badge + replace target)
|
|
6698
|
+
// onto each row by sequence index — it sees wrapper-component + array-fed
|
|
6699
|
+
// media the row parser can't (the showreel <ClipShot clip={CLIPS[i]}/> case).
|
|
6700
|
+
const merged = parsed.sequences.map((s, i) => {
|
|
6701
|
+
const cm = timelineClipMedia[i];
|
|
6702
|
+
if (!cm) return s;
|
|
6703
|
+
return {
|
|
6704
|
+
...s,
|
|
6705
|
+
mediaTag: cm.mediaTag ?? s.mediaTag,
|
|
6706
|
+
mediaSrc: cm.mediaSrc ?? s.mediaSrc,
|
|
6707
|
+
// Replaceable when the enumerator found an addressable media target:
|
|
6708
|
+
// a literal-src element (mediaCdId) or an array-fed src (mediaArrayRef).
|
|
6709
|
+
replaceable: !!(cm.mediaCdId || cm.mediaArrayRef),
|
|
6710
|
+
// The clip's stacked layers (mp4 background + title/…) for expandable rows.
|
|
6711
|
+
layers: Array.isArray(cm.layers) ? cm.layers : [],
|
|
6712
|
+
hidden: !!cm.hidden,
|
|
6713
|
+
};
|
|
6714
|
+
});
|
|
6715
|
+
setTimelineSequences(merged);
|
|
6716
|
+
setTimelineAudio(parsed.audio || []);
|
|
6717
|
+
setTimelineTotal(parsed.total);
|
|
6718
|
+
}, [timelineSource, timelineClipMedia, selected, activeComps, canvasActiveArtboard]);
|
|
6719
|
+
|
|
6291
6720
|
const toggleAnnotations = useCallback(() => {
|
|
6292
6721
|
setAnnotationsVisible((v) => {
|
|
6293
6722
|
const next = !v;
|
|
@@ -6563,6 +6992,11 @@ function App() {
|
|
|
6563
6992
|
// the branch-scoped tree so other open tabs reflect it without a
|
|
6564
6993
|
// reload. Cross-machine peers get a new canvas via git "Get latest".
|
|
6565
6994
|
loadTree();
|
|
6995
|
+
} else if (m.type === 'config-updated') {
|
|
6996
|
+
// Server hot-reloaded .design/config.json (/design:setup-ds rewrote
|
|
6997
|
+
// it) — refetch /_config so designSystems / tokensCssRel / groups
|
|
6998
|
+
// match. The tree refresh arrives separately via canvas-list-update.
|
|
6999
|
+
loadServerConfig();
|
|
6566
7000
|
} else if (m.type === 'acp-focus') {
|
|
6567
7001
|
// Phase 31 (DDR-123) — `/design:chat` from the terminal asked us to
|
|
6568
7002
|
// surface the native ACP chat sidepanel. Native-only (the panel
|
|
@@ -6590,9 +7024,9 @@ function App() {
|
|
|
6590
7024
|
}
|
|
6591
7025
|
connect();
|
|
6592
7026
|
return () => wsRef.current && wsRef.current.close();
|
|
6593
|
-
// loadTree
|
|
6594
|
-
//
|
|
6595
|
-
}, [loadTree]);
|
|
7027
|
+
// loadTree + loadServerConfig are stable useCallback([])s; listed so the
|
|
7028
|
+
// canvas-list-update / config-updated handlers always call the live refs.
|
|
7029
|
+
}, [loadTree, loadServerConfig]);
|
|
6596
7030
|
|
|
6597
7031
|
function wsSend(obj) {
|
|
6598
7032
|
const ws = wsRef.current;
|
|
@@ -6878,6 +7312,139 @@ function App() {
|
|
|
6878
7312
|
[loadTree, openTab]
|
|
6879
7313
|
);
|
|
6880
7314
|
|
|
7315
|
+
// DDR-150 P4 Task 12 — one-click "udělej z toho video". Reads the ACTIVE
|
|
7316
|
+
// canvas's annotation sidecar (main-origin — no cross-origin round-trip),
|
|
7317
|
+
// gathers every dropped media-reference chip's src in document order, and POSTs
|
|
7318
|
+
// an assembled video-comp (kind: 'video-comp') → opens the new, immediately
|
|
7319
|
+
// hand-editable comp. Durations default server-side to fps*3 (the user trims on
|
|
7320
|
+
// the Timeline); client-side <video>.duration probing is a documented tightening.
|
|
7321
|
+
const assembleVideo = useCallback(async () => {
|
|
7322
|
+
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
7323
|
+
try {
|
|
7324
|
+
const ar = await fetch(`/_api/annotations?file=${encodeURIComponent(activePath)}`);
|
|
7325
|
+
const svg = ar.ok ? await ar.text() : '';
|
|
7326
|
+
const clips = [];
|
|
7327
|
+
if (svg) {
|
|
7328
|
+
const doc = new DOMParser().parseFromString(svg, 'image/svg+xml');
|
|
7329
|
+
for (const g of doc.querySelectorAll('[data-tool="mediaref"]')) {
|
|
7330
|
+
const src = g.getAttribute('data-src');
|
|
7331
|
+
if (!src) continue;
|
|
7332
|
+
clips.push({ src, mediaKind: g.getAttribute('data-media-kind') === 'audio' ? 'audio' : 'video' });
|
|
7333
|
+
}
|
|
7334
|
+
}
|
|
7335
|
+
if (clips.length === 0) {
|
|
7336
|
+
window.alert('Drop video/audio clips on the canvas first, then assemble them into a video.');
|
|
7337
|
+
return;
|
|
7338
|
+
}
|
|
7339
|
+
const baseName = displayName(basename(activePath)).replace(/\.tsx$/i, '');
|
|
7340
|
+
const name = `${baseName} Video`;
|
|
7341
|
+
const r = await fetch('/_api/canvas', {
|
|
7342
|
+
method: 'POST',
|
|
7343
|
+
headers: { 'Content-Type': 'application/json' },
|
|
7344
|
+
body: JSON.stringify({ name, kind: 'video-comp', clips }),
|
|
7345
|
+
});
|
|
7346
|
+
const j = await r.json().catch(() => ({}));
|
|
7347
|
+
if (!r.ok || !j.ok) {
|
|
7348
|
+
window.alert(`Assemble failed: ${j.error || `error ${r.status}`}`);
|
|
7349
|
+
return;
|
|
7350
|
+
}
|
|
7351
|
+
await loadTree();
|
|
7352
|
+
openTab(j.file);
|
|
7353
|
+
} catch (e) {
|
|
7354
|
+
window.alert(`Assemble failed: ${e instanceof Error ? e.message : 'network error'}`);
|
|
7355
|
+
}
|
|
7356
|
+
}, [activePath, loadTree, openTab]);
|
|
7357
|
+
|
|
7358
|
+
// DDR-150 dogfood #5 — shared replace-media flow, PICKER-FIRST. The <input
|
|
7359
|
+
// type=file> is created + clicked SYNCHRONOUSLY inside the caller's click
|
|
7360
|
+
// gesture (browsers revoke transient user-activation after any await, which is
|
|
7361
|
+
// why the old fetch-then-click never opened the dialog). The comp-clips lookup
|
|
7362
|
+
// + upload + src patch all happen in the change handler, where no activation
|
|
7363
|
+
// is needed. `resolveCdId(cc)` maps the fresh enumerator payload to the target
|
|
7364
|
+
// media target (sequence clip via cd-id, audio bed via cd-id, or a showreel
|
|
7365
|
+
// array-fed src via mediaArrayRef → /_api/edit-array-src). `resolveTarget(cc)`
|
|
7366
|
+
// returns { cdId } | { arrayRef } | null.
|
|
7367
|
+
const replaceMediaViaPicker = useCallback(
|
|
7368
|
+
({ accept, resolveTarget }) => {
|
|
7369
|
+
const canvas = activePath;
|
|
7370
|
+
const artboardId = timelineArtboardId || undefined;
|
|
7371
|
+
const input = document.createElement('input');
|
|
7372
|
+
input.type = 'file';
|
|
7373
|
+
input.accept = accept;
|
|
7374
|
+
// OFF-SCREEN, not display:none — the native desktop app (Tauri WKWebView)
|
|
7375
|
+
// will NOT present the file panel for a `display:none` input (it must be
|
|
7376
|
+
// laid out). Off-screen + transparent works in both WKWebView and browsers
|
|
7377
|
+
// (dogfood: "replace ... nefunguje ale v desktop app"). Guard against a
|
|
7378
|
+
// cancelled dialog leaking the node with a one-shot focus cleanup.
|
|
7379
|
+
input.style.cssText =
|
|
7380
|
+
'position:fixed;left:-9999px;top:0;width:1px;height:1px;opacity:0;pointer-events:none';
|
|
7381
|
+
document.body.appendChild(input);
|
|
7382
|
+
const cleanup = () => {
|
|
7383
|
+
if (input.isConnected) input.remove();
|
|
7384
|
+
};
|
|
7385
|
+
// If the user cancels the panel, `change` never fires — reclaim the node
|
|
7386
|
+
// when focus returns to the window (fires on both pick and cancel).
|
|
7387
|
+
window.addEventListener('focus', () => setTimeout(cleanup, 300), { once: true });
|
|
7388
|
+
input.addEventListener('change', () => {
|
|
7389
|
+
const file = input.files?.[0];
|
|
7390
|
+
cleanup();
|
|
7391
|
+
if (!file) return;
|
|
7392
|
+
const ccUrl = `/_api/comp-clips?canvas=${encodeURIComponent(canvas)}${artboardId ? `&artboardId=${encodeURIComponent(artboardId)}` : ''}`;
|
|
7393
|
+
fetch(ccUrl)
|
|
7394
|
+
.then((r) => r.json().catch(() => ({})))
|
|
7395
|
+
.then((cc) => {
|
|
7396
|
+
const target = resolveTarget(cc);
|
|
7397
|
+
if (!target) {
|
|
7398
|
+
shellToast('No replaceable media here (its src is computed) — edit via chat.');
|
|
7399
|
+
return null;
|
|
7400
|
+
}
|
|
7401
|
+
return fetch('/_api/asset', {
|
|
7402
|
+
method: 'POST',
|
|
7403
|
+
headers: { 'Content-Type': file.type || 'application/octet-stream' },
|
|
7404
|
+
body: file,
|
|
7405
|
+
})
|
|
7406
|
+
.then((r) => r.json().catch(() => ({})))
|
|
7407
|
+
.then((up) => {
|
|
7408
|
+
if (!up?.path) {
|
|
7409
|
+
shellToast(`Upload failed: ${up?.error || 'unknown error'}`);
|
|
7410
|
+
return null;
|
|
7411
|
+
}
|
|
7412
|
+
if (target.arrayRef) {
|
|
7413
|
+
// Showreel pattern: the src lives in an array literal.
|
|
7414
|
+
return fetch('/_api/edit-array-src', {
|
|
7415
|
+
method: 'POST',
|
|
7416
|
+
headers: { 'content-type': 'application/json' },
|
|
7417
|
+
body: JSON.stringify({
|
|
7418
|
+
canvas,
|
|
7419
|
+
arrayName: target.arrayRef.arrayName,
|
|
7420
|
+
index: target.arrayRef.index,
|
|
7421
|
+
field: target.arrayRef.field,
|
|
7422
|
+
value: up.path,
|
|
7423
|
+
}),
|
|
7424
|
+
});
|
|
7425
|
+
}
|
|
7426
|
+
return fetch('/_api/edit-attr', {
|
|
7427
|
+
method: 'POST',
|
|
7428
|
+
headers: { 'content-type': 'application/json' },
|
|
7429
|
+
body: JSON.stringify({ canvas, id: target.cdId, attr: 'src', value: up.path }),
|
|
7430
|
+
});
|
|
7431
|
+
});
|
|
7432
|
+
})
|
|
7433
|
+
.then((r) => (r ? r.json() : null))
|
|
7434
|
+
.then((j) => {
|
|
7435
|
+
if (j && !j.ok) shellToast(`Replace refused: ${j.error || 'failed'}`);
|
|
7436
|
+
else if (j && j.ok) {
|
|
7437
|
+
shellToast('Media replaced.', true);
|
|
7438
|
+
if (j.seq != null) pushTlUndo(canvas, j.seq, 'replace media');
|
|
7439
|
+
}
|
|
7440
|
+
})
|
|
7441
|
+
.catch(() => shellToast('Replace failed: network error'));
|
|
7442
|
+
});
|
|
7443
|
+
input.click();
|
|
7444
|
+
},
|
|
7445
|
+
[activePath, timelineArtboardId, pushTlUndo]
|
|
7446
|
+
);
|
|
7447
|
+
|
|
6881
7448
|
// Phase 22 — soft-delete a canvas from the file tree. Confirms (destructive),
|
|
6882
7449
|
// DELETEs to the main-origin-only endpoint, refreshes the tree, and resets the
|
|
6883
7450
|
// active tab if the deleted canvas was open. The server moves the whole sidecar
|
|
@@ -7020,7 +7587,18 @@ function App() {
|
|
|
7020
7587
|
// Phase 12 (DDR-103) — inline text edit committed in the canvas. POST to
|
|
7021
7588
|
// the main-origin-only /_api/edit-text → editText writes the escaped
|
|
7022
7589
|
// JSXText to source; the file-watcher HMR reload then shows the new text.
|
|
7023
|
-
//
|
|
7590
|
+
// DDR-150 P1: a refusal is NO LONGER silent — post `edit-reverted` back
|
|
7591
|
+
// to the canvas, which reverts the optimistic contenteditable text AND
|
|
7592
|
+
// toasts the reason, so a failed edit can't silently vanish on the next
|
|
7593
|
+
// reload ("my text edit didn't stick"). The user-facing toast lives
|
|
7594
|
+
// canvas-side (showCanvasToast) because App has no in-scope status
|
|
7595
|
+
// surface — `setStatus` belongs to ExportDialog, not App.
|
|
7596
|
+
const editSource = e.source;
|
|
7597
|
+
const revert = (reason) => {
|
|
7598
|
+
try {
|
|
7599
|
+
editSource?.postMessage({ dgn: 'edit-reverted', op: 'text', id: m.id, reason }, '*');
|
|
7600
|
+
} catch {}
|
|
7601
|
+
};
|
|
7024
7602
|
fetch('/_api/edit-text', {
|
|
7025
7603
|
method: 'POST',
|
|
7026
7604
|
headers: { 'content-type': 'application/json' },
|
|
@@ -7028,9 +7606,9 @@ function App() {
|
|
|
7028
7606
|
})
|
|
7029
7607
|
.then((r) => r.json().catch(() => ({})))
|
|
7030
7608
|
.then((j) => {
|
|
7031
|
-
if (!j.ok)
|
|
7609
|
+
if (!j.ok) revert(j.error || "this element can't be edited inline");
|
|
7032
7610
|
})
|
|
7033
|
-
.catch(() =>
|
|
7611
|
+
.catch(() => revert('network error'));
|
|
7034
7612
|
} else if (m.dgn === 'apply-edit' && m.id && (m.op === 'css' || m.op === 'text' || m.op === 'attr')) {
|
|
7035
7613
|
// Inline-edit undo/redo (DDR-103/104 follow-up). The canvas iframe's
|
|
7036
7614
|
// `edit-source` command can't call the main-origin-only `/_api/edit-*`
|
|
@@ -7068,6 +7646,10 @@ function App() {
|
|
|
7068
7646
|
})
|
|
7069
7647
|
.then((r) => r.json().catch(() => ({})))
|
|
7070
7648
|
.then((j) => {
|
|
7649
|
+
// Undo/redo lane (Cmd+Z/Cmd+Shift+Z re-application). The primary
|
|
7650
|
+
// edit-text lane above owns the user-facing revert + toast; a
|
|
7651
|
+
// refusal here is logged. (DDR-150 P1 kept this a warn after the
|
|
7652
|
+
// setStatus-scope fix — setStatus is not in App's scope.)
|
|
7071
7653
|
if (!j.ok) console.warn('[apply-edit]', op, j.error || 'failed');
|
|
7072
7654
|
})
|
|
7073
7655
|
.catch(() => {})
|
|
@@ -7221,6 +7803,49 @@ function App() {
|
|
|
7221
7803
|
// meta.json-derived seed when the canvas knows better. Clamp.
|
|
7222
7804
|
const n = Math.round(m.count);
|
|
7223
7805
|
if (Number.isFinite(n) && n >= 0 && n <= 999) setActiveArtboards(n);
|
|
7806
|
+
} else if (m.dgn === 'active-artboard') {
|
|
7807
|
+
// DDR-148 — canvas-lib reports the viewport-active artboard on pan. Gate
|
|
7808
|
+
// to the ACTIVE canvas window (a background canvas must not hijack the
|
|
7809
|
+
// Timeline's target). The Timeline re-parses to this artboard's comp.
|
|
7810
|
+
const activeWin =
|
|
7811
|
+
activePath && activePath !== SYSTEM_TAB
|
|
7812
|
+
? iframesRef.current.get(activePath)?.contentWindow
|
|
7813
|
+
: null;
|
|
7814
|
+
if (e.source !== activeWin) return;
|
|
7815
|
+
// Cap the id like the sibling timeline-comps handler (defensive; inert).
|
|
7816
|
+
setCanvasActiveArtboard(typeof m.id === 'string' ? m.id.slice(0, 120) : null);
|
|
7817
|
+
} else if (m.dgn === 'timeline-comps' && Array.isArray(m.comps)) {
|
|
7818
|
+
// DDR-148 — a video-comp announces its comp meta (from video-comp.tsx).
|
|
7819
|
+
// Gate to the ACTIVE canvas window (phase-28 F-2 pattern): a background
|
|
7820
|
+
// canvas must not plant comps into the Timeline panel, which shows the
|
|
7821
|
+
// active canvas's comps. Inert display, but keep the seam closed.
|
|
7822
|
+
const activeWin =
|
|
7823
|
+
activePath && activePath !== SYSTEM_TAB
|
|
7824
|
+
? iframesRef.current.get(activePath)?.contentWindow
|
|
7825
|
+
: null;
|
|
7826
|
+
if (e.source !== activeWin) return;
|
|
7827
|
+
const safe = m.comps
|
|
7828
|
+
.filter((c) => c && typeof c.id === 'string')
|
|
7829
|
+
.slice(0, 32)
|
|
7830
|
+
.map((c) => ({
|
|
7831
|
+
id: String(c.id).slice(0, 120),
|
|
7832
|
+
fps: Math.max(1, Math.min(120, Math.round(Number(c.fps) || 30))),
|
|
7833
|
+
durationInFrames: Math.max(1, Math.min(1_000_000, Math.round(Number(c.durationInFrames) || 1))),
|
|
7834
|
+
width: Math.max(1, Math.round(Number(c.width) || 0)),
|
|
7835
|
+
height: Math.max(1, Math.round(Number(c.height) || 0)),
|
|
7836
|
+
}));
|
|
7837
|
+
setActiveComps(safe);
|
|
7838
|
+
} else if (m.dgn === 'timeline-frame' && typeof m.frame === 'number') {
|
|
7839
|
+
// Live playhead mirror from the Player (preview scrub/playback).
|
|
7840
|
+
const activeWin =
|
|
7841
|
+
activePath && activePath !== SYSTEM_TAB
|
|
7842
|
+
? iframesRef.current.get(activePath)?.contentWindow
|
|
7843
|
+
: null;
|
|
7844
|
+
if (e.source !== activeWin) return;
|
|
7845
|
+
// Only the Timeline's target comp drives the playhead — a sibling
|
|
7846
|
+
// artboard's Player must not fight it for the readout (multi-comp canvas).
|
|
7847
|
+
if (m.id && timelineCompIdRef.current && m.id !== timelineCompIdRef.current) return;
|
|
7848
|
+
if (Number.isFinite(m.frame)) setTimelineFrame(Math.max(0, Math.round(m.frame)));
|
|
7224
7849
|
} else if (m.dgn === 'toggle-palette') {
|
|
7225
7850
|
// ⌘K pressed while focus was inside the canvas iframe — the injected
|
|
7226
7851
|
// inspector forwards the chord here since the iframe's keydown never
|
|
@@ -7357,6 +7982,15 @@ function App() {
|
|
|
7357
7982
|
const fn = /filename="([^"]+)"/.exec(disp);
|
|
7358
7983
|
const filename = (fn && fn[1]) || 'export';
|
|
7359
7984
|
const blob = await r.blob();
|
|
7985
|
+
if (isNativeApp()) {
|
|
7986
|
+
// Native: route the bridged in-canvas export through the OS save dialog
|
|
7987
|
+
// too (WKWebView swallows the `<a download>` blob). Mirror the shell
|
|
7988
|
+
// ExportDialog's doExport native branch.
|
|
7989
|
+
const bytes = Array.from(new Uint8Array(await blob.arrayBuffer()));
|
|
7990
|
+
const savedPath = await saveExport(filename, bytes);
|
|
7991
|
+
reply(savedPath ? { ok: true, filename } : { ok: false, error: 'Save cancelled' });
|
|
7992
|
+
return;
|
|
7993
|
+
}
|
|
7360
7994
|
const url = URL.createObjectURL(blob);
|
|
7361
7995
|
const a = document.createElement('a');
|
|
7362
7996
|
a.href = url;
|
|
@@ -7625,6 +8259,18 @@ function App() {
|
|
|
7625
8259
|
// makes inspector CSS / inline text / attr edits undoable from anywhere.
|
|
7626
8260
|
if (meta && !e.altKey && (e.key === 'z' || e.key === 'Z' || e.key === 'y' || e.key === 'Y')) {
|
|
7627
8261
|
if (!inEditable && !inCanvasIframe && activePath && activePath !== SYSTEM_TAB) {
|
|
8262
|
+
// DDR-150 dogfood #1 (team finding) — SINGLE OWNER per keypress. With
|
|
8263
|
+
// the Timeline open on a video-comp canvas, the timeline undo stack
|
|
8264
|
+
// owns Cmd+Z/Shift+Cmd+Z (the timeline keydown effect performs it).
|
|
8265
|
+
// Without this skip, BOTH handlers fired on one keypress — undoing a
|
|
8266
|
+
// clip op AND popping the canvas's annotation undo simultaneously.
|
|
8267
|
+
if (
|
|
8268
|
+
(e.key === 'z' || e.key === 'Z') &&
|
|
8269
|
+
tlKeyRef.current.open &&
|
|
8270
|
+
tlKeyRef.current.comps?.length
|
|
8271
|
+
) {
|
|
8272
|
+
return; // the timeline shortcuts effect claims it
|
|
8273
|
+
}
|
|
7628
8274
|
e.preventDefault();
|
|
7629
8275
|
const redo = e.key === 'y' || e.key === 'Y' || e.shiftKey;
|
|
7630
8276
|
postToActiveCanvas({ dgn: redo ? 'redo' : 'undo' });
|
|
@@ -8007,6 +8653,9 @@ function App() {
|
|
|
8007
8653
|
inspectorOpen={inspectorOpen}
|
|
8008
8654
|
inspectorTab={inspectorTab}
|
|
8009
8655
|
onToggleInspector={() => toggleRightPanel('inspector')}
|
|
8656
|
+
timelineOpen={timelineOpen}
|
|
8657
|
+
onToggleTimeline={toggleTimeline}
|
|
8658
|
+
hasComps={activeComps.length > 0}
|
|
8010
8659
|
assistantOpen={assistantOpen}
|
|
8011
8660
|
onToggleAssistant={() => toggleRightPanel('assistant')}
|
|
8012
8661
|
assistantBusy={assistantBusy}
|
|
@@ -8028,6 +8677,7 @@ function App() {
|
|
|
8028
8677
|
60
|
|
8029
8678
|
);
|
|
8030
8679
|
}}
|
|
8680
|
+
onAssembleVideo={assembleVideo}
|
|
8031
8681
|
onOpenExport={(mode) => setExportDialog({ mode })}
|
|
8032
8682
|
onReload={reloadActive}
|
|
8033
8683
|
onCloseCanvas={() => activePath && closeTab(activePath)}
|
|
@@ -8219,6 +8869,347 @@ function App() {
|
|
|
8219
8869
|
/>
|
|
8220
8870
|
)}
|
|
8221
8871
|
</div>
|
|
8872
|
+
{/* DDR-148 — Timeline is a BOTTOM dock (full-width strip below the stage,
|
|
8873
|
+
above the status bar) — video timelines are horizontal. */}
|
|
8874
|
+
{timelineOpen && (
|
|
8875
|
+
<TimelinePanel
|
|
8876
|
+
comps={activeComps}
|
|
8877
|
+
sequences={timelineSequences}
|
|
8878
|
+
audio={timelineAudio}
|
|
8879
|
+
total={timelineTotal}
|
|
8880
|
+
frame={timelineFrame}
|
|
8881
|
+
playing={timelinePlaying}
|
|
8882
|
+
loop={timelineLoop}
|
|
8883
|
+
onSeek={(f) => {
|
|
8884
|
+
setTimelineFrame(f);
|
|
8885
|
+
setTimelinePlaying(false);
|
|
8886
|
+
postToActiveCanvas({ dgn: 'timeline-seek', frame: f, id: timelineCompId });
|
|
8887
|
+
}}
|
|
8888
|
+
onPlay={() => {
|
|
8889
|
+
setTimelinePlaying(true);
|
|
8890
|
+
// Sync mute + loop to the Player, then play (the artboard has no
|
|
8891
|
+
// chrome — the Timeline owns transport/sound/loop now).
|
|
8892
|
+
postToActiveCanvas({ dgn: 'timeline-mute', muted: timelineMuted, id: timelineCompId });
|
|
8893
|
+
postToActiveCanvas({ dgn: 'timeline-loop', loop: timelineLoop, id: timelineCompId });
|
|
8894
|
+
postToActiveCanvas({ dgn: 'timeline-play', id: timelineCompId });
|
|
8895
|
+
}}
|
|
8896
|
+
onPause={() => {
|
|
8897
|
+
setTimelinePlaying(false);
|
|
8898
|
+
postToActiveCanvas({ dgn: 'timeline-pause', id: timelineCompId });
|
|
8899
|
+
}}
|
|
8900
|
+
onToggleLoop={() =>
|
|
8901
|
+
setTimelineLoop((v) => {
|
|
8902
|
+
const next = !v;
|
|
8903
|
+
postToActiveCanvas({ dgn: 'timeline-loop', loop: next, id: timelineCompId });
|
|
8904
|
+
return next;
|
|
8905
|
+
})
|
|
8906
|
+
}
|
|
8907
|
+
muted={timelineMuted}
|
|
8908
|
+
onToggleMute={() => {
|
|
8909
|
+
setTimelineMuted((v) => {
|
|
8910
|
+
const next = !v;
|
|
8911
|
+
postToActiveCanvas({ dgn: 'timeline-mute', muted: next, id: timelineCompId });
|
|
8912
|
+
return next;
|
|
8913
|
+
});
|
|
8914
|
+
}}
|
|
8915
|
+
volume={timelineVolume}
|
|
8916
|
+
onVolume={(v) => {
|
|
8917
|
+
setTimelineVolume(v);
|
|
8918
|
+
// Dragging volume implies "I want to hear it" — unmute.
|
|
8919
|
+
if (v > 0 && timelineMuted) {
|
|
8920
|
+
setTimelineMuted(false);
|
|
8921
|
+
postToActiveCanvas({ dgn: 'timeline-mute', muted: false, id: timelineCompId });
|
|
8922
|
+
}
|
|
8923
|
+
postToActiveCanvas({ dgn: 'timeline-volume', volume: v, id: timelineCompId });
|
|
8924
|
+
}}
|
|
8925
|
+
onRetime={(index, patch) => {
|
|
8926
|
+
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
8927
|
+
// DDR-150 P2 — address the clip by its comp-scoped stableId (from
|
|
8928
|
+
// /_api/comp-clips), NOT the raw row index, so a retime on a
|
|
8929
|
+
// multi-comp canvas can never mis-hit a clip in another comp. The
|
|
8930
|
+
// row index counts sequence rows only, so map it to the index-th
|
|
8931
|
+
// sequence clip (transitions filtered). Fall back to the legacy
|
|
8932
|
+
// index if the enumerator is unavailable.
|
|
8933
|
+
const artboardId = timelineArtboardId || undefined;
|
|
8934
|
+
const ccUrl = `/_api/comp-clips?canvas=${encodeURIComponent(activePath)}${artboardId ? `&artboardId=${encodeURIComponent(artboardId)}` : ''}`;
|
|
8935
|
+
fetch(ccUrl)
|
|
8936
|
+
.then((r) => r.json().catch(() => ({})))
|
|
8937
|
+
.then((cc) => {
|
|
8938
|
+
const seqs =
|
|
8939
|
+
cc?.ok && Array.isArray(cc.clips)
|
|
8940
|
+
? cc.clips.filter((c) => c.kind === 'sequence')
|
|
8941
|
+
: [];
|
|
8942
|
+
const clip = seqs[index] || null;
|
|
8943
|
+
const body = clip?.stableId
|
|
8944
|
+
? {
|
|
8945
|
+
canvas: activePath,
|
|
8946
|
+
artboardId,
|
|
8947
|
+
stableId: clip.stableId,
|
|
8948
|
+
contentHash: clip.contentHash,
|
|
8949
|
+
...patch,
|
|
8950
|
+
}
|
|
8951
|
+
: { canvas: activePath, index, ...patch };
|
|
8952
|
+
return fetch('/_api/retime-sequence', {
|
|
8953
|
+
method: 'POST',
|
|
8954
|
+
headers: { 'content-type': 'application/json' },
|
|
8955
|
+
body: JSON.stringify(body),
|
|
8956
|
+
});
|
|
8957
|
+
})
|
|
8958
|
+
.then((r) => r.json())
|
|
8959
|
+
.then((j) => {
|
|
8960
|
+
if (!j?.ok) {
|
|
8961
|
+
console.warn('[retime]', j?.error || 'failed');
|
|
8962
|
+
shellToast(`Retime refused: ${j?.error || 'failed'}`);
|
|
8963
|
+
} else if (j?.seq != null) {
|
|
8964
|
+
pushTlUndo(activePath, j.seq, patch.from != null ? 'move clip' : 'trim clip');
|
|
8965
|
+
}
|
|
8966
|
+
// The file watcher reloads the canvas → re-announce → the
|
|
8967
|
+
// source-fetch effect re-parses the new timing.
|
|
8968
|
+
})
|
|
8969
|
+
.catch(() => {});
|
|
8970
|
+
}}
|
|
8971
|
+
onRemove={(index) => {
|
|
8972
|
+
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
8973
|
+
// DDR-150 P3 — remove the clip addressed by its comp-scoped stableId
|
|
8974
|
+
// (the sequence-row index → the index-th sequence clip). The engine
|
|
8975
|
+
// fingerprint + semantic gate refuse a stale/raced or series-breaking
|
|
8976
|
+
// removal; the file watcher reloads the canvas after.
|
|
8977
|
+
const artboardId = timelineArtboardId || undefined;
|
|
8978
|
+
const ccUrl = `/_api/comp-clips?canvas=${encodeURIComponent(activePath)}${artboardId ? `&artboardId=${encodeURIComponent(artboardId)}` : ''}`;
|
|
8979
|
+
fetch(ccUrl)
|
|
8980
|
+
.then((r) => r.json().catch(() => ({})))
|
|
8981
|
+
.then((cc) => {
|
|
8982
|
+
const seqs =
|
|
8983
|
+
cc?.ok && Array.isArray(cc.clips)
|
|
8984
|
+
? cc.clips.filter((c) => c.kind === 'sequence')
|
|
8985
|
+
: [];
|
|
8986
|
+
const clip = seqs[index] || null;
|
|
8987
|
+
if (!clip?.stableId) return null;
|
|
8988
|
+
return fetch('/_api/remove-sequence', {
|
|
8989
|
+
method: 'POST',
|
|
8990
|
+
headers: { 'content-type': 'application/json' },
|
|
8991
|
+
body: JSON.stringify({
|
|
8992
|
+
canvas: activePath,
|
|
8993
|
+
artboardId,
|
|
8994
|
+
stableId: clip.stableId,
|
|
8995
|
+
contentHash: clip.contentHash,
|
|
8996
|
+
}),
|
|
8997
|
+
});
|
|
8998
|
+
})
|
|
8999
|
+
.then((r) => (r ? r.json() : null))
|
|
9000
|
+
.then((j) => {
|
|
9001
|
+
if (j && !j.ok) {
|
|
9002
|
+
console.warn('[remove-clip]', j.error || 'failed');
|
|
9003
|
+
shellToast(`Remove refused: ${j.error || 'failed'}`);
|
|
9004
|
+
} else if (j?.seq != null) {
|
|
9005
|
+
pushTlUndo(activePath, j.seq, 'remove clip');
|
|
9006
|
+
}
|
|
9007
|
+
})
|
|
9008
|
+
.catch(() => {});
|
|
9009
|
+
}}
|
|
9010
|
+
onReplace={(index) => {
|
|
9011
|
+
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
9012
|
+
// DDR-150 P3 + dogfood #5 — replace a clip's media. The file picker
|
|
9013
|
+
// MUST open synchronously inside the click gesture: browsers revoke
|
|
9014
|
+
// the transient user-activation after an await/fetch round-trip, so
|
|
9015
|
+
// the old fetch-then-click() silently no-oped ("replace neotevře
|
|
9016
|
+
// žádné okno"). Picker first; resolve the target + upload in the
|
|
9017
|
+
// change handler.
|
|
9018
|
+
replaceMediaViaPicker({
|
|
9019
|
+
accept: 'video/*,image/*',
|
|
9020
|
+
resolveTarget: (cc) => {
|
|
9021
|
+
const seqs =
|
|
9022
|
+
cc?.ok && Array.isArray(cc.clips)
|
|
9023
|
+
? cc.clips.filter((c) => c.kind === 'sequence')
|
|
9024
|
+
: [];
|
|
9025
|
+
const clip = seqs[index];
|
|
9026
|
+
if (clip?.mediaArrayRef) return { arrayRef: clip.mediaArrayRef };
|
|
9027
|
+
if (clip?.mediaCdId) return { cdId: clip.mediaCdId };
|
|
9028
|
+
return null;
|
|
9029
|
+
},
|
|
9030
|
+
});
|
|
9031
|
+
}}
|
|
9032
|
+
onReplaceAudio={(index) => {
|
|
9033
|
+
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
9034
|
+
// DDR-150 dogfood #5 — audio beds are addressable too: the
|
|
9035
|
+
// enumerator lists loose media (an <Audio> under the reel) with a
|
|
9036
|
+
// cd-id; ⇄ on the audio row swaps its src.
|
|
9037
|
+
replaceMediaViaPicker({
|
|
9038
|
+
accept: 'audio/*',
|
|
9039
|
+
resolveTarget: (cc) => {
|
|
9040
|
+
const beds =
|
|
9041
|
+
cc?.ok && Array.isArray(cc.media)
|
|
9042
|
+
? cc.media.filter((m) => m.tag === 'Audio')
|
|
9043
|
+
: [];
|
|
9044
|
+
return beds[index]?.cdId ? { cdId: beds[index].cdId } : null;
|
|
9045
|
+
},
|
|
9046
|
+
});
|
|
9047
|
+
}}
|
|
9048
|
+
onReplaceLayer={(clipIndex, layerIndex) => {
|
|
9049
|
+
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
9050
|
+
// DDR-150 dogfood — replace a SPECIFIC layer inside an expanded clip
|
|
9051
|
+
// (the mp4 background separately from the title layer). Targets the
|
|
9052
|
+
// layer's own media (array-fed or literal-src) from the enumerator.
|
|
9053
|
+
const kind =
|
|
9054
|
+
timelineSequences[clipIndex]?.layers?.[layerIndex]?.kind === 'audio'
|
|
9055
|
+
? 'audio/*'
|
|
9056
|
+
: timelineSequences[clipIndex]?.layers?.[layerIndex]?.kind === 'image'
|
|
9057
|
+
? 'image/*'
|
|
9058
|
+
: 'video/*';
|
|
9059
|
+
replaceMediaViaPicker({
|
|
9060
|
+
accept: kind,
|
|
9061
|
+
resolveTarget: (cc) => {
|
|
9062
|
+
const seqs =
|
|
9063
|
+
cc?.ok && Array.isArray(cc.clips)
|
|
9064
|
+
? cc.clips.filter((c) => c.kind === 'sequence')
|
|
9065
|
+
: [];
|
|
9066
|
+
const ly = seqs[clipIndex]?.layers?.[layerIndex];
|
|
9067
|
+
if (ly?.mediaArrayRef) return { arrayRef: ly.mediaArrayRef };
|
|
9068
|
+
if (ly?.mediaCdId) return { cdId: ly.mediaCdId };
|
|
9069
|
+
return null;
|
|
9070
|
+
},
|
|
9071
|
+
});
|
|
9072
|
+
}}
|
|
9073
|
+
onReorder={(index, direction) => {
|
|
9074
|
+
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
9075
|
+
// DDR-150 P5 — z-order reorder: move a standalone <Sequence> before/
|
|
9076
|
+
// after a sibling (render stacking; later sibling paints on top).
|
|
9077
|
+
// ▲ forward = move AFTER the next sibling; ▼ backward = move BEFORE
|
|
9078
|
+
// the previous sibling. Both clips addressed by comp-scoped stableId +
|
|
9079
|
+
// fingerprint (via /_api/comp-clips); the engine refuses a TransitionSeries
|
|
9080
|
+
// clip + a stale/raced target, then reloads via the file watcher.
|
|
9081
|
+
const artboardId = timelineArtboardId || undefined;
|
|
9082
|
+
const ccUrl = `/_api/comp-clips?canvas=${encodeURIComponent(activePath)}${artboardId ? `&artboardId=${encodeURIComponent(artboardId)}` : ''}`;
|
|
9083
|
+
fetch(ccUrl)
|
|
9084
|
+
.then((r) => r.json().catch(() => ({})))
|
|
9085
|
+
.then((cc) => {
|
|
9086
|
+
const seqs =
|
|
9087
|
+
cc?.ok && Array.isArray(cc.clips)
|
|
9088
|
+
? cc.clips.filter((c) => c.kind === 'sequence')
|
|
9089
|
+
: [];
|
|
9090
|
+
const moved = seqs[index] || null;
|
|
9091
|
+
const refIdx = direction === 'forward' ? index + 1 : index - 1;
|
|
9092
|
+
const ref = seqs[refIdx] || null;
|
|
9093
|
+
const position = direction === 'forward' ? 'after' : 'before';
|
|
9094
|
+
if (!moved?.stableId || !ref?.stableId) return null;
|
|
9095
|
+
return fetch('/_api/reorder-sequence', {
|
|
9096
|
+
method: 'POST',
|
|
9097
|
+
headers: { 'content-type': 'application/json' },
|
|
9098
|
+
body: JSON.stringify({
|
|
9099
|
+
canvas: activePath,
|
|
9100
|
+
artboardId,
|
|
9101
|
+
stableId: moved.stableId,
|
|
9102
|
+
contentHash: moved.contentHash,
|
|
9103
|
+
refStableId: ref.stableId,
|
|
9104
|
+
refContentHash: ref.contentHash,
|
|
9105
|
+
position,
|
|
9106
|
+
}),
|
|
9107
|
+
});
|
|
9108
|
+
})
|
|
9109
|
+
.then((r) => (r ? r.json() : null))
|
|
9110
|
+
.then((j) => {
|
|
9111
|
+
if (j && !j.ok) {
|
|
9112
|
+
console.warn('[reorder-clip]', j.error || 'failed');
|
|
9113
|
+
shellToast(`Reorder refused: ${j.error || 'failed'}`);
|
|
9114
|
+
} else if (j?.seq != null) {
|
|
9115
|
+
pushTlUndo(activePath, j.seq, 'reorder clip');
|
|
9116
|
+
}
|
|
9117
|
+
})
|
|
9118
|
+
.catch(() => {});
|
|
9119
|
+
}}
|
|
9120
|
+
onToggleHide={(index) => {
|
|
9121
|
+
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
9122
|
+
// DDR-150 dogfood — hide/show a clip (gates its body behind
|
|
9123
|
+
// {false && …}; the tag + time slot stay). Addressed by comp-scoped
|
|
9124
|
+
// stableId + fingerprint.
|
|
9125
|
+
const artboardId = timelineArtboardId || undefined;
|
|
9126
|
+
const ccUrl = `/_api/comp-clips?canvas=${encodeURIComponent(activePath)}${artboardId ? `&artboardId=${encodeURIComponent(artboardId)}` : ''}`;
|
|
9127
|
+
fetch(ccUrl)
|
|
9128
|
+
.then((r) => r.json().catch(() => ({})))
|
|
9129
|
+
.then((cc) => {
|
|
9130
|
+
const seqs =
|
|
9131
|
+
cc?.ok && Array.isArray(cc.clips)
|
|
9132
|
+
? cc.clips.filter((c) => c.kind === 'sequence')
|
|
9133
|
+
: [];
|
|
9134
|
+
const clip = seqs[index] || null;
|
|
9135
|
+
if (!clip?.stableId) return null;
|
|
9136
|
+
return fetch('/_api/toggle-hide', {
|
|
9137
|
+
method: 'POST',
|
|
9138
|
+
headers: { 'content-type': 'application/json' },
|
|
9139
|
+
body: JSON.stringify({
|
|
9140
|
+
canvas: activePath,
|
|
9141
|
+
artboardId,
|
|
9142
|
+
stableId: clip.stableId,
|
|
9143
|
+
contentHash: clip.contentHash,
|
|
9144
|
+
}),
|
|
9145
|
+
});
|
|
9146
|
+
})
|
|
9147
|
+
.then((r) => (r ? r.json() : null))
|
|
9148
|
+
.then((j) => {
|
|
9149
|
+
if (j && !j.ok) shellToast(`Hide refused: ${j.error || 'failed'}`);
|
|
9150
|
+
else if (j && j.ok) {
|
|
9151
|
+
shellToast(j.hidden ? 'Clip hidden.' : 'Clip shown.', true);
|
|
9152
|
+
if (j.seq != null) pushTlUndo(activePath, j.seq, j.hidden ? 'hide clip' : 'show clip');
|
|
9153
|
+
}
|
|
9154
|
+
})
|
|
9155
|
+
.catch(() => shellToast('Hide failed: network error'));
|
|
9156
|
+
}}
|
|
9157
|
+
onDropMedia={(file) => {
|
|
9158
|
+
if (!activePath || activePath === SYSTEM_TAB) return;
|
|
9159
|
+
// DDR-150 P4 Task 11 — drop a media file onto the timeline to
|
|
9160
|
+
// insert a clip: upload to assets/, then append a <Sequence> with
|
|
9161
|
+
// the media (playing after existing content). fps*3 default length.
|
|
9162
|
+
const artboardId = timelineArtboardId || undefined;
|
|
9163
|
+
const mediaTag = file.type.startsWith('video/')
|
|
9164
|
+
? 'Video'
|
|
9165
|
+
: file.type.startsWith('audio/')
|
|
9166
|
+
? 'Audio'
|
|
9167
|
+
: file.type.startsWith('image/')
|
|
9168
|
+
? 'Img'
|
|
9169
|
+
: null;
|
|
9170
|
+
const fps = activeComps[0]?.fps || 30;
|
|
9171
|
+
fetch('/_api/asset', {
|
|
9172
|
+
method: 'POST',
|
|
9173
|
+
headers: { 'Content-Type': file.type || 'application/octet-stream' },
|
|
9174
|
+
body: file,
|
|
9175
|
+
})
|
|
9176
|
+
.then((r) => r.json().catch(() => ({})))
|
|
9177
|
+
.then((up) => {
|
|
9178
|
+
if (!up?.path) {
|
|
9179
|
+
console.warn('[insert] upload failed', up?.error);
|
|
9180
|
+
shellToast(`Upload failed: ${up?.error || 'unknown error'}`);
|
|
9181
|
+
return null;
|
|
9182
|
+
}
|
|
9183
|
+
return fetch('/_api/insert-sequence', {
|
|
9184
|
+
method: 'POST',
|
|
9185
|
+
headers: { 'content-type': 'application/json' },
|
|
9186
|
+
body: JSON.stringify({
|
|
9187
|
+
canvas: activePath,
|
|
9188
|
+
artboardId,
|
|
9189
|
+
from: Math.max(0, timelineTotal),
|
|
9190
|
+
durationInFrames: Math.round(fps * 3),
|
|
9191
|
+
mediaTag,
|
|
9192
|
+
src: up.path,
|
|
9193
|
+
}),
|
|
9194
|
+
});
|
|
9195
|
+
})
|
|
9196
|
+
.then((r) => (r ? r.json() : null))
|
|
9197
|
+
.then((j) => {
|
|
9198
|
+
if (j && !j.ok) {
|
|
9199
|
+
console.warn('[insert-clip]', j.error || 'failed');
|
|
9200
|
+
shellToast(`Insert refused: ${j.error || 'failed'}`);
|
|
9201
|
+
} else if (j && j.ok) {
|
|
9202
|
+
shellToast('Clip added to the timeline.', true);
|
|
9203
|
+
if (j.seq != null) pushTlUndo(activePath, j.seq, 'add clip');
|
|
9204
|
+
}
|
|
9205
|
+
})
|
|
9206
|
+
.catch(() => {});
|
|
9207
|
+
}}
|
|
9208
|
+
height={timelineHeight}
|
|
9209
|
+
onResize={setTimelineHeight}
|
|
9210
|
+
onClose={() => setTimelineOpen(false)}
|
|
9211
|
+
/>
|
|
9212
|
+
)}
|
|
8222
9213
|
<StatusBar
|
|
8223
9214
|
activePath={activePath}
|
|
8224
9215
|
selected={selected}
|
|
@@ -8264,6 +9255,14 @@ function App() {
|
|
|
8264
9255
|
mode={exportDialog.mode}
|
|
8265
9256
|
initialScope={exportDialog.scope}
|
|
8266
9257
|
activePath={activePath}
|
|
9258
|
+
hasComps={activeComps.length > 0}
|
|
9259
|
+
// Which artboard "Active artboard" scope targets. The shell can't query
|
|
9260
|
+
// the cross-origin canvas DOM (the in-canvas dialog's activeArtboardId),
|
|
9261
|
+
// so use the tracked signals: an explicit selection wins, else the
|
|
9262
|
+
// viewport-active artboard canvas-lib reports on pan. Without this,
|
|
9263
|
+
// scope=artboard fell back to `:first-of-type` (always the first).
|
|
9264
|
+
activeArtboardId={selected?.artboardId ?? canvasActiveArtboard ?? null}
|
|
9265
|
+
selection={selected?.selector ? { selector: selected.selector, file: selected.file } : null}
|
|
8267
9266
|
onClose={() => setExportDialog(null)}
|
|
8268
9267
|
/>
|
|
8269
9268
|
)}
|