From e837e7504379e100ff3b56282577ec67bb88bec9 Mon Sep 17 00:00:00 2001 From: cashmere Date: Tue, 28 Jul 2026 19:41:14 +0200 Subject: feat: ripple-extend resize, timeline filmstrip, export hardening Yesterday's verified web session (gates green: 316 tests, tsc, build): - ripple-extend pushes right-hand chain on static-element resize - per-position video filmstrip thumbnails with zoom-adaptive density - fps-aware export bitrate, 2s keyframes, even-dim export canvas - freeze frame uses fixed 3s duration, prompt removed --- apps/web/src/services/thumbnail-cache/service.ts | 272 +++++++++++++++++++++++ 1 file changed, 272 insertions(+) create mode 100644 apps/web/src/services/thumbnail-cache/service.ts (limited to 'apps/web/src/services/thumbnail-cache/service.ts') diff --git a/apps/web/src/services/thumbnail-cache/service.ts b/apps/web/src/services/thumbnail-cache/service.ts new file mode 100644 index 00000000..7002cd9c --- /dev/null +++ b/apps/web/src/services/thumbnail-cache/service.ts @@ -0,0 +1,272 @@ +"use client"; + +import { + ALL_FORMATS, + BlobSource, + CanvasSink, + Input, + type WrappedCanvas, +} from "mediabunny"; +import { buildThumbnailTileKey } from "./tiling"; + +const MAX_TILE_ENTRIES = 400; +const MAX_SOURCE_ENTRIES = 8; +const MAX_CONCURRENT_FLUSHES = 2; +const TILE_JPEG_QUALITY = 0.7; +const CANVAS_POOL_SIZE = 2; + +interface PendingTile { + time: number; + resolve: (url: string | null) => void; +} + +interface TileSource { + width: number; + height: number; + init: Promise; + pending: PendingTile[]; + flushing: boolean; + flushScheduled: boolean; + dispose: () => void; +} + +function renderTileDataUrl({ + wrapped, +}: { + wrapped: WrappedCanvas; +}): string | null { + const canvas = wrapped.canvas; + if (canvas instanceof HTMLCanvasElement) { + return canvas.toDataURL("image/jpeg", TILE_JPEG_QUALITY); + } + return null; +} + +export class ThumbnailCache { + private tiles = new Map>(); + private sources = new Map(); + private activeFlushes = 0; + private flushQueue: Array<() => void> = []; + + getTile({ + mediaId, + file, + time, + width, + height, + }: { + mediaId: string; + file: File; + time: number; + width: number; + height: number; + }): Promise { + const key = buildThumbnailTileKey({ mediaId, time, width, height }); + const existing = this.tiles.get(key); + if (existing) { + this.tiles.delete(key); + this.tiles.set(key, existing); + return existing; + } + + const source = this.ensureSource({ mediaId, file, width, height }); + const requested = new Promise((resolve) => { + source.pending.push({ time, resolve }); + }); + this.scheduleFlush({ mediaId }); + + const tracked = requested.then((url) => { + if (url === null && this.tiles.get(key) === tracked) { + this.tiles.delete(key); + } + return url; + }); + + this.tiles.set(key, tracked); + this.evictTiles(); + return tracked; + } + + clearMedia({ mediaId }: { mediaId: string }): void { + for (const key of [...this.tiles.keys()]) { + if (key.startsWith(`${mediaId}:`)) { + this.tiles.delete(key); + } + } + const source = this.sources.get(mediaId); + if (source) { + this.sources.delete(mediaId); + this.releaseSource({ source }); + } + } + + clearAll(): void { + this.tiles.clear(); + for (const source of this.sources.values()) { + this.releaseSource({ source }); + } + this.sources.clear(); + } + + private releaseSource({ source }: { source: TileSource }): void { + const pending = source.pending.splice(0); + for (const tile of pending) { + tile.resolve(null); + } + source.dispose(); + } + + private ensureSource({ + mediaId, + file, + width, + height, + }: { + mediaId: string; + file: File; + width: number; + height: number; + }): TileSource { + const existing = this.sources.get(mediaId); + if (existing && existing.width === width && existing.height === height) { + this.sources.delete(mediaId); + this.sources.set(mediaId, existing); + return existing; + } + if (existing) { + this.sources.delete(mediaId); + this.releaseSource({ source: existing }); + } + + const input = new Input({ + source: new BlobSource(file), + formats: ALL_FORMATS, + }); + const init = input + .getPrimaryVideoTrack() + .then((track) => { + if (!track) { + return null; + } + return new CanvasSink(track, { + width, + height, + fit: "cover", + poolSize: CANVAS_POOL_SIZE, + }); + }) + .catch(() => null); + + const source: TileSource = { + width, + height, + init, + pending: [], + flushing: false, + flushScheduled: false, + dispose: () => { + void init.finally(() => input.dispose()); + }, + }; + this.sources.set(mediaId, source); + this.evictSources(); + return source; + } + + private evictTiles(): void { + while (this.tiles.size > MAX_TILE_ENTRIES) { + const oldestKey = this.tiles.keys().next().value; + if (oldestKey === undefined) { + return; + } + this.tiles.delete(oldestKey); + } + } + + private evictSources(): void { + while (this.sources.size > MAX_SOURCE_ENTRIES) { + const oldestKey = this.sources.keys().next().value; + if (oldestKey === undefined) { + return; + } + const oldest = this.sources.get(oldestKey); + this.sources.delete(oldestKey); + if (oldest) { + this.releaseSource({ source: oldest }); + } + } + } + + private scheduleFlush({ mediaId }: { mediaId: string }): void { + const source = this.sources.get(mediaId); + if (!source || source.flushScheduled) { + return; + } + source.flushScheduled = true; + queueMicrotask(() => { + source.flushScheduled = false; + void this.flush({ mediaId }); + }); + } + + private async flush({ mediaId }: { mediaId: string }): Promise { + const source = this.sources.get(mediaId); + if (!source || source.flushing || source.pending.length === 0) { + return; + } + source.flushing = true; + const batch = source.pending.splice(0).sort((a, b) => a.time - b.time); + + await this.acquireFlushSlot(); + try { + const sink = await source.init; + if (!sink) { + for (const tile of batch) { + tile.resolve(null); + } + return; + } + + const iterator = sink.canvasesAtTimestamps( + batch.map((tile) => tile.time), + ); + for (const tile of batch) { + const { value, done } = await iterator.next(); + tile.resolve( + !done && value ? renderTileDataUrl({ wrapped: value }) : null, + ); + } + } catch { + for (const tile of batch) { + tile.resolve(null); + } + } finally { + this.releaseFlushSlot(); + source.flushing = false; + if (source.pending.length > 0) { + this.scheduleFlush({ mediaId }); + } + } + } + + private async acquireFlushSlot(): Promise { + if (this.activeFlushes < MAX_CONCURRENT_FLUSHES) { + this.activeFlushes++; + return; + } + await new Promise((resolve) => { + this.flushQueue.push(resolve); + }); + this.activeFlushes++; + } + + private releaseFlushSlot(): void { + this.activeFlushes--; + const next = this.flushQueue.shift(); + if (next) { + next(); + } + } +} + +export const thumbnailCache = new ThumbnailCache(); -- cgit v1.3.1