feat(video): absolute per-request minimax shift on every path

Shift is a property of the trained schedule, not of the step count, so
the tab sliders take absolute values, defaulting to the shipped 12 and
3. video_minimax resolves each request from the request value or the
scheduler config inside apply_overrides, which the tab, the api and
the still path all call, so a request without values lands on the
shipped schedule. The api maps sampler_shift onto the video schedule
and gains audio_shift. Applied values are recorded as Video shift and
Audio shift; the PDD pin records what it enforces.
This commit is contained in:
CalamitousFelicitousness
2026-09-15 23:01:41 +01:00
parent 5a2870f345
commit 29fe895c0b
11 changed files with 265 additions and 33 deletions
+3 -1
View File
@@ -22,8 +22,9 @@ class ReqVideo(BaseModel):
frames: int = Field(default=17, ge=1, le=1024, title="Frames", description="Number of frames; 1 produces a single still image on workflow models")
steps: int = Field(default=50, ge=1, le=200, title="Steps", description="Number of inference steps")
sampler_name: str = Field(default="Default", title="Sampler", description="Sampler name; Default keeps the model scheduler")
sampler_shift: float = Field(default=-1.0, title="Sampler shift", description="Scheduler flow shift; -1 keeps the model default")
sampler_shift: float = Field(default=-1.0, title="Sampler shift", description="Scheduler flow shift, the video schedule on models with a separate audio schedule; -1 keeps the model default")
dynamic_shift: bool = Field(default=False, title="Dynamic shift", description="Enable dynamic scheduler shifting")
audio_shift: float = Field(default=-1.0, title="Audio shift", description="Audio schedule shift on models with a separate audio scheduler; -1 keeps the model default")
seed: int = Field(default=-1, title="Seed", description="Generation seed; -1 for random")
guidance_scale: float = Field(default=-1.0, title="Guidance scale", description="CFG scale; -1 keeps the model default")
guidance_true: float = Field(default=-1.0, title="True guidance", description="True CFG scale; -1 keeps the model default")
@@ -173,6 +174,7 @@ class APIVideo:
sampler_name=sampler_name,
sampler_shift=req.sampler_shift,
dynamic_shift=req.dynamic_shift,
audio_shift=req.audio_shift,
seed=req.seed,
guidance_scale=req.guidance_scale,
guidance_true=req.guidance_true,
+5 -1
View File
@@ -20,10 +20,11 @@ EXTRAS_KEY = 'pdd'
class ArchSpec:
"""How an architecture hosts parallel heads: the scheduler behind each head and how interval counts map onto its num_inference_steps."""
def __init__(self, schedulers=None, default_scheduler='scheduler', steps_for=None):
def __init__(self, schedulers=None, default_scheduler='scheduler', steps_for=None, shift_keys=None):
self.schedulers = schedulers or {} # head path -> attribute of the scheduler the head was trained on
self.default_scheduler = default_scheduler
self.steps_for = steps_for or (lambda intervals: intervals) # num_inference_steps that yields this many grid intervals
self.shift_keys = shift_keys or {} # scheduler attribute -> infotext key the pinned shift is recorded under
def scheduler_name(self, head):
return self.schedulers.get(head, self.default_scheduler)
@@ -301,5 +302,8 @@ def pin(p, model):
p.task_args['num_inference_steps'] = state.steps
if getattr(model, 'num_timesteps', None) is not None:
model.num_timesteps = state.heads.nfe # the progress total counts transformer evaluations
extra = getattr(p, 'extra_generation_params', None)
if extra is not None:
extra.update({state.spec.shift_keys[name]: shift for name, shift in shifts.items() if name in state.spec.shift_keys})
log.info(f'Network: type=PDD name="{state.name}" steps={state.steps} requested={requested} nfe={state.heads.nfe} shift={shifts}')
return state.steps
+2 -2
View File
@@ -29,8 +29,8 @@ def create_ui(prompt, _negative, styles, overrides, script_inputs, mp4_fps, mp4_
steps = gr.Slider(minimum=2, maximum=100, step=1, label="MiniMax steps", elem_id='minimax_steps', value=30)
frames = gr.Slider(label='MiniMax frames', minimum=22, maximum=362, step=17, value=124, elem_id='minimax_frames')
with gr.Row():
video_shift = gr.Slider(minimum=0.05, maximum=0.95, step=0.05, value=0.40, label="MiniMax video shift", elem_id='minimax_video_shift')
audio_shift = gr.Slider(minimum=0.05, maximum=0.95, step=0.05, value=0.15, label="MiniMax audio shift", elem_id='minimax_audio_shift')
video_shift = gr.Slider(minimum=0.5, maximum=20.0, step=0.1, value=12.0, label="MiniMax video shift", elem_id='minimax_video_shift')
audio_shift = gr.Slider(minimum=0.5, maximum=10.0, step=0.1, value=3.0, label="MiniMax audio shift", elem_id='minimax_audio_shift')
with gr.Row():
seed = gr.Number(label='Seed', value=-1, elem_id='minimax_seed', container=True)
random_seed = ToolButton(ui_symbols.random, elem_id='minimax_seed_random')
+1 -2
View File
@@ -144,8 +144,7 @@ def generate(task_id, _ui_state,
outpath_samples=paths.resolve_output_path(shared.opts.outdir_samples, shared.opts.outdir_video),
ops=['video'],
)
video_minimax.apply_overrides(p, shared.sd_model, still=False, audio=enable_audio, preview=enable_preview)
video_minimax.set_sampler_shift(shared.sd_model, steps=steps, video_shift=video_shift, audio_shift=audio_shift)
video_minimax.apply_overrides(p, shared.sd_model, still=False, audio=enable_audio, preview=enable_preview, video_shift=video_shift, audio_shift=audio_shift)
log.debug(f'Video: engine="{engine}" model="{model}" workflow={workflow} cls={shared.sd_model.__class__.__name__} audio={enable_audio} preview={enable_preview} kwargs={p.task_args}')
processing.fix_seed(p)
p.ops.append('video')
+22 -22
View File
@@ -3,11 +3,12 @@ from modules.logger import log
MIN_LATENT_FRAMES = 7 # decoder floor: fewer latent frames leave the chunked decode with nothing to emit
SHIFT_KEYS = {'scheduler': 'Video shift', 'audio_scheduler': 'Audio shift'} # infotext key per schedule
def apply_overrides(p, pipe, still: bool = False, audio: bool = True, preview: bool = False):
"""Per-generation constraints shared by the video tab and the image path: canvas and frame
alignment, the bespoke scheduler guard, tiling, and the audio/still toggles."""
def apply_overrides(p, pipe, still: bool = False, audio: bool = True, preview: bool = False, video_shift: float | None = None, audio_shift: float | None = None):
"""Per-generation constraints shared by the video tab, the api and the image path: canvas and frame
alignment, the bespoke scheduler guard, the schedule shifts, tiling, and the audio/still toggles."""
if still:
audio = False # a sub-second soundtrack is pure waste on a kept single frame
multiple = pipe.canvas_multiple
@@ -34,6 +35,7 @@ def apply_overrides(p, pipe, still: bool = False, audio: bool = True, preview: b
if p.sampler_name not in ('None', 'Default'):
log.warning(f'Pipeline: cls={pipe.__class__.__name__} sampler={p.sampler_name} unsupported: using model default')
p.sampler_name = 'Default' # the model default is the bespoke scheduler pair, which discrete samplers must not replace
p.extra_generation_params.update(set_sampler_shift(pipe, video_shift=video_shift, audio_shift=audio_shift))
pipe.vae.enable_tiling() # model always tiles; the shared vae params path may have disabled it
set_audio(pipe, audio)
p.task_args['output'] = ['videos', 'audio', 'sampling_rate'] if audio else ['videos']
@@ -91,29 +93,27 @@ def set_audio(pipe, enabled: bool):
log.debug(f'Pipeline: cls={pipe.__class__.__name__} audio=disabled')
def calculate_video_shift(steps: int, value: float = 0.40, max_shift: float = 16.0) -> float:
value = max(0.05, min(0.95, value))
return min(max_shift, round((value * steps) + 0.5))
def resolve_shift(scheduler, requested: float | None = None) -> float:
"""The shift one request lands on: a positive request value, else the value the scheduler config ships."""
if requested is not None and requested > 0:
return float(requested)
return float(scheduler.config['shift'])
def calculate_audio_shift(steps: int, value: float = 0.15, max_shift: float = 6.0) -> float:
value = max(0.05, min(0.95, value))
return min(max_shift, round((value * steps) + 0.5))
def set_sampler_shift(pipe, steps: int, video_shift: float = 12.0, audio_shift: float = 3.0):
def set_sampler_shift(pipe, video_shift: float | None = None, audio_shift: float | None = None) -> dict:
"""Apply the video and audio schedule shift for one request; returns the applied values keyed for infotext.
Non-positive values resolve to the shipped schedule; default_scheduler is written too, since the Default
sampler restore copies it over scheduler each generation."""
scheduler = getattr(pipe, 'scheduler', None)
audio_scheduler = getattr(pipe, 'audio_scheduler', None)
if not hasattr(scheduler, 'set_shift') or not hasattr(audio_scheduler, 'set_shift'):
if any(not hasattr(s, 'set_shift') or 'shift' not in getattr(s, 'config', {}) for s in (scheduler, audio_scheduler)):
log.warning(f'Pipeline: cls={pipe.__class__.__name__} scheduler={scheduler.__class__.__name__} audio={audio_scheduler.__class__.__name__} shift unsupported')
return
video_calc_shift = calculate_video_shift(steps=steps, value=video_shift)
audio_calc_shift = calculate_audio_shift(steps=steps, value=audio_shift)
dct_video = { 'value': video_shift, 'shift': video_calc_shift }
dct_audio = { 'value': audio_shift, 'shift': audio_calc_shift }
# set_shift keeps the shipped value in config.shift; the default sampler restores scheduler from default_scheduler every generation, so that copy carries the shift too
return {}
video = resolve_shift(scheduler, video_shift)
audio = resolve_shift(audio_scheduler, audio_shift)
for target in (scheduler, getattr(pipe, 'default_scheduler', None)):
if hasattr(target, 'set_shift'):
target.set_shift(video_calc_shift)
audio_scheduler.set_shift(audio_calc_shift)
log.debug(f'Pipeline: scheduler={scheduler.__class__.__name__} video={dct_video} audio={dct_audio}')
target.set_shift(video)
audio_scheduler.set_shift(audio)
log.debug(f'Pipeline: cls={pipe.__class__.__name__} shift video={video} audio={audio} requested={video_shift}/{audio_shift}')
return {SHIFT_KEYS['scheduler']: video, SHIFT_KEYS['audio_scheduler']: audio}
+1 -1
View File
@@ -114,4 +114,4 @@ def set_overrides(p: processing.StableDiffusionProcessingVideo, selected: Model)
# MiniMax H3
if 'MiniMaxH3' in cls:
from modules.video_models import video_minimax
video_minimax.apply_overrides(p, shared.sd_model, still=getattr(p, 'video_still', False), audio=getattr(p, 'video_audio', True))
video_minimax.apply_overrides(p, shared.sd_model, still=getattr(p, 'video_still', False), audio=getattr(p, 'video_audio', True), video_shift=getattr(p, 'sampler_shift', None), audio_shift=getattr(p, 'audio_shift', None))
+2
View File
@@ -106,6 +106,7 @@ def run(selected: models_def.Model, *,
sampler_name: str = 'Default',
sampler_shift: float = -1.0,
dynamic_shift: bool = False,
audio_shift: float = -1.0,
seed: int = -1,
guidance_scale: float = -1.0,
guidance_true: float = -1.0,
@@ -162,6 +163,7 @@ def run(selected: models_def.Model, *,
seed=int(seed),
sampler_name=sampler_name,
sampler_shift=float(sampler_shift),
audio_shift=float(audio_shift),
steps=int(steps),
width=16 * int(width // 16),
height=16 * int(height // 16),
+2 -1
View File
@@ -16,10 +16,11 @@ import torch
from modules.logger import log
from modules.lora import native_adapter, network_pdd
from modules.video_models.video_minimax import SHIFT_KEYS
# Parallel decoding heads: the audio projection follows the audio schedule, and MiniMaxH3Scheduler counts the terminal sigma in num_inference_steps.
PDD = network_pdd.ArchSpec(schedulers={"audio_proj_out": "audio_scheduler"}, steps_for=lambda intervals: intervals + 1)
PDD = network_pdd.ArchSpec(schedulers={"audio_proj_out": "audio_scheduler"}, steps_for=lambda intervals: intervals + 1, shift_keys=SHIFT_KEYS)
KNOWN_PREFIXES = (
+223
View File
@@ -0,0 +1,223 @@
#!/usr/bin/env python
"""
Offline unit tests for the MiniMax schedule shift in modules.video_models.video_minimax.
- ``resolve_shift`` takes a positive request value and falls back to the scheduler config otherwise
- ``set_sampler_shift`` writes scheduler, default_scheduler and audio_scheduler and keys the result for infotext
- a request without values resets what the previous request set
- the Default sampler restore, a deepcopy of default_scheduler, carries the shift into the sigma grid
- ``apply_overrides`` records the applied values on the processing object
No running server required.
Usage:
python test/test-minimax-shift.py
"""
import os
import sys
import copy
import types
import torch
script_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, script_dir)
os.chdir(script_dir)
os.environ['SD_INSTALL_QUIET'] = '1'
# Bootstrap cmd_args before any module that pulls in shared.py.
import modules.cmd_args # pylint: disable=wrong-import-position
import installer # pylint: disable=wrong-import-position
_orig_argv = sys.argv
sys.argv = [sys.argv[0]]
try:
modules.cmd_args.parse_args()
finally:
sys.argv = _orig_argv
installer.add_args(modules.cmd_args.parser)
modules.cmd_args.parsed, _ = modules.cmd_args.parser.parse_known_args([])
import diffusers # pylint: disable=wrong-import-position
from modules.errors import log # pylint: disable=wrong-import-position
from modules.video_models import video_minimax # pylint: disable=wrong-import-position
VIDEO_SHIFT = 12.0
AUDIO_SHIFT = 3.0
STEPS = 5
results = {}
def category(name: str):
if name not in results:
results[name] = {'passed': 0, 'failed': 0, 'tests': []}
return name
def record(cat: str, passed: bool, name: str, detail: str = ''):
status = 'PASS' if passed else 'FAIL'
results[cat]['passed' if passed else 'failed'] += 1
results[cat]['tests'].append((status, name))
msg = f' {status}: {name}'
if detail:
msg += f' ({detail})'
if passed:
log.info(msg)
else:
log.error(msg)
def run_test(cat: str, fn):
name = fn.__name__
try:
ok = fn()
record(cat, ok is not False, name)
except AssertionError as e:
record(cat, False, name, str(e))
except Exception as e: # pylint: disable=broad-except
record(cat, False, name, f'{type(e).__name__}: {e}')
# ============================================================
# Fixtures
# ============================================================
class StubVae:
def enable_tiling(self):
pass
def decode(self, z, *args, **kwargs): # still mode wraps it with the latent-frame padding
return z
class StubPipe:
"""The parts of the modular pipeline the shim touches: the scheduler pair with the shipped copy, and the canvas and frame constants."""
canvas_multiple = 32
vae_frames_per_chunk = 17
vae_latents_per_chunk = 5
max_duration = 15.0
fps = 24
sdnext_supported_min_frames = 120
def __init__(self):
self.scheduler = diffusers.MiniMaxH3Scheduler(shift=VIDEO_SHIFT)
self.audio_scheduler = diffusers.MiniMaxH3Scheduler(shift=AUDIO_SHIFT)
self.default_scheduler = copy.deepcopy(self.scheduler)
self.vae = StubVae()
@property
def min_duration(self):
return 5.0
def make_p():
return types.SimpleNamespace(width=1024, height=576, steps=STEPS, frames=124, sampler_name='Default', task_args={}, extra_generation_params={})
def sigmas(shift: float):
scheduler = diffusers.MiniMaxH3Scheduler(shift=shift)
scheduler.set_timesteps(STEPS)
return scheduler.sigmas.detach().cpu()
def shifts(pipe):
return (pipe.scheduler.shift, pipe.default_scheduler.shift, pipe.audio_scheduler.shift)
# ============================================================
# Tests
# ============================================================
def test_resolve_prefers_positive_request():
scheduler = diffusers.MiniMaxH3Scheduler(shift=VIDEO_SHIFT)
assert video_minimax.resolve_shift(scheduler, 6.0) == 6.0
assert video_minimax.resolve_shift(scheduler, 0.5) == 0.5
for absent in (None, -1.0, 0.0):
assert video_minimax.resolve_shift(scheduler, absent) == VIDEO_SHIFT, f'requested={absent}'
def test_resolve_reads_the_shipped_value_not_the_live_one():
scheduler = diffusers.MiniMaxH3Scheduler(shift=VIDEO_SHIFT)
scheduler.set_shift(4.0)
assert video_minimax.resolve_shift(scheduler, None) == VIDEO_SHIFT
def test_set_writes_every_copy_and_keys_the_result():
pipe = StubPipe()
applied = video_minimax.set_sampler_shift(pipe, video_shift=6.0, audio_shift=4.0)
assert applied == {'Video shift': 6.0, 'Audio shift': 4.0}, f'applied={applied}'
assert shifts(pipe) == (6.0, 6.0, 4.0), f'shifts={shifts(pipe)}'
assert pipe.scheduler.config['shift'] == VIDEO_SHIFT and pipe.audio_scheduler.config['shift'] == AUDIO_SHIFT
def test_next_request_without_values_resets():
pipe = StubPipe()
video_minimax.set_sampler_shift(pipe, video_shift=6.0, audio_shift=4.0)
applied = video_minimax.set_sampler_shift(pipe)
assert applied == {'Video shift': VIDEO_SHIFT, 'Audio shift': AUDIO_SHIFT}, f'applied={applied}'
assert shifts(pipe) == (VIDEO_SHIFT, VIDEO_SHIFT, AUDIO_SHIFT), f'shifts={shifts(pipe)}'
applied = video_minimax.set_sampler_shift(pipe, video_shift=-1.0, audio_shift=-1.0)
assert applied == {'Video shift': VIDEO_SHIFT, 'Audio shift': AUDIO_SHIFT}, f'applied={applied}'
def test_default_sampler_restore_carries_the_shift():
pipe = StubPipe()
video_minimax.set_sampler_shift(pipe, video_shift=6.0)
pipe.scheduler = copy.deepcopy(pipe.default_scheduler) # sd_samplers.restore_default
pipe.scheduler.set_timesteps(STEPS)
assert torch.allclose(pipe.scheduler.sigmas.detach().cpu(), sigmas(6.0)), 'restored scheduler does not follow the requested shift'
assert not torch.allclose(pipe.scheduler.sigmas.detach().cpu(), sigmas(VIDEO_SHIFT)), 'requested shift did not change the grid'
def test_unsupported_scheduler_is_skipped():
pipe = StubPipe()
pipe.scheduler = diffusers.EulerDiscreteScheduler() # no set_shift and no shift in its config
assert video_minimax.set_sampler_shift(pipe, video_shift=6.0) == {}
assert pipe.audio_scheduler.shift == AUDIO_SHIFT
def test_apply_overrides_records_the_applied_values():
pipe = StubPipe()
p = make_p()
video_minimax.apply_overrides(p, pipe, still=False, audio=True, video_shift=6.0, audio_shift=-1.0)
assert p.extra_generation_params == {'Video shift': 6.0, 'Audio shift': AUDIO_SHIFT}, f'recorded={p.extra_generation_params}'
assert shifts(pipe) == (6.0, 6.0, AUDIO_SHIFT), f'shifts={shifts(pipe)}'
assert p.sampler_name == 'Default'
def test_apply_overrides_without_values_uses_the_shipped_schedule():
pipe = StubPipe()
video_minimax.set_sampler_shift(pipe, video_shift=6.0, audio_shift=4.0) # an earlier request on the same pipe
p = make_p()
video_minimax.apply_overrides(p, pipe, still=True, audio=False)
assert p.extra_generation_params == {'Video shift': VIDEO_SHIFT, 'Audio shift': AUDIO_SHIFT}, f'recorded={p.extra_generation_params}'
assert shifts(pipe) == (VIDEO_SHIFT, VIDEO_SHIFT, AUDIO_SHIFT), f'shifts={shifts(pipe)}'
# ============================================================
# Main
# ============================================================
def main():
cat = category('resolve')
for fn in (test_resolve_prefers_positive_request, test_resolve_reads_the_shipped_value_not_the_live_one):
run_test(cat, fn)
cat = category('apply')
for fn in (test_set_writes_every_copy_and_keys_the_result, test_next_request_without_values_resets, test_default_sampler_restore_carries_the_shift, test_unsupported_scheduler_is_skipped):
run_test(cat, fn)
cat = category('overrides')
for fn in (test_apply_overrides_records_the_applied_values, test_apply_overrides_without_values_uses_the_shipped_schedule):
run_test(cat, fn)
failed = sum(r['failed'] for r in results.values())
passed = sum(r['passed'] for r in results.values())
log.info(f'MiniMax shift tests: passed={passed} failed={failed}')
return 1 if failed else 0
if __name__ == '__main__':
sys.exit(main())
+2 -1
View File
@@ -333,11 +333,12 @@ def test_pin_overrides_steps_and_shift():
assert network_pdd.install(pipe, make_net(heads), heads, minimax_lora.PDD, ['transformer']) is True
pipe.scheduler.set_shift(4.0)
pipe.audio_scheduler.set_shift(2.0)
p = types.SimpleNamespace(steps=30, task_args={'num_inference_steps': 30})
p = types.SimpleNamespace(steps=30, task_args={'num_inference_steps': 30}, extra_generation_params={'Video shift': 4.0, 'Audio shift': 2.0})
assert network_pdd.pin(p, pipe) == 9
assert p.steps == 9 and p.task_args['num_inference_steps'] == 9
assert pipe.num_timesteps == 8
assert pipe.scheduler.shift == 12.0 and pipe.audio_scheduler.shift == 3.0
assert p.extra_generation_params == {'Video shift': 12.0, 'Audio shift': 3.0}, f'recorded={p.extra_generation_params}'
network_pdd.restore(pipe)
assert network_pdd.pin(p, pipe) is None
+2 -2
View File
@@ -946,8 +946,8 @@
{"id":"","label":"Max tags","localized":"","hint":"Maximum number of tags to include in the output.<br>Limits the result length when an image has many detected features.<br>Tags are sorted by confidence, so the most relevant ones are kept.","ui":"caption"},
{"id":"","label":"Memory","localized":"","hint":"","ui":"component-8779"},
{"id":"","label":"Memory optimization","localized":"","hint":"","ui":"component-8779"},
{"id":"","label":"MiniMax Video Shift","localized":"","hint":"Controls how inference steps are distributed along the flow-matching curve, where higher values prioritize large-scale motion dynamics, camera movement, and global scene composition, while lower values focus steps on refining fine spatial textures and sharp visual details.","ui":"video"},
{"id":"","label":"MiniMax Audio Shift","localized":"","hint":"Governs the step distribution for latent sound generation, where higher values enforce strong temporal alignment with visual action and macro rhythm, while lower values allocate sampling depth toward high-frequency acoustic fidelity, speech clarity, and crisp sound effects.","ui":"video"},
{"id":"minimax_video_shift","label":"MiniMax video shift","localized":"","hint":"Exponential shift of the video sigma schedule, <code>sigma' = s*sigma / (1 + (s-1)*sigma)</code>. Values above 1 move the step grid toward full noise, values below 1 toward the clean end. The value is absolute and does not scale with the step count.<br><br>Default is <b>12</b>, the value the model ships with. Distilled LoRAs run at the shift they were trained with: <b>12</b> for the 544p <i>lightx2v</i> files, <b>6</b> for their 768p files. Parallel decoding (PDD) LoRAs pin the shipped value.<br><br>Recorded in the output metadata as <b>Video shift</b>.","ui":"video"},
{"id":"minimax_audio_shift","label":"MiniMax audio shift","localized":"","hint":"Exponential shift of the audio sigma schedule. The audio rows are denoised on this schedule inside the joint pass, so the value applies with audio output disabled too.<br><br>Default is <b>3</b>, the value the model ships with; the published turbo LoRAs keep it. Parallel decoding (PDD) LoRAs pin the shipped value.<br><br>Recorded in the output metadata as <b>Audio shift</b>.","ui":"video"},
{"id":"","label":"MiniMax Frames","localized":"","hint":"MiniMax is optimized to generate 5-15sec videos at 24 FPS","ui":"video"},
{"id":"","label":"Model Info","localized":"","hint":"","ui":"component-8779"},
{"id":"","label":"Model pipeline","localized":"","hint":"If autodetect does not detect model automatically, select model type before loading a model","ui":"settings_sd"},