@playcraft/devkit 1.0.27 → 1.0.28

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.
@@ -10,6 +10,7 @@ const { runThemeValidation, restoreSchema } = require('../../core/utils/validate
10
10
  const { runPreBuildChecks } = require('../../core/validators/pre-build-checker.js');
11
11
  const { FontInjectionWebpackPlugin } = require('../../core/plugins/FontInjectionWebpackPlugin.js');
12
12
  const { SocialMediaInjectorPlugin } = require('../../core/plugins/SocialMediaInjectorPlugin.js');
13
+ const { getPlaycraftOrientationSuffix } = require('../../core/utils/getPlaycraftOrientationSuffix.js');
13
14
  const { options } = require('../../core/options.js');
14
15
 
15
16
  /**
@@ -157,30 +158,80 @@ function continueWithBuild() {
157
158
  }
158
159
  }
159
160
 
160
- // 注入字体插件(build 模式 base64 内联)
161
- const fontPlugin = themeName
162
- ? new FontInjectionWebpackPlugin({ projectRoot, themeName, themeConfig })
163
- : null;
164
-
165
- // social_media 渠道:注入 OG / Twitter meta 标签,让 Facebook / LinkedIn
166
- // 等抓取预览时拿到正确的标题/描述/图片。无 shareMeta 时空跑,不影响构建
167
- const socialMediaPlugin = (themeName && adNetwork === 'social_media')
168
- ? new SocialMediaInjectorPlugin({ projectRoot, themeName, themeConfig })
169
- : null;
170
-
171
- // 执行构建,无论成功或失败都恢复 schema
172
- runBuild(undefined, undefined, undefined, {
173
- plugins: [
174
- ...(fontPlugin ? [fontPlugin] : []),
175
- ...(socialMediaPlugin ? [socialMediaPlugin] : []),
176
- ],
177
- })
178
- .then(() => {
161
+ // ============================================================
162
+ // 📌 横竖屏方向多版本构建
163
+ // ============================================================
164
+ // playcraftOrientationChannel 中指定的渠道,需要按 playcraftOrientation
165
+ // 列出的每个方向版本分别打一个包(渠道_L / 渠道_P / 渠道,即 both 无后缀);
166
+ // 不在列表中的渠道始终只按 both(自适应)打一个包。
167
+ const orientationChannels = options['playcraftOrientationChannel'] || [];
168
+ const orientationVersions =
169
+ Array.isArray(options['playcraftOrientation']) && options['playcraftOrientation'].length > 0
170
+ ? options['playcraftOrientation']
171
+ : ['both'];
172
+ const buildOrientations = orientationChannels.includes(adNetwork) ? orientationVersions : ['both'];
173
+
174
+ /**
175
+ * 构建单个方向版本
176
+ */
177
+ async function buildOneOrientation(orientationValue) {
178
+ const suffix = getPlaycraftOrientationSuffix(orientationValue);
179
+ if (buildOrientations.length > 1) {
180
+ console.log(`\n📱 开始构建方向版本: ${orientationValue}${suffix ? ` (后缀: ${suffix})` : ''}`);
181
+ }
182
+
183
+ // 注入字体插件(build 模式 base64 内联)
184
+ const fontPlugin = themeName
185
+ ? new FontInjectionWebpackPlugin({ projectRoot, themeName, themeConfig })
186
+ : null;
187
+
188
+ // social_media 渠道:注入 OG / Twitter meta 标签,让 Facebook / LinkedIn
189
+ // 等抓取预览时拿到正确的标题/描述/图片。无 shareMeta 时空跑,不影响构建
190
+ const socialMediaPlugin = (themeName && adNetwork === 'social_media')
191
+ ? new SocialMediaInjectorPlugin({ projectRoot, themeName, themeConfig })
192
+ : null;
193
+
194
+ await runBuild(
195
+ undefined,
196
+ {
197
+ playcraftOrientationValue: orientationValue,
198
+ filename: suffix ? `${options.filename}${suffix}` : options.filename
199
+ },
200
+ undefined,
201
+ {
202
+ plugins: [
203
+ ...(fontPlugin ? [fontPlugin] : []),
204
+ ...(socialMediaPlugin ? [socialMediaPlugin] : []),
205
+ ],
206
+ }
207
+ );
208
+ }
209
+
210
+ (async () => {
211
+ try {
212
+ // 注意:方向版本之间故意串行执行(不用 Promise.all 并发)。
213
+ // 原因:obfuscateLevel=4 等重度混淆/压缩下单次 webpack 编译本身就
214
+ // 会占用数 GB 内存;而该 build.js 进程本身通常已是 batch-build.js
215
+ // 渠道级并发中的一个子进程(多个渠道同时在跑)。若在这里再叠加同
216
+ // 进程内的多方向并发(多个 webpack compiler 共享同一份 V8 堆),
217
+ // 内存需求会成倍叠加,实测会导致 "JavaScript heap out of memory"
218
+ // 崩溃。因此并发只在渠道层(各自独立进程、独立堆)进行,方向版本
219
+ // 层始终串行,以保证稳定性。
220
+ for (const orientationValue of buildOrientations) {
221
+ await buildOneOrientation(orientationValue);
222
+ }
223
+
179
224
  if (schemaPathToRestore) restoreSchema(schemaPathToRestore);
180
- })
181
- .catch((err) => {
225
+
226
+ // 主动退出进程,避免 TerserPlugin/webpack-obfuscator 等可能残留的
227
+ // worker 进程句柄(jest-worker 子进程 IPC 通道等)迟迟不释放,
228
+ // 导致本次 build 子进程(乃至上层 batch-build 的 spawn 等待)
229
+ // 迟迟无法结束。此时构建已全部完成,不再有需要等待的异步操作。
230
+ process.exit(0);
231
+ } catch (err) {
182
232
  if (schemaPathToRestore) restoreSchema(schemaPathToRestore);
183
233
  console.error(err);
184
234
  process.exit(1);
185
- });
235
+ }
236
+ })();
186
237
  }
@@ -12,16 +12,83 @@
12
12
  * 4. 支持灵活的配置系统
13
13
  */
14
14
 
15
- const { execSync, exec } = require('child_process');
15
+ const { execSync, spawn } = require('child_process');
16
16
  const fs = require('fs');
17
17
  const path = require('path');
18
18
  const os = require('os');
19
- const { promisify } = require('util');
20
19
  const { fetchTemporaryCredentials, createCOSClient, uploadDirectoryToCOS, uploadFileToCOS, purgeCDNCache } = require('./cos-uploader');
21
20
  const { runThemeValidation, restoreSchema } = require('./utils/validateThemeData');
22
21
  const { runPreBuildChecks } = require('./validators/pre-build-checker.js');
22
+ const { getPlaycraftOrientationSuffix } = require('./utils/getPlaycraftOrientationSuffix.js');
23
23
 
