要试十几组素材,卡住的地方往往不是生成本身,而是准备阶段:几百个文件,手点上传、手改文件名,试到第四五组就没耐心了。可行的做法是把这件事拆成四步,全部在本地命令行和一段小脚本里完成——先用 ffprobe 把目录里的素材列成一张表,再按比例和尺寸分桶生成清单 CSV,然后批量改名成「分组_序号_主体名」,最后只从每个桶里挑代表素材去试,结果回填到另一张 CSV。整条链路不涉及任何生成接口调用,只是文件操作,随时可以中断和重跑。
素材量在几十到几百个、又要反复试十几组时,手改文件名通常是主要瓶颈。建议先用 ffprobe 把宽高时长导出成 probe.csv,再用 Python 按比例、分辨率、时长分成六到十二个桶生成 manifest.csv;改名用「桶名_三位序号_主体名」,先 dry-run 打印新旧名确认无冲突再执行。抽样时每桶取一到两个,优先覆盖比例极端和数量最多的桶。每次生成结果追加写进 runs.csv,靠行数核对避免重复试。分桶阈值需要结合自己的素材确认,脚本只做本地文件操作,不调用任何模型接口。
用一条命令列出目录里素材的名称、尺寸和时长
手改文件名慢,一半原因是不知道目录里到底有什么。先让每个素材变成结构化字段,后面分桶、抽样、回填才有依据。下面这段循环对目录里的每个视频跑一次 ffprobe,拿到 JSON 后交给 jq 拼成一行 CSV,整段重定向到 probe.csv。命令放在素材目录的上一层执行。
find ./clips -maxdepth 1 -type f \( -iname '*.mp4' -o -iname '*.mov' -o -iname '*.mkv' \) | sort \
| while IFS= read -r f; do
ffprobe -v error -select_streams v:0 \
-show_entries stream=width,height:format=duration,size \
-of json "$f" \
| jq -r `--arg` p "$f" \
'[$p, .streams[0].width, .streams[0].height, (.format.duration|tonumber), .format.size] | @csv'
done > probe.csv
probe.csv 每行五个字段:文件路径、宽度(像素)、高度(像素)、时长(秒,取自容器的 format.duration)、文件体积(字节)。这里用 -of json 再交给 jq 解析,是因为 ffprobe 的 csv / default 输出字段顺序会随版本和字段组合变化,先单独跑一个文件看输出,能省掉后面反复调列号的麻烦。> 是覆盖写,如果只想补录新增素材就改成 >> 追加,但要注意别把重复行写进去。
两个常见情况先处理掉:没有视频轨的文件(.streams[0] 为空)会输出 null,jq 那一行会报错,可以在循环里加判断或事后过滤;容器时长和视频轨时长偶尔有差异,做分桶用 format.duration 足够,不必追求精确到帧。如果目录里有几百个文件,ffprobe 逐个跑会比较慢,可以先只对一个子目录跑通全流程再全量执行。
按尺寸和比例把素材分桶并生成清单 CSV
几百个素材逐个看没有意义,分桶的目的是把它们压成几个可比较的组,试生成时按组推进而不是按文件推进。分桶维度建议用比例、分辨率档、时长档三个,脚本读 probe.csv、写 manifest.csv。
import csv
from collections import Counter
def bucket(w, h, dur):
ar = w / h if h else 0
if 0.50 <= ar < 0.70: shape = "portrait"
elif 0.90 <= ar <= 1.10: shape = "square"
elif 1.60 <= ar <= 1.90: shape = "wide"
else: shape = "other"
if w * h >= 1920 * 1080: tier = "hd"
elif w * h >= 1280 * 720: tier = "sd"
else: tier = "low"
if dur <= 4: length = "short"
elif dur <= 10: length = "mid"
else: length = "long"
return f"{shape}_{tier}_{length}"
rows = []
with open("probe.csv", newline="", encoding="utf-8") as f:
for path, w, h, dur, size in csv.reader(f):
w, h, dur, size = int(w), int(h), float(dur), int(size)
rows.append({
"path": path, "width": w, "height": h,
"duration": round(dur, 2), "size": size,
"ratio": round(w / h, 3),
"bucket": bucket(w, h, dur),
})
FIELDS = ["path","width","height","duration","size","ratio","bucket","subject","note"]
with open("manifest.csv", "w", newline="", encoding="utf-8") as f:
wr = csv.DictWriter(f, fieldnames=FIELDS)
wr.writeheader()
for r in rows:
wr.writerow(r)
print(Counter(r["bucket"] for r in rows))
manifest.csv 比 probe.csv 多三列:ratio(宽高比,保留三位小数)、bucket(分桶名)、subject 与 note(先留空,改名前手工补或从原文件名提取)。桶名按「比例_分辨率档_时长档」拼,看名字就知道是哪种素材,不用回头查表。
阈值没有通用正确答案。素材以竖屏为主时,portrait 区间可以放宽到 0.5–0.75;截图类素材多时,square 可以放宽到 0.85–1.15。改完阈值重跑脚本,看末行 Counter 打印的分布:如果某个桶只有一个素材,通常说明阈值切得太细;如果这个孤例本身比例就很怪,那留着反而值得单独试一次。
批量重命名为序号加主体名
改名的目的是让试生成时一眼看出素材来源,同时保证同一桶里的素材有序号、便于在结果表里对齐。命名规则建议:桶名_三位序号_主体名.扩展名,例如 portrait_hd_short_003_dance.mp4。序号按桶各自计数,不要全局计数,否则回填时很难按桶找齐。
import csv, os
DRY_RUN = True # 先 True,确认无误再改 False
DST_DIR = "./renamed"
with open("manifest.csv", newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
counter = {}
for r in rows:
b = r["bucket"]
counter[b] = counter.get(b, 0) + 1
ext = os.path.splitext(r["path"])[1].lower()
subject = (r.get("subject") or "anon").strip().replace(" ", "_")
new_name = f"{b}_{counter[b]:03d}_{subject}{ext}"
new_path = os.path.join(DST_DIR, new_name)
if os.path.exists(new_path):
print("跳过,目标已存在:", new_path)
continue
if DRY_RUN:
print(f"{r['path']} -> {new_path}")
else:
os.makedirs(DST_DIR, exist_ok=True)
os.rename(r["path"], new_path)
dry-run 那一步不能省。先跑 DRY_RUN 为 True 的版本,把输出存成 rename_plan.txt,然后做三件事:用 sed 's/.*-> //' rename_plan.txt | sort | uniq -d 检查目标名有没有重复;用 wc -l 比对计划行数和 manifest.csv 的数据行数是否一致;抽几行肉眼确认主体名有没有取错。三项都过了再改成 False。
另外,POSIX 上 os.rename 在目标文件已存在时会直接覆盖,脚本里的 exists 判断只是兜底,不要指望它保护重要素材。素材只有一份又不想冒险,可以先在一个小目录里挑十几个文件跑通全流程,再对全量执行。
从每个桶里挑代表素材去试生成
抽样目标是「用尽量少的次数覆盖尽量不同的素材类型」,不是随机抽。规则可以这样定:每个桶至少选一个;桶内数量大于等于五个时选两个,一个取该桶分辨率最高的,一个取比例最接近桶内中位数的;桶内数量小于等于两个时全部入选;other 桶优先处理,因为比例异常的素材最容易暴露问题。
import csv, statistics
from collections import defaultdict
groups = defaultdict(list)
with open("manifest.csv", newline="", encoding="utf-8") as f:
for r in csv.DictReader(f):
groups[r["bucket"]].append(r)
for b, items in sorted(groups.items()):
items.sort(key=lambda x: x["width"] * x["height"], reverse=True)
chosen = items[:1] # 分辨率最高的一个
if len(items) >= 5:
med = statistics.median(float(x["ratio"]) for x in items)
best = min(items, key=lambda x: abs(float(x["ratio"]) - med))
if best not in chosen:
chosen.append(best)
print(b, "共", len(items), "个,入选:", [x["path"] for x in chosen])
把每个桶的入选编号记下来,落选的写清原因,方便后面复盘。常见落选原因是「与已入选素材同桶同主体」「比例差异小于阈值」「分辨率更低、画面信息更少」。这份名单可以直接当成试生成的排队顺序:先跑每个桶的第一个,跑完再回头补桶内第二个。这样一轮下来,试的次数通常比逐个素材试少很多,同时不至于只覆盖到同一类画面。
把每次生成结果回填到 CSV 并核对
试过十几组之后,最容易出的问题不是效果,而是「这组到底试没试过」「同一条素材的两个参数有没有混」。解决办法是每次生成后立刻追加一行到 runs.csv,不要攒着最后补。
import csv, os, datetime
FIELDS = ["run_id","素材编号","新文件名","分组","提示词摘要",
"参数摘要","生成结果路径","人工评分","备注"]
path = "runs.csv"
row = {
"run_id": datetime.datetime.now().strftime("%Y%m%d-%H%M%S"),
"素材编号": "portrait_hd_short_003",
"新文件名": "portrait_hd_short_003_dance.mp4",
"分组": "portrait_hd_short",
"提示词摘要": "……",
"参数摘要": "……",
"生成结果路径": "./out/xxx.mp4",
"人工评分": "",
"备注": "",
}
exists = os.path.exists(path)
with open(path, "a", newline="", encoding="utf-8") as f:
wr = csv.DictWriter(f, fieldnames=FIELDS)
if not exists:
wr.writeheader()
wr.writerow(row)
字段里 run_id 一次生成一个;素材编号直接用改名后的文件名去掉扩展名,保证和 manifest.csv 的桶、序号对得上;参数摘要只记关键差异项,不要贴长参数;人工评分先用 pass / fail 或 1–5 都行,回填时不一定当场填,事后补也可以,但别留成永远空着的占位列。
核对主要做两步。第一步查行数:wc -l runs.csv 的结果应该等于实际生成次数加一(表头占一行),对不上说明有漏记或重复写。第二步查重复:用 Python 的 csv 模块读一遍,统计 run_id 以及「素材编号 + 参数摘要」组合有没有重复。注意不要图省事用 cut -d, -f3 这类命令拆列,参数摘要里一旦出现逗号,字段位置就会整体错位,用 csv 模块读才稳。
还想知道哪些素材还没试过,可以把 runs.csv 里的素材编号列去重,和 manifest.csv 的路径列做差集,剩下的就是没覆盖到的桶和素材。这份差集通常比任何评分表都更直接地告诉你下一步该试什么,也能帮你判断是继续加素材,还是先把手上的几组结论整理清楚。