@1agh/maude 0.57.0 → 0.58.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/apps/studio/api.ts +69 -4
- package/apps/studio/artboard-hug-commit.ts +30 -0
- package/apps/studio/client/app.jsx +162 -20
- package/apps/studio/client/panels/chat-markdown.jsx +76 -5
- package/apps/studio/client/panels/file-preview.jsx +115 -0
- package/apps/studio/client/panels/timeline-media-cache.js +38 -2
- package/apps/studio/client/styles/3-shell-maude.css +45 -0
- package/apps/studio/client/styles/6-acp-chat.css +28 -0
- package/apps/studio/clip-ops.ts +84 -35
- package/apps/studio/dist/client.bundle.js +1585 -1585
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +8 -0
- package/apps/studio/input-router.tsx +39 -1
- package/apps/studio/test/artboard-hug-commit.test.ts +36 -0
- package/apps/studio/test/clip-ops.test.ts +84 -2
- package/apps/studio/test/config-version.test.ts +88 -0
- package/apps/studio/test/input-router.test.ts +56 -0
- package/apps/studio/test/timeline-media-cache.test.ts +41 -0
- package/apps/studio/test/whats-new.test.ts +13 -1
- package/apps/studio/whats-new.json +9 -0
- package/apps/studio/whats-new.ts +34 -10
- package/package.json +8 -8
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// feature-studio-file-preview — inline preview for a non-canvas tree row
|
|
2
|
+
// (markdown / text / image / video / audio / font). Reuses the existing
|
|
3
|
+
// static byte-serving route (no new server endpoint) and the chat panel's
|
|
4
|
+
// hand-rolled Markdown renderer — no new dependency.
|
|
5
|
+
|
|
6
|
+
import { useEffect, useState } from 'react';
|
|
7
|
+
import { Markdown } from './chat-markdown.jsx';
|
|
8
|
+
|
|
9
|
+
const TEXT_PREVIEW_MAX_BYTES = 2 * 1024 * 1024; // 2 MB — beyond this, just show a size note
|
|
10
|
+
|
|
11
|
+
function basename(p) {
|
|
12
|
+
return p.split('/').pop() || p;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Bidi-override control chars (U+202A-E, U+2066-9) let a crafted filename
|
|
16
|
+
// visually disguise its own name/extension (the classic "invoicegnp.exe"
|
|
17
|
+
// trick) wherever we render a raw path/name as text — strip them before
|
|
18
|
+
// display. Doesn't affect matching (previewKind() regexes run on the
|
|
19
|
+
// unstripped name), only what a human/AT reads.
|
|
20
|
+
export function sanitizeDisplayText(s) {
|
|
21
|
+
return String(s).replace(/[\u202a-\u202e\u2066-\u2069]/g, '');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function useFetchedText(url, enabled) {
|
|
25
|
+
const [state, setState] = useState({ loading: true, error: null, text: '', tooLarge: false });
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!enabled) return;
|
|
28
|
+
let cancelled = false;
|
|
29
|
+
setState({ loading: true, error: null, text: '', tooLarge: false });
|
|
30
|
+
fetch(url)
|
|
31
|
+
.then(async (r) => {
|
|
32
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
33
|
+
const len = Number(r.headers.get('content-length') || 0);
|
|
34
|
+
if (len > TEXT_PREVIEW_MAX_BYTES) {
|
|
35
|
+
if (!cancelled) setState({ loading: false, error: null, text: '', tooLarge: len });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const text = await r.text();
|
|
39
|
+
if (!cancelled) setState({ loading: false, error: null, text, tooLarge: false });
|
|
40
|
+
})
|
|
41
|
+
.catch((err) => {
|
|
42
|
+
if (!cancelled) setState({ loading: false, error: err.message || 'Failed to load', text: '', tooLarge: false });
|
|
43
|
+
});
|
|
44
|
+
return () => {
|
|
45
|
+
cancelled = true;
|
|
46
|
+
};
|
|
47
|
+
}, [url, enabled]);
|
|
48
|
+
return state;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function TextPreview({ url, name, as }) {
|
|
52
|
+
const { loading, error, text, tooLarge } = useFetchedText(url, true);
|
|
53
|
+
if (loading) return <div className="st-file-preview-status">Loading {name}…</div>;
|
|
54
|
+
if (error) return <div className="st-file-preview-status st-file-preview-error">Couldn't load {name}: {error}</div>;
|
|
55
|
+
if (tooLarge) {
|
|
56
|
+
return (
|
|
57
|
+
<div className="st-file-preview-status">
|
|
58
|
+
{name} is {Math.round(tooLarge / 1024)} KB — too large to preview inline.
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (as === 'markdown') return <div className="st-file-preview-markdown"><Markdown text={text} /></div>;
|
|
63
|
+
return <pre className="st-file-preview-text">{text}</pre>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// CSS-string-literal escape for a value interpolated inside url("...") —
|
|
67
|
+
// filenames can contain arbitrary characters (only "/" and NUL are forbidden
|
|
68
|
+
// on disk), so an unescaped `"` or `\` could break out of the string and
|
|
69
|
+
// inject arbitrary rules into the live app-shell stylesheet (not just the
|
|
70
|
+
// sandboxed canvas iframe). family is already alnum/hyphen-only; this covers
|
|
71
|
+
// the url() side of the same interpolation.
|
|
72
|
+
function cssStringEscape(s) {
|
|
73
|
+
// CSS input-preprocessing (CSS Syntax Module Level 3 §3.3) normalizes CR,
|
|
74
|
+
// CRLF, AND FORM FEED (\f, U+000C) to a single LF before tokenizing, so an
|
|
75
|
+
// unescaped \f terminates a double-quoted string exactly like \r/\n does —
|
|
76
|
+
// strip all three, not just the two obvious ones.
|
|
77
|
+
return String(s).replace(/[\\"]/g, (c) => `\\${c}`).replace(/[\r\n\f]/g, '');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function FontPreview({ url, name }) {
|
|
81
|
+
const family = `st-preview-font-${name.replace(/[^a-z0-9]+/gi, '-')}`;
|
|
82
|
+
return (
|
|
83
|
+
<div className="st-file-preview-font">
|
|
84
|
+
<style>{`@font-face { font-family: "${family}"; src: url("${cssStringEscape(url)}"); }`}</style>
|
|
85
|
+
<div className="st-file-preview-font-specimen" style={{ fontFamily: family }}>
|
|
86
|
+
<div className="st-file-preview-font-lg">The quick brown fox</div>
|
|
87
|
+
<div className="st-file-preview-font-md">jumps over the lazy dog</div>
|
|
88
|
+
<div className="st-file-preview-font-sm">ABCDEFGHIJKLM abcdefghijklm 0123456789</div>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function FilePreview({ path, kind }) {
|
|
95
|
+
if (!path || !kind) return null;
|
|
96
|
+
const url = `/${path}`;
|
|
97
|
+
const name = sanitizeDisplayText(basename(path));
|
|
98
|
+
return (
|
|
99
|
+
<div className="st-file-preview" data-kind={kind}>
|
|
100
|
+
<h2 className="st-file-preview-header">{sanitizeDisplayText(path)}</h2>
|
|
101
|
+
<div className="st-file-preview-body">
|
|
102
|
+
{kind === 'markdown' && <TextPreview url={url} name={name} as="markdown" />}
|
|
103
|
+
{kind === 'text' && <TextPreview url={url} name={name} as="text" />}
|
|
104
|
+
{kind === 'image' && (
|
|
105
|
+
<div className="st-file-preview-image-wrap">
|
|
106
|
+
<img src={url} alt={`Preview: ${name}`} className="st-file-preview-image" />
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
{kind === 'video' && <video key={url} src={url} controls className="st-file-preview-media" />}
|
|
110
|
+
{kind === 'audio' && <audio key={url} src={url} controls className="st-file-preview-audio" />}
|
|
111
|
+
{kind === 'font' && <FontPreview url={url} name={name} />}
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -80,7 +80,10 @@ export function stripBucket(widthPx) {
|
|
|
80
80
|
return 7;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
/** Hidden <video>, loaded and waited on through `loadedmetadata`/`canplay` so
|
|
84
|
+
* `.duration`/`.videoWidth`/`.videoHeight` are readable. Caller owns cleanup
|
|
85
|
+
* (`video.remove()`) once done with it. */
|
|
86
|
+
async function loadVideoMetadata(url, timeoutMs) {
|
|
84
87
|
const video = document.createElement('video');
|
|
85
88
|
video.muted = true;
|
|
86
89
|
video.playsInline = true;
|
|
@@ -99,7 +102,7 @@ async function extractStrip(url, count) {
|
|
|
99
102
|
}
|
|
100
103
|
try {
|
|
101
104
|
await new Promise((res, rej) => {
|
|
102
|
-
const to = setTimeout(() => rej(new Error('metadata timeout')),
|
|
105
|
+
const to = setTimeout(() => rej(new Error('metadata timeout')), timeoutMs);
|
|
103
106
|
const ok = () => {
|
|
104
107
|
clearTimeout(to);
|
|
105
108
|
res();
|
|
@@ -114,6 +117,39 @@ async function extractStrip(url, count) {
|
|
|
114
117
|
});
|
|
115
118
|
} catch (err) {
|
|
116
119
|
video.remove();
|
|
120
|
+
throw err;
|
|
121
|
+
}
|
|
122
|
+
return video;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Real duration (seconds) of a video/audio src, probed via a hidden <video>
|
|
126
|
+
* element — never throws, resolves null on failure so callers (e.g. the
|
|
127
|
+
* timeline drop handler) can fall back to a default duration cleanly. */
|
|
128
|
+
export async function probeMediaDuration(url) {
|
|
129
|
+
let video;
|
|
130
|
+
try {
|
|
131
|
+
video = await loadVideoMetadata(url, 6000);
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
const dur = video.duration;
|
|
136
|
+
video.remove();
|
|
137
|
+
return Number.isFinite(dur) && dur > 0 ? dur : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** durationInFrames for a clip dropped on the timeline: the real probed
|
|
141
|
+
* duration when available, else a fallback (probe failure, or synthetic
|
|
142
|
+
* content with no inherent duration like a title/image). */
|
|
143
|
+
export function durationFramesForDrop(fps, probedSeconds, fallbackSeconds = 3) {
|
|
144
|
+
const sec = Number.isFinite(probedSeconds) && probedSeconds > 0 ? probedSeconds : fallbackSeconds;
|
|
145
|
+
return Math.max(1, Math.round(fps * sec));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function extractStrip(url, count) {
|
|
149
|
+
let video;
|
|
150
|
+
try {
|
|
151
|
+
video = await loadVideoMetadata(url, 8000);
|
|
152
|
+
} catch (err) {
|
|
117
153
|
console.warn('[timeline] filmstrip extraction failed for', url, err?.message || err);
|
|
118
154
|
throw err;
|
|
119
155
|
}
|
|
@@ -219,6 +219,10 @@
|
|
|
219
219
|
.st-sb-changes.has-changes .st-sb-changes-dot { background: var(--status-warn); }
|
|
220
220
|
.st-sb-changes.has-unpushed .st-sb-changes-dot { background: var(--accent); }
|
|
221
221
|
|
|
222
|
+
/* The running release. Ambient, not a state to act on — so it sits at the same
|
|
223
|
+
weight as a slot label rather than at .val's foreground. No new tokens. */
|
|
224
|
+
.st-sb-version .val { color: var(--fg-3); }
|
|
225
|
+
|
|
222
226
|
/* ═══ Task 4 — body row · sidebar · tree · rail ═══════════════════════════════ */
|
|
223
227
|
|
|
224
228
|
/* The body flex row replaces the legacy `.app` grid: rail | sidebar | main | rpanel. */
|
|
@@ -425,6 +429,47 @@
|
|
|
425
429
|
incomplete; auto-hides once probeSetupReadiness() reports ready. */
|
|
426
430
|
.st-empty-quick-setup { pointer-events: auto; margin-top: var(--space-2); }
|
|
427
431
|
|
|
432
|
+
/* ═══ feature-studio-file-preview — non-canvas file preview overlay ════════
|
|
433
|
+
* Layered above the (still-mounted) canvas iframe / empty state so switching
|
|
434
|
+
* back to a canvas is instant. Shell-token only, no hardcoded colors. */
|
|
435
|
+
.st-file-preview-overlay {
|
|
436
|
+
position: absolute; inset: 0; z-index: 5; display: flex; flex-direction: column;
|
|
437
|
+
background: var(--bg-1); overflow: auto;
|
|
438
|
+
}
|
|
439
|
+
.st-file-preview-overlay:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
440
|
+
.st-file-preview { display: flex; flex-direction: column; min-height: 100%; }
|
|
441
|
+
.st-file-preview-header {
|
|
442
|
+
flex: none; margin: 0; padding: var(--space-3) var(--space-4); font-family: var(--font-mono);
|
|
443
|
+
font-size: var(--type-xs); font-weight: 400; color: var(--fg-2); border-bottom: 1px solid var(--border-subtle);
|
|
444
|
+
background: var(--bg-0);
|
|
445
|
+
}
|
|
446
|
+
.st-file-preview-body { flex: 1; padding: var(--space-6); }
|
|
447
|
+
.st-file-preview-status { color: var(--fg-3); font-size: var(--type-sm); }
|
|
448
|
+
.st-file-preview-error { color: var(--status-error); }
|
|
449
|
+
.st-file-preview-text {
|
|
450
|
+
font-family: var(--font-mono); font-size: var(--type-xs); line-height: 1.6; color: var(--fg-1);
|
|
451
|
+
white-space: pre-wrap; word-break: break-word; background: var(--bg-0);
|
|
452
|
+
border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); padding: var(--space-4);
|
|
453
|
+
}
|
|
454
|
+
.st-file-preview-markdown { max-width: 720px; color: var(--fg-1); }
|
|
455
|
+
.st-file-preview-image-wrap {
|
|
456
|
+
display: flex; align-items: center; justify-content: center; min-height: 200px;
|
|
457
|
+
background-image: linear-gradient(45deg, var(--bg-2) 25%, transparent 25%),
|
|
458
|
+
linear-gradient(-45deg, var(--bg-2) 25%, transparent 25%),
|
|
459
|
+
linear-gradient(45deg, transparent 75%, var(--bg-2) 75%),
|
|
460
|
+
linear-gradient(-45deg, transparent 75%, var(--bg-2) 75%);
|
|
461
|
+
background-size: 16px 16px; background-position: 0 0, 0 8px, 8px -8px, -8px 0;
|
|
462
|
+
border: 1px solid var(--border-subtle); border-radius: var(--radius-sm);
|
|
463
|
+
}
|
|
464
|
+
.st-file-preview-image { max-width: 100%; max-height: 70vh; object-fit: contain; }
|
|
465
|
+
.st-file-preview-media { max-width: 100%; max-height: 70vh; display: block; }
|
|
466
|
+
.st-file-preview-audio { width: 100%; }
|
|
467
|
+
.st-file-preview-font { display: flex; flex-direction: column; gap: var(--space-4); }
|
|
468
|
+
.st-file-preview-font-specimen { color: var(--fg-0); }
|
|
469
|
+
.st-file-preview-font-lg { font-size: 40px; line-height: 1.3; }
|
|
470
|
+
.st-file-preview-font-md { font-size: 24px; line-height: 1.4; }
|
|
471
|
+
.st-file-preview-font-sm { font-size: 14px; line-height: 1.6; color: var(--fg-2); word-break: break-all; }
|
|
472
|
+
|
|
428
473
|
/* ═══ Task 6 — right panel (Inspector / Comments) + palette + toast ═══════════ */
|
|
429
474
|
.st-rpanel {
|
|
430
475
|
background: var(--bg-1); border-left: 1px solid var(--border-default);
|
|
@@ -619,6 +619,34 @@
|
|
|
619
619
|
border-radius: var(--radius-sm);
|
|
620
620
|
overflow-x: auto;
|
|
621
621
|
}
|
|
622
|
+
.chat-md-table-wrap {
|
|
623
|
+
overflow-x: auto;
|
|
624
|
+
border: 1px solid var(--border-subtle);
|
|
625
|
+
border-radius: var(--radius-sm);
|
|
626
|
+
}
|
|
627
|
+
.chat-md-table {
|
|
628
|
+
width: 100%;
|
|
629
|
+
border-collapse: collapse;
|
|
630
|
+
font-size: var(--type-xs);
|
|
631
|
+
}
|
|
632
|
+
.chat-md-table th,
|
|
633
|
+
.chat-md-table td {
|
|
634
|
+
padding: var(--space-2) var(--space-3);
|
|
635
|
+
text-align: left;
|
|
636
|
+
border-bottom: 1px solid var(--border-subtle);
|
|
637
|
+
white-space: pre-wrap;
|
|
638
|
+
}
|
|
639
|
+
.chat-md-table th {
|
|
640
|
+
background: var(--bg-2);
|
|
641
|
+
font-weight: 600;
|
|
642
|
+
color: var(--fg-0);
|
|
643
|
+
}
|
|
644
|
+
.chat-md-table td {
|
|
645
|
+
color: var(--fg-1);
|
|
646
|
+
}
|
|
647
|
+
.chat-md-table tbody tr:last-child td {
|
|
648
|
+
border-bottom: none;
|
|
649
|
+
}
|
|
622
650
|
.chat-code code {
|
|
623
651
|
font-family: var(--font-mono);
|
|
624
652
|
font-size: var(--type-xs);
|
package/apps/studio/clip-ops.ts
CHANGED
|
@@ -1802,26 +1802,51 @@ export function applyMoveClipToOverlay(
|
|
|
1802
1802
|
});
|
|
1803
1803
|
}
|
|
1804
1804
|
const mediaStart = directMediaTagStart(source, clip);
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1805
|
+
const isPlaceholder = clip.placeholder != null;
|
|
1806
|
+
if (!isPlaceholder && (mediaStart == null || !clip.mediaSrc || clip.durationInFrames == null)) {
|
|
1807
|
+
throw new CanvasEditError(
|
|
1808
|
+
'only a media clip with a direct source, or an AI placeholder, can move between layers',
|
|
1809
|
+
{ canvas: canvasAbsPath, id: stableId }
|
|
1810
|
+
);
|
|
1811
|
+
}
|
|
1812
|
+
if (isPlaceholder && clip.durationInFrames == null) {
|
|
1813
|
+
throw new CanvasEditError(
|
|
1814
|
+
'only a media clip with a direct source, or an AI placeholder, can move between layers',
|
|
1815
|
+
{ canvas: canvasAbsPath, id: stableId }
|
|
1816
|
+
);
|
|
1810
1817
|
}
|
|
1811
1818
|
const start = clipAbsoluteStart(source, canvasAbsPath, artboardId, stableId);
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1819
|
+
// A placeholder slate has no media element to graft trim/rate onto — it
|
|
1820
|
+
// rides over as-is (prompt + kind), never as media.
|
|
1821
|
+
const ins = isPlaceholder
|
|
1822
|
+
? applyInsertClipAt(canvasAbsPath, source, artboardId, {
|
|
1823
|
+
lane: 'overlay',
|
|
1824
|
+
from: start,
|
|
1825
|
+
durationInFrames: clip.durationInFrames as number,
|
|
1826
|
+
placeholder: {
|
|
1827
|
+
prompt: clip.placeholder?.prompt ?? '',
|
|
1828
|
+
kind: clip.placeholder?.kind ?? 'veo',
|
|
1829
|
+
},
|
|
1830
|
+
})
|
|
1831
|
+
: applyInsertClipAt(canvasAbsPath, source, artboardId, {
|
|
1832
|
+
lane: 'overlay',
|
|
1833
|
+
from: start,
|
|
1834
|
+
durationInFrames: clip.durationInFrames as number,
|
|
1835
|
+
mediaTag: clip.mediaTag === 'Audio' ? 'Audio' : 'Video',
|
|
1836
|
+
src: clip.mediaSrc,
|
|
1837
|
+
});
|
|
1838
|
+
let out = isPlaceholder
|
|
1839
|
+
? ins.source
|
|
1840
|
+
: graftMediaAttrs(
|
|
1841
|
+
canvasAbsPath,
|
|
1842
|
+
ins.source,
|
|
1843
|
+
artboardId,
|
|
1844
|
+
ins.stableId,
|
|
1845
|
+
numAttrText(source, mediaStart as number, 'trimBefore') ??
|
|
1846
|
+
numAttrText(source, mediaStart as number, 'startFrom') ??
|
|
1847
|
+
0,
|
|
1848
|
+
numAttrText(source, mediaStart as number, 'playbackRate') ?? 1
|
|
1849
|
+
);
|
|
1825
1850
|
const again = enumerateClips(canvasAbsPath, out, artboardId);
|
|
1826
1851
|
const insertedHash = again.clips.find((c) => c.stableId === ins.stableId)?.contentHash ?? null;
|
|
1827
1852
|
const original = again.clips.find(
|
|
@@ -1861,24 +1886,48 @@ export function applyMoveClipToStoryline(
|
|
|
1861
1886
|
throw new CanvasEditError('already a storyline beat', { canvas: canvasAbsPath, id: stableId });
|
|
1862
1887
|
}
|
|
1863
1888
|
const mediaStart = directMediaTagStart(source, clip);
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1889
|
+
const isPlaceholder = clip.placeholder != null;
|
|
1890
|
+
if (!isPlaceholder && (mediaStart == null || !clip.mediaSrc || clip.durationInFrames == null)) {
|
|
1891
|
+
throw new CanvasEditError(
|
|
1892
|
+
'only a media clip with a direct source, or an AI placeholder, can move between layers',
|
|
1893
|
+
{ canvas: canvasAbsPath, id: stableId }
|
|
1894
|
+
);
|
|
1869
1895
|
}
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1896
|
+
if (isPlaceholder && clip.durationInFrames == null) {
|
|
1897
|
+
throw new CanvasEditError(
|
|
1898
|
+
'only a media clip with a direct source, or an AI placeholder, can move between layers',
|
|
1899
|
+
{ canvas: canvasAbsPath, id: stableId }
|
|
1900
|
+
);
|
|
1901
|
+
}
|
|
1902
|
+
// A placeholder slate has no media element to graft trim/rate onto — it
|
|
1903
|
+
// rides over as-is (prompt + kind), never as media.
|
|
1904
|
+
const ins = isPlaceholder
|
|
1905
|
+
? applyInsertClipAt(canvasAbsPath, source, artboardId, {
|
|
1906
|
+
lane: 'storyline',
|
|
1907
|
+
durationInFrames: clip.durationInFrames as number,
|
|
1908
|
+
placeholder: {
|
|
1909
|
+
prompt: clip.placeholder?.prompt ?? '',
|
|
1910
|
+
kind: clip.placeholder?.kind ?? 'veo',
|
|
1911
|
+
},
|
|
1912
|
+
})
|
|
1913
|
+
: applyInsertClipAt(canvasAbsPath, source, artboardId, {
|
|
1914
|
+
lane: 'storyline',
|
|
1915
|
+
durationInFrames: clip.durationInFrames as number,
|
|
1916
|
+
mediaTag: clip.mediaTag === 'Audio' ? 'Audio' : 'Video',
|
|
1917
|
+
src: clip.mediaSrc,
|
|
1918
|
+
});
|
|
1919
|
+
let out = isPlaceholder
|
|
1920
|
+
? ins.source
|
|
1921
|
+
: graftMediaAttrs(
|
|
1922
|
+
canvasAbsPath,
|
|
1923
|
+
ins.source,
|
|
1924
|
+
artboardId,
|
|
1925
|
+
ins.stableId,
|
|
1926
|
+
numAttrText(source, mediaStart as number, 'trimBefore') ??
|
|
1927
|
+
numAttrText(source, mediaStart as number, 'startFrom') ??
|
|
1928
|
+
0,
|
|
1929
|
+
numAttrText(source, mediaStart as number, 'playbackRate') ?? 1
|
|
1930
|
+
);
|
|
1882
1931
|
const again = enumerateClips(canvasAbsPath, out, artboardId);
|
|
1883
1932
|
const insertedHash = again.clips.find((c) => c.stableId === ins.stableId)?.contentHash ?? null;
|
|
1884
1933
|
const original = again.clips.find(
|