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,
+ },
+ });
+ });
+});