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({
|
||||
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<string | null>(null);
|
||||
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 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 (
|
||||
<div
|
||||
|
||||
@@ -24,6 +24,8 @@ export function RecordedEvidenceVideoScene({
|
||||
semanticOverlay,
|
||||
ariaLabel,
|
||||
interactive = true,
|
||||
segmentSequence,
|
||||
segmentCount,
|
||||
onPlaybackChange,
|
||||
}: {
|
||||
source: ObservationSourceDescriptor;
|
||||
@@ -34,6 +36,8 @@ export function RecordedEvidenceVideoScene({
|
||||
semanticOverlay?: RecordedEvidenceSemanticOverlay;
|
||||
ariaLabel: string;
|
||||
interactive?: boolean;
|
||||
segmentSequence?: number;
|
||||
segmentCount?: number;
|
||||
onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -43,6 +47,8 @@ export function RecordedEvidenceVideoScene({
|
||||
playback={playback}
|
||||
interactive={interactive}
|
||||
prepare
|
||||
segmentSequence={segmentSequence}
|
||||
segmentCount={segmentCount}
|
||||
onPlaybackChange={onPlaybackChange}
|
||||
/>
|
||||
{semanticOverlay ? (
|
||||
|
||||
@@ -42,6 +42,7 @@ export function advanceRecordedEvidencePlayback(
|
||||
|
||||
export function useRecordedEvidencePlayback(
|
||||
range: RecordedEvidencePlaybackRange | null,
|
||||
{ clock = "animation" }: { clock?: "animation" | "external" } = {},
|
||||
) {
|
||||
const [playback, setPlayback] = useState<RecordedObservationPlayback>({
|
||||
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]);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ? (
|
||||
<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, /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, /<RecordedFmp4Player/);
|
||||
|
||||
@@ -8,6 +8,7 @@ let server;
|
||||
let fetchRecordedMediaArchive;
|
||||
let recordedMediaPresentationState;
|
||||
let recordedMediaSeekableCoverage;
|
||||
let recordedMediaFragmentUrl;
|
||||
|
||||
before(async () => {
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user