diff --git a/app/services/story_library.py b/app/services/story_library.py index fed13b34..7542b8ec 100644 --- a/app/services/story_library.py +++ b/app/services/story_library.py @@ -156,6 +156,138 @@ def patch_story_project( return write_story_library(workspace_dir, next_library, base_revision=expected) +def _story_id_token(value: Any) -> str: + return str(value or "").strip() + + +def _index_by_id(items: list[Any], token: str) -> int: + for index, item in enumerate(items): + if isinstance(item, dict) and _story_id_token(item.get("id")) == token: + return index + return -1 + + +def _require_music_row(project: dict[str, Any], cue_id: str, candidate_id: str) -> tuple[dict, list, int, list, int]: + music = dict(project.get("music") or {}) + cues = list(music.get("cues") or []) + cue_index = _index_by_id(cues, cue_id) + if cue_index < 0: + raise KeyError(cue_id) + cue = dict(cues[cue_index]) + candidates = list(cue.get("candidates") or []) + candidate_index = _index_by_id(candidates, candidate_id) + if candidate_index < 0: + raise KeyError(candidate_id) + return music, cues, cue_index, candidates, candidate_index + + +def _apply_song_candidate_patch( + candidate: dict[str, Any], + *, + project_id: str, + cue_id: str, + candidate_id: str, + source: str, + filename: str, + status: str, + duration_seconds: float | int | None, + task_id: str | None, + root_task_id: str | None, + job_id: str | None, +) -> dict[str, Any]: + patched = dict(candidate) + patched["id"] = candidate_id + patched["source"] = str(source or "") + patched["name"] = str(filename or patched.get("name") or "") + patched["status"] = status + if duration_seconds is not None: + patched["durationSeconds"] = duration_seconds + if task_id: + patched["taskId"] = task_id + if root_task_id: + patched["rootTaskId"] = root_task_id + provenance = dict(patched.get("provenance") or {}) + provenance.update({ + "projectId": project_id, + "cueId": cue_id, + "candidateId": candidate_id, + }) + if job_id: + provenance["jobId"] = job_id + if task_id: + provenance["taskId"] = task_id + if root_task_id: + provenance["rootTaskId"] = root_task_id + patched["provenance"] = provenance + return patched + + +def attach_story_song_candidate( + workspace_dir: str, + *, + project_id: str, + cue_id: str, + candidate_id: str, + source: str, + filename: str, + status: str = "ready", + base_revision: int, + duration_seconds: float | int | None = None, + task_id: str | None = None, + root_task_id: str | None = None, + job_id: str | None = None, +) -> dict[str, Any]: + """CAS-patch one pending Story song row by project/cue/candidate IDs. + + Operates only on the library file inside ``workspace_dir``. A matching + candidate in another folder is never visible here. + """ + token_project = _story_id_token(project_id) + token_cue = _story_id_token(cue_id) + token_candidate = _story_id_token(candidate_id) + if not token_project or not token_cue or not token_candidate: + raise ValueError("Story song attach requires project, cue and candidate IDs") + if status not in {"pending", "ready", "failed"}: + raise ValueError("Story song status must be pending, ready or failed") + with _STORY_LIBRARY_LOCK: + current = read_story_library(workspace_dir) + expected = _base_revision(base_revision) + if expected != current["revision"]: + raise StoryLibraryRevisionConflict(expected, int(current["revision"])) + project = current["projects"].get(token_project) + if not isinstance(project, dict): + raise KeyError(token_project) + music, cues, cue_index, candidates, candidate_index = _require_music_row( + project, token_cue, token_candidate, + ) + cue = dict(cues[cue_index]) + candidates[candidate_index] = _apply_song_candidate_patch( + dict(candidates[candidate_index]), + project_id=token_project, + cue_id=token_cue, + candidate_id=token_candidate, + source=source, + filename=filename, + status=status, + duration_seconds=duration_seconds, + task_id=task_id, + root_task_id=root_task_id, + job_id=job_id, + ) + cue["candidates"] = candidates + cue["selectedCandidateId"] = token_candidate + cues[cue_index] = cue + music["cues"] = cues + music["selectedCandidateId"] = token_candidate + next_project = dict(project) + next_project["music"] = music + return write_story_library( + workspace_dir, + {**current, "projects": {**current["projects"], token_project: next_project}}, + base_revision=expected, + ) + + def delete_story_project( workspace_dir: str, project_id: str, diff --git a/docs/development/DOMAIN_MODEL_AND_ASSET_PROVENANCE.md b/docs/development/DOMAIN_MODEL_AND_ASSET_PROVENANCE.md index 23be656f..b12b1ee2 100644 --- a/docs/development/DOMAIN_MODEL_AND_ASSET_PROVENANCE.md +++ b/docs/development/DOMAIN_MODEL_AND_ASSET_PROVENANCE.md @@ -38,6 +38,12 @@ Every command and relationship propagates opaque IDs. Names, selected labels and `v1`-style display versions are never used to recover an identity that was already returned by the previous step. +Story Lab song versions follow the same rule: identity is +`StoryMusicCandidate.id` (`song-…`), not the display `v1` / `v2` integer. The +pending row is saved to the Story library before generate starts so a client +close can recover the WAV from a sidecar `candidate_id`. See +[STORY_SONG_IDENTITY.md](STORY_SONG_IDENTITY.md). + ## Storage transition Existing workspace folders and `.meta.json` files remain readable. New APIs diff --git a/docs/development/STORY_SONG_IDENTITY.md b/docs/development/STORY_SONG_IDENTITY.md new file mode 100644 index 00000000..c7c8c5fb --- /dev/null +++ b/docs/development/STORY_SONG_IDENTITY.md @@ -0,0 +1,47 @@ +# Story song identity + +Status: accepted for Block 3 (client persist-before-generate). Server-side +`generate-music` attach is a follow-up and is **not** in this slice. + +A song version **is** `StoryMusicCandidate.id` (`song-…`). Display `v1` / `v2` +is denormalized and must never be used to recover identity. + +## Persist before compute + +1. Resolve the **open Story** first (`targetStoryId` / current Story Lab + project), then a unique title. Do not invent another project. +2. Resolve the cue by **cueId**. Use title only when no ID is supplied. A stale + title fails. Never pick an unrelated sole cue because a title was given. +3. Mint `candidateId = storyId('song')` and `version = nextMusicCandidateVersion(...)` + **before** calling generate. +4. Save a **pending** row on that cue (`status: 'pending'`, empty source, + provenance with `projectId` / `cueId` / `candidateId` / `songVersion`) with + Story library CAS **before** compute starts. +5. Call generate with provenance + `{ actor, capability, project_id, cue_id, candidate_id, song_version }`. +6. On success, patch **the same id**: source, filename, task/job ids, + `status: 'ready'`. Do not mint a second id. +7. On failure, mark `status: 'failed'` and keep the id for retry lineage. + +Client close during generate is recoverable because the pending row is already +on disk. `loadWorkspace` reattaches a WAV whose sidecar/output carries the +matching `candidate_id`, including rows marked `failed` after a client timeout +once the sidecar exists. Recovery writes the `ready` patch to the Story +library immediately; it must not only update the in-memory snapshot. + +## Staging + +Staging a videoclip requires a **ready** candidate by id. Pending and failed +rows are refused. The synthetic cue id `story-song` is refused unless the +caller passed that exact id. + +## Normalize + +`normalizeMusicCandidate` keeps pending/failed rows that already have a stable +id, even with an empty `source`. It never remints an existing id on load. +Incomplete preview rows with no id and no source stay dropped. + +## Out of scope + +This slice does not edit `_launch_runtime.py`, `useStore.ts` or +`agentActions.ts`. Server-side attach during `generate-music` is the next PR. diff --git a/tests/test_story_library.py b/tests/test_story_library.py index 9be699c7..340aca7e 100644 --- a/tests/test_story_library.py +++ b/tests/test_story_library.py @@ -10,6 +10,7 @@ from app.services.story_library import ( MAX_STORY_PROJECTS, StoryLibraryRevisionConflict, + attach_story_song_candidate, normalize_story_library, delete_story_project, patch_story_project, @@ -106,6 +107,128 @@ def test_invalid_existing_json_is_not_silently_overwritten(self): with self.assertRaises(json.JSONDecodeError): read_story_library(directory) + def test_attach_story_song_candidate_patches_pending_row(self): + with tempfile.TemporaryDirectory() as directory: + initial = write_story_library(directory, { + "activeId": "story-a", + "projects": { + "story-a": { + "id": "story-a", + "title": "Workspace A", + "music": { + "cues": [{ + "id": "cue-a", + "title": "Theme", + "candidates": [{ + "id": "song-a", + "status": "pending", + "source": "", + "name": "", + }], + }], + }, + }, + }, + }, base_revision=0) + saved = attach_story_song_candidate( + directory, + project_id="story-a", + cue_id="cue-a", + candidate_id="song-a", + source="/api/v1/file/theme.wav?workspace=a", + filename="theme.wav", + status="ready", + base_revision=initial["revision"], + task_id="task-1", + ) + candidate = saved["projects"]["story-a"]["music"]["cues"][0]["candidates"][0] + self.assertEqual(saved["revision"], 2) + self.assertEqual(candidate["id"], "song-a") + self.assertEqual(candidate["status"], "ready") + self.assertEqual(candidate["name"], "theme.wav") + self.assertEqual(candidate["provenance"]["candidateId"], "song-a") + + def test_attach_story_song_candidate_cas_conflict_keeps_pending_row(self): + with tempfile.TemporaryDirectory() as directory: + first = write_story_library(directory, { + "projects": { + "story-a": { + "id": "story-a", + "music": { + "cues": [{ + "id": "cue-a", + "candidates": [{"id": "song-a", "status": "pending", "source": ""}], + }], + }, + }, + }, + }, base_revision=0) + attach_story_song_candidate( + directory, + project_id="story-a", + cue_id="cue-a", + candidate_id="song-a", + source="/api/v1/file/theme.wav", + filename="theme.wav", + base_revision=first["revision"], + ) + with self.assertRaises(StoryLibraryRevisionConflict): + attach_story_song_candidate( + directory, + project_id="story-a", + cue_id="cue-a", + candidate_id="song-a", + source="/api/v1/file/other.wav", + filename="other.wav", + base_revision=first["revision"], + ) + candidate = read_story_library(directory)["projects"]["story-a"]["music"]["cues"][0]["candidates"][0] + self.assertEqual(candidate["name"], "theme.wav") + self.assertEqual(candidate["id"], "song-a") + + def test_attach_story_song_candidate_is_isolated_by_workspace_dir(self): + with tempfile.TemporaryDirectory() as workspace_a, tempfile.TemporaryDirectory() as workspace_b: + write_story_library(workspace_a, { + "projects": { + "story-a": { + "id": "story-a", + "music": { + "cues": [{ + "id": "cue-a", + "candidates": [{"id": "song-a", "status": "pending", "source": ""}], + }], + }, + }, + }, + }, base_revision=0) + write_story_library(workspace_b, { + "projects": { + "story-b": { + "id": "story-b", + "music": { + "cues": [{ + "id": "cue-b", + "candidates": [{"id": "song-b", "status": "pending", "source": ""}], + }], + }, + }, + }, + }, base_revision=0) + with self.assertRaises(KeyError): + attach_story_song_candidate( + workspace_b, + project_id="story-a", + cue_id="cue-a", + candidate_id="song-a", + source="/api/v1/file/stolen.wav", + filename="stolen.wav", + base_revision=1, + ) + self.assertEqual( + read_story_library(workspace_a)["projects"]["story-a"]["music"]["cues"][0]["candidates"][0]["source"], + "", + ) + def test_project_limit_is_enforced(self): value = { "projects": { diff --git a/ui/src/api/director.ts b/ui/src/api/director.ts index 5e127a3e..a52156ab 100644 --- a/ui/src/api/director.ts +++ b/ui/src/api/director.ts @@ -41,6 +41,7 @@ export async function generateMusic(params: { production_id?: string cue_id?: string candidate_id?: string + song_version?: string command?: Record } }): Promise<{ diff --git a/ui/src/api/stories.ts b/ui/src/api/stories.ts index 54db7463..7375ce27 100644 --- a/ui/src/api/stories.ts +++ b/ui/src/api/stories.ts @@ -51,6 +51,14 @@ export interface StoryMusicCandidateRequest { reference_audio_filename?: string instrumental?: boolean workspace?: string + provenance?: { + actor?: 'user' | 'wizard' | 'system' | 'unknown' + capability?: string + project_id?: string + cue_id?: string + candidate_id?: string + song_version?: string + } } export async function startStoryMusicCandidatesJob( diff --git a/ui/src/features/stories/StoryLabPanel.tsx b/ui/src/features/stories/StoryLabPanel.tsx index cd437682..fa61ef2b 100644 --- a/ui/src/features/stories/StoryLabPanel.tsx +++ b/ui/src/features/stories/StoryLabPanel.tsx @@ -3256,66 +3256,13 @@ export function StoryLabPanel() { : beginStoryActivity('generating_music', `${usingAceStep ? 'ACE-Step' : 'MiniMax Music'} is generating “${cue.title}”…`, 1) setMusicCueBusy(`audio:${cueId}`) try { - if (usingLocalMusic) { - const prompt = cue.style.trim() - const durationSeconds = clampStoryMusicDuration( - cue.durationSeconds || current.music.targetDurationSeconds, - current.music.model, - ) - const rendered = await api.generateMusic({ - style: prompt, - lyrics: cue.instrumental ? '[Instrumental]' : cue.lyrics, - instrumental: cue.instrumental, - duration_seconds: durationSeconds, - model_type: current.music.model, - workspace: activeWorkspace, - initiator: `Story Lab · ${current.projectType === 'music_video' ? 'Videoclip' : 'Story song'}`, - }) - const createdAt = new Date().toISOString() - const language = cue.lyricsLanguage || current.language - const firstVersion = nextMusicCandidateVersion(cue.candidates, language, current.language) - const candidates = [{ - id: storyId('song'), - displayName: `${cue.title} · ${language} · v${firstVersion}`, - title: cue.title, - language, - version: firstVersion, - name: rendered.filename, - source: api.getFileUrl(rendered.filename, activeWorkspace), - prompt, - lyrics: cue.lyrics, - provider: 'local' as const, - model: current.music.model, - durationSeconds, - createdAt, - }] - updateProjectById(sourceProjectId, latest => { - const target = latest.music.cues.find(item => item.id === cueId) - if (!target) return latest - return { - ...latest, - music: { - ...latest.music, - cues: latest.music.cues.map(item => item.id === cueId ? { - ...item, - candidates: [...item.candidates, ...candidates], - selectedCandidateId: candidates[0]?.id || item.selectedCandidateId, - } : item), - }, - } - }) - setNotice({ kind: 'ok', text: t(usingAceStep ? 'notice.aceStepGenerated' : 'notice.minimaxMusic3LocalGenerated', { title: cue.title }) }) - return true - } - const prompt = cue.style.trim().slice(0, 300) - const result = await api.generateStoryMusicCandidates({ - prompt, - lyrics: cue.instrumental ? '' : cue.lyrics, - instrumental: cue.instrumental, - count: 1, - model: current.music.model, + const { generateStoryCueSong } = await import('./storySongGeneration') + await generateStoryCueSong({ workspace: activeWorkspace, - }, { + projectId: sourceProjectId, + cueId, + actor: 'user', + capability: 'generate_story_song', onJobSubmitted: job => { activeMusicJobId.current = job.jobId activity?.handoff(`Continuing as recoverable MiniMax Music job ${job.jobId}`) @@ -3328,43 +3275,14 @@ export function StoryLabPanel() { job.total, ), }) - const createdAt = new Date().toISOString() - const language = cue.lyricsLanguage || current.language - const firstVersion = nextMusicCandidateVersion(cue.candidates, language, current.language) - const candidates = result.candidates.map((candidate, index) => ({ - id: storyId('song'), - displayName: `${cue.title} · ${language} · v${firstVersion + index}`, - title: cue.title, - language, - version: firstVersion + index, - name: candidate.filename, - source: candidate.source, - prompt, - lyrics: cue.lyrics, - provider: 'minimax' as const, - model: candidate.model, - durationSeconds: candidate.duration_seconds, - createdAt, - taskId: candidate.taskId || candidate.task_id, - rootTaskId: candidate.rootTaskId || candidate.root_task_id, - })) - updateProjectById(sourceProjectId, latest => { - const target = latest.music.cues.find(item => item.id === cueId) - if (target) { - target.candidates.push(...candidates) - target.selectedCandidateId = candidates[0]?.id || target.selectedCandidateId - } - return latest - }) + if (usingLocalMusic) { + setNotice({ kind: 'ok', text: t(usingAceStep ? 'notice.aceStepGenerated' : 'notice.minimaxMusic3LocalGenerated', { title: cue.title }) }) + return true + } if (!queued) { - setNotice({ - kind: result.status === 'completed' ? 'ok' : 'error', - text: result.status === 'completed' - ? t('notice.minimaxCueGenerated', { title: cue.title }) - : t('notice.minimaxCuePartial', { title: cue.title, message: result.message }), - }) + setNotice({ kind: 'ok', text: t('notice.minimaxCueGenerated', { title: cue.title }) }) } - return result.status === 'completed' || result.status === 'cancelled' + return true } catch (error) { activity?.fail(error, 'generating_music') setNotice({ kind: 'error', text: t('notice.cueGenerateFailed', { title: cue.title, message: (error as Error).message }) }) diff --git a/ui/src/features/stories/actions.ts b/ui/src/features/stories/actions.ts index 4161bff6..759ce55b 100644 --- a/ui/src/features/stories/actions.ts +++ b/ui/src/features/stories/actions.ts @@ -15,16 +15,13 @@ import { buildStorySongWritingRequest, protectedSongLyrics, resolveStorySongLanguage, - songProviderLanguageIntent, storySongSemanticAnchors, } from './songLanguage' import { directorResultDetails, directorRunProvenance, - generatedSongProvenance, } from './provenance' import { - buildGeneratedSongCandidate, buildMusicVideoProduction, validateMusicVideoStaging, } from './musicWorkflowState' @@ -102,46 +99,8 @@ async function saveActiveStoryProjectMutation( projectId: string, mutate: (project: import('./types').StoryProject) => import('./types').StoryProject, ): Promise { - const [{ useStoryStore }, api] = await Promise.all([ - import('./store'), import('../../api/client'), - ]) - let baseline = current - let library: Awaited> | null = null - for (let attempt = 0; attempt < 3; attempt += 1) { - const source = baseline.projects[projectId] - if (!source) throw new Error('La historia activa desapareció antes de poder guardarla.') - const project = mutate(source) - try { - library = await api.saveStoryLibrary(workspace, { - version: 2, - revision: baseline.libraryRevision, - activeId: project.id, - projects: { ...baseline.projects, [project.id]: project }, - }) - break - } catch (error) { - if (!(error instanceof api.StoryLibraryRevisionError) || attempt === 2) throw error - const remote = await api.fetchStoryLibrary(workspace) - baseline = { - libraryRevision: remote.revision, - projects: remote.projects, - } - } - } - if (!library?.projects[projectId]) throw new Error('Story Lab guardó la biblioteca sin devolver la historia editada.') - useStoryStore.setState({ - workspace, - project: library.projects[projectId], - projects: library.projects, - libraryRevision: library.revision, - dirty: false, - hydrated: false, - loading: false, - saveError: null, - libraryConflicts: [], - }) - await useStoryStore.getState().loadWorkspace(workspace) - return library.projects[projectId] + const { saveStoryProjectMutation } = await import('./store') + return saveStoryProjectMutation(workspace, current, projectId, mutate) } export async function configureStorySong(action: ConfigureStorySongCommand): Promise { @@ -278,8 +237,8 @@ export async function configureStorySong(action: ConfigureStorySongCommand): Pro export async function generateStorySong(action: GenerateStorySongCommand): Promise { if (!action.confirm) throw new Error('Generar la canción requiere confirm=true.') const workspace = useStore.getState().activeWorkspace || 'default' - const [{ useStoryStore, normalizeStoryProject, storyId }, { isLocalMusicModel }, api] = await Promise.all([ - import('./store'), import('./musicModel'), import('../../api/client'), + const [{ useStoryStore }, { isLocalMusicModel }, { generateStoryCueSong }] = await Promise.all([ + import('./store'), import('./musicModel'), import('./storySongGeneration'), ]) await useStoryStore.getState().loadWorkspace(workspace) const current = useStoryStore.getState() @@ -301,105 +260,37 @@ export async function generateStorySong(action: GenerateStorySongCommand): Promi || target.music.cues.find(item => item.kind === 'story') : undefined) if (!cue) throw new Error(`No existe la canción “${action.cueTitle || 'principal'}” en “${target.title}”.`) - if (!cue.style.trim()) throw new Error(`“${cue.title}” necesita un estilo musical antes de generarse.`) - if (!cue.instrumental && !cue.lyrics.trim()) throw new Error(`“${cue.title}” necesita letra antes de generarse.`) if (!isLocalMusicModel(target.music.model)) { throw new Error('Este contrato automatizado necesita un modelo local: ACE-Step 1.5 XL o MiniMax Music 3 local.') } if (current.activeProjectOperations[target.id]) throw new Error(`La historia “${target.title}” tiene una operación activa.`) useStoryStore.getState().beginProjectOperation(target.id) try { - const startedAt = new Date().toISOString() - // Allocate the durable candidate identity before submitting the compute - // job so the WAV sidecar and the Story object can carry the same ID. - const candidateId = storyId('song') - const rendered = await api.generateMusic({ - style: compileProviderPrompt(cue.style.trim(), songProviderLanguageIntent( - target.languageIntent, - cue.lyricsLanguage || target.languageIntent.spokenLanguage || target.spokenLanguage, - ), { medium: 'music' }), - lyrics: cue.instrumental ? '[Instrumental]' : cue.lyrics, - instrumental: cue.instrumental, - duration_seconds: clampStoryMusicDuration(cue.durationSeconds, target.music.model), - model_type: target.music.model, + const generated = await generateStoryCueSong({ workspace, - initiator: `Story Lab · ${target.projectType === 'music_video' ? 'Videoclip' : 'Story song'}`, - provenance: { - actor: 'wizard', - capability: 'generate_story_song', - project_id: target.id, - cue_id: cue.id, - candidate_id: candidateId, - }, - }) - if (!rendered.filename || !rendered.audio_path) throw new Error('El modelo local terminó sin devolver un archivo de audio verificable.') - const completedAt = new Date().toISOString() - const taskId = rendered.task_id || undefined - const rootTaskId = rendered.root_task_id || taskId - const jobId = rendered.job_id || undefined - const provenance = generatedSongProvenance({ - outputFolder: workspace, projectId: target.id, cueId: cue.id, - candidateId, - taskId, - rootTaskId, - jobId, - startedAt, - completedAt, + actor: 'wizard', + capability: 'generate_story_song', }) - let version = 1 - const project = await saveActiveStoryProjectMutation( - workspace, - useStoryStore.getState(), - target.id, - source => { - const latestCue = source.music.cues.find(item => item.id === cue.id) - if (!latestCue) throw new Error(`El cue “${cue.title}” desapareció mientras se generaba el audio.`) - const existingCandidate = latestCue.candidates.find(item => item.id === candidateId) - version = existingCandidate?.version || (latestCue.candidates.length + 1) - const candidate = existingCandidate || buildGeneratedSongCandidate({ - project: source, cue: latestCue, candidateId, version, - filename: rendered.filename, source: api.getFileUrl(rendered.filename, workspace), - model: target.music.model, taskId, rootTaskId, provenance, - }) - return normalizeStoryProject({ - ...source, - revision: source.revision + 1, - music: { - ...source.music, - selectedCandidateId: candidateId, - cues: source.music.cues.map(item => item.id === latestCue.id ? { - ...item, - candidates: existingCandidate ? item.candidates : [...item.candidates, candidate], - selectedCandidateId: candidateId, - } : item), - }, - updatedAt: new Date().toISOString(), - }) - }, - ) - const savedCue = project.music.cues.find(item => item.id === cue.id) - const savedCandidate = savedCue?.candidates.find(item => item.id === candidateId) - if (!savedCue || !savedCandidate) { - throw new Error('Story Lab guardó la canción sin devolver el candidato generado.') - } + const savedCue = generated.project.music.cues.find(item => item.id === cue.id) + if (!savedCue) throw new Error('Story Lab guardó la canción sin devolver el cue generado.') return storyResult( workspace, - project, + generated.project, 'music', - `${target.music.model === 'minimax_music3' ? 'MiniMax Music 3 local' : 'ACE-Step'} ha generado “${savedCue.title}” y la versión v${version} ha quedado seleccionada en Story Lab → Music.`, + `${target.music.model === 'minimax_music3' ? 'MiniMax Music 3 local' : 'ACE-Step'} ha generado “${savedCue.title}” y la versión v${generated.version} ha quedado seleccionada en Story Lab → Music.`, { - projectId: project.id, + projectId: generated.project.id, cueId: savedCue.id, - candidateId, - songVersion: version, - taskId, - rootTaskId, - jobId, - provenance: savedCandidate.provenance, + candidateId: generated.candidateId, + songVersion: generated.version, + taskId: generated.taskId, + rootTaskId: generated.rootTaskId, + jobId: generated.jobId, + provenance: generated.candidate.provenance, cueTitle: savedCue.title, - outputName: rendered.filename, + outputName: generated.filename, }, ) } finally { @@ -1533,7 +1424,7 @@ export async function stageStoryMusicVideo(action: StageStoryMusicVideoCommand): action.cueId, action.candidateId, ) - const resolvedCue = selection.effectiveStoryMusicCue(found, cue, candidate) + const resolvedCue = selection.effectiveStoryMusicCue(found, cue, candidate, action.cueId) const target = applyMusicVideoDirectVideoDefaults(found.projectType === 'music_video' ? found : { ...found, projectType: 'music_video', musicVideoGenerationMode: 'direct_video' }) @@ -1561,7 +1452,9 @@ export async function stageStoryMusicVideo(action: StageStoryMusicVideoCommand): if (!latestCue || !latestCandidate) { throw new Error('La canción seleccionada cambió mientras se preparaba el videoclip; vuelve a intentarlo con la versión visible en Story Lab.') } - const latestResolvedCue = selection.effectiveStoryMusicCue(latestTarget, latestCue, latestCandidate) + const latestResolvedCue = selection.effectiveStoryMusicCue( + latestTarget, latestCue, latestCandidate, action.cueId, + ) const latestAdaptation = adaptations.buildMusicVideoAdaptation(latestTarget, latestResolvedCue, { generationMode: latestTarget.musicVideoGenerationMode, }) diff --git a/ui/src/features/stories/model.ts b/ui/src/features/stories/model.ts index c19fc404..bb364728 100644 --- a/ui/src/features/stories/model.ts +++ b/ui/src/features/stories/model.ts @@ -150,27 +150,54 @@ export function storyId(prefix: string): string { return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` } +function normalizeMusicCandidateStatus(value: unknown, source: string): StoryMusicCandidate['status'] | undefined { + if (value === 'pending' || value === 'ready' || value === 'failed') return value + return source ? 'ready' : undefined +} + +function keepMusicCandidate(id: string, source: string, status: StoryMusicCandidate['status']): boolean { + if (!id && !source) return false + return Boolean(source) || status === 'pending' || status === 'failed' +} + +function musicCandidateProvider(value: unknown): StoryMusicCandidate['provider'] { + if (value === 'local' || value === 'lyria') return value + return 'minimax' +} + +function optionalText(value: unknown): string | undefined { + const result = text(value) + return result || undefined +} + function normalizeMusicCandidate(value: unknown, now: string): StoryMusicCandidate | null { if (!value || typeof value !== 'object') return null const candidate = value as Partial - if (!text(candidate.source)) return null + const id = text(candidate.id) + const source = text(candidate.source) + const status = normalizeMusicCandidateStatus(candidate.status, source) + // Incomplete preview rows without a stable id and without audio are dropped. + // Pending/failed rows keep their minted `song-…` id even with an empty source. + // Never remint an existing empty id on load; only creators mint `storyId('song')`. + if (!keepMusicCandidate(id, source, status)) return null + const version = Number(candidate.version) return { - id: text(candidate.id) || storyId('song'), - displayName: text(candidate.displayName) || undefined, - title: text(candidate.title) || undefined, - language: text(candidate.language) || undefined, - version: Number(candidate.version) > 0 ? Math.max(1, Number(candidate.version)) : undefined, - name: text(candidate.name, 'Story song'), - source: text(candidate.source), + id, + displayName: optionalText(candidate.displayName), + title: optionalText(candidate.title), + language: optionalText(candidate.language), + version: version > 0 ? Math.max(1, version) : undefined, + name: text(candidate.name, source ? 'Story song' : ''), + source, prompt: text(candidate.prompt), lyrics: text(candidate.lyrics), - provider: candidate.provider === 'local' ? 'local' - : candidate.provider === 'lyria' ? 'lyria' : 'minimax', + provider: musicCandidateProvider(candidate.provider), model: text(candidate.model), durationSeconds: Math.max(0, Number(candidate.durationSeconds) || 0), createdAt: text(candidate.createdAt, now), - taskId: text(candidate.taskId) || undefined, - rootTaskId: text(candidate.rootTaskId) || undefined, + status, + taskId: optionalText(candidate.taskId), + rootTaskId: optionalText(candidate.rootTaskId), provenance: normalizeStoryProvenance(candidate.provenance), } } @@ -213,8 +240,11 @@ function normalizeMusicCue(value: unknown, index: number, now: string): StoryMus lyriaPrompt: text(cue.lyriaPrompt), instrumental: cue.instrumental === true, durationSeconds: Math.max(20, Math.min(360, Number(cue.durationSeconds) || 90)), - candidates: Array.isArray(cue.candidates) - ? cue.candidates.flatMap(candidate => normalizeMusicCandidate(candidate, now) || []) : [], + candidates: uniqueIds( + Array.isArray(cue.candidates) + ? cue.candidates.flatMap(candidate => normalizeMusicCandidate(candidate, now) || []) : [], + 'song', + ), selectedCandidateId: text(cue.selectedCandidateId) || undefined, } } @@ -633,9 +663,12 @@ export function normalizeStoryProject(value: unknown): StoryProject { candidateCount: project.music?.candidateCount === 3 ? 3 : 2, cues: Array.isArray(project.music?.cues) ? project.music.cues.flatMap((cue, index) => normalizeMusicCue(cue, index, now) || []) : [], - candidates: Array.isArray(project.music?.candidates) - ? project.music.candidates.flatMap(candidate => normalizeMusicCandidate(candidate, now) || []) - : [], + candidates: uniqueIds( + Array.isArray(project.music?.candidates) + ? project.music.candidates.flatMap(candidate => normalizeMusicCandidate(candidate, now) || []) + : [], + 'song', + ), selectedCandidateId: text(project.music?.selectedCandidateId) || undefined, }, productions: Array.isArray(project.productions) diff --git a/ui/src/features/stories/musicVideoSelection.ts b/ui/src/features/stories/musicVideoSelection.ts index 688e3d23..772f3626 100644 --- a/ui/src/features/stories/musicVideoSelection.ts +++ b/ui/src/features/stories/musicVideoSelection.ts @@ -1,5 +1,7 @@ import type { StoryMusicCandidate, StoryMusicCue, StoryProject } from './types' +export const SYNTHETIC_STORY_SONG_CUE_ID = 'story-song' + const normalizeName = (value: string): string => value .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') @@ -12,6 +14,11 @@ export interface StoryMusicSelection { candidate: StoryMusicCandidate } +export function isReadyStoryMusicCandidate(candidate: StoryMusicCandidate): boolean { + if (candidate.status === 'pending' || candidate.status === 'failed') return false + return Boolean((candidate.source || '').trim()) +} + function candidateNames(candidate: StoryMusicCandidate): string[] { return [candidate.displayName, candidate.title, candidate.name] .map(value => normalizeName(value || '')) @@ -129,33 +136,69 @@ export function resolveStoryMusicSelection( const candidate = resolveCandidateIdentity( project, pool, cue, candidateFromTitle, songName, candidateId, ) - if (!candidate.source.trim()) { - throw new Error(`La canción “${candidate.displayName || candidate.title || candidate.name}” no tiene un archivo de audio.`) - } - - const owningCue = cue || project.music.cues.find(item => item.candidates.some(itemCandidate => itemCandidate.id === candidate!.id)) + assertReadyStoryMusicCandidate(candidate) + const owningCue = cue || project.music.cues.find(item => item.candidates.some(itemCandidate => itemCandidate.id === candidate.id)) + assertPersistedMusicCue(project, candidate, owningCue, cueId) return { cue: owningCue, candidate } } -export function effectiveStoryMusicCue( +function songLabel(candidate: StoryMusicCandidate): string { + return candidate.displayName || candidate.title || candidate.name || candidate.id +} + +function assertReadyStoryMusicCandidate(candidate: StoryMusicCandidate): void { + if (isReadyStoryMusicCandidate(candidate)) return + const label = songLabel(candidate) + if (candidate.status === 'pending') { + throw new Error(`La canción “${label}” todavía se está generando; espera a que quede lista antes de preparar el videoclip.`) + } + if (candidate.status === 'failed') { + throw new Error(`La canción “${label}” falló y no se puede usar para un videoclip. Genera otra versión.`) + } + throw new Error(`La canción “${label}” no tiene un archivo de audio.`) +} + +function assertPersistedMusicCue( project: StoryProject, - cue: StoryMusicCue | undefined, candidate: StoryMusicCandidate, -): StoryMusicCue { - return cue || { - id: 'story-song', + owningCue: StoryMusicCue | undefined, + cueId: string, +): void { + const requestedCueId = cueId.trim() + if (owningCue && owningCue.id !== SYNTHETIC_STORY_SONG_CUE_ID) return + if (requestedCueId === SYNTHETIC_STORY_SONG_CUE_ID) return + throw new Error(`La canción “${songLabel(candidate)}” no pertenece a un cue persistido en “${project.title}”.`) +} + +function syntheticStorySongCue(project: StoryProject, candidate: StoryMusicCandidate): StoryMusicCue { + const lyrics = candidate.lyrics || project.music.lyrics || '' + return { + id: SYNTHETIC_STORY_SONG_CUE_ID, kind: 'story', targetId: project.id, title: candidate.title || candidate.displayName || candidate.name, purpose: project.music.brief || `Tell ${project.title} as a song-led visual story.`, referenceSong: '', - brief: project.music.brief, - style: candidate.prompt || project.music.style, - lyrics: candidate.lyrics || project.music.lyrics, + brief: project.music.brief || '', + style: candidate.prompt || project.music.style || '', + lyrics, lyriaPrompt: '', - instrumental: !(candidate.lyrics || project.music.lyrics).trim(), + instrumental: !lyrics.trim(), durationSeconds: candidate.durationSeconds || project.music.targetDurationSeconds, candidates: [candidate], selectedCandidateId: candidate.id, } } + +export function effectiveStoryMusicCue( + project: StoryProject, + cue: StoryMusicCue | undefined, + candidate: StoryMusicCandidate, + requestedCueId = '', +): StoryMusicCue { + if (cue && cue.id !== SYNTHETIC_STORY_SONG_CUE_ID) return cue + if (requestedCueId.trim() !== SYNTHETIC_STORY_SONG_CUE_ID) { + throw new Error(`“${project.title}” no tiene un cue persistido para esta canción.`) + } + return cue || syntheticStorySongCue(project, candidate) +} diff --git a/ui/src/features/stories/musicWorkflowState.ts b/ui/src/features/stories/musicWorkflowState.ts index d2dda94b..3d6cca72 100644 --- a/ui/src/features/stories/musicWorkflowState.ts +++ b/ui/src/features/stories/musicWorkflowState.ts @@ -1,5 +1,5 @@ import type { MusicVideoAdaptation } from './adaptations' -import { generatedSongProvenance, musicVideoProductionProvenance } from './provenance' +import { generatedSongProvenance, musicVideoProductionProvenance, pendingSongProvenance } from './provenance' import type { StoryMusicCandidate, StoryMusicCue, @@ -8,42 +8,159 @@ import type { StoryProvenance, } from './types' -export function buildGeneratedSongCandidate(input: { +function songCandidateLanguage(project: StoryProject, cue: StoryMusicCue): string { + return cue.lyricsLanguage || project.language +} + +export function buildPendingSongCandidate(input: { project: StoryProject cue: StoryMusicCue candidateId: string version: number - filename: string - source: string model: StoryMusicCandidate['model'] - taskId?: string - rootTaskId?: string + provider?: StoryMusicCandidate['provider'] provenance: StoryProvenance }): StoryMusicCandidate { const { project, cue } = input + const language = songCandidateLanguage(project, cue) return { id: input.candidateId, - displayName: `${cue.title} · ${cue.lyricsLanguage || project.language} · v${input.version}`, + displayName: `${cue.title} · ${language} · v${input.version}`, title: cue.title, - language: cue.lyricsLanguage || project.language, + language, version: input.version, - name: input.filename, - source: input.source, + name: '', + source: '', prompt: cue.style, lyrics: cue.instrumental ? '' : cue.lyrics, - provider: 'local', + provider: input.provider || 'local', model: input.model, durationSeconds: cue.durationSeconds, createdAt: new Date().toISOString(), + status: 'pending', + provenance: pendingSongProvenance({ + outputFolder: input.provenance.outputFolder || '', + projectId: project.id, + cueId: cue.id, + candidateId: input.candidateId, + startedAt: input.provenance.startedAt || new Date().toISOString(), + songVersion: input.version, + actor: input.provenance.actor, + }), + } +} + +export function patchSongCandidateReady( + candidate: StoryMusicCandidate, + patch: { + filename: string + source: string + durationSeconds?: number + taskId?: string + rootTaskId?: string + jobId?: string + completedAt?: string + provenance?: StoryProvenance + }, +): StoryMusicCandidate { + const completedAt = patch.completedAt || new Date().toISOString() + const provenance = patch.provenance || generatedSongProvenance({ + outputFolder: candidate.provenance?.outputFolder || '', + projectId: candidate.provenance?.projectId || '', + cueId: candidate.provenance?.cueId || '', + candidateId: candidate.id, + startedAt: candidate.provenance?.startedAt || candidate.createdAt, + completedAt, + songVersion: candidate.version, + actor: candidate.provenance?.actor, + taskId: patch.taskId, + rootTaskId: patch.rootTaskId, + jobId: patch.jobId, + }) + return { + ...candidate, + name: patch.filename, + source: patch.source, + durationSeconds: patch.durationSeconds ?? candidate.durationSeconds, + status: 'ready', + taskId: patch.taskId || candidate.taskId, + rootTaskId: patch.rootTaskId || candidate.rootTaskId, + provenance: { + ...candidate.provenance, + ...provenance, + candidateId: candidate.id, + completedAt, + }, + } +} + +export function patchSongCandidateFailed(candidate: StoryMusicCandidate): StoryMusicCandidate { + return { + ...candidate, + status: 'failed', + provenance: { + ...candidate.provenance, + candidateId: candidate.id, + completedAt: new Date().toISOString(), + }, + } +} + +export function upsertCueMusicCandidate( + project: StoryProject, + cueId: string, + candidate: StoryMusicCandidate, +): StoryProject { + return { + ...project, + revision: project.revision + 1, + music: { + ...project.music, + selectedCandidateId: candidate.id, + cues: project.music.cues.map(item => item.id === cueId ? { + ...item, + candidates: item.candidates.some(existing => existing.id === candidate.id) + ? item.candidates.map(existing => existing.id === candidate.id ? candidate : existing) + : [...item.candidates, candidate], + selectedCandidateId: candidate.id, + } : item), + }, + updatedAt: new Date().toISOString(), + } +} + +export function buildGeneratedSongCandidate(input: { + project: StoryProject + cue: StoryMusicCue + candidateId: string + version: number + filename: string + source: string + model: StoryMusicCandidate['model'] + taskId?: string + rootTaskId?: string + provenance: StoryProvenance +}): StoryMusicCandidate { + return patchSongCandidateReady(buildPendingSongCandidate({ + project: input.project, + cue: input.cue, + candidateId: input.candidateId, + version: input.version, + model: input.model, + provenance: input.provenance, + }), { + filename: input.filename, + source: input.source, taskId: input.taskId, rootTaskId: input.rootTaskId, provenance: generatedSongProvenance({ ...input.provenance, - projectId: project.id, - cueId: cue.id, + projectId: input.project.id, + cueId: input.cue.id, + candidateId: input.candidateId, songVersion: input.version, } as Parameters[0]), - } + }) } export function buildMusicVideoProduction(input: { diff --git a/ui/src/features/stories/provenance.ts b/ui/src/features/stories/provenance.ts index 1c2e736d..4bd03b5a 100644 --- a/ui/src/features/stories/provenance.ts +++ b/ui/src/features/stories/provenance.ts @@ -28,6 +28,28 @@ type RuntimeIds = { jobId?: string } +export function pendingSongProvenance(input: { + outputFolder: string + projectId: string + cueId: string + candidateId: string + startedAt: string + songVersion?: number + actor?: StoryProvenance['actor'] +}): StoryProvenance { + return { + outputFolder: input.outputFolder, + projectId: input.projectId, + cueId: input.cueId, + candidateId: input.candidateId, + startedAt: input.startedAt, + songVersion: input.songVersion === undefined ? undefined : String(input.songVersion), + actor: input.actor || 'wizard', + tool: 'story_lab', + capability: 'generate_story_song', + } +} + export function generatedSongProvenance(input: RuntimeIds & { outputFolder: string projectId: string @@ -36,11 +58,12 @@ export function generatedSongProvenance(input: RuntimeIds & { startedAt: string completedAt: string songVersion?: number + actor?: StoryProvenance['actor'] }): StoryProvenance { return { ...input, songVersion: input.songVersion === undefined ? undefined : String(input.songVersion), - actor: 'wizard', + actor: input.actor || 'wizard', tool: 'story_lab', capability: 'generate_story_song', } diff --git a/ui/src/features/stories/store.ts b/ui/src/features/stories/store.ts index e249d918..cf3e7671 100644 --- a/ui/src/features/stories/store.ts +++ b/ui/src/features/stories/store.ts @@ -3,6 +3,13 @@ import * as api from '../../api/client' import { changedSections, createStoryProject, normalizeStoryProject } from './model' import { mergeStoryLibraries } from './library' import type { StoryLibraryConflict, StoryLibraryData } from './library' +import { + libraryHasPendingSongs, + recoverPendingStorySongs, + storySongOutputRefFromAsset, + storySongOutputRefFromMetadata, + type StorySongOutputRef, +} from './storySongRecovery' import type { StoryProject, StoryProjectType } from './types' const LEGACY_AUTOSAVE_KEY = 'maestro-story-lab-v1' @@ -198,6 +205,77 @@ function duplicateStoryProject(source: StoryProject): StoryProject { }) } +async function fetchStorySongOutputRefs(workspace: string): Promise { + try { + const { assets } = await api.fetchAssets({ kind: 'audio', workspace, limit: 500 }) + return assets.flatMap(asset => storySongOutputRefFromAsset(asset) || []) + } catch { + const { outputs } = await api.fetchOutputs(0, 0, { workspace, mediaType: 'audio' }) + const refs: StorySongOutputRef[] = [] + for (const output of outputs) { + const metadata = await api.fetchOutputMetadata(output.name, workspace).catch(() => null) + const ref = storySongOutputRefFromMetadata(output.name, output.url, metadata) + if (ref) refs.push(ref) + } + return refs + } +} + +function writeLocalStoryLibrary(workspace: string, library: StoryLibraryData) { + persistLocalLibrary( + workspace, + library.projects[library.activeId], + library.projects, + library.revision, + ) +} + +async function recoverHydratedLibrary( + workspace: string, + library: StoryLibraryData, +): Promise<{ library: StoryLibraryData; recovered: boolean }> { + if (!libraryHasPendingSongs(library)) return { library, recovered: false } + try { + const recovered = recoverPendingStorySongs( + library.projects, + await fetchStorySongOutputRefs(workspace), + ) + if (!recovered.changed) return { library, recovered: false } + const projects = Object.fromEntries( + Object.values(recovered.projects).map(project => { + const normalized = normalizeStoryProject(project) + return [normalized.id, normalized] + }), + ) + return { + library: { + ...library, + projects, + activeId: projects[library.activeId] ? library.activeId : (Object.keys(projects)[0] || library.activeId), + }, + recovered: true, + } + } catch { + return { library, recovered: false } + } +} + +async function commitRecoveredStoryLibrary( + workspace: string, + library: StoryLibraryData, + remoteSerialized: string, +): Promise<{ library: StoryLibraryData; persisted: boolean }> { + try { + const saved = normalizeLibrary(await api.saveStoryLibrary(workspace, library)) || library + writeLocalStoryLibrary(workspace, saved) + lastPersistedLibrary.set(workspace, JSON.stringify(saved)) + return { library: saved, persisted: true } + } catch { + lastPersistedLibrary.set(workspace, remoteSerialized) + return { library, persisted: false } + } +} + function touched(before: StoryProject, candidate: StoryProject): StoryProject { const after = normalizeStoryProject(candidate) const sections = changedSections(before, after) @@ -331,28 +409,34 @@ export const useStoryStore = create((set, get) => ({ conflicts = merged.conflicts needsRemoteSync = merged.needsRemoteSync } - persistLocalLibrary( - workspace, - library.projects[library.activeId], - library.projects, - library.revision, - ) - // A local-newer/exclusive merge must be sent back to the server. A - // conflict deliberately stays unsynced until a future explicit review. - const remoteSerialized = remoteLibrary - ? JSON.stringify(remoteLibrary) - : JSON.stringify(library) - lastPersistedLibrary.set( - workspace, - needsRemoteSync && !conflicts.length - ? remoteSerialized - : JSON.stringify(library), - ) + writeLocalStoryLibrary(workspace, library) + const remoteSerialized = remoteLibrary ? JSON.stringify(remoteLibrary) : '' + const recovered = conflicts.length + ? { library, recovered: false } + : await recoverHydratedLibrary(workspace, library) + library = recovered.library + writeLocalStoryLibrary(workspace, library) + // Recovery must reach the server. Caching the recovered snapshot in + // lastPersistedLibrary would make the autosave subscriber treat it as + // already persisted. A conflict still stays unsynced until review. + let recoveredPersisted = !recovered.recovered + if (recovered.recovered && !conflicts.length) { + const committed = await commitRecoveredStoryLibrary(workspace, library, remoteSerialized) + library = committed.library + recoveredPersisted = committed.persisted + } else { + lastPersistedLibrary.set( + workspace, + needsRemoteSync && !conflicts.length + ? remoteSerialized + : JSON.stringify(library), + ) + } set({ project: library.projects[library.activeId], projects: library.projects, libraryRevision: library.revision, - dirty: false, + dirty: needsRemoteSync || (recovered.recovered && !recoveredPersisted), hydrated: true, loading: false, saveError: null, @@ -568,6 +652,51 @@ useStoryStore.subscribe(state => { }, 750) }) +export async function saveStoryProjectMutation( + workspace: string, + current: { libraryRevision: number; projects: Record }, + projectId: string, + mutate: (project: StoryProject) => StoryProject, +): Promise { + let baseline = current + let library: Awaited> | null = null + for (let attempt = 0; attempt < 3; attempt += 1) { + const source = baseline.projects[projectId] + if (!source) throw new Error('La historia activa desapareció antes de poder guardarla.') + const project = mutate(source) + try { + library = await api.saveStoryLibrary(workspace, { + version: 2, + revision: baseline.libraryRevision, + activeId: project.id, + projects: { ...baseline.projects, [project.id]: project }, + }) + break + } catch (error) { + if (!(error instanceof api.StoryLibraryRevisionError) || attempt === 2) throw error + const remote = await api.fetchStoryLibrary(workspace) + baseline = { + libraryRevision: remote.revision, + projects: remote.projects, + } + } + } + if (!library?.projects[projectId]) throw new Error('Story Lab guardó la biblioteca sin devolver la historia editada.') + useStoryStore.setState({ + workspace, + project: library.projects[projectId], + projects: library.projects, + libraryRevision: library.revision, + dirty: false, + hydrated: false, + loading: false, + saveError: null, + libraryConflicts: [], + }) + await useStoryStore.getState().loadWorkspace(workspace) + return useStoryStore.getState().projects[projectId] || library.projects[projectId] +} + export { createStoryProject, normalizeStoryProject, storyId } from './model' export { mergeStoryLibraries } from './library' export type { StoryLibraryConflict, StoryLibraryData } from './library' diff --git a/ui/src/features/stories/storyProductionController.ts b/ui/src/features/stories/storyProductionController.ts index 5f510d68..7ced2915 100644 --- a/ui/src/features/stories/storyProductionController.ts +++ b/ui/src/features/stories/storyProductionController.ts @@ -7,6 +7,10 @@ import { } from './adaptations' import type { TrailerAdaptationOptions } from './adaptations' import type { AspectRatio, ResolutionPreset } from '../../types' +import { + effectiveStoryMusicCue, + isReadyStoryMusicCandidate, +} from './musicVideoSelection' import type { StoryMusicCandidate, StoryMusicCue, @@ -62,28 +66,14 @@ export function musicCandidateById(source: StoryProject, candidateId?: string): || cue?.candidates.find(item => item.id === candidateId) } -/** Build a cue around a legacy/global candidate so every Director handoff has a full context. */ +/** Build a cue around a legacy/global candidate only when the caller asked for `story-song`. */ export function effectiveMusicCue( source: StoryProject, cue: StoryMusicCue | undefined, candidate: StoryMusicCandidate, + requestedCueId = '', ): StoryMusicCue { - return cue || { - id: 'story-song', - kind: 'story', - targetId: source.id, - title: candidate.title || candidate.displayName || candidate.name, - purpose: source.music.brief || `Tell ${source.title} as a song-led visual story.`, - referenceSong: '', - brief: source.music.brief, - style: candidate.prompt || source.music.style, - lyrics: candidate.lyrics || source.music.lyrics, - lyriaPrompt: '', - instrumental: !(candidate.lyrics || source.music.lyrics).trim(), - durationSeconds: candidate.durationSeconds || source.music.targetDurationSeconds, - candidates: [candidate], - selectedCandidateId: candidate.id, - } + return effectiveStoryMusicCue(source, cue, candidate, requestedCueId) } type StoryReference = { assetId: string; label: string } @@ -330,7 +320,11 @@ export async function loadStoryFilmProduction(options: StoryFilmProductionOption */ export async function loadStoryMusicVideoProduction(options: StoryMusicVideoProductionOptions) { const { source, cue, candidate, generationSettings } = options - const resolvedCue = effectiveMusicCue(source, cue, candidate) + if (!isReadyStoryMusicCandidate(candidate)) { + const label = candidate.displayName || candidate.title || candidate.name || candidate.id + throw new Error(`Cannot stage a videoclip from “${label}” until that song version is ready.`) + } + const resolvedCue = effectiveMusicCue(source, cue, candidate, cue?.id || '') const directReferences = generationSettings.generationMode === 'direct_references' if (directReferences && !generationSettings.videoModel.startsWith('minimax_h3')) { throw new Error('Direct references currently require a MiniMax H3 video model with Ref2VA support.') diff --git a/ui/src/features/stories/storySongGeneration.ts b/ui/src/features/stories/storySongGeneration.ts new file mode 100644 index 00000000..4b1acec3 --- /dev/null +++ b/ui/src/features/stories/storySongGeneration.ts @@ -0,0 +1,275 @@ +import { compileProviderPrompt } from '../../lib/languageIntent' +import * as api from '../../api/client' +import type { MiniMaxMusicJob } from '../../api/stories' +import { clampStoryMusicDuration, isLocalMusicModel } from './musicModel' +import { + buildPendingSongCandidate, + patchSongCandidateFailed, + patchSongCandidateReady, + upsertCueMusicCandidate, +} from './musicWorkflowState' +import { pendingSongProvenance } from './provenance' +import { songProviderLanguageIntent } from './songLanguage' +import { nextMusicCandidateVersion } from './storyLabMusic' +import { normalizeStoryProject, saveStoryProjectMutation, storyId, useStoryStore } from './store' +import type { StoryMusicCandidate, StoryMusicCue, StoryProject } from './types' + +export interface GenerateStoryCueSongInput { + workspace: string + projectId: string + cueId: string + actor: 'user' | 'wizard' + capability?: string + onJobSubmitted?: (job: MiniMaxMusicJob) => void + onProgress?: (job: MiniMaxMusicJob) => void +} + +export interface GenerateStoryCueSongResult { + project: StoryProject + cueId: string + candidate: StoryMusicCandidate + candidateId: string + version: number + filename?: string + taskId?: string + rootTaskId?: string + jobId?: string +} + +type ReadySongPatch = { + filename: string + source: string + durationSeconds?: number + taskId?: string + rootTaskId?: string + jobId?: string +} + +function songGenerationProvenance( + input: GenerateStoryCueSongInput, + candidateId: string, + version: number, +) { + return { + actor: input.actor, + capability: input.capability || 'generate_story_song', + project_id: input.projectId, + cue_id: input.cueId, + candidate_id: candidateId, + song_version: String(version), + } +} + +function cueCandidate(project: StoryProject, cueId: string, candidateId: string): StoryMusicCandidate | undefined { + return project.music.cues.find(item => item.id === cueId) + ?.candidates.find(item => item.id === candidateId) +} + +function requireSavedCandidate( + project: StoryProject, + cueId: string, + candidateId: string, + version: number, + filename?: string, + jobId?: string, +): GenerateStoryCueSongResult { + const candidate = cueCandidate(project, cueId, candidateId) + if (!candidate) throw new Error('Story Lab guardó la canción sin devolver el candidato generado.') + return { + project, + cueId, + candidate, + candidateId, + version: candidate.version || version, + filename, + taskId: candidate.taskId, + rootTaskId: candidate.rootTaskId, + jobId: candidate.provenance?.jobId || jobId, + } +} + +async function persistCueCandidate( + workspace: string, + projectId: string, + cueId: string, + candidateId: string, + patch: (source: StoryProject) => StoryMusicCandidate, +): Promise { + const current = useStoryStore.getState() + return saveStoryProjectMutation( + workspace, + current, + projectId, + source => { + const latestCue = source.music.cues.find(item => item.id === cueId) + if (!latestCue) throw new Error('El cue desapareció mientras se generaba el audio.') + const existing = latestCue.candidates.find(item => item.id === candidateId) + const candidate = patch(source) + const next = upsertCueMusicCandidate( + source, + cueId, + existing ? { ...existing, ...candidate, id: candidateId } : candidate, + ) + return normalizeStoryProject(next) + }, + ) +} + +function requireOpenCue(project: StoryProject | undefined, cueId: string): { project: StoryProject; cue: StoryMusicCue } { + if (!project) throw new Error('La historia activa desapareció antes de generar la canción.') + const cue = project.music.cues.find(item => item.id === cueId) + if (!cue) throw new Error(`No existe el cue con ID “${cueId}” en “${project.title}”.`) + if (!cue.style.trim()) throw new Error(`“${cue.title}” necesita un estilo musical antes de generarse.`) + if (!cue.instrumental && !cue.lyrics.trim()) { + throw new Error(`“${cue.title}” necesita letra antes de generarse.`) + } + return { project, cue } +} + +function mintPendingCandidate( + input: GenerateStoryCueSongInput, + project: StoryProject, + cue: StoryMusicCue, +): { pending: StoryMusicCandidate; candidateId: string; version: number } { + const candidateId = storyId('song') + const version = nextMusicCandidateVersion( + cue.candidates, + cue.lyricsLanguage || project.language, + project.language, + ) + const pending = buildPendingSongCandidate({ + project, + cue, + candidateId, + version, + model: project.music.model, + provider: isLocalMusicModel(project.music.model) ? 'local' : 'minimax', + provenance: pendingSongProvenance({ + outputFolder: input.workspace, + projectId: project.id, + cueId: cue.id, + candidateId, + startedAt: new Date().toISOString(), + songVersion: version, + actor: input.actor, + }), + }) + return { pending, candidateId, version } +} + +async function persistReadyCandidate( + input: GenerateStoryCueSongInput, + pending: StoryMusicCandidate, + patch: ReadySongPatch, +): Promise { + return persistCueCandidate( + input.workspace, + input.projectId, + input.cueId, + pending.id, + source => patchSongCandidateReady(cueCandidate(source, input.cueId, pending.id) || pending, patch), + ) +} + +async function generateLocalStorySong( + input: GenerateStoryCueSongInput, + project: StoryProject, + cue: StoryMusicCue, + pending: StoryMusicCandidate, + version: number, +): Promise { + const rendered = await api.generateMusic({ + style: compileProviderPrompt( + cue.style.trim(), + songProviderLanguageIntent( + project.languageIntent, + cue.lyricsLanguage || project.languageIntent.spokenLanguage || project.spokenLanguage, + ), + { medium: 'music' }, + ), + lyrics: cue.instrumental ? '[Instrumental]' : cue.lyrics, + instrumental: cue.instrumental, + duration_seconds: clampStoryMusicDuration(cue.durationSeconds, project.music.model), + model_type: project.music.model, + workspace: input.workspace, + initiator: `Story Lab · ${project.projectType === 'music_video' ? 'Videoclip' : 'Story song'}`, + provenance: songGenerationProvenance(input, pending.id, version), + }) + if (!rendered.filename || !rendered.audio_path) { + throw new Error('El modelo local terminó sin devolver un archivo de audio verificable.') + } + const saved = await persistReadyCandidate(input, pending, { + filename: rendered.filename, + source: api.getFileUrl(rendered.filename, input.workspace), + durationSeconds: clampStoryMusicDuration(cue.durationSeconds, project.music.model), + taskId: rendered.task_id, + rootTaskId: rendered.root_task_id || rendered.task_id, + jobId: rendered.job_id, + }) + return requireSavedCandidate(saved, input.cueId, pending.id, version, rendered.filename) +} + +async function generateRemoteStorySong( + input: GenerateStoryCueSongInput, + cue: StoryMusicCue, + pending: StoryMusicCandidate, + version: number, + model: StoryProject['music']['model'], +): Promise { + const result = await api.generateStoryMusicCandidates({ + prompt: cue.style.trim().slice(0, 300), + lyrics: cue.instrumental ? '' : cue.lyrics, + instrumental: cue.instrumental, + count: 1, + model, + workspace: input.workspace, + provenance: songGenerationProvenance(input, pending.id, version), + }, { + onJobSubmitted: input.onJobSubmitted, + onProgress: input.onProgress, + }) + const rendered = result.candidates[0] + if (!rendered?.filename || !rendered.source) { + throw new Error(result.message || 'MiniMax Music terminó sin devolver un archivo de audio verificable.') + } + const saved = await persistReadyCandidate(input, pending, { + filename: rendered.filename, + source: rendered.source, + durationSeconds: rendered.duration_seconds, + taskId: rendered.taskId || rendered.task_id || result.taskId, + rootTaskId: rendered.rootTaskId || rendered.root_task_id || result.taskId, + jobId: result.jobId, + }) + return requireSavedCandidate(saved, input.cueId, pending.id, version, rendered.filename, result.jobId) +} + +async function markSongFailed(input: GenerateStoryCueSongInput, pending: StoryMusicCandidate): Promise { + try { + await persistCueCandidate( + input.workspace, + input.projectId, + input.cueId, + pending.id, + source => patchSongCandidateFailed(cueCandidate(source, input.cueId, pending.id) || pending), + ) + } catch { + // Keep the original generate failure; the pending row still has the minted id. + } +} + +export async function generateStoryCueSong( + input: GenerateStoryCueSongInput, +): Promise { + const { project, cue } = requireOpenCue(useStoryStore.getState().projects[input.projectId], input.cueId) + const minted = mintPendingCandidate(input, project, cue) + await persistCueCandidate(input.workspace, input.projectId, input.cueId, minted.candidateId, () => minted.pending) + try { + if (isLocalMusicModel(project.music.model)) { + return await generateLocalStorySong(input, project, cue, minted.pending, minted.version) + } + return await generateRemoteStorySong(input, cue, minted.pending, minted.version, project.music.model) + } catch (error) { + await markSongFailed(input, minted.pending) + throw error + } +} diff --git a/ui/src/features/stories/storySongRecovery.ts b/ui/src/features/stories/storySongRecovery.ts new file mode 100644 index 00000000..a026b8fb --- /dev/null +++ b/ui/src/features/stories/storySongRecovery.ts @@ -0,0 +1,203 @@ +import type { AssetCatalogItem } from '../../api/assets' +import type { OutputMetadata } from '../../types' +import type { StoryLibraryData } from './library' +import { patchSongCandidateReady } from './musicWorkflowState' +import type { StoryMusicCandidate, StoryProject } from './types' + +export interface StorySongOutputRef { + candidateId: string + filename: string + source: string + projectId?: string + cueId?: string + outputFolder?: string + taskId?: string + rootTaskId?: string + jobId?: string + durationSeconds?: number +} + +function textValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function nestedRecord(value: unknown): Record | undefined { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : undefined +} + +function firstText(...values: unknown[]): string { + for (const value of values) { + const text = textValue(value) + if (text) return text + } + return '' +} + +function executionRecord(sidecar: Record): Record { + return nestedRecord(sidecar.execution) || sidecar +} + +export function storySongOutputRefFromSidecar( + filename: string, + source: string, + sidecar: Record | undefined, +): StorySongOutputRef | null { + if (!sidecar) return null + const execution = executionRecord(sidecar) + const origin = nestedRecord(sidecar.origin) + const params = nestedRecord(sidecar.params) + const candidateId = firstText(execution.candidate_id, params?.candidate_id, sidecar.candidate_id) + if (!candidateId) return null + const project = nestedRecord(origin?.project) + return { + candidateId, + filename: firstText(sidecar.filename, filename), + source, + projectId: firstText(project?.id, origin?.project_id, params?.project_id), + cueId: firstText(execution.cue_id, params?.cue_id), + outputFolder: firstText(origin?.output_folder, params?.output_folder), + taskId: firstText(execution.task_id, params?.task_id), + rootTaskId: firstText(execution.root_task_id, params?.root_task_id), + jobId: firstText(execution.job_id, params?.job_id), + } +} + +function mergeSongRefs( + filename: string, + source: string, + primary: StorySongOutputRef | null, + extras: Record, +): StorySongOutputRef | null { + const candidateId = firstText(primary?.candidateId, extras.candidate_id) + if (!candidateId) return null + return { + candidateId, + filename, + source, + projectId: firstText(primary?.projectId, extras.project_id), + cueId: firstText(primary?.cueId, extras.cue_id), + outputFolder: firstText(primary?.outputFolder, extras.output_folder), + taskId: firstText(primary?.taskId, extras.task_id), + rootTaskId: primary?.rootTaskId, + jobId: firstText(primary?.jobId, extras.job_id), + } +} + +export function storySongOutputRefFromAsset(asset: AssetCatalogItem): StorySongOutputRef | null { + const fromManifest = storySongOutputRefFromSidecar( + asset.filename, + asset.url, + nestedRecord(asset.manifest), + ) + const execution = asset.execution as Record + const project = nestedRecord(asset.origin.project) + return mergeSongRefs(asset.filename, asset.url, fromManifest, { + candidate_id: execution.candidate_id, + project_id: project?.id, + cue_id: execution.cue_id, + output_folder: asset.origin.output_folder, + task_id: execution.task_id, + job_id: execution.job_id, + }) +} + +export function storySongOutputRefFromMetadata( + filename: string, + source: string, + metadata: OutputMetadata | null | undefined, +): StorySongOutputRef | null { + if (!metadata) return null + const params = nestedRecord(metadata.params) + return storySongOutputRefFromSidecar(filename, source, { + ...params, + params, + execution: nestedRecord(params?.execution) || params, + origin: nestedRecord(params?.origin), + candidate_id: params?.candidate_id, + filename, + }) +} + +export function isPendingStoryMusicCandidate(candidate: StoryMusicCandidate): boolean { + return candidate.status === 'pending' || (!candidate.source.trim() && candidate.status !== 'failed') +} + +export function isRecoverableStoryMusicCandidate(candidate: StoryMusicCandidate): boolean { + if (candidate.status === 'ready' && candidate.source.trim()) return false + return candidate.status === 'pending' || candidate.status === 'failed' || !candidate.source.trim() +} + +export function libraryHasPendingSongs(library: Pick): boolean { + return Object.values(library.projects).some(project => ( + project.music.cues.some(cue => cue.candidates.some(isRecoverableStoryMusicCandidate)) + || project.music.candidates.some(isRecoverableStoryMusicCandidate) + )) +} + +function matchingOutput( + project: StoryProject, + cueId: string | undefined, + candidate: StoryMusicCandidate, + outputs: StorySongOutputRef[], +): StorySongOutputRef | undefined { + return outputs.find(output => { + if (output.candidateId !== candidate.id) return false + if (output.projectId && output.projectId !== project.id) return false + if (output.cueId && cueId && output.cueId !== cueId) return false + return true + }) +} + +export function recoverPendingStorySongs( + projects: Record, + outputs: StorySongOutputRef[], +): { projects: Record; changed: boolean } { + if (!outputs.length) return { projects, changed: false } + let changed = false + const next: Record = {} + Object.entries(projects).forEach(([projectId, project]) => { + let projectChanged = false + const cues = project.music.cues.map(cue => { + const candidates = cue.candidates.map(candidate => { + if (!isRecoverableStoryMusicCandidate(candidate)) return candidate + const output = matchingOutput(project, cue.id, candidate, outputs) + if (!output?.source) return candidate + projectChanged = true + return patchSongCandidateReady(candidate, { + filename: output.filename, + source: output.source, + durationSeconds: output.durationSeconds, + taskId: output.taskId, + rootTaskId: output.rootTaskId, + jobId: output.jobId, + }) + }) + return projectChanged ? { ...cue, candidates } : cue + }) + const globalCandidates = project.music.candidates.map(candidate => { + if (!isRecoverableStoryMusicCandidate(candidate)) return candidate + const output = matchingOutput(project, undefined, candidate, outputs) + if (!output?.source) return candidate + projectChanged = true + return patchSongCandidateReady(candidate, { + filename: output.filename, + source: output.source, + durationSeconds: output.durationSeconds, + taskId: output.taskId, + rootTaskId: output.rootTaskId, + jobId: output.jobId, + }) + }) + next[projectId] = projectChanged + ? { + ...project, + music: { ...project.music, cues, candidates: globalCandidates }, + updatedAt: new Date().toISOString(), + } + : project + if (projectChanged) changed = true + }) + return { projects: changed ? next : projects, changed } +} diff --git a/ui/src/features/stories/types.ts b/ui/src/features/stories/types.ts index a6ef7707..8bda67ab 100644 --- a/ui/src/features/stories/types.ts +++ b/ui/src/features/stories/types.ts @@ -160,6 +160,8 @@ export interface StoryProduction { status: 'draft' | 'staged' } +export type StoryMusicCandidateStatus = 'pending' | 'ready' | 'failed' + export interface StoryMusicCandidate { id: string /** Human-readable identity; the provider filename remains in `name`. */ @@ -175,6 +177,11 @@ export interface StoryMusicCandidate { model: string durationSeconds: number createdAt: string + /** + * Durable lifecycle of this `song-…` row. Display `v1/v2` is never identity. + * Pending/failed rows may have an empty source so they survive client close. + */ + status?: StoryMusicCandidateStatus /** Canonical backend identity for audit, cancellation and exact output correlation. */ taskId?: string rootTaskId?: string diff --git a/ui/tests/musicVideoSelection.test.mjs b/ui/tests/musicVideoSelection.test.mjs index 3df6d2e5..650600a1 100644 --- a/ui/tests/musicVideoSelection.test.mjs +++ b/ui/tests/musicVideoSelection.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import { getPlayableFileUrl, getServerMediaReference } from '../src/api/client.ts' -import { resolveStoryMusicSelection } from '../src/features/stories/musicVideoSelection.ts' +import { effectiveStoryMusicCue, resolveStoryMusicSelection } from '../src/features/stories/musicVideoSelection.ts' const candidate = { id: 'song-v2', @@ -145,6 +145,99 @@ test('absolute generator paths become playable workspace URLs', () => { ) }) +test('pending candidates cannot be staged as a videoclip', () => { + const pending = { + ...candidate, + id: 'song-pending', + source: '', + status: 'pending', + } + assert.throws( + () => resolveStoryMusicSelection( + { + ...project, + music: { + ...project.music, + cues: [{ ...cue, candidates: [pending], selectedCandidateId: pending.id }], + selectedCandidateId: pending.id, + }, + }, + '', + cue.title, + cue.id, + pending.id, + ), + /todavía se está generando/, + ) +}) + +test('v1 and v2 remain distinct song identities', () => { + const first = { + ...candidate, + id: 'song-v1', + displayName: 'El Himno del Sysadmin · Español · v1', + version: 1, + name: 'sysadmin-v1.wav', + source: '/home/user/hocuspocus/outputs/sysadmin-v1.wav', + } + const second = { + ...candidate, + id: 'song-v2', + displayName: 'El Himno del Sysadmin · Español · v2', + version: 2, + } + const twoVersions = { + ...project, + music: { + ...project.music, + cues: [{ ...cue, candidates: [first, second], selectedCandidateId: second.id }], + selectedCandidateId: second.id, + }, + } + const v1 = resolveStoryMusicSelection(twoVersions, '', cue.title, cue.id, first.id) + const v2 = resolveStoryMusicSelection(twoVersions, '', cue.title, cue.id, second.id) + assert.equal(v1.candidate.id, 'song-v1') + assert.equal(v2.candidate.id, 'song-v2') + assert.notEqual(v1.candidate.id, v2.candidate.id) +}) + +test('workspace B cannot adopt a candidate that only exists in workspace A', () => { + const projectB = { + id: 'story-b', + title: 'Otro workspace', + music: { + cues: [{ + ...cue, + id: 'cue-b', + title: 'Otra pista', + candidates: [{ + id: 'song-b', + displayName: 'Otra pista · Español · v1', + title: 'Otra pista', + name: 'b.wav', + source: '/home/user/hocuspocus/outputs/b.wav', + }], + selectedCandidateId: 'song-b', + }], + candidates: [], + selectedCandidateId: 'song-b', + }, + } + assert.throws( + () => resolveStoryMusicSelection(projectB, '', '', 'cue-b', candidate.id), + /No existe la versión de canción con ID/, + ) +}) + +test('synthetic story-song cue is refused unless the caller passed that exact id', () => { + assert.throws( + () => effectiveStoryMusicCue(project, undefined, candidate), + /no tiene un cue persistido/, + ) + const synthetic = effectiveStoryMusicCue(project, undefined, candidate, 'story-song') + assert.equal(synthetic.id, 'story-song') +}) + test('media the server already holds is handed over by name, not by bytes', () => { // An absolute generator path is a file in the workspace folder: the backend // resolves the bare name against that root, so Director never has to pull diff --git a/ui/tests/storyProductionController.test.ts b/ui/tests/storyProductionController.test.ts index 2dd388e6..55b50c36 100644 --- a/ui/tests/storyProductionController.test.ts +++ b/ui/tests/storyProductionController.test.ts @@ -68,13 +68,17 @@ test('music production resolves cue and candidate by durable IDs', () => { assert.equal(musicCandidateById(project, 'missing-song'), undefined) }) -test('effective music cue preserves the selected song context for legacy candidates', () => { +test('effective music cue preserves the selected song context only when story-song is requested', () => { const project = createStoryProject('music_video') project.music.brief = 'A heroic uptime story' project.music.style = '80s heavy metal' const selected = candidate('song-legacy', { lyrics: '', durationSeconds: 42 }) - const cue = effectiveMusicCue(project, undefined, selected) + assert.throws( + () => effectiveMusicCue(project, undefined, selected), + /no tiene un cue persistido/, + ) + const cue = effectiveMusicCue(project, undefined, selected, 'story-song') assert.equal(cue.id, 'story-song') assert.equal(cue.targetId, project.id) diff --git a/ui/tests/storySongRecovery.test.mjs b/ui/tests/storySongRecovery.test.mjs new file mode 100644 index 00000000..9125cc50 --- /dev/null +++ b/ui/tests/storySongRecovery.test.mjs @@ -0,0 +1,335 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { JSDOM } from 'jsdom' + +const dom = new JSDOM('', { url: 'http://localhost/' }) +Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + localStorage: dom.window.localStorage, + Event: dom.window.Event, + CustomEvent: dom.window.CustomEvent, +}) +window.matchMedia = () => ({ matches: false }) + +test('normalize keeps pending rows with a stable id and never remints them', async () => { + const { normalizeStoryProject, createStoryProject, storyId } = await import('../src/features/stories/model.ts') + const minted = 'song-keep-me' + const base = createStoryProject('music_video') + const pending = { + id: minted, + status: 'pending', + name: '', + source: '', + prompt: 'metal', + lyrics: '[Verse]\nCode', + provider: 'local', + model: 'ace_step_v1_5_xl_sft_lm_4b', + durationSeconds: 30, + createdAt: '2026-09-05T00:00:00.000Z', + } + const project = normalizeStoryProject({ + ...base, + music: { + ...base.music, + cues: [{ + id: 'cue-1', + kind: 'story', + targetId: base.id, + title: 'Theme', + purpose: '', + referenceSong: '', + brief: '', + style: 'metal', + lyrics: '[Verse]\nCode', + lyriaPrompt: '', + instrumental: false, + durationSeconds: 30, + candidates: [pending, { source: '', prompt: 'preview' }, { id: '', source: '' }], + }], + }, + }) + const again = normalizeStoryProject(project) + assert.equal(project.music.cues[0].candidates.length, 1) + assert.equal(project.music.cues[0].candidates[0].id, minted) + assert.equal(project.music.cues[0].candidates[0].status, 'pending') + assert.equal(again.music.cues[0].candidates[0].id, minted) + assert.notEqual(minted, storyId('song')) +}) + +test('hydrate recovers a pending song from a sidecar with matching candidate_id', async () => { + const { + recoverPendingStorySongs, + storySongOutputRefFromSidecar, + } = await import('../src/features/stories/storySongRecovery.ts') + const { createStoryProject, normalizeStoryProject } = await import('../src/features/stories/model.ts') + const base = createStoryProject('music_video') + const pendingId = 'song-recover-1' + const project = normalizeStoryProject({ + ...base, + id: 'story-recover', + title: 'Recovered anthem', + music: { + ...base.music, + cues: [{ + id: 'cue-recover', + kind: 'story', + targetId: 'story-recover', + title: 'Anthem', + purpose: '', + referenceSong: '', + brief: '', + style: 'metal', + lyrics: '[Verse]\nCode', + lyriaPrompt: '', + instrumental: false, + durationSeconds: 30, + candidates: [{ + id: pendingId, + status: 'pending', + name: '', + source: '', + prompt: 'metal', + lyrics: '[Verse]\nCode', + provider: 'local', + model: 'ace_step_v1_5_xl_sft_lm_4b', + durationSeconds: 30, + createdAt: '2026-09-05T00:00:00.000Z', + }], + }], + }, + }) + const ref = storySongOutputRefFromSidecar('anthem.wav', '/api/v1/file/anthem.wav?workspace=lab', { + origin: { project: { kind: 'story', id: project.id }, output_folder: 'lab' }, + execution: { candidate_id: pendingId, cue_id: 'cue-recover', task_id: 'task-9' }, + }) + const recovered = recoverPendingStorySongs({ [project.id]: project }, [ref]) + assert.equal(recovered.changed, true) + const candidate = recovered.projects[project.id].music.cues[0].candidates[0] + assert.equal(candidate.id, pendingId) + assert.equal(candidate.status, 'ready') + assert.equal(candidate.name, 'anthem.wav') + assert.equal(candidate.source, '/api/v1/file/anthem.wav?workspace=lab') + assert.equal(candidate.taskId, 'task-9') +}) + +test('a failed row without audio is recovered when the sidecar exists', async () => { + const { recoverPendingStorySongs } = await import('../src/features/stories/storySongRecovery.ts') + const { createStoryProject, normalizeStoryProject } = await import('../src/features/stories/model.ts') + const base = createStoryProject('music_video') + const failedId = 'song-failed-timeout' + const project = normalizeStoryProject({ + ...base, + id: 'story-failed', + music: { + ...base.music, + cues: [{ + id: 'cue-failed', + kind: 'story', + targetId: 'story-failed', + title: 'Failed', + purpose: '', + referenceSong: '', + brief: '', + style: 'metal', + lyrics: '[Verse]\nFailed', + lyriaPrompt: '', + instrumental: false, + durationSeconds: 30, + candidates: [{ + id: failedId, + status: 'failed', + name: '', + source: '', + prompt: 'metal', + lyrics: '[Verse]\nFailed', + provider: 'local', + model: 'ace_step_v1_5_xl_sft_lm_4b', + durationSeconds: 30, + createdAt: '2026-09-05T00:00:00.000Z', + }], + }], + }, + }) + const recovered = recoverPendingStorySongs({ [project.id]: project }, [{ + candidateId: failedId, + filename: 'late.wav', + source: '/api/v1/file/late.wav', + projectId: project.id, + cueId: 'cue-failed', + }]) + assert.equal(recovered.changed, true) + const candidate = recovered.projects[project.id].music.cues[0].candidates[0] + assert.equal(candidate.id, failedId) + assert.equal(candidate.status, 'ready') + assert.equal(candidate.name, 'late.wav') +}) + +test('recovery ignores a sidecar from another project or workspace candidate', async () => { + const { recoverPendingStorySongs } = await import('../src/features/stories/storySongRecovery.ts') + const { createStoryProject, normalizeStoryProject } = await import('../src/features/stories/model.ts') + const base = createStoryProject('music_video') + const project = normalizeStoryProject({ + ...base, + id: 'story-b', + music: { + ...base.music, + cues: [{ + id: 'cue-b', + kind: 'story', + targetId: 'story-b', + title: 'B', + purpose: '', + referenceSong: '', + brief: '', + style: 'metal', + lyrics: '[Verse]\nB', + lyriaPrompt: '', + instrumental: false, + durationSeconds: 30, + candidates: [{ + id: 'song-b', + status: 'pending', + name: '', + source: '', + prompt: 'metal', + lyrics: '[Verse]\nB', + provider: 'local', + model: 'ace_step_v1_5_xl_sft_lm_4b', + durationSeconds: 30, + createdAt: '2026-09-05T00:00:00.000Z', + }], + }], + }, + }) + const recovered = recoverPendingStorySongs({ [project.id]: project }, [{ + candidateId: 'song-a', + filename: 'a.wav', + source: '/api/v1/file/a.wav', + projectId: 'story-a', + cueId: 'cue-a', + }]) + assert.equal(recovered.changed, false) + assert.equal(recovered.projects[project.id].music.cues[0].candidates[0].status, 'pending') +}) + +test('loadWorkspace attaches a matching WAV after client close with no live generate promise', { concurrency: false }, async t => { + const workspace = 'song-recover-hydrate' + const { createStoryProject, normalizeStoryProject, useStoryStore } = await import('../src/features/stories/store.ts') + const base = createStoryProject('music_video') + const pendingId = 'song-closed-client' + const project = normalizeStoryProject({ + ...base, + id: 'story-closed', + title: 'Closed client', + music: { + ...base.music, + cues: [{ + id: 'cue-closed', + kind: 'story', + targetId: 'story-closed', + title: 'Closed', + purpose: '', + referenceSong: '', + brief: '', + style: 'metal', + lyrics: '[Verse]\nClosed', + lyriaPrompt: '', + instrumental: false, + durationSeconds: 30, + candidates: [{ + id: pendingId, + status: 'pending', + name: '', + source: '', + prompt: 'metal', + lyrics: '[Verse]\nClosed', + provider: 'local', + model: 'ace_step_v1_5_xl_sft_lm_4b', + durationSeconds: 30, + createdAt: '2026-09-05T00:00:00.000Z', + }], + selectedCandidateId: pendingId, + }], + }, + }) + const library = { + version: 2, + revision: 4, + activeId: project.id, + projects: { [project.id]: project }, + } + window.localStorage.setItem(`maestro-story-library-v2:${workspace}`, JSON.stringify(library)) + useStoryStore.setState({ + workspace: 'other', + hydrated: false, + loading: false, + libraryConflicts: [], + }) + const originalFetch = globalThis.fetch + const putBodies = [] + t.after(() => { + globalThis.fetch = originalFetch + window.localStorage.removeItem(`maestro-story-library-v2:${workspace}`) + }) + globalThis.fetch = async (input, init = {}) => { + const url = String(input) + if (url.includes('/api/v1/stories/library?')) { + return new Response(JSON.stringify(library), { headers: { 'content-type': 'application/json' } }) + } + if (url.endsWith('/api/v1/stories/library') && init.method === 'PUT') { + putBodies.push(JSON.parse(String(init.body))) + const saved = { + ...JSON.parse(String(init.body)).library, + revision: 5, + } + return new Response(JSON.stringify(saved), { headers: { 'content-type': 'application/json' } }) + } + if (url.includes('/api/v1/assets')) { + return new Response(JSON.stringify({ + assets: [{ + id: 'asset-closed', + kind: 'audio', + filename: 'closed.wav', + size_bytes: 12, + created_at: 1, + completed_at: 1, + metadata_status: 'canonical', + workspace_ids: [workspace], + locations: [{ workspace_id: workspace, filename: 'closed.wav', url: '/api/v1/file/closed.wav' }], + url: '/api/v1/file/closed.wav?workspace=song-recover-hydrate', + origin: { + tool: 'story_lab', + output_folder: workspace, + project: { kind: 'story', id: project.id }, + }, + execution: { candidate_id: pendingId, cue_id: 'cue-closed', status: 'completed', mode: 'real' }, + model: { provider: 'local', id: 'ace_step_v1_5_xl_sft_lm_4b' }, + prompt_preview: 'metal', + }], + total: 1, + }), { headers: { 'content-type': 'application/json' } }) + } + if (url.includes('/api/v1/outputs')) { + return new Response(JSON.stringify({ outputs: [], total: 0 }), { + headers: { 'content-type': 'application/json' }, + }) + } + throw new Error(`Unexpected request: ${url}`) + } + + await useStoryStore.getState().loadWorkspace(workspace) + const recovered = useStoryStore.getState().projects[project.id].music.cues[0].candidates[0] + assert.equal(recovered.id, pendingId) + assert.equal(recovered.status, 'ready') + assert.equal(recovered.name, 'closed.wav') + assert.match(recovered.source, /closed\.wav/) + assert.equal(putBodies.length, 1) + assert.equal(putBodies[0].baseRevision, 4) + assert.equal( + putBodies[0].library.projects[project.id].music.cues[0].candidates[0].status, + 'ready', + ) + assert.equal(useStoryStore.getState().libraryRevision, 5) + assert.equal(useStoryStore.getState().dirty, false) +}) diff --git a/ui/tests/storySongRevision.test.mjs b/ui/tests/storySongRevision.test.mjs index 6d358777..11bbc5bd 100644 --- a/ui/tests/storySongRevision.test.mjs +++ b/ui/tests/storySongRevision.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' import test from 'node:test' import { JSDOM } from 'jsdom' @@ -12,14 +13,8 @@ Object.assign(globalThis, { }) window.matchMedia = () => ({ matches: false }) -test('song generation rebases its candidate when Story autosave wins the CAS race', { concurrency: false }, async t => { - const workspace = 'wizard-song-cas-retry' - const [{ useStore }, { useStoryStore, createStoryProject, normalizeStoryProject }] = await Promise.all([ - import('../src/stores/useStore.ts'), - import('../src/features/stories/store.ts'), - ]) - const base = createStoryProject('music_video') - const cue = { +function cueFixture(base) { + return { id: 'cue-canonical', kind: 'story', targetId: base.id, @@ -35,23 +30,30 @@ test('song generation rebases its candidate when Story autosave wins the CAS rac durationSeconds: 30, candidates: [], } - const project = normalizeStoryProject({ - ...base, - title: 'Videoclip CAS', - music: { - ...base.music, - model: 'ace_step_v1_5_xl_sft_lm_4b', - cues: [cue], - }, - }) - const remoteLibrary = { - version: 2, - revision: 2, - activeId: project.id, +} + +async function installStory(workspace, project, revision = 1) { + const { useStore } = await import('../src/stores/useStore.ts') + const { useStoryStore } = await import('../src/features/stories/store.ts') + useStore.setState({ activeWorkspace: workspace }) + useStoryStore.setState({ + workspace, + project, projects: { [project.id]: project }, - } - let savedLibrary = remoteLibrary + libraryRevision: revision, + dirty: false, + hydrated: true, + loading: false, + saveError: null, + libraryConflicts: [], + activeProjectOperations: {}, + }) +} + +function mockStoryFetch(t, workspace, savedLibrary, options = {}) { const putRevisions = [] + const events = [] + const putBodies = [] let generationRequest const originalFetch = globalThis.fetch t.after(() => { @@ -61,50 +63,101 @@ test('song generation rebases its candidate when Story autosave wins the CAS rac globalThis.fetch = async (input, init = {}) => { const url = String(input) if (url.endsWith('/api/v1/director/generate-music')) { - generationRequest = JSON.parse(String(init.body)) + events.push('generate') + if (options.failGenerate) { + return new Response(JSON.stringify({ detail: 'music failed' }), { + status: 500, headers: { 'content-type': 'application/json' }, + }) + } + generationRequest = JSON.parse(String(init.body || '{}')) return new Response(JSON.stringify({ audio_path: '/tmp/himno.wav', filename: 'himno.wav', - style: cue.style, - lyrics: cue.lyrics, + style: 'Heavy metal', + lyrics: '[Verse]\nLa red sigue viva.', }), { headers: { 'content-type': 'application/json' } }) } - if (url.includes('/api/v1/stories/library?')) { - return new Response(JSON.stringify(savedLibrary), { headers: { 'content-type': 'application/json' } }) + if (url.includes('/api/v1/stories/music-candidates/jobs')) { + events.push('generate') + generationRequest = JSON.parse(String(init.body || '{}')) + return new Response(JSON.stringify({ + jobId: 'job-1', + taskId: 'task-1', + rootTaskId: 'task-1', + workspace, + status: 'completed', + phase: 'completed', + message: 'done', + current: 1, + total: 1, + progress: 1, + provider: 'minimax', + model: 'music-3.0', + candidates: [{ + filename: 'himno.wav', + audio_path: '/tmp/himno.wav', + source: '/api/v1/file/himno.wav', + duration_seconds: 30, + provider: 'minimax', + model: 'music-3.0', + }], + }), { headers: { 'content-type': 'application/json' } }) } - if (url.includes('/api/v1/outputs?')) { - return new Response(JSON.stringify({ outputs: [], total: 0 }), { headers: { 'content-type': 'application/json' } }) + if (url.includes('/api/v1/assets')) { + return new Response(JSON.stringify({ assets: [], total: 0 }), { + headers: { 'content-type': 'application/json' }, + }) + } + if (url.includes('/api/v1/outputs')) { + return new Response(JSON.stringify({ outputs: [], total: 0 }), { + headers: { 'content-type': 'application/json' }, + }) + } + if (url.includes('/api/v1/stories/library?')) { + return new Response(JSON.stringify(savedLibrary.value), { + headers: { 'content-type': 'application/json' }, + }) } if (url.endsWith('/api/v1/stories/library') && init.method === 'PUT') { const body = JSON.parse(String(init.body)) putRevisions.push(body.baseRevision) - if (putRevisions.length === 1) { + putBodies.push(body.library) + events.push('put') + if (options.conflictFirst && putRevisions.length === 1) { return new Response(JSON.stringify({ detail: { code: 'story_library_revision_conflict', message: 'expected 1, current 2', expectedRevision: 1, - currentRevision: 2, + currentRevision: savedLibrary.value.revision, } }), { status: 409, headers: { 'content-type': 'application/json' } }) } - savedLibrary = { ...body.library, revision: 3 } - return new Response(JSON.stringify(savedLibrary), { headers: { 'content-type': 'application/json' } }) + savedLibrary.value = { ...body.library, revision: body.baseRevision + 1 } + return new Response(JSON.stringify(savedLibrary.value), { + headers: { 'content-type': 'application/json' }, + }) } throw new Error(`Unexpected request: ${url}`) } + return { putRevisions, putBodies, events, get generationRequest() { return generationRequest } } +} - useStore.setState({ activeWorkspace: workspace }) - useStoryStore.setState({ - workspace, - project, - projects: { [project.id]: project }, - libraryRevision: 1, - dirty: false, - hydrated: true, - loading: false, - saveError: null, - libraryConflicts: [], - activeProjectOperations: {}, +test('song generation saves a pending candidate before generate-music HTTP', { concurrency: false }, async t => { + const workspace = 'wizard-song-pending-first' + const [{ createStoryProject, normalizeStoryProject }] = await Promise.all([ + import('../src/features/stories/store.ts'), + ]) + const base = createStoryProject('music_video') + const cue = cueFixture(base) + const project = normalizeStoryProject({ + ...base, + title: 'Videoclip pending', + music: { ...base.music, model: 'ace_step_v1_5_xl_sft_lm_4b', cues: [cue] }, }) + const savedLibrary = { + value: { version: 2, revision: 1, activeId: project.id, projects: { [project.id]: project } }, + } + const mock = mockStoryFetch(t, workspace, savedLibrary) + await installStory(workspace, project, 1) const { generateStorySong } = await import('../src/features/agent/labActions.ts') const result = await generateStorySong({ @@ -114,16 +167,193 @@ test('song generation rebases its candidate when Story autosave wins the CAS rac confirm: true, }) - assert.deepEqual(putRevisions, [1, 2]) + assert.equal(mock.events[0], 'put') + assert.ok(mock.events.indexOf('put') < mock.events.indexOf('generate')) + const pendingPut = mock.putBodies[0] + const pendingCandidate = pendingPut.projects[project.id].music.cues[0].candidates[0] + assert.equal(pendingCandidate.status, 'pending') + assert.equal(pendingCandidate.source, '') + assert.match(pendingCandidate.id, /^song-/) const meta = result.artifacts[0].metadata - assert.equal(meta.cueTitle, cue.title) - assert.equal(meta.outputName, 'himno.wav') - const savedCue = savedLibrary.projects[project.id].music.cues[0] + assert.equal(pendingCandidate.id, meta.candidateId) + const ready = savedLibrary.value.projects[project.id].music.cues[0].candidates[0] + assert.equal(ready.id, meta.candidateId) + assert.equal(ready.status, 'ready') + assert.equal(ready.name, 'himno.wav') + assert.equal(mock.generationRequest.provenance.candidate_id, meta.candidateId) + assert.equal(mock.generationRequest.provenance.song_version, String(meta.songVersion)) + assert.equal(mock.generationRequest.provenance.project_id, project.id) + assert.equal(mock.generationRequest.provenance.cue_id, cue.id) + assert.equal(mock.generationRequest.provenance.workspace_id, undefined) +}) + +test('song generation rebases its candidate when Story autosave wins the CAS race', { concurrency: false }, async t => { + const workspace = 'wizard-song-cas-retry' + const { createStoryProject, normalizeStoryProject } = await import('../src/features/stories/store.ts') + const base = createStoryProject('music_video') + const cue = cueFixture(base) + const project = normalizeStoryProject({ + ...base, + title: 'Videoclip CAS', + music: { ...base.music, model: 'ace_step_v1_5_xl_sft_lm_4b', cues: [cue] }, + }) + const savedLibrary = { + value: { version: 2, revision: 2, activeId: project.id, projects: { [project.id]: project } }, + } + const mock = mockStoryFetch(t, workspace, savedLibrary, { conflictFirst: true }) + await installStory(workspace, project, 1) + + const { generateStorySong } = await import('../src/features/agent/labActions.ts') + const result = await generateStorySong({ + type: 'generate_story_song', + targetStoryTitle: project.title, + cueTitle: cue.title, + confirm: true, + }) + + assert.deepEqual(mock.putRevisions.slice(0, 2), [1, 2]) + const meta = result.artifacts[0].metadata + const savedCue = savedLibrary.value.projects[project.id].music.cues[0] assert.equal(savedCue.selectedCandidateId, meta.candidateId) assert.equal(savedCue.candidates.length, 1) assert.equal(savedCue.candidates[0].id, meta.candidateId) - assert.equal(generationRequest.provenance.project_id, project.id) - assert.equal(generationRequest.provenance.cue_id, cue.id) - assert.equal(generationRequest.provenance.candidate_id, meta.candidateId) - assert.equal(generationRequest.provenance.workspace_id, undefined) + assert.equal(mock.generationRequest.provenance.candidate_id, meta.candidateId) + const pendingIds = mock.putBodies.map(body => body.projects[project.id].music.cues[0].candidates[0]?.id) + assert.ok(pendingIds.every(id => id === meta.candidateId)) +}) + +test('failed generation keeps the minted candidate id as failed', { concurrency: false }, async t => { + const workspace = 'wizard-song-failed-id' + const { createStoryProject, normalizeStoryProject } = await import('../src/features/stories/store.ts') + const base = createStoryProject('music_video') + const cue = cueFixture(base) + const project = normalizeStoryProject({ + ...base, + title: 'Videoclip fail', + music: { ...base.music, model: 'ace_step_v1_5_xl_sft_lm_4b', cues: [cue] }, + }) + const savedLibrary = { + value: { version: 2, revision: 1, activeId: project.id, projects: { [project.id]: project } }, + } + mockStoryFetch(t, workspace, savedLibrary, { failGenerate: true }) + await installStory(workspace, project, 1) + + const { generateStorySong } = await import('../src/features/agent/labActions.ts') + await assert.rejects( + () => generateStorySong({ + type: 'generate_story_song', + targetStoryTitle: project.title, + cueTitle: cue.title, + confirm: true, + }), + /music failed/, + ) + const savedCue = savedLibrary.value.projects[project.id].music.cues[0] + assert.equal(savedCue.candidates.length, 1) + assert.match(savedCue.candidates[0].id, /^song-/) + assert.equal(savedCue.candidates[0].status, 'failed') + assert.equal(savedCue.candidates[0].source, '') +}) + +test('a missing cue id throws instead of picking another cue', { concurrency: false }, async t => { + const workspace = 'wizard-song-missing-cue' + const { createStoryProject, normalizeStoryProject } = await import('../src/features/stories/store.ts') + const base = createStoryProject('music_video') + const cue = cueFixture(base) + const project = normalizeStoryProject({ + ...base, + title: 'Videoclip cue', + music: { ...base.music, model: 'ace_step_v1_5_xl_sft_lm_4b', cues: [cue] }, + }) + const savedLibrary = { + value: { version: 2, revision: 1, activeId: project.id, projects: { [project.id]: project } }, + } + mockStoryFetch(t, workspace, savedLibrary) + await installStory(workspace, project, 1) + const { generateStorySong } = await import('../src/features/agent/labActions.ts') + await assert.rejects( + () => generateStorySong({ + type: 'generate_story_song', + targetStoryTitle: project.title, + cueTitle: cue.title, + cueId: 'cue-missing', + confirm: true, + }), + /No existe el cue con ID/, + ) +}) + +test('Wizard generate uses the open project when title is omitted', { concurrency: false }, async t => { + const workspace = 'wizard-song-open-project' + const { createStoryProject, normalizeStoryProject, useStoryStore } = await import('../src/features/stories/store.ts') + const otherBase = createStoryProject('music_video') + const openBase = createStoryProject('music_video') + const other = normalizeStoryProject({ + ...otherBase, + title: 'Other story', + music: { ...otherBase.music, model: 'ace_step_v1_5_xl_sft_lm_4b', cues: [cueFixture(otherBase)] }, + }) + const openCue = cueFixture(openBase) + const open = normalizeStoryProject({ + ...openBase, + title: 'Open story', + music: { ...openBase.music, model: 'ace_step_v1_5_xl_sft_lm_4b', cues: [openCue] }, + }) + const savedLibrary = { + value: { + version: 2, + revision: 1, + activeId: open.id, + projects: { [other.id]: other, [open.id]: open }, + }, + } + const mock = mockStoryFetch(t, workspace, savedLibrary) + await installStory(workspace, open, 1) + useStoryStore.setState({ + project: open, + projects: { [other.id]: other, [open.id]: open }, + }) + + const { generateStorySong } = await import('../src/features/agent/labActions.ts') + const result = await generateStorySong({ + type: 'generate_story_song', + targetStoryTitle: '', + cueTitle: '', + confirm: true, + }) + assert.equal(result.artifacts[0].metadata.projectId, open.id) + assert.equal(mock.generationRequest.provenance.project_id, open.id) + assert.equal(mock.generationRequest.provenance.cue_id, openCue.id) +}) + +test('generateStoryCueSong sends provenance with candidate_id for the Story Lab path', { concurrency: false }, async t => { + const workspace = 'story-lab-cue-audio' + const { createStoryProject, normalizeStoryProject } = await import('../src/features/stories/store.ts') + const base = createStoryProject('music_video') + const cue = cueFixture(base) + const project = normalizeStoryProject({ + ...base, + title: 'Story Lab song', + music: { ...base.music, model: 'ace_step_v1_5_xl_sft_lm_4b', cues: [cue] }, + }) + const savedLibrary = { + value: { version: 2, revision: 1, activeId: project.id, projects: { [project.id]: project } }, + } + const mock = mockStoryFetch(t, workspace, savedLibrary) + await installStory(workspace, project, 1) + const { generateStoryCueSong } = await import('../src/features/stories/storySongGeneration.ts') + const generated = await generateStoryCueSong({ + workspace, + projectId: project.id, + cueId: cue.id, + actor: 'user', + capability: 'generate_story_song', + }) + assert.equal(mock.generationRequest.provenance.actor, 'user') + assert.equal(mock.generationRequest.provenance.candidate_id, generated.candidateId) + assert.equal(mock.generationRequest.provenance.cue_id, cue.id) + assert.equal(mock.events[0], 'put') + const panel = readFileSync(new URL('../src/features/stories/StoryLabPanel.tsx', import.meta.url), 'utf8') + assert.match(panel, /generateStoryCueSong/) + assert.match(panel, /generateMusicCueAudio/) })