@hyperframes/studio 0.7.93 → 0.7.95
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-NSdcoOcK.js → hyperframes-player-C1o3TZ1w.js} +1 -1
- package/dist/assets/{index-Dp1zVb9X.js → index-4KcC7fDo.js} +195 -195
- package/dist/assets/{index-wS6f7F5q.js → index-Bl6x228Z.js} +1 -1
- package/dist/assets/{index-CCGnc_0M.js → index-DR4Dsw8D.js} +1 -1
- package/dist/assets/index-tBPidglp.css +1 -0
- package/dist/index.d.ts +27 -7
- package/dist/index.html +2 -2
- package/dist/index.js +2644 -1837
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/components/EditorShell.selectionSync.test.tsx +109 -0
- package/src/components/EditorShell.tsx +31 -5
- package/src/components/TimelineToolbar.test.tsx +20 -1
- package/src/components/TimelineToolbar.tsx +29 -1
- package/src/hooks/useRenderClipContent.test.ts +77 -2
- package/src/hooks/useRenderClipContent.ts +78 -14
- package/src/hooks/useThumbnailLease.test.tsx +150 -0
- package/src/hooks/useThumbnailLease.ts +44 -0
- package/src/hooks/useTimelineSelectionPreviewSync.test.tsx +79 -5
- package/src/hooks/useTimelineSelectionPreviewSync.ts +40 -13
- package/src/player/components/AudioWaveform.test.tsx +53 -0
- package/src/player/components/AudioWaveform.tsx +137 -140
- package/src/player/components/CompositionThumbnail.test.ts +33 -10
- package/src/player/components/CompositionThumbnail.tsx +78 -61
- package/src/player/components/ImageThumbnail.test.tsx +26 -14
- package/src/player/components/ImageThumbnail.tsx +58 -101
- package/src/player/components/Timeline.tsx +20 -19
- package/src/player/components/TimelineGestureOverlay.tsx +3 -0
- package/src/player/components/TimelineLanes.test.tsx +24 -2
- package/src/player/components/TimelineLanes.tsx +12 -12
- package/src/player/components/TimelineTypes.ts +6 -0
- package/src/player/components/VideoThumbnail.test.tsx +46 -100
- package/src/player/components/VideoThumbnail.tsx +74 -156
- package/src/player/components/thumbnailUtils.test.ts +35 -0
- package/src/player/components/thumbnailUtils.ts +86 -8
- package/src/player/components/timelineClipChildren.test.ts +31 -0
- package/src/player/components/timelineClipChildren.tsx +21 -2
- package/src/player/components/timelineLaneProps.ts +3 -0
- package/src/player/components/useTimelineClipRenderWindow.ts +9 -2
- package/src/player/hooks/useTimelinePlayer.ts +14 -9
- package/src/player/lib/mediaProbe.test.ts +143 -0
- package/src/player/lib/mediaProbe.ts +117 -25
- package/src/player/lib/thumbnailPolicy.test.ts +14 -0
- package/src/player/lib/thumbnailPolicy.ts +24 -0
- package/src/player/lib/thumbnailScheduler.test.ts +388 -0
- package/src/player/lib/thumbnailScheduler.ts +483 -0
- package/src/player/lib/thumbnailVideoDecoder.test.ts +127 -0
- package/src/player/lib/thumbnailVideoDecoder.ts +170 -0
- package/src/player/lib/timelineViewportBudgets.ts +2 -0
- package/src/player/store/playerStore.ts +3 -1
- package/src/player/store/thumbnailSlice.ts +18 -0
- package/src/utils/frameCapture.test.ts +1 -1
- package/src/utils/frameCapture.ts +1 -0
- package/src/utils/projectRouting.test.ts +1 -1
- package/src/utils/studioSelectionSnapshot.test.ts +1 -1
- package/src/utils/studioSelectionSnapshot.ts +1 -0
- package/src/utils/studioUiPreferences.test.ts +14 -0
- package/src/utils/studioUiPreferences.ts +6 -0
- package/dist/assets/index-D78KEjgB.css +0 -1
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
2
|
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
|
|
3
|
+
import type { TimelineTimeRange } from "../lib/timelineClipIndex";
|
|
3
4
|
import type { TrackVisualStyle } from "./timelineIcons";
|
|
5
|
+
import type { TimelineClipRenderContext } from "./TimelineTypes";
|
|
6
|
+
|
|
7
|
+
export function resolveClipRenderContext(
|
|
8
|
+
element: TimelineElement,
|
|
9
|
+
visibleTimeRange: TimelineTimeRange,
|
|
10
|
+
interactive: boolean,
|
|
11
|
+
): TimelineClipRenderContext {
|
|
12
|
+
if (interactive) return { priority: "interaction", rich: true };
|
|
13
|
+
const visible =
|
|
14
|
+
element.start < visibleTimeRange.end &&
|
|
15
|
+
element.start + element.duration > visibleTimeRange.start;
|
|
16
|
+
return { priority: visible ? "visible" : "overscan", rich: false };
|
|
17
|
+
}
|
|
4
18
|
|
|
5
19
|
function ClipLintDot({ element }: { element: TimelineElement }) {
|
|
6
20
|
const lint = usePlayerStore((s) => s.lintFindingsByElement.get(element.key ?? element.id));
|
|
@@ -18,9 +32,14 @@ export function renderClipChildren(
|
|
|
18
32
|
element: TimelineElement,
|
|
19
33
|
clipStyle: TrackVisualStyle,
|
|
20
34
|
renderClipContent:
|
|
21
|
-
| ((
|
|
35
|
+
| ((
|
|
36
|
+
element: TimelineElement,
|
|
37
|
+
style: { clip: string; label: string },
|
|
38
|
+
context: TimelineClipRenderContext,
|
|
39
|
+
) => ReactNode)
|
|
22
40
|
| undefined,
|
|
23
41
|
renderClipOverlay: ((element: TimelineElement) => ReactNode) | undefined,
|
|
42
|
+
context: TimelineClipRenderContext = { priority: "visible", rich: false },
|
|
24
43
|
): ReactNode {
|
|
25
44
|
return (
|
|
26
45
|
<>
|
|
@@ -31,7 +50,7 @@ export function renderClipChildren(
|
|
|
31
50
|
// diamonds hang outside its bounds), so the thumbnail layer must clip
|
|
32
51
|
// itself to the clip's rounded corners or sharp corners poke out.
|
|
33
52
|
<div className="absolute inset-0 overflow-hidden" style={{ borderRadius: "inherit" }}>
|
|
34
|
-
{renderClipContent(element, clipStyle)}
|
|
53
|
+
{renderClipContent(element, clipStyle, context)}
|
|
35
54
|
</div>
|
|
36
55
|
)}
|
|
37
56
|
</>
|
|
@@ -10,6 +10,7 @@ import type { TimelineClipIndex, TimelineTimeRange } from "../lib/timelineClipIn
|
|
|
10
10
|
import type { TimelineRowGeometry } from "./timelineLayout";
|
|
11
11
|
import type { TimelineVirtualRow } from "./useTimelineVirtualRows";
|
|
12
12
|
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
|
|
13
|
+
import type { TimelineClipRenderContext } from "./TimelineTypes";
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Props shared by the scroll container ({@link import("./TimelineCanvas")}) and
|
|
@@ -33,6 +34,7 @@ export interface TimelineLaneBaseProps {
|
|
|
33
34
|
rowsVirtualized: boolean;
|
|
34
35
|
clipIndex: TimelineClipIndex;
|
|
35
36
|
renderTimeRange: TimelineTimeRange;
|
|
37
|
+
visibleTimeRange: TimelineTimeRange;
|
|
36
38
|
pinnedClipIdentities: ReadonlySet<string>;
|
|
37
39
|
trackOrder: number[];
|
|
38
40
|
tracks: [number, TimelineElement[]][];
|
|
@@ -48,6 +50,7 @@ export interface TimelineLaneBaseProps {
|
|
|
48
50
|
renderClipContent?: (
|
|
49
51
|
element: TimelineElement,
|
|
50
52
|
style: { clip: string; label: string },
|
|
53
|
+
context: TimelineClipRenderContext,
|
|
51
54
|
) => ReactNode;
|
|
52
55
|
renderClipOverlay?: (element: TimelineElement) => ReactNode;
|
|
53
56
|
onDrillDown?: (element: TimelineElement) => void;
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { useMemo } from "react";
|
|
2
2
|
import { createTimelineClipIndex } from "../lib/timelineClipIndex";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
getTimelineRenderTimeRange,
|
|
5
|
+
getTimelineVisibleTimeRange,
|
|
6
|
+
} from "./timelineViewportGeometry";
|
|
4
7
|
import type { TimelineScrollViewportSnapshot } from "./useTimelineScrollViewport";
|
|
5
8
|
|
|
6
9
|
interface UseTimelineClipRenderWindowInput {
|
|
@@ -37,6 +40,10 @@ export function useTimelineClipRenderWindow({
|
|
|
37
40
|
() => getTimelineRenderTimeRange(viewport, pixelsPerSecond, contentOrigin, duration),
|
|
38
41
|
[contentOrigin, duration, pixelsPerSecond, viewport],
|
|
39
42
|
);
|
|
43
|
+
const visibleTimeRange = useMemo(
|
|
44
|
+
() => getTimelineVisibleTimeRange(viewport, pixelsPerSecond, contentOrigin, duration),
|
|
45
|
+
[contentOrigin, duration, pixelsPerSecond, viewport],
|
|
46
|
+
);
|
|
40
47
|
const pinnedClipIdentities = useMemo(
|
|
41
48
|
() =>
|
|
42
49
|
new Set(
|
|
@@ -60,5 +67,5 @@ export function useTimelineClipRenderWindow({
|
|
|
60
67
|
selectedElementId,
|
|
61
68
|
],
|
|
62
69
|
);
|
|
63
|
-
return { clipIndex, renderTimeRange, pinnedClipIdentities };
|
|
70
|
+
return { clipIndex, renderTimeRange, visibleTimeRange, pinnedClipIdentities };
|
|
64
71
|
}
|
|
@@ -86,6 +86,7 @@ export function useTimelinePlayer() {
|
|
|
86
86
|
state.duration,
|
|
87
87
|
resolvedDuration,
|
|
88
88
|
),
|
|
89
|
+
state.timelineProjectId,
|
|
89
90
|
),
|
|
90
91
|
);
|
|
91
92
|
|
|
@@ -105,15 +106,19 @@ export function useTimelinePlayer() {
|
|
|
105
106
|
|
|
106
107
|
// Asynchronously enrich media elements still missing sourceDuration
|
|
107
108
|
// (header-only probe, cheap), applying each resolved value to the store.
|
|
108
|
-
void probeMissingSourceDurations(
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
109
|
+
void probeMissingSourceDurations(
|
|
110
|
+
mergedElements,
|
|
111
|
+
state.timelineProjectId,
|
|
112
|
+
(key, durationSeconds) => {
|
|
113
|
+
usePlayerStore.setState((state) => {
|
|
114
|
+
const idx = state.elements.findIndex((e) => (e.key ?? e.id) === key);
|
|
115
|
+
if (idx === -1 || state.elements[idx].sourceDuration != null) return {};
|
|
116
|
+
const patched = state.elements.slice();
|
|
117
|
+
patched[idx] = { ...state.elements[idx], sourceDuration: durationSeconds };
|
|
118
|
+
return { elements: patched };
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
);
|
|
117
122
|
},
|
|
118
123
|
[setElements, setTimelineReady, setDuration],
|
|
119
124
|
);
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
applyCachedSourceDurations,
|
|
6
|
+
getMediaProbeDiagnostics,
|
|
7
|
+
probeMediaUrl,
|
|
8
|
+
probeMissingSourceDurations,
|
|
9
|
+
resetMediaProbeRegistry,
|
|
10
|
+
} from "./mediaProbe";
|
|
11
|
+
|
|
12
|
+
const dispose = vi.fn();
|
|
13
|
+
const getDurationFromMetadata = vi.fn(async () => 5);
|
|
14
|
+
const requestedSources: string[] = [];
|
|
15
|
+
|
|
16
|
+
vi.mock("mediabunny", () => ({
|
|
17
|
+
ALL_FORMATS: {},
|
|
18
|
+
UrlSource: class {
|
|
19
|
+
constructor(readonly url: string) {
|
|
20
|
+
requestedSources.push(url);
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
Input: class {
|
|
24
|
+
getDurationFromMetadata = getDurationFromMetadata;
|
|
25
|
+
getPrimaryVideoTrack = vi.fn(async () => ({ displayWidth: 640, displayHeight: 360 }));
|
|
26
|
+
getAudioTracks = vi.fn(async () => []);
|
|
27
|
+
dispose = dispose;
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
resetMediaProbeRegistry();
|
|
33
|
+
vi.clearAllMocks();
|
|
34
|
+
requestedSources.length = 0;
|
|
35
|
+
getDurationFromMetadata.mockResolvedValue(5);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
vi.useRealTimers();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("media probe registry", () => {
|
|
43
|
+
it("deduplicates and caches successful probes", async () => {
|
|
44
|
+
const [first, second] = await Promise.all([
|
|
45
|
+
probeMediaUrl("/video.mp4"),
|
|
46
|
+
probeMediaUrl("/video.mp4"),
|
|
47
|
+
]);
|
|
48
|
+
expect(first).toEqual(second);
|
|
49
|
+
expect(getDurationFromMetadata).toHaveBeenCalledTimes(1);
|
|
50
|
+
expect(dispose).toHaveBeenCalledTimes(1);
|
|
51
|
+
expect(getMediaProbeDiagnostics()).toEqual({ cached: 1, failed: 0, inflight: 0 });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("bounds retained successes to the configured registry count", async () => {
|
|
55
|
+
for (let index = 0; index < 513; index++) {
|
|
56
|
+
await probeMediaUrl(`/video-${index}.mp4`);
|
|
57
|
+
}
|
|
58
|
+
expect(getMediaProbeDiagnostics().cached).toBe(512);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("limits concurrent metadata probes and drains the queue", async () => {
|
|
62
|
+
const resolvers: Array<(duration: number) => void> = [];
|
|
63
|
+
getDurationFromMetadata.mockImplementation(
|
|
64
|
+
() => new Promise<number>((resolve) => resolvers.push(resolve)),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const probes = Array.from({ length: 5 }, (_, index) => probeMediaUrl(`/queued-${index}.mp4`));
|
|
68
|
+
await Promise.resolve();
|
|
69
|
+
await Promise.resolve();
|
|
70
|
+
expect(getDurationFromMetadata).toHaveBeenCalledTimes(4);
|
|
71
|
+
|
|
72
|
+
resolvers[0]?.(5);
|
|
73
|
+
await vi.waitFor(() => expect(getDurationFromMetadata).toHaveBeenCalledTimes(5));
|
|
74
|
+
|
|
75
|
+
for (const resolve of resolvers.slice(1)) resolve(5);
|
|
76
|
+
await expect(Promise.all(probes)).resolves.toHaveLength(5);
|
|
77
|
+
expect(getMediaProbeDiagnostics()).toEqual({ cached: 5, failed: 0, inflight: 0 });
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("retries failures only after the failure TTL", async () => {
|
|
81
|
+
vi.useFakeTimers();
|
|
82
|
+
getDurationFromMetadata.mockRejectedValue(new Error("bad source"));
|
|
83
|
+
await expect(probeMediaUrl("/bad.mp4")).resolves.toBeNull();
|
|
84
|
+
await expect(probeMediaUrl("/bad.mp4")).resolves.toBeNull();
|
|
85
|
+
expect(getDurationFromMetadata).toHaveBeenCalledTimes(1);
|
|
86
|
+
|
|
87
|
+
vi.advanceTimersByTime(30_001);
|
|
88
|
+
await expect(probeMediaUrl("/bad.mp4")).resolves.toBeNull();
|
|
89
|
+
expect(getDurationFromMetadata).toHaveBeenCalledTimes(2);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("probes same-origin rooted media through the active project preview", async () => {
|
|
93
|
+
const apply = vi.fn();
|
|
94
|
+
await probeMissingSourceDurations(
|
|
95
|
+
[
|
|
96
|
+
{
|
|
97
|
+
id: "clip",
|
|
98
|
+
tag: "video",
|
|
99
|
+
src: `${window.location.origin}/assets/clip.mp4`,
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
"project-a",
|
|
103
|
+
apply,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(requestedSources).toEqual([
|
|
107
|
+
`${window.location.origin}/api/projects/project-a/preview/assets/clip.mp4`,
|
|
108
|
+
]);
|
|
109
|
+
expect(apply).toHaveBeenCalledWith("clip", 5);
|
|
110
|
+
expect(
|
|
111
|
+
applyCachedSourceDurations(
|
|
112
|
+
[
|
|
113
|
+
{
|
|
114
|
+
id: "clip",
|
|
115
|
+
tag: "video",
|
|
116
|
+
src: `${window.location.origin}/assets/clip.mp4`,
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
"project-a",
|
|
120
|
+
),
|
|
121
|
+
).toEqual([
|
|
122
|
+
{
|
|
123
|
+
id: "clip",
|
|
124
|
+
tag: "video",
|
|
125
|
+
src: `${window.location.origin}/assets/clip.mp4`,
|
|
126
|
+
sourceDuration: 5,
|
|
127
|
+
},
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
await probeMissingSourceDurations(
|
|
131
|
+
[
|
|
132
|
+
{
|
|
133
|
+
id: "clip",
|
|
134
|
+
tag: "video",
|
|
135
|
+
src: `${window.location.origin}/assets/clip.mp4`,
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
"project-a",
|
|
139
|
+
apply,
|
|
140
|
+
);
|
|
141
|
+
expect(requestedSources).toHaveLength(1);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
import { resolveMediaPreviewUrl } from "../components/thumbnailUtils";
|
|
2
|
+
import { TIMELINE_VIEWPORT_BUDGETS } from "./timelineViewportBudgets";
|
|
3
|
+
|
|
4
|
+
export interface MediaProbeResult {
|
|
2
5
|
duration: number;
|
|
3
6
|
width?: number;
|
|
4
7
|
height?: number;
|
|
@@ -6,11 +9,24 @@ interface MediaProbeResult {
|
|
|
6
9
|
hasAudio: boolean;
|
|
7
10
|
}
|
|
8
11
|
|
|
9
|
-
|
|
12
|
+
interface CachedProbe {
|
|
13
|
+
result: MediaProbeResult;
|
|
14
|
+
lastAccess: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const cache = new Map<string, CachedProbe>();
|
|
10
18
|
const inflight = new Map<string, Promise<MediaProbeResult | null>>();
|
|
11
19
|
// URLs whose probe failed (CORS, 404, non-media). Remembered so the rAF-driven
|
|
12
20
|
// timeline re-derive doesn't re-fetch them every frame and flood the console.
|
|
13
|
-
const failed = new
|
|
21
|
+
const failed = new Map<string, { failedAt: number; lastAccess: number }>();
|
|
22
|
+
let accessSequence = 0;
|
|
23
|
+
let activeProbes = 0;
|
|
24
|
+
let registryEpoch = 0;
|
|
25
|
+
const probeQueue: Array<{
|
|
26
|
+
key: string;
|
|
27
|
+
epoch: number;
|
|
28
|
+
resolve: (result: MediaProbeResult | null) => void;
|
|
29
|
+
}> = [];
|
|
14
30
|
|
|
15
31
|
let mediabunnyModule: typeof import("mediabunny") | null | false = null;
|
|
16
32
|
|
|
@@ -65,7 +81,26 @@ async function probeOne(url: string): Promise<MediaProbeResult | null> {
|
|
|
65
81
|
}
|
|
66
82
|
|
|
67
83
|
function getCachedProbe(url: string): MediaProbeResult | undefined {
|
|
68
|
-
|
|
84
|
+
const cached = cache.get(normalizeUrl(url));
|
|
85
|
+
if (cached) cached.lastAccess = ++accessSequence;
|
|
86
|
+
return cached?.result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function resolveProbeSource(src: string, projectId: string | null): string {
|
|
90
|
+
return projectId ? resolveMediaPreviewUrl(src, projectId, window.location.origin) : src;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function evictMetadataOverflow(): void {
|
|
94
|
+
const overflow = cache.size + failed.size - TIMELINE_VIEWPORT_BUDGETS.metadataRegistryEntries;
|
|
95
|
+
if (overflow <= 0) return;
|
|
96
|
+
const entries = [
|
|
97
|
+
...Array.from(cache, ([key, value]) => ({ key, at: value.lastAccess, failed: false })),
|
|
98
|
+
...Array.from(failed, ([key, value]) => ({ key, at: value.lastAccess, failed: true })),
|
|
99
|
+
].sort((left, right) => left.at - right.at);
|
|
100
|
+
for (const entry of entries.slice(0, overflow)) {
|
|
101
|
+
if (entry.failed) failed.delete(entry.key);
|
|
102
|
+
else cache.delete(entry.key);
|
|
103
|
+
}
|
|
69
104
|
}
|
|
70
105
|
|
|
71
106
|
/**
|
|
@@ -76,11 +111,11 @@ function getCachedProbe(url: string): MediaProbeResult | undefined {
|
|
|
76
111
|
*/
|
|
77
112
|
export function applyCachedSourceDurations<
|
|
78
113
|
T extends { src?: string; tag: string; sourceDuration?: number },
|
|
79
|
-
>(elements: T[]): T[] {
|
|
114
|
+
>(elements: T[], projectId: string | null): T[] {
|
|
80
115
|
return elements.map((el) => {
|
|
81
116
|
const tag = el.tag.toLowerCase();
|
|
82
117
|
if (!el.src || el.sourceDuration != null || (tag !== "audio" && tag !== "video")) return el;
|
|
83
|
-
const cached = getCachedProbe(el.src);
|
|
118
|
+
const cached = getCachedProbe(resolveProbeSource(el.src, projectId));
|
|
84
119
|
return cached?.duration && cached.duration > 0
|
|
85
120
|
? { ...el, sourceDuration: cached.duration }
|
|
86
121
|
: el;
|
|
@@ -94,39 +129,96 @@ export function applyCachedSourceDurations<
|
|
|
94
129
|
*/
|
|
95
130
|
export async function probeMissingSourceDurations<
|
|
96
131
|
T extends { src?: string; tag: string; sourceDuration?: number; key?: string; id: string },
|
|
97
|
-
>(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
!
|
|
105
|
-
|
|
132
|
+
>(
|
|
133
|
+
elements: T[],
|
|
134
|
+
projectId: string | null,
|
|
135
|
+
apply: (key: string, durationSeconds: number) => void,
|
|
136
|
+
): Promise<void> {
|
|
137
|
+
const needs = elements.flatMap((el) => {
|
|
138
|
+
if (
|
|
139
|
+
!el.src ||
|
|
140
|
+
el.sourceDuration != null ||
|
|
141
|
+
!["video", "audio"].includes(el.tag.toLowerCase())
|
|
142
|
+
) {
|
|
143
|
+
return [];
|
|
144
|
+
}
|
|
145
|
+
const source = resolveProbeSource(el.src, projectId);
|
|
146
|
+
return !getCachedProbe(source) && !hasFreshFailure(normalizeUrl(source))
|
|
147
|
+
? [{ el, source }]
|
|
148
|
+
: [];
|
|
149
|
+
});
|
|
106
150
|
if (needs.length === 0) return;
|
|
107
151
|
await Promise.allSettled(
|
|
108
|
-
needs.map(async (el) => {
|
|
109
|
-
const result = await probeMediaUrl(
|
|
152
|
+
needs.map(async ({ el, source }) => {
|
|
153
|
+
const result = await probeMediaUrl(source);
|
|
110
154
|
if (result) apply(el.key ?? el.id, result.duration);
|
|
111
155
|
}),
|
|
112
156
|
);
|
|
113
157
|
}
|
|
114
158
|
|
|
115
|
-
|
|
159
|
+
function hasFreshFailure(key: string): boolean {
|
|
160
|
+
const failedAt = failed.get(key);
|
|
161
|
+
if (failedAt === undefined) return false;
|
|
162
|
+
if (Date.now() - failedAt.failedAt < TIMELINE_VIEWPORT_BUDGETS.metadataFailureTtlMs) {
|
|
163
|
+
failedAt.lastAccess = ++accessSequence;
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
failed.delete(key);
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function probeMediaUrl(url: string): Promise<MediaProbeResult | null> {
|
|
116
171
|
const key = normalizeUrl(url);
|
|
117
|
-
const cached =
|
|
172
|
+
const cached = getCachedProbe(key);
|
|
118
173
|
if (cached) return cached;
|
|
119
|
-
if (
|
|
174
|
+
if (hasFreshFailure(key)) return null;
|
|
120
175
|
|
|
121
176
|
let pending = inflight.get(key);
|
|
122
177
|
if (pending) return pending;
|
|
123
178
|
|
|
124
|
-
pending =
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
else failed.add(key);
|
|
128
|
-
return result;
|
|
179
|
+
pending = new Promise<MediaProbeResult | null>((resolve) => {
|
|
180
|
+
probeQueue.push({ key, epoch: registryEpoch, resolve });
|
|
181
|
+
pumpProbeQueue();
|
|
129
182
|
});
|
|
130
183
|
inflight.set(key, pending);
|
|
131
184
|
return pending;
|
|
132
185
|
}
|
|
186
|
+
|
|
187
|
+
function pumpProbeQueue(): void {
|
|
188
|
+
while (activeProbes < TIMELINE_VIEWPORT_BUDGETS.concurrentMetadataJobs) {
|
|
189
|
+
const queued = probeQueue.shift();
|
|
190
|
+
if (!queued) return;
|
|
191
|
+
if (queued.epoch !== registryEpoch) {
|
|
192
|
+
queued.resolve(null);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
activeProbes++;
|
|
196
|
+
void probeOne(queued.key)
|
|
197
|
+
.then((result) => {
|
|
198
|
+
if (queued.epoch !== registryEpoch) return null;
|
|
199
|
+
inflight.delete(queued.key);
|
|
200
|
+
if (result) cache.set(queued.key, { result, lastAccess: ++accessSequence });
|
|
201
|
+
else failed.set(queued.key, { failedAt: Date.now(), lastAccess: ++accessSequence });
|
|
202
|
+
evictMetadataOverflow();
|
|
203
|
+
return result;
|
|
204
|
+
})
|
|
205
|
+
.then(queued.resolve)
|
|
206
|
+
.finally(() => {
|
|
207
|
+
activeProbes--;
|
|
208
|
+
pumpProbeQueue();
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function getMediaProbeDiagnostics() {
|
|
214
|
+
return { cached: cache.size, failed: failed.size, inflight: inflight.size };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function resetMediaProbeRegistry(): void {
|
|
218
|
+
registryEpoch++;
|
|
219
|
+
for (const queued of probeQueue.splice(0)) queued.resolve(null);
|
|
220
|
+
cache.clear();
|
|
221
|
+
failed.clear();
|
|
222
|
+
inflight.clear();
|
|
223
|
+
accessSequence = 0;
|
|
224
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { defaultThumbnailMode, effectiveThumbnailMode } from "./thumbnailPolicy";
|
|
3
|
+
|
|
4
|
+
describe("thumbnail runtime policy", () => {
|
|
5
|
+
it("defaults missing preferences adaptively after activation", () => {
|
|
6
|
+
expect(defaultThumbnailMode(undefined, "follow-preference")).toBe("adaptive");
|
|
7
|
+
expect(defaultThumbnailMode(undefined, "legacy-default")).toBe("hidden");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("forces the safe renderer without overwriting user intent", () => {
|
|
11
|
+
expect(effectiveThumbnailMode("adaptive", "force-hidden")).toBe("hidden");
|
|
12
|
+
expect(effectiveThumbnailMode("adaptive", "follow-preference")).toBe("adaptive");
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type ThumbnailMode = "adaptive" | "hidden";
|
|
2
|
+
export type ThumbnailRuntimePolicy = "follow-preference" | "force-hidden" | "legacy-default";
|
|
3
|
+
|
|
4
|
+
// "Adaptive" currently means the scheduler pauses rich work while scrolling.
|
|
5
|
+
// The mode name leaves room for finer-grained runtime heuristics later.
|
|
6
|
+
|
|
7
|
+
const rawPolicy = import.meta.env.VITE_STUDIO_TIMELINE_THUMBNAIL_POLICY;
|
|
8
|
+
|
|
9
|
+
const studioThumbnailRuntimePolicy: ThumbnailRuntimePolicy =
|
|
10
|
+
rawPolicy === "force-hidden" || rawPolicy === "legacy-default" ? rawPolicy : "follow-preference";
|
|
11
|
+
|
|
12
|
+
export function defaultThumbnailMode(
|
|
13
|
+
storedMode: ThumbnailMode | undefined,
|
|
14
|
+
policy: ThumbnailRuntimePolicy = studioThumbnailRuntimePolicy,
|
|
15
|
+
): ThumbnailMode {
|
|
16
|
+
return storedMode ?? (policy === "legacy-default" ? "hidden" : "adaptive");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function effectiveThumbnailMode(
|
|
20
|
+
preferredMode: ThumbnailMode,
|
|
21
|
+
policy: ThumbnailRuntimePolicy = studioThumbnailRuntimePolicy,
|
|
22
|
+
): ThumbnailMode {
|
|
23
|
+
return policy === "force-hidden" ? "hidden" : preferredMode;
|
|
24
|
+
}
|