本文是「门店短视频自动化生成系统」的公开技术文档,描述其架构、流水线、关键算法与实现坑位。 目标读者:想了解或复现该系统的工程师、产品与技术决策者。 设计原则:读完应能照此从零实现一套功能等价、产物兼容的系统。 全文不含任何密钥、凭据或内网地址;涉及敏感配置(DeepSeek Key / rclone 凭据)均以外部环境变量 / 配置文件方式引用,不在此落明文。
给定「一套门店素材(视频/图片)+ 一支 BGM + 一份品牌档案」,自动产出竖屏 1080×1920 / 30fps 卡点短视频,并派生抖音/视频号/小红书/B站/门店屏多平台版本。
系统由三层组成:
- 引擎层(Python,品牌无关,读配置):素材建库 → 节拍分析 → 分镜编排 → AI 字幕 → 装配渲染 → 质检 → 指标 → 多平台派生。
- 调度层(零依赖 Node workbench/):任务队列 + 实时进度(SSE)+ 取消 + 远程任务拉取 + 成片推送。
- 触发层:浏览器工作台 UI(workbench/index.html)+ 远程 API(业务平台 /api)。
[素材库 library/<client>/ + library/_shared/]
│ scan.py ①
▼
library.json(索引 + 缩略图) ──┐
│ plan.py ④ beats.json(BGM 节拍表)── beats.py ③
┌─────────────────────────┘
▼
edl.json(剪辑决策表 / 人工卡口)
│ caption.py ⑤ → work/<project>/lines.txt(AI 逐镜台词)
▼
render.py ⑥ → output/<project>_master.mp4
├─ sfx.py ⑧-b 按切点叠加卡点音效轨
├─ subtitle.py ⑧-c 生成 ASS 烧录字幕
└─ 混音:BGM + 响度标准化(loudnorm) + 配音闪避(sidechaincompress) + 末级限幅
│ qc.py ⑦ → work/_qc/<file>.qc.json(9 项门禁)
│ metrics.py ⑨ → work/metrics.jsonl(追加一行)
▼
derive.py ⑩ → output/derived/<project>_<platform>.mp4
│
▼
成品(控制台预览 / 推送业务平台)
调度层 autocut.py 把 ①③④⑤⑥⑦ 串成一键流程(② 编号在原始设计里预留,实际未单独成模块,节拍分析即 ③)。
_autocut/
├── autocut.py # 总控编排(①→③→④→⑤→⑥→⑦ + 指标)
├── scripts/
│ ├── _common.py # 路径常量 / 配置加载 / ffprobe / 命名校验 / JSON IO
│ ├── scan.py # ① 素材建库
│ ├── beats.py # ③ 节拍分析(librosa)
│ ├── plan.py # ④ 分镜编排
│ ├── caption.py # ⑤ AI 字幕脚本(调 DeepSeek)
│ ├── llm.py # DeepSeek 零依赖封装
│ ├── render.py # ⑥ 装配出片
│ ├── sfx.py # ⑧-b 卡点音效轨
│ ├── subtitle.py # ⑧-c ASS 生成 + 烧录滤镜串
│ ├── qc.py # ⑦ 质检门禁
│ ├── metrics.py # ⑨ 指标记录
│ ├── derive.py # ⑩ 多平台派生
│ ├── make_sfx.py # ⑧-a 程序化合成音效库
│ ├── remote.sh # 远程同步变量(被 sync-to-media.sh source)
│ └── sync-to-media.sh # 成片/索引推送到业务平台
├── configs/
│ ├── platforms.yaml # 通用基线配置(母版制式/音频/字幕/QC/平台规格)
│ └── remote.yaml # rclone 远程目标变量(RCLONE_REMOTE / SITE_ROOT / DOMAIN)
├── profiles/
│ ├── _template.yaml # 客户档案模板(照抄改)
│ └── <client>.yaml # 每个客户一份(moen 等)
├── library/
│ ├── _shared/ # 共享素材:audio/bgm/、audio/sfx/、image/、video/
│ └── <client>/ # 客户私有素材:video/ image/ audio/bgm/ + index/library.json
├── work/ # 工作区(产物中间文件,可重建)
│ ├── beats/ edl/ <project>/parts <project>/sfx.wav <project>/subtitle.ass
│ ├── _qc/ # 质检报告
│ ├── tasks/ # 远程拉取的任务落地 JSON
│ ├── metrics.jsonl # 指标流水
│ ├── jobs.json # 调度队列持久化
│ └── pipeline.alert.log
├── output/ # 母版成品 + derived/ 多平台版本
├── workbench/ # 调度层(见 §8)
├── templates/ # 预留
├── bin/rclone.exe + rclone.conf # 同步工具(不入 git,单独保管)
└── .env # DEEPSEEK_API_KEY(明文,不入 git,单独保管)
关键路径常量(scripts/_common.py):ROOT=仓库根;CONFIG_DIR=configs/;PROFILE_DIR=profiles/;LIBRARY_DIR=library/;WORK_DIR=work/;OUTPUT_DIR=output/;TEMPLATE_DIR=templates/。
| 组件 | 版本/要求 | 说明 |
|---|---|---|
| Python | 3.x(建议 venv) | 引擎全部脚本经 Python venv 运行;Node 调度层通过 AUTOCUT_PY 环境变量指向该 venv 的 python.exe(缺省回退到内置路径) |
| 第三方包 | librosa numpy Pillow pyyaml |
librosa 用于节拍分析;numpy 用于音效与首帧检测;Pillow 仅首帧标准差 |
| ffmpeg | 8.x(实测 8.1.1) | 必须 ffprobe + ffmpeg 在 PATH;用到 ebur128/blackdetect/silencedetect/loudnorm/sidechaincompress 等滤镜 |
| Node.js | 内置模块即可(零依赖) | 调度层只用 http/fs/path/child_process/os,不装 npm 包 |
| DeepSeek Key | sk-... |
仅 ⑤ 字幕需要;缺失则降级跳过(出片不阻塞),通过环境变量或 .env 注入 |
| rclone | bin/rclone.exe + bin/rclone.conf |
仅推送平台用;缺失则本机出片不受影响 |
Python venv 路径注入:workbench/lib/runner.js 中 PY = process.env.AUTOCUT_PY || '<内置 venv>/Scripts/python.exe',部署时通过环境变量指定,避免硬编码本机路径。
library/<client>/index/library.json(scan.py 产出){
"client": "moen", "display_name": "摩恩卫浴(MOEN)",
"generated_at": "ISO8601", "root": "<仓库绝对路径>",
"counts": {"video":0,"image":0,"audio":0}, "total": N,
"items": [
{
"id": "<文件名无扩展>", "type": "video|image|audio",
"scope": "client|shared",
"rel_path": "library/moen/video/xxx.mp4", // 相对 ROOT,跨机同步用
"local_path": "<绝对路径>",
"thumb": "library/moen/index/thumbs/<md5前16>.jpg",
"naming_ok": true, "naming_reason": "",
"sync": "local_only",
"duration": 12.3, "width":854,"height":480,"fps":29.97,
"size": 1234567, "vcodec":"h264","has_audio":true,
"sample_rate":48000,"channels":2
}
]
}
type==video && duration>=0.8s(plan.py pick_candidates)。validate_name(stem, profile),按 profile 的 naming.template 占位符 + tokens 白名单校验;不合规只 WARN 不阻断。work/beats/<bgm>.beats.json(beats.py 产出){
"audio":"<BGM绝对路径>", "generated_at":"...", "duration":60.0,
"sr":22050, "bpm":76.5, "beat_count":N,
"beats":[{"t":0.0,"strength":0.9,"is_downbeat":true}, ...], // 每拍
"onset_count":N, "onsets":[...],
"sections":[{"start":0,"end":4,"energy":0.9,"label":"chorus"}],
"highlight":{"start":16.0,"end":20.0}, // 能量最高段
"source_size":123456, "source_mtime":1.7e9, "source_segment":0 // 缓存指纹
}
source_size + source_mtime + source_segment 三元一致才复用;否则重算。防止「同名不同内容 BGM 沿用旧节拍表导致卡点全错且不报错」。work/edl/<project>.edl.json(plan.py 产出,人工卡口){
"client":"moen","project":"moen_20260930","created_at":"...",
"fps":30,"size":[1080,1920],"target_duration":15,
"window":{"start":16.0,"end":31.0}, // BGM 上的取段窗口
"bgm":{"path":"<绝对路径>","bpm":76.5},
"shot_count":18,"total_duration":15.2,
"timeline":[
{"shot":1,"src":"rel_path","abs_src":"<绝对>","type":"video|image",
"src_in":0.0,"dur":0.79,"at":16.0, // at = BGM 绝对秒数(含窗口起点)
"transition":"cut","cue":"强拍|弱拍","strength":0.9,
"subtitle":"可选逐镜台词"}
]
}
at 字段是 BGM 时间轴(含 window.start),落字幕/音效时必须减去 window.start 转成成片时间轴(见 §5 ⑧-b/⑧-c 坑位)。work/_qc/<file>.qc.json(qc.py 产出){"file","checked_at","passed":9,"total":9,"verdict":"PASS|FAIL","metrics":{...},"checks":[{"item","ok","detail"}]}。9 个 item:分辨率/帧率/时长/音轨/响度/真峰值/黑帧/静音/首帧非纯色。
work/metrics.jsonl(metrics.py 追加,每行一条 JSON){ts, project, client, shots, duration_s, elapsed_s, ok, qc:{item:bool}, bgm, picked, voice, caption}。
work/jobs.json(queue.js 持久化,原子写)[{id, params, status, stage, pct, error, started_at, finished_at, output}]。
library/<client> 与 library/_shared(profile library.use_shared 开关)。probe_media 取元信息 + validate_name + 抽 320px 缩略图(md5 命名)。library/<client>/index/library.json(同时供 profile 读)。librosa.load(sr=22050, mono=True);beat_track 取拍点,onset_detect(backtrack=True) 取音头。is_downbeat = (i % 4 == 0)(简化 4/4 假设)。pad(<0.45) / build(<0.75) / chorus;highlight=能量最高段。--force 忽略缓存。beat_dur = 60 / bpm # 或窗口首尾拍差推算
span = max(1, round(target_shot_s / beat_dur)) # 每镜占几拍
# high→span=1(每拍切);low→span*2target_shot_s(默认 0.8)换算。since>=span 或 since>=span-1 且强拍 才切。dur<min_shot_s 跳过(min_shot_s = rhythm.min_shot_frames / fps,默认 4/30)。win_start=highlight.start,win_end=min(start+target, beats.duration);首镜从窗口起点开始(否则白丢开头)。allocate:轮转队列,优先与上一镜不同 id,按 img_ratio 概率插图片做节奏变化。--pick:任务 JSON {"picks":[...]} 或每行一个 id 的文本;只取命中素材。timeline 时 src_in 视频随机偏移(避免总从同一帧起)。--seed/--target/--img-ratio/--density 重跑即可出不同版本。SYSTEM_PROMPT 固定(见源码):行数严格=镜头数、中文口语、单行≤14字、首句钩子末句 CTA、无序号引号 Markdown。build_user_prompt(profile, edl):注入 display_name / shot_count / total_duration / bpm / tone / hook_style / closing_cta / slogan / script_brief / selling_points。parse_lines(text, n):清洗前缀、尺寸裁剪/用末句循环补足到正好 N 行。llm.chat(...) 失败 / 无 key → 降级跳过(出片不阻塞)。--mock 用 profile 字段拼占位文案验证烧录链路。work/<project>/lines.txt(一行一句)。urllib.request 调 https://api.deepseek.com/v1/chat/completions,模型 deepseek-chat。DEEPSEEK_API_KEY → 项目根 .env 的 DEEPSEEK_API_KEY= → 环境变量 DEEPSEEK_KEY_FILE 指向文件(纯 key 或含 KEY= 行)。RuntimeError,绝不把 key 进日志/异常正文。__main__:有 key 调一次连通性测试返回单字。流程:逐镜渲染(统一规格)→ concat 拼接 → 混 BGM+响度标准化 → 成品。
vf_video(横屏→竖屏,模糊背景+居中,不裁切):
[0:v]fps={fps},split=2[bg][fg];
[bg]scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h},gblur=sigma=25[bgb];
[fg]scale={w}:{h}:force_original_aspect_ratio=decrease[fgs];
[bgb][fgs]overlay=(W-w)/2:(H-h)/2,setsar=1[v]vf_image:放大 2x + zoompan 做 Ken Burns 推近(z 0→1.18)。crf/preset/pix_fmt 取自 master 段),避免同一画面压两遍画质损失。-stream_loop -1 -t total(短 BGM 不能截短片);否则 -ss win_start -t total。measure_loudness 先测(必须带与混音一致的 highpass+prechain),再套 loudnorm=I/TP/LRA/measured_*。测量若不带上前置链,会测未压缩信号导致参数对不上、双遍更差。loudnorm(母带流程),再 alimiter(limit=0.794, level=disabled) 末级限幅。highpass+prechain 不单独归一化(否则闪避后响度被拉偏)。highpass=f={voice_highpass_hz},volume,**asplit=2**[vo][vo2](一个流标签只能消费一次,不分路会报 matches no streams)。[bgm][vo]sidechaincompress=threshold={ducking.threshold}(线性值≈0.02=-34dBFS):ratio:attack:release:level_sc。amix=inputs=N:duration=first:normalize=0。-t total,绝不用 -shortest:配音比成片短,-shortest 会把整条截短(实测 16s→9s)。-c:v 而非 copy),ffmpeg cwd 切到 ASS 目录,滤镜只写 ass=文件名(避开 Windows 盘符冒号转义坑);--out 必须绝对路径(否则被 cwd 改写后找不到)。build_sfx_track(edl, cfg):读 library/_shared/audio/sfx/*.wav,按规则落位:first_shot(at=0) / strong_cut(每个强拍切点) / every_n_cuts(每 N 切点) / last_shot(at=total-0.2)。timeline[].at 是 BGM 轴,place(t.at - win_start) 转成片轴;否则音效整体后移且 QC 查不出。margin_v=420 会把字幕推到画面外看不见。→ 统一生成 ASS 并显式写 PlayResX/PlayResY = 成片分辨率。work/<project>/subtitle.srt(手工)→ ② lines.txt(按切点配时长)→ ③ edl.timeline[].subtitle。win_start(同 ⑧-b 原因);① SRT 是用户按成片轴写的,不减。ass_color:#RRGGBB → &HAABBGGRR(ASS 是 BGR 顺序,写反颜色错)。resolve_margin_v:与 safe_area.subtitle_bottom_px 联动——显式设但低于安全线则提升到安全线(防被平台 UI 遮挡)。max_chars_per_line 按字数断行(_wrap)。subtitle_filter 只返回文件名,调用方负责把 cwd 设到 ASS 目录。1080x1920 2. 帧率 30 3. 时长(有 edl 比 total_duration±0.5s;无 edl 比 qc.duration_s 硬限)4. 音轨存在 5. 响度 ebur128 ≈ -14±1 LUFS 6. 真峰值 ≤ -1 dBTP 7. 黑帧 blackdetect 8. 静音 silencedetect 9. 首帧非纯色(抽首帧算亮度 std>3)。pipeline.alert.log(无人值守留痕)。record(entry) 追加一行到 metrics.jsonl(ts 默认补),失败静默(指标不能拖垮出片)。summarize:成功率、平均/最长耗时、平均镜头数、按客户、质检失败项排行。platforms.yaml 5 套规格;比例不符走「模糊背景+居中」(同 render vf_video 思路),不裁切。shutil.copyfile(省 3 次全片编码)。library/_shared/audio/sfx/:whoosh_soft/whoosh_sharp/impact_boom/impact_hit/riser_1s/click/tick/swipe。configs/platforms.yaml(通用基线,全部字段)见源码;重点字段与保留未接字段(重做时别浪费时间接,但别删):
- master:resolution/fps/vcodec/crf/preset/pix_fmt/audio_codec/bitrate/rate。
- audio_baseline:loudness_lufs=-14, true_peak_dbtp=-1, bgm_highpass_hz=30, voice_highpass_hz=80, loudness_lra=9, bgm_prechain="acompressor...,alimiter=..."(压 BGM 峰值因子,否则 loudnorm 到不了 -14)、limiter_sample_peak=0.794(样本峰值留余量覆盖 intersample peak + AAC 过冲)。
- sfx:enabled/gain_db/rules(first_shot/strong_cut/every_n_cuts/last_shot)。
- subtitle:enabled/font/font_size/bold/color/outline/outline_color/shadow/margin_v/alignment/max_chars_per_line。
- ducking:enabled/threshold 是线性值(0.02≈-34dBFS,设太高完全不触发)/ratio/attack_ms/release_ms/level_sc/makeup_db。
- safe_area:✅ subtitle_bottom_px=400 已生效(与 subtitle.margin_v 联动);⛔ top_px/bottom_px/side_px 预留未接(画面模糊背景居中,暂不需按安全区裁切)。
- rhythm:✅ min_shot_frames=4 已生效;⛔ max_shot_s/hardcut_ratio/hook_window_s 预留未接(切镜密度现由 plan.py --shot-len 控制)。
- qc:duration_s(硬限兜底)/resolution/fps/loudness_lufs/tolerance/true_peak_dbtp/max_black_s/max_silence_s/first_frame_must_not_be_flat。
- platforms:douyin/wechat_channel/xiaohongshu/bilibili/offline_screen,各含 name/ratio/resolution/duration_s/bitrate。
- sfx_types:分类标签列表(whoosh/impact/riser/click/transition)。
profiles/_template.yaml(客户档案,引擎品牌无关)client_id / display_namenaming:template="{store}_{series}_{scene}_{date}_{seq}" + tokens{store,series,scene} 白名单 + date_format + seq_digitsbrand:primary_color/accent_color/font/logo/intro/outro/slogan/selling_pointslogo/intro/outro 当前未接入 render(预留),只 slogan/selling_points 被 caption 用。content:default_duration/hook_style/closing_cta/tone/script_brief/caption.autoplatforms:要投的平台 key 列表library:dir/use_shared_template.yaml → profiles/<id>.yaml 填内容。library/<id>/{video,image,audio/bgm},按 naming.template 命名素材。python scripts/scan.py --client <id> 建索引。python scripts/caption.py 自动用品牌档案写台词;autocut.py 自动出片。| # | 坑 | 正确做法 |
|---|---|---|
| 1 | SRT 直接烧字幕 | 必须生成 ASS 并显式 PlayResX/PlayResY=成片分辨率 |
| 2 | 字幕/音效时间用 timeline[].at |
必须减 window.start 转成片轴 |
| 3 | 混音用 -shortest |
用 -t total 锁死时长 |
| 4 | loudnorm 测量不带上 prechain | 测量链 = 混音前置链(highpass+prechain)一致 |
| 5 | 配音流直接喂 sidechain + amix | 先 asplit=2,sidechain 用 vo、amix 用 vo2 |
| 6 | SFX 输出单声道 | 必须双声道,否则成片音轨降 1ch |
| 7 | 切镜密度只看强弱拍 | 按 target_shot_s/beat_dur 算每镜占几拍 |
| 8 | 节拍表按 BGM 名无脑复用 | 校验 size+mtime+segment 指纹,换文件必重算 |
| 9 | 字幕滤镜写绝对路径带盘符 | cwd 切到 ASS 目录 + 只写文件名;--out 用绝对路径 |
| 10 | 短 BGM 截短片 | -stream_loop -1 -t total 循环铺满 |
| 11 | 逐镜与终稿编码参数不一致 | 用同一套 master 段 crf/preset |
| 12 | safe_area.top/side 与 rhythm.max_shot_s 等 |
当前预留未接,别花时间接(但保留字段) |
| 13 | brand.logo/intro/outro |
尚未接入 render,仅 slogan/selling_points 生效 |
| 14 | PY 路径硬编码 | 通过 AUTOCUT_PY 环境变量注入,避免本机路径写死 |
| 15 | 队列 onFinish 不清 current |
会永久卡住后续任务(见 queue.js 注释) |
| 文件 | 职责 |
|---|---|
server.js |
http 服务(PORT 3091,HOST 0.0.0.0),静态 UI + /api/* 路由 + SSE |
lib/runner.js |
Job 类:spawn autocut.py --progress-json,解析 @PROGRESS@ JSON 行,订阅推送;cancel() 用 taskkill /PID /T /F 杀进程树(Windows ffmpeg 残留) |
lib/queue.js |
串行队列(ffmpeg 吃满 CPU,并行无益);jobs.json 原子写(tmp+rename)持久化;重启恢复 running/pending→interrupted |
lib/sysinfo.js |
读本地:磁盘(PowerShell Win32_LogicalDisk)/metrics/alerts/assets/options/lanIp,不经服务器 |
index.html |
前端 UI(触发层):提交渲染、看队列、SSE 进度、预览成片、拉远程任务、推送平台 |
核心路由:
- POST /api/render {client,bgm,target,seed,density,scan,pick,voice} → 入队
- POST /api/batch {count,seed,...} → 批量多 seed 出片
- POST /api/pull-tasks → 从业务平台 /api/tasks + /task/:id 拉远程任务落地 work/tasks/<id>.json,转成队列任务
- GET /api/jobs GET /api/jobs/:id/stream(SSE)POST /api/jobs/:id/cancel POST /api/jobs/:id/retry DELETE /api/jobs/:id
- GET /api/outputs POST /api/outputs/push → 调 scripts/sync-to-media.sh
- GET /api/system/{disk,metrics,alerts,assets,info}
进度协议:autocut.py 在 --progress-json 时打印 @PROGRESS@<json>,含 stage/done/total/pct;runner.js 解析后推 SSE(STAGE_LABEL:scan/beats/plan/render/qc/done)。
取消:Windows 必须 taskkill /T /F 杀整棵进程树,否则 ffmpeg 残留占端口/CPU。
copy --max-age(只增不删)。scripts/remote.sh(source):RCLONE_REMOTE / SITE_ROOT / DOMAIN,统一从 configs/remote.yaml 读(换服务器只改这一处)。library.json MD5 本地 vs 远端一致;缩略图数量一致。bin/rclone.exe + bin/rclone.conf(不入 git,单独保管)。必备份(不进 git,单独保管):
1. configs/remote.yaml(rclone 目标)
2. bin/rclone.conf(同步凭据)
3. .env(DeepSeek key)
4. 客户素材 library/<client>/ 与 library/_shared/(体积大,用 rclone 或外置盘)
5. 各 profiles/<client>.yaml
恢复步骤:
1. 拉仓库(含 scripts/configs/workbench/templates)。
2. 建 Python venv:pip install librosa numpy Pillow pyyaml;确认 ffmpeg/ffprobe 在 PATH。
3. 还原上述 4 类敏感/数据文件到对应位置。
4. 设 AUTOCUT_PY 指向 venv 的 python.exe(或改 runner.js 常量)。
5. python scripts/make_sfx.py 生成音效库(若 _shared/audio/sfx 未备份)。
6. 跑 python scripts/scan.py --client <id> 重建索引(或 rclone 拉回 library/<id>/index)。
7. 启动:node workbench/server.js → http://localhost:3091(手机同 WiFi 用 lanIp)。
最小冒烟测试:python scripts/autocut.py --client moen --bgm library/_shared/audio/bgm/bgm_calm_60s.wav --target 15 --mock-caption → 应产出 output/moen_<日期>_master.mp4 且 qc 9/9。
brand.logo / intro / outro:profile 有字段,render 未消费(无角标/片头片尾)。safe_area.top_px / side_px、rhythm.max_shot_s / hardcut_ratio / hook_window_s:配置保留但未接(抖音精细化适配时再接)。moen(历史成片,854×480 横屏,验证非规整素材处理);德莉玛等客户需先灌真实门店素材进 library/<client> 才能全自动出片。_common.py(路径/配置/ffprobe/命名校验)+ configs/platforms.yaml + profiles/_template.yaml。scan.py(索引 + 缩略图)。beats.py(librosa + 缓存指纹)。plan.py(span 算法 + 轮转分配 + 白名单),先出 edl.json 人工核对。render.py(逐镜 + concat + 音频链路,按 §5⑥ 坑位),先不出字幕验证母版。subtitle.py(ASS)+ sfx.py(numpy 时间轴),接 §7 坑位。qc.py + metrics.py。llm.py + caption.py(DeepSeek 接入,降级策略)。derive.py(多平台分组复用)。workbench/(server/runner/queue/sysinfo + index.html + SSE + cancel)。sync-to-media.sh + remote.sh(rclone)。本文依据已运行版本源码整理,覆盖引擎层(scripts/ 12 个模块)、配置层(platforms.yaml / profiles)、调度层(workbench/ 5 个文件)与同步层。重做时严格照 §5 算法与 §7 坑位,可保证产物与质量门禁兼容。