Allow Studio 2 MiniMax text-to-video with empty start when locks are off.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Towsty
2026-09-16 14:56:16 -05:00
co-authored by Cursor
parent 5694104226
commit 36d24feb9c
4 changed files with 27 additions and 5 deletions
+5 -4
View File
@@ -89,11 +89,12 @@ async function prepareGraph(r: any) {
const { frameLength } = await import('../videoChain'); const { frameLength } = await import('../videoChain');
const { ltxWorkflowEnabled, LTX_DISABLED_MESSAGE } = await import('~/utils/videoModels'); const { ltxWorkflowEnabled, LTX_DISABLED_MESSAGE } = await import('~/utils/videoModels');
if (!ltxWorkflowEnabled()) throw new Error(LTX_DISABLED_MESSAGE); if (!ltxWorkflowEnabled()) throw new Error(LTX_DISABLED_MESSAGE);
if (!start) throw new Error('Choose a start still for LTX video.'); const textToVideo = q.mode === 'video' && !start;
if (!start && !textToVideo) throw new Error('Choose a start still for LTX video.');
const fps = s.fps || 24; const fps = s.fps || 24;
graph = buildWorkflow({ graph = buildWorkflow({
workflow: 'ltx', workflow: textToVideo ? 'ltx-t2v' : 'ltx',
imageName: start, imageName: start || '',
prompt: q.compiledPrompt, prompt: q.compiledPrompt,
width: s.width, width: s.width,
height: s.height, height: s.height,
@@ -111,7 +112,7 @@ async function prepareGraph(r: any) {
useIdentityRefs: false useIdentityRefs: false
}); });
await ensureComfyLoraNames('video'); await ensureComfyLoraNames('video');
r.graphId = 'workflow_ltx_video.json'; r.graphId = textToVideo ? 'workflow_ltx_video.json#t2v' : 'workflow_ltx_video.json';
} else { } else {
if (q.engine !== 'minimax') if (q.engine !== 'minimax')
throw new Error('Studio 2 video supports MiniMax H3 and LTX (xAIGen).'); throw new Error('Studio 2 video supports MiniMax H3 and LTX (xAIGen).');
-1
View File
@@ -52,7 +52,6 @@ export function validateRequest(raw, xaigen = false) {
if (video) { if (video) {
if (promptSections.cameraBeats.slice(0,-1).some(b=>b.durationSec == null)) fail('Only the last camera beat can run to the end.') if (promptSections.cameraBeats.slice(0,-1).some(b=>b.durationSec == null)) fail('Only the last camera beat can run to the end.')
if (promptSections.cameraBeats.reduce((sum,b)=>sum+(b.durationSec || 0),0)>duration) fail('Camera beats exceed the clip duration.') if (promptSections.cameraBeats.reduce((sum,b)=>sum+(b.durationSec || 0),0)>duration) fail('Camera beats exceed the clip duration.')
if (raw.lockFace === false && raw.lockOutfit === false && !raw.imageAId && !raw.startClipId) fail('Choose a start still or previous clip when both identity locks are off.')
} }
const frames=Math.ceil((duration*number('fps',24,1,60)-5)/17)*17+5 const frames=Math.ceil((duration*number('fps',24,1,60)-5)/17)*17+5
const shotSeeds=queueSeeds(raw.settings,1+(Array.isArray(raw.batch)?raw.batch.length:0)) const shotSeeds=queueSeeds(raw.settings,1+(Array.isArray(raw.batch)?raw.batch.length:0))
+11
View File
@@ -2,9 +2,20 @@ export function nativeVideoGraph(template, request, files, prefix) {
const appearance = request.lockFace !== false || request.lockOutfit !== false const appearance = request.lockFace !== false || request.lockOutfit !== false
if (appearance && !files.hero) throw new Error('Identity still is required; never substitute the start frame.') if (appearance && !files.hero) throw new Error('Identity still is required; never substitute the start frame.')
const g=structuredClone(template), s=request.settings const g=structuredClone(template), s=request.settings
const textToVideo = request.mode === 'video' && !appearance && !files.start
if (appearance) { g.hero.inputs.image=files.hero; g['155'].inputs['ref_images.ref_image_0']=['hero',0] } if (appearance) { g.hero.inputs.image=files.hero; g['155'].inputs['ref_images.ref_image_0']=['hero',0] }
else { delete g.hero; delete g['155'].inputs['ref_images.ref_image_0'] } else { delete g.hero; delete g['155'].inputs['ref_images.ref_image_0'] }
Object.assign(g['155'].inputs,{prompt:request.compiledPrompt,width:s.width,height:s.height,length:Math.ceil((s.duration*(s.fps || 24)-5)/17)*17+5,ref_image_size:'max'}) Object.assign(g['155'].inputs,{prompt:request.compiledPrompt,width:s.width,height:s.height,length:Math.ceil((s.duration*(s.fps || 24)-5)/17)*17+5,ref_image_size:'max'})
if (textToVideo) {
// Studio 1 T2V: MiniMaxH3ImageToVideo with first_frame disconnected (no empty placeholder still).
g['155'].class_type = 'MiniMaxH3ImageToVideo'
g['155']._meta = { title: 'MiniMax H3 Text to Video' }
delete g['155'].inputs['ref_images.ref_image_0']
delete g['155'].inputs.ref_image_size
delete g['155'].inputs.audio_vae
delete g['155'].inputs.first_frame
delete g['155'].inputs.last_frame
}
let conditioning=['155',0] let conditioning=['155',0]
for (const [name,image,frame] of [['start',files.start,0],['end',files.end,-1],...(files.guides||[]).map((f,i)=>[`guide${i}`,f.image,f.frame])]) { for (const [name,image,frame] of [['start',files.start,0],['end',files.end,-1],...(files.guides||[]).map((f,i)=>[`guide${i}`,f.image,f.frame])]) {
if (!image) continue if (!image) continue
+11
View File
@@ -36,6 +36,17 @@ test('appearance roles distinguish wardrobe lock and both-off graph excludes her
const graph=nativeVideoGraph(template,r,{hero:'ignored.png',start:'start.png'},'preview') const graph=nativeVideoGraph(template,r,{hero:'ignored.png',start:'start.png'},'preview')
assert.equal(graph.hero,undefined);assert.equal(graph['155'].inputs['ref_images.ref_image_0'],undefined);assert.equal(graph.load_start.inputs.image,'start.png');assert.equal(r.identityStillId,'hero') assert.equal(graph.hero,undefined);assert.equal(graph['155'].inputs['ref_images.ref_image_0'],undefined);assert.equal(graph.load_start.inputs.image,'start.png');assert.equal(r.identityStillId,'hero')
}) })
test('video with locks off and no assets is MiniMax T2V empty first frame',()=>{
const r=validateRequest({mode:'video',engine:'minimax',folderId:'f',lockFace:false,lockOutfit:false,identityStillId:'',imageAId:'',promptSections:{action:'Wide empty street at dawn'}})
assert.equal(r.lockFace,false);assert.equal(r.lockOutfit,false);assert.equal(r.imageAId,'');assert.equal(r.startClipId,'')
const template=JSON.parse(fs.readFileSync(new URL('../server/assets/studio2_minimax_native.json',import.meta.url)))
const graph=nativeVideoGraph(template,r,{hero:'',start:''},'preview/t2v')
assert.equal(graph['155'].class_type,'MiniMaxH3ImageToVideo')
assert.equal(graph['155'].inputs.first_frame,undefined)
assert.equal(graph.load_start,undefined)
assert.equal(graph.hero,undefined)
assert.throws(()=>validateRequest({mode:'extend',engine:'minimax',folderId:'f',lockFace:false,lockOutfit:false,promptSections:{action:'Continue'}}),e=>/Previous clip|family strip/i.test(e.message))
})
test('sound/dialogue/music compile without empty invented audio',()=>{ test('sound/dialogue/music compile without empty invented audio',()=>{
const empty=compilePrompt({},true);assert.doesNotMatch(empty,/Soundscape:|<d>/);assert.match(empty,/non_diegetic_music: N\/A/) const empty=compilePrompt({},true);assert.doesNotMatch(empty,/Soundscape:|<d>/);assert.match(empty,/non_diegetic_music: N\/A/)
const text=compilePrompt({soundscapeChips:['footsteps'],dialogue:{mode:'spoken off-screen',line:'Hello'}},true);assert.match(text,/lips remain closed/);assert.match(text,/<Subject 1> \(S1\) says <d>\[English\]Hello<\/d>/) const text=compilePrompt({soundscapeChips:['footsteps'],dialogue:{mode:'spoken off-screen',line:'Hello'}},true);assert.match(text,/lips remain closed/);assert.match(text,/<Subject 1> \(S1\) says <d>\[English\]Hello<\/d>/)