mirror of
https://github.com/zoriya/v10.git
synced 2026-08-15 02:14:06 +00:00
feat(spf): per-type live media-playlist reload
Generalize the video-only reload spike into a per-type factory mirroring resolve-track: reloadVideoTrack / reloadAudioTrack / reloadTextTrack, each gating on its own selected*TrackId + track type so demuxed audio and video reload independently. Inject fetchResolvableText via config (parity with resolve-track, and testable). Update the live-playlist spike to reloadVideoTrack. 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
0cec3132a9
commit
df4ec77c18
@@ -1,17 +1,22 @@
|
||||
/**
|
||||
* **POC SPIKE** — live media-playlist reload loop.
|
||||
* Live media-playlist reload loop, per track type.
|
||||
*
|
||||
* Drives the live foundation at the playlist layer only (no MSE, no segment
|
||||
* fetching): once a video track is selected and the presentation is resolved,
|
||||
* Once the presentation is resolved and a track of this type is selected,
|
||||
* repeatedly re-fetch + parse that track's media playlist on a target-duration
|
||||
* cadence — passing the prior snapshot back in so the parser carries the
|
||||
* timeline forward — and patch it into `state.presentation` until
|
||||
* `#EXT-X-ENDLIST`. Validates the model in
|
||||
* [live-presentation-modeling.md](../../../internal/design/spf/live-presentation-modeling.md).
|
||||
* `#EXT-X-ENDLIST`. Drives the live foundation at the playlist layer
|
||||
* ([live-presentation-modeling.md](../../../internal/design/spf/live-presentation-modeling.md)).
|
||||
*
|
||||
* Spike limitations (intentional): video only; selection is read once at loop
|
||||
* start (mid-stream track switching isn't handled — the reactor's two states
|
||||
* don't encode the track id); PDT / discontinuity / A/V sync untouched.
|
||||
* Specialized per type via the shared `setupTrackReload` (mirrors
|
||||
* `resolve-track`): `reloadVideoTrack` / `reloadAudioTrack` / `reloadTextTrack`,
|
||||
* each gating on its own `selected*TrackId` + track type, so demuxed audio and
|
||||
* video reload independently.
|
||||
*
|
||||
* Limitations (intentional, for now): selection is read once at loop start
|
||||
* (mid-stream track switching isn't encoded in the reactor's two states);
|
||||
* PDT / discontinuity / A/V sync are handled by separate timeline transforms,
|
||||
* not here.
|
||||
*/
|
||||
import { defineBehavior } from '../../core/composition/create-composition';
|
||||
import { createMachineReactor } from '../../core/reactors/create-machine-reactor';
|
||||
@@ -24,17 +29,38 @@ import {
|
||||
isResolvedTrack,
|
||||
type MaybeResolvedPresentation,
|
||||
type ResolvedTrack,
|
||||
type TrackType,
|
||||
} from '../../media/types';
|
||||
import { findTrack, updateTrackInPresentation } from '../../media/utils/tracks';
|
||||
import { fetchResolvableText } from '../../network/fetch';
|
||||
import { fetchResolvableText as defaultFetchResolvableText, type FetchText } from '../../network/fetch';
|
||||
import { AUDIO_TYPE_CONFIG, TEXT_TYPE_CONFIG, VIDEO_TYPE_CONFIG } from './track-types';
|
||||
|
||||
export interface ReloadTrackState {
|
||||
presentation?: MaybeResolvedPresentation;
|
||||
selectedVideoTrackId?: string;
|
||||
selectedAudioTrackId?: string;
|
||||
selectedTextTrackId?: string;
|
||||
}
|
||||
|
||||
/** Engine-config slice each `reload*` behavior reads. */
|
||||
export interface ReloadTrackConfig {
|
||||
/** Playlist-text fetch; defaults to the plain resolvable fetch. */
|
||||
fetchResolvableText?: FetchText;
|
||||
}
|
||||
|
||||
type SelectedTrackKey = 'selectedVideoTrackId' | 'selectedAudioTrackId' | 'selectedTextTrackId';
|
||||
type ReloadTrackStateName = 'idle' | 'reloading';
|
||||
|
||||
type ReloadTrackStateMap<K extends SelectedTrackKey> = {
|
||||
presentation: Signal<ReloadTrackState['presentation']>;
|
||||
} & { [P in K]: ReadonlySignal<ReloadTrackState[P]> };
|
||||
|
||||
interface TrackReloadConfig<K extends SelectedTrackKey> {
|
||||
type: TrackType;
|
||||
selectedKey: K;
|
||||
fetchResolvableText?: FetchText;
|
||||
}
|
||||
|
||||
/** Fallback reload cadence when the playlist carries no usable target duration. */
|
||||
const FALLBACK_TARGET_DURATION = 6;
|
||||
|
||||
@@ -64,19 +90,18 @@ function snapshotChanged(prev: ResolvedTrack, next: ResolvedTrack): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function reloadTrackSetup({
|
||||
function setupTrackReload<K extends SelectedTrackKey>({
|
||||
state,
|
||||
config: { type, selectedKey, fetchResolvableText = defaultFetchResolvableText },
|
||||
}: {
|
||||
state: {
|
||||
presentation: Signal<ReloadTrackState['presentation']>;
|
||||
selectedVideoTrackId: ReadonlySignal<ReloadTrackState['selectedVideoTrackId']>;
|
||||
};
|
||||
state: ReloadTrackStateMap<K>;
|
||||
config: TrackReloadConfig<K>;
|
||||
}) {
|
||||
const derivedStateSignal = computed<ReloadTrackStateName>(() => {
|
||||
const presentation = state.presentation.get();
|
||||
const trackId = state.selectedVideoTrackId.get();
|
||||
const trackId = state[selectedKey].get();
|
||||
if (!isResolvedPresentation(presentation) || !trackId) return 'idle';
|
||||
return findTrack(presentation, 'video', trackId) ? 'reloading' : 'idle';
|
||||
return findTrack(presentation, type, trackId) ? 'reloading' : 'idle';
|
||||
});
|
||||
|
||||
return createMachineReactor<ReloadTrackStateName>({
|
||||
@@ -87,7 +112,7 @@ function reloadTrackSetup({
|
||||
reloading: {
|
||||
entry: () => {
|
||||
const ac = new AbortController();
|
||||
const trackId = state.selectedVideoTrackId.get()!;
|
||||
const trackId = state[selectedKey].get()!;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
@@ -97,7 +122,7 @@ function reloadTrackSetup({
|
||||
// The track currently in the presentation is the prior snapshot
|
||||
// (the unresolved shell on the first pass, the last resolved
|
||||
// window thereafter); the parser carries its timeline forward.
|
||||
const previousTrack = findTrack(presentation, 'video', trackId);
|
||||
const previousTrack = findTrack(presentation, type, trackId);
|
||||
if (!previousTrack) break;
|
||||
|
||||
const text = await fetchResolvableText(previousTrack, { signal: ac.signal });
|
||||
@@ -122,7 +147,7 @@ function reloadTrackSetup({
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') return;
|
||||
// TODO(error-management): route to a state-error slot once one exists.
|
||||
console.error('[reloadTrack] media-playlist reload failed:', error);
|
||||
console.error(`[reload:${type}] media-playlist reload failed:`, error);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -134,8 +159,30 @@ function reloadTrackSetup({
|
||||
});
|
||||
}
|
||||
|
||||
export const reloadTrack = defineBehavior({
|
||||
const VIDEO_TRACK_RELOAD_CONFIG = { type: VIDEO_TYPE_CONFIG.type, selectedKey: VIDEO_TYPE_CONFIG.selectedKey } as const;
|
||||
const AUDIO_TRACK_RELOAD_CONFIG = { type: AUDIO_TYPE_CONFIG.type, selectedKey: AUDIO_TYPE_CONFIG.selectedKey } as const;
|
||||
const TEXT_TRACK_RELOAD_CONFIG = { type: TEXT_TYPE_CONFIG.type, selectedKey: TEXT_TYPE_CONFIG.selectedKey } as const;
|
||||
|
||||
/** Reload the selected video track's media playlist on a live cadence. */
|
||||
export const reloadVideoTrack = defineBehavior({
|
||||
stateKeys: ['presentation', 'selectedVideoTrackId'],
|
||||
contextKeys: [],
|
||||
setup: reloadTrackSetup,
|
||||
setup: ({ state, config = {} }: { state: ReloadTrackStateMap<'selectedVideoTrackId'>; config?: ReloadTrackConfig }) =>
|
||||
setupTrackReload({ state, config: { ...VIDEO_TRACK_RELOAD_CONFIG, ...config } }),
|
||||
});
|
||||
|
||||
/** Reload the selected audio track's media playlist (demuxed audio). */
|
||||
export const reloadAudioTrack = defineBehavior({
|
||||
stateKeys: ['presentation', 'selectedAudioTrackId'],
|
||||
contextKeys: [],
|
||||
setup: ({ state, config = {} }: { state: ReloadTrackStateMap<'selectedAudioTrackId'>; config?: ReloadTrackConfig }) =>
|
||||
setupTrackReload({ state, config: { ...AUDIO_TRACK_RELOAD_CONFIG, ...config } }),
|
||||
});
|
||||
|
||||
/** Reload the selected text track's media playlist (live captions). */
|
||||
export const reloadTextTrack = defineBehavior({
|
||||
stateKeys: ['presentation', 'selectedTextTrackId'],
|
||||
contextKeys: [],
|
||||
setup: ({ state, config = {} }: { state: ReloadTrackStateMap<'selectedTextTrackId'>; config?: ReloadTrackConfig }) =>
|
||||
setupTrackReload({ state, config: { ...TEXT_TRACK_RELOAD_CONFIG, ...config } }),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { signal } from '../../../core/signals/primitives';
|
||||
import {
|
||||
isResolvedTrack,
|
||||
type MaybeResolvedPresentation,
|
||||
type PartiallyResolvedAudioTrack,
|
||||
type PartiallyResolvedVideoTrack,
|
||||
type Presentation,
|
||||
} from '../../../media/types';
|
||||
import { findTrack } from '../../../media/utils/tracks';
|
||||
import { reloadAudioTrack, reloadVideoTrack } from '../reload-track';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const MEDIA_PLAYLIST = `#EXTM3U
|
||||
#EXT-X-VERSION:7
|
||||
#EXT-X-TARGETDURATION:4
|
||||
#EXT-X-MEDIA-SEQUENCE:0
|
||||
#EXT-X-MAP:URI="init.mp4"
|
||||
#EXTINF:4.0,
|
||||
seg0.m4s
|
||||
#EXT-X-ENDLIST`;
|
||||
|
||||
const unresolvedVideo: PartiallyResolvedVideoTrack = {
|
||||
type: 'video',
|
||||
id: 'v-1',
|
||||
url: 'https://example.com/video.m3u8',
|
||||
bandwidth: 1_000_000,
|
||||
mimeType: 'video/mp4',
|
||||
codecs: [],
|
||||
};
|
||||
|
||||
const unresolvedAudio: PartiallyResolvedAudioTrack = {
|
||||
type: 'audio',
|
||||
id: 'a-1',
|
||||
url: 'https://example.com/audio.m3u8',
|
||||
groupId: 'aud',
|
||||
name: 'Default',
|
||||
language: 'und',
|
||||
codecs: ['mp4a.40.2'],
|
||||
mimeType: 'audio/mp4',
|
||||
bandwidth: 0,
|
||||
sampleRate: 48000,
|
||||
channels: 2,
|
||||
};
|
||||
|
||||
function makePresentation(): Presentation {
|
||||
return {
|
||||
id: 'pres-1',
|
||||
url: 'https://example.com/master.m3u8',
|
||||
startTime: 0,
|
||||
selectionSets: [
|
||||
{ id: 'video-set', type: 'video', switchingSets: [{ id: 'vs', type: 'video', tracks: [unresolvedVideo] }] },
|
||||
{ id: 'audio-set', type: 'audio', switchingSets: [{ id: 'as', type: 'audio', tracks: [unresolvedAudio] }] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const fakeFetch = () =>
|
||||
vi.fn((_addressable: { url: string }, _options?: { signal?: AbortSignal }) => Promise.resolve(MEDIA_PLAYLIST));
|
||||
|
||||
describe('reloadVideoTrack', () => {
|
||||
it('resolves the selected video track and fetches *its* playlist (not audio)', async () => {
|
||||
const presentation = makePresentation();
|
||||
const state = {
|
||||
presentation: signal<MaybeResolvedPresentation | undefined>(presentation),
|
||||
selectedVideoTrackId: signal<string | undefined>('v-1'),
|
||||
};
|
||||
const fetchResolvableText = fakeFetch();
|
||||
|
||||
const reactor = reloadVideoTrack.setup({ state, config: { fetchResolvableText } });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const track = findTrack(state.presentation.get()!, 'video', 'v-1');
|
||||
expect(track && isResolvedTrack(track)).toBe(true);
|
||||
});
|
||||
|
||||
// Fetched the video playlist; the audio track is left untouched.
|
||||
expect(fetchResolvableText.mock.calls[0]?.[0]?.url).toContain('video.m3u8');
|
||||
const audio = findTrack(state.presentation.get()!, 'audio', 'a-1');
|
||||
expect(audio && isResolvedTrack(audio)).toBe(false);
|
||||
|
||||
reactor.destroy();
|
||||
});
|
||||
|
||||
it('stays idle with no selected track', () => {
|
||||
const fetchResolvableText = fakeFetch();
|
||||
const state = {
|
||||
presentation: signal<MaybeResolvedPresentation | undefined>(makePresentation()),
|
||||
selectedVideoTrackId: signal<string | undefined>(undefined),
|
||||
};
|
||||
const reactor = reloadVideoTrack.setup({ state, config: { fetchResolvableText } });
|
||||
expect(fetchResolvableText).not.toHaveBeenCalled();
|
||||
reactor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reloadAudioTrack', () => {
|
||||
it('resolves the selected audio track and fetches *its* playlist (not video)', async () => {
|
||||
const presentation = makePresentation();
|
||||
const state = {
|
||||
presentation: signal<MaybeResolvedPresentation | undefined>(presentation),
|
||||
selectedAudioTrackId: signal<string | undefined>('a-1'),
|
||||
};
|
||||
const fetchResolvableText = fakeFetch();
|
||||
|
||||
const reactor = reloadAudioTrack.setup({ state, config: { fetchResolvableText } });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const track = findTrack(state.presentation.get()!, 'audio', 'a-1');
|
||||
expect(track && isResolvedTrack(track)).toBe(true);
|
||||
});
|
||||
|
||||
expect(fetchResolvableText.mock.calls[0]?.[0]?.url).toContain('audio.m3u8');
|
||||
const video = findTrack(state.presentation.get()!, 'video', 'v-1');
|
||||
expect(video && isResolvedTrack(video)).toBe(false);
|
||||
|
||||
reactor.destroy();
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,7 @@ import { makeShareSignals, type ShareSignalsConfig } from '../../../core/composi
|
||||
import { parseMultivariantPlaylist } from '../../../media/hls/parse-multivariant';
|
||||
import { pickHighestResolutionVideoTrack, type TrackPicker } from '../../../media/primitives/select-tracks';
|
||||
import type { MaybeResolvedPresentation } from '../../../media/types';
|
||||
import { reloadTrack } from '../../behaviors/reload-track';
|
||||
import { reloadVideoTrack } from '../../behaviors/reload-track';
|
||||
import { type ParsePresentation, resolvePresentation } from '../../behaviors/resolve-presentation';
|
||||
import { type SelectVideoTrackConfig, selectVideoTrack } from '../../behaviors/select-tracks';
|
||||
|
||||
@@ -58,7 +58,7 @@ export function createLivePlaylistSpikeEngine(
|
||||
parsePresentation: config.parsePresentation ?? parseMultivariantPlaylist,
|
||||
};
|
||||
|
||||
return createComposition([resolvePresentation, selectVideoTrack, reloadTrack, shareSignals], {
|
||||
return createComposition([resolvePresentation, selectVideoTrack, reloadVideoTrack, shareSignals], {
|
||||
config: finalConfig,
|
||||
// Spike skips the preload gate — resolve as soon as a url is set.
|
||||
initialState: { loadActivated: true },
|
||||
|
||||
Reference in New Issue
Block a user