@hyperframes/studio 0.8.89 → 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.
Files changed (93) hide show
  1. package/dist/assets/{hyperframes-player-CVoS7kv8.js → hyperframes-player-zT2Bdx_l.js} +1 -1
  2. package/dist/assets/{index-D35-L_yn.js → index-DSOLcE8X.js} +1 -1
  3. package/dist/assets/{index-DBub9qQY.js → index-DV-z19zB.js} +1 -1
  4. package/dist/assets/{index-BM8PwYby.js → index-V-0rfhe6.js} +168 -168
  5. package/dist/{chunk-KU4F7QZ3.js → chunk-QPSOTK6M.js} +8 -1
  6. package/dist/chunk-QPSOTK6M.js.map +1 -0
  7. package/dist/index.d.ts +12 -3
  8. package/dist/index.html +1 -1
  9. package/dist/index.js +1349 -1066
  10. package/dist/index.js.map +1 -1
  11. package/dist/player.js +1 -1
  12. package/package.json +8 -8
  13. package/src/components/TimelineToolbar.test.tsx +12 -0
  14. package/src/components/editor/MotionPathOverlay.tsx +5 -1
  15. package/src/components/editor/commitPreflightHandles.test.tsx +7 -20
  16. package/src/components/editor/elementPositionOffset.test.ts +62 -0
  17. package/src/components/editor/elementPositionOffset.ts +64 -0
  18. package/src/components/editor/motionPathCommit.test.ts +25 -5
  19. package/src/components/editor/motionPathSelection.ts +10 -3
  20. package/src/components/editor/useInlineTextEditing.tsx +2 -4
  21. package/src/components/editor/useLayerRevealOverride.test.ts +8 -0
  22. package/src/components/editor/useLayerRevealOverride.ts +2 -1
  23. package/src/components/nle/NLEContext.tsx +1 -1
  24. package/src/components/nle/useTimelineEditCallbacks.test.tsx +53 -0
  25. package/src/hooks/domEditPersistFailure.ts +8 -6
  26. package/src/hooks/elementOffsetStager.ts +65 -0
  27. package/src/hooks/gestureTransaction.ts +4 -2
  28. package/src/hooks/gsapDragCommit.ts +1 -0
  29. package/src/hooks/gsapEditOutcome.ts +2 -1
  30. package/src/hooks/gsapRuntimeBridge.sharedTween.test.ts +157 -0
  31. package/src/hooks/gsapRuntimeBridge.ts +50 -14
  32. package/src/hooks/gsapShared.test.ts +1 -1
  33. package/src/hooks/gsapShared.ts +16 -12
  34. package/src/hooks/gsapTweenReach.ts +30 -0
  35. package/src/hooks/timelineEditingHelpers.ts +2 -1
  36. package/src/hooks/useClipboard.source.test.ts +58 -0
  37. package/src/hooks/useClipboard.ts +12 -3
  38. package/src/hooks/useCommitPreflightCapabilities.ts +2 -2
  39. package/src/hooks/useDomEditCommits.ts +2 -0
  40. package/src/hooks/useDomEditPositionPatchCommit.ts +4 -1
  41. package/src/hooks/useDomEditSession.ts +2 -0
  42. package/src/hooks/useDomGeometryCommit.test.tsx +102 -0
  43. package/src/hooks/useDomGeometryCommit.ts +42 -1
  44. package/src/hooks/useDomGeometryCommits.test.tsx +27 -0
  45. package/src/hooks/useDomGeometryCommits.ts +14 -1
  46. package/src/hooks/useGsapAwareEditing.test.tsx +113 -5
  47. package/src/hooks/useGsapAwareEditing.ts +44 -7
  48. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +2 -1
  49. package/src/hooks/useGsapInteractionFailureTelemetry.ts +8 -4
  50. package/src/hooks/useRazorSplit.test.ts +5 -5
  51. package/src/hooks/useTimelineEditing.test.tsx +384 -0
  52. package/src/hooks/useTimelineEditing.ts +12 -12
  53. package/src/hooks/useTimelineGroupEditing.ts +16 -7
  54. package/src/player/components/Timeline.rulerSeek.test.tsx +111 -0
  55. package/src/player/components/TimelinePropertyLanes.test.tsx +1 -1
  56. package/src/player/components/TimelineProvider.tsx +1 -0
  57. package/src/player/components/TimelineTrackHeader.test.tsx +1 -1
  58. package/src/player/components/TimelineTypes.ts +1 -1
  59. package/src/player/components/timelineClipDragCommit.test.ts +18 -8
  60. package/src/player/components/timelineClipDragCommit.ts +5 -0
  61. package/src/player/components/timelineClipDragPreview.test.ts +70 -2
  62. package/src/player/components/timelineClipDragPreview.ts +15 -12
  63. package/src/player/components/timelineEditing.ts +2 -1
  64. package/src/player/components/timelineGroupEditing.ts +7 -2
  65. package/src/player/components/timelineMultiDragPreview.test.ts +10 -8
  66. package/src/player/components/timelineMultiDragPreview.ts +8 -5
  67. package/src/player/components/timelineProviderStateBuilders.test.ts +1 -0
  68. package/src/player/components/timelineProviderStateBuilders.ts +7 -2
  69. package/src/player/components/timelineZMirror.test.ts +2 -2
  70. package/src/player/components/timelineZMirror.ts +1 -1
  71. package/src/player/components/useTimelineClipDrag.resize.test.tsx +8 -5
  72. package/src/player/components/useTimelineClipDrag.ts +3 -2
  73. package/src/player/components/useTimelinePlayhead.test.tsx +215 -26
  74. package/src/player/components/useTimelinePlayhead.ts +56 -27
  75. package/src/player/components/useTimelineProviderState.tsx +1 -0
  76. package/src/player/components/useTimelineZoom.ts +9 -1
  77. package/src/player/hooks/useTimelinePlayer.seek.test.ts +44 -0
  78. package/src/player/hooks/useTimelinePlayer.ts +7 -4
  79. package/src/player/lib/timelineDOM.test.ts +79 -0
  80. package/src/player/lib/timelineDOM.ts +16 -7
  81. package/src/player/lib/timelineIframeHelpers.test.ts +36 -0
  82. package/src/player/lib/timelineIframeHelpers.ts +12 -3
  83. package/src/player/sceneSwap.ts +21 -0
  84. package/src/player/store/liveTime.ts +6 -0
  85. package/src/player/store/playerStore.ts +2 -0
  86. package/src/player/store/timelineElement.ts +28 -3
  87. package/src/utils/gsapUndoRestore.test.ts +58 -0
  88. package/src/utils/gsapUndoRestore.ts +6 -1
  89. package/src/utils/razorSplitTransaction.test.ts +1 -1
  90. package/src/utils/razorSplitTransaction.ts +3 -3
  91. package/src/utils/studioPreviewHelpers.test.ts +29 -0
  92. package/src/utils/studioPreviewHelpers.ts +37 -80
  93. 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 { TimelineElement } from "../player";
4
- import { usePlayerStore } from "../player";
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(updates.start)]);
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
- updates.start,
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: updates.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(updates.start)],
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: updates.start, duration: updates.duration },
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
- ["data-start", formatTimelineAttributeNumber(change.start)],
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) => ({ start: change.start })),
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
- ["data-start", formatTimelineAttributeNumber(change.start)],
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, expandedParentStart: 16 };
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;
@@ -209,7 +209,7 @@ describe("TimelineTrackHeader", () => {
209
209
  start: 16.5,
210
210
  duration: 2,
211
211
  track: 0,
212
- expandedParentStart: 16,
212
+ parentCompositionStart: 16,
213
213
  sourceFile: "scene.html",
214
214
  };
215
215
  const local: GsapAnimation = {