@hyperframes/studio 0.8.60 → 0.8.62
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-Di7GyCOP.js → hyperframes-player-BMHU9-3w.js} +1 -1
- package/dist/assets/{index-Dx_pt9m4.js → index-Ce1SRT2i.js} +1 -1
- package/dist/assets/{index-Dpq2lkOm.js → index-DLYk_vu_.js} +186 -186
- package/dist/assets/{index-CBKb_0TI.css → index-DS0kLbuM.css} +1 -1
- package/dist/assets/{index-jAjhQTrB.js → index-Dg0Vjj_i.js} +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.html +2 -2
- package/dist/index.js +3308 -2895
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +7 -7
- package/src/App.tsx +2 -0
- package/src/hooks/useExternalFileChangeCoordinator.test.tsx +56 -0
- package/src/hooks/useExternalFileChangeCoordinator.ts +13 -0
- package/src/hooks/useRenderClipContent.test.ts +33 -4
- package/src/hooks/useRenderClipContent.ts +7 -3
- package/src/hooks/useSdkSession.lifecycle.test.tsx +179 -0
- package/src/hooks/useSdkSession.ts +84 -14
- package/src/hooks/useStudioSdkSessions.ts +8 -1
- package/src/player/components/AudioWaveform.test.tsx +53 -1
- package/src/player/components/AudioWaveform.tsx +113 -63
- package/src/player/components/TimelineClipFades.tsx +64 -31
- package/src/player/components/TimelineLanes.tsx +15 -8
- package/src/player/components/TimelineTransitionBadge.tsx +59 -0
- package/src/player/components/TimelineTransitionOverlays.tsx +30 -0
- package/src/player/components/VideoThumbnail.test.tsx +23 -12
- package/src/player/components/VideoThumbnail.tsx +83 -27
- package/src/player/components/audioClipLink.test.ts +34 -0
- package/src/player/components/audioClipLink.ts +33 -0
- package/src/player/components/audioWaveformPeaks.test.ts +32 -0
- package/src/player/components/audioWaveformPeaks.ts +46 -0
- package/src/player/components/thumbnailUtils.test.ts +17 -0
- package/src/player/components/thumbnailUtils.ts +7 -0
- package/src/player/components/timelineTheme.ts +6 -0
- package/src/player/components/timelineTransitionSeams.test.ts +48 -0
- package/src/player/components/timelineTransitionSeams.ts +36 -0
- package/src/player/components/timelineZones.test.ts +18 -0
- package/src/player/components/timelineZones.ts +4 -1
- package/src/player/hooks/useShadowPreviewReload.ts +41 -6
- package/src/player/hooks/useTimelinePlayer.shadowReload.test.ts +53 -0
- package/src/player/lib/playbackTypes.ts +2 -0
- package/src/player/lib/timelineDOM.test.ts +28 -0
- package/src/player/lib/timelineDOM.ts +24 -2
- package/src/player/lib/timelineIframeHelpers.ts +7 -0
- package/src/player/lib/timelineTransitionMetadata.ts +52 -0
- package/src/player/lib/timelineViewportBudgets.ts +2 -1
- package/src/player/store/timelineElement.ts +1 -0
- package/src/styles/hex-baseline.json +4 -4
- package/src/styles/studio.css +84 -0
- package/src/styles/theme.css +10 -2
- package/src/utils/sdkCutover.test.ts +102 -1
- package/src/utils/sdkCutover.ts +16 -21
- 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 {
|
|
4
|
+
import {
|
|
5
|
+
createThumbnailKey,
|
|
6
|
+
type ThumbnailPriority,
|
|
7
|
+
type ThumbnailSnapshot,
|
|
8
|
+
} from "../lib/thumbnailScheduler";
|
|
5
9
|
import { decodeVideoThumbnail } from "../lib/thumbnailVideoDecoder";
|
|
6
|
-
import {
|
|
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
|
-
|
|
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
|
|
94
|
+
const requestFrameCount = quantizeThumbnailFrameCount(
|
|
95
|
+
computeThumbnailStrip(containerWidth, 16 / 9).frameCount,
|
|
96
|
+
);
|
|
97
|
+
const requestProps = useMemo(
|
|
37
98
|
() => ({
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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,
|
|
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
|
|
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) =>
|
|
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([]);
|
|
@@ -16,8 +16,13 @@ import type { PlaybackAdapter } from "../lib/playbackTypes";
|
|
|
16
16
|
|
|
17
17
|
// The single wait budget for a shadow: the player's 8s asset cap plus its 0.42s loader fade
|
|
18
18
|
// leaves about 6.5s for the document load and runtime boot. Nothing shorter may fail the swap.
|
|
19
|
+
// It only runs while the tab is visible: readiness is frame-driven, and a hidden tab renders none.
|
|
19
20
|
export const SHADOW_READY_TIMEOUT_MS = 15_000;
|
|
20
21
|
|
|
22
|
+
function isDocumentHidden(): boolean {
|
|
23
|
+
return typeof document !== "undefined" && document.visibilityState === "hidden";
|
|
24
|
+
}
|
|
25
|
+
|
|
21
26
|
type UseShadowPreviewReloadParams = Omit<
|
|
22
27
|
UseTimelineSyncCallbacksParams,
|
|
23
28
|
"probeIntervalRef" | "onAdapterReady" | "getAdapter" | "isCurrent" | "onLoadGiveUp"
|
|
@@ -55,11 +60,14 @@ export function useShadowPreviewReload({
|
|
|
55
60
|
const onReloadFailedRef = useRef(onReloadFailed);
|
|
56
61
|
onReloadFailedRef.current = onReloadFailed;
|
|
57
62
|
const readyTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
63
|
+
// The shadow still owed a wait budget, so a hidden tab can resume it when it becomes visible.
|
|
64
|
+
const budgetGenRef = useRef<number | null>(null);
|
|
58
65
|
const cancelPendingLoadRef = useRef<() => void>(() => {});
|
|
59
66
|
const [previewSlots, setPreviewSlots] = useState<PreviewIframeSlot[]>([{ gen: 0, role: "live" }]);
|
|
60
67
|
|
|
61
68
|
const stopPendingShadow = useCallback(() => {
|
|
62
69
|
clearTimeout(readyTimerRef.current);
|
|
70
|
+
budgetGenRef.current = null;
|
|
63
71
|
cancelPendingLoadRef.current();
|
|
64
72
|
pendingCommitRef.current = null;
|
|
65
73
|
visuallyReadyGenRef.current = null;
|
|
@@ -145,18 +153,30 @@ export function useShadowPreviewReload({
|
|
|
145
153
|
shadowIframeRef.current = node;
|
|
146
154
|
}, []);
|
|
147
155
|
|
|
156
|
+
const armReadyTimer = useCallback(
|
|
157
|
+
(gen: number) => {
|
|
158
|
+
clearTimeout(readyTimerRef.current);
|
|
159
|
+
if (isDocumentHidden()) return;
|
|
160
|
+
readyTimerRef.current = setTimeout(
|
|
161
|
+
() => failShadow(gen, "it took too long to load"),
|
|
162
|
+
SHADOW_READY_TIMEOUT_MS,
|
|
163
|
+
);
|
|
164
|
+
},
|
|
165
|
+
[failShadow],
|
|
166
|
+
);
|
|
167
|
+
const armReadyTimerRef = useRef(armReadyTimer);
|
|
168
|
+
armReadyTimerRef.current = armReadyTimer;
|
|
169
|
+
|
|
148
170
|
const beginShadowReload = useCallback(
|
|
149
171
|
(url: string) => {
|
|
150
172
|
shadowGenRef.current += 1;
|
|
151
173
|
const gen = shadowGenRef.current;
|
|
152
174
|
stopPendingShadow();
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
SHADOW_READY_TIMEOUT_MS,
|
|
156
|
-
);
|
|
175
|
+
budgetGenRef.current = gen;
|
|
176
|
+
armReadyTimer(gen);
|
|
157
177
|
setPreviewSlots((prev) => planShadowReload(prev, gen, url));
|
|
158
178
|
},
|
|
159
|
-
[stopPendingShadow,
|
|
179
|
+
[stopPendingShadow, armReadyTimer],
|
|
160
180
|
);
|
|
161
181
|
|
|
162
182
|
// Composition switch (not an edit reload): drop any in-flight shadow.
|
|
@@ -169,7 +189,22 @@ export function useShadowPreviewReload({
|
|
|
169
189
|
setPreviewSlots(planShadowDiscard);
|
|
170
190
|
}, [stopPendingShadow, isRefreshingRef, pendingSeekRef]);
|
|
171
191
|
|
|
172
|
-
|
|
192
|
+
// Hiding the tab pauses the budget; showing it again restarts the full budget for a pending shadow.
|
|
193
|
+
useMountEffect(() => {
|
|
194
|
+
const onVisibilityChange = () => {
|
|
195
|
+
if (isDocumentHidden()) {
|
|
196
|
+
clearTimeout(readyTimerRef.current);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const gen = budgetGenRef.current;
|
|
200
|
+
if (gen != null && gen === shadowGenRef.current) armReadyTimerRef.current(gen);
|
|
201
|
+
};
|
|
202
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
203
|
+
return () => {
|
|
204
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
205
|
+
stopPendingShadow();
|
|
206
|
+
};
|
|
207
|
+
});
|
|
173
208
|
|
|
174
209
|
return {
|
|
175
210
|
previewSlots,
|
|
@@ -289,6 +289,50 @@ describe("shadow reload readiness and failure", () => {
|
|
|
289
289
|
unmount(root);
|
|
290
290
|
});
|
|
291
291
|
|
|
292
|
+
it("does not spend the budget while the tab is hidden, and promotes once it is visible", () => {
|
|
293
|
+
vi.useFakeTimers();
|
|
294
|
+
const onPreviewReloadFailed = vi.fn();
|
|
295
|
+
const setVisibility = stubVisibility("hidden");
|
|
296
|
+
const { getApi, gen, root } = beginReload({ onPreviewReloadFailed });
|
|
297
|
+
const shadow = makeShadowWithSpies();
|
|
298
|
+
act(() => {
|
|
299
|
+
getApi().setShadowIframeNode(shadow.iframe);
|
|
300
|
+
getApi().onShadowIframeLoad(gen);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// A hidden tab renders no frames, so readiness cannot arrive; the wall clock must not fail it.
|
|
304
|
+
act(() => void vi.advanceTimersByTime(SHADOW_READY_TIMEOUT_MS * 3));
|
|
305
|
+
expect(onPreviewReloadFailed).not.toHaveBeenCalled();
|
|
306
|
+
expect(getApi().previewSlots).toHaveLength(2);
|
|
307
|
+
|
|
308
|
+
act(() => setVisibility("visible"));
|
|
309
|
+
act(() => getApi().onShadowReadyChange(gen, true));
|
|
310
|
+
expect(getApi().iframeRef.current).toBe(shadow.iframe);
|
|
311
|
+
expect(onPreviewReloadFailed).not.toHaveBeenCalled();
|
|
312
|
+
unmount(root);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("restarts the full budget when a hidden tab becomes visible, then fails a shadow that stays silent", () => {
|
|
316
|
+
vi.useFakeTimers();
|
|
317
|
+
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
318
|
+
const onPreviewReloadFailed = vi.fn();
|
|
319
|
+
const setVisibility = stubVisibility("visible");
|
|
320
|
+
const { getApi, live, root } = beginReload({ onPreviewReloadFailed });
|
|
321
|
+
|
|
322
|
+
act(() => void vi.advanceTimersByTime(SHADOW_READY_TIMEOUT_MS - 1000));
|
|
323
|
+
act(() => setVisibility("hidden"));
|
|
324
|
+
act(() => void vi.advanceTimersByTime(SHADOW_READY_TIMEOUT_MS));
|
|
325
|
+
expect(onPreviewReloadFailed).not.toHaveBeenCalled();
|
|
326
|
+
|
|
327
|
+
act(() => setVisibility("visible"));
|
|
328
|
+
act(() => void vi.advanceTimersByTime(SHADOW_READY_TIMEOUT_MS - 1));
|
|
329
|
+
expect(onPreviewReloadFailed).not.toHaveBeenCalled();
|
|
330
|
+
act(() => void vi.advanceTimersByTime(1));
|
|
331
|
+
expect(onPreviewReloadFailed).toHaveBeenCalledWith(expect.stringContaining("too long"));
|
|
332
|
+
expect(getApi().iframeRef.current).toBe(live);
|
|
333
|
+
unmount(root);
|
|
334
|
+
});
|
|
335
|
+
|
|
292
336
|
it("drops a shadow whose document reports an error and reports the cause", () => {
|
|
293
337
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
|
294
338
|
const onPreviewReloadFailed = vi.fn();
|
|
@@ -455,3 +499,12 @@ function unmount(root: ReturnType<typeof createRoot>) {
|
|
|
455
499
|
root.unmount();
|
|
456
500
|
});
|
|
457
501
|
}
|
|
502
|
+
|
|
503
|
+
function stubVisibility(initial: DocumentVisibilityState) {
|
|
504
|
+
let state = initial;
|
|
505
|
+
vi.spyOn(document, "visibilityState", "get").mockImplementation(() => state);
|
|
506
|
+
return (next: DocumentVisibilityState) => {
|
|
507
|
+
state = next;
|
|
508
|
+
document.dispatchEvent(new Event("visibilitychange"));
|
|
509
|
+
};
|
|
510
|
+
}
|
|
@@ -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 {
|