From 9fc98f35fafc7b402e53a1af3e1603b4f7b2cc64 Mon Sep 17 00:00:00 2001 From: IAnMove <216241348+IAnMove@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:02:42 +0200 Subject: [PATCH 1/2] feat(assets): play explorer preview on demand Arm video/audio/GLB only in the preview pane, pause and unload on change or close, and never mount 24 players. Play does not confirm. --- docs/development/ASSET_PICKER_MIGRATION.md | 4 +- docs/development/CURRENT_WORK.md | 13 ++- .../components/common/AssetExplorerChrome.tsx | 7 +- .../features/asset-picker/previewPlayer.tsx | 105 +++++++++++++++++ ui/src/i18n/locales/en/common.json | 6 +- ui/src/i18n/locales/es/common.json | 6 +- ui/tests/assetExplorerPreview.test.tsx | 108 ++++++++++++++++++ 7 files changed, 234 insertions(+), 15 deletions(-) create mode 100644 ui/src/features/asset-picker/previewPlayer.tsx create mode 100644 ui/tests/assetExplorerPreview.test.tsx diff --git a/docs/development/ASSET_PICKER_MIGRATION.md b/docs/development/ASSET_PICKER_MIGRATION.md index 3c34e28b..69bbd9a5 100644 --- a/docs/development/ASSET_PICKER_MIGRATION.md +++ b/docs/development/ASSET_PICKER_MIGRATION.md @@ -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 | diff --git a/docs/development/CURRENT_WORK.md b/docs/development/CURRENT_WORK.md index afd70fd2..8f935f92 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` **`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. @@ -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. @@ -44,10 +46,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 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). 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 diff --git a/ui/src/components/common/AssetExplorerChrome.tsx b/ui/src/components/common/AssetExplorerChrome.tsx index 54149cab..48b26f22 100644 --- a/ui/src/components/common/AssetExplorerChrome.tsx +++ b/ui/src/components/common/AssetExplorerChrome.tsx @@ -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'] @@ -177,11 +178,7 @@ export function ExplorerPreview({ return ( <>
- {selected.thumbnailUrl ? ( - {t('explorer.previewAria', - ) : ( - - )} +
{selected.title}
{selected.filename}
diff --git a/ui/src/features/asset-picker/previewPlayer.tsx b/ui/src/features/asset-picker/previewPlayer.tsx new file mode 100644 index 00000000..01aee1a7 --- /dev/null +++ b/ui/src/features/asset-picker/previewPlayer.tsx @@ -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

{t('explorer.loading')}

+ return +} + +export function AssetPreviewPlayer({ item }: { item: PickerItem }) { + const { t } = useUiTranslation('common') + const videoRef = useRef(null) + const audioRef = useRef(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

{t('explorer.previewFailed')}

+ } + if (item.kind === 'image') { + return {t('explorer.previewAria', setFailed(true)} /> + } + if (!armed) { + return ( + + ) + } + if (item.kind === 'video') { + return ( +