@coralai/sps-cli 0.65.75 → 0.65.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/dist/console/index.d.ts.map +1 -1
  2. package/dist/console/index.js +6 -0
  3. package/dist/console/index.js.map +1 -1
  4. package/dist/console/lib/artPaths.d.ts +4 -4
  5. package/dist/console/lib/artPaths.d.ts.map +1 -1
  6. package/dist/console/lib/artPaths.js +24 -16
  7. package/dist/console/lib/artPaths.js.map +1 -1
  8. package/dist/console/lib/artRunRequest.d.ts +46 -0
  9. package/dist/console/lib/artRunRequest.d.ts.map +1 -0
  10. package/dist/console/lib/artRunRequest.js +107 -0
  11. package/dist/console/lib/artRunRequest.js.map +1 -0
  12. package/dist/console/routes/art.d.ts.map +1 -1
  13. package/dist/console/routes/art.js +65 -47
  14. package/dist/console/routes/art.js.map +1 -1
  15. package/dist/console/routes/artPipeline.d.ts +16 -0
  16. package/dist/console/routes/artPipeline.d.ts.map +1 -0
  17. package/dist/console/routes/artPipeline.js +126 -0
  18. package/dist/console/routes/artPipeline.js.map +1 -0
  19. package/dist/core/art/artConcurrency.d.ts +63 -0
  20. package/dist/core/art/artConcurrency.d.ts.map +1 -0
  21. package/dist/core/art/artConcurrency.js +63 -0
  22. package/dist/core/art/artConcurrency.js.map +1 -0
  23. package/dist/core/art/artRunLauncher.d.ts +6 -0
  24. package/dist/core/art/artRunLauncher.d.ts.map +1 -0
  25. package/dist/core/art/artRunLauncher.js +91 -0
  26. package/dist/core/art/artRunLauncher.js.map +1 -0
  27. package/dist/core/art/artRunService.d.ts +115 -0
  28. package/dist/core/art/artRunService.d.ts.map +1 -0
  29. package/dist/core/art/artRunService.js +183 -0
  30. package/dist/core/art/artRunService.js.map +1 -0
  31. package/dist/core/art/assetLocator.d.ts +33 -0
  32. package/dist/core/art/assetLocator.d.ts.map +1 -0
  33. package/dist/core/art/assetLocator.js +61 -0
  34. package/dist/core/art/assetLocator.js.map +1 -0
  35. package/dist/providers/art/artDriver.d.ts +71 -0
  36. package/dist/providers/art/artDriver.d.ts.map +1 -0
  37. package/dist/providers/art/artDriver.js +119 -0
  38. package/dist/providers/art/artDriver.js.map +1 -0
  39. package/dist/providers/art/artEvents.d.ts +23 -0
  40. package/dist/providers/art/artEvents.d.ts.map +1 -0
  41. package/dist/providers/art/artEvents.js +40 -0
  42. package/dist/providers/art/artEvents.js.map +1 -0
  43. package/dist/providers/art/artGen.d.ts +259 -0
  44. package/dist/providers/art/artGen.d.ts.map +1 -0
  45. package/dist/providers/art/artGen.js +1303 -0
  46. package/dist/providers/art/artGen.js.map +1 -0
  47. package/dist/providers/art/artPrompt.d.ts +42 -0
  48. package/dist/providers/art/artPrompt.d.ts.map +1 -0
  49. package/dist/providers/art/artPrompt.js +41 -0
  50. package/dist/providers/art/artPrompt.js.map +1 -0
  51. package/dist/providers/art/artRunChild.d.ts +3 -0
  52. package/dist/providers/art/artRunChild.d.ts.map +1 -0
  53. package/dist/providers/art/artRunChild.js +153 -0
  54. package/dist/providers/art/artRunChild.js.map +1 -0
  55. package/dist/providers/art/atomicWrite.d.ts +16 -0
  56. package/dist/providers/art/atomicWrite.d.ts.map +1 -0
  57. package/dist/providers/art/atomicWrite.js +62 -0
  58. package/dist/providers/art/atomicWrite.js.map +1 -0
  59. package/dist/providers/art/cancelMarker.d.ts +9 -0
  60. package/dist/providers/art/cancelMarker.d.ts.map +1 -0
  61. package/dist/providers/art/cancelMarker.js +60 -0
  62. package/dist/providers/art/cancelMarker.js.map +1 -0
  63. package/dist/providers/art/composeAsset.d.ts +52 -0
  64. package/dist/providers/art/composeAsset.d.ts.map +1 -0
  65. package/dist/providers/art/composeAsset.js +73 -0
  66. package/dist/providers/art/composeAsset.js.map +1 -0
  67. package/dist/providers/art/imageSize.d.ts +31 -0
  68. package/dist/providers/art/imageSize.d.ts.map +1 -0
  69. package/dist/providers/art/imageSize.js +92 -0
  70. package/dist/providers/art/imageSize.js.map +1 -0
  71. package/dist/providers/art/jobContext.d.ts +25 -0
  72. package/dist/providers/art/jobContext.d.ts.map +1 -0
  73. package/dist/providers/art/jobContext.js +5 -0
  74. package/dist/providers/art/jobContext.js.map +1 -0
  75. package/dist/providers/art/rateLimitRetry.d.ts +25 -0
  76. package/dist/providers/art/rateLimitRetry.d.ts.map +1 -0
  77. package/dist/providers/art/rateLimitRetry.js +40 -0
  78. package/dist/providers/art/rateLimitRetry.js.map +1 -0
  79. package/dist/providers/art/uiKit.d.ts +61 -0
  80. package/dist/providers/art/uiKit.d.ts.map +1 -0
  81. package/dist/providers/art/uiKit.js +331 -0
  82. package/dist/providers/art/uiKit.js.map +1 -0
  83. package/dist/providers/art/visionJudge.d.ts +26 -0
  84. package/dist/providers/art/visionJudge.d.ts.map +1 -0
  85. package/dist/providers/art/visionJudge.js +127 -0
  86. package/dist/providers/art/visionJudge.js.map +1 -0
  87. package/dist/providers/art/withinDir.d.ts +2 -0
  88. package/dist/providers/art/withinDir.d.ts.map +1 -0
  89. package/dist/providers/art/withinDir.js +19 -0
  90. package/dist/providers/art/withinDir.js.map +1 -0
  91. package/dist/providers/media/gatewayMediaProviders.d.ts +58 -0
  92. package/dist/providers/media/gatewayMediaProviders.d.ts.map +1 -0
  93. package/dist/providers/media/gatewayMediaProviders.js +46 -0
  94. package/dist/providers/media/gatewayMediaProviders.js.map +1 -0
  95. package/dist/providers/media/generateAsset.d.ts +9 -0
  96. package/dist/providers/media/generateAsset.d.ts.map +1 -1
  97. package/dist/providers/media/generateAsset.js +7 -1
  98. package/dist/providers/media/generateAsset.js.map +1 -1
  99. package/dist/providers/media/mediaProviders.d.ts +31 -1
  100. package/dist/providers/media/mediaProviders.d.ts.map +1 -1
  101. package/dist/providers/media/mediaProviders.js +33 -1
  102. package/dist/providers/media/mediaProviders.js.map +1 -1
  103. package/package.json +1 -1
