feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

移植自 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:
2026-10-10 17:12:25 +08:00
parent 21ff01b894
commit 2112c1a870
1601 changed files with 367425 additions and 0 deletions
@@ -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())