test webapp 3d demo (#1517)

link for scene with output preview
This commit is contained in:
BFlat authored and GitHub committed 2026-05-15 02:47:10 +07:00
1 parent c8d4f9f297
commit bcadbaec35
3 files changed
+356 -137

No files matched your search

@@ -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=<media_token>` (alias: `?demo=<media_token>`). 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<OverlayMedia | null>(null);
const [promptText, setPromptText] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState<string | null>(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 = (
<Card
isExpanded={isExpanded}
onToggleExpanded={() => setIsExpanded((v) => !v)}
promptText={promptText}
>
{loading ? (
<div className="absolute inset-0 flex items-center justify-center">
<LoadingSpinner className="h-6 w-6 text-white/60" />
</div>
) : errorMessage ? (
<div className="absolute inset-0 flex items-center justify-center px-3 text-center text-xs text-white/60">
{errorMessage}
</div>
) : media ? (
<OverlayMediaView media={media} />
) : null}
</Card>
);
if (isExpanded) {
return (
<div className="pointer-events-auto absolute inset-0 z-30 flex items-center justify-center bg-black/70 p-8 backdrop-blur-sm animate-in fade-in duration-200">
<button
type="button"
aria-label="Close expanded view"
className="absolute inset-0 cursor-default"
onClick={() => setIsExpanded(false)}
/>
<div className="relative w-full max-w-5xl">{body}</div>
</div>
);
}
return (
<div className="pointer-events-none absolute right-4 top-4 z-30 w-[30%] min-w-[260px] max-w-lg animate-in fade-in slide-in-from-right-8 duration-500">
{body}
</div>
);
}
interface CardProps {
isExpanded: boolean;
onToggleExpanded: () => void;
promptText: string | null;
children: React.ReactNode;
}
function Card({
isExpanded,
onToggleExpanded,
promptText,
children,
}: CardProps) {
return (
<div className="pointer-events-auto overflow-hidden rounded-xl border border-white/10 bg-black/50 shadow-2xl ring-1 ring-blue-500/20 backdrop-blur-lg">
<div className="flex items-center justify-between gap-3 border-b border-white/10 bg-gradient-to-r from-blue-500/10 via-transparent to-transparent px-3 py-2">
<div className="flex min-w-0 items-center gap-2">
<FontAwesomeIcon
icon={faWandMagicSparkles}
className="h-3 w-3 shrink-0 text-blue-300"
/>
<div className="min-w-0 leading-tight">
<div className="text-xs font-semibold uppercase tracking-wider text-white">
Rendered Output
</div>
<div className="truncate text-[10px] text-white/50">
Generated from this scene
</div>
</div>
</div>
<button
type="button"
onClick={onToggleExpanded}
aria-label={isExpanded ? "Collapse output view" : "Expand output view"}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-white/60 transition hover:bg-white/10 hover:text-white"
>
<FontAwesomeIcon
icon={isExpanded ? faCompress : faExpand}
className="h-3 w-3"
/>
</button>
</div>
<div className="relative aspect-video w-full bg-black/60">{children}</div>
{promptText && (
<div
className="border-t border-white/10 px-3 py-2 text-[11px] leading-snug text-white/70"
title={promptText}
style={{
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{promptText}
</div>
)}
</div>
);
}
function OverlayMediaView({ media }: { media: OverlayMedia }) {
const src = addCorsParam(media.url) || media.url;
if (media.is3D) {
return <Viewer3D modelUrl={src} isActive className="h-full w-full" />;
}
if (media.isVideo) {
return (
<video
src={src}
className="h-full w-full object-contain"
autoPlay
loop
muted
playsInline
/>
);
}
return (
<img
src={src}
alt="Rendered output"
className="h-full w-full object-contain"
onError={(e) => {
(e.currentTarget as HTMLImageElement).src = PLACEHOLDER_IMAGES.DEFAULT;
}}
/>
);
}
@@ -1,5 +1,10 @@
import { useCallback, useEffect, useRef } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import {
Link,
useNavigate,
useParams,
useSearchParams,
} from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faDesktop, faHouse } from "@fortawesome/pro-solid-svg-icons";
import { Button } from "@storyteller/ui-button";
@@ -11,6 +16,7 @@ import {
import { useSession } from "../../lib/session";
import { useSidebar } from "../../components/ui/sidebar";
import { useSignupCta } from "../../components/signup-cta-modal";
import { DemoOutputOverlay } from "./demo-output-overlay";
import { useSceneCacheStore } from "./scene-cache-store";
import { useWebAppPageSceneAdapter } from "./web-adapter";
@@ -45,6 +51,12 @@ export default function PageScene() {
function PageSceneEditor() {
const { sceneToken } = useParams<{ sceneToken?: string }>();
const [searchParams] = useSearchParams();
// `?output=<media_token>` (alias `?demo=`) flips the editor into demo
// mode: the named output media renders in a top-right picture-in-picture
// card alongside the live scene. Empty string is normalized to null.
const demoOutputToken =
searchParams.get("output") || searchParams.get("demo") || null;
const { user } = useSession();
const navigate = useNavigate();
const { setOpen } = useSidebar();
@@ -145,6 +157,7 @@ function PageSceneEditor() {
adds. Both components portal themselves out of this wrapper. */}
<GalleryModal mode="view" />
<GalleryDragComponent />
{demoOutputToken && <DemoOutputOverlay outputToken={demoOutputToken} />}
</div>
);
}
@@ -123,148 +123,114 @@ export class StoryTellerProxyScene {
version: number,
ticket?: LoadTicket,
) {
if (scene_json == null || this.scene == null) return;
while (this.scene.scene.children.length > 0) {
this.scene.scene.remove(this.scene.scene.children[0]);
}
// Warm Scene's URL cache up front so per-asset loadObject() calls
// never hit the network for token→URL resolution. One batch call
// (or N parallel singles via the fallback) instead of an N+1
// sequential chain.
const mediaTokens = scene_json
.map((j) => j.media_file_token)
.filter((t) => t.startsWith("m_"));
if (mediaTokens.length > 0) {
await this.scene.warmMediaURLs(mediaTokens);
}
if (ticket?.cancelled) return;
// Build per-object load tasks. Each task creates its Object3D
// (network calls happen here, in parallel across tasks) and
// returns the pair so the post-pass can apply transforms in
// original JSON order. Errors don't abort siblings — Promise
// .allSettled lets one bad asset land while the rest succeed.
const tasks = scene_json.map(
async (
json_object,
): Promise<{
json_object: ObjectJSON;
obj: THREE.Object3D | undefined;
}> => {
const token = json_object.media_file_token;
if (token === "Parim") {
// The display name ("Cube", "Point Light") doesn't match
// Scene.instantiate's geometry switch — that switch wants
// the geometry key ("Box", "PointLight"). addShape stashes
// the original key in userData.shapeKey so we can recover
// it here; without this, instantiate falls through to its
// else branch and returns an empty Mesh with undefined
// geometry, which renders invisibly and isn't raycast-
// pickable.
const shapeKey =
(json_object.user_data?.shapeKey as string | undefined) ??
json_object.object_name;
const newScene = await this.scene.instantiate(shapeKey);
return {
json_object,
obj: this.scene.get_object_by_uuid(newScene.uuid),
};
}
if (token === "DirectionalLight") {
return { json_object, obj: this.scene._create_base_lighting() };
}
if (token.includes("m_")) {
const obj = await this.scene.loadObject(
token,
json_object.object_name,
true,
new THREE.Vector3(-0.5, 1.5, 0),
version,
ticket?.signal,
);
obj.userData = json_object.user_data;
if (json_object.rigData) {
const boneHelper = new BoneJSONHelper(obj);
boneHelper.poseFromBoneJSON(json_object.rigData);
if (scene_json != null && this.scene != null) {
while (this.scene.scene.children.length > 0) {
this.scene.scene.remove(this.scene.scene.children[0]);
}
for (const json_object of scene_json) {
const token: string = json_object.media_file_token;
let obj;
switch (token) {
case "Parim": {
// The display name ("Cube", "Point Light") doesn't match
// Scene.instantiate's geometry switch — that switch wants
// the geometry key ("Box", "PointLight"). addShape stashes
// the original key in userData.shapeKey so we can recover it
// here; without this, instantiate falls through to its else
// branch and returns an empty Mesh with undefined geometry,
// which renders invisibly and isn't raycast-pickable.
const shapeKey =
(json_object.user_data?.shapeKey as string | undefined) ??
json_object.object_name;
const newScene = await this.scene.instantiate(shapeKey);
const prim_uuid = newScene.uuid;
obj = this.scene.get_object_by_uuid(prim_uuid);
break;
}
return { json_object, obj };
}
if (token.includes("Point::")) {
const keyframe_uuid = token.replace("Point::", "");
const obj = this.scene.createPoint(
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(0, 0, 0),
keyframe_uuid,
);
return { json_object, obj };
}
if (token.includes("Image::")) {
const prim = await this.scene.instantiate(token);
const obj = this.scene.get_object_by_uuid(prim.uuid);
if (obj) {
obj.name = json_object.object_name;
obj.userData["name"] = json_object.object_name;
case "DirectionalLight": {
obj = this.scene._create_base_lighting();
break;
}
return { json_object, obj };
}
return { json_object, obj: undefined };
},
);
default: {
if (token.includes("m_")) {
obj = await this.scene.loadObject(
token,
json_object.object_name,
true,
new THREE.Vector3(-0.5, 1.5, 0),
version,
);
// Add any missing userdata back to the object. Older
// scene JSON may lack a `user_data` field; keep the
// default `{}` from THREE.Object3D's constructor in
// that case so the downstream `obj.userData.name = ...`
// assignment (below) doesn't crash on undefined.
if (json_object.user_data) {
obj.userData = json_object.user_data;
}
const settled = await Promise.allSettled(tasks);
if (ticket?.cancelled) return;
// Add rig data if present
if (json_object.rigData) {
const boneHelper = new BoneJSONHelper(obj);
boneHelper.poseFromBoneJSON(json_object.rigData);
} else {
console.log("No rig data found");
}
} else if (token.includes("Point::")) {
const keyframe_uuid = token.replace("Point::", "");
obj = this.scene.createPoint(
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(0, 0, 0),
keyframe_uuid,
);
} else if (token.includes("Image::")) {
const prim_uuid = (await this.scene.instantiate(token)).uuid;
obj = this.scene.get_object_by_uuid(prim_uuid);
// Synchronous transform-application pass. We walk results in
// original JSON order to preserve scene.children insertion
// ordering for downstream consumers that rely on it (timeline,
// export, etc.).
for (const result of settled) {
if (result.status === "rejected") {
// Swallow aborts from cancelled loads — they're expected and
// don't represent a real failure.
const reason = result.reason as { name?: string } | undefined;
if (reason?.name !== "AbortError") {
console.error("Scene load: object failed to load", result.reason);
if (obj) {
obj.name = json_object.object_name;
obj.userData["name"] = json_object.object_name;
}
}
break;
}
}
if (obj) {
obj.position.copy(json_object.position);
obj.rotation.copy(
new THREE.Euler(
json_object.rotation.x,
json_object.rotation.y,
json_object.rotation.z,
),
);
obj.scale.copy(json_object.scale);
obj.name = json_object.object_name;
obj.userData.name = json_object.object_user_data_name;
obj.uuid = json_object.object_uuid;
obj.userData["media_id"] = json_object.media_file_token;
obj.userData["locked"] = json_object.locked;
obj.userData["color"] = json_object.color;
obj.userData["metalness"] = json_object.metalness;
obj.userData["shininess"] = json_object.shininess;
obj.userData["specular"] = json_object.specular;
obj.userData["media_file_type"] = json_object.media_file_type;
// Re-stash the geometry key on the recreated shape so the
// next save round-trip survives — the wholesale userData
// copy that the m_* branch does isn't applied here.
if (json_object.user_data?.shapeKey) {
obj.userData.shapeKey = json_object.user_data.shapeKey;
}
if (json_object.visible !== undefined) {
this.scene.setVisible(obj.uuid, json_object.visible);
}
this.scene.setColor(obj.uuid, json_object.color);
}
continue;
}
const { obj, json_object } = result.value;
if (!obj) continue;
obj.position.copy(json_object.position);
obj.rotation.copy(
new THREE.Euler(
json_object.rotation.x,
json_object.rotation.y,
json_object.rotation.z,
),
);
obj.scale.copy(json_object.scale);
obj.name = json_object.object_name;
obj.userData.name = json_object.object_user_data_name;
obj.uuid = json_object.object_uuid;
obj.userData["media_id"] = json_object.media_file_token;
obj.userData["locked"] = json_object.locked;
obj.userData["color"] = json_object.color;
obj.userData["metalness"] = json_object.metalness;
obj.userData["shininess"] = json_object.shininess;
obj.userData["specular"] = json_object.specular;
obj.userData["media_file_type"] = json_object.media_file_type;
// Re-stash the geometry key on the recreated shape so the next
// save round-trip survives — the wholesale userData copy that
// the m_* branch does isn't applied here.
if (json_object.user_data?.shapeKey) {
obj.userData.shapeKey = json_object.user_data.shapeKey;
}
if (json_object.visible !== undefined) {
this.scene.setVisible(obj.uuid, json_object.visible);
}
this.scene.setColor(obj.uuid, json_object.color);
this.scene._createGrid();
this.scene.updateSkybox(skybox_media_id);
}
this.scene._createGrid();
this.scene.updateSkybox(skybox_media_id);
}
}