@hyperframes/studio 0.8.138 → 0.8.140
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/{hyperframes-player-B30apksz.js → hyperframes-player-BLuyxnK7.js} +1 -1
- package/dist/assets/{index-DXVvWfQG.js → index-CWfuDCoA.js} +1 -1
- package/dist/assets/{index-DIJuIBwI.js → index-Dr3Bo9uH.js} +1 -1
- package/dist/assets/{index-BdjA0CLD.js → index-VAHDM_zL.js} +210 -210
- package/dist/{chunk-4S3AYCOG.js → chunk-H2DUOXTF.js} +3 -2
- package/dist/{chunk-TDWGXBUT.js → chunk-TFLU2MGI.js} +1 -6
- package/dist/chunk-TFLU2MGI.js.map +1 -0
- package/dist/{chunk-7UQWVN52.js → chunk-UYWON5AX.js} +2 -2
- package/dist/{domEditingLayers-IEVLO54Z.js → domEditingLayers-KBTPQKGA.js} +3 -3
- package/dist/index.d.ts +20 -3
- package/dist/index.html +1 -1
- package/dist/index.js +7327 -6265
- package/dist/index.js.map +1 -1
- package/dist/player.js +2 -2
- package/package.json +8 -8
- package/src/App.tsx +9 -4
- package/src/components/TimelineToolbar.test.tsx +79 -1
- package/src/components/TimelineToolbar.tsx +14 -29
- package/src/components/editor/DomEditOverlay.tsx +11 -7
- package/src/components/editor/PropertyPanel.test.tsx +600 -748
- package/src/components/editor/PropertyPanel.tsx +1 -0
- package/src/components/editor/PropertyPanelFlat.tsx +1 -0
- package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +2 -0
- package/src/components/editor/domEditOverlayGeometry.ts +7 -7
- package/src/components/editor/domEditOverlayGestures.ts +18 -0
- package/src/components/editor/domEditOverlayStartGesture.blocked.test.ts +1 -0
- package/src/components/editor/domEditOverlayStartGesture.test.ts +3 -0
- package/src/components/editor/domEditOverlayStartGesture.ts +9 -1
- package/src/components/editor/dragUndoPaint.test.ts +2 -0
- package/src/components/editor/gestureHoldsPreviewReload.test.tsx +432 -15
- package/src/components/editor/groupDropKeepsSelection.test.ts +1 -0
- package/src/components/editor/manualEdits.ts +1 -1
- package/src/components/editor/manualEditsDom.ts +7 -7
- package/src/components/editor/manualEditsParsing.ts +14 -2
- package/src/components/editor/manualEditsTypes.ts +5 -0
- package/src/components/editor/manualOffsetDrag.test.ts +23 -0
- package/src/components/editor/manualOffsetDrag.ts +5 -0
- package/src/components/editor/previewGestureStarts.ts +284 -0
- package/src/components/editor/previewReadOnly.overlay.test.tsx +3 -2
- package/src/components/editor/propertyPanelFill.tsx +36 -13
- package/src/components/editor/propertyPanelFlatStyleSections.test.tsx +4 -0
- package/src/components/editor/propertyPanelFlatStyleSections.tsx +13 -7
- package/src/components/editor/propertyPanelImageFill.test.tsx +164 -0
- package/src/components/editor/propertyPanelStyleSections.tsx +10 -7
- package/src/components/editor/rotateInlineSave.test.ts +1 -0
- package/src/components/editor/shiftDragAxisLock.test.tsx +1 -0
- package/src/components/editor/useDomEditOverlayGestures.ts +67 -71
- package/src/components/nle/NLEContext.test.ts +26 -0
- package/src/components/nle/NLEContext.tsx +8 -3
- package/src/components/nle/NLEPreview.test.ts +44 -1
- package/src/components/nle/NLEPreview.tsx +7 -2
- package/src/hooks/draggedGsapPosition.ts +8 -0
- package/src/hooks/editMoment.ts +13 -0
- package/src/hooks/externalFileChangeOwnership.test.ts +25 -4
- package/src/hooks/gsapDragCommit.ts +2 -1
- package/src/hooks/gsapDragPositionCommit.ts +19 -7
- package/src/hooks/gsapPositionDetection.ts +5 -3
- package/src/hooks/gsapResizeIntercept.ts +11 -4
- package/src/hooks/gsapRuntimeBridge.ts +31 -11
- package/src/hooks/gsapValueAtPlayhead.browser.test.ts +3 -22
- package/src/hooks/gsapValueAtPlayhead.test.ts +15 -0
- package/src/hooks/gsapValueAtPlayhead.ts +12 -5
- package/src/hooks/timelineEditingHelpers.test.ts +22 -0
- package/src/hooks/timelineEditingHelpers.ts +18 -2
- package/src/hooks/useDomSelection.test.ts +21 -2
- package/src/hooks/useEditHistoryActions.test.tsx +20 -0
- package/src/hooks/useEditHistoryActions.ts +2 -0
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +536 -10
- package/src/hooks/useExternalFileChangeCoordinator.ts +72 -45
- package/src/hooks/useGsapAwareEditing.test.tsx +38 -0
- package/src/hooks/useGsapAwareEditing.ts +148 -136
- package/src/hooks/useGsapAwareGroupMove.ts +126 -113
- package/src/hooks/useGsapScriptCommits.ts +5 -4
- package/src/hooks/useMusicBeatAnalysis.ts +2 -1
- package/src/hooks/usePreviewPersistence.history.test.tsx +7 -2
- package/src/hooks/usePreviewPersistence.ts +1 -4
- package/src/hooks/useSdkSession.lifecycle.test.tsx +20 -0
- package/src/hooks/useSdkSession.ts +4 -1
- package/src/hooks/useThumbnailStripSize.test.tsx +65 -0
- package/src/hooks/useThumbnailStripSize.ts +23 -1
- package/src/hooks/useTimelineDeleteOps.test.tsx +10 -0
- package/src/hooks/useTimelineDeleteOps.ts +9 -10
- package/src/index.ts +2 -0
- package/src/player/components/AudioWaveform.test.tsx +153 -1
- package/src/player/components/AudioWaveform.tsx +93 -37
- package/src/player/components/Timeline.scrollRest.test.tsx +22 -0
- package/src/player/components/Timeline.test.ts +3 -1
- package/src/player/components/TimelineCanvas.tsx +4 -0
- package/src/player/components/TimelineClip.tsx +20 -6
- package/src/player/components/TimelineClipFades.test.tsx +11 -0
- package/src/player/components/TimelineClipFades.tsx +4 -4
- package/src/player/components/TimelineClipJoins.tsx +23 -18
- package/src/player/components/TimelineGestureOverlay.tsx +30 -27
- package/src/player/components/TimelineGroupRow.tsx +1 -0
- package/src/player/components/TimelineLaneClip.tsx +127 -0
- package/src/player/components/TimelineLanes.test.tsx +51 -6
- package/src/player/components/TimelineLanes.tsx +166 -178
- package/src/player/components/TimelineRuler.tsx +2 -0
- package/src/player/components/TimelineTimeLayer.tsx +25 -0
- package/src/player/components/VideoThumbnail.test.tsx +204 -12
- package/src/player/components/VideoThumbnail.tsx +37 -9
- package/src/player/components/thumbnailUtils.test.ts +17 -3
- package/src/player/components/thumbnailUtils.ts +17 -1
- package/src/player/components/timelineLayout.test.ts +3 -3
- package/src/player/components/timelineMotion.test.ts +27 -0
- package/src/player/components/timelineMotion.ts +62 -0
- package/src/player/components/timelineZoomInput.test.ts +438 -0
- package/src/player/components/timelineZoomInput.ts +456 -0
- package/src/player/components/useTimelineGeometry.ts +0 -3
- package/src/player/components/useTimelinePlayhead.test.tsx +97 -30
- package/src/player/components/useTimelinePlayhead.ts +53 -59
- package/src/player/components/useTimelineProviderState.tsx +16 -21
- package/src/player/components/useTimelineZoom.ts +7 -12
- package/src/player/hooks/useShadowPreviewReload.ts +6 -6
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +14 -0
- package/src/player/hooks/useTimelinePlayer.ts +8 -5
- package/src/player/lib/thumbnailScheduler.test.ts +20 -1
- package/src/player/lib/thumbnailScheduler.ts +1 -1
- package/src/player/lib/thumbnailVideoDecoder.test.ts +230 -30
- package/src/player/lib/thumbnailVideoDecoder.ts +197 -54
- package/src/player/lib/timelineElementHelpers.ts +0 -11
- package/src/player/lib/timelineViewportBudgets.test.ts +1 -1
- package/src/player/lib/timelineViewportBudgets.ts +1 -1
- package/src/player/previewReloading.test.ts +48 -0
- package/src/player/previewReloading.ts +48 -0
- package/src/utils/gsapUndoRestore.browser.test.ts +4 -17
- package/src/utils/rootDuration.ts +5 -4
- package/src/utils/studioPendingEdits.test.ts +27 -0
- package/src/utils/studioPendingEdits.ts +9 -0
- package/src/utils/timelineAssetDrop.test.ts +13 -4
- package/src/utils/timelineAssetDrop.ts +6 -2
- package/dist/chunk-TDWGXBUT.js.map +0 -1
- /package/dist/{chunk-4S3AYCOG.js.map → chunk-H2DUOXTF.js.map} +0 -0
- /package/dist/{chunk-7UQWVN52.js.map → chunk-UYWON5AX.js.map} +0 -0
- /package/dist/{domEditingLayers-IEVLO54Z.js.map → domEditingLayers-KBTPQKGA.js.map} +0 -0
|
@@ -50,6 +50,7 @@ interface NLEPreviewProps extends Pick<
|
|
|
50
50
|
onCompositionSizeChange?: (size: PreviewCompositionSize | null) => void;
|
|
51
51
|
/** Draws the picture edge to edge in this box, without Studio's inset band. */
|
|
52
52
|
fillBox?: boolean;
|
|
53
|
+
compositionSizeHint?: PreviewCompositionSize;
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
export function getPreviewPlayerKey({
|
|
@@ -127,6 +128,7 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
127
128
|
onStageRef,
|
|
128
129
|
onCompositionSizeChange,
|
|
129
130
|
fillBox,
|
|
131
|
+
compositionSizeHint,
|
|
130
132
|
}: NLEPreviewProps) {
|
|
131
133
|
const activeKey = getPreviewPlayerKey({ projectId, directUrl });
|
|
132
134
|
const viewportRef = useRef<HTMLDivElement>(null);
|
|
@@ -151,6 +153,8 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
151
153
|
const gutterPx = usePreviewGuidesStore((s) => (s.rulerVisible ? RULER_GUTTER_PX : 0));
|
|
152
154
|
const insetPx = fillBox ? 0 : PREVIEW_STAGE_INSET_PX;
|
|
153
155
|
const [stageSize, setStageSize] = useState(() => resolvePreviewStageSize(0, 0, null, portrait));
|
|
156
|
+
const hintWidth = compositionSizeHint?.width;
|
|
157
|
+
const hintHeight = compositionSizeHint?.height;
|
|
154
158
|
|
|
155
159
|
const zoomRef = useRef<PreviewZoomState>(DEFAULT_PREVIEW_ZOOM);
|
|
156
160
|
const [settledZoom, setSettledZoom] = useState<PreviewZoomState>(DEFAULT_PREVIEW_ZOOM);
|
|
@@ -183,7 +187,8 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
183
187
|
resolvePreviewStageSize(
|
|
184
188
|
rect.width,
|
|
185
189
|
rect.height,
|
|
186
|
-
compositionSize
|
|
190
|
+
compositionSize ??
|
|
191
|
+
(hintWidth && hintHeight ? { width: hintWidth, height: hintHeight } : null),
|
|
187
192
|
portrait,
|
|
188
193
|
gutterPx,
|
|
189
194
|
insetPx,
|
|
@@ -195,7 +200,7 @@ export const NLEPreview = memo(function NLEPreview({
|
|
|
195
200
|
const observer = new ResizeObserver(updateStageSize);
|
|
196
201
|
observer.observe(viewport);
|
|
197
202
|
return () => observer.disconnect();
|
|
198
|
-
}, [compositionSize, portrait, gutterPx, insetPx]);
|
|
203
|
+
}, [compositionSize, hintWidth, hintHeight, portrait, gutterPx, insetPx]);
|
|
199
204
|
|
|
200
205
|
const onCompositionSizeChangeRef = useRef(onCompositionSizeChange);
|
|
201
206
|
onCompositionSizeChangeRef.current = onCompositionSizeChange;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { roundTo3 } from "../utils/rounding";
|
|
2
|
+
import { STUDIO_EDIT_MOMENT_ATTR, type EditMoment } from "../components/editor/manualEditsTypes";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Drag → GSAP position math, shared by the commit path
|
|
@@ -41,6 +42,7 @@ export interface DragStamp {
|
|
|
41
42
|
origY: number;
|
|
42
43
|
baseX: number;
|
|
43
44
|
baseY: number;
|
|
45
|
+
at?: EditMoment;
|
|
44
46
|
frozen?: boolean;
|
|
45
47
|
}
|
|
46
48
|
|
|
@@ -52,9 +54,15 @@ export function readDragStamp(element: HTMLElement): DragStamp {
|
|
|
52
54
|
origY: read("data-hf-drag-initial-offset-y") || 0,
|
|
53
55
|
baseX: read("data-hf-drag-gsap-base-x"),
|
|
54
56
|
baseY: read("data-hf-drag-gsap-base-y"),
|
|
57
|
+
at: readEditMoment(element),
|
|
55
58
|
};
|
|
56
59
|
}
|
|
57
60
|
|
|
61
|
+
function readEditMoment(element: HTMLElement): EditMoment | undefined {
|
|
62
|
+
const raw = element.getAttribute(STUDIO_EDIT_MOMENT_ATTR);
|
|
63
|
+
return raw ? (JSON.parse(raw) as EditMoment) : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
58
66
|
/** A gesture's stamp read at its release, for commits that finish after the next gesture re-stamps the element. */
|
|
59
67
|
export const freezeDragStamp = (element: HTMLElement): DragStamp => ({
|
|
60
68
|
...readDragStamp(element),
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { usePlayerStore } from "../player/store/playerStore";
|
|
2
|
+
import type { EditMoment } from "../components/editor/manualEditsTypes";
|
|
3
|
+
import type { DragStamp } from "./draggedGsapPosition";
|
|
4
|
+
|
|
5
|
+
export function playheadMoment(): EditMoment {
|
|
6
|
+
const { currentTime, activeKeyframePct } = usePlayerStore.getState();
|
|
7
|
+
return { time: currentTime, keyframePct: activeKeyframePct };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The playhead an edit writes at: its gesture's press, else the playhead now. */
|
|
11
|
+
export function editMoment(stamp?: DragStamp): EditMoment {
|
|
12
|
+
return stamp?.at ?? playheadMoment();
|
|
13
|
+
}
|
|
@@ -21,9 +21,30 @@ describe("external file-change subscription ownership", () => {
|
|
|
21
21
|
new URL("./useExternalFileChangeCoordinator.ts", import.meta.url),
|
|
22
22
|
"utf8",
|
|
23
23
|
);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
expect(coordinator.match(/sseFileChangeChannel\(handler, catchUp\)/g)).toHaveLength(1);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("asks to catch up on every reconnect, since changes made while disconnected are never resent", () => {
|
|
28
|
+
const listeners = new Map<string, (event: unknown) => void>();
|
|
29
|
+
class FakeEventSource {
|
|
30
|
+
addEventListener(type: string, listener: (event: unknown) => void) {
|
|
31
|
+
listeners.set(type, listener);
|
|
32
|
+
}
|
|
33
|
+
close = vi.fn();
|
|
34
|
+
}
|
|
35
|
+
vi.stubGlobal("EventSource", FakeEventSource);
|
|
36
|
+
try {
|
|
37
|
+
const onReconnect = vi.fn();
|
|
38
|
+
sseFileChangeChannel(vi.fn(), onReconnect);
|
|
39
|
+
listeners.get("open")?.(new Event("open"));
|
|
40
|
+
expect(onReconnect).not.toHaveBeenCalled();
|
|
41
|
+
listeners.get("error")?.(new Event("error"));
|
|
42
|
+
listeners.get("open")?.(new Event("open"));
|
|
43
|
+
listeners.get("open")?.(new Event("open"));
|
|
44
|
+
expect(onReconnect).toHaveBeenCalledTimes(2);
|
|
45
|
+
} finally {
|
|
46
|
+
vi.unstubAllGlobals();
|
|
47
|
+
}
|
|
27
48
|
});
|
|
28
49
|
|
|
29
50
|
it("delivers the raw file-change event from EventSource to the handler and closes on cleanup", () => {
|
|
@@ -42,7 +63,7 @@ describe("external file-change subscription ownership", () => {
|
|
|
42
63
|
vi.stubGlobal("EventSource", FakeEventSource);
|
|
43
64
|
try {
|
|
44
65
|
const onDelivery = vi.fn();
|
|
45
|
-
const stop = sseFileChangeChannel(onDelivery);
|
|
66
|
+
const stop = sseFileChangeChannel(onDelivery, vi.fn());
|
|
46
67
|
const event = new MessageEvent("file-change", { data: JSON.stringify({ path: "a.html" }) });
|
|
47
68
|
listeners.get("file-change")?.(event);
|
|
48
69
|
expect(opened).toEqual(["/api/events"]);
|
|
@@ -56,8 +56,9 @@ function newTweenTarget(selection: DomEditSelection): string | null {
|
|
|
56
56
|
export function computeCurrentPercentage(
|
|
57
57
|
selection: DomEditSelection,
|
|
58
58
|
animation?: GsapAnimation,
|
|
59
|
+
time = usePlayerStore.getState().currentTime,
|
|
59
60
|
): number {
|
|
60
|
-
return computeElementPercentage(
|
|
61
|
+
return computeElementPercentage(time, selection, animation);
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
// When a drag edits a SELECTED keyframe, park the playhead on that keyframe's exact
|
|
@@ -21,6 +21,8 @@ import {
|
|
|
21
21
|
} from "./gsapDragCommit";
|
|
22
22
|
import type { GsapEditOutcome } from "./gsapEditOutcome";
|
|
23
23
|
import { commitValueAtPlayhead, planValueEdit } from "./gsapValueAtPlayhead";
|
|
24
|
+
import { editMoment } from "./editMoment";
|
|
25
|
+
import type { EditMoment } from "../components/editor/manualEditsTypes";
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* The tween's keyframes with one inserted at `percentage`. Any existing keyframe
|
|
@@ -95,6 +97,7 @@ export function gsapPositionFromDragOutcome(
|
|
|
95
97
|
studioOffset: { x: number; y: number },
|
|
96
98
|
gsapPos: { x: number; y: number },
|
|
97
99
|
iframe: HTMLIFrameElement | null,
|
|
100
|
+
moment?: EditMoment,
|
|
98
101
|
): GsapEditOutcome {
|
|
99
102
|
if (anim.arcPath?.enabled) return { status: "persisted" };
|
|
100
103
|
const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition(
|
|
@@ -104,6 +107,7 @@ export function gsapPositionFromDragOutcome(
|
|
|
104
107
|
);
|
|
105
108
|
const plan = planValueEdit(selection, anim, { x: newX, y: newY }, iframe, {
|
|
106
109
|
backfill: { x: baseGsapX, y: baseGsapY },
|
|
110
|
+
moment,
|
|
107
111
|
});
|
|
108
112
|
return plan.ok
|
|
109
113
|
? { status: "persisted" }
|
|
@@ -132,7 +136,8 @@ export async function commitGsapPositionFromDrag(
|
|
|
132
136
|
const restoreOffset = () => restoreDragOffset(el, stamp);
|
|
133
137
|
|
|
134
138
|
if (anim.arcPath?.enabled) {
|
|
135
|
-
const { activeKeyframePct, currentTime
|
|
139
|
+
const { keyframePct: activeKeyframePct, time: currentTime } = editMoment(stamp);
|
|
140
|
+
const { setActiveKeyframePct } = usePlayerStore.getState();
|
|
136
141
|
const tweenStart = resolveTweenStart(anim);
|
|
137
142
|
const tweenDuration = resolveTweenDuration(anim);
|
|
138
143
|
if (
|
|
@@ -165,7 +170,7 @@ export async function commitGsapPositionFromDrag(
|
|
|
165
170
|
);
|
|
166
171
|
return { status: "persisted" };
|
|
167
172
|
}
|
|
168
|
-
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
|
|
173
|
+
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim, currentTime);
|
|
169
174
|
const keyframes = anim.keyframes?.keyframes ?? [];
|
|
170
175
|
// A drag counts as on a waypoint when it plays within KEYFRAME_PCT_MATCH of it,
|
|
171
176
|
// so landing a fraction of a percent off an authored waypoint updates that point
|
|
@@ -212,9 +217,16 @@ export async function commitGsapPositionFromDrag(
|
|
|
212
217
|
);
|
|
213
218
|
return { status: "persisted" };
|
|
214
219
|
}
|
|
215
|
-
return commitValueAtPlayhead(
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
+
return commitValueAtPlayhead(
|
|
221
|
+
selection,
|
|
222
|
+
anim,
|
|
223
|
+
{ x: newX, y: newY },
|
|
224
|
+
iframe,
|
|
225
|
+
{ ...callbacks, stamp },
|
|
226
|
+
{
|
|
227
|
+
label: "Move layer",
|
|
228
|
+
backfill: { x: baseGsapX, y: baseGsapY },
|
|
229
|
+
beforeReload: restoreOffset,
|
|
230
|
+
},
|
|
231
|
+
);
|
|
220
232
|
}
|
|
@@ -44,9 +44,9 @@ function animHasPosition(anim: GsapAnimation): boolean {
|
|
|
44
44
|
export function findGsapPositionAnimation(
|
|
45
45
|
animations: GsapAnimation[],
|
|
46
46
|
selector?: string,
|
|
47
|
+
currentTime = usePlayerStore.getState().currentTime,
|
|
47
48
|
): GsapAnimation | null {
|
|
48
49
|
if (animations.length === 0) return null;
|
|
49
|
-
const currentTime = usePlayerStore.getState().currentTime;
|
|
50
50
|
|
|
51
51
|
const scored = animations.filter(animHasPosition).map((a) => {
|
|
52
52
|
let score = 0;
|
|
@@ -87,9 +87,11 @@ function statesValueAt(anim: GsapAnimation, time: number): boolean {
|
|
|
87
87
|
* playhead (the later one, which renders, if both do), then most keyframes.
|
|
88
88
|
*/
|
|
89
89
|
// fallow-ignore-next-line complexity
|
|
90
|
-
export function pickClosestToPlayhead(
|
|
90
|
+
export function pickClosestToPlayhead(
|
|
91
|
+
anims: GsapAnimation[],
|
|
92
|
+
ct = usePlayerStore.getState().currentTime,
|
|
93
|
+
): GsapAnimation | null {
|
|
91
94
|
if (anims.length <= 1) return anims[0] ?? null;
|
|
92
|
-
const ct = usePlayerStore.getState().currentTime;
|
|
93
95
|
return anims.reduce((best, a) => {
|
|
94
96
|
const dist = playheadDistance(a, ct);
|
|
95
97
|
const bestDist = playheadDistance(best, ct);
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
type DragStamp,
|
|
34
34
|
} from "./draggedGsapPosition";
|
|
35
35
|
import { pickClosestToPlayhead, readGsapPositionFromIframe } from "./gsapPositionDetection";
|
|
36
|
+
import { editMoment } from "./editMoment";
|
|
36
37
|
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
|
37
38
|
import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit";
|
|
38
39
|
import { resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
@@ -141,8 +142,9 @@ function firstSizeKey(
|
|
|
141
142
|
size: { width: number; height: number },
|
|
142
143
|
resizeGroup: PropertyGroupName,
|
|
143
144
|
animations: GsapAnimation[],
|
|
145
|
+
currentTime: number,
|
|
144
146
|
): Record<string, unknown> | null {
|
|
145
|
-
const { autoKeyframeEnabled
|
|
147
|
+
const { autoKeyframeEnabled } = usePlayerStore.getState();
|
|
146
148
|
const keyframed = animations.some(
|
|
147
149
|
(a) => a.keyframes && animationWritesAnyProperty(a, MOVED_OR_SIZED),
|
|
148
150
|
);
|
|
@@ -180,6 +182,7 @@ export async function tryGsapResizeIntercept(
|
|
|
180
182
|
draw: <T>(run: () => T) => T = (run) => run(),
|
|
181
183
|
stamp?: DragStamp,
|
|
182
184
|
): Promise<GsapEditOutcome> {
|
|
185
|
+
const moment = editMoment(stamp);
|
|
183
186
|
const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
|
|
184
187
|
const outcome = preflightGsapResizeIntercept(selection, animations, iframe, fetchedAnimations);
|
|
185
188
|
if (outcome.status === "blocked") return outcome;
|
|
@@ -199,6 +202,7 @@ export async function tryGsapResizeIntercept(
|
|
|
199
202
|
selection,
|
|
200
203
|
commitMutation,
|
|
201
204
|
postSplitFetch,
|
|
205
|
+
moment.time,
|
|
202
206
|
);
|
|
203
207
|
|
|
204
208
|
const anim =
|
|
@@ -218,7 +222,7 @@ export async function tryGsapResizeIntercept(
|
|
|
218
222
|
animationWritesAnyProperty(a, SIZE_PROPS),
|
|
219
223
|
);
|
|
220
224
|
if (!scriptWritesSize) {
|
|
221
|
-
const firstKey = firstSizeKey(selection, sized, resizeGroup, workingAnimations);
|
|
225
|
+
const firstKey = firstSizeKey(selection, sized, resizeGroup, workingAnimations, moment.time);
|
|
222
226
|
if (!firstKey) return { status: "element-size" };
|
|
223
227
|
await commitMutation(selection, firstKey, { label: "Resize", softReload: true });
|
|
224
228
|
return handOverDraftSize(selection, { status: "persisted" }, sized, draw);
|
|
@@ -249,6 +253,7 @@ export async function tryGsapResizeIntercept(
|
|
|
249
253
|
resolveTweenDuration(a) > 0 &&
|
|
250
254
|
animationWritesAnyProperty(a, resizeProperties),
|
|
251
255
|
),
|
|
256
|
+
moment.time,
|
|
252
257
|
);
|
|
253
258
|
if (animatedTween) {
|
|
254
259
|
logResize("intercept-route", { route: "keyframed-size", tweenId: animatedTween.id });
|
|
@@ -284,8 +289,9 @@ export async function tryGsapResizeIntercept(
|
|
|
284
289
|
return { status: "blocked", reason: "source-uneditable", detail: "zero-duration-tween" };
|
|
285
290
|
}
|
|
286
291
|
|
|
287
|
-
const {
|
|
288
|
-
const
|
|
292
|
+
const { setActiveKeyframePct } = usePlayerStore.getState();
|
|
293
|
+
const activeKeyframePct = moment.keyframePct;
|
|
294
|
+
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim, moment.time);
|
|
289
295
|
const selector = selectorFromSelection(selection);
|
|
290
296
|
|
|
291
297
|
let resizeProps: Record<string, number>;
|
|
@@ -473,6 +479,7 @@ export async function tryGsapResizeIntercept(
|
|
|
473
479
|
currentAnimations.filter(
|
|
474
480
|
(a) => a.propertyGroup === "position" && !isInstantHold(a) && resolveTweenDuration(a) > 0,
|
|
475
481
|
),
|
|
482
|
+
moment.time,
|
|
476
483
|
);
|
|
477
484
|
if (positionTween) {
|
|
478
485
|
logResize("scale-finalize", { route: "position-keyframe", tweenId: positionTween.id });
|
|
@@ -31,7 +31,9 @@ import { tweenReach, tweensForThisElement } from "./gsapTweenReach";
|
|
|
31
31
|
import { resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
32
32
|
import { roundTo3 } from "../utils/rounding";
|
|
33
33
|
import type { GsapDragCommitCallbacks } from "./gsapDragCommit";
|
|
34
|
-
import type
|
|
34
|
+
import { readDragStamp, type DragStamp } from "./draggedGsapPosition";
|
|
35
|
+
import { editMoment } from "./editMoment";
|
|
36
|
+
import type { EditMoment } from "../components/editor/manualEditsTypes";
|
|
35
37
|
import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared";
|
|
36
38
|
import {
|
|
37
39
|
findGsapPositionAnimation,
|
|
@@ -66,12 +68,13 @@ export async function resolveGroupTween(
|
|
|
66
68
|
_selection: DomEditSelection,
|
|
67
69
|
_commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
68
70
|
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
71
|
+
time?: number,
|
|
69
72
|
): Promise<{ anim: GsapAnimation; animations: GsapAnimation[] } | null> {
|
|
70
73
|
const inGroup = (list: GsapAnimation[]) => {
|
|
71
74
|
const tagged = list.filter((a) => a.propertyGroup === group);
|
|
72
75
|
const props = new Set(PROPERTY_GROUPS[group]);
|
|
73
76
|
const mixed = list.filter((a) => !a.propertyGroup && animationWritesAnyProperty(a, props));
|
|
74
|
-
return pickClosestToPlayhead(tagged.length > 0 ? tagged : mixed);
|
|
77
|
+
return pickClosestToPlayhead(tagged.length > 0 ? tagged : mixed, time);
|
|
75
78
|
};
|
|
76
79
|
const anim = inGroup(animations);
|
|
77
80
|
if (anim) return { anim, animations };
|
|
@@ -205,6 +208,7 @@ async function planDrag(
|
|
|
205
208
|
allAnimations: GsapAnimation[],
|
|
206
209
|
iframe: HTMLIFrameElement | null,
|
|
207
210
|
options: { altKey?: boolean },
|
|
211
|
+
moment: EditMoment,
|
|
208
212
|
): Promise<GsapEditOutcome> {
|
|
209
213
|
const selector = selectorFromSelection(selection);
|
|
210
214
|
if (!selector) return { status: "blocked", reason: "no-selector" };
|
|
@@ -213,8 +217,15 @@ async function planDrag(
|
|
|
213
217
|
const animations = keeper
|
|
214
218
|
? own.filter((a) => a === keeper || !isPositionWriteOf(selector)(a))
|
|
215
219
|
: own;
|
|
216
|
-
const resolved = await resolveGroupTween(
|
|
217
|
-
|
|
220
|
+
const resolved = await resolveGroupTween(
|
|
221
|
+
"position",
|
|
222
|
+
animations,
|
|
223
|
+
selection,
|
|
224
|
+
async () => {},
|
|
225
|
+
undefined,
|
|
226
|
+
moment.time,
|
|
227
|
+
);
|
|
228
|
+
const posAnim = resolved?.anim ?? findGsapPositionAnimation(animations, selector, moment.time);
|
|
218
229
|
const route = dragRoute(posAnim, iframe, selector, options.altKey);
|
|
219
230
|
if (route === "static") return { status: "persisted" };
|
|
220
231
|
if (!posAnim) {
|
|
@@ -227,7 +238,7 @@ async function planDrag(
|
|
|
227
238
|
: { status: "persisted" };
|
|
228
239
|
}
|
|
229
240
|
const gsapPos = readGsapPositionFromIframe(iframe, selector) ?? { x: 0, y: 0 };
|
|
230
|
-
return gsapPositionFromDragOutcome(selection, posAnim, offset, gsapPos, iframe);
|
|
241
|
+
return gsapPositionFromDragOutcome(selection, posAnim, offset, gsapPos, iframe, moment);
|
|
231
242
|
}
|
|
232
243
|
|
|
233
244
|
/** Commits a drag through the GSAP script. Callers reject `blocked` (the gesture layer
|
|
@@ -247,6 +258,9 @@ export async function tryGsapDragIntercept(
|
|
|
247
258
|
stamp?: DragStamp;
|
|
248
259
|
},
|
|
249
260
|
): Promise<GsapEditOutcome> {
|
|
261
|
+
const stamp = options?.stamp ?? readDragStamp(selection.element);
|
|
262
|
+
const moment = editMoment(stamp);
|
|
263
|
+
const time = moment.time;
|
|
250
264
|
if (!options?.preflightPassed) {
|
|
251
265
|
const preflight = await preflightGsapDragIntercept(
|
|
252
266
|
selection,
|
|
@@ -258,7 +272,7 @@ export async function tryGsapDragIntercept(
|
|
|
258
272
|
if (preflight.status !== "persisted") return preflight;
|
|
259
273
|
if (options?.preflightOnly) {
|
|
260
274
|
return options.group
|
|
261
|
-
? planDrag(selection, offset, allAnimations, iframe, options)
|
|
275
|
+
? planDrag(selection, offset, allAnimations, iframe, options, moment)
|
|
262
276
|
: preflight;
|
|
263
277
|
}
|
|
264
278
|
}
|
|
@@ -302,16 +316,17 @@ export async function tryGsapDragIntercept(
|
|
|
302
316
|
selection,
|
|
303
317
|
commitMutation,
|
|
304
318
|
fetchFallbackAnimations,
|
|
319
|
+
time,
|
|
305
320
|
);
|
|
306
321
|
|
|
307
322
|
let posAnim = resolved?.anim ?? null;
|
|
308
323
|
let resolvedAnimations = resolved?.animations ?? workingAnimations;
|
|
309
324
|
if (!posAnim) {
|
|
310
|
-
posAnim = findGsapPositionAnimation(workingAnimations, selector);
|
|
325
|
+
posAnim = findGsapPositionAnimation(workingAnimations, selector, time);
|
|
311
326
|
if (!posAnim && fetchFallbackAnimations) {
|
|
312
327
|
const fresh = await fetchFallbackAnimations();
|
|
313
328
|
resolvedAnimations = fresh;
|
|
314
|
-
posAnim = findGsapPositionAnimation(fresh, selector);
|
|
329
|
+
posAnim = findGsapPositionAnimation(fresh, selector, time);
|
|
315
330
|
}
|
|
316
331
|
}
|
|
317
332
|
|
|
@@ -349,13 +364,14 @@ export async function tryGsapDragIntercept(
|
|
|
349
364
|
a.propertyGroup === posAnim!.propertyGroup &&
|
|
350
365
|
isXYPositionWrite(a) === isXYPositionWrite(posAnim!),
|
|
351
366
|
),
|
|
367
|
+
time,
|
|
352
368
|
);
|
|
353
369
|
if (freshMatch && freshMatch.id !== posAnim.id) {
|
|
354
370
|
posAnim = freshMatch;
|
|
355
371
|
}
|
|
356
372
|
}
|
|
357
373
|
|
|
358
|
-
const cbs = { commitMutation, fetchAnimations: fetchFallbackAnimations, stamp
|
|
374
|
+
const cbs = { commitMutation, fetchAnimations: fetchFallbackAnimations, stamp };
|
|
359
375
|
if (route === "whole-path") {
|
|
360
376
|
await commitWholePathOffset(selection, posAnim, offset, gsapPos, iframe, selector, cbs);
|
|
361
377
|
} else {
|
|
@@ -412,7 +428,9 @@ export async function tryGsapRotationIntercept(
|
|
|
412
428
|
iframe: HTMLIFrameElement | null,
|
|
413
429
|
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
414
430
|
fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
|
|
431
|
+
stamp?: DragStamp,
|
|
415
432
|
): Promise<GsapEditOutcome> {
|
|
433
|
+
const time = editMoment(stamp).time;
|
|
416
434
|
const fetchedAnimations = fetchFallbackAnimations ? await fetchFallbackAnimations() : [];
|
|
417
435
|
const outcome = preflightGsapRotationIntercept(selection, animations, iframe, fetchedAnimations);
|
|
418
436
|
if (outcome.status === "blocked") return outcome;
|
|
@@ -429,6 +447,7 @@ export async function tryGsapRotationIntercept(
|
|
|
429
447
|
selection,
|
|
430
448
|
commitMutation,
|
|
431
449
|
postSplitFetch,
|
|
450
|
+
time,
|
|
432
451
|
);
|
|
433
452
|
const resolvedAnimations = resolved?.animations ?? workingAnimations;
|
|
434
453
|
|
|
@@ -440,6 +459,7 @@ export async function tryGsapRotationIntercept(
|
|
|
440
459
|
if (!anim) {
|
|
441
460
|
anim = pickClosestToPlayhead(
|
|
442
461
|
workingAnimations.filter((a) => animationWritesAnyProperty(a, ROTATION_CHANNEL_SET)),
|
|
462
|
+
time,
|
|
443
463
|
);
|
|
444
464
|
}
|
|
445
465
|
|
|
@@ -462,7 +482,7 @@ export async function tryGsapRotationIntercept(
|
|
|
462
482
|
return { status: "persisted" };
|
|
463
483
|
}
|
|
464
484
|
|
|
465
|
-
const pct = computeCurrentPercentage(selection, anim);
|
|
485
|
+
const pct = computeCurrentPercentage(selection, anim, time);
|
|
466
486
|
|
|
467
487
|
// With auto-keyframe off (#1808), a rotation tween already exists for this
|
|
468
488
|
// element (checked above) so nudge it as a whole rather than adding a
|
|
@@ -485,7 +505,7 @@ export async function tryGsapRotationIntercept(
|
|
|
485
505
|
anim,
|
|
486
506
|
{ rotation: newRotation },
|
|
487
507
|
iframe,
|
|
488
|
-
{ commitMutation, fetchAnimations: fetchFallbackAnimations },
|
|
508
|
+
{ commitMutation, fetchAnimations: fetchFallbackAnimations, stamp },
|
|
489
509
|
{ label: "Rotate", backfill: { rotation: newRotation }, holdFromStart: true },
|
|
490
510
|
);
|
|
491
511
|
}
|
|
@@ -1,18 +1,14 @@
|
|
|
1
1
|
// @vitest-environment happy-dom
|
|
2
2
|
// Real Chrome with the built runtime: its seek, not a plain GSAP seek, decides what a saved edit shows.
|
|
3
|
-
import { readFileSync } from "node:fs";
|
|
4
|
-
import { createRequire } from "node:module";
|
|
5
3
|
import { gsap } from "gsap";
|
|
6
4
|
import { parseGsapScriptAcorn } from "@hyperframes/parsers/gsap-parser-acorn";
|
|
7
5
|
import { replaceTweenWithKeyframesInScript } from "@hyperframes/parsers/gsap-writer-acorn";
|
|
8
|
-
import puppeteer from "puppeteer-core";
|
|
9
6
|
import { expect, it, vi } from "vitest";
|
|
10
|
-
import {
|
|
7
|
+
import { launchTestChrome, showWithRuntime } from "../../tests/chromeTestUtils";
|
|
11
8
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
12
9
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
13
10
|
import { planValueEdit } from "./gsapValueAtPlayhead";
|
|
14
11
|
|
|
15
|
-
const require = createRequire(import.meta.url);
|
|
16
12
|
vi.setConfig({ testTimeout: 60_000 });
|
|
17
13
|
|
|
18
14
|
const SRC = `var tl = gsap.timeline({ paused: true });
|
|
@@ -49,25 +45,10 @@ const page = (script: string) => `<!doctype html><html><body>
|
|
|
49
45
|
|
|
50
46
|
it("a drag on a fromTo's start shows the dropped x there once the runtime seeks", async () => {
|
|
51
47
|
const written = writeDrag(90, 2);
|
|
52
|
-
const
|
|
53
|
-
if (!executablePath) throw new Error("no Chrome found: set HYPERFRAMES_BROWSER_PATH");
|
|
54
|
-
const browser = await puppeteer.launch({
|
|
55
|
-
executablePath,
|
|
56
|
-
headless: true,
|
|
57
|
-
args: ["--no-sandbox"],
|
|
58
|
-
});
|
|
48
|
+
const browser = await launchTestChrome();
|
|
59
49
|
try {
|
|
60
50
|
const tab = await browser.newPage();
|
|
61
|
-
await tab
|
|
62
|
-
// Only the GSAP script is served; anything else is refused, never fetched.
|
|
63
|
-
tab.on("request", (request) =>
|
|
64
|
-
request.url().endsWith("/gsap.min.js")
|
|
65
|
-
? request.respond({ body: readFileSync(require.resolve("gsap/dist/gsap.min.js"), "utf8") })
|
|
66
|
-
: request.abort("blockedbyclient"),
|
|
67
|
-
);
|
|
68
|
-
await tab.setContent(page(written), { waitUntil: "load" });
|
|
69
|
-
await tab.evaluate(readFileSync(require.resolve("@hyperframes/core/runtime"), "utf8"));
|
|
70
|
-
await tab.waitForFunction(() => "__player" in window);
|
|
51
|
+
await showWithRuntime(tab, page(written));
|
|
71
52
|
const shown = await tab.evaluate(() => {
|
|
72
53
|
const w = window as unknown as {
|
|
73
54
|
__player: { seek(t: number): void };
|
|
@@ -8,6 +8,7 @@ import { boxSteps, liveTween, previewWith, tween } from "./gsapParsedTween.test-
|
|
|
8
8
|
import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
|
|
9
9
|
import { tryGsapDragIntercept, tryGsapRotationIntercept } from "./gsapRuntimeBridge";
|
|
10
10
|
import { planValueAtPlayhead, planValueEdit, type PlayheadEdit } from "./gsapValueAtPlayhead";
|
|
11
|
+
import { STUDIO_EDIT_MOMENT_ATTR } from "../components/editor/manualEditsTypes";
|
|
11
12
|
|
|
12
13
|
let el: HTMLElement;
|
|
13
14
|
let selection: DomEditSelection;
|
|
@@ -181,6 +182,20 @@ describe("a move on a GSAP-animated layer, at the playhead", () => {
|
|
|
181
182
|
]);
|
|
182
183
|
});
|
|
183
184
|
|
|
185
|
+
it("keys the time the drag was pressed at, wherever the playhead is when it commits", async () => {
|
|
186
|
+
const { keys, live } = boxSteps([
|
|
187
|
+
[2, { x: 60 }],
|
|
188
|
+
[1, { x: 120 }],
|
|
189
|
+
]);
|
|
190
|
+
el.setAttribute(STUDIO_EDIT_MOMENT_ATTR, JSON.stringify({ time: 1, keyframePct: null }));
|
|
191
|
+
const { mutations } = await drag([keys], [live(el)], { time: 2, base: [30, 0], dx: 10 });
|
|
192
|
+
expect(mutations[0].keyframes[0]).toEqual({
|
|
193
|
+
percentage: 33.333,
|
|
194
|
+
properties: { x: 40, y: 0 },
|
|
195
|
+
ease: "none",
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
184
199
|
it("never writes x and y into a tween that only animates size", async () => {
|
|
185
200
|
const size = tween({
|
|
186
201
|
id: "#box-to-0-size",
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
2
2
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
3
3
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
4
|
+
import type { EditMoment } from "../components/editor/manualEditsTypes";
|
|
5
|
+
import { editMoment, playheadMoment } from "./editMoment";
|
|
4
6
|
import { resolveTweenDuration, resolveTweenStart } from "../utils/globalTimeCompiler";
|
|
5
7
|
import { ARRAY_STEP_EASE, PERCENTAGE_SEGMENT_EASE, runEaseOf } from "../utils/gsapKeyframeEases";
|
|
6
8
|
import { roundTo3 } from "../utils/rounding";
|
|
@@ -287,11 +289,15 @@ export function planValueEdit(
|
|
|
287
289
|
anim: GsapAnimation,
|
|
288
290
|
values: Record<string, number>,
|
|
289
291
|
iframe: HTMLIFrameElement | null,
|
|
290
|
-
{
|
|
292
|
+
{
|
|
293
|
+
backfill,
|
|
294
|
+
holdFromStart,
|
|
295
|
+
moment = playheadMoment(),
|
|
296
|
+
}: Pick<PlayheadEdit, "backfill" | "holdFromStart"> & { moment?: EditMoment } = {},
|
|
291
297
|
): PlayheadEditPlan {
|
|
292
298
|
// One keyframe of a tween its siblings share would move them all.
|
|
293
299
|
if (tweenReach(anim, selection.element) === "shared") return refuse("shared-tween");
|
|
294
|
-
const { activeKeyframePct, currentTime } =
|
|
300
|
+
const { keyframePct: activeKeyframePct, time: currentTime } = moment;
|
|
295
301
|
const tween = findParsedTween(iframe, selection.element, anim);
|
|
296
302
|
const timed = withExactStepTimes(anim, tween);
|
|
297
303
|
return planValueAtPlayhead({
|
|
@@ -321,11 +327,12 @@ export async function commitValueAtPlayhead(
|
|
|
321
327
|
},
|
|
322
328
|
): Promise<GsapEditOutcome> {
|
|
323
329
|
await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
|
|
324
|
-
const
|
|
330
|
+
const moment = editMoment(callbacks.stamp);
|
|
331
|
+
const { setActiveKeyframePct } = usePlayerStore.getState();
|
|
325
332
|
const tween = findParsedTween(iframe, selection.element, anim);
|
|
326
333
|
const parkAt =
|
|
327
|
-
|
|
328
|
-
const plan = planValueEdit(selection, anim, values, iframe, options);
|
|
334
|
+
moment.keyframePct == null ? null : exactKeyframePct(anim, tween, moment.keyframePct);
|
|
335
|
+
const plan = planValueEdit(selection, anim, values, iframe, { ...options, moment });
|
|
329
336
|
if (!plan.ok) return { status: "blocked", reason: "keyframes-uneditable", detail: plan.reason };
|
|
330
337
|
await callbacks.commitMutation(selection, plan.mutation, {
|
|
331
338
|
label: options.label,
|
|
@@ -233,6 +233,28 @@ describe("extendRootDurationIfNeeded", () => {
|
|
|
233
233
|
});
|
|
234
234
|
});
|
|
235
235
|
|
|
236
|
+
describe("buildTimelineMoveTimingPatch", () => {
|
|
237
|
+
it.each([
|
|
238
|
+
{ root: ' data-duration="20"', length: 'data-composition-id="c" data-duration="8"' },
|
|
239
|
+
{ root: "", length: 'data-composition-id="c">' },
|
|
240
|
+
])(
|
|
241
|
+
"parses the saved file once to move a clip and sync the length (root$root)",
|
|
242
|
+
({ root, length }) => {
|
|
243
|
+
const source = `<div data-composition-id="c"${root}><div id="a" class="clip" data-start="1" data-duration="3"></div></div>`;
|
|
244
|
+
const parse = vi.spyOn(DOMParser.prototype, "parseFromString");
|
|
245
|
+
try {
|
|
246
|
+
const patched = buildTimelineMoveTimingPatch(source, { id: "a" }, 5, 3);
|
|
247
|
+
|
|
248
|
+
expect(patched).toContain('data-start="5"');
|
|
249
|
+
expect(patched).toContain(length);
|
|
250
|
+
expect(parse).toHaveBeenCalledTimes(1);
|
|
251
|
+
} finally {
|
|
252
|
+
parse.mockRestore();
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
|
|
236
258
|
describe("buildTimelineResizeTimingPatch", () => {
|
|
237
259
|
it("moves a source-only in-point by the caller's own start change", () => {
|
|
238
260
|
const source = `<div id="root"><video id="a" class="clip" data-start="5" data-duration="3" data-media-start="0.337"></video></div>`;
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
} from "../player/components/timelineEditing";
|
|
16
16
|
import { getElementZIndex } from "../player/lib/layerOrdering";
|
|
17
17
|
import {
|
|
18
|
-
|
|
18
|
+
furthestClipEndFromDocument,
|
|
19
19
|
getTimelineElementIdentity,
|
|
20
20
|
playbackStartAttributeForElement,
|
|
21
21
|
readPlaybackStartAttributes,
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";
|
|
30
30
|
import type { TimelineZIndexReorderCommit } from "./useTimelineEditingTypes";
|
|
31
31
|
import { setCompositionDurationToContent } from "../utils/timelineAssetDrop";
|
|
32
|
+
import { rootCompositionDurationOf } from "../utils/rootDuration";
|
|
32
33
|
import { readFileContent } from "./timelineTimingSync";
|
|
33
34
|
import {
|
|
34
35
|
findElementForSelection,
|
|
@@ -263,7 +264,22 @@ export function applyTimelineMoveAttributes(
|
|
|
263
264
|
// Root data-duration follows the furthest clip end in the source; the store's durations are
|
|
264
265
|
// truncated to the current length, so reading them would ratchet the duration down every edit.
|
|
265
266
|
export function syncCompositionDurationToContent(source: string): string {
|
|
266
|
-
return
|
|
267
|
+
return syncCompositionDurationWithEnd(source).source;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The synced source and the clip end it was synced to, from one parse of the source. */
|
|
271
|
+
export function syncCompositionDurationWithEnd(source: string): {
|
|
272
|
+
source: string;
|
|
273
|
+
contentEnd: number;
|
|
274
|
+
} {
|
|
275
|
+
const doc = new DOMParser().parseFromString(source, "text/html");
|
|
276
|
+
const contentEnd = furthestClipEndFromDocument(doc);
|
|
277
|
+
const synced = setCompositionDurationToContent(
|
|
278
|
+
source,
|
|
279
|
+
contentEnd,
|
|
280
|
+
rootCompositionDurationOf(doc),
|
|
281
|
+
);
|
|
282
|
+
return { source: synced, contentEnd };
|
|
267
283
|
}
|
|
268
284
|
|
|
269
285
|
export function buildTimelineMoveTimingPatch(
|