From 36d24feb9c7c41f37b810149f672df082d0d5b56 Mon Sep 17 00:00:00 2001 From: Towsty Date: Wed, 16 Sep 2026 14:56:16 -0500 Subject: [PATCH] Allow Studio 2 MiniMax text-to-video with empty start when locks are off. Co-authored-by: Cursor --- server/utils/studio2/runner.ts | 9 +++++---- shared/studio2/contracts.mjs | 1 - shared/studio2/graphs.mjs | 11 +++++++++++ tests/studio2-controls.test.mjs | 11 +++++++++++ 4 files changed, 27 insertions(+), 5 deletions(-) diff --git a/server/utils/studio2/runner.ts b/server/utils/studio2/runner.ts index abcd82d..365d89a 100644 --- a/server/utils/studio2/runner.ts +++ b/server/utils/studio2/runner.ts @@ -89,11 +89,12 @@ async function prepareGraph(r: any) { const { frameLength } = await import('../videoChain'); const { ltxWorkflowEnabled, LTX_DISABLED_MESSAGE } = await import('~/utils/videoModels'); 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; graph = buildWorkflow({ - workflow: 'ltx', - imageName: start, + workflow: textToVideo ? 'ltx-t2v' : 'ltx', + imageName: start || '', prompt: q.compiledPrompt, width: s.width, height: s.height, @@ -111,7 +112,7 @@ async function prepareGraph(r: any) { useIdentityRefs: false }); await ensureComfyLoraNames('video'); - r.graphId = 'workflow_ltx_video.json'; + r.graphId = textToVideo ? 'workflow_ltx_video.json#t2v' : 'workflow_ltx_video.json'; } else { if (q.engine !== 'minimax') throw new Error('Studio 2 video supports MiniMax H3 and LTX (xAIGen).'); diff --git a/shared/studio2/contracts.mjs b/shared/studio2/contracts.mjs index 7009d97..68793f2 100644 --- a/shared/studio2/contracts.mjs +++ b/shared/studio2/contracts.mjs @@ -52,7 +52,6 @@ export function validateRequest(raw, xaigen = false) { 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.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 shotSeeds=queueSeeds(raw.settings,1+(Array.isArray(raw.batch)?raw.batch.length:0)) diff --git a/shared/studio2/graphs.mjs b/shared/studio2/graphs.mjs index 6c8f32d..66fb353 100644 --- a/shared/studio2/graphs.mjs +++ b/shared/studio2/graphs.mjs @@ -2,9 +2,20 @@ export function nativeVideoGraph(template, request, files, prefix) { const appearance = request.lockFace !== false || request.lockOutfit !== false if (appearance && !files.hero) throw new Error('Identity still is required; never substitute the start frame.') 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] } 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'}) + 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] 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 diff --git a/tests/studio2-controls.test.mjs b/tests/studio2-controls.test.mjs index 0ace347..e26ff21 100644 --- a/tests/studio2-controls.test.mjs +++ b/tests/studio2-controls.test.mjs @@ -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') 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',()=>{ const empty=compilePrompt({},true);assert.doesNotMatch(empty,/Soundscape:|/);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,/ \(S1\) says \[English\]Hello<\/d>/)