Skip to content

Commit e18a515

Browse files
fix(studio): preserve media sourceDuration across element re-derivation
Moving a non-music clip re-derived the timeline elements into fresh objects whose sourceDuration the DOM scan hadn't loaded yet. The async probe skips srcs already in its cache, so the value was silently dropped — trimFractions then returned no window and the trimmed music waveform reset to the full source pinned at the track start. Re-apply the cached probe duration synchronously on every derivation (applyCachedSourceDurations) and extract the async probe loop into probeMissingSourceDurations to keep useTimelinePlayer within the file size limit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
1 parent 1622c53 commit e18a515

3 files changed

Lines changed: 103 additions & 48 deletions

File tree

.commitmsg.tmp

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
fix(studio): preserve media sourceDuration across element re-derivation
2+
3+
Moving a non-music clip re-derived the timeline elements into fresh
4+
objects whose sourceDuration the DOM scan hadn't loaded yet. The async
5+
probe skips srcs already in its cache, so the value was silently
6+
dropped — trimFractions then returned no window and the trimmed music
7+
waveform reset to the full source pinned at the track start.
8+
9+
Re-apply the cached probe duration synchronously on every derivation
10+
(applyCachedSourceDurations) and extract the async probe loop into
11+
probeMissingSourceDurations to keep useTimelinePlayer within the file
12+
size limit.
13+
14+
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

packages/studio/src/player/hooks/useTimelinePlayer.ts

Lines changed: 43 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,29 @@ import {
4242
shouldMutePreviewAudio,
4343
} from "../lib/timelineIframeHelpers";
4444
import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub";
45-
import { probeMediaUrl, getCachedProbe } from "../lib/mediaProbe";
45+
import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe";
4646
import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek";
4747

