3.1 素材生产线与批量产出

素材设计框架已在卷三 5 章写完,本节只做工业化产线:把钩子、场景、CTA、版式四个维度做笛卡尔积,用整数优先级裁剪到产能上限,再排出逐日交付计划;配 S 层 Python 仿真与 P 层 TypeScript 内核跨语言对拍同一份 config。

素材的创意设计——钩子怎么想、素材怎么和玩法保持一致、怎么小步迭代——已经在卷三 5.2 素材设计与创意迭代 与 5.3 素材-玩法一致性 写完,卷三目录 《上线变现与长线运营》 也给了渠道侧全貌。本节不重做创意设计,只换一个视角:把素材当成可编号、可批量生产、可排期的物料,像管一条产线那样管它。

买量的素材消耗量常被低估:一个跑得动的账户一周要换掉十几条素材,靠「一个人一条一条抠」根本供不上。产线思维解决的不是「创意好不好」,而是「给定人力与外包产能,这个窗口能交付多少条、先交付哪些条」。

素材工业化到底在生产什么

一条素材不是整体,而是几个可组合的坐标拼出来的:钩子(前 3 秒抓什么)、场景(哪个画面)、CTA(结尾怎么喊)、版式(投哪个比例)。把每个坐标的取值列出来,理论上限就是它们的乘积。「批量产出」的本质,就是在乘积空间里按优先级取样。

视角问的问题交付物
创意设计(卷三 5 章)这条素材打动谁、讲什么故事分镜脚本、参考片
产线工程(本节)这个窗口产多少条、先产哪些、谁产变体清单、编号、排期表
测试与淘汰(3.2)哪条真的赢、预算怎么分显著性判定、分配方案

变体维度:把创意拆成可组合的坐标

《合成小岛》的素材拆成四个维度:钩子 5 个取值、场景 3 个、CTA 3 个、版式 3 个,乘积是 135 条理论变体。每条变体给一个优先级分:维度权重乘以该取值的产能偏好分,加权求和后取整。权重反映「哪个维度最影响成败」,偏好分反映「产线内部认为哪个取值更值得先做」——这是产线自己的排序,不是投放效果承诺。

维度权重取值(偏好分)
钩子 hook40merge 90 / rescue 80 / growth 75 / contrast 70 / fail 60
场景 scene30island 85 / workshop 70 / party 65
CTA20install 80 / try 70 / free 65
版式 ratio109x16 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/,产线跑出来的结果(同机相对量级,绝对数值不可移植):

指标数值
理论变体 combos135
本窗口产能 capacity80
已排产 produced80
覆盖率 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 素材测试矩阵与优胜劣汰 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制