From 4c272f1890d1498bb778baaa3f147301f880afda Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Tue, 14 Jul 2026 07:59:26 -0700 Subject: [PATCH] feat(spf): skip relocation for near-zero-PTS origins (spike) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add NEAR_ZERO_ORIGIN_THRESHOLD (1s, absolute) to the derive: a shared/per-type origin below the threshold — including negatives — resolves to 0, so ordinary ~0-PTS VOD is left on its native timeline and no timestampOffset is set (the loader stamp already no-ops on a derived 0). Relocation stays targeted at streams with an intentional large origin (instant clips, bipbop @10s). Absolute basis, not proportional: the DTS-below-zero / edge-ripple risk scales with the origin's absolute size, not the presentation's duration. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../behaviors/establish-start-media-time.ts | 29 +++++++++- .../tests/establish-start-media-time.test.ts | 55 ++++++++++++++++++- 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/packages/spf/src/playback/behaviors/establish-start-media-time.ts b/packages/spf/src/playback/behaviors/establish-start-media-time.ts index e093362e..760d85c0 100644 --- a/packages/spf/src/playback/behaviors/establish-start-media-time.ts +++ b/packages/spf/src/playback/behaviors/establish-start-media-time.ts @@ -74,6 +74,25 @@ function ownOrigin(data: MediaContainerData | undefined): number | undefined { : undefined; } +/** + * Origins below this magnitude (seconds) are treated as `0` — the derive returns `0`, so + * the presentation is left on its native (~0-based) timeline and no `timestampOffset` is + * set (the loader stamp no-ops on a derived `0`). Ordinary VOD carries a small nonzero + * encode origin (audio priming, first-frame CTS, edit lists); relocating by a sub-second + * amount is pointless, and setting `timestampOffset` at all can ripple edge segments. + * Relocation targets streams with an intentional large origin (instant clips, bipbop @10s). + * + * Absolute basis, not proportional: the DTS-below-zero / ripple risk scales with the + * origin's absolute size, not with the presentation's duration. Also snaps negatives to + * `0` — a negative origin would relocate *forward*, which is never the intent. + */ +export const NEAR_ZERO_ORIGIN_THRESHOLD = 1; + +/** Snap a below-threshold (incl. negative) origin to `0` so it isn't relocated. */ +function thresholdOrigin(origin: number): number { + return origin < NEAR_ZERO_ORIGIN_THRESHOLD ? 0 : origin; +} + /** * The **default** — relocate the whole presentation by one shared origin: the `min` * across the *selected* A/V tracks' own origins, denormalized onto every type. This @@ -87,7 +106,8 @@ function ownOrigin(data: MediaContainerData | undefined): number | undefined { * Returns `undefined` for every type until all *selected* types have a complete origin * (the shared-`min` barrier). Which types must contribute is read from `ctx` (the * selected v/a ids); with no selection context it coordinates across whatever types - * have data. + * have data. A shared origin below {@link NEAR_ZERO_ORIGIN_THRESHOLD} is returned as `0` + * (native — ordinary ~0-PTS VOD isn't relocated). */ export const deriveSharedMinStartMediaTime: DeriveStartMediaTime = (containerData, ctx) => { const contributingTypes: string[] = []; @@ -99,7 +119,7 @@ export const deriveSharedMinStartMediaTime: DeriveStartMediaTime = (containerDat // Barrier: not ready until every contributing type has a complete origin. if (origins.length === 0 || origins.some((origin) => origin === undefined)) return {}; - const shared = Math.min(...(origins as number[])); + const shared = thresholdOrigin(Math.min(...(origins as number[]))); const out: Record = {}; for (const type of Object.keys(containerData)) out[type] = shared; return out; @@ -113,7 +133,10 @@ export const deriveSharedMinStartMediaTime: DeriveStartMediaTime = (containerDat */ export const derivePerTypeStartMediaTime: DeriveStartMediaTime = (containerData) => { const out: Record = {}; - for (const [type, data] of Object.entries(containerData)) out[type] = ownOrigin(data); + for (const [type, data] of Object.entries(containerData)) { + const origin = ownOrigin(data); + out[type] = origin === undefined ? undefined : thresholdOrigin(origin); + } return out; }; diff --git a/packages/spf/src/playback/behaviors/tests/establish-start-media-time.test.ts b/packages/spf/src/playback/behaviors/tests/establish-start-media-time.test.ts index 792da459..b0287c09 100644 --- a/packages/spf/src/playback/behaviors/tests/establish-start-media-time.test.ts +++ b/packages/spf/src/playback/behaviors/tests/establish-start-media-time.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { derivePerTypeStartMediaTime, deriveSharedMinStartMediaTime } from '../establish-start-media-time'; +import { + derivePerTypeStartMediaTime, + deriveSharedMinStartMediaTime, + NEAR_ZERO_ORIGIN_THRESHOLD, +} from '../establish-start-media-time'; describe('deriveSharedMinStartMediaTime', () => { const sel = { selectedVideoTrackId: 'v', selectedAudioTrackId: 'a' }; @@ -73,6 +77,43 @@ describe('deriveSharedMinStartMediaTime', () => { ) ).toEqual({ video: 9.956, audio: 9.956 }); }); + + it('leaves ordinary ~0-PTS VOD native: a shared origin below the threshold returns 0', () => { + // Both types carry a sub-second (0.5s) encode offset → not relocated. + expect( + deriveSharedMinStartMediaTime( + { + video: { timescale: 90000, baseMediaDecodeTime: 45000, segmentStartTime: 0 }, + audio: { timescale: 48000, baseMediaDecodeTime: 24000, segmentStartTime: 0 }, + }, + sel + ) + ).toEqual({ video: 0, audio: 0 }); + }); + + it('relocates at/above the threshold (the boundary is exclusive)', () => { + const atThreshold = { + video: { timescale: 90000, baseMediaDecodeTime: 90000 * NEAR_ZERO_ORIGIN_THRESHOLD, segmentStartTime: 0 }, + audio: { timescale: 48000, baseMediaDecodeTime: 48000 * NEAR_ZERO_ORIGIN_THRESHOLD, segmentStartTime: 0 }, + }; + expect(deriveSharedMinStartMediaTime(atThreshold, sel)).toEqual({ + video: NEAR_ZERO_ORIGIN_THRESHOLD, + audio: NEAR_ZERO_ORIGIN_THRESHOLD, + }); + }); + + it('snaps a negative shared origin to 0 (never relocates forward)', () => { + // segmentStartTime > bmdt/ts → negative own origin. + expect( + deriveSharedMinStartMediaTime( + { + video: { timescale: 90000, baseMediaDecodeTime: 0, segmentStartTime: 5 }, + audio: { timescale: 48000, baseMediaDecodeTime: 0, segmentStartTime: 5 }, + }, + sel + ) + ).toEqual({ video: 0, audio: 0 }); + }); }); describe('derivePerTypeStartMediaTime', () => { @@ -103,4 +144,16 @@ describe('derivePerTypeStartMediaTime', () => { audio: undefined, }); }); + + it('snaps a below-threshold origin to 0 independently per type', () => { + expect( + derivePerTypeStartMediaTime( + { + video: { timescale: 90000, baseMediaDecodeTime: 45000, segmentStartTime: 0 }, // 0.5s → 0 + audio: { timescale: 48000, baseMediaDecodeTime: 48000 * 60, segmentStartTime: 0 }, // 60s → 60 + }, + {} + ) + ).toEqual({ video: 0, audio: 60 }); + }); });