Skip to content

feat: add local MiniMax Music3 generation - #135

Merged
IAnMove merged 4 commits into
mainfrom
feat/local-minimax-music3
Sep 5, 2026
Merged

feat: add local MiniMax Music3 generation#135
IAnMove merged 4 commits into
mainfrom
feat/local-minimax-music3

Conversation

@IAnMove

@IAnMove IAnMove commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Resumen ejecutivo

Qué cambia

MiniMax-Music3 local sigue siendo un backend de canción de primera clase. Este follow-up corrige el freeze de CI (download_model) y los cinco hallazgos de Cursor: captions estructurados, shards de pesos, duración máxima, bloqueo de generación y generate sin API key remota.

Para qué sirve

Permite descargar y generar canciones locales de hasta cinco minutos con letra en el idioma pedido, sin marcar el modelo como listo a medias ni truncar el caption que Music3 necesita.

Impacto para el usuario

  • Settings/Models muestra requisitos y no da por instalado un download parcial.
  • Story Lab puede generar con MiniMax-Music3 o ACE-Step sin clave MiniMax remota.
  • La duración de Music3 se limita a 300 s.
  • El botón Generate queda bloqueado mientras corre un job local largo.

Riesgo

  • Medio

Estado

  • Bloqueado por CI o revisión
  • Validación local verde; CI y Cursor del nuevo commit pendientes.

Summary

  • add downloadable local MiniMax-Music3 backend and model metadata
  • expose model selection, resource requirements, and Wizard download capability
  • add Spanish-aware song guides, duration contract, UI wiring, and tests
  • keep Music3 captions multiline (do not apply remote MiniMax 300-char flatten)
  • require every language-model and transformer shard before readiness
  • clamp Story duration to the Music3 300s limit and hold productionBusy for local generate
  • enable per-cue generate when the selected backend is local
  • freeze Studio capabilities including download_model

Overview

Adds local MiniMax-Music3 as a first-class music backend: native components, a single-GPU pipeline, Hugging Face download handler, and required_model_assets covering all safetensor shards plus tokenizer/license. Story Lab, Studio Audio and the Wizard share the same catalog. The song-writer target minimax-music3 keeps ### Global Metadata / ### Vocal Details / ### Arrangement. Remote MiniMax API truncation stays only on target=minimax.

Detailed changes

Backend

  • Family handler, pipeline, defaults JSON, LLM write-song target.
  • Readiness lists every LM shard 00001-00004 and transformer 00001-00002.
  • Music3 caption normalizer preserves newlines; 8000-char bound only.

UI and Wizard

  • download_model capability with confirmation.
  • Local generate no longer requires MiniMax API key.
  • productionBusy wraps local Music3 generate.
  • Duration max is 300s for Music3, 360s otherwise.

Data, provenance and compatibility

  • No identity rewrite in this follow-up. Cue/candidate durable finalization remains a follow-up after this PR.
  • ACE-Step and remote MiniMax remain selectable.

Files and ownership

Owned: MiniMax-Music3 model tree, handler, defaults, LLM song-writer Music3 path, Story music selectors, Wizard download capability, local validation ratchet helper.

Deliberately untouched this follow-up: Director pipeline internals, wgp.generate_video, Activity UI, generation-record contract (separate PR).

Validation

  • python scripts/verify_clean_repo.py — PASS
  • Focused Python: tests/test_minimax_music3_local.py tests/test_minimax_song_writer_prompt.py — 10 passed
  • Focused UI: studioCapabilities, songLanguage, agentActions — 77 passed
  • bash scripts/validate_local.sh — PASS (Python contracts, code-health vs origin/main, 609 UI tests, lint, build, 7 simulated E2E)
  • git diff --check — clean
  • GitHub CI on 97033e7e — pending after push
  • Cursor Bugbot re-review — pending
  • E2E/smoke real: N/A (no GPU/weights in CI). Manual: bash scripts/run_real_media_smoke.sh with HOCUSPOCUS_SMOKE_CONFIRM=GENERATE_REAL_MEDIA

Code quality

  • Score on previous commit: 49.2/100 (CI comment). Delta vs PR base +0.0. Ratchet passed.
  • This follow-up is a small additive fix; do not invent a new score before CI comments.
  • Historical baseline was not refreshed.

CI and review

  • CI: pending on 97033e7e
  • Cursor/Bugbot: previous review had 5 issues; addressed in this commit; re-review pending
  • Human review: pending — do not merge

Cursor findings resolved:

  1. Music3 captions no longer go through _normalize_minimax_song_output.
  2. Local generate sets productionBusy.
  3. Cue/header generate allows local models without API key.
  4. required_model_assets includes every safetensor shard.
  5. Story duration clamped to 300s for Music3.

The eval() flagged by the heuristic PR review is PyTorch .eval(), not Python eval().

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 (Grok session tokens not exposed as a reliable count)
  • Generaciones de imágenes/audio/vídeo: 0 in this follow-up
  • Tiempo transcurrido: ~2 min local validation after the fix
  • Proveedores/modelos: N/A

Notes and limitations

  • Real Music3 inference is still local-only, ~28 GB, not in CI.
  • Community GGUF/MLX variants remain informational.
  • Server-side candidate linking if the browser closes mid-job is not in this PR.
  • Lyrics language contamination repair is a separate PR (new modules, no launch).

