Files
automatic/cli/api-video.py
T
CalamitousFelicitousness ff42f1631c feat(api): add video generation endpoint
Add POST /sdapi/v1/video plus GET /sdapi/v1/video/models and
GET /sdapi/v1/video/file. The generation body is extracted from the
gradio handler into a keyword-only core, video_run.run, which returns a
structured result and raises typed errors; the positional generate
signature is unchanged and now adapts to the core. Omitting engine and
model drives the currently loaded checkpoint when it is video-capable,
which covers models loaded from local folders without a registry entry.

- registry helpers in models_def (find, engines, pipeline_classes,
  workflow_for_class); validate_pipeline reuses the shared class set
- modular pipes stamp their workflow so out-of-registry loads dispatch
  onto the modular branch
- disk switches (mp4_*) and wire switches (send_*) are independent;
  artifacts above the base64 cap fall back to path plus the file route,
  which is jailed to the video output directory and serves video/mp4
  with range support
- always-on video scripts get bootstrapped default args, matching the
  txt2img handler; missing bootstrap raised a TypeError per frame
- checkpoint overrides are rejected with a pointer to the checkpoint
  endpoint; unknown engine, model and sampler names return 404 with the
  valid choices
- cli/api-video.py client, test/test-video-api.py suite and a
  full-test.sh entry; video mimetypes registered; rate-limit cost set
- remove the unreferenced video_ui.run_video dispatcher
2026-08-10 22:14:18 +01:00

158 lines
6.9 KiB
Python

