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
15 changes: 13 additions & 2 deletions ui/e2e/specs/scene-template-review.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,17 @@ async function confirmLibraryScene(dialog: Locator, title: string): Promise<void
await dialog.getByRole('button', { name: 'Open scene', exact: true }).click()
}

async function chooseComposerLibraryFile(page: Page, composer: Locator, filename: string): Promise<void> {
await composer.getByRole('button', { name: 'From HocusPocus' }).click()
const explorer = page.getByRole('dialog').filter({ has: page.getByTestId('asset-explorer') }).last()
await expect(explorer).toBeVisible()
const card = explorer.locator(`button[title="${filename}"]`)
await expect(card).toBeVisible()
await card.click()
await explorer.getByRole('button', { name: 'Choose', exact: true }).click()
await expect(composer.getByText(filename).first()).toBeVisible()
}

test('opens cinema-establishing in the real editor, saves exact scene JSON, and reopens the saved scene', async ({ page }) => {
const session = await prepareReviewPage(page)
const state = reviewRouteState()
Expand Down Expand Up @@ -275,9 +286,9 @@ test('Library template bindings survive the real editor save and reopen without
await page.getByRole('button', { name: 'Templates · create with my Library assets' }).click()
const composer = page.getByRole('dialog', { name: 'Create scene from Library' })
await expect(composer.getByLabel('Visual BPM')).toBeDisabled()
await composer.getByRole('button', { name: 'Select hero(1).svg', exact: true }).click()
await chooseComposerLibraryFile(page, composer, 'hero(1).svg')
await composer.getByRole('button', { name: 'Background (required)', exact: true }).click()
await composer.getByRole('button', { name: 'Select plate(1).svg', exact: true }).click()
await chooseComposerLibraryFile(page, composer, 'plate(1).svg')
await composer.getByRole('checkbox').check()
await composer.getByRole('button', { name: 'Create and open in editor', exact: true }).click()
await expect(composer).toHaveCount(0)
Expand Down
220 changes: 57 additions & 163 deletions ui/src/features/sceneTemplates/TemplateAssetPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,188 +1,82 @@
import { useEffect, useMemo, useState } from 'react'
import { fetchAssets, type AssetCatalogItem } from '../../api/assets'
import { useEffect, useRef, useState } from 'react'
import { fetchAsset, type AssetCatalogItem } from '../../api/assets'
import { AssetInput } from '../asset-picker/AssetInput.tsx'
import { catalogItemToOutput } from '../asset-picker/adapters.ts'
import { useUiTranslation } from '../../i18n'
import { catalogLocation } from './catalogLocation'
import { acceptForSlotKinds, commitTemplateSlotChoice } from './templateSlotPick.ts'

const PAGE_SIZE = 12
const TABS = [
{ id: 'image', labelKey: 'composer.tabImages' },
{ id: 'model3d', labelKey: 'composer.tabGlb' },
] as const
type PickerKind = (typeof TABS)[number]['id']
type PickerKind = 'image' | 'model3d'

export interface TemplateAssetPickerProps {
workspace: string
kinds: readonly PickerKind[]
selectedId?: string
selected?: AssetCatalogItem
onPick: (asset: AssetCatalogItem) => void
disabledReason?: (asset: AssetCatalogItem) => string | undefined
}

function previewUrlFor(asset: AssetCatalogItem, workspace: string): string | null {
if (asset.kind !== 'image') return null
try { return catalogLocation(asset, workspace).url }
catch { return null }
optional?: boolean
onClear?: () => void
}

export function TemplateAssetPicker({
workspace,
kinds,
selectedId,
selected,
onPick,
disabledReason,
optional,
onClear,
}: TemplateAssetPickerProps) {
const { t } = useUiTranslation('scene3d')
const allowedKinds = useMemo(() => new Set(kinds), [kinds])
const [kind, setKind] = useState<PickerKind>(kinds[0] || 'image')
const [search, setSearch] = useState('')
const [offset, setOffset] = useState(0)
const [retry, setRetry] = useState(0)
const [previewErrors, setPreviewErrors] = useState<Record<string, boolean>>({})
const [result, setResult] = useState<{ query: string; assets: AssetCatalogItem[]; total: number; error: string }>({ query: '', assets: [], total: 0, error: '' })

const activeKind = allowedKinds.has(kind) ? kind : (kinds[0] || 'image')
const queryKey = `${workspace}\u0000${activeKind}\u0000${search}\u0000${offset}\u0000${retry}`

const generationRef = useRef(0)
const workspaceRef = useRef(workspace)
const [issue, setIssue] = useState('')
useEffect(() => {
const controller = new AbortController()
let active = true

fetchAssets({
workspace,
kind: activeKind,
search: search.trim() || undefined,
limit: PAGE_SIZE,
offset,
signal: controller.signal,
}).then(result => {
if (!active || controller.signal.aborted) return
setResult({ query: queryKey, assets: result.assets, total: result.total, error: '' })
}).catch(reason => {
if (!active || controller.signal.aborted) return
setResult({ query: queryKey, assets: [], total: 0, error: reason instanceof Error ? reason.message : t('composer.loadAssetsFailed') })
})

return () => {
active = false
controller.abort()
}
}, [activeKind, offset, queryKey, search, t, workspace])
if (workspaceRef.current !== workspace) generationRef.current += 1
workspaceRef.current = workspace
}, [workspace])

const loading = result.query !== queryKey
const error = result.query === queryKey ? result.error : ''
const visibleAssets = result.query === queryKey && !error ? result.assets : []
const total = result.query === queryKey && !error ? result.total : 0
const hasPrevious = offset > 0
const hasNext = offset + PAGE_SIZE < total

const reasonFor = (asset: AssetCatalogItem): string | undefined => {
if (asset.kind !== activeKind) return t('composer.incompatibleKind')
return disabledReason?.(asset)
}
const value = selected ? catalogItemToOutput(selected, workspace) ?? undefined : undefined
const slotId = selectedId || kinds.join('-')

return (
<section aria-label={t('composer.pickerAria')} className="space-y-3 rounded-xl border border-border bg-bg-secondary/60 p-3">
<div className="flex flex-wrap items-end gap-2">
<label className="min-w-48 flex-1 text-[10px] font-medium text-text-secondary">
{t('composer.searchLibrary')}
<input
type="search"
value={search}
onChange={event => { setSearch(event.target.value); setOffset(0) }}
placeholder={t('composer.searchPlaceholder')}
className="mt-1 w-full rounded-md border border-border bg-bg-primary px-2 py-1.5 text-xs text-text-primary outline-none focus:border-accent-blue"
aria-label={t('composer.searchAria')}
/>
</label>
<div role="tablist" aria-label={t('composer.assetType')} className="flex rounded-md border border-border bg-bg-tertiary p-0.5">
{TABS.map(tab => {
const enabled = allowedKinds.has(tab.id)
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={activeKind === tab.id}
disabled={!enabled}
title={enabled ? undefined : t('composer.typeUnavailable')}
onClick={() => { setKind(tab.id); setOffset(0) }}
className={`rounded px-2.5 py-1.5 text-[10px] ${activeKind === tab.id ? 'bg-accent-blue/15 text-accent-blue' : 'text-text-muted hover:bg-bg-hover'} disabled:cursor-not-allowed disabled:opacity-40`}
>
{t(tab.labelKey)}
{!enabled && <span className="ml-1 text-[9px]">{t('composer.unavailableSuffix')}</span>}
</button>
)
})}
</div>
</div>

{loading && <p role="status" className="py-4 text-center text-xs text-text-muted">{t('composer.loadingAssets')}</p>}
{error && !loading && (
<div role="alert" className="flex items-center justify-between gap-2 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-200">
<span>{t('composer.libraryLoadFailed', { error })}</span>
<button type="button" onClick={() => setRetry(value => value + 1)} className="rounded border border-red-300/40 px-2 py-1 text-[10px] hover:bg-red-400/10">{t('composer.retry')}</button>
</div>
)}

{!loading && !error && result.query === queryKey && visibleAssets.length === 0 && (
<p className="py-4 text-center text-xs text-text-muted">{t('composer.noAssets')}</p>
)}

{visibleAssets.length > 0 && (
<ul aria-label={t('composer.assetsKind', { kind: activeKind })} className="grid max-h-80 gap-2 overflow-y-auto sm:grid-cols-2">
{visibleAssets.map(asset => {
const disabled = reasonFor(asset)
const previewUrl = previewUrlFor(asset, workspace)
const previewKey = `${queryKey}|${asset.id}`
const previewFailed = previewErrors[previewKey] === true
return (
<li key={asset.id}>
<button
type="button"
aria-label={t('composer.selectAria', { name: asset.filename })}
aria-pressed={selectedId === asset.id}
disabled={Boolean(disabled)}
title={disabled}
onClick={() => onPick(asset)}
className={`w-full rounded-lg border p-2 text-left transition-colors ${selectedId === asset.id ? 'border-accent-blue bg-accent-blue/10' : 'border-border bg-bg-tertiary hover:border-accent-blue/60'} disabled:cursor-not-allowed disabled:opacity-50`}
>
<div className="flex h-20 items-center justify-center overflow-hidden rounded bg-black/20">
{asset.kind === 'model3d' ? (
<span className="text-[10px] text-text-muted">{t('composer.glbPreview')}</span>
) : previewFailed ? (
<span className="px-2 text-center text-[10px] text-amber-200">{t('composer.previewUnavailable')}</span>
) : previewUrl ? (
<img src={previewUrl} alt={t('composer.previewAria', { name: asset.filename })} className="h-full w-full object-contain" onError={() => setPreviewErrors(current => ({ ...current, [previewKey]: true }))} />
) : (
<span className="px-2 text-center text-[10px] text-text-muted">{t('composer.previewUnavailableWorkspace')}</span>
)}
</div>
<div className="mt-1.5 flex items-start justify-between gap-2">
<span className="min-w-0 truncate text-xs font-medium text-text-primary" title={asset.filename}>{asset.filename}</span>
<span className="shrink-0 text-[9px] text-text-muted">{asset.kind === 'model3d' ? t('composer.kindGlb') : t('composer.kindImage')}</span>
</div>
<span className="mt-1 block text-[9px] text-text-muted">{
asset.metadata_status === 'canonical' ? t('composer.metaCanonical')
: asset.metadata_status === 'missing' ? t('composer.metaMissing')
: asset.metadata_status === 'invalid' ? t('composer.metaInvalid')
: asset.metadata_status === 'unreadable' ? t('composer.metaUnreadable')
: t('composer.metaLegacy')
}</span>
{disabled && <span className="mt-1 block text-[10px] text-amber-200">{disabled}</span>}
</button>
</li>
)
})}
</ul>
)}

<div className="flex items-center justify-between gap-2 text-[10px] text-text-muted">
<span>{total ? t('composer.pageRange', { from: offset + 1, to: Math.min(offset + PAGE_SIZE, total), total }) : t('composer.noResults')}</span>
<div className="flex gap-1">
<button type="button" disabled={!hasPrevious || loading} onClick={() => setOffset(value => Math.max(0, value - PAGE_SIZE))} className="rounded border border-border px-2 py-1 disabled:opacity-40">{t('composer.previous')}</button>
<button type="button" disabled={!hasNext || loading} onClick={() => setOffset(value => value + PAGE_SIZE)} className="rounded border border-border px-2 py-1 disabled:opacity-40">{t('composer.next')}</button>
</div>
</div>
</section>
<div className="space-y-1">
<AssetInput
label={t('composer.searchLibrary')}
placeholder={t('composer.searchPlaceholder')}
items={[]}
value={value}
optional={optional}
accept={acceptForSlotKinds(kinds)}
workspaceId={workspace}
constraints={{ kinds, maxCount: 1, optional: Boolean(optional) }}
onChoose={item => {
const capture = { generation: generationRef.current, workspaceId: workspaceRef.current, slotId }
void commitTemplateSlotChoice(
{ generation: generationRef.current, workspaceId: workspaceRef.current, slotId },
capture,
item,
id => fetchAsset(id),
asset => disabledReason?.(asset),
).then(commit => {
if (commit.action === 'ignore') return
if (commit.action === 'reject') {
setIssue(commit.reasonKey === 'missing-id' ? t('composer.needsCatalogIdentity') : commit.message)
return
}
setIssue('')
if (commit.action === 'clear') {
onClear?.()
return
}
onPick(commit.item)
}).catch(error => {
setIssue(error instanceof Error ? error.message : t('composer.loadAssetsFailed'))
})
}}
/>
{issue && <p role="alert" className="text-[10px] text-amber-200">{issue}</p>}
</div>
)
}
2 changes: 1 addition & 1 deletion ui/src/features/sceneTemplates/TemplateComposerDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ function TemplateComposerForm({ template, workspace, onClose, onApply }: Props &
{selections[item.id] && <><p className="break-all text-[10px] text-text-muted">ID: {selections[item.id]!.id}</p><button type="button" aria-label={t('composer.removeAria', { slot: t(SLOT_COPY[item.id]) })} onClick={() => setSelections(current => ({ ...current, [item.id]: undefined }))} className="mt-1 text-rose-200">{t('composer.remove')}</button></>}
</div>)}</div>
<p className="text-xs">{t('composer.assignHelp', { slot: t(SLOT_COPY[slot.id]) })}</p>
<TemplateAssetPicker key={slot.id} workspace={workspace} kinds={slot.kinds} selectedId={selections[slot.id]?.id} disabledReason={item => catalogBindingIssue(item, workspace, slot)} onPick={item => { setSelections(current => ({ ...current, [slot.id]: item })); setError('') }} />
<TemplateAssetPicker key={slot.id} workspace={workspace} kinds={slot.kinds} selected={selections[slot.id]} selectedId={selections[slot.id]?.id} optional={!slot.required} disabledReason={item => catalogBindingIssue(item, workspace, slot)} onPick={item => { setSelections(current => ({ ...current, [slot.id]: item })); setError('') }} onClear={() => setSelections(current => ({ ...current, [slot.id]: undefined }))} />
<div className="grid gap-3 sm:grid-cols-3">
<label className="text-xs">{t('composer.duration')}<input aria-label={t('composer.duration')} type="number" min={3} max={12} step={1} value={duration} onChange={event => setDuration(Number(event.target.value))} className={inputClass} /></label>
<label className="text-xs">{t('composer.visualBpm')} {!rhythmic && '×'}<input aria-label={t('composer.visualBpm')} disabled={!rhythmic} type="number" min={40} max={220} value={bpm} onChange={event => setBpm(Number(event.target.value))} className={inputClass} /></label>
Expand Down
50 changes: 50 additions & 0 deletions ui/src/features/sceneTemplates/templateSlotPick.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { ApiOutput } from '../../api/outputs'
import type { AssetCatalogItem } from '../../api/assets'

