diff --git a/ui/src/features/agent/agentActions.ts b/ui/src/features/agent/agentActions.ts index 1944a59b..afcee475 100644 --- a/ui/src/features/agent/agentActions.ts +++ b/ui/src/features/agent/agentActions.ts @@ -3027,29 +3027,29 @@ export async function executeAgentActions( }), }) } else if (action.type === 'create_series_episode') { - const { createFilledSeriesEpisode } = await import('./labActions') - results.push({ action, ok: true, message: await createFilledSeriesEpisode(action) }) + const outcome = await defaultApplicationAdapters.seriesLab.createEpisode(action) + results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'update_series_episode') { - const { updateSeriesEpisode } = await import('./labActions') - results.push({ action, ok: true, message: await updateSeriesEpisode(action) }) + const outcome = await defaultApplicationAdapters.seriesLab.updateEpisode(action) + results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'generate_series_plan') { - const { generateSeriesPlan } = await import('./labActions') - results.push({ action, ok: true, message: await generateSeriesPlan(action) }) + const outcome = await defaultApplicationAdapters.seriesLab.generatePlan(action) + results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'apply_series_plan') { - const { applySeriesPlan } = await import('./labActions') - results.push({ action, ok: true, message: await applySeriesPlan(action) }) + const outcome = await defaultApplicationAdapters.seriesLab.applyPlan(action) + results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'render_series_shots') { - const { renderSeriesShots } = await import('./labActions') - results.push({ action, ok: true, message: await renderSeriesShots(action) }) + const outcome = await defaultApplicationAdapters.seriesLab.renderShots(action) + results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'review_series_attempts') { - const { reviewSeriesAttempts } = await import('./labActions') - results.push({ action, ok: true, message: await reviewSeriesAttempts(action) }) + const outcome = await defaultApplicationAdapters.seriesLab.reviewAttempts(action) + results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'assemble_series_episode') { - const { assembleSeriesEpisode } = await import('./labActions') - results.push({ action, ok: true, message: await assembleSeriesEpisode(action) }) + const outcome = await defaultApplicationAdapters.seriesLab.assembleEpisode(action) + results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'commit_series_canon') { - const { commitSeriesCanonDelta } = await import('./labActions') - results.push({ action, ok: true, message: await commitSeriesCanonDelta(action) }) + const outcome = await defaultApplicationAdapters.seriesLab.commitCanon(action) + results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'open_3d_scene') { await defaultApplicationAdapters.video3d.open() const { requestAgentSceneControl } = await import('./agentUiBus') @@ -3063,83 +3063,63 @@ export async function executeAgentActions( const { requestAgentSceneControl } = await import('./agentUiBus') results.push({ action, ok: true, message: await requestAgentSceneControl(action) }) } else if (action.type === 'create_character_kit') { - const { createAgentCharacterKit } = await import('./characterKitActions') - const outcome = await createAgentCharacterKit(action) + const outcome = await defaultApplicationAdapters.characterKit.create(action) results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'open_character_kit') { - const { openAgentCharacterKit } = await import('./characterKitActions') - const outcome = await openAgentCharacterKit(action) + const outcome = await defaultApplicationAdapters.characterKit.openKit(action) results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'update_character_kit') { - const { updateAgentCharacterKit } = await import('./characterKitActions') - const outcome = await updateAgentCharacterKit(action) + const outcome = await defaultApplicationAdapters.characterKit.update(action) results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'attach_character_kit_references') { - const { attachAgentCharacterKitReferences } = await import('./characterKitActions') - const outcome = await attachAgentCharacterKitReferences(action) + const outcome = await defaultApplicationAdapters.characterKit.attachReference(action) results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'build_character_kit') { - const { buildAgentCharacterKit } = await import('./characterKitActions') - const outcome = await buildAgentCharacterKit(action) + const outcome = await defaultApplicationAdapters.characterKit.build(action) results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'open_character_kit_rig') { - const { openAgentCharacterKitRig } = await import('./characterKitActions') - const outcome = await openAgentCharacterKitRig(action) + const outcome = await defaultApplicationAdapters.characterKit.openRig(action) results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'apply_character_kit_preset') { - const { applyAgentCharacterKitPreset } = await import('./characterKitActions') - const outcome = await applyAgentCharacterKitPreset(action) + const outcome = await defaultApplicationAdapters.characterKit.applyPreset(action) results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'track_character_kit_job') { - const { trackAgentCharacterKitJob } = await import('./characterKitActions') - const outcome = await trackAgentCharacterKitJob(action) + const outcome = await defaultApplicationAdapters.characterKit.trackJob(action) results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'create_video_editor_project') { - const { createAgentVideoEditorProject } = await import('./videoEditorActions') - const outcome = await createAgentVideoEditorProject(action) + const outcome = await defaultApplicationAdapters.videoEditor.create(action) results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'open_video_editor_project') { - const { openAgentVideoEditorProject } = await import('./videoEditorActions') - const outcome = await openAgentVideoEditorProject(action) + const outcome = await defaultApplicationAdapters.videoEditor.openProject(action) results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'add_video_editor_clips') { - const { addAgentVideoEditorClips } = await import('./videoEditorActions') - const outcome = await addAgentVideoEditorClips(action) + const outcome = await defaultApplicationAdapters.videoEditor.addClips(action) results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'order_video_editor_clips') { - const { orderAgentVideoEditorClips } = await import('./videoEditorActions') - const outcome = await orderAgentVideoEditorClips(action) + const outcome = await defaultApplicationAdapters.videoEditor.orderClips(action) results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'trim_video_editor_clip') { - const { trimAgentVideoEditorClip } = await import('./videoEditorActions') - const outcome = await trimAgentVideoEditorClip(action) + const outcome = await defaultApplicationAdapters.videoEditor.trimClip(action) results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'add_video_editor_audio') { - const { addAgentVideoEditorAudio } = await import('./videoEditorActions') - const outcome = await addAgentVideoEditorAudio(action) + const outcome = await defaultApplicationAdapters.videoEditor.addAudio(action) results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'validate_video_editor_timeline') { - const { validateAgentVideoEditorTimeline } = await import('./videoEditorActions') - const outcome = await validateAgentVideoEditorTimeline() + const outcome = await defaultApplicationAdapters.videoEditor.validateTimeline() results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'export_video_editor') { if (!action.confirm) throw new Error('Exportar Video Editor requiere confirm=true.') - const { exportAgentVideoEditor } = await import('./videoEditorActions') - const outcome = await exportAgentVideoEditor(action) + const outcome = await defaultApplicationAdapters.videoEditor.exportProject(action) results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'track_video_editor_export') { - const { trackAgentVideoEditorExport } = await import('./videoEditorActions') - const outcome = await trackAgentVideoEditorExport() + const outcome = await defaultApplicationAdapters.videoEditor.trackExport() results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'create_comic') { - const { createFilledComic } = await import('./labActions') - results.push({ action, ok: true, message: await createFilledComic(action) }) + const outcome = await defaultApplicationAdapters.comic.create(action) + results.push({ action, ok: true, message: outcome.message, report: outcome.report }) } else if (action.type === 'generate_comic') { if (!action.confirm) throw new Error('Dibujar las viñetas requiere confirm=true.') - const { generateFilledComicArtwork } = await import('./labActions') - const outcome = await generateFilledComicArtwork(action, onStep, createdComicId || undefined) - const { useComicStore } = await import('../comics/store') - const project = useComicStore.getState().project + const outcome = await defaultApplicationAdapters.comic.generate(action, createdComicId || undefined) results.push({ action, ok: outcome.state !== 'failed', @@ -3148,23 +3128,19 @@ export async function executeAgentActions( state: outcome.state, message: outcome.message, recoverable: outcome.state === 'partial' || outcome.state === 'failed', - target: { kind: 'comic', id: project.id, title: project.title }, + target: outcome.target, executionKey: executionKey({ workspace: useStore.getState().activeWorkspace || 'default', type: action.type, - targetId: project.id, + targetId: outcome.target.id, params: action, }), }), }) } else if (action.type === 'generate_comic_panel') { if (!action.confirm) throw new Error('Regenerar una viñeta requiere confirm=true.') - const { generateComicPanelArtwork } = await import('./labActions') - results.push({ - action, - ok: true, - message: await generateComicPanelArtwork(action.pageNumber, action.panelNumber, onStep), - }) + const outcome = await defaultApplicationAdapters.comic.generatePanel(action.pageNumber, action.panelNumber) + 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) }) diff --git a/ui/src/features/agent/applicationAdapters.ts b/ui/src/features/agent/applicationAdapters.ts index afa9fae4..ad2ee326 100644 --- a/ui/src/features/agent/applicationAdapters.ts +++ b/ui/src/features/agent/applicationAdapters.ts @@ -1,14 +1,42 @@ import { useStore } from '../../stores/useStore' +import type { CommandResult } from '../../lib/commandContract' +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, AgentGenerateComicAction, AgentGenerateSeriesPlanAction, AgentGenerateStorySectionAction, AgentGenerateStorySongAction, AgentGenerateStoryVisualsAction, AgentRenderSeriesShotsAction, AgentReviewSeriesAttemptsAction, AgentStageStoryComicAction, AgentStartDirectorProductionAction, AgentStageStoryMusicVideoAction, AgentStageStoryVideoAction, AgentUpdateSeriesEpisodeAction, AgentUpdateStoryAction } from './agentActions' -import type { AgentExecutionTarget } from './agentContract' +import { + executionKey, + executionReport, + rememberExecution, + reuseExecution, + type AgentExecutionReport, + type AgentExecutionTarget, +} from './agentContract' import { openAgentActivityDetails, requestAgentSceneControl, requestAgentSceneRhythm, requestAgentSceneWorkflow, 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 { AgentCreateVideoEditorProjectAction, AgentOpenVideoEditorProjectAction } from './videoEditorActions' -import type { AgentApplyCharacterKitPresetAction, AgentAttachCharacterKitReferencesAction, AgentBuildCharacterKitAction, AgentCreateCharacterKitAction, AgentOpenCharacterKitAction, AgentOpenCharacterKitRigAction, AgentTrackCharacterKitJobAction } from './characterKitActions' +import type { + AgentAddVideoEditorAudioAction, + AgentAddVideoEditorClipsAction, + AgentCreateVideoEditorProjectAction, + AgentExportVideoEditorAction, + AgentOpenVideoEditorProjectAction, + AgentOrderVideoEditorClipsAction, + AgentTrimVideoEditorClipAction, +} from './videoEditorActions' +import type { + AgentApplyCharacterKitPresetAction, + AgentAttachCharacterKitReferencesAction, + AgentBuildCharacterKitAction, + AgentCreateCharacterKitAction, + AgentOpenCharacterKitAction, + AgentOpenCharacterKitRigAction, + AgentTrackCharacterKitJobAction, + AgentUpdateCharacterKitAction, +} from './characterKitActions' export interface AdapterOutcome { message: string @@ -25,6 +53,7 @@ export interface AdapterOutcome { beatCount?: number downbeatCount?: number rhythmGrid?: AgentRhythmGrid + report?: AgentExecutionReport } export interface StudioAdapter { @@ -63,12 +92,25 @@ export interface ComicAdapter { open(): Promise create(action: AgentCreateComicAction): Promise generate(action: AgentGenerateComicAction, expectedProjectId?: string): Promise + generatePanel(pageNumber: number, panelNumber: number): Promise +} +export interface VideoEditorAdapter { + open(): Promise + create(action: AgentCreateVideoEditorProjectAction): Promise + openProject(action: AgentOpenVideoEditorProjectAction): Promise + addClips(action: AgentAddVideoEditorClipsAction): Promise + orderClips(action: AgentOrderVideoEditorClipsAction): Promise + trimClip(action: AgentTrimVideoEditorClipAction): Promise + addAudio(action: AgentAddVideoEditorAudioAction): Promise + validateTimeline(): Promise + exportProject(action: AgentExportVideoEditorAction): Promise + trackExport(): Promise } -export interface VideoEditorAdapter { open(): Promise; create(action: AgentCreateVideoEditorProjectAction): Promise; openProject(action: AgentOpenVideoEditorProjectAction): Promise } export interface CharacterKitAdapter { open(creator?: boolean): Promise create(action: AgentCreateCharacterKitAction): Promise openKit(action: AgentOpenCharacterKitAction): Promise + update(action: AgentUpdateCharacterKitAction): Promise attachReference(action: AgentAttachCharacterKitReferencesAction): Promise build(action: AgentBuildCharacterKitAction): Promise openRig(action: AgentOpenCharacterKitRigAction): Promise @@ -168,6 +210,56 @@ async function navigate(tab: AgentTab): Promise { } } +const SLICE_TABS: Record = { + character_kit: 'character_kit', + character_creator: 'character_creator', + video_editor: 'video_editor', +} + +async function applySliceNavigation(result: CommandResult): Promise { + const destination = result.navigationTarget?.destination + const tab = destination ? SLICE_TABS[destination] : undefined + if (tab) await navigate(tab) +} + +function kitNameFromResult(result: CommandResult): string { + const id = result.entities[0]?.id + const library = rememberedCharacterKitLibrary() + return (id && library?.kits[id]?.name) || id || 'Character Kit' +} + +function entityTarget(result: CommandResult, title: string, fallbackKind = 'application_section'): AgentExecutionTarget { + const entity = result.entities[0] + return { + kind: entity?.kind || fallbackKind, + id: entity?.id || title, + title, + } +} + +async function kitOutcome(result: CommandResult, message: string): Promise { + await applySliceNavigation(result) + return { + message, + target: entityTarget(result, kitNameFromResult(result), 'character_kit'), + taskId: result.taskIds[0], + pipelineId: result.pipelineIds[0], + } +} + +async function editorOutcome(result: CommandResult, message: string, extra: Partial = {}): Promise { + await applySliceNavigation(result) + const id = result.entities[0]?.id || 'video_editor' + return { + message, + target: { kind: 'video_editor', id, title: id }, + taskId: result.taskIds[0], + pipelineId: result.pipelineIds[0], + outputNames: result.artifacts.map(item => item.id), + ...extra, + } +} + export function createDefaultApplicationAdapters(): WizardApplicationAdapters { const adapters = {} as WizardApplicationAdapters adapters.studio = { @@ -260,88 +352,259 @@ export function createDefaultApplicationAdapters(): WizardApplicationAdapters { adapters.seriesLab = { open: () => navigate('series_lab'), async createEpisode(action) { - const { createFilledSeriesEpisode } = await import('./labActions') - const message = await createFilledSeriesEpisode(action) - return seriesEpisodeOutcome(message) + const { createEpisode } = await import('../series/adapters') + return presentSeriesSliceResult(await createEpisode(action)) }, async updateEpisode(action) { - const { updateSeriesEpisode } = await import('./labActions') - const message = await updateSeriesEpisode(action) - return seriesEpisodeOutcome(message) + const { updateEpisode } = await import('../series/adapters') + return presentSeriesSliceResult(await updateEpisode(action)) }, async generatePlan(action) { - const { generateSeriesPlan } = await import('./labActions') - const message = await generateSeriesPlan(action) - const { getAgentSeriesPlanJob } = await import('./agentUiBus') - const job = getAgentSeriesPlanJob() - if (!job?.jobId) throw new Error('Series Lab no devolvió el job de planificación iniciado.') - const outcome = await seriesEpisodeOutcome(message) - if (job.episodeId !== outcome.target.id) throw new Error('El job de planificación no pertenece al episodio canónico abierto.') - return { ...outcome, taskId: job.jobId } + const { generatePlan } = await import('../series/adapters') + const outcome = await presentSeriesSliceResult(await generatePlan(action)) + if (!outcome.taskId) throw new Error('Series Lab no devolvió el job de planificación iniciado.') + return outcome }, async applyPlan(action) { - const { applySeriesPlan } = await import('./labActions') - const message = await applySeriesPlan(action) - return { ...await seriesEpisodeOutcome(message), taskId: action.jobId || undefined } + const { applyPlan } = await import('../series/adapters') + return presentSeriesSliceResult(await applyPlan(action)) }, async renderShots(action) { - const { renderSeriesShots } = await import('./labActions') - const message = await renderSeriesShots(action) - const { getAgentSeriesRenderJob } = await import('./agentUiBus') - const job = getAgentSeriesRenderJob() - if (!job?.jobId) throw new Error('Series Lab no devolvió el job de render iniciado.') - const outcome = await seriesEpisodeOutcome(message) - if (job.episodeId !== outcome.target.id) throw new Error('El job de render no pertenece al episodio canónico abierto.') - return { ...outcome, taskId: job.jobId } + const { renderShots } = await import('../series/adapters') + const outcome = await presentSeriesSliceResult(await renderShots(action)) + if (!outcome.taskId) throw new Error('Series Lab no devolvió el job de render iniciado.') + return outcome }, async reviewAttempts(action) { - const { reviewSeriesAttempts } = await import('./labActions') - return seriesEpisodeOutcome(await reviewSeriesAttempts(action)) + const { reviewAttempts } = await import('../series/adapters') + return presentSeriesSliceResult(await reviewAttempts(action)) }, async commitCanon(action) { - const { commitSeriesCanonDelta } = await import('./labActions') - return seriesEpisodeOutcome(await commitSeriesCanonDelta(action)) + const { commitCanon } = await import('../series/adapters') + return presentSeriesSliceResult(await commitCanon(action)) }, async assembleEpisode(action) { - const { assembleSeriesEpisode } = await import('./labActions') - const message = await assembleSeriesEpisode(action) - const { getAgentSeriesAssemblyJob } = await import('./agentUiBus') - const job = getAgentSeriesAssemblyJob() - if (!job?.jobId) throw new Error('Series Lab no devolvió el job de ensamblado iniciado.') - const outcome = await seriesEpisodeOutcome(message) - if (job.episodeId !== outcome.target.id) throw new Error('El job de ensamblado no pertenece al episodio canónico abierto.') - return { ...outcome, taskId: job.jobId } + const { assembleEpisode } = await import('../series/adapters') + const outcome = await presentSeriesSliceResult(await assembleEpisode(action)) + if (!outcome.taskId) throw new Error('Series Lab no devolvió el job de ensamblado iniciado.') + return outcome }, } adapters.comic = { open: () => navigate('comics'), async create(action) { - await navigate('comics') - const { createFilledComic } = await import('./labActions') - const message = await createFilledComic(action) - const { useComicStore } = await import('../comics/store') - const project = useComicStore.getState().project - return { message, target: { kind: 'comic', id: project.id, title: project.title } } + const { create } = await import('../comics/adapters') + return presentComicSliceResult(await create(action)) }, async generate(action, expectedProjectId) { - await navigate('comics') - const { generateFilledComicArtwork } = await import('./labActions') - const result = await generateFilledComicArtwork(action, undefined, expectedProjectId) + const { bindGenerateComicTarget } = await import('./agentContract') const { useComicStore } = await import('../comics/store') - const project = useComicStore.getState().project - return { ...result, target: { kind: 'comic', id: project.id, title: project.title } } + const current = useComicStore.getState().project + bindGenerateComicTarget(expectedProjectId, current.id, current.title) + const { generate } = await import('../comics/adapters') + return presentComicSliceResult(await generate(action)) + }, + async generatePanel(pageNumber, panelNumber) { + const { generatePanel } = await import('../comics/adapters') + return presentComicSliceResult(await generatePanel(pageNumber, panelNumber)) + }, + } + adapters.videoEditor = { + open: () => navigate('video_editor'), + async create(action) { + const { createProject } = await import('../video-editor/adapters') + const result = await createProject({ projectName: action.projectName }) + const name = result.entities[0]?.id || action.projectName.trim() || 'my_video' + return editorOutcome(result, `He creado el proyecto de Video Editor “${name}”.`) + }, + async openProject(action) { + const { openProject } = await import('../video-editor/adapters') + const result = await openProject({ projectName: action.projectName }) + const { loadEditorDraft } = await import('../video-editor/editorDraft') + const draft = loadEditorDraft(useStore.getState().activeWorkspace || 'default') + return editorOutcome(result, `He abierto Video Editor “${draft.projectName}” con ${draft.clips.length} clips.`) + }, + async addClips(action) { + const { addClips } = await import('../video-editor/adapters') + const result = await addClips({ outputNames: action.outputNames }) + const name = result.entities[0]?.id || 'Video Editor' + return editorOutcome(result, `He añadido ${action.outputNames.length} clips exactos a “${name}”.`) + }, + async orderClips(action) { + const { orderClips } = await import('../video-editor/adapters') + const result = await orderClips({ clipNames: action.clipNames }) + return editorOutcome(result, `He reordenado ${action.clipNames.length} clips.`) + }, + async trimClip(action) { + const { trimClip } = await import('../video-editor/adapters') + const result = await trimClip({ + clipName: action.clipName, + trimStart: action.trimStart, + trimEnd: action.trimEnd, + }) + return editorOutcome(result, `He recortado “${action.clipName}” a ${action.trimStart}-${action.trimEnd}s.`) + }, + async addAudio(action) { + const { addAudio } = await import('../video-editor/adapters') + const result = await addAudio({ clipName: action.clipName, outputName: action.outputName }) + const name = result.entities[0]?.id || 'Video Editor' + return editorOutcome(result, `He configurado “${action.outputName}” como banda sonora de “${name}”.`) + }, + async validateTimeline() { + const { validateTimeline } = await import('../video-editor/adapters') + const result = await validateTimeline() + const { loadEditorDraft } = await import('../video-editor/editorDraft') + const { sequenceTotalDuration } = await import('../video-editor/editorTimeline') + const draft = loadEditorDraft(useStore.getState().activeWorkspace || 'default') + const duration = sequenceTotalDuration(draft.clips) + return editorOutcome(result, `Línea de tiempo válida: ${draft.clips.length} clips, ${duration.toFixed(1)}s.`) + }, + async exportProject(action) { + if (!action.confirm) throw new Error('Exportar requiere confirm=true.') + const { loadEditorDraft } = await import('../video-editor/editorDraft') + const { sequenceTotalDuration } = await import('../video-editor/editorTimeline') + const workspace = useStore.getState().activeWorkspace || 'default' + const draft = loadEditorDraft(workspace) + const key = executionKey({ + workspace, + type: 'export_video_editor', + targetId: draft.projectName, + params: { + clips: draft.clips.map(clip => clip.name), + duration: sequenceTotalDuration(draft.clips), + soundtrack: draft.soundtrack ? { + name: draft.soundtrack.name, + source: draft.soundtrack.source, + trimStart: draft.soundtrack.trimStart, + trimEnd: draft.soundtrack.trimEnd, + volume: draft.soundtrack.volume, + loop: draft.soundtrack.loop, + } : null, + }, + }) + const reused = reuseExecution(key) + if (reused) { + return { + message: `Reutilizo la ejecución anterior (${reused.state}). ${reused.message}`, + target: reused.target || { kind: 'video_editor', id: draft.projectName, title: draft.projectName }, + taskId: reused.taskId, + outputNames: reused.outputNames, + report: reused, + } + } + const { exportProject } = await import('../video-editor/adapters') + const result = await exportProject({ confirm: true }) + const jobId = result.taskIds[0] + const message = `He encolado la exportación de “${draft.projectName}” (${jobId}).` + const report = executionReport({ + state: 'queued', + message, + recoverable: true, + target: { kind: 'video_editor', id: draft.projectName, title: draft.projectName }, + taskId: jobId, + executionKey: key, + }) + rememberExecution(report) + return editorOutcome(result, message, { report }) + }, + async trackExport() { + const { trackExport } = await import('../video-editor/adapters') + const result = await trackExport() + const jobId = result.taskIds[0] + const artifact = result.artifacts[0] + const jobMessage = typeof artifact?.metadata?.message === 'string' ? artifact.metadata.message : '' + const jobStatus = typeof artifact?.metadata?.status === 'string' ? artifact.metadata.status : result.status + const state = jobStatus === 'completed' || result.status === 'completed' ? 'completed' + : jobStatus === 'failed' || jobStatus === 'cancelled' || result.status === 'failed' ? 'failed' + : jobStatus === 'queued' || jobStatus === 'waiting_resource' ? 'queued' + : 'running' + const message = `Exportación ${jobId}: ${jobStatus}. ${jobMessage}`.trim() + const outputNames = result.artifacts.filter(item => item.kind === 'video').map(item => item.id) + const report = executionReport({ + state, + message, + recoverable: state === 'failed', + target: { kind: 'video_editor', id: result.entities[0]?.id || 'video_editor', title: result.entities[0]?.id || 'video_editor' }, + taskId: jobId, + outputNames, + }) + return editorOutcome(result, message, { report, outputNames }) }, } - adapters.videoEditor = { open: () => navigate('video_editor'), async create(action) { const { createAgentVideoEditorProject } = await import('./videoEditorActions'); const result = await createAgentVideoEditorProject(action); return { message: result.message, target: result.report.target! } }, async openProject(action) { const { openAgentVideoEditorProject } = await import('./videoEditorActions'); const result = await openAgentVideoEditorProject(action); return { message: result.message, target: result.report.target! } } } adapters.characterKit = { open: creator => navigate(creator ? 'character_creator' : 'character_kit'), - async create(action) { const { createAgentCharacterKit } = await import('./characterKitActions'); const result = await createAgentCharacterKit(action); return { message: result.message, target: result.report.target! } }, - async openKit(action) { const { openAgentCharacterKit } = await import('./characterKitActions'); const result = await openAgentCharacterKit(action); return { message: result.message, target: result.report.target! } }, - async attachReference(action) { const { attachAgentCharacterKitReferences } = await import('./characterKitActions'); const result = await attachAgentCharacterKitReferences(action); return { message: result.message, target: result.report.target! } }, - async build(action) { const { buildAgentCharacterKit } = await import('./characterKitActions'); const result = await buildAgentCharacterKit(action); return { message: result.message, target: result.report.target! } }, - async openRig(action) { const { openAgentCharacterKitRig } = await import('./characterKitActions'); const result = await openAgentCharacterKitRig(action); return { message: result.message, target: result.report.target! } }, - async applyPreset(action) { const { applyAgentCharacterKitPreset } = await import('./characterKitActions'); const result = await applyAgentCharacterKitPreset(action); return { message: result.message, target: result.report.target! } }, - async trackJob(action) { const { trackAgentCharacterKitJob } = await import('./characterKitActions'); const result = await trackAgentCharacterKitJob(action); return { message: result.message, target: result.report.target! } }, + async create(action) { + const { createKit } = await import('../characters/adapters') + const result = await createKit({ name: action.name, style: action.style }) + const name = kitNameFromResult(result) + const message = result.navigationTarget?.section === 'existing' + ? `He abierto el Character Kit existente “${name}”.` + : `He creado el Character Kit “${name}”. Todavía no he generado poses.` + return kitOutcome(result, message) + }, + async openKit(action) { + const { openKit } = await import('../characters/adapters') + const result = await openKit({ kitName: action.kitName }) + return kitOutcome(result, `He abierto Character Kit “${kitNameFromResult(result)}”.`) + }, + async update(action) { + const { updateKit } = await import('../characters/adapters') + const result = await updateKit({ + kitName: action.kitName, + name: action.name, + lookNotes: action.lookNotes, + style: action.style, + }) + return kitOutcome(result, `He actualizado la identidad de “${kitNameFromResult(result)}”.`) + }, + async attachReference(action) { + const { attachReference } = await import('../characters/adapters') + const result = await attachReference({ kitName: action.kitName, outputNames: action.outputNames }) + return kitOutcome( + result, + `He adjuntado “${action.outputNames[0]}” como referencia de identidad de “${kitNameFromResult(result)}”.`, + ) + }, + async build(action) { + const { buildKit } = await import('../characters/adapters') + const result = await buildKit({ kitName: action.kitName }) + return kitOutcome(result, `He montado el kit “${kitNameFromResult(result)}” con la pose base. No he lanzado generación.`) + }, + async openRig(action) { + const { openRig } = await import('../characters/adapters') + const result = await openRig({ kitName: action.kitName }) + return kitOutcome(result, `He abierto el Face Rig de “${kitNameFromResult(result)}”.`) + }, + async applyPreset(action) { + const { applyPreset } = await import('../characters/adapters') + const result = await applyPreset({ kitName: action.kitName, presetId: action.presetId }) + return kitOutcome(result, `He aplicado el preset “${action.presetId}” al Face Rig de “${kitNameFromResult(result)}”.`) + }, + async trackJob(action) { + const { trackJob } = await import('../characters/adapters') + const result = await trackJob({ kitName: action.kitName }) + const { inspectCanonicalQueue } = await import('./queueActions') + const inspected: unknown = await inspectCanonicalQueue('active') + const queue = typeof inspected === 'string' + ? inspected + : (inspected && typeof inspected === 'object' && 'artifacts' in inspected + ? String((inspected as CommandResult).artifacts[0]?.metadata?.summary || '') + : '') + openAgentActivityDetails() + const name = kitNameFromResult(result) + const message = `Sigo el trabajo de “${name}”. ${queue}` + const target = entityTarget(result, name, 'character_kit') + return { + message, + target, + report: executionReport({ + state: 'running', + message, + target, + recoverable: false, + }), + } + }, } adapters.queue = { async openActivity() { @@ -412,4 +675,50 @@ async function seriesEpisodeOutcome(message: string): Promise { return { message, target: { kind: 'series_episode', id: episode.id, title: `${series.title} · ${episode.title}` } } } +async function presentSeriesSliceResult(result: CommandResult): Promise { + await navigate('series_lab') + const { + clearAgentSeriesPlanJob, + notifyAgentSeriesAssemblyJob, + notifyAgentSeriesPlanJob, + notifyAgentSeriesRenderJob, + openAgentSeriesReviewView, + openAgentSeriesSection, + } = await import('./agentUiBus') + const section = result.navigationTarget?.section + if (section === 'setup' || section === 'canon' || section === 'episode' || section === 'shots' || section === 'review') { + openAgentSeriesSection(section) + } + if (result.navigationTarget?.anchor === 'finish') openAgentSeriesReviewView('finish') + const meta = result.artifacts[0]?.metadata || {} + const channel = typeof meta.channel === 'string' ? meta.channel : '' + const job = meta.job && typeof meta.job === 'object' ? meta.job as Record : null + if (channel === 'series_plan' && job) notifyAgentSeriesPlanJob(job as unknown as SeriesJobStatus) + if (channel === 'series_render' && job) notifyAgentSeriesRenderJob(job as unknown as SeriesJobStatus) + if (channel === 'series_assembly' && job) notifyAgentSeriesAssemblyJob(job as unknown as SeriesAssemblyJob) + if (channel === 'series_plan_clear') clearAgentSeriesPlanJob(result.entities[0]?.id || '') + const summary = typeof meta.summary === 'string' ? meta.summary : 'Series Lab listo.' + const outcome = await seriesEpisodeOutcome(summary) + if (channel === 'series_plan' || channel === 'series_render' || channel === 'series_assembly') { + const jobEpisodeId = typeof job?.episodeId === 'string' ? job.episodeId : '' + if (jobEpisodeId && jobEpisodeId !== outcome.target.id) { + throw new Error('El job de Series Lab no pertenece al episodio canónico abierto.') + } + } + return { ...outcome, taskId: result.taskIds[0] } +} + +async function presentComicSliceResult(result: CommandResult): Promise { + await navigate('comics') + const meta = result.artifacts[0]?.metadata || {} + const summary = typeof meta.summary === 'string' ? meta.summary : 'Comics listo.' + const title = typeof meta.title === 'string' ? meta.title : (result.entities[0]?.id || 'comic') + const state = result.status === 'partial' ? 'partial' : result.status === 'failed' ? 'failed' : 'completed' + return { + message: summary, + target: { kind: 'comic', id: result.entities[0]?.id || title, title }, + state, + } +} + export const defaultApplicationAdapters = createDefaultApplicationAdapters() diff --git a/ui/src/features/agent/characterKitActions.ts b/ui/src/features/agent/characterKitActions.ts index 81eae718..3db5acc5 100644 --- a/ui/src/features/agent/characterKitActions.ts +++ b/ui/src/features/agent/characterKitActions.ts @@ -1 +1,58 @@ -export * from '../characters/actions' +import type { CharacterKitStyle } from '../../lib/characterKit' + +export { + applyAgentCharacterKitPreset, + attachAgentCharacterKitReferences, + buildAgentCharacterKit, + createAgentCharacterKit, + openAgentCharacterKit, + openAgentCharacterKitRig, + trackAgentCharacterKitJob, + updateAgentCharacterKit, +} from '../characters/actions' + +export interface AgentCreateCharacterKitAction { + type: 'create_character_kit' + name: string + style: CharacterKitStyle +} + +export interface AgentOpenCharacterKitAction { + type: 'open_character_kit' + kitName: string +} + +export interface AgentUpdateCharacterKitAction { + type: 'update_character_kit' + kitName: string + name: string + lookNotes: string + style: CharacterKitStyle | '' +} + +export interface AgentAttachCharacterKitReferencesAction { + type: 'attach_character_kit_references' + kitName: string + outputNames: string[] +} + +export interface AgentBuildCharacterKitAction { + type: 'build_character_kit' + kitName: string +} + +export interface AgentOpenCharacterKitRigAction { + type: 'open_character_kit_rig' + kitName: string +} + +export interface AgentApplyCharacterKitPresetAction { + type: 'apply_character_kit_preset' + kitName: string + presetId: string +} + +export interface AgentTrackCharacterKitJobAction { + type: 'track_character_kit_job' + kitName: string +} diff --git a/ui/src/features/agent/comicLabActions.ts b/ui/src/features/agent/comicLabActions.ts index b79d62ab..031f2e73 100644 --- a/ui/src/features/agent/comicLabActions.ts +++ b/ui/src/features/agent/comicLabActions.ts @@ -1,389 +1 @@ -import { useStore } from '../../stores/useStore' -import type { AgentCreateComicAction } from './agentActions' - -function showComics(): void { - const state = useStore.getState() - state.setSettingsOpen(false) - state.setDashboardOpen(false) - state.setMediaFilter('comics') - state.setSidebarOpen(false) -} - -export async function createFilledComic(action: AgentCreateComicAction): Promise { - const [{ useComicStore }, { comicId, projectFromPlan }] = await Promise.all([ - import('../comics/store'), - import('../comics/model'), - ]) - const characters = (action.characters.length ? action.characters : [{ - name: 'Protagonista', - role: 'Protagonista', - personality: '', - desire: '', - flaw: '', - appearance: 'Silueta clara y reconocible', - voice: '', - }]).map((character, index) => ({ - id: comicId('character'), - name: character.name || `Personaje ${index + 1}`, - description: character.appearance || character.role || character.name, - role: character.role || (index ? 'Secundario' : 'Protagonista'), - personality: character.personality, - motivation: character.desire, - voice: character.voice, - wardrobe: character.appearance || 'Vestuario fijo y reconocible durante todo el cómic.', - visualNotes: [character.appearance, action.styleName, 'Silueta, escala y paleta constantes.'].filter(Boolean).join('. '), - negativePrompt: 'inconsistent face, changed wardrobe, duplicate character, extra limbs, unreadable silhouette', - referenceAssetIds: [], - locked: false, - })) - const panels = action.panels.length ? action.panels : [ - { caption: action.synopsis || action.title, dialogue: '', sfx: '', scene: action.synopsis }, - ] - const requestedPages = action.pages.length ? action.pages : [{ title: 'Página 1', stage: '', panels }] - const allPanels = requestedPages.flatMap(page => page.panels) - const ending = allPanels.at(-1)?.dialogue - || allPanels.at(-1)?.caption - || `El conflicto de “${action.title}” se resuelve con una consecuencia visual clara.` - const castBible = characters.map(character => [ - `${character.name} (${character.role || 'personaje'})`, - character.description, - character.personality, - character.motivation, - ].filter(Boolean).join(': ')).join('\n') - const storyContext = [ - `Premisa: ${action.synopsis || action.title}`, - `Personajes:\n${castBible}`, - `Progresión: ${requestedPages.map((page, index) => `${index + 1}. ${page.title}: ${page.stage || page.panels[0]?.scene || page.panels[0]?.caption}`).join(' → ')}`, - `Final: ${ending}`, - ].join('\n\n') - const worldContext = [ - `Universo visual de “${action.title}”.`, - action.synopsis, - `Mantener localizaciones, época, escala y utilería coherentes durante ${requestedPages.length} páginas y ${allPanels.length} viñetas.`, - ].filter(Boolean).join(' ') - const forbiddenElements = [ - 'No cambiar el diseño, la edad aparente, la paleta ni el vestuario de los personajes entre viñetas.', - 'No añadir texto, bocadillos, marcos, cuadrículas, logotipos ni marcas de agua dentro de las imágenes generadas.', - 'No duplicar personajes ni introducir elementos ajenos a la escena.', - ].join(' ') - const planId = comicId('plan') - const plan = { - version: 1 as const, - id: planId, - title: action.title, - logline: action.synopsis, - synopsis: action.synopsis || action.title, - language: action.language || 'Español', - styleBible: action.styleName || 'Tira cómica clara, 4 viñetas', - characters, - storyStructure: requestedPages.map((page, pageIndex) => ({ - pageNumber: pageIndex + 1, stage: page.stage || page.title, - goal: `Representar con claridad la etapa “${page.title}”.`, - turningPoint: page.panels.at(-1)?.dialogue || page.panels.at(-1)?.caption || page.stage || page.title, - })), - pages: requestedPages.map((page, pageIndex) => ({ - pageNumber: pageIndex + 1, - layoutHint: 'grid' as const, - panels: page.panels.map((panel, index) => { - const beat = panel.scene || panel.caption || panel.dialogue || action.synopsis - const who = characters.map(character => `${character.name}: ${character.description}`).join('; ') - return { - id: comicId('panel-plan'), - order: index + 1, - narrativeRole: `${page.title} · viñeta ${index + 1}`, - sceneDescription: beat, - imagePrompt: [ - `Single comic panel for "${action.title}".`, - action.styleName, - beat ? `Scene: ${beat}.` : '', - who ? `Characters: ${who}.` : '', - 'Clear acting, readable silhouette, no lettering, no balloons, no captions.', - ].filter(Boolean).join(' '), - characters: characters.map(character => character.id), - framing: 'medium', - dialogue: panel.dialogue ? [{ text: panel.dialogue, bubbleType: 'speech' as const }] : [], - captions: panel.caption ? [panel.caption] : [], - soundEffects: panel.sfx ? [panel.sfx] : [], - continuityNotes: `Conservar identidad, vestuario, paleta, iluminación y eje espacial respecto a la viñeta ${Math.max(1, index)}.`, - } - }), - })), - } - const project = projectFromPlan(plan) - if (action.styleName) { - project.style = { - ...project.style, - name: action.styleName, - promptSuffix: `${action.styleName}. Consistent character design, readable acting, coherent palette and continuity across panels.`, - } - } - const studio = useStore.getState() - const provider = action.imageProvider === 'minimax' ? 'minimax' as const - : action.imageProvider === 'maestro' ? 'maestro' as const - : studio.productionProfile.image.provider === 'minimax' ? 'minimax' as const : 'maestro' as const - const imageModel = action.imageModel || (provider === 'minimax' ? 'image-01' : studio.productionProfile.image.model || studio.selectedModelPerMode.image || '') - project.director = { - planId, - provider, - imageModel, - input: { - useGlobalProfile: true, - premise: action.synopsis || action.title, - storyContext, - productionMode: 'comic', - pageCount: requestedPages.length, - language: action.language || 'Español', - format: project.format.preset, - panelsPerPage: Math.max(...requestedPages.map(page => page.panels.length)), - genre: 'Comedy', - tone: 'Warm', - audience: 'General', - artStyle: action.styleName, - worldContext, - forbiddenElements, - dialogueDensity: 'medium', - provider, - imageModel, - characters, - ending, - }, - plan, - completedPanelIds: [], - failedPanelIds: [], - panelJobs: {}, - factualBiography: action.factualBiography === true, - scriptVersion: 1, - scriptApprovedAt: new Date().toISOString(), - } - useComicStore.getState().setProject(project) - useComicStore.setState({ dirty: true }) - showComics() - const stored = useComicStore.getState().project - const storedPanels = stored.pages.reduce((sum, page) => ( - sum + page.elements.filter(element => element.type === 'panel' && !element.parentId).length - ), 0) - if (stored.pages.length !== requestedPages.length || storedPanels !== allPanels.length) { - throw new Error(`El cómic guardado tiene ${stored.pages.length} páginas y ${storedPanels} viñetas; pediste ${requestedPages.length} páginas y ${allPanels.length} viñetas.`) - } - return `He creado desde cero “${project.title}” con ${requestedPages.length} páginas, ${characters.length} personajes y ${allPanels.length} viñetas. Comic Director usará ${provider === 'minimax' ? 'MiniMax image-01' : imageModel || 'el modelo local seleccionado'}. No he generado imágenes todavía.` -} - -export async function generateFilledComicArtwork( - action: import('./agentActions').AgentGenerateComicAction, - onProgress?: (message: string) => void, - expectedProjectId?: string, -): Promise<{ message: string; state: 'completed' | 'partial' | 'failed'; generated: number; failed: number; cancelled: boolean }> { - showComics() - const [{ useComicStore }, { comicId }, { generateDirectorArtwork }] = await Promise.all([ - import('../comics/store'), - import('../comics/model'), - import('../comics/generateArtwork'), - ]) - const { bindGenerateComicTarget } = await import('./agentContract') - const state = useComicStore.getState() - bindGenerateComicTarget(expectedProjectId, state.project.id, state.project.title) - if (!state.project.director) { - const project = state.project - const characters = (project.characters.length ? project.characters : [{ - id: comicId('character'), - name: 'Protagonista', - description: 'Silueta clara', - locked: false, - }]).map(character => ({ - ...character, - role: character.role || 'Personaje principal', - personality: character.personality || 'Expresivo y coherente con el tono del cómic.', - motivation: character.motivation || project.synopsis || 'Resolver el conflicto de la historia.', - voice: character.voice || 'Voz breve, clara y diferenciada.', - wardrobe: character.wardrobe || character.description || 'Vestuario fijo y reconocible.', - visualNotes: character.visualNotes || `${character.description}. Silueta, escala y paleta constantes.`, - negativePrompt: character.negativePrompt || 'inconsistent face, changed wardrobe, duplicate character, extra limbs', - referenceAssetIds: character.referenceAssetIds || [], - })) - const pages = project.pages.map((page, pageIndex) => { - const panels = page.elements - .filter(element => element.type === 'panel' && !element.parentId) - .sort((left, right) => left.zIndex - right.zIndex) - return { - pageNumber: pageIndex + 1, - layoutHint: 'grid' as const, - panels: panels.map((panel, index) => { - const texts = page.elements.filter(element => element.type === 'text' && element.parentId === panel.id) - const captions = texts - .filter(text => text.type === 'text' && (text.letteringType === 'caption' || text.bubble === 'caption')) - .map(text => text.type === 'text' ? text.content : '') - const soundEffects = texts - .filter(text => text.type === 'text' && (text.letteringType === 'sound-effect' || text.bubble === 'burst')) - .map(text => text.type === 'text' ? text.content : '') - const dialogue = texts - .filter(text => text.type === 'text' && (text.letteringType === 'dialogue' || text.bubble === 'speech')) - .map(text => text.type === 'text' ? text.content : '') - .filter(content => !captions.includes(content) && !soundEffects.includes(content)) - const beat = captions[0] || dialogue[0] || project.synopsis || project.title - return { - id: comicId('panel-plan'), - order: index + 1, - narrativeRole: `Viñeta ${index + 1}`, - sceneDescription: beat, - imagePrompt: [ - `Single comic panel for "${project.title}".`, - project.style.name, - beat ? `Scene: ${beat}.` : '', - 'Clear acting, readable silhouette, no lettering, no balloons, no captions.', - ].filter(Boolean).join(' '), - characters: characters.map(character => character.id), - framing: 'medium', - dialogue: dialogue.map(text => ({ text, bubbleType: 'speech' as const })), - captions, - soundEffects, - continuityNotes: `Conservar identidad, vestuario, paleta y eje espacial respecto a la viñeta ${Math.max(1, index)}.`, - } - }), - } - }) - if (!pages.some(page => page.panels.length)) { - throw new Error('El cómic abierto no tiene viñetas que dibujar.') - } - const studio = useStore.getState() - const provider = studio.productionProfile.image.provider === 'minimax' ? 'minimax' as const : 'maestro' as const - const imageModel = studio.productionProfile.image.model || studio.selectedModelPerMode.image || '' - const plan = { - version: 1 as const, - id: comicId('plan'), - title: project.title, - logline: project.synopsis, - synopsis: project.synopsis || project.title, - language: project.language, - styleBible: project.style.name, - characters, - storyStructure: pages.map((page, index) => ({ - pageNumber: page.pageNumber, - stage: index === 0 ? 'Planteamiento y complicación' : `Desarrollo ${index + 1}`, - goal: project.synopsis || `Hacer avanzar “${project.title}”.`, - turningPoint: page.panels.at(-1)?.dialogue.at(-1)?.text - || page.panels.at(-1)?.captions.at(-1) - || `Cerrar el beat de la página ${page.pageNumber}.`, - })), - pages, - } - const storyContext = [ - `Premisa: ${project.synopsis || project.title}`, - `Personajes: ${characters.map(character => `${character.name}: ${character.description}`).join('; ')}`, - `Estructura: ${plan.storyStructure.map(beat => beat.turningPoint).join(' → ')}`, - ].join('\n\n') - useComicStore.getState().patchProject({ - characters, - director: { - planId: plan.id, - provider, - imageModel, - input: { - useGlobalProfile: true, - premise: project.synopsis || project.title, - storyContext, - productionMode: 'comic', - pageCount: pages.length, - language: project.language, - format: project.format.preset, - panelsPerPage: Math.max(1, pages[0]?.panels.length || 4), - genre: 'Comedy', - tone: 'Warm', - audience: 'General', - artStyle: project.style.name, - worldContext: `Universo visual de “${project.title}”. Mantener época, localizaciones, escala y utilería coherentes entre páginas.`, - forbiddenElements: 'No cambiar identidades ni vestuario. No añadir texto, cuadrículas, marcos, logos o marcas de agua dentro de la ilustración.', - dialogueDensity: 'medium', - provider, - imageModel, - characters, - ending: plan.storyStructure.at(-1)?.turningPoint, - }, - plan, - completedPanelIds: [], - failedPanelIds: [], - panelJobs: {}, - scriptVersion: 1, - scriptApprovedAt: new Date().toISOString(), - }, - }) - } - if (!useComicStore.getState().project.director) { - throw new Error('No hay un cómic con plan de Director abierto. Pide primero un cómic de ejemplo o crea uno con tema.') - } - const current = useComicStore.getState().project.director! - if (current.factualBiography && !current.biographyReviewedAt && !action.biographyReview) { - throw new Error('Este cómic es una biografía factual. Confirma hechos, inferencias y dramatización (biography_review=true) antes de dibujar; no inventaré familiares, citas ni acontecimientos.') - } - if (action.biographyReview && !current.biographyReviewedAt) { - useComicStore.getState().patchProject({ - director: { ...current, biographyReviewedAt: new Date().toISOString() }, - }) - } - if (action.imageProvider !== 'keep' || action.imageModel) { - const latest = useComicStore.getState().project.director! - const provider = action.imageProvider === 'keep' ? latest.provider : action.imageProvider - const imageModel = action.imageModel || (provider === 'minimax' ? 'image-01' : latest.imageModel) - useComicStore.getState().patchProject({ director: { ...latest, provider, imageModel, input: { ...latest.input, provider, imageModel } } }) - } - const pages = action.pilot ? [1] : (action.pages || []) - const result = await generateDirectorArtwork({ - scope: action.scope || 'missing', - pages: pages.length ? pages : undefined, - force: action.scope === 'all', - onProgress: (message, current, total) => { - onProgress?.(`${message} (${current}/${total})`) - }, - }) - const provider = useComicStore.getState().project.director?.provider - const providerLabel = provider === 'minimax' ? 'MiniMax image-01' : 'el proveedor local configurado' - if (!result.total) { - return { message: 'Todas las viñetas de este cómic ya tenían dibujo.', state: 'completed', generated: 0, failed: 0, cancelled: false } - } - if (result.cancelled) { - return { - message: `He cancelado el lote con ${result.generated} viñetas terminadas y ${result.failed} fallidas; no he perdido lo ya dibujado.`, - state: result.generated > 0 ? 'partial' : 'failed', - generated: result.generated, - failed: result.failed, - cancelled: true, - } - } - if (result.failed) { - return { - message: `He dibujado ${result.generated} viñetas con ${providerLabel} y han fallado ${result.failed}. Puedo reanudar desde la primera pendiente o reintentar las fallidas.`, - state: result.generated > 0 ? 'partial' : 'failed', - generated: result.generated, - failed: result.failed, - cancelled: false, - } - } - return { - message: `He dibujado ${result.generated} viñetas con ${providerLabel}. Aparecen dentro de cada recuadro al terminar.`, - state: 'completed', - generated: result.generated, - failed: 0, - cancelled: false, - } -} - -export async function generateComicPanelArtwork( - pageNumber: number, - panelNumber: number, - onProgress?: (message: string) => void, -): Promise { - showComics() - const [{ useComicStore }, { generateDirectorArtwork }] = await Promise.all([ - import('../comics/store'), - import('../comics/generateArtwork'), - ]) - if (!useComicStore.getState().project.director) { - throw new Error('El cómic abierto no tiene un plan de Director. Crea primero el borrador completo antes de regenerar una viñeta.') - } - const result = await generateDirectorArtwork({ - force: true, - target: { pageNumber, panelNumber }, - onProgress: (message, current, total) => onProgress?.(`${message} (${current}/${total})`), - }) - if (result.failed) throw new Error(`No pude regenerar la viñeta ${panelNumber} de la página ${pageNumber}.`) - return `He regenerado únicamente la viñeta ${panelNumber} de la página ${pageNumber}; las demás imágenes permanecen intactas (${result.generated}/${result.total}).` -} +export * from '../comics/actions' diff --git a/ui/src/features/agent/commandContract.ts b/ui/src/features/agent/commandContract.ts index 21e66dd7..3428f72a 100644 --- a/ui/src/features/agent/commandContract.ts +++ b/ui/src/features/agent/commandContract.ts @@ -1,661 +1,2 @@ -import { stableSerialize as sharedStableSerialize } from './agentContract' - -/** - * Shared command-plane states. `prepared` and `running` remain internal - * execution states in agentContract; this is the transport-level vocabulary - * exposed to the UI/API. - */ -export const COMMAND_STATUSES = [ - 'completed', - 'queued', - 'awaiting_input', - 'partial', - 'failed', -] as const - -export type CommandStatus = typeof COMMAND_STATUSES[number] -export type CommandResultStatus = CommandStatus -export type CommandActor = 'user' | 'wizard' -export type ArtifactKind = 'image' | 'audio' | 'video' | 'scene' | 'document' -export type PresentationSpeed = 'instant' | 'normal' | 'theatrical' - -export const ARTIFACT_KINDS = ['image', 'audio', 'video', 'scene', 'document'] as const -export const PRESENTATION_SPEEDS = ['instant', 'normal', 'theatrical'] as const - -export interface EntityRef { - kind: string - id: string - workspaceId: string - version?: number -} - -export interface ArtifactRef { - id: string - kind: ArtifactKind - owner: EntityRef - taskId?: string - uri: string - metadata: Record -} - -/** Logical destination used by navigation and by the presentation layer. */ -export interface NavigationTarget { - destination: string - section?: string - entity?: EntityRef - anchor?: string -} - -/** - * The smallest UI-facing plan: where to go and which semantic anchors to - * reveal/focus. It deliberately contains no DOM selector or business state. - * `replay` keeps a compatibility bridge with CapabilityPresentation while - * presentation choreography is still being migrated. - */ -export interface PresentationPlan { - navigationTarget?: NavigationTarget - anchors: string[] - focus?: string - speed: PresentationSpeed - replay?: 'atomic' -} - -export interface StructuredError { - code: string - message: string - retryable: boolean - field?: string - details?: Record -} - -export interface CommandEnvelope { - commandId: string - capability: string - workspaceId: string - actor: CommandActor - target?: EntityRef - input: T - idempotencyKey: string - expectedVersion?: number - presentation?: PresentationPlan -} - -export interface CommandResult { - commandId: string - status: CommandStatus - entities: EntityRef[] - artifacts: ArtifactRef[] - taskIds: string[] - pipelineIds: string[] - navigationTarget?: NavigationTarget - error?: StructuredError -} - -export interface CommandEnvelopeInput { - commandId: string - capability: string - workspaceId: string - actor: CommandActor - target?: EntityRef - input: T - idempotencyKey?: string - expectedVersion?: number - presentation?: PresentationPlan -} - -export class CommandContractError extends Error { - readonly code: string - readonly path?: string - - constructor(message: string, code = 'invalid_command_contract', path?: string) { - super(message) - this.name = 'CommandContractError' - this.code = code - this.path = path - } -} - -type RecordValue = Record - -/** - * Command payloads cross the UI/API boundary as JSON. Keeping that invariant - * here prevents two especially subtle bugs: an idempotency key that changes - * after a caller mutates its input object, and values (Date/Map/functions, - * cycles, NaN, etc.) that the transport cannot faithfully reproduce. - */ -const MAX_JSON_DEPTH = 64 - - -function isRecord(value: unknown): value is RecordValue { - if (!value || typeof value !== 'object' || Array.isArray(value)) return false - const prototype = Object.getPrototypeOf(value) - return prototype === Object.prototype || prototype === null -} - -function record(value: unknown, name: string): RecordValue { - if (!isRecord(value)) throw new CommandContractError(`${name} debe ser un objeto.`, 'invalid_object', name) - return value -} - -function text(value: unknown, name: string): string { - if (typeof value !== 'string' || !value.trim()) { - throw new CommandContractError(`${name} no puede estar vacío.`, 'empty_id', name) - } - return value.trim() -} - -function optionalText(value: unknown, name: string): string | undefined { - if (value == null) return undefined - return text(value, name) -} - -function nonNegativeInteger(value: unknown, name: string): number { - if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { - throw new CommandContractError(`${name} debe ser un entero no negativo.`, 'invalid_version', name) - } - return value -} - -function defineEnumerableValue(target: RecordValue, key: string, value: unknown): void { - // `Object.defineProperty` keeps a JSON key named `__proto__` data-only and - // avoids accidentally changing the prototype of the normalized object. - Object.defineProperty(target, key, { - configurable: true, - enumerable: true, - value, - writable: true, - }) -} - -function normalizeJsonValue( - value: unknown, - name: string, - seen = new WeakSet(), - depth = 0, -): unknown { - if (value === undefined) return undefined - if (value === null || typeof value === 'string' || typeof value === 'boolean') return value - if (typeof value === 'number') { - if (!Number.isFinite(value)) { - throw new CommandContractError(`${name} debe contener números finitos.`, 'invalid_json_value', name) - } - return value - } - if (typeof value !== 'object') { - throw new CommandContractError(`${name} contiene un valor no serializable.`, 'invalid_json_value', name) - } - if (depth >= MAX_JSON_DEPTH) { - throw new CommandContractError(`${name} es demasiado profundo.`, 'json_depth_exceeded', name) - } - if (seen.has(value)) { - throw new CommandContractError(`${name} contiene una referencia circular.`, 'circular_json_value', name) - } - seen.add(value) - try { - if (Array.isArray(value)) { - const normalizedArray: unknown[] = [] - for (let index = 0; index < value.length; index += 1) { - const item = value[index] - const normalized = normalizeJsonValue(item, `${name}[${index}]`, seen, depth + 1) - // JSON.stringify serializes undefined array slots as null. Make that - // conversion explicit so the object used for the command is stable. - normalizedArray.push(normalized === undefined ? null : normalized) - } - return normalizedArray - } - if (!isRecord(value)) { - throw new CommandContractError(`${name} debe ser JSON plano.`, 'invalid_json_value', name) - } - if (Object.getOwnPropertySymbols(value).length > 0) { - throw new CommandContractError(`${name} no puede contener claves Symbol.`, 'invalid_json_value', name) - } - const normalized: RecordValue = {} - for (const [key, item] of Object.entries(value)) { - const child = normalizeJsonValue(item, `${name}.${key}`, seen, depth + 1) - // Undefined object properties are omitted by the shared serializer. Do - // the same in the cloned payload so both representations agree. - if (child !== undefined) defineEnumerableValue(normalized, key, child) - } - return normalized - } finally { - seen.delete(value) - } -} - -function normalizePayload(value: unknown, name: string, defaultValue: T): T { - const normalized = normalizeJsonValue(value === undefined ? defaultValue : value, name) - return normalized as T -} - -function normalizeMetadata(value: unknown, name: string): RecordValue { - const normalized = normalizeJsonValue(value, name) - if (!isRecord(normalized)) { - throw new CommandContractError(`${name} debe ser un objeto JSON.`, 'invalid_object', name) - } - return normalized -} - -function stringList(value: unknown, name: string): string[] { - if (value == null) return [] - if (!Array.isArray(value)) throw new CommandContractError(`${name} debe ser una lista.`, 'invalid_list', name) - const seen = new Set() - return value.map((item, index) => { - const next = text(item, `${name}[${index}]`) - if (seen.has(next)) return next - seen.add(next) - return next - }) -} - -function normalizedUniqueList(value: unknown, name: string): string[] { - const values = stringList(value, name) - return [...new Set(values)] -} - -function expectedWorkspace(value: unknown, name = 'workspaceId'): string { - return text(value, name) -} - -function assertWorkspaceMatch(expected: string, actual: string, path: string): void { - if (expected !== actual) { - throw new CommandContractError( - `${path} pertenece al workspace “${actual}”, no al workspace “${expected}”.`, - 'cross_workspace_reference', - path, - ) - } -} - -function normalizeEntityRefRecord(raw: RecordValue, workspaceId?: string, path = 'entity'): EntityRef { - const kind = text(raw.kind, `${path}.kind`) - const id = text(raw.id, `${path}.id`) - const refWorkspace = expectedWorkspace(raw.workspaceId, `${path}.workspaceId`) - if (workspaceId !== undefined) assertWorkspaceMatch(expectedWorkspace(workspaceId), refWorkspace, `${path}.workspaceId`) - const version = raw.version == null ? undefined : nonNegativeInteger(raw.version, `${path}.version`) - return { kind, id, workspaceId: refWorkspace, ...(version == null ? {} : { version }) } -} - -/** Normalize an entity reference and reject empty or cross-workspace IDs. */ -export function normalizeEntityRef(value: unknown, workspaceId?: string): EntityRef { - return normalizeEntityRefRecord(record(value, 'entity'), workspaceId) -} - -export function assertEntityRef(value: unknown, workspaceId?: string): EntityRef { - return normalizeEntityRef(value, workspaceId) -} - -export function validateEntityRef(value: unknown, workspaceId?: string): string[] { - try { - normalizeEntityRef(value, workspaceId) - return [] - } catch (error) { - return [error instanceof Error ? error.message : 'Referencia de entidad inválida.'] - } -} - -function normalizeArtifactRefRecord(raw: RecordValue, workspaceId?: string, path = 'artifact'): ArtifactRef { - const id = text(raw.id, `${path}.id`) - const kind = text(raw.kind, `${path}.kind`) - if (!(ARTIFACT_KINDS as readonly string[]).includes(kind)) { - throw new CommandContractError(`${path}.kind no es un tipo de artefacto válido.`, 'invalid_artifact_kind', `${path}.kind`) - } - const owner = normalizeEntityRefRecord(record(raw.owner, `${path}.owner`), workspaceId, `${path}.owner`) - const taskId = optionalText(raw.taskId, `${path}.taskId`) - const uri = text(raw.uri, `${path}.uri`) - const metadata = raw.metadata == null ? {} : normalizeMetadata(raw.metadata, `${path}.metadata`) - return { - id, - kind: kind as ArtifactKind, - owner, - ...(taskId == null ? {} : { taskId }), - uri, - metadata, - } -} - -/** Normalize an artifact and bind its owner to the expected workspace. */ -export function normalizeArtifactRef(value: unknown, workspaceId?: string): ArtifactRef { - return normalizeArtifactRefRecord(record(value, 'artifact'), workspaceId) -} - -export function assertArtifactRef(value: unknown, workspaceId?: string): ArtifactRef { - return normalizeArtifactRef(value, workspaceId) -} - -export function validateArtifactRef(value: unknown, workspaceId?: string): string[] { - try { - normalizeArtifactRef(value, workspaceId) - return [] - } catch (error) { - return [error instanceof Error ? error.message : 'Referencia de artefacto inválida.'] - } -} - -function normalizeNavigationTargetRecord(raw: RecordValue, workspaceId?: string, path = 'navigationTarget'): NavigationTarget { - const destination = text(raw.destination ?? raw.tab ?? raw.area, `${path}.destination`) - const section = optionalText(raw.section, `${path}.section`) - const anchor = optionalText(raw.anchor, `${path}.anchor`) - const rawEntity = raw.entity ?? raw.target - const entity = rawEntity == null ? undefined : normalizeEntityRefRecord(record(rawEntity, `${path}.entity`), workspaceId, `${path}.entity`) - return { - destination, - ...(section == null ? {} : { section }), - ...(entity == null ? {} : { entity }), - ...(anchor == null ? {} : { anchor }), - } -} - -export function normalizeNavigationTarget(value: unknown, workspaceId?: string): NavigationTarget { - return normalizeNavigationTargetRecord(record(value, 'navigationTarget'), workspaceId) -} - -export function assertNavigationTarget(value: unknown, workspaceId?: string): NavigationTarget { - return normalizeNavigationTarget(value, workspaceId) -} - -export function validateNavigationTarget(value: unknown, workspaceId?: string): string[] { - try { - normalizeNavigationTarget(value, workspaceId) - return [] - } catch (error) { - return [error instanceof Error ? error.message : 'Destino de navegación inválido.'] - } -} - -function normalizePresentationRecord(raw: RecordValue, workspaceId?: string): PresentationPlan { - const rawNavigation = raw.navigationTarget ?? raw.navigation - const navigationTarget = rawNavigation == null - ? (raw.destination == null ? undefined : normalizeNavigationTargetRecord({ - destination: raw.destination, - section: raw.section, - anchor: raw.anchor, - }, workspaceId, 'presentation.navigationTarget')) - : normalizeNavigationTargetRecord(record(rawNavigation, 'presentation.navigationTarget'), workspaceId, 'presentation.navigationTarget') - const anchors = normalizedUniqueList(raw.anchors, 'presentation.anchors') - const focus = optionalText(raw.focus, 'presentation.focus') - const speed = raw.speed == null ? 'normal' : text(raw.speed, 'presentation.speed') - if (!(PRESENTATION_SPEEDS as readonly string[]).includes(speed)) { - throw new CommandContractError('presentation.speed no es válido.', 'invalid_presentation_speed', 'presentation.speed') - } - if (raw.replay != null && raw.replay !== 'atomic') { - throw new CommandContractError('presentation.replay sólo admite “atomic”.', 'invalid_presentation_replay', 'presentation.replay') - } - return { - ...(navigationTarget == null ? {} : { navigationTarget }), - anchors, - ...(focus == null ? {} : { focus }), - speed: speed as PresentationSpeed, - ...(raw.replay == null ? {} : { replay: 'atomic' }), - } -} - -export function normalizePresentationPlan(value: unknown, workspaceId?: string): PresentationPlan { - return normalizePresentationRecord(record(value, 'presentation'), workspaceId) -} - -export function assertPresentationPlan(value: unknown, workspaceId?: string): PresentationPlan { - return normalizePresentationPlan(value, workspaceId) -} - -export function validatePresentationPlan(value: unknown, workspaceId?: string): string[] { - try { - normalizePresentationPlan(value, workspaceId) - return [] - } catch (error) { - return [error instanceof Error ? error.message : 'Plan de presentación inválido.'] - } -} - -/** - * Compatibility bridge for the currently registered capability metadata. - * It keeps destination/anchors/replay usable while callers migrate to the - * global command contract. - */ -export function presentationPlanFromCapabilityPresentation(value: { - destination: string - anchors?: string[] - replay?: 'atomic' -}): PresentationPlan { - return normalizePresentationPlan(value) -} - -export function normalizeStructuredError(value: unknown): StructuredError { - if (typeof value === 'string') { - return { code: 'unknown_error', message: text(value, 'error.message'), retryable: false } - } - const raw = record(value, 'error') - const code = text(raw.code, 'error.code') - const message = text(raw.message, 'error.message') - const retryable = raw.retryable == null - ? false - : (typeof raw.retryable === 'boolean' - ? raw.retryable - : (() => { - throw new CommandContractError('error.retryable debe ser booleano.', 'invalid_boolean', 'error.retryable') - })()) - const field = optionalText(raw.field, 'error.field') - const details = raw.details == null ? undefined : normalizeMetadata(raw.details, 'error.details') - return { - code, - message, - retryable, - ...(field == null ? {} : { field }), - ...(details == null ? {} : { details }), - } -} - -export function assertStructuredError(value: unknown): StructuredError { - return normalizeStructuredError(value) -} - -export function validateStructuredError(value: unknown): string[] { - try { - normalizeStructuredError(value) - return [] - } catch (error) { - return [error instanceof Error ? error.message : 'Error estructurado inválido.'] - } -} - -function normalizeExpectedVersion(value: unknown, name: string): number | undefined { - return value == null ? undefined : nonNegativeInteger(value, name) -} - -function targetForKey(target: EntityRef | undefined): Record | null { - return target == null ? null : { - kind: target.kind, - id: target.id, - workspaceId: target.workspaceId, - ...(target.version == null ? {} : { version: target.version }), - } -} - -export interface IdempotencyKeyInput { - workspaceId: string - capability: string - target?: EntityRef - input: T -} - -/** - * Stable, inspectable key for command retries. The shared stable serializer - * from agentContract sorts object keys recursively, so object insertion order - * cannot produce a second command identity. - */ -export function buildIdempotencyKey(value: IdempotencyKeyInput): string { - const raw = record(value, 'idempotency') - const workspaceId = expectedWorkspace(raw.workspaceId, 'workspaceId') - const capability = text(raw.capability, 'capability') - const target = raw.target == null ? undefined : normalizeEntityRef(raw.target, workspaceId) - const input = normalizePayload(raw.input, 'input', {} as T) - return `command:${sharedStableSerialize({ - workspaceId, - capability, - target: targetForKey(target), - input, - })}` -} - -/** Named aliases make the constructor discoverable during the migration. */ -export const commandIdempotencyKey = buildIdempotencyKey -export const idempotencyKey = buildIdempotencyKey -export const createIdempotencyKey = buildIdempotencyKey - -export function createCommandEnvelope(value: CommandEnvelopeInput): CommandEnvelope { - const normalized = normalizeCommandEnvelope(value) - return normalized -} - -export function normalizeCommandEnvelope(value: unknown): CommandEnvelope { - const raw = record(value, 'command') - const commandId = text(raw.commandId ?? raw.command_id, 'command.commandId') - const capability = text(raw.capability, 'command.capability') - const workspaceId = expectedWorkspace(raw.workspaceId ?? raw.workspace_id, 'command.workspaceId') - const actor = text(raw.actor, 'command.actor') - if (actor !== 'user' && actor !== 'wizard') { - throw new CommandContractError('command.actor debe ser “user” o “wizard”.', 'invalid_actor', 'command.actor') - } - const target = raw.target == null ? undefined : normalizeEntityRef(raw.target, workspaceId) - const input = normalizePayload(raw.input, 'command.input', {} as T) - const expectedVersion = normalizeExpectedVersion(raw.expectedVersion ?? raw.expected_version, 'command.expectedVersion') - const presentation = raw.presentation == null ? undefined : normalizePresentationPlan(raw.presentation, workspaceId) - const derivedKey = buildIdempotencyKey({ workspaceId, capability, target, input }) - const suppliedKey = raw.idempotencyKey ?? raw.idempotency_key - if (suppliedKey != null && text(suppliedKey, 'command.idempotencyKey') !== derivedKey) { - throw new CommandContractError( - 'command.idempotencyKey no coincide con workspace, capability, target e input.', - 'idempotency_key_mismatch', - 'command.idempotencyKey', - ) - } - return { - commandId, - capability, - workspaceId, - actor: actor as CommandActor, - ...(target == null ? {} : { target }), - input, - idempotencyKey: derivedKey, - ...(expectedVersion == null ? {} : { expectedVersion }), - ...(presentation == null ? {} : { presentation }), - } -} - -export function assertCommandEnvelope(value: unknown): CommandEnvelope { - return normalizeCommandEnvelope(value) -} - -export function validateCommandEnvelope(value: unknown): string[] { - try { - normalizeCommandEnvelope(value) - return [] - } catch (error) { - return [error instanceof Error ? error.message : 'Envelope de comando inválido.'] - } -} - -function normalizeStatus(value: unknown): CommandStatus { - const status = text(value, 'result.status') - if (!(COMMAND_STATUSES as readonly string[]).includes(status)) { - throw new CommandContractError(`result.status “${status}” no es válido.`, 'invalid_status', 'result.status') - } - return status as CommandStatus -} - -export function normalizeCommandResult(value: unknown, workspaceId?: string): CommandResult { - const raw = record(value, 'result') - const expected = workspaceId == null ? undefined : expectedWorkspace(workspaceId) - const commandId = text(raw.commandId ?? raw.command_id, 'result.commandId') - const status = normalizeStatus(raw.status) - const entities = raw.entities == null - ? [] - : (Array.isArray(raw.entities) - ? raw.entities.map((item, index) => normalizeEntityRefRecord(record(item, `result.entities[${index}]`), expected, `result.entities[${index}]`)) - : (() => { throw new CommandContractError('result.entities debe ser una lista.', 'invalid_list', 'result.entities') })()) - const artifacts = raw.artifacts == null - ? [] - : (Array.isArray(raw.artifacts) - ? raw.artifacts.map((item, index) => normalizeArtifactRefRecord(record(item, `result.artifacts[${index}]`), expected, `result.artifacts[${index}]`)) - : (() => { throw new CommandContractError('result.artifacts debe ser una lista.', 'invalid_list', 'result.artifacts') })()) - const workspaces = new Set([...entities.map(item => item.workspaceId), ...artifacts.map(item => item.owner.workspaceId)]) - if (expected == null && workspaces.size > 1) { - throw new CommandContractError('result contiene referencias de varios workspaces.', 'cross_workspace_reference', 'result') - } - const inferredWorkspace = expected ?? [...workspaces][0] - const taskIds = normalizedUniqueList(raw.taskIds ?? raw.task_ids, 'result.taskIds') - const pipelineIds = normalizedUniqueList(raw.pipelineIds ?? raw.pipeline_ids, 'result.pipelineIds') - const navigationTarget = raw.navigationTarget == null - ? undefined - : normalizeNavigationTarget(raw.navigationTarget, inferredWorkspace) - const error = raw.error == null ? undefined : normalizeStructuredError(raw.error) - return { - commandId, - status, - entities, - artifacts, - taskIds, - pipelineIds, - ...(navigationTarget == null ? {} : { navigationTarget }), - ...(error == null ? {} : { error }), - } -} - -export function assertCommandResult(value: unknown, workspaceId?: string): CommandResult { - return normalizeCommandResult(value, workspaceId) -} - -export function validateCommandResult(value: unknown, workspaceId?: string): string[] { - try { - normalizeCommandResult(value, workspaceId) - return [] - } catch (error) { - return [error instanceof Error ? error.message : 'Resultado de comando inválido.'] - } -} - -/** Canonical JSON-like serialization for transport snapshots and tests. */ -export function serializeCommand(value: CommandEnvelope): string { - return sharedStableSerialize(normalizeCommandEnvelope(value)) -} - -export function serializeCommandResult(value: CommandResult, workspaceId?: string): string { - return sharedStableSerialize(normalizeCommandResult(value, workspaceId)) -} - -/** Explicit workspace guard for adapters before they consume a reference. */ -export function assertWorkspaceScope(workspaceId: string, references: readonly (EntityRef | ArtifactRef)[]): void { - const expected = expectedWorkspace(workspaceId) - if (!Array.isArray(references)) { - throw new CommandContractError('references debe ser una lista.', 'invalid_list', 'references') - } - references.forEach((reference, index) => { - const path = `references[${index}]` - const raw = record(reference, path) - if ('owner' in raw) { - normalizeArtifactRefRecord(raw, expected, path) - } else { - normalizeEntityRefRecord(raw, expected, path) - } - }) -} - -export function sameWorkspace(left: EntityRef | ArtifactRef, right: EntityRef | ArtifactRef): boolean { - const normalizeReferenceWorkspace = (value: unknown, path: string): string => { - const raw = record(value, path) - return 'owner' in raw - ? normalizeArtifactRefRecord(raw, undefined, path).owner.workspaceId - : normalizeEntityRefRecord(raw, undefined, path).workspaceId - } - const leftWorkspace = normalizeReferenceWorkspace(left, 'left') - const rightWorkspace = normalizeReferenceWorkspace(right, 'right') - return leftWorkspace === rightWorkspace -} - -/** - * Re-export the shared canonical serializer through the command boundary, but - * validate first. This keeps the key format compatible with agentContract - * while rejecting values that JSON transport would silently corrupt. - */ -export function stableSerialize(value: unknown): string { - return sharedStableSerialize(normalizeJsonValue(value, 'value')) -} +/** Compatibility reexport. Canonical command types live in `lib/commandContract`. */ +export * from '../../lib/commandContract' diff --git a/ui/src/features/agent/editorAuxCapabilities.ts b/ui/src/features/agent/editorAuxCapabilities.ts index 176ad397..3a2b01f4 100644 --- a/ui/src/features/agent/editorAuxCapabilities.ts +++ b/ui/src/features/agent/editorAuxCapabilities.ts @@ -1,5 +1,5 @@ import type { AgentGenerateComicPanelAction } from './agentActions' -import type { defineCapability, CapabilityExecutionOutcome } from './capabilityRegistry' +import type { defineCapability } from './capabilityRegistry' import type { AgentAddVideoEditorAudioAction, AgentAddVideoEditorClipsAction, @@ -41,20 +41,6 @@ function boundedNumber(value: unknown, minimum: number, maximum: number, fallbac return Math.min(maximum, Math.max(minimum, parsed)) } -function outcomeFromReport( - outcome: { message: string; report: NonNullable }, -): CapabilityExecutionOutcome { - return { - message: outcome.message, - report: outcome.report, - target: outcome.report.target, - taskId: outcome.report.taskId, - pipelineId: outcome.report.pipelineId, - outputNames: outcome.report.outputNames, - assetIds: outcome.report.assetIds, - } -} - export function registerEditorAuxCapabilities(register: typeof defineCapability): void { const registrar = register as unknown as object if (registeredRegistrars.has(registrar)) return @@ -100,17 +86,8 @@ export function registerEditorAuxCapabilities(register: typeof defineCapability) : ['page and panel numbers plus confirmation are required'] }, async prepare(action) { return action }, - async execute(action) { - const [{ generateComicPanelArtwork }, { useComicStore }] = await Promise.all([ - import('./labActions'), - import('../comics/store'), - ]) - const message = await generateComicPanelArtwork(action.pageNumber, action.panelNumber) - const project = useComicStore.getState().project - return { - message, - target: { kind: 'comic', id: project.id, title: project.title }, - } + async execute(action, context) { + return context.adapters.comic.generatePanel(action.pageNumber, action.panelNumber) }, correlate(_action, outcome) { return outcome.target }, async track(_action, outcome) { return outcome }, @@ -156,9 +133,8 @@ export function registerEditorAuxCapabilities(register: typeof defineCapability) : ['at least one Character Kit field is required'] }, async prepare(action) { return action }, - async execute(action) { - const { updateAgentCharacterKit } = await import('./characterKitActions') - return outcomeFromReport(await updateAgentCharacterKit(action)) + async execute(action, context) { + return context.adapters.characterKit.update(action) }, correlate(_action, outcome) { return outcome.target }, async track(_action, outcome) { return outcome }, @@ -191,9 +167,8 @@ export function registerEditorAuxCapabilities(register: typeof defineCapability) }, validate(action) { return action.outputNames.length ? [] : ['at least one output is required'] }, async prepare(action) { return action }, - async execute(action) { - const { addAgentVideoEditorClips } = await import('./videoEditorActions') - return outcomeFromReport(await addAgentVideoEditorClips(action)) + async execute(action, context) { + return context.adapters.videoEditor.addClips(action) }, correlate(_action, outcome) { return outcome.target }, async track(_action, outcome) { return outcome }, @@ -226,9 +201,8 @@ export function registerEditorAuxCapabilities(register: typeof defineCapability) }, validate(action) { return action.clipNames.length ? [] : ['at least one clip is required'] }, async prepare(action) { return action }, - async execute(action) { - const { orderAgentVideoEditorClips } = await import('./videoEditorActions') - return outcomeFromReport(await orderAgentVideoEditorClips(action)) + async execute(action, context) { + return context.adapters.videoEditor.orderClips(action) }, correlate(_action, outcome) { return outcome.target }, async track(_action, outcome) { return outcome }, @@ -277,9 +251,8 @@ export function registerEditorAuxCapabilities(register: typeof defineCapability) : ['clip name and a usable trim range are required'] }, async prepare(action) { return action }, - async execute(action) { - const { trimAgentVideoEditorClip } = await import('./videoEditorActions') - return outcomeFromReport(await trimAgentVideoEditorClip(action)) + async execute(action, context) { + return context.adapters.videoEditor.trimClip(action) }, correlate(_action, outcome) { return outcome.target }, async track(_action, outcome) { return outcome }, @@ -315,9 +288,8 @@ export function registerEditorAuxCapabilities(register: typeof defineCapability) }, validate(action) { return action.outputName ? [] : ['audio output name is required'] }, async prepare(action) { return action }, - async execute(action) { - const { addAgentVideoEditorAudio } = await import('./videoEditorActions') - return outcomeFromReport(await addAgentVideoEditorAudio(action)) + async execute(action, context) { + return context.adapters.videoEditor.addAudio(action) }, correlate(_action, outcome) { return outcome.target }, async track(_action, outcome) { return outcome }, @@ -344,9 +316,8 @@ export function registerEditorAuxCapabilities(register: typeof defineCapability) resolve() { return { type: 'validate_video_editor_timeline' } }, validate() { return [] }, async prepare(action) { return action }, - async execute() { - const { validateAgentVideoEditorTimeline } = await import('./videoEditorActions') - return outcomeFromReport(await validateAgentVideoEditorTimeline()) + async execute(_action, context) { + return context.adapters.videoEditor.validateTimeline() }, correlate(_action, outcome) { return outcome.target }, async track(_action, outcome) { return outcome }, @@ -373,9 +344,8 @@ export function registerEditorAuxCapabilities(register: typeof defineCapability) resolve(raw) { return raw.confirm === true ? { type: 'export_video_editor', confirm: true } : null }, validate(action) { return action.confirm === true ? [] : ['export confirmation is required'] }, async prepare(action) { return action }, - async execute(action) { - const { exportAgentVideoEditor } = await import('./videoEditorActions') - return outcomeFromReport(await exportAgentVideoEditor(action)) + async execute(action, context) { + return context.adapters.videoEditor.exportProject(action) }, correlate(_action, outcome) { return outcome.target }, async track(_action, outcome) { return outcome }, @@ -402,9 +372,8 @@ export function registerEditorAuxCapabilities(register: typeof defineCapability) resolve() { return { type: 'track_video_editor_export' } }, validate() { return [] }, async prepare(action) { return action }, - async execute() { - const { trackAgentVideoEditorExport } = await import('./videoEditorActions') - return outcomeFromReport(await trackAgentVideoEditorExport()) + async execute(_action, context) { + return context.adapters.videoEditor.trackExport() }, correlate(_action, outcome) { return outcome.target }, async track(_action, outcome) { return outcome }, diff --git a/ui/src/features/agent/labActionHelpers.ts b/ui/src/features/agent/labActionHelpers.ts index adfc7603..7190923d 100644 --- a/ui/src/features/agent/labActionHelpers.ts +++ b/ui/src/features/agent/labActionHelpers.ts @@ -1,54 +1,14 @@ import { useStore } from '../../stores/useStore' -import type { AgentCreativeCharacter, AgentCreativeLocation } from './agentActions' -export const normalizeName = (value: string): string => value - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .replace(/[^a-zA-Z0-9]+/g, ' ') - .trim() - .toLowerCase() - -export const boundedDuration = (value: number | undefined, fallback: number): number => ( - Math.max(15, Math.min(3_600, Math.round(value || fallback))) -) - -export const explicitMusicLanguage = (value: string): string => { - const raw = value.trim() - const aliases: Record = { - es: 'Español', en: 'English', fr: 'Français', de: 'Deutsch', - it: 'Italiano', pt: 'Português', ja: '日本語', ko: '한국어', zh: '中文', - } - return aliases[raw.toLowerCase()] || raw || 'Español' -} - -export function creativeCharacters(values: AgentCreativeCharacter[]): AgentCreativeCharacter[] { - return values.length ? values : [{ - name: 'Protagonista', - role: 'Protagonista', - personality: 'Ingenioso, curioso y decidido.', - desire: 'Resolver el conflicto central.', - flaw: 'Se precipita cuando cree tener razón.', - appearance: 'Silueta clara, vestuario reconocible y expresiones legibles.', - voice: 'Natural, expresiva y coherente con el tono.', - }] -} - -export function creativeLocations(values: AgentCreativeLocation[]): AgentCreativeLocation[] { - return values.length ? values : [{ - name: 'Escenario principal', - purpose: 'Reunir a los personajes y hacer visible el conflicto.', - description: 'Un lugar reconocible, visualmente coherente y con espacio para la acción.', - }] -} - -export function outlineBeats(values: string[], premise: string, ending: string): string[] { - if (values.length >= 3) return values - return [ - `Inicio: ${premise || 'se presenta el deseo del protagonista y aparece una complicación.'}`, - 'Desarrollo: el plan inicial empeora el conflicto y obliga a los personajes a cambiar de estrategia.', - `Final: ${ending || 'la decisión final resuelve el problema con una consecuencia clara y memorable.'}`, - ] -} +export { + boundedDuration, + creativeCharacters, + creativeLocations, + explicitMusicLanguage, + normalizeName, + outlineBeats, +} from '../../lib/labHelpers' +export type { CreativeCharacter, CreativeLocation } from '../../lib/labHelpers' export function showLab(filter: 'stories' | 'series'): void { const state = useStore.getState() diff --git a/ui/src/features/agent/seriesLabActions.ts b/ui/src/features/agent/seriesLabActions.ts index 603ed018..8ed8159a 100644 --- a/ui/src/features/agent/seriesLabActions.ts +++ b/ui/src/features/agent/seriesLabActions.ts @@ -1,596 +1 @@ -import { useStore } from '../../stores/useStore' -import type { - AgentApplySeriesPlanAction, - AgentAssembleSeriesEpisodeAction, - AgentCommitSeriesCanonAction, - AgentCreateSeriesEpisodeAction, - AgentGenerateSeriesPlanAction, - AgentRenderSeriesShotsAction, - AgentReviewSeriesAttemptsAction, - AgentUpdateSeriesEpisodeAction, -} from './agentActions' -import { - boundedDuration, - creativeCharacters, - creativeLocations, - normalizeName, - outlineBeats, - showLab, -} from './labActionHelpers' -import { - clearAgentSeriesPlanJob, - notifyAgentSeriesAssemblyJob, - notifyAgentSeriesPlanJob, - notifyAgentSeriesRenderJob, - openAgentSeriesReviewView, - openAgentSeriesSection, -} from './agentUiBus' - -export async function createFilledSeriesEpisode(action: AgentCreateSeriesEpisodeAction): Promise { - const workspace = useStore.getState().activeWorkspace || 'default' - const [api, { useSeriesStore }, seriesModel] = await Promise.all([ - import('../../api/client'), - import('../series/store'), - import('../series/model'), - ]) - const store = useSeriesStore.getState() - await store.loadWorkspace(workspace) - await useSeriesStore.getState().saveNow() - - let library = await api.fetchSeriesLibrary(workspace) - const requestedName = normalizeName(action.seriesTitle) - let series = requestedName - ? Object.values(library.seriesById).find(item => normalizeName(item.title) === requestedName) - : library.seriesById[useSeriesStore.getState().activeSeriesId] - const createdSeries = !series - if (!series) { - if (!action.createIfMissing) throw new Error(`No existe la serie “${action.seriesTitle}” y la orden no autorizó crearla.`) - series = await api.createSeriesProject(workspace, action.seriesTitle || 'Nueva serie') - } - - const existingEpisode = Object.values(series.episodesById).find(episode => ( - normalizeName(episode.title) === normalizeName(action.episodeTitle) - && normalizeName(episode.premise) === normalizeName(action.episodePremise) - )) - if (existingEpisode) { - await useSeriesStore.getState().reload() - await useSeriesStore.getState().openSeries(series.id) - useSeriesStore.getState().openEpisode(existingEpisode.id) - showLab('series') - openAgentSeriesSection('episode') - return `El episodio “${existingEpisode.title}” ya existía; lo he abierto en Series Lab → Episode room.` - } - - const characters = series.characters.length ? series.characters : creativeCharacters(action.characters).map((character, index) => ({ - ...seriesModel.createSeriesCharacter(), - name: character.name || `Personaje ${index + 1}`, - role: character.role || (index ? 'Secundario' : 'Protagonista'), - personality: character.personality, - desire: character.desire, - need: `Aprender algo que contradice su deseo inmediato: ${character.desire || 'resolver el conflicto'}.`, - flaw: character.flaw, - longArc: action.seriesPremise, - voiceAndDialogue: character.voice, - appearance: character.appearance, - identityLock: `${character.appearance}. Mantener identidad, edad aparente y vestuario entre episodios.`, - })) - const locations = series.locations.length ? series.locations : creativeLocations(action.locations).map(location => ({ - ...seriesModel.createSeriesLocation(), - name: location.name, - purpose: location.purpose, - description: location.description, - })) - const needsSetup = createdSeries - || !series.premise.trim() - || !series.visualStyle.trim() - || !series.canon.worldSummary.trim() - || !series.characters.length - || !series.locations.length - if (needsSetup) { - const patched = { - ...series, - title: series.title === 'Untitled series' ? action.seriesTitle : series.title, - premise: series.premise || action.seriesPremise || action.episodePremise, - logline: series.logline || action.seriesLogline || action.episodeLogline, - genre: series.genre || action.genre || 'Comedia dramática', - tone: series.tone || action.tone || 'Cinematográfico', - visualStyle: series.visualStyle || action.visualStyle || 'Continuidad televisiva cinematográfica, composición clara y personajes consistentes.', - characterVisualStyle: series.characterVisualStyle || action.visualStyle || 'Identidades y vestuario consistentes entre episodios.', - cameraLanguage: series.cameraLanguage || 'Planos de situación claros, planos medios para diálogo y primeros planos para reacciones.', - language: action.language || series.language, - spokenLanguage: action.language || series.spokenLanguage, - sourceMode: action.knownUniverse ? 'known_universe_experimental' as const : series.sourceMode, - masterUniversePrompt: series.masterUniversePrompt || (action.knownUniverse - ? `Borrador fan inspirado en ${action.seriesTitle}; conservar los rasgos generales sin afirmar derechos sobre la obra original.` - : ''), - rightsNote: series.rightsNote || (action.knownUniverse - ? 'Borrador creativo no oficial. Verifica los derechos necesarios antes de publicar o monetizar.' - : ''), - canon: { - ...series.canon, - worldSummary: series.canon.worldSummary || action.worldSummary || action.seriesPremise || action.episodePremise, - immutableRules: series.canon.immutableRules.length ? series.canon.immutableRules : [{ - id: seriesModel.seriesId('fact'), - description: 'Mantener personalidades, relaciones, espacios y consecuencias coherentes entre episodios.', - status: 'draft' as const, - }], - themes: series.canon.themes.length ? series.canon.themes : [action.theme || 'Relaciones y consecuencias cotidianas'], - approval: 'draft' as const, - approvedAt: undefined, - }, - characters, - locations, - updatedAt: new Date().toISOString(), - } - series = await api.saveSeriesProject(workspace, patched, series.revision) - } - let approvedCanon = false - if (series.canon.approval !== 'approved') { - series = await api.approveSeriesCanon(workspace, series.id, series.canon.revision) - approvedCanon = true - } - - const beats = outlineBeats(action.outlineBeats, action.episodePremise, action.ending) - const createdEpisode = await api.createSeriesEpisode( - workspace, - series.id, - series.seasons[0]?.id, - { - title: action.episodeTitle || `Episodio ${Object.keys(series.episodesById).length + 1}`, - premise: action.episodePremise, - logline: action.episodeLogline, - targetDurationSeconds: boundedDuration(action.targetDurationSeconds, series.defaultEpisodeDurationSeconds), - status: 'outline', - outline: { beats }, - }, - ) - - library = await api.fetchSeriesLibrary(workspace) - series = library.seriesById[series.id] - useSeriesStore.setState({ hydrated: false }) - await useSeriesStore.getState().loadWorkspace(workspace) - await useSeriesStore.getState().openSeries(series.id) - useSeriesStore.getState().openEpisode(createdEpisode.id) - showLab('series') - openAgentSeriesSection('episode') - const canonResult = approvedCanon ? 'preparado y aprobado el canon editable necesario, y ' : '' - return `He ${createdSeries ? 'creado la serie, ' : ''}${canonResult}guardado el episodio “${createdEpisode.title}” con ${beats.length} beats; está abierto en Series Lab → Episode room.` -} - -export async function updateSeriesEpisode(action: AgentUpdateSeriesEpisodeAction): Promise { - const workspace = useStore.getState().activeWorkspace || 'default' - const [api, { useSeriesStore }] = await Promise.all([ - import('../../api/client'), - import('../series/store'), - ]) - await useSeriesStore.getState().loadWorkspace(workspace) - await useSeriesStore.getState().saveNow() - const library = await api.fetchSeriesLibrary(workspace) - const seriesMatches = action.seriesTitle - ? Object.values(library.seriesById).filter(item => normalizeName(item.title) === normalizeName(action.seriesTitle)) - : [] - if (seriesMatches.length > 1) throw new Error(`Hay varias series tituladas “${action.seriesTitle}”; renombra una para poder elegirla sin ambigüedad.`) - const series = seriesMatches[0] - || (!action.seriesTitle ? library.seriesById[useSeriesStore.getState().activeSeriesId] : null) - if (!series) throw new Error(action.seriesTitle - ? `No existe la serie “${action.seriesTitle}” en este workspace.` - : 'No hay una serie activa que modificar.') - - const episodeMatches = action.targetEpisodeTitle - ? Object.values(series.episodesById).filter(item => normalizeName(item.title) === normalizeName(action.targetEpisodeTitle)) - : [] - if (episodeMatches.length > 1) { - throw new Error(`Hay varios episodios titulados “${action.targetEpisodeTitle}”; usa un título inequívoco antes de modificarlos.`) - } - const activeEpisodeId = useSeriesStore.getState().activeSeriesId === series.id - ? useSeriesStore.getState().activeEpisodeId : '' - const episodes = Object.values(series.episodesById) - const episode = episodeMatches[0] - || (!action.targetEpisodeTitle && activeEpisodeId ? series.episodesById[activeEpisodeId] : null) - || (!action.targetEpisodeTitle && episodes.length === 1 ? episodes[0] : null) - if (!episode) throw new Error(action.targetEpisodeTitle - ? `No existe el episodio “${action.targetEpisodeTitle}” en “${series.title}”.` - : `“${series.title}” necesita un episodio activo o un único episodio para poder inferir el destino.`) - - await useSeriesStore.getState().openSeries(series.id) - useSeriesStore.getState().openEpisode(episode.id) - useSeriesStore.getState().updateEpisode(episode.id, current => ({ - ...current, - title: action.episodeTitle || current.title, - premise: action.episodePremise || current.premise, - logline: action.episodeLogline || current.logline, - targetDurationSeconds: action.targetDurationSeconds ?? current.targetDurationSeconds, - outline: action.outlineBeats.length ? { beats: action.outlineBeats } : current.outline, - })) - const saved = await useSeriesStore.getState().saveNow() - const verified = saved?.episodesById[episode.id] - if (!verified) throw new Error(`Series Lab no devolvió el episodio “${episode.title}” tras guardarlo.`) - if (action.episodeTitle && verified.title !== action.episodeTitle) throw new Error('El backend no confirmó el nuevo título del episodio.') - if (action.episodePremise && verified.premise !== action.episodePremise) throw new Error('El backend no confirmó la nueva premisa del episodio.') - if (action.episodeLogline && verified.logline !== action.episodeLogline) throw new Error('El backend no confirmó la nueva logline del episodio.') - if (action.outlineBeats.length && JSON.stringify(verified.outline.beats) !== JSON.stringify(action.outlineBeats)) { - throw new Error('El backend no confirmó la nueva estructura del episodio.') - } - showLab('series') - openAgentSeriesSection('episode') - return `He actualizado y guardado “${verified.title}” en la serie “${saved.title}”; conserva ${verified.script.length} escenas y ${verified.shots.length} tomas existentes.` -} - -export async function generateSeriesPlan(action: AgentGenerateSeriesPlanAction): Promise { - if (!action.confirm) throw new Error('Generar un plan de Series Lab requiere confirm=true.') - const workspace = useStore.getState().activeWorkspace || 'default' - const [api, { useSeriesStore }] = await Promise.all([ - import('../../api/client'), - import('../series/store'), - ]) - await useSeriesStore.getState().loadWorkspace(workspace) - await useSeriesStore.getState().saveNow() - const library = await api.fetchSeriesLibrary(workspace) - const seriesMatches = action.seriesTitle - ? Object.values(library.seriesById).filter(item => normalizeName(item.title) === normalizeName(action.seriesTitle)) - : [] - if (seriesMatches.length > 1) throw new Error(`Hay varias series tituladas “${action.seriesTitle}”; el destino no es inequívoco.`) - const series = seriesMatches[0] - || (!action.seriesTitle ? library.seriesById[useSeriesStore.getState().activeSeriesId] : null) - if (!series) throw new Error(action.seriesTitle - ? `No existe la serie “${action.seriesTitle}” en este workspace.` - : 'No hay una serie activa que planificar.') - const episodeMatches = action.targetEpisodeTitle - ? Object.values(series.episodesById).filter(item => normalizeName(item.title) === normalizeName(action.targetEpisodeTitle)) - : [] - if (episodeMatches.length > 1) throw new Error(`Hay varios episodios titulados “${action.targetEpisodeTitle}”; el destino no es inequívoco.`) - const activeEpisodeId = useSeriesStore.getState().activeSeriesId === series.id - ? useSeriesStore.getState().activeEpisodeId : '' - const episodes = Object.values(series.episodesById) - const episode = episodeMatches[0] - || (!action.targetEpisodeTitle && activeEpisodeId ? series.episodesById[activeEpisodeId] : null) - || (!action.targetEpisodeTitle && episodes.length === 1 ? episodes[0] : null) - if (!episode) throw new Error(action.targetEpisodeTitle - ? `No existe el episodio “${action.targetEpisodeTitle}” en “${series.title}”.` - : `“${series.title}” necesita un episodio activo o único.`) - if (!episode.premise.trim()) throw new Error(`“${episode.title}” necesita una premisa antes de planificarse.`) - if (action.scope === 'shots' && !episode.script.length) { - throw new Error('Regenerar shots requiere un guion existente; genera script o complete primero.') - } - - await useSeriesStore.getState().openSeries(series.id) - useSeriesStore.getState().openEpisode(episode.id) - showLab('series') - openAgentSeriesSection('episode') - const job = await api.startSeriesPlan(workspace, series.id, episode.id, { - scope: action.scope, - instruction: action.instruction, - writingProvider: series.provider.writingProvider, - writingModel: series.provider.writingModel, - writingBaseUrl: series.provider.writingBaseUrl, - }) - if (job.seriesId !== series.id || job.episodeId !== episode.id) { - throw new Error('Series Lab devolvió un job asociado a otro episodio; no lo mostraré como correcto.') - } - notifyAgentSeriesPlanJob(job) - return `He iniciado el plan ${action.scope} de “${episode.title}” (${job.jobId}). El progreso y la propuesta recuperable están abiertos en Series Lab → Episode room; todavía no se ha aplicado ni renderizado.` -} - -export async function applySeriesPlan(action: AgentApplySeriesPlanAction): Promise { - if (!action.confirm) throw new Error('Aplicar una propuesta de Series Lab requiere confirm=true.') - const workspace = useStore.getState().activeWorkspace || 'default' - const [api, { useSeriesStore }] = await Promise.all([ - import('../../api/client'), - import('../series/store'), - ]) - await useSeriesStore.getState().loadWorkspace(workspace) - await useSeriesStore.getState().saveNow() - const library = await api.fetchSeriesLibrary(workspace) - const seriesMatches = action.seriesTitle - ? Object.values(library.seriesById).filter(item => normalizeName(item.title) === normalizeName(action.seriesTitle)) - : [] - if (seriesMatches.length > 1) throw new Error(`Hay varias series tituladas “${action.seriesTitle}”; el destino no es inequívoco.`) - const series = seriesMatches[0] - || (!action.seriesTitle ? library.seriesById[useSeriesStore.getState().activeSeriesId] : null) - if (!series) throw new Error(action.seriesTitle - ? `No existe la serie “${action.seriesTitle}” en este workspace.` - : 'No hay una serie activa para aplicar la propuesta.') - const episodeMatches = action.targetEpisodeTitle - ? Object.values(series.episodesById).filter(item => normalizeName(item.title) === normalizeName(action.targetEpisodeTitle)) - : [] - if (episodeMatches.length > 1) throw new Error(`Hay varios episodios titulados “${action.targetEpisodeTitle}”; el destino no es inequívoco.`) - const activeEpisodeId = useSeriesStore.getState().activeSeriesId === series.id - ? useSeriesStore.getState().activeEpisodeId : '' - const episodes = Object.values(series.episodesById) - const episode = episodeMatches[0] - || (!action.targetEpisodeTitle && activeEpisodeId ? series.episodesById[activeEpisodeId] : null) - || (!action.targetEpisodeTitle && episodes.length === 1 ? episodes[0] : null) - if (!episode) throw new Error(action.targetEpisodeTitle - ? `No existe el episodio “${action.targetEpisodeTitle}” en “${series.title}”.` - : `“${series.title}” necesita un episodio activo o único.`) - - const job = action.jobId - ? await api.fetchSeriesPlanJob(action.jobId) - : (await api.fetchSeriesPlanRecovery(workspace)).jobs - .filter(item => item.seriesId === series.id && item.episodeId === episode.id && item.status === 'completed' && item.episodeResult) - .sort((left, right) => Number(right.updatedAt || right.finishedAt || 0) - Number(left.updatedAt || left.finishedAt || 0))[0] - if (!job) throw new Error(`No hay una propuesta completada y recuperable para “${episode.title}”.`) - if (job.workspace !== workspace || job.seriesId !== series.id || job.episodeId !== episode.id) { - throw new Error('El job indicado pertenece a otro workspace, serie o episodio; no se aplicará.') - } - if (job.status !== 'completed' || !job.episodeResult) { - throw new Error(`El job ${job.jobId} está ${job.status}; sólo se puede aplicar una propuesta completada.`) - } - const applied = await api.applySeriesPlanJob(job.jobId, job.episodeResult) - if (applied.id !== episode.id) throw new Error('Series Lab aplicó la propuesta a un episodio inesperado; recarga antes de continuar.') - await useSeriesStore.getState().reload() - await useSeriesStore.getState().openSeries(series.id) - useSeriesStore.getState().openEpisode(episode.id) - clearAgentSeriesPlanJob(episode.id) - showLab('series') - openAgentSeriesSection('episode') - return `He aplicado el plan ${job.jobId} a “${applied.title}”: ${applied.outline.beats.length} beats, ${applied.script.length} escenas y ${applied.shots.length} tomas. No he renderizado ni comprometido el delta de canon.` -} - -export async function renderSeriesShots(action: AgentRenderSeriesShotsAction): Promise { - if (!action.confirm) throw new Error('Renderizar tomas de Series Lab requiere confirm=true.') - const workspace = useStore.getState().activeWorkspace || 'default' - const [api, { useSeriesStore }] = await Promise.all([ - import('../../api/client'), - import('../series/store'), - ]) - await useSeriesStore.getState().loadWorkspace(workspace) - await useSeriesStore.getState().saveNow() - const library = await api.fetchSeriesLibrary(workspace) - const seriesMatches = action.seriesTitle - ? Object.values(library.seriesById).filter(item => normalizeName(item.title) === normalizeName(action.seriesTitle)) - : [] - if (seriesMatches.length > 1) throw new Error(`Hay varias series tituladas “${action.seriesTitle}”; el destino no es inequívoco.`) - const series = seriesMatches[0] - || (!action.seriesTitle ? library.seriesById[useSeriesStore.getState().activeSeriesId] : null) - if (!series) throw new Error(action.seriesTitle - ? `No existe la serie “${action.seriesTitle}” en este workspace.` - : 'No hay una serie activa que renderizar.') - const episodeMatches = action.targetEpisodeTitle - ? Object.values(series.episodesById).filter(item => normalizeName(item.title) === normalizeName(action.targetEpisodeTitle)) - : [] - if (episodeMatches.length > 1) throw new Error(`Hay varios episodios titulados “${action.targetEpisodeTitle}”; el destino no es inequívoco.`) - const activeEpisodeId = useSeriesStore.getState().activeSeriesId === series.id - ? useSeriesStore.getState().activeEpisodeId : '' - const episodes = Object.values(series.episodesById) - const episode = episodeMatches[0] - || (!action.targetEpisodeTitle && activeEpisodeId ? series.episodesById[activeEpisodeId] : null) - || (!action.targetEpisodeTitle && episodes.length === 1 ? episodes[0] : null) - if (!episode) throw new Error(action.targetEpisodeTitle - ? `No existe el episodio “${action.targetEpisodeTitle}” en “${series.title}”.` - : `“${series.title}” necesita un episodio activo o único.`) - if (!episode.shots.length) throw new Error(`“${episode.title}” no tiene shots; genera y aplica un plan complete primero.`) - if (episode.shots.some(shot => shot.dialogueBeats.length > 0) && !series.bestEffortLipSyncAcknowledged) { - throw new Error('Este episodio tiene diálogo. Marca primero “I understand lip sync is best-effort” en Series Lab; el Wizard no puede inferir ese consentimiento.') - } - - const byId = new Map(episode.shots.map(shot => [shot.id, shot])) - if (action.mode === 'selected') { - const unknown = action.shotIds.filter(id => !byId.has(id)) - if (unknown.length) throw new Error(`Shots desconocidos: ${unknown.join(', ')}.`) - const approved = action.shotIds.filter(id => Boolean(byId.get(id)?.approvedAttemptId)) - if (approved.length) throw new Error(`Los shots ya aprobados no se vuelven a renderizar: ${approved.join(', ')}.`) - } - const eligible = episode.shots.filter(shot => { - if (shot.approvedAttemptId) return false - if (action.mode === 'selected') return action.shotIds.includes(shot.id) - if (action.mode === 'missing') return !shot.attempts.some(attempt => attempt.status === 'completed') - if (action.mode === 'failed') return shot.attempts.some(attempt => attempt.status === 'failed') - return true - }) - if (!eligible.length) throw new Error(`No hay shots elegibles para el modo ${action.mode}.`) - - await useSeriesStore.getState().openSeries(series.id) - useSeriesStore.getState().openEpisode(episode.id) - const current = useSeriesStore.getState().library.seriesById[series.id] || series - const job = await api.startSeriesRender(workspace, series.id, episode.id, { - mode: action.mode, - shotIds: action.mode === 'selected' ? eligible.map(shot => shot.id) : undefined, - seed: action.seed === -1 ? undefined : action.seed, - settings: current.provider.videoSettings, - }) - if (job.workspace !== workspace || job.seriesId !== series.id || job.episodeId !== episode.id) { - throw new Error('Series Lab devolvió un render job para otro destino; no se mostrará como correcto.') - } - notifyAgentSeriesRenderJob(job) - showLab('series') - openAgentSeriesSection('review') - return `He encolado ${eligible.length} shots de “${episode.title}” (${job.jobId}) en modo ${action.mode}. El progreso recuperable está abierto en Series Lab → Render & review.` -} - -export async function reviewSeriesAttempts(action: AgentReviewSeriesAttemptsAction): Promise { - if (!action.confirm) throw new Error('Revisar intentos de Series Lab requiere confirm=true.') - const workspace = useStore.getState().activeWorkspace || 'default' - const [api, { useSeriesStore }] = await Promise.all([ - import('../../api/client'), - import('../series/store'), - ]) - await useSeriesStore.getState().loadWorkspace(workspace) - await useSeriesStore.getState().saveNow() - const library = await api.fetchSeriesLibrary(workspace) - const seriesMatches = action.seriesTitle - ? Object.values(library.seriesById).filter(item => normalizeName(item.title) === normalizeName(action.seriesTitle)) - : [] - if (seriesMatches.length > 1) throw new Error(`Hay varias series tituladas “${action.seriesTitle}”; el destino no es inequívoco.`) - const series = seriesMatches[0] - || (!action.seriesTitle ? library.seriesById[useSeriesStore.getState().activeSeriesId] : null) - if (!series) throw new Error(action.seriesTitle - ? `No existe la serie “${action.seriesTitle}” en este workspace.` - : 'No hay una serie activa cuyos intentos revisar.') - const episodeMatches = action.targetEpisodeTitle - ? Object.values(series.episodesById).filter(item => normalizeName(item.title) === normalizeName(action.targetEpisodeTitle)) - : [] - if (episodeMatches.length > 1) throw new Error(`Hay varios episodios titulados “${action.targetEpisodeTitle}”; el destino no es inequívoco.`) - const activeEpisodeId = useSeriesStore.getState().activeSeriesId === series.id - ? useSeriesStore.getState().activeEpisodeId : '' - const episodes = Object.values(series.episodesById) - const episode = episodeMatches[0] - || (!action.targetEpisodeTitle && activeEpisodeId ? series.episodesById[activeEpisodeId] : null) - || (!action.targetEpisodeTitle && episodes.length === 1 ? episodes[0] : null) - if (!episode) throw new Error(action.targetEpisodeTitle - ? `No existe el episodio “${action.targetEpisodeTitle}” en “${series.title}”.` - : `“${series.title}” necesita un episodio activo o único.`) - - const shotsByOrder = new Map() - for (const shot of episode.shots) { - if (shotsByOrder.has(shot.order)) throw new Error(`El episodio tiene más de un shot con el número ${shot.order}; no se puede resolver de forma segura.`) - shotsByOrder.set(shot.order, shot) - } - const selectedShots = action.scope === 'all_latest' - ? episode.shots - : action.shotNumbers.map(number => { - const shot = shotsByOrder.get(number) - if (!shot) throw new Error(`No existe el shot ${number} en “${episode.title}”.`) - return shot - }) - - if (action.decision === 'approve') { - const selections = selectedShots.flatMap(shot => { - const attempt = action.attemptId - ? shot.attempts.find(item => item.id === action.attemptId) - : [...shot.attempts].reverse().find(item => ( - item.status === 'completed' - && item.reviewDecision !== 'rejected' - && item.outputAssetIds.some(id => Boolean(series.assets[id])) - )) - if (!attempt) { - if (action.attemptId) throw new Error(`El intento ${action.attemptId} no pertenece al shot ${shot.order}.`) - if (action.scope === 'selected_latest') throw new Error(`El shot ${shot.order} no tiene un intento completado y reproducible que aprobar.`) - return [] - } - if (attempt.status !== 'completed' || attempt.reviewDecision === 'rejected') { - throw new Error(`El intento ${attempt.id} del shot ${shot.order} no es aprobable.`) - } - if (!attempt.outputAssetIds.some(id => Boolean(series.assets[id]))) { - throw new Error(`El intento ${attempt.id} del shot ${shot.order} no tiene un asset reproducible.`) - } - return attempt.id === shot.approvedAttemptId ? [] : [{ shotId: shot.id, attemptId: attempt.id }] - }) - if (!selections.length) throw new Error('No hay nuevos intentos elegibles que aprobar; las tomas resueltas ya están aprobadas o no tienen vídeo válido.') - const result = await api.approveSeriesAttemptsBulk(workspace, series.id, episode.id, selections) - if (result.seriesId !== series.id || result.episodeId !== episode.id) { - throw new Error('Series Lab aprobó intentos para otro destino; recarga antes de continuar.') - } - await useSeriesStore.getState().reload() - await useSeriesStore.getState().openSeries(series.id) - useSeriesStore.getState().openEpisode(episode.id) - showLab('series') - openAgentSeriesSection('review') - return `He aprobado ${selections.length} intento${selections.length === 1 ? '' : 's'} en “${episode.title}” y he abierto Render & Review.` - } - - const shot = selectedShots[0] - const attempt = action.attemptId - ? shot.attempts.find(item => item.id === action.attemptId) - : [...shot.attempts].reverse().find(item => item.status === 'completed' && item.reviewDecision !== 'rejected') - if (!attempt) throw new Error(action.attemptId - ? `El intento ${action.attemptId} no pertenece al shot ${shot.order}.` - : `El shot ${shot.order} no tiene un intento completado pendiente de rechazo.`) - if (attempt.status !== 'completed' || attempt.reviewDecision === 'rejected') { - throw new Error(`El intento ${attempt.id} del shot ${shot.order} no se puede rechazar.`) - } - if (shot.approvedAttemptId === attempt.id) { - throw new Error(`El intento ${attempt.id} ya es el aprobado del shot ${shot.order}; la UI no permite rechazar el montaje final sin elegir antes otra toma.`) - } - const rejectedShot = await api.rejectSeriesAttempt(workspace, series.id, episode.id, shot.id, attempt.id) - const rejectedAttempt = rejectedShot.attempts.find(item => item.id === attempt.id) - if (rejectedShot.id !== shot.id || rejectedAttempt?.reviewDecision !== 'rejected') { - throw new Error('Series Lab no confirmó el rechazo solicitado; recarga antes de continuar.') - } - await useSeriesStore.getState().reload() - await useSeriesStore.getState().openSeries(series.id) - useSeriesStore.getState().openEpisode(episode.id) - showLab('series') - openAgentSeriesSection('review') - return `He rechazado el intento ${attempt.id} del shot ${shot.order} en “${episode.title}” y he abierto Render & Review.` -} - -export async function assembleSeriesEpisode(action: AgentAssembleSeriesEpisodeAction): Promise { - if (!action.confirm) throw new Error('Ensamblar un episodio de Series Lab requiere confirm=true.') - const workspace = useStore.getState().activeWorkspace || 'default' - const [api, { useSeriesStore }] = await Promise.all([ - import('../../api/client'), - import('../series/store'), - ]) - await useSeriesStore.getState().loadWorkspace(workspace) - await useSeriesStore.getState().saveNow() - const library = await api.fetchSeriesLibrary(workspace) - const seriesMatches = action.seriesTitle - ? Object.values(library.seriesById).filter(item => normalizeName(item.title) === normalizeName(action.seriesTitle)) - : [] - if (seriesMatches.length > 1) throw new Error(`Hay varias series tituladas “${action.seriesTitle}”; el destino no es inequívoco.`) - const series = seriesMatches[0] - || (!action.seriesTitle ? library.seriesById[useSeriesStore.getState().activeSeriesId] : null) - if (!series) throw new Error(action.seriesTitle - ? `No existe la serie “${action.seriesTitle}” en este workspace.` - : 'No hay una serie activa que ensamblar.') - const episodeMatches = action.targetEpisodeTitle - ? Object.values(series.episodesById).filter(item => normalizeName(item.title) === normalizeName(action.targetEpisodeTitle)) - : [] - if (episodeMatches.length > 1) throw new Error(`Hay varios episodios titulados “${action.targetEpisodeTitle}”; el destino no es inequívoco.`) - const activeEpisodeId = useSeriesStore.getState().activeSeriesId === series.id - ? useSeriesStore.getState().activeEpisodeId : '' - const episodes = Object.values(series.episodesById) - const episode = episodeMatches[0] - || (!action.targetEpisodeTitle && activeEpisodeId ? series.episodesById[activeEpisodeId] : null) - || (!action.targetEpisodeTitle && episodes.length === 1 ? episodes[0] : null) - if (!episode) throw new Error(action.targetEpisodeTitle - ? `No existe el episodio “${action.targetEpisodeTitle}” en “${series.title}”.` - : `“${series.title}” necesita un episodio activo o único.`) - if (!episode.shots.length) throw new Error(`“${episode.title}” no tiene shots que ensamblar.`) - const incomplete = episode.shots.filter(shot => { - const approved = shot.attempts.find(attempt => attempt.id === shot.approvedAttemptId) - return !approved || approved.status !== 'completed' - || !approved.outputAssetIds.some(id => Boolean(series.assets[id])) - }) - if (incomplete.length) { - throw new Error(`Aprueba primero un vídeo reproducible para todos los shots. Faltan: ${incomplete.map(shot => shot.order).join(', ')}.`) - } - - await useSeriesStore.getState().openSeries(series.id) - useSeriesStore.getState().openEpisode(episode.id) - const job = await api.startSeriesEpisodeAssembly(workspace, series.id, episode.id) - if (job.workspace !== workspace || job.seriesId !== series.id || job.episodeId !== episode.id) { - throw new Error('Series Lab devolvió un ensamblado para otro destino; no se mostrará como correcto.') - } - notifyAgentSeriesAssemblyJob(job) - showLab('series') - openAgentSeriesSection('review') - return `He iniciado el ensamblado ordenado de ${episode.shots.length} shots de “${episode.title}” (${job.jobId}). El progreso recuperable y la descarga están abiertos en Render & Review; no he comprometido el delta de canon.` -} - -export async function commitSeriesCanonDelta(action: AgentCommitSeriesCanonAction): Promise { - if (!action.confirm) throw new Error('Comprometer cambios de canon requiere confirm=true.') - const workspace = useStore.getState().activeWorkspace || 'default' - const [api, { useSeriesStore }] = await Promise.all([import('../../api/client'), import('../series/store')]) - await useSeriesStore.getState().loadWorkspace(workspace) - await useSeriesStore.getState().saveNow() - const library = await api.fetchSeriesLibrary(workspace) - const matches = action.seriesTitle ? Object.values(library.seriesById).filter(item => normalizeName(item.title) === normalizeName(action.seriesTitle)) : [] - if (matches.length > 1) throw new Error(`Hay varias series tituladas “${action.seriesTitle}”; el destino no es inequívoco.`) - const series = matches[0] || (!action.seriesTitle ? library.seriesById[useSeriesStore.getState().activeSeriesId] : null) - if (!series) throw new Error(action.seriesTitle ? `No existe la serie “${action.seriesTitle}”.` : 'No hay una serie activa.') - const episodeMatches = action.targetEpisodeTitle ? Object.values(series.episodesById).filter(item => normalizeName(item.title) === normalizeName(action.targetEpisodeTitle)) : [] - if (episodeMatches.length > 1) throw new Error(`Hay varios episodios titulados “${action.targetEpisodeTitle}”.`) - const activeId = useSeriesStore.getState().activeSeriesId === series.id ? useSeriesStore.getState().activeEpisodeId : '' - const episodes = Object.values(series.episodesById) - const episode = episodeMatches[0] || (!action.targetEpisodeTitle && activeId ? series.episodesById[activeId] : null) || (!action.targetEpisodeTitle && episodes.length === 1 ? episodes[0] : null) - if (!episode) throw new Error(action.targetEpisodeTitle ? `No existe el episodio “${action.targetEpisodeTitle}”.` : 'La serie necesita un episodio activo o único.') - const deltaIds = [...episode.proposedCanonDelta.add.map(item => item.id), ...episode.proposedCanonDelta.change.map(item => item.id), ...episode.proposedCanonDelta.retire.map(item => item.factId)] - if (!deltaIds.length) throw new Error(`“${episode.title}” no tiene cambios de canon propuestos.`) - const unknown = action.itemIds.filter(id => !deltaIds.includes(id)) - if (unknown.length) throw new Error(`Cambios de canon desconocidos: ${unknown.join(', ')}.`) - const selected = action.decision.endsWith('_all') ? deltaIds : action.itemIds - const value = action.decision.startsWith('accept_') ? 'accepted' : 'rejected' - const decisions = Object.fromEntries(selected.map(id => [id, value])) as Record - const updated = await api.commitSeriesCanon(workspace, series.id, episode.id, episode.proposedCanonDelta.baseRevision, decisions) - if (updated.id !== series.id || !updated.episodesById[episode.id]) throw new Error('Series Lab confirmó decisiones para otro destino.') - await useSeriesStore.getState().reload() - await useSeriesStore.getState().openSeries(series.id) - useSeriesStore.getState().openEpisode(episode.id) - showLab('series') - openAgentSeriesSection('review') - openAgentSeriesReviewView('finish') - return `He marcado ${selected.length} cambio${selected.length === 1 ? '' : 's'} de canon como ${value === 'accepted' ? 'aceptados' : 'rechazados'} en “${episode.title}”. Los demás permanecen pendientes.` -} - +export * from '../series/actions' diff --git a/ui/src/features/agent/videoEditorActions.ts b/ui/src/features/agent/videoEditorActions.ts index 930f6fc6..84b7db55 100644 --- a/ui/src/features/agent/videoEditorActions.ts +++ b/ui/src/features/agent/videoEditorActions.ts @@ -1 +1,57 @@ -export * from '../video-editor/actions' +export { + addAgentVideoEditorAudio, + addAgentVideoEditorClips, + createAgentVideoEditorProject, + exportAgentVideoEditor, + openAgentVideoEditorProject, + orderAgentVideoEditorClips, + trackAgentVideoEditorExport, + trimAgentVideoEditorClip, + validateAgentVideoEditorTimeline, +} from '../video-editor/actions' + +export interface AgentCreateVideoEditorProjectAction { + type: 'create_video_editor_project' + projectName: string +} + +export interface AgentOpenVideoEditorProjectAction { + type: 'open_video_editor_project' + projectName: string +} + +export interface AgentAddVideoEditorClipsAction { + type: 'add_video_editor_clips' + outputNames: string[] +} + +export interface AgentOrderVideoEditorClipsAction { + type: 'order_video_editor_clips' + clipNames: string[] +} + +export interface AgentTrimVideoEditorClipAction { + type: 'trim_video_editor_clip' + clipName: string + trimStart: number + trimEnd: number +} + +export interface AgentAddVideoEditorAudioAction { + type: 'add_video_editor_audio' + clipName: string + outputName: string +} + +export interface AgentValidateVideoEditorTimelineAction { + type: 'validate_video_editor_timeline' +} + +export interface AgentExportVideoEditorAction { + type: 'export_video_editor' + confirm: true +} + +export interface AgentTrackVideoEditorExportAction { + type: 'track_video_editor_export' +} diff --git a/ui/src/features/agent/wizardLabSession.ts b/ui/src/features/agent/wizardLabSession.ts index c99479ec..66cf44c2 100644 --- a/ui/src/features/agent/wizardLabSession.ts +++ b/ui/src/features/agent/wizardLabSession.ts @@ -1,4 +1,7 @@ -import type { CharacterKitLibrary } from '../../lib/characterKit' +export { + rememberCharacterKitLibrary, + rememberedCharacterKitLibrary, +} from '../characters/session' export interface Video3dSessionSnapshot { scene_id: string @@ -7,17 +10,8 @@ export interface Video3dSessionSnapshot { state: string } -let characterKitLibrary: CharacterKitLibrary | null = null let video3dScene: Video3dSessionSnapshot | null = null -export function rememberCharacterKitLibrary(library: CharacterKitLibrary | null): void { - characterKitLibrary = library -} - -export function rememberedCharacterKitLibrary(): CharacterKitLibrary | null { - return characterKitLibrary -} - export function rememberVideo3dScene(scene: Video3dSessionSnapshot | null): void { video3dScene = scene } diff --git a/ui/src/features/characters/actions.ts b/ui/src/features/characters/actions.ts index d45dd112..fb95640a 100644 --- a/ui/src/features/characters/actions.ts +++ b/ui/src/features/characters/actions.ts @@ -7,55 +7,19 @@ import { } from '../../lib/characterKit' import { applyFaceRigMouthPreset, FACE_RIG_PRESET_ROOT, type FaceRigMouthPresetPack } from '../../lib/characterKitFaceRig' import { queueFaceRigHandoff } from '../../lib/characterKitHandoff' +import { commandResultFromSlice, type CommandResult } from '../../lib/commandContract' import { useStore } from '../../stores/useStore' -import { rememberCharacterKitLibrary } from '../agent/wizardLabSession' -import { executionReport, type AgentExecutionReport } from '../agent/agentContract' - -export interface AgentCreateCharacterKitAction { - type: 'create_character_kit' - name: string - style: CharacterKitStyle -} - -export interface AgentOpenCharacterKitAction { - type: 'open_character_kit' - kitName: string -} - -export interface AgentUpdateCharacterKitAction { - type: 'update_character_kit' - kitName: string - name: string - lookNotes: string - style: CharacterKitStyle | '' -} - -export interface AgentAttachCharacterKitReferencesAction { - type: 'attach_character_kit_references' - kitName: string - outputNames: string[] -} - -export interface AgentBuildCharacterKitAction { - type: 'build_character_kit' - kitName: string -} - -export interface AgentOpenCharacterKitRigAction { - type: 'open_character_kit_rig' - kitName: string -} - -export interface AgentApplyCharacterKitPresetAction { - type: 'apply_character_kit_preset' - kitName: string - presetId: string -} - -export interface AgentTrackCharacterKitJobAction { - type: 'track_character_kit_job' - kitName: string -} +import type { + ApplyCharacterKitPresetCommand, + AttachCharacterKitReferencesCommand, + BuildCharacterKitCommand, + CreateCharacterKitCommand, + OpenCharacterKitCommand, + OpenCharacterKitRigCommand, + TrackCharacterKitJobCommand, + UpdateCharacterKitCommand, +} from './commands' +import { rememberCharacterKitLibrary } from './session' const STYLES = new Set(['cutout', 'children-illustration', 'anime-2d']) @@ -63,14 +27,6 @@ function workspaceName(): string { return useStore.getState().activeWorkspace || 'default' } -function showCharacterKit(): void { - const state = useStore.getState() - state.setSettingsOpen(false) - state.setDashboardOpen(false) - state.setMediaFilter('characters') - state.setSidebarOpen(false) -} - function normalizeName(value: string): string { return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim().toLowerCase() } @@ -99,60 +55,56 @@ async function persist(library: CharacterKitLibrary, kit: CharacterKit): Promise return next } -function kitReport(kit: CharacterKit, message: string, state: AgentExecutionReport['state'] = 'completed'): AgentExecutionReport { - return executionReport({ - state, - message, - recoverable: false, - target: { kind: 'character_kit', id: kit.id, title: kit.name }, +function kitResult(kit: CharacterKit, destination: 'character_kit' | 'character_creator' = 'character_kit'): CommandResult { + const entity = { kind: 'character_kit', id: kit.id, workspaceId: workspaceName() } + return commandResultFromSlice({ + entity, + navigationTarget: { destination, entity }, }) } -export async function createAgentCharacterKit(action: AgentCreateCharacterKitAction): Promise<{ message: string; report: AgentExecutionReport }> { +export async function createAgentCharacterKit(command: CreateCharacterKitCommand): Promise { const library = await loadLibrary() - const existing = Object.values(library.kits).find(kit => normalizeName(kit.name) === normalizeName(action.name)) + const existing = Object.values(library.kits).find(kit => normalizeName(kit.name) === normalizeName(command.name)) if (existing) { rememberCharacterKitLibrary({ ...library, activeId: existing.id }) - showCharacterKit() - const message = `He abierto el Character Kit existente “${existing.name}”.` - return { message, report: kitReport(existing, message) } + const entity = { kind: 'character_kit', id: existing.id, workspaceId: workspaceName() } + return commandResultFromSlice({ + entity, + navigationTarget: { destination: 'character_kit', entity, section: 'existing' }, + }) } - const kit = createCharacterKit(action.name, STYLES.has(action.style) ? action.style : 'cutout') + const kit = createCharacterKit(command.name, STYLES.has(command.style) ? command.style : 'cutout') await persist(library, kit) - showCharacterKit() - const message = `He creado el Character Kit “${kit.name}”. Todavía no he generado poses.` - return { message, report: kitReport(kit, message) } + return kitResult(kit) } -export async function openAgentCharacterKit(action: AgentOpenCharacterKitAction): Promise<{ message: string; report: AgentExecutionReport }> { +export async function openAgentCharacterKit(command: OpenCharacterKitCommand): Promise { const library = await loadLibrary() - const kit = findKit(library, action.kitName) + const kit = findKit(library, command.kitName) rememberCharacterKitLibrary({ ...library, activeId: kit.id }) - showCharacterKit() - const message = `He abierto Character Kit “${kit.name}”.` - return { message, report: kitReport(kit, message) } + return kitResult(kit) } -export async function updateAgentCharacterKit(action: AgentUpdateCharacterKitAction): Promise<{ message: string; report: AgentExecutionReport }> { +export async function updateAgentCharacterKit(command: UpdateCharacterKitCommand): Promise { const library = await loadLibrary() - const current = findKit(library, action.kitName) + const current = findKit(library, command.kitName) const kit: CharacterKit = { ...current, - name: action.name.trim() || current.name, - lookNotes: action.lookNotes.trim() || current.lookNotes, - style: action.style && STYLES.has(action.style) ? action.style : current.style, + name: command.name.trim() || current.name, + lookNotes: command.lookNotes.trim() || current.lookNotes, + style: command.style && STYLES.has(command.style) ? command.style : current.style, updatedAt: new Date().toISOString(), } await persist(library, kit) - const message = `He actualizado la identidad de “${kit.name}”.` - return { message, report: kitReport(kit, message) } + return kitResult(kit) } -export async function attachAgentCharacterKitReferences(action: AgentAttachCharacterKitReferencesAction): Promise<{ message: string; report: AgentExecutionReport }> { +export async function attachAgentCharacterKitReferences(command: AttachCharacterKitReferencesCommand): Promise { const library = await loadLibrary() - const current = findKit(library, action.kitName) + const current = findKit(library, command.kitName) const outputs = await fetchOutputs(80, 0, { workspace: workspaceName(), mediaType: 'image' }) - const wanted = action.outputNames.map(name => name.trim()).filter(Boolean) + const wanted = command.outputNames.map(name => name.trim()).filter(Boolean) if (wanted.length !== 1) { throw new Error('Character Kit admite una única referencia de identidad. Indica un solo output exacto.') } @@ -172,13 +124,12 @@ export async function attachAgentCharacterKitReferences(action: AgentAttachChara updatedAt: new Date().toISOString(), } await persist(library, kit) - const message = `He adjuntado “${identity.name}” como referencia de identidad de “${kit.name}”.` - return { message, report: kitReport(kit, message) } + return kitResult(kit) } -export async function buildAgentCharacterKit(action: AgentBuildCharacterKitAction): Promise<{ message: string; report: AgentExecutionReport }> { +export async function buildAgentCharacterKit(command: BuildCharacterKitCommand): Promise { const library = await loadLibrary() - const current = findKit(library, action.kitName) + const current = findKit(library, command.kitName) const source = current.identityReference || current.base if (!source?.source) throw new Error('El kit no tiene una referencia de identidad para construir la pose base.') const kit: CharacterKit = { @@ -187,41 +138,36 @@ export async function buildAgentCharacterKit(action: AgentBuildCharacterKitActio updatedAt: new Date().toISOString(), } await persist(library, kit) - const message = `He montado el kit “${kit.name}” con la pose base. No he lanzado generación.` - return { message, report: kitReport(kit, message) } + return kitResult(kit) } -export async function openAgentCharacterKitRig(action: AgentOpenCharacterKitRigAction): Promise<{ message: string; report: AgentExecutionReport }> { +export async function openAgentCharacterKitRig(command: OpenCharacterKitRigCommand): Promise { const library = await loadLibrary() - const kit = findKit(library, action.kitName) + const kit = findKit(library, command.kitName) const source = kit.base?.source || kit.identityReference?.source if (source) queueFaceRigHandoff({ name: kit.name, source, workspace: workspaceName() }) - showCharacterKit() - const message = `He abierto el Face Rig de “${kit.name}”.` - return { message, report: kitReport(kit, message) } + return kitResult(kit) } -export async function applyAgentCharacterKitPreset(action: AgentApplyCharacterKitPresetAction): Promise<{ message: string; report: AgentExecutionReport }> { +export async function applyAgentCharacterKitPreset(command: ApplyCharacterKitPresetCommand): Promise { const library = await loadLibrary() - const current = findKit(library, action.kitName) + const current = findKit(library, command.kitName) const response = await fetch(`${FACE_RIG_PRESET_ROOT}/manifest.json`) if (!response.ok) throw new Error('No pude cargar los packs de visemas.') const data = await response.json() as { packs?: FaceRigMouthPresetPack[] } - const pack = (data.packs || []).find(item => item.id === action.presetId || item.label === action.presetId) - if (!pack) throw new Error(`No existe el preset de animación “${action.presetId}”.`) + const pack = (data.packs || []).find(item => item.id === command.presetId || item.label === command.presetId) + if (!pack) throw new Error(`No existe el preset de animación “${command.presetId}”.`) const kit = applyFaceRigMouthPreset(current, pack, workspaceName()) await persist(library, kit) - const message = `He aplicado el preset “${pack.label}” al Face Rig de “${kit.name}”.` - return { message, report: kitReport(kit, message) } + return kitResult(kit) } -export async function trackAgentCharacterKitJob(action: AgentTrackCharacterKitJobAction): Promise<{ message: string; report: AgentExecutionReport }> { - const { inspectCanonicalQueue } = await import('../agent/queueActions') +export async function trackAgentCharacterKitJob(command: TrackCharacterKitJobCommand): Promise { const library = await loadLibrary() - const kit = findKit(library, action.kitName) - const message = await inspectCanonicalQueue('active') - return { - message: `Sigo el trabajo de “${kit.name}”. ${message}`, - report: kitReport(kit, message, 'running'), - } + const kit = findKit(library, command.kitName) + const entity = { kind: 'character_kit', id: kit.id, workspaceId: workspaceName() } + return commandResultFromSlice({ + entity, + navigationTarget: { destination: 'activity', entity }, + }) } diff --git a/ui/src/features/characters/commands.ts b/ui/src/features/characters/commands.ts index 910116ca..a6cb6715 100644 --- a/ui/src/features/characters/commands.ts +++ b/ui/src/features/characters/commands.ts @@ -1,10 +1,39 @@ -export type { - AgentApplyCharacterKitPresetAction as ApplyCharacterKitPresetCommand, - AgentAttachCharacterKitReferencesAction as AttachCharacterKitReferencesCommand, - AgentBuildCharacterKitAction as BuildCharacterKitCommand, - AgentCreateCharacterKitAction as CreateCharacterKitCommand, - AgentOpenCharacterKitAction as OpenCharacterKitCommand, - AgentOpenCharacterKitRigAction as OpenCharacterKitRigCommand, - AgentTrackCharacterKitJobAction as TrackCharacterKitJobCommand, - AgentUpdateCharacterKitAction as UpdateCharacterKitCommand, -} from './actions' +import type { CharacterKitStyle } from '../../lib/characterKit' + +export interface CreateCharacterKitCommand { + name: string + style: CharacterKitStyle +} + +export interface OpenCharacterKitCommand { + kitName: string +} + +export interface UpdateCharacterKitCommand { + kitName: string + name: string + lookNotes: string + style: CharacterKitStyle | '' +} + +export interface AttachCharacterKitReferencesCommand { + kitName: string + outputNames: string[] +} + +export interface BuildCharacterKitCommand { + kitName: string +} + +export interface OpenCharacterKitRigCommand { + kitName: string +} + +export interface ApplyCharacterKitPresetCommand { + kitName: string + presetId: string +} + +export interface TrackCharacterKitJobCommand { + kitName: string +} diff --git a/ui/src/features/characters/session.ts b/ui/src/features/characters/session.ts new file mode 100644 index 00000000..c082c9c7 --- /dev/null +++ b/ui/src/features/characters/session.ts @@ -0,0 +1,11 @@ +import type { CharacterKitLibrary } from '../../lib/characterKit' + +let characterKitLibrary: CharacterKitLibrary | null = null + +export function rememberCharacterKitLibrary(library: CharacterKitLibrary | null): void { + characterKitLibrary = library +} + +export function rememberedCharacterKitLibrary(): CharacterKitLibrary | null { + return characterKitLibrary +} diff --git a/ui/src/features/comics/actions.ts b/ui/src/features/comics/actions.ts new file mode 100644 index 00000000..779ef372 --- /dev/null +++ b/ui/src/features/comics/actions.ts @@ -0,0 +1,424 @@ +import { commandResultFromSlice, type CommandResult } from '../../lib/commandContract' +import { useStore } from '../../stores/useStore' +import type { CreateComicCommand, GenerateComicCommand } from './commands' + +function workspaceName(): string { + return useStore.getState().activeWorkspace || 'default' +} + +function comicResult( + project: { id: string; title: string }, + message: string, + extra: { + status?: CommandResult['status'] + generated?: number + failed?: number + cancelled?: boolean + } = {}, +): CommandResult { + const entity = { kind: 'comic', id: project.id, workspaceId: workspaceName() } + return commandResultFromSlice({ + status: extra.status, + entity, + navigationTarget: { destination: 'comics', entity }, + artifacts: [{ + id: 'reply', + kind: 'document', + owner: entity, + uri: 'comics:reply', + metadata: { + summary: message, + title: project.title, + ...(extra.generated == null ? {} : { generated: extra.generated }), + ...(extra.failed == null ? {} : { failed: extra.failed }), + ...(extra.cancelled == null ? {} : { cancelled: extra.cancelled }), + }, + }], + }) +} + +export async function createFilledComic(action: CreateComicCommand): Promise { + const [{ useComicStore }, { comicId, projectFromPlan }] = await Promise.all([ + import('./store'), + import('./model'), + ]) + const characters = (action.characters.length ? action.characters : [{ + name: 'Protagonista', + role: 'Protagonista', + personality: '', + desire: '', + flaw: '', + appearance: 'Silueta clara y reconocible', + voice: '', + }]).map((character, index) => ({ + id: comicId('character'), + name: character.name || `Personaje ${index + 1}`, + description: character.appearance || character.role || character.name, + role: character.role || (index ? 'Secundario' : 'Protagonista'), + personality: character.personality, + motivation: character.desire, + voice: character.voice, + wardrobe: character.appearance || 'Vestuario fijo y reconocible durante todo el cómic.', + visualNotes: [character.appearance, action.styleName, 'Silueta, escala y paleta constantes.'].filter(Boolean).join('. '), + negativePrompt: 'inconsistent face, changed wardrobe, duplicate character, extra limbs, unreadable silhouette', + referenceAssetIds: [], + locked: false, + })) + const panels = action.panels.length ? action.panels : [ + { caption: action.synopsis || action.title, dialogue: '', sfx: '', scene: action.synopsis }, + ] + const requestedPages = action.pages.length ? action.pages : [{ title: 'Página 1', stage: '', panels }] + const allPanels = requestedPages.flatMap(page => page.panels) + const ending = allPanels.at(-1)?.dialogue + || allPanels.at(-1)?.caption + || `El conflicto de “${action.title}” se resuelve con una consecuencia visual clara.` + const castBible = characters.map(character => [ + `${character.name} (${character.role || 'personaje'})`, + character.description, + character.personality, + character.motivation, + ].filter(Boolean).join(': ')).join('\n') + const storyContext = [ + `Premisa: ${action.synopsis || action.title}`, + `Personajes:\n${castBible}`, + `Progresión: ${requestedPages.map((page, index) => `${index + 1}. ${page.title}: ${page.stage || page.panels[0]?.scene || page.panels[0]?.caption}`).join(' → ')}`, + `Final: ${ending}`, + ].join('\n\n') + const worldContext = [ + `Universo visual de “${action.title}”.`, + action.synopsis, + `Mantener localizaciones, época, escala y utilería coherentes durante ${requestedPages.length} páginas y ${allPanels.length} viñetas.`, + ].filter(Boolean).join(' ') + const forbiddenElements = [ + 'No cambiar el diseño, la edad aparente, la paleta ni el vestuario de los personajes entre viñetas.', + 'No añadir texto, bocadillos, marcos, cuadrículas, logotipos ni marcas de agua dentro de las imágenes generadas.', + 'No duplicar personajes ni introducir elementos ajenos a la escena.', + ].join(' ') + const planId = comicId('plan') + const plan = { + version: 1 as const, + id: planId, + title: action.title, + logline: action.synopsis, + synopsis: action.synopsis || action.title, + language: action.language || 'Español', + styleBible: action.styleName || 'Tira cómica clara, 4 viñetas', + characters, + storyStructure: requestedPages.map((page, pageIndex) => ({ + pageNumber: pageIndex + 1, stage: page.stage || page.title, + goal: `Representar con claridad la etapa “${page.title}”.`, + turningPoint: page.panels.at(-1)?.dialogue || page.panels.at(-1)?.caption || page.stage || page.title, + })), + pages: requestedPages.map((page, pageIndex) => ({ + pageNumber: pageIndex + 1, + layoutHint: 'grid' as const, + panels: page.panels.map((panel, index) => { + const beat = panel.scene || panel.caption || panel.dialogue || action.synopsis + const who = characters.map(character => `${character.name}: ${character.description}`).join('; ') + return { + id: comicId('panel-plan'), + order: index + 1, + narrativeRole: `${page.title} · viñeta ${index + 1}`, + sceneDescription: beat, + imagePrompt: [ + `Single comic panel for "${action.title}".`, + action.styleName, + beat ? `Scene: ${beat}.` : '', + who ? `Characters: ${who}.` : '', + 'Clear acting, readable silhouette, no lettering, no balloons, no captions.', + ].filter(Boolean).join(' '), + characters: characters.map(character => character.id), + framing: 'medium', + dialogue: panel.dialogue ? [{ text: panel.dialogue, bubbleType: 'speech' as const }] : [], + captions: panel.caption ? [panel.caption] : [], + soundEffects: panel.sfx ? [panel.sfx] : [], + continuityNotes: `Conservar identidad, vestuario, paleta, iluminación y eje espacial respecto a la viñeta ${Math.max(1, index)}.`, + } + }), + })), + } + const project = projectFromPlan(plan) + if (action.styleName) { + project.style = { + ...project.style, + name: action.styleName, + promptSuffix: `${action.styleName}. Consistent character design, readable acting, coherent palette and continuity across panels.`, + } + } + const studio = useStore.getState() + const provider = action.imageProvider === 'minimax' ? 'minimax' as const + : action.imageProvider === 'maestro' ? 'maestro' as const + : studio.productionProfile.image.provider === 'minimax' ? 'minimax' as const : 'maestro' as const + const imageModel = action.imageModel || (provider === 'minimax' ? 'image-01' : studio.productionProfile.image.model || studio.selectedModelPerMode.image || '') + project.director = { + planId, + provider, + imageModel, + input: { + useGlobalProfile: true, + premise: action.synopsis || action.title, + storyContext, + productionMode: 'comic', + pageCount: requestedPages.length, + language: action.language || 'Español', + format: project.format.preset, + panelsPerPage: Math.max(...requestedPages.map(page => page.panels.length)), + genre: 'Comedy', + tone: 'Warm', + audience: 'General', + artStyle: action.styleName, + worldContext, + forbiddenElements, + dialogueDensity: 'medium', + provider, + imageModel, + characters, + ending, + }, + plan, + completedPanelIds: [], + failedPanelIds: [], + panelJobs: {}, + factualBiography: action.factualBiography === true, + scriptVersion: 1, + scriptApprovedAt: new Date().toISOString(), + } + useComicStore.getState().setProject(project) + useComicStore.setState({ dirty: true }) + const stored = useComicStore.getState().project + const storedPanels = stored.pages.reduce((sum, page) => ( + sum + page.elements.filter(element => element.type === 'panel' && !element.parentId).length + ), 0) + if (stored.pages.length !== requestedPages.length || storedPanels !== allPanels.length) { + throw new Error(`El cómic guardado tiene ${stored.pages.length} páginas y ${storedPanels} viñetas; pediste ${requestedPages.length} páginas y ${allPanels.length} viñetas.`) + } + return comicResult( + project, + `He creado desde cero “${project.title}” con ${requestedPages.length} páginas, ${characters.length} personajes y ${allPanels.length} viñetas. Comic Director usará ${provider === 'minimax' ? 'MiniMax image-01' : imageModel || 'el modelo local seleccionado'}. No he generado imágenes todavía.`, + ) +} + +export async function generateFilledComicArtwork( + action: GenerateComicCommand, + onProgress?: (message: string) => void, +): Promise { + const [{ useComicStore }, { comicId }, { generateDirectorArtwork }] = await Promise.all([ + import('./store'), + import('./model'), + import('./generateArtwork'), + ]) + const state = useComicStore.getState() + if (!state.project.director) { + const project = state.project + const characters = (project.characters.length ? project.characters : [{ + id: comicId('character'), + name: 'Protagonista', + description: 'Silueta clara', + locked: false, + }]).map(character => ({ + ...character, + role: character.role || 'Personaje principal', + personality: character.personality || 'Expresivo y coherente con el tono del cómic.', + motivation: character.motivation || project.synopsis || 'Resolver el conflicto de la historia.', + voice: character.voice || 'Voz breve, clara y diferenciada.', + wardrobe: character.wardrobe || character.description || 'Vestuario fijo y reconocible.', + visualNotes: character.visualNotes || `${character.description}. Silueta, escala y paleta constantes.`, + negativePrompt: character.negativePrompt || 'inconsistent face, changed wardrobe, duplicate character, extra limbs', + referenceAssetIds: character.referenceAssetIds || [], + })) + const pages = project.pages.map((page, pageIndex) => { + const panels = page.elements + .filter(element => element.type === 'panel' && !element.parentId) + .sort((left, right) => left.zIndex - right.zIndex) + return { + pageNumber: pageIndex + 1, + layoutHint: 'grid' as const, + panels: panels.map((panel, index) => { + const texts = page.elements.filter(element => element.type === 'text' && element.parentId === panel.id) + const captions = texts + .filter(text => text.type === 'text' && (text.letteringType === 'caption' || text.bubble === 'caption')) + .map(text => text.type === 'text' ? text.content : '') + const soundEffects = texts + .filter(text => text.type === 'text' && (text.letteringType === 'sound-effect' || text.bubble === 'burst')) + .map(text => text.type === 'text' ? text.content : '') + const dialogue = texts + .filter(text => text.type === 'text' && (text.letteringType === 'dialogue' || text.bubble === 'speech')) + .map(text => text.type === 'text' ? text.content : '') + .filter(content => !captions.includes(content) && !soundEffects.includes(content)) + const beat = captions[0] || dialogue[0] || project.synopsis || project.title + return { + id: comicId('panel-plan'), + order: index + 1, + narrativeRole: `Viñeta ${index + 1}`, + sceneDescription: beat, + imagePrompt: [ + `Single comic panel for "${project.title}".`, + project.style.name, + beat ? `Scene: ${beat}.` : '', + 'Clear acting, readable silhouette, no lettering, no balloons, no captions.', + ].filter(Boolean).join(' '), + characters: characters.map(character => character.id), + framing: 'medium', + dialogue: dialogue.map(text => ({ text, bubbleType: 'speech' as const })), + captions, + soundEffects, + continuityNotes: `Conservar identidad, vestuario, paleta y eje espacial respecto a la viñeta ${Math.max(1, index)}.`, + } + }), + } + }) + if (!pages.some(page => page.panels.length)) { + throw new Error('El cómic abierto no tiene viñetas que dibujar.') + } + const studio = useStore.getState() + const provider = studio.productionProfile.image.provider === 'minimax' ? 'minimax' as const : 'maestro' as const + const imageModel = studio.productionProfile.image.model || studio.selectedModelPerMode.image || '' + const plan = { + version: 1 as const, + id: comicId('plan'), + title: project.title, + logline: project.synopsis, + synopsis: project.synopsis || project.title, + language: project.language, + styleBible: project.style.name, + characters, + storyStructure: pages.map((page, index) => ({ + pageNumber: page.pageNumber, + stage: index === 0 ? 'Planteamiento y complicación' : `Desarrollo ${index + 1}`, + goal: project.synopsis || `Hacer avanzar “${project.title}”.`, + turningPoint: page.panels.at(-1)?.dialogue.at(-1)?.text + || page.panels.at(-1)?.captions.at(-1) + || `Cerrar el beat de la página ${page.pageNumber}.`, + })), + pages, + } + const storyContext = [ + `Premisa: ${project.synopsis || project.title}`, + `Personajes: ${characters.map(character => `${character.name}: ${character.description}`).join('; ')}`, + `Estructura: ${plan.storyStructure.map(beat => beat.turningPoint).join(' → ')}`, + ].join('\n\n') + useComicStore.getState().patchProject({ + characters, + director: { + planId: plan.id, + provider, + imageModel, + input: { + useGlobalProfile: true, + premise: project.synopsis || project.title, + storyContext, + productionMode: 'comic', + pageCount: pages.length, + language: project.language, + format: project.format.preset, + panelsPerPage: Math.max(1, pages[0]?.panels.length || 4), + genre: 'Comedy', + tone: 'Warm', + audience: 'General', + artStyle: project.style.name, + worldContext: `Universo visual de “${project.title}”. Mantener época, localizaciones, escala y utilería coherentes entre páginas.`, + forbiddenElements: 'No cambiar identidades ni vestuario. No añadir texto, cuadrículas, marcos, logos o marcas de agua dentro de la ilustración.', + dialogueDensity: 'medium', + provider, + imageModel, + characters, + ending: plan.storyStructure.at(-1)?.turningPoint, + }, + plan, + completedPanelIds: [], + failedPanelIds: [], + panelJobs: {}, + scriptVersion: 1, + scriptApprovedAt: new Date().toISOString(), + }, + }) + } + if (!useComicStore.getState().project.director) { + throw new Error('No hay un cómic con plan de Director abierto. Pide primero un cómic de ejemplo o crea uno con tema.') + } + const current = useComicStore.getState().project.director! + if (current.factualBiography && !current.biographyReviewedAt && !action.biographyReview) { + throw new Error('Este cómic es una biografía factual. Confirma hechos, inferencias y dramatización (biography_review=true) antes de dibujar; no inventaré familiares, citas ni acontecimientos.') + } + if (action.biographyReview && !current.biographyReviewedAt) { + useComicStore.getState().patchProject({ + director: { ...current, biographyReviewedAt: new Date().toISOString() }, + }) + } + if (action.imageProvider !== 'keep' || action.imageModel) { + const latest = useComicStore.getState().project.director! + const provider = action.imageProvider === 'keep' ? latest.provider : action.imageProvider + const imageModel = action.imageModel || (provider === 'minimax' ? 'image-01' : latest.imageModel) + useComicStore.getState().patchProject({ director: { ...latest, provider, imageModel, input: { ...latest.input, provider, imageModel } } }) + } + const pages = action.pilot ? [1] : (action.pages || []) + const result = await generateDirectorArtwork({ + scope: action.scope || 'missing', + pages: pages.length ? pages : undefined, + force: action.scope === 'all', + onProgress: (message, current, total) => { + onProgress?.(`${message} (${current}/${total})`) + }, + }) + const project = useComicStore.getState().project + const provider = project.director?.provider + const providerLabel = provider === 'minimax' ? 'MiniMax image-01' : 'el proveedor local configurado' + if (!result.total) { + return comicResult(project, 'Todas las viñetas de este cómic ya tenían dibujo.', { + status: 'completed', generated: 0, failed: 0, cancelled: false, + }) + } + if (result.cancelled) { + return comicResult( + project, + `He cancelado el lote con ${result.generated} viñetas terminadas y ${result.failed} fallidas; no he perdido lo ya dibujado.`, + { + status: result.generated > 0 ? 'partial' : 'failed', + generated: result.generated, + failed: result.failed, + cancelled: true, + }, + ) + } + if (result.failed) { + return comicResult( + project, + `He dibujado ${result.generated} viñetas con ${providerLabel} y han fallado ${result.failed}. Puedo reanudar desde la primera pendiente o reintentar las fallidas.`, + { + status: result.generated > 0 ? 'partial' : 'failed', + generated: result.generated, + failed: result.failed, + cancelled: false, + }, + ) + } + return comicResult( + project, + `He dibujado ${result.generated} viñetas con ${providerLabel}. Aparecen dentro de cada recuadro al terminar.`, + { status: 'completed', generated: result.generated, failed: 0, cancelled: false }, + ) +} + +export async function generateComicPanelArtwork( + pageNumber: number, + panelNumber: number, + onProgress?: (message: string) => void, +): Promise { + const [{ useComicStore }, { generateDirectorArtwork }] = await Promise.all([ + import('./store'), + import('./generateArtwork'), + ]) + if (!useComicStore.getState().project.director) { + throw new Error('El cómic abierto no tiene un plan de Director. Crea primero el borrador completo antes de regenerar una viñeta.') + } + const result = await generateDirectorArtwork({ + force: true, + target: { pageNumber, panelNumber }, + onProgress: (message, current, total) => onProgress?.(`${message} (${current}/${total})`), + }) + if (result.failed) throw new Error(`No pude regenerar la viñeta ${panelNumber} de la página ${pageNumber}.`) + return comicResult( + useComicStore.getState().project, + `He regenerado únicamente la viñeta ${panelNumber} de la página ${pageNumber}; las demás imágenes permanecen intactas (${result.generated}/${result.total}).`, + ) +} diff --git a/ui/src/features/comics/adapters.ts b/ui/src/features/comics/adapters.ts new file mode 100644 index 00000000..e539c18d --- /dev/null +++ b/ui/src/features/comics/adapters.ts @@ -0,0 +1,14 @@ +import { createFilledComic, generateComicPanelArtwork, generateFilledComicArtwork } from './actions' +import type { CreateComicCommand, GenerateComicCommand } from './commands' + +export async function create(command: CreateComicCommand) { + return createFilledComic(command) +} + +export async function generate(command: GenerateComicCommand) { + return generateFilledComicArtwork(command) +} + +export async function generatePanel(pageNumber: number, panelNumber: number) { + return generateComicPanelArtwork(pageNumber, panelNumber) +} diff --git a/ui/src/features/comics/commands.ts b/ui/src/features/comics/commands.ts new file mode 100644 index 00000000..3ba3064b --- /dev/null +++ b/ui/src/features/comics/commands.ts @@ -0,0 +1,43 @@ +import type { CreativeCharacter } from '../../lib/labHelpers' + +export interface ComicPanelCommand { + caption: string + dialogue: string + sfx: string + scene?: string +} + +export interface ComicPageCommand { + title: string + stage: string + panels: ComicPanelCommand[] +} + +export interface CreateComicCommand { + title: string + synopsis: string + language: string + styleName: string + characters: CreativeCharacter[] + panels: ComicPanelCommand[] + pages: ComicPageCommand[] + imageProvider: 'profile' | 'maestro' | 'minimax' + imageModel: string + factualBiography: boolean +} + +export interface GenerateComicCommand { + imageProvider: 'keep' | 'maestro' | 'minimax' + imageModel: string + scope: 'all' | 'missing' | 'failed' + pages: number[] + pilot: boolean + biographyReview: boolean + confirm: true +} + +export interface GenerateComicPanelCommand { + pageNumber: number + panelNumber: number + confirm: true +} diff --git a/ui/src/features/series/actions.ts b/ui/src/features/series/actions.ts new file mode 100644 index 00000000..ef42d77a --- /dev/null +++ b/ui/src/features/series/actions.ts @@ -0,0 +1,654 @@ +import { commandResultFromSlice, type CommandResult } from '../../lib/commandContract' +import { + boundedDuration, + creativeCharacters, + creativeLocations, + normalizeName, + outlineBeats, +} from '../../lib/labHelpers' +import { useStore } from '../../stores/useStore' +import type { + ApplySeriesPlanCommand, + AssembleSeriesEpisodeCommand, + CommitSeriesCanonCommand, + CreateSeriesEpisodeCommand, + GenerateSeriesPlanCommand, + RenderSeriesShotsCommand, + ReviewSeriesAttemptsCommand, + UpdateSeriesEpisodeCommand, +} from './commands' + +function seriesEpisodeResult( + workspaceId: string, + episode: { id: string; title: string }, + section: 'episode' | 'review', + message: string, + extra: { + taskIds?: string[] + channel?: 'series_plan' | 'series_render' | 'series_assembly' | 'series_plan_clear' + job?: Record + reviewView?: string + } = {}, +): CommandResult { + const entity = { kind: 'series_episode', id: episode.id, workspaceId } + return commandResultFromSlice({ + entity, + taskIds: extra.taskIds, + navigationTarget: { + destination: 'series_lab', + section, + entity, + ...(extra.reviewView ? { anchor: extra.reviewView } : {}), + }, + artifacts: [{ + id: 'reply', + kind: 'document', + owner: entity, + uri: 'series:reply', + metadata: { + summary: message, + ...(extra.channel ? { channel: extra.channel } : {}), + ...(extra.job ? { job: extra.job } : {}), + }, + }], + }) +} + +export async function createFilledSeriesEpisode(action: CreateSeriesEpisodeCommand): Promise { + const workspace = useStore.getState().activeWorkspace || 'default' + const [api, { useSeriesStore }, seriesModel] = await Promise.all([ + import('../../api/client'), + import('./store'), + import('./model'), + ]) + const store = useSeriesStore.getState() + await store.loadWorkspace(workspace) + await useSeriesStore.getState().saveNow() + + let library = await api.fetchSeriesLibrary(workspace) + const requestedName = normalizeName(action.seriesTitle) + let series = requestedName + ? Object.values(library.seriesById).find(item => normalizeName(item.title) === requestedName) + : library.seriesById[useSeriesStore.getState().activeSeriesId] + const createdSeries = !series + if (!series) { + if (!action.createIfMissing) throw new Error(`No existe la serie “${action.seriesTitle}” y la orden no autorizó crearla.`) + series = await api.createSeriesProject(workspace, action.seriesTitle || 'Nueva serie') + } + + const existingEpisode = Object.values(series.episodesById).find(episode => ( + normalizeName(episode.title) === normalizeName(action.episodeTitle) + && normalizeName(episode.premise) === normalizeName(action.episodePremise) + )) + if (existingEpisode) { + await useSeriesStore.getState().reload() + await useSeriesStore.getState().openSeries(series.id) + useSeriesStore.getState().openEpisode(existingEpisode.id) + return seriesEpisodeResult( + workspace, + existingEpisode, + 'episode', + `El episodio “${existingEpisode.title}” ya existía; lo he abierto en Series Lab → Episode room.`, + ) + } + + const characters = series.characters.length ? series.characters : creativeCharacters(action.characters).map((character, index) => ({ + ...seriesModel.createSeriesCharacter(), + name: character.name || `Personaje ${index + 1}`, + role: character.role || (index ? 'Secundario' : 'Protagonista'), + personality: character.personality, + desire: character.desire, + need: `Aprender algo que contradice su deseo inmediato: ${character.desire || 'resolver el conflicto'}.`, + flaw: character.flaw, + longArc: action.seriesPremise, + voiceAndDialogue: character.voice, + appearance: character.appearance, + identityLock: `${character.appearance}. Mantener identidad, edad aparente y vestuario entre episodios.`, + })) + const locations = series.locations.length ? series.locations : creativeLocations(action.locations).map(location => ({ + ...seriesModel.createSeriesLocation(), + name: location.name, + purpose: location.purpose, + description: location.description, + })) + const needsSetup = createdSeries + || !series.premise.trim() + || !series.visualStyle.trim() + || !series.canon.worldSummary.trim() + || !series.characters.length + || !series.locations.length + if (needsSetup) { + const patched = { + ...series, + title: series.title === 'Untitled series' ? action.seriesTitle : series.title, + premise: series.premise || action.seriesPremise || action.episodePremise, + logline: series.logline || action.seriesLogline || action.episodeLogline, + genre: series.genre || action.genre || 'Comedia dramática', + tone: series.tone || action.tone || 'Cinematográfico', + visualStyle: series.visualStyle || action.visualStyle || 'Continuidad televisiva cinematográfica, composición clara y personajes consistentes.', + characterVisualStyle: series.characterVisualStyle || action.visualStyle || 'Identidades y vestuario consistentes entre episodios.', + cameraLanguage: series.cameraLanguage || 'Planos de situación claros, planos medios para diálogo y primeros planos para reacciones.', + language: action.language || series.language, + spokenLanguage: action.language || series.spokenLanguage, + sourceMode: action.knownUniverse ? 'known_universe_experimental' as const : series.sourceMode, + masterUniversePrompt: series.masterUniversePrompt || (action.knownUniverse + ? `Borrador fan inspirado en ${action.seriesTitle}; conservar los rasgos generales sin afirmar derechos sobre la obra original.` + : ''), + rightsNote: series.rightsNote || (action.knownUniverse + ? 'Borrador creativo no oficial. Verifica los derechos necesarios antes de publicar o monetizar.' + : ''), + canon: { + ...series.canon, + worldSummary: series.canon.worldSummary || action.worldSummary || action.seriesPremise || action.episodePremise, + immutableRules: series.canon.immutableRules.length ? series.canon.immutableRules : [{ + id: seriesModel.seriesId('fact'), + description: 'Mantener personalidades, relaciones, espacios y consecuencias coherentes entre episodios.', + status: 'draft' as const, + }], + themes: series.canon.themes.length ? series.canon.themes : [action.theme || 'Relaciones y consecuencias cotidianas'], + approval: 'draft' as const, + approvedAt: undefined, + }, + characters, + locations, + updatedAt: new Date().toISOString(), + } + series = await api.saveSeriesProject(workspace, patched, series.revision) + } + let approvedCanon = false + if (series.canon.approval !== 'approved') { + series = await api.approveSeriesCanon(workspace, series.id, series.canon.revision) + approvedCanon = true + } + + const beats = outlineBeats(action.outlineBeats, action.episodePremise, action.ending) + const createdEpisode = await api.createSeriesEpisode( + workspace, + series.id, + series.seasons[0]?.id, + { + title: action.episodeTitle || `Episodio ${Object.keys(series.episodesById).length + 1}`, + premise: action.episodePremise, + logline: action.episodeLogline, + targetDurationSeconds: boundedDuration(action.targetDurationSeconds, series.defaultEpisodeDurationSeconds), + status: 'outline', + outline: { beats }, + }, + ) + + library = await api.fetchSeriesLibrary(workspace) + series = library.seriesById[series.id] + useSeriesStore.setState({ hydrated: false }) + await useSeriesStore.getState().loadWorkspace(workspace) + await useSeriesStore.getState().openSeries(series.id) + useSeriesStore.getState().openEpisode(createdEpisode.id) + const canonResult = approvedCanon ? 'preparado y aprobado el canon editable necesario, y ' : '' + return seriesEpisodeResult( + workspace, + createdEpisode, + 'episode', + `He ${createdSeries ? 'creado la serie, ' : ''}${canonResult}guardado el episodio “${createdEpisode.title}” con ${beats.length} beats; está abierto en Series Lab → Episode room.`, + ) +} + +export async function updateSeriesEpisode(action: UpdateSeriesEpisodeCommand): Promise { + const workspace = useStore.getState().activeWorkspace || 'default' + const [api, { useSeriesStore }] = await Promise.all([ + import('../../api/client'), + import('./store'), + ]) + await useSeriesStore.getState().loadWorkspace(workspace) + await useSeriesStore.getState().saveNow() + const library = await api.fetchSeriesLibrary(workspace) + const seriesMatches = action.seriesTitle + ? Object.values(library.seriesById).filter(item => normalizeName(item.title) === normalizeName(action.seriesTitle)) + : [] + if (seriesMatches.length > 1) throw new Error(`Hay varias series tituladas “${action.seriesTitle}”; renombra una para poder elegirla sin ambigüedad.`) + const series = seriesMatches[0] + || (!action.seriesTitle ? library.seriesById[useSeriesStore.getState().activeSeriesId] : null) + if (!series) throw new Error(action.seriesTitle + ? `No existe la serie “${action.seriesTitle}” en este workspace.` + : 'No hay una serie activa que modificar.') + + const episodeMatches = action.targetEpisodeTitle + ? Object.values(series.episodesById).filter(item => normalizeName(item.title) === normalizeName(action.targetEpisodeTitle)) + : [] + if (episodeMatches.length > 1) { + throw new Error(`Hay varios episodios titulados “${action.targetEpisodeTitle}”; usa un título inequívoco antes de modificarlos.`) + } + const activeEpisodeId = useSeriesStore.getState().activeSeriesId === series.id + ? useSeriesStore.getState().activeEpisodeId : '' + const episodes = Object.values(series.episodesById) + const episode = episodeMatches[0] + || (!action.targetEpisodeTitle && activeEpisodeId ? series.episodesById[activeEpisodeId] : null) + || (!action.targetEpisodeTitle && episodes.length === 1 ? episodes[0] : null) + if (!episode) throw new Error(action.targetEpisodeTitle + ? `No existe el episodio “${action.targetEpisodeTitle}” en “${series.title}”.` + : `“${series.title}” necesita un episodio activo o un único episodio para poder inferir el destino.`) + + await useSeriesStore.getState().openSeries(series.id) + useSeriesStore.getState().openEpisode(episode.id) + useSeriesStore.getState().updateEpisode(episode.id, current => ({ + ...current, + title: action.episodeTitle || current.title, + premise: action.episodePremise || current.premise, + logline: action.episodeLogline || current.logline, + targetDurationSeconds: action.targetDurationSeconds ?? current.targetDurationSeconds, + outline: action.outlineBeats.length ? { beats: action.outlineBeats } : current.outline, + })) + const saved = await useSeriesStore.getState().saveNow() + const verified = saved?.episodesById[episode.id] + if (!verified) throw new Error(`Series Lab no devolvió el episodio “${episode.title}” tras guardarlo.`) + if (action.episodeTitle && verified.title !== action.episodeTitle) throw new Error('El backend no confirmó el nuevo título del episodio.') + if (action.episodePremise && verified.premise !== action.episodePremise) throw new Error('El backend no confirmó la nueva premisa del episodio.') + if (action.episodeLogline && verified.logline !== action.episodeLogline) throw new Error('El backend no confirmó la nueva logline del episodio.') + if (action.outlineBeats.length && JSON.stringify(verified.outline.beats) !== JSON.stringify(action.outlineBeats)) { + throw new Error('El backend no confirmó la nueva estructura del episodio.') + } + return seriesEpisodeResult( + workspace, + verified, + 'episode', + `He actualizado y guardado “${verified.title}” en la serie “${saved.title}”; conserva ${verified.script.length} escenas y ${verified.shots.length} tomas existentes.`, + ) +} + +export async function generateSeriesPlan(action: GenerateSeriesPlanCommand): Promise { + if (!action.confirm) throw new Error('Generar un plan de Series Lab requiere confirm=true.') + const workspace = useStore.getState().activeWorkspace || 'default' + const [api, { useSeriesStore }] = await Promise.all([ + import('../../api/client'), + import('./store'), + ]) + await useSeriesStore.getState().loadWorkspace(workspace) + await useSeriesStore.getState().saveNow() + const library = await api.fetchSeriesLibrary(workspace) + const seriesMatches = action.seriesTitle + ? Object.values(library.seriesById).filter(item => normalizeName(item.title) === normalizeName(action.seriesTitle)) + : [] + if (seriesMatches.length > 1) throw new Error(`Hay varias series tituladas “${action.seriesTitle}”; el destino no es inequívoco.`) + const series = seriesMatches[0] + || (!action.seriesTitle ? library.seriesById[useSeriesStore.getState().activeSeriesId] : null) + if (!series) throw new Error(action.seriesTitle + ? `No existe la serie “${action.seriesTitle}” en este workspace.` + : 'No hay una serie activa que planificar.') + const episodeMatches = action.targetEpisodeTitle + ? Object.values(series.episodesById).filter(item => normalizeName(item.title) === normalizeName(action.targetEpisodeTitle)) + : [] + if (episodeMatches.length > 1) throw new Error(`Hay varios episodios titulados “${action.targetEpisodeTitle}”; el destino no es inequívoco.`) + const activeEpisodeId = useSeriesStore.getState().activeSeriesId === series.id + ? useSeriesStore.getState().activeEpisodeId : '' + const episodes = Object.values(series.episodesById) + const episode = episodeMatches[0] + || (!action.targetEpisodeTitle && activeEpisodeId ? series.episodesById[activeEpisodeId] : null) + || (!action.targetEpisodeTitle && episodes.length === 1 ? episodes[0] : null) + if (!episode) throw new Error(action.targetEpisodeTitle + ? `No existe el episodio “${action.targetEpisodeTitle}” en “${series.title}”.` + : `“${series.title}” necesita un episodio activo o único.`) + if (!episode.premise.trim()) throw new Error(`“${episode.title}” necesita una premisa antes de planificarse.`) + if (action.scope === 'shots' && !episode.script.length) { + throw new Error('Regenerar shots requiere un guion existente; genera script o complete primero.') + } + + await useSeriesStore.getState().openSeries(series.id) + useSeriesStore.getState().openEpisode(episode.id) + const job = await api.startSeriesPlan(workspace, series.id, episode.id, { + scope: action.scope, + instruction: action.instruction, + writingProvider: series.provider.writingProvider, + writingModel: series.provider.writingModel, + writingBaseUrl: series.provider.writingBaseUrl, + }) + if (job.seriesId !== series.id || job.episodeId !== episode.id) { + throw new Error('Series Lab devolvió un job asociado a otro episodio; no lo mostraré como correcto.') + } + return seriesEpisodeResult( + workspace, + episode, + 'episode', + `He iniciado el plan ${action.scope} de “${episode.title}” (${job.jobId}). El progreso y la propuesta recuperable están abiertos en Series Lab → Episode room; todavía no se ha aplicado ni renderizado.`, + { taskIds: [job.jobId], channel: 'series_plan', job: job as unknown as Record }, + ) +} + +export async function applySeriesPlan(action: ApplySeriesPlanCommand): Promise { + if (!action.confirm) throw new Error('Aplicar una propuesta de Series Lab requiere confirm=true.') + const workspace = useStore.getState().activeWorkspace || 'default' + const [api, { useSeriesStore }] = await Promise.all([ + import('../../api/client'), + import('./store'), + ]) + await useSeriesStore.getState().loadWorkspace(workspace) + await useSeriesStore.getState().saveNow() + const library = await api.fetchSeriesLibrary(workspace) + const seriesMatches = action.seriesTitle + ? Object.values(library.seriesById).filter(item => normalizeName(item.title) === normalizeName(action.seriesTitle)) + : [] + if (seriesMatches.length > 1) throw new Error(`Hay varias series tituladas “${action.seriesTitle}”; el destino no es inequívoco.`) + const series = seriesMatches[0] + || (!action.seriesTitle ? library.seriesById[useSeriesStore.getState().activeSeriesId] : null) + if (!series) throw new Error(action.seriesTitle + ? `No existe la serie “${action.seriesTitle}” en este workspace.` + : 'No hay una serie activa para aplicar la propuesta.') + const episodeMatches = action.targetEpisodeTitle + ? Object.values(series.episodesById).filter(item => normalizeName(item.title) === normalizeName(action.targetEpisodeTitle)) + : [] + if (episodeMatches.length > 1) throw new Error(`Hay varios episodios titulados “${action.targetEpisodeTitle}”; el destino no es inequívoco.`) + const activeEpisodeId = useSeriesStore.getState().activeSeriesId === series.id + ? useSeriesStore.getState().activeEpisodeId : '' + const episodes = Object.values(series.episodesById) + const episode = episodeMatches[0] + || (!action.targetEpisodeTitle && activeEpisodeId ? series.episodesById[activeEpisodeId] : null) + || (!action.targetEpisodeTitle && episodes.length === 1 ? episodes[0] : null) + if (!episode) throw new Error(action.targetEpisodeTitle + ? `No existe el episodio “${action.targetEpisodeTitle}” en “${series.title}”.` + : `“${series.title}” necesita un episodio activo o único.`) + + const job = action.jobId + ? await api.fetchSeriesPlanJob(action.jobId) + : (await api.fetchSeriesPlanRecovery(workspace)).jobs + .filter(item => item.seriesId === series.id && item.episodeId === episode.id && item.status === 'completed' && item.episodeResult) + .sort((left, right) => Number(right.updatedAt || right.finishedAt || 0) - Number(left.updatedAt || left.finishedAt || 0))[0] + if (!job) throw new Error(`No hay una propuesta completada y recuperable para “${episode.title}”.`) + if (job.workspace !== workspace || job.seriesId !== series.id || job.episodeId !== episode.id) { + throw new Error('El job indicado pertenece a otro workspace, serie o episodio; no se aplicará.') + } + if (job.status !== 'completed' || !job.episodeResult) { + throw new Error(`El job ${job.jobId} está ${job.status}; sólo se puede aplicar una propuesta completada.`) + } + const applied = await api.applySeriesPlanJob(job.jobId, job.episodeResult) + if (applied.id !== episode.id) throw new Error('Series Lab aplicó la propuesta a un episodio inesperado; recarga antes de continuar.') + await useSeriesStore.getState().reload() + await useSeriesStore.getState().openSeries(series.id) + useSeriesStore.getState().openEpisode(episode.id) + return seriesEpisodeResult( + workspace, + applied, + 'episode', + `He aplicado el plan ${job.jobId} a “${applied.title}”: ${applied.outline.beats.length} beats, ${applied.script.length} escenas y ${applied.shots.length} tomas. No he renderizado ni comprometido el delta de canon.`, + { taskIds: [job.jobId], channel: 'series_plan_clear' }, + ) +} + +export async function renderSeriesShots(action: RenderSeriesShotsCommand): Promise { + if (!action.confirm) throw new Error('Renderizar tomas de Series Lab requiere confirm=true.') + const workspace = useStore.getState().activeWorkspace || 'default' + const [api, { useSeriesStore }] = await Promise.all([ + import('../../api/client'), + import('./store'), + ]) + await useSeriesStore.getState().loadWorkspace(workspace) + await useSeriesStore.getState().saveNow() + const library = await api.fetchSeriesLibrary(workspace) + const seriesMatches = action.seriesTitle + ? Object.values(library.seriesById).filter(item => normalizeName(item.title) === normalizeName(action.seriesTitle)) + : [] + if (seriesMatches.length > 1) throw new Error(`Hay varias series tituladas “${action.seriesTitle}”; el destino no es inequívoco.`) + const series = seriesMatches[0] + || (!action.seriesTitle ? library.seriesById[useSeriesStore.getState().activeSeriesId] : null) + if (!series) throw new Error(action.seriesTitle + ? `No existe la serie “${action.seriesTitle}” en este workspace.` + : 'No hay una serie activa que renderizar.') + const episodeMatches = action.targetEpisodeTitle + ? Object.values(series.episodesById).filter(item => normalizeName(item.title) === normalizeName(action.targetEpisodeTitle)) + : [] + if (episodeMatches.length > 1) throw new Error(`Hay varios episodios titulados “${action.targetEpisodeTitle}”; el destino no es inequívoco.`) + const activeEpisodeId = useSeriesStore.getState().activeSeriesId === series.id + ? useSeriesStore.getState().activeEpisodeId : '' + const episodes = Object.values(series.episodesById) + const episode = episodeMatches[0] + || (!action.targetEpisodeTitle && activeEpisodeId ? series.episodesById[activeEpisodeId] : null) + || (!action.targetEpisodeTitle && episodes.length === 1 ? episodes[0] : null) + if (!episode) throw new Error(action.targetEpisodeTitle + ? `No existe el episodio “${action.targetEpisodeTitle}” en “${series.title}”.` + : `“${series.title}” necesita un episodio activo o único.`) + if (!episode.shots.length) throw new Error(`“${episode.title}” no tiene shots; genera y aplica un plan complete primero.`) + if (episode.shots.some(shot => shot.dialogueBeats.length > 0) && !series.bestEffortLipSyncAcknowledged) { + throw new Error('Este episodio tiene diálogo. Marca primero “I understand lip sync is best-effort” en Series Lab; el Wizard no puede inferir ese consentimiento.') + } + + const byId = new Map(episode.shots.map(shot => [shot.id, shot])) + if (action.mode === 'selected') { + const unknown = action.shotIds.filter(id => !byId.has(id)) + if (unknown.length) throw new Error(`Shots desconocidos: ${unknown.join(', ')}.`) + const approved = action.shotIds.filter(id => Boolean(byId.get(id)?.approvedAttemptId)) + if (approved.length) throw new Error(`Los shots ya aprobados no se vuelven a renderizar: ${approved.join(', ')}.`) + } + const eligible = episode.shots.filter(shot => { + if (shot.approvedAttemptId) return false + if (action.mode === 'selected') return action.shotIds.includes(shot.id) + if (action.mode === 'missing') return !shot.attempts.some(attempt => attempt.status === 'completed') + if (action.mode === 'failed') return shot.attempts.some(attempt => attempt.status === 'failed') + return true + }) + if (!eligible.length) throw new Error(`No hay shots elegibles para el modo ${action.mode}.`) + + await useSeriesStore.getState().openSeries(series.id) + useSeriesStore.getState().openEpisode(episode.id) + const current = useSeriesStore.getState().library.seriesById[series.id] || series + const job = await api.startSeriesRender(workspace, series.id, episode.id, { + mode: action.mode, + shotIds: action.mode === 'selected' ? eligible.map(shot => shot.id) : undefined, + seed: action.seed === -1 ? undefined : action.seed, + settings: current.provider.videoSettings, + }) + if (job.workspace !== workspace || job.seriesId !== series.id || job.episodeId !== episode.id) { + throw new Error('Series Lab devolvió un render job para otro destino; no se mostrará como correcto.') + } + return seriesEpisodeResult( + workspace, + episode, + 'review', + `He encolado ${eligible.length} shots de “${episode.title}” (${job.jobId}) en modo ${action.mode}. El progreso recuperable está abierto en Series Lab → Render & review.`, + { taskIds: [job.jobId], channel: 'series_render', job: job as unknown as Record }, + ) +} + +export async function reviewSeriesAttempts(action: ReviewSeriesAttemptsCommand): Promise { + if (!action.confirm) throw new Error('Revisar intentos de Series Lab requiere confirm=true.') + const workspace = useStore.getState().activeWorkspace || 'default' + const [api, { useSeriesStore }] = await Promise.all([ + import('../../api/client'), + import('./store'), + ]) + await useSeriesStore.getState().loadWorkspace(workspace) + await useSeriesStore.getState().saveNow() + const library = await api.fetchSeriesLibrary(workspace) + const seriesMatches = action.seriesTitle + ? Object.values(library.seriesById).filter(item => normalizeName(item.title) === normalizeName(action.seriesTitle)) + : [] + if (seriesMatches.length > 1) throw new Error(`Hay varias series tituladas “${action.seriesTitle}”; el destino no es inequívoco.`) + const series = seriesMatches[0] + || (!action.seriesTitle ? library.seriesById[useSeriesStore.getState().activeSeriesId] : null) + if (!series) throw new Error(action.seriesTitle + ? `No existe la serie “${action.seriesTitle}” en este workspace.` + : 'No hay una serie activa cuyos intentos revisar.') + const episodeMatches = action.targetEpisodeTitle + ? Object.values(series.episodesById).filter(item => normalizeName(item.title) === normalizeName(action.targetEpisodeTitle)) + : [] + if (episodeMatches.length > 1) throw new Error(`Hay varios episodios titulados “${action.targetEpisodeTitle}”; el destino no es inequívoco.`) + const activeEpisodeId = useSeriesStore.getState().activeSeriesId === series.id + ? useSeriesStore.getState().activeEpisodeId : '' + const episodes = Object.values(series.episodesById) + const episode = episodeMatches[0] + || (!action.targetEpisodeTitle && activeEpisodeId ? series.episodesById[activeEpisodeId] : null) + || (!action.targetEpisodeTitle && episodes.length === 1 ? episodes[0] : null) + if (!episode) throw new Error(action.targetEpisodeTitle + ? `No existe el episodio “${action.targetEpisodeTitle}” en “${series.title}”.` + : `“${series.title}” necesita un episodio activo o único.`) + + const shotsByOrder = new Map() + for (const shot of episode.shots) { + if (shotsByOrder.has(shot.order)) throw new Error(`El episodio tiene más de un shot con el número ${shot.order}; no se puede resolver de forma segura.`) + shotsByOrder.set(shot.order, shot) + } + const selectedShots = action.scope === 'all_latest' + ? episode.shots + : action.shotNumbers.map(number => { + const shot = shotsByOrder.get(number) + if (!shot) throw new Error(`No existe el shot ${number} en “${episode.title}”.`) + return shot + }) + + if (action.decision === 'approve') { + const selections = selectedShots.flatMap(shot => { + const attempt = action.attemptId + ? shot.attempts.find(item => item.id === action.attemptId) + : [...shot.attempts].reverse().find(item => ( + item.status === 'completed' + && item.reviewDecision !== 'rejected' + && item.outputAssetIds.some(id => Boolean(series.assets[id])) + )) + if (!attempt) { + if (action.attemptId) throw new Error(`El intento ${action.attemptId} no pertenece al shot ${shot.order}.`) + if (action.scope === 'selected_latest') throw new Error(`El shot ${shot.order} no tiene un intento completado y reproducible que aprobar.`) + return [] + } + if (attempt.status !== 'completed' || attempt.reviewDecision === 'rejected') { + throw new Error(`El intento ${attempt.id} del shot ${shot.order} no es aprobable.`) + } + if (!attempt.outputAssetIds.some(id => Boolean(series.assets[id]))) { + throw new Error(`El intento ${attempt.id} del shot ${shot.order} no tiene un asset reproducible.`) + } + return attempt.id === shot.approvedAttemptId ? [] : [{ shotId: shot.id, attemptId: attempt.id }] + }) + if (!selections.length) throw new Error('No hay nuevos intentos elegibles que aprobar; las tomas resueltas ya están aprobadas o no tienen vídeo válido.') + const result = await api.approveSeriesAttemptsBulk(workspace, series.id, episode.id, selections) + if (result.seriesId !== series.id || result.episodeId !== episode.id) { + throw new Error('Series Lab aprobó intentos para otro destino; recarga antes de continuar.') + } + await useSeriesStore.getState().reload() + await useSeriesStore.getState().openSeries(series.id) + useSeriesStore.getState().openEpisode(episode.id) + return seriesEpisodeResult( + workspace, + episode, + 'review', + `He aprobado ${selections.length} intento${selections.length === 1 ? '' : 's'} en “${episode.title}” y he abierto Render & Review.`, + ) + } + + const shot = selectedShots[0] + const attempt = action.attemptId + ? shot.attempts.find(item => item.id === action.attemptId) + : [...shot.attempts].reverse().find(item => item.status === 'completed' && item.reviewDecision !== 'rejected') + if (!attempt) throw new Error(action.attemptId + ? `El intento ${action.attemptId} no pertenece al shot ${shot.order}.` + : `El shot ${shot.order} no tiene un intento completado pendiente de rechazo.`) + if (attempt.status !== 'completed' || attempt.reviewDecision === 'rejected') { + throw new Error(`El intento ${attempt.id} del shot ${shot.order} no se puede rechazar.`) + } + if (shot.approvedAttemptId === attempt.id) { + throw new Error(`El intento ${attempt.id} ya es el aprobado del shot ${shot.order}; la UI no permite rechazar el montaje final sin elegir antes otra toma.`) + } + const rejectedShot = await api.rejectSeriesAttempt(workspace, series.id, episode.id, shot.id, attempt.id) + const rejectedAttempt = rejectedShot.attempts.find(item => item.id === attempt.id) + if (rejectedShot.id !== shot.id || rejectedAttempt?.reviewDecision !== 'rejected') { + throw new Error('Series Lab no confirmó el rechazo solicitado; recarga antes de continuar.') + } + await useSeriesStore.getState().reload() + await useSeriesStore.getState().openSeries(series.id) + useSeriesStore.getState().openEpisode(episode.id) + return seriesEpisodeResult( + workspace, + episode, + 'review', + `He rechazado el intento ${attempt.id} del shot ${shot.order} en “${episode.title}” y he abierto Render & Review.`, + ) +} + +export async function assembleSeriesEpisode(action: AssembleSeriesEpisodeCommand): Promise { + if (!action.confirm) throw new Error('Ensamblar un episodio de Series Lab requiere confirm=true.') + const workspace = useStore.getState().activeWorkspace || 'default' + const [api, { useSeriesStore }] = await Promise.all([ + import('../../api/client'), + import('./store'), + ]) + await useSeriesStore.getState().loadWorkspace(workspace) + await useSeriesStore.getState().saveNow() + const library = await api.fetchSeriesLibrary(workspace) + const seriesMatches = action.seriesTitle + ? Object.values(library.seriesById).filter(item => normalizeName(item.title) === normalizeName(action.seriesTitle)) + : [] + if (seriesMatches.length > 1) throw new Error(`Hay varias series tituladas “${action.seriesTitle}”; el destino no es inequívoco.`) + const series = seriesMatches[0] + || (!action.seriesTitle ? library.seriesById[useSeriesStore.getState().activeSeriesId] : null) + if (!series) throw new Error(action.seriesTitle + ? `No existe la serie “${action.seriesTitle}” en este workspace.` + : 'No hay una serie activa que ensamblar.') + const episodeMatches = action.targetEpisodeTitle + ? Object.values(series.episodesById).filter(item => normalizeName(item.title) === normalizeName(action.targetEpisodeTitle)) + : [] + if (episodeMatches.length > 1) throw new Error(`Hay varios episodios titulados “${action.targetEpisodeTitle}”; el destino no es inequívoco.`) + const activeEpisodeId = useSeriesStore.getState().activeSeriesId === series.id + ? useSeriesStore.getState().activeEpisodeId : '' + const episodes = Object.values(series.episodesById) + const episode = episodeMatches[0] + || (!action.targetEpisodeTitle && activeEpisodeId ? series.episodesById[activeEpisodeId] : null) + || (!action.targetEpisodeTitle && episodes.length === 1 ? episodes[0] : null) + if (!episode) throw new Error(action.targetEpisodeTitle + ? `No existe el episodio “${action.targetEpisodeTitle}” en “${series.title}”.` + : `“${series.title}” necesita un episodio activo o único.`) + if (!episode.shots.length) throw new Error(`“${episode.title}” no tiene shots que ensamblar.`) + const incomplete = episode.shots.filter(shot => { + const approved = shot.attempts.find(attempt => attempt.id === shot.approvedAttemptId) + return !approved || approved.status !== 'completed' + || !approved.outputAssetIds.some(id => Boolean(series.assets[id])) + }) + if (incomplete.length) { + throw new Error(`Aprueba primero un vídeo reproducible para todos los shots. Faltan: ${incomplete.map(shot => shot.order).join(', ')}.`) + } + + await useSeriesStore.getState().openSeries(series.id) + useSeriesStore.getState().openEpisode(episode.id) + const job = await api.startSeriesEpisodeAssembly(workspace, series.id, episode.id) + if (job.workspace !== workspace || job.seriesId !== series.id || job.episodeId !== episode.id) { + throw new Error('Series Lab devolvió un ensamblado para otro destino; no se mostrará como correcto.') + } + return seriesEpisodeResult( + workspace, + episode, + 'review', + `He iniciado el ensamblado ordenado de ${episode.shots.length} shots de “${episode.title}” (${job.jobId}). El progreso recuperable y la descarga están abiertos en Render & Review; no he comprometido el delta de canon.`, + { taskIds: [job.jobId], channel: 'series_assembly', job: job as unknown as Record }, + ) +} + +export async function commitSeriesCanonDelta(action: CommitSeriesCanonCommand): Promise { + if (!action.confirm) throw new Error('Comprometer cambios de canon requiere confirm=true.') + const workspace = useStore.getState().activeWorkspace || 'default' + const [api, { useSeriesStore }] = await Promise.all([import('../../api/client'), import('./store')]) + await useSeriesStore.getState().loadWorkspace(workspace) + await useSeriesStore.getState().saveNow() + const library = await api.fetchSeriesLibrary(workspace) + const matches = action.seriesTitle ? Object.values(library.seriesById).filter(item => normalizeName(item.title) === normalizeName(action.seriesTitle)) : [] + if (matches.length > 1) throw new Error(`Hay varias series tituladas “${action.seriesTitle}”; el destino no es inequívoco.`) + const series = matches[0] || (!action.seriesTitle ? library.seriesById[useSeriesStore.getState().activeSeriesId] : null) + if (!series) throw new Error(action.seriesTitle ? `No existe la serie “${action.seriesTitle}”.` : 'No hay una serie activa.') + const episodeMatches = action.targetEpisodeTitle ? Object.values(series.episodesById).filter(item => normalizeName(item.title) === normalizeName(action.targetEpisodeTitle)) : [] + if (episodeMatches.length > 1) throw new Error(`Hay varios episodios titulados “${action.targetEpisodeTitle}”.`) + const activeId = useSeriesStore.getState().activeSeriesId === series.id ? useSeriesStore.getState().activeEpisodeId : '' + const episodes = Object.values(series.episodesById) + const episode = episodeMatches[0] || (!action.targetEpisodeTitle && activeId ? series.episodesById[activeId] : null) || (!action.targetEpisodeTitle && episodes.length === 1 ? episodes[0] : null) + if (!episode) throw new Error(action.targetEpisodeTitle ? `No existe el episodio “${action.targetEpisodeTitle}”.` : 'La serie necesita un episodio activo o único.') + const deltaIds = [...episode.proposedCanonDelta.add.map(item => item.id), ...episode.proposedCanonDelta.change.map(item => item.id), ...episode.proposedCanonDelta.retire.map(item => item.factId)] + if (!deltaIds.length) throw new Error(`“${episode.title}” no tiene cambios de canon propuestos.`) + const unknown = action.itemIds.filter(id => !deltaIds.includes(id)) + if (unknown.length) throw new Error(`Cambios de canon desconocidos: ${unknown.join(', ')}.`) + const selected = action.decision.endsWith('_all') ? deltaIds : action.itemIds + const value = action.decision.startsWith('accept_') ? 'accepted' : 'rejected' + const decisions = Object.fromEntries(selected.map(id => [id, value])) as Record + const updated = await api.commitSeriesCanon(workspace, series.id, episode.id, episode.proposedCanonDelta.baseRevision, decisions) + if (updated.id !== series.id || !updated.episodesById[episode.id]) throw new Error('Series Lab confirmó decisiones para otro destino.') + await useSeriesStore.getState().reload() + await useSeriesStore.getState().openSeries(series.id) + useSeriesStore.getState().openEpisode(episode.id) + return seriesEpisodeResult( + workspace, + episode, + 'review', + `He marcado ${selected.length} cambio${selected.length === 1 ? '' : 's'} de canon como ${value === 'accepted' ? 'aceptados' : 'rechazados'} en “${episode.title}”. Los demás permanecen pendientes.`, + { reviewView: 'finish' }, + ) +} + diff --git a/ui/src/features/series/adapters.ts b/ui/src/features/series/adapters.ts new file mode 100644 index 00000000..ec79c23b --- /dev/null +++ b/ui/src/features/series/adapters.ts @@ -0,0 +1,52 @@ +import { + applySeriesPlan, + assembleSeriesEpisode, + commitSeriesCanonDelta, + createFilledSeriesEpisode, + generateSeriesPlan, + renderSeriesShots, + reviewSeriesAttempts, + updateSeriesEpisode, +} from './actions' +import type { + ApplySeriesPlanCommand, + AssembleSeriesEpisodeCommand, + CommitSeriesCanonCommand, + CreateSeriesEpisodeCommand, + GenerateSeriesPlanCommand, + RenderSeriesShotsCommand, + ReviewSeriesAttemptsCommand, + UpdateSeriesEpisodeCommand, +} from './commands' + +export async function createEpisode(command: CreateSeriesEpisodeCommand) { + return createFilledSeriesEpisode(command) +} + +export async function updateEpisode(command: UpdateSeriesEpisodeCommand) { + return updateSeriesEpisode(command) +} + +export async function generatePlan(command: GenerateSeriesPlanCommand) { + return generateSeriesPlan(command) +} + +export async function applyPlan(command: ApplySeriesPlanCommand) { + return applySeriesPlan(command) +} + +export async function renderShots(command: RenderSeriesShotsCommand) { + return renderSeriesShots(command) +} + +export async function reviewAttempts(command: ReviewSeriesAttemptsCommand) { + return reviewSeriesAttempts(command) +} + +export async function assembleEpisode(command: AssembleSeriesEpisodeCommand) { + return assembleSeriesEpisode(command) +} + +export async function commitCanon(command: CommitSeriesCanonCommand) { + return commitSeriesCanonDelta(command) +} diff --git a/ui/src/features/series/commands.ts b/ui/src/features/series/commands.ts new file mode 100644 index 00000000..10cde8e3 --- /dev/null +++ b/ui/src/features/series/commands.ts @@ -0,0 +1,81 @@ +import type { CreativeCharacter, CreativeLocation } from '../../lib/labHelpers' + +export interface CreateSeriesEpisodeCommand { + seriesTitle: string + seriesPremise: string + seriesLogline: string + episodeTitle: string + episodePremise: string + episodeLogline: string + genre: string + tone: string + visualStyle: string + worldSummary: string + theme: string + ending: string + language: string + characters: CreativeCharacter[] + locations: CreativeLocation[] + outlineBeats: string[] + targetDurationSeconds?: number + createIfMissing: boolean + knownUniverse: boolean +} + +export interface UpdateSeriesEpisodeCommand { + seriesTitle: string + targetEpisodeTitle: string + episodeTitle: string + episodePremise: string + episodeLogline: string + outlineBeats: string[] + targetDurationSeconds?: number +} + +export interface GenerateSeriesPlanCommand { + seriesTitle: string + targetEpisodeTitle: string + scope: 'outline' | 'script' | 'shots' | 'complete' + instruction: string + confirm: true +} + +export interface ApplySeriesPlanCommand { + seriesTitle: string + targetEpisodeTitle: string + jobId: string + confirm: true +} + +export interface RenderSeriesShotsCommand { + seriesTitle: string + targetEpisodeTitle: string + mode: 'selected' | 'missing' | 'failed' | 'all' + shotIds: string[] + seed?: number + confirm: true +} + +export interface ReviewSeriesAttemptsCommand { + seriesTitle: string + targetEpisodeTitle: string + decision: 'approve' | 'reject' + scope: 'selected_latest' | 'all_latest' + shotNumbers: number[] + attemptId: string + confirm: true +} + +export interface AssembleSeriesEpisodeCommand { + seriesTitle: string + targetEpisodeTitle: string + confirm: true +} + +export interface CommitSeriesCanonCommand { + seriesTitle: string + targetEpisodeTitle: string + decision: 'accept_all' | 'reject_all' | 'accept_selected' | 'reject_selected' + itemIds: string[] + confirm: true +} diff --git a/ui/src/features/video-editor/actions.ts b/ui/src/features/video-editor/actions.ts index f19fd679..d30dbc34 100644 --- a/ui/src/features/video-editor/actions.ts +++ b/ui/src/features/video-editor/actions.ts @@ -1,55 +1,18 @@ import { fetchOutputs, fetchVideoEditorExport, getVideoEditorThumbnailUrl, probeVideoEditorAudio, probeVideoEditorClip, startVideoEditorExport, type ApiOutput } from '../../api/client' +import { commandResultFromSlice, type CommandResult } from '../../lib/commandContract' import { useStore } from '../../stores/useStore' import { clipId, loadEditorDraft, persistEditorDraft, RESOLUTIONS, type EditorSoundtrack } from './editorDraft' import { sequenceTotalDuration } from './editorTimeline' import type { EditorClip } from './editorClipNormalization' -import { executionKey, executionReport, rememberExecution, reuseExecution, type AgentExecutionReport } from '../agent/agentContract' - -export interface AgentCreateVideoEditorProjectAction { - type: 'create_video_editor_project' - projectName: string -} - -export interface AgentOpenVideoEditorProjectAction { - type: 'open_video_editor_project' - projectName: string -} - -export interface AgentAddVideoEditorClipsAction { - type: 'add_video_editor_clips' - outputNames: string[] -} - -export interface AgentOrderVideoEditorClipsAction { - type: 'order_video_editor_clips' - clipNames: string[] -} - -export interface AgentTrimVideoEditorClipAction { - type: 'trim_video_editor_clip' - clipName: string - trimStart: number - trimEnd: number -} - -export interface AgentAddVideoEditorAudioAction { - type: 'add_video_editor_audio' - clipName: string - outputName: string -} - -export interface AgentValidateVideoEditorTimelineAction { - type: 'validate_video_editor_timeline' -} - -export interface AgentExportVideoEditorAction { - type: 'export_video_editor' - confirm: true -} - -export interface AgentTrackVideoEditorExportAction { - type: 'track_video_editor_export' -} +import type { + AddVideoEditorAudioCommand, + AddVideoEditorClipsCommand, + CreateVideoEditorProjectCommand, + ExportVideoEditorCommand, + OpenVideoEditorProjectCommand, + OrderVideoEditorClipsCommand, + TrimVideoEditorClipCommand, +} from './commands' const EXPORT_KEY = 'maestro-video-editor-export-v1' @@ -57,14 +20,6 @@ function workspaceName(): string { return useStore.getState().activeWorkspace || 'default' } -function showEditor(): void { - const state = useStore.getState() - state.setSettingsOpen(false) - state.setDashboardOpen(false) - state.setMediaFilter('videoeditor') - state.setSidebarOpen(false) -} - function loadDraft() { return loadEditorDraft(workspaceName()) } @@ -79,39 +34,35 @@ function saveDraft( persistEditorDraft(clips, projectName, resolution, fps, workspaceName(), soundtrack) } -function editorReport(message: string, extra: Partial = {}): AgentExecutionReport { +function editorEntity() { const draft = loadDraft() - return executionReport({ - state: extra.state || 'completed', - message, - recoverable: extra.recoverable === true, - target: { kind: 'video_editor', id: draft.projectName, title: draft.projectName }, - taskId: extra.taskId, - outputNames: extra.outputNames, - executionKey: extra.executionKey, + return { kind: 'video_editor', id: draft.projectName, workspaceId: workspaceName() } +} + +function editorResult(extra: Parameters[0] = {}): CommandResult { + const entity = editorEntity() + return commandResultFromSlice({ + entity, + navigationTarget: { destination: 'video_editor', entity }, + ...extra, }) } -export async function createAgentVideoEditorProject(action: AgentCreateVideoEditorProjectAction): Promise<{ message: string; report: AgentExecutionReport }> { - const name = action.projectName.trim() || 'my_video' +export async function createAgentVideoEditorProject(command: CreateVideoEditorProjectCommand): Promise { + const name = command.projectName.trim() || 'my_video' saveDraft([], name, RESOLUTIONS[0], 30, null) - showEditor() - const message = `He creado el proyecto de Video Editor “${name}”.` - return { message, report: editorReport(message) } + return editorResult() } -export async function openAgentVideoEditorProject(action: AgentOpenVideoEditorProjectAction): Promise<{ message: string; report: AgentExecutionReport }> { +export async function openAgentVideoEditorProject(command: OpenVideoEditorProjectCommand): Promise { const draft = loadDraft() - if (action.projectName.trim() && draft.projectName !== action.projectName.trim()) { + if (command.projectName.trim() && draft.projectName !== command.projectName.trim()) { throw new Error( `Solo existe un borrador de Video Editor por workspace: “${draft.projectName}”. ` + 'Pide explícitamente crear otro proyecto para reemplazar el borrador actual.', ) } - showEditor() - const next = loadDraft() - const message = `He abierto Video Editor “${next.projectName}” con ${next.clips.length} clips.` - return { message, report: editorReport(message) } + return editorResult() } async function clipsFromNamedOutputs( @@ -150,48 +101,44 @@ async function clipsFromNamedOutputs( return added } -export async function addAgentVideoEditorClips(action: AgentAddVideoEditorClipsAction): Promise<{ message: string; report: AgentExecutionReport }> { +export async function addAgentVideoEditorClips(command: AddVideoEditorClipsCommand): Promise { const draft = loadDraft() - const added = await clipsFromNamedOutputs(action.outputNames) + const added = await clipsFromNamedOutputs(command.outputNames) saveDraft([...draft.clips, ...added], draft.projectName, draft.resolution, draft.fps) - showEditor() - const message = `He añadido ${added.length} clips exactos a “${draft.projectName}”.` - return { message, report: editorReport(message) } + return editorResult() } -export async function orderAgentVideoEditorClips(action: AgentOrderVideoEditorClipsAction): Promise<{ message: string; report: AgentExecutionReport }> { +export async function orderAgentVideoEditorClips(command: OrderVideoEditorClipsCommand): Promise { const draft = loadDraft() const byName = new Map(draft.clips.map(clip => [clip.name, clip])) - const ordered = action.clipNames.map(name => { + const ordered = command.clipNames.map(name => { const clip = byName.get(name) if (!clip) throw new Error(`El clip “${name}” no está en la línea de tiempo.`) return clip }) - const rest = draft.clips.filter(clip => !action.clipNames.includes(clip.name)) + const rest = draft.clips.filter(clip => !command.clipNames.includes(clip.name)) saveDraft([...ordered, ...rest], draft.projectName, draft.resolution, draft.fps) - const message = `He reordenado ${ordered.length} clips.` - return { message, report: editorReport(message) } + return editorResult() } -export async function trimAgentVideoEditorClip(action: AgentTrimVideoEditorClipAction): Promise<{ message: string; report: AgentExecutionReport }> { +export async function trimAgentVideoEditorClip(command: TrimVideoEditorClipCommand): Promise { const draft = loadDraft() const clips = draft.clips.map(clip => { - if (clip.name !== action.clipName && clip.id !== action.clipName) return clip - const start = Math.max(0, action.trimStart) - const end = Math.max(start + 0.05, Math.min(clip.duration || action.trimEnd, action.trimEnd)) + if (clip.name !== command.clipName && clip.id !== command.clipName) return clip + const start = Math.max(0, command.trimStart) + const end = Math.max(start + 0.05, Math.min(clip.duration || command.trimEnd, command.trimEnd)) return { ...clip, trimStart: start, trimEnd: end } }) - if (!draft.clips.some(clip => clip.name === action.clipName || clip.id === action.clipName)) { - throw new Error(`No encuentro el clip “${action.clipName}” para recortar.`) + if (!draft.clips.some(clip => clip.name === command.clipName || clip.id === command.clipName)) { + throw new Error(`No encuentro el clip “${command.clipName}” para recortar.`) } saveDraft(clips, draft.projectName, draft.resolution, draft.fps) - const message = `He recortado “${action.clipName}” a ${action.trimStart}-${action.trimEnd}s.` - return { message, report: editorReport(message) } + return editorResult() } -export async function addAgentVideoEditorAudio(action: AgentAddVideoEditorAudioAction): Promise<{ message: string; report: AgentExecutionReport }> { +export async function addAgentVideoEditorAudio(command: AddVideoEditorAudioCommand): Promise { const draft = loadDraft() - const wanted = action.outputName.trim() + const wanted = command.outputName.trim() if (!wanted) throw new Error('Indica el nombre exacto del output de audio.') const outputs = await fetchOutputs(80, 0, { workspace: workspaceName(), mediaType: 'audio' }) const output = outputs.outputs.find(item => item.name === wanted) @@ -208,43 +155,21 @@ export async function addAgentVideoEditorAudio(action: AgentAddVideoEditorAudioA loop: probe.duration < sequenceTotalDuration(draft.clips), } saveDraft(draft.clips, draft.projectName, draft.resolution, draft.fps, soundtrack) - showEditor() - const message = `He configurado “${soundtrack.name}” como banda sonora de “${draft.projectName}”.` - return { message, report: editorReport(message) } + return editorResult() } -export async function validateAgentVideoEditorTimeline(): Promise<{ message: string; report: AgentExecutionReport }> { +export async function validateAgentVideoEditorTimeline(): Promise { const draft = loadDraft() if (!draft.clips.length) throw new Error('La línea de tiempo está vacía.') const duration = sequenceTotalDuration(draft.clips) if (duration <= 0) throw new Error('La línea de tiempo no tiene duración usable.') - const message = `Línea de tiempo válida: ${draft.clips.length} clips, ${duration.toFixed(1)}s.` - return { message, report: editorReport(message, { state: 'prepared' }) } + return editorResult() } -export async function exportAgentVideoEditor(action: AgentExportVideoEditorAction): Promise<{ message: string; report: AgentExecutionReport }> { - if (!action.confirm) throw new Error('Exportar requiere confirm=true.') +export async function exportAgentVideoEditor(command: ExportVideoEditorCommand): Promise { + if (!command.confirm) throw new Error('Exportar requiere confirm=true.') const draft = loadDraft() if (!draft.clips.length) throw new Error('No hay clips para exportar.') - const key = executionKey({ - workspace: workspaceName(), - type: 'export_video_editor', - targetId: draft.projectName, - params: { - clips: draft.clips.map(clip => clip.name), - duration: sequenceTotalDuration(draft.clips), - soundtrack: draft.soundtrack ? { - name: draft.soundtrack.name, - source: draft.soundtrack.source, - trimStart: draft.soundtrack.trimStart, - trimEnd: draft.soundtrack.trimEnd, - volume: draft.soundtrack.volume, - loop: draft.soundtrack.loop, - } : null, - }, - }) - const reused = reuseExecution(key) - if (reused) return { message: `Reutilizo la ejecución anterior (${reused.state}). ${reused.message}`, report: reused } const job = await startVideoEditorExport({ name: draft.projectName, width: draft.resolution.width, @@ -277,16 +202,13 @@ export async function exportAgentVideoEditor(action: AgentExportVideoEditorActio try { window.localStorage.setItem(`${EXPORT_KEY}:${encodeURIComponent(workspaceName())}`, job.job_id) } catch { /* keep going */ } - const report = editorReport( - `He encolado la exportación de “${draft.projectName}” (${job.job_id}).`, - { state: 'queued', taskId: job.job_id, executionKey: key, recoverable: true }, - ) - rememberExecution(report) - showEditor() - return { message: report.message, report } + return editorResult({ + status: 'queued', + taskIds: [job.job_id], + }) } -export async function trackAgentVideoEditorExport(): Promise<{ message: string; report: AgentExecutionReport }> { +export async function trackAgentVideoEditorExport(): Promise { let jobId = '' try { jobId = window.localStorage.getItem(`${EXPORT_KEY}:${encodeURIComponent(workspaceName())}`) || '' @@ -295,18 +217,20 @@ export async function trackAgentVideoEditorExport(): Promise<{ message: string; } if (!jobId) throw new Error('No hay una exportación de Video Editor en curso.') const job = await fetchVideoEditorExport(jobId) - const state = job.status === 'completed' ? 'completed' + const status = job.status === 'completed' ? 'completed' : job.status === 'failed' || job.status === 'cancelled' ? 'failed' - : job.status === 'queued' || job.status === 'waiting_resource' ? 'queued' - : 'running' - const message = `Exportación ${job.job_id}: ${job.status}. ${job.message || ''}` - return { - message, - report: editorReport(message, { - state, + : 'queued' + const owner = editorEntity() + return editorResult({ + status, + taskIds: [job.job_id], + artifacts: [{ + id: job.filename || `export-job:${job.job_id}`, + kind: job.filename ? 'video' : 'document', + owner, taskId: job.job_id, - outputNames: job.filename ? [job.filename] : [], - recoverable: state === 'failed', - }), - } + uri: job.url || job.filename || job.job_id, + metadata: { status: job.status, message: job.message || '' }, + }], + }) } diff --git a/ui/src/features/video-editor/commands.ts b/ui/src/features/video-editor/commands.ts index f4d83d8a..234b77ae 100644 --- a/ui/src/features/video-editor/commands.ts +++ b/ui/src/features/video-editor/commands.ts @@ -1,11 +1,34 @@ -export type { - AgentAddVideoEditorAudioAction as AddVideoEditorAudioCommand, - AgentAddVideoEditorClipsAction as AddVideoEditorClipsCommand, - AgentCreateVideoEditorProjectAction as CreateVideoEditorProjectCommand, - AgentExportVideoEditorAction as ExportVideoEditorCommand, - AgentOpenVideoEditorProjectAction as OpenVideoEditorProjectCommand, - AgentOrderVideoEditorClipsAction as OrderVideoEditorClipsCommand, - AgentTrackVideoEditorExportAction as TrackVideoEditorExportCommand, - AgentTrimVideoEditorClipAction as TrimVideoEditorClipCommand, - AgentValidateVideoEditorTimelineAction as ValidateVideoEditorTimelineCommand, -} from './actions' +export interface CreateVideoEditorProjectCommand { + projectName: string +} + +export interface OpenVideoEditorProjectCommand { + projectName: string +} + +export interface AddVideoEditorClipsCommand { + outputNames: string[] +} + +export interface OrderVideoEditorClipsCommand { + clipNames: string[] +} + +export interface TrimVideoEditorClipCommand { + clipName: string + trimStart: number + trimEnd: number +} + +export interface AddVideoEditorAudioCommand { + clipName: string + outputName: string +} + +export type ValidateVideoEditorTimelineCommand = Record + +export interface ExportVideoEditorCommand { + confirm: true +} + +export type TrackVideoEditorExportCommand = Record diff --git a/ui/src/lib/commandContract.ts b/ui/src/lib/commandContract.ts new file mode 100644 index 00000000..d0ae0c91 --- /dev/null +++ b/ui/src/lib/commandContract.ts @@ -0,0 +1,692 @@ +/** + * Shared command-plane states. `prepared` and `running` remain internal + * execution states in agentContract; this is the transport-level vocabulary + * exposed to the UI/API. + */ +export const COMMAND_STATUSES = [ + 'completed', + 'queued', + 'awaiting_input', + 'partial', + 'failed', +] as const + +export type CommandStatus = typeof COMMAND_STATUSES[number] +export type CommandResultStatus = CommandStatus +export type CommandActor = 'user' | 'wizard' +export type ArtifactKind = 'image' | 'audio' | 'video' | 'scene' | 'document' +export type PresentationSpeed = 'instant' | 'normal' | 'theatrical' + +export const ARTIFACT_KINDS = ['image', 'audio', 'video', 'scene', 'document'] as const +export const PRESENTATION_SPEEDS = ['instant', 'normal', 'theatrical'] as const + +export interface EntityRef { + kind: string + id: string + workspaceId: string + version?: number +} + +export interface ArtifactRef { + id: string + kind: ArtifactKind + owner: EntityRef + taskId?: string + uri: string + metadata: Record +} + +/** Logical destination used by navigation and by the presentation layer. */ +export interface NavigationTarget { + destination: string + section?: string + entity?: EntityRef + anchor?: string +} + +/** + * The smallest UI-facing plan: where to go and which semantic anchors to + * reveal/focus. It deliberately contains no DOM selector or business state. + * `replay` keeps a compatibility bridge with CapabilityPresentation while + * presentation choreography is still being migrated. + */ +export interface PresentationPlan { + navigationTarget?: NavigationTarget + anchors: string[] + focus?: string + speed: PresentationSpeed + replay?: 'atomic' +} + +export interface StructuredError { + code: string + message: string + retryable: boolean + field?: string + details?: Record +} + +export interface CommandEnvelope { + commandId: string + capability: string + workspaceId: string + actor: CommandActor + target?: EntityRef + input: T + idempotencyKey: string + expectedVersion?: number + presentation?: PresentationPlan +} + +export interface CommandResult { + commandId: string + status: CommandStatus + entities: EntityRef[] + artifacts: ArtifactRef[] + taskIds: string[] + pipelineIds: string[] + navigationTarget?: NavigationTarget + error?: StructuredError +} + +export interface CommandEnvelopeInput { + commandId: string + capability: string + workspaceId: string + actor: CommandActor + target?: EntityRef + input: T + idempotencyKey?: string + expectedVersion?: number + presentation?: PresentationPlan +} + +export class CommandContractError extends Error { + readonly code: string + readonly path?: string + + constructor(message: string, code = 'invalid_command_contract', path?: string) { + super(message) + this.name = 'CommandContractError' + this.code = code + this.path = path + } +} + + +function sharedStableSerialize(value: unknown): string { + if (value == null || typeof value !== 'object') return JSON.stringify(value ?? null) + if (Array.isArray(value)) return `[${value.map(item => sharedStableSerialize(item)).join(',')}]` + const entries = Object.entries(value as Record) + .filter(([, item]) => item !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${sharedStableSerialize(item)}`).join(',')}}` +} + +type RecordValue = Record + +/** + * Command payloads cross the UI/API boundary as JSON. Keeping that invariant + * here prevents two especially subtle bugs: an idempotency key that changes + * after a caller mutates its input object, and values (Date/Map/functions, + * cycles, NaN, etc.) that the transport cannot faithfully reproduce. + */ +const MAX_JSON_DEPTH = 64 + + +function isRecord(value: unknown): value is RecordValue { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +function record(value: unknown, name: string): RecordValue { + if (!isRecord(value)) throw new CommandContractError(`${name} debe ser un objeto.`, 'invalid_object', name) + return value +} + +function text(value: unknown, name: string): string { + if (typeof value !== 'string' || !value.trim()) { + throw new CommandContractError(`${name} no puede estar vacío.`, 'empty_id', name) + } + return value.trim() +} + +function optionalText(value: unknown, name: string): string | undefined { + if (value == null) return undefined + return text(value, name) +} + +function nonNegativeInteger(value: unknown, name: string): number { + if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) { + throw new CommandContractError(`${name} debe ser un entero no negativo.`, 'invalid_version', name) + } + return value +} + +function defineEnumerableValue(target: RecordValue, key: string, value: unknown): void { + // `Object.defineProperty` keeps a JSON key named `__proto__` data-only and + // avoids accidentally changing the prototype of the normalized object. + Object.defineProperty(target, key, { + configurable: true, + enumerable: true, + value, + writable: true, + }) +} + +function normalizeJsonValue( + value: unknown, + name: string, + seen = new WeakSet(), + depth = 0, +): unknown { + if (value === undefined) return undefined + if (value === null || typeof value === 'string' || typeof value === 'boolean') return value + if (typeof value === 'number') { + if (!Number.isFinite(value)) { + throw new CommandContractError(`${name} debe contener números finitos.`, 'invalid_json_value', name) + } + return value + } + if (typeof value !== 'object') { + throw new CommandContractError(`${name} contiene un valor no serializable.`, 'invalid_json_value', name) + } + if (depth >= MAX_JSON_DEPTH) { + throw new CommandContractError(`${name} es demasiado profundo.`, 'json_depth_exceeded', name) + } + if (seen.has(value)) { + throw new CommandContractError(`${name} contiene una referencia circular.`, 'circular_json_value', name) + } + seen.add(value) + try { + if (Array.isArray(value)) { + const normalizedArray: unknown[] = [] + for (let index = 0; index < value.length; index += 1) { + const item = value[index] + const normalized = normalizeJsonValue(item, `${name}[${index}]`, seen, depth + 1) + // JSON.stringify serializes undefined array slots as null. Make that + // conversion explicit so the object used for the command is stable. + normalizedArray.push(normalized === undefined ? null : normalized) + } + return normalizedArray + } + if (!isRecord(value)) { + throw new CommandContractError(`${name} debe ser JSON plano.`, 'invalid_json_value', name) + } + if (Object.getOwnPropertySymbols(value).length > 0) { + throw new CommandContractError(`${name} no puede contener claves Symbol.`, 'invalid_json_value', name) + } + const normalized: RecordValue = {} + for (const [key, item] of Object.entries(value)) { + const child = normalizeJsonValue(item, `${name}.${key}`, seen, depth + 1) + // Undefined object properties are omitted by the shared serializer. Do + // the same in the cloned payload so both representations agree. + if (child !== undefined) defineEnumerableValue(normalized, key, child) + } + return normalized + } finally { + seen.delete(value) + } +} + +function normalizePayload(value: unknown, name: string, defaultValue: T): T { + const normalized = normalizeJsonValue(value === undefined ? defaultValue : value, name) + return normalized as T +} + +function normalizeMetadata(value: unknown, name: string): RecordValue { + const normalized = normalizeJsonValue(value, name) + if (!isRecord(normalized)) { + throw new CommandContractError(`${name} debe ser un objeto JSON.`, 'invalid_object', name) + } + return normalized +} + +function stringList(value: unknown, name: string): string[] { + if (value == null) return [] + if (!Array.isArray(value)) throw new CommandContractError(`${name} debe ser una lista.`, 'invalid_list', name) + const seen = new Set() + return value.map((item, index) => { + const next = text(item, `${name}[${index}]`) + if (seen.has(next)) return next + seen.add(next) + return next + }) +} + +function normalizedUniqueList(value: unknown, name: string): string[] { + const values = stringList(value, name) + return [...new Set(values)] +} + +function expectedWorkspace(value: unknown, name = 'workspaceId'): string { + return text(value, name) +} + +function assertWorkspaceMatch(expected: string, actual: string, path: string): void { + if (expected !== actual) { + throw new CommandContractError( + `${path} pertenece al workspace “${actual}”, no al workspace “${expected}”.`, + 'cross_workspace_reference', + path, + ) + } +} + +function normalizeEntityRefRecord(raw: RecordValue, workspaceId?: string, path = 'entity'): EntityRef { + const kind = text(raw.kind, `${path}.kind`) + const id = text(raw.id, `${path}.id`) + const refWorkspace = expectedWorkspace(raw.workspaceId, `${path}.workspaceId`) + if (workspaceId !== undefined) assertWorkspaceMatch(expectedWorkspace(workspaceId), refWorkspace, `${path}.workspaceId`) + const version = raw.version == null ? undefined : nonNegativeInteger(raw.version, `${path}.version`) + return { kind, id, workspaceId: refWorkspace, ...(version == null ? {} : { version }) } +} + +/** Normalize an entity reference and reject empty or cross-workspace IDs. */ +export function normalizeEntityRef(value: unknown, workspaceId?: string): EntityRef { + return normalizeEntityRefRecord(record(value, 'entity'), workspaceId) +} + +export function assertEntityRef(value: unknown, workspaceId?: string): EntityRef { + return normalizeEntityRef(value, workspaceId) +} + +export function validateEntityRef(value: unknown, workspaceId?: string): string[] { + try { + normalizeEntityRef(value, workspaceId) + return [] + } catch (error) { + return [error instanceof Error ? error.message : 'Referencia de entidad inválida.'] + } +} + +function normalizeArtifactRefRecord(raw: RecordValue, workspaceId?: string, path = 'artifact'): ArtifactRef { + const id = text(raw.id, `${path}.id`) + const kind = text(raw.kind, `${path}.kind`) + if (!(ARTIFACT_KINDS as readonly string[]).includes(kind)) { + throw new CommandContractError(`${path}.kind no es un tipo de artefacto válido.`, 'invalid_artifact_kind', `${path}.kind`) + } + const owner = normalizeEntityRefRecord(record(raw.owner, `${path}.owner`), workspaceId, `${path}.owner`) + const taskId = optionalText(raw.taskId, `${path}.taskId`) + const uri = text(raw.uri, `${path}.uri`) + const metadata = raw.metadata == null ? {} : normalizeMetadata(raw.metadata, `${path}.metadata`) + return { + id, + kind: kind as ArtifactKind, + owner, + ...(taskId == null ? {} : { taskId }), + uri, + metadata, + } +} + +/** Normalize an artifact and bind its owner to the expected workspace. */ +export function normalizeArtifactRef(value: unknown, workspaceId?: string): ArtifactRef { + return normalizeArtifactRefRecord(record(value, 'artifact'), workspaceId) +} + +export function assertArtifactRef(value: unknown, workspaceId?: string): ArtifactRef { + return normalizeArtifactRef(value, workspaceId) +} + +export function validateArtifactRef(value: unknown, workspaceId?: string): string[] { + try { + normalizeArtifactRef(value, workspaceId) + return [] + } catch (error) { + return [error instanceof Error ? error.message : 'Referencia de artefacto inválida.'] + } +} + +function normalizeNavigationTargetRecord(raw: RecordValue, workspaceId?: string, path = 'navigationTarget'): NavigationTarget { + const destination = text(raw.destination ?? raw.tab ?? raw.area, `${path}.destination`) + const section = optionalText(raw.section, `${path}.section`) + const anchor = optionalText(raw.anchor, `${path}.anchor`) + const rawEntity = raw.entity ?? raw.target + const entity = rawEntity == null ? undefined : normalizeEntityRefRecord(record(rawEntity, `${path}.entity`), workspaceId, `${path}.entity`) + return { + destination, + ...(section == null ? {} : { section }), + ...(entity == null ? {} : { entity }), + ...(anchor == null ? {} : { anchor }), + } +} + +export function normalizeNavigationTarget(value: unknown, workspaceId?: string): NavigationTarget { + return normalizeNavigationTargetRecord(record(value, 'navigationTarget'), workspaceId) +} + +export function assertNavigationTarget(value: unknown, workspaceId?: string): NavigationTarget { + return normalizeNavigationTarget(value, workspaceId) +} + +export function validateNavigationTarget(value: unknown, workspaceId?: string): string[] { + try { + normalizeNavigationTarget(value, workspaceId) + return [] + } catch (error) { + return [error instanceof Error ? error.message : 'Destino de navegación inválido.'] + } +} + +function normalizePresentationRecord(raw: RecordValue, workspaceId?: string): PresentationPlan { + const rawNavigation = raw.navigationTarget ?? raw.navigation + const navigationTarget = rawNavigation == null + ? (raw.destination == null ? undefined : normalizeNavigationTargetRecord({ + destination: raw.destination, + section: raw.section, + anchor: raw.anchor, + }, workspaceId, 'presentation.navigationTarget')) + : normalizeNavigationTargetRecord(record(rawNavigation, 'presentation.navigationTarget'), workspaceId, 'presentation.navigationTarget') + const anchors = normalizedUniqueList(raw.anchors, 'presentation.anchors') + const focus = optionalText(raw.focus, 'presentation.focus') + const speed = raw.speed == null ? 'normal' : text(raw.speed, 'presentation.speed') + if (!(PRESENTATION_SPEEDS as readonly string[]).includes(speed)) { + throw new CommandContractError('presentation.speed no es válido.', 'invalid_presentation_speed', 'presentation.speed') + } + if (raw.replay != null && raw.replay !== 'atomic') { + throw new CommandContractError('presentation.replay sólo admite “atomic”.', 'invalid_presentation_replay', 'presentation.replay') + } + return { + ...(navigationTarget == null ? {} : { navigationTarget }), + anchors, + ...(focus == null ? {} : { focus }), + speed: speed as PresentationSpeed, + ...(raw.replay == null ? {} : { replay: 'atomic' }), + } +} + +export function normalizePresentationPlan(value: unknown, workspaceId?: string): PresentationPlan { + return normalizePresentationRecord(record(value, 'presentation'), workspaceId) +} + +export function assertPresentationPlan(value: unknown, workspaceId?: string): PresentationPlan { + return normalizePresentationPlan(value, workspaceId) +} + +export function validatePresentationPlan(value: unknown, workspaceId?: string): string[] { + try { + normalizePresentationPlan(value, workspaceId) + return [] + } catch (error) { + return [error instanceof Error ? error.message : 'Plan de presentación inválido.'] + } +} + +/** + * Compatibility bridge for the currently registered capability metadata. + * It keeps destination/anchors/replay usable while callers migrate to the + * global command contract. + */ +export function presentationPlanFromCapabilityPresentation(value: { + destination: string + anchors?: string[] + replay?: 'atomic' +}): PresentationPlan { + return normalizePresentationPlan(value) +} + +export function normalizeStructuredError(value: unknown): StructuredError { + if (typeof value === 'string') { + return { code: 'unknown_error', message: text(value, 'error.message'), retryable: false } + } + const raw = record(value, 'error') + const code = text(raw.code, 'error.code') + const message = text(raw.message, 'error.message') + const retryable = raw.retryable == null + ? false + : (typeof raw.retryable === 'boolean' + ? raw.retryable + : (() => { + throw new CommandContractError('error.retryable debe ser booleano.', 'invalid_boolean', 'error.retryable') + })()) + const field = optionalText(raw.field, 'error.field') + const details = raw.details == null ? undefined : normalizeMetadata(raw.details, 'error.details') + return { + code, + message, + retryable, + ...(field == null ? {} : { field }), + ...(details == null ? {} : { details }), + } +} + +export function assertStructuredError(value: unknown): StructuredError { + return normalizeStructuredError(value) +} + +export function validateStructuredError(value: unknown): string[] { + try { + normalizeStructuredError(value) + return [] + } catch (error) { + return [error instanceof Error ? error.message : 'Error estructurado inválido.'] + } +} + +function normalizeExpectedVersion(value: unknown, name: string): number | undefined { + return value == null ? undefined : nonNegativeInteger(value, name) +} + +function targetForKey(target: EntityRef | undefined): Record | null { + return target == null ? null : { + kind: target.kind, + id: target.id, + workspaceId: target.workspaceId, + ...(target.version == null ? {} : { version: target.version }), + } +} + +export interface IdempotencyKeyInput { + workspaceId: string + capability: string + target?: EntityRef + input: T +} + +/** + * Stable, inspectable key for command retries. The shared stable serializer + * from agentContract sorts object keys recursively, so object insertion order + * cannot produce a second command identity. + */ +export function buildIdempotencyKey(value: IdempotencyKeyInput): string { + const raw = record(value, 'idempotency') + const workspaceId = expectedWorkspace(raw.workspaceId, 'workspaceId') + const capability = text(raw.capability, 'capability') + const target = raw.target == null ? undefined : normalizeEntityRef(raw.target, workspaceId) + const input = normalizePayload(raw.input, 'input', {} as T) + return `command:${sharedStableSerialize({ + workspaceId, + capability, + target: targetForKey(target), + input, + })}` +} + +/** Named aliases make the constructor discoverable during the migration. */ +export const commandIdempotencyKey = buildIdempotencyKey +export const idempotencyKey = buildIdempotencyKey +export const createIdempotencyKey = buildIdempotencyKey + +export function createCommandEnvelope(value: CommandEnvelopeInput): CommandEnvelope { + const normalized = normalizeCommandEnvelope(value) + return normalized +} + +export function normalizeCommandEnvelope(value: unknown): CommandEnvelope { + const raw = record(value, 'command') + const commandId = text(raw.commandId ?? raw.command_id, 'command.commandId') + const capability = text(raw.capability, 'command.capability') + const workspaceId = expectedWorkspace(raw.workspaceId ?? raw.workspace_id, 'command.workspaceId') + const actor = text(raw.actor, 'command.actor') + if (actor !== 'user' && actor !== 'wizard') { + throw new CommandContractError('command.actor debe ser “user” o “wizard”.', 'invalid_actor', 'command.actor') + } + const target = raw.target == null ? undefined : normalizeEntityRef(raw.target, workspaceId) + const input = normalizePayload(raw.input, 'command.input', {} as T) + const expectedVersion = normalizeExpectedVersion(raw.expectedVersion ?? raw.expected_version, 'command.expectedVersion') + const presentation = raw.presentation == null ? undefined : normalizePresentationPlan(raw.presentation, workspaceId) + const derivedKey = buildIdempotencyKey({ workspaceId, capability, target, input }) + const suppliedKey = raw.idempotencyKey ?? raw.idempotency_key + if (suppliedKey != null && text(suppliedKey, 'command.idempotencyKey') !== derivedKey) { + throw new CommandContractError( + 'command.idempotencyKey no coincide con workspace, capability, target e input.', + 'idempotency_key_mismatch', + 'command.idempotencyKey', + ) + } + return { + commandId, + capability, + workspaceId, + actor: actor as CommandActor, + ...(target == null ? {} : { target }), + input, + idempotencyKey: derivedKey, + ...(expectedVersion == null ? {} : { expectedVersion }), + ...(presentation == null ? {} : { presentation }), + } +} + +export function assertCommandEnvelope(value: unknown): CommandEnvelope { + return normalizeCommandEnvelope(value) +} + +export function validateCommandEnvelope(value: unknown): string[] { + try { + normalizeCommandEnvelope(value) + return [] + } catch (error) { + return [error instanceof Error ? error.message : 'Envelope de comando inválido.'] + } +} + +function normalizeStatus(value: unknown): CommandStatus { + const status = text(value, 'result.status') + if (!(COMMAND_STATUSES as readonly string[]).includes(status)) { + throw new CommandContractError(`result.status “${status}” no es válido.`, 'invalid_status', 'result.status') + } + return status as CommandStatus +} + +export function normalizeCommandResult(value: unknown, workspaceId?: string): CommandResult { + const raw = record(value, 'result') + const expected = workspaceId == null ? undefined : expectedWorkspace(workspaceId) + const commandId = text(raw.commandId ?? raw.command_id, 'result.commandId') + const status = normalizeStatus(raw.status) + const entities = raw.entities == null + ? [] + : (Array.isArray(raw.entities) + ? raw.entities.map((item, index) => normalizeEntityRefRecord(record(item, `result.entities[${index}]`), expected, `result.entities[${index}]`)) + : (() => { throw new CommandContractError('result.entities debe ser una lista.', 'invalid_list', 'result.entities') })()) + const artifacts = raw.artifacts == null + ? [] + : (Array.isArray(raw.artifacts) + ? raw.artifacts.map((item, index) => normalizeArtifactRefRecord(record(item, `result.artifacts[${index}]`), expected, `result.artifacts[${index}]`)) + : (() => { throw new CommandContractError('result.artifacts debe ser una lista.', 'invalid_list', 'result.artifacts') })()) + const workspaces = new Set([...entities.map(item => item.workspaceId), ...artifacts.map(item => item.owner.workspaceId)]) + if (expected == null && workspaces.size > 1) { + throw new CommandContractError('result contiene referencias de varios workspaces.', 'cross_workspace_reference', 'result') + } + const inferredWorkspace = expected ?? [...workspaces][0] + const taskIds = normalizedUniqueList(raw.taskIds ?? raw.task_ids, 'result.taskIds') + const pipelineIds = normalizedUniqueList(raw.pipelineIds ?? raw.pipeline_ids, 'result.pipelineIds') + const navigationTarget = raw.navigationTarget == null + ? undefined + : normalizeNavigationTarget(raw.navigationTarget, inferredWorkspace) + const error = raw.error == null ? undefined : normalizeStructuredError(raw.error) + return { + commandId, + status, + entities, + artifacts, + taskIds, + pipelineIds, + ...(navigationTarget == null ? {} : { navigationTarget }), + ...(error == null ? {} : { error }), + } +} + +export function assertCommandResult(value: unknown, workspaceId?: string): CommandResult { + return normalizeCommandResult(value, workspaceId) +} + +export function validateCommandResult(value: unknown, workspaceId?: string): string[] { + try { + normalizeCommandResult(value, workspaceId) + return [] + } catch (error) { + return [error instanceof Error ? error.message : 'Resultado de comando inválido.'] + } +} + +/** Canonical JSON-like serialization for transport snapshots and tests. */ +export function serializeCommand(value: CommandEnvelope): string { + return sharedStableSerialize(normalizeCommandEnvelope(value)) +} + +export function serializeCommandResult(value: CommandResult, workspaceId?: string): string { + return sharedStableSerialize(normalizeCommandResult(value, workspaceId)) +} + +/** Explicit workspace guard for adapters before they consume a reference. */ +export function assertWorkspaceScope(workspaceId: string, references: readonly (EntityRef | ArtifactRef)[]): void { + const expected = expectedWorkspace(workspaceId) + if (!Array.isArray(references)) { + throw new CommandContractError('references debe ser una lista.', 'invalid_list', 'references') + } + references.forEach((reference, index) => { + const path = `references[${index}]` + const raw = record(reference, path) + if ('owner' in raw) { + normalizeArtifactRefRecord(raw, expected, path) + } else { + normalizeEntityRefRecord(raw, expected, path) + } + }) +} + +export function sameWorkspace(left: EntityRef | ArtifactRef, right: EntityRef | ArtifactRef): boolean { + const normalizeReferenceWorkspace = (value: unknown, path: string): string => { + const raw = record(value, path) + return 'owner' in raw + ? normalizeArtifactRefRecord(raw, undefined, path).owner.workspaceId + : normalizeEntityRefRecord(raw, undefined, path).workspaceId + } + const leftWorkspace = normalizeReferenceWorkspace(left, 'left') + const rightWorkspace = normalizeReferenceWorkspace(right, 'right') + return leftWorkspace === rightWorkspace +} + +/** + * Re-export the shared canonical serializer through the command boundary, but + * validate first. This keeps the key format compatible with agentContract + * while rejecting values that JSON transport would silently corrupt. + */ +export function stableSerialize(value: unknown): string { + return sharedStableSerialize(normalizeJsonValue(value, 'value')) +} + +export function commandResultFromSlice(input: { + commandId?: string + status?: CommandStatus + entity?: EntityRef + entities?: EntityRef[] + artifacts?: ArtifactRef[] + taskIds?: string[] + pipelineIds?: string[] + navigationTarget?: NavigationTarget + error?: StructuredError +}): CommandResult { + return { + commandId: input.commandId || 'slice', + status: input.status || (input.error ? 'failed' : 'completed'), + entities: input.entities || (input.entity ? [input.entity] : []), + artifacts: input.artifacts || [], + taskIds: input.taskIds || [], + pipelineIds: input.pipelineIds || [], + navigationTarget: input.navigationTarget, + error: input.error, + } +} diff --git a/ui/src/lib/labHelpers.ts b/ui/src/lib/labHelpers.ts new file mode 100644 index 00000000..8b9f35f7 --- /dev/null +++ b/ui/src/lib/labHelpers.ts @@ -0,0 +1,66 @@ +export interface CreativeCharacter { + name: string + role: string + personality: string + desire: string + flaw: string + appearance: string + voice: string +} + +export interface CreativeLocation { + name: string + purpose: string + description: string +} + +export const normalizeName = (value: string): string => value + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-zA-Z0-9]+/g, ' ') + .trim() + .toLowerCase() + +export const boundedDuration = (value: number | undefined, fallback: number): number => ( + Math.max(15, Math.min(3_600, Math.round(value || fallback))) +) + +export const explicitMusicLanguage = (value: string): string => { + const raw = value.trim() + const aliases: Record = { + es: 'Español', en: 'English', fr: 'Français', de: 'Deutsch', + it: 'Italiano', pt: 'Português', ja: '日本語', ko: '한국어', zh: '中文', + } + return aliases[raw.toLowerCase()] || raw || 'Español' +} + +export function creativeCharacters(values: CreativeCharacter[]): CreativeCharacter[] { + return values.length ? values : [{ + name: 'Protagonista', + role: 'Protagonista', + personality: 'Ingenioso, curioso y decidido.', + desire: 'Resolver el conflicto central.', + flaw: 'Se precipita cuando cree tener razón.', + appearance: 'Silueta clara, vestuario reconocible y expresiones legibles.', + voice: 'Natural, expresiva y coherente con el tono.', + }] +} + +export function creativeLocations(values: CreativeLocation[]): CreativeLocation[] { + return values.length ? values : [{ + name: 'Escenario principal', + purpose: 'Reunir a los personajes y hacer visible el conflicto.', + description: 'Un lugar reconocible, visualmente coherente y con espacio para la acción.', + }] +} + +export function outlineBeats(values: string[], premise: string, ending: string): string[] { + if (values.length >= 3) return values + return [ + `Inicio: ${premise || 'se presenta el deseo del protagonista y aparece una complicación.'}`, + 'Desarrollo: el plan inicial empeora el conflicto y obliga a los personajes a cambiar de estrategia.', + `Final: ${ending || 'la decisión final resuelve el problema con una consecuencia clara y memorable.'}`, + ] +} + + diff --git a/ui/tests/agentActions.test.mjs b/ui/tests/agentActions.test.mjs index 0ed9b838..8d6934d1 100644 --- a/ui/tests/agentActions.test.mjs +++ b/ui/tests/agentActions.test.mjs @@ -1119,6 +1119,125 @@ test('character kit and video editor execute, reuse export id, and reject unsign } }) +test('track_video_editor_export keeps job status without a filename', async () => { + const { executeAgentActions } = await import('../src/features/agent/agentActions.ts') + const { clearExecutionMemory } = await import('../src/features/agent/agentContract.ts') + const { useStore } = await import('../src/stores/useStore.ts') + clearExecutionMemory() + const originalFetch = globalThis.fetch + const originalLoadOutputs = useStore.getState().loadOutputs + useStore.setState({ loadOutputs: async () => {}, activeWorkspace: 'default' }) + let job = { + job_id: 'export-88', + status: 'running', + progress: 40, + message: 'Codificando el corte', + filename: null, + url: null, + error: null, + } + globalThis.fetch = async (input, init) => { + const url = String(typeof input === 'string' ? input : input.url) + const method = init?.method || 'GET' + const json = body => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }) + if (url.includes('/api/v1/video-editor/export/export-88') && method === 'GET') return json(job) + return json({}) + } + window.localStorage.setItem('maestro-video-editor-export-v1:default', 'export-88') + try { + await executeAgentActions([{ type: 'create_video_editor_project', projectName: 'corte-final' }]) + const running = await executeAgentActions([{ type: 'track_video_editor_export' }]) + assert.equal(running[0].ok, true) + assert.equal(running[0].report.state, 'running') + assert.equal(running[0].report.taskId, 'export-88') + assert.match(running[0].message, /Codificando el corte/) + assert.deepEqual(running[0].report.outputNames || [], []) + + job = { ...job, status: 'failed', message: 'FFmpeg se quedó sin disco', error: 'disk' } + const failed = await executeAgentActions([{ type: 'track_video_editor_export' }]) + assert.equal(failed[0].report.state, 'failed') + assert.match(failed[0].message, /FFmpeg se quedó sin disco/) + assert.deepEqual(failed[0].report.outputNames || [], []) + } finally { + window.localStorage.removeItem('maestro-video-editor-export-v1:default') + globalThis.fetch = originalFetch + useStore.setState({ loadOutputs: originalLoadOutputs }) + clearExecutionMemory() + } +}) + +test('track_character_kit_job inspects the canonical queue and stays running', async () => { + const { executeAgentActions } = await import('../src/features/agent/agentActions.ts') + const { clearExecutionMemory } = await import('../src/features/agent/agentContract.ts') + const { useStore } = await import('../src/stores/useStore.ts') + clearExecutionMemory() + let kitLibrary = { version: 1, revision: 0, activeId: '', kits: {} } + let activityOpened = 0 + const onActivity = () => { activityOpened += 1 } + window.addEventListener('hocuspocus:activity-details', onActivity) + const originalFetch = globalThis.fetch + const originalLoadOutputs = useStore.getState().loadOutputs + useStore.setState({ loadOutputs: async () => {} }) + globalThis.fetch = async (input, init) => { + const url = String(typeof input === 'string' ? input : input.url) + const method = init?.method || 'GET' + const json = body => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }) + if (url.includes('/api/v1/character-kits/library/kits/') && method === 'PATCH') { + const body = JSON.parse(String(init.body || '{}')) + kitLibrary = { ...kitLibrary, revision: kitLibrary.revision + 1, activeId: body.kit.id, kits: { ...kitLibrary.kits, [body.kit.id]: body.kit } } + return json(kitLibrary) + } + if (url.includes('/api/v1/character-kits/library')) return json(kitLibrary) + if (url.includes('/api/v1/tasks')) { + return json({ + workspace: 'default', + latest_event_id: 1, + tasks: [{ + id: 'task-kit-1', + root_id: 'task-kit-1', + parent_id: null, + kind: 'generation', + title: 'Pose base Nora', + workflow: 'character_kit', + status: 'running', + phase: 'render', + message: 'Generando', + current: 1, + total: 2, + progress: 0.5, + detail_current: 0, + detail_total: 0, + created_at: 1, + updated_at: 1, + attempt: 1, + max_attempts: 1, + cancelable: true, + resumable: false, + recoverable: false, + resource_requirements: ['gpu'], + }], + }) + } + return json({}) + } + try { + await executeAgentActions([{ type: 'create_character_kit', name: 'Nora', style: 'cutout' }]) + const tracked = await executeAgentActions([{ type: 'track_character_kit_job', kitName: 'Nora' }]) + assert.equal(tracked[0].ok, true) + assert.equal(tracked[0].report.state, 'running') + assert.equal(tracked[0].report.target.kind, 'character_kit') + assert.match(tracked[0].message, /Sigo el trabajo de “Nora”/) + assert.match(tracked[0].message, /Pose base Nora/) + assert.match(tracked[0].message, /He abierto Activity/) + assert.ok(activityOpened >= 1) + } finally { + window.removeEventListener('hocuspocus:activity-details', onActivity) + globalThis.fetch = originalFetch + useStore.setState({ loadOutputs: originalLoadOutputs }) + clearExecutionMemory() + } +}) + test('compute comic render without confirm is dropped', async () => { const { parseAgentTurn } = await import('../src/features/agent/agentActions.ts') const turn = parseAgentTurn(JSON.stringify({ diff --git a/ui/tests/agentCapabilityPorts.test.mjs b/ui/tests/agentCapabilityPorts.test.mjs index c7fc920a..3023cccf 100644 --- a/ui/tests/agentCapabilityPorts.test.mjs +++ b/ui/tests/agentCapabilityPorts.test.mjs @@ -29,14 +29,10 @@ const SET_STATE_ALLOWLIST = [ ] const SLICE_AGENT_IMPORT_ALLOWLIST = [ - ['characters/actions.ts', '../agent/agentContract'], - ['characters/actions.ts', '../agent/queueActions'], - ['characters/actions.ts', '../agent/wizardLabSession'], ['series/SeriesEpisodePanel.tsx', '../agent/agentUiBus'], ['series/SeriesLabPanel.tsx', '../agent/agentUiBus'], ['series/SeriesReviewPanel.tsx', '../agent/agentUiBus'], ['stories/StoryLabPanel.tsx', '../agent/agentUiBus'], - ['video-editor/actions.ts', '../agent/agentContract'], ] const LEGACY_EXECUTE_ALLOWLIST = [ @@ -56,15 +52,6 @@ const LEGACY_EXECUTE_ALLOWLIST = [ 'retry_task', 'select_workspace', 'create_workspace', - 'generate_comic_panel', - 'update_character_kit', - 'add_video_editor_clips', - 'order_video_editor_clips', - 'trim_video_editor_clip', - 'add_video_editor_audio', - 'validate_video_editor_timeline', - 'export_video_editor', - 'track_video_editor_export', ] const AGENT_ACTIONS_IMPORTS = [ @@ -171,13 +158,20 @@ function importSpecifiers(source) { function sliceAgentImports() { const found = [] + const seen = new Set() for (const path of walk(FEATURES_ROOT)) { const rel = relative(FEATURES_ROOT, path).replaceAll('\\', '/') if (rel.startsWith('agent/')) continue const source = readFileSync(path, 'utf8') const pattern = /(?:from\s*|import\s*\()\s*['"]([^'"]*agent\/[^'"]+)['"]/g let match - while ((match = pattern.exec(source))) found.push([rel, match[1]]) + while ((match = pattern.exec(source))) { + const row = [rel, match[1]] + const key = JSON.stringify(row) + if (seen.has(key)) continue + seen.add(key) + found.push(row) + } } return found.sort((a, b) => a[0].localeCompare(b[0]) || a[1].localeCompare(b[1])) } @@ -246,7 +240,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, 25) + assert.equal(legacy.length, 16) }) test('agentActions.ts and labActions.ts keep their current module graph until a slice PR shrinks it', () => {