24
- const execAsync = promisify(exec);
24
+ /**
25
+ * 执行构建命令,实时把 stdout/stderr 落盘到日志文件,而不是像 exec()
26
+ * 那样把全部输出攒在内存里的一个 buffer 中。
27
+ *
28
+ * 背景:exec() 的 maxBuffer 是硬性内存上限(原来设为 10MB)。当某个渠道
29
+ * 需要连续构建多个方向版本(playcraftOrientationChannel)时,日志量会
30
+ * 成倍增加,很容易超过该上限触发 "stderr maxBuffer length exceeded" 而
31
+ * 直接杀掉子进程。改用 spawn 边收边写文件后,日志大小不再受内存限制;
32
+ * 内存中只保留末尾一小段用于失败时快速定位问题,完整日志可在日志文件中查看。
33
+ *
34
+ * @param {string} command - 要执行的命令(通过 shell 解析,支持带引号的参数)
35
+ * @param {object} options - spawn 选项(cwd/env 等)
36
+ * @param {string} logPath - 日志文件落盘路径
37
+ * @returns {Promise<{logPath: string}>}
38
+ */
39
+ function execWithLoggedOutput(command, options, logPath) {
40
+ return new Promise((resolve, reject) => {
41
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
42
+ const logStream = fs.createWriteStream(logPath, { flags: 'w' });
43
+
44
+ const TAIL_LIMIT = 8000; // 失败时用于在错误信息中展示的日志末尾字符数
45
+ let tail = '';
46
+ const appendTail = (chunk) => {
47
+ tail += chunk;
48
+ if (tail.length > TAIL_LIMIT) {
49
+ tail = tail.slice(tail.length - TAIL_LIMIT);
50
+ }
51
+ };
52
+
53
+ let child;
54
+ try {
55
+ child = spawn(command, { ...options, shell: true });
56
+ } catch (err) {
57
+ logStream.end();
58
+ reject(err);
59
+ return;
60
+ }
61
+
62
+ child.stdout && child.stdout.on('data', (chunk) => {
63
+ logStream.write(chunk);
64
+ appendTail(chunk.toString());
65
+ });
66
+ child.stderr && child.stderr.on('data', (chunk) => {
67
+ logStream.write(chunk);
68
+ appendTail(chunk.toString());
69
+ });
70
+
71
+ child.on('error', (err) => {
72
+ logStream.end();
73
+ reject(err);
74
+ });
75
+
76
+ child.on('close', (code) => {
77
+ logStream.end(() => {
78
+ if (code === 0) {
79
+ resolve({ logPath });
80
+ } else {
81
+ const err = new Error(
82
+ `子进程退出码 ${code}\n---- 日志末尾 ----\n${tail}\n---- 完整日志见: ${logPath} ----`
83
+ );
84
+ err.logPath = logPath;
85
+ err.exitCode = code;
86
+ reject(err);
87
+ }
88
+ });
89
+ });
90
+ });
91
+ }
25
92
 
26
93
  /**
27
94
  * 更新 preview.json 文件
@@ -359,14 +426,64 @@ function copyDirRecursive(src, dest) {
359
426
  }
360
427
  }
361
428
 
429
+ /**
430
+ * 根据 playcraftOrientationChannel / playcraftOrientation 配置,把渠道列表
431
+ * 展开为「渠道构建任务」列表:需要多方向版本的渠道会被拆分成多个独立任务
432
+ * (对应 渠道_L / 渠道_P / 渠道 三个方向),每个任务各自对应一次独立的
433
+ * 子进程构建。
434
+ *
435
+ * 背景:此前多方向版本是在单个子进程(cli/commands/build.js)内部循环
436
+ * 串行构建的,obfuscateLevel=4 等重度混淆下单次编译本身就占用较大内存/
437
+ * 日志量,3 个方向串行叠加很容易撑爆内存或撑爆日志 buffer。改为在这里
438
+ * 提前拆分任务后,多方向构建就和普通的多渠道构建一样,交给已有的
439
+ * `buildChannelsInParallel` 并发调度(各任务独立进程、独立堆、独立日志
440
+ * 文件),从而把「单进程内串行」变成「渠道层面的多进程并发」。
441
+ *
442
+ * @param {string[]} channels - 原始渠道列表(已包含 'preview')
443
+ * @param {object} config - 批量构建配置
444
+ * @returns {{channel: string, orientation: ('both'|'landscape'|'portrait'|null)}[]}
445
+ */
446
+ function buildChannelTasks(channels, config) {
447
+ const orientationChannels = config.playcraftOrientationChannel || [];
448
+ const orientationVersions =
449
+ Array.isArray(config.playcraftOrientation) && config.playcraftOrientation.length > 0
450
+ ? config.playcraftOrientation
451
+ : ['both'];
452
+
453
+ const tasks = [];
454
+ for (const channel of channels) {
455
+ // preview 渠道用于收集预览文件,不参与多方向拆分
456
+ if (channel !== 'preview' && orientationChannels.includes(channel)) {
457
+ for (const orientation of orientationVersions) {
458
+ tasks.push({ channel, orientation });
459
+ }
460
+ } else {
461
+ tasks.push({ channel, orientation: null });
462
+ }
463
+ }
464
+ return tasks;
465
+ }
466
+
467
+ /**
468
+ * 生成任务展示标签,用于日志中区分同一渠道下的不同方向任务,如 google(landscape) → google_L
469
+ */
470
+ function getTaskLabel(channel, orientation) {
471
+ if (!orientation) return channel;
472
+ const suffix = getPlaycraftOrientationSuffix(orientation);
473
+ return suffix ? `${channel}${suffix}` : channel;
474
+ }
475
+
362
476
  /**
363
477
  * 构建单个渠道(使用独立临时目录)
478
+ * @param {('both'|'landscape'|'portrait'|null)} [orientation] - 指定要强制构建的单一方向版本;
479
+ * 为 null/undefined 时不干预,由渠道自身默认逻辑决定(通常即 'both')
364
480
  */
