fix(lab): stream synchronized replay fragments

This commit is contained in:
DCCONSTRUCTIONS
2026-08-23 23:36:04 +03:00
parent a55ef475d9
commit a7caed70c1
9 changed files with 443 additions and 34 deletions
@@ -232,11 +232,148 @@ async function mountRecordedEpochStream(
} }
} }
interface RecordedSegmentStreamRuntime {
readonly generation: string;
readonly mediaSource: MediaSource;
readonly sourceBuffer: SourceBuffer;
readonly objectUrl: string;
readonly epoch: ObservationRecordedMediaEpoch;
readonly contractGeneration: string;
readonly segmentCount: number;
readonly appended: Set<number>;
readonly abort: AbortController;
desiredStart: number;
desiredEnd: number;
target: number;
pumping: boolean;
disposed: boolean;
onTargetReady: ((sequence: number) => void) | null;
}
export function recordedMediaFragmentUrl(
epoch: ObservationRecordedMediaEpoch,
generation: string,
fragment: "init" | number,
): string {
const match = epoch.streamUrl.match(
/^(\/api\/v1\/observation-sessions\/[A-Za-z0-9._%:-]+\/media\/[A-Za-z0-9._%-]+\/epochs\/[1-9][0-9]*)\/recording\.mp4\?generation=([a-f0-9]{64})$/,
);
if (!match || match[2] !== generation) {
throw new ObservationSessionContractError("URL фрагментов записанной камеры не привязан к manifest.");
}
const suffix = fragment === "init" ? "init.mp4" : `segments/${fragment}.m4s`;
return `${match[1]}/${suffix}?generation=${generation}`;
}
function waitForMediaSourceOpen(mediaSource: MediaSource, signal: AbortSignal): Promise<void> {
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
if (mediaSource.readyState === "open") return Promise.resolve();
return new Promise((resolve, reject) => {
const cleanup = () => {
mediaSource.removeEventListener("sourceopen", onOpen);
mediaSource.removeEventListener("sourceclose", onClose);
signal.removeEventListener("abort", onAbort);
};
const onOpen = () => {
cleanup();
resolve();
};
const onClose = () => {
cleanup();
reject(new Error("Recorded fragment MediaSource closed before opening"));
};
const onAbort = () => {
cleanup();
reject(new DOMException("Aborted", "AbortError"));
};
mediaSource.addEventListener("sourceopen", onOpen, { once: true });
mediaSource.addEventListener("sourceclose", onClose, { once: true });
signal.addEventListener("abort", onAbort, { once: true });
});
}
function appendRecordedFragment(
sourceBuffer: SourceBuffer,
payload: ArrayBuffer,
signal: AbortSignal,
): Promise<void> {
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
return new Promise((resolve, reject) => {
const cleanup = () => {
sourceBuffer.removeEventListener("updateend", onUpdateEnd);
sourceBuffer.removeEventListener("error", onError);
signal.removeEventListener("abort", onAbort);
};
const onUpdateEnd = () => {
cleanup();
resolve();
};
const onError = () => {
cleanup();
reject(new Error("Recorded fragment append failed"));
};
const onAbort = () => {
cleanup();
reject(new DOMException("Aborted", "AbortError"));
};
sourceBuffer.addEventListener("updateend", onUpdateEnd, { once: true });
sourceBuffer.addEventListener("error", onError, { once: true });
signal.addEventListener("abort", onAbort, { once: true });
try {
sourceBuffer.appendBuffer(payload);
} catch (error) {
cleanup();
reject(error);
}
});
}
async function fetchRecordedFragment(url: string, signal: AbortSignal): Promise<ArrayBuffer> {
const response = await fetch(url, {
method: "GET",
cache: "force-cache",
headers: { Accept: "video/mp4" },
signal,
});
if (!response.ok) throw new Error(`Recorded fragment returned HTTP ${response.status}`);
return response.arrayBuffer();
}
async function pumpRecordedSegmentWindow(runtime: RecordedSegmentStreamRuntime): Promise<void> {
if (runtime.pumping || runtime.disposed) return;
runtime.pumping = true;
try {
while (!runtime.disposed && !runtime.abort.signal.aborted) {
const targetMissing = !runtime.appended.has(runtime.target) ? runtime.target : null;
let next = targetMissing;
if (next === null) {
for (let sequence = runtime.desiredStart; sequence <= runtime.desiredEnd; sequence += 1) {
if (!runtime.appended.has(sequence)) {
next = sequence;
break;
}
}
}
if (next === null) break;
const url = recordedMediaFragmentUrl(runtime.epoch, runtime.contractGeneration, next);
const payload = await fetchRecordedFragment(url, runtime.abort.signal);
if (runtime.disposed || runtime.abort.signal.aborted) return;
await appendRecordedFragment(runtime.sourceBuffer, payload, runtime.abort.signal);
runtime.appended.add(next);
if (runtime.appended.has(runtime.target)) runtime.onTargetReady?.(runtime.target);
}
} finally {
runtime.pumping = false;
}
}
export function RecordedFmp4Player({ export function RecordedFmp4Player({
source, source,
playback, playback,
interactive = false, interactive = false,
prepare = true, prepare = true,
segmentSequence = null,
segmentCount = null,
sessionGate = "ready", sessionGate = "ready",
admissionKey = null, admissionKey = null,
onAdmissionChange, onAdmissionChange,
@@ -246,6 +383,8 @@ export function RecordedFmp4Player({
playback?: RecordedObservationPlayback | null; playback?: RecordedObservationPlayback | null;
interactive?: boolean; interactive?: boolean;
prepare?: boolean; prepare?: boolean;
segmentSequence?: number | null;
segmentCount?: number | null;
sessionGate?: RecordedAdmissionPhase; sessionGate?: RecordedAdmissionPhase;
admissionKey?: string | null; admissionKey?: string | null;
onAdmissionChange?: (state: RecordedCameraAdmissionState) => void; onAdmissionChange?: (state: RecordedCameraAdmissionState) => void;
@@ -290,6 +429,8 @@ export function RecordedFmp4Player({
const [state, setState] = useState<"loading" | "ready" | "error">("loading"); const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [readyGeneration, setReadyGeneration] = useState<string | null>(null); const [readyGeneration, setReadyGeneration] = useState<string | null>(null);
const [bufferRevision, setBufferRevision] = useState(0); const [bufferRevision, setBufferRevision] = useState(0);
const segmentedRuntimeRef = useRef<RecordedSegmentStreamRuntime | null>(null);
const [segmentedRuntimeGeneration, setSegmentedRuntimeGeneration] = useState<string | null>(null);
const currentSeconds = playback?.currentSeconds ?? contract?.timelineStartSeconds ?? 0; const currentSeconds = playback?.currentSeconds ?? contract?.timelineStartSeconds ?? 0;
const playbackRate = playback?.rate && Number.isFinite(playback.rate) const playbackRate = playback?.rate && Number.isFinite(playback.rate)
? Math.min(4, Math.max(0.25, playback.rate)) ? Math.min(4, Math.max(0.25, playback.rate))
@@ -298,6 +439,17 @@ export function RecordedFmp4Player({
() => selectRecordedMediaEpoch(archive?.manifest.epochs ?? [], currentSeconds), () => selectRecordedMediaEpoch(archive?.manifest.epochs ?? [], currentSeconds),
[archive?.manifest.epochs, currentSeconds], [archive?.manifest.epochs, currentSeconds],
); );
const segmented = Boolean(
segmentSequence !== null
&& Number.isInteger(segmentSequence)
&& segmentSequence >= 1
&& segmentCount !== null
&& Number.isInteger(segmentCount)
&& segmentCount >= segmentSequence
&& typeof MediaSource !== "undefined"
&& epoch
&& MediaSource.isTypeSupported(epoch.mediaType),
);
const waitingForEpoch = Boolean(archive && !epoch); const waitingForEpoch = Boolean(archive && !epoch);
const selectedGeneration = contract && epoch const selectedGeneration = contract && epoch
? `${contract.manifestGenerationSha256}:${epoch.ordinal}:${epoch.timelineStartSeconds}:${epoch.timelineEndSeconds}` ? `${contract.manifestGenerationSha256}:${epoch.ordinal}:${epoch.timelineStartSeconds}:${epoch.timelineEndSeconds}`
@@ -354,7 +506,7 @@ export function RecordedFmp4Player({
}, [admissionKey, contract, prepare]); }, [admissionKey, contract, prepare]);
useEffect(() => { useEffect(() => {
if (!archive || !contract || !prepare) return; if (!archive || !contract || !prepare || segmented) return;
const abort = new AbortController(); const abort = new AbortController();
let disposed = false; let disposed = false;
void (async () => { void (async () => {
@@ -390,11 +542,155 @@ export function RecordedFmp4Player({
disposed = true; disposed = true;
abort.abort(); abort.abort();
}; };
}, [admissionKey, archive, contract, prepare]); }, [admissionKey, archive, contract, prepare, segmented]);
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
if (!video || !epoch) return; if (!video || !archive || !contract || !epoch || !segmented || segmentCount === null) return;
const abort = new AbortController();
const mediaSource = new MediaSource();
const objectUrl = URL.createObjectURL(mediaSource);
const generation = `${contract.manifestGenerationSha256}:${epoch.ordinal}:${epoch.timelineStartSeconds}:${epoch.timelineEndSeconds}`;
let runtime: RecordedSegmentStreamRuntime | null = null;
setReadyGeneration(null);
setSegmentedRuntimeGeneration(null);
setState("loading");
video.pause();
video.src = objectUrl;
video.load();
void (async () => {
await waitForMediaSourceOpen(mediaSource, abort.signal);
const sourceBuffer = mediaSource.addSourceBuffer(epoch.mediaType);
const initUrl = recordedMediaFragmentUrl(
epoch,
contract.manifestGenerationSha256,
"init",
);
const init = await fetchRecordedFragment(initUrl, abort.signal);
await appendRecordedFragment(sourceBuffer, init, abort.signal);
if (abort.signal.aborted) return;
mediaSource.duration = epoch.timelineEndSeconds - epoch.timelineStartSeconds;
runtime = {
generation,
mediaSource,
sourceBuffer,
objectUrl,
epoch,
contractGeneration: contract.manifestGenerationSha256,
segmentCount,
appended: new Set(),
abort,
desiredStart: 1,
desiredEnd: 1,
target: 1,
pumping: false,
disposed: false,
onTargetReady: null,
};
segmentedRuntimeRef.current = runtime;
setSegmentedRuntimeGeneration(generation);
})().catch((error: unknown) => {
if (abort.signal.aborted || (error instanceof DOMException && error.name === "AbortError")) {
return;
}
setReadyGeneration(null);
setState("error");
reportAdmission({
phase: "error",
byteLength: archive.byteLength,
message: "Покадровый буфер записанной камеры не открылся.",
});
});
return () => {
abort.abort();
if (runtime) {
runtime.disposed = true;
runtime.onTargetReady = null;
}
if (segmentedRuntimeRef.current === runtime) segmentedRuntimeRef.current = null;
setSegmentedRuntimeGeneration(null);
video.pause();
if (video.src === objectUrl) {
video.removeAttribute("src");
video.load();
}
URL.revokeObjectURL(objectUrl);
};
}, [archive, contract, epoch, segmentCount, segmented]);
useEffect(() => {
const runtime = segmentedRuntimeRef.current;
const video = videoRef.current;
if (
!runtime
|| !video
|| !archive
|| !segmented
|| segmentedRuntimeGeneration !== runtime.generation
|| segmentSequence === null
) return;
const archiveByteLength = archive.byteLength;
runtime.target = segmentSequence;
runtime.desiredStart = Math.max(1, segmentSequence - 2);
runtime.desiredEnd = Math.min(runtime.segmentCount, segmentSequence + 36);
const targetSeconds = recordedMediaLocalTime(
runtime.epoch.timelineStartSeconds,
currentSeconds,
runtime.epoch.timelineEndSeconds - runtime.epoch.timelineStartSeconds,
);
const markReady = (sequence: number) => {
if (
runtime.disposed
|| segmentedRuntimeRef.current !== runtime
|| runtime.target !== sequence
) return;
try {
if (Math.abs(video.currentTime - targetSeconds) > 0.2) video.currentTime = targetSeconds;
} catch {
setState("error");
return;
}
setBufferRevision((revision) => revision + 1);
setReadyGeneration(runtime.generation);
setState("ready");
reportAdmission({
phase: "ready",
byteLength: archiveByteLength,
message: null,
});
};
runtime.onTargetReady = markReady;
if (runtime.appended.has(segmentSequence)) {
markReady(segmentSequence);
} else {
setReadyGeneration(null);
setState("loading");
}
void pumpRecordedSegmentWindow(runtime).catch((error: unknown) => {
if (
runtime.disposed
|| runtime.abort.signal.aborted
|| (error instanceof DOMException && error.name === "AbortError")
) return;
setReadyGeneration(null);
setState("error");
reportAdmission({
phase: "error",
byteLength: archiveByteLength,
message: "Покадровый фрагмент записанной камеры недоступен.",
});
});
return () => {
if (runtime.onTargetReady === markReady) runtime.onTargetReady = null;
};
}, [archive?.byteLength, currentSeconds, segmentSequence, segmented, segmentedRuntimeGeneration]);
useEffect(() => {
const video = videoRef.current;
if (!video || !epoch || segmented) return;
const epochDescriptor = epoch; const epochDescriptor = epoch;
const generation = contract const generation = contract
? `${contract.manifestGenerationSha256}:${epochDescriptor.ordinal}:${epochDescriptor.timelineStartSeconds}:${epochDescriptor.timelineEndSeconds}` ? `${contract.manifestGenerationSha256}:${epochDescriptor.ordinal}:${epochDescriptor.timelineStartSeconds}:${epochDescriptor.timelineEndSeconds}`
@@ -440,7 +736,7 @@ export function RecordedFmp4Player({
abort.abort(); abort.abort();
cleanup?.(); cleanup?.();
}; };
}, [archive?.byteLength, contract, epoch]); }, [archive?.byteLength, contract, epoch, segmented]);
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
@@ -482,9 +778,18 @@ export function RecordedFmp4Player({
useEffect(() => { useEffect(() => {
const video = videoRef.current; const video = videoRef.current;
if (!interactive || !video || !epoch || visualState !== "ready") return; if ((!interactive && onPlaybackChange === undefined) || !video || !epoch || visualState !== "ready") return;
let videoFrameRequest: number | null = null; let videoFrameRequest: number | null = null;
const emitPlayback = () => { const emitPlayback = () => {
const expectedLocalSeconds = recordedMediaLocalTime(
epoch.timelineStartSeconds,
currentSeconds,
video.duration,
);
// A controlled seek first moves the shared LAB clock and only then fetches
// its exact fMP4 fragment. Do not let the previously displayed native
// frame race that pending seek and rewind the shared clock.
if (Math.abs(video.currentTime - expectedLocalSeconds) > 0.35) return;
onPlaybackChangeRef.current?.({ onPlaybackChangeRef.current?.({
currentSeconds: epoch.timelineStartSeconds + video.currentTime, currentSeconds: epoch.timelineStartSeconds + video.currentTime,
playing: !video.paused && !video.ended, playing: !video.paused && !video.ended,
@@ -511,7 +816,7 @@ export function RecordedFmp4Player({
video.cancelVideoFrameCallback(videoFrameRequest); video.cancelVideoFrameCallback(videoFrameRequest);
} }
}; };
}, [epoch, interactive, visualState]); }, [currentSeconds, epoch, interactive, onPlaybackChange, visualState]);
return ( return (
<div <div
@@ -24,6 +24,8 @@ export function RecordedEvidenceVideoScene({
semanticOverlay, semanticOverlay,
ariaLabel, ariaLabel,
interactive = true, interactive = true,
segmentSequence,
segmentCount,
onPlaybackChange, onPlaybackChange,
}: { }: {
source: ObservationSourceDescriptor; source: ObservationSourceDescriptor;
@@ -34,6 +36,8 @@ export function RecordedEvidenceVideoScene({
semanticOverlay?: RecordedEvidenceSemanticOverlay; semanticOverlay?: RecordedEvidenceSemanticOverlay;
ariaLabel: string; ariaLabel: string;
interactive?: boolean; interactive?: boolean;
segmentSequence?: number;
segmentCount?: number;
onPlaybackChange?: (playback: RecordedObservationPlayback) => void; onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
}) { }) {
return ( return (
@@ -43,6 +47,8 @@ export function RecordedEvidenceVideoScene({
playback={playback} playback={playback}
interactive={interactive} interactive={interactive}
prepare prepare
segmentSequence={segmentSequence}
segmentCount={segmentCount}
onPlaybackChange={onPlaybackChange} onPlaybackChange={onPlaybackChange}
/> />
{semanticOverlay ? ( {semanticOverlay ? (
@@ -42,6 +42,7 @@ export function advanceRecordedEvidencePlayback(
export function useRecordedEvidencePlayback( export function useRecordedEvidencePlayback(
range: RecordedEvidencePlaybackRange | null, range: RecordedEvidencePlaybackRange | null,
{ clock = "animation" }: { clock?: "animation" | "external" } = {},
) { ) {
const [playback, setPlayback] = useState<RecordedObservationPlayback>({ const [playback, setPlayback] = useState<RecordedObservationPlayback>({
currentSeconds: 0, currentSeconds: 0,
@@ -60,7 +61,7 @@ export function useRecordedEvidencePlayback(
}, [range]); }, [range]);
useEffect(() => { useEffect(() => {
if (!validRange(range) || !playback.playing) return; if (!validRange(range) || !playback.playing || clock === "external") return;
let animationFrame = 0; let animationFrame = 0;
let previous = performance.now(); let previous = performance.now();
const tick = (now: number) => { const tick = (now: number) => {
@@ -76,7 +77,7 @@ export function useRecordedEvidencePlayback(
}; };
animationFrame = window.requestAnimationFrame(tick); animationFrame = window.requestAnimationFrame(tick);
return () => window.cancelAnimationFrame(animationFrame); return () => window.cancelAnimationFrame(animationFrame);
}, [playback.playing, range]); }, [clock, playback.playing, range]);
const seek = useCallback((seconds: number, pause = true) => { const seek = useCallback((seconds: number, pause = true) => {
if (!validRange(range)) return; if (!validRange(range)) return;
@@ -103,10 +104,20 @@ export function useRecordedEvidencePlayback(
setPlayback((current) => ({ ...current, rate })); setPlayback((current) => ({ ...current, rate }));
}, []); }, []);
const synchronize = useCallback((next: RecordedObservationPlayback) => {
if (!validRange(range) || !Number.isFinite(next.currentSeconds)) return;
setPlayback((current) => ({
...current,
currentSeconds: clampRecordedEvidenceSeconds(next.currentSeconds, range),
playing: next.playing,
}));
}, [range]);
return useMemo(() => ({ return useMemo(() => ({
playback, playback,
seek, seek,
setPlaying, setPlaying,
setRate, setRate,
}), [playback, seek, setPlaying, setRate]); synchronize,
}), [playback, seek, setPlaying, setRate, synchronize]);
} }
@@ -218,8 +218,11 @@
} }
.m4-replay-threat-visual__overlay { .m4-replay-threat-visual__overlay {
box-sizing: border-box;
align-items: stretch; align-items: stretch;
gap: 0.35rem; width: min(22rem, calc(33.333333% - 0.8rem));
grid-template-columns: minmax(0, 1fr);
gap: 0.3rem;
background: transparent; background: transparent;
padding: 0; padding: 0;
backdrop-filter: none; backdrop-filter: none;
@@ -232,6 +235,12 @@
backdrop-filter: blur(var(--nodedc-blur-control)); backdrop-filter: blur(var(--nodedc-blur-control));
} }
@media (max-width: 900px) {
.m4-replay-threat-visual__overlay {
width: min(22rem, calc(100% - 1.2rem));
}
}
.laboratory-metric-evidence-scene__viewport { .laboratory-metric-evidence-scene__viewport {
position: absolute; position: absolute;
inset: 0; inset: 0;
@@ -117,7 +117,9 @@ export function M4ReplayThreatVisual({
startSeconds: metadata.timeline.timelineStartSeconds, startSeconds: metadata.timeline.timelineStartSeconds,
endSeconds: metadata.timeline.timelineEndSeconds, endSeconds: metadata.timeline.timelineEndSeconds,
}) : null, [metadata.timeline]); }) : null, [metadata.timeline]);
const playbackController = useRecordedEvidencePlayback(playbackRange); const playbackController = useRecordedEvidencePlayback(playbackRange, {
clock: mediaMode === "video" ? "external" : "animation",
});
const timelineFrame = useM4ThreatTimelineFrame({ const timelineFrame = useM4ThreatTimelineFrame({
resultId, resultId,
timeline: metadata.timeline, timeline: metadata.timeline,
@@ -521,6 +523,9 @@ export function M4ReplayThreatVisual({
semanticOverlay={mediaMode === "video" ? semanticOverlay : undefined} semanticOverlay={mediaMode === "video" ? semanticOverlay : undefined}
ariaLabel={`M4.6 recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`} ariaLabel={`M4.6 recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`}
interactive={false} interactive={false}
segmentSequence={frame ? frame.sequence + 1 : undefined}
segmentCount={timeline.frameCount}
onPlaybackChange={playbackController.synchronize}
/> />
) : videoError ? ( ) : videoError ? (
<SpatialState message={videoError} /> <SpatialState message={videoError} />
@@ -422,6 +422,10 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
assert.match(visual, /separatorLabel="Изменить размер VIDEO\/CAMERA и 3D\/PLAN"/); assert.match(visual, /separatorLabel="Изменить размер VIDEO\/CAMERA и 3D\/PLAN"/);
assert.match(visual, /secondaryMode=\{\{/); assert.match(visual, /secondaryMode=\{\{/);
assert.match(visual, /playback=\{playbackController\.playback\}/); assert.match(visual, /playback=\{playbackController\.playback\}/);
assert.match(visual, /clock: mediaMode === "video" \? "external" : "animation"/);
assert.match(visual, /segmentSequence=\{frame \? frame\.sequence \+ 1 : undefined\}/);
assert.match(visual, /segmentCount=\{timeline\.frameCount\}/);
assert.match(visual, /onPlaybackChange=\{playbackController\.synchronize\}/);
assert.match(visual, /currentSeconds: playbackController\.playback\.currentSeconds/); assert.match(visual, /currentSeconds: playbackController\.playback\.currentSeconds/);
assert.doesNotMatch(visual, /setPlaying\(false\)/); assert.doesNotMatch(visual, /setPlaying\(false\)/);
assert.match(visualCss, /m4-replay-threat-visual__deck > \.nodedc-split-pane/); assert.match(visualCss, /m4-replay-threat-visual__deck > \.nodedc-split-pane/);
@@ -430,6 +434,8 @@ test("M4.6 viewer keeps media and spatial panes on one playback clock", async ()
assert.match(visualCss, /width: 33\.333333%/); assert.match(visualCss, /width: 33\.333333%/);
assert.match(visualCss, /flex-flow: column nowrap/); assert.match(visualCss, /flex-flow: column nowrap/);
assert.match(visualCss, /m4-replay-threat-visual__overlay > div/); assert.match(visualCss, /m4-replay-threat-visual__overlay > div/);
assert.match(visualCss, /grid-template-columns: minmax\(0, 1fr\)/);
assert.match(visualCss, /width: min\(22rem, calc\(33\.333333% - 0\.8rem\)\)/);
assert.match(visualCss, /laboratory-metric-evidence-scene__legend/); assert.match(visualCss, /laboratory-metric-evidence-scene__legend/);
assert.match(visualCss, /bottom: auto/); assert.match(visualCss, /bottom: auto/);
assert.match(videoScene, /<RecordedFmp4Player/); assert.match(videoScene, /<RecordedFmp4Player/);
@@ -8,6 +8,7 @@ let server;
let fetchRecordedMediaArchive; let fetchRecordedMediaArchive;
let recordedMediaPresentationState; let recordedMediaPresentationState;
let recordedMediaSeekableCoverage; let recordedMediaSeekableCoverage;
let recordedMediaFragmentUrl;
before(async () => { before(async () => {
server = await createServer({ server = await createServer({
@@ -19,6 +20,7 @@ before(async () => {
fetchRecordedMediaArchive, fetchRecordedMediaArchive,
recordedMediaPresentationState, recordedMediaPresentationState,
recordedMediaSeekableCoverage, recordedMediaSeekableCoverage,
recordedMediaFragmentUrl,
} = await server.ssrLoadModule("/src/components/RecordedFmp4Player.tsx")); } = await server.ssrLoadModule("/src/components/RecordedFmp4Player.tsx"));
}); });
@@ -144,13 +146,38 @@ test("decoded duration and seekable range cover the complete declared epoch", ()
assert.equal(recordedMediaSeekableCoverage(20, 20, 20, 1, 1.01), false); assert.equal(recordedMediaSeekableCoverage(20, 20, 20, 1, 1.01), false);
}); });
test("recorded player range-streams and never builds a whole-video RAM Blob", async () => { test("recorded player keeps full-archive range fallback and uses bounded generation-bound fragments", async () => {
const source = await readFile( const source = await readFile(
new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url), new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url),
"utf8", "utf8",
); );
assert.match(source, /video\.src\s*=\s*descriptor\.streamUrl/); assert.match(source, /video\.src\s*=\s*descriptor\.streamUrl/);
assert.doesNotMatch(source, /new Blob\(|response\.arrayBuffer\(|SourceBuffer/); assert.doesNotMatch(source, /new Blob\(/);
assert.match(source, /new MediaSource\(\)/);
assert.match(source, /segmentSequence \+ 36/);
assert.match(source, /pumpRecordedSegmentWindow/);
const { manifest, generation } = fixture();
const epoch = {
ordinal: 1,
timelineStartSeconds: 0,
timelineEndSeconds: 36_000,
mediaType: manifest.epochs[0].media_type,
byteLength: manifest.epochs[0].byte_length,
streamUrl: manifest.epochs[0].stream_url,
};
assert.equal(
recordedMediaFragmentUrl(epoch, generation, "init"),
`/api/v1/observation-sessions/session-1/media/camera-1/epochs/1/init.mp4?generation=${generation}`,
);
assert.equal(
recordedMediaFragmentUrl(epoch, generation, 17),
`/api/v1/observation-sessions/session-1/media/camera-1/epochs/1/segments/17.m4s?generation=${generation}`,
);
assert.throws(
() => recordedMediaFragmentUrl(epoch, "b".repeat(64), 17),
/не привязан/,
);
}); });
test("loading and error overlays fully conceal recorded camera pixels", async () => { test("loading and error overlays fully conceal recorded camera pixels", async () => {
+16 -2
View File
@@ -1159,6 +1159,7 @@ def build_session_router(
session_id: str, session_id: str,
artifact_id: str, artifact_id: str,
epoch_ordinal: int, epoch_ordinal: int,
generation: Annotated[str | None, Query(min_length=64, max_length=64)] = None,
if_match: Annotated[str | None, Header(alias="If-Match")] = None, if_match: Annotated[str | None, Header(alias="If-Match")] = None,
range_header: Annotated[str | None, Header(alias="Range")] = None, range_header: Annotated[str | None, Header(alias="Range")] = None,
) -> Response: ) -> Response:
@@ -1172,7 +1173,13 @@ def build_session_router(
artifact_id, artifact_id,
) )
expected_sha256 = _recorded_media_init_sha256(manifest, epoch_ordinal) expected_sha256 = _recorded_media_init_sha256(manifest, epoch_ordinal)
_require_matching_digest(f'"sha256:{expected_sha256}"', if_match) if generation is None:
_require_matching_digest(f'"sha256:{expected_sha256}"', if_match)
elif SAFE_SHA256.fullmatch(generation) is None or generation != manifest.generation_sha256:
raise HTTPException(
status_code=412,
detail="Поколение записанного видео было заменено.",
)
try: try:
media_file = recorded_media_inspector.get_init(manifest, epoch_ordinal) media_file = recorded_media_inspector.get_init(manifest, epoch_ordinal)
except SessionIntegrityError as exc: except SessionIntegrityError as exc:
@@ -1188,6 +1195,7 @@ def build_session_router(
artifact_id: str, artifact_id: str,
epoch_ordinal: int, epoch_ordinal: int,
segment_sequence: int, segment_sequence: int,
generation: Annotated[str | None, Query(min_length=64, max_length=64)] = None,
if_match: Annotated[str | None, Header(alias="If-Match")] = None, if_match: Annotated[str | None, Header(alias="If-Match")] = None,
range_header: Annotated[str | None, Header(alias="Range")] = None, range_header: Annotated[str | None, Header(alias="Range")] = None,
) -> Response: ) -> Response:
@@ -1205,7 +1213,13 @@ def build_session_router(
epoch_ordinal, epoch_ordinal,
segment_sequence, segment_sequence,
) )
_require_matching_digest(f'"sha256:{expected_sha256}"', if_match) if generation is None:
_require_matching_digest(f'"sha256:{expected_sha256}"', if_match)
elif SAFE_SHA256.fullmatch(generation) is None or generation != manifest.generation_sha256:
raise HTTPException(
status_code=412,
detail="Поколение записанного видео было заменено.",
)
try: try:
media_file = recorded_media_inspector.get_segment( media_file = recorded_media_inspector.get_segment(
manifest, manifest,
+45 -19
View File
@@ -194,10 +194,7 @@ async def render_file_response(
return { return {
"status": response.status_code, "status": response.status_code,
"headers": response.raw_headers, "headers": response.raw_headers,
}, b"".join( }, b"".join(chunk.encode("utf-8") if isinstance(chunk, str) else chunk for chunk in chunks)
chunk.encode("utf-8") if isinstance(chunk, str) else chunk
for chunk in chunks
)
sent: list[dict[str, Any]] = [] sent: list[dict[str, Any]] = []
request_delivered = False request_delivered = False
@@ -318,9 +315,7 @@ def test_session_router_exposes_immutable_lab_provenance(tmp_path: Path) -> None
assert item["lab"] == binding.as_dict() assert item["lab"] == binding.as_dict()
assert [value["id"] for value in source_listing["items"]] == [source.name] assert [value["id"] for value in source_listing["items"]] == [source.name]
assert [value["id"] for value in laboratory_listing["items"]] == [ assert [value["id"] for value in laboratory_listing["items"]] == [binding.session_id]
binding.session_id
]
assert detail["lab"] == binding.as_dict() assert detail["lab"] == binding.as_dict()
assert item["lab"]["source_session_id"] == source.name assert item["lab"]["source_session_id"] == source.name
assert item["lab"]["provenance"]["source_payloads_mutated"] is False assert item["lab"]["provenance"]["source_payloads_mutated"] is False
@@ -1350,9 +1345,7 @@ def test_recorded_perception_endpoint_returns_one_complete_optional_overlay(
assert response.body == b"RRF2perception" assert response.body == b"RRF2perception"
assert response.media_type == "application/vnd.rerun.rrd" assert response.media_type == "application/vnd.rerun.rrd"
assert response.headers["content-length"] == str(len(response.body)) assert response.headers["content-length"] == str(len(response.body))
assert calls == [ assert calls == [(session.name, "nodedc_mission_core_recorded", "recording-001")]
(session.name, "nodedc_mission_core_recorded", "recording-001")
]
status_route = endpoint( status_route = endpoint(
router, router,
"/api/v1/observation-sessions/{session_id}/perception/status", "/api/v1/observation-sessions/{session_id}/perception/status",
@@ -1699,8 +1692,7 @@ def test_session_router_exposes_opaque_recorded_media_manifest_and_ranges(
render_file_response(stream_response, range_header=None) render_file_response(stream_response, range_header=None)
) )
stream_headers = { stream_headers = {
key.decode("latin-1"): value.decode("latin-1") key.decode("latin-1"): value.decode("latin-1") for key, value in stream_start["headers"]
for key, value in stream_start["headers"]
} }
assert stream_start["status"] == 206 assert stream_start["status"] == 206
assert stream_body == init[-2:] + segment[:4] assert stream_body == init[-2:] + segment[:4]
@@ -1736,6 +1728,19 @@ def test_session_router_exposes_opaque_recorded_media_manifest_and_ranges(
assert init_start["status"] == 206 assert init_start["status"] == 206
assert init_body == init[4:8] assert init_body == init[4:8]
generation_bound_init = init_route(
session_id=session.name,
artifact_id=artifact_id,
epoch_ordinal=1,
generation=generation,
range_header="bytes=0-3",
)
generation_init_start, generation_init_body = asyncio.run(
render_file_response(generation_bound_init, range_header=b"bytes=0-3")
)
assert generation_init_start["status"] == 206
assert generation_init_body == init[:4]
with pytest.raises(HTTPException) as stale_segment_generation: with pytest.raises(HTTPException) as stale_segment_generation:
segment_route( segment_route(
session_id=session.name, session_id=session.name,
@@ -1768,6 +1773,31 @@ def test_session_router_exposes_opaque_recorded_media_manifest_and_ranges(
assert headers["content-length"] == "6" assert headers["content-length"] == "6"
assert headers["x-content-type-options"] == "nosniff" assert headers["x-content-type-options"] == "nosniff"
generation_bound_segment = segment_route(
session_id=session.name,
artifact_id=artifact_id,
epoch_ordinal=1,
segment_sequence=1,
generation=generation,
range_header="bytes=0-3",
)
generation_segment_start, generation_segment_body = asyncio.run(
render_file_response(generation_bound_segment, range_header=b"bytes=0-3")
)
assert generation_segment_start["status"] == 206
assert generation_segment_body == segment[:4]
with pytest.raises(HTTPException) as stale_fragment_generation:
segment_route(
session_id=session.name,
artifact_id=artifact_id,
epoch_ordinal=1,
segment_sequence=1,
generation="0" * 64,
range_header=None,
)
assert stale_fragment_generation.value.status_code == 412
original_segment = writer.segments_dir / "1.m4s" original_segment = writer.segments_dir / "1.m4s"
outside = tmp_path / "outside.m4s" outside = tmp_path / "outside.m4s"
outside.write_bytes(segment) outside.write_bytes(segment)
@@ -1859,14 +1889,12 @@ def test_replay_exposes_generation_bound_perception_video_with_native_ranges(
) )
manifest_route = endpoint( manifest_route = endpoint(
router, router,
"/api/v1/observation-sessions/{session_id}/perception-media/" "/api/v1/observation-sessions/{session_id}/perception-media/{result_id}/manifest",
"{result_id}/manifest",
"GET", "GET",
) )
stream_route = endpoint( stream_route = endpoint(
router, router,
"/api/v1/observation-sessions/{session_id}/perception-media/" "/api/v1/observation-sessions/{session_id}/perception-media/{result_id}/recording.mp4",
"{result_id}/recording.mp4",
"GET", "GET",
) )
@@ -1929,9 +1957,7 @@ def test_replay_exposes_generation_bound_perception_video_with_native_ranges(
range_header="bytes=7-14", range_header="bytes=7-14",
) )
start, body = asyncio.run(render_file_response(response, range_header=b"bytes=7-14")) start, body = asyncio.run(render_file_response(response, range_header=b"bytes=7-14"))
headers = { headers = {key.decode("latin-1"): value.decode("latin-1") for key, value in start["headers"]}
key.decode("latin-1"): value.decode("latin-1") for key, value in start["headers"]
}
assert start["status"] == 206 assert start["status"] == 206
assert body == video_payload[7:15] assert body == video_payload[7:15]
assert headers["content-range"] == f"bytes 7-14/{len(video_payload)}" assert headers["content-range"] == f"bytes 7-14/{len(video_payload)}"