fix(lab): запускать RAV004 с первого кадра

This commit is contained in:
DCCONSTRUCTIONS
2026-08-30 14:33:17 +03:00
parent 35daf73d5c
commit 9c5259dbc9
3 changed files with 152 additions and 1 deletions
@@ -34,7 +34,9 @@ import {
RECORDED_BASE_POINT_COLOR_KEY, RECORDED_BASE_POINT_COLOR_KEY,
canPublishRecordedPlaybackController, canPublishRecordedPlaybackController,
createRecordedAutoplayGate, createRecordedAutoplayGate,
createRecordedInitialSeekGate,
createRecordedOpenWatchdog, createRecordedOpenWatchdog,
shouldReapplyRecordedBlueprint,
} from "../core/observation/recordedRerunLifecycle"; } from "../core/observation/recordedRerunLifecycle";
import type { import type {
RecordedPerceptionLayers, RecordedPerceptionLayers,
@@ -59,6 +61,7 @@ export {
attemptRecordedAutoplay, attemptRecordedAutoplay,
canPublishRecordedPlaybackController, canPublishRecordedPlaybackController,
createRecordedAutoplayGate, createRecordedAutoplayGate,
createRecordedInitialSeekGate,
createRecordedOpenWatchdog, createRecordedOpenWatchdog,
isRecordedPlaybackFullyBuffered, isRecordedPlaybackFullyBuffered,
isRecordedPlaybackPresentationReady, isRecordedPlaybackPresentationReady,
@@ -69,6 +72,7 @@ export {
recordedPlaybackRangeWhenReady, recordedPlaybackRangeWhenReady,
recordedPointColorKey, recordedPointColorKey,
rerunPresentationStatus, rerunPresentationStatus,
shouldReapplyRecordedBlueprint,
type RecordedPlaybackBufferState, type RecordedPlaybackBufferState,
} from "../core/observation/recordedRerunLifecycle"; } from "../core/observation/recordedRerunLifecycle";
export { export {
@@ -828,6 +832,7 @@ export function RerunViewport({
let publishPlaybackBufferState: (() => void) | null = null; let publishPlaybackBufferState: (() => void) | null = null;
let playbackState: RerunPlaybackState | null = null; let playbackState: RerunPlaybackState | null = null;
const recordedAutoplay = createRecordedAutoplayGate(); const recordedAutoplay = createRecordedAutoplayGate();
const recordedInitialSeek = createRecordedInitialSeekGate();
let blueprintChannel: RerunBlueprintChannel | null = null; let blueprintChannel: RerunBlueprintChannel | null = null;
let perceptionChannel: RerunBlueprintChannel | null = null; let perceptionChannel: RerunBlueprintChannel | null = null;
let perceptionReceiver: RerunNativeReceiver | null = null; let perceptionReceiver: RerunNativeReceiver | null = null;
@@ -1144,9 +1149,25 @@ export function RerunViewport({
}) => { }) => {
if ( if (
disposed || disposed ||
recordingOpened ||
(isRecordedSource && event.application_id !== "nodedc_mission_core_recorded") (isRecordedSource && event.application_id !== "nodedc_mission_core_recorded")
) return; ) return;
if (recordingOpened) {
const identity = recordedIdentityRef.current;
if (shouldReapplyRecordedBlueprint(
recordingOpened,
isRecordedSource,
recordedBlueprintUrl !== null,
identity,
event,
)) {
// A LAB sidecar is a second upstream receiver for the same
// recording store. Rerun re-opens that store only after the
// sidecar has been decoded, so reapply the canonical blueprint
// at this exact boundary instead of racing it during download.
setBlueprintChannelRevision((revision) => revision + 1);
}
return;
}
recordingOpened = true; recordingOpened = true;
if (!isRecordedSource) { if (!isRecordedSource) {
// Store discovery only establishes a candidate. Admission is // Store discovery only establishes a candidate. Admission is
@@ -1314,6 +1335,23 @@ export function RerunViewport({
playing = true; playing = true;
} }
} }
if (!autoplayWhenReady && !followLive && readyToRender) {
recordedInitialSeek.attempt(
viewerStartResolved,
recordedBuffer.fullyBuffered,
presentationReady,
rangeNs,
(initialStartNs) => {
viewer.set_current_time(event.recording_id, timeline, initialStartNs);
currentNs = initialStartNs;
},
initialPlaybackStartSeconds === undefined
? expectedTimelineStartSeconds === undefined
? undefined
: expectedTimelineStartSeconds * 1_000_000_000
: initialPlaybackStartSeconds * 1_000_000_000,
);
}
emitPlayback({ emitPlayback({
rangeNs: followLive rangeNs: followLive
? rangeNs ? rangeNs
@@ -196,6 +196,20 @@ export function isRecordedPlaybackFullyBuffered(
return recordedPlaybackBufferState(rangeNs, expectedTimelineEndSeconds).fullyBuffered; return recordedPlaybackBufferState(rangeNs, expectedTimelineEndSeconds).fullyBuffered;
} }
export function shouldReapplyRecordedBlueprint(
recordingOpened: boolean,
recordedSource: boolean,
hasBlueprint: boolean,
identity: { applicationId: string; recordingId: string } | null,
event: { application_id: string; recording_id: string },
): boolean {
return recordingOpened
&& recordedSource
&& hasBlueprint
&& identity?.applicationId === event.application_id
&& identity.recordingId === event.recording_id;
}
export function attemptRecordedAutoplay( export function attemptRecordedAutoplay(
seekToStart: () => void, seekToStart: () => void,
startPlaying: () => void, startPlaying: () => void,
@@ -209,6 +223,49 @@ export function attemptRecordedAutoplay(
} }
} }
export function createRecordedInitialSeekGate(): {
attempt: (
viewerStarted: boolean,
fullyBuffered: boolean,
presentationReady: boolean,
rangeNs: { min: number; max: number } | null,
seekToStart: (startNs: number) => void,
preferredStartNs?: number,
) => boolean;
attempted: () => boolean;
} {
let consumed = false;
return {
attempt(
viewerStarted,
fullyBuffered,
presentationReady,
rangeNs,
seekToStart,
preferredStartNs,
) {
if (
consumed
|| !viewerStarted
|| !fullyBuffered
|| !presentationReady
|| !isUsableRecordedPlaybackRange(rangeNs)
) return false;
consumed = true;
const startNs = Number.isFinite(preferredStartNs)
? Math.min(Math.max(preferredStartNs as number, rangeNs.min), rangeNs.max)
: rangeNs.min;
try {
seekToStart(startNs);
return true;
} catch {
return false;
}
},
attempted: () => consumed,
};
}
export function createRecordedAutoplayGate(): { export function createRecordedAutoplayGate(): {
attempt: ( attempt: (
viewerStarted: boolean, viewerStarted: boolean,
@@ -6,12 +6,14 @@ import { createServer } from "vite";
let server; let server;
let canPublishRecordedPlaybackController; let canPublishRecordedPlaybackController;
let createRecordedAutoplayGate; let createRecordedAutoplayGate;
let createRecordedInitialSeekGate;
let isRecordedPlaybackReady; let isRecordedPlaybackReady;
let isRecordedPlaybackPresentationReady; let isRecordedPlaybackPresentationReady;
let isUsableRecordedPlaybackRange; let isUsableRecordedPlaybackRange;
let recordedPlaybackBufferState; let recordedPlaybackBufferState;
let recordedPlaybackRangeWhenReady; let recordedPlaybackRangeWhenReady;
let rerunPresentationStatus; let rerunPresentationStatus;
let shouldReapplyRecordedBlueprint;
before(async () => { before(async () => {
server = await createServer({ server = await createServer({
@@ -22,12 +24,14 @@ before(async () => {
({ ({
canPublishRecordedPlaybackController, canPublishRecordedPlaybackController,
createRecordedAutoplayGate, createRecordedAutoplayGate,
createRecordedInitialSeekGate,
isRecordedPlaybackReady, isRecordedPlaybackReady,
isRecordedPlaybackPresentationReady, isRecordedPlaybackPresentationReady,
isUsableRecordedPlaybackRange, isUsableRecordedPlaybackRange,
recordedPlaybackBufferState, recordedPlaybackBufferState,
recordedPlaybackRangeWhenReady, recordedPlaybackRangeWhenReady,
rerunPresentationStatus, rerunPresentationStatus,
shouldReapplyRecordedBlueprint,
} = await server.ssrLoadModule("/src/components/RerunViewport.tsx")); } = await server.ssrLoadModule("/src/components/RerunViewport.tsx"));
}); });
@@ -159,6 +163,58 @@ test("recorded autoplay waits for the full range and then runs exactly once", ()
assert.equal(gate.attempted(), true); assert.equal(gate.attempted(), true);
}); });
test("paused recorded replay seeks once to its first presentable frame", () => {
const gate = createRecordedInitialSeekGate();
const seeks = [];
const range = { min: 0, max: 535_717_620_042 };
assert.equal(gate.attempt(
true,
true,
true,
range,
(value) => seeks.push(value),
39_215_263_458,
), true);
assert.equal(gate.attempt(
true,
true,
true,
range,
(value) => seeks.push(value),
50_000_000_000,
), false);
assert.deepEqual(seeks, [39_215_263_458]);
assert.equal(gate.attempted(), true);
});
test("LAB sidecar store reopen reapplies only its matching recorded blueprint", () => {
const identity = {
applicationId: "nodedc_mission_core_recorded",
recordingId: "recording-001",
};
const event = {
application_id: "nodedc_mission_core_recorded",
recording_id: "recording-001",
};
assert.equal(
shouldReapplyRecordedBlueprint(true, true, true, identity, event),
true,
);
assert.equal(
shouldReapplyRecordedBlueprint(false, true, true, identity, event),
false,
);
assert.equal(
shouldReapplyRecordedBlueprint(true, true, true, identity, {
...event,
recording_id: "recording-002",
}),
false,
);
});
test("recorded autoplay starts at the first presentable camera frame without shrinking the range", () => { test("recorded autoplay starts at the first presentable camera frame without shrinking the range", () => {
const gate = createRecordedAutoplayGate(); const gate = createRecordedAutoplayGate();
const seeks = []; const seeks = [];