Files
MediaCrawler/mac-agent-os-main/05_tools/09_ave/scripts/composer/align.py
T
butubb 2112c1a870
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s
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 带网关原话 / 正常返回)。
2026-10-10 17:12:25 +08:00

137 lines
5.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
composer/align.py — 画面跟随音频对齐("剪画面"策略)
用户洞察(2026-10-03):
音画不同步时,**不该动声音**(变速会变调、裁剪会丢字),
而应该**动画面** —— 画面变速几乎没有质量损失(atempo 保音高、setpts 改时长)。
适用范围(严格遵守):
· 有对口型的镜头 **不动**(口型已与音频绑定,变速会破坏)
· 无音频的镜头不动
· 变速比超出安全范围(默认 0.67~1.5 倍)→ 不动并告警(避免明显快进/慢放感)
用法:
from composer.align import align_clips_to_audio
new_clips = align_clips_to_audio(work_dir, sb, clips, lipsync_indexes={2,3})
"""
from pathlib import Path
from lib.logger import get_logger
log = get_logger("composer.align")
def _dur(p: str) -> float:
try:
from lib.ffmpeg import probe_duration
return float(probe_duration(str(p)) or 0)
except Exception:
return 0.0
def _has_audio(p: str) -> bool:
try:
from lib.ffmpeg import has_audio_stream
return bool(has_audio_stream(str(p)))
except Exception:
return False
def align_clips_to_audio(work_dir, sb, clips: list,
lipsync_indexes: set | None = None,
max_speed: float = 1.5,
min_delta: float = 0.15,
progress_cb=None) -> list:
"""把「有台词音频、但没做对口型」的镜头**变速**到音频长度
work_dir : 工作目录(音频在 {work_dir}/pre_audio/{shot_id}.mp3)
clips : 当前镜头片段路径列表(与 sb.shots 同序)
lipsync_indexes: 已做对口型的镜头序号(1-based)——这些不动
max_speed : 允许的最大变速倍率(默认 1.5,atempo 安全区 0.5~2.0)
min_delta : 小于该时长差(秒)视为已对齐,不动
返回:新的片段路径列表(失败的镜头保持原样)
"""
import subprocess
from lib.ffmpeg import FFMPEG
work = Path(work_dir)
pre_dir = work / "pre_audio"
skip = set(lipsync_indexes or ())
out = list(clips)
done = 0
for idx, shot in enumerate(list(getattr(sb, "shots", []) or []), 1):
if idx - 1 >= len(out):
break
if idx in skip:
continue # 对口型镜头不动
sid = getattr(shot, "shot_id", f"shot_{idx:02d}")
audio = pre_dir / f"{sid}.mp3"
if not audio.exists():
continue # 无音频 → 不动
adur = _dur(str(audio))
clip = Path(str(out[idx - 1]))
if not clip.exists() or adur <= 0:
continue
vdur = _dur(str(clip))
if vdur <= 0:
continue
delta = vdur - adur
if abs(delta) < min_delta:
continue # 已对齐
ratio = vdur / adur # >1 = 画面偏长,需加速
# 超出安全区**不再跳过**(跳过 = 音画依然不同步)——
# 改为:变速到安全极限 + 裁剪剩余(画面比音频长时裁掉尾部,内容损失最小)
if ratio > max_speed:
applied = max_speed
need_trim = True
elif ratio < 1.0 / max_speed:
applied = 1.0 / max_speed
need_trim = False # 画面太短:变速到极限即可(不要补黑)
else:
applied = ratio
need_trim = abs(delta) > min_delta
dst = work / f"aligned_{idx:02d}.mp4"
if progress_cb:
progress_cb("align", sid, "running", f"画面变速 {applied:.2f}x")
# 视频变速用 setpts;音频(若存在)用 atempo 同步变速
vf = f"[0:v]setpts=PTS/{applied:.4f}[v]"
if _has_audio(str(clip)):
ap = min(max(applied, 0.5), 2.0) # atempo 安全区 0.5~2.0
fc = f"{vf};[0:a]atempo={ap:.4f}[a]"
maps = ["-map", "[v]", "-map", "[a]", "-c:a", "aac", "-b:a", "192k"]
else:
fc = vf
maps = ["-map", "[v]"]
cmd = [FFMPEG, "-y", "-i", str(clip),
"-filter_complex", fc, *maps,
"-c:v", "libx264", "-preset", "fast", "-crf", "20",
"-pix_fmt", "yuv420p"]
if need_trim and applied > 1.0:
# 变速后仍偏长 → 裁到音频长度(保留开头,丢尾部多余部分)
cmd += ["-t", f"{adur:.3f}"]
cmd.append(str(dst))
r = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
if r.returncode != 0 or not dst.exists() or dst.stat().st_size < 1_000:
log.warning(" ⚠️ %s 变速失败,保留原片段: %s", sid, (r.stderr or "")[-160:])
continue
new_dur = _dur(str(dst))
out[idx - 1] = str(dst)
done += 1
_note = f"{applied:.2f}x" + ("+裁尾" if (need_trim and applied > 1.0) else "")
log.info(" ✂️ %s 画面对齐:%.2fs → %.2fs(%s,音频 %.2fs)",
sid, vdur, new_dur, _note, adur)
if progress_cb:
progress_cb("align", sid, "done", f"{vdur:.1f}s → {new_dur:.1f}s({_note})")
if done:
log.info(" ✂️ 画面跟随音频对齐完成:%d 个镜头", done)
return out