diff --git a/ui/src/components/artifacts/ArtifactCard.test.tsx b/ui/src/components/artifacts/ArtifactCard.test.tsx index 2c7503ff..9ea18a61 100644 --- a/ui/src/components/artifacts/ArtifactCard.test.tsx +++ b/ui/src/components/artifacts/ArtifactCard.test.tsx @@ -1,4 +1,8 @@ +// @vitest-environment jsdom + import type { ReactNode } from "react"; +import { flushSync } from "react-dom"; +import { createRoot } from "react-dom/client"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it, vi } from "vitest"; @@ -88,6 +92,82 @@ describe("ArtifactCard", () => { expect(markup).toContain('preload="metadata"'); }); + it("seeks video previews after metadata loads so a thumbnail frame is painted", () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + flushSync(() => { + root.render( + , + ); + }); + + const video = container.querySelector("video") as HTMLVideoElement; + expect(video).not.toBeNull(); + expect(video.dataset.frameReady).toBe("false"); + + Object.defineProperty(video, "readyState", { + configurable: true, + value: HTMLMediaElement.HAVE_METADATA, + }); + + flushSync(() => { + video.dispatchEvent(new Event("loadedmetadata", { bubbles: true })); + }); + + expect(video.currentTime).toBe(0.05); + + flushSync(() => { + video.dispatchEvent(new Event("seeked", { bubbles: true })); + }); + + expect(video.dataset.frameReady).toBe("true"); + + flushSync(() => root.unmount()); + 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 f5f6d0e7..bc4a4819 100644 --- a/ui/src/components/artifacts/ArtifactCard.tsx +++ b/ui/src/components/artifacts/ArtifactCard.tsx @@ -1,4 +1,4 @@ -import { 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"; @@ -52,6 +52,16 @@ function ImagePreview({ artifact }: { artifact: CompanyArtifact }) { 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 ( @@ -61,6 +71,43 @@ function VideoPreview({ artifact }: { artifact: CompanyArtifact }) { ); } + + 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; + const video = event.currentTarget; + const duration = Number.isFinite(video.duration) && video.duration > 0 ? video.duration : 0; + const seekTarget = duration > 0 ? Math.min(0.12, duration / 2) : 0.05; + try { + if (Math.abs(video.currentTime - seekTarget) > 0.001) { + video.currentTime = seekTarget; + scheduleFrameReadyFallback(); + } else { + markFrameReady(); + } + } catch { + markFrameReady(); + } + }; + const handleLoadedData = (event: SyntheticEvent) => { + if (thumbnailSeekRequested.current || event.currentTarget.currentTime > 0) { + markFrameReady(); + } + }; + return (