mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
refactor(spf): make relocation-pipelines a DOM-free primitive
Completes the loader-pipeline extraction: relocation-pipelines moves from behaviors/dom to primitives/ root, fully DOM-free, so it's a genuine primitive rather than a stray non-behavior parked under behaviors/. Two decouplings make that honest: - Cue: the text steps are now generic over `C extends Cue` instead of naming the DOM `VTTCue`. Relocation only mutates startTime/endTime, which the structural media/types `Cue` already covers — no new type needed. - metadata resolver: resolveVttSegmentMetadata + TextSegmentMetadata move to a DOM-free media/text/resolve-vtt-metadata.ts (fetch + parseVttTimestampMap, no document). The document-based resolveVttSegment stays in media/dom/text. primitives/dom is never needed. relocation's test moves to primitives/tests (now a Node test, not Chromium) with its behavior-derive dependency replaced by an inline no-op (only the discover steps are exercised). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
0b8efb34bc
commit
d7a76fbf9a
@@ -5,7 +5,7 @@
|
||||
* the browser's optimized VTT parsing. Returns parsed VTTCue objects.
|
||||
*/
|
||||
|
||||
import { parseVttTimestampMap, type TimestampMap } from '../../text/parse-vtt-timestamp-map';
|
||||
import { resolveVttSegmentMetadata, type TextSegmentMetadata } from '../../text/resolve-vtt-metadata';
|
||||
|
||||
// Singleton dummy video (reused across all parsing)
|
||||
let dummyVideo: HTMLVideoElement | null = null;
|
||||
@@ -67,14 +67,6 @@ export function destroyVttResolver(): void {
|
||||
dummyVideo = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header-level metadata for a text segment, surfaced alongside its cues. Each
|
||||
* field is present only when the segment declared it.
|
||||
*/
|
||||
export interface TextSegmentMetadata {
|
||||
timestampMap?: TimestampMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* A resolved VTT segment paired with its header metadata — the shape used when a
|
||||
* caller needs the `X-TIMESTAMP-MAP` correlation (e.g. non-zero-PTS sources),
|
||||
@@ -85,19 +77,6 @@ export interface ResolvedVttSegment {
|
||||
metadata: TextSegmentMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a VTT segment and scrape only its header metadata (no cue parsing).
|
||||
*
|
||||
* The native `<track>` parser used by {@link resolveVttSegment} discards
|
||||
* `X-TIMESTAMP-MAP`, so reading it requires the raw bytes. This is a separate,
|
||||
* caller-controlled fetch — the caller decides *when* metadata is needed (e.g.
|
||||
* once per source) rather than paying for it on every segment.
|
||||
*/
|
||||
export async function resolveVttSegmentMetadata(url: string): Promise<TextSegmentMetadata> {
|
||||
const text = await fetch(url).then((response) => response.text());
|
||||
return { timestampMap: parseVttTimestampMap(text) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a VTT segment's cues and header metadata together. Cues still come
|
||||
* from the browser's native parser ({@link resolveVttSegment}); the header is
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
destroyVttResolver,
|
||||
resolveVttSegment,
|
||||
resolveVttSegmentMetadata,
|
||||
resolveVttSegmentWithMetadata,
|
||||
} from '../resolve-vtt-segment';
|
||||
import { resolveVttSegmentMetadata } from '../../../text/resolve-vtt-metadata';
|
||||
import { destroyVttResolver, resolveVttSegment, resolveVttSegmentWithMetadata } from '../resolve-vtt-segment';
|
||||
|
||||
describe('resolveVttSegment', () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Header-level text-segment metadata — the `X-TIMESTAMP-MAP` correlation scraped from
|
||||
* a VTT segment's raw bytes. DOM-free (a plain fetch + regex parse): the native
|
||||
* `<track>` parser in `media/dom/text` (`resolveVttSegment`) discards this header, so a
|
||||
* caller that needs it (e.g. non-zero-PTS relocation) fetches the bytes itself.
|
||||
*/
|
||||
import { parseVttTimestampMap, type TimestampMap } from './parse-vtt-timestamp-map';
|
||||
|
||||
/**
|
||||
* Header-level metadata for a text segment, surfaced alongside its cues. Each
|
||||
* field is present only when the segment declared it.
|
||||
*/
|
||||
export interface TextSegmentMetadata {
|
||||
timestampMap?: TimestampMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a VTT segment and scrape only its header metadata (no cue parsing).
|
||||
*
|
||||
* The native `<track>` parser (`media/dom/text`'s `resolveVttSegment`) discards
|
||||
* `X-TIMESTAMP-MAP`, so reading it requires the raw bytes. This is a separate,
|
||||
* caller-controlled fetch — the caller decides *when* metadata is needed (e.g.
|
||||
* once per source) rather than paying for it on every segment.
|
||||
*/
|
||||
export async function resolveVttSegmentMetadata(url: string): Promise<TextSegmentMetadata> {
|
||||
const text = await fetch(url).then((response) => response.text());
|
||||
return { timestampMap: parseVttTimestampMap(text) };
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
* {@link DeriveStartMediaTime} seam over it in `monitoring`, and stamps the settled
|
||||
* `startMediaTime` onto the model — the coordinate *consume* — until `established`.
|
||||
* The byte-level discover/stamp steps that fill the slot are a separate,
|
||||
* DOM-scoped config `messagePipelines` array (`behaviors/dom/relocation-pipelines`);
|
||||
* config `messagePipelines` array (`primitives/relocation-pipelines`);
|
||||
* the two coordinate only through the shared `state.mediaContainerData` slot, never
|
||||
* by import. See `internal/design/spf/presentation-timeline-model.md`.
|
||||
*/
|
||||
|
||||
@@ -22,7 +22,6 @@ import { deriveCdnPriority } from '../../behaviors/derive-cdn-priority';
|
||||
import { endOfStream } from '../../behaviors/dom/end-of-stream';
|
||||
import { loadAudioSegments } from '../../behaviors/dom/load-segments';
|
||||
import { recoverEndStall } from '../../behaviors/dom/recover-end-stall';
|
||||
import { relocationPipelinesFor } from '../../behaviors/dom/relocation-pipelines';
|
||||
import { setupAudioBufferActors } from '../../behaviors/dom/setup-buffer-actors';
|
||||
import { setupMediaSource } from '../../behaviors/dom/setup-mediasource';
|
||||
import { trackCurrentTime } from '../../behaviors/dom/track-current-time';
|
||||
@@ -41,6 +40,7 @@ import { resolveAudioTrack } from '../../behaviors/resolve-track';
|
||||
import { type FailoverMonitorConfig, setupFailoverMonitor } from '../../behaviors/setup-failover-monitor';
|
||||
import { syncPreload } from '../../behaviors/sync-preload';
|
||||
import { switchAudioTrack } from '../../behaviors/track-switching';
|
||||
import { relocationPipelinesFor } from '../../primitives/relocation-pipelines';
|
||||
|
||||
// ============================================================================
|
||||
// Audio-Only HLS Engine State & Context
|
||||
|
||||
@@ -39,7 +39,6 @@ import { deriveCdnPriority } from '../../behaviors/derive-cdn-priority';
|
||||
import { endOfStream } from '../../behaviors/dom/end-of-stream';
|
||||
import { loadAudioSegments, loadTextTrackSegments, loadVideoSegments } from '../../behaviors/dom/load-segments';
|
||||
import { recoverEndStall } from '../../behaviors/dom/recover-end-stall';
|
||||
import { relocatingTextPipelines, relocationPipelinesFor } from '../../behaviors/dom/relocation-pipelines';
|
||||
import { setupAudioBufferActors, setupVideoBufferActors } from '../../behaviors/dom/setup-buffer-actors';
|
||||
import { setupMediaSource } from '../../behaviors/dom/setup-mediasource';
|
||||
import { setupTextTrackActors } from '../../behaviors/dom/setup-text-track-actors';
|
||||
@@ -61,6 +60,7 @@ import { resolveAudioTrack, resolveTextTrack, resolveVideoTrack } from '../../be
|
||||
import { type FailoverMonitorConfig, setupFailoverMonitor } from '../../behaviors/setup-failover-monitor';
|
||||
import { syncPreload } from '../../behaviors/sync-preload';
|
||||
import { switchAudioTrack, switchTextTrack, switchVideoTrack } from '../../behaviors/track-switching';
|
||||
import { relocatingTextPipelines, relocationPipelinesFor } from '../../primitives/relocation-pipelines';
|
||||
import type { TextTrackSegmentResolver } from '../../primitives/text-segment-load-pipeline';
|
||||
|
||||
// ============================================================================
|
||||
|
||||
+45
-45
@@ -1,57 +1,49 @@
|
||||
/**
|
||||
* Non-zero-PTS relocation pipelines — the config-supplied, loader-facing half of the
|
||||
* `establishStartMediaTime` behavior (`../establish-start-media-time`). The reactor
|
||||
* there owns the lifecycle and the `derive` coordination seam; this file supplies the
|
||||
* `messagePipelines` the segment loader runs to *fill and act on* that behavior's
|
||||
* state. It's the relocation analog of `track-switching`'s config-supplied
|
||||
* constraint/rule chain — same shape (pluggable strategy handed to the machinery via
|
||||
* config, not applied inline), but supplied to the loader rather than applied by the
|
||||
* behavior, so the two coordinate through the shared `mediaContainerData` /
|
||||
* `startMediaTime` slots alone, never by import.
|
||||
* `establishStartMediaTime` behavior (`../behaviors/establish-start-media-time`). The
|
||||
* reactor there owns the lifecycle and the `derive` coordination seam; this file supplies
|
||||
* the `messagePipelines` the segment/text loaders run to *fill and act on* that behavior's
|
||||
* state. It's the relocation analog of `track-switching`'s config-supplied constraint/rule
|
||||
* chain — a pluggable strategy handed to the machinery via config, not applied inline —
|
||||
* supplied to the loaders rather than applied by the behavior, so the two coordinate
|
||||
* through the shared `mediaContainerData` / `startMediaTime` slots alone, never by import.
|
||||
*
|
||||
* It lives in `behaviors/dom` rather than beside the reactor because it's the DOM arm
|
||||
* of that behavior: it references the loader's base steps + `StepDeps` (which carry the
|
||||
* `SourceBuffer`-backed actor), reads container bytes, and shifts `VTTCue`s — none of
|
||||
* which the DOM-free reactor may touch. The A/V pipeline is a plain `messagePipelines`
|
||||
* array:
|
||||
* - `discover` — init `track_id` + `mdhd` timescale for the buffered media track,
|
||||
* then that same track's `tfdt` baseMediaDecodeTime, matched by `track_id` so a
|
||||
* muxed segment reads the media track's origin, not the first `traf` — writes
|
||||
* DOM-free: it composes the loader vocabularies (`segment-load-pipeline`,
|
||||
* `text-segment-load-pipeline`) through their structural sink seams and shifts cues via
|
||||
* the structural `Cue` type, so it names no `SourceBuffer`, `TextTracksActor`, or
|
||||
* `VTTCue`. The A/V pipeline is a plain `messagePipelines` array:
|
||||
* - `discover` — init `track_id` + `mdhd` timescale for the buffered media track, then
|
||||
* that same track's `tfdt` baseMediaDecodeTime, matched by `track_id` so a muxed
|
||||
* segment reads the media track's origin, not the first `traf` — writes
|
||||
* `state.mediaContainerData`.
|
||||
* - `stamp` — reads that track's derived origin back and relocates via
|
||||
* `timestampOffset = −startMediaTime`.
|
||||
* Steps read composition `state` from their call-time `deps` (no closures, no context).
|
||||
*
|
||||
* The text half (`relocatingTextPipelines`) is the same idea for the text-segment
|
||||
* loader: a `resolveWithMetadata → relocateCues → dispatchCues` pipeline that shifts
|
||||
* VTT cues onto the same 0-based timeline, reading the primary A/V track's
|
||||
* `startMediaTime` (the reactor's consumed value) via `deps`.
|
||||
* The text half (`relocatingTextPipelines`) is the same idea for the text-segment loader:
|
||||
* a `resolveWithMetadata → relocateCues → dispatchCues` pipeline that shifts cues onto the
|
||||
* same 0-based timeline, reading the primary A/V track's `startMediaTime` (the reactor's
|
||||
* consumed value) via `deps`.
|
||||
*/
|
||||
import type { StateSignals } from '../../../core/composition/create-composition';
|
||||
import { effect } from '../../../core/signals/effect';
|
||||
import { peek, type Signal, update } from '../../../core/signals/primitives';
|
||||
import { resolveVttSegmentMetadata, type TextSegmentMetadata } from '../../../media/dom/text/resolve-vtt-segment';
|
||||
import { findMediaTrack, type MediaHandlerType, readBaseMediaDecodeTime } from '../../../media/mp4/timestamp-origin';
|
||||
import type { MaybeResolvedPresentation, MediaContainerData } from '../../../media/types';
|
||||
import { findTrackById } from '../../../media/utils/tracks';
|
||||
import { peekHead } from '../../primitives/head-peek';
|
||||
import {
|
||||
dispatchStep,
|
||||
fetchStep,
|
||||
type LoadStep,
|
||||
type MessagePipelines,
|
||||
type StepDeps,
|
||||
} from '../../primitives/segment-load-pipeline';
|
||||
import type { StateSignals } from '../../core/composition/create-composition';
|
||||
import { effect } from '../../core/signals/effect';
|
||||
import { peek, type Signal, update } from '../../core/signals/primitives';
|
||||
import { findMediaTrack, type MediaHandlerType, readBaseMediaDecodeTime } from '../../media/mp4/timestamp-origin';
|
||||
import { resolveVttSegmentMetadata, type TextSegmentMetadata } from '../../media/text/resolve-vtt-metadata';
|
||||
import type { Cue, MaybeResolvedPresentation, MediaContainerData } from '../../media/types';
|
||||
import { findTrackById } from '../../media/utils/tracks';
|
||||
import { peekHead } from './head-peek';
|
||||
import { dispatchStep, fetchStep, type LoadStep, type MessagePipelines, type StepDeps } from './segment-load-pipeline';
|
||||
import {
|
||||
dispatchCuesStep,
|
||||
type TextFrame,
|
||||
type TextLoadStep,
|
||||
type TextMessagePipelines,
|
||||
type TextStepDeps,
|
||||
textStepWiring,
|
||||
} from '../../primitives/text-segment-load-pipeline';
|
||||
} from './text-segment-load-pipeline';
|
||||
|
||||
// Declared locally so this module carries no `behaviors` import (first step toward
|
||||
// relocating it to `primitives/dom`). Structurally identical to the
|
||||
// `establishStartMediaTime` behavior's own `DeriveStartMediaTime` and state shape — the
|
||||
// Declared locally so this module carries no `behaviors` import. Structurally identical to
|
||||
// the `establishStartMediaTime` behavior's own `DeriveStartMediaTime` and state shape — the
|
||||
// engine feeds one resolved `derive` to both sides, so the duplication is type-only and
|
||||
// folds away once these land in a shared home.
|
||||
export interface DeriveStartMediaTimeContext {
|
||||
@@ -215,9 +207,13 @@ export function relocationPipelinesFor(trackType: 'video' | 'audio', derive: Der
|
||||
* base `resolveCuesStep` (which fetches cues only) — text's native `<track>` parser
|
||||
* discards the header, so the map needs its own raw-bytes fetch.
|
||||
*/
|
||||
const resolveWithMetadataStep: TextLoadStep<VTTCue> = async (frame, signal, deps) => {
|
||||
const resolveWithMetadataStep = async <C extends Cue>(
|
||||
frame: TextFrame<C>,
|
||||
signal: AbortSignal,
|
||||
deps: TextStepDeps
|
||||
): Promise<void> => {
|
||||
const [cues, metadata] = await Promise.all([
|
||||
textStepWiring<VTTCue>(deps).resolveSegment(frame.op.segment.url),
|
||||
textStepWiring<C>(deps).resolveSegment(frame.op.segment.url),
|
||||
resolveVttSegmentMetadata(frame.op.segment.url),
|
||||
]);
|
||||
if (signal.aborted) return;
|
||||
@@ -226,7 +222,7 @@ const resolveWithMetadataStep: TextLoadStep<VTTCue> = async (frame, signal, deps
|
||||
};
|
||||
|
||||
/**
|
||||
* Relocate step — shifts each VTT cue onto the 0-based presentation timeline:
|
||||
* Relocate step — shifts each cue onto the 0-based presentation timeline:
|
||||
* `cueFinal = cueNative − startMediaTime`, where `startMediaTime` is the primary
|
||||
* A/V track's origin (selected **video**, else **audio** — the single-anchor rule,
|
||||
* and defensive like the reactor's optional selection) and `cueNative` folds in the
|
||||
@@ -235,7 +231,11 @@ const resolveWithMetadataStep: TextLoadStep<VTTCue> = async (frame, signal, deps
|
||||
* establishes, so the origin is awaited; fMP4 always establishes it (0-PTS → 0),
|
||||
* and a text-only source (no A/V selected) simply gets offset 0.
|
||||
*/
|
||||
const relocateCuesStep: TextLoadStep<VTTCue> = async (frame, signal, deps) => {
|
||||
const relocateCuesStep = async <C extends Cue>(
|
||||
frame: TextFrame<C>,
|
||||
signal: AbortSignal,
|
||||
deps: TextStepDeps
|
||||
): Promise<void> => {
|
||||
if (!frame.cues?.length) return;
|
||||
const state = deps.state as unknown as StateSignals<RelocationSlots>;
|
||||
const startMediaTime = await awaitDefined(() => {
|
||||
@@ -261,7 +261,7 @@ const relocateCuesStep: TextLoadStep<VTTCue> = async (frame, signal, deps) => {
|
||||
* `resolveWithMetadata` (cues + `X-TIMESTAMP-MAP`) → `relocateCues` (shift by the
|
||||
* primary A/V origin) → `dispatchCues`.
|
||||
*/
|
||||
export const relocatingTextPipelines: TextMessagePipelines<VTTCue> = () => [
|
||||
export const relocatingTextPipelines = <C extends Cue>(): TextLoadStep<C>[] => [
|
||||
resolveWithMetadataStep,
|
||||
relocateCuesStep,
|
||||
dispatchCuesStep,
|
||||
+7
-7
@@ -1,10 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { signal } from '../../../../core/signals/primitives';
|
||||
import { initSegment, mediaSegment, trak } from '../../../../media/mp4/tests/synthetic-boxes';
|
||||
import type { MediaContainerData } from '../../../../media/types';
|
||||
import type { Frame, StepDeps } from '../../../primitives/segment-load-pipeline';
|
||||
import { deriveSharedMinStartMediaTime } from '../../establish-start-media-time';
|
||||
import { signal } from '../../../core/signals/primitives';
|
||||
import { initSegment, mediaSegment, trak } from '../../../media/mp4/tests/synthetic-boxes';
|
||||
import type { MediaContainerData } from '../../../media/types';
|
||||
import { relocationPipelinesFor } from '../relocation-pipelines';
|
||||
import type { Frame, StepDeps } from '../segment-load-pipeline';
|
||||
|
||||
// A caption-first muxing: the `clcp` traf precedes the media traf, so a
|
||||
// first-`traf` read would pair the media track's timescale with the caption
|
||||
@@ -31,9 +30,10 @@ function makeDeps(): {
|
||||
return { deps: { state: { mediaContainerData: slot }, context: {}, config: {} }, slot };
|
||||
}
|
||||
|
||||
/** The discover steps at their pipeline positions: `[fetch, discover, dispatch]`. */
|
||||
/** The discover steps at their pipeline positions: `[fetch, discover, dispatch]`. The
|
||||
* derive is irrelevant here (only the stamp step consumes it), so a no-op suffices. */
|
||||
function discoverSteps(trackType: 'video' | 'audio') {
|
||||
const pipelines = relocationPipelinesFor(trackType, deriveSharedMinStartMediaTime)();
|
||||
const pipelines = relocationPipelinesFor(trackType, () => ({}))();
|
||||
return { readInitTrackInfo: pipelines['append-init'][1]!, readSegmentOrigin: pipelines['append-segment'][1]! };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user