export type TemplateSlotCapture = {
generation: number
workspaceId: string
slotId: string
}

export type TemplateSlotLive = {
generation: number
workspaceId: string
slotId: string
}

export type TemplateSlotCommit =
| { action: 'ignore' }
| { action: 'clear' }
| { action: 'reject'; reasonKey: 'missing-id' }
| { action: 'reject'; reasonKey: 'incompatible'; message: string }
| { action: 'apply'; item: AssetCatalogItem }

export async function commitTemplateSlotChoice(
live: TemplateSlotLive,
capture: TemplateSlotCapture,
item: ApiOutput | null,
loadAsset: (id: string) => Promise<AssetCatalogItem>,
bindingIssue: (asset: AssetCatalogItem) => string | undefined,
): Promise<TemplateSlotCommit> {
if (live.generation !== capture.generation) return { action: 'ignore' }
if (live.workspaceId !== capture.workspaceId) return { action: 'ignore' }
if (live.slotId !== capture.slotId) return { action: 'ignore' }
if (!item) return { action: 'clear' }
const id = typeof item.asset_id === 'string' ? item.asset_id.trim() : ''
if (!id) return { action: 'reject', reasonKey: 'missing-id' }
const asset = await loadAsset(id)
if (live.generation !== capture.generation) return { action: 'ignore' }
if (live.workspaceId !== capture.workspaceId) return { action: 'ignore' }
if (live.slotId !== capture.slotId) return { action: 'ignore' }
const issue = bindingIssue(asset)
if (issue) return { action: 'reject', reasonKey: 'incompatible', message: issue }
return { action: 'apply', item: asset }
}

