13.1 把需求写成了带字段的 Brief;这一节管 Brief 的下游:需求方定义的技术规格表,与外包实际交回的交付物清单。分工延续上一节:《移动端与 PC 性能预算 2026》 讲预算怎么定档,本节不重推预算,只做规格表 ↔ 交付物清单这一层的对账——分辨率、格式、通道、命名这四项到底对不对得上。
13.2.1 规格表和交付物清单是两份数据
技术规格表是需求方写的:每件资产该是什么分辨率、什么格式、什么通道、什么命名。交付物清单是外包交回来的:实际交了哪些文件、每份的实际参数。两份数据各自演进,于是必然出现三类问题:
| 方向 | 含义 | 后果 |
|---|---|---|
| 缺项(MISSING) | 规格里有、交付物里没有 | 规格没落地,资产少了一件 |
| 多项(EXTRA) | 交付物里有、规格里没有 | 交了没要的东西,白花钱还占包体 |
| 字段不一致(MISMATCH) | 同 id 但参数对不上 | 通道少一个 alpha、命名带下划线,导入就炸 |
和 6.3 的「清单 ↔ 目录」一样,只查一个方向就只治一半的病。区别在于:6.3 对的是「文件在不在盘上」,本节对的是「参数对不对得上」——后者更隐蔽,因为文件存在、只是不对。
13.2.2 D 层:规格与清单
下面这段是 D 层配置 config/sp13_techspec.json:四条规格(specs)与四份交付(deliverables)故意不完全一致,好让三个方向都非空:
{
"project": "stardust-inn",
"schemaVersion": 1,
"note": "技术规格表与交付物清单的双向核对。specs 是需求方定义的技术规格(分辨率/格式/通道/命名/轴向);deliverables 是外包实际交付的清单。缺项 = 规格有、交付物没有;多项 = 交付物有、规格没有;同 id 的字段不一致记 MISMATCH。",
"fields": ["resolution", "format", "channel", "naming"],
"specs": [
{ "id": "furn_nightstand", "resolution": [128, 128], "format": "png", "channel": "rgba", "naming": "furn_nightstand_01" },
{ "id": "furn_wardrobe", "resolution": [128, 128], "format": "png", "channel": "rgba", "naming": "furn_wardrobe_01" },
{ "id": "char_host", "resolution": [256, 256], "format": "webp", "channel": "rgba", "naming": "char_host_01" },
{ "id": "prop_lamp", "resolution": [64, 64], "format": "png", "channel": "rgba", "naming": "prop_lamp_01" }
],
"deliverables": [
{ "id": "furn_nightstand", "resolution": [128, 128], "format": "png", "channel": "rgba", "naming": "furn_nightstand_01" },
{ "id": "furn_wardrobe", "resolution": [128, 128], "format": "png", "channel": "rgb", "naming": "furn_wardrobe_01" },
{ "id": "char_host", "resolution": [256, 256], "format": "webp", "channel": "rgba", "naming": "char_host_1" },
{ "id": "prop_stool", "resolution": [64, 64], "format": "png", "channel": "rgba", "naming": "prop_stool_01" }
]
}
三处故意埋的雷:prop_lamp 规格里有、交付里没有(缺项);prop_stool 交付里有、规格里没有(多项);furn_wardrobe 的通道交成了 rgb(少了 alpha)、char_host 的命名交成了 char_host_1(少了一个 0)。
13.2.3 S 层:双向核对 + 字段比对
下面这段是 S 层脚本。它按 id 对齐后算三个方向:缺项、多项、同 id 字段不一致:
#!/usr/bin/env python3
# file: sim/sp13_techspec.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp13_techspec.py
"""13.2 技术规格表 ↔ 交付物清单双向核对的 S 层脚本。
读 config/sp13_techspec.json:specs 是需求方定义的技术规格,deliverables 是外包实际交付。
按 id 对齐后算三个方向:
1. 缺项(MISSING)= 规格有、交付物没有;
2. 多项(EXTRA) = 交付物有、规格没有;
3. 字段不一致(MISMATCH)= 同 id 的 resolution/format/channel/naming 有一项对不上。
产出 out/sp13_techspec.json,供 P 层跨语言对照。全部为字符串数组,逐位一致。
"""
from __future__ import annotations
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
CFG = os.path.join(ROOT, "config/sp13_techspec.json")
OUT = os.path.join(ROOT, "out")
def load(p):
with open(p, encoding="utf-8") as f:
return json.load(f)
def fmt(v):
if isinstance(v, list):
return "x".join(str(x) for x in v)
return str(v)
def reconcile(specs: list, deliverables: list, fields: list):
smap = {s["id"]: s for s in specs}
dmap = {d["id"]: d for d in deliverables}
missing = sorted(i for i in smap if i not in dmap)
extra = sorted(i for i in dmap if i not in smap)
mism = []
for i in sorted(set(smap) & set(dmap)):
for f in fields:
a, b = smap[i].get(f), dmap[i].get(f)
if a != b:
mism.append(f"MISMATCH:{i}:{f}:{fmt(a)}!={fmt(b)}")
return extra, missing, sorted(mism)
def main() -> int:
cfg = load(CFG)
extra, missing, mism = reconcile(cfg["specs"], cfg["deliverables"], cfg["fields"])
report = {
"project": cfg["project"],
"specCount": len(cfg["specs"]),
"deliverableCount": len(cfg["deliverables"]),
"extra": extra,
"missing": missing,
"mismatches": mism,
"count": len(extra) + len(missing) + len(mism),
}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "sp13_techspec.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print(f"specs={report['specCount']} deliverables={report['deliverableCount']} "
f"extra={len(extra)} missing={len(missing)} mismatches={len(mism)}")
for v in extra:
print(f" EXTRA {v}")
for v in missing:
print(f" MISSING {v}")
for v in mism:
print(f" {v}")
print("wrote out/sp13_techspec.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑 /opt/homebrew/bin/python3 sim/sp13_techspec.py:
specs=4 deliverables=4 extra=1 missing=1 mismatches=2
EXTRA prop_stool
MISSING prop_lamp
MISMATCH:char_host:naming:char_host_01!=char_host_1
MISMATCH:furn_wardrobe:channel:rgba!=rgb
wrote out/sp13_techspec.json
规格 4 条、交付 4 份,个数一样,内容错了四处——这就是只数个数会漏掉的。三个方向各非空:一条缺项、一条多项、两条字段不一致。
13.2.4 P 层:同一套核对的 TS 内核
下面这段是 P 层 TS 内核 core/src/sp13/techspec.ts。差集与字段比对简单,但 resolution 是数组,必须按值比较而不是按引用,否则跨语言对照会飘:
// file: core/src/sp13/techspec.ts
// tier: P
// verify: node --test 'core/test/sp13_*.test.ts'
// 13.2 技术规格表 ↔ 交付物清单双向核对的 TS 侧内核。与 sim/sp13_techspec.py 同一套规则:
// 按 id 对齐后算缺项(MISSING)、多项(EXTRA)与字段不一致(MISMATCH)。
// 全部为字符串数组,逐位一致。只允许可擦除 TS 语法。
function fmt(v: any): string {
if (Array.isArray(v)) return v.join("x");
return String(v);
}
function eq(a: any, b: any): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
export function reconcileTechSpec(specs: any[], deliverables: any[], fields: string[]): any {
const smap = new Map(specs.map((s: any) => [s.id, s]));
const dmap = new Map(deliverables.map((d: any) => [d.id, d]));
const extra = deliverables.map((d: any) => d.id).filter((i: string) => !smap.has(i)).sort();
const missing = specs.map((s: any) => s.id).filter((i: string) => !dmap.has(i)).sort();
const mismatches: string[] = [];
const shared = specs.map((s: any) => s.id).filter((i: string) => dmap.has(i)).sort();
for (const i of shared) {
const s = smap.get(i);
const d = dmap.get(i);
for (const f of fields) {
if (!eq(s[f], d[f])) mismatches.push(`MISMATCH:${i}:${f}:${fmt(s[f])}!=${fmt(d[f])}`);
}
}
mismatches.sort();
return { extra, missing, mismatches, count: extra.length + missing.length + mismatches.length };
}
core/test/sp13_techspec.test.ts 读 Python 产物做跨语言对照,并用内存样本分别构造缺项、多项、字段不一致三类负例:
// file: core/test/sp13_techspec.test.ts
// tier: P
// verify: node --test 'core/test/sp13_*.test.ts'
// 跨语言对照:读 sim/sp13_techspec.py 落盘的 out/sp13_techspec.json,断言 TS 侧的双向
// 核对结果「逐位一致」。负例覆盖缺项、多项、字段不一致三类。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { reconcileTechSpec } from "../src/sp13/techspec.ts";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/sp13_techspec.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp13_techspec.json"), "utf8"));
test("技术规格 ↔ 交付物清单:TS 复算与 Python 产物逐位一致", () => {
const mine = reconcileTechSpec(cfg.specs, cfg.deliverables, cfg.fields);
assert.deepEqual(mine.extra, py.extra);
assert.deepEqual(mine.missing, py.missing);
assert.deepEqual(mine.mismatches, py.mismatches);
assert.equal(mine.count, py.count);
});
test("正例:样本缺项/多项/字段不一致各命中", () => {
const mine = reconcileTechSpec(cfg.specs, cfg.deliverables, cfg.fields);
assert.deepEqual(mine.missing, ["prop_lamp"]);
assert.deepEqual(mine.extra, ["prop_stool"]);
assert.ok(mine.mismatches.includes("MISMATCH:furn_wardrobe:channel:rgba!=rgb"));
assert.ok(mine.mismatches.includes("MISMATCH:char_host:naming:char_host_01!=char_host_1"));
});
test("负例:规格有、交付物没有(缺项)必须被检出", () => {
const specs = [{ id: "a", format: "png" }, { id: "b", format: "png" }];
const dels = [{ id: "a", format: "png" }];
assert.deepEqual(reconcileTechSpec(specs, dels, ["format"]).missing, ["b"]);
});
test("负例:交付物有、规格没有(多项)必须被检出", () => {
const specs = [{ id: "a", format: "png" }];
const dels = [{ id: "a", format: "png" }, { id: "c", format: "png" }];
assert.deepEqual(reconcileTechSpec(specs, dels, ["format"]).extra, ["c"]);
});
test("负例:同 id 字段不一致必须被检出", () => {
const specs = [{ id: "a", resolution: [128, 128], format: "png" }];
const dels = [{ id: "a", resolution: [128, 64], format: "png" }];
assert.deepEqual(reconcileTechSpec(specs, dels, ["resolution", "format"]).mismatches,
["MISMATCH:a:resolution:128x128!=128x64"]);
});
实跑 node --test 'core/test/sp13_*.test.ts'(此处只列本节相关的 5 条;三节合计 14 条全绿):
✔ 技术规格 ↔ 交付物清单:TS 复算与 Python 产物逐位一致
✔ 正例:样本缺项/多项/字段不一致各命中
✔ 负例:规格有、交付物没有(缺项)必须被检出
✔ 负例:交付物有、规格没有(多项)必须被检出
✔ 负例:同 id 字段不一致必须被检出
ℹ tests 14
ℹ pass 14
ℹ fail 0
resolution 是 [128,128] 这样的数组,Python 按值比较、TS 用 JSON.stringify 按值比较,两边都拿 128x128 这种形态落进违例字符串,逐位一致。
13.2.5 三个方向分别意味着什么
| 方向 | 该找谁 | 修法 |
|---|---|---|
| 缺项 | 外包漏交,或需求方漏发规格 | 催交 / 补规格;别急着删规格 |
| 多项 | 外包自作主张,或规格更新滞后 | 确认是不是白做;要就补规格,不要就退货 |
| 字段不一致 | 沟通落差 | 按规格重导;通道与命名优先修,因为导入期会直接炸 |
三条纪律:
- 字段比对要覆盖通道与命名。分辨率对了、通道错了(
rgbvsrgba)在缩略图上看不出来,但透明通道一丢,贴图就废了。 - 多项不是好事。「多交了一件」听着像赚了,实际是没进规格的东西没进预算、没进图集、没被盘点——和 6.3 的漏登记是同一类隐患。
- 本节与 13.1 是上下游:13.1 校验 Brief 自身是否写清,本节校验 Brief 派生的规格表与交付物是否对得上。两节合起来才是一条完整的需求闭环。
13.2.6 边界与取舍
- 真交付物比对(真读回 PNG 的通道、真量分辨率)需要真实环境(DCC / 图像工具 / 真交付文件),命令行不可复现,标 X;本节只对两份清单文本做静态对账。
- 轴向(
axis)在 2D 项目里恒为n/a,本节的fields不含它;3D 项目的轴向对齐属管线实现,见卷二相关章节,外链。 - 三个方向的差集都是字符串数组,逐位一致;本节不涉及色彩派生量,故没有浮点容差问题。
阅读导航:上一节:13.1 美术需求书(Brief)的结构化写法 · 下一节:13.3 验收标准与返工归因 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。