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:
@@ -21,3 +21,6 @@ venv/
|
|||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# 用户配置(含 API Key,不提交)
|
||||||
|
.config
|
||||||
|
|||||||
+123
-3
@@ -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 注册表读取 < 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):
|
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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
@@ -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 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("<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
@@ -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]:
|
||||||
|
|||||||
@@ -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("<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
|
||||||
Reference in New Issue
Block a user