thumbnail speedup
This commit is contained in:
parent
93b664d871
commit
b8b2091067
|
|
@ -8,13 +8,14 @@ import { BoxIcon, ImagesIcon, PlusIcon, UploadIcon, XIcon } from "lucide-react";
|
|||
import { twMerge } from "tailwind-merge";
|
||||
import { useImageTo3DStore } from "../../pages/PageImageTo3DObject/ImageTo3DStore";
|
||||
import { useImageTo3DWorldStore } from "../../pages/PageImageTo3DWorld/ImageTo3DWorldStore";
|
||||
import { MediaUploadApi, downloadFileFromUrl } from "@storyteller/api";
|
||||
import { MediaUploadApi } from "@storyteller/api";
|
||||
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
|
||||
import {
|
||||
GenerateMesh,
|
||||
EnqueueImageTo3dObjectModel,
|
||||
GenerateSplat,
|
||||
commandErrorMessage,
|
||||
downloadFileFromUrl,
|
||||
} from "@storyteller/tauri-api";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
|
|
|||
|
|
@ -2,12 +2,15 @@ import toast from "react-hot-toast";
|
|||
import {
|
||||
FilterMediaClasses,
|
||||
PromptsApi,
|
||||
} from "@storyteller/api";
|
||||
import type { Prompts } from "@storyteller/api";
|
||||
import {
|
||||
DownloadUrl,
|
||||
useModelsStore,
|
||||
downloadUrlToPath,
|
||||
pickDownloadDirectory,
|
||||
promptDownloadLocationIfNeeded,
|
||||
} from "@storyteller/api";
|
||||
import type { Prompts } from "@storyteller/api";
|
||||
import { DownloadUrl, useModelsStore } from "@storyteller/tauri-api";
|
||||
} from "@storyteller/tauri-api";
|
||||
import {
|
||||
RefImage,
|
||||
RefVideo,
|
||||
|
|
|
|||
|
|
@ -35,6 +35,11 @@ import {
|
|||
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
|
||||
// Newly completed videos that arrived without a thumbnail URL are looked up
|
||||
// again on this interval, for up to MISSING_THUMBNAIL_GIVE_UP_MS.
|
||||
const MISSING_THUMBNAIL_POLL_MS = 15_000;
|
||||
const MISSING_THUMBNAIL_GIVE_UP_MS = 10 * 60_000;
|
||||
|
||||
const FAILED_STATUSES = new Set([
|
||||
"complete_failure",
|
||||
"attempt_failed",
|
||||
|
|
@ -197,6 +202,71 @@ export function useDesktopGenerationFeed(options: {
|
|||
load();
|
||||
});
|
||||
|
||||
// Newly completed videos occasionally arrive without a thumbnail URL (the
|
||||
// Rust media-file lookup fails open). The library copy would have it, but the
|
||||
// newly completed item takes precedence in the feed, so look it up again
|
||||
// until the URL shows up. GalleryThumbnail then re-checks the image itself.
|
||||
const newlyCompletedRef = useRef(newlyCompleted);
|
||||
newlyCompletedRef.current = newlyCompleted;
|
||||
const missingThumbnailSinceRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (mediaType !== "video") return;
|
||||
let cancelled = false;
|
||||
let running = false;
|
||||
|
||||
const lookUpMissingThumbnails = async () => {
|
||||
if (running) return; // A slow round is still going.
|
||||
running = true;
|
||||
try {
|
||||
await lookUpMissingThumbnailsOnce();
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
};
|
||||
|
||||
const lookUpMissingThumbnailsOnce = async () => {
|
||||
const now = Date.now();
|
||||
const missing = newlyCompletedRef.current.filter((item) => {
|
||||
if (item.thumbnail) return false;
|
||||
const since = missingThumbnailSinceRef.current.get(item.id) ?? now;
|
||||
missingThumbnailSinceRef.current.set(item.id, since);
|
||||
return now - since < MISSING_THUMBNAIL_GIVE_UP_MS;
|
||||
});
|
||||
for (const item of missing) {
|
||||
const response = await mediaApiRef.current.GetMediaFileByToken({
|
||||
mediaFileToken: item.id,
|
||||
});
|
||||
if (cancelled) return;
|
||||
const links = response.success
|
||||
? (response.data?.media_links as VideoMediaLinks | undefined)
|
||||
: undefined;
|
||||
// Without a preview or template, getMediaThumbnail would fall back to
|
||||
// the .mp4 itself, which an <img> can't show. Try again next round.
|
||||
if (!links?.maybe_video_previews?.animated && !links?.maybe_thumbnail_template) {
|
||||
continue;
|
||||
}
|
||||
const thumbnail = getMediaThumbnail(links, "video", {
|
||||
size: THUMBNAIL_SIZES.LARGE,
|
||||
});
|
||||
const stillThumbnail = getMediaStillThumbnail(links, {
|
||||
size: THUMBNAIL_SIZES.LARGE,
|
||||
});
|
||||
setNewlyCompleted((prev) =>
|
||||
prev.map((i) =>
|
||||
i.id === item.id ? { ...i, thumbnail, stillThumbnail } : i,
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const intervalId = setInterval(lookUpMissingThumbnails, MISSING_THUMBNAIL_POLL_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [mediaType]);
|
||||
|
||||
// Poll + reload on Tauri generation events and local "task-queue-update"
|
||||
// dispatches (fired by the pages right after enqueueing).
|
||||
useEffect(() => {
|
||||
|
|
@ -249,6 +319,20 @@ export function useDesktopGenerationFeed(options: {
|
|||
};
|
||||
}
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// The media-file API's link fields used for video thumbnails. (The shared
|
||||
// MediaFile type predates these fields.)
|
||||
interface VideoMediaLinks {
|
||||
cdn_url?: string | null;
|
||||
maybe_thumbnail_template?: string | null;
|
||||
maybe_video_previews?: {
|
||||
animated?: string | null;
|
||||
still?: string | null;
|
||||
still_thumbnail_template?: string | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
// ── Task mapping helpers ────────────────────────────────────────────────────
|
||||
|
||||
function getTaskMediaType(t: TaskQueueItem): "image" | "video" | "other" {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
|
|||
import {
|
||||
MediaUploadApi,
|
||||
MediaFilesApi,
|
||||
downloadFileFromUrl,
|
||||
} from "@storyteller/api";
|
||||
import toast from "react-hot-toast";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
|
@ -25,7 +24,7 @@ import { twMerge } from "tailwind-merge";
|
|||
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
|
||||
import { SliderV2 } from "@storyteller/ui-sliderv2";
|
||||
// import { Switch } from "@headlessui/react";
|
||||
import { GenerateImage } from "@storyteller/tauri-api";
|
||||
import { GenerateImage, downloadFileFromUrl } from "@storyteller/tauri-api";
|
||||
import {
|
||||
ClassyModelSelector,
|
||||
useAnglesPageModelList,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ImagesIcon, MaximizeIcon, PencilIcon } from "lucide-react";
|
||||
import { downloadFileFromUrl } from "libs/api/src/lib/LocalApi";
|
||||
import { downloadFileFromUrl } from "@storyteller/tauri-api";
|
||||
import { GalleryModal, GalleryItem } from "@storyteller/ui-gallery-modal";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from "react";
|
|||
import { DropletIcon, ImagesIcon, RotateCwIcon } from "lucide-react";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
|
||||
import { downloadFileFromUrl } from "@storyteller/api";
|
||||
import { downloadFileFromUrl } from "@storyteller/tauri-api";
|
||||
import { UploadEntryCard } from "../../components/media/UploadEntryCard";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react";
|
|||
import { DownloadIcon, EyeIcon, ImagesIcon, PlusIcon, UploadIcon, WandSparklesIcon } from "lucide-react";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
|
||||
import { downloadFileFromUrl } from "@storyteller/api";
|
||||
import toast from "react-hot-toast";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { UploadEntryCard } from "../../components/media/UploadEntryCard";
|
||||
|
|
@ -13,6 +12,7 @@ import {
|
|||
import {
|
||||
EnqueueImageBgRemoval,
|
||||
useCanvasBgRemovedEvent,
|
||||
downloadFileFromUrl,
|
||||
} from "@storyteller/tauri-api";
|
||||
import { PopoverMenu, PopoverItem } from "@storyteller/ui-popover";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
|
|
|||
|
|
@ -3,10 +3,7 @@ import type {
|
|||
ExportSinkAdapter,
|
||||
} from "@storyteller/ui-video-editor";
|
||||
import { kindFromMime } from "@storyteller/ui-video-editor";
|
||||
import {
|
||||
promptDownloadLocationIfNeeded,
|
||||
downloadUrlToPath,
|
||||
} from "@storyteller/api";
|
||||
import { promptDownloadLocationIfNeeded, downloadUrlToPath } from "@storyteller/tauri-api";
|
||||
import { downloadDir } from "@tauri-apps/api/path";
|
||||
import { uploadByKind } from "./upload-by-kind";
|
||||
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ import { DynamicIcon } from "@storyteller/icons";
|
|||
import { Button } from "@storyteller/ui-button";
|
||||
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
|
||||
import {
|
||||
downloadFileFromUrl,
|
||||
MediaUploadApi,
|
||||
EIntermediateFile,
|
||||
} from "@storyteller/api";
|
||||
import { downloadFileFromUrl } from "@storyteller/tauri-api";
|
||||
import toast from "react-hot-toast";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { usePromptVideoStore, RefImage } from "@storyteller/ui-promptbox";
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { DropletIcon, ImagesIcon, PauseIcon, PlayIcon, RotateCwIcon, SkipBackIco
|
|||
import { DynamicIcon } from "@storyteller/icons";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
|
||||
import { downloadFileFromUrl } from "@storyteller/api";
|
||||
import { downloadFileFromUrl } from "@storyteller/tauri-api";
|
||||
import { UploadEntryCard } from "../../components/media/UploadEntryCard";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
|
|
|
|||
|
|
@ -14,9 +14,5 @@
|
|||
"import": "./dist/index.js",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-upload": "2.4.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ export * from "./lib/JobsApi.js";
|
|||
export * from "./lib/MediaFilesApi.js";
|
||||
export * from "./lib/MediaUploadApi.js";
|
||||
export * from "./lib/MiscApi.js";
|
||||
export * from "./lib/LocalApi.js";
|
||||
export * from "./lib/ProjectsApi.js";
|
||||
export * from "./lib/PromptsApi.js";
|
||||
export * from "./lib/TagsApi.js";
|
||||
|
|
|
|||
|
|
@ -1,41 +1,19 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { LoaderCircleIcon } from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { DynamicIcon } from "@storyteller/icons";
|
||||
import { PLACEHOLDER_IMAGES } from "@storyteller/common";
|
||||
import { useGalleryViewStore } from "./gallery-view-store";
|
||||
import {
|
||||
markVideoThumbnailFailed,
|
||||
useVideoThumbnailState,
|
||||
} from "./pending-video-thumbnails";
|
||||
|
||||
// Media thumbnail shared by the masonry GalleryCard and the list GalleryRow.
|
||||
// Encapsulates the two awkward bits of thumbnail rendering: freshly generated
|
||||
// video thumbnails 404 until the render job finishes (so we retry on a timer
|
||||
// and on tab refocus), and broken image URLs fall back to a placeholder.
|
||||
|
||||
// ── Shared visibility listener for video thumbnail retries ────────────────
|
||||
|
||||
const visibilityCallbacks = new Set<() => void>();
|
||||
|
||||
function onTabVisible(cb: () => void) {
|
||||
visibilityCallbacks.add(cb);
|
||||
if (visibilityCallbacks.size === 1) {
|
||||
document.addEventListener("visibilitychange", fireVisibilityCallbacks);
|
||||
}
|
||||
return () => {
|
||||
visibilityCallbacks.delete(cb);
|
||||
if (visibilityCallbacks.size === 0) {
|
||||
document.removeEventListener("visibilitychange", fireVisibilityCallbacks);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function fireVisibilityCallbacks() {
|
||||
if (document.hidden) return;
|
||||
visibilityCallbacks.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
// ── Retry constants ────────────────────────────────────────────────────────
|
||||
|
||||
const MAX_RETRIES = 20;
|
||||
const RETRY_INTERVAL = 5000;
|
||||
// video thumbnails 404 until the render job finishes (so failed ones are
|
||||
// marked and re-checked in the background, see pending-video-thumbnails), and
|
||||
// broken image URLs fall back to a placeholder.
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -70,71 +48,17 @@ export function GalleryThumbnail({
|
|||
}: GalleryThumbnailProps) {
|
||||
const autoplayVideos = useGalleryViewStore((s) => s.autoplayVideos);
|
||||
// With autoplay off, videos freeze on their still first frame. The still is
|
||||
// also what the retry machinery below polls in that mode.
|
||||
// also what gets re-checked in that mode.
|
||||
const thumbnail =
|
||||
isVideo && !autoplayVideos && stillThumbnail
|
||||
? stillThumbnail
|
||||
: animatedThumbnail;
|
||||
// "retrying" flips to true only after the first error, so videos with ready
|
||||
// thumbnails render the normal <img> path with zero overhead. While retrying,
|
||||
// a hidden <img> loads via ref (no re-renders) and the spinner stays stable.
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
const retryImgRef = useRef<HTMLImageElement>(null);
|
||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const retryCountRef = useRef(0);
|
||||
|
||||
const kickRetry = useCallback(() => {
|
||||
if (!retryImgRef.current || !thumbnail) return;
|
||||
retryImgRef.current.src = `${thumbnail}?_r=${Date.now()}`;
|
||||
}, [thumbnail]);
|
||||
|
||||
const scheduleRetry = useCallback(() => {
|
||||
if (retryCountRef.current >= MAX_RETRIES || !thumbnail) return;
|
||||
if (document.hidden) return;
|
||||
retryTimerRef.current = setTimeout(() => {
|
||||
retryCountRef.current++;
|
||||
kickRetry();
|
||||
}, RETRY_INTERVAL);
|
||||
}, [thumbnail, kickRetry]);
|
||||
|
||||
// Subscribe to the shared visibility listener while a video card is retrying.
|
||||
useEffect(() => {
|
||||
if (!isVideo || !thumbnail || !retrying) return;
|
||||
const unsubscribe = onTabVisible(() => {
|
||||
retryCountRef.current = 0;
|
||||
kickRetry();
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
||||
};
|
||||
}, [isVideo, thumbnail, retrying, kickRetry]);
|
||||
|
||||
// Reset retry state when the thumbnail URL changes.
|
||||
useEffect(() => {
|
||||
if (!isVideo) return;
|
||||
setRetrying(false);
|
||||
retryCountRef.current = 0;
|
||||
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
||||
}, [isVideo, thumbnail]);
|
||||
|
||||
const handleLoad = useCallback(
|
||||
(e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
if (isVideo && retrying) {
|
||||
setRetrying(false);
|
||||
retryCountRef.current = 0;
|
||||
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
||||
}
|
||||
onLoad?.(e);
|
||||
},
|
||||
[isVideo, retrying, onLoad],
|
||||
);
|
||||
const videoState = useVideoThumbnailState(isVideo ? thumbnail : null);
|
||||
|
||||
const handleError = useCallback(
|
||||
(e: React.SyntheticEvent<HTMLImageElement>) => {
|
||||
if (isVideo) {
|
||||
setRetrying(true);
|
||||
scheduleRetry();
|
||||
if (thumbnail) markVideoThumbnailFailed(thumbnail);
|
||||
} else {
|
||||
const target = e.currentTarget;
|
||||
if (target.dataset.fallback) return;
|
||||
|
|
@ -143,43 +67,32 @@ export function GalleryThumbnail({
|
|||
target.style.opacity = "0.3";
|
||||
}
|
||||
},
|
||||
[isVideo, scheduleRetry],
|
||||
[isVideo, thumbnail],
|
||||
);
|
||||
|
||||
if (retrying) {
|
||||
if (videoState?.status === "pending") {
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2">
|
||||
<LoaderCircleIcon
|
||||
|
||||
className="animate-spin text-lg text-white/30" />
|
||||
{showRetryLabel && (
|
||||
<span className="text-[10px] text-white/30">Loading thumbnail…</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Hidden img retries in the background via ref — zero re-renders */}
|
||||
<img
|
||||
ref={retryImgRef}
|
||||
src={thumbnail!}
|
||||
alt=""
|
||||
className="absolute h-0 w-0 opacity-0"
|
||||
aria-hidden
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
</>
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2">
|
||||
<LoaderCircleIcon className="animate-spin text-lg text-white/30" />
|
||||
{showRetryLabel && (
|
||||
<span className="text-[10px] text-white/30">Loading thumbnail…</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (thumbnail) {
|
||||
// Loaded after a re-check: render the (cache-busted) URL that worked.
|
||||
const src = videoState?.status === "ready" ? videoState.src : thumbnail;
|
||||
|
||||
if (src && videoState?.status !== "gave_up") {
|
||||
return (
|
||||
<img
|
||||
src={thumbnail}
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className={imgClassName}
|
||||
onLoad={handleLoad}
|
||||
onLoad={onLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,173 @@
|
|||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { VideoIcon } from "lucide-react";
|
||||
import {
|
||||
getVideoThumbnailState,
|
||||
markVideoThumbnailFailed,
|
||||
resetVideoThumbnailsForTests,
|
||||
} from "./pending-video-thumbnails";
|
||||
import { GalleryThumbnail } from "./GalleryThumbnail";
|
||||
|
||||
vi.mock("@storyteller/icons", () => ({
|
||||
DynamicIcon: () => <span data-testid="fallback-icon" />,
|
||||
}));
|
||||
vi.mock("@storyteller/common", () => ({
|
||||
PLACEHOLDER_IMAGES: { DEFAULT: "placeholder.png" },
|
||||
}));
|
||||
|
||||
const THUMB =
|
||||
"https://cdn-2.fakeyou.com/cdn-cgi/image/width=512,quality=95/media/a/b/storyteller_x.mp4-thumb.gif";
|
||||
|
||||
// Probes made by the checker; a test settles each one by hand.
|
||||
let probes: FakeImage[] = [];
|
||||
|
||||
class FakeImage {
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
src = "";
|
||||
constructor() {
|
||||
probes.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal("Image", FakeImage);
|
||||
probes = [];
|
||||
resetVideoThumbnailsForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("checker", () => {
|
||||
it("re-checks a failed thumbnail and hands back the URL that loaded", () => {
|
||||
markVideoThumbnailFailed(THUMB);
|
||||
expect(getVideoThumbnailState(THUMB)).toEqual({ status: "pending" });
|
||||
|
||||
vi.advanceTimersByTime(5_000);
|
||||
expect(probes).toHaveLength(1);
|
||||
expect(probes[0].src).toMatch(/^https:\/\/cdn-2\.fakeyou\.com\/.*\.mp4-thumb\.gif\?_r=\d+$/);
|
||||
|
||||
probes[0].onload!();
|
||||
expect(getVideoThumbnailState(THUMB)).toEqual({ status: "ready", src: probes[0].src });
|
||||
});
|
||||
|
||||
it("keeps checking past the old 20-retry limit, backing off", () => {
|
||||
markVideoThumbnailFailed(THUMB);
|
||||
failProbesFor(2 * 60_000);
|
||||
expect(probes).toHaveLength(24); // Every 5s for the first two minutes.
|
||||
|
||||
failProbesFor(8 * 60_000);
|
||||
expect(probes).toHaveLength(24 + 32); // Then every 15s.
|
||||
expect(getVideoThumbnailState(THUMB)).toEqual({ status: "pending" });
|
||||
});
|
||||
|
||||
it("gives up after an hour", () => {
|
||||
markVideoThumbnailFailed(THUMB);
|
||||
failProbesFor(60 * 60_000 + 60_000);
|
||||
expect(getVideoThumbnailState(THUMB)).toEqual({ status: "gave_up" });
|
||||
|
||||
const probeCount = probes.length;
|
||||
vi.advanceTimersByTime(10 * 60_000);
|
||||
expect(probes).toHaveLength(probeCount);
|
||||
});
|
||||
|
||||
it("probes a URL once however many cards mark it", () => {
|
||||
markVideoThumbnailFailed(THUMB);
|
||||
markVideoThumbnailFailed(THUMB);
|
||||
vi.advanceTimersByTime(5_000);
|
||||
markVideoThumbnailFailed(THUMB);
|
||||
vi.advanceTimersByTime(5_000);
|
||||
expect(probes).toHaveLength(1); // The first probe is still in flight.
|
||||
});
|
||||
|
||||
it("checks right away when the window regains focus", () => {
|
||||
markVideoThumbnailFailed(THUMB);
|
||||
failProbesFor(3 * 60_000); // Now on the 15s interval.
|
||||
const probeCount = probes.length;
|
||||
|
||||
vi.advanceTimersByTime(1_000);
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
vi.advanceTimersByTime(0);
|
||||
expect(probes).toHaveLength(probeCount + 1);
|
||||
});
|
||||
|
||||
it("pauses while the window is hidden", () => {
|
||||
const hidden = vi.spyOn(document, "hidden", "get").mockReturnValue(true);
|
||||
markVideoThumbnailFailed(THUMB);
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(probes).toHaveLength(0);
|
||||
|
||||
hidden.mockReturnValue(false);
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
vi.advanceTimersByTime(0);
|
||||
expect(probes).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GalleryThumbnail", () => {
|
||||
it("shows a spinner for a failed video thumbnail, then swaps in the loaded one", () => {
|
||||
renderVideoThumbnail();
|
||||
fireEvent.error(screen.getByRole("img"));
|
||||
expect(screen.getByText("Loading thumbnail…")).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(5_000);
|
||||
probes[0].onerror!();
|
||||
vi.advanceTimersByTime(5_000);
|
||||
probes[1].onload!();
|
||||
});
|
||||
expect(screen.getByRole("img").getAttribute("src")).toBe(probes[1].src);
|
||||
});
|
||||
|
||||
it("renders an already-recovered thumbnail straight away when remounted", () => {
|
||||
markVideoThumbnailFailed(THUMB);
|
||||
vi.advanceTimersByTime(5_000);
|
||||
probes[0].onload!();
|
||||
|
||||
renderVideoThumbnail();
|
||||
expect(screen.getByRole("img").getAttribute("src")).toBe(probes[0].src);
|
||||
});
|
||||
|
||||
it("falls back to the video icon once the thumbnail is given up on", () => {
|
||||
markVideoThumbnailFailed(THUMB);
|
||||
failProbesFor(60 * 60_000 + 60_000);
|
||||
|
||||
renderVideoThumbnail();
|
||||
expect(screen.getByTestId("fallback-icon")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("leaves image thumbnails on the placeholder fallback", () => {
|
||||
render(
|
||||
<GalleryThumbnail thumbnail={THUMB} alt="Image" isVideo={false} fallbackIcon={VideoIcon} />,
|
||||
);
|
||||
fireEvent.error(screen.getByRole("img"));
|
||||
expect(screen.getByRole("img").getAttribute("src")).toBe("placeholder.png");
|
||||
expect(getVideoThumbnailState(THUMB)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
function renderVideoThumbnail() {
|
||||
render(
|
||||
<GalleryThumbnail thumbnail={THUMB} alt="Video" isVideo fallbackIcon={VideoIcon} />,
|
||||
);
|
||||
}
|
||||
|
||||
// Advance time, failing every probe the checker starts along the way.
|
||||
function failProbesFor(ms: number) {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end) {
|
||||
vi.advanceTimersByTime(1_000);
|
||||
for (const probe of probes) {
|
||||
if (probe.onerror) {
|
||||
const onerror = probe.onerror;
|
||||
probe.onerror = null;
|
||||
onerror();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
import { useSyncExternalStore } from "react";
|
||||
|
||||
// Freshly generated video thumbnails are rendered asynchronously after the
|
||||
// video lands, so the first <img> load usually 404s. A card whose video
|
||||
// thumbnail fails marks it here; one shared checker then probes every marked
|
||||
// URL on a backoff until it loads, and subscribed cards swap it in.
|
||||
//
|
||||
// Probes add a cache-busting query param (the CDN may have cached the 404 for
|
||||
// the plain URL), and the URL that loaded is the one handed back to render.
|
||||
|
||||
// ── Check schedule ────────────────────────────────────────────────────────
|
||||
|
||||
// How often to re-check a marked thumbnail, by time since it was marked.
|
||||
const CHECK_SCHEDULE: { untilMs: number; everyMs: number }[] = [
|
||||
{ untilMs: 2 * 60_000, everyMs: 5_000 },
|
||||
{ untilMs: 10 * 60_000, everyMs: 15_000 },
|
||||
{ untilMs: 60 * 60_000, everyMs: 60_000 },
|
||||
];
|
||||
|
||||
// Past the last window the thumbnail is treated as never coming.
|
||||
const GIVE_UP_AFTER_MS = CHECK_SCHEDULE[CHECK_SCHEDULE.length - 1].untilMs;
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export type VideoThumbnailState =
|
||||
// Render this src (the original URL, or the cache-busted URL that loaded).
|
||||
| { status: "ready"; src: string }
|
||||
// Failed to load; being re-checked in the background.
|
||||
| { status: "pending" }
|
||||
// Never loaded within GIVE_UP_AFTER_MS.
|
||||
| { status: "gave_up" };
|
||||
|
||||
interface PendingEntry {
|
||||
markedAt: number;
|
||||
nextCheckAt: number;
|
||||
probing: boolean;
|
||||
}
|
||||
|
||||
// ── Registry state ────────────────────────────────────────────────────────
|
||||
|
||||
const PENDING_STATE: VideoThumbnailState = { status: "pending" };
|
||||
const GAVE_UP_STATE: VideoThumbnailState = { status: "gave_up" };
|
||||
|
||||
const pending = new Map<string, PendingEntry>();
|
||||
// Settled states, kept for the session so remounted cards skip the 404.
|
||||
const settled = new Map<string, VideoThumbnailState>();
|
||||
const listeners = new Map<string, Set<() => void>>();
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let wakeListenersAttached = false;
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Mark a video thumbnail URL as failed so it gets re-checked. */
|
||||
export function markVideoThumbnailFailed(url: string) {
|
||||
if (pending.has(url) || settled.get(url) === GAVE_UP_STATE) return;
|
||||
const now = Date.now();
|
||||
// Also covers a previously loaded src failing again: start over.
|
||||
settled.delete(url);
|
||||
pending.set(url, {
|
||||
markedAt: now,
|
||||
nextCheckAt: now + CHECK_SCHEDULE[0].everyMs,
|
||||
probing: false,
|
||||
});
|
||||
attachWakeListeners();
|
||||
notify(url);
|
||||
scheduleNextTick();
|
||||
}
|
||||
|
||||
export function getVideoThumbnailState(
|
||||
url: string,
|
||||
): VideoThumbnailState | undefined {
|
||||
if (pending.has(url)) return PENDING_STATE;
|
||||
return settled.get(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* The render state for a video thumbnail URL. Untracked URLs render as-is.
|
||||
* Pass null to opt out (non-video media, or no thumbnail).
|
||||
*/
|
||||
export function useVideoThumbnailState(
|
||||
url: string | null,
|
||||
): VideoThumbnailState | undefined {
|
||||
return useSyncExternalStore(
|
||||
(onChange) => (url ? subscribe(url, onChange) : () => {}),
|
||||
() => (url ? getVideoThumbnailState(url) : undefined),
|
||||
);
|
||||
}
|
||||
|
||||
/** Test-only: forget all tracked thumbnails and stop the checker. */
|
||||
export function resetVideoThumbnailsForTests() {
|
||||
pending.clear();
|
||||
settled.clear();
|
||||
listeners.clear();
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
|
||||
// ── Checker ───────────────────────────────────────────────────────────────
|
||||
|
||||
function scheduleNextTick() {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = undefined;
|
||||
if (pending.size === 0) return;
|
||||
let earliest = Infinity;
|
||||
for (const entry of pending.values()) {
|
||||
if (!entry.probing) earliest = Math.min(earliest, entry.nextCheckAt);
|
||||
}
|
||||
if (earliest === Infinity) return; // Every entry is mid-probe.
|
||||
timer = setTimeout(tick, Math.max(0, earliest - Date.now()));
|
||||
}
|
||||
|
||||
function tick() {
|
||||
timer = undefined;
|
||||
// Paused while the window is hidden; the wake listeners resume it.
|
||||
if (typeof document !== "undefined" && document.hidden) return;
|
||||
const now = Date.now();
|
||||
for (const [url, entry] of pending) {
|
||||
if (entry.probing || entry.nextCheckAt > now) continue;
|
||||
if (now - entry.markedAt >= GIVE_UP_AFTER_MS) {
|
||||
pending.delete(url);
|
||||
settled.set(url, GAVE_UP_STATE);
|
||||
notify(url);
|
||||
continue;
|
||||
}
|
||||
probe(url, entry);
|
||||
}
|
||||
scheduleNextTick();
|
||||
}
|
||||
|
||||
function probe(url: string, entry: PendingEntry) {
|
||||
entry.probing = true;
|
||||
const src = withCacheBust(url);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (pending.get(url) !== entry) return; // Reset while in flight.
|
||||
pending.delete(url);
|
||||
settled.set(url, { status: "ready", src });
|
||||
notify(url);
|
||||
scheduleNextTick();
|
||||
};
|
||||
img.onerror = () => {
|
||||
if (pending.get(url) !== entry) return;
|
||||
entry.probing = false;
|
||||
entry.nextCheckAt = Date.now() + checkInterval(Date.now() - entry.markedAt);
|
||||
scheduleNextTick();
|
||||
};
|
||||
img.src = src;
|
||||
}
|
||||
|
||||
function checkInterval(ageMs: number): number {
|
||||
const slot = CHECK_SCHEDULE.find((w) => ageMs < w.untilMs);
|
||||
return (slot ?? CHECK_SCHEDULE[CHECK_SCHEDULE.length - 1]).everyMs;
|
||||
}
|
||||
|
||||
// Re-check everything immediately when the app comes back into view or focus
|
||||
// (the desktop window rarely fires visibilitychange, but does fire focus).
|
||||
function attachWakeListeners() {
|
||||
if (wakeListenersAttached || typeof window === "undefined") return;
|
||||
wakeListenersAttached = true;
|
||||
const wake = () => {
|
||||
if (document.hidden || pending.size === 0) return;
|
||||
const now = Date.now();
|
||||
for (const entry of pending.values()) {
|
||||
entry.nextCheckAt = Math.min(entry.nextCheckAt, now);
|
||||
}
|
||||
scheduleNextTick();
|
||||
};
|
||||
document.addEventListener("visibilitychange", wake);
|
||||
window.addEventListener("focus", wake);
|
||||
}
|
||||
|
||||
// ── Subscriptions ─────────────────────────────────────────────────────────
|
||||
|
||||
function subscribe(url: string, onChange: () => void) {
|
||||
let set = listeners.get(url);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
listeners.set(url, set);
|
||||
}
|
||||
set.add(onChange);
|
||||
return () => {
|
||||
set.delete(onChange);
|
||||
if (set.size === 0) listeners.delete(url);
|
||||
};
|
||||
}
|
||||
|
||||
function notify(url: string) {
|
||||
listeners.get(url)?.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
function withCacheBust(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
parsed.searchParams.set("_r", Date.now().toString());
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return `${url}${url.includes("?") ? "&" : "?"}_r=${Date.now()}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -43,9 +43,6 @@
|
|||
"src/**/*.tsx"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../api/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../icons/tsconfig.lib.json"
|
||||
},
|
||||
|
|
@ -56,7 +53,7 @@
|
|||
"path": "../input/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../tauri-api/tsconfig.lib.json"
|
||||
"path": "../../api/tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import {
|
|||
CharactersApi,
|
||||
Character,
|
||||
MediaUploadApi,
|
||||
downloadFileFromUrl,
|
||||
} from "@storyteller/api";
|
||||
import { downloadFileFromUrl } from "@storyteller/tauri-api";
|
||||
import { toast } from "@storyteller/ui-toaster";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
import { Button } from "@storyteller/ui-button";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
|
||||
import { downloadFileFromUrl } from "@storyteller/api";
|
||||
import { downloadFileFromUrl } from "@storyteller/tauri-api";
|
||||
import { ImageIcon, ImagesIcon, LoaderCircleIcon, MusicIcon, PlayIcon, PlusIcon, SquareIcon, Trash2Icon, VideoIcon, XIcon } from "lucide-react";
|
||||
import { DynamicIcon } from "@storyteller/icons";
|
||||
import { RefImage, RefVideo, RefAudio } from "./promptStore";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
|
||||
import { downloadFileFromUrl, type UploadMediaFn } from "@storyteller/api";
|
||||
import { type UploadMediaFn } from "@storyteller/api";
|
||||
import { downloadFileFromUrl } from "@storyteller/tauri-api";
|
||||
import { toast } from "@storyteller/ui-toaster";
|
||||
import { UploaderStates } from "@storyteller/common";
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ vi.mock("@storyteller/tauri-api", () => ({
|
|||
GetAppPreferences: api.get,
|
||||
UpdateAppPreferences: api.update,
|
||||
DownloadDirectoryReveal: vi.fn(),
|
||||
getAskLocationBeforeDownload: () => false,
|
||||
setAskLocationBeforeDownload: vi.fn(),
|
||||
PreferenceName: {
|
||||
AutoDownload: "auto_download",
|
||||
PreferredDownloadDirectory: "preferred_download_directory",
|
||||
|
|
@ -16,10 +18,6 @@ vi.mock("@storyteller/tauri-api", () => ({
|
|||
},
|
||||
}));
|
||||
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn() }));
|
||||
vi.mock("@storyteller/api", () => ({
|
||||
getAskLocationBeforeDownload: () => false,
|
||||
setAskLocationBeforeDownload: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("ResizeObserver", class {
|
||||
|
|
|
|||
|
|
@ -9,16 +9,14 @@ import {
|
|||
PreferredDownloadFilename,
|
||||
SystemDirectory,
|
||||
UpdateAppPreferences,
|
||||
getAskLocationBeforeDownload,
|
||||
setAskLocationBeforeDownload,
|
||||
} from "@storyteller/tauri-api";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { Label } from "@storyteller/ui-label";
|
||||
import { Switch } from "@storyteller/ui-switch";
|
||||
import { Select } from "@storyteller/ui-select";
|
||||
import { FolderIcon, RotateCcwIcon, SearchIcon } from "lucide-react";
|
||||
import {
|
||||
getAskLocationBeforeDownload,
|
||||
setAskLocationBeforeDownload,
|
||||
} from "@storyteller/api";
|
||||
|
||||
const DEFAULT_CUSTOM_FORMAT = "{model}_{date}";
|
||||
const FILENAME_OPTIONS = [
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@
|
|||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-upload": "2.4.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export * from "./lib/artcraft/task_queue/TasksNukeAll.js";
|
|||
export * from "./lib/common/CommandStatus.js";
|
||||
export * from "./lib/download/DownloadDirectoryReveal.js";
|
||||
export * from "./lib/download/DownloadUrl.js";
|
||||
export * from "./lib/download/LocalDownloads.js";
|
||||
export * from "./lib/enqueue/EnqueueEditImage.js";
|
||||
export * from "./lib/enqueue/EnqueueImageBgRemovalCommand.js";
|
||||
export * from "./lib/enqueue/EnqueueImageInpaint.js";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
|
||||
import { download } from "@tauri-apps/plugin-upload";
|
||||
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||
import { DownloadUrl, GetDownloadPath } from "@storyteller/tauri-api";
|
||||
import { DownloadUrl, GetDownloadPath } from "./DownloadUrl";
|
||||
|
||||
const ASK_LOCATION_BEFORE_DOWNLOAD_KEY = "artcraft_ask_location_before_download";
|
||||
|
||||
|
|
@ -443,11 +443,7 @@
|
|||
},
|
||||
"libs/api": {
|
||||
"name": "@storyteller/api",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-upload": "2.4.0"
|
||||
}
|
||||
"version": "0.0.1"
|
||||
},
|
||||
"libs/api-enums": {
|
||||
"name": "@storyteller/api-enums",
|
||||
|
|
@ -681,7 +677,9 @@
|
|||
"name": "@storyteller/tauri-api",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.11.0"
|
||||
"@tauri-apps/api": "^2.11.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||
"@tauri-apps/plugin-upload": "2.4.0"
|
||||
}
|
||||
},
|
||||
"libs/tauri-events": {
|
||||
|
|
|
|||
Loading…
Reference in New Issue