15.2 大文件与二进制资产的协作

PSD、PSB、Aseprite、MOV 这类真身动辄几十兆,塞进主库会让每次克隆都慢到无法忍受。本节给《星屑旅店》定一套「主库只放指针、真身放远端」的协作法,用一份真跑的 S 层脚本和 P 层 TS 内核做指针清单与远端清单的双向对账,并算清仓库体积账。

15.2.1 大文件为什么不进主库

15.1 管的是版本号,这一节管版本库里到底放什么。美术的真身是二进制大文件:一张 .psd 源稿 40 MiB、一个 .psb 图集源 60 MiB、一段 .mov 参考 8 MiB。二进制文件有两个致命性质:一是不可 diff(改一个像素,整块字节全变,版本库存不下增量,只能整份重存);二是不可合并(两个人改同一份只能二选一)。把这类文件直接塞进主库,结果是仓库体积指数膨胀、每次克隆要拉几十 GB、切分支慢到没法用。

行业的通行解法是指针文件 + 远端存储:主库里每个大文件只留一份几十到几百字节的指针(记录真身的 oid 与大小),真身放在对象存储/制品库上按 oid 寻址。本节不重推这套机制的原理(那是工具链与管线工程的事),只做两件可算的账:指针清单 ↔ 远端清单的双向对账,以及仓库体积账。

15.2.2 D 层:指针清单与远端清单

config/sp15_lfs.json 是这份账的静态输入:pointerFiles(主库里的指针,每份 pointerBytesEach 字节)、remoteObjects(远端对象,带 oid 与真实字节)、otherRepoFiles(真正入库的常规小文件)。样本里故意留了一条悬空指针与一条孤儿对象:

{
  "project": "stardust-inn",
  "pointerBytesEach": 132,
  "note": "《星屑旅店》大文件协作样本:主库只放指针文件(每份固定 132 字节),真身放在远端对象存储。对账「指针清单 ↔ 远端清单」两个方向:悬空指针(指向不存在的对象)与孤儿对象(无人引用)。样本各含一条。",
  "pointerFiles": [
    { "path": "art/source/furn_chair_01.psd", "oid": "3f9a1c7e2b" },
    { "path": "art/source/portrait_host_01.psd", "oid": "7b2d4e6a10" },
    { "path": "art/source/atlas_ui.psb", "oid": "c1e8a5039d" },
    { "path": "art/source/tile_floor_01.aseprite", "oid": "5a6f2b8c44" },
    { "path": "art/source/vfx_steam_01.mov", "oid": "deadbeef01" }
  ],
  "remoteObjects": [
    { "oid": "3f9a1c7e2b", "size": 48234496 },
    { "oid": "7b2d4e6a10", "size": 31457280 },
    { "oid": "c1e8a5039d", "size": 62914560 },
    { "oid": "5a6f2b8c44", "size": 12582912 },
    { "oid": "0123456789", "size": 4194304 }
  ],
  "otherRepoFiles": [
    { "path": "art/atlas/ui_atlas.png", "size": 1398128 },
    { "path": "art/atlas/furn_atlas.png", "size": 1398128 },
    { "path": "assets/config_bundle.bin", "size": 131072 }
  ]
}

读法:vfx_steam_01.mov 的指针指向 deadbeef01,但远端没有这个对象——悬空指针,谁去取这份参考都会失败。远端另有 0123456789(4 MiB),没有任何指针引用它——孤儿对象,白占远端。

15.2.3 S 层:双向对账

下面这段是 S 层脚本。reconcile 做两个方向的差集,volume 算体积账,结果落 out/sp15_lfs.json:

#!/usr/bin/env python3
# file: sim/sp15_lfs.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/sp15_lfs.py
"""15.2 大文件与二进制资产协作的 S 层对账器。读 config/sp15_lfs.json:
  1) 指针清单 ↔ 远端清单双向对账:
       DANGLING  指针指向的 oid 在远端不存在(悬空指针,取不到真身)
       ORPHAN    远端存在、却没有任何指针引用的对象(孤儿,白占远端)
  2) 仓库体积账(全字节):主库 = 指针文件(每份 pointerBytesEach)+ 其它入库文件;
     若不进指针而把真身直接入库,则主库 = 其它入库文件 + 被引用对象字节。
     两者之差即「指针化省下的字节」。
违例是字符串数组,体积是整数,排序后落 out/sp15_lfs.json,供 P 层跨语言对照。全整数 → 逐位一致。
"""
import json
import os
import sys

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))


def reconcile(cfg: dict):
    pointers = cfg["pointerFiles"]
    remote = {o["oid"]: o["size"] for o in cfg["remoteObjects"]}
    referenced = {p["oid"] for p in pointers}
    dangling = sorted(f"DANGLING:{p['path']}:{p['oid']}" for p in pointers if p["oid"] not in remote)
    orphan = sorted(f"ORPHAN:{oid}" for oid in remote if oid not in referenced)
    return dangling, orphan


