Skip to content

Commit f49f44f

Browse files
committed
fix(core): never pre-roll a segment whose roll-in sits at/past the media end
play() on an element at its media end rewinds to the beginning (HTMLMediaElement spec). When a clip's in-point lies at/past the end of its source, the pre-roll's currentTime assignment clamps to duration, ended stays true, and play() silently rewinds — the hidden element plays the source's HEAD and the boundary flip flashes it for ~2-3 frames while the activation seek recovers. Skip the roll for that shape (parked at the end is the element's best readiness; the active tick holds the last frame). A roll-in before the end stays safe even on an ended element: the assignment clears the ended state before play() runs.
1 parent c0a2f89 commit f49f44f

2 files changed

Lines changed: 43 additions & 1 deletion

File tree

packages/core/src/runtime/media.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1149,6 +1149,32 @@ describe("syncRuntimeMedia", () => {
11491149
expect(clip.el.play).not.toHaveBeenCalled();
11501150
});
11511151

1152+
it("skips the pre-roll when the roll-in point sits at/past the media end — play() would rewind to 0", () => {
1153+
// In-point beyond the source's real duration: the roll-in assignment
1154+
// clamps to the end, `ended` stays true, and play() would silently seek
1155+
// back to 0 — the hidden element then plays the source's HEAD and the
1156+
// boundary flip flashes it (the "frame 0 blink" at every cut of an
1157+
// over-extended clip). The element must stay parked and paused instead.
1158+
const clip = readyClip({ start: 5.2, end: 12, mediaStart: 2.2 });
1159+
Object.defineProperty(clip.el, "duration", { value: 1.96, configurable: true });
1160+
syncAt(clip, 5, { outputMuted: true });
1161+
expect(clip.el.play).not.toHaveBeenCalled();
1162+
// Falls back to the pre-seek stage (assignment clamps in a real browser;
1163+
// the mock records the raw target — the point is the park, not the roll).
1164+
expect(clip.el.currentTime).toBe(2.2);
1165+
});
1166+
1167+
it("still pre-rolls when the roll-in lands before the media end", () => {
1168+
// Safe even on an element parked at the end (`ended`): the roll-in
1169+
// assignment moves currentTime off the end first, clearing the ended
1170+
// state before play() runs.
1171+
const clip = readyClip({ start: 5.2, end: 12, mediaStart: 1.9 });
1172+
Object.defineProperty(clip.el, "duration", { value: 1.96, configurable: true });
1173+
syncAt(clip, 5, { outputMuted: true });
1174+
expect(clip.el.play).toHaveBeenCalled();
1175+
expect(clip.el.currentTime).toBeCloseTo(1.7, 5);
1176+
});
1177+
11521178
it("retries the pre-seek on a later tick when the first tick is below HAVE_METADATA", () => {
11531179
const clip = readyClip({ start: 7, end: 12, mediaStart: 30 });
11541180
setReadyState(clip.el, 0 /* HAVE_NOTHING */);

packages/core/src/runtime/media.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,21 @@ const prerolling = new WeakSet<HTMLMediaElement>();
192192
// would still reach its boundary at the wrong offset, paying the cold seek this
193193
// whole path exists to avoid. Instead we leave it unlatched and retry on the
194194
// next tick, once metadata has arrived.
195+
// HTMLMediaElement.play() on media sitting at its end seeks back to the
196+
// beginning before playing. A segment whose roll-in point lies at/past the end
197+
// of its source can't escape that state: the `currentTime` assignment below
198+
// CLAMPS to `duration`, `ended` stays true, and play() silently rewinds — the
199+
// hidden element then plays the source's HEAD, and the boundary flip flashes
200+
// it while the activation seek recovers (a visible "frame 0" blink at every
201+
// cut of a clip whose in-point outlives its media). Parked at the end IS that
202+
// element's best readiness: skip the roll and let the active tick hold the
203+
// last frame. (A roll-in BEFORE the end is safe even on an `ended` element —
204+
// the assignment clears the ended state before play() runs.)
205+
function prerollWouldRewind(el: HTMLMediaElement, prerollFrom: number): boolean {
206+
const dur = el.duration;
207+
return Number.isFinite(dur) && dur > 0 && prerollFrom >= dur - 0.05;
208+
}
209+
195210
function preseekUpcoming(el: HTMLMediaElement, mediaStart: number): void {
196211
if (upcomingPreseeked.has(el)) return;
197212
if (el.readyState < HTMLMediaElement.HAVE_METADATA) return;
@@ -540,7 +555,8 @@ export function syncRuntimeMedia(params: {
540555
el.tagName === "VIDEO" &&
541556
mutedThroughPreroll &&
542557
leadSeconds <= PREROLL_WINDOW_SECONDS &&
543-
prerollFrom >= 0
558+
prerollFrom >= 0 &&
559+
!prerollWouldRewind(el, prerollFrom)
544560
) {
545561
if (!prerolling.has(el)) {
546562
prerolling.add(el);

0 commit comments

Comments
 (0)