@hyperframes/studio 0.8.90 → 0.8.91
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-DDjER_ii.js → hyperframes-player-zT2Bdx_l.js} +1 -1
- package/dist/assets/{index-5SFeUXP8.js → index-DSOLcE8X.js} +1 -1
- package/dist/assets/{index-CVsCxnlz.js → index-DV-z19zB.js} +1 -1
- package/dist/assets/{index-BKE3_N4v.js → index-V-0rfhe6.js} +168 -168
- package/dist/{chunk-KU4F7QZ3.js → chunk-QPSOTK6M.js} +8 -1
- package/dist/chunk-QPSOTK6M.js.map +1 -0
- package/dist/index.d.ts +12 -3
- package/dist/index.html +1 -1
- package/dist/index.js +1298 -1003
- package/dist/index.js.map +1 -1
- package/dist/player.js +1 -1
- package/package.json +8 -8
- package/src/components/TimelineToolbar.test.tsx +12 -0
- package/src/components/editor/MotionPathOverlay.tsx +5 -1
- package/src/components/editor/commitPreflightHandles.test.tsx +7 -20
- package/src/components/editor/elementPositionOffset.test.ts +62 -0
- package/src/components/editor/elementPositionOffset.ts +64 -0
- package/src/components/editor/motionPathCommit.test.ts +25 -5
- package/src/components/editor/motionPathSelection.ts +10 -3
- package/src/components/editor/useLayerRevealOverride.test.ts +8 -0
- package/src/components/editor/useLayerRevealOverride.ts +2 -1
- package/src/components/nle/NLEContext.tsx +1 -1
- package/src/components/nle/useTimelineEditCallbacks.test.tsx +53 -0
- package/src/hooks/domEditPersistFailure.ts +8 -6
- package/src/hooks/elementOffsetStager.ts +65 -0
- package/src/hooks/gestureTransaction.ts +4 -2
- package/src/hooks/gsapDragCommit.ts +1 -0
- package/src/hooks/gsapEditOutcome.ts +2 -1
- package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +157 -0
- package/src/hooks/gsapRuntimeBridge.ts +50 -14
- package/src/hooks/gsapShared.test.ts +1 -1
- package/src/hooks/gsapShared.ts +16 -12
- package/src/hooks/gsapTweenReach.ts +30 -0
- package/src/hooks/timelineEditingHelpers.ts +2 -1
- package/src/hooks/useClipboard.source.test.ts +58 -0
- package/src/hooks/useClipboard.ts +12 -3
- package/src/hooks/useCommitPreflightCapabilities.ts +2 -2
- package/src/hooks/useDomEditCommits.ts +2 -0
- package/src/hooks/useDomEditPositionPatchCommit.ts +4 -1
- package/src/hooks/useDomEditSession.ts +2 -0
- package/src/hooks/useDomGeometryCommit.test.tsx +102 -0
- package/src/hooks/useDomGeometryCommit.ts +42 -1
- package/src/hooks/useDomGeometryCommits.test.tsx +27 -0
- package/src/hooks/useDomGeometryCommits.ts +14 -1
- package/src/hooks/useGsapAwareEditing.test.tsx +113 -5
- package/src/hooks/useGsapAwareEditing.ts +44 -7
- package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +2 -1
- package/src/hooks/useGsapInteractionFailureTelemetry.ts +8 -4
- package/src/hooks/useRazorSplit.test.ts +5 -5
- package/src/hooks/useTimelineEditing.test.tsx +384 -0
- package/src/hooks/useTimelineEditing.ts +12 -12
- package/src/hooks/useTimelineGroupEditing.ts +16 -7
- package/src/player/components/Timeline.rulerSeek.test.tsx +111 -0
- package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
- package/src/player/components/TimelineProvider.tsx +1 -0
- package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
- package/src/player/components/TimelineTypes.ts +1 -1
- package/src/player/components/timelineClipDragCommit.test.ts +18 -8
- package/src/player/components/timelineClipDragCommit.ts +5 -0
- package/src/player/components/timelineClipDragPreview.test.ts +70 -2
- package/src/player/components/timelineClipDragPreview.ts +15 -12
- package/src/player/components/timelineEditing.ts +2 -1
- package/src/player/components/timelineGroupEditing.ts +7 -2
- package/src/player/components/timelineMultiDragPreview.test.ts +10 -8
- package/src/player/components/timelineMultiDragPreview.ts +8 -5
- package/src/player/components/timelineProviderStateBuilders.test.ts +1 -0
- package/src/player/components/timelineProviderStateBuilders.ts +7 -2
- package/src/player/components/timelineZMirror.test.ts +2 -2
- package/src/player/components/timelineZMirror.ts +1 -1
- package/src/player/components/useTimelineClipDrag.resize.test.tsx +8 -5
- package/src/player/components/useTimelineClipDrag.ts +3 -2
- package/src/player/components/useTimelinePlayhead.test.tsx +215 -26
- package/src/player/components/useTimelinePlayhead.ts +56 -27
- package/src/player/components/useTimelineProviderState.tsx +1 -0
- package/src/player/components/useTimelineZoom.ts +9 -1
- package/src/player/hooks/useTimelinePlayer.seek.test.ts +44 -0
- package/src/player/hooks/useTimelinePlayer.ts +7 -4
- package/src/player/lib/timelineDOM.test.ts +79 -0
- package/src/player/lib/timelineDOM.ts +16 -7
- package/src/player/lib/timelineIframeHelpers.test.ts +36 -0
- package/src/player/lib/timelineIframeHelpers.ts +12 -3
- package/src/player/store/liveTime.ts +6 -0
- package/src/player/store/playerStore.ts +2 -0
- package/src/player/store/timelineElement.ts +28 -3
- package/src/utils/razorSplitTransaction.test.ts +1 -1
- package/src/utils/razorSplitTransaction.ts +3 -3
- package/dist/chunk-KU4F7QZ3.js.map +0 -1
|
@@ -9,6 +9,18 @@ import { usePlayerStore, type TimelineElement } from "../player";
|
|
|
9
9
|
import { jsonResponse, requestUrl } from "./fetchStubTestUtils";
|
|
10
10
|
import { useElementLifecycleOps } from "./useElementLifecycleOps";
|
|
11
11
|
import { useTimelineEditing } from "./useTimelineEditing";
|
|
12
|
+
import {
|
|
13
|
+
buildMissingCompositionElements,
|
|
14
|
+
createTimelineElementFromManifestClip,
|
|
15
|
+
parseTimelineFromDOM,
|
|
16
|
+
} from "../player/lib/timelineDOM";
|
|
17
|
+
import type { ClipManifestClip, IframeWindow } from "../player/lib/playbackTypes";
|
|
18
|
+
import { computeResizePreview } from "../player/components/timelineClipDragPreview";
|
|
19
|
+
import {
|
|
20
|
+
buildTimelineGroupResizeMembers,
|
|
21
|
+
resolveTimelineGroupResizeChanges,
|
|
22
|
+
} from "../player/components/timelineGroupEditing";
|
|
23
|
+
import { createRuntimeStartTimeResolver } from "@hyperframes/core/runtime/start-resolver";
|
|
12
24
|
|
|
13
25
|
vi.mock("../components/editor/manualEditingAvailability", async (importOriginal) => {
|
|
14
26
|
const actual =
|
|
@@ -1969,3 +1981,375 @@ describe("useTimelineEditing effect saves report what happened", () => {
|
|
|
1969
1981
|
unmount();
|
|
1970
1982
|
});
|
|
1971
1983
|
});
|
|
1984
|
+
|
|
1985
|
+
// main 0 > intro 2 > logo 3 > badge 1: rows are master time, files are local.
|
|
1986
|
+
const NESTED_PREVIEW = `
|
|
1987
|
+
<div data-composition-id="main" data-start="0" data-duration="20">
|
|
1988
|
+
<div id="intro" data-hf-id="hf-intro" data-composition-id="intro" data-start="2" data-duration="10">
|
|
1989
|
+
<div data-composition-id="intro" data-composition-file="compositions/intro.html">
|
|
1990
|
+
<video id="vo" data-start="7" data-duration="2" data-hf-media-start-basis="global"></video>
|
|
1991
|
+
<div id="logo" data-hf-id="hf-logo" data-composition-id="logo" data-start="3" data-duration="5">
|
|
1992
|
+
<div data-composition-id="logo" data-composition-file="compositions/logo.html">
|
|
1993
|
+
<div id="badge" data-hf-id="hf-badge" class="clip" data-start="1" data-duration="2"></div>
|
|
1994
|
+
<div id="star" data-hf-id="hf-star" data-composition-id="star" data-start="1.5" data-duration="2">
|
|
1995
|
+
<div data-composition-id="star" data-composition-file="compositions/star.html"></div>
|
|
1996
|
+
</div>
|
|
1997
|
+
</div>
|
|
1998
|
+
</div>
|
|
1999
|
+
</div>
|
|
2000
|
+
</div>
|
|
2001
|
+
</div>`;
|
|
2002
|
+
const NESTED_FILES: Record<string, string> = {
|
|
2003
|
+
"index.html": [
|
|
2004
|
+
`<div data-composition-id="main" data-duration="20">`,
|
|
2005
|
+
` <div id="intro" data-composition-id="intro" data-composition-src="compositions/intro.html" data-start="2" data-duration="10"></div>`,
|
|
2006
|
+
`</div>`,
|
|
2007
|
+
].join("\n"),
|
|
2008
|
+
"compositions/intro.html": [
|
|
2009
|
+
`<div data-composition-id="intro" data-duration="10">`,
|
|
2010
|
+
` <div id="intro-bg" class="clip" data-start="0" data-duration="10"></div>`,
|
|
2011
|
+
` <video id="vo" data-start="7" data-duration="2" data-hf-media-start-basis="global"></video>`,
|
|
2012
|
+
` <div id="logo" data-composition-id="logo" data-composition-src="compositions/logo.html" data-start="3" data-duration="5"></div>`,
|
|
2013
|
+
`</div>`,
|
|
2014
|
+
].join("\n"),
|
|
2015
|
+
"compositions/logo.html": [
|
|
2016
|
+
`<div data-composition-id="logo" data-duration="5">`,
|
|
2017
|
+
` <div id="logo-bg" class="clip" data-start="0" data-duration="5"></div>`,
|
|
2018
|
+
` <div id="badge" class="clip" data-start="1" data-duration="2"></div>`,
|
|
2019
|
+
` <div id="star" data-composition-id="star" data-composition-src="compositions/star.html" data-start="1.5" data-duration="2"></div>`,
|
|
2020
|
+
`</div>`,
|
|
2021
|
+
].join("\n"),
|
|
2022
|
+
};
|
|
2023
|
+
|
|
2024
|
+
function setupNestedHarness() {
|
|
2025
|
+
const iframe = document.createElement("iframe");
|
|
2026
|
+
document.body.append(iframe);
|
|
2027
|
+
const doc = iframe.contentDocument!;
|
|
2028
|
+
doc.body.innerHTML = NESTED_PREVIEW;
|
|
2029
|
+
const row = (domId: string) => parseTimelineFromDOM(doc, 20).find((e) => e.domId === domId)!;
|
|
2030
|
+
// The app's path: the runtime manifest's root clips, then the missing-host pass.
|
|
2031
|
+
const manifestRow = (domId: string) => {
|
|
2032
|
+
const intro = { id: "intro", start: 2, duration: 10, track: 0, kind: "composition" };
|
|
2033
|
+
const clip = { ...intro, tagName: "div", compositionId: "intro" } as ClipManifestClip;
|
|
2034
|
+
const hostEl = doc.getElementById("intro");
|
|
2035
|
+
const roots = [createTimelineElementFromManifestClip({ clip, fallbackIndex: 0, doc, hostEl })];
|
|
2036
|
+
const win = iframe.contentWindow as IframeWindow;
|
|
2037
|
+
return buildMissingCompositionElements(doc, win, roots, 20).missing.find(
|
|
2038
|
+
(e) => e.domId === domId,
|
|
2039
|
+
)!;
|
|
2040
|
+
};
|
|
2041
|
+
const playsAt = (domId: string) => {
|
|
2042
|
+
const resolver = createRuntimeStartTimeResolver({
|
|
2043
|
+
includeAuthoredTimingAttrs: true,
|
|
2044
|
+
documentRef: doc,
|
|
2045
|
+
});
|
|
2046
|
+
const el = doc.getElementById(domId)!;
|
|
2047
|
+
return el.tagName === "VIDEO"
|
|
2048
|
+
? resolver.resolveMediaStartForElement(el)
|
|
2049
|
+
: resolver.resolveStartForElement(el);
|
|
2050
|
+
};
|
|
2051
|
+
const writeProjectFile = vi.fn<(path: string, content: string) => Promise<void>>(async () => {});
|
|
2052
|
+
const written = (path: string, domId: string, attr: string) => {
|
|
2053
|
+
const call = writeProjectFile.mock.calls.find(([p]) => p === path);
|
|
2054
|
+
if (!call) throw new Error(`nothing written to ${path}`);
|
|
2055
|
+
const el = new DOMParser().parseFromString(call[1], "text/html").getElementById(domId);
|
|
2056
|
+
return el?.getAttribute(attr);
|
|
2057
|
+
};
|
|
2058
|
+
const writtenRootDuration = (path: string) => {
|
|
2059
|
+
const call = writeProjectFile.mock.calls.find(([p]) => p === path)!;
|
|
2060
|
+
return new DOMParser()
|
|
2061
|
+
.parseFromString(call[1], "text/html")
|
|
2062
|
+
.querySelector("[data-composition-id]")
|
|
2063
|
+
?.getAttribute("data-duration");
|
|
2064
|
+
};
|
|
2065
|
+
const fetchMock = stubProjectFetch(NESTED_FILES);
|
|
2066
|
+
const scaleCalls = () =>
|
|
2067
|
+
fetchMock.mock.calls
|
|
2068
|
+
.filter((call) => requestUrl(call[0]).includes("/gsap-mutations/"))
|
|
2069
|
+
.map((call) => JSON.parse(String((call[1] as RequestInit).body)))
|
|
2070
|
+
.filter((body) => body.type === "scale-positions");
|
|
2071
|
+
usePlayerStore.getState().setDuration(20);
|
|
2072
|
+
const hook = renderTimelineEditingHook({
|
|
2073
|
+
timelineElements: parseTimelineFromDOM(doc, 20),
|
|
2074
|
+
iframe,
|
|
2075
|
+
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
|
2076
|
+
projectId: "p1",
|
|
2077
|
+
writeProjectFile,
|
|
2078
|
+
recordEdit: vi.fn(async () => {}),
|
|
2079
|
+
});
|
|
2080
|
+
return {
|
|
2081
|
+
doc,
|
|
2082
|
+
row,
|
|
2083
|
+
manifestRow,
|
|
2084
|
+
playsAt,
|
|
2085
|
+
written,
|
|
2086
|
+
writtenRootDuration,
|
|
2087
|
+
writeProjectFile,
|
|
2088
|
+
scaleCalls,
|
|
2089
|
+
...hook,
|
|
2090
|
+
};
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
describe("useTimelineEditing: nested rows write composition-local starts", () => {
|
|
2094
|
+
it("round-trips a depth-2 drag: drawn and played at the drop point, written local", async () => {
|
|
2095
|
+
const h = setupNestedHarness();
|
|
2096
|
+
const logo = h.row("logo");
|
|
2097
|
+
expect(logo.start).toBe(5);
|
|
2098
|
+
|
|
2099
|
+
await act(async () => {
|
|
2100
|
+
await h.move(logo, { start: 6, track: logo.track });
|
|
2101
|
+
});
|
|
2102
|
+
|
|
2103
|
+
expect(h.written("compositions/intro.html", "logo", "data-start")).toBe("4");
|
|
2104
|
+
expect(h.doc.getElementById("logo")?.getAttribute("data-start")).toBe("4");
|
|
2105
|
+
expect(h.row("logo").start).toBe(6);
|
|
2106
|
+
expect(h.playsAt("logo")).toBe(6);
|
|
2107
|
+
expect(h.writtenRootDuration("compositions/intro.html")).toBe("10");
|
|
2108
|
+
h.unmount();
|
|
2109
|
+
});
|
|
2110
|
+
|
|
2111
|
+
it("round-trips a depth-3 drag through two enclosing hosts", async () => {
|
|
2112
|
+
const h = setupNestedHarness();
|
|
2113
|
+
const badge = h.row("badge");
|
|
2114
|
+
expect(badge.start).toBe(6);
|
|
2115
|
+
|
|
2116
|
+
await act(async () => {
|
|
2117
|
+
await h.move(badge, { start: 7, track: badge.track });
|
|
2118
|
+
});
|
|
2119
|
+
|
|
2120
|
+
expect(h.written("compositions/logo.html", "badge", "data-start")).toBe("2");
|
|
2121
|
+
expect(h.row("badge").start).toBe(7);
|
|
2122
|
+
expect(h.playsAt("badge")).toBe(7);
|
|
2123
|
+
expect(h.writtenRootDuration("compositions/logo.html")).toBe("5");
|
|
2124
|
+
h.unmount();
|
|
2125
|
+
});
|
|
2126
|
+
|
|
2127
|
+
it("round-trips a legacy root-time video inside a sub-composition, writing master time", async () => {
|
|
2128
|
+
const h = setupNestedHarness();
|
|
2129
|
+
const vo = h.row("vo");
|
|
2130
|
+
expect(vo.start).toBe(7);
|
|
2131
|
+
|
|
2132
|
+
await act(async () => {
|
|
2133
|
+
await h.move(vo, { start: 8, track: vo.track });
|
|
2134
|
+
});
|
|
2135
|
+
|
|
2136
|
+
expect(h.written("compositions/intro.html", "vo", "data-start")).toBe("8");
|
|
2137
|
+
expect(h.row("vo").start).toBe(8);
|
|
2138
|
+
expect(h.playsAt("vo")).toBe(8);
|
|
2139
|
+
h.unmount();
|
|
2140
|
+
});
|
|
2141
|
+
|
|
2142
|
+
it("writes a local start on a head trim and leaves a tail trim's start alone", async () => {
|
|
2143
|
+
const h = setupNestedHarness();
|
|
2144
|
+
await act(async () => {
|
|
2145
|
+
await h.resize(h.row("logo"), { start: 5.5, duration: 4.5, playbackStart: undefined });
|
|
2146
|
+
});
|
|
2147
|
+
expect(h.written("compositions/intro.html", "logo", "data-start")).toBe("3.5");
|
|
2148
|
+
expect(h.written("compositions/intro.html", "logo", "data-duration")).toBe("4.5");
|
|
2149
|
+
expect(h.row("logo")).toMatchObject({ start: 5.5, duration: 4.5 });
|
|
2150
|
+
|
|
2151
|
+
h.writeProjectFile.mockClear();
|
|
2152
|
+
await act(async () => {
|
|
2153
|
+
await h.resize(h.row("logo"), { start: 5.5, duration: 3, playbackStart: undefined });
|
|
2154
|
+
});
|
|
2155
|
+
expect(h.doc.getElementById("logo")?.getAttribute("data-start")).toBe("3.5");
|
|
2156
|
+
expect(h.doc.getElementById("logo")?.getAttribute("data-duration")).toBe("3");
|
|
2157
|
+
h.unmount();
|
|
2158
|
+
});
|
|
2159
|
+
|
|
2160
|
+
it("scales a head-trimmed nested row's tweens over its local window", async () => {
|
|
2161
|
+
const h = setupNestedHarness();
|
|
2162
|
+
await act(async () => {
|
|
2163
|
+
await h.resize(h.row("logo"), { start: 5.5, duration: 4.5, playbackStart: undefined });
|
|
2164
|
+
});
|
|
2165
|
+
expect(h.scaleCalls()).toEqual([
|
|
2166
|
+
expect.objectContaining({ oldStart: 3, oldDuration: 5, newStart: 3.5, newDuration: 4.5 }),
|
|
2167
|
+
]);
|
|
2168
|
+
h.unmount();
|
|
2169
|
+
});
|
|
2170
|
+
|
|
2171
|
+
it("writes local starts and a local scale window for a nested group resize", async () => {
|
|
2172
|
+
const h = setupNestedHarness();
|
|
2173
|
+
await act(async () => {
|
|
2174
|
+
await h.groupResize([{ element: h.row("logo"), start: 5.5, duration: 4.5 }]);
|
|
2175
|
+
});
|
|
2176
|
+
expect(h.written("compositions/intro.html", "logo", "data-start")).toBe("3.5");
|
|
2177
|
+
expect(h.scaleCalls()).toEqual([
|
|
2178
|
+
expect.objectContaining({ oldStart: 3, oldDuration: 5, newStart: 3.5, newDuration: 4.5 }),
|
|
2179
|
+
]);
|
|
2180
|
+
h.unmount();
|
|
2181
|
+
});
|
|
2182
|
+
|
|
2183
|
+
it("scales a legacy root-time video's tweens on its host's clock, single and group", async () => {
|
|
2184
|
+
const window = { oldStart: 5, oldDuration: 2, newStart: 5.5, newDuration: 1.5 };
|
|
2185
|
+
for (const group of [false, true]) {
|
|
2186
|
+
const h = setupNestedHarness();
|
|
2187
|
+
const vo = h.row("vo");
|
|
2188
|
+
await act(async () => {
|
|
2189
|
+
if (group) await h.groupResize([{ element: vo, start: 7.5, duration: 1.5 }]);
|
|
2190
|
+
else await h.resize(vo, { start: 7.5, duration: 1.5, playbackStart: undefined });
|
|
2191
|
+
});
|
|
2192
|
+
expect(h.scaleCalls()).toEqual([expect.objectContaining(window)]);
|
|
2193
|
+
h.unmount();
|
|
2194
|
+
vi.unstubAllGlobals();
|
|
2195
|
+
}
|
|
2196
|
+
});
|
|
2197
|
+
|
|
2198
|
+
it("stops a head trim past the host's start at the host's start, end held", async () => {
|
|
2199
|
+
const h = setupNestedHarness();
|
|
2200
|
+
const logo = h.manifestRow("logo");
|
|
2201
|
+
expect(logo).toMatchObject({ start: 5, duration: 5, parentCompositionStart: 2 });
|
|
2202
|
+
const pps = 100;
|
|
2203
|
+
const preview = computeResizePreview(
|
|
2204
|
+
{
|
|
2205
|
+
element: logo,
|
|
2206
|
+
edge: "start",
|
|
2207
|
+
originClientX: 0,
|
|
2208
|
+
previewStart: 5,
|
|
2209
|
+
previewDuration: 5,
|
|
2210
|
+
started: true,
|
|
2211
|
+
pointerId: 1,
|
|
2212
|
+
},
|
|
2213
|
+
-4 * pps,
|
|
2214
|
+
{ scroll: null, pps, buildSnapTargets: () => [] },
|
|
2215
|
+
);
|
|
2216
|
+
expect(preview).toMatchObject({ previewStart: 2, previewDuration: 8 });
|
|
2217
|
+
|
|
2218
|
+
await act(async () => {
|
|
2219
|
+
await h.resize(logo, {
|
|
2220
|
+
start: preview.previewStart,
|
|
2221
|
+
duration: preview.previewDuration,
|
|
2222
|
+
playbackStart: undefined,
|
|
2223
|
+
});
|
|
2224
|
+
});
|
|
2225
|
+
expect(h.written("compositions/intro.html", "logo", "data-start")).toBe("0");
|
|
2226
|
+
expect(h.written("compositions/intro.html", "logo", "data-duration")).toBe("8");
|
|
2227
|
+
expect(h.row("logo")).toMatchObject({ start: 2, duration: 8 });
|
|
2228
|
+
expect(h.scaleCalls()).toEqual([
|
|
2229
|
+
expect.objectContaining({ oldStart: 3, oldDuration: 5, newStart: 0, newDuration: 8 }),
|
|
2230
|
+
]);
|
|
2231
|
+
h.unmount();
|
|
2232
|
+
});
|
|
2233
|
+
|
|
2234
|
+
it("stops a group head trim at the nested member's host start", async () => {
|
|
2235
|
+
const h = setupNestedHarness();
|
|
2236
|
+
const logo = h.manifestRow("logo");
|
|
2237
|
+
const outro: TimelineElement = {
|
|
2238
|
+
...logo,
|
|
2239
|
+
id: "outro",
|
|
2240
|
+
key: "outro",
|
|
2241
|
+
domId: undefined,
|
|
2242
|
+
start: 12,
|
|
2243
|
+
parentCompositionStart: 0,
|
|
2244
|
+
};
|
|
2245
|
+
const members = buildTimelineGroupResizeMembers(
|
|
2246
|
+
[logo, outro],
|
|
2247
|
+
new Set([logo.key ?? logo.id, "outro"]),
|
|
2248
|
+
logo.key ?? logo.id,
|
|
2249
|
+
"start",
|
|
2250
|
+
)!;
|
|
2251
|
+
const changes = resolveTimelineGroupResizeChanges(members, "start", -4);
|
|
2252
|
+
const logoChange = changes.find((c) => c.key === (logo.key ?? logo.id))!;
|
|
2253
|
+
expect(logoChange).toMatchObject({ start: 2, duration: 8 });
|
|
2254
|
+
|
|
2255
|
+
await act(async () => {
|
|
2256
|
+
await h.groupResize([
|
|
2257
|
+
{ element: logo, start: logoChange.start, duration: logoChange.duration },
|
|
2258
|
+
]);
|
|
2259
|
+
});
|
|
2260
|
+
expect(h.written("compositions/intro.html", "logo", "data-start")).toBe("0");
|
|
2261
|
+
expect(h.written("compositions/intro.html", "logo", "data-duration")).toBe("8");
|
|
2262
|
+
expect(h.scaleCalls()).toEqual([
|
|
2263
|
+
expect.objectContaining({ oldStart: 3, oldDuration: 5, newStart: 0, newDuration: 8 }),
|
|
2264
|
+
]);
|
|
2265
|
+
h.unmount();
|
|
2266
|
+
});
|
|
2267
|
+
|
|
2268
|
+
it("round-trips a depth-3 host from the manifest path", async () => {
|
|
2269
|
+
const h = setupNestedHarness();
|
|
2270
|
+
const star = h.manifestRow("star");
|
|
2271
|
+
expect(star).toMatchObject({ start: 6.5, parentCompositionStart: 5 });
|
|
2272
|
+
|
|
2273
|
+
await act(async () => {
|
|
2274
|
+
await h.move(star, { start: 7.5, track: star.track });
|
|
2275
|
+
});
|
|
2276
|
+
|
|
2277
|
+
expect(h.written("compositions/logo.html", "star", "data-start")).toBe("2.5");
|
|
2278
|
+
expect(h.playsAt("star")).toBe(7.5);
|
|
2279
|
+
expect(h.manifestRow("star").start).toBe(7.5);
|
|
2280
|
+
h.unmount();
|
|
2281
|
+
});
|
|
2282
|
+
|
|
2283
|
+
it("clamps a nested row dropped before its host's start to the host's start", async () => {
|
|
2284
|
+
const h = setupNestedHarness();
|
|
2285
|
+
const logo = h.row("logo");
|
|
2286
|
+
await act(async () => {
|
|
2287
|
+
await h.move(logo, { start: 1, track: logo.track });
|
|
2288
|
+
});
|
|
2289
|
+
expect(h.written("compositions/intro.html", "logo", "data-start")).toBe("0");
|
|
2290
|
+
expect(h.row("logo").start).toBe(2);
|
|
2291
|
+
h.unmount();
|
|
2292
|
+
});
|
|
2293
|
+
|
|
2294
|
+
it("writes local starts for nested rows in a group move", async () => {
|
|
2295
|
+
const h = setupNestedHarness();
|
|
2296
|
+
await act(async () => {
|
|
2297
|
+
await h.groupMove([{ element: h.row("logo"), start: 6 }]);
|
|
2298
|
+
});
|
|
2299
|
+
expect(h.written("compositions/intro.html", "logo", "data-start")).toBe("4");
|
|
2300
|
+
expect(h.row("logo").start).toBe(6);
|
|
2301
|
+
h.unmount();
|
|
2302
|
+
});
|
|
2303
|
+
|
|
2304
|
+
it("leaves a top-level row's start as written", async () => {
|
|
2305
|
+
const h = setupNestedHarness();
|
|
2306
|
+
const intro = h.row("intro");
|
|
2307
|
+
expect(intro.parentCompositionStart).toBe(0);
|
|
2308
|
+
|
|
2309
|
+
await act(async () => {
|
|
2310
|
+
await h.move(intro, { start: 3, track: intro.track });
|
|
2311
|
+
});
|
|
2312
|
+
|
|
2313
|
+
expect(h.written("index.html", "intro", "data-start")).toBe("3");
|
|
2314
|
+
expect(h.row("intro").start).toBe(3);
|
|
2315
|
+
h.unmount();
|
|
2316
|
+
});
|
|
2317
|
+
|
|
2318
|
+
it("hands the SDK a local start for a nested clip in the open file", async () => {
|
|
2319
|
+
const source = [
|
|
2320
|
+
`<div data-hf-id="hf-main" data-hf-root data-composition-id="main" data-duration="20">`,
|
|
2321
|
+
` <div id="intro" data-hf-id="hf-intro" data-composition-id="intro" data-start="2" data-duration="10">`,
|
|
2322
|
+
` <div id="clip" data-hf-id="hf-clip" data-start="1" data-duration="2"></div>`,
|
|
2323
|
+
` </div>`,
|
|
2324
|
+
`</div>`,
|
|
2325
|
+
].join("\n");
|
|
2326
|
+
const iframe = document.createElement("iframe");
|
|
2327
|
+
document.body.append(iframe);
|
|
2328
|
+
iframe.contentDocument!.body.innerHTML = source;
|
|
2329
|
+
const clip = parseTimelineFromDOM(iframe.contentDocument!, 20).find((e) => e.domId === "clip")!;
|
|
2330
|
+
expect(clip.start).toBe(3);
|
|
2331
|
+
const sdkSession = await openComposition(source);
|
|
2332
|
+
const writeProjectFile = vi.fn<(...args: unknown[]) => Promise<void>>(async () => {});
|
|
2333
|
+
stubProjectFetch(source);
|
|
2334
|
+
usePlayerStore.getState().setDuration(20);
|
|
2335
|
+
const hook = renderTimelineEditingHook({
|
|
2336
|
+
timelineElements: [clip],
|
|
2337
|
+
iframe,
|
|
2338
|
+
onZIndexCommit: vi.fn().mockResolvedValue(undefined),
|
|
2339
|
+
projectId: "p1",
|
|
2340
|
+
writeProjectFile,
|
|
2341
|
+
recordEdit: vi.fn(async () => {}),
|
|
2342
|
+
sdkSession,
|
|
2343
|
+
publishSdkSession: vi.fn<TimelinePublishSdkSession>(() => "published"),
|
|
2344
|
+
});
|
|
2345
|
+
|
|
2346
|
+
await act(async () => {
|
|
2347
|
+
await hook.move(clip, { start: 4, track: clip.track });
|
|
2348
|
+
});
|
|
2349
|
+
|
|
2350
|
+
expect(writeProjectFile.mock.calls[0]?.[1]).toContain(
|
|
2351
|
+
'id="clip" data-hf-id="hf-clip" data-start="2"',
|
|
2352
|
+
);
|
|
2353
|
+
hook.unmount();
|
|
2354
|
+
});
|
|
2355
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// fallow-ignore-file complexity
|
|
2
2
|
import { useCallback, useRef } from "react";
|
|
3
|
-
import type
|
|
4
|
-
import {
|
|
3
|
+
import { usePlayerStore, type TimelineElement } from "../player";
|
|
4
|
+
import { toAuthoredStart, toCompositionTime } from "../player/store/timelineElement";
|
|
5
5
|
import { useRazorSplit } from "./useRazorSplit";
|
|
6
6
|
import { selectSplittableElements } from "../utils/timelineElementSplit";
|
|
7
7
|
import { useTimelineAssetDropOps } from "./useTimelineAssetDropOps";
|
|
@@ -187,12 +187,11 @@ export function useTimelineEditing({
|
|
|
187
187
|
// other move — early-returning on !startChanged alone silently dropped
|
|
188
188
|
// the file write, so the lane snapped back on reload.
|
|
189
189
|
const trackChanged = updates.track !== element.track;
|
|
190
|
-
|
|
190
|
+
const authoredStart = toAuthoredStart(element, updates.start);
|
|
191
191
|
if (startChanged || trackChanged) {
|
|
192
192
|
const liveAttrs: Array<[string, string]> = [];
|
|
193
|
-
if (startChanged)
|
|
194
|
-
liveAttrs.push(["data-start", formatTimelineAttributeNumber(
|
|
195
|
-
}
|
|
193
|
+
if (startChanged)
|
|
194
|
+
liveAttrs.push(["data-start", formatTimelineAttributeNumber(authoredStart)]);
|
|
196
195
|
if (trackChanged) {
|
|
197
196
|
liveAttrs.push(["data-track-index", formatTimelineAttributeNumber(updates.track)]);
|
|
198
197
|
}
|
|
@@ -224,7 +223,7 @@ export function useTimelineEditing({
|
|
|
224
223
|
return buildTimelineMoveTimingPatch(
|
|
225
224
|
original,
|
|
226
225
|
target,
|
|
227
|
-
|
|
226
|
+
authoredStart,
|
|
228
227
|
element.duration,
|
|
229
228
|
track,
|
|
230
229
|
);
|
|
@@ -258,7 +257,7 @@ export function useTimelineEditing({
|
|
|
258
257
|
return sdkTimingPersist(
|
|
259
258
|
element.hfId,
|
|
260
259
|
targetPath,
|
|
261
|
-
{ start:
|
|
260
|
+
{ start: authoredStart },
|
|
262
261
|
sdkSession,
|
|
263
262
|
{
|
|
264
263
|
editHistory: { recordEdit },
|
|
@@ -309,8 +308,9 @@ export function useTimelineEditing({
|
|
|
309
308
|
element: TimelineElement,
|
|
310
309
|
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
|
311
310
|
) => {
|
|
311
|
+
const authoredStart = toAuthoredStart(element, updates.start);
|
|
312
312
|
const liveAttrs: Array<[string, string]> = [
|
|
313
|
-
["data-start", formatTimelineAttributeNumber(
|
|
313
|
+
["data-start", formatTimelineAttributeNumber(authoredStart)],
|
|
314
314
|
["data-duration", formatTimelineAttributeNumber(updates.duration)],
|
|
315
315
|
];
|
|
316
316
|
if (updates.playbackStart != null) {
|
|
@@ -350,8 +350,8 @@ export function useTimelineEditing({
|
|
|
350
350
|
writeProjectFile,
|
|
351
351
|
edit: {
|
|
352
352
|
kind: "scale",
|
|
353
|
-
from: { start: element.start, duration: element.duration },
|
|
354
|
-
to: { start: updates.start, duration: updates.duration },
|
|
353
|
+
from: { start: toCompositionTime(element, element.start), duration: element.duration },
|
|
354
|
+
to: { start: toCompositionTime(element, updates.start), duration: updates.duration },
|
|
355
355
|
},
|
|
356
356
|
}).finally(() => invalidateGsapCache?.());
|
|
357
357
|
const resizeFallback = () =>
|
|
@@ -363,7 +363,7 @@ export function useTimelineEditing({
|
|
|
363
363
|
? sdkTimingPersist(
|
|
364
364
|
element.hfId,
|
|
365
365
|
targetPath,
|
|
366
|
-
{ start:
|
|
366
|
+
{ start: authoredStart, duration: updates.duration },
|
|
367
367
|
sdkSession,
|
|
368
368
|
{
|
|
369
369
|
editHistory: { recordEdit },
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useCallback, type MutableRefObject, type RefObject } from "react";
|
|
4
4
|
import type { Composition } from "@hyperframes/sdk";
|
|
5
5
|
import type { TimelineElement } from "../player";
|
|
6
|
+
import { toAuthoredStart, toCompositionTime } from "../player/store/timelineElement";
|
|
6
7
|
import {
|
|
7
8
|
cutoverCommittedOrThrow,
|
|
8
9
|
sdkTimingBatchPersist,
|
|
@@ -244,7 +245,10 @@ export function useTimelineGroupEditing({
|
|
|
244
245
|
if (changes.length === 0) return Promise.resolve();
|
|
245
246
|
for (const change of changes) {
|
|
246
247
|
const attrs: Array<[string, string]> = [
|
|
247
|
-
[
|
|
248
|
+
[
|
|
249
|
+
"data-start",
|
|
250
|
+
formatTimelineAttributeNumber(toAuthoredStart(change.element, change.start)),
|
|
251
|
+
],
|
|
248
252
|
];
|
|
249
253
|
if (change.track != null) {
|
|
250
254
|
attrs.push(["data-track-index", formatTimelineAttributeNumber(change.track)]);
|
|
@@ -287,7 +291,9 @@ export function useTimelineGroupEditing({
|
|
|
287
291
|
await options?.beforeTiming;
|
|
288
292
|
const handledBySdk = await trySdkBatchPersist({
|
|
289
293
|
changes,
|
|
290
|
-
sdkChanges: toSdkTimingChanges(changes, (change) => ({
|
|
294
|
+
sdkChanges: toSdkTimingChanges(changes, (change) => ({
|
|
295
|
+
start: toAuthoredStart(change.element, change.start),
|
|
296
|
+
})),
|
|
291
297
|
eligible: changes.every((change) => change.track == null),
|
|
292
298
|
needsExtension,
|
|
293
299
|
label,
|
|
@@ -304,7 +310,7 @@ export function useTimelineGroupEditing({
|
|
|
304
310
|
buildTimelineMoveTimingPatch(
|
|
305
311
|
original,
|
|
306
312
|
target,
|
|
307
|
-
change.start,
|
|
313
|
+
toAuthoredStart(change.element, change.start),
|
|
308
314
|
change.element.duration,
|
|
309
315
|
change.track,
|
|
310
316
|
),
|
|
@@ -375,7 +381,10 @@ export function useTimelineGroupEditing({
|
|
|
375
381
|
if (changes.length === 0) return Promise.resolve();
|
|
376
382
|
for (const change of changes) {
|
|
377
383
|
const liveAttrs: Array<[string, string]> = [
|
|
378
|
-
[
|
|
384
|
+
[
|
|
385
|
+
"data-start",
|
|
386
|
+
formatTimelineAttributeNumber(toAuthoredStart(change.element, change.start)),
|
|
387
|
+
],
|
|
379
388
|
["data-duration", formatTimelineAttributeNumber(change.duration)],
|
|
380
389
|
];
|
|
381
390
|
if (change.playbackStart != null) {
|
|
@@ -402,7 +411,7 @@ export function useTimelineGroupEditing({
|
|
|
402
411
|
const handledBySdk = await trySdkBatchPersist({
|
|
403
412
|
changes,
|
|
404
413
|
sdkChanges: toSdkTimingChanges(changes, (change) => ({
|
|
405
|
-
start: change.start,
|
|
414
|
+
start: toAuthoredStart(change.element, change.start),
|
|
406
415
|
duration: change.duration,
|
|
407
416
|
})),
|
|
408
417
|
eligible: changes.every((change) => !resizeHasPlaybackStartAdjustment(change)),
|
|
@@ -453,9 +462,9 @@ export function useTimelineGroupEditing({
|
|
|
453
462
|
projectId,
|
|
454
463
|
changePath,
|
|
455
464
|
domId,
|
|
456
|
-
change.element.start,
|
|
465
|
+
toCompositionTime(change.element, change.element.start),
|
|
457
466
|
change.element.duration,
|
|
458
|
-
change.start,
|
|
467
|
+
toCompositionTime(change.element, change.start),
|
|
459
468
|
change.duration,
|
|
460
469
|
);
|
|
461
470
|
},
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import React, { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { Timeline } from "./Timeline";
|
|
7
|
+
import { installTimelineMountEnv } from "./timelineMountTestEnv";
|
|
8
|
+
import { usePlayerStore } from "../store/playerStore";
|
|
9
|
+
|
|
10
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
|
11
|
+
|
|
12
|
+
installTimelineMountEnv();
|
|
13
|
+
|
|
14
|
+
beforeAll(() => {
|
|
15
|
+
HTMLElement.prototype.setPointerCapture = vi.fn();
|
|
16
|
+
HTMLElement.prototype.releasePointerCapture = vi.fn();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
document.body.innerHTML = "";
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const KEYFRAMED = new Map([
|
|
24
|
+
[
|
|
25
|
+
"card",
|
|
26
|
+
[
|
|
27
|
+
{
|
|
28
|
+
id: "card-position",
|
|
29
|
+
targetSelector: "#card",
|
|
30
|
+
method: "to" as const,
|
|
31
|
+
position: 0,
|
|
32
|
+
duration: 2,
|
|
33
|
+
properties: {},
|
|
34
|
+
propertyGroup: "position" as const,
|
|
35
|
+
keyframes: {
|
|
36
|
+
format: "percentage" as const,
|
|
37
|
+
keyframes: [
|
|
38
|
+
{ percentage: 0, properties: { x: 0 } },
|
|
39
|
+
{ percentage: 50, properties: { x: 100 } },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
],
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
/** Presses the ruler exactly on the first drawn tick after 0 and returns its time and the seek. */
|
|
48
|
+
async function seekOnFirstTick(
|
|
49
|
+
props: { showKeyframes?: boolean },
|
|
50
|
+
keyframesLate: boolean,
|
|
51
|
+
animations: typeof KEYFRAMED | Map<string, never[]> = KEYFRAMED,
|
|
52
|
+
) {
|
|
53
|
+
usePlayerStore.setState({
|
|
54
|
+
duration: 10,
|
|
55
|
+
currentTime: 0,
|
|
56
|
+
timelineReady: true,
|
|
57
|
+
elements: [{ id: "card", label: "Hero card", tag: "div", start: 0, duration: 10, track: 0 }],
|
|
58
|
+
gsapAnimations: keyframesLate ? new Map() : animations,
|
|
59
|
+
});
|
|
60
|
+
const onSeek = vi.fn();
|
|
61
|
+
const host = document.createElement("div");
|
|
62
|
+
document.body.append(host);
|
|
63
|
+
const root = createRoot(host);
|
|
64
|
+
await act(async () => root.render(<Timeline {...props} onSeek={onSeek} />));
|
|
65
|
+
if (keyframesLate) await act(async () => usePlayerStore.setState({ gsapAnimations: animations }));
|
|
66
|
+
|
|
67
|
+
const viewport = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]")!;
|
|
68
|
+
viewport.getBoundingClientRect = () =>
|
|
69
|
+
({ left: 0, top: 0, right: 900, bottom: 400, width: 900, height: 400 }) as DOMRect;
|
|
70
|
+
const label = host.querySelector<HTMLElement>('[data-timeline-grid-cell="major"] span')!;
|
|
71
|
+
const band = label.parentElement!.parentElement!;
|
|
72
|
+
const tick = band.querySelectorAll<HTMLElement>('[data-timeline-grid-cell="major"]')[1]!;
|
|
73
|
+
const [, mm, ss] = /(\d+):(\d+)/.exec(tick.textContent ?? "")!;
|
|
74
|
+
const tickTime = Number(mm) * 60 + Number(ss);
|
|
75
|
+
// The ruler's corner block is as wide as the origin the ruler draws 0 s at.
|
|
76
|
+
const origin = (band.previousElementSibling as HTMLElement).style.width;
|
|
77
|
+
const clientX = parseFloat(origin) + parseFloat(tick.style.left) + 0.5;
|
|
78
|
+
const at = { bubbles: true, clientX, clientY: 4, button: 0, pointerId: 1 };
|
|
79
|
+
await act(async () => tick.dispatchEvent(new PointerEvent("pointerdown", at)));
|
|
80
|
+
await act(async () => tick.dispatchEvent(new PointerEvent("pointerup", at)));
|
|
81
|
+
act(() => root.unmount());
|
|
82
|
+
return {
|
|
83
|
+
time: onSeek.mock.calls.at(-1)?.[0] as number | undefined,
|
|
84
|
+
tickTime,
|
|
85
|
+
origin,
|
|
86
|
+
published: viewport.getAttribute("data-timeline-content-origin"),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe("Timeline ruler origin", () => {
|
|
91
|
+
it.each([
|
|
92
|
+
["keyframes shown", { showKeyframes: true }, false],
|
|
93
|
+
["keyframes hidden", { showKeyframes: false }, false],
|
|
94
|
+
["keyframes shown, arriving after mount", { showKeyframes: true }, true],
|
|
95
|
+
["keyframes hidden, arriving after mount", { showKeyframes: false }, true],
|
|
96
|
+
])(
|
|
97
|
+
"publishes where the ruler draws 0 s, and a press seeks by it (%s)",
|
|
98
|
+
async (_case, props, late) => {
|
|
99
|
+
const { time, tickTime, origin, published } = await seekOnFirstTick(props, late);
|
|
100
|
+
expect(origin).toBe("264px");
|
|
101
|
+
expect(published).toBe("264");
|
|
102
|
+
expect(time).toBeCloseTo(tickTime, 1);
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
it("publishes the narrow origin when no clip needs the label column", async () => {
|
|
107
|
+
const { origin, published } = await seekOnFirstTick({}, false, new Map());
|
|
108
|
+
expect(origin).toBe("80px");
|
|
109
|
+
expect(published).toBe("80");
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -189,7 +189,7 @@ describe("TimelinePropertyLanes", () => {
|
|
|
189
189
|
// host-absolute start while its tweens are parsed from its own file and are
|
|
190
190
|
// local to it. clipTimingStart is what brings the two into one frame.
|
|
191
191
|
it("keeps an expanded sub-comp child's lane percentages inside the clip", () => {
|
|
192
|
-
const child = { start: 16.5, duration: 2,
|
|
192
|
+
const child = { start: 16.5, duration: 2, parentCompositionStart: 16 };
|
|
193
193
|
const local = animation("pill-tween", "position", [
|
|
194
194
|
{ percentage: 0, properties: { x: 0 } },
|
|
195
195
|
{ percentage: 100, properties: { x: 100 } },
|
|
@@ -127,6 +127,7 @@ export interface TimelineViewportProps {
|
|
|
127
127
|
ref: RefCallback<HTMLDivElement>;
|
|
128
128
|
"data-timeline-scroll-viewport": boolean;
|
|
129
129
|
"data-timeline-auto-scroll-left-inset": number;
|
|
130
|
+
"data-timeline-content-origin": number;
|
|
130
131
|
tabIndex: number;
|
|
131
132
|
className: string;
|
|
132
133
|
onScroll: (event: UIEvent<HTMLDivElement>) => void;
|