素材的创意设计——钩子怎么想、素材怎么和玩法保持一致、怎么小步迭代——已经在卷三 5.2 素材设计与创意迭代 与 5.3 素材-玩法一致性 写完,卷三目录 《上线变现与长线运营》 也给了渠道侧全貌。本节不重做创意设计,只换一个视角:把素材当成可编号、可批量生产、可排期的物料,像管一条产线那样管它。
买量的素材消耗量常被低估:一个跑得动的账户一周要换掉十几条素材,靠「一个人一条一条抠」根本供不上。产线思维解决的不是「创意好不好」,而是「给定人力与外包产能,这个窗口能交付多少条、先交付哪些条」。
素材工业化到底在生产什么
一条素材不是整体,而是几个可组合的坐标拼出来的:钩子(前 3 秒抓什么)、场景(哪个画面)、CTA(结尾怎么喊)、版式(投哪个比例)。把每个坐标的取值列出来,理论上限就是它们的乘积。「批量产出」的本质,就是在乘积空间里按优先级取样。
| 视角 | 问的问题 | 交付物 |
|---|---|---|
| 创意设计(卷三 5 章) | 这条素材打动谁、讲什么故事 | 分镜脚本、参考片 |
| 产线工程(本节) | 这个窗口产多少条、先产哪些、谁产 | 变体清单、编号、排期表 |
| 测试与淘汰(3.2) | 哪条真的赢、预算怎么分 | 显著性判定、分配方案 |
变体维度:把创意拆成可组合的坐标
《合成小岛》的素材拆成四个维度:钩子 5 个取值、场景 3 个、CTA 3 个、版式 3 个,乘积是 135 条理论变体。每条变体给一个优先级分:维度权重乘以该取值的产能偏好分,加权求和后取整。权重反映「哪个维度最影响成败」,偏好分反映「产线内部认为哪个取值更值得先做」——这是产线自己的排序,不是投放效果承诺。
| 维度 | 权重 | 取值(偏好分) |
|---|---|---|
| 钩子 hook | 40 | merge 90 / rescue 80 / growth 75 / contrast 70 / fail 60 |
| 场景 scene | 30 | island 85 / workshop 70 / party 65 |
| CTA | 20 | install 80 / try 70 / free 65 |
| 版式 ratio | 10 | 9x16 90 / 1x1 80 / 16x9 70 |
产线公式:组合数、优先级与产能
把上面这套写成整数公式,产线就能被算出来:
combos = Π len(dim.values) # 理论变体总数
priority = Σ (dim.weight × value.score) // 100 # 0-100 的产能优先级
capacity = inhousePerDay×inhouseDays + outsourcePerDay×outsourceDays
produced = 按 (priority 降序, id 升序) 取前 capacity 条
coverage‰ = produced × 1000 // combos # 本窗口覆盖率
关键在 capacity 那一行:产线是受约束的。当 capacity < combos,你必须做取舍——这正是优先级排序存在的理由。135 条变体不可能全产,只能按优先级先产一批,跑出数据后下一轮再补。
S 层仿真:从组合到排期
下面这段是 S 层仿真脚本 sim/o03_line.py,读 config/o03_line.json 做笛卡尔积、算优先级、裁到产能上限,并排出逐日交付计划,产出 JSON + CSV + SVG。
#!/usr/bin/env python3
# file: sim/o03_line.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/o03_line.py --config config/ --out out/
"""素材生产线仿真:变体维度笛卡尔积 → 优先级打分 → 产能裁剪 → 逐日排期。
combos = Π len(dim.values)
priority = Σ (dim.weight * value.score) // 100 # 0-100 的产能优先级
capacity = inhousePerDay*inhouseDays + outsourcePerDay*outsourceDays
produced = 按 (priority 降序, id 升序) 取前 capacity 条;coverage‰ = produced*1000//combos
排期:第 d 日先出内部产能(共 inhouseDays 天),叠加外包产能(仅前 outsourceDays 天)。
本脚本(S 层)与 core/src/o03/line.ts(P 层)读同一份 config,产出逐字符一致的 digest。
全程整数、地板除,无浮点。真实产能需真实业务环境,命令行不可复现(X)。
产出 out/o03_line.json + .csv + .svg。
"""
from __future__ import annotations
import argparse
import csv
import itertools
import json
import os
import xml.etree.ElementTree as ET
def build_variants(cfg: dict) -> list[dict]:
dims = cfg["dimensions"]
out: list[dict] = []
for picks in itertools.product(*[d["values"] for d in dims]):
score = sum(d["weight"] * v["score"] for d, v in zip(dims, picks)) // 100
out.append({"id": "mg-" + "-".join(v["id"] for v in picks), "priority": score})
return out
def capacity_of(cfg: dict) -> int:
return cfg["inhousePerDay"] * cfg["inhouseDays"] + cfg["outsourcePerDay"] * cfg["outsourceDays"]
def schedule(cfg: dict, produced: int) -> list[dict]:
rows, cum = [], 0
days = cfg["inhouseDays"]
for d in range(1, days + 1):
made = cfg["inhousePerDay"] + (cfg["outsourcePerDay"] if d <= cfg["outsourceDays"] else 0)
cum = min(cum + made, produced)
rows.append({"day": d, "made": made, "cumulative": cum})
return rows
def draw_svg(path: str, rep: dict) -> int:
"""纯 Python SVG:两根横条——组合总数 / 本窗口可产,右侧标覆盖率。"""
combos, capacity = rep["combos"], rep["capacity"]
W, H, PAD, ROW = 760, 170, 170, 46
root = ET.Element("svg", {"xmlns": "http://www.w3.org/2000/svg", "width": str(W),
"height": str(H), "viewBox": f"0 0 {W} {H}"})
ET.SubElement(root, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
ET.SubElement(root, "text", {"x": "16", "y": "28", "font-size": "15",
"fill": "#111827"}).text = "《合成小岛》素材产线:变体组合与产能覆盖(条)"
span = W - PAD - 120
hi = combos or 1
bars = [("组合总数", combos, "#e5e7eb"), ("本窗口可产", capacity, "#2563eb")]
for i, (label, val, color) in enumerate(bars):
y = 56 + i * ROW
ET.SubElement(root, "text", {"x": "16", "y": str(y + 22), "font-size": "12",
"fill": "#374151"}).text = label
w = span * val // hi
ET.SubElement(root, "rect", {"x": str(PAD), "y": str(y + 6), "width": str(w),
"height": "18", "rx": "3", "fill": color})
ET.SubElement(root, "text", {"x": str(PAD + w + 8), "y": str(y + 20), "font-size": "11",
"fill": "#6b7280"}).text = f'{val} 条'
os.makedirs(os.path.dirname(path), exist_ok=True)
ET.ElementTree(root).write(path, encoding="utf-8", xml_declaration=True)
return len(ET.tostring(root))
def run(cfg: dict) -> dict:
variants = build_variants(cfg)
variants.sort(key=lambda v: (-v["priority"], v["id"]))
capacity = capacity_of(cfg)
produced = variants[:capacity]
coverage = len(produced) * 1000 // len(variants)
rounds = (len(variants) + capacity - 1) // capacity
top = [{"id": v["id"], "priority": v["priority"]} for v in produced[:3]]
digest = (f'combos:{len(variants)}|capacity:{capacity}|coveragePermille:{coverage}'
f'|produced:{len(produced)}|top3:'
+ ",".join(f'{t["id"]}:{t["priority"]}' for t in top))
return {"combos": len(variants), "capacity": capacity, "produced": len(produced),
"coveragePermille": coverage, "roundsToFull": rounds, "top": top,
"schedule": schedule(cfg, len(produced)), "digest": digest}
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
with open(os.path.join(args.config, "o03_line.json"), encoding="utf-8") as f:
cfg = json.load(f)
rep = run(cfg)
os.makedirs(args.out, exist_ok=True)
svg_bytes = draw_svg(os.path.join(args.out, "o03_line.svg"), rep)
report = {"version": cfg["version"], "game": cfg["game"], "observedAt": cfg["observedAt"],
**rep, "svg": os.path.join(args.out, "o03_line.svg"), "svgBytes": svg_bytes}
with open(os.path.join(args.out, "o03_line.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "o03_line.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["day", "made", "cumulative"])
for r in rep["schedule"]:
w.writerow([r["day"], r["made"], r["cumulative"]])
print(json.dumps({"combos": rep["combos"], "capacity": rep["capacity"],
"produced": rep["produced"], "coveragePermille": rep["coveragePermille"],
"roundsToFull": rep["roundsToFull"], "top": rep["top"],
"schedule": rep["schedule"], "digest": rep["digest"]},
ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
维度与产能放在 config/o03_line.json。这里的偏好分是产线自己的排序假设,不是任何平台后台的实测数据。
{
"version": 1,
"game": "merge-island",
"observedAt": "2026-10",
"note": "素材生产线参数,口径是「公开可观察机制」的建模假设,不是任何平台后台实测。dimensions 是变体维度(钩子/场景/CTA/版式),每个取值带 score(0-100 的产能优先级,人工定的排序偏好);weight 是维度权重,Σweight=100。inhousePerDay × inhouseDays + outsourcePerDay × outsourceDays = 本排期窗口可交付的成品素材上限。全整数,无浮点。真实产线交付速度与真实素材产能需真实业务环境,命令行不可复现(X)。",
"inhousePerDay": 6,
"inhouseDays": 10,
"outsourcePerDay": 4,
"outsourceDays": 5,
"dimensions": [
{ "id": "hook", "name": "钩子", "weight": 40, "values": [
{ "id": "merge", "score": 90 }, { "id": "rescue", "score": 80 },
{ "id": "growth", "score": 75 }, { "id": "contrast", "score": 70 },
{ "id": "fail", "score": 60 } ] },
{ "id": "scene", "name": "场景", "weight": 30, "values": [
{ "id": "island", "score": 85 }, { "id": "workshop", "score": 70 },
{ "id": "party", "score": 65 } ] },
{ "id": "cta", "name": "CTA", "weight": 20, "values": [
{ "id": "install", "score": 80 }, { "id": "try", "score": 70 },
{ "id": "free", "score": 65 } ] },
{ "id": "ratio", "name": "版式", "weight": 10, "values": [
{ "id": "9x16", "score": 90 }, { "id": "1x1", "score": 80 },
{ "id": "16x9", "score": 70 } ] }
]
}
在本机跑 /opt/homebrew/bin/python3 sim/o03_line.py --config config/ --out out/,产线跑出来的结果(同机相对量级,绝对数值不可移植):
| 指标 | 数值 |
|---|---|
| 理论变体 combos | 135 |
| 本窗口产能 capacity | 80 |
| 已排产 produced | 80 |
| 覆盖率 coverage‰ | 592 |
| 覆盖全部所需窗口数 | 2 |
排期表才是产线真正的交付物:第 1 天出 10 条(内部 6 + 外包 4),第 6 天起外包交完只剩内部 6 条,第 10 天累计到 80 条封顶。覆盖率 592‰ 说明一个窗口只够产 59% 的理论变体——取舍不是选项,是必然。
P 层内核:跨语言对拍,证明排序没写错
光看一张表,你没法确认「是模型对」还是「排序碰巧看着对」。本套用一份 P 层 TypeScript 内核 core/src/o03/line.ts 复算同一条产线:同样的笛卡尔积、同样的加权取整、同样的 (priority 降序, id 升序) 排序。它读同一份 config/o03_line.json,产出和 Python 逐字符一致的摘要。
// file: core/src/o03/line.ts
// tier: P
// verify: node --test 'core/test/o03_*.test.ts'
//
// 素材生产线内核:与 sim/o03_line.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/o03_line.json;全整数、地板除、无浮点;只用可擦除语法(禁 enum / namespace)。
// 排序键固定为 (priority 降序, id 升序),保证两套语言取到同一条产线清单。
export interface LineValue { readonly id: string; readonly score: number; }
export interface LineDimension { readonly id: string; readonly name: string; readonly weight: number; readonly values: readonly LineValue[]; }
export interface LineConfig {
readonly version: number; readonly game: string;
readonly inhousePerDay: number; readonly inhouseDays: number;
readonly outsourcePerDay: number; readonly outsourceDays: number;
readonly dimensions: readonly LineDimension[];
}
export interface LineVariant { readonly id: string; readonly priority: number; }
export interface LineScheduleRow { readonly day: number; readonly made: number; readonly cumulative: number; }
export interface LineResult {
readonly combos: number; readonly capacity: number; readonly produced: number;
readonly coveragePermille: number; readonly roundsToFull: number;
readonly top: readonly LineVariant[]; readonly schedule: readonly LineScheduleRow[];
readonly digest: string;
}
/** 变体维度笛卡尔积:每个组合的 priority = Σ(weight * score) // 100(先求和再地板除)。 */
export function buildVariants(cfg: LineConfig): LineVariant[] {
let acc: { id: string; raw: number }[] = [{ id: "mg", raw: 0 }];
for (const dim of cfg.dimensions) {
const next: { id: string; raw: number }[] = [];
for (const base of acc) {
for (const v of dim.values) {
next.push({ id: `${base.id}-${v.id}`, raw: base.raw + dim.weight * v.score });
}
}
acc = next;
}
return acc.map((a) => ({ id: a.id, priority: Math.floor(a.raw / 100) }));
}
export function capacityOf(cfg: LineConfig): number {
return cfg.inhousePerDay * cfg.inhouseDays + cfg.outsourcePerDay * cfg.outsourceDays;
}
export function schedule(cfg: LineConfig, produced: number): LineScheduleRow[] {
const rows: LineScheduleRow[] = [];
let cum = 0;
for (let d = 1; d <= cfg.inhouseDays; d += 1) {
const made = cfg.inhousePerDay + (d <= cfg.outsourceDays ? cfg.outsourcePerDay : 0);
cum = Math.min(cum + made, produced);
rows.push({ day: d, made, cumulative: cum });
}
return rows;
}
/** 排序:priority 降序,同分按 id 升序(与 Python 的 (-priority, id) 完全一致)。 */
function byPriorityThenId(a: LineVariant, b: LineVariant): number {
if (a.priority !== b.priority) return b.priority - a.priority;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
}
export function run(cfg: LineConfig): LineResult {
const variants = buildVariants(cfg).sort(byPriorityThenId);
const capacity = capacityOf(cfg);
const produced = variants.slice(0, capacity);
const coveragePermille = Math.floor((produced.length * 1000) / variants.length);
const roundsToFull = Math.floor((variants.length + capacity - 1) / capacity);
const top = produced.slice(0, 3);
const digest = `combos:${variants.length}|capacity:${capacity}|coveragePermille:${coveragePermille}`
+ `|produced:${produced.length}|top3:${top.map((t) => `${t.id}:${t.priority}`).join(",")}`;
return { combos: variants.length, capacity, produced: produced.length,
coveragePermille, roundsToFull, top, schedule: schedule(cfg, produced.length), digest };
}
配套测试 core/test/o03_line.test.ts 把 Python 落盘的摘要写成金标准,断言 TS 内核复算结果逐字符相等,并验同分排序与排期。在本机跑 node --test 'core/test/o03_*.test.ts',两套独立实现给出同一个摘要:
digest = combos:135|capacity:80|coveragePermille:592|produced:80|top3:mg-merge-island-install-9x16:86,mg-merge-island-install-1x1:85,mg-merge-island-install-16x9:84
只要有一边的加权取整、排序方向或同分规则写错,摘要立刻分叉。这和卷二「一份逻辑、两种宿主」 是同一个信念:同一个数,在 Python 仿真、TS 内核、浏览器原型里必须长得一模一样。
X 层:真实产线不可复现
必须把边界划清。上面算的是产线侧:给定维度、偏好分与产能,这个窗口能交付多少条、先交付哪些。下面这些属于 X 层,需真实业务环境,命令行不可复现:
- 真实的产线交付速度(同一个人画不同复杂度素材,日产能能差好几倍);
- 真实的偏好分依据(哪个钩子更值得先做,只有投放数据能回答,产线只能先押)。
工程上正确的做法是:把可复现的排产层与不可复现的真实产能层分开——排产层做「只有 80 条产能、先做哪些」的推演,真实产能层用来在跑过一轮后反标定偏好分与日产能。
自检表
| 检查项 | 合格标准 |
|---|---|
| 维度可枚举 | 每个维度的取值列全,乘积即理论上限 |
| 优先级可解释 | 权重与偏好分有明确含义,不是拍脑袋的魔数 |
| 产能显式 | 内部 + 外包产能写成 config 字段,不藏在代码里 |
| 编号确定 | 变体 ID 由维度取值拼出,可复现、可去重 |
| 排期可见 | 逐日交付量可复算,能对接外包排期 |
产线最危险的错觉,是把「排出来的清单」当成「必须全做的清单」。产能永远小于理论变体,排产的价值恰恰在于逼你承认取舍。下一节处理取舍的下半场:这批素材投出去后怎么用数据判定谁该留、谁该杀、预算怎么分。
阅读导航:上一节:2.3 止损线与关停规则 · 下一节:3.2 素材测试矩阵与优胜劣汰 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。