Files
v10/internal/design/spf/features/source-replacement.md
T

13 KiB
Raw Blame History

status, date, definition
status date definition
implemented 2026-05-20 sketched

Source replacement

The engine's source-change capability: load a new source on an already-attached engine without recreating it. Driven by overwriting state.presentation with a new unresolved {url}; resolvePresentation routes the FSM back through 'resolving' and downstream behaviors tear down their per-source state via reactor state-exit. The cascade is load-bearing — every behavior that gates on isResolvedPresentation must honor the state-exit cleanup contract, or in-place replacement breaks silently.

The canonical adapter (SimpleHlsMediaMixin.src) takes a different path: destroy the engine and create a fresh one on every assignment. Both paths work; the in-place path is the load-bearing one for engine-internal reasoning, since the adapter's destroy path bypasses the cascade entirely.

This doc captures the capability surface, the cleanup contract new behaviors must honor, and the verification that pins the in-place path against regression.

Status

  • Composition: createSimpleHlsEngine (HLS VoD)
  • Definition depth: sketched — capability surface, cleanup contract, and validation test all in place
  • Cleanup contract (load-bearing): every behavior that gates on isResolvedPresentation MUST tear down cleanly via reactor state-exit when presentation un-resolves. Setup behaviors detach DOM resources / destroy actors / clear context slots; async-fetch behaviors bind AbortController to state-exit. New behaviors that join the engine must follow this pattern or the in-place source-replacement validation test (engine.test.ts → "cleanly replaces source in place via state.presentation overwrite") will fail.

Phases of complexity

Capability slices around the source-change contract. Each phase is a distinct engine behavior observable from outside.

Phase What Notes
Initial source load First source on a fresh engine: external write of state.presentation = { url } triggers resolve + full pipeline setup The unresolved → resolved transition that bootstraps everything
In-place source replacement Overwrite state.presentation with a new { url } while a previous source is resolved / playing. resolvePresentation routes back through 'resolving'; downstream behaviors tear down via reactor state-exit; new source resolves and plays — same engine instance Validated end-to-end. MediaSource + buffer actors are fresh instances; in-flight fetches aborted via state-bound AbortControllers
Source unset Set state.presentation to undefined. All presentation-gated behaviors transition to 'preconditions-unmet' and tear down. Engine is fresh-but-attached, ready for the next source The "no source" steady state; reachable from any resolved state
Destroy + recreate (canonical adapter path) SimpleHlsMediaMixin.src destroys the current engine and creates a fresh one on every assignment. Re-attaches the media element to the new engine Canonical consumer-side mechanism. Bypasses the in-place cascade entirely. Tested via adapter.test.ts:115150
Per-source-identity slot lifecycle loadActivated resets to false when source identity changes (URL or mediaElement); selected*TrackIds clear naturally on un-resolve (their pickers re-run against the new presentation); bandwidthState is intentionally preserved across source resets — sampling accumulates via the once-per-behavior createTrackedFetch ABR resume: bandwidth estimate carries over so the first segment of a new source picks an appropriate quality based on observed throughput

