From eb59e7fcd76f56847fefc391a8fb66efae6569f2 Mon Sep 17 00:00:00 2001 From: BFlat Date: Mon, 20 Jul 2026 21:12:01 +0700 Subject: [PATCH] Webapp: move audio/3D reference inputs into the prompt box reference deck (#1795) ## Summary Compacts the legacy reference bands above the prompt box (audio ref row, multi-view/input-mesh row, reference video row) into the reference deck design already used on the image and video pages. Mobile forms keep their existing rows. ## Changes - **Create Audio** - audio tracks are now deck cards (playable tile, duration badge) with Upload / From library in the deck's "+" menu; band removed. Mutual-exclusion and "Audio track required" behavior unchanged. - **Create 3D World** - guide video is now a deck card; store reworked to the shared `RefVideo[]` shape. - **Create 3D Object** - Front/Back/Left/Right are always-visible labeled slot cards next to the image-ref deck (same design as First/Last frame on video), plus a "Mesh" slot card for mesh-to-mesh models. Upload logic deduplicated into `mesh-upload.ts`. - **Webapp PromptBox** - removed the `mediaReferenceRow` band prop; deck video/audio groups gate on `videoRefsSupported`/`audioRefsSupported` (no longer video-page-only); new `referenceSlots` prop; `maxImages: 0` blocks image uploads on pages whose model takes no image refs. - **Deck lib** - exported `DeckSlotCard`; new `mesh` card kind; `sortable: false` for fixed slots; non-finite duration caps hide the seconds limit; collapsed fan width now tracks card count, so a single ref sits snug against the prompt (multi-ref unchanged). --- .../prompt-box/MediaReferenceRow.tsx | 2 +- .../src/components/prompt-box/PromptBox.tsx | 78 ++++---- .../src/pages/create-audio/create-audio.tsx | 66 +++---- .../src/pages/create-image/create-image.tsx | 6 +- .../components/MeshDeckSlots.tsx | 170 ++++++++++++++++++ .../components/MeshInputsRow.tsx | 75 ++------ .../create-object/components/mesh-upload.ts | 63 +++++++ .../src/pages/create-object/create-object.tsx | 59 ++++-- .../src/pages/create-video/create-video.tsx | 2 +- .../components/ReferenceVideoSlot.tsx | 72 ++++---- .../pages/create-world/create-world-store.ts | 20 +-- .../src/pages/create-world/create-world.tsx | 51 ++++-- .../promptbox/src/lib/deck/DeckCard.tsx | 13 +- .../promptbox/src/lib/deck/KeyframeCards.tsx | 16 +- .../promptbox/src/lib/deck/ReferenceDeck.tsx | 21 ++- .../promptbox/src/lib/deck/deckTypes.ts | 5 +- .../promptbox/src/lib/deck/useDeckMedia.tsx | 13 +- 17 files changed, 510 insertions(+), 222 deletions(-) create mode 100644 frontend/apps/artcraft-webapp/src/pages/create-object/components/MeshDeckSlots.tsx create mode 100644 frontend/apps/artcraft-webapp/src/pages/create-object/components/mesh-upload.ts diff --git a/frontend/apps/artcraft-webapp/src/components/prompt-box/MediaReferenceRow.tsx b/frontend/apps/artcraft-webapp/src/components/prompt-box/MediaReferenceRow.tsx index 3a8fe900de..4aaaaf87b8 100644 --- a/frontend/apps/artcraft-webapp/src/components/prompt-box/MediaReferenceRow.tsx +++ b/frontend/apps/artcraft-webapp/src/components/prompt-box/MediaReferenceRow.tsx @@ -213,7 +213,7 @@ export const MediaReferenceRow = ({ />
e.stopPropagation()} diff --git a/frontend/apps/artcraft-webapp/src/components/prompt-box/PromptBox.tsx b/frontend/apps/artcraft-webapp/src/components/prompt-box/PromptBox.tsx index 38623077cf..af3850be51 100644 --- a/frontend/apps/artcraft-webapp/src/components/prompt-box/PromptBox.tsx +++ b/frontend/apps/artcraft-webapp/src/components/prompt-box/PromptBox.tsx @@ -81,7 +81,8 @@ interface PromptBoxProps { onClearAllExtras?: () => void; hasClearableExtras?: boolean; - // Reference-mode media (video page passes these; other pages omit) + // Video/audio references, rendered as cards in the reference deck (video + // page in reference mode, world page's guide video, audio page's tracks). videoRefsSupported?: boolean; referenceVideos?: RefVideo[]; onReferenceVideosChange?: (videos: RefVideo[]) => void; @@ -93,9 +94,9 @@ interface PromptBoxProps { maxAudioCount?: number; maxAudioRefDuration?: number; - // Legacy band rendered above the box (audio/object/world pages still use - // this; the image/video pages moved to the inline reference deck). - mediaReferenceRow?: ReactNode; + // Always-visible named slot cards rendered beside the reference deck + // (object page's multi-view angles + input mesh), built from DeckSlotCard. + referenceSlots?: ReactNode; // Model selector (rendered above the toolbar, typically hidden on desktop via lg:hidden) modelSelector?: ReactNode; @@ -151,7 +152,7 @@ export const PromptBox = forwardRef( onReferenceAudiosChange, maxAudioCount = 2, maxAudioRefDuration = 30, - mediaReferenceRow, + referenceSlots, modelSelector, secondaryPromptRow, mentionItems, @@ -195,7 +196,9 @@ export const PromptBox = forwardRef( const deck = useDeckMedia({ referenceImages, setReferenceImages: onReferenceImagesChange, - maxImages: maxImagePromptCount, + // 0 blocks image uploads (incl. via the combined picker) on pages whose + // model only takes audio/video/mesh refs. + maxImages: supportsImagePrompts ? maxImagePromptCount : 0, setEndFrameImage: onEndFrameImageChange, referenceVideos, setReferenceVideos: onReferenceVideosChange, @@ -278,8 +281,9 @@ export const PromptBox = forwardRef( const deckAddActions: DeckAddAction[] = []; if ( + supportsImagePrompts && referenceImages.length + deck.uploadingImages.length < - maxImagePromptCount + maxImagePromptCount ) { deckAddActions.push({ key: "upload-image", @@ -297,8 +301,6 @@ export const PromptBox = forwardRef( } } if ( - isVideo && - isReferenceMode && videoRefsSupported && referenceVideos.length < maxVideoCount && !deck.uploadingVideo @@ -319,8 +321,6 @@ export const PromptBox = forwardRef( } } if ( - isVideo && - isReferenceMode && audioRefsSupported && referenceAudios.length < maxAudioCount && !deck.uploadingAudio @@ -422,10 +422,10 @@ export const PromptBox = forwardRef( }; // Left-of-textarea reference widget: image deck, keyframe cards, or the - // mixed reference-mode deck depending on page/mode. + // mixed deck depending on page/mode. const renderReferenceWidget = (alwaysExpanded?: boolean) => { - if (!supportsImagePrompts) return null; if (isVideo && !isReferenceMode) { + if (!supportsImagePrompts) return null; return ( ( /> ); } + if (!supportsImagePrompts && !videoRefsSupported && !audioRefsSupported) { + return null; + } const totalVideoRefSeconds = referenceVideos.reduce( (sum, video) => sum + video.duration, 0, @@ -477,16 +480,24 @@ export const PromptBox = forwardRef( (sum, audio) => sum + audio.duration, 0, ); - const groupHints: Record = { - image: `${referenceImages.length}/${maxImagePromptCount}`, - }; - if (isVideo && isReferenceMode) { - if (videoRefsSupported) { - groupHints.video = `${referenceVideos.length}/${maxVideoCount} · ${totalVideoRefSeconds}/${maxVideoRefDuration}s`; - } - if (audioRefsSupported) { - groupHints.audio = `${referenceAudios.length}/${maxAudioCount} · ${totalAudioRefSeconds}/${maxAudioRefDuration}s`; - } + const groupHints: Record = {}; + if (supportsImagePrompts) { + groupHints.image = `${referenceImages.length}/${maxImagePromptCount}`; + } + // A non-finite duration cap means "no limit" — show counts only. + if (videoRefsSupported) { + groupHints.video = + `${referenceVideos.length}/${maxVideoCount}` + + (isFinite(maxVideoRefDuration) + ? ` · ${totalVideoRefSeconds}/${maxVideoRefDuration}s` + : ""); + } + if (audioRefsSupported) { + groupHints.audio = + `${referenceAudios.length}/${maxAudioCount}` + + (isFinite(maxAudioRefDuration) + ? ` · ${totalAudioRefSeconds}/${maxAudioRefDuration}s` + : ""); } return ( @@ -495,7 +506,11 @@ export const PromptBox = forwardRef( canAdd={deckAddActions.length > 0} addActions={deckAddActions} addMenuGroupHints={groupHints} - onAddClick={deck.openAnyUpload} + onAddClick={ + supportsImagePrompts || videoRefsSupported || audioRefsSupported + ? deck.openAnyUpload + : undefined + } onRemove={handleRemoveDeckItem} onReorderImages={(from, to) => onReferenceImagesChange(arrayMove(referenceImages, from, to)) @@ -690,19 +705,15 @@ export const PromptBox = forwardRef(
{deck.fileInputs} - {mediaReferenceRow && ( -
{mediaReferenceRow}
- )} -
{renderReferenceWidget()} + {referenceSlots}
{hasMentionItems && mentionItems ? ( @@ -908,7 +919,14 @@ export const PromptBox = forwardRef( {leftToolbar} } - imagePromptRow={renderReferenceWidget(true) ?? undefined} + imagePromptRow={ + renderReferenceWidget(true) || referenceSlots ? ( +
+ {renderReferenceWidget(true)} + {referenceSlots} +
+ ) : undefined + } clearAllButton={ s.ui); const setUi = useCreateAudioStore((s) => s.setUi); - const selectedModel = useMemo( - (): OmniGenAudioModelDetails | undefined => { - if (!apiModels.length) return undefined; - if (ui.selectedModelId) { - return ( - apiModels.find((m) => m.model === ui.selectedModelId) ?? - apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ?? - apiModels[0] - ); - } + const selectedModel = useMemo((): OmniGenAudioModelDetails | undefined => { + if (!apiModels.length) return undefined; + if (ui.selectedModelId) { return ( - apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ?? apiModels[0] + apiModels.find((m) => m.model === ui.selectedModelId) ?? + apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ?? + apiModels[0] ); - }, - [apiModels, ui.selectedModelId], - ); + } + return apiModels.find((m) => m.model === DEFAULT_MODEL_ID) ?? apiModels[0]; + }, [apiModels, ui.selectedModelId]); const prompt = ui.prompt; const setPrompt = useCallback((v: string) => setUi({ prompt: v }), [setUi]); @@ -287,7 +282,9 @@ export default function CreateAudio() { (audios: typeof referenceAudios) => { if (audios.length > 0 && referenceImages.length > 0) { setReferenceImages([]); - toast.error("Removed image reference — it can't be combined with audio"); + toast.error( + "Removed image reference — it can't be combined with audio", + ); } setReferenceAudios(audios); }, @@ -298,7 +295,9 @@ export default function CreateAudio() { (images: RefImage[]) => { if (images.length > 0 && referenceAudios.length > 0) { setReferenceAudios([]); - toast.error("Removed audio reference — it can't be combined with an image"); + toast.error( + "Removed audio reference — it can't be combined with an image", + ); } setReferenceImages(images); }, @@ -363,13 +362,15 @@ export default function CreateAudio() { const handleLibraryImageSelect = useCallback( (items: GalleryItem[]) => { - const newImages: RefImage[] = items.slice(0, maxImageRefs).map((item) => ({ - id: Math.random().toString(36).substring(7), - url: item.thumbnail || item.fullImage || "", - fullUrl: item.fullImage || undefined, - file: new File([], "library-image"), - mediaToken: item.id, - })); + const newImages: RefImage[] = items + .slice(0, maxImageRefs) + .map((item) => ({ + id: Math.random().toString(36).substring(7), + url: item.thumbnail || item.fullImage || "", + fullUrl: item.fullImage || undefined, + file: new File([], "library-image"), + mediaToken: item.id, + })); handleReferenceImagesChange(newImages); setIsImagePickerOpen(false); }, @@ -547,6 +548,7 @@ export default function CreateAudio() { ); + // Mobile-only band; on desktop the audio refs live in the reference deck. const audioReferenceRow = audioRefsSupported ? ( setIsImagePickerOpen(true)} - onClearAllExtras={() => { - setStylePrompt(""); - setReferenceAudios([]); - }} - hasClearableExtras={ - ui.stylePrompt.length > 0 || referenceAudios.length > 0 - } - mediaReferenceRow={audioReferenceRow} + audioRefsSupported={audioRefsSupported} + referenceAudios={referenceAudios} + onReferenceAudiosChange={handleReferenceAudiosChange} + maxAudioCount={maxAudioRefs} + maxAudioRefDuration={AUDIO_REF_MAX_DURATION_SECONDS} + onPickAudioFromLibrary={() => setIsAudioPickerOpen(true)} + onClearAllExtras={() => setStylePrompt("")} + hasClearableExtras={ui.stylePrompt.length > 0} secondaryPromptRow={ styleSupported ? ( diff --git a/frontend/apps/artcraft-webapp/src/pages/create-object/components/MeshDeckSlots.tsx b/frontend/apps/artcraft-webapp/src/pages/create-object/components/MeshDeckSlots.tsx new file mode 100644 index 0000000000..d9cd2390c1 --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/pages/create-object/components/MeshDeckSlots.tsx @@ -0,0 +1,170 @@ +import { useRef, useState } from "react"; +import { + DeckPreviewModal, + DeckSlotCard, + type DeckItem, +} from "@storyteller/ui-promptbox"; +import type { MeshInputsState } from "../create-object-store"; +import type { MultiViewSlot } from "./MeshInputsRow"; +import { MESH_FILE_ACCEPT, uploadMeshFile, uploadViewImage } from "./mesh-upload"; + +// Always-visible named slot cards (Front/Back/Left/Right angles + input mesh) +// rendered beside the reference deck in the desktop prompt box — same design +// as the video page's First/Last frame cards. The mobile form keeps the +// MeshInputsRow band; both write to the same store slice. + +const VIEW_SLOTS: { + slot: MultiViewSlot; + key: keyof MeshInputsState; + label: string; +}[] = [ + { slot: "front", key: "frontImage", label: "Front" }, + { slot: "back", key: "backImage", label: "Back" }, + { slot: "left", key: "leftImage", label: "Left" }, + { slot: "right", key: "rightImage", label: "Right" }, +]; + +interface MeshDeckSlotsProps { + showMultiView: boolean; + showMeshInput: boolean; + inputs: MeshInputsState; + setInputs: (patch: Partial) => void; + onPickSlotFromLibrary: (slot: MultiViewSlot) => void; +} + +export function MeshDeckSlots({ + showMultiView, + showMeshInput, + inputs, + setInputs, + onPickSlotFromLibrary, +}: MeshDeckSlotsProps) { + const viewInputRef = useRef(null); + const meshInputRef = useRef(null); + const targetSlotRef = useRef("front"); + const [uploadingView, setUploadingView] = useState<{ + slot: MultiViewSlot; + previewUrl: string; + } | null>(null); + const [isUploadingMesh, setIsUploadingMesh] = useState(false); + const [previewItem, setPreviewItem] = useState(null); + + const handleViewFile = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (viewInputRef.current) viewInputRef.current.value = ""; + if (!file) return; + const target = VIEW_SLOTS.find((v) => v.slot === targetSlotRef.current)!; + const previewUrl = URL.createObjectURL(file); + setUploadingView({ slot: target.slot, previewUrl }); + const image = await uploadViewImage(target.label, file); + URL.revokeObjectURL(previewUrl); + setUploadingView(null); + if (image) setInputs({ [target.key]: image }); + }; + + const handleMeshFile = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (meshInputRef.current) meshInputRef.current.value = ""; + if (!file) return; + setIsUploadingMesh(true); + const mesh = await uploadMeshFile(file); + setIsUploadingMesh(false); + if (mesh) setInputs({ inputMesh: mesh }); + }; + + const meshItem: DeckItem | undefined = inputs.inputMesh + ? { + id: inputs.inputMesh.id, + kind: "mesh", + name: inputs.inputMesh.file?.name || "Mesh file", + } + : isUploadingMesh + ? { id: "uploading-mesh", kind: "mesh", name: "Mesh file", uploading: true } + : undefined; + + return ( +
+ + + + {showMultiView && + VIEW_SLOTS.map(({ slot, key, label }) => { + const image = inputs[key]; + const item: DeckItem | undefined = image + ? { + id: image.id, + kind: "image", + url: image.url, + previewUrl: image.fullUrl ?? image.url, + name: `${label} view`, + } + : uploadingView?.slot === slot + ? { + id: `uploading-${slot}`, + kind: "image", + url: uploadingView.previewUrl, + name: `${label} view`, + uploading: true, + } + : undefined; + return ( + { + targetSlotRef.current = slot; + viewInputRef.current?.click(); + }, + }, + { + key: `library-${slot}`, + label: "From library", + onSelect: () => onPickSlotFromLibrary(slot), + }, + ]} + onRemove={() => setInputs({ [key]: undefined })} + onPreview={setPreviewItem} + /> + ); + })} + + {showMeshInput && ( + meshInputRef.current?.click(), + }, + ]} + onRemove={() => setInputs({ inputMesh: undefined })} + onPreview={setPreviewItem} + /> + )} + + setPreviewItem(null)} + /> +
+ ); +} diff --git a/frontend/apps/artcraft-webapp/src/pages/create-object/components/MeshInputsRow.tsx b/frontend/apps/artcraft-webapp/src/pages/create-object/components/MeshInputsRow.tsx index 08aad58433..1eac331bb9 100644 --- a/frontend/apps/artcraft-webapp/src/pages/create-object/components/MeshInputsRow.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/create-object/components/MeshInputsRow.tsx @@ -6,16 +6,12 @@ import { faCube, faImage, } from "@fortawesome/pro-solid-svg-icons"; -import { MediaUploadApi } from "@storyteller/api"; -import { FilterEngineCategories } from "@storyteller/api"; -import { UploaderStates } from "@storyteller/common"; -import { uploadImage } from "../../../components/prompt-box/upload-image"; import { AddButton, type RefImage } from "../../../components/prompt-box"; +import { MESH_FILE_ACCEPT, uploadMeshFile, uploadViewImage } from "./mesh-upload"; // Multi-view side inputs (front/back/left/right) and the mesh-to-mesh input -// file, shown above the prompt box when the selected model supports them. Uses -// the same media upload path (and Upload / Pick-from-library affordance) as the -// primary reference image row. +// file — the mobile form band. The desktop prompt box renders the same store +// slice as reference-deck cards via useMeshDeckRefs. export type MultiViewSlot = "front" | "back" | "left" | "right"; @@ -38,8 +34,6 @@ interface MeshInputsRowProps { onPickSlotFromLibrary?: (slot: MultiViewSlot) => void; } -const MESH_FILE_ACCEPT = ".glb,.gltf,.fbx,.obj"; - export function MeshInputsRow({ showMultiView, showMeshInput, @@ -63,9 +57,8 @@ export function MeshInputsRow({ return ( // Title on the left, upload slots right-aligned + top-aligned (matches - // ImagePromptRow). Flat bottom on desktop so it seams into the prompt box - // glass stacked below it; fully rounded on mobile (stands alone there). -
+ // ImagePromptRow). +
@@ -163,35 +156,14 @@ function ImageSlot({ const inputRef = useRef(null); const [uploading, setUploading] = useState(false); - const handleUpload = (event: React.ChangeEvent) => { + const handleUpload = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; + if (inputRef.current) inputRef.current.value = ""; if (!file) return; setUploading(true); - const reader = new FileReader(); - reader.onloadend = async () => { - await uploadImage({ - title: `${label.toLowerCase()}-view-${Math.random().toString(36).substring(2, 10)}`, - assetFile: file, - progressCallback: (state) => { - if (state.status === UploaderStates.success && state.data) { - onChange({ - id: Math.random().toString(36).substring(7), - url: reader.result as string, - file, - mediaToken: state.data, - }); - setUploading(false); - } else if ( - state.status === UploaderStates.assetError || - state.status === UploaderStates.imageCreateError - ) { - setUploading(false); - } - }, - }); - if (inputRef.current) inputRef.current.value = ""; - }; - reader.readAsDataURL(file); + const image = await uploadViewImage(label, file); + setUploading(false); + if (image) onChange(image); }; return ( @@ -247,31 +219,12 @@ function MeshFileSlot({ const handleUpload = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; + if (inputRef.current) inputRef.current.value = ""; if (!file) return; setUploading(true); - try { - const api = new MediaUploadApi(); - const response = await api.UploadNewEngineAsset({ - file, - fileName: file.name || `input-mesh-${Date.now()}`, - uuid: crypto.randomUUID(), - engine_category: FilterEngineCategories.OBJECT, - maybe_title: "input_mesh", - }); - if (response?.success && response.data) { - onChange({ - id: Math.random().toString(36).substring(7), - url: "", - file, - mediaToken: response.data, - }); - } - } catch { - // ignore — user can retry - } finally { - setUploading(false); - if (inputRef.current) inputRef.current.value = ""; - } + const mesh = await uploadMeshFile(file); + setUploading(false); + if (mesh) onChange(mesh); }; return ( diff --git a/frontend/apps/artcraft-webapp/src/pages/create-object/components/mesh-upload.ts b/frontend/apps/artcraft-webapp/src/pages/create-object/components/mesh-upload.ts new file mode 100644 index 0000000000..e632cf71d9 --- /dev/null +++ b/frontend/apps/artcraft-webapp/src/pages/create-object/components/mesh-upload.ts @@ -0,0 +1,63 @@ +import { MediaUploadApi, FilterEngineCategories } from "@storyteller/api"; +import { UploaderStates } from "@storyteller/common"; +import { uploadImage } from "../../../components/prompt-box/upload-image"; +import type { RefImage } from "../../../components/prompt-box"; + +// Upload paths shared by the desktop reference deck (useMeshDeckRefs) and the +// mobile MeshInputsRow band. + +export const MESH_FILE_ACCEPT = ".glb,.gltf,.fbx,.obj"; + +const randomId = () => Math.random().toString(36).substring(7); + +const readAsDataUrl = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + +/** Uploads a multi-view angle image; resolves null on failure. */ +export async function uploadViewImage( + label: string, + file: File, +): Promise { + const url = await readAsDataUrl(file); + return new Promise((resolve) => { + uploadImage({ + title: `${label.toLowerCase()}-view-${Math.random().toString(36).substring(2, 10)}`, + assetFile: file, + progressCallback: (state) => { + if (state.status === UploaderStates.success && state.data) { + resolve({ id: randomId(), url, file, mediaToken: state.data }); + } else if ( + state.status === UploaderStates.assetError || + state.status === UploaderStates.imageCreateError + ) { + resolve(null); + } + }, + }); + }); +} + +/** Uploads a mesh file via the engine-asset endpoint; resolves null on failure. */ +export async function uploadMeshFile(file: File): Promise { + try { + const api = new MediaUploadApi(); + const response = await api.UploadNewEngineAsset({ + file, + fileName: file.name || `input-mesh-${Date.now()}`, + uuid: crypto.randomUUID(), + engine_category: FilterEngineCategories.OBJECT, + maybe_title: "input_mesh", + }); + if (response?.success && response.data) { + return { id: randomId(), url: "", file, mediaToken: response.data }; + } + } catch { + // fall through — user can retry + } + return null; +} diff --git a/frontend/apps/artcraft-webapp/src/pages/create-object/create-object.tsx b/frontend/apps/artcraft-webapp/src/pages/create-object/create-object.tsx index 203765631f..5e065863fe 100644 --- a/frontend/apps/artcraft-webapp/src/pages/create-object/create-object.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/create-object/create-object.tsx @@ -4,11 +4,7 @@ import { PopoverMenu, type PopoverItem } from "@storyteller/ui-popover"; import { Tooltip } from "@storyteller/ui-tooltip"; import { Button, ToggleButton } from "@storyteller/ui-button"; import { GalleryModal, type GalleryItem } from "@storyteller/ui-gallery-modal"; -import { - faSparkles, - faGem, - faImage, -} from "@fortawesome/pro-solid-svg-icons"; +import { faSparkles, faGem, faImage } from "@fortawesome/pro-solid-svg-icons"; import { PromptBox, ImagePromptRow, @@ -37,6 +33,7 @@ import { QUALITY_OPTIONS, } from "./components/MeshOptionPickers"; import { MeshInputsRow, type MultiViewSlot } from "./components/MeshInputsRow"; +import { MeshDeckSlots } from "./components/MeshDeckSlots"; import { useMeshCostEstimate } from "../../lib/cost-estimate-api"; import { resolveModelOption } from "../../lib/resolve-model-setting"; import { @@ -124,8 +121,16 @@ export default function CreateObject() { // Settings are sticky across model switches, resolved against the current // model's supported options at read time (see lib/resolve-model-setting). - const meshOutputType = resolveModelOption(ui.meshOutputType, outputTypes, outputTypes[0]); - const polygonType = resolveModelOption(ui.polygonType, polygonTypes, polygonTypes[0]); + const meshOutputType = resolveModelOption( + ui.meshOutputType, + outputTypes, + outputTypes[0], + ); + const polygonType = resolveModelOption( + ui.polygonType, + polygonTypes, + polygonTypes[0], + ); const geometryQuality = resolveModelOption( ui.geometryQuality, supportsGeometryQuality ? QUALITY_OPTIONS : [], @@ -138,7 +143,9 @@ export default function CreateObject() { ); // Toggles default on when supported unless the user turned them off. const enablePbr = supportsPbr ? (ui.enablePbr ?? false) : false; - const enableTexture = supportsTextureToggle ? (ui.enableTexture ?? true) : true; + const enableTexture = supportsTextureToggle + ? (ui.enableTexture ?? true) + : true; const [isGenerating, setIsGenerating] = useState(false); const referenceImages = useCreateObjectStore((s) => s.referenceImages); @@ -458,12 +465,23 @@ export default function CreateObject() { const placeholder = supportsText ? "Describe the 3D object you want..." : supportsMeshInput - ? "Upload a mesh file below to process" - : "Add a reference image below"; + ? "Upload a mesh file to process" + : "Add a reference image"; - // Only render the extra-inputs row when the model actually supports one of its - // slots, otherwise it renders empty but still flags a "row above" the prompt - // box (flat-top visual glitch). + // Desktop: multi-view angles + input mesh render as always-visible slot + // cards beside the reference deck (like the video page's keyframe cards). + const meshDeckSlots = + supportsMultiView || supportsMeshInput ? ( + + ) : undefined; + + // Mobile-only band over the same store slice. const meshInputsRow = supportsMultiView || supportsMeshInput ? ( + diff --git a/frontend/apps/artcraft-webapp/src/pages/create-video/create-video.tsx b/frontend/apps/artcraft-webapp/src/pages/create-video/create-video.tsx index f348fbbbb6..bb1acd232a 100644 --- a/frontend/apps/artcraft-webapp/src/pages/create-video/create-video.tsx +++ b/frontend/apps/artcraft-webapp/src/pages/create-video/create-video.tsx @@ -1491,7 +1491,7 @@ export default function CreateVideo() { promptBox={
void; + video?: RefVideo; + onChange: (video?: RefVideo) => void; }) { const inputRef = useRef(null); const [uploading, setUploading] = useState(false); @@ -30,41 +34,41 @@ export function ReferenceVideoSlot({ if (!file) return; setUploading(true); const previewUrl = URL.createObjectURL(file); - try { - const api = new MediaUploadApi(); - const response = await api.UploadNewVideo({ - blob: file, - fileName: file.name || `reference-video-${Date.now()}`, - uuid: crypto.randomUUID(), - maybe_title: "splat_reference_video", - is_intermediate_system_file: EIntermediateFile.false, - }); - if (response?.success && response.data) { - onChange({ - id: Math.random().toString(36).substring(7), - previewUrl, - mediaToken: response.data, - }); - } else { - URL.revokeObjectURL(previewUrl); - } - } catch { - URL.revokeObjectURL(previewUrl); - } finally { - setUploading(false); - if (inputRef.current) inputRef.current.value = ""; - } + const duration = await getVideoDuration(file); + await uploadVideo({ + title: "splat_reference", + assetFile: file, + progressCallback: (state) => { + if (state.status === UploaderStates.success && state.data) { + onChange({ + id: Math.random().toString(36).substring(7), + url: previewUrl, + file, + mediaToken: state.data, + duration, + }); + setUploading(false); + } else if ( + state.status === UploaderStates.assetError || + state.status === UploaderStates.imageCreateError + ) { + URL.revokeObjectURL(previewUrl); + setUploading(false); + } + }, + }); + if (inputRef.current) inputRef.current.value = ""; }; const handleRemove = () => { - if (video?.previewUrl) URL.revokeObjectURL(video.previewUrl); + if (video?.url.startsWith("blob:")) URL.revokeObjectURL(video.url); onChange(undefined); }; return ( // Title on the left, upload slot right-aligned + top-aligned (matches - // ImagePromptRow). Flat bottom on desktop so it seams into the prompt box. -
+ // ImagePromptRow). +
@@ -82,7 +86,7 @@ export function ReferenceVideoSlot({ {video ? (