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,641 @@
|
||||
"""
|
||||
AVE 视频工厂 — 统一生产入口
|
||||
|
||||
按策略路由到不同管线, 提供统一的输入接口和进度报告。
|
||||
|
||||
策略:
|
||||
口播 文案 → TTS → Pexels素材 → BGM → 字幕 → 数字人片头片尾
|
||||
卡点 BGM → 节拍检测 → 素材 → 拍点切换 → xfade
|
||||
数字人 照片+音频 → OmniHuman 或 照片+视频 → DreamActor
|
||||
|
||||
用法:
|
||||
python main.py video-factory --strategy 口播 --script script.yaml
|
||||
python main.py video-factory --strategy 卡点 --bgm bgm.wav --search 关键词
|
||||
python main.py video-factory --strategy 数字人 --image photo.jpg --text 文案
|
||||
"""
|
||||
import sys, os; sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from lib.config import load_config
|
||||
from lib.cost_tracker import get_tracker, print_history
|
||||
from lib.logger import get_logger
|
||||
|
||||
logger = get_logger("factory")
|
||||
|
||||
|
||||
# ── 策略配置 ─────────────────────────────────────────────
|
||||
|
||||
STRATEGIES = {
|
||||
"口播": {
|
||||
"desc": "文案→TTS→素材→BGM→字幕→数字人片头片尾",
|
||||
"default_output": "/tmp/ave_oral.mp4",
|
||||
},
|
||||
"卡点": {
|
||||
"desc": "BGM→节拍检测→素材→拍点切换→xfade过渡",
|
||||
"default_output": "/tmp/ave_beat.mp4",
|
||||
},
|
||||
"数字人": {
|
||||
"desc": "OmniHuman(对口型) / DreamActor(动作模仿)",
|
||||
"default_output": "/tmp/ave_digital_human.mp4",
|
||||
},
|
||||
"口播+卡点": {
|
||||
"desc": "人声锚点+BGM能量变速→帧锁定拼接",
|
||||
"default_output": "/tmp/ave_hybrid.mp4",
|
||||
},
|
||||
"故事": {
|
||||
"desc": "剧本→场景分解→Kling批量生成→角色一致性拼接",
|
||||
"default_output": "/tmp/ave_story.mp4",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def run_oral(script: str, output: str, clips_per_segment: int,
|
||||
bgm: str | None, duck: bool, anchor: bool, subtitles: bool,
|
||||
mix_engine: str = "ffmpeg") -> str:
|
||||
"""口播策略: 调用 main.py generate"""
|
||||
from main import main as cli_main
|
||||
# 直接调用 generate 的逻辑
|
||||
cfg = load_config()
|
||||
import yaml
|
||||
|
||||
print(f"[视频工厂] 口播策略: {script}")
|
||||
|
||||
# Dashboard 埋点: 创建 production 记录
|
||||
try:
|
||||
from lib.dashboard import init_db, log_production, log_step, complete_production
|
||||
init_db()
|
||||
dash_pid = log_production(
|
||||
strategy="口播",
|
||||
script_path=script,
|
||||
script_name=os.path.basename(script) if script else "",
|
||||
)
|
||||
if dash_pid > 0:
|
||||
from lib.cost_tracker import set_current_production_id
|
||||
set_current_production_id(dash_pid)
|
||||
except Exception:
|
||||
dash_pid = -1
|
||||
|
||||
# 1. 读取脚本
|
||||
if script.endswith((".yaml", ".yml")):
|
||||
with open(script, encoding="utf-8") as f:
|
||||
script_data = yaml.safe_load(f)
|
||||
else:
|
||||
from director_parser.parser import parse_script
|
||||
script = parse_script(script)
|
||||
with open(script, encoding="utf-8") as f:
|
||||
script_data = yaml.safe_load(f)
|
||||
|
||||
segments = script_data.get("segments", [])
|
||||
total_duration = sum(s.get("duration_sec", 10) for s in segments)
|
||||
print(f" 脚本: {len(segments)} 段, 预计 {total_duration}s")
|
||||
|
||||
# 2. TTS
|
||||
print(" [1/6] 合成人声...")
|
||||
from voice_synthesizer.aliyun import synthesize_with_timestamps
|
||||
ak = cfg.get("aliyun", {}).get("api_key", "")
|
||||
vid = cfg.get("aliyun", {}).get("voice_id", "")
|
||||
text_all = "\n".join(s["text"] for s in segments)
|
||||
voice_path, word_ts = synthesize_with_timestamps(
|
||||
text_all, "/tmp/ave_factory_voice.wav",
|
||||
api_key=ak, voice_id=vid,
|
||||
)
|
||||
if dash_pid > 0:
|
||||
log_step(dash_pid, "tts", "completed", detail=f"{len(text_all)}字")
|
||||
tracker = get_tracker()
|
||||
tracker.log("CosyVoice", chars=len(text_all), note=f"口播TTS {len(segments)}段")
|
||||
|
||||
# 3. 素材搜索
|
||||
print(" [2/6] 搜索素材...")
|
||||
from material_producer.pexels.search import search_videos
|
||||
mat_cfg = cfg.get("pexels", {})
|
||||
all_clips = []
|
||||
for seg in segments:
|
||||
keyword = seg.get("material", {}).get("search", "")
|
||||
if not keyword:
|
||||
continue
|
||||
clips = search_videos(keyword, count=clips_per_segment,
|
||||
api_key=mat_cfg.get("api_key", ""), orientation="portrait")
|
||||
all_clips.extend((c["path"], c["duration"]) for c in clips)
|
||||
tracker.log("Pexels", note=f"搜索: {keyword}")
|
||||
clip_paths = [c[0] for c in all_clips]
|
||||
print(f" 素材: {len(clip_paths)} 个")
|
||||
if dash_pid > 0:
|
||||
log_step(dash_pid, "search_material", "completed", detail=f"搜索到 {len(clip_paths)} 个素材")
|
||||
|
||||
# 3.5 自动 BGM 选择 (基于情绪分析)
|
||||
if not bgm:
|
||||
print(" [▸] 自动选择 BGM (情绪分析)...")
|
||||
try:
|
||||
from audio_line.layer2_analysis.emotion_analyzer import EmotionAnalyzer
|
||||
from music_selector import BGMSelector
|
||||
|
||||
emotions_analyzer = EmotionAnalyzer()
|
||||
emotion_result = emotions_analyzer.analyze(voice_path)
|
||||
|
||||
# 情绪 → BGM 风格映射
|
||||
EMOTION_STYLE_MAP = {
|
||||
"energetic": "燃系",
|
||||
"happy": "轻快",
|
||||
"calm": "舒缓",
|
||||
"sad": "治愈",
|
||||
"aggressive": "燃系",
|
||||
"neutral": "治愈",
|
||||
}
|
||||
target_style = EMOTION_STYLE_MAP.get(emotion_result.mood, "治愈")
|
||||
print(f" 检测到情绪: {emotion_result.mood} ({emotion_result.description})")
|
||||
print(f" 匹配 BGM 风格: {target_style}")
|
||||
|
||||
selector = BGMSelector()
|
||||
candidates = selector.get_by_style(target_style) + selector.get_trending(limit=3)
|
||||
# 去重
|
||||
seen = set()
|
||||
unique_candidates = []
|
||||
for c in candidates:
|
||||
if c["id"] not in seen:
|
||||
seen.add(c["id"])
|
||||
unique_candidates.append(c)
|
||||
|
||||
if unique_candidates:
|
||||
chosen = unique_candidates[0]
|
||||
bgm_path_auto = chosen.get("file", "")
|
||||
if os.path.exists(bgm_path_auto):
|
||||
bgm = bgm_path_auto
|
||||
print(f" ✅ 自动选中 BGM: {chosen['name']} - {chosen['artist']} ({chosen['style']})")
|
||||
else:
|
||||
print(f" ⚠ BGM 文件不存在,跳过: {chosen['name']} ({bgm_path_auto})")
|
||||
else:
|
||||
print(" ⚠ 未找到匹配的 BGM")
|
||||
except ImportError as e:
|
||||
print(f" ⚠ 情绪分析模块不可用: {e} (跳过自动选BGM)")
|
||||
except Exception as e:
|
||||
print(f" ⚠ 自动选 BGM 失败: {e}")
|
||||
|
||||
# 4. BGM + 避让 + 混音
|
||||
print(" [3/6] 混音...")
|
||||
from composer.ffmpeg import mix_audio, duck_bgm
|
||||
final_audio = voice_path
|
||||
if bgm and os.path.exists(bgm):
|
||||
if duck:
|
||||
bgm_path = duck_bgm(voice_path, bgm, "/tmp/ave_factory_ducked.wav",
|
||||
word_timestamps=word_ts)
|
||||
bgm_for_mix = bgm_path
|
||||
print(f" BGM避让: ON")
|
||||
else:
|
||||
bgm_for_mix = bgm
|
||||
print(f" BGM: ON (固定音量)")
|
||||
|
||||
# 混音引擎选择: ffmpeg (默认) | audio_line (MixModule)
|
||||
if mix_engine == "audio_line":
|
||||
try:
|
||||
from audio_line.layer3_creation.module_d_mix import MixModule
|
||||
mix_mod = MixModule()
|
||||
mix_result = mix_mod.mix(
|
||||
stems={"voice": voice_path, "bgm": bgm_for_mix},
|
||||
output_path="/tmp/ave_factory_master.wav",
|
||||
stem_levels={"voice": 0.0, "bgm": -6.0},
|
||||
)
|
||||
final_audio = mix_result.master_path
|
||||
print(f" AudioLine 混音: {mix_result.loudness} LUFS, "
|
||||
f"峰值={mix_result.peak_dB} dBFS")
|
||||
except Exception as e:
|
||||
print(f" ⚠ AudioLine 混音失败: {e} (回退 ffmpeg)")
|
||||
final_audio = mix_audio(voice_path, bgm_for_mix, bgm_volume=1.0)
|
||||
else:
|
||||
final_audio = mix_audio(voice_path, bgm_for_mix, bgm_volume=1.0)
|
||||
|
||||
# 5. 字幕
|
||||
print(" [4/6] 生成字幕...")
|
||||
from composer.ffmpeg import create_subtitles
|
||||
subtitles_path = None
|
||||
if subtitles and segments and word_ts:
|
||||
full_text_from_ts = "".join(w["text"] for w in word_ts)
|
||||
char_pos = []
|
||||
acc = 0
|
||||
for w in word_ts:
|
||||
char_pos.append(acc)
|
||||
acc += len(w["text"])
|
||||
cursor = 0
|
||||
for seg in segments:
|
||||
seg_text = seg["text"]
|
||||
try:
|
||||
pos = full_text_from_ts.index(seg_text, cursor)
|
||||
char_end = pos + len(seg_text)
|
||||
seg_words = [word_ts[i] for i in range(len(word_ts))
|
||||
if char_pos[i] >= pos and char_pos[i] + len(word_ts[i]["text"]) <= char_end]
|
||||
except ValueError:
|
||||
seg_words = []
|
||||
if seg_words:
|
||||
seg["start_sec"] = seg_words[0]["begin_time"] / 1000.0
|
||||
seg["end_sec"] = seg_words[-1]["end_time"] / 1000.0
|
||||
cursor = pos + len(seg_text)
|
||||
subtitles_path = "/tmp/ave_factory_subtitles.ass"
|
||||
create_subtitles(segments, subtitles_path)
|
||||
print(f" 字幕: {len(segments)} 段")
|
||||
|
||||
# 6. 合成视频
|
||||
print(" [5/6] 合成视频...")
|
||||
from composer.ffmpeg import compose_video, _get_media_duration
|
||||
from composer.ffmpeg import segment_render, concat_segments
|
||||
|
||||
audio_duration = _get_media_duration(final_audio)
|
||||
if audio_duration > 180:
|
||||
import tempfile, shutil
|
||||
seg_dir = tempfile.mkdtemp(prefix="ave_fac_")
|
||||
seg_files = segment_render(clip_paths, final_audio, output_dir=seg_dir,
|
||||
subtitles_path=subtitles_path)
|
||||
if len(seg_files) > 1:
|
||||
concat_segments(seg_files, output)
|
||||
shutil.rmtree(seg_dir, ignore_errors=True)
|
||||
else:
|
||||
shutil.move(seg_files[0], output)
|
||||
shutil.rmtree(seg_dir, ignore_errors=True)
|
||||
else:
|
||||
if anchor:
|
||||
print(" 锚点画面切换...")
|
||||
from anchor_extractor.extractor import get_silence_periods
|
||||
from composer.ffmpeg import compose_with_anchors
|
||||
silences = get_silence_periods(final_audio, min_silence_sec=0.1)
|
||||
compose_with_anchors(clip_paths, final_audio, output,
|
||||
silence_periods=silences,
|
||||
total_duration=audio_duration,
|
||||
subtitles_path=subtitles_path)
|
||||
else:
|
||||
compose_video(clip_paths, final_audio, output,
|
||||
resolution="1080x1920", subtitles_path=subtitles_path)
|
||||
|
||||
if dash_pid > 0:
|
||||
log_step(dash_pid, "compose", "completed", cost=0, detail=f"{audio_duration:.0f}s 视频")
|
||||
|
||||
# 7. LipSync 后处理 (如果脚本指定了角色且有定妆照)
|
||||
print(" [6/7] 检查 LipSync 后处理...")
|
||||
character_refs = script_data.get("meta", {}).get("character_refs", [])
|
||||
lipsync_needed = False
|
||||
for seg in segments:
|
||||
if seg.get("character_ref") and any(
|
||||
cr.get("lip_sync") for cr in character_refs if cr.get("name") == seg["character_ref"]
|
||||
):
|
||||
lipsync_needed = True
|
||||
break
|
||||
if lipsync_needed:
|
||||
print(" 角色 LipSync 已启用, 尝试后处理...")
|
||||
try:
|
||||
cfg = load_config()
|
||||
fal_key = cfg.get("fal", {}).get("api_key", "")
|
||||
if fal_key:
|
||||
from composer.lipsync import lipsync_audio_to_video
|
||||
lipsync_result = lipsync_audio_to_video(
|
||||
video_path=output,
|
||||
audio_path=voice_path,
|
||||
output_path=output.replace(".mp4", "_lipsync.mp4"),
|
||||
)
|
||||
# 替换原视频
|
||||
import shutil
|
||||
shutil.move(lipsync_result, output)
|
||||
print(f" ✅ LipSync 完成")
|
||||
if dash_pid > 0:
|
||||
log_step(dash_pid, "lipsync", "completed", detail="口型对齐")
|
||||
else:
|
||||
print(" ⏭️ 未配置 fal.ai API Key, 跳过 LipSync")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ LipSync 失败: {e} (不影响主线流程)")
|
||||
|
||||
# 8. 检查数字人片头片尾 (可选)
|
||||
print(" [7/7] 检查数字人片头片尾...")
|
||||
avatar_cfg = script_data.get("meta", {}).get("avatar", {})
|
||||
opening_text = avatar_cfg.get("opening_text", "")
|
||||
closing_text = avatar_cfg.get("closing_text", "")
|
||||
avatar_image = avatar_cfg.get("image", "")
|
||||
|
||||
if avatar_image and opening_text:
|
||||
print(" 需要生成片头 + 片尾数字人 (需手动调用 digital-human)")
|
||||
print(f" 片头: {opening_text}")
|
||||
print(f" 片尾: {closing_text}")
|
||||
|
||||
sz_mb = os.path.getsize(output) / 1024 / 1024
|
||||
print(f"\n✅ 口播完成: {output} ({sz_mb:.0f}MB, {audio_duration:.0f}s)")
|
||||
|
||||
# Dashboard: 完成 production
|
||||
if dash_pid > 0:
|
||||
try:
|
||||
complete_production(
|
||||
dash_pid, status="completed", output_path=output,
|
||||
duration_sec=audio_duration,
|
||||
total_cost=get_tracker()._total_cost(),
|
||||
)
|
||||
from lib.cost_tracker import set_current_production_id
|
||||
set_current_production_id(0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def run_beat(bgm: str, output: str, search: str, group_size: int,
|
||||
clips: list[str], texts: list[str],
|
||||
target_bpm: float = 0.0) -> str:
|
||||
"""卡点策略"""
|
||||
print(f"[视频工厂] 卡点策略: {bgm}")
|
||||
from composer.beat_sync import compose_beat_sync
|
||||
cfg = load_config()
|
||||
pexels_key = cfg.get("pexels", {}).get("api_key", "")
|
||||
|
||||
result = compose_beat_sync(
|
||||
bgm_path=bgm,
|
||||
output_path=output,
|
||||
material_clips=clips or None,
|
||||
group_size=group_size,
|
||||
texts=texts or None,
|
||||
pexels_api_key=pexels_key,
|
||||
pexels_search=search,
|
||||
target_bpm=target_bpm,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def run_digital_human(image: str, text: str, output: str, resolution: str,
|
||||
mode: str, video: str | None,
|
||||
character: str = "") -> str:
|
||||
"""数字人策略"""
|
||||
from voice_synthesizer.aliyun import synthesize
|
||||
cfg = load_config()
|
||||
ak = cfg.get("aliyun", {}).get("api_key", "")
|
||||
vid = cfg.get("aliyun", {}).get("voice_id", "")
|
||||
|
||||
# 角色注册 → 音色选择
|
||||
char_voice_style = ""
|
||||
if character:
|
||||
try:
|
||||
from character_registry import CharacterRegistry
|
||||
registry = CharacterRegistry()
|
||||
try:
|
||||
char_obj = registry.get_character(character)
|
||||
char_voice_style = char_obj.voice_style
|
||||
print(f" 角色 '{character}' 音色: {char_voice_style}")
|
||||
except KeyError:
|
||||
print(f" ⚠ 未找到角色 '{character}', 使用默认音色")
|
||||
except Exception as e:
|
||||
print(f" ⚠ 角色注册读取失败: {e}")
|
||||
|
||||
print(f"[视频工厂] 数字人策略: mode={mode}")
|
||||
|
||||
# 角色 voice_style → voice_id 映射 (占位, 可根据实际音色库扩展)
|
||||
VOICE_STYLE_MAP = {
|
||||
"甜美": "wan2.2-s2v", # 示例映射
|
||||
"成熟": "cosyvoice-v3.5-plus",
|
||||
"知性": "cosyvoice-v3.5-plus",
|
||||
"默认": vid,
|
||||
}
|
||||
if char_voice_style and char_voice_style in VOICE_STYLE_MAP:
|
||||
vid = VOICE_STYLE_MAP[char_voice_style]
|
||||
print(f" → 映射音色: {char_voice_style} → {vid}")
|
||||
|
||||
if mode == "对口型":
|
||||
from material_producer.wan2_2.wan2_2 import generate_digital_human
|
||||
audio_path = "/tmp/ave_factory_dh_voice.wav"
|
||||
print(" [1/2] 合成人声...")
|
||||
synthesize(text, audio_path, api_key=ak, voice_id=vid)
|
||||
print(" [2/2] 生成数字人 (OmniHuman)...")
|
||||
result = generate_digital_human(
|
||||
image, audio_path, ak, output_path=output,
|
||||
resolution=resolution, text=text,
|
||||
)
|
||||
elif mode == "动作模仿":
|
||||
if not video:
|
||||
raise ValueError("动作模仿模式需要 --video 参数")
|
||||
# DreamActor M1 调用
|
||||
from lib.ghvideo_upload import upload_and_get_url
|
||||
import httpx, json, hmac, hashlib, datetime
|
||||
from datetime import timezone
|
||||
|
||||
ak_v = cfg.get("volcano", {}).get("access_key_id", ak)
|
||||
sk_v = cfg.get("volcano", {}).get("secret_access_key", "")
|
||||
|
||||
host='visual.volcengineapi.com';region='cn-north-1';service='cv'
|
||||
|
||||
def sr(p,b):
|
||||
t=datetime.datetime.now(timezone.utc);cd=t.strftime('%Y%m%dT%H%M%SZ');ds=t.strftime('%Y%m%d')
|
||||
q='&'.join(f'{k}={p[k]}'for k in sorted(p));ph=hashlib.sha256(b.encode()).hexdigest()
|
||||
kd=hmac.new(sk_v.encode(),ds.encode(),hashlib.sha256).digest();kr=hmac.new(kd,region.encode(),hashlib.sha256).digest()
|
||||
ks=hmac.new(kr,service.encode(),hashlib.sha256).digest();skey=hmac.new(ks,b'request',hashlib.sha256).digest()
|
||||
sig=hmac.new(skey,f'HMAC-SHA256\n{cd}\n{ds}/{region}/{service}/request\n{hashlib.sha256(f"POST\n/\n{q}\ncontent-type:application/json\nhost:{host}\nx-content-sha256:{ph}\nx-date:{cd}\n\ncontent-type;host;x-content-sha256;x-date\n{ph}".encode()).hexdigest()}'.encode(),hashlib.sha256).hexdigest()
|
||||
return {'X-Date':cd,'Authorization':f'HMAC-SHA256 Credential={ak_v}/{ds}/{region}/{service}/request, SignedHeaders=content-type;host;x-content-sha256;x-date, Signature={sig}','X-Content-Sha256':ph,'Content-Type':'application/json'}
|
||||
|
||||
# 获取公网URL
|
||||
img_url = upload_and_get_url(image) if not image.startswith("http") else image
|
||||
vid_url = upload_and_get_url(video) if not video.startswith("http") else video
|
||||
print(f" 头像URL: {img_url[:60]}...")
|
||||
print(f" 参考视频URL: {vid_url[:60]}...")
|
||||
|
||||
p={'Action':'CVSync2AsyncSubmitTask','Version':'2022-08-31'}
|
||||
b=json.dumps({'req_key':'jimeng_dream_actor_m1_gen_video_cv','image_url':img_url,'video_url':vid_url})
|
||||
r=httpx.post(f'https://{host}/',params=p,headers=sr(p,b),data=b,timeout=30).json()
|
||||
tid=r.get('data',{}).get('task_id','')
|
||||
print(f" 任务提交: {tid}")
|
||||
|
||||
for i in range(60):
|
||||
time.sleep(10)
|
||||
p2={'Action':'CVSync2AsyncGetResult','Version':'2022-08-31'}
|
||||
b2=json.dumps({'req_key':'jimeng_dream_actor_m1_gen_video_cv','task_id':tid})
|
||||
try:
|
||||
r2=httpx.post(f'https://{host}/',params=p2,headers=sr(p2,b2),data=b2,timeout=30).json()
|
||||
s=r2.get('data',{}).get('status','?');u=r2.get('data',{}).get('video_url','')
|
||||
if u:
|
||||
r3=httpx.get(u,timeout=120)
|
||||
with open(output,'wb') as f:f.write(r3.content)
|
||||
dur=r2.get('usage',{}).get('duration',0)
|
||||
get_tracker().log("DreamActor",duration=dur,resolution=resolution.replace("P","")+"P",note="动作模仿")
|
||||
break
|
||||
print(f' [{i+1}/60] {s}')
|
||||
if s in ('FAILED','failed'):
|
||||
print(f' 失败: {json.dumps(r2,ensure_ascii=False)[:300]}')
|
||||
break
|
||||
except Exception as e:
|
||||
print(f' [{i+1}/60] {e}')
|
||||
time.sleep(5)
|
||||
else:
|
||||
raise ValueError(f"未知数字人模式: {mode}")
|
||||
|
||||
sz_mb = os.path.getsize(result) / 1024 / 1024
|
||||
print(f"\n✅ 数字人完成: {result} ({sz_mb:.1f}MB)")
|
||||
return result
|
||||
|
||||
|
||||
def run_hybrid(
|
||||
voice: str, bgm: str, output: str,
|
||||
search: str = "", group_size: int = 4,
|
||||
clips: list[str] | None = None,
|
||||
texts: list[str] | None = None,
|
||||
enable_speed_ramp: bool = True,
|
||||
base_speed: float = 1.0, high_speed: float = 1.5, low_speed: float = 0.7,
|
||||
min_silence: float = 0.15,
|
||||
) -> str:
|
||||
"""口播+卡点融合策略"""
|
||||
from composer.hybrid import compose_hybrid
|
||||
cfg = load_config()
|
||||
pexels_key = cfg.get("pexels", {}).get("api_key", "")
|
||||
|
||||
print(f"[视频工厂] 口播+卡点策略")
|
||||
print(f" 人声: {voice}")
|
||||
print(f" BGM: {bgm}")
|
||||
print(f" 变速: {'关闭' if not enable_speed_ramp else f'{low_speed:.1f}~{high_speed:.1f}x'}")
|
||||
|
||||
# Dashboard 埋点
|
||||
try:
|
||||
from lib.dashboard import init_db, log_production, log_step, complete_production
|
||||
init_db()
|
||||
prod_id = log_production("hybrid", f"混合_{Path(voice).stem}")
|
||||
log_step(prod_id, "compose", "开始")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
result = compose_hybrid(
|
||||
voice_path=voice,
|
||||
bgm_path=bgm,
|
||||
output_path=output,
|
||||
material_clips=clips or None,
|
||||
texts=texts or None,
|
||||
pexels_api_key=pexels_key,
|
||||
pexels_search=search,
|
||||
enable_speed_ramp=enable_speed_ramp,
|
||||
base_speed=base_speed,
|
||||
high_speed=high_speed,
|
||||
low_speed=low_speed,
|
||||
group_size=group_size,
|
||||
min_silence_sec=min_silence,
|
||||
)
|
||||
|
||||
try:
|
||||
complete_production(prod_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def run_story(
|
||||
script: str,
|
||||
output: str,
|
||||
character: str = "",
|
||||
block: str = "",
|
||||
story_model: str = "turbo",
|
||||
story_duration: int = 5,
|
||||
story_lang: str = "en",
|
||||
seed: int = 42,
|
||||
dry_run: bool = False,
|
||||
force: bool = False,
|
||||
) -> str:
|
||||
"""故事策略: 剧本→场景分解→Kling批量生成→拼接"""
|
||||
from story_director.scene_planner import plan_scenes, export_scenes
|
||||
from story_director.temporal_bridge import enrich_scenes_with_bridges
|
||||
from story_director.batch_generator import run_story_pipeline
|
||||
|
||||
cfg = load_config()
|
||||
|
||||
print(f"[视频工厂] 故事策略: {script}")
|
||||
print(f" 角色: {character or '(无)'} Seed: {seed}")
|
||||
|
||||
# Dashboard 埋点
|
||||
try:
|
||||
from lib.dashboard import init_db, log_production, log_step, complete_production
|
||||
init_db()
|
||||
prod_id = log_production("故事", os.path.basename(script))
|
||||
if prod_id > 0:
|
||||
from lib.cost_tracker import set_current_production_id
|
||||
set_current_production_id(prod_id)
|
||||
log_step(prod_id, "plan_scenes", "开始")
|
||||
except Exception:
|
||||
prod_id = -1
|
||||
|
||||
# 加载角色描述块
|
||||
character_block = block
|
||||
if not character_block and character:
|
||||
from character_sheet import load_character
|
||||
char = load_character(character)
|
||||
if char:
|
||||
character_block = char.get("description", "")
|
||||
print(f" 角色库: '{character}' → {character_block[:40]}...")
|
||||
|
||||
# Step 1: 场景分解
|
||||
print(" [1/3] 场景分解...")
|
||||
scenes = plan_scenes(
|
||||
script_path=script,
|
||||
character_name=character or None,
|
||||
character_block=character_block,
|
||||
lang=story_lang,
|
||||
)
|
||||
print(f" → {len(scenes)} 个场景")
|
||||
if prod_id > 0:
|
||||
log_step(prod_id, "plan_scenes", "completed", detail=f"{len(scenes)} 场景")
|
||||
|
||||
# Step 2: 桥接
|
||||
print(" [2/3] 过渡桥接...")
|
||||
enriched = enrich_scenes_with_bridges(scenes, character_block=character_block)
|
||||
import time
|
||||
tmp_scenes_path = f"/tmp/ave_story_scenes_factory_{int(time.time())}.json"
|
||||
export_scenes(scenes, tmp_scenes_path, character_block=character_block,
|
||||
lang=story_lang, seed=seed)
|
||||
if prod_id > 0:
|
||||
log_step(prod_id, "build_bridges", "completed")
|
||||
|
||||
if dry_run:
|
||||
print("\n⏸️ Dry-run — 场景分解:")
|
||||
for s in enriched:
|
||||
bridge = s.get("bridge_to_next", {})
|
||||
print(f" Scene {s['scene_id']}: {s.get('duration_sec', '?')}s "
|
||||
f"char={s.get('character_ref', 'none')} "
|
||||
f"trans={bridge.get('type', '-') if bridge else '-'}")
|
||||
print(f" Prompt: {s.get('prompt', '')[:80]}...")
|
||||
return ""
|
||||
|
||||
# Step 3: 批量生成
|
||||
print(" [3/3] 批量 Kling 生成...")
|
||||
pipeline_result = run_story_pipeline(
|
||||
scenes_path=tmp_scenes_path,
|
||||
output_dir="",
|
||||
model=story_model,
|
||||
duration=story_duration,
|
||||
force=force,
|
||||
)
|
||||
|
||||
# 尝试拼接最终视频
|
||||
import subprocess
|
||||
concat_script = pipeline_result.get("concat_script", "")
|
||||
if concat_script and os.path.exists(concat_script):
|
||||
print(f" [后处理] 执行拼接脚本...")
|
||||
try:
|
||||
subprocess.run(["bash", concat_script, output], check=True, timeout=600)
|
||||
except subprocess.TimeoutExpired:
|
||||
print(" ⚠️ 拼接超时")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f" ⚠️ 拼接失败: {e}")
|
||||
|
||||
if prod_id > 0:
|
||||
try:
|
||||
complete_production(prod_id, output_path=output,
|
||||
total_cost=get_tracker()._total_cost())
|
||||
set_current_production_id(0)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 汇总
|
||||
for r in pipeline_result.get("results", []):
|
||||
icon = "✅" if r["status"] == "success" else "💾" if r["status"] == "cached" else "❌"
|
||||
print(f" {icon} Scene {r['scene_id']:2d}: {r.get('path', 'N/A')}")
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def show_status():
|
||||
"""显示工厂状态"""
|
||||
print("=" * 40)
|
||||
print("🎬 AVE 视频工厂 · 状态")
|
||||
print("=" * 40)
|
||||
print()
|
||||
for name, info in STRATEGIES.items():
|
||||
print(f" {name:5s}: {info['desc']}")
|
||||
print()
|
||||
tracker = get_tracker()
|
||||
tracker.summary()
|
||||
Reference in New Issue
Block a user