diff --git a/apps/control-station/src/components/RecordedFmp4Player.tsx b/apps/control-station/src/components/RecordedFmp4Player.tsx index 421bb2e..90db69e 100644 --- a/apps/control-station/src/components/RecordedFmp4Player.tsx +++ b/apps/control-station/src/components/RecordedFmp4Player.tsx @@ -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; + 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 { + 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 { + 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 { + 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 { + 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({ source, playback, interactive = false, prepare = true, + segmentSequence = null, + segmentCount = null, sessionGate = "ready", admissionKey = null, onAdmissionChange, @@ -246,6 +383,8 @@ export function RecordedFmp4Player({ playback?: RecordedObservationPlayback | null; interactive?: boolean; prepare?: boolean; + segmentSequence?: number | null; + segmentCount?: number | null; sessionGate?: RecordedAdmissionPhase; admissionKey?: string | null; onAdmissionChange?: (state: RecordedCameraAdmissionState) => void; @@ -290,6 +429,8 @@ export function RecordedFmp4Player({ const [state, setState] = useState<"loading" | "ready" | "error">("loading"); const [readyGeneration, setReadyGeneration] = useState(null); const [bufferRevision, setBufferRevision] = useState(0); + const segmentedRuntimeRef = useRef(null); + const [segmentedRuntimeGeneration, setSegmentedRuntimeGeneration] = useState(null); const currentSeconds = playback?.currentSeconds ?? contract?.timelineStartSeconds ?? 0; const playbackRate = playback?.rate && Number.isFinite(playback.rate) ? Math.min(4, Math.max(0.25, playback.rate)) @@ -298,6 +439,17 @@ export function RecordedFmp4Player({ () => selectRecordedMediaEpoch(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 selectedGeneration = contract && epoch ? `${contract.manifestGenerationSha256}:${epoch.ordinal}:${epoch.timelineStartSeconds}:${epoch.timelineEndSeconds}` @@ -354,7 +506,7 @@ export function RecordedFmp4Player({ }, [admissionKey, contract, prepare]); useEffect(() => { - if (!archive || !contract || !prepare) return; + if (!archive || !contract || !prepare || segmented) return; const abort = new AbortController(); let disposed = false; void (async () => { @@ -390,11 +542,155 @@ export function RecordedFmp4Player({ disposed = true; abort.abort(); }; - }, [admissionKey, archive, contract, prepare]); + }, [admissionKey, archive, contract, prepare, segmented]); useEffect(() => { 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 generation = contract ? `${contract.manifestGenerationSha256}:${epochDescriptor.ordinal}:${epochDescriptor.timelineStartSeconds}:${epochDescriptor.timelineEndSeconds}` @@ -440,7 +736,7 @@ export function RecordedFmp4Player({ abort.abort(); cleanup?.(); }; - }, [archive?.byteLength, contract, epoch]); + }, [archive?.byteLength, contract, epoch, segmented]); useEffect(() => { const video = videoRef.current; @@ -482,9 +778,18 @@ export function RecordedFmp4Player({ useEffect(() => { 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; 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?.({ currentSeconds: epoch.timelineStartSeconds + video.currentTime, playing: !video.paused && !video.ended, @@ -511,7 +816,7 @@ export function RecordedFmp4Player({ video.cancelVideoFrameCallback(videoFrameRequest); } }; - }, [epoch, interactive, visualState]); + }, [currentSeconds, epoch, interactive, onPlaybackChange, visualState]); return (
void; }) { return ( @@ -43,6 +47,8 @@ export function RecordedEvidenceVideoScene({ playback={playback} interactive={interactive} prepare + segmentSequence={segmentSequence} + segmentCount={segmentCount} onPlaybackChange={onPlaybackChange} /> {semanticOverlay ? ( diff --git a/apps/control-station/src/components/laboratory/useRecordedEvidencePlayback.ts b/apps/control-station/src/components/laboratory/useRecordedEvidencePlayback.ts index 9a38a79..95e660d 100644 --- a/apps/control-station/src/components/laboratory/useRecordedEvidencePlayback.ts +++ b/apps/control-station/src/components/laboratory/useRecordedEvidencePlayback.ts @@ -42,6 +42,7 @@ export function advanceRecordedEvidencePlayback( export function useRecordedEvidencePlayback( range: RecordedEvidencePlaybackRange | null, + { clock = "animation" }: { clock?: "animation" | "external" } = {}, ) { const [playback, setPlayback] = useState({ currentSeconds: 0, @@ -60,7 +61,7 @@ export function useRecordedEvidencePlayback( }, [range]); useEffect(() => { - if (!validRange(range) || !playback.playing) return; + if (!validRange(range) || !playback.playing || clock === "external") return; let animationFrame = 0; let previous = performance.now(); const tick = (now: number) => { @@ -76,7 +77,7 @@ export function useRecordedEvidencePlayback( }; animationFrame = window.requestAnimationFrame(tick); return () => window.cancelAnimationFrame(animationFrame); - }, [playback.playing, range]); + }, [clock, playback.playing, range]); const seek = useCallback((seconds: number, pause = true) => { if (!validRange(range)) return; @@ -103,10 +104,20 @@ export function useRecordedEvidencePlayback( 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(() => ({ playback, seek, setPlaying, setRate, - }), [playback, seek, setPlaying, setRate]); + synchronize, + }), [playback, seek, setPlaying, setRate, synchronize]); } diff --git a/apps/control-station/src/styles/m4-replay-threat.css b/apps/control-station/src/styles/m4-replay-threat.css index 671164f..1a0af6b 100644 --- a/apps/control-station/src/styles/m4-replay-threat.css +++ b/apps/control-station/src/styles/m4-replay-threat.css @@ -218,8 +218,11 @@ } .m4-replay-threat-visual__overlay { + box-sizing: border-box; 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; padding: 0; backdrop-filter: none; @@ -232,6 +235,12 @@ 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 { position: absolute; inset: 0; diff --git a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx index e6a726f..0181887 100644 --- a/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx +++ b/apps/control-station/src/workspaces/laboratory/M4ReplayThreatVisual.tsx @@ -117,7 +117,9 @@ export function M4ReplayThreatVisual({ startSeconds: metadata.timeline.timelineStartSeconds, endSeconds: metadata.timeline.timelineEndSeconds, }) : null, [metadata.timeline]); - const playbackController = useRecordedEvidencePlayback(playbackRange); + const playbackController = useRecordedEvidencePlayback(playbackRange, { + clock: mediaMode === "video" ? "external" : "animation", + }); const timelineFrame = useM4ThreatTimelineFrame({ resultId, timeline: metadata.timeline, @@ -521,6 +523,9 @@ export function M4ReplayThreatVisual({ semanticOverlay={mediaMode === "video" ? semanticOverlay : undefined} ariaLabel={`M4.6 recorded-realtime frame ${frame?.sequence ?? 0}: ${activeBoxes.length} proposals`} interactive={false} + segmentSequence={frame ? frame.sequence + 1 : undefined} + segmentCount={timeline.frameCount} + onPlaybackChange={playbackController.synchronize} /> ) : videoError ? ( diff --git a/apps/control-station/test/m4ReplayThreat.test.mjs b/apps/control-station/test/m4ReplayThreat.test.mjs index 8b62466..373a8cf 100644 --- a/apps/control-station/test/m4ReplayThreat.test.mjs +++ b/apps/control-station/test/m4ReplayThreat.test.mjs @@ -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, /secondaryMode=\{\{/); 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.doesNotMatch(visual, /setPlaying\(false\)/); 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, /flex-flow: column nowrap/); 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, /bottom: auto/); assert.match(videoScene, / { server = await createServer({ @@ -19,6 +20,7 @@ before(async () => { fetchRecordedMediaArchive, recordedMediaPresentationState, recordedMediaSeekableCoverage, + recordedMediaFragmentUrl, } = 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); }); -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( new URL("../src/components/RecordedFmp4Player.tsx", import.meta.url), "utf8", ); 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 () => { diff --git a/src/k1link/web/session_api.py b/src/k1link/web/session_api.py index cd7cdd3..39f64d0 100644 --- a/src/k1link/web/session_api.py +++ b/src/k1link/web/session_api.py @@ -1159,6 +1159,7 @@ def build_session_router( session_id: str, artifact_id: str, 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, range_header: Annotated[str | None, Header(alias="Range")] = None, ) -> Response: @@ -1172,7 +1173,13 @@ def build_session_router( artifact_id, ) 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: media_file = recorded_media_inspector.get_init(manifest, epoch_ordinal) except SessionIntegrityError as exc: @@ -1188,6 +1195,7 @@ def build_session_router( artifact_id: str, epoch_ordinal: 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, range_header: Annotated[str | None, Header(alias="Range")] = None, ) -> Response: @@ -1205,7 +1213,13 @@ def build_session_router( epoch_ordinal, 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: media_file = recorded_media_inspector.get_segment( manifest, diff --git a/tests/test_session_api.py b/tests/test_session_api.py index 5560ef4..b0cbdff 100644 --- a/tests/test_session_api.py +++ b/tests/test_session_api.py @@ -194,10 +194,7 @@ async def render_file_response( return { "status": response.status_code, "headers": response.raw_headers, - }, b"".join( - chunk.encode("utf-8") if isinstance(chunk, str) else chunk - for chunk in chunks - ) + }, b"".join(chunk.encode("utf-8") if isinstance(chunk, str) else chunk for chunk in chunks) sent: list[dict[str, Any]] = [] 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 [value["id"] for value in source_listing["items"]] == [source.name] - assert [value["id"] for value in laboratory_listing["items"]] == [ - binding.session_id - ] + assert [value["id"] for value in laboratory_listing["items"]] == [binding.session_id] assert detail["lab"] == binding.as_dict() assert item["lab"]["source_session_id"] == source.name 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.media_type == "application/vnd.rerun.rrd" assert response.headers["content-length"] == str(len(response.body)) - assert calls == [ - (session.name, "nodedc_mission_core_recorded", "recording-001") - ] + assert calls == [(session.name, "nodedc_mission_core_recorded", "recording-001")] status_route = endpoint( router, "/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) ) stream_headers = { - key.decode("latin-1"): value.decode("latin-1") - for key, value in stream_start["headers"] + key.decode("latin-1"): value.decode("latin-1") for key, value in stream_start["headers"] } assert stream_start["status"] == 206 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_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: segment_route( 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["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" outside = tmp_path / "outside.m4s" outside.write_bytes(segment) @@ -1859,14 +1889,12 @@ def test_replay_exposes_generation_bound_perception_video_with_native_ranges( ) manifest_route = endpoint( router, - "/api/v1/observation-sessions/{session_id}/perception-media/" - "{result_id}/manifest", + "/api/v1/observation-sessions/{session_id}/perception-media/{result_id}/manifest", "GET", ) stream_route = endpoint( router, - "/api/v1/observation-sessions/{session_id}/perception-media/" - "{result_id}/recording.mp4", + "/api/v1/observation-sessions/{session_id}/perception-media/{result_id}/recording.mp4", "GET", ) @@ -1929,9 +1957,7 @@ def test_replay_exposes_generation_bound_perception_video_with_native_ranges( range_header="bytes=7-14", ) start, body = asyncio.run(render_file_response(response, range_header=b"bytes=7-14")) - headers = { - key.decode("latin-1"): value.decode("latin-1") for key, value in start["headers"] - } + headers = {key.decode("latin-1"): value.decode("latin-1") for key, value in start["headers"]} assert start["status"] == 206 assert body == video_payload[7:15] assert headers["content-range"] == f"bytes 7-14/{len(video_payload)}"