@hyperframes/studio 0.8.60 → 0.8.61

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 (51) hide show
  1. package/dist/assets/{hyperframes-player-Di7GyCOP.js → hyperframes-player-nmVkPQza.js} +1 -1
  2. package/dist/assets/{index-jAjhQTrB.js → index-2-HsQB2C.js} +1 -1
  3. package/dist/assets/{index-Dpq2lkOm.js → index-BK36b0XY.js} +160 -160
  4. package/dist/assets/{index-Dx_pt9m4.js → index-COG0K-CQ.js} +1 -1
  5. package/dist/assets/{index-CBKb_0TI.css → index-DS0kLbuM.css} +1 -1
  6. package/dist/index.d.ts +4 -1
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +3272 -2889
  9. package/dist/index.js.map +1 -1
  10. package/dist/styles.css +1 -1
  11. package/package.json +7 -7
  12. package/src/App.tsx +2 -0
  13. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +56 -0
  14. package/src/hooks/useExternalFileChangeCoordinator.ts +13 -0
  15. package/src/hooks/useRenderClipContent.test.ts +33 -4
  16. package/src/hooks/useRenderClipContent.ts +7 -3
  17. package/src/hooks/useSdkSession.lifecycle.test.tsx +179 -0
  18. package/src/hooks/useSdkSession.ts +84 -14
  19. package/src/hooks/useStudioSdkSessions.ts +8 -1
  20. package/src/player/components/AudioWaveform.test.tsx +53 -1
  21. package/src/player/components/AudioWaveform.tsx +113 -63
  22. package/src/player/components/TimelineClipFades.tsx +64 -31
  23. package/src/player/components/TimelineLanes.tsx +15 -8
  24. package/src/player/components/TimelineTransitionBadge.tsx +59 -0
  25. package/src/player/components/TimelineTransitionOverlays.tsx +30 -0
  26. package/src/player/components/VideoThumbnail.test.tsx +23 -12
  27. package/src/player/components/VideoThumbnail.tsx +83 -27
  28. package/src/player/components/audioClipLink.test.ts +34 -0
  29. package/src/player/components/audioClipLink.ts +33 -0
  30. package/src/player/components/audioWaveformPeaks.test.ts +32 -0
  31. package/src/player/components/audioWaveformPeaks.ts +46 -0
  32. package/src/player/components/thumbnailUtils.test.ts +17 -0
  33. package/src/player/components/thumbnailUtils.ts +7 -0
  34. package/src/player/components/timelineTheme.ts +6 -0
  35. package/src/player/components/timelineTransitionSeams.test.ts +48 -0
  36. package/src/player/components/timelineTransitionSeams.ts +36 -0
  37. package/src/player/components/timelineZones.test.ts +18 -0
  38. package/src/player/components/timelineZones.ts +4 -1
  39. package/src/player/lib/playbackTypes.ts +2 -0
  40. package/src/player/lib/timelineDOM.test.ts +28 -0
  41. package/src/player/lib/timelineDOM.ts +24 -2
  42. package/src/player/lib/timelineIframeHelpers.ts +7 -0
  43. package/src/player/lib/timelineTransitionMetadata.ts +52 -0
  44. package/src/player/lib/timelineViewportBudgets.ts +2 -1
  45. package/src/player/store/timelineElement.ts +1 -0
  46. package/src/styles/hex-baseline.json +4 -4
  47. package/src/styles/studio.css +84 -0
  48. package/src/styles/theme.css +10 -2
  49. package/src/utils/sdkCutover.test.ts +102 -1
  50. package/src/utils/sdkCutover.ts +16 -21
  51. package/src/utils/sdkEditTransaction.ts +50 -1
@@ -1,9 +1,17 @@
1
1
  import { memo, useCallback, useMemo, useRef, useState } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import { useThumbnailLease } from "../../hooks/useThumbnailLease";
4
- import { createThumbnailKey, type ThumbnailPriority } from "../lib/thumbnailScheduler";
4
+ import {
5
+ createThumbnailKey,
6
+ type ThumbnailPriority,
7
+ type ThumbnailSnapshot,
8
+ } from "../lib/thumbnailScheduler";
5
9
  import { decodeVideoThumbnail } from "../lib/thumbnailVideoDecoder";
