Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 80 additions & 44 deletions ui/src/features/scene3d/Scene3DWorkspace.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { fetchOutputs, type ApiOutput } from '../../api/client'
import { AssetExplorerDialog } from '../../components/common/AssetExplorerDialog'
import { AssetInput } from '../../features/asset-picker/AssetInput.tsx'
import { useUiTranslation } from '../../i18n'
import { useStore } from '../../stores/useStore'
import { cameraEyeAtTime, projectPoint } from './camera.ts'
Expand All @@ -11,6 +11,7 @@ import { canMutateWorld3DScene } from './exportLock.ts'
import { exportWorld3DDocument } from './exportFlow.ts'
import { Scene3DStage, type Scene3DStageHandle } from './Scene3DStage.tsx'
import { applyScene3DTemplate, patchScene3DSlot, SCENE3D_TEMPLATES, type Scene3DTemplateId } from './templates.ts'
import { commitSlotSourceChoice, pickerOutputFromSlot, type SlotSourceCapture } from './slotSource.ts'
import type { Scene3DCameraFamily, Scene3DClipCatalogEntry, Scene3DDocument, Scene3DLoop, Scene3DSlot } from './types.ts'
import { documentFromWorld3DRequest, listenForWorld3DWorkflow } from './world3dAgent.ts'

Expand Down Expand Up @@ -54,11 +55,13 @@ export function Scene3DWorkspace({ width, height }: Props) {
const sceneDocRef = useRef(sceneDoc)
const dragRef = useRef<{ id: string; startX: number; startZ: number; pointerX: number; pointerY: number } | null>(null)
const [catalogs, setCatalogs] = useState<Record<string, Scene3DClipCatalogEntry[]>>({})
const [explorerSlot, setExplorerSlot] = useState<string | null>(null)
const [explorerItems, setExplorerItems] = useState<ApiOutput[]>([])
const [modelItems, setModelItems] = useState<ApiOutput[]>([])
const [imageItems, setImageItems] = useState<ApiOutput[]>([])
const [exporting, setExporting] = useState(false)
const [exportNote, setExportNote] = useState<string | null>(null)
const exportingRef = useRef(false)
const generationRef = useRef(0)
const workspaceRef = useRef('')
const stageRef = useRef<Scene3DStageHandle>(null)
const workspace = useStore(s => s.activeWorkspace)
const fps = sceneDoc.fps
Expand Down Expand Up @@ -94,6 +97,7 @@ export function Scene3DWorkspace({ width, height }: Props) {
throw new Error('world3d-export-in-progress')
}
const next = documentFromWorld3DRequest(request)
generationRef.current += 1
setSceneDoc(next)
setFrame(0)
return { message: next.templateId, templateId: request.templateId, slotIds: next.slots.map(slot => slot.id) }
Expand Down Expand Up @@ -125,35 +129,70 @@ export function Scene3DWorkspace({ width, height }: Props) {
return () => cancelAnimationFrame(raf)
}, [playing, sceneDoc.duration, fps, count])

