docs(spf): HLS engine composition walkthrough + doc-driven cleanups#1512
docs(spf): HLS engine composition walkthrough + doc-driven cleanups#1512cjpillsbury merged 36 commits intomainfrom
Conversation
WIP scaffold for document-driven development of the HLS playback engine composition. Bundles the full ancestry of that work onto the fundamentals baseline so future rebases against main stay clean.
First step in the src/ reorganization ahead of the HLS engine doc.
Kind-separation only — no code changes, no abstraction changes, no
cross-concern moves. Purpose is to make the piles visible so the
later seam-extraction work (substrate, transport, engines) has
something obvious to pull from.
What moved:
src/dom/features/ → split into:
- src/dom/behaviors/ — the {state, owners[, config]} → cleanup
composition integrations (end-of-stream,
load-segments, load-text-track-cues,
setup-mediasource, setup-sourcebuffer,
sync-text-tracks, track-current-time,
track-playback-initiated,
track-playback-rate, update-duration)
- src/dom/actors/ — createXActor factories returning message/
transition actors (segment-loader,
text-track-segment-loader, text-tracks)
src/media/features/ → split into:
- src/media/behaviors/ — DOM-free composition integrations
(calculate-presentation-duration,
quality-switching, resolve-presentation,
resolve-track, sync-preload-attribute)
- src/media/primitives/ — pure selector functions (select-tracks)
The -actor suffix drops from filenames now that the actors/ directory
carries the kind. Export names keep the suffix
(createSegmentLoaderActor, TextTracksActor, etc.) so call sites
remain self-describing.
No intentional behavior changes. Build, tests, typecheck, lint,
workspace consistency — all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds packages/spf/src/core/tsconfig.json as a separate TS project with lib ["ES2022", "WebWorker"] — no DOM. Matches the pattern already used in packages/core and packages/store. The package-level tsconfig stays permissive and still covers src/core (double coverage, same as how packages/spf/src/dom/tsconfig.json already overlaps with the package config). The subtree config is strict, so `pnpm typecheck` — which runs `tsgo --build` against the root solution — now fails if src/core/ references DOM-only globals. Verified: a deliberate `HTMLElement` reference in core produces `error TS2304: Cannot find name 'HTMLElement'`. Tests under src/core/**/tests/** are excluded from this subproject. They use HTMLElement as example owner types (intentionally, to match the fundamentals-doc illustrations), and rewriting them to be DOM-free is a separate concern from enforcing purity on the source code. They still typecheck through the package-level config. WebWorker lib (not just ES2022) is required because core uses AbortSignal, AbortController, setTimeout — universal primitives that live in the DOM lib but are also in the worker lib; WebWorker gives us them without pulling in the DOM. Next step in the sequence: introduce a media subproject tsconfig and fix the media → dom/network layering violation surfaced along the way (resolve-presentation.ts and resolve-track.ts reach into dom/network for fetch, which itself is universal JS and should not live in dom/). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The composition type-tests previously used HTMLElement, HTMLVideoElement, and HTMLCanvasElement as example owner types. Those are DOM-only, so the prior commit had to exclude **/tests/** from the core subproject to keep typecheck green. Replace them with minimal DOM-free stand-ins that preserve the same type relationships the tests exercise: - Surface — base "writable surface" with textContent - VideoSurface — extends Surface (covariance test) - CanvasSurface — sibling of VideoSurface (incompatibility test) This drops the tests-exclude from packages/spf/src/core/tsconfig.json, so src/core is now fully DOM-enforced — source code and tests alike. No runtime behavior change; only the types used in example behaviors and composition type-assertion tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fetch, Request, Response, and ReadableStream are universal JavaScript
APIs — available in browsers, Node 18+, Deno, and workers. They live
under src/dom/ only for historical reasons. Moving them out:
- Breaks a layering violation surfaced by the core tsconfig enforcement:
src/media/behaviors/resolve-presentation.ts and resolve-track.ts
reached into src/dom/network/fetch, which was a media → dom import
that couldn't be modeled cleanly with project references (dom already
depends on media, adding the reverse edge creates a cycle).
- Gives a natural home for future transport work. When DASH and MoQ
introduce WebTransport or custom transports, they can land as
siblings under src/network/ without reshaping dom/.
Changes:
- src/dom/network/{fetch,chunked-stream-iterable}.ts → src/network/
- tests move alongside
- packages/spf/vitest.config.ts: add a network project (Node env;
these tests don't need a browser)
- import paths updated in all.ts, dom/behaviors/load-segments.ts,
media/behaviors/resolve-{presentation,track}.ts, and the fetch.ts
file's own media/types import
No runtime change. Build, tests (707), typecheck, lint, workspace
check all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds subtree tsconfigs for src/media and src/network, both with
lib ["ES2022", "WebWorker"] (no DOM). Matches the pattern core
already uses.
Breaking the media ↔ network cycle:
Attempting to set up media as its own project surfaced a real
cyclic project reference — network/fetch.ts imported
AddressableObject from media/types, while media/behaviors reached
the other way into network/fetch. Project references can't model
cycles, and structurally this was also a layering mistake: network
is the lower layer, so it shouldn't depend on media.
Fix: network defines its own local Resource interface (url plus
optional byteRange — a tiny universal shape). Media's
AddressableObject is structurally compatible, so no media-side
callers change. network/ is now standalone — no references out.
Fixing the one test-level DOM leak:
src/media/behaviors/tests/sync-preload-attribute.test.ts typed its
mock mediaElement as HTMLMediaElement. The behavior itself takes
MediaElementLike (DOM-free media type); the test now matches.
Other files I had flagged as potential media DOM leaks earlier
turned out to be false positives from regex-based auditing — the
SourceBuffer / HTMLMediaElement / TextTrack references in
media/buffer/forward-buffer.ts, media/types/index.ts,
media/hls/parse-*.ts, etc. were all in comments or already in type
positions that get erased. Verified by deliberately injecting a
real DOM reference and watching it fail.
Resulting project graph (rooted at root tsconfig):
utils → core → network
↘
media
↓
dom (plus package tsconfig as the permissive umbrella)
Each arrow is a project reference; no cycles. core, network, media
are all DOM-free enforced. dom is the only subtree with DOM lib.
All green: typecheck, build, 707 tests, workspace check, lint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 1 of decoupling the text-track actors from DOM. The actor types had no semantic DOM dependency — the only DOM leak in the type graph was AddCuesMessage.cues being typed as VTTCue[]. Rewrite as generic over a host-agnostic Cue shape; the DOM factory instantiates it with VTTCue. Changes: - media/types: add Cue interface (startTime, endTime, text). VTTCue structurally satisfies this; non-DOM hosts can too. - media/actors/text-tracks.ts (new, type-only): TextTracksActor<C>, TextTracksActorMessage<C>, AddCuesMessage<C>, TextTracksActorContext, CueSegmentMeta. Generic over C extends Cue, default Cue. - media/actors/text-track-segment-loader.ts (new, type-only): TextTrackSegmentLoaderActor, TextTrackSegmentLoaderMessage. - dom/actors/text-tracks.ts: drops type declarations, re-exports from media/actors for existing consumers, factory now returns TextTracksActor<VTTCue>. isDuplicateCue's existing[] now typed Cue[]. - dom/actors/text-track-segment-loader.ts: drops type declarations, re-exports from media/actors, factory takes TextTracksActor<VTTCue>. No behavior change. Build, tests (707), typecheck, lint, workspace check — all green. Next (Phase 2): parameterize createTextTrackSegmentLoaderActor over the cue parser so the actor's runtime is also DOM-free, and move the implementation out of dom/actors/. Planned to use the composition config channel for injecting the DOM-bound parser at engine assembly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 of decoupling text-track actors from DOM. The factory's runtime had exactly one DOM dependency — the `parseVttSegment` import — and the rest of its logic was already host-agnostic. Inject the parser instead of importing it directly, and move the implementation to media/actors/. Changes: - media/actors/text-track-segment-loader.ts: now carries the factory implementation, generic over C extends Cue. Signature adds a `parseSegment: (url: string) => Promise<C[]>` parameter (typed as the new CueParser<C>). All core / media deps; no DOM imports. - dom/actors/text-track-segment-loader.ts: becomes a thin 25-line wrapper that binds the browser's parseVttSegment into the media-level factory. Re-exports the types so existing call sites don't change. - Error message: generalized from "Failed to load VTT segment" to "Failed to load text-track segment" since the injected parser need not be a VTT parser. Two test assertions updated to match. Behavior is unchanged from a caller's perspective. Non-DOM engines (future DASH, MoQ) can build the actor directly against the media-level factory with their own cue parser — VTT isn't required structurally. Next (Phase 3): move load-text-track-cues behavior to media/behaviors/ with its DOM deps (parseSegment, actor factories, textTrack iteration) declared via composition config. That's the last coupling between this feature and the dom/ tree. Build (161 files), tests (707), typecheck, workspace check, lint — all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3 of decoupling text-track orchestration from DOM. The prior
single behavior in dom/behaviors/load-text-track-cues.ts did two jobs:
orchestrate state transitions (DOM-free), and manage actor lifecycle
tied to an HTMLMediaElement (DOM-bound). Splitting these concerns
lets the orchestration live host-agnostic in media/behaviors/.
Changes:
- media/types: add MediaElementWithTextTracks — MediaElementLike plus
an iterable textTracks of {id: string}. HTMLMediaElement satisfies
it structurally.
- media/behaviors/load-text-track-cues.ts (new): host-agnostic
orchestrator. Reads actors from owners; never creates them. State
machine drops 'setting-up' — actor presence is now just another
precondition. DOM-free enforced by the media subproject tsconfig.
- dom/behaviors/provide-text-track-actors.ts (new): DOM-side provider
that creates/destroys TextTracksActor and the segment-loader actor
on mediaElement mount/unmount, writing them to owners. Replaces
the actor lifecycle logic previously buried in the orchestrator.
- dom/behaviors/load-text-track-cues.ts: deleted. Owner management
moves to the provider; orchestration moves to media.
- hls-engine: imports loadTextTrackCues from media/behaviors, and
adds provideTextTrackActors to the composition list alongside it.
- dom/index.ts: replaces the loadTextTrackCues export with
provideTextTrackActors (the loader is now re-exported by media).
- Tests updated: setup composes both provider and loader, types the
shared owners signal as the intersection so it satisfies each
behavior's shape (HTMLMediaElement narrows MediaElementWithTextTracks).
Benefits:
- The orchestration can now be reused by non-DOM hosts (worker,
test fake, future MoQ engine) that provide their own actor factories.
- Actor lifecycle is explicit as its own behavior — easier to reason
about and to substitute in alternate engines.
- media/behaviors/ now has a real occupant beyond the pure-function
behaviors; validates the directory earning its name.
All green: build (163 files), tests (707), typecheck, workspace
check, lint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eliminates the dom/actors/text-track-segment-loader.ts wrapper that
was currying parseVttSegment into the media-level factory. The
provider behavior now declares `config.parseSegment` and receives
the parser through the composition config channel — the pattern
we'd agreed would be right for DOM-bound dependencies.
Changes:
- provideTextTrackActors: signature grows a `config: { parseSegment:
CueParser<VTTCue> }` requirement. Imports createTextTrackSegmentLoaderActor
from media/actors/ directly (no wrapper) and threads the parser through.
- hls-engine: adds a provideDomTextTrackActors wrapper that closes
over parseVttSegment, matching the existing pattern for config-
threading wrappers (selectVideoTrackFromConfig, etc.). Swaps the
engine's TextTrackSegmentLoaderActor type import to the media/actors
path (the dom wrapper no longer re-exports it).
- dom/actors/text-track-segment-loader.ts: deleted. Consumers import
from media/actors directly and pass the parser explicitly.
- Test updates: the actor's direct test imports the factory from
media/actors and passes parseVttSegment to each call site. The
load-text-track-cues test supplies parseSegment in provider config.
Net: one fewer file, one less indirection, and the DOM/host seam is
now crisply at the composition-config layer. The parser doesn't leak
into HlsPlaybackEngineConfig — it's closed over by the engine's
internal wrapper, same as how other config-threading wrappers work.
All green: build (160 files), tests (707), typecheck, workspace
check, lint.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous implementation held three pieces of coupled state — a lastMediaElement tracking variable, a teardown closure that read actors back out of owners via untrack(), and a final disposer that ran the effect cleanup and teardown separately. All three exist because the teardown needed to reach actors it didn't own. With `effect()`'s cleanup-return convention (the callback returns its own teardown), actors live in the effect's closure and cleanup destroys them directly. No tracking var, no untrack, no external teardown. The only preservation that required care: subscribing the effect to a `computed(() => owners.get().mediaElement)` rather than to `owners` directly. Without the projection, writing the actor slots back to owners from inside the effect would re-trigger it — destroying the actors it just created in a feedback loop. The computed's equality short-circuits subsequent owner writes that don't change mediaElement. 15 insertions, 22 deletions. No behavior change (tests pass without modification). Semantics equivalent across all four transitions (set, change, unset, final destroy). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d names
The text-track segment fn does both fetch and parse — "resolver" captures
that more accurately than "parser". The config key especially was
misleading: `parseSegment` at the composition level doesn't convey that
it's for text-track segments (vs. video or audio segments), and doesn't
convey the fetch step either.
Renames, in order of surface:
- Concrete DOM impl:
- parseVttSegment → resolveVttSegment
- destroyVttParser → destroyVttResolver
- parse-vtt-segment.ts → resolve-vtt-segment.ts (file + test file)
- Generic type (media/actors/text-track-segment-loader.ts):
- CueParser<C> → TextTrackSegmentResolver<C>
Domain-anchored rather than output-anchored; matches the config key.
- Factory parameter (internal to the actor):
- parseSegment → resolveSegment
Local scope inside the text-track-segment-loader — "segment" is
unambiguous here.
- Provider config key (engine-level surface):
- parseSegment → resolveTextTrackSegment
Self-describing at the composition config layer where multiple
segment kinds coexist.
Module-level dummy video cleanup is called destroyVttResolver now to
pair with the renamed function; JSDoc on the type explains the
"resolve" framing (fetch + parse into domain model).
Build, tests (707), typecheck, workspace, lint — all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…hls/
The engine and its DOM adapter were buried under src/dom/playback-engine/,
which (a) conflated a cross-cutting composition (engine) with a DOM-bound
adapter (SpfMedia) under one directory, and (b) misnamed the subpath
(singular "playback-engine" won't age well once DASH/MoQ land, and "engine"
as a subpath category isn't where the adapter belongs).
New structure, anticipating multiple engines + possibly multiple HLS
compositions per engine:
src/playback-engines/
└── hls/
├── engine.ts (was src/dom/playback-engine/hls-engine.ts)
├── adapter.ts (was src/dom/playback-engine/adapter.ts)
├── index.ts
├── tests/
└── tsconfig.json — own subtree project, DOM lib
SpfMedia stays alongside the HLS engine for now because the current
adapter is coupled to this specific composition in practice (even though
structurally it just needs any composition with the right owners shape).
Revisit when a second engine lands and the engine-agnostic seam becomes
concrete.
BREAKING:
- @videojs/spf/playback-engine → @videojs/spf/hls (subpath renamed)
- @videojs/spf/dom no longer re-exports SpfMedia/SpfMediaMixin/
spfMediaDefaultProps/SpfMediaAPI/SpfMediaProps — import them from
@videojs/spf/hls instead.
- External consumers updated: packages/core/src/dom/media/simple-hls,
packages/react/src/media/simple-hls-video, and the sandbox template
for spf-segment-loading.
Scaffolding:
- Root tsconfig gains { path: "packages/spf/src/playback-engines/hls" }.
- packages/spf/package.json exports renamed.
- tsdown.config.ts entry "dom/playback-engine" → "hls".
- vitest.config.ts adds a "playback-engines" project (browser mode).
No behavior change. Build (160 files), 707 tests, typecheck, workspace
check, lint — all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per the current state: the HLS engine composition and its DOM adapter are specifically for "simple HLS" (a minimal set of HLS behaviors over MSE). Naming them as if they were THE HLS engine and THE media adapter papered over that. This rename scopes the names to what they actually are, leaving room for variants (low-latency, DRM-specific, etc.) as siblings in the same hls/ directory. Renames (in packages/spf/src/playback-engines/hls/): Engine (engine.ts): - createHlsPlaybackEngine → createSimpleHlsEngine - HlsPlaybackEngineState → SimpleHlsEngineState - HlsPlaybackEngineOwners → SimpleHlsEngineOwners - HlsPlaybackEngineConfig → SimpleHlsEngineConfig Adapter (adapter.ts): - SpfMedia (class) → SimpleHlsMediaElement - SpfMediaMixin → SimpleHlsMediaMixin - SpfMediaAPI → SimpleHlsMediaAPI - SpfMediaProps → SimpleHlsMediaProps - spfMediaDefaultProps → simpleHlsMediaDefaultProps The class was renamed to SimpleHlsMediaElement to avoid colliding with core's SimpleHlsMedia (custom element that extends this mixin). External consumers updated: - packages/core/src/dom/media/simple-hls/index.ts (uses mixin) - packages/react/src/media/simple-hls-video/index.tsx (uses props + defaults) - apps/sandbox/templates/spf-segment-loading/main.ts (uses engine) Also updated one JSDoc comment in media/behaviors/sync-preload-attribute.ts and one code example in the WIP spf explainer doc. No behavior change. Build, tests (707), typecheck, workspace check, lint — all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First half of the 4a/4b organizational pivot. The 4b "SPF-framework integration" code was living under src/media/ (named as if it were CML-like), conflating the two buckets. This move separates them. Changes: - src/media/behaviors/ → src/behaviors/ (calculate-presentation-duration, load-text-track-cues, quality-switching, resolve-presentation, resolve-track, sync-preload-attribute — all DOM-free SPF behaviors) - src/media/actors/ → src/behaviors/actors/ (text-tracks and text-track-segment-loader — DOM-free actor types and the generic injected-parser factory; actors are nested under behaviors because they're building blocks that serve behaviors, not peers) - All tests moved alongside their source files. Scaffolding: - New subtree tsconfigs at src/behaviors/ and src/behaviors/actors/, both DOM-free (lib ES2022 + WebWorker), referencing core / network / media / utils. - Root tsconfig gains both references. - playback-engines/hls/tsconfig.json adds references to behaviors/ and behaviors/actors/. - vitest.config.ts adds a 'behaviors' project (node env, excluding the not-yet-created behaviors/dom/ subtrees). Import paths swept across src/. No behavior change. Next (second half): eliminate src/dom/ entirely; redistribute src/dom/media/ → src/media/dom/, src/dom/text/ → src/media/dom/text/, src/dom/behaviors/ → src/behaviors/dom/, src/dom/actors/ → src/behaviors/actors/dom/ (and pull source-buffer-actor from dom/media/ to behaviors/actors/dom/ since it's a SPF actor, not a CML-like MSE utility). All green: build (160 files), tests (707), typecheck, workspace check, lint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Second half of the 4a/4b pivot. The previous commit promoted SPF
behaviors/actors out of src/media/; this one splits src/dom/ along
the same axis: 4a CML-like DOM-bound code → src/media/dom/, 4b
SPF-integration DOM-bound code → src/behaviors/{dom,actors/dom}/.
"DOM vs. not" becomes a cross-cutting axis encoded per-subtree with a
`dom/` subfolder marker — not a top-level bucket. Enforcement via
sub-tsconfigs at each level; a file's closest `dom/` path segment
signals whether it can touch the DOM, absent means DOM-free.
Moves:
src/dom/behaviors/ → src/behaviors/dom/
(end-of-stream, load-segments, provide-text-track-actors,
setup-mediasource, setup-sourcebuffer, sync-text-tracks,
track-current-time, track-playback-initiated,
track-playback-rate, update-duration; plus tests)
src/dom/actors/ → src/behaviors/actors/dom/
(segment-loader, text-tracks; plus tests)
src/dom/media/ → split:
append-segment.ts → src/media/dom/mse/ (4a, CML-like MSE)
buffer-flusher.ts → src/media/dom/mse/ (4a)
mediasource-setup.ts → src/media/dom/mse/ (4a)
mediasource.d.ts → src/media/dom/mse/ (4a ambient)
source-buffer-actor.ts → src/behaviors/actors/dom/source-buffer.ts
(this is a SPF actor, not a CML-like utility;
drop the '-actor' suffix since dir carries it)
src/dom/text/ → src/media/dom/text/ (4a VTT resolver)
src/dom/tests/ → src/behaviors/dom/tests/
(end-of-stream.test.ts renamed to end-of-stream-partial-resolution.test.ts
to avoid name collision with the behavior's own test)
src/dom/index.ts → src/dom.ts (flat file, aggregation entry for
@videojs/spf/dom subpath)
Scaffolding:
- New subtree tsconfigs: src/media/dom/, src/behaviors/dom/,
src/behaviors/actors/dom/ (all DOM-permissive, referencing siblings).
- src/media/tsconfig.json and src/behaviors/tsconfig.json add excludes
to cleanly partition their dom/ subtrees.
- Old src/dom/tsconfig.json deleted.
- Root tsconfig references updated: remove packages/spf/src/dom; add
packages/spf/src/{media/dom, behaviors/dom, behaviors/actors/dom}.
- playback-engines/hls/tsconfig.json references updated.
- tsdown.config.ts entry: 'dom/index' → 'dom.ts'.
- vitest.config.ts: 'dom' project now matches
src/**/dom/**/*.test.ts across media, behaviors, and actors.
Extensive relative-path sweeps inside moved files (many went from
depth 3 → depth 4 or depth 4 → depth 5 in the tree). Biome auto-
organized imports across 22 files.
No public API change. No test behavior change.
Build (160 files), tests (707), typecheck, workspace check, lint
— all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ayback/
Move src/{behaviors, behaviors/actors, playback-engines/hls} into a single
src/playback/{behaviors, actors, engines/hls} umbrella, making "playback" the
explicit domain instead of a generic top-level "behaviors/" name. Behaviors and
actors are 100% playback-specific in practice, so co-locating them with the
engines that consume them clarifies the conceptual model that the explainer
docs describe.
Public exports unchanged: @videojs/spf, @videojs/spf/dom, @videojs/spf/hls all
keep their current entry shapes. External monorepo consumers (packages/core,
packages/react, apps/sandbox) are unaffected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…imitive Rewrites observeMediaSourceReadyState (signal-returning) as onMediaSourceReadyStateChange (callback-based) so it stays a pure DOM primitive in media/dom/mse/. Drops the only signal/effect dependency from that file; the signal binding now happens at the call site in playback/behaviors/dom/setup-mediasource.ts where signals are allowed. This is the first of three media-→-core boundary fixes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Splits packages/spf/src/media/primitives/select-tracks.ts into pure logic (stays in media/primitives/) and signal-based orchestrations (selectVideoTrack, selectAudioTrack, selectTextTrack — moved to playback/behaviors/select-tracks.ts). Drops core/signals dependency from media/primitives/. Tests split correspondingly. This is the second of three media-→-core boundary fixes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Promotes the generateId helper out of @videojs/spf/core/utils into @videojs/utils/string where it belongs as a generic, framework-agnostic helper. Updates both consumers (core/tasks/task.ts and media/hls/parse-multivariant.ts) to import from the new home. This is the third of three media-→-core boundary fixes; @videojs/spf/media no longer imports from @videojs/spf/core anywhere. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After the three media-→-core fixes landed, neither media/ nor media/dom/ imports anything from core/ or network/. Removing the unused project references from their tsconfigs structurally enforces the boundary: future violations will surface at typecheck time rather than rely on review. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Documents the four-bucket layout (core/media/network/playback) and the dependency rules that govern them, noting which rules are structurally enforced via tsconfig references and lib settings vs. which rely on review. Aimed at AI agents and contributors deciding where to put new code.
Renames the behavior so it slots into the existing setup* family
(setupMediaSource, setupSourceBuffers) — all three create stateful
resources and publish them onto owners. The provide* prefix was the
only verb of its kind in playback/behaviors/dom/ and stood out as
inconsistent.
Also drops the orphaned .d.ts/.d.ts.map for the renamed module
(stale build artifacts that had been committed in error).
Touches:
- packages/spf/src/playback/behaviors/dom/{provide,setup}-text-track-actors.ts
- packages/spf/src/dom.ts (re-export)
- packages/spf/src/playback/engines/hls/engine.ts (import + local wrapper)
- packages/spf/src/playback/behaviors/dom/tests/load-text-track-cues.test.ts
- packages/spf/src/playback/behaviors/load-text-track-cues.ts (comment)
Public surface: @videojs/spf/dom now exports setupTextTrackActors
instead of provideTextTrackActors.
22 .d.ts/.d.ts.map files were checked into src/ in error — they're compiler-generated declarations (sourceMappingURL footer, export declare syntax) that should land in dist/types/, not alongside the source. Deleting them removes the "two files for one module" confusion when walking the source tree. Preserved: src/globals.d.ts and src/media/dom/mse/mediasource.d.ts — both hand-written ambient declarations (declare const, declare global).
Initial draft of the HLS engine composition doc. Covers the engine at a glance (full createSimpleHlsEngine composition list), the state/owners/ config split, and stage 1 (syncPreloadAttribute, trackPlaybackInitiated, resolvePresentation). Aligned with fundamentals.md terminology: "resources" for things in owners, channels for state/owners, config as static. Substitutes "imperative interfaces" for the "behavior" overload to avoid clashing with the technical SPF term. Includes a "Friction surfaced by writing this doc" section that captures awkward bits encountered during drafting (presentation?: any, the trailing destroyVttResolver call, casts on initial values, signal slot on owners). These are candidates for doc-driven cleanup as the draft expands.
…lue casts
Two doc-driven cleanups surfaced while writing hls-engine.md.
mediaSourceReadyState was published as a ReadonlySignal slot on owners
so behaviors could observe it reactively. But owners is for resources —
values with identity and imperative interfaces — and a signal is data.
Move it to state as a plain MediaSource['readyState'] string; setupMediaSource
writes via update(state, {...}) on each event. Consumers (update-duration,
end-of-stream) read state.mediaSourceReadyState directly.
Also drops the unnecessary `as SimpleHlsEngineState` / `as SimpleHlsEngineOwners`
casts on createSimpleHlsEngine's initial values — the interfaces are
all-optional, the casts were noise.
Tests in setup-mediasource, end-of-stream, and update-duration updated to
match the new shape; helpers now default mediaSourceReadyState to 'open'
to mirror what setupMediaSource produces in production.
hls-engine.md updated to reflect the new shapes; the friction list moves
both items to a "Resolved during drafting" subsection so the trail of
doc-driven changes stays visible.
…ition
The trailing `() => destroyVttResolver()` was running at engine SETUP
(not destroy), and the dummy-<video> singleton it tries to free is lazy
— so the call did nothing. Removing it leaves the composition list
uniform (every entry is a behavior with deps) and surfaces the real
missing concept: per-engine VTT resolver lifecycle. Singleton callers
who really need cleanup can still invoke destroyVttResolver from
@videojs/spf/dom.
A side effect: the engine's `initialState: { bandwidthState: ... }`
needs `as SimpleHlsEngineState` again. With every behavior now taking
typed deps, TypeScript's inference for the composition list collapses
the engine's state shape and drops fields. The cast is annotated with a
comment pointing at this and added back to the friction list as a
known issue to investigate in createComposition.
hls-engine.md updated: destroyVttResolver moves to "Resolved during
drafting", the cast moves back to active friction.
…tation
Presentation lifecycle was modelled with a single `presentation` slot that
transitioned from `{ url }` (caller's input) to `Presentation` (resolver's
output). Different behaviors needed different shapes for it, so the engine
declared `presentation?: any` to thread the needle through Signal
invariance. The any was a doc-driven smell — the doc had to wave its hands
at "shape that becomes another shape."
Split the slot into two:
- `presentationUrl?: string` — input, written by the caller (e.g. adapter
src setter). Watched by resolvePresentation.
- `presentation?: Presentation` — output, written by resolvePresentation
on parse success.
Every downstream behavior now declares `presentation?: Presentation` —
no union, no `any`. `UnresolvedPresentation` and `isUnresolved` go away.
`resolvePresentation`'s `deriveState` re-resolves when `presentationUrl`
changes after a previous resolution (so adapter-style URL swaps still
work in-engine, even though SimpleHlsMediaElement destroys/recreates).
Tests across `resolve-presentation`, `track-playback-initiated`,
`setup-mediasource`, `engine`, and `adapter` updated to use
`presentationUrl` as the input slot. The `as SimpleHlsEngineState` cast
on initialState stays — that's a separate TypeScript inference issue
in the composition-list inference, surfaced in the friction list.
hls-engine.md updated: state shape, the resolvePresentation walkthrough,
and the friction "Resolved during drafting" section.
When an engine aggregates many wrapper-style behaviors all sharing the
same Behavior<S, O, C> type, TypeScript's distributive intersection
inference over Behaviors[number] collapses partway through and drops
fields from the inferred state shape. For the HLS engine that meant
losing `bandwidthState` from the inferred type, requiring an
`as SimpleHlsEngineState` cast on initialState.
Add a second overload that takes explicit <S, O, C> type arguments:
createComposition<MyState, MyOwners, MyConfig>(behaviors, options);
This sidesteps the inference and uses the caller's declared shapes
directly. The inference-style overload remains the right call for
small or single-behavior compositions; engines with many behaviors
should use the explicit form.
Engine.ts now uses the explicit form, drops the cast, and goes back
to plain (deps: Deps) typing on the wrappers — no more local
EngineBehavior alias needed.
hls-engine.md gets a new "Two ways to call createComposition" section
explaining when each form fits, replacing the friction-list entry.
Track selection (stage 2) introduces the engine's wrapper pattern with both flavours visible in the composition list — media-type wrappers (close over a fixed `type`) and config-aware wrappers (close over the engine's config). Calls out why select-tracks is split between media/primitives (pure logic) and playback/behaviors (orchestration): the pure helpers stay reusable outside SPF. Track resolution (stage 3) reuses the same wrapper pattern across video/audio/text and explains how a selected id becomes a fully populated track via media-playlist parsing. Also moved the `createComposition` overload discussion away from between stages — it interrupted the walkthrough. Will land it as an appendix at the end of the doc once more stages are in.
Presentation duration (stage 4) is a tiny derivation behavior — small on its own but a useful pattern call-out for "derived state lives in a behavior so re-derivation is automatic when inputs change." MSE setup (stage 5) is the bigger stretch — three behaviors that bridge SPF state into the imperative MSE pipeline. Calls out: - The owners/state split for MediaSource (resource) vs readyState (data) - The "preconditions as signal reads, framework decides when" pattern illustrated by updateDuration's safety gates - The actor-wrapping of SourceBuffer, with raw-buffer and actor co-existing on owners for different consumers
Playback tracking and ABR (stage 6) — call out two patterns: state-as- bus (behaviors read/write a shared signal slot, never push or pull from each other) and the cascade of selection changes (writing selectedVideoTrackId re-runs everything downstream because effects tracked the reads). Segment loading (stage 7) is the engine's busiest behavior — fetches, plans the forward buffer, samples bandwidth, dispatches append messages to the SourceBufferActor. Calls out the same logic-vs-orchestration split we saw in track selection: the forward-buffer planner is a pure function in media/buffer/, the loader is the SPF-integrated wrapper. Also notes how serial appendBuffer ordering is the actor's job, not the loader's.
End of stream (stage 8) is the one behavior that has to coordinate across the whole pipeline. Calls out two SPF properties this makes visible: coordination is just reading (no callbacks, no event bus — N inputs not N² connections), and actor snapshots are signals too (transitions trigger re-evaluation just like state writes). Text tracks (stage 9) — different shape from MSE (cues land directly on <track> elements, no source buffers) but the same SPF patterns: actor- as-resource, orchestrator-as-behavior. The DOM-specific VTT resolver is wired in by an engine-level wrapper, parallel to how the engine wires config to its select-track behaviors. The "Two ways to call createComposition" section lands as an appendix at the end of the doc — it's an API note, not a stage in the walkthrough, so it shouldn't interrupt the narrative. Doc now covers all 9 stages plus the API note. ~380 lines.
…orts
The *FromConfig suffix read as if config was the source of selection
(e.g. "select a track from config") rather than what it actually means
(an engine-local wrapper that threads engine config to the underlying
behavior). The wrappers are really just the engine's adapted version
of each behavior.
Drop the suffix and shadow the imports — engine.ts now defines local
selectVideoTrack, selectAudioTrack, selectTextTrack, switchQuality, and
setupTextTrackActors that wrap underscore-aliased imports of the same
names. The composition list reads at the right level of abstraction
("the engine selects video tracks") and the wrappers vanish into glue.
hls-engine.md updated to reflect the rename in the composition list,
the Stage 2 wrapper-pattern explanation, and Stage 9's text-track setup.
… slot c8bab06 split state into `presentationUrl` (string input) and `presentation` (Presentation output) to avoid an `any` at the engine level. The `any` was real, but the fix introduced two slots for one concept: when resolved, `presentationUrl` and `presentation.url` carry the same value, the adapter's `src` getter has to read the non-semantic slot, and resolvePresentation grew explicit URL-change-after-resolved logic. Restore the single-slot model. Address the `any` smell at the behavior level instead: every behavior interface and the engine state now declares `presentation?: MaybeResolvedPresentation` (an intersection: `AddressableObject & Partial<Omit<Presentation, keyof AddressableObject>>`). All sides agree on the type, so Signal invariance no longer forces an `any` bridge. `Presentation` itself stays strict — parser output and resolved consumers are unaffected. A new `isResolvedPresentation` guard in `media/types` narrows `MaybeResolvedPresentation` to `Presentation` for behaviors that need resolved fields. Most existing access sites already used `?.` chains and required no change. URL changes after resolution work naturally: writing `presentation: { url: 'new' }` overwrites the slot with an unresolved value; deriveState sees no `id` and transitions through 'resolving'. The dedicated re-resolution branch from c8bab06 is gone. Net diff is similar in size to c8bab06; the win is conceptual: one slot, one type, presentation lifecycle in a single place. This also unbreaks the `apps/sandbox/templates/spf-segment-loading` harness, which was silently broken by c8bab06 — it kept writing `{ presentation: { url: src } }` against the strict-typed slot while resolvePresentation watched the new `presentationUrl` slot. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
✅ Deploy Preview for vjs10-site ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
📦 Bundle Size Report🎨 @videojs/html — no changesPresets (7)
Media (8)
Players (5)
Skins (30)
UI Components (33)
Sizes are marginal over the root entry point. ⚛️ @videojs/react — no changesPresets (7)
Media (7)
Skins (27)
UI Components (26)
Sizes are marginal over the root entry point. 🧩 @videojs/core — no changesEntries (9)
🏷️ @videojs/element — no changesEntries (2)
📦 @videojs/store — no changesEntries (3)
🔧 @videojs/utils — no changesEntries (10)
📦 @videojs/spf
Entries (3)
ℹ️ How to interpretAll sizes are standalone totals (minified + brotli).
Run |
The guard previously narrowed to `Presentation` based solely on
`'id' in presentation`, but `Presentation` also requires
`selectionSets`. A `{ url, id }` value with no `selectionSets`
would slip through the guard and crash downstream behaviors
(`resolveTrack`, `selectTextTrack`, the resolvePresentation FSM)
when they accessed `selectionSets`.
Tighten the guard to check both fields by value (not just `in`,
which can't distinguish a present-but-undefined property). Add
guard tests covering the partial-value cases plus the empty
`selectionSets: []` case (a valid resolved manifest with no
playable tracks, which must still narrow to `Presentation`).
Surfaced by Cursor Bugbot on #1512.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 174052e. Configure here.
| if (!!mediaSourceSignal.get() || mediaSourceReadyState.get() !== 'open') return; | ||
| owners.set(Object.assign({}, owners.get(), { mediaSource, mediaSourceReadyState }) as O); | ||
| if (!!mediaSourceSignal.get() || state.get().mediaSourceReadyState !== 'open') return; | ||
| owners.set(Object.assign({}, owners.get(), { mediaSource }) as O); |
There was a problem hiding this comment.
Ready state can cross streams
Medium Severity
mediaSourceReadyState is now a shared state slot, so an old MediaSource listener can mark the current setup as open. A detach/reattach before sourceopen can publish a new mediaSource before it is actually open, causing downstream addSourceBuffer() setup to fail.
Reviewed by Cursor Bugbot for commit 174052e. Configure here.
|
Local sandbox smoke testing and local run of e2e (all) look good. This is an intermediary step for SPF architecture hardening (pre-onsite discussions/followups). |


Summary
packages/spf/docs/hls-engine.md— a stage-by-stage walkthrough of how the HLS engine in@videojs/spf/hlscomposes behaviors, actors, and reactors. Reference implementation for any future SPF playback engine. Covers state/owners/config, stages 1–9, and acreateCompositionappendix.packages/spf/src/CLAUDE.mddocumenting thecore//media//network//playback/dependency boundaries enforced by per-subtree tsconfigs.Doc-driven cleanups
Structural reorg:
features/intobehaviors/,actors/,primitives/by role.core/,media/,network/via per-subtree tsconfiglib.src/network.src/dom/, redistribute by role.playback/.SimpleHlsMediaElementtosrc/playback/engines/hls/.select-tracksorchestrations toplayback/behaviors/.Behavior + primitive cleanups:
onMediaSourceReadyStateChangea callback-based primitive; bind to a state signal at the call site rather than reaching intocore/frommedia/dom.mediaSourceReadyStateonto state (drop initial-value casts).media/actors.parseVttSegment→resolveVttSegment,provideTextTrackActors→setupTextTrackActors.destroyVttResolverline from the HLS composition.Engine + composition surface:
*FromConfigsuffix on engine wrappers; shadow imports inengine.ts.createCompositionfor engines that aggregate many wrapper-style behaviors (works around distributive intersection inference dropping types).presentationUrlback into a singlepresentationslot. A prior split intopresentationUrl(input) +presentation(output) eliminated ananyat the engine but introduced two slots for one concept and silently broke the sandbox harness. NewMaybeResolvedPresentationtype +isResolvedPresentationguard let every behavior agree on the same shape — Signal invariance is satisfied withoutany, andPresentationstays strict for parser/resolved consumers.Other:
generateIdto@videojs/utils/string.core/networkreferences frommedia/tsconfigs (boundary enforcement)..d.ts/.d.ts.mapartifacts.Breaking changes
Three
refactor(spf)!:commits in the series rename or relocate exports under@videojs/spf/hlsand the engine subtree. Consumers in this repo (packages/html, sandbox templates) are updated in the same series.Test plan
pnpm -F @videojs/spf buildcleanpnpm typecheck— no SPF errors (pre-existing react/html errors unrelated)pnpm -F @videojs/spf test— 699 passing, 12 skipped, 0 failing, no type errorspnpm check:workspace— 6/6 passapps/sandbox/templates/spf-segment-loadingruns end-to-end against the restored single-slot API🤖 Generated with Claude Code
Note
Medium Risk
Medium risk due to public export-path changes (
@videojs/spf/playback-engine→@videojs/spf/hls), behavior/DOM API reshuffling, and a newcreateCompositionoverload that could affect type inference for downstream engine assemblies.Overview
Adds new SPF documentation (
docs/explainer.md,docs/hls-engine.md) plus contributor guidance (src/CLAUDE.md).Refactors the public surface and module layout: replaces the
./playback-engineexport with./hls, introduces a newsrc/dom.tsaggregate, and moves/renames several DOM utilities (e.g.observeMediaSourceReadyState→onMediaSourceReadyStateChange,parseVttSegment→resolveVttSegment, and text-track actor setup split intosetupTextTrackActors+ host-agnostic loader).Strengthens type/architecture boundaries by adding per-subtree tsconfigs (
core/,media/,network/, etc.), making the network layer domain-agnostic (Resource), introducingMaybeResolvedPresentation+isResolvedPresentation, and adding an explicit-typed overload tocreateComposition; updates sandbox and React/Core wrappers to import the newSimpleHlsengine/media props from@videojs/spf/hls.Reviewed by Cursor Bugbot for commit 174052e. Bugbot is set up for automated code reviews on this repo. Configure here.