mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-04 04:46:59 +00:00
fix(core): Safari audio frozen after its cut is pulled onto the playhead at 0.5 s behind, not 1 s (#7082)
* fix(core): Safari audio that sits frozen after its cut is pulled onto the playhead in 0.4 s, not 1 s * fix(core): shorten the frozen-audio comment * fix(core): leave a WebKit audio element that is still filling data out of the frozen rule
This commit is contained in:
@@ -1704,6 +1704,57 @@ describe("syncRuntimeMedia", () => {
|
||||
expect(clip.el.currentTime).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("pulls a WebKit audio element that sits frozen behind the playhead, well before the 1 s valve", () => {
|
||||
const userAgent = vi
|
||||
.spyOn(navigator, "userAgent", "get")
|
||||
.mockReturnValue(
|
||||
"Mozilla/5.0 AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15",
|
||||
);
|
||||
const clip = createMockClip({ start: 0, end: 100, mediaStart: 0 }, "audio");
|
||||
Object.defineProperty(clip.el, "paused", { value: false, writable: true, configurable: true });
|
||||
Object.defineProperty(clip.el, "readyState", { value: 4 });
|
||||
Object.defineProperty(clip.el, "currentTime", { value: 0, writable: true });
|
||||
for (const t of [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7]) {
|
||||
syncRuntimeMedia({ clips: [clip], timeSeconds: t, playing: true, playbackRate: 1 });
|
||||
}
|
||||
userAgent.mockRestore();
|
||||
expect(clip.el.currentTime).toBeGreaterThan(0.5);
|
||||
});
|
||||
|
||||
it("leaves a WebKit audio element alone while it is still filling data", () => {
|
||||
const userAgent = vi
|
||||
.spyOn(navigator, "userAgent", "get")
|
||||
.mockReturnValue(
|
||||
"Mozilla/5.0 AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15",
|
||||
);
|
||||
const clip = createMockClip({ start: 0, end: 100, mediaStart: 0 }, "audio");
|
||||
Object.defineProperty(clip.el, "paused", { value: false, writable: true, configurable: true });
|
||||
Object.defineProperty(clip.el, "readyState", { value: 2 });
|
||||
Object.defineProperty(clip.el, "currentTime", { value: 0, writable: true });
|
||||
for (const t of [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7]) {
|
||||
syncRuntimeMedia({ clips: [clip], timeSeconds: t, playing: true, playbackRate: 1 });
|
||||
}
|
||||
userAgent.mockRestore();
|
||||
expect(clip.el.currentTime).toBe(0);
|
||||
});
|
||||
|
||||
it("leaves a moving WebKit audio element alone at the same lag", () => {
|
||||
const userAgent = vi
|
||||
.spyOn(navigator, "userAgent", "get")
|
||||
.mockReturnValue(
|
||||
"Mozilla/5.0 AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15",
|
||||
);
|
||||
const clip = createMockClip({ start: 0, end: 100, mediaStart: 0 }, "audio");
|
||||
Object.defineProperty(clip.el, "paused", { value: false, writable: true, configurable: true });
|
||||
Object.defineProperty(clip.el, "currentTime", { value: 0, writable: true });
|
||||
for (const t of [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7]) {
|
||||
(clip.el as { currentTime: number }).currentTime = Math.max(0, t - 0.6);
|
||||
syncRuntimeMedia({ clips: [clip], timeSeconds: t, playing: true, playbackRate: 1 });
|
||||
}
|
||||
userAgent.mockRestore();
|
||||
expect(clip.el.currentTime).toBeLessThan(0.2);
|
||||
});
|
||||
|
||||
it("clears offset baseline when clip deactivates — re-entry hard-syncs", () => {
|
||||
const clip = createMockClip({ start: 0, end: 5, mediaStart: 0 });
|
||||
Object.defineProperty(clip.el, "currentTime", { value: 0, writable: true });
|
||||
|
||||
@@ -37,6 +37,17 @@ export function readElementPlaybackStart(el: Element): number {
|
||||
const HOLD_END_EVENTS = ["seeked", "loadeddata", "error", "emptied", "abort"] as const;
|
||||
export const HOLD_CAP_MS = 5000;
|
||||
const WEBKIT_STUCK_AUDIO_SECONDS = 1;
|
||||
// A playing WebKit audio element that has not moved for this long is frozen, not starting (it starts ~150 ms late).
|
||||
const WEBKIT_FROZEN_AUDIO_SECONDS = 0.4;
|
||||
const lastMoved = new WeakMap<HTMLMediaElement, { ct: number; at: number }>();
|
||||
function frozenAudioFor(el: HTMLMediaElement, ct: number, now: number): number {
|
||||
const m = lastMoved.get(el);
|
||||
if (!m || m.ct !== ct || el.paused || el.seeking || el.readyState < 3) {
|
||||
lastMoved.set(el, { ct, at: now });
|
||||
return 0;
|
||||
}
|
||||
return now - m.at;
|
||||
}
|
||||
const releaseHeldVideo = new WeakMap<HTMLMediaElement, () => void>();
|
||||
|
||||
// A seeking video still paints its previous frame, and one still fetching its first data paints none (its seek
|
||||
@@ -242,6 +253,7 @@ function clampVolume(volume: number): number {
|
||||
* element at a different file without ever leaving its active window.
|
||||
*/
|
||||
export function evictMediaSyncState(el: HTMLMediaElement): void {
|
||||
lastMoved.delete(el);
|
||||
lastOffset.delete(el);
|
||||
lastRelativeTime.delete(el);
|
||||
strictDriftSamples.delete(el);
|
||||
@@ -650,8 +662,11 @@ export function syncRuntimeMedia(params: {
|
||||
lastOffset.set(el, offset);
|
||||
const firstTickOfClip = prevOffset === undefined;
|
||||
const offsetJumped = !firstTickOfClip && Math.abs(offset - prevOffset!) > 0.5;
|
||||
const webkitAudio = el.tagName === "AUDIO" && isWebKit();
|
||||
const frozenFor = webkitAudio ? frozenAudioFor(el, currentElTime, params.timeSeconds) : 0;
|
||||
const catastrophicDrift =
|
||||
drift > (el.tagName === "AUDIO" && isWebKit() ? WEBKIT_STUCK_AUDIO_SECONDS : 3);
|
||||
drift > (webkitAudio ? WEBKIT_STUCK_AUDIO_SECONDS : 3) ||
|
||||
frozenFor >= WEBKIT_FROZEN_AUDIO_SECONDS;
|
||||
// A short audio clip can leave its native element paused just before EOF.
|
||||
// When the timeline re-enters the clip, rewind stale forward state at the
|
||||
// strict threshold; do not force cold audio forward while it buffers.
|
||||
|
||||
Reference in New Issue
Block a user