fix(lab): stream synchronized replay fragments
This commit is contained in:
@@ -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 () => {
|
||||||
|
|||||||
@@ -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
@@ -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)}"
|
||||||
|
|||||||
Reference in New Issue
Block a user