diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f1c31a..4df1db3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ concurrency: jobs: validate: - runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","arko","typetype"]') }} + runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","r730","typetype"]') }} timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cf88389..44b62a0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,7 +26,7 @@ permissions: jobs: publish-npm: if: github.event_name == 'push' || inputs.registry == 'npm' || inputs.registry == 'both' - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 15 environment: npm permissions: @@ -92,7 +92,7 @@ jobs: publish-jsr: if: (github.event_name == 'push' && startsWith(github.ref_name, 'v')) || inputs.registry == 'jsr' || inputs.registry == 'both' - runs-on: [self-hosted, Linux, X64, arko, typetype] + runs-on: [self-hosted, Linux, X64, r730, typetype] timeout-minutes: 15 environment: jsr permissions: diff --git a/jsr.json b/jsr.json index a0008f2..ecc3eaa 100644 --- a/jsr.json +++ b/jsr.json @@ -1,7 +1,7 @@ { "$schema": "https://jsr.io/schema/config-file.v1.json", "name": "@typetype/mse", - "version": "0.1.56", + "version": "0.1.59", "exports": "./src/index.ts", "publish": { "include": ["LICENSE", "README.md", "src/**/*.ts"] diff --git a/package.json b/package.json index 45d7edc..cc20321 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/mse", - "version": "0.1.56", + "version": "0.1.59", "description": "MSE playback engine for TypeType", "license": "MIT", "type": "module", diff --git a/src/playback-window-error.ts b/src/playback-window-error.ts index 51b10ee..f585785 100644 --- a/src/playback-window-error.ts +++ b/src/playback-window-error.ts @@ -29,6 +29,14 @@ export function playbackSessionExpiredError(): PlaybackWindowRecoveryError { return new PlaybackWindowRecoveryError("Playback session expired", "retry_fresh_session", []); } +export function playbackSegmentTimeoutError(): PlaybackWindowRecoveryError { + return new PlaybackWindowRecoveryError( + "SABR segment was not ready in time", + "retry_fresh_session", + [], + ); +} + export function isPlaybackSessionExpiryStatus(status: number): boolean { return status === 404 || status === 410; } diff --git a/src/segment-fetcher.ts b/src/segment-fetcher.ts index 4590108..0acb2fb 100644 --- a/src/segment-fetcher.ts +++ b/src/segment-fetcher.ts @@ -1,6 +1,7 @@ import type { HttpClient } from "./http-client"; import { isPlaybackSessionExpiryStatus, + playbackSegmentTimeoutError, playbackSessionExpiredError, } from "./playback-window-error"; @@ -19,7 +20,7 @@ export async function fetchSegmentBytes( const delayMs = await retryAfterMs(response); await new Promise((resolve) => setTimeout(resolve, delayMs)); } - throw new Error("Segment was not ready in time"); + throw playbackSegmentTimeoutError(); } async function retryAfterMs(response: Response): Promise { diff --git a/src/type-type-mse-player.ts b/src/type-type-mse-player.ts index 0161cde..4a4f787 100644 --- a/src/type-type-mse-player.ts +++ b/src/type-type-mse-player.ts @@ -310,6 +310,7 @@ import type { const revision = this.operation.next(); const signal = this.operation.signal; const targetMs = Math.max(0, Math.round(positionMs)); + if (isWebKitMediaElement(this.video)) this.deps.media.requireFreshAttachment(); if (!quality) { this.deps.loop.stop(); await this.deps.loop.quiesce(); @@ -457,12 +458,13 @@ import type { } /** Applies a bounded decode preroll using the player-owned media override. */ - private runDecodePreroll( + private async runDecodePreroll( targetMs: number, resumePlayback: boolean, signal: AbortSignal, requireFrame = false, ): Promise { + await this.deps.loop.fillOnce(); return runDecodePreroll( this.video, targetMs, @@ -690,3 +692,10 @@ function isAbortError(error: unknown): boolean { function asError(error: unknown): Error { return error instanceof Error ? error : new Error("SABR playback recovery failed"); } + +function isWebKitMediaElement(video: HTMLVideoElement): boolean { + return ( + typeof (video as HTMLVideoElement & { webkitSupportsFullscreen?: unknown }) + .webkitSupportsFullscreen === "boolean" + ); +} diff --git a/tests/segment-fetcher.test.ts b/tests/segment-fetcher.test.ts index 1a7e427..2e29e3e 100644 --- a/tests/segment-fetcher.test.ts +++ b/tests/segment-fetcher.test.ts @@ -46,3 +46,26 @@ test("turns an expired segment into fresh session recovery", async () => { expect(error).toBeInstanceOf(PlaybackWindowRecoveryError); expect(error).toMatchObject({ recoveryAction: "retry_fresh_session" }); }); + +test("turns a segment poll timeout into fresh session recovery", async () => { + globalThis.fetch = () => + Promise.resolve( + new Response(JSON.stringify({ retryAfterMs: 0 }), { + status: 202, + headers: { "content-type": "application/json" }, + }), + ); + + const request = fetchSegmentBytes( + new HttpClient({ endpoint: "https://example.com/api" }), + "https://example.com/segment", + 1, + ); + const error = await request.catch((reason: unknown) => reason); + + expect(error).toBeInstanceOf(PlaybackWindowRecoveryError); + expect(error).toMatchObject({ + message: "SABR segment was not ready in time", + recoveryAction: "retry_fresh_session", + }); +}); diff --git a/tests/type-type-mse-preroll-buffer.test.ts b/tests/type-type-mse-preroll-buffer.test.ts new file mode 100644 index 0000000..484683a --- /dev/null +++ b/tests/type-type-mse-preroll-buffer.test.ts @@ -0,0 +1,93 @@ +import { expect, test } from "bun:test"; +import { TransientMediaState } from "../src/transient-media-state"; +import { TypeTypeMsePlayer } from "../src/type-type-mse-player"; + +type PrerollHarness = { + video: HTMLVideoElement; + transientMediaState: TransientMediaState; + deps: { loop: { fillOnce: () => Promise } }; + runDecodePreroll: ( + targetMs: number, + resume: boolean, + signal: AbortSignal, + requireFrame?: boolean, + ) => Promise; +}; + +function harness(fillOnce: () => Promise) { + const player = Object.create(TypeTypeMsePlayer.prototype) as PrerollHarness; + const calls: string[] = []; + let time = 11.4; + const video = { + autoplay: false, + defaultPlaybackRate: 1, + playbackRate: 1, + muted: false, + paused: true, + readyState: 4, + seeking: false, + error: null, + buffered: { length: 1, start: () => 11.4, end: () => 23 }, + get currentTime() { + return time; + }, + set currentTime(value: number) { + calls.push("snap"); + time = value; + }, + play: async () => { + calls.push("decode"); + time = 13.45; + video.paused = false; + }, + pause: () => { + video.paused = true; + }, + }; + player.video = video as HTMLVideoElement; + player.transientMediaState = new TransientMediaState(player.video); + player.deps = { loop: { fillOnce } }; + return { player, calls, video }; +} + +test("fills the paused session before decoding and snapping its target", async () => { + let release = () => {}; + const fill = new Promise((resolve) => { + release = resolve; + }); + const { player, calls, video } = harness(() => fill); + const pending = player.runDecodePreroll(13_430, false, new AbortController().signal, true); + await Promise.resolve(); + expect(calls).toEqual([]); + expect(video.paused).toBe(true); + expect(video.playbackRate).toBe(1); + release(); + await pending; + expect(calls).toEqual(["decode", "snap"]); + expect(video.currentTime).toBe(13.43); + expect(video.paused).toBe(true); + expect(video.playbackRate).toBe(1); + expect(video.muted).toBe(false); +}); + +test("does not start decoding when filling the session fails", async () => { + const failure = new Error("segment unavailable"); + const { player, calls } = harness(() => Promise.reject(failure)); + await expect( + player.runDecodePreroll(13_430, false, new AbortController().signal, true), + ).rejects.toBe(failure); + expect(calls).toEqual([]); +}); + +test("does not decode a superseded seek after its fill completes", async () => { + const controller = new AbortController(); + const { player, calls, video } = harness(async () => controller.abort()); + await expect( + player.runDecodePreroll(13_430, false, controller.signal, true), + ).rejects.toMatchObject({ + name: "AbortError", + }); + expect(calls).toEqual([]); + expect(video.currentTime).toBe(11.4); + expect(video.paused).toBe(true); +}); diff --git a/tests/type-type-mse-quality-transition.test.ts b/tests/type-type-mse-quality-transition.test.ts index 80ad665..adaf10d 100644 --- a/tests/type-type-mse-quality-transition.test.ts +++ b/tests/type-type-mse-quality-transition.test.ts @@ -21,6 +21,7 @@ type QualityHarness = { playerState: { value: TypeTypeMseState; set: (state: TypeTypeMseState) => void }; deps: { loop: { stop: () => void; start: () => void; quiesce: () => Promise }; + media: { requireFreshAttachment: () => void }; playback: { seek: ( sessionId: string, @@ -77,6 +78,22 @@ test("stops the active playback loop before a timeline seek", async () => { expect(events).toEqual(["stop", "quiesce", "pause", "seek"]); }); +test("requires a fresh media attachment for WebKit timeline seeks", async () => { + let freshAttachments = 0; + const player = harness( + async () => response(), + () => undefined, + ); + player.video.webkitSupportsFullscreen = true; + player.deps.media.requireFreshAttachment = () => { + freshAttachments += 1; + }; + + await player.performSeek(120_000); + + expect(freshAttachments).toBe(1); +}); + test("aborts an obsolete quality preparation and applies only the latest selection", async () => { const requested: number[] = []; let aborted = 0; @@ -125,6 +142,7 @@ function harness( player.video = { currentTime: 120, paused: false, + webkitSupportsFullscreen: undefined, pause: () => { player.video.paused = true; pause(); @@ -136,6 +154,7 @@ function harness( }; player.deps = { loop: { stop, start: () => undefined, quiesce }, + media: { requireFreshAttachment: () => undefined }, playback: { seek }, }; player.emitter = { emit: () => undefined };