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:
+1
-77
@@ -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 以下。"
|
||||
|
||||
+4
-88
@@ -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/<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:
|
||||
"""从 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")
|
||||
|
||||
Reference in New Issue
Block a user