Follow-up work

  • Generation-record v1 (no _launch_runtime.py).
  • Lyrics language guard.
  • Durable cue/candidate finalization after client close (sequential on launch after this PR merges).
  • Story Music router extract (sequential on launch).
  • Do not open a second PR that edits _launch_runtime.py while this one is open.

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
Large new local inference stack and director/music HTTP paths with long-running jobs; changes are additive but increase GPU/memory and download correctness surface area.

Overview
Adds local MiniMax-Music3 as a downloadable TTS-family backend: native Diffusers-style components, a single-GPU pipeline (Qwen AR + flow/vocoder stages), handler/defaults, and registration in wgp. Model readiness now also checks required_model_assets so a tiny manifest alone cannot mark a partial ~28 GB install as ready.

Director / LLM: /api/v1/director/generate-music and the song writer gain a minimax-music3 target with structured-caption guides, runtime-scaled lyrics, higher token limits, and a 3600s job timeout for long local decodes. API model listings expose resource_requirements for UI hints.

Story Lab & Studio: Users can pick minimax_music3, generate locally without a remote MiniMax API key, clamp duration to 300s, and see VRAM/storage hints. The Wizard can download_model (opens Settings, waits for completion) and sees available_audio_models in context.

Tooling: Local validation compares code-health to the PR base via check_code_health_pr_base.sh instead of the historical dashboard baseline. Contract tests cover handler assets, prompt shape, and Music3 caption normalization (multiline, not remote 300-char flatten).

Reviewed by Cursor Bugbot for commit 9328718. Configure here.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

PR Review — Loreframe Studio

Risk: medium
Scope: 54 file(s); +2746/-78; React UI, backend services, docs, generation pipeline, repo scripts / CI

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

Findings

  • medium — Dangerous dynamic execution (app/models/TTS/minimax_music3/pipeline.py)
    app/models/TTS/minimax_music3/pipeline.py adds eval(). Confirm the input is trusted and sandboxed.
  • medium — Dangerous dynamic execution (tests/test_minimax_music3_local.py)
    tests/test_minimax_music3_local.py adds exec(). Confirm the input is trusted and sandboxed.
  • medium — Large pull request
    2746 additions / 78 deletions. Reviewers will have an easier time with smaller, focused PRs.
  • medium — Very large file change (app/models/TTS/minimax_music3/pipeline.py)
    app/models/TTS/minimax_music3/pipeline.py adds 900 lines. Consider splitting the PR.
  • 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: app/defaults/minimax_music3.json, app/models/TTS/minimax_music3/NOTICE.md, app/models/TTS/minimax_music3/__init__.py, app/models/TTS/minimax_music3/condition_encoder.py, app/models/TTS/minimax_music3/pipeline.py, app/models/TTS/minimax_music3/rvq_depth_decoder.py, app/models/TTS/minimax_music3/transformer.py, app/models/TTS/minimax_music3/vocoder.py, app/models/TTS/minimax_music3_handler.py, app/services/llm_guides/music/song_writer_minimax_music3.md, app/services/llm_guides/music/song_writer_minimax_music3_instrumental.md, scripts/check_code_health_pr_base.sh, tests/test_minimax_music3_local.py, ui/src/lib/minimaxMusicCatalog.ts
  • modified: app/_launch_runtime.py, app/models/TTS/__init__.py, app/routers/llm.py, app/wgp.py, docs/development/CODE_HEALTH.md, docs/development/LOCAL_VALIDATION.md, scripts/validate_local.sh, tests/test_minimax_song_writer_prompt.py, ui/src/api/generation.ts, ui/src/api/llm.ts, ui/src/api/stories.ts, ui/src/components/SettingsDrawer/SystemSettingsPanel.tsx, ui/src/components/Sidebar/ModelSelector.tsx, ui/src/components/Sidebar/MusicControls.tsx, ui/src/features/agent/agentActionTypes.ts, ui/src/features/agent/agentActions.ts, ui/src/features/agent/agentKnowledge.ts, ui/src/features/agent/applicationAdapters.ts, ui/src/features/agent/capabilityRegistry.ts, ui/src/features/agent/studioCapabilities.ts
    … and 20 more

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 4, 2026

Copy link
Copy Markdown

Code health

Quality score: 49.2/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.2 +0.0
File concentration 25% 54.3 +0.2
Oversized-file debt 20% 29.5 +0.0
Modularity 10% 62.6 +0.1

Change vs PR base: +0.0 points.

Metric Value
Production LOC 241,799
Production files 533
Test LOC 70,378
Functions measured 15,118
Functions complexity ≥ 15 786
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 +471
Test LOC +200
Functions ≥ 15 +2
Maximum complexity +0

Warnings

  • production LOC increased by +471
  • functions at complexity >= 15 increased by +2
  • complexity hotspot ui/src/features/stories/StoryMusicProductionLegacyDrawer.tsx rose 19 -> 20
  • hotspot app/_launch_runtime.py increased by +36 lines
  • hotspot ui/src/stores/useStore.ts increased by +3 lines
  • hotspot ui/src/features/stories/StoryLabPanel.tsx increased by +31 lines
  • hotspot ui/src/features/agent/agentActions.ts increased by +30 lines
  • hotspot ui/src/types/index.ts increased by +12 lines
  • hotspot ui/src/features/stories/actions.ts increased by +12 lines
  • hotspot ui/src/features/agent/wizardContext.ts increased by +3 lines
  • hotspot ui/src/components/SettingsDrawer/SystemSettingsPanel.tsx increased by +63 lines
  • hotspot ui/src/features/agent/capabilityRegistry.ts increased by +4 lines
  • hotspot ui/src/features/agent/applicationAdapters.ts increased by +29 lines

