Skip to content

feat: persist Story song identity before generate - #140

Merged
IAnMove merged 2 commits into
mainfrom
feat/story-song-durable-identity
Sep 5, 2026
Merged

feat: persist Story song identity before generate#140
IAnMove merged 2 commits into
mainfrom
feat/story-song-durable-identity

Conversation

@IAnMove

@IAnMove IAnMove commented Sep 5, 2026

Copy link
Copy Markdown
Owner

Resumen ejecutivo

Esta primera sección está pensada para project managers y revisores no
técnicos. Mantén el detalle técnico completo más abajo.

Qué cambia

Una versión de canción de Story Lab ahora tiene identidad antes de generar
el audio. El ID durable es song-…. v1/v2/v3 solo es una etiqueta de
pantalla. El candidato se guarda como pending en la biblioteca de Story en
disco
antes de llamar a generate; si el cliente se cierra, el WAV se puede
volver a enganchar por candidate_id del sidecar.

Para qué sirve

Hoy el Wizard mintaba el ID, generaba y después guardaba. Si se cerraba el
cliente, el WAV quedaba huérfano. Story Lab UI mintaba el ID después de
generar y no enviaba procedencia. normalize tiraba candidatos sin source,
así que un pending no podía persistir. Este PR corta esa pérdida sin tocar
Launch, useStore ni agentActions.

Impacto para el usuario

Al generar una canción desde el Wizard o Story Lab → Music, la versión queda
registrada enseguida. Si se cierra la pestaña a mitad de generate, al reabrir
el workspace la canción vuelve a aparecer cuando el WAV ya está en disco. Un
videoclip solo se puede preparar con una versión lista, no con una pendiente
o fallida.

Riesgo

  • Bajo
  • Medio
  • Alto

Estado

  • En desarrollo
  • Listo para revisión
  • Bloqueado por CI o revisión
  • Requiere migración o acción manual

Summary

Makes Story song identity survive client close by saving a pending
StoryMusicCandidate (song-…) to the Story library with CAS before
compute starts, then patching that same id to ready or failed. Display
v1/v2 is denormalized and is never identity.

Does not touch _launch_runtime.py, ui/src/stores/useStore.ts or
ui/src/features/agent/agentActions.ts. Server-side generate-music attach
is a follow-up PR. This slice is not done until required CI and
Cursor/Bugbot complete.

Overview

  1. Resolve the open Story first (targetStoryId / current project), then unique title.
  2. Resolve the cue by cueId. Title only if no ID; a stale title fails.
  3. Mint candidateId = storyId('song') and display version before generate.
  4. Persist status: 'pending' with empty source and provenance
    { projectId, cueId, candidateId, songVersion } via library CAS.
  5. Call generate with
    { actor, capability, project_id, cue_id, candidate_id, song_version }.
  6. On success, patch the same id (source, filename, task/job ids, ready).
  7. On failure, mark failed and keep the id for retry lineage.
  8. Staging a videoclip requires a ready candidate by id. Refuse pending/failed.
    Refuse synthetic cue story-song unless the caller passed that exact id.
  9. loadWorkspace recovers pending rows whose sidecar/output has matching
    candidate_id.
  10. normalizeMusicCandidate keeps pending/failed rows with a stable id and
    never remints an existing empty id on load.

Wizard generateStorySong and Story Lab generateMusicCueAudio (ACE-Step,
MiniMax Music 3 local, MiniMax remote) share generateStoryCueSong.

Detailed changes

Backend

  • attach_story_song_candidate(...) in app/services/story_library.py CAS-patches
    one pending row by project/cue/candidate IDs. Isolated per workspace directory.
  • No FastAPI routes, no WanGP, no _launch_runtime.py.

UI and Wizard

  • New ui/src/features/stories/storySongGeneration.ts shared helper.
  • New ui/src/features/stories/storySongRecovery.ts sidecar/output matching.
  • Wizard generate saves pending first, then generate, then patches the same id.
  • Story Lab generateMusicCueAudio calls the helper instead of minting after generate.
  • pinokio.js / launchers untouched. Story Lab panel shrinks (~80 lines extracted).

