diff --git a/frontend/apps/editor3d/app/public/resources/gifs/artcraft-logo-load.gif b/frontend/apps/editor3d/app/public/resources/gifs/artcraft-logo-load.gif new file mode 100644 index 0000000000..6d5e2bcac2 Binary files /dev/null and b/frontend/apps/editor3d/app/public/resources/gifs/artcraft-logo-load.gif differ diff --git a/frontend/apps/editor3d/app/src/components/signaled/TopBar/TaskQueue.tsx b/frontend/apps/editor3d/app/src/components/signaled/TopBar/TaskQueue.tsx index 9a222e6a7c..2216cd537a 100644 --- a/frontend/apps/editor3d/app/src/components/signaled/TopBar/TaskQueue.tsx +++ b/frontend/apps/editor3d/app/src/components/signaled/TopBar/TaskQueue.tsx @@ -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] : [], diff --git a/frontend/libs/common/src/index.ts b/frontend/libs/common/src/index.ts index 86bf6b115f..46c3b1c3f7 100644 --- a/frontend/libs/common/src/index.ts +++ b/frontend/libs/common/src/index.ts @@ -2,3 +2,4 @@ export * from "./lib/assets"; export * from "./lib/enums"; export * from "./lib/interfaces"; export * from "./lib/types"; +export * from "./lib/utils"; diff --git a/frontend/libs/common/src/lib/utils/index.ts b/frontend/libs/common/src/lib/utils/index.ts new file mode 100644 index 0000000000..2df68b71a0 --- /dev/null +++ b/frontend/libs/common/src/lib/utils/index.ts @@ -0,0 +1 @@ +export * from "./thumbnail-utils"; diff --git a/frontend/libs/common/src/lib/utils/thumbnail-utils.ts b/frontend/libs/common/src/lib/utils/thumbnail-utils.ts new file mode 100644 index 0000000000..5392a2f372 --- /dev/null +++ b/frontend/libs/common/src/lib/utils/thumbnail-utils.ts @@ -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; + } +} diff --git a/frontend/libs/components/gallery-modal/src/lib/gallery-modal.tsx b/frontend/libs/components/gallery-modal/src/lib/gallery-modal.tsx index 8344ef9362..cef3522d5b 100644 --- a/frontend/libs/components/gallery-modal/src/lib/gallery-modal.tsx +++ b/frontend/libs/components/gallery-modal/src/lib/gallery-modal.tsx @@ -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"} diff --git a/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx b/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx index 6b5a1c7933..dc5cd6db6f 100644 --- a/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx +++ b/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx @@ -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({
{`${imageAlt { @@ -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({ )} > {`Thumbnail @@ -520,7 +527,6 @@ export function LightboxModal({ )}
- {/* Context Images */} {contextImages && contextImages.length > 0 && (
@@ -528,30 +534,19 @@ export function LightboxModal({
{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 (
setRefPreviewUrl(fullSizeUrl)} + onClick={() => setRefPreviewUrl(fullSize)} > {`Reference @@ -743,7 +738,7 @@ export function LightboxModal({
Reference preview