@playcraft/build 0.0.42 → 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: 5 * 60 * 1000,
653
- env: process.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 实际状态: ${error.message}`);
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
- console.error(`[PlayableScriptsAdapter] ❌ 找到 devkit 但无法定位 CLI: ${devkitDir}`);
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
- env: process.env,
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 = `data:application/json;base64,${Buffer.from(sceneContent).toString('base64')}`;
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 = `data:application/json;base64,${Buffer.from(sceneContent).toString('base64')}`;
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
- return settingsCode; // 已经是 data URL 或为空
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 = `data:application/json;base64,${Buffer.from(sceneContent).toString('base64')}`;
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.hash = '';
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
- // ⚠️ ESM 脚本特殊处理(参考 PlayCanvas 官方 one-page.js):
940
- // 官方工具对于 loadingType !== 0 的脚本,会将内容设为空字符串
941
- // 然后配合 one-page-inline-game-scripts.js 引擎补丁处理
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
- file.url = getPlaceholderDataUrl(asset.type, fileName);
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.preload = false;
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 (isImageFile(cleanUrl)) {
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
- if ('hash' in file) {
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
- return `"data:application/json;base64,${Buffer.from(configText).toString('base64')}"`;
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/build",
3
- "version": "0.0.42",
3
+ "version": "0.0.44",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",