Data, provenance and compatibility

  • StoryMusicCandidate.status?: 'pending' | 'ready' | 'failed'.
  • Legacy rows with a source and no status remain ready.
  • Synthetic cue story-song is no longer a silent staging fallback.
  • Workspace A cannot attach a candidate that only exists in workspace B.

Files and ownership

File Why
ui/src/features/stories/types.ts status on StoryMusicCandidate
ui/src/features/stories/model.ts keep pending; do not remint ids
ui/src/features/stories/musicWorkflowState.ts pending builder + ready/failed patch
ui/src/features/stories/storySongGeneration.ts shared persist-then-generate helper
ui/src/features/stories/storySongRecovery.ts sidecar/output recovery
ui/src/features/stories/actions.ts Wizard generate uses the helper
ui/src/features/stories/StoryLabPanel.tsx generateMusicCueAudio calls the helper
ui/src/features/stories/store.ts recover pending on loadWorkspace
ui/src/features/stories/musicVideoSelection.ts refuse pending; refuse synthetic cue
ui/src/features/stories/storyProductionController.ts stop inventing story-song
app/services/story_library.py attach_story_song_candidate for recovery/tests
docs/development/STORY_SONG_IDENTITY.md identity contract

Deliberately not edited: _launch_runtime.py, ui/src/stores/useStore.ts,
ui/src/features/agent/agentActions.ts. SLICE_QUEUE.md left alone because
#136 is still open.

Validation

  • python scripts/verify_clean_repo.py — not run as a standalone step; validate_local.sh is the mandated gate
  • Focused Python tests: pytest -q tests/test_story_library.py10 passed
  • Focused UI tests: storySongRevision, storySongRecovery, musicVideoSelection, storyProductionController, agentActions95 passed
  • cd ui && npm test636 passed
  • cd ui && npm run i18n:check — covered by lint/build gate (no catalog keys added)
  • cd ui && npm run lint -- --max-warnings=0 → pass
  • cd ui && npm run build → pass
  • git diff --check → pass
  • E2E/smoke: npm run test:e2e7 passed (simulated, no GPU)
  • PYTHON=... bash scripts/validate_local.shcomplete
  • PYTHON=... bash scripts/check_code_health_pr_base.shRatchet passed

No real MiniMax/ACE-Step generation tests.

Code quality

  • Score: 49.8/100 vs PR base 9ac4cacb
  • Change vs PR base: +0.2
  • Complexity trend: cyclomatic 52.6 (0.0), concentration +0.2, oversized-file +0.4, modularity +0.1
  • Production LOC: +750 (539 files)
  • Test LOC: +716
  • Functions ≥ 15: +2 (budget 5)
  • Warning: ui/src/features/stories/store.ts 22 → 25
  • Ratchet: passed (not the historical dashboard baseline)

CI will republish the table; do not treat this as certified until CI comments.

CI and review

  • CI: pending
  • Cursor/Bugbot: pending
  • Human review: pending

This PR is not done while required CI or Cursor/Bugbot is still running.

Coste de la tarea

  • Tests simulados: 0 tokens externos
  • Tests reales: N/A
  • Llamadas LLM externas: 0
  • Tokens de prompt: N/A
  • Tokens de respuesta: N/A
  • Tokens totales: N/A
  • Generaciones de imágenes/audio/vídeo: 0
  • Tiempo transcurrido: ~2 h de implementación local
  • Proveedores/modelos: N/A

Notes and limitations

  • Recovery is client-side: loadWorkspace matches pending rows to asset catalog /
    output metadata candidate_id. Server-side attach inside generate-music is
    explicitly deferred.
  • MiniMax remote jobs still ignore extra provenance fields in Launch; the client
    sends them so a later PR can persist them without changing this contract.
  • Synthetic cue story-song remains available only when the caller passes that
    exact id (legacy global candidates).