const assignSource = (slotId: string, url: string, media: Scene3DSlot['media'] = 'model3d') => {
useEffect(() => {
if (workspaceRef.current && workspaceRef.current !== workspace) generationRef.current += 1
workspaceRef.current = workspace
}, [workspace])

useEffect(() => {
let alive = true
Promise.all([
fetchOutputs(200, 0, { mediaType: 'model3d', workspace }),
fetchOutputs(200, 0, { mediaType: 'image', workspace }),
]).then(([models, images]) => {
if (!alive) return
setModelItems(models.outputs.filter(item => item.type === 'model3d' && /\.glb$/i.test(item.name)))
setImageItems(images.outputs.filter(item => item.type === 'image'))
}).catch(() => {
if (!alive) return
setModelItems([])
setImageItems([])
})
return () => { alive = false }
}, [workspace])

const assignChoice = (slot: Scene3DSlot, capture: SlotSourceCapture, item: ApiOutput | null) => {
const commit = commitSlotSourceChoice({
generation: generationRef.current,
slotId: slot.id,
templateId: sceneDocRef.current.templateId,
workspaceId: workspaceRef.current || workspace,
exporting: exportingRef.current,
}, capture, item)
if (commit.action === 'ignore') return
if (!canMutateWorld3DScene(exportingRef.current)) return
setSceneDoc(current => patchScene3DSlot(current, slotId, { sourceUrl: url, media, clip: null }))
if (commit.action === 'clear') {
revokeIfBlob(slot.sourceUrl)
setSceneDoc(current => patchScene3DSlot(current, slot.id, { sourceUrl: '', sourceRef: undefined, clip: null }))
setCatalogs(current => {
const next = { ...current }
delete next[slot.id]
return next
})
return
}
revokeIfBlob(slot.sourceUrl)
setSceneDoc(current => patchScene3DSlot(current, slot.id, {
sourceUrl: commit.sourceUrl,
sourceRef: commit.sourceRef,
media: commit.media,
clip: commit.clip,
}))
setCatalogs(current => {
const next = { ...current }
delete next[slotId]
delete next[slot.id]
return next
})
}

const openExplorer = async (slot: Scene3DSlot) => {
if (!canMutateWorld3DScene(exportingRef.current)) return
setExplorerSlot(slot.id)
try {
const kind = slot.slot === 'background' ? 'image' : 'model3d'
const data = await fetchOutputs(0, 0, { mediaType: kind, workspace })
setExplorerItems(kind === 'model3d' ? data.outputs.filter(item => /\.glb$/i.test(item.name)) : data.outputs)
} catch {
setExplorerItems([])
}
}

const applyScene = (updater: Parameters<typeof setSceneDoc>[0]) => {
if (!canMutateWorld3DScene(exportingRef.current)) return
setSceneDoc(updater)
}

const mountTemplate = (id: Scene3DTemplateId) => {
if (!canMutateWorld3DScene(exportingRef.current)) return
generationRef.current += 1
for (const slot of sceneDoc.slots) revokeIfBlob(slot.sourceUrl)
applyScene(applyScene3DTemplate(id))
setCatalogs({})
Expand Down Expand Up @@ -183,7 +222,6 @@ export function Scene3DWorkspace({ width, height }: Props) {
const stage = stageRef.current
if (!stage || exportingRef.current || playing) return
dragRef.current = null
setExplorerSlot(null)
setExportingFlag(true)
setExportNote(t('stage.exporting'))
try {
Expand Down Expand Up @@ -289,24 +327,32 @@ export function Scene3DWorkspace({ width, height }: Props) {
<div className="grid gap-1.5 md:grid-cols-2">
{sceneDoc.slots.map(slot => {
const clips = catalogs[slot.id] ?? []
const capture: SlotSourceCapture = {
generation: generationRef.current,
slotId: slot.id,
templateId: sceneDoc.templateId,
workspaceId: workspace,
}
return (
<div key={slot.id} className={`rounded border p-1.5 text-[9px] text-text-secondary ${selectedId === slot.id ? 'border-cyan-300 bg-cyan-400/5' : 'border-border bg-bg-primary'}`}>
<button type="button" className="block font-medium text-text-primary" onClick={() => setSelectedId(slot.id)}>{t(`stage.slot.${slot.slot}`)}</button>
<button type="button" disabled={exporting} onClick={() => void openExplorer(slot)} className="mt-1 w-full rounded border border-cyan-400/40 bg-cyan-400/10 px-1.5 py-1 text-[9px] text-cyan-100 disabled:opacity-40">
{t('stage.fromApp')}
</button>
<input
type="file"
disabled={exporting}
accept={slot.slot === 'background' ? 'image/*' : '.glb,model/gltf-binary'}
className="mt-1 w-full text-[8px] disabled:opacity-40"
onChange={event => {
const file = event.target.files?.[0]
if (!file) return
revokeIfBlob(slot.sourceUrl)
assignSource(slot.id, URL.createObjectURL(file), slot.slot === 'background' ? 'image' : 'model3d')
}}
/>
<div className="mt-1">
<AssetInput
label={t(`stage.slot.${slot.slot}`)}
placeholder={t('stage.fromApp')}
items={slot.slot === 'background' ? imageItems : modelItems}
value={pickerOutputFromSlot(slot.sourceUrl, slot.media, slot.sourceRef)}
accept={slot.slot === 'background' ? 'image/*' : '.glb,model/gltf-binary'}
optional
disabled={exporting}
constraints={{
kinds: slot.slot === 'background' ? ['image'] : ['model3d'],
maxCount: 1,
optional: true,
}}
onChoose={item => assignChoice(slot, capture, item)}
/>
</div>
{clips.length > 0 && (
<select
className="mt-1 w-full rounded border border-border bg-bg-tertiary px-1 py-0.5 disabled:opacity-40"
Expand Down Expand Up @@ -349,16 +395,6 @@ export function Scene3DWorkspace({ width, height }: Props) {
{exportNote && <p className="text-[8px] text-cyan-100" data-testid="world3d-export-note">{exportNote}</p>}
<p className="text-[8px] text-text-muted">{sceneDoc.templateId === 'run-loop' ? t('stage.runHelp') : t('stage.help')}</p>
<span data-testid="scene3d-roundtrip" className="hidden">{roundtrip ? 'ok' : 'bad'}</span>
<AssetExplorerDialog
open={Boolean(explorerSlot)}
title={t('stage.fromApp')}
items={explorerItems}
onClose={() => setExplorerSlot(null)}
onChoose={item => {
if (item && explorerSlot) assignSource(explorerSlot, item.url, item.type === 'image' ? 'image' : 'model3d')
setExplorerSlot(null)
}}
/>
</div>
)
}
Expand Down
17 changes: 12 additions & 5 deletions ui/src/features/scene3d/document.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { parseScene3DLoop } from './backdrop.ts'
import { durableScene3DSourceUrl, parseScene3DSourceRef } from './slotSource.ts'
import { SCENE3D_TEMPLATE_IDS, type Scene3DDocument, type Scene3DSlot, type Scene3DTemplateId } from './types.ts'

const SLOT_COLORS: Record<string, [number, number, number]> = {
Expand Down Expand Up @@ -71,11 +72,17 @@ export function parseScene3DDocument(raw: unknown): Scene3DDocument | null {
const value = raw as Partial<Scene3DDocument>
if (value.version !== 1 || value.units !== 'meters' || value.up !== 'y') return null
if (!Array.isArray(value.slots) || !value.camera || !value.light) return null
const slots = value.slots.map(slot => ({
...slot,
media: slot.media === 'image' ? 'image' as const : 'model3d' as const,
loop: parseScene3DLoop(slot.loop),
}))
const slots = value.slots.map(slot => {
const sourceUrl = durableScene3DSourceUrl(typeof slot.sourceUrl === 'string' ? slot.sourceUrl : '')
const sourceRef = parseScene3DSourceRef(slot.sourceRef)
return {
...slot,
sourceUrl,
sourceRef: sourceUrl && sourceRef ? sourceRef : undefined,
media: slot.media === 'image' ? 'image' as const : 'model3d' as const,
loop: parseScene3DLoop(slot.loop),
}
})
const templateId: Scene3DTemplateId = typeof value.templateId === 'string'
&& (SCENE3D_TEMPLATE_IDS as readonly string[]).includes(value.templateId)
? value.templateId as Scene3DTemplateId
Expand Down
94 changes: 94 additions & 0 deletions ui/src/features/scene3d/slotSource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { ApiOutput } from '../../api/outputs'
import type { Scene3DSlotMedia, Scene3DSourceRef } from './types.ts'

export type { Scene3DSourceRef }

export type SlotSourceCapture = {
generation: number
slotId: string
templateId: string
workspaceId: string
}

export type SlotSourceLive = {
generation: number
slotId: string
templateId: string
workspaceId: string
exporting: boolean
}

export type SlotSourceCommit =
| { action: 'ignore' }
| { action: 'clear' }
| {
action: 'apply'
sourceUrl: string
sourceRef: Scene3DSourceRef
media: Scene3DSlotMedia
clip: null
}

export function isTransientSourceUrl(url: string): boolean {
return url.startsWith('blob:') || url.startsWith('filesystem:')
}

export function parseScene3DSourceRef(raw: unknown): Scene3DSourceRef | undefined {
if (!raw || typeof raw !== 'object') return undefined
const value = raw as Partial<Scene3DSourceRef>
if (typeof value.workspaceId !== 'string' || !value.workspaceId) return undefined
if (typeof value.filename !== 'string' || !value.filename) return undefined
if (typeof value.url !== 'string' || !value.url || isTransientSourceUrl(value.url)) return undefined
const assetId = typeof value.assetId === 'string' && value.assetId ? value.assetId : undefined
return { workspaceId: value.workspaceId, filename: value.filename, url: value.url, assetId }
}

export function durableScene3DSourceUrl(url: string): string {
return typeof url === 'string' && url && !isTransientSourceUrl(url) ? url : ''
}

export function sourceRefFromOutput(item: ApiOutput, workspaceId: string): Scene3DSourceRef {
const extra = item as ApiOutput & { id?: string }
return {
workspaceId,
filename: item.name,
url: item.url,
assetId: typeof extra.id === 'string' && extra.id ? extra.id : undefined,
}
}

export function pickerOutputFromSlot(sourceUrl: string, media: Scene3DSlotMedia, sourceRef?: Scene3DSourceRef): ApiOutput | undefined {
const url = durableScene3DSourceUrl(sourceUrl)
if (!url) return undefined
return {
name: sourceRef?.filename || url.split('/').pop() || url,
type: media === 'image' ? 'image' : 'model3d',
mode: null,
size: 0,
created_at: 0,
url,
thumbnail_url: media === 'image' ? url : '',
}
}

export function commitSlotSourceChoice(
live: SlotSourceLive,
capture: SlotSourceCapture,
item: ApiOutput | null,
): SlotSourceCommit {
if (live.exporting) return { action: 'ignore' }
if (live.generation !== capture.generation) return { action: 'ignore' }
if (live.slotId !== capture.slotId) return { action: 'ignore' }
if (live.templateId !== capture.templateId) return { action: 'ignore' }
if (live.workspaceId !== capture.workspaceId) return { action: 'ignore' }
if (!item) return { action: 'clear' }
if (isTransientSourceUrl(item.url) || !item.url || !item.name) return { action: 'ignore' }
const media: Scene3DSlotMedia = item.type === 'image' ? 'image' : 'model3d'
return {
action: 'apply',
sourceUrl: item.url,
sourceRef: sourceRefFromOutput(item, capture.workspaceId),
media,
clip: null,
}
}
2 changes: 1 addition & 1 deletion ui/src/features/scene3d/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ const DRESSING_BY_TEMPLATE: Partial<Record<Scene3DTemplateId, Scene3DDocument['d
export function patchScene3DSlot(
document: Scene3DDocument,
slotId: string,
patch: Partial<Pick<Scene3DSlot, 'position' | 'rotationY' | 'scale' | 'sourceUrl' | 'media' | 'clip' | 'loop'>>,
patch: Partial<Pick<Scene3DSlot, 'position' | 'rotationY' | 'scale' | 'sourceUrl' | 'sourceRef' | 'media' | 'clip' | 'loop'>>,
): Scene3DDocument {
return {
...document,
Expand Down
8 changes: 8 additions & 0 deletions ui/src/features/scene3d/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,21 @@ export type Scene3DLoop = {

export type Scene3DDressing = 'none' | 'street' | 'space' | 'treadmill' | 'cafe' | 'drive-city' | 'drive-coast' | 'drive-tunnel'

export type Scene3DSourceRef = {
workspaceId: string
filename: string
url: string
assetId?: string
}

export type Scene3DSlot = {
id: string
slot: Scene3DSlotId
position: Vec3
rotationY: number
scale: number
sourceUrl: string
sourceRef?: Scene3DSourceRef
media: Scene3DSlotMedia
clip: Scene3DClipRef | null
loop?: Scene3DLoop
Expand Down
Loading
Loading