Spaces:
Sleeping
Sleeping
Upload phase_b/tools/broll_randomizer.py
Browse files
phase_b/tools/broll_randomizer.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
方案 B v2 落地工具 #2:B-Roll 随机组合 + CapCut 操作指令生成
|
| 3 |
+
|
| 4 |
+
输入:批量生成的 ScriptVariant JSON
|
| 5 |
+
输出:
|
| 6 |
+
1. 每条视频的 B-Roll 随机分配(24段素材,8动作×3角度)
|
| 7 |
+
2. 防查重参数(翻转/滤镜/裁剪)
|
| 8 |
+
3. CapCut 逐条操作手册
|
| 9 |
+
4. 20天发布排期表
|
| 10 |
+
|
| 11 |
+
使用方法:
|
| 12 |
+
python broll_randomizer.py --input batch_result.json --output ./production/
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import random
|
| 17 |
+
import os
|
| 18 |
+
import csv
|
| 19 |
+
import argparse
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from datetime import datetime, timedelta
|
| 22 |
+
|
| 23 |
+
# 24 段素材:8 个动作 × 3 个角度
|
| 24 |
+
BROLL_POOL = {
|
| 25 |
+
"product_show": {"angle_A":"broll_01_product_front.mp4", "angle_B":"broll_02_product_side.mp4", "angle_C":"broll_03_product_topdown.mp4"},
|
| 26 |
+
"texture_squeeze": {"angle_A":"broll_04_squeeze_front.mp4", "angle_B":"broll_05_squeeze_side.mp4", "angle_C":"broll_06_squeeze_45deg.mp4"},
|
| 27 |
+
"texture_spread": {"angle_A":"broll_07_spread_front.mp4", "angle_B":"broll_08_spread_side_slowmo.mp4", "angle_C":"broll_09_spread_topdown.mp4"},
|
| 28 |
+
"face_apply": {"angle_A":"broll_10_apply_front.mp4", "angle_B":"broll_11_apply_side.mp4", "angle_C":None},
|
| 29 |
+
"absorption": {"angle_A":"broll_12_absorb_front.mp4", "angle_B":"broll_13_absorb_sidecloseup.mp4", "angle_C":None},
|
| 30 |
+
"paper_test": {"angle_A":"broll_14_paper_front.mp4", "angle_B":"broll_15_paper_side.mp4", "angle_C":"broll_16_paper_topdown.mp4"},
|
| 31 |
+
"result_glow": {"angle_A":"broll_17_glow_front.mp4", "angle_B":"broll_18_glow_sidelight.mp4", "angle_C":None},
|
| 32 |
+
"cta": {"angle_A":"broll_19_cta_front.mp4", "angle_B":"broll_20_cta_side.mp4", "angle_C":"broll_21_cta_topdown.mp4"},
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
FORMAT_BROLL_ACTIONS = {
|
| 36 |
+
"before_after": ["product_show","face_apply","result_glow","texture_spread","cta"],
|
| 37 |
+
"day_in_life": ["product_show","face_apply","texture_spread","absorption","result_glow","cta"],
|
| 38 |
+
"unboxing_test": ["product_show","texture_squeeze","texture_spread","face_apply","result_glow","cta"],
|
| 39 |
+
"reaction_duet": ["product_show","texture_spread","face_apply","result_glow","cta"],
|
| 40 |
+
"viral_challenge": ["product_show","face_apply","result_glow","cta"],
|
| 41 |
+
"educational_hack": ["texture_squeeze","texture_spread","absorption","result_glow","cta"],
|
| 42 |
+
"comparison_test": ["product_show","texture_spread","face_apply","result_glow","cta"],
|
| 43 |
+
"texture_asmr": ["texture_squeeze","texture_spread","absorption","product_show","cta"],
|
| 44 |
+
"storytelling": ["product_show","face_apply","texture_spread","result_glow","cta"],
|
| 45 |
+
"trend_setter": ["product_show","face_apply","result_glow","cta"],
|
| 46 |
+
"ingredient_deep_dive": ["texture_squeeze","texture_spread","absorption","result_glow","cta"],
|
| 47 |
+
"transformation_journey": ["product_show","face_apply","absorption","result_glow","cta"],
|
| 48 |
+
"myth_busting": ["product_show","texture_spread","absorption","result_glow","cta"],
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
FLIP_OPTIONS = [False, True]
|
| 52 |
+
FILTER_OPTIONS = ["原色","暖调","冷调","褪色","日系","复古","清新","美食","电影","自然"]
|
| 53 |
+
CROP_OPTIONS = ["无裁剪","放大105%","放大110%","居中裁剪","上移10%"]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def assign_broll(video_format: str, seed: int = None) -> list[dict]:
|
| 57 |
+
if seed is not None: random.seed(seed)
|
| 58 |
+
actions = FORMAT_BROLL_ACTIONS.get(video_format, FORMAT_BROLL_ACTIONS["before_after"])
|
| 59 |
+
assigned = []
|
| 60 |
+
for action in actions:
|
| 61 |
+
pool = BROLL_POOL.get(action, {})
|
| 62 |
+
available = [k for k, v in pool.items() if v is not None]
|
| 63 |
+
if not available: continue
|
| 64 |
+
chosen = random.choice(available)
|
| 65 |
+
assigned.append({
|
| 66 |
+
"action": action, "angle": chosen, "file": pool[chosen],
|
| 67 |
+
"flip": random.choice(FLIP_OPTIONS),
|
| 68 |
+
"filter": random.choice(FILTER_OPTIONS),
|
| 69 |
+
"crop": random.choice(CROP_OPTIONS),
|
| 70 |
+
})
|
| 71 |
+
return assigned
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def generate_production_plan(variants_raw: list, output_dir: str) -> dict:
|
| 75 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 76 |
+
random.seed(42)
|
| 77 |
+
valid = [v for v in variants_raw if v.get("video_format") in FORMAT_BROLL_ACTIONS]
|
| 78 |
+
plan = []
|
| 79 |
+
used = set()
|
| 80 |
+
for i, v in enumerate(valid):
|
| 81 |
+
broll = assign_broll(v["video_format"], seed=42 + i * 7)
|
| 82 |
+
fp = "|".join(b["file"] for b in broll) + str(broll[0]["flip"])
|
| 83 |
+
if fp in used:
|
| 84 |
+
broll = assign_broll(v["video_format"], seed=42 + i * 7 + 999)
|
| 85 |
+
fp = "|".join(b["file"] for b in broll) + str(broll[0]["flip"])
|
| 86 |
+
used.add(fp)
|
| 87 |
+
capcut_steps = _capcut_steps(v, broll, i+1)
|
| 88 |
+
plan.append({"sequence":i+1,"variant_id":v["variant_id"],"video_format":v["video_format"],
|
| 89 |
+
"hook_text":v.get("hook_style",""),"caption":v.get("caption_local",""),
|
| 90 |
+
"hashtags":v.get("hashtags",[]),"broll_assignment":broll,"fingerprint":fp,
|
| 91 |
+
"capcut_steps":capcut_steps})
|
| 92 |
+
|
| 93 |
+
schedule = _make_schedule(plan)
|
| 94 |
+
plan_path = os.path.join(output_dir, "production_plan.json")
|
| 95 |
+
with open(plan_path,"w",encoding="utf-8") as f:
|
| 96 |
+
json.dump({"total_videos":len(plan),"generated_at":datetime.now().isoformat(),"production_plan":plan},f,ensure_ascii=False,indent=2)
|
| 97 |
+
md_path = os.path.join(output_dir, "CAPCUT_操作手册.md")
|
| 98 |
+
_make_md(plan, schedule, md_path)
|
| 99 |
+
csv_path = os.path.join(output_dir, "发布排期表.csv")
|
| 100 |
+
_make_csv(schedule, csv_path)
|
| 101 |
+
return {"plan_file":plan_path,"manual_file":md_path,"schedule_csv":csv_path,"total":len(plan)}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _capcut_steps(variant: dict, broll: list, seq: int) -> list[str]:
|
| 105 |
+
fn = f"output_{seq:03d}_{variant['variant_id']}.mp4"
|
| 106 |
+
steps = [
|
| 107 |
+
f"1. 打开模板「beauty_sunscreen_template」→ 另存为「{variant['variant_id']}」",
|
| 108 |
+
f"2. 口播音轨:拖入 /tts/{variant['variant_id']}.mp3",
|
| 109 |
+
f"3. 前置 Hook 自拍:录 5 秒手机自拍,说:「{variant.get('hook_style','')[:50]}...」",
|
| 110 |
+
]
|
| 111 |
+
for j, b in enumerate(broll):
|
| 112 |
+
fn = " → 水平翻转" if b["flip"] else ""
|
| 113 |
+
steps.append(f"4.{j+1}. 视频轨-素材{j+1}:拖入 /broll/{b['file']}{fn}")
|
| 114 |
+
steps.append(f"5. 滤镜:{broll[0]['filter']}")
|
| 115 |
+
steps.append("6. 自动字幕 → 语言选越南语 → 确认")
|
| 116 |
+
steps.append(f"7. 文案:{variant.get('caption_local','')[:80]}")
|
| 117 |
+
steps.append(f"8. 导出 → /output/{fn}")
|
| 118 |
+
steps.append(f"9. 发布时粘贴 Hashtags:{' '.join(variant.get('hashtags',[])[:5])}")
|
| 119 |
+
return steps
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _make_schedule(plan: list) -> list:
|
| 123 |
+
slots = ["07:30","12:30","19:00","20:00","21:00"]
|
| 124 |
+
schedule = []
|
| 125 |
+
start = datetime.now().replace(hour=0,minute=0,second=0,microsecond=0)+timedelta(days=1)
|
| 126 |
+
for i, item in enumerate(plan):
|
| 127 |
+
day = start + timedelta(days=i//5)
|
| 128 |
+
h,m = int(slots[i%5].split(":")[0]), int(slots[i%5].split(":")[1])
|
| 129 |
+
pt = day.replace(hour=h, minute=m)
|
| 130 |
+
schedule.append({
|
| 131 |
+
"video_seq":item["sequence"],"variant_id":item["variant_id"],
|
| 132 |
+
"video_format":item["video_format"],
|
| 133 |
+
"publish_date":pt.strftime("%Y-%m-%d"),"publish_time":pt.strftime("%H:%M"),
|
| 134 |
+
"publish_datetime":pt.isoformat(),
|
| 135 |
+
"hashtags":" ".join(item.get("hashtags",[])[:5]),
|
| 136 |
+
})
|
| 137 |
+
return schedule
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def _make_md(plan, schedule, path):
|
| 141 |
+
lines = ["# CapCut 批量视频生产 — 操作手册","",f"总视频数: {len(plan)}","",
|
| 142 |
+
"## 准备工作","",
|
| 143 |
+
"□ /broll/ 有 24 个素材文件 (broll_01 ~ broll_21)",
|
| 144 |
+
"□ /tts/ 有所有口播 .mp3",
|
| 145 |
+
"□ CapCut 模板「beauty_sunscreen_template」已创建",
|
| 146 |
+
"□ 滤镜已安装(暖调/冷调/日系/复古/清新/自然/电影)","",
|
| 147 |
+
"## 逐条操作(前 5 条示例)",""]
|
| 148 |
+
for item in plan[:5]:
|
| 149 |
+
lines.append(f"### {item['sequence']}. `{item['variant_id']}` ({item['video_format']})")
|
| 150 |
+
for s in item["capcut_steps"]: lines.append(f"- {s}")
|
| 151 |
+
lines.append("")
|
| 152 |
+
lines.append(f"*(共 {len(plan)} 条,完整见 production_plan.json)*\n\n## 发布排期(前 3 天)\n")
|
| 153 |
+
lines.append("| 日期 | 时间 | 视频 | 格式 |")
|
| 154 |
+
lines.append("|---|---|---|---|")
|
| 155 |
+
for s in schedule[:15]:
|
| 156 |
+
lines.append(f"| {s['publish_date']} | {s['publish_time']} | #{s['video_seq']} | {s['video_format']} |")
|
| 157 |
+
with open(path,"w",encoding="utf-8") as f: f.write("\n".join(lines))
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _make_csv(schedule, path):
|
| 161 |
+
with open(path,"w",newline="",encoding="utf-8") as f:
|
| 162 |
+
w = csv.DictWriter(f,fieldnames=["publish_date","publish_time","video_seq","variant_id","video_format","hashtags"])
|
| 163 |
+
w.writeheader()
|
| 164 |
+
for s in schedule:
|
| 165 |
+
w.writerow({"publish_date":s["publish_date"],"publish_time":s["publish_time"],
|
| 166 |
+
"video_seq":s["video_seq"],"variant_id":s["variant_id"],
|
| 167 |
+
"video_format":s["video_format"],"hashtags":s["hashtags"]})
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
if __name__ == "__main__":
|
| 171 |
+
parser = argparse.ArgumentParser()
|
| 172 |
+
parser.add_argument("--input", required=True)
|
| 173 |
+
parser.add_argument("--output", default="./production")
|
| 174 |
+
args = parser.parse_args()
|
| 175 |
+
with open(args.input,"r",encoding="utf-8") as f: data = json.load(f)
|
| 176 |
+
variants = data.get("variants",[])
|
| 177 |
+
result = generate_production_plan(variants, args.output)
|
| 178 |
+
print(f"\n✅ 生产计划已生成!{result['total']} 条视频")
|
| 179 |
+
print(f" 手册: {result['manual_file']}")
|
| 180 |
+
print(f" 排期: {result['schedule_csv']}")
|