handle thumbnails error (#883)

This commit is contained in:
BFlat authored and GitHub committed 2025-10-20 14:00:29 +07:00
1 parent 952d1e936c
commit dbedb25930
7 files changed
+166 -50

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

@@ -32,6 +32,11 @@ import {
import { CloseButton } from "@storyteller/ui-close-button";
import { ActionReminderModal } from "@storyteller/ui-action-reminder-modal";
import { TaskMediaFileClass } from "@storyteller/api-enums";
import {
getThumbnailUrl,
THUMBNAIL_SIZES,
getPlaceholderForMediaClass,
} from "@storyteller/common";
type InProgressTask = {
id: string;
@@ -132,15 +137,10 @@ const CompletedCard = ({
src={task.thumbnailUrl}
alt={task.title}
onError={(e) => {
console.log("Failed to load thumbnail", e);
let errorPlaceholder = "/resources/placeholders/placeholder.png";
switch (task.mediaFileClass) {
case TaskMediaFileClass.Video:
errorPlaceholder =
"/resources/placeholders/placeholder_play.png";
break;
}
e.currentTarget.src = errorPlaceholder;
e.currentTarget.src = getPlaceholderForMediaClass(
task.mediaFileClass,
);
e.currentTarget.style.opacity = "0.3";
}}
className="h-full w-full object-cover"
/>
@@ -290,13 +290,12 @@ export const TaskQueue = () => {
.map((t: TaskQueueItem) => ({
id: t.id,
...formatTitleParts(t),
thumbnailUrl: t.completed_item?.primary_media_file
?.maybe_thumbnail_url_template
? t.completed_item?.primary_media_file?.maybe_thumbnail_url_template.replace(
"{WIDTH}",
"250",
)
: undefined,
thumbnailUrl:
getThumbnailUrl(
t.completed_item?.primary_media_file
?.maybe_thumbnail_url_template,
{ width: THUMBNAIL_SIZES.MEDIUM },
) || undefined,
imageUrls: t.completed_item?.primary_media_file?.cdn_url
? [t.completed_item?.primary_media_file?.cdn_url]
: [],
+1
View File
@@ -2,3 +2,4 @@ export * from "./lib/assets";
export * from "./lib/enums";
export * from "./lib/interfaces";
export * from "./lib/types";
export * from "./lib/utils";
@@ -0,0 +1 @@
export * from "./thumbnail-utils";
@@ -0,0 +1,120 @@
export const THUMBNAIL_SIZES = {
SMALL: 128,
MEDIUM: 250,
LARGE: 512,
EXTRA_LARGE: 1024,
} as const;
export type ThumbnailSize =
(typeof THUMBNAIL_SIZES)[keyof typeof THUMBNAIL_SIZES];
interface ThumbnailOptions {
width?: ThumbnailSize | number;
addCors?: boolean;
}
export function getThumbnailUrl(
urlTemplate: string | null | undefined,
options: ThumbnailOptions = {}
): string | null {
if (!urlTemplate) return null;
const { width = THUMBNAIL_SIZES.MEDIUM, addCors = false } = options;
let url = urlTemplate.replace("{WIDTH}", width.toString());
if (addCors) {
const corsUrl = addCorsParam(url);
if (corsUrl) url = corsUrl;
}
return url;
}
export function addCorsParam(url: string | null | undefined): string | null {
if (!url) return null;
return `${url}?cors=1`;
}
interface MediaThumbnailOptions {
size?: ThumbnailSize | number;
addCors?: boolean;
}
export function getMediaThumbnail(
mediaLinks:
| {
maybe_thumbnail_template?: string | null;
cdn_url?: string | null;
maybe_video_previews?: {
animated?: string | null;
} | null;
}
| null
| undefined,
mediaClass: string | undefined,
options: MediaThumbnailOptions = {}
): string | null {
if (!mediaLinks) return null;
const { size = THUMBNAIL_SIZES.MEDIUM, addCors = false } = options;
let thumbnailUrl: string | null = null;
if (mediaClass === "video" && mediaLinks.maybe_video_previews?.animated) {
thumbnailUrl = mediaLinks.maybe_video_previews.animated;
} else if (mediaLinks.maybe_thumbnail_template) {
thumbnailUrl = getThumbnailUrl(mediaLinks.maybe_thumbnail_template, {
width: size,
});
} else if (mediaLinks.cdn_url) {
thumbnailUrl = mediaLinks.cdn_url;
}
if (thumbnailUrl && addCors) {
thumbnailUrl = addCorsParam(thumbnailUrl);
}
return thumbnailUrl;
}
export function getContextImageThumbnail(
contextImage: {
media_links: {
maybe_thumbnail_template?: string | null;
cdn_url: string;
};
},
options: MediaThumbnailOptions = {}
): { thumbnail: string; fullSize: string } {
const { size = THUMBNAIL_SIZES.SMALL } = options;
const fullSizeWidth = THUMBNAIL_SIZES.LARGE;
const thumbnail = contextImage.media_links.maybe_thumbnail_template
? getThumbnailUrl(contextImage.media_links.maybe_thumbnail_template, {
width: size,
}) || contextImage.media_links.cdn_url
: contextImage.media_links.cdn_url;
const fullSize = contextImage.media_links.maybe_thumbnail_template
? getThumbnailUrl(contextImage.media_links.maybe_thumbnail_template, {
width: fullSizeWidth,
}) || contextImage.media_links.cdn_url
: contextImage.media_links.cdn_url;
return { thumbnail, fullSize };
}
export const PLACEHOLDER_IMAGES = {
DEFAULT: "/resources/gifs/artcraft-logo-load.gif",
VIDEO: "/resources/gifs/artcraft-logo-load.gif",
} as const;
export function getPlaceholderForMediaClass(mediaClass?: string): string {
switch (mediaClass) {
case "video":
return PLACEHOLDER_IMAGES.VIDEO;
default:
return PLACEHOLDER_IMAGES.DEFAULT;
}
}
@@ -19,6 +19,11 @@ import {
import { twMerge } from "tailwind-merge";
import { GalleryDraggableItem } from "./GalleryDraggableItem";
import { useSignals } from "@preact/signals-react/runtime";
import {
getThumbnailUrl,
THUMBNAIL_SIZES,
PLACEHOLDER_IMAGES,
} from "@storyteller/common";
import {
galleryModalVisibleDuringDrag,
galleryReopenAfterDragSignal,
@@ -262,7 +267,6 @@ export const GalleryModal = React.memo(
response = await api.listUserMediaFiles(query);
if (response.success && response.data) {
const thumbnail_size = 250;
const newItems = response.data
.filter(
(item: any) => item.media_type !== FilterMediaType.SCENE_JSON
@@ -272,15 +276,12 @@ export const GalleryModal = React.memo(
label: getLabel(item),
thumbnail:
item.media_class === "video"
? item.media_links.maybe_video_previews.animated
? item.media_links.maybe_video_previews?.animated
: item.media_class === "dimensional"
? item.cover_image?.maybe_cover_image_public_bucket_url
: item.media_links.maybe_thumbnail_template?.replace(
"{WIDTH}",
thumbnail_size.toString()
),
// TODO(bt): Thumbnail template URL may be wrong for videos and other asset types, but I'm
// trying to plumb it through for the inpainting editor, which is just images.
: getThumbnailUrl(item.media_links.maybe_thumbnail_template, {
width: THUMBNAIL_SIZES.MEDIUM,
}),
thumbnailUrlTemplate: item.media_links.maybe_thumbnail_template,
fullImage: item.media_links.cdn_url,
createdAt: item.created_at,
@@ -289,7 +290,6 @@ export const GalleryModal = React.memo(
(item.filter_media_classes
? item.filter_media_classes[0]
: "image"),
// assetType for 3D assets so drop handlers can decide between character vs object
assetType:
item.media_class === "dimensional"
? item.maybe_animation_type ||
@@ -641,9 +641,9 @@ export const GalleryModal = React.memo(
selected={selectedItemIds.includes(item.id)}
onClick={() => handleItemClick(item)}
onImageError={(e) => {
// NB: Replace the broken thumbnail with a placeholder.
e.currentTarget.src =
"/resources/placeholders/placeholder.png";
PLACEHOLDER_IMAGES.DEFAULT;
e.currentTarget.style.opacity = "0.3";
handleImageError(item.thumbnail!);
}}
disableTooltipAndBadge={mode === "select"}
@@ -26,6 +26,12 @@ import {
} from "@storyteller/model-list";
import useEmblaCarousel from "embla-carousel-react";
import type { EmblaOptionsType } from "embla-carousel";
import {
addCorsParam,
getContextImageThumbnail,
THUMBNAIL_SIZES,
PLACEHOLDER_IMAGES,
} from "@storyteller/common";
interface LightboxModalProps {
isOpen: boolean;
@@ -301,7 +307,7 @@ export function LightboxModal({
setMediaLoaded(false);
const img = new Image();
img.src = `${selectedImageUrl}?cors=1`;
img.src = addCorsParam(selectedImageUrl) || selectedImageUrl;
const handleLoad = () => setMediaLoaded(true);
const handleError = () => setMediaLoaded(true);
@@ -376,7 +382,7 @@ export function LightboxModal({
<div className="relative flex h-full items-center justify-center overflow-hidden rounded-lg bg-black/20">
<img
data-lightbox-modal="true"
src={`${url}?cors=1`}
src={addCorsParam(url) || url}
alt={`${imageAlt || "Generated image"} ${idx + 1}`}
className="h-full w-full object-contain"
onError={(e) => {
@@ -384,7 +390,8 @@ export function LightboxModal({
if (idx === selectedIndex) {
setMediaLoaded(true);
e.currentTarget.src =
"/resources/placeholders/placeholder.png";
PLACEHOLDER_IMAGES.DEFAULT;
e.currentTarget.style.opacity = "0.3";
}
}}
onLoad={() => {
@@ -421,7 +428,7 @@ export function LightboxModal({
)}
>
<img
src={`${url}?cors=1`}
src={addCorsParam(url) || url}
alt={`Thumbnail ${idx + 1}`}
className="h-full w-full object-cover bg-black/20"
/>
@@ -520,7 +527,6 @@ export function LightboxModal({
)}
</div>
{/* Context Images */}
{contextImages && contextImages.length > 0 && (
<div className="space-y-1.5">
<div className="text-sm font-medium text-base-fg/90">
@@ -528,30 +534,19 @@ export function LightboxModal({
</div>
<div className="grid grid-cols-6 gap-2">
{contextImages.map((contextImage, index) => {
const thumbnailUrl = contextImage.media_links
.maybe_thumbnail_template
? contextImage.media_links.maybe_thumbnail_template.replace(
"{WIDTH}",
"128"
)
: contextImage.media_links.cdn_url;
const fullSizeUrl = contextImage.media_links
.maybe_thumbnail_template
? contextImage.media_links.maybe_thumbnail_template.replace(
"{WIDTH}",
"512"
)
: contextImage.media_links.cdn_url;
const { thumbnail, fullSize } =
getContextImageThumbnail(contextImage, {
size: THUMBNAIL_SIZES.SMALL,
});
return (
<div
key={contextImage.media_token}
className="glass relative aspect-square overflow-hidden rounded-lg w-14 border-2 border-white/30 hover:border-white/80 transition-all group cursor-pointer hover:cursor-zoom-in"
onClick={() => setRefPreviewUrl(fullSizeUrl)}
onClick={() => setRefPreviewUrl(fullSize)}
>
<img
src={thumbnailUrl}
src={thumbnail}
alt={`Reference image ${index + 1}`}
className="h-full w-full object-cover"
/>
@@ -743,7 +738,7 @@ export function LightboxModal({
</Modal.DragHandle>
<div className="relative flex h-full items-center justify-center overflow-hidden rounded-xl bg-black/30">
<img
src={`${refPreviewUrl}?cors=1`}
src={addCorsParam(refPreviewUrl) || refPreviewUrl}
alt="Reference preview"
className="h-full w-full object-contain"
/>