fix(studio): enable timeline resize for all elements, improve perf and UX

Enable trim-start and trim-end for all authored timeline elements (divs,
sections, compositions) — not just video/audio/img. The deterministic-window
gate was overly restrictive since all non-implicit elements have authored
data-start/data-duration that define their timeline window.

Replace iframe reload after resize/move with direct DOM attribute patching
via patchIframeDomTiming(). This eliminates playhead-jump-to-zero, visual
blinking, and race conditions from file-watcher echoes. File persistence
runs in a serialized background queue (persistTimelineEdit + enqueueEdit)
so rapid edits don't overwrite each other.

Add mediabunny-based media probe service (mediaProbe.ts) for fast metadata
extraction from file headers. Timeline elements missing sourceDuration are
enriched asynchronously without waiting for DOM loadedmetadata events.

Tune the runtime media preloader: lower lazy threshold from 6 to 3 clips,
add 3s lookbehind window for reverse scrub, adaptive promoted-clip cap.

Deduplicate getTimelineEditCapabilities — computed once in TimelineCanvas
and passed as a prop to TimelineClip instead of recomputing per clip.

Remove dead PlaybackAdapter re-export from useTimelinePlayer — all consumers
import directly from playbackTypes.
This commit is contained in:
Miguel Ángel
2026-05-19 20:40:39 -04:00
parent 4237165517
commit 89f9ca196d
13 changed files with 460 additions and 346 deletions
@@ -0,0 +1,68 @@
import { Input, UrlSource, ALL_FORMATS } from "mediabunny";
export interface MediaProbeResult {
duration: number;
width?: number;
height?: number;
hasVideo: boolean;
hasAudio: boolean;
}
const cache = new Map<string, MediaProbeResult>();
const inflight = new Map<string, Promise<MediaProbeResult | null>>();
function normalizeUrl(url: string): string {
try {
return new URL(url, window.location.href).href;
} catch {
return url;
}
}
async function probeOne(url: string): Promise<MediaProbeResult | null> {
const input = new Input({
source: new UrlSource(url),
formats: ALL_FORMATS,
});
try {
const duration = await input.getDurationFromMetadata();
if (duration == null || !Number.isFinite(duration) || duration <= 0) return null;
const videoTrack = await input.getPrimaryVideoTrack();
const audioTracks = await input.getAudioTracks();
const result: MediaProbeResult = {
duration,
width: videoTrack?.displayWidth,
height: videoTrack?.displayHeight,
hasVideo: videoTrack != null,
hasAudio: audioTracks.length > 0,
};
return result;
} catch {
return null;
} finally {
input.dispose();
}
}
export function getCachedProbe(url: string): MediaProbeResult | undefined {
return cache.get(normalizeUrl(url));
}
export async function probeMediaUrl(url: string): Promise<MediaProbeResult | null> {
const key = normalizeUrl(url);
const cached = cache.get(key);
if (cached) return cached;
let pending = inflight.get(key);
if (pending) return pending;
pending = probeOne(key).then((result) => {
inflight.delete(key);
if (result) cache.set(key, result);
return result;
});
inflight.set(key, pending);
return pending;
}