diff --git a/docs/development/ASSET_PICKER_MIGRATION.md b/docs/development/ASSET_PICKER_MIGRATION.md index 69bbd9a5..cdcafc9b 100644 --- a/docs/development/ASSET_PICKER_MIGRATION.md +++ b/docs/development/ASSET_PICKER_MIGRATION.md @@ -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 | diff --git a/docs/development/CURRENT_WORK.md b/docs/development/CURRENT_WORK.md index 1f3a0244..bec32be4 100644 --- a/docs/development/CURRENT_WORK.md +++ b/docs/development/CURRENT_WORK.md @@ -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. @@ -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. @@ -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 diff --git a/ui/src/features/asset-picker/AssetInput.tsx b/ui/src/features/asset-picker/AssetInput.tsx new file mode 100644 index 00000000..c82b6d9e --- /dev/null +++ b/ui/src/features/asset-picker/AssetInput.tsx @@ -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(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 ( +
{ event.preventDefault(); if (!disabled && !busy) void pickLocal(event.dataTransfer.files[0]) }} onDragOver={event => event.preventDefault()}> + setOpen(true)} /> +
+ + + {optional && value && ( + + )} +
+ {error &&

{error}

} + { void pickLocal(event.target.files?.[0]) }} + /> + setOpen(false)} + onChoose={item => { onChoose(item); setOpen(false) }} + /> +
+ ) +} diff --git a/ui/src/features/asset-picker/index.ts b/ui/src/features/asset-picker/index.ts index 0a8a4a37..4b2ed2c8 100644 --- a/ui/src/features/asset-picker/index.ts +++ b/ui/src/features/asset-picker/index.ts @@ -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' diff --git a/ui/src/features/asset-picker/upload.ts b/ui/src/features/asset-picker/upload.ts new file mode 100644 index 00000000..5254e4fa --- /dev/null +++ b/ui/src/features/asset-picker/upload.ts @@ -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 { + 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 + } + }, + } +} diff --git a/ui/src/i18n/locales/en/common.json b/ui/src/i18n/locales/en/common.json index e7ba6cb5..0ffe7949 100644 --- a/ui/src/i18n/locales/en/common.json +++ b/ui/src/i18n/locales/en/common.json @@ -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", diff --git a/ui/src/i18n/locales/es/common.json b/ui/src/i18n/locales/es/common.json index 9270c540..59a21ba1 100644 --- a/ui/src/i18n/locales/es/common.json +++ b/ui/src/i18n/locales/es/common.json @@ -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", diff --git a/ui/tests/assetInput.test.tsx b/ui/tests/assetInput.test.tsx new file mode 100644 index 00000000..11048091 --- /dev/null +++ b/ui/tests/assetInput.test.tsx @@ -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('', { 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 = [] + try { + render( + { 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 = [] + 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( + { 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 = [] + try { + const view = render( + { 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() + } +})