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
4 changes: 2 additions & 2 deletions docs/development/ASSET_PICKER_MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,8 +348,8 @@ Un agente es dueño del **núcleo** del picker (PR 1–4). Los demás solo adapt
| 0 (este) | Inventario + contrato | Docs | — |
| 1 | Tipos, catálogo, identidad | Núcleo picker | Mezclado en #207 |
| 2 | Modal, tarjetas, transacción | Núcleo picker | Mezclado en #208 |
| 3 | Preview RAM-safe | Núcleo picker (archivos de preview) | `previewPlayer.tsx`, `AssetExplorerChrome.tsx`. No mezclar con PR 4 |
| 4 | AssetInput dual origin + upload | Núcleo picker | `FileUploadZone` / campo común |
| 3 | Preview RAM-safe | Núcleo picker (archivos de preview) | Mezclado en #210 |
| 4 | AssetInput dual origin + upload | Núcleo picker | `AssetInput.tsx`, `upload.ts` |
| 5 | 2.5D + audio de escena + templates + Scene3D | Compositor 2.5D/3D | `SceneAnimatorPanel` (serializar vs i18n); `Scene3DWorkspace` ya no está reservado (#204 mezclado) |
| 6A | Tools, imagen, Hunyuan, edit | Tools/imagen | No núcleo modal |
| 6B | Audio, vídeo, Video Editor, mixer | Audio/vídeo | Paralelo a 6A si no comparten archivo |
Expand Down
9 changes: 5 additions & 4 deletions docs/development/CURRENT_WORK.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Estado de desarrollo y punto de entrada

Verificado el 7 de septiembre de 2026 contra `origin/development` **`4cce2452`**.
Verificado el 7 de septiembre de 2026 contra `origin/development` **`631c0d47`**.
Es una fotografía con evidencia, no un sustituto de Git. Antes de reservar trabajo:
`git fetch origin development`, consultar PR abiertos y comprobar sus archivos.

Expand Down Expand Up @@ -36,6 +36,7 @@ no autorizan acciones ni representan el estado actual.
| Contrato selector (PR 1) | #207, merge `f1855ab7` | Identidad, sort/paginación y adapters. No es el modal transaccional |
| Modal selector transaccional (PR 2) | #208, merge `059282ed` | Choose/Cancel/None; sin doble clic ni preselección. No es preview real ni dual origin |
| Set café vídeo 3D | #209 | Decorado texturizado; no es el picker |
| Preview selector (PR 3) | #210, merge `631c0d47` | Un reproductor/visor a demanda. No es el campo de doble origen |

La integración es en **development**. No implica que el servidor local esté usando
esa revisión ni que exista una publicación de aplicación en main.
Expand All @@ -46,9 +47,9 @@ Al cerrar esta revisión el taller de habla (#200), la limpieza documental
(#199) y el contrato attemptId (#201) ya están integrados. Escenas 3D reales
(#198) se mezcló en development el 07/09 (`fae7d3f6`). Estado por dominio:

- **Selector universal de recursos (PR 3)**: preview RAM-safe (un reproductor /
un visor GLB). PR [#210](https://github.com/IAnMove/hocuspocus/pull/210), rama
`feat/asset-picker-preview`. Modal #208 ya integrado. Cursor: no ejecutada
- **Selector universal de recursos (PR 4)**: campo de doble origen
(`AssetInput`). PR [#211](https://github.com/IAnMove/hocuspocus/pull/211), rama
`feat/asset-picker-input`. Preview #210 ya integrado. Cursor: no ejecutada
(cuota). No mezclar hasta que lo pidan.
- **Vídeo procedural**: conservar el checkpoint `work/procedural-video-pilot-checkpoint`;
consultar [PROCEDURAL_VIDEO_ROADMAP](PROCEDURAL_VIDEO_ROADMAP.md) y el documento del
Expand Down
99 changes: 99 additions & 0 deletions ui/src/features/asset-picker/AssetInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { useEffect, useRef, useState } from 'react'
import { FolderOpen, Monitor, X } from 'lucide-react'
import type { ApiOutput } from '../../api/outputs'
import { useUiTranslation } from '../../i18n'
import { AssetExplorerDialog, AssetPickTrigger } from '../../components/common/AssetExplorerDialog'
import type { AssetConstraints } from './types.ts'
import { createUploadSession } from './upload.ts'

export function AssetInput({
label,
placeholder,
items,
value,
accept,
optional,
constraints,
disabled,
onChoose,
}: {
label: string
placeholder: string
items: ApiOutput[]
value?: ApiOutput
accept?: string
optional?: boolean
constraints?: AssetConstraints
disabled?: boolean
onChoose: (item: ApiOutput | null) => void
}) {
const { t } = useUiTranslation('common')
const fileRef = useRef<HTMLInputElement>(null)
const upload = useRef(createUploadSession())
const [open, setOpen] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
useEffect(() => () => upload.current.abort(), [])

const pickLocal = async (file: File | undefined) => {
if (!file) return
setError('')
setBusy(true)
try {
const uploaded = await upload.current.run(file)
onChoose({
name: uploaded.filename,
type: uploaded.kind === 'model3d' ? 'model3d' : uploaded.kind === 'audio' ? 'audio' : uploaded.kind === 'video' ? 'video' : 'image',
mode: null,
size: file.size,
created_at: Date.now() / 1000,
url: uploaded.url,
thumbnail_url: uploaded.kind === 'image' ? uploaded.url : '',
})
} catch (caught) {
if (caught instanceof DOMException && caught.name === 'AbortError') return
setError(t('picker.uploadFailed'))
} finally {
setBusy(false)
if (fileRef.current) fileRef.current.value = ''
}
}

return (
<div className="space-y-1" onDrop={event => { event.preventDefault(); if (!disabled && !busy) void pickLocal(event.dataTransfer.files[0]) }} onDragOver={event => event.preventDefault()}>
<AssetPickTrigger label={label} selected={value} placeholder={busy ? t('picker.uploading') : placeholder} disabled={disabled || busy} onOpen={() => setOpen(true)} />
<div className="flex flex-wrap gap-1">
<button type="button" disabled={disabled || busy} onClick={() => fileRef.current?.click()} className="inline-flex items-center gap-1 rounded border border-border px-1.5 py-0.5 text-[9px] text-text-secondary disabled:opacity-40">
<Monitor size={10} />{t('picker.fromDevice')}
</button>
<button type="button" disabled={disabled || busy} onClick={() => setOpen(true)} className="inline-flex items-center gap-1 rounded border border-border px-1.5 py-0.5 text-[9px] text-text-secondary disabled:opacity-40">
<FolderOpen size={10} />{t('picker.fromLibrary')}
</button>
{optional && value && (
<button type="button" disabled={disabled || busy} onClick={() => onChoose(null)} className="inline-flex items-center gap-1 rounded border border-border px-1.5 py-0.5 text-[9px] text-text-secondary disabled:opacity-40">
<X size={10} />{t('picker.remove')}
</button>
)}
</div>
{error && <p className="text-[9px] text-red-300">{error}</p>}
<input
ref={fileRef}
type="file"
accept={accept}
className="hidden"
data-testid="asset-input-file"
onChange={event => { void pickLocal(event.target.files?.[0]) }}
/>
<AssetExplorerDialog
open={open}
title={label}
items={items}
selectedName={value?.name}
allowNone={optional}
constraints={constraints}
onClose={() => setOpen(false)}
onChoose={item => { onChoose(item); setOpen(false) }}
/>
</div>
)
}
2 changes: 2 additions & 0 deletions ui/src/features/asset-picker/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export { AssetInput } from './AssetInput.tsx'
export { catalogItemToPickerItem, checkCompatibility, outputToPickerItem, resolveCatalogMatch } from './adapters.ts'
export { createUploadSession, inferUploadKind, uploadLocalAsset } from './upload.ts'
export { filterPickerItems, paginatePickerItems, sortPickerItems } from './localQuery.ts'
export { createCatalogQuerySession, queryAssetCatalog, resolveAssetRef } from './query.ts'
export { displayAssetTitle, formatCreatedDate, formatUnknownDate, knownCreatedAt } from './titles.ts'
Expand Down
54 changes: 54 additions & 0 deletions ui/src/features/asset-picker/upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { uploadAudio } from '../../api/director'
import { uploadImage } from '../../api/generation'
import type { AssetKind } from '../../api/assets'

export type LocalUploadResult = {
filename: string
url: string
kind: AssetKind
}

const AUDIO_TYPES = new Set(['audio/mpeg', 'audio/wav', 'audio/x-wav', 'audio/mp4', 'audio/ogg', 'audio/flac', 'audio/webm'])
const VIDEO_TYPES = new Set(['video/mp4', 'video/webm', 'video/quicktime'])
const MODEL_EXT = /\.(glb|gltf|obj|usdz|stl|ply)$/i

export function inferUploadKind(file: File): AssetKind {
if (file.type.startsWith('image/')) return 'image'
if (AUDIO_TYPES.has(file.type) || file.type.startsWith('audio/')) return 'audio'
if (VIDEO_TYPES.has(file.type) || file.type.startsWith('video/')) return 'video'
if (MODEL_EXT.test(file.name)) return 'model3d'
return 'other'
}

export async function uploadLocalAsset(file: File, signal?: AbortSignal): Promise<LocalUploadResult> {
if (signal?.aborted) throw new DOMException('The operation was aborted.', 'AbortError')
const kind = inferUploadKind(file)
const uploaded = kind === 'audio' ? await uploadAudio(file) : await uploadImage(file)
if (signal?.aborted) throw new DOMException('The operation was aborted.', 'AbortError')
return {
filename: uploaded.filename,
url: uploaded.url,
kind,
}
}

export function createUploadSession() {
let controller: AbortController | null = null
return {
abort() { controller?.abort() },
async run(file: File) {
controller?.abort()
controller = new AbortController()
const signal = controller.signal
try {
return await uploadLocalAsset(file, signal)
} catch (error) {
if (signal.aborted) {
const abort = new DOMException('The operation was aborted.', 'AbortError')
throw abort
}
throw error
}
},
}
}
7 changes: 6 additions & 1 deletion ui/src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,12 @@
"titleWithDate": "{{type}} · {{date}}",
"titleUnknownDate": "{{type}} · Unknown date",
"incompatibleKind": "This asset type is not allowed here.",
"tooMany": "This field cannot accept another asset."
"tooMany": "This field cannot accept another asset.",
"fromDevice": "From my computer",
"fromLibrary": "From HocusPocus",
"remove": "Remove",
"uploading": "Uploading…",
"uploadFailed": "The file could not be uploaded."
},
"welcome": {
"title": "What’s new in HocusPocus",
Expand Down
7 changes: 6 additions & 1 deletion ui/src/i18n/locales/es/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,12 @@
"titleWithDate": "{{type}} · {{date}}",
"titleUnknownDate": "{{type}} · Fecha desconocida",
"incompatibleKind": "Este tipo de recurso no está permitido aquí.",
"tooMany": "Este campo no admite otro recurso."
"tooMany": "Este campo no admite otro recurso.",
"fromDevice": "Desde mi equipo",
"fromLibrary": "Desde HocusPocus",
"remove": "Quitar",
"uploading": "Subiendo…",
"uploadFailed": "No se pudo subir el archivo."
},
"welcome": {
"title": "Novedades de HocusPocus",
Expand Down
105 changes: 105 additions & 0 deletions ui/tests/assetInput.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import React from 'react'
import { JSDOM } from 'jsdom'

Object.assign(globalThis, { React })

const dom = new JSDOM('<!doctype html><html><body /></html>', { url: 'http://localhost/' })
Object.assign(globalThis, {
window: dom.window,
document: dom.window.document,
HTMLElement: dom.window.HTMLElement,
HTMLButtonElement: dom.window.HTMLButtonElement,
HTMLInputElement: dom.window.HTMLInputElement,
Event: dom.window.Event,
MouseEvent: dom.window.MouseEvent,
MutationObserver: dom.window.MutationObserver,
ResizeObserver: class { observe() {} disconnect() {} },
})
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: dom.window.navigator })

test('inferUploadKind maps image audio video and glb', async () => {
const { inferUploadKind } = await import('../src/features/asset-picker/upload.ts')
assert.equal(inferUploadKind(new File(['x'], 'a.png', { type: 'image/png' })), 'image')
assert.equal(inferUploadKind(new File(['x'], 'a.mp3', { type: 'audio/mpeg' })), 'audio')
assert.equal(inferUploadKind(new File(['x'], 'a.mp4', { type: 'video/mp4' })), 'video')
assert.equal(inferUploadKind(new File(['x'], 'hero.glb', { type: 'model/gltf-binary' })), 'model3d')
})

test('native file cancel does not change the field', { concurrency: false }, async () => {
const { render, screen, fireEvent, cleanup } = await import('@testing-library/react')
const { AssetInput } = await import('../src/features/asset-picker/AssetInput.tsx')
const chosen: Array<string | null> = []
try {
render(
<AssetInput
label="Hero"
placeholder="Choose"
items={[]}
onChoose={item => { chosen.push(item ? item.name : null) }}
/>,
)
fireEvent.change(screen.getByTestId('asset-input-file'), { target: { files: [] } })
assert.deepEqual(chosen, [])
assert.equal(screen.queryByTestId('asset-explorer'), null)
} finally {
cleanup()
}
})

test('From HocusPocus opens the shared explorer and Remove clears', { concurrency: false }, async () => {
const { render, screen, fireEvent, cleanup } = await import('@testing-library/react')
const { AssetInput } = await import('../src/features/asset-picker/AssetInput.tsx')
const chosen: Array<string | null> = []
const current = {
name: 'hero.png', type: 'image' as const, mode: null, size: 2, created_at: 1,
url: '/api/v1/file/hero.png', thumbnail_url: '/api/v1/file/hero.png',
}
try {
render(
<AssetInput
label="Hero"
placeholder="Choose"
items={[current]}
value={current}
optional
onChoose={item => { chosen.push(item ? item.name : null) }}
/>,
)
fireEvent.click(screen.getByRole('button', { name: /From HocusPocus/ }))
assert.ok(screen.getByTestId('asset-explorer'))
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
assert.deepEqual(chosen, [])
fireEvent.click(screen.getByRole('button', { name: /Remove/ }))
assert.deepEqual(chosen, [null])
} finally {
cleanup()
}
})

test('failed upload after the field closed does not apply a value', { concurrency: false }, async () => {
const { render, screen, fireEvent, cleanup } = await import('@testing-library/react')
const { AssetInput } = await import('../src/features/asset-picker/AssetInput.tsx')
const originalFetch = globalThis.fetch
globalThis.fetch = (async () => new Response('nope', { status: 500 })) as typeof fetch
const chosen: Array<string | null> = []
try {
const view = render(
<AssetInput
label="Hero"
placeholder="Choose"
items={[]}
onChoose={item => { chosen.push(item ? item.name : null) }}
/>,
)
const file = new File(['x'], 'late.png', { type: 'image/png' })
fireEvent.change(screen.getByTestId('asset-input-file'), { target: { files: [file] } })
view.unmount()
await new Promise(resolve => setTimeout(resolve, 20))
assert.deepEqual(chosen, [])
} finally {
globalThis.fetch = originalFetch
cleanup()
}
})
Loading