Ratchet passed.

@IAnMove

IAnMove commented Sep 4, 2026

Copy link
Copy Markdown
Owner Author

cursor review

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

Stale Bugbot comment from a previous run.

Comment thread app/routers/llm.py Outdated
Comment thread ui/src/features/stories/StoryLabPanel.tsx
Comment thread ui/src/features/stories/ManualSongPanel.tsx Outdated
Comment thread app/defaults/minimax_music3.json
Comment thread ui/src/features/stories/StoryLabPanel.tsx

@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 4 potential issues.

Autofix Details

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

  • ✅ Fixed: Music3 captions get MiniMax truncation
    • write-song now keeps the structured Music3 caption intact and only applies the 300-character MiniMax style normalizer to the cloud MiniMax target.
  • ✅ Fixed: Incomplete Music3 download readiness
    • required_model_assets now lists every language-model and transformer shard so a partial Hugging Face download is no longer marked ready.
  • ✅ Fixed: Story duration exceeds Music3 limits
    • Story Lab, Wizard, and generate-music now clamp MiniMax-Music3 durations to 20–300 seconds so a 360-second request cannot fail validation or crop the arrangement.
  • ✅ Fixed: Studio writer skips Music3 target
    • Studio Write Song now sends target, model, and duration_seconds when MiniMax-Music3 is selected so the writer produces a structured caption instead of an ACE-Step style line.

Create PR

Or push these changes by commenting:

@cursor push 437b969661
Preview (437b969661)
diff --git a/app/_launch_runtime.py b/app/_launch_runtime.py
--- a/app/_launch_runtime.py
+++ b/app/_launch_runtime.py
@@ -8298,7 +8298,10 @@
     params.setdefault("negative_prompt", "")
     if duration_seconds:
         try:
-            params["duration_seconds"] = float(duration_seconds)
+            duration = float(duration_seconds)
+            if str(model_type) == "minimax_music3":
+                duration = min(300.0, max(5.0, duration))
+            params["duration_seconds"] = duration
         except (TypeError, ValueError):
             pass
     try:

diff --git a/app/defaults/minimax_music3.json b/app/defaults/minimax_music3.json
--- a/app/defaults/minimax_music3.json
+++ b/app/defaults/minimax_music3.json
@@ -22,9 +22,13 @@
         "required_model_assets": [
             "minimax_music3/LICENSE",
             "minimax_music3/tokenizer/tokenizer.json",
+            "minimax_music3/language_model/model-00001-of-00004.safetensors",
+            "minimax_music3/language_model/model-00002-of-00004.safetensors",
+            "minimax_music3/language_model/model-00003-of-00004.safetensors",
             "minimax_music3/language_model/model-00004-of-00004.safetensors",
             "minimax_music3/rvq_depth_decoder/diffusion_pytorch_model.safetensors",
             "minimax_music3/condition_encoder/diffusion_pytorch_model.safetensors",
+            "minimax_music3/transformer/diffusion_pytorch_model-00001-of-00002.safetensors",
             "minimax_music3/transformer/diffusion_pytorch_model-00002-of-00002.safetensors",
             "minimax_music3/vocoder/diffusion_pytorch_model.safetensors"
         ]

diff --git a/app/routers/llm.py b/app/routers/llm.py
--- a/app/routers/llm.py
+++ b/app/routers/llm.py
@@ -256,8 +256,9 @@
     """Parse STYLE/LYRICS (and optional Lyria) into the write-song JSON body."""
     style, lyrics = _parse_song_output(raw, instrumental)
     lyria_prompt = _parse_lyria_output(raw) if target == "minimax" and include_lyria else ""
+    if target == "minimax":
+        style, lyrics = _normalize_minimax_song_output(style, lyrics, instrumental, model)
     if target in {"minimax", "minimax-music3"}:
-        style, lyrics = _normalize_minimax_song_output(style, lyrics, instrumental, model)
         if len(style) < 10:
             raise HTTPException(status_code=502, detail="The LLM did not return a valid MiniMax style prompt")
         if not instrumental and not lyrics:

diff --git a/tests/test_minimax_music3_local.py b/tests/test_minimax_music3_local.py
--- a/tests/test_minimax_music3_local.py
+++ b/tests/test_minimax_music3_local.py
@@ -77,7 +77,11 @@
     assert model["source_repo"].endswith("MiniMax-Music3")
     assert model["license_name"] == "MiniMax-Music3 Community License"
     assert model["model_size_gb"] >= 28
-    assert len(model["required_model_assets"]) >= 7
+    assets = model["required_model_assets"]
+    assert len(assets) >= 7
+    assert "minimax_music3/language_model/model-00001-of-00004.safetensors" in assets
+    assert "minimax_music3/language_model/model-00003-of-00004.safetensors" in assets
+    assert "minimax_music3/transformer/diffusion_pytorch_model-00001-of-00002.safetensors" in assets
 
 
 def test_handler_registers_model_and_validates_audio_contract():

diff --git a/tests/test_minimax_song_writer_prompt.py b/tests/test_minimax_song_writer_prompt.py
--- a/tests/test_minimax_song_writer_prompt.py
+++ b/tests/test_minimax_song_writer_prompt.py
@@ -11,6 +11,7 @@
     _optional_lyria_warning,
     _parse_lyria_output,
     _parse_song_output,
+    _song_writer_payload,
 )
 
 
@@ -85,6 +86,25 @@
         self.assertIn("[Chorus]", lyrics)
         self.assertIn("[0:00 - 0:12]", _parse_lyria_output(raw))
 
