Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
832315a
ci: route workflows to R730 runners
Priveetee Aug 31, 2026
b5c0253
chore: benchmark dev runners
Priveetee Aug 31, 2026
2388bd0
ci: route npm publication through R730
Priveetee Aug 31, 2026
511a7e0
fix: recover stalled SABR segment fetches
Priveetee Sep 3, 2026
9483786
chore: release mse 0.1.57
Priveetee Sep 3, 2026
9d9c7d0
fix: publish npm provenance from hosted runner
Priveetee Sep 3, 2026
2116bbb
fix: fill playback buffer before paused seek decoding
Priveetee Sep 5, 2026
2eba202
chore: bump MSE package to 0.1.58
Priveetee Sep 5, 2026
158db51
fix: isolate WebKit media seeks
Priveetee Sep 5, 2026
0b725e2
chore: bump MSE package to 0.1.59
Priveetee Sep 5, 2026
ee4a713
fix: recover evicted SABR segments
Priveetee Sep 7, 2026
26a57fb
chore: bump MSE package to 0.1.60
Priveetee Sep 7, 2026
d1be046
chore: update player lint dependency
Priveetee Sep 9, 2026
f655568
docs: link AI transparency statement
Priveetee Sep 18, 2026
da493ab
docs: move AI transparency notice
Priveetee Sep 18, 2026
15de088
ci: route workflows to CI stack
Priveetee Sep 19, 2026
0d20e47
ci: install node for player validation
Priveetee Sep 19, 2026
d7aee18
fix: preserve expanded MSE duration
Priveetee Sep 21, 2026
0dfe553
chore: bump MSE package to 0.1.61
Priveetee Sep 21, 2026
4dd30e8
fix: publish npm from self-hosted CI
Priveetee Sep 21, 2026
8003345
fix: disable npm provenance on self-hosted CI
Priveetee Sep 21, 2026
15cf803
feat: add shared live playback policy
Priveetee Sep 23, 2026
ffff2e4
chore: bump MSE package to 0.1.62
Priveetee Sep 23, 2026
8990f61
chore: bump MSE package to 0.1.63
Priveetee Sep 23, 2026
3df261d
style: fix playback policy formatting
Priveetee Sep 23, 2026
a71dab0
chore: bump MSE package to 0.1.64
Priveetee Sep 23, 2026
08abddd
docs: document playback policy API
Priveetee Sep 23, 2026
a9a37dc
docs: document playback policy API
Priveetee Sep 23, 2026
01ee6ca
fix: use PipePipe live edge target
Priveetee Sep 24, 2026
a533440
feat: add shared HLS and DASH transport
Priveetee Sep 24, 2026
574bdbe
perf: centralize HLS and DASH transport policy
Priveetee Sep 24, 2026
55b758e
perf: lower live HLS target latency
Priveetee Sep 24, 2026
135e4b0
chore: release mse 0.1.70
Priveetee Sep 24, 2026
e92bf14
chore: merge main for 1.9.0 release
Priveetee Sep 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ permissions:
jobs:
publish-npm:
if: github.event_name == 'push' || inputs.registry == 'npm' || inputs.registry == 'both'
runs-on: ubuntu-24.04
runs-on: [self-hosted, Linux, X64, ci-stack]
timeout-minutes: 15
environment: npm
permissions:
Expand Down Expand Up @@ -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'
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
<p>The browser MSE and SABR playback engine for TypeType.</p>
</div>

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).
Expand Down
2 changes: 1 addition & 1 deletion jsr.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://jsr.io/schema/config-file.v1.json",
"name": "@typetype/mse",
"version": "0.1.60",
"version": "0.1.70",
"exports": "./src/index.ts",
"publish": {
"include": ["LICENSE", "README.md", "src/**/*.ts"]
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@typetype/mse",
"version": "0.1.60",
"version": "0.1.70",
"description": "MSE playback engine for TypeType",
"license": "MIT",
"type": "module",
Expand Down
18 changes: 18 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,31 @@ 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,
PlaybackWindowRecoveryAction,
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,
Expand Down
7 changes: 7 additions & 0 deletions src/media-source-timing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
79 changes: 79 additions & 0 deletions src/playback-policy.ts
Original file line number Diff line number Diff line change
@@ -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<PlaybackPolicy>;

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;
}
125 changes: 125 additions & 0 deletions src/transport.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;

/** Inputs used to create a deterministic HLS transport configuration. */
export type HlsTransportOptions<TLoader extends TransportLoader> = {
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<TConfig extends TransportConfig, TLoader extends TransportLoader>(
options: HlsTransportOptions<TLoader>,
): Partial<TConfig> {
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<TConfig>;
}

/** 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,
},
};
}
16 changes: 16 additions & 0 deletions tests/media-source-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand Down
45 changes: 45 additions & 0 deletions tests/playback-policy.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading