From 832315a3601c2232ca2f41ab9808db7e868e1313 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 31 Aug 2026 14:19:48 +0200 Subject: [PATCH 01/33] ci: route workflows to R730 runners --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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..211c725 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: From b5c0253f698ebd0d95dc4802e23b7c2f0e62d640 Mon Sep 17 00:00:00 2001 From: Tax_Tux <138765817+Priveetee@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:31:09 +0200 Subject: [PATCH 02/33] chore: benchmark dev runners From 2388bd0133985923d75b84e29cc83dd28d31fdbd Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 31 Aug 2026 21:59:33 +0200 Subject: [PATCH 03/33] ci: route npm publication through R730 --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 211c725..8823611 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: [self-hosted, Linux, X64, r730, typetype] timeout-minutes: 15 environment: npm permissions: From 511a7e004f80172029231f61a37f92fbb86ae9db Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 3 Sep 2026 12:21:20 +0200 Subject: [PATCH 04/33] fix: recover stalled SABR segment fetches --- src/playback-window-error.ts | 8 ++++++++ src/segment-fetcher.ts | 3 ++- tests/segment-fetcher.test.ts | 23 +++++++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) 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/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", + }); +}); From 9483786fb0a2b1039b43ba765f5baecb32410f76 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 3 Sep 2026 12:21:36 +0200 Subject: [PATCH 05/33] chore: release mse 0.1.57 --- jsr.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/jsr.json b/jsr.json index a0008f2..4581aed 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.57", "exports": "./src/index.ts", "publish": { "include": ["LICENSE", "README.md", "src/**/*.ts"] diff --git a/package.json b/package.json index 45d7edc..5a3fbb2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/mse", - "version": "0.1.56", + "version": "0.1.57", "description": "MSE playback engine for TypeType", "license": "MIT", "type": "module", From 9d9c7d0aaee736fadda79fd39db8684c107a99f8 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 3 Sep 2026 12:23:08 +0200 Subject: [PATCH 06/33] fix: publish npm provenance from hosted runner --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8823611..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: [self-hosted, Linux, X64, r730, typetype] + runs-on: ubuntu-24.04 timeout-minutes: 15 environment: npm permissions: From 2116bbb6d041a5baa7aac91fe232bd3ec397d26f Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 5 Sep 2026 14:22:26 +0200 Subject: [PATCH 07/33] fix: fill playback buffer before paused seek decoding --- src/type-type-mse-player.ts | 3 +- tests/type-type-mse-preroll-buffer.test.ts | 93 ++++++++++++++++++++++ 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 tests/type-type-mse-preroll-buffer.test.ts diff --git a/src/type-type-mse-player.ts b/src/type-type-mse-player.ts index 0161cde..de5df73 100644 --- a/src/type-type-mse-player.ts +++ b/src/type-type-mse-player.ts @@ -457,12 +457,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, 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); +}); From 2eba2025d3759caa89a3df8e6bcc72aa3c4c6189 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 5 Sep 2026 14:24:31 +0200 Subject: [PATCH 08/33] chore: bump MSE package to 0.1.58 --- jsr.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/jsr.json b/jsr.json index 4581aed..ebd6c55 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.57", + "version": "0.1.58", "exports": "./src/index.ts", "publish": { "include": ["LICENSE", "README.md", "src/**/*.ts"] diff --git a/package.json b/package.json index 5a3fbb2..f62a671 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/mse", - "version": "0.1.57", + "version": "0.1.58", "description": "MSE playback engine for TypeType", "license": "MIT", "type": "module", From 158db51e440662d4bc5756c57c65a775f92c802d Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 5 Sep 2026 15:59:19 +0200 Subject: [PATCH 09/33] fix: isolate WebKit media seeks --- src/type-type-mse-player.ts | 8 ++++++++ .../type-type-mse-quality-transition.test.ts | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/type-type-mse-player.ts b/src/type-type-mse-player.ts index de5df73..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(); @@ -691,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/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 }; From 0b725e2cf9ec046ca24d78d15b4b86f2bc23f76c Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 5 Sep 2026 15:59:43 +0200 Subject: [PATCH 10/33] chore: bump MSE package to 0.1.59 --- jsr.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/jsr.json b/jsr.json index ebd6c55..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.58", + "version": "0.1.59", "exports": "./src/index.ts", "publish": { "include": ["LICENSE", "README.md", "src/**/*.ts"] diff --git a/package.json b/package.json index f62a671..cc20321 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/mse", - "version": "0.1.58", + "version": "0.1.59", "description": "MSE playback engine for TypeType", "license": "MIT", "type": "module", From ee4a7136299434487674557a85cf7dc708226f6b Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 7 Sep 2026 20:02:52 +0200 Subject: [PATCH 11/33] fix: recover evicted SABR segments --- src/media-buffer.ts | 27 +++++++++++++++ src/player-deps.ts | 14 +++++++- src/segment-scheduler.ts | 35 ++++++++++++++----- tests/media-buffer.test.ts | 12 +++++++ tests/segment-scheduler.test.ts | 60 +++++++++++++++++++++++++++++---- 5 files changed, 132 insertions(+), 16 deletions(-) diff --git a/src/media-buffer.ts b/src/media-buffer.ts index 8086341..44301a9 100644 --- a/src/media-buffer.ts +++ b/src/media-buffer.ts @@ -1,7 +1,14 @@ const RANGE_TOLERANCE_SECONDS = 0.05; +const TRACK_RANGE_TOLERANCE_MS = 50; const MIN_SEEK_BUFFER_MS = 250; const RANGE_ENTRY_OFFSET_SECONDS = 0.001; +export type BufferedTrackRange = { + kind: "audio" | "video"; + startMs: number; + endMs: number; +}; + export function bufferedEndAtCurrentTime( video: Pick, ): number { @@ -9,6 +16,26 @@ export function bufferedEndAtCurrentTime( return range ? Math.round(range.end * 1000) : 0; } +export function bufferedEndAtCurrentTrackRanges( + ranges: readonly BufferedTrackRange[], + currentMs: number, + hasVideo: boolean, +): number { + const kinds: BufferedTrackRange["kind"][] = hasVideo ? ["audio", "video"] : ["audio"]; + const ends = kinds.map((kind) => { + const range = ranges.find( + (candidate) => + candidate.kind === kind && + candidate.startMs <= currentMs + TRACK_RANGE_TOLERANCE_MS && + candidate.endMs > currentMs - TRACK_RANGE_TOLERANCE_MS, + ); + return range?.endMs ?? null; + }); + if (ends.some((endMs) => endMs === null)) return 0; + const completeEnds = ends.filter((endMs): endMs is number => endMs !== null); + return Math.min(...completeEnds); +} + export function seekWithinBufferedMedia( video: Pick, targetMs: number, diff --git a/src/player-deps.ts b/src/player-deps.ts index a6b78fd..11b3895 100644 --- a/src/player-deps.ts +++ b/src/player-deps.ts @@ -1,6 +1,7 @@ import { type BufferPolicy, resolveBufferPolicy } from "./buffer-policy"; import type { EventEmitter } from "./event-emitter"; import { HttpClient } from "./http-client"; +import { bufferedEndAtCurrentTrackRanges } from "./media-buffer"; import { MediaElementObserver } from "./media-element-observer"; import { MediaSourceController } from "./media-source-controller"; import { PlaybackClient } from "./playback-client"; @@ -53,6 +54,17 @@ export function createPlayerDeps(args: Args): PlayerDeps { }); const media = new MediaSourceController(args.video); const scheduler = new SegmentScheduler(http, media, args.emitter, policy.segmentPollLimit); + const currentBufferedEndMs = () => { + const elementBufferedEndMs = bufferedEndMs(args.video); + if (elementBufferedEndMs > 0) return elementBufferedEndMs; + const session = args.session(); + if (!session) return elementBufferedEndMs; + return bufferedEndAtCurrentTrackRanges( + media.bufferedRanges(), + Math.max(0, Math.round(args.video.currentTime * 1000)), + session.manifest.video !== null, + ); + }; const loop = new PlaybackLoop({ video: args.video, playback, @@ -63,7 +75,7 @@ export function createPlayerDeps(args: Args): PlayerDeps { playbackRate, session: args.session, signal: args.signal, - bufferedEndMs: () => bufferedEndMs(args.video), + bufferedEndMs: currentBufferedEndMs, error: args.loopError, }); const destroy = () => { diff --git a/src/segment-scheduler.ts b/src/segment-scheduler.ts index c359c20..d2a880b 100644 --- a/src/segment-scheduler.ts +++ b/src/segment-scheduler.ts @@ -1,18 +1,19 @@ import type { EventEmitter } from "./event-emitter"; import type { HttpClient } from "./http-client"; import type { ManifestSegment, PlaybackManifest } from "./manifest"; -import type { MediaSourceController } from "./media-source-controller"; +import type { MediaBufferedRange, MediaSourceController } from "./media-source-controller"; import { fetchSegmentBytes } from "./segment-fetcher"; import type { TrackKind } from "./types"; +const BUFFERED_RANGE_TOLERANCE_MS = 50; + export class SegmentScheduler { private readonly appended = new Set(); - private readonly appendedEndMs = new Map(); private revision = 0; constructor( private readonly http: HttpClient, - private readonly media: MediaSourceController, + private readonly media: Pick, private readonly emitter: EventEmitter, private readonly pollLimit: number, ) {} @@ -20,7 +21,6 @@ export class SegmentScheduler { reset(): void { this.revision += 1; this.appended.clear(); - this.appendedEndMs.clear(); } async appendInit(manifest: PlaybackManifest, signal?: AbortSignal): Promise { @@ -65,8 +65,7 @@ export class SegmentScheduler { .sort((left, right) => left.startMs - right.startMs); for (const segment of candidates) { const segmentEndMs = segment.startMs + segment.durationMs; - const appendedEndMs = this.appendedEndMs.get(kind); - if (appendedEndMs !== undefined && segmentEndMs <= appendedEndMs) continue; + if (this.isBuffered(kind, segment.startMs, segmentEndMs)) continue; await this.appendUrl( kind, segment.url, @@ -75,7 +74,6 @@ export class SegmentScheduler { revision, signal, ); - this.appendedEndMs.set(kind, Math.max(appendedEndMs ?? 0, segmentEndMs)); } } @@ -89,7 +87,10 @@ export class SegmentScheduler { ): Promise { this.ensureActive(revision, signal); const key = `${kind}:${url}`; - if (this.appended.has(key)) return; + if (this.appended.has(key)) { + if (durationMs <= 0 || this.isBuffered(kind, startMs, startMs + durationMs)) return; + this.appended.delete(key); + } const bytes = await fetchSegmentBytes(this.http, url, this.pollLimit, signal); this.ensureActive(revision, signal); await this.media.append(kind, bytes); @@ -98,9 +99,27 @@ export class SegmentScheduler { this.emitter.emit({ type: "segment", kind, url, startMs, durationMs }); } + private isBuffered(kind: TrackKind, startMs: number, endMs: number): boolean { + if (endMs <= startMs) return false; + return this.media.bufferedRanges().some((range) => coversSegment(range, kind, startMs, endMs)); + } + private ensureActive(revision: number, signal?: AbortSignal): void { if (revision !== this.revision || signal?.aborted) { throw new DOMException("Operation aborted", "AbortError"); } } } + +function coversSegment( + range: MediaBufferedRange, + kind: TrackKind, + startMs: number, + endMs: number, +): boolean { + return ( + range.kind === kind && + range.startMs <= startMs + BUFFERED_RANGE_TOLERANCE_MS && + range.endMs >= endMs - BUFFERED_RANGE_TOLERANCE_MS + ); +} diff --git a/tests/media-buffer.test.ts b/tests/media-buffer.test.ts index 4628c57..873d227 100644 --- a/tests/media-buffer.test.ts +++ b/tests/media-buffer.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "bun:test"; import { alignPlayheadToBufferedRange, bufferedEndAtCurrentTime, + bufferedEndAtCurrentTrackRanges, canUseBufferedMediaSeek, seekWithinBufferedMedia, } from "../src/media-buffer"; @@ -34,6 +35,17 @@ test("does not report media beyond a gap as buffered at the playhead", () => { ).toBe(0); }); +test("uses the shorter source-buffer track when the media element range is empty", () => { + const ranges = [ + { kind: "audio" as const, startMs: 0, endMs: 20_000 }, + { kind: "video" as const, startMs: 500, endMs: 18_000 }, + ]; + + expect(bufferedEndAtCurrentTrackRanges(ranges, 1_000, true)).toBe(18_000); + expect(bufferedEndAtCurrentTrackRanges(ranges, 19_000, true)).toBe(0); + expect(bufferedEndAtCurrentTrackRanges(ranges, 1_000, false)).toBe(20_000); +}); + test("seeks locally only when the target has enough buffered media", () => { const video = media( [ diff --git a/tests/segment-scheduler.test.ts b/tests/segment-scheduler.test.ts index 864da2d..4a9cff4 100644 --- a/tests/segment-scheduler.test.ts +++ b/tests/segment-scheduler.test.ts @@ -2,7 +2,7 @@ import { expect, test } from "bun:test"; import type { EventEmitter } from "../src/event-emitter"; import type { HttpClient } from "../src/http-client"; import type { PlaybackManifest } from "../src/manifest"; -import type { MediaSourceController } from "../src/media-source-controller"; +import type { MediaBufferedRange, MediaSourceController } from "../src/media-source-controller"; import { SegmentScheduler } from "../src/segment-scheduler"; const track = { @@ -23,6 +23,7 @@ function manifest(segments: PlaybackManifest["audio"]["segments"]): PlaybackMani test("never appends a late segment behind the buffered track edge", async () => { const appended: string[] = []; + const buffered: MediaBufferedRange[] = []; let requestedUrl = ""; const http = { response: async (url: string) => { @@ -30,15 +31,28 @@ test("never appends a late segment behind the buffered track edge", async () => return new Response(new Uint8Array([1])); }, } as HttpClient; - const media = { - append: async () => appended.push(requestedUrl), - } as MediaSourceController; - const scheduler = new SegmentScheduler(http, media, { emit: () => undefined } as EventEmitter, 1); const segment40 = { url: "/40", startMs: 389_398, durationMs: 9_985 }; const segment41 = { url: "/41", startMs: 399_383, durationMs: 9_985 }; const segment42 = { url: "/42", startMs: 409_367, durationMs: 9_985 }; const segment43 = { url: "/43", startMs: 419_352, durationMs: 9_985 }; - + const segmentsByUrl = new Map( + [segment40, segment41, segment42, segment43].map((segment) => [segment.url, segment]), + ); + const media = { + append: async (kind: "audio" | "video") => { + appended.push(requestedUrl); + const segment = segmentsByUrl.get(requestedUrl); + if (segment) { + buffered.push({ + kind, + startMs: segment.startMs, + endMs: segment.startMs + segment.durationMs, + }); + } + }, + bufferedRanges: () => buffered, + } as MediaSourceController; + const scheduler = new SegmentScheduler(http, media, { emit: () => undefined } as EventEmitter, 1); await scheduler.fill(manifest([segment43, segment41, segment42]), 401_200, 430_000); await scheduler.fill(manifest([segment40, segment41, segment42, segment43]), 399_383, 430_000); @@ -56,8 +70,14 @@ test("appends a self-initializing live segment only once", async () => { }, } as HttpClient; const appended: string[] = []; + const buffered: MediaBufferedRange[] = []; const media = { - append: async () => appended.push(requestedUrl), + append: async (kind: "audio" | "video") => { + appended.push(requestedUrl); + const startMs = requestedUrl === "/40" ? 390_000 : 400_000; + buffered.push({ kind, startMs, endMs: startMs + 10_000 }); + }, + bufferedRanges: () => buffered, } as MediaSourceController; const scheduler = new SegmentScheduler(http, media, { emit: () => undefined } as EventEmitter, 1); const liveManifest = manifest([ @@ -84,6 +104,7 @@ test("rejects fetched bytes from a superseded scheduler revision", async () => { append: async () => { appends += 1; }, + bufferedRanges: () => [], } as MediaSourceController; const scheduler = new SegmentScheduler(http, media, { emit: () => undefined } as EventEmitter, 1); const fill = scheduler.fill( @@ -99,3 +120,28 @@ test("rejects fetched bytes from a superseded scheduler revision", async () => { await expect(fill).rejects.toHaveProperty("name", "AbortError"); expect(appends).toBe(0); }); + +test("re-fetches a segment after its SourceBuffer range was evicted", async () => { + const requested: string[] = []; + let buffered: MediaBufferedRange[] = []; + const http = { + response: async (url: string) => { + requested.push(url); + return new Response(new Uint8Array([1])); + }, + } as HttpClient; + const media = { + append: async () => { + buffered = [{ kind: "audio", startMs: 0, endMs: 10_000 }]; + }, + bufferedRanges: () => buffered, + } as MediaSourceController; + const scheduler = new SegmentScheduler(http, media, { emit: () => undefined } as EventEmitter, 1); + const segment = { url: "/segment", startMs: 0, durationMs: 10_000 }; + + await scheduler.fill(manifest([segment]), 0, 10_000); + buffered = []; + await scheduler.fill(manifest([segment]), 0, 10_000); + + expect(requested).toEqual(["/segment", "/segment"]); +}); From 26a57fba91500292cde3ffa3e7434f8ac30c4446 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 7 Sep 2026 20:04:20 +0200 Subject: [PATCH 12/33] chore: bump MSE package to 0.1.60 --- jsr.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/jsr.json b/jsr.json index ecc3eaa..bd72115 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.59", + "version": "0.1.60", "exports": "./src/index.ts", "publish": { "include": ["LICENSE", "README.md", "src/**/*.ts"] diff --git a/package.json b/package.json index cc20321..1e00167 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/mse", - "version": "0.1.59", + "version": "0.1.60", "description": "MSE playback engine for TypeType", "license": "MIT", "type": "module", From d1be046b050aaf9253782da1f8ea6d6f6b5b104b Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 9 Sep 2026 10:08:38 +0200 Subject: [PATCH 13/33] chore: update player lint dependency --- biome.json | 2 +- bun.lock | 20 ++++++++++---------- package.json | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/biome.json b/biome.json index 5540498..78b06b9 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.5.10/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.12/schema.json", "formatter": { "enabled": true, "indentStyle": "space", diff --git a/bun.lock b/bun.lock index 4587969..f1b5b4e 100644 --- a/bun.lock +++ b/bun.lock @@ -5,29 +5,29 @@ "": { "name": "@typetype/mse", "devDependencies": { - "@biomejs/biome": "^2.5.10", + "@biomejs/biome": "^2.5.12", "typescript": "~7.0.2", }, }, }, "packages": { - "@biomejs/biome": ["@biomejs/biome@2.5.10", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.10", "@biomejs/cli-darwin-x64": "2.5.10", "@biomejs/cli-linux-arm64": "2.5.10", "@biomejs/cli-linux-arm64-musl": "2.5.10", "@biomejs/cli-linux-x64": "2.5.10", "@biomejs/cli-linux-x64-musl": "2.5.10", "@biomejs/cli-win32-arm64": "2.5.10", "@biomejs/cli-win32-x64": "2.5.10" }, "bin": { "biome": "bin/biome" } }, "sha512-WRKXARA3kTuiV5sxqTpobJ/I0MVd4vk3pOL6wnp5az4LntFIhWTj1RWZq3DI9PCEN3lXcqy7p5aqUHzvq8AXyQ=="], + "@biomejs/biome": ["@biomejs/biome@2.5.12", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.12", "@biomejs/cli-darwin-x64": "2.5.12", "@biomejs/cli-linux-arm64": "2.5.12", "@biomejs/cli-linux-arm64-musl": "2.5.12", "@biomejs/cli-linux-x64": "2.5.12", "@biomejs/cli-linux-x64-musl": "2.5.12", "@biomejs/cli-win32-arm64": "2.5.12", "@biomejs/cli-win32-x64": "2.5.12" }, "bin": { "biome": "bin/biome" } }, "sha512-Lw4VHZRebrReBBnlHa12JQjnIBm3JJAA55PDB9LbBVBF0q4RYphm6KfmIjqtPhf61MxZ5Q9KoK8R8x+7per5Aw=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ItCrxKK6SXVT6flYs0qIuBd4AA3TTTl4d66Re6YI2FuGZnN85NmuYNzkiTJUyYw8qBLv69L5zTUB6uyWd++h3Q=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lCRY1rwgNeWNgTr4DI/u6ZwXTRwRLHAvbaio1YLLGS+4r1nhvB2ssyPqIpfUSmRveNfv0fn/N58C7CAdK2XVrg=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-yLsPU9pAmtChXDu8vhKAzErqe+LeeYuwuUB2FZMkRitsmdodxsYRa9KHrFispsUHzzOu+9HB3nP/TQxyia+Sjw=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-vhPgwnh+6tN3ArdAXuET99xaNbFt7CG82Bqn+omHVLC5xdVx45JsYjGPmUIGNzjDek5XdNCP1HKksK7fn8+3bQ=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-VG8uQW/86a1roLaIFvtIbEigxIdzdJ190oGyg1tV7VYeQtOS+x10sflk7WbuXgw91EtZX5DlIIIej1YqkNLlcg=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-2gp8aVwXYKdAtmBfRFCUuyDMcfN1ahHqUkGfLYrZlNRFmryMATLVvJgWKvyA8wu4Rwn5OSxM1UcUmOuOFNGeBQ=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-t1QAKZwQJRB4dvgJSgFiQ4BNfNPChg69BNonz854qLVxnjT3UvDzQg9mbkTJRu35ZqU0Rw10A73J8Urgbg2RPw=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-couHYjFLL5uuI8ne6zhT7KwEsXo5YP7ry/2xmEqah7qanu0YmfDi3mwJg47YXSuv/NpZj22CZzcRH/5c4gjPSQ=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-4O6T0eq2heoHZN0a9UX+rWQoxXEBaKf+lRi2hbsGlHneUz9BWXM76nEWMK7Eeq8gzMxR1khQB6BFpAASpeXqGg=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-SnvOs3TSTiuia4SQOUNe1aWC9RT4+YkjcKnOhL/nsKOV0k5ycgBkDzF0lUxKn1V7Q8CLTRq6iV23ZAivHomRoA=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-pgDDqp9JybHm2I0KRgzN6i4+lt8xu4iqxUwLzglUMmOmyRTU1AYBGKzh9sNMOtIjah7xoWvKHlLVetvyifzoiQ=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-8A0oDW58/w9f/PQNYuq0sGUZtGtGrkNF4Z6n0PUoXpLCshi85vtKTv1XSznQawhdE4MXJ8ufpzHXyLFe87M/+w=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-pxAbxduPO4xq/Cvgaa2lOrs9BB0hEXmmDqfMNP4ZOffGOkUrD1/QGw9UAMpFQpX2P8MqTIIRuQKcmetum4Oa6A=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-b9vtoZFsuZt1pdjNwJvXl0f+BpayRzV008uS2+JpmwIKdSE2qdu4A/l04FESwLoou5g2E/Qlec0xwJydZplH+A=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.10", "", { "os": "win32", "cpu": "x64" }, "sha512-M+2dgBsl3lXRiTfgPVc2p3anS4Tocojke4rzFLScZ2Y/wmF+36dRb1iHCLiyGqOzQGyTplZH1HnEYviiAqi3nA=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.12", "", { "os": "win32", "cpu": "x64" }, "sha512-B1R/l+CwEpKFSuqiwePzPNRk1EiJN8kc0UhdafNz6MZN9v5OFP9HYP1irptvWzHrwVI4blVNGMbxc5zt70m3IA=="], "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], diff --git a/package.json b/package.json index 1e00167..3e63fcb 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "publish:jsr": "deno publish" }, "devDependencies": { - "@biomejs/biome": "^2.5.10", + "@biomejs/biome": "^2.5.12", "typescript": "~7.0.2" }, "publishConfig": { From f6555685844ea8a82472ed388bf07258f8da609b Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 18 Sep 2026 10:45:34 +0200 Subject: [PATCH 14/33] docs: link AI transparency statement --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 09a5496..b0f11d2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +U want to know the current position of TypeType about AI ? Go check [this](https://github.com/TypeType-Video/TypeType/blob/dev/AI_TRANSPARENCY.md). +
TypeType

TypeType Player

From da493ab3e985a032d75b3de77d95b01d2414aadd Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 18 Sep 2026 10:57:08 +0200 Subject: [PATCH 15/33] docs: move AI transparency notice --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b0f11d2..bcef76b 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -U want to know the current position of TypeType about AI ? Go check [this](https://github.com/TypeType-Video/TypeType/blob/dev/AI_TRANSPARENCY.md). -
TypeType

TypeType Player

The browser MSE and SABR playback engine for TypeType.

+You want to know the current position of TypeType about AI ? Go check [this](https://github.com/TypeType-Video/TypeType/blob/dev/AI_TRANSPARENCY.md). + TypeType-Player is the TypeScript package that turns TypeType playback sessions into audio and video on an `HTMLVideoElement`. It owns the Media Source Extensions pipeline and leaves controls and page layout to the consuming application. The TypeType web client uses this package through [TypeType-Frontend](https://github.com/TypeType-Video/TypeType-Frontend). The playback-session API is provided by [TypeType-Server](https://github.com/TypeType-Video/TypeType-Server). From 15de0883e89fb1d51f0573664641799a18038fae Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 19 Sep 2026 10:18:46 +0200 Subject: [PATCH 16/33] ci: route workflows to CI stack --- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4df1db3..88838db 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","r730","typetype"]') }} + runs-on: [self-hosted, Linux, X64, ci-stack] timeout-minutes: 10 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 @@ -40,7 +40,7 @@ jobs: dependency-review: if: github.event_name == 'pull_request' - runs-on: ubuntu-24.04 + runs-on: [self-hosted, Linux, X64, ci-stack] timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 44b62a0..47ce96d 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-24.04 + runs-on: [self-hosted, Linux, X64, ci-stack] 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, r730, typetype] + runs-on: [self-hosted, Linux, X64, ci-stack] timeout-minutes: 15 environment: jsr permissions: From 0d20e47f36cd5967da97ab4451ea6aa914f6dafe Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 19 Sep 2026 10:21:39 +0200 Subject: [PATCH 17/33] ci: install node for player validation --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88838db..591797a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,9 @@ jobs: - uses: denoland/setup-deno@22d081ff2d3a40755e97629de92e3bcbfa7cf2ed with: deno-version: 2.9.2 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version: 24.18.0 - run: bun install --frozen-lockfile - run: bun audit --audit-level=high - run: bun run check From d7aee18456545d75f2e30dfcd06653334cfd7546 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 21 Sep 2026 15:39:25 +0000 Subject: [PATCH 18/33] fix: preserve expanded MSE duration --- src/media-source-timing.ts | 7 +++++++ tests/media-source-controller.test.ts | 16 ++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/media-source-timing.ts b/src/media-source-timing.ts index d63c0b3..67186b1 100644 --- a/src/media-source-timing.ts +++ b/src/media-source-timing.ts @@ -22,6 +22,13 @@ export class MediaSourceTiming { } private setDuration(mediaSource: MediaSource, duration: number): void { + if ( + Number.isFinite(duration) && + Number.isFinite(mediaSource.duration) && + mediaSource.duration > duration + TIMING_TOLERANCE_SECONDS + ) { + return; + } if (sameNumber(mediaSource.duration, duration)) return; mediaSource.duration = duration; } diff --git a/tests/media-source-controller.test.ts b/tests/media-source-controller.test.ts index 8c33bcd..b1796b2 100644 --- a/tests/media-source-controller.test.ts +++ b/tests/media-source-controller.test.ts @@ -225,6 +225,22 @@ test("updates the MSE live seekable range as the live head advances", () => { expect(mediaSource.durationWrites).toBe(2); }); +test("does not shorten finite duration after buffered media extends it", () => { + const mediaSource = new FakeMediaSource(); + const { video } = videoElement("blob:finite"); + const controller = new MediaSourceController(video); + const state = controller as unknown as ControllerState; + state.objectUrl = "blob:finite"; + state.mediaSource = mediaSource as unknown as MediaSource; + mediaSource.duration = 120.294; + const writes = mediaSource.durationWrites; + + controller.updateTiming(manifest(true)); + + expect(mediaSource.duration).toBe(120.294); + expect(mediaSource.durationWrites).toBe(writes); +}); + class FakeMediaSource { readonly sourceBuffers: SourceBuffer[] = []; readonly removed: SourceBuffer[] = []; From 0dfe5530fc357e13b022b84c6a7bfa038ad604f1 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 21 Sep 2026 15:42:01 +0000 Subject: [PATCH 19/33] chore: bump MSE package to 0.1.61 --- jsr.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/jsr.json b/jsr.json index bd72115..a232f0b 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.60", + "version": "0.1.61", "exports": "./src/index.ts", "publish": { "include": ["LICENSE", "README.md", "src/**/*.ts"] diff --git a/package.json b/package.json index 3e63fcb..03260ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/mse", - "version": "0.1.60", + "version": "0.1.61", "description": "MSE playback engine for TypeType", "license": "MIT", "type": "module", From 4dd30e8e6b8d444cb647c7c5cead4d6f4400e8d7 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 21 Sep 2026 15:44:31 +0000 Subject: [PATCH 20/33] fix: publish npm from self-hosted CI --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 47ce96d..76172ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,7 +88,7 @@ jobs: if-no-files-found: error retention-days: 30 - if: steps.npm-version.outputs.exists != 'true' - run: npm publish "${{ steps.pack.outputs.filename }}" --access public --provenance + run: npm publish "${{ steps.pack.outputs.filename }}" --access public publish-jsr: if: (github.event_name == 'push' && startsWith(github.ref_name, 'v')) || inputs.registry == 'jsr' || inputs.registry == 'both' From 8003345a015e7fccb90472997f36b7b05ab804ae Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 21 Sep 2026 15:45:54 +0000 Subject: [PATCH 21/33] fix: disable npm provenance on self-hosted CI --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 76172ca..9d8bb29 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -88,7 +88,7 @@ jobs: if-no-files-found: error retention-days: 30 - if: steps.npm-version.outputs.exists != 'true' - run: npm publish "${{ steps.pack.outputs.filename }}" --access public + run: npm publish "${{ steps.pack.outputs.filename }}" --access public --provenance=false publish-jsr: if: (github.event_name == 'push' && startsWith(github.ref_name, 'v')) || inputs.registry == 'jsr' || inputs.registry == 'both' From 15cf8033faf1c8ec90d8b66c897269a5ef166498 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 23 Sep 2026 22:31:15 +0000 Subject: [PATCH 22/33] feat: add shared live playback policy --- src/index.ts | 2 + src/playback-policy.ts | 77 +++++++++++++++++++++++++++++++++++ tests/playback-policy.test.ts | 45 ++++++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 src/playback-policy.ts create mode 100644 tests/playback-policy.test.ts diff --git a/src/index.ts b/src/index.ts index 7f8602d..ec082e0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,8 @@ export type { ManifestTrack, PlaybackManifest, } from "./manifest"; +export type { PlaybackPolicy, PlaybackPolicyInput, PlaybackRetryPolicy } from "./playback-policy"; +export { bufferSeconds, resolvePlaybackPolicy } from "./playback-policy"; export { isMseTypeSupported } from "./media-source-runtime"; export type { PlaybackBufferedRange, diff --git a/src/playback-policy.ts b/src/playback-policy.ts new file mode 100644 index 0000000..6fb95c2 --- /dev/null +++ b/src/playback-policy.ts @@ -0,0 +1,77 @@ +export type PlaybackRetryPolicy = { + manifestAttempts: number; + mediaAttempts: number; + retryIntervalMs: number; +}; + +export type PlaybackPolicy = PlaybackRetryPolicy & { + startupBufferMs: number; + steadyBufferMs: number; + maxBufferMs: number; + backBufferMs: number; + manifestRefreshMs: number; + liveTargetLatencyMs: number; + liveMaxLatencyMs: number; + liveCatchupMinRate: number; + liveCatchupMaxRate: number; +}; + +export type PlaybackPolicyInput = Partial; + +const DEFAULT_POLICY: PlaybackPolicy = { + startupBufferMs: 2_000, + steadyBufferMs: 24_000, + maxBufferMs: 50_000, + backBufferMs: 30_000, + manifestRefreshMs: 1_000, + manifestAttempts: 5, + mediaAttempts: 3, + retryIntervalMs: 500, + liveTargetLatencyMs: 2_000, + liveMaxLatencyMs: 15_000, + liveCatchupMinRate: 1, + liveCatchupMaxRate: 1.25, +}; + +export function resolvePlaybackPolicy(input: PlaybackPolicyInput = {}): PlaybackPolicy { + const policy = { + startupBufferMs: positiveMs(input.startupBufferMs, DEFAULT_POLICY.startupBufferMs), + steadyBufferMs: positiveMs(input.steadyBufferMs, DEFAULT_POLICY.steadyBufferMs), + maxBufferMs: positiveMs(input.maxBufferMs, DEFAULT_POLICY.maxBufferMs), + backBufferMs: positiveMs(input.backBufferMs, DEFAULT_POLICY.backBufferMs), + manifestRefreshMs: positiveMs(input.manifestRefreshMs, DEFAULT_POLICY.manifestRefreshMs), + manifestAttempts: positiveInteger(input.manifestAttempts, DEFAULT_POLICY.manifestAttempts), + mediaAttempts: positiveInteger(input.mediaAttempts, DEFAULT_POLICY.mediaAttempts), + retryIntervalMs: positiveMs(input.retryIntervalMs, DEFAULT_POLICY.retryIntervalMs), + liveTargetLatencyMs: positiveMs( + input.liveTargetLatencyMs, + DEFAULT_POLICY.liveTargetLatencyMs, + ), + liveMaxLatencyMs: positiveMs(input.liveMaxLatencyMs, DEFAULT_POLICY.liveMaxLatencyMs), + liveCatchupMinRate: playbackRate(input.liveCatchupMinRate, DEFAULT_POLICY.liveCatchupMinRate), + liveCatchupMaxRate: playbackRate(input.liveCatchupMaxRate, DEFAULT_POLICY.liveCatchupMaxRate), + }; + + policy.steadyBufferMs = Math.min(policy.steadyBufferMs, policy.maxBufferMs); + policy.liveTargetLatencyMs = Math.min(policy.liveTargetLatencyMs, policy.liveMaxLatencyMs); + policy.liveCatchupMaxRate = Math.max(policy.liveCatchupMaxRate, policy.liveCatchupMinRate); + return policy; +} + +export function bufferSeconds(valueMs: number): number { + return valueMs / 1_000; +} + +function positiveMs(value: number | undefined, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) && value > 0 + ? Math.round(value) + : fallback; +} + +function positiveInteger(value: number | undefined, fallback: number): number { + return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : fallback; +} + +function playbackRate(value: number | undefined, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) && value >= 1 ? value : fallback; +} diff --git a/tests/playback-policy.test.ts b/tests/playback-policy.test.ts new file mode 100644 index 0000000..6f2b5da --- /dev/null +++ b/tests/playback-policy.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { bufferSeconds, resolvePlaybackPolicy } from "../src/playback-policy"; + +describe("playback policy", () => { + test("uses low-latency defaults from Xtra and the existing web player", () => { + const policy = resolvePlaybackPolicy(); + + expect(policy).toEqual({ + startupBufferMs: 2_000, + steadyBufferMs: 24_000, + maxBufferMs: 50_000, + backBufferMs: 30_000, + manifestRefreshMs: 1_000, + manifestAttempts: 5, + mediaAttempts: 3, + retryIntervalMs: 500, + liveTargetLatencyMs: 2_000, + liveMaxLatencyMs: 15_000, + liveCatchupMinRate: 1, + liveCatchupMaxRate: 1.25, + }); + expect(bufferSeconds(policy.startupBufferMs)).toBe(2); + }); + + test("clamps dependent buffer and live-catch-up values", () => { + const policy = resolvePlaybackPolicy({ + steadyBufferMs: 60_000, + maxBufferMs: 30_000, + liveTargetLatencyMs: 20_000, + liveMaxLatencyMs: 10_000, + liveCatchupMaxRate: 1, + liveCatchupMinRate: 1.25, + }); + + expect(policy.steadyBufferMs).toBe(30_000); + expect(policy.liveTargetLatencyMs).toBe(10_000); + expect(policy.liveCatchupMaxRate).toBe(1.25); + }); + + test("rejects invalid timing and attempts", () => { + expect(resolvePlaybackPolicy({ startupBufferMs: 0 }).startupBufferMs).toBe(2_000); + expect(resolvePlaybackPolicy({ manifestAttempts: 1.5 }).manifestAttempts).toBe(5); + expect(resolvePlaybackPolicy({ mediaAttempts: -1 }).mediaAttempts).toBe(3); + }); +}); From ffff2e46f730ecd1f295f68647de5261ecca79ff Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 23 Sep 2026 22:31:15 +0000 Subject: [PATCH 23/33] chore: bump MSE package to 0.1.62 --- jsr.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/jsr.json b/jsr.json index a232f0b..351f1c7 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.61", + "version": "0.1.62", "exports": "./src/index.ts", "publish": { "include": ["LICENSE", "README.md", "src/**/*.ts"] diff --git a/package.json b/package.json index 03260ef..0687547 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/mse", - "version": "0.1.61", + "version": "0.1.62", "description": "MSE playback engine for TypeType", "license": "MIT", "type": "module", From 8990f61ca9b7ee10ce8f01b9a65ef5702cb2d485 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 23 Sep 2026 22:33:00 +0000 Subject: [PATCH 24/33] chore: bump MSE package to 0.1.63 --- jsr.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/jsr.json b/jsr.json index 351f1c7..15d7e0d 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.62", + "version": "0.1.63", "exports": "./src/index.ts", "publish": { "include": ["LICENSE", "README.md", "src/**/*.ts"] diff --git a/package.json b/package.json index 0687547..341ae31 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/mse", - "version": "0.1.62", + "version": "0.1.63", "description": "MSE playback engine for TypeType", "license": "MIT", "type": "module", From 3df261d72ca7860bef19a611c4f00c80c8fcf916 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 23 Sep 2026 22:34:46 +0000 Subject: [PATCH 25/33] style: fix playback policy formatting --- src/index.ts | 2 +- src/playback-policy.ts | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index ec082e0..20eddf4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,9 +5,9 @@ export type { ManifestTrack, PlaybackManifest, } from "./manifest"; +export { isMseTypeSupported } from "./media-source-runtime"; export type { PlaybackPolicy, PlaybackPolicyInput, PlaybackRetryPolicy } from "./playback-policy"; export { bufferSeconds, resolvePlaybackPolicy } from "./playback-policy"; -export { isMseTypeSupported } from "./media-source-runtime"; export type { PlaybackBufferedRange, PlaybackWindow, diff --git a/src/playback-policy.ts b/src/playback-policy.ts index 6fb95c2..06464a5 100644 --- a/src/playback-policy.ts +++ b/src/playback-policy.ts @@ -43,10 +43,7 @@ export function resolvePlaybackPolicy(input: PlaybackPolicyInput = {}): Playback manifestAttempts: positiveInteger(input.manifestAttempts, DEFAULT_POLICY.manifestAttempts), mediaAttempts: positiveInteger(input.mediaAttempts, DEFAULT_POLICY.mediaAttempts), retryIntervalMs: positiveMs(input.retryIntervalMs, DEFAULT_POLICY.retryIntervalMs), - liveTargetLatencyMs: positiveMs( - input.liveTargetLatencyMs, - DEFAULT_POLICY.liveTargetLatencyMs, - ), + liveTargetLatencyMs: positiveMs(input.liveTargetLatencyMs, DEFAULT_POLICY.liveTargetLatencyMs), liveMaxLatencyMs: positiveMs(input.liveMaxLatencyMs, DEFAULT_POLICY.liveMaxLatencyMs), liveCatchupMinRate: playbackRate(input.liveCatchupMinRate, DEFAULT_POLICY.liveCatchupMinRate), liveCatchupMaxRate: playbackRate(input.liveCatchupMaxRate, DEFAULT_POLICY.liveCatchupMaxRate), From a71dab06b9e2fde7c418ad40ed7b9a81bfb2fff3 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 23 Sep 2026 22:34:56 +0000 Subject: [PATCH 26/33] chore: bump MSE package to 0.1.64 --- jsr.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/jsr.json b/jsr.json index 15d7e0d..3ff0cdc 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.63", + "version": "0.1.64", "exports": "./src/index.ts", "publish": { "include": ["LICENSE", "README.md", "src/**/*.ts"] diff --git a/package.json b/package.json index 341ae31..199191e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/mse", - "version": "0.1.63", + "version": "0.1.64", "description": "MSE playback engine for TypeType", "license": "MIT", "type": "module", From 08abddd16251ea01e5b5118813c2f5582df87275 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 23 Sep 2026 22:37:01 +0000 Subject: [PATCH 27/33] docs: document playback policy API --- jsr.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/jsr.json b/jsr.json index 3ff0cdc..9d9d781 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.64", + "version": "0.1.65", "exports": "./src/index.ts", "publish": { "include": ["LICENSE", "README.md", "src/**/*.ts"] diff --git a/package.json b/package.json index 199191e..1d43f59 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/mse", - "version": "0.1.64", + "version": "0.1.65", "description": "MSE playback engine for TypeType", "license": "MIT", "type": "module", From a9a37dc00b822d0c02f789b8d8adeeea5c7e997a Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 23 Sep 2026 22:37:56 +0000 Subject: [PATCH 28/33] docs: document playback policy API --- jsr.json | 2 +- package.json | 2 +- src/playback-policy.ts | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/jsr.json b/jsr.json index 9d9d781..3cb66e8 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.65", + "version": "0.1.66", "exports": "./src/index.ts", "publish": { "include": ["LICENSE", "README.md", "src/**/*.ts"] diff --git a/package.json b/package.json index 1d43f59..c631c1f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/mse", - "version": "0.1.65", + "version": "0.1.66", "description": "MSE playback engine for TypeType", "license": "MIT", "type": "module", diff --git a/src/playback-policy.ts b/src/playback-policy.ts index 06464a5..6528ef5 100644 --- a/src/playback-policy.ts +++ b/src/playback-policy.ts @@ -1,9 +1,11 @@ +/** Retry budgets for manifest and media transport requests. */ export type PlaybackRetryPolicy = { manifestAttempts: number; mediaAttempts: number; retryIntervalMs: number; }; +/** Transport-independent buffering, live-edge, and retry policy. */ export type PlaybackPolicy = PlaybackRetryPolicy & { startupBufferMs: number; steadyBufferMs: number; @@ -16,6 +18,7 @@ export type PlaybackPolicy = PlaybackRetryPolicy & { liveCatchupMaxRate: number; }; +/** Optional overrides applied to the default TypeType playback policy. */ export type PlaybackPolicyInput = Partial; const DEFAULT_POLICY: PlaybackPolicy = { @@ -33,6 +36,7 @@ const DEFAULT_POLICY: PlaybackPolicy = { liveCatchupMaxRate: 1.25, }; +/** Resolve a transport policy with bounded, internally consistent values. */ export function resolvePlaybackPolicy(input: PlaybackPolicyInput = {}): PlaybackPolicy { const policy = { startupBufferMs: positiveMs(input.startupBufferMs, DEFAULT_POLICY.startupBufferMs), @@ -55,6 +59,7 @@ export function resolvePlaybackPolicy(input: PlaybackPolicyInput = {}): Playback return policy; } +/** Convert a policy duration from milliseconds to seconds. */ export function bufferSeconds(valueMs: number): number { return valueMs / 1_000; } From 01ee6caa7c5a48d63bec1b754dca9b56a99c9c0a Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 24 Sep 2026 00:01:05 +0000 Subject: [PATCH 29/33] fix: use PipePipe live edge target --- jsr.json | 2 +- package.json | 2 +- src/playback-policy.ts | 4 ++-- tests/playback-policy.test.ts | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/jsr.json b/jsr.json index 3cb66e8..c3ffe2f 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.66", + "version": "0.1.67", "exports": "./src/index.ts", "publish": { "include": ["LICENSE", "README.md", "src/**/*.ts"] diff --git a/package.json b/package.json index c631c1f..8d9e37a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/mse", - "version": "0.1.66", + "version": "0.1.67", "description": "MSE playback engine for TypeType", "license": "MIT", "type": "module", diff --git a/src/playback-policy.ts b/src/playback-policy.ts index 6528ef5..1637b09 100644 --- a/src/playback-policy.ts +++ b/src/playback-policy.ts @@ -30,8 +30,8 @@ const DEFAULT_POLICY: PlaybackPolicy = { manifestAttempts: 5, mediaAttempts: 3, retryIntervalMs: 500, - liveTargetLatencyMs: 2_000, - liveMaxLatencyMs: 15_000, + liveTargetLatencyMs: 10_000, + liveMaxLatencyMs: 20_000, liveCatchupMinRate: 1, liveCatchupMaxRate: 1.25, }; diff --git a/tests/playback-policy.test.ts b/tests/playback-policy.test.ts index 6f2b5da..9e587b0 100644 --- a/tests/playback-policy.test.ts +++ b/tests/playback-policy.test.ts @@ -14,8 +14,8 @@ describe("playback policy", () => { manifestAttempts: 5, mediaAttempts: 3, retryIntervalMs: 500, - liveTargetLatencyMs: 2_000, - liveMaxLatencyMs: 15_000, + liveTargetLatencyMs: 10_000, + liveMaxLatencyMs: 20_000, liveCatchupMinRate: 1, liveCatchupMaxRate: 1.25, }); From a5334408860aead9b149a59dda243d24628fc83d Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 24 Sep 2026 04:04:05 +0000 Subject: [PATCH 30/33] feat: add shared HLS and DASH transport --- jsr.json | 2 +- package.json | 2 +- src/index.ts | 16 ++++++ src/transport.ts | 124 ++++++++++++++++++++++++++++++++++++++++ tests/transport.test.ts | 75 ++++++++++++++++++++++++ 5 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 src/transport.ts create mode 100644 tests/transport.test.ts diff --git a/jsr.json b/jsr.json index c3ffe2f..90b2e04 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.67", + "version": "0.1.68", "exports": "./src/index.ts", "publish": { "include": ["LICENSE", "README.md", "src/**/*.ts"] diff --git a/package.json b/package.json index 8d9e37a..7a739ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/mse", - "version": "0.1.67", + "version": "0.1.68", "description": "MSE playback engine for TypeType", "license": "MIT", "type": "module", diff --git a/src/index.ts b/src/index.ts index 20eddf4..a0773c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,22 @@ export type { PlaybackWindowRequest, } from "./playback-window"; export type { TypeTypeMseSnapshot } from "./player-snapshot"; +export type { + DashStreamingSettings, + HlsTransportOptions, + MediaHandleUrl, + TransportConfig, + TransportFetchRequest, + TransportLoader, + TransportRequestInit, +} from "./transport"; +export { + createDashSettings, + createHlsConfig, + createHlsPlaybackKey, + hlsRequestUrl, + isMediaHandleUrl, +} from "./transport"; export { TypeTypeMsePlayer } from "./type-type-mse-player"; export type { TrackKind, diff --git a/src/transport.ts b/src/transport.ts new file mode 100644 index 0000000..c7ffeb4 --- /dev/null +++ b/src/transport.ts @@ -0,0 +1,124 @@ +import { bufferSeconds, type PlaybackPolicyInput, resolvePlaybackPolicy } from "./playback-policy"; + +const MEDIA_HANDLE_PATTERN = /\/media\/(m1_[A-Za-z0-9_-]{24})$/; + +/** Opaque provider media URL accepted by the TypeType media-handle route. */ +export type MediaHandleUrl = string & { readonly __mediaHandle: unique symbol }; + +/** Minimal fetch request shape shared by HLS transport implementations. */ +export type TransportFetchRequest = { + url: string; + [key: string]: unknown; +}; + +/** Initialization parameters passed through by an HLS transport. */ +export type TransportRequestInit = RequestInit; + +/** A transport loader class accepted by HLS engines. */ +export type TransportLoader = unknown; + +/** Generic configuration returned for an HLS engine. */ +export type TransportConfig = Record; + +/** Inputs used to create a deterministic HLS transport configuration. */ +export type HlsTransportOptions = { + FetchLoader: TLoader; + playbackKey?: string; + policy?: PlaybackPolicyInput; + crypto?: Crypto; +}; + +/** DASH buffer and retry settings, keyed by the names used by dash.js. */ +export type DashStreamingSettings = { + buffer: { + bufferTimeAtTopQuality: number; + bufferTimeAtTopQualityLongForm: number; + bufferToKeep: number; + }; + retryAttempts: { + MPD: number; + MediaSegment: number; + InitializationSegment: number; + IndexSegment: number; + }; + retryIntervals: { + MPD: number; + MediaSegment: number; + InitializationSegment: number; + IndexSegment: number; + }; +}; + +export function isMediaHandleUrl(url: string): boolean { + return MEDIA_HANDLE_PATTERN.test(url); +} + +/** Version an opaque media-handle request without changing its media identity. */ +export function hlsRequestUrl(url: string, playbackKey: string): string { + if (!isMediaHandleUrl(url)) return url; + const absolute = URL.canParse(url); + const parsed = new URL(url, "https://typetype.invalid"); + parsed.searchParams.set("playback", playbackKey); + return absolute ? parsed.toString() : `${parsed.pathname}${parsed.search}`; +} + +/** Create a bounded playback key even when the secure randomness API is absent. */ +export function createHlsPlaybackKey(cryptoApi: Crypto | undefined = globalThis.crypto): string { + if (typeof cryptoApi?.randomUUID === "function") return cryptoApi.randomUUID(); + if (typeof cryptoApi?.getRandomValues === "function") { + return Array.from(cryptoApi.getRandomValues(new Uint32Array(4)), (value) => + value.toString(16).padStart(8, "0"), + ).join("-"); + } + return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; +} + +/** Create the low-level startup and buffering contract for an HLS MSE engine. */ +export function createHlsConfig( + options: HlsTransportOptions, +): Partial { + const policy = resolvePlaybackPolicy(options.policy); + let requestSequence = 0; + const playbackKey = options.playbackKey ?? createHlsPlaybackKey(options.crypto); + return { + abrEwmaDefaultEstimate: 1_000_000, + backBufferLength: bufferSeconds(policy.backBufferMs), + capLevelToPlayerSize: true, + fetchSetup: (context: TransportFetchRequest, initParams: TransportRequestInit) => + new Request(hlsRequestUrl(context.url, `${playbackKey}-${requestSequence++}`), initParams), + liveMaxLatencyDuration: bufferSeconds(policy.liveMaxLatencyMs), + liveSyncDuration: bufferSeconds(policy.liveTargetLatencyMs), + loader: options.FetchLoader, + maxBufferLength: bufferSeconds(policy.steadyBufferMs), + maxLiveSyncPlaybackRate: policy.liveCatchupMaxRate, + maxMaxBufferLength: bufferSeconds(policy.maxBufferMs), + progressive: true, + startFragPrefetch: true, + startLevel: 0, + testBandwidth: false, + } as unknown as Partial; +} + +/** Create the DASH MSE buffering and retry contract from the shared policy. */ +export function createDashSettings(policyInput: PlaybackPolicyInput = {}): DashStreamingSettings { + const policy = resolvePlaybackPolicy(policyInput); + return { + buffer: { + bufferTimeAtTopQuality: bufferSeconds(policy.steadyBufferMs), + bufferTimeAtTopQualityLongForm: bufferSeconds(policy.steadyBufferMs), + bufferToKeep: bufferSeconds(policy.backBufferMs), + }, + retryAttempts: { + MPD: policy.manifestAttempts, + MediaSegment: policy.mediaAttempts, + InitializationSegment: policy.mediaAttempts, + IndexSegment: policy.mediaAttempts, + }, + retryIntervals: { + MPD: policy.retryIntervalMs, + MediaSegment: policy.retryIntervalMs, + InitializationSegment: policy.retryIntervalMs, + IndexSegment: policy.retryIntervalMs, + }, + }; +} diff --git a/tests/transport.test.ts b/tests/transport.test.ts new file mode 100644 index 0000000..cf632d8 --- /dev/null +++ b/tests/transport.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test"; +import { + createDashSettings, + createHlsConfig, + createHlsPlaybackKey, + hlsRequestUrl, + isMediaHandleUrl, +} from "../src/transport"; + +describe("HLS transport", () => { + test("starts on the smallest level without a bandwidth test round trip", () => { + const config = createHlsConfig<{ startLevel: number }, object>({ + FetchLoader: class {}, + }); + + expect(config).toMatchObject({ + abrEwmaDefaultEstimate: 1_000_000, + backBufferLength: 30, + capLevelToPlayerSize: true, + liveMaxLatencyDuration: 20, + liveSyncDuration: 10, + maxBufferLength: 24, + maxMaxBufferLength: 50, + progressive: true, + startFragPrefetch: true, + startLevel: 0, + testBandwidth: false, + }); + expect(config).not.toHaveProperty("liveBackBufferLength"); + }); + + test("versions opaque media handles by playback generation", () => { + const handle = "/media/m1_0123456789abcdefghijklmn"; + expect(isMediaHandleUrl(handle)).toBe(true); + expect(hlsRequestUrl(handle, "generation-1")).toBe(`${handle}?playback=generation-1`); + expect(hlsRequestUrl("https://example.test/video.m3u8", "generation-1")).toBe( + "https://example.test/video.m3u8", + ); + }); + + test("keeps a transport key when secure randomness is unavailable", () => { + const cryptoApi = { + getRandomValues: (values: Uint32Array) => { + values.set([1, 35, 171, 0xffffffff]); + return values; + }, + } as unknown as Crypto; + + expect(createHlsPlaybackKey(cryptoApi)).toBe("00000001-00000023-000000ab-ffffffff"); + }); +}); + +describe("DASH transport", () => { + test("maps the shared policy to dash.js settings", () => { + expect(createDashSettings()).toEqual({ + buffer: { + bufferTimeAtTopQuality: 24, + bufferTimeAtTopQualityLongForm: 24, + bufferToKeep: 30, + }, + retryAttempts: { + MPD: 5, + MediaSegment: 3, + InitializationSegment: 3, + IndexSegment: 3, + }, + retryIntervals: { + MPD: 500, + MediaSegment: 500, + InitializationSegment: 500, + IndexSegment: 500, + }, + }); + }); +}); From 574bdbe001bebb02fbd23c20139d52abfb68c05f Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 24 Sep 2026 04:38:57 +0000 Subject: [PATCH 31/33] perf: centralize HLS and DASH transport policy --- jsr.json | 2 +- package.json | 2 +- src/transport.ts | 1 + tests/transport.test.ts | 15 +++++++++++++++ 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/jsr.json b/jsr.json index 90b2e04..2c8196b 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.68", + "version": "0.1.69", "exports": "./src/index.ts", "publish": { "include": ["LICENSE", "README.md", "src/**/*.ts"] diff --git a/package.json b/package.json index 7a739ad..926aa1a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/mse", - "version": "0.1.68", + "version": "0.1.69", "description": "MSE playback engine for TypeType", "license": "MIT", "type": "module", diff --git a/src/transport.ts b/src/transport.ts index c7ffeb4..48bfa11 100644 --- a/src/transport.ts +++ b/src/transport.ts @@ -49,6 +49,7 @@ export type DashStreamingSettings = { }; }; +/** Detect an opaque provider media handle that requires a playback-generation query. */ export function isMediaHandleUrl(url: string): boolean { return MEDIA_HANDLE_PATTERN.test(url); } diff --git a/tests/transport.test.ts b/tests/transport.test.ts index cf632d8..e7e9759 100644 --- a/tests/transport.test.ts +++ b/tests/transport.test.ts @@ -29,6 +29,21 @@ describe("HLS transport", () => { expect(config).not.toHaveProperty("liveBackBufferLength"); }); + test("versions every opaque media request within a playback generation", () => { + const config = createHlsConfig<{ fetchSetup: unknown }, object>({ + FetchLoader: class {}, + playbackKey: "generation-1", + }); + const setup = config.fetchSetup as ( + context: { url: string }, + initParams: RequestInit, + ) => Request; + const url = "https://example.test/media/m1_0123456789abcdefghijklmn"; + + expect(new URL(setup({ url }, {}).url).searchParams.get("playback")).toBe("generation-1-0"); + expect(new URL(setup({ url }, {}).url).searchParams.get("playback")).toBe("generation-1-1"); + }); + test("versions opaque media handles by playback generation", () => { const handle = "/media/m1_0123456789abcdefghijklmn"; expect(isMediaHandleUrl(handle)).toBe(true); From 55b758ef6422d24ad10ca6554a01dd720f2ad891 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 24 Sep 2026 05:59:27 +0000 Subject: [PATCH 32/33] perf: lower live HLS target latency --- src/playback-policy.ts | 4 ++-- tests/playback-policy.test.ts | 6 +++--- tests/transport.test.ts | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/playback-policy.ts b/src/playback-policy.ts index 1637b09..3e3946f 100644 --- a/src/playback-policy.ts +++ b/src/playback-policy.ts @@ -30,8 +30,8 @@ const DEFAULT_POLICY: PlaybackPolicy = { manifestAttempts: 5, mediaAttempts: 3, retryIntervalMs: 500, - liveTargetLatencyMs: 10_000, - liveMaxLatencyMs: 20_000, + liveTargetLatencyMs: 5_000, + liveMaxLatencyMs: 15_000, liveCatchupMinRate: 1, liveCatchupMaxRate: 1.25, }; diff --git a/tests/playback-policy.test.ts b/tests/playback-policy.test.ts index 9e587b0..d12d79c 100644 --- a/tests/playback-policy.test.ts +++ b/tests/playback-policy.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { bufferSeconds, resolvePlaybackPolicy } from "../src/playback-policy"; describe("playback policy", () => { - test("uses low-latency defaults from Xtra and the existing web player", () => { + test("uses a lower live HLS target latency", () => { const policy = resolvePlaybackPolicy(); expect(policy).toEqual({ @@ -14,8 +14,8 @@ describe("playback policy", () => { manifestAttempts: 5, mediaAttempts: 3, retryIntervalMs: 500, - liveTargetLatencyMs: 10_000, - liveMaxLatencyMs: 20_000, + liveTargetLatencyMs: 5_000, + liveMaxLatencyMs: 15_000, liveCatchupMinRate: 1, liveCatchupMaxRate: 1.25, }); diff --git a/tests/transport.test.ts b/tests/transport.test.ts index e7e9759..d417537 100644 --- a/tests/transport.test.ts +++ b/tests/transport.test.ts @@ -17,8 +17,8 @@ describe("HLS transport", () => { abrEwmaDefaultEstimate: 1_000_000, backBufferLength: 30, capLevelToPlayerSize: true, - liveMaxLatencyDuration: 20, - liveSyncDuration: 10, + liveMaxLatencyDuration: 15, + liveSyncDuration: 5, maxBufferLength: 24, maxMaxBufferLength: 50, progressive: true, From 135e4b036997079cbb7d6d2f25c733bbb5bce92f Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 24 Sep 2026 05:59:33 +0000 Subject: [PATCH 33/33] chore: release mse 0.1.70 --- jsr.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/jsr.json b/jsr.json index 2c8196b..ebfc53b 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.69", + "version": "0.1.70", "exports": "./src/index.ts", "publish": { "include": ["LICENSE", "README.md", "src/**/*.ts"] diff --git a/package.json b/package.json index 926aa1a..e80e87f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/mse", - "version": "0.1.69", + "version": "0.1.70", "description": "MSE playback engine for TypeType", "license": "MIT", "type": "module",