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 @@ -349,8 +349,8 @@ Un agente es dueño del **núcleo** del picker (PR 1–4). Los demás solo adapt
| 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) | 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) |
| 4 | AssetInput dual origin + upload | Núcleo picker | Mezclado en #211 |
| 5 | 2.5D + audio de escena + templates + Scene3D | Compositor 2.5D/3D | `SceneAnimatorExplorer` / recetas. **Scene3DWorkspace reservado por #212** |
| 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 |
| 7 | Story, Series, personajes, cómics, Director | Labs | Dividir 7A/7B si el diff crece |
Expand Down
10 changes: 6 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` **`631c0d47`**.
Verificado el 7 de septiembre de 2026 contra `origin/development` **`1e6cb636`**.
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 @@ -37,6 +37,7 @@ no autorizan acciones ni representan el estado actual.
| 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 |
| Campo doble origen (PR 4) | #211, merge `1e6cb636` | `AssetInput`. No migra todos los consumidores |

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 @@ -47,9 +48,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 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
- **Selector universal de recursos (PR 5)**: migración 2.5D/recetas a
`AssetInput`. PR [#213](https://github.com/IAnMove/hocuspocus/pull/213), rama
`feat/asset-picker-scene-fields`. `Scene3DWorkspace` queda para después de
[#212](https://github.com/IAnMove/hocuspocus/pull/212). 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
83 changes: 76 additions & 7 deletions ui/src/components/Sidebar/SceneAnimatorExplorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
type NarrativeSceneTemplate,
type NarrativeTemplateInput,
} from '../../lib/sceneNarrative'
import { AssetExplorerDialog, AssetPickTrigger } from '../common/AssetExplorerDialog'
import { AssetExplorerDialog } from '../common/AssetExplorerDialog'
import { AssetInput } from '../../features/asset-picker/AssetInput.tsx'
import type { AssetKind } from '../../api/assets'
import {
applyExplorerChoice,
assetsForExplorer,
Expand Down Expand Up @@ -55,6 +57,67 @@ export function SceneAnimatorExplorer({
)
}

function NarrativeAssetField({
label,
placeholder,
items,
selectedName,
optional,
disabled,
kinds,
accept,
onName,
}: {
label: string
placeholder: string
items: ApiOutput[]
selectedName: string
optional?: boolean
disabled: boolean
kinds: readonly AssetKind[]
accept?: string
onName: (name: string) => void
}) {
return (
<AssetInput
label={label}
placeholder={placeholder}
items={items}
value={items.find(asset => asset.name === selectedName)}
optional={optional}
disabled={disabled}
accept={accept}
constraints={{ kinds, maxCount: 1, optional: Boolean(optional) }}
onChoose={item => onName(item?.name ?? '')}
/>
)
}

export function SceneAnimatorAudioInput({
audio,
disabled,
onAttach,
}: {
audio: ApiOutput[]
disabled: boolean
onAttach: (filename: string, title: string, kind: 'audio') => void
}) {
const { t } = useUiTranslation('scene3d')
return (
<AssetInput
label={t('animator.attachOutput')}
placeholder={t('animator.chooseAudio')}
items={audio}
disabled={disabled}
accept="audio/*"
constraints={{ kinds: ['audio'], maxCount: 1, optional: true }}
onChoose={item => {
if (item) onAttach(item.name, item.name.replace(/\.[^.]+$/, ''), 'audio')
}}
/>
)
}

export function SceneAnimatorNarrativeSetup({
busy,
templateId,
Expand All @@ -74,7 +137,10 @@ export function SceneAnimatorNarrativeSetup({
voiceSpace,
suitability,
onTemplateId,
onOpenExplorer,
onHero,
onPlate,
onProp,
onForeground,
onPlateLoopReady,
onMood,
onIntensity,
Expand Down Expand Up @@ -102,7 +168,10 @@ export function SceneAnimatorNarrativeSetup({
voiceSpace: NonNullable<NarrativeTemplateInput['controls']>['voiceSpace']
suitability: (role: NarrativeAssetRole, name: string) => AssetSuitability
onTemplateId: (id: NarrativeSceneId) => void
onOpenExplorer: (purpose: AssetExplorerPurpose) => void
onHero: (name: string) => void
onPlate: (name: string) => void
onProp: (name: string) => void
onForeground: (name: string) => void
onPlateLoopReady: (ready: boolean) => void
onMood: (value: NonNullable<NarrativeTemplateInput['controls']>['mood']) => void
onIntensity: (value: 1 | 2 | 3) => void
Expand All @@ -125,13 +194,13 @@ export function SceneAnimatorNarrativeSetup({
</select>
<div className="grid grid-cols-2 gap-1">{NARRATIVE_SCENE_TEMPLATES.map(item => <button key={item.id} type="button" disabled={busy} onClick={() => onTemplateId(item.id)} title={item.description} className={`rounded border p-1 text-left disabled:opacity-40 ${templateId === item.id ? 'border-fuchsia-300/70 bg-fuchsia-400/15 text-fuchsia-100' : 'border-border bg-bg-primary text-text-secondary hover:border-fuchsia-300/40'}`}><span className="block truncate text-[8px] font-medium">{item.experimental ? t('animator.experimental') : ''}{item.title}</span><span className="block text-[7px] text-text-muted">{t('animator.templateMeta', { duration: item.defaultDuration, count: item.assetSlots.filter(slot => slot.required).length })}</span></button>)}</div>
<p className="text-[8px] leading-relaxed text-text-muted">{template.description}</p>
<AssetPickTrigger label={t('animator.character')} selected={visuals.find(asset => asset.name === hero)} placeholder={t('animator.chooseAsset')} disabled={busy} onOpen={() => onOpenExplorer('narrative-hero')} />
<NarrativeAssetField label={t('animator.character')} placeholder={t('animator.chooseAsset')} items={visuals} selectedName={hero} disabled={busy} kinds={['image', 'video', 'model3d']} accept="image/*,video/*,.glb" onName={onHero} />
{hero ? <p className={`rounded border px-1.5 py-1 text-[8px] leading-relaxed ${heroFit.level === 'warning' ? 'border-amber-300/25 bg-amber-400/[.06] text-amber-100' : 'border-emerald-300/20 bg-emerald-400/[.04] text-emerald-100'}`}>{heroFit.message}</p> : null}
<AssetPickTrigger label={t('animator.background')} selected={media.find(asset => asset.name === plate)} placeholder={t('animator.chooseAsset')} disabled={busy} onOpen={() => onOpenExplorer('narrative-plate')} />
<NarrativeAssetField label={t('animator.background')} placeholder={t('animator.chooseAsset')} items={media} selectedName={plate} disabled={busy} kinds={['image', 'video']} accept="image/*,video/*" onName={onPlate} />
{plate && plateFit.level !== 'ok' ? <p className="rounded border border-cyan-300/20 bg-cyan-400/[.04] px-1.5 py-1 text-[8px] leading-relaxed text-cyan-100">{plateFit.message}</p> : null}
{plate ? <label className="flex items-start gap-1.5 rounded border border-amber-300/20 bg-amber-400/[.035] p-1.5 text-[8px] leading-relaxed text-amber-100"><input type="checkbox" checked={plateLoopReady} onChange={event => onPlateLoopReady(event.target.checked)} className="mt-0.5" /> <span><strong>{t('animator.loopReady')}</strong><br />{t('animator.loopReadyHelp')}</span></label> : null}
{propSlot ? <AssetPickTrigger label={`${t('animator.objectPortal')}${propSlot.required ? '' : t('animator.optional')}`} selected={visuals.find(asset => asset.name === prop)} placeholder={t('animator.none')} disabled={busy} onOpen={() => onOpenExplorer('narrative-prop')} /> : null}
{foregroundSlot ? <AssetPickTrigger label={t('animator.foreground')} selected={media.find(asset => asset.name === foreground)} placeholder={t('animator.none')} disabled={busy} onOpen={() => onOpenExplorer('narrative-foreground')} /> : null}
{propSlot ? <NarrativeAssetField label={`${t('animator.objectPortal')}${propSlot.required ? '' : t('animator.optional')}`} placeholder={t('animator.none')} items={visuals} selectedName={prop} optional={!propSlot.required} disabled={busy} kinds={['image', 'video', 'model3d']} accept="image/*,video/*,.glb" onName={onProp} /> : null}
{foregroundSlot ? <NarrativeAssetField label={t('animator.foreground')} placeholder={t('animator.none')} items={media} selectedName={foreground} optional disabled={busy} kinds={['image', 'video']} accept="image/*,video/*" onName={onForeground} /> : null}
<div className="grid grid-cols-2 gap-1 text-[9px] text-text-muted">
{template.controls.includes('mood') ? <label>{t('animator.mood')}<select value={mood} onChange={event => onMood(event.target.value as typeof mood)} className="mt-0.5 w-full rounded border border-border bg-bg-primary px-1 py-1 text-[9px]"><option value="calm">{t('animator.moodCalm')}</option><option value="tense">{t('animator.moodTense')}</option><option value="dreamy">{t('animator.moodDreamy')}</option><option value="heroic">{t('animator.moodHeroic')}</option></select></label> : null}
{template.controls.includes('intensity') ? <label>{t('animator.intensity')}<select value={intensity} onChange={event => onIntensity(Number(event.target.value) as 1 | 2 | 3)} className="mt-0.5 w-full rounded border border-border bg-bg-primary px-1 py-1 text-[9px]"><option value={1}>{t('animator.intensityLow')}</option><option value={2}>{t('animator.intensityMedium')}</option><option value={3}>{t('animator.intensityHigh')}</option></select></label> : null}
Expand Down
11 changes: 7 additions & 4 deletions ui/src/components/Sidebar/SceneAnimatorPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import { ArrayBufferTarget, Muxer } from 'mp4-muxer'
import { useUiTranslation } from '../../i18n'
import { useStore } from '../../stores/useStore'
import { analyzeAudio, deleteCharacterKit, fetchCharacterKitLibrary, fetchOutputs, generateLlmText, saveCharacterKit, saveScene as saveSceneOutput, saveSceneRecording, uploadImage } from '../../api/client'
import { AssetPickTrigger } from '../common/AssetExplorerDialog'

import type { AssetExplorerPurpose } from '../common/assetExplorer.ts'
import { SceneAnimatorExplorer, SceneAnimatorNarrativeSetup } from './SceneAnimatorExplorer'
import { SceneAnimatorAudioInput, SceneAnimatorExplorer, SceneAnimatorNarrativeSetup } from './SceneAnimatorExplorer'
import { generateSceneSpeechClip } from '../../lib/sceneSpeech'
import { SceneRecipePanel } from './SceneRecipePanel'
import { TemplateComposerDialog } from '../../features/sceneTemplates/TemplateComposerDialog'
Expand Down Expand Up @@ -3012,7 +3012,10 @@ export function SceneAnimatorPanel() {
voiceSpace={narrativeVoiceSpace}
suitability={narrativeSuitability}
onTemplateId={setNarrativeTemplateId}
onOpenExplorer={setAssetExplorer}
onHero={setNarrativeHero}
onPlate={name => { setNarrativePlate(name); setNarrativePlateLoopReady(false) }}
onProp={setNarrativeProp}
onForeground={setNarrativeForeground}
onPlateLoopReady={setNarrativePlateLoopReady}
onMood={setNarrativeMood}
onIntensity={setNarrativeIntensity}
Expand All @@ -3035,7 +3038,7 @@ export function SceneAnimatorPanel() {
<p className="text-[8px] leading-relaxed text-text-muted">{t('animator.sceneAudioHelp')}</p>
<textarea value={sceneAudioPrompt} disabled={sceneAudioBusy || playing || recording || publishing} onChange={event => setSceneAudioPrompt(event.target.value)} placeholder={t('animator.sceneAudioPlaceholder')} rows={2} className="w-full resize-y rounded border border-border bg-bg-primary px-2 py-1 text-[10px] disabled:opacity-50" />
<button type="button" disabled={!sceneAudioPrompt.trim() || sceneAudioBusy || playing || recording || publishing} onClick={() => void generateSceneSpeech()} className="w-full rounded border border-amber-300/50 bg-amber-400/10 px-2 py-1 text-[10px] text-amber-100 disabled:opacity-40">{sceneAudioBusy ? t('animator.generatingNarration') : t('animator.generateSpeech', { model: selectedSpeechModel })}</button>
{generatedAudio.length > 0 && <AssetPickTrigger label={t('animator.attachOutput')} placeholder={t('animator.chooseAudio')} disabled={playing || recording || publishing} onOpen={() => setAssetExplorer('scene-audio')} />}
{generatedAudio.length > 0 && <SceneAnimatorAudioInput audio={generatedAudio} disabled={playing || recording || publishing} onAttach={(filename, title, kind) => attachSceneAudio(filename, title, kind)} />}
{(scene.audioTracks ?? []).length > 0 && <div className="space-y-1 rounded border border-amber-300/15 bg-black/15 p-1.5">{scene.audioTracks!.map(track => <div key={track.id} className="grid grid-cols-[1fr_44px_44px_18px] items-center gap-1 text-[8px]"><span title={track.prompt ?? track.name} className="truncate text-amber-100">{track.kind} · {track.name}</span><label className="text-text-muted">{t('animator.at')}<input aria-label={t('animator.startTrack', { name: track.name })} type="number" min="0" max={scene.duration} step="0.1" value={track.startTime} onChange={event => { const startTime = Number(event.target.value); if (Number.isFinite(startTime)) updateScene(current => ({ ...current, audioTracks: (current.audioTracks ?? []).map(item => item.id === track.id ? { ...item, startTime: Math.max(0, Math.min(current.duration, startTime)) } : item) })) }} className="mt-0.5 w-full rounded border border-border bg-bg-primary px-1 py-0.5 text-[8px]" /></label><label className="text-text-muted">{t('animator.vol')}<input aria-label={t('animator.volumeTrack', { name: track.name })} type="number" min="0" max="2" step="0.1" value={track.volume} onChange={event => { const volume = Number(event.target.value); if (Number.isFinite(volume)) updateScene(current => ({ ...current, audioTracks: (current.audioTracks ?? []).map(item => item.id === track.id ? { ...item, volume: Math.max(0, Math.min(2, volume)) } : item) })) }} className="mt-0.5 w-full rounded border border-border bg-bg-primary px-1 py-0.5 text-[8px]" /></label><button type="button" title={t('animator.removeTrack', { name: track.name })} onClick={() => updateScene(current => ({ ...current, audioTracks: (current.audioTracks ?? []).filter(item => item.id !== track.id) }))} className="mt-3 text-red-300"><Trash2 size={12} /></button></div>)}</div>}
{rhythmAudioTracks.length > 0 && <div className="space-y-1.5 rounded border border-violet-300/25 bg-violet-400/[.045] p-1.5">
<div className="flex items-center justify-between gap-2"><span className="text-[9px] font-medium text-violet-100">{t('animator.rhythmTitle')}</span><span className="text-[7px] text-violet-200/70">{t('animator.rhythmMeta')}</span></div>
Expand Down
Loading
Loading