refactor(mediabunny): [PKG-MB-03] type inputs and cancel owned loads

This commit is contained in:
Harvey Zhao committed 2026-09-12 18:08:37 +08:00
1 parent 03451c029b
commit adba8a3d12
35 files changed
+11260 -3327

No files matched your search

@@ -3,7 +3,10 @@
* Coordinates audio and video playback
*/
import AudioEngine from './AudioEngine.js'
import { createInput, getHlsState, resolveDuration, selectPlaybackTracks } from './input.js'
import { getHlsState } from './hls-state'
import { createInput } from './input'
import LoadSession from './load-session'
import { resolveDuration, selectPlaybackTracks } from './tracks'
import VideoEngine from './VideoEngine.js'
export default class MediaBunnyEngine {
@@ -34,6 +37,8 @@ export default class MediaBunnyEngine {
this.loadSeq = 0
this.input = null
this.media = null
this.loadSession = null
this.destroyed = false
// Listen to ended event
events.addEventListener?.('ended', () => {
@@ -43,41 +48,50 @@ export default class MediaBunnyEngine {
}
async load(src) {
if (this.destroyed)
return
const id = ++this.loadSeq
this.loadSession?.cancel()
if (id !== this.loadSeq || this.destroyed)
return
this.pause()
if (id !== this.loadSeq || this.destroyed)
return
this.disposeInput()
const session = new LoadSession()
this.loadSession = session
this.ended = false
this.error = null
this.networkState = 2 // NETWORK_LOADING
this.readyState = 0 // HAVE_NOTHING
setTimeout(() => this.events.emit('waiting'), 0)
setTimeout(() => this.events.emit('loadstart'), 0)
session.defer(() => this.events.emit('waiting'))
session.defer(() => this.events.emit('loadstart'))
const loadTimeout = Number.isFinite(this.option.loadTimeout)
? this.option.loadTimeout
: 0
try {
await Promise.race([
this.performLoad(src, id),
loadTimeout > 0 ? this.createTimeout(loadTimeout) : new Promise(() => {}),
])
await session.wait(this.performLoad(src, id, session), loadTimeout)
}
catch (err) {
if (id !== this.loadSeq)
return
this.loadSeq++
const failed = ++this.loadSeq
session.cancel()
if (failed !== this.loadSeq || this.destroyed)
return
this.disposeInput()
this.error = { code: 4, message: err.message }
this.networkState = 3 // NETWORK_NO_SOURCE
this.events.emit('error')
}
}
async performLoad(src, id) {
if (!(await this.video.preflight(src)))
async performLoad(src, id, session = this.loadSession) {
if (!(await this.video.preflight(src, session?.signal, () => id === this.loadSeq)) || id !== this.loadSeq)
return
const input = createInput(src)
@@ -86,14 +100,10 @@ export default class MediaBunnyEngine {
this.audio.handleNoAudioTrack()
return
}
session.own(input)
const media = await selectPlaybackTracks(input, src)
if (id !== this.loadSeq) {
input.dispose()
if (id !== this.loadSeq)
return
}
this.disposeInput()
this.input = input
this.media = media
@@ -139,10 +149,8 @@ export default class MediaBunnyEngine {
if (id !== this.loadSeq)
return
this.error = { code: 4, message: err.message }
this.networkState = 3
this.events.emit('error')
console.error('MediaBunny load error:', err)
throw err
}
}
@@ -153,7 +161,7 @@ export default class MediaBunnyEngine {
}
disposeInput() {
this.input?.dispose()
this.loadSession?.disposeInput()
this.input = null
this.media = null
}
@@ -334,6 +342,11 @@ export default class MediaBunnyEngine {
}
destroy() {
if (this.destroyed)
return
this.destroyed = true
this.loadSeq++
this.loadSession?.cancel()
this.pause()
this.disposeInput()
this.audio.destroy()
@@ -5,7 +5,7 @@
import {
CanvasSink,
} from 'mediabunny'
import { isHlsSource } from './input.js'
import { preflightRange } from './preflight'
export default class VideoEngine {
constructor({
@@ -51,23 +51,8 @@ export default class VideoEngine {
this.isFetching = false
}
async preflight(url) {
if (!this.preflightRange || typeof url !== 'string' || isHlsSource(url))
return true
try {
const res = await fetch(url, { method: 'HEAD' })
const acceptRanges = res.headers.get('accept-ranges')
if (!acceptRanges || acceptRanges === 'none') {
this.events.emit('error', new Event('RangeNotSupported'))
return false
}
return true
}
catch (e) {
console.warn('Preflight check failed:', e)
return true
}
async preflight(url, signal, isCurrent) {
return preflightRange(url, this.preflightRange, this.events, signal, isCurrent)
}
drawPoster() {
@@ -0,0 +1,47 @@
import type { InputTrack } from 'mediabunny'
import type { PlaybackMedia } from './media'
async function getTrackBitrate(track: InputTrack): Promise<number> {
return await track.getAverageBitrate() ?? await track.getBitrate() ?? 0
}
export async function getHlsState(media: PlaybackMedia | null) {
if (!media?.isHls || !media.input)
return null
const [videoTracks, pairableAudioTracks] = await Promise.all([
media.input.getVideoTracks(),
media.videoTrack ? media.videoTrack.getPairableAudioTracks() : [],
])
const [levels, audios] = await Promise.all([
Promise.all(videoTracks.map(async (track, index) => ({
id: track.id,
track,
index,
name: await track.getName(),
height: await track.getDisplayHeight(),
bitrate: await getTrackBitrate(track),
}))),
Promise.all(pairableAudioTracks.map(async (track, index) => {
const name = await track.getName()
const language = await track.getLanguageCode()
return {
id: track.id,
track,
index,
name,
lang: language,
language,
bitrate: await getTrackBitrate(track),
}
})),
])
return {
levels,
audios,
currentLevel: media.videoTrack ? levels.find(item => item.id === media.videoTrack?.id) ?? null : null,
currentAudio: media.audioTrack ? audios.find(item => item.id === media.audioTrack?.id) ?? null : null,
videoMode: media.videoMode ?? 'auto',
audioMode: media.audioMode ?? 'auto',
}
}
@@ -1,120 +0,0 @@
import {
ALL_FORMATS,
BlobSource,
HLS_FORMATS,
Input,
ReadableStreamSource,
UrlSource,
} from 'mediabunny'
const M3U8_RE = /\.m3u8(?:$|[?#])/i
export function isHlsSource(src) {
return typeof src === 'string' && M3U8_RE.test(src)
}
export function normalizeSource(src) {
if (typeof src === 'string')
return new UrlSource(src)
if (src instanceof Blob)
return new BlobSource(src)
if (typeof ReadableStream !== 'undefined' && src instanceof ReadableStream) {
return new ReadableStreamSource(src)
}
return src ?? null
}
export function createInput(src) {
const source = normalizeSource(src)
if (!source)
return null
return new Input({
source,
formats: isHlsSource(src) ? HLS_FORMATS : ALL_FORMATS,
})
}
export async function resolveDuration({ input, videoTrack, audioTrack }) {
const tracks = [videoTrack, audioTrack].filter(Boolean)
const referenceTrack = videoTrack || audioTrack
const isLive = Boolean(referenceTrack && await referenceTrack.isLive())
let duration = Number.NaN
if (tracks.length > 0) {
duration = await input.getDurationFromMetadata(tracks, { skipLiveWait: true })
if (duration === null && !isLive) {
duration = await input.computeDuration(tracks, { skipLiveWait: true })
}
}
return isLive ? Number.POSITIVE_INFINITY : duration ?? Number.NaN
}
export async function selectPlaybackTracks(input, src) {
const videoTrack = await input.getPrimaryVideoTrack()
const audioTrack = videoTrack
? await videoTrack.getPrimaryPairableAudioTrack()
: await input.getPrimaryAudioTrack()
const duration = await resolveDuration({ input, videoTrack, audioTrack })
return {
input,
videoTrack,
audioTrack,
duration,
isLive: Number.isFinite(duration) ? false : Boolean((videoTrack || audioTrack) && await (videoTrack || audioTrack).isLive()),
isHls: isHlsSource(src),
videoMode: isHlsSource(src) ? 'auto' : 'manual',
audioMode: isHlsSource(src) ? 'auto' : 'manual',
}
}
async function getTrackBitrate(track) {
return await track.getAverageBitrate() ?? await track.getBitrate() ?? 0
}
export async function getHlsState(media) {
if (!media?.isHls || !media.input)
return null
const [videoTracks, pairableAudioTracks] = await Promise.all([
media.input.getVideoTracks(),
media.videoTrack
? media.videoTrack.getPairableAudioTracks()
: [],
])
const [levels, audios] = await Promise.all([
Promise.all(videoTracks.map(async (track, index) => ({
id: track.id,
track,
index,
name: await track.getName(),
height: await track.getDisplayHeight(),
bitrate: await getTrackBitrate(track),
}))),
Promise.all(pairableAudioTracks.map(async (track, index) => {
const name = await track.getName()
const language = await track.getLanguageCode()
return {
id: track.id,
track,
index,
name,
lang: language,
language,
bitrate: await getTrackBitrate(track),
}
})),
])
return {
levels,
audios,
currentLevel: media.videoTrack ? levels.find(item => item.id === media.videoTrack.id) ?? null : null,
currentAudio: media.audioTrack ? audios.find(item => item.id === media.audioTrack.id) ?? null : null,
videoMode: media.videoMode ?? 'auto',
audioMode: media.audioMode ?? 'auto',
}
}
@@ -0,0 +1,37 @@
import type { InputOptions } from 'mediabunny'
import {
ALL_FORMATS,
BlobSource,
HLS_FORMATS,
Input,
ReadableStreamSource,
UrlSource,
} from 'mediabunny'
const M3U8_RE = /\.m3u8(?:$|[?#])/i
export function isHlsSource(src: unknown): boolean {
return typeof src === 'string' && M3U8_RE.test(src)
}
export function normalizeSource(src: unknown): unknown {
if (typeof src === 'string')
return new UrlSource(src)
if (src instanceof Blob)
return new BlobSource(src)
if (typeof ReadableStream !== 'undefined' && src instanceof ReadableStream)
return new ReadableStreamSource(src)
return src ?? null
}
export function createInput(src: unknown): Input | null {
const source = normalizeSource(src)
if (!source)
return null
return new Input({
// Preserve SDK Source/SourceRef pass-through and its historical validation errors.
source: source as InputOptions['source'],
formats: isHlsSource(src) ? HLS_FORMATS : ALL_FORMATS,
})
}
@@ -0,0 +1,77 @@
import type { Input } from 'mediabunny'
export default class LoadSession {
private active = true
private input: Input | null = null
private timers = new Set<ReturnType<typeof setTimeout>>()
private deadline: ReturnType<typeof setTimeout> | undefined
private controller = typeof AbortController === 'undefined' ? undefined : new AbortController()
private resolveCancellation!: () => void
private cancellation = new Promise<void>((resolve) => {
this.resolveCancellation = resolve
})
get signal(): AbortSignal | undefined {
return this.controller?.signal
}
own(input: Input): void {
if (this.active)
this.input = input
else input.dispose()
}
defer(callback: () => void): void {
if (!this.active)
return
const timer = setTimeout(() => {
this.timers.delete(timer)
if (this.active)
callback()
}, 0)
this.timers.add(timer)
}
async wait(operation: Promise<void>, milliseconds: number): Promise<void> {
try {
await Promise.race([
operation,
this.cancellation,
milliseconds > 0 ? this.timeout(milliseconds) : new Promise<void>(() => {}),
])
}
finally {
if (this.deadline !== undefined) {
clearTimeout(this.deadline)
this.deadline = undefined
}
}
}
timeout(milliseconds: number): Promise<never> {
return new Promise((_, reject) => {
this.deadline = setTimeout(() => reject(new Error('Load timeout')), milliseconds)
})
}
disposeInput(): void {
const input = this.input
this.input = null
input?.dispose()
}
cancel(): void {
if (!this.active)
return
this.active = false
for (const timer of this.timers) clearTimeout(timer)
this.timers.clear()
if (this.deadline !== undefined) {
clearTimeout(this.deadline)
this.deadline = undefined
}
this.resolveCancellation()
this.controller?.abort()
this.disposeInput()
}
}
@@ -0,0 +1,14 @@
import type { Input, InputAudioTrack, InputVideoTrack } from 'mediabunny'
export type TrackMode = 'auto' | 'manual'
export interface PlaybackMedia {
input: Input
videoTrack: InputVideoTrack | null
audioTrack: InputAudioTrack | null
duration: number
isLive: boolean
isHls: boolean
videoMode: TrackMode
audioMode: TrackMode
}
@@ -0,0 +1,30 @@
import { isHlsSource } from './input'
interface RangeEvents {
emit: (type: string, detail?: Event) => void
}
export async function preflightRange(url: unknown, enabled: boolean, events: RangeEvents, signal?: AbortSignal, isCurrent = (): boolean => true): Promise<boolean> {
if (signal?.aborted || !isCurrent())
return false
if (!enabled || typeof url !== 'string' || isHlsSource(url))
return true
try {
const res = await fetch(url, signal ? { method: 'HEAD', signal } : { method: 'HEAD' })
if (signal?.aborted || !isCurrent())
return false
const acceptRanges = res.headers.get('accept-ranges')
if (!acceptRanges || acceptRanges === 'none') {
events.emit('error', new Event('RangeNotSupported'))
return false
}
return true
}
catch (error) {
if (signal?.aborted || !isCurrent())
return false
console.warn('Preflight check failed:', error)
return true
}
}
@@ -0,0 +1,38 @@
import type { Input, InputAudioTrack, InputVideoTrack } from 'mediabunny'
import type { PlaybackMedia } from './media'
import { isHlsSource } from './input'
export async function resolveDuration({ input, videoTrack, audioTrack }: Pick<PlaybackMedia, 'input' | 'videoTrack' | 'audioTrack'>): Promise<number> {
const tracks = [videoTrack, audioTrack].filter((track): track is InputVideoTrack | InputAudioTrack => track !== null)
const referenceTrack = videoTrack || audioTrack
const isLive = Boolean(referenceTrack && await referenceTrack.isLive())
let duration: number | null = Number.NaN
if (tracks.length > 0) {
duration = await input.getDurationFromMetadata(tracks, { skipLiveWait: true })
if (duration === null && !isLive)
duration = await input.computeDuration(tracks, { skipLiveWait: true })
}
return isLive ? Number.POSITIVE_INFINITY : duration ?? Number.NaN
}
export async function selectPlaybackTracks(input: Input, src: unknown): Promise<PlaybackMedia> {
const videoTrack = await input.getPrimaryVideoTrack()
const audioTrack = videoTrack
? await videoTrack.getPrimaryPairableAudioTrack()
: await input.getPrimaryAudioTrack()
const duration = await resolveDuration({ input, videoTrack, audioTrack })
const referenceTrack = videoTrack || audioTrack
const isHls = isHlsSource(src)
return {
input,
videoTrack,
audioTrack,
duration,
isLive: Number.isFinite(duration) ? false : Boolean(referenceTrack && await referenceTrack.isLive()),
isHls,
videoMode: isHls ? 'auto' : 'manual',
audioMode: isHls ? 'auto' : 'manual',
}
}