Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion jsr.json
Original file line number Diff line number Diff line change
@@ -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"]
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
8 changes: 8 additions & 0 deletions src/playback-window-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
3 changes: 2 additions & 1 deletion src/segment-fetcher.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { HttpClient } from "./http-client";
import {
isPlaybackSessionExpiryStatus,
playbackSegmentTimeoutError,
playbackSessionExpiredError,
} from "./playback-window-error";

Expand All @@ -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<number> {
Expand Down
11 changes: 10 additions & 1 deletion src/type-type-mse-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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<void> {
await this.deps.loop.fillOnce();
return runDecodePreroll(
this.video,
targetMs,
Expand Down Expand Up @@ -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"
);
}
23 changes: 23 additions & 0 deletions tests/segment-fetcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
});
93 changes: 93 additions & 0 deletions tests/type-type-mse-preroll-buffer.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> } };
runDecodePreroll: (
targetMs: number,
resume: boolean,
signal: AbortSignal,
requireFrame?: boolean,
) => Promise<void>;
};

function harness(fillOnce: () => Promise<void>) {
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<void>((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);
});
19 changes: 19 additions & 0 deletions tests/type-type-mse-quality-transition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type QualityHarness = {
playerState: { value: TypeTypeMseState; set: (state: TypeTypeMseState) => void };
deps: {
loop: { stop: () => void; start: () => void; quiesce: () => Promise<void> };
media: { requireFreshAttachment: () => void };
playback: {
seek: (
sessionId: string,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -125,6 +142,7 @@ function harness(
player.video = {
currentTime: 120,
paused: false,
webkitSupportsFullscreen: undefined,
pause: () => {
player.video.paused = true;
pause();
Expand All @@ -136,6 +154,7 @@ function harness(
};
player.deps = {
loop: { stop, start: () => undefined, quiesce },
media: { requireFreshAttachment: () => undefined },
playback: { seek },
};
player.emitter = { emit: () => undefined };
Expand Down