From 70ebe11423dccbde5c537934c09a228314ba1372 Mon Sep 17 00:00:00 2001 From: IAnMove <216241348+IAnMove@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:20:52 +0200 Subject: [PATCH 1/2] refactor: move Studio prepare, start, references and LoRAs to the slice prepare_video, prepare_image, prepare_audio, prepare_3d, start_generation, attach_studio_references and configure_studio_loras execute through context.adapters.studio. queue_sfx_pack stays legacy for a follow-up PR. Legacy executors drop from 10 to 3. --- ui/src/features/agent/agentActions.ts | 307 +----------- ui/src/features/agent/applicationAdapters.ts | 74 ++- ui/src/features/agent/audioActions.ts | 71 +-- ui/src/features/agent/studioCapabilities.ts | 92 +--- ui/src/features/agent/studioGuidance.ts | 118 +---- ui/src/features/studio/actions.ts | 465 +++++++++++++++++++ ui/src/features/studio/adapters.ts | 48 +- ui/src/features/studio/commands.ts | 67 +++ ui/tests/agentCapabilityPorts.test.mjs | 11 +- 9 files changed, 692 insertions(+), 561 deletions(-) create mode 100644 ui/src/features/studio/actions.ts diff --git a/ui/src/features/agent/agentActions.ts b/ui/src/features/agent/agentActions.ts index 64cc0d02..6430b5a3 100644 --- a/ui/src/features/agent/agentActions.ts +++ b/ui/src/features/agent/agentActions.ts @@ -1,7 +1,7 @@ import { getModelsForFamily, getFamiliesForMode, useStore } from '../../stores/useStore' import { comicArtworkInventory } from '../comics/generateArtwork' import { buildWizardContextSnapshot, buildWizardLabSnapshots, comicLabSnapshot, type BuildWizardContextOptions, type WizardContextSnapshot } from './wizardContext' -import type { AspectRatio, ModelDef, ResolutionPreset } from '../../types' +import type { AspectRatio, ResolutionPreset } from '../../types' import type { AgentExecutionReport, AgentExecutionTarget } from './agentContract' import type { CommandEnvelope, CommandResult } from './commandContract' import { @@ -2476,273 +2476,12 @@ const TAB_LABELS: Record = { settings: 'Settings', } -function visibleT2vModels(models: ModelDef[]): ModelDef[] { - const enabledModels = useStore.getState().enabledModels - const families = getFamiliesForMode('video', useStore.getState().families) - const familyIds = new Set(families.map(family => family.id)) - const ordered = families.flatMap(family => getModelsForFamily(family.id, models, 'video')) - const orderedIds = new Set(ordered.map(model => model.model_type)) - const extras = models.filter(model => familyIds.has(model.family) && !orderedIds.has(model.model_type)) - return [...ordered, ...extras].filter(model => ( - model.is_t2v - && !model.tool_only - && enabledModels.has(model.model_type) - && model.is_downloaded !== false - )) -} - -async function prepareVideo(action: AgentPrepareVideoAction): Promise { - let state = useStore.getState() - if (!state.modelsLoaded) await state.loadModels() - - state = useStore.getState() - state.setSettingsOpen(false) - state.setDashboardOpen(false) - state.setSidebarMode('studio') - state.setSidebarOpen(true) - state.setGenerationMode('video') - state.setMediaFilter('videos') - - state = useStore.getState() - const candidates = visibleT2vModels(state.models) - const requested = action.modelType - ? candidates.find(model => model.model_type === action.modelType) - : undefined - if (action.modelType && !requested) { - throw new Error(`El modelo ${action.modelType} no está instalado, habilitado o no admite texto a vídeo.`) - } - const current = candidates.find(model => model.model_type === state.params.model_type) - const selected = requested || current || candidates.find(model => model.is_downloaded) || candidates[0] - if (!selected) { - throw new Error('No hay ningún modelo texto-a-vídeo instalado y habilitado.') - } - - if (state.params.model_type !== selected.model_type) state.selectModel(selected.model_type) - await useStore.getState().loadModelOptions(selected.model_type) - - state = useStore.getState() - state.setStartImage(null) - state.setEndImage(null) - state.setPromptSchedulerEnabled(false) - state.setOutputCount(action.outputCount ?? 1) - if (action.aspectRatio) state.setAspectRatio(action.aspectRatio) - if (action.resolutionPreset) state.setResolutionPreset(action.resolutionPreset) - if (action.durationSeconds !== undefined) state.setDurationSeconds(action.durationSeconds) - - const params: Record = { - prompt: action.prompt, - image_mode: 0, - image_start: undefined, - image_end: undefined, - image_prompt_type: '', - minimax_h3_references: undefined, - h3_ref_videos: [], - h3_ref_audios: [], - } - if (action.resolution) params.resolution = action.resolution - if (action.negativePrompt !== undefined) params.negative_prompt = action.negativePrompt - if (action.seed !== undefined) params.seed = action.seed - if (action.inferenceSteps !== undefined) params.num_inference_steps = action.inferenceSteps - if (action.guidanceScale !== undefined) params.guidance_scale = action.guidanceScale - if (action.audioDirection !== undefined) params.h3_audio_prompt = action.audioDirection - if (action.turbo !== undefined) params.minimax_h3_turbo_mode = action.turbo - state.setParams(params) - - return `He preparado Studio → Video con ${selected.name}, ${useStore.getState().durationSeconds.toFixed(1)} s y el prompt indicado.` -} - -/** - * Public bridge for the registered Studio capability. - * - * Keep the actual form mutation here so both the legacy action runner and the - * canonical capability runner exercise exactly the same visible Studio UI. - * The capability module imports this lazily to avoid an agentActions ↔ - * capabilityRegistry initialization cycle. - */ -export async function prepareVideoForAgent(action: AgentPrepareVideoAction): Promise { - return prepareVideo(action) -} - -function visibleImageModels(models: ModelDef[]): ModelDef[] { - const enabledModels = useStore.getState().enabledModels - const families = getFamiliesForMode('image', useStore.getState().families) - const familyIds = new Set(families.map(family => family.id)) - const ordered = families.flatMap(family => getModelsForFamily(family.id, models, 'image')) - const orderedIds = new Set(ordered.map(model => model.model_type)) - const extras = models.filter(model => familyIds.has(model.family) && !orderedIds.has(model.model_type)) - return [...ordered, ...extras].filter(model => ( - !model.tool_only - && enabledModels.has(model.model_type) - && model.is_downloaded !== false - )) -} - -async function prepareImage(action: AgentPrepareImageAction): Promise { - let state = useStore.getState() - if (!state.modelsLoaded) await state.loadModels() - - state = useStore.getState() - state.setSettingsOpen(false) - state.setDashboardOpen(false) - state.setSidebarMode('studio') - state.setSidebarOpen(true) - state.setGenerationMode('image') - state.setMediaFilter('images') - - state = useStore.getState() - const candidates = visibleImageModels(state.models) - const requested = action.modelType - ? candidates.find(model => model.model_type === action.modelType) - : undefined - if (action.modelType && !requested) { - throw new Error(`El modelo ${action.modelType} no está instalado, habilitado o no admite texto a imagen.`) - } - const current = candidates.find(model => model.model_type === state.params.model_type) - const selected = requested || current || candidates.find(model => model.is_downloaded) || candidates[0] - if (!selected) { - throw new Error('No hay ningún modelo de imagen instalado y habilitado.') - } - - if (state.params.model_type !== selected.model_type) state.selectModel(selected.model_type) - await useStore.getState().loadModelOptions(selected.model_type) - - state = useStore.getState() - state.setStartImage(null) - state.setEndImage(null) - state.setOutputCount(action.outputCount ?? 1) - if (action.aspectRatio) state.setAspectRatio(action.aspectRatio) - if (action.resolutionPreset) state.setResolutionPreset(action.resolutionPreset) - - const params: Record = { - prompt: action.prompt, - image_mode: 1, - video_length: 1, - image_start: undefined, - image_end: undefined, - image_prompt_type: '', - } - if (action.resolution) params.resolution = action.resolution - if (action.negativePrompt !== undefined) params.negative_prompt = action.negativePrompt - if (action.seed !== undefined) params.seed = action.seed - if (action.inferenceSteps !== undefined) params.num_inference_steps = action.inferenceSteps - if (action.guidanceScale !== undefined) params.guidance_scale = action.guidanceScale - state.setParams(params) - - const resolution = useStore.getState().params.resolution || useStore.getState().resolutionPreset - return `He preparado Studio → Image con ${selected.name}, ${resolution} y el prompt indicado.` -} - -/** See prepareVideoForAgent: canonical registry entry point for Studio Image. */ -export async function prepareImageForAgent(action: AgentPrepareImageAction): Promise { - return prepareImage(action) -} - -let prepared3dPreset = 'balanced' - -async function prepare3d(action: AgentPrepare3dAction): Promise { - let state = useStore.getState() - if (!state.modelsLoaded) await state.loadModels() - state = useStore.getState() - state.setSettingsOpen(false) - state.setDashboardOpen(false) - state.setSidebarMode('studio') - state.setSidebarOpen(true) - state.setGenerationMode('model3d') - state.setMediaFilter('model3d') - - state = useStore.getState() - const families = getFamiliesForMode('model3d', state.families) - const candidates = families.flatMap(family => getModelsForFamily(family.id, state.models, 'model3d')) - .filter(model => !model.tool_only) - const requested = action.modelType - ? candidates.find(model => model.model_type === action.modelType) - : undefined - if (action.modelType && !requested) { - throw new Error(`El modelo 3D ${action.modelType} no está disponible.`) - } - const current = candidates.find(model => model.model_type === state.params.model_type) - const selected = requested || current || candidates[0] - if (!selected) throw new Error('No hay ningún modelo Hunyuan3D disponible.') - if (state.params.model_type !== selected.model_type) state.selectModel(selected.model_type) - useStore.getState().setParams({ - prompt: action.prompt, - seed: action.seed ?? 1234, - }) - prepared3dPreset = action.preset || 'balanced' - return `He preparado Studio → 3D con ${selected.name}, preset ${prepared3dPreset} y el prompt indicado. La pestaña 3D solo muestra resultados; la creación queda en Studio.` -} - -/** See prepareVideoForAgent: canonical registry entry point for Studio 3D. */ -export async function prepare3dForAgent(action: AgentPrepare3dAction): Promise { - return prepare3d(action) -} - -async function startPreparedGeneration(): Promise<{ message: string; taskId?: string }> { - const state = useStore.getState() - if (state.generationMode === 'model3d') { - const { startHunyuan3DJob } = await import('../../api/client') - const job = await startHunyuan3DJob({ - operation: 'generate', - model_id: String(state.params.model_type || ''), - prompt: String(state.params.prompt || ''), - workspace: state.activeWorkspace || 'default', - preset: prepared3dPreset, - seed: typeof state.params.seed === 'number' ? state.params.seed : 1234, - }) - if (!job.job_id) throw new Error('Hunyuan3D devolvió éxito sin jobId; no considero la generación encolada.') - return { - taskId: job.job_id, - message: `He enviado el modelo 3D a Hunyuan3D (${job.job_id}). Aparecerá en la galería 3D al terminar.`, - } - } - const before = useStore.getState().jobs - const knownJobs = new Set(before) - await useStore.getState().startGeneration() - const created = useStore.getState().jobs.find(job => !knownJobs.has(job)) - if (!created) throw new Error('HocusPocus no creó una tarea; revisa los requisitos del modelo y los campos visibles.') - if (created.status === 'failed') throw new Error(created.error || created.message || 'La generación no pudo entrar en cola.') - if (!created.id) throw new Error('HocusPocus devolvió éxito sin taskId; no considero la generación encolada.') - const mode = useStore.getState().generationMode - const kind = mode === 'image' ? 'imagen' : mode === 'audio' ? 'pista de audio' : 'vídeo' - return { - taskId: created.id, - message: `He enviado la ${kind} a la cola (${created.id}).`, - } -} - -/** - * Queue the form currently prepared by one of the Studio prepare actions. - * This remains a single bridge so the registered and legacy runners cannot - * drift in how they verify the returned task identity. - */ -export async function startPreparedGenerationForAgent(): Promise<{ message: string; taskId?: string }> { - return startPreparedGeneration() -} - -/** Canonical Studio Audio form bridge; the implementation stays lazy. */ -export async function prepareAudioForAgent(action: AgentPrepareAudioAction): Promise { - const { prepareAudio } = await import('./audioActions') - return prepareAudio(action) -} - /** Canonical Studio SFX-pack bridge; the implementation stays lazy. */ export async function queueSfxPackForAgent(action: AgentQueueSfxPackAction): Promise { const { queueSfxPack } = await import('./audioActions') return queueSfxPack(action) } -/** Canonical Studio references bridge; the implementation stays lazy. */ -export async function attachStudioReferencesForAgent(action: AgentAttachStudioReferencesAction): Promise { - const { attachStudioReferences } = await import('./studioGuidance') - return attachStudioReferences(action) -} - -/** Canonical Studio LoRA bridge; the implementation stays lazy. */ -export async function configureStudioLorasForAgent(action: AgentConfigureStudioLorasAction): Promise { - const { configureStudioLoras } = await import('./studioGuidance') - return configureStudioLoras(action) -} - export async function executeAgentActions( actions: AgentAction[], onStep?: (message: string) => void, @@ -2977,44 +2716,28 @@ export async function executeAgentActions( openAgentSeriesSection(action.section) results.push({ action, ok: true, message: `He abierto Series Lab → ${action.section}.` }) } else if (action.type === 'prepare_video') { - const message = await prepareVideo(action) + const outcome = await defaultApplicationAdapters.studio.prepareVideo(action) preparedStudio = true - results.push({ action, ok: true, message }) + results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'prepare_image') { - const message = await prepareImage(action) + const outcome = await defaultApplicationAdapters.studio.prepareImage(action) preparedStudio = true - results.push({ action, ok: true, message }) + results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'prepare_audio') { - const { prepareAudio } = await import('./audioActions') - const message = await prepareAudio(action) + const outcome = await defaultApplicationAdapters.studio.prepareAudio(action) preparedStudio = true - results.push({ action, ok: true, message }) + results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'prepare_3d') { - const message = await prepare3d(action) + const outcome = await defaultApplicationAdapters.studio.prepare3d(action) preparedStudio = true - results.push({ action, ok: true, message }) + results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'queue_sfx_pack') { const { queueSfxPack } = await import('./audioActions') results.push({ action, ok: true, message: await queueSfxPack(action) }) } else if (action.type === 'start_generation') { if (!preparedStudio) throw new Error('Studio no se preparó en este turno; no lo he lanzado.') - const outcome = await startPreparedGeneration() - results.push({ - action, - ok: true, - message: outcome.message, - report: executionReport({ - state: 'queued', - message: outcome.message, - taskId: outcome.taskId, - recoverable: false, - executionKey: executionKey({ - workspace: useStore.getState().activeWorkspace || 'default', - type: action.type, - params: action, - }), - }), - }) + const outcome = await defaultApplicationAdapters.studio.startGeneration(action) + results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'create_story') { const outcome = await defaultApplicationAdapters.storyLab.create(action) results.push({ action, ok: true, message: outcome.message, report: outcome.report }) @@ -3183,11 +2906,11 @@ export async function executeAgentActions( const outcome = await defaultApplicationAdapters.comic.generatePanel(action.pageNumber, action.panelNumber, onStep) results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'attach_studio_references') { - const { attachStudioReferences } = await import('./studioGuidance') - results.push({ action, ok: true, message: await attachStudioReferences(action) }) + const outcome = await defaultApplicationAdapters.studio.attachReferences(action) + results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'configure_studio_loras') { - const { configureStudioLoras } = await import('./studioGuidance') - results.push({ action, ok: true, message: await configureStudioLoras(action) }) + const outcome = await defaultApplicationAdapters.studio.configureLoras(action) + results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'inspect_queue') { const outcome = await defaultApplicationAdapters.queue.inspect(action.scope) results.push({ action, ok: true, message: outcome.message, report: outcome.report }) diff --git a/ui/src/features/agent/applicationAdapters.ts b/ui/src/features/agent/applicationAdapters.ts index 87851319..d908af6c 100644 --- a/ui/src/features/agent/applicationAdapters.ts +++ b/ui/src/features/agent/applicationAdapters.ts @@ -4,7 +4,7 @@ import { rememberedCharacterKitLibrary } from '../characters/session' import type { SeriesAssemblyJob } from '../series/assemblyContract' import type { SeriesJobStatus } from '../series/types' import type { MediaFilter } from '../../types' -import type { AgentApply3dRhythmAction, AgentApplySeriesPlanAction, AgentApplyStoryProposalAction, AgentApproveStorySectionAction, AgentApproveStoryVisualsAction, AgentAssembleSeriesEpisodeAction, AgentCommitSeriesCanonAction, AgentConfigureStorySongAction, AgentCreateComicAction, AgentCreateSeriesEpisodeAction, AgentCreateStoryAction, AgentCreateWorkspaceAction, AgentGenerateComicAction, AgentGenerateSeriesPlanAction, AgentGenerateStorySectionAction, AgentGenerateStorySongAction, AgentGenerateStoryVisualsAction, AgentRenderSeriesShotsAction, AgentReviewSeriesAttemptsAction, AgentSelectWorkspaceAction, AgentStageStoryComicAction, AgentStartDirectorProductionAction, AgentStageStoryMusicVideoAction, AgentStageStoryVideoAction, AgentUpdateSeriesEpisodeAction, AgentUpdateStoryAction } from './agentActions' +import type { AgentApply3dRhythmAction, AgentApplySeriesPlanAction, AgentApplyStoryProposalAction, AgentApproveStorySectionAction, AgentApproveStoryVisualsAction, AgentAssembleSeriesEpisodeAction, AgentAttachStudioReferencesAction, AgentCommitSeriesCanonAction, AgentConfigureStudioLorasAction, AgentConfigureStorySongAction, AgentCreateComicAction, AgentCreateSeriesEpisodeAction, AgentCreateStoryAction, AgentCreateWorkspaceAction, AgentGenerateComicAction, AgentGenerateSeriesPlanAction, AgentGenerateStorySectionAction, AgentGenerateStorySongAction, AgentGenerateStoryVisualsAction, AgentPrepare3dAction, AgentPrepareAudioAction, AgentPrepareImageAction, AgentPrepareVideoAction, AgentRenderSeriesShotsAction, AgentReviewSeriesAttemptsAction, AgentSelectWorkspaceAction, AgentStartGenerationAction, AgentStageStoryComicAction, AgentStartDirectorProductionAction, AgentStageStoryMusicVideoAction, AgentStageStoryVideoAction, AgentUpdateSeriesEpisodeAction, AgentUpdateStoryAction } from './agentActions' import { executionKey, executionReport, @@ -16,7 +16,6 @@ import { import { openAgentActivityDetails, requestAgentSceneControl, requestAgentSceneRhythm, requestAgentSceneWorkflow, requestAgentStoryVisualGeneration, type AgentSceneControlRequest, type AgentSceneWorkflowRequest } from './agentUiBus' import type { AgentRhythmGrid } from './agentUiBus' import { queueMusic } from './audioActions' -import type { AgentPrepareAudioAction } from './agentActions' import type { AgentTab } from './capabilityRegistry' import type { AgentAddVideoEditorAudioAction, @@ -59,6 +58,13 @@ export interface AdapterOutcome { export interface StudioAdapter { open(tab?: 'studio' | 'images' | 'videos' | 'audio' | '3d'): Promise queueMusic(action: AgentPrepareAudioAction): Promise + prepareVideo(action: AgentPrepareVideoAction): Promise + prepareImage(action: AgentPrepareImageAction): Promise + prepareAudio(action: AgentPrepareAudioAction): Promise + prepare3d(action: AgentPrepare3dAction): Promise + startGeneration(action: AgentStartGenerationAction): Promise + attachReferences(action: AgentAttachStudioReferencesAction): Promise + configureLoras(action: AgentConfigureStudioLorasAction): Promise } export interface StoryLabAdapter { @@ -279,6 +285,52 @@ export function createDefaultApplicationAdapters(): WizardApplicationAdapters { const result = await queueMusic(action) return { ...result, target: { kind: 'queue_task', id: result.taskId, title: 'Song generation' } } }, + async prepareVideo(action) { + const { prepareVideoForm } = await import('../studio/adapters') + return presentStudioSliceResult(await prepareVideoForm(action), 'Video') + }, + async prepareImage(action) { + const { prepareImageForm } = await import('../studio/adapters') + return presentStudioSliceResult(await prepareImageForm(action), 'Image') + }, + async prepareAudio(action) { + const { prepareAudioForm } = await import('../studio/adapters') + return presentStudioSliceResult(await prepareAudioForm(action), 'Audio') + }, + async prepare3d(action) { + const { prepare3dForm } = await import('../studio/adapters') + return presentStudioSliceResult(await prepare3dForm(action), '3D') + }, + async startGeneration(action) { + const { startGeneration } = await import('../studio/adapters') + const result = await startGeneration() + const presented = await presentStudioSliceResult(result, 'Studio generation') + const taskId = result.taskIds[0] + return { + ...presented, + taskId, + report: executionReport({ + state: 'queued', + message: presented.message, + target: presented.target, + taskId, + recoverable: true, + executionKey: executionKey({ + workspace: useStore.getState().activeWorkspace || 'default', + type: action.type, + params: action, + }), + }), + } + }, + async attachReferences(action) { + const { attachReferences } = await import('../studio/adapters') + return presentStudioSliceResult(await attachReferences(action), 'Image / Video') + }, + async configureLoras(action) { + const { configureLoras } = await import('../studio/adapters') + return presentStudioSliceResult(await configureLoras(action), 'Image / Video') + }, } adapters.storyLab = { open: () => navigate('story_lab'), @@ -823,6 +875,24 @@ async function presentQueueSliceResult(result: CommandResult): Promise { + await navigate('studio') + const summary = typeof result.artifacts[0]?.metadata?.summary === 'string' + ? result.artifacts[0].metadata.summary + : 'Studio listo.' + const title = String(result.artifacts[0]?.metadata?.title || fallbackTitle) + const mode = String(result.artifacts[0]?.metadata?.mode || 'studio') + return { + message: summary, + target: { + kind: mode === 'generation' ? 'generation_task' : 'studio_form', + id: result.entities[0]?.id || mode, + title, + }, + taskId: result.taskIds[0], + } +} + async function presentWorkspaceSliceResult(result: CommandResult): Promise { const summary = typeof result.artifacts[0]?.metadata?.summary === 'string' ? result.artifacts[0].metadata.summary diff --git a/ui/src/features/agent/audioActions.ts b/ui/src/features/agent/audioActions.ts index cb4d1b37..0c719d04 100644 --- a/ui/src/features/agent/audioActions.ts +++ b/ui/src/features/agent/audioActions.ts @@ -1,77 +1,16 @@ import { useStore } from '../../stores/useStore' import type { AgentPrepareAudioAction, AgentQueueSfxPackAction } from './agentActions' -import type { AgentSfxClip } from './sfxPack' - -const AUDIO_SUB_MODE_DEFAULTS: Record = { - speech: 'kugelaudio_0_open', - music: 'ace_step_v1_5_xl_sft_lm_4b', - sfx: 'mmaudio_v2', -} - -function openStudioAudio(subMode: AgentPrepareAudioAction['subMode']): void { - const state = useStore.getState() - state.setSettingsOpen(false) - state.setDashboardOpen(false) - state.setSidebarMode('studio') - state.setSidebarOpen(true) - state.setGenerationMode('audio') - state.setAudioSubMode(subMode) -} - -async function selectAudioModel(preferred?: string, subMode: AgentPrepareAudioAction['subMode'] = 'sfx'): Promise { - let state = useStore.getState() - if (!state.modelsLoaded) await state.loadModels() - state = useStore.getState() - const fallback = AUDIO_SUB_MODE_DEFAULTS[subMode] - const requested = preferred && state.models.some(model => model.model_type === preferred) - ? preferred - : state.params.model_type && state.models.some(model => model.model_type === state.params.model_type) - ? state.params.model_type - : fallback - if (requested && state.params.model_type !== requested) { - state.selectModel(requested) - } - const selected = useStore.getState().params.model_type || requested || fallback - const selectedModel = useStore.getState().models.find(model => model.model_type === selected) - return selectedModel?.name || selected -} - -function applySfxClip(clip: AgentSfxClip, negativePrompt: string): void { - const state = useStore.getState() - state.setDurationSeconds(Math.max(1, Math.min(20, clip.durationSeconds))) - state.setParams({ - prompt: clip.prompt, - MMAudio_prompt: clip.prompt, - MMAudio_neg_prompt: negativePrompt || 'music, speech, talking, vocals, long melody', - video_guide: undefined, - }) -} +import { applySfxClip, openStudioAudio, prepareAudio as prepareStudioAudio, selectAudioModel } from '../studio/actions' export async function prepareAudio(action: AgentPrepareAudioAction): Promise { - openStudioAudio(action.subMode) - const modelName = await selectAudioModel(action.modelType, action.subMode) - const state = useStore.getState() - if (action.subMode === 'sfx') { - applySfxClip({ - name: 'sfx', - prompt: action.prompt, - durationSeconds: action.durationSeconds ?? 2, - }, action.negativePrompt || '') - } else { - state.setDurationSeconds(action.durationSeconds ?? state.durationSeconds) - state.setParams({ - prompt: action.prompt, - negative_prompt: action.negativePrompt || '', - }) - } - const duration = useStore.getState().durationSeconds - const room = action.subMode === 'sfx' ? 'SFX' : action.subMode === 'music' ? 'Music' : 'Speech' - return `He preparado Studio → Audio → ${room} con ${modelName}, ${duration.toFixed(0)} s y el prompt indicado. La pestaña Audios solo muestra resultados; la creación queda en Studio.` + const result = await prepareStudioAudio(action) + const summary = result.artifacts[0]?.metadata?.summary + return typeof summary === 'string' ? summary : 'Studio → Audio listo.' } export async function queueMusic(action: AgentPrepareAudioAction): Promise<{ message: string; taskId: string }> { if (action.subMode !== 'music') throw new Error('queueMusic solo acepta peticiones de música.') - await prepareAudio(action) + await prepareStudioAudio(action) const known = new Set(useStore.getState().jobs) await useStore.getState().startGeneration() const created = useStore.getState().jobs.find(job => !known.has(job)) diff --git a/ui/src/features/agent/studioCapabilities.ts b/ui/src/features/agent/studioCapabilities.ts index bbd628d9..cd6b54c0 100644 --- a/ui/src/features/agent/studioCapabilities.ts +++ b/ui/src/features/agent/studioCapabilities.ts @@ -7,7 +7,6 @@ * parent capability registry imports this file as a side effect. */ import type { defineCapability, CapabilityDefinition, CapabilityExecutionContext, CapabilityExecutionOutcome } from './capabilityRegistry' -import { executionKey, executionReport } from './agentContract' import type { AgentAction, AgentAttachStudioReferencesAction, @@ -54,14 +53,6 @@ function studioTarget(mode: 'video' | 'image' | 'audio' | '3d', title: string) { return { kind: 'studio_form', id: mode, title: `Studio → ${title}` } } -function studioOutcome( - mode: 'video' | 'image' | 'audio' | '3d', - title: string, - message: string, -): CapabilityExecutionOutcome { - return { message, target: studioTarget(mode, title) } -} - function commonPresentation(anchors: string[]) { return { destination: 'studio' as const, anchors, replay: 'atomic' as const } } @@ -195,64 +186,13 @@ function validType(type: T['type'], action: AgentAction): return action.type === type ? [] : [`${type} is invalid`] } -async function bridge( +async function bridgeSfx( action: TAction, - context: CapabilityExecutionContext, + _context: CapabilityExecutionContext, ): Promise { - // Keep `context` in the signature so every Studio capability has the same - // adapter contract as the rest of the registry. The form itself is shared - // with the legacy runner through these explicit bridges. - if (action.type === 'prepare_video') { - const { prepareVideoForAgent } = await import('./agentActions') - return studioOutcome('video', 'Video', await prepareVideoForAgent(action)) - } - if (action.type === 'prepare_image') { - const { prepareImageForAgent } = await import('./agentActions') - return studioOutcome('image', 'Image', await prepareImageForAgent(action)) - } - if (action.type === 'prepare_audio') { - const { prepareAudioForAgent } = await import('./agentActions') - return studioOutcome('audio', 'Audio', await prepareAudioForAgent(action)) - } - if (action.type === 'prepare_3d') { - const { prepare3dForAgent } = await import('./agentActions') - return studioOutcome('3d', '3D', await prepare3dForAgent(action)) - } - if (action.type === 'queue_sfx_pack') { - const { queueSfxPackForAgent } = await import('./agentActions') - return { message: await queueSfxPackForAgent(action), target: studioTarget('audio', 'Audio → SFX') } - } - if (action.type === 'attach_studio_references') { - const { attachStudioReferencesForAgent } = await import('./agentActions') - return studioOutcome('image', 'Image / Video', await attachStudioReferencesForAgent(action)) - } - if (action.type === 'configure_studio_loras') { - const { configureStudioLorasForAgent } = await import('./agentActions') - return studioOutcome('image', 'Image / Video', await configureStudioLorasForAgent(action)) - } - if (action.type === 'start_generation') { - const { startPreparedGenerationForAgent } = await import('./agentActions') - const result = await startPreparedGenerationForAgent() - const target = { kind: 'generation_task', id: result.taskId || 'studio-generation', title: 'Studio generation' } - return { - message: result.message, - target, - taskId: result.taskId, - report: executionReport({ - state: 'queued', - message: result.message, - target, - taskId: result.taskId, - recoverable: true, - executionKey: executionKey({ - workspace: context.workspace || 'default', - type: action.type, - params: action, - }), - }), - } - } - throw new Error(`No hay puente Studio para ${action.type}.`) + if (action.type !== 'queue_sfx_pack') throw new Error(`No hay puente Studio para ${action.type}.`) + const { queueSfxPackForAgent } = await import('./agentActions') + return { message: await queueSfxPackForAgent(action), target: studioTarget('audio', 'Audio → SFX') } } /** @@ -279,7 +219,7 @@ export function registerStudioCapabilities(register: typeof defineCapability): v resolve: videoAction, validate(action) { return action.prompt ? validType('prepare_video', action) : ['prompt is required'] }, async prepare(action) { return action }, - execute: bridge, + async execute(action, context) { return context.adapters.studio.prepareVideo(action) }, correlate(_action, outcome) { return outcome.target }, async track(_action, outcome) { return outcome }, report: { targetKind: 'studio_form', successState: 'prepared' }, @@ -297,7 +237,8 @@ export function registerStudioCapabilities(register: typeof defineCapability): v risk: 'edit', confirmation: 'none', progress: 'Rellenando Studio → Image…', resolve: imageAction, validate(action) { return action.prompt ? validType('prepare_image', action) : ['prompt is required'] }, - async prepare(action) { return action }, execute: bridge, + async prepare(action) { return action }, + async execute(action, context) { return context.adapters.studio.prepareImage(action) }, correlate(_action, outcome) { return outcome.target }, async track(_action, outcome) { return outcome }, report: { targetKind: 'studio_form', successState: 'prepared' }, summarize(_action, outcome) { return outcome.message }, presentation: commonPresentation(['prompt', 'model', 'generate']), @@ -313,7 +254,8 @@ export function registerStudioCapabilities(register: typeof defineCapability): v risk: 'edit', confirmation: 'none', progress: 'Rellenando Studio → Audio…', resolve: audioAction, validate(action) { return action.prompt ? validType('prepare_audio', action) : ['prompt is required'] }, - async prepare(action) { return action }, execute: bridge, + async prepare(action) { return action }, + async execute(action, context) { return context.adapters.studio.prepareAudio(action) }, correlate(_action, outcome) { return outcome.target }, async track(_action, outcome) { return outcome }, report: { targetKind: 'studio_form', successState: 'prepared' }, summarize(_action, outcome) { return outcome.message }, presentation: commonPresentation(['audio-mode', 'prompt', 'model', 'generate']), @@ -329,7 +271,8 @@ export function registerStudioCapabilities(register: typeof defineCapability): v risk: 'edit', confirmation: 'none', progress: 'Rellenando Studio → 3D…', resolve: model3dAction, validate(action) { return action.prompt ? validType('prepare_3d', action) : ['prompt is required'] }, - async prepare(action) { return action }, execute: bridge, + async prepare(action) { return action }, + async execute(action, context) { return context.adapters.studio.prepare3d(action) }, correlate(_action, outcome) { return outcome.target }, async track(_action, outcome) { return outcome }, report: { targetKind: 'studio_form', successState: 'prepared' }, summarize(_action, outcome) { return outcome.message }, presentation: commonPresentation(['prompt', 'model', 'generate']), @@ -345,7 +288,7 @@ export function registerStudioCapabilities(register: typeof defineCapability): v risk: 'compute', confirmation: 'required', progress: 'Encolando el pack de SFX…', resolve: sfxAction, validate(action) { return action.confirm === true && action.clips.length > 0 ? validType('queue_sfx_pack', action) : ['confirmed SFX clips are required'] }, - async prepare(action) { return action }, execute: bridge, + async prepare(action) { return action }, execute: bridgeSfx, correlate(_action, outcome) { return outcome.target }, async track(_action, outcome) { return outcome }, report: { targetKind: 'studio_sfx_pack', successState: 'completed' }, summarize(_action, outcome) { return outcome.message }, presentation: commonPresentation(['audio-mode', 'sfx-pack', 'queue']), @@ -361,7 +304,8 @@ export function registerStudioCapabilities(register: typeof defineCapability): v risk: 'compute', confirmation: 'required', progress: 'Enviando la generación de Studio a la cola…', resolve(raw) { return raw.type === 'start_generation' && raw.confirm === true ? { type: 'start_generation', confirm: true } : null }, validate(action) { return validType('start_generation', action) }, - async prepare(action) { return action }, execute: bridge, + async prepare(action) { return action }, + async execute(action, context) { return context.adapters.studio.startGeneration(action) }, correlate(_action, outcome) { return outcome.target }, async track(_action, outcome) { return outcome }, report: { targetKind: 'generation_task', successState: 'completed' }, summarize(_action, outcome) { return outcome.message }, presentation: commonPresentation(['generate', 'queue']), @@ -377,7 +321,8 @@ export function registerStudioCapabilities(register: typeof defineCapability): v risk: 'edit', confirmation: 'none', progress: 'Adjuntando referencias a Studio…', resolve: referencesAction, validate(action) { return action.outputNames.length ? validType('attach_studio_references', action) : ['at least one exact output name is required'] }, - async prepare(action) { return action }, execute: bridge, + async prepare(action) { return action }, + async execute(action, context) { return context.adapters.studio.attachReferences(action) }, correlate(_action, outcome) { return outcome.target }, async track(_action, outcome) { return outcome }, report: { targetKind: 'studio_form', successState: 'completed' }, summarize(_action, outcome) { return outcome.message }, presentation: commonPresentation(['references', 'prompt']), @@ -393,7 +338,8 @@ export function registerStudioCapabilities(register: typeof defineCapability): v risk: 'edit', confirmation: 'none', progress: 'Configurando LoRAs compatibles en Studio…', resolve: lorasAction, validate(action) { return action.loras.length || action.replaceExisting ? validType('configure_studio_loras', action) : ['LoRA selections or replace_existing are required'] }, - async prepare(action) { return action }, execute: bridge, + async prepare(action) { return action }, + async execute(action, context) { return context.adapters.studio.configureLoras(action) }, correlate(_action, outcome) { return outcome.target }, async track(_action, outcome) { return outcome }, report: { targetKind: 'studio_form', successState: 'completed' }, summarize(_action, outcome) { return outcome.message }, presentation: commonPresentation(['loras', 'model']), diff --git a/ui/src/features/agent/studioGuidance.ts b/ui/src/features/agent/studioGuidance.ts index 27fd7e8e..6d0553bc 100644 --- a/ui/src/features/agent/studioGuidance.ts +++ b/ui/src/features/agent/studioGuidance.ts @@ -1,117 +1 @@ -import * as api from '../../api/client' -import { useStore } from '../../stores/useStore' -import type { - AgentAttachStudioReferencesAction, - AgentConfigureStudioLorasAction, -} from './agentActions' - -const normalized = (value: string): string => value.trim().toLocaleLowerCase() - -async function imageOutputFiles(names: string[]): Promise { - const workspace = useStore.getState().activeWorkspace || 'default' - const { outputs } = await api.fetchOutputs(0, 0, { workspace, mediaType: 'image' }) - const byName = new Map(outputs.map(output => [normalized(output.name), output])) - const files: File[] = [] - for (const requestedName of names) { - const output = byName.get(normalized(requestedName)) - if (!output) { - throw new Error(`No existe la imagen “${requestedName}” en el workspace activo; no he inventado ni sustituido la referencia.`) - } - const response = await fetch(api.getFileUrl(output.name, workspace)) - if (!response.ok) throw new Error(`No pude leer la imagen “${output.name}” para usarla como referencia.`) - const blob = await response.blob() - files.push(new File([blob], output.name, { type: blob.type || 'image/png' })) - } - return files -} - -function clearImageReferences(): void { - const state = useStore.getState() - for (let index = state.imageRefs.length - 1; index >= 0; index -= 1) { - useStore.getState().removeImageRef(index) - } -} - -export async function attachStudioReferences( - action: AgentAttachStudioReferencesAction, -): Promise { - let state = useStore.getState() - if (state.generationMode !== 'image' && state.generationMode !== 'video') { - throw new Error('Las referencias visuales sólo pueden adjuntarse a Studio → Image o Studio → Video.') - } - const selectedModel = state.models.find(model => model.model_type === state.params.model_type) - if (!selectedModel) throw new Error('Studio no tiene un modelo de imagen/vídeo válido seleccionado.') - const files = await imageOutputFiles(action.outputNames) - - if (action.role === 'start_frame') { - if (state.generationMode !== 'video' || !selectedModel.is_i2v) { - throw new Error(`${selectedModel.name} no admite una imagen inicial en el modo actual.`) - } - if (action.replaceExisting) state.setStartImage(null) - state.setStartImage(files[0]) - return `He adjuntado “${files[0].name}” como start frame de Studio → Video.` - } - - const config = state.modelOptions?.image_ref_choices - const choices = config?.choices?.map(([, value]) => value) || [] - const desiredType = action.role === 'style' ? 'KI' : 'I' - const supportsDesiredType = desiredType === 'KI' - ? choices.some(value => value.includes('K')) - : choices.some(value => value === 'I') - if (!config || !supportsDesiredType) { - throw new Error(`${selectedModel.name} no admite referencias de ${action.role === 'style' ? 'estilo/escenario' : 'sujeto'} en este formulario.`) - } - const configuredLimit = state.modelOptions?.max_image_refs - const existingCount = action.replaceExisting ? 0 : state.imageRefs.length - if (configuredLimit != null && existingCount + files.length > configuredLimit) { - throw new Error(`${selectedModel.name} admite como máximo ${configuredLimit} referencias; se solicitaron ${existingCount + files.length}.`) - } - if (action.replaceExisting) clearImageReferences() - files.forEach(file => useStore.getState().addImageRef(file)) - state = useStore.getState() - state.setImageRefType(desiredType) - state.setRemoveBackgroundRefs(action.removeBackground) - if (state.modelOptions?.architecture === 'minimax_h3') { - state.setParam('h3_reference_mode', 'references') - } - return `He adjuntado ${files.length} referencia${files.length === 1 ? '' : 's'} de ${action.role === 'style' ? 'estilo/escenario' : 'sujeto'} a Studio usando nombres reales del workspace.` -} - -export async function configureStudioLoras( - action: AgentConfigureStudioLorasAction, -): Promise { - let state = useStore.getState() - if (state.generationMode !== 'image' && state.generationMode !== 'video') { - throw new Error('Los LoRAs sólo pueden configurarse en Studio → Image o Studio → Video.') - } - const modelType = state.params.model_type - if (!modelType) throw new Error('Studio no tiene un modelo seleccionado para consultar LoRAs compatibles.') - await state.loadLoras(modelType) - state = useStore.getState() - const availableByName = new Map(state.availableLoras.map(name => [normalized(name), name])) - const resolved = action.loras.map(selection => { - const filename = availableByName.get(normalized(selection.name)) - if (!filename) { - throw new Error(`El LoRA “${selection.name}” no está instalado o no es compatible con ${modelType}; no lo he activado.`) - } - return { ...selection, name: filename } - }) - const requested = new Set(resolved.map(selection => selection.name)) - if (action.replaceExisting) { - for (const active of [...(useStore.getState().params.activated_loras || [])]) { - if (!requested.has(active)) useStore.getState().toggleLora(active) - } - } - for (const selection of resolved) { - if (!(useStore.getState().params.activated_loras || []).includes(selection.name)) { - useStore.getState().toggleLora(selection.name) - } - const phases = Math.max(1, useStore.getState().modelOptions?.guidance_max_phases || 1) - for (let phase = 0; phase < phases; phase += 1) { - useStore.getState().setLoraWeight(selection.name, phase, selection.weight) - } - } - const active = useStore.getState().params.activated_loras || [] - if (!active.length) return 'He desactivado todos los LoRAs de Studio para el modelo actual.' - return `He configurado ${active.length} LoRA${active.length === 1 ? '' : 's'} compatible${active.length === 1 ? '' : 's'} en Studio: ${active.join(', ')}.` -} +export { attachStudioReferences, configureStudioLoras } from '../studio/actions' diff --git a/ui/src/features/studio/actions.ts b/ui/src/features/studio/actions.ts new file mode 100644 index 00000000..9fceddb9 --- /dev/null +++ b/ui/src/features/studio/actions.ts @@ -0,0 +1,465 @@ +import * as api from '../../api/client' +import { commandResultFromSlice, type CommandResult } from '../../lib/commandContract' +import { getFamiliesForMode, getModelsForFamily, useStore } from '../../stores/useStore' +import type { ModelDef } from '../../types' +import type { + AttachStudioReferencesCommand, + ConfigureStudioLorasCommand, + Prepare3dCommand, + PrepareAudioCommand, + PrepareImageCommand, + PrepareVideoCommand, +} from './commands' + +export type StudioSfxClip = { + name: string + prompt: string + durationSeconds: number +} + +const AUDIO_SUB_MODE_DEFAULTS: Record = { + speech: 'kugelaudio_0_open', + music: 'ace_step_v1_5_xl_sft_lm_4b', + sfx: 'mmaudio_v2', +} + +let prepared3dPreset = 'balanced' + +function workspaceId(): string { + return useStore.getState().activeWorkspace || 'default' +} + +function studioResult( + mode: 'video' | 'image' | 'audio' | '3d' | 'generation', + title: string, + message: string, + extra: { taskId?: string } = {}, +): CommandResult { + const entity = { + kind: mode === 'generation' ? 'generation_task' : 'studio_form', + id: extra.taskId || mode, + workspaceId: workspaceId(), + } + return commandResultFromSlice({ + entity, + taskIds: extra.taskId ? [extra.taskId] : undefined, + artifacts: [{ + id: 'reply', + kind: 'document', + owner: entity, + uri: 'studio:reply', + metadata: { summary: message, title, mode }, + }], + }) +} + +function visibleT2vModels(models: ModelDef[]): ModelDef[] { + const enabledModels = useStore.getState().enabledModels + const families = getFamiliesForMode('video', useStore.getState().families) + const familyIds = new Set(families.map(family => family.id)) + const ordered = families.flatMap(family => getModelsForFamily(family.id, models, 'video')) + const orderedIds = new Set(ordered.map(model => model.model_type)) + const extras = models.filter(model => familyIds.has(model.family) && !orderedIds.has(model.model_type)) + return [...ordered, ...extras].filter(model => ( + model.is_t2v + && !model.tool_only + && enabledModels.has(model.model_type) + && model.is_downloaded !== false + )) +} + +function visibleImageModels(models: ModelDef[]): ModelDef[] { + const enabledModels = useStore.getState().enabledModels + const families = getFamiliesForMode('image', useStore.getState().families) + const familyIds = new Set(families.map(family => family.id)) + const ordered = families.flatMap(family => getModelsForFamily(family.id, models, 'image')) + const orderedIds = new Set(ordered.map(model => model.model_type)) + const extras = models.filter(model => familyIds.has(model.family) && !orderedIds.has(model.model_type)) + return [...ordered, ...extras].filter(model => ( + !model.tool_only + && enabledModels.has(model.model_type) + && model.is_downloaded !== false + )) +} + +export function openStudioAudio(subMode: PrepareAudioCommand['subMode']): void { + const state = useStore.getState() + state.setSettingsOpen(false) + state.setDashboardOpen(false) + state.setSidebarMode('studio') + state.setSidebarOpen(true) + state.setGenerationMode('audio') + state.setAudioSubMode(subMode) +} + +export async function selectAudioModel( + preferred?: string, + subMode: PrepareAudioCommand['subMode'] = 'sfx', +): Promise { + let state = useStore.getState() + if (!state.modelsLoaded) await state.loadModels() + state = useStore.getState() + const fallback = AUDIO_SUB_MODE_DEFAULTS[subMode] + const requested = preferred && state.models.some(model => model.model_type === preferred) + ? preferred + : state.params.model_type && state.models.some(model => model.model_type === state.params.model_type) + ? state.params.model_type + : fallback + if (requested && state.params.model_type !== requested) { + state.selectModel(requested) + } + const selected = useStore.getState().params.model_type || requested || fallback + const selectedModel = useStore.getState().models.find(model => model.model_type === selected) + return selectedModel?.name || selected +} + +export function applySfxClip(clip: StudioSfxClip, negativePrompt: string): void { + const state = useStore.getState() + state.setDurationSeconds(Math.max(1, Math.min(20, clip.durationSeconds))) + state.setParams({ + prompt: clip.prompt, + MMAudio_prompt: clip.prompt, + MMAudio_neg_prompt: negativePrompt || 'music, speech, talking, vocals, long melody', + video_guide: undefined, + }) +} + +export async function prepareVideo(action: PrepareVideoCommand): Promise { + let state = useStore.getState() + if (!state.modelsLoaded) await state.loadModels() + + state = useStore.getState() + state.setSettingsOpen(false) + state.setDashboardOpen(false) + state.setSidebarMode('studio') + state.setSidebarOpen(true) + state.setGenerationMode('video') + state.setMediaFilter('videos') + + state = useStore.getState() + const candidates = visibleT2vModels(state.models) + const requested = action.modelType + ? candidates.find(model => model.model_type === action.modelType) + : undefined + if (action.modelType && !requested) { + throw new Error(`El modelo ${action.modelType} no está instalado, habilitado o no admite texto a vídeo.`) + } + const current = candidates.find(model => model.model_type === state.params.model_type) + const selected = requested || current || candidates.find(model => model.is_downloaded) || candidates[0] + if (!selected) { + throw new Error('No hay ningún modelo texto-a-vídeo instalado y habilitado.') + } + + if (state.params.model_type !== selected.model_type) state.selectModel(selected.model_type) + await useStore.getState().loadModelOptions(selected.model_type) + + state = useStore.getState() + state.setStartImage(null) + state.setEndImage(null) + state.setPromptSchedulerEnabled(false) + state.setOutputCount(action.outputCount ?? 1) + if (action.aspectRatio) state.setAspectRatio(action.aspectRatio) + if (action.resolutionPreset) state.setResolutionPreset(action.resolutionPreset) + if (action.durationSeconds !== undefined) state.setDurationSeconds(action.durationSeconds) + + const params: Record = { + prompt: action.prompt, + image_mode: 0, + image_start: undefined, + image_end: undefined, + image_prompt_type: '', + minimax_h3_references: undefined, + h3_ref_videos: [], + h3_ref_audios: [], + } + if (action.resolution) params.resolution = action.resolution + if (action.negativePrompt !== undefined) params.negative_prompt = action.negativePrompt + if (action.seed !== undefined) params.seed = action.seed + if (action.inferenceSteps !== undefined) params.num_inference_steps = action.inferenceSteps + if (action.guidanceScale !== undefined) params.guidance_scale = action.guidanceScale + if (action.audioDirection !== undefined) params.h3_audio_prompt = action.audioDirection + if (action.turbo !== undefined) params.minimax_h3_turbo_mode = action.turbo + state.setParams(params) + + return studioResult( + 'video', + 'Video', + `He preparado Studio → Video con ${selected.name}, ${useStore.getState().durationSeconds.toFixed(1)} s y el prompt indicado.`, + ) +} + +export async function prepareImage(action: PrepareImageCommand): Promise { + let state = useStore.getState() + if (!state.modelsLoaded) await state.loadModels() + + state = useStore.getState() + state.setSettingsOpen(false) + state.setDashboardOpen(false) + state.setSidebarMode('studio') + state.setSidebarOpen(true) + state.setGenerationMode('image') + state.setMediaFilter('images') + + state = useStore.getState() + const candidates = visibleImageModels(state.models) + const requested = action.modelType + ? candidates.find(model => model.model_type === action.modelType) + : undefined + if (action.modelType && !requested) { + throw new Error(`El modelo ${action.modelType} no está instalado, habilitado o no admite texto a imagen.`) + } + const current = candidates.find(model => model.model_type === state.params.model_type) + const selected = requested || current || candidates.find(model => model.is_downloaded) || candidates[0] + if (!selected) { + throw new Error('No hay ningún modelo de imagen instalado y habilitado.') + } + + if (state.params.model_type !== selected.model_type) state.selectModel(selected.model_type) + await useStore.getState().loadModelOptions(selected.model_type) + + state = useStore.getState() + state.setStartImage(null) + state.setEndImage(null) + state.setOutputCount(action.outputCount ?? 1) + if (action.aspectRatio) state.setAspectRatio(action.aspectRatio) + if (action.resolutionPreset) state.setResolutionPreset(action.resolutionPreset) + + const params: Record = { + prompt: action.prompt, + image_mode: 1, + video_length: 1, + image_start: undefined, + image_end: undefined, + image_prompt_type: '', + } + if (action.resolution) params.resolution = action.resolution + if (action.negativePrompt !== undefined) params.negative_prompt = action.negativePrompt + if (action.seed !== undefined) params.seed = action.seed + if (action.inferenceSteps !== undefined) params.num_inference_steps = action.inferenceSteps + if (action.guidanceScale !== undefined) params.guidance_scale = action.guidanceScale + state.setParams(params) + + const resolution = useStore.getState().params.resolution || useStore.getState().resolutionPreset + return studioResult( + 'image', + 'Image', + `He preparado Studio → Image con ${selected.name}, ${resolution} y el prompt indicado.`, + ) +} + +export async function prepare3d(action: Prepare3dCommand): Promise { + let state = useStore.getState() + if (!state.modelsLoaded) await state.loadModels() + state = useStore.getState() + state.setSettingsOpen(false) + state.setDashboardOpen(false) + state.setSidebarMode('studio') + state.setSidebarOpen(true) + state.setGenerationMode('model3d') + state.setMediaFilter('model3d') + + state = useStore.getState() + const families = getFamiliesForMode('model3d', state.families) + const candidates = families.flatMap(family => getModelsForFamily(family.id, state.models, 'model3d')) + .filter(model => !model.tool_only) + const requested = action.modelType + ? candidates.find(model => model.model_type === action.modelType) + : undefined + if (action.modelType && !requested) { + throw new Error(`El modelo 3D ${action.modelType} no está disponible.`) + } + const current = candidates.find(model => model.model_type === state.params.model_type) + const selected = requested || current || candidates[0] + if (!selected) throw new Error('No hay ningún modelo Hunyuan3D disponible.') + if (state.params.model_type !== selected.model_type) state.selectModel(selected.model_type) + useStore.getState().setParams({ + prompt: action.prompt, + seed: action.seed ?? 1234, + }) + prepared3dPreset = action.preset || 'balanced' + return studioResult( + '3d', + '3D', + `He preparado Studio → 3D con ${selected.name}, preset ${prepared3dPreset} y el prompt indicado. La pestaña 3D solo muestra resultados; la creación queda en Studio.`, + ) +} + +export async function prepareAudio(action: PrepareAudioCommand): Promise { + openStudioAudio(action.subMode) + const modelName = await selectAudioModel(action.modelType, action.subMode) + const state = useStore.getState() + if (action.subMode === 'sfx') { + applySfxClip({ + name: 'sfx', + prompt: action.prompt, + durationSeconds: action.durationSeconds ?? 2, + }, action.negativePrompt || '') + } else { + state.setDurationSeconds(action.durationSeconds ?? state.durationSeconds) + state.setParams({ + prompt: action.prompt, + negative_prompt: action.negativePrompt || '', + }) + } + const duration = useStore.getState().durationSeconds + const room = action.subMode === 'sfx' ? 'SFX' : action.subMode === 'music' ? 'Music' : 'Speech' + return studioResult( + 'audio', + `Audio → ${room}`, + `He preparado Studio → Audio → ${room} con ${modelName}, ${duration.toFixed(0)} s y el prompt indicado. La pestaña Audios solo muestra resultados; la creación queda en Studio.`, + ) +} + +export async function startPreparedGeneration(): Promise { + const state = useStore.getState() + if (state.generationMode === 'model3d') { + const { startHunyuan3DJob } = await import('../../api/client') + const job = await startHunyuan3DJob({ + operation: 'generate', + model_id: String(state.params.model_type || ''), + prompt: String(state.params.prompt || ''), + workspace: state.activeWorkspace || 'default', + preset: prepared3dPreset, + seed: typeof state.params.seed === 'number' ? state.params.seed : 1234, + }) + if (!job.job_id) throw new Error('Hunyuan3D devolvió éxito sin jobId; no considero la generación encolada.') + return studioResult( + 'generation', + 'Studio generation', + `He enviado el modelo 3D a Hunyuan3D (${job.job_id}). Aparecerá en la galería 3D al terminar.`, + { taskId: job.job_id }, + ) + } + const before = useStore.getState().jobs + const knownJobs = new Set(before) + await useStore.getState().startGeneration() + const created = useStore.getState().jobs.find(job => !knownJobs.has(job)) + if (!created) throw new Error('HocusPocus no creó una tarea; revisa los requisitos del modelo y los campos visibles.') + if (created.status === 'failed') throw new Error(created.error || created.message || 'La generación no pudo entrar en cola.') + if (!created.id) throw new Error('HocusPocus devolvió éxito sin taskId; no considero la generación encolada.') + const mode = useStore.getState().generationMode + const kind = mode === 'image' ? 'imagen' : mode === 'audio' ? 'pista de audio' : 'vídeo' + return studioResult( + 'generation', + 'Studio generation', + `He enviado la ${kind} a la cola (${created.id}).`, + { taskId: created.id }, + ) +} + +const normalized = (value: string): string => value.trim().toLocaleLowerCase() + +async function imageOutputFiles(names: string[]): Promise { + const workspace = useStore.getState().activeWorkspace || 'default' + const { outputs } = await api.fetchOutputs(0, 0, { workspace, mediaType: 'image' }) + const byName = new Map(outputs.map(output => [normalized(output.name), output])) + const files: File[] = [] + for (const requestedName of names) { + const output = byName.get(normalized(requestedName)) + if (!output) { + throw new Error(`No existe la imagen “${requestedName}” en el workspace activo; no he inventado ni sustituido la referencia.`) + } + const response = await fetch(api.getFileUrl(output.name, workspace)) + if (!response.ok) throw new Error(`No pude leer la imagen “${output.name}” para usarla como referencia.`) + const blob = await response.blob() + files.push(new File([blob], output.name, { type: blob.type || 'image/png' })) + } + return files +} + +function clearImageReferences(): void { + const state = useStore.getState() + for (let index = state.imageRefs.length - 1; index >= 0; index -= 1) { + useStore.getState().removeImageRef(index) + } +} + +export async function attachStudioReferences(action: AttachStudioReferencesCommand): Promise { + let state = useStore.getState() + if (state.generationMode !== 'image' && state.generationMode !== 'video') { + throw new Error('Las referencias visuales sólo pueden adjuntarse a Studio → Image o Studio → Video.') + } + const selectedModel = state.models.find(model => model.model_type === state.params.model_type) + if (!selectedModel) throw new Error('Studio no tiene un modelo de imagen/vídeo válido seleccionado.') + const files = await imageOutputFiles(action.outputNames) + + if (action.role === 'start_frame') { + if (state.generationMode !== 'video' || !selectedModel.is_i2v) { + throw new Error(`${selectedModel.name} no admite una imagen inicial en el modo actual.`) + } + if (action.replaceExisting) state.setStartImage(null) + state.setStartImage(files[0]) + return studioResult('image', 'Image / Video', `He adjuntado “${files[0].name}” como start frame de Studio → Video.`) + } + + const config = state.modelOptions?.image_ref_choices + const choices = config?.choices?.map(([, value]) => value) || [] + const desiredType = action.role === 'style' ? 'KI' : 'I' + const supportsDesiredType = desiredType === 'KI' + ? choices.some(value => value.includes('K')) + : choices.some(value => value === 'I') + if (!config || !supportsDesiredType) { + throw new Error(`${selectedModel.name} no admite referencias de ${action.role === 'style' ? 'estilo/escenario' : 'sujeto'} en este formulario.`) + } + const configuredLimit = state.modelOptions?.max_image_refs + const existingCount = action.replaceExisting ? 0 : state.imageRefs.length + if (configuredLimit != null && existingCount + files.length > configuredLimit) { + throw new Error(`${selectedModel.name} admite como máximo ${configuredLimit} referencias; se solicitaron ${existingCount + files.length}.`) + } + if (action.replaceExisting) clearImageReferences() + files.forEach(file => useStore.getState().addImageRef(file)) + state = useStore.getState() + state.setImageRefType(desiredType) + state.setRemoveBackgroundRefs(action.removeBackground) + if (state.modelOptions?.architecture === 'minimax_h3') { + state.setParam('h3_reference_mode', 'references') + } + return studioResult( + 'image', + 'Image / Video', + `He adjuntado ${files.length} referencia${files.length === 1 ? '' : 's'} de ${action.role === 'style' ? 'estilo/escenario' : 'sujeto'} a Studio usando nombres reales del workspace.`, + ) +} + +export async function configureStudioLoras(action: ConfigureStudioLorasCommand): Promise { + let state = useStore.getState() + if (state.generationMode !== 'image' && state.generationMode !== 'video') { + throw new Error('Los LoRAs sólo pueden configurarse en Studio → Image o Studio → Video.') + } + const modelType = state.params.model_type + if (!modelType) throw new Error('Studio no tiene un modelo seleccionado para consultar LoRAs compatibles.') + await state.loadLoras(modelType) + state = useStore.getState() + const availableByName = new Map(state.availableLoras.map(name => [normalized(name), name])) + const resolved = action.loras.map(selection => { + const filename = availableByName.get(normalized(selection.name)) + if (!filename) { + throw new Error(`El LoRA “${selection.name}” no está instalado o no es compatible con ${modelType}; no lo he activado.`) + } + return { ...selection, name: filename } + }) + const requested = new Set(resolved.map(selection => selection.name)) + if (action.replaceExisting) { + for (const active of [...(useStore.getState().params.activated_loras || [])]) { + if (!requested.has(active)) useStore.getState().toggleLora(active) + } + } + for (const selection of resolved) { + if (!(useStore.getState().params.activated_loras || []).includes(selection.name)) { + useStore.getState().toggleLora(selection.name) + } + const phases = Math.max(1, useStore.getState().modelOptions?.guidance_max_phases || 1) + for (let phase = 0; phase < phases; phase += 1) { + useStore.getState().setLoraWeight(selection.name, phase, selection.weight) + } + } + const active = useStore.getState().params.activated_loras || [] + if (!active.length) { + return studioResult('image', 'Image / Video', 'He desactivado todos los LoRAs de Studio para el modelo actual.') + } + return studioResult( + 'image', + 'Image / Video', + `He configurado ${active.length} LoRA${active.length === 1 ? '' : 's'} compatible${active.length === 1 ? '' : 's'} en Studio: ${active.join(', ')}.`, + ) +} diff --git a/ui/src/features/studio/adapters.ts b/ui/src/features/studio/adapters.ts index 58a2ada2..0f7a6a32 100644 --- a/ui/src/features/studio/adapters.ts +++ b/ui/src/features/studio/adapters.ts @@ -1,10 +1,28 @@ +import { + attachStudioReferences, + configureStudioLoras, + prepare3d, + prepareAudio, + prepareImage, + prepareVideo, + startPreparedGeneration, +} from './actions' import { cancelCanonicalQueueTask, inspectCanonicalQueue, resumeCanonicalQueueTask, retryCanonicalQueueTask, } from './queueActions' -import type { InspectQueueCommand, QueueTaskCommand } from './commands' +import type { + AttachStudioReferencesCommand, + ConfigureStudioLorasCommand, + InspectQueueCommand, + Prepare3dCommand, + PrepareAudioCommand, + PrepareImageCommand, + PrepareVideoCommand, + QueueTaskCommand, +} from './commands' export async function inspect(command: InspectQueueCommand) { return inspectCanonicalQueue(command.scope) @@ -21,3 +39,31 @@ export async function resume(command: QueueTaskCommand) { export async function retry(command: QueueTaskCommand) { return retryCanonicalQueueTask(command.taskId, command.confirm) } + +export async function prepareVideoForm(command: PrepareVideoCommand) { + return prepareVideo(command) +} + +export async function prepareImageForm(command: PrepareImageCommand) { + return prepareImage(command) +} + +export async function prepareAudioForm(command: PrepareAudioCommand) { + return prepareAudio(command) +} + +export async function prepare3dForm(command: Prepare3dCommand) { + return prepare3d(command) +} + +export async function startGeneration() { + return startPreparedGeneration() +} + +export async function attachReferences(command: AttachStudioReferencesCommand) { + return attachStudioReferences(command) +} + +export async function configureLoras(command: ConfigureStudioLorasCommand) { + return configureStudioLoras(command) +} diff --git a/ui/src/features/studio/commands.ts b/ui/src/features/studio/commands.ts index 654e34d2..90c7b360 100644 --- a/ui/src/features/studio/commands.ts +++ b/ui/src/features/studio/commands.ts @@ -1,2 +1,69 @@ +import type { AspectRatio, ResolutionPreset } from '../../types' + export type InspectQueueCommand = { scope: 'active' | 'all' } export type QueueTaskCommand = { taskId: string; confirm: true } + +export type PrepareVideoCommand = { + prompt: string + modelType?: string + durationSeconds?: number + resolutionPreset?: ResolutionPreset + resolution?: string + aspectRatio?: AspectRatio + negativePrompt?: string + seed?: number + inferenceSteps?: number + guidanceScale?: number + outputCount?: number + audioDirection?: string + turbo?: boolean +} + +export type PrepareImageCommand = { + prompt: string + modelType?: string + resolutionPreset?: ResolutionPreset + resolution?: string + aspectRatio?: AspectRatio + negativePrompt?: string + seed?: number + inferenceSteps?: number + guidanceScale?: number + outputCount?: number +} + +export type PrepareAudioCommand = { + subMode: 'speech' | 'music' | 'sfx' + prompt: string + modelType?: string + durationSeconds?: number + negativePrompt?: string +} + +export type Prepare3dCommand = { + prompt: string + modelType?: string + preset?: string + seed?: number +} + +export type StartGenerationCommand = { + confirm: true +} + +export type AttachStudioReferencesCommand = { + outputNames: string[] + role: 'start_frame' | 'subject' | 'style' + replaceExisting: boolean + removeBackground: boolean +} + +export type StudioLoraSelection = { + name: string + weight: number +} + +export type ConfigureStudioLorasCommand = { + loras: StudioLoraSelection[] + replaceExisting: boolean +} diff --git a/ui/tests/agentCapabilityPorts.test.mjs b/ui/tests/agentCapabilityPorts.test.mjs index 6a4dc2b0..c6661592 100644 --- a/ui/tests/agentCapabilityPorts.test.mjs +++ b/ui/tests/agentCapabilityPorts.test.mjs @@ -34,18 +34,10 @@ const SLICE_AGENT_IMPORT_ALLOWLIST = [ const LEGACY_EXECUTE_ALLOWLIST = [ 'attach_videoclip_alternative_song', 'mount_videoclip_alternative_song', - 'prepare_video', - 'prepare_image', - 'prepare_audio', - 'prepare_3d', 'queue_sfx_pack', - 'start_generation', - 'attach_studio_references', - 'configure_studio_loras', ] const AGENT_ACTIONS_IMPORTS = [ - '../../api/client', '../../stores/useStore', '../../types', '../comics/generateArtwork', @@ -62,7 +54,6 @@ const AGENT_ACTIONS_IMPORTS = [ './characterKitActions', './commandContract', './sfxPack', - './studioGuidance', './videoEditorActions', './wizardContext', ] @@ -213,7 +204,7 @@ test('capabilities execute through adapters except the frozen legacy executors', + `added=${JSON.stringify(added)} removed=${JSON.stringify(removed)}`, ) assert.equal(registered.length, 73) - assert.equal(legacy.length, 10) + assert.equal(legacy.length, 3) }) test('agentActions.ts and labActions.ts keep their current module graph until a slice PR shrinks it', () => { From d032153fbb471d48b9a3b6bd3068c5904e569e0f Mon Sep 17 00:00:00 2001 From: IAnMove <216241348+IAnMove@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:52:57 +0200 Subject: [PATCH 2/2] fix: drop unused SFX bridge context so Studio lint is clean queue_sfx_pack still uses the legacy bridge; the unused context argument tripped eslint unused-vars on CI. --- ui/src/features/agent/studioCapabilities.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ui/src/features/agent/studioCapabilities.ts b/ui/src/features/agent/studioCapabilities.ts index cd6b54c0..e7910213 100644 --- a/ui/src/features/agent/studioCapabilities.ts +++ b/ui/src/features/agent/studioCapabilities.ts @@ -6,7 +6,7 @@ * agentActions import and, in turn, avoids an initialization cycle when the * parent capability registry imports this file as a side effect. */ -import type { defineCapability, CapabilityDefinition, CapabilityExecutionContext, CapabilityExecutionOutcome } from './capabilityRegistry' +import type { defineCapability, CapabilityDefinition, CapabilityExecutionOutcome } from './capabilityRegistry' import type { AgentAction, AgentAttachStudioReferencesAction, @@ -188,7 +188,6 @@ function validType(type: T['type'], action: AgentAction): async function bridgeSfx( action: TAction, - _context: CapabilityExecutionContext, ): Promise { if (action.type !== 'queue_sfx_pack') throw new Error(`No hay puente Studio para ${action.type}.`) const { queueSfxPackForAgent } = await import('./agentActions')