feat: Seedance 视频/音频改用 R2 预签名上传,移除插件内凭证

- 新增 utils/r2_uploader.py:通过 o1key 后端预签名接口上传视频/音频,零 R2 凭证
- seedance_video.py:视频、音频均改为 await upload_video/upload_audio,移除 base64 inline 编码
- 删除 doubao-seedance-2-0-fast 模型选项
- .config 加入 .gitignore,停止 git 追踪,防止 API Key 泄露
- base_client.py:新增系统代理自动检测(Windows 注册表 / macOS networksetup)
This commit is contained in:
Jony
2026-04-21 00:10:55 +08:00
parent c4bb8d9724
commit 07f0c5ed5f
9 changed files with 276 additions and 173 deletions
-1
View File
@@ -1 +0,0 @@
O1KEY_API_KEY=
+3
View File
@@ -21,3 +21,6 @@ venv/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# 用户配置(含 API Key,不提交)
.config
+123 -3
View File
@@ -9,9 +9,124 @@ import threading
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any, Callable, Dict, List, Optional from typing import Any, Callable, Dict, List, Optional
import os
import platform
import time
import aiohttp 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 注册表读取 < 1msTTL 设短;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): class BaseAPIClient(ABC):
""" """
API 客户端抽象基类 API 客户端抽象基类
@@ -195,8 +310,9 @@ class BaseAPIClient(ABC):
) )
async def _do_request(): async def _do_request():
_proxy = _get_proxy()
connect_start = time.time() 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 connect_time = time.time() - connect_start
if response.status != 200: if response.status != 200:
@@ -308,7 +424,10 @@ class BaseAPIClient(ABC):
close_session = True close_session = True
try: 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: if response.status != 200:
error_text = await response.text() error_text = await response.text()
@@ -375,7 +494,8 @@ class BaseAPIClient(ABC):
f"API 返回错误:{error_message}" f"API 返回错误:{error_message}"
) )
return await response.json() _resp_data = await response.json()
return _resp_data
finally: finally:
if close_session: if close_session:
+9 -1
View File
@@ -577,7 +577,14 @@ class GeminiAPIClient(BaseAPIClient):
download_info = f"{img_size_str}" 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 total_time = time.time() - total_start
@@ -721,6 +728,7 @@ class GeminiAPIClient(BaseAPIClient):
raise first_error raise first_error
raise RuntimeError(f"批量生成失败,{fail_count} 个请求全部失败") raise RuntimeError(f"批量生成失败,{fail_count} 个请求全部失败")
if batch_size > 1:
print(f"GeminiClient: 批量生成完成,成功 {success_count}/{batch_size},失败 {fail_count}") print(f"GeminiClient: 批量生成完成,成功 {success_count}/{batch_size},失败 {fail_count}")
return all_images return all_images
+8 -1
View File
@@ -632,7 +632,14 @@ class OpenAIAPIClient(BaseAPIClient):
else: else:
download_info = img_size_str 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 total_time = time.time() - total_start
timing_info = { timing_info = {
-76
View File
@@ -43,14 +43,6 @@ VIDEO_MIME_TYPES = {
".3gpp": "video/3gpp" ".3gpp": "video/3gpp"
} }
# 尝试导入视频处理库
try:
import cv2
CV2_AVAILABLE = True
except ImportError:
CV2_AVAILABLE = False
print("⚠️ Google Gemini: OpenCV (cv2) 不可用,视频压缩功能将受限")
try: try:
import subprocess import subprocess
FFMPEG_AVAILABLE = True FFMPEG_AVAILABLE = True
@@ -328,63 +320,6 @@ class GoogleGemini:
print(f"Google Gemini: 视频压缩异常: {str(e)}") print(f"Google Gemini: 视频压缩异常: {str(e)}")
return False 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: def _compress_video(self, video_path: str) -> str:
""" """
压缩视频到 1-10MB 之间 压缩视频到 1-10MB 之间
@@ -425,17 +360,6 @@ class GoogleGemini:
# 如果仍然太大,继续降低目标 # 如果仍然太大,继续降低目标
os.remove(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( raise ValueError(
f"视频文件过大 ({original_size / 1024 / 1024:.2f}MB) 且无法压缩到 20MB 以下。" f"视频文件过大 ({original_size / 1024 / 1024:.2f}MB) 且无法压缩到 20MB 以下。"
+4 -88
View File
@@ -14,6 +14,7 @@ import torch
from ..clients.seedance_client import SeedanceClient from ..clients.seedance_client import SeedanceClient
from ..clients.gemini_client import GeminiAPIClient from ..clients.gemini_client import GeminiAPIClient
from ..utils.image_utils import tensor_to_pil, encode_image_to_base64, pil_to_tensor 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 from comfy_api.latest import InputImpl
@@ -28,7 +29,6 @@ except ImportError:
_MODELS = [ _MODELS = [
"doubao-seedance-2-0-260128", "doubao-seedance-2-0-260128",
"doubao-seedance-2-0-fast-260128",
] ]
_RESOLUTIONS = ["720p", "480p"] _RESOLUTIONS = ["720p", "480p"]
@@ -73,78 +73,6 @@ def _tensor_to_base64_url(tensor) -> str:
return f"data:image/png;base64,{b64}" return f"data:image/png;base64,{b64}"
def _video_to_base64_url(video) -> str:
"""ComfyUI VIDEO 对象 → data:video/<ext>;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 dictwaveform 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("<I", 36 + data_size))
buf.write(b"WAVE")
# fmt chunk
buf.write(b"fmt ")
buf.write(struct.pack("<IHHIIHH", 16, 1, num_channels, sample_rate,
byte_rate, block_align, bits_per_sample))
# data chunk
buf.write(b"data")
buf.write(struct.pack("<I", data_size))
buf.write(pcm.tobytes())
b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
return f"data:audio/wav;base64,{b64}"
async def _url_to_tensor(url: str) -> torch.Tensor: async def _url_to_tensor(url: str) -> torch.Tensor:
"""从 URL 下载图片并转为 ComfyUI IMAGE tensor,失败时返回 None""" """从 URL 下载图片并转为 ComfyUI IMAGE tensor,失败时返回 None"""
@@ -201,6 +129,7 @@ def _make_callbacks(tag: str, pbar):
return on_stage, on_progress return on_stage, on_progress
# ── 统一节点 ───────────────────────────────────────────────────────────────── # ── 统一节点 ─────────────────────────────────────────────────────────────────
# #
# 模式由图片输入自动判断: # 模式由图片输入自动判断:
@@ -454,7 +383,7 @@ class SeedanceMultiModal:
# 参考视频(最多3个) # 参考视频(最多3个)
for v in ref_videos: for v in ref_videos:
url = _video_to_base64_url(v) url = await upload_video(v)
content.append({ content.append({
"type": "video_url", "type": "video_url",
"video_url": {"url": url}, "video_url": {"url": url},
@@ -463,7 +392,7 @@ class SeedanceMultiModal:
# 参考音频(最多3段) # 参考音频(最多3段)
for a in ref_audios: for a in ref_audios:
url = _audio_to_base64_url(a) url = await upload_audio(a)
content.append({ content.append({
"type": "audio_url", "type": "audio_url",
"audio_url": {"url": url}, "audio_url": {"url": url},
@@ -511,19 +440,6 @@ class SeedanceMultiModal:
if first_image_url: if first_image_url:
body["image"] = 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() video_dir = _get_video_output_dir()
counter = _get_next_counter(video_dir, "seedance_mm") counter = _get_next_counter(video_dir, "seedance_mm")
+1 -1
View File
@@ -15,7 +15,7 @@ CONFIG_FILE = os.path.join(PLUGIN_ROOT, ".config")
# ============ API 基础配置 ============ # ============ API 基础配置 ============
# 所有 API 客户端的统一基础 URL # 所有 API 客户端的统一基础 URL
# 可通过环境变量 O1KEY_API_BASE_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]: def load_config(config_path: Optional[str] = None) -> Dict[str, str]:
+126
View File
@@ -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 dictwaveform 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("<I", 36 + data_size))
buf.write(b"WAVE")
buf.write(b"fmt ")
buf.write(struct.pack("<IHHIIHH", 16, 1, num_channels, sample_rate,
byte_rate, block_align, bits_per_sample))
buf.write(b"data")
buf.write(struct.pack("<I", data_size))
buf.write(pcm.tobytes())
data = buf.getvalue()
filename = f"{uuid.uuid4()}.wav"
upload_url, public_url = await _presign(filename, "audio/wav")
await _put_upload(upload_url, data, "audio/wav")
print(f"[R2] 音频已上传: {public_url}")
return public_url