def volume(cfg: dict):
    pointers = cfg["pointerFiles"]
    remote = {o["oid"]: o["size"] for o in cfg["remoteObjects"]}
    other = sum(f["size"] for f in cfg["otherRepoFiles"])
    pointer_overhead = cfg["pointerBytesEach"] * len(pointers)
    repo_bytes = pointer_overhead + other
    referenced_bytes = sum(remote[p["oid"]] for p in pointers if p["oid"] in remote)
    naive_repo_bytes = other + referenced_bytes
    return {"pointerOverheadBytes": pointer_overhead, "otherRepoBytes": other,
            "repoBytes": repo_bytes, "referencedRemoteBytes": referenced_bytes,
            "naiveRepoBytes": naive_repo_bytes, "savedBytes": naive_repo_bytes - repo_bytes}


def main() -> int:
    cfg = json.load(open(os.path.join(ROOT, "config/sp15_lfs.json"), encoding="utf-8"))
    dangling, orphan = reconcile(cfg)
    vol = volume(cfg)
    viol = dangling + orphan
    report = {"project": cfg["project"], "pointerCount": len(cfg["pointerFiles"]),
              "remoteCount": len(cfg["remoteObjects"]), "dangling": dangling, "orphan": orphan,
              "volume": vol, "violations": viol, "count": len(viol)}
    with open(os.path.join(ROOT, "out/sp15_lfs.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
        f.write("\n")

    print(f"pointers={report['pointerCount']} remote={report['remoteCount']} "
          f"dangling={len(dangling)} orphan={len(orphan)}")
    for v in viol:
        print(f"  VIOLATION {v}")
    print(f"  repoBytes={vol['repoBytes']} (pointerOverhead={vol['pointerOverheadBytes']} "
          f"other={vol['otherRepoBytes']})")
    print(f"  naiveRepoBytes={vol['naiveRepoBytes']} savedBytes={vol['savedBytes']}")
    print("wrote out/sp15_lfs.json")
    return 0


if __name__ == "__main__":
    sys.exit(main())

实跑 /opt/homebrew/bin/python3 sim/sp15_lfs.py:

pointers=5 remote=5 dangling=1 orphan=1
  VIOLATION DANGLING:art/source/vfx_steam_01.mov:deadbeef01
  VIOLATION ORPHAN:0123456789
  repoBytes=2927988 (pointerOverhead=660 other=2927328)
  naiveRepoBytes=158116576 savedBytes=155188588
wrote out/sp15_lfs.json

15.2.4 仓库体积账:指针化省下的到底是什么

体积账的三行数字要连起来读:

量字节含义
pointerOverheadBytes6605 份指针 × 132 字节,这是指针化唯一的代价
repoBytes2927988主库实际占用 = 指针 660 + 其它入库 2927328
naiveRepoBytes158116576若真身直接入库 = 其它入库 2927328 + 被引用对象 155189248
savedBytes155188588naive − repo,即省下的净字节

关键点:省下的字节 = 被引用对象的全部字节 − 指针开销(155189248 − 660)。也就是说指针化把「几十兆的真身」换成了「132 字节的指针」,代价几乎可以忽略。注意 savedBytes 用的是被引用对象的字节,不含孤儿 0123456789——孤儿根本不参与主库账,它只体现在对账违例里。

15.2.5 P 层:同一套对账的 TS 内核

下面这段是 P 层 TS 内核 core/src/sp15/lfs.ts,与 Python 逐条对应——同一套 oid 集合运算、同一套体积公式:

// file: core/src/sp15/lfs.ts
// tier: P
// verify: node --test 'core/test/sp15_*.test.ts'
// 15.2 大文件协作的 P 层内核。与 sim/sp15_lfs.py 同一套规则:指针清单 ↔ 远端清单双向对账
// (DANGLING 悬空指针 / ORPHAN 孤儿对象)+ 仓库体积账。字节与条数全是整数 → 逐位一致。
// 只允许可擦除 TS 语法(禁 enum / namespace / 参数属性 / 装饰器)。

export function reconcile(cfg: any): { dangling: string[]; orphan: string[] } {
  const remote: Record<string, number> = {};
  for (const o of cfg.remoteObjects) remote[o.oid] = o.size;
  const referenced = new Set(cfg.pointerFiles.map((p: any) => p.oid));
  const dangling = cfg.pointerFiles
    .filter((p: any) => !(p.oid in remote))
    .map((p: any) => `DANGLING:${p.path}:${p.oid}`)
    .sort();
  const orphan = Object.keys(remote)
    .filter((oid) => !referenced.has(oid))
    .map((oid) => `ORPHAN:${oid}`)
    .sort();
  return { dangling, orphan };
}

export function volume(cfg: any): any {
  const remote: Record<string, number> = {};
  for (const o of cfg.remoteObjects) remote[o.oid] = o.size;
  const other = cfg.otherRepoFiles.reduce((s: number, f: any) => s + f.size, 0);
  const pointerOverheadBytes = cfg.pointerBytesEach * cfg.pointerFiles.length;
  const repoBytes = pointerOverheadBytes + other;
  const referencedRemoteBytes = cfg.pointerFiles
    .filter((p: any) => p.oid in remote)
    .reduce((s: number, p: any) => s + remote[p.oid], 0);
  const naiveRepoBytes = other + referencedRemoteBytes;
  return {
    pointerOverheadBytes,
    otherRepoBytes: other,
    repoBytes,
    referencedRemoteBytes,
    naiveRepoBytes,
    savedBytes: naiveRepoBytes - repoBytes,
  };
}

15.2.6 负例:悬空指针与孤儿对象

core/test/sp15_lfs.test.ts 一边读 Python 产物做跨语言对照,一边用内存对象构造两条负例——悬空指针与孤儿对象都必须 fail:

// file: core/test/sp15_lfs.test.ts
// tier: P
// verify: node --test 'core/test/sp15_*.test.ts'
// 跨语言对照:读 sim/sp15_lfs.py 落盘的 out/sp15_lfs.json,断言 TS 侧复算的对账结果与
// 体积账逐位一致。负例:悬空指针、孤儿对象都必须 fail。
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 { reconcile, volume } from "../src/sp15/lfs.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = resolve(HERE, "../..");
const cfg = JSON.parse(readFileSync(resolve(ROOT, "config/sp15_lfs.json"), "utf8"));
const py = JSON.parse(readFileSync(resolve(ROOT, "out/sp15_lfs.json"), "utf8"));

test("大文件对账:TS 复算与 Python 产物逐位一致", () => {
  const mine = reconcile(cfg);
  assert.deepEqual(mine.dangling, py.dangling);
  assert.deepEqual(mine.orphan, py.orphan);
  assert.deepEqual(volume(cfg), py.volume);
  assert.equal(mine.dangling.length + mine.orphan.length, py.count);
});

test("正例:样本恰好一条悬空指针与一条孤儿对象", () => {
  const mine = reconcile(cfg);
  assert.deepEqual(mine.dangling, ["DANGLING:art/source/vfx_steam_01.mov:deadbeef01"]);
  assert.deepEqual(mine.orphan, ["ORPHAN:0123456789"]);
});

test("负例:悬空指针(指针指向远端不存在的 oid)必须 fail", () => {
  const bad = {
    pointerBytesEach: 132,
    pointerFiles: [{ path: "a/x.psd", oid: "gone" }],
    remoteObjects: [],
    otherRepoFiles: [],
  };
  assert.deepEqual(reconcile(bad).dangling, ["DANGLING:a/x.psd:gone"]);
});

test("负例:孤儿对象(远端有、无指针引用)必须 fail", () => {
  const bad = {
    pointerBytesEach: 132,
    pointerFiles: [],
    remoteObjects: [{ oid: "wasted", size: 100 }],
    otherRepoFiles: [],
  };
  assert.deepEqual(reconcile(bad).orphan, ["ORPHAN:wasted"]);
});

test("正例:体积账——指针化省下被引用对象的全部字节", () => {
  const v = volume(cfg);
  assert.equal(v.repoBytes, 132 * 5 + 2927328);
  assert.equal(v.naiveRepoBytes, 2927328 + 155189248);
  assert.equal(v.savedBytes, 155189248 - 660);
});

实跑 node --test 'core/test/sp15_*.test.ts'(本节 5 条;三节合计 12 条全绿):

✔ 大文件对账:TS 复算与 Python 产物逐位一致 (1.053834ms)
✔ 正例:样本恰好一条悬空指针与一条孤儿对象 (0.109042ms)
✔ 负例:悬空指针(指针指向远端不存在的 oid)必须 fail (0.080125ms)
✔ 负例:孤儿对象(远端有、无指针引用)必须 fail (0.07525ms)
✔ 正例:体积账——指针化省下被引用对象的全部字节 (0.073542ms)
ℹ tests 12
ℹ pass 12
ℹ fail 0

15.2.7 边界与取舍

  • 真实指针服务端 / 真远端对象存储(Git LFS / Perforce / 制品库)需要真实环境,命令行不可复现,标 X;本节只对两份清单做静态集合运算与体积汇总,不触网、不上传、不下载任何真身。
  • 两方向对账都是字符串数组,逐位一致;体积账全是整数(字节),同样逐位一致。本节不涉及色彩派生量,故无浮点容差问题。
  • 孤儿对象要定期清(远端计费按存储量),悬空指针必须阻断合并(否则别人取不到真身,本地却看起来「文件在」)。两条违例的处理方向相反:孤儿删远端、悬空补上传或改指针。
  • 指针的 oid 只做寻址,不做完整性校验;真身的 sha256 校验属于远端存储的职责,本节不展开。

阅读导航:上一节:15.1 资产版本治理:命名与分支 · 下一节:15.3 资产 diff 与回滚 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gameart」更多文章

  1. 附录 A:风格参数表、量化规则与数据集索引
  2. 9.3 等距资产的制作约束
  3. 9.2 等距 tile 与遮挡规则