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
@@ -7,7 +7,7 @@ import { useTimelineSyncCallbacks } from "./useTimelineSyncCallbacks";
// Re-export public API consumed by tests and external modules.
// All of these were previously defined in this file; they now live in focused
// sub-modules but are re-exported here so existing import sites don't change.
export type { PlaybackAdapter, ClipManifestClip } from "../lib/playbackTypes";
export type { ClipManifestClip } from "../lib/playbackTypes";
export { createStaticSeekPlaybackAdapter } from "../lib/playbackAdapter";
export {
getTimelineElementSelector,
@@ -42,6 +42,7 @@ import {
setPreviewPlaybackRate,
shouldMutePreviewAudio,
} from "../lib/timelineIframeHelpers";
import { probeMediaUrl, getCachedProbe } from "../lib/mediaProbe";
// ---------------------------------------------------------------------------
// Hook
@@ -106,6 +107,31 @@ export function useTimelinePlayer() {
if (!state.timelineReady) {
setTimelineReady(true);
}
// Asynchronously enrich media elements missing sourceDuration via mediabunny.
// The probe reads file headers only — no full decode — so this is cheap.
const needsProbe = mergedElements.filter(
(el) =>
el.src &&
el.sourceDuration == null &&
["video", "audio"].includes(el.tag.toLowerCase()) &&
!getCachedProbe(el.src),
);
if (needsProbe.length > 0) {
void Promise.allSettled(
needsProbe.map(async (el) => {
const result = await probeMediaUrl(el.src!);
if (!result) return;
const current = usePlayerStore.getState().elements;
const key = el.key ?? el.id;
const idx = current.findIndex((e) => (e.key ?? e.id) === key);
if (idx === -1 || current[idx].sourceDuration != null) return;
const patched = current.slice();
patched[idx] = { ...current[idx], sourceDuration: result.duration };
setElements(patched);
}),
);
}
},
[setElements, setTimelineReady, setDuration],
);