365
- async function buildChannel(config, themeName, channel, outputDir, projectRoot, isChannelFold2Zip) {
481
+ async function buildChannel(config, themeName, channel, outputDir, projectRoot, isChannelFold2Zip, orientation) {
366
482
  const taskId = generateTaskId();
367
483
  const tempDir = createTempDir(config, projectRoot, taskId);
484
+ const taskLabel = getTaskLabel(channel, orientation);
368
485
 
369
- log(`开始构建: 主题=${themeName}, 渠道=${channel}, 任务ID=${taskId.substr(0, 8)}...`);
486
+ log(`开始构建: 主题=${themeName}, 渠道=${taskLabel}, 任务ID=${taskId.substr(0, 8)}...`);
370
487
 
371
488
  try {
372
489
  // 生成文件名
@@ -380,7 +497,7 @@ async function buildChannel(config, themeName, channel, outputDir, projectRoot,
380
497
  // 注意:
381
498
  // 1. 命令行参数使用 --out-dir (连字符格式)
382
499
  // 2. 通过环境变量 PLAYABLE_FILENAME 传递文件名,避免并发时修改 build.json 导致竞态条件
383
- // 3. 使用 execAsync 实现真正的并发构建
500
+ // 3. 使用 spawn + 落盘日志实现真正的并发构建(避免 exec 的内存 buffer 上限)
384
501
  // 4. 通过命令行参数传递商店链接(如果配置中有定义)
385
502
 
386
503
  // 构建命令行参数
@@ -398,23 +515,51 @@ async function buildChannel(config, themeName, channel, outputDir, projectRoot,
398
515
  if (isChannelFold2Zip) {
399
516
  buildArgs.push('--is-channel-fold2zip');
400
517
  }
518
+
519
+ // 指定单一方向版本:显式传入仅含该方向的 --playcraft-orientation,
520
+ // 并把当前渠道加入 --playcraft-orientation-channel,使子进程内
521
+ // (cli/commands/build.js)的方向循环只会执行这一个方向,从而把
522
+ // 「单进程内串行构建多个方向」变成「渠道层面的多进程并发构建」。
523
+ if (orientation) {
524
+ buildArgs.push(`--playcraft-orientation ${orientation}`);
525
+ buildArgs.push(`--playcraft-orientation-channel ${channel}`);
526
+ }
401
527
 
402
- await execAsync(`npm run build -- ${buildArgs.join(' ')}`, {
403
- cwd: projectRoot,
404
- env: {
405
- ...process.env,
406
- PLAYABLE_BUILD_TASK_ID: taskId,
407
- PLAYABLE_FILENAME: fileName, // 通过环境变量传递文件名
408
- PLAYABLE_BATCH_MODE: '1' // 标识批量构建模式,子进程跳过 runThemeValidation(主进程已处理)
409
- },
410
- // 不使用 stdio: 'inherit' 以支持并发,而是捕获输出
411
- maxBuffer: 10 * 1024 * 1024 // 10MB buffer
412
- });
528
+ const buildLogPath = path.join(
529
+ projectRoot,
530
+ '.playable-build-logs',
531
+ `${themeName}_${taskLabel}_${taskId.substr(0, 8)}.log`
532
+ );
533
+
534
+ try {
535
+ await execWithLoggedOutput(`npm run build -- ${buildArgs.join(' ')}`, {
536
+ cwd: projectRoot,
537
+ env: {
538
+ ...process.env,
539
+ // 兜底:即使 webpack compiler 已正确 close(),重度混淆(obfuscateLevel=4)
540
+ // 下多方向串行构建仍可能瞬时占用较大堆内存,此处适当调高上限做防御
541
+ // (不覆盖用户已自行设置的 NODE_OPTIONS)
542
+ NODE_OPTIONS: process.env.NODE_OPTIONS || '--max-old-space-size=8192',
543
+ PLAYABLE_BUILD_TASK_ID: taskId,
544
+ PLAYABLE_FILENAME: fileName, // 通过环境变量传递文件名
545
+ PLAYABLE_BATCH_MODE: '1' // 标识批量构建模式,子进程跳过 runThemeValidation(主进程已处理)
546
+ }
547
+ }, buildLogPath);
548
+
549
+ // 构建成功后日志已无需保留,删除避免日志文件堆积
550
+ fs.rmSync(buildLogPath, { force: true });
551
+ } catch (buildError) {
552
+ // 失败时保留日志文件,方便排查(不在这里删除)
553
+ throw buildError;
554
+ }
413
555
 
414
556
  // 复制 tempDir 所有文件到目标目录
415
- // 第二级目录名使用 "主题名_渠道名",避免不同主题下同名渠道目录/文件混淆
557
+ // 第二级目录名使用 "主题名_渠道名[_L|_P]",避免不同主题下同名渠道目录/文件混淆;
558
+ // 若该渠道被 playcraftOrientationChannel 拆分为多方向任务,还需带上方向后缀,
559
+ // 否则 landscape/portrait/both 三个并发任务会把产物混进同一个文件夹
416
560
  function copyTempFiles(outputDir, themeName, channel) {
417
- const channelDirName = `${themeName}_${channel}`;
561
+ const orientationSuffix = orientation ? getPlaycraftOrientationSuffix(orientation) : '';
562
+ const channelDirName = `${themeName}_${channel}${orientationSuffix}`;
418
563
  const targetDir = path.join(outputDir, themeName, channelDirName);
419
564
  if (!fs.existsSync(targetDir)) {
420
565
  fs.mkdirSync(targetDir, { recursive: true });
@@ -432,27 +577,31 @@ async function buildChannel(config, themeName, channel, outputDir, projectRoot,
432
577
  }
433
578
  });
434
579
 
435
- log(`✓ 构建成功: ${themeName}/${channel} → ${themeName}/${channelDirName}`, 'success');
580
+ log(`✓ 构建成功: ${themeName}/${taskLabel} → ${themeName}/${channelDirName}`, 'success');
436
581
  }
437
582
 
438
583
  // 移动构建产物到目标目录
439
584
  if (fs.existsSync(tempDir)) {
440
585
  if (isChannelFold2Zip && channel !== 'preview') {
441
- // isChannelFold2Zip 模式:只移动 渠道名.zip 到 outputDir/themeName/主题名_渠道名.zip
442
- const zipFileName = `${channel}.zip`;
443
- const destZipFileName = `${themeName}_${channel}.zip`;
444
- const srcZipPath = path.join(tempDir, zipFileName);
586
+ // isChannelFold2Zip 模式:移动 渠道名[.zip|_L.zip|_P.zip] 到 outputDir/themeName/主题名_渠道名[_L|_P].zip
587
+ // 注:若该渠道被配置为多方向版本构建(playcraftOrientationChannel),tempDir 内
588
+ // 会同时存在 channel.zip / channel_L.zip / channel_P.zip 中的一个或多个,需要全部匹配并逐个复制
589
+ const zipRegex = new RegExp(`^${channel}(_L|_P)?\\.zip$`);
445
590
  const themeDir = path.join(outputDir, themeName);
446
- const destZipPath = path.join(themeDir, destZipFileName);
447
-
448
- if (fs.existsSync(srcZipPath)) {
591
+ const matchedZipFiles = fs.readdirSync(tempDir).filter((file) => zipRegex.test(file));
592
+
593
+ if (matchedZipFiles.length > 0) {
449
594
  if (!fs.existsSync(themeDir)) {
450
595
  fs.mkdirSync(themeDir, { recursive: true });
451
596
  }
452
- fs.copyFileSync(srcZipPath, destZipPath);
453
- log(`✓ 构建成功: ${themeName}/${channel} → ${themeName}/${destZipFileName}`, 'success');
597
+ matchedZipFiles.forEach((zipFileName) => {
598
+ const suffix = zipFileName.match(zipRegex)[1] || '';
599
+ const destZipFileName = `${themeName}_${channel}${suffix}.zip`;
600
+ fs.copyFileSync(path.join(tempDir, zipFileName), path.join(themeDir, destZipFileName));
601
+ log(`✓ 构建成功: ${themeName}/${channel}${suffix} → ${themeName}/${destZipFileName}`, 'success');
602
+ });
454
603
  } else {
455
- log(`⚠️ 未找到 zip 文件: ${srcZipPath},回退到普通文件移动`, 'warn');
604
+ log(`⚠️ 未找到 zip 文件: ${channel}*.zip,回退到普通文件移动`, 'warn');
456
605
  // 回退:移动所有文件到 outputDir/themeName/主题名_渠道名/
457
606
  copyTempFiles(outputDir, themeName, channel);
458
607
  }
@@ -467,9 +616,9 @@ async function buildChannel(config, themeName, channel, outputDir, projectRoot,
467
616
  cleanTempDir(tempDir);
468
617
  }
469
618
 
470
- return { success: true, theme: themeName, channel };
619
+ return { success: true, theme: themeName, channel, orientation };
471
620
  } catch (error) {
472
- log(`构建失败: ${themeName}/${channel} - ${error.message}`, 'error');
621
+ log(`构建失败: ${themeName}/${taskLabel} - ${error.message}`, 'error');
473
622
 
474
623
  // 清理临时目录
475
624
  if (config.build.cleanTemp) {
@@ -480,22 +629,22 @@ async function buildChannel(config, themeName, channel, outputDir, projectRoot,
480
629
  throw error;
481
630
  }
482
631
 
483
- return { success: false, theme: themeName, channel, error: error.message };
632
+ return { success: false, theme: themeName, channel, orientation, error: error.message };
484
633
  }
485
634
  }
486
635
 
487
636
  /**
488
- * 并发构建多个渠道
637
+ * 并发构建多个渠道任务(一个任务 = 一个渠道的一个方向版本,preview 渠道无方向拆分)
489
638
  */
490
- async function buildChannelsInParallel(config, themeName, channels, outputDir, projectRoot, isChannelFold2Zip) {
639
+ async function buildChannelsInParallel(config, themeName, tasks, outputDir, projectRoot, isChannelFold2Zip) {
491
640
  const maxParallel = config.build.parallel || 3;
492
641
  const results = [];
493
642
 
494
643
  // 分批并发构建
495
- for (let i = 0; i < channels.length; i += maxParallel) {
496
- const batch = channels.slice(i, i + maxParallel);
497
- const batchPromises = batch.map(channel =>
498
- buildChannel(config, themeName, channel, outputDir, projectRoot, isChannelFold2Zip)
644
+ for (let i = 0; i < tasks.length; i += maxParallel) {
645
+ const batch = tasks.slice(i, i + maxParallel);
646
+ const batchPromises = batch.map(task =>
647
+ buildChannel(config, themeName, task.channel, outputDir, projectRoot, isChannelFold2Zip, task.orientation)
499
648
  );
500
649
 
501
650
  const batchResults = await Promise.allSettled(batchPromises);
@@ -507,7 +656,8 @@ async function buildChannelsInParallel(config, themeName, channels, outputDir, p
507
656
  results.push({
508
657
  success: false,
509
658
  theme: themeName,
510
- channel: batch[index],
659
+ channel: batch[index].channel,
660
+ orientation: batch[index].orientation,
511
661
  error: result.reason?.message || '未知错误'
512
662
  });
513
663
  }
@@ -818,9 +968,13 @@ function mergeConfig(defaultConfig, userConfig) {
818
968
 
819
969
  /**
820
970
  * 打印配置摘要
971
+ * @param {object} config
972
+ * @param {string[]} themes
973
+ * @param {string[]} channels - 原始渠道列表(未展开方向)
974
+ * @param {{channel:string, orientation:(string|null)}[]} tasks - 展开方向后的实际构建任务列表
821
975
  */
822
- function printSummary(config, themes, channels) {
823
- const totalBuilds = themes.length * channels.length;
976
+ function printSummary(config, themes, channels, tasks) {
977
+ const totalBuilds = themes.length * tasks.length;
824
978
  const cpuCount = os.cpus().length;
825
979
  const cpuModel = os.cpus()[0]?.model || 'Unknown';
826
980
 
@@ -829,6 +983,9 @@ function printSummary(config, themes, channels) {
829
983
  log(`项目: ${config.title}`);
830
984
  log(`主题数: ${themes.length}`);
831
985
  log(`渠道数: ${channels.length}`);
986
+ if (tasks.length !== channels.length) {
987
+ log(`构建任务数(展开多方向后): ${tasks.length}`);
988
+ }
832
989
  log(`总构建数: ${totalBuilds}`);
833
990
  log(`CPU: ${cpuCount} 核心 (${cpuModel.substring(0, 40)}${cpuModel.length > 40 ? '...' : ''})`);
834
991
  log(`并发数: ${config.build.parallel} (自动优化)`);
@@ -838,6 +995,10 @@ function printSummary(config, themes, channels) {
838
995
  log(`主题列表: ${themes.join(', ')}`, 'debug');
839
996
  }
840
997
  log(`渠道列表: ${channels.join(', ')}`, 'debug');
998
+ if (tasks.length !== channels.length) {
999
+ const taskLabels = tasks.map(t => getTaskLabel(t.channel, t.orientation));
1000
+ log(`已按 playcraftOrientationChannel 展开为独立并发任务: ${taskLabels.join(', ')}`, 'info');
1001
+ }
841
1002
  log('');
842
1003
  }
843
1004
 
@@ -888,6 +1049,11 @@ async function buildBatch() {
888
1049
  log('已添加 preview 渠道', 'info');
889
1050
  }
890
1051
 
1052
+ // 根据 playcraftOrientationChannel / playcraftOrientation 配置,把需要
1053
+ // 多方向版本的渠道拆分为独立的构建任务(渠道_L / 渠道_P / 渠道),
1054
+ // 后续统一交给渠道级并发调度,每个方向各自独立进程构建。
1055
+ const tasks = buildChannelTasks(channels, config);
1056
+
891
1057
  // ============================================================
892
1058
  // 📌 编译前检查(埋点验证、主题名称检查等)- 仅在主进程执行一次
893
1059
  // ============================================================
@@ -915,7 +1081,7 @@ async function buildBatch() {
915
1081
  }
916
1082
 
917
1083
  // 打印摘要
918
- printSummary(config, themes, channels);
1084
+ printSummary(config, themes, channels, tasks);
919
1085
 
920
1086
  // 备份入口文件(如果启用主题切换)
921
1087
  if (config.themes.enabled) {
@@ -955,20 +1121,20 @@ async function buildBatch() {
955
1121
  if (!config.build.continueOnError) {
956
1122
  throw new Error(`Theme data validation failed for "${theme}"`);
957
1123
  }
958
- // Record failures for all channels of this theme and skip building
959
- channels.forEach(channel => {
960
- allResults.push({ success: false, theme, channel, error: 'Theme data validation failed' });
1124
+ // Record failures for all tasks of this theme and skip building
1125
+ tasks.forEach(({ channel, orientation }) => {
1126
+ allResults.push({ success: false, theme, channel, orientation, error: 'Theme data validation failed' });
961
1127
  });
962
1128
  continue;
963
1129
  }
964
1130
 
965
- // 并发构建该主题的所有渠道
1131
+ // 并发构建该主题的所有渠道任务(多方向渠道已展开为独立任务)
966
1132
  let themeResults;
967
1133
  try {
968
1134
  themeResults = await buildChannelsInParallel(
969
1135
  config,
970
1136
  theme,
971
- channels,
1137
+ tasks,
972
1138
  outputDir,
973
1139
  projectRoot,
974
1140
  isChannelFold2Zip
@@ -1039,7 +1205,7 @@ async function buildBatch() {
1039
1205
 
1040
1206
  // 打印进度
1041
1207
  const completedBuilds = allResults.length;
1042
- const totalBuilds = themes.length * channels.length;
1208
+ const totalBuilds = themes.length * tasks.length;
1043
1209
  const progress = Math.round(completedBuilds / totalBuilds * 100);
1044
1210
  log(`进度: ${completedBuilds}/${totalBuilds} (${progress}%)`, 'success');
1045
1211
  }
@@ -1090,8 +1256,8 @@ async function buildBatch() {
1090
1256
 
1091
1257
  if (failedBuilds.length > 0) {
1092
1258
  log(`✗ 失败构建: ${failedBuilds.length}`, 'warn');
1093
- failedBuilds.forEach(({ theme, channel, error }) => {
1094
- log(` - ${theme}/${channel}: ${error}`, 'error');
1259
+ failedBuilds.forEach(({ theme, channel, orientation, error }) => {
1260
+ log(` - ${theme}/${getTaskLabel(channel, orientation)}: ${error}`, 'error');
1095
1261
  });
1096
1262
  }
1097
1263
 
@@ -1126,6 +1292,10 @@ async function buildBatch() {
1126
1292
 
1127
1293
  log('========================================');
1128
1294
 
1295
+ // 主动退出进程:COS SDK 的 https keep-alive 连接、子进程遗留的 IPC 句柄
1296
+ // 等都可能让事件循环迟迟无法自然清空,导致命令执行完毕后终端长时间不返回。
1297
+ // 此处所有任务已全部完成,无需再等待任何异步操作,直接退出即可。
1298
+ process.exit(0);
1129
1299
  } catch (error) {
1130
1300
  log(`严重错误: ${error.message}`, 'error');
1131
1301
  console.error(error);
package/core/index.d.ts CHANGED
@@ -46,8 +46,8 @@ export interface DefinesOptions {
46
46
  BUILD_HASH: string;
47
47
  /** Current language code */
48
48
  LANGUAGE: string;
49
- /** Current orientation identifier */
50
- ORIENTATION: 'both' | 'portrait' | 'landscape' | 'square';
49
+ /** Current playcraft orientation identifier (window.playcraftOrientation / PLAYCRAFT_ORIENTATION define) */
50
+ PLAYCRAFT_ORIENTATION: 'both' | 'landscape' | 'portrait';
51
51
  }
52
52
 
53
53
  /** CLI configuration options */
@@ -110,8 +110,12 @@ export interface CLIOptions {
110
110
  version: string;
111
111
  /** Language code for localization */
112
112
  language: 'auto' | 'en' | 'es' | 'zh' | 'hi' | 'ar' | 'fr' | 'de' | 'ja' | 'pt';
113
- /** Orientation identifier */
114
- orientation: 'both' | 'portrait' | 'landscape' | 'square';
113
+ /** 当前要打包的横竖屏方向版本列表(builds.config.js 专属),默认 ['both'] */
114
+ playcraftOrientation: ('both' | 'landscape' | 'portrait')[];
115
+ /** 哪些渠道需要按 playcraftOrientation 分别打出多个方向版本的包(builds.config.js 专属),默认 [] */
116
+ playcraftOrientationChannel: string[];
117
+ /** 当前单次构建实际使用的横竖屏方向版本值(内部循环时按需注入,非 CLI 参数),默认 'both' */
118
+ playcraftOrientationValue?: 'both' | 'landscape' | 'portrait';
115
119
  /** Google Play Store URL for the app */
116
120
  googlePlayUrl: string;
117
121
  /** App Store URL for the app */
package/core/options.js CHANGED
@@ -2,7 +2,7 @@ const {
2
2
  parseArgvOptions,
3
3
  allowedAdProtocols,
4
4
  allowedAdNetworks,
5
- allowedOrientations,
5
+ allowedPlaycraftOrientations,
6
6
  allowedLanguages
7
7
  } = require('./utils/parseArgvOptions');
8
8
  const path = require('path');
@@ -137,15 +137,31 @@ const possibleOptions = [
137
137
  }
138
138
  },
139
139
  {
140
- name: 'orientation',
141
- defaultValue: 'both',
140
+ name: 'playcraft-orientation',
141
+ alias: 'playcraftOrientation',
142
+ defaultValue: ['both'],
142
143
  hasValue: true,
143
- description: 'Specifies the ad orientation used in ORIENTATION define and some network specific configurations',
144
+ description:
145
+ '当前要打包的横竖屏方向版本列表,可指定多个(逗号分隔),取值:landscape/portrait/both。用于 PLAYCRAFT_ORIENTATION define 及 window.playcraftOrientation 运行时变量',
144
146
  parser: function (rawValue) {
145
- if (!allowedOrientations.includes(rawValue)) {
146
- throw new Error(`--orientation should have one of the value: ${allowedOrientations.join(', ')}`);
147
+ const values = rawValue.split(',').map((v) => v.trim()).filter(Boolean);
148
+ for (const value of values) {
149
+ if (!allowedPlaycraftOrientations.includes(value)) {
150
+ throw new Error(`--playcraft-orientation should have one of the value: ${allowedPlaycraftOrientations.join(', ')}`);
151
+ }
147
152
  }
148
- return rawValue;
153
+ return values;
154
+ }
155
+ },
156
+ {
157
+ name: 'playcraft-orientation-channel',
158
+ alias: 'playcraftOrientationChannel',
159
+ defaultValue: [],
160
+ hasValue: true,
161
+ description:
162
+ '哪些渠道需要按 playcraftOrientation 分别打出多个方向版本的包(逗号分隔的渠道名列表)。不在此列表中的渠道始终按 both(自适应)单一版本打包',
163
+ parser: function (rawValue) {
164
+ return rawValue.split(',').map((v) => v.trim()).filter(Boolean);
149
165
  }
150
166
  },
151
167
  {
@@ -10,10 +10,10 @@ const HtmlWebpackPlugin = require('html-webpack-plugin');
10
10
  */
11
11
  class ExitAPIInjectorPlugin {
12
12
  /**
13
- * @param {ORIENTATION} orientation - device orientation
13
+ * @param {'both'|'landscape'|'portrait'} orientation - 当前构建的横竖屏方向版本,默认 'both'
14
14
  */
15
15
  constructor(orientation) {
16
- this.orientation = orientation;
16
+ this.orientation = orientation || 'both';
17
17
  }
18
18
 
19
19
  apply(compiler) {
@@ -32,8 +32,7 @@ class ExitAPIInjectorPlugin {
32
32
  let width = 320;
33
33
  let height = 480;
34
34
 
35
- if (this.orientation === 'square') width = 480;
36
- else if (this.orientation === 'landscape') {
35
+ if (this.orientation === 'landscape') {
37
36
  width = 480;
38
37
  height = 320;
39
38
  }
@@ -51,7 +50,7 @@ class ExitAPIInjectorPlugin {
51
50
  meta: { plugin: 'html-webpack-plugin' },
52
51
  attributes: {
53
52
  name: 'ad.orientation',
54
- content: this.orientation === 'both' || this.orientation === 'square' ? 'portrait,landscape' : this.orientation
53
+ content: this.orientation === 'both' ? 'portrait,landscape' : this.orientation
55
54
  }
56
55
  });
57
56
 
@@ -0,0 +1,33 @@
1
+ const HtmlWebpackPlugin = require('html-webpack-plugin');
2
+
3
+ /**
4
+ * 在 HTML <head> 最前面注入 window.playcraftOrientation 全局变量,
5
+ * 标识当前构建产物对应的横竖屏方向版本,供游戏/主题代码在运行时读取判断。
6
+ * @implements {import('webpack').WebpackPluginInstance}
7
+ */
8
+ class PlaycraftOrientationInjectorPlugin {
9
+ /**
10
+ * @param {'both'|'landscape'|'portrait'} orientation - 当前构建的方向版本,默认 'both'
11
+ */
12
+ constructor(orientation) {
13
+ this.orientation = orientation || 'both';
14
+ }
15
+
16
+ apply(compiler) {
17
+ compiler.hooks.compilation.tap('PlaycraftOrientationInjectorPlugin', (compilation) => {
18
+ HtmlWebpackPlugin.getHooks(compilation).alterAssetTagGroups.tapAsync('PlaycraftOrientationInjectorPlugin', (data, callback) => {
19
+ data.headTags.splice(0, 0, {
20
+ tagName: 'script',
21
+ voidTag: false,
22
+ meta: { plugin: 'html-inline-script-webpack-plugin' },
23
+ attributes: { type: 'text/javascript' },
24
+ innerHTML: `window.playcraftOrientation=${JSON.stringify(this.orientation)};`
25
+ });
26
+
27
+ callback(null, data);
28
+ });
29
+ });
30
+ }
31
+ }
32
+
33
+ exports.PlaycraftOrientationInjectorPlugin = PlaycraftOrientationInjectorPlugin;
@@ -2,24 +2,26 @@ const { options } = require('../options');
2
2
 
3
3
  /**
4
4
  * Build's default defines list for dev & prod builds
5
+ * @param {import('../index').CLIOptions} [customOptions] - 合并后的当前构建选项(用于读取会随单次构建变化的字段,如 playcraftOrientationValue);未提供时回退到全局 options
5
6
  */
6
- exports.buildDefines = function buildDefines() {
7
+ exports.buildDefines = function buildDefines(customOptions) {
8
+ const opts = customOptions || options;
7
9
  const defines = {
8
- __DEV__: JSON.stringify(options['dev'] === undefined ? false : options['dev']),
9
- GOOGLE_PLAY_URL: JSON.stringify(options.googlePlayUrl),
10
- APP_STORE_URL: JSON.stringify(options.appStoreUrl),
11
- AD_NETWORK: JSON.stringify(options['network']),
12
- AD_PROTOCOL: JSON.stringify(options['protocol']),
13
- APP: JSON.stringify(options.app),
14
- NAME: JSON.stringify(options.name),
15
- VERSION: JSON.stringify(options.version),
10
+ __DEV__: JSON.stringify(opts['dev'] === undefined ? false : opts['dev']),
11
+ GOOGLE_PLAY_URL: JSON.stringify(opts.googlePlayUrl),
12
+ APP_STORE_URL: JSON.stringify(opts.appStoreUrl),
13
+ AD_NETWORK: JSON.stringify(opts['network']),
14
+ AD_PROTOCOL: JSON.stringify(opts['protocol']),
15
+ APP: JSON.stringify(opts.app),
16
+ NAME: JSON.stringify(opts.name),
17
+ VERSION: JSON.stringify(opts.version),
16
18
  BUILD_HASH: JSON.stringify((+new Date()).toString(36)),
17
- LANGUAGE: JSON.stringify(options.language),
18
- ORIENTATION: JSON.stringify(options.orientation)
19
+ LANGUAGE: JSON.stringify(opts.language),
20
+ PLAYCRAFT_ORIENTATION: JSON.stringify(opts.playcraftOrientationValue || 'both')
19
21
  };
20
22
 
21
23
  /** @type {AD_NETWORK} */
22
- let adNetworkDefine = options['network'];
24
+ let adNetworkDefine = opts['network'];
23
25
 
24
26
  if (adNetworkDefine === 'pangle') adNetworkDefine = 'tiktok';
25
27
  if (adNetworkDefine === 'mytarget' || adNetworkDefine === 'moloco') adNetworkDefine = 'facebook';
@@ -33,7 +33,6 @@ exports.buildTemplateString = function buildTemplateString(template, customOptio
33
33
  template = template.replaceAll('{version}', customOptions.version);
34
34
  template = template.replaceAll('{date}', getCurrentDateFormatted());
35
35
  template = template.replaceAll('{language}', customOptions.language);
36
- template = template.replaceAll('{orientation}', customOptions.orientation);
37
36
  template = template.replaceAll('{network}', networkName.toLowerCase());
38
37
  template = template.replaceAll('{hash}', '[fullhash:6]');
39
38
 
@@ -0,0 +1,13 @@
1
+ /**
2
+ * 根据当前构建的横竖屏方向版本,计算用于产物文件夹/文件命名的后缀。
3
+ * 命名规则:landscape → '_L',portrait → '_P',both(自适应)→ ''(无后缀)。
4
+ * @param {'both'|'landscape'|'portrait'} [orientation] - 方向版本,默认为 'both'
5
+ * @returns {string} 命名后缀
6
+ */
7
+ function getPlaycraftOrientationSuffix(orientation) {
8
+ if (orientation === 'landscape') return '_L';
9
+ if (orientation === 'portrait') return '_P';
10
+ return '';
11
+ }
12
+
13
+ exports.getPlaycraftOrientationSuffix = getPlaycraftOrientationSuffix;
@@ -22,14 +22,14 @@ const { SocialMediaInjectorPlugin } = require('../plugins/SocialMediaInjectorPlu
22
22
  * @param {Object} options - 配置选项
23
23
  * @param {AD_NETWORK} options.network - 广告网络
24
24
  * @param {AD_PROTOCOL} options.protocol - 广告协议
25
- * @param {string} [options.orientation] - 屏幕方向(google 渠道需要)
25
+ * @param {'both'|'landscape'|'portrait'} [options.playcraftOrientationValue] - 当前构建的横竖屏方向版本(google 渠道需要)
26
26
  * @param {string} [options.projectRoot] - 项目根目录(social_media 渠道读取 theme.data.json5 用)
27
27
  * @param {string} [options.themeName] - 当前主题名
28
28
  * @param {object} [options.themeConfig] - builds.config.js 中的 themes 配置
29
29
  * @param {object} [options.mergedData] - 预合并的主题数据(可选)
30
30
  */
31
31
  function injectSDKPlugins(webpackConfig, options) {
32
- const { network: adNetwork, protocol: adProtocol, orientation, projectRoot, themeName, themeConfig, mergedData } = options;
32
+ const { network: adNetwork, protocol: adProtocol, playcraftOrientationValue, projectRoot, themeName, themeConfig, mergedData } = options;
33
33
 
34
34
  // 协议层注入
35
35
  if ('dapi' === adProtocol) {
@@ -44,7 +44,7 @@ function injectSDKPlugins(webpackConfig, options) {
44
44
 
45
45
  // 渠道特定注入
46
46
  if (adNetwork === 'google') {
47
- webpackConfig.plugins.push(new ExitAPIInjectorPlugin(orientation));
47
+ webpackConfig.plugins.push(new ExitAPIInjectorPlugin(playcraftOrientationValue));
48
48
  } else if (adNetwork === 'unity') {
49
49
  // Unity Ads 使用专门的 UnityInjectorPlugin
50
50
  webpackConfig.plugins.push(new UnityInjectorPlugin());
@@ -22,7 +22,16 @@ exports.logOptions = function logOptions(options) {
22
22
  if (logOptions.jsConfig === 'jsconfig.json') delete logOptions.jsConfig;
23
23
  if (logOptions.buildConfig === 'build.json') delete logOptions.buildConfig;
24
24
  if (logOptions.language === 'en') delete logOptions.language;
25
- if (logOptions.orientation === 'both') delete logOptions.orientation;
25
+ // playcraftOrientation/playcraftOrientationChannel 为默认值时不显示,避免日志噪音
26
+ if (Array.isArray(logOptions.playcraftOrientation) && logOptions.playcraftOrientation.length === 1 && logOptions.playcraftOrientation[0] === 'both') {
27
+ delete logOptions.playcraftOrientation;
28
+ }
29
+ if (Array.isArray(logOptions.playcraftOrientationChannel) && logOptions.playcraftOrientationChannel.length === 0) {
30
+ delete logOptions.playcraftOrientationChannel;
31
+ }
32
+ if (!logOptions.playcraftOrientationValue || logOptions.playcraftOrientationValue === 'both') {
33
+ delete logOptions.playcraftOrientationValue;
34
+ }
26
35
  // 在 dev 模式下,如果 network 为默认值 preview 则不显示
27
36
  if (!isProduction && logOptions.network === 'preview') delete logOptions.network;
28
37
  // 在 dev 模式下,如果 protocol 为默认值 none 则不显示
@@ -46,8 +46,8 @@ const mraidPartners = [
46
46
  /** @type {LANGUAGE[]} */
47
47
  const allowedLanguages = ['auto', 'en', 'es', 'zh', 'hi', 'ar', 'fr', 'de', 'ja', 'pt'];
48
48
 
49
- /** @type {ORIENTATION[]} */
50
- const allowedOrientations = ['both', 'portrait', 'landscape', 'square'];
49
+ /** @type {('both'|'landscape'|'portrait')[]} playcraftOrientation 允许的横竖屏方向版本取值 */
50
+ const allowedPlaycraftOrientations = ['both', 'landscape', 'portrait'];
51
51
 
52
52
  /**
53
53
  * Parses command line arguments based on provided options configuration
@@ -111,4 +111,4 @@ exports.allowedAdNetworks = allowedAdNetworks;
111
111
  exports.allowedAdProtocols = allowedAdProtocols;
112
112
  exports.mraidPartners = mraidPartners;
113
113
  exports.allowedLanguages = allowedLanguages;
114
- exports.allowedOrientations = allowedOrientations;
114
+ exports.allowedPlaycraftOrientations = allowedPlaycraftOrientations;
@@ -19,6 +19,8 @@ const { buildTemplateString } = require('./utils/buildTemplateString.js');
19
19
  const { logOptions } = require('./utils/logOptions.js');
20
20
  const { injectSDKPlugins } = require('./utils/injectSDKPlugins.js');
21
21
  const { resolveChannelFold2Zip } = require('./utils/resolveChannelFold2Zip.js');
22
+ const { getPlaycraftOrientationSuffix } = require('./utils/getPlaycraftOrientationSuffix.js');
23
+ const { PlaycraftOrientationInjectorPlugin } = require('./plugins/PlaycraftOrientationInjectorPlugin.js');
22
24
 
23
25
 
24
26
  const META = {
@@ -106,6 +108,13 @@ function makeWebpackBuildConfig(customOptions, customDefines, webpackCustomConfi
106
108
  /** @type {AD_PROTOCOL} */
107
109
  const adProtocol = buildOptions['protocol'];
108
110
 
111
+ /** @type {'both'|'landscape'|'portrait'} 当前单次构建实际使用的横竖屏方向版本,默认 'both' */
112
+ const playcraftOrientationValue = buildOptions.playcraftOrientationValue || 'both';
113
+ /** @type {string} 方向版本对应的命名后缀:landscape→'_L',portrait→'_P',both→'' */
114
+ const orientationSuffix = getPlaycraftOrientationSuffix(playcraftOrientationValue);
115
+ /** @type {string} 渠道 + 方向后缀组成的产物标识,如 'google_L' */
116
+ const outputId = `${adNetwork}${orientationSuffix}`;
117
+
109
118
  let outDir = buildTemplateString(buildOptions.outDir, buildOptions);
110
119
 
111
120
  function getFileName() {
@@ -224,9 +233,11 @@ function makeWebpackBuildConfig(customOptions, customDefines, webpackCustomConfi
224
233
  plugins: [
225
234
  new HtmlWebpackPlugin(htmlWebpackPluginConfig),
226
235
  new MetaTagOverridePlugin(adNetwork),
236
+ // 注入 window.playcraftOrientation 全局变量,标识当前构建产物对应的横竖屏方向版本
237
+ new PlaycraftOrientationInjectorPlugin(playcraftOrientationValue),
227
238
 
228
239
  new webpack.DefinePlugin({
229
- ...buildDefines(),
240
+ ...buildDefines(buildOptions),
230
241
  ...buildOptions.defines,
231
242
  ...customDefines
232
243
  }),
@@ -388,7 +399,7 @@ function makeWebpackBuildConfig(customOptions, customDefines, webpackCustomConfi
388
399
  injectSDKPlugins(webpackConfig, {
389
400
  network: adNetwork,
390
401
  protocol: adProtocol,
391
- orientation: buildOptions.orientation
402
+ playcraftOrientationValue
392
403
  });
393
404
 
394
405
  if (isZipOutputFinal) {
@@ -418,13 +429,13 @@ function makeWebpackBuildConfig(customOptions, customDefines, webpackCustomConfi
418
429
 
419
430
  webpackConfig.plugins.push(
420
431
  new ZipPlugin({
421
- filename: isChannelFold2Zip ? adNetwork : getFileName(),
432
+ filename: isChannelFold2Zip ? outputId : getFileName(),
422
433
  path: path.resolve(outDir),
423
434
  cleanTempFolder: true // 🗑️ 生成 zip 后删除临时文件夹
424
435
  })
425
436
  );
426
437
 
427
- webpackConfig.output.path = path.resolve(outDir, adNetwork);
438
+ webpackConfig.output.path = path.resolve(outDir, outputId);
428
439
  if (adNetwork === 'adikteev') {
429
440
  webpackConfig.output.filename = 'creative.js';
430
441
  // Adikteev 需要将 CSS 单独提取为 style.css 文件
@@ -467,13 +478,33 @@ function runBuild(webpackConfig, customOptions, customDefines, webpackCustomConf
467
478
  if (!webpackConfig) webpackConfig = makeWebpackBuildConfig(customOptions, customDefines, webpackCustomConfig);
468
479
 
469
480
  const compiler = webpack(webpackConfig);
481
+
482
+ // 必须显式调用 compiler.close() 释放本次编译占用的内存(模块图/资源缓存/
483
+ // Terser 与 obfuscator 等插件持有的引用)。webpack 官方要求:若在同一进程内
484
+ // 多次调用 compiler.run(),每次 run 完成后都要 close,否则上一次编译的内存
485
+ // 无法被回收。playcraftOrientationChannel 会在同一进程里串行构建多个方向
486
+ // 版本(多次调用本函数),若不 close,内存会随构建次数线性累加,最终触发
487
+ // "JavaScript heap out of memory"。
488
+ const finalize = (error) => {
489
+ compiler.close((closeErr) => {
490
+ if (closeErr) {
491
+ console.error('关闭 webpack compiler 时出错:', closeErr);
492
+ }
493
+ if (error) {
494
+ reject(error);
495
+ } else {
496
+ resolve();
497
+ }
498
+ });
499
+ };
500
+
470
501
  compiler.run((err, stats) => {
471
502
  if (err) {
472
503
  console.error('Build failed:', err.stack || err);
473
504
  if (err.details) {
474
505
  console.error('Error details:', err.details);
475
506
  }
476
- return reject(err);
507
+ return finalize(err);
477
508
  }
478
509
 
479
510
  if (stats.hasErrors()) {
@@ -491,10 +522,10 @@ function runBuild(webpackConfig, customOptions, customDefines, webpackCustomConf
491
522
 
492
523
  // 拒绝 Promise 时传递格式化的错误消息
493
524
  const errorMessage = errors.map((e, i) => `[${i + 1}] ${e.message || e}`).join('\n');
494
- reject(new Error(`Webpack build failed with ${errors.length} error(s):\n${errorMessage}`));
525
+ finalize(new Error(`Webpack build failed with ${errors.length} error(s):\n${errorMessage}`));
495
526
  } else {
496
527
  console.log(`Build successful!`);
497
- resolve();
528
+ finalize(null);
498
529
  }
499
530
  });
500
531
  });
@@ -10,6 +10,7 @@ const { mergeOptions } = require('./utils/mergeOptions.js');
10
10
  const { buildDefines } = require('./utils/buildDefines.js');
11
11
  const { logOptions } = require('./utils/logOptions.js');
12
12
  const { injectSDKPlugins } = require('./utils/injectSDKPlugins.js');
13
+ const { PlaycraftOrientationInjectorPlugin } = require('./plugins/PlaycraftOrientationInjectorPlugin.js');
13
14
 
14
15
  /**
15
16
  * Creates webpack configuration for development
@@ -24,6 +25,9 @@ function makeWebpackDevConfig(customOptions, customDefines, webpackCustomConfig)
24
25
  customDefines = customDefines || {};
25
26
  webpackCustomConfig = webpackCustomConfig || {};
26
27
 
28
+ /** @type {'both'|'landscape'|'portrait'} */
29
+ const playcraftOrientationValue = devOptions.playcraftOrientationValue || 'both';
30
+
27
31
  const webpackConfig = merge(
28
32
  webpackCommonConfig,
29
33
  {
@@ -47,9 +51,11 @@ function makeWebpackDevConfig(customOptions, customDefines, webpackCustomConfig)
47
51
  viewport: 'width=device-width,initial-scale=1.0,viewport-fit=cover,maximum-scale=1.0,user-scalable=no'
48
52
  }
49
53
  }),
54
+ // 注入 window.playcraftOrientation 全局变量,标识当前构建产物对应的横竖屏方向版本
55
+ new PlaycraftOrientationInjectorPlugin(playcraftOrientationValue),
50
56
 
51
57
  new webpack.DefinePlugin({
52
- ...buildDefines(),
58
+ ...buildDefines(devOptions),
53
59
  ...devOptions.defines,
54
60
  __DEV__: JSON.stringify(devOptions['dev'] === undefined ? true : devOptions['dev']),
55
61
  ...customDefines
@@ -67,7 +73,7 @@ function makeWebpackDevConfig(customOptions, customDefines, webpackCustomConfig)
67
73
  injectSDKPlugins(webpackConfig, {
68
74
  network: devOptions['network'],
69
75
  protocol: devOptions['protocol'],
70
- orientation: devOptions['orientation']
76
+ playcraftOrientationValue
71
77
  });
72
78
 
73
79
  return webpackConfig;
package/defines.d.ts CHANGED
@@ -49,7 +49,7 @@ declare const APP: string;
49
49
  declare const NAME: string;
50
50
  declare const VERSION: string;
51
51
  declare const LANGUAGE: 'auto' | 'en' | 'es' | 'zh' | 'hi' | 'ar' | 'fr' | 'de' | 'ja' | 'pt';
52
- declare const ORIENTATION: 'both' | 'portrait' | 'landscape' | 'square';
52
+ declare const PLAYCRAFT_ORIENTATION: 'both' | 'landscape' | 'portrait';
53
53
  declare const BUILD_HASH: string;
54
54
 
55
55
  declare const __DEV__: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/devkit",
3
- "version": "1.0.27",
3
+ "version": "1.0.28",
4
4
  "description": "HTML5 Playable Ads 构建工具,支持多广告渠道打包",
5
5
  "main": "core/index.js",
6
6
  "module": "core/index.js",