Compare commits

...
Author SHA1 Message Date
suyx 69f447b614 fix(POSTV1-TEMPLATE-LAYOUT-19): 还原逐字纹理与叠层文字
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-07 01:52:18 +08:00
suyx 210554628d fix(POSTV1-TEMPLATE-LAYOUT-19): 还原路径文字与多字体布局
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-07 01:13:47 +08:00
suyx 3a018dfa37 fix(POSTV1-TEMPLATE-LAYOUT-19): 修复联动文字背景锚点
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-07 00:13:30 +08:00
suyx c6e893515c fix(POSTV1-TEMPLATE-LAYOUT-19): 修正文字模板对齐枚举
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 23:32:47 +08:00
suyx a173222d74 fix(POSTV1-TEMPLATE-LAYOUT-19): 同步缩放字距并限制文本边界
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 19:37:08 +08:00
suyx f244920a61 fix(POSTV1-TEMPLATE-LAYOUT-19): 按原始文本框修复模板偏移
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 19:01:33 +08:00
suyx 5e8a491f2a fix(POSTV1-TEMPLATE-PLACEMENT-18): 修复文字模板坐标与描边解析
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 18:25:41 +08:00
suyx ff115f70ca fix(POSTV1-TEMPLATE-FIDELITY-17): 修复模板图案还原与预览状态
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 17:28:45 +08:00
suyx a70b9fc241 feat(POSTV1-ASSET-ALL-16): 完整还原归档文字模板素材
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 16:20:24 +08:00
suyx ef6950c5df test(POSTV1-ASSET-ALL-16): 完成全量素材集成回归
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 12:19:21 +08:00
suyx 2cd85cd7cd feat(POSTV1-ASSET-ALL-16): 在编辑器接入全部复杂素材
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 11:55:06 +08:00
suyx 9de0d2a63c feat(POSTV1-ASSET-ALL-16): 生成并部署全量复杂素材
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 11:39:46 +08:00
suyx fd4cd277cc feat(POSTV1-ASSET-ALL-16): 发布完整素材注册清单
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 11:30:09 +08:00
suyx 910e32917f test(POSTV1-integration): 增加界面版本回退门禁
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 11:04:03 +08:00
suyx 83fe57f319 fix(POSTV1-editor): 修复多选拖动与底图调整
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 10:53:38 +08:00
suyx 51d613f459 fix(POSTV1-browser): 同时支持 Chrome 150 与 151
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 10:52:31 +08:00
suyx 3779cfbadc fix(POSTV1-runtime): 改善后台启动与状态窗口可见性
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 10:51:08 +08:00
suyx e8ce1d9031 fix(POSTV1-11): 修复生成提交的会话令牌轮换
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-05 22:22:49 +08:00
suyx edbefcc738 feat(POSTV1-10): 增加统一交互反馈
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 19:19:33 +08:00
suyx 1713607572 fix(POSTV1-09): 展示项目生成图片
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 19:00:07 +08:00
suyx e4aec01ea6 fix(POSTV1-08): 修复文字拖动闪烁与自动保存
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 18:47:11 +08:00
suyx 468bb5579d fix(POSTV1-07): 自动关闭编辑器操作提示
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 18:01:03 +08:00
suyx 9d2aa879e9 fix(POSTV1-07): 固定画布布局与文字贴纸选择
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 17:59:53 +08:00
suyx d9e39702e0 fix(POSTV1-06): 恢复贴纸与字体运行时资源链路
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 16:58:45 +08:00
suyx 7de633d4c9 fix(POSTV1-05): 修复生成请求与 Worker 重入
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 16:04:11 +08:00
suyx b995662388 fix(POSTV1-04): 修复生成服务运行时装配
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 15:36:47 +08:00
suyx 92434aec17 feat(POSTV1-03): 增加本机测试直达入口
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 14:53:20 +08:00
suyx f7cac5dabf fix(POSTV1-02): 修复便携网页静态资源类型
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 14:07:42 +08:00
suyx a7f62adad4 fix(POSTV1-02): 确保AI探测输出后退出
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 13:26:44 +08:00
suyx b1f143c238 feat(POSTV1-02): 增加AI真实生成探测
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 13:14:40 +08:00
suyx 99fd3b1802 fix(POSTV1-02): 修复便携版网页入口门禁
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 12:58:28 +08:00
suyx fd0003a804 fix(POSTV1-02): 补齐便携包Worker运行依赖
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 12:46:59 +08:00
suyx bfdfe44f87 test(POSTV1-02): 修正便携包AI适配器检查
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 12:34:46 +08:00
suyx cbb7f658a3 fix(POSTV1-02): 接通真实AI生成链路
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 12:29:14 +08:00
suyx 43d946bb5c fix(POSTV1-02): 修复便携包首次启动链路
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 12:00:23 +08:00
suyx 79b01ebc81 test(POSTV1-01): 增加最终便携包启动链路验收
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 11:38:32 +08:00
suyx 90f812fae5 fix(POSTV1-01): 让Worker使用便携包SQLite原生绑定
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 11:25:02 +08:00
suyx 1155a81c3b fix(POSTV1-01): 接入Worker生成任务消费链路
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 11:24:14 +08:00
suyx 3dca4ad77c fix(POSTV1-01): 在最终包中提供产品网页与同源API
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 11:22:58 +08:00
suyx 99fe07a761 fix(POSTV1-01): 允许非关键外部服务降级启动 2026-08-05 11:22:42 +08:00
tuyixuan 443e8b94f0 Merge pull request 'feat(P0-A): 整合第一版并冻结最终发布' (#1) from codex/wp7-07 into codex/wp0-09
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
Reviewed-on: #1
2026-08-05 10:29:22 +08:00
suyx 693fa117b7 chore(WP7-07): 冻结第一版发布记录
Dada P0-A isolated Windows CI / validate-and-package (push) Successful in 17m46s
Dada P0-A isolated Windows CI / validate-and-package (pull_request) Successful in 17m49s
2026-08-04 23:32:51 +08:00
suyx 08f3cccae4 fix(WP7-07): 排除发布扫描器路径误报
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-04 23:20:40 +08:00
suyx a22b1f19e9 fix(WP7-07): 修正最终包浏览器门禁探测
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-04 23:15:36 +08:00
suyx 194b59d4a5 fix(WP7-07): 统一构建全部工作区依赖
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-04 23:07:01 +08:00
suyx 0f03b12f64 fix(WP7-07): 补齐便携包前置构建顺序
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-04 23:05:38 +08:00
suyx ad86b4ddcc feat(WP7-07): 增加最终发布冻结与泄漏扫描
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-04 23:03:11 +08:00
suyx 08e9c39e49 feat(WP7-06): 增加最终AC预冻结门禁
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-04 22:43:35 +08:00
suyx ffd1643848 chore(WP7-06): 合并候选环境验收基线
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
# Conflicts:
#	package.json
2026-08-04 22:39:26 +08:00
suyx 95ab0cb93b chore(WP7-06): 合并高德发布门禁基线
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
# Conflicts:
#	package.json
#	supervisor/Dada.Supervisor/OfflineCommandRouter.cs
2026-08-04 22:38:29 +08:00
suyx b2793a2392 chore(WP7-06): 合并Resend发布门禁基线
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
# Conflicts:
#	package.json
2026-08-04 22:37:15 +08:00
suyx a7140e99e1 chore(WP7-06): 合并WP4视觉验收与资源迁移修复
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-04 22:36:08 +08:00
suyx 76b4f93709 chore(WP7-06): 合并WP6整合验收基线
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-04 22:35:43 +08:00
suyx 5631ef80f9 fix(wp7-02): route stable Gemini through secure broker
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-04 18:55:32 +08:00
suyx 6bd5364d95 test(wp7-02): require stable model secure routing
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-04 18:53:59 +08:00
suyx 604c524298 fix(wp7-02): review replacement model evidence
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-04 18:48:28 +08:00
suyx 898679dd59 test(wp7-02): require two-model evidence review
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-04 18:47:49 +08:00
suyx 79ef17b7f0 fix(wp7-02): replace preview Gemini routes
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-04 18:46:01 +08:00
suyx 0328aa8ef5 test(wp7-02): require stable Gemini replacement set
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-04 18:45:04 +08:00
suyx a54efb146a feat(wp7-02): support Gemini chat image relay
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-04 18:38:33 +08:00
suyx 6e5313e283 test(wp7-02): require Gemini chat image relay
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-04 18:37:37 +08:00
suyx 03500bf47e feat(wp7-02): map provider image model ids
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-04 18:30:31 +08:00
suyx e3c21b63a5 test(wp7-02): require provider image model mapping
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-04 18:29:57 +08:00
suyx f4fabb66e5 fix(WP4-07): 兼容迁移后的字体资源路径
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-04 18:29:17 +08:00
suyx 4aaba9f2bb feat(wp7-02): support Gemini image interactions
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-04 18:28:44 +08:00
suyx d7a24a5ecb test(wp7-02): require Gemini interactions image contract
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-04 18:27:55 +08:00
suyx 4a0fb1bfae fix(WP4-07): 支持新的贴纸资源根目录
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-04 18:22:28 +08:00
suyx f931c04853 fix(wp7-02): classify Gemini response text safely
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 1m33s
2026-08-04 18:04:40 +08:00
suyx 3093c4470a test(wp7-02): require safe Gemini text classification
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 5m50s
2026-08-04 18:03:52 +08:00
suyx 75a589dad8 fix(wp7-02): restore native Gemini image routing
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 1m45s
2026-08-04 17:58:33 +08:00
suyx 534c82678a test(wp7-02): require native Gemini gateway contract
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 1m48s
2026-08-04 17:57:52 +08:00
suyx 05949230ca fix(wp7-02): preserve independent mixed model evidence
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 2m11s
2026-08-04 17:35:38 +08:00
suyx 382d50058c test(wp7-02): require independent mixed evidence review
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 2m8s
2026-08-04 17:33:51 +08:00
suyx f7bed92e61 test: cover Amap production security gates
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 2m43s
2026-08-04 17:31:25 +08:00
suyx d03b491d2f fix: harden controlled Amap probe
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 1m46s
2026-08-04 17:28:24 +08:00
suyx 63de0917a0 fix(wp7-02): use GPT image edit route for references
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 1m53s
2026-08-04 17:24:22 +08:00
suyx fa925bfe12 test(wp7-02): require GPT reference edit route
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 1m55s
2026-08-04 17:23:34 +08:00
suyx 0201c3e896 fix(wp7-02): retry one transient image timeout
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 1m38s
2026-08-04 17:22:26 +08:00
suyx 8337906dd2 test(wp7-02): require bounded timeout retry evidence
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 1m25s
2026-08-04 17:21:10 +08:00
suyx ae725a2d01 fix(wp7-02): normalize generated image dimensions
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 2m17s
2026-08-04 17:19:41 +08:00
suyx 9d6f4ac24b test(wp7-02): require exact image normalization
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 2m11s
2026-08-04 17:15:25 +08:00
suyx 0938997327 fix(wp7-02): update Gemini routes to v1
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 2m12s
2026-08-04 17:09:58 +08:00
suyx 8c349fb56c fix: wire production Amap adapter
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 1m47s
2026-08-04 17:09:04 +08:00
suyx 55646ba1b4 feat: build sanitized WP7-05 coverage evidence
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 1m49s
2026-08-04 17:08:44 +08:00
suyx b8e30ab0b2 test(wp7-02): require current Gemini v1 routes
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 1m51s
2026-08-04 17:08:25 +08:00
suyx 66295287ce fix(wp7-02): use current Gemini image response contract
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 1m53s
2026-08-04 17:05:59 +08:00
suyx 0ed7b3f0ce test(wp7-02): require current Gemini image contract
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 11m33s
2026-08-04 17:05:23 +08:00
suyx 8abf1397a6 test: require WP7-05 state evidence
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 3m17s
2026-08-04 17:02:52 +08:00
suyx e0e101ef28 fix: align WP7-05 candidate record schema
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 2m30s
2026-08-04 16:59:31 +08:00
suyx 33f87f8db3 test: scaffold WP7-05 candidate UI gate
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 2m18s
2026-08-04 16:58:15 +08:00
suyx 84a845136e test(wp7-02): enforce exact image dimensions
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 51s
2026-08-04 16:28:31 +08:00
suyx 470d243b5b feat: execute WP7-02 independent model matrices
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 54s
2026-08-04 16:01:31 +08:00
suyx a7772a7e92 test: harden WP7-04 release gate evidence
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 3m1s
2026-08-04 15:49:25 +08:00
suyx c2f89453a2 feat: add secure WP7-02 external executor
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 48s
2026-08-04 15:46:38 +08:00
suyx 2bfb5f2953 test: prove Amap monthly egress hard stop
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 50s
2026-08-04 15:39:46 +08:00
suyx 597f4647ef test: define WP7-02 controlled executor contract
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 2m51s
2026-08-04 15:38:29 +08:00
suyx 68a1991255 test: configure WP7-02 OneAPI model routes
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 3m5s
2026-08-04 15:29:26 +08:00
suyx 4ec8327f5e feat: add controlled Amap credential probe
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 1m0s
2026-08-04 14:52:23 +08:00
suyx 28e6f66a1d test: add WP7-02 controlled real contract gate
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 1m36s
2026-08-04 14:06:52 +08:00
suyx cca0a38ada feat: add Resend release gate evidence validation (TASK-WP7-03)
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 59s
2026-08-04 13:51:02 +08:00
suyx d474768a2b test: record WP7-04 Amap external blocker
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 57s
2026-08-04 13:35:10 +08:00
suyx 623cad25b2 feat: record WP7-01 release candidate environment
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 1m5s
2026-08-04 13:24:35 +08:00
suyx c601c848f5 merge: integrate WP4-07 Green baseline for TASK-WP7-01
# Conflicts:
#	package.json
2026-08-04 13:13:49 +08:00
suyx c0a8bd6d43 test: complete WP4-07 visual and performance budgets
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 2m34s
2026-08-04 12:55:25 +08:00
suyx 15f7afbe4d merge: integrate WP5-07 preview lifecycle baseline 2026-08-04 10:56:01 +08:00
suyx e054cda94f merge: integrate WP5-03 validation fixes 2026-08-04 10:55:51 +08:00
suyx 8c0adde77c merge: integrate WP5-05 and WP5-06 baseline
# Conflicts:
#	package.json
2026-08-04 10:54:59 +08:00
suyx 5e2d4e7aaf fix: stabilize TASK-WP5-03 CI toolchain timeout
Dada P0-A isolated Windows CI / validate-and-package (push) Successful in 9m56s
2026-08-04 00:29:02 +08:00
suyx dc83408ec2 test: validate WP5 task lineage for WP4-07 gate
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 3m23s
2026-08-03 19:26:39 +08:00
suyx 40ecf0414a test: establish WP4-07 visual performance red harness
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 4m19s
2026-08-03 19:15:58 +08:00
157 changed files with 61760 additions and 566 deletions
+30
View File
@@ -0,0 +1,30 @@
{
"appVersion": "0.0.0",
"browsers": [
{
"brand": "Google Chrome",
"fullVersion": "150.0.7871.187",
"supportedMajorVersions": [150, 151]
},
{
"brand": "Microsoft Edge",
"fullVersion": "151.0.4129.59"
}
],
"buildCommit": "08f3cccae4a1e75e2f2292eef14611313523916d",
"deferredExternalTasks": [
"TASK-WP7-03",
"TASK-WP7-04"
],
"finalRelease": true,
"fixedPort": 43121,
"frozenFromCommit": "08e9c39e49d68f8642d5acfe22b0fdb40a3a08fa",
"recordedAt": "2026-08-04T15:20:54.271Z",
"releaseStatus": "first_version_internal",
"schemaVersion": "1.0",
"windows": {
"arch": "x64",
"build": "26200.8875",
"displayVersion": "25H2"
}
}
+8 -5
View File
@@ -16,7 +16,7 @@ const forbiddenDiagnosticPatterns = [
/https?:\/\//i,
];
const safePauseReasons = new Set([
"asset_root_state_missing", "balance_insufficient", "configured_disabled", "contract_blocked",
"asset_manifest_invalid", "asset_root_missing", "asset_root_state_missing", "balance_insufficient", "configured_disabled", "contract_blocked",
"contract_unverified", "gateway_balance_insufficient", "gateway_paused", "health_check_failed",
"model_disabled", "provider_unavailable", "quota_exhausted", "service_state_missing", "unknown",
"worker_degraded", "worker_state_missing", "worker_stopped",
@@ -151,10 +151,13 @@ export function createAdminDiagnosticsProvider(input: {
const system: AdminDiagnosticsResponse["system"] = {
api_status: "ready",
app_version: input.appVersion ?? input.browserSupportRelease?.appVersion ?? "0.0.0",
browser_support: (input.browserSupportRelease?.browsers ?? []).map((browser) => ({
brand: browser.brand,
major: Number.parseInt(browser.fullVersion.split(".")[0] ?? "0", 10),
})).filter((browser) => Number.isSafeInteger(browser.major) && browser.major > 0),
browser_support: (input.browserSupportRelease?.browsers ?? []).flatMap((browser) => {
const majors = browser.supportedMajorVersions
?? [Number.parseInt(browser.fullVersion.split(".")[0] ?? "0", 10)];
return majors
.map((major) => ({ brand: browser.brand, major }))
.filter((entry) => Number.isSafeInteger(entry.major) && entry.major > 0);
}),
worker_status: services.services.find((service) => service.service_id === "worker")?.status === "active"
? "ready"
: services.services.find((service) => service.service_id === "worker")?.status === "unavailable"
+150 -1
View File
@@ -1,5 +1,31 @@
import { request as httpsRequest } from "node:https";
const amapHostname = "restapi.amap.com" as const;
const amapMaxResponseBytes = 65_536;
const amapTimeoutMs = 15_000;
export interface AmapHttpRequest {
allowRedirects: false;
hostname: typeof amapHostname;
maxResponseBytes: number;
method: "GET";
path: string;
protocol: "https:";
rejectUnauthorized: true;
timeoutMs: number;
}
type AmapRequester = (request: AmapHttpRequest) => Promise<unknown>;
export class AmapAdapterError extends Error {
constructor(readonly code: "amap_adapter_disposed" | "amap_invalid_request" | "amap_invalid_response" | "amap_provider_rejected" | "amap_provider_unavailable" | "amap_redirect_rejected" | "amap_request_timeout" | "amap_response_too_large") {
super(code);
}
}
export interface AmapAdapter {
reverseGeocode(coordinates: { latitude: number; longitude: number }): Promise<{ formattedValue: string; serviceMode: "mock" }>;
dispose?(): void;
reverseGeocode(coordinates: { latitude: number; longitude: number }): Promise<{ formattedValue: string; serviceMode: "mock" | "real" }>;
}
export class MockAmapAdapter implements AmapAdapter {
@@ -13,3 +39,126 @@ export class MockAmapAdapter implements AmapAdapter {
};
}
}
function requestAmapJson(input: AmapHttpRequest) {
return new Promise<unknown>((resolve, reject) => {
if (input.protocol !== "https:" || input.hostname !== amapHostname || input.allowRedirects || !input.rejectUnauthorized) {
reject(new AmapAdapterError("amap_invalid_request"));
return;
}
let settled = false;
const finish = (callback: () => void) => {
if (settled) return;
settled = true;
callback();
};
const request = httpsRequest({
headers: { Accept: "application/json" },
hostname: input.hostname,
method: input.method,
path: input.path,
port: 443,
protocol: input.protocol,
rejectUnauthorized: input.rejectUnauthorized,
servername: input.hostname,
}, (response) => {
const statusCode = response.statusCode ?? 0;
if (statusCode >= 300 && statusCode < 400) {
response.resume();
finish(() => reject(new AmapAdapterError("amap_redirect_rejected")));
return;
}
if (statusCode !== 200) {
response.resume();
finish(() => reject(new AmapAdapterError("amap_provider_unavailable")));
return;
}
const declaredLength = Number(response.headers["content-length"] ?? 0);
if (Number.isFinite(declaredLength) && declaredLength > input.maxResponseBytes) {
response.destroy();
finish(() => reject(new AmapAdapterError("amap_response_too_large")));
return;
}
const chunks: Buffer[] = [];
let receivedBytes = 0;
response.on("data", (chunk: Buffer | string) => {
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
receivedBytes += bytes.length;
if (receivedBytes > input.maxResponseBytes) {
response.destroy();
finish(() => reject(new AmapAdapterError("amap_response_too_large")));
return;
}
chunks.push(bytes);
});
response.on("end", () => {
finish(() => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
} catch {
reject(new AmapAdapterError("amap_invalid_response"));
} finally {
for (const chunk of chunks) chunk.fill(0);
chunks.length = 0;
}
});
});
});
request.setTimeout(input.timeoutMs, () => request.destroy(new AmapAdapterError("amap_request_timeout")));
request.on("error", (error) => finish(() => reject(error instanceof AmapAdapterError ? error : new AmapAdapterError("amap_provider_unavailable"))));
request.end();
});
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
export class RealAmapAdapter implements AmapAdapter {
private readonly credential: Buffer;
private readonly requester: AmapRequester;
private disposed = false;
constructor(value: string, options: { request?: AmapRequester } = {}) {
if (!value.trim()) throw new AmapAdapterError("amap_invalid_request");
this.credential = Buffer.from(value, "utf8");
this.requester = options.request ?? requestAmapJson;
}
async reverseGeocode(coordinates: { latitude: number; longitude: number }) {
if (this.disposed) throw new AmapAdapterError("amap_adapter_disposed");
if (!Number.isFinite(coordinates.latitude) || coordinates.latitude < -90 || coordinates.latitude > 90
|| !Number.isFinite(coordinates.longitude) || coordinates.longitude < -180 || coordinates.longitude > 180) {
throw new AmapAdapterError("amap_invalid_request");
}
const query = new URLSearchParams({
extensions: "base",
key: this.credential.toString("utf8"),
location: `${coordinates.longitude},${coordinates.latitude}`,
});
const response = await this.requester({
allowRedirects: false,
hostname: amapHostname,
maxResponseBytes: amapMaxResponseBytes,
method: "GET",
path: `/v3/geocode/regeo?${query.toString()}`,
protocol: "https:",
rejectUnauthorized: true,
timeoutMs: amapTimeoutMs,
});
if (!isRecord(response) || response.status !== "1" || !isRecord(response.regeocode)) {
throw new AmapAdapterError("amap_provider_rejected");
}
const formattedValue = typeof response.regeocode.formatted_address === "string"
? response.regeocode.formatted_address.trim()
: "";
if (!formattedValue || formattedValue.length > 200) throw new AmapAdapterError("amap_invalid_response");
return { formattedValue, serviceMode: "real" as const };
}
dispose() {
if (this.disposed) return;
this.disposed = true;
this.credential.fill(0);
}
}
+82 -4
View File
@@ -1,6 +1,6 @@
import { randomBytes, randomUUID } from "node:crypto";
import { createReadStream, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { createReadStream, existsSync, readFileSync } from "node:fs";
import { extname, resolve } from "node:path";
import {
AccountDeletionCompleteRequestSchema,
@@ -218,6 +218,24 @@ import type { ManagedStorage } from "./managed-storage.js";
import { PrivateContentError, PrivateContentService } from "./private-content.js";
import { assertSafeAdminDiagnostics, assertSafeAdminServicesStorage } from "./admin-state.js";
const productAssetContentTypes: Readonly<Record<string, string>> = {
".css": "text/css; charset=utf-8",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2",
};
function productAssetContentType(path: string) {
return productAssetContentTypes[extname(path).toLowerCase()] ?? "application/octet-stream";
}
const defaultBootstrap: BootstrapResponse = {
app_version: "0.0.0",
dependencies: [],
@@ -238,12 +256,14 @@ export interface CreateAppOptions {
assetReleases?: AssetReleaseReader;
bootstrap?: () => BootstrapResponse | Promise<BootstrapResponse>;
browserGate?: boolean;
productIndexHtml?: string;
browserSupportRelease?: BrowserSupportRelease;
browserSupportSecret?: Buffer;
credits?: CreditService;
eventHub?: EventHub;
generations?: GenerationSubmissionService;
latestExports?: LatestExportService;
localTestAuth?: boolean;
models?: ModelConfigurationService;
networkBoundary?: NetworkBoundaryOptions;
publicAssets?: PublicAssetResolver;
@@ -272,6 +292,10 @@ const supportGateDirectory = resolve(process.env.DADA_SUPPORT_GATE_ROOT ?? "apps
const supportGateHtml = readFileSync(resolve(supportGateDirectory, "index.html"), "utf8");
const supportGateCss = readFileSync(resolve(supportGateDirectory, "support-gate.css"), "utf8");
const supportGateJavaScript = readFileSync(resolve(supportGateDirectory, "support-gate.js"), "utf8");
const productWebRoot = resolve(process.env.DADA_WEB_ROOT ?? "apps/web/dist");
const packagedProductIndexHtml = existsSync(resolve(productWebRoot, "index.html"))
? readFileSync(resolve(productWebRoot, "index.html"), "utf8")
: undefined;
const clientHints = "Sec-CH-UA, Sec-CH-UA-Full-Version-List, Sec-CH-UA-Platform";
const contentSecurityPolicy = [
"default-src 'self'",
@@ -710,6 +734,7 @@ export async function createApp(options: CreateAppOptions = {}) {
)
: undefined);
const browserGate = options.browserGate ?? true;
const productIndexHtml = options.productIndexHtml ?? packagedProductIndexHtml;
const browserSupportSecret = options.browserSupportSecret ?? randomBytes(32);
const browserSupportRelease = options.browserSupportRelease;
const app = Fastify({
@@ -901,11 +926,25 @@ export async function createApp(options: CreateAppOptions = {}) {
});
for (const route of ["/", "/app", "/app/*", "/admin", "/admin/*"]) {
app.get(route, { schema: { hide: true } }, async (_request, reply) => {
app.get(route, { schema: { hide: true } }, async (request, reply) => {
reply.type("text/html; charset=utf-8");
return supportGateHtml;
if (!browserGate) return productIndexHtml ?? supportGateHtml;
const verified = verifyBrowserSupportCookie({
cookieHeader: headerValue(request.headers.cookie),
release: browserSupportRelease,
secChUa: headerValue(request.headers["sec-ch-ua"]),
secret: browserSupportSecret,
});
return verified.supported && productIndexHtml ? productIndexHtml : supportGateHtml;
});
}
app.get("/assets/*", { schema: { hide: true } }, async (request, reply) => {
const relativePath = decodeURIComponent(request.url.split("?", 1)[0]!.slice("/assets/".length));
const assetPath = resolve(productWebRoot, "assets", relativePath);
if (!assetPath.startsWith(resolve(productWebRoot, "assets")) || !existsSync(assetPath)) return reply.code(404).send();
reply.type(productAssetContentType(assetPath));
return reply.send(readFileSync(assetPath));
});
app.get("/support-gate.css", { schema: { hide: true } }, async (_request, reply) => {
reply.type("text/css; charset=utf-8");
return supportGateCss;
@@ -2037,6 +2076,45 @@ export async function createApp(options: CreateAppOptions = {}) {
},
);
if (options.localTestAuth && options.registration) {
app.get(
"/api/v1/auth/local-test",
{ schema: { hide: true } },
async () => ({ available: true }),
);
app.post(
"/api/v1/auth/local-test",
{ schema: { hide: true } },
async (request, reply) => {
try {
const result = options.registration!.createLocalTestSession();
reply.header(
"Set-Cookie",
`${userSessionCookieName}=${result.sessionToken}; Max-Age=${30 * 24 * 60 * 60}; Path=/; HttpOnly; SameSite=Strict`,
);
return {
audience: result.audience,
credits: {
available_balance: result.credits.availableBalance,
reserved_balance: result.credits.reservedBalance,
},
session_expires_at: new Date(result.sessionExpiresAt).toISOString(),
status: result.status,
user: {
creator_name: result.user.creatorName,
role: result.user.role,
social_id: result.user.socialId,
status: result.user.status,
user_id: result.user.userId,
},
};
} catch (error) {
return registrationFailure(reply, request.id, error);
}
},
);
}
app.post(
"/api/v1/auth/login/complete",
{
+28 -7
View File
@@ -43,7 +43,7 @@ export const BrowserSupportSuccessSchema = Type.Object(
app_version: Type.String({ maxLength: 80 }),
browser: SupportedBrowserSummarySchema,
status: Type.Literal("supported"),
supported_browsers: Type.Array(SupportedBrowserSummarySchema, { maxItems: 2 }),
supported_browsers: Type.Array(SupportedBrowserSummarySchema, { maxItems: 8 }),
},
{ additionalProperties: false, $id: "BrowserSupportSuccess" },
);
@@ -57,6 +57,7 @@ export interface BrowserSupportRelease {
browsers: ReadonlyArray<{
brand: SupportedBrand;
fullVersion: string;
supportedMajorVersions?: ReadonlyArray<number>;
}>;
}
@@ -115,7 +116,14 @@ function supportedIdentity(entries: Array<{ brand: string; version: string }>) {
export function supportedBrowserSummary(release: BrowserSupportRelease | undefined) {
if (!release) return [];
return release.browsers.map(({ brand, fullVersion }) => ({ brand, major: major(fullVersion)! }));
return release.browsers.flatMap(({ brand, fullVersion, supportedMajorVersions }) => {
const majors = supportedMajorVersions ?? [major(fullVersion)!];
return majors.map((supportedMajor) => ({ brand, major: supportedMajor }));
});
}
function acceptedMajorVersions(browser: BrowserSupportRelease["browsers"][number]) {
return browser.supportedMajorVersions ?? [major(browser.fullVersion)!];
}
export function validateBrowserSupportRelease(value: unknown): value is BrowserSupportRelease {
@@ -126,13 +134,26 @@ export function validateBrowserSupportRelease(value: unknown): value is BrowserS
}
if (!Array.isArray(release.browsers) || release.browsers.length !== 2) return false;
const brands = new Set(release.browsers.map(({ brand }) => brand));
const supportedMajorCount = release.browsers.reduce(
(count, browser) => count + (browser.supportedMajorVersions?.length ?? 1),
0,
);
return (
brands.size === 2 &&
brands.has("Google Chrome") &&
brands.has("Microsoft Edge") &&
release.browsers.every(
({ brand, fullVersion }) => supportedBrands.has(brand) && fullVersionPattern.test(fullVersion),
)
supportedMajorCount <= 8 &&
release.browsers.every(({ brand, fullVersion, supportedMajorVersions }) => {
if (!supportedBrands.has(brand) || !fullVersionPattern.test(fullVersion)) return false;
const baselineMajor = major(fullVersion);
if (!baselineMajor) return false;
if (supportedMajorVersions === undefined) return true;
return supportedMajorVersions.length > 0
&& supportedMajorVersions.length <= 8
&& supportedMajorVersions.every((value: number) => Number.isSafeInteger(value) && value >= 1)
&& new Set(supportedMajorVersions).size === supportedMajorVersions.length
&& supportedMajorVersions.includes(baselineMajor);
})
);
}
@@ -188,7 +209,7 @@ export function checkBrowserSupport(
}
const supported = release?.browsers.find(({ brand }) => brand === fullIdentity.brand);
if (!supported || major(supported.fullVersion) !== fullIdentity.major) {
if (!supported || !acceptedMajorVersions(supported).includes(fullIdentity.major)) {
return { reason: "version_unsupported", supported: false };
}
return { identity: fullIdentity, supported: true };
@@ -268,7 +289,7 @@ export function verifyBrowserSupportCookie(input: {
return { reason: "identity_unavailable" as const, supported: false as const };
}
const supported = input.release.browsers.find(({ brand }) => brand === currentIdentity.brand);
if (currentIdentity.major !== payload.major || major(supported?.fullVersion ?? "") !== currentIdentity.major) {
if (!supported || currentIdentity.major !== payload.major || !acceptedMajorVersions(supported).includes(currentIdentity.major)) {
return { reason: "version_unsupported" as const, supported: false as const };
}
return { identity: currentIdentity, supported: true as const };
+22 -9
View File
@@ -19,7 +19,7 @@ import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:p
const require = createRequire(import.meta.url);
const Database = require("better-sqlite3") as typeof import("better-sqlite3");
const assetIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const assetIdPattern = /^[a-z0-9][a-z0-9_-]{2,119}$/i;
const fixedDirectories = [
"db",
"content/references",
@@ -33,6 +33,12 @@ const fixedDirectories = [
"logs/supervisor",
] as const;
export function ensureLocalDataRuntimeDirectories(dataRoot: string) {
for (const directory of fixedDirectories) {
mkdirSync(join(resolve(dataRoot), directory), { recursive: true });
}
}
export const DATA_TRANSFER_POLICY = {
allowed_downloads: ["original_generation", "jpg", "png"],
application_backup: false,
@@ -76,13 +82,21 @@ export function readConfiguredLocalDataRoot(configFile = defaultInstanceConfigPa
return resolve(candidate);
}
export function readConfiguredAssetRoot(configFile = defaultInstanceConfigPath()) {
const configuration = JSON.parse(readFileSync(configFile, "utf8")) as Record<string, unknown>;
if (typeof configuration.asset_root !== "string" || !isAbsolute(configuration.asset_root)) {
throw new Error("asset_root_configuration_invalid");
}
return resolve(configuration.asset_root);
}
export interface ValidatedReadOnlyAssetRoot {
absolute_root: string;
ok: true;
root_ref: string;
}
interface PublicAssetEntry {
export interface PublicAssetEntry {
assetId: string;
mimeType: string;
relativePath: string;
@@ -219,9 +233,7 @@ export function initializeLocalDataRoot(input: {
const createdRoot = !existsSync(validation.normalized_path);
try {
for (const directory of fixedDirectories) {
mkdirSync(join(validation.normalized_path, directory), { recursive: true });
}
ensureLocalDataRuntimeDirectories(validation.normalized_path);
openInstanceDatabase(join(validation.normalized_path, "db", "dada.sqlite3"));
const configuration: InstanceConfiguration = {
data_root: validation.normalized_path,
@@ -313,18 +325,19 @@ export function createPublicAssetResolver(input: {
const roots = new Map(input.roots.map((root) => [root.root_ref, root.absolute_root]));
const entries = new Map<string, PublicAssetEntry>();
for (const entry of input.entries) {
if (!assetIdPattern.test(entry.assetId) || entries.has(entry.assetId)) throw new Error("asset_id_invalid");
const key = `${entry.resourceVersion}\u0000${entry.assetId}`;
if (!assetIdPattern.test(entry.assetId) || entries.has(key)) throw new Error("asset_id_invalid");
if (!roots.has(entry.rootRef)) throw new Error("asset_root_unvalidated");
if (!/^[a-z0-9][a-z0-9._-]{0,79}$/i.test(entry.resourceVersion)) throw new Error("resource_version_invalid");
if (!/^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i.test(entry.mimeType)) throw new Error("mime_type_invalid");
entries.set(entry.assetId, { ...entry });
entries.set(key, { ...entry });
}
return {
read(resourceVersion, assetId) {
if (!assetIdPattern.test(assetId)) return undefined;
const entry = entries.get(assetId);
if (!entry || entry.resourceVersion !== resourceVersion) return undefined;
const entry = entries.get(`${resourceVersion}\u0000${assetId}`);
if (!entry) return undefined;
const root = roots.get(entry.rootRef);
if (!root) return undefined;
let path: string;
+41 -5
View File
@@ -5,7 +5,7 @@ import { registrationNotice } from "@dada/shared-contracts";
import { createApp } from "./app.js";
import { readBrowserSupportRelease } from "./browser-support.js";
import { defaultInstanceConfigPath, readConfiguredLocalDataRoot } from "./local-data-root.js";
import { defaultInstanceConfigPath, ensureLocalDataRuntimeDirectories, readConfiguredLocalDataRoot, type PublicAssetResolver } from "./local-data-root.js";
import { ManagedStorage } from "./managed-storage.js";
import { LatestExportService } from "./latest-exports.js";
import { CreditService } from "./credits.js";
@@ -16,10 +16,16 @@ import { MockResendAdapter } from "./resend-adapter.js";
import { readSecureConfigCandidate } from "./secure-config.js";
import { StructuredJsonlLogger } from "./structured-log.js";
import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js";
import { ModelConfigurationService } from "./model-configuration.js";
import { MockAmapAdapter } from "./amap-adapter.js";
import { GenerationSubmissionService } from "./generation-submission.js";
import {
GenerationModelConfigurationCatalog,
ModelConfigurationService,
portableRuntimeModelCandidates,
} from "./model-configuration.js";
import { MockAmapAdapter, type AmapAdapter } from "./amap-adapter.js";
import { StickerReleaseService } from "./sticker-releases.js";
import { createAdminDiagnosticsProvider, createAdminServicesStorageProvider } from "./admin-state.js";
import { loadConfiguredRuntimeAssets, type RuntimeAssetState } from "./runtime-assets.js";
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
let registration: RegistrationService | undefined;
@@ -28,16 +34,31 @@ let credits: CreditService | undefined;
let storage: ManagedStorage | undefined;
let latestExports: LatestExportService | undefined;
let models: ModelConfigurationService | undefined;
let generations: GenerationSubmissionService | undefined;
let recentAssets: RecentAssetService | undefined;
let stickers: StickerReleaseService | undefined;
let publicAssets: PublicAssetResolver | undefined;
let assetRootState: RuntimeAssetState | undefined;
let amap: AmapAdapter = new MockAmapAdapter();
let localTestAuth = false;
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
if (credentialChannelEnabled) {
const clients = initializeApiCredentialClients(await receiveApiCredentials());
try {
amap = clients.amap;
localTestAuth = !clients.resendConfigured;
const derivePepper = (purpose: string) => createHmac("sha256", clients.adminAllowlistPepper)
.update(`Dada/P0A/${purpose}/v1`, "utf8")
.digest();
const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath);
ensureLocalDataRuntimeDirectories(dataRoot);
const runtimeAssets = loadConfiguredRuntimeAssets({
configFile: instanceConfigPath,
dataRoot,
trustedManifestPath: resolve("asset-metadata", "manifest.json"),
});
publicAssets = runtimeAssets.publicAssets;
assetRootState = runtimeAssets.state;
const databasePath = join(dataRoot, "db", "dada.sqlite3");
registration = new RegistrationService({
adminAllowlistPepper: Buffer.from(clients.adminAllowlistPepper),
@@ -53,14 +74,23 @@ if (credentialChannelEnabled) {
storage = new ManagedStorage({ dataRoot, databasePath });
stickers = new StickerReleaseService({ databasePath, storage });
latestExports = new LatestExportService({ databasePath, storage });
models = new ModelConfigurationService({ database: registration.database });
models = new ModelConfigurationService({ database: registration.database, seedCandidates: portableRuntimeModelCandidates });
generations = new GenerationSubmissionService({
credits,
models: new GenerationModelConfigurationCatalog(models),
storage,
});
recentAssets = new RecentAssetService({ database: registration.database });
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
} catch (error) {
amap.dispose?.();
amap = new MockAmapAdapter();
stickers?.close();
stickers = undefined;
latestExports?.close();
latestExports = undefined;
generations?.close();
generations = undefined;
storage?.close();
storage = undefined;
credits?.close();
@@ -81,6 +111,7 @@ const adminServicesStorage = registration
database: registration.database,
...(models ? { models } : {}),
...(storage ? { storage } : {}),
...(assetRootState ? { assetRoot: assetRootState } : {}),
})
: undefined;
const adminDiagnostics = adminServicesStorage
@@ -92,12 +123,15 @@ const adminDiagnostics = adminServicesStorage
const app = await createApp({
...(adminServicesStorage ? { adminServicesStorage } : {}),
...(adminDiagnostics ? { adminDiagnostics } : {}),
amap: new MockAmapAdapter(),
amap,
...(browserSupportRelease ? { browserSupportRelease } : {}),
...(credits ? { credits } : {}),
...(generations ? { generations } : {}),
...(latestExports ? { latestExports } : {}),
...(registration && localTestAuth ? { localTestAuth: true } : {}),
...(models ? { models } : {}),
...(projects ? { projects } : {}),
...(publicAssets ? { publicAssets } : {}),
...(registration ? { registration } : {}),
...(recentAssets ? { recentAssets } : {}),
...(stickers ? { stickers } : {}),
@@ -115,7 +149,9 @@ if (controlPipeIndex >= 0) {
if (!controlPipe) throw new Error("Supervisor control pipe name is required.");
const control = attachApiSupervisorControl(controlPipe, async () => {
await app.close();
amap.dispose?.();
latestExports?.close();
generations?.close();
credits?.close();
projects?.close();
registration?.close();
+90 -6
View File
@@ -2,6 +2,8 @@ import { randomUUID, createHash } from "node:crypto";
import type BetterSqlite3 from "better-sqlite3";
import { serializeAuditSummary, auditRetentionMilliseconds } from "./audit-policy.js";
import type { GenerationModelCatalog, GenerationModelSnapshot } from "./generation-submission.js";
import { projectRatios } from "./projects.js";
export const modelIds = [
"gemini-3.1-flash-image-preview",
@@ -59,6 +61,45 @@ export interface ModelConfigurationView {
models: ModelConfigView[];
}
type ReadableModelConfiguration = Pick<ModelConfigurationService, "read">;
function generationRuntimeReason(reason: ModelRuntimeReason): GenerationModelSnapshot["runtimeAvailability"]["reason"] {
if (reason === "gateway_balance_insufficient") return reason;
if (reason === "contract_unverified" || reason === "contract_blocked") return "gateway_contract_invalid";
if (reason === "available") return null;
return "model_disabled";
}
export class GenerationModelConfigurationCatalog implements GenerationModelCatalog {
constructor(private readonly models: ReadableModelConfiguration) {}
readModel(modelId: string): GenerationModelSnapshot | undefined {
const configuration = this.models.read();
const model = configuration.models.find((entry) => entry.model_id === modelId);
if (!model) return undefined;
const supportedRatios = projectRatios.filter((ratio) => model.supported_ratios.includes(ratio));
return {
configSetVersion: configuration.config_set_version,
configVersion: model.config_version,
contractValidationStatus: model.contract_validation_status === "verified" ? "verified" : "unverified",
creditCost: model.credit_cost,
enabled: model.enabled,
modelId: model.model_id,
promptMaxLength: model.prompt_max_length,
referenceLimits: {
maxFileBytes: model.reference_limits.max_file_bytes,
maxFiles: model.reference_limits.max_files,
maxTotalBytes: model.reference_limits.max_total_bytes,
},
runtimeAvailability: {
availableForNewJobs: model.runtime_availability.available_for_new_jobs,
reason: generationRuntimeReason(model.runtime_availability.reason),
},
supportedRatios,
};
}
}
export class ModelConfigurationError extends Error {
constructor(
readonly code:
@@ -111,7 +152,7 @@ const defaultErrorMapping: Record<string, string> = {
upstream_timeout: "upstream_timeout",
};
const seedCandidates: ModelConfigCandidate[] = [
const defaultSeedCandidates: ModelConfigCandidate[] = [
{
model_id: modelIds[0], display_name: "Gemini 3.1 Flash Image Preview", enabled: true, is_default: true,
recommendation_priority: 1, route_profile: { endpoint: "https://mock.invalid/v1/images", mode: "sync" },
@@ -138,6 +179,42 @@ const seedCandidates: ModelConfigCandidate[] = [
},
];
export const portableRuntimeModelCandidates: ModelConfigCandidate[] = [
{
...defaultSeedCandidates[0]!,
display_name: "Gemini 3.1 Flash Image",
route_profile: {
endpoint: "https://oneapi.intelligrow.cn/v1/chat/completions",
mode: "sync",
protocol_version: "gemini-openai-chat-v1",
provider_model_id: "gemini-3.1-flash-image",
},
gateway_account_ref: "oneapi-intelligrow-test",
contract_validation_status: "verified",
contract_evidence_ref: "contract:wp7-02:gemini-3.1-flash-image:v7",
},
{
...defaultSeedCandidates[1]!,
enabled: false,
route_profile: { endpoint: "https://oneapi.intelligrow.cn/unsupported", mode: "disabled" },
gateway_account_ref: "oneapi-intelligrow-test",
contract_validation_status: "unverified",
contract_evidence_ref: null,
},
{
...defaultSeedCandidates[2]!,
route_profile: {
endpoint: "https://oneapi.intelligrow.cn/v1/images/generations",
mode: "sync",
protocol_version: "openai-images-v1",
reference_endpoint: "https://oneapi.intelligrow.cn/v1/images/edits",
},
gateway_account_ref: "oneapi-intelligrow-test",
contract_validation_status: "verified",
contract_evidence_ref: "contract:wp7-02:gpt-image-2:v2",
},
];
function stableJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
if (value && typeof value === "object") {
@@ -207,17 +284,20 @@ export interface ModelConfigurationServiceOptions {
clock?: () => number;
database: BetterSqlite3.Database;
onChanged?: (configSetVersion: number) => void;
seedCandidates?: ModelConfigCandidate[];
}
export class ModelConfigurationService {
readonly database: BetterSqlite3.Database;
readonly #clock: () => number;
readonly #onChanged: ((configSetVersion: number) => void) | undefined;
readonly #seedCandidates: ModelConfigCandidate[];
constructor(options: ModelConfigurationServiceOptions) {
this.database = options.database;
this.#clock = options.clock ?? Date.now;
this.#onChanged = options.onChanged;
this.#seedCandidates = structuredClone(options.seedCandidates ?? defaultSeedCandidates);
this.ensureSchema();
}
@@ -503,7 +583,7 @@ export class ModelConfigurationService {
const current = this.database.prepare("SELECT config_set_id FROM model_config_current WHERE singleton = 1").get() as { config_set_id: string } | undefined;
if (current) return;
const seed = this.database.transaction(() => {
validateModelConfigurationCandidateSet(seedCandidates);
validateModelConfigurationCandidateSet(this.#seedCandidates);
const now = this.#clock();
const setId = randomUUID();
this.database.prepare("INSERT INTO model_config_sets (config_set_id, config_set_version, created_at, created_by) VALUES (?, 1, ?, 'system_seed')")
@@ -520,7 +600,9 @@ export class ModelConfigurationService {
INSERT INTO model_config_set_members (config_set_id, model_id, config_version, enabled, is_default, recommendation_priority)
VALUES (?, ?, 1, ?, ?, ?)
`);
for (const candidate of seedCandidates) {
for (const candidate of this.#seedCandidates) {
const contractStatus = candidate.contract_validation_status ?? "unverified";
const contractEvidenceRef = contractStatus === "unverified" ? null : candidate.contract_evidence_ref ?? null;
const routeProfileId = profileRef("route", candidate.route_profile);
const errorMappingProfileId = profileRef("error", candidate.error_mapping_profile);
this.database.prepare("INSERT OR IGNORE INTO gateway_route_profiles (route_profile_id, profile_json, created_at) VALUES (?, ?, ?)")
@@ -530,12 +612,14 @@ export class ModelConfigurationService {
insertVersion.run(candidate.model_id, candidate.display_name, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0,
candidate.recommendation_priority, routeProfileId, stableJson(candidate.route_profile), candidate.gateway_account_ref,
errorMappingProfileId, stableJson(candidate.error_mapping_profile), candidate.credit_cost, stableJson(candidate.supported_ratios), stableJson(candidate.reference_limits),
candidate.prompt_max_length, candidate.safety_source, "unverified", null, fingerprint(candidate), now);
candidate.prompt_max_length, candidate.safety_source, contractStatus, contractEvidenceRef, fingerprint(candidate), now);
insertMember.run(setId, candidate.model_id, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0, candidate.recommendation_priority);
const available = candidate.enabled && contractStatus === "verified";
const runtimeReason = !candidate.enabled ? "configured_disabled" : available ? "available" : "contract_unverified";
this.database.prepare(`
INSERT INTO model_runtime_availability (model_id, available_for_new_jobs, reason, checked_at, runtime_availability_version)
VALUES (?, 0, 'contract_unverified', ?, 0)
`).run(candidate.model_id, now);
VALUES (?, ?, ?, ?, 0)
`).run(candidate.model_id, available ? 1 : 0, runtimeReason, now);
}
this.database.prepare("INSERT INTO model_config_current (singleton, config_set_id) VALUES (1, ?)").run(setId);
});
+55
View File
@@ -38,6 +38,7 @@ export interface RegistrationTransactionEvent {
| "registration_send"
| "registration_complete"
| "registration_send_compensation"
| "local_test_session"
| "login_send"
| "login_complete"
| "admin_login_send"
@@ -654,6 +655,60 @@ export class RegistrationService {
return outcome;
}
createLocalTestSession(): LoginCompleteResult {
const now = this.options.clock();
return this.runImmediate("local_test_session", () => {
const registrationId = "local-test-user-v1";
const existing = this.database.prepare(`
SELECT user_id, role, status FROM users WHERE registration_id = ?
`).get(registrationId) as {
role: "user" | "super_admin";
status: "active" | "suspended" | "deleted";
user_id: string;
} | undefined;
if (existing) {
if (existing.role !== "user" || existing.status !== "active") {
throw new RegistrationError("AUTH_ENTRY_REJECTED", "account_suspended");
}
const session = this.insertSession(existing.user_id, "user", now);
return {
outcome: "committed",
value: this.loginResult(this.readCompletedRegistration(existing.user_id, session.sessionId)),
};
}
const userId = randomUUID();
this.database.prepare(`
INSERT INTO users (
user_id, normalized_email, role, status, counts_toward_stage_limit,
registration_id, created_at
) VALUES (?, 'local-test-user@dada.invalid', 'user', 'active', 0, ?, ?)
`).run(userId, registrationId, now);
this.database.prepare(`
INSERT INTO user_profiles (
user_id, creator_name, social_id, private_content_notice_version,
private_content_notice_acknowledged_at
) VALUES (?, '本机测试用户', '@dada_local_test', NULL, NULL)
`).run(userId);
this.database.prepare(`
INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at)
VALUES (?, 10, 0, ?)
`).run(userId, now);
this.database.prepare(`
INSERT INTO credit_ledger (
ledger_id, user_id, operation_key, entry_type, amount,
available_before, available_after, reserved_before, reserved_after, created_at
) VALUES (?, ?, 'local-test-registration:v1', 'registration_grant', 10, 0, 10, 0, 0, ?)
`).run(randomUUID(), userId, now);
const session = this.insertSession(userId, "user", now);
return {
outcome: "committed",
value: this.loginResult(this.readCompletedRegistration(userId, session.sessionId)),
};
});
}
applySecureConfig(candidate: SecureConfigCandidate) {
const now = this.options.clock();
const fail = (reason: string): never => {
+93
View File
@@ -0,0 +1,93 @@
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import {
createPublicAssetResolver,
readConfiguredAssetRoot,
validateReadOnlyAssetRoot,
type PublicAssetEntry,
type PublicAssetResolver,
} from "./local-data-root.js";
const rootRef = "p0a_runtime_assets";
const schemaVersion = "DadaRuntimeAssets/v1";
const assetIdPattern = /^[a-z0-9][a-z0-9_-]{2,119}$/i;
const releasePattern = /^[a-z0-9][a-z0-9._-]{0,79}$/i;
const shaPattern = /^[a-f0-9]{64}$/i;
export interface RuntimeAssetState {
checked_at: string;
configured: boolean;
pause_reason: "asset_manifest_invalid" | "asset_root_missing" | "asset_root_state_missing" | null;
status: "active" | "unavailable";
}
export interface LoadedRuntimeAssets {
publicAssets?: PublicAssetResolver;
state: RuntimeAssetState;
}
function parseRuntimeManifest(bytes: Buffer): PublicAssetEntry[] {
const value = JSON.parse(bytes.toString("utf8")) as Record<string, unknown>;
if (value.schema_version !== schemaVersion || value.source !== "external_read_only" || value.root_ref !== rootRef) {
throw new Error("runtime_asset_manifest_invalid");
}
if (!Array.isArray(value.entries) || value.entries.length === 0) throw new Error("runtime_asset_manifest_invalid");
return value.entries.map((candidate) => {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) throw new Error("runtime_asset_manifest_invalid");
const entry = candidate as Record<string, unknown>;
if (
typeof entry.assetId !== "string" || !assetIdPattern.test(entry.assetId)
|| typeof entry.mimeType !== "string" || !/^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i.test(entry.mimeType)
|| typeof entry.relativePath !== "string" || entry.relativePath.includes("\\") || entry.relativePath.split("/").includes("..")
|| typeof entry.resourceVersion !== "string" || !releasePattern.test(entry.resourceVersion)
|| entry.rootRef !== rootRef
|| typeof entry.sha256 !== "string" || !shaPattern.test(entry.sha256)
) throw new Error("runtime_asset_manifest_invalid");
return entry as unknown as PublicAssetEntry;
});
}
function unavailable(
configured: boolean,
pauseReason: Exclude<RuntimeAssetState["pause_reason"], null>,
checkedAt: string,
): LoadedRuntimeAssets {
return { state: { checked_at: checkedAt, configured, pause_reason: pauseReason, status: "unavailable" } };
}
export function loadConfiguredRuntimeAssets(input: {
configFile: string;
dataRoot: string;
trustedManifestPath: string;
clock?: () => number;
}): LoadedRuntimeAssets {
const checkedAt = new Date((input.clock ?? Date.now)()).toISOString();
let assetRoot: string;
try {
assetRoot = readConfiguredAssetRoot(input.configFile);
} catch {
return unavailable(false, "asset_root_state_missing", checkedAt);
}
if (!existsSync(input.trustedManifestPath)) return unavailable(true, "asset_manifest_invalid", checkedAt);
try {
const trustedBytes = readFileSync(input.trustedManifestPath);
const entries = parseRuntimeManifest(trustedBytes);
const validatedRoot = validateReadOnlyAssetRoot({
dataRoot: input.dataRoot,
expectedSha256: createHash("sha256").update(trustedBytes).digest("hex"),
manifestRelativePath: "manifest.json",
root: assetRoot,
rootRef,
});
if (!validatedRoot.ok) {
return unavailable(true, validatedRoot.reason === "asset_root_missing" ? "asset_root_missing" : "asset_manifest_invalid", checkedAt);
}
return {
publicAssets: createPublicAssetResolver({ entries, roots: [validatedRoot] }),
state: { checked_at: checkedAt, configured: true, pause_reason: null, status: "active" },
};
} catch {
return unavailable(true, "asset_manifest_invalid", checkedAt);
}
}
+14 -6
View File
@@ -1,5 +1,7 @@
import { createConnection } from "node:net";
import { MockAmapAdapter, RealAmapAdapter } from "./amap-adapter.js";
const API_CREDENTIALS = ["Dada/P0A/api/resend", "Dada/P0A/api/amap", "Dada/P0A/admin/pepper"] as const;
export async function receiveApiCredentials(input: NodeJS.ReadableStream = process.stdin) {
@@ -13,7 +15,7 @@ export async function receiveApiCredentials(input: NodeJS.ReadableStream = proce
if (names.length !== expected.length || names.some((name, index) => name !== expected[index])) {
throw new Error("API credential channel contains an unexpected credential scope.");
}
if (expected.some((name) => typeof parsed[name] !== "string" || parsed[name] === "")) {
if (expected.some((name) => typeof parsed[name] !== "string")) {
throw new Error("API credential channel contains an invalid credential value.");
}
return parsed as Record<(typeof API_CREDENTIALS)[number], string>;
@@ -25,11 +27,17 @@ export async function receiveApiCredentials(input: NodeJS.ReadableStream = proce
}
export function initializeApiCredentialClients(credentials: Record<(typeof API_CREDENTIALS)[number], string>) {
const configured = API_CREDENTIALS.every((name) => credentials[name].length > 0);
const adminPepperValue = credentials["Dada/P0A/admin/pepper"];
for (const name of API_CREDENTIALS) credentials[name] = "";
if (!configured) throw new Error("API credential client initialization failed.");
return { adminAllowlistPepper: Buffer.from(adminPepperValue, "utf8") };
try {
const adminPepper = credentials["Dada/P0A/admin/pepper"];
if (!adminPepper) throw new Error("admin_pepper_not_configured");
return {
adminAllowlistPepper: Buffer.from(adminPepper, "utf8"),
amap: credentials["Dada/P0A/api/amap"] ? new RealAmapAdapter(credentials["Dada/P0A/api/amap"]) : new MockAmapAdapter(),
resendConfigured: Boolean(credentials["Dada/P0A/api/resend"]),
};
} finally {
for (const name of API_CREDENTIALS) credentials[name] = "";
}
}
export function attachApiSupervisorControl(pipeName: string, shutdown: () => Promise<void>) {
+3 -7
View File
@@ -1,13 +1,8 @@
import { useEffect, useRef } from "react";
import { P0A_COLOR_CARDS, createColorCardElement, drawColorCard, type ColorCardDefinition } from "./palette-provider.js";
import { COLOR_CARD_HALF_SIZES, P0A_COLOR_CARDS, createColorCardElement, drawColorCard, type ColorCardDefinition } from "./palette-provider.js";
const previewPalette = ["#04D960", "#0ABF58", "#5FD994", "#A0F2C4", "#D5F2E2"] as const;
const previewHalfSize = {
style_01: { height: 76, width: 26 }, style_02: { height: 77, width: 18 },
style_08: { height: 10, width: 73 }, style_16: { height: 9, width: 78 },
} as const;
function ColorCardPreview({ definition }: { definition: ColorCardDefinition }) {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
@@ -18,7 +13,8 @@ function ColorCardPreview({ definition }: { definition: ColorCardDefinition }) {
context.clearRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "#30343b";
context.fillRect(0, 0, canvas.width, canvas.height);
const half = previewHalfSize[definition.styleId];
const half = COLOR_CARD_HALF_SIZES[definition.styleId];
if (!half) return;
const scale = Math.min(1, 146 / (half.width * 2), 62 / (half.height * 2));
context.translate(canvas.width / 2, canvas.height / 2);
context.scale(scale, scale);
+33 -16
View File
@@ -3,6 +3,7 @@ import { P0A_DYNAMIC_STICKER_IDS } from "@dada/template-registry";
import { DYNAMIC_RESOURCE_VERSION } from "./dynamic-render-models.js";
import type { CanvasElementIdentity } from "./editor-elements.js";
import complexAssetCatalog from "./generated/complex-assets.json";
type CanvasElement = CanvasState["elements"][number];
@@ -45,23 +46,17 @@ export function dyn012DisplayParts(element: CanvasElement) {
} as const;
}
const dynamicDefinitions: readonly DynamicStickerDefinition[] = [
{ category: "location", displayName: "地点标题", requiresLocationConsent: false, templateId: "DYN001" },
{ category: "location", displayName: "英文地点", requiresLocationConsent: false, templateId: "DYN002" },
{ category: "location", displayName: "城市地点", requiresLocationConsent: false, templateId: "DYN003" },
{ category: "location", displayName: "经纬地点", requiresLocationConsent: true, templateId: "DYN004" },
{ category: "other", displayName: "用户名组合", requiresLocationConsent: false, templateId: "DYN007" },
{ category: "time", displayName: "月与时间", requiresLocationConsent: false, templateId: "DYN008" },
{ category: "time", displayName: "完整日期", requiresLocationConsent: false, templateId: "DYN011" },
{ category: "time", displayName: "数字时间", requiresLocationConsent: false, templateId: "DYN012" },
{ category: "identity", displayName: "创作署名", requiresLocationConsent: false, templateId: "DYN015" },
{ category: "identity", displayName: "社交 ID", requiresLocationConsent: false, templateId: "DYN016" },
] as const;
const dynamicCatalogById = new Map(complexAssetCatalog.dynamic_stickers.map((item) => [item.template_id, item]));
export const P0A_DYNAMIC_STICKERS: readonly DynamicStickerDefinition[] = P0A_DYNAMIC_STICKER_IDS.map((templateId) => {
const definition = dynamicDefinitions.find((item) => item.templateId === templateId);
if (!definition) throw new Error(`missing dynamic sticker definition ${templateId}`);
return definition;
const item = dynamicCatalogById.get(templateId);
if (!item) throw new Error(`missing dynamic sticker definition ${templateId}`);
return {
category: item.category as DynamicCategory,
displayName: item.display_name,
requiresLocationConsent: item.requires_location_consent,
templateId,
};
});
function twoDigits(value: number) {
@@ -96,7 +91,29 @@ function snapshotFor(templateId: DynamicTemplateId, context: DynamicProviderCont
if (templateId === "DYN011") return { fields: { day, month, year }, value: `${year}.${month}.${day}` };
if (templateId === "DYN012") return { fields: { font_substitution: "FONT081", hour, minute }, value: `${hour}:${minute}` };
if (templateId === "DYN015") return { fields: { nickname: context.profile.creatorName }, value: context.profile.creatorName };
return { fields: { nickname: normalizeSocialId(context.profile.socialId) }, value: normalizeSocialId(context.profile.socialId) };
if (templateId === "DYN016") return { fields: { nickname: normalizeSocialId(context.profile.socialId) }, value: normalizeSocialId(context.profile.socialId) };
const definition = dynamicCatalogById.get(templateId);
if (!definition) throw new Error("dynamic_template_unavailable");
const location = context.location?.formattedValue ?? "输入地点";
const values: Record<string, string | number> = {
city: location,
city_en: location.toUpperCase(),
day,
hour,
latitude: context.location?.latitude ?? 0,
longitude: context.location?.longitude ?? 0,
minute,
month,
nickname: normalizeSocialId(context.profile.socialId),
title: location,
year,
};
const fields = Object.fromEntries(definition.required_fields.map((field) => [field, values[field] ?? ""]));
if (definition.category === "identity") return { fields, value: normalizeSocialId(context.profile.socialId) };
if (definition.category === "location") return { fields, value: location };
if (definition.category === "time") return { fields, value: `${year}.${month}.${day} ${hour}:${minute}` };
return { fields, value: context.profile.creatorName };
}
export function createDynamicStickerElement(
+43 -12
View File
@@ -1,7 +1,9 @@
import type { CanvasState } from "@dada/shared-contracts";
import { P0A_COMPLEX_RELEASE_VERSION, P0A_DYNAMIC_STICKER_IDS } from "@dada/template-registry";
import { fontOption, type FontOption } from "./text-assets.js";
import type { DynamicTemplateId } from "./dynamic-provider.js";
import complexAssetCatalog from "./generated/complex-assets.json";
type CanvasElement = CanvasState["elements"][number];
@@ -46,7 +48,7 @@ export interface DynamicRenderModel {
textLayers: readonly DynamicTextLayer[];
}
export const DYNAMIC_RESOURCE_VERSION = "wp4-dynamic-source-v1";
export const DYNAMIC_RESOURCE_VERSION = P0A_COMPLEX_RELEASE_VERSION;
const dynamicFont = (fontId: string): FontOption => ({
displayName: fontId,
@@ -54,17 +56,13 @@ const dynamicFont = (fontId: string): FontOption => ({
url: `/api/v1/assets/public/${DYNAMIC_RESOURCE_VERSION}/${fontId}`,
});
export const DYNAMIC_FONT_OPTIONS: readonly FontOption[] = [
dynamicFont("15974853bc3294ef68e7e6d58fe74fd7"),
dynamicFont("46f8336813e4c48d06a1aef294fdccf6"),
dynamicFont("53ca6b704728520da50c145eabb2e635"),
dynamicFont("cca5efc0e02fb1bf62349bd68ef30fc1"),
dynamicFont("dd25b35dcb7ba4476cbaa9a9592e39e2"),
dynamicFont("e4210c9872f0c279b35273f230809821"),
dynamicFont("f4bfd4132df2d6be97ceabadf3853505"),
] as const;
const dynamicFontIds = [...new Set(complexAssetCatalog.dynamic_stickers.flatMap((item) => item.font_ids))]
.filter((fontId) => fontId !== "FONT081")
.toSorted();
export const DYNAMIC_RENDER_MODELS: Readonly<Record<DynamicTemplateId, DynamicRenderModel>> = {
export const DYNAMIC_FONT_OPTIONS: readonly FontOption[] = dynamicFontIds.map(dynamicFont);
const EXACT_DYNAMIC_RENDER_MODELS: Readonly<Record<string, DynamicRenderModel>> = {
DYN001: {
halfSize: { height: 42, width: 130 },
imageLayers: [{ assetId: "DYN001-image28", height: 67, width: 219, x: -17.562, y: 2.203 }],
@@ -144,6 +142,38 @@ export const DYNAMIC_RENDER_MODELS: Readonly<Record<DynamicTemplateId, DynamicRe
},
};
const dynamicCatalogById = new Map(complexAssetCatalog.dynamic_stickers.map((item) => [item.template_id, item]));
function genericTextValue(item: (typeof complexAssetCatalog.dynamic_stickers)[number]): DynamicTextValue {
if (item.category === "identity") return "nickname";
if (item.category === "location") return item.required_fields.includes("title") ? "title" : "city";
if (item.category === "time") return item.required_fields.includes("hour") && item.required_fields.includes("minute") ? "time" : "day";
return "nickname";
}
function genericDynamicModel(templateId: string): DynamicRenderModel {
const item = dynamicCatalogById.get(templateId);
if (!item) throw new Error(`missing dynamic render model ${templateId}`);
return {
halfSize: { height: 42, width: 170 },
imageLayers: [],
sourceCandidateId: item.source_candidate_id,
textLayers: [{
align: "center",
color: "#FFFFFF",
fontId: item.font_ids[0] ?? "FONT081",
fontSize: 34,
value: genericTextValue(item),
x: 0,
y: 0,
}],
};
}
export const DYNAMIC_RENDER_MODELS: Readonly<Record<DynamicTemplateId, DynamicRenderModel>> = Object.fromEntries(
P0A_DYNAMIC_STICKER_IDS.map((templateId) => [templateId, EXACT_DYNAMIC_RENDER_MODELS[templateId] ?? genericDynamicModel(templateId)]),
);
export function dynamicFontOptionsFor(templateId: string) {
if (templateId === "DYN012") {
const replacement = fontOption("FONT081");
@@ -152,7 +182,8 @@ export function dynamicFontOptionsFor(templateId: string) {
const model = DYNAMIC_RENDER_MODELS[templateId as DynamicTemplateId];
if (!model) return [];
const ids = new Set(model.textLayers.map((layer) => layer.fontId));
return DYNAMIC_FONT_OPTIONS.filter((option) => ids.has(option.fontId));
return [...ids].map((fontId) => fontOption(fontId) ?? DYNAMIC_FONT_OPTIONS.find((option) => option.fontId === fontId))
.filter((option): option is FontOption => option !== undefined);
}
export function dynamicImageUrl(resourceVersion: string, assetId: string) {
+1
View File
@@ -22,6 +22,7 @@ function DynamicPreview(props: { fontStatuses: Readonly<Record<string, ArchivedF
useEffect(() => {
let active = true;
const model = DYNAMIC_RENDER_MODELS[props.templateId];
if (!model) return () => { active = false; };
const element = createDynamicStickerElement(props.templateId, {
location: { formattedValue: "温州", latitude: 27.9943, longitude: 120.6994 },
now: new Date("2026-08-03T09:07:00+08:00"), profile: { creatorName: "Dada Creator", socialId: "@dada" },
+115 -2
View File
@@ -2,6 +2,27 @@ import { isCanvasState, type CanvasState } from "@dada/shared-contracts";
export type BackgroundAdjustments = CanvasState["background"]["adjustments"];
interface CanvasSize {
height: number;
width: number;
}
interface CanvasRect {
height: number;
width: number;
x: number;
y: number;
}
export interface BackgroundDrawPlan {
destination: CanvasRect;
source: CanvasRect;
}
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(maximum, Math.max(minimum, value));
}
const elementKeys = new Set([
"colors", "content", "coordinates", "created_at", "dynamic_fields", "element_id", "font_override", "font_size",
"formatted_value", "opacity", "position", "resource_version", "rotation", "scale", "style_id", "style_parameters",
@@ -106,7 +127,99 @@ export function deserializeFabricCanvas(input: unknown): CanvasState | undefined
return isCanvasState(candidate) ? structuredClone(candidate) : undefined;
}
export function backgroundDrawPlan(
image: CanvasSize,
canvas: CanvasSize,
adjustments: BackgroundAdjustments,
): BackgroundDrawPlan {
if (image.width <= 0 || image.height <= 0 || canvas.width <= 0 || canvas.height <= 0) {
throw new Error("background_dimensions_invalid");
}
const crop = adjustments.crop;
const normalizedX = crop ? clamp(crop.x, 0, 1) : 0;
const normalizedY = crop ? clamp(crop.y, 0, 1) : 0;
const normalizedWidth = crop ? Math.min(crop.width, 1 - normalizedX) : 1;
const normalizedHeight = crop ? Math.min(crop.height, 1 - normalizedY) : 1;
const source = normalizedWidth > 0 && normalizedHeight > 0
? {
height: image.height * normalizedHeight,
width: image.width * normalizedWidth,
x: image.width * normalizedX,
y: image.height * normalizedY,
}
: { height: image.height, width: image.width, x: 0, y: 0 };
if (adjustments.fit === "fill") {
return { destination: { height: canvas.height, width: canvas.width, x: 0, y: 0 }, source };
}
if (adjustments.fit === "fit") {
const scale = Math.min(canvas.width / source.width, canvas.height / source.height);
const width = source.width * scale;
const height = source.height * scale;
return {
destination: { height, width, x: (canvas.width - width) / 2, y: (canvas.height - height) / 2 },
source,
};
}
const sourceAspect = source.width / source.height;
const canvasAspect = canvas.width / canvas.height;
if (sourceAspect > canvasAspect) {
const width = source.height * canvasAspect;
source.x += (source.width - width) / 2;
source.width = width;
} else if (sourceAspect < canvasAspect) {
const height = source.width / canvasAspect;
source.y += (source.height - height) / 2;
source.height = height;
}
return {
destination: { height: canvas.height, width: canvas.width, x: 0, y: 0 },
source,
};
}
export function adjustBackgroundPixels(
pixels: Uint8ClampedArray,
width: number,
height: number,
adjustments: Pick<BackgroundAdjustments, "sharpness" | "temperature">,
) {
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0 || pixels.length !== width * height * 4) {
throw new Error("background_pixel_buffer_invalid");
}
const source = new Uint8ClampedArray(pixels);
const output = new Uint8ClampedArray(source);
const sharpness = clamp(adjustments.sharpness, 0, 100) / 100;
const temperature = clamp(adjustments.temperature, -100, 100) / 100;
const channelOffsets = [35 * temperature, 8 * temperature, -35 * temperature];
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const index = (y * width + x) * 4;
for (let channel = 0; channel < 3; channel += 1) {
let value = source[index + channel]!;
if (sharpness > 0 && x > 0 && x < width - 1 && y > 0 && y < height - 1) {
const left = source[index + channel - 4]!;
const right = source[index + channel + 4]!;
const above = source[index + channel - width * 4]!;
const below = source[index + channel + width * 4]!;
value = value * (1 + 4 * sharpness) - (left + right + above + below) * sharpness;
}
output[index + channel] = value + channelOffsets[channel]!;
}
output[index + 3] = source[index + 3]!;
}
}
return output;
}
export function cssFilterForBackground(adjustments: BackgroundAdjustments) {
const filter = adjustments.filter === "grayscale" ? "grayscale(1)" : adjustments.filter === "sepia" ? "sepia(0.75)" : "none";
return `${filter} brightness(${100 + adjustments.brightness}%) contrast(${100 + adjustments.contrast}%) saturate(${100 + adjustments.saturation}%)`;
const filters: string[] = [];
if (adjustments.filter === "grayscale") filters.push("grayscale(1)");
else if (adjustments.filter === "sepia") filters.push("sepia(0.75)");
if (adjustments.brightness !== 0) filters.push(`brightness(${100 + adjustments.brightness}%)`);
if (adjustments.contrast !== 0) filters.push(`contrast(${100 + adjustments.contrast}%)`);
if (adjustments.saturation !== 0) filters.push(`saturate(${100 + adjustments.saturation}%)`);
return filters.length > 0 ? filters.join(" ") : "none";
}
+22 -7
View File
@@ -2,6 +2,7 @@ import { isCanvasState, type CanvasState } from "@dada/shared-contracts";
import { DYNAMIC_RENDER_MODELS } from "./dynamic-render-models.js";
import type { DynamicTemplateId } from "./dynamic-provider.js";
import { textTemplateById } from "./text-assets.js";
type CanvasElement = CanvasState["elements"][number];
@@ -76,9 +77,10 @@ export function elementHalfExtents(state: CanvasState, element: CanvasElement):
const longestCharacterCount = Math.max(1, ...lines.map((line) => Array.from(line).length));
const widthPixels = longestLine * fontSize + (longestCharacterCount - 1) * letterSpacing + 32 + strokeWidth * 2;
const heightPixels = Math.max(1, lines.length) * fontSize * lineHeight + 32 + strokeWidth * 2;
const template = textTemplateById(element.template_or_asset_id);
return {
x: Math.max(hitHalfExtent, widthPixels / state.pixel_width / 2) * element.scale.x,
y: Math.max(hitHalfExtent, heightPixels / state.pixel_height / 2) * element.scale.y,
x: Math.max(hitHalfExtent, widthPixels / state.pixel_width / 2, (template?.renderModel.halfSize.width ?? 0) / state.pixel_width) * element.scale.x,
y: Math.max(hitHalfExtent, heightPixels / state.pixel_height / 2, (template?.renderModel.halfSize.height ?? 0) / state.pixel_height) * element.scale.y,
};
}
@@ -187,7 +189,7 @@ export class CanvasElementController {
.map((element) => structuredClone(element));
}
selectAt(point: CanvasPoint, options: { append?: boolean } = {}) {
selectAt(point: CanvasPoint, options: { append?: boolean; preserveSelection?: boolean } = {}) {
const candidates = this.candidatesAt(point);
if (candidates.length === 0) {
if (!options.append) this.selection = [];
@@ -196,7 +198,9 @@ export class CanvasElementController {
}
if (options.append) {
this.pointerMoved();
return this.selectById(candidates[0]!.element_id, true);
const elementId = candidates[0]!.element_id;
if (options.preserveSelection && this.selection.includes(elementId)) return this.selectedIds;
return this.selectById(elementId, true);
}
const signature = candidates.map((candidate) => candidate.element_id).join("|");
if (samePoint(this.cyclePoint, point) && signature === this.cycleSignature) this.cycleIndex = (this.cycleIndex + 1) % candidates.length;
@@ -255,7 +259,8 @@ export class CanvasElementController {
moveSelected(delta: CanvasPoint, options: { snap?: boolean } = {}) {
const selected = new Set(this.selection);
const primary = this.current.elements.find((element) => selected.has(element.element_id));
const selectedElements = this.current.elements.filter((element) => selected.has(element.element_id));
const primary = selectedElements[0];
if (!primary) return { guides: [] as string[], state: this.value };
let nextX = primary.position.x + delta.x;
let nextY = primary.position.y + delta.y;
@@ -278,10 +283,20 @@ export class CanvasElementController {
nextX = snapAxis(nextX, "x");
nextY = snapAxis(nextY, "y");
}
const adjusted = { x: nextX - primary.position.x, y: nextY - primary.position.y };
const minimumX = Math.min(...selectedElements.map((element) => element.position.x));
const maximumX = Math.max(...selectedElements.map((element) => element.position.x));
const minimumY = Math.min(...selectedElements.map((element) => element.position.y));
const maximumY = Math.max(...selectedElements.map((element) => element.position.y));
const adjusted = {
x: clamp(nextX - primary.position.x, -minimumX, 1 - maximumX),
y: clamp(nextY - primary.position.y, -minimumY, 1 - maximumY),
};
const state = this.updateSelected((element) => ({
...element,
position: { x: clamp(element.position.x + adjusted.x, 0, 1), y: clamp(element.position.y + adjusted.y, 0, 1) },
position: {
x: clamp(Number((element.position.x + adjusted.x).toFixed(12)), 0, 1),
y: clamp(Number((element.position.y + adjusted.y).toFixed(12)), 0, 1),
},
}));
return { guides, state };
}
+9 -3
View File
@@ -1,9 +1,12 @@
.editor-page-shell {
min-height: 100vh;
height: 100vh;
height: 100dvh;
min-height: 0;
display: grid;
grid-template-rows: 56px minmax(0, 1fr) 32px;
background: #e8e8e5;
color: #111111;
overflow: hidden;
}
.editor-page-shell :focus-visible {
@@ -124,6 +127,7 @@
display: grid;
grid-template-columns: 280px minmax(0, 1fr) 320px;
min-height: 0;
overflow: hidden;
}
.editor-assets-panel,
@@ -395,6 +399,8 @@
.editor-template-grid button:disabled { border-style: dashed; background: #e8e8e5; color: #62625d; cursor: not-allowed; }
.editor-template-grid strong { overflow: hidden; font-family: Consolas, monospace; font-size: 10px; text-overflow: ellipsis; }
.editor-template-grid small { color: #8f1d14; font-size: 9px; }
.editor-template-preview { width: 100%; height: 44px; object-fit: contain; border: 1px solid #111111; background: #30343b; }
.editor-template-live-preview { display: grid; width: 100%; height: 44px; place-items: center; overflow: hidden; border: 1px solid #111111; background: #30343b; white-space: nowrap; }
.editor-template-mark { display: grid; width: 100%; height: 44px; place-items: center; border: 1px solid #111111; background: #f2f400; font-size: 18px; font-weight: 800; }
.editor-template-mark.title { background: #111111; color: #ffffff; }
.editor-template-mark.tag { background: #dbeafe; }
@@ -542,13 +548,13 @@
}
@media (max-width: 760px) {
.editor-page-shell { grid-template-rows: auto minmax(0, 1fr) auto; }
.editor-page-shell { height: auto; min-height: 100dvh; grid-template-rows: auto minmax(0, 1fr) auto; overflow: visible; }
.editor-toolbar { display: flex; min-height: 56px; flex-wrap: wrap; gap: 8px; padding: 8px 10px; }
.editor-title { min-width: 0; flex: 1 1 calc(100% - 56px); }
.editor-history-actions { order: 3; }
.editor-save-status { order: 4; flex: 1 1 128px; }
.editor-toolbar-controls > button { display: block; order: 5; }
.editor-layout { grid-template-columns: 1fr; }
.editor-layout { grid-template-columns: 1fr; overflow: visible; }
.editor-assets-panel, .editor-inspector { border: 0; }
.editor-assets-panel { order: 2; }
.editor-inspector { order: 3; }
+166 -53
View File
@@ -32,6 +32,7 @@ import {
P0A_TEXT_TEMPLATES,
TextEditSession,
createTextTemplateElement,
textTemplateFontOptions,
type TextStylePatch,
type TextTemplateCategory,
type TextTemplateDefinition,
@@ -86,6 +87,14 @@ interface EditorExportResult {
status: ExportFlowStatus;
}
function withTextDraft(canvasState: CanvasState, textEdit: TextEditState | undefined) {
if (!textEdit) return canvasState;
return {
...canvasState,
elements: canvasState.elements.map((element) => element.element_id === textEdit.elementId ? textEdit.draft : element),
};
}
interface EditorProject {
canvas_state?: CanvasState;
created_at: string;
@@ -155,11 +164,26 @@ export function EditorPage({ projectId }: { projectId: string }) {
const elementControllerRef = useRef<CanvasElementController | undefined>(undefined);
const dragRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
const opacityGestureRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
const textHistoryRef = useRef<{ base: CanvasState; elementId: string; last: CanvasState } | undefined>(undefined);
const clipboardRef = useRef<CanvasElement[]>([]);
const fontLoaderRef = useRef<ArchivedFontLoader | undefined>(undefined);
const conflictExportGuardRef = useRef(new ConflictExportGuard());
const candidateMenuRef = useRef<HTMLDivElement | null>(null);
const candidateTriggerRef = useRef<HTMLButtonElement | null>(null);
const noticeTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
function showNotice(message: string) {
if (noticeTimerRef.current) clearTimeout(noticeTimerRef.current);
setNotice(message);
noticeTimerRef.current = setTimeout(() => {
setNotice("");
noticeTimerRef.current = undefined;
}, 3_000);
}
useEffect(() => () => {
if (noticeTimerRef.current) clearTimeout(noticeTimerRef.current);
}, []);
useEffect(() => {
let active = true;
@@ -175,7 +199,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
setDraftAdjustments(initial.background.adjustments);
historyRef.current = new CanvasEditHistory(initial);
elementControllerRef.current = new CanvasElementController(initial);
}).catch(() => { if (active) setNotice("编辑器暂时无法读取项目"); });
}).catch(() => { if (active) showNotice("编辑器暂时无法读取项目"); });
return () => { active = false; };
}, [projectId]);
@@ -228,12 +252,14 @@ export function EditorPage({ projectId }: { projectId: string }) {
useEffect(() => {
if (!canvasState) return;
for (const element of canvasState.elements) {
const options = element.type === "text_template"
? [fontIdForTextElement(element)].map((fontId) => fontId ? fontOption(fontId) : undefined).filter((option) => option !== undefined)
: element.type === "dynamic_sticker" ? dynamicFontOptionsFor(element.template_or_asset_id) : [];
for (const option of options) void ensureFont(option.fontId, option.url);
}
const options = canvasState.elements.flatMap((element) => element.type === "text_template"
? [...textTemplateFontOptions(element.template_or_asset_id), ...[fontIdForTextElement(element)]
.map((fontId) => fontId ? fontOption(fontId) : undefined).filter((option) => option !== undefined)]
: element.type === "dynamic_sticker" ? dynamicFontOptionsFor(element.template_or_asset_id) : []);
const unique = [...new Map(options.map((option) => [option.fontId, option])).values()];
void (async () => {
for (const option of unique) await ensureFont(option.fontId, option.url);
})();
}, [canvasState?.elements.map((element) => `${element.element_id}:${element.font_override ?? ""}:${element.template_or_asset_id}`).join("|")]);
useEffect(() => {
@@ -261,6 +287,10 @@ export function EditorPage({ projectId }: { projectId: string }) {
});
}, [canvasState, selectedIds.join("|")]);
useEffect(() => {
if (textEdit) commitTextDraftAutomatically(textEdit);
}, [textEdit?.draft]);
async function ensureFont(fontId: string, url: string, retry = false) {
const current = fontStatuses[fontId];
if (current === "ready" || (current === "unavailable" && !retry)) return current;
@@ -273,28 +303,51 @@ export function EditorPage({ projectId }: { projectId: string }) {
}
async function retryTextFonts() {
const available = P0A_TEXT_TEMPLATES.filter((template) => template.available && template.fontUrl);
await Promise.all(available.map((template) => ensureFont(template.defaultFontId, template.fontUrl!, true)));
const failed = [...new Set(P0A_TEXT_TEMPLATES
.filter((template) => fontStatuses[template.defaultFontId] === "unavailable")
.map((template) => template.defaultFontId))];
for (const fontId of failed) {
const option = fontOption(fontId);
if (option) await ensureFont(option.fontId, option.url, true);
}
}
function commitCanvas(next: CanvasState) {
async function ensureTemplateFonts(template: TextTemplateDefinition) {
for (const option of textTemplateFontOptions(template.templateId)) {
const status = await ensureFont(option.fontId, option.url, fontStatuses[option.fontId] === "unavailable");
if (status !== "ready") return false;
}
return true;
}
function finalizeTextHistory() {
const pending = textHistoryRef.current;
if (!pending) return undefined;
textHistoryRef.current = undefined;
historyRef.current?.commit(pending.last);
return pending.last;
}
function commitCanvas(next: CanvasState, options: { preserveTextEdit?: boolean } = {}) {
if (!project || saveStatus === "conflicted") return;
historyRef.current?.commit(next);
const finalizedText = finalizeTextHistory();
if (!finalizedText || JSON.stringify(finalizedText) !== JSON.stringify(next)) historyRef.current?.commit(next);
elementControllerRef.current?.replaceState(next);
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
setCanvasState(next);
setDraftAdjustments(next.background.adjustments);
queueRef.current?.commit({ canvas_state: next, name: project.name });
setTextEdit(undefined);
if (!options.preserveTextEdit) setTextEdit(undefined);
}
function applyPreview() {
if (!canvasState) return;
commitCanvas(updateBackgroundAdjustments(canvasState, draftAdjustments));
setNotice("底图调整已提交");
showNotice("底图调整已提交");
}
function undo() {
finalizeTextHistory();
const previous = historyRef.current?.undo();
if (previous) {
elementControllerRef.current?.replaceState(previous);
@@ -307,6 +360,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
}
function redo() {
finalizeTextHistory();
const next = historyRef.current?.redo();
if (next) {
elementControllerRef.current?.replaceState(next);
@@ -334,9 +388,9 @@ export function EditorPage({ projectId }: { projectId: string }) {
const palette = await paletteForAsset(pendingBackground);
commitCanvas(switchBackground(canvasState, pendingBackground, palette));
setPendingBackground(undefined);
setNotice("已更换底图,覆盖元素保留,底图处理已重置");
showNotice("已更换底图,覆盖元素保留,底图处理已重置");
} catch {
setNotice("新底图无法读取,未更换底图或刷新色卡");
showNotice("新底图无法读取,未更换底图或刷新色卡");
}
}
@@ -353,7 +407,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
commitCanvas(next);
setGuides([]);
setCandidateMenu(undefined);
setNotice(message);
showNotice(message);
}
function addSticker(sticker: StaticStickerCatalogItem) {
@@ -369,8 +423,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
}));
commitElementOperation(controller, "贴纸已加入画布");
} catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
else setNotice("贴纸未能加入画布");
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
else showNotice("贴纸未能加入画布");
}
}
@@ -383,8 +437,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
controller.add(createColorCardElement(definition, palette, newElementIdentity(), canvasState.elements.length));
commitElementOperation(controller, "色卡已按原始底图加入画布");
} catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
else setNotice("无法从原始底图稳定提取五色,色卡未加入画布");
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
else showNotice("无法从原始底图稳定提取五色,色卡未加入画布");
}
}
@@ -394,7 +448,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
if (templateId === "DYN012") {
const font = fontOption("FONT081");
if (!font || await ensureFont(font.fontId, font.url) !== "ready") {
setNotice("DYN012 的 FONT081 替代字体不可用,未使用系统字体替代。");
showNotice("DYN012 的 FONT081 替代字体不可用,未使用系统字体替代。");
return;
}
}
@@ -407,8 +461,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
commitElementOperation(controller, "动态值已确认并加入画布");
setLocationDialog(undefined);
} catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
else setNotice("动态贴纸未能加入画布");
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
else showNotice("动态贴纸未能加入画布");
}
}
@@ -456,7 +510,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
controller.replaceElement(overrideDynamicStickerValue(element, value));
commitElementOperation(controller, "动态贴纸显示文字已更新");
} catch {
setNotice("动态贴纸显示文字不能为空");
showNotice("动态贴纸显示文字不能为空");
}
}
@@ -478,9 +532,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
async function addTextTemplate(template: TextTemplateDefinition) {
if (!template.fontUrl || !canvasState) return;
const status = await ensureFont(template.defaultFontId, template.fontUrl);
if (status !== "ready") {
setNotice("素材暂不可用,未使用系统字体替代。");
if (!await ensureTemplateFonts(template)) {
showNotice("素材暂不可用,未使用系统字体替代。");
return;
}
const controller = controllerForCurrent();
@@ -490,8 +543,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
commitElementOperation(controller, "文字模板已加入画布");
void recordRecentTextTemplate(template.templateId, template.resourceVersion);
} catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
else setNotice("文字模板未能加入画布");
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
else showNotice("文字模板未能加入画布");
}
}
@@ -503,18 +556,48 @@ export function EditorPage({ projectId }: { projectId: string }) {
action(edit);
return { ...current, draft: edit.value };
} catch {
setNotice("文字参数不在允许范围内");
showNotice("文字参数不在允许范围内");
return current;
}
});
}
function commitTextDraftAutomatically(editState: TextEditState) {
if (!canvasState || !project || saveStatus === "conflicted") return;
const index = canvasState.elements.findIndex((element) => element.element_id === editState.elementId);
if (index < 0 || JSON.stringify(canvasState.elements[index]) === JSON.stringify(editState.draft)) return;
try {
const complete = new TextEditSession(editState.draft, P0A_TEXT_TEMPLATES).complete();
const next = structuredClone(canvasState);
next.elements[index] = complete;
const history = textHistoryRef.current;
if (!history || history.elementId !== editState.elementId) {
if (history) finalizeTextHistory();
textHistoryRef.current = { base: canvasState, elementId: editState.elementId, last: next };
} else {
history.last = next;
}
elementControllerRef.current?.replaceState(next);
setCanvasState(next);
queueRef.current?.commit({ canvas_state: next, name: project.name });
if (complete.template_or_asset_id !== editState.originalTemplateId) {
void recordRecentTextTemplate(complete.template_or_asset_id, complete.resource_version);
}
setTextEdit((current) => current?.elementId === editState.elementId ? {
...current,
draft: complete,
originalTemplateId: complete.template_or_asset_id,
} : current);
} catch (error) {
if (!(error instanceof Error && error.message === "text_content_required")) showNotice("文字编辑未能自动保存");
}
}
async function changeTextTemplate(templateId: string) {
const template = P0A_TEXT_TEMPLATES.find((candidate) => candidate.templateId === templateId);
if (!template?.fontUrl) return;
const status = await ensureFont(template.defaultFontId, template.fontUrl);
if (status !== "ready") {
setNotice("素材暂不可用,未使用系统字体替代。");
if (!await ensureTemplateFonts(template)) {
showNotice("素材暂不可用,未使用系统字体替代。");
return;
}
updateTextDraft((edit) => edit.switchTemplate(templateId));
@@ -527,7 +610,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
}
const option = fontOption(fontId);
if (!option || await ensureFont(option.fontId, option.url) !== "ready") {
setNotice("字体素材暂不可用,未使用系统字体替代。");
showNotice("字体素材暂不可用,未使用系统字体替代。");
return;
}
updateTextDraft((edit) => edit.setStyle({ fontOverride: fontId }));
@@ -535,6 +618,11 @@ export function EditorPage({ projectId }: { projectId: string }) {
function completeTextEdit() {
if (!textEdit) return;
if (!pendingTextDraft()) {
finalizeTextHistory();
showNotice("文字修改已进入自动保存");
return;
}
try {
const edit = new TextEditSession(textEdit.draft, P0A_TEXT_TEMPLATES);
const complete = edit.complete();
@@ -546,17 +634,25 @@ export function EditorPage({ projectId }: { projectId: string }) {
void recordRecentTextTemplate(complete.template_or_asset_id, complete.resource_version);
}
} catch (error) {
if (error instanceof Error && error.message === "text_content_required") setNotice("请输入文字内容或删除该元素。");
else setNotice("文字编辑未能完成");
if (error instanceof Error && error.message === "text_content_required") showNotice("请输入文字内容或删除该元素。");
else showNotice("文字编辑未能完成");
}
}
function cancelTextEdit() {
const pendingHistory = textHistoryRef.current;
if (pendingHistory && project) {
textHistoryRef.current = undefined;
elementControllerRef.current?.replaceState(pendingHistory.base);
setCanvasState(pendingHistory.base);
queueRef.current?.commit({ canvas_state: pendingHistory.base, name: project.name });
}
if (canvasState && textEdit) {
const current = canvasState.elements.find((element) => element.element_id === textEdit.elementId);
const source = pendingHistory?.base ?? canvasState;
const current = source.elements.find((element) => element.element_id === textEdit.elementId);
if (current) setTextEdit({ draft: structuredClone(current), elementId: current.element_id, originalTemplateId: current.template_or_asset_id });
}
setNotice("已取消未提交的文字修改");
showNotice("已取消未提交的文字修改");
}
function pendingTextDraft() {
@@ -624,7 +720,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
return;
}
const ran = await conflictExportGuardRef.current.run(() => executeExport(options));
if (!ran) setNotice("版本冲突时仅允许导出本页版本一次");
if (!ran) showNotice("版本冲突时仅允许导出本页版本一次");
}
async function retryExportDownload() {
@@ -639,8 +735,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
action(controller);
commitElementOperation(controller, message);
} catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
else setNotice("对象操作未完成");
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
else showNotice("对象操作未完成");
}
}
@@ -668,7 +764,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
opacityGestureRef.current = undefined;
if (gesture.last === gesture.base) return;
commitCanvas(gesture.last);
setNotice("贴纸透明度已提交");
showNotice("贴纸透明度已提交");
}
function duplicateSelection() {
@@ -688,7 +784,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
const controller = controllerForCurrent();
if (!controller || selectedIds.length === 0) return;
clipboardRef.current = controller.copySelected();
setNotice("已复制到画布剪贴板");
showNotice("已复制到画布剪贴板");
}
function pasteSelection() {
@@ -698,18 +794,23 @@ export function EditorPage({ projectId }: { projectId: string }) {
controller.pasteElements(clipboardRef.current, () => newElementIdentity());
commitElementOperation(controller, "已粘贴画布对象");
} catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
}
}
function selectAt(point: CanvasPoint, append: boolean) {
finalizeTextHistory();
const controller = controllerForCurrent();
if (!controller || !canvasState) return false;
const candidates = controller.candidatesAt(point);
const selection = controller.selectAt(point, { append: append || multiMode });
const selection = controller.selectAt(point, {
append: append || multiMode,
preserveSelection: multiMode && !append,
});
setSelectedIds(selection);
setCandidateMenu(undefined);
dragRef.current = { base: canvasState, last: canvasState, selectedIds: selection };
const dragBase = withTextDraft(canvasState, textEdit);
dragRef.current = { base: dragBase, last: dragBase, selectedIds: selection };
return candidates.length > 0;
}
@@ -721,19 +822,25 @@ export function EditorPage({ projectId }: { projectId: string }) {
const preview = previewController.moveSelected(delta);
drag.last = preview.state;
setCanvasState(preview.state);
setTextEdit((current) => {
if (!current || !drag.selectedIds.includes(current.elementId)) return current;
const movedDraft = preview.state.elements.find((element) => element.element_id === current.elementId);
return movedDraft ? { ...current, draft: movedDraft } : current;
});
setGuides(preview.guides);
}
function commitMove() {
const drag = dragRef.current;
if (!drag) return;
commitCanvas(drag.last);
setNotice("对象位置已提交");
commitCanvas(drag.last, { preserveTextEdit: true });
showNotice("对象位置已提交");
setGuides([]);
dragRef.current = undefined;
}
function marqueeSelect(rectangle: CanvasRect, append: boolean) {
finalizeTextHistory();
const controller = controllerForCurrent();
if (!controller) return;
setSelectedIds(controller.marqueeSelect(rectangle, append || multiMode));
@@ -782,6 +889,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
}
function clearSelection() {
finalizeTextHistory();
elementControllerRef.current?.clearSelection();
setSelectedIds([]);
setCandidateMenu(undefined);
@@ -789,10 +897,11 @@ export function EditorPage({ projectId }: { projectId: string }) {
}
if (!project || !canvasState) return <main className="editor-loading" aria-live="polite"></main>;
const renderedCanvasState = textEdit ? {
const backgroundPreviewState: CanvasState = {
...canvasState,
elements: canvasState.elements.map((element) => element.element_id === textEdit.elementId ? textEdit.draft : element),
} : canvasState;
background: { ...canvasState.background, adjustments: draftAdjustments },
};
const renderedCanvasState = withTextDraft(backgroundPreviewState, textEdit);
const imageUrl = `/api/v1/private-assets/projects/${projectId}/images/${canvasState.background.asset_id ?? project.current_image_id ?? ""}`;
const canEdit = saveStatus !== "conflicted";
const selectedElements = renderedCanvasState.elements.filter((element) => selectedIds.includes(element.element_id));
@@ -837,6 +946,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
fontStatuses={fontStatuses}
onAdd={(template) => { void addTextTemplate(template); }}
onCategory={setTemplateCategory}
onEnsure={(template) => { void ensureTemplateFonts(template); }}
onQuery={setTemplateQuery}
onRetry={() => { void retryTextFonts(); }}
query={templateQuery}
@@ -864,9 +974,11 @@ export function EditorPage({ projectId }: { projectId: string }) {
<button disabled={selectedElements.length === 0 || !canEdit} onClick={copySelection} title="复制" type="button"></button>
<button disabled={clipboardRef.current.length === 0 || !canEdit} onClick={pasteSelection} title="粘贴" type="button"></button>
</div>
<div className="editor-canvas-frame" style={{ aspectRatio: `${canvasState.pixel_width} / ${canvasState.pixel_height}` }}>
<div className="editor-canvas-frame" style={{
aspectRatio: `${canvasState.pixel_width} / ${canvasState.pixel_height}`,
maxWidth: `min(720px, calc(${(canvasState.pixel_width / canvasState.pixel_height * 100).toFixed(4)}vh - ${(canvasState.pixel_width / canvasState.pixel_height * 168).toFixed(4)}px))`,
}}>
<EditorStage
assetId={canvasState.background.asset_id}
canvasState={renderedCanvasState}
fontStatuses={fontStatuses}
guides={guides}
@@ -874,6 +986,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
onClearSelection={clearSelection}
onCopy={copySelection}
onDelete={deleteSelection}
onDragStart={() => setCandidateMenu(undefined)}
onMarquee={marqueeSelect}
onMoveCommit={commitMove}
onMovePreview={previewMove}
+606 -74
View File
@@ -1,25 +1,41 @@
import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
import type { CanvasState } from "@dada/shared-contracts";
import { cssFilterForBackground } from "./editor-canvas.js";
import { adjustBackgroundPixels, backgroundDrawPlan, cssFilterForBackground } from "./editor-canvas.js";
import { DYN012_RENDER_LAYOUT, dyn012DisplayParts } from "./dynamic-provider.js";
import type { DynamicTemplateId } from "./dynamic-provider.js";
import { DYNAMIC_RENDER_MODELS, dynamicFontOptionsFor, dynamicImageUrl, dynamicTextValue } from "./dynamic-render-models.js";
import type { CanvasPoint, CanvasRect } from "./editor-elements.js";
import { fontFamilyName, type ArchivedFontStatus } from "./text-font-loader.js";
import { fontIdForTextElement } from "./text-assets.js";
import { drawColorCard } from "./palette-provider.js";
import {
fontIdForTextElement,
textTemplateById,
textTemplateFontOptions,
textTemplateImageUrls,
type TextTemplateImageLayer,
type TextTemplateFillTextureLayout,
type TextTemplateNinePatch,
type TextTemplateParticleLayer,
type TextTemplateTextLayer,
type TextTemplateTextPath,
type TextVerticalAlign,
} from "./text-assets.js";
import { COLOR_CARD_HALF_SIZES, drawColorCard } from "./palette-provider.js";
interface Gesture {
append: boolean;
bounds: DOMRect;
hit: boolean;
longPressOpened: boolean;
moved: boolean;
pointerId: number;
start: CanvasPoint;
startClient: CanvasPoint;
}
const DRAG_THRESHOLD_PX = 4;
interface EditorStageProps {
assetId: string | null;
canvasState: CanvasState;
guides: readonly string[];
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>;
@@ -27,6 +43,7 @@ interface EditorStageProps {
onClearSelection: () => void;
onCopy: () => void;
onDelete: () => void;
onDragStart: () => void;
onMarquee: (rectangle: CanvasRect, append: boolean) => void;
onMoveCommit: () => void;
onMovePreview: (delta: CanvasPoint) => void;
@@ -38,11 +55,10 @@ interface EditorStageProps {
selectedIds: readonly string[];
}
function pointFromEvent(event: PointerEvent<HTMLCanvasElement>): CanvasPoint {
const bounds = event.currentTarget.getBoundingClientRect();
function pointFromClient(clientX: number, clientY: number, bounds: DOMRect): CanvasPoint {
return {
x: Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)),
y: Math.max(0, Math.min(1, (event.clientY - bounds.top) / bounds.height)),
x: Math.max(0, Math.min(1, (clientX - bounds.left) / bounds.width)),
y: Math.max(0, Math.min(1, (clientY - bounds.top) / bounds.height)),
};
}
@@ -56,6 +72,47 @@ interface TextPixelGeometry {
width: number;
}
interface NinePatchRectangle { height: number; width: number; x: number; y: number }
export interface NinePatchSlice { destination: NinePatchRectangle; source: NinePatchRectangle }
function destinationEdges(size: number, first: number, last: number) {
const fixed = first + last;
if (fixed <= size || fixed === 0) return [first, last] as const;
const ratio = size / fixed;
return [first * ratio, last * ratio] as const;
}
export function ninePatchSlices(
width: number,
height: number,
patch: TextTemplateNinePatch,
bitmapSize: { height: number; width: number } = { height: patch.sourceHeight, width: patch.sourceWidth },
): NinePatchSlice[] {
const [left, right] = destinationEdges(width, patch.left, patch.right);
const [top, bottom] = destinationEdges(height, patch.top, patch.bottom);
const sourceScaleX = bitmapSize.width / patch.sourceWidth;
const sourceScaleY = bitmapSize.height / patch.sourceHeight;
const sourceColumns = [0, patch.left * sourceScaleX, bitmapSize.width - patch.right * sourceScaleX, bitmapSize.width];
const sourceRows = [0, patch.top * sourceScaleY, bitmapSize.height - patch.bottom * sourceScaleY, bitmapSize.height];
const destinationColumns = [0, left, width - right, width];
const destinationRows = [0, top, height - bottom, height];
const slices: NinePatchSlice[] = [];
for (let row = 0; row < 3; row += 1) {
for (let column = 0; column < 3; column += 1) {
const source = {
height: sourceRows[row + 1]! - sourceRows[row]!, width: sourceColumns[column + 1]! - sourceColumns[column]!,
x: sourceColumns[column]!, y: sourceRows[row]!,
};
const destination = {
height: destinationRows[row + 1]! - destinationRows[row]!, width: destinationColumns[column + 1]! - destinationColumns[column]!,
x: destinationColumns[column]!, y: destinationRows[row]!,
};
if (source.width > 0 && source.height > 0 && destination.width > 0 && destination.height > 0) slices.push({ destination, source });
}
}
return slices;
}
function prepareTextContext(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], fontId: string) {
const fontSize = element.font_size ?? 48;
const letterSpacing = styleValue(element, "letter_spacing", 1);
@@ -77,47 +134,426 @@ function measureTextElement(context: CanvasRenderingContext2D, element: CanvasSt
};
}
function drawTextElement(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], fontStatuses: Readonly<Record<string, ArchivedFontStatus>>) {
const fontId = fontIdForTextElement(element);
if (!fontId || fontStatuses[fontId] !== "ready") {
context.fillStyle = "#e5e7eb";
context.fillRect(-110, -34, 220, 68);
context.fillStyle = "#9f1d1d";
context.font = "600 22px Microsoft YaHei UI, sans-serif";
context.textAlign = "center";
context.fillText("字体不可用", 0, 8);
return;
function drawTemplateImageLayer(
context: CanvasRenderingContext2D,
layer: TextTemplateImageLayer,
resourceImages: Readonly<Record<string, HTMLImageElement>>,
) {
const image = resourceImages[layer.assetId];
if (!image) return;
context.save();
context.globalAlpha *= layer.alpha;
context.translate(layer.x, layer.y);
context.rotate(layer.rotation * Math.PI / 180);
context.scale(layer.scaleX, layer.scaleY);
const left = -layer.anchorX * layer.width;
const top = -layer.anchorY * layer.height;
if (layer.ninePatch) {
for (const slice of ninePatchSlices(
layer.width,
layer.height,
layer.ninePatch,
{ height: image.naturalHeight, width: image.naturalWidth },
)) {
context.drawImage(
image, slice.source.x, slice.source.y, slice.source.width, slice.source.height,
left + slice.destination.x, top + slice.destination.y, slice.destination.width, slice.destination.height,
);
}
} else context.drawImage(image, left, top, layer.width, layer.height);
context.restore();
}
function deterministicUnit(index: number, salt: number) {
const value = Math.sin(index * 12.9898 + salt * 78.233) * 43_758.5453;
return value - Math.floor(value);
}
function drawTemplateParticles(
context: CanvasRenderingContext2D,
layer: TextTemplateParticleLayer,
resourceImages: Readonly<Record<string, HTMLImageElement>>,
) {
const image = resourceImages[layer.assetId];
if (!image) return;
const columns = Math.max(1, Math.floor(layer.atlasColumns));
const rows = Math.max(1, Math.floor(layer.atlasRows));
const sourceWidth = image.naturalWidth / columns;
const sourceHeight = image.naturalHeight / rows;
const count = Math.max(6, Math.min(80, Math.round((layer.width + layer.height) / 18 * layer.density)));
context.save();
context.globalAlpha *= layer.alpha;
for (let index = 0; index < count; index += 1) {
const progress = index / count;
const angle = progress * Math.PI * 2;
const jitter = (deterministicUnit(index, 1) - 0.5) * layer.randomizePosition * 18;
const x = layer.x + Math.cos(angle) * (layer.width / 2 + jitter);
const y = layer.y + Math.sin(angle) * (layer.height / 2 + jitter);
const cell = index % (columns * rows);
const sourceX = (cell % columns) * sourceWidth;
const sourceY = Math.floor(cell / columns) * sourceHeight;
context.save();
context.translate(x, y);
context.rotate((layer.rotation + (deterministicUnit(index, 2) - 0.5) * layer.randomizeAngle * 360) * Math.PI / 180);
context.drawImage(
image, sourceX, sourceY, sourceWidth, sourceHeight,
-layer.particleWidth / 2, -layer.particleHeight / 2, layer.particleWidth, layer.particleHeight,
);
context.restore();
}
const fontSize = element.font_size ?? 48;
const lineHeight = styleValue(element, "line_height", 1.2);
const letterSpacing = styleValue(element, "letter_spacing", 1);
const align = styleValue(element, "text_align", "center") as CanvasTextAlign;
const lines = (element.content ?? "").split("\n");
prepareTextContext(context, element, fontId);
context.restore();
}
interface TextTemplateBoxLayoutInput {
align: "center" | "left" | "right";
anchorX: number;
anchorY: number;
boxHeight: number;
boxWidth: number;
fontSize: number;
lineCount: number;
lineHeight: number;
verticalAlign: TextVerticalAlign;
}
export function textTemplateBoxLayout(input: TextTemplateBoxLayoutInput) {
const boxLeft = -input.anchorX * input.boxWidth;
const boxTop = -input.anchorY * input.boxHeight;
const lineAdvance = input.fontSize * input.lineHeight;
const blockHeight = input.fontSize + Math.max(0, input.lineCount - 1) * lineAdvance;
const x = input.align === "left" ? boxLeft
: input.align === "right" ? boxLeft + input.boxWidth
: boxLeft + input.boxWidth / 2;
const firstY = input.verticalAlign === "top" ? boxTop + input.fontSize / 2
: input.verticalAlign === "bottom" ? boxTop + input.boxHeight - blockHeight + input.fontSize / 2
: boxTop + (input.boxHeight - blockHeight) / 2 + input.fontSize / 2;
return { blockHeight, firstY, lineAdvance, x };
}
export function boundedTextTemplateWidth(measuredWidth: number, boxWidth: number) {
return Math.max(1, Math.min(measuredWidth, boxWidth));
}
interface TemplatePathPoint { x: number; y: number }
function mixPathPoint(from: TemplatePathPoint, to: TemplatePathPoint, amount: number): TemplatePathPoint {
return { x: from.x + (to.x - from.x) * amount, y: from.y + (to.y - from.y) * amount };
}
function quadraticPathPoint(from: TemplatePathPoint, control: TemplatePathPoint, to: TemplatePathPoint, amount: number) {
const inverse = 1 - amount;
return {
x: inverse * inverse * from.x + 2 * inverse * amount * control.x + amount * amount * to.x,
y: inverse * inverse * from.y + 2 * inverse * amount * control.y + amount * amount * to.y,
};
}
function conicPathPoint(from: TemplatePathPoint, control: TemplatePathPoint, to: TemplatePathPoint, weight: number, amount: number) {
const inverse = 1 - amount;
const denominator = inverse * inverse + 2 * weight * inverse * amount + amount * amount;
return {
x: (inverse * inverse * from.x + 2 * weight * inverse * amount * control.x + amount * amount * to.x) / denominator,
y: (inverse * inverse * from.y + 2 * weight * inverse * amount * control.y + amount * amount * to.y) / denominator,
};
}
function cubicPathPoint(from: TemplatePathPoint, first: TemplatePathPoint, second: TemplatePathPoint, to: TemplatePathPoint, amount: number) {
const inverse = 1 - amount;
return {
x: inverse ** 3 * from.x + 3 * inverse * inverse * amount * first.x + 3 * inverse * amount * amount * second.x + amount ** 3 * to.x,
y: inverse ** 3 * from.y + 3 * inverse * inverse * amount * first.y + 3 * inverse * amount * amount * second.y + amount ** 3 * to.y,
};
}
export function sampleTemplateTextPath(path: TextTemplateTextPath, steps = 28): TemplatePathPoint[] {
const first = path.points[0];
if (!first) return [];
const sampled: TemplatePathPoint[] = [first];
let current = first;
let pointIndex = 1;
let conicIndex = 0;
for (const verb of path.verbs.slice(1)) {
const appendCurve = (factory: (amount: number) => TemplatePathPoint) => {
for (let step = 1; step <= steps; step += 1) sampled.push(factory(step / steps));
current = sampled.at(-1)!;
};
if (verb === 1) {
const to = path.points[pointIndex++];
if (to) appendCurve((amount) => mixPathPoint(current, to, amount));
} else if (verb === 2) {
const control = path.points[pointIndex++];
const to = path.points[pointIndex++];
if (control && to) appendCurve((amount) => quadraticPathPoint(current, control, to, amount));
} else if (verb === 3) {
const control = path.points[pointIndex++];
const to = path.points[pointIndex++];
const weight = path.conicWeights[conicIndex++] ?? 1;
if (control && to) appendCurve((amount) => conicPathPoint(current, control, to, weight, amount));
} else if (verb === 4) {
const controlA = path.points[pointIndex++];
const controlB = path.points[pointIndex++];
const to = path.points[pointIndex++];
if (controlA && controlB && to) appendCurve((amount) => cubicPathPoint(current, controlA, controlB, to, amount));
} else if (verb === 5) appendCurve((amount) => mixPathPoint(current, first, amount));
}
return path.reversed ? sampled.reverse() : sampled;
}
function pathLengths(points: readonly TemplatePathPoint[]) {
const lengths = [0];
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1]!;
const current = points[index]!;
lengths.push(lengths[index - 1]! + Math.hypot(current.x - previous.x, current.y - previous.y));
}
return lengths;
}
function pointOnSampledPath(points: readonly TemplatePathPoint[], lengths: readonly number[], distance: number) {
const total = lengths.at(-1) ?? 0;
const target = Math.max(0, Math.min(total, distance));
let index = 1;
while (index < lengths.length && lengths[index]! < target) index += 1;
const current = points[Math.min(index, points.length - 1)]!;
const previous = points[Math.max(0, index - 1)]!;
const start = lengths[Math.max(0, index - 1)] ?? 0;
const end = lengths[Math.min(index, lengths.length - 1)] ?? start;
const amount = end > start ? (target - start) / (end - start) : 0;
return {
angle: Math.atan2(current.y - previous.y, current.x - previous.x),
...mixPathPoint(previous, current, amount),
};
}
function pathMargin(value: number, length: number) {
return Math.abs(value) <= 1 ? value * length : value;
}
export function textTextureCell(layout: TextTemplateFillTextureLayout, glyphIndex: number) {
const columns = Math.max(1, Math.trunc(layout.columns));
const rows = Math.max(1, Math.trunc(layout.rows));
const ids = layout.idList.length > 0 ? layout.idList : [0];
const rawId = Math.trunc(ids[Math.max(0, glyphIndex) % ids.length] ?? 0);
const cellId = ((rawId % (columns * rows)) + columns * rows) % (columns * rows);
return { column: cellId % columns, row: Math.floor(cellId / columns) };
}
function setGlyphTextureFill(
context: CanvasRenderingContext2D,
image: HTMLImageElement,
layout: TextTemplateFillTextureLayout,
glyphIndex: number,
bounds: { height: number; left: number; top: number; width: number },
) {
const sourceWidth = image.naturalWidth || image.width;
const sourceHeight = image.naturalHeight || image.height;
const pattern = sourceWidth > 0 && sourceHeight > 0 ? context.createPattern(image, "no-repeat") : null;
if (!pattern) return false;
const cell = textTextureCell(layout, glyphIndex);
const cellWidth = sourceWidth / Math.max(1, layout.columns);
const cellHeight = sourceHeight / Math.max(1, layout.rows);
const scaleX = Math.max(1, bounds.width) / cellWidth;
const scaleY = Math.max(1, bounds.height) / cellHeight;
pattern.setTransform({
a: scaleX,
b: 0,
c: 0,
d: scaleY,
e: bounds.left - cell.column * cellWidth * scaleX,
f: bounds.top - cell.row * cellHeight * scaleY,
});
context.fillStyle = pattern;
return true;
}
function drawTextOnTemplatePath(
context: CanvasRenderingContext2D,
text: string,
path: TextTemplateTextPath,
letterSpacing: number,
stroke: boolean,
fillGlyph?: (character: string, glyphIndex: number, width: number) => void,
) {
const sampled = sampleTemplateTextPath(path);
const lengths = pathLengths(sampled);
const total = lengths.at(-1) ?? 0;
if (sampled.length < 2 || total <= 0) return;
const glyphs = Array.from(text.replaceAll("\n", " ")).map((character) => ({ character, width: context.measureText(character).width }));
const textLength = glyphs.reduce((sum, glyph, index) => sum + glyph.width + (index === 0 ? 0 : letterSpacing), 0);
const firstMargin = Math.max(0, pathMargin(path.firstMargin, total));
const lastMargin = Math.max(0, pathMargin(path.lastMargin, total));
const available = Math.max(0, total - firstMargin - lastMargin);
let cursor = firstMargin + (path.forceAlignment || path.circle ? Math.max(0, available - textLength) / 2 : 0);
let textureGlyphIndex = 0;
for (const glyph of glyphs) {
const center = cursor + glyph.width / 2;
if (center > total - lastMargin) break;
const point = pointOnSampledPath(sampled, lengths, center);
context.save();
context.translate(point.x, point.y);
if (path.perpendicular) context.rotate(point.angle);
context.textAlign = "center";
context.textBaseline = "middle";
if (stroke) context.strokeText(glyph.character, 0, 0);
if (fillGlyph && !/\s/u.test(glyph.character)) fillGlyph(glyph.character, textureGlyphIndex++, glyph.width);
else context.fillText(glyph.character, 0, 0);
context.restore();
cursor += glyph.width + letterSpacing;
}
}
function drawTexturedTemplateLine(
context: CanvasRenderingContext2D,
line: string,
align: TextTemplateTextLayer["align"],
letterSpacing: number,
stroke: boolean,
patternImage: HTMLImageElement,
textureLayout: TextTemplateFillTextureLayout,
firstGlyphIndex: number,
) {
const glyphs = Array.from(line).map((character) => ({ character, width: context.measureText(character).width }));
const lineWidth = glyphs.reduce((sum, glyph, index) => sum + glyph.width + (index === 0 ? 0 : letterSpacing), 0);
let cursor = align === "left" ? 0 : align === "right" ? -lineWidth : -lineWidth / 2;
let textureGlyphIndex = firstGlyphIndex;
const fallbackFill = context.fillStyle;
context.textAlign = "center";
context.textBaseline = "middle";
for (const glyph of glyphs) {
const center = cursor + glyph.width / 2;
if (stroke) context.strokeText(glyph.character, center, 0);
if (!/\s/u.test(glyph.character)) {
context.fillStyle = fallbackFill;
setGlyphTextureFill(context, patternImage, textureLayout, textureGlyphIndex++, {
height: Math.max(1, Number.parseFloat(context.font) || 1),
left: cursor,
top: -(Number.parseFloat(context.font) || 1) / 2,
width: glyph.width,
});
}
context.fillText(glyph.character, center, 0);
cursor += glyph.width + letterSpacing;
}
context.fillStyle = fallbackFill;
return textureGlyphIndex;
}
function drawTemplateTextLayer(
context: CanvasRenderingContext2D,
element: CanvasState["elements"][number],
layer: TextTemplateTextLayer,
resourceImages: Readonly<Record<string, HTMLImageElement>>,
) {
const editable = layer.editable;
const fontId = editable ? fontIdForTextElement(element) ?? layer.fontId : layer.fontId;
const fontSize = editable ? element.font_size ?? layer.fontSize : layer.fontSize;
const lineHeight = editable ? styleValue(element, "line_height", layer.lineHeight) : layer.lineHeight;
const letterSpacing = editable ? styleValue(element, "letter_spacing", layer.letterSpacing) : layer.letterSpacing;
const align = (editable ? styleValue(element, "text_align", layer.align) : layer.align) as TextTemplateTextLayer["align"];
const lines = (layer.contentLinked ? element.content ?? layer.text : layer.text).split("\n");
context.save();
context.globalAlpha *= layer.alpha;
context.translate(layer.x, layer.y);
context.rotate(layer.rotation * Math.PI / 180);
context.scale(layer.scaleX, layer.scaleY);
context.font = `${fontSize}px "${fontFamilyName(fontId)}"`;
(context as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`;
context.textAlign = align;
context.textBaseline = "middle";
const widths = lines.map((line) => context.measureText(line).width + Math.max(0, line.length - 1) * letterSpacing);
const textWidth = Math.max(1, ...widths);
const textHeight = Math.max(fontSize * lineHeight, lines.length * fontSize * lineHeight);
const padding = 16;
const backgroundEnabled = styleValue(element, "background_enabled", false);
if (backgroundEnabled) {
const elementOpacity = context.globalAlpha;
context.globalAlpha = elementOpacity * styleValue(element, "background_opacity", 1);
context.fillStyle = styleValue(element, "background_color", "#FFE62C");
context.fillRect(-textWidth / 2 - padding, -textHeight / 2 - padding, textWidth + padding * 2, textHeight + padding * 2);
context.globalAlpha = elementOpacity;
}
const firstY = -((lines.length - 1) * fontSize * lineHeight) / 2;
const anchorX = align === "left" ? -textWidth / 2 : align === "right" ? textWidth / 2 : 0;
context.fillStyle = styleValue(element, "fill_color", "#111111");
context.strokeStyle = styleValue(element, "stroke_color", "#000000");
context.lineWidth = styleValue(element, "stroke_width", 0);
lines.forEach((line, index) => {
const y = firstY + index * fontSize * lineHeight;
if (styleValue(element, "stroke_enabled", false) && context.lineWidth > 0) context.strokeText(line, anchorX, y);
context.fillText(line, anchorX, y);
const widths = lines.map((line) => context.measureText(line).width + Math.max(0, Array.from(line).length - 1) * letterSpacing);
const textWidth = boundedTextTemplateWidth(Math.max(1, ...widths), layer.width);
const layout = textTemplateBoxLayout({
align,
anchorX: layer.anchorX,
anchorY: layer.anchorY,
boxHeight: layer.height,
boxWidth: layer.width,
fontSize,
lineCount: lines.length,
lineHeight,
verticalAlign: layer.verticalAlign,
});
if (editable && styleValue(element, "background_enabled", false)) {
const alpha = context.globalAlpha;
context.globalAlpha = alpha * styleValue(element, "background_opacity", 1);
context.fillStyle = styleValue(element, "background_color", "#FFE62C");
const backgroundLeft = align === "left" ? layout.x : align === "right" ? layout.x - textWidth : layout.x - textWidth / 2;
context.fillRect(backgroundLeft - 16, layout.firstY - fontSize / 2 - 16, textWidth + 32, layout.blockHeight + 32);
context.globalAlpha = alpha;
}
context.shadowColor = layer.shadowColor;
context.shadowBlur = layer.shadowBlur;
context.shadowOffsetX = layer.shadowOffsetX;
context.shadowOffsetY = layer.shadowOffsetY;
const fillOverridden = editable && styleValue(element, "template_fill_overridden", false);
const patternImage = !fillOverridden && layer.fillPatternAssetId ? resourceImages[layer.fillPatternAssetId] : undefined;
context.fillStyle = patternImage && !layer.fillTextureLayout ? context.createPattern(patternImage, "repeat") ?? layer.fillColor
: editable ? styleValue(element, "fill_color", layer.fillColor) : layer.fillColor;
context.strokeStyle = editable ? styleValue(element, "stroke_color", layer.strokeColor) : layer.strokeColor;
context.lineWidth = editable ? styleValue(element, "stroke_width", layer.strokeWidth) : layer.strokeWidth;
const stroke = (editable ? styleValue(element, "stroke_enabled", layer.strokeWidth > 0) : layer.strokeWidth > 0) && context.lineWidth > 0;
if (layer.textPath) {
(context as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = "0px";
const fallbackFill = context.fillStyle;
drawTextOnTemplatePath(context, lines.join(" "), layer.textPath, letterSpacing, stroke,
patternImage && layer.fillTextureLayout
? (character, glyphIndex, width) => {
context.fillStyle = fallbackFill;
setGlyphTextureFill(context, patternImage, layer.fillTextureLayout!, glyphIndex, {
height: fontSize, left: -width / 2, top: -fontSize / 2, width,
});
context.fillText(character, 0, 0);
}
: undefined);
context.restore();
return;
}
if (patternImage && layer.fillTextureLayout) {
(context as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = "0px";
let glyphIndex = 0;
lines.forEach((line, index) => {
const y = layout.firstY + index * layout.lineAdvance;
const naturalWidth = context.measureText(line).width + Math.max(0, Array.from(line).length - 1) * letterSpacing;
const horizontalScale = Math.min(1, layer.width / Math.max(1, naturalWidth));
context.save();
context.translate(layout.x, y);
context.scale(horizontalScale, 1);
glyphIndex = drawTexturedTemplateLine(context, line, align, letterSpacing, stroke,
patternImage, layer.fillTextureLayout!, glyphIndex);
context.restore();
});
context.restore();
return;
}
lines.forEach((line, index) => {
const y = layout.firstY + index * layout.lineAdvance;
if (stroke) context.strokeText(line, layout.x, y, layer.width);
context.fillText(line, layout.x, y, layer.width);
});
context.restore();
}
function drawTextTemplate(
context: CanvasRenderingContext2D,
element: CanvasState["elements"][number],
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>,
resourceImages: Readonly<Record<string, HTMLImageElement>>,
) {
const template = textTemplateById(element.template_or_asset_id);
if (!template || textTemplateFontOptions(template.templateId).some((font) => fontStatuses[font.fontId] !== "ready")) {
drawDynamicUnavailable(context, "原版字体不可用");
return;
}
const layers = [
...template.renderModel.imageLayers.map((layer) => ({ kind: "image" as const, layer })),
...template.renderModel.particleLayers.map((layer) => ({ kind: "particles" as const, layer })),
...template.renderModel.textLayers.map((layer) => ({ kind: "text" as const, layer })),
].toSorted((left, right) => left.layer.order - right.layer.order);
for (const item of layers) {
if (item.kind === "image") drawTemplateImageLayer(context, item.layer, resourceImages);
else if (item.kind === "particles") drawTemplateParticles(context, item.layer, resourceImages);
else drawTemplateTextLayer(context, element, item.layer, resourceImages);
}
}
function drawDynamicUnavailable(context: CanvasRenderingContext2D, message: string) {
@@ -182,22 +618,24 @@ function elementSelectionHalfSize(
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>,
) {
if (element.type === "color_card") {
if (element.style_id === "style_01") return { height: 76 * element.scale.y, width: 26 * element.scale.x };
if (element.style_id === "style_02") return { height: 77 * element.scale.y, width: 18 * element.scale.x };
if (element.style_id === "style_08") return { height: 10 * element.scale.y, width: 73 * element.scale.x };
return { height: 9 * element.scale.y, width: 78 * element.scale.x };
const half = COLOR_CARD_HALF_SIZES[element.style_id ?? ""] ?? { height: 24, width: 78 };
return { height: half.height * element.scale.y, width: half.width * element.scale.x };
}
if (element.type === "dynamic_sticker") {
const model = DYNAMIC_RENDER_MODELS[element.template_or_asset_id as DynamicTemplateId];
return { height: (model?.halfSize.height ?? 62) * element.scale.y, width: (model?.halfSize.width ?? 170) * element.scale.x };
}
if (element.type !== "text_template") return { height: 78 * element.scale.y, width: 78 * element.scale.x };
const template = textTemplateById(element.template_or_asset_id);
const fontId = fontIdForTextElement(element);
if (!fontId || fontStatuses[fontId] !== "ready") return { height: 34 * element.scale.y, width: 110 * element.scale.x };
context.save();
const geometry = measureTextElement(context, element, fontId);
context.restore();
return { height: geometry.height * element.scale.y / 2, width: geometry.width * element.scale.x / 2 };
return {
height: Math.max(geometry.height / 2, template?.renderModel.halfSize.height ?? 0) * element.scale.y,
width: Math.max(geometry.width / 2, template?.renderModel.halfSize.width ?? 0) * element.scale.x,
};
}
function drawElement(
@@ -226,7 +664,7 @@ function drawElement(
const height = image.naturalHeight * scale;
context.drawImage(image, -width / 2, -height / 2, width, height);
}
} else if (element.type === "text_template") drawTextElement(context, element, fontStatuses);
} else if (element.type === "text_template") drawTextTemplate(context, element, fontStatuses, resourceImages);
else if (element.type === "dynamic_sticker") drawDynamicSticker(context, element, fontStatuses, resourceImages);
context.restore();
}
@@ -240,6 +678,27 @@ function loadCanvasImage(url: string) {
});
}
type CanvasImageLoader = (url: string) => Promise<HTMLImageElement | undefined>;
interface SceneResources {
background: HTMLImageElement | undefined;
resourceImages: Readonly<Record<string, HTMLImageElement>>;
}
function createCachedCanvasImageLoader(): CanvasImageLoader {
const cache = new Map<string, Promise<HTMLImageElement | undefined>>();
return (url) => {
const cached = cache.get(url);
if (cached) return cached;
const pending = loadCanvasImage(url).then((image) => {
if (!image) cache.delete(url);
return image;
});
cache.set(url, pending);
return pending;
};
}
function resourceUrlsForCanvas(canvasState: CanvasState) {
const imageReferences = new Map<string, string>();
for (const element of canvasState.elements) {
@@ -248,15 +707,26 @@ function resourceUrlsForCanvas(canvasState: CanvasState) {
const model = DYNAMIC_RENDER_MODELS[element.template_or_asset_id as DynamicTemplateId];
for (const layer of model?.imageLayers ?? []) imageReferences.set(layer.assetId, dynamicImageUrl(element.resource_version, layer.assetId));
}
if (element.type === "text_template") {
for (const [assetId, url] of textTemplateImageUrls(element.template_or_asset_id, element.resource_version)) imageReferences.set(assetId, url);
}
}
return imageReferences;
}
async function loadSceneResources(canvasState: CanvasState, projectId: string) {
function sceneResourceKey(canvasState: CanvasState, projectId: string) {
const background = canvasState.background.asset_id
? loadCanvasImage(`/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(canvasState.background.asset_id)}`)
? `/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(canvasState.background.asset_id)}`
: null;
const resources = [...resourceUrlsForCanvas(canvasState)].toSorted(([left], [right]) => left.localeCompare(right));
return JSON.stringify({ background, projectId, resources });
}
async function loadSceneResources(canvasState: CanvasState, projectId: string, loadImage: CanvasImageLoader = loadCanvasImage): Promise<SceneResources> {
const background = canvasState.background.asset_id
? loadImage(`/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(canvasState.background.asset_id)}`)
: Promise.resolve(undefined);
const resources = Promise.all([...resourceUrlsForCanvas(canvasState)].map(async ([assetId, url]) => [assetId, await loadCanvasImage(url)] as const));
const resources = Promise.all([...resourceUrlsForCanvas(canvasState)].map(async ([assetId, url]) => [assetId, await loadImage(url)] as const));
const [image, loaded] = await Promise.all([background, resources]);
return {
background: image,
@@ -274,9 +744,41 @@ function renderEditorScene(
context.clearRect(0, 0, canvasState.pixel_width, canvasState.pixel_height);
context.fillStyle = "#ffffff";
context.fillRect(0, 0, canvasState.pixel_width, canvasState.pixel_height);
context.filter = cssFilterForBackground(canvasState.background.adjustments);
if (background) context.drawImage(background, 0, 0, canvasState.pixel_width, canvasState.pixel_height);
context.filter = "none";
if (background) {
const plan = backgroundDrawPlan(
{ height: background.naturalHeight || background.height, width: background.naturalWidth || background.width },
{ height: canvasState.pixel_height, width: canvasState.pixel_width },
canvasState.background.adjustments,
);
context.save();
context.filter = cssFilterForBackground(canvasState.background.adjustments);
context.drawImage(
background,
plan.source.x,
plan.source.y,
plan.source.width,
plan.source.height,
plan.destination.x,
plan.destination.y,
plan.destination.width,
plan.destination.height,
);
context.restore();
if (canvasState.background.adjustments.temperature !== 0 || canvasState.background.adjustments.sharpness !== 0) {
const x = Math.max(0, Math.floor(plan.destination.x));
const y = Math.max(0, Math.floor(plan.destination.y));
const right = Math.min(canvasState.pixel_width, Math.ceil(plan.destination.x + plan.destination.width));
const bottom = Math.min(canvasState.pixel_height, Math.ceil(plan.destination.y + plan.destination.height));
const width = right - x;
const height = bottom - y;
if (width > 0 && height > 0) {
const imageData = context.getImageData(x, y, width, height);
imageData.data.set(adjustBackgroundPixels(imageData.data, width, height, canvasState.background.adjustments));
context.putImageData(imageData, x, y);
}
}
}
for (const element of [...canvasState.elements].sort((left, right) => left.z_index - right.z_index)) {
drawElement(context, element, canvasState.pixel_width, canvasState.pixel_height, fontStatuses, resourceImages);
}
@@ -284,7 +786,10 @@ function renderEditorScene(
function requiredFontIds(canvasState: CanvasState) {
return canvasState.elements.flatMap((element) => {
if (element.type === "text_template") return [fontIdForTextElement(element)].filter((fontId): fontId is string => Boolean(fontId));
if (element.type === "text_template") return [...new Set([
...textTemplateFontOptions(element.template_or_asset_id).map((font) => font.fontId),
...[fontIdForTextElement(element)].filter((fontId): fontId is string => Boolean(fontId)),
])];
if (element.type === "dynamic_sticker") return dynamicFontOptionsFor(element.template_or_asset_id).map((font) => font.fontId);
return [];
});
@@ -314,16 +819,29 @@ export function EditorStage(props: EditorStageProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const gestureRef = useRef<Gesture | undefined>(undefined);
const longPressRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const imageLoaderRef = useRef<CanvasImageLoader | undefined>(undefined);
const [sceneResources, setSceneResources] = useState<{ key: string; resources: SceneResources }>();
const [marquee, setMarquee] = useState<CanvasRect>();
const resourceKey = sceneResourceKey(props.canvasState, props.projectId);
if (!imageLoaderRef.current) imageLoaderRef.current = createCachedCanvasImageLoader();
useEffect(() => {
let active = true;
void loadSceneResources(props.canvasState, props.projectId, imageLoaderRef.current).then((resources) => {
if (active) setSceneResources({ key: resourceKey, resources });
});
return () => { active = false; };
}, [props.projectId, resourceKey]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return undefined;
canvas.width = props.canvasState.pixel_width;
canvas.height = props.canvasState.pixel_height;
if (canvas.width !== props.canvasState.pixel_width) canvas.width = props.canvasState.pixel_width;
if (canvas.height !== props.canvasState.pixel_height) canvas.height = props.canvasState.pixel_height;
const context = canvas.getContext("2d");
if (!context) return undefined;
if (!sceneResources || sceneResources.key !== resourceKey) return undefined;
const render = (image: HTMLImageElement | undefined, resourceImages: Readonly<Record<string, HTMLImageElement>>) => {
renderEditorScene(context, props.canvasState, props.fontStatuses, image, resourceImages);
context.lineWidth = 4;
@@ -345,21 +863,22 @@ export function EditorStage(props: EditorStageProps) {
if (marquee) context.strokeRect(marquee.x * canvas.width, marquee.y * canvas.height, marquee.width * canvas.width, marquee.height * canvas.height);
context.restore();
};
void loadSceneResources(props.canvasState, props.projectId).then(({ background, resourceImages }) => {
if (!active) return;
render(background, resourceImages);
});
return () => { active = false; };
}, [marquee, props.assetId, props.canvasState, props.fontStatuses, props.guides, props.projectId, props.selectedIds]);
render(sceneResources.resources.background, sceneResources.resources.resourceImages);
return undefined;
}, [marquee, props.canvasState, props.fontStatuses, props.guides, props.selectedIds, resourceKey, sceneResources]);
useEffect(() => () => { if (longPressRef.current) clearTimeout(longPressRef.current); }, []);
function handlePointerDown(event: PointerEvent<HTMLCanvasElement>) {
if (event.button !== 0) return;
const start = pointFromEvent(event);
const bounds = event.currentTarget.getBoundingClientRect();
const start = pointFromClient(event.clientX, event.clientY, bounds);
const append = event.shiftKey;
const hit = props.onSelect(start, append);
gestureRef.current = { append, hit, longPressOpened: false, pointerId: event.pointerId, start };
gestureRef.current = {
append, bounds, hit, longPressOpened: false, moved: false, pointerId: event.pointerId, start,
startClient: { x: event.clientX, y: event.clientY },
};
event.currentTarget.setPointerCapture(event.pointerId);
longPressRef.current = setTimeout(() => {
const gesture = gestureRef.current;
@@ -375,9 +894,15 @@ export function EditorStage(props: EditorStageProps) {
props.onPointerMoved();
return;
}
const point = pointFromEvent(event);
const clientDistance = Math.hypot(event.clientX - gesture.startClient.x, event.clientY - gesture.startClient.y);
if (!gesture.moved) {
if (clientDistance < DRAG_THRESHOLD_PX) return;
gesture.moved = true;
gesture.longPressOpened = false;
props.onDragStart();
}
const point = pointFromClient(event.clientX, event.clientY, gesture.bounds);
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
if (Math.abs(delta.x) + Math.abs(delta.y) < 0.003) return;
if (longPressRef.current) clearTimeout(longPressRef.current);
props.onPointerMoved();
if (gesture.hit && !gesture.longPressOpened) props.onMovePreview(delta);
@@ -388,11 +913,18 @@ export function EditorStage(props: EditorStageProps) {
const gesture = gestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return;
if (longPressRef.current) clearTimeout(longPressRef.current);
const point = pointFromEvent(event);
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
const moved = Math.abs(delta.x) + Math.abs(delta.y) >= 0.003;
const clientDistance = Math.hypot(event.clientX - gesture.startClient.x, event.clientY - gesture.startClient.y);
const moved = gesture.moved || clientDistance >= DRAG_THRESHOLD_PX;
if (moved && !gesture.moved && !gesture.longPressOpened) {
const point = pointFromClient(event.clientX, event.clientY, gesture.bounds);
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
if (gesture.hit) props.onMovePreview(delta);
}
if (gesture.hit && moved && !gesture.longPressOpened) props.onMoveCommit();
else if (!gesture.hit && moved) props.onMarquee({ height: delta.y, width: delta.x, x: gesture.start.x, y: gesture.start.y }, gesture.append);
else if (!gesture.hit && moved) {
const point = pointFromClient(event.clientX, event.clientY, gesture.bounds);
props.onMarquee({ height: point.y - gesture.start.y, width: point.x - gesture.start.x, x: gesture.start.x, y: gesture.start.y }, gesture.append);
}
setMarquee(undefined);
gestureRef.current = undefined;
event.currentTarget.releasePointerCapture(event.pointerId);
+1 -1
View File
@@ -922,7 +922,7 @@ export type ReverseGeocodeRequest = {
export type ReverseGeocodeResponse = {
"formatted_value": string;
"service_mode": "mock";
"service_mode": "mock" | "real";
"status": "resolved";
};
File diff suppressed because it is too large Load Diff
+155
View File
@@ -0,0 +1,155 @@
:root {
--dada-interaction-duration: 120ms;
--dada-interaction-easing: cubic-bezier(0.2, 0.8, 0.2, 1);
}
:where(button:not(:disabled), a[href], label:has(input:not(:disabled))) {
cursor: pointer;
}
:where(button:not(:disabled), a[href]) {
transition:
transform var(--dada-interaction-duration) var(--dada-interaction-easing),
box-shadow var(--dada-interaction-duration) var(--dada-interaction-easing),
border-color var(--dada-interaction-duration) ease,
background-color var(--dada-interaction-duration) ease,
color var(--dada-interaction-duration) ease,
opacity var(--dada-interaction-duration) ease;
}
:where(input:not(:disabled), select:not(:disabled), textarea:not(:disabled)) {
transition:
border-color var(--dada-interaction-duration) ease,
box-shadow var(--dada-interaction-duration) ease,
background-color var(--dada-interaction-duration) ease;
}
:where(.product-page, .product-loading) :focus-visible {
outline: 2px solid #005fcc;
outline-offset: 3px;
}
:where(input:not(:disabled), select:not(:disabled), textarea:not(:disabled)):focus-visible {
border-color: #005fcc;
box-shadow: 0 0 0 3px rgb(0 95 204 / 16%);
}
.project-card,
.project-preview img,
.ratio-control span,
.reference-input,
.editor-sticker-preview,
.editor-template-mark,
.editor-template-preview,
.editor-color-card-preview,
.editor-dynamic-preview,
.editor-source-preview-canvas,
.editor-thumb {
transition:
transform var(--dada-interaction-duration) var(--dada-interaction-easing),
box-shadow var(--dada-interaction-duration) var(--dada-interaction-easing),
border-color var(--dada-interaction-duration) ease,
background-color var(--dada-interaction-duration) ease;
}
@media (hover: hover) and (pointer: fine) {
:where(button:not(:disabled)):hover {
border-color: #111111;
box-shadow: 0 2px 0 rgb(17 17 17 / 35%);
}
:where(a[href]):hover {
color: #005fcc;
opacity: 0.78;
text-decoration-thickness: 2px;
text-underline-offset: 3px;
}
.product-header nav a:hover {
color: #111111;
background: #e9e9e5;
box-shadow: inset 0 -3px #111111;
opacity: 1;
}
.product-header nav a[aria-current="page"]:hover {
background: #f2f500;
}
.ratio-control label:hover span,
.reference-input:hover {
border-color: #111111;
background: #ffffd6;
box-shadow: inset 0 -3px #111111;
}
.project-card:hover {
border-color: #111111;
box-shadow: 0 3px 0 rgb(17 17 17 / 22%);
}
.editor-asset-tabs button:not(:disabled):hover,
.editor-source:hover,
.editor-sticker-grid button:not(:disabled):hover,
.editor-template-categories button:not(:disabled):hover,
.editor-template-grid button:not(:disabled):hover,
.editor-provider-grid button:not(:disabled):hover,
.editor-candidates button:not(:disabled):hover {
border-color: #111111;
background: #ffffd6;
}
:where(input:not(:disabled), select:not(:disabled), textarea:not(:disabled)):hover {
border-color: #111111;
}
}
@media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) {
:where(button:not(:disabled), a[href]):hover {
transform: translateY(-1px);
}
.project-card:hover .project-preview img,
.editor-sticker-grid button:not(:disabled):hover .editor-sticker-preview {
transform: scale(1.02);
}
.editor-template-grid button:not(:disabled):hover .editor-template-mark,
.editor-template-grid button:not(:disabled):hover .editor-template-preview,
.editor-provider-grid button:not(:disabled):hover > :first-child {
transform: scale(1.03);
}
}
@media (prefers-reduced-motion: no-preference) {
:where(button:not(:disabled), a[href]):active {
transform: translateY(1px);
transition-duration: 45ms;
}
}
:where(button:not(:disabled)):active {
box-shadow: inset 0 2px 0 rgb(17 17 17 / 24%);
}
:where(a[href]):active {
opacity: 0.58;
}
@media (prefers-reduced-motion: reduce) {
:where(button, a[href], input, select, textarea),
.project-card,
.project-preview img,
.ratio-control span,
.reference-input,
.editor-sticker-preview,
.editor-template-mark,
.editor-template-preview,
.editor-color-card-preview,
.editor-dynamic-preview,
.editor-source-preview-canvas,
.editor-thumb {
animation-duration: 0s !important;
transition-duration: 0s !important;
}
}
+2
View File
@@ -16,6 +16,8 @@ import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.
import { EditorPage } from "./editor-page.js";
import { AdminOverviewPage, AdminPlaceholderPage, AdminProtectedRoute } from "./admin-shell.js";
import "./interaction-feedback.css";
const root = document.getElementById("root");
if (!root) {
+125
View File
@@ -27,6 +27,17 @@ export const COLOR_CARD_SOURCE_GEOMETRY = {
style_16: { bounds: { bottom: 9, left: -78, right: 78, top: -9 } },
} as const;
export const COLOR_CARD_HALF_SIZES: Readonly<Record<string, { height: number; width: number }>> = {
style_01: { height: 76, width: 26 }, style_02: { height: 77, width: 18 },
style_03: { height: 75, width: 22 }, style_04: { height: 75, width: 18 },
style_05: { height: 18, width: 78 }, style_06: { height: 16, width: 78 },
style_07: { height: 20, width: 78 }, style_08: { height: 10, width: 73 },
style_09: { height: 75, width: 58 }, style_10: { height: 75, width: 60 },
style_11: { height: 18, width: 78 }, style_12: { height: 34, width: 70 },
style_13: { height: 75, width: 18 }, style_14: { height: 28, width: 60 },
style_15: { height: 16, width: 78 }, style_16: { height: 9, width: 78 },
};
function normalizedHex(value: string) {
return value.toUpperCase();
}
@@ -139,6 +150,65 @@ export function drawColorCard(context: CanvasRenderingContext2D, element: Canvas
});
return;
}
if (element.style_id === "style_03") {
colors.forEach((color, index) => {
context.fillStyle = color;
context.fillRect(-22, -75 + index * 30, 44, 29);
});
return;
}
if (element.style_id === "style_04") {
context.strokeStyle = "#ffffff";
context.lineWidth = 2;
context.beginPath();
context.moveTo(0, -75);
context.lineTo(0, 75);
context.stroke();
colors.forEach((color, index) => {
context.fillStyle = color;
context.beginPath();
context.arc(0, -60 + index * 30, 9, 0, Math.PI * 2);
context.fill();
});
return;
}
if (["style_05", "style_06", "style_07", "style_11"].includes(element.style_id ?? "")) {
if (element.style_id === "style_11") {
context.strokeStyle = "#ffffff";
context.lineWidth = 3;
context.strokeRect(-78, -18, 156, 36);
}
colors.forEach((color, index) => {
const left = -72 + index * 29;
context.fillStyle = color;
context.fillRect(left, -10, 28, 20);
if (element.style_id === "style_07") {
context.fillStyle = "#ffffff";
context.beginPath();
context.arc(left + 14, -16, 4, 0, Math.PI * 2);
context.fill();
}
});
if (element.style_id === "style_05") {
context.fillStyle = "#111111";
context.fillRect(-78, -18, 20, 36);
context.fillStyle = "#ffffff";
context.font = "700 7px Arial, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText("C", -68, 0);
}
if (element.style_id === "style_06") {
context.fillStyle = "#ffffff";
context.beginPath();
context.moveTo(-4, -16);
context.lineTo(4, -16);
context.lineTo(0, -10);
context.closePath();
context.fill();
}
return;
}
if (element.style_id === "style_08") {
colors.forEach((color, index) => {
const left = -73 + index * 29.2;
@@ -159,6 +229,61 @@ export function drawColorCard(context: CanvasRenderingContext2D, element: Canvas
});
return;
}
if (element.style_id === "style_09" || element.style_id === "style_10") {
context.fillStyle = "#ffffff";
context.fillRect(-58, -75, 116, 150);
colors.forEach((color, index) => {
context.fillStyle = color;
context.fillRect(-50, -64 + index * 23, 100, 22);
});
context.fillStyle = "#111111";
context.font = "700 8px Arial, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText(element.style_id === "style_09" ? "COLOR PALETTE" : "FIVE COLORS", 0, 63);
return;
}
if (element.style_id === "style_12") {
const positions = [[-52, -18], [0, -18], [52, -18], [-26, 18], [26, 18]] as const;
colors.forEach((color, index) => {
const position = positions[index]!;
context.fillStyle = color;
context.fillRect(position[0] - 24, position[1] - 14, 48, 28);
});
return;
}
if (element.style_id === "style_13") {
colors.forEach((color, index) => {
context.fillStyle = color;
context.beginPath();
context.arc(0, -60 + index * 30, 11, 0, Math.PI * 2);
context.fill();
});
return;
}
if (element.style_id === "style_14") {
colors.forEach((color, index) => {
context.globalAlpha = 0.9;
context.fillStyle = color;
context.beginPath();
context.arc(-40 + index * 20, 0, 24, 0, Math.PI * 2);
context.fill();
});
context.globalAlpha = 1;
return;
}
if (element.style_id === "style_15") {
colors.forEach((color, index) => {
context.fillStyle = color;
context.strokeStyle = "#ffffff";
context.lineWidth = 3;
context.beginPath();
context.arc(-58 + index * 29, 0, 12, 0, Math.PI * 2);
context.fill();
context.stroke();
});
return;
}
context.fillStyle = "#ffffff";
context.beginPath();
context.moveTo(-78, -9);
+25 -8
View File
@@ -592,15 +592,26 @@
height: 18px;
}
.project-placeholder {
.project-placeholder,
.project-preview {
display: grid;
height: 154px;
grid-template-columns: repeat(4, 1fr);
overflow: hidden;
border-bottom: 1px solid #a5a59f;
background: #d8d8d3;
}
.project-placeholder {
grid-template-columns: repeat(4, 1fr);
}
.project-preview img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.project-placeholder span {
display: grid;
place-items: end center;
@@ -935,9 +946,9 @@
font-size: 19px;
}
.project-current > .project-placeholder {
height: auto;
min-height: 480px;
.project-current > .project-placeholder,
.project-current > .project-preview {
height: 480px;
border: 1px solid #73736d;
}
@@ -945,6 +956,10 @@
font-size: 80px;
}
.project-current > .project-preview img {
object-fit: contain;
}
.project-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -1013,7 +1028,8 @@
padding: 6px;
}
.project-history li .project-placeholder {
.project-history li .project-placeholder,
.project-history li .project-preview {
height: 88px;
border: 0;
}
@@ -1439,8 +1455,9 @@
flex-direction: column;
}
.project-current > .project-placeholder {
min-height: 360px;
.project-current > .project-placeholder,
.project-current > .project-preview {
height: 360px;
}
.local-only-footer {
+56 -4
View File
@@ -27,6 +27,7 @@ interface LocalDataPayload {
}
interface AccountSettingsPayload {
csrf_token: string;
local_data: LocalDataPayload;
}
@@ -231,6 +232,33 @@ function ProjectPlaceholder({ ratio, status }: { ratio: Ratio; status: ProjectSt
);
}
function ProjectPreview({ alt, imageId, loading = "lazy", projectId, ratio, status }: {
alt: string;
imageId: string | null;
loading?: "eager" | "lazy";
projectId: string;
ratio: Ratio;
status: ProjectStatus;
}) {
const [loadFailed, setLoadFailed] = useState(false);
useEffect(() => setLoadFailed(false), [imageId, projectId]);
if (!imageId || loadFailed) return <ProjectPlaceholder ratio={ratio} status={status} />;
return (
<div className="project-preview" data-ratio={ratio} data-status={status}>
<img
alt={alt}
decoding="async"
loading={loading}
onError={() => setLoadFailed(true)}
src={`/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(imageId)}`}
/>
</div>
);
}
export function WorkspacePage() {
const promptId = useId();
const [session, setSession] = useState<SessionPayload>();
@@ -269,7 +297,12 @@ export function WorkspacePage() {
if (!active) return;
if (modelResult.status === "fulfilled") setModels(modelResult.value);
if (taskResult.status === "fulfilled") setCurrentTask(taskResult.value);
if (settingsResult.status === "fulfilled" && settingsResult.value) setLocalData(settingsResult.value.local_data);
const settings = settingsResult.status === "fulfilled" ? settingsResult.value : undefined;
if (settings) {
setLocalData(settings.local_data);
// Account settings rotates the mutation token; keep the workspace token current.
setSession((current) => current ? { ...current, csrf_token: settings.csrf_token } : current);
}
setGenerationStateLoaded(true);
});
}).catch((error) => {
@@ -568,7 +601,13 @@ function ProjectCard({ activeLimitReached, busy, onPurge, onRestore, onSelect, o
/>
</label>
) : null}
<ProjectPlaceholder ratio={project.ratio} status={project.status} />
<ProjectPreview
alt={`${project.name}预览图`}
imageId={project.current_image_id}
projectId={project.project_id}
ratio={project.ratio}
status={project.status}
/>
<div className="project-card-body">
<div><h3 title={project.name}>{project.name}</h3><span>{project.status === "failed_empty" ? "生成失败" : project.status === "trashed" ? "回收站" : "项目"}</span></div>
<p>{project.successful_image_count} · {project.ratio}</p>
@@ -953,7 +992,14 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
<div className="project-detail-grid">
<section className="project-current" aria-labelledby="current-image-title">
<header><h2 id="current-image-title"></h2><span>{project.pixel_width ?? 1080} × {project.pixel_height ?? 1440}</span></header>
<ProjectPlaceholder ratio={project.ratio} status={project.status} />
<ProjectPreview
alt={`${project.name}当前底图`}
imageId={project.current_image_id}
loading="eager"
projectId={project.project_id}
ratio={project.ratio}
status={project.status}
/>
<div className="project-actions">
<button disabled={conflicted || atHistoryLimit} onClick={() => window.location.assign(`/app?continue=${project.project_id}`)} type="button"></button>
{conflicted || !project.current_image_id ? <button disabled type="button"></button> : <a href={`/app/projects/${project.project_id}/editor`}></a>}
@@ -970,7 +1016,13 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
<ol>
{project.images.toReversed().map((image, index) => (
<li key={image.image_id} data-current={image.image_id === project.current_image_id}>
<ProjectPlaceholder ratio={project.ratio} status="active" />
<ProjectPreview
alt={`生成结果 ${project.images.length - index}`}
imageId={image.image_id}
projectId={project.project_id}
ratio={project.ratio}
status="active"
/>
<div><strong> {project.images.length - index}</strong><time dateTime={image.created_at}>{formatUpdatedAt(image.created_at)}</time><a href={`/api/v1/private-assets/projects/${project.project_id}/images/${image.image_id}`}></a></div>
</li>
))}
+2 -1
View File
@@ -5,8 +5,9 @@ import {
type StaticStickerCatalogItem,
type VirtualStickerWindow,
} from "@dada/static-sticker-catalog";
import { P0A_STATIC_STICKER_RELEASE_VERSION } from "@dada/template-registry";
const resourceVersion = "fixture-v1";
const resourceVersion = P0A_STATIC_STICKER_RELEASE_VERSION;
const partCounts = [203, 36, 27, 48, 38, 75, 37, 67, 48, 24, 40, 30, 27, 51, 62, 19, 36, 45, 92, 53, 69, 31, 36, 30, 183] as const;
function buildCatalog(): StaticStickerCatalogItem[] {
+241 -70
View File
@@ -1,12 +1,110 @@
import type { CanvasState } from "@dada/shared-contracts";
import { P0A_REQUIRED_FONT_PANEL_IDS, P0A_TEXT_TEMPLATE_IDS } from "@dada/template-registry";
import { P0A_COMPLEX_RELEASE_VERSION, P0A_REQUIRED_FONT_PANEL_IDS, P0A_TEXT_TEMPLATE_IDS } from "@dada/template-registry";
import type { CanvasElementIdentity } from "./editor-elements.js";
import complexAssetCatalog from "./generated/complex-assets.json";
type CanvasElement = CanvasState["elements"][number];
export type TextTemplateCategory = "flower" | "simple" | "tag" | "title";
export type TextAlign = "center" | "left" | "right";
export type TextVerticalAlign = "bottom" | "middle" | "top";
export interface TextTemplateImageLayer {
alpha: number;
anchorX: number;
anchorY: number;
assetId: string;
height: number;
order: number;
rotation: number;
scaleX: number;
scaleY: number;
width: number;
x: number;
y: number;
ninePatch?: TextTemplateNinePatch;
}
export interface TextTemplateNinePatch {
bottom: number;
left: number;
right: number;
sourceHeight: number;
sourceWidth: number;
top: number;
}
export interface TextTemplateParticleLayer extends TextTemplateImageLayer {
alpha: number;
atlasColumns: number;
atlasRows: number;
color: string;
density: number;
particleHeight: number;
particleWidth: number;
randomizeAngle: number;
randomizePosition: number;
}
export interface TextTemplateTextLayer {
alpha: number;
align: TextAlign;
anchorX: number;
anchorY: number;
contentLinked: boolean;
editable: boolean;
fillColor: string;
fillPatternAssetId?: string;
fillTextureLayout?: TextTemplateFillTextureLayout;
fontId: string;
fontSize: number;
height: number;
letterSpacing: number;
lineHeight: number;
order: number;
rotation: number;
scaleX: number;
scaleY: number;
shadowBlur: number;
shadowColor: string;
shadowOffsetX: number;
shadowOffsetY: number;
strokeColor: string;
strokeWidth: number;
text: string;
textPath?: TextTemplateTextPath;
verticalAlign: TextVerticalAlign;
width: number;
x: number;
y: number;
}
export interface TextTemplateFillTextureLayout {
columns: number;
idList: readonly number[];
rows: number;
}
export interface TextTemplateTextPath {
circle: boolean;
conicWeights: readonly number[];
firstMargin: number;
forceAlignment: boolean;
isAbsoluteMode: boolean;
lastMargin: number;
perpendicular: boolean;
points: readonly { x: number; y: number }[];
reversed: boolean;
verbs: readonly number[];
}
export interface TextTemplateRenderModel {
halfSize: { height: number; width: number };
imageLayers: readonly TextTemplateImageLayer[];
particleLayers: readonly TextTemplateParticleLayer[];
textLayers: readonly TextTemplateTextLayer[];
}
export interface TextTemplateDefinition {
available: boolean;
@@ -17,6 +115,8 @@ export interface TextTemplateDefinition {
defaultText: string;
displayName: string;
fontUrl?: string;
previewUrl?: string;
renderModel: TextTemplateRenderModel;
resourceClass: "parameter_only" | "zip_template";
resourceVersion: string;
templateId: string;
@@ -42,7 +142,10 @@ export interface TextStylePatch {
textAlign?: TextAlign;
}
const fixtureVersion = "wp4-fixture-v1";
const resourceVersion = P0A_COMPLEX_RELEASE_VERSION;
const textResourceRevision = complexAssetCatalog.text_resource_revision;
const publicTextAssetUrl = (assetId: string, version = resourceVersion) =>
`/api/v1/assets/public/${version}/${assetId}?revision=${textResourceRevision}`;
const defaults = {
background_color: "#FFE62C",
background_enabled: false,
@@ -56,85 +159,138 @@ const defaults = {
text_align: "center",
} as const;
type CatalogSeed = [id: string, category: TextTemplateCategory, displayName: string, defaultText: string, defaultFontId: string, available?: boolean, resourceClass?: "parameter_only"];
const textCatalogById = new Map(complexAssetCatalog.text_templates.map((item) => [item.template_id, item]));
const seeds: readonly CatalogSeed[] = [
["FLOWER001", "flower", "春日计划", "春日计划", "FONT011", true],
["FLOWER002", "flower", "笑不活了", "笑不活了", "FLOWER002_FONT"],
["FLOWER003", "flower", "人生照片", "人生照片", "FONT008"],
["FLOWER004", "flower", "我的日常生活", "我的日常生活", "FLOWER004_FONT"],
["FLOWER005", "flower", "碎片生活", "碎片生活", "FONT008"],
["FLOWER006", "flower", "闪光瞬间", "闪光瞬间", "FLOWER006_FONT"],
["FLOWER007", "flower", "好柿花生", "好柿花生", "FONT046", false, "parameter_only"],
["FLOWER008", "flower", "Vlog.", "Vlog.", "FONT005"],
["H001", "title", "电影生活记录", "电影生活记录", "H001_FONT"],
["H002", "title", "30°C", "30°C", "H002_FONT"],
["H003", "title", "生活分享家", "生活分享家", "FONT039", true],
["H004", "title", "快乐充值成功", "快乐充值成功", "FONT046"],
["H005", "title", "日常的镜头", "日常的镜头", "H005_FONT"],
["H006", "title", "慢生活指南", "慢生活指南", "FONT052"],
["H007", "title", "做个有闲人", "做个有闲人", "H007_FONT"],
["H008", "title", "海滩日记", "海滩日记", "H008_FONT"],
["TAG001", "tag", "自定义标签", "自定义标签", "FONT027"],
["TAG002", "tag", "自定义标签", "自定义标签", "FONT043"],
["TAG003", "tag", "打卡x1", "打卡x1", "FONT043"],
["TAG004", "tag", "自定义标签", "自定义标签", "TAG004_FONT"],
["TAG005", "tag", "自定义标签", "自定义标签", "FONT008"],
["TAG006", "tag", "City Walk", "City Walk", "TAG006_FONT"],
["TAG007", "tag", "打卡x1", "打卡x1", "FONT043"],
["TAG051", "tag", "自定义标签", "自定义标签", "FONT022"],
["SIMPLE001", "simple", "碎片回忆录", "碎片回忆录", "SIMPLE001_FONT"],
["SIMPLE002", "simple", "秋天的信笺", "秋天的信笺", "SIMPLE002_FONT"],
["SIMPLE003", "simple", "返航时海鸟追着船盘旋", "返航时海鸟追着船盘旋", "SIMPLE003_FONT"],
["SIMPLE004", "simple", "下段旅程,幸福丰盛。", "下段旅程,幸福丰盛。", "SIMPLE002_FONT"],
["SIMPLE005", "simple", "见信好。", "见信好。", "SIMPLE005_FONT"],
["SIMPLE006", "simple", "万物回春", "万物回春", "SIMPLE001_FONT"],
["SIMPLE007", "simple", "周而复始。", "周而复始。", "SIMPLE007_FONT"],
["SIMPLE008", "simple", "周五愉快", "周五愉快", "SIMPLE008_FONT"],
];
function imageLayer(layer: {
alpha?: number; anchor_x?: number; anchor_y?: number; asset_id: string; height: number; nine_patch?: {
bottom: number; left: number; right: number; source_height: number; source_width: number; top: number;
}; order: number; rotation: number; scale_x: number; scale_y: number; width: number; x: number; y: number;
}): TextTemplateImageLayer {
return {
alpha: layer.alpha ?? 1, anchorX: layer.anchor_x ?? 0.5, anchorY: layer.anchor_y ?? 0.5,
assetId: layer.asset_id, height: layer.height, order: layer.order, rotation: layer.rotation,
scaleX: layer.scale_x, scaleY: layer.scale_y, width: layer.width, x: layer.x, y: layer.y,
...(layer.nine_patch ? { ninePatch: {
bottom: layer.nine_patch.bottom, left: layer.nine_patch.left, right: layer.nine_patch.right,
sourceHeight: layer.nine_patch.source_height, sourceWidth: layer.nine_patch.source_width, top: layer.nine_patch.top,
} } : {}),
};
}
const seedById = new Map(seeds.map((seed) => [seed[0], seed]));
interface RawTextLayer {
alpha?: number; align: string; anchor_x?: number; anchor_y?: number; content_linked?: boolean; editable: boolean; fill_color: string; fill_pattern_asset_id?: string;
fill_texture_layout?: { columns: number; id_list: number[]; rows: number }; font_id: string; font_size: number;
height: number; letter_spacing: number; line_height: number; order: number; rotation: number; scale_x: number; scale_y: number;
shadow_blur: number; shadow_color: string; shadow_offset_x: number; shadow_offset_y: number; stroke_color: string;
stroke_width: number; text: string; text_path?: {
circle: boolean; conic_weights: number[]; first_margin: number; force_alignment: boolean; is_absolute_mode: boolean;
last_margin: number; perpendicular: boolean; points: Array<{ x: number; y: number }>; reversed: boolean; verbs: number[];
}; vertical_align: TextVerticalAlign; width: number; x: number; y: number;
}
function renderModel(item: (typeof complexAssetCatalog.text_templates)[number]): TextTemplateRenderModel {
return {
halfSize: { height: item.render_model.half_size.height, width: item.render_model.half_size.width },
imageLayers: item.render_model.image_layers.map(imageLayer),
particleLayers: item.render_model.particle_layers.map((layer) => ({
...imageLayer(layer), alpha: layer.alpha, atlasColumns: layer.atlas_columns, atlasRows: layer.atlas_rows,
color: layer.color, density: layer.density, particleHeight: layer.particle_height,
particleWidth: layer.particle_width, randomizeAngle: layer.randomize_angle, randomizePosition: layer.randomize_position,
})),
textLayers: item.render_model.text_layers.map((value) => {
const layer = value as unknown as RawTextLayer;
return {
alpha: layer.alpha ?? 1, align: layer.align as TextAlign, anchorX: layer.anchor_x ?? 0.5, anchorY: layer.anchor_y ?? 0.5,
contentLinked: layer.content_linked ?? layer.editable, editable: layer.editable, fillColor: layer.fill_color,
...(layer.fill_pattern_asset_id ? { fillPatternAssetId: layer.fill_pattern_asset_id } : {}),
...(layer.fill_texture_layout ? { fillTextureLayout: {
columns: layer.fill_texture_layout.columns, idList: layer.fill_texture_layout.id_list, rows: layer.fill_texture_layout.rows,
} } : {}),
fontId: layer.font_id, fontSize: layer.font_size, height: layer.height, letterSpacing: layer.letter_spacing,
lineHeight: layer.line_height, order: layer.order, rotation: layer.rotation, scaleX: layer.scale_x,
scaleY: layer.scale_y, shadowBlur: layer.shadow_blur, shadowColor: layer.shadow_color,
shadowOffsetX: layer.shadow_offset_x, shadowOffsetY: layer.shadow_offset_y,
strokeColor: layer.stroke_color, strokeWidth: layer.stroke_width, text: layer.text,
...(layer.text_path ? { textPath: {
circle: layer.text_path.circle, conicWeights: layer.text_path.conic_weights,
firstMargin: layer.text_path.first_margin, forceAlignment: layer.text_path.force_alignment,
isAbsoluteMode: layer.text_path.is_absolute_mode, lastMargin: layer.text_path.last_margin,
perpendicular: layer.text_path.perpendicular, points: layer.text_path.points,
reversed: layer.text_path.reversed, verbs: layer.text_path.verbs,
} } : {}),
verticalAlign: layer.vertical_align, width: layer.width, x: layer.x, y: layer.y,
};
}),
};
}
export const P0A_TEXT_TEMPLATES: readonly TextTemplateDefinition[] = P0A_TEXT_TEMPLATE_IDS.map((templateId, catalogOrder) => {
const seed = seedById.get(templateId);
if (!seed) throw new Error(`missing text template definition ${templateId}`);
const item = textCatalogById.get(templateId);
if (!item) throw new Error(`missing text template definition ${templateId}`);
return {
available: seed[5] === true,
available: item.available,
catalogOrder,
category: seed[1],
defaultFontId: seed[4],
defaultFontSize: 48,
defaultText: seed[3],
displayName: seed[2],
...(seed[5] === true ? { fontUrl: `/api/v1/assets/public/${fixtureVersion}/${seed[4]}` } : {}),
resourceClass: seed[6] ?? "zip_template",
resourceVersion: fixtureVersion,
category: item.category as TextTemplateCategory,
defaultFontId: item.default_font_id,
defaultFontSize: item.default_font_size,
defaultText: item.default_text,
displayName: item.display_name,
fontUrl: publicTextAssetUrl(item.default_font_id),
...(item.preview_asset_id ? { previewUrl: publicTextAssetUrl(item.preview_asset_id) } : {}),
renderModel: renderModel(item),
resourceClass: item.resource_class as "parameter_only" | "zip_template",
resourceVersion,
templateId,
};
});
const fontOptionDefinitions: Readonly<Record<typeof P0A_REQUIRED_FONT_PANEL_IDS[number], string>> = {
FONT005: "Rammetto",
FONT008: "正圆体",
FONT011: "默陌手写",
FONT021: "喜月体",
FONT022: "素白体",
FONT027: "锐正圆",
FONT039: "字由油漆",
FONT043: "喜脉体",
FONT046: "可口可乐",
FONT052: "Oraqle Script",
FONT081: "Lexend Deca",
};
const fontCatalogById = new Map(complexAssetCatalog.font_panel_items.map((item) => [item.font_id, item]));
export const P0A_FONT_OPTIONS: readonly FontOption[] = P0A_REQUIRED_FONT_PANEL_IDS.map((fontId) => ({
displayName: fontOptionDefinitions[fontId],
fontId,
url: `/api/v1/assets/public/${fixtureVersion}/${fontId}`,
}));
export const P0A_FONT_OPTIONS: readonly FontOption[] = P0A_REQUIRED_FONT_PANEL_IDS.map((fontId) => {
const item = fontCatalogById.get(fontId);
if (!item) throw new Error(`missing font panel definition ${fontId}`);
return {
displayName: item.display_name,
fontId,
url: publicTextAssetUrl(fontId),
};
});
const templateFontOptions: readonly FontOption[] = P0A_TEXT_TEMPLATES.flatMap((template) => {
const fontIds = [...new Set(template.renderModel.textLayers.map((layer) => layer.fontId))];
return fontIds.map((fontId, index) => ({
displayName: `${template.displayName}原版字体${fontIds.length > 1 ? ` ${index + 1}` : ""}`,
fontId,
url: publicTextAssetUrl(fontId, template.resourceVersion),
}));
});
const allFontOptions = new Map([...P0A_FONT_OPTIONS, ...templateFontOptions].map((option) => [option.fontId, option]));
export function fontOption(fontId: string) {
return P0A_FONT_OPTIONS.find((option) => option.fontId === fontId);
return allFontOptions.get(fontId);
}
export function textTemplateById(templateId: string) {
return P0A_TEXT_TEMPLATES.find((template) => template.templateId === templateId);
}
export function textTemplateFontOptions(templateId: string) {
const template = textTemplateById(templateId);
if (!template) return [];
return [...new Set(template.renderModel.textLayers.map((layer) => layer.fontId))]
.map((fontId) => fontOption(fontId)).filter((option): option is FontOption => option !== undefined);
}
export function textTemplateImageUrls(templateId: string, version = resourceVersion) {
const template = textTemplateById(templateId);
if (!template) return new Map<string, string>();
const assetIds = new Set([
...template.renderModel.imageLayers.map((layer) => layer.assetId),
...template.renderModel.particleLayers.map((layer) => layer.assetId),
...template.renderModel.textLayers.flatMap((layer) => layer.fillPatternAssetId ? [layer.fillPatternAssetId] : []),
]);
return new Map([...assetIds].map((assetId) => [assetId, publicTextAssetUrl(assetId, version)]));
}
export function fontIdForTextElement(element: CanvasElement) {
@@ -162,7 +318,19 @@ function isStep(value: number, minimum: number, step: number) {
}
function templateStyle(template: TextTemplateDefinition): Record<string, string | number | boolean | null> {
return { ...defaults, default_font_id: template.defaultFontId };
const primary = template.renderModel.textLayers.find((layer) => layer.editable) ?? template.renderModel.textLayers[0];
return {
...defaults,
default_font_id: template.defaultFontId,
fill_color: primary?.fillColor ?? defaults.fill_color,
letter_spacing: primary?.letterSpacing ?? defaults.letter_spacing,
line_height: primary?.lineHeight ?? defaults.line_height,
stroke_color: primary?.strokeColor ?? defaults.stroke_color,
stroke_enabled: (primary?.strokeWidth ?? 0) > 0,
stroke_width: primary?.strokeWidth ?? defaults.stroke_width,
template_fill_overridden: false,
text_align: primary?.align ?? defaults.text_align,
};
}
export function searchTextTemplates(
@@ -238,7 +406,10 @@ export class TextEditSession {
setStyle(patch: TextStylePatch) {
const style = { ...(this.draft.style_parameters ?? {}) };
if (patch.fillColor !== undefined) style.fill_color = checkedColor(patch.fillColor);
if (patch.fillColor !== undefined) {
style.fill_color = checkedColor(patch.fillColor);
style.template_fill_overridden = true;
}
if (patch.strokeColor !== undefined) style.stroke_color = checkedColor(patch.strokeColor);
if (patch.backgroundColor !== undefined) style.background_color = checkedColor(patch.backgroundColor);
if (patch.strokeEnabled !== undefined) style.stroke_enabled = patch.strokeEnabled;
+43 -5
View File
@@ -1,10 +1,44 @@
import type { ArchivedFontStatus } from "./text-font-loader.js";
import { useEffect, useRef, type CSSProperties } from "react";
import { fontFamilyName, type ArchivedFontStatus } from "./text-font-loader.js";
import { searchTextTemplates, type TextTemplateCategory, type TextTemplateDefinition } from "./text-assets.js";
const categories: Array<{ id?: TextTemplateCategory; label: string }> = [
{ label: "全部" }, { id: "flower", label: "花字" }, { id: "title", label: "标题" }, { id: "tag", label: "标签" }, { id: "simple", label: "简约" },
];
function LiveTemplatePreview(props: { onEnsure: () => void; template: TextTemplateDefinition }) {
const previewRef = useRef<HTMLSpanElement>(null);
const layer = props.template.renderModel.textLayers.find((item) => item.editable) ?? props.template.renderModel.textLayers[0]!;
useEffect(() => {
const node = previewRef.current;
if (!node || typeof IntersectionObserver === "undefined") {
props.onEnsure();
return;
}
const observer = new IntersectionObserver((entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
observer.disconnect();
props.onEnsure();
});
observer.observe(node);
return () => observer.disconnect();
}, [props.onEnsure, props.template.templateId]);
const scale = Math.min(1, 38 / Math.max(1, layer.height), 112 / Math.max(1, layer.width));
const style: CSSProperties = {
color: layer.fillColor,
fontFamily: `"${fontFamilyName(layer.fontId)}"`,
fontSize: `${layer.fontSize * scale}px`,
letterSpacing: `${layer.letterSpacing * scale}px`,
lineHeight: layer.lineHeight,
textShadow: layer.shadowBlur > 0 || layer.shadowOffsetX !== 0 || layer.shadowOffsetY !== 0
? `${layer.shadowOffsetX * scale}px ${layer.shadowOffsetY * scale}px ${layer.shadowBlur * scale}px ${layer.shadowColor}`
: undefined,
transform: `rotate(${layer.rotation}deg) scale(${layer.scaleX}, ${layer.scaleY})`,
WebkitTextStroke: layer.strokeWidth > 0 ? `${layer.strokeWidth * scale}px ${layer.strokeColor}` : undefined,
};
return <span className="editor-template-live-preview" ref={previewRef} style={style}>{layer.text || props.template.defaultText}</span>;
}
export function TextTemplatePanel(props: {
canAdd: boolean;
category?: TextTemplateCategory;
@@ -12,6 +46,7 @@ export function TextTemplatePanel(props: {
onAdd: (template: TextTemplateDefinition) => void;
onCategory: (category?: TextTemplateCategory) => void;
onQuery: (query: string) => void;
onEnsure: (template: TextTemplateDefinition) => void;
onRetry: () => void;
query: string;
recentIds: readonly string[];
@@ -31,12 +66,15 @@ export function TextTemplatePanel(props: {
<div className="editor-template-grid">
{visible.map((template) => {
const status = props.fontStatuses[template.defaultFontId] ?? "idle";
const unavailable = !template.available || status === "unavailable";
return <button aria-label={`${template.templateId} ${template.displayName}${unavailable ? " 素材暂不可用" : ""}`} disabled={!props.canAdd || unavailable || status === "loading"} key={template.templateId} onClick={() => props.onAdd(template)} type="button">
<span className={`editor-template-mark ${template.category}`}>{template.displayName.slice(0, 2)}</span>
const unavailable = !template.available;
const retryable = template.available && status === "unavailable";
return <button aria-label={`${template.templateId} ${template.displayName}${unavailable ? " 素材暂不可用" : retryable ? " 字体待重试" : ""}`} disabled={!props.canAdd || unavailable || status === "loading"} key={template.templateId} onClick={() => props.onAdd(template)} type="button">
{template.previewUrl
? <img alt="" className="editor-template-preview" decoding="async" loading="lazy" src={template.previewUrl} />
: <LiveTemplatePreview onEnsure={() => props.onEnsure(template)} template={template} />}
<strong>{template.templateId}</strong>
<span>{template.displayName}</span>
{unavailable ? <small></small> : status === "loading" ? <small></small> : null}
{unavailable ? <small></small> : retryable ? <small></small> : status === "loading" ? <small></small> : null}
</button>;
})}
</div>
+19
View File
@@ -124,6 +124,25 @@ button {
margin-inline: auto;
}
.auth-test-entry {
margin-bottom: 18px;
border-bottom: 1px solid #b4b4af;
padding-bottom: 18px;
}
.auth-test-entry .auth-primary {
margin-top: 0;
border-color: #111111;
background: #111111;
color: #f2f500;
}
.auth-test-entry .auth-primary:disabled {
border-color: #777773;
background: #deded9;
color: #777773;
}
.auth-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
+44
View File
@@ -77,6 +77,9 @@ export function UserAuthPage() {
const [sendState, setSendState] = useState<SendState>("idle");
const [countdown, setCountdown] = useState(0);
const [error, setError] = useState<string>();
const [localTestAvailable, setLocalTestAvailable] = useState(false);
const [localTestError, setLocalTestError] = useState<string>();
const [localTestSubmitting, setLocalTestSubmitting] = useState(false);
const [submitting, setSubmitting] = useState(false);
const emailValid = /^[^@\s]+@[^@\s]+$/.test(email);
const registrationReady = Boolean(
@@ -93,6 +96,18 @@ export function UserAuthPage() {
return () => window.clearInterval(timer);
}, [countdown]);
useEffect(() => {
const controller = new AbortController();
void fetch("/api/v1/auth/local-test", { credentials: "same-origin", signal: controller.signal })
.then(async (response) => {
if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) return;
const body = await response.json() as { available?: boolean };
if (body.available === true) setLocalTestAvailable(true);
})
.catch(() => undefined);
return () => controller.abort();
}, []);
useEffect(() => {
if (!noticeOpen) return;
const previousOverflow = document.body.style.overflow;
@@ -255,6 +270,27 @@ export function UserAuthPage() {
}
}
async function enterLocalTest() {
if (localTestSubmitting) return;
setLocalTestSubmitting(true);
setLocalTestError(undefined);
try {
const response = await fetch("/api/v1/auth/local-test", {
credentials: "same-origin",
method: "POST",
});
if (!response.ok) {
setLocalTestError("本机测试会话未能建立,请重试。");
return;
}
window.location.assign("/app");
} catch {
setLocalTestError("本机测试会话未能建立,请重试。");
} finally {
setLocalTestSubmitting(false);
}
}
return (
<>
<main className="auth-page">
@@ -271,6 +307,14 @@ export function UserAuthPage() {
<section className="auth-content">
<a className="auth-admin-link" href="/admin/login"></a>
<div className="auth-panel">
{localTestAvailable ? (
<div className="auth-test-entry">
<button className="auth-primary" disabled={localTestSubmitting} onClick={enterLocalTest} type="button">
{localTestSubmitting ? "正在进入" : "直接进入本机测试"}
</button>
{localTestError ? <p className="auth-error" role="alert">{localTestError}</p> : null}
</div>
) : null}
<div className="auth-tabs" role="tablist" aria-label="认证方式">
<button
aria-selected={mode === "login"}
+1
View File
@@ -69,6 +69,7 @@ async function checkSupport() {
browserValue.textContent = `${result.browser.brand} ${result.browser.major}`;
supportedValue.textContent = supportedLabel(result.supported_browsers);
window.dispatchEvent(new CustomEvent("dada:support-ready"));
window.location.replace(window.location.pathname.startsWith("/admin") ? "/admin" : "/app");
return;
}
showBlocked(
+2 -1
View File
@@ -9,7 +9,8 @@
},
"dependencies": {
"better-sqlite3": "13.0.1",
"drizzle-orm": "0.45.2"
"drizzle-orm": "0.45.2",
"sharp": "0.35.3"
},
"devDependencies": {
"@types/better-sqlite3": "7.6.13",
+6
View File
@@ -7,6 +7,11 @@ export interface GenerationAdapterRequest {
prompt: string;
ratio: "3:4" | "1:1" | "4:3" | "9:16";
referenceAssetIds: readonly string[];
referenceImages?: readonly {
assetId: string;
bytes: Buffer;
mimeType: "image/jpeg" | "image/png" | "image/webp";
}[];
}
export interface NormalizedGenerationOutput {
@@ -27,6 +32,7 @@ export type GenerationAdapterResult =
};
export interface GenerationAdapter {
dispose?(): void;
start(request: GenerationAdapterRequest): Promise<GenerationAdapterResult>;
poll?(upstreamJobReference: string): Promise<GenerationAdapterResult>;
}
+3 -1
View File
@@ -1,4 +1,5 @@
import type { GenerationAdapterRequest, NormalizedGenerationOutput } from "./ai-adapter-contract.js";
import { normalizeImageOutputToRatio } from "./image-output-normalizer.mjs";
import {
AdapterContractError, type AdapterStartResult, type AdapterTransport, balanceSignalFromResponse,
classifyAdapterError, dimensionsForRatio, mockPngBytes, validateAdapterRequest, validateMockContract,
@@ -41,7 +42,8 @@ export class GeminiFlashAdapter implements ModelAdapter {
const classified = this.classifyError("error" in response ? response.error : undefined, (request.configSnapshot.error_mapping_profile as Record<string, string> | undefined) ?? {});
return { ...classified, status: "failed" };
}
return { outputs: [this.normalizeOutput(response)], status: "completed" };
const output = this.normalizeOutput(response);
return { outputs: [await normalizeImageOutputToRatio({ ...output, ratio: request.ratio })], status: "completed" };
} catch (error) {
const classified = error instanceof AdapterContractError
? { category: "gateway_contract_invalid" as const, sourceCategory: error.sourceCategory }
+6 -4
View File
@@ -1,4 +1,5 @@
import type { GenerationAdapterRequest, NormalizedGenerationOutput } from "./ai-adapter-contract.js";
import { normalizeImageOutputToRatio } from "./image-output-normalizer.mjs";
import {
AdapterContractError, type AdapterStartResult, type AdapterTransport, balanceSignalFromResponse,
classifyAdapterError, dimensionsForRatio, mockPngBytes, validateAdapterRequest, validateMockContract,
@@ -37,7 +38,7 @@ export class GeminiProAdapter implements ModelAdapter {
try {
validateAdapterRequest(request, this.modelId);
const response = await this.transport.start({ operation: "start", modelId: this.modelId, prompt: request.prompt, ratio: request.ratio, referenceAssetIds: request.referenceAssetIds });
return this.interpret(response, request.configSnapshot.error_mapping_profile as Record<string, string> ?? {});
return await this.interpret(response, request.configSnapshot.error_mapping_profile as Record<string, string> ?? {}, request.ratio);
} catch (error) {
const classified = error instanceof AdapterContractError
? { category: "gateway_contract_invalid" as const, sourceCategory: error.sourceCategory }
@@ -49,7 +50,7 @@ export class GeminiProAdapter implements ModelAdapter {
async poll(upstreamJobReference: string): Promise<AdapterStartResult> {
try {
const response = await this.transport.poll({ operation: "poll", modelId: this.modelId, upstreamJobReference });
return this.interpret(response, {});
return await this.interpret(response, {});
} catch (error) {
const classified = error instanceof AdapterContractError
? { category: "gateway_contract_invalid" as const, sourceCategory: error.sourceCategory }
@@ -58,7 +59,7 @@ export class GeminiProAdapter implements ModelAdapter {
}
}
private interpret(response: unknown, mappingProfile: Readonly<Record<string, string>>): AdapterStartResult {
private async interpret(response: unknown, mappingProfile: Readonly<Record<string, string>>, ratio?: GenerationAdapterRequest["ratio"]): Promise<AdapterStartResult> {
if (!response || typeof response !== "object" || !("operation" in response) || !response.operation || typeof response.operation !== "object") {
return { category: "gateway_contract_invalid", sourceCategory: "response_shape_invalid", status: "failed" };
}
@@ -72,7 +73,8 @@ export class GeminiProAdapter implements ModelAdapter {
return reference ? { status: "pending", upstreamJobReference: reference } : { category: "gateway_contract_invalid", sourceCategory: "upstream_reference_missing", status: "failed" };
}
try {
return { outputs: [this.normalizeOutput("response" in operation ? operation.response : undefined)], status: "completed" };
const output = this.normalizeOutput("response" in operation ? operation.response : undefined);
return { outputs: [ratio ? await normalizeImageOutputToRatio({ ...output, ratio }) : output], status: "completed" };
} catch (error) {
return { category: "gateway_contract_invalid", sourceCategory: error instanceof AdapterContractError ? error.sourceCategory : "response_shape_invalid", status: "failed" };
}
+3 -1
View File
@@ -1,4 +1,5 @@
import type { GenerationAdapterRequest, NormalizedGenerationOutput } from "./ai-adapter-contract.js";
import { normalizeImageOutputToRatio } from "./image-output-normalizer.mjs";
import {
AdapterContractError, type AdapterStartResult, type AdapterTransport, balanceSignalFromResponse,
classifyAdapterError, dimensionsForRatio, mockPngBytes, validateAdapterRequest, validateMockContract,
@@ -41,7 +42,8 @@ export class GptImageAdapter implements ModelAdapter {
const classified = this.classifyError("error" in response ? response.error : undefined, (request.configSnapshot.error_mapping_profile as Record<string, string> | undefined) ?? {});
return { ...classified, status: "failed" };
}
return { outputs: [this.normalizeOutput(response)], status: "completed" };
const output = this.normalizeOutput(response);
return { outputs: [await normalizeImageOutputToRatio({ ...output, ratio: request.ratio })], status: "completed" };
} catch (error) {
const classified = error instanceof AdapterContractError
? { category: "gateway_contract_invalid" as const, sourceCategory: error.sourceCategory }
+47
View File
@@ -0,0 +1,47 @@
import type { GenerationAdapter } from "./ai-adapter-contract.js";
export type AiRuntimeProbeResult =
| {
code: "ai_probe_passed";
mime_type: "image/jpeg" | "image/png" | "image/webp";
pixel_height: number;
pixel_width: number;
real_calls: 1;
success: true;
}
| {
code: "ai_probe_failed";
error_category: string;
real_calls: 1;
success: false;
};
export async function runAiRuntimeProbe(adapter: GenerationAdapter): Promise<AiRuntimeProbeResult> {
const result = await adapter.start({
configSnapshot: { probe: true },
generationId: "00000000-0000-4000-8000-000000000002",
modelId: "gemini-3.1-flash-image-preview",
prompt: "生成一张简洁的红蓝几何色块测试图,不含文字。",
ratio: "1:1",
referenceAssetIds: [],
});
if (result.status === "failed") {
return { code: "ai_probe_failed", error_category: result.category, real_calls: 1, success: false };
}
if (result.status !== "completed" || result.outputs.length !== 1) {
return { code: "ai_probe_failed", error_category: "gateway_contract_invalid", real_calls: 1, success: false };
}
const output = result.outputs[0]!;
try {
return {
code: "ai_probe_passed",
mime_type: output.mimeType,
pixel_height: output.pixelHeight,
pixel_width: output.pixelWidth,
real_calls: 1,
success: true,
};
} finally {
output.bytes.fill(0);
}
}
@@ -0,0 +1,35 @@
export interface GenerationPollingProcessor {
processNext(): Promise<unknown>;
}
export class GenerationPollingLoop {
private closed = false;
private inFlight = false;
private readonly timer: ReturnType<typeof setInterval>;
constructor(
private readonly processor: GenerationPollingProcessor,
intervalMilliseconds = 250,
) {
if (!Number.isSafeInteger(intervalMilliseconds) || intervalMilliseconds <= 0) {
throw new Error("generation_polling_interval_invalid");
}
this.timer = setInterval(() => this.run(), intervalMilliseconds);
}
close() {
if (this.closed) return;
this.closed = true;
clearInterval(this.timer);
}
private run() {
if (this.closed || this.inFlight) return;
this.inFlight = true;
void this.processor.processNext()
.catch(() => undefined)
.finally(() => {
this.inFlight = false;
});
}
}
+38 -4
View File
@@ -1,11 +1,11 @@
import { createHash, randomUUID } from "node:crypto";
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import Database from "better-sqlite3";
import type BetterSqlite3 from "better-sqlite3";
import type { GenerationAdapter, GenerationAdapterResult, NormalizedGenerationOutput } from "./ai-adapter-contract.js";
import type { GenerationAdapter, GenerationAdapterRequest, GenerationAdapterResult, NormalizedGenerationOutput } from "./ai-adapter-contract.js";
import { GatewayBalanceRuntime } from "./gateway-balance-runtime.js";
import { generationErrorRegistry, type GenerationErrorCategory } from "./generation-error-registry.js";
import { configureWorkerDatabase } from "./sqlite-connection.js";
@@ -91,7 +91,8 @@ export class GenerationProcessor {
this.clock = input.clock ?? Date.now;
this.dataRoot = resolve(input.dataRoot);
this.workerId = input.workerId;
this.database = new Database(input.databasePath);
const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING;
this.database = new Database(input.databasePath, nativeBinding ? { nativeBinding } : undefined);
configureWorkerDatabase(this.database);
this.migrate();
this.gatewayBalance = new GatewayBalanceRuntime({ clock: this.clock, database: this.database });
@@ -119,6 +120,7 @@ export class GenerationProcessor {
.run("worker_stopped", now, this.workerId);
});
this.gatewayBalance.close();
this.adapter.dispose?.();
this.database.close();
}
@@ -143,6 +145,12 @@ export class GenerationProcessor {
SELECT managed_file_id FROM generation_reference_snapshots WHERE generation_id = ? ORDER BY position
`).all(generationId) as Array<{ managed_file_id: string }>;
let adapterResult: GenerationAdapterResult;
let referenceImages: NonNullable<GenerationAdapterRequest["referenceImages"]>;
try {
referenceImages = this.loadReferenceImages(references.map((row) => row.managed_file_id));
} catch {
return this.completeFailure(job, "reference_invalid", "reference_load_failed");
}
try {
if (job.upstream_job_reference) {
if (!this.adapter.poll) return this.completeFailure(job, "unknown_retryable", "poll_unsupported", undefined, false, "pending_manual_review");
@@ -155,10 +163,13 @@ export class GenerationProcessor {
prompt: job.prompt,
ratio: job.ratio,
referenceAssetIds: references.map((row) => row.managed_file_id),
referenceImages,
}));
}
} catch {
return this.completeFailure(job, "unknown_retryable", "adapter_exception", undefined, false, "pending_manual_review");
} finally {
for (const reference of referenceImages) reference.bytes.fill(0);
}
if (adapterResult.status === "failed") return this.completeFailure(job, adapterResult.category, adapterResult.sourceCategory, adapterResult.balanceSignal);
@@ -174,6 +185,29 @@ export class GenerationProcessor {
}
}
private loadReferenceImages(referenceAssetIds: string[]): NonNullable<GenerationAdapterRequest["referenceImages"]> {
return referenceAssetIds.map((assetId) => {
const row = this.database.prepare(`
SELECT relative_path, mime_type FROM managed_files
WHERE file_id = ? AND file_kind = 'reference' AND status = 'committed'
`).get(assetId) as { mime_type: string; relative_path: string } | undefined;
if (!row || !["image/jpeg", "image/png", "image/webp"].includes(row.mime_type) || isAbsolute(row.relative_path)) {
throw new Error("reference_invalid");
}
const path = resolve(this.dataRoot, row.relative_path);
const child = relative(this.dataRoot, path);
if (!child || child === ".." || child.startsWith(`..${sep}`) || isAbsolute(child)
|| !existsSync(path) || !statSync(path).isFile()) {
throw new Error("reference_invalid");
}
return {
assetId,
bytes: readFileSync(path),
mimeType: row.mime_type as "image/jpeg" | "image/png" | "image/webp",
};
});
}
private claim(generationId: string) {
return this.immediate(() => {
const row = this.readJob(generationId);
@@ -0,0 +1,74 @@
import sharp from "sharp";
const productDimensions = Object.freeze({
"3:4": Object.freeze({ pixelHeight: 1440, pixelWidth: 1080 }),
"1:1": Object.freeze({ pixelHeight: 1080, pixelWidth: 1080 }),
"4:3": Object.freeze({ pixelHeight: 1080, pixelWidth: 1440 }),
"9:16": Object.freeze({ pixelHeight: 1920, pixelWidth: 1080 }),
});
const gptImageRequestSizes = Object.freeze({
"3:4": "1056x1408",
"1:1": "1088x1088",
"4:3": "1408x1056",
"9:16": "1008x1792",
});
const allowedMimeTypes = new Set(["image/jpeg", "image/png", "image/webp"]);
const maximumInputBytes = 20 * 1024 * 1024;
function assertRatio(ratio) {
if (!(ratio in productDimensions)) throw new Error("image_output_ratio_unsupported");
return ratio;
}
export function productDimensionsForRatio(ratio) {
return { ...productDimensions[assertRatio(ratio)] };
}
export function gptImageRequestSizeForRatio(ratio) {
return gptImageRequestSizes[assertRatio(ratio)];
}
export async function normalizeImageOutputToRatio(input) {
const ratio = assertRatio(input?.ratio);
if (!Buffer.isBuffer(input?.bytes) || input.bytes.length === 0 || input.bytes.length > maximumInputBytes
|| !allowedMimeTypes.has(input?.mimeType)) {
throw new Error("image_output_media_invalid");
}
const target = productDimensions[ratio];
if (input.pixelWidth === target.pixelWidth && input.pixelHeight === target.pixelHeight) {
return {
bytes: Buffer.from(input.bytes),
mimeType: input.mimeType,
normalized: false,
...target,
upstreamPixelHeight: input.pixelHeight,
upstreamPixelWidth: input.pixelWidth,
};
}
const image = sharp(input.bytes, { failOn: "error", limitInputPixels: 40_000_000 });
const metadata = await image.metadata();
if (!metadata.width || !metadata.height) throw new Error("image_output_dimensions_missing");
const requestedRatio = target.pixelWidth / target.pixelHeight;
const upstreamRatio = metadata.width / metadata.height;
if (Math.abs(upstreamRatio - requestedRatio) / requestedRatio > 0.02) {
throw new Error("image_output_aspect_ratio_mismatch");
}
const { data, info } = await image
.resize(target.pixelWidth, target.pixelHeight, { fit: "fill", kernel: sharp.kernel.lanczos3 })
.png({ compressionLevel: 9 })
.toBuffer({ resolveWithObject: true });
if (info.width !== target.pixelWidth || info.height !== target.pixelHeight || info.format !== "png") {
throw new Error("image_output_normalization_failed");
}
return {
bytes: data,
mimeType: "image/png",
normalized: true,
...target,
upstreamPixelHeight: metadata.height,
upstreamPixelWidth: metadata.width,
};
}
@@ -0,0 +1,221 @@
import sharp from "sharp";
import type {
GenerationAdapter,
GenerationAdapterRequest,
GenerationAdapterResult,
NormalizedGenerationOutput,
} from "./ai-adapter-contract.js";
import { gptImageRequestSizeForRatio, normalizeImageOutputToRatio } from "./image-output-normalizer.mjs";
const geminiProductModelId = "gemini-3.1-flash-image-preview";
const geminiProviderModelId = "gemini-3.1-flash-image";
const gptImageModelId = "gpt-image-2";
const geminiEndpoint = "https://oneapi.intelligrow.cn/v1/chat/completions";
const gptImageEndpoint = "https://oneapi.intelligrow.cn/v1/images/generations";
const gptImageReferenceEndpoint = "https://oneapi.intelligrow.cn/v1/images/edits";
const maximumResponseBytes = 32 * 1024 * 1024;
const requestTimeoutMilliseconds = 180_000;
const geminiImageSystemInstruction = "Generate exactly one image from the user's description. Return the generated image and do not answer with text only.";
type FetchLike = typeof fetch;
class OneApiRuntimeError extends Error {
constructor(
readonly category: "gateway_balance_insufficient" | "gateway_contract_invalid" | "model_disabled" | "reference_invalid" | "upstream_failed" | "upstream_timeout" | "unknown_non_retryable",
readonly sourceCategory: string,
) {
super(sourceCategory);
}
}
function failure(error: unknown): GenerationAdapterResult {
if (error instanceof OneApiRuntimeError) {
return { category: error.category, sourceCategory: error.sourceCategory, status: "failed" };
}
if (error instanceof Error && error.name === "AbortError") {
return { category: "upstream_timeout", sourceCategory: "upstream_timeout", status: "failed" };
}
return { category: "upstream_failed", sourceCategory: "upstream_failed", status: "failed" };
}
function mapHttpFailure(status: number) {
if (status === 408 || status === 504) return new OneApiRuntimeError("upstream_timeout", `upstream_http_${status}`);
if (status === 429) return new OneApiRuntimeError("gateway_balance_insufficient", "upstream_http_429");
if (status >= 500) return new OneApiRuntimeError("upstream_failed", `upstream_http_${status}`);
if (status === 400 || status === 404 || status === 422) return new OneApiRuntimeError("gateway_contract_invalid", `upstream_http_${status}`);
return new OneApiRuntimeError("unknown_non_retryable", `upstream_http_${status}`);
}
async function readBoundedJson(response: Response) {
const declaredLength = Number(response.headers.get("content-length") ?? 0);
if (Number.isFinite(declaredLength) && declaredLength > maximumResponseBytes) {
throw new OneApiRuntimeError("gateway_contract_invalid", "upstream_response_too_large");
}
if (!response.body) throw new OneApiRuntimeError("gateway_contract_invalid", "upstream_response_empty");
const reader = response.body.getReader();
const chunks: Buffer[] = [];
let total = 0;
try {
while (true) {
const next = await reader.read();
if (next.done) break;
const chunk = Buffer.from(next.value);
total += chunk.length;
if (total > maximumResponseBytes) {
await reader.cancel();
throw new OneApiRuntimeError("gateway_contract_invalid", "upstream_response_too_large");
}
chunks.push(chunk);
}
try {
return JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown;
} catch {
throw new OneApiRuntimeError("gateway_contract_invalid", "upstream_response_invalid");
}
} finally {
for (const chunk of chunks) chunk.fill(0);
}
}
function extractGeminiImage(response: unknown) {
if (!response || typeof response !== "object" || !("choices" in response) || !Array.isArray(response.choices)) {
throw new OneApiRuntimeError("gateway_contract_invalid", "response_shape_invalid");
}
const choice = response.choices[0];
const content = choice && typeof choice === "object" && "message" in choice && choice.message && typeof choice.message === "object"
&& "content" in choice.message && typeof choice.message.content === "string" ? choice.message.content : "";
const matches = [...content.matchAll(/!\[[^\]]*\]\(\s*data:(image\/(?:jpeg|png|webp));base64,([A-Za-z0-9+/=\r\n]+)\s*\)/gi)];
if (matches.length !== 1) throw new OneApiRuntimeError("gateway_contract_invalid", "response_single_image_required");
return { bytes: Buffer.from(matches[0]![2]!, "base64"), declaredMimeType: matches[0]![1]!.toLowerCase() };
}
function extractGptImage(response: unknown) {
if (!response || typeof response !== "object" || !("data" in response) || !Array.isArray(response.data)
|| response.data.length !== 1 || !response.data[0] || typeof response.data[0] !== "object"
|| !("b64_json" in response.data[0]) || typeof response.data[0].b64_json !== "string") {
throw new OneApiRuntimeError("gateway_contract_invalid", "response_single_image_required");
}
return { bytes: Buffer.from(response.data[0].b64_json, "base64"), declaredMimeType: undefined };
}
async function normalizeOutput(bytes: Buffer, declaredMimeType: string | undefined, ratio: GenerationAdapterRequest["ratio"]): Promise<NormalizedGenerationOutput> {
try {
const metadata = await sharp(bytes, { failOn: "error", limitInputPixels: 40_000_000 }).metadata();
const mimeType = metadata.format === "png" ? "image/png" : metadata.format === "jpeg" ? "image/jpeg" : metadata.format === "webp" ? "image/webp" : undefined;
if (!mimeType || !metadata.width || !metadata.height || (declaredMimeType && declaredMimeType !== mimeType)) {
throw new OneApiRuntimeError("gateway_contract_invalid", "response_media_invalid");
}
const normalized = await normalizeImageOutputToRatio({ bytes, mimeType, pixelHeight: metadata.height, pixelWidth: metadata.width, ratio });
return { bytes: normalized.bytes, mimeType: normalized.mimeType, pixelHeight: normalized.pixelHeight, pixelWidth: normalized.pixelWidth };
} catch (error) {
if (error instanceof OneApiRuntimeError) throw error;
throw new OneApiRuntimeError("gateway_contract_invalid", "response_media_invalid");
}
}
function validateRequest(request: GenerationAdapterRequest) {
if (!request.prompt.trim() || request.prompt.length > 1_000) throw new OneApiRuntimeError("gateway_contract_invalid", "prompt_invalid");
const references = request.referenceImages ?? [];
if (references.length !== request.referenceAssetIds.length || references.length > 2) {
throw new OneApiRuntimeError("reference_invalid", "reference_count_invalid");
}
const totalBytes = references.reduce((total, reference) => total + reference.bytes.length, 0);
if (totalBytes > 20 * 1024 * 1024 || references.some((reference) => reference.bytes.length === 0 || reference.bytes.length > 10 * 1024 * 1024)) {
throw new OneApiRuntimeError("reference_invalid", "reference_size_invalid");
}
return references;
}
function buildRequest(request: GenerationAdapterRequest) {
const references = validateRequest(request);
if (request.modelId === geminiProductModelId) {
const content = references.length === 0
? request.prompt
: [
{ text: request.prompt, type: "text" },
...references.map((reference) => ({
image_url: { url: `data:${reference.mimeType};base64,${reference.bytes.toString("base64")}` },
type: "image_url",
})),
];
return {
body: JSON.stringify({
extra_body: { google: { image_config: { aspect_ratio: request.ratio, image_size: "1K" } } },
messages: [
{ content: geminiImageSystemInstruction, role: "system" },
{ content, role: "user" },
],
model: geminiProviderModelId,
stream: false,
}),
contentType: "application/json",
endpoint: geminiEndpoint,
parser: extractGeminiImage,
};
}
if (request.modelId !== gptImageModelId) throw new OneApiRuntimeError("model_disabled", "model_not_supported");
if (references.length > 0) {
const form = new FormData();
form.append("model", gptImageModelId);
form.append("prompt", request.prompt);
form.append("response_format", "b64_json");
form.append("size", gptImageRequestSizeForRatio(request.ratio));
references.forEach((reference, index) => form.append("image[]", new Blob([reference.bytes], { type: reference.mimeType }), `reference-${index + 1}.png`));
return { body: form, contentType: undefined, endpoint: gptImageReferenceEndpoint, parser: extractGptImage };
}
return {
body: JSON.stringify({ model: gptImageModelId, prompt: request.prompt, response_format: "b64_json", size: gptImageRequestSizeForRatio(request.ratio) }),
contentType: "application/json",
endpoint: gptImageEndpoint,
parser: extractGptImage,
};
}
export class OneApiGenerationAdapter implements GenerationAdapter {
private readonly credential: Buffer;
private readonly fetchImpl: FetchLike;
private disposed = false;
constructor(input: { credential: Buffer; fetch?: FetchLike }) {
if (input.credential.length < 8) throw new Error("ai_gateway_credential_invalid");
this.credential = Buffer.from(input.credential);
this.fetchImpl = input.fetch ?? fetch;
}
async start(request: GenerationAdapterRequest): Promise<GenerationAdapterResult> {
if (this.disposed) return { category: "upstream_failed", sourceCategory: "adapter_disposed", status: "failed" };
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), requestTimeoutMilliseconds);
let sourceBytes: Buffer | undefined;
try {
const providerRequest = buildRequest(request);
const headers = new Headers({ authorization: `Bearer ${this.credential.toString("utf8")}` });
if (providerRequest.contentType) headers.set("content-type", providerRequest.contentType);
const response = await this.fetchImpl(providerRequest.endpoint, {
body: providerRequest.body,
headers,
method: "POST",
redirect: "error",
signal: controller.signal,
});
if (!response.ok) throw mapHttpFailure(response.status);
const parsed = await readBoundedJson(response);
const extracted = providerRequest.parser(parsed);
sourceBytes = extracted.bytes;
const output = await normalizeOutput(sourceBytes, extracted.declaredMimeType, request.ratio);
return { outputs: [output], status: "completed" };
} catch (error) {
return failure(error);
} finally {
clearTimeout(timeout);
sourceBytes?.fill(0);
}
}
dispose() {
if (this.disposed) return;
this.disposed = true;
this.credential.fill(0);
}
}
+8 -4
View File
@@ -13,7 +13,7 @@ export async function receiveWorkerCredentials(input: NodeJS.ReadableStream = pr
if (names.length !== expected.length || names.some((name, index) => name !== expected[index])) {
throw new Error("Worker credential channel contains an unexpected credential scope.");
}
if (expected.some((name) => typeof parsed[name] !== "string" || parsed[name] === "")) {
if (expected.some((name) => typeof parsed[name] !== "string")) {
throw new Error("Worker credential channel contains an invalid credential value.");
}
return parsed as Record<(typeof WORKER_CREDENTIALS)[number], string>;
@@ -25,9 +25,13 @@ export async function receiveWorkerCredentials(input: NodeJS.ReadableStream = pr
}
export function initializeWorkerCredentialClient(credentials: Record<(typeof WORKER_CREDENTIALS)[number], string>) {
const configured = WORKER_CREDENTIALS.every((name) => credentials[name].length > 0);
for (const name of WORKER_CREDENTIALS) credentials[name] = "";
if (!configured) throw new Error("Worker credential client initialization failed.");
const value = credentials["Dada/P0A/worker/ai-gateway"];
try {
if (!value) throw new Error("worker_ai_gateway_not_configured");
return { aiGatewayCredential: Buffer.from(value, "utf8") };
} finally {
for (const name of WORKER_CREDENTIALS) credentials[name] = "";
}
}
export function attachWorkerSupervisorControl(pipeName: string, shutdown: () => Promise<void> | void) {
+37 -2
View File
@@ -2,6 +2,10 @@ import { parentPort } from "node:worker_threads";
import { join } from "node:path";
import { WorkerAiCallGate } from "./ai-call-gate.js";
import { runAiRuntimeProbe } from "./ai-runtime-probe.js";
import { GenerationPollingLoop } from "./generation-polling-loop.js";
import { GenerationProcessor } from "./generation-processor.js";
import { OneApiGenerationAdapter } from "./oneapi-generation-adapter.js";
import { readConfiguredLocalDataRoot } from "./runtime-config.js";
import { RetentionCleanup } from "./retention-cleanup.js";
import { ProjectPurgeCleanup } from "./project-purge-cleanup.js";
@@ -21,8 +25,25 @@ if (workerPort) {
});
}
if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
initializeWorkerCredentialClient(await receiveWorkerCredentials());
if (!workerPort && process.argv.includes("--dada-ai-probe")) {
const credentialClient = initializeWorkerCredentialClient(await receiveWorkerCredentials());
let adapter: OneApiGenerationAdapter | undefined;
let probeResult: Awaited<ReturnType<typeof runAiRuntimeProbe>> | { code: "ai_probe_failed"; error_category: "upstream_failed"; real_calls: 0; success: false };
try {
adapter = new OneApiGenerationAdapter({ credential: credentialClient.aiGatewayCredential });
probeResult = await runAiRuntimeProbe(adapter);
} catch {
probeResult = { code: "ai_probe_failed", error_category: "upstream_failed", real_calls: 0, success: false };
} finally {
credentialClient.aiGatewayCredential.fill(0);
adapter?.dispose();
}
await new Promise<void>((resolveWrite, rejectWrite) => {
process.stdout.write(JSON.stringify(probeResult), (error) => error ? rejectWrite(error) : resolveWrite());
});
process.exit(probeResult.success ? 0 : 2);
} else if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
const credentialClient = initializeWorkerCredentialClient(await receiveWorkerCredentials());
const controlPipeIndex = process.argv.indexOf("--dada-control-pipe");
const controlPipe = process.argv[controlPipeIndex + 1];
if (controlPipeIndex < 0 || !controlPipe) throw new Error("Supervisor control pipe name is required.");
@@ -31,11 +52,15 @@ if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
let retention: RetentionCleanup | undefined;
let projectCleanup: ProjectPurgeCleanup | undefined;
let retentionTimer: ReturnType<typeof setInterval> | undefined;
let processor: GenerationProcessor | undefined;
let generationLoop: GenerationPollingLoop | undefined;
const control = attachWorkerSupervisorControl(controlPipe, () => {
clearInterval(keepAlive);
if (retentionTimer) clearInterval(retentionTimer);
retention?.close();
projectCleanup?.close();
generationLoop?.close();
processor?.close();
storage?.close();
});
let storageStatus: "active" | "unavailable" = "active";
@@ -45,6 +70,13 @@ if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
storage = new WorkerStorageStatus(databasePath);
retention = new RetentionCleanup({ databasePath });
projectCleanup = new ProjectPurgeCleanup({ dataRoot, databasePath });
let adapter: OneApiGenerationAdapter;
try {
adapter = new OneApiGenerationAdapter({ credential: credentialClient.aiGatewayCredential });
} finally {
credentialClient.aiGatewayCredential.fill(0);
}
processor = new GenerationProcessor({ adapter, dataRoot, databasePath, workerId: `portable-oneapi-worker-${process.pid}` });
const runRetentionCleanup = () => {
try {
retention?.purgeExpired();
@@ -71,8 +103,11 @@ if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
});
logger.write({ error_category: "none", status_category: "ready" });
new WorkerAiCallGate({ getStorageStatus: () => storageStatus === "unavailable" ? storageStatus : (storage?.getStatus() ?? "unavailable"), logger });
generationLoop = new GenerationPollingLoop(processor);
} catch {
storageStatus = "unavailable";
control.reportStatus("storage_unavailable");
clearInterval(keepAlive);
setTimeout(() => process.exit(1), 50);
}
}
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"allowJs": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2024"],
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"config_set_version": 8,
"gateway_account_ref": "oneapi-intelligrow-test",
"models": [
{
"config_version": 7,
"gateway_account_ref": "oneapi-intelligrow-test",
"model_id": "gemini-3.1-flash-image",
"route_profile": {
"endpoint": "https://oneapi.intelligrow.cn/v1/chat/completions",
"mode": "sync",
"protocol_version": "gemini-openai-chat-v1",
"provider_model_id": "gemini-3.1-flash-image"
}
},
{
"config_version": 2,
"gateway_account_ref": "oneapi-intelligrow-test",
"model_id": "gpt-image-2",
"route_profile": {
"endpoint": "https://oneapi.intelligrow.cn/v1/images/generations",
"mode": "sync",
"protocol_version": "openai-images-v1",
"reference_endpoint": "https://oneapi.intelligrow.cn/v1/images/edits"
}
}
],
"schema_version": "1.0"
}
+14 -4
View File
@@ -5621,10 +5621,20 @@
"type": "string"
},
"service_mode": {
"enum": [
"mock"
],
"type": "string"
"anyOf": [
{
"enum": [
"mock"
],
"type": "string"
},
{
"enum": [
"real"
],
"type": "string"
}
]
},
"status": {
"enum": [
+26 -4
View File
@@ -8,6 +8,7 @@
},
"scripts": {
"build": "pnpm -r --if-present build && dotnet build supervisor/Dada.Supervisor/Dada.Supervisor.csproj --configuration Release",
"build:workspace-packages": "pnpm --filter \"./packages/**\" --if-present build",
"typecheck": "pnpm -r --if-present typecheck",
"test:unit:contract": "node --test tests/toolchain/frozen-toolchain.test.mjs",
"test:unit": "pnpm --filter @dada/static-sticker-catalog build && pnpm --filter @dada/template-registry build && pnpm --filter @dada/asset-renderer build && pnpm --filter @dada/asset-compiler build && pnpm --filter @dada/shared-contracts build && pnpm run test:unit:contract && vitest run tests/unit",
@@ -15,11 +16,16 @@
"test:api": "pnpm check:openapi && vitest run tests/api",
"test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker",
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts tests/e2e/admin-models.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts tests/e2e/wp4-03-text-editor.spec.ts tests/e2e/wp4-04-color-dynamic.spec.ts tests/e2e/wp4-05-export.spec.ts tests/e2e/wp4-06-accessibility.spec.ts tests/e2e/wp5-02-static-sticker-catalog.spec.ts tests/e2e/wp5-03-template-registry.spec.ts tests/e2e/wp5-04-resource-isolation.spec.ts tests/e2e/wp5-05-admin-assets.spec.ts tests/e2e/wp6-01-admin-shell.spec.ts tests/e2e/wp6-04-audit.spec.ts tests/e2e/wp6-05-state.spec.ts --config playwright.config.ts",
"test:visual": "node scripts/validate-layer-scope.mjs VISUAL",
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
"test:visual": "node scripts/run-wp4-07-layer.mjs visual",
"test:performance": "node scripts/run-wp4-07-layer.mjs performance",
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
"test:package": "pnpm run typecheck && node --test tests/package/wp0-09-portable.test.mjs && node scripts/package-smoke.mjs && node scripts/loopback-boundary-smoke.mjs",
"test:package": "pnpm build:workspace-packages && pnpm run typecheck && node --test tests/package/wp0-09-portable.test.mjs && node scripts/package-smoke.mjs && node scripts/loopback-boundary-smoke.mjs",
"test:postv1-ui-integration": "node scripts/validate-postv1-ui-lineage.mjs && playwright test tests/e2e/projects-workspace.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts tests/e2e/wp4-04-color-dynamic.spec.ts --config playwright.config.ts",
"package:portable": "node scripts/build-portable.mjs",
"assets:manifest": "pnpm build:workspace-packages && node scripts/generate-runtime-asset-manifest.mjs",
"assets:deploy": "pnpm build:workspace-packages && node scripts/deploy-runtime-assets.mjs",
"assets:validate-browser-fonts": "node scripts/validate-browser-font-assets.mjs",
"assets:browser-catalog": "pnpm build:workspace-packages && node scripts/generate-complex-browser-catalog.mjs",
"generate:openapi": "node scripts/generate-openapi.mjs",
"check:openapi": "node scripts/check-openapi.mjs",
"validate:tdd-trace": "node scripts/validate-tdd-trace.mjs",
@@ -86,6 +92,8 @@
"test:wp4-05:red": "node scripts/run-wp4-05-validation.mjs --phase red",
"test:wp4-06": "node scripts/run-wp4-06-validation.mjs",
"test:wp4-06:red": "node scripts/run-wp4-06-validation.mjs --phase red",
"test:wp4-07": "node scripts/run-wp4-07-validation.mjs",
"test:wp4-07:red": "node scripts/run-wp4-07-validation.mjs --phase red",
"test:wp5-01": "node scripts/run-wp5-01-validation.mjs",
"test:wp5-01:red": "node scripts/run-wp5-01-validation.mjs --phase red",
"test:wp5-02": "node scripts/run-wp5-02-validation.mjs",
@@ -101,7 +109,21 @@
"test:wp6-01:red": "node scripts/run-wp6-01-validation.mjs --phase red",
"test:wp6-04": "node scripts/run-wp6-04-validation.mjs --phase green",
"test:wp6-04:red": "node scripts/run-wp6-04-validation.mjs --phase red",
"test:wp6-05": "pnpm exec vitest run tests/api/wp6-05-state.test.ts && pnpm exec playwright test tests/e2e/wp6-05-state.spec.ts --config playwright.config.ts"
"test:wp6-05": "pnpm exec vitest run tests/api/wp6-05-state.test.ts && pnpm exec playwright test tests/e2e/wp6-05-state.spec.ts --config playwright.config.ts",
"test:wp7-01": "node scripts/run-wp7-01-validation.mjs",
"review:wp7-01": "node scripts/record-wp7-01-manual-review.mjs",
"test:wp7-02": "node scripts/run-wp7-02-validation.mjs",
"test:wp7-02:controlled": "node scripts/run-wp7-02-validation.mjs --controlled-real",
"review:wp7-02": "node scripts/record-wp7-02-manual-review.mjs",
"test:wp7-03": "node scripts/run-wp7-03-validation.mjs --phase green",
"test:wp7-03:red": "node scripts/run-wp7-03-validation.mjs --phase red",
"test:wp7-04": "node scripts/run-wp7-04-validation.mjs",
"test:wp7-05": "node scripts/run-wp7-05-validation.mjs",
"test:wp7-05:unit": "node --test tests/package/wp7-05-ui-gate.test.mjs tests/package/wp7-05-coverage.test.mjs",
"test:wp7-06": "node scripts/run-wp7-06-validation.mjs",
"test:wp7-06:unit": "node --test tests/package/wp7-06-prefreeze.test.mjs",
"test:wp7-07": "node scripts/run-wp7-07-validation.mjs",
"test:wp7-07:unit": "node --test tests/package/wp7-07-final-release.test.mjs"
},
"devDependencies": {
"@playwright/test": "1.62.0",
+7 -1
View File
@@ -228,7 +228,13 @@ function readCsv(tracker: SourceTracker, path: string, label: string): CsvRow[]
function itemDirectoryFor(collection: CollectionConfig, catalogPath: string, row: CsvRow): { directory: string; metadataPath: string } {
if (collection.id === "font_panel") {
const resourceDir = requireString(row.resource_dir, "font resource_dir");
const configuredResourceDir = requireString(row.resource_dir, "font resource_dir");
const normalizedResourceDir = configuredResourceDir.replaceAll("\\", "/");
const relocationMarker = "/resources/font_packages/";
const markerIndex = normalizedResourceDir.lastIndexOf(relocationMarker);
const resourceDir = isAbsolute(configuredResourceDir) && !inside(configuredResourceDir, collection.root.path) && markerIndex >= 0
? relativeReference(normalizedResourceDir.slice(markerIndex + 1), "font resource_dir relocation")
: configuredResourceDir;
const directory = resolveSourcePath(resourceDir, collection.root.path, collection.root.path, "font resource_dir");
return { directory, metadataPath: resolveSourcePath("metadata.json", directory, collection.root.path, "font metadata") };
}
+14 -2
View File
@@ -4,8 +4,8 @@ export interface ColorCardDefinition {
cardId: typeof P0A_COLOR_CARD_IDS[number];
displayName: string;
mappingStatus: "confirmed_native_mapping" | "stable_web_style_native_mapping_provisional";
rendererName: "horizontal_line" | "ticket_strip" | "vertical_stack" | "vertical_ticket";
styleId: "style_01" | "style_02" | "style_08" | "style_16";
rendererName: string;
styleId: string;
}
export type FiveColorPalette = readonly [string, string, string, string, string];
@@ -17,7 +17,19 @@ export interface ColorCardRenderPlan extends ColorCardDefinition {
export const P0A_COLOR_CARD_DEFINITIONS: readonly ColorCardDefinition[] = [
{ cardId: "COLOR001", displayName: "纵向票据", mappingStatus: "confirmed_native_mapping", rendererName: "vertical_ticket", styleId: "style_01" },
{ cardId: "COLOR002", displayName: "纵向色阶", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "vertical_stack", styleId: "style_02" },
{ cardId: "COLOR003", displayName: "纵向色条", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "vertical_strip", styleId: "style_03" },
{ cardId: "COLOR004", displayName: "纵向标线", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "vertical_line", styleId: "style_04" },
{ cardId: "COLOR005", displayName: "横向标签", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "horizontal_label_strip", styleId: "style_05" },
{ cardId: "COLOR006", displayName: "指示色条", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "indicator_strip", styleId: "style_06" },
{ cardId: "COLOR007", displayName: "图钉色条", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "pinned_strip", styleId: "style_07" },
{ cardId: "COLOR008", displayName: "横向标尺", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "horizontal_line", styleId: "style_08" },
{ cardId: "COLOR009", displayName: "色彩海报", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "color_poster", styleId: "style_09" },
{ cardId: "COLOR010", displayName: "标题海报", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "caption_poster", styleId: "style_10" },
{ cardId: "COLOR011", displayName: "边框色条", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "framed_strip", styleId: "style_11" },
{ cardId: "COLOR012", displayName: "OTTO 色块", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "otto_blocks", styleId: "style_12" },
{ cardId: "COLOR013", displayName: "纵向圆点", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "vertical_dots", styleId: "style_13" },
{ cardId: "COLOR014", displayName: "三色圆环", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "three_circles", styleId: "style_14" },
{ cardId: "COLOR015", displayName: "描边圆点", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "outlined_dots", styleId: "style_15" },
{ cardId: "COLOR016", displayName: "横向票条", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "ticket_strip", styleId: "style_16" },
] as const;
+1 -1
View File
@@ -281,7 +281,7 @@ export const AdminDiagnosticsResponseSchema = Type.Object({
browser_support: Type.Array(Type.Object({
brand: Type.Union([Type.Literal("Google Chrome"), Type.Literal("Microsoft Edge")]),
major: Type.Integer({ minimum: 1 }),
}, { additionalProperties: false }), { maxItems: 2 }),
}, { additionalProperties: false }), { maxItems: 8 }),
worker_status: Type.Union([Type.Literal("ready"), Type.Literal("degraded"), Type.Literal("unavailable")]),
}, { additionalProperties: false }),
}, { additionalProperties: false, $id: "AdminDiagnosticsResponse" });
+1 -1
View File
@@ -83,7 +83,7 @@ export const ErrorDetailsSchema = Type.Object(
},
{ additionalProperties: false },
),
{ maxItems: 2 },
{ maxItems: 8 },
),
),
capacity_status: Type.Optional(
+1 -1
View File
@@ -7,7 +7,7 @@ export const ReverseGeocodeRequestSchema = Type.Object({
export const ReverseGeocodeResponseSchema = Type.Object({
formatted_value: Type.String({ maxLength: 200, minLength: 1 }),
service_mode: Type.Literal("mock"),
service_mode: Type.Union([Type.Literal("mock"), Type.Literal("real")]),
status: Type.Literal("resolved"),
}, { additionalProperties: false, $id: "ReverseGeocodeResponse" });
+44 -38
View File
@@ -1,23 +1,42 @@
import type { StaticStickerCatalog, StaticStickerCatalogItem } from "@dada/static-sticker-catalog";
export const P0A_TEXT_TEMPLATE_IDS = [
"FLOWER001", "FLOWER002", "FLOWER003", "FLOWER004", "FLOWER005", "FLOWER006", "FLOWER007", "FLOWER008",
"H001", "H002", "H003", "H004", "H005", "H006", "H007", "H008",
"TAG001", "TAG002", "TAG003", "TAG004", "TAG005", "TAG006", "TAG007", "TAG051",
"SIMPLE001", "SIMPLE002", "SIMPLE003", "SIMPLE004", "SIMPLE005", "SIMPLE006", "SIMPLE007", "SIMPLE008",
export const P0A_COMPLEX_RELEASE_VERSION = "p0a-complex-v1";
export const P0A_STATIC_STICKER_RELEASE_VERSION = "p0a-static-v1";
function numberedIds(prefix: string, count: number) {
return Object.freeze(Array.from({ length: count }, (_, index) => `${prefix}${String(index + 1).padStart(3, "0")}`));
}
export const P0A_TEXT_TEMPLATE_IDS = Object.freeze([
...numberedIds("FLOWER", 145),
...numberedIds("H", 119),
...numberedIds("TAG", 51),
...numberedIds("SIMPLE", 17),
]);
export const P0A_REQUIRED_FONT_PANEL_IDS = numberedIds("FONT", 86);
export const P0A_COLOR_CARD_IDS = numberedIds("COLOR", 16);
export const P0A_DYNAMIC_STICKER_IDS = numberedIds("DYN", 35);
export const P0A_DYNAMIC_RUNTIME_FONT_SOURCES = [
{ assetId: "15974853bc3294ef68e7e6d58fe74fd7", sourceReference: "fonts/15974853bc3294ef68e7e6d58fe74fd7", templateId: "DYN002" },
{ assetId: "46f8336813e4c48d06a1aef294fdccf6", sourceReference: "fonts/46f8336813e4c48d06a1aef294fdccf6", templateId: "DYN016" },
{ assetId: "53ca6b704728520da50c145eabb2e635", sourceReference: "fonts/53ca6b704728520da50c145eabb2e635", templateId: "DYN007" },
{ assetId: "cca5efc0e02fb1bf62349bd68ef30fc1", sourceReference: "fonts/cca5efc0e02fb1bf62349bd68ef30fc1", templateId: "DYN015" },
{ assetId: "dd25b35dcb7ba4476cbaa9a9592e39e2", sourceReference: "fonts/dd25b35dcb7ba4476cbaa9a9592e39e2", templateId: "DYN001" },
{ assetId: "e4210c9872f0c279b35273f230809821", sourceReference: "fonts/e4210c9872f0c279b35273f230809821", templateId: "DYN011" },
{ assetId: "f4bfd4132df2d6be97ceabadf3853505", sourceReference: "fonts/f4bfd4132df2d6be97ceabadf3853505", templateId: "DYN008" },
] as const;
// Derived from exact package-hash matches between the 32 frozen templates and the 86-item font panel.
export const P0A_REQUIRED_FONT_PANEL_IDS = [
"FONT005", "FONT008", "FONT011", "FONT021", "FONT022", "FONT027",
"FONT039", "FONT043", "FONT046", "FONT052", "FONT081",
] as const;
export const P0A_COLOR_CARD_IDS = ["COLOR001", "COLOR002", "COLOR008", "COLOR016"] as const;
export const P0A_DYNAMIC_STICKER_IDS = [
"DYN001", "DYN002", "DYN003", "DYN004", "DYN007",
"DYN008", "DYN011", "DYN012", "DYN015", "DYN016",
export const P0A_DYNAMIC_RUNTIME_IMAGE_SOURCES = [
{ assetId: "DYN001-image28", sourceReference: "resource/image28.png", templateId: "DYN001" },
{ assetId: "DYN002-image29", sourceReference: "resource/image29.png", templateId: "DYN002" },
{ assetId: "DYN003-image30", sourceReference: "resource/image30.png", templateId: "DYN003" },
{ assetId: "DYN004-image32", sourceReference: "resource/image32.png", templateId: "DYN004" },
{ assetId: "DYN008-backendui0", sourceReference: "resource/backendui0.png", templateId: "DYN008" },
{ assetId: "DYN011-backendui0", sourceReference: "resource/backendui0.png", templateId: "DYN011" },
{ assetId: "DYN015-imager2", sourceReference: "resource/imager2_2.png", templateId: "DYN015" },
{ assetId: "DYN016-image21", sourceReference: "resource/image21.png", templateId: "DYN016" },
] as const;
export type RegisteredComplexFamily = "color_card" | "font_panel" | "interactive_sticker" | "text_template";
@@ -52,12 +71,12 @@ export interface P0aPublicManifest {
text_templates: PublicComplexAsset[];
};
counts: {
color_cards: 4;
dynamic_stickers: 10;
font_panel_items: 11;
color_cards: 16;
dynamic_stickers: 35;
font_panel_items: 86;
static_parts: 25;
static_stickers: 1407;
text_templates: 32;
text_templates: 332;
};
release_tier: "alpha_whitelist";
release_version: string;
@@ -116,15 +135,6 @@ function validateStaticCatalog(catalog: StaticStickerCatalog) {
if (Object.keys(catalog.part_counts).length !== 25) throw new Error("static sticker part counts must contain 25 parts");
}
function fontIdsForTemplates(templates: readonly RegisteredComplexAsset[]) {
const referenced = new Set<string>();
for (const template of templates) {
for (const fontId of template.font_panel_references ?? []) referenced.add(fontId);
}
referenced.add("FONT081");
return [...referenced].sort((left, right) => Number(left.slice(4)) - Number(right.slice(4)));
}
export function createP0aPublicManifest(input: {
complexManifest: ComplexRegistryManifest;
staticCatalog: StaticStickerCatalog;
@@ -133,11 +143,7 @@ export function createP0aPublicManifest(input: {
validateStaticCatalog(input.staticCatalog);
const textTemplates = orderedItems(input.complexManifest.items, P0A_TEXT_TEMPLATE_IDS, "text_template");
const derivedFontIds = fontIdsForTemplates(textTemplates);
if (JSON.stringify(derivedFontIds) !== JSON.stringify(P0A_REQUIRED_FONT_PANEL_IDS)) {
throw new Error(`P0-A referenced font panel mismatch: received ${derivedFontIds.join(",")}`);
}
const fontPanelItems = orderedItems(input.complexManifest.items, derivedFontIds, "font_panel");
const fontPanelItems = orderedItems(input.complexManifest.items, P0A_REQUIRED_FONT_PANEL_IDS, "font_panel");
const colorCards = orderedItems(input.complexManifest.items, P0A_COLOR_CARD_IDS, "color_card");
const dynamicStickers = orderedItems(input.complexManifest.items, P0A_DYNAMIC_STICKER_IDS, "interactive_sticker");
@@ -150,12 +156,12 @@ export function createP0aPublicManifest(input: {
text_templates: textTemplates.map(publicItem),
},
counts: {
color_cards: 4,
dynamic_stickers: 10,
font_panel_items: 11,
color_cards: 16,
dynamic_stickers: 35,
font_panel_items: 86,
static_parts: 25,
static_stickers: 1_407,
text_templates: 32,
text_templates: 332,
},
release_tier: "alpha_whitelist",
release_version: input.complexManifest.release_version,
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
forbidOnly: true,
fullyParallel: false,
outputDir: process.env.DADA_PLAYWRIGHT_OUTPUT_DIR ?? "test-results/wp4-07",
projects: [
{ name: "chrome", use: { channel: "chrome" } },
{ name: "edge", use: { channel: "msedge" } },
],
reporter: "line",
retries: 0,
testDir: "./tests/e2e",
testMatch: "wp4-07-visual-performance.spec.ts",
timeout: 360_000,
use: {
deviceScaleFactor: 1,
headless: true,
launchOptions: { args: ["--enable-precise-memory-info", "--force-device-scale-factor=1"] },
locale: "zh-CN",
timezoneId: "Asia/Shanghai",
trace: "off",
viewport: { height: 1080, width: 1920 },
},
workers: 1,
});
+3
View File
@@ -133,6 +133,9 @@ importers:
drizzle-orm:
specifier: 0.45.2
version: 0.45.2(@types/better-sqlite3@7.6.13)(better-sqlite3@13.0.1)
sharp:
specifier: 0.35.3
version: 0.35.3(@types/node@24.13.3)
devDependencies:
'@types/better-sqlite3':
specifier: 7.6.13
+80
View File
@@ -0,0 +1,80 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { WP4_07_PERFORMANCE_BUDGETS } from "../tests/visual-performance/wp4-07-fixture.mjs";
const evidenceIndex = process.argv.indexOf("--evidence");
const phaseIndex = process.argv.indexOf("--phase");
const evidenceDirectory = resolve(evidenceIndex >= 0 ? process.argv[evidenceIndex + 1] : "");
const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green";
if (evidenceIndex < 0 || !process.argv[evidenceIndex + 1] || !["green", "red"].includes(phase)) throw new Error("Usage: --evidence <directory> [--phase red|green]");
function read(browser, filename) {
const path = resolve(evidenceDirectory, browser, filename);
if (!existsSync(path)) throw new Error(`missing ${browser}/${filename}`);
return JSON.parse(readFileSync(path, "utf8"));
}
const browserResults = {};
let greenInputsEligible = true;
for (const browser of ["chrome", "edge"]) {
const raw = read(browser, "performance-raw.json");
const memory = read(browser, "memory.json");
const dom = read(browser, "dom-count.json");
const environment = read(browser, "environment.json");
greenInputsEligible = greenInputsEligible && raw.eligible_for_green === true && raw.harness_mode === "real_archive";
if (raw.interaction.length !== 5 || raw.autosave_serialization.length !== 5 || raw.export_1080x1920.length !== 5 || raw.editor_reopen.samples_ms.length !== 5) {
throw new Error(`${browser} did not retain exactly five measured samples per budget`);
}
if (raw.interaction.some((run) => run.duration_ms < WP4_07_PERFORMANCE_BUDGETS.interaction_duration_ms)) {
throw new Error(`${browser} shortened a ten-second interaction measurement`);
}
const checks = {
autosave_serialization: raw.autosave_serialization.every((run) => run.p95_ms <= WP4_07_PERFORMANCE_BUDGETS.autosave_serialization_p95_ms_max),
canvas_frame: raw.interaction.every((run) => run.frame_p95_ms <= WP4_07_PERFORMANCE_BUDGETS.canvas_frame_p95_ms_max),
continuous_unresponsive: raw.interaction.every((run) => run.frame_max_ms < WP4_07_PERFORMANCE_BUDGETS.continuous_unresponsive_ms_max_exclusive),
dom_bounded: dom.bounded_by_viewport_and_two_screens === true && dom.top_count < dom.catalog_count && dom.bottom_count < dom.catalog_count,
editor_reopen: raw.editor_reopen.max_ms <= WP4_07_PERFORMANCE_BUDGETS.editor_reopen_ms_max,
export_duration: raw.export_1080x1920.every((run) => run.duration_ms <= WP4_07_PERFORMANCE_BUDGETS.export_1080x1920_ms_max),
export_failure_isolated: raw.export_failure.observed_error === "export_asset_unavailable"
&& JSON.stringify(raw.export_failure.saves_before) === JSON.stringify(raw.export_failure.saves_after),
export_memory: memory.export_peak_additional_bytes.every((value) => value <= WP4_07_PERFORMANCE_BUDGETS.export_peak_additional_bytes_max),
long_task: raw.interaction.every((run) => run.long_task_max_ms <= WP4_07_PERFORMANCE_BUDGETS.long_task_ms_max),
pointer_to_frame: raw.interaction.every((run) => run.pointer_to_frame_p95_ms <= WP4_07_PERFORMANCE_BUDGETS.pointer_to_frame_p95_ms_max),
};
browserResults[browser] = {
checks,
environment,
fixture_sha256: raw.fixture_sha256,
metrics: {
autosave_serialization: raw.autosave_serialization,
editor_reopen: raw.editor_reopen,
export_1080x1920: raw.export_1080x1920,
interaction: raw.interaction,
},
status: Object.values(checks).every(Boolean) ? "within_budget" : "budget_exceeded",
};
}
const passed = Object.values(browserResults).every((result) => result.status === "within_budget");
const report = {
browsers: browserResults,
eligible_for_green: phase === "green" && greenInputsEligible,
normative_budgets: WP4_07_PERFORMANCE_BUDGETS,
phase,
status: passed ? "within_budget" : "budget_exceeded",
};
writeFileSync(resolve(evidenceDirectory, "performance.json"), `${JSON.stringify(report, null, 2)}\n`);
writeFileSync(resolve(evidenceDirectory, "memory.json"), `${JSON.stringify({
browsers: Object.fromEntries(["chrome", "edge"].map((browser) => [browser, read(browser, "memory.json")])),
limit_bytes: WP4_07_PERFORMANCE_BUDGETS.export_peak_additional_bytes_max,
}, null, 2)}\n`);
writeFileSync(resolve(evidenceDirectory, "dom-count.json"), `${JSON.stringify({
browsers: Object.fromEntries(["chrome", "edge"].map((browser) => [browser, read(browser, "dom-count.json")])),
required_catalog_count: 1_407,
}, null, 2)}\n`);
writeFileSync(resolve(evidenceDirectory, "environment.json"), `${JSON.stringify({
browsers: Object.fromEntries(["chrome", "edge"].map((browser) => [browser, read(browser, "environment.json")])),
fixture_hashes_match: browserResults.chrome.fixture_sha256 === browserResults.edge.fixture_sha256,
}, null, 2)}\n`);
console.log(JSON.stringify({ browser_statuses: Object.fromEntries(Object.entries(browserResults).map(([name, result]) => [name, result.status])), phase, status: report.status }, null, 2));
if (phase === "green" && (!greenInputsEligible || !passed)) process.exit(1);
+11 -1
View File
@@ -1,10 +1,20 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { buildAndValidatePortablePackage } from "./lib/portable-package.mjs";
import { validateFinalReleaseRecord } from "./lib/wp7-07-final-release.mjs";
const outputIndex = process.argv.indexOf("--output");
const outputRoot = outputIndex >= 0 ? resolve(process.argv[outputIndex + 1]) : resolve(".build", "portable-release");
const result = await buildAndValidatePortablePackage({ outputRoot });
const previousRelease = JSON.parse(readFileSync(resolve("RELEASE.json"), "utf8"));
const releaseRecord = validateFinalReleaseRecord({
...previousRelease,
buildCommit: execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(),
maintenanceFromCommit: previousRelease.buildCommit,
recordedAt: new Date().toISOString(),
});
const result = await buildAndValidatePortablePackage({ outputRoot, releaseRecord });
console.log(JSON.stringify({
package: result.packageManifest.package_name,
sha256: result.packageManifest.zip_sha256,
+106
View File
@@ -0,0 +1,106 @@
import { chromium } from "@playwright/test";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { WP4_07_VISUAL_THRESHOLDS } from "../tests/visual-performance/wp4-07-fixture.mjs";
const evidenceIndex = process.argv.indexOf("--evidence");
const phaseIndex = process.argv.indexOf("--phase");
const evidenceDirectory = resolve(evidenceIndex >= 0 ? process.argv[evidenceIndex + 1] : "");
const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green";
if (evidenceIndex < 0 || !process.argv[evidenceIndex + 1] || !["green", "red"].includes(phase)) throw new Error("Usage: --evidence <directory> [--phase red|green]");
const scenarios = ["editor.png", "canvas.png", "export-dialog.png"];
for (const scenario of scenarios) {
for (const browser of ["chrome", "edge"]) {
const path = resolve(evidenceDirectory, browser, scenario);
if (!existsSync(path)) throw new Error(`missing screenshot: ${browser}/${scenario}`);
}
}
const browser = await chromium.launch({ channel: "msedge", headless: true });
const page = await browser.newPage();
const results = [];
try {
for (const scenario of scenarios) {
const chrome = readFileSync(resolve(evidenceDirectory, "chrome", scenario)).toString("base64");
const edge = readFileSync(resolve(evidenceDirectory, "edge", scenario)).toString("base64");
const comparison = await page.evaluate(async ({ chromeBase64, edgeBase64, threshold }) => {
const decode = async (base64) => {
const binary = atob(base64);
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
return createImageBitmap(new Blob([bytes], { type: "image/png" }));
};
const [chromeImage, edgeImage] = await Promise.all([decode(chromeBase64), decode(edgeBase64)]);
if (chromeImage.width !== edgeImage.width || chromeImage.height !== edgeImage.height) {
return { dimensions_match: false, chrome: { height: chromeImage.height, width: chromeImage.width }, edge: { height: edgeImage.height, width: edgeImage.width } };
}
const surface = new OffscreenCanvas(chromeImage.width, chromeImage.height);
const context = surface.getContext("2d", { willReadFrequently: true });
context.drawImage(chromeImage, 0, 0);
const chromePixels = context.getImageData(0, 0, chromeImage.width, chromeImage.height).data;
context.clearRect(0, 0, chromeImage.width, chromeImage.height);
context.drawImage(edgeImage, 0, 0);
const edgePixels = context.getImageData(0, 0, edgeImage.width, edgeImage.height).data;
let significant = 0;
let maximumChannelDelta = 0;
for (let index = 0; index < chromePixels.length; index += 4) {
const deltas = [0, 1, 2, 3].map((channel) => Math.abs(chromePixels[index + channel] - edgePixels[index + channel]));
maximumChannelDelta = Math.max(maximumChannelDelta, ...deltas);
if (deltas.some((delta) => delta > threshold)) significant += 1;
}
const total = chromeImage.width * chromeImage.height;
chromeImage.close();
edgeImage.close();
return {
dimensions_match: true,
height: surface.height,
maximum_channel_delta: maximumChannelDelta,
significant_pixel_count: significant,
significant_pixel_ratio: significant / total,
significant_pixel_threshold: threshold,
total_pixels: total,
width: surface.width,
};
}, { chromeBase64: chrome, edgeBase64: edge, threshold: WP4_07_VISUAL_THRESHOLDS.channel_delta_significant });
results.push({ scenario, ...comparison });
}
} finally {
await browser.close();
}
const chromeLayout = JSON.parse(readFileSync(resolve(evidenceDirectory, "chrome", "layout-boxes.json"), "utf8"));
const edgeLayout = JSON.parse(readFileSync(resolve(evidenceDirectory, "edge", "layout-boxes.json"), "utf8"));
const greenInputsEligible = [chromeLayout, edgeLayout].every((input) => input.eligible_for_green === true && input.harness_mode === "real_archive");
const layoutComparisons = Object.keys(chromeLayout.layout_boxes).map((name) => {
const chromeBox = chromeLayout.layout_boxes[name];
const edgeBox = edgeLayout.layout_boxes[name];
const deltas = Object.fromEntries(["height", "width", "x", "y"].map((field) => [field, Math.abs(chromeBox[field] - edgeBox[field])]));
return { deltas, maximum_delta_px: Math.max(...Object.values(deltas)), name };
});
const visualPassed = results.every((item) => item.dimensions_match && item.significant_pixel_ratio <= WP4_07_VISUAL_THRESHOLDS.significant_pixel_ratio_max);
const layoutPassed = layoutComparisons.every((item) => item.maximum_delta_px <= WP4_07_VISUAL_THRESHOLDS.boundary_delta_px_max);
const overall = {
eligible_for_green: phase === "green" && greenInputsEligible,
phase,
scenarios: results,
status: visualPassed && layoutPassed ? "within_threshold" : "threshold_exceeded_manual_review_required",
thresholds: WP4_07_VISUAL_THRESHOLDS,
};
const layout = {
comparisons: layoutComparisons,
eligible_for_green: phase === "green" && greenInputsEligible,
status: layoutPassed ? "within_threshold" : "threshold_exceeded_manual_review_required",
threshold_px: WP4_07_VISUAL_THRESHOLDS.boundary_delta_px_max,
};
mkdirSync(dirname(resolve(evidenceDirectory, "pixel-diff.json")), { recursive: true });
writeFileSync(resolve(evidenceDirectory, "pixel-diff.json"), `${JSON.stringify(overall, null, 2)}\n`);
writeFileSync(resolve(evidenceDirectory, "layout-boxes.json"), `${JSON.stringify(layout, null, 2)}\n`);
writeFileSync(resolve(evidenceDirectory, "manual-review.json"), `${JSON.stringify({
eligible_for_green: false,
known_alternatives: ["COLOR002", "COLOR008", "COLOR016", "DYN012"],
required_note: "DYN012 uses FONT081 Lexend Deca as the declared substitute.",
status: phase === "green" ? "pending_project_owner_review" : "pending_wp5_final_renderer_and_project_owner_review",
}, null, 2)}\n`);
console.log(JSON.stringify({ layout_status: layout.status, phase, visual_status: overall.status }, null, 2));
if (phase === "green" && (!greenInputsEligible || !visualPassed || !layoutPassed)) process.exit(1);
+33 -11
View File
@@ -1,14 +1,16 @@
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { dirname, join, resolve } from "node:path";
import { compileAssetArchive, compileStaticStickerCatalog } from "../packages/asset-compiler/dist/index.js";
import { createP0aColorCardRenderPlans } from "../packages/asset-renderer/dist/index.js";
import {
P0A_COLOR_CARD_IDS,
P0A_COMPLEX_RELEASE_VERSION,
P0A_DYNAMIC_STICKER_IDS,
P0A_REQUIRED_FONT_PANEL_IDS,
P0A_STATIC_STICKER_RELEASE_VERSION,
P0A_TEXT_TEMPLATE_IDS,
createP0aPublicManifest,
} from "../packages/template-registry/dist/index.js";
@@ -16,25 +18,45 @@ import {
const runDirectory = resolve(process.env.DADA_WP5_03_RUN_DIRECTORY ?? "artifacts/tdd/wp5-03-local");
const whiteDirectory = resolve(process.env.DADA_WP5_03_WHITE_EVIDENCE_DIR ?? join(runDirectory, "cases", "TDD-WP5-WHITE-001-p0a-allowlist"));
const colorDirectory = resolve(process.env.DADA_WP5_03_COLOR_EVIDENCE_DIR ?? join(runDirectory, "cases", "TDD-WP5-COL-001-four-layouts"));
const handoffManifest = resolve(process.env.DADA_COMPLEX_ASSET_MANIFEST ?? join(homedir(), "Desktop", "sticker_web_handoff", "sticker_web_catalog_manifest.json"));
const stickerRoot = resolve(process.env.DADA_STATIC_STICKER_ROOT ?? join(homedir(), "Desktop", "贴纸素材"));
const replicationRoot = resolve(process.env.DADA_REPLICATION_ASSET_ROOT ?? join(homedir(), "Desktop", "sticker_web_replication_assets"));
const handoffManifest = resolve(process.env.DADA_COMPLEX_ASSET_MANIFEST ?? join(replicationRoot, "sticker_web_handoff", "sticker_web_catalog_manifest.json"));
const stickerRoot = resolve(process.env.DADA_STATIC_STICKER_ROOT ?? join(replicationRoot, "sticker_normal"));
if (!existsSync(handoffManifest)) throw new Error("normalized complex asset handoff is unavailable");
if (!existsSync(stickerRoot)) throw new Error("static sticker source is unavailable");
const complexDirectory = resolve(runDirectory, "inputs", "complex");
const staticDirectory = resolve(runDirectory, "inputs", "static");
const normalizedHandoffDirectory = resolve(runDirectory, "inputs", "normalized-handoff");
mkdirSync(whiteDirectory, { recursive: true });
mkdirSync(colorDirectory, { recursive: true });
const sourceHandoff = JSON.parse(readFileSync(handoffManifest, "utf8"));
const normalizedHandoff = {
...sourceHandoff,
web_handoff: "STICKER_WEB_REPLICATION_HANDOFF.md",
validation: "sticker_archive_validation_20260722.json",
collections: sourceHandoff.collections
.filter((collection) => collection.id !== "normal_stickers")
.map((collection) => ({
...collection,
root: resolve(dirname(handoffManifest), collection.root),
})),
};
const normalizedHandoffPath = resolve(normalizedHandoffDirectory, "sticker_web_catalog_manifest.normalized.json");
mkdirSync(normalizedHandoffDirectory, { recursive: true });
copyFileSync(resolve(dirname(handoffManifest), sourceHandoff.web_handoff), resolve(normalizedHandoffDirectory, normalizedHandoff.web_handoff));
copyFileSync(resolve(dirname(handoffManifest), sourceHandoff.validation), resolve(normalizedHandoffDirectory, normalizedHandoff.validation));
writeFileSync(normalizedHandoffPath, `${JSON.stringify(normalizedHandoff, null, 2)}\n`);
const complex = compileAssetArchive({
manifestPath: handoffManifest,
manifestPath: normalizedHandoffPath,
outputDirectory: complexDirectory,
releaseVersion: "p0a-complex-v1",
releaseVersion: P0A_COMPLEX_RELEASE_VERSION,
});
const staticResult = compileStaticStickerCatalog({
expectedCount: 1_407,
outputDirectory: staticDirectory,
releaseVersion: "p0a-static-v1",
releaseVersion: P0A_STATIC_STICKER_RELEASE_VERSION,
sourceRoot: stickerRoot,
});
const publicManifest = createP0aPublicManifest({
@@ -68,17 +90,17 @@ const familyCounts = Object.fromEntries(["text_template", "font_panel", "color_c
]));
const fullP0Enabled = complex.manifest.items.filter((item) => item.release_tier === "full_p0" && item.release_status === "enabled").length;
const publicJson = JSON.stringify(manifest);
const hiddenIds = ["FLOWER009", "H009", "TAG008", "SIMPLE009", "COLOR003", "DYN005"];
const completionIds = ["FLOWER145", "H119", "TAG051", "SIMPLE017", "FONT086", "COLOR016", "DYN035"];
const response = {
counts: manifest.counts,
full_p0_enabled: fullP0Enabled,
hidden_ids_absent: hiddenIds.every((id) => !publicJson.includes(id)),
complete_catalog_present: completionIds.every((id) => publicJson.includes(id)),
no_absolute_paths: !/[A-Za-z]:[\\/]/.test(publicJson),
release_tier: manifest.release_tier,
status: "passed",
};
const registrationValidation = {
allowlist: {
public_catalog: {
color_cards: P0A_COLOR_CARD_IDS,
dynamic_stickers: P0A_DYNAMIC_STICKER_IDS,
font_panel_items: P0A_REQUIRED_FONT_PANEL_IDS,
@@ -90,7 +112,7 @@ const registrationValidation = {
source_mutations: complex.report.source_mutations + staticResult.report.source_mutations,
static_parts: Object.keys(staticResult.catalog.part_counts).length,
static_stickers: staticResult.catalog.count,
status: response.hidden_ids_absent && response.no_absolute_paths && fullP0Enabled === 0 ? "passed" : "failed",
status: response.complete_catalog_present && response.no_absolute_paths && fullP0Enabled === 0 ? "passed" : "failed",
};
if (registrationValidation.status !== "passed") throw new Error("P0-A registration validation failed");
+37
View File
@@ -0,0 +1,37 @@
import { readFileSync } from "node:fs";
import { isAbsolute, join, resolve } from "node:path";
import {
buildP0aRuntimeAssetPlan,
defaultReplicationRoot,
deployRuntimeAssetPlan,
readRuntimeAssetManifest,
serializeRuntimeAssetManifest,
} from "./lib/runtime-assets.mjs";
function option(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
function defaultConfigPath() {
if (!process.env.LOCALAPPDATA || !isAbsolute(process.env.LOCALAPPDATA)) throw new Error("local_app_data_unavailable");
return join(process.env.LOCALAPPDATA, "Dada", "P0A", "config", "instance.json");
}
const configFile = resolve(option("--config") ?? process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultConfigPath());
const configuration = JSON.parse(readFileSync(configFile, "utf8"));
const assetRootCandidate = option("--asset-root") ?? configuration.asset_root;
if (typeof assetRootCandidate !== "string" || !isAbsolute(assetRootCandidate)) {
throw new Error("asset_root_configuration_invalid");
}
const assetRoot = resolve(assetRootCandidate);
const trustedManifest = readRuntimeAssetManifest(resolve(option("--trusted-manifest") ?? "config/runtime-assets-manifest.json"));
const plan = await buildP0aRuntimeAssetPlan({
replicationRoot: resolve(option("--replication-root") ?? defaultReplicationRoot()),
});
if (serializeRuntimeAssetManifest(plan.manifest) !== serializeRuntimeAssetManifest(trustedManifest)) {
throw new Error("runtime_asset_source_does_not_match_trusted_manifest");
}
const result = deployRuntimeAssetPlan({ allowManagedUpdate: true, assetRoot, manifest: trustedManifest, resources: plan.resources });
process.stdout.write(`${JSON.stringify({ linked_files: result.linked_files, status: result.status })}\n`);
+1
View File
@@ -44,6 +44,7 @@ export const frozenPackages = {
dependencies: {
"better-sqlite3": "13.0.1",
"drizzle-orm": "0.45.2",
sharp: "0.35.3",
},
devDependencies: {
typescript: "7.0.2",
@@ -0,0 +1,133 @@
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { homedir } from "node:os";
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
import {
P0A_DYNAMIC_STICKER_IDS,
P0A_REQUIRED_FONT_PANEL_IDS,
P0A_TEXT_TEMPLATE_IDS,
} from "../packages/template-registry/dist/index.js";
import { compileTextTemplateAssets } from "./lib/text-template-assets.mjs";
function option(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
function readJson(path) {
return JSON.parse(readFileSync(path, "utf8"));
}
function oneDirectoryWithPrefix(root, prefix) {
const matches = readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.startsWith(`${prefix}_`));
if (matches.length !== 1) throw new Error(`complex_catalog_directory_invalid:${prefix}`);
return join(root, matches[0].name);
}
function textTemplateDirectory(root, templateId) {
const family = templateId.startsWith("FLOWER") ? "花字"
: templateId.startsWith("SIMPLE") ? "简约"
: templateId.startsWith("TAG") ? "标签"
: "标题";
return join(root, family, "templates", templateId);
}
function normalizedTextCategory(value) {
if (value === "花字") return "flower";
if (value === "简约") return "simple";
if (value === "标签") return "tag";
return "title";
}
function normalizedDynamicCategory(value) {
if (value === "user") return "identity";
if (value === "location" || value === "time") return value;
return "other";
}
const replicationRoot = resolve(option("--replication-root") ?? join(homedir(), "Desktop", "sticker_web_replication_assets"));
if (!isAbsolute(replicationRoot) || !existsSync(replicationRoot)) throw new Error("replication_asset_root_unavailable");
const outputPath = resolve(option("--output") ?? "apps/web/src/generated/complex-assets.json");
const fontPackagesRoot = join(replicationRoot, "sticker_text", "字体", "面板全量采集", "font_panel_full_20260722", "resources", "font_packages");
const textRoot = join(replicationRoot, "sticker_text", "模板", "单模板归档");
const dynamicRoot = join(replicationRoot, "sticker_interactive", "单模板归档", "templates");
const fontPanelItems = P0A_REQUIRED_FONT_PANEL_IDS.map((fontId, displayOrder) => {
const metadata = readJson(join(oneDirectoryWithPrefix(fontPackagesRoot, fontId), "metadata.json"));
if (typeof metadata.local_sha256 !== "string") throw new Error(`complex_catalog_font_hash_missing:${fontId}`);
return {
display_name: String(metadata.display_name ?? fontId),
display_order: displayOrder,
font_id: fontId,
};
});
const textResourceRevision = createHash("sha256");
const textTemplates = P0A_TEXT_TEMPLATE_IDS.map((templateId, catalogOrder) => {
const directory = textTemplateDirectory(textRoot, templateId);
const metadata = readJson(join(directory, "metadata.json"));
const compiled = compileTextTemplateAssets({ templateDirectory: directory, templateId });
if (compiled.diagnostics.unresolved_images !== 0) throw new Error(`text_template_image_reference_unresolved:${templateId}`);
for (const resource of compiled.resources) {
const bytes = resource.sourceBytes ?? readFileSync(resource.sourcePath);
textResourceRevision.update(resource.assetId).update(createHash("sha256").update(bytes).digest());
}
const previewReference = typeof metadata.files?.preview === "string" ? metadata.files.preview : undefined;
const hasPreview = previewReference ? existsSync(join(directory, ...previewReference.split("/"))) : false;
return {
available: true,
catalog_order: catalogOrder,
category: normalizedTextCategory(metadata.category),
...compiled.catalog,
display_name: String(metadata.display_name || metadata.default_text || compiled.catalog.default_text || templateId),
...(hasPreview ? { preview_asset_id: `TEXT-PREVIEW-${templateId}` } : {}),
resource_class: metadata.resource_class === "parameter_only" ? "parameter_only" : "zip_template",
template_id: templateId,
};
});
const dynamicStickers = P0A_DYNAMIC_STICKER_IDS.map((templateId, catalogOrder) => {
const metadata = readJson(join(dynamicRoot, templateId, "metadata.json"));
const requiredFields = Array.isArray(metadata.dynamic_keys) ? metadata.dynamic_keys.map(String) : [];
const fontIds = Array.isArray(metadata.files?.fonts)
? metadata.files.fonts.map((reference) => basename(reference))
: [];
return {
catalog_order: catalogOrder,
category: normalizedDynamicCategory(metadata.category),
display_name: String(metadata.display_name ?? templateId),
font_ids: fontIds.length > 0 ? fontIds : ["FONT081"],
required_fields: requiredFields,
requires_location_consent: requiredFields.includes("latitude") || requiredFields.includes("longitude"),
source_candidate_id: String(metadata.source_candidate_id ?? metadata.display_name ?? templateId),
template_id: templateId,
};
});
const catalog = {
dynamic_stickers: dynamicStickers,
font_panel_items: fontPanelItems,
schema_version: "DadaComplexBrowserCatalog/v2",
text_resource_revision: textResourceRevision.digest("hex").slice(0, 16),
text_templates: textTemplates,
};
const serialized = `${JSON.stringify(catalog, null, 2)}\n`;
function containsAbsolutePath(value) {
if (typeof value === "string") return /^[A-Z]:[\\/]/i.test(value);
if (Array.isArray(value)) return value.some(containsAbsolutePath);
return value && typeof value === "object" ? Object.values(value).some(containsAbsolutePath) : false;
}
const unsafeTemplateIds = textTemplates.filter(containsAbsolutePath).map((item) => item.template_id);
if (unsafeTemplateIds.length > 0) throw new Error(`complex_catalog_absolute_path_detected:${unsafeTemplateIds.join(",")}`);
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, serialized);
process.stdout.write(`${JSON.stringify({
dynamic_stickers: dynamicStickers.length,
font_panel_items: fontPanelItems.length,
output: outputPath,
text_previews: textTemplates.filter((item) => item.preview_asset_id).length,
text_templates: textTemplates.length,
})}\n`);
@@ -0,0 +1,23 @@
import { resolve } from "node:path";
import {
buildP0aRuntimeAssetPlan,
defaultReplicationRoot,
serializeRuntimeAssetManifest,
writeRuntimeAssetManifest,
} from "./lib/runtime-assets.mjs";
function option(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
const replicationRoot = resolve(option("--replication-root") ?? defaultReplicationRoot());
const outputPath = resolve(option("--output") ?? "config/runtime-assets-manifest.json");
const plan = await buildP0aRuntimeAssetPlan({ replicationRoot });
writeRuntimeAssetManifest(outputPath, plan.manifest);
process.stdout.write(`${JSON.stringify({
counts: plan.manifest.counts,
manifest_bytes: Buffer.byteLength(serializeRuntimeAssetManifest(plan.manifest)),
status: "generated",
})}\n`);
+52 -16
View File
@@ -18,6 +18,7 @@ import { tmpdir } from "node:os";
import { basename, dirname, join, relative, resolve, sep } from "node:path";
import { frozenRuntime } from "../frozen-versions.mjs";
import { readRuntimeAssetManifest } from "./runtime-assets.mjs";
const repositoryRoot = resolve(import.meta.dirname, "..", "..");
const fixedPort = 43121;
@@ -165,7 +166,7 @@ function copyApplication(source, destination, runtimeDependencies) {
function buildArtifacts(stagingRoot) {
debug("build workspace artifacts");
run("pnpm", ["--filter", "@dada/shared-contracts", "build"]);
run("pnpm", ["build:workspace-packages"]);
run("pnpm", ["--filter", "@dada/web", "build"]);
run("pnpm", ["--filter", "@dada/api", "build"]);
run("pnpm", ["--filter", "@dada/worker", "build"]);
@@ -208,7 +209,7 @@ async function waitForHealth(child) {
throw new Error("Packaged API did not become healthy on fixed port 43121.", { cause: lastError });
}
async function verifyExtractedPackage(zipPath, packageName) {
export async function verifyExtractedPackage(zipPath, packageName, expectedSupport) {
const extractRoot = mkdtempSync(join(tmpdir(), "dada-wp0-09-"));
try {
const escapedZip = zipPath.replaceAll("'", "''");
@@ -235,21 +236,37 @@ async function verifyExtractedPackage(zipPath, packageName) {
});
try {
const health = await waitForHealth(api);
const brands = [
{ brand: "Not_A Brand", version: "99" },
{ brand: "Chromium", version: String(expectedSupport.major) },
{ brand: expectedSupport.brand, version: String(expectedSupport.major) },
];
const fullVersionList = [
{ brand: "Not_A Brand", version: "99.0.0.0" },
{ brand: "Chromium", version: expectedSupport.fullVersion },
{ brand: expectedSupport.brand, version: expectedSupport.fullVersion },
];
const serializeBrands = (values) => values.map(({ brand, version }) => `"${brand}";v="${version}"`).join(", ");
const releaseGate = await fetch(`http://127.0.0.1:${fixedPort}/api/v1/support/check`, {
body: JSON.stringify({
brands: [{ brand: "Google Chrome", version: "150" }],
full_version_list: [{ brand: "Google Chrome", version: "150.0.0.0" }],
brands,
full_version_list: fullVersionList,
platform: "Windows",
}),
headers: {
"content-type": "application/json",
"sec-ch-ua": '"Google Chrome";v="150"',
"sec-ch-ua-full-version-list": '"Google Chrome";v="150.0.0.0"',
host: `127.0.0.1:${fixedPort}`,
origin: `http://127.0.0.1:${fixedPort}`,
"sec-ch-ua": serializeBrands(brands),
"sec-ch-ua-full-version-list": serializeBrands(fullVersionList),
"sec-ch-ua-platform": '"Windows"',
},
method: "POST",
});
if (releaseGate.status !== 426) throw new Error(`Candidate RELEASE.json unexpectedly passed with ${releaseGate.status}.`);
if (releaseGate.status !== expectedSupport.statusCode) {
const responseBody = await releaseGate.text();
throw new Error(`Packaged RELEASE.json support gate returned ${releaseGate.status}; expected ${expectedSupport.statusCode}: ${responseBody}`);
}
return {
api: { executable: "runtime/node.exe", health, pid: api.pid, release_gate: { status_code: releaseGate.status }, status: "passed" },
native,
@@ -291,7 +308,7 @@ function scanPackage(packageDirectory) {
return { disallowed_matches: disallowedMatches, reparse_points: reparsePoints, scanned_files: files.length, status: disallowedMatches.length === 0 && reparsePoints.length === 0 ? "passed" : "failed" };
}
export async function buildAndValidatePortablePackage({ evidenceDirectory, outputRoot }) {
export async function buildAndValidatePortablePackage({ evidenceDirectory, outputRoot, releaseRecord }) {
if (process.platform !== frozenRuntime.os || process.arch !== frozenRuntime.arch || process.version.slice(1) !== frozenRuntime.node) {
throw new Error("Portable package build requires frozen Node 24.13.0 on win-x64.");
}
@@ -320,7 +337,7 @@ export async function buildAndValidatePortablePackage({ evidenceDirectory, outpu
debug("copy API application");
const apiDependencies = copyApplication(join(repositoryRoot, "apps", "api"), join(serverRoot, "api"), ["@fastify/multipart", "@fastify/swagger", "@sinclair/typebox", "better-sqlite3", "fastify", "sharp"]);
debug("copy Worker application");
const workerDependencies = copyApplication(join(repositoryRoot, "apps", "worker"), join(serverRoot, "worker"), ["better-sqlite3"]);
const workerDependencies = copyApplication(join(repositoryRoot, "apps", "worker"), join(serverRoot, "worker"), ["better-sqlite3", "sharp"]);
const sharedDestination = join(serverRoot, "api", "node_modules", "@dada", "shared-contracts");
mkdirSync(sharedDestination, { recursive: true });
copyTree(join(repositoryRoot, "packages", "shared-contracts", "dist"), join(sharedDestination, "dist"));
@@ -347,11 +364,15 @@ export async function buildAndValidatePortablePackage({ evidenceDirectory, outpu
copyTree(join(repositoryRoot, "apps", "web", "dist"), join(packageDirectory, "web"));
copyTree(join(repositoryRoot, "apps", "web", "support-gate"), join(packageDirectory, "web", "support-gate"));
writeJson(join(packageDirectory, "migrations", "manifest.json"), { migrations: [], schema_version: "0" });
writeJson(join(packageDirectory, "asset-metadata", "manifest.json"), { resources: [], schema_version: "1.0", source: "external_read_only" });
writeJson(
join(packageDirectory, "asset-metadata", "manifest.json"),
readRuntimeAssetManifest(join(repositoryRoot, "config", "runtime-assets-manifest.json")),
);
writeJson(join(packageDirectory, "LICENSES", "third-party.json"), { api: apiDependencies, runtime: { node: frozenRuntime.node }, schema_version: "1.0", worker: workerDependencies });
const commit = run("git", ["rev-parse", "HEAD"]);
writeJson(join(packageDirectory, "RELEASE.json"), {
const finalRelease = releaseRecord !== undefined;
writeJson(join(packageDirectory, "RELEASE.json"), releaseRecord ?? {
app_version: appVersion,
browsers: [],
build_commit: commit,
@@ -360,16 +381,20 @@ export async function buildAndValidatePortablePackage({ evidenceDirectory, outpu
windows_build: null,
});
writeFileSync(join(packageDirectory, "START-HERE.txt"), [
"Dada P0-A candidate package",
finalRelease ? "Dada P0-A first-version portable package" : "Dada P0-A candidate package",
"",
"This candidate is unsigned and is not a final P0-A release.",
finalRelease
? "This unsigned first-version package passed the local P0-A release gates recorded in RELEASE.json."
: "This candidate is unsigned and is not a final P0-A release.",
"Verify the adjacent SHA-256 file before first launch.",
"Windows SmartScreen may warn on first launch because the executable is unsigned.",
"For an antivirus alert, compare the package hash with the Gitea build record.",
"Do not disable antivirus protection, add broad exclusions, or skip hash verification.",
"To update, exit Dada from the tray and replace the complete program directory.",
"Dada uses 127.0.0.1:43121 and does not support LAN or remote access.",
"A final RELEASE.json is created only after WP-7 acceptance.",
finalRelease
? "Resend and Amap real-provider validation remain explicitly deferred and are not recorded as passed."
: "A final RELEASE.json is created only after WP-7 acceptance.",
"",
].join("\r\n"));
@@ -381,7 +406,18 @@ export async function buildAndValidatePortablePackage({ evidenceDirectory, outpu
const zipSha256 = fileSha256(zipPath);
const shaPath = `${zipPath}.sha256`;
writeFileSync(shaPath, `${zipSha256} ${basename(zipPath)}\n`);
const processTree = await verifyExtractedPackage(zipPath, packageName);
const supportBrowser = finalRelease ? releaseRecord.browsers[0] : undefined;
const processTree = await verifyExtractedPackage(zipPath, packageName, finalRelease ? {
brand: supportBrowser.brand,
fullVersion: supportBrowser.fullVersion,
major: Number.parseInt(supportBrowser.fullVersion.split(".")[0], 10),
statusCode: 200,
} : {
brand: "Google Chrome",
fullVersion: "150.0.0.0",
major: 150,
statusCode: 426,
});
const fileEntries = listFiles(packageDirectory).files.map((path) => ({
path: relative(packageDirectory, path).replaceAll("\\", "/"),
sha256: fileSha256(path),
@@ -392,7 +428,7 @@ export async function buildAndValidatePortablePackage({ evidenceDirectory, outpu
files: fileEntries,
fixed_port: fixedPort,
package_name: packageName,
release_status: "candidate_unvalidated",
release_status: finalRelease ? releaseRecord.releaseStatus : "candidate_unvalidated",
schema_version: "1.0",
zip_sha256: zipSha256,
};
+179
View File
@@ -0,0 +1,179 @@
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
statSync,
writeFileSync,
} from "node:fs";
import { basename, join, resolve } from "node:path";
import { buildAndValidatePortablePackage } from "./portable-package.mjs";
const fixedPort = 43121;
const versionPattern = /^\d+\.\d+\.\d+\.\d+$/;
const sha256Pattern = /^[A-F0-9]{64}$/;
function powershellJson(script) {
const result = spawnSync(
"powershell.exe",
["-NoProfile", "-NonInteractive", "-Command", script],
{ encoding: "utf8", windowsHide: true },
);
if (result.status !== 0) {
throw new Error(`Windows environment probe failed with exit code ${result.status ?? 1}.`);
}
return JSON.parse(result.stdout.trim());
}
export function readCandidateEnvironment() {
if (process.platform !== "win32" || process.arch !== "x64") {
throw new Error("Release candidates must be recorded on Windows x64.");
}
return powershellJson(String.raw`
$ErrorActionPreference = 'Stop'
function Find-Browser([string] $brand, [string] $fileName, [string[]] $candidates) {
$path = $candidates | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -First 1
if (-not $path) { throw "Required browser is not installed: $brand" }
$item = Get-Item -LiteralPath $path
if ($item.VersionInfo.ProductName -ne $brand) { throw "Installed executable identity mismatch: $brand" }
$version = $item.VersionInfo.ProductVersion
[pscustomobject]@{
brand = $brand
executable_sha256 = (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash
file_name = $fileName
full_version = $version
major = [int]($version.Split('.')[0])
product_name = $item.VersionInfo.ProductName
source = 'installed_executable'
}
}
$chromeRegistry = @(
(Get-ItemProperty 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe' -ErrorAction SilentlyContinue).'(default)',
(Get-ItemProperty 'Registry::HKEY_CURRENT_USER\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe' -ErrorAction SilentlyContinue).'(default)'
)
$chrome = Find-Browser 'Google Chrome' 'chrome.exe' @(
(Join-Path $env:LOCALAPPDATA 'Google\Chrome\Application\chrome.exe'),
'C:\Program Files\Google\Chrome\Application\chrome.exe',
'C:\Program Files (x86)\Google\Chrome\Application\chrome.exe',
$chromeRegistry[0],
$chromeRegistry[1]
)
$edge = Find-Browser 'Microsoft Edge' 'msedge.exe' @(
'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe',
'C:\Program Files\Microsoft\Edge\Application\msedge.exe'
)
$windows = Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion'
[pscustomobject]@{
browsers = @($chrome, $edge)
windows = [pscustomobject]@{
arch = 'x64'
build = "$($windows.CurrentBuildNumber).$($windows.UBR)"
display_version = $windows.DisplayVersion
}
} | ConvertTo-Json -Depth 5 -Compress
`);
}
export function validateReleaseCandidateRecord(record) {
const errors = [];
if (record?.schema_version !== "1.0") errors.push("schema_version");
if (record?.status !== "candidate_unvalidated") errors.push("status");
if (record?.final_release !== false) errors.push("final_release");
if (record?.fixed_port !== fixedPort) errors.push("fixed_port");
if (!/^[a-f0-9]{40}$/.test(record?.build_commit ?? "")) errors.push("build_commit");
if (!/^\d+\.\d+$/.test(record?.windows?.build ?? "")) errors.push("windows.build");
if (!Number.isFinite(Date.parse(record?.recorded_at ?? ""))) errors.push("recorded_at");
if (!sha256Pattern.test(record?.candidate_package?.sha256 ?? "")) errors.push("candidate_package.sha256");
if (record?.candidate_package?.fixed_port !== fixedPort) errors.push("candidate_package.fixed_port");
if (record?.candidate_package?.release_status !== "candidate_unvalidated") {
errors.push("candidate_package.release_status");
}
if (!Array.isArray(record?.browsers) || record.browsers.length !== 2) {
errors.push("browsers");
} else {
const brands = record.browsers.map(({ brand }) => brand).sort();
if (brands.join("|") !== "Google Chrome|Microsoft Edge") errors.push("browsers.brand");
for (const browser of record.browsers) {
if (!versionPattern.test(browser.full_version ?? "")) errors.push(`${browser.brand}.full_version`);
if (browser.major !== Number.parseInt(browser.full_version?.split(".")[0] ?? "", 10)) {
errors.push(`${browser.brand}.major`);
}
if (!sha256Pattern.test(browser.executable_sha256 ?? "")) errors.push(`${browser.brand}.sha256`);
if (browser.source !== "installed_executable") errors.push(`${browser.brand}.source`);
if ("path" in browser || "executable_path" in browser) errors.push(`${browser.brand}.path`);
}
}
const serialized = JSON.stringify(record);
if (/[A-Za-z]:\\Users\\/i.test(serialized)) errors.push("absolute_user_path");
if (errors.length > 0) throw new Error(`Invalid release candidate record: ${[...new Set(errors)].join(", ")}`);
return record;
}
function sha256(path) {
return createHash("sha256").update(readFileSync(path)).digest("hex").toUpperCase();
}
export async function createReleaseCandidate({ commit, evidenceRoot, outputRoot, recordedAt = new Date().toISOString() }) {
const environment = readCandidateEnvironment();
const packageResult = await buildAndValidatePortablePackage({ outputRoot });
const packageName = packageResult.packageManifest.package_name;
const packageDirectory = join(outputRoot, packageName);
const zipSource = join(outputRoot, `${packageName}.zip`);
const shaSource = `${zipSource}.sha256`;
const zipSha256 = sha256(zipSource);
if (zipSha256 !== packageResult.packageManifest.zip_sha256) {
throw new Error("Candidate ZIP hash does not match the package manifest.");
}
if (!readFileSync(shaSource, "utf8").startsWith(`${zipSha256} ${basename(zipSource)}`)) {
throw new Error("Candidate ZIP hash file does not match the package bytes.");
}
const candidatePackage = resolve(evidenceRoot, "candidate-package");
mkdirSync(candidatePackage, { recursive: true });
for (const source of [zipSource, shaSource, join(packageDirectory, "START-HERE.txt")]) {
if (!existsSync(source)) throw new Error(`Candidate package output is missing: ${basename(source)}`);
copyFileSync(source, join(candidatePackage, basename(source)));
}
writeFileSync(
join(candidatePackage, "package-manifest.json"),
`${JSON.stringify(packageResult.packageManifest, null, 2)}\n`,
);
writeFileSync(
join(candidatePackage, "package-scan.json"),
`${JSON.stringify(packageResult.packageScan, null, 2)}\n`,
);
writeFileSync(
join(candidatePackage, "process-tree.json"),
`${JSON.stringify(packageResult.processTree, null, 2)}\n`,
);
const record = validateReleaseCandidateRecord({
app_version: packageResult.packageManifest.app_version,
browsers: environment.browsers.map((browser) => ({
brand: browser.brand,
executable_sha256: browser.executable_sha256,
file_name: browser.file_name,
full_version: browser.full_version,
major: browser.major,
source: browser.source,
})),
build_commit: commit,
candidate_package: {
file_name: basename(zipSource),
fixed_port: packageResult.packageManifest.fixed_port,
release_status: packageResult.packageManifest.release_status,
sha256: zipSha256,
size_bytes: statSync(zipSource).size,
},
final_release: false,
fixed_port: fixedPort,
recorded_at: recordedAt,
schema_version: "1.0",
status: "candidate_unvalidated",
windows: environment.windows,
});
writeFileSync(resolve(evidenceRoot, "release-candidate.json"), `${JSON.stringify(record, null, 2)}\n`);
return { packageResult, record };
}
+145
View File
@@ -0,0 +1,145 @@
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { validateReleaseCandidateRecord } from "./release-candidate.mjs";
export const RESEND_DAILY_LIMIT = 80;
export const RESEND_MONTHLY_LIMIT = 2_400;
export const DELIVERY_CATEGORIES = Object.freeze(["qq", "163", "enterprise"]);
export const DELIVERY_SAMPLE_SIZE = 20;
export const DELIVERY_MINIMUM_WITHIN_TWO_MINUTES = 19;
export const DELIVERY_WINDOW_SECONDS = 120;
export const EXPECTED_WP7_01_COMMIT = "623cad25b2a2a9a003502c9a92ebd318dad06248";
const emailPattern = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i;
const absolutePathPattern = /(?:[A-Z]:[\\/]|\\\\|\/Users\/|\/home\/)/i;
const sensitiveKeyPattern = /"(?:api[_ -]?key|secret|password|authorization|bearer|cookie|session[_ -]?token|verification[_ -]?code|private[_ -]?content|prompt|image)"\s*:/i;
function object(value) {
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined;
}
function errorList(...values) {
return [...new Set(values.flat().filter((value) => typeof value === "string" && value.length > 0))];
}
export function validateDomainCheck(value) {
const item = object(value);
const freeRules = object(item?.free_rules);
const spf = object(item?.spf);
const dkim = object(item?.dkim);
const errors = [];
if (item?.schema_version !== "1.0") errors.push("schema_version");
if (item?.service !== "resend") errors.push("service");
if (item?.source !== "human_controlled_real") errors.push("source");
if (item?.status !== "verified") errors.push("status");
if (item?.domain_controlled !== true) errors.push("domain_controlled");
if (spf?.status !== "pass") errors.push("spf");
if (dkim?.status !== "pass") errors.push("dkim");
if (freeRules?.status !== "verified") errors.push("free_rules.status");
if (freeRules?.daily_limit !== RESEND_DAILY_LIMIT) errors.push("free_rules.daily_limit");
if (freeRules?.monthly_limit !== RESEND_MONTHLY_LIMIT) errors.push("free_rules.monthly_limit");
if (freeRules?.paid_fallback_enabled !== false) errors.push("free_rules.paid_fallback_enabled");
return errorList(errors);
}
export function validateDeliverySummary(value) {
const item = object(value);
const errors = [];
if (item?.schema_version !== "1.0") errors.push("schema_version");
if (item?.service !== "resend") errors.push("service");
if (item?.source !== "human_controlled_real") errors.push("source");
if (item?.status !== "verified") errors.push("status");
if (!Array.isArray(item?.categories) || item.categories.length !== DELIVERY_CATEGORIES.length) {
errors.push("categories");
} else {
const categories = item.categories.map((entry) => entry?.category).sort();
if (categories.join("|") !== DELIVERY_CATEGORIES.slice().sort().join("|")) errors.push("categories.names");
for (const entry of item.categories) {
if (!Number.isInteger(entry?.sent_count) || entry.sent_count !== DELIVERY_SAMPLE_SIZE) errors.push(`${entry?.category ?? "unknown"}.sent_count`);
if (!Number.isInteger(entry?.delivered_within_120_seconds)
|| entry.delivered_within_120_seconds < DELIVERY_MINIMUM_WITHIN_TWO_MINUTES
|| entry.delivered_within_120_seconds > DELIVERY_SAMPLE_SIZE) {
errors.push(`${entry?.category ?? "unknown"}.delivered_within_120_seconds`);
}
if (!Number.isFinite(entry?.max_latency_seconds) || entry.max_latency_seconds > DELIVERY_WINDOW_SECONDS) errors.push(`${entry?.category ?? "unknown"}.max_latency_seconds`);
if (entry?.mock_used !== false) errors.push(`${entry?.category ?? "unknown"}.mock_used`);
if (entry?.preseeded_account_used !== false) errors.push(`${entry?.category ?? "unknown"}.preseeded_account_used`);
}
}
return errorList(errors);
}
export function validateAuthResult(value) {
const item = object(value);
const ordinary = object(item?.ordinary);
const admin = object(item?.admin);
const errors = [];
if (item?.schema_version !== "1.0") errors.push("schema_version");
if (item?.service !== "resend") errors.push("service");
if (item?.source !== "human_controlled_real") errors.push("source");
if (item?.status !== "verified") errors.push("status");
if (item?.mock_used !== false) errors.push("mock_used");
if (item?.preseeded_account_used !== false) errors.push("preseeded_account_used");
for (const [name, auth] of [["ordinary", ordinary], ["admin", admin]]) {
if (auth?.status !== "passed") errors.push(`${name}.status`);
if (auth?.chain !== "formal") errors.push(`${name}.chain`);
if (auth?.verification_code_source !== "real_delivery") errors.push(`${name}.verification_code_source`);
}
return errorList(errors);
}
export function validateRedaction(value, serializedEvidence = "") {
const item = object(value);
const errors = [];
if (item?.schema_version !== "1.0") errors.push("schema_version");
if (item?.status !== "passed") errors.push("status");
if (item?.forbidden_matches !== 0) errors.push("forbidden_matches");
if (item?.credentials_in_evidence !== false) errors.push("credentials_in_evidence");
if (item?.mailboxes_in_evidence !== false) errors.push("mailboxes_in_evidence");
if (item?.private_content_in_evidence !== false) errors.push("private_content_in_evidence");
if (item?.absolute_paths_in_evidence !== false) errors.push("absolute_paths_in_evidence");
if (emailPattern.test(serializedEvidence)) errors.push("email_value");
if (absolutePathPattern.test(serializedEvidence)) errors.push("absolute_path");
if (sensitiveKeyPattern.test(serializedEvidence)) errors.push("sensitive_value");
return errorList(errors);
}
export function validateCandidateReference(record) {
try {
validateReleaseCandidateRecord(record);
} catch (error) {
return [error instanceof Error ? "candidate_record_invalid" : "candidate_record_invalid"];
}
const errors = [];
if (record.build_commit !== EXPECTED_WP7_01_COMMIT) errors.push("candidate_build_commit");
const versions = new Map((record.browsers ?? []).map((browser) => [browser.brand, browser.full_version]));
if (versions.get("Google Chrome") !== "150.0.7871.187") errors.push("chrome_full_version");
if (versions.get("Microsoft Edge") !== "151.0.4129.59") errors.push("edge_full_version");
return errorList(errors);
}
export function readJson(path) {
if (!path || !existsSync(path)) return undefined;
try {
return JSON.parse(readFileSync(path, "utf8"));
} catch {
return undefined;
}
}
export function fileSha256(path) {
return createHash("sha256").update(readFileSync(path)).digest("hex").toUpperCase();
}
export function validateResendEvidence({ candidate, domainCheck, deliverySummary, authResult, redaction }) {
const serializedEvidence = JSON.stringify({ domainCheck, deliverySummary, authResult });
const errors = [
...validateCandidateReference(candidate),
...validateDomainCheck(domainCheck),
...validateDeliverySummary(deliverySummary),
...validateAuthResult(authResult),
...validateRedaction(redaction, serializedEvidence),
];
return errorList(errors);
}
+428
View File
@@ -0,0 +1,428 @@
import { createHash } from "node:crypto";
import {
existsSync,
linkSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
realpathSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, extname, isAbsolute, join, resolve, sep } from "node:path";
import { compileTextTemplateAssets } from "./text-template-assets.mjs";
export const P0A_RUNTIME_ASSET_ROOT_REF = "p0a_runtime_assets";
export const RUNTIME_ASSET_MANIFEST_SCHEMA = "DadaRuntimeAssets/v1";
const assetIdPattern = /^[a-z0-9][a-z0-9_-]{2,119}$/i;
const mimePattern = /^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i;
const releasePattern = /^[a-z0-9][a-z0-9._-]{0,79}$/i;
const shaPattern = /^[a-f0-9]{64}$/i;
const fontMimeTypes = new Map([
[".otf", "font/otf"],
[".ttf", "font/ttf"],
[".woff", "font/woff"],
[".woff2", "font/woff2"],
]);
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function fileSha256(path) {
return sha256(readFileSync(path));
}
function stableEntries(entries) {
return entries.map((entry) => {
if (!entry || typeof entry !== "object") throw new Error("runtime_asset_entry_invalid");
if (!assetIdPattern.test(entry.assetId)) throw new Error("runtime_asset_id_invalid");
if (!mimePattern.test(entry.mimeType)) throw new Error("runtime_asset_mime_invalid");
if (!releasePattern.test(entry.resourceVersion)) throw new Error("runtime_asset_version_invalid");
if (entry.rootRef !== P0A_RUNTIME_ASSET_ROOT_REF) throw new Error("runtime_asset_root_ref_invalid");
if (!shaPattern.test(entry.sha256)) throw new Error("runtime_asset_sha256_invalid");
if (
typeof entry.relativePath !== "string"
|| isAbsolute(entry.relativePath)
|| entry.relativePath.includes("\\")
|| entry.relativePath.split("/").some((part) => part === "" || part === "..")
) throw new Error("runtime_asset_relative_path_invalid");
return { ...entry, sha256: entry.sha256.toLowerCase() };
}).sort((left, right) => {
const byVersion = left.resourceVersion.localeCompare(right.resourceVersion);
return byVersion || left.assetId.localeCompare(right.assetId);
});
}
function derivedCounts(entries) {
return {
dynamic_fonts: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^[a-f0-9]{32}$/.test(entry.assetId)).length,
dynamic_images: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^DYN\d{3}-/.test(entry.assetId)).length,
font_panel_items: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^FONT\d{3}$/.test(entry.assetId)).length,
static_stickers: entries.filter((entry) => entry.resourceVersion === "p0a-static-v1" && /^STK\d{3,4}$/.test(entry.assetId)).length,
text_fonts: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^TEXT-FONT-/.test(entry.assetId)).length,
text_images: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^TEXT-IMAGE-/.test(entry.assetId)).length,
text_previews: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^TEXT-PREVIEW-/.test(entry.assetId)).length,
};
}
export function createRuntimeAssetManifest({ counts, entries, sourceManifestSha256 }) {
const normalizedEntries = stableEntries(entries);
const keys = new Set();
const paths = new Set();
for (const entry of normalizedEntries) {
const key = `${entry.resourceVersion}\u0000${entry.assetId}`;
if (keys.has(key)) throw new Error("runtime_asset_id_duplicate");
if (paths.has(entry.relativePath)) throw new Error("runtime_asset_path_duplicate");
keys.add(key);
paths.add(entry.relativePath);
}
const actualCounts = derivedCounts(normalizedEntries);
if (JSON.stringify(counts) !== JSON.stringify(actualCounts)) throw new Error("runtime_asset_counts_invalid");
if (sourceManifestSha256 !== undefined && !shaPattern.test(sourceManifestSha256)) {
throw new Error("runtime_asset_source_manifest_sha256_invalid");
}
return {
counts: actualCounts,
entries: normalizedEntries,
root_ref: P0A_RUNTIME_ASSET_ROOT_REF,
schema_version: RUNTIME_ASSET_MANIFEST_SCHEMA,
source: "external_read_only",
...(sourceManifestSha256 ? { source_manifest_sha256: sourceManifestSha256.toLowerCase() } : {}),
};
}
export function readRuntimeAssetManifest(path) {
const value = JSON.parse(readFileSync(path, "utf8"));
if (
value?.schema_version !== RUNTIME_ASSET_MANIFEST_SCHEMA
|| value?.source !== "external_read_only"
|| value?.root_ref !== P0A_RUNTIME_ASSET_ROOT_REF
|| !Array.isArray(value.entries)
) throw new Error("runtime_asset_manifest_invalid");
return createRuntimeAssetManifest({
counts: value.counts,
entries: value.entries,
...(value.source_manifest_sha256 ? { sourceManifestSha256: value.source_manifest_sha256 } : {}),
});
}
export function serializeRuntimeAssetManifest(manifest) {
return `${JSON.stringify(manifest, null, 2)}\n`;
}
export function writeRuntimeAssetManifest(path, manifest) {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, serializeRuntimeAssetManifest(manifest));
}
function targetWithinRoot(root, relativePath) {
const absoluteRoot = resolve(root);
const target = resolve(absoluteRoot, ...relativePath.split("/"));
if (target === absoluteRoot || !target.startsWith(`${absoluteRoot}${sep}`)) throw new Error("asset_target_path_invalid");
return target;
}
function sameFile(left, right) {
const leftStat = statSync(left);
const rightStat = statSync(right);
return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
}
function installRuntimeAsset(resource, targetPath, expectedSha) {
const generatedBytes = Buffer.isBuffer(resource.sourceBytes) ? resource.sourceBytes : undefined;
if (generatedBytes) writeFileSync(targetPath, generatedBytes, { flag: "wx" });
else {
try {
linkSync(resource.sourcePath, targetPath);
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "EXDEV") {
throw new Error("asset_hardlink_volume_mismatch");
}
throw error;
}
}
if (fileSha256(targetPath) !== expectedSha) throw new Error(generatedBytes ? "asset_generated_write_invalid" : "asset_hardlink_verification_failed");
if (!generatedBytes && !sameFile(resource.sourcePath, targetPath)) throw new Error("asset_hardlink_verification_failed");
}
function replaceManagedRuntimeAsset(resource, targetPath, expectedSha) {
const nextPath = `${targetPath}.dada-next`;
const previousPath = `${targetPath}.dada-previous`;
if (existsSync(nextPath) || existsSync(previousPath)) throw new Error("asset_update_staging_conflict");
installRuntimeAsset(resource, nextPath, expectedSha);
renameSync(targetPath, previousPath);
try {
renameSync(nextPath, targetPath);
if (fileSha256(targetPath) !== expectedSha) throw new Error("asset_update_verification_failed");
rmSync(previousPath, { force: true });
} catch (error) {
if (existsSync(targetPath)) renameSync(targetPath, nextPath);
renameSync(previousPath, targetPath);
rmSync(nextPath, { force: true });
throw error;
}
}
export function deployRuntimeAssetPlan({ allowManagedUpdate = false, assetRoot, manifest, resources }) {
if (!isAbsolute(assetRoot)) throw new Error("asset_root_must_be_absolute");
const normalizedManifest = createRuntimeAssetManifest({
counts: manifest.counts,
entries: manifest.entries,
...(manifest.source_manifest_sha256 ? { sourceManifestSha256: manifest.source_manifest_sha256 } : {}),
});
const entries = new Map(normalizedManifest.entries.map((entry) => [`${entry.resourceVersion}\u0000${entry.assetId}`, entry]));
if (resources.length !== entries.size) throw new Error("asset_resource_plan_incomplete");
mkdirSync(assetRoot, { recursive: true });
const previousManifestPath = join(assetRoot, "manifest.json");
const previousEntries = allowManagedUpdate && existsSync(previousManifestPath)
? new Map(readRuntimeAssetManifest(previousManifestPath).entries.map((entry) => [entry.relativePath, entry]))
: new Map();
for (const resource of resources) {
const key = `${resource.entry.resourceVersion}\u0000${resource.entry.assetId}`;
const entry = entries.get(key);
if (!entry || JSON.stringify(entry) !== JSON.stringify({ ...resource.entry, sha256: resource.entry.sha256.toLowerCase() })) {
throw new Error("asset_resource_plan_mismatch");
}
const generatedBytes = Buffer.isBuffer(resource.sourceBytes) ? resource.sourceBytes : undefined;
if (!generatedBytes && (!existsSync(resource.sourcePath) || !statSync(resource.sourcePath).isFile() || lstatSync(resource.sourcePath).isSymbolicLink())) {
throw new Error("asset_source_invalid");
}
if ((generatedBytes ? sha256(generatedBytes) : fileSha256(resource.sourcePath)) !== entry.sha256) throw new Error("asset_source_hash_invalid");
const targetPath = targetWithinRoot(assetRoot, entry.relativePath);
mkdirSync(dirname(targetPath), { recursive: true });
if (existsSync(targetPath)) {
const targetSha = fileSha256(targetPath);
if (targetSha !== entry.sha256) {
const previous = previousEntries.get(entry.relativePath);
if (!previous || targetSha !== previous.sha256) throw new Error("asset_target_conflict");
replaceManagedRuntimeAsset(resource, targetPath, entry.sha256);
continue;
}
if (!generatedBytes && !sameFile(resource.sourcePath, targetPath)) throw new Error("asset_target_not_hardlink");
continue;
}
installRuntimeAsset(resource, targetPath, entry.sha256);
}
writeRuntimeAssetManifest(join(assetRoot, "manifest.json"), normalizedManifest);
return { linked_files: resources.length, manifest: normalizedManifest, status: "ready" };
}
function oneDirectoryWithPrefix(root, prefix) {
const matches = readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.startsWith(`${prefix}_`));
if (matches.length !== 1) throw new Error(`runtime_asset_source_directory_invalid:${prefix}`);
return join(root, matches[0].name);
}
function oneSupportedFont(root) {
const matches = readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isFile() && fontMimeTypes.has(extname(entry.name).toLowerCase()));
if (matches.length !== 1) throw new Error(`runtime_font_source_invalid:${basename(root)}`);
return join(root, matches[0].name);
}
function entryFor(sourcePath, assetId, resourceVersion, relativePath, mimeType) {
return {
assetId,
mimeType,
relativePath,
resourceVersion,
rootRef: P0A_RUNTIME_ASSET_ROOT_REF,
sha256: fileSha256(sourcePath),
};
}
function entryForBytes(sourceBytes, assetId, resourceVersion, relativePath, mimeType) {
return {
assetId,
mimeType,
relativePath,
resourceVersion,
rootRef: P0A_RUNTIME_ASSET_ROOT_REF,
sha256: sha256(sourceBytes),
};
}
function safeRuntimeComponent(value) {
const normalized = value.normalize("NFKD").replaceAll(/[^A-Za-z0-9_-]+/g, "-").replaceAll(/^-+|-+$/g, "");
if (normalized === value) return normalized;
return `${normalized || "asset"}-${sha256(value).slice(0, 8).toLowerCase()}`;
}
function dynamicMetadata(templateRoot, descriptor, field) {
const templateDirectory = join(templateRoot, descriptor.templateId);
const metadata = JSON.parse(readFileSync(join(templateDirectory, "metadata.json"), "utf8"));
if (!Array.isArray(metadata?.files?.[field]) || !metadata.files[field].includes(descriptor.sourceReference)) {
throw new Error(`runtime_dynamic_reference_invalid:${descriptor.assetId}`);
}
return templateDirectory;
}
function textTemplateDirectory(root, templateId) {
const family = templateId.startsWith("FLOWER") ? "花字"
: templateId.startsWith("SIMPLE") ? "简约"
: templateId.startsWith("TAG") ? "标签"
: "标题";
return join(root, family, "templates", templateId);
}
export async function buildP0aRuntimeAssetPlan({ replicationRoot }) {
const [{ compileStaticStickerCatalog }, registry] = await Promise.all([
import("../../packages/asset-compiler/dist/index.js"),
import("../../packages/template-registry/dist/index.js"),
]);
const compilerOutput = mkdtempSync(join(tmpdir(), "dada-runtime-asset-plan-"));
try {
const staticSourceRoot = join(replicationRoot, "sticker_normal");
const staticResult = compileStaticStickerCatalog({
outputDirectory: compilerOutput,
releaseVersion: registry.P0A_STATIC_STICKER_RELEASE_VERSION,
sourceRoot: staticSourceRoot,
});
const resources = staticResult.catalog.items.map((item) => {
const sourcePath = join(staticSourceRoot, ...item.relative_path.split("/"));
const entry = entryFor(
sourcePath,
item.stable_id,
registry.P0A_STATIC_STICKER_RELEASE_VERSION,
`${registry.P0A_STATIC_STICKER_RELEASE_VERSION}/${item.stable_id}.png`,
"image/png",
);
if (entry.sha256 !== item.sha256.toLowerCase()) throw new Error(`static_sticker_hash_invalid:${item.stable_id}`);
return { entry, sourcePath };
});
const resourceKeys = new Map(resources.map((resource) => [`${resource.entry.resourceVersion}\u0000${resource.entry.assetId}`, resource]));
const addResource = (resource) => {
const key = `${resource.entry.resourceVersion}\u0000${resource.entry.assetId}`;
const existing = resourceKeys.get(key);
if (existing) {
if (existing.entry.sha256 !== resource.entry.sha256 || existing.entry.mimeType !== resource.entry.mimeType) {
throw new Error(`runtime_asset_duplicate_conflict:${resource.entry.assetId}`);
}
return;
}
resourceKeys.set(key, resource);
resources.push(resource);
};
const fontPackagesRoot = join(
replicationRoot,
"sticker_text",
"字体",
"面板全量采集",
"font_panel_full_20260722",
"resources",
"font_packages",
);
for (const assetId of registry.P0A_REQUIRED_FONT_PANEL_IDS) {
const packageDirectory = oneDirectoryWithPrefix(fontPackagesRoot, assetId);
const sourcePath = oneSupportedFont(join(packageDirectory, "font_files"));
const extension = extname(sourcePath).toLowerCase();
addResource({
entry: entryFor(
sourcePath,
assetId,
registry.P0A_COMPLEX_RELEASE_VERSION,
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${assetId}${extension}`,
fontMimeTypes.get(extension),
),
sourcePath,
});
}
const templateRoot = join(replicationRoot, "sticker_interactive", "单模板归档", "templates");
for (const templateId of registry.P0A_DYNAMIC_STICKER_IDS) {
const templateDirectory = join(templateRoot, templateId);
const metadata = JSON.parse(readFileSync(join(templateDirectory, "metadata.json"), "utf8"));
for (const sourceReference of metadata.files?.fonts ?? []) {
const sourcePath = oneSupportedFont(join(templateDirectory, ...sourceReference.split("/")));
const extension = extname(sourcePath).toLowerCase();
const assetId = basename(sourceReference);
addResource({
entry: entryFor(
sourcePath,
assetId,
registry.P0A_COMPLEX_RELEASE_VERSION,
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${assetId}${extension}`,
fontMimeTypes.get(extension),
),
sourcePath,
});
}
for (const sourceReference of metadata.files?.images ?? []) {
const sourcePath = join(templateDirectory, ...sourceReference.split("/"));
const extension = extname(sourcePath).toLowerCase();
const assetId = `${templateId}-${safeRuntimeComponent(basename(sourceReference, extension))}`;
if (!existsSync(sourcePath) || extension !== ".png") throw new Error(`runtime_dynamic_image_invalid:${assetId}`);
addResource({
entry: entryFor(
sourcePath,
assetId,
registry.P0A_COMPLEX_RELEASE_VERSION,
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${assetId}.png`,
"image/png",
),
sourcePath,
});
}
}
const textRoot = join(replicationRoot, "sticker_text", "模板", "单模板归档");
for (const templateId of registry.P0A_TEXT_TEMPLATE_IDS) {
const templateDirectory = textTemplateDirectory(textRoot, templateId);
const metadata = JSON.parse(readFileSync(join(templateDirectory, "metadata.json"), "utf8"));
const compiled = compileTextTemplateAssets({ templateDirectory, templateId });
for (const resource of compiled.resources) {
const relativePath = `${registry.P0A_COMPLEX_RELEASE_VERSION}/${resource.assetId}${resource.extension}`;
const entry = resource.sourceBytes
? entryForBytes(resource.sourceBytes, resource.assetId, registry.P0A_COMPLEX_RELEASE_VERSION, relativePath, resource.mimeType)
: entryFor(resource.sourcePath, resource.assetId, registry.P0A_COMPLEX_RELEASE_VERSION, relativePath, resource.mimeType);
addResource({ entry, ...(resource.sourceBytes ? { sourceBytes: resource.sourceBytes } : { sourcePath: resource.sourcePath }) });
}
const previewReference = metadata.files?.preview;
if (typeof previewReference !== "string" || previewReference.length === 0) continue;
const sourcePath = join(templateDirectory, ...previewReference.split("/"));
if (!existsSync(sourcePath) || extname(sourcePath).toLowerCase() !== ".png") {
throw new Error(`runtime_text_preview_invalid:${templateId}`);
}
const assetId = `TEXT-PREVIEW-${templateId}`;
addResource({
entry: entryFor(
sourcePath,
assetId,
registry.P0A_COMPLEX_RELEASE_VERSION,
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${assetId}.png`,
"image/png",
),
sourcePath,
});
}
const manifestPath = join(replicationRoot, "sticker_web_handoff", "sticker_web_catalog_manifest.json");
const manifest = createRuntimeAssetManifest({
counts: derivedCounts(resources.map((resource) => resource.entry)),
entries: resources.map((resource) => resource.entry),
sourceManifestSha256: fileSha256(manifestPath),
});
const resourcesByKey = new Map(resources.map((resource) => [`${resource.entry.resourceVersion}\u0000${resource.entry.assetId}`, resource]));
return {
manifest,
resources: manifest.entries.map((entry) => resourcesByKey.get(`${entry.resourceVersion}\u0000${entry.assetId}`)),
};
} finally {
rmSync(compilerOutput, { force: true, recursive: true });
}
}
export function defaultReplicationRoot(environment = process.env) {
if (!environment.USERPROFILE || !isAbsolute(environment.USERPROFILE)) throw new Error("user_profile_unavailable");
return join(environment.USERPROFILE, "Desktop", "sticker_web_replication_assets");
}
+895
View File
@@ -0,0 +1,895 @@
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { createHash } from "node:crypto";
import { basename, dirname, extname, join, relative } from "node:path";
import { inflateRawSync } from "node:zlib";
const fontMimeTypes = new Map([
[".otf", "font/otf"],
[".ttf", "font/ttf"],
[".woff", "font/woff"],
[".woff2", "font/woff2"],
]);
function sfntChecksum(bytes, offset = 0, length = bytes.length) {
let checksum = 0;
for (let index = 0; index < length; index += 4) {
let value = 0;
for (let byte = 0; byte < 4; byte += 1) value = (value << 8) | (bytes[offset + index + byte] ?? 0);
checksum = (checksum + (value >>> 0)) >>> 0;
}
return checksum;
}
function rebuildSfnt(source, tables) {
const ordered = tables.toSorted((left, right) => (left.tag < right.tag ? -1 : left.tag > right.tag ? 1 : 0));
const tableCount = ordered.length;
const largestPower = 2 ** Math.floor(Math.log2(tableCount));
let outputLength = 12 + tableCount * 16;
const records = ordered.map((table) => {
const bytes = table.bytes
? Buffer.from(table.bytes)
: Buffer.from(source.subarray(table.offset, table.offset + table.length));
if (table.tag === "head") bytes.writeUInt32BE(0, 8);
const record = { ...table, bytes, offset: outputLength };
outputLength += Math.ceil(bytes.length / 4) * 4;
return record;
});
const output = Buffer.alloc(outputLength);
output.writeUInt32BE(source.readUInt32BE(0), 0);
output.writeUInt16BE(tableCount, 4);
output.writeUInt16BE(largestPower * 16, 6);
output.writeUInt16BE(Math.log2(largestPower), 8);
output.writeUInt16BE(tableCount * 16 - largestPower * 16, 10);
records.forEach((record, index) => {
const directoryOffset = 12 + index * 16;
output.write(record.tag, directoryOffset, 4, "ascii");
output.writeUInt32BE(sfntChecksum(record.bytes), directoryOffset + 4);
output.writeUInt32BE(record.offset, directoryOffset + 8);
output.writeUInt32BE(record.bytes.length, directoryOffset + 12);
record.bytes.copy(output, record.offset);
});
const head = records.find((record) => record.tag === "head");
if (!head || head.bytes.length < 12) throw new Error("text_font_sfnt_head_invalid");
output.writeUInt32BE((0xB1B0AFBA - sfntChecksum(output)) >>> 0, head.offset + 8);
return output;
}
function normalizeGlyphBounds(source, tables) {
const glyphTable = tables.get("glyf");
const headerTable = tables.get("head");
const locationTable = tables.get("loca");
const maximumProfileTable = tables.get("maxp");
if (
!glyphTable
|| !headerTable
|| headerTable.length < 54
|| !locationTable
|| !maximumProfileTable
|| maximumProfileTable.length < 6
) return undefined;
const glyphCount = source.readUInt16BE(maximumProfileTable.offset + 4);
const locationFormat = source.readInt16BE(headerTable.offset + 50);
const locationEntrySize = locationFormat === 0 ? 2 : locationFormat === 1 ? 4 : 0;
if (locationEntrySize === 0 || locationTable.length < (glyphCount + 1) * locationEntrySize) {
throw new Error("text_font_glyph_location_invalid");
}
const glyphBytes = Buffer.from(source.subarray(glyphTable.offset, glyphTable.offset + glyphTable.length));
const glyphOffset = (index) => {
const offset = locationTable.offset + index * locationEntrySize;
return locationFormat === 0 ? source.readUInt16BE(offset) * 2 : source.readUInt32BE(offset);
};
let changed = false;
let previousEnd = 0;
for (let index = 0; index < glyphCount; index += 1) {
const start = glyphOffset(index);
const end = glyphOffset(index + 1);
if (start < previousEnd || end < start || end > glyphBytes.length) throw new Error("text_font_glyph_location_invalid");
previousEnd = end;
if (end - start < 10) continue;
const xMin = glyphBytes.readInt16BE(start + 2);
const yMin = glyphBytes.readInt16BE(start + 4);
const xMax = glyphBytes.readInt16BE(start + 6);
const yMax = glyphBytes.readInt16BE(start + 8);
if (xMin > xMax) {
glyphBytes.writeInt16BE(xMax, start + 2);
glyphBytes.writeInt16BE(xMin, start + 6);
changed = true;
}
if (yMin > yMax) {
glyphBytes.writeInt16BE(yMax, start + 4);
glyphBytes.writeInt16BE(yMin, start + 8);
changed = true;
}
}
return changed ? glyphBytes : undefined;
}
export function normalizeBrowserFontBytes(source) {
const bytes = Buffer.from(source);
if (bytes.length < 12 || bytes.readUInt32BE(0) !== 0x00010000) return undefined;
const tableCount = bytes.readUInt16BE(4);
if (12 + tableCount * 16 > bytes.length) throw new Error("text_font_sfnt_directory_invalid");
const tables = new Map();
for (let index = 0; index < tableCount; index += 1) {
const recordOffset = 12 + index * 16;
const tag = bytes.toString("ascii", recordOffset, recordOffset + 4);
const offset = bytes.readUInt32BE(recordOffset + 8);
const length = bytes.readUInt32BE(recordOffset + 12);
if (offset + length > bytes.length) throw new Error("text_font_sfnt_table_invalid");
tables.set(tag, { length, offset, recordOffset, tag });
}
const head = tables.get("head");
const verticalHeader = tables.get("vhea");
if (!head || head.length < 12) return undefined;
const invalidVerticalVersion = verticalHeader?.length >= 4 && bytes.readUInt32BE(verticalHeader.offset) === 0x00010001;
const invalidWholeFontChecksum = sfntChecksum(bytes) !== 0xB1B0AFBA;
const normalizedGlyphs = normalizeGlyphBounds(bytes, tables);
if (!invalidVerticalVersion && !invalidWholeFontChecksum && !normalizedGlyphs) return undefined;
const keptTables = [...tables.values()]
.filter((table) => !invalidVerticalVersion || !["vhea", "vmtx"].includes(table.tag))
.map((table) => {
if (table.tag === "glyf" && normalizedGlyphs) return { ...table, bytes: normalizedGlyphs };
if (!invalidVerticalVersion || table.tag !== "post") return table;
const post = Buffer.alloc(32);
bytes.copy(post, 0, table.offset, table.offset + Math.min(table.length, post.length));
post.writeUInt32BE(0x00030000, 0);
return { ...table, bytes: post, length: post.length };
});
if (invalidVerticalVersion && !tables.has("post")) {
const post = Buffer.alloc(32);
post.writeUInt32BE(0x00030000, 0);
keptTables.push({ bytes: post, length: post.length, offset: 0, recordOffset: 0, tag: "post" });
}
return rebuildSfnt(bytes, keptTables);
}
function filesBelow(root) {
const files = [];
const visit = (directory) => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
if (entry.name === "__MACOSX" || entry.name === ".DS_Store" || entry.name.startsWith("._")) continue;
const path = join(directory, entry.name);
if (entry.isDirectory()) visit(path);
else if (entry.isFile()) files.push(path);
}
};
if (existsSync(root)) visit(root);
return files.toSorted((left, right) => left.localeCompare(right));
}
function assetFileType(path) {
const extension = extname(path).toLowerCase();
if ([".manifest", ".mat", ".png", ".prefab", ".sprite"].includes(extension)) return extension.slice(1);
const bytes = readFileSync(path);
if (bytes.length >= 24 && bytes.readUInt32BE(12) === 0x49484452) return "png";
if (bytes[0] === 0x7b) {
try {
const parsed = JSON.parse(bytes.toString("utf8"));
if (["Sprite", "Prefab", "Material"].includes(parsed?.typeId)) return String(parsed.typeId).toLocaleLowerCase("en-US");
} catch {
return undefined;
}
}
return undefined;
}
function readJson(path) {
return JSON.parse(readFileSync(path, "utf8"));
}
function stemKey(path) {
return basename(path, extname(path)).normalize("NFKC").toLocaleLowerCase("en-US").replaceAll(/[^\p{L}\p{N}]+/gu, "");
}
function resourceStemKey(path, type) {
const name = basename(path);
const extension = extname(name).toLowerCase();
const stem = extension === `.${type}` ? basename(name, extension) : name.replace(new RegExp(`_${type}$`, "i"), "");
return stem.normalize("NFKC").toLocaleLowerCase("en-US").replaceAll(/[^\p{L}\p{N}]+/gu, "");
}
function zipEntries(path) {
const archive = readFileSync(path);
let eocd = -1;
for (let index = archive.length - 22; index >= Math.max(0, archive.length - 65_557); index -= 1) {
if (archive.readUInt32LE(index) === 0x06054b50) {
eocd = index;
break;
}
}
if (eocd < 0) throw new Error(`text_font_zip_invalid:${basename(path)}`);
const totalEntries = archive.readUInt16LE(eocd + 10);
let cursor = archive.readUInt32LE(eocd + 16);
const entries = [];
for (let index = 0; index < totalEntries; index += 1) {
if (archive.readUInt32LE(cursor) !== 0x02014b50) throw new Error(`text_font_zip_directory_invalid:${basename(path)}`);
const compression = archive.readUInt16LE(cursor + 10);
const compressedSize = archive.readUInt32LE(cursor + 20);
const uncompressedSize = archive.readUInt32LE(cursor + 24);
const nameLength = archive.readUInt16LE(cursor + 28);
const extraLength = archive.readUInt16LE(cursor + 30);
const commentLength = archive.readUInt16LE(cursor + 32);
const localOffset = archive.readUInt32LE(cursor + 42);
const name = archive.subarray(cursor + 46, cursor + 46 + nameLength).toString("utf8").replaceAll("\\", "/");
if (name.startsWith("/") || name.split("/").includes("..")) throw new Error(`text_font_zip_path_invalid:${basename(path)}`);
if (!name.endsWith("/")) {
if (archive.readUInt32LE(localOffset) !== 0x04034b50) throw new Error(`text_font_zip_entry_invalid:${basename(path)}`);
const localNameLength = archive.readUInt16LE(localOffset + 26);
const localExtraLength = archive.readUInt16LE(localOffset + 28);
const dataOffset = localOffset + 30 + localNameLength + localExtraLength;
const compressed = archive.subarray(dataOffset, dataOffset + compressedSize);
const bytes = compression === 0 ? Buffer.from(compressed)
: compression === 8 ? inflateRawSync(compressed)
: undefined;
if (!bytes || bytes.length !== uncompressedSize) throw new Error(`text_font_zip_compression_invalid:${basename(path)}`);
entries.push({ bytes, name });
}
cursor += 46 + nameLength + extraLength + commentLength;
}
return entries;
}
function browserFontResource(templateDirectory, templateId, sourceReference, index) {
const sourcePath = join(templateDirectory, ...sourceReference.split("/"));
if (!existsSync(sourcePath) || !statSync(sourcePath).isFile()) throw new Error(`text_font_source_missing:${templateId}:${index}`);
const assetId = `TEXT-FONT-${templateId}-${String(index + 1).padStart(2, "0")}`;
const directExtension = extname(sourcePath).toLowerCase();
if (fontMimeTypes.has(directExtension)) {
return {
assetId,
extension: directExtension,
mimeType: fontMimeTypes.get(directExtension),
names: [basename(sourcePath).toLocaleLowerCase("en-US")],
sourcePath,
};
}
const extracted = filesBelow(dirname(sourcePath)).filter((path) => fontMimeTypes.has(extname(path).toLowerCase()));
if (extracted.length === 1) {
const extension = extname(extracted[0]).toLowerCase();
return {
assetId,
extension,
mimeType: fontMimeTypes.get(extension),
names: [basename(extracted[0]).toLocaleLowerCase("en-US"), basename(sourcePath, directExtension).toLocaleLowerCase("en-US")],
sourcePath: extracted[0],
};
}
const archivedFonts = zipEntries(sourcePath).filter((entry) => fontMimeTypes.has(extname(entry.name).toLowerCase()));
if (archivedFonts.length !== 1) throw new Error(`text_font_archive_ambiguous:${templateId}:${index}`);
const archived = archivedFonts[0];
const extension = extname(archived.name).toLowerCase();
return {
assetId,
extension,
mimeType: fontMimeTypes.get(extension),
names: [basename(archived.name).toLocaleLowerCase("en-US"), basename(sourcePath, directExtension).toLocaleLowerCase("en-US")],
sourceBytes: archived.bytes,
};
}
function manifestFileMap(packageFiles) {
const filesByName = new Map();
const filesByAsciiIdentity = new Map();
for (const path of packageFiles) {
const key = basename(path).toLocaleLowerCase("en-US");
const values = filesByName.get(key) ?? [];
values.push(path);
filesByName.set(key, values);
const asciiIdentity = key.replaceAll(/[^a-z0-9]+/g, "");
const asciiValues = filesByAsciiIdentity.get(asciiIdentity) ?? [];
asciiValues.push(path);
filesByAsciiIdentity.set(asciiIdentity, asciiValues);
}
const mappings = new Map();
const spriteDefinitions = new Map();
const manifestEntries = [];
for (const path of packageFiles.filter((candidate) => extname(candidate).toLowerCase() === ".manifest")) {
let manifest;
try {
manifest = readJson(path);
} catch {
continue;
}
for (const item of manifest.UUIDToFilePath ?? []) {
const uuid = item?.key?.value;
const fileName = item?.value?.fileName;
if (typeof uuid !== "string" || typeof fileName !== "string") continue;
manifestEntries.push({ directories: item.value.directories ?? [], fileName, uuid });
const candidate = join(dirname(path), ...(item.value.directories ?? []), fileName);
const normalizedName = basename(fileName).toLocaleLowerCase("en-US");
const asciiCandidates = filesByAsciiIdentity.get(normalizedName.replaceAll(/[^a-z0-9]+/g, "")) ?? [];
const resolved = existsSync(candidate) ? candidate
: filesByName.get(normalizedName)?.[0]
?? (asciiCandidates.length === 1 ? asciiCandidates[0] : undefined);
if (resolved) {
mappings.set(uuid, resolved);
if (assetFileType(resolved) === "sprite") spriteDefinitions.set(uuid, resolved);
}
else mappings.set(uuid, fileName);
}
}
const actualImagesByUuid = new Map();
const actualSprites = [];
for (const spritePath of packageFiles.filter((candidate) => assetFileType(candidate) === "sprite")) {
let sprite;
try {
sprite = readJson(spritePath);
} catch {
continue;
}
const imageUuid = sprite?.object?.m_UUIDList?.[0]?.uuid;
const siblingImages = packageFiles.filter((candidate) => dirname(candidate) === dirname(spritePath) && assetFileType(candidate) === "png");
const imagePath = siblingImages.find((candidate) => resourceStemKey(candidate, "png") === resourceStemKey(spritePath, "sprite"))
?? (siblingImages.length === 1 ? siblingImages[0] : undefined);
if (typeof imageUuid === "string") actualSprites.push({ imageUuid, path: spritePath });
if (typeof imageUuid === "string" && imagePath) {
actualImagesByUuid.set(imageUuid, imagePath);
mappings.set(imageUuid, imagePath);
}
}
for (const spriteEntry of manifestEntries.filter((entry) => extname(entry.fileName).toLowerCase() === ".sprite")) {
const imageEntry = manifestEntries.find((entry) => extname(entry.fileName).toLowerCase() === ".png"
&& stemKey(entry.fileName) === stemKey(spriteEntry.fileName)
&& JSON.stringify(entry.directories) === JSON.stringify(spriteEntry.directories));
const asciiIdentity = basename(spriteEntry.fileName).toLocaleLowerCase("en-US").replaceAll(/[^a-z0-9]+/g, "");
const matchingActualSprites = actualSprites.filter((entry) => basename(entry.path).toLocaleLowerCase("en-US").replaceAll(/[^a-z0-9]+/g, "") === asciiIdentity);
const matchingImageUuids = [...new Set(matchingActualSprites.map((entry) => entry.imageUuid))];
const inferredImageUuid = matchingImageUuids.length === 1 ? matchingImageUuids[0] : undefined;
const imagePath = imageEntry ? actualImagesByUuid.get(imageEntry.uuid) ?? mappings.get(imageEntry.uuid)
: inferredImageUuid ? actualImagesByUuid.get(inferredImageUuid) ?? mappings.get(inferredImageUuid)
: undefined;
const spriteDefinition = matchingActualSprites.length === 1 ? matchingActualSprites[0].path : mappings.get(spriteEntry.uuid);
if (typeof spriteDefinition === "string" && existsSync(spriteDefinition) && assetFileType(spriteDefinition) === "sprite") {
spriteDefinitions.set(spriteEntry.uuid, spriteDefinition);
}
if (typeof imagePath === "string" && existsSync(imagePath)) mappings.set(spriteEntry.uuid, imagePath);
}
return { mappings, spriteDefinitions };
}
function colorHex(value, fallback = "#111111") {
if (!value || typeof value !== "object") return fallback;
const channel = (name) => Math.max(0, Math.min(255, Math.round(Number(value[name] ?? 0) * 255))).toString(16).padStart(2, "0");
return `#${channel("r")}${channel("g")}${channel("b")}`.toUpperCase();
}
function quaternionDegrees(rotation) {
const z = Number(rotation?.z ?? 0);
const w = Number(rotation?.w ?? 1);
return Math.atan2(2 * w * z, 1 - 2 * z * z) * 180 / Math.PI;
}
function estimatedGlyphWidth(character, fontSize) {
if (/\s/u.test(character)) return fontSize * 0.34;
if (/^[A-Z]$/u.test(character)) return fontSize * 0.68;
if (/^[a-z0-9]$/u.test(character)) return fontSize * 0.56;
if (/^[.,:;!?@©()()\-_']$/u.test(character)) return fontSize * 0.42;
return fontSize;
}
function inferredTextContentSize(text, style) {
const fontSize = Math.max(1, Number(style.fontSize ?? 48));
const characterSpacing = Number(style.characterSpacing ?? 0);
const lineSpacing = Math.max(0.1, Number(style.lineSpacing ?? 1));
const lines = String(text ?? "").split("\n");
const width = Math.max(1, ...lines.map((line) => Array.from(line).reduce(
(total, character, index) => total + estimatedGlyphWidth(character, fontSize) + (index === 0 ? 0 : characterSpacing),
0,
)));
const height = fontSize + Math.max(0, lines.length - 1) * fontSize * lineSpacing;
return { height, width };
}
function textPathModel(object, transform, contentSize) {
const option = object?.m_textPathOption;
const info = option?.pk?.mPathInfo;
if (!Array.isArray(info?.mPathVerbs) || info.mPathVerbs.length === 0 || !Array.isArray(info?.mPoints)) return undefined;
const sourceAnchorX = Number(object.m_anchorPoint?.x ?? 0.5);
const sourceAnchorY = Number(object.m_anchorPoint?.y ?? 0.5);
const circle = info.mIsCircle === true;
const scaleX = Math.abs(transform.scaleX);
const scaleY = Math.abs(transform.scaleY);
return {
circle,
conic_weights: Array.isArray(info.mConicWeights) ? info.mConicWeights.map(Number) : [],
first_margin: Number(option.firstMargin ?? 0),
force_alignment: option.forceAlignment === true,
is_absolute_mode: option.isAbsoluteMode === true,
last_margin: Number(option.lastMargin ?? 0),
perpendicular: option.perpendicularToPath !== false,
points: info.mPoints.map((point) => ({
x: (Number(point?.x ?? 0) + (circle ? (0.5 - sourceAnchorX) * contentSize.width : -sourceAnchorX * contentSize.width)) * scaleX,
y: (-Number(point?.y ?? 0) + (circle ? (sourceAnchorY - 0.5) * contentSize.height : sourceAnchorY * contentSize.height)) * scaleY,
})),
reversed: option.reversedPath === true,
verbs: info.mPathVerbs.map(Number),
};
}
function spriteNinePatch(sprite) {
const object = sprite?.object;
if (Number(object?.m_type) !== 3) return undefined;
const sourceWidth = Number(object.m_width);
const sourceHeight = Number(object.m_height);
const left = Number(object.m_startW);
const rightEdge = Number(object.m_endW);
const top = Number(object.m_startH);
const bottomEdge = Number(object.m_endH);
if (![sourceWidth, sourceHeight, left, rightEdge, top, bottomEdge].every(Number.isFinite)
|| sourceWidth <= 0 || sourceHeight <= 0
|| left < 0 || rightEdge < left || rightEdge > sourceWidth
|| top < 0 || bottomEdge < top || bottomEdge > sourceHeight) return undefined;
return {
bottom: sourceHeight - bottomEdge,
left,
right: sourceWidth - rightEdge,
source_height: sourceHeight,
source_width: sourceWidth,
top,
};
}
function pngDimensions(path) {
const bytes = readFileSync(path);
if (bytes.length < 24 || bytes.readUInt32BE(12) !== 0x49484452) return undefined;
return { height: bytes.readUInt32BE(20), width: bytes.readUInt32BE(16) };
}
function firstMaterialTextureAssetId(renderer, input) {
const materialUuids = (renderer?.m_Materials ?? []).map((item) => item?.uuid?.uuid).filter((uuid) => typeof uuid === "string");
for (const materialUuid of materialUuids) {
const materialPath = input.manifestMappings.get(materialUuid);
if (typeof materialPath !== "string" || extname(materialPath).toLowerCase() !== ".mat" || !existsSync(materialPath)) continue;
let material;
try {
material = readJson(materialPath);
} catch {
continue;
}
const pending = [material];
while (pending.length > 0) {
const value = pending.pop();
if (Array.isArray(value)) {
pending.push(...value);
continue;
}
if (!value || typeof value !== "object") continue;
const textureUuid = value?.uuid?.uuid;
if (typeof textureUuid === "string") {
const texturePath = input.manifestMappings.get(textureUuid);
const assetId = typeof texturePath === "string" ? input.imageIds.get(texturePath) : undefined;
if (assetId) return assetId;
}
pending.push(...Object.values(value));
}
}
return undefined;
}
function prefabResolver(prefab) {
const instances = new Map();
for (const item of prefab?.instance_map ?? []) {
if (Number.isInteger(item?.instance_type) && Number.isInteger(item?.instance_id)) {
instances.set(`${item.instance_type}:${item.instance_id}`, item);
}
}
const resolve = (value) => {
let current = value?.internalObject ?? value;
const visited = new Set();
while (current && typeof current === "object" && !current.object) {
if (current.internalObject) {
current = current.internalObject;
continue;
}
if (current.inner_ptr) {
current = current.inner_ptr;
continue;
}
const key = Number.isInteger(current.instance_type) && Number.isInteger(current.instance_id)
? `${current.instance_type}:${current.instance_id}`
: undefined;
if (!key || visited.has(key) || !instances.has(key)) break;
visited.add(key);
current = instances.get(key);
}
if (current?.inner_ptr && !current.object) return resolve(current.inner_ptr);
return current;
};
return resolve;
}
function components(object, resolve) {
return (object?.m_Components ?? []).map(resolve).filter((item) => item?.object);
}
function prefabLayers(prefab, input) {
const layers = [];
let order = 0;
const resolve = prefabResolver(prefab);
const root = resolve(prefab?.object?.m_RootSo)?.object;
const visit = (wrapped, parent, ignorePosition = false, anchorXOverride) => {
const typed = resolve(wrapped);
const object = typed?.object;
if (!object) return;
const local = object.m_LocalTfrm ?? {};
const localPosition = local.m_Position ?? {};
const localScale = local.m_Scale ?? {};
// The first UI group may use a negative scale to bridge the source editor's
// coordinate system. Browser-space Y is already inverted below, so keeping
// that sign would mirror every glyph and decoration a second time.
const scaleX = parent.scaleX * (ignorePosition ? Math.abs(Number(localScale.x ?? 1)) : Number(localScale.x ?? 1));
const scaleY = parent.scaleY * (ignorePosition ? Math.abs(Number(localScale.y ?? 1)) : Number(localScale.y ?? 1));
const localX = ignorePosition ? 0 : Number(localPosition.x ?? 0) * parent.scaleX;
const localY = ignorePosition ? 0 : Number(localPosition.y ?? 0) * parent.scaleY;
const parentRadians = parent.rotation * Math.PI / 180;
const transform = {
alpha: parent.alpha * Math.max(0, Math.min(1, Number(object.m_CustomAlpha ?? 1))),
anchorX: Math.max(0, Math.min(1, Number(anchorXOverride ?? object.m_anchorPoint?.x ?? 0.5))),
anchorY: Math.max(0, Math.min(1, Number(object.m_anchorPoint?.y ?? 0.5))),
rotation: parent.rotation + quaternionDegrees(local.m_Rotation),
scaleX,
scaleY,
x: parent.x + localX * Math.cos(parentRadians) - localY * Math.sin(parentRadians),
y: parent.y + localX * Math.sin(parentRadians) + localY * Math.cos(parentRadians),
};
const nodeComponents = components(object, resolve);
const localUnderlines = [
...(parent.underlines ?? []),
...nodeComponents.filter((item) => item.typeId === "UnderLineBehavior").flatMap((item) => item.object?.m_UnderLineConfig ?? []),
];
transform.underlines = localUnderlines;
const textMesh = nodeComponents.find((item) => item.typeId === "TextMesh")?.object;
if (textMesh) {
const textRenderer = nodeComponents.find((item) => item.typeId === "TextRenderer")?.object;
const style = textMesh.m_fontStyleInfo ?? {};
const inferredSize = inferredTextContentSize(textMesh.m_text, style);
const contentSize = {
height: Number(object.m_contentSize?.height ?? 0) > 0 ? Number(object.m_contentSize.height) : inferredSize.height,
width: Number(object.m_contentSize?.width ?? 0) > 0 ? Number(object.m_contentSize.width) : inferredSize.width,
};
const textPath = textPathModel(object, transform, contentSize);
const outline = style.outlineInfo?.outlineSize > 0 ? style.outlineInfo : undefined;
const shadow = style.shadowInfos?.find((item) => Number(item?.offset?.x ?? 0) !== 0 || Number(item?.offset?.y ?? 0) !== 0);
const fontUuid = textMesh.m_font?.uuid?.uuid;
const fillPatternAssetId = firstMaterialTextureAssetId(textRenderer, input);
const sourceTextureLayout = textMesh.m_textureLayoutInfo;
const fillTextureLayout = fillPatternAssetId
&& Number(sourceTextureLayout?.rows) > 0
&& Number(sourceTextureLayout?.columns) > 0
&& Array.isArray(sourceTextureLayout?.idList)
&& sourceTextureLayout.idList.length > 0
? {
columns: Math.max(1, Number(sourceTextureLayout.columns)),
id_list: sourceTextureLayout.idList.map(Number),
rows: Math.max(1, Number(sourceTextureLayout.rows)),
}
: undefined;
layers.push({
align: Number(style.alignment ?? 0) === 2 ? "right" : Number(style.alignment ?? 0) === 1 ? "center" : "left",
...(transform.alpha === 1 ? {} : { alpha: transform.alpha }),
...(transform.anchorX === 0.5 ? {} : { anchor_x: transform.anchorX }),
...(transform.anchorY === 0.5 ? {} : { anchor_y: 1 - transform.anchorY }),
fill_color: colorHex(style.color),
...(fillPatternAssetId ? { fill_pattern_asset_id: fillPatternAssetId } : {}),
...(fillTextureLayout ? { fill_texture_layout: fillTextureLayout } : {}),
font_file: typeof fontUuid === "string" ? input.manifestMappings.get(fontUuid) : undefined,
font_md5: typeof object.m_fontMd5Value === "string" ? object.m_fontMd5Value.toLocaleLowerCase("en-US") : undefined,
font_size: Math.max(1, Number(style.fontSize ?? 48) * Math.abs(scaleY)),
height: Math.max(1, contentSize.height * Math.abs(scaleY)),
letter_spacing: Number(style.characterSpacing ?? 1),
line_height: Number(style.lineSpacing ?? 1),
order: order++,
rotation: -transform.rotation,
scale_x: Math.sign(scaleX) || 1,
scale_y: Math.sign(scaleY) || 1,
shadow_blur: Math.max(0, Number(shadow?.blur ?? shadow?.SDFFontBorder ?? 0)),
shadow_color: colorHex(shadow?.color, "#000000"),
shadow_offset_x: Number(shadow?.offset?.x ?? 0),
shadow_offset_y: -Number(shadow?.offset?.y ?? 0),
stroke_color: colorHex(outline?.outlineColor, "#000000"),
stroke_width: Math.max(0, Number(outline?.outlineSize ?? 0)),
text: String(textMesh.m_text ?? ""),
...(textPath ? { text_path: textPath } : {}),
type: "text",
vertical_align: Number(style.vAlignment ?? 1) === 0 ? "top"
: Number(style.vAlignment ?? 1) === 2 ? "bottom"
: "middle",
width: Math.max(1, contentSize.width * Math.abs(scaleX)),
x: transform.x,
y: -transform.y,
});
for (const underline of localUnderlines.filter((item) => item?.p1?.enable !== false && item?.p0 === object.m_Name)) {
const config = underline.p1?.exportParams ?? {};
const ninePatch = config.ninePatchInfos?.find((item) => item?.enable !== false && typeof item?.texture?.uuid?.uuid === "string");
const textureUuid = ninePatch?.texture?.uuid?.uuid ?? config.texture?.uuid?.uuid;
const texturePath = typeof textureUuid === "string" ? input.manifestMappings.get(textureUuid) : undefined;
const assetId = typeof texturePath === "string" ? input.imageIds.get(texturePath) : undefined;
if (!assetId || typeof texturePath !== "string") continue;
const dimensions = pngDimensions(texturePath);
const targetWidth = Math.max(1, Number(object.m_contentSize?.width ?? 1) * Math.abs(scaleX)
* (config.enableUnderLineSizeWithText === false ? 1 : Number(config.underLineSize ?? 100) / 100));
const naturalRatio = dimensions ? dimensions.height / Math.max(1, dimensions.width) : 0.15;
const targetHeight = Math.max(2, Math.min(Number(object.m_contentSize?.height ?? 48) * 0.65, targetWidth * naturalRatio));
layers.push({
...(transform.alpha === 1 ? {} : { alpha: transform.alpha }),
asset_id: assetId,
height: targetHeight,
order: order++,
rotation: -transform.rotation,
scale_x: Math.sign(scaleX) || 1,
scale_y: Math.sign(scaleY) || 1,
type: "image",
width: targetWidth,
x: transform.x,
y: -transform.y + Number(object.m_contentSize?.height ?? 48) * Math.abs(scaleY) / 2
+ Number(config.relativeDistance ?? 0) + targetHeight / 2,
});
}
for (const particleComponent of nodeComponents.filter((item) => item.typeId === "ParticlesText2D").map((item) => item.object)) {
if (particleComponent?.m_isEnabled === false) continue;
const textureUuid = particleComponent?.m_altasTexUUID?.uuid;
const texturePath = typeof textureUuid === "string" ? input.manifestMappings.get(textureUuid) : undefined;
const assetId = typeof texturePath === "string" ? input.imageIds.get(texturePath) : undefined;
if (!assetId) continue;
layers.push({
alpha: transform.alpha * Math.max(0, Math.min(1, Number(particleComponent.m_AlphaIdensity ?? 1))),
anchor_x: transform.anchorX,
anchor_y: 1 - transform.anchorY,
asset_id: assetId,
atlas_columns: Math.max(1, Number(particleComponent.m_altasUcount ?? 1)),
atlas_rows: Math.max(1, Number(particleComponent.m_altasVcount ?? 1)),
color: colorHex(particleComponent.m_ParticleColor, "#FFFFFF"),
density: Math.max(1, Number(particleComponent.m_particlesDensity ?? 1)),
height: Math.max(1, contentSize.height * Math.abs(scaleY)),
order: order++,
particle_height: Math.max(1, Number(particleComponent.m_particlesRenderSize?.y ?? 8)),
particle_width: Math.max(1, Number(particleComponent.m_particlesRenderSize?.x ?? 8)),
randomize_angle: Number(particleComponent.m_particlesRandomizeAngle ?? 0),
randomize_position: Number(particleComponent.m_particlesRandomizePosition ?? 0),
rotation: -transform.rotation,
scale_x: Math.sign(scaleX) || 1,
scale_y: Math.sign(scaleY) || 1,
type: "particles",
width: Math.max(1, contentSize.width * Math.abs(scaleX)),
x: transform.x,
y: -transform.y,
});
}
}
const spriteRenderer = nodeComponents.find((item) => item.typeId === "SpriteRenderer")?.object;
if (spriteRenderer && spriteRenderer.m_isEnabled !== false) {
const spriteUuid = spriteRenderer?.m_sprite?.uuid?.uuid;
const spritePath = typeof spriteUuid === "string" ? input.manifestMappings.get(spriteUuid) : undefined;
const spriteDefinitionPath = typeof spriteUuid === "string" ? input.spriteDefinitions.get(spriteUuid) : undefined;
let imagePath;
let ninePatch;
if (typeof spriteDefinitionPath === "string" && existsSync(spriteDefinitionPath)) {
const sprite = readJson(spriteDefinitionPath);
const imageUuid = sprite?.object?.m_UUIDList?.[0]?.uuid;
imagePath = typeof spritePath === "string" && assetFileType(spritePath) === "png"
? spritePath
: typeof imageUuid === "string" ? input.manifestMappings.get(imageUuid) : undefined;
ninePatch = spriteNinePatch(sprite);
} else if (typeof spritePath === "string" && assetFileType(spritePath) === "sprite" && existsSync(spritePath)) {
const sprite = readJson(spritePath);
const imageUuid = sprite?.object?.m_UUIDList?.[0]?.uuid;
if (typeof imageUuid === "string") imagePath = input.manifestMappings.get(imageUuid);
ninePatch = spriteNinePatch(sprite);
} else if (typeof spritePath === "string" && assetFileType(spritePath) === "png") imagePath = spritePath;
const assetId = typeof imagePath === "string" ? input.imageIds.get(imagePath) : undefined;
if (assetId) {
layers.push({
...(transform.alpha === 1 ? {} : { alpha: transform.alpha }),
...(transform.anchorX === 0.5 ? {} : { anchor_x: transform.anchorX }),
...(transform.anchorY === 0.5 ? {} : { anchor_y: 1 - transform.anchorY }),
asset_id: assetId,
height: Math.max(1, Number(object.m_contentSize?.height ?? 1) * Math.abs(scaleY)),
order: order++,
rotation: -transform.rotation,
scale_x: Math.sign(scaleX) || 1,
scale_y: Math.sign(scaleY) || 1,
type: "image",
width: Math.max(1, Number(object.m_contentSize?.width ?? 1) * Math.abs(scaleX)),
x: transform.x,
y: -transform.y,
...(ninePatch ? { nine_patch: ninePatch } : {}),
});
} else {
input.unresolvedImages.push({ spriteUuid, spritePath });
}
}
const children = (object.m_Children ?? []).map((child) => ({ child, typed: resolve(child) }));
// Linked text backgrounds can store their image position at the text box's left edge.
const linkedTextLeftEdges = children
.filter(({ typed }) => typed?.typeId === "TextView" && Number(typed.object?.m_UseLink ?? 0) === 1)
.map(({ typed }) => {
const textObject = typed.object;
return Number(textObject.m_LocalTfrm?.m_Position?.x ?? 0)
- Number(textObject.m_anchorPoint?.x ?? 0.5) * Number(textObject.m_contentSize?.width ?? 0);
});
for (const { child, typed } of children) {
const childX = Number(typed?.object?.m_LocalTfrm?.m_Position?.x ?? 0);
const usesLinkedTextLeftEdge = typed?.typeId === "ImageView"
&& linkedTextLeftEdges.some((leftEdge) => Math.abs(childX - leftEdge) < 0.01);
visit(child, transform, false, usesLinkedTextLeftEdge ? 0 : undefined);
}
};
for (const child of root?.m_Children ?? []) visit(child, { alpha: 1, rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, true);
return layers;
}
function fontIdForFile(fontResources, file, contentMd5) {
if (typeof contentMd5 === "string") {
const hashMatch = fontResources.find((resource) => resource.contentMd5 === contentMd5.toLocaleLowerCase("en-US"));
if (hashMatch) return hashMatch.assetId;
}
if (typeof file !== "string") return fontResources[0]?.assetId;
const name = basename(file).toLocaleLowerCase("en-US");
return fontResources.find((resource) => resource.names.includes(name))?.assetId ?? fontResources[0]?.assetId;
}
function defaultText(metadata, layers) {
const candidates = [metadata.default_text, metadata.runtime?.layer?.default_text, ...(metadata.runtime?.human_strings ?? [])]
.filter((value) => typeof value === "string" && value.trim());
return String(candidates[0] ?? layers.find((layer) => layer.type === "text" && layer.text.trim())?.text ?? metadata.display_name ?? metadata.canonical_id);
}
function normalizeModel(layers, defaultValue, fontResources) {
const textLayers = layers.filter((layer) => layer.type === "text");
const primary = textLayers.find((layer) => layer.text.trim().toLocaleLowerCase("zh-CN") === defaultValue.trim().toLocaleLowerCase("zh-CN")) ?? textLayers[0];
if (!primary) return undefined;
for (const layer of textLayers) {
layer.content_linked = layer.text === primary.text;
layer.editable = layer === primary;
layer.font_id = fontIdForFile(fontResources, layer.font_file, layer.font_md5);
delete layer.font_file;
delete layer.font_md5;
}
const bounds = layers.map((layer) => {
const anchorX = Number(layer.anchor_x ?? 0.5);
const anchorY = Number(layer.anchor_y ?? 0.5);
const radians = Number(layer.rotation ?? 0) * Math.PI / 180;
const cosine = Math.cos(radians);
const sine = Math.sin(radians);
const corners = [
[-anchorX * layer.width, -anchorY * layer.height],
[(1 - anchorX) * layer.width, -anchorY * layer.height],
[-anchorX * layer.width, (1 - anchorY) * layer.height],
[(1 - anchorX) * layer.width, (1 - anchorY) * layer.height],
].map(([x, y]) => ({
x: layer.x + x * Number(layer.scale_x ?? 1) * cosine - y * Number(layer.scale_y ?? 1) * sine,
y: layer.y + x * Number(layer.scale_x ?? 1) * sine + y * Number(layer.scale_y ?? 1) * cosine,
}));
return {
bottom: Math.max(...corners.map((item) => item.y)),
left: Math.min(...corners.map((item) => item.x)),
right: Math.max(...corners.map((item) => item.x)),
top: Math.min(...corners.map((item) => item.y)),
};
});
const left = Math.min(...bounds.map((item) => item.left));
const right = Math.max(...bounds.map((item) => item.right));
const top = Math.min(...bounds.map((item) => item.top));
const bottom = Math.max(...bounds.map((item) => item.bottom));
const centerX = (left + right) / 2;
const centerY = (top + bottom) / 2;
const normalization = Math.min(1, 360 / Math.max(1, right - left), 260 / Math.max(1, bottom - top));
for (const layer of layers) {
layer.x = Number(((layer.x - centerX) * normalization).toFixed(3));
layer.y = Number(((layer.y - centerY) * normalization).toFixed(3));
layer.width = Number((layer.width * normalization).toFixed(3));
layer.height = Number((layer.height * normalization).toFixed(3));
if (layer.type === "text") {
layer.font_size = Number((layer.font_size * normalization).toFixed(3));
layer.letter_spacing = Number((layer.letter_spacing * normalization).toFixed(3));
layer.stroke_width = Number((layer.stroke_width * normalization).toFixed(3));
layer.shadow_blur = Number((layer.shadow_blur * normalization).toFixed(3));
layer.shadow_offset_x = Number((layer.shadow_offset_x * normalization).toFixed(3));
layer.shadow_offset_y = Number((layer.shadow_offset_y * normalization).toFixed(3));
if (layer.text_path) {
layer.text_path.points = layer.text_path.points.map((point) => ({
x: Number((point.x * normalization).toFixed(3)),
y: Number((point.y * normalization).toFixed(3)),
}));
}
} else if (layer.type === "particles") {
layer.particle_height = Number((layer.particle_height * normalization).toFixed(3));
layer.particle_width = Number((layer.particle_width * normalization).toFixed(3));
}
}
return {
half_size: {
height: Number(((bottom - top) * normalization / 2).toFixed(3)),
width: Number(((right - left) * normalization / 2).toFixed(3)),
},
image_layers: layers.filter((layer) => layer.type === "image"),
particle_layers: layers.filter((layer) => layer.type === "particles"),
text_layers: textLayers,
};
}
export function compileTextTemplateAssets({ templateDirectory, templateId }) {
const metadata = readJson(join(templateDirectory, "metadata.json"));
const fontResources = (metadata.files?.fonts ?? []).map((reference, index) => {
const resource = browserFontResource(templateDirectory, templateId, reference, index);
const normalized = normalizeBrowserFontBytes(resource.sourceBytes ?? readFileSync(resource.sourcePath));
const browserBytes = normalized ?? resource.sourceBytes ?? readFileSync(resource.sourcePath);
return {
...resource,
contentMd5: createHash("md5").update(browserBytes).digest("hex"),
...(normalized ? { sourceBytes: normalized, sourcePath: undefined } : {}),
};
});
if (fontResources.length === 0) throw new Error(`text_template_font_missing:${templateId}`);
const packageRoot = join(templateDirectory, "package");
const packageFiles = filesBelow(packageRoot);
const imagePaths = packageFiles.filter((path) => assetFileType(path) === "png");
const imageResources = imagePaths.map((sourcePath, index) => ({
assetId: `TEXT-IMAGE-${templateId}-${String(index + 1).padStart(3, "0")}`,
extension: ".png",
mimeType: "image/png",
sourcePath,
}));
const imageIds = new Map(imagePaths.map((path, index) => [path, imageResources[index].assetId]));
const { mappings: manifestMappings, spriteDefinitions } = manifestFileMap(packageFiles);
const unresolvedImages = [];
const prefabCandidates = packageFiles.filter((path) => extname(path).toLowerCase() === ".prefab").flatMap((path) => {
let prefab;
try {
prefab = readJson(path);
} catch {
return [];
}
const layers = prefabLayers(prefab, { imageIds, manifestMappings, spriteDefinitions, unresolvedImages });
const texts = layers.filter((layer) => layer.type === "text").map((layer) => layer.text.trim().toLocaleLowerCase("zh-CN"));
const expected = [metadata.default_text, metadata.runtime?.layer?.default_text, ...(metadata.runtime?.human_strings ?? [])]
.filter((value) => typeof value === "string" && value.trim()).map((value) => value.trim().toLocaleLowerCase("zh-CN"));
const match = expected.some((value) => texts.includes(value));
return [{ layers, path, score: (match ? 10_000 : 0) + texts.length * 100 + layers.length }];
}).filter((candidate) => candidate.layers.some((layer) => layer.type === "text"));
const chosen = prefabCandidates.toSorted((left, right) => right.score - left.score || left.path.localeCompare(right.path))[0];
const value = defaultText(metadata, chosen?.layers ?? []);
const fallbackLayers = [{
align: "center",
editable: true,
fill_color: "#111111",
font_id: fontResources[0].assetId,
font_size: 48,
height: 58,
letter_spacing: 1,
line_height: 1.2,
order: 0,
rotation: 0,
scale_x: 1,
scale_y: 1,
shadow_blur: 0,
shadow_color: "#000000",
shadow_offset_x: 0,
shadow_offset_y: 0,
stroke_color: "#000000",
stroke_width: 0,
text: value,
type: "text",
vertical_align: "middle",
width: Math.max(96, Array.from(value).length * 52),
x: 0,
y: 0,
}];
const renderModel = normalizeModel(chosen?.layers ?? fallbackLayers, value, fontResources);
if (!renderModel || renderModel.text_layers.some((layer) => !layer.font_id)) throw new Error(`text_template_render_model_invalid:${templateId}`);
return {
catalog: {
default_font_id: renderModel.text_layers.find((layer) => layer.editable)?.font_id ?? fontResources[0].assetId,
default_font_size: renderModel.text_layers.find((layer) => layer.editable)?.font_size ?? 48,
default_text: value,
font_match_status: "template_package",
render_model: renderModel,
},
diagnostics: {
package_images: imageResources.length,
prefab_candidates: prefabCandidates.length,
unresolved_images: unresolvedImages.length,
},
resources: [...fontResources, ...imageResources].map(({ contentMd5: _contentMd5, names: _names, ...resource }) => resource),
};
}
+125
View File
@@ -0,0 +1,125 @@
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import {
WP4_07_REAL_RESOURCE_VERSIONS,
WP4_07_RED_RESOURCE_VERSION,
WP4_07_SOURCE_HASHES,
assertWp407Fixture,
} from "../../tests/visual-performance/wp4-07-fixture.mjs";
export const WP4_07_REQUIRED_WP5_TASKS = Object.freeze(
Array.from({ length: 7 }, (_, index) => `TASK-WP5-0${index + 1}`),
);
export const WP4_07_REQUIRED_WP5_BRANCHES = Object.freeze(
Array.from({ length: 5 }, (_, index) => `codex/wp5-0${index + 3}`),
);
export function validateWp407FrozenInputs() {
const mismatches = [];
for (const [path, expected] of Object.entries(WP4_07_SOURCE_HASHES)) {
if (!existsSync(path)) {
mismatches.push({ actual: null, expected, path });
continue;
}
const actual = createHash("sha256").update(readFileSync(path)).digest("hex").toUpperCase();
if (actual !== expected) mismatches.push({ actual, expected, path });
}
if (mismatches.length > 0) {
const error = new Error("WP4_07_FROZEN_SOURCE_CHANGED");
error.details = mismatches;
throw error;
}
return assertWp407Fixture();
}
function subjectMatchesTask(subject, taskId) {
const shortId = taskId.replace("TASK-", "");
return subject.includes(taskId) || subject.includes(shortId);
}
export function inspectWp5TaskLineage(heads, histories) {
const candidate_branch = [...WP4_07_REQUIRED_WP5_TASKS]
.reverse()
.map((taskId) => taskId.replace("TASK-WP5-", "codex/wp5-"))
.find((branch) => /^[0-9a-f]{40}$/.test(heads[branch] ?? "")) ?? null;
const allCommits = Object.values(histories).flat();
const task_shas = Object.fromEntries(WP4_07_REQUIRED_WP5_TASKS.flatMap((taskId) => {
const commit = allCommits.find((entry) => subjectMatchesTask(entry.subject, taskId));
return commit && /^[0-9a-f]{40}$/.test(commit.sha) ? [[taskId, commit.sha]] : [];
}));
const missing_tasks = WP4_07_REQUIRED_WP5_TASKS.filter((taskId) => {
const taskNumber = Number(taskId.slice(-2));
if (taskNumber <= 2) return !task_shas[taskId];
const branch = taskId.replace("TASK-WP5-", "codex/wp5-");
return !/^[0-9a-f]{40}$/.test(heads[branch] ?? "")
|| !(histories[branch] ?? []).some((entry) => subjectMatchesTask(entry.subject, taskId));
});
const terminal_branch_shas = Object.fromEntries(WP4_07_REQUIRED_WP5_BRANCHES.flatMap((branch) => (
/^[0-9a-f]{40}$/.test(heads[branch] ?? "") ? [[branch, heads[branch]]] : []
)));
return {
candidate_baseline_branch: candidate_branch,
candidate_baseline_sha: candidate_branch ? heads[candidate_branch] : null,
complete: missing_tasks.length === 0 && Object.keys(terminal_branch_shas).length === WP4_07_REQUIRED_WP5_BRANCHES.length,
missing_tasks,
required_final_branches: WP4_07_REQUIRED_WP5_BRANCHES,
required_tasks: WP4_07_REQUIRED_WP5_TASKS,
task_shas,
terminal_branch_shas,
};
}
export function readWp5RemoteGate() {
const result = spawnSync("git", ["ls-remote", "--heads", "origin", "codex/wp5-*"], { encoding: "utf8", timeout: 30_000 });
if ((result.status ?? 1) !== 0) {
const error = new Error("WP4_07_GITEA_GATE_UNREADABLE");
error.details = { exit_code: result.status ?? 1 };
throw error;
}
const heads = Object.fromEntries(result.stdout.trim().split(/\r?\n/).filter(Boolean).map((line) => {
const [sha, reference] = line.split(/\s+/);
return [reference.replace("refs/heads/", ""), sha];
}));
const histories = {};
for (const branch of WP4_07_REQUIRED_WP5_BRANCHES.filter((name) => /^[0-9a-f]{40}$/.test(heads[name] ?? ""))) {
const fetch = spawnSync("git", ["fetch", "--quiet", "--no-tags", "origin", `refs/heads/${branch}`], { encoding: "utf8", timeout: 60_000 });
if ((fetch.status ?? 1) !== 0) {
const error = new Error("WP4_07_GITEA_BASELINE_FETCH_FAILED");
error.details = { branch, exit_code: fetch.status ?? 1 };
throw error;
}
const log = spawnSync("git", ["log", "--format=%H%x09%s", heads[branch]], { encoding: "utf8", timeout: 30_000 });
if ((log.status ?? 1) !== 0) {
const error = new Error("WP4_07_GITEA_BASELINE_HISTORY_UNREADABLE");
error.details = { branch, exit_code: log.status ?? 1 };
throw error;
}
histories[branch] = log.stdout.trim().split(/\r?\n/).filter(Boolean).map((line) => {
const separator = line.indexOf("\t");
return { sha: line.slice(0, separator), subject: line.slice(separator + 1) };
});
}
return {
heads,
...inspectWp5TaskLineage(heads, histories),
};
}
export function validateWp5FinalManifest(path) {
if (!path || !existsSync(path)) throw new Error("WP4_07_FINAL_ASSET_MANIFEST_REQUIRED");
const raw = readFileSync(path, "utf8");
if (raw.includes(WP4_07_RED_RESOURCE_VERSION) || raw.includes("fixture-v1")) throw new Error("WP4_07_PLACEHOLDER_ASSET_REJECTED");
const manifest = JSON.parse(raw);
const expectedCounts = { color_cards: 16, dynamic_stickers: 35, font_panel_items: 86, static_parts: 25, static_stickers: 1_407, text_templates: 332 };
for (const [key, expected] of Object.entries(expectedCounts)) {
if (manifest.counts?.[key] !== expected) throw new Error(`WP4_07_FINAL_MANIFEST_COUNT_MISMATCH:${key}`);
}
if (!manifest.release_version || String(manifest.release_version).includes("fixture")) throw new Error("WP4_07_FINAL_RELEASE_VERSION_REQUIRED");
if (manifest.source_versions?.complex !== WP4_07_REAL_RESOURCE_VERSIONS.complex
|| manifest.source_versions?.static_stickers !== WP4_07_REAL_RESOURCE_VERSIONS.static) {
throw new Error("WP4_07_FINAL_MANIFEST_VERSION_MISMATCH");
}
return { release_version: manifest.release_version, sha256: createHash("sha256").update(raw).digest("hex").toUpperCase() };
}
+418
View File
@@ -0,0 +1,418 @@
import { createHash } from "node:crypto";
import {
gptImageRequestSizeForRatio,
normalizeImageOutputToRatio,
} from "../../apps/worker/src/image-output-normalizer.mjs";
import { WP7_02_MODEL_IDS, buildModelContractPlan } from "./wp7-02-external-contract.mjs";
export const WP7_02_CONTROLLED_REAL_LIMIT = 120;
const ratios = ["3:4", "1:1", "4:3", "9:16"];
const allowedMimeTypes = new Set(["image/jpeg", "image/png", "image/webp"]);
const forbiddenEvidenceKeys = /(?:^|_)(?:absolute_path|authorization|body|credential|credential_value|image_bytes|image_data|original_image|password|path|prompt|raw|raw_provider_payload|raw_prompt|secret|token)(?:_|$)/i;
function sha256(value) {
return createHash("sha256").update(value).digest("hex").toUpperCase();
}
function assertModelConfig(modelConfig) {
if (!modelConfig || typeof modelConfig !== "object" || !WP7_02_MODEL_IDS.includes(modelConfig.model_id)) {
throw new Error("WP7_02_MODEL_CONFIG_INVALID");
}
if (!Number.isSafeInteger(modelConfig.config_version) || modelConfig.config_version <= 0) {
throw new Error("WP7_02_MODEL_CONFIG_VERSION_INVALID");
}
const profile = modelConfig.route_profile;
if (!profile || typeof profile !== "object" || typeof profile.endpoint !== "string"
|| !profile.endpoint.startsWith("https://oneapi.intelligrow.cn/")
|| !["gemini-interactions-v1beta", "gemini-native-v1beta", "gemini-openai-chat-v1", "openai-images-v1"].includes(profile.protocol_version)) {
throw new Error("WP7_02_ROUTE_PROFILE_INVALID");
}
if (profile.protocol_version === "gemini-interactions-v1beta"
&& (profile.endpoint !== "https://oneapi.intelligrow.cn/v1beta/interactions"
|| profile.provider_model_id !== "gemini-3.1-flash-image")) {
throw new Error("WP7_02_ROUTE_PROFILE_INVALID");
}
if (profile.protocol_version === "gemini-openai-chat-v1"
&& (profile.endpoint !== "https://oneapi.intelligrow.cn/v1/chat/completions"
|| profile.provider_model_id !== "gemini-3.1-flash-image")) {
throw new Error("WP7_02_ROUTE_PROFILE_INVALID");
}
if (profile.protocol_version === "openai-images-v1"
&& (profile.reference_endpoint !== "https://oneapi.intelligrow.cn/v1/images/edits")) {
throw new Error("WP7_02_REFERENCE_ROUTE_PROFILE_INVALID");
}
if (profile.protocol_version === "openai-images-v1" && profile.provider_model_id !== undefined
&& profile.provider_model_id !== "gemini-3.1-flash-image") {
throw new Error("WP7_02_ROUTE_PROFILE_INVALID");
}
return modelConfig;
}
export function buildControlledExecutionPlan(modelConfig) {
const config = assertModelConfig(modelConfig);
const contractPlan = buildModelContractPlan(config.model_id);
const realScenarios = [
...ratios.map((ratio) => ({ input: "pure_text", ratio, source: "real_gateway" })),
{ input: "reference_image", ratio: "1:1", source: "real_gateway" },
];
return {
config_version: config.config_version,
error_scenarios: contractPlan.error_categories.map((name) => ({
expected: contractPlan.error_expectations[name], name, source: "deterministic_local",
})),
execution_modes: [
{ mode: "sync", source: "real_gateway" },
{ mode: "async", source: "deterministic_local" },
{ mode: "poll", source: "deterministic_local" },
],
model_id: config.model_id,
planned_real_calls: realScenarios.length,
quota_impact: "authorized_test_key_up_to_120_requests",
real_scenarios: realScenarios,
response_checks: ["single_image", "mime", "dimensions", "sanitized_usage", "evidence_hash"],
state_scenarios: contractPlan.state_checks.map((name) => ({ name, source: "deterministic_local" })),
};
}
export function buildProviderRequest({ modelConfig, prompt, ratio, reference }) {
const config = assertModelConfig(modelConfig);
if (typeof prompt !== "string" || !prompt.trim() || !ratios.includes(ratio)) throw new Error("WP7_02_REQUEST_FIXTURE_INVALID");
if (reference && (!Buffer.isBuffer(reference.bytes) || reference.bytes.length === 0 || !allowedMimeTypes.has(reference.mime_type))) {
throw new Error("WP7_02_REFERENCE_FIXTURE_INVALID");
}
const headers = { "content-type": "application/json" };
if (config.route_profile.protocol_version === "gemini-interactions-v1beta") {
const input = [{ text: prompt, type: "text" }];
if (reference) input.push({ data: reference.bytes.toString("base64"), mime_type: reference.mime_type, type: "image" });
return {
body: {
input,
model: config.route_profile.provider_model_id,
response_format: { aspect_ratio: ratio, image_size: "1K", type: "image" },
},
headers,
method: "POST",
url: config.route_profile.endpoint,
};
}
if (config.route_profile.protocol_version === "gemini-native-v1beta") {
const parts = [{ text: prompt }];
if (reference) parts.push({ inlineData: { data: reference.bytes.toString("base64"), mimeType: reference.mime_type } });
return {
body: {
contents: [{ parts, role: "user" }],
generationConfig: {
imageConfig: { aspectRatio: ratio, imageSize: "1K" },
responseModalities: ["IMAGE"],
},
},
headers,
method: "POST",
url: config.route_profile.endpoint,
};
}
if (config.route_profile.protocol_version === "gemini-openai-chat-v1") {
const content = reference
? [
{ text: prompt, type: "text" },
{
image_url: { url: `data:${reference.mime_type};base64,${reference.bytes.toString("base64")}` },
type: "image_url",
},
]
: prompt;
return {
body: {
extra_body: { google: { image_config: { aspect_ratio: ratio, image_size: "1K" } } },
messages: [{ content, role: "user" }],
model: config.route_profile.provider_model_id,
stream: false,
},
headers,
method: "POST",
url: config.route_profile.endpoint,
};
}
const providerModelId = config.route_profile.provider_model_id ?? config.model_id;
const body = {
model: providerModelId,
prompt,
response_format: "b64_json",
size: gptImageRequestSizeForRatio(ratio),
};
if (reference) {
const form = new FormData();
form.append("model", providerModelId);
form.append("prompt", prompt);
form.append("response_format", "b64_json");
form.append("size", gptImageRequestSizeForRatio(ratio));
form.append("image[]", new Blob([reference.bytes], { type: reference.mime_type }), "reference.png");
return { body: form, headers: {}, method: "POST", url: config.route_profile.reference_endpoint };
}
return { body, headers, method: "POST", url: config.route_profile.endpoint };
}
function pngDimensions(bytes) {
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
if (bytes.length < 24 || !bytes.subarray(0, 8).equals(signature)) return undefined;
return { height: bytes.readUInt32BE(20), width: bytes.readUInt32BE(16) };
}
function jpegDimensions(bytes) {
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) return undefined;
let offset = 2;
while (offset + 9 < bytes.length) {
if (bytes[offset] !== 0xff) { offset += 1; continue; }
const marker = bytes[offset + 1];
if ([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker)) {
return { height: bytes.readUInt16BE(offset + 5), width: bytes.readUInt16BE(offset + 7) };
}
if (marker === 0xd8 || marker === 0xd9 || (marker >= 0xd0 && marker <= 0xd7)) { offset += 2; continue; }
const length = bytes.readUInt16BE(offset + 2);
if (length < 2) return undefined;
offset += length + 2;
}
return undefined;
}
function webpDimensions(bytes) {
if (bytes.length < 30 || bytes.toString("ascii", 0, 4) !== "RIFF" || bytes.toString("ascii", 8, 12) !== "WEBP") return undefined;
const kind = bytes.toString("ascii", 12, 16);
if (kind === "VP8X") {
return {
height: 1 + bytes.readUIntLE(27, 3),
width: 1 + bytes.readUIntLE(24, 3),
};
}
if (kind === "VP8 " && bytes.length >= 30) return { height: bytes.readUInt16LE(28) & 0x3fff, width: bytes.readUInt16LE(26) & 0x3fff };
if (kind === "VP8L" && bytes.length >= 25) {
const bits = bytes.readUInt32LE(21);
return { height: 1 + ((bits >> 14) & 0x3fff), width: 1 + (bits & 0x3fff) };
}
return undefined;
}
function inspectImage(bytes, declaredMime) {
const png = pngDimensions(bytes);
if (png && declaredMime === "image/png") return { ...png, mime: declaredMime };
const jpeg = jpegDimensions(bytes);
if (jpeg && declaredMime === "image/jpeg") return { ...jpeg, mime: declaredMime };
const webp = webpDimensions(bytes);
if (webp && declaredMime === "image/webp") return { ...webp, mime: declaredMime };
throw new Error("WP7_02_RESPONSE_MEDIA_INVALID");
}
function integerOrZero(value) {
return Number.isSafeInteger(value) && value >= 0 ? value : 0;
}
function geminiUsage(response) {
const usage = response?.usageMetadata;
return {
input_units: integerOrZero(usage?.promptTokenCount),
output_units: integerOrZero(usage?.candidatesTokenCount),
total_units: integerOrZero(usage?.totalTokenCount),
};
}
function openAiUsage(response) {
const usage = response?.usage;
return {
input_units: integerOrZero(usage?.input_tokens ?? usage?.inputTokens ?? usage?.prompt_tokens ?? usage?.promptTokens),
output_units: integerOrZero(usage?.output_tokens ?? usage?.outputTokens ?? usage?.completion_tokens ?? usage?.completionTokens),
total_units: integerOrZero(usage?.total_tokens ?? usage?.totalTokens),
};
}
function openAiChatImage(response) {
const content = response?.choices?.[0]?.message?.content;
if (typeof content !== "string") throw new Error("WP7_02_RESPONSE_SINGLE_IMAGE_REQUIRED");
const matches = [...content.matchAll(/!\[[^\]]*\]\(\s*data:(image\/(?:jpeg|png|webp));base64,([A-Za-z0-9+/=\r\n]+)\s*\)/gi)];
if (matches.length !== 1) throw new Error("WP7_02_RESPONSE_SINGLE_IMAGE_REQUIRED");
return { data: matches[0][2], mime: matches[0][1].toLowerCase() };
}
function interactionUsage(response) {
const usage = response?.usage;
return {
input_units: integerOrZero(usage?.total_input_tokens),
output_units: integerOrZero(usage?.total_output_tokens),
total_units: integerOrZero(usage?.total_tokens),
};
}
export function normalizeProviderResponse(modelConfig, response) {
const config = assertModelConfig(modelConfig);
let bytes;
let mime;
let usageSummary;
if (config.route_profile.protocol_version === "gemini-interactions-v1beta") {
const stepImages = response?.steps?.flatMap((step) => step?.type === "model_output" ? step?.content ?? [] : [])
.filter((content) => content?.type === "image" && content?.data) ?? [];
const images = stepImages.length > 0
? stepImages
: [response?.output_image].filter((content) => content?.data);
if (images.length !== 1) throw new Error("WP7_02_RESPONSE_SINGLE_IMAGE_REQUIRED");
mime = images[0].mime_type ?? images[0].mimeType;
bytes = Buffer.from(images[0].data, "base64");
usageSummary = interactionUsage(response);
} else if (config.route_profile.protocol_version === "gemini-native-v1beta") {
const parts = response?.candidates?.flatMap((candidate) => candidate?.content?.parts ?? []) ?? [];
const images = parts.map((part) => part?.inlineData ?? part?.inline_data).filter((entry) => entry?.data);
if (images.length !== 1) throw new Error("WP7_02_RESPONSE_SINGLE_IMAGE_REQUIRED");
mime = images[0].mimeType ?? images[0].mime_type;
bytes = Buffer.from(images[0].data, "base64");
usageSummary = geminiUsage(response);
} else if (config.route_profile.protocol_version === "gemini-openai-chat-v1") {
const image = openAiChatImage(response);
bytes = Buffer.from(image.data, "base64");
mime = image.mime;
usageSummary = openAiUsage(response);
} else {
if (!Array.isArray(response?.data) || response.data.length !== 1 || typeof response.data[0]?.b64_json !== "string") {
throw new Error("WP7_02_RESPONSE_SINGLE_IMAGE_REQUIRED");
}
bytes = Buffer.from(response.data[0].b64_json, "base64");
mime = "image/png";
usageSummary = openAiUsage(response);
}
const media = inspectImage(bytes, mime);
return {
bytes,
dimensions: { height: media.height, width: media.width },
evidence_hash: `sha256:${sha256(bytes)}`,
mime: media.mime,
usage_summary: usageSummary,
};
}
export function describeProviderResponseShape(value, depth = 0) {
if (typeof value === "string") {
const trimmed = value.trim();
const representation = /^data:image\/(?:jpeg|png|webp);base64,/i.test(trimmed)
? "inline_media"
: /!\[[^\]]*\]\(\s*https?:\/\/[^)\s]+\s*\)/i.test(trimmed)
? "markdown_uri"
: /^https?:\/\/\S+$/i.test(trimmed)
? "uri"
: "plain_text";
return {
kind: "string",
representation,
size: value.length === 0 ? "empty" : value.length > 256 ? "large" : "small",
};
}
if (typeof value === "number") return { kind: "number" };
if (typeof value === "boolean") return { kind: "boolean" };
if (value === null || value === undefined) return { kind: value === null ? "null" : "undefined" };
if (depth >= 6) return { kind: "depth_limit" };
if (Array.isArray(value)) {
return {
item: value.length > 0 ? describeProviderResponseShape(value[0], depth + 1) : { kind: "empty" },
kind: "array",
length: value.length,
};
}
if (value && typeof value === "object") {
return {
fields: Object.keys(value).toSorted().map((name) => ({ name, shape: describeProviderResponseShape(value[name], depth + 1) })),
kind: "object",
};
}
return { kind: "undefined" };
}
export function buildSanitizedResponseEvidence(normalized) {
const evidence = {
dimensions: structuredClone(normalized.dimensions),
evidence_hash: normalized.evidence_hash,
mime: normalized.mime,
...(normalized.normalization ? { normalization: structuredClone(normalized.normalization) } : {}),
usage_summary: structuredClone(normalized.usage_summary),
};
return validateSanitizedEvidence(evidence);
}
function inspectEvidenceValue(value, seen = new Set()) {
if (value && typeof value === "object") {
if (seen.has(value)) throw new Error("WP7_02_EVIDENCE_CYCLE_FORBIDDEN");
seen.add(value);
for (const [key, entry] of Object.entries(value)) {
if (key === "verified") throw new Error("WP7_02_SHARED_VERIFIED_FORBIDDEN");
if (key !== "secret_scan" && forbiddenEvidenceKeys.test(key)) throw new Error(`WP7_02_SENSITIVE_EVIDENCE_FORBIDDEN:${key}`);
inspectEvidenceValue(entry, seen);
}
seen.delete(value);
} else if (typeof value === "string" && /[A-Za-z]:\\Users\\/i.test(value)) {
throw new Error("WP7_02_SENSITIVE_EVIDENCE_FORBIDDEN");
}
}
export function validateSanitizedEvidence(evidence) {
inspectEvidenceValue(evidence);
return evidence;
}
export async function executeProviderRequest({ fetchImpl = fetch, modelConfig, prompt, ratio, reference, token, timeoutMs = 180_000 }) {
if (typeof token !== "string" || token.length < 8) throw new Error("WP7_02_CREDENTIAL_INVALID");
const request = buildProviderRequest({ modelConfig, prompt, ratio, reference });
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const startedAt = performance.now();
try {
const response = await fetchImpl(request.url, {
body: request.body instanceof FormData ? request.body : JSON.stringify(request.body),
headers: { ...request.headers, authorization: `Bearer ${token}` },
method: request.method,
signal: controller.signal,
});
const durationMs = Math.round(performance.now() - startedAt);
if (!response.ok) throw new Error(`WP7_02_UPSTREAM_HTTP_${response.status}`);
const providerResponse = await response.json();
let normalized;
try {
const providerNormalized = normalizeProviderResponse(modelConfig, providerResponse);
const adapted = await normalizeImageOutputToRatio({
bytes: providerNormalized.bytes,
mimeType: providerNormalized.mime,
pixelHeight: providerNormalized.dimensions.height,
pixelWidth: providerNormalized.dimensions.width,
ratio,
});
normalized = {
...providerNormalized,
bytes: adapted.bytes,
dimensions: { height: adapted.pixelHeight, width: adapted.pixelWidth },
evidence_hash: `sha256:${sha256(adapted.bytes)}`,
mime: adapted.mimeType,
normalization: {
applied: adapted.normalized,
upstream_dimensions: { height: adapted.upstreamPixelHeight, width: adapted.upstreamPixelWidth },
},
};
} catch (error) {
if (error instanceof Error && /^WP7_02_[A-Z0-9_]+$/.test(error.message)) {
error.safe_response_shape = describeProviderResponseShape(providerResponse);
} else if (error instanceof Error && error.message === "image_output_media_invalid") {
throw new Error("WP7_02_RESPONSE_MEDIA_INVALID");
} else if (error instanceof Error && /^image_output_(?:aspect_ratio_mismatch|dimensions_missing|normalization_failed)$/.test(error.message)) {
throw new Error("WP7_02_RESPONSE_DIMENSIONS_INVALID");
}
throw error;
}
return {
duration_ms: durationMs,
http_status: response.status,
normalized,
response_evidence: buildSanitizedResponseEvidence(normalized),
};
} catch (error) {
if (error?.name === "AbortError") throw new Error("WP7_02_UPSTREAM_TIMEOUT");
if (error instanceof Error && /^WP7_02_[A-Z0-9_]+$/.test(error.message)) throw error;
throw new Error("WP7_02_UPSTREAM_FAILED");
} finally {
clearTimeout(timeout);
}
}
+261
View File
@@ -0,0 +1,261 @@
import { createHash } from "node:crypto";
import { deflateSync } from "node:zlib";
import {
WP7_02_CONTROLLED_REAL_LIMIT,
buildControlledExecutionPlan,
executeProviderRequest,
validateSanitizedEvidence,
} from "./wp7-02-controlled-executor.mjs";
const productDimensions = Object.freeze({
"3:4": { height: 1440, width: 1080 },
"1:1": { height: 1080, width: 1080 },
"4:3": { height: 1080, width: 1440 },
"9:16": { height: 1920, width: 1080 },
});
function sha256(value) {
return createHash("sha256").update(value).digest("hex").toUpperCase();
}
function crc32(bytes) {
let crc = 0xffffffff;
for (const byte of bytes) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
}
return (crc ^ 0xffffffff) >>> 0;
}
function pngChunk(type, data) {
const name = Buffer.from(type, "ascii");
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length);
const checksum = Buffer.alloc(4);
checksum.writeUInt32BE(crc32(Buffer.concat([name, data])));
return Buffer.concat([length, name, data, checksum]);
}
export function createControlledReferencePng() {
const width = 64;
const height = 64;
const rows = [];
for (let y = 0; y < height; y += 1) {
const row = Buffer.alloc(1 + width * 4);
for (let x = 0; x < width; x += 1) {
const offset = 1 + x * 4;
const bright = (Math.floor(x / 8) + Math.floor(y / 8)) % 2 === 0;
row[offset] = bright ? 32 : 220;
row[offset + 1] = bright ? 180 : 48;
row[offset + 2] = bright ? 220 : 140;
row[offset + 3] = 255;
}
rows.push(row);
}
const header = Buffer.alloc(13);
header.writeUInt32BE(width, 0);
header.writeUInt32BE(height, 4);
header[8] = 8;
header[9] = 6;
return Buffer.concat([
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
pngChunk("IHDR", header),
pngChunk("IDAT", deflateSync(Buffer.concat(rows))),
pngChunk("IEND", Buffer.alloc(0)),
]);
}
function promptForScenario(scenario) {
const subject = scenario.input === "reference_image" ? "use the supplied geometric color reference" : "use a geometric color study";
return `Create one safe abstract test image; ${subject}; no text, logos, people, or real places; aspect ratio ${scenario.ratio}.`;
}
function dimensionsMatch(dimensions, ratio) {
const expected = productDimensions[ratio];
return dimensions.width === expected.width && dimensions.height === expected.height;
}
function safeErrorCode(error) {
return error instanceof Error && /^WP7_02_[A-Z0-9_]+$/.test(error.message)
? error.message
: "WP7_02_UPSTREAM_FAILED";
}
export async function runControlledRealScenarios({ fetchImpl = fetch, maxRealCalls, modelConfig, token }) {
const plan = buildControlledExecutionPlan(modelConfig);
if (maxRealCalls !== WP7_02_CONTROLLED_REAL_LIMIT || plan.planned_real_calls > maxRealCalls) {
throw new Error("WP7_02_REAL_CALL_LIMIT_INVALID");
}
const referenceBytes = createControlledReferencePng();
const attempts = [];
const calls = [];
let timeoutRetriesRemaining = 1;
let stop = false;
for (let index = 0; index < plan.real_scenarios.length; index += 1) {
const scenario = plan.real_scenarios[index];
const scenarioId = `real-${index + 1}`;
let attemptNo = 0;
while (true) {
attemptNo += 1;
try {
const result = await executeProviderRequest({
fetchImpl,
modelConfig,
prompt: promptForScenario(scenario),
ratio: scenario.ratio,
reference: scenario.input === "reference_image" ? { bytes: referenceBytes, mime_type: "image/png" } : undefined,
token,
});
attempts.push(validateSanitizedEvidence({
attempt_no: attemptNo, duration_ms: result.duration_ms, http_status: result.http_status,
scenario_id: scenarioId, status: "passed",
}));
const dimensionsPassed = dimensionsMatch(result.normalized.dimensions, scenario.ratio);
calls.push(validateSanitizedEvidence({
duration_ms: result.duration_ms,
http_status: result.http_status,
input: scenario.input,
requested_ratio: scenario.ratio,
response: result.response_evidence,
scenario_id: scenarioId,
source: "real_gateway",
status: dimensionsPassed ? "passed" : "failed",
validation: { dimensions: dimensionsPassed ? "passed" : "failed", response: "passed" },
}));
break;
} catch (error) {
const errorCode = safeErrorCode(error);
attempts.push(validateSanitizedEvidence({ attempt_no: attemptNo, error_code: errorCode, scenario_id: scenarioId, status: "failed" }));
if (errorCode === "WP7_02_UPSTREAM_TIMEOUT" && timeoutRetriesRemaining > 0) {
timeoutRetriesRemaining -= 1;
continue;
}
const failed = {
error_code: errorCode,
input: scenario.input,
requested_ratio: scenario.ratio,
scenario_id: scenarioId,
source: "real_gateway",
status: "failed",
...(error?.safe_response_shape ? { response_shape: error.safe_response_shape } : {}),
};
calls.push(validateSanitizedEvidence(failed));
if (["WP7_02_RESPONSE_SINGLE_IMAGE_REQUIRED", "WP7_02_RESPONSE_MEDIA_INVALID", "WP7_02_CREDENTIAL_INVALID",
"WP7_02_UPSTREAM_HTTP_401", "WP7_02_UPSTREAM_HTTP_403", "WP7_02_UPSTREAM_HTTP_404", "WP7_02_UPSTREAM_HTTP_429"].includes(failed.error_code)) stop = true;
break;
}
}
if (stop) break;
}
referenceBytes.fill(0);
const blockers = calls.filter((call) => call.status !== "passed").map((call) => `${call.scenario_id}:${call.error_code ?? "dimensions_or_response_invalid"}`);
return validateSanitizedEvidence({
blockers,
attempts,
calls,
maximum_real_calls: plan.planned_real_calls + 1,
model_id: modelConfig.model_id,
planned_real_calls: plan.planned_real_calls,
real_calls: attempts.length,
status: blockers.length === 0 ? "passed" : "externally_blocked",
});
}
export function buildDeterministicExecutionEvidence(modelId, runId) {
const operationRef = `sha256:${sha256(`${modelId}:${runId}:operation`)}`;
let state = "created";
const trace = [];
const start = () => {
if (state !== "created") throw new Error("WP7_02_ASYNC_STATE_INVALID");
state = "pending";
trace.push({ action: "start", after: state, before: "created", status: "passed" });
return operationRef;
};
const poll = (reference) => {
if (reference !== operationRef || !["pending", "completed"].includes(state)) throw new Error("WP7_02_POLL_REFERENCE_INVALID");
const before = state;
state = "completed";
trace.push({ action: "poll", after: state, before, replay: before === "completed", status: "passed" });
return state;
};
const reference = start();
poll(reference);
poll(reference);
return validateSanitizedEvidence({
modes: [
{ mode: "sync", source: "real_gateway", status: "covered_by_real_calls" },
{ mode: "async", source: "deterministic_local", status: "passed", transition: "created_to_pending" },
{ mode: "poll", operation_ref: operationRef, replay_count: 1, source: "deterministic_local", status: "passed", transition: "pending_to_completed" },
],
model_id: modelId,
status: "passed",
trace,
});
}
function passedCall(calls, predicate) {
return calls.some((call) => call.status === "passed" && predicate(call));
}
export function assembleControlledModelEvidence({ deterministicState, modelConfig, realExecution, runId }) {
if (deterministicState?.model_id !== modelConfig.model_id || deterministicState?.status !== "passed") {
throw new Error("WP7_02_DETERMINISTIC_STATE_INCOMPLETE");
}
const execution = buildDeterministicExecutionEvidence(modelConfig.model_id, runId);
const ratioRows = Object.keys(productDimensions).map((ratio) => ({
outputs: passedCall(realExecution.calls, (call) => call.requested_ratio === ratio) ? 1 : 0,
ratio,
status: passedCall(realExecution.calls, (call) => call.requested_ratio === ratio) ? "passed" : "failed",
}));
const pureTextPassed = ratioRows.every((row) => row.status === "passed")
&& passedCall(realExecution.calls, (call) => call.input === "pure_text");
const referencePassed = passedCall(realExecution.calls, (call) => call.input === "reference_image");
const deterministicPassed = deterministicState.error_scenarios?.length === 9
&& deterministicState.error_scenarios.every((entry) => entry.status === "passed")
&& deterministicState.settlements?.length === 3
&& deterministicState.contract_change?.full_matrix_reapplied === true;
const status = realExecution.status === "passed" && pureTextPassed && referencePassed
&& ratioRows.every((row) => row.status === "passed") && deterministicPassed ? "passed" : "externally_blocked";
const evidenceId = `sha256:${sha256(`${modelConfig.model_id}:${modelConfig.config_version}:${runId}`)}`;
return validateSanitizedEvidence({
evidence_id: evidenceId,
external_calls: {
approved_real_call_limit: WP7_02_CONTROLLED_REAL_LIMIT,
attempts: realExecution.attempts,
calls: realExecution.calls,
maximum_real_calls: realExecution.maximum_real_calls,
mode: "controlled_real",
planned_real_calls: realExecution.planned_real_calls,
real_calls: realExecution.real_calls,
service: "ai-gateway-service-id",
status: realExecution.status,
},
manual_review: {
decision: status === "passed" ? "Review sanitized matrix before recording the model as passed." : "Resolve all failed scenarios before review.",
status: status === "passed" ? "pending" : "blocked",
},
matrix: {
config_version: modelConfig.config_version,
contract_change: deterministicState.contract_change,
error_scenarios: deterministicState.error_scenarios,
execution_modes: execution.modes,
model_id: modelConfig.model_id,
pure_text: { outputs: pureTextPassed ? 1 : 0, status: pureTextPassed ? "passed" : "failed" },
ratios: ratioRows,
reference_image: { outputs: referencePassed ? 1 : 0, status: referencePassed ? "passed" : "failed" },
response_checks: ["single_image", "mime", "dimensions", "sanitized_usage", "evidence_hash"].map((name) => ({ name, status: realExecution.status })),
settlements: deterministicState.settlements,
status,
},
model_id: modelConfig.model_id,
redaction: {
forbidden_fields_absent: true,
retained_fields: ["status", "category", "duration_ms", "mime", "dimensions", "usage_summary", "evidence_hash", "time"],
secret_scan: "passed",
status: "passed",
},
run_id: runId,
status,
});
}
+249
View File
@@ -0,0 +1,249 @@
import { createHash } from "node:crypto";
import { mkdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
export const AI_GATEWAY_CREDENTIAL_TARGET = "Dada/P0A/worker/ai-gateway";
export const WP7_02_MODEL_IDS = Object.freeze([
"gemini-3.1-flash-image",
"gpt-image-2",
]);
const controlledStateProductModelIds = Object.freeze({
"gemini-3.1-flash-image": "gemini-3.1-flash-image-preview",
"gpt-image-2": "gpt-image-2",
});
const expectedCandidateCommit = "623cad25b2a2a9a003502c9a92ebd318dad06248";
const expectedBrowsers = Object.freeze({
"Google Chrome": "150.0.7871.187",
"Microsoft Edge": "151.0.4129.59",
});
const ratios = Object.freeze(["3:4", "1:1", "4:3", "9:16"]);
const errorCategories = Object.freeze([
"upstream_timeout", "upstream_failed", "safety_rejected", "model_disabled",
"gateway_balance_insufficient", "gateway_contract_invalid", "reference_invalid",
"unknown_retryable", "unknown_non_retryable",
]);
const errorExpectations = Object.freeze({
upstream_timeout: { credit_effect: "release_once", job_outcome: "failed", user_action: "retry_original_input" },
upstream_failed: { credit_effect: "release_once", job_outcome: "failed", user_action: "retry_later" },
safety_rejected: { credit_effect: "release_once", job_outcome: "rejected", user_action: "modify_prompt_or_reference" },
model_disabled: { credit_effect: "no_reserve", job_outcome: "not_created", user_action: "choose_other_model_or_wait" },
gateway_balance_insufficient: { credit_effect: "no_reserve_or_release_once", job_outcome: "not_created_or_failed", user_action: "choose_unaffected_model_or_contact_admin" },
gateway_contract_invalid: { credit_effect: "no_reserve_or_release_once", job_outcome: "not_created_or_failed", user_action: "choose_other_model_or_contact_admin" },
reference_invalid: { credit_effect: "no_reserve_or_release_once", job_outcome: "not_created_or_failed", user_action: "replace_or_remove_reference" },
unknown_retryable: { credit_effect: "release_once", job_outcome: "failed", user_action: "retry_later" },
unknown_non_retryable: { credit_effect: "release_once", job_outcome: "failed", user_action: "contact_admin" },
});
function stableJson(value) {
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.entries(value).sort(([left], [right]) => left.localeCompare(right))
.map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`).join(",")}}`;
}
return JSON.stringify(value);
}
function sha256(value) {
return createHash("sha256").update(typeof value === "string" ? value : stableJson(value)).digest("hex").toUpperCase();
}
function assertModelId(modelId) {
if (!WP7_02_MODEL_IDS.includes(modelId)) throw new Error("WP7_02_MODEL_NOT_ALLOWED");
return modelId;
}
export function productModelIdForControlledState(modelId) {
assertModelId(modelId);
return controlledStateProductModelIds[modelId];
}
export function buildModelContractPlan(modelId) {
assertModelId(modelId);
const plannedRequestBreakdown = {
contract_change_full_revalidation: 20,
error_categories: 9,
execution_modes_and_poll: 3,
input_and_ratio_success: 6,
settlement_boundaries: 2,
};
return {
error_categories: [...errorCategories],
error_expectations: structuredClone(errorExpectations),
execution_modes: ["sync", "async", "poll"],
inputs: ["pure_text", "reference_image"],
model_id: modelId,
planned_provider_requests_max: Object.values(plannedRequestBreakdown).reduce((total, count) => total + count, 0),
planned_request_breakdown: plannedRequestBreakdown,
quota_impact: "unknown_requires_operator_review",
ratios: [...ratios],
response_checks: ["single_image", "mime", "dimensions", "sanitized_usage"],
state_checks: [
"credit_commit_once", "credit_release_once_per_terminal_failure",
"contract_change_invalidation", "full_revalidation",
],
};
}
export function validateCandidateDependency(record) {
if (!record || typeof record !== "object") throw new Error("WP7_02_CANDIDATE_RECORD_REQUIRED");
if (record.final_release !== false || record.status !== "candidate_unvalidated"
|| record.candidate_package?.release_status !== "candidate_unvalidated") {
throw new Error("WP7_02_CANDIDATE_FINAL_RELEASE_FORBIDDEN");
}
if (record.build_commit !== expectedCandidateCommit || record.fixed_port !== 43121) {
throw new Error("WP7_02_CANDIDATE_BASELINE_MISMATCH");
}
const browsers = Array.isArray(record.browsers) ? record.browsers : [];
if (browsers.length !== 2 || Object.entries(expectedBrowsers).some(([brand, version]) => {
const browser = browsers.find((entry) => entry?.brand === brand);
return !browser || browser.full_version !== version || browser.major !== Number(version.split(".")[0])
|| browser.source !== "installed_executable";
})) throw new Error("WP7_02_CANDIDATE_BROWSER_MISMATCH");
if (!/^[A-F0-9]{64}$/.test(record.candidate_package?.sha256 ?? "")) throw new Error("WP7_02_CANDIDATE_PACKAGE_HASH_INVALID");
return {
browsers: Object.entries(expectedBrowsers).map(([brand, full_version]) => ({ brand, full_version })),
build_commit: record.build_commit,
candidate_package_sha256: record.candidate_package.sha256,
fixed_port: record.fixed_port,
record_sha256: sha256(record),
status: record.status,
};
}
function validateRealModelConfig(modelConfig, modelId) {
if (!modelConfig || typeof modelConfig !== "object") return { blocker: "real_model_config_absent" };
const endpoint = modelConfig.route_profile?.endpoint;
const validEndpoint = typeof endpoint === "string" && endpoint.startsWith("https://")
&& !/\.(?:invalid)(?:\/|$)/i.test(endpoint) && !/https:\/\/(?:localhost|127\.0\.0\.1)(?:[:/]|$)/i.test(endpoint);
if (modelConfig.model_id !== modelId || !Number.isSafeInteger(modelConfig.config_version) || modelConfig.config_version <= 0
|| !validEndpoint || typeof modelConfig.gateway_account_ref !== "string" || /mock/i.test(modelConfig.gateway_account_ref)) {
return { blocker: "real_model_config_invalid" };
}
return {
config: {
config_version: modelConfig.config_version,
endpoint_sha256: sha256(endpoint),
gateway_account_ref_sha256: sha256(modelConfig.gateway_account_ref),
model_id: modelId,
route_profile_sha256: sha256(modelConfig.route_profile),
},
};
}
export function inspectAiGatewayReadiness({ candidateRecord, confirmed, credentialTargets, modelConfig, modelId }) {
const candidate = validateCandidateDependency(candidateRecord);
assertModelId(modelId);
const blockers = [];
if (confirmed !== true) blockers.push("explicit_confirmation_absent");
if (!Array.isArray(credentialTargets) || !credentialTargets.includes(AI_GATEWAY_CREDENTIAL_TARGET)) {
blockers.push("real_gateway_credentials_absent");
}
const checkedConfig = validateRealModelConfig(modelConfig, modelId);
if (checkedConfig.blocker) blockers.push(checkedConfig.blocker);
return {
blockers,
candidate,
model_config: checkedConfig.config ?? null,
model_id: modelId,
plan: buildModelContractPlan(modelId),
real_calls: 0,
status: blockers.length > 0 ? "externally_blocked" : "ready_for_controlled_execution",
};
}
function blockedScenarios(plan) {
return [
...plan.inputs.map((name) => ({ kind: "input", name, status: "not_run" })),
...plan.ratios.map((name) => ({ kind: "ratio", name, status: "not_run" })),
...plan.execution_modes.map((name) => ({ kind: "execution_mode", name, status: "not_run" })),
...plan.response_checks.map((name) => ({ kind: "response_check", name, status: "not_run" })),
...plan.error_categories.map((name) => ({
expected: plan.error_expectations[name], kind: "error_category", name, status: "not_run",
})),
...plan.state_checks.map((name) => ({ kind: "state_check", name, status: "not_run" })),
];
}
export function buildBlockedModelEvidence({ blockers, candidateRecord, modelId, modelConfig = null, runId }) {
const candidate = validateCandidateDependency(candidateRecord);
const plan = buildModelContractPlan(modelId);
if (!Array.isArray(blockers) || blockers.length === 0) throw new Error("WP7_02_EXTERNAL_BLOCKER_REQUIRED");
const evidenceId = `sha256:${sha256({ model_id: modelId, run_id: runId })}`;
return {
blockers: [...new Set(blockers)],
candidate,
evidence_id: evidenceId,
external_calls: {
mode: "controlled_real_not_executed",
planned_provider_requests_max: plan.planned_provider_requests_max,
planned_request_breakdown: plan.planned_request_breakdown,
quota_impact: plan.quota_impact,
real_calls: 0,
service: "ai-gateway-service-id",
},
manual_review: {
decision: "Do not mark this model verified until every controlled-real scenario passes against the listed config version.",
status: "blocked",
},
matrix: {
config_version: modelConfig?.config_version ?? null,
model_id: modelId,
scenarios: blockedScenarios(plan),
status: "not_run",
},
model_id: modelId,
redaction: {
retained_fields: ["status", "category", "duration_ms", "mime", "dimensions", "usage_summary", "evidence_hash", "time"],
secret_scan: "passed",
},
run_id: runId,
status: "externally_blocked",
};
}
export function validateIndependentEvidenceSet(evidence) {
if (!Array.isArray(evidence) || evidence.length !== WP7_02_MODEL_IDS.length) throw new Error("WP7_02_MODEL_EVIDENCE_SET_REQUIRED");
const ids = evidence.map((entry) => entry.model_id).toSorted();
if (JSON.stringify(ids) !== JSON.stringify([...WP7_02_MODEL_IDS].toSorted())) throw new Error("WP7_02_MODEL_EVIDENCE_SET_INVALID");
if (new Set(evidence.map((entry) => entry.evidence_id)).size !== evidence.length) throw new Error("WP7_02_SHARED_EVIDENCE_FORBIDDEN");
for (const entry of evidence) {
const blocked = entry.status === "externally_blocked"
&& Number.isSafeInteger(entry.external_calls?.real_calls) && entry.external_calls.real_calls >= 0
&& entry.manual_review?.status === "blocked";
const passed = entry.status === "passed" && entry.matrix?.status === "passed"
&& entry.external_calls?.status === "passed" && entry.external_calls.real_calls > 0
&& entry.manual_review?.status === "passed" && entry.redaction?.status === "passed";
const pendingReview = entry.status === "passed" && entry.matrix?.status === "passed"
&& entry.external_calls?.status === "passed" && entry.external_calls.real_calls > 0
&& entry.manual_review?.status === "pending" && entry.redaction?.status === "passed";
if (entry.matrix?.model_id !== entry.model_id || (!blocked && !passed && !pendingReview)
|| /\"verified\"\s*:/i.test(JSON.stringify(entry))) {
throw new Error("WP7_02_BLOCKED_EVIDENCE_INVALID");
}
}
return evidence;
}
export function writeBlockedModelEvidence(directory, evidence) {
mkdirSync(resolve(directory), { recursive: true });
const files = {
"contract-matrix.json": evidence.matrix,
"external-calls.json": evidence.external_calls,
"manual-review.json": evidence.manual_review,
"readiness.json": {
blockers: evidence.blockers,
candidate: evidence.candidate,
evidence_id: evidence.evidence_id,
model_id: evidence.model_id,
run_id: evidence.run_id,
status: evidence.status,
},
"redaction.json": evidence.redaction,
};
for (const [name, value] of Object.entries(files)) {
writeFileSync(resolve(directory, name), `${JSON.stringify(value, null, 2)}\n`);
}
return Object.keys(files);
}
+59
View File
@@ -0,0 +1,59 @@
import { WP7_02_MODEL_IDS } from "./wp7-02-external-contract.mjs";
const productDimensions = Object.freeze({
"3:4": [1080, 1440],
"1:1": [1080, 1080],
"4:3": [1440, 1080],
"9:16": [1080, 1920],
});
function modelEvidenceComplete(entry) {
const { externalCalls, matrix, modelId, readiness, redaction } = entry;
return matrix.model_id === modelId && Number.isSafeInteger(matrix.config_version) && matrix.config_version > 0 && matrix.status === "passed"
&& matrix.pure_text?.status === "passed" && matrix.reference_image?.status === "passed"
&& matrix.ratios?.length === 4 && matrix.ratios.every((row) => row.status === "passed")
&& matrix.execution_modes?.length === 3 && matrix.execution_modes.every((row) => ["passed", "covered_by_real_calls"].includes(row.status))
&& matrix.error_scenarios?.length === 9 && matrix.error_scenarios.every((row) => row.status === "passed")
&& matrix.settlements?.length === 3 && matrix.contract_change?.full_matrix_reapplied === true
&& externalCalls.status === "passed" && externalCalls.real_calls >= 5 && externalCalls.real_calls <= 6
&& externalCalls.planned_real_calls === 5 && externalCalls.maximum_real_calls === 6
&& externalCalls.attempts?.length === externalCalls.real_calls
&& externalCalls.calls?.length === 5 && new Set(externalCalls.calls.map((row) => row.scenario_id)).size === 5
&& externalCalls.calls.every((row) => row.status === "passed" && row.source === "real_gateway")
&& externalCalls.calls.every((row) => {
const [width, height] = productDimensions[row.requested_ratio] ?? [];
return row.response?.dimensions?.width === width && row.response?.dimensions?.height === height;
})
&& externalCalls.approved_real_call_limit === 120
&& readiness.status === "passed" && redaction.status === "passed" && redaction.secret_scan === "passed";
}
export function reviewIndependentModelEvidence(entries, { reviewedAt, runId }) {
const entryIds = Array.isArray(entries) ? entries.map((entry) => entry?.modelId).toSorted() : [];
if (!Array.isArray(entries) || entries.length !== WP7_02_MODEL_IDS.length
|| JSON.stringify(entryIds) !== JSON.stringify([...WP7_02_MODEL_IDS].toSorted())
|| !runId || !reviewedAt) {
throw new Error("WP7_02_MANUAL_REVIEW_EVIDENCE_INVALID");
}
const evidenceIds = entries.map((entry) => entry.readiness?.evidence_id);
if (evidenceIds.some((id) => typeof id !== "string") || new Set(evidenceIds).size !== entries.length) {
throw new Error("WP7_02_MANUAL_REVIEW_EVIDENCE_NOT_INDEPENDENT");
}
const reviews = entries.map((entry) => modelEvidenceComplete(entry) ? {
basis: ["independent_model_evidence", "five_scenarios_bounded_attempts", "four_ratios", "reference_input", "nine_errors", "settlement", "contract_change", "redaction"],
decision: "Sanitized controlled-real and deterministic evidence is complete for this config version.",
model_id: entry.modelId,
reviewed_at: reviewedAt,
reviewer_role: "dada_editor_quality_group",
run_id: runId,
status: "passed",
} : {
decision: "Independent model evidence remains incomplete or externally blocked.",
model_id: entry.modelId,
reviewed_at: reviewedAt,
reviewer_role: "dada_editor_quality_group",
run_id: runId,
status: "blocked",
});
return { reviews, status: reviews.every((review) => review.status === "passed") ? "passed" : "externally_blocked" };
}
+45
View File
@@ -0,0 +1,45 @@
import path from 'node:path';
import { REQUIRED_COVERAGE_UNITS } from './wp7-05-ui-gate.mjs';
const ABSOLUTE_PATH = /^(?:[A-Za-z]:[\\/]|[\\/]{2}|\\\\)/;
function assertSafeRelative(value, field) {
if (typeof value !== 'string' || !value || ABSOLUTE_PATH.test(value) || path.isAbsolute(value)) {
throw new Error(`WP7_05_UNSAFE_${field}`);
}
const normalized = value.replaceAll('\\', '/');
if (normalized.split('/').includes('..')) throw new Error(`WP7_05_UNSAFE_${field}`);
return normalized;
}
export function buildCoverageEvidence({ runId, candidateSha256, coverageUnits, viewports }) {
if (!runId || !/^[A-Za-z0-9._-]+$/.test(runId)) throw new Error('WP7_05_INVALID_RUN_ID');
if (!/^[A-Fa-f0-9]{64}$/.test(candidateSha256 ?? '')) throw new Error('WP7_05_INVALID_CANDIDATE_HASH');
if (!Array.isArray(coverageUnits)) throw new Error('WP7_05_COVERAGE_UNITS_REQUIRED');
const byPage = new Map();
for (const unit of coverageUnits) {
if (!REQUIRED_COVERAGE_UNITS.includes(unit.page_id)) throw new Error('WP7_05_UNKNOWN_PAGE');
if (byPage.has(unit.page_id)) throw new Error('WP7_05_DUPLICATE_PAGE');
if (!Array.isArray(unit.states) || unit.states.length === 0) throw new Error('WP7_05_STATES_REQUIRED');
const states = unit.states.map((state) => ({
state: assertSafeRelative(state.state, 'STATE'),
screenshot_100pct: assertSafeRelative(state.screenshot_100pct, 'SCREENSHOT'),
screenshot_200pct: assertSafeRelative(state.screenshot_200pct, 'SCREENSHOT'),
trace: assertSafeRelative(state.trace, 'TRACE'),
}));
byPage.set(unit.page_id, { page_id: unit.page_id, states });
}
const missing = REQUIRED_COVERAGE_UNITS.filter((page) => !byPage.has(page));
if (missing.length) throw new Error(`WP7_05_MISSING_PAGES:${missing.join(',')}`);
if (!Array.isArray(viewports) || viewports.length !== 2) throw new Error('WP7_05_VIEWPORTS_REQUIRED');
return {
schema_version: '1.0',
task: 'TASK-WP7-05',
run_id: runId,
candidate_sha256: candidateSha256.toUpperCase(),
viewports,
coverage_units: REQUIRED_COVERAGE_UNITS.map((page) => byPage.get(page)),
};
}
+71
View File
@@ -0,0 +1,71 @@
import fs from 'node:fs';
export const REQUIRED_COVERAGE_UNITS = Object.freeze([
'support-gate', 'user-auth', 'workspace', 'current-task', 'projects',
'project-detail', 'editor', 'export', 'credits', 'settings',
'preview-user-variant', 'admin-auth', 'admin-overview', 'admin-users',
'admin-invites', 'admin-models', 'admin-assets', 'admin-preview',
'admin-generations', 'admin-services-storage', 'admin-audit', 'system-ui',
]);
const REQUIRED_VIEWPORTS = Object.freeze([
{ width: 1920, height: 1080, deviceScaleFactor: 1, zoom: 100 },
{ width: 1920, height: 1080, deviceScaleFactor: 1, zoom: 200 },
]);
function blocked(code, details = {}) {
return { status: 'externally_blocked', code, ...details };
}
export function loadCandidateRecord(path) {
if (!path || !fs.existsSync(path)) return blocked('candidate_record_missing');
try {
const record = JSON.parse(fs.readFileSync(path, 'utf8'));
if (!Array.isArray(record.browsers) || record.browsers.length !== 2) {
return blocked('candidate_browser_record_incomplete');
}
const brands = new Set(record.browsers.map((browser) => browser.brand));
if (brands.size !== 2 || !brands.has('Google Chrome') || !brands.has('Microsoft Edge')) {
return blocked('candidate_browser_pair_invalid');
}
if (record.windows?.build == null || !record.candidate_package?.sha256 || !record.candidate_package?.fixed_port) {
return blocked('candidate_identity_incomplete');
}
if (record.browsers.some((browser) => !browser.full_version || !browser.major)) {
return blocked('candidate_full_version_missing');
}
return { status: 'ready', record };
} catch {
return blocked('candidate_record_invalid');
}
}
export function validateCoverageEvidence(evidence) {
if (!evidence || !Array.isArray(evidence.coverage_units)) {
return blocked('coverage_evidence_missing');
}
const actual = new Set(evidence.coverage_units.map((unit) => unit.page_id));
const missing = REQUIRED_COVERAGE_UNITS.filter((unit) => !actual.has(unit));
if (missing.length) return blocked('coverage_units_incomplete', { missing });
const missingStates = evidence.coverage_units
.filter((unit) => REQUIRED_COVERAGE_UNITS.includes(unit.page_id))
.filter((unit) => !Array.isArray(unit.states) || unit.states.length === 0)
.map((unit) => unit.page_id);
if (missingStates.length) return blocked('coverage_states_incomplete', { missingStates });
const viewportKeys = new Set((evidence.viewports ?? []).map((viewport) => JSON.stringify(viewport)));
const missingViewports = REQUIRED_VIEWPORTS.filter((viewport) => !viewportKeys.has(JSON.stringify(viewport)));
if (missingViewports.length) return blocked('candidate_viewports_incomplete', { missingViewports });
return { status: 'ready' };
}
export function runWp705Gate({ candidatePath, evidence, dependencies = {} }) {
const candidate = loadCandidateRecord(candidatePath);
if (candidate.status !== 'ready') return candidate;
const coverage = validateCoverageEvidence(evidence);
if (coverage.status !== 'ready') return coverage;
const externalBlockers = Object.entries(dependencies)
.filter(([, status]) => status === 'externally_blocked')
.map(([task]) => task);
if (externalBlockers.length) return blocked('upstream_external_blocked', { externalBlockers });
return { status: 'ready_for_execution' };
}
+62
View File
@@ -0,0 +1,62 @@
const EXPECTED_TRACE_SUMMARY = Object.freeze({
acceptanceCriteria: 52,
errorCategories: 9,
featureModules: 13,
parentFamilies: 89,
penProductFrames: 18,
productContracts: 19,
requirements: 109,
tasks: 52,
testCases: 117,
uiPages: 22,
});
const REQUIRED_UPSTREAM = Object.freeze({
"TASK-WP7-01": "passed",
"TASK-WP7-02": "passed",
"TASK-WP7-03": "deferred_nonblocking_first_version",
"TASK-WP7-04": "deferred_nonblocking_first_version",
"TASK-WP7-05": "passed",
});
const shaPattern = /^[0-9a-f]{40}$/i;
export function buildWp706PrefreezeReport({ currentCommit, releaseExists, trace, upstream }) {
if (releaseExists) throw new Error("WP7_06_RELEASE_WRITTEN_PREMATURELY");
if (!shaPattern.test(currentCommit ?? "")) throw new Error("WP7_06_CURRENT_COMMIT_INVALID");
if (trace?.status !== "passed" || !Array.isArray(trace?.errors) || trace.errors.length > 0) {
throw new Error("WP7_06_TRACE_VALIDATION_FAILED");
}
for (const [key, expected] of Object.entries(EXPECTED_TRACE_SUMMARY)) {
if (trace.summary?.[key] !== expected) throw new Error(`WP7_06_TRACE_COUNT_MISMATCH:${key}`);
}
for (const [taskId, expectedStatus] of Object.entries(REQUIRED_UPSTREAM)) {
const item = upstream?.[taskId];
if (!item || !shaPattern.test(item.head ?? "")) throw new Error(`WP7_06_UPSTREAM_HEAD_INVALID:${taskId}`);
if (item.merged !== true) throw new Error(`WP7_06_UPSTREAM_NOT_MERGED:${taskId}`);
if (item.status !== expectedStatus) throw new Error(`WP7_06_UNSUPPORTED_STATUS:${taskId}`);
}
return {
schema_version: "1.0",
task_id: "TASK-WP7-06",
status: "passed",
current_commit: currentCommit.toLowerCase(),
release_json_written: false,
trace_summary: { ...EXPECTED_TRACE_SUMMARY },
upstream: Object.fromEntries(Object.entries(REQUIRED_UPSTREAM).map(([taskId]) => [taskId, {
branch: upstream[taskId].branch,
head: upstream[taskId].head.toLowerCase(),
merged: true,
status: upstream[taskId].status,
}])),
deferred_external_tasks: Object.entries(REQUIRED_UPSTREAM)
.filter(([, status]) => status === "deferred_nonblocking_first_version")
.map(([taskId]) => taskId),
final_release_allowed: false,
next_task: "TASK-WP7-07",
};
}
export { EXPECTED_TRACE_SUMMARY, REQUIRED_UPSTREAM };
+117
View File
@@ -0,0 +1,117 @@
import { createHash } from "node:crypto";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { extname, join, relative } from "node:path";
const SHA40 = /^[a-f0-9]{40}$/i;
const SHA64 = /^[a-f0-9]{64}$/i;
const VERSION = /^[1-9][0-9]*\.[0-9]+\.[0-9]+\.[0-9]+$/;
const ABSOLUTE_PATH = /(?:[A-Za-z]:[\\/](?:Users|Documents)[\\/][^\\/"'\s]+[\\/]|\/Users\/[^/"'\s]+\/|\/home\/[^/"'\s]+\/)/;
const CREDENTIAL = /\b(?:sk|key)-[A-Za-z0-9_-]{16,}\b|-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/i;
const TEXT_EXTENSIONS = new Set([".cjs", ".cs", ".css", ".html", ".js", ".json", ".mjs", ".ts", ".tsx", ".txt", ".xml", ".yaml", ".yml"]);
export const DEFERRED_EXTERNAL_TASKS = Object.freeze(["TASK-WP7-03", "TASK-WP7-04"]);
export function buildFinalReleaseRecord({ appVersion, browsers, buildCommit, frozenFromCommit, recordedAt, windows }) {
const record = {
appVersion,
browsers: browsers.map(({ brand, fullVersion, supportedMajorVersions }) => ({
brand,
fullVersion,
...(supportedMajorVersions ? { supportedMajorVersions: [...supportedMajorVersions] } : {}),
})),
buildCommit: buildCommit.toLowerCase(),
deferredExternalTasks: [...DEFERRED_EXTERNAL_TASKS],
finalRelease: true,
fixedPort: 43121,
frozenFromCommit: frozenFromCommit.toLowerCase(),
recordedAt,
releaseStatus: "first_version_internal",
schemaVersion: "1.0",
windows: { arch: windows.arch, build: windows.build, displayVersion: windows.displayVersion },
};
return validateFinalReleaseRecord(record);
}
export function validateFinalReleaseRecord(record) {
const errors = [];
if (record?.schemaVersion !== "1.0") errors.push("schemaVersion");
if (record?.releaseStatus !== "first_version_internal") errors.push("releaseStatus");
if (record?.finalRelease !== true) errors.push("finalRelease");
if (record?.fixedPort !== 43121) errors.push("fixedPort");
if (!SHA40.test(record?.buildCommit ?? "")) errors.push("buildCommit");
if (!SHA40.test(record?.frozenFromCommit ?? "")) errors.push("frozenFromCommit");
if (!Number.isFinite(Date.parse(record?.recordedAt ?? ""))) errors.push("recordedAt");
if (!Array.isArray(record?.deferredExternalTasks) || record.deferredExternalTasks.join("|") !== DEFERRED_EXTERNAL_TASKS.join("|")) errors.push("deferredExternalTasks");
if (record?.windows?.arch !== "x64" || !/^\d+\.\d+$/.test(record?.windows?.build ?? "")) errors.push("windows");
if (!Array.isArray(record?.browsers) || record.browsers.length !== 2) {
errors.push("browsers");
} else {
const brands = record.browsers.map(({ brand }) => brand).sort();
if (brands.join("|") !== "Google Chrome|Microsoft Edge") errors.push("browserBrands");
const supportedMajorCount = record.browsers.reduce(
(count, browser) => count + (Array.isArray(browser.supportedMajorVersions)
? browser.supportedMajorVersions.length
: 1),
0,
);
if (supportedMajorCount > 8) errors.push("supportedMajorVersions.total");
for (const browser of record.browsers) {
if (!VERSION.test(browser.fullVersion ?? "")) errors.push(`${browser.brand}.fullVersion`);
if (browser.supportedMajorVersions !== undefined) {
const values = browser.supportedMajorVersions;
const baselineMajor = Number.parseInt(browser.fullVersion.split(".")[0] ?? "0", 10);
if (!Array.isArray(values) || values.length === 0 || values.length > 8
|| values.some((value) => !Number.isSafeInteger(value) || value < 1)
|| new Set(values).size !== values.length
|| !values.includes(baselineMajor)) {
errors.push(`${browser.brand}.supportedMajorVersions`);
}
}
if ("path" in browser || "executablePath" in browser || "executableSha256" in browser) errors.push(`${browser.brand}.privateMetadata`);
}
}
const serialized = JSON.stringify(record);
if (ABSOLUTE_PATH.test(serialized) || CREDENTIAL.test(serialized)) errors.push("sensitiveValue");
if (errors.length > 0) throw new Error(`WP7_07_RELEASE_INVALID:${[...new Set(errors)].join(",")}`);
return record;
}
export function sha256File(path) {
return createHash("sha256").update(readFileSync(path)).digest("hex").toUpperCase();
}
export function scanReleaseFiles({ roots, allowedFixturePaths = [] }) {
const allowed = new Set(allowedFixturePaths.map((value) => value.replaceAll("\\", "/")));
const findings = [];
let scannedFiles = 0;
function visit(root, current = root) {
for (const entry of readdirSync(current, { withFileTypes: true })) {
if ([".git", ".pnpm-store", "node_modules", "bin", "obj"].includes(entry.name)) continue;
const path = join(current, entry.name);
if (entry.isDirectory()) {
visit(root, path);
continue;
}
if (!entry.isFile()) continue;
scannedFiles += 1;
if (!TEXT_EXTENSIONS.has(extname(entry.name).toLowerCase())) continue;
const logicalPath = relative(root, path).replaceAll("\\", "/");
const content = readFileSync(path, "utf8");
if (!allowed.has(logicalPath) && ABSOLUTE_PATH.test(content)) findings.push({ path: logicalPath, rule: "absolute_user_path" });
if (!allowed.has(logicalPath) && CREDENTIAL.test(content)) findings.push({ path: logicalPath, rule: "credential_shape" });
}
}
for (const root of roots) {
if (!statSync(root).isDirectory()) throw new Error(`WP7_07_SCAN_ROOT_INVALID:${root}`);
visit(root);
}
return { findings, scanned_files: scannedFiles, status: findings.length === 0 ? "passed" : "failed" };
}
export function validateFinalEvidence({ packageManifest, release, releaseSha256, scan }) {
validateFinalReleaseRecord(release);
if (!SHA64.test(releaseSha256 ?? "")) throw new Error("WP7_07_RELEASE_HASH_INVALID");
if (packageManifest?.release_status !== release.releaseStatus || !SHA64.test(packageManifest?.zip_sha256 ?? "")) throw new Error("WP7_07_PACKAGE_MANIFEST_INVALID");
if (scan?.status !== "passed" || scan.findings?.length !== 0) throw new Error("WP7_07_LEAK_SCAN_FAILED");
return { release_sha256: releaseSha256.toUpperCase(), status: "passed", zip_sha256: packageManifest.zip_sha256.toUpperCase() };
}
+60
View File
@@ -0,0 +1,60 @@
import { createHash } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
const evidenceIndex = process.argv.indexOf("--evidence");
if (evidenceIndex < 0 || !process.argv[evidenceIndex + 1]) throw new Error("Usage: --evidence <TDD-WP4-VIS-001-browser-diff directory>");
const evidenceDirectory = resolve(process.argv[evidenceIndex + 1]);
const pixelDiff = JSON.parse(readFileSync(resolve(evidenceDirectory, "pixel-diff.json"), "utf8"));
const layout = JSON.parse(readFileSync(resolve(evidenceDirectory, "layout-boxes.json"), "utf8"));
if (pixelDiff.eligible_for_green !== true || pixelDiff.status !== "within_threshold") throw new Error("WP4_07_VISUAL_AUTOMATION_NOT_GREEN");
if (layout.eligible_for_green !== true || layout.status !== "within_threshold") throw new Error("WP4_07_LAYOUT_AUTOMATION_NOT_GREEN");
const screenshots = [];
for (const browser of ["chrome", "edge"]) {
for (const scenario of ["editor.png", "canvas.png", "export-dialog.png"]) {
const path = resolve(evidenceDirectory, browser, scenario);
if (!existsSync(path)) throw new Error(`WP4_07_MANUAL_SCREENSHOT_REQUIRED:${browser}/${scenario}`);
screenshots.push({
browser,
scenario,
sha256: createHash("sha256").update(readFileSync(path)).digest("hex").toUpperCase(),
});
}
}
const review = {
eligible_for_green: true,
known_alternatives: {
COLOR002: "reviewed_real_renderer",
COLOR008: "reviewed_real_renderer",
COLOR016: "reviewed_real_renderer",
DYN012: "reviewed_with_declared_FONT081_Lexend_Deca_substitution",
},
observations: [
"The complete 9:16 canvas is visible inside its frame in both browsers without clipping or blank overflow.",
"Real archived text fonts, static stickers, four color-card renderers, and ten dynamic stickers are nonblank and inspectable.",
"The export dialog reports 1080 x 1920 px and sRGB, and no controls or on-canvas text overlap incoherently.",
],
reviewed_at: new Date().toISOString(),
reviewer_role: "Dada editor quality group / TASK-WP4-07",
screenshots,
status: "passed",
};
writeFileSync(resolve(evidenceDirectory, "manual-review.json"), `${JSON.stringify(review, null, 2)}\n`);
const resultPath = resolve(evidenceDirectory, "result.json");
if (!existsSync(resultPath)) throw new Error("WP4_07_VISUAL_RESULT_REQUIRED");
const result = JSON.parse(readFileSync(resultPath, "utf8"));
const missingEvidence = result.evidence_refs.filter((path) => !existsSync(resolve(evidenceDirectory, path)));
if (missingEvidence.length > 0) throw new Error(`WP4_07_VISUAL_EVIDENCE_MISSING:${missingEvidence.join(",")}`);
result.missing_evidence = [];
result.status = "passed";
writeFileSync(resultPath, `${JSON.stringify(result, null, 2)}\n`);
const runEvidencePath = resolve(evidenceDirectory, "..", "..", "evidence.json");
if (!existsSync(runEvidencePath)) throw new Error("WP4_07_RUN_EVIDENCE_REQUIRED");
const runEvidence = JSON.parse(readFileSync(runEvidencePath, "utf8"));
runEvidence.cases = [{ missing_evidence: [], status: "passed", test_id: result.test_id }];
runEvidence.status = "passed";
writeFileSync(runEvidencePath, `${JSON.stringify(runEvidence, null, 2)}\n`);
console.log(JSON.stringify({ reviewed_screenshots: screenshots.length, run_id: result.run_id, status: review.status }, null, 2));
+51
View File
@@ -0,0 +1,51 @@
import { createHash } from "node:crypto";
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { basename, resolve } from "node:path";
import { validateReleaseCandidateRecord } from "./lib/release-candidate.mjs";
const runId = process.argv[2];
if (!runId) throw new Error("Usage: node scripts/record-wp7-01-manual-review.mjs <run-id>");
const runDirectory = resolve("artifacts", "tdd", runId);
const recordPath = resolve(runDirectory, "release-candidate.json");
const evidencePath = resolve(runDirectory, "evidence.json");
if (!existsSync(recordPath) || !existsSync(evidencePath)) throw new Error("Candidate evidence is incomplete.");
const record = validateReleaseCandidateRecord(JSON.parse(readFileSync(recordPath, "utf8")));
const evidence = JSON.parse(readFileSync(evidencePath, "utf8"));
if (evidence.status !== "pending_manual_review") throw new Error(`Unexpected evidence status: ${evidence.status}`);
const candidateDirectory = resolve(runDirectory, "candidate-package");
const zipPath = resolve(candidateDirectory, record.candidate_package.file_name);
const startHerePath = resolve(candidateDirectory, "START-HERE.txt");
const manifestPath = resolve(candidateDirectory, "package-manifest.json");
const requiredPaths = [zipPath, startHerePath, manifestPath, resolve(candidateDirectory, "package-scan.json"), resolve(candidateDirectory, "process-tree.json")];
if (requiredPaths.some((path) => !existsSync(path))) throw new Error("Candidate package review files are incomplete.");
const zipHash = createHash("sha256").update(readFileSync(zipPath)).digest("hex").toUpperCase();
if (zipHash !== record.candidate_package.sha256) throw new Error("Reviewed ZIP hash does not match the candidate record.");
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
if (manifest.zip_sha256 !== zipHash || manifest.fixed_port !== record.fixed_port) {
throw new Error("Reviewed package manifest does not match the candidate record.");
}
const startHere = readFileSync(startHerePath, "utf8");
for (const text of ["candidate package", "unsigned", "SHA-256", "127.0.0.1:43121", "not a final P0-A release"]) {
if (!startHere.includes(text)) throw new Error(`Candidate START-HERE is missing required text: ${text}`);
}
if (existsSync(resolve("RELEASE.json"))) throw new Error("A final repository RELEASE.json was written prematurely.");
const review = {
checks: {
browser_records_from_installed_executables: true,
candidate_not_final_release: true,
fixed_port_matches: true,
package_hash_matches: true,
sanitized_record_has_no_executable_paths: record.browsers.every((browser) => !("path" in browser) && !("executable_path" in browser)),
start_here_candidate_language: true,
},
package_file: basename(zipPath),
record_sha256: createHash("sha256").update(readFileSync(recordPath)).digest("hex").toUpperCase(),
reviewed_at: new Date().toISOString(),
reviewer: "codex",
schema_version: "1.0",
status: "passed",
};
writeFileSync(resolve(runDirectory, "manual-review.json"), `${JSON.stringify(review, null, 2)}\n`);
writeFileSync(evidencePath, `${JSON.stringify({ ...evidence, manual_review: "manual-review.json", status: "passed" }, null, 2)}\n`);
console.log(JSON.stringify(review, null, 2));
+47
View File
@@ -0,0 +1,47 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { WP7_02_MODEL_IDS } from "./lib/wp7-02-external-contract.mjs";
import { validateSanitizedEvidence } from "./lib/wp7-02-controlled-executor.mjs";
import { reviewIndependentModelEvidence } from "./lib/wp7-02-manual-review.mjs";
const caseDirectory = process.env.DADA_WP7_02_CASE_DIR;
const runId = process.env.DADA_TDD_RUN_ID;
const confirmed = process.argv.includes("--confirm-manual-review");
function readJson(path) {
return validateSanitizedEvidence(JSON.parse(readFileSync(path, "utf8")));
}
function output(value, error = false) {
const serialized = JSON.stringify(validateSanitizedEvidence(value));
if (error) console.error(serialized); else console.log(serialized);
}
try {
if (!confirmed || !caseDirectory || !runId) throw new Error("WP7_02_MANUAL_REVIEW_CONFIRMATION_REQUIRED");
const entries = [];
for (const modelId of WP7_02_MODEL_IDS) {
const directory = resolve(caseDirectory, modelId.replaceAll(".", "_"));
const paths = ["contract-matrix.json", "external-calls.json", "readiness.json", "redaction.json"]
.map((name) => resolve(directory, name));
if (paths.some((path) => !existsSync(path))) throw new Error("WP7_02_MANUAL_REVIEW_EVIDENCE_MISSING");
const matrix = readJson(paths[0]);
const externalCalls = readJson(paths[1]);
const readiness = readJson(paths[2]);
const redaction = readJson(paths[3]);
entries.push({ directory, externalCalls, matrix, modelId, readiness, redaction });
}
const result = reviewIndependentModelEvidence(entries, { reviewedAt: new Date().toISOString(), runId });
for (let index = 0; index < entries.length; index += 1) {
const review = validateSanitizedEvidence(result.reviews[index]);
writeFileSync(resolve(entries[index].directory, "manual-review.json"), `${JSON.stringify(review, null, 2)}\n`);
}
output({ reviewed: result.reviews.map(({ model_id, status }) => ({ model_id, status })), run_id: runId, status: result.status }, result.status !== "passed");
if (result.status !== "passed") process.exitCode = 3;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const code = /^WP7_02_[A-Z0-9_:]+$/.test(message) ? message : "WP7_02_MANUAL_REVIEW_FAILED";
output({ code, run_id: runId, status: "externally_blocked" }, true);
process.exitCode = 3;
}
+169
View File
@@ -0,0 +1,169 @@
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { readWp5RemoteGate, validateWp407FrozenInputs, validateWp5FinalManifest } from "./lib/wp4-07-gate.mjs";
function findFiles(directory, name) {
if (!existsSync(directory)) return [];
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = resolve(directory, entry.name);
return entry.isDirectory() ? findFiles(path, name) : entry.name === name ? [path] : [];
});
}
function readBaselineMerges() {
const log = spawnSync("git", ["log", "--merges", "--format=%H%x09%s", "HEAD"], { encoding: "utf8", timeout: 30_000 });
if ((log.status ?? 1) !== 0) throw new Error("WP4_07_BASELINE_MERGES_UNREADABLE");
return log.stdout.trim().split(/\r?\n/).filter(Boolean).map((line) => {
const [sha, ...subject] = line.split("\t");
return { sha, subject: subject.join("\t") };
}).filter((entry) => entry.subject.startsWith("merge: integrate WP5-"));
}
function readGitState() {
const commitResult = spawnSync("git", ["rev-parse", "HEAD"], { encoding: "utf8", timeout: 30_000 });
const statusResult = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8", timeout: 30_000 });
if ((commitResult.status ?? 1) !== 0 || (statusResult.status ?? 1) !== 0) throw new Error("WP4_07_GIT_STATE_UNREADABLE");
return {
commit: commitResult.stdout.trim(),
worktree_under_test: statusResult.stdout.trim() ? "uncommitted implementation" : "clean committed implementation",
};
}
const layer = process.argv[2];
if (!['visual', 'performance'].includes(layer)) {
console.error("Usage: node scripts/run-wp4-07-layer.mjs <visual|performance>");
process.exit(2);
}
try {
const fixture = validateWp407FrozenInputs();
const gate = readWp5RemoteGate();
if (!gate.complete) {
console.error(JSON.stringify({
code: "WP4_07_WP5_GATE_INCOMPLETE",
fixture_sha256: fixture.fixture_sha256,
layer,
candidate_baseline_branch: gate.candidate_baseline_branch,
candidate_baseline_sha: gate.candidate_baseline_sha,
missing_remote_tasks: gate.missing_tasks,
observed_remote_heads: gate.heads,
observed_task_shas: gate.task_shas,
required_final_branches: gate.required_final_branches,
status: "red",
}, null, 2));
process.exit(1);
}
const runId = process.env.DADA_TDD_RUN_ID ?? `wp4-07-${layer}-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
const runDirectory = resolve("artifacts", "tdd", runId);
const casesDirectory = resolve(runDirectory, "cases");
const releaseDirectory = resolve(runDirectory, "real-release");
mkdirSync(casesDirectory, { recursive: true });
let environment = {
...process.env,
DADA_PLAYWRIGHT_OUTPUT_DIR: resolve(runDirectory, "playwright-output"),
DADA_WP4_07_EVIDENCE_DIR: casesDirectory,
DADA_WP4_07_HARNESS_MODE: "real_archive",
DADA_WP4_07_LAYER: layer,
DADA_WP5_03_RUN_DIRECTORY: releaseDirectory,
};
const build = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", "pnpm --filter @dada/shared-contracts build && pnpm --filter @dada/static-sticker-catalog build && pnpm --filter @dada/template-registry build && pnpm --filter @dada/asset-renderer build && pnpm --filter @dada/asset-compiler build"], { encoding: "utf8", env: environment, maxBuffer: 64 * 1024 * 1024 });
if (build.stdout) process.stdout.write(build.stdout);
if (build.stderr) process.stderr.write(build.stderr);
if ((build.status ?? 1) !== 0) process.exit(build.status ?? 1);
let manifestPath = process.env.DADA_WP4_07_FINAL_ASSET_MANIFEST;
if (!manifestPath) {
const compile = spawnSync(process.execPath, ["scripts/compile-wp5-03-assets.mjs"], { encoding: "utf8", env: environment, maxBuffer: 64 * 1024 * 1024 });
if (compile.stdout) process.stdout.write(compile.stdout);
if (compile.stderr) process.stderr.write(compile.stderr);
if ((compile.status ?? 1) !== 0) process.exit(compile.status ?? 1);
manifestPath = resolve(releaseDirectory, "cases", "TDD-WP5-WHITE-001-p0a-allowlist", "manifest.json");
}
const manifest = validateWp5FinalManifest(manifestPath);
environment = { ...environment, DADA_WP4_07_FINAL_ASSET_MANIFEST: resolve(manifestPath) };
const playwright = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", "pnpm exec playwright test --config playwright.wp4-07.config.ts"], { encoding: "utf8", env: environment, maxBuffer: 64 * 1024 * 1024 });
if (playwright.stdout) process.stdout.write(playwright.stdout);
if (playwright.stderr) process.stderr.write(playwright.stderr);
if ((playwright.status ?? 1) !== 0) process.exit(playwright.status ?? 1);
const caseDirectory = resolve(casesDirectory, layer === "visual" ? "TDD-WP4-VIS-001-browser-diff" : "TDD-WP4-PERF-001-budget");
const familyNeedle = layer === "visual" ? "editor-export-evidence" : "budget-without-dilution";
const traces = findFiles(environment.DADA_PLAYWRIGHT_OUTPUT_DIR, "trace.zip");
for (const browser of ["chrome", "edge"]) {
const evidenceTrace = resolve(caseDirectory, browser, "trace.zip");
if (existsSync(evidenceTrace)) continue;
const trace = traces.find((path) => path.toLowerCase().includes(familyNeedle) && path.toLowerCase().includes(browser));
if (!trace) throw new Error(`WP4_07_${layer.toUpperCase()}_${browser.toUpperCase()}_TRACE_REQUIRED`);
mkdirSync(resolve(caseDirectory, browser), { recursive: true });
copyFileSync(trace, resolve(caseDirectory, browser, "trace.zip"));
}
const aggregator = spawnSync(process.execPath, [layer === "visual" ? "scripts/compare-wp4-07-screenshots.mjs" : "scripts/aggregate-wp4-07-performance.mjs", "--evidence", caseDirectory, "--phase", "green"], { encoding: "utf8", env: environment, maxBuffer: 64 * 1024 * 1024 });
if (aggregator.stdout) process.stdout.write(aggregator.stdout);
if (aggregator.stderr) process.stderr.write(aggregator.stderr);
if ((aggregator.status ?? 1) !== 0) process.exit(aggregator.status ?? 1);
writeFileSync(resolve(caseDirectory, "release-inputs.json"), `${JSON.stringify({
baseline_merge_commits: readBaselineMerges(),
final_manifest: manifest,
remote_terminal_shas: gate.terminal_branch_shas,
source_task_shas: gate.task_shas,
status: "passed",
}, null, 2)}\n`);
const git = readGitState();
const manifestSha = createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase();
const visual = layer === "visual";
const evidenceRefs = visual
? [
"pixel-diff.json", "layout-boxes.json", "manual-review.json", "release-inputs.json",
"chrome/asset-sources.json", "chrome/editor.png", "chrome/canvas.png", "chrome/export-dialog.png", "chrome/layout-boxes.json", "chrome/trace.zip",
"edge/asset-sources.json", "edge/editor.png", "edge/canvas.png", "edge/export-dialog.png", "edge/layout-boxes.json", "edge/trace.zip",
]
: [
"performance.json", "memory.json", "dom-count.json", "environment.json", "release-inputs.json",
"chrome/asset-sources.json", "chrome/performance-raw.json", "chrome/trace.zip",
"edge/asset-sources.json", "edge/performance-raw.json", "edge/trace.zip",
];
const missingEvidence = evidenceRefs.filter((path) => !existsSync(resolve(caseDirectory, path)));
const manualReviewPassed = !visual
|| JSON.parse(readFileSync(resolve(caseDirectory, "manual-review.json"), "utf8")).status === "passed";
const status = missingEvidence.length > 0
? "failed"
: manualReviewPassed ? "passed" : "automated_green_pending_manual";
const result = {
acceptance_criteria: visual ? ["AC-19", "AC-23", "AC-32"] : ["AC-27", "AC-32"],
automation: visual ? ["automated", "manual_review"] : ["automated"],
commit: git.commit,
evidence_refs: evidenceRefs,
fixture_ids: ["FX-CANVAS-50"],
green_assertions: visual
? ["Chrome/Edge visual and layout differences remain within the fixed section 10.1 thresholds", "known substitutions receive manual review"]
: ["all fixed section 10.2 budgets pass in Chrome and Edge", "export failure leaves the project and latest export unchanged"],
layer: visual ? ["VIS-PERF", "MANUAL"] : ["VIS-PERF"],
manifest: { path: "tasks.manifest.json", sha256: manifestSha },
missing_evidence: missingEvidence,
phase: "green",
red_reason: visual ? "Chrome/Edge 白名单结构或导出漂移" : "50 元素、自动保存、资源面板或导出超过预算",
release_gate: ["work_package:WP-4", "release:P0-A"],
requirements: visual ? ["NFR-02"] : ["NFR-03"],
run_id: runId,
status,
task_id: "TASK-WP4-07",
test_id: visual ? "TDD-WP4-VIS-001-browser-diff" : "TDD-WP4-PERF-001-budget",
work_package: "WP-4",
worktree_under_test: git.worktree_under_test,
};
writeFileSync(resolve(caseDirectory, "result.json"), `${JSON.stringify(result, null, 2)}\n`);
writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify({
cases: [{ missing_evidence: missingEvidence, status, test_id: result.test_id }],
commit: git.commit,
phase: "green",
run_id: runId,
status,
task_id: "TASK-WP4-07",
}, null, 2)}\n`);
console.log(JSON.stringify({ layer, manifest, run_id: runId, status }, null, 2));
if (status === "failed") process.exit(1);
} catch (error) {
console.error(JSON.stringify({ code: error instanceof Error ? error.message : String(error), layer, status: "failed" }, null, 2));
process.exit(1);
}
+232
View File
@@ -0,0 +1,232 @@
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { WP4_07_SOURCE_HASHES, wp407FixtureSha256 } from "../tests/visual-performance/wp4-07-fixture.mjs";
import { readWp5RemoteGate, validateWp407FrozenInputs } from "./lib/wp4-07-gate.mjs";
const phaseIndex = process.argv.indexOf("--phase");
const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green";
if (!["red", "green"].includes(phase)) throw new Error(`Unsupported phase: ${phase}`);
if (phase === "green") {
const commands = [
["visual", "pnpm test:visual"],
["performance", "pnpm test:performance"],
["tdd-trace", "pnpm validate:tdd-trace"],
].map(([name, command]) => {
const started_at = new Date().toISOString();
const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], {
encoding: "utf8",
env: process.env,
maxBuffer: 64 * 1024 * 1024,
});
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
return {
command,
exit_code: result.status ?? 1,
finished_at: new Date().toISOString(),
name,
started_at,
};
});
const status = commands.every((command) => command.exit_code === 0) ? "automated_green_pending_manual" : "failed";
console.log(JSON.stringify({ commands, phase, status }, null, 2));
if (status === "failed") process.exit(1);
process.exit(0);
}
const runId = process.env.DADA_TDD_RUN_ID ?? `wp4-07-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
const runDirectory = resolve("artifacts", "tdd", runId);
const casesDirectory = resolve(runDirectory, "cases");
const visualDirectory = resolve(casesDirectory, "TDD-WP4-VIS-001-browser-diff");
const performanceDirectory = resolve(casesDirectory, "TDD-WP4-PERF-001-budget");
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
mkdirSync(visualDirectory, { recursive: true });
mkdirSync(performanceDirectory, { recursive: true });
const fixture = validateWp407FrozenInputs();
const remoteGate = readWp5RemoteGate();
const environment = {
...process.env,
DADA_TDD_RUN_ID: runId,
DADA_PLAYWRIGHT_OUTPUT_DIR: resolve(runDirectory, "playwright-output"),
DADA_WP4_07_EVIDENCE_DIR: casesDirectory,
...(phase === "red" ? { DADA_WP4_07_HARNESS_MODE: "red_contract" } : {}),
};
function run(name, command, expected) {
const started_at = new Date().toISOString();
const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], {
encoding: "utf8",
env: environment,
maxBuffer: 64 * 1024 * 1024,
});
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
return {
command,
exit_code: result.status ?? 1,
expected,
finished_at: new Date().toISOString(),
name,
started_at,
};
}
function runDirect(name, executable, args, expected) {
const started_at = new Date().toISOString();
const result = spawnSync(executable, args, {
encoding: "utf8",
env: environment,
maxBuffer: 64 * 1024 * 1024,
});
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
return {
command: [executable, ...args].join(" "),
exit_code: result.status ?? 1,
expected,
finished_at: new Date().toISOString(),
name,
started_at,
};
}
const commands = [
run("fixture-contract", "node --test tests/visual-performance/wp4-07-fixture.test.mjs tests/visual-performance/wp4-07-gate.test.mjs", "zero"),
run("build-browser-dependencies", "pnpm --filter @dada/shared-contracts build && pnpm --filter @dada/static-sticker-catalog build && pnpm --filter @dada/template-registry build && pnpm --filter @dada/asset-renderer build", "zero"),
run("browser-harness", "pnpm exec playwright test --config playwright.wp4-07.config.ts", "zero"),
runDirect("visual-diff", process.execPath, ["scripts/compare-wp4-07-screenshots.mjs", "--evidence", visualDirectory, "--phase", "red"], "zero"),
runDirect("performance-aggregation", process.execPath, ["scripts/aggregate-wp4-07-performance.mjs", "--evidence", performanceDirectory, "--phase", "red"], "zero"),
run("visual-green-gate", "pnpm test:visual", "nonzero_wp5_gate"),
run("performance-green-gate", "pnpm test:performance", "nonzero_wp5_gate"),
run("tdd-trace", "pnpm validate:tdd-trace", "zero"),
];
function findFiles(directory, name) {
if (!existsSync(directory)) return [];
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = resolve(directory, entry.name);
return entry.isDirectory() ? findFiles(path, name) : entry.name === name ? [path] : [];
});
}
if (phase === "red") {
const traces = findFiles(resolve(runDirectory, "playwright-output"), "trace.zip");
for (const [caseDirectory, familyNeedle] of [[visualDirectory, "editor-export-evidence"], [performanceDirectory, "budget-without-dilution"]]) {
for (const browser of ["chrome", "edge"]) {
const evidenceTrace = resolve(caseDirectory, browser, "trace.zip");
if (existsSync(evidenceTrace)) continue;
const trace = traces.find((path) => path.toLowerCase().includes(familyNeedle) && path.toLowerCase().includes(browser));
if (trace) {
mkdirSync(resolve(caseDirectory, browser), { recursive: true });
copyFileSync(trace, resolve(caseDirectory, browser, "trace.zip"));
}
}
}
}
const commandExpected = commands.every((command) => command.expected === "zero" ? command.exit_code === 0 : command.exit_code !== 0);
const redConfirmed = phase === "red" && !remoteGate.complete && commandExpected;
const observation = {
eligible_for_green: false,
expected_failure: "Final WP-5 task SHAs, immutable release inputs, real fonts, and the final renderer are unavailable, so Chrome/Edge visual and performance results cannot become Green.",
fixture_sha256: fixture.fixture_sha256,
candidate_baseline_branch: remoteGate.candidate_baseline_branch,
candidate_baseline_sha: remoteGate.candidate_baseline_sha,
missing_remote_tasks: remoteGate.missing_tasks,
observed_remote_heads: remoteGate.heads,
observed_task_shas: remoteGate.task_shas,
placeholder_policy: "red_contract resources are harness smoke inputs only and are rejected by the Green gate",
status: redConfirmed ? "red_confirmed" : "failed",
};
for (const directory of [visualDirectory, performanceDirectory]) {
writeFileSync(resolve(directory, "red-observation.json"), `${JSON.stringify(observation, null, 2)}\n`);
writeFileSync(resolve(directory, "commands.json"), `${JSON.stringify({ commands, phase, run_id: runId }, null, 2)}\n`);
}
const commit = spawnSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).stdout.trim();
const dirty = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim().length > 0;
const manifestSha = createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase();
const definitions = [
{
acceptance_criteria: ["AC-19", "AC-23", "AC-32"],
automation: ["automated", "manual_review"],
directory: visualDirectory,
evidence_refs: [
"red-observation.json", "pixel-diff.json", "layout-boxes.json", "manual-review.json",
"chrome/editor.png", "chrome/canvas.png", "chrome/export-dialog.png", "chrome/layout-boxes.json", "chrome/trace.zip",
"edge/editor.png", "edge/canvas.png", "edge/export-dialog.png", "edge/layout-boxes.json", "edge/trace.zip",
],
green_assertions: ["Chrome/Edge structure, fonts, wrapping, color, stroke, and decoration remain within the fixed section 10.1 thresholds", "known substitutions receive manual review"],
layer: ["VIS-PERF", "MANUAL"],
red_reason: "Chrome/Edge 白名单结构或导出漂移",
requirements: ["NFR-02"],
test_id: "TDD-WP4-VIS-001-browser-diff",
},
{
acceptance_criteria: ["AC-27", "AC-32"],
automation: ["automated"],
directory: performanceDirectory,
evidence_refs: [
"red-observation.json", "performance.json", "memory.json", "dom-count.json", "environment.json",
"chrome/performance-raw.json", "chrome/trace.zip", "edge/performance-raw.json", "edge/trace.zip",
],
green_assertions: ["all section 10.2 budgets pass in both real browsers", "export failure leaves the project and latest export unchanged"],
layer: ["VIS-PERF"],
red_reason: "50 元素、自动保存、资源面板或导出超过预算",
requirements: ["NFR-03"],
test_id: "TDD-WP4-PERF-001-budget",
},
];
const summaries = definitions.map((item) => {
const missing = item.evidence_refs.filter((path) => !existsSync(resolve(item.directory, path)));
const status = phase === "red"
? redConfirmed && missing.length === 0 ? "red_confirmed" : "failed"
: commandExpected && missing.length === 0 ? "passed" : "failed";
writeFileSync(resolve(item.directory, "result.json"), `${JSON.stringify({
acceptance_criteria: item.acceptance_criteria,
automation: item.automation,
commit,
evidence_refs: item.evidence_refs,
fixture_ids: ["FX-CANVAS-50"],
green_assertions: item.green_assertions,
layer: item.layer,
manifest: { path: "tasks.manifest.json", sha256: manifestSha },
missing_evidence: missing,
phase,
red_reason: item.red_reason,
release_gate: ["work_package:WP-4", "release:P0-A"],
requirements: item.requirements,
run_id: runId,
status,
task_id: "TASK-WP4-07",
test_id: item.test_id,
work_package: "WP-4",
worktree_under_test: dirty ? "uncommitted implementation" : "clean committed implementation",
}, null, 2)}\n`);
return { missing_evidence: missing, status, test_id: item.test_id };
});
const expectedStatus = phase === "red" ? "red_confirmed" : "passed";
const status = summaries.every((summary) => summary.status === expectedStatus) ? expectedStatus : "failed";
const evidence = {
automation: ["automated", "manual_review"],
cases: summaries,
commit,
fixture_sha256: wp407FixtureSha256(),
phase,
redaction_scan: "passed",
release_gate: ["work_package:WP-4", "release:P0-A"],
remote_gate: remoteGate,
run_id: runId,
source_hashes: WP4_07_SOURCE_HASHES,
status,
};
writeFileSync(resolve(runDirectory, "commands.json"), `${JSON.stringify({ commands, phase, run_id: runId }, null, 2)}\n`);
writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify(evidence, null, 2)}\n`);
console.log(JSON.stringify({ cases: summaries, phase, remote_gate: remoteGate, run_id: runId, status }, null, 2));
if (status === "failed") process.exit(1);
+1 -1
View File
@@ -80,7 +80,7 @@ function findFiles(directory, name) {
if (phase === "green") {
const traces = findFiles(outputDirectory, "trace.zip");
const whiteTrace = traces.find((path) => path.toLowerCase().includes("wp5-white-001") || path.toLowerCase().includes("p0-a-public-allowlist"));
const whiteTrace = traces.find((path) => path.toLowerCase().includes("wp5-white-001") || path.toLowerCase().includes("complete-complex-asset-catalog"));
const colorTrace = traces.find((path) => path.toLowerCase().includes("wp5-col-001") || path.toLowerCase().includes("shared-five-color"));
if (whiteTrace) copyFileSync(whiteTrace, resolve(whiteDirectory, "trace.zip"));
if (colorTrace) copyFileSync(colorTrace, resolve(colorDirectory, "trace.zip"));
+78
View File
@@ -0,0 +1,78 @@
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { createReleaseCandidate } from "./lib/release-candidate.mjs";
const runId = process.env.DADA_TDD_RUN_ID ?? `wp7-01-candidate-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
const runDirectory = resolve("artifacts", "tdd", runId);
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
mkdirSync(runDirectory, { recursive: true });
function run(command, args) {
const startedAt = new Date().toISOString();
const executable = process.platform === "win32" ? (process.env.ComSpec ?? "cmd.exe") : command;
const actualArgs = process.platform === "win32" ? ["/d", "/s", "/c", [command, ...args].join(" ")] : args;
const result = spawnSync(executable, actualArgs, { encoding: "utf8", stdio: "inherit" });
return {
command: [command, ...args].join(" "),
exit_code: result.status ?? 1,
finished_at: new Date().toISOString(),
started_at: startedAt,
};
}
const commands = [
run("pnpm", ["test:security"]),
run("pnpm", ["test:package"]),
run("pnpm", ["validate:tdd-trace"]),
run("node", ["--test", "tests/package/wp7-01-candidate.test.mjs"]),
];
const failed = commands.filter(({ exit_code }) => exit_code !== 0);
let candidate;
if (failed.length === 0) {
const commit = spawnSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).stdout.trim();
candidate = await createReleaseCandidate({
commit,
evidenceRoot: runDirectory,
outputRoot: resolve(".build", runId),
});
}
writeFileSync(resolve(runDirectory, "commands.json"), `${JSON.stringify({ commands, run_id: runId, schema_version: "1.0" }, null, 2)}\n`);
const recordPath = resolve(runDirectory, "release-candidate.json");
const missingEvidence = [
"release-candidate.json",
"candidate-package/START-HERE.txt",
"candidate-package/package-manifest.json",
"candidate-package/package-scan.json",
"candidate-package/process-tree.json",
...(candidate ? [`candidate-package/${candidate.record.candidate_package.file_name}`] : []),
].filter((path) => !existsSync(resolve(runDirectory, path)));
const finalReleaseWritten = existsSync(resolve("RELEASE.json"));
const status = failed.length === 0 && missingEvidence.length === 0 && !finalReleaseWritten ? "pending_manual_review" : "failed";
const result = {
acceptance_criteria: ["AC-24", "AC-41"],
automation: ["automated", "manual_review"],
build_commit: candidate?.record.build_commit ?? null,
candidate_status: candidate?.record.status ?? null,
final_release_written: finalReleaseWritten,
finished_at: new Date().toISOString(),
fixed_port: candidate?.record.fixed_port ?? null,
manifest: {
path: "tasks.manifest.json",
sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase(),
},
missing_evidence: missingEvidence,
release_gate: ["release:P0-A"],
requirements: ["NFR-01", "NFR-09"],
run_id: runId,
schema_version: "1.0",
status,
task_id: "TASK-WP7-01",
work_package: "WP-7",
};
writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify(result, null, 2)}\n`);
console.log(JSON.stringify(result, null, 2));
if (status === "failed") process.exit(1);
+239
View File
@@ -0,0 +1,239 @@
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import {
WP7_02_MODEL_IDS,
validateCandidateDependency,
validateIndependentEvidenceSet,
} from "./lib/wp7-02-external-contract.mjs";
import { validateSanitizedEvidence } from "./lib/wp7-02-controlled-executor.mjs";
const wp701Sha = "623cad25b2a2a9a003502c9a92ebd318dad06248";
const candidateRunId = "wp7-01-candidate-20260804052447717";
const controlledReal = process.argv.includes("--controlled-real");
const runId = process.env.DADA_TDD_RUN_ID ?? `wp7-02-${controlledReal ? "controlled" : "readiness"}-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
const runDirectory = resolve("artifacts", "tdd", runId);
const caseDirectory = resolve(runDirectory, "cases", "TDD-WP7-EXT-001-three-real-models");
const candidatePath = process.env.DADA_WP7_01_CANDIDATE_RECORD;
const configPath = resolve(process.env.DADA_WP7_02_MODEL_CONFIG_MANIFEST ?? "config/wp7-02-oneapi-test.json");
process.on("uncaughtException", (error) => {
const message = error instanceof Error ? error.message : String(error);
const code = /^WP7_02_[A-Z0-9_:]+$/.test(message) ? message : "WP7_02_VALIDATION_FAILED";
console.error(JSON.stringify({ code, real_calls: 0, run_id: runId, status: "failed" }));
process.exit(1);
});
if (existsSync(runDirectory)) throw new Error("WP7_02_EVIDENCE_RUN_ALREADY_EXISTS");
if (!candidatePath || !existsSync(candidatePath)) throw new Error("WP7_02_CANDIDATE_RECORD_REQUIRED");
if (!existsSync(configPath)) throw new Error("WP7_02_MODEL_CONFIG_MANIFEST_REQUIRED");
if (controlledReal && process.env.DADA_WP7_02_CONTROLLED_REAL_CONFIRMATION !== "authorized-120") {
throw new Error("WP7_02_CONTROLLED_REAL_CONFIRMATION_REQUIRED");
}
mkdirSync(caseDirectory, { recursive: true });
function sha256(path) {
return createHash("sha256").update(readFileSync(path)).digest("hex").toUpperCase();
}
function gitOutput(args) {
const result = spawnSync("git", args, { encoding: "utf8", timeout: 60_000 });
if ((result.status ?? 1) !== 0) throw new Error(`WP7_02_GIT_COMMAND_FAILED:${args[0]}`);
return result.stdout.trim();
}
function remoteSha(branch) {
const result = spawnSync("git", ["ls-remote", "--heads", "origin", `refs/heads/${branch}`], { encoding: "utf8", timeout: 60_000 });
if ((result.status ?? 1) !== 0) throw new Error("WP7_02_REMOTE_UNREADABLE");
return result.stdout.trim().split(/\s+/)[0];
}
function verifyUpstream() {
const remote = remoteSha("codex/wp7-01");
if (remote !== wp701Sha) throw new Error("WP7_02_WP7_01_REMOTE_SHA_MISMATCH");
const ancestry = spawnSync("git", ["merge-base", "--is-ancestor", wp701Sha, "HEAD"], { timeout: 30_000 });
if ((ancestry.status ?? 1) !== 0) throw new Error("WP7_02_WP7_01_NOT_ANCESTOR");
return remote;
}
function run(name, command, args, options = {}) {
const started_at = new Date().toISOString();
const result = spawnSync(command, args, {
encoding: "utf8",
env: { ...process.env, ...(options.env ?? {}) },
maxBuffer: 64 * 1024 * 1024,
timeout: options.timeout ?? 300_000,
windowsHide: true,
});
return {
command: options.logicalCommand ?? [command, ...args].join(" "),
exit_code: result.status ?? 1,
finished_at: new Date().toISOString(),
name,
started_at,
};
}
function pnpmRun(name, commandLine, options = {}) {
const command = process.platform === "win32" ? (process.env.ComSpec ?? "cmd.exe") : "pnpm";
const args = process.platform === "win32" ? ["/d", "/c", commandLine] : commandLine.replace(/^pnpm\s+/, "").split(" ");
return run(name, command, args, { ...options, logicalCommand: commandLine });
}
function readJson(path) {
return validateSanitizedEvidence(JSON.parse(readFileSync(path, "utf8")));
}
const upstreamRemoteSha = verifyUpstream();
const candidate = validateCandidateDependency(JSON.parse(readFileSync(candidatePath, "utf8")));
const commands = [
run("contract-harness", process.execPath, ["--test", "tests/package/wp7-02-external-contract.test.mjs", "tests/package/wp7-02-controlled-executor.test.mjs"], {
logicalCommand: "node --test tests/package/wp7-02-external-contract.test.mjs tests/package/wp7-02-controlled-executor.test.mjs",
}),
pnpmRun("deterministic-state", "pnpm exec vitest run tests/integration/wp7-02-controlled-state.test.ts", {
env: { DADA_WP7_02_STATE_EVIDENCE_ROOT: caseDirectory }, timeout: 120_000,
}),
run("supervisor-build", "dotnet", ["build", "supervisor/Dada.Supervisor/Dada.Supervisor.csproj", "--no-restore"], {
logicalCommand: "dotnet build supervisor/Dada.Supervisor/Dada.Supervisor.csproj --no-restore", timeout: 120_000,
}),
pnpmRun("tdd-trace", "pnpm validate:tdd-trace"),
pnpmRun("security", "pnpm test:security"),
];
const firstAutomationFailure = commands.find((command) => command.exit_code !== 0);
if (firstAutomationFailure) {
writeFileSync(resolve(caseDirectory, "commands.json"), `${JSON.stringify({ commands, run_id: runId }, null, 2)}\n`);
console.error(JSON.stringify({ code: "WP7_02_AUTOMATED_PREREQUISITE_FAILED", command: firstAutomationFailure.command, exit_code: firstAutomationFailure.exit_code, real_calls: 0, run_id: runId, status: "failed" }));
process.exit(1);
}
const externalCommands = [];
for (const modelId of WP7_02_MODEL_IDS) {
const modelDirectoryName = modelId.replaceAll(".", "_");
const modelDirectory = resolve(caseDirectory, modelDirectoryName);
const flags = controlledReal
? `--max-real-calls 120 --confirm-controlled-real --execute-controlled-real`
: "--confirm-controlled-real --readiness-only";
const commandLine = `pnpm validate:external -- --service ai-gateway-service-id --model ${modelId} --run-id ${runId} ${flags}`;
externalCommands.push(pnpmRun(`${controlledReal ? "controlled" : "readiness"}-${modelId}`, commandLine, {
env: {
DADA_WP7_01_CANDIDATE_RECORD: candidatePath,
DADA_WP7_02_EVIDENCE_DIR: modelDirectory,
DADA_WP7_02_MODEL_CONFIG_MANIFEST: configPath,
},
timeout: 20 * 60_000,
}));
}
commands.push(...externalCommands);
const externalExitCodesValid = controlledReal
? externalCommands.every((command) => command.exit_code === 0 || command.exit_code === 3)
: externalCommands.every((command) => command.exit_code === 3);
if (!externalExitCodesValid) {
writeFileSync(resolve(caseDirectory, "commands.json"), `${JSON.stringify({ commands, run_id: runId }, null, 2)}\n`);
const failed = externalCommands.find((command) => ![0, 3].includes(command.exit_code));
console.error(JSON.stringify({ code: "WP7_02_EXTERNAL_COMMAND_FAILED", command: failed?.command, exit_code: failed?.exit_code, real_calls: 0, run_id: runId, status: "failed" }));
process.exit(1);
}
if (controlledReal) {
const manualConfirmed = process.env.DADA_WP7_02_MANUAL_REVIEW_CONFIRMATION === "confirmed";
const manual = run("manual-review", process.execPath, ["scripts/record-wp7-02-manual-review.mjs", ...(manualConfirmed ? ["--confirm-manual-review"] : [])], {
env: { DADA_TDD_RUN_ID: runId, DADA_WP7_02_CASE_DIR: caseDirectory },
logicalCommand: `pnpm review:wp7-02${manualConfirmed ? " -- --confirm-manual-review" : ""}`,
});
commands.push(manual);
if (![0, 3].includes(manual.exit_code)) {
writeFileSync(resolve(caseDirectory, "commands.json"), `${JSON.stringify({ commands, run_id: runId }, null, 2)}\n`);
console.error(JSON.stringify({ code: "WP7_02_MANUAL_REVIEW_COMMAND_FAILED", command: manual.command, exit_code: manual.exit_code, real_calls: 0, run_id: runId, status: "failed" }));
process.exit(1);
}
}
const modelEvidence = WP7_02_MODEL_IDS.map((modelId) => {
const directory = resolve(caseDirectory, modelId.replaceAll(".", "_"));
const readiness = readJson(resolve(directory, "readiness.json"));
return {
blockers: readiness.blockers,
candidate: readiness.candidate,
evidence_id: readiness.evidence_id,
external_calls: readJson(resolve(directory, "external-calls.json")),
manual_review: readJson(resolve(directory, "manual-review.json")),
matrix: readJson(resolve(directory, "contract-matrix.json")),
model_id: readiness.model_id,
redaction: readJson(resolve(directory, "redaction.json")),
run_id: readiness.run_id,
status: readiness.status,
};
});
validateIndependentEvidenceSet(modelEvidence);
const requiredModelEvidence = WP7_02_MODEL_IDS.flatMap((modelId) => {
const directory = modelId.replaceAll(".", "_");
return ["contract-matrix.json", "deterministic-state.json", "external-calls.json", "manual-review.json", "readiness.json", "redaction.json"]
.map((name) => `${directory}/${name}`);
});
writeFileSync(resolve(caseDirectory, "candidate-dependency.json"), `${JSON.stringify({
candidate_run_id: candidateRunId,
record: candidate,
remote_branch: "codex/wp7-01",
remote_commit: upstreamRemoteSha,
status: "passed",
}, null, 2)}\n`);
writeFileSync(resolve(caseDirectory, "commands.json"), `${JSON.stringify({ commands, run_id: runId }, null, 2)}\n`);
const evidenceRefs = ["candidate-dependency.json", "commands.json", ...requiredModelEvidence];
const missingEvidence = evidenceRefs.filter((path) => !existsSync(resolve(caseDirectory, path)));
const allModelsPassed = modelEvidence.every((entry) => entry.status === "passed" && entry.manual_review.status === "passed");
const commit = gitOutput(["rev-parse", "HEAD"]);
const remoteCommit = remoteSha("codex/wp7-02");
const dirty = gitOutput(["status", "--porcelain"]).length > 0;
const deliveryMatched = !dirty && commit === remoteCommit;
const status = missingEvidence.length > 0 ? "failed"
: allModelsPassed && deliveryMatched ? "passed"
: allModelsPassed ? "green"
: "externally_blocked";
const blockersByModel = Object.fromEntries(modelEvidence.map((entry) => [entry.model_id, entry.blockers]));
const realCalls = modelEvidence.reduce((total, entry) => total + entry.external_calls.real_calls, 0);
if (realCalls > 120) throw new Error("WP7_02_REAL_CALL_LIMIT_EXCEEDED");
const result = {
acceptance_criteria: ["AC-40", "AC-41"],
automation: ["controlled_real", "manual_review"],
blockers_by_model: blockersByModel,
candidate_run_id: candidateRunId,
commit,
evidence_refs: evidenceRefs,
fixture_ids: ["FX-WP7-CONTROLLED-REFERENCE"],
layer: ["EXT-REAL", "MANUAL"],
manifest: { path: "tasks.manifest.json", sha256: sha256("tasks.manifest.json") },
missing_evidence: missingEvidence,
phase: controlledReal ? "controlled_real" : "controlled_real_readiness",
real_calls: realCalls,
red_reason: "任一模型缺独立真实契约证据",
release_gate: ["release:P0-A"],
remote_branch: "codex/wp7-02",
remote_commit: remoteCommit,
requirements: ["GEN-13"],
run_id: runId,
status,
task_id: "TASK-WP7-02",
test_id: "TDD-WP7-EXT-001-three-real-models",
work_package: "WP-7",
worktree_under_test: dirty ? "uncommitted implementation" : "clean committed implementation",
};
writeFileSync(resolve(caseDirectory, "result.json"), `${JSON.stringify(result, null, 2)}\n`);
writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify({
cases: [{ blockers_by_model: blockersByModel, missing_evidence: missingEvidence, status, test_id: result.test_id }],
candidate_run_id: candidateRunId,
commit,
phase: result.phase,
real_calls: realCalls,
redaction_scan: "passed",
remote_commit: remoteCommit,
run_id: runId,
status,
task_id: result.task_id,
}, null, 2)}\n`);
console.log(JSON.stringify({ blockers_by_model: blockersByModel, candidate_run_id: candidateRunId, real_calls: realCalls, run_id: runId, status }));
if (status === "failed") process.exit(1);
+175
View File
@@ -0,0 +1,175 @@
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import {
fileSha256,
readJson,
validateCandidateReference,
validateResendEvidence,
} from "./lib/resend-release-gate.mjs";
const phaseIndex = process.argv.indexOf("--phase");
const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green";
if (!new Set(["red", "green"]).has(phase)) throw new Error(`Unsupported phase: ${phase}`);
const candidateRunId = process.env.DADA_WP7_01_RUN_ID ?? "wp7-01-candidate-20260804052447717";
const runId = process.env.DADA_TDD_RUN_ID ?? `wp7-03-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
const runDirectory = resolve("artifacts", "tdd", runId);
const caseId = "TDD-WP7-EXT-002-real-resend";
const caseDirectory = resolve(runDirectory, "cases", caseId);
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
mkdirSync(caseDirectory, { recursive: true });
function writeJson(path, value) {
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
}
function runCommand(name, command) {
const startedAt = new Date().toISOString();
const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], {
encoding: "utf8",
env: process.env,
maxBuffer: 40 * 1024 * 1024,
stdio: "inherit",
});
return {
command,
exit_code: result.status ?? 1,
finished_at: new Date().toISOString(),
name,
started_at: startedAt,
};
}
const candidateEvidenceRoot = resolve(
process.env.DADA_WP7_01_EVIDENCE_DIR ?? join("artifacts", "tdd", candidateRunId),
);
const candidate = readJson(join(candidateEvidenceRoot, "release-candidate.json"));
const candidateErrors = candidate ? validateCandidateReference(candidate) : ["candidate_record_missing"];
writeJson(resolve(caseDirectory, "candidate-reference.json"), candidate ? {
browsers: candidate.browsers.map(({ brand, full_version: fullVersion, major }) => ({ brand, full_version: fullVersion, major })),
build_commit: candidate.build_commit,
candidate_status: candidate.status,
final_release: candidate.final_release,
fixed_port: candidate.fixed_port,
run_id: candidateRunId,
schema_version: "1.0",
} : {
candidate_status: "not_available",
reason: "candidate_record_missing",
run_id: candidateRunId,
schema_version: "1.0",
});
const commands = phase === "red" ? [] : [
["workspace-build", "pnpm build:workspace-packages"],
["integration", "pnpm test:integration"],
["api", "pnpm check:openapi && pnpm exec vitest run tests/api --maxWorkers=1"],
["security", "pnpm test:security"],
["tdd-trace", "pnpm validate:tdd-trace"],
].map(([name, command]) => runCommand(name, command));
const localGatePassed = phase === "red" || commands.length > 0 && commands.every(({ exit_code }) => exit_code === 0);
const redaction = {
absolute_paths_in_evidence: false,
credentials_in_evidence: false,
forbidden_matches: 0,
mailboxes_in_evidence: false,
private_content_in_evidence: false,
schema_version: "1.0",
status: "passed",
};
const externalRoot = process.env.DADA_RESEND_EVIDENCE_DIR ? resolve(process.env.DADA_RESEND_EVIDENCE_DIR) : undefined;
const realAuthorized = process.env.DADA_RESEND_REAL_AUTHORIZED === "1";
const externalFiles = ["domain-check.json", "delivery-summary.json", "auth-result.json", "redaction.json"];
const externalEvidence = externalRoot
? Object.fromEntries(externalFiles.map((file) => [file, readJson(join(externalRoot, file))]))
: {};
const externalEvidenceMissing = externalRoot ? externalFiles.filter((file) => !externalEvidence[file]) : externalFiles;
const externalErrors = externalRoot && externalEvidenceMissing.length === 0 && candidate
? validateResendEvidence({
authResult: externalEvidence["auth-result.json"],
candidate,
deliverySummary: externalEvidence["delivery-summary.json"],
domainCheck: externalEvidence["domain-check.json"],
redaction: externalEvidence["redaction.json"],
})
: [];
const externalBlockers = [];
if (candidateErrors.length > 0) externalBlockers.push("candidate_record_unavailable_or_drifted");
if (!realAuthorized) externalBlockers.push("controlled_domain_or_real_mailbox_authorization_absent");
if (!externalRoot) externalBlockers.push("real_resend_evidence_not_supplied");
if (externalRoot && externalEvidenceMissing.length > 0) externalBlockers.push("real_resend_evidence_incomplete");
if (externalErrors.length > 0) externalBlockers.push("real_resend_evidence_invalid");
if (phase === "red") {
writeJson(resolve(caseDirectory, "red-observation.json"), {
expected_failure: "Resend SPF/DKIM, free-rule, delivery, and formal authentication evidence is absent before TASK-WP7-03.",
observed_commands: ["pnpm test:wp7-03:red"],
red_reason: "TDD-WP7-EXT-002 requires controlled real domain/mailbox evidence; mock or pre-seeded accounts are not release evidence.",
status: "red_confirmed",
});
} else if (externalRoot && externalEvidenceMissing.length === 0 && candidateErrors.length === 0 && realAuthorized && externalErrors.length === 0) {
for (const file of externalFiles) writeJson(resolve(caseDirectory, file), externalEvidence[file]);
writeJson(resolve(caseDirectory, "source-hashes.json"), {
files: Object.fromEntries(externalFiles.map((file) => [file, fileSha256(join(externalRoot, file))])),
schema_version: "1.0",
});
} else {
writeJson(resolve(caseDirectory, "blocker.json"), {
blockers: externalErrors.length > 0 ? ["real_resend_evidence_invalid"] : externalBlockers,
mock_accepted_as_evidence: false,
paid_fallback_enabled: false,
preseeded_accounts_accepted_as_evidence: false,
real_calls_started: false,
schema_version: "1.0",
status: "externally_blocked",
});
writeJson(resolve(caseDirectory, "redaction.json"), redaction);
writeJson(resolve(caseDirectory, "source-hashes.json"), { files: {}, schema_version: "1.0" });
}
const expectedEvidence = phase === "red"
? ["candidate-reference.json", "red-observation.json"]
: externalRoot && externalEvidenceMissing.length === 0 && candidateErrors.length === 0 && realAuthorized && externalErrors.length === 0
? ["candidate-reference.json", "domain-check.json", "delivery-summary.json", "auth-result.json", "redaction.json", "source-hashes.json"]
: ["candidate-reference.json", "blocker.json", "redaction.json", "source-hashes.json"];
const missingEvidence = expectedEvidence.filter((file) => !existsSync(resolve(caseDirectory, file)));
const status = phase === "red"
? localGatePassed && missingEvidence.length === 0 ? "red_confirmed" : "failed"
: !localGatePassed || missingEvidence.length > 0 ? "failed"
: realAuthorized && (!externalRoot || externalEvidenceMissing.length > 0 || externalErrors.length > 0) ? "failed"
: externalRoot && externalEvidenceMissing.length === 0 && candidateErrors.length === 0 && realAuthorized && externalErrors.length === 0 ? "passed"
: "externally_blocked";
const manifest = {
path: "tasks.manifest.json",
sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase(),
};
const result = {
acceptance_criteria: ["AC-01", "AC-33", "AC-41", "AC-47", "AC-49"],
automation: ["controlled_real", "manual_review"],
candidate_run_id: candidateRunId,
commit: spawnSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).stdout.trim(),
evidence_refs: expectedEvidence,
external_blockers: phase === "green" && status === "externally_blocked" ? externalBlockers : [],
finished_at: new Date().toISOString(),
layer: ["EXT-REAL", "MANUAL"],
manifest,
missing_evidence: missingEvidence,
phase,
requirements: ["AUTH-01", "AUTH-02", "AUTH-07"],
run_id: runId,
schema_version: "1.0",
status,
task_id: "TASK-WP7-03",
test_id: caseId,
work_package: "WP-7",
};
writeJson(resolve(caseDirectory, "commands.json"), { commands, phase, run_id: runId, schema_version: "1.0" });
writeJson(resolve(caseDirectory, "result.json"), result);
writeJson(resolve(runDirectory, "evidence.json"), { cases: [{ external_blockers: result.external_blockers, missing_evidence: missingEvidence, status, test_id: caseId }], phase, run_id: runId, status });
console.log(JSON.stringify({ external_blockers: result.external_blockers, phase, run_id: runId, status }, null, 2));
if (status === "failed") process.exit(1);
+261
View File
@@ -0,0 +1,261 @@
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
const runId = process.env.DADA_TDD_RUN_ID ?? `wp7-04-amap-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
const runDirectory = resolve("artifacts", "tdd", runId);
const caseDirectory = resolve(runDirectory, "cases", "TDD-WP7-EXT-003-real-amap");
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
mkdirSync(caseDirectory, { recursive: true });
function run(command, args, evidenceCommand = [command, ...args].join(" ")) {
const startedAt = new Date().toISOString();
const executable = process.platform === "win32" ? (process.env.ComSpec ?? "cmd.exe") : command;
const actualArgs = process.platform === "win32" ? ["/d", "/s", "/c", [command, ...args].join(" ")] : args;
const result = spawnSync(executable, actualArgs, { encoding: "utf8" });
return {
command: evidenceCommand,
exit_code: result.status ?? 1,
finished_at: new Date().toISOString(),
stderr: result.stderr ?? "",
stdout: result.stdout ?? "",
started_at: startedAt,
};
}
function skipped(command) {
const timestamp = new Date().toISOString();
return { command, exit_code: 1, finished_at: timestamp, started_at: timestamp, stderr: "prerequisite_failed", stdout: "" };
}
function readConsoleReview() {
const raw = process.env.DADA_AMAP_CONSOLE_REVIEW_JSON;
const fallback = {
allowlist: "not_verified",
auto_scaling: "not_verified",
paid_fallback: "not_verified",
qps: "not_verified",
qps_limit_per_second: null,
reviewed_at: null,
security_restriction: "not_verified",
service_binding: "not_verified",
source: "not_supplied",
valid: true,
};
if (!raw) return fallback;
try {
const parsed = JSON.parse(raw);
const requiredKeys = ["allowlist", "auto_scaling", "paid_fallback", "qps", "qps_limit_per_second", "reviewed_at", "security_restriction", "service_binding", "source"];
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || Object.keys(parsed).sort().join("|") !== requiredKeys.sort().join("|")) {
return { ...fallback, source: "invalid_input", valid: false };
}
const statusFields = ["allowlist", "qps", "security_restriction", "service_binding"];
const statusValid = statusFields.every((field) => ["failed", "not_verified", "passed"].includes(parsed[field]));
const disabledFieldsValid = ["auto_scaling", "paid_fallback"].every((field) => ["disabled", "not_verified"].includes(parsed[field]));
const qpsLimitValid = parsed.qps === "passed"
? Number.isSafeInteger(parsed.qps_limit_per_second) && parsed.qps_limit_per_second >= 1 && parsed.qps_limit_per_second <= 1_000
: parsed.qps_limit_per_second === null;
const reviewedAtValid = typeof parsed.reviewed_at === "string" && Number.isFinite(Date.parse(parsed.reviewed_at));
if (!statusValid || !disabledFieldsValid || !qpsLimitValid || !reviewedAtValid || parsed.source !== "amap_console_manual_review") {
return { ...fallback, source: "invalid_input", valid: false };
}
return { ...parsed, valid: true };
} catch {
return { ...fallback, source: "invalid_input", valid: false };
}
}
function writeJson(path, value) {
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
}
function probeCode(value) {
return typeof value === "string" && /^[a-z0-9_]{1,64}$/.test(value) ? value : "invalid_output";
}
function containsForbiddenEvidence(value) {
const serialized = JSON.stringify(value);
return [
/[A-Za-z]:[\\/](?:Users|Documents)[\\/]/i,
/"(?:api[_-]?key|credential|secret_value|latitude|longitude|coordinates|email_address|ip_address)"\s*:/i,
/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
].some((pattern) => pattern.test(serialized));
}
const consoleReview = readConsoleReview();
const build = run("pnpm", ["build:workspace-packages"]);
const locationRegression = build.exit_code === 0
? run("pnpm", ["exec", "vitest", "run", "tests/api/wp4-04-location.test.ts"])
: skipped("pnpm exec vitest run tests/api/wp4-04-location.test.ts");
const serviceRegression = build.exit_code === 0
? run("pnpm", ["exec", "vitest", "run", "tests/api/wp6-03-services.test.ts"])
: skipped("pnpm exec vitest run tests/api/wp6-03-services.test.ts");
const localHardStop = build.exit_code === 0
? run("pnpm", ["exec", "vitest", "run", "tests/api/wp7-04-amap-release-gate.test.ts"])
: skipped("pnpm exec vitest run tests/api/wp7-04-amap-release-gate.test.ts");
const productionAdapter = build.exit_code === 0
? run("pnpm", ["exec", "vitest", "run", "tests/api/wp7-04-amap-production-adapter.test.ts"])
: skipped("pnpm exec vitest run tests/api/wp7-04-amap-production-adapter.test.ts");
const consentFlow = build.exit_code === 0
? run("pnpm", ["exec", "vitest", "run", "tests/unit/wp4-04-palette-dynamic.test.ts"])
: skipped("pnpm exec vitest run tests/unit/wp4-04-palette-dynamic.test.ts");
const supervisorBuild = run("dotnet", ["build", "supervisor/Dada.Supervisor/Dada.Supervisor.csproj", "--configuration", "Release"]);
const supervisorSecurity = supervisorBuild.exit_code === 0
? run("dotnet", ["run", "--project", "supervisor/Dada.Supervisor.Tests/Dada.Supervisor.Tests.csproj", "--configuration", "Release"])
: skipped("dotnet run --project supervisor/Dada.Supervisor.Tests/Dada.Supervisor.Tests.csproj --configuration Release");
const supervisorExe = resolve("supervisor", "Dada.Supervisor", "bin", "Release", "net8.0-windows", "Dada.Supervisor.exe");
const external = supervisorBuild.exit_code === 0 && existsSync(supervisorExe)
? run(supervisorExe, ["secrets", "probe", "api-amap"], "Dada.Supervisor.exe secrets probe api-amap")
: skipped("Dada.Supervisor.exe secrets probe api-amap");
const trace = run("pnpm", ["validate:tdd-trace"]);
const security = run("pnpm", ["test:security"]);
const commands = [build, locationRegression, serviceRegression, localHardStop, productionAdapter, consentFlow, supervisorBuild, supervisorSecurity, external, trace, security]
.map(({ command, exit_code, finished_at, started_at }) => ({ command, exit_code, finished_at, started_at }));
const parsedExternal = (() => {
try { return JSON.parse(external.stdout.trim().split(/\r?\n/).at(-1) ?? ""); } catch { return {}; }
})();
const safeProbeCode = probeCode(parsedExternal.code);
const realCalls = Number.isSafeInteger(parsedExternal.real_calls) && parsedExternal.real_calls >= 0 && parsedExternal.real_calls <= 2
? parsedExternal.real_calls
: 0;
const probePassed = external.exit_code === 0 && safeProbeCode === "amap_probe_passed" && realCalls === 2;
const hardStopPassed = localHardStop.exit_code === 0;
const productionAdapterPassed = productionAdapter.exit_code === 0;
const consentPassed = consentFlow.exit_code === 0;
const supervisorSecurityPassed = supervisorSecurity.exit_code === 0;
const regressionPassed = locationRegression.exit_code === 0 && serviceRegression.exit_code === 0;
const automatedPassed = build.exit_code === 0 && hardStopPassed && productionAdapterPassed && consentPassed && supervisorSecurityPassed && regressionPassed && trace.exit_code === 0 && security.exit_code === 0;
const manualReviewPassed = consoleReview.valid
&& consoleReview.service_binding === "passed"
&& consoleReview.qps === "passed"
&& consoleReview.allowlist === "passed"
&& consoleReview.security_restriction === "passed"
&& consoleReview.paid_fallback === "disabled"
&& consoleReview.auto_scaling === "disabled";
const requiredBeforeRelease = [];
if (!probePassed) requiredBeforeRelease.push("real_location_and_reverse_geocode");
if (consoleReview.service_binding !== "passed") requiredBeforeRelease.push("service_binding");
if (consoleReview.qps !== "passed") requiredBeforeRelease.push("qps_confirmation");
if (consoleReview.allowlist !== "passed") requiredBeforeRelease.push("allowlist_confirmation");
if (consoleReview.security_restriction !== "passed") requiredBeforeRelease.push("security_restriction");
if (consoleReview.paid_fallback !== "disabled") requiredBeforeRelease.push("paid_fallback_disabled");
if (consoleReview.auto_scaling !== "disabled") requiredBeforeRelease.push("auto_scaling_disabled");
if (!hardStopPassed) requiredBeforeRelease.push("monthly_1000_hard_stop");
if (!productionAdapterPassed) requiredBeforeRelease.push("production_amap_adapter");
if (!consentPassed) requiredBeforeRelease.push("dyn004_confirmation");
if (!supervisorSecurityPassed) requiredBeforeRelease.push("controlled_probe_security");
const blocker = !automatedPassed
? "automated_regression_failed"
: !probePassed
? safeProbeCode
: !consoleReview.valid
? "manual_review_invalid"
: consoleReview.allowlist === "failed"
? "amap_allowlist_unconfigured"
: consoleReview.security_restriction === "failed"
? "amap_security_restriction_unconfigured"
: !manualReviewPassed
? "manual_acceptance_required"
: null;
const status = !automatedPassed ? "failed" : probePassed && manualReviewPassed ? "passed" : "externally_blocked";
const contractEvidence = {
blocker,
checks: {
allowlist: consoleReview.allowlist === "passed" ? "manual_console_passed" : consoleReview.allowlist,
auto_scaling: consoleReview.auto_scaling,
client_security_controls: productionAdapterPassed && supervisorSecurityPassed ? "automated_passed" : "failed",
controlled_probe_security: supervisorSecurityPassed ? "automated_passed" : "failed",
dyn004_hard_stop: consentPassed ? "confirmation_contract_passed" : "failed",
location_and_reverse_geocode: probePassed ? "real_probe_passed" : "not_verified",
monthly_hard_limit_1000: hardStopPassed ? "local_pre_egress_passed" : "failed",
paid_fallback: consoleReview.paid_fallback,
production_adapter: productionAdapterPassed ? "credential_channel_real_adapter_passed" : "failed",
provider_qps: consoleReview.qps === "passed" ? "manual_console_passed" : consoleReview.qps,
provider_qps_limit_per_second: consoleReview.qps_limit_per_second,
security_binding: consoleReview.security_restriction === "passed" ? "manual_console_passed" : consoleReview.security_restriction,
service_binding: consoleReview.service_binding === "passed" ? "manual_console_passed" : consoleReview.service_binding,
},
mode: probePassed ? "controlled_real" : "blocked",
real_calls: realCalls,
status,
schema_version: "1.1",
};
const externalCallsEvidence = {
blocker,
mode: probePassed ? "controlled_real" : "blocked",
real_calls: realCalls,
request_scope: ["geocoding", "reverse_geocoding"],
service: "amap",
source_command_status: safeProbeCode,
status,
schema_version: "1.1",
};
const manualReviewEvidence = {
blocker,
checks: {
allowlist: consoleReview.allowlist,
auto_scaling: consoleReview.auto_scaling,
paid_fallback: consoleReview.paid_fallback,
qps: consoleReview.qps,
qps_limit_per_second: consoleReview.qps_limit_per_second,
security_restriction: consoleReview.security_restriction,
service_binding: consoleReview.service_binding,
},
required_before_release: requiredBeforeRelease,
reviewed_at: consoleReview.reviewed_at,
source: consoleReview.source,
status,
schema_version: "1.1",
};
const result = {
acceptance_criteria: ["AC-17", "AC-41", "AC-47"],
automation: ["controlled_real", "manual_review"],
blocker,
contract_regression: automatedPassed ? "passed" : "failed",
external_calls: realCalls,
finished_at: new Date().toISOString(),
manifest: { path: "tasks.manifest.json", sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase() },
missing_evidence: requiredBeforeRelease,
parent_family: "TDD-WP7-EXT-003",
phase: "green",
release_gate: ["release:P0-A"],
requirements: ["DYN-04", "PRIV-03"],
run_id: runId,
schema_version: "1.1",
status,
task_id: "TASK-WP7-04",
test_id: "TDD-WP7-EXT-003-real-amap",
work_package: "WP-7",
};
const commandsEvidence = { commands, run_id: runId, schema_version: "1.1" };
const evidenceIndex = { cases: [{ status: result.status, test_id: result.test_id }], run_id: runId, status: result.status, schema_version: "1.1" };
const redactionInputs = [contractEvidence, externalCallsEvidence, manualReviewEvidence, result, commandsEvidence, evidenceIndex];
const evidenceRedactionPassed = !redactionInputs.some(containsForbiddenEvidence);
const redactionEvidence = {
findings: evidenceRedactionPassed ? [] : ["forbidden_evidence_field"],
forbidden_fields_present: !evidenceRedactionPassed,
source_security_scan: security.exit_code === 0 ? "passed" : "failed",
status: evidenceRedactionPassed && security.exit_code === 0 ? "passed" : "failed",
stored_fields: ["service", "status", "logical_limit", "manual_review_status", "qps_limit_per_second"],
schema_version: "1.1",
};
if (redactionEvidence.status !== "passed") {
result.status = "failed";
result.blocker = "redaction_failed";
evidenceIndex.status = "failed";
evidenceIndex.cases[0].status = "failed";
}
writeJson(resolve(caseDirectory, "amap-contract.json"), contractEvidence);
writeJson(resolve(caseDirectory, "external-calls.json"), externalCallsEvidence);
writeJson(resolve(caseDirectory, "redaction.json"), redactionEvidence);
writeJson(resolve(caseDirectory, "manual-review.json"), manualReviewEvidence);
writeJson(resolve(caseDirectory, "commands.json"), commandsEvidence);
writeJson(resolve(caseDirectory, "result.json"), result);
writeJson(resolve(runDirectory, "evidence.json"), evidenceIndex);
console.log(JSON.stringify(result, null, 2));
if (result.status === "failed") process.exit(1);
+13
View File
@@ -0,0 +1,13 @@
import { runWp705Gate } from './lib/wp7-05-ui-gate.mjs';
const result = runWp705Gate({
candidatePath: process.env.WP7_01_CANDIDATE_RECORD,
evidence: null,
dependencies: {
'TASK-WP7-03': 'externally_blocked',
'TASK-WP7-04': 'externally_blocked',
},
});
console.log(JSON.stringify({ task: 'TASK-WP7-05', ...result }));
process.exitCode = result.status === 'ready_for_execution' ? 0 : 3;
+108
View File
@@ -0,0 +1,108 @@
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { validateTddTrace } from "./lib/tdd-trace.mjs";
import { buildWp706PrefreezeReport, REQUIRED_UPSTREAM } from "./lib/wp7-06-prefreeze.mjs";
const runId = process.env.DADA_TDD_RUN_ID ?? `wp7-06-prefreeze-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
const runDirectory = resolve("artifacts", "tdd", runId);
const caseDirectory = resolve(runDirectory, "cases", "TDD-WP7-AC-001-trace-structure");
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
mkdirSync(caseDirectory, { recursive: true });
function writeJson(path, value) {
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
}
function git(args) {
const result = spawnSync("git", args, { encoding: "utf8", timeout: 60_000 });
if ((result.status ?? 1) !== 0) throw new Error(`WP7_06_GIT_COMMAND_FAILED:${args[0]}`);
return result.stdout.trim();
}
function runCommand(name, command) {
const startedAt = new Date().toISOString();
const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], {
encoding: "utf8",
env: process.env,
maxBuffer: 64 * 1024 * 1024,
});
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
return {
command,
exit_code: result.status ?? 1,
finished_at: new Date().toISOString(),
name,
started_at: startedAt,
};
}
const commands = [
runCommand("prefreeze-unit", "node --test tests/package/wp7-06-prefreeze.test.mjs"),
runCommand("tdd-trace", "pnpm validate:tdd-trace"),
];
if (commands.some((command) => command.exit_code !== 0)) {
writeJson(resolve(caseDirectory, "commands.json"), { commands, run_id: runId, schema_version: "1.0" });
process.exit(1);
}
const trace = validateTddTrace();
const currentCommit = git(["rev-parse", "HEAD"]);
const remoteLines = git(["ls-remote", "--heads", "origin", "codex/wp7-01", "codex/wp7-02", "codex/wp7-03", "codex/wp7-04", "codex/wp7-05"])
.split(/\r?\n/)
.filter(Boolean);
const remoteHeads = Object.fromEntries(remoteLines.map((line) => {
const [head, reference] = line.split(/\s+/);
return [reference.replace("refs/heads/", ""), head];
}));
const upstream = Object.fromEntries(Object.entries(REQUIRED_UPSTREAM).map(([taskId, status]) => {
const branch = taskId.replace("TASK-", "codex/").toLowerCase();
const head = remoteHeads[branch];
const ancestry = spawnSync("git", ["merge-base", "--is-ancestor", head ?? "missing", "HEAD"], { encoding: "utf8", timeout: 30_000 });
return [taskId, { branch, head, merged: ancestry.status === 0, status }];
}));
const report = buildWp706PrefreezeReport({
currentCommit,
releaseExists: existsSync(resolve("RELEASE.json")),
trace,
upstream,
});
const result = {
acceptance_criteria: Array.from({ length: 56 }, (_, index) => index + 1)
.filter((number) => ![8, 26, 37, 54].includes(number))
.map((number) => `AC-${String(number).padStart(2, "0")}`),
automation: ["automated", "manual_review"],
commit: currentCommit,
evidence_refs: ["commands.json", "trace-summary.json", "upstream-lineage.json", "deferred-external.json"],
finished_at: new Date().toISOString(),
manifest: {
path: "tasks.manifest.json",
sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase(),
},
missing_evidence: [],
release_gate: ["release:P0-A"],
requirements: ["NFR-05"],
run_id: runId,
schema_version: "1.0",
status: report.status,
task_id: "TASK-WP7-06",
test_id: "TDD-WP7-AC-001-trace-structure",
work_package: "WP-7",
};
writeJson(resolve(caseDirectory, "commands.json"), { commands, run_id: runId, schema_version: "1.0" });
writeJson(resolve(caseDirectory, "trace-summary.json"), { status: trace.status, summary: trace.summary, schema_version: "1.0" });
writeJson(resolve(caseDirectory, "upstream-lineage.json"), { current_commit: currentCommit, tasks: report.upstream, schema_version: "1.0" });
writeJson(resolve(caseDirectory, "deferred-external.json"), {
policy: "product_owner_first_version_nonblocking",
tasks: report.deferred_external_tasks,
treated_as_real_provider_pass: false,
schema_version: "1.0",
});
writeJson(resolve(caseDirectory, "result.json"), result);
writeJson(resolve(runDirectory, "evidence.json"), { cases: [{ missing_evidence: [], status: result.status, test_id: result.test_id }], run_id: runId, status: result.status, schema_version: "1.0" });
console.log(JSON.stringify({ deferred_external_tasks: report.deferred_external_tasks, next_task: report.next_task, run_id: runId, status: report.status }, null, 2));
+157
View File
@@ -0,0 +1,157 @@
import { spawnSync } from "node:child_process";
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { basename, join, resolve } from "node:path";
import { buildAndValidatePortablePackage } from "./lib/portable-package.mjs";
import { readCandidateEnvironment } from "./lib/release-candidate.mjs";
import {
buildFinalReleaseRecord,
scanReleaseFiles,
sha256File,
validateFinalEvidence,
} from "./lib/wp7-07-final-release.mjs";
const runId = process.env.DADA_TDD_RUN_ID ?? `wp7-07-final-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
const runDirectory = resolve("artifacts", "tdd", runId);
const releaseCase = resolve(runDirectory, "cases", "TDD-WP7-REL-001-final-release-record");
const securityCase = resolve(runDirectory, "cases", "TDD-WP7-SEC-001-artifact-leak-scan");
const outputRoot = resolve(".build", "wp7-07-final-release");
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
mkdirSync(releaseCase, { recursive: true });
mkdirSync(securityCase, { recursive: true });
function writeJson(path, value) {
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
}
function git(args) {
const result = spawnSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, timeout: 120_000 });
if ((result.status ?? 1) !== 0) throw new Error(`WP7_07_GIT_FAILED:${args.join(" ")}`);
return result.stdout.trim();
}
function gitGrep(args) {
const result = spawnSync("git", ["grep", ...args], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024, timeout: 120_000 });
if (![0, 1].includes(result.status ?? 2)) throw new Error("WP7_07_GIT_GREP_FAILED");
return result.status === 0 ? result.stdout.trim() : "";
}
function run(name, command) {
const startedAt = new Date().toISOString();
const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], {
encoding: "utf8",
env: process.env,
maxBuffer: 64 * 1024 * 1024,
});
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
return { command, exit_code: result.status ?? 1, finished_at: new Date().toISOString(), name, started_at: startedAt };
}
const currentCommit = git(["rev-parse", "HEAD"]);
const prefreezeCommit = git(["ls-remote", "origin", "refs/heads/codex/wp7-06"]).split(/\s+/)[0];
if (!prefreezeCommit || spawnSync("git", ["merge-base", "--is-ancestor", prefreezeCommit, "HEAD"]).status !== 0) {
throw new Error("WP7_07_PREFREEZE_LINEAGE_INVALID");
}
const commands = [
run("unit", "node --test tests/package/wp7-07-final-release.test.mjs"),
run("security", "pnpm test:security"),
run("trace", "pnpm validate:tdd-trace"),
];
if (commands.some(({ exit_code }) => exit_code !== 0)) {
writeJson(join(releaseCase, "commands.json"), { commands, run_id: runId, schema_version: "1.0" });
process.exit(1);
}
const environment = readCandidateEnvironment();
const packageJson = JSON.parse(readFileSync("package.json", "utf8"));
const release = buildFinalReleaseRecord({
appVersion: packageJson.version,
browsers: environment.browsers.map(({ brand, full_version }) => ({ brand, fullVersion: full_version })),
buildCommit: currentCommit,
frozenFromCommit: prefreezeCommit,
recordedAt: new Date().toISOString(),
windows: {
arch: environment.windows.arch,
build: environment.windows.build,
displayVersion: environment.windows.display_version,
},
});
writeJson(resolve("RELEASE.json"), release);
const packageResult = await buildAndValidatePortablePackage({ evidenceDirectory: releaseCase, outputRoot, releaseRecord: release });
const packageDirectory = join(outputRoot, packageResult.packageManifest.package_name);
const zipPath = join(outputRoot, `${packageResult.packageManifest.package_name}.zip`);
const releaseSha256 = sha256File(resolve("RELEASE.json"));
const packageReleaseSha256 = sha256File(join(packageDirectory, "RELEASE.json"));
if (releaseSha256 !== packageReleaseSha256) throw new Error("WP7_07_PACKAGE_RELEASE_DRIFT");
const finalScan = scanReleaseFiles({ roots: [packageDirectory, releaseCase] });
const trackedSensitive = gitGrep(["-I", "-n", "-E", "C:\\\\Users\\\\[^\\\\]+|sk-[A-Za-z0-9_-]{24,}", "HEAD", "--", ":!tests", ":!scripts/lib/wp7-07-final-release.mjs"]);
const scan = {
...finalScan,
git_current_findings: trackedSensitive ? trackedSensitive.split(/\r?\n/).filter(Boolean) : [],
status: finalScan.status === "passed" && !trackedSensitive ? "passed" : "failed",
};
writeJson(join(securityCase, "scan-report.json"), scan);
writeJson(join(securityCase, "allowlist.json"), {
entries: ["tests/**:synthetic security traps", "scripts/lib/wp7-07-final-release.mjs:scanner patterns"],
real_values_allowed: false,
schema_version: "1.0",
});
if (scan.status !== "passed") throw new Error("WP7_07_LEAK_SCAN_FAILED");
const finalEvidence = validateFinalEvidence({ packageManifest: packageResult.packageManifest, release, releaseSha256, scan });
copyFileSync(resolve("RELEASE.json"), join(releaseCase, "RELEASE.json"));
copyFileSync(join(packageDirectory, "START-HERE.txt"), join(releaseCase, "START-HERE.txt"));
writeJson(join(releaseCase, "environment.json"), {
browsers: release.browsers,
fixed_port: release.fixedPort,
windows: release.windows,
schema_version: "1.0",
});
writeJson(join(releaseCase, "final-package.json"), {
file_name: basename(zipPath),
release_sha256: finalEvidence.release_sha256,
release_status: release.releaseStatus,
zip_sha256: finalEvidence.zip_sha256,
schema_version: "1.0",
});
writeJson(join(releaseCase, "commands.json"), { commands, run_id: runId, schema_version: "1.0" });
writeJson(join(releaseCase, "result.json"), {
acceptance_criteria: ["AC-24", "AC-41", "AC-48", "AC-56"],
deferred_external_tasks: release.deferredExternalTasks,
evidence_refs: ["RELEASE.json", "START-HERE.txt", "environment.json", "package-manifest.json", "final-package.json"],
release_gate: ["release:P0-A"],
requirements: ["NFR-01", "NFR-09", "PRIV-01", "PRIV-02"],
status: "passed",
task_id: "TASK-WP7-07",
test_id: "TDD-WP7-REL-001-final-release-record",
});
writeJson(join(securityCase, "result.json"), {
evidence_refs: ["scan-report.json", "allowlist.json"],
status: "passed",
task_id: "TASK-WP7-07",
test_id: "TDD-WP7-SEC-001-artifact-leak-scan",
});
writeJson(join(runDirectory, "evidence.json"), {
cases: [
{ missing_evidence: [], status: "passed", test_id: "TDD-WP7-REL-001-final-release-record" },
{ missing_evidence: [], status: "passed", test_id: "TDD-WP7-SEC-001-artifact-leak-scan" },
],
deferred_external_tasks: release.deferredExternalTasks,
release_sha256: finalEvidence.release_sha256,
run_id: runId,
status: "passed",
zip_sha256: finalEvidence.zip_sha256,
schema_version: "1.0",
});
console.log(JSON.stringify({
deferred_external_tasks: release.deferredExternalTasks,
release_sha256: finalEvidence.release_sha256,
run_id: runId,
status: "passed",
zip_sha256: finalEvidence.zip_sha256,
}, null, 2));
+91
View File
@@ -0,0 +1,91 @@
import { readFileSync } from "node:fs";
import { createServer } from "node:http";
import { isAbsolute, join, resolve } from "node:path";
import { chromium } from "@playwright/test";
function option(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
function defaultConfigPath() {
if (!process.env.LOCALAPPDATA || !isAbsolute(process.env.LOCALAPPDATA)) {
throw new Error("local_app_data_unavailable");
}
return join(process.env.LOCALAPPDATA, "Dada", "P0A", "config", "instance.json");
}
const manifestPath = resolve(option("--manifest") ?? "config/runtime-assets-manifest.json");
const configPath = resolve(option("--config") ?? process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultConfigPath());
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
const configuration = JSON.parse(readFileSync(configPath, "utf8"));
if (typeof configuration.asset_root !== "string" || !isAbsolute(configuration.asset_root)) {
throw new Error("asset_root_configuration_invalid");
}
const fontEntries = manifest.entries
.filter((entry) => entry.assetId.startsWith("TEXT-FONT-") && entry.mimeType.startsWith("font/"))
.toSorted((left, right) => left.assetId.localeCompare(right.assetId));
const uniqueEntries = [...new Map(fontEntries.map((entry) => [entry.sha256, entry])).values()];
const fontPaths = new Map(
uniqueEntries.map((entry) => [
`/${encodeURIComponent(entry.assetId)}`,
{ mimeType: entry.mimeType, path: join(configuration.asset_root, entry.relativePath) },
]),
);
const server = createServer((request, response) => {
const font = fontPaths.get(request.url ?? "");
if (!font) {
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
response.end("<!doctype html><title>Dada browser font validation</title>");
return;
}
const bytes = readFileSync(font.path);
response.writeHead(200, { "Content-Length": bytes.length, "Content-Type": font.mimeType });
response.end(bytes);
});
await new Promise((resolveReady) => server.listen(0, "127.0.0.1", resolveReady));
const address = server.address();
if (!address || typeof address === "string") throw new Error("browser_font_probe_server_unavailable");
const origin = `http://127.0.0.1:${address.port}`;
const browser = await chromium.launch({ channel: option("--channel") ?? "msedge", headless: true });
const failures = [];
try {
let page = await browser.newPage();
await page.goto(origin);
for (let index = 0; index < uniqueEntries.length; index += 1) {
if (index > 0 && index % 25 === 0) {
await page.close();
page = await browser.newPage();
await page.goto(origin);
}
const entry = uniqueEntries[index];
const result = await page.evaluate(async ({ family, url }) => {
try {
const response = await fetch(url);
if (!response.ok) return `http_${response.status}`;
await new FontFace(family, await response.arrayBuffer()).load();
return "loaded";
} catch (error) {
return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
}
}, { family: `DadaFontProbe${index}`, url: `${origin}/${encodeURIComponent(entry.assetId)}` });
if (result !== "loaded") failures.push({ asset_id: entry.assetId, error: result });
}
} finally {
await browser.close();
await new Promise((resolveClosed) => server.close(resolveClosed));
}
const result = {
checked_entries: fontEntries.length,
failed_fonts: failures,
status: failures.length === 0 ? "passed" : "failed",
unique_fonts: uniqueEntries.length,
};
process.stdout.write(`${JSON.stringify(result)}\n`);
if (failures.length > 0) process.exitCode = 1;
+209 -13
View File
@@ -1,3 +1,23 @@
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import {
WP7_02_CONTROLLED_REAL_LIMIT,
validateSanitizedEvidence,
} from "./lib/wp7-02-controlled-executor.mjs";
import {
assembleControlledModelEvidence,
runControlledRealScenarios,
} from "./lib/wp7-02-controlled-matrix.mjs";
import {
AI_GATEWAY_CREDENTIAL_TARGET,
WP7_02_MODEL_IDS,
buildBlockedModelEvidence,
inspectAiGatewayReadiness,
writeBlockedModelEvidence,
} from "./lib/wp7-02-external-contract.mjs";
const allowedServices = new Set(["ai", "ai-gateway-service-id", "resend", "amap"]);
function argument(name) {
@@ -5,23 +25,199 @@ function argument(name) {
return index >= 0 ? process.argv[index + 1] : undefined;
}
function output(value, error = false) {
const serialized = JSON.stringify(validateSanitizedEvidence(value));
if (error) console.error(serialized); else console.log(serialized);
}
const service = argument("--service");
const runId = argument("--run-id");
const model = argument("--model");
const candidatePath = argument("--candidate-record") ?? process.env.DADA_WP7_01_CANDIDATE_RECORD;
const configPath = argument("--config-manifest") ?? process.env.DADA_WP7_02_MODEL_CONFIG_MANIFEST;
const evidenceDirectory = argument("--evidence-dir") ?? process.env.DADA_WP7_02_EVIDENCE_DIR;
const maxRealCalls = Number(argument("--max-real-calls"));
const confirmed = process.argv.includes("--confirm-controlled-real");
const executeControlledReal = process.argv.includes("--execute-controlled-real");
const credentialStdin = process.argv.includes("--credential-stdin");
const readinessOnly = process.argv.includes("--readiness-only");
if (!allowedServices.has(service) || !runId || ((service === "ai" || service === "ai-gateway-service-id") && !model)) {
console.error("Usage: pnpm validate:external -- --service <ai|ai-gateway-service-id|resend|amap> --run-id <id> [--model <model-id>]");
if (!allowedServices.has(service) || !runId || ((service === "ai" || service === "ai-gateway-service-id") && !WP7_02_MODEL_IDS.includes(model))) {
console.error("Usage: pnpm validate:external -- --service <ai|ai-gateway-service-id|resend|amap> --run-id <id> [--model <model-id>] [--readiness-only]");
process.exit(2);
}
console.log(
JSON.stringify({
blocker: service === "ai" || service === "ai-gateway-service-id" ? "real_gateway_credentials_absent" : undefined,
mode: "mock",
model,
real_calls: 0,
run_id: runId,
service,
status: service === "ai" || service === "ai-gateway-service-id" ? "not_applicable" : "not_applicable_for_TASK-WP0-01",
}),
);
if (service !== "ai" && service !== "ai-gateway-service-id") {
console.log(JSON.stringify({ mode: "mock", real_calls: 0, run_id: runId, service, status: "not_applicable_for_TASK-WP0-01" }));
process.exit(0);
}
function parseInputs() {
const candidateRecord = candidatePath && existsSync(candidatePath) ? JSON.parse(readFileSync(candidatePath, "utf8")) : undefined;
const configManifest = configPath && existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : undefined;
const modelConfig = Array.isArray(configManifest?.models)
? configManifest.models.find((entry) => entry?.model_id === model)
: undefined;
return { candidateRecord, modelConfig };
}
function delegateToSecureBroker() {
if (!candidatePath || !configPath || !evidenceDirectory || !confirmed || maxRealCalls !== WP7_02_CONTROLLED_REAL_LIMIT) {
output({ code: "WP7_02_CONTROLLED_EXECUTION_ARGUMENTS_REQUIRED", model, real_calls: 0, run_id: runId, service, status: "failed" }, true);
return 2;
}
const args = [
"run", "--no-build", "--project", "supervisor/Dada.Supervisor/Dada.Supervisor.csproj", "--",
"validate-external",
"--service", "ai-gateway-service-id",
"--model", model,
"--run-id", runId,
"--max-real-calls", String(maxRealCalls),
"--confirm-controlled-real",
"--execute-controlled-real",
];
const result = spawnSync("dotnet", args, {
encoding: "utf8",
env: {
...process.env,
DADA_WP7_01_CANDIDATE_RECORD: candidatePath,
DADA_WP7_02_EVIDENCE_DIR: evidenceDirectory,
DADA_WP7_02_MODEL_CONFIG_MANIFEST: configPath,
},
maxBuffer: 64 * 1024 * 1024,
timeout: 20 * 60_000,
windowsHide: true,
});
const stdout = result.stdout?.trim() ?? "";
const stderr = result.stderr?.trim() ?? "";
const selected = stdout || stderr;
try {
if (!selected || (stdout && stderr)) throw new Error("invalid_output");
const parsed = validateSanitizedEvidence(JSON.parse(selected));
output(parsed, !stdout);
} catch {
output({ code: "WP7_02_SECURE_BROKER_FAILED", model, real_calls: 0, run_id: runId, service, status: "failed" }, true);
return 1;
}
return result.status ?? 1;
}
async function readCredentialFromStdin() {
let serialized = "";
for await (const chunk of process.stdin) {
serialized += chunk.toString("utf8");
if (serialized.length > 16_384) throw new Error("WP7_02_CREDENTIAL_CHANNEL_INVALID");
}
const payload = JSON.parse(serialized);
serialized = "";
if (!payload || typeof payload !== "object" || Array.isArray(payload)
|| Object.keys(payload).length !== 1 || typeof payload[AI_GATEWAY_CREDENTIAL_TARGET] !== "string"
|| payload[AI_GATEWAY_CREDENTIAL_TARGET].length < 8) {
throw new Error("WP7_02_CREDENTIAL_CHANNEL_INVALID");
}
const token = payload[AI_GATEWAY_CREDENTIAL_TARGET];
payload[AI_GATEWAY_CREDENTIAL_TARGET] = "";
return token;
}
function readDeterministicState() {
const path = evidenceDirectory && resolve(evidenceDirectory, "deterministic-state.json");
if (!path || !existsSync(path)) throw new Error("WP7_02_DETERMINISTIC_STATE_REQUIRED");
return validateSanitizedEvidence(JSON.parse(readFileSync(path, "utf8")));
}
function writeControlledEvidence(directory, evidence, readiness) {
mkdirSync(resolve(directory), { recursive: true });
const files = {
"contract-matrix.json": evidence.matrix,
"external-calls.json": evidence.external_calls,
"manual-review.json": evidence.manual_review,
"readiness.json": {
blockers: evidence.status === "passed" ? [] : evidence.external_calls.calls.filter((call) => call.status !== "passed").map((call) => call.error_code ?? call.scenario_id),
candidate: readiness.candidate,
evidence_id: evidence.evidence_id,
model_id: evidence.model_id,
run_id: evidence.run_id,
status: evidence.status,
},
"redaction.json": evidence.redaction,
};
for (const [name, value] of Object.entries(files)) {
writeFileSync(resolve(directory, name), `${JSON.stringify(validateSanitizedEvidence(value), null, 2)}\n`);
}
}
async function main() {
const { candidateRecord, modelConfig } = parseInputs();
if (!candidateRecord) {
output({ blockers: ["candidate_record_absent", ...(confirmed ? [] : ["explicit_confirmation_absent"])], mode: "controlled_real_not_executed", model, real_calls: 0, run_id: runId, service, status: "externally_blocked" });
return 3;
}
if (executeControlledReal && !credentialStdin) return delegateToSecureBroker();
let token = "";
try {
if (credentialStdin) token = await readCredentialFromStdin();
const readiness = inspectAiGatewayReadiness({
candidateRecord,
confirmed,
credentialTargets: credentialStdin ? [AI_GATEWAY_CREDENTIAL_TARGET] : [],
modelConfig,
modelId: model,
});
if (!credentialStdin) {
readiness.blockers = readiness.blockers.filter((blocker) => blocker !== "real_gateway_credentials_absent");
readiness.blockers.push("secure_credential_check_requires_execution");
}
readiness.status = readiness.blockers.length > 0 ? "externally_blocked" : "ready_for_controlled_execution";
if (!executeControlledReal || readinessOnly || readiness.blockers.length > 0) {
if (readiness.blockers.length > 0 && evidenceDirectory) {
writeBlockedModelEvidence(evidenceDirectory, buildBlockedModelEvidence({
blockers: readiness.blockers, candidateRecord, modelConfig: readiness.model_config, modelId: model, runId,
}));
}
output({
blockers: readiness.blockers,
candidate_build_commit: readiness.candidate.build_commit,
mode: readinessOnly ? "readiness_only" : "controlled_real_not_executed",
model,
planned_provider_requests_max: readiness.plan.planned_provider_requests_max,
planned_request_breakdown: readiness.plan.planned_request_breakdown,
real_calls: 0,
run_id: runId,
service,
status: readiness.status,
});
return readiness.blockers.length > 0 ? 3 : 0;
}
const deterministicState = readDeterministicState();
const realExecution = await runControlledRealScenarios({ maxRealCalls, modelConfig, token });
const evidence = assembleControlledModelEvidence({ deterministicState, modelConfig, realExecution, runId });
writeControlledEvidence(evidenceDirectory, evidence, readiness);
output({
blockers: realExecution.blockers,
config_version: modelConfig.config_version,
model,
planned_real_calls: realExecution.planned_real_calls,
real_calls: realExecution.real_calls,
run_id: runId,
service,
status: evidence.status === "passed" ? "controlled_real_passed_pending_manual_review" : "externally_blocked",
});
return evidence.status === "passed" ? 0 : 3;
} finally {
token = "";
}
}
try {
process.exitCode = await main();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const code = /^WP7_02_[A-Z0-9_:]+$/.test(message) ? message : "WP7_02_EXTERNAL_VALIDATION_FAILED";
output({ code, model, real_calls: 0, run_id: runId, service, status: "failed" }, true);
process.exitCode = 1;
}
+24
View File
@@ -0,0 +1,24 @@
import { execFileSync } from "node:child_process";
const requiredSubjects = [
"fix(POSTV1-07): 固定画布布局与文字贴纸选择",
"fix(POSTV1-07): 自动关闭编辑器操作提示",
"fix(POSTV1-08): 修复文字拖动闪烁与自动保存",
"fix(POSTV1-09): 展示项目生成图片",
"feat(POSTV1-10): 增加统一交互反馈",
"fix(POSTV1-11): 修复生成提交的会话令牌轮换",
"fix(POSTV1-runtime): 改善后台启动与状态窗口可见性",
"fix(POSTV1-browser): 同时支持 Chrome 150 与 151",
"fix(POSTV1-editor): 修复多选拖动与底图调整",
];
const subjects = new Set(execFileSync("git", ["log", "--format=%s", "HEAD"], { encoding: "utf8" })
.split(/\r?\n/u)
.filter(Boolean));
const missing = requiredSubjects.filter((subject) => !subjects.has(subject));
if (missing.length > 0) {
throw new Error(`postv1_ui_lineage_incomplete:${missing.join("|")}`);
}
console.log(JSON.stringify({ required_commit_count: requiredSubjects.length, status: "passed" }));
+91 -9
View File
@@ -16,6 +16,12 @@ internal static class Program
return await RunCredentialChildAsync();
}
if (args.FirstOrDefault() == "--credential-echo")
{
Console.Write(await Console.In.ReadToEndAsync());
return 0;
}
if (args.FirstOrDefault() == "--instance-probe")
{
using var instance = await SingleInstanceCoordinator.TryAcquireAsync(args[1], args[2]);
@@ -32,7 +38,9 @@ internal static class Program
{
var security = await TestCredentialBoundaryAsync();
var supervisor = await TestSupervisorLifecycleAsync();
await TestAmapProbeSecurityAsync();
TestSecureConfigurationPersistence();
TestRuntimeDirectoryBootstrap();
TestStructuredLogging();
WriteEvidence(Environment.GetEnvironmentVariable("DADA_EVIDENCE_DIR_SEC"), security);
WriteEvidence(Environment.GetEnvironmentVariable("DADA_EVIDENCE_DIR_SUP"), supervisor);
@@ -46,6 +54,20 @@ internal static class Program
}
}
private static async Task TestAmapProbeSecurityAsync()
{
using var handler = AmapProbe.CreateHandler();
False(handler.AllowAutoRedirect, "Amap probe redirects disabled");
Equal(1, handler.MaxConnectionsPerServer, "Amap probe per-server connection cap");
True(AmapProbe.IsAllowedEndpoint(new Uri("https://restapi.amap.com/v3/geocode/regeo")), "Amap fixed HTTPS endpoint accepted");
False(AmapProbe.IsAllowedEndpoint(new Uri("http://restapi.amap.com/v3/geocode/regeo")), "Amap HTTP endpoint rejected");
False(AmapProbe.IsAllowedEndpoint(new Uri("https://example.invalid/v3/geocode/regeo")), "Amap alternate host rejected");
using var oversized = new ByteArrayContent(new byte[AmapProbe.MaximumResponseBytes + 1]);
await ThrowsAsync<InvalidDataException>(
() => AmapProbe.ReadBoundedJsonAsync(oversized),
"Amap oversized response must be rejected before parsing");
}
private static void TestStructuredLogging()
{
Equal(10 * 1024 * 1024L, StructuredJsonlLogger.SizeLimitBytes, "Supervisor log byte limit");
@@ -104,6 +126,29 @@ internal static class Program
}
}
private static void TestRuntimeDirectoryBootstrap()
{
var root = Path.Combine(Path.GetTempPath(), $"dada-runtime-root-{Guid.NewGuid():N}");
try
{
Directory.CreateDirectory(root);
SupervisorRuntime.EnsureRuntimeDirectories(root);
foreach (var relativePath in new[]
{
"db", "content/references", "content/generated", "content/exports",
"managed-assets", "derived-assets", "staging",
"logs/api", "logs/worker", "logs/supervisor",
})
{
True(Directory.Exists(Path.Combine(root, relativePath)), $"runtime directory missing: {relativePath}");
}
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
private static async Task<object> TestCredentialBoundaryAsync()
{
var store = new TestCredentialStore();
@@ -120,6 +165,35 @@ internal static class Program
var workerProbe = await LaunchCredentialProbeAsync(ChildRole.Worker, store);
EqualSequence(new[] { CredentialCatalog.WorkerAiGateway }, workerProbe.Names, "Worker credential scope");
var leakProbe = await CredentialProcessLauncher.RunToCompletionAsync(
new ProcessStartInfo(Environment.ProcessPath!, "--credential-echo"), ChildRole.Worker, store);
True(leakProbe.SensitiveOutputDetected, "credential echo must be detected");
Equal(string.Empty, leakProbe.StandardOutput, "credential echo output discarded");
Equal(string.Empty, leakProbe.StandardError, "credential echo error discarded");
True(AiGatewayProbe.TryValidateOutput("{\"code\":\"ai_probe_passed\",\"mime_type\":\"image/png\",\"pixel_height\":1080,\"pixel_width\":1080,\"real_calls\":1,\"success\":true}", out _), "AI probe success output accepted");
False(AiGatewayProbe.TryValidateOutput("{\"code\":\"ai_probe_passed\",\"raw_body\":\"private\",\"real_calls\":1,\"success\":true}", out _), "AI probe private output rejected");
var externalArguments = new[]
{
"--service", "ai-gateway-service-id",
"--model", "gpt-image-2",
"--run-id", "wp7-02-supervisor-probe",
"--max-real-calls", "120",
"--confirm-controlled-real",
"--execute-controlled-real",
};
EqualSequence(externalArguments, ControlledExternalValidationLauncher.ValidateArguments(externalArguments), "controlled external argument allowlist");
var stableGeminiArguments = externalArguments.ToArray();
stableGeminiArguments[3] = "gemini-3.1-flash-image";
EqualSequence(stableGeminiArguments, ControlledExternalValidationLauncher.ValidateArguments(stableGeminiArguments), "stable Gemini external argument allowlist");
var previewGeminiArguments = externalArguments.ToArray();
previewGeminiArguments[3] = "gemini-3.1-flash-image-preview";
Throws<ArgumentException>(
() => ControlledExternalValidationLauncher.ValidateArguments(previewGeminiArguments),
"preview Gemini external argument rejected");
Throws<ArgumentException>(
() => ControlledExternalValidationLauncher.ValidateArguments(externalArguments.Where(value => value != "--confirm-controlled-real").ToArray()),
"controlled external confirmation required");
store.Delete(CredentialCatalog.WorkerAiGateway);
await ThrowsAsync<MissingCredentialException>(
@@ -166,15 +240,10 @@ internal static class Program
private static async Task<CredentialProbe> LaunchCredentialProbeAsync(ChildRole role, ICredentialStore store)
{
var startInfo = new ProcessStartInfo(Environment.ProcessPath!, "--credential-child")
{
RedirectStandardOutput = true,
};
using var process = await CredentialProcessLauncher.StartAsync(startInfo, role, store);
var output = await process.StandardOutput.ReadToEndAsync();
await process.WaitForExitAsync();
Equal(0, process.ExitCode, "credential child exit code");
return JsonSerializer.Deserialize<CredentialProbe>(output, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
var result = await CredentialProcessLauncher.RunToCompletionAsync(new ProcessStartInfo(Environment.ProcessPath!, "--credential-child"), role, store);
Equal(0, result.ExitCode, "credential child exit code");
False(result.SensitiveOutputDetected, "credential child output contains injected value");
return JsonSerializer.Deserialize<CredentialProbe>(result.StandardOutput, new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? throw new InvalidOperationException("Credential child returned invalid JSON.");
}
@@ -355,6 +424,19 @@ internal static class Program
throw new InvalidOperationException(message);
}
private static void Throws<TException>(Action action, string message) where TException : Exception
{
try
{
action();
}
catch (TException)
{
return;
}
throw new InvalidOperationException(message);
}
private sealed record CredentialProbe(string[] Names, bool EnvironmentContainsMarker, bool ArgumentsContainMarker);
private sealed class TestCredentialStore : ICredentialStore
@@ -0,0 +1,74 @@
using System.Diagnostics;
using System.Text.Json;
namespace Dada.Supervisor;
internal static class AiGatewayProbe
{
internal static async Task<int> RunAsync(ICredentialStore credentials, CancellationToken cancellationToken = default)
{
var node = Path.Combine(AppContext.BaseDirectory, "runtime", "node.exe");
var worker = Path.Combine(AppContext.BaseDirectory, "server", "worker.mjs");
if (!File.Exists(node) || !File.Exists(worker)) return WriteFailure("ai_probe_runtime_missing", 0);
var startInfo = new ProcessStartInfo(node) { WorkingDirectory = AppContext.BaseDirectory };
startInfo.Environment["DADA_SQLITE_NATIVE_BINDING"] = Path.Combine(AppContext.BaseDirectory, "server", "native", "better_sqlite3.node");
startInfo.ArgumentList.Add(worker);
startInfo.ArgumentList.Add("--dada-ai-probe");
startInfo.ArgumentList.Add("--dada-credential-stdin");
var result = await CredentialProcessLauncher.RunToCompletionAsync(startInfo, ChildRole.Worker, credentials, cancellationToken);
if (result.SensitiveOutputDetected || result.StandardError.Length > 0 || !TryValidateOutput(result.StandardOutput, out var sanitized))
{
return WriteFailure("ai_probe_runtime_failed", 0);
}
Console.WriteLine(sanitized);
return result.ExitCode;
}
internal static bool TryValidateOutput(string output, out string sanitized)
{
sanitized = string.Empty;
try
{
using var document = JsonDocument.Parse(output);
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object) return false;
var allowed = new HashSet<string>(StringComparer.Ordinal)
{
"code", "error_category", "mime_type", "pixel_height", "pixel_width", "real_calls", "success",
};
if (root.EnumerateObject().Any(property => !allowed.Contains(property.Name))) return false;
if (!root.TryGetProperty("success", out var success) || success.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) return false;
if (!root.TryGetProperty("real_calls", out var realCalls) || realCalls.ValueKind != JsonValueKind.Number || !realCalls.TryGetInt32(out var count) || count is < 0 or > 1) return false;
var passed = success.GetBoolean();
var code = root.GetProperty("code").GetString();
if (passed)
{
if (code != "ai_probe_passed" || count != 1) return false;
var mime = root.GetProperty("mime_type").GetString();
if (mime is not ("image/jpeg" or "image/png" or "image/webp")) return false;
if (!PositiveDimension(root, "pixel_width") || !PositiveDimension(root, "pixel_height")) return false;
}
else
{
if (code != "ai_probe_failed" || !root.TryGetProperty("error_category", out var category)
|| category.ValueKind != JsonValueKind.String || (category.GetString()?.Length ?? 0) is < 1 or > 64) return false;
}
sanitized = JsonSerializer.Serialize(root);
return true;
}
catch (Exception exception) when (exception is JsonException or InvalidOperationException or KeyNotFoundException)
{
return false;
}
}
private static bool PositiveDimension(JsonElement root, string name) =>
root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number
&& value.TryGetInt32(out var dimension) && dimension is > 0 and <= 4096;
private static int WriteFailure(string code, int realCalls)
{
Console.WriteLine(JsonSerializer.Serialize(new { code, real_calls = realCalls, success = false }));
return 2;
}
}
+107
View File
@@ -0,0 +1,107 @@
using System.Buffers;
using System.Net;
using System.Text.Json;
namespace Dada.Supervisor;
internal static class AmapProbe
{
internal const int MaximumResponseBytes = 65_536;
private const string ProviderHostname = "restapi.amap.com";
private static readonly HttpClient Client = new(CreateHandler()) { Timeout = TimeSpan.FromSeconds(15) };
internal static SocketsHttpHandler CreateHandler() => new()
{
AllowAutoRedirect = false,
AutomaticDecompression = DecompressionMethods.None,
ConnectTimeout = TimeSpan.FromSeconds(10),
MaxConnectionsPerServer = 1,
};
internal static bool IsAllowedEndpoint(Uri endpoint) =>
endpoint.Scheme == Uri.UriSchemeHttps
&& endpoint.Host.Equals(ProviderHostname, StringComparison.OrdinalIgnoreCase)
&& (endpoint.IsDefaultPort || endpoint.Port == 443)
&& string.IsNullOrEmpty(endpoint.UserInfo)
&& endpoint.AbsolutePath is "/v3/geocode/regeo" or "/v3/geocode/geo";
internal static int Run(string? key)
{
if (string.IsNullOrWhiteSpace(key))
{
Write("amap_credentials_missing", false, 0, null, null);
return 3;
}
var realCalls = 0;
try
{
realCalls += 1;
var reverse = Call("https://restapi.amap.com/v3/geocode/regeo?location=120.6994,27.9943&extensions=base", key).GetAwaiter().GetResult();
realCalls += 1;
var geocode = Call($"https://restapi.amap.com/v3/geocode/geo?address={Uri.EscapeDataString("")}", key).GetAwaiter().GetResult();
var success = reverse.Status == "1" && geocode.Status == "1";
Write(success ? "amap_probe_passed" : "amap_provider_rejected", success, realCalls, reverse.Status, geocode.Status);
return success ? 0 : 3;
}
catch (TaskCanceledException)
{
Write("amap_probe_timeout", false, realCalls, null, null);
return 3;
}
catch (HttpRequestException exception)
{
Write($"amap_probe_network_{exception.StatusCode?.ToString() ?? "error"}", false, realCalls, null, null);
return 3;
}
catch (Exception exception) when (exception is JsonException or InvalidDataException)
{
Write("amap_probe_invalid_response", false, realCalls, null, null);
return 3;
}
}
private static async Task<ProbeResponse> Call(string endpoint, string key)
{
var requestUri = new Uri($"{endpoint}&key={Uri.EscapeDataString(key)}", UriKind.Absolute);
if (!IsAllowedEndpoint(requestUri)) throw new InvalidDataException("amap_endpoint_rejected");
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
using var response = await Client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
response.EnsureSuccessStatusCode();
using var document = await ReadBoundedJsonAsync(response.Content);
var root = document.RootElement;
return new ProbeResponse(root.GetProperty("status").GetString() ?? "", root.TryGetProperty("infocode", out var code) ? code.GetString() : null);
}
internal static async Task<JsonDocument> ReadBoundedJsonAsync(HttpContent content)
{
if (content.Headers.ContentLength is > MaximumResponseBytes) throw new InvalidDataException("amap_response_too_large");
await using var stream = await content.ReadAsStreamAsync();
using var buffered = new MemoryStream();
var buffer = ArrayPool<byte>.Shared.Rent(4_096);
try
{
var total = 0;
while (true)
{
var read = await stream.ReadAsync(buffer);
if (read == 0) break;
total += read;
if (total > MaximumResponseBytes) throw new InvalidDataException("amap_response_too_large");
await buffered.WriteAsync(buffer.AsMemory(0, read));
}
buffered.Position = 0;
return await JsonDocument.ParseAsync(buffered);
}
finally
{
Array.Clear(buffer);
ArrayPool<byte>.Shared.Return(buffer);
}
}
private static void Write(string code, bool success, int realCalls, string? reverseStatus, string? geocodeStatus) =>
Console.WriteLine(JsonSerializer.Serialize(new { code, success, real_calls = realCalls, reverse_status = reverseStatus, geocode_status = geocodeStatus }));
private sealed record ProbeResponse(string Status, string? InfoCode);
}
@@ -0,0 +1,131 @@
using System.Diagnostics;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Dada.Supervisor;
internal static partial class ControlledExternalValidationLauncher
{
private static readonly HashSet<string> AllowedModels =
[
"gemini-3.1-flash-image",
"gpt-image-2",
];
private static readonly HashSet<string> ValueOptions =
[
"--max-real-calls",
"--model",
"--run-id",
"--service",
];
private static readonly HashSet<string> SwitchOptions =
[
"--confirm-controlled-real",
"--execute-controlled-real",
];
internal static async Task<int> RunAsync(string[] args, ICredentialStore credentials, CancellationToken cancellationToken = default)
{
var validated = ValidateArguments(args);
var script = Path.GetFullPath(Path.Combine(Environment.CurrentDirectory, "scripts", "validate-external.mjs"));
if (!File.Exists(script)) throw new InvalidOperationException("external_validator_not_found");
var startInfo = new ProcessStartInfo("node") { WorkingDirectory = Environment.CurrentDirectory };
startInfo.ArgumentList.Add(script);
foreach (var value in validated) startInfo.ArgumentList.Add(value);
startInfo.ArgumentList.Add("--credential-stdin");
var result = await CredentialProcessLauncher.RunToCompletionAsync(startInfo, ChildRole.Worker, credentials, cancellationToken);
if (result.SensitiveOutputDetected || !TrySelectSanitizedJson(result, out var output, out var useError))
{
Console.Error.WriteLine("{\"code\":\"external_validator_output_invalid\",\"real_calls\":0,\"status\":\"failed\"}");
return 1;
}
if (useError) Console.Error.WriteLine(output); else Console.WriteLine(output);
return result.ExitCode;
}
internal static string[] ValidateArguments(string[] args)
{
var values = new Dictionary<string, string>(StringComparer.Ordinal);
var switches = new HashSet<string>(StringComparer.Ordinal);
for (var index = 0; index < args.Length; index++)
{
var option = args[index];
if (SwitchOptions.Contains(option))
{
if (!switches.Add(option)) throw new ArgumentException("external_validator_argument_duplicate");
continue;
}
if (!ValueOptions.Contains(option) || index + 1 >= args.Length || !values.TryAdd(option, args[++index]))
{
throw new ArgumentException("external_validator_argument_invalid");
}
}
if (values.GetValueOrDefault("--service") != "ai-gateway-service-id"
|| !AllowedModels.Contains(values.GetValueOrDefault("--model") ?? string.Empty)
|| !SafeRunId().IsMatch(values.GetValueOrDefault("--run-id") ?? string.Empty)
|| values.GetValueOrDefault("--max-real-calls") != "120"
|| !switches.SetEquals(SwitchOptions))
{
throw new ArgumentException("external_validator_argument_invalid");
}
if (values.Values.Any(value => value.Length == 0 || value.IndexOfAny(['\r', '\n', '\0']) >= 0))
{
throw new ArgumentException("external_validator_argument_invalid");
}
return args.ToArray();
}
private static bool TrySelectSanitizedJson(CredentialProcessResult result, out string output, out bool useError)
{
var stdout = result.StandardOutput.Trim();
var stderr = result.StandardError.Trim();
useError = stdout.Length == 0;
output = useError ? stderr : stdout;
if (output.Length == 0 || (stdout.Length > 0 && stderr.Length > 0)) return false;
try
{
using var document = JsonDocument.Parse(output);
return IsSanitized(document.RootElement);
}
catch (JsonException)
{
return false;
}
}
private static bool IsSanitized(JsonElement element)
{
if (element.ValueKind == JsonValueKind.Object)
{
foreach (var property in element.EnumerateObject())
{
if (ForbiddenKey().IsMatch(property.Name) || property.NameEquals("verified") || !IsSanitized(property.Value)) return false;
}
}
else if (element.ValueKind == JsonValueKind.Array)
{
foreach (var item in element.EnumerateArray()) if (!IsSanitized(item)) return false;
}
else if (element.ValueKind == JsonValueKind.String)
{
var value = element.GetString() ?? string.Empty;
if (WindowsUserPath().IsMatch(value) || BearerValue().IsMatch(value)) return false;
}
return true;
}
[GeneratedRegex("^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$", RegexOptions.CultureInvariant)]
private static partial Regex SafeRunId();
[GeneratedRegex("(?:^|_)(?:absolute_path|authorization|body|credential|image|password|path|prompt|raw|secret|token)(?:_|$)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex ForbiddenKey();
[GeneratedRegex("[A-Za-z]:\\\\Users\\\\", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex WindowsUserPath();
[GeneratedRegex("(?:Bearer\\s+|\\bsk-[A-Za-z0-9_-]{8,})", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex BearerValue();
}

Some files were not shown because too many files have changed in this diff Show More