export function acceptForSlotKinds(kinds: readonly ('image' | 'model3d')[]): string {
const parts: string[] = []
if (kinds.includes('image')) parts.push('image/*')
if (kinds.includes('model3d')) parts.push('.glb,model/gltf-binary')
return parts.join(',')
}
1 change: 1 addition & 0 deletions ui/src/i18n/locales/en/scene3d.json
Original file line number Diff line number Diff line change
Expand Up @@ -1087,6 +1087,7 @@
"retry": "Retry",
"loadAssetsFailed": "Could not load assets.",
"incompatibleKind": "Type incompatible with this filter.",
"needsCatalogIdentity": "This slot needs a Library asset with a durable id. A local file is not enough.",
"pickerAria": "Template asset picker",
"kindImage": "Image",
"kindGlb": "GLB",
Expand Down
1 change: 1 addition & 0 deletions ui/src/i18n/locales/es/scene3d.json
Original file line number Diff line number Diff line change
Expand Up @@ -1087,6 +1087,7 @@
"retry": "Reintentar",
"loadAssetsFailed": "No se pudieron cargar los assets.",
"incompatibleKind": "Tipo incompatible con este filtro.",
"needsCatalogIdentity": "Este hueco necesita un asset de Library con id durable. Un archivo local no basta.",
"pickerAria": "Selector de assets de plantilla",
"kindImage": "Imagen",
"kindGlb": "GLB",
Expand Down
Loading
Loading