764 lines
26 KiB
Python
764 lines
26 KiB
Python
import configparser
|
|
from contextlib import contextmanager
|
|
import io
|
|
import json
|
|
import mimetypes
|
|
import os
|
|
from pathlib import Path
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import urllib.parse
|
|
import uuid
|
|
|
|
import numpy
|
|
from PIL import Image
|
|
import requests
|
|
|
|
try:
|
|
import folder_paths
|
|
except ImportError:
|
|
comfy_root = Path(__file__).resolve().parents[3]
|
|
if str(comfy_root) not in sys.path:
|
|
sys.path.insert(0, str(comfy_root))
|
|
import folder_paths
|
|
|
|
from .api import DashScopeClient
|
|
|
|
|
|
NODE_PREFIX = "ComfyUI-Happyhorse-Wrapper"
|
|
NODE_CATEGORY = NODE_PREFIX
|
|
|
|
ROOT_DIR = Path(__file__).resolve().parents[1]
|
|
CONFIG_JSON_PATH = ROOT_DIR / "config.local.json"
|
|
ENV_PATH = ROOT_DIR / ".env"
|
|
LEGACY_CONFIG_PATH = ROOT_DIR / "config.ini"
|
|
LEGACY_CONFIG_SECTION = "API"
|
|
|
|
DEFAULT_ENDPOINT = "https://dashscope.aliyuncs.com"
|
|
DEFAULT_POLL_INTERVAL = 15.0
|
|
DEFAULT_REQUEST_TIMEOUT = 60
|
|
DEFAULT_UPLOAD_TIMEOUT = 120
|
|
DEFAULT_OSS_URL_EXPIRES = 24 * 60 * 60
|
|
DEFAULT_FILENAME_PREFIX = NODE_PREFIX
|
|
|
|
TMPFILES_UPLOAD_API_URL = "https://tmpfiles.org/api/v1/upload"
|
|
TMPFILES_MAX_SIZE_BYTES = 100 * 1024 * 1024
|
|
TMPFILES_UPLOAD_RETRY_COUNT = 3
|
|
TMPFILES_UPLOAD_RETRY_DELAY = 1.0
|
|
|
|
RESOLUTIONS = ["720P", "1080P"]
|
|
RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4"]
|
|
DURATIONS = [str(value) for value in range(3, 16)]
|
|
AUDIO_SETTINGS = ["auto", "origin"]
|
|
SEED_MAX = 2147483647
|
|
|
|
_ENV_FILE_CACHE = None
|
|
_ENV_FILE_MTIME = None
|
|
|
|
|
|
def _load_json_config():
|
|
if not CONFIG_JSON_PATH.exists():
|
|
return {}
|
|
try:
|
|
with CONFIG_JSON_PATH.open("r", encoding="utf-8") as handle:
|
|
data = json.load(handle)
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError(f"{CONFIG_JSON_PATH.name} is not valid JSON: {exc}") from exc
|
|
if not isinstance(data, dict):
|
|
raise ValueError(f"{CONFIG_JSON_PATH.name} must contain a JSON object.")
|
|
return data
|
|
|
|
|
|
def _present(data, key):
|
|
value = data.get(key)
|
|
if value is None:
|
|
return False
|
|
if isinstance(value, str):
|
|
return bool(value.strip())
|
|
return True
|
|
|
|
|
|
def _strip_env_quotes(value):
|
|
text = str(value or "").strip()
|
|
if len(text) >= 2 and text[0] == text[-1] and text[0] in {'"', "'"}:
|
|
text = text[1:-1]
|
|
return text
|
|
|
|
|
|
def _parse_env_line(line):
|
|
text = str(line or "").lstrip("\ufeff").strip()
|
|
if not text or text.startswith("#"):
|
|
return None
|
|
if text.startswith("export "):
|
|
text = text[len("export "):].strip()
|
|
if "=" not in text:
|
|
return None
|
|
key, value = text.split("=", 1)
|
|
key = key.strip()
|
|
if not key:
|
|
return None
|
|
return key, _strip_env_quotes(value)
|
|
|
|
|
|
def _load_env_file():
|
|
global _ENV_FILE_CACHE, _ENV_FILE_MTIME
|
|
|
|
try:
|
|
mtime = ENV_PATH.stat().st_mtime
|
|
except OSError:
|
|
_ENV_FILE_CACHE = {}
|
|
_ENV_FILE_MTIME = None
|
|
return _ENV_FILE_CACHE
|
|
|
|
if _ENV_FILE_CACHE is not None and _ENV_FILE_MTIME == mtime:
|
|
return _ENV_FILE_CACHE
|
|
|
|
values = {}
|
|
with ENV_PATH.open("r", encoding="utf-8") as handle:
|
|
for line in handle:
|
|
parsed = _parse_env_line(line)
|
|
if parsed:
|
|
key, value = parsed
|
|
values[key] = value
|
|
_ENV_FILE_CACHE = values
|
|
_ENV_FILE_MTIME = mtime
|
|
return values
|
|
|
|
|
|
def _env_value(*keys):
|
|
env_file = _load_env_file()
|
|
for key in keys:
|
|
value = os.getenv(key, "").strip()
|
|
if value:
|
|
return value
|
|
value = str(env_file.get(key) or "").strip()
|
|
if value:
|
|
return value
|
|
return ""
|
|
|
|
|
|
def _legacy_value(*keys):
|
|
if not LEGACY_CONFIG_PATH.exists():
|
|
return ""
|
|
config = configparser.ConfigParser()
|
|
config.read(LEGACY_CONFIG_PATH, encoding="utf-8")
|
|
for key in keys:
|
|
value = config.get(LEGACY_CONFIG_SECTION, key, fallback="").strip()
|
|
if value:
|
|
return value
|
|
return ""
|
|
|
|
|
|
def _number(value, default, minimum=None, name="value"):
|
|
if value is None or value == "":
|
|
return default
|
|
try:
|
|
parsed = float(value)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError(f"{name} must be a number.") from exc
|
|
if minimum is not None and parsed < minimum:
|
|
raise ValueError(f"{name} must be greater than or equal to {minimum}.")
|
|
return parsed
|
|
|
|
|
|
def _normalize_oss_endpoint(endpoint):
|
|
value = str(endpoint or "").strip()
|
|
if not value:
|
|
return ""
|
|
if "://" not in value:
|
|
value = f"https://{value}"
|
|
return value.rstrip("/")
|
|
|
|
|
|
def _parse_oss_uri(uri):
|
|
value = str(uri or "").strip()
|
|
if not value:
|
|
return "", ""
|
|
parsed = urllib.parse.urlparse(value)
|
|
if parsed.scheme != "oss" or not parsed.netloc:
|
|
raise ValueError("oss_uri must look like oss://bucket/optional/prefix/.")
|
|
prefix = parsed.path.lstrip("/")
|
|
if prefix and not prefix.endswith("/"):
|
|
prefix += "/"
|
|
return parsed.netloc, prefix
|
|
|
|
|
|
def _normalize_oss_prefix(prefix):
|
|
value = str(prefix or "").strip().replace("\\", "/").lstrip("/")
|
|
while "//" in value:
|
|
value = value.replace("//", "/")
|
|
if value and not value.endswith("/"):
|
|
value += "/"
|
|
return value
|
|
|
|
|
|
def _resolve_oss_location(data):
|
|
oss_uri = (
|
|
str(data["oss_uri"]).strip()
|
|
if _present(data, "oss_uri")
|
|
else _env_value("OSS_URI", "HAPPYHORSE_OSS_URI")
|
|
)
|
|
uri_bucket, uri_prefix = _parse_oss_uri(oss_uri) if oss_uri else ("", "")
|
|
bucket = (
|
|
str(data["oss_bucket"]).strip()
|
|
if _present(data, "oss_bucket")
|
|
else _env_value("OSS_BUCKET", "HAPPYHORSE_OSS_BUCKET")
|
|
or uri_bucket
|
|
)
|
|
if uri_bucket and bucket and bucket != uri_bucket:
|
|
raise ValueError("OSS bucket in oss_uri does not match oss_bucket/OSS_BUCKET.")
|
|
prefix = (
|
|
str(data["oss_prefix"]).strip()
|
|
if _present(data, "oss_prefix")
|
|
else _env_value("OSS_PREFIX", "HAPPYHORSE_OSS_PREFIX")
|
|
or uri_prefix
|
|
)
|
|
return bucket, _normalize_oss_prefix(prefix)
|
|
|
|
|
|
def _resolve_config():
|
|
data = _load_json_config()
|
|
oss_bucket, oss_prefix = _resolve_oss_location(data)
|
|
oss_url_expires = _number(
|
|
data.get("oss_url_expires") if _present(data, "oss_url_expires") else _env_value("OSS_URL_EXPIRES"),
|
|
DEFAULT_OSS_URL_EXPIRES,
|
|
60,
|
|
"oss_url_expires",
|
|
)
|
|
api_key = (
|
|
str(data["api_key"]).strip()
|
|
if _present(data, "api_key")
|
|
else _env_value("DASHSCOPE_API_KEY", "AIHUBMIX_API_KEY")
|
|
or _legacy_value("DASHSCOPE_API_KEY", "AIHUBMIX_API_KEY")
|
|
)
|
|
endpoint = (
|
|
str(data["endpoint"]).strip()
|
|
if _present(data, "endpoint")
|
|
else _env_value("DASHSCOPE_ENDPOINT", "AIHUBMIX_ENDPOINT", "AIHUBMIX_BASE_URL")
|
|
or _legacy_value("DASHSCOPE_ENDPOINT", "AIHUBMIX_ENDPOINT", "AIHUBMIX_BASE_URL")
|
|
or DEFAULT_ENDPOINT
|
|
)
|
|
poll_interval = _number(data.get("poll_interval"), DEFAULT_POLL_INTERVAL, 1, "poll_interval")
|
|
request_timeout = _number(data.get("request_timeout"), DEFAULT_REQUEST_TIMEOUT, 5, "request_timeout")
|
|
upload_timeout = _number(data.get("upload_timeout"), DEFAULT_UPLOAD_TIMEOUT, 5, "upload_timeout")
|
|
return {
|
|
"api_key": api_key,
|
|
"endpoint": endpoint,
|
|
"poll_interval": poll_interval,
|
|
"request_timeout": request_timeout,
|
|
"upload_timeout": upload_timeout,
|
|
"oss_endpoint": _normalize_oss_endpoint(
|
|
str(data["oss_endpoint"]).strip()
|
|
if _present(data, "oss_endpoint")
|
|
else _env_value("OSS_ENDPOINT", "HAPPYHORSE_OSS_ENDPOINT")
|
|
),
|
|
"oss_access_key_id": (
|
|
str(data["oss_access_key_id"]).strip()
|
|
if _present(data, "oss_access_key_id")
|
|
else _env_value("OSS_ACCESS_KEY_ID", "ALIBABA_CLOUD_ACCESS_KEY_ID")
|
|
),
|
|
"oss_access_key_secret": (
|
|
str(data["oss_access_key_secret"]).strip()
|
|
if _present(data, "oss_access_key_secret")
|
|
else _env_value("OSS_ACCESS_KEY_SECRET", "ALIBABA_CLOUD_ACCESS_KEY_SECRET")
|
|
),
|
|
"oss_security_token": (
|
|
str(data["oss_security_token"]).strip()
|
|
if _present(data, "oss_security_token")
|
|
else _env_value("OSS_SECURITY_TOKEN", "ALIBABA_CLOUD_SECURITY_TOKEN")
|
|
),
|
|
"oss_bucket": oss_bucket,
|
|
"oss_prefix": oss_prefix,
|
|
"oss_url_expires": oss_url_expires,
|
|
}
|
|
|
|
|
|
@contextmanager
|
|
def _runtime_client():
|
|
config = _resolve_config()
|
|
client = DashScopeClient(
|
|
config["api_key"],
|
|
config["endpoint"],
|
|
timeout=config["request_timeout"],
|
|
poll_interval=config["poll_interval"],
|
|
)
|
|
try:
|
|
yield client
|
|
finally:
|
|
client.close()
|
|
|
|
|
|
def _clean_prompt(prompt, required=True):
|
|
value = str(prompt or "").strip()
|
|
if required and not value:
|
|
raise ValueError("prompt is required.")
|
|
return value
|
|
|
|
|
|
def _normalize_duration(duration):
|
|
try:
|
|
parsed = int(duration)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError("duration must be an integer between 3 and 15.") from exc
|
|
if parsed < 3 or parsed > 15:
|
|
raise ValueError("duration must be an integer between 3 and 15.")
|
|
return parsed
|
|
|
|
|
|
def _normalize_seed(seed):
|
|
try:
|
|
parsed = int(seed)
|
|
except (TypeError, ValueError) as exc:
|
|
raise ValueError("seed must be an integer.") from exc
|
|
if parsed < -1 or parsed > SEED_MAX:
|
|
raise ValueError(f"seed must be -1 or an integer between 0 and {SEED_MAX}.")
|
|
return None if parsed < 0 else parsed
|
|
|
|
|
|
def _build_parameters(resolution="1080P", duration=None, ratio=None, watermark=True, seed=-1, audio_setting=None):
|
|
if resolution not in RESOLUTIONS:
|
|
raise ValueError(f"resolution must be one of: {', '.join(RESOLUTIONS)}.")
|
|
parameters = {"resolution": resolution, "watermark": bool(watermark)}
|
|
if duration is not None:
|
|
parameters["duration"] = _normalize_duration(duration)
|
|
if ratio is not None:
|
|
if ratio not in RATIOS:
|
|
raise ValueError(f"ratio must be one of: {', '.join(RATIOS)}.")
|
|
parameters["ratio"] = ratio
|
|
if audio_setting is not None:
|
|
if audio_setting not in AUDIO_SETTINGS:
|
|
raise ValueError(f"audio_setting must be one of: {', '.join(AUDIO_SETTINGS)}.")
|
|
parameters["audio_setting"] = audio_setting
|
|
normalized_seed = _normalize_seed(seed)
|
|
if normalized_seed is not None:
|
|
parameters["seed"] = normalized_seed
|
|
return parameters
|
|
|
|
|
|
def _submit_and_wait(payload):
|
|
with _runtime_client() as client:
|
|
task = client.create_video_task(payload)
|
|
task_id = task["task_id"]
|
|
print(f"[{NODE_PREFIX}] created task: {task_id}")
|
|
result = client.wait_for_video(task_id)
|
|
output = result.get("output") or {}
|
|
video_url = str(output.get("video_url") or "").strip()
|
|
task_id = str(output.get("task_id") or task_id).strip()
|
|
print(f"[{NODE_PREFIX}] output video: task_id={task_id}, video_url={video_url}")
|
|
return video_url, task_id
|
|
|
|
|
|
def build_text_to_video_payload(prompt, resolution, ratio, duration, watermark, seed):
|
|
return {
|
|
"model": "happyhorse-1.0-t2v",
|
|
"input": {"prompt": _clean_prompt(prompt)},
|
|
"parameters": _build_parameters(
|
|
resolution=resolution,
|
|
ratio=ratio,
|
|
duration=duration,
|
|
watermark=watermark,
|
|
seed=seed,
|
|
),
|
|
}
|
|
|
|
|
|
def build_image_to_video_payload(prompt, first_frame_url, resolution, duration, watermark, seed):
|
|
if not str(first_frame_url or "").strip():
|
|
raise ValueError("image is required for Image To Video.")
|
|
return {
|
|
"model": "happyhorse-1.0-i2v",
|
|
"input": {
|
|
"prompt": _clean_prompt(prompt, required=False),
|
|
"media": [{"type": "first_frame", "url": str(first_frame_url).strip()}],
|
|
},
|
|
"parameters": _build_parameters(
|
|
resolution=resolution,
|
|
duration=duration,
|
|
watermark=watermark,
|
|
seed=seed,
|
|
),
|
|
}
|
|
|
|
|
|
def build_reference_to_video_payload(prompt, reference_urls, resolution, ratio, duration, watermark, seed):
|
|
urls = [str(url).strip() for url in reference_urls if str(url or "").strip()]
|
|
if not (1 <= len(urls) <= 9):
|
|
raise ValueError("Reference To Video requires 1 to 9 reference images.")
|
|
return {
|
|
"model": "happyhorse-1.0-r2v",
|
|
"input": {
|
|
"prompt": _clean_prompt(prompt),
|
|
"media": [{"type": "reference_image", "url": url} for url in urls],
|
|
},
|
|
"parameters": _build_parameters(
|
|
resolution=resolution,
|
|
ratio=ratio,
|
|
duration=duration,
|
|
watermark=watermark,
|
|
seed=seed,
|
|
),
|
|
}
|
|
|
|
|
|
def build_video_edit_payload(prompt, video_url, reference_urls, resolution, watermark, audio_setting, seed):
|
|
video_url = str(video_url or "").strip()
|
|
if not video_url:
|
|
raise ValueError("video_url is required for Video Edit.")
|
|
urls = [str(url).strip() for url in reference_urls if str(url or "").strip()]
|
|
if len(urls) > 5:
|
|
raise ValueError("Video Edit supports at most 5 reference images.")
|
|
media = [{"type": "video", "url": video_url}]
|
|
media.extend({"type": "reference_image", "url": url} for url in urls)
|
|
return {
|
|
"model": "happyhorse-1.0-video-edit",
|
|
"input": {
|
|
"prompt": _clean_prompt(prompt),
|
|
"media": media,
|
|
},
|
|
"parameters": _build_parameters(
|
|
resolution=resolution,
|
|
watermark=watermark,
|
|
seed=seed,
|
|
audio_setting=audio_setting,
|
|
),
|
|
}
|
|
|
|
|
|
def _tensor_to_pil_images(image):
|
|
if image is None:
|
|
return []
|
|
if hasattr(image, "detach"):
|
|
array = image.detach().cpu().numpy()
|
|
elif hasattr(image, "cpu"):
|
|
array = image.cpu().numpy()
|
|
else:
|
|
array = image
|
|
array = numpy.asarray(array)
|
|
if array.ndim == 3:
|
|
array = array[numpy.newaxis, ...]
|
|
if array.ndim != 4:
|
|
raise ValueError("IMAGE input must be a 3D or 4D tensor.")
|
|
images = []
|
|
for frame in array:
|
|
pixels = numpy.clip(frame * 255.0, 0.0, 255.0).astype(numpy.uint8)
|
|
images.append(Image.fromarray(pixels).convert("RGB"))
|
|
return images
|
|
|
|
|
|
def _normalize_tmpfiles_download_url(page_url):
|
|
normalized = str(page_url or "").strip()
|
|
if not normalized:
|
|
raise ValueError("Upload service did not return a file URL.")
|
|
parsed = urllib.parse.urlparse(normalized)
|
|
if parsed.netloc.lower() != "tmpfiles.org":
|
|
return normalized
|
|
path = parsed.path.strip("/")
|
|
if not path:
|
|
raise ValueError("Upload service returned an invalid tmpfiles URL.")
|
|
if path.startswith("dl/"):
|
|
return f"https://tmpfiles.org/{path}"
|
|
return f"https://tmpfiles.org/dl/{path}"
|
|
|
|
|
|
def _upload_file_to_tmpfiles(file_path, timeout=DEFAULT_UPLOAD_TIMEOUT):
|
|
normalized_path = os.path.abspath(os.fspath(file_path))
|
|
if not os.path.exists(normalized_path):
|
|
raise ValueError(f"Upload file does not exist: {normalized_path}")
|
|
if os.path.getsize(normalized_path) > TMPFILES_MAX_SIZE_BYTES:
|
|
raise ValueError("Local media file exceeds tmpfiles.org's 100 MB upload limit.")
|
|
|
|
filename = os.path.basename(normalized_path)
|
|
last_error = None
|
|
for attempt in range(1, TMPFILES_UPLOAD_RETRY_COUNT + 1):
|
|
try:
|
|
with open(normalized_path, "rb") as handle:
|
|
response = requests.post(
|
|
TMPFILES_UPLOAD_API_URL,
|
|
files={"file": (filename, handle)},
|
|
timeout=float(timeout),
|
|
)
|
|
response.raise_for_status()
|
|
payload = response.json()
|
|
if payload.get("status") != "success":
|
|
raise ValueError(f"Temporary media upload failed: {payload}")
|
|
return _normalize_tmpfiles_download_url(payload.get("data", {}).get("url"))
|
|
except (requests.RequestException, ValueError) as exc:
|
|
last_error = exc
|
|
if attempt < TMPFILES_UPLOAD_RETRY_COUNT:
|
|
print(f"[{NODE_PREFIX}] tmpfiles upload retry {attempt}: {exc}")
|
|
time.sleep(TMPFILES_UPLOAD_RETRY_DELAY)
|
|
raise ConnectionError(f"Temporary media upload failed: {last_error}") from last_error
|
|
|
|
|
|
def _require_oss2():
|
|
try:
|
|
import oss2
|
|
except ImportError as exc:
|
|
raise ImportError(
|
|
"OSS media upload requires the optional dependency 'oss2'. "
|
|
"Install requirements.txt in the ComfyUI Python environment."
|
|
) from exc
|
|
return oss2
|
|
|
|
|
|
def _build_oss_object_name(prefix, filename):
|
|
safe_filename = urllib.parse.quote(os.path.basename(filename), safe="._-")
|
|
return f"{_normalize_oss_prefix(prefix)}{time.strftime('%Y%m%d')}/{uuid.uuid4().hex}_{safe_filename}"
|
|
|
|
|
|
def _upload_file_to_oss(file_path, config):
|
|
normalized_path = os.path.abspath(os.fspath(file_path))
|
|
if not os.path.exists(normalized_path):
|
|
raise ValueError(f"Upload file does not exist: {normalized_path}")
|
|
|
|
required = {
|
|
"OSS_ENDPOINT": config.get("oss_endpoint"),
|
|
"OSS_ACCESS_KEY_ID": config.get("oss_access_key_id"),
|
|
"OSS_ACCESS_KEY_SECRET": config.get("oss_access_key_secret"),
|
|
"OSS_BUCKET": config.get("oss_bucket"),
|
|
}
|
|
missing = [name for name, value in required.items() if not str(value or "").strip()]
|
|
if missing:
|
|
raise ValueError(f"OSS upload is enabled but missing required setting(s): {', '.join(missing)}.")
|
|
|
|
oss2 = _require_oss2()
|
|
auth = (
|
|
oss2.StsAuth(
|
|
config["oss_access_key_id"],
|
|
config["oss_access_key_secret"],
|
|
config["oss_security_token"],
|
|
)
|
|
if config.get("oss_security_token")
|
|
else oss2.Auth(config["oss_access_key_id"], config["oss_access_key_secret"])
|
|
)
|
|
bucket = oss2.Bucket(
|
|
auth,
|
|
config["oss_endpoint"],
|
|
config["oss_bucket"],
|
|
connect_timeout=float(config.get("upload_timeout") or DEFAULT_UPLOAD_TIMEOUT),
|
|
)
|
|
|
|
filename = os.path.basename(normalized_path)
|
|
object_name = _build_oss_object_name(config.get("oss_prefix"), filename)
|
|
content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
bucket.put_object_from_file(object_name, normalized_path, headers={"Content-Type": content_type})
|
|
return bucket.sign_url("GET", object_name, int(config.get("oss_url_expires") or DEFAULT_OSS_URL_EXPIRES))
|
|
|
|
|
|
def _upload_file(file_path, config):
|
|
if config.get("oss_bucket") or config.get("oss_endpoint"):
|
|
return _upload_file_to_oss(file_path, config)
|
|
return _upload_file_to_tmpfiles(file_path, timeout=config["upload_timeout"])
|
|
|
|
|
|
def _upload_pil_image(image, index=0):
|
|
config = _resolve_config()
|
|
with tempfile.NamedTemporaryFile(prefix=f"happyhorse_{index}_", suffix=".png", delete=False) as handle:
|
|
temp_path = handle.name
|
|
try:
|
|
image.save(temp_path, format="PNG")
|
|
return _upload_file(temp_path, config)
|
|
finally:
|
|
try:
|
|
os.remove(temp_path)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _upload_image_batch(image, minimum=1, maximum=None):
|
|
images = _tensor_to_pil_images(image)
|
|
if len(images) < minimum:
|
|
raise ValueError(f"At least {minimum} image(s) are required.")
|
|
if maximum is not None and len(images) > maximum:
|
|
raise ValueError(f"At most {maximum} image(s) are supported.")
|
|
return [_upload_pil_image(frame, index=index) for index, frame in enumerate(images)]
|
|
|
|
|
|
def _fetch_binary(url):
|
|
headers = {"User-Agent": "Mozilla/5.0"}
|
|
config = _resolve_config()
|
|
endpoint = str(config.get("endpoint") or "").rstrip("/")
|
|
if endpoint and str(url).startswith(endpoint + "/videos/"):
|
|
headers["Authorization"] = f"Bearer {config['api_key']}"
|
|
response = requests.get(url, timeout=60, stream=True, headers=headers)
|
|
response.raise_for_status()
|
|
return response.content
|
|
|
|
|
|
def _saved_result(filename, subfolder, folder_type):
|
|
return {"filename": filename, "subfolder": subfolder, "type": folder_type}
|
|
|
|
|
|
def _register_output_asset(file_path):
|
|
try:
|
|
import app.assets.services.ingest as asset_ingest
|
|
except Exception:
|
|
return
|
|
try:
|
|
asset_ingest.ingest_existing_file(file_path)
|
|
except Exception as exc:
|
|
print(f"[{NODE_PREFIX}] Failed to register output asset: {exc}")
|
|
|
|
|
|
def _build_local_media_view_url(filename, subfolder, folder_type):
|
|
query = [
|
|
f"type={urllib.parse.quote(str(folder_type), safe='')}",
|
|
f"filename={urllib.parse.quote(str(filename), safe='')}",
|
|
]
|
|
if subfolder:
|
|
query.append(f"subfolder={urllib.parse.quote(str(subfolder), safe='')}")
|
|
return "/api/view?" + "&".join(query)
|
|
|
|
|
|
class HappyHorseTextToVideoNode:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"prompt": ("STRING", {"multiline": True, "default": ""}),
|
|
"resolution": (RESOLUTIONS, {"default": "1080P"}),
|
|
"ratio": (RATIOS, {"default": "16:9"}),
|
|
"duration": (DURATIONS, {"default": "5"}),
|
|
"watermark": ("BOOLEAN", {"default": True}),
|
|
"seed": ("INT", {"default": -1, "min": -1, "max": SEED_MAX}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING")
|
|
RETURN_NAMES = ("video_url", "task_id")
|
|
FUNCTION = "generate"
|
|
CATEGORY = NODE_CATEGORY
|
|
|
|
def generate(self, prompt, resolution, ratio, duration, watermark, seed):
|
|
return _submit_and_wait(build_text_to_video_payload(prompt, resolution, ratio, duration, watermark, seed))
|
|
|
|
|
|
class HappyHorseImageToVideoNode:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
"prompt": ("STRING", {"multiline": True, "default": ""}),
|
|
"resolution": (RESOLUTIONS, {"default": "1080P"}),
|
|
"duration": (DURATIONS, {"default": "5"}),
|
|
"watermark": ("BOOLEAN", {"default": True}),
|
|
"seed": ("INT", {"default": -1, "min": -1, "max": SEED_MAX}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING")
|
|
RETURN_NAMES = ("video_url", "task_id")
|
|
FUNCTION = "generate"
|
|
CATEGORY = NODE_CATEGORY
|
|
|
|
def generate(self, image, prompt, resolution, duration, watermark, seed):
|
|
first_frame_url = _upload_image_batch(image, minimum=1, maximum=1)[0]
|
|
payload = build_image_to_video_payload(prompt, first_frame_url, resolution, duration, watermark, seed)
|
|
return _submit_and_wait(payload)
|
|
|
|
|
|
class HappyHorseReferenceToVideoNode:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"reference_images": ("IMAGE",),
|
|
"prompt": ("STRING", {"multiline": True, "default": ""}),
|
|
"resolution": (RESOLUTIONS, {"default": "1080P"}),
|
|
"ratio": (RATIOS, {"default": "16:9"}),
|
|
"duration": (DURATIONS, {"default": "5"}),
|
|
"watermark": ("BOOLEAN", {"default": True}),
|
|
"seed": ("INT", {"default": -1, "min": -1, "max": SEED_MAX}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING")
|
|
RETURN_NAMES = ("video_url", "task_id")
|
|
FUNCTION = "generate"
|
|
CATEGORY = NODE_CATEGORY
|
|
|
|
def generate(self, reference_images, prompt, resolution, ratio, duration, watermark, seed):
|
|
urls = _upload_image_batch(reference_images, minimum=1, maximum=9)
|
|
payload = build_reference_to_video_payload(prompt, urls, resolution, ratio, duration, watermark, seed)
|
|
return _submit_and_wait(payload)
|
|
|
|
|
|
class HappyHorseVideoEditNode:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"video_url": ("STRING", {"multiline": False, "default": ""}),
|
|
"prompt": ("STRING", {"multiline": True, "default": ""}),
|
|
"resolution": (RESOLUTIONS, {"default": "1080P"}),
|
|
"watermark": ("BOOLEAN", {"default": True}),
|
|
"audio_setting": (AUDIO_SETTINGS, {"default": "auto"}),
|
|
"seed": ("INT", {"default": -1, "min": -1, "max": SEED_MAX}),
|
|
},
|
|
"optional": {
|
|
"reference_images": ("IMAGE",),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING")
|
|
RETURN_NAMES = ("video_url", "task_id")
|
|
FUNCTION = "generate"
|
|
CATEGORY = NODE_CATEGORY
|
|
|
|
def generate(self, video_url, prompt, resolution, watermark, audio_setting, seed, reference_images=None):
|
|
urls = _upload_image_batch(reference_images, minimum=0, maximum=5) if reference_images is not None else []
|
|
payload = build_video_edit_payload(prompt, video_url, urls, resolution, watermark, audio_setting, seed)
|
|
return _submit_and_wait(payload)
|
|
|
|
|
|
class HappyHorsePreviewVideoNode:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"video_url": ("STRING", {"forceInput": True}),
|
|
"filename_prefix": ("STRING", {"default": DEFAULT_FILENAME_PREFIX}),
|
|
"save_output": ("BOOLEAN", {"default": True}),
|
|
}
|
|
}
|
|
|
|
OUTPUT_NODE = True
|
|
RETURN_TYPES = ("STRING",)
|
|
RETURN_NAMES = ("file_path",)
|
|
FUNCTION = "run"
|
|
CATEGORY = NODE_CATEGORY
|
|
|
|
def run(self, video_url, filename_prefix, save_output):
|
|
if isinstance(video_url, list):
|
|
video_url = video_url[0]
|
|
video_url = str(video_url or "").strip()
|
|
if not video_url:
|
|
raise ValueError("Preview Video received an empty video_url.")
|
|
|
|
if not save_output:
|
|
return {"ui": {"video_url": [video_url]}, "result": ("",)}
|
|
|
|
output_dir = folder_paths.get_output_directory()
|
|
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(
|
|
filename_prefix,
|
|
output_dir,
|
|
)
|
|
file = f"{filename}_{counter:05}_.mp4"
|
|
file_path = os.path.join(full_output_folder, file)
|
|
os.makedirs(full_output_folder, exist_ok=True)
|
|
with open(file_path, "wb") as handle:
|
|
handle.write(_fetch_binary(video_url))
|
|
|
|
_register_output_asset(file_path)
|
|
preview_url = _build_local_media_view_url(file, subfolder, "output")
|
|
return {
|
|
"ui": {
|
|
"images": [_saved_result(file, subfolder, "output")],
|
|
"video_url": [preview_url],
|
|
"animated": (True,),
|
|
},
|
|
"result": (file_path,),
|
|
}
|