6
- import { computeThumbnailStrip, THUMBNAIL_CLIP_HEIGHT } from "./thumbnailUtils";
10
+ import {
11
+ computeThumbnailStrip,
12
+ quantizeThumbnailFrameCount,
13
+ THUMBNAIL_CLIP_HEIGHT,
14
+ } from "./thumbnailUtils";
7
15
 
8
16
  interface VideoThumbnailProps {
9
17
  videoSrc: string;
@@ -15,7 +23,58 @@ interface VideoThumbnailProps {
15
23
  projectId?: string;
16
24
  sessionEpoch?: number;
17
25
  priority?: ThumbnailPriority;
18
- rich?: boolean;
26
+ }
27
+
28
+ function createVideoThumbnailRequest(
29
+ props: Pick<VideoThumbnailProps, "videoSrc" | "sourceStart" | "sourceRangeDuration"> &
30
+ Required<Pick<VideoThumbnailProps, "duration" | "projectId" | "sessionEpoch" | "priority">>,
31
+ frameCount: number,
32
+ rich: boolean,
33
+ ) {
34
+ const {
35
+ videoSrc,
36
+ sourceStart,
37
+ sourceRangeDuration,
38
+ duration,
39
+ projectId,
40
+ sessionEpoch,
41
+ priority,
42
+ } = props;
43
+ return {
44
+ key: createThumbnailKey({
45
+ kind: "video",
46
+ source: videoSrc,
47
+ start: sourceStart,
48
+ duration: sourceRangeDuration ?? duration,
49
+ frames: frameCount,
50
+ }),
51
+ projectId,
52
+ sessionEpoch,
53
+ kind: "video" as const,
54
+ priority,
55
+ rich,
56
+ load: (signal: AbortSignal) =>
57
+ decodeVideoThumbnail(
58
+ {
59
+ source: videoSrc,
60
+ sourceStart,
61
+ sourceRangeDuration: sourceRangeDuration ?? duration,
62
+ frameCount,
63
+ fit: "cover",
64
+ },
65
+ signal,
66
+ ),
67
+ };
68
+ }
69
+
70
+ function selectThumbnailSnapshot(
71
+ poster: ThumbnailSnapshot,
72
+ rich: ThumbnailSnapshot,
73
+ ): ThumbnailSnapshot {
74
+ if (rich.status === "ready") return rich;
75
+ if (poster.status === "ready") return poster;
76
+ if (rich.status === "loading" || poster.status === "loading") return { status: "loading" };
77
+ return poster;
19
78
  }
20
79
 
21
80
  /** Sparse, bounded video frames supplied by the shared thumbnail scheduler. */
@@ -29,39 +88,36 @@ export const VideoThumbnail = memo(function VideoThumbnail({
29
88
  projectId = videoSrc,
30
89
  sessionEpoch = 0,
31
90
  priority = "visible",
32
- rich = false,
33
91
  }: VideoThumbnailProps) {
34
92
  const [containerWidth, setContainerWidth] = useState(0);
35
93
  const observerRef = useRef<ResizeObserver | null>(null);
36
- const request = useMemo(
94
+ const requestFrameCount = quantizeThumbnailFrameCount(
95
+ computeThumbnailStrip(containerWidth, 16 / 9).frameCount,
96
+ );
97
+ const requestProps = useMemo(
37
98
  () => ({
38
- key: createThumbnailKey({
39
- kind: "video",
40
- source: videoSrc,
41
- start: sourceStart,
42
- duration: sourceRangeDuration ?? duration,
43
- frames: rich ? 6 : 1,
44
- }),
99
+ videoSrc,
100
+ sourceStart,
101
+ sourceRangeDuration,
102
+ duration,
45
103
  projectId,
46
104
  sessionEpoch,
47
- kind: "video" as const,
48
105
  priority,
49
- rich,
50
- load: (signal: AbortSignal) =>
51
- decodeVideoThumbnail(
52
- {
53
- source: videoSrc,
54
- sourceStart,
55
- sourceRangeDuration: sourceRangeDuration ?? duration,
56
- frameCount: rich ? 6 : 1,
57
- fit: "cover",
58
- },
59
- signal,
60
- ),
61
106
  }),
62
- [duration, priority, projectId, rich, sessionEpoch, sourceRangeDuration, sourceStart, videoSrc],
107
+ [duration, priority, projectId, sessionEpoch, sourceRangeDuration, sourceStart, videoSrc],
108
+ );
109
+ const posterRequest = useMemo(
110
+ () => createVideoThumbnailRequest(requestProps, 1, false),
111
+ [requestProps],
112
+ );
113
+ const richRequest = useMemo(
114
+ () => createVideoThumbnailRequest(requestProps, requestFrameCount, true),
115
+ [requestFrameCount, requestProps],
63
116
  );
64
- const snapshot = useThumbnailLease(request);
117
+ const measured = containerWidth > 0;
118
+ const posterSnapshot = useThumbnailLease(measured ? posterRequest : null);
119
+ const richSnapshot = useThumbnailLease(measured && requestFrameCount > 1 ? richRequest : null);
120
+ const snapshot = selectThumbnailSnapshot(posterSnapshot, richSnapshot);
65
121
  const value = snapshot.status === "ready" ? snapshot.value : null;
66
122
  const urls =
67
123
  value?.kind === "filmstrip" ? value.urls : value?.kind === "image" ? [value.url] : [];
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { audioPillFlags, isLinkedVideoAudio } from "./audioClipLink";
3
+
4
+ const audio = { id: "bed", tag: "audio", src: "assets/City Ride.mp4?v=2" };
5
+
6
+ describe("isLinkedVideoAudio", () => {
7
+ it("links an audio pill to a video of the same file", () => {
8
+ expect(
9
+ isLinkedVideoAudio(audio, [
10
+ audio,
11
+ { id: "picture", tag: "video", src: "/preview/assets/city ride.mp4" },
12
+ ]),
13
+ ).toBe(true);
14
+ });
15
+
16
+ it("greys a hidden clip and a muted group without dropping the link", () => {
17
+ expect(audioPillFlags({ ...audio, hidden: true }, [])).toEqual({
18
+ muted: true,
19
+ linked: false,
20
+ });
21
+ expect(audioPillFlags({ ...audio, audioGroupHidden: true }, [])).toEqual({
22
+ muted: true,
23
+ linked: false,
24
+ });
25
+ });
26
+
27
+ it("leaves a standalone audio file unlinked", () => {
28
+ expect(
29
+ isLinkedVideoAudio({ id: "vo", tag: "audio", src: "assets/voice.wav" }, [
30
+ { id: "picture", tag: "video", src: "assets/city.mp4" },
31
+ ]),
32
+ ).toBe(false);
33
+ });
34
+ });
@@ -0,0 +1,33 @@
1
+ import type { TimelineElement } from "../store/timelineElement";
2
+
3
+ function mediaFileKey(src: string | undefined): string | null {
4
+ if (!src) return null;
5
+ const path = src.split(/[?#]/, 1)[0] ?? "";
6
+ const segment = path.split(/[/\\]/).pop()?.trim().toLowerCase() ?? "";
7
+ return segment.length > 0 ? segment : null;
8
+ }
9
+
10
+ export function audioPillFlags(
11
+ audio: Pick<TimelineElement, "id" | "tag" | "src" | "hidden" | "audioGroupHidden">,
12
+ elements: readonly Pick<TimelineElement, "id" | "tag" | "src">[],
13
+ ): { muted: boolean; linked: boolean } {
14
+ return {
15
+ muted: audio.hidden === true || audio.audioGroupHidden === true,
16
+ linked: isLinkedVideoAudio(audio, elements),
17
+ };
18
+ }
19
+
20
+ /** An audio pill whose file is also a video clip on this timeline. */
21
+ export function isLinkedVideoAudio(
22
+ audio: Pick<TimelineElement, "id" | "tag" | "src">,
23
+ elements: readonly Pick<TimelineElement, "id" | "tag" | "src">[],
24
+ ): boolean {
25
+ if (audio.tag.trim().toLowerCase() !== "audio") return false;
26
+ const key = mediaFileKey(audio.src);
27
+ if (!key) return false;
28
+ return elements.some((element) => {
29
+ if (element.id === audio.id) return false;
30
+ if (element.tag.trim().toLowerCase() !== "video") return false;
31
+ return mediaFileKey(element.src) === key;
32
+ });
33
+ }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { decimatePeaks, loudnessToOpacity } from "./audioWaveformPeaks";
3
+
4
+ describe("loudnessToOpacity", () => {
5
+ it("maps silence to the floor and the file peak to fully opaque", () => {
6
+ expect(loudnessToOpacity(0)).toBe(0.64);
7
+ expect(loudnessToOpacity(1)).toBe(1);
8
+ expect(loudnessToOpacity(0.5)).toBeCloseTo(0.82, 5);
9
+ });
10
+
11
+ it("clamps out-of-range and non-finite peaks to the same floor or ceiling", () => {
12
+ expect(loudnessToOpacity(-0.2)).toBe(0.64);
13
+ expect(loudnessToOpacity(4)).toBe(1);
14
+ expect(loudnessToOpacity(Number.NaN)).toBe(0.64);
15
+ });
16
+ });
17
+
18
+ describe("decimatePeaks", () => {
19
+ it("keeps a transient that a single sample in the bucket would miss", () => {
20
+ const peaks = [0, 0, 0, 1, 0, 0, 0, 0];
21
+ expect(decimatePeaks(peaks, 0, 1, 2)).toEqual([1, 0]);
22
+ });
23
+
24
+ it("windows to the trimmed source slice", () => {
25
+ expect(decimatePeaks([0.1, 0.2, 0.9, 0.3], 0.5, 1, 1)).toEqual([0.9]);
26
+ });
27
+
28
+ it("returns no bars when there is nothing to draw", () => {
29
+ expect(decimatePeaks([], 0, 1, 4)).toEqual([]);
30
+ expect(decimatePeaks([0.5], 0, 1, 0)).toEqual([]);
31
+ });
32
+ });
@@ -0,0 +1,46 @@
1
+ /** Quietest bar that still clears the timeline contrast gate on the 70% pill. */
2
+ const LOUDNESS_OPACITY_FLOOR = 0.64;
3
+
4
+ const LOUDNESS_OPACITY_SPAN = 1 - LOUDNESS_OPACITY_FLOOR;
5
+
6
+ /** Peak is 0–1 against the file's own maximum. Hotter peaks paint more opaque. */
7
+ export function loudnessToOpacity(peak: number): number {
8
+ if (!Number.isFinite(peak)) return LOUDNESS_OPACITY_FLOOR;
9
+ const clamped = Math.min(1, Math.max(0, peak));
10
+ return LOUDNESS_OPACITY_FLOOR + LOUDNESS_OPACITY_SPAN * clamped;
11
+ }
12
+
13
+ /**
14
+ * Max-in-bucket over the trimmed peak window, one value per bar.
15
+ * A point sample drops a transient that sits between bar columns.
16
+ */
17
+ export function decimatePeaks(
18
+ peaks: readonly number[],
19
+ startFraction: number,
20
+ endFraction: number,
21
+ barCount: number,
22
+ ): number[] {
23
+ if (barCount <= 0 || peaks.length === 0) return [];
24
+ const startFractionClamped = Math.min(1, Math.max(0, startFraction));
25
+ const endFractionClamped = Math.min(1, Math.max(startFractionClamped, endFraction));
26
+ const start = Math.floor(startFractionClamped * peaks.length);
27
+ const endIndex = Math.ceil(endFractionClamped * peaks.length);
28
+ const end = Math.max(start + 1, Math.min(peaks.length, endIndex));
29
+ const span = end - start;
30
+ const bars: number[] = [];
31
+ for (let index = 0; index < barCount; index++) {
32
+ let from = start + Math.floor((index * span) / barCount);
33
+ let to = start + Math.floor(((index + 1) * span) / barCount);
34
+ if (to <= from) {
35
+ from = Math.min(from, end - 1);
36
+ to = from + 1;
37
+ }
38
+ let max = 0;
39
+ for (let cursor = from; cursor < to; cursor++) {
40
+ const value = peaks[cursor] ?? 0;
41
+ if (value > max) max = value;
42
+ }
43
+ bars.push(max);
44
+ }
45
+ return bars;
46
+ }
@@ -3,6 +3,7 @@ import {
3
3
  computeThumbnailStrip,
4
4
  encodePreviewPath,
5
5
  resolveMediaPreviewUrl,
6
+ quantizeThumbnailFrameCount,
6
7
  THUMBNAIL_CLIP_HEIGHT,
7
8
  } from "./thumbnailUtils";
8
9
 
@@ -19,6 +20,10 @@ describe("computeThumbnailStrip", () => {
19
20
  expect(frameCount * frameW).toBeGreaterThanOrEqual(500);
20
21
  });
21
22
 
23
+ it("paints tiles across the full clip past the shared visible-frame budget", () => {
24
+ expect(computeThumbnailStrip(14_400, 16 / 9).frameCount).toBeGreaterThan(33);
25
+ });
26
+
22
27
  it("returns one tile when the container width is unknown", () => {
23
28
  expect(computeThumbnailStrip(0, 16 / 9).frameCount).toBe(1);
24
29
  expect(computeThumbnailStrip(-10, 16 / 9).frameCount).toBe(1);
@@ -50,6 +55,18 @@ describe("computeThumbnailStrip", () => {
50
55
  });
51
56
  });
52
57
 
58
+ describe("quantizeThumbnailFrameCount", () => {
59
+ it("uses doubling buckets and never exceeds the 4K geometry ceiling", () => {
60
+ expect(quantizeThumbnailFrameCount(5)).toBe(8);
61
+ expect(quantizeThumbnailFrameCount(32)).toBe(32);
62
+ expect(quantizeThumbnailFrameCount(34)).toBe(33);
63
+ });
64
+
65
+ it("caps decode requests at the shared visible-frame budget", () => {
66
+ expect(quantizeThumbnailFrameCount(124)).toBe(33);
67
+ });
68
+ });
69
+
53
70
  describe("resolveMediaPreviewUrl", () => {
54
71
  it("reroutes same-origin root media resolved by the preview iframe", () => {
55
72
  expect(
@@ -1,4 +1,5 @@
1
1
  import { buildProjectApiPath } from "../../utils/projectRouting";
2
+ import { MAX_VISIBLE_THUMBNAIL_FRAMES } from "../lib/timelineViewportBudgets";
2
3
  /** Rendered height of a timeline-clip thumbnail strip, in CSS px. */
3
4
  export const THUMBNAIL_CLIP_HEIGHT = 66;
4
5
 
@@ -9,6 +10,12 @@ export interface ThumbnailStripLayout {
9
10
  frameCount: number;
10
11
  }
11
12
 
13
+ /** Quantize request identities so a pixel-by-pixel resize does not thrash the cache. */
14
+ export function quantizeThumbnailFrameCount(frameCount: number): number {
15
+ const safeCount = Math.max(1, Number.isFinite(frameCount) ? Math.ceil(frameCount) : 1);
16
+ return Math.min(MAX_VISIBLE_THUMBNAIL_FRAMES, 2 ** Math.ceil(Math.log2(safeCount)));
17
+ }
18
+
12
19
  /**
13
20
  * Measure an image without mounting it in React's DOM. The scheduler owns the
14
21
  * abort signal, so an unmounted clip cannot leave Blink retaining a pending
@@ -32,6 +32,9 @@ export interface TimelineTheme {
32
32
  panelResizeActive: string;
33
33
  clipRadius: string;
34
34
  audioClipRadius: string;
35
+ transitionZone: string;
36
+ transitionBadge: string;
37
+ transitionBadgeInk: string;
35
38
  }
36
39
 
37
40
  const TRACK_STYLE: TimelineTrackStyle = {
@@ -69,6 +72,9 @@ export const defaultTimelineTheme: TimelineTheme = {
69
72
  panelResizeActive: "var(--timeline-resize-active)",
70
73
  clipRadius: "var(--timeline-clip-radius)",
71
74
  audioClipRadius: "var(--timeline-clip-audio-radius)",
75
+ transitionZone: "var(--timeline-transition-zone)",
76
+ transitionBadge: "var(--timeline-transition-badge)",
77
+ transitionBadgeInk: "var(--timeline-transition-badge-ink)",
72
78
  };
73
79
 
74
80
  export type ClipWidthLadder = "labeled" | "picture" | "frame";
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { deriveTimelineTransitionSeams } from "./timelineTransitionSeams";
3
+ import type { TimelineElement } from "../store/playerStore";
4
+
5
+ const clip = (
6
+ id: string,
7
+ start: number,
8
+ duration: number,
9
+ track = 0,
10
+ transitionLabel?: string,
11
+ ): TimelineElement => ({
12
+ id,
13
+ tag: "video",
14
+ start,
15
+ duration,
16
+ track,
17
+ transitionLabel,
18
+ });
19
+
20
+ describe("deriveTimelineTransitionSeams", () => {
21
+ it("finds a labelled transition and centers the seam in the shared time", () => {
22
+ const label = "hf:transition:out:in:crossfade";
23
+ const [seam] = deriveTimelineTransitionSeams([
24
+ clip("in", 1.8, 2, 0, label),
25
+ clip("out", 0, 2, 0, label),
26
+ ]);
27
+ expect(seam?.outgoing).toEqual(clip("out", 0, 2, 0, label));
28
+ expect(seam?.incoming).toEqual(clip("in", 1.8, 2, 0, label));
29
+ expect(seam?.centerTime).toBeCloseTo(1.9);
30
+ expect(seam?.duration).toBeCloseTo(0.2);
31
+ });
32
+
33
+ it("does not badge an unlabelled overlap", () => {
34
+ expect(deriveTimelineTransitionSeams([clip("out", 0, 2), clip("in", 1.8, 2)])).toEqual([]);
35
+ });
36
+
37
+ it("does not badge a cross-track overlap", () => {
38
+ const label = "hf:transition:out:in:crossfade";
39
+ expect(
40
+ deriveTimelineTransitionSeams([clip("out", 0, 2, 0, label), clip("in", 1.8, 2, 1, label)]),
41
+ ).toEqual([]);
42
+ });
43
+
44
+ it("rejects crisp gaps and touching edges", () => {
45
+ expect(deriveTimelineTransitionSeams([clip("a", 0, 2), clip("b", 2.04, 1)])).toEqual([]);
46
+ expect(deriveTimelineTransitionSeams([clip("a", 0, 2), clip("b", 2, 1)])).toEqual([]);
47
+ });
48
+ });
@@ -0,0 +1,36 @@
1
+ import type { TimelineElement } from "../store/playerStore";
2
+
3
+ export interface TimelineTransitionSeam {
4
+ outgoing: TimelineElement;
5
+ incoming: TimelineElement;
6
+ centerTime: number;
7
+ duration: number;
8
+ }
9
+
10
+ export function isTransitionPair(a: TimelineElement, b: TimelineElement): boolean {
11
+ return Boolean(a.transitionLabel && a.transitionLabel === b.transitionLabel);
12
+ }
13
+
14
+ export function deriveTimelineTransitionSeams(
15
+ elements: readonly TimelineElement[],
16
+ ): TimelineTransitionSeam[] {
17
+ const sorted = [...elements].sort((left, right) => left.start - right.start);
18
+ const seams: TimelineTransitionSeam[] = [];
19
+ for (let index = 0; index < sorted.length - 1; index += 1) {
20
+ const outgoing = sorted[index]!;
21
+ const incoming = sorted[index + 1]!;
22
+ if (outgoing.track !== incoming.track || !isTransitionPair(outgoing, incoming)) continue;
23
+ const outgoingEnd = outgoing.start + outgoing.duration;
24
+ const overlapStart = Math.max(outgoing.start, incoming.start);
25
+ const overlapEnd = Math.min(outgoingEnd, incoming.start + incoming.duration);
26
+ const duration = overlapEnd - overlapStart;
27
+ if (duration <= 0) continue;
28
+ seams.push({
29
+ outgoing,
30
+ incoming,
31
+ centerTime: overlapStart + duration / 2,
32
+ duration,
33
+ });
34
+ }
35
+ return seams;
36
+ }
@@ -171,6 +171,24 @@ describe("normalizeToZones — CapCut-stable lanes follow the track-index (never
171
171
  });
172
172
 
173
173
  describe("normalizeToZones — legacy overlap spill (display-only, deterministic)", () => {
174
+ it("keeps a labelled transition pair on one row", () => {
175
+ const transitionLabel = "hf:transition:outgoing:incoming:crossfade";
176
+ const out = normalizeToZones([
177
+ { ...zClip("outgoing", 0, 2.5, 0, 0), transitionLabel },
178
+ { ...zClip("incoming", 2, 2.5, 0, 0), transitionLabel },
179
+ ]);
180
+ expect(trackOf(out, "outgoing")).toBe(0);
181
+ expect(trackOf(out, "incoming")).toBe(0);
182
+ });
183
+
184
+ it("still splits an unlabelled overlap", () => {
185
+ const out = normalizeToZones([
186
+ zClip("outgoing", 0, 2.5, 0, 0),
187
+ zClip("incoming", 2, 2.5, 0, 0),
188
+ ]);
189
+ expect(trackOf(out, "outgoing")).not.toBe(trackOf(out, "incoming"));
190
+ });
191
+
174
192
  it("splits time-overlapping SAME-track clips onto adjacent sub-lanes (no visible overlap)", () => {
175
193
  // a [0,5), b [2,7) overlaps a, c [6,9) sequential — all authored on track 1.
176
194
  // The editor forbids per-track overlap, but a legacy file can carry it; the
@@ -1,5 +1,6 @@
1
1
  import type { TimelineElement } from "../store/playerStore";
2
2
  import { isAudioTimelineElement } from "../../utils/timelineInspector";
3
+ import { isTransitionPair } from "./timelineTransitionSeams";
3
4
 
4
5
  /**
5
6
  * Free-form vertical zones, top → bottom: visual, audio. Canvas layering is
@@ -66,7 +67,9 @@ function packTrackLanes(
66
67
  const ordered = [...clips].sort(byStableId);
67
68
  const lanes: TimelineElement[][] = [];
68
69
  for (const el of ordered) {
69
- let sub = lanes.findIndex((occ) => occ.every((o) => !overlaps(o, el)));
70
+ let sub = lanes.findIndex((occ) =>
71
+ occ.every((o) => !overlaps(o, el) || isTransitionPair(o, el)),
72
+ );
70
73
  if (sub === -1) {
71
74
  sub = lanes.length;
72
75
  lanes.push([]);
@@ -32,6 +32,8 @@ export interface TimelineLike {
32
32
  time: () => number;
33
33
  duration: () => number;
34
34
  isActive: () => boolean;
35
+ labels?: Record<string, number>;
36
+ getLabels?: () => Record<string, number>;
35
37
  }
36
38
 
37
39
  export interface ClipManifestClip extends RuntimeTimelineClipIdentity {
@@ -1,5 +1,6 @@
1
1
  // @vitest-environment jsdom
2
2
  import { describe, expect, it } from "vitest";
3
+ import { gsap } from "gsap";
3
4
  import {
4
5
  createTimelineElementFromManifestClip,
5
6
  parseTimelineFromDOM,
@@ -20,6 +21,33 @@ function makeDoc(html: string): Document {
20
21
  }
21
22
 
22
23
  describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
24
+ it("bridges a real GSAP transition marker onto both named clips", () => {
25
+ document.body.innerHTML = `
26
+ <div data-composition-id="root">
27
+ <img data-hf-id="outgoing" data-start="0" data-duration="2.5" data-track-index="0" />
28
+ <img data-hf-id="incoming" data-start="2" data-duration="2.5" data-track-index="0" />
29
+ </div>
30
+ `;
31
+ const timeline = gsap.timeline({ paused: true });
32
+ timeline.addLabel("hf:transition:outgoing:incoming:crossfade", 2);
33
+ const runtimeWindow = window as Window & {
34
+ __timelines?: Record<string, typeof timeline>;
35
+ };
36
+ runtimeWindow.__timelines = { main: timeline };
37
+
38
+ try {
39
+ const elements = parseTimelineFromDOM(document, 5);
40
+ expect(elements.map((element) => element.transitionLabel)).toEqual([
41
+ "hf:transition:outgoing:incoming:crossfade",
42
+ "hf:transition:outgoing:incoming:crossfade",
43
+ ]);
44
+ } finally {
45
+ delete runtimeWindow.__timelines;
46
+ timeline.kill();
47
+ document.body.innerHTML = "";
48
+ }
49
+ });
50
+
23
51
  it("harvests hfId from a data-start element that has data-hf-id", () => {
24
52
  const doc = makeDoc(`
25
53
  <div data-composition-id="root">
@@ -9,10 +9,11 @@
9
9
  */
10
10
 
11
11
  import type { TimelineElement } from "../store/playerStore";
12
- import type { ClipManifestClip } from "./playbackTypes";
12
+ import type { ClipManifestClip, IframeWindow, TimelineLike } from "./playbackTypes";
13
13
  import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
14
14
  import { readClipTiming } from "@hyperframes/core/composition-contract";
15
15
  import { groupInfoFor } from "./timelineGroupInfo";
16
+ import { transitionLabelsForDocument } from "./timelineTransitionMetadata";
16
17
  import {
17
18
  resolveMediaElement,
18
19
  applyMediaMetadataFromElement,
@@ -75,6 +76,9 @@ export function createTimelineElementFromManifestClip(params: {
75
76
  }): TimelineElement {
76
77
  const { clip, fallbackIndex, doc } = params;
77
78
  let hostEl = params.hostEl ?? null;
79
+ const transitionLabels = doc
80
+ ? transitionLabelsForDocument(doc, (doc.defaultView as IframeWindow | null)?.__timelines)
81
+ : new Map<Element, string>();
78
82
  const label = getTimelineElementDisplayLabel({
79
83
  id: clip.id,
80
84
  label: clip.label,
@@ -108,6 +112,10 @@ export function createTimelineElementFromManifestClip(params: {
108
112
  const entry: TimelineElement = {
109
113
  id: identity.id,
110
114
  label,
115
+ transitionLabel:
116
+ (hostEl && transitionLabels.get(hostEl)) ||
117
+ hostEl?.getAttribute("data-transition-label") ||
118
+ undefined,
111
119
  key: identity.key,
112
120
  kind: clip.kind,
113
121
  tag: resolveClipTag(clip),
@@ -134,6 +142,10 @@ export function createTimelineElementFromManifestClip(params: {
134
142
 
135
143
  if (hostEl) {
136
144
  applyMediaMetadataFromElement(entry, hostEl);
145
+ if (!entry.src) {
146
+ const rawSrc = hostEl.getAttribute("src");
147
+ if (rawSrc) entry.src = new URL(rawSrc, hostEl.baseURI).href;
148
+ }
137
149
  if (hostEl.hasAttribute("data-hidden")) entry.hidden = true;
138
150
  const timelineRole = hostEl.getAttribute("data-timeline-role");
139
151
  if (timelineRole) entry.timelineRole = timelineRole;
@@ -205,11 +217,19 @@ export function createTimelineElementFromManifestClip(params: {
205
217
  * Parse [data-start] elements from a Document into TimelineElement[].
206
218
  * Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions.
207
219
  */
208
- export function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElement[] {
220
+ export function parseTimelineFromDOM(
221
+ doc: Document,
222
+ rootDuration: number,
223
+ timelines?: Readonly<Record<string, TimelineLike>>,
224
+ ): TimelineElement[] {
209
225
  const rootComp = doc.querySelector("[data-composition-id]");
210
226
  const nodes = doc.querySelectorAll("[data-start]");
211
227
  const els: TimelineElement[] = [];
212
228
  let trackCounter = 0;
229
+ const transitionLabels = transitionLabelsForDocument(
230
+ doc,
231
+ timelines ?? (doc.defaultView as IframeWindow | null)?.__timelines,
232
+ );
213
233
 
214
234
  // fallow-ignore-next-line complexity
215
235
  nodes.forEach((node) => {
@@ -252,6 +272,8 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
252
272
  const entry: TimelineElement = {
253
273
  id: identity.id,
254
274
  label,
275
+ transitionLabel:
276
+ transitionLabels.get(el) ?? el.getAttribute("data-transition-label") ?? undefined,
255
277
  key: identity.key,
256
278
  kind:
257
279
  compId && compId !== rootComp?.getAttribute("data-composition-id")
@@ -22,6 +22,7 @@ import {
22
22
  readTimelineElementZIndex,
23
23
  } from "./timelineElementHelpers";
24
24
  import { postRuntimeControlMessage } from "./runtimeProtocol";
25
+ import { transitionLabelsForDocument } from "./timelineTransitionMetadata";
25
26
 
26
27
  // ---------------------------------------------------------------------------
27
28
  // Viewport / DOM normalisation
@@ -414,6 +415,7 @@ function buildMissingCompositionEntry(params: {
414
415
  }): TimelineElement | null {
415
416
  const { doc, iframeWin, element, compositionId, rootDuration, fallbackIndex, resolveEnd } =
416
417
  params;
418
+ const transitionLabels = transitionLabelsForDocument(doc, iframeWin.__timelines);
417
419
  const timing = readClipTiming(element, {
418
420
  resolveReferenceEnd: (refId) => resolveEnd(refId, new Set([compositionId])),
419
421
  });
@@ -447,6 +449,8 @@ function buildMissingCompositionEntry(params: {
447
449
  const entry: TimelineElement = {
448
450
  id: identity.id,
449
451
  label,
452
+ transitionLabel:
453
+ transitionLabels.get(element) ?? element.getAttribute("data-transition-label") ?? undefined,
450
454
  key: identity.key,
451
455
  tag: element.tagName.toLowerCase(),
452
456
  start: window.start,
@@ -459,6 +463,9 @@ function buildMissingCompositionEntry(params: {
459
463
  selectorIndex,
460
464
  sourceFile,
461
465
  zIndex: readTimelineElementZIndex(element),
466
+ src: optionalNonEmpty(element.getAttribute("src"))
467
+ ? new URL(element.getAttribute("src")!, element.baseURI).href
468
+ : undefined,
462
469
  };
463
470
  return attachCompositionSource(entry, element, compositionSrc);
464
471
  }