diff --git a/src/features/panels/Image/ImagePanel.tsx b/src/features/panels/Image/ImagePanel.tsx index 1a4510e..26e8e4c 100644 --- a/src/features/panels/Image/ImagePanel.tsx +++ b/src/features/panels/Image/ImagePanel.tsx @@ -76,6 +76,7 @@ export const ImagePanel: React.FC = (props) => { const workerDisposeTimerRef = useRef(null); const transferredCanvasRef = useRef(null); const lastPlaybackTimeNsRef = useRef(null); + const h264SeekRepairAbortRef = useRef(null); const lastUiStatusRef = useRef({ phase: 'idle' }); const h264ModeRef = useRef(false); const [status, setStatus] = useState({ phase: 'idle' }); @@ -254,6 +255,13 @@ export const ImagePanel: React.FC = (props) => { }; }, [player, mainConsumerId, h264ConsumerId, topic]); + useEffect(() => { + return () => { + h264SeekRepairAbortRef.current?.abort(); + h264SeekRepairAbortRef.current = null; + }; + }, [player, topic]); + // Keep the worker's media deadline current. On rewind, rebuild H.264 state // from the nearest complete random-access point. useEffect(() => { @@ -266,13 +274,23 @@ export const ImagePanel: React.FC = (props) => { const nowNs = toNano(time); const previousNs = lastPlaybackTimeNsRef.current; if (previousNs != null && nowNs + 5_000_000n < previousNs) { + h264SeekRepairAbortRef.current?.abort(); + h264SeekRepairAbortRef.current = null; const worker = workerRef.current; if (worker && topic && h264ModeRef.current) { + const controller = new AbortController(); + h264SeekRepairAbortRef.current = controller; worker.postMessage({ type: 'reset', preserveFrame: true, } satisfies ImageRenderWorkerRequest); - void repairH264Seek(player, worker, topic, time); + void repairH264Seek(player, worker, topic, time, { + signal: controller.signal, + }).finally(() => { + if (h264SeekRepairAbortRef.current === controller) { + h264SeekRepairAbortRef.current = null; + } + }); } else { workerRef.current?.postMessage({ type: 'reset' } satisfies ImageRenderWorkerRequest); } diff --git a/src/features/panels/Image/core/h264SeekRepair.test.ts b/src/features/panels/Image/core/h264SeekRepair.test.ts index a631a7a..779d50f 100644 --- a/src/features/panels/Image/core/h264SeekRepair.test.ts +++ b/src/features/panels/Image/core/h264SeekRepair.test.ts @@ -168,4 +168,30 @@ describe('h264SeekRepair', () => { expect(keyPayload.byteLength).toBeGreaterThan(0); expect(deltaPayload.byteLength).toBeGreaterThan(0); }); + + it('does not reset or post frames when an in-flight repair is aborted', async () => { + let resolveMessages: ((messages: RosMessageEvent[]) => void) | undefined; + const messagesPromise = new Promise((resolve) => { + resolveMessages = resolve; + }); + const posts: unknown[] = []; + const worker = { + postMessage(request: unknown) { + posts.push(request); + }, + } as unknown as Worker; + const player = { + getMessagesInTimeRange: () => messagesPromise, + } as unknown as Player; + const controller = new AbortController(); + + const repair = repairH264Seek(player, worker, '/camera/video', { sec: 2, nsec: 0 }, { + signal: controller.signal, + }); + controller.abort(); + resolveMessages?.([makeEvent(1, keyChunk), makeEvent(2, deltaChunk)]); + + await expect(repair).resolves.toBe(false); + expect(posts).toEqual([]); + }); }); diff --git a/src/features/panels/Image/core/h264SeekRepair.ts b/src/features/panels/Image/core/h264SeekRepair.ts index 97943c3..36e7111 100644 --- a/src/features/panels/Image/core/h264SeekRepair.ts +++ b/src/features/panels/Image/core/h264SeekRepair.ts @@ -73,8 +73,9 @@ export async function repairH264Seek( worker: Worker, topic: string, targetTime: Time, + options: { signal?: AbortSignal } = {}, ): Promise { - if (!player.getMessagesInTimeRange) { + if (!player.getMessagesInTimeRange || options.signal?.aborted) { return false; } @@ -85,6 +86,9 @@ export async function repairH264Seek( end: targetTime, topics: [topic], }); + if (options.signal?.aborted) { + return false; + } const repairFrames = selectH264SeekRepairFrames( messages.filter((event) => event.topic === topic),