mirror of
https://github.com/storytold/artcraft.git
synced 2026-10-31 23:47:51 +00: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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user