What's not implemented

  • Cross-codec source replacement — same-codec sources work (the segment-loader's time-aligned dedup handles it); cross-codec replacement would need changeType() handling. Folds into the [buffer-flushing] and [5.1-surround-selection] / [hevc-variant-selection] candidates.
  • Source-error recovery stateresolvePresentation currently surfaces fetch / parse failures via console.error (with a TODO in the code: "route to a state-error slot once one exists"). Consumers have no observable signal for "source failed to load."
  • Per-source bandwidthState reset opt-in — preservation is the baked-in policy (ABR resume). No opt-out exists for testing or fresh-session scenarios that want a clean estimator.
  • Concurrent pre-fetch / hand-off — replacement is teardown-then- rebuild; the engine doesn't support pre-warming the next source while the current one plays. Playlist / queue semantics are out of scope.

Implementation surface

Composition: packages/spf/src/playback/engines/hls/engine.ts — the source-replacement capability isn't owned by any single behavior; it emerges from resolvePresentation's 4-state FSM + the cleanup contract every presentation-gated behavior honors.

Orchestrator: resolvePresentation (packages/spf/src/playback/behaviors/resolve-presentation.ts)

State What
'preconditions-unmet' No presentation, or presentation has no URL
'idle' URL present, unresolved, preload gate unmet — waits for loadActivated or non-blocking preload
'resolving' URL present, unresolved, gate met. Entry starts fetch + binds AbortController to state-exit
'resolved' state.presentation holds a resolved Presentation

State transitions on state.presentation writes are the source- replacement mechanism: overwriting a resolved presentation with a new unresolved { url } routes the FSM 'resolved' → 'resolving' (assuming gate open), aborting any prior in-flight fetch and starting fresh.

Cleanup contract — behaviors that ride the resolved/unresolved cascade:

Behavior What it tears down on state-exit
setupMediaSource Aborts in-flight waitForMediaSourceOpen; detaches MediaSource from element; clears context.mediaSource
setupVideoBufferActors / setupAudioBufferActors Destroys SegmentLoaderActor then SourceBufferActor (reverse order); clears context.{video,audio}{BufferActor,SegmentLoaderActor}
setupTextTrackActors Destroys text-track actors; clears context slots
updateMediaSourceDuration Aborts in-flight waitForMediaSourceOpen + waitForSourceBuffersReady
endOfStream Aborts in-flight wait
resolveVideoTrack / resolveAudioTrack / resolveTextTrack Aborts in-flight playlist fetch
resolvePresentation Aborts in-flight manifest fetch via state-exit on the 'resolving' entry's AbortController return

Per-source-identity-aware behaviors (transition independently on source change, not via the resolved/unresolved cascade):

Behavior Per-source reset
trackLoadTriggers loadActivated reset to false on (mediaElement, presentation.url) identity change
selectAudioTrack / selectTextTrack / switchVideoQuality Re-run pickers when presentation transitions to resolved with new content

Cross-source preservation:

Slot Why preserved
state.bandwidthState ABR resume — createTrackedFetch in setupVideoBufferActors is constructed once at behavior setup and accumulates EWMA across source resets
state.preload Engine-wide preference, not source-specific
state.currentTime DOM-side mirror via trackCurrentTime; resets on new presentation naturally (element currentTime resets on src change)

Multi-writer state.presentation pipeline. Three writer domains, non-overlapping aspects:

  • Adapter / external — writes initial unresolved { url } (and overwrites with new { url } for in-place replacement)
  • resolvePresentation — writes resolved Presentation with id, selectionSets, etc.
  • Per-track resolvers + calculatePresentationDurationresolveVideoTrack / resolveAudioTrack / resolveTextTrack patch resolved segment lists into their respective tracks; calculatePresentationDuration patches in duration. Each reads current value and writes a new one with their field added; none overwrites a field another owns.

Config surface

No engine-level config for source replacement itself. The config.parsePresentation required by resolvePresentation is the format-neutral parser hook (e.g., the HLS engine supplies parseMultivariantPlaylist); it doesn't affect source-replacement semantics — every replaced source runs through the same parser.

Verification

  • Unit tests:
    • packages/spf/src/playback/engines/hls/tests/engine.test.ts"cleanly replaces source in place via state.presentation overwrite" — end-to-end validation: mock fetch with two sources A and B, resolve source A through the full pipeline, capture identities of mediaSource / videoBufferActor / audioBufferActor, overwrite state.presentation with source B, verify B resolves and the captured identities differ from the new ones (proving teardown cascade ran)
    • packages/spf/src/playback/engines/hls/tests/adapter.test.ts"creates a new engine when src is set" / "destroys the old engine when src changes" / "re-attaches the media element to the new engine when src changes" — validates the canonical adapter destroy-recreate path
    • packages/spf/src/playback/behaviors/dom/tests/track-load-triggers.test.tsloadActivated per-source-identity reset coverage
  • Sandbox:
    • apps/sandbox/src/spf-segment-loading/ — exercises initial source load + manual rendition switching (in-track, not source change)
    • apps/sandbox/src/simple-hls-html/ / simple-hls-react/ — adapter integration; src reassignment hits the destroy-recreate path

Open questions

  • Error recovery surface. resolvePresentation currently uses console.error for fetch / parse failures and has a TODO(error- management) for a state-error slot. The shape of this slot — single error vs per-source — affects how consumers respond to "source failed to load."
  • Adapter rationale. The canonical adapter destroys + recreates the engine on every src change instead of using in-place replacement. The reasoning (stricter isolation? simpler reasoning? guarding against cleanup-cascade bugs?) isn't documented in the code or commit history. If the in-place path is the engine's load-bearing capability for internal reasoning, why doesn't the adapter use it?
  • Per-source bandwidthState reset opt-in. Preserving across sources is the right default for ABR resume, but a test / fresh- session escape hatch may earn its place when consumers start needing it.
  • preload-modes — gates resolvePresentation's 'idle''resolving' transition; per-source loadActivated reset (in trackLoadTriggers) is part of the source-identity-aware behavior surface this feature relies on.
  • mse-mms-pipelinesetupMediaSource is the canonical example of riding the resolver's resolved/unresolved lifecycle for cleanup; the feature doc calls this "structural" routing out explicitly.
  • buffer-management — segment-loader actors tear down via the same cascade; in-flight fetches aborted; bandwidth sampling preserved across sources via createTrackedFetch constructed once.
  • video-abrbandwidthState preservation policy lives here. Observable behavior: ABR resumes across source changes rather than re-bootstrapping from initialBandwidth.
  • subtitles — text-track actors and selection clear on source un-resolve via the cleanup cascade.
  • engine-adapter-integration (not yet documented, candidate)SimpleHlsMediaMixin's destroy-recreate path lives here. The adapter's choice to bypass in-place replacement is the feature-design decision to capture in that doc.

See also

  • presentation-modeling.md — architectural deep-dive on the format-neutral data shape, parser interface, and per-track resolution layer. The multi-writer state.presentation pipeline this feature characterizes is anchored in that doc's data model
  • clusters.md § Engine lifecycle — cross-cutting concerns around engine instantiation, source loading, and per-source identity resets (this feature + preload-modes)
  • packages/spf/docs/hls-engine.md — engine composition walkthrough (Stage 1 covers resolvePresentation)
  • conventions/behaviors.md — reactor state-exit cleanup conventions
  • conventions/signals.md — multi-writer slot conventions (state.presentation is the canonical pipeline- pattern example)