mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-20 17:02:53 -04:00
Webapp/desktop: video preview and lightbox sound settings (#1798)
This commit is contained in:
+7
-2
@@ -7,7 +7,7 @@ import { TabSelector } from "@storyteller/ui-tab-selector";
|
||||
import { TruchetPattern } from "@storyteller/ui-vfx";
|
||||
import Seo from "../../components/seo";
|
||||
import { useIsMobile } from "../ui/use-mobile";
|
||||
import { GalleryViewToggle } from "./GalleryViewToggle";
|
||||
import { GalleryAutoplayToggle, GalleryViewToggle } from "./GalleryViewToggle";
|
||||
import {
|
||||
MobileCreateTabsProvider,
|
||||
type MobileCreateTab,
|
||||
@@ -23,6 +23,9 @@ interface CreateMediaPageShellProps {
|
||||
authChecked: boolean;
|
||||
// Content
|
||||
hasContent: boolean;
|
||||
// Video page only: playing/still toggle for video preview thumbnails,
|
||||
// rendered next to the grid/list toggle on the mobile History tab.
|
||||
showAutoplayToggle?: boolean;
|
||||
emptyStateTitle: string;
|
||||
emptyStateSubtitle: string;
|
||||
// Optional CTA rendered under the empty-state subtitle (e.g. a signup button
|
||||
@@ -54,6 +57,7 @@ export function CreateMediaPageShell({
|
||||
description,
|
||||
authChecked,
|
||||
hasContent,
|
||||
showAutoplayToggle = false,
|
||||
emptyStateTitle,
|
||||
emptyStateSubtitle,
|
||||
emptyStateCta,
|
||||
@@ -100,7 +104,8 @@ export function CreateMediaPageShell({
|
||||
className="w-auto"
|
||||
/>
|
||||
{mobileTab === "history" && hasContent && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<div className="absolute right-3 top-1/2 flex -translate-y-1/2 items-center gap-1.5">
|
||||
{showAutoplayToggle && <GalleryAutoplayToggle />}
|
||||
<GalleryViewToggle />
|
||||
</div>
|
||||
)}
|
||||
|
||||
+4
-1
@@ -1,2 +1,5 @@
|
||||
// Moved to the shared generation-list lib (used by both webapp and desktop).
|
||||
export { GalleryViewToggle } from "@storyteller/ui-generation-list";
|
||||
export {
|
||||
GalleryViewToggle,
|
||||
GalleryAutoplayToggle,
|
||||
} from "@storyteller/ui-generation-list";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export { GenerationGallery } from "./GenerationGallery";
|
||||
export { GenerationGalleryGrid } from "./GenerationGalleryGrid";
|
||||
export { GenerationGalleryList } from "./GenerationGalleryList";
|
||||
export { GalleryViewToggle } from "./GalleryViewToggle";
|
||||
export { GalleryViewToggle, GalleryAutoplayToggle } from "./GalleryViewToggle";
|
||||
export { useGalleryData, type GalleryItem } from "./useGalleryData";
|
||||
export { useSessionMediaByClass } from "@storyteller/ui-generation-list";
|
||||
export {
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
applyMakeVideoFromImage,
|
||||
applyRecreateFromMediaToken,
|
||||
} from "../../lib/recreate";
|
||||
import { useLightboxSoundStore } from "../../lib/lightbox-sound-store";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -80,6 +81,7 @@ export function Lightbox({
|
||||
showBatchCarousel = true,
|
||||
}: LightboxProps) {
|
||||
const navigate = useNavigate();
|
||||
const soundEnabled = useLightboxSoundStore((s) => s.soundEnabled);
|
||||
const [mediaLoaded, setMediaLoaded] = useState(false);
|
||||
const [promptData, setPromptData] = useState<PromptData>(EMPTY_PROMPT);
|
||||
const [creator, setCreator] = useState<UserInfo | null>(null);
|
||||
@@ -379,7 +381,7 @@ export function Lightbox({
|
||||
controls
|
||||
loop
|
||||
autoPlay
|
||||
muted
|
||||
muted={!soundEnabled}
|
||||
playsInline
|
||||
disablePictureInPicture
|
||||
controlsList="nodownload noplaybackrate nofullscreen"
|
||||
@@ -389,6 +391,22 @@ export function Lightbox({
|
||||
const el = e.currentTarget;
|
||||
setMediaWidth(el.videoWidth);
|
||||
setMediaHeight(el.videoHeight);
|
||||
// Restore the user's last volume (getState: subscribing
|
||||
// would re-render on every tick of a volume drag).
|
||||
el.volume = useLightboxSoundStore.getState().volume;
|
||||
// If the browser blocked unmuted autoplay, fall back to
|
||||
// muted playback rather than leaving the video frozen.
|
||||
if (soundEnabled && el.paused) {
|
||||
el.play().catch(() => {
|
||||
el.muted = true;
|
||||
el.play().catch(() => {});
|
||||
});
|
||||
}
|
||||
}}
|
||||
onVolumeChange={(e) => {
|
||||
useLightboxSoundStore
|
||||
.getState()
|
||||
.setVolume(e.currentTarget.volume);
|
||||
}}
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Switch } from "@storyteller/ui-switch";
|
||||
import { USER_FEATURE_FLAGS } from "@storyteller/api";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useEnterToGenerateStore } from "../../lib/enter-to-generate-store";
|
||||
import { useLightboxSoundStore } from "../../lib/lightbox-sound-store";
|
||||
import { useSession } from "../../lib/session";
|
||||
import { AccountSection } from "./AccountSection";
|
||||
import { ApiKeySection } from "./ApiKeySection";
|
||||
@@ -105,6 +106,8 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
function GeneralPanel() {
|
||||
const enterToGenerate = useEnterToGenerateStore((s) => s.enabled);
|
||||
const setEnterToGenerate = useEnterToGenerateStore((s) => s.setEnabled);
|
||||
const lightboxSound = useLightboxSoundStore((s) => s.soundEnabled);
|
||||
const setLightboxSound = useLightboxSoundStore((s) => s.setSoundEnabled);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 text-base-fg">
|
||||
@@ -123,6 +126,20 @@ function GeneralPanel() {
|
||||
offClassName="bg-white/20"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<p className="text-sm font-medium">Play videos with sound</p>
|
||||
<p className="text-xs opacity-70">
|
||||
When on, videos in the media viewer start unmuted so you don't have
|
||||
to click unmute every time. When off (default), they start muted.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
enabled={lightboxSound}
|
||||
setEnabled={setLightboxSound}
|
||||
offClassName="bg-white/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { SidebarTrigger, useSidebar } from "../ui/sidebar";
|
||||
import { GalleryViewToggle } from "../generation-gallery/GalleryViewToggle";
|
||||
import {
|
||||
GalleryAutoplayToggle,
|
||||
GalleryViewToggle,
|
||||
} from "../generation-gallery/GalleryViewToggle";
|
||||
import { Breadcrumbs } from "./breadcrumbs";
|
||||
import { TopBarActions } from "./TopBarActions";
|
||||
|
||||
@@ -14,6 +17,8 @@ export function TopBar() {
|
||||
// On mobile the toggle lives next to the Generate/History tabs (see
|
||||
// CreateMediaPageShell), so keep it out of the top bar there.
|
||||
const showViewToggle = !isMobile && GALLERY_VIEW_ROUTES.has(pathname);
|
||||
// Playing/still video previews only matter where the feed has videos.
|
||||
const showAutoplayToggle = showViewToggle && pathname === "/create-video";
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-20 flex items-center gap-3 border-b border-white/[0.06] bg-[#121212]/80 backdrop-blur-md px-3 pb-4 pt-3 sm:pt-6">
|
||||
@@ -41,6 +46,7 @@ export function TopBar() {
|
||||
|
||||
{/* Right: gallery layout toggle (create pages) + credits / upgrade / task queue / avatar */}
|
||||
<div className="ml-auto flex items-center gap-1.5 sm:gap-2">
|
||||
{showAutoplayToggle && <GalleryAutoplayToggle />}
|
||||
{showViewToggle && <GalleryViewToggle />}
|
||||
<TopBarActions />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
// Audio behavior for lightbox videos: whether they open with sound (off by
|
||||
// default, toggled in the settings modal's General tab) and the last volume
|
||||
// the user picked, so each video doesn't reset to 100%.
|
||||
|
||||
interface LightboxSoundStore {
|
||||
soundEnabled: boolean;
|
||||
setSoundEnabled: (enabled: boolean) => void;
|
||||
// 0..1, mirrors HTMLMediaElement.volume.
|
||||
volume: number;
|
||||
setVolume: (volume: number) => void;
|
||||
}
|
||||
|
||||
export const useLightboxSoundStore = create<LightboxSoundStore>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
soundEnabled: false,
|
||||
setSoundEnabled: (soundEnabled) => set({ soundEnabled }),
|
||||
volume: 1,
|
||||
setVolume: (volume) => set({ volume }),
|
||||
}),
|
||||
{ name: "artcraft-lightbox-sound" },
|
||||
),
|
||||
);
|
||||
@@ -1454,6 +1454,7 @@ export default function CreateVideo() {
|
||||
description="Generate stunning AI videos with ArtCraft"
|
||||
authChecked={authChecked}
|
||||
hasContent={hasContent}
|
||||
showAutoplayToggle
|
||||
emptyStateTitle="Create Video"
|
||||
emptyStateSubtitle="Describe a scene. See it in motion."
|
||||
emptyStateCta={
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@storyteller/tauri-events";
|
||||
import { MediaFilesApi } from "@storyteller/api";
|
||||
import {
|
||||
getMediaStillThumbnail,
|
||||
getMediaThumbnail,
|
||||
getThumbnailUrl,
|
||||
THUMBNAIL_SIZES,
|
||||
@@ -410,6 +411,12 @@ async function taskToGalleryItems(
|
||||
id: batchFile.token,
|
||||
label,
|
||||
thumbnail: thumbnail || cdnUrl,
|
||||
stillThumbnail:
|
||||
mediaType === "video"
|
||||
? getMediaStillThumbnail(batchFile.media_links, {
|
||||
size: THUMBNAIL_SIZES.LARGE,
|
||||
})
|
||||
: null,
|
||||
fullImage: cdnUrl,
|
||||
createdAt,
|
||||
mediaClass: mediaType,
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
faCoins,
|
||||
faGear,
|
||||
faGem,
|
||||
faGrid2,
|
||||
faHouse,
|
||||
faImages,
|
||||
faCalculator,
|
||||
@@ -51,7 +50,10 @@ import {
|
||||
useCostBreakdownModalStore,
|
||||
CreditsModal,
|
||||
} from "@storyteller/ui-pricing-modal";
|
||||
import { GalleryViewToggle } from "@storyteller/ui-generation-list";
|
||||
import {
|
||||
GalleryAutoplayToggle,
|
||||
GalleryViewToggle,
|
||||
} from "@storyteller/ui-generation-list";
|
||||
import { SettingsModal } from "@storyteller/ui-settings-modal";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
@@ -500,6 +502,7 @@ export const TopBar = ({ pageName }: Props) => {
|
||||
|
||||
<div className="flex justify-end gap-2" data-tauri-drag-region>
|
||||
<div className="no-drag flex items-center gap-1.5">
|
||||
{tabStore.activeTabId === "VIDEO" && <GalleryAutoplayToggle />}
|
||||
{(tabStore.activeTabId === "IMAGE" ||
|
||||
tabStore.activeTabId === "VIDEO" ||
|
||||
tabStore.activeTabId === "AUDIO") && <GalleryViewToggle />}
|
||||
|
||||
@@ -84,6 +84,41 @@ export function getMediaThumbnail(
|
||||
return thumbnailUrl;
|
||||
}
|
||||
|
||||
// Still-frame thumbnail for a video (first frame), for hosts that let the
|
||||
// user turn off animated previews. Falls back to the regular thumbnail
|
||||
// template when the backend hasn't attached still previews.
|
||||
export function getMediaStillThumbnail(
|
||||
mediaLinks:
|
||||
| {
|
||||
maybe_thumbnail_template?: string | null;
|
||||
maybe_video_previews?: {
|
||||
still?: string | null;
|
||||
still_thumbnail_template?: string | null;
|
||||
} | null;
|
||||
}
|
||||
| null
|
||||
| undefined,
|
||||
options: MediaThumbnailOptions = {},
|
||||
): string | null {
|
||||
if (!mediaLinks) return null;
|
||||
|
||||
const { size = THUMBNAIL_SIZES.MEDIUM } = options;
|
||||
const previews = mediaLinks.maybe_video_previews;
|
||||
|
||||
if (previews?.still_thumbnail_template) {
|
||||
return getThumbnailUrl(previews.still_thumbnail_template, { width: size });
|
||||
}
|
||||
if (previews?.still) {
|
||||
return previews.still;
|
||||
}
|
||||
if (mediaLinks.maybe_thumbnail_template) {
|
||||
return getThumbnailUrl(mediaLinks.maybe_thumbnail_template, {
|
||||
width: size,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getContextImageThumbnail(
|
||||
contextImage: {
|
||||
media_links: {
|
||||
|
||||
@@ -5,6 +5,7 @@ export * from "./lib/prompts-cache";
|
||||
export * from "./lib/media-prompt-token-cache";
|
||||
export * from "./lib/gallery-view-store";
|
||||
export * from "./lib/GalleryViewToggle";
|
||||
export * from "./lib/GalleryAutoplayToggle";
|
||||
export * from "./lib/useGalleryData";
|
||||
export * from "./lib/useSessionMediaByClass";
|
||||
export * from "./lib/useGalleryEntries";
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faPause, faPlay } from "@fortawesome/pro-solid-svg-icons";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { Tooltip } from "@storyteller/ui-tooltip";
|
||||
import { useGalleryViewStore } from "./gallery-view-store";
|
||||
|
||||
const OPTIONS: {
|
||||
autoplay: boolean;
|
||||
icon: IconDefinition;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ autoplay: true, icon: faPlay, label: "Play video previews" },
|
||||
{ autoplay: false, icon: faPause, label: "Still thumbnails" },
|
||||
];
|
||||
|
||||
// Segmented playing/still switch for video preview thumbnails. Sits next to
|
||||
// GalleryViewToggle on the video page; the selection is shared with the
|
||||
// gallery via useGalleryViewStore.
|
||||
export function GalleryAutoplayToggle() {
|
||||
const autoplayVideos = useGalleryViewStore((s) => s.autoplayVideos);
|
||||
const setAutoplayVideos = useGalleryViewStore((s) => s.setAutoplayVideos);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Video preview playback"
|
||||
className="flex items-center gap-0.5 rounded-lg border border-white/[0.08] bg-white/[0.04] p-0.5"
|
||||
>
|
||||
{OPTIONS.map(({ autoplay, icon, label }) => {
|
||||
const active = autoplayVideos === autoplay;
|
||||
return (
|
||||
<Tooltip key={label} content={label} position="bottom" delay={300}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-pressed={active}
|
||||
onClick={() => setAutoplayVideos(autoplay)}
|
||||
className={`flex h-6 w-6 items-center justify-center rounded-md text-xs transition-colors ${
|
||||
active
|
||||
? "bg-white/10 text-white"
|
||||
: "text-white/45 hover:text-white/80"
|
||||
}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -185,6 +185,7 @@ export const GalleryCard = memo(function GalleryCard({
|
||||
) : (
|
||||
<GalleryThumbnail
|
||||
thumbnail={item.thumbnail}
|
||||
stillThumbnail={item.stillThumbnail}
|
||||
alt={item.label}
|
||||
isVideo={isVideo}
|
||||
fallbackIcon={mediaIcon}
|
||||
|
||||
@@ -85,6 +85,7 @@ export const GalleryRow = memo(function GalleryRow({
|
||||
<div className="relative size-[100px] shrink-0 overflow-hidden rounded-md bg-ui-controls/40 leading-none">
|
||||
<GalleryThumbnail
|
||||
thumbnail={item.thumbnail}
|
||||
stillThumbnail={item.stillThumbnail}
|
||||
alt={item.label}
|
||||
isVideo={isVideo}
|
||||
fallbackIcon={mediaIcon}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
import { PLACEHOLDER_IMAGES } from "@storyteller/common";
|
||||
import { useGalleryViewStore } from "./gallery-view-store";
|
||||
|
||||
// Media thumbnail shared by the masonry GalleryCard and the list GalleryRow.
|
||||
// Encapsulates the two awkward bits of thumbnail rendering: freshly generated
|
||||
@@ -40,6 +41,9 @@ const RETRY_INTERVAL = 5000;
|
||||
|
||||
interface GalleryThumbnailProps {
|
||||
thumbnail: string | null;
|
||||
// Still first-frame variant for videos. Shown instead of the (animated)
|
||||
// thumbnail when the user turns off autoplay in useGalleryViewStore.
|
||||
stillThumbnail?: string | null;
|
||||
alt: string;
|
||||
isVideo: boolean;
|
||||
// Icon shown when there is no thumbnail at all (e.g. 3D meshes).
|
||||
@@ -54,7 +58,8 @@ interface GalleryThumbnailProps {
|
||||
}
|
||||
|
||||
export function GalleryThumbnail({
|
||||
thumbnail,
|
||||
thumbnail: animatedThumbnail,
|
||||
stillThumbnail,
|
||||
alt,
|
||||
isVideo,
|
||||
fallbackIcon,
|
||||
@@ -63,6 +68,13 @@ export function GalleryThumbnail({
|
||||
showRetryLabel = true,
|
||||
onLoad,
|
||||
}: 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.
|
||||
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.
|
||||
|
||||
@@ -10,6 +10,10 @@ export type GalleryViewMode = "grid" | "list";
|
||||
interface GalleryViewState {
|
||||
viewMode: GalleryViewMode;
|
||||
setViewMode: (mode: GalleryViewMode) => void;
|
||||
// Whether video cards play their animated previews. When off, they show the
|
||||
// still first-frame thumbnail instead (toggled from the video page TopBar).
|
||||
autoplayVideos: boolean;
|
||||
setAutoplayVideos: (autoplay: boolean) => void;
|
||||
}
|
||||
|
||||
export const useGalleryViewStore = create<GalleryViewState>()(
|
||||
@@ -17,6 +21,8 @@ export const useGalleryViewStore = create<GalleryViewState>()(
|
||||
(set) => ({
|
||||
viewMode: "grid",
|
||||
setViewMode: (viewMode) => set({ viewMode }),
|
||||
autoplayVideos: true,
|
||||
setAutoplayVideos: (autoplayVideos) => set({ autoplayVideos }),
|
||||
}),
|
||||
{ name: "artcraft-gallery-view" },
|
||||
),
|
||||
|
||||
@@ -42,6 +42,9 @@ export interface GalleryItem {
|
||||
id: string;
|
||||
label: string;
|
||||
thumbnail: string | null;
|
||||
// Still first-frame thumbnail for videos, shown when the user turns off
|
||||
// animated previews (thumbnail holds the animated one).
|
||||
stillThumbnail?: string | null;
|
||||
fullImage: string | null;
|
||||
createdAt: string;
|
||||
mediaClass: string;
|
||||
|
||||
@@ -4,7 +4,11 @@ import {
|
||||
FilterMediaClasses,
|
||||
FilterMediaType,
|
||||
} from "@storyteller/api";
|
||||
import { getMediaThumbnail, THUMBNAIL_SIZES } from "@storyteller/common";
|
||||
import {
|
||||
getMediaStillThumbnail,
|
||||
getMediaThumbnail,
|
||||
THUMBNAIL_SIZES,
|
||||
} from "@storyteller/common";
|
||||
import type { GalleryItem } from "./types";
|
||||
import { is3DMediaClass } from "./types";
|
||||
|
||||
@@ -83,6 +87,12 @@ export function useGalleryData(options: {
|
||||
id: item.token,
|
||||
label: getLabel(item),
|
||||
thumbnail,
|
||||
stillThumbnail:
|
||||
item.media_class === "video"
|
||||
? getMediaStillThumbnail(item.media_links, {
|
||||
size: THUMBNAIL_SIZES.LARGE,
|
||||
})
|
||||
: null,
|
||||
fullImage: item.media_links?.cdn_url || null,
|
||||
createdAt: item.created_at,
|
||||
mediaClass: item.media_class || "image",
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { JobsApi, JobStatus, MediaFilesApi } from "@storyteller/api";
|
||||
import type { Job, Prompts } from "@storyteller/api";
|
||||
import { getMediaThumbnail, THUMBNAIL_SIZES } from "@storyteller/common";
|
||||
import {
|
||||
getMediaStillThumbnail,
|
||||
getMediaThumbnail,
|
||||
THUMBNAIL_SIZES,
|
||||
} from "@storyteller/common";
|
||||
import {
|
||||
getModelDisplayName,
|
||||
getProviderDisplayName,
|
||||
@@ -252,6 +256,12 @@ function jobToGalleryItem(
|
||||
id: result.entity_token,
|
||||
label: getPrompt(job, promptsMap) || "Generation",
|
||||
thumbnail,
|
||||
stillThumbnail:
|
||||
mediaClass === "video"
|
||||
? getMediaStillThumbnail(result.media_links, {
|
||||
size: THUMBNAIL_SIZES.LARGE,
|
||||
})
|
||||
: null,
|
||||
fullImage: result.media_links?.cdn_url || null,
|
||||
// Sort by job creation time (not completion time) so the completed card
|
||||
// occupies the same Masonry slot the pending card held — no layout shift.
|
||||
@@ -294,6 +304,12 @@ async function expandBatchItems(
|
||||
id: file.token,
|
||||
label: item.label,
|
||||
thumbnail,
|
||||
stillThumbnail:
|
||||
item.mediaClass === "video"
|
||||
? getMediaStillThumbnail(file.media_links, {
|
||||
size: THUMBNAIL_SIZES.LARGE,
|
||||
})
|
||||
: null,
|
||||
fullImage: cdnUrl,
|
||||
createdAt: item.createdAt,
|
||||
mediaClass: item.mediaClass,
|
||||
|
||||
Reference in New Issue
Block a user