From bcadbaec355a613a8b9a76d07d49abe826bdbe97 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 15 May 2026 02:47:10 +0700 Subject: [PATCH] test webapp 3d demo (#1517) link for scene with output preview --- .../pages/pagescene/demo-output-overlay.tsx | 240 ++++++++++++++++++ .../src/pages/pagescene/pagescene.tsx | 15 +- .../src/lib/proxy/storyteller_proxy_scene.ts | 238 ++++++++--------- 3 files changed, 356 insertions(+), 137 deletions(-) create mode 100644 frontend/apps/artcraft-webapp/src/pages/pagescene/demo-output-overlay.tsx diff --git a/frontend/apps/artcraft-webapp/src/pages/pagescene/demo-output-overlay.tsx b/frontend/apps/artcraft-webapp/src/pages/pagescene/demo-output-overlay.tsx new file mode 100644 index 0000000000..7a36bb715c --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/pages/pagescene/demo-output-overlay.tsx @@ -0,0 +1,240 @@ +import { useEffect, useState } from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faCompress, + faExpand, + faWandMagicSparkles, +} from "@fortawesome/pro-solid-svg-icons"; +import { MediaFilesApi, PromptsApi } from "@storyteller/api"; +import { addCorsParam, PLACEHOLDER_IMAGES } from "@storyteller/common"; +import { LoadingSpinner } from "@storyteller/ui-loading-spinner"; +import { Viewer3D } from "@storyteller/ui-viewer-3d"; +import { is3DModelUrl, isVideoUrl } from "../../components/lightbox/shared"; + +// Demo overlay rendered on top-right of the 3D editor when the URL carries +// `?output=` (alias: `?demo=`). It resolves the +// token to a media URL and renders the asset (image / video / 3D model) +// in a 16:9 picture-in-picture card so the scene and its rendered output +// can be shown side by side. +// +// UX details aimed at making the relationship obvious at a glance: +// - "Rendered Output" header with a wand-sparkles icon and a +// "Generated from this scene" subtitle so first-time viewers +// immediately understand the card is the AI render of the scene +// they're looking at. +// - Slide-in entrance animation draws the eye to the corner on load +// so the card isn't missed. +// - The title-bar expand button promotes the card to a centered +// larger view over the editor; clicking again (or the backdrop / +// Esc) collapses it back to the corner. +// +// The component is fully self-contained: it fetches its own media and +// renders nothing while the token is unresolved or invalid. + +interface DemoOutputOverlayProps { + outputToken: string; +} + +interface OverlayMedia { + url: string; + isVideo: boolean; + is3D: boolean; +} + +export function DemoOutputOverlay({ outputToken }: DemoOutputOverlayProps) { + const [media, setMedia] = useState(null); + const [promptText, setPromptText] = useState(null); + const [loading, setLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(null); + const [isExpanded, setIsExpanded] = useState(false); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setErrorMessage(null); + setMedia(null); + setPromptText(null); + + (async () => { + try { + const mediaResponse = await new MediaFilesApi().GetMediaFileByToken({ + mediaFileToken: outputToken, + }); + if (cancelled) return; + + const file = mediaResponse?.data; + const url = file?.media_links?.cdn_url || null; + if (!mediaResponse?.success || !file || !url) { + setErrorMessage("Rendered output not found"); + return; + } + setMedia({ + url, + isVideo: isVideoUrl(url), + is3D: is3DModelUrl(url), + }); + + // The prompt fetch is best-effort and decorative; failures or a + // missing prompt_token should leave the card without a caption, + // not surface an error to the user. + if (file.maybe_prompt_token) { + const promptResponse = await new PromptsApi().GetPromptsByToken({ + token: file.maybe_prompt_token, + }); + if (cancelled) return; + const text = promptResponse?.success + ? promptResponse.data?.maybe_positive_prompt || null + : null; + setPromptText(text); + } + } catch { + if (!cancelled) setErrorMessage("Failed to load rendered output"); + } finally { + if (!cancelled) setLoading(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [outputToken]); + + useEffect(() => { + if (!isExpanded) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") setIsExpanded(false); + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [isExpanded]); + + const body = ( + setIsExpanded((v) => !v)} + promptText={promptText} + > + {loading ? ( +
+ +
+ ) : errorMessage ? ( +
+ {errorMessage} +
+ ) : media ? ( + + ) : null} +
+ ); + + if (isExpanded) { + return ( +
+
+ ); + } + + return ( +
+ {body} +
+ ); +} + +interface CardProps { + isExpanded: boolean; + onToggleExpanded: () => void; + promptText: string | null; + children: React.ReactNode; +} + +function Card({ + isExpanded, + onToggleExpanded, + promptText, + children, +}: CardProps) { + return ( +
+
+
+ +
+
+ Rendered Output +
+
+ Generated from this scene +
+
+
+ +
+
{children}
+ {promptText && ( +
+ {promptText} +
+ )} +
+ ); +} + +function OverlayMediaView({ media }: { media: OverlayMedia }) { + const src = addCorsParam(media.url) || media.url; + if (media.is3D) { + return ; + } + if (media.isVideo) { + return ( +