mirror of
https://github.com/vladmandic/automatic
synced 2026-09-06 13:00:44 +02:00
ff42f1631c
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
158 lines
6.9 KiB
Python
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)
|