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 (