codeplay-common 4.6.6 → 4.6.8

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.
@@ -86,7 +86,8 @@ const analysisFrameDirectory = path.join(temporaryDirectory, 'ai-analysis');
86
86
  const endCardBannerPath = path.join(outputVideoDirectory, `${appFilePrefix}-ai-end-card-banner.png`);
87
87
  const promoCopySchemaPath = path.join(temporaryDirectory, 'promo-copy-schema.json');
88
88
  const promoCopyResultPath = path.join(temporaryDirectory, 'promo-copy-result.json');
89
- const ffmpegFallbackDirectory = String(process.env.FFMPEG_BIN || '').trim();
89
+ const ffmpegFallbackDirectory = String(process.env.FFMPEG_BIN || '').trim()
90
+ || path.join(projectDirectory, 'tools', 'ffmpeg');
90
91
  const minimumSceneCount = 7;
91
92
  const minimumSourceDuration = 12;
92
93
  let expectedDuration = 34;
@@ -208,15 +209,15 @@ async function ensureEndCardBanner() {
208
209
  + '- Keep all logos and badge text sharp, correctly proportioned, and completely visible.\n'
209
210
  + '- Match the background colors and visual style to the actual app icon.\n'
210
211
  + '- Create a polished, professional background related to the app’s purpose.\n'
211
- + '- Keep the background subtle so it does not compete with the icon, title, feature text, or download badges.\n'
212
+ + '- Keep the background subtle so it does not compete with the icon, title, feature text, or download badges.\n'
212
213
  + '- Use a balanced landscape composition:\n'
213
214
  + ' - Large actual app icon on the left.\n'
214
215
  + ' - App Store badge followed by Google Play badge on the right.\n'
215
216
  + ' - Maintain comfortable margins and clear space around every element.\n'
216
- + '- Add the application title prominently.\n'
217
- + '- Add concise promotional text describing the application’s actual features, presenting each feature one by one.\n'
218
- + '- Keep the title and feature text sharp, readable, accurate, and visually balanced with the icon and download badges.\n'
219
- + '- Do not invent unsupported features or add unrelated marketing text.\n'
217
+ + '- Add the application title prominently.\n'
218
+ + '- Add concise promotional text describing the application’s actual features, presenting each feature one by one.\n'
219
+ + '- Keep the title and feature text sharp, readable, accurate, and visually balanced with the icon and download badges.\n'
220
+ + '- Do not invent unsupported features or add unrelated marketing text.\n'
220
221
  + '- Do not add watermarks, fake UI, generated symbols, or unrelated decorations.\n'
221
222
  + '- Rounded corners and a subtle shadow may be applied around the app icon, but its internal artwork must remain unchanged.\n'
222
223
  + '- Use image generation only for the background.\n'
@@ -467,12 +468,10 @@ function commandExists(command) {
467
468
  }
468
469
 
469
470
  function getExecutable(command) {
470
- if (commandExists(command)) return command;
471
-
472
- if (!ffmpegFallbackDirectory) return '';
473
471
  const executableName = process.platform === 'win32' ? `${command}.exe` : command;
474
472
  const fallbackPath = path.join(ffmpegFallbackDirectory, executableName);
475
- return fs.existsSync(fallbackPath) ? fallbackPath : '';
473
+ if (fs.existsSync(fallbackPath)) return fallbackPath;
474
+ return commandExists(command) ? command : '';
476
475
  }
477
476
 
478
477
  function detectRedundantStaticRanges(ffmpegCommand, inputPath, sourceDuration) {
@@ -641,7 +640,7 @@ function resolvePromoLayout(requestedLayout) {
641
640
  }
642
641
 
643
642
  function getPythonExecutable() {
644
- for (const candidate of ['py', 'python']) {
643
+ for (const candidate of ['py', 'python', 'python3']) {
645
644
  const result = spawnSync(candidate, ['-m', 'edge_tts', '--version'], {
646
645
  encoding: 'utf8',
647
646
  windowsHide: true,
@@ -788,7 +787,7 @@ function getCodexCandidates() {
788
787
  const configuredCodexPath = String(process.env.CODEX_BIN || '').trim();
789
788
  if (configuredCodexPath) candidates.unshift(configuredCodexPath);
790
789
 
791
- if (process.platform === 'win32') {
790
+ if (process.platform === 'win32' || process.platform === 'darwin' || process.platform === 'linux') {
792
791
  const userProfile = process.env.USERPROFILE || process.env.HOME;
793
792
  const extensionRoots = [
794
793
  path.join(userProfile || '', '.vscode', 'extensions'),
@@ -796,7 +795,7 @@ function getCodexCandidates() {
796
795
  path.join(userProfile || '', '.vscode-server', 'extensions'),
797
796
  path.join(userProfile || '', '.vscode-server-insiders', 'extensions'),
798
797
  ];
799
- const executableNames = ['codex.exe', 'codex'];
798
+ const executableNames = process.platform === 'win32' ? ['codex.exe', 'codex'] : ['codex'];
800
799
 
801
800
  for (const extensionRoot of extensionRoots) {
802
801
  if (!fs.existsSync(extensionRoot)) continue;
@@ -916,7 +915,7 @@ Rules:
916
915
  - footer_keywords must contain exactly 3 distinct, natural app-search phrases, each 2-24 characters.
917
916
  - Generate a YouTube upload title and description from the same verified features.
918
917
  - YouTube title: 40-100 characters, include ${JSON.stringify(appName)} and one strong natural search phrase, without clickbait.
919
- - YouTube description: 350-1500 characters with readable paragraphs. Put the clearest app summary and primary search phrase in the opening two lines, explain the demonstrated features, add a simple call to action, and finish with exactly 3 relevant hashtags on one line.
918
+ - YouTube description: 350-1500 characters with readable paragraphs. Put the clearest app summary and primary search phrase in the opening two lines, explain the demonstrated features, add a simple call to action, then insert a newline and finish with exactly 3 relevant hashtags on that separate final line. The hashtags must not be appended to the preceding paragraph.
920
919
  - Do not add download links or URLs; the script will append the verified Google Play URL built from the configured package ID. Do not add chapter timestamps or unsupported availability claims.
921
920
  - Keep YouTube text natural and useful; do not repeat keywords unnaturally.
922
921
  - Avoid prices, ratings, awards, competitor names, unsupported superlatives, and words such as best, guaranteed, or free.
@@ -1027,7 +1026,13 @@ function validatePromoCopy(value) {
1027
1026
  }
1028
1027
 
1029
1028
  const youtubeTitle = cleanGeneratedString(value.youtube.title);
1030
- const youtubeDescription = cleanGeneratedDescription(value.youtube.description);
1029
+ let youtubeDescription = cleanGeneratedDescription(value.youtube.description);
1030
+ const trailingHashtagGroup = youtubeDescription.match(
1031
+ /(?:^|\s)(#[\p{L}\p{N}_]+\s+#[\p{L}\p{N}_]+\s+#[\p{L}\p{N}_]+)\s*$/u,
1032
+ );
1033
+ if (trailingHashtagGroup) {
1034
+ youtubeDescription = `${youtubeDescription.slice(0, trailingHashtagGroup.index).trimEnd()}\n${trailingHashtagGroup[1]}`;
1035
+ }
1031
1036
  if (youtubeTitle.length < 40 || youtubeTitle.length > 100) {
1032
1037
  throw new Error('YouTube title must be 40-100 characters.');
1033
1038
  }
@@ -1,12 +1,15 @@
1
1
  //node take-screen-image.js --delete 3
2
2
  //node take-screen-image.js --delete 3,4,5
3
-
3
+ //node take-screen-image.js --profiles vivo-store //only taking screenshot for vivo-store - Not only vivo, we can do this to all of our store images.
4
+ //node take-screen-image.js --vivo-existing //Used if we already took the screenshot and those vivo screenshots are not there then execute this to resize the screenshot for vivo store.
5
+ //node take-screen-image.js --oppo-existing //Used if we already took the screenshot and those oppo screenshots are not there then execute this to resize the screenshot for Oppo store.
4
6
 
5
7
 
6
8
  const fs = require('node:fs');
7
9
  const path = require('node:path');
8
10
  const readline = require('node:readline/promises');
9
11
  const { spawn, spawnSync } = require('node:child_process');
12
+ const sharp = require('sharp');
10
13
 
11
14
  const screenshotDirectory = path.join(__dirname, 'Auto-Screenshot', 'Store-image-Screenshot');
12
15
  const inFrameDirectory = path.join(__dirname, 'Auto-Screenshot', 'Store-image-Frame');
@@ -32,6 +35,9 @@ const playstoreAssetDefinitions = [
32
35
  { fileName: 'feature-graphic-1024x500.png', width: 1024, height: 500 },
33
36
  ];
34
37
  const browserExecutablePaths = [
38
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
39
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
40
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
35
41
  'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
36
42
  'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
37
43
  'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
@@ -50,6 +56,52 @@ const deviceProfiles = [
50
56
  orientationSensitive: true,
51
57
  mobile: true,
52
58
  },
59
+ {
60
+ name: 'vivo-store',
61
+ label: 'Vivo Store portrait screenshot',
62
+ outputDirectory: 'Vivo-Store',
63
+ frameType: 'android-phone',
64
+ width: 360,
65
+ height: 720,
66
+ deviceScaleFactor: 3,
67
+ framedWidth: 1080,
68
+ framedHeight: 2160,
69
+ orientationSensitive: false,
70
+ mobile: true,
71
+ storeRequirements: {
72
+ maxFileSizeBytes: 5 * 1024 * 1024,
73
+ minPixels: 320,
74
+ maxPixels: 3840,
75
+ minAspectRatio: 0.5,
76
+ maxAspectRatio: 2,
77
+ minImages: 4,
78
+ maxImages: 8,
79
+ },
80
+ },
81
+ {
82
+ name: 'oppo-store',
83
+ label: 'Oppo Store portrait screenshot',
84
+ outputDirectory: 'Oppo-Store',
85
+ frameType: 'android-phone',
86
+ width: 360,
87
+ height: 640,
88
+ deviceScaleFactor: 3,
89
+ framedWidth: 1080,
90
+ framedHeight: 1920,
91
+ orientationSensitive: false,
92
+ mobile: true,
93
+ outputFormat: 'jpeg',
94
+ outputExtension: 'jpg',
95
+ storeRequirements: {
96
+ maxFileSizeBytes: 3 * 1024 * 1024,
97
+ minPixels: 320,
98
+ maxPixels: 1920,
99
+ minAspectRatio: 0.5,
100
+ maxAspectRatio: 0.6,
101
+ minImages: 3,
102
+ maxImages: 5,
103
+ },
104
+ },
53
105
  {
54
106
  name: 'android-tablet-7-inch',
55
107
  aliases: ['android-tablet'],
@@ -350,31 +402,33 @@ function getSelectedOutputDirectories(selectedProfiles) {
350
402
  }
351
403
 
352
404
  function getNextNumberedFileName(selectedProfiles) {
405
+ const extension = selectedProfiles[0]?.outputExtension || 'png';
353
406
  const highestNumber = getSelectedOutputDirectories(selectedProfiles)
354
407
  .filter((directory) => fs.existsSync(directory))
355
408
  .reduce((highestAcrossDirectories, directory) => {
356
409
  const highestInDirectory = fs.readdirSync(directory, { withFileTypes: true })
357
410
  .filter((entry) => entry.isFile())
358
411
  .reduce((highest, entry) => {
359
- const match = entry.name.match(/^(\d+)\.png$/i);
412
+ const match = entry.name.match(new RegExp(`^(\\d+)\\.${extension}$`, 'i'));
360
413
  return match ? Math.max(highest, Number(match[1])) : highest;
361
414
  }, 0);
362
415
 
363
416
  return Math.max(highestAcrossDirectories, highestInDirectory);
364
417
  }, 0);
365
418
 
366
- return `${highestNumber + 1}.png`;
419
+ return `${highestNumber + 1}.${extension}`;
367
420
  }
368
421
 
369
422
  function getUniqueFileName(prefix, selectedProfiles) {
423
+ const extension = selectedProfiles[0]?.outputExtension || 'png';
370
424
  const outputDirectories = getSelectedOutputDirectories(selectedProfiles);
371
- let fileName = `${prefix}.png`;
425
+ let fileName = `${prefix}.${extension}`;
372
426
  let duplicateNumber = 2;
373
427
 
374
428
  while (outputDirectories.some((directory) => (
375
429
  fs.existsSync(path.join(directory, fileName))
376
430
  ))) {
377
- fileName = `${prefix}-${duplicateNumber}.png`;
431
+ fileName = `${prefix}-${duplicateNumber}.${extension}`;
378
432
  duplicateNumber += 1;
379
433
  }
380
434
 
@@ -382,7 +436,7 @@ function getUniqueFileName(prefix, selectedProfiles) {
382
436
  }
383
437
 
384
438
  function getScreenshotFileName(fileNameInput, selectedProfiles) {
385
- const inputWithoutExtension = fileNameInput.trim().replace(/\.png$/i, '');
439
+ const inputWithoutExtension = fileNameInput.trim().replace(/\.(png|jpe?g)$/i, '');
386
440
 
387
441
  if (!inputWithoutExtension) {
388
442
  return getNextNumberedFileName(selectedProfiles);
@@ -408,6 +462,8 @@ function parseArguments() {
408
462
  samsungAssets: false,
409
463
  dryRun: false,
410
464
  frameExisting: false,
465
+ vivoExisting: false,
466
+ oppoExisting: false,
411
467
  help: false,
412
468
  list: false,
413
469
  deleteRequested: false,
@@ -427,6 +483,10 @@ function parseArguments() {
427
483
  options.dryRun = true;
428
484
  } else if (argument === '--frame-existing') {
429
485
  options.frameExisting = true;
486
+ } else if (argument === '--vivo-existing') {
487
+ options.vivoExisting = true;
488
+ } else if (argument === '--oppo-existing') {
489
+ options.oppoExisting = true;
430
490
  } else if (argument === '--help' || argument === '-h') {
431
491
  options.help = true;
432
492
  } else if (argument === '--list') {
@@ -457,13 +517,20 @@ function printProfiles() {
457
517
  const outputWidth = profile.width * profile.deviceScaleFactor;
458
518
  const outputHeight = profile.height * profile.deviceScaleFactor;
459
519
  const framedSize = profile.framedWidth
460
- ? `, framed PNG ${profile.framedWidth}x${profile.framedHeight}`
520
+ ? `, framed ${(profile.outputFormat || 'png').toUpperCase()} ${profile.framedWidth}x${profile.framedHeight}`
461
521
  : '';
462
522
  console.log(
463
523
  `${profile.name.padEnd(15)} ${profile.label} `
464
524
  + `(folder ${profile.outputDirectory}, viewport ${profile.width}x${profile.height}, `
465
- + `PNG ${outputWidth}x${outputHeight}${framedSize})`,
525
+ + `${(profile.outputFormat || 'png').toUpperCase()} ${outputWidth}x${outputHeight}${framedSize})`,
466
526
  );
527
+ if (profile.storeRequirements) {
528
+ console.log(
529
+ ` Store limits: ${profile.storeRequirements.minImages}-${profile.storeRequirements.maxImages} images, `
530
+ + `max ${profile.storeRequirements.maxFileSizeBytes / (1024 * 1024)} MB, `
531
+ + `width/height ${profile.storeRequirements.minAspectRatio}-${profile.storeRequirements.maxAspectRatio}:1`,
532
+ );
533
+ }
467
534
  }
468
535
  }
469
536
 
@@ -475,6 +542,8 @@ function printHelp() {
475
542
  console.log(' node take-screen-image.js --profiles iphone,ipad');
476
543
  console.log(' node take-screen-image.js --prefix home --profiles android-phone,ipad');
477
544
  console.log(' node take-screen-image.js --frame-existing');
545
+ console.log(' node take-screen-image.js --vivo-existing');
546
+ console.log(' node take-screen-image.js --oppo-existing');
478
547
  console.log(' node take-screen-image.js --amazon-assets');
479
548
  console.log(' node take-screen-image.js --samsung-assets');
480
549
  console.log(' node take-screen-image.js --dry-run');
@@ -1151,9 +1220,84 @@ function getFrameAdornment(frameType) {
1151
1220
  return '<div class="xr-status"></div>';
1152
1221
  }
1153
1222
 
1154
- function createDeviceFrameHtml(profile, screenshotBuffer) {
1155
- const screenshotDataUrl = `data:image/png;base64,${screenshotBuffer.toString('base64')}`;
1156
- const aspectRatio = `${profile.width} / ${profile.height}`;
1223
+ function createStoreDeviceFrameHtml(profile, screenshotBuffer, imageWidth, imageHeight, canvasWidth, canvasHeight, imageFormat) {
1224
+ const screenshotDataUrl = `data:image/${imageFormat === 'jpeg' ? 'jpeg' : 'png'};base64,${screenshotBuffer.toString('base64')}`;
1225
+ const screenWidth = Math.floor(Math.min(canvasWidth - 160, (canvasHeight - 180) * imageWidth / imageHeight));
1226
+ const accent = profile.name === 'oppo-store' ? '#76e4c2' : '#9daaff';
1227
+
1228
+ return `<!doctype html>
1229
+ <html>
1230
+ <head>
1231
+ <meta charset="utf-8" />
1232
+ <style>
1233
+ * { box-sizing: border-box; }
1234
+ html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
1235
+ body {
1236
+ display: grid;
1237
+ place-items: center;
1238
+ background: radial-gradient(ellipse at 50% 43%, #24344d 0%, #101b2d 47%, #070e1a 100%);
1239
+ }
1240
+ body::before {
1241
+ content: '';
1242
+ position: absolute;
1243
+ width: 82%;
1244
+ height: 62%;
1245
+ border-radius: 50%;
1246
+ background: ${accent};
1247
+ opacity: 0.11;
1248
+ filter: blur(90px);
1249
+ }
1250
+ .device {
1251
+ position: relative;
1252
+ z-index: 1;
1253
+ width: ${screenWidth + 48}px;
1254
+ padding: 34px 18px 20px;
1255
+ border: 6px solid #8d99ac;
1256
+ border-radius: 48px;
1257
+ background: linear-gradient(135deg, #404c60, #111925 35%, #070c15 72%, #59657a);
1258
+ box-shadow: 0 38px 85px rgba(0, 0, 0, 0.58), 0 0 0 2px rgba(255, 255, 255, 0.19) inset;
1259
+ }
1260
+ .screen {
1261
+ width: ${screenWidth}px;
1262
+ background: #fff;
1263
+ box-shadow: 0 0 0 2px #03060c;
1264
+ }
1265
+ .screen img { display: block; width: 100%; height: auto; }
1266
+ .camera {
1267
+ position: absolute;
1268
+ top: 11px;
1269
+ left: 50%;
1270
+ width: 12px;
1271
+ height: 12px;
1272
+ transform: translateX(-50%);
1273
+ border-radius: 50%;
1274
+ background: radial-gradient(circle at 32% 30%, #547496, #070b12 48%, #000 80%);
1275
+ box-shadow: 0 0 0 3px #1b2534;
1276
+ }
1277
+ .side-button {
1278
+ position: absolute;
1279
+ top: 19%;
1280
+ right: -10px;
1281
+ width: 4px;
1282
+ height: 66px;
1283
+ border-radius: 0 3px 3px 0;
1284
+ background: #79869a;
1285
+ }
1286
+ </style>
1287
+ </head>
1288
+ <body>
1289
+ <main class="device">
1290
+ <div class="camera"></div>
1291
+ <div class="screen"><img src="${screenshotDataUrl}" /></div>
1292
+ <div class="side-button"></div>
1293
+ </main>
1294
+ </body>
1295
+ </html>`;
1296
+ }
1297
+
1298
+ function createDeviceFrameHtml(profile, screenshotBuffer, imageWidth = profile.width, imageHeight = profile.height, imageFormat = 'png') {
1299
+ const screenshotDataUrl = `data:image/${imageFormat === 'jpeg' ? 'jpeg' : 'png'};base64,${screenshotBuffer.toString('base64')}`;
1300
+ const aspectRatio = `${imageWidth} / ${imageHeight}`;
1157
1301
 
1158
1302
  return `<!doctype html>
1159
1303
  <html>
@@ -1197,20 +1341,21 @@ function createDeviceFrameHtml(profile, screenshotBuffer) {
1197
1341
  .screen {
1198
1342
  position: relative;
1199
1343
  width: 100%;
1200
- height: 100%;
1344
+ height: 87%;
1201
1345
  overflow: hidden;
1202
- background: #05070b;
1346
+ background: #f5f7fc;
1203
1347
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.95) inset;
1204
1348
  }
1205
1349
  .screen img {
1206
1350
  display: block;
1207
1351
  width: 100%;
1208
1352
  height: 100%;
1209
- object-fit: cover;
1353
+ object-fit: contain;
1354
+ background: #f5f7fc;
1210
1355
  }
1211
1356
  .device.android-phone,
1212
1357
  .device.iphone {
1213
- height: 87%;
1358
+ height: 100%;
1214
1359
  padding: 1.25%;
1215
1360
  border-radius: clamp(24px, 8vw, 70px);
1216
1361
  }
@@ -2037,9 +2182,15 @@ async function captureFramedScreenshot(
2037
2182
  return false;
2038
2183
  }
2039
2184
 
2040
- const hasCustomFrameSize = Boolean(profile.framedWidth && profile.framedHeight);
2041
- const frameWidth = profile.framedWidth || profile.width;
2042
- const frameHeight = profile.framedHeight || profile.height;
2185
+ const originalImageMetadata = profile.storeRequirements
2186
+ ? await sharp(screenshotBuffer).metadata()
2187
+ : null;
2188
+ const frameDimensions = originalImageMetadata?.width && originalImageMetadata?.height
2189
+ ? { width: originalImageMetadata.width, height: originalImageMetadata.height }
2190
+ : null;
2191
+ const hasCustomFrameSize = Boolean(frameDimensions || (profile.framedWidth && profile.framedHeight));
2192
+ const frameWidth = frameDimensions?.width || profile.framedWidth || profile.width;
2193
+ const frameHeight = frameDimensions?.height || profile.framedHeight || profile.height;
2043
2194
  const frameScaleFactor = hasCustomFrameSize ? 1 : profile.deviceScaleFactor;
2044
2195
 
2045
2196
  await client.send('Emulation.setDeviceMetricsOverride', {
@@ -2056,7 +2207,12 @@ async function captureFramedScreenshot(
2056
2207
  const frameTree = await client.send('Page.getFrameTree');
2057
2208
  await client.send('Page.setDocumentContent', {
2058
2209
  frameId: frameTree.frameTree.frame.id,
2059
- html: createDeviceFrameHtml(profile, screenshotBuffer),
2210
+ html: profile.name === 'vivo-store' || profile.name === 'oppo-store'
2211
+ ? createStoreDeviceFrameHtml(
2212
+ profile, screenshotBuffer, originalImageMetadata.width, originalImageMetadata.height,
2213
+ frameWidth, frameHeight, originalImageMetadata.format,
2214
+ )
2215
+ : createDeviceFrameHtml(profile, screenshotBuffer, frameWidth, frameHeight),
2060
2216
  });
2061
2217
  await client.send('Runtime.evaluate', {
2062
2218
  expression: `new Promise((resolve) => {
@@ -2073,21 +2229,52 @@ async function captureFramedScreenshot(
2073
2229
  });
2074
2230
 
2075
2231
  const framedScreenshot = await client.send('Page.captureScreenshot', {
2076
- format: 'png',
2232
+ format: profile.outputFormat || 'png',
2233
+ quality: profile.outputFormat === 'jpeg' ? 90 : undefined,
2077
2234
  fromSurface: true,
2078
2235
  captureBeyondViewport: false,
2079
2236
  });
2080
2237
 
2238
+ const framedScreenshotBuffer = Buffer.from(framedScreenshot.data, 'base64');
2239
+ if (!frameDimensions) {
2240
+ validateStoreImage(profile, frameWidth, frameHeight, framedScreenshotBuffer);
2241
+ }
2242
+
2081
2243
  fs.mkdirSync(framedProfileDirectory, { recursive: true });
2082
2244
  fs.writeFileSync(
2083
2245
  framedScreenshotPath,
2084
- Buffer.from(framedScreenshot.data, 'base64'),
2085
- { flag: 'wx' },
2246
+ framedScreenshotBuffer,
2247
+ { flag: skipExisting ? 'wx' : 'w' },
2086
2248
  );
2087
2249
  console.log(`Saved framed ${profile.label}: ${framedScreenshotPath}`);
2088
2250
  return true;
2089
2251
  }
2090
2252
 
2253
+ function validateStoreImage(profile, width, height, imageBuffer) {
2254
+ if (!profile.storeRequirements) return;
2255
+
2256
+ const {
2257
+ maxFileSizeBytes, minPixels, maxPixels, minAspectRatio, maxAspectRatio,
2258
+ } = profile.storeRequirements;
2259
+ const aspectRatio = width / height;
2260
+
2261
+ if (
2262
+ width < minPixels || height < minPixels
2263
+ || width > maxPixels || height > maxPixels
2264
+ || aspectRatio < minAspectRatio || aspectRatio > maxAspectRatio
2265
+ ) {
2266
+ throw new Error(
2267
+ `${profile.label} must be between ${minPixels}px and ${maxPixels}px with aspect ratio <= ${maxAspectRatio}:1; received ${width}x${height}.`,
2268
+ );
2269
+ }
2270
+
2271
+ if (imageBuffer.length > maxFileSizeBytes) {
2272
+ throw new Error(
2273
+ `${profile.label} exceeds ${maxFileSizeBytes / (1024 * 1024)} MB: ${Math.ceil(imageBuffer.length / (1024 * 1024))} MB.`,
2274
+ );
2275
+ }
2276
+ }
2277
+
2091
2278
  async function captureProfile(client, snapshot, profile, screenshotFileName) {
2092
2279
  await client.send('Emulation.setDeviceMetricsOverride', {
2093
2280
  width: profile.width,
@@ -2115,7 +2302,8 @@ async function captureProfile(client, snapshot, profile, screenshotFileName) {
2115
2302
  }
2116
2303
 
2117
2304
  const screenshot = await client.send('Page.captureScreenshot', {
2118
- format: 'png',
2305
+ format: profile.outputFormat || 'png',
2306
+ quality: profile.outputFormat === 'jpeg' ? 90 : undefined,
2119
2307
  fromSurface: true,
2120
2308
  captureBeyondViewport: false,
2121
2309
  });
@@ -2123,6 +2311,12 @@ async function captureProfile(client, snapshot, profile, screenshotFileName) {
2123
2311
  const profileDirectory = path.join(screenshotDirectory, profile.outputDirectory);
2124
2312
  const screenshotPath = path.join(profileDirectory, screenshotFileName);
2125
2313
  const screenshotBuffer = Buffer.from(screenshot.data, 'base64');
2314
+ validateStoreImage(
2315
+ profile,
2316
+ profile.width * profile.deviceScaleFactor,
2317
+ profile.height * profile.deviceScaleFactor,
2318
+ screenshotBuffer,
2319
+ );
2126
2320
 
2127
2321
  fs.mkdirSync(profileDirectory, { recursive: true });
2128
2322
  fs.writeFileSync(screenshotPath, screenshotBuffer, { flag: 'wx' });
@@ -2142,6 +2336,22 @@ async function captureScreenshotSet(
2142
2336
  selectedProfiles,
2143
2337
  fileNameInput,
2144
2338
  ) {
2339
+ const storeProfile = selectedProfiles.find((profile) => profile.storeRequirements);
2340
+ if (storeProfile) {
2341
+ const profileDirectory = path.join(screenshotDirectory, storeProfile.outputDirectory);
2342
+ const existingImageCount = fs.existsSync(profileDirectory)
2343
+ ? fs.readdirSync(profileDirectory, { withFileTypes: true })
2344
+ .filter((entry) => entry.isFile() && /\.(png|jpe?g|webp)$/i.test(entry.name))
2345
+ .length
2346
+ : 0;
2347
+
2348
+ if (existingImageCount >= storeProfile.storeRequirements.maxImages) {
2349
+ throw new Error(
2350
+ `${storeProfile.label} already contains ${existingImageCount} images; the store allows a maximum of ${storeProfile.storeRequirements.maxImages}.`,
2351
+ );
2352
+ }
2353
+ }
2354
+
2145
2355
  console.log('Capturing the current rendered page and resolved native data...');
2146
2356
  const snapshot = await capturePageSnapshot(deviceClient);
2147
2357
  const screenshotFileName = getScreenshotFileName(fileNameInput, selectedProfiles);
@@ -2210,6 +2420,148 @@ async function frameExistingScreenshots(selectedProfiles) {
2210
2420
  }
2211
2421
  }
2212
2422
 
2423
+ async function createVivoAssetsFromExistingScreenshots() {
2424
+ const vivoProfile = deviceProfiles.find((profile) => profile.name === 'vivo-store');
2425
+ const sourceDirectory = path.join(screenshotDirectory, 'Android');
2426
+ const outputDirectory = path.join(screenshotDirectory, vivoProfile.outputDirectory);
2427
+ const sourceFiles = fs.existsSync(sourceDirectory)
2428
+ ? fs.readdirSync(sourceDirectory, { withFileTypes: true })
2429
+ .filter((entry) => entry.isFile() && /\.png$/i.test(entry.name))
2430
+ .map((entry) => entry.name)
2431
+ .sort((first, second) => first.localeCompare(second, undefined, { numeric: true }))
2432
+ : [];
2433
+
2434
+ if (!sourceFiles.length) {
2435
+ throw new Error(`No existing PNG screenshots found in ${sourceDirectory}.`);
2436
+ }
2437
+
2438
+ const { maxImages } = vivoProfile.storeRequirements;
2439
+ const selectedSourceFiles = sourceFiles.slice(0, maxImages);
2440
+ if (sourceFiles.length > maxImages) {
2441
+ console.warn(
2442
+ `Vivo Store allows a maximum of ${maxImages} images; using the first ${maxImages} of ${sourceFiles.length} existing Android screenshots.`,
2443
+ );
2444
+ }
2445
+
2446
+ let replayBrowser;
2447
+ let replayClient;
2448
+ let createdCount = 0;
2449
+
2450
+ try {
2451
+ replayBrowser = await launchReplayBrowser();
2452
+ replayClient = await createReplayPage(replayBrowser);
2453
+ fs.mkdirSync(outputDirectory, { recursive: true });
2454
+
2455
+ for (const screenshotFileName of selectedSourceFiles) {
2456
+ const sourcePath = path.join(sourceDirectory, screenshotFileName);
2457
+ const outputPath = path.join(outputDirectory, screenshotFileName);
2458
+ const sourceBuffer = fs.readFileSync(sourcePath);
2459
+ const vivoBuffer = await sharp(sourceBuffer)
2460
+ .resize(vivoProfile.width * vivoProfile.deviceScaleFactor, vivoProfile.height * vivoProfile.deviceScaleFactor, {
2461
+ fit: 'contain',
2462
+ background: '#f5f7fc',
2463
+ })
2464
+ .png()
2465
+ .toBuffer();
2466
+
2467
+ validateStoreImage(
2468
+ vivoProfile,
2469
+ vivoProfile.width * vivoProfile.deviceScaleFactor,
2470
+ vivoProfile.height * vivoProfile.deviceScaleFactor,
2471
+ vivoBuffer,
2472
+ );
2473
+
2474
+ fs.writeFileSync(outputPath, vivoBuffer);
2475
+ createdCount += 1;
2476
+ console.log(`Saved Vivo frameless screenshot: ${outputPath}`);
2477
+
2478
+ await captureFramedScreenshot(
2479
+ replayClient,
2480
+ sourceBuffer,
2481
+ vivoProfile,
2482
+ screenshotFileName,
2483
+ false,
2484
+ );
2485
+ }
2486
+ } finally {
2487
+ if (replayClient) replayClient.close();
2488
+ if (replayBrowser) await closeReplayBrowser(replayBrowser);
2489
+ }
2490
+
2491
+ console.log(`Vivo assets created from existing screenshots: ${createdCount} frameless image(s).`);
2492
+ }
2493
+
2494
+ async function createOppoAssetsFromExistingScreenshots() {
2495
+ const oppoProfile = deviceProfiles.find((profile) => profile.name === 'oppo-store');
2496
+ const sourceDirectory = path.join(screenshotDirectory, 'Android');
2497
+ const outputDirectory = path.join(screenshotDirectory, oppoProfile.outputDirectory);
2498
+ const sourceFiles = fs.existsSync(sourceDirectory)
2499
+ ? fs.readdirSync(sourceDirectory, { withFileTypes: true })
2500
+ .filter((entry) => entry.isFile() && /\.png$/i.test(entry.name))
2501
+ .map((entry) => entry.name)
2502
+ .sort((first, second) => first.localeCompare(second, undefined, { numeric: true }))
2503
+ : [];
2504
+
2505
+ if (!sourceFiles.length) {
2506
+ throw new Error(`No existing PNG screenshots found in ${sourceDirectory}.`);
2507
+ }
2508
+
2509
+ const { maxImages } = oppoProfile.storeRequirements;
2510
+ const selectedSourceFiles = sourceFiles.slice(0, maxImages);
2511
+ if (sourceFiles.length > maxImages) {
2512
+ console.warn(
2513
+ `Oppo Store allows a maximum of ${maxImages} images; using the first ${maxImages} of ${sourceFiles.length} existing Android screenshots.`,
2514
+ );
2515
+ }
2516
+
2517
+ let replayBrowser;
2518
+ let replayClient;
2519
+ let createdCount = 0;
2520
+
2521
+ try {
2522
+ replayBrowser = await launchReplayBrowser();
2523
+ replayClient = await createReplayPage(replayBrowser);
2524
+ fs.mkdirSync(outputDirectory, { recursive: true });
2525
+
2526
+ for (const sourceFileName of selectedSourceFiles) {
2527
+ const outputFileName = `${path.basename(sourceFileName, path.extname(sourceFileName))}.jpg`;
2528
+ const sourceBuffer = fs.readFileSync(path.join(sourceDirectory, sourceFileName));
2529
+ const oppoBuffer = await sharp(sourceBuffer)
2530
+ .resize(oppoProfile.width * oppoProfile.deviceScaleFactor, oppoProfile.height * oppoProfile.deviceScaleFactor, {
2531
+ fit: 'contain',
2532
+ background: '#f5f7fc',
2533
+ })
2534
+ .jpeg({ quality: 90 })
2535
+ .toBuffer();
2536
+ const outputPath = path.join(outputDirectory, outputFileName);
2537
+
2538
+ validateStoreImage(
2539
+ oppoProfile,
2540
+ oppoProfile.width * oppoProfile.deviceScaleFactor,
2541
+ oppoProfile.height * oppoProfile.deviceScaleFactor,
2542
+ oppoBuffer,
2543
+ );
2544
+
2545
+ fs.writeFileSync(outputPath, oppoBuffer);
2546
+ createdCount += 1;
2547
+ console.log(`Saved Oppo frameless screenshot: ${outputPath}`);
2548
+
2549
+ await captureFramedScreenshot(
2550
+ replayClient,
2551
+ sourceBuffer,
2552
+ oppoProfile,
2553
+ outputFileName,
2554
+ false,
2555
+ );
2556
+ }
2557
+ } finally {
2558
+ if (replayClient) replayClient.close();
2559
+ if (replayBrowser) await closeReplayBrowser(replayBrowser);
2560
+ }
2561
+
2562
+ console.log(`Oppo assets created from existing screenshots: ${createdCount} frameless image(s).`);
2563
+ }
2564
+
2213
2565
  async function runInteractiveCaptureLoop(
2214
2566
  deviceClient,
2215
2567
  replayClient,
@@ -2251,6 +2603,8 @@ async function getInteractiveOptions(options) {
2251
2603
  || options.dryRun
2252
2604
  || options.amazonAssets
2253
2605
  || options.samsungAssets
2606
+ || options.vivoExisting
2607
+ || options.oppoExisting
2254
2608
  ) {
2255
2609
  return options;
2256
2610
  }
@@ -2329,6 +2683,16 @@ async function main() {
2329
2683
  return;
2330
2684
  }
2331
2685
 
2686
+ if (options.vivoExisting) {
2687
+ await createVivoAssetsFromExistingScreenshots();
2688
+ return;
2689
+ }
2690
+
2691
+ if (options.oppoExisting) {
2692
+ await createOppoAssetsFromExistingScreenshots();
2693
+ return;
2694
+ }
2695
+
2332
2696
  if (!options.dryRun) {
2333
2697
  await createMissingStoreAssets();
2334
2698
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeplay-common",
3
- "version": "4.6.6",
3
+ "version": "4.6.8",
4
4
  "description": "Common build scripts and files",
5
5
  "scripts": {
6
6
  "postinstall": "node scripts/sync-files.js",