Skip to content

Commit df6ac61

Browse files
authored
Merge pull request #185 from walterlow/develop
Timeline compact clips, zoom perf, and filmstrip gap coverage
2 parents 9b8bcc2 + effb561 commit df6ac61

23 files changed

Lines changed: 1253 additions & 544 deletions

src/features/media-library/services/media-library-service.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,15 @@ describe('MediaLibraryService', () => {
203203
expect(indexedDbMocks.createMedia).toHaveBeenCalledTimes(1);
204204
expect(indexedDbMocks.associateMediaWithProject).toHaveBeenCalledWith('project-1', result.id);
205205
expect(indexedDbMocks.saveThumbnail).toHaveBeenCalledTimes(1);
206-
expect(filmstripCacheMocks.prewarmPriorityWindow).toHaveBeenCalledWith(
206+
expect(filmstripCacheMocks.prewarmPriorityWindow).toHaveBeenNthCalledWith(
207+
1,
208+
result.id,
209+
mockFile,
210+
10,
211+
{ startTime: 0, endTime: 1 },
212+
);
213+
expect(filmstripCacheMocks.prewarmPriorityWindow).toHaveBeenNthCalledWith(
214+
2,
207215
result.id,
208216
mockFile,
209217
10,

src/features/media-library/services/media-library-service.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ import {
4646
} from '@/features/media-library/deps/composition-runtime';
4747
export { FileAccessError } from './file-access';
4848

49+
const IMPORT_FILMSTRIP_COVER_PREWARM_SECONDS = 1;
4950
const IMPORT_FILMSTRIP_PREWARM_SECONDS = 12;
51+
const IMPORT_BACKGROUND_COVER_WARM_DELAY_MS = 0;
5052
const IMPORT_BACKGROUND_WARM_DELAY_MS = 600;
5153
const IMPORT_BACKGROUND_HEAVY_DELAY_MS = 2200;
5254

@@ -289,6 +291,20 @@ class MediaLibraryService {
289291
await associateMediaWithProject(projectId, id);
290292

291293
if (metadata.type === 'video' && mediaMetadata.duration > 0) {
294+
const coverWarmEndTime = Math.min(
295+
mediaMetadata.duration,
296+
IMPORT_FILMSTRIP_COVER_PREWARM_SECONDS,
297+
);
298+
enqueueBackgroundMediaWork(() => (
299+
filmstripCache.prewarmPriorityWindow(id, file, mediaMetadata.duration, {
300+
startTime: 0,
301+
endTime: coverWarmEndTime,
302+
})
303+
), {
304+
priority: 'warm',
305+
delayMs: IMPORT_BACKGROUND_COVER_WARM_DELAY_MS,
306+
});
307+
292308
const warmEndTime = Math.min(mediaMetadata.duration, IMPORT_FILMSTRIP_PREWARM_SECONDS);
293309
enqueueBackgroundMediaWork(() => (
294310
filmstripCache.prewarmPriorityWindow(id, file, mediaMetadata.duration, {

src/features/media-library/services/proxy-service.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -387,7 +387,15 @@ describe('proxyService.loadExistingProxies', () => {
387387
loadCompletedProxy: (proxyKey: string) => Promise<void>;
388388
}).loadCompletedProxy('proxy-video-4');
389389

390-
expect(timelineServiceMocks.filmstripCache.prewarmPriorityWindow).toHaveBeenCalledWith(
390+
expect(timelineServiceMocks.filmstripCache.prewarmPriorityWindow).toHaveBeenNthCalledWith(
391+
1,
392+
'video-4',
393+
expect.objectContaining({ size: 1024 }),
394+
20,
395+
{ startTime: 0, endTime: 1 },
396+
);
397+
expect(timelineServiceMocks.filmstripCache.prewarmPriorityWindow).toHaveBeenNthCalledWith(
398+
2,
391399
'video-4',
392400
expect.objectContaining({ size: 1024 }),
393401
20,

src/features/media-library/services/proxy-service.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,9 @@ interface ProgressEmissionState {
129129

130130
const PROXY_PROGRESS_EMIT_INTERVAL_MS = 150;
131131
const PROXY_PROGRESS_EMIT_MIN_DELTA = 0.01;
132+
const PROXY_FILMSTRIP_COVER_PREWARM_SECONDS = 1;
132133
const PROXY_FILMSTRIP_PREWARM_SECONDS = 12;
134+
const PROXY_FILMSTRIP_COVER_PREWARM_DELAY_MS = 0;
133135
const PROXY_FILMSTRIP_PREWARM_DELAY_MS = 900;
134136
const PROXY_PLAYBACK_ISSUE_SCORE_THRESHOLD = 5;
135137
const PROXY_PLAYBACK_ISSUE_WEIGHTS: Record<ProxyPlaybackIssue, number> = {
@@ -735,6 +737,17 @@ class ProxyService {
735737
continue;
736738
}
737739

740+
const coverWarmEndTime = Math.min(media.duration, PROXY_FILMSTRIP_COVER_PREWARM_SECONDS);
741+
enqueueBackgroundMediaWork(() => (
742+
filmstripCache.prewarmPriorityWindow(mediaId, proxyFile, media.duration, {
743+
startTime: 0,
744+
endTime: coverWarmEndTime,
745+
})
746+
), {
747+
priority: 'warm',
748+
delayMs: PROXY_FILMSTRIP_COVER_PREWARM_DELAY_MS,
749+
});
750+
738751
const warmEndTime = Math.min(media.duration, PROXY_FILMSTRIP_PREWARM_SECONDS);
739752
enqueueBackgroundMediaWork(() => (
740753
filmstripCache.prewarmPriorityWindow(mediaId, proxyFile, media.duration, {

src/features/timeline/components/clip-filmstrip/index.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -531,7 +531,9 @@ export const ClipFilmstrip = memo(function ClipFilmstrip({
531531
const tileTime = effectiveStart + (tileCenterX / renderPixelsPerSecond) * speed;
532532
const nearestFrameIndex = Math.max(0, Math.round(tileTime));
533533
const frame = candidateFrameByIndex?.get(nearestFrameIndex)
534-
?? findClosestFrame(candidateFrames, tileTime);
534+
?? findClosestFrame(candidateFrames, tileTime)
535+
// Fall back to full frame set — always cover gaps with the closest available frame
536+
?? findClosestFrame(frames, tileTime);
535537

536538
if (frame) {
537539
result.push({ tileIndex: tile, frame, x: tileX, width: tileWidth });
@@ -553,6 +555,16 @@ export const ClipFilmstrip = memo(function ClipFilmstrip({
553555
renderWindow,
554556
]);
555557

558+
// Pick a cover frame from the middle of available frames — used as a repeating
559+
// CSS background behind tiles so gaps during zoom reconciliation show a frame
560+
// instead of black. Track the full frame for stale-URL probing.
561+
const coverFrame = useMemo(() => {
562+
if (!frames || frames.length === 0) return null;
563+
const mid = Math.floor(frames.length / 2);
564+
return frames[mid] ?? frames[0] ?? null;
565+
}, [frames]);
566+
const coverFrameUrl = coverFrame?.url ?? null;
567+
556568
if (error) {
557569
return null;
558570
}
@@ -577,6 +589,11 @@ export const ClipFilmstrip = memo(function ClipFilmstrip({
577589
)}
578590
<div
579591
className="absolute inset-0 overflow-hidden pointer-events-none"
592+
style={coverFrameUrl ? {
593+
backgroundImage: `url(${coverFrameUrl})`,
594+
backgroundRepeat: 'repeat-x',
595+
backgroundSize: `${thumbnailWidth}px ${height}px`,
596+
} : undefined}
580597
>
581598
{tiles.map(({ tileIndex, frame, x, width }) => (
582599
<FilmstripTile
@@ -591,6 +608,17 @@ export const ClipFilmstrip = memo(function ClipFilmstrip({
591608
onSourceError={handleFrameSourceError}
592609
/>
593610
))}
611+
{/* Hidden probe to detect stale cover background URL */}
612+
{coverFrame && !coverFrame.bitmap && (
613+
<img
614+
src={coverFrame.url}
615+
alt=""
616+
aria-hidden
617+
className="absolute h-px w-px opacity-0 pointer-events-none"
618+
style={{ left: 0, top: 0 }}
619+
onError={() => handleFrameSourceError(coverFrame.index)}
620+
/>
621+
)}
594622
</div>
595623
</div>
596624
);

src/features/timeline/components/clip-waveform/adaptive-render-version.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from 'vitest';
22
import {
33
getWaveformActiveTileCount,
4+
getWaveformZoomCommitPhaseMs,
45
getWaveformZoomRedrawIntervalMs,
56
} from './adaptive-render-version';
67

@@ -27,4 +28,13 @@ describe('adaptive waveform render version helpers', () => {
2728
expect(getWaveformZoomRedrawIntervalMs(8)).toBe(24);
2829
expect(getWaveformZoomRedrawIntervalMs(12)).toBe(32);
2930
});
31+
32+
it('adds a stable phase delay for heavier redraw batches', () => {
33+
expect(getWaveformZoomCommitPhaseMs(2, 'media-1')).toBe(0);
34+
expect(getWaveformZoomCommitPhaseMs(8, 'media-1')).toBe(
35+
getWaveformZoomCommitPhaseMs(8, 'media-1'),
36+
);
37+
expect(getWaveformZoomCommitPhaseMs(12, 'media-1')).toBeGreaterThanOrEqual(0);
38+
expect(getWaveformZoomCommitPhaseMs(12, 'media-1')).toBeLessThanOrEqual(72);
39+
});
3040
});

src/features/timeline/components/clip-waveform/adaptive-render-version.ts

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ interface AdaptiveWaveformRenderVersionOptions {
1212
baseVersion: string;
1313
pixelsPerSecond: number;
1414
activeTileCount: number;
15+
phaseKey?: string;
1516
}
1617

1718
function nowMs(): number {
@@ -46,10 +47,36 @@ export function getWaveformZoomRedrawIntervalMs(activeTileCount: number): number
4647
return 32;
4748
}
4849

50+
function hashPhaseKey(phaseKey: string): number {
51+
let hash = 0;
52+
for (let i = 0; i < phaseKey.length; i += 1) {
53+
hash = ((hash << 5) - hash) + phaseKey.charCodeAt(i);
54+
hash |= 0;
55+
}
56+
return Math.abs(hash);
57+
}
58+
59+
export function getWaveformZoomCommitPhaseMs(activeTileCount: number, phaseKey?: string): number {
60+
if (!phaseKey || activeTileCount <= 2) {
61+
return 0;
62+
}
63+
64+
if (activeTileCount <= 4) {
65+
return (hashPhaseKey(phaseKey) % 2) * 8;
66+
}
67+
68+
if (activeTileCount <= 8) {
69+
return (hashPhaseKey(phaseKey) % 4) * 8;
70+
}
71+
72+
return (hashPhaseKey(phaseKey) % 6) * 10;
73+
}
74+
4975
export function useAdaptiveWaveformRenderVersion({
5076
baseVersion,
5177
pixelsPerSecond,
5278
activeTileCount,
79+
phaseKey,
5380
}: AdaptiveWaveformRenderVersionOptions): string {
5481
const zoomVersion = useMemo(
5582
() => `e${Math.round(Math.max(1, pixelsPerSecond) * 1000)}`,
@@ -59,6 +86,10 @@ export function useAdaptiveWaveformRenderVersion({
5986
() => getWaveformZoomRedrawIntervalMs(activeTileCount),
6087
[activeTileCount],
6188
);
89+
const phaseDelayMs = useMemo(
90+
() => getWaveformZoomCommitPhaseMs(activeTileCount, phaseKey),
91+
[activeTileCount, phaseKey],
92+
);
6293
const [committedZoomVersion, setCommittedZoomVersion] = useState(zoomVersion);
6394
const latestZoomVersionRef = useRef(zoomVersion);
6495
const lastCommitAtRef = useRef(0);
@@ -89,7 +120,11 @@ export function useAdaptiveWaveformRenderVersion({
89120
};
90121

91122
const now = nowMs();
92-
if (lastCommitAtRef.current === 0 || now - lastCommitAtRef.current >= redrawIntervalMs) {
123+
const elapsedMs = now - lastCommitAtRef.current;
124+
if (
125+
lastCommitAtRef.current === 0
126+
|| (elapsedMs >= redrawIntervalMs && phaseDelayMs === 0)
127+
) {
93128
clearPending();
94129
commit();
95130
return;
@@ -99,17 +134,19 @@ export function useAdaptiveWaveformRenderVersion({
99134
return;
100135
}
101136

102-
const remainingMs = Math.max(0, redrawIntervalMs - (now - lastCommitAtRef.current));
137+
const remainingMs = Math.max(0, redrawIntervalMs - elapsedMs);
138+
const scheduledDelayMs = lastCommitAtRef.current === 0
139+
? phaseDelayMs
140+
: remainingMs + phaseDelayMs;
103141
timeoutRef.current = setTimeout(() => {
104142
timeoutRef.current = null;
105143
rafRef.current = requestAnimationFrame(() => {
106144
rafRef.current = null;
107145
commit();
108146
});
109-
}, remainingMs);
110-
147+
}, scheduledDelayMs);
111148
return clearPending;
112-
}, [committedZoomVersion, redrawIntervalMs, zoomVersion]);
149+
}, [committedZoomVersion, phaseDelayMs, redrawIntervalMs, zoomVersion]);
113150

114151
useEffect(() => {
115152
return () => {

src/features/timeline/components/clip-waveform/compound-clip-waveform.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export const CompoundClipWaveform = memo(function CompoundClipWaveform({
4747
const requestTokenRef = useRef(0);
4848
const pixelsPerSecondRef = useRef(pixelsPerSecond);
4949
pixelsPerSecondRef.current = pixelsPerSecond;
50+
const amplitudesBufferRef = useRef<Float32Array>(new Float32Array(0));
5051
const [height, setHeight] = useState(0);
5152
const [waveformsByMediaId, setWaveformsByMediaId] = useState<Map<string, CachedWaveform>>(new Map());
5253
const [isLoading, setIsLoading] = useState(false);
@@ -217,12 +218,17 @@ export const CompoundClipWaveform = memo(function CompoundClipWaveform({
217218
const currentPps = Math.max(1, pixelsPerSecondRef.current);
218219
const centerY = height / 2;
219220
const maxWaveHeight = Math.max(1, (height / 2) - WAVEFORM_VERTICAL_PADDING_PX);
220-
const amplitudes = new Array<number>(tileWidth + 1).fill(0);
221+
const amplitudeCount = tileWidth + 1;
222+
if (amplitudesBufferRef.current.length < amplitudeCount) {
223+
amplitudesBufferRef.current = new Float32Array(amplitudeCount);
224+
}
225+
const amplitudes = amplitudesBufferRef.current;
226+
amplitudes.fill(0, 0, amplitudeCount);
221227

222228
ctx.beginPath();
223229
ctx.moveTo(0, centerY);
224230

225-
for (let x = 0; x <= tileWidth; x += 1) {
231+
for (let x = 0; x < amplitudeCount; x += 1) {
226232
const timelinePosition = (tileOffset + x) / currentPps;
227233
const compoundTime = sourceStart + timelinePosition;
228234

@@ -273,10 +279,10 @@ export const CompoundClipWaveform = memo(function CompoundClipWaveform({
273279
amplitudes[x] = amp * maxWaveHeight;
274280
}
275281

276-
for (let x = 0; x <= tileWidth; x += 1) {
282+
for (let x = 0; x < amplitudeCount; x += 1) {
277283
ctx.lineTo(x, centerY - amplitudes[x]!);
278284
}
279-
for (let x = tileWidth; x >= 0; x -= 1) {
285+
for (let x = amplitudeCount - 1; x >= 0; x -= 1) {
280286
ctx.lineTo(x, centerY + amplitudes[x]!);
281287
}
282288
ctx.closePath();
@@ -296,6 +302,7 @@ export const CompoundClipWaveform = memo(function CompoundClipWaveform({
296302
baseVersion: `${peaks?.length ?? 0}:${height}:${waveformsByMediaId.size}`,
297303
pixelsPerSecond,
298304
activeTileCount,
305+
phaseKey: mediaIdsKey,
299306
});
300307

301308
if (hasError) {

src/features/timeline/components/clip-waveform/index.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export const ClipWaveform = memo(function ClipWaveform({
7373
const containerRef = useRef<HTMLDivElement>(null);
7474
const pixelsPerSecondRef = useRef(pixelsPerSecond);
7575
pixelsPerSecondRef.current = pixelsPerSecond;
76+
const amplitudesBufferRef = useRef<Float32Array>(new Float32Array(0));
7677
const [height, setHeight] = useState(0);
7778
const conformStartedRef = useRef(false);
7879
const { blobUrl, setBlobUrl, hasStartedLoadingRef, blobUrlVersion } = useMediaBlobUrl(mediaId);
@@ -212,12 +213,17 @@ export const ClipWaveform = memo(function ClipWaveform({
212213
const currentPps = Math.max(1, pixelsPerSecondRef.current);
213214
const centerY = height / 2;
214215
const maxWaveHeight = Math.max(1, (height / 2) - WAVEFORM_VERTICAL_PADDING_PX);
215-
const amplitudes = new Array<number>(tileWidth + 1).fill(0);
216+
const amplitudeCount = tileWidth + 1;
217+
if (amplitudesBufferRef.current.length < amplitudeCount) {
218+
amplitudesBufferRef.current = new Float32Array(amplitudeCount);
219+
}
220+
const amplitudes = amplitudesBufferRef.current;
221+
amplitudes.fill(0, 0, amplitudeCount);
216222

217223
ctx.beginPath();
218224
ctx.moveTo(0, centerY);
219225

220-
for (let x = 0; x <= tileWidth; x++) {
226+
for (let x = 0; x < amplitudeCount; x++) {
221227
const timelinePosition = (tileOffset + x) / currentPps;
222228
const sourceTime = effectiveStart + (timelinePosition * speed);
223229

@@ -274,10 +280,10 @@ export const ClipWaveform = memo(function ClipWaveform({
274280
amplitudes[x] = amp * maxWaveHeight;
275281
}
276282

277-
for (let x = 0; x <= tileWidth; x++) {
283+
for (let x = 0; x < amplitudeCount; x++) {
278284
ctx.lineTo(x, centerY - amplitudes[x]!);
279285
}
280-
for (let x = tileWidth; x >= 0; x--) {
286+
for (let x = amplitudeCount - 1; x >= 0; x--) {
281287
ctx.lineTo(x, centerY + amplitudes[x]!);
282288
}
283289
ctx.closePath();
@@ -314,6 +320,7 @@ export const ClipWaveform = memo(function ClipWaveform({
314320
baseVersion: `${loadedSamples}:${height}`,
315321
pixelsPerSecond,
316322
activeTileCount,
323+
phaseKey: mediaId,
317324
});
318325

319326
// Show empty state for unsupported/failed waveforms (no infinite skeleton).

0 commit comments

Comments
 (0)