diff --git a/.config b/.config deleted file mode 100644 index cece251..0000000 --- a/.config +++ /dev/null @@ -1 +0,0 @@ -O1KEY_API_KEY= \ No newline at end of file diff --git a/.gitignore b/.gitignore index a614a27..ebc61e1 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ venv/ # OS .DS_Store Thumbs.db + +# 用户配置(含 API Key,不提交) +.config diff --git a/clients/base_client.py b/clients/base_client.py index cadef18..54a95d2 100644 --- a/clients/base_client.py +++ b/clients/base_client.py @@ -9,9 +9,124 @@ import threading from abc import ABC, abstractmethod from typing import Any, Callable, Dict, List, Optional +import os +import platform +import time + import aiohttp +def _detect_proxy() -> str | None: + """ + 检测当前系统的 HTTP 代理地址。 + 只读取,不修改任何环境变量或系统设置。 + + 检测顺序: + 1. 环境变量 HTTPS_PROXY / HTTP_PROXY(用户/启动脚本已配置时直接用) + 2. Windows 注册表 Internet Settings + 3. macOS networksetup + + Returns: + 代理 URL 字符串(如 "http://127.0.0.1:10808"),未检测到返回 None。 + """ + # 1. 环境变量优先 + for key in ("HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy", "ALL_PROXY", "all_proxy"): + val = os.environ.get(key) + if val: + return val + + system = platform.system() + + # 2. Windows 注册表 + if system == "Windows": + try: + import winreg + reg_key = winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Internet Settings", + ) + enabled = winreg.QueryValueEx(reg_key, "ProxyEnable")[0] + if enabled: + server = winreg.QueryValueEx(reg_key, "ProxyServer")[0] + if server: + # 过滤掉 "http=...;https=..." 多协议格式,取第一个可用地址 + if "=" in server: + # 例: "http=127.0.0.1:10808;https=127.0.0.1:10808" + for part in server.split(";"): + if "=" in part: + addr = part.split("=", 1)[1].strip() + if addr: + return f"http://{addr}" + else: + return f"http://{server}" + except Exception: + pass + + # 3. macOS networksetup + elif system == "Darwin": + try: + import subprocess + for iface in ("Wi-Fi", "Ethernet", "USB 10/100/1000 LAN"): + for flag, proto in (("-getsecurewebproxy", "https"), ("-getwebproxy", "http"), + ("-getsocksfirewallproxy", "socks5")): + out = subprocess.run( + ["networksetup", flag, iface], + capture_output=True, text=True, timeout=3, + ).stdout + if "Enabled: Yes" in out: + lines = {l.split(":")[0].strip(): l.split(":", 1)[1].strip() + for l in out.splitlines() if ":" in l} + host = lines.get("Server", "") + port = lines.get("Port", "") + if host and port and port != "0": + return f"{proto}://{host}:{port}" + except Exception: + pass + + return None + + +# ── 带 TTL 的代理缓存 ────────────────────────────────────────────────────────── +# Windows 注册表读取 < 1ms,TTL 设短;macOS 需要子进程,TTL 设长一些。 +_PROXY_TTL = 3.0 if platform.system() == "Windows" else 10.0 +_proxy_cache_value: str | None = None +_proxy_cache_expires: float = 0.0 +_proxy_cache_lock = threading.Lock() + + +def _get_proxy() -> str | None: + """ + 返回当前生效的代理地址(带 TTL 缓存)。 + 缓存过期后重新检测,代理状态变化时自动打印日志。 + """ + global _proxy_cache_value, _proxy_cache_expires + + now = time.monotonic() + if now < _proxy_cache_expires: # 缓存命中,零开销 + return _proxy_cache_value + + with _proxy_cache_lock: + if now < _proxy_cache_expires: # 双重检查,防止并发重复检测 + return _proxy_cache_value + + new_value = _detect_proxy() + + if new_value != _proxy_cache_value: + if new_value: + print("[o1key] 检测到代理已开启") + else: + print("[o1key] 代理已关闭,切换为直连模式") + + _proxy_cache_value = new_value + _proxy_cache_expires = time.monotonic() + _PROXY_TTL + return _proxy_cache_value + + +# 启动时打印一次初始状态 +_initial = _get_proxy() +print(f"[o1key] 启动代理检测: {'已开启' if _initial else '未开启,直连模式'}") + + class BaseAPIClient(ABC): """ API 客户端抽象基类 @@ -195,8 +310,9 @@ class BaseAPIClient(ABC): ) async def _do_request(): + _proxy = _get_proxy() connect_start = time.time() - async with session.post(url, json=request_body, headers=headers, timeout=_aiohttp_timeout) as response: + async with session.post(url, json=request_body, headers=headers, timeout=_aiohttp_timeout, proxy=_proxy) as response: connect_time = time.time() - connect_start if response.status != 200: @@ -308,7 +424,10 @@ class BaseAPIClient(ABC): close_session = True try: - async with session.get(url, headers=headers) as response: + _proxy = _get_proxy() + _get_start = time.time() + async with session.get(url, headers=headers, proxy=_proxy) as response: + _get_elapsed = time.time() - _get_start if response.status != 200: error_text = await response.text() @@ -375,7 +494,8 @@ class BaseAPIClient(ABC): f"API 返回错误:{error_message}" ) - return await response.json() + _resp_data = await response.json() + return _resp_data finally: if close_session: diff --git a/clients/gemini_client.py b/clients/gemini_client.py index 941e4a6..e468a93 100644 --- a/clients/gemini_client.py +++ b/clients/gemini_client.py @@ -577,7 +577,14 @@ class GeminiAPIClient(BaseAPIClient): download_info = f"{img_size_str}" # 单行输出 - print(f"{task_prefix}请求 {size_str} → API {request_time:.1f}s → {download_info} ✓") + timing = response.get("_timing", {}) + net_connect = timing.get("connect_time") + net_download = timing.get("download_time") + if net_connect is not None and net_download is not None: + net_str = f" | 连接 {net_connect:.2f}s | 下载 {net_download:.2f}s" + else: + net_str = "" + print(f"{task_prefix}请求 {size_str} → API {request_time:.1f}s → {download_info} ✓{net_str}") # 返回结果和计时信息 total_time = time.time() - total_start @@ -721,7 +728,8 @@ class GeminiAPIClient(BaseAPIClient): raise first_error raise RuntimeError(f"批量生成失败,{fail_count} 个请求全部失败") - print(f"GeminiClient: 批量生成完成,成功 {success_count}/{batch_size},失败 {fail_count}") + if batch_size > 1: + print(f"GeminiClient: 批量生成完成,成功 {success_count}/{batch_size},失败 {fail_count}") return all_images def generate_sync( diff --git a/clients/openai_client.py b/clients/openai_client.py index 72ad2ce..4dc5e39 100644 --- a/clients/openai_client.py +++ b/clients/openai_client.py @@ -632,7 +632,14 @@ class OpenAIAPIClient(BaseAPIClient): else: download_info = img_size_str - print(f"{task_prefix} 请求 {size_str} → API {request_time:.1f}s → {download_info} ✓") + timing = response.get("_timing", {}) + net_connect = timing.get("connect_time") + net_download = timing.get("download_time") + if net_connect is not None and net_download is not None: + net_str = f" | 连接 {net_connect:.2f}s | 下载 {net_download:.2f}s" + else: + net_str = "" + print(f"{task_prefix} 请求 {size_str} → API {request_time:.1f}s → {download_info} ✓{net_str}") total_time = time.time() - total_start timing_info = { diff --git a/nodes/google_gemini.py b/nodes/google_gemini.py index 6c4f1cd..8434fa2 100644 --- a/nodes/google_gemini.py +++ b/nodes/google_gemini.py @@ -43,14 +43,6 @@ VIDEO_MIME_TYPES = { ".3gpp": "video/3gpp" } -# 尝试导入视频处理库 -try: - import cv2 - CV2_AVAILABLE = True -except ImportError: - CV2_AVAILABLE = False - print("⚠️ Google Gemini: OpenCV (cv2) 不可用,视频压缩功能将受限") - try: import subprocess FFMPEG_AVAILABLE = True @@ -328,63 +320,6 @@ class GoogleGemini: print(f"Google Gemini: 视频压缩异常: {str(e)}") return False - def _compress_video_with_opencv(self, input_path: str, output_path: str, scale: float = 0.5) -> bool: - """ - 使用 OpenCV 压缩视频(备用方案) - - Args: - input_path: 输入视频路径 - output_path: 输出视频路径 - scale: 尺寸缩放比例 - - Returns: - 是否压缩成功 - """ - if not CV2_AVAILABLE: - return False - - try: - cap = cv2.VideoCapture(input_path) - if not cap.isOpened(): - return False - - # 获取原视频参数 - fps = cap.get(cv2.CAP_PROP_FPS) - width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) - height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) - - # 计算新尺寸 - new_width = int(width * scale) - new_height = int(height * scale) - - # 创建视频写入器 - fourcc = cv2.VideoWriter_fourcc(*'mp4v') - out = cv2.VideoWriter(output_path, fourcc, fps, (new_width, new_height)) - - print(f"Google Gemini: 使用 OpenCV 压缩视频,分辨率 {width}x{height} -> {new_width}x{new_height}") - - while True: - ret, frame = cap.read() - if not ret: - break - - # 缩放帧 - resized = cv2.resize(frame, (new_width, new_height)) - out.write(resized) - - cap.release() - out.release() - - if os.path.exists(output_path): - final_size = os.path.getsize(output_path) - print(f"Google Gemini: 视频压缩完成,最终大小 {final_size / 1024 / 1024:.2f}MB") - return True - return False - - except Exception as e: - print(f"Google Gemini: OpenCV 压缩失败: {str(e)}") - return False - def _compress_video(self, video_path: str) -> str: """ 压缩视频到 1-10MB 之间 @@ -424,18 +359,7 @@ class GoogleGemini: return output_path # 如果仍然太大,继续降低目标 os.remove(output_path) - - # FFmpeg 失败或不可用,尝试 OpenCV - if CV2_AVAILABLE: - scales = [0.7, 0.5, 0.4, 0.3, 0.25] - for scale in scales: - if self._compress_video_with_opencv(video_path, output_path, scale): - final_size = os.path.getsize(output_path) - if final_size <= MAX_FILE_SIZE: - return output_path - # 如果仍然太大,继续降低分辨率 - os.remove(output_path) - + # 所有压缩方法都失败 raise ValueError( f"视频文件过大 ({original_size / 1024 / 1024:.2f}MB) 且无法压缩到 20MB 以下。" diff --git a/nodes/seedance_video.py b/nodes/seedance_video.py index b2eb7b4..27f7447 100644 --- a/nodes/seedance_video.py +++ b/nodes/seedance_video.py @@ -14,6 +14,7 @@ import torch from ..clients.seedance_client import SeedanceClient from ..clients.gemini_client import GeminiAPIClient from ..utils.image_utils import tensor_to_pil, encode_image_to_base64, pil_to_tensor +from ..utils.r2_uploader import upload_video, upload_audio from comfy_api.latest import InputImpl @@ -28,7 +29,6 @@ except ImportError: _MODELS = [ "doubao-seedance-2-0-260128", - "doubao-seedance-2-0-fast-260128", ] _RESOLUTIONS = ["720p", "480p"] @@ -73,78 +73,6 @@ def _tensor_to_base64_url(tensor) -> str: return f"data:image/png;base64,{b64}" -def _video_to_base64_url(video) -> str: - """ComfyUI VIDEO 对象 → data:video/;base64,xxx""" - import base64 - import io as _io - - source = video.get_stream_source() - - if isinstance(source, _io.BytesIO): - source.seek(0) - data = source.read() - ext = "mp4" - else: - video_path = source - if not video_path or not os.path.isfile(video_path): - raise ValueError(f"无法获取参考视频文件路径(当前路径:{video_path})") - ext = os.path.splitext(video_path)[1].lower().lstrip(".") - if ext not in ("mp4", "mov"): - raise ValueError(f"参考视频格式须为 mp4 或 mov,当前为 .{ext}") - with open(video_path, "rb") as f: - data = f.read() - - b64 = base64.b64encode(data).decode("utf-8") - return f"data:video/{ext};base64,{b64}" - - -def _audio_to_base64_url(audio) -> str: - """ComfyUI AUDIO dict(waveform tensor + sample_rate)→ data:audio/wav;base64,xxx""" - import base64 - import io - import struct - import numpy as np - - waveform = audio["waveform"] # shape: [B, C, N] or [C, N] - sample_rate = int(audio["sample_rate"]) - - # 统一为 [C, N] - if waveform.dim() == 3: - waveform = waveform[0] - - # 转为 numpy float32,然后转 int16 PCM - wav_np = waveform.cpu().numpy() - if wav_np.ndim == 2: - # 多声道 → 单声道(取均值) - wav_np = wav_np.mean(axis=0) - wav_np = np.clip(wav_np, -1.0, 1.0) - pcm = (wav_np * 32767).astype(np.int16) - - # 写 WAV 文件到内存 - buf = io.BytesIO() - num_samples = len(pcm) - num_channels = 1 - bits_per_sample = 16 - byte_rate = sample_rate * num_channels * bits_per_sample // 8 - block_align = num_channels * bits_per_sample // 8 - data_size = num_samples * block_align - - # RIFF header - buf.write(b"RIFF") - buf.write(struct.pack(" torch.Tensor: """从 URL 下载图片并转为 ComfyUI IMAGE tensor,失败时返回 None""" @@ -201,6 +129,7 @@ def _make_callbacks(tag: str, pbar): return on_stage, on_progress + # ── 统一节点 ───────────────────────────────────────────────────────────────── # # 模式由图片输入自动判断: @@ -454,7 +383,7 @@ class SeedanceMultiModal: # 参考视频(最多3个) for v in ref_videos: - url = _video_to_base64_url(v) + url = await upload_video(v) content.append({ "type": "video_url", "video_url": {"url": url}, @@ -463,7 +392,7 @@ class SeedanceMultiModal: # 参考音频(最多3段) for a in ref_audios: - url = _audio_to_base64_url(a) + url = await upload_audio(a) content.append({ "type": "audio_url", "audio_url": {"url": url}, @@ -511,19 +440,6 @@ class SeedanceMultiModal: if first_image_url: body["image"] = first_image_url - # ── 打印请求体结构(base64 截断显示)──────────────────────────────── - import json as _json, copy as _copy - def _truncate_body(obj): - if isinstance(obj, dict): - return {k: _truncate_body(v) for k, v in obj.items()} - if isinstance(obj, list): - return [_truncate_body(i) for i in obj] - if isinstance(obj, str) and obj.startswith("data:") and len(obj) > 80: - return obj[:60] + f"...[{len(obj)}chars]" - return obj - print("[SeedanceMultiModal] 请求体预览:") - print(_json.dumps(_truncate_body(_copy.deepcopy(body)), ensure_ascii=False, indent=2)) - # ── 保存路径 ────────────────────────────────────────────────────── video_dir = _get_video_output_dir() counter = _get_next_counter(video_dir, "seedance_mm") diff --git a/utils/config.py b/utils/config.py index fad6f1b..90bf003 100644 --- a/utils/config.py +++ b/utils/config.py @@ -15,7 +15,7 @@ CONFIG_FILE = os.path.join(PLUGIN_ROOT, ".config") # ============ API 基础配置 ============ # 所有 API 客户端的统一基础 URL # 可通过环境变量 O1KEY_API_BASE_URL 覆盖 -DEFAULT_API_BASE_URL = "https://vip.o1key.com" +DEFAULT_API_BASE_URL = "https://api.o1key.com" def load_config(config_path: Optional[str] = None) -> Dict[str, str]: diff --git a/utils/r2_uploader.py b/utils/r2_uploader.py new file mode 100644 index 0000000..8d65757 --- /dev/null +++ b/utils/r2_uploader.py @@ -0,0 +1,126 @@ +""" +R2 文件上传工具(通过 o1key 后端预签名接口) +- 插件内零 R2 凭证,仅使用用户的 O1KEY_API_KEY +- 流程:请求预签名 URL → PUT 直传 R2 → 返回公网 URL +""" + +import io +import os +import uuid + +import aiohttp + +from .config import get_api_key_or_raise, get_api_base_url + + +async def _presign(filename: str, content_type: str) -> tuple: + """向 o1key 后端请求预签名 URL,返回 (upload_url, public_url)""" + api_key = get_api_key_or_raise() + base_url = get_api_base_url() + + async with aiohttp.ClientSession() as session: + async with session.post( + f"{base_url}/v1/storage/presign", + headers={"Authorization": f"Bearer {api_key}"}, + json={"filename": filename, "content_type": content_type}, + timeout=aiohttp.ClientTimeout(total=10), + ) as resp: + if resp.status != 200: + text = await resp.text() + raise RuntimeError(f"预签名请求失败 ({resp.status}): {text}") + data = await resp.json() + + return data["upload_url"], data["public_url"] + + +async def _put_upload(upload_url: str, data: bytes, content_type: str): + """用预签名 URL 直传文件到 R2(不带 Authorization)""" + async with aiohttp.ClientSession() as session: + async with session.put( + upload_url, + data=data, + headers={"Content-Type": content_type}, + timeout=aiohttp.ClientTimeout(total=120), + ) as resp: + if resp.status not in (200, 204): + text = await resp.text() + raise RuntimeError(f"文件上传失败 ({resp.status}): {text}") + + +async def upload_video(video) -> str: + """ + 接受 ComfyUI VIDEO 对象,上传到 R2,返回公网 URL。 + 支持 mp4 / mov 格式。 + """ + source = video.get_stream_source() + + if isinstance(source, io.BytesIO): + source.seek(0) + data = source.read() + ext = "mp4" + else: + video_path = source + if not video_path or not os.path.isfile(video_path): + raise ValueError(f"无法获取参考视频文件路径(当前路径:{video_path})") + ext = os.path.splitext(video_path)[1].lower().lstrip(".") + if ext not in ("mp4", "mov"): + raise ValueError(f"参考视频格式须为 mp4 或 mov,当前为 .{ext}") + with open(video_path, "rb") as f: + data = f.read() + + content_type = "video/mp4" if ext == "mp4" else "video/quicktime" + filename = f"{uuid.uuid4()}.{ext}" + + upload_url, public_url = await _presign(filename, content_type) + await _put_upload(upload_url, data, content_type) + + print(f"[R2] 视频已上传: {public_url}") + return public_url + + +async def upload_audio(audio) -> str: + """ + 接受 ComfyUI AUDIO dict(waveform tensor + sample_rate), + 编码为 WAV 后上传到 R2,返回公网 URL。 + """ + import struct + import numpy as np + + waveform = audio["waveform"] # shape: [B, C, N] or [C, N] + sample_rate = int(audio["sample_rate"]) + + if waveform.dim() == 3: + waveform = waveform[0] + + wav_np = waveform.cpu().numpy() + if wav_np.ndim == 2: + wav_np = wav_np.mean(axis=0) + wav_np = np.clip(wav_np, -1.0, 1.0) + pcm = (wav_np * 32767).astype(np.int16) + + num_samples = len(pcm) + num_channels = 1 + bits_per_sample = 16 + byte_rate = sample_rate * num_channels * bits_per_sample // 8 + block_align = num_channels * bits_per_sample // 8 + data_size = num_samples * block_align + + buf = io.BytesIO() + buf.write(b"RIFF") + buf.write(struct.pack("