diff --git a/frontend/libs/components/generation-list/src/lib/pending-video-thumbnails.spec.tsx b/frontend/libs/components/generation-list/src/lib/pending-video-thumbnails.spec.tsx
--- a/frontend/libs/components/generation-list/src/lib/pending-video-thumbnails.spec.tsx
+++ b/frontend/libs/components/generation-list/src/lib/pending-video-thumbnails.spec.tsx
@@ -39,6 +39,7 @@ beforeEach(() => {
 
 afterEach(() => {
   cleanup();
+  resetVideoThumbnailsForTests();
   vi.unstubAllGlobals();
   vi.useRealTimers();
 });
@@ -66,6 +67,54 @@ describe("checker", () => {
     expect(getVideoThumbnailState(THUMB)).toEqual({ status: "pending" });
   });
 
+  it("retries a probe that never settles and ignores its late callbacks", () => {
+    markVideoThumbnailFailed(THUMB);
+    vi.advanceTimersByTime(5_000);
+    const staleLoad = probes[0].onload!;
+    const staleError = probes[0].onerror!;
+
+    vi.advanceTimersByTime(30_000);
+    expect(probes[0].onload).toBeNull();
+    expect(probes[0].onerror).toBeNull();
+    vi.advanceTimersByTime(5_000);
+    expect(probes).toHaveLength(2);
+
+    staleLoad();
+    staleError();
+    expect(getVideoThumbnailState(THUMB)).toEqual({ status: "pending" });
+
+    probes[1].onload!();
+    const recoveredSrc = probes[1].src;
+    staleLoad();
+    staleError();
+    expect(getVideoThumbnailState(THUMB)).toEqual({ status: "ready", src: recoveredSrc });
+    vi.advanceTimersByTime(60_000);
+    expect(probes).toHaveLength(2);
+    expect(vi.getTimerCount()).toBe(0);
+  });
+
+  it("cancels in-flight probes when reset without affecting a new entry", () => {
+    markVideoThumbnailFailed(THUMB);
+    vi.advanceTimersByTime(5_000);
+    const staleLoad = probes[0].onload!;
+    const staleError = probes[0].onerror!;
+
+    resetVideoThumbnailsForTests();
+    expect(probes[0].onload).toBeNull();
+    expect(probes[0].onerror).toBeNull();
+    expect(getVideoThumbnailState(THUMB)).toBeUndefined();
+    expect(vi.getTimerCount()).toBe(0);
+
+    markVideoThumbnailFailed(THUMB);
+    vi.advanceTimersByTime(5_000);
+    expect(probes).toHaveLength(2);
+    staleLoad();
+    staleError();
+    expect(getVideoThumbnailState(THUMB)).toEqual({ status: "pending" });
+    probes[1].onload!();
+    expect(getVideoThumbnailState(THUMB)).toEqual({ status: "ready", src: probes[1].src });
+  });
+
   it("gives up after an hour", () => {
     markVideoThumbnailFailed(THUMB);
     failProbesFor(60 * 60_000 + 60_000);
@@ -141,6 +190,20 @@ describe("GalleryThumbnail", () => {
     expect(screen.getByTestId("fallback-icon")).toBeTruthy();
   });
 
+  it("stops showing the spinner after an hour even when probes never settle", () => {
+    renderVideoThumbnail();
+    fireEvent.error(screen.getByRole("img"));
+    expect(screen.getByText("Loading thumbnail…")).toBeTruthy();
+
+    act(() => vi.advanceTimersByTime(60 * 60_000));
+    expect(getVideoThumbnailState(THUMB)).toEqual({ status: "gave_up" });
+    expect(screen.getByTestId("fallback-icon")).toBeTruthy();
+    const probeCount = probes.length;
+    vi.advanceTimersByTime(60_000);
+    expect(probes).toHaveLength(probeCount);
+    expect(vi.getTimerCount()).toBe(0);
+  });
+
   it("leaves image thumbnails on the placeholder fallback", () => {
     render(
       <GalleryThumbnail thumbnail={THUMB} alt="Image" isVideo={false} fallbackIcon={VideoIcon} />,
diff --git a/frontend/libs/components/generation-list/src/lib/pending-video-thumbnails.ts b/frontend/libs/components/generation-list/src/lib/pending-video-thumbnails.ts
--- a/frontend/libs/components/generation-list/src/lib/pending-video-thumbnails.ts
+++ b/frontend/libs/components/generation-list/src/lib/pending-video-thumbnails.ts
@@ -20,6 +20,9 @@ const CHECK_SCHEDULE: { untilMs: number; everyMs: number }[] = [
 // Past the last window the thumbnail is treated as never coming.
 const GIVE_UP_AFTER_MS = CHECK_SCHEDULE[CHECK_SCHEDULE.length - 1].untilMs;
 
+// A request can stall without firing load or error; release it so retries continue.
+const PROBE_TIMEOUT_MS = 30_000;
+
 // ── Types ─────────────────────────────────────────────────────────────────
 
 export type VideoThumbnailState =
@@ -34,6 +37,7 @@ interface PendingEntry {
   markedAt: number;
   nextCheckAt: number;
   probing: boolean;
+  cancelProbe?: () => void;
 }
 
 // ── Registry state ────────────────────────────────────────────────────────
@@ -89,6 +93,7 @@ export function useVideoThumbnailState(
 
 /** Test-only: forget all tracked thumbnails and stop the checker. */
 export function resetVideoThumbnailsForTests() {
+  for (const entry of pending.values()) entry.cancelProbe?.();
   pending.clear();
   settled.clear();
   listeners.clear();
@@ -132,19 +137,48 @@ function probe(url: string, entry: PendingEntry) {
   entry.probing = true;
   const src = withCacheBust(url);
   const img = new Image();
+  let finished = false;
+  let probeTimer: ReturnType<typeof setTimeout>;
+  const cleanup = () => {
+    if (finished) return false;
+    finished = true;
+    clearTimeout(probeTimer);
+    img.onload = null;
+    img.onerror = null;
+    entry.cancelProbe = undefined;
+    return true;
+  };
   img.onload = () => {
-    if (pending.get(url) !== entry) return; // Reset while in flight.
+    if (!cleanup() || pending.get(url) !== entry) return;
     pending.delete(url);
     settled.set(url, { status: "ready", src });
     notify(url);
     scheduleNextTick();
   };
-  img.onerror = () => {
-    if (pending.get(url) !== entry) return;
+  const onFailure = () => {
+    if (!cleanup() || pending.get(url) !== entry) return;
     entry.probing = false;
-    entry.nextCheckAt = Date.now() + checkInterval(Date.now() - entry.markedAt);
+    const now = Date.now();
+    if (now - entry.markedAt >= GIVE_UP_AFTER_MS) {
+      pending.delete(url);
+      settled.set(url, GAVE_UP_STATE);
+      notify(url);
+    } else {
+      entry.nextCheckAt = Math.min(
+        now + checkInterval(now - entry.markedAt),
+        entry.markedAt + GIVE_UP_AFTER_MS,
+      );
+    }
     scheduleNextTick();
   };
+  img.onerror = onFailure;
+  probeTimer = setTimeout(() => {
+    onFailure();
+    img.src = "";
+  }, Math.min(PROBE_TIMEOUT_MS, entry.markedAt + GIVE_UP_AFTER_MS - Date.now()));
+  entry.cancelProbe = () => {
+    if (cleanup()) img.src = "";
+  };
   img.src = src;
 }
 
diff --git a/netlify.toml b/netlify.toml
new file mode 100644
--- /dev/null
+++ b/netlify.toml
@@ -0,0 +1,7 @@
+# The `storyteller-docs` Netlify site still builds this repository, but its
+# build command (./script/netlify_build_docs.sh) and the docs themselves now
+# live in storytold/artcraft-services, so every deploy preview here fails.
+# Skip those builds until the Netlify site is repointed or disconnected, then
+# delete this file.
+[build]
+  ignore = "exit 0"
