@hyperframes/studio 0.4.17 → 0.4.18
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/dist/assets/index-D0VntLIQ.js +115 -0
- package/dist/index.html +1 -1
- package/package.json +4 -4
- package/src/App.tsx +359 -48
- package/src/components/nle/NLELayout.tsx +15 -0
- package/src/components/sidebar/AssetsTab.tsx +7 -0
- package/src/player/components/Timeline.test.ts +73 -0
- package/src/player/components/Timeline.tsx +183 -12
- package/src/utils/timelineAssetDrop.test.ts +80 -0
- package/src/utils/timelineAssetDrop.ts +87 -0
- package/dist/assets/index-JZr8f8y8.js +0 -115
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
type TimelineTheme,
|
|
28
28
|
} from "./timelineTheme";
|
|
29
29
|
import { getTimelinePixelsPerSecond } from "./timelineZoom";
|
|
30
|
+
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
|
|
30
31
|
|
|
31
32
|
/* ── Layout ─────────────────────────────────────────────────────── */
|
|
32
33
|
const GUTTER = 32;
|
|
@@ -140,6 +141,70 @@ export function getTimelineCanvasHeight(trackCount: number): number {
|
|
|
140
141
|
return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER;
|
|
141
142
|
}
|
|
142
143
|
|
|
144
|
+
export function shouldHandleTimelineDeleteKey(input: {
|
|
145
|
+
key: string;
|
|
146
|
+
metaKey?: boolean;
|
|
147
|
+
ctrlKey?: boolean;
|
|
148
|
+
altKey?: boolean;
|
|
149
|
+
target?: EventTarget | null;
|
|
150
|
+
}): boolean {
|
|
151
|
+
if (input.key !== "Delete" && input.key !== "Backspace") return false;
|
|
152
|
+
if (input.metaKey || input.ctrlKey || input.altKey) return false;
|
|
153
|
+
const target =
|
|
154
|
+
input.target && typeof input.target === "object"
|
|
155
|
+
? (input.target as {
|
|
156
|
+
tagName?: string;
|
|
157
|
+
isContentEditable?: boolean;
|
|
158
|
+
closest?: (selector: string) => Element | null;
|
|
159
|
+
})
|
|
160
|
+
: null;
|
|
161
|
+
if (target) {
|
|
162
|
+
const tag = target.tagName?.toLowerCase() ?? "";
|
|
163
|
+
if (target.isContentEditable) return false;
|
|
164
|
+
if (["input", "textarea", "select"].includes(tag)) return false;
|
|
165
|
+
if (typeof target.closest === "function" && target.closest("[contenteditable='true']")) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function getDefaultDroppedTrack(trackOrder: number[], rowIndex?: number): number {
|
|
173
|
+
if (trackOrder.length === 0) return 0;
|
|
174
|
+
if (rowIndex == null || rowIndex < 0) return trackOrder[0];
|
|
175
|
+
if (rowIndex >= trackOrder.length) {
|
|
176
|
+
return Math.max(...trackOrder) + 1;
|
|
177
|
+
}
|
|
178
|
+
return trackOrder[rowIndex] ?? trackOrder[trackOrder.length - 1] ?? 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function resolveTimelineAssetDrop(
|
|
182
|
+
input: {
|
|
183
|
+
rectLeft: number;
|
|
184
|
+
rectTop: number;
|
|
185
|
+
scrollLeft: number;
|
|
186
|
+
scrollTop: number;
|
|
187
|
+
pixelsPerSecond: number;
|
|
188
|
+
duration: number;
|
|
189
|
+
trackHeight: number;
|
|
190
|
+
trackOrder: number[];
|
|
191
|
+
},
|
|
192
|
+
clientX: number,
|
|
193
|
+
clientY: number,
|
|
194
|
+
): { start: number; track: number } {
|
|
195
|
+
const x = clientX - input.rectLeft + input.scrollLeft - GUTTER;
|
|
196
|
+
const y = clientY - input.rectTop + input.scrollTop - RULER_H;
|
|
197
|
+
const start = Math.max(
|
|
198
|
+
0,
|
|
199
|
+
Math.min(input.duration, Math.round((x / Math.max(input.pixelsPerSecond, 1)) * 100) / 100),
|
|
200
|
+
);
|
|
201
|
+
const rowIndex = Math.floor(y / Math.max(input.trackHeight, 1));
|
|
202
|
+
return {
|
|
203
|
+
start,
|
|
204
|
+
track: getDefaultDroppedTrack(input.trackOrder, rowIndex),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
143
208
|
/* ── Component ──────────────────────────────────────────────────── */
|
|
144
209
|
interface TimelineProps {
|
|
145
210
|
/** Called when user seeks via ruler/track click or playhead drag */
|
|
@@ -154,8 +219,19 @@ interface TimelineProps {
|
|
|
154
219
|
/** Optional overlay renderer for clips (e.g. badges, cursors) */
|
|
155
220
|
renderClipOverlay?: (element: import("../store/playerStore").TimelineElement) => ReactNode;
|
|
156
221
|
/** Called when files are dropped onto the empty timeline */
|
|
157
|
-
onFileDrop?: (
|
|
222
|
+
onFileDrop?: (
|
|
223
|
+
files: File[],
|
|
224
|
+
placement?: { start: number; track: number },
|
|
225
|
+
) => Promise<void> | void;
|
|
226
|
+
/** Called when an existing asset is dropped from the Assets tab */
|
|
227
|
+
onAssetDrop?: (
|
|
228
|
+
assetPath: string,
|
|
229
|
+
placement: { start: number; track: number },
|
|
230
|
+
) => Promise<void> | void;
|
|
158
231
|
/** Persist a clip move back into source HTML */
|
|
232
|
+
onDeleteElement?: (
|
|
233
|
+
element: import("../store/playerStore").TimelineElement,
|
|
234
|
+
) => Promise<void> | void;
|
|
159
235
|
onMoveElement?: (
|
|
160
236
|
element: import("../store/playerStore").TimelineElement,
|
|
161
237
|
updates: Pick<import("../store/playerStore").TimelineElement, "start" | "track">,
|
|
@@ -213,6 +289,8 @@ export const Timeline = memo(function Timeline({
|
|
|
213
289
|
renderClipContent,
|
|
214
290
|
renderClipOverlay,
|
|
215
291
|
onFileDrop,
|
|
292
|
+
onAssetDrop,
|
|
293
|
+
onDeleteElement,
|
|
216
294
|
onMoveElement,
|
|
217
295
|
onResizeElement,
|
|
218
296
|
onBlockedEditAttempt,
|
|
@@ -263,10 +341,13 @@ export const Timeline = memo(function Timeline({
|
|
|
263
341
|
const resizingClipRef = useRef<ResizingClipState | null>(null);
|
|
264
342
|
resizingClipRef.current = resizingClip;
|
|
265
343
|
const blockedClipRef = useRef<BlockedClipState | null>(null);
|
|
344
|
+
const deleteInFlightRef = useRef(false);
|
|
266
345
|
const onMoveElementRef = useRef(onMoveElement);
|
|
267
346
|
onMoveElementRef.current = onMoveElement;
|
|
268
347
|
const onResizeElementRef = useRef(onResizeElement);
|
|
269
348
|
onResizeElementRef.current = onResizeElement;
|
|
349
|
+
const onDeleteElementRef = useRef(onDeleteElement);
|
|
350
|
+
onDeleteElementRef.current = onDeleteElement;
|
|
270
351
|
const suppressClickRef = useRef(false);
|
|
271
352
|
const [showPopover, setShowPopover] = useState(false);
|
|
272
353
|
const [viewportWidth, setViewportWidth] = useState(0);
|
|
@@ -337,6 +418,12 @@ export const Timeline = memo(function Timeline({
|
|
|
337
418
|
}
|
|
338
419
|
return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
|
|
339
420
|
}, [draggedClip, trackOrder]);
|
|
421
|
+
const selectedElement = useMemo(
|
|
422
|
+
() => elements.find((element) => (element.key ?? element.id) === selectedElementId) ?? null,
|
|
423
|
+
[elements, selectedElementId],
|
|
424
|
+
);
|
|
425
|
+
const selectedElementRef = useRef<TimelineElement | null>(selectedElement);
|
|
426
|
+
selectedElementRef.current = selectedElement;
|
|
340
427
|
|
|
341
428
|
// Calculate effective pixels per second
|
|
342
429
|
// In fit mode, use clientWidth (excludes scrollbar) with a small padding
|
|
@@ -743,6 +830,28 @@ export const Timeline = memo(function Timeline({
|
|
|
743
830
|
};
|
|
744
831
|
});
|
|
745
832
|
|
|
833
|
+
useMountEffect(() => {
|
|
834
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
835
|
+
if (!shouldHandleTimelineDeleteKey(event)) return;
|
|
836
|
+
const selected = selectedElementRef.current;
|
|
837
|
+
const onDelete = onDeleteElementRef.current;
|
|
838
|
+
if (!selected || !onDelete || deleteInFlightRef.current) return;
|
|
839
|
+
event.preventDefault();
|
|
840
|
+
deleteInFlightRef.current = true;
|
|
841
|
+
suppressClickRef.current = true;
|
|
842
|
+
setShowPopover(false);
|
|
843
|
+
setRangeSelection(null);
|
|
844
|
+
Promise.resolve(onDelete(selected)).finally(() => {
|
|
845
|
+
deleteInFlightRef.current = false;
|
|
846
|
+
requestAnimationFrame(() => {
|
|
847
|
+
suppressClickRef.current = false;
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
};
|
|
851
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
852
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
853
|
+
});
|
|
854
|
+
|
|
746
855
|
const handlePointerDown = useCallback(
|
|
747
856
|
(e: React.PointerEvent) => {
|
|
748
857
|
if (e.button !== 0) return;
|
|
@@ -833,6 +942,74 @@ export const Timeline = memo(function Timeline({
|
|
|
833
942
|
);
|
|
834
943
|
|
|
835
944
|
const [isDragOver, setIsDragOver] = useState(false);
|
|
945
|
+
const handleAssetDragOver = useCallback((e: React.DragEvent) => {
|
|
946
|
+
const hasFiles = e.dataTransfer.files.length > 0;
|
|
947
|
+
const hasAsset = Array.from(e.dataTransfer.types).includes(TIMELINE_ASSET_MIME);
|
|
948
|
+
if (!hasFiles && !hasAsset) return;
|
|
949
|
+
e.preventDefault();
|
|
950
|
+
if (hasAsset) {
|
|
951
|
+
e.dataTransfer.dropEffect = "copy";
|
|
952
|
+
}
|
|
953
|
+
setIsDragOver(true);
|
|
954
|
+
}, []);
|
|
955
|
+
|
|
956
|
+
const handleAssetDrop = useCallback(
|
|
957
|
+
(e: React.DragEvent) => {
|
|
958
|
+
e.preventDefault();
|
|
959
|
+
setIsDragOver(false);
|
|
960
|
+
if (onFileDrop && e.dataTransfer.files.length > 0) {
|
|
961
|
+
const scroll = scrollRef.current;
|
|
962
|
+
const rect = scroll?.getBoundingClientRect();
|
|
963
|
+
const placement =
|
|
964
|
+
scroll && rect
|
|
965
|
+
? resolveTimelineAssetDrop(
|
|
966
|
+
{
|
|
967
|
+
rectLeft: rect.left,
|
|
968
|
+
rectTop: rect.top,
|
|
969
|
+
scrollLeft: scroll.scrollLeft,
|
|
970
|
+
scrollTop: scroll.scrollTop,
|
|
971
|
+
pixelsPerSecond: ppsRef.current,
|
|
972
|
+
duration: durationRef.current,
|
|
973
|
+
trackHeight: TRACK_H,
|
|
974
|
+
trackOrder: trackOrderRef.current,
|
|
975
|
+
},
|
|
976
|
+
e.clientX,
|
|
977
|
+
e.clientY,
|
|
978
|
+
)
|
|
979
|
+
: undefined;
|
|
980
|
+
void onFileDrop(Array.from(e.dataTransfer.files), placement);
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const assetPayload = e.dataTransfer.getData(TIMELINE_ASSET_MIME);
|
|
985
|
+
if (!assetPayload || !onAssetDrop) return;
|
|
986
|
+
try {
|
|
987
|
+
const parsed = JSON.parse(assetPayload) as { path?: string };
|
|
988
|
+
if (!parsed.path) return;
|
|
989
|
+
const scroll = scrollRef.current;
|
|
990
|
+
const rect = scroll?.getBoundingClientRect();
|
|
991
|
+
if (!scroll || !rect) return;
|
|
992
|
+
const placement = resolveTimelineAssetDrop(
|
|
993
|
+
{
|
|
994
|
+
rectLeft: rect.left,
|
|
995
|
+
rectTop: rect.top,
|
|
996
|
+
scrollLeft: scroll.scrollLeft,
|
|
997
|
+
scrollTop: scroll.scrollTop,
|
|
998
|
+
pixelsPerSecond: ppsRef.current,
|
|
999
|
+
duration: durationRef.current,
|
|
1000
|
+
trackHeight: TRACK_H,
|
|
1001
|
+
trackOrder: trackOrderRef.current,
|
|
1002
|
+
},
|
|
1003
|
+
e.clientX,
|
|
1004
|
+
e.clientY,
|
|
1005
|
+
);
|
|
1006
|
+
void onAssetDrop(parsed.path, placement);
|
|
1007
|
+
} catch {
|
|
1008
|
+
// ignore malformed drag payloads
|
|
1009
|
+
}
|
|
1010
|
+
},
|
|
1011
|
+
[onAssetDrop, onFileDrop],
|
|
1012
|
+
);
|
|
836
1013
|
|
|
837
1014
|
if (!timelineReady || elements.length === 0) {
|
|
838
1015
|
return (
|
|
@@ -840,18 +1017,9 @@ export const Timeline = memo(function Timeline({
|
|
|
840
1017
|
className={`h-full border-t bg-[#0a0a0b] flex flex-col select-none transition-colors duration-150 ${
|
|
841
1018
|
isDragOver ? "border-studio-accent/50 bg-studio-accent/[0.03]" : "border-neutral-800/50"
|
|
842
1019
|
}`}
|
|
843
|
-
onDragOver={
|
|
844
|
-
e.preventDefault();
|
|
845
|
-
setIsDragOver(true);
|
|
846
|
-
}}
|
|
1020
|
+
onDragOver={handleAssetDragOver}
|
|
847
1021
|
onDragLeave={() => setIsDragOver(false)}
|
|
848
|
-
onDrop={
|
|
849
|
-
e.preventDefault();
|
|
850
|
-
setIsDragOver(false);
|
|
851
|
-
if (onFileDrop && e.dataTransfer.files.length > 0) {
|
|
852
|
-
onFileDrop(Array.from(e.dataTransfer.files));
|
|
853
|
-
}
|
|
854
|
-
}}
|
|
1022
|
+
onDrop={handleAssetDrop}
|
|
855
1023
|
>
|
|
856
1024
|
{/* Ruler */}
|
|
857
1025
|
<div
|
|
@@ -1015,6 +1183,9 @@ export const Timeline = memo(function Timeline({
|
|
|
1015
1183
|
<div
|
|
1016
1184
|
ref={scrollRef}
|
|
1017
1185
|
className={`${zoomMode === "fit" ? "overflow-x-hidden" : "overflow-x-auto"} overflow-y-auto h-full`}
|
|
1186
|
+
onDragOver={handleAssetDragOver}
|
|
1187
|
+
onDragLeave={() => setIsDragOver(false)}
|
|
1188
|
+
onDrop={handleAssetDrop}
|
|
1018
1189
|
onPointerDown={handlePointerDown}
|
|
1019
1190
|
onPointerMove={handlePointerMove}
|
|
1020
1191
|
onPointerUp={handlePointerUp}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildTimelineFileDropPlacements,
|
|
4
|
+
buildTimelineAssetInsertHtml,
|
|
5
|
+
getTimelineAssetKind,
|
|
6
|
+
insertTimelineAssetIntoSource,
|
|
7
|
+
resolveTimelineAssetSrc,
|
|
8
|
+
} from "./timelineAssetDrop";
|
|
9
|
+
|
|
10
|
+
describe("getTimelineAssetKind", () => {
|
|
11
|
+
it("detects image, video, and audio assets", () => {
|
|
12
|
+
expect(getTimelineAssetKind("assets/photo.png")).toBe("image");
|
|
13
|
+
expect(getTimelineAssetKind("assets/clip.mp4")).toBe("video");
|
|
14
|
+
expect(getTimelineAssetKind("assets/music.wav")).toBe("audio");
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("buildTimelineAssetInsertHtml", () => {
|
|
19
|
+
it("builds an image clip with explicit timing and track", () => {
|
|
20
|
+
expect(
|
|
21
|
+
buildTimelineAssetInsertHtml({
|
|
22
|
+
id: "photo_asset",
|
|
23
|
+
assetPath: "assets/photo.png",
|
|
24
|
+
kind: "image",
|
|
25
|
+
start: 1.25,
|
|
26
|
+
duration: 3,
|
|
27
|
+
track: 2,
|
|
28
|
+
zIndex: 4,
|
|
29
|
+
}),
|
|
30
|
+
).toContain('img id="photo_asset"');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("builds an audio clip without visual layout styles", () => {
|
|
34
|
+
const html = buildTimelineAssetInsertHtml({
|
|
35
|
+
id: "music_asset",
|
|
36
|
+
assetPath: "assets/music.wav",
|
|
37
|
+
kind: "audio",
|
|
38
|
+
start: 0.5,
|
|
39
|
+
duration: 5,
|
|
40
|
+
track: 0,
|
|
41
|
+
zIndex: 1,
|
|
42
|
+
});
|
|
43
|
+
expect(html).toContain("<audio");
|
|
44
|
+
expect(html).not.toContain("object-fit");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("resolveTimelineAssetSrc", () => {
|
|
49
|
+
it("keeps project-root asset paths for index.html", () => {
|
|
50
|
+
expect(resolveTimelineAssetSrc("index.html", "assets/photo.png")).toBe("assets/photo.png");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("rewrites asset paths relative to sub-compositions", () => {
|
|
54
|
+
expect(resolveTimelineAssetSrc("compositions/scene-a.html", "assets/photo.png")).toBe(
|
|
55
|
+
"../assets/photo.png",
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("buildTimelineFileDropPlacements", () => {
|
|
61
|
+
it("uses the dropped start and stacks multiple files onto successive tracks", () => {
|
|
62
|
+
expect(buildTimelineFileDropPlacements({ start: 1.5, track: 2 }, 3)).toEqual([
|
|
63
|
+
{ start: 1.5, track: 2 },
|
|
64
|
+
{ start: 1.5, track: 3 },
|
|
65
|
+
{ start: 1.5, track: 4 },
|
|
66
|
+
]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("insertTimelineAssetIntoSource", () => {
|
|
71
|
+
it("appends the new asset inside the root composition", () => {
|
|
72
|
+
const source = `<!doctype html><html><body><div id="root" data-composition-id="main"></div></body></html>`;
|
|
73
|
+
const html = insertTimelineAssetIntoSource(
|
|
74
|
+
source,
|
|
75
|
+
'<img id="photo_asset" data-start="0" data-duration="3" />',
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
expect(html).toContain('<div id="root" data-composition-id="main"><img id="photo_asset"');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { AUDIO_EXT, IMAGE_EXT, VIDEO_EXT } from "./mediaTypes";
|
|
2
|
+
|
|
3
|
+
export const TIMELINE_ASSET_MIME = "application/x-hyperframes-asset";
|
|
4
|
+
|
|
5
|
+
export type TimelineAssetKind = "image" | "video" | "audio";
|
|
6
|
+
|
|
7
|
+
export function getTimelineAssetKind(assetPath: string): TimelineAssetKind | null {
|
|
8
|
+
if (IMAGE_EXT.test(assetPath)) return "image";
|
|
9
|
+
if (VIDEO_EXT.test(assetPath)) return "video";
|
|
10
|
+
if (AUDIO_EXT.test(assetPath)) return "audio";
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function buildTimelineAssetId(assetPath: string, existingIds: Iterable<string>): string {
|
|
15
|
+
const baseName = assetPath.split("/").pop() ?? "asset";
|
|
16
|
+
const normalized = baseName
|
|
17
|
+
.replace(/\.[^.]+$/, "")
|
|
18
|
+
.replace(/[^a-zA-Z0-9_-]+/g, "_")
|
|
19
|
+
.replace(/^_+|_+$/g, "")
|
|
20
|
+
.toLowerCase();
|
|
21
|
+
const baseId = normalized || "asset";
|
|
22
|
+
const ids = new Set(existingIds);
|
|
23
|
+
if (!ids.has(baseId)) return baseId;
|
|
24
|
+
let suffix = 2;
|
|
25
|
+
while (ids.has(`${baseId}_${suffix}`)) suffix += 1;
|
|
26
|
+
return `${baseId}_${suffix}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolveTimelineAssetSrc(targetPath: string, assetPath: string): string {
|
|
30
|
+
const targetDir = targetPath.includes("/")
|
|
31
|
+
? targetPath.slice(0, targetPath.lastIndexOf("/"))
|
|
32
|
+
: "";
|
|
33
|
+
if (!targetDir) return assetPath;
|
|
34
|
+
|
|
35
|
+
const fromParts = targetDir.split("/").filter(Boolean);
|
|
36
|
+
const toParts = assetPath.split("/").filter(Boolean);
|
|
37
|
+
while (fromParts.length > 0 && toParts.length > 0 && fromParts[0] === toParts[0]) {
|
|
38
|
+
fromParts.shift();
|
|
39
|
+
toParts.shift();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const up = fromParts.map(() => "..");
|
|
43
|
+
const relative = [...up, ...toParts].join("/");
|
|
44
|
+
return relative || assetPath.split("/").pop() || assetPath;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildTimelineFileDropPlacements(
|
|
48
|
+
placement: { start: number; track: number },
|
|
49
|
+
count: number,
|
|
50
|
+
): Array<{ start: number; track: number }> {
|
|
51
|
+
return Array.from({ length: Math.max(0, count) }, (_, index) => ({
|
|
52
|
+
start: placement.start,
|
|
53
|
+
track: placement.track + index,
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function buildTimelineAssetInsertHtml(input: {
|
|
58
|
+
id: string;
|
|
59
|
+
assetPath: string;
|
|
60
|
+
kind: TimelineAssetKind;
|
|
61
|
+
start: number;
|
|
62
|
+
duration: number;
|
|
63
|
+
track: number;
|
|
64
|
+
zIndex: number;
|
|
65
|
+
}): string {
|
|
66
|
+
const sharedAttrs = `id="${input.id}" class="clip" src="${input.assetPath}" data-start="${input.start}" data-duration="${input.duration}" data-track-index="${input.track}"`;
|
|
67
|
+
|
|
68
|
+
if (input.kind === "image") {
|
|
69
|
+
return `<img ${sharedAttrs} style="position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; z-index: ${input.zIndex}" />`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (input.kind === "video") {
|
|
73
|
+
return `<video ${sharedAttrs} muted playsinline style="position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; z-index: ${input.zIndex}"></video>`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return `<audio ${sharedAttrs} style="z-index: ${input.zIndex}"></audio>`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function insertTimelineAssetIntoSource(source: string, assetHtml: string): string {
|
|
80
|
+
const rootOpenTag = /<[^>]*data-composition-id="[^"]+"[^>]*>/i;
|
|
81
|
+
const match = rootOpenTag.exec(source);
|
|
82
|
+
if (!match || match.index == null) {
|
|
83
|
+
throw new Error("No composition root found in target source");
|
|
84
|
+
}
|
|
85
|
+
const insertAt = match.index + match[0].length;
|
|
86
|
+
return `${source.slice(0, insertAt)}${assetHtml}${source.slice(insertAt)}`;
|
|
87
|
+
}
|