@playcraft/build 0.0.43 → 0.0.44
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.
|
@@ -631,6 +631,12 @@ export class PlayableScriptsAdapter {
|
|
|
631
631
|
'ignore-scripts=false',
|
|
632
632
|
'strict-peer-dependencies=false',
|
|
633
633
|
'dangerously-allow-all-builds=true',
|
|
634
|
+
// pnpm >=10.10 默认启用 minimum-release-age=24h(supply-chain 防护,
|
|
635
|
+
// 阻止安装发布不满 24 小时的包),构建端的 worker 镜像若意外跑到 pnpm 11
|
|
636
|
+
// (corepack 行为差异等),此策略会让恰好今天发版的依赖(如 vue@3.5.x)报
|
|
637
|
+
// ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION。这里在临时 .npmrc 里强制关掉,
|
|
638
|
+
// install 完即 unlink,仅作用于本次 PlayCraft 构建,不影响外部环境。
|
|
639
|
+
'minimum-release-age=0',
|
|
634
640
|
].join('\n');
|
|
635
641
|
try {
|
|
636
642
|
await fs.writeFile(npmrcPath, npmrcContent, 'utf-8');
|
|
@@ -646,19 +652,34 @@ export class PlayableScriptsAdapter {
|
|
|
646
652
|
try {
|
|
647
653
|
// 执行 pnpm install 安装 package.json 中定义的所有依赖
|
|
648
654
|
// 包括 @playcraft/devkit 和 babel-loader 等构建工具
|
|
649
|
-
// 注意:不要设置 NODE_ENV=production,否则会跳过 devDependencies
|
|
650
655
|
await this.runCommandWithTimeout('pnpm', installArgs, {
|
|
651
656
|
cwd: this.projectDir,
|
|
652
|
-
timeout:
|
|
653
|
-
env:
|
|
657
|
+
timeout: 10 * 60 * 1000,
|
|
658
|
+
env: {
|
|
659
|
+
...process.env,
|
|
660
|
+
NODE_ENV: 'development',
|
|
661
|
+
},
|
|
654
662
|
});
|
|
655
663
|
}
|
|
656
664
|
catch (error) {
|
|
665
|
+
const errMsg = error.message ?? String(error);
|
|
666
|
+
if (errMsg.startsWith('命令被中止')) {
|
|
667
|
+
// 清理 .npmrc 后再抛,避免临时配置残留
|
|
668
|
+
try {
|
|
669
|
+
await fs.unlink(npmrcPath);
|
|
670
|
+
}
|
|
671
|
+
catch { /* ignore */ }
|
|
672
|
+
throw new Error(`[PlayableScriptsAdapter] pnpm install 被中止(5 分钟 timeout)。\n` +
|
|
673
|
+
`install 未跑完 link-bin 阶段,node_modules/.bin 软链缺失,无法继续构建。\n` +
|
|
674
|
+
`常见原因:依赖 1000+ 包冷启动、mozjpeg/sharp 等 native postinstall 编译过慢。\n` +
|
|
675
|
+
`建议:worker 镜像预热 pnpm store / 修复 mozjpeg 预编译产物 / 调高 install timeout。\n` +
|
|
676
|
+
`原始错误: ${errMsg}`);
|
|
677
|
+
}
|
|
657
678
|
// 不立即放弃:新版 pnpm 即使 install 实际成功,也可能因 ERR_PNPM_IGNORED_BUILDS
|
|
658
679
|
// 等告警退出非零;devkit 本身不需要 native build,只要 node_modules 里能定位到
|
|
659
680
|
// CLI 入口,构建即可继续。先记下错误,后面 verify 再决定是否真正失败。
|
|
660
681
|
installError = error;
|
|
661
|
-
console.log(`[PlayableScriptsAdapter] ⚠️ pnpm install 退出非零,将再次校验 node_modules 实际状态: ${
|
|
682
|
+
console.log(`[PlayableScriptsAdapter] ⚠️ pnpm install 退出非零,将再次校验 node_modules 实际状态: ${errMsg}`);
|
|
662
683
|
}
|
|
663
684
|
// 清理临时 .npmrc
|
|
664
685
|
try {
|
|
@@ -681,12 +702,28 @@ export class PlayableScriptsAdapter {
|
|
|
681
702
|
path.join(devkitDir, 'dist', 'cli.js'),
|
|
682
703
|
path.join(devkitDir, 'bin', 'playable-scripts.js'),
|
|
683
704
|
];
|
|
705
|
+
const dotBinPath = path.join(this.projectDir, 'node_modules', '.bin', 'playable-scripts');
|
|
706
|
+
let dotBinReady = false;
|
|
707
|
+
try {
|
|
708
|
+
await fs.access(dotBinPath);
|
|
709
|
+
dotBinReady = true;
|
|
710
|
+
}
|
|
711
|
+
catch {
|
|
712
|
+
// .bin 软链不存在,下面 fallback 路径也会再检一次
|
|
713
|
+
}
|
|
684
714
|
for (const binPath of possiblePaths) {
|
|
685
715
|
try {
|
|
686
716
|
await fs.access(binPath);
|
|
717
|
+
if (!dotBinReady) {
|
|
718
|
+
// CLI 物理文件已到位但 .bin 软链缺失 —— install 没跑完 link-bin 阶段
|
|
719
|
+
console.error(`[PlayableScriptsAdapter] ❌ 找到 CLI 物理文件 (${binPath}) 但 .bin/playable-scripts 软链缺失,` +
|
|
720
|
+
`判定 install 未跑完 link-bin 阶段,拒绝继续构建`);
|
|
721
|
+
// 跳出 for 循环,让外层走 fallback / return null
|
|
722
|
+
break;
|
|
723
|
+
}
|
|
687
724
|
console.log(`[PlayableScriptsAdapter] 安装后找到 playable-scripts: ${binPath}`);
|
|
688
725
|
if (installError) {
|
|
689
|
-
console.log('[PlayableScriptsAdapter] ✅ pnpm install 虽然退出非零,但 devkit
|
|
726
|
+
console.log('[PlayableScriptsAdapter] ✅ pnpm install 虽然退出非零,但 devkit 与 .bin 软链均已就绪,忽略告警继续构建');
|
|
690
727
|
}
|
|
691
728
|
return binPath;
|
|
692
729
|
}
|
|
@@ -694,7 +731,9 @@ export class PlayableScriptsAdapter {
|
|
|
694
731
|
// 继续尝试下一个
|
|
695
732
|
}
|
|
696
733
|
}
|
|
697
|
-
|
|
734
|
+
if (dotBinReady) {
|
|
735
|
+
console.error(`[PlayableScriptsAdapter] ❌ 找到 devkit 但无法定位 CLI: ${devkitDir}`);
|
|
736
|
+
}
|
|
698
737
|
}
|
|
699
738
|
catch {
|
|
700
739
|
// require.resolve 失败,回退到 findLocalPlayableScripts
|
|
@@ -835,7 +874,14 @@ export class PlayableScriptsAdapter {
|
|
|
835
874
|
await this.runCommandWithTimeout('pnpm', installArgs, {
|
|
836
875
|
cwd: this.projectDir,
|
|
837
876
|
timeout: 3 * 60 * 1000,
|
|
838
|
-
|
|
877
|
+
// NODE_ENV 覆盖原因同 ensureLocalDevkit 中 pnpm install:
|
|
878
|
+
// worker 容器自身 ENV NODE_ENV=production,若直接透传,pnpm 会按 --prod 处理,
|
|
879
|
+
// 即使是 `pnpm add -D` 也存在跨版本行为差异的风险。这里强制 development 保证
|
|
880
|
+
// install 主路径与 add 兜底路径行为一致。
|
|
881
|
+
env: {
|
|
882
|
+
...process.env,
|
|
883
|
+
NODE_ENV: 'development',
|
|
884
|
+
},
|
|
839
885
|
});
|
|
840
886
|
}
|
|
841
887
|
catch (error) {
|
|
@@ -64,6 +64,7 @@ export function vitePlayCanvasPlugin(options) {
|
|
|
64
64
|
// 8. 处理 manifest.json
|
|
65
65
|
html = await inlineManifest(html, options.baseBuildDir, options);
|
|
66
66
|
}
|
|
67
|
+
html = await compressInlineBuildJsonDataUrls(html, options);
|
|
67
68
|
}
|
|
68
69
|
else if (options.outputFormat === 'zip') {
|
|
69
70
|
// ZIP 格式:不内联引擎,但需要在 IIFE 之前添加引擎脚本标签
|
|
@@ -87,6 +88,20 @@ export function vitePlayCanvasPlugin(options) {
|
|
|
87
88
|
}
|
|
88
89
|
return code;
|
|
89
90
|
},
|
|
91
|
+
async generateBundle(_outputOptions, bundle) {
|
|
92
|
+
if (options.outputFormat !== 'html') {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
for (const asset of Object.values(bundle)) {
|
|
96
|
+
if (asset.type !== 'asset' || !asset.fileName.endsWith('.html')) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const source = typeof asset.source === 'string'
|
|
100
|
+
? asset.source
|
|
101
|
+
: new TextDecoder().decode(asset.source);
|
|
102
|
+
asset.source = await compressInlineBuildJsonDataUrls(source, options);
|
|
103
|
+
}
|
|
104
|
+
},
|
|
90
105
|
};
|
|
91
106
|
}
|
|
92
107
|
/**
|
|
@@ -184,7 +199,7 @@ async function generateAndInlineSettings(html, baseBuildDir, options, pluginCont
|
|
|
184
199
|
configJson = applyMraidConfig(configJson);
|
|
185
200
|
}
|
|
186
201
|
// 生成 config 值
|
|
187
|
-
const configValue = buildConfigValue(configJson);
|
|
202
|
+
const configValue = await buildConfigValue(configJson, options, 'config.json');
|
|
188
203
|
// 生成 scene data URL
|
|
189
204
|
let sceneDataUrl = '';
|
|
190
205
|
if (configJson.scenes && configJson.scenes.length > 0) {
|
|
@@ -193,14 +208,14 @@ async function generateAndInlineSettings(html, baseBuildDir, options, pluginCont
|
|
|
193
208
|
const scenePath = path.join(baseBuildDir, sceneUrl);
|
|
194
209
|
try {
|
|
195
210
|
const sceneContent = await fs.readFile(scenePath, 'utf-8');
|
|
196
|
-
sceneDataUrl = `
|
|
211
|
+
sceneDataUrl = await buildJsonDataUrl(sceneContent, options, `scene ${sceneUrl}`);
|
|
197
212
|
}
|
|
198
213
|
catch (error) {
|
|
199
214
|
console.warn(`警告: 场景文件不存在: ${sceneUrl}`);
|
|
200
215
|
}
|
|
201
216
|
}
|
|
202
217
|
else {
|
|
203
|
-
sceneDataUrl = sceneUrl;
|
|
218
|
+
sceneDataUrl = await normalizeJsonDataUrl(sceneUrl || '', options, `scene ${sceneUrl || 'inline data URL'}`);
|
|
204
219
|
}
|
|
205
220
|
}
|
|
206
221
|
const appProps = configJson.application_properties || {};
|
|
@@ -292,7 +307,7 @@ async function inlineESMBundleAssets(html, baseBuildDir, options, pluginContext)
|
|
|
292
307
|
const scenePath = path.join(baseBuildDir, sceneUrl);
|
|
293
308
|
try {
|
|
294
309
|
const sceneContent = await fs.readFile(scenePath, 'utf-8');
|
|
295
|
-
sceneDataUrl = `
|
|
310
|
+
sceneDataUrl = await buildJsonDataUrl(sceneContent, options, `scene ${sceneUrl}`);
|
|
296
311
|
console.log(`[PlayCanvasPlugin] ESM Bundle: 场景已内联: ${sceneUrl}`);
|
|
297
312
|
}
|
|
298
313
|
catch (error) {
|
|
@@ -300,7 +315,7 @@ async function inlineESMBundleAssets(html, baseBuildDir, options, pluginContext)
|
|
|
300
315
|
}
|
|
301
316
|
}
|
|
302
317
|
else {
|
|
303
|
-
sceneDataUrl = sceneUrl || '';
|
|
318
|
+
sceneDataUrl = await normalizeJsonDataUrl(sceneUrl || '', options, `scene ${sceneUrl || 'inline data URL'}`);
|
|
304
319
|
}
|
|
305
320
|
}
|
|
306
321
|
// 7. (Logo removed - no longer needed)
|
|
@@ -326,7 +341,7 @@ async function inlineESMBundleAssets(html, baseBuildDir, options, pluginContext)
|
|
|
326
341
|
const escapedSceneDataUrl = sceneDataUrl.replace(/\$/g, '$$$$');
|
|
327
342
|
// 查找 SCENE_PATH 的各种格式并替换
|
|
328
343
|
// 格式: const SCENE_PATH = "xxx.json" 或 SCENE_PATH = "xxx"
|
|
329
|
-
html = html.replace(/SCENE_PATH\s*=\s*["'][^"']+\.json["']/g, `SCENE_PATH = "${escapedSceneDataUrl}"`);
|
|
344
|
+
html = html.replace(/SCENE_PATH\s*=\s*["'](?:[^"']+\.json|data:application\/json;base64,[^"']+)["']/g, `SCENE_PATH = "${escapedSceneDataUrl}"`);
|
|
330
345
|
// ⚠️ Vite 压缩后格式:loadScene("xxx.json" 或 loadScene(变量名,
|
|
331
346
|
// 需要同时替换 loadScene 调用中的场景路径字符串
|
|
332
347
|
// 格式1: .loadScene("2412781.json", ...)
|
|
@@ -349,6 +364,10 @@ async function inlineESMBundleAssets(html, baseBuildDir, options, pluginContext)
|
|
|
349
364
|
console.log(`[PlayCanvasPlugin] ESM Bundle: 替换了 ${varDefReplaceCount} 处场景路径变量定义`);
|
|
350
365
|
}
|
|
351
366
|
}
|
|
367
|
+
else if (sceneUrl && sceneUrl !== sceneDataUrl && sceneUrl.startsWith('data:application/json;base64,')) {
|
|
368
|
+
html = html.split(sceneUrl).join(escapedSceneDataUrl);
|
|
369
|
+
console.log('[PlayCanvasPlugin] ESM Bundle: 已重新压缩内联场景 data URL');
|
|
370
|
+
}
|
|
352
371
|
}
|
|
353
372
|
// 10. 处理 PRELOAD_MODULES(WASM 模块)
|
|
354
373
|
// 在 ESM Bundle 模式下,需要将 PRELOAD_MODULES 中的 URL 也转换为 data URL
|
|
@@ -441,7 +460,7 @@ async function convertSettingsToDataUrls(settingsCode, baseBuildDir, options, pl
|
|
|
441
460
|
// 1. 转换 config.json(传递插件上下文)
|
|
442
461
|
settingsCode = await convertConfigUrl(settingsCode, baseBuildDir, options, pluginContext);
|
|
443
462
|
// 2. 转换场景文件
|
|
444
|
-
settingsCode = await convertSceneUrl(settingsCode, baseBuildDir);
|
|
463
|
+
settingsCode = await convertSceneUrl(settingsCode, baseBuildDir, options);
|
|
445
464
|
// 3. 转换 PRELOAD_MODULES(优先使用 JS fallback)
|
|
446
465
|
settingsCode = await convertPreloadModules(settingsCode, baseBuildDir, options);
|
|
447
466
|
return settingsCode;
|
|
@@ -471,7 +490,7 @@ async function convertConfigUrl(settingsCode, baseBuildDir, options, pluginConte
|
|
|
471
490
|
if (options.mraidSupport) {
|
|
472
491
|
configJson = applyMraidConfig(configJson);
|
|
473
492
|
}
|
|
474
|
-
const configValue = buildConfigValue(configJson);
|
|
493
|
+
const configValue = await buildConfigValue(configJson, options, 'config.json');
|
|
475
494
|
return settingsCode.replace(/window\.CONFIG_FILENAME\s*=\s*"[^"]+"/, `window.CONFIG_FILENAME = ${configValue}`);
|
|
476
495
|
}
|
|
477
496
|
catch (error) {
|
|
@@ -482,19 +501,23 @@ async function convertConfigUrl(settingsCode, baseBuildDir, options, pluginConte
|
|
|
482
501
|
/**
|
|
483
502
|
* 转换 SCENE_PATH 为 data URL
|
|
484
503
|
*/
|
|
485
|
-
async function convertSceneUrl(settingsCode, baseBuildDir) {
|
|
504
|
+
async function convertSceneUrl(settingsCode, baseBuildDir, options) {
|
|
486
505
|
const sceneMatch = settingsCode.match(/window\.SCENE_PATH\s*=\s*"([^"]+)"/);
|
|
487
506
|
if (!sceneMatch) {
|
|
488
507
|
return settingsCode;
|
|
489
508
|
}
|
|
490
509
|
const scenePath = sceneMatch[1];
|
|
491
510
|
if (scenePath.startsWith('data:') || !scenePath) {
|
|
492
|
-
|
|
511
|
+
const sceneDataUrl = await normalizeJsonDataUrl(scenePath, options, `scene ${scenePath ? 'inline data URL' : 'empty'}`);
|
|
512
|
+
if (sceneDataUrl === scenePath) {
|
|
513
|
+
return settingsCode; // 已经是 data URL 或为空
|
|
514
|
+
}
|
|
515
|
+
return settingsCode.replace(/window\.SCENE_PATH\s*=\s*"[^"]+"/, `window.SCENE_PATH = "${sceneDataUrl}"`);
|
|
493
516
|
}
|
|
494
517
|
const fullScenePath = path.join(baseBuildDir, scenePath);
|
|
495
518
|
try {
|
|
496
519
|
const sceneContent = await fs.readFile(fullScenePath, 'utf-8');
|
|
497
|
-
const sceneDataUrl = `
|
|
520
|
+
const sceneDataUrl = await buildJsonDataUrl(sceneContent, options, `scene ${scenePath}`);
|
|
498
521
|
return settingsCode.replace(/window\.SCENE_PATH\s*=\s*"[^"]+"/, `window.SCENE_PATH = "${sceneDataUrl}"`);
|
|
499
522
|
}
|
|
500
523
|
catch (error) {
|
|
@@ -911,6 +934,30 @@ async function inlineConfigAssetUrls(configJson, baseBuildDir, pluginContext) {
|
|
|
911
934
|
const skippedScripts = [];
|
|
912
935
|
const missingAssets = []; // 新增:缺失资源列表
|
|
913
936
|
const SIZE_LIMIT = 1 * 1024 * 1024; // 1MB - 跳过超过这个大小的文件
|
|
937
|
+
const clearDataUrlMetadata = (targetFile) => {
|
|
938
|
+
if ('hash' in targetFile) {
|
|
939
|
+
delete targetFile.hash;
|
|
940
|
+
}
|
|
941
|
+
if ('variants' in targetFile) {
|
|
942
|
+
delete targetFile.variants;
|
|
943
|
+
}
|
|
944
|
+
};
|
|
945
|
+
const usePlaceholderAsset = (targetAsset, targetFile, targetFileName, size) => {
|
|
946
|
+
targetFile.url = getPlaceholderDataUrl(targetAsset.type, targetFileName);
|
|
947
|
+
if (size !== undefined) {
|
|
948
|
+
targetFile.size = size;
|
|
949
|
+
}
|
|
950
|
+
clearDataUrlMetadata(targetFile);
|
|
951
|
+
targetAsset.preload = false;
|
|
952
|
+
};
|
|
953
|
+
const isImageAsset = (targetAsset, targetFile, targetFileName) => {
|
|
954
|
+
const hasImageFile = isImageFile(targetFileName) ||
|
|
955
|
+
isImageFile(targetFile?.filename || '') ||
|
|
956
|
+
isImageFile(targetAsset.name || '');
|
|
957
|
+
return (targetAsset.type === 'texture' ||
|
|
958
|
+
targetAsset.type === 'textureatlas' ||
|
|
959
|
+
hasImageFile);
|
|
960
|
+
};
|
|
914
961
|
for (const [assetId, asset] of Object.entries(assets)) {
|
|
915
962
|
const file = asset?.file;
|
|
916
963
|
if (!file?.url || typeof file.url !== 'string') {
|
|
@@ -922,32 +969,22 @@ async function inlineConfigAssetUrls(configJson, baseBuildDir, pluginContext) {
|
|
|
922
969
|
}
|
|
923
970
|
const cleanUrl = url.split('?')[0];
|
|
924
971
|
const fileName = cleanUrl.toLowerCase();
|
|
972
|
+
const imageAsset = isImageAsset(asset, file, fileName);
|
|
925
973
|
// ⚠️ 脚本资源特殊处理(参考 PlayCanvas 官方 one-page.js 实现)
|
|
926
974
|
// ESM Bundle 模式下,脚本代码已被打包到 IIFE 中执行
|
|
927
975
|
// PlayCanvas 官方做法:将脚本内容设为空,配合引擎补丁跳过执行
|
|
928
976
|
if (cleanUrl === '__game-scripts.js') {
|
|
929
|
-
// __game-scripts.js 是 Classic 模式的打包产物
|
|
930
977
|
skippedScripts.push(asset.name || assetId);
|
|
931
|
-
// 使用空内容的 data URL(PlayCanvas 官方做法)
|
|
932
978
|
file.url = 'data:text/javascript;base64,';
|
|
933
|
-
file
|
|
979
|
+
clearDataUrlMetadata(file);
|
|
934
980
|
asset.preload = false;
|
|
935
981
|
continue;
|
|
936
982
|
}
|
|
937
983
|
if (asset.type === 'script') {
|
|
938
984
|
skippedScripts.push(asset.name || assetId);
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
//
|
|
943
|
-
// 我们的方案:
|
|
944
|
-
// 1. 将脚本 URL 设为空内容的 data URL
|
|
945
|
-
// 2. 清空 hash 避免追加查询参数
|
|
946
|
-
// 3. 脚本代码已经通过 IIFE 中的 pc.createScript() 注册
|
|
947
|
-
// 4. 需要配合引擎补丁让 ScriptHandler 跳过空脚本
|
|
948
|
-
file.url = 'data:text/javascript;base64,'; // 空 JavaScript
|
|
949
|
-
file.hash = ''; // 清空 hash
|
|
950
|
-
asset.preload = false; // 禁用预加载
|
|
985
|
+
file.url = 'data:text/javascript;base64,';
|
|
986
|
+
clearDataUrlMetadata(file);
|
|
987
|
+
asset.preload = false;
|
|
951
988
|
continue;
|
|
952
989
|
}
|
|
953
990
|
// 跳过物理引擎缓存文件和大型文本文件
|
|
@@ -956,10 +993,7 @@ async function inlineConfigAssetUrls(configJson, baseBuildDir, pluginContext) {
|
|
|
956
993
|
fileName.includes('deferredbrowsermetrics') ||
|
|
957
994
|
(fileName.endsWith('.txt') && file.size && file.size > SIZE_LIMIT)) {
|
|
958
995
|
skippedAssets.push(`${asset.name || assetId} (${(file.size / 1024 / 1024).toFixed(2)}MB)`);
|
|
959
|
-
|
|
960
|
-
asset.preload = false;
|
|
961
|
-
// 提供有效的占位符数据,避免解析错误
|
|
962
|
-
file.url = getPlaceholderDataUrl(asset.type, fileName);
|
|
996
|
+
usePlaceholderAsset(asset, file, fileName);
|
|
963
997
|
continue;
|
|
964
998
|
}
|
|
965
999
|
const fullPath = path.join(baseBuildDir, cleanUrl);
|
|
@@ -970,16 +1004,13 @@ async function inlineConfigAssetUrls(configJson, baseBuildDir, pluginContext) {
|
|
|
970
1004
|
// 检查空文件,提供有效的占位符
|
|
971
1005
|
if (originalSize === 0) {
|
|
972
1006
|
console.warn(`⚠️ 警告: 文件为空,使用占位符: ${asset.name || cleanUrl}`);
|
|
973
|
-
|
|
974
|
-
file.size = 0;
|
|
1007
|
+
usePlaceholderAsset(asset, file, fileName, 0);
|
|
975
1008
|
continue;
|
|
976
1009
|
}
|
|
977
|
-
//
|
|
978
|
-
if (originalSize > SIZE_LIMIT) {
|
|
1010
|
+
// Non-image files cannot be optimized here, so keep the original size guard.
|
|
1011
|
+
if (originalSize > SIZE_LIMIT && !imageAsset) {
|
|
979
1012
|
skippedAssets.push(`${asset.name || assetId} (${(originalSize / 1024 / 1024).toFixed(2)}MB)`);
|
|
980
|
-
asset
|
|
981
|
-
// 提供有效的占位符数据,避免解析错误
|
|
982
|
-
file.url = getPlaceholderDataUrl(asset.type, fileName);
|
|
1013
|
+
usePlaceholderAsset(asset, file, fileName);
|
|
983
1014
|
continue;
|
|
984
1015
|
}
|
|
985
1016
|
let dataUrl;
|
|
@@ -1011,7 +1042,7 @@ async function inlineConfigAssetUrls(configJson, baseBuildDir, pluginContext) {
|
|
|
1011
1042
|
// ⚠️ 重要:字体纹理(type === 'font')不能转换为 WebP!
|
|
1012
1043
|
// MSDF/位图字体的 PNG 纹理包含精确的距离场/像素数据,
|
|
1013
1044
|
// WebP 有损压缩会破坏这些数据导致字体渲染异常
|
|
1014
|
-
else if (
|
|
1045
|
+
else if (imageAsset) {
|
|
1015
1046
|
const isFontTexture = asset.type === 'font';
|
|
1016
1047
|
const compressed = await compressImage(buffer, cleanUrl, {
|
|
1017
1048
|
convertToWebP: !isFontTexture, // 字体纹理不转换为 WebP
|
|
@@ -1048,16 +1079,19 @@ async function inlineConfigAssetUrls(configJson, baseBuildDir, pluginContext) {
|
|
|
1048
1079
|
dataUrl = `data:${mime};base64,${buffer.toString('base64')}`;
|
|
1049
1080
|
finalSize = originalSize;
|
|
1050
1081
|
}
|
|
1082
|
+
if (finalSize > SIZE_LIMIT && !imageAsset) {
|
|
1083
|
+
skippedAssets.push(`${asset.name || assetId} (${(finalSize / 1024 / 1024).toFixed(2)}MB after optimization)`);
|
|
1084
|
+
usePlaceholderAsset(asset, file, fileName, finalSize);
|
|
1085
|
+
continue;
|
|
1086
|
+
}
|
|
1087
|
+
if (finalSize > SIZE_LIMIT && imageAsset) {
|
|
1088
|
+
console.warn(`[PlayCanvasPlugin] Image asset kept as data URL even though it exceeds inline limit: ${asset.name || assetId} (${(finalSize / 1024 / 1024).toFixed(2)}MB)`);
|
|
1089
|
+
}
|
|
1051
1090
|
// 更新 asset 配置
|
|
1052
1091
|
file.url = dataUrl;
|
|
1053
1092
|
file.size = finalSize;
|
|
1054
1093
|
// data URL 不需要 hash/variants,避免引擎追加 ?t=hash 造成无效 URL
|
|
1055
|
-
|
|
1056
|
-
delete file.hash;
|
|
1057
|
-
}
|
|
1058
|
-
if ('variants' in file) {
|
|
1059
|
-
delete file.variants;
|
|
1060
|
-
}
|
|
1094
|
+
clearDataUrlMetadata(file);
|
|
1061
1095
|
}
|
|
1062
1096
|
catch (error) {
|
|
1063
1097
|
// 记录缺失资源详情,不只是简单警告
|
|
@@ -1284,9 +1318,80 @@ function guessMimeType(filePath) {
|
|
|
1284
1318
|
return 'application/octet-stream';
|
|
1285
1319
|
}
|
|
1286
1320
|
}
|
|
1287
|
-
function buildConfigValue(configJson) {
|
|
1321
|
+
async function buildConfigValue(configJson, options, label) {
|
|
1288
1322
|
const configText = JSON.stringify(configJson);
|
|
1289
|
-
|
|
1323
|
+
const dataUrl = await buildJsonDataUrl(configText, options, label);
|
|
1324
|
+
return `"${dataUrl}"`;
|
|
1325
|
+
}
|
|
1326
|
+
async function buildJsonDataUrl(jsonText, options, label) {
|
|
1327
|
+
let compactJsonText = jsonText;
|
|
1328
|
+
try {
|
|
1329
|
+
compactJsonText = JSON.stringify(JSON.parse(jsonText));
|
|
1330
|
+
}
|
|
1331
|
+
catch {
|
|
1332
|
+
// Keep the original text if it is not strict JSON.
|
|
1333
|
+
}
|
|
1334
|
+
if (!options.compressConfigJson) {
|
|
1335
|
+
return `data:application/json;base64,${Buffer.from(compactJsonText).toString('base64')}`;
|
|
1336
|
+
}
|
|
1337
|
+
const lz4 = await loadLz4Module();
|
|
1338
|
+
const source = Buffer.from(compactJsonText);
|
|
1339
|
+
const compressed = lz4.compress(source);
|
|
1340
|
+
const compressedBase64 = Buffer.from(compressed).toString('base64');
|
|
1341
|
+
const ratio = ((1 - compressed.length / source.length) * 100).toFixed(1);
|
|
1342
|
+
console.log(`[PlayCanvasPlugin] ${label} compressed (${formatBytes(source.length)} -> ${formatBytes(compressed.length)}, -${ratio}%)`);
|
|
1343
|
+
return `data:application/x-lz4-json;base64,${compressedBase64}`;
|
|
1344
|
+
}
|
|
1345
|
+
async function normalizeJsonDataUrl(dataUrl, options, label) {
|
|
1346
|
+
const plainJsonPrefix = 'data:application/json;base64,';
|
|
1347
|
+
if (!options.compressConfigJson || !dataUrl.startsWith(plainJsonPrefix)) {
|
|
1348
|
+
return dataUrl;
|
|
1349
|
+
}
|
|
1350
|
+
try {
|
|
1351
|
+
const jsonText = Buffer.from(dataUrl.slice(plainJsonPrefix.length), 'base64').toString('utf-8');
|
|
1352
|
+
return await buildJsonDataUrl(jsonText, options, label);
|
|
1353
|
+
}
|
|
1354
|
+
catch (error) {
|
|
1355
|
+
console.warn(`[PlayCanvasPlugin] 无法重新压缩 JSON data URL: ${label}`, error);
|
|
1356
|
+
return dataUrl;
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
async function compressInlineBuildJsonDataUrls(html, options) {
|
|
1360
|
+
if (!options.compressConfigJson) {
|
|
1361
|
+
return html;
|
|
1362
|
+
}
|
|
1363
|
+
const pattern = /data:application\/json;base64,[A-Za-z0-9+/=]+/g;
|
|
1364
|
+
let nextHtml = '';
|
|
1365
|
+
let lastIndex = 0;
|
|
1366
|
+
let compressedCount = 0;
|
|
1367
|
+
for (const match of html.matchAll(pattern)) {
|
|
1368
|
+
const dataUrl = match[0];
|
|
1369
|
+
const index = match.index ?? 0;
|
|
1370
|
+
try {
|
|
1371
|
+
const jsonText = Buffer
|
|
1372
|
+
.from(dataUrl.slice('data:application/json;base64,'.length), 'base64')
|
|
1373
|
+
.toString('utf-8');
|
|
1374
|
+
const parsed = JSON.parse(jsonText);
|
|
1375
|
+
const isSceneJson = parsed && typeof parsed === 'object' && parsed.entities && parsed.settings;
|
|
1376
|
+
const isConfigJson = parsed && typeof parsed === 'object' && parsed.application_properties && parsed.assets;
|
|
1377
|
+
if (!isSceneJson && !isConfigJson) {
|
|
1378
|
+
continue;
|
|
1379
|
+
}
|
|
1380
|
+
const label = isSceneJson ? 'inline scene data URL' : 'inline config data URL';
|
|
1381
|
+
const compressedDataUrl = await buildJsonDataUrl(jsonText, options, label);
|
|
1382
|
+
nextHtml += html.slice(lastIndex, index) + compressedDataUrl;
|
|
1383
|
+
lastIndex = index + dataUrl.length;
|
|
1384
|
+
compressedCount++;
|
|
1385
|
+
}
|
|
1386
|
+
catch {
|
|
1387
|
+
continue;
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
if (compressedCount === 0) {
|
|
1391
|
+
return html;
|
|
1392
|
+
}
|
|
1393
|
+
console.log(`[PlayCanvasPlugin] 重新压缩 ${compressedCount} 个内联 JSON data URL`);
|
|
1394
|
+
return nextHtml + html.slice(lastIndex);
|
|
1290
1395
|
}
|
|
1291
1396
|
function applyMraidConfig(configJson) {
|
|
1292
1397
|
const next = { ...configJson };
|