Files
comfyui_o1key/utils/video_task.py
T
Jony ba920f2b66 Publish current ComfyUI O1Key code baseline
Replace the prior release tree with the current plugin, frontend, tests, and documentation. Document retired node IDs and the public Gitea update source.
2026-09-24 19:56:48 +08:00

472 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import os
import time
from typing import Any, Callable, Dict, Optional
import aiohttp
# 视频任务轮询总时长上限(秒)。超过后停止等待并抛出 TimeoutError
# 避免任务在服务端长时间无进展时无限轮询、迫使用户手动中断。
POLL_DEADLINE_SECONDS = 2000
class PollDeadline:
"""轮询看门狗:累计等待超过上限即抛出 TimeoutError。
用法:
deadline = PollDeadline(label="K3 图生视频")
while True:
deadline.check()
...
"""
def __init__(self, seconds: float = POLL_DEADLINE_SECONDS, label: str = "视频任务"):
self.seconds = seconds
self.label = label
self.start = time.time()
def elapsed(self) -> float:
return time.time() - self.start
def check(self) -> None:
if self.elapsed() >= self.seconds:
raise TimeoutError(
f"{self.label} 轮询已超过 {self.seconds:.0f}s 仍未完成,已停止等待。"
f"任务可能仍在服务端生成,请稍后重试。"
)
try:
from comfy.model_management import processing_interrupted, InterruptProcessingException
INTERRUPT_AVAILABLE = True
except Exception:
INTERRUPT_AVAILABLE = False
processing_interrupted = lambda: False
class InterruptProcessingException(Exception):
pass
SUCCESS_STATUSES = {"succeed", "succeeded", "success", "completed", "done", "finished"}
FAILURE_STATUSES = {
"fail",
"failed",
"failure",
"error",
"expired",
"timeout",
"timed_out",
"cancel",
"canceled",
"cancelled",
"rejected",
}
SEEDANCE_COPYRIGHT_RESTRICTION_ERRORS = (
"The request failed because the output video may be related to copyright restriction",
"The request failed because the output video may be related to copyright restrictions",
)
SEEDANCE_COPYRIGHT_RESTRICTION_MESSAGE = "输出视频触发版权审查被拒绝生成!"
def format_seedance_generation_error(value: Any) -> str:
"""Normalize errors shared by the two interactive Seedance video nodes."""
message = str(value or "视频生成失败")
normalized_message = message.lower()
if any(
marker.lower() in normalized_message
for marker in SEEDANCE_COPYRIGHT_RESTRICTION_ERRORS
):
return SEEDANCE_COPYRIGHT_RESTRICTION_MESSAGE
return message
def check_interrupt() -> None:
if INTERRUPT_AVAILABLE and processing_interrupted():
raise InterruptProcessingException()
async def interruptible_sleep(seconds: float, step: float = 0.2) -> None:
elapsed = 0.0
while elapsed < seconds:
check_interrupt()
delay = min(step, seconds - elapsed)
await asyncio.sleep(delay)
elapsed += delay
check_interrupt()
async def run_with_interrupt(coro, step: float = 0.2):
task = asyncio.ensure_future(coro)
try:
while not task.done():
check_interrupt()
await asyncio.wait({task}, timeout=step)
check_interrupt()
return await task
except InterruptProcessingException:
task.cancel()
try:
await task
except BaseException:
pass
raise
def _as_dict(value: Any) -> Dict[str, Any]:
return value if isinstance(value, dict) else {}
def _nested_payloads(payload: Dict[str, Any]):
root = _as_dict(payload)
data = _as_dict(root.get("data"))
inner = _as_dict(data.get("data"))
return root, data, inner
def extract_status(payload: Dict[str, Any]) -> str:
root, data, inner = _nested_payloads(payload)
keys = ("status", "task_status", "state", "task_state")
statuses = []
for source in (data, inner, root):
for key in keys:
value = source.get(key)
if value is not None and str(value).strip():
statuses.append(str(value).strip().lower())
for status in statuses:
if status in FAILURE_STATUSES or any(
token in status for token in ("fail", "error", "reject", "timeout", "cancel")
):
return status
for status in statuses:
if status in SUCCESS_STATUSES:
return status
return statuses[0] if statuses else ""
def extract_progress(payload: Dict[str, Any]) -> int:
root, data, inner = _nested_payloads(payload)
for source in (data, inner, root):
value = source.get("progress")
if value is None:
continue
try:
return max(0, min(100, int(float(str(value).strip().rstrip("%")))))
except (TypeError, ValueError):
return 0
return 0
def extract_error_message(payload: Dict[str, Any], default: str = "未知错误") -> str:
root, data, inner = _nested_payloads(payload)
keys = (
"fail_reason",
"failure_reason",
"task_status_msg",
"status_msg",
"error_message",
"message",
"msg",
"reason",
"detail",
"details",
)
for source in (data, inner, root):
error = source.get("error")
if isinstance(error, dict):
for key in ("message", "msg", "detail", "reason", "code"):
value = error.get(key)
if value:
return str(value)
elif error:
return str(error)
for key in keys:
value = source.get(key)
if value:
return str(value)
return default
def extract_video_url(payload: Dict[str, Any]) -> str | None:
root, data, inner = _nested_payloads(payload)
for source in (data, inner, root):
for key in ("video_url", "result_url", "url", "download_url"):
value = source.get(key)
if value:
return str(value)
result = _as_dict(source.get("result"))
for key in ("video_url", "result_url", "url", "download_url"):
value = result.get(key)
if value:
return str(value)
content = _as_dict(source.get("content"))
value = content.get("video_url") or content.get("url")
if value:
return str(value)
task_result = _as_dict(source.get("task_result"))
videos = task_result.get("videos")
if isinstance(videos, list) and videos:
first = _as_dict(videos[0])
value = first.get("url") or first.get("video_url")
if value:
return str(value)
# 腾讯 Kling(v3-t)渠道:完成时视频地址在 metadata.url
metadata = _as_dict(source.get("metadata"))
value = metadata.get("url") or metadata.get("video_url")
if value:
return str(value)
return None
def is_success_status(status: str) -> bool:
return status in SUCCESS_STATUSES
def is_failure_status(status: str, payload: Dict[str, Any] | None = None) -> bool:
if status in FAILURE_STATUSES:
return True
if any(token in status for token in ("fail", "error", "reject", "timeout", "cancel")):
return True
if payload is None:
return False
root, data, inner = _nested_payloads(payload)
failure_keys = ("error", "fail_reason", "failure_reason", "task_status_msg", "error_message")
return any(any(source.get(key) for key in failure_keys) for source in (data, inner, root))
# ═══════════════════════════════════════════════════════════════════════════════
# 视频下载:抗超时 / 可断点续传 / 无限重试 / 可随时取消
# ═══════════════════════════════════════════════════════════════════════════════
#
# 设计目标(针对“视频已在服务端生成成功、后台已扣费,必须把成品拿到手”的场景):
# 1. 不用固定 total 超时一刀切大文件——只要持续有数据就一直下载;
# 用 sock_read 检测“卡死”(连续 N 秒收不到任何字节)才判定异常。
# 2. 网络抖动 / 超时 / 5xx / 连接中断 → 退避后无限重试,直到成功。
# 3. 已下载的字节用 HTTP Range 断点续传,不从 0 重来。
# 4. 永久性错误(403/404/410 等)快速失败,不做无意义的死循环。
# 5. 全程可被 ComfyUI 的“取消”随时打断(每个 attempt 包在 run_with_interrupt 中,
# 分块写入与退避等待都会检查中断)。
# 连续多少秒收不到任何数据就判定当前连接卡死(触发重试,而非整体失败)
DOWNLOAD_SOCK_READ_TIMEOUT = 120
# 建立连接的超时
DOWNLOAD_CONNECT_TIMEOUT = 30
# 重试退避:起始 / 上限(秒)
DOWNLOAD_RETRY_BASE_DELAY = 2.0
DOWNLOAD_RETRY_MAX_DELAY = 30.0
# 视为“永久失败、无需重试”的 HTTP 状态码
DOWNLOAD_PERMANENT_STATUS = {400, 401, 403, 404, 405, 410, 451}
class _PermanentDownloadError(RuntimeError):
"""不可重试的下载错误(如 403/404)。"""
class _IncompleteDownloadError(RuntimeError):
"""连接被提前关闭、文件未下完,需要续传重试。"""
def _download_timeout(sock_read: float) -> aiohttp.ClientTimeout:
# total=None:不限制总时长,让缓慢但持续的大文件下载得以完成;
# sock_connect/sock_read:分别约束“连接建立”和“两次收包之间”的最大间隔。
return aiohttp.ClientTimeout(
total=None,
connect=None,
sock_connect=DOWNLOAD_CONNECT_TIMEOUT,
sock_read=sock_read,
)
async def _stream_once(
session: aiohttp.ClientSession,
url: str,
fobj,
*,
headers: Optional[dict],
resume_from: int,
sock_read: float,
chunk_size: int,
on_bytes: Optional[Callable[[int], None]],
) -> int:
"""发起一次 GET 并把响应体写入已打开的文件对象 fobj。
返回“本次结束后文件应当达到的总字节数”(已知时),未知时返回 -1。
若服务端支持 Range 且 resume_from>0,则带上 Range 头从断点继续;
否则从头下载(必要时先 truncate)。出错时抛异常,由上层决定是否重试。
"""
req_headers = dict(headers) if headers else {}
if resume_from > 0:
req_headers["Range"] = f"bytes={resume_from}-"
timeout = _download_timeout(sock_read)
async with session.get(
url, headers=req_headers or None, timeout=timeout, allow_redirects=True
) as resp:
status = resp.status
# 206:服务端接受断点续传,从 resume_from 续写。
# 200:服务端忽略 Range(或本就从头下),需从文件起点重写。
base = resume_from
if resume_from > 0 and status == 200:
fobj.seek(0)
fobj.truncate(0)
base = 0
elif status not in (200, 206):
text = ""
try:
text = (await resp.text())[:500]
except Exception:
pass
if status in DOWNLOAD_PERMANENT_STATUS:
raise _PermanentDownloadError(f"视频下载失败 ({status}){text}")
raise RuntimeError(f"视频下载失败 ({status}){text}")
# 解析“完整文件总大小”,用于检测连接被提前关闭导致的截断。
expected_total = _expected_total_size(resp, base)
async for chunk in resp.content.iter_chunked(chunk_size):
check_interrupt()
if chunk:
fobj.write(chunk)
if on_bytes:
on_bytes(len(chunk))
fobj.flush()
return expected_total
def _expected_total_size(resp: aiohttp.ClientResponse, base: int) -> int:
"""根据响应头推断完整文件总字节数;无法判断时返回 -1。"""
# Content-Range: bytes start-end/total → total 即完整大小
cr = resp.headers.get("Content-Range", "")
if "/" in cr:
tail = cr.rsplit("/", 1)[-1].strip()
if tail.isdigit():
return int(tail)
# Content-Length 是“本次响应体长度”,加上已续传的 base 即完整大小
cl = resp.headers.get("Content-Length")
if cl is not None and cl.isdigit():
return base + int(cl)
return -1
async def download_video_to_file(
session: aiohttp.ClientSession,
url: str,
save_path: str,
*,
headers: Optional[dict] = None,
sock_read: float = DOWNLOAD_SOCK_READ_TIMEOUT,
chunk_size: int = 1024 * 1024,
label: str = "视频",
on_bytes: Optional[Callable[[int], None]] = None,
max_retries: Optional[int] = None,
) -> str:
"""把远程视频下载到 save_path,抗超时 + 断点续传 + 无限重试 + 可取消。
- 已生成成功的视频务必拿到手:默认 max_retries=None 表示对“可重试错误”
(网络中断 / 超时 / 5xx / 连接失败)一直重试,直到成功。
- 永久性错误(403/404/410 等)立即抛出,不做无意义重试。
- 通过 HTTP Range 从已落盘的字节处续传,不重复下载。
- 通过 check_interrupt() 全程响应 ComfyUI 取消。
返回 save_path。
"""
parent = os.path.dirname(save_path)
if parent:
os.makedirs(parent, exist_ok=True)
# 起始先清空目标文件:避免对调用方残留的旧文件做错误续传(续传只针对本次调用
# 内部已写入的字节)。
try:
with open(save_path, "wb"):
pass
except OSError:
pass
attempt = 0
while True:
check_interrupt()
# 仅对“本次调用已落盘”的字节做断点续传。
resume_from = 0
if os.path.isfile(save_path):
try:
resume_from = os.path.getsize(save_path)
except OSError:
resume_from = 0
# 已有部分文件 → 追加续写;否则新建。
mode = "r+b" if resume_from > 0 else "wb"
try:
with open(save_path, mode) as f:
if resume_from > 0:
f.seek(0, os.SEEK_END)
expected_total = await run_with_interrupt(
_stream_once(
session, url, f,
headers=headers,
resume_from=resume_from,
sock_read=sock_read,
chunk_size=chunk_size,
on_bytes=on_bytes,
)
)
# 走到这里说明本次 GET 的响应体已读尽、连接已关闭。
size = os.path.getsize(save_path) if os.path.isfile(save_path) else 0
if size <= 0:
raise RuntimeError(f"{label}下载失败:保存后的文件为空。")
# 连接被提前关闭(截断):实际大小 < 服务端声明的完整大小 → 续传重试。
if expected_total > 0 and size < expected_total:
raise _IncompleteDownloadError(
f"{label}下载不完整:{size}/{expected_total} 字节,将续传。"
)
return save_path
except InterruptProcessingException:
raise
except _PermanentDownloadError:
raise
except (_IncompleteDownloadError, aiohttp.ClientError, asyncio.TimeoutError, OSError) as e:
attempt += 1
if max_retries is not None and attempt > max_retries:
raise RuntimeError(f"{label}下载失败(已重试 {max_retries} 次):{e}") from None
delay = min(DOWNLOAD_RETRY_BASE_DELAY * (2 ** (attempt - 1)), DOWNLOAD_RETRY_MAX_DELAY)
done = "续传" if (os.path.isfile(save_path) and os.path.getsize(save_path) > 0) else "重连"
print(f"[{label}] 下载中断({type(e).__name__}),{delay:.0f}s 后{done}重试(第 {attempt} 次)...")
await interruptible_sleep(delay)
async def download_video_bytes(
session: aiohttp.ClientSession,
url: str,
*,
headers: Optional[dict] = None,
label: str = "视频",
**kwargs,
) -> bytes:
"""与 download_video_to_file 相同的健壮性,但返回内存中的 bytes。
内部仍落盘到临时文件以支持断点续传,读出后删除。
"""
import tempfile
fd, tmp_path = tempfile.mkstemp(suffix=".bin", prefix="o1key_dl_")
os.close(fd)
try:
await download_video_to_file(
session, url, tmp_path, headers=headers, label=label, **kwargs
)
with open(tmp_path, "rb") as f:
return f.read()
finally:
try:
os.remove(tmp_path)
except OSError:
pass