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 @@ -347,8 +347,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 | `AssetExplorerDialog`, `ModalShell`. Conservar wrapper `ApiOutput` para consumidores de #198 |
| 3 | Preview RAM-safe | Núcleo picker (archivos de preview) | No mezclar con PR 4 |
| 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 |
| 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 |
Expand Down
14 changes: 8 additions & 6 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` **`f1855ab7`**.
Verificado el 7 de septiembre de 2026 contra `origin/development` **`4cce2452`**.
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 @@ -29,11 +29,13 @@ no autorizan acciones ni representan el estado actual.
| Inspección GLB y parches faciales | #190, #193, #195 | Router de inspección de #195 todavía sin montar; parches tienen límites de piloto |
| Taller de habla 2D (preparación manual) | #200 | Panel, borrador, recarga y e2e simulado. El test del panel evita aserciones HTMLElement-vs-null (~260 MiB RSS). No es validación artística de un personaje hablando ni cierra R2–R4 |
| Series attemptId vs número de plano | #201 | `attempt_id` en un único shot selecciona esa toma histórica. `shot_numbers: [2]` sin `attempt_id` sigue siendo el último eligible del plano 2. No cubre móvil real ni GPU |
| Escenas 3D reales (editor + AssetExplorer inicial) | #198, merge `fae7d3f6` | No es el selector universal. Conserva doble clic, preselección del primero e identidad por `name` |
| Escenas 3D reales (editor + AssetExplorer inicial) | #198, merge `fae7d3f6` | Explorador inicial; el contrato transaccional está en #208 |
| Plantillas musicales vídeo 3D | #204 | Cámara `side` y plantillas de videoclip; `Scene3DWorkspace` ya no está reservado por un PR abierto |
| Copy i18n de Vídeo 3D / compositor | #205 | No cubre todo el chrome restante del laboratorio de plantillas |
| Inventario selector universal | #206 | Contrato e inventario; no implementa el picker |
| 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 |

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 @@ -44,10 +46,10 @@ 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 2)**: modal común transaccional sobre
`AssetExplorerDialog`. PR [#208](https://github.com/IAnMove/hocuspocus/pull/208),
rama `feat/asset-picker-modal`. Contrato #207 ya integrado. Cursor: no
ejecutada (cuota). No mezclar hasta que lo pidan.
- **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
(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
subdominio asignado. No mezclar el checkpoint en bloque ni asumir que todo su
Expand Down
7 changes: 2 additions & 5 deletions ui/src/components/common/AssetExplorerChrome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type Compatibility,
type PickerItem,
} from '../../features/asset-picker'
import { AssetPreviewPlayer } from '../../features/asset-picker/previewPlayer.tsx'
import { useUiTranslation } from '../../i18n'

const EXPLORER_SORTS: CatalogSort[] = ['created_desc', 'created_asc', 'name_asc', 'name_desc']
Expand Down Expand Up @@ -177,11 +178,7 @@ export function ExplorerPreview({
return (
<>
<div className="flex aspect-video items-center justify-center overflow-hidden rounded bg-black/50">
{selected.thumbnailUrl ? (
<img src={selected.thumbnailUrl} alt={t('explorer.previewAria', { name: selected.filename })} className="h-full w-full object-contain" />
) : (
<KindGlyph kind={selected.kind} size={36} />
)}
<AssetPreviewPlayer item={selected} />
</div>
<div className="mt-2 break-all text-[11px] font-medium text-text-primary" title={selected.filename}>{selected.title}</div>
<div className="mt-0.5 break-all text-[9px] text-text-muted">{selected.filename}</div>
Expand Down
105 changes: 105 additions & 0 deletions ui/src/features/asset-picker/previewPlayer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { useEffect, useRef, useState } from 'react'
import { Play } from 'lucide-react'
import { useUiTranslation } from '../../i18n'
import type { PickerItem } from './types.ts'

const PREVIEW_BYTE_LIMIT = 80 * 1024 * 1024

function pauseMedia(element: HTMLMediaElement | null) {
if (!element) return
try { element.pause() } catch { /* jsdom does not implement media playback */ }
element.removeAttribute('src')
try { element.load() } catch { /* jsdom does not implement media playback */ }
}

function GlbPreview({ url }: { url: string }) {
const { t } = useUiTranslation('common')
const [ready, setReady] = useState(false)
useEffect(() => {
let cancelled = false
void import('@google/model-viewer').then(() => {
if (!cancelled) setReady(true)
}).catch(() => {
if (!cancelled) setReady(false)
})
return () => { cancelled = true }
}, [url])
if (!ready) return <p className="p-2 text-center text-[10px] text-text-muted">{t('explorer.loading')}</p>
return <model-viewer src={url} camera-controls className="h-full w-full" />
}

export function AssetPreviewPlayer({ item }: { item: PickerItem }) {
const { t } = useUiTranslation('common')
const videoRef = useRef<HTMLVideoElement>(null)
const audioRef = useRef<HTMLAudioElement>(null)
const [armedUrl, setArmedUrl] = useState('')
const [failed, setFailed] = useState(false)
const armed = armedUrl === item.url
const large = item.sizeBytes > PREVIEW_BYTE_LIMIT

useEffect(() => {
const video = videoRef.current
const audio = audioRef.current
return () => {
pauseMedia(video)
pauseMedia(audio)
}
}, [item.url, armed])

if (failed) {
return <p className="p-2 text-center text-[10px] text-text-muted">{t('explorer.previewFailed')}</p>
}
if (item.kind === 'image') {
return <img src={item.url} alt={t('explorer.previewAria', { name: item.filename })} className="h-full w-full object-contain" onError={() => setFailed(true)} />
}
if (!armed) {
return (
<button
type="button"
data-testid="asset-preview-arm"
aria-label={item.kind === 'model3d' ? t('explorer.view3d') : t('explorer.playPreview')}
onClick={() => { setFailed(false); setArmedUrl(item.url) }}
className="relative flex h-full w-full items-center justify-center"
>
{item.thumbnailUrl ? <img src={item.thumbnailUrl} alt="" className="absolute inset-0 h-full w-full object-contain opacity-70" /> : null}
<span className="relative rounded-full bg-black/70 px-2 py-1 text-[10px] text-white">
{large ? t('explorer.tooLarge') : item.kind === 'model3d' ? t('explorer.view3d') : t('explorer.playPreview')}
</span>
{item.kind !== 'model3d' && <Play size={16} className="relative ml-1 text-white" />}
</button>
)
}
if (item.kind === 'video') {
return (
<video
ref={videoRef}
data-testid="asset-preview-video"
src={item.url}
controls
playsInline
preload="metadata"
className="h-full w-full object-contain"
onError={() => setFailed(true)}
/>
)
}
if (item.kind === 'audio') {
return (
<div className="flex h-full w-full items-center justify-center px-2">
<audio
ref={audioRef}
data-testid="asset-preview-audio"
src={item.url}
controls
preload="metadata"
className="w-full"
onError={() => setFailed(true)}
/>
</div>
)
}
if (item.kind === 'model3d') {
return <GlbPreview url={item.url} />
}
return <p className="p-2 text-center text-[10px] text-text-muted">{t('explorer.selectHint')}</p>
}
6 changes: 5 additions & 1 deletion ui/src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,11 @@
"loadFailed": "Could not load assets.",
"retry": "Retry",
"noResults": "No assets match this search.",
"filteredHidden": "The selected asset is hidden by the current filter."
"filteredHidden": "The selected asset is hidden by the current filter.",
"playPreview": "Play",
"view3d": "3D view",
"previewFailed": "This preview cannot be played.",
"tooLarge": "Large file — load preview?"
},
"picker": {
"unknownDate": "Unknown date",
Expand Down
6 changes: 5 additions & 1 deletion ui/src/i18n/locales/es/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,11 @@
"loadFailed": "No se pudieron cargar los recursos.",
"retry": "Reintentar",
"noResults": "Ningún recurso coincide con esta búsqueda.",
"filteredHidden": "El recurso seleccionado está oculto por el filtro actual."
"filteredHidden": "El recurso seleccionado está oculto por el filtro actual.",
"playPreview": "Reproducir",
"view3d": "Vista 3D",
"previewFailed": "No se puede reproducir esta vista previa.",
"tooLarge": "Archivo grande — ¿cargar vista previa?"
},
"picker": {
"unknownDate": "Fecha desconocida",
Expand Down
108 changes: 108 additions & 0 deletions ui/tests/assetExplorerPreview.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
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,
HTMLMediaElement: dom.window.HTMLMediaElement,
HTMLVideoElement: dom.window.HTMLVideoElement,
HTMLAudioElement: dom.window.HTMLAudioElement,
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 })
if (globalThis.HTMLMediaElement?.prototype) {
globalThis.HTMLMediaElement.prototype.pause = function pause() {}
globalThis.HTMLMediaElement.prototype.load = function load() {}
}

function videoItem(name: string) {
return {
name, type: 'video' as const, mode: null, size: 12, created_at: 1_700_000_000,
url: `/api/v1/file/${name}`, thumbnail_url: `/api/v1/file/${name}.png`,
}
}

test('preview does not mount a video until Play, and switching items pauses it', { concurrency: false }, async () => {
const { render, screen, fireEvent, cleanup } = await import('@testing-library/react')
const { outputToPickerItem } = await import('../src/features/asset-picker/index.ts')
const { AssetPreviewPlayer } = await import('../src/features/asset-picker/previewPlayer.tsx')
const paused: string[] = []
const proto = globalThis.HTMLMediaElement?.prototype
const originalPause = proto?.pause
const originalLoad = proto?.load
if (proto) {
proto.pause = function pause() { paused.push(this.getAttribute('src') || this.src || '') }
proto.load = function load() {}
}
const first = outputToPickerItem(videoItem('clip-a.mp4'), 'default')
const second = outputToPickerItem(videoItem('clip-b.mp4'), 'default')
try {
const view = render(<AssetPreviewPlayer item={first} />)
assert.equal(screen.queryByTestId('asset-preview-video'), null)
fireEvent.click(screen.getByTestId('asset-preview-arm'))
assert.ok(screen.getByTestId('asset-preview-video'))
view.rerender(<AssetPreviewPlayer item={second} />)
assert.ok(paused.length >= 1)
assert.equal(screen.queryByTestId('asset-preview-video'), null)
} finally {
if (proto && originalPause) proto.pause = originalPause
if (proto && originalLoad) proto.load = originalLoad
cleanup()
}
})

test('unmounting the preview pauses media', { concurrency: false }, async () => {
const { render, screen, fireEvent, cleanup } = await import('@testing-library/react')
const { outputToPickerItem } = await import('../src/features/asset-picker/index.ts')
const { AssetPreviewPlayer } = await import('../src/features/asset-picker/previewPlayer.tsx')
const paused: string[] = []
const proto = globalThis.HTMLMediaElement?.prototype
const originalPause = proto?.pause
const originalLoad = proto?.load
if (proto) {
proto.pause = function pause() { paused.push('paused') }
proto.load = function load() {}
}
try {
render(<AssetPreviewPlayer item={outputToPickerItem(videoItem('clip.mp4'), 'default')} />)
fireEvent.click(screen.getByTestId('asset-preview-arm'))
cleanup()
assert.ok(paused.includes('paused'))
} finally {
if (proto && originalPause) proto.pause = originalPause
if (proto && originalLoad) proto.load = originalLoad
}
})

test('Play in the explorer preview does not confirm the choice', { concurrency: false }, async () => {
const { render, screen, fireEvent, cleanup } = await import('@testing-library/react')
const { AssetExplorerDialog } = await import('../src/components/common/AssetExplorerDialog.tsx')
const chosen: string[] = []
try {
render(
<AssetExplorerDialog
open
title="Choose media"
items={[videoItem('plate.mp4')]}
selectedName="plate.mp4"
onClose={() => undefined}
onChoose={item => { if (item) chosen.push(item.name) }}
/>,
)
fireEvent.click(screen.getByTestId('asset-preview-arm'))
assert.deepEqual(chosen, [])
assert.ok(screen.getByTestId('asset-preview-video'))
} finally {
cleanup()
}
})
Loading