From e2404daeeb9bf289a7e2675f2a667379aaad6c15 Mon Sep 17 00:00:00 2001 From: IAnMove <216241348+IAnMove@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:06:53 +0200 Subject: [PATCH] refactor: split labActions into story, series and comic modules Keep Story (and its setState allowlist) in labActions.ts. Move Series and Comics implementations behind a barrel so later slice PRs no longer have to edit the same 2.6k file. Shared name/duration helpers live in labActionHelpers.ts. --- ui/src/features/agent/comicLabActions.ts | 389 ++++++++ ui/src/features/agent/labActionHelpers.ts | 59 ++ ui/src/features/agent/labActions.ts | 1047 +-------------------- ui/src/features/agent/seriesLabActions.ts | 596 ++++++++++++ ui/tests/agentCapabilityPorts.test.mjs | 7 +- 5 files changed, 1072 insertions(+), 1026 deletions(-) create mode 100644 ui/src/features/agent/comicLabActions.ts create mode 100644 ui/src/features/agent/labActionHelpers.ts create mode 100644 ui/src/features/agent/seriesLabActions.ts diff --git a/ui/src/features/agent/comicLabActions.ts b/ui/src/features/agent/comicLabActions.ts new file mode 100644 index 00000000..b79d62ab --- /dev/null +++ b/ui/src/features/agent/comicLabActions.ts @@ -0,0 +1,389 @@ +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}).` +} diff --git a/ui/src/features/agent/labActionHelpers.ts b/ui/src/features/agent/labActionHelpers.ts new file mode 100644 index 00000000..adfc7603 --- /dev/null +++ b/ui/src/features/agent/labActionHelpers.ts @@ -0,0 +1,59 @@ +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 function showLab(filter: 'stories' | 'series'): void { + const state = useStore.getState() + state.setSettingsOpen(false) + state.setDashboardOpen(false) + state.setMediaFilter(filter) + state.setSidebarOpen(false) +} diff --git a/ui/src/features/agent/labActions.ts b/ui/src/features/agent/labActions.ts index 3e9b68d9..07a78947 100644 --- a/ui/src/features/agent/labActions.ts +++ b/ui/src/features/agent/labActions.ts @@ -6,85 +6,27 @@ import type { AgentApproveStoryVisualsAction, AgentConfigureStorySongAction, AgentGenerateStoryVisualsAction, - AgentCreateComicAction, AgentGenerateStorySectionAction, AgentGenerateStorySongAction, - AgentGenerateSeriesPlanAction, - AgentApplySeriesPlanAction, - AgentRenderSeriesShotsAction, - AgentReviewSeriesAttemptsAction, - AgentAssembleSeriesEpisodeAction, - AgentCommitSeriesCanonAction, AgentStageStoryComicAction, AgentStageStoryVideoAction, AgentStageStoryMusicVideoAction, AgentStartDirectorProductionAction, - AgentUpdateSeriesEpisodeAction, - AgentCreateSeriesEpisodeAction, AgentCreateStoryAction, AgentUpdateStoryAction, - AgentCreativeCharacter, - AgentCreativeLocation, } from './agentActions' import { bindDirectorProductionTarget, type AgentExecutionTarget } from './agentContract' -import { clearAgentSeriesPlanJob, notifyAgentSeriesAssemblyJob, notifyAgentSeriesPlanJob, notifyAgentSeriesRenderJob, notifyAgentStoryDraft, openAgentSeriesReviewView, openAgentSeriesSection, openAgentStorySection, requestAgentStoryVisualGeneration } from './agentUiBus' +import { notifyAgentStoryDraft, openAgentStorySection, requestAgentStoryVisualGeneration } from './agentUiBus' +import { + boundedDuration, + creativeCharacters, + creativeLocations, + explicitMusicLanguage, + normalizeName, + outlineBeats, + showLab, +} from './labActionHelpers' -const normalizeName = (value: string): string => value - .normalize('NFD') - .replace(/[\u0300-\u036f]/g, '') - .replace(/[^a-zA-Z0-9]+/g, ' ') - .trim() - .toLowerCase() - -const boundedDuration = (value: number | undefined, fallback: number): number => ( - Math.max(15, Math.min(3_600, Math.round(value || fallback))) -) - -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' -} - -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.', - }] -} - -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.', - }] -} - -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.'}`, - ] -} - -function showLab(filter: 'stories' | 'series'): void { - const state = useStore.getState() - state.setSettingsOpen(false) - state.setDashboardOpen(false) - state.setMediaFilter(filter) - state.setSidebarOpen(false) -} async function saveActiveStoryProjectMutation( workspace: string, @@ -1706,957 +1648,18 @@ export async function startDirectorProduction( } } -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.` -} - -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 { + assembleSeriesEpisode, + applySeriesPlan, + commitSeriesCanonDelta, + createFilledSeriesEpisode, + generateSeriesPlan, + renderSeriesShots, + reviewSeriesAttempts, + updateSeriesEpisode, +} from './seriesLabActions' +export { + createFilledComic, + generateComicPanelArtwork, + generateFilledComicArtwork, +} from './comicLabActions' diff --git a/ui/src/features/agent/seriesLabActions.ts b/ui/src/features/agent/seriesLabActions.ts new file mode 100644 index 00000000..603ed018 --- /dev/null +++ b/ui/src/features/agent/seriesLabActions.ts @@ -0,0 +1,596 @@ +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.` +} + diff --git a/ui/tests/agentCapabilityPorts.test.mjs b/ui/tests/agentCapabilityPorts.test.mjs index 190b2a6b..9d24c214 100644 --- a/ui/tests/agentCapabilityPorts.test.mjs +++ b/ui/tests/agentCapabilityPorts.test.mjs @@ -93,11 +93,7 @@ const AGENT_ACTIONS_IMPORTS = [ const LAB_ACTIONS_IMPORTS = [ '../../api/client', '../../stores/useStore', - '../comics/generateArtwork', - '../comics/model', '../comics/store', - '../series/model', - '../series/store', '../stories/adaptations', '../stories/model', '../stories/musicModel', @@ -109,6 +105,9 @@ const LAB_ACTIONS_IMPORTS = [ './agentActions', './agentContract', './agentUiBus', + './comicLabActions', + './labActionHelpers', + './seriesLabActions', ] function walk(dir) {