From 5d51353408a6c566c51818ad095bc24ab8453fcf Mon Sep 17 00:00:00 2001 From: IAnMove <216241348+IAnMove@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:49:02 +0200 Subject: [PATCH 1/2] feat: persist Story song identity before generate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mint a durable song-… candidate, save it pending on the Story library, then generate and patch the same id so a client close can recover the WAV. --- app/services/story_library.py | 132 +++++++ .../DOMAIN_MODEL_AND_ASSET_PROVENANCE.md | 6 + docs/development/STORY_SONG_IDENTITY.md | 45 +++ tests/test_story_library.py | 123 +++++++ ui/src/api/director.ts | 1 + ui/src/api/stories.ts | 8 + ui/src/features/stories/StoryLabPanel.tsx | 106 +----- ui/src/features/stories/actions.ts | 153 ++------ ui/src/features/stories/model.ts | 67 +++- .../features/stories/musicVideoSelection.ts | 71 +++- ui/src/features/stories/musicWorkflowState.ts | 145 +++++++- ui/src/features/stories/provenance.ts | 25 +- ui/src/features/stories/store.ts | 112 +++++- .../stories/storyProductionController.ts | 30 +- .../features/stories/storySongGeneration.ts | 275 ++++++++++++++ ui/src/features/stories/storySongRecovery.ts | 198 ++++++++++ ui/src/features/stories/types.ts | 7 + ui/tests/musicVideoSelection.test.mjs | 95 ++++- ui/tests/storyProductionController.test.ts | 8 +- ui/tests/storySongRecovery.test.mjs | 266 ++++++++++++++ ui/tests/storySongRevision.test.mjs | 338 +++++++++++++++--- 21 files changed, 1864 insertions(+), 347 deletions(-) create mode 100644 docs/development/STORY_SONG_IDENTITY.md create mode 100644 ui/src/features/stories/storySongGeneration.ts create mode 100644 ui/src/features/stories/storySongRecovery.ts create mode 100644 ui/tests/storySongRecovery.test.mjs 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..c035a88c --- /dev/null +++ b/docs/development/STORY_SONG_IDENTITY.md @@ -0,0 +1,45 @@ +# 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`. + +## 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..c22d9f2d 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,52 @@ 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 + } +} + +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 } + } +} + function touched(before: StoryProject, candidate: StoryProject): StoryProject { const after = normalizeStoryProject(candidate) const sections = changedSections(before, after) @@ -337,6 +390,16 @@ export const useStoryStore = create((set, get) => ({ library.projects, library.revision, ) + const recovered = conflicts.length + ? { library, recovered: false } + : await recoverHydratedLibrary(workspace, library) + library = recovered.library + 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 @@ -344,7 +407,7 @@ export const useStoryStore = create((set, get) => ({ : JSON.stringify(library) lastPersistedLibrary.set( workspace, - needsRemoteSync && !conflicts.length + needsRemoteSync && !conflicts.length && !recovered.recovered ? remoteSerialized : JSON.stringify(library), ) @@ -352,7 +415,7 @@ export const useStoryStore = create((set, get) => ({ project: library.projects[library.activeId], projects: library.projects, libraryRevision: library.revision, - dirty: false, + dirty: needsRemoteSync || recovered.recovered, hydrated: true, loading: false, saveError: null, @@ -568,6 +631,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..cb84e365 --- /dev/null +++ b/ui/src/features/stories/storySongRecovery.ts @@ -0,0 +1,198 @@ +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 libraryHasPendingSongs(library: Pick): boolean { + return Object.values(library.projects).some(project => ( + project.music.cues.some(cue => cue.candidates.some(isPendingStoryMusicCandidate)) + || project.music.candidates.some(isPendingStoryMusicCandidate) + )) +} + +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 (!isPendingStoryMusicCandidate(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 (!isPendingStoryMusicCandidate(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..ab7d2e0e --- /dev/null +++ b/ui/tests/storySongRecovery.test.mjs @@ -0,0 +1,266 @@ +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('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 + t.after(() => { + globalThis.fetch = originalFetch + window.localStorage.removeItem(`maestro-story-library-v2:${workspace}`) + }) + globalThis.fetch = async input => { + const url = String(input) + if (url.includes('/api/v1/stories/library?')) { + return new Response(JSON.stringify(library), { 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/) +}) 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/) }) From dc63eb5b53c775e0b31e16c1cdc58d39fae3c76c Mon Sep 17 00:00:00 2001 From: IAnMove <216241348+IAnMove@users.noreply.github.com> Date: Sat, 5 Sep 2026 03:03:55 +0200 Subject: [PATCH 2/2] fix: persist recovered Story songs and reattach failed rows Autosave skipped the ready patch because lastPersistedLibrary already held the recovered snapshot. Write that patch to the Story library immediately. Also recover failed rows when a matching sidecar exists after a client timeout. --- docs/development/STORY_SONG_IDENTITY.md | 4 +- ui/src/features/stories/store.ts | 69 ++++++++++++------- ui/src/features/stories/storySongRecovery.ts | 13 ++-- ui/tests/storySongRecovery.test.mjs | 71 +++++++++++++++++++- 4 files changed, 127 insertions(+), 30 deletions(-) diff --git a/docs/development/STORY_SONG_IDENTITY.md b/docs/development/STORY_SONG_IDENTITY.md index c035a88c..c7c8c5fb 100644 --- a/docs/development/STORY_SONG_IDENTITY.md +++ b/docs/development/STORY_SONG_IDENTITY.md @@ -25,7 +25,9 @@ is denormalized and must never be used to recover identity. 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`. +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 diff --git a/ui/src/features/stories/store.ts b/ui/src/features/stories/store.ts index c22d9f2d..cf3e7671 100644 --- a/ui/src/features/stories/store.ts +++ b/ui/src/features/stories/store.ts @@ -221,6 +221,15 @@ async function fetchStorySongOutputRefs(workspace: string): Promise { + 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) @@ -384,38 +409,34 @@ export const useStoryStore = create((set, get) => ({ conflicts = merged.conflicts needsRemoteSync = merged.needsRemoteSync } - persistLocalLibrary( - workspace, - library.projects[library.activeId], - library.projects, - library.revision, - ) + writeLocalStoryLibrary(workspace, library) + const remoteSerialized = remoteLibrary ? JSON.stringify(remoteLibrary) : '' const recovered = conflicts.length ? { library, recovered: false } : await recoverHydratedLibrary(workspace, library) library = recovered.library - 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 && !recovered.recovered - ? remoteSerialized - : JSON.stringify(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: needsRemoteSync || recovered.recovered, + dirty: needsRemoteSync || (recovered.recovered && !recoveredPersisted), hydrated: true, loading: false, saveError: null, diff --git a/ui/src/features/stories/storySongRecovery.ts b/ui/src/features/stories/storySongRecovery.ts index cb84e365..a026b8fb 100644 --- a/ui/src/features/stories/storySongRecovery.ts +++ b/ui/src/features/stories/storySongRecovery.ts @@ -124,10 +124,15 @@ export function isPendingStoryMusicCandidate(candidate: StoryMusicCandidate): bo 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(isPendingStoryMusicCandidate)) - || project.music.candidates.some(isPendingStoryMusicCandidate) + project.music.cues.some(cue => cue.candidates.some(isRecoverableStoryMusicCandidate)) + || project.music.candidates.some(isRecoverableStoryMusicCandidate) )) } @@ -156,7 +161,7 @@ export function recoverPendingStorySongs( let projectChanged = false const cues = project.music.cues.map(cue => { const candidates = cue.candidates.map(candidate => { - if (!isPendingStoryMusicCandidate(candidate)) return candidate + if (!isRecoverableStoryMusicCandidate(candidate)) return candidate const output = matchingOutput(project, cue.id, candidate, outputs) if (!output?.source) return candidate projectChanged = true @@ -172,7 +177,7 @@ export function recoverPendingStorySongs( return projectChanged ? { ...cue, candidates } : cue }) const globalCandidates = project.music.candidates.map(candidate => { - if (!isPendingStoryMusicCandidate(candidate)) return candidate + if (!isRecoverableStoryMusicCandidate(candidate)) return candidate const output = matchingOutput(project, undefined, candidate, outputs) if (!output?.source) return candidate projectChanged = true diff --git a/ui/tests/storySongRecovery.test.mjs b/ui/tests/storySongRecovery.test.mjs index ab7d2e0e..9125cc50 100644 --- a/ui/tests/storySongRecovery.test.mjs +++ b/ui/tests/storySongRecovery.test.mjs @@ -113,6 +113,58 @@ test('hydrate recovers a pending song from a sidecar with matching candidate_id' 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') @@ -215,15 +267,24 @@ test('loadWorkspace attaches a matching WAV after client close with no live gene libraryConflicts: [], }) const originalFetch = globalThis.fetch + const putBodies = [] t.after(() => { globalThis.fetch = originalFetch window.localStorage.removeItem(`maestro-story-library-v2:${workspace}`) }) - globalThis.fetch = async input => { + 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: [{ @@ -263,4 +324,12 @@ test('loadWorkspace attaches a matching WAV after client close with no live gene 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) })