#!/usr/bin/env python
# python cli/api-video.py --prompt "a paper boat drifting down a rain gutter" --frames 17 --steps 8 --output /tmp/video.mp4
import os
import time
import base64
import logging
import argparse
import threading
import requests
import urllib3
sd_url = os.environ.get('SDAPI_URL', "http://127.0.0.1:7860")
sd_username = os.environ.get('SDAPI_USR', None)
sd_password = os.environ.get('SDAPI_PWD', None)
options = {
"send_video": True,
"send_thumbnail": False,
}
logging.basicConfig(level = logging.INFO, format = '%(asctime)s %(levelname)s: %(message)s')
log = logging.getLogger(__name__)
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
def auth():
if sd_username is not None and sd_password is not None:
return requests.auth.HTTPBasicAuth(sd_username, sd_password)
return None
def get(endpoint: str, params: dict | None = None, timeout: int = 60):
req = requests.get(f'{sd_url}{endpoint}', params=params, timeout=timeout, verify=False, auth=auth())
if req.status_code != 200:
return { 'error': req.status_code, 'reason': req.reason, 'url': req.url }
return req.json()
def post(endpoint: str, dct: dict | None = None, timeout: int = 3600):
req = requests.post(f'{sd_url}{endpoint}', json=dct, timeout=timeout, verify=False, auth=auth())
if req.status_code != 200:
res = { 'error': req.status_code, 'reason': req.reason, 'url': req.url }
try:
res['detail'] = req.json().get('detail', None)
except Exception:
pass
return res
return req.json()
def encode(f: str):
with open(f, 'rb') as file:
return base64.b64encode(file.read()).decode()
def list_models():
data = get('/sdapi/v1/video/models')
if isinstance(data, dict) and 'error' in data:
log.error(f'video models: {data}')
return
for item in data:
loaded = ' loaded=true' if item.get('loaded') else ''
log.info(f'engine="{item["engine"]}" model="{item["name"]}" mode={item["mode"]}{loaded}')
log.info(f'video models: {len(data)}')
def watch_progress(stop_event: threading.Event):
while not stop_event.is_set():
status = get('/sdapi/v1/progress', params={ 'skip_current_image': True })
if 'error' not in status:
state = status.get('state') or {}
log.info(f'progress={status.get("progress", 0):.2f} eta={status.get("eta_relative", 0):.1f} step={state.get("sampling_step", 0)}/{state.get("sampling_steps", 0)} info="{status.get("textinfo") or ""}"')
stop_event.wait(5)
def save_output(data: dict, output: str):
if data.get('video'):
with open(output, 'wb') as f:
f.write(base64.b64decode(data['video']))
log.info(f'video saved: filename={output}')
elif data.get('still') and data.get('frames'):
with open(output, 'wb') as f:
f.write(base64.b64decode(data['frames'][0]))
log.info(f'still saved: filename={output}')
elif data.get('video_path'):
req = requests.get(f'{sd_url}/sdapi/v1/video/file', params={ 'file': data['video_path'] }, timeout=300, verify=False, auth=auth())
if req.status_code == 200:
with open(output, 'wb') as f:
f.write(req.content)
log.info(f'video fetched: filename={output} size={len(req.content)}')
else:
log.error(f'video fetch failed: code={req.status_code} reason={req.reason}')
else:
log.warning('no video output received')
def generate(args): # pylint: disable=redefined-outer-name
t0 = time.time()
if args.engine:
options['engine'] = args.engine
if args.model:
options['model'] = args.model
options['prompt'] = args.prompt
options['negative_prompt'] = args.negative
options['width'] = int(args.width)
options['height'] = int(args.height)
options['frames'] = int(args.frames)
options['steps'] = int(args.steps)
options['seed'] = int(args.seed)
options['sampler_name'] = args.sampler
options['mp4_fps'] = int(args.fps)
options['mp4_interpolate'] = int(args.interpolate)
options['audio'] = bool(args.audio)
if args.init:
options['init_image'] = encode(args.init)
if args.last:
options['last_image'] = encode(args.last)
stop_event = threading.Event()
if args.progress:
threading.Thread(target=watch_progress, args=(stop_event,), daemon=True).start()
data = post('/sdapi/v1/video', options, timeout=int(args.timeout))
stop_event.set()
t1 = time.time()
if 'error' in data:
log.error(f'generate failed: {data}')
return
log.info(f'video received: frames={data.get("frames_count")} fps={data.get("fps")} duration={data.get("duration")} audio={data.get("has_audio")} still={data.get("still")} path={data.get("video_path")} time={t1-t0:.2f}')
if args.output:
save_output(data, args.output)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description = 'api-video')
parser.add_argument('--list', action='store_true', help='list video engines and models')
parser.add_argument('--engine', required=False, default=None, help='video engine; omit with --model to use the loaded checkpoint')
parser.add_argument('--model', required=False, default=None, help='video model name within the engine')
parser.add_argument('--prompt', required=False, default='', help='prompt text')
parser.add_argument('--negative', required=False, default='', help='negative prompt text')
parser.add_argument('--width', required=False, default=832, help='video width')
parser.add_argument('--height', required=False, default=480, help='video height')
parser.add_argument('--frames', required=False, default=17, help='number of frames; 1 for a still image')
parser.add_argument('--steps', required=False, default=20, help='number of steps')
parser.add_argument('--seed', required=False, default=-1, help='initial seed')
parser.add_argument('--sampler', required=False, default='Default', help='sampler name')
parser.add_argument('--fps', required=False, default=24, help='frames per second')
parser.add_argument('--interpolate', required=False, default=0, help='rife interpolation passes')
parser.add_argument('--audio', action=argparse.BooleanOptionalAction, default=True, help='generate audio on supported models')
parser.add_argument('--init', required=False, default=None, help='init image file')
parser.add_argument('--last', required=False, default=None, help='last frame image file')
parser.add_argument('--output', required=False, default=None, help='output video file')
parser.add_argument('--progress', action='store_true', help='poll and log progress during generation')
parser.add_argument('--timeout', required=False, default=3600, help='request timeout in seconds')
args = parser.parse_args()
log.info(f'api-video: {args}')
if args.list:
list_models()
else:
generate(args)