@hyperframes/studio 0.6.26 → 0.6.28
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-EdfhuQ5T.js +362 -0
- package/dist/index.html +1 -1
- package/package.json +5 -4
- package/src/App.tsx +13 -13
- package/src/components/editor/domEditing.test.ts +23 -0
- package/src/components/editor/domEditingElement.ts +18 -31
- package/src/hooks/useDomEditSession.ts +0 -5
- package/src/hooks/usePreviewInteraction.ts +2 -40
- package/src/hooks/{useManifestPersistence.ts → usePreviewPersistence.ts} +13 -6
- package/src/hooks/useTimelineEditing.ts +170 -99
- package/src/player/components/TimelineCanvas.tsx +2 -0
- package/src/player/components/TimelineClip.tsx +4 -2
- package/src/player/components/timelineEditing.test.ts +54 -42
- package/src/player/components/timelineEditing.ts +2 -14
- package/src/player/hooks/useTimelinePlayer.ts +28 -1
- package/src/player/lib/mediaProbe.ts +68 -0
- package/src/utils/studioPreviewHelpers.ts +3 -69
- package/dist/assets/index-DZWPbGBw.js +0 -116
|
@@ -38,6 +38,8 @@ interface UseTimelineEditingOptions {
|
|
|
38
38
|
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
39
39
|
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
40
40
|
reloadPreview: () => void;
|
|
41
|
+
previewIframeRef: React.RefObject<HTMLIFrameElement | null>;
|
|
42
|
+
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
|
|
41
43
|
uploadProjectFiles: (files: Iterable<File>, dir?: string) => Promise<string[]>;
|
|
42
44
|
}
|
|
43
45
|
|
|
@@ -53,6 +55,97 @@ function buildPatchTarget(element: { domId?: string; selector?: string; selector
|
|
|
53
55
|
return null;
|
|
54
56
|
}
|
|
55
57
|
|
|
58
|
+
// The runtime re-reads data-start/data-duration from the DOM on each sync tick
|
|
59
|
+
// (packages/core/src/runtime/init.ts:1324-1368), so attribute mutations here are
|
|
60
|
+
// picked up automatically on the next frame without a rebind call.
|
|
61
|
+
function patchIframeDomTiming(
|
|
62
|
+
iframe: HTMLIFrameElement | null,
|
|
63
|
+
element: TimelineElement,
|
|
64
|
+
attrs: Array<[string, string]>,
|
|
65
|
+
): void {
|
|
66
|
+
try {
|
|
67
|
+
const doc = iframe?.contentDocument;
|
|
68
|
+
if (!doc) return;
|
|
69
|
+
const el = element.domId
|
|
70
|
+
? doc.getElementById(element.domId)
|
|
71
|
+
: element.selector
|
|
72
|
+
? (doc.querySelectorAll(element.selector)[element.selectorIndex ?? 0] ?? null)
|
|
73
|
+
: null;
|
|
74
|
+
if (!el) return;
|
|
75
|
+
for (const [name, value] of attrs) el.setAttribute(name, value);
|
|
76
|
+
} catch {
|
|
77
|
+
// Cross-origin or mid-navigation — file save is enqueued; iframe patch is best-effort.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resolveResizePlaybackStart(
|
|
82
|
+
original: string,
|
|
83
|
+
target: PatchTarget,
|
|
84
|
+
element: TimelineElement,
|
|
85
|
+
updates: Pick<TimelineElement, "start" | "playbackStart">,
|
|
86
|
+
): { attrName: string; value: number } | null {
|
|
87
|
+
if (updates.playbackStart != null) {
|
|
88
|
+
const attrName =
|
|
89
|
+
element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
|
|
90
|
+
return { attrName, value: updates.playbackStart };
|
|
91
|
+
}
|
|
92
|
+
const trimDelta = updates.start - element.start;
|
|
93
|
+
if (trimDelta === 0) return null;
|
|
94
|
+
const raw =
|
|
95
|
+
readAttributeByTarget(original, target, "playback-start") ??
|
|
96
|
+
readAttributeByTarget(original, target, "media-start");
|
|
97
|
+
const current = raw != null ? parseFloat(raw) : undefined;
|
|
98
|
+
if (current == null || !Number.isFinite(current)) return null;
|
|
99
|
+
const attrName =
|
|
100
|
+
element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
|
|
101
|
+
return {
|
|
102
|
+
attrName,
|
|
103
|
+
value: Math.max(0, current + trimDelta * Math.max(element.playbackRate ?? 1, 0.1)),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
type PatchTarget = NonNullable<ReturnType<typeof buildPatchTarget>>;
|
|
108
|
+
|
|
109
|
+
interface PersistTimelineEditInput {
|
|
110
|
+
projectId: string;
|
|
111
|
+
element: TimelineElement;
|
|
112
|
+
activeCompPath: string | null;
|
|
113
|
+
label: string;
|
|
114
|
+
buildPatches: (original: string, target: PatchTarget) => string;
|
|
115
|
+
writeProjectFile: (path: string, content: string) => Promise<void>;
|
|
116
|
+
recordEdit: (input: RecordEditInput) => Promise<void>;
|
|
117
|
+
domEditSaveTimestampRef: React.MutableRefObject<number>;
|
|
118
|
+
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function persistTimelineEdit(input: PersistTimelineEditInput): Promise<void> {
|
|
122
|
+
const targetPath = input.element.sourceFile || input.activeCompPath || "index.html";
|
|
123
|
+
const originalContent = await readFileContent(input.projectId, targetPath);
|
|
124
|
+
|
|
125
|
+
const patchTarget = buildPatchTarget(input.element);
|
|
126
|
+
if (!patchTarget) {
|
|
127
|
+
throw new Error(`Timeline element ${input.element.id} is missing a patchable target`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const patchedContent = input.buildPatches(originalContent, patchTarget);
|
|
131
|
+
if (patchedContent === originalContent) {
|
|
132
|
+
throw new Error(`Unable to patch timeline element ${input.element.id} in ${targetPath}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
input.pendingTimelineEditPathRef.current.add(targetPath);
|
|
136
|
+
input.domEditSaveTimestampRef.current = Date.now();
|
|
137
|
+
await saveProjectFilesWithHistory({
|
|
138
|
+
projectId: input.projectId,
|
|
139
|
+
label: input.label,
|
|
140
|
+
kind: "timeline",
|
|
141
|
+
files: { [targetPath]: patchedContent },
|
|
142
|
+
readFile: async () => originalContent,
|
|
143
|
+
writeFile: input.writeProjectFile,
|
|
144
|
+
recordEdit: input.recordEdit,
|
|
145
|
+
});
|
|
146
|
+
input.domEditSaveTimestampRef.current = Date.now();
|
|
147
|
+
}
|
|
148
|
+
|
|
56
149
|
async function readFileContent(projectId: string, targetPath: string): Promise<string> {
|
|
57
150
|
const response = await fetch(
|
|
58
151
|
`/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`,
|
|
@@ -78,127 +171,105 @@ export function useTimelineEditing({
|
|
|
78
171
|
recordEdit,
|
|
79
172
|
domEditSaveTimestampRef,
|
|
80
173
|
reloadPreview,
|
|
174
|
+
previewIframeRef,
|
|
175
|
+
pendingTimelineEditPathRef,
|
|
81
176
|
uploadProjectFiles,
|
|
82
177
|
}: UseTimelineEditingOptions) {
|
|
83
178
|
const projectIdRef = useRef(projectId);
|
|
84
179
|
projectIdRef.current = projectId;
|
|
85
180
|
|
|
181
|
+
const editQueueRef = useRef(Promise.resolve());
|
|
86
182
|
const lastBlockedTimelineToastAtRef = useRef(0);
|
|
87
183
|
|
|
88
|
-
const
|
|
89
|
-
|
|
184
|
+
const enqueueEdit = useCallback(
|
|
185
|
+
(
|
|
186
|
+
element: TimelineElement,
|
|
187
|
+
label: string,
|
|
188
|
+
buildPatches: PersistTimelineEditInput["buildPatches"],
|
|
189
|
+
): Promise<void> => {
|
|
90
190
|
const pid = projectIdRef.current;
|
|
91
|
-
if (!pid)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
type: "attribute",
|
|
108
|
-
property: "track-index",
|
|
109
|
-
value: String(updates.track),
|
|
191
|
+
if (!pid) return Promise.resolve();
|
|
192
|
+
const queued = editQueueRef.current.then(() =>
|
|
193
|
+
persistTimelineEdit({
|
|
194
|
+
projectId: pid,
|
|
195
|
+
element,
|
|
196
|
+
activeCompPath,
|
|
197
|
+
label,
|
|
198
|
+
buildPatches,
|
|
199
|
+
writeProjectFile,
|
|
200
|
+
recordEdit,
|
|
201
|
+
domEditSaveTimestampRef,
|
|
202
|
+
pendingTimelineEditPathRef,
|
|
203
|
+
}),
|
|
204
|
+
);
|
|
205
|
+
editQueueRef.current = queued.catch((error) => {
|
|
206
|
+
console.error(`[Timeline] Failed to persist: ${label}`, error);
|
|
110
207
|
});
|
|
208
|
+
return queued;
|
|
209
|
+
},
|
|
210
|
+
[
|
|
211
|
+
activeCompPath,
|
|
212
|
+
recordEdit,
|
|
213
|
+
writeProjectFile,
|
|
214
|
+
domEditSaveTimestampRef,
|
|
215
|
+
pendingTimelineEditPathRef,
|
|
216
|
+
],
|
|
217
|
+
);
|
|
111
218
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
219
|
+
const handleTimelineElementMove = useCallback(
|
|
220
|
+
(element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
|
|
221
|
+
patchIframeDomTiming(previewIframeRef.current, element, [
|
|
222
|
+
["data-start", formatTimelineAttributeNumber(updates.start)],
|
|
223
|
+
["data-track-index", String(updates.track)],
|
|
224
|
+
]);
|
|
225
|
+
return enqueueEdit(element, "Move timeline clip", (original, target) => {
|
|
226
|
+
let patched = applyPatchByTarget(original, target, {
|
|
227
|
+
type: "attribute",
|
|
228
|
+
property: "start",
|
|
229
|
+
value: formatTimelineAttributeNumber(updates.start),
|
|
230
|
+
});
|
|
231
|
+
return applyPatchByTarget(patched, target, {
|
|
232
|
+
type: "attribute",
|
|
233
|
+
property: "track-index",
|
|
234
|
+
value: String(updates.track),
|
|
235
|
+
});
|
|
125
236
|
});
|
|
126
|
-
|
|
127
|
-
reloadPreview();
|
|
128
237
|
},
|
|
129
|
-
[
|
|
238
|
+
[previewIframeRef, enqueueEdit],
|
|
130
239
|
);
|
|
131
240
|
|
|
132
241
|
const handleTimelineElementResize = useCallback(
|
|
133
|
-
|
|
242
|
+
(
|
|
134
243
|
element: TimelineElement,
|
|
135
244
|
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
136
245
|
) => {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
if (!patchTarget) {
|
|
145
|
-
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const playbackStartAttrName =
|
|
149
|
-
element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
|
|
150
|
-
const currentPlaybackStartValue =
|
|
151
|
-
readAttributeByTarget(originalContent, patchTarget, "playback-start") ??
|
|
152
|
-
readAttributeByTarget(originalContent, patchTarget, "media-start");
|
|
153
|
-
const currentPlaybackStart =
|
|
154
|
-
currentPlaybackStartValue != null ? parseFloat(currentPlaybackStartValue) : undefined;
|
|
155
|
-
const trimDelta = updates.start - element.start;
|
|
156
|
-
const fallbackPlaybackStart =
|
|
157
|
-
updates.playbackStart == null &&
|
|
158
|
-
trimDelta !== 0 &&
|
|
159
|
-
Number.isFinite(currentPlaybackStart) &&
|
|
160
|
-
currentPlaybackStart != null
|
|
161
|
-
? Math.max(0, currentPlaybackStart + trimDelta * Math.max(element.playbackRate ?? 1, 0.1))
|
|
162
|
-
: undefined;
|
|
163
|
-
const nextPlaybackStart = updates.playbackStart ?? fallbackPlaybackStart;
|
|
164
|
-
|
|
165
|
-
let patchedContent = originalContent;
|
|
166
|
-
patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
|
|
167
|
-
type: "attribute",
|
|
168
|
-
property: "start",
|
|
169
|
-
value: formatTimelineAttributeNumber(updates.start),
|
|
170
|
-
});
|
|
171
|
-
patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
|
|
172
|
-
type: "attribute",
|
|
173
|
-
property: "duration",
|
|
174
|
-
value: formatTimelineAttributeNumber(updates.duration),
|
|
175
|
-
});
|
|
176
|
-
if (nextPlaybackStart != null) {
|
|
177
|
-
patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
|
|
246
|
+
patchIframeDomTiming(previewIframeRef.current, element, [
|
|
247
|
+
["data-start", formatTimelineAttributeNumber(updates.start)],
|
|
248
|
+
["data-duration", formatTimelineAttributeNumber(updates.duration)],
|
|
249
|
+
]);
|
|
250
|
+
return enqueueEdit(element, "Resize timeline clip", (original, target) => {
|
|
251
|
+
const pbs = resolveResizePlaybackStart(original, target, element, updates);
|
|
252
|
+
let patched = applyPatchByTarget(original, target, {
|
|
178
253
|
type: "attribute",
|
|
179
|
-
property:
|
|
180
|
-
value: formatTimelineAttributeNumber(
|
|
254
|
+
property: "start",
|
|
255
|
+
value: formatTimelineAttributeNumber(updates.start),
|
|
181
256
|
});
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
writeFile: writeProjectFile,
|
|
196
|
-
recordEdit,
|
|
257
|
+
patched = applyPatchByTarget(patched, target, {
|
|
258
|
+
type: "attribute",
|
|
259
|
+
property: "duration",
|
|
260
|
+
value: formatTimelineAttributeNumber(updates.duration),
|
|
261
|
+
});
|
|
262
|
+
if (pbs) {
|
|
263
|
+
patched = applyPatchByTarget(patched, target, {
|
|
264
|
+
type: "attribute",
|
|
265
|
+
property: pbs.attrName,
|
|
266
|
+
value: formatTimelineAttributeNumber(pbs.value),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
return patched;
|
|
197
270
|
});
|
|
198
|
-
|
|
199
|
-
reloadPreview();
|
|
200
271
|
},
|
|
201
|
-
[
|
|
272
|
+
[previewIframeRef, enqueueEdit],
|
|
202
273
|
);
|
|
203
274
|
|
|
204
275
|
const handleTimelineElementDelete = useCallback(
|
|
@@ -252,6 +252,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
252
252
|
isHovered={hoveredClip === clipKey}
|
|
253
253
|
isDragging={false}
|
|
254
254
|
hasCustomContent={!!renderClipContent}
|
|
255
|
+
capabilities={capabilities}
|
|
255
256
|
theme={theme}
|
|
256
257
|
trackStyle={clipStyle}
|
|
257
258
|
isComposition={isComposition}
|
|
@@ -369,6 +370,7 @@ export const TimelineCanvas = memo(function TimelineCanvas({
|
|
|
369
370
|
isHovered={false}
|
|
370
371
|
isDragging={true}
|
|
371
372
|
hasCustomContent={!!renderClipContent}
|
|
373
|
+
capabilities={getTimelineEditCapabilities(activeDraggedElement)}
|
|
372
374
|
theme={theme}
|
|
373
375
|
trackStyle={getTrackStyle(activeDraggedElement.tag)}
|
|
374
376
|
isComposition={!!activeDraggedElement.compositionSrc}
|
|
@@ -3,7 +3,7 @@ import type { TimelineTrackStyle } from "./timelineTheme";
|
|
|
3
3
|
import { memo, type ReactNode } from "react";
|
|
4
4
|
import type { TimelineElement } from "../store/playerStore";
|
|
5
5
|
import { defaultTimelineTheme, getClipHandleOpacity, type TimelineTheme } from "./timelineTheme";
|
|
6
|
-
import {
|
|
6
|
+
import type { TimelineEditCapabilities } from "./timelineEditing";
|
|
7
7
|
|
|
8
8
|
interface TimelineClipProps {
|
|
9
9
|
el: TimelineElement;
|
|
@@ -13,6 +13,7 @@ interface TimelineClipProps {
|
|
|
13
13
|
isHovered: boolean;
|
|
14
14
|
isDragging?: boolean;
|
|
15
15
|
hasCustomContent: boolean;
|
|
16
|
+
capabilities: TimelineEditCapabilities;
|
|
16
17
|
theme?: TimelineTheme;
|
|
17
18
|
trackStyle: TimelineTrackStyle;
|
|
18
19
|
isComposition: boolean;
|
|
@@ -33,6 +34,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
33
34
|
isHovered,
|
|
34
35
|
isDragging = false,
|
|
35
36
|
hasCustomContent,
|
|
37
|
+
capabilities,
|
|
36
38
|
theme = defaultTimelineTheme,
|
|
37
39
|
trackStyle,
|
|
38
40
|
isComposition,
|
|
@@ -47,6 +49,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
47
49
|
const leftPx = el.start * pps;
|
|
48
50
|
const widthPx = Math.max(el.duration * pps, 4);
|
|
49
51
|
const handleOpacity = getClipHandleOpacity({ isHovered, isSelected, isDragging });
|
|
52
|
+
|
|
50
53
|
const borderColor = isSelected
|
|
51
54
|
? theme.clipBorderActive
|
|
52
55
|
: isHovered
|
|
@@ -59,7 +62,6 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
59
62
|
: isHovered
|
|
60
63
|
? theme.clipShadowHover
|
|
61
64
|
: theme.clipShadow;
|
|
62
|
-
const capabilities = getTimelineEditCapabilities(el);
|
|
63
65
|
const displayLabel = el.label || el.id || el.tag;
|
|
64
66
|
const showHandles = handleOpacity > 0.01;
|
|
65
67
|
|
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
buildPromptCopyText,
|
|
5
5
|
buildTimelineElementAgentPrompt,
|
|
6
6
|
buildTimelineAgentPrompt,
|
|
7
|
-
canOffsetTrimClipStart,
|
|
8
7
|
getTimelineEditCapabilities,
|
|
9
8
|
hasPatchableTimelineTarget,
|
|
10
9
|
resolveBlockedTimelineEditIntent,
|
|
@@ -158,42 +157,6 @@ describe("resolveTimelineMove", () => {
|
|
|
158
157
|
});
|
|
159
158
|
});
|
|
160
159
|
|
|
161
|
-
describe("canOffsetTrimClipStart", () => {
|
|
162
|
-
it("allows front trim for clips that carry playback offset metadata", () => {
|
|
163
|
-
expect(
|
|
164
|
-
canOffsetTrimClipStart({
|
|
165
|
-
tag: "div",
|
|
166
|
-
playbackStartAttr: "media-start",
|
|
167
|
-
}),
|
|
168
|
-
).toBe(true);
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it("allows front trim for media clips with source duration metadata", () => {
|
|
172
|
-
expect(
|
|
173
|
-
canOffsetTrimClipStart({
|
|
174
|
-
tag: "video",
|
|
175
|
-
sourceDuration: 12,
|
|
176
|
-
}),
|
|
177
|
-
).toBe(true);
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it("allows front trim for plain audio clips even before media-start exists", () => {
|
|
181
|
-
expect(
|
|
182
|
-
canOffsetTrimClipStart({
|
|
183
|
-
tag: "audio",
|
|
184
|
-
}),
|
|
185
|
-
).toBe(true);
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it("blocks front trim for generic motion clips", () => {
|
|
189
|
-
expect(
|
|
190
|
-
canOffsetTrimClipStart({
|
|
191
|
-
tag: "section",
|
|
192
|
-
}),
|
|
193
|
-
).toBe(false);
|
|
194
|
-
});
|
|
195
|
-
});
|
|
196
|
-
|
|
197
160
|
describe("hasPatchableTimelineTarget", () => {
|
|
198
161
|
it("returns true when the clip has a DOM id", () => {
|
|
199
162
|
expect(hasPatchableTimelineTarget({ domId: "hero-card" })).toBe(true);
|
|
@@ -224,7 +187,7 @@ describe("getTimelineEditCapabilities", () => {
|
|
|
224
187
|
});
|
|
225
188
|
});
|
|
226
189
|
|
|
227
|
-
it("allows
|
|
190
|
+
it("allows full editing of generic motion clips with authored timing", () => {
|
|
228
191
|
expect(
|
|
229
192
|
getTimelineEditCapabilities({
|
|
230
193
|
tag: "section",
|
|
@@ -233,8 +196,8 @@ describe("getTimelineEditCapabilities", () => {
|
|
|
233
196
|
}),
|
|
234
197
|
).toEqual({
|
|
235
198
|
canMove: true,
|
|
236
|
-
canTrimStart:
|
|
237
|
-
canTrimEnd:
|
|
199
|
+
canTrimStart: true,
|
|
200
|
+
canTrimEnd: true,
|
|
238
201
|
});
|
|
239
202
|
});
|
|
240
203
|
|
|
@@ -285,7 +248,7 @@ describe("getTimelineEditCapabilities", () => {
|
|
|
285
248
|
});
|
|
286
249
|
});
|
|
287
250
|
|
|
288
|
-
it("allows
|
|
251
|
+
it("allows full editing for patchable composition hosts", () => {
|
|
289
252
|
expect(
|
|
290
253
|
getTimelineEditCapabilities({
|
|
291
254
|
tag: "div",
|
|
@@ -295,7 +258,22 @@ describe("getTimelineEditCapabilities", () => {
|
|
|
295
258
|
}),
|
|
296
259
|
).toEqual({
|
|
297
260
|
canMove: true,
|
|
298
|
-
canTrimStart:
|
|
261
|
+
canTrimStart: true,
|
|
262
|
+
canTrimEnd: true,
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("allows full editing of explicitly authored generic elements", () => {
|
|
267
|
+
expect(
|
|
268
|
+
getTimelineEditCapabilities({
|
|
269
|
+
tag: "div",
|
|
270
|
+
duration: 4,
|
|
271
|
+
selector: "#hero-card",
|
|
272
|
+
timingSource: "authored",
|
|
273
|
+
}),
|
|
274
|
+
).toEqual({
|
|
275
|
+
canMove: true,
|
|
276
|
+
canTrimStart: true,
|
|
299
277
|
canTrimEnd: true,
|
|
300
278
|
});
|
|
301
279
|
});
|
|
@@ -576,6 +554,40 @@ describe("resolveTimelineResize", () => {
|
|
|
576
554
|
),
|
|
577
555
|
).toEqual({ start: 0.8, duration: 3.2, playbackStart: 0 });
|
|
578
556
|
});
|
|
557
|
+
|
|
558
|
+
it("trims generic element start without media offset", () => {
|
|
559
|
+
expect(
|
|
560
|
+
resolveTimelineResize(
|
|
561
|
+
{
|
|
562
|
+
start: 2,
|
|
563
|
+
duration: 4,
|
|
564
|
+
originClientX: 100,
|
|
565
|
+
pixelsPerSecond: 100,
|
|
566
|
+
minStart: 0,
|
|
567
|
+
maxEnd: 10,
|
|
568
|
+
},
|
|
569
|
+
"start",
|
|
570
|
+
200,
|
|
571
|
+
),
|
|
572
|
+
).toEqual({ start: 3, duration: 3, playbackStart: undefined });
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it("extends generic element start leftward to time zero", () => {
|
|
576
|
+
expect(
|
|
577
|
+
resolveTimelineResize(
|
|
578
|
+
{
|
|
579
|
+
start: 1,
|
|
580
|
+
duration: 3,
|
|
581
|
+
originClientX: 100,
|
|
582
|
+
pixelsPerSecond: 100,
|
|
583
|
+
minStart: 0,
|
|
584
|
+
maxEnd: 10,
|
|
585
|
+
},
|
|
586
|
+
"start",
|
|
587
|
+
-200,
|
|
588
|
+
),
|
|
589
|
+
).toEqual({ start: 0, duration: 4, playbackStart: undefined });
|
|
590
|
+
});
|
|
579
591
|
});
|
|
580
592
|
|
|
581
593
|
describe("buildPromptCopyText", () => {
|
|
@@ -201,18 +201,6 @@ export function hasPatchableTimelineTarget(input: { domId?: string; selector?: s
|
|
|
201
201
|
return Boolean(input.domId || input.selector);
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
-
export function canOffsetTrimClipStart(input: {
|
|
205
|
-
tag: string;
|
|
206
|
-
playbackStart?: number;
|
|
207
|
-
playbackStartAttr?: "media-start" | "playback-start";
|
|
208
|
-
sourceDuration?: number;
|
|
209
|
-
}): boolean {
|
|
210
|
-
if (input.playbackStartAttr != null) return true;
|
|
211
|
-
if (input.playbackStart != null) return true;
|
|
212
|
-
const normalizedTag = input.tag.toLowerCase();
|
|
213
|
-
return ["video", "audio"].includes(normalizedTag);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
204
|
export function getTimelineEditCapabilities(input: {
|
|
217
205
|
tag: string;
|
|
218
206
|
duration: number;
|
|
@@ -237,8 +225,8 @@ export function getTimelineEditCapabilities(input: {
|
|
|
237
225
|
const hasDeterministicWindow = isDeterministicTimelineWindow(input);
|
|
238
226
|
return {
|
|
239
227
|
canMove: canPatch && (hasDeterministicWindow || hasFiniteDuration),
|
|
240
|
-
canTrimEnd: canPatch && hasFiniteDuration
|
|
241
|
-
canTrimStart: canPatch && hasFiniteDuration
|
|
228
|
+
canTrimEnd: canPatch && hasFiniteDuration,
|
|
229
|
+
canTrimStart: canPatch && hasFiniteDuration,
|
|
242
230
|
};
|
|
243
231
|
}
|
|
244
232
|
|
|
@@ -7,7 +7,7 @@ import { useTimelineSyncCallbacks } from "./useTimelineSyncCallbacks";
|
|
|
7
7
|
// Re-export public API consumed by tests and external modules.
|
|
8
8
|
// All of these were previously defined in this file; they now live in focused
|
|
9
9
|
// sub-modules but are re-exported here so existing import sites don't change.
|
|
10
|
-
export type {
|
|
10
|
+
export type { ClipManifestClip } from "../lib/playbackTypes";
|
|
11
11
|
export { createStaticSeekPlaybackAdapter } from "../lib/playbackAdapter";
|
|
12
12
|
export {
|
|
13
13
|
getTimelineElementSelector,
|
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
setPreviewPlaybackRate,
|
|
43
43
|
shouldMutePreviewAudio,
|
|
44
44
|
} from "../lib/timelineIframeHelpers";
|
|
45
|
+
import { probeMediaUrl, getCachedProbe } from "../lib/mediaProbe";
|
|
45
46
|
|
|
46
47
|
// ---------------------------------------------------------------------------
|
|
47
48
|
// Hook
|
|
@@ -106,6 +107,32 @@ export function useTimelinePlayer() {
|
|
|
106
107
|
if (!state.timelineReady) {
|
|
107
108
|
setTimelineReady(true);
|
|
108
109
|
}
|
|
110
|
+
|
|
111
|
+
// Asynchronously enrich media elements missing sourceDuration via mediabunny.
|
|
112
|
+
// The probe reads file headers only — no full decode — so this is cheap.
|
|
113
|
+
const needsProbe = mergedElements.filter(
|
|
114
|
+
(el) =>
|
|
115
|
+
el.src &&
|
|
116
|
+
el.sourceDuration == null &&
|
|
117
|
+
["video", "audio"].includes(el.tag.toLowerCase()) &&
|
|
118
|
+
!getCachedProbe(el.src),
|
|
119
|
+
);
|
|
120
|
+
if (needsProbe.length > 0) {
|
|
121
|
+
void Promise.allSettled(
|
|
122
|
+
needsProbe.map(async (el) => {
|
|
123
|
+
const result = await probeMediaUrl(el.src!);
|
|
124
|
+
if (!result) return;
|
|
125
|
+
const key = el.key ?? el.id;
|
|
126
|
+
usePlayerStore.setState((state) => {
|
|
127
|
+
const idx = state.elements.findIndex((e) => (e.key ?? e.id) === key);
|
|
128
|
+
if (idx === -1 || state.elements[idx].sourceDuration != null) return {};
|
|
129
|
+
const patched = state.elements.slice();
|
|
130
|
+
patched[idx] = { ...state.elements[idx], sourceDuration: result.duration };
|
|
131
|
+
return { elements: patched };
|
|
132
|
+
});
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
109
136
|
},
|
|
110
137
|
[setElements, setTimelineReady, setDuration],
|
|
111
138
|
);
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Input, UrlSource, ALL_FORMATS } from "mediabunny";
|
|
2
|
+
|
|
3
|
+
export interface MediaProbeResult {
|
|
4
|
+
duration: number;
|
|
5
|
+
width?: number;
|
|
6
|
+
height?: number;
|
|
7
|
+
hasVideo: boolean;
|
|
8
|
+
hasAudio: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const cache = new Map<string, MediaProbeResult>();
|
|
12
|
+
const inflight = new Map<string, Promise<MediaProbeResult | null>>();
|
|
13
|
+
|
|
14
|
+
function normalizeUrl(url: string): string {
|
|
15
|
+
try {
|
|
16
|
+
return new URL(url, window.location.href).href;
|
|
17
|
+
} catch {
|
|
18
|
+
return url;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function probeOne(url: string): Promise<MediaProbeResult | null> {
|
|
23
|
+
const input = new Input({
|
|
24
|
+
source: new UrlSource(url),
|
|
25
|
+
formats: ALL_FORMATS,
|
|
26
|
+
});
|
|
27
|
+
try {
|
|
28
|
+
const duration = await input.getDurationFromMetadata();
|
|
29
|
+
if (duration == null || !Number.isFinite(duration) || duration <= 0) return null;
|
|
30
|
+
|
|
31
|
+
const videoTrack = await input.getPrimaryVideoTrack();
|
|
32
|
+
const audioTracks = await input.getAudioTracks();
|
|
33
|
+
|
|
34
|
+
const result: MediaProbeResult = {
|
|
35
|
+
duration,
|
|
36
|
+
width: videoTrack?.displayWidth,
|
|
37
|
+
height: videoTrack?.displayHeight,
|
|
38
|
+
hasVideo: videoTrack != null,
|
|
39
|
+
hasAudio: audioTracks.length > 0,
|
|
40
|
+
};
|
|
41
|
+
return result;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
} finally {
|
|
45
|
+
input.dispose();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getCachedProbe(url: string): MediaProbeResult | undefined {
|
|
50
|
+
return cache.get(normalizeUrl(url));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function probeMediaUrl(url: string): Promise<MediaProbeResult | null> {
|
|
54
|
+
const key = normalizeUrl(url);
|
|
55
|
+
const cached = cache.get(key);
|
|
56
|
+
if (cached) return cached;
|
|
57
|
+
|
|
58
|
+
let pending = inflight.get(key);
|
|
59
|
+
if (pending) return pending;
|
|
60
|
+
|
|
61
|
+
pending = probeOne(key).then((result) => {
|
|
62
|
+
inflight.delete(key);
|
|
63
|
+
if (result) cache.set(key, result);
|
|
64
|
+
return result;
|
|
65
|
+
});
|
|
66
|
+
inflight.set(key, pending);
|
|
67
|
+
return pending;
|
|
68
|
+
}
|