@hyperframes/studio 0.5.7 → 0.6.0-alpha.10
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-14zH9lqh.css +1 -0
- package/dist/assets/index-B-16fRnH.js +108 -0
- package/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/App.tsx +2965 -186
- package/src/components/LintModal.tsx +3 -4
- package/src/components/editor/DomEditOverlay.test.ts +241 -0
- package/src/components/editor/DomEditOverlay.tsx +1300 -0
- package/src/components/editor/MotionPanel.tsx +651 -0
- package/src/components/editor/PropertyPanel.test.ts +116 -0
- package/src/components/editor/PropertyPanel.tsx +2829 -205
- package/src/components/editor/TimelineLayerPanel.test.ts +42 -0
- package/src/components/editor/TimelineLayerPanel.tsx +113 -0
- package/src/components/editor/colorValue.test.ts +82 -0
- package/src/components/editor/colorValue.ts +175 -0
- package/src/components/editor/domEditing.test.ts +1120 -0
- package/src/components/editor/domEditing.ts +1117 -0
- package/src/components/editor/floatingPanel.test.ts +34 -0
- package/src/components/editor/floatingPanel.ts +54 -0
- package/src/components/editor/fontAssets.ts +32 -0
- package/src/components/editor/fontCatalog.ts +126 -0
- package/src/components/editor/gradientValue.test.ts +89 -0
- package/src/components/editor/gradientValue.ts +445 -0
- package/src/components/editor/manualEditingAvailability.test.ts +131 -0
- package/src/components/editor/manualEditingAvailability.ts +62 -0
- package/src/components/editor/manualEdits.test.ts +945 -0
- package/src/components/editor/manualEdits.ts +1409 -0
- package/src/components/editor/manualOffsetDrag.test.ts +140 -0
- package/src/components/editor/manualOffsetDrag.ts +307 -0
- package/src/components/editor/studioMotion.test.ts +355 -0
- package/src/components/editor/studioMotion.ts +632 -0
- package/src/components/nle/NLELayout.test.ts +12 -0
- package/src/components/nle/NLELayout.tsx +84 -22
- package/src/components/nle/NLEPreview.tsx +56 -5
- package/src/components/renders/RenderQueue.tsx +24 -11
- package/src/components/sidebar/AssetsTab.tsx +3 -4
- package/src/components/sidebar/CompositionsTab.test.ts +16 -1
- package/src/components/sidebar/CompositionsTab.tsx +117 -45
- package/src/components/sidebar/LeftSidebar.tsx +194 -179
- package/src/hooks/usePersistentEditHistory.test.ts +256 -0
- package/src/hooks/usePersistentEditHistory.ts +337 -0
- package/src/icons/SystemIcons.tsx +2 -0
- package/src/player/components/CompositionThumbnail.test.ts +19 -0
- package/src/player/components/CompositionThumbnail.tsx +50 -13
- package/src/player/components/EditModal.tsx +5 -20
- package/src/player/components/Player.test.ts +58 -0
- package/src/player/components/Player.tsx +88 -5
- package/src/player/components/PlayerControls.tsx +20 -7
- package/src/player/components/Timeline.test.ts +20 -0
- package/src/player/components/Timeline.tsx +147 -40
- package/src/player/components/TimelineClip.test.ts +92 -0
- package/src/player/components/TimelineClip.tsx +241 -7
- package/src/player/components/timelineEditing.test.ts +16 -3
- package/src/player/components/timelineEditing.ts +10 -3
- package/src/player/hooks/useTimelinePlayer.test.ts +148 -19
- package/src/player/hooks/useTimelinePlayer.ts +287 -16
- package/src/player/store/playerStore.ts +2 -0
- package/src/utils/clipboard.test.ts +89 -0
- package/src/utils/clipboard.ts +57 -0
- package/src/utils/editHistory.test.ts +244 -0
- package/src/utils/editHistory.ts +218 -0
- package/src/utils/editHistoryStorage.test.ts +37 -0
- package/src/utils/editHistoryStorage.ts +99 -0
- package/src/utils/mediaTypes.ts +1 -1
- package/src/utils/sourcePatcher.test.ts +128 -1
- package/src/utils/sourcePatcher.ts +130 -18
- package/src/utils/studioFileHistory.test.ts +156 -0
- package/src/utils/studioFileHistory.ts +61 -0
- package/src/utils/timelineAssetDrop.test.ts +31 -11
- package/src/utils/timelineAssetDrop.ts +22 -2
- package/src/utils/timelineDiscovery.ts +1 -1
- package/src/utils/timelineInspector.test.ts +79 -0
- package/src/utils/timelineInspector.ts +116 -0
- package/dist/assets/index-04Mp2wOn.css +0 -1
- package/dist/assets/index-Dcw3BoVw.js +0 -93
|
@@ -17,15 +17,67 @@ interface TimelineClipProps {
|
|
|
17
17
|
theme?: TimelineTheme;
|
|
18
18
|
trackStyle: TimelineTrackStyle;
|
|
19
19
|
isComposition: boolean;
|
|
20
|
+
isInspectorActive?: boolean;
|
|
21
|
+
isThumbnailActive?: boolean;
|
|
22
|
+
thumbnailLabel?: string;
|
|
23
|
+
childCount?: number;
|
|
20
24
|
onHoverStart: () => void;
|
|
21
25
|
onHoverEnd: () => void;
|
|
22
26
|
onPointerDown?: (e: React.PointerEvent) => void;
|
|
23
27
|
onResizeStart?: (edge: "start" | "end", e: React.PointerEvent) => void;
|
|
28
|
+
onInspectorClick?: (e: React.MouseEvent) => void;
|
|
29
|
+
onThumbnailClick?: (e: React.MouseEvent) => void;
|
|
24
30
|
onClick: (e: React.MouseEvent) => void;
|
|
25
31
|
onDoubleClick: (e: React.MouseEvent) => void;
|
|
26
32
|
children?: ReactNode;
|
|
27
33
|
}
|
|
28
34
|
|
|
35
|
+
export const TIMELINE_CLIP_CONTROL_Z_INDEX = 20;
|
|
36
|
+
|
|
37
|
+
const COMPACT_CLIP_CONTROL_WIDTH = 112;
|
|
38
|
+
|
|
39
|
+
interface TimelineClipControlPresentationInput {
|
|
40
|
+
widthPx: number;
|
|
41
|
+
isSelected: boolean;
|
|
42
|
+
isHovered: boolean;
|
|
43
|
+
isInspectorActive: boolean;
|
|
44
|
+
isThumbnailActive: boolean;
|
|
45
|
+
isDragging: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface TimelineClipControlPresentation {
|
|
49
|
+
compact: boolean;
|
|
50
|
+
showControls: boolean;
|
|
51
|
+
containerClassName: string;
|
|
52
|
+
buttonClassName: string;
|
|
53
|
+
iconSize: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function getTimelineClipControlPresentation({
|
|
57
|
+
widthPx,
|
|
58
|
+
isSelected,
|
|
59
|
+
isHovered,
|
|
60
|
+
isInspectorActive,
|
|
61
|
+
isThumbnailActive,
|
|
62
|
+
isDragging,
|
|
63
|
+
}: TimelineClipControlPresentationInput): TimelineClipControlPresentation {
|
|
64
|
+
const compact = widthPx < COMPACT_CLIP_CONTROL_WIDTH;
|
|
65
|
+
const isInteractive = isHovered || isSelected || isInspectorActive || isThumbnailActive;
|
|
66
|
+
const showControls = !isDragging && (!compact || isInteractive);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
compact,
|
|
70
|
+
showControls,
|
|
71
|
+
containerClassName: compact
|
|
72
|
+
? "absolute right-1 top-1 flex items-center gap-1"
|
|
73
|
+
: "absolute right-2 top-2 flex items-center gap-1",
|
|
74
|
+
buttonClassName: compact
|
|
75
|
+
? "flex h-5 w-5 items-center justify-center rounded-[7px]"
|
|
76
|
+
: "flex h-6 w-6 items-center justify-center rounded-md",
|
|
77
|
+
iconSize: compact ? 12 : 14,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
29
81
|
export const TimelineClip = memo(function TimelineClip({
|
|
30
82
|
el,
|
|
31
83
|
pps,
|
|
@@ -37,10 +89,16 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
37
89
|
theme = defaultTimelineTheme,
|
|
38
90
|
trackStyle,
|
|
39
91
|
isComposition,
|
|
92
|
+
isInspectorActive = false,
|
|
93
|
+
isThumbnailActive = false,
|
|
94
|
+
thumbnailLabel = "thumbnail",
|
|
95
|
+
childCount = 0,
|
|
40
96
|
onHoverStart,
|
|
41
97
|
onHoverEnd,
|
|
42
98
|
onPointerDown,
|
|
43
99
|
onResizeStart,
|
|
100
|
+
onInspectorClick,
|
|
101
|
+
onThumbnailClick,
|
|
44
102
|
onClick,
|
|
45
103
|
onDoubleClick,
|
|
46
104
|
children,
|
|
@@ -62,7 +120,38 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
62
120
|
: theme.clipShadow;
|
|
63
121
|
const capabilities = getTimelineEditCapabilities(el);
|
|
64
122
|
const displayLabel = el.label || el.id || el.tag;
|
|
123
|
+
const inspectorLabel =
|
|
124
|
+
childCount > 0
|
|
125
|
+
? `${childCount} nested selectable layer${childCount === 1 ? "" : "s"}`
|
|
126
|
+
: "Inspect clip layer";
|
|
65
127
|
const showHandles = handleOpacity > 0.01;
|
|
128
|
+
const baseBackgroundImage = isSelected ? theme.clipBackgroundActive : theme.clipBackground;
|
|
129
|
+
const controlPresentation = getTimelineClipControlPresentation({
|
|
130
|
+
widthPx,
|
|
131
|
+
isSelected,
|
|
132
|
+
isHovered,
|
|
133
|
+
isInspectorActive,
|
|
134
|
+
isThumbnailActive,
|
|
135
|
+
isDragging,
|
|
136
|
+
});
|
|
137
|
+
const glossBackgroundImage = isSelected
|
|
138
|
+
? "linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0))"
|
|
139
|
+
: "linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0))";
|
|
140
|
+
const accentBackgroundImage = `linear-gradient(120deg, ${trackStyle.accent}${
|
|
141
|
+
isSelected ? "22" : "1e"
|
|
142
|
+
}, transparent 28%)`;
|
|
143
|
+
const compositionStripeBackgroundImage =
|
|
144
|
+
isComposition && !hasCustomContent
|
|
145
|
+
? "repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.05) 3px, rgba(255,255,255,0.05) 6px)"
|
|
146
|
+
: undefined;
|
|
147
|
+
const clipBackgroundImage = [
|
|
148
|
+
compositionStripeBackgroundImage,
|
|
149
|
+
glossBackgroundImage,
|
|
150
|
+
accentBackgroundImage,
|
|
151
|
+
baseBackgroundImage,
|
|
152
|
+
]
|
|
153
|
+
.filter(Boolean)
|
|
154
|
+
.join(", ");
|
|
66
155
|
|
|
67
156
|
return (
|
|
68
157
|
<div
|
|
@@ -76,13 +165,7 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
76
165
|
top: clipY,
|
|
77
166
|
bottom: clipY,
|
|
78
167
|
borderRadius: theme.clipRadius,
|
|
79
|
-
|
|
80
|
-
? `linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}22, transparent 28%), ${theme.clipBackgroundActive}`
|
|
81
|
-
: `linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}1e, transparent 28%), ${theme.clipBackground}`,
|
|
82
|
-
backgroundImage:
|
|
83
|
-
isComposition && !hasCustomContent
|
|
84
|
-
? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.05) 3px, rgba(255,255,255,0.05) 6px)`
|
|
85
|
-
: undefined,
|
|
168
|
+
backgroundImage: clipBackgroundImage,
|
|
86
169
|
border: `1px solid ${borderColor}`,
|
|
87
170
|
boxShadow,
|
|
88
171
|
transition:
|
|
@@ -102,6 +185,157 @@ export const TimelineClip = memo(function TimelineClip({
|
|
|
102
185
|
onClick={onClick}
|
|
103
186
|
onDoubleClick={onDoubleClick}
|
|
104
187
|
>
|
|
188
|
+
{childCount > 0 && controlPresentation.showControls && (
|
|
189
|
+
<button
|
|
190
|
+
type="button"
|
|
191
|
+
className={`absolute flex items-center gap-1 rounded-md border border-studio-accent/30 bg-neutral-950/75 text-[10px] font-semibold tabular-nums text-studio-accent shadow-lg shadow-black/25 backdrop-blur transition-colors hover:border-studio-accent/60 hover:bg-studio-accent/15 ${
|
|
192
|
+
controlPresentation.compact ? "left-1 top-1 h-5 px-1" : "left-2 top-2 h-6 px-1.5"
|
|
193
|
+
}`}
|
|
194
|
+
style={{ zIndex: TIMELINE_CLIP_CONTROL_Z_INDEX }}
|
|
195
|
+
title={inspectorLabel}
|
|
196
|
+
aria-label={inspectorLabel}
|
|
197
|
+
onPointerDown={(event) => {
|
|
198
|
+
event.stopPropagation();
|
|
199
|
+
}}
|
|
200
|
+
onClick={(event) => {
|
|
201
|
+
event.stopPropagation();
|
|
202
|
+
onInspectorClick?.(event);
|
|
203
|
+
}}
|
|
204
|
+
>
|
|
205
|
+
<svg
|
|
206
|
+
width={controlPresentation.compact ? "11" : "13"}
|
|
207
|
+
height={controlPresentation.compact ? "11" : "13"}
|
|
208
|
+
viewBox="0 0 24 24"
|
|
209
|
+
fill="none"
|
|
210
|
+
stroke="currentColor"
|
|
211
|
+
strokeWidth="1.8"
|
|
212
|
+
strokeLinecap="round"
|
|
213
|
+
strokeLinejoin="round"
|
|
214
|
+
aria-hidden="true"
|
|
215
|
+
>
|
|
216
|
+
<rect x="4" y="4" width="6" height="6" rx="1" />
|
|
217
|
+
<rect x="14" y="4" width="6" height="6" rx="1" />
|
|
218
|
+
<rect x="4" y="14" width="6" height="6" rx="1" />
|
|
219
|
+
<path d="M14 17h6" />
|
|
220
|
+
</svg>
|
|
221
|
+
{childCount}
|
|
222
|
+
</button>
|
|
223
|
+
)}
|
|
224
|
+
{onInspectorClick &&
|
|
225
|
+
controlPresentation.compact &&
|
|
226
|
+
!controlPresentation.showControls &&
|
|
227
|
+
!isDragging && (
|
|
228
|
+
<button
|
|
229
|
+
type="button"
|
|
230
|
+
className="group/clip-inspect absolute right-1 top-1/2 flex h-7 w-2 -translate-y-1/2 items-center justify-center rounded-full border border-white/15 bg-neutral-950/70 text-neutral-300 shadow-lg shadow-black/25 backdrop-blur transition-all hover:w-5 hover:border-white/30 hover:bg-neutral-950/90 focus:w-5 focus:border-studio-accent/60 focus:bg-studio-accent/15 focus:outline-none"
|
|
231
|
+
style={{ zIndex: TIMELINE_CLIP_CONTROL_Z_INDEX }}
|
|
232
|
+
title={inspectorLabel}
|
|
233
|
+
aria-label={inspectorLabel}
|
|
234
|
+
onPointerDown={(event) => {
|
|
235
|
+
event.stopPropagation();
|
|
236
|
+
}}
|
|
237
|
+
onClick={(event) => {
|
|
238
|
+
event.stopPropagation();
|
|
239
|
+
onInspectorClick(event);
|
|
240
|
+
}}
|
|
241
|
+
>
|
|
242
|
+
<svg
|
|
243
|
+
className="opacity-0 transition-opacity group-hover/clip-inspect:opacity-100 group-focus/clip-inspect:opacity-100"
|
|
244
|
+
width="12"
|
|
245
|
+
height="12"
|
|
246
|
+
viewBox="0 0 24 24"
|
|
247
|
+
fill="none"
|
|
248
|
+
stroke="currentColor"
|
|
249
|
+
strokeWidth="1.8"
|
|
250
|
+
strokeLinecap="round"
|
|
251
|
+
strokeLinejoin="round"
|
|
252
|
+
aria-hidden="true"
|
|
253
|
+
>
|
|
254
|
+
<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Z" />
|
|
255
|
+
<circle cx="12" cy="12" r="3" />
|
|
256
|
+
</svg>
|
|
257
|
+
</button>
|
|
258
|
+
)}
|
|
259
|
+
{(onThumbnailClick || onInspectorClick) && controlPresentation.showControls && (
|
|
260
|
+
<div
|
|
261
|
+
className={controlPresentation.containerClassName}
|
|
262
|
+
style={{ zIndex: TIMELINE_CLIP_CONTROL_Z_INDEX }}
|
|
263
|
+
>
|
|
264
|
+
{onThumbnailClick && (
|
|
265
|
+
<button
|
|
266
|
+
type="button"
|
|
267
|
+
className={`${controlPresentation.buttonClassName} border shadow-lg shadow-black/25 backdrop-blur transition-colors ${
|
|
268
|
+
isThumbnailActive
|
|
269
|
+
? "border-studio-accent/60 bg-studio-accent/18 text-studio-accent"
|
|
270
|
+
: "border-white/12 bg-neutral-950/70 text-neutral-400 hover:border-white/24 hover:text-neutral-100"
|
|
271
|
+
}`}
|
|
272
|
+
title={
|
|
273
|
+
isThumbnailActive ? `Hide clip ${thumbnailLabel}` : `Show clip ${thumbnailLabel}`
|
|
274
|
+
}
|
|
275
|
+
aria-label={
|
|
276
|
+
isThumbnailActive ? `Hide clip ${thumbnailLabel}` : `Show clip ${thumbnailLabel}`
|
|
277
|
+
}
|
|
278
|
+
onPointerDown={(event) => {
|
|
279
|
+
event.stopPropagation();
|
|
280
|
+
}}
|
|
281
|
+
onClick={(event) => {
|
|
282
|
+
event.stopPropagation();
|
|
283
|
+
onThumbnailClick(event);
|
|
284
|
+
}}
|
|
285
|
+
>
|
|
286
|
+
<svg
|
|
287
|
+
width={controlPresentation.iconSize}
|
|
288
|
+
height={controlPresentation.iconSize}
|
|
289
|
+
viewBox="0 0 24 24"
|
|
290
|
+
fill="none"
|
|
291
|
+
stroke="currentColor"
|
|
292
|
+
strokeWidth="1.8"
|
|
293
|
+
strokeLinecap="round"
|
|
294
|
+
strokeLinejoin="round"
|
|
295
|
+
aria-hidden="true"
|
|
296
|
+
>
|
|
297
|
+
<rect x="3" y="5" width="18" height="14" rx="2" />
|
|
298
|
+
<circle cx="8" cy="10" r="1.5" />
|
|
299
|
+
<path d="m4 17 5-5 4 4 2-2 5 5" />
|
|
300
|
+
</svg>
|
|
301
|
+
</button>
|
|
302
|
+
)}
|
|
303
|
+
{onInspectorClick && (
|
|
304
|
+
<button
|
|
305
|
+
type="button"
|
|
306
|
+
className={`${controlPresentation.buttonClassName} border shadow-lg shadow-black/25 backdrop-blur transition-colors ${
|
|
307
|
+
isInspectorActive
|
|
308
|
+
? "border-studio-accent/60 bg-studio-accent/18 text-studio-accent"
|
|
309
|
+
: "border-white/12 bg-neutral-950/70 text-neutral-400 hover:border-white/24 hover:text-neutral-100"
|
|
310
|
+
}`}
|
|
311
|
+
title={inspectorLabel}
|
|
312
|
+
aria-label={inspectorLabel}
|
|
313
|
+
onPointerDown={(event) => {
|
|
314
|
+
event.stopPropagation();
|
|
315
|
+
}}
|
|
316
|
+
onClick={(event) => {
|
|
317
|
+
event.stopPropagation();
|
|
318
|
+
onInspectorClick(event);
|
|
319
|
+
}}
|
|
320
|
+
>
|
|
321
|
+
<svg
|
|
322
|
+
width={controlPresentation.iconSize}
|
|
323
|
+
height={controlPresentation.iconSize}
|
|
324
|
+
viewBox="0 0 24 24"
|
|
325
|
+
fill="none"
|
|
326
|
+
stroke="currentColor"
|
|
327
|
+
strokeWidth="1.8"
|
|
328
|
+
strokeLinecap="round"
|
|
329
|
+
strokeLinejoin="round"
|
|
330
|
+
aria-hidden="true"
|
|
331
|
+
>
|
|
332
|
+
<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12Z" />
|
|
333
|
+
<circle cx="12" cy="12" r="3" />
|
|
334
|
+
</svg>
|
|
335
|
+
</button>
|
|
336
|
+
)}
|
|
337
|
+
</div>
|
|
338
|
+
)}
|
|
105
339
|
<div
|
|
106
340
|
aria-hidden="true"
|
|
107
341
|
role="presentation"
|
|
@@ -248,13 +248,28 @@ describe("getTimelineEditCapabilities", () => {
|
|
|
248
248
|
});
|
|
249
249
|
});
|
|
250
250
|
|
|
251
|
-
it("
|
|
251
|
+
it("allows moving generic motion clips while keeping trims blocked", () => {
|
|
252
252
|
expect(
|
|
253
253
|
getTimelineEditCapabilities({
|
|
254
254
|
tag: "section",
|
|
255
255
|
duration: 2,
|
|
256
256
|
selector: ".feature-card",
|
|
257
257
|
}),
|
|
258
|
+
).toEqual({
|
|
259
|
+
canMove: true,
|
|
260
|
+
canTrimStart: false,
|
|
261
|
+
canTrimEnd: false,
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("keeps implicit layout layers selectable but not timeline-editable", () => {
|
|
266
|
+
expect(
|
|
267
|
+
getTimelineEditCapabilities({
|
|
268
|
+
duration: 8,
|
|
269
|
+
selector: ".scene-shell",
|
|
270
|
+
tag: "div",
|
|
271
|
+
timingSource: "implicit",
|
|
272
|
+
}),
|
|
258
273
|
).toEqual({
|
|
259
274
|
canMove: false,
|
|
260
275
|
canTrimStart: false,
|
|
@@ -428,7 +443,6 @@ describe("buildClipRangeSelection", () => {
|
|
|
428
443
|
});
|
|
429
444
|
});
|
|
430
445
|
});
|
|
431
|
-
|
|
432
446
|
describe("resolveTimelineAutoScroll", () => {
|
|
433
447
|
it("does not scroll when the pointer stays away from the edges", () => {
|
|
434
448
|
expect(
|
|
@@ -512,7 +526,6 @@ describe("buildTimelineElementAgentPrompt", () => {
|
|
|
512
526
|
).toContain("If this clip is animated with GSAP");
|
|
513
527
|
});
|
|
514
528
|
});
|
|
515
|
-
|
|
516
529
|
describe("resolveTimelineResize", () => {
|
|
517
530
|
it("shrinks clip duration from the right edge", () => {
|
|
518
531
|
expect(
|
|
@@ -228,12 +228,21 @@ export function getTimelineEditCapabilities(input: {
|
|
|
228
228
|
playbackStart?: number;
|
|
229
229
|
playbackStartAttr?: "media-start" | "playback-start";
|
|
230
230
|
sourceDuration?: number;
|
|
231
|
+
timingSource?: "authored" | "implicit";
|
|
231
232
|
}): TimelineEditCapabilities {
|
|
233
|
+
if (input.timingSource === "implicit") {
|
|
234
|
+
return {
|
|
235
|
+
canMove: false,
|
|
236
|
+
canTrimStart: false,
|
|
237
|
+
canTrimEnd: false,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
232
241
|
const canPatch = hasPatchableTimelineTarget(input);
|
|
233
242
|
const hasFiniteDuration = Number.isFinite(input.duration) && input.duration > 0;
|
|
234
243
|
const hasDeterministicWindow = isDeterministicTimelineWindow(input);
|
|
235
244
|
return {
|
|
236
|
-
canMove: canPatch && hasDeterministicWindow,
|
|
245
|
+
canMove: canPatch && (hasDeterministicWindow || hasFiniteDuration),
|
|
237
246
|
canTrimEnd: canPatch && hasFiniteDuration && hasDeterministicWindow,
|
|
238
247
|
canTrimStart: canPatch && hasFiniteDuration && canOffsetTrimClipStart(input),
|
|
239
248
|
};
|
|
@@ -273,7 +282,6 @@ export function buildClipRangeSelection(
|
|
|
273
282
|
anchorY: anchor.anchorY,
|
|
274
283
|
};
|
|
275
284
|
}
|
|
276
|
-
|
|
277
285
|
export function buildTimelineAgentPrompt({
|
|
278
286
|
rangeStart,
|
|
279
287
|
rangeEnd,
|
|
@@ -347,7 +355,6 @@ export function buildTimelineElementAgentPrompt(element: {
|
|
|
347
355
|
|
|
348
356
|
return lines.join("\n");
|
|
349
357
|
}
|
|
350
|
-
|
|
351
358
|
export function formatTimelineAttributeNumber(value: number): string {
|
|
352
359
|
return Number(roundToCentiseconds(value).toFixed(2)).toString();
|
|
353
360
|
}
|
|
@@ -2,10 +2,12 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
import { Window } from "happy-dom";
|
|
3
3
|
import {
|
|
4
4
|
buildStandaloneRootTimelineElement,
|
|
5
|
+
createStaticSeekPlaybackAdapter,
|
|
5
6
|
createTimelineElementFromManifestClip,
|
|
6
7
|
findTimelineDomNodeForClip,
|
|
7
8
|
getTimelineElementSelector,
|
|
8
9
|
parseTimelineFromDOM,
|
|
10
|
+
readTimelineDurationFromDocument,
|
|
9
11
|
type ClipManifestClip,
|
|
10
12
|
mergeTimelineElementsPreservingDowngrades,
|
|
11
13
|
resolveStandaloneRootCompositionSrc,
|
|
@@ -13,6 +15,30 @@ import {
|
|
|
13
15
|
shouldIgnorePlaybackShortcutTarget,
|
|
14
16
|
} from "./useTimelinePlayer";
|
|
15
17
|
|
|
18
|
+
function createDocument(markup: string): Document {
|
|
19
|
+
const window = new Window();
|
|
20
|
+
Object.assign(window, { SyntaxError });
|
|
21
|
+
window.document.body.innerHTML = markup;
|
|
22
|
+
return window.document;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function createClip(overrides: Partial<ClipManifestClip>): ClipManifestClip {
|
|
26
|
+
return {
|
|
27
|
+
id: null,
|
|
28
|
+
label: "",
|
|
29
|
+
start: 0,
|
|
30
|
+
duration: 4,
|
|
31
|
+
track: 0,
|
|
32
|
+
kind: "element",
|
|
33
|
+
tagName: "div",
|
|
34
|
+
compositionId: null,
|
|
35
|
+
parentCompositionId: null,
|
|
36
|
+
compositionSrc: null,
|
|
37
|
+
assetUrl: null,
|
|
38
|
+
...overrides,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
16
42
|
function mockTargetMatching(selectorNeedle: string): EventTarget {
|
|
17
43
|
return {
|
|
18
44
|
closest: (selector: string) => (selector.includes(selectorNeedle) ? ({} as Element) : null),
|
|
@@ -33,29 +59,102 @@ function mockKeyboardEvent(
|
|
|
33
59
|
};
|
|
34
60
|
}
|
|
35
61
|
|
|
36
|
-
function
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function createClip(overrides: Partial<ClipManifestClip>): ClipManifestClip {
|
|
62
|
+
function createManualAnimationClock() {
|
|
63
|
+
let now = 0;
|
|
64
|
+
let nextId = 0;
|
|
65
|
+
const callbacks = new Map<number, FrameRequestCallback>();
|
|
43
66
|
return {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
67
|
+
now: () => now,
|
|
68
|
+
requestAnimationFrame: (callback: FrameRequestCallback) => {
|
|
69
|
+
nextId += 1;
|
|
70
|
+
callbacks.set(nextId, callback);
|
|
71
|
+
return nextId;
|
|
72
|
+
},
|
|
73
|
+
cancelAnimationFrame: (id: number) => {
|
|
74
|
+
callbacks.delete(id);
|
|
75
|
+
},
|
|
76
|
+
step: (milliseconds: number) => {
|
|
77
|
+
now += milliseconds;
|
|
78
|
+
const pending = Array.from(callbacks.entries());
|
|
79
|
+
callbacks.clear();
|
|
80
|
+
for (const [, callback] of pending) {
|
|
81
|
+
callback(now);
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
scheduledCount: () => callbacks.size,
|
|
56
85
|
};
|
|
57
86
|
}
|
|
58
87
|
|
|
88
|
+
describe("readTimelineDurationFromDocument", () => {
|
|
89
|
+
it("prefers the root composition duration", () => {
|
|
90
|
+
const doc = createDocument(`
|
|
91
|
+
<div data-composition-id="main" data-duration="3">
|
|
92
|
+
<section data-start="0" data-duration="8"></section>
|
|
93
|
+
</div>
|
|
94
|
+
`);
|
|
95
|
+
|
|
96
|
+
expect(readTimelineDurationFromDocument(doc)).toBe(3);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("falls back to the maximum child end time", () => {
|
|
100
|
+
const doc = createDocument(`
|
|
101
|
+
<div data-composition-id="main">
|
|
102
|
+
<section data-start="1" data-duration="2"></section>
|
|
103
|
+
<section data-start="4" data-duration="1.5"></section>
|
|
104
|
+
</div>
|
|
105
|
+
`);
|
|
106
|
+
|
|
107
|
+
expect(readTimelineDurationFromDocument(doc)).toBe(5.5);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("createStaticSeekPlaybackAdapter", () => {
|
|
112
|
+
it("drives renderSeek while playing a duration-only composition", () => {
|
|
113
|
+
const clock = createManualAnimationClock();
|
|
114
|
+
const renderedTimes: number[] = [];
|
|
115
|
+
const adapter = createStaticSeekPlaybackAdapter(
|
|
116
|
+
{
|
|
117
|
+
getTime: () => 0,
|
|
118
|
+
renderSeek: (time: number) => {
|
|
119
|
+
renderedTimes.push(time);
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
3,
|
|
123
|
+
clock,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
adapter.seek(1);
|
|
127
|
+
adapter.play();
|
|
128
|
+
clock.step(500);
|
|
129
|
+
clock.step(2_000);
|
|
130
|
+
|
|
131
|
+
expect(renderedTimes).toEqual([1, 1.5, 3]);
|
|
132
|
+
expect(adapter.getTime()).toBe(3);
|
|
133
|
+
expect(adapter.isPlaying()).toBe(false);
|
|
134
|
+
expect(clock.scheduledCount()).toBe(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("clamps explicit seeks to the fallback duration", () => {
|
|
138
|
+
const clock = createManualAnimationClock();
|
|
139
|
+
const renderedTimes: number[] = [];
|
|
140
|
+
const adapter = createStaticSeekPlaybackAdapter(
|
|
141
|
+
{
|
|
142
|
+
getTime: () => 0,
|
|
143
|
+
renderSeek: (time: number) => {
|
|
144
|
+
renderedTimes.push(time);
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
2,
|
|
148
|
+
clock,
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
adapter.seek(9);
|
|
152
|
+
|
|
153
|
+
expect(renderedTimes).toEqual([2]);
|
|
154
|
+
expect(adapter.getTime()).toBe(2);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
59
158
|
describe("buildStandaloneRootTimelineElement", () => {
|
|
60
159
|
it("includes selector and source metadata for standalone composition fallback clips", () => {
|
|
61
160
|
expect(
|
|
@@ -150,6 +249,36 @@ describe("findTimelineDomNodeForClip", () => {
|
|
|
150
249
|
});
|
|
151
250
|
|
|
152
251
|
describe("anonymous timeline identity", () => {
|
|
252
|
+
it("adds root-level untimed DOM layers as implicit full-duration layers", () => {
|
|
253
|
+
const doc = createDocument(`
|
|
254
|
+
<div data-composition-id="compare" data-start="0" data-duration="18">
|
|
255
|
+
<link rel="stylesheet" href="styles.css" />
|
|
256
|
+
<div class="scene-shell">
|
|
257
|
+
<div class="topline">Title</div>
|
|
258
|
+
</div>
|
|
259
|
+
<video id="main-video" class="clip main-video" data-start="0" data-duration="18" data-track-index="1"></video>
|
|
260
|
+
<script></script>
|
|
261
|
+
</div>
|
|
262
|
+
`);
|
|
263
|
+
|
|
264
|
+
const elements = parseTimelineFromDOM(doc, 18);
|
|
265
|
+
|
|
266
|
+
expect(elements).toEqual(
|
|
267
|
+
expect.arrayContaining([
|
|
268
|
+
expect.objectContaining({
|
|
269
|
+
duration: 18,
|
|
270
|
+
label: "Scene Shell",
|
|
271
|
+
selector: ".scene-shell",
|
|
272
|
+
start: 0,
|
|
273
|
+
tag: "div",
|
|
274
|
+
timingSource: "implicit",
|
|
275
|
+
}),
|
|
276
|
+
]),
|
|
277
|
+
);
|
|
278
|
+
expect(elements.find((element) => element.tag === "link")).toBeUndefined();
|
|
279
|
+
expect(elements.find((element) => element.tag === "script")).toBeUndefined();
|
|
280
|
+
});
|
|
281
|
+
|
|
153
282
|
it("keeps fallback-parsed anonymous clips distinct when labels match", () => {
|
|
154
283
|
const doc = createDocument(`
|
|
155
284
|
<div data-composition-id="main" data-start="0" data-duration="8">
|