@@ -0,0 +1,1303 @@
1
+ /**
2
+ * 美术资源生成 Job(阶段 1:串行生成)。
3
+ * 读 docs/art-manifest.json → 对每个 asset 调图像管线生成(prompt = desc + 全局 style)→
4
+ * 存 repo/assets/<id>.png + assets/asset-status.json(done/failed)。
5
+ * 失败不阻塞其它(代码侧回退矢量)。匹配复用/并发提速留阶段 3。
6
+ */
7
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
8
+ import { createHash } from 'node:crypto';
9
+ import { join } from 'node:path';
10
+ import sharp from 'sharp';
11
+ import { withinDir } from './withinDir.js';
12
+ import { packSheet } from './sheet.js';
13
+ import { fitAndRepackUiKit, renderUiWireframe, uiKitPrompt, UIKIT_STRUCTURE_BROKEN, UI_KIT_FRAMES, UI_KIT_CANVAS } from './uiKit.js';
14
+ import { emitBuildEvent } from './artEvents.js';
15
+ import { runWithJob } from './jobContext.js';
16
+ import { detectFacing, inspectSheetLabels } from './visionJudge.js';
17
+ import { withTransparency } from './artPrompt.js';
18
+ import { composeAsset, defaultPixelOps } from './composeAsset.js';
19
+ import { compressKeepFormat, genSizeFor, parseSize } from './imageSize.js';
20
+ import { writeAssetAtomic } from './atomicWrite.js';
21
+ import { withRateLimitRetry } from './rateLimitRetry.js';
22
+ import { generateAsset } from '../media/generateAsset.js';
23
+ import { listUsableProviders } from '../media/mediaProviders.js';
24
+ import { resolveProjectDir } from '../../core/art/assetLocator.js';
25
+ import { ART_ASSET_CONCURRENCY_MAX } from '../../core/art/artConcurrency.js';
26
+ /**
27
+ * 归一化单条资产:把设计师自由发挥的旧/非标准 schema 修回标准结构字段。
28
+ * 背景(gmr1ze4zr 街头拳王事故):设计师加载了 skill 却写成 `{id,type,desc,frames,width,height}`——
29
+ * 没 `sheet` → 序列帧不打图集(动画废);没 `transparent` → 抠图跳过(白底)。pipeline 原本零校验、静默出废图。
30
+ * 这里在花钱生成【前】把能安全推断的补回来,并 warn 出来(违约可见)。返回是否发生了归一化(供上层汇总告警)。
31
+ */
32
+ function normalizeAsset(a) {
33
+ const fixes = [];
34
+ const t = (a.type ?? '').toLowerCase();
35
+ const isAudio = t.startsWith('audio');
36
+ // 1) frames → sheet:{cols:frames,rows:1}(旧的横向序列帧写法)。width≈frames×height 时更笃定,但只要 frames≥2 就转。
37
+ if (!a.sheet && typeof a.frames === 'number' && a.frames >= 2 && !isAudio) {
38
+ a.sheet = { cols: a.frames, rows: 1, count: a.frames };
39
+ fixes.push(`frames:${a.frames}→sheet:{cols:${a.frames},rows:1}`);
40
+ }
41
+ // 2) transparent 缺失时按语义推断:音频不涉及;desc 明说 transparent → true;背景/铺满 → false;其余精灵/道具/UI/图标 → true。
42
+ if (typeof a.transparent !== 'boolean' && !isAudio) {
43
+ const hay = `${a.desc ?? ''} ${a.usage ?? ''}`.toLowerCase();
44
+ const id = (a.id ?? '').toLowerCase();
45
+ const saysTransparent = /transparent|透明|no background|alpha/.test(hay);
46
+ // 背景:type=scene/bg、或 id 以 bg_/background_ 打头(设计师常把背景 type 写成 image、只靠 id 前缀区分)、或 desc/usage 明说背景/铺满。背景绝不抠图(否则打洞)。
47
+ // 前景层(scene/foreground / fg_ 前缀,docs/25 多层视差)必须透明——它要叠在背景上
48
+ const isForeground = t.includes('foreground') || /^fg[_-]/.test(id) || /前景/.test(hay);
49
+ const isBackground = !isForeground && (t.startsWith('scene') || t.startsWith('bg') || /^(bg|background|backdrop|scene)[_-]/.test(id) || /\bbackground\b|backdrop|full[\s-]?screen|铺满|背景/.test(hay));
50
+ a.transparent = saysTransparent || isForeground ? true : isBackground ? false : true;
51
+ fixes.push(`transparent(缺)→${a.transparent}`);
52
+ }
53
+ return fixes;
54
+ }
55
+ /**
56
+ * 生图【前】合图兜底:设计师违反 design-art-manifest「成套/同类合一张 sheet、每局 ≤3 张」、把同类元素拆成一堆单张精灵时,
57
+ * 在花钱生成前自动把它们合并成一个 sheet 资产 → 生图只生 1 张 → 从根上规避几十张并行生成的图床限流 429(星际防线事故:20 单张→codex 限流→回退差供应商)。
58
+ * 这是「生图用合图」的确定性兜底(不是生图后拼接):合并发生在生成之前,生图任务本身就是一张 sheet。
59
+ *
60
+ * 保守判据(避免误伤真需要独立的图):
61
+ * - 只合"同一大类 + 都是单张(无 sheet)"的资产;背景(scene/bg)、音频、3D、provided、已 sheet 一律不动。
62
+ * - 分两大组各合一张:`ui/*` → UI 图集、其余精灵 → 精灵图集(正对 design-art-manifest 的「UI sheet + 精灵 sheet」)。
63
+ * - 组内 ≥ MERGE_THRESHOLD 个才合(2–3 个的小游戏不折腾,只治"明显拆碎")。
64
+ * 合并顺序记进 desc(编号)+ mergedFrom → 写进 asset-meta(semanticId),worker 按帧对应回原语义。
65
+ * 就地改写 manifest.assets,返回合并说明(供上层 warn + 决定是否写回文件)。
66
+ */
67
+ const MERGE_THRESHOLD = 4;
68
+ export function mergeStraySprites(manifest) {
69
+ const notes = [];
70
+ const mergeable = (a) => {
71
+ const t = (a.type ?? '').toLowerCase();
72
+ if (t.startsWith('audio') || t === 'model3d' || t === 'ui/kit')
73
+ return false; // 声音/3D/UI契约套件不合
74
+ if (a.provided || a.sheet)
75
+ return false; // 用户给的 / 已是 sheet:不动
76
+ if (t.startsWith('scene') || t.startsWith('bg'))
77
+ return false; // 背景不合
78
+ const id = (a.id ?? '').toLowerCase();
79
+ if (/^(bg|background|backdrop|scene)[_-]/.test(id))
80
+ return false; // id 前缀判背景
81
+ return true;
82
+ };
83
+ const groupOf = (a) => ((a.type ?? '').toLowerCase().startsWith('ui') ? 'ui' : 'sprite');
84
+ for (const grp of ['sprite', 'ui']) {
85
+ const members = manifest.assets.filter((a) => mergeable(a) && groupOf(a) === grp);
86
+ if (members.length < MERGE_THRESHOLD)
87
+ continue; // 不够碎,保持原样
88
+ const n = members.length;
89
+ const cols = Math.ceil(Math.sqrt(n));
90
+ const rows = Math.ceil(n / cols);
91
+ const baseId = grp === 'ui' ? 'ui' : 'sprites';
92
+ const packId = manifest.assets.some((a) => a.id === baseId && !members.includes(a)) ? `${baseId}_pack` : baseId;
93
+ const merged = {
94
+ id: packId,
95
+ // desc 按 1)2)3) 编号列出各元素 → 与 mergedFrom / 生成布局顺序对齐;buildPrompt 的 isSheet 分支再补"禁网格线"等系统措辞。
96
+ desc: `共 ${n} 个独立元素,按 ${cols}×${rows} 均匀排开、每个居中等大、彼此留充足空隙:` +
97
+ members.map((m, i) => `${i + 1}) ${m.desc}`).join('; '),
98
+ type: grp === 'ui' ? 'ui/sheet' : 'sprite/sheet',
99
+ transparent: true,
100
+ orient: 'static',
101
+ sheet: { cols, rows, count: n },
102
+ size: '1024x1024', // codex 支持的方形;元素多也维持一张(守 ≤3 张)
103
+ mergedFrom: members.map((m) => m.id),
104
+ tags: ['自动合图'],
105
+ };
106
+ const memberSet = new Set(members);
107
+ manifest.assets = manifest.assets.filter((a) => !memberSet.has(a));
108
+ manifest.assets.push(merged);
109
+ notes.push(`${grp}: 合并 ${n} 个单张(${members.map((m) => m.id).join(', ')}) → sheet '${packId}' ${cols}×${rows}`);
110
+ }
111
+ return notes;
112
+ }
113
+ /** 资产描述指纹:二修时对比,变了 = 设计师改了这条资产 → 自动重生(确定性兜底,不依赖 regen 自觉)。
114
+ * 只含逐资产字段,不含全局 style(风格微调不该触发全量重生;整体换风格请显式 regen)。 */
115
+ export function assetDescHash(a) {
116
+ const key = JSON.stringify({ d: a.desc ?? '', s: a.size ?? '', sh: a.sheet ?? null, t: a.transparent ?? null, o: a.orient ?? '', r: a.ref ?? '', p: a.provided ?? '' });
117
+ return createHash('sha1').update(key).digest('hex').slice(0, 12);
118
+ }
119
+ /** 读 art-manifest;缺失/非法返回 null。 */
120
+ /**
121
+ * manifest 的落点。**导出**是因为子进程要区分三件今天长得一模一样的事(3.5):
122
+ * 「文件不存在(没有美术需求,合法)」/「文件在但读不出来(坏了,该响亮)」/「零资产」——
123
+ * 而 `readArtManifest` 对这三者一律返回 `null`/空。路径只能有一个真源,所以从这里拿。
124
+ */
125
+ export function artManifestPath(repoDir) {
126
+ return join(repoDir, 'docs', 'art-manifest.json');
127
+ }
128
+ export function readArtManifest(repoDir) {
129
+ try {
130
+ const f = artManifestPath(repoDir);
131
+ if (!existsSync(f))
132
+ return null;
133
+ const j = JSON.parse(readFileSync(f, 'utf-8'));
134
+ if (!Array.isArray(j.assets))
135
+ return null;
136
+ const assets = j.assets.filter((a) => a && a.id && (a.desc || a.provided)); // 逐资产字段(含 ref/provided)原样保留;provided 资产可无 desc
137
+ // 🔴 归一化:设计师即便加载了 skill 也常写非标准 schema(frames/width 代 sheet、透明塞进 desc)。在花钱生成前把能安全推断的补回标准字段,并汇总 warn(违约可见,而非静默出白底/未切图的废图)。
138
+ const normNotes = [];
139
+ for (const a of assets) {
140
+ const fixes = normalizeAsset(a);
141
+ if (fixes.length)
142
+ normNotes.push(`${a.id}: ${fixes.join(', ')}`);
143
+ }
144
+ if (normNotes.length)
145
+ console.warn(`⚠️ art-manifest 非标准字段已归一化(${normNotes.length} 条):\n ${normNotes.join('\n ')}`);
146
+ // 🔴 兜底:设计师(尤其 MiniMax/GLM 等遵循较弱的模型)漏写 BGM 时,自动补一段游戏 BGM,保证游戏有声音(见 docs/19「音乐不生成」)。
147
+ if (assets.length && !assets.some((a) => (a.type ?? '').startsWith('audio'))) {
148
+ assets.push({ id: 'bgm_game', usage: '游戏循环 BGM(设计师未声明,自动补)', type: 'audio/bgm', desc: 'upbeat cheerful casual project background music loop, light and pleasant, melodic, moderate tempo around 110 BPM, looping instrumental' });
149
+ }
150
+ return {
151
+ style: j.style,
152
+ view: j.view,
153
+ ref: typeof j.ref === 'string' ? j.ref : undefined,
154
+ regen: Array.isArray(j.regen) ? j.regen.filter((x) => typeof x === 'string') : undefined,
155
+ concurrency: j.concurrency,
156
+ assets,
157
+ };
158
+ }
159
+ catch {
160
+ return null;
161
+ }
162
+ }
163
+ /**
164
+ * 把一个资源的"功能理解"组装成精确的文生图指令(模型无关,所有 text2img 通用)。
165
+ * desc 只写主体/外观/姿态;**视角、朝向(按 orient)、构图、干净度由这里系统化补齐**,
166
+ * 不靠设计 agent 每条 desc 的发挥——这是我们可控的"指令精度"。透明抠图指令在 generateImage 里再补。
167
+ */
168
+ function buildPrompt(a, style, view, artPrompts) {
169
+ const parts = [a.desc, style, view];
170
+ const t = a.type ?? '';
171
+ const isUI = t.startsWith('ui');
172
+ const isFg = t.startsWith('scene/foreground');
173
+ const isBg = !isFg && t.startsWith('scene/');
174
+ const isSheet = !!(a.sheet && a.sheet.cols >= 1 && a.sheet.rows >= 1);
175
+ if (isSheet && !isUI) {
176
+ // 图集(多个元素一张图):AI 常把 desc 里的"网格"理解成"画出格子线",把网格线/边框烤进图 →
177
+ // 游戏里精灵带框 + packSheet 的透明缝隙切分被带偏(gmqt87mec 事故)。这里系统化禁掉:只画元素本身、
178
+ // 元素间是透明空隙,绝不画任何网格线/边框/分隔线/底色。布局信息(cols×rows)给到、但与"画线"切开。
179
+ const n = a.sheet?.count ?? (a.sheet ? a.sheet.cols * a.sheet.rows : 0);
180
+ // 布局是通用的(cols×rows 由 manifest 定);措辞归产品(随请求下发的 artPrompts)。
181
+ parts.push(`共 ${n} 个独立元素,按 ${a.sheet?.cols}×${a.sheet?.rows} 均匀排开、每个居中等大、彼此留充足空隙;` +
182
+ '纯透明背景,元素之间只是透明空隙;' +
183
+ artPrompts.sheet);
184
+ }
185
+ else if (isUI) {
186
+ // UI 控件资产:系统化注入"决定生图质量"的游戏 UI 提示词(像真实可点控件 + 强层级 + 锐利清晰 +
187
+ // 纯件透明便于切分 + 不烤文字 + 负面词),不靠设计 agent 每条 desc 自觉。文案由引擎渲染。
188
+ parts.push(artPrompts.ui);
189
+ }
190
+ else if (isFg) {
191
+ // 前景视差层(docs/25 多层):透明底靠色键抠品红 → 辉光/半透明羽化与背景混色物理上抠不净
192
+ //(星际穿梭 fg 品红残留事故)。强制硬边剪影 + 贴边构图。
193
+ parts.push(artPrompts.foreground);
194
+ }
195
+ else if (isBg) {
196
+ // 横竖版构图方向(竖版游戏的背景要竖向延展,横图压竖屏必拉伸损失)
197
+ const dim = /(\d+)\s*x\s*(\d+)/.exec(a.size ?? '');
198
+ const ratio = dim ? Number(dim[1]) / Number(dim[2]) : 0;
199
+ if (ratio && ratio < 0.77)
200
+ parts.push('竖版画幅,竖向构图、上下延展(天空在上/地面在下之类的纵深层次)');
201
+ else if (ratio > 1.3)
202
+ parts.push('横版画幅,横向构图、左右延展');
203
+ parts.push(artPrompts.background);
204
+ }
205
+ else {
206
+ // 朝向/动作 ↔ 游戏逻辑强关联,按 orient 给死基线(代码再翻转/旋转);
207
+ // rotate 必须沿指向轴镜像对称、轴心在底,否则旋转起来歪。
208
+ if (a.orient === 'flipX')
209
+ parts.push('严格正侧面,面朝画面右侧(facing right)');
210
+ else if (a.orient === 'rotate')
211
+ parts.push('主体朝正上方,沿垂直中轴左右镜像对称,旋转轴心在底部中央');
212
+ else
213
+ parts.push(artPrompts.generic);
214
+ parts.push('单一主体,完整不裁切,紧凑铺满画面,四周尽量无留白,无边框,无地面阴影,无多余角色或文字');
215
+ }
216
+ return parts.filter(Boolean).join(', ');
217
+ }
218
+ /** 把 repo 相对路径的参考图读成 data URL(图生图用);缺失/越界/读失败返回 undefined。 */
219
+ function refToDataUrl(repoDir, ref) {
220
+ if (!ref)
221
+ return undefined;
222
+ try {
223
+ const p = join(repoDir, ref); // ref 是 repo 相对路径(如 docs/refs/x.png)
224
+ if (!withinDir(repoDir, p) || !existsSync(p))
225
+ return undefined; // 防越界 + 缺失
226
+ const ext = (ref.split('.').pop() || 'png').toLowerCase();
227
+ const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : ext === 'webp' ? 'image/webp' : 'image/png';
228
+ return `data:${mime};base64,${readFileSync(p).toString('base64')}`;
229
+ }
230
+ catch {
231
+ return undefined;
232
+ }
233
+ }
234
+ /**
235
+ * 写图集描述文件 `<id>.json`(TexturePacker JSONHash 格式)。
236
+ * Phaser/Pixi 可直接 load.atlas 原生加载;Kaplay 读 frames 映射。帧名 `<id>_0..<id>_N`(行优先=manifest 顺序)。
237
+ */
238
+ // ══════════════════════════════════════════════════════════════════════════
239
+ // 🔴 **这里曾经有音频**(`AUDIO_LIBRARY` + `pickLibraryBgm`:BGM 生成失败时从公共音频库取替代)。
240
+ //
241
+ // 本版**音频留 studio**(2.8):它的 `audioGen` 要 ffmpeg-static + spawnSync,
242
+ // 而音频库在 coral 家(`DATA_DIR/audio-library`,sps 只读)—— 搬它是**另一次迁移的量**。
243
+ // driver 按能力派发,audio 在**资产边界就明确跳过并回报**(不是 filter 抹掉),所以
244
+ // sps 侧的音频代码永远走不到 ⇒ 留着它才是半途:两个 tsc 缺口(`audioGen`/`paths`)
245
+ // 其实是**同一件事**,而它们让整个文件连 import 都进不来、测试无从写起。
246
+ //
247
+ // ⚠️ **音频后续同样归 sps**(Eddy 已定)。那天不是"重新发明音频",是**按能力补一个 case**:
248
+ // `generateAsset({ capability: 'music' })` + 这里补回库兜底。
249
+ // 留这段注释是因为 —— **"这里什么都不做"和"这里从来没有过"是两回事,而代码默认表达后者。**
250
+ // ══════════════════════════════════════════════════════════════════════════
251
+ // 占位色板:高区分度、编号可读(worker 开工即接入,真图生成完原地替换)。
252
+ const PLACEHOLDER_COLORS = ['#e74c3c', '#3498db', '#2ecc71', '#f1c40f', '#9b59b6', '#e67e22', '#1abc9c', '#fd79a8', '#95a5a6', '#8e44ad', '#d35400', '#16a085'];
253
+ // 🔴 占位清单 docs/placeholder-assets.json:记录哪些 assets/<id> 当前是占位版。
254
+ // 生成器把「占位」视同「缺失」(否则占位文件会骗过 kept 判定 → 真图永远不生成的回归);
255
+ // 每个 id 真图生成成功后从清单移除。文件缺失/坏 → 空集(无占位,行为同旧)。
256
+ const PLACEHOLDER_MARKER = 'placeholder-assets.json';
257
+ export function readPlaceholderIds(repoDir) {
258
+ try {
259
+ const j = JSON.parse(readFileSync(join(repoDir, 'docs', PLACEHOLDER_MARKER), 'utf-8'));
260
+ return new Set(Array.isArray(j.ids) ? j.ids : []);
261
+ }
262
+ catch {
263
+ return new Set();
264
+ }
265
+ }
266
+ function writePlaceholderIds(repoDir, ids) {
267
+ try {
268
+ const f = join(repoDir, 'docs', PLACEHOLDER_MARKER);
269
+ if (ids.size === 0) {
270
+ if (existsSync(f))
271
+ unlinkSync(f);
272
+ return;
273
+ }
274
+ mkdirSync(join(repoDir, 'docs'), { recursive: true });
275
+ writeFileSync(f, JSON.stringify({ ids: [...ids] }));
276
+ }
277
+ catch { /* 标记写失败不致命:最坏=当占位为真资产,回到旧行为 */ }
278
+ }
279
+ /**
280
+ * 🔴 占位资产:建卡瞬间按 art-manifest 确定性生成"同名、同帧结构"的编号色块图(+ 音频库 BGM),
281
+ * 让 worker 从第 0 秒就能按正式写法接入(load.atlas 按帧/load.image/load.audio),零等待开写;
282
+ * 真图生成完由 finalizeAsset 写到相同路径【原地替换】,代码零改动(帧名 <id>_<i> 两边同构)。
283
+ * 治的是实测 4~5 分钟的"worker 等资产才动笔"(gmrdj9udt:13:23 跑 wait-assets、13:27 才写第一行)。
284
+ * 幂等:目标文件已存在(续跑/用户 provided)一律不动。全程 best-effort,失败不阻断构建。
285
+ */
286
+ /**
287
+ * @param only 只写这一类的占位(2.8)。
288
+ * 🔴 **这条最容易漏**:切换后视觉占位归 sps,而**音频占位仍归 studio** ——
289
+ * 漏了的话 `ART_DRIVER=sps` 之后占位 BGM 不再产出,
290
+ * 症状是"游戏没有 BGM",**不是报错**。
291
+ * (真图生成的派发点和占位这条是**两个**位置,改一个不够。)
292
+ */
293
+ export async function writePlaceholderAssets(repoDir, manifest, logical, only) {
294
+ const assetsDir = join(repoDir, 'assets');
295
+ mkdirSync(assetsDir, { recursive: true });
296
+ const marks = readPlaceholderIds(repoDir); // 累积(续跑幂等):真图生成成功时由 generateGameArt 移除
297
+ for (const a of manifest.assets) {
298
+ if (only && assetKind(a) !== only)
299
+ continue;
300
+ try {
301
+ const t = (a.type ?? '').toLowerCase();
302
+ // 🔴 音频占位仍归 studio(2.8 特意点名的"两处别漏"之一)—— sps 本版不写 .mp3。
303
+ // 症状警示原样留着:漏了占位 BGM 的表现是"游戏没有 BGM",**不是报错**。
304
+ // 音频归 sps 那天,在这里补回"从公共音频库取一首"的分支。
305
+ if (t.startsWith('audio'))
306
+ continue;
307
+ if (a.provided || existsSync(join(assetsDir, `${a.id}.png`)))
308
+ continue;
309
+ marks.add(a.id);
310
+ if (t === 'ui/kit') {
311
+ // 🔴 UI 契约套件:占位直接用灰阶线稿本体(结构=真图同契约、帧名固定)→ worker 从第 0 秒按语义帧名接入;
312
+ // 真图生成完原地替换,坐标/帧名不变。
313
+ {
314
+ const wire = await renderUiWireframe();
315
+ try {
316
+ const rp = await fitAndRepackUiKit(wire);
317
+ const m = await sharp(rp.buf).metadata();
318
+ writeFileSync(join(assetsDir, `${a.id}.png`), rp.buf);
319
+ writeAtlasJsonNamed(assetsDir, a.id, {
320
+ frames: rp.frames.map((f) => ({ x: f.x, y: f.y, w: f.w, h: f.h })),
321
+ width: m.width ?? 0, height: m.height ?? 0,
322
+ }, rp.frames.map((f) => f.name));
323
+ }
324
+ catch {
325
+ // 重组失败 → 线稿原图 + 契约矩形直切
326
+ writeFileSync(join(assetsDir, `${a.id}.png`), wire);
327
+ writeAtlasJsonNamed(assetsDir, a.id, {
328
+ frames: UI_KIT_FRAMES.map((f) => ({ x: f.x, y: f.y, w: f.w, h: f.h })),
329
+ width: UI_KIT_CANVAS.w, height: UI_KIT_CANVAS.h,
330
+ }, UI_KIT_FRAMES.map((f) => f.name));
331
+ }
332
+ }
333
+ continue;
334
+ }
335
+ if (t.startsWith('scene') || t.startsWith('bg')) {
336
+ // 背景:柔和渐变 + 资产名水印,尺寸=逻辑分辨率。
337
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${logical.w}" height="${logical.h}">` +
338
+ `<defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#2c3e70"/><stop offset="1" stop-color="#1a1a2e"/></linearGradient></defs>` +
339
+ `<rect width="${logical.w}" height="${logical.h}" fill="url(#g)"/>` +
340
+ `<text x="${logical.w / 2}" y="${logical.h / 2}" font-size="28" font-family="sans-serif" fill="#ffffff55" text-anchor="middle">${a.id}(占位,真图生成中)</text></svg>`;
341
+ await sharp(Buffer.from(svg)).png().toFile(join(assetsDir, `${a.id}.png`));
342
+ continue;
343
+ }
344
+ // 精灵/UI:帧高按平台归一化档位(与真图一致 → 尺寸契约从第 0 秒就成立);图集帧名 <id>_<i> 与 finalizeAsset 同构。
345
+ const frameH = targetMaxH(a.type, logical.h);
346
+ const count = Math.max(1, a.sheet ? (a.sheet.count ?? a.sheet.cols * a.sheet.rows) : 1);
347
+ const cols = a.sheet?.cols ?? 1;
348
+ const rows = Math.ceil(count / cols);
349
+ const GAP = 2;
350
+ const W = cols * frameH + (cols - 1) * GAP;
351
+ const H = rows * frameH + (rows - 1) * GAP;
352
+ let cells = '';
353
+ const frames = [];
354
+ for (let i = 0; i < count; i++) {
355
+ const x = (i % cols) * (frameH + GAP);
356
+ const y = Math.floor(i / cols) * (frameH + GAP);
357
+ const color = PLACEHOLDER_COLORS[i % PLACEHOLDER_COLORS.length];
358
+ cells +=
359
+ `<rect x="${x + 2}" y="${y + 2}" width="${frameH - 4}" height="${frameH - 4}" rx="${Math.round(frameH * 0.18)}" fill="${color}" stroke="#ffffff" stroke-width="2"/>` +
360
+ `<text x="${x + frameH / 2}" y="${y + frameH / 2 + frameH * 0.12}" font-size="${Math.round(frameH * 0.34)}" font-family="sans-serif" font-weight="bold" fill="#fff" text-anchor="middle">${i}</text>`;
361
+ frames.push({ x, y, w: frameH, h: frameH });
362
+ }
363
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}">${cells}</svg>`;
364
+ await sharp(Buffer.from(svg)).png().toFile(join(assetsDir, `${a.id}.png`));
365
+ if (a.sheet)
366
+ writeAtlasJson(assetsDir, a.id, { frames, width: W, height: H });
367
+ }
368
+ catch { /* 单个占位失败不影响其它 */ }
369
+ }
370
+ writePlaceholderIds(repoDir, marks);
371
+ }
372
+ /**
373
+ * 造 TexturePacker atlas 对象(**只造,不写盘**)。两个 write 函数原来各内联一份,现在共用这里。
374
+ *
375
+ * 🔴 `imageFile` 是**参数,不是从 id 推的**(2026-08-11,sps 报的潜伏 bug)——
376
+ * `meta.image` 要回答的是「**引擎该去加载哪个文件**」,而只有**写图的那个人**知道图去了哪。
377
+ * 从 id 推,等于假设「图一定叫 <id>.png」。
378
+ *
379
+ * 在 studio 这个假设**碰巧成立**:`writeAsset` 走 `assetPath()`,扩展名钉死 `.png`、
380
+ * 与 atlas 同 id 同目录(`repo-shared.ts:20`)。所以本仓从来没撞过。
381
+ * 但**碰巧成立的前提不会跟着调用方走** —— sps 的 CLI 面加了与 `--id` 解耦的 `--out`,
382
+ * 只要 out 不是 `<assets-dir>/<id>.png`,atlas 就指向一个不存在的文件:
383
+ * **结构合法、无报错、加载器找不到图。**
384
+ *
385
+ * 🔴 这正是本函数注释里那条教条自己被违反的例子:
386
+ * **对引擎的声明要描述「引擎将看到的东西」,不是「我们做过什么」。**
387
+ * `meta.image` 描述的必须是图的实际去处。
388
+ *
389
+ * ⚠️ 形状值得记:`id` 在这里身兼三职 —— 帧名前缀 / 引用的图名 / 落盘路径。
390
+ * `--out` 只接管了第三职,**另外两职静默留在原处**。
391
+ * **拆一个身兼多职的参数时,没被接管的那几职会静默错。**
392
+ *
393
+ * @param imageFile 图的实际文件名(相对 atlas 所在目录),如 `hero.png`
394
+ * @param framePrefix 帧名前缀(`<prefix>_0`…);`names` 给了就按 names 命名
395
+ */
396
+ export function buildAtlas(imageFile, framePrefix, packed, names) {
397
+ const frames = {};
398
+ packed.frames.forEach((f, i) => {
399
+ frames[names?.[i] ?? `${framePrefix}_${i}`] = {
400
+ frame: { x: f.x, y: f.y, w: f.w, h: f.h },
401
+ // 🔴 已在 sliceSheet 裁到紧致 bbox → 这里必须声明 false 且偏移 0,
402
+ // 否则引擎会**再补一次偏移**(atlas 合法、每帧偏一点)。
403
+ rotated: false,
404
+ trimmed: false,
405
+ spriteSourceSize: { x: 0, y: 0, w: f.w, h: f.h },
406
+ sourceSize: { w: f.w, h: f.h },
407
+ };
408
+ });
409
+ return { frames, meta: { image: imageFile, format: 'RGBA8888', size: { w: packed.width, h: packed.height }, scale: '1' } };
410
+ }
411
+ /** 具名帧版(UI 契约用):帧名由调用方给定(如 ui_btn_lg),不走 <id>_<i> 序号。 */
412
+ export function writeAtlasJsonNamed(assetsDir, id, packed, names) {
413
+ // studio 侧图恒为 `<assetsDir>/<id>.png`(assetPath 钉死扩展名)—— 这个推导在**这里**是真的。
414
+ writeFileSync(join(assetsDir, `${id}.json`), JSON.stringify(buildAtlas(`${id}.png`, id, packed, names)));
415
+ }
416
+ export function writeAtlasJson(assetsDir, id, packed) {
417
+ writeFileSync(join(assetsDir, `${id}.json`), JSON.stringify(buildAtlas(`${id}.png`, id, packed)));
418
+ }
419
+ /** 在紧凑图集每帧左上角画红色序号(0,1,2…),供视觉打标签时显式对齐索引。 */
420
+ async function numberedPreview(packed) {
421
+ const texts = packed.frames
422
+ .map((f, i) => `<text x="${f.x + 2}" y="${f.y + 14}" font-size="14" font-family="sans-serif" font-weight="bold" fill="#ff0000" stroke="#ffffff" stroke-width="0.6">${i}</text>`)
423
+ .join('');
424
+ const svg = `<svg width="${packed.width}" height="${packed.height}">${texts}</svg>`;
425
+ return sharp(packed.buf).composite([{ input: Buffer.from(svg), top: 0, left: 0 }]).png().toBuffer();
426
+ }
427
+ /**
428
+ * 写「美术就绪」信号 `docs/asset-ready.json`(供构建 worker 的 wait-assets 脚本轮询)。
429
+ * generating=生成中;done=全部生成+检测完成、配置就位。worker 据此在使用点同步(美术与构建并行)。
430
+ */
431
+ /**
432
+ * 等美术就绪 —— **读工作区里的 `docs/asset-ready.json`,不等进程内 promise**。
433
+ *
434
+ * ─── 为什么换掉 `await artJobs.get(id)` ────────────────────────────────
435
+ * 美术迁进 sps 之后,studio 手里**没有那个 promise 了**。而"打包前必须等资产就位"
436
+ * 这个需求还在(`buildAndServe` → `npm run build` → smoke,缺图就 404)。
437
+ *
438
+ * 🔴 状态放在**工作区文件**里,谁来读都一样 —— 这正是 worker 一直在用的机制
439
+ * (`scripts/wait-assets.mjs` 轮询同一个文件)。**studio 挂了重启也能接着读。**
440
+ *
441
+ * ⚠️ **诚实的限定**:这不是"取消了轮询"。
442
+ * 「读文件直到 done」本质仍是循环,只是它在**打包这一步内部**,不是后台常驻。
443
+ * 准确说法:**取消了后台常驻轮询,保留了打包步骤内的一次性等待。**
444
+ *
445
+ * 🔴 **而它本身也是过渡机制**:Eddy 已定打包 + smoke 后续也迁 sps ——
446
+ * 那时生产方与消费方同侧,**这个等待自然消失**。
447
+ * 别把它当终态去加固(加重试策略、加指数退避、抽成通用 waiter……)。
448
+ *
449
+ * 语义:
450
+ * - 文件不存在 → **立刻返回**(这个项目没有美术任务,不是"还没开始")
451
+ * - `done` → 立刻返回
452
+ * - `generating` → 轮到 done 或超时
453
+ * - 超时 → **放行并大声记一笔**,不抛
454
+ * 理由:卡死打包比晚一点打包更糟;而缺图 smoke 会抓到(它自带重试)。
455
+ * 🔴 但日志必须说清"我是超时放行的",否则 smoke 挂了会被归因成 chromium 抖动 ——
456
+ * 那正是这条竞态最难查的地方。
457
+ */
458
+ export async function waitArtReady(repoDir, opts) {
459
+ const f = join(repoDir, 'docs', 'asset-ready.json');
460
+ if (!existsSync(f))
461
+ return 'absent';
462
+ const deadline = Date.now() + (opts?.timeoutMs ?? 30 * 60_000);
463
+ const interval = opts?.intervalMs ?? 2_000;
464
+ for (;;) {
465
+ try {
466
+ const j = JSON.parse(readFileSync(f, 'utf-8'));
467
+ if (j.state === 'done')
468
+ return 'done';
469
+ }
470
+ catch { /* 写到一半读到半个 JSON → 下一轮再读 */ }
471
+ if (Date.now() > deadline)
472
+ return 'timeout';
473
+ await new Promise((r) => setTimeout(r, interval));
474
+ }
475
+ }
476
+ export function writeArtReady(repoDir, state, total) {
477
+ try {
478
+ const d = join(repoDir, 'docs');
479
+ mkdirSync(d, { recursive: true });
480
+ writeFileSync(join(d, 'asset-ready.json'), JSON.stringify({ state, total }));
481
+ }
482
+ catch {
483
+ /* 写就绪信号失败不致命:worker 超时后会按现有资源降级继续 */
484
+ }
485
+ }
486
+ /**
487
+ * 把 level.json 渲染成【语义蓝图 PNG】(绿底 / 红粗折线=路 / 蓝方块=塔位 / 紫方块=base),供图生图当结构参考——
488
+ * AI 图生图会保持这张图的布局(路走向、塔位位置),精修成精美背景 → 图和坐标同源。复用 numberedPreview 的 SVG+sharp 手法。
489
+ */
490
+ export async function renderLevelBlueprint(level) {
491
+ const W = Math.max(1, level.width || 960);
492
+ const H = Math.max(1, level.height || 540);
493
+ const roadW = Math.round(Math.min(W, H) * 0.08); // 路宽 ≈ 屏 8%
494
+ const s = Math.round(Math.min(W, H) * 0.09); // 塔位方块边长
495
+ const pts = (level.path ?? []).map(([x, y]) => `${x},${y}`).join(' ');
496
+ const road = pts ? `<polyline points="${pts}" fill="none" stroke="#c0392b" stroke-width="${roadW}" stroke-linejoin="round" stroke-linecap="round"/>` : '';
497
+ const spots = (level.buildSpots ?? []).map(([x, y]) => `<rect x="${x - s / 2}" y="${y - s / 2}" width="${s}" height="${s}" rx="6" fill="#2f6fd8" stroke="#173a73" stroke-width="3"/>`).join('');
498
+ const base = level.base ? `<rect x="${level.base[0] - s}" y="${level.base[1] - s}" width="${s * 2}" height="${s * 2}" rx="8" fill="#8e44ad" stroke="#4a235a" stroke-width="4"/>` : '';
499
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}"><rect width="${W}" height="${H}" fill="#4a7c3a"/>${road}${base}${spots}</svg>`;
500
+ return sharp(Buffer.from(svg)).png().toBuffer();
501
+ }
502
+ /** 支持图生图(image-to-image)的 adapter。codex 通过多模态输入(input_image)做图像编辑;minimax/openrouter/gemini 各有图生图。火山等只文生图。 */
503
+ const IMG2IMG_ADAPTERS = new Set(['codex', 'chatgpt', 'minimax', 'openrouter', 'gemini', 'volcengine']);
504
+ /** 蓝图图生图的 prompt 补充:告诉模型参考图是【布局结构】而非风格。 */
505
+ const BLUEPRINT_HINT = '。🔴以参考图为【布局结构蓝图】(不是风格参考):红色粗线的走向=铺成石板道路/敌人行进路径,**严格保持这条路的走向和形状**;' +
506
+ '蓝色方块、紫色方块只是【位置标记】——把那些格子画成**平整的空地/草地(可微微不同色示意),绝对不要画成城堡/塔楼/建筑/任何立体物件**(大本营和防御塔由游戏程序另外放置,AI 画了会重叠冲突);' +
507
+ '其余精修成精美的草原战场地形(草地/树/石头点缀)。**只精修路径和地形,不添加任何游戏建筑/单位**。';
508
+ /** 确定性 downscale 目标高(px):按 type 语义分档、相对游戏逻辑分辨率。
509
+ * 2026-07-21 与用户定案「资源尺寸相对适配游戏」:此前一刀切 35% 屏高,小道具实际只显示 4~8% 屏高
510
+ * → 源图超出 5~8 倍(包体/加载浪费,且绝对值缩放 bug 一触发就是好几倍的巨物)。
511
+ * 按 manifest type 的语义定档(设计时已知),各档保留 1.5~3× 余量(TUNABLES 调参/二修放大仍清晰):
512
+ * 角色/主角/Boss(显示 10~18% 屏高)→ 30%;敌人/混合图集(含主角的合图)→ 24%;
513
+ * 道具/元素/特效/子弹(显示 4~8%)→ 16%;UI(非契约套件)→ 22%;背景整屏。 */
514
+ function targetMaxH(type, logicalH) {
515
+ const t = (type ?? '').toLowerCase();
516
+ if (t.startsWith('scene') || t.startsWith('bg'))
517
+ return logicalH;
518
+ if (t.startsWith('ui'))
519
+ return Math.round(logicalH * 0.22);
520
+ if (/character|player|hero|boss|主角/.test(t))
521
+ return Math.round(logicalH * 0.3);
522
+ if (/element|item|effect|projectile|bullet|pickup|collect|prop|道具/.test(t))
523
+ return Math.round(logicalH * 0.16);
524
+ return Math.round(logicalH * 0.24); // enemy / sprite/sheet(混合合图,可能含主角)/未细分
525
+ }
526
+ /**
527
+ * 成品落盘前统一成 PNG(**#19d**,Eddy 2026-08-12 拍板的修法②)。
528
+ *
529
+ * 🔴 **这不是顺手优化,是在本次迁移唯一的防漂移网上有意开的一个口子。**
530
+ * 在此之前,产物的真实格式**随 provider 漂**:1.11b 真跑实测 doubao 返回 JPEG、
531
+ * agnes 返回 PNG,而 `<id>.png` 这个名字是写死的 ⇒ 文件名与内容可以不符,
532
+ * 且 JPEG **没有 alpha 通道**。它同时取决于哪家 provider 与这张资产 transparent 开没开。
533
+ * 归因:**本来就坏** —— studio 冻结基线 `imageGen.ts:733/:800` 同样不转码,逐字移植搬了过来。
534
+ *
535
+ * 🔴 **已知代价(拍板时已接受)**:「逐像素与 studio 一致」这条判据在**无 `logical` 的项目**上
536
+ * 不再成立 —— studio 那边不转。一致性测试(studio `artConsistency.test.ts`)若因此在无 logical
537
+ * 夹具上转红,**那是预期的**:要改就改断言并写明理由,**不要改夹具让它变绿**。
538
+ * (该测试在 4.2 会整体删掉,但**现在还不能删**。)
539
+ *
540
+ * ⚠️ 成本:只在**不是 PNG 时**多一次编码。有 `logical` 的项目(gameforge 的 `game.json` 有宽高)
541
+ * 末尾那次 `resize(...).png()` 本就已经转过,此处是 metadata 一次读、直接返回原 buf。
542
+ */
543
+ async function ensureAssetPng(buf) {
544
+ try {
545
+ if ((await sharp(buf).metadata()).format === 'png')
546
+ return buf;
547
+ return await sharp(buf).png().toBuffer();
548
+ }
549
+ catch {
550
+ // 🔴 失败一律保守(与本文件其它 catch 同取向):转不动也不能毁掉产物。
551
+ // 代价是退回"名叫 .png 的非 PNG",但那正是改这条之前的行为,不会更糟。
552
+ return buf;
553
+ }
554
+ }
555
+ // (导出供修复脚本复用:从 _raw 原图重跑「抠图→切帧→归一→打包」链,修复被旧抠图算法损坏的成品资产)
556
+ export async function finalizeAsset(buf, a, assetsDir, source, rawBuf, rawOnly = false, logical) {
557
+ // 🔴 留底【AI 直出原图】到 assets/_raw/<id>.png:优先用 rawBuf(抠图/缩放【前】、含品红底的真原图);
558
+ // 缺则退回 buf。下游后处理(抠图/图集切分/透明裁边/翻转)有损,原图必须可追溯/可恢复/可重切。
559
+ // 用户明确要求:_raw 要是 AI 首次返回、带品红背景、未经任何处理的图。
560
+ try {
561
+ const rawDir = join(assetsDir, '_raw');
562
+ mkdirSync(rawDir, { recursive: true });
563
+ const src = rawBuf ?? buf;
564
+ // 统一编码成 PNG 保证可预览(只换容器、不动像素,品红底保留)。
565
+ const png = await sharp(src).png().toBuffer().catch(() => src);
566
+ writeFileSync(join(rawDir, `${a.id}.png`), png);
567
+ }
568
+ catch { /* 留底失败不阻断主流程 */ }
569
+ // rawOnly:美术流水线的「生成棒」用——只落原图,把抠图/切分/裁边/翻转全留给后面的「处理 agent」,避免与之重叠打架。
570
+ if (rawOnly) {
571
+ // 🔴 成品落盘一律走原子写(1.10):幂等的判据是"目标文件存在就跳过",
572
+ // 直写最终名时任何一次中断都会留下顶着最终名的半截文件 → 下一轮当它已完成。
573
+ // #19d:这条路同样写成品 `<id>.png`(rawOnly 只是不做抠图/切分,不是"可以名不副实")。
574
+ writeAssetAtomic(join(assetsDir, `${a.id}.png`), await ensureAssetPng(buf));
575
+ return { id: a.id, ok: true, source };
576
+ }
577
+ let outBuf = buf;
578
+ let meta;
579
+ // 🔴 前景视差层验收:中央区(60%×60%)不透明占比 >35% = 模型把剪影画成了盖住玩法区的大块
580
+ // (星际穿梭 fg 巨舰事故;prompt 只有劝导力)→ 抛错让 genOne 换 provider 重试;全败由
581
+ // genOne 落【全透明整幅】优雅降级(fg 是增强层,缺了游戏无感)。
582
+ if ((a.type ?? '').toLowerCase().startsWith('scene/foreground')) {
583
+ try {
584
+ const { data, info } = await sharp(buf).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
585
+ const W = info.width, H = info.height;
586
+ const x0 = Math.round(W * 0.2), x1 = Math.round(W * 0.8), y0 = Math.round(H * 0.2), y1 = Math.round(H * 0.8);
587
+ let n = 0, op = 0;
588
+ for (let y = y0; y < y1; y += 2)
589
+ for (let x = x0; x < x1; x += 2) {
590
+ n++;
591
+ if (data[(y * W + x) * 4 + 3] > 40)
592
+ op++;
593
+ }
594
+ const ratio = op / Math.max(1, n);
595
+ if (ratio > 0.35)
596
+ throw new Error(`fg-center-covered: 前景层中央区不透明 ${Math.round(ratio * 100)}%(>35%),会盖住玩法区,拒用`);
597
+ }
598
+ catch (e) {
599
+ if (e instanceof Error && e.message.startsWith('fg-center-covered'))
600
+ throw e;
601
+ /* 读图失败 → 放行(别为校验挂正常资产) */
602
+ }
603
+ }
604
+ const isUiKit = (a.type ?? '') === 'ui/kit';
605
+ if (isUiKit) {
606
+ // 🔴 UI 线稿契约(uiKit.ts):帧矩形生成前已知 → 图集 json 直接按契约写(帧名全平台固定),
607
+ // 跳过缝隙切分与视觉打标。先把成图精确拉回画布尺寸(provider 可能按自家档位回图,
608
+ // 坐标契约以画布为基准,fit:fill 保契约优先);下游 trim/downscale 对 ui/kit 一律豁免(会移坐标)。
609
+ try {
610
+ outBuf = await sharp(outBuf).resize(UI_KIT_CANVAS.w, UI_KIT_CANVAS.h, { fit: 'fill' }).png().toBuffer();
611
+ }
612
+ catch { /* resize 失败保持原图:若 provider 恰好同尺寸仍可用 */ }
613
+ // v1.3 裁切重组:bbox 逐帧裁出(搜索窗=邻帧半距)→ 紧凑重排(尺寸=实际内容 + 挤掉空隙)。
614
+ // 失败退回契约矩形直切(图保持画布尺寸)。
615
+ let fit = UI_KIT_FRAMES.map((f) => ({ name: f.name, x: f.x, y: f.y, w: f.w, h: f.h }));
616
+ let outW = UI_KIT_CANVAS.w, outH = UI_KIT_CANVAS.h;
617
+ try {
618
+ const rp = await fitAndRepackUiKit(outBuf);
619
+ outBuf = rp.buf;
620
+ fit = rp.frames;
621
+ const m = await sharp(outBuf).metadata();
622
+ outW = m.width ?? outW;
623
+ outH = m.height ?? outH;
624
+ }
625
+ catch (e) {
626
+ // 🔴 结构失守 = 这张图不能切(位置锚定切分会把错位控件切成几块)→ 上抛,genOne 按
627
+ // provider 失败处理(换下家重试,链全败落线稿兜底)。其余异常退回契约矩形直切。
628
+ if (e instanceof Error && e.message.includes(UIKIT_STRUCTURE_BROKEN))
629
+ throw e;
630
+ }
631
+ const packed = {
632
+ buf: outBuf,
633
+ frames: fit.map((f) => ({ x: f.x, y: f.y, w: f.w, h: f.h })),
634
+ width: outW, height: outH, detected: true,
635
+ };
636
+ writeAtlasJsonNamed(assetsDir, a.id, packed, fit.map((f) => f.name));
637
+ meta = { frames: UI_KIT_FRAMES.map((f) => ({ name: f.name, label: f.label })) };
638
+ }
639
+ else if (a.sheet && a.sheet.cols >= 1 && a.sheet.rows >= 1) {
640
+ // 成套图集:切出每元素 → 紧凑重排成图集 + 写同名 .json(TexturePacker)→ 构建端 load.atlas。
641
+ try {
642
+ const packed = await packSheet(buf, a.sheet.cols, a.sheet.rows, logical ? { frameMaxHeight: targetMaxH(a.type, logical.h) } : undefined);
643
+ outBuf = packed.buf;
644
+ writeAtlasJson(assetsDir, a.id, packed);
645
+ const numbered = await numberedPreview(packed);
646
+ const insp = await inspectSheetLabels(`data:image/png;base64,${numbered.toString('base64')}`, packed.frames.length, a.desc);
647
+ const labels = insp.labels;
648
+ // 🔴 up/down 判定的几何护栏(Iron Wings 实测:俯视飞机全部朝右,视觉却误判 up/down,
649
+ // 烤转把对图转错)。机头真朝上/下的帧几乎必然"高>宽"(雷霆战机 4 帧全 h>w 实证);
650
+ // 判 up/down 但形状横扁 = 存疑,拒转保原图——误放行(不转)的代价远小于误转。
651
+ for (let i = 0; i < packed.frames.length; i++) {
652
+ const fc = insp.facing[i];
653
+ if ((fc === 'up' || fc === 'down') && packed.frames[i].h < packed.frames[i].w * 1.1) {
654
+ console.warn(`⚠️ 图集 ${a.id}:帧 ${i} 视觉判 ${fc} 但形状横扁(${packed.frames[i].w}x${packed.frames[i].h}),存疑不转`);
655
+ insp.facing[i] = 'none';
656
+ }
657
+ }
658
+ // 🔴 逐帧朝向纠正(平台契约:角色美术统一面朝右):left 水平翻转;up/down 旋转 90° 归一
659
+ // (竖版飞机大战实测:desc 写"机头朝上"模型照样画横的、偶尔真画竖的——四向不归一,
660
+ // worker 的固定校正角就永远不可靠)。重建整张画布逐帧 composite(不能只叠改动帧:
661
+ // 透明区 over 混合会留下原像素的叠影)。
662
+ const fixIdx = packed.frames.map((_, i) => i).filter((i) => insp.facing[i] === 'left' || insp.facing[i] === 'up' || insp.facing[i] === 'down');
663
+ if (fixIdx.length) {
664
+ try {
665
+ const parts = [];
666
+ for (let i = 0; i < packed.frames.length; i++) {
667
+ const f = packed.frames[i];
668
+ const fc = insp.facing[i];
669
+ const region = await sharp(outBuf).extract({ left: f.x, top: f.y, width: f.w, height: f.h }).png().toBuffer();
670
+ if (fc === 'up' || fc === 'down') {
671
+ // 机头朝上→顺时针 90° 变朝右;朝下→逆时针。旋转后宽高互换,超出原格子就等比缩回、居中放置。
672
+ let rw = f.h, rh = f.w;
673
+ let img = sharp(region).rotate(fc === 'up' ? 90 : 270);
674
+ if (rw > f.w || rh > f.h) {
675
+ const s = Math.min(f.w / rw, f.h / rh);
676
+ rw = Math.max(1, Math.floor(rw * s));
677
+ rh = Math.max(1, Math.floor(rh * s));
678
+ img = sharp(await img.png().toBuffer()).resize(rw, rh);
679
+ }
680
+ parts.push({ input: await img.png().toBuffer(), left: f.x + Math.floor((f.w - rw) / 2), top: f.y + Math.floor((f.h - rh) / 2) });
681
+ }
682
+ else if (fc === 'left') {
683
+ parts.push({ input: await sharp(region).flop().png().toBuffer(), left: f.x, top: f.y });
684
+ }
685
+ else {
686
+ parts.push({ input: region, left: f.x, top: f.y });
687
+ }
688
+ }
689
+ outBuf = await sharp({ create: { width: packed.width, height: packed.height, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } })
690
+ .composite(parts).png().toBuffer();
691
+ console.warn(`⚠️ 图集 ${a.id}:${fixIdx.length} 帧朝向已归一为面朝右(帧 ${fixIdx.map((i) => `${i}:${insp.facing[i]}`).join(',')})`);
692
+ }
693
+ catch { /* 纠正失败保持原图(朝向错但图不坏) */ }
694
+ }
695
+ // 自动合图的 sheet:即使视觉打标为空,也把 mergedFrom 的原语义 id 按帧序记进 asset-meta(semanticId),worker 据此映射回原语义。
696
+ // facing = 归一后的朝向落进配置(worker 可据此精确决定哪些帧要按场景校正角度):
697
+ // 'right' = 有明确朝向、已归一为面朝右(竖版射击等场景需 setAngle 校正);'none' = 对称/无方向物件,免转。
698
+ const semIds = a.mergedFrom;
699
+ if (labels.length || semIds?.length)
700
+ meta = { frames: packed.frames.map((_, i) => ({
701
+ name: `${a.id}_${i}`,
702
+ label: labels[i] ?? '',
703
+ ...(insp.facing[i] ? { facing: insp.facing[i] === 'none' ? 'none' : 'right' } : {}),
704
+ ...(semIds?.[i] ? { semanticId: semIds[i] } : {}),
705
+ })) };
706
+ }
707
+ catch {
708
+ outBuf = buf; // 打包失败 → 退回原图(下游仍可按 cols/rows 等分)
709
+ }
710
+ }
711
+ else if (a.orient === 'flipX') {
712
+ // flipX 侧面精灵:约定"面朝右",模型/用户画成面朝左 → 视觉判出后水平翻转。失败/unknown 不动。
713
+ try {
714
+ if ((await detectFacing(`data:image/png;base64,${outBuf.toString('base64')}`)) === 'left') {
715
+ outBuf = await sharp(outBuf).flop().png().toBuffer();
716
+ }
717
+ }
718
+ catch { /* 检测/翻转失败 → 保持原图 */ }
719
+ }
720
+ // 单图透明精灵:裁掉四周透明边距,主体填满画框 → hitbox 才准。
721
+ // 🔴 场景层(scene/*:前景 fg 等透明整幅)不 trim:透明区是【构图】(草丛贴下缘、上方留空),
722
+ // 裁掉就只剩条带,上屏铺满时被拉伸成巨物(像素跑酷 fg_near 事故第二环)。
723
+ const isSceneLayer = (a.type ?? '').toLowerCase().startsWith('scene');
724
+ if (!isUiKit && !isSceneLayer && !a.sheet && a.transparent === true) {
725
+ try {
726
+ outBuf = await sharp(outBuf).trim().png().toBuffer();
727
+ }
728
+ catch { /* 全透明/裁剪失败 → 保持原图 */ }
729
+ }
730
+ // 确定性 downscale(压缩尺寸):单图(背景=整屏、其余按 type 档)缩到合理大小。图集在 packSheet 的 frameMaxHeight 已缩。
731
+ if (!isUiKit && !a.sheet && logical) {
732
+ try {
733
+ const t = (a.type ?? '').toLowerCase();
734
+ const opt = t.startsWith('scene') || t.startsWith('bg')
735
+ ? { width: logical.w, height: logical.h, fit: 'inside', withoutEnlargement: true }
736
+ : { height: targetMaxH(a.type, logical.h), fit: 'inside', withoutEnlargement: true };
737
+ outBuf = await sharp(outBuf).resize(opt).png().toBuffer();
738
+ }
739
+ catch { /* 缩放失败 → 保持原图 */ }
740
+ }
741
+ // #19d:落盘前统一成 PNG —— 末尾那次 resize().png() 只在有 logical 时执行,
742
+ // 没有它时上游给什么格式就写什么(1.11b 实测:doubao 给 JPEG)。
743
+ writeAssetAtomic(join(assetsDir, `${a.id}.png`), await ensureAssetPng(outBuf)); // 见上:成品一律原子写
744
+ return { id: a.id, ok: true, source, meta };
745
+ }
746
+ /** 生成单张资源(含尺寸+抠图)→ 存盘。**429/限流/5xx/超时 自动指数退避重试**(适配 API QPS 限制)。
747
+ *
748
+ * ⚠️ **导出仅为测试**:这段是「改了不会有任何测试发现」清单的所在
749
+ * (退避阶梯 / 失败分类 / 回退留痕 / 三条降级),而它今天唯一的网就是 `genOne.test.ts`。 */
750
+ export async function genOne(providers, a, style, view, ctx, referenceDataUrl, rawOnly = false, logical) {
751
+ const { assetsDir, repoDir } = ctx;
752
+ // 蓝图图生图:prompt 追加"按参考图布局"说明(仅当 ref 是关卡蓝图);其余 ref(风格锚定)不加。
753
+ const isBlueprint = !!referenceDataUrl && !!a.ref?.includes('level-blueprint');
754
+ // 🔴 UI 契约套件:prompt 整体换成 uiKitPrompt(结构靠线稿参考图锁死,这里只注风格),不走 buildPrompt。
755
+ const prompt = (a.type ?? '') === 'ui/kit'
756
+ ? uiKitPrompt([style, a.desc].filter(Boolean).join(';'))
757
+ : buildPrompt(a, style, view, ctx.artPrompts) + (isBlueprint ? BLUEPRINT_HINT : '');
758
+ let lastErr = '';
759
+ // 🔴 图生图必须用支持图生图的 provider(minimax/openrouter/gemini);默认 codex/火山不支持会退化成文生图、蓝图白给。
760
+ // 有 referenceDataUrl 时把供应商链重排成"支持图生图的优先";一个都没配 → warn、退回文生图不崩。
761
+ let provs = providers;
762
+ if (referenceDataUrl) {
763
+ provs = [...providers].sort((x, y) => (IMG2IMG_ADAPTERS.has(y.adapter) ? 1 : 0) - (IMG2IMG_ADAPTERS.has(x.adapter) ? 1 : 0));
764
+ if (!provs.some((p) => IMG2IMG_ADAPTERS.has(p.adapter))) {
765
+ // 🔴 UI 契约套件:结构 100% 依赖图生图。纯文生图必然自由排版 → 契约切分错乱(画卷飞剑事故:
766
+ // 远程无 codex、火山未接图生图 → ui 布局乱 + 裁切碎)。无图生图 provider 时直接用线稿当成品
767
+ // (灰阶但结构/坐标全对,可玩;下轮配好 provider 重生成即可换风格)。
768
+ if ((a.type ?? '') === 'ui/kit') {
769
+ console.warn(`⚠️ 资产 ${a.id}(ui/kit):未配任何图生图 provider,风格化降级为灰阶线稿本体(结构优先)`);
770
+ try {
771
+ const wire = await renderUiWireframe();
772
+ const st = await finalizeAsset(wire, a, assetsDir, 'generated', wire, rawOnly, logical);
773
+ st.provider = 'uikit-wireframe/降级';
774
+ return st;
775
+ }
776
+ catch { /* 线稿失败 → 继续走文生图(最后手段) */ }
777
+ }
778
+ console.warn(`⚠️ 资产 ${a.id} 需图生图但未配支持的 provider(codex/minimax/openrouter/gemini/volcengine),退回文生图`);
779
+ }
780
+ }
781
+ // 供应商回退链:首选失败 → 依次换其它。内容审核拒绝换家常能过;限流/超时则同一家退避重试。
782
+ for (const provider of provs) {
783
+ for (let attempt = 0; attempt < 5; attempt++) {
784
+ if (attempt > 0) {
785
+ // 退避:1s/2s/4s/8s + 抖动,错开并发重试避免再次撞限流
786
+ const wait = 1000 * 2 ** (attempt - 1) + Math.floor(Math.random() * 600);
787
+ await new Promise((r) => setTimeout(r, wait));
788
+ }
789
+ try {
790
+ // 🔴 三段拆开(studio 侧它们长在 generateImage 一个函数里):
791
+ // ① 出图 = media 层(provider 适配 + 点名这一家)· ② 抠图决策树 = composeAsset
792
+ // ③ 生成尺寸/最终编码 = imageSize —— ③ 谁都不认领,但它是 `a.size` 唯一被兑现的地方。
793
+ const target = parseSize(a.size);
794
+ const gen = target ? genSizeFor(target) : null;
795
+ // _raw 就是 generateAsset 的落点:AI 直出、含品红底、未经处理 —— 留底本来就是设计要求的
796
+ //(`redo:post` 靠它重跑抠图→切帧→归一),所以这次落盘不是多余的中间产物。
797
+ // ⚠️ `finalizeAsset` 随后会把同一份 raw 再统一编码成 PNG 写回**同一个路径**(幂等):
798
+ // 留着那次写是因为"_raw 必须是 AI 首次返回的图"这条语义归 finalizeAsset 管,
799
+ // provided/线稿降级等不走 generateAsset 的路径也靠它。
800
+ // 🔴 内层 `withRateLimitRetry` 是**限流退让**(只认 429,等一等就过去);外层 attempt 是
801
+ // **供应商级**重试+换家。少了内层,限流会变成"消耗一次供应商配额" → 更快回退到差供应商。
802
+ const res = await withRateLimitRetry(() => generateAsset({
803
+ capability: 'image',
804
+ providerId: provider.id, // 🔴 点名:没有它,"换一家重试"会变成同一家跑五遍
805
+ prompt: withTransparency(prompt, a.transparent === true),
806
+ cwd: repoDir,
807
+ outPath: join('assets', '_raw', `${a.id}.png`),
808
+ size: gen ? `${gen.w}x${gen.h}` : a.size,
809
+ referenceUrl: referenceDataUrl,
810
+ }));
811
+ const raw = readFileSync(join(repoDir, res.path));
812
+ let buf = await composeAsset(raw, { transparent: a.transparent === true }, ctx.ops);
813
+ // 最后一次编码:resize + 压缩一次完成(有 target);无 target 则纯压缩。内部失败回退原图。
814
+ buf = target ? await compressKeepFormat(buf, { resize: { w: target.w, h: target.h } }) : await compressKeepFormat(buf);
815
+ const pName = `${provider.name || provider.adapter}/${res.model || provider.adapter}`;
816
+ // 非首选生成成功 = 走了回退链 → 必须留痕(否则"配置 A、实际 B 生成"完全不可见,排查靠猜)
817
+ // 🔴 留痕是**运维信号**,不是用户叙事 —— 不要升级成事件。
818
+ if (provider !== provs[0])
819
+ console.warn(`⚠️ 资产 ${a.id}:首选 provider 失败,回退到 ${pName} 生成(上家错误:${lastErr.slice(0, 160)})`);
820
+ const st = await finalizeAsset(buf, a, assetsDir, 'generated', raw, rawOnly, logical);
821
+ st.provider = pName;
822
+ return st;
823
+ }
824
+ catch (e) {
825
+ lastErr = e instanceof Error ? e.message : String(e);
826
+ console.warn(`⚠️ 资产 ${a.id}:${provider.name || provider.adapter} 生成失败(attempt ${attempt + 1}):${lastErr.slice(0, 200)}`);
827
+ // 结构失守(ui/kit 生成图没跟线稿布局)→ 同一家重试大概率还是不跟,直接换下一家
828
+ if (lastErr.includes('uikit-structure-broken'))
829
+ break;
830
+ // 前景中央被盖 = 采样波动,同一家重画常能救 → 不 break,走退避重试(耗尽再换家)
831
+ if (lastErr.includes('fg-center-covered'))
832
+ continue;
833
+ // 限流/网关/超时 → 同一家退避重试;参数错/鉴权错/内容审核拒绝 → 不重试,跳出换下一家供应商
834
+ if (!/429|rate|throttl|限流|timeout|超时|50[234]|网关|abort/i.test(lastErr))
835
+ break;
836
+ }
837
+ }
838
+ // 这家失败(审核拒绝/重试耗尽)→ 外层 for 换下一家供应商
839
+ }
840
+ // 🔴 前景层链全败(各家都画满中心/失败)→ 全透明整幅优雅降级:fg 是增强层,透明=没有前景,游戏无感
841
+ if ((a.type ?? '').toLowerCase().startsWith('scene/foreground')) {
842
+ try {
843
+ const dim = /(\d+)\s*x\s*(\d+)/.exec(a.size ?? '');
844
+ const w = dim ? Number(dim[1]) : 1024, h = dim ? Number(dim[2]) : 1024;
845
+ const blank = await sharp({ create: { width: w, height: h, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } }).png().toBuffer();
846
+ writeAssetAtomic(join(assetsDir, `${a.id}.png`), blank); // 成品落盘一律原子写(1.10)
847
+ console.warn(`⚠️ 资产 ${a.id}(前景层):各 provider 均不合格,降级为全透明整幅(游戏无感,失去视差增强)`);
848
+ return { id: a.id, ok: true, source: 'generated', provider: 'fg-blank/降级', error: lastErr.slice(0, 160) };
849
+ }
850
+ catch { /* 落到 ok:false */ }
851
+ }
852
+ // 🔴 ui/kit 链全败(含各家结构失守)→ 灰阶线稿本体兜底:结构/坐标全对、可玩,绝不输出错乱切分
853
+ if ((a.type ?? '') === 'ui/kit') {
854
+ try {
855
+ console.warn(`⚠️ 资产 ${a.id}(ui/kit):所有 provider 失败/结构失守,降级为灰阶线稿本体(${lastErr.slice(0, 120)})`);
856
+ const wire = await renderUiWireframe();
857
+ const st = await finalizeAsset(wire, a, assetsDir, 'generated', wire, rawOnly, logical);
858
+ st.provider = 'uikit-wireframe/降级';
859
+ return st;
860
+ }
861
+ catch { /* 线稿也失败 → 落到 ok:false */ }
862
+ }
863
+ return { id: a.id, ok: false, error: lastErr };
864
+ }
865
+ /**
866
+ * 批量生成游戏美术资源 —— **并发 worker 池 + 限流**(阶段3)。
867
+ * concurrency 个 worker 从队列拉取并行生成(每张含尺寸+抠图);失败不阻塞其它。
868
+ * onProgress(done,total) 供编排层报进度。结果写 assets/asset-status.json。
869
+ */
870
+ /** 占位资产的逻辑尺寸:产物清单权威 → build-manifest 兜底 → 默认 800×600。
871
+ * `manifestFile` 随任务下发(`game.json` / `experiment.json` …)—— sps 不该知道哪个产品叫什么。 */
872
+ function placeholderLogicalSize(repoDir, manifestFile) {
873
+ try {
874
+ const gj = JSON.parse(readFileSync(join(repoDir, manifestFile), 'utf-8'));
875
+ if (gj.width && gj.height)
876
+ return { w: gj.width, h: gj.height };
877
+ }
878
+ catch { /* 无产物清单 */ }
879
+ try {
880
+ const bm = JSON.parse(readFileSync(join(repoDir, 'docs', 'build-manifest.json'), 'utf-8'));
881
+ if (bm.width && bm.height)
882
+ return { w: bm.width, h: bm.height };
883
+ }
884
+ catch { /* 无 build-manifest */ }
885
+ return { w: 800, h: 600 };
886
+ }
887
+ function expectedAssetFile(a) {
888
+ const t = (a.type ?? '').toLowerCase();
889
+ return t.startsWith('audio') ? `${a.id}.mp3` : `${a.id}.png`;
890
+ }
891
+ export function assetKind(a) {
892
+ return (a.type ?? '').toLowerCase().startsWith('audio') ? 'audio' : 'visual';
893
+ }
894
+ // 🔴 studio 侧这里还有一个 `generateAudioAssets`(音频专用入口,刻意绕开单一开关,
895
+ // 免得切换那一刻 BGM 跟着断)—— **sps 侧不要**:音频本版留 studio,
896
+ // 在这儿摆一个跑不了的入口是**假的**(它会让人以为 sps 能出音频)。
897
+ // 音频归 sps 那天再加,而那时它不需要"绕开开关"这条理由。
898
+ export async function generateGameArt(task, onProgress, opts) {
899
+ // 🔴 它是**第二个入口**(index.ts:1330 绕过 runArt 直接调)——
900
+ // ⚠️ studio 侧那句 assertLocalArtDriverAllowed() 在这里**删掉**:那是 studio 的"单一开关",
901
+ // 用来保证切换后旧 driver 不再动手;**sps 就是新 driver**,再守一次等于自己把自己关掉。
902
+ // 作业归因:计量随网关 token 走,sps 不维护 ALS(runWithJob 是显式空实现,见 jobContext.ts 头注)。
903
+ return runWithJob({ jobId: task.jobId, projectId: task.locator.projectId }, () => generateGameArtInner(task, onProgress, opts));
904
+ }
905
+ async function generateGameArtInner(task, onProgress, opts) {
906
+ // 落点由 sps 正向拼(调用方永远不传路径)——`repoDir` 的等价物。
907
+ const repoDir = resolveProjectDir(task.locator);
908
+ const { artPrompts, manifestFile } = task;
909
+ const manifest = readArtManifest(repoDir);
910
+ if (!manifest || !manifest.assets.length)
911
+ return [];
912
+ // 归一化 id:剥掉 id 里误带的图片扩展名(设计师有时写 bg.jpg/myplane.png,尤其套模板时)——
913
+ // 否则存成 bg.jpg.png(双扩展),代码按 assets/bg.jpg 加载 404、整局 smoke 挂。
914
+ // 在生成前重写 manifest 文件,构建 worker(等 wait-assets 后才读 manifest 接入)读到的就是裸 id。
915
+ const EXT = /\.(png|jpe?g|webp|gif)$/i;
916
+ if (manifest.assets.some((a) => EXT.test(a.id))) {
917
+ for (const a of manifest.assets)
918
+ a.id = a.id.replace(EXT, '');
919
+ try {
920
+ const mf = join(repoDir, 'docs', 'art-manifest.json');
921
+ const raw = JSON.parse(readFileSync(mf, 'utf-8'));
922
+ if (Array.isArray(raw.assets)) {
923
+ for (const a of raw.assets)
924
+ if (typeof a?.id === 'string')
925
+ a.id = a.id.replace(EXT, '');
926
+ writeFileSync(mf, `${JSON.stringify(raw, null, 2)}\n`);
927
+ }
928
+ }
929
+ catch { /* 重写失败:内存里已归一化,生成的文件仍裸名,worker 多数也能对上 */ }
930
+ }
931
+ // 🔴 生图前合图兜底:设计师把同类元素拆成一堆单张(违反 design-art-manifest「≤3 张」)时,自动合并成 sheet →
932
+ // 生图只生 1 张 → 规避几十张并行的图床限流 429(星际防线事故)。写回 manifest,worker 读到的就是合并后的 sheet。
933
+ const mergeNotes = mergeStraySprites(manifest);
934
+ if (mergeNotes.length) {
935
+ console.warn(`⚠️ 生图前自动合图(设计师未按 design-art-manifest 合并,${mergeNotes.length} 组):\n ${mergeNotes.join('\n ')}`);
936
+ try {
937
+ const mf = join(repoDir, 'docs', 'art-manifest.json');
938
+ const raw = JSON.parse(readFileSync(mf, 'utf-8'));
939
+ raw.assets = manifest.assets; // 用合并后的资产覆盖 → worker(wait-assets 后才读 manifest)按 sheet 接入
940
+ writeFileSync(mf, `${JSON.stringify(raw, null, 2)}\n`);
941
+ }
942
+ catch { /* 写回失败:内存已合并、生成仍走合并;worker 读旧 manifest 可能对不上散 id → 已 warn */ }
943
+ }
944
+ // ⚠️ studio 侧这里还有一句「没有 art 工序的通道直接空返回」的防御性护栏 —— **删掉**:
945
+ // 那是 studio 内部的工序表守卫,而调用方已经决定了要不要调这条流水线。
946
+ // 尽早写「生成中」(覆盖上次构建的 done,避免 revise 时 worker 读到陈旧 done 提前放行)。
947
+ writeArtReady(repoDir, 'generating', manifest.assets.length);
948
+ // ══════════════════════════════════════════════════════════════════════
949
+ // 🔴 studio 侧这里原有 18 行「选 provider」(fetchPartnerConfig → pcTool → listImageProviders
950
+ // → getArtConfig → getImageProvider),**整段删掉**。
951
+ // 读整段才看得出:它在 import 列表里是三个独立依赖,实际是**一条链**,
952
+ // 而链的出口只有 `(provider, model)` 两个值 —— 而 sps 的 media 层
953
+ // (`resolveMediaProvider(capability, cwd)`:项目级 `<cwd>/.sps/media-providers.json`
954
+ // > 全局默认,按 capability 逐个覆盖)**已经在做同一件事**。
955
+ // 逐个替换会写出三层适配器去还原一个已经存在的能力。
956
+ // ⇒ 这里只要"有哪些能用、谁排第一",provider/key 的解析在 `generateAsset` 内部完成。
957
+ // ══════════════════════════════════════════════════════════════════════
958
+ const assetsDir = join(repoDir, 'assets');
959
+ mkdirSync(assetsDir, { recursive: true });
960
+ // 改图:regen 列出的资产先删旧文件 → 下面 worker 见缺失即重生(只重生这些,其余 kept)。
961
+ // 删完即把 regen 从磁盘 manifest 清掉(一次性,避免下次构建又重删)。
962
+ if (manifest.regen?.length) {
963
+ const regenSet = new Set(manifest.regen);
964
+ for (const id of regenSet) {
965
+ // 🔴 删除前先把旧图快照到 docs/refs/<id>-prev.png:支持「微调型改图」——修改师把该资产
966
+ // ref 设为这个快照路径 → 生成走图生图、大体保持原构图只按新 desc 微调(delete-first 的
967
+ // 顺序坑:不快照的话,生成时 ref 指向的原文件已被删)。没设 ref 的默认重生完全不受影响
968
+ // (快照只是多一份文件);首次构建无 regen → 本段不跑,首次生图规则零影响。
969
+ try {
970
+ const prevPng = join(assetsDir, `${id}.png`);
971
+ if (existsSync(prevPng)) {
972
+ const refDir = join(repoDir, 'docs', 'refs');
973
+ mkdirSync(refDir, { recursive: true });
974
+ copyFileSync(prevPng, join(refDir, `${id}-prev.png`));
975
+ }
976
+ }
977
+ catch { /* 快照失败不阻断:微调退化成普通重生 */ }
978
+ for (const f of [`${id}.png`, `${id}.json`, `${id}.mp3`, `${id}.glb`]) {
979
+ const p = join(assetsDir, f);
980
+ if (existsSync(p)) {
981
+ try {
982
+ unlinkSync(p);
983
+ }
984
+ catch { /* */ }
985
+ }
986
+ }
987
+ }
988
+ try {
989
+ const mf = join(repoDir, 'docs', 'art-manifest.json');
990
+ const raw = JSON.parse(readFileSync(mf, 'utf-8'));
991
+ delete raw.regen;
992
+ writeFileSync(mf, `${JSON.stringify(raw, null, 2)}\n`);
993
+ }
994
+ catch { /* 清空失败不致命:大不了下次再删一遍同样的文件 */ }
995
+ }
996
+ // 参考图(图生图):每张优先用资产自带 ref,否则用全局 ref;读成 data URL 传给生成。
997
+ const globalRef = refToDataUrl(repoDir, manifest.ref);
998
+ // 游戏逻辑分辨率(game.json)→ 确定性 downscale 的目标尺寸基准。读不到就不缩(保持原尺寸)。
999
+ let logical;
1000
+ try {
1001
+ const gj = JSON.parse(readFileSync(join(repoDir, manifestFile), 'utf-8'));
1002
+ if (gj.width && gj.height)
1003
+ logical = { w: gj.width, h: gj.height };
1004
+ }
1005
+ catch { /* 无 game.json → 不缩 */ }
1006
+ // 🔴 背景/前景画幅:scene 契约(docs/25)优先——scroll 决定构图轴,画幅跟着 scroll 走;
1007
+ // 无契约则缺 size 按逻辑分辨率兜底(横竖版画幅跟着游戏走,竖版游戏别再吃横图拉伸)。
1008
+ {
1009
+ let scroll;
1010
+ try {
1011
+ const bm = JSON.parse(readFileSync(join(repoDir, 'docs', 'build-manifest.json'), 'utf-8'));
1012
+ scroll = bm.scene?.scroll;
1013
+ }
1014
+ catch { /* 无 build-manifest → 走逻辑分辨率兜底 */ }
1015
+ const scrollSize = scroll === 'horizontal' ? '1536x1024' : scroll === 'vertical' ? '1024x1536' : scroll === 'free' ? '1024x1024' : undefined;
1016
+ for (const a of manifest.assets) {
1017
+ const t = (a.type ?? '').toLowerCase();
1018
+ if (!(t.startsWith('scene') || t.startsWith('bg')))
1019
+ continue;
1020
+ if (scrollSize) {
1021
+ // 以 scroll 为准纠正(设计师给了冲突的画幅也覆盖——滚动轴错了整图作废)
1022
+ if (a.size && a.size !== scrollSize)
1023
+ console.warn(`⚠️ 资产 ${a.id}:size=${a.size} 与 scene.scroll=${scroll} 冲突,已按契约纠正为 ${scrollSize}`);
1024
+ a.size = scrollSize;
1025
+ }
1026
+ else if (!a.size && logical) {
1027
+ a.size = `${logical.w}x${logical.h}`;
1028
+ }
1029
+ }
1030
+ }
1031
+ // 🔴 布局型游戏(塔防等):有 docs/level.json → 渲染语义蓝图 → 给场景背景资产注入图生图 ref。
1032
+ // 数据驱动:无 level.json 则整段跳过,非布局游戏零影响。
1033
+ try {
1034
+ const levelFile = join(repoDir, 'docs', 'level.json');
1035
+ if (existsSync(levelFile)) {
1036
+ const level = JSON.parse(readFileSync(levelFile, 'utf-8'));
1037
+ const refDir = join(repoDir, 'docs', 'refs');
1038
+ mkdirSync(refDir, { recursive: true });
1039
+ writeFileSync(join(refDir, 'level-blueprint.png'), await renderLevelBlueprint(level));
1040
+ // 背景资产没自己标 ref 时,注入蓝图 ref → 下面走图生图(以蓝图为布局结构)。
1041
+ // 🔴 同时把 size 设成 = level 尺寸,让 codex 按蓝图【同尺寸】生成 → 零缩放变形、塔位精确对齐
1042
+ // (尺寸不一致会非等比压扁、建造位偏移)。level 尺寸最好用 codex 支持的宽高比(见 design-genre-slg)。
1043
+ for (const a of manifest.assets) {
1044
+ if ((a.type ?? '').startsWith('scene') && !a.ref) {
1045
+ a.ref = 'docs/refs/level-blueprint.png';
1046
+ a.size = `${level.width}x${level.height}`;
1047
+ }
1048
+ }
1049
+ }
1050
+ }
1051
+ catch { /* 蓝图失败 → 背景退回普通文生图,不阻断整局 */ }
1052
+ // 🔴 UI 契约套件(uiKit.ts):type='ui/kit' → 渲染灰阶线稿 → 注入图生图 ref(结构由线稿锁死,生成只换风格)。
1053
+ // ref/size/transparent/sheet 一律以契约为准覆盖(设计师只该给 desc=风格补充;误写 sheet 会带偏契约切分)。
1054
+ try {
1055
+ if (manifest.assets.some((a) => (a.type ?? '') === 'ui/kit')) {
1056
+ const refDir = join(repoDir, 'docs', 'refs');
1057
+ mkdirSync(refDir, { recursive: true });
1058
+ writeFileSync(join(refDir, 'ui-kit-wireframe.png'), await renderUiWireframe());
1059
+ for (const a of manifest.assets) {
1060
+ if ((a.type ?? '') !== 'ui/kit')
1061
+ continue;
1062
+ a.ref = 'docs/refs/ui-kit-wireframe.png';
1063
+ a.size = `${UI_KIT_CANVAS.w}x${UI_KIT_CANVAS.h}`;
1064
+ a.transparent = true;
1065
+ delete a.sheet;
1066
+ }
1067
+ }
1068
+ }
1069
+ catch { /* 线稿失败 → 退回无参考生成(finalize 仍按契约矩形写 atlas,结构可能不准但不阻断) */ }
1070
+ // 🔴 按归属过滤(2.8):`only` 给了就只处理那一类。
1071
+ // 切换后 studio 只跑 audio,sps 只跑 visual —— 两边都拿全量 manifest,各取所需。
1072
+ const scoped = opts?.only ? manifest.assets.filter((a) => assetKind(a) === opts.only) : manifest.assets;
1073
+ const total = scoped.length;
1074
+ // 🔴 封顶值与子进程的 `UV_THREADPOOL_SIZE` 是**同一个常量**(见 core/art/artConcurrency):
1075
+ // 池的大小和"同时有几张图在处理"是同一个数的两面,分开写会各自漂,而漂了不报错。
1076
+ const concurrency = Math.max(1, Math.min(ART_ASSET_CONCURRENCY_MAX, manifest.concurrency ?? 4));
1077
+ const queue = [...scoped];
1078
+ const status = [];
1079
+ const placeholderIds = readPlaceholderIds(repoDir); // 占位的一律重生成(见 PLACEHOLDER_MARKER 注释)
1080
+ // 🔴 上次生成的描述指纹:二修中设计师改了 desc 但忘写 regen(第三方模型常见)→ 按指纹差自动重生。
1081
+ // 老资产无指纹(升级前生成)→ 视为未变,不触发大规模重生。
1082
+ const prevHash = new Map();
1083
+ try {
1084
+ const prev = JSON.parse(readFileSync(join(assetsDir, 'asset-status.json'), 'utf-8'));
1085
+ for (const st of prev)
1086
+ if (st.descHash)
1087
+ prevHash.set(st.id, st.descHash);
1088
+ }
1089
+ catch { /* 无历史 → 全部视为未变 */ }
1090
+ let done = 0;
1091
+ // 供应商回退链:**顺序即优先级**(默认那家在前,其余 capability=image 的兜底)。
1092
+ // 某家被内容审核拒/限流耗尽时,genOne 依次换下一家(审核口径不同,常能救回)。
1093
+ // 🔴 只拿"有哪些能用"(`listUsableProviders` 不给 apiKey/hasKey);"先用哪家 · 失败了怎么办"
1094
+ // 是**消费方的使用策略**,就在 genOne 里 —— 这条切分是 media 层的对外契约。
1095
+ const imageProviders = listUsableProviders('image', repoDir);
1096
+ if (!imageProviders.length) {
1097
+ // 🔴 **这条早返回也要守"跳过要可见"**:audio 不归 sps 做,它没出图**不是"缺 provider"**。
1098
+ // (1.4 子进程冒烟实测抓到的:一个只有 BGM 的 manifest 在没配图像 provider 时
1099
+ // 整体报 `failed`,而正确答案是 `skipped` —— 一句"错的原因"比没有原因更难排查。)
1100
+ for (const a of manifest.assets) {
1101
+ if (assetKind(a) !== 'visual') {
1102
+ status.push({ id: a.id, ok: false, source: 'skipped', error: '音频资产本版由 studio 生成(sps 只做视觉)' });
1103
+ continue;
1104
+ }
1105
+ status.push({ id: a.id, ok: false, error: '未配置图像供应商(去工作台设置添加)' });
1106
+ }
1107
+ writeFileSync(join(assetsDir, 'asset-status.json'), JSON.stringify(status, null, 2));
1108
+ writeArtReady(repoDir, 'done', total); // 放行 worker(没图也别让它干等)
1109
+ return status;
1110
+ }
1111
+ const genCtx = { repoDir, assetsDir, artPrompts, ops: await defaultPixelOps() };
1112
+ async function worker() {
1113
+ for (;;) {
1114
+ const a = queue.shift();
1115
+ if (!a)
1116
+ return;
1117
+ // 🔴 取消检查点:**取下一个资产之前**,这是唯一允许停下的地方(1.9)。
1118
+ // 半张图落盘后,"文件存在就跳过"的幂等会把它当成已完成 ⇒ 取消一次就永久污染一个资产,而且不报错。
1119
+ // ⚠️ studio 侧 `generateGameArt` **收了 `isCancelled` 却从未调用**(实测:整个 artGen 里
1120
+ // 零个检查点)—— 参数接上了、没人读它,和 `native` 恒 undefined 同一族。已单独报给 coral。
1121
+ if (opts?.isCancelled?.())
1122
+ return;
1123
+ const isAudio = (a.type || '').startsWith('audio'); // type:"audio/bgm" → 走音乐生成,落 .mp3
1124
+ const isModel3d = (a.type || '') === 'model3d'; // type:"model3d" → 概念图→Tripo 图生3D,落 .glb
1125
+ const outFile = isModel3d ? `${a.id}.glb` : isAudio ? `${a.id}.mp3` : `${a.id}.png`;
1126
+ // model3d 以概念图 .png 为"已有"判据(glb 是加成,无 glb 用概念图当 billboard)→ 二修不再反复重试失败的 Tripo。
1127
+ const haveFile = isModel3d ? `${a.id}.png` : outFile;
1128
+ const channel = isAudio ? 'audio' : 'art';
1129
+ // 🔴 事件只给**事实**({i18nKey, params}),不给文案:产品词表在消费方
1130
+ // (gameforge 说"游戏"、lab 说"实验"),而拼出来的占位符会一路漏到用户眼前。
1131
+ // `desc` 是 manifest 里设计师写的描述 = 数据,原样当参数递出去(studio 侧原本就拿它当 title)。
1132
+ const evParams = { id: a.id, desc: a.desc || a.id };
1133
+ const before = status.length;
1134
+ // 🔴 占位文件不算"已有"(否则真图永远不生成):在占位清单里的一律重生成。
1135
+ // 🔴 描述指纹变了(设计师二修改了 desc/size/sheet 等但忘写 regen)→ 视同 regen 自动重生。
1136
+ const hashNow = assetDescHash(a);
1137
+ const oldHash = prevHash.get(a.id);
1138
+ const descChanged = !!oldHash && oldHash !== hashNow;
1139
+ const willKeep = existsSync(join(assetsDir, haveFile)) && !placeholderIds.has(a.id) && !descChanged;
1140
+ if (descChanged && existsSync(join(assetsDir, haveFile))) {
1141
+ console.warn(`⚠️ 资产 ${a.id}:描述指纹变化(设计师改了但未写 regen)→ 自动重生`);
1142
+ try {
1143
+ const refDir = join(repoDir, 'docs', 'refs');
1144
+ mkdirSync(refDir, { recursive: true });
1145
+ if (!isAudio && !isModel3d)
1146
+ copyFileSync(join(assetsDir, `${a.id}.png`), join(refDir, `${a.id}-prev.png`));
1147
+ }
1148
+ catch { /* 快照失败不阻断 */ }
1149
+ for (const f of [`${a.id}.png`, `${a.id}.json`, `${a.id}.mp3`, `${a.id}.glb`]) {
1150
+ const pth = join(assetsDir, f);
1151
+ if (existsSync(pth)) {
1152
+ try {
1153
+ unlinkSync(pth);
1154
+ }
1155
+ catch { /* */ }
1156
+ }
1157
+ }
1158
+ }
1159
+ // 🔴 只对【真要生成】的资产发 feed 事件;已存在(kept)= 这轮没生成 → 不输出(二修不重复刷已有图/音)。
1160
+ // 🔴 skipped(本版不归 sps 做的类型)**连 running 都不发**。
1161
+ // 1.11 真跑抓到的:audio 发了 running、却永远等不到终态(终态那一侧已经排除了 skipped)——
1162
+ // 而 BuildEvent 是"同 id 新状态覆盖旧",于是 UI **永远显示它在生成中**。
1163
+ // 这正是 `started` 事件那条判据的反面:**开了头没有尾,比压根没开头更糟**。
1164
+ // ⚠️ 而且音频归 studio 写,它会发自己那条 —— sps 再发一条就成了两个写者。
1165
+ if (!willKeep && !isAudio)
1166
+ emitBuildEvent(repoDir, { channel, id: a.id, i18nKey: 'build.event.asset', params: evParams, status: 'running' });
1167
+ if (willKeep) {
1168
+ status.push({ id: a.id, ok: true, source: 'kept', descHash: hashNow });
1169
+ }
1170
+ else if (a.provided) {
1171
+ // 用户提供的资产:从 repo 内路径(设计师登记的 docs/refs/x.png)拷进 assets/、不生成。
1172
+ let src = null;
1173
+ try {
1174
+ const p = join(repoDir, a.provided);
1175
+ if (withinDir(repoDir, p) && existsSync(p))
1176
+ src = p;
1177
+ }
1178
+ catch { /* 越界/坏路径 → null */ }
1179
+ if (!src) {
1180
+ status.push({ id: a.id, ok: false, error: `provided 路径越界或缺失:${a.provided}` });
1181
+ }
1182
+ else if (isAudio) {
1183
+ try {
1184
+ writeFileSync(join(assetsDir, outFile), readFileSync(src));
1185
+ status.push({ id: a.id, ok: true, source: 'provided' });
1186
+ }
1187
+ catch (e) {
1188
+ status.push({ id: a.id, ok: false, error: `provided 音频拷贝失败:${e instanceof Error ? e.message : String(e)}` });
1189
+ }
1190
+ }
1191
+ else {
1192
+ // 图像:归一成 png(用户可能传 jpg/webp)→ 走与生成图一致的后处理(图集打包/朝向/裁边)。
1193
+ try {
1194
+ const pbuf = await sharp(readFileSync(src)).png().toBuffer();
1195
+ status.push(await finalizeAsset(pbuf, a, assetsDir, 'provided', undefined, false, logical));
1196
+ }
1197
+ catch (e) {
1198
+ status.push({ id: a.id, ok: false, error: `provided 资产处理失败:${e instanceof Error ? e.message : String(e)}` });
1199
+ }
1200
+ }
1201
+ }
1202
+ else if (isModel3d) {
1203
+ // 3D 模型(Plan B):① 图床先出概念图(必出——既是 Tripo 输入,也是无 Tripo/失败时的 billboard 贴图兜底);
1204
+ // ② 有 3D 供应商才 Tripo 图生3D 出 GLB;无供应商 / 失败 / 无额度 → 概念图当 billboard(ok,不算失败,不阻断)。
1205
+ // worker 端:有 glb 用真模型,无 glb 但有概念图 → 当贴图/billboard(见 engines/three TEMPLATE)。
1206
+ let conceptBuf = null;
1207
+ try {
1208
+ const conceptPrompt = [a.desc, manifest.style, '单一主体居中、完整不裁切、3/4 斜俯视角、干净纯色背景、无场景无文字、适合转 3D 模型'].filter(Boolean).join(', ');
1209
+ // 概念图走首选那家(与 studio 同:概念图必出,它既是 Tripo 输入也是 billboard 兜底)。
1210
+ // 限流退让同样套在这条上 —— studio 那条注释写明「3D 概念图也走 generateImage,所以一并受益」。
1211
+ await withRateLimitRetry(() => generateAsset({ capability: 'image', providerId: imageProviders[0].id, prompt: conceptPrompt, cwd: repoDir, outPath: join('assets', `${a.id}.png`), size: '1024x1024' }));
1212
+ conceptBuf = readFileSync(join(assetsDir, `${a.id}.png`));
1213
+ }
1214
+ catch (e) {
1215
+ status.push({ id: a.id, ok: false, error: `概念图生成失败:${e instanceof Error ? e.message : String(e)}` });
1216
+ }
1217
+ if (conceptBuf) {
1218
+ // 🔴 "没配 3D 供应商"与"Tripo 失败"在 media 层是同一种表现(generateAsset 抛),
1219
+ // 而 studio 侧两者的**处置本来就一样**(概念图当 billboard,ok 不算失败)——
1220
+ // 所以这里合成一条 catch,不是把两种情况混掉。
1221
+ try {
1222
+ await generateAsset({
1223
+ capability: '3d',
1224
+ prompt: a.desc ?? a.id,
1225
+ cwd: repoDir,
1226
+ outPath: join('assets', `${a.id}.glb`),
1227
+ referenceUrl: `data:image/png;base64,${conceptBuf.toString('base64')}`,
1228
+ });
1229
+ status.push({ id: a.id, ok: true, source: 'generated' });
1230
+ }
1231
+ catch {
1232
+ status.push({ id: a.id, ok: true, source: 'billboard' }); // 无 3D 供应商 / Tripo 失败(余额/限流等)→ 概念图当 billboard
1233
+ }
1234
+ }
1235
+ }
1236
+ else if (isAudio) {
1237
+ // 🔴 音频本版由 studio 生成(2.8)。**正常走不到这里** —— driver 在资产边界就按能力
1238
+ // 明确跳过并回报;派发漏了才会落到这条,所以它**必须响亮**:
1239
+ // 静默当作已完成的症状是"游戏没有 BGM",**不是报错**。
1240
+ // ⚠️ 音频归 sps 那天,这里换成 `generateAsset({ capability: 'music' })` + 公共音频库兜底
1241
+ // (`ok:true, source:'library'` 那条语义一并补回)。
1242
+ status.push({ id: a.id, ok: false, source: 'skipped', error: '音频资产本版由 studio 生成(sps 只做视觉)' });
1243
+ }
1244
+ else {
1245
+ const ref = a.ref ? refToDataUrl(repoDir, a.ref) : globalRef;
1246
+ status.push(await genOne(imageProviders, a, manifest.style, manifest.view, genCtx, ref, opts?.rawOnly, logical));
1247
+ }
1248
+ const r = status[status.length - 1];
1249
+ if (r && status.length > before && r.ok && !r.descHash)
1250
+ r.descHash = hashNow; // 成功项统一记录指纹(供下轮改描述自动重生)
1251
+ // 🔴 kept(这轮没真生成)不发 done/failed 事件 —— 按实际生成输出,没生成就没输出(二修不重复刷)。
1252
+ // skipped(本版不支持的类型)同理:它不是"失败的资产",发 failed 事件会在 UI 上
1253
+ // 把 studio 正常生成的 BGM 显示成红叉。**跳过要出现在 run 的结果里,不是出现在事件流里。**
1254
+ if (r && status.length > before && r.source !== 'kept' && r.source !== 'skipped') {
1255
+ // 概念图/普通图缩略图都是 <id>.png;音频是 <id>.mp3(3D 也用概念图当缩略)。
1256
+ const asset = r.ok ? (isAudio ? `assets/${a.id}.mp3` : `assets/${a.id}.png`) : undefined;
1257
+ // 「已用公共音频库替代」是另一句话 → 另一个 key,不是把后缀拼进参数里
1258
+ //(拼后缀 = 在 sps 侧造了一句中文文案,而它会原样漏到界面上)。
1259
+ const key = r.source === 'library' ? 'build.event.asset.library' : 'build.event.asset';
1260
+ emitBuildEvent(repoDir, { channel, id: a.id, i18nKey: key, params: evParams, status: r.ok ? 'done' : 'failed', asset, mime: isAudio ? 'audio' : 'image', detail: r.ok ? undefined : r.error });
1261
+ }
1262
+ done++;
1263
+ try {
1264
+ onProgress?.(done, total);
1265
+ }
1266
+ catch { /* */ }
1267
+ }
1268
+ }
1269
+ await Promise.all(Array.from({ length: Math.min(concurrency, total) }, () => worker()));
1270
+ writeFileSync(join(assetsDir, 'asset-status.json'), JSON.stringify(status, null, 2));
1271
+ // 语义层:合并写 docs/asset-meta.json(主 agent 读它把帧对到语义)。pool 后单次写 → 无竞态;
1272
+ // 只更新本次生成出标签的资产,kept/无标签的保留旧条目(改图时不丢历史标签)。
1273
+ const newMeta = status.filter((s) => s.meta?.frames.length).reduce((acc, s) => {
1274
+ acc[s.id] = s.meta;
1275
+ return acc;
1276
+ }, {});
1277
+ if (Object.keys(newMeta).length) {
1278
+ try {
1279
+ const metaFile = join(repoDir, 'docs', 'asset-meta.json');
1280
+ let existing = {};
1281
+ if (existsSync(metaFile)) {
1282
+ try {
1283
+ existing = JSON.parse(readFileSync(metaFile, 'utf-8'));
1284
+ }
1285
+ catch { /* 坏文件 → 重写 */ }
1286
+ }
1287
+ writeFileSync(metaFile, `${JSON.stringify({ ...existing, ...newMeta }, null, 2)}\n`);
1288
+ }
1289
+ catch { /* 写 meta 失败不致命:几何在 <id>.json 里,标签可后补 */ }
1290
+ }
1291
+ // rawOnly 只是流水线的「生成棒」,后面还有处理/验证 → 不写 done(由 driver runAssetPipeline 在三棒完成后写),避免构建 worker 提前用到未处理的原图。
1292
+ // 真图已就位的 id 从占位清单移除(失败的保留 → 续跑/下次构建会再试,占位图继续顶着)。
1293
+ if (placeholderIds.size) {
1294
+ for (const s of status)
1295
+ if (s.ok && s.source !== 'kept')
1296
+ placeholderIds.delete(s.id);
1297
+ writePlaceholderIds(repoDir, placeholderIds);
1298
+ }
1299
+ if (!opts?.rawOnly)
1300
+ writeArtReady(repoDir, 'done', total);
1301
+ return status;
1302
+ }
1303
+ //# sourceMappingURL=artGen.js.map