diff --git a/.claude/plans/spf-non-zero-pts-timestamp-offset-spike.md b/.claude/plans/spf-non-zero-pts-timestamp-offset-spike.md index 96a09361..b3ef907b 100644 --- a/.claude/plans/spf-non-zero-pts-timestamp-offset-spike.md +++ b/.claude/plans/spf-non-zero-pts-timestamp-offset-spike.md @@ -39,33 +39,28 @@ your detailed pass; **DEFERRED** = later/open. - spike relocation (boolean/inline approach, since reworked) — `6a20bade9` - VTT `X-TIMESTAMP-MAP` parse — `7b0d8bbdc` - messagePipelines step model (loader step pipelines) — `041cecca6` +- relocation as `establishStartMediaTime` reactor (DOM-free) + `relocation-steps` + (config messagePipelines) + DOM split; deleted `relocation.ts`/`origin-discoverer.ts` — `05a0a6452` +- audio-only relocation wiring (reactor + `audioMessagePipelines` baked into + `engine-audio-only`) — audio 0-based sandbox-verified -### Uncommitted — KEEP (reactor + config-pipelines; typecheck/tests/lint/build green, A/V sandbox-verified 0-based) -- `behaviors/dom/establish-start-media-time.ts` — **new**. Two pieces: - - **Steps** (`relocationMessagePipelines`): a plain config array. `discover` - (`readInitTimescale`/`readSegmentOrigin`) writes `state.mediaContainerData`; - `stampStartMediaTime` reads that track's origin back and sets `timestampOffset`. - Steps read composition `state` from their call-time `deps` — no closures, no - context. One map serves both track types (steps key by the segment's trackId). - - **Reactor** (`establishStartMediaTime`): 3 states — `inactive` (clears the slot - per source) / `monitoring` (derive effect = injected `deriveStartMediaTime` seam - → writes `Track.startMediaTime`, the consume) / `established` (derive disabled, - sticky). Owns `mediaContainerData` on `state`; selection **optional/defensive**. -- `primitives/head-peek.ts` — **new** generic eager head-peek (replaces the old - self-discriminating `origin-discoverer`). -- `media/types/index.ts` — `Track.startMediaTime` + `MediaContainerData`. -- `actors/dom/segment-loader.ts` — `StepDeps` widened with the composition - `{ state, context, config }` (opaque conduit; loader never reads them). -- `behaviors/dom/setup-buffer-actors.ts` — reads relocation pipelines from **config** - (`video/audioMessagePipelines`), threads composition deps into the loader. No context slots. -- `engines/hls/engine.ts` — composes the reactor before `setup*BufferActors`; - bakes `video/audioMessagePipelines = relocationMessagePipelines` + `mediaContainerData` - slot + `deriveStartMediaTime` config. All comment-marked for easy removal. -- `engines/hls/index.ts` — exports `DeriveStartMediaTime` + `derivePerTrackStartMediaTime`. -- **Deleted:** `engines/hls/relocation.ts`, `primitives/origin-discoverer.ts`. +### Architecture (as landed) +- **Steps** (`behaviors/dom/relocation-steps.ts`, `relocationMessagePipelines`): a + plain config array. `discover` (`readInitTimescale`/`readSegmentOrigin`) writes + `state.mediaContainerData`; `stampStartMediaTime` reads that track's origin back + and sets `timestampOffset`. Steps read composition `state` from call-time `deps` + (no closures/context); one map serves both track types (key by segment trackId). +- **Reactor** (`behaviors/establish-start-media-time.ts`, DOM-free): 3 states — + `inactive` (clears the slot per source) / `monitoring` (derive effect = injected + `deriveStartMediaTime` seam → writes `Track.startMediaTime`, the consume) / + `established` (derive disabled, sticky). Owns `mediaContainerData` on `state`; + selection **optional/defensive** (composes across video-only/audio-only/both). +- `StepDeps` carries the composition `{state,context,config}` (opaque conduit). + Engines bake `*MessagePipelines = relocationMessagePipelines` + the reactor + + `mediaContainerData` slot + `deriveStartMediaTime` config, all comment-marked. ### REVIEW — your detailed pass -- The reactor + steps end-to-end (KEEP surface above). +- The reactor + steps end-to-end (architecture above). - **`StepDeps` typing:** steps get composition `{state,context,config}` typed loose (`AnySlotMap`); relocation asserts its slots via `containerSlot(deps)` (one cast, since it legitimately knows the composition provides them). OK, or want it tighter? @@ -86,8 +81,10 @@ your detailed pass; **DEFERRED** = later/open. with comment markers; revisit a tree-shakeable opt-in + measure Tier-0 bundle. ### DEFERRED / open (see the doc's Open questions) +- **Per-type keying + non-0th-segment origin (#3/#4)** — next up. `mediaContainerData` + keyed by track *type* (ABR rungs share); `startMediaTime = bmdt/ts − segment.startTime` + so non-zero starts (initial currentTime ≠ 0, live/DVR) relocate correctly. - Tier 2 shared-`min` (a `deriveStartMediaTime` swap) + barrier-liveness bound. -- Audio-only composition wiring (reactor baked into the standard engine only). - Honest-`startMediaTime`-everywhere convergence + live-anchor dedup. - Suspected pre-existing: seek-into-evicted-back-buffer. diff --git a/packages/spf/src/playback/engines/hls/engine-audio-only.ts b/packages/spf/src/playback/engines/hls/engine-audio-only.ts index 210c73a1..7658a4c3 100644 --- a/packages/spf/src/playback/engines/hls/engine-audio-only.ts +++ b/packages/spf/src/playback/engines/hls/engine-audio-only.ts @@ -9,7 +9,7 @@ import type { BackBufferConfig } from '../../../media/buffer/back-buffer'; import type { ForwardBufferConfig } from '../../../media/buffer/forward-buffer'; import { canPlayTrack } from '../../../media/dom/capabilities'; import { parseMultivariantPlaylist } from '../../../media/hls/parse-multivariant'; -import type { AudioTrack, CanPlayTrack, MaybeResolvedPresentation } from '../../../media/types'; +import type { AudioTrack, CanPlayTrack, MaybeResolvedPresentation, MediaContainerData } from '../../../media/types'; import type { GetCdnId } from '../../../media/utils/cdn'; import { getResolvedSelectedTrackDuration } from '../../../media/utils/track-selection'; import type { SegmentLoaderActor } from '../../actors/dom/segment-loader'; @@ -21,11 +21,16 @@ import { import { deriveCdnPriority } from '../../behaviors/derive-cdn-priority'; import { endOfStream } from '../../behaviors/dom/end-of-stream'; import { loadAudioSegments } from '../../behaviors/dom/load-segments'; +import { relocationMessagePipelines } from '../../behaviors/dom/relocation-steps'; import { setupAudioBufferActors } from '../../behaviors/dom/setup-buffer-actors'; import { setupMediaSource } from '../../behaviors/dom/setup-mediasource'; import { trackCurrentTime } from '../../behaviors/dom/track-current-time'; import { trackLoadTriggers } from '../../behaviors/dom/track-load-triggers'; import { updateMediaSourceDuration } from '../../behaviors/dom/update-mediasource-duration'; +// Non-zero-PTS relocation (spike): remove this import, the composed reactor, the +// `audioMessagePipelines` finalConfig entry, the `mediaContainerData` state slot, +// and the `deriveStartMediaTime` config field to drop relocation from audio-only. +import { type DeriveStartMediaTime, establishStartMediaTime } from '../../behaviors/establish-start-media-time'; import { type ParsePresentation, resolvePresentation } from '../../behaviors/resolve-presentation'; import { resolveAudioTrack } from '../../behaviors/resolve-track'; import { type FailoverMonitorConfig, setupFailoverMonitor } from '../../behaviors/setup-failover-monitor'; @@ -47,6 +52,9 @@ export interface SimpleHlsAudioOnlyEngineState { presentation?: MaybeResolvedPresentation; preload?: 'auto' | 'metadata' | 'none'; selectedAudioTrackId?: string; + // Non-zero-PTS relocation (spike): transient per-track container data owned by + // `establishStartMediaTime`. Remove with the composed reactor. + mediaContainerData?: Record; /** * Consumer-driven constraint narrowing the audio candidate set. Sibling * of `userVideoTrackSelection` in the default engine. Partial-track @@ -119,6 +127,8 @@ export interface SimpleHlsAudioOnlyEngineConfig * Defaults to the URL origin; override to key on e.g. Mux's `cdn=` param. */ getCdnId?: GetCdnId; + /** Non-zero-PTS relocation (spike): the reduce seam (tier knob); defaults to per-track own. */ + deriveStartMediaTime?: DeriveStartMediaTime; } // ============================================================================ @@ -171,6 +181,9 @@ export function createHlsAudioOnlyEngine( canPlayTrack: config.canPlayTrack ?? canPlayTrack, resolveDuration: config.resolveDuration ?? getResolvedSelectedTrackDuration, parsePresentation: config.parsePresentation ?? parseMultivariantPlaylist, + // Non-zero-PTS relocation (spike): pair the audio loader with the relocation + // steps `establishStartMediaTime` derives from. Remove with the composed reactor. + audioMessagePipelines: relocationMessagePipelines, }; return createComposition( @@ -210,6 +223,13 @@ export function createHlsAudioOnlyEngine( // so the Firefox `mozHasAudio` registration ordering is moot here. setupMediaSource, updateMediaSourceDuration, + + // Non-zero-PTS relocation (spike): establishes per-track startMediaTime; + // MUST precede setupAudioBufferActors. Remove this line + the import + the + // finalConfig/state entries to drop relocation. (Selection is optional in the + // reactor, so it works with only audio in scope.) + establishStartMediaTime, + setupAudioBufferActors, // Playback tracking