AIO 框架架构
版本:2026-08-20 第二版(推翻第一版的「路径挂载 + 独立文档」方案) 交付平台:EdgeOne Maker / Pages(主)+ COS + EdgeOne CDN(资源面) 代码:本仓库
packages/,已实现并有测试覆盖
一、第一版错在哪
第一版把每个查看器挂在各自的子路径上,切换走整页加载,理由是四条运行时 (three.js / Pixi / cocos2d-html5 / Cubism)互相冲突。
那个约束是真的,但结论是错的。 运行时冲突不该由「放弃整合」来解决, 该由框架承担隔离来解决。用户在剧情里点一下就该看到 ADV 实机播放, 而不是被扔到另一个页面。
第二版的判据只有一条:
模块之间不互相 import、不知道对方存在,却能互相调用并回话。 少装一个模块,宿主依然自洽,只是少一项能力。
二、三个概念
资源引用(ResourceRef)——通用货币
mr:character/1001 魔法纪录 · 环伊吕波
mr:sprite/100100/d_r 魔法纪录 · 精灵
mr:scenario/310241@zh 魔法纪录 · 剧情(中文)
ex:model3d/100101 示例作品 B · 鹿目圆香的 3D 模型universe 前缀不是装饰。实测跨仓库对账发现:
| 证据 | 后果 |
|---|---|
Exedra 的 style3dCharacterMstId: 100101 = 鹿目まどか;魔法纪录的 sprite 100100 = 环伊吕波 | 同号不同人。 裸数字在系统里流动,迟早把环伊吕波的档案配上鹿目圆香的模型,而且不报错 |
Exedra 的 100101 对应资源名 chara_100107_battle_unit | ID 与资源号根本不是一回事 |
wiki 给 charaId 1001 登记 costumeIds 03/04/50/53,而镜像里实际存在 100100/100101/100109 | 「charaId + 服装号」这条规律不成立 |
所以解析器拒绝没有前缀的字符串(packages/core/src/ref.ts)。
能力与意图(Capability / Intent)——调用方式
剧情阅读器不 import ADV 播放器。它只是发一个意图:
if (host.can('adv.play', scenario)) { // ← 决定按钮画不画
await host.request({
capability: 'adv.play',
ref: scenario,
params: { line: 42 },
surface: 'sheet',
});
}内核找到声明了 adv.play 且接受 scenario 的插件,装载到一个 surface 上。 没装 ADV 插件时 can() 返回 false,按钮根本不渲染——这就是插件化与 跳转链接的区别。
can() 还会检查资源清单里有没有这条 ref:装了插件也打不开不存在的东西, 在这里挡掉,用户点了才发现 404 的情况不复存在。
事件总线——回话通道
单向调用只能做到「点一下打开另一个东西」。有机整合需要反向回流:
// ADV 播到第 42 行
host.events.emit('progress', { surfaceId, ref, position: 42, total: 120 });
// 剧情阅读器(另一个模块,从未引用过 ADV)据此高亮第 42 行
kernel.events.on('progress', (p) => {
if (formatRef(p.ref) === formatRef(myScenario)) highlight(p.position);
});三种事件构成完整闭环:progress(进度)、entity.focused(ADV 里点了立绘)、 surface.opened/closed。
三、包结构(已实现)
packages/
core/ @magireco/core ref 语法、能力/意图类型、事件总线 零依赖
registry/ @magireco/registry 实体交叉表,防撞号
resource/ @magireco/resource 清单、多源回退、完整性校验
kernel/ @magireco/kernel 插件注册、意图派发、surface、上下文治理、iframe 桥
plugin-sdk/ @magireco/plugin-sdk definePlugin + 无头测试宿主依赖方向单向:core ← registry/resource ← kernel ← plugin-sdk。 core 零依赖,任何人可以只拿它去解析 ref。
四、隔离:让老库共处一室
四个查看器里有两个靠全局变量活着:
| 运行时 | 全局 | 隔离级别 |
|---|---|---|
| three.js(ESM 的现代库) | 无 | inline |
| Pixi(Exedra ADV,版本已 pin) | 无,ESM,但 pin 不能被打破 | iframe |
| cocos2d-html5(靠全局活着的老库) | window.cc | iframe |
| Cubism Core(Live2D) | window.Live2DCubismCore | iframe |
iframe 插件跑在独立 realm,通过 postMessage RPC 收发生命周期命令。 关键在于对调用方完全透明:
// 调用方写法完全一样,不知道对面是不是 iframe
await host.request({ capability: 'sprite.show', ref });createIframePlugin() 把「跑在 iframe 里的查看器」包装成一个普通 Plugin, 内核和其它插件看到的与 inline 插件毫无区别。传输层是注入的(Transport 接口), 所以协议逻辑能在 node 上直接测,不用起浏览器。
已覆盖的边界:命令超时必须 reject(不回话的 iframe 不能让 dispose 永远挂着)、 子帧已死时 dispose 仍要拆干净、子帧发来的坏 ref 直接丢弃不污染事件总线。
WebGL 上下文治理
浏览器同时保有的 WebGL 上下文有硬上限(常见 8–16)。超过之后不报错, 只是最早那个被丢弃——表现是「刚才还好好的 3D 查看器突然变黑」。
这个问题在「一个页面一个查看器」下不存在,但本框架要让剧情、精灵、Live2D、3D 同时活在一个页面上,就必须有人管。内核按 LRU 挂起最久未用的实例 (suspend() 释放上下文与音频,状态保留),用户再交互时 resume() 回来。
五、资源面:assets 与网站彻底分离
判据只有一条:网站源码里 grep 不到任何资源路径。
插件: host.resources.fetchPart(ref, 'texture')
↓
清单: mr:sprite/100100/d_r → [ {path: 's/100100.png', role: 'texture', sha256} ]
↓
选路: 按权重排序的多个 base,失败打冷却、跨资源共享
↓
COS + EdgeOne CDN- 插件不碰 URL、不碰 fetch、不知道 CDN 有几条线。 换 CDN、加备份源、 把某批资源下架,全都不需要动插件。
- 选路语义照抄客户端
CNMirrors——那套被真实玩家验证过。一条没照抄:switch_after_failures: 1是为大文件长连接定的,浏览器端小文件短请求 一次超时就永久降权会在弱网下把所有线路轮空,这里默认 2 次。 - 只收 https(localhost 例外)。清单条目缺独立校验和时完整性全押在 TLS 上, 一个明文源就是整条资源链的投毒入口。
- sha256 不符视为该源失败并继续回退,不接受坏字节。地址不是身份。
- 下架 = 从清单里去掉条目,
resolve()抛「可能已下架」,UI 降级提示而非白屏。
资源前缀在契约里全局唯一(守卫第 4 条),两个源共用一个前缀会互相覆盖清单。
这带来一个架构简化
第一版要拆 7 个 Pages 项目,是被 5 GiB / 20,000 文件的限额逼的。 资源全部外置之后,交付面只剩一个小体量的 Pages 项目:
| 第一版 | 第二版 | |
|---|---|---|
| Pages 项目 | 7 个 + 1 条反代 | 1 个 aio-station + 1 条反代 |
| 部署产物 | 各自塞着素材 | 只有代码与插件 chunk |
| iframe 插件 | — | 同一项目下的 /frames/* 路由,几十 KB |
守卫第 5 条盯着这件事:契约里声明的资产量若逼近 Pages 限额,说明它没真的外置。
六、交叉表:让「点一下看精灵」真的指对人
const spriteRef = registry.primaryLink(parseRef('mr:character/1001'), 'sprite');
// → mr:sprite/100100/d_r这是数据,不是公式(理由见 §二 的证据表)。查不到就返回空数组, 绝不按编号规律猜——猜错的代价是显示了另一个角色,而没人会立刻发现。
三条硬规则,都有测试:
- 跨作品关联直接拒收(
mr:character/1001不许链到ex:model3d/100101)。 - 分组与 kind 不符拒收。
- 反查支持:
ownerOf(sprite)→ character,供「ADV 里点立绘打开档案」。
素材来源与那条禁令
交叉表最重要的素材在一个 publish=forbidden 的源里(charaId、costumeIds、 voice_index、media_manifest),而那个仓库有 CI 强制的公开部署禁令。
交叉表是 ID 映射,不是 wiki 正文,但它是否算「派生自禁止公开的仓库」 需要维护者拍板。本方案默认:交叉表的生成脚本可以读那个源, 但生成结果能否随公开站发布,等你的决定。见「待拍板事项」。
七、宿主不止一个
最容易被忽略的一点:框架不吸收那些站点,是那些站点采用框架。
// 第三方阅读器自己的 Next.js 站点里
kernel.register(advPlayerPlugin);
// 从此它的剧情页多出一个「实机播放」按钮这一条同时解决了许可证问题:那个阅读器明确未授予任何开源许可, 不能被 vendor。但它可以作为独立宿主安装框架插件——主权不变, 能力增加。别的上游同理,装上 voice 插件就有了语音。
AIO 工作站只是其中一个宿主,装了全部插件而已。
八、EdgeOne 上怎么跑
| 部分 | 落点 |
|---|---|
aio-station(宿主 + 全部插件 chunk) | EdgeOne Pages 单项目 |
| iframe 插件的子文档 | 同项目 /frames/sprite/、/frames/adv/ 等路由 |
| 资源(约 20 GiB) | COS 桶 + EdgeOne CDN,assets.<域> |
| 清单与交叉表 | 与资源同桶,独立于网站发版更新 |
/story/ | 反代那个阅读器既有的部署 |
| 边缘函数 | 线路探测、鉴权后台会话(Phase 5) |
EdgeOne Makers 的 Agent 托管留到最后(诊断 Agent > 剧情语义检索 > 资料问答), 不得成为前面阶段的依赖。
待验证项(开工前必须复核)
- EdgeOne Pages 限额(5 GiB / 20,000 文件 / 25 MiB)——本文数据来自第三方文章, 官方文档站当时 503。资源外置后余量很大,但仍需复核。
- EdgeOne Pages Functions 的 CPU 时间 / 内存 / 请求体上限。
- 自定义域名的 ICP 备案要求;Cloudflare 托管的根域名不支持绑定,需用子域。
- COS 与 EdgeOne 回源的计费口径(资源面约 20 GiB,以图片小文件为主)。
- 精灵图集里
rotated=true的帧转的是哪个方向。 打包器把竖长图转 90° 塞进图集,两个方向都会画出一张「看着像那么回事」的图,错的那个是上下颠倒 或镜像的零件,不报错。所以plugin-sprite的placeFrame()目前对这类帧 返回 null 而不是蒙一个,舞台跳过并经onSkipped报出来。 复核方式:拿一张真实图集,找一个rotated=true的帧比对它在游戏里的朝向; 定了之后补一次 90° 旋转(五行)与一条判据。 - CocosStudio 导出的
version >= 0.3(combined)要不要那两步补偿。 运行时对这类导出会先把bone_data并进每一帧再做scale -= 1,那是它为 自己的插值方式做的修正。没有真实导出文件无从验证,所以worldPoseAt()不做这两步,只做无歧义的父级合成,并把根上的version原样放在doc.dataVersion里备查。复核方式见packages/plugin-sprite/src/pose.ts。
待拍板事项
- 交叉表能否公开。 素材来自一个有公开禁令的源。 本方案默认「生成可以、发布待定」。
- Story 走反代还是迁移。 默认反代(§七 的宿主模型让迁移不再必要)。
- 域名与备案。