+    def test_music3_payload_keeps_structured_caption(self):
+        caption = (
+            "### Global Metadata\n"
+            "Warm acoustic pop at 96 BPM in C major; intimate and hopeful, growing "
+            "into a wide final chorus; polished natural production.\n\n"
+            "### Vocal Details\n"
+            "Soft, close female lead with breathy verses, clear diction, and light "
+            "stacked harmonies in the chorus.\n\n"
+            "### Arrangement\n"
+            "Fingerpicked acoustic guitar and soft piano open the song. Brushed drums "
+            "and upright bass enter in the chorus; strings bloom gently before a sparse outro."
+        )
+        raw = f"[STYLE]\n{caption}\n[LYRICS]\n[Verse]\nHello\n[Chorus]\nWorld"
+        payload = _song_writer_payload(raw, False, "minimax-music3", False, "minimax_music3")
+        self.assertEqual(payload["style"], caption)
+        self.assertGreater(len(payload["style"]), 300)
+        self.assertIn("\n", payload["style"])
+        self.assertIn("[Chorus]", payload["lyrics"])
+
     def test_missing_optional_lyria_does_not_invalidate_style_or_lyrics(self):
         raw = """[STYLE]
 Cinematic rap, heavy 808 bass, reflective vocal, 85 BPM

diff --git a/ui/src/components/Sidebar/MusicControls.tsx b/ui/src/components/Sidebar/MusicControls.tsx
--- a/ui/src/components/Sidebar/MusicControls.tsx
+++ b/ui/src/components/Sidebar/MusicControls.tsx
@@ -4,6 +4,7 @@
 import { useUiTranslation } from '../../i18n'
 import * as api from '../../api/client'
 import type { GenerateParams } from '../../types'
+import { MINIMAX_MUSIC3_LOCAL_MODEL } from '../../features/stories/musicModel'
 
 const TEXTAREA_BASE =
   'w-full bg-bg-tertiary border border-border rounded-lg px-3 py-2 text-sm text-text-primary ' +
@@ -79,6 +80,7 @@
   const setInstrumental = useStore(s => s.setMusicInstrumental)
   const params = useStore(s => s.params)
   const setParam = useStore(s => s.setParam)
+  const durationSeconds = useStore(s => s.durationSeconds)
 
   const style = (params.alt_prompt as string) || ''
   const lyrics = (params.prompt as string) || ''
@@ -104,7 +106,18 @@
     setWriting(true)
     setWriteError(null)
     try {
-      const r = await api.writeSong({ description: description.trim(), instrumental })
+      const modelType = String(params.model_type || '')
+      const r = await api.writeSong({
+        description: description.trim(),
+        instrumental,
+        ...(modelType === MINIMAX_MUSIC3_LOCAL_MODEL
+          ? {
+            target: 'minimax-music3' as const,
+            model: 'minimax_music3' as const,
+            duration_seconds: durationSeconds,
+          }
+          : {}),
+      })
       if (r.style) setStyle(r.style)
       setLyrics(instrumental ? '[Instrumental]' : (r.lyrics || ''))
     } catch (e) {

diff --git a/ui/src/features/agent/capabilityRegistry.ts b/ui/src/features/agent/capabilityRegistry.ts
--- a/ui/src/features/agent/capabilityRegistry.ts
+++ b/ui/src/features/agent/capabilityRegistry.ts
@@ -37,6 +37,7 @@
 import { executionKey, executionReport } from './agentContract'
 import type { WizardApplicationAdapters } from './applicationAdapters'
 import { inferStoryProjectTypeFromText } from '../stories/musicVideoLook'
+import { clampStorySongDuration } from '../stories/musicModel'
 import type { AgentCreateVideoEditorProjectAction, AgentOpenVideoEditorProjectAction } from './videoEditorActions'
 import type { AgentAttachVideoclipAlternativeSongAction, AgentMountVideoclipAlternativeSongAction } from './alternativeSongActions'
 import type { AgentApplyCharacterKitPresetAction, AgentAttachCharacterKitReferencesAction, AgentBuildCharacterKitAction, AgentCreateCharacterKitAction, AgentOpenCharacterKitAction, AgentOpenCharacterKitRigAction, AgentTrackCharacterKitJobAction } from './characterKitActions'
@@ -962,7 +963,9 @@
       ...(model === 'minimax_music3' || model === 'music-3.0' || model === 'music-2.6' || model === 'ace_step_v1_5_xl_sft_lm_4b'
         ? { model: model as AgentConfigureStorySongAction['model'] }
         : {}),
-      durationSeconds: raw.target_duration_seconds === undefined ? undefined : boundedNumber(raw.target_duration_seconds, 20, 360, 90),
+      durationSeconds: raw.target_duration_seconds === undefined
+        ? undefined
+        : clampStorySongDuration(boundedNumber(raw.target_duration_seconds, 20, 360, 90), model),
     }
   },
   validate(action) { return action.style && (action.instrumental || action.lyrics || action.writeLyrics) ? [] : ['music style and vocal lyrics or write_lyrics are required'] },

diff --git a/ui/src/features/stories/ManualSongPanel.tsx b/ui/src/features/stories/ManualSongPanel.tsx
--- a/ui/src/features/stories/ManualSongPanel.tsx
+++ b/ui/src/features/stories/ManualSongPanel.tsx
@@ -4,7 +4,7 @@
 import { useUiTranslation } from '../../i18n'
 import { button, completeGenerationButton, input, panel, Field } from './storyLabChrome'
 import { musicCandidateDisplayName, storySongBrief } from './storyLabMusic'
-import { isLocalMusicModel } from './musicModel'
+import { clampStorySongDuration, isLocalMusicModel, storySongDurationBounds } from './musicModel'
 import type { StoryMusicTabProps } from './StoryMusicTab'
 
 export function ManualSongPanel({
@@ -89,9 +89,9 @@
             <p className="text-[9px] text-text-muted">{t('music.manualVersionHint')}</p>
           </div>
           <label className="block text-[10px] text-text-muted">{t('music.targetDuration')}
-            <input className={`${input} mt-1`} type="number" min={20} max={360} step={5}
+            <input className={`${input} mt-1`} type="number" min={storySongDurationBounds(project.music.model).min} max={storySongDurationBounds(project.music.model).max} step={5}
               value={project.music.targetDurationSeconds}
-              onChange={event => patch({ music: { ...project.music, targetDurationSeconds: Math.max(20, Math.min(360, Number(event.target.value) || 90)) } })} />
+              onChange={event => patch({ music: { ...project.music, targetDurationSeconds: clampStorySongDuration(Number(event.target.value) || 90, project.music.model) } })} />
           </label>
           <p className="text-[9px] text-text-muted">{t('music.manualDurationHint')}</p>
           <button className={`${button} ${completeGenerationButton} w-full`}

diff --git a/ui/src/features/stories/MusicCueCard.tsx b/ui/src/features/stories/MusicCueCard.tsx
--- a/ui/src/features/stories/MusicCueCard.tsx
+++ b/ui/src/features/stories/MusicCueCard.tsx
@@ -2,6 +2,7 @@
 import * as api from '../../api/client'
 import { useUiTranslation } from '../../i18n'
 import { button, completeGenerationButton, input, panel, Field } from './storyLabChrome'
+import { clampStorySongDuration, storySongDurationBounds } from './musicModel'
 import { MINIMAX_LYRIC_SECTION, miniMaxCuePayload, musicCandidateDisplayName } from './storyLabMusic'
 import type { StoryMusicCue } from './types'
 import type { StoryMusicTabProps } from './StoryMusicTab'
@@ -51,9 +52,9 @@
               {t('music.instrumental')}
             </label>
             <label className="block text-[10px] text-text-muted">{t('music.targetDuration')}
-              <input className={`${input} mt-1`} type="number" min={20} max={360} step={5}
+              <input className={`${input} mt-1`} type="number" min={storySongDurationBounds(project.music.model).min} max={storySongDurationBounds(project.music.model).max} step={5}
                 value={cue.durationSeconds}
-                onChange={event => patchMusicCue(cue.id, { durationSeconds: Math.max(20, Math.min(360, Number(event.target.value) || 90)) })} />
+                onChange={event => patchMusicCue(cue.id, { durationSeconds: clampStorySongDuration(Number(event.target.value) || 90, project.music.model) })} />
             </label>
           </div>
           <p className="text-[9px] text-text-muted">{t('music.durationHint')}</p>

diff --git a/ui/src/features/stories/StoryLabPanel.tsx b/ui/src/features/stories/StoryLabPanel.tsx
--- a/ui/src/features/stories/StoryLabPanel.tsx
+++ b/ui/src/features/stories/StoryLabPanel.tsx
@@ -70,7 +70,7 @@
   StoryTrailerFormat, StoryTrailerIntensity, StoryTrailerNarration, StoryTrailerSpoiler, StoryWritingProvider,
 } from './types'
 import type { AspectRatio, ModelOptions, ResolutionPreset } from '../../types'
-import { isAceStepMusicModel, isLocalMusicModel, songWriteTarget } from './musicModel'
+import { clampStorySongDuration, isAceStepMusicModel, isLocalMusicModel, songWriteTarget } from './musicModel'
 import { listenForAgentStoryDraft, listenForAgentStorySection, listenForAgentStoryVisualGeneration } from '../../lib/uiBus'
 
 const storyLookupName = (value: string) => value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-zA-Z0-9]+/g, ' ').trim().toLowerCase()
@@ -3253,7 +3253,7 @@
           style: prompt,
           lyrics: cue.instrumental ? '[Instrumental]' : cue.lyrics,
           instrumental: cue.instrumental,
-          duration_seconds: current.music.targetDurationSeconds,
+          duration_seconds: clampStorySongDuration(current.music.targetDurationSeconds, current.music.model),
           model_type: current.music.model,
           workspace: activeWorkspace,
           initiator: `Story Lab · ${current.projectType === 'music_video' ? 'Videoclip' : 'Story song'}`,
@@ -3273,7 +3273,7 @@
           lyrics: cue.lyrics,
           provider: 'local' as const,
           model: current.music.model,
-          durationSeconds: current.music.targetDurationSeconds,
+          durationSeconds: clampStorySongDuration(current.music.targetDurationSeconds, current.music.model),
           createdAt,
         }]
         updateProjectById(sourceProjectId, latest => {

diff --git a/ui/src/features/stories/StoryMusicProductionLegacyDrawer.tsx b/ui/src/features/stories/StoryMusicProductionLegacyDrawer.tsx
--- a/ui/src/features/stories/StoryMusicProductionLegacyDrawer.tsx
+++ b/ui/src/features/stories/StoryMusicProductionLegacyDrawer.tsx
@@ -2,7 +2,7 @@
 import * as api from '../../api/client'
 import { useUiTranslation } from '../../i18n'
 import { button, completeGenerationButton, input } from './storyLabChrome'
-import { ACE_STEP_MUSIC_MODEL, MINIMAX_MUSIC3_LOCAL_MODEL, isAceStepMusicModel, isLocalMusicModel, normalizeStoryMusicModel } from './musicModel'
+import { ACE_STEP_MUSIC_MODEL, MINIMAX_MUSIC3_LOCAL_MODEL, clampStorySongDuration, isAceStepMusicModel, isLocalMusicModel, normalizeStoryMusicModel, storySongDurationBounds } from './musicModel'
 import { musicCandidateDisplayName, storySongBrief } from './storyLabMusic'
 import type { StoryProductionsTabProps } from './storyLabProductions'
 
@@ -57,9 +57,9 @@
         aria-label={t('productions.songBriefAria')} />
       <div className="grid grid-cols-2 gap-2">
         <label className="block text-[10px] text-text-muted">{t('productions.approxDuration')}
-          <input className={`${input} mt-1`} type="number" min={20} max={360} step={5}
+          <input className={`${input} mt-1`} type="number" min={storySongDurationBounds(project.music.model).min} max={storySongDurationBounds(project.music.model).max} step={5}
             value={project.music.targetDurationSeconds}
-            onChange={event => patch({ music: { ...project.music, targetDurationSeconds: Math.max(20, Math.min(360, Number(event.target.value) || 90)) } })} />
+            onChange={event => patch({ music: { ...project.music, targetDurationSeconds: clampStorySongDuration(Number(event.target.value) || 90, project.music.model) } })} />
         </label>
         <label className="block text-[10px] text-text-muted">{t('productions.candidates')}
           <select className={`${input} mt-1`} value={project.music.candidateCount}

diff --git a/ui/src/features/stories/StoryMusicSettingsBar.tsx b/ui/src/features/stories/StoryMusicSettingsBar.tsx
--- a/ui/src/features/stories/StoryMusicSettingsBar.tsx
+++ b/ui/src/features/stories/StoryMusicSettingsBar.tsx
@@ -1,7 +1,7 @@
 import { Loader2, Palette, RefreshCcw } from 'lucide-react'
 import { useUiTranslation } from '../../i18n'
 import { button, input, panel } from './storyLabChrome'
-import { ACE_STEP_MUSIC_MODEL, MINIMAX_MUSIC3_LOCAL_MODEL, isLocalMusicModel, normalizeStoryMusicModel } from './musicModel'
+import { ACE_STEP_MUSIC_MODEL, MINIMAX_MUSIC3_LOCAL_MODEL, clampStorySongDuration, isLocalMusicModel, normalizeStoryMusicModel } from './musicModel'
 import type { StoryMusicTabProps } from './StoryMusicTab'
 import { useStore } from '../../stores/useStore'
 import { modelRequirementsText } from '../../lib/minimaxMusicCatalog'
@@ -21,7 +21,20 @@
       <div className={`${panel} mb-4 grid md:grid-cols-[1fr_1fr_2fr] gap-3 items-end`}>
         <label className="block text-[10px] text-text-muted">{t('music.songModel')}
           <select className={`${input} mt-1`} value={project.music.model} title={resourceHint || undefined}
-            onChange={event => patch({ music: { ...project.music, model: normalizeStoryMusicModel(event.target.value) } })}>
+            onChange={event => {
+              const model = normalizeStoryMusicModel(event.target.value)
+              patch({
+                music: {
+                  ...project.music,
+                  model,
+                  targetDurationSeconds: clampStorySongDuration(project.music.targetDurationSeconds, model),
+                  cues: project.music.cues.map(cue => ({
+                    ...cue,
+                    durationSeconds: clampStorySongDuration(cue.durationSeconds, model),
+                  })),
+                },
+              })
+            }}>
             <option value={ACE_STEP_MUSIC_MODEL}>{t('music.aceStepDefault')}</option>
             <option value={MINIMAX_MUSIC3_LOCAL_MODEL}>{t('music.music30Local')}</option>
             <option value="music-3.0">{t('music.music30Unavailable')}</option>

diff --git a/ui/src/features/stories/StoryOverviewTab.tsx b/ui/src/features/stories/StoryOverviewTab.tsx
--- a/ui/src/features/stories/StoryOverviewTab.tsx
+++ b/ui/src/features/stories/StoryOverviewTab.tsx
@@ -6,6 +6,7 @@
   type StoryLabSectionTabProps,
 } from './storyLabChrome'
 import { storyRenderStyle } from './model'
+import { clampStorySongDuration, storySongDurationBounds } from './musicModel'
 import { storyContentLanguagePatch, storySpokenLanguagePatch } from './languageIntent'
 import { StoryProviderPanel } from './StoryProviderPanel'
 import type { StoryProject } from './types'
@@ -111,9 +112,9 @@
           <div className="md:col-span-2"><Field required label={t('overview.songStory')} value={project.creativeBrief.songStory} onChange={songStory => patch({ creativeBrief: { ...project.creativeBrief, songStory }, music: { ...project.music, brief: songStory } })} rows={5} placeholder={t('overview.songStoryPlaceholder')} /></div>
           <label className="block text-[10px] text-text-muted">
             {t('overview.targetDuration', { seconds: project.creativeBrief.durationSeconds })}
-            <input type="range" min={30} max={360} step={5} className="mt-2 w-full accent-accent-blue" value={project.creativeBrief.durationSeconds}
+            <input type="range" min={30} max={storySongDurationBounds(project.music.model).max} step={5} className="mt-2 w-full accent-accent-blue" value={project.creativeBrief.durationSeconds}
               onChange={event => {
-                const durationSeconds = Number(event.target.value)
+                const durationSeconds = clampStorySongDuration(Number(event.target.value), project.music.model)
                 patch({ creativeBrief: { ...project.creativeBrief, durationSeconds }, music: { ...project.music, targetDurationSeconds: durationSeconds } })
               }} />
           </label>

diff --git a/ui/src/features/stories/actions.ts b/ui/src/features/stories/actions.ts
--- a/ui/src/features/stories/actions.ts
+++ b/ui/src/features/stories/actions.ts
@@ -28,7 +28,7 @@
   buildMusicVideoProduction,
   validateMusicVideoStaging,
 } from './musicWorkflowState'
-import { resolveStoryMusicModel } from './musicModel'
+import { clampStorySongDuration, resolveStoryMusicModel } from './musicModel'
 import type {
   ApplyStoryProposalCommand,
   ApproveStorySectionCommand,
@@ -167,7 +167,6 @@
   if (current.activeProjectOperations[target.id]) throw new Error(`La historia “${target.title}” tiene una operación activa.`)
   const lyricsLanguage = resolveStorySongLanguage(action.lyricsLanguage, languageIntent, target.language)
   const protectedLyrics = protectedSongLyrics(languageIntent)
-  const durationSeconds = boundedDuration(action.durationSeconds, target.music.targetDurationSeconds)
   const model = resolveStoryMusicModel(
     action.model,
     target.music.model,
@@ -177,6 +176,10 @@
       is_downloaded: item.is_downloaded,
     })),
   )
+  const durationSeconds = clampStorySongDuration(
+    boundedDuration(action.durationSeconds, target.music.targetDurationSeconds),
+    model,
+  )
   const brief = action.brief.trim() || target.music.brief || target.creativeBrief.songStory || target.premise
   const semanticAnchors = storySongSemanticAnchors({
     premise: target.premise, theme: target.theme, songStory: target.creativeBrief.songStory, brief,
@@ -317,7 +320,7 @@
       ), { medium: 'music' }),
       lyrics: cue.instrumental ? '[Instrumental]' : cue.lyrics,
       instrumental: cue.instrumental,
-      duration_seconds: cue.durationSeconds,
+      duration_seconds: clampStorySongDuration(cue.durationSeconds, target.music.model),
       model_type: target.music.model,
       workspace,
       initiator: `Story Lab · ${target.projectType === 'music_video' ? 'Videoclip' : 'Story song'}`,

diff --git a/ui/src/features/stories/model.ts b/ui/src/features/stories/model.ts
--- a/ui/src/features/stories/model.ts
+++ b/ui/src/features/stories/model.ts
@@ -6,7 +6,7 @@
   DEFAULT_DIRECT_VIDEO_MASTER_PROMPT,
   LEGACY_HEAVY_METAL_DIRECT_VIDEO_MASTER_PROMPT,
 } from '../../types/index.ts'
-import { normalizeStoryMusicModel } from './musicModel'
+import { clampStorySongDuration, normalizeStoryMusicModel } from './musicModel'
 import { normalizeLanguageIntent } from '../../lib/languageIntent'
 
 export type StorySection = 'overview' | 'world' | 'characters' | 'relationships' | 'structure'
@@ -194,7 +194,7 @@
   return Object.keys(result).length ? result : undefined
 }
 
-function normalizeMusicCue(value: unknown, index: number, now: string): StoryMusicCue | null {
+function normalizeMusicCue(value: unknown, index: number, now: string, model?: string): StoryMusicCue | null {
   if (!value || typeof value !== 'object') return null
   const cue = value as Partial<StoryMusicCue>
   const kind = cue.kind === 'world' || cue.kind === 'character' ? cue.kind : 'story'
@@ -212,7 +212,7 @@
     lyricsLanguage: text(cue.lyricsLanguage) || undefined,
     lyriaPrompt: text(cue.lyriaPrompt),
     instrumental: cue.instrumental === true,
-    durationSeconds: Math.max(20, Math.min(360, Number(cue.durationSeconds) || 90)),
+    durationSeconds: clampStorySongDuration(Number(cue.durationSeconds) || 90, model),
     candidates: Array.isArray(cue.candidates)
       ? cue.candidates.flatMap(candidate => normalizeMusicCandidate(candidate, now) || []) : [],
     selectedCandidateId: text(cue.selectedCandidateId) || undefined,
@@ -426,6 +426,7 @@
   const fallback = createStoryProject()
   if (!value || typeof value !== 'object') return fallback
   const project = value as Partial<StoryProject>
+  const musicModel = normalizeStoryMusicModel(project.music?.model)
   const creativeBrief = project.creativeBrief && typeof project.creativeBrief === 'object'
     ? project.creativeBrief : fallback.creativeBrief
   const world: Partial<StoryProject['world']> =
@@ -621,7 +622,7 @@
       : {},
     music: {
       mode: project.music?.mode === 'cover' ? 'cover' : 'original',
-      model: normalizeStoryMusicModel(project.music?.model),
+      model: musicModel,
       brief: text(project.music?.brief),
       style: text(project.music?.style),
       sourceLyrics: text(project.music?.sourceLyrics),
@@ -629,10 +630,13 @@
       lyricsLanguage: text(project.music?.lyricsLanguage) || undefined,
       coverReferenceFilename: text(project.music?.coverReferenceFilename) || undefined,
       coverReferenceName: text(project.music?.coverReferenceName) || undefined,
-      targetDurationSeconds: Math.max(20, Math.min(360, Number(project.music?.targetDurationSeconds) || 90)),
+      targetDurationSeconds: clampStorySongDuration(
+        Number(project.music?.targetDurationSeconds) || 90,
+        musicModel,
+      ),
       candidateCount: project.music?.candidateCount === 3 ? 3 : 2,
       cues: Array.isArray(project.music?.cues)
-        ? project.music.cues.flatMap((cue, index) => normalizeMusicCue(cue, index, now) || []) : [],
+        ? project.music.cues.flatMap((cue, index) => normalizeMusicCue(cue, index, now, musicModel) || []) : [],
       candidates: Array.isArray(project.music?.candidates)
         ? project.music.candidates.flatMap(candidate => normalizeMusicCandidate(candidate, now) || [])
         : [],

diff --git a/ui/src/features/stories/musicModel.ts b/ui/src/features/stories/musicModel.ts
--- a/ui/src/features/stories/musicModel.ts
+++ b/ui/src/features/stories/musicModel.ts
@@ -60,3 +60,15 @@
   if (String(model || '') === MINIMAX_MUSIC3_LOCAL_MODEL) return 'minimax-music3'
   return isAceStepMusicModel(model) ? 'ace-step' : 'minimax'
 }
+
+export function storySongDurationBounds(model?: string): { min: number; max: number } {
+  if (String(model || '') === MINIMAX_MUSIC3_LOCAL_MODEL) return { min: 20, max: 300 }
+  return { min: 20, max: 360 }
+}
+
+export function clampStorySongDuration(seconds: number, model?: string, fallback = 90): number {
+  const { min, max } = storySongDurationBounds(model)
+  const value = Number(seconds)
+  const resolved = Number.isFinite(value) && value > 0 ? value : fallback
+  return Math.max(min, Math.min(max, resolved))
+}

diff --git a/ui/tests/storyMusicModel.test.ts b/ui/tests/storyMusicModel.test.ts
--- a/ui/tests/storyMusicModel.test.ts
+++ b/ui/tests/storyMusicModel.test.ts
@@ -1,6 +1,6 @@
 import assert from 'node:assert/strict'
 import test from 'node:test'
-import { ACE_STEP_MUSIC_MODEL, normalizeStoryMusicModel, songWriteTarget } from '../src/features/stories/musicModel.ts'
+import { ACE_STEP_MUSIC_MODEL, clampStorySongDuration, normalizeStoryMusicModel, songWriteTarget } from '../src/features/stories/musicModel.ts'
 
 test('new stories default to ACE-Step and keep MiniMax only when chosen', () => {
   assert.equal(normalizeStoryMusicModel(''), ACE_STEP_MUSIC_MODEL)
@@ -9,4 +9,7 @@
   assert.equal(normalizeStoryMusicModel('music-2.6'), 'music-2.6')
   assert.equal(songWriteTarget(ACE_STEP_MUSIC_MODEL), 'ace-step')
   assert.equal(songWriteTarget('music-3.0'), 'minimax')
+  assert.equal(songWriteTarget('minimax_music3'), 'minimax-music3')
+  assert.equal(clampStorySongDuration(360, 'minimax_music3'), 300)
+  assert.equal(clampStorySongDuration(360, ACE_STEP_MUSIC_MODEL), 360)
 })

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

Comment thread app/routers/llm.py Outdated
Comment thread app/defaults/minimax_music3.json
Comment thread ui/src/features/agent/capabilityRegistry.ts
Comment thread ui/src/api/llm.ts
Preserve multiline MiniMax-Music3 captions instead of applying remote API
truncation, require every weight shard before marking the model ready, clamp
Story duration to the 300s Music3 limit, and let local backends generate
without a MiniMax API key while holding the production busy lock.
Studio Audio now sends the selected song-writer target, model and duration
so MiniMax-Music3 gets a structured caption. Wizard configure_story_song
clamps Music3 duration to 300 seconds.
@IAnMove
IAnMove merged commit a899a8c into main Sep 5, 2026
4 checks passed
@IAnMove
IAnMove deleted the feat/local-minimax-music3 branch September 5, 2026 11:52

@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 default effort and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix is ON, but it could not run because the branch was deleted or merged before autofix could start.

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

Reviewed by Cursor Bugbot for commit 9328718. Configure here.

model: ACE_STEP_MUSIC_MODEL,
durationSeconds: current.music.targetDurationSeconds,
model: current.music.model,
durationSeconds,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Generate All still requires API key

Medium Severity

The header Generate All control now treats local Music3 and ACE-Step as ready without a MiniMax key, but generateAllMusicCues still returns minimaxKeyFirst whenever minimax_api_key_set is missing. A Story with a local backend can click the newly enabled button and fail immediately, even though per-cue generate already works.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9328718. Configure here.

model: modelType === 'minimax_music3' || modelType === 'music-3.0' || modelType === 'music-2.6' || modelType.startsWith('ace_step')
? modelType as 'minimax_music3' | 'music-3.0' | 'music-2.6' | 'ace_step_v1_5_xl_sft_lm_4b'
: undefined,
duration_seconds: clampStoryMusicDuration(params.duration_seconds, modelType),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Studio write uses Story duration floor

Low Severity

Studio Write Song now sends clampStoryMusicDuration, which floors every request at 20 seconds and defaults missing values to 90. MiniMax-Music3’s Studio slider and writer contract allow 5–300 seconds and default to 120, so short Studio tracks get a mismatched runtime brief.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9328718. Configure here.

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