feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。
**目前能用的(真环境实测,非推断)**:
profile/other : 200, 7075 字节 —— 博主主页指标(粉丝/获赞/作品数/昵称)
aweme/detail : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)
**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
挑中的恰好是「批量拉作品列表」这个最敏感的动作。
2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
代价,过几小时要重测)。
所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。
**排查中控住变量后得到的两条事实**(都写进注释了):
· `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
(我先前猜的 sec-ch-ua 不是关键。)
· 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
从 CDP 取齐 cookie + UA + hints,而不是各自写死。
「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。
测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
person_swap — 人物置换(全身身份替换)视频生成模块
|
||||
|
||||
基于阿里云百炼 Wan2.2-Animate (图+原视频 → 保留动作的新人物视频)
|
||||
|
||||
模块组成:
|
||||
api.py — 百炼异步任务封装(上传/提交/轮询/下载) + 账号探活
|
||||
preprocess.py — ffmpeg 素材合规化(转码/竖屏裁切/限长/参考图归一化)
|
||||
service.py — 任务队列(SQLite + 后台 worker) + 重试 + 费用护栏
|
||||
|
||||
运行数据(程序自建, 不入 git):
|
||||
agent-local/runtime/person_swap/{tasks.db, uploads/, outputs/}
|
||||
|
||||
⚠️ 账号欠费(Arrearage)会阻塞生成 — 页面/api 会给出明确提示, 处理后无需改代码。
|
||||
⚠️ 模型 id 与 input 字段名: 配置 person_swap.model / input_image_field / input_video_field,
|
||||
待账号开通后在百炼控制台模型广场核对校准。
|
||||
"""
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,324 @@
|
||||
"""
|
||||
person_swap/api.py — 阿里云百炼 Wan2.2-Animate 人物置换异步任务封装
|
||||
|
||||
人物置换(全身身份替换, 非换脸):
|
||||
原视频(人物A表演/动作) + 目标人物参考图(人物B 形象/衣着) → 输出「人物B 保留 A 动作姿态」的新视频
|
||||
|
||||
DashScope 异步任务通用流程:
|
||||
1. POST /api/v1/uploads?model={model} 上传素材(参考图/源视频) → 拿文件 URL
|
||||
2. POST .../video-generation/video-synthesis (X-DashScope-Async: enable)
|
||||
提交 {model, input, parameters} → output.task_id
|
||||
3. GET /api/v1/tasks/{task_id} 轮询 → SUCCEEDED 取 video_url
|
||||
4. 下载成片到本地
|
||||
|
||||
⚠️ 状态说明(2026-09-05 实测):
|
||||
- 本机 key 可鉴权, 但账号 Arrearage(欠费) — 需在百炼控制台充值后恢复
|
||||
- model id 与 input 字段名待开通后校准: 豆包展示名 "Wan2.2-Animate(-mix-std/pro)"
|
||||
API id 疑似 wan2.2-animate-mix-std; 若报 Model not exist, 以控制台模型广场为准更新配置
|
||||
"""
|
||||
import sys, os, time
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
# ── 顶层目录约定: 本包位于 09_ave/scripts/person_swap/, 依赖 scripts/lib/* ──
|
||||
_SCRIPTS = Path(__file__).resolve().parent.parent
|
||||
if str(_SCRIPTS) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPTS))
|
||||
|
||||
from lib.logger import get_logger
|
||||
from lib.config import load_config
|
||||
|
||||
logger = get_logger("person_swap")
|
||||
|
||||
DASHSCOPE_BASE = "https://dashscope.aliyuncs.com"
|
||||
# Wan2.2-Animate-Mix(视频换人) 走 image2video 路径(非 video-generation, 2026-10-02 实测)
|
||||
SYNTHESIS_URL = f"{DASHSCOPE_BASE}/api/v1/services/aigc/image2video/video-synthesis"
|
||||
TASK_URL = f"{DASHSCOPE_BASE}/api/v1/tasks/{{task_id}}"
|
||||
TASK_CANCEL_URL = f"{DASHSCOPE_BASE}/api/v1/tasks/{{task_id}}/cancel"
|
||||
UPLOAD_URL = f"{DASHSCOPE_BASE}/api/v1/uploads"
|
||||
|
||||
|
||||
def get_api_key() -> str:
|
||||
"""读取百炼 API key (local.yaml 的 aliyun.api_key, 优先环境变量覆盖)"""
|
||||
env = os.environ.get("DASHSCOPE_API_KEY", "")
|
||||
if env:
|
||||
return env
|
||||
cfg = load_config()
|
||||
return (cfg.get("aliyun", {}) or {}).get("api_key", "")
|
||||
|
||||
|
||||
def get_model() -> str:
|
||||
"""人物置换模型 id — 配置 person_swap.model (官方名 wan2.2-animate-mix, 2026-10-02 校准)"""
|
||||
cfg = load_config()
|
||||
ps = cfg.get("person_swap", {}) or {}
|
||||
return ps.get("model", "wan2.2-animate-mix")
|
||||
|
||||
|
||||
def get_mode() -> str:
|
||||
"""生成档位 std(¥0.6/秒) | pro(¥0.9/秒, 增强版) — 配置 person_swap.mode"""
|
||||
cfg = load_config()
|
||||
ps = cfg.get("person_swap", {}) or {}
|
||||
return ps.get("mode", "std")
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 账号状态探活 (供页面展示「账号是否可生成」)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
def check_account_status() -> dict:
|
||||
"""探测百炼账号可用性(最小真实调用, 零费用)
|
||||
|
||||
注意: GET /models 在欠费账号下仍返回 200, 必须发一个最小 chat 请求才能探到
|
||||
Arrearage — 欠费账号连免费模型(qwen-turbo)都会被拒。
|
||||
|
||||
返回: {"status": "ok"|"arrears"|"unconfigured"|"error", "detail": str, "model": str}
|
||||
"""
|
||||
ak = get_api_key()
|
||||
if not ak or ak == "sk-xxx":
|
||||
return {"status": "unconfigured", "detail": "未配置百炼 API Key (agent-local/tools/ave/config/local.yaml → aliyun.api_key)"}
|
||||
try:
|
||||
# 最小 chat 请求(qwen-turbo 有免费额度; 欠费会 Arrearage)
|
||||
r = httpx.post(f"{DASHSCOPE_BASE}/compatible-mode/v1/chat/completions",
|
||||
headers={"Authorization": f"Bearer {ak}", "Content-Type": "application/json"},
|
||||
json={"model": "qwen-turbo",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"max_tokens": 1},
|
||||
timeout=20, trust_env=False)
|
||||
if r.status_code == 200:
|
||||
return {"status": "ok", "detail": "账号可用(qwen-turbo 探测通过)", "model": get_model()}
|
||||
body = r.text[:300]
|
||||
if "Arrearage" in body or "overdue" in body or "good standing" in body:
|
||||
return {"status": "arrears", "detail": "账号欠费/状态不良(Arrearage), 需到百炼控制台充值或解除冻结", "model": get_model()}
|
||||
return {"status": "error", "detail": f"HTTP {r.status_code}: {body}", "model": get_model()}
|
||||
except Exception as e:
|
||||
return {"status": "error", "detail": f"探测异常: {e}", "model": get_model()}
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 文件上传
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
def upload_file(api_key: str, file_path: str, model: str = "") -> str:
|
||||
"""上传素材到百炼(两步式, 2026-10-02 实测校准)
|
||||
|
||||
DashScope 文件上传不是直接 POST /api/v1/uploads(会 405), 正确流程:
|
||||
1. GET /api/v1/uploads?action=getPolicy&model={model}
|
||||
→ 返回 OSS 直传凭证: policy / signature / upload_dir / upload_host / oss_access_key_id
|
||||
2. POST {upload_host} (multipart 表单直传 OSS)
|
||||
→ 字段: OSSAccessKeyId / policy / signature / key / x-oss-object-acl / file
|
||||
3. 返回 oss://{key} 供模型 input 引用
|
||||
|
||||
返回: "oss://dashscope-instant/.../filename.ext"
|
||||
"""
|
||||
model = model or get_model()
|
||||
p = Path(file_path)
|
||||
|
||||
# ── 1) 获取上传凭证 ──
|
||||
r = httpx.get(f"{UPLOAD_URL}?action=getPolicy&model={model}",
|
||||
headers={"Authorization": f"Bearer {api_key}"}, timeout=30, trust_env=False)
|
||||
if r.status_code != 200:
|
||||
raise RuntimeError(f"百炼获取上传凭证失败 HTTP {r.status_code}: {r.text[:300]}")
|
||||
d = r.json().get("data") or {}
|
||||
upload_host = d.get("upload_host", "")
|
||||
upload_dir = d.get("upload_dir", "")
|
||||
if not (upload_host and upload_dir):
|
||||
raise RuntimeError(f"百炼上传凭证字段缺失: {str(d)[:300]}")
|
||||
key = f"{upload_dir}/{p.name}" # upload_dir 每次含 uuid → key 天然唯一
|
||||
|
||||
# ── 2) 直传 OSS ──
|
||||
form = {
|
||||
"OSSAccessKeyId": d.get("oss_access_key_id", ""),
|
||||
"policy": d.get("policy", ""),
|
||||
"signature": d.get("signature", ""),
|
||||
"key": key,
|
||||
"x-oss-object-acl": d.get("x_oss_object_acl", "private"),
|
||||
"x-oss-forbid-overwrite": "true",
|
||||
}
|
||||
with open(p, "rb") as f:
|
||||
r2 = httpx.post(upload_host, data=form,
|
||||
files={"file": (p.name, f, "application/octet-stream")},
|
||||
timeout=300, trust_env=False)
|
||||
if r2.status_code not in (200, 201, 204):
|
||||
raise RuntimeError(f"百炼 OSS 直传失败 HTTP {r2.status_code}: {r2.text[:300]}")
|
||||
|
||||
oss_url = f"oss://{key}"
|
||||
logger.info(" 上传成功: %s → %s", p.name, oss_url[:70])
|
||||
return oss_url
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 任务提交 / 轮询 / 下载
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
def build_input(model: str, ref_image_url: str, source_video_url: str,
|
||||
prompt: str = "", duration: int = 0) -> dict:
|
||||
"""构造人物置换 input — 字段名由 2026-10-02 实测确认(image2video 接口)
|
||||
|
||||
失败任务详情明确要求: input.image_url & input.video_url & parameters.mode
|
||||
"""
|
||||
inp = {"image_url": ref_image_url, "video_url": source_video_url}
|
||||
if prompt:
|
||||
inp["prompt"] = prompt # 可选(部分档位支持文字引导)
|
||||
return inp
|
||||
|
||||
|
||||
def build_parameters(model: str, duration: int = 0, aspect_ratio: str = "9:16") -> dict:
|
||||
"""构造 parameters — mode 为必填档位(std/pro), 由 2026-10-02 实测确认"""
|
||||
cfg = load_config()
|
||||
ps = cfg.get("person_swap", {}) or {}
|
||||
params = {"mode": ps.get("mode", "std")}
|
||||
if duration and duration > 0:
|
||||
params["duration"] = duration
|
||||
return params
|
||||
|
||||
|
||||
def submit_task(api_key: str, model: str, ref_image_url: str, source_video_url: str,
|
||||
prompt: str = "", duration: int = 0) -> str:
|
||||
"""提交人物置换任务, 返回 task_id"""
|
||||
payload = {
|
||||
"model": model,
|
||||
"input": build_input(model, ref_image_url, source_video_url, prompt, duration),
|
||||
"parameters": build_parameters(model, duration),
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"X-DashScope-Async": "enable",
|
||||
# 引用百炼自有 OSS 资源(oss://) 需此 header 解析
|
||||
"X-DashScope-OssResourceResolve": "enable",
|
||||
}
|
||||
logger.info("提交人物置换: model=%s mode=%s 时长=%ss", model, payload["parameters"].get("mode"), duration or "auto")
|
||||
r = httpx.post(SYNTHESIS_URL, headers=headers, json=payload, timeout=60, trust_env=False)
|
||||
if r.status_code != 200:
|
||||
raise RuntimeError(f"百炼提交失败 HTTP {r.status_code}: {r.text[:400]}")
|
||||
task_id = (r.json().get("output") or {}).get("task_id", "")
|
||||
if not task_id:
|
||||
raise RuntimeError(f"百炼提交返回无 task_id: {r.text[:400]}")
|
||||
return task_id
|
||||
|
||||
|
||||
def cancel_task(api_key: str, task_id: str) -> bool:
|
||||
"""取消任务(DashScope: POST /api/v1/tasks/{id}/cancel)"""
|
||||
try:
|
||||
r = httpx.post(TASK_CANCEL_URL.format(task_id=task_id),
|
||||
headers={"Authorization": f"Bearer {api_key}"}, timeout=20, trust_env=False)
|
||||
return r.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def poll_task(api_key: str, task_id: str, timeout_sec: int = 900, interval: int = 10,
|
||||
progress_cb=None) -> dict:
|
||||
"""轮询任务直到完成/失败/超时
|
||||
|
||||
返回: {"status": "SUCCEEDED"|"FAILED"|"TIMEOUT", "video_url": str, "message": str}
|
||||
progress_cb(percent:int, note:str) 可选回调
|
||||
"""
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
deadline = time.time() + timeout_sec
|
||||
last_note = ""
|
||||
while time.time() < deadline:
|
||||
r = httpx.get(TASK_URL.format(task_id=task_id), headers=headers, timeout=30, trust_env=False)
|
||||
if r.status_code != 200:
|
||||
raise RuntimeError(f"查询任务失败 HTTP {r.status_code}: {r.text[:300]}")
|
||||
data = r.json()
|
||||
out = data.get("output") or {}
|
||||
status = out.get("task_status", "")
|
||||
if status == "SUCCEEDED":
|
||||
video_url = out.get("video_url", "")
|
||||
if not video_url:
|
||||
results = out.get("results", [])
|
||||
if isinstance(results, list) and results:
|
||||
video_url = results[0].get("video_url", "") or results[0].get("url", "")
|
||||
elif isinstance(results, dict):
|
||||
video_url = results.get("video_url", "") or results.get("url", "")
|
||||
if progress_cb:
|
||||
progress_cb(100, "生成完成")
|
||||
return {"status": "SUCCEEDED", "video_url": video_url, "message": out.get("message", "")}
|
||||
if status == "FAILED":
|
||||
msg = out.get("message", "") or out.get("code", "") or "未知错误"
|
||||
if progress_cb:
|
||||
progress_cb(0, f"失败: {msg}")
|
||||
return {"status": "FAILED", "video_url": "", "message": msg}
|
||||
# 估算进度(无标准进度字段时用耗时粗估)
|
||||
pct = min(95, int((time.time() - (deadline - timeout_sec)) / timeout_sec * 100))
|
||||
note = out.get("task_status", status)
|
||||
if note != last_note:
|
||||
last_note = note
|
||||
logger.info(" [%s] 任务状态: %s", task_id[:8], status)
|
||||
if progress_cb:
|
||||
progress_cb(pct, f"生成中({status})")
|
||||
time.sleep(interval)
|
||||
return {"status": "TIMEOUT", "video_url": "", "message": f"轮询超时(>{timeout_sec}s)"}
|
||||
|
||||
|
||||
def download_file(url: str, output_path: str) -> str:
|
||||
"""下载成片到本地"""
|
||||
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
||||
with httpx.stream("GET", url, timeout=600) as resp:
|
||||
resp.raise_for_status()
|
||||
with open(output_path, "wb") as f:
|
||||
for chunk in resp.iter_bytes(chunk_size=8192):
|
||||
f.write(chunk)
|
||||
return output_path
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 一站式: 上传×2 → 提交 → 轮询 → 下载
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
def run_swap(ref_image_path: str, source_video_path: str, output_path: str,
|
||||
prompt: str = "", duration: int = 0,
|
||||
model: str = "", api_key: str = "",
|
||||
timeout_sec: int = 900, progress_cb=None) -> str:
|
||||
"""完整跑一次人物置换, 返回成片本地路径
|
||||
|
||||
供 CLI / service worker / 调试脚本直接调用。
|
||||
"""
|
||||
model = model or get_model()
|
||||
api_key = api_key or get_api_key()
|
||||
progress_cb = progress_cb or (lambda p, n: None)
|
||||
|
||||
progress_cb(5, "上传参考图")
|
||||
img_url = upload_file(api_key, ref_image_path, model)
|
||||
progress_cb(15, "上传源视频")
|
||||
vid_url = upload_file(api_key, source_video_path, model)
|
||||
|
||||
progress_cb(25, "提交生成任务")
|
||||
task_id = submit_task(api_key, model, img_url, vid_url, prompt, duration)
|
||||
|
||||
progress_cb(30, "生成中(轮询)")
|
||||
result = poll_task(api_key, task_id, timeout_sec=timeout_sec,
|
||||
progress_cb=lambda p, n: progress_cb(30 + int(p * 0.65), n))
|
||||
if result["status"] != "SUCCEEDED" or not result["video_url"]:
|
||||
raise RuntimeError(f"生成失败: {result['status']} {result['message']}")
|
||||
|
||||
progress_cb(98, "下载成片")
|
||||
download_file(result["video_url"], output_path)
|
||||
progress_cb(100, "完成")
|
||||
return output_path
|
||||
|
||||
|
||||
# ── CLI 冒烟测试 ──
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
ap = argparse.ArgumentParser(description="百炼人物置换 CLI (需先处理欠费并校准模型id)")
|
||||
ap.add_argument("--image", required=True, help="人物参考图")
|
||||
ap.add_argument("--video", required=True, help="原视频(动作来源)")
|
||||
ap.add_argument("--prompt", default="", help="可选文字描述")
|
||||
ap.add_argument("--duration", type=int, default=5, help="成片时长(秒)")
|
||||
ap.add_argument("--model", default="", help="模型id(默认取配置)")
|
||||
ap.add_argument("--output", default="", help="成片输出路径")
|
||||
args = ap.parse_args()
|
||||
|
||||
st = check_account_status()
|
||||
print("账号状态:", st["status"], "-", st["detail"])
|
||||
if st["status"] != "ok":
|
||||
print("⛔ 账号不可用, 无法生成。处理后重试。")
|
||||
sys.exit(1)
|
||||
|
||||
out = args.output or str(Path.home() / "workbuddy-agent-os/agent-local/runtime/person_swap/outputs/smoke_test.mp4")
|
||||
path = run_swap(args.image, args.video, out, prompt=args.prompt,
|
||||
duration=args.duration, model=args.model)
|
||||
print(f"\n✅ 成片: {path}")
|
||||
@@ -0,0 +1,244 @@
|
||||
"""
|
||||
person_swap/preprocess.py — 素材合规化与预处理 (ffmpeg)
|
||||
|
||||
职责:
|
||||
1. probe_video — 探测源视频(时长/分辨率/编码/fps)
|
||||
2. check_image — 校验人物参考图(可解码/尺寸/非动图)
|
||||
3. prep_source_video — 源视频合规化: 转码 h264 + 竖屏 9:16 裁切 + 限长截断
|
||||
4. prep_reference_image — 参考图归一化(缩放至模型常用尺寸上限)
|
||||
5. extract_preview_frame — 抽帧预览(供前端展示)
|
||||
|
||||
⚠️ 本机 ~/.local/bin/ffprobe 是软链到 ffmpeg 的假 ffprobe(v6.0 单二进制),
|
||||
_find_bin 会用 `-version` 验证真实身份; 无真 ffprobe 时走 ffmpeg stderr 解析兜底。
|
||||
|
||||
输出目录约定: agent-local/runtime/person_swap/uploads/{task_id}/
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
# ── ffmpeg / ffprobe 路径解析 ──
|
||||
def _find_bin(name: str) -> str:
|
||||
"""查找真实二进制: 候选目录 + PATH, 且 -version 首行必须含目标名"""
|
||||
cands = [
|
||||
os.environ.get("FFMPEG_DIR", ""),
|
||||
str(Path.home() / ".local" / "bin"),
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
"/usr/bin",
|
||||
]
|
||||
seen = []
|
||||
for d in cands:
|
||||
if d:
|
||||
p = Path(d) / name
|
||||
if p.exists():
|
||||
seen.append(str(p))
|
||||
w = shutil.which(name)
|
||||
if w:
|
||||
seen.append(w)
|
||||
for cand in seen:
|
||||
try:
|
||||
r = subprocess.run([cand, "-version"], capture_output=True, text=True, timeout=10)
|
||||
if r.returncode == 0 and r.stdout.splitlines() and name in r.stdout.splitlines()[0]:
|
||||
return cand
|
||||
except Exception:
|
||||
continue
|
||||
return name # 找不到就交给系统 PATH, 调用时会自然报错
|
||||
|
||||
|
||||
# ⚠️ 统一用 lib.ffmpeg 的解析:它会**自动查找真正的 ffprobe**
|
||||
# (本机 ~/.local/bin/ffprobe 是 ffmpeg 的符号链接,假货的 -version 首行不含 "ffprobe",
|
||||
# 一旦没找到真 ffprobe,_find_bin 会兜底返回 "ffprobe" → 仍会踩到假货)
|
||||
try:
|
||||
os.sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
from lib.ffmpeg import FFMPEG, FFPROBE # noqa: E402
|
||||
except Exception:
|
||||
FFMPEG = _find_bin("ffmpeg")
|
||||
FFPROBE = _find_bin("ffprobe")
|
||||
|
||||
# ── 常用约束 (可被 person_swap 配置覆盖) ──
|
||||
DEFAULT_MAX_DURATION = 10 # 单条源视频最长秒数(超长提示分段)
|
||||
DEFAULT_ORIENTATION = "9:16" # 输出竖屏
|
||||
DEFAULT_IMAGE_MAX = 2048 # 参考图长边上限
|
||||
|
||||
_SCRIPT_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(_SCRIPT_ROOT) not in os.sys.path:
|
||||
os.sys.path.insert(0, str(_SCRIPT_ROOT))
|
||||
|
||||
from lib.logger import get_logger
|
||||
from lib.config import load_config
|
||||
|
||||
logger = get_logger("person_swap")
|
||||
|
||||
|
||||
def _cfg() -> dict:
|
||||
cfg = load_config()
|
||||
return cfg.get("person_swap", {}) or {}
|
||||
|
||||
|
||||
def _run(cmd: list, timeout: int = 120) -> subprocess.CompletedProcess:
|
||||
logger.debug("执行: %s", " ".join(str(c) for c in cmd))
|
||||
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 探测与校验
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
def _parse_ffmpeg_info(path: str) -> dict:
|
||||
"""ffmpeg -i stderr 解析兜底 (无真 ffprobe 时使用)"""
|
||||
r = subprocess.run([FFMPEG, "-hide_banner", "-i", path],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
text = r.stderr
|
||||
dur_s = 0.0
|
||||
m = re.search(r"Duration:\s*(\d+):(\d+):([\d.]+)", text)
|
||||
if m:
|
||||
h, mi, s = int(m.group(1)), int(m.group(2)), float(m.group(3))
|
||||
dur_s = h * 3600 + mi * 60 + s
|
||||
# 分辨率组首位 1-9: 排除 0x31637661(avc1 十六进制) 这类误匹配
|
||||
m = re.search(r"Stream #0[^\n]*?Video:\s*([\w-]+)[^\n]*?([1-9]\d{1,4})x([1-9]\d{1,4})", text)
|
||||
if not m:
|
||||
raise ValueError(f"无法识别媒体流: {text.strip()[-200:]}")
|
||||
codec, w, h = m.group(1), int(m.group(2)), int(m.group(3))
|
||||
fps = ""
|
||||
mf = re.search(r"(\d+(?:\.\d+)?)\s*fps", text)
|
||||
if mf:
|
||||
fps = mf.group(1)
|
||||
return {"duration_sec": round(dur_s, 2), "width": w, "height": h,
|
||||
"codec": codec, "fps": fps}
|
||||
|
||||
|
||||
def probe_video(path: str) -> dict:
|
||||
"""探测视频元信息, 失败抛 ValueError; 优先 ffprobe JSON, 兜底 ffmpeg 解析"""
|
||||
if FFPROBE != "ffprobe":
|
||||
r = _run([FFPROBE, "-v", "error", "-print_format", "json", "-show_format", "-show_streams", path])
|
||||
if r.returncode == 0:
|
||||
try:
|
||||
d = json.loads(r.stdout)
|
||||
streams = d.get("streams", [])
|
||||
v = next((s for s in streams if s.get("codec_type") == "video"), None)
|
||||
if not v:
|
||||
raise ValueError("视频无视频流")
|
||||
fmt = d.get("format", {})
|
||||
dur = float(fmt.get("duration", 0) or 0)
|
||||
if dur <= 0:
|
||||
dur = float(v.get("duration", 0) or 0)
|
||||
return {
|
||||
"duration_sec": round(dur, 2),
|
||||
"width": int(v.get("width", 0)),
|
||||
"height": int(v.get("height", 0)),
|
||||
"codec": v.get("codec_name", ""),
|
||||
"fps": v.get("avg_frame_rate", ""),
|
||||
"size_bytes": int(fmt.get("size", 0) or 0),
|
||||
}
|
||||
except Exception:
|
||||
pass # 解析失败落到 ffmpeg 兜底
|
||||
info = _parse_ffmpeg_info(path)
|
||||
info["size_bytes"] = os.path.getsize(path)
|
||||
return info
|
||||
|
||||
|
||||
def check_image(path: str) -> dict:
|
||||
"""校验人物参考图: 可解码 + 尺寸, 返回 {width,height,ok,error}"""
|
||||
try:
|
||||
info = _parse_ffmpeg_info(path)
|
||||
w, h = info["width"], info["height"]
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"图片无法解码: {e}"}
|
||||
if w < 256 or h < 256:
|
||||
return {"ok": False, "error": f"参考图过小({w}x{h}), 至少 256px"}
|
||||
return {"ok": True, "width": w, "height": h, "codec": info["codec"], "error": ""}
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 预处理(生成合规中间文件)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
def prep_source_video(src_path: str, out_path: str,
|
||||
max_duration: int = 0, orientation: str = "") -> dict:
|
||||
"""源视频合规化 → h264 + 竖屏裁切 + 限长
|
||||
|
||||
返回 {out_path, duration_sec, width, height, trimmed: bool}
|
||||
超长时截取前 max_duration 秒(并返回 trimmed=True)
|
||||
"""
|
||||
info = probe_video(src_path)
|
||||
max_duration = max_duration or int(_cfg().get("max_duration", DEFAULT_MAX_DURATION))
|
||||
orientation = orientation or _cfg().get("orientation", DEFAULT_ORIENTATION)
|
||||
|
||||
dur = info["duration_sec"]
|
||||
duration_arg = []
|
||||
if dur > max_duration:
|
||||
duration_arg = ["-t", str(max_duration)]
|
||||
logger.info(" 源视频 %.1fs 超限, 截取前 %ds", dur, max_duration)
|
||||
|
||||
# 裁切到竖屏: 保持画面内容, 按 9:16 裁掉左右 (16:9 横屏 → 切中间竖条)
|
||||
vf = []
|
||||
if orientation == "9:16":
|
||||
vf.append("scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920")
|
||||
else:
|
||||
vf.append("scale=1920:1080:force_original_aspect_ratio=increase,crop=1920:1080")
|
||||
|
||||
cmd = [FFMPEG, "-y", "-i", src_path, *duration_arg,
|
||||
"-vf", ",".join(vf),
|
||||
"-c:v", "libx264", "-preset", "veryfast", "-crf", "20",
|
||||
"-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart",
|
||||
out_path]
|
||||
r = _run(cmd, timeout=300)
|
||||
if r.returncode != 0:
|
||||
raise ValueError(f"视频预处理失败: {r.stderr.strip()[-300:]}")
|
||||
|
||||
out_info = probe_video(out_path)
|
||||
return {**out_info, "out_path": out_path, "trimmed": dur > max_duration}
|
||||
|
||||
|
||||
def prep_reference_image(src_path: str, out_path: str, max_side: int = 0) -> dict:
|
||||
"""参考图归一化: 转 jpg + 长边上限, 返回 {out_path, width, height}"""
|
||||
chk = check_image(src_path)
|
||||
if not chk["ok"]:
|
||||
raise ValueError(chk["error"])
|
||||
max_side = max_side or int(_cfg().get("image_max_side", DEFAULT_IMAGE_MAX))
|
||||
|
||||
vf = f"scale='min({max_side},iw)':'min({max_side},ih)':force_original_aspect_ratio=decrease"
|
||||
out = Path(out_path)
|
||||
if out.suffix.lower() not in (".jpg", ".jpeg"):
|
||||
out = out.with_suffix(".jpg")
|
||||
cmd = [FFMPEG, "-y", "-i", src_path, "-vf", vf, "-q:v", "2", str(out)]
|
||||
r = _run(cmd, timeout=120)
|
||||
if r.returncode != 0:
|
||||
raise ValueError(f"参考图处理失败: {r.stderr.strip()[-300:]}")
|
||||
return {"out_path": str(out), "width": chk["width"], "height": chk["height"]}
|
||||
|
||||
|
||||
def extract_preview_frame(video_path: str, out_path: str) -> str:
|
||||
"""抽取视频中帧作为预览图(jpg)"""
|
||||
out = Path(out_path)
|
||||
if out.suffix.lower() not in (".jpg", ".jpeg"):
|
||||
out = out.with_suffix(".jpg")
|
||||
cmd = [FFMPEG, "-y", "-i", video_path, "-frames:v", "1", "-q:v", "3", str(out)]
|
||||
r = _run(cmd, timeout=60)
|
||||
if r.returncode != 0:
|
||||
raise ValueError(f"抽帧失败: {r.stderr.strip()[-200:]}")
|
||||
return str(out)
|
||||
|
||||
|
||||
# ── CLI 自测 ──
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
ap = argparse.ArgumentParser(description="person_swap 预处理自测")
|
||||
ap.add_argument("video", help="源视频路径")
|
||||
ap.add_argument("--image", default="", help="参考图路径(可选, 测试图片处理)")
|
||||
args = ap.parse_args()
|
||||
|
||||
print(f"FFMPEG={FFMPEG} FFPROBE={FFPROBE}")
|
||||
info = probe_video(args.video)
|
||||
print("源视频:", info)
|
||||
out = "/tmp/ps_prep_test.mp4"
|
||||
res = prep_source_video(args.video, out)
|
||||
print("预处理:", res["out_path"], f"{res['width']}x{res['height']}", f"{res['duration_sec']}s", "trimmed" if res["trimmed"] else "")
|
||||
if args.image:
|
||||
print("参考图检查:", check_image(args.image))
|
||||
r = prep_reference_image(args.image, "/tmp/ps_ref_test.jpg")
|
||||
print("参考图处理:", r["out_path"])
|
||||
@@ -0,0 +1,376 @@
|
||||
"""
|
||||
person_swap/service.py — 人物置换任务管理 (SQLite + 后台单worker)
|
||||
|
||||
设计:
|
||||
- 任务持久化: agent-local/runtime/person_swap/tasks.db (程序运行时自建)
|
||||
- 单 worker 线程顺序处理队列(账号并发受限, 避免撞限流)
|
||||
- 状态机: queued → processing(内含 preprocessing/submitting/generating) → succeeded|failed|cancelled
|
||||
- 失败自动重试(损耗系数建模: 重试多耗一轮生成, 由费用护栏兜底)
|
||||
- 费用护栏: 单价(配置, 实测后填)×时长 估算; 月度累计超预算 → 新任务 blocked
|
||||
- Dashboard 重启恢复: processing → queued 重新整跑(远端已提交任务可能浪费一次, MVP 接受的局限)
|
||||
|
||||
线程安全: worker 独占写状态; 查询接口直接读 sqlite(每次新连接)。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
_SCRIPT_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(_SCRIPT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_SCRIPT_ROOT))
|
||||
|
||||
from lib.logger import get_logger
|
||||
|
||||
logger = get_logger("person_swap")
|
||||
|
||||
# ── 运行时目录(agent-local, 程序自建, 非人工修改) ──
|
||||
_RUNTIME = Path.home() / "workbuddy-agent-os" / "agent-local" / "runtime" / "person_swap"
|
||||
_UPLOAD_DIR = _RUNTIME / "uploads"
|
||||
_OUTPUT_DIR = _RUNTIME / "outputs"
|
||||
_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_DB_PATH = _RUNTIME / "tasks.db"
|
||||
|
||||
STATUS_QUEUED = "queued"
|
||||
STATUS_PROCESSING = "processing"
|
||||
STATUS_SUCCEEDED = "succeeded"
|
||||
STATUS_FAILED = "failed"
|
||||
STATUS_CANCELLED = "cancelled"
|
||||
STATUS_BLOCKED = "blocked" # 预算/账号不可用等业务拦截
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
source_orig TEXT NOT NULL,
|
||||
image_orig TEXT NOT NULL,
|
||||
source_prep TEXT DEFAULT '',
|
||||
image_prep TEXT DEFAULT '',
|
||||
preview_path TEXT DEFAULT '',
|
||||
prompt TEXT DEFAULT '',
|
||||
character_name TEXT DEFAULT '',
|
||||
duration_sec INTEGER DEFAULT 0,
|
||||
model TEXT DEFAULT '',
|
||||
output_path TEXT DEFAULT '',
|
||||
error TEXT DEFAULT '',
|
||||
retries INTEGER DEFAULT 0,
|
||||
cost_est REAL DEFAULT 0,
|
||||
progress INTEGER DEFAULT 0,
|
||||
note TEXT DEFAULT '',
|
||||
cancel_requested INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
||||
"""
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _connect() -> sqlite3.Connection:
|
||||
con = sqlite3.connect(str(_DB_PATH), timeout=15)
|
||||
con.row_factory = sqlite3.Row
|
||||
return con
|
||||
|
||||
|
||||
def _cfg() -> dict:
|
||||
from lib.config import load_config
|
||||
return (load_config().get("person_swap", {}) or {})
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# 任务服务
|
||||
# ════════════════════════════════════════════════════════════
|
||||
|
||||
class PersonSwapService:
|
||||
"""单例任务服务: create/list/get/cancel + 后台 worker 消费队列"""
|
||||
|
||||
_instance = None
|
||||
|
||||
@classmethod
|
||||
def get(cls) -> "PersonSwapService":
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
con = _connect()
|
||||
con.executescript(_SCHEMA)
|
||||
con.commit()
|
||||
con.close()
|
||||
self._wake = threading.Event()
|
||||
self._stop = threading.Event()
|
||||
self._thread = threading.Thread(target=self._run_worker, name="ps-worker", daemon=True)
|
||||
self._thread.start()
|
||||
# 启动恢复: processing → queued (Dashboard 重启后自动续跑)
|
||||
con = _connect()
|
||||
con.execute("UPDATE tasks SET status=?, note='服务重启, 任务重新排队' WHERE status=?",
|
||||
(STATUS_QUEUED, STATUS_PROCESSING))
|
||||
con.commit()
|
||||
con.close()
|
||||
logger.info("person_swap service 启动, worker=%s", self._thread.name)
|
||||
|
||||
# ── 查询 ──
|
||||
|
||||
def list_tasks(self, limit: int = 50, status: str = "") -> list:
|
||||
con = _connect()
|
||||
sql = "SELECT * FROM tasks"
|
||||
args = []
|
||||
if status:
|
||||
sql += " WHERE status=?"
|
||||
args.append(status)
|
||||
sql += " ORDER BY created_at DESC LIMIT ?"
|
||||
args.append(int(limit))
|
||||
rows = [dict(r) for r in con.execute(sql, args).fetchall()]
|
||||
con.close()
|
||||
return rows
|
||||
|
||||
def get_task(self, task_id: str) -> dict:
|
||||
con = _connect()
|
||||
row = con.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
con.close()
|
||||
return dict(row) if row else {}
|
||||
|
||||
def monthly_cost(self) -> dict:
|
||||
"""本月费用估算与预算"""
|
||||
cfg = _cfg()
|
||||
budget = float(cfg.get("monthly_budget", 300))
|
||||
con = _connect()
|
||||
row = con.execute(
|
||||
"SELECT COALESCE(SUM(cost_est),0) AS used FROM tasks "
|
||||
"WHERE status=? AND substr(created_at,1,7)=strftime('%Y-%m','now')",
|
||||
(STATUS_SUCCEEDED,)).fetchone()
|
||||
con.close()
|
||||
used = round(float(row["used"]), 2) if row else 0.0
|
||||
return {"used": used, "budget": budget, "remaining": round(budget - used, 2)}
|
||||
|
||||
# ── 创建/取消 ──
|
||||
|
||||
def create_task(self, source_path: str, image_path: str,
|
||||
prompt: str = "", character_name: str = "",
|
||||
duration_sec: int = 0) -> dict:
|
||||
"""登记任务(先做基础校验), 返回任务 dict; 超预算返回 blocked 任务"""
|
||||
source_path = str(source_path)
|
||||
image_path = str(image_path)
|
||||
if not os.path.exists(source_path):
|
||||
raise ValueError(f"源视频不存在: {source_path}")
|
||||
if not os.path.exists(image_path):
|
||||
raise ValueError(f"参考图不存在: {image_path}")
|
||||
|
||||
# 素材预检(时长/尺寸) — 快速失败, 不进队列
|
||||
from person_swap import preprocess as pp
|
||||
try:
|
||||
vinfo = pp.probe_video(source_path)
|
||||
ichk = pp.check_image(image_path)
|
||||
except Exception as e:
|
||||
raise ValueError(f"素材预检失败: {e}")
|
||||
if not ichk["ok"]:
|
||||
raise ValueError(ichk["error"])
|
||||
|
||||
# 预算护栏
|
||||
mc = self.monthly_cost()
|
||||
if mc["remaining"] <= 0:
|
||||
raise ValueError(f"月度预算已用尽(¥{mc['used']}/{mc['budget']}), 请调整 person_swap.monthly_budget")
|
||||
|
||||
# 超长提示(仍允许入队, worker 会截取)
|
||||
max_dur = int(_cfg().get("max_duration", 10))
|
||||
trimmed_note = ""
|
||||
if vinfo["duration_sec"] > max_dur:
|
||||
trimmed_note = f"源视频{vinfo['duration_sec']}s 超过{max_dur}s, 将截取前{max_dur}s"
|
||||
|
||||
task_id = uuid.uuid4().hex[:12]
|
||||
now = _now()
|
||||
model = ""
|
||||
try:
|
||||
from person_swap.api import get_model
|
||||
model = get_model()
|
||||
except Exception:
|
||||
pass
|
||||
task = {
|
||||
"id": task_id, "status": STATUS_QUEUED, "created_at": now, "updated_at": now,
|
||||
"source_orig": source_path, "image_orig": image_path,
|
||||
"prompt": prompt or "", "character_name": character_name or "",
|
||||
"duration_sec": int(duration_sec or 0), "model": model,
|
||||
"note": trimmed_note or "排队中", "progress": 0,
|
||||
}
|
||||
con = _connect()
|
||||
con.execute("INSERT INTO tasks (id,status,created_at,updated_at,source_orig,image_orig,prompt,character_name,duration_sec,model,note) "
|
||||
"VALUES (:id,:status,:created_at,:updated_at,:source_orig,:image_orig,:prompt,:character_name,:duration_sec,:model,:note)",
|
||||
task)
|
||||
con.commit()
|
||||
con.close()
|
||||
logger.info("新任务 %s queued (视频 %.1fs %dx%d)", task_id, vinfo["duration_sec"], vinfo["width"], vinfo["height"])
|
||||
self._wake.set()
|
||||
task["video_info"] = vinfo
|
||||
return task
|
||||
|
||||
def cancel_task(self, task_id: str) -> dict:
|
||||
task = self.get_task(task_id)
|
||||
if not task:
|
||||
raise ValueError("任务不存在")
|
||||
if task["status"] in (STATUS_SUCCEEDED, STATUS_FAILED, STATUS_CANCELLED):
|
||||
return task
|
||||
con = _connect()
|
||||
con.execute("UPDATE tasks SET cancel_requested=1, note='取消请求已登记(生成中任务远端可能继续跑完)' WHERE id=?",
|
||||
(task_id,))
|
||||
con.commit()
|
||||
con.close()
|
||||
self._wake.set()
|
||||
return self.get_task(task_id)
|
||||
|
||||
# ── worker ──
|
||||
|
||||
def _run_worker(self):
|
||||
while not self._stop.is_set():
|
||||
task = self._dequeue()
|
||||
if task is None:
|
||||
self._wake.wait(timeout=5)
|
||||
self._wake.clear()
|
||||
continue
|
||||
try:
|
||||
self._process(task)
|
||||
except Exception as e:
|
||||
logger.exception("任务 %s 处理异常", task["id"])
|
||||
self._update(task["id"], status=STATUS_FAILED, error=str(e)[:500], note="内部异常")
|
||||
# 让出, 检查下一个
|
||||
time.sleep(1)
|
||||
|
||||
def _dequeue(self) -> dict:
|
||||
con = _connect()
|
||||
row = con.execute("SELECT * FROM tasks WHERE status=? AND cancel_requested=0 "
|
||||
"ORDER BY created_at ASC LIMIT 1", (STATUS_QUEUED,)).fetchone()
|
||||
if row:
|
||||
con.execute("UPDATE tasks SET status=?, updated_at=? WHERE id=?",
|
||||
(STATUS_PROCESSING, _now(), row["id"]))
|
||||
con.commit()
|
||||
con.close()
|
||||
return dict(row) if row else None
|
||||
|
||||
def _update(self, task_id: str, **kw):
|
||||
cols = ", ".join(f"{k}=?" for k in kw)
|
||||
vals = list(kw.values())
|
||||
con = _connect()
|
||||
con.execute(f"UPDATE tasks SET {cols}, updated_at=? WHERE id=?",
|
||||
vals + [_now(), task_id])
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
def _process(self, task: dict):
|
||||
tid = task["id"]
|
||||
cfg = _cfg()
|
||||
|
||||
def setp(p: int, note: str):
|
||||
self._update(tid, progress=p, note=note)
|
||||
logger.info(" [%s] %d%% %s", tid, p, note)
|
||||
|
||||
# ── 阶段0: 账号可用性预检(欠费直接失败, 不烧重试) ──
|
||||
from person_swap.api import check_account_status, get_api_key
|
||||
st = check_account_status()
|
||||
if st["status"] != "ok":
|
||||
reason = "账号不可用: " + st["detail"]
|
||||
self._update(tid, status=STATUS_FAILED, error=reason, note="账号不可用", progress=0)
|
||||
logger.warning("任务 %s 失败: %s", tid, reason)
|
||||
return
|
||||
|
||||
# ── 阶段1: 预处理 ──
|
||||
setp(3, "预处理素材(转码/裁切/归一化)")
|
||||
from person_swap import preprocess as pp
|
||||
work_dir = _UPLOAD_DIR / tid
|
||||
work_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
prep_src = work_dir / "source_prep.mp4"
|
||||
try:
|
||||
sr = pp.prep_source_video(task["source_orig"], str(prep_src))
|
||||
except Exception as e:
|
||||
return self._handle_fail(task, f"视频预处理失败: {e}")
|
||||
prep_img = work_dir / "ref_prep.jpg"
|
||||
try:
|
||||
ir = pp.prep_reference_image(task["image_orig"], str(prep_img))
|
||||
except Exception as e:
|
||||
return self._handle_fail(task, f"参考图处理失败: {e}")
|
||||
preview = work_dir / "preview.jpg"
|
||||
try:
|
||||
pp.extract_preview_frame(sr["out_path"], str(preview))
|
||||
except Exception:
|
||||
preview = Path("")
|
||||
self._update(tid, source_prep=sr["out_path"], image_prep=ir["out_path"],
|
||||
preview_path=str(preview))
|
||||
|
||||
# ── 阶段2: 生成(重试循环, 损耗系数由费用护栏兜底) ──
|
||||
from person_swap import api
|
||||
max_retries = int(cfg.get("max_retries", 2))
|
||||
timeout_sec = int(cfg.get("timeout_sec", 900))
|
||||
output_path = str(_OUTPUT_DIR / f"{tid}.mp4")
|
||||
attempt = 0
|
||||
while True:
|
||||
if self._is_cancel_requested(tid):
|
||||
self._update(tid, status=STATUS_CANCELLED, note="已取消", progress=0)
|
||||
return
|
||||
attempt += 1
|
||||
setp(20, f"提交百炼生成(第{attempt}次)")
|
||||
try:
|
||||
api.run_swap(
|
||||
ref_image_path=ir["out_path"],
|
||||
source_video_path=sr["out_path"],
|
||||
output_path=output_path,
|
||||
prompt=task["prompt"],
|
||||
duration=task["duration_sec"],
|
||||
model=task["model"] or api.get_model(),
|
||||
api_key=get_api_key(),
|
||||
timeout_sec=timeout_sec,
|
||||
progress_cb=lambda p, n: self._update(tid, progress=max(p, self.get_task(tid)["progress"]), note=n)
|
||||
if not self._is_cancel_requested(tid) else None,
|
||||
)
|
||||
# 成功
|
||||
cost = self._estimate_cost(task, sr["duration_sec"])
|
||||
self._update(tid, status=STATUS_SUCCEEDED, output_path=output_path,
|
||||
cost_est=cost, progress=100, note="✅ 生成完成")
|
||||
logger.info("任务 %s 完成: %s (费用≈¥%.3f)", tid, output_path, cost)
|
||||
return
|
||||
except Exception as e:
|
||||
msg = str(e)[:500]
|
||||
logger.warning("任务 %s 第%d次失败: %s", tid, attempt, msg)
|
||||
if attempt > max_retries or self._is_cancel_requested(tid):
|
||||
return self._handle_fail(task, f"生成失败(重试{attempt-1}次后): {msg}")
|
||||
self._update(tid, note=f"失败, 自动重试({attempt}/{max_retries+1}): {msg[:80]}")
|
||||
time.sleep(3) # 重试前喘息
|
||||
|
||||
def _handle_fail(self, task: dict, reason: str):
|
||||
self._update(task["id"], status=STATUS_FAILED, error=reason[:500],
|
||||
note="❌ " + reason[:100])
|
||||
|
||||
def _is_cancel_requested(self, task_id: str) -> bool:
|
||||
con = _connect()
|
||||
row = con.execute("SELECT cancel_requested FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||
con.close()
|
||||
return bool(row and row["cancel_requested"])
|
||||
|
||||
def _estimate_cost(self, task: dict, actual_duration: float) -> float:
|
||||
"""费用估算: 单价(元/秒) × 实际生成时长(含损耗系数)"""
|
||||
cfg = _cfg()
|
||||
price = float(cfg.get("price_per_sec", 0.0) or 0.0)
|
||||
if price <= 0:
|
||||
return 0.0
|
||||
gen_dur = task["duration_sec"] or actual_duration
|
||||
# 损耗系数: 每次重试多耗一轮; 用 1 + 0.4*retries 简化(行业 ~1.4)
|
||||
loss = 1 + 0.4 * int(cfg.get("max_retries", 2))
|
||||
return round(price * gen_dur * loss, 3)
|
||||
|
||||
|
||||
def get_service() -> PersonSwapService:
|
||||
return PersonSwapService.get()
|
||||
|
||||
|
||||
# ── CLI 自测 ──
|
||||
if __name__ == "__main__":
|
||||
svc = get_service()
|
||||
print("任务数:", len(svc.list_tasks(limit=100)))
|
||||
print("本月费用:", svc.monthly_cost())
|
||||
Reference in New Issue
Block a user