import type { MediaContext, PlaybackSession, TransportConfig } from "../../types.js"; import { VideoRenderer } from "./video-renderer.js"; import { AudioOutput } from "./audio-output.js"; import { startDecoder, type DecoderHandles } from "./decoder.js"; import { dbg } from "../../util/debug.js"; import { makeTimeRanges } from "../../util/time-ranges.js"; /** * Fallback strategy session. * * Owns the orchestration between the libav decoder, the audio scheduler, * and the canvas renderer. Three things make this non-trivial: * * 1. **Cold-start ready gate.** When `play()` is called, we wait until the * audio scheduler has buffered enough audio (≥ 300 ms) AND the renderer * has at least one decoded video frame, before actually telling the * audio context to start. Without this gate, audio and the wall clock * race ahead of the still-warming-up software decoder, and every video * frame lands "in the past" and gets dropped. * * 2. **Pause / resume.** The audio context is suspended on pause and * resumed on play. The media-time anchor is preserved across the * suspend so the clock is continuous. * * 3. **Seek.** Pauses the audio scheduler, asks the decoder to cancel its * current pump and `av_seek_frame` to the target, resets the audio * output's media-time anchor to the seek target, flushes the renderer * queue, then re-enters the ready gate. If we were playing before the * seek, we automatically resume once the buffer fills. * * The unified player API on top of this just sees `play() / pause() / * seek(t)` — none of the buffering choreography leaks out. */ // Gate for cold-start playback. We want to start playing as soon as // there's any decoded output — the decoder will keep pumping during // playback, so more-is-better buffering only helps for fast decoders. // // For software-decode-bound content (rv40 / wmv3 / mpeg4 @ 720p+ on // single-threaded WASM), the decoder may run *slower* than realtime. // Waiting for a large audio-buffer threshold is actively wrong in that // case: it will never be reached, so the old gate would sit out its // full 10-second timeout before playing anything. An aggressive gate // ships the first frame to the screen fast, at the cost of the audio // clock racing a little ahead of video in the first few seconds — // which is the same situation we'd have been in after the timeout // anyway. // // READY_AUDIO_BUFFER_SECONDS: minimum audio queued before start. Set // low enough that a slow decoder still reaches it before the user // loses patience; 40 ms ≈ 2 cook packets or ~2 AAC packets. // READY_TIMEOUT_SECONDS: hard safety. If even 40 ms of audio can't be // produced in 3 s, give up and play whatever we have. const READY_AUDIO_BUFFER_SECONDS = 0.04; const READY_TIMEOUT_SECONDS = 3; export async function createFallbackSession( ctx: MediaContext, target: HTMLVideoElement, transport?: TransportConfig, ): Promise { // Normalize the source so URL inputs go through the libav HTTP block // reader instead of being buffered into memory. const { normalizeSource } = await import("../../util/source.js"); const source = await normalizeSource(ctx.source); const fps = ctx.videoTracks[0]?.fps ?? 30; const audio = new AudioOutput(); const renderer = new VideoRenderer(target, audio, fps); let handles: DecoderHandles; try { handles = await startDecoder({ source, filename: ctx.name ?? "input.bin", context: ctx, renderer, audio, transport, }); } catch (err) { audio.destroy(); renderer.destroy(); throw err; } // Patch the