diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index faef587..cef4d32 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 @@ -39,8 +42,8 @@ jobs: - run: node --input-type=module --eval 'const mod = await import("./dist/index.js"); if (typeof mod.TypeTypeMsePlayer !== "function") process.exit(1)' dependency-review: - if: github.event_name == 'pull_request' runs-on: ubuntu-24.04 + if: github.event_name == 'pull_request' timeout-minutes: 5 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f5c6cc5..9d8bb29 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: @@ -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 --provenance=false publish-jsr: if: (github.event_name == 'push' && startsWith(github.ref_name, 'v')) || inputs.registry == 'jsr' || inputs.registry == 'both' diff --git a/README.md b/README.md index 09a5496..bcef76b 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@

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). diff --git a/jsr.json b/jsr.json index bd72115..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.60", + "version": "0.1.70", "exports": "./src/index.ts", "publish": { "include": ["LICENSE", "README.md", "src/**/*.ts"] diff --git a/package.json b/package.json index 3e63fcb..e80e87f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@typetype/mse", - "version": "0.1.60", + "version": "0.1.70", "description": "MSE playback engine for TypeType", "license": "MIT", "type": "module", diff --git a/src/index.ts b/src/index.ts index 7f8602d..a0773c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,8 @@ export type { PlaybackManifest, } from "./manifest"; export { isMseTypeSupported } from "./media-source-runtime"; +export type { PlaybackPolicy, PlaybackPolicyInput, PlaybackRetryPolicy } from "./playback-policy"; +export { bufferSeconds, resolvePlaybackPolicy } from "./playback-policy"; export type { PlaybackBufferedRange, PlaybackWindow, @@ -13,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/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/src/playback-policy.ts b/src/playback-policy.ts new file mode 100644 index 0000000..3e3946f --- /dev/null +++ b/src/playback-policy.ts @@ -0,0 +1,79 @@ +/** 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; + maxBufferMs: number; + backBufferMs: number; + manifestRefreshMs: number; + liveTargetLatencyMs: number; + liveMaxLatencyMs: number; + liveCatchupMinRate: number; + liveCatchupMaxRate: number; +}; + +/** Optional overrides applied to the default TypeType playback policy. */ +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: 5_000, + liveMaxLatencyMs: 15_000, + liveCatchupMinRate: 1, + 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), + 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; +} + +/** Convert a policy duration from milliseconds to seconds. */ +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/src/transport.ts b/src/transport.ts new file mode 100644 index 0000000..48bfa11 --- /dev/null +++ b/src/transport.ts @@ -0,0 +1,125 @@ +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; + }; +}; + +/** Detect an opaque provider media handle that requires a playback-generation query. */ +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/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[] = []; diff --git a/tests/playback-policy.test.ts b/tests/playback-policy.test.ts new file mode 100644 index 0000000..d12d79c --- /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 a lower live HLS target latency", () => { + 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: 5_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); + }); +}); diff --git a/tests/transport.test.ts b/tests/transport.test.ts new file mode 100644 index 0000000..d417537 --- /dev/null +++ b/tests/transport.test.ts @@ -0,0 +1,90 @@ +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: 15, + liveSyncDuration: 5, + maxBufferLength: 24, + maxMaxBufferLength: 50, + progressive: true, + startFragPrefetch: true, + startLevel: 0, + testBandwidth: false, + }); + 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); + 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, + }, + }); + }); +});