From 32d38571ede382dd0b234621abd180dfcd932d08 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 6 Jun 2026 19:45:38 +0000 Subject: [PATCH] PAP-10499: guard video thumbnail fallback Co-Authored-By: Paperclip --- .../artifacts/ArtifactCard.test.tsx | 38 +++++++++++++++++++ ui/src/components/artifacts/ArtifactCard.tsx | 27 ++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/ui/src/components/artifacts/ArtifactCard.test.tsx b/ui/src/components/artifacts/ArtifactCard.test.tsx index 5e206763f1a..9ea18a61894 100644 --- a/ui/src/components/artifacts/ArtifactCard.test.tsx +++ b/ui/src/components/artifacts/ArtifactCard.test.tsx @@ -130,6 +130,44 @@ describe("ArtifactCard", () => { container.remove(); }); + it("reveals video previews if the browser does not report seek completion", () => { + vi.useFakeTimers(); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + try { + flushSync(() => { + root.render( + , + ); + }); + + const video = container.querySelector("video") as HTMLVideoElement; + expect(video).not.toBeNull(); + expect(video.dataset.frameReady).toBe("false"); + + flushSync(() => { + video.dispatchEvent(new Event("loadedmetadata", { bubbles: true })); + }); + + expect(video.currentTime).toBe(0.05); + expect(video.dataset.frameReady).toBe("false"); + + flushSync(() => { + vi.advanceTimersByTime(3000); + }); + + expect(video.dataset.frameReady).toBe("true"); + } finally { + flushSync(() => root.unmount()); + container.remove(); + vi.useRealTimers(); + } + }); + it("renders a falling-back video placeholder when no content path exists", () => { const markup = renderToStaticMarkup( , diff --git a/ui/src/components/artifacts/ArtifactCard.tsx b/ui/src/components/artifacts/ArtifactCard.tsx index 61c52b1e800..bc4a4819093 100644 --- a/ui/src/components/artifacts/ArtifactCard.tsx +++ b/ui/src/components/artifacts/ArtifactCard.tsx @@ -1,4 +1,4 @@ -import { type SyntheticEvent, useRef, useState } from "react"; +import { type SyntheticEvent, useEffect, useRef, useState } from "react"; import { Download, ExternalLink, Paperclip, Play } from "lucide-react"; import type { CompanyArtifact } from "@/api/artifacts"; import { Link } from "@/lib/router"; @@ -54,6 +54,14 @@ function VideoPreview({ artifact }: { artifact: CompanyArtifact }) { const [errored, setErrored] = useState(false); const [frameReady, setFrameReady] = useState(false); const thumbnailSeekRequested = useRef(false); + const frameReadyFallbackTimer = useRef(null); + useEffect(() => { + return () => { + if (frameReadyFallbackTimer.current !== null) { + window.clearTimeout(frameReadyFallbackTimer.current); + } + }; + }, []); if (errored || !artifact.contentPath) { return ( @@ -64,7 +72,19 @@ function VideoPreview({ artifact }: { artifact: CompanyArtifact }) { ); } - const markFrameReady = () => setFrameReady(true); + const markFrameReady = () => { + if (frameReadyFallbackTimer.current !== null) { + window.clearTimeout(frameReadyFallbackTimer.current); + frameReadyFallbackTimer.current = null; + } + setFrameReady(true); + }; + const scheduleFrameReadyFallback = () => { + if (frameReadyFallbackTimer.current !== null) { + window.clearTimeout(frameReadyFallbackTimer.current); + } + frameReadyFallbackTimer.current = window.setTimeout(markFrameReady, 3000); + }; const loadThumbnailFrame = (event: SyntheticEvent) => { if (thumbnailSeekRequested.current) return; thumbnailSeekRequested.current = true; @@ -74,6 +94,9 @@ function VideoPreview({ artifact }: { artifact: CompanyArtifact }) { try { if (Math.abs(video.currentTime - seekTarget) > 0.001) { video.currentTime = seekTarget; + scheduleFrameReadyFallback(); + } else { + markFrameReady(); } } catch { markFrameReady();