48+
/**
49+
* Whether the derived elements differ from the current ones in any field that
50+
* affects rendering (identity, timing, track, or source length) — used to skip
51+
* redundant store writes.
52+
*/
53+
function timelineElementsChanged(prev: TimelineElement[], next: TimelineElement[]): boolean {
54+
if (next.length !== prev.length) return true;
55+
return next.some((el, i) => {
56+
const p = prev[i];
57+
return (
58+
!p ||
59+
el.id !== p.id ||
60+
el.start !== p.start ||
61+
el.duration !== p.duration ||
62+
el.track !== p.track ||
63+
el.sourceDuration !== p.sourceDuration
64+
);
65+
});
66+
}
67+
4868
export function useTimelinePlayer() {
4969
const iframeRef = useRef<HTMLIFrameElement | null>(null);
5070
const rafRef = useRef<number>(0);
@@ -66,27 +86,19 @@ export function useTimelinePlayer() {
6686
(elements: TimelineElement[], nextDuration?: number) => {
6787
const state = usePlayerStore.getState();
6888
const resolvedDuration = nextDuration ?? state.duration;
69-
const mergedElements = mergeTimelineElementsPreservingDowngrades(
70-
state.elements,
71-
elements,
72-
state.duration,
73-
resolvedDuration,
89+
// applyCachedSourceDurations re-applies the cached probe duration: re-derived
90+
// elements (e.g. after a clip move) can arrive without sourceDuration, which
91+
// otherwise makes trimmed waveforms lose their window.
92+
const mergedElements = applyCachedSourceDurations(
93+
mergeTimelineElementsPreservingDowngrades(
94+
state.elements,
95+
elements,
96+
state.duration,
97+
resolvedDuration,
98+
),
7499
);
75100

76-
const elementsChanged =
77-
mergedElements.length !== state.elements.length ||
78-
mergedElements.some((el, i) => {
79-
const prev = state.elements[i];
80-
return (
81-
!prev ||
82-
el.id !== prev.id ||
83-
el.start !== prev.start ||
84-
el.duration !== prev.duration ||
85-
el.track !== prev.track
86-
);
87-
});
88-
89-
if (elementsChanged) {
101+
if (timelineElementsChanged(state.elements, mergedElements)) {
90102
setElements(mergedElements);
91103
}
92104
if (
@@ -100,31 +112,17 @@ export function useTimelinePlayer() {
100112
setTimelineReady(true);
101113
}
102114

103-
// Asynchronously enrich media elements missing sourceDuration via mediabunny.
104-
// The probe reads file headers only — no full decode — so this is cheap.
105-
const needsProbe = mergedElements.filter(
106-
(el) =>
107-
el.src &&
108-
el.sourceDuration == null &&
109-
["video", "audio"].includes(el.tag.toLowerCase()) &&
110-
!getCachedProbe(el.src),
111-
);
112-
if (needsProbe.length > 0) {
113-
void Promise.allSettled(
114-
needsProbe.map(async (el) => {
115-
const result = await probeMediaUrl(el.src!);
116-
if (!result) return;
117-
const key = el.key ?? el.id;
118-
usePlayerStore.setState((state) => {
119-
const idx = state.elements.findIndex((e) => (e.key ?? e.id) === key);
120-
if (idx === -1 || state.elements[idx].sourceDuration != null) return {};
121-
const patched = state.elements.slice();
122-
patched[idx] = { ...state.elements[idx], sourceDuration: result.duration };
123-
return { elements: patched };
124-
});
125-
}),
126-
);
127-
}
115+
// Asynchronously enrich media elements still missing sourceDuration
116+
// (header-only probe, cheap), applying each resolved value to the store.
117+
void probeMissingSourceDurations(mergedElements, (key, durationSeconds) => {
118+
usePlayerStore.setState((state) => {
119+
const idx = state.elements.findIndex((e) => (e.key ?? e.id) === key);
120+
if (idx === -1 || state.elements[idx].sourceDuration != null) return {};
121+
const patched = state.elements.slice();
122+
patched[idx] = { ...state.elements[idx], sourceDuration: durationSeconds };
123+
return { elements: patched };
124+
});
125+
});
128126
},
129127
[setElements, setTimelineReady, setDuration],
130128
);

packages/studio/src/player/lib/mediaProbe.ts

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export interface MediaProbeResult {
1+
interface MediaProbeResult {
22
duration: number;
33
width?: number;
44
height?: number;
@@ -61,11 +61,54 @@ async function probeOne(url: string): Promise<MediaProbeResult | null> {
6161
}
6262
}
6363

64-
export function getCachedProbe(url: string): MediaProbeResult | undefined {
64+
function getCachedProbe(url: string): MediaProbeResult | undefined {
6565
return cache.get(normalizeUrl(url));
6666
}
6767

68-
export async function probeMediaUrl(url: string): Promise<MediaProbeResult | null> {
68+
/**
69+
* Re-apply the cached probe `sourceDuration` to media elements that arrive
70+
* without it. Re-deriving the timeline (e.g. after a clip move) produces fresh
71+
* objects whose duration the DOM scan may not have, and the async probe skips
72+
* already-cached srcs — so without this, trimmed waveforms lose their window.
73+
*/
74+
export function applyCachedSourceDurations<
75+
T extends { src?: string; tag: string; sourceDuration?: number },
76+
>(elements: T[]): T[] {
77+
return elements.map((el) => {
78+
const tag = el.tag.toLowerCase();
79+
if (!el.src || el.sourceDuration != null || (tag !== "audio" && tag !== "video")) return el;
80+
const cached = getCachedProbe(el.src);
81+
return cached?.duration && cached.duration > 0
82+
? { ...el, sourceDuration: cached.duration }
83+
: el;
84+
});
85+
}
86+
87+
/**
88+
* Probe (header-only, cheap) any media elements still missing sourceDuration
89+
* after the cache pass, applying each resolved duration via `apply(key, secs)`.
90+
* Skips already-cached srcs.
91+
*/
92+
export async function probeMissingSourceDurations<
93+
T extends { src?: string; tag: string; sourceDuration?: number; key?: string; id: string },
94+
>(elements: T[], apply: (key: string, durationSeconds: number) => void): Promise<void> {
95+
const needs = elements.filter(
96+
(el) =>
97+
el.src &&
98+
el.sourceDuration == null &&
99+
["video", "audio"].includes(el.tag.toLowerCase()) &&
100+
!getCachedProbe(el.src),
101+
);
102+
if (needs.length === 0) return;
103+
await Promise.allSettled(
104+
needs.map(async (el) => {
105+
const result = await probeMediaUrl(el.src!);
106+
if (result) apply(el.key ?? el.id, result.duration);
107+
}),
108+
);
109+
}
110+
111+
async function probeMediaUrl(url: string): Promise<MediaProbeResult | null> {
69112
const key = normalizeUrl(url);
70113
const cached = cache.get(key);
71114
if (cached) return cached;

0 commit comments

Comments
 (0)