feat: add local MiniMax Music3 generation - #135
Conversation
PR Review — Loreframe StudioRisk: medium Automated review from Findings
Changed files
CONTRIBUTING checklist
Posted by the repo PR review workflow. Re-runs on each push to the PR. |
Code healthQuality score: 49.2/100Higher is better. The score is a trend dashboard; the independent ratchet below remains the CI gate.
Change vs PR base: +0.0 points.
Markdown, JSON catalogs and tests are out of this table. Only Most complex functions
Trend vs baseline
Warnings
Ratchet passed. |
|
cursor review |
There was a problem hiding this comment.
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.
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.
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.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.
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, |
There was a problem hiding this comment.
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)
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), |
There was a problem hiding this comment.
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.
Reviewed by Cursor Bugbot for commit 9328718. Configure here.



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
Riesgo
Estado
Summary
productionBusyfor local generatedownload_modelOverview
Adds local MiniMax-Music3 as a first-class music backend: native components, a single-GPU pipeline, Hugging Face download handler, and
required_model_assetscovering all safetensor shards plus tokenizer/license. Story Lab, Studio Audio and the Wizard share the same catalog. The song-writer targetminimax-music3keeps### Global Metadata/### Vocal Details/### Arrangement. Remote MiniMax API truncation stays only ontarget=minimax.Detailed changes
Backend
00001-00004and transformer00001-00002.UI and Wizard
download_modelcapability with confirmation.productionBusywraps local Music3 generate.Data, provenance and compatibility
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— PASStests/test_minimax_music3_local.py tests/test_minimax_song_writer_prompt.py— 10 passedbash scripts/validate_local.sh— PASS (Python contracts, code-health vsorigin/main, 609 UI tests, lint, build, 7 simulated E2E)git diff --check— clean97033e7e— pending after pushbash scripts/run_real_media_smoke.shwithHOCUSPOCUS_SMOKE_CONFIRM=GENERATE_REAL_MEDIACode quality
CI and review
97033e7eCursor findings resolved:
_normalize_minimax_song_output.productionBusy.required_model_assetsincludes every safetensor shard.The
eval()flagged by the heuristic PR review is PyTorch.eval(), not Pythoneval().Coste de la tarea
Notes and limitations
Follow-up work
_launch_runtime.py)._launch_runtime.pywhile this one is open.Checklist
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 checksrequired_model_assetsso a tiny manifest alone cannot mark a partial ~28 GB install as ready.Director / LLM:
/api/v1/director/generate-musicand the song writer gain aminimax-music3target with structured-caption guides, runtime-scaled lyrics, higher token limits, and a 3600s job timeout for long local decodes. API model listings exposeresource_requirementsfor 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 candownload_model(opens Settings, waits for completion) and seesavailable_audio_modelsin context.Tooling: Local validation compares code-health to the PR base via
check_code_health_pr_base.shinstead 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.