Files
CalamitousFelicitousness 2c66039c37 feat(video): derive input mode from the registry row
Two ladders decided how a model's inputs get wired, and they had drifted: the
api reported an unrecognized row as t2v while run() fell through to a branch
that wires nothing. Eight LTX condition rows sat in that gap, advertised as
text to video and generating without their conditioning.

dispatch_mode answers once from the row, reading name markers before the
pipeline class because one class serves several modes: six LTXConditionPipeline
rows are named T2V or I2V and generate as such. Rows that declare nothing now
resolve as condition, and the api reports that instead of guessing t2v.
2026-08-18 00:44:28 +01:00

266 lines
17 KiB
Python

from types import SimpleNamespace
from threading import Lock
from pydantic import BaseModel, Field # pylint: disable=no-name-in-module
from fastapi.exceptions import HTTPException
from modules import errors, shared, scripts_manager, ui
from modules.api import script, helpers
from modules.paths import resolve_output_path
from modules.video_models import models_def, video_load, video_run
errors.install()
class ReqVideo(BaseModel):
engine: str | None = Field(default=None, title="Engine", description="Video engine family; omit together with model to use the currently loaded checkpoint")
model: str | None = Field(default=None, title="Model", description="Video model name within the engine; see GET /sdapi/v1/video/models")
prompt: str = Field(default="", title="Prompt", description="Text prompt")
negative_prompt: str = Field(default="", title="Negative prompt", description="Negative text prompt")
styles: list[str] = Field(default=[], title="Styles", description="Prompt style names to apply")
width: int = Field(default=832, ge=64, le=4096, title="Width", description="Output width; snapped to the model canvas multiple")
height: int = Field(default=480, ge=64, le=4096, title="Height", description="Output height; snapped to the model canvas multiple")
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")
dynamic_shift: bool = Field(default=False, title="Dynamic shift", description="Enable dynamic scheduler shifting")
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")
init_image: str | None = Field(default=None, title="Init image", description="Base64 or data URI for the first-frame image; an upload reference resolves only where an extension provides the upload store")
init_strength: float = Field(default=0.8, ge=0.0, le=1.0, title="Init strength", description="Denoising strength for the init image")
last_image: str | None = Field(default=None, title="Last image", description="Base64 or data URI for the last-frame image; an upload reference resolves only where an extension provides the upload store")
references: list[str] = Field(default=[], title="References", description="Reference images for a reference workflow, in the order the model reads them; base64 or data URIs, or upload references where an extension provides the upload store. Images only: the video core also conditions on video and audio references, which this endpoint cannot carry. At most 9, each within a 1:4 to 4:1 aspect ratio. Rejected on models that do not condition on references")
vae_type: str = Field(default="Default", title="VAE type", description="Decode variant: Default, Tiny, Remote, or Upscale")
vae_tile_frames: int = Field(default=16, ge=1, le=64, title="VAE tile frames", description="Frames per VAE decode tile")
audio: bool = Field(default=True, title="Audio", description="Generate audio on models that support it")
mp4_fps: int = Field(default=24, ge=1, le=60, title="FPS", description="Frames per second of the saved video")
mp4_interpolate: int = Field(default=0, ge=0, le=10, title="Interpolation", description="RIFE interpolation passes between frames")
mp4_codec: str = Field(default="libx264", title="Codec", description="Video codec; none skips video encoding")
mp4_ext: str = Field(default="mp4", title="Container", description="Container extension; the muxer is inferred from it")
mp4_opt: str = Field(default="crf=16", title="Codec options", description="Encoder options as key=value pairs separated by : or ,")
mp4_video: bool = Field(default=True, title="Save video", description="Write the video container to disk")
mp4_frames: bool = Field(default=False, title="Save frames", description="Write individual frame images to disk")
mp4_sf: bool = Field(default=False, title="Save safetensors", description="Write raw frames as a safetensors file")
mp4_thumb: bool = Field(default=True, title="Save thumbnail", description="Write a thumbnail image next to the video")
override_settings: dict = Field(default={}, title="Override settings", description="Setting overrides applied for this generation only")
script_args: list = Field(default=[], title="Script args", description="Positional arguments for a selectable script")
alwayson_scripts: dict = Field(default={}, title="Always-on scripts", description="Per-script argument overrides, keyed by script name")
send_video: bool = Field(default=True, title="Send video", description="Return the video base64-encoded in the response")
send_frames: bool = Field(default=False, title="Send frames", description="Return every frame base64-encoded in the response")
send_thumbnail: bool = Field(default=True, title="Send thumbnail", description="Return the thumbnail base64-encoded in the response")
extra: dict | None = Field(default={}, exclude=True, title="Extra", description="Extra attributes set on the processing object")
class ResVideo(BaseModel):
video: str | None = Field(default=None, title="Video", description="Base64-encoded video file; empty when not requested, above the size cap, or in still mode")
video_path: str | None = Field(default=None, title="Video path", description="Server path of the saved video; fetch via GET /sdapi/v1/video/file")
thumbnail: str | None = Field(default=None, title="Thumbnail", description="Base64-encoded thumbnail image")
thumbnail_path: str | None = Field(default=None, title="Thumbnail path", description="Server path of the saved thumbnail")
frames: list[str] = Field(default=[], title="Frames", description="Base64-encoded frames; always populated in still mode")
frames_count: int = Field(default=0, title="Frame count", description="Number of frames written, after interpolation")
fps: float = Field(default=0.0, title="FPS", description="Effective frames per second of the saved video")
duration: float = Field(default=0.0, title="Duration", description="Video duration in seconds")
has_audio: bool = Field(default=False, title="Has audio", description="Whether the video carries an audio track")
still: bool = Field(default=False, title="Still", description="Single-frame result; the product is in frames and no video was written")
params: dict = Field(default={}, title="Parameters", description="Echo of the request parameters used for generation")
info: str = Field(default="", title="Info", description="Generation info string with seed, sampler, and pipeline details")
class ItemVideoModel(BaseModel):
engine: str = Field(title="Engine", description="Video engine family")
name: str = Field(title="Name", description="Model name; pass together with engine to select it")
repo: str = Field(default="", title="Repo", description="Model repository or path")
url: str = Field(default="", title="URL", description="Model information page")
mode: str = Field(title="Mode", description="Input mode: workflow, t2v, i2v, flf2v, vace, animate, condition, or unknown; condition models accept conditioning the generic path does not wire and run as text to video here")
workflow: str | None = Field(default=None, title="Workflow", description="Modular workflow name when the model dispatches on inputs; ref2va conditions on references and ignores the keyframe images")
base: bool = Field(default=False, title="Base", description="Also listed in the base checkpoint dropdown")
loaded: bool = Field(default=False, title="Loaded", description="Currently loaded through the video registry")
def model_mode(m: models_def.Model) -> str:
return models_def.dispatch_mode(m)
class APIVideo:
def __init__(self, queue_lock: Lock):
self.queue_lock = queue_lock
self.default_script_arg_video = []
def prepare_scripts(self, p_stub, req: ReqVideo):
script_runner = scripts_manager.scripts_video
if not script_runner.scripts:
script_runner.initialize_scripts(is_img2img=False, is_control=False, is_video=True)
ui.create_ui(None)
if not self.default_script_arg_video:
self.default_script_arg_video = script.init_default_script_args(script_runner)
script_args = script.init_script_args(p_stub, req, self.default_script_arg_video, None, None, script_runner)
return script_runner, script_args
def sanitize_b64(self, req: ReqVideo):
def sanitize_str(args: list):
for idx in range(0, len(args)):
if isinstance(args[idx], str) and len(args[idx]) >= 1000:
args[idx] = f"<str {len(args[idx])}>"
for name in ('init_image', 'last_image'):
val = getattr(req, name, None)
if isinstance(val, str) and len(val) >= 1000:
setattr(req, name, f"<str {len(val)}>")
if req.references:
sanitize_str(req.references)
if req.script_args:
sanitize_str(req.script_args)
if req.alwayson_scripts:
for script_obj in req.alwayson_scripts.values():
if script_obj and "args" in script_obj and script_obj["args"]:
sanitize_str(script_obj["args"])
def post_video(self, req: ReqVideo):
"""Generate a video, or a single still frame, using a video model.
Omit `engine` and `model` to drive the currently loaded checkpoint when it is
video-capable; this covers models loaded from local folders that have no registry
entry. Pass both names to select a registry model, which is loaded on demand;
`GET /sdapi/v1/video/models` enumerates the valid pairs.
`frames` of 1 on a workflow model produces a single still image returned in `frames`.
Disk outputs are controlled by `mp4_video`, `mp4_frames`, `mp4_sf`, and `mp4_thumb`;
response payloads are controlled independently by `send_video`, `send_frames`, and
`send_thumbnail`. Artifacts above the base64 size cap return `video` empty with
`video_path` set; fetch those via `GET /sdapi/v1/video/file`.
`init_image` and `last_image` accept base64 data or data URIs. An `upload:` reference
resolves only where an extension registers an upload store; without one it is rejected.
Models whose workflow is `ref2va` condition on `references` instead: an ordered list of
images the prompt addresses as `<Picture 1>`, `<Picture 2>` and so on, following list
order. A single reference may also be passed as `init_image`. Reference images do not
set the output canvas, and `last_image` is ignored. The workflow also conditions on video
and audio references, addressed as `<Video i>` and `<Audio i>`, but they decode from files
rather than from the wire, so this endpoint carries images alone.
Progress is reported on `GET /sdapi/v1/progress`; `POST /sdapi/v1/interrupt` cancels.
Switching checkpoints via `override_settings` is not supported here; use
`POST /sdapi/v1/checkpoint` before generating.
"""
try:
selected, needs_load = video_run.resolve_model(req.engine, req.model)
except video_run.VideoError as e:
raise HTTPException(status_code=e.code, detail=str(e)) from e
sampler_name = helpers.validate_sampler_name(req.sampler_name)
init_image = helpers.decode_base64_to_image(req.init_image) if req.init_image else None
last_image = helpers.decode_base64_to_image(req.last_image) if req.last_image else None
references = [helpers.decode_base64_to_image(x) for x in (req.references or [])]
overrides = dict(req.override_settings or {})
for key in ('sd_model_checkpoint', 'sd_model_refiner'):
if key in overrides:
raise HTTPException(status_code=400, detail=f"{key} override is not supported here: switch models via POST /sdapi/v1/checkpoint before generating")
p_stub = SimpleNamespace(per_script_args={})
script_runner, script_args = self.prepare_scripts(p_stub, req)
extra = getattr(req, 'extra', {}) or {}
with self.queue_lock:
jobid = shared.state.begin('API-VID', api=True)
try:
res = video_run.run(
selected,
prompt=req.prompt,
negative=req.negative_prompt,
styles=req.styles,
width=req.width,
height=req.height,
frames=req.frames,
steps=req.steps,
sampler_name=sampler_name,
sampler_shift=req.sampler_shift,
dynamic_shift=req.dynamic_shift,
seed=req.seed,
guidance_scale=req.guidance_scale,
guidance_true=req.guidance_true,
init_image=init_image,
init_strength=req.init_strength,
last_image=last_image,
references=references,
vae_type=req.vae_type,
vae_tile_frames=req.vae_tile_frames,
audio=req.audio,
mp4_fps=req.mp4_fps,
mp4_interpolate=req.mp4_interpolate,
mp4_codec=req.mp4_codec,
mp4_ext=req.mp4_ext,
mp4_opt=req.mp4_opt,
mp4_video=req.mp4_video,
mp4_frames=req.mp4_frames,
mp4_sf=req.mp4_sf,
mp4_thumb=req.mp4_thumb,
override_settings=overrides,
engine=req.engine,
scripts=script_runner,
script_args=script_args,
per_script_args=p_stub.per_script_args,
extra_p=extra,
needs_load=needs_load,
)
except video_run.VideoError as e:
raise HTTPException(status_code=e.code, detail=str(e)) from e
finally:
shared.state.end(jobid, api=False)
send_frames = req.send_frames or res.still # a still request has no other product to return
b64_frames = list(map(helpers.encode_pil_to_base64, res.images)) if send_frames else []
video_b64 = helpers.encode_file_to_base64(res.video_path) if req.send_video and res.video_path else None
thumb_b64 = helpers.encode_file_to_base64(res.thumb_path) if req.send_thumbnail and res.thumb_path else None
duration = round(res.num_frames / res.fps, 3) if res.fps > 0 else 0.0
self.sanitize_b64(req)
params = {k: v for k, v in vars(req).items() if k != 'extra'}
return ResVideo(
video=video_b64,
video_path=res.video_path,
thumbnail=thumb_b64,
thumbnail_path=res.thumb_path,
frames=b64_frames,
frames_count=res.num_frames,
fps=res.fps,
duration=duration,
has_audio=res.has_audio,
still=res.still,
params=params,
info=res.processed.info,
)
def get_video_models(self, engine: str | None = None):
"""List video engines and models; optionally filter by engine."""
items = []
for family, rows in models_def.models.items():
if engine is not None and family.lower() != engine.lower():
continue
for m in rows:
if not models_def.is_model(m):
continue
items.append(ItemVideoModel(
engine=family,
name=m.name,
repo=m.repo or '',
url=m.url or '',
mode=model_mode(m),
workflow=m.workflow,
base=m.base,
loaded=(m.name == video_load.loaded_model),
))
return items
def get_video_file(self, file: str):
"""Serve a video artifact produced by this endpoint; the path must resolve inside the video output directory."""
import mimetypes
from pathlib import Path
from starlette.responses import FileResponse
if not file or not file.strip():
raise HTTPException(status_code=400, detail="file path is required")
root = Path(resolve_output_path(shared.opts.outdir_samples, shared.opts.outdir_video)).resolve()
target = Path(file).resolve()
if root not in target.parents:
raise HTTPException(status_code=403, detail=f"file {file}: must be inside the video output directory")
if not target.is_file():
raise HTTPException(status_code=404, detail=f"file not found: {file}")
media_type = mimetypes.guess_type(target.name)[0] or 'application/octet-stream'
return FileResponse(str(target), media_type=media_type, filename=target.name)