183 lines
5.3 KiB
Python
183 lines
5.3 KiB
Python
# SPDX-License-Identifier: MIT
|
|
|
|
import json
|
|
import math
|
|
import os
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
BASE_MODEL_ID = "meituan-longcat/LongCat-Video"
|
|
AVATAR_MODEL_ID = "meituan-longcat/LongCat-Video-Avatar-1.5"
|
|
MODEL_KIND_BASE = "base"
|
|
MODEL_KIND_AVATAR = "avatar"
|
|
|
|
ATTENTION_OVERRIDES = {
|
|
"auto": {},
|
|
"sdpa": {
|
|
"enable_flashattn2": False,
|
|
"enable_flashattn3": False,
|
|
"enable_xformers": False,
|
|
},
|
|
"flash2": {
|
|
"enable_flashattn2": True,
|
|
"enable_flashattn3": False,
|
|
"enable_xformers": False,
|
|
},
|
|
"flash3": {
|
|
"enable_flashattn2": False,
|
|
"enable_flashattn3": True,
|
|
"enable_xformers": False,
|
|
},
|
|
"xformers": {
|
|
"enable_flashattn2": False,
|
|
"enable_flashattn3": False,
|
|
"enable_xformers": True,
|
|
},
|
|
}
|
|
|
|
|
|
def parse_options(values):
|
|
options = {}
|
|
for raw in values:
|
|
if ":" not in raw:
|
|
options[raw.strip()] = True
|
|
continue
|
|
key, value = raw.split(":", 1)
|
|
key = key.strip()
|
|
value = value.strip()
|
|
if not key:
|
|
continue
|
|
lower = value.lower()
|
|
if lower in {"true", "false"}:
|
|
options[key] = lower == "true"
|
|
continue
|
|
try:
|
|
options[key] = int(value)
|
|
continue
|
|
except ValueError:
|
|
pass
|
|
try:
|
|
options[key] = float(value)
|
|
continue
|
|
except ValueError:
|
|
pass
|
|
options[key] = value
|
|
return options
|
|
|
|
|
|
def require_bool(value, name):
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, str) and value.lower() in {"true", "false"}:
|
|
return value.lower() == "true"
|
|
raise ValueError(f"{name} must be true or false")
|
|
|
|
|
|
def require_int(value, name, minimum=None, maximum=None):
|
|
try:
|
|
parsed = int(value)
|
|
except (TypeError, ValueError) as err:
|
|
raise ValueError(f"{name} must be an integer") from err
|
|
if minimum is not None and parsed < minimum:
|
|
raise ValueError(f"{name} must be at least {minimum}")
|
|
if maximum is not None and parsed > maximum:
|
|
raise ValueError(f"{name} must be at most {maximum}")
|
|
return parsed
|
|
|
|
|
|
def require_float(value, name, minimum=None, maximum=None):
|
|
try:
|
|
parsed = float(value)
|
|
except (TypeError, ValueError) as err:
|
|
raise ValueError(f"{name} must be a number") from err
|
|
if minimum is not None and parsed < minimum:
|
|
raise ValueError(f"{name} must be at least {minimum}")
|
|
if maximum is not None and parsed > maximum:
|
|
raise ValueError(f"{name} must be at most {maximum}")
|
|
return parsed
|
|
|
|
|
|
def attention_overrides(name):
|
|
try:
|
|
return dict(ATTENTION_OVERRIDES[name])
|
|
except KeyError as err:
|
|
choices = ", ".join(ATTENTION_OVERRIDES)
|
|
raise ValueError(f"attention_backend must be one of: {choices}") from err
|
|
|
|
|
|
def _model_name_from_directory(path):
|
|
for filename in ("model_index.json", "config.json"):
|
|
config_path = os.path.join(path, filename)
|
|
try:
|
|
with open(config_path, "r", encoding="utf-8") as config_file:
|
|
model_name = json.load(config_file).get("model_name", "")
|
|
except (FileNotFoundError, OSError, ValueError, TypeError):
|
|
continue
|
|
if model_name:
|
|
return model_name
|
|
return ""
|
|
|
|
|
|
def normalize_model_source(model):
|
|
value = model.rstrip("/")
|
|
for prefix in ("huggingface://", "hf://"):
|
|
if value.startswith(prefix):
|
|
return value[len(prefix) :]
|
|
parsed = urlparse(value)
|
|
if parsed.scheme in {"http", "https"} and parsed.netloc.lower() == "huggingface.co":
|
|
parts = [part for part in parsed.path.split("/") if part]
|
|
if len(parts) >= 2:
|
|
return "/".join(parts[:2])
|
|
return value
|
|
|
|
|
|
def classify_model(model):
|
|
if not model:
|
|
return None
|
|
normalized = normalize_model_source(model)
|
|
if os.path.isdir(normalized):
|
|
name = _model_name_from_directory(normalized).lower()
|
|
if name == "longcat-video":
|
|
return MODEL_KIND_BASE
|
|
if name == "longcat-video-avatar-1.5":
|
|
return MODEL_KIND_AVATAR
|
|
return None
|
|
|
|
normalized = normalized.lower()
|
|
if normalized == BASE_MODEL_ID.lower():
|
|
return MODEL_KIND_BASE
|
|
if normalized == AVATAR_MODEL_ID.lower():
|
|
return MODEL_KIND_AVATAR
|
|
return None
|
|
|
|
|
|
def normalize_num_frames(value, default=93):
|
|
frames = default if not value or value < 1 else value
|
|
return max(1, ((frames - 1) // 4) * 4 + 1)
|
|
|
|
|
|
def avatar_segments_for_frames(frames):
|
|
if not frames or frames <= 93:
|
|
return 1
|
|
return 1 + math.ceil((frames - 93) / 80)
|
|
|
|
|
|
def avatar_segments_for_duration(duration_seconds, fps=25):
|
|
if duration_seconds <= 0:
|
|
return 1
|
|
return avatar_segments_for_frames(math.ceil(duration_seconds * fps))
|
|
|
|
|
|
def validate_dimensions(width, height):
|
|
width = width or 832
|
|
height = height or 480
|
|
if width < 256 or height < 256:
|
|
raise ValueError("width and height must each be at least 256")
|
|
if width > 1280 or height > 768:
|
|
raise ValueError("width and height must not exceed 1280x768")
|
|
if width % 16 != 0 or height % 16 != 0:
|
|
raise ValueError("width and height must be divisible by 16")
|
|
if width * height > 1280 * 768:
|
|
raise ValueError("requested video dimensions exceed the 1280x768 pixel limit")
|
|
return width, height
|