Follow-up work

  • Wire attach_story_song_candidate into generate-music in _launch_runtime.py
    (sequential PR; that file is reserved).
  • Optional: persist recovered ready rows immediately instead of relying on
    autosave + next hydrate.
  • Do not fold this into docs: architecture execution baseline (phases 1-12) #136 (SLICE_QUEUE.md).

Checklist

  • The executive summary is understandable without reading the code.
  • The detailed Summary/Overview has not been removed or shortened.
  • Tests and their actual results are recorded.
  • Generated assets, secrets and local-only files are not committed.
  • Required CI and Cursor/Bugbot review are complete, or the PR is clearly
    marked as waiting for them.

Note

Medium Risk
Changes Story library persistence, generation ordering, and videoclip staging gates across Wizard, Story Lab, and normalize/load paths; backend attach is untested in production HTTP until a follow-up.

Overview
Story Lab song versions now get a durable song-… id before audio generation. The PR saves a pending StoryMusicCandidate to the Story library (CAS) with empty source and full provenance, then calls generate with project_id, cue_id, candidate_id, and song_version. Success patches the same row to ready; failure marks failed without minting a new id.

Wizard generateStorySong and Story Lab cue audio generation are unified in generateStoryCueSong. loadWorkspace can reattach finished WAVs to pending rows by matching asset/sidecar candidate_id. Normalization keeps pending/failed rows with stable ids and no longer drops or remints empty-source candidates.

Videoclip staging and music-video selection now require a ready candidate and tighten the legacy story-song synthetic cue (only when explicitly requested). Backend adds attach_story_song_candidate for CAS patch-by-id (tests only; no route yet). Docs add STORY_SONG_IDENTITY.md and domain-model notes.

Reviewed by Cursor Bugbot for commit 5d51353. Configure here.

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.
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

PR Review — Loreframe Studio

Risk: medium
Scope: 21 file(s); +1977/-363; React UI, backend services, docs

Automated review from scripts/analyze_pr.py. This is a heuristic pass (no LLM) so humans still own the merge decision.

Findings

  • medium — Large pull request
    1977 additions / 363 deletions. Reviewers will have an easier time with smaller, focused PRs.
  • low — UI changed — rebuild before merge
    Run cd ui && npm run build (CI already does this). Pinokio Update rebuilds for end users; keep ui/dist untracked.

Changed files

  • added: docs/development/STORY_SONG_IDENTITY.md, ui/src/features/stories/storySongGeneration.ts, ui/src/features/stories/storySongRecovery.ts, ui/tests/storySongRecovery.test.mjs
  • modified: app/services/story_library.py, docs/development/DOMAIN_MODEL_AND_ASSET_PROVENANCE.md, tests/test_story_library.py, ui/src/api/director.ts, ui/src/api/stories.ts, ui/src/features/stories/StoryLabPanel.tsx, ui/src/features/stories/actions.ts, ui/src/features/stories/model.ts, ui/src/features/stories/musicVideoSelection.ts, ui/src/features/stories/musicWorkflowState.ts, ui/src/features/stories/provenance.ts, ui/src/features/stories/store.ts, ui/src/features/stories/storyProductionController.ts, ui/src/features/stories/types.ts, ui/tests/musicVideoSelection.test.mjs, ui/tests/storyProductionController.test.ts, ui/tests/storySongRevision.test.mjs

CONTRIBUTING checklist

  • python scripts/verify_clean_repo.py
  • python -m compileall -q app/services app/launch.py scripts
  • cd ui && npm run build if the UI changed
  • No weights, CivitAI sidecars, or generated guides
  • Stays local-first (no required accounts / telemetry)

Posted by the repo PR review workflow. Re-runs on each push to the PR.

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

Code health

Quality score: 49.8/100

Higher is better. The score is a trend dashboard; the independent ratchet below remains the CI gate.

Component Weight Current Change
Cyclomatic health 45% 52.6 +0.0
File concentration 25% 55.2 +0.2
Oversized-file debt 20% 30.5 +0.4
Modularity 10% 62.2 +0.1

Change vs PR base: +0.2 points.

Metric Value
Production LOC 244,543
Production files 539
Test LOC 71,812
Functions measured 15,307
Functions complexity ≥ 15 788
Maximum complexity 667

Markdown, JSON catalogs and tests are out of this table. Only app/ runtime + ui/src TS/JS count.

Most complex functions

Complexity Where
667 app/wgp.py:7164 generate_video
374 ui/src/stores/useStore.ts:4024 Async method 'startGeneration'
355 app/_launch_runtime.py:23508 _run_generation
308 app/wgp.py:12281 generate_video_tab
271 ui/src/components/Sidebar/SceneAnimatorPanel.tsx:474 Function 'SceneAnimatorPanel'
266 ui/src/stores/useStore.ts:8566 Async method 'loadSettingsFromOutput'
258 app/services/director/planners/short_film.py:3433 ShortFilmPlanner._plan_story_driven
248 app/services/director_pipeline.py:13735 _run_video_generation
245 app/services/director_pipeline.py:7860 _run_pipeline
243 ui/src/features/agent/agentActions.ts:1128 Function 'parseAction'
226 app/services/director_pipeline.py:6689 update_comic_preview
225 ui/src/features/agent/agentActions.ts:2796 Async function 'executeAgentActions'

Trend vs baseline

Metric Δ
Production LOC +776
Test LOC +785
Functions ≥ 15 +2
Maximum complexity +0

Warnings

  • production LOC increased by +776
  • functions at complexity >= 15 increased by +2
  • complexity hotspot ui/src/features/stories/store.ts rose 22 -> 27

Ratchet passed.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Recovered songs skip library autosave
    • Recovery now caches the pre-recovery remote snapshot so the autosave subscriber sees a diff and PUTs the ready song.
  • ✅ Fixed: Failed status blocks sidecar recovery
    • Empty-source failed candidates are now treated as recoverable so a matching sidecar WAV can be reattached after a client timeout.

Create PR

Or push these changes by commenting:

@cursor push ae76c6537a
Preview (ae76c6537a)
diff --git a/ui/src/features/stories/store.ts b/ui/src/features/stories/store.ts
--- a/ui/src/features/stories/store.ts
+++ b/ui/src/features/stories/store.ts
@@ -390,6 +390,11 @@
         library.projects,
         library.revision,
       )
+      // Cache the pre-recovery snapshot (remote, or the just-uploaded first-run
+      // library) so sidecar recovery can queue the same PUT as a local-newer merge.
+      const remoteSerialized = remoteLibrary
+        ? JSON.stringify(remoteLibrary)
+        : JSON.stringify(library)
       const recovered = conflicts.length
         ? { library, recovered: false }
         : await recoverHydratedLibrary(workspace, library)
@@ -402,12 +407,9 @@
       )
       // 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
+        (needsRemoteSync || recovered.recovered) && !conflicts.length
           ? remoteSerialized
           : JSON.stringify(library),
       )

diff --git a/ui/src/features/stories/storySongRecovery.ts b/ui/src/features/stories/storySongRecovery.ts
--- a/ui/src/features/stories/storySongRecovery.ts
+++ b/ui/src/features/stories/storySongRecovery.ts
@@ -121,7 +121,9 @@
 }
 
 export function isPendingStoryMusicCandidate(candidate: StoryMusicCandidate): boolean {
-  return candidate.status === 'pending' || (!candidate.source.trim() && candidate.status !== 'failed')
+  // Empty-source rows stay recoverable even after a client timeout marks them
+  // failed: the WAV and candidate_id sidecar may already be on disk.
+  return candidate.status === 'pending' || !candidate.source.trim()
 }
 
 export function libraryHasPendingSongs(library: Pick<StoryLibraryData, 'projects'>): boolean {

diff --git a/ui/tests/storySongRecovery.test.mjs b/ui/tests/storySongRecovery.test.mjs
--- a/ui/tests/storySongRecovery.test.mjs
+++ b/ui/tests/storySongRecovery.test.mjs
@@ -113,6 +113,61 @@
   assert.equal(candidate.taskId, 'task-9')
 })
 
+test('hydrate recovers a failed song when a matching sidecar already 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-timeout-failed'
+  const project = normalizeStoryProject({
+    ...base,
+    id: 'story-failed-recover',
+    music: {
+      ...base.music,
+      cues: [{
+        id: 'cue-failed',
+        kind: 'story',
+        targetId: 'story-failed-recover',
+        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: 'timeout.wav',
+    source: '/api/v1/file/timeout.wav',
+    projectId: project.id,
+    cueId: 'cue-failed',
+    taskId: 'task-timeout',
+  }])
+  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, 'timeout.wav')
+  assert.equal(candidate.source, '/api/v1/file/timeout.wav')
+  assert.equal(candidate.taskId, 'task-timeout')
+})
+
 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 +270,24 @@
     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') {
+      const body = JSON.parse(String(init.body || '{}'))
+      putBodies.push(body.library)
+      return new Response(JSON.stringify({
+        ...body.library,
+        revision: (body.baseRevision || library.revision) + 1,
+      }), { headers: { 'content-type': 'application/json' } })
+    }
     if (url.includes('/api/v1/assets')) {
       return new Response(JSON.stringify({
         assets: [{
@@ -263,4 +327,12 @@
   assert.equal(recovered.status, 'ready')
   assert.equal(recovered.name, 'closed.wav')
   assert.match(recovered.source, /closed\.wav/)
+
+  const deadline = Date.now() + 2000
+  while (!putBodies.length && Date.now() < deadline) {
+    await new Promise(resolve => setTimeout(resolve, 50))
+  }
+  assert.equal(putBodies.length > 0, true)
+  assert.equal(putBodies[0].projects[project.id].music.cues[0].candidates[0].status, 'ready')
+  assert.match(putBodies[0].projects[project.id].music.cues[0].candidates[0].source, /closed\.wav/)
 })

You can send follow-ups to the cloud agent here.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 5d51353. Configure here.

Comment thread ui/src/features/stories/store.ts Outdated
projects: library.projects,
libraryRevision: library.revision,
dirty: false,
dirty: needsRemoteSync || recovered.recovered,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Recovered songs skip library autosave

Medium Severity

After loadWorkspace reattaches a pending song, lastPersistedLibrary is set to the recovered snapshot. The autosave subscriber only writes when that cache differs from the current library, so the ready patch never reaches the server. dirty is set but is not what triggers persist. Another tab or device still sees pending.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5d51353. Configure here.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in dc63eb5: recovery now PUTs the ready library immediately. lastPersistedLibrary is only updated after that save succeeds; a failed save keeps the pre-recovery remote snapshot so autosave can retry.


export function isPendingStoryMusicCandidate(candidate: StoryMusicCandidate): boolean {
return candidate.status === 'pending' || (!candidate.source.trim() && candidate.status !== 'failed')
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failed status blocks sidecar recovery

Medium Severity

Any generate error calls markSongFailed, and recovery only reattaches rows that still look pending. A client timeout after the WAV and candidate_id sidecar are already on disk leaves a durable failed row. Later loadWorkspace will not bind that audio, and videoclip staging refuses the version.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5d51353. Configure here.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in dc63eb5: isRecoverableStoryMusicCandidate includes failed rows without a ready source. loadWorkspace reattaches them when the sidecar has the same candidate_id.

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.
@IAnMove
IAnMove merged commit d4263ce into main Sep 5, 2026
4 checks passed
@IAnMove
IAnMove deleted the feat/story-song-durable-identity branch September 5, 2026 11:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant