@playcraft/cli 0.1.64 → 0.1.66

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 (29) hide show
  1. package/README.md +24 -31
  2. package/dist/commands/build.js +70 -5
  3. package/dist/commands/create.js +4 -4
  4. package/dist/commands/inspect.js +4 -6
  5. package/dist/index.js +27 -19
  6. package/dist/model-adaptation/node-inspection.js +173 -0
  7. package/dist/model-adaptation/project-template.js +40 -8
  8. package/dist/model-adaptation/project.js +2 -2
  9. package/dist/model-preparation/pipeline-engine.js +157 -0
  10. package/dist/model-preparation/pipeline-files.js +108 -0
  11. package/dist/model-preparation/pipeline-worker.js +32 -0
  12. package/dist/workspace-runtime/batch/deliver.js +2 -2
  13. package/dist/workspace-runtime/codex/delivery-tools.js +36 -3
  14. package/dist/workspace-runtime/previews/publish.js +2 -2
  15. package/dist/workspace-runtime/serve.js +5 -0
  16. package/dist/workspace-runtime/server/directory-access.js +1 -1
  17. package/dist/workspace-runtime/server/dispatch.js +27 -47
  18. package/dist/workspace-runtime/server/http.js +12 -0
  19. package/dist/workspace-runtime/workspaces/build-isolation.js +75 -0
  20. package/dist/workspace-runtime/workspaces/model-build-inputs.js +27 -0
  21. package/dist/workspace-runtime/workspaces/model-preparation-store.js +134 -0
  22. package/dist/workspace-runtime/workspaces/model-preparation.js +357 -0
  23. package/dist/workspace-runtime/workspaces/platform-export.js +1 -0
  24. package/package.json +4 -4
  25. package/project-template/.claude/skills/playcraft-build-optimizer/SKILL.md +19 -39
  26. package/project-templates/empty/AGENTS.md +6 -4
  27. package/project-templates/phaser/.claude/skills/playcraft-build-optimizer/SKILL.md +19 -39
  28. package/project-templates/threejs/.claude/skills/playcraft-build-optimizer/SKILL.md +19 -39
  29. package/dist/workspace-runtime/workspaces/delivery-build.js +0 -440
package/README.md CHANGED
@@ -258,16 +258,16 @@ PLAYCRAFT_TOOL_REQUEST_ID=<错误中显示的请求编号> playcraft tools build
258
258
 
259
259
  ### 构建相关
260
260
 
261
- | 命令 | 说明 | 示例 |
262
- | ---------------- | ---------------------------------------- | ------------------------------------------------------ |
263
- | `build` | 完整打包(阶段1 + 阶段2) | `playcraft build --platform facebook` |
264
- | `build-base` | 仅执行阶段1(生成多文件产物) | `playcraft build-base ./src --output ./build` |
265
- | `build-playable` | 仅执行阶段2(从多文件产物生成 Playable) | `playcraft build-playable ./build --platform facebook` |
266
- | `analyze` | 生成打包分析报告 | `playcraft analyze --platform facebook` |
261
+ | 命令 | 说明 | 示例 |
262
+ | ---------------- | ---------------------------------------------- | ------------------------------------------------------ |
263
+ | `build` | 本地构建预览,产物为 `dist/preview/index.html` | `playcraft build` |
264
+ | `build-base` | 仅执行阶段1(生成多文件产物) | `playcraft build-base ./src --output ./build` |
265
+ | `build-playable` | 仅执行阶段2(从多文件产物生成 Playable) | `playcraft build-playable ./build --platform facebook` |
266
+ | `analyze` | 生成打包分析报告 | `playcraft analyze --platform facebook` |
267
267
 
268
268
  ## 🎯 支持的平台
269
269
 
270
- 现已支持 **10 个主流广告平台**:
270
+ 平台导出支持以下主流广告平台(渠道包通过平台导出生成,`playcraft build` 只做本地预览构建):
271
271
 
272
272
  | 平台 | 格式 | 大小限制 | SDK/API |
273
273
  | ----------------- | -------- | -------------------- | ------------ |
@@ -296,45 +296,39 @@ playcraft start --project your-project-id
296
296
  # 3. 在本地 IDE 编辑代码,云端编辑器自动同步
297
297
  ```
298
298
 
299
- ### 打包 Playable Ads
299
+ ### 本地构建预览
300
300
 
301
301
  ```bash
302
- # Facebook 平台(ZIP,Phaser 产物格式)
303
- playcraft build --platform facebook --format zip --output ./dist/facebook
304
-
305
- # Snapchat 平台(ZIP)
306
- playcraft build --platform snapchat --format zip --output ./dist/snapchat
307
-
308
- # 批量打包所有平台
309
- for platform in facebook snapchat ironsource applovin google tiktok unity liftoff moloco bigo; do
310
- playcraft build --platform $platform --output ./dist/$platform
311
- done
302
+ # 在项目根目录构建,产物固定为 dist/preview/index.html
303
+ playcraft build
312
304
  ```
313
305
 
306
+ `playcraft build` 不提供渠道、格式和输出目录参数,各广告平台的渠道包请通过平台导出生成。下方的优化参数只影响本地预览,平台导出不会读取它们。
307
+
314
308
  ### 构建优化
315
309
 
316
310
  ```bash
317
311
  # 🎬 场景选择(推荐!可减小 30-60% 文件大小)
318
312
  # 交互式选择(自动检测多个场景并提示)
319
- playcraft build --platform facebook
313
+ playcraft build
320
314
 
321
315
  # 指定特定场景
322
- playcraft build --platform facebook --scenes BallGame
316
+ playcraft build --scenes BallGame
323
317
 
324
318
  # 指定多个场景
325
- playcraft build --platform facebook --scenes "MainMenu,Gameplay,Settings"
319
+ playcraft build --scenes "MainMenu,Gameplay,Settings"
326
320
 
327
321
  # 启用图片压缩
328
- playcraft build --platform facebook --compress-images --image-quality 75
322
+ playcraft build --compress-images --image-quality 75
329
323
 
330
324
  # 启用模型压缩
331
- playcraft build --platform facebook --compress-models --model-compression draco
325
+ playcraft build --compress-models --model-compression draco
332
326
 
333
327
  # 生成分析报告
334
- playcraft build --platform facebook --analyze
328
+ playcraft build --analyze
335
329
 
336
330
  # 组合使用(场景选择 + 图片压缩 + 分析)
337
- playcraft build --platform facebook --scenes MainScene --compress-images --analyze
331
+ playcraft build --scenes MainScene --compress-images --analyze
338
332
  ```
339
333
 
340
334
  ## ⚙️ 配置
@@ -350,9 +344,6 @@ playcraft build --platform facebook --scenes MainScene --compress-images --analy
350
344
  "dir": "./"
351
345
  },
352
346
  "build": {
353
- "platform": "facebook",
354
- "format": "html",
355
- "outputDir": "./dist",
356
347
  "useVite": true,
357
348
  "compressImages": true,
358
349
  "imageQuality": 75,
@@ -370,6 +361,8 @@ playcraft build --platform facebook --scenes MainScene --compress-images --analy
370
361
  3. 配置文件(`playcraft.config.json`)
371
362
  4. 默认值
372
363
 
364
+ `build` 字段只为本地构建补充命令行没有给出的优化参数。`playcraft build` 的渠道、格式和输出位置是固定的,配置文件里的 `platform`、`format`、`outputDir` 对它不起作用;渠道包请通过平台导出生成,平台导出不读取这个文件。
365
+
373
366
  ## 📁 数据目录
374
367
 
375
368
  CLI 会在 `~/.playcraft/` 目录下存储:
@@ -384,7 +377,7 @@ CLI 会在 `~/.playcraft/` 目录下存储:
384
377
  当项目包含多个场景时,CLI 会自动提供交互式场景选择:
385
378
 
386
379
  ```bash
387
- playcraft build --platform facebook
380
+ playcraft build
388
381
  ```
389
382
 
390
383
  **交互界面**:
@@ -411,10 +404,10 @@ playcraft build --platform facebook
411
404
 
412
405
  ```bash
413
406
  # 指定单个场景
414
- playcraft build --platform facebook --scenes MainScene
407
+ playcraft build --scenes MainScene
415
408
 
416
409
  # 指定多个场景
417
- playcraft build --platform facebook --scenes "MainScene,Gameplay"
410
+ playcraft build --scenes "MainScene,Gameplay"
418
411
  ```
419
412
 
420
413
  详见:[场景选择功能文档](../../docs/cli/scene-selection-feature.md)
@@ -8,9 +8,10 @@ import { cleanupBuildTempDirectory, createBuildTempDirectory } from './build-tem
8
8
  import inquirer from 'inquirer';
9
9
  import { tmpdir } from 'node:os';
10
10
  import { generatedWorkspace } from '../workspace-runtime/workspaces/artifact-receipts.js';
11
- import { isolateWorkspaceForBuild } from '../workspace-runtime/workspaces/delivery-build.js';
11
+ import { isolateWorkspaceForBuild } from '../workspace-runtime/workspaces/build-isolation.js';
12
12
  import { finalizeWorkspacePlayable, readWorkspacePlayable, workspacePlayableSignature } from '../workspace-runtime/previews/finalize.js';
13
13
  import { AgentApiClient } from '../utils/agent-api-client.js';
14
+ import { WORKSPACE_PLAYABLE_OUTPUT_PATH } from '@playcraft/common/cloud-workspace/runtime';
14
15
  /**
15
16
  * 类型守卫:检查对象是否符合 PlayableScriptsConfig 类型
16
17
  */
@@ -247,13 +248,52 @@ async function analyzeBaseBuild(baseBuildDir, ammoReplacement) {
247
248
  suggestedEngine: ammoCheck.suggestedEngine,
248
249
  };
249
250
  }
251
+ /**
252
+ * Format and clean the former `playcraft build` flags carried. Vite and draco model compression are
253
+ * already the builder's defaults, and leaving them unset lets playcraft.config.json supply them.
254
+ */
255
+ const BUILD_DEFAULTS = { format: 'html', clean: true };
256
+ /**
257
+ * `playcraft build`: a local review build of the current project. It always produces one HTML file at
258
+ * `dist/preview/index.html`; channel packaging is done by platform export, not by this command. The
259
+ * tuning options (compression, scenes, engine, ...) apply to this preview only.
260
+ * `output` and `base` are internal: the preview publisher builds into its own scratch folder, and the
261
+ * batch runtime only needs the multi-file base build.
262
+ */
263
+ export async function previewBuildCommand(projectPath, options = {}) {
264
+ const { output, base, ...tuning } = options;
265
+ const project = path.resolve(projectPath);
266
+ if (base)
267
+ return buildCommand(project, { ...BUILD_DEFAULTS, ...tuning, mode: 'base', ...(output ? { output } : {}) });
268
+ const preview = { ...BUILD_DEFAULTS, ...tuning, mode: 'full', platform: 'playcraft' };
269
+ if (output)
270
+ return buildCommand(project, { ...preview, output });
271
+ const workspace = process.env.PLAYCRAFT_BUILD_ISOLATED !== '1' ? await generatedWorkspace(project) : undefined;
272
+ // A workspace build isolates, validates and publishes the canonical preview itself.
273
+ if (workspace?.root === project)
274
+ return buildCommand(project, preview);
275
+ const scratch = await fs.mkdtemp(path.join(tmpdir(), 'playcraft-preview-build-'));
276
+ try {
277
+ await buildCommand(project, { ...preview, output: scratch });
278
+ const entries = await fs.readdir(scratch, { withFileTypes: true });
279
+ if (entries.length !== 1 || !entries[0].isFile() || !entries[0].name.endsWith('.html'))
280
+ throw new Error('preview_not_single_file');
281
+ const target = path.join(project, WORKSPACE_PLAYABLE_OUTPUT_PATH);
282
+ await fs.mkdir(path.dirname(target), { recursive: true });
283
+ await fs.copyFile(path.join(scratch, entries[0].name), target);
284
+ console.log(target);
285
+ }
286
+ finally {
287
+ await fs.rm(scratch, { recursive: true, force: true });
288
+ }
289
+ }
250
290
  export async function buildCommand(projectPath, options) {
251
291
  // 解析项目路径 - 在 spinner 之前执行以便调试
252
292
  const resolvedProjectPath = path.resolve(projectPath);
253
293
  const workspace = options.workspaceArtifact !== false && process.env.PLAYCRAFT_BUILD_ISOLATED !== '1' && options.mode === 'full' && options.platform === 'playcraft'
254
294
  && (!options.format || options.format === 'html') ? await generatedWorkspace(resolvedProjectPath) : undefined;
255
295
  const requestedOutput = options.output ? path.resolve(options.output) : path.join(resolvedProjectPath, 'dist');
256
- const canonicalOutput = [path.join(resolvedProjectPath, 'dist'), path.join(resolvedProjectPath, 'dist/playable')].includes(requestedOutput);
296
+ const canonicalOutput = [path.join(resolvedProjectPath, 'dist'), path.join(resolvedProjectPath, path.dirname(WORKSPACE_PLAYABLE_OUTPUT_PATH))].includes(requestedOutput);
257
297
  if (workspace?.root === resolvedProjectPath) {
258
298
  const prefix = requestedOutput.endsWith(path.sep) ? requestedOutput : `${requestedOutput}${path.sep}`;
259
299
  if (requestedOutput === workspace.root || workspace.root.startsWith(prefix))
@@ -269,7 +309,7 @@ export async function buildCommand(projectPath, options) {
269
309
  if (entries.length !== 1 || !entries[0].isFile() || !entries[0].name.endsWith('.html'))
270
310
  throw new Error('preview_not_single_file');
271
311
  await finalizeWorkspacePlayable(workspace.root, await fs.readFile(path.join(output, entries[0].name)), 'playcraft build', { expectedSignature: signature });
272
- console.log(path.join(workspace.root, 'dist/playable/index.html'));
312
+ console.log(path.join(workspace.root, WORKSPACE_PLAYABLE_OUTPUT_PATH));
273
313
  try {
274
314
  const { bytes } = await readWorkspacePlayable(workspace.root);
275
315
  const { previewUrl } = await new AgentApiClient().publishWorkspacePlayable({ workspaceId: workspace.workspaceId, directoryName: path.basename(workspace.root) }, bytes);
@@ -744,7 +784,32 @@ export async function buildCommand(projectPath, options) {
744
784
  ...(options.tracking === false ? { tracking: { enabled: false } } : {}),
745
785
  };
746
786
  if (fileConfig) {
747
- Object.assign(buildOptions, fileConfig);
787
+ // playcraft.config.json 只补充调用方没有给出的参数:显式参数,以及上面已确定的渠道、格式和
788
+ // 输出目录始终优先(playcraft build 固定这三项,配置文件不能把它改成渠道包或别的输出位置)。
789
+ const explicit = {
790
+ platform: buildOptions.platform,
791
+ format: buildOptions.format,
792
+ outputDir: buildOptions.outputDir,
793
+ compressEngine: options.compress,
794
+ compressConfigJson: options.compressConfig,
795
+ compressJS: options.compressJs,
796
+ analyze: options.analyze,
797
+ useVite: options.useVite,
798
+ cssMinify: options.cssMinify,
799
+ jsMinify: options.jsMinify,
800
+ compressImages: options.compressImages,
801
+ imageQuality: options.imageQuality,
802
+ convertToWebP: options.convertToWebP,
803
+ compressModels: options.compressModels,
804
+ modelCompression: options.modelCompression,
805
+ esmMode: options.esmMode,
806
+ storeUrls: options.storeUrls,
807
+ tracking: options.tracking === false ? false : undefined,
808
+ };
809
+ for (const [key, value] of Object.entries(fileConfig)) {
810
+ if (explicit[key] === undefined)
811
+ Object.assign(buildOptions, { [key]: value });
812
+ }
748
813
  }
749
814
  buildOptions.validation = {
750
815
  ...buildOptions.validation,
@@ -760,7 +825,7 @@ export async function buildCommand(projectPath, options) {
760
825
  if (isEditorProject) {
761
826
  throw new Error('检测到这是 PlayCanvas 编辑器项目,不是多文件构建产物。\n\n' +
762
827
  '请使用以下命令之一:\n' +
763
- ' 1. playcraft build <项目路径> # 完整构建(推荐)\n' +
828
+ ' 1. playcraft build <项目路径> # 本地构建预览(推荐)\n' +
764
829
  ' 2. playcraft build-base <项目路径> # 先生成多文件产物\n' +
765
830
  ' playcraft build-playable <产物路径> # 再转换为 Playable\n\n' +
766
831
  '💡 提示: build-playable 命令仅用于已有的多文件构建产物');
@@ -1067,8 +1067,8 @@ function printCreateNextSteps(template, _targetDir, name, here, worktree) {
1067
1067
  console.log(` ${pc.cyan('playcraft remix init-template --from-atom-tree')}`);
1068
1068
  console.log(` ${pc.cyan('npm run verify:contract && npm run dev')}`);
1069
1069
  console.log();
1070
- console.log(pc.dim('如需使用 PlayCraft CLI 打渠道包:'));
1071
- console.log(` ${pc.cyan('playcraft build . -p facebook')}`);
1070
+ console.log(pc.dim('本地构建预览(产物在 dist/preview/index.html,渠道包请使用平台导出):'));
1071
+ console.log(` ${pc.cyan('playcraft build .')}`);
1072
1072
  console.log();
1073
1073
  console.log(pc.dim('正式接入 PlayCraft 后,替换项目 ID:'));
1074
1074
  console.log(` ${pc.cyan('playcraft config set --key projectId --value <真实项目ID>')}`);
@@ -1081,8 +1081,8 @@ function printCreateNextSteps(template, _targetDir, name, here, worktree) {
1081
1081
  console.log(` ${pc.cyan('npm run build -- google')}`);
1082
1082
  console.log(` ${pc.cyan('npm run builds')}`);
1083
1083
  console.log();
1084
- console.log(pc.dim('或使用 PlayCraft CLI:'));
1085
- console.log(` ${pc.cyan(`playcraft build ${here ? '.' : name} -p facebook`)}`);
1084
+ console.log(pc.dim('或使用 PlayCraft CLI 本地构建预览(产物在 dist/preview/index.html):'));
1085
+ console.log(` ${pc.cyan(`playcraft build ${here ? '.' : name}`)}`);
1086
1086
  }
1087
1087
  console.log();
1088
1088
  }
@@ -106,18 +106,16 @@ export async function inspectCommand(projectPath) {
106
106
  console.log('\n' + pc.bold('💡 建议:\n'));
107
107
  if (projectType === 'build') {
108
108
  console.log(pc.green('✅ 这是一个构建后的项目,可以直接打包为 Playable Ad'));
109
- console.log(pc.dim(' 运行: playcraft build --platform facebook'));
109
+ console.log(pc.dim(' 运行: playcraft build'));
110
110
  }
111
111
  else if (projectType === 'playcraft-source' || projectType === 'playcanvas-source') {
112
112
  console.log(pc.yellow('⚠️ 这是源代码项目,建议先构建再打包\n'));
113
113
  console.log(pc.cyan('方式 1 (推荐): 使用 PlayCanvas REST API 构建'));
114
114
  console.log(pc.dim(' 1. 在 PlayCanvas Editor 中发布项目'));
115
115
  console.log(pc.dim(' 2. 使用 REST API 下载构建'));
116
- console.log(pc.dim(' 3. 运行: playcraft build --platform facebook\n'));
117
- console.log(pc.cyan('方式 2: 使用本地构建(实验性功能)'));
118
- console.log(pc.dim(' 运行: playcraft build --platform facebook --auto-build\n'));
119
- console.log(pc.cyan('方式 3: 直接从源代码打包(可能不完整)'));
120
- console.log(pc.dim(' 运行: playcraft build --platform facebook --skip-build'));
116
+ console.log(pc.dim(' 3. 运行: playcraft build\n'));
117
+ console.log(pc.cyan('方式 2: 直接本地构建'));
118
+ console.log(pc.dim(' 运行: playcraft build'));
121
119
  }
122
120
  else {
123
121
  console.log(pc.red('❌ 无法识别项目类型,请确保:'));
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { Command, CommanderError } from 'commander';
2
+ import { Command, CommanderError, Option } from 'commander';
3
3
  import { accountActivationMessage } from './auth/messages.js';
4
4
  import { readFileSync } from 'fs';
5
5
  import { fileURLToPath } from 'url';
@@ -11,7 +11,7 @@ import { stopCommand } from './commands/stop.js';
11
11
  import { statusCommand } from './commands/status.js';
12
12
  import { logsCommand } from './commands/logs.js';
13
13
  import { configCommand } from './commands/config.js';
14
- import { buildCommand } from './commands/build.js';
14
+ import { buildCommand, previewBuildCommand } from './commands/build.js';
15
15
  import { buildAllCommand } from './commands/build-all.js';
16
16
  import { upgradeCommand } from './commands/upgrade.js';
17
17
  import { checkForUpdates } from './utils/version-checker.js';
@@ -174,28 +174,34 @@ program
174
174
  token: options.token,
175
175
  });
176
176
  });
177
- // build 命令 - 完整流程(阶段1 + 阶段2)
177
+ /**
178
+ * 只保留命令行上显式给出的参数。commander 的默认值与构建器默认值相同,去掉后 playcraft.config.json
179
+ * 才能补充这些参数;否则默认值会被当成显式参数,压过配置文件。
180
+ */
181
+ function explicitOptions(command) {
182
+ const options = command.opts();
183
+ return Object.fromEntries(Object.keys(options)
184
+ .filter(key => command.getOptionValueSource(key) !== 'default')
185
+ .map(key => [key, options[key]]));
186
+ }
187
+ /** commander 把 --convert-webp / --no-convert-webp 解析成 convertWebp,构建器使用的字段是 convertToWebP。 */
188
+ function withConvertToWebP({ convertWebp, ...options }) {
189
+ return { ...options, convertToWebP: convertWebp };
190
+ }
191
+ // build 命令 - 本地构建当前项目用于 review,产物固定为 dist/preview/index.html;渠道导出走平台导出
178
192
  program
179
193
  .command('build')
180
- .description('打包项目为 Playable Ad(自动执行基础构建和渠道打包)')
194
+ .description('本地构建当前项目,产物输出到 dist/preview/index.html(渠道导出请使用平台导出)')
181
195
  .argument('[project-path]', '项目路径', process.cwd())
182
- .option('-p, --platform <platform>', '目标平台 (facebook|snapchat|ironsource|applovin|google|tiktok|unity|liftoff|moloco|bigo|inmobi|adikteev|remerge|mintegral)')
183
- .option('-f, --format <format>', '输出格式 (html|zip)', 'html')
184
- .option('-o, --output <path>', '输出目录', './dist')
185
196
  .option('-c, --config <file>', '配置文件路径')
186
197
  .option('-s, --scenes <scenes>', '选择场景: "all"=所有场景, "default"=默认场景, 或逗号分隔的场景名,如 "MainMenu,Gameplay"')
187
198
  .option('-e, --engine <engine>', '指定引擎类型 (playcanvas|phaser|pixijs|threejs|cocos|babylonjs|layaair|egret|generic),默认自动检测')
188
- .option('--clean', '构建前清理旧的输出目录(默认启用)', true)
189
- .option('--no-clean', '跳过清理旧的输出目录')
190
199
  .option('--compress', '压缩引擎代码(使用 LZ4,仅 PlayCanvas)')
191
200
  .option('--compress-config', '压缩 config.json(使用 LZ4,可减少 60-70% 体积,仅 PlayCanvas)')
192
201
  .option('--compress-js', '压缩所有 JS 代码(LZ4 源码保护,浏览器中无法查看变量名和方法名)')
193
202
  .option('--analyze', '生成打包分析报告')
194
203
  .option('--replace-ammo', '检测到 Ammo 时自动替换为 p2 或 cannon(仅 PlayCanvas)')
195
204
  .option('--ammo-engine <engine>', '指定替换物理引擎 (p2|cannon),仅 PlayCanvas')
196
- .option('--auto-build', '如果是源代码项目,自动执行本地构建')
197
- .option('--skip-build', '跳过本地构建,直接从源代码打包(可能不完整)')
198
- .option('--mode <mode>', '构建模式 (full|base|playable)')
199
205
  .option('--use-vite', '使用 Vite 构建(默认启用)', true)
200
206
  .option('--no-use-vite', '禁用 Vite,使用旧构建器')
201
207
  .option('--css-minify', '压缩 CSS(默认根据平台配置)')
@@ -212,8 +218,10 @@ program
212
218
  .option('--model-compression <method>', '模型压缩方法 (draco|meshopt)', 'draco')
213
219
  .option('--use-playable-scripts', '使用 @playcraft/devkit 构建工具打包(可玩广告项目专用)', false)
214
220
  .option('--no-tracking', '禁用埋点 tracking adapter 注入(默认启用)')
215
- .action(async (projectPath, options) => {
216
- await buildCommand(projectPath, options);
221
+ .addOption(new Option('-o, --output <path>').hideHelp())
222
+ .addOption(new Option('--base').hideHelp())
223
+ .action(async (projectPath, _options, command) => {
224
+ await previewBuildCommand(projectPath, withConvertToWebP(explicitOptions(command)));
217
225
  });
218
226
  // build-base 命令 - 只执行阶段1:基础构建
219
227
  program
@@ -262,9 +270,9 @@ program
262
270
  .option('--model-compression <method>', '模型压缩方法 (draco|meshopt)', 'draco')
263
271
  .option('--esm-mode <mode>', 'ESM 模块处理模式 (auto|enabled|disabled)', 'auto')
264
272
  .option('--no-tracking', '禁用埋点 tracking adapter 注入(默认启用)')
265
- .action(async (projectPath, options) => {
273
+ .action(async (projectPath, _options, command) => {
266
274
  await buildCommand(projectPath, {
267
- ...options,
275
+ ...withConvertToWebP(explicitOptions(command)),
268
276
  analyze: true,
269
277
  });
270
278
  });
@@ -300,9 +308,9 @@ program
300
308
  .option('--model-compression <method>', '模型压缩方法 (draco|meshopt)', 'draco')
301
309
  .option('--esm-mode <mode>', 'ESM 模块处理模式 (auto|enabled|disabled)', 'auto')
302
310
  .option('--no-tracking', '禁用埋点 tracking adapter 注入(默认启用)')
303
- .action(async (baseBuildDir, options) => {
311
+ .action(async (baseBuildDir, _options, command) => {
304
312
  await buildCommand(baseBuildDir, {
305
- ...options,
313
+ ...withConvertToWebP(explicitOptions(command)),
306
314
  mode: 'playable',
307
315
  });
308
316
  });
@@ -343,7 +351,7 @@ program
343
351
  .option('--theme <theme>', '指定要构建的主题(devkit 项目专用)')
344
352
  .option('--no-tracking', '禁用埋点 tracking adapter 注入(默认启用)')
345
353
  .action(async (projectPath, options) => {
346
- await buildAllCommand(projectPath, options);
354
+ await buildAllCommand(projectPath, withConvertToWebP(options));
347
355
  });
348
356
  // 新增:inspect 命令 - 检查项目类型
349
357
  program
@@ -0,0 +1,173 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { createRequire } from 'node:module';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { dirname, join } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ import sharp from 'sharp';
7
+ import { canonicalModelAdaptation, createModelAdaptationCore, inspectModelAdaptationSource, readModelSourceDocument, workspaceModelAdaptationSchema, } from '@playcraft/common/cloud-workspace/runtime';
8
+ const require = createRequire(import.meta.url);
9
+ const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
10
+ let serialized = Promise.resolve();
11
+ function assert(condition, code) { if (!condition)
12
+ throw Error(code); }
13
+ /** Actual engine loading and decoded pixels, with no renderer, browser or project code execution. */
14
+ export function inspectRigidModel(input) {
15
+ const result = serialized.then(() => inspect(input));
16
+ serialized = result.catch(() => undefined);
17
+ return result;
18
+ }
19
+ async function inspect(input) {
20
+ input.signal?.throwIfAborted();
21
+ const parsed = readModelSourceDocument(input.bytes), document = parsed.document;
22
+ const { binary, extraChunks } = parsed;
23
+ assert(binary && !extraChunks?.length && !(document.buffers ?? []).some((value) => value.uri)
24
+ && !(document.images ?? []).some((value) => value.uri), 'model_prepare_self_contained_glb_required');
25
+ assert(!(document.extensionsRequired ?? []).some((name) => ['KHR_draco_mesh_compression', 'KHR_texture_basisu', 'EXT_meshopt_compression'].includes(name)), 'model_prepare_decoder_not_configured');
26
+ assert((document.images ?? []).length <= 64, 'model_prepare_image_bound');
27
+ const threeEntry = require.resolve('three'), threeRoot = dirname(dirname(threeEntry));
28
+ const threePackage = JSON.parse(await readFile(join(threeRoot, 'package.json'), 'utf8'));
29
+ assert(threePackage.version === '0.183.2', 'model_prepare_loader_version_mismatch');
30
+ // Variable module URLs avoid inventing a second public engine type contract.
31
+ const THREE = await import(pathToFileURL(join(threeRoot, 'build/three.module.js')).href);
32
+ const loaderPath = require.resolve('three/examples/jsm/loaders/GLTFLoader.js');
33
+ const { GLTFLoader } = await import(pathToFileURL(loaderPath).href);
34
+ const descriptors = new Map(['self', 'ProgressEvent', 'createImageBitmap'].map(name => [name, Object.getOwnPropertyDescriptor(globalThis, name)]));
35
+ const pending = new Set(), decoded = [];
36
+ let loaded, factory;
37
+ const errors = [];
38
+ let decodedBytes = 0;
39
+ try {
40
+ Object.defineProperty(globalThis, 'self', { configurable: true, value: globalThis });
41
+ Object.defineProperty(globalThis, 'ProgressEvent', { configurable: true, value: class extends Event {
42
+ lengthComputable;
43
+ loaded;
44
+ total;
45
+ constructor(type, init = {}) { super(type); this.lengthComputable = Boolean(init.lengthComputable); this.loaded = init.loaded ?? 0; this.total = init.total ?? 0; }
46
+ } });
47
+ Object.defineProperty(globalThis, 'createImageBitmap', { configurable: true, value: (blob, options = {}) => {
48
+ const task = (async () => {
49
+ assert(blob instanceof Blob && blob.size <= 32 * 1024 * 1024, 'model_prepare_image_bound');
50
+ const bytes = Buffer.from(await blob.arrayBuffer());
51
+ let decoder = sharp(bytes, { limitInputPixels: 16_777_216 });
52
+ if (options.imageOrientation === 'flipY')
53
+ decoder = decoder.flip();
54
+ const metadata = await decoder.metadata(), allocation = (metadata.width ?? 0) * (metadata.height ?? 0) * 4;
55
+ assert(allocation > 0 && decodedBytes + allocation <= 128 * 1024 * 1024, 'model_prepare_decoded_image_budget');
56
+ decodedBytes += allocation;
57
+ const raw = await decoder.ensureAlpha().raw().toBuffer({ resolveWithObject: true });
58
+ assert(raw.info.channels === 4 && raw.data.length === raw.info.width * raw.info.height * 4, 'model_prepare_image_decode_failed');
59
+ const image = { width: raw.info.width, height: raw.info.height, data: new Uint8ClampedArray(raw.data), close() { this.data = null; } };
60
+ decoded.push({ image, encodedHash: hash(bytes), rgbaHash: hash(raw.data), width: image.width, height: image.height });
61
+ return image;
62
+ })();
63
+ pending.add(task);
64
+ void task.then(() => pending.delete(task), () => pending.delete(task));
65
+ return task;
66
+ } });
67
+ const manager = new THREE.LoadingManager();
68
+ manager.onError = () => errors.push('embedded-resource-load-failed');
69
+ loaded = await new GLTFLoader(manager).parseAsync(input.bytes.buffer.slice(input.bytes.byteOffset, input.bytes.byteOffset + input.bytes.byteLength), '');
70
+ await Promise.all([...pending]);
71
+ input.signal?.throwIfAborted();
72
+ assert(!errors.length, 'model_prepare_resource_load_failed');
73
+ const inventory = await inspectModelAdaptationSource({ source: loaded.scene, animationCount: loaded.animations.length, digestBytes: hash, signal: input.signal });
74
+ assert(!inventory.unsupported.length, 'model_prepare_rigid_source_required');
75
+ let triangles = 0;
76
+ loaded.scene.traverse((node) => {
77
+ if (node.isMesh && node.geometry) {
78
+ const count = node.geometry.index?.count ?? node.geometry.getAttribute('position')?.count ?? 0;
79
+ assert(Number.isSafeInteger(count) && count % 3 === 0, 'model_prepare_triangles_required');
80
+ triangles += count / 3;
81
+ }
82
+ });
83
+ assert(triangles > 0, 'model_prepare_empty_model');
84
+ let plan = workspaceModelAdaptationSchema.parse(input.adaptation ? { ...input.adaptation, source: input.source } : { schemaVersion: 1, source: input.source });
85
+ if (input.split) {
86
+ const ref = (role) => {
87
+ const expected = input.split.output.nodes.filter(node => node.role === role);
88
+ assert(expected.length === 1, 'model_prepare_split_roles_invalid');
89
+ const matches = inventory.nodes.filter(node => node.sourceNodeId === expected[0].sourceNodeId && node.name === expected[0].name);
90
+ assert(matches.length === 1 && matches[0].geometryHash, 'model_prepare_split_node_identity_mismatch');
91
+ return { sourceNodeId: matches[0].sourceNodeId, geometryHash: matches[0].geometryHash };
92
+ };
93
+ plan = workspaceModelAdaptationSchema.parse({ ...plan, hinges: [{ id: 'primary', movingNodeRef: ref('lid'), bodyNodeRefs: [ref('body')],
94
+ pivot: input.split.hinge.pivot, axis: input.split.hinge.axis, openAngleDegrees: input.split.hinge.openAngleDegrees }] });
95
+ }
96
+ assert(input.requiredCapability !== 'rigid-hinge' || plan.hinges.length > 0, 'model_prepare_needs_part_mapping');
97
+ const points = (node) => {
98
+ const result = [];
99
+ node.updateMatrixWorld(true);
100
+ node.traverse((item) => {
101
+ const position = item.isMesh && item.geometry?.getAttribute('position');
102
+ if (position)
103
+ for (let index = 0; index < position.count; index++) {
104
+ if (result.length >= 4_000_000)
105
+ throw Error('model_prepare_geometry_bound');
106
+ result.push(new THREE.Vector3().fromBufferAttribute(position, index).applyMatrix4(item.matrixWorld).toArray());
107
+ }
108
+ });
109
+ return result;
110
+ };
111
+ const distance = (before, after) => {
112
+ assert(before.length === after.length, 'model_prepare_geometry_changed');
113
+ return before.reduce((maximum, point, index) => Math.max(maximum, Math.hypot(...point.map((value, axis) => value - after[index][axis]))), 0);
114
+ };
115
+ const original = points(loaded.scene);
116
+ factory = await createModelAdaptationCore({ THREE, source: loaded.scene, sourceIdentity: input.source, plan,
117
+ animationCount: loaded.animations.length, digestBytes: hash, signal: input.signal });
118
+ const instance = factory.create(), samples = [];
119
+ try {
120
+ for (const declared of plan.hinges) {
121
+ const hinge = instance.getHinge(declared.id);
122
+ const body = hinge.body.map(node => points(node)), moving = points(hinge.moving);
123
+ for (const fraction of [.25, .5, .75, 1]) {
124
+ input.signal?.throwIfAborted();
125
+ const angleDegrees = declared.openAngleDegrees * fraction;
126
+ instance.setHinge(declared.id, angleDegrees);
127
+ const bodyDisplacement = Math.max(...hinge.body.map((node, index) => distance(body[index], points(node))));
128
+ const movingDisplacement = distance(moving, points(hinge.moving));
129
+ assert(bodyDisplacement <= 1e-8 && movingDisplacement > 1e-8, 'model_prepare_hinge_motion_failed');
130
+ samples.push({ hingeId: declared.id, angleDegrees, bodyDisplacement, movingDisplacement });
131
+ }
132
+ instance.setHinge(declared.id, 0);
133
+ assert(distance(moving, points(hinge.moving)) <= 1e-8, 'model_prepare_closed_recovery_failed');
134
+ }
135
+ assert(distance(original, points(loaded.scene)) === 0, 'model_prepare_source_mutated');
136
+ }
137
+ finally {
138
+ instance.dispose();
139
+ }
140
+ const dependencies = await Promise.all([['three', join(threeRoot, 'build/three.module.js')], ['three-core', join(threeRoot, 'build/three.core.js')], ['loader', loaderPath]]
141
+ .map(async ([name, path]) => ({ name: name, contentHash: hash(await readFile(path)) })));
142
+ return { schemaVersion: 1, kind: 'actual-engine-geometry-inspection', source: input.source, plan, inventory, samples, triangles,
143
+ decodedTextures: decoded.map(({ image: _image, ...value }) => value),
144
+ tool: { threeVersion: threePackage.version, sharpVersions: sharp.versions, nodeVersion: process.versions.node, dependencies,
145
+ dependencyHash: hash(canonicalModelAdaptation(dependencies)) },
146
+ rendered: false, browserStarted: false, modelAcceptance: false };
147
+ }
148
+ finally {
149
+ await Promise.allSettled([...pending]);
150
+ factory?.release();
151
+ const disposed = new Set();
152
+ loaded?.scene?.traverse((node) => {
153
+ for (const item of [node.geometry, ...(Array.isArray(node.material) ? node.material : node.material ? [node.material] : [])]) {
154
+ if (!item || disposed.has(item))
155
+ continue;
156
+ disposed.add(item);
157
+ for (const value of Object.values(item))
158
+ if (value?.isTexture && !disposed.has(value)) {
159
+ disposed.add(value);
160
+ value.dispose();
161
+ }
162
+ item.dispose();
163
+ }
164
+ });
165
+ for (const entry of decoded)
166
+ entry.image.close();
167
+ for (const [name, descriptor] of descriptors)
168
+ if (descriptor)
169
+ Object.defineProperty(globalThis, name, descriptor);
170
+ else
171
+ delete globalThis[name];
172
+ }
173
+ }
@@ -120,7 +120,7 @@ try {
120
120
  export const modelProjectStyle = `body{margin:0;background:#eee9e0;color:#202d38;font:16px system-ui}main{max-width:1120px;margin:auto;padding:24px}#stage{height:62vh;min-height:240px;border:1px solid #c2ccd1;border-radius:8px;overflow:hidden}canvas{display:block;width:100%;height:100%}#controls{display:flex;gap:12px;flex-wrap:wrap;margin-top:16px}button,input{padding:10px 18px;border:1px solid currentColor;border-radius:4px;background:#f8f4ec;color:inherit}button{cursor:pointer}label{display:inline-flex;align-items:center;gap:8px}input{width:110px}
121
121
  `;
122
122
  export const modelProjectPrepare = String.raw `import { createHash } from 'node:crypto';
123
- import { readFile, readdir } from 'node:fs/promises';
123
+ import { readFile, readdir, lstat } from 'node:fs/promises';
124
124
  import { join, relative } from 'node:path';
125
125
  const hash = bytes => createHash('sha256').update(bytes).digest('hex');
126
126
  const canonical = value => Array.isArray(value) ? '[' + value.map(canonical).join(',') + ']' : value && typeof value === 'object'
@@ -137,13 +137,45 @@ export async function prepareModelBuild(root) {
137
137
  }
138
138
  const packed = await readFile(join(root, 'src/assets/packed-model.glb')); if (hash(packed) !== meta.packedHash) throw Error('model_packed_bytes_changed');
139
139
  const core = await readFile(join(root, 'src/runtime/model-adaptation-core.js')); if (hash(core) !== meta.coreHash) throw Error('model_core_changed');
140
- const inputs = []; async function walk(directory) { for (const entry of await readdir(directory, { withFileTypes: true })) {
141
- const path = join(directory, entry.name); if (entry.isDirectory()) await walk(path); else if (entry.isFile()) inputs.push({ path: relative(root, path).split('\\').join('/'), contentHash: hash(await readFile(path)) }); else throw Error('model_input_not_regular');
142
- } }
143
- await walk(join(root, 'src')); await walk(join(root, 'source')); await walk(join(root, 'scripts'));
144
- for (const path of ['index.html','package.json','vite.config.mjs','.playcraft/workspace.json','.playcraft/model-adaptation.json','.playcraft/model-adaptation-lineage.json','package-lock.json','pnpm-lock.yaml']) {
145
- try { inputs.push({ path, contentHash: hash(await readFile(join(root, path))) }); } catch (error) { if (error.code !== 'ENOENT' || !path.endsWith('lock.json') && path !== 'pnpm-lock.yaml') throw error; }
146
- }
140
+ const inputs = [];
141
+ if (meta.buildBindingVersion === 2) {
142
+ const declaration = JSON.parse(await readFile(join(root, '.playcraft/model-build-inputs.json'), 'utf8'));
143
+ const model = declaration.models?.[0];
144
+ if (declaration.schemaVersion !== 1 || !Array.isArray(declaration.models) || declaration.models.length !== 1 ||
145
+ model?.source?.kind !== 'resource' || canonical(model.source) !== canonical(plan.source) || !Array.isArray(model.adaptationFiles)) throw Error('model_build_manifest_invalid');
146
+ const paths = new Map();
147
+ for (const file of [...model.source.files, ...model.adaptationFiles]) {
148
+ if (!file || typeof file.path !== 'string' || !file.path || file.path.length > 512 || /[\\\0:]/.test(file.path) || file.path.startsWith('/') ||
149
+ file.path.split('/').some(part => !part || part === '.' || part === '..') ||
150
+ file.contentHash !== undefined && !/^[a-f0-9]{64}$/.test(file.contentHash)) throw Error('model_build_manifest_invalid');
151
+ const previous = paths.get(file.path); if (previous?.contentHash && file.contentHash && previous.contentHash !== file.contentHash) throw Error('model_build_input_conflict');
152
+ paths.set(file.path, previous?.contentHash ? previous : file);
153
+ }
154
+ if (paths.size > 256) throw Error('model_build_input_limit');
155
+ const readInput = async path => {
156
+ let current = root; for (const [index, part] of path.split('/').entries()) {
157
+ current = join(current, part); const stat = await lstat(current);
158
+ if (stat.isSymbolicLink() || (index < path.split('/').length - 1 ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1 || stat.size > 64 * 1024 * 1024)) throw Error('model_input_not_regular');
159
+ }
160
+ return readFile(current);
161
+ };
162
+ for (const path of ['package.json','pnpm-lock.yaml','package-lock.json','yarn.lock']) {
163
+ try { const bytes = await readInput(path); if (!paths.has(path)) inputs.push({ path, contentHash: hash(bytes) }); }
164
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
165
+ }
166
+ for (const file of paths.values()) {
167
+ const contentHash = hash(await readInput(file.path)); if (file.contentHash && contentHash !== file.contentHash) throw Error('model_build_input_changed');
168
+ inputs.push({ path: file.path, contentHash });
169
+ }
170
+ } else if (meta.buildBindingVersion === undefined || meta.buildBindingVersion === 1) {
171
+ async function walk(directory) { for (const entry of await readdir(directory, { withFileTypes: true })) {
172
+ const path = join(directory, entry.name); if (entry.isDirectory()) await walk(path); else if (entry.isFile()) inputs.push({ path: relative(root, path).split('\\').join('/'), contentHash: hash(await readFile(path)) }); else throw Error('model_input_not_regular');
173
+ } }
174
+ await walk(join(root, 'src')); await walk(join(root, 'source')); await walk(join(root, 'scripts'));
175
+ for (const path of ['index.html','package.json','vite.config.mjs','.playcraft/workspace.json','.playcraft/model-adaptation.json','.playcraft/model-adaptation-lineage.json','package-lock.json','pnpm-lock.yaml']) {
176
+ try { inputs.push({ path, contentHash: hash(await readFile(join(root, path))) }); } catch (error) { if (error.code !== 'ENOENT' || !path.endsWith('lock.json') && path !== 'pnpm-lock.yaml') throw error; }
177
+ }
178
+ } else throw Error('model_build_binding_version_unsupported');
147
179
  inputs.sort((a,b) => a.path.localeCompare(b.path, 'en'));
148
180
  const binding = { sourceKind: 'external-model', candidateId: meta.candidateId, sourceHash: plan.source.contentHash,
149
181
  adaptationHash: hash(await readFile(join(root, 'src/adapter.js'))), buildHash: hash(canonical(inputs)) };