codeplay-common 4.7.2 → 4.7.4

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.
@@ -5,14 +5,34 @@
5
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.
6
6
 
7
7
 
8
+ /*
9
+ 1) run this "node take-screen-image.js"
10
+
11
+ 2) After take all screenshot do the following
12
+ Execute the following in codex
13
+ Execute this AGENTS\Commands\AI-Screenshot text generation.txt
14
+
15
+ 3) Execute the following
16
+ node take-screen-image.js --reframe-existing
17
+ node take-screen-image.js --reframe-existing --screenshots 6,7
18
+ */
19
+
20
+
8
21
  const fs = require('node:fs');
9
22
  const path = require('node:path');
10
23
  const readline = require('node:readline/promises');
11
24
  const { spawn, spawnSync } = require('node:child_process');
12
25
  const sharp = require('sharp');
26
+ const { frameStyles, createFrameSamples } = require('./scripts-screenshot/screenshot/frame-samples.js');
27
+ const {
28
+ getDecorationSeed,
29
+ createPromotionalDecorations,
30
+ getPromotionalDecorationStyles,
31
+ } = require('./scripts-screenshot/screenshot/background-decorations.js');
13
32
 
14
33
  const screenshotDirectory = path.join(__dirname, 'Auto-Screenshot', 'Store-image-Screenshot');
15
34
  const inFrameDirectory = path.join(__dirname, 'Auto-Screenshot', 'Store-image-Frame');
35
+ const uploadDirectory = path.join(__dirname, 'Auto-Screenshot', 'Store-upload');
16
36
  const autoScreenshotDirectory = path.join(__dirname, 'Auto-Screenshot');
17
37
  const aiAssetsDirectory = path.join(__dirname, 'AI-Assets');
18
38
  const amazonAssetsDirectory = path.join(aiAssetsDirectory, 'Amazon-Assets');
@@ -21,6 +41,13 @@ const playstoreAssetsDirectory = path.join(aiAssetsDirectory, 'Playstore-Assets'
21
41
  const capacitorConfigPath = path.join(__dirname, 'capacitor.config.json');
22
42
  const amazonIconPath = path.join(__dirname, 'resources', 'icon-only.png');
23
43
  const temporaryDirectory = path.join(__dirname, 'agent-temp');
44
+ const screenshotCopyPath = path.join(__dirname, 'scripts-screenshot', 'screenshot', 'screenshot-copy.json');
45
+ const screenshotInfoPath = path.join(__dirname, 'scripts-screenshot', 'screenshot', 'screenshot-info.json');
46
+ let selectedFrameStyles = { android: 'graphite', ios: 'midnight' };
47
+ let frameCaptureSequence = 0;
48
+ let screenshotCopyConfig;
49
+ const missingScreenshotCopyWarnings = new Set();
50
+ const frameTimeBase = Date.now();
24
51
  const amazonAssetDefinitions = [
25
52
  { fileName: 'icon-1280x720.png', type: 'icon', width: 1280, height: 720 },
26
53
  { fileName: 'background-image-1920x1080.png', type: 'background-image', width: 1920, height: 1080 },
@@ -146,7 +173,7 @@ const deviceProfiles = [
146
173
  name: 'ipad',
147
174
  label: 'iPad 13-inch App Store viewport',
148
175
  outputDirectory: 'iPad',
149
- frameType: 'tablet',
176
+ frameType: 'ipad',
150
177
  width: 1032,
151
178
  height: 1376,
152
179
  deviceScaleFactor: 2,
@@ -462,6 +489,8 @@ function parseArguments() {
462
489
  samsungAssets: false,
463
490
  dryRun: false,
464
491
  frameExisting: false,
492
+ reframeExisting: false,
493
+ refreshInfo: '',
465
494
  vivoExisting: false,
466
495
  oppoExisting: false,
467
496
  help: false,
@@ -470,6 +499,9 @@ function parseArguments() {
470
499
  deleteNames: '',
471
500
  prefix: '',
472
501
  profiles: '',
502
+ screenshots: '',
503
+ androidFrame: '',
504
+ iosFrame: '',
473
505
  };
474
506
 
475
507
  for (let index = 0; index < args.length; index += 1) {
@@ -483,6 +515,11 @@ function parseArguments() {
483
515
  options.dryRun = true;
484
516
  } else if (argument === '--frame-existing') {
485
517
  options.frameExisting = true;
518
+ } else if (argument === '--reframe-existing') {
519
+ options.reframeExisting = true;
520
+ } else if (argument === '--refresh-info') {
521
+ options.refreshInfo = args[index + 1] || '';
522
+ index += 1;
486
523
  } else if (argument === '--vivo-existing') {
487
524
  options.vivoExisting = true;
488
525
  } else if (argument === '--oppo-existing') {
@@ -501,6 +538,15 @@ function parseArguments() {
501
538
  } else if (argument === '--profiles') {
502
539
  options.profiles = args[index + 1] || '';
503
540
  index += 1;
541
+ } else if (argument === '--screenshots') {
542
+ options.screenshots = args[index + 1] || '';
543
+ index += 1;
544
+ } else if (argument === '--android-frame') {
545
+ options.androidFrame = args[index + 1] || '';
546
+ index += 1;
547
+ } else if (argument === '--ios-frame') {
548
+ options.iosFrame = args[index + 1] || '';
549
+ index += 1;
504
550
  } else {
505
551
  throw new Error(`Unknown argument: ${argument}`);
506
552
  }
@@ -540,8 +586,12 @@ function printHelp() {
540
586
  console.log('Usage:');
541
587
  console.log(' node take-screen-image.js');
542
588
  console.log(' node take-screen-image.js --profiles iphone,ipad');
589
+ console.log(' node take-screen-image.js --android-frame graphite --ios-frame titanium');
543
590
  console.log(' node take-screen-image.js --prefix home --profiles android-phone,ipad');
544
591
  console.log(' node take-screen-image.js --frame-existing');
592
+ console.log(' node take-screen-image.js --reframe-existing --profiles android-phone,iphone');
593
+ console.log(' node take-screen-image.js --reframe-existing --screenshots 1,2');
594
+ console.log(' node take-screen-image.js --refresh-info 1');
545
595
  console.log(' node take-screen-image.js --vivo-existing');
546
596
  console.log(' node take-screen-image.js --oppo-existing');
547
597
  console.log(' node take-screen-image.js --amazon-assets');
@@ -551,6 +601,74 @@ function printHelp() {
551
601
  console.log(' node take-screen-image.js --delete 3,4,5');
552
602
  console.log('');
553
603
  console.log('A normal capture automatically creates any missing Amazon, Samsung, and Play Store assets.');
604
+ console.log('Frame defaults: Android graphite (A), iPhone midnight (D).');
605
+ }
606
+
607
+ async function chooseFrameStyles(options) {
608
+ const choices = { android: options.androidFrame, ios: options.iosFrame };
609
+ const defaults = { android: 'graphite', ios: 'midnight' };
610
+ const aliases = {
611
+ android: { a: 'graphite', b: 'cobalt', 1: 'graphite', 2: 'cobalt' },
612
+ ios: { c: 'titanium', d: 'midnight', 1: 'titanium', 2: 'midnight' },
613
+ };
614
+ const canPrompt = process.stdin.isTTY && !options.prefix;
615
+ const terminal = canPrompt ? readline.createInterface({ input: process.stdin, output: process.stdout }) : null;
616
+
617
+ try {
618
+ for (const platform of ['android', 'ios']) {
619
+ if (!choices[platform] && terminal) {
620
+ const names = Object.values(frameStyles[platform]).map((style) => style.label);
621
+ choices[platform] = await terminal.question(
622
+ `${platform === 'ios' ? 'iPhone' : 'Android'} frame (${names.join(' / ')}), Enter for ${defaults[platform]}: `,
623
+ );
624
+ }
625
+ choices[platform] = (choices[platform] || defaults[platform]).trim().toLowerCase();
626
+ choices[platform] = aliases[platform][choices[platform]] || choices[platform];
627
+ if (!frameStyles[platform][choices[platform]]) {
628
+ throw new Error(`Unknown ${platform} frame: ${choices[platform]}. Choose ${Object.keys(frameStyles[platform]).join(' or ')}.`);
629
+ }
630
+ }
631
+ } finally {
632
+ if (terminal) terminal.close();
633
+ }
634
+
635
+ console.log(`Frame selection: Android ${choices.android}, iPhone ${choices.ios}.`);
636
+ return choices;
637
+ }
638
+
639
+ async function prepareFrameSelection(options) {
640
+ if (process.stdin.isTTY) {
641
+ const samplePaths = await createFrameSamples(autoScreenshotDirectory);
642
+ for (const samplePath of samplePaths) console.log(`Frame choices: ${samplePath}`);
643
+ }
644
+ selectedFrameStyles = await chooseFrameStyles(options);
645
+ }
646
+
647
+ function getFrameDisplayTime(platform) {
648
+ const time = new Date(frameTimeBase + frameCaptureSequence * 60000);
649
+ frameCaptureSequence += 1;
650
+ if (platform === 'ipad') {
651
+ const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
652
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
653
+ return `${String(time.getHours()).padStart(2, '0')}:${String(time.getMinutes()).padStart(2, '0')} `
654
+ + `${weekdays[time.getDay()]} ${time.getDate()} ${months[time.getMonth()]}`;
655
+ }
656
+ const hour = platform === 'ios' ? (time.getHours() % 12 || 12) : time.getHours();
657
+ return `${String(hour).padStart(2, '0')}:${String(time.getMinutes()).padStart(2, '0')}`;
658
+ }
659
+
660
+ function getFrameBatteryPercentage() {
661
+ try {
662
+ const deviceId = getConnectedDevice();
663
+ const batteryDetails = runAdb(['-s', deviceId, 'shell', 'dumpsys', 'battery']);
664
+ const level = Number(batteryDetails.match(/^\s*level:\s*(\d+)/m)?.[1]);
665
+
666
+ if (Number.isInteger(level) && level >= 0 && level <= 100) return level;
667
+ } catch {
668
+ // Existing screenshots can still be framed when no Android device is connected.
669
+ }
670
+
671
+ return 75;
554
672
  }
555
673
 
556
674
  function deleteMatchingImages(input) {
@@ -585,10 +703,92 @@ function deleteMatchingImages(input) {
585
703
  }
586
704
 
587
705
  scanDirectory(autoScreenshotDirectory);
706
+ removeScreenshotRecords(nameSet);
588
707
  console.log(`Deleted ${deletedFiles.length} matching image(s) from ${autoScreenshotDirectory}.`);
589
708
  for (const filePath of deletedFiles) console.log(`Deleted: ${filePath}`);
590
709
  }
591
710
 
711
+ function readScreenshotRecordSource(filePath) {
712
+ if (!fs.existsSync(filePath)) return { screenshots: {} };
713
+
714
+ try {
715
+ const source = JSON.parse(fs.readFileSync(filePath, 'utf8'));
716
+ return source && typeof source === 'object' ? source : { screenshots: {} };
717
+ } catch (error) {
718
+ throw new Error(`Cannot reconcile invalid JSON file ${filePath}: ${error.message}`);
719
+ }
720
+ }
721
+
722
+ function removeScreenshotRecords(names) {
723
+ for (const [filePath, label] of [
724
+ [screenshotCopyPath, 'screenshot copy'],
725
+ [screenshotInfoPath, 'screenshot information'],
726
+ ]) {
727
+ const source = readScreenshotRecordSource(filePath);
728
+ const screenshots = source.screenshots && typeof source.screenshots === 'object'
729
+ ? source.screenshots
730
+ : {};
731
+ const removedNames = [];
732
+
733
+ for (const name of names) {
734
+ if (!Object.hasOwn(screenshots, name)) continue;
735
+ delete screenshots[name];
736
+ removedNames.push(name);
737
+ }
738
+
739
+ if (!removedNames.length) continue;
740
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
741
+ fs.writeFileSync(filePath, `${JSON.stringify({ ...source, screenshots }, null, 2)}\n`);
742
+ console.log(
743
+ `Removed stale ${label} record(s) ${removedNames.join(', ')} from ${filePath}.`,
744
+ );
745
+ }
746
+
747
+ screenshotCopyConfig = undefined;
748
+ }
749
+
750
+ function getCapturedScreenshotNames() {
751
+ const capturedNames = new Set();
752
+
753
+ if (!fs.existsSync(screenshotDirectory)) return capturedNames;
754
+
755
+ const scanDirectory = (directory) => {
756
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
757
+ const entryPath = path.join(directory, entry.name);
758
+ if (entry.isDirectory()) {
759
+ scanDirectory(entryPath);
760
+ } else if (/\.(?:png|jpe?g|webp)$/i.test(entry.name)) {
761
+ capturedNames.add(path.parse(entry.name).name);
762
+ }
763
+ }
764
+ };
765
+
766
+ scanDirectory(screenshotDirectory);
767
+ return capturedNames;
768
+ }
769
+
770
+ function reconcileScreenshotRecords() {
771
+ const capturedNames = getCapturedScreenshotNames();
772
+ const configuredNames = new Set();
773
+
774
+ for (const filePath of [screenshotCopyPath, screenshotInfoPath]) {
775
+ const source = readScreenshotRecordSource(filePath);
776
+ const screenshots = source.screenshots && typeof source.screenshots === 'object'
777
+ ? source.screenshots
778
+ : {};
779
+ Object.keys(screenshots).forEach((name) => configuredNames.add(name));
780
+ }
781
+
782
+ const staleNames = new Set(
783
+ [...configuredNames].filter((name) => !capturedNames.has(name)),
784
+ );
785
+ removeScreenshotRecords(staleNames);
786
+
787
+ if (!staleNames.size) {
788
+ console.log('Screenshot metadata and copy records match the captured screenshots.');
789
+ }
790
+ }
791
+
592
792
  function selectProfiles(input) {
593
793
  const orientation = getConfiguredOrientation();
594
794
  const names = input
@@ -750,6 +950,266 @@ async function capturePageSnapshot(client) {
750
950
  return canvas.toDataURL('image/png');
751
951
  };
752
952
 
953
+ const normalizeUiText = (value) => String(value || '')
954
+ .replace(/\\s+/g, ' ')
955
+ .trim();
956
+
957
+ const isVisible = (element) => {
958
+ if (!element) return false;
959
+ const style = getComputedStyle(element);
960
+ const bounds = element.getBoundingClientRect();
961
+ return style.display !== 'none'
962
+ && style.visibility !== 'hidden'
963
+ && Number(style.opacity || 1) > 0
964
+ && bounds.width > 0
965
+ && bounds.height > 0
966
+ && bounds.bottom > 0
967
+ && bounds.right > 0
968
+ && bounds.top < innerHeight
969
+ && bounds.left < innerWidth;
970
+ };
971
+
972
+ const isSafeUiText = (value) => {
973
+ const text = normalizeUiText(value);
974
+ if (text.length < 2 || text.length > 110) return false;
975
+ if (/(?:https?:\\/\\/|www\\.|@|(?:^|\\s)[a-z]:\\\\)/i.test(text)) return false;
976
+ if (/\\.(?:jpe?g|png|webp|gif|heic|zip|pdf|mp4|mov)(?:\\s|$)/i.test(text)) return false;
977
+ const digitCount = (text.match(/\\d/g) || []).length;
978
+ return digitCount <= Math.max(4, Math.floor(text.length * 0.25));
979
+ };
980
+
981
+ const collectUiText = (root, selector, limit) => {
982
+ if (!root) return [];
983
+ const values = [];
984
+ const seen = new Set();
985
+ for (const element of root.querySelectorAll(selector)) {
986
+ if (!isVisible(element)) continue;
987
+ const value = normalizeUiText(
988
+ element.getAttribute('aria-label')
989
+ || element.getAttribute('title')
990
+ || element.textContent,
991
+ );
992
+ const key = value.toLowerCase();
993
+ if (!isSafeUiText(value) || seen.has(key)) continue;
994
+ seen.add(key);
995
+ values.push(value);
996
+ if (values.length >= limit) break;
997
+ }
998
+ return values;
999
+ };
1000
+
1001
+ const getElementLabel = (element) => {
1002
+ const explicitLabel = normalizeUiText(
1003
+ element.getAttribute('aria-label') || element.getAttribute('title'),
1004
+ );
1005
+ if (isSafeUiText(explicitLabel)) return explicitLabel;
1006
+
1007
+ const labelRoot = element.matches('input, select, textarea')
1008
+ ? element.closest('label, .item-content, .input')
1009
+ : element;
1010
+ const preferredLabel = labelRoot?.querySelector(
1011
+ '.item-title, .button-label, strong, .dialog-button-text',
1012
+ );
1013
+ const textRoot = preferredLabel || labelRoot || element;
1014
+ const clone = textRoot.cloneNode(true);
1015
+ clone.querySelectorAll('small, .item-subtitle, svg, img').forEach((child) => child.remove());
1016
+ const text = normalizeUiText(clone.textContent);
1017
+ return isSafeUiText(text) ? text : '';
1018
+ };
1019
+
1020
+ const getElementContext = (element) => {
1021
+ if (element.closest('.dialog, .popup, .sheet-modal, .popover, .actions-modal')) return 'overlay';
1022
+ if (element.closest('.navbar')) return 'navbar';
1023
+ if (element.closest('.toolbar')) return 'toolbar';
1024
+ return 'content';
1025
+ };
1026
+
1027
+ const getStableClasses = (element) => [...element.classList]
1028
+ .filter((className) => !/^(?:active-state|ripple-wave|focus-visible)$/.test(className))
1029
+ .slice(0, 12);
1030
+
1031
+ const collectButtons = (root, limit) => {
1032
+ if (!root) return [];
1033
+ const records = [];
1034
+ const seen = new Set();
1035
+ const selector = 'button, a.button, [role="button"], .list-button, .dialog-button';
1036
+ for (const element of root.querySelectorAll(selector)) {
1037
+ if (!isVisible(element)) continue;
1038
+ const label = getElementLabel(element);
1039
+ if (!label) continue;
1040
+ const context = getElementContext(element);
1041
+ const key = [label.toLowerCase(), context, element.id].join('|');
1042
+ if (seen.has(key)) continue;
1043
+ seen.add(key);
1044
+ const classes = getStableClasses(element);
1045
+ const classText = classes.join(' ');
1046
+ const prominence = /(?:button-fill|primary|main-action|save-all)/i.test(classText)
1047
+ ? 'primary'
1048
+ : /(?:button-large|dialog-button-bold)/i.test(classText)
1049
+ ? 'major'
1050
+ : /(?:button-outline|secondary)/i.test(classText)
1051
+ ? 'secondary'
1052
+ : 'standard';
1053
+ records.push({
1054
+ label,
1055
+ id: element.id || '',
1056
+ context,
1057
+ prominence,
1058
+ disabled: Boolean(element.disabled || element.getAttribute('aria-disabled') === 'true'),
1059
+ selected: element.getAttribute('aria-pressed') === 'true'
1060
+ || element.getAttribute('aria-selected') === 'true'
1061
+ || element.classList.contains('tab-link-active'),
1062
+ classes,
1063
+ });
1064
+ if (records.length >= limit) break;
1065
+ }
1066
+ return records;
1067
+ };
1068
+
1069
+ const collectInputs = (root, limit) => {
1070
+ if (!root) return [];
1071
+ const records = [];
1072
+ for (const element of root.querySelectorAll('input, select, textarea')) {
1073
+ if (!isVisible(element) || element.type === 'hidden' || element.type === 'file') continue;
1074
+ const labelRoot = element.closest('label, .item-content, .input');
1075
+ const description = collectUiText(
1076
+ labelRoot,
1077
+ 'small, .item-subtitle, .item-after',
1078
+ 1,
1079
+ )[0] || '';
1080
+ const record = {
1081
+ label: getElementLabel(element),
1082
+ id: element.id || '',
1083
+ name: normalizeUiText(element.name),
1084
+ type: element.tagName.toLowerCase() === 'select' ? 'select' : element.type || 'text',
1085
+ context: getElementContext(element),
1086
+ disabled: Boolean(element.disabled),
1087
+ description,
1088
+ };
1089
+ if (element.type === 'checkbox' || element.type === 'radio') {
1090
+ record.checked = Boolean(element.checked);
1091
+ const safeValue = normalizeUiText(element.value);
1092
+ if (isSafeUiText(safeValue)) record.value = safeValue;
1093
+ } else if (element.tagName.toLowerCase() === 'select') {
1094
+ const selectedText = normalizeUiText(element.selectedOptions?.[0]?.textContent);
1095
+ if (isSafeUiText(selectedText)) record.selectedOption = selectedText;
1096
+ }
1097
+ records.push(record);
1098
+ if (records.length >= limit) break;
1099
+ }
1100
+ return records;
1101
+ };
1102
+
1103
+ const visiblePages = [...document.querySelectorAll('.page')].filter(isVisible);
1104
+ const activePage = visiblePages.find((page) => page.classList.contains('page-current'))
1105
+ || visiblePages[visiblePages.length - 1]
1106
+ || null;
1107
+ const overlaySelector = [
1108
+ '.dialog.modal-in',
1109
+ '.popup.modal-in',
1110
+ '.sheet-modal.modal-in',
1111
+ '.popover.modal-in',
1112
+ '.actions-modal.modal-in',
1113
+ '.login-screen.modal-in',
1114
+ ].join(',');
1115
+ const visibleOverlays = [...document.querySelectorAll(overlaySelector)].filter(isVisible);
1116
+ const activeOverlay = visibleOverlays[visibleOverlays.length - 1] || null;
1117
+ const overlayType = activeOverlay
1118
+ ? ['dialog', 'popup', 'sheet-modal', 'popover', 'actions-modal', 'login-screen']
1119
+ .find((className) => activeOverlay.classList.contains(className)) || 'overlay'
1120
+ : '';
1121
+ const textSelector = [
1122
+ 'h1', 'h2', 'h3',
1123
+ '.title', '.dialog-title', '.dialog-text',
1124
+ 'legend', 'strong', '.t-output-choice-heading',
1125
+ '.item-title', '.item-subtitle', '.item-after',
1126
+ 'label', 'button', '[role="button"]',
1127
+ 'p', 'small', 'option:checked',
1128
+ ].join(',');
1129
+ const pageHeadings = collectUiText(
1130
+ activePage,
1131
+ 'h1, h2, h3, .navbar .title, .t-section-heading, .headTitle',
1132
+ 10,
1133
+ );
1134
+ const pageControls = collectUiText(
1135
+ activePage,
1136
+ 'button, [role="button"], label, .item-title, .item-subtitle, .list-button, option:checked',
1137
+ 30,
1138
+ );
1139
+ const overlayText = collectUiText(activeOverlay, textSelector, 35);
1140
+ const pageButtons = collectButtons(activePage, 40);
1141
+ const overlayButtons = collectButtons(activeOverlay, 20);
1142
+ const pageInputs = collectInputs(activePage, 30);
1143
+ const overlayInputs = collectInputs(activeOverlay, 25);
1144
+ const f7Route = activePage?.f7Page?.route
1145
+ || activePage?.f7Page?.router?.currentRoute
1146
+ || null;
1147
+ const framework7Route = f7Route ? {
1148
+ url: normalizeUiText(f7Route.url),
1149
+ path: normalizeUiText(f7Route.path || f7Route.route?.path),
1150
+ name: normalizeUiText(f7Route.name || f7Route.route?.name),
1151
+ params: Object.keys(f7Route.params || {}),
1152
+ query: Object.keys(f7Route.query || {}),
1153
+ } : null;
1154
+ const screenInfo = {
1155
+ documentTitle: normalizeUiText(document.title),
1156
+ route: {
1157
+ pathname: location.pathname,
1158
+ hash: location.hash,
1159
+ framework7: framework7Route,
1160
+ },
1161
+ page: activePage ? {
1162
+ dataName: activePage.getAttribute('data-name') || '',
1163
+ id: activePage.id || '',
1164
+ classes: getStableClasses(activePage),
1165
+ navbarTitle: collectUiText(activePage, '.navbar .title', 1)[0] || '',
1166
+ headings: pageHeadings,
1167
+ sections: collectUiText(
1168
+ activePage,
1169
+ 'h1, h2, h3, legend, .t-section-heading, .t-output-choice-heading, .headTitle',
1170
+ 20,
1171
+ ),
1172
+ descriptions: collectUiText(
1173
+ activePage,
1174
+ 'p, small, .item-subtitle, .item-after, .t-settings-footnote',
1175
+ 30,
1176
+ ),
1177
+ controls: pageControls,
1178
+ buttons: pageButtons,
1179
+ primaryButtons: pageButtons.filter((button) => (
1180
+ button.prominence === 'primary' || button.prominence === 'major'
1181
+ )),
1182
+ inputs: pageInputs,
1183
+ obscuredByOverlay: Boolean(activeOverlay),
1184
+ } : null,
1185
+ overlay: activeOverlay ? {
1186
+ type: overlayType,
1187
+ id: activeOverlay.id || '',
1188
+ classes: getStableClasses(activeOverlay),
1189
+ title: collectUiText(activeOverlay, '.dialog-title, .navbar .title, h1, h2, h3', 1)[0] || '',
1190
+ sections: collectUiText(
1191
+ activeOverlay,
1192
+ 'h1, h2, h3, legend, strong, .t-section-heading, .t-output-choice-heading',
1193
+ 20,
1194
+ ),
1195
+ visibleText: overlayText,
1196
+ buttons: overlayButtons,
1197
+ primaryButtons: overlayButtons.filter((button) => (
1198
+ button.prominence === 'primary' || button.prominence === 'major'
1199
+ )),
1200
+ inputs: overlayInputs,
1201
+ } : null,
1202
+ theme: document.documentElement.classList.contains('theme-dark')
1203
+ || document.body.classList.contains('theme-dark')
1204
+ ? 'dark'
1205
+ : 'light',
1206
+ viewport: {
1207
+ width: innerWidth,
1208
+ height: innerHeight,
1209
+ devicePixelRatio,
1210
+ },
1211
+ };
1212
+
753
1213
  const serializeDocument = async (sourceDocument) => {
754
1214
  const clonedRoot = sourceDocument.documentElement.cloneNode(true);
755
1215
  const originalElements = [
@@ -892,6 +1352,7 @@ async function capturePageSnapshot(client) {
892
1352
  return {
893
1353
  html: '<!doctype html>' + html,
894
1354
  scrollY,
1355
+ screenInfo,
895
1356
  };
896
1357
  })()`,
897
1358
  awaitPromise: true,
@@ -1190,17 +1651,27 @@ async function waitForReplayLayout(client, snapshot) {
1190
1651
 
1191
1652
  function getFrameAdornment(frameType) {
1192
1653
  if (frameType === 'iphone') {
1193
- return '<div class="dynamic-island"></div>';
1654
+ return '';
1194
1655
  }
1195
1656
 
1196
1657
  if (frameType === 'android-phone') {
1197
- return '<div class="camera-dot"></div>';
1658
+ return '';
1198
1659
  }
1199
1660
 
1200
1661
  if (frameType === 'tablet') {
1201
1662
  return '<div class="tablet-camera"></div>';
1202
1663
  }
1203
1664
 
1665
+ if (frameType === 'ipad') {
1666
+ return `<div class="ipad-camera-array" aria-hidden="true">
1667
+ <span class="ipad-sensor ipad-sensor-large"></span>
1668
+ <span class="ipad-sensor ipad-sensor-large"></span>
1669
+ <span class="ipad-sensor ipad-sensor-lens"></span>
1670
+ <span class="ipad-sensor ipad-sensor-small"></span>
1671
+ <span class="ipad-sensor ipad-sensor-large"></span>
1672
+ </div><div class="ipad-side-button" aria-hidden="true"></div>`;
1673
+ }
1674
+
1204
1675
  if (frameType === 'tv' || frameType === 'apple-tv') {
1205
1676
  return '<div class="tv-stand"></div>';
1206
1677
  }
@@ -1220,10 +1691,167 @@ function getFrameAdornment(frameType) {
1220
1691
  return '<div class="xr-status"></div>';
1221
1692
  }
1222
1693
 
1223
- function createStoreDeviceFrameHtml(profile, screenshotBuffer, imageWidth, imageHeight, canvasWidth, canvasHeight, imageFormat) {
1694
+ async function getScreenshotBarStyle(screenshotBuffer, edge = 'top') {
1695
+ const metadata = await sharp(screenshotBuffer).metadata();
1696
+ const { data, info } = await sharp(screenshotBuffer)
1697
+ .extract({
1698
+ left: 0,
1699
+ top: edge === 'bottom' ? metadata.height - 1 : 0,
1700
+ width: 1,
1701
+ height: 1,
1702
+ })
1703
+ .removeAlpha()
1704
+ .raw()
1705
+ .toBuffer({ resolveWithObject: true });
1706
+ const color = [data[0], data[1], data[2]];
1707
+ const luminance = color
1708
+ .map((value) => value / 255)
1709
+ .map((value) => (value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4))
1710
+ .reduce((total, value, index) => total + value * [0.2126, 0.7152, 0.0722][index], 0);
1711
+
1712
+ if (info.channels < 3) {
1713
+ throw new Error('Unable to read the screenshot starting pixel color.');
1714
+ }
1715
+
1716
+ return {
1717
+ background: `rgb(${color.join(', ')})`,
1718
+ foreground: luminance > 0.179 ? '#050505' : '#ffffff',
1719
+ };
1720
+ }
1721
+
1722
+ function getScreenshotCopy(screenshotFileName) {
1723
+ screenshotCopyConfig ||= readJsonAssetSource(screenshotCopyPath);
1724
+ const screenshotKey = path.parse(screenshotFileName).name;
1725
+ const configuredCopy = screenshotCopyConfig.screenshots?.[screenshotKey];
1726
+
1727
+ if (!configuredCopy || typeof configuredCopy !== 'object') return null;
1728
+
1729
+ const title = cleanAssetCopy(configuredCopy.title, 56);
1730
+ const subtitle = cleanAssetCopy(configuredCopy.subtitle, 110);
1731
+ const features = Array.isArray(configuredCopy.features)
1732
+ ? configuredCopy.features
1733
+ .map((feature) => cleanAssetCopy(feature, 28))
1734
+ .filter(Boolean)
1735
+ .slice(0, 3)
1736
+ : [];
1737
+
1738
+ if (!title && !subtitle && !features.length) return null;
1739
+
1740
+ return { title, subtitle, features };
1741
+ }
1742
+
1743
+ function createScreenshotCopyMarkup(screenshotCopy) {
1744
+ if (!screenshotCopy) return '';
1745
+
1746
+ const featureMarkup = screenshotCopy.features
1747
+ .map((feature) => `<span>${escapeHtml(feature)}</span>`)
1748
+ .join('');
1749
+
1750
+ return `<section class="promo-copy">
1751
+ ${screenshotCopy.title ? `<h1>${escapeHtml(screenshotCopy.title)}</h1>` : ''}
1752
+ ${screenshotCopy.subtitle ? `<p>${escapeHtml(screenshotCopy.subtitle)}</p>` : ''}
1753
+ ${featureMarkup ? `<div class="promo-features">${featureMarkup}</div>` : ''}
1754
+ </section>`;
1755
+ }
1756
+
1757
+ function rgbToHsl(red, green, blue) {
1758
+ const values = [red, green, blue].map((value) => value / 255);
1759
+ const maximum = Math.max(...values);
1760
+ const minimum = Math.min(...values);
1761
+ const lightness = (maximum + minimum) / 2;
1762
+ const delta = maximum - minimum;
1763
+ if (!delta) return { hue: 230, saturation: 0, lightness };
1764
+
1765
+ const saturation = delta / (1 - Math.abs(2 * lightness - 1));
1766
+ let hue;
1767
+ if (maximum === values[0]) hue = 60 * (((values[1] - values[2]) / delta) % 6);
1768
+ else if (maximum === values[1]) hue = 60 * ((values[2] - values[0]) / delta + 2);
1769
+ else hue = 60 * ((values[0] - values[1]) / delta + 4);
1770
+ return { hue: (hue + 360) % 360, saturation, lightness };
1771
+ }
1772
+
1773
+ async function getPromotionalPalette(screenshotBuffer, screenshotFileName) {
1774
+ const { data, info } = await sharp(screenshotBuffer)
1775
+ .resize(36, 36, { fit: 'fill' })
1776
+ .removeAlpha()
1777
+ .raw()
1778
+ .toBuffer({ resolveWithObject: true });
1779
+ const colorBuckets = new Map();
1780
+
1781
+ for (let index = 0; index < data.length; index += info.channels) {
1782
+ const red = data[index];
1783
+ const green = data[index + 1];
1784
+ const blue = data[index + 2];
1785
+ const hsl = rgbToHsl(red, green, blue);
1786
+ if (hsl.lightness < 0.1 || hsl.lightness > 0.94 || hsl.saturation < 0.12) continue;
1787
+ const key = `${Math.round(red / 24)},${Math.round(green / 24)},${Math.round(blue / 24)}`;
1788
+ const bucket = colorBuckets.get(key) || {
1789
+ count: 0, red: 0, green: 0, blue: 0, saturation: 0, lightness: 0,
1790
+ };
1791
+ bucket.count += 1;
1792
+ bucket.red += red;
1793
+ bucket.green += green;
1794
+ bucket.blue += blue;
1795
+ bucket.saturation += hsl.saturation;
1796
+ bucket.lightness += hsl.lightness;
1797
+ colorBuckets.set(key, bucket);
1798
+ }
1799
+
1800
+ let selectedColor = { red: 85, green: 70, blue: 232 };
1801
+ let selectedScore = -1;
1802
+ for (const bucket of colorBuckets.values()) {
1803
+ const saturation = bucket.saturation / bucket.count;
1804
+ const lightness = bucket.lightness / bucket.count;
1805
+ const score = bucket.count * (0.55 + saturation * 1.8)
1806
+ * (1 - Math.abs(lightness - 0.55) * 0.7);
1807
+ if (score <= selectedScore) continue;
1808
+ selectedScore = score;
1809
+ selectedColor = {
1810
+ red: Math.round(bucket.red / bucket.count),
1811
+ green: Math.round(bucket.green / bucket.count),
1812
+ blue: Math.round(bucket.blue / bucket.count),
1813
+ };
1814
+ }
1815
+
1816
+ const seed = getDecorationSeed(path.parse(screenshotFileName).name);
1817
+ const sourceHsl = rgbToHsl(selectedColor.red, selectedColor.green, selectedColor.blue);
1818
+ const hue = Math.round((sourceHsl.hue + (seed % 25) - 12 + 360) % 360);
1819
+ const secondaryHue = (hue + [38, 56, 142, 205][seed % 4]) % 360;
1820
+ const saturation = Math.max(58, Math.min(82, Math.round(sourceHsl.saturation * 100)));
1821
+
1822
+ return {
1823
+ seed,
1824
+ accent: `hsl(${hue} ${saturation}% 46%)`,
1825
+ accentSoft: `hsla(${hue} ${saturation}% 55% / 0.2)`,
1826
+ secondary: `hsl(${secondaryHue} 70% 54%)`,
1827
+ secondarySoft: `hsla(${secondaryHue} 72% 58% / 0.18)`,
1828
+ backgroundStart: `hsl(${hue} 68% 97%)`,
1829
+ backgroundMiddle: `hsl(${(hue + 14) % 360} 58% 93%)`,
1830
+ backgroundEnd: `hsl(${secondaryHue} 62% 88%)`,
1831
+ text: `hsl(${hue} 35% 17%)`,
1832
+ mutedText: `hsl(${hue} 18% 38%)`,
1833
+ };
1834
+ }
1835
+
1836
+ function createStoreDeviceFrameHtml(
1837
+ profile,
1838
+ screenshotBuffer,
1839
+ imageWidth,
1840
+ imageHeight,
1841
+ canvasWidth,
1842
+ canvasHeight,
1843
+ imageFormat,
1844
+ displayTime,
1845
+ batteryPercentage,
1846
+ statusBarStyle,
1847
+ navigationBarStyle,
1848
+ screenshotCopy,
1849
+ palette,
1850
+ ) {
1224
1851
  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';
1852
+ const availableDeviceHeight = screenshotCopy ? canvasHeight * 0.64 : canvasHeight - 180;
1853
+ const screenWidth = Math.floor(Math.min(canvasWidth - 160, availableDeviceHeight * imageWidth / imageHeight));
1854
+ const frameStyle = frameStyles.android[selectedFrameStyles.android];
1227
1855
 
1228
1856
  return `<!doctype html>
1229
1857
  <html>
@@ -1235,34 +1863,67 @@ function createStoreDeviceFrameHtml(profile, screenshotBuffer, imageWidth, image
1235
1863
  body {
1236
1864
  display: grid;
1237
1865
  place-items: center;
1238
- background: radial-gradient(ellipse at 50% 43%, #24344d 0%, #101b2d 47%, #070e1a 100%);
1866
+ position: relative;
1867
+ background:
1868
+ radial-gradient(circle at 12% 16%, ${palette.accentSoft}, transparent 32%),
1869
+ radial-gradient(circle at 88% 84%, ${palette.secondarySoft}, transparent 34%),
1870
+ linear-gradient(145deg, ${palette.backgroundStart}, ${palette.backgroundMiddle} 52%, ${palette.backgroundEnd});
1239
1871
  }
1240
1872
  body::before {
1241
1873
  content: '';
1242
1874
  position: absolute;
1243
- width: 82%;
1244
- height: 62%;
1245
- border-radius: 50%;
1246
- background: ${accent};
1247
- opacity: 0.11;
1248
- filter: blur(90px);
1875
+ inset: 0;
1876
+ opacity: 0.16;
1877
+ background-image: radial-gradient(${palette.accent} 1px, transparent 1px);
1878
+ background-size: 28px 28px;
1879
+ mask-image: linear-gradient(135deg, black, transparent 42%, transparent 64%, black);
1249
1880
  }
1881
+ ${getPromotionalDecorationStyles()}
1882
+ .promo-copy {
1883
+ position: absolute;
1884
+ z-index: 2;
1885
+ top: 4.5%;
1886
+ right: 7%;
1887
+ left: 7%;
1888
+ display: flex;
1889
+ flex-direction: column;
1890
+ align-items: center;
1891
+ color: ${palette.text};
1892
+ text-align: center;
1893
+ font-family: Arial, sans-serif;
1894
+ }
1895
+ .promo-copy h1 { max-width: 900px; margin: 0; font-size: clamp(42px, 5.6vw, 72px); line-height: 1.02; letter-spacing: -0.035em; }
1896
+ .promo-copy p { max-width: 850px; margin: 2.1% 0 0; color: ${palette.mutedText}; font-size: clamp(22px, 2.5vw, 32px); line-height: 1.35; }
1897
+ .promo-features { display: flex; flex-wrap: wrap; justify-content: center; gap: 12px; margin-top: 2.4%; }
1898
+ .promo-features span { padding: 10px 17px; border: 1px solid ${palette.accentSoft}; border-radius: 999px; background: rgba(255,255,255,0.72); color: ${palette.accent}; box-shadow: 0 8px 24px rgba(15,23,42,0.08); font: 700 clamp(16px, 1.8vw, 23px)/1 Arial, sans-serif; }
1899
+ body.has-copy .device { position: absolute; bottom: 3.5%; left: 50%; transform: translateX(-50%); }
1250
1900
  .device {
1251
1901
  position: relative;
1252
1902
  z-index: 1;
1253
1903
  width: ${screenWidth + 48}px;
1254
1904
  padding: 34px 18px 20px;
1255
- border: 6px solid #8d99ac;
1905
+ border: 6px solid ${frameStyle.edge};
1256
1906
  border-radius: 48px;
1257
- background: linear-gradient(135deg, #404c60, #111925 35%, #070c15 72%, #59657a);
1907
+ background: ${frameStyle.outer};
1258
1908
  box-shadow: 0 38px 85px rgba(0, 0, 0, 0.58), 0 0 0 2px rgba(255, 255, 255, 0.19) inset;
1259
1909
  }
1260
1910
  .screen {
1911
+ display: flex;
1912
+ flex-direction: column;
1261
1913
  width: ${screenWidth}px;
1914
+ aspect-ratio: ${imageWidth} / ${imageHeight};
1915
+ overflow: hidden;
1262
1916
  background: #fff;
1263
1917
  box-shadow: 0 0 0 2px #03060c;
1264
1918
  }
1265
- .screen img { display: block; width: 100%; height: auto; }
1919
+ .screen img { display: block; width: 100%; height: 0; min-height: 0; flex: 1 1 0; object-fit: fill; }
1920
+ .phone-statusbar { position: relative; flex: none; height: 5%; display: flex; align-items: center; background: ${statusBarStyle.background}; color: ${statusBarStyle.foreground}; font-family: Arial, sans-serif; }
1921
+ .status-time { margin-left: 5%; font-size: ${Math.max(11, Math.round(screenWidth * 0.03))}px; white-space: nowrap; }
1922
+ .android-statusbar { height: 4%; font-family: Roboto, Arial, sans-serif; }
1923
+ .android-notification { width: 3.4%; height: 46%; margin-left: 2%; }
1924
+ .android-status-icons { width: 32%; height: 58%; margin-left: auto; margin-right: 4%; }
1925
+ .android-navigation { position: absolute; z-index: 2; right: 0; bottom: 0; left: 0; height: 2.2%; display: grid; place-items: center; background: transparent; }
1926
+ .gesture-pill { width: 40%; height: 14%; border-radius: 999px; background: ${navigationBarStyle.foreground}; }
1266
1927
  .camera {
1267
1928
  position: absolute;
1268
1929
  top: 11px;
@@ -1285,19 +1946,112 @@ function createStoreDeviceFrameHtml(profile, screenshotBuffer, imageWidth, image
1285
1946
  }
1286
1947
  </style>
1287
1948
  </head>
1288
- <body>
1949
+ <body class="${screenshotCopy ? 'has-copy' : ''}">
1950
+ ${createPromotionalDecorations(palette)}
1951
+ ${createScreenshotCopyMarkup(screenshotCopy)}
1289
1952
  <main class="device">
1290
1953
  <div class="camera"></div>
1291
- <div class="screen"><img src="${screenshotDataUrl}" /></div>
1954
+ <div class="screen">${getPhoneStatusBar('android', displayTime, batteryPercentage)}<img src="${screenshotDataUrl}" /><div class="android-navigation"><div class="gesture-pill"></div></div></div>
1292
1955
  <div class="side-button"></div>
1293
1956
  </main>
1294
1957
  </body>
1295
1958
  </html>`;
1296
1959
  }
1297
1960
 
1298
- function createDeviceFrameHtml(profile, screenshotBuffer, imageWidth = profile.width, imageHeight = profile.height, imageFormat = 'png') {
1961
+ function getPhoneStatusBar(platform, time, batteryPercentage = null) {
1962
+ const batteryLevel = Number.isInteger(batteryPercentage) ? batteryPercentage : 75;
1963
+
1964
+ if (platform === 'ios') {
1965
+ return `<div class="phone-statusbar ios-statusbar">
1966
+ <span class="status-time">${time}</span>
1967
+ <div class="dynamic-island"></div>
1968
+ <svg viewBox="0 0 90 28" aria-hidden="true">
1969
+ <circle cx="5" cy="14" r="2" fill="currentColor" opacity="0.35"></circle><circle cx="13" cy="14" r="2" fill="currentColor" opacity="0.35"></circle>
1970
+ <circle cx="21" cy="14" r="2" fill="currentColor" opacity="0.35"></circle><circle cx="29" cy="14" r="2" fill="currentColor" opacity="0.35"></circle>
1971
+ <path d="M36 10 Q46 2 56 10 M39 14 Q46 8 53 14 M44 18 Q46 16 48 18" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"></path>
1972
+ <rect x="61" y="7" width="22" height="15" rx="4" fill="none" stroke="currentColor" stroke-width="1.5" opacity="0.65"></rect>
1973
+ <rect x="64" y="10" width="16" height="9" rx="2" fill="currentColor"></rect>
1974
+ <rect x="85" y="12" width="2" height="5" rx="1" fill="currentColor" opacity="0.65"></rect>
1975
+ </svg>
1976
+ </div>`;
1977
+ }
1978
+
1979
+ if (platform === 'ipad') {
1980
+ const batteryFillWidth = Math.max(2, Math.round(24 * batteryLevel / 100));
1981
+ return `<div class="phone-statusbar ipad-statusbar">
1982
+ <span class="status-time">${time}</span>
1983
+ <svg class="ipad-status-icons" viewBox="0 0 130 28" aria-hidden="true">
1984
+ <g fill="currentColor">
1985
+ <rect x="2" y="19" width="3" height="5" rx="1"></rect>
1986
+ <rect x="7" y="15" width="3" height="9" rx="1"></rect>
1987
+ <rect x="12" y="11" width="3" height="13" rx="1"></rect>
1988
+ <rect x="17" y="7" width="3" height="17" rx="1"></rect>
1989
+ </g>
1990
+ <path d="M28 10 Q38 2 48 10 M31 14 Q38 8 45 14 M36 18 Q38 16 40 18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"></path>
1991
+ <circle cx="38" cy="21" r="1.7" fill="currentColor"></circle>
1992
+ <text x="54" y="20" font-size="12" font-weight="600" fill="currentColor">${batteryLevel}%</text>
1993
+ <rect x="88" y="6" width="32" height="17" rx="4" fill="none" stroke="currentColor" stroke-width="1.6"></rect>
1994
+ <rect x="91" y="9" width="${batteryFillWidth}" height="11" rx="2" fill="currentColor" opacity="0.72"></rect>
1995
+ <rect x="122" y="11" width="3" height="7" rx="1.5" fill="currentColor"></rect>
1996
+ </svg>
1997
+ </div>`;
1998
+ }
1999
+
2000
+ const batteryFillWidth = Math.max(2, Math.round(30 * batteryLevel / 100));
2001
+
2002
+ return `<div class="phone-statusbar android-statusbar">
2003
+ <span class="status-time">${time}</span>
2004
+ <svg class="android-notification" viewBox="0 0 24 24" aria-hidden="true">
2005
+ <circle cx="12" cy="12" r="9.5" fill="none" stroke="currentColor" stroke-width="1.8"></circle>
2006
+ <circle cx="8" cy="10" r="1.3" fill="currentColor"></circle>
2007
+ <circle cx="12" cy="8" r="1.3" fill="currentColor"></circle>
2008
+ <circle cx="16" cy="10" r="1.3" fill="currentColor"></circle>
2009
+ <path d="M7.5 14 Q12 18 16.5 14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"></path>
2010
+ </svg>
2011
+ <svg class="android-status-icons" viewBox="0 0 200 28" aria-hidden="true">
2012
+ <g fill="currentColor">
2013
+ <rect x="1" y="20" width="5" height="5" rx="1"></rect><rect x="8" y="16" width="5" height="9" rx="1"></rect>
2014
+ <rect x="15" y="12" width="5" height="13" rx="1"></rect><rect x="22" y="8" width="5" height="17" rx="1"></rect><rect x="29" y="4" width="5" height="21" rx="1"></rect>
2015
+ <rect x="43" y="20" width="5" height="5" rx="1"></rect><rect x="50" y="16" width="5" height="9" rx="1"></rect>
2016
+ <rect x="57" y="12" width="5" height="13" rx="1"></rect><rect x="64" y="8" width="5" height="17" rx="1"></rect><rect x="71" y="4" width="5" height="21" rx="1"></rect>
2017
+ </g>
2018
+ <path d="M86 9 Q99 -1 112 9 M90 14 Q99 7 108 14 M95 19 Q99 16 103 19" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"></path>
2019
+ <circle cx="99" cy="22" r="2" fill="currentColor"></circle>
2020
+ <text x="108" y="24" font-size="7" font-weight="700" fill="currentColor">6</text>
2021
+ <rect x="121" y="4" width="39" height="21" rx="5" fill="none" stroke="currentColor" stroke-width="2"></rect>
2022
+ <rect x="125" y="8" width="${batteryFillWidth}" height="13" rx="2" fill="currentColor" opacity="0.65"></rect>
2023
+ <rect x="162" y="10" width="3" height="9" rx="1.5" fill="currentColor"></rect>
2024
+ <text x="169" y="21" font-size="13" font-weight="500" fill="currentColor">${batteryLevel}%</text>
2025
+ </svg>
2026
+ </div>`;
2027
+ }
2028
+
2029
+ function createDeviceFrameHtml(
2030
+ profile,
2031
+ screenshotBuffer,
2032
+ imageWidth = profile.width,
2033
+ imageHeight = profile.height,
2034
+ imageFormat = 'png',
2035
+ displayTime = '',
2036
+ batteryPercentage = null,
2037
+ statusBarStyle = null,
2038
+ navigationBarStyle = null,
2039
+ screenshotCopy = null,
2040
+ palette,
2041
+ ) {
1299
2042
  const screenshotDataUrl = `data:image/${imageFormat === 'jpeg' ? 'jpeg' : 'png'};base64,${screenshotBuffer.toString('base64')}`;
1300
2043
  const aspectRatio = `${imageWidth} / ${imageHeight}`;
2044
+ const phonePlatform = profile.frameType === 'iphone' ? 'ios' : profile.frameType === 'android-phone' ? 'android' : null;
2045
+ const statusBarPlatform = profile.frameType === 'ipad' ? 'ipad' : phonePlatform;
2046
+ const phoneStyle = phonePlatform && frameStyles[phonePlatform][selectedFrameStyles[phonePlatform]];
2047
+ const phoneStatusBar = statusBarPlatform
2048
+ ? getPhoneStatusBar(statusBarPlatform, displayTime, batteryPercentage)
2049
+ : '';
2050
+ const phoneNavigation = phonePlatform === 'android'
2051
+ ? '<div class="android-navigation"><div class="gesture-pill"></div></div>'
2052
+ : statusBarPlatform === 'ios' || statusBarPlatform === 'ipad'
2053
+ ? `<div class="ios-navigation${statusBarPlatform === 'ipad' ? ' ipad-navigation' : ''}"><div class="ios-gesture-pill"></div></div>`
2054
+ : '';
1301
2055
 
1302
2056
  return `<!doctype html>
1303
2057
  <html>
@@ -1311,22 +2065,33 @@ function createDeviceFrameHtml(profile, screenshotBuffer, imageWidth = profile.w
1311
2065
  place-items: center;
1312
2066
  position: relative;
1313
2067
  background:
1314
- radial-gradient(circle at 14% 18%, rgba(45, 213, 255, 0.24), transparent 30%),
1315
- radial-gradient(circle at 86% 82%, rgba(196, 255, 61, 0.16), transparent 28%),
1316
- linear-gradient(145deg, #111827 0%, #070b12 52%, #020409 100%);
2068
+ radial-gradient(circle at 12% 16%, ${palette.accentSoft}, transparent 32%),
2069
+ radial-gradient(circle at 88% 84%, ${palette.secondarySoft}, transparent 34%),
2070
+ linear-gradient(145deg, ${palette.backgroundStart}, ${palette.backgroundMiddle} 52%, ${palette.backgroundEnd});
1317
2071
  font-family: Arial, sans-serif;
1318
2072
  }
1319
2073
  body::before {
1320
2074
  content: '';
1321
2075
  position: absolute;
1322
2076
  inset: 0;
1323
- opacity: 0.18;
1324
- background-image:
1325
- linear-gradient(rgba(255,255,255,0.045) 1px, transparent 1px),
1326
- linear-gradient(90deg, rgba(255,255,255,0.045) 1px, transparent 1px);
1327
- background-size: 7vw 7vw;
1328
- mask-image: linear-gradient(to bottom, transparent, black 28%, black 72%, transparent);
2077
+ opacity: 0.16;
2078
+ background-image: radial-gradient(${palette.accent} 0.8px, transparent 0.8px);
2079
+ background-size: clamp(18px, 2.2vw, 34px) clamp(18px, 2.2vw, 34px);
2080
+ mask-image: linear-gradient(135deg, black, transparent 42%, transparent 64%, black);
2081
+ }
2082
+ ${getPromotionalDecorationStyles()}
2083
+ .promo-copy {
2084
+ position: absolute;
2085
+ z-index: 4;
2086
+ display: flex;
2087
+ flex-direction: column;
2088
+ color: ${palette.text};
2089
+ font-family: Arial, sans-serif;
1329
2090
  }
2091
+ .promo-copy h1 { margin: 0; font-size: clamp(24px, 5.4vw, 82px); line-height: 1.02; letter-spacing: -0.04em; }
2092
+ .promo-copy p { margin: 1.8vh 0 0; color: ${palette.mutedText}; font-size: clamp(14px, 2.1vw, 34px); line-height: 1.35; }
2093
+ .promo-features { display: flex; flex-wrap: wrap; gap: clamp(6px, 0.9vw, 16px); margin-top: 2vh; }
2094
+ .promo-features span { padding: clamp(6px, 0.75vw, 13px) clamp(9px, 1.2vw, 20px); border: 1px solid ${palette.accentSoft}; border-radius: 999px; background: rgba(255,255,255,0.72); color: ${palette.accent}; box-shadow: 0 0.5vh 1.8vh rgba(15,23,42,0.08); font-size: clamp(11px, 1.45vw, 23px); font-weight: 700; line-height: 1; }
1330
2095
  .device {
1331
2096
  position: relative;
1332
2097
  z-index: 1;
@@ -1338,10 +2103,13 @@ function createDeviceFrameHtml(profile, screenshotBuffer, imageWidth = profile.w
1338
2103
  0 0 0 1px rgba(255, 255, 255, 0.18) inset,
1339
2104
  0 0 5vh rgba(45, 213, 255, 0.12);
1340
2105
  }
2106
+ .device:not(.android-phone):not(.iphone) {
2107
+ box-sizing: content-box;
2108
+ }
1341
2109
  .screen {
1342
2110
  position: relative;
1343
2111
  width: 100%;
1344
- height: 87%;
2112
+ height: 100%;
1345
2113
  overflow: hidden;
1346
2114
  background: #f5f7fc;
1347
2115
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.95) inset;
@@ -1355,14 +2123,86 @@ function createDeviceFrameHtml(profile, screenshotBuffer, imageWidth = profile.w
1355
2123
  }
1356
2124
  .device.android-phone,
1357
2125
  .device.iphone {
1358
- height: 100%;
1359
- padding: 1.25%;
2126
+ height: 84%;
2127
+ padding: 2.5%;
1360
2128
  border-radius: clamp(24px, 8vw, 70px);
1361
2129
  }
1362
- .device.android-phone .screen,
1363
- .device.iphone .screen {
1364
- border-radius: clamp(19px, 6.7vw, 60px);
2130
+ ${phoneStyle ? `
2131
+ .device.${profile.frameType} {
2132
+ background: ${phoneStyle.outer};
2133
+ border-color: ${phoneStyle.edge};
2134
+ border-radius: calc(84vh * ${imageWidth / imageHeight * phoneStyle.radius / 340});
2135
+ box-shadow: 0 4vh 9vh rgba(0, 0, 0, 0.48), 0 0 0 0.55vw ${phoneStyle.inner} inset;
2136
+ }
2137
+ .device.${profile.frameType} .screen {
2138
+ display: flex;
2139
+ flex-direction: column;
2140
+ height: 100%;
2141
+ border-radius: calc(84vh * ${imageWidth / imageHeight * phoneStyle.radius / 340} - 2.5vw - clamp(2px, 0.45vw, 9px));
1365
2142
  }
2143
+ .device.${profile.frameType} .screen img {
2144
+ flex: 1 1 0;
2145
+ min-height: 0;
2146
+ object-fit: fill;
2147
+ }` : ''}
2148
+ .phone-statusbar {
2149
+ position: relative;
2150
+ z-index: 2;
2151
+ flex: none;
2152
+ height: 5%;
2153
+ display: flex;
2154
+ align-items: center;
2155
+ font-family: Arial, sans-serif;
2156
+ }
2157
+ .status-time {
2158
+ margin-left: 5%;
2159
+ font-size: clamp(11px, 3vw, 40px);
2160
+ white-space: nowrap;
2161
+ }
2162
+ .ios-statusbar { background: ${statusBarStyle?.background || 'transparent'}; color: ${statusBarStyle?.foreground || '#050505'}; font-weight: 700; }
2163
+ .ios-statusbar svg { width: 27%; height: 62%; margin-left: auto; margin-right: 3%; }
2164
+ .ios-statusbar .dynamic-island {
2165
+ position: absolute;
2166
+ top: 22%;
2167
+ left: 50%;
2168
+ width: 25%;
2169
+ height: 56%;
2170
+ transform: translateX(-50%);
2171
+ border-radius: 999px;
2172
+ background: #000;
2173
+ }
2174
+ .android-statusbar { height: 4%; background: ${statusBarStyle?.background || 'transparent'}; color: ${statusBarStyle?.foreground || '#050505'}; font-family: Roboto, Arial, sans-serif; }
2175
+ .android-notification { width: 3.4%; height: 46%; margin-left: 2%; }
2176
+ .android-status-icons { width: 32%; height: 58%; margin-left: auto; margin-right: 4%; }
2177
+ .android-navigation {
2178
+ position: absolute;
2179
+ z-index: 2;
2180
+ right: 0;
2181
+ bottom: 0;
2182
+ left: 0;
2183
+ height: 2.2%;
2184
+ display: grid;
2185
+ place-items: center;
2186
+ background: transparent;
2187
+ }
2188
+ .gesture-pill { width: 40%; height: 14%; border-radius: 999px; background: ${navigationBarStyle?.foreground || '#7a7b7c'}; }
2189
+ .ios-navigation {
2190
+ position: relative;
2191
+ z-index: 2;
2192
+ flex: none;
2193
+ height: 2.2%;
2194
+ display: grid;
2195
+ place-items: center;
2196
+ background: ${navigationBarStyle?.background || 'transparent'};
2197
+ }
2198
+ .ios-gesture-pill {
2199
+ width: 36%;
2200
+ height: clamp(3px, 0.42vw, 8px);
2201
+ border-radius: 999px;
2202
+ background: ${navigationBarStyle?.foreground || '#050505'};
2203
+ }
2204
+ .ipad-navigation { height: 1.8%; }
2205
+ .ipad-navigation .ios-gesture-pill { width: 24%; }
1366
2206
  .camera-dot {
1367
2207
  position: absolute;
1368
2208
  z-index: 3;
@@ -1395,6 +2235,85 @@ function createDeviceFrameHtml(profile, screenshotBuffer, imageWidth = profile.w
1395
2235
  .device.tablet .screen {
1396
2236
  border-radius: clamp(13px, 2.5vw, 38px);
1397
2237
  }
2238
+ .device.ipad {
2239
+ height: 86%;
2240
+ padding: 4.1%;
2241
+ border: clamp(2px, 0.34vw, 7px) solid #9da3ad;
2242
+ border-radius: clamp(32px, 5.2vw, 84px);
2243
+ background: linear-gradient(145deg, #24272c 0%, #0b0c0e 22%, #050506 70%, #17191c 100%);
2244
+ box-shadow:
2245
+ 0 4vh 9vh rgba(0, 0, 0, 0.62),
2246
+ 0 0 0 clamp(1px, 0.16vw, 3px) rgba(230, 233, 238, 0.42) inset,
2247
+ 0 0 0 clamp(3px, 0.45vw, 9px) rgba(0, 0, 0, 0.82) inset;
2248
+ }
2249
+ .device.ipad .screen {
2250
+ display: flex;
2251
+ flex-direction: column;
2252
+ border: clamp(2px, 0.25vw, 5px) solid #181a1e;
2253
+ border-radius: clamp(20px, 3.4vw, 55px);
2254
+ background: #000;
2255
+ box-shadow: 0 0 0 1px rgba(255,255,255,0.05), 0 0 1.4vh rgba(0,0,0,0.9) inset;
2256
+ }
2257
+ .device.ipad .screen img {
2258
+ flex: 1 1 0;
2259
+ min-height: 0;
2260
+ height: 0;
2261
+ object-fit: fill;
2262
+ }
2263
+ .ipad-statusbar {
2264
+ height: 3.2%;
2265
+ background: ${statusBarStyle?.background || 'transparent'};
2266
+ color: ${statusBarStyle?.foreground || '#050505'};
2267
+ font-weight: 700;
2268
+ }
2269
+ .ipad-statusbar .status-time {
2270
+ margin-left: 2.2%;
2271
+ font-size: clamp(9px, 1.18vw, 22px);
2272
+ }
2273
+ .ipad-status-icons {
2274
+ width: 17%;
2275
+ height: 58%;
2276
+ margin-left: auto;
2277
+ margin-right: 1.8%;
2278
+ }
2279
+ .ipad-camera-array {
2280
+ position: absolute;
2281
+ z-index: 3;
2282
+ top: 1.05%;
2283
+ left: 50%;
2284
+ display: flex;
2285
+ align-items: center;
2286
+ justify-content: center;
2287
+ gap: clamp(4px, 0.62vw, 12px);
2288
+ width: 18%;
2289
+ height: 1.65%;
2290
+ transform: translateX(-50%);
2291
+ }
2292
+ .ipad-sensor {
2293
+ display: block;
2294
+ flex: none;
2295
+ border-radius: 50%;
2296
+ background: #020304;
2297
+ box-shadow: 0 0 0 1px rgba(29,33,39,0.9), 0 0 0.35vh rgba(0,0,0,0.85) inset;
2298
+ }
2299
+ .ipad-sensor-large { width: clamp(5px, 1vw, 17px); aspect-ratio: 1; }
2300
+ .ipad-sensor-small { width: clamp(3px, 0.38vw, 7px); aspect-ratio: 1; }
2301
+ .ipad-sensor-lens {
2302
+ width: clamp(4px, 0.62vw, 11px);
2303
+ aspect-ratio: 1;
2304
+ background: radial-gradient(circle at 38% 35%, #355e8a, #07101d 42%, #010205 75%);
2305
+ box-shadow: 0 0 0 1px #121a26, 0 0 0.45vh rgba(42,110,202,0.45);
2306
+ }
2307
+ .ipad-side-button {
2308
+ position: absolute;
2309
+ top: 7.6%;
2310
+ right: -0.45%;
2311
+ width: 0.45%;
2312
+ height: 7.8%;
2313
+ border-radius: 0 999px 999px 0;
2314
+ background: linear-gradient(90deg, #4c5159, #a9afb7 58%, #555b64);
2315
+ box-shadow: 0 0.3vh 0.7vh rgba(0,0,0,0.5);
2316
+ }
1398
2317
  .device.apple-watch {
1399
2318
  height: 82%;
1400
2319
  padding: 2.2%;
@@ -1526,11 +2445,51 @@ function createDeviceFrameHtml(profile, screenshotBuffer, imageWidth = profile.w
1526
2445
  background: #6fe8ff;
1527
2446
  box-shadow: 0 0 1.8vh #2dd5ff;
1528
2447
  }
2448
+ @media (orientation: portrait) {
2449
+ body.has-copy .promo-copy { top: 4%; right: 7%; left: 7%; align-items: center; text-align: center; }
2450
+ body.has-copy .promo-copy h1 { max-width: 90%; }
2451
+ body.has-copy .promo-copy p { max-width: 86%; }
2452
+ body.has-copy .promo-features { justify-content: center; }
2453
+ body.has-copy .device,
2454
+ body.has-copy .device.android-phone,
2455
+ body.has-copy .device.iphone,
2456
+ body.has-copy .device.tablet,
2457
+ body.has-copy .device.apple-watch {
2458
+ position: absolute;
2459
+ bottom: 3%;
2460
+ left: 50%;
2461
+ width: auto;
2462
+ height: 68%;
2463
+ transform: translateX(-50%);
2464
+ }
2465
+ }
2466
+ @media (orientation: landscape) {
2467
+ body.has-copy .promo-copy { top: 50%; left: 5%; width: 31%; transform: translateY(-50%); }
2468
+ body.has-copy .promo-copy h1 { font-size: clamp(28px, 4.4vw, 84px); }
2469
+ body.has-copy .promo-copy p { font-size: clamp(16px, 1.7vw, 32px); }
2470
+ body.has-copy .device,
2471
+ body.has-copy .device.tv,
2472
+ body.has-copy .device.apple-tv,
2473
+ body.has-copy .device.laptop,
2474
+ body.has-copy .device.xr,
2475
+ body.has-copy .device.apple-vision-pro {
2476
+ position: absolute;
2477
+ top: 50%;
2478
+ right: 3%;
2479
+ left: auto;
2480
+ width: 60%;
2481
+ height: auto;
2482
+ max-height: 84%;
2483
+ transform: translateY(-50%);
2484
+ }
2485
+ }
1529
2486
  </style>
1530
2487
  </head>
1531
- <body>
2488
+ <body class="${phonePlatform ? 'phone-frame' : ''} ${screenshotCopy ? 'has-copy' : ''}">
2489
+ ${createPromotionalDecorations(palette)}
2490
+ ${createScreenshotCopyMarkup(screenshotCopy)}
1532
2491
  <main class="device ${profile.frameType}">
1533
- <div class="screen"><img src="${screenshotDataUrl}" /></div>
2492
+ <div class="screen">${phoneStatusBar}<img src="${screenshotDataUrl}" />${phoneNavigation}</div>
1534
2493
  ${getFrameAdornment(profile.frameType)}
1535
2494
  </main>
1536
2495
  </body>
@@ -1570,6 +2529,88 @@ function readJsonAssetSource(filePath) {
1570
2529
  }
1571
2530
  }
1572
2531
 
2532
+ function getScreenshotSourceDetails(screenInfo) {
2533
+ const pageDirectory = path.join(__dirname, 'src', 'pages');
2534
+ const routesPath = path.join(__dirname, 'src', 'js', 'routes.js');
2535
+ const pageName = String(screenInfo?.page?.dataName || '').trim();
2536
+ const pageId = String(screenInfo?.page?.id || '').trim();
2537
+ const escapePattern = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2538
+ const pageFiles = [];
2539
+
2540
+ if (fs.existsSync(pageDirectory) && (pageName || pageId)) {
2541
+ for (const fileName of fs.readdirSync(pageDirectory).filter((name) => /\.(?:f7|html)$/i.test(name))) {
2542
+ const filePath = path.join(pageDirectory, fileName);
2543
+ const source = fs.readFileSync(filePath, 'utf8');
2544
+ const hasPageName = pageName
2545
+ && new RegExp(`data-name=["']${escapePattern(pageName)}["']`).test(source);
2546
+ const hasPageId = pageId
2547
+ && new RegExp(`id=["']${escapePattern(pageId)}["']`).test(source);
2548
+ if (hasPageName || hasPageId) {
2549
+ pageFiles.push(path.relative(__dirname, filePath).replace(/\\/g, '/'));
2550
+ }
2551
+ }
2552
+ }
2553
+
2554
+ const routeCandidates = [];
2555
+ if (fs.existsSync(routesPath)) {
2556
+ const routesSource = fs.readFileSync(routesPath, 'utf8');
2557
+ const staticImports = new Map();
2558
+ const staticImportPattern = /import\s+([A-Za-z_$][\w$]*)\s+from\s+["']\.\.\/pages\/([^"']+)["']/g;
2559
+ let match;
2560
+ while ((match = staticImportPattern.exec(routesSource))) {
2561
+ staticImports.set(match[1], `src/pages/${match[2]}`);
2562
+ }
2563
+
2564
+ const routeBlockPattern = /\{\s*path:\s*["']([^"']+)["']([\s\S]*?)\n\s*\},/g;
2565
+ while ((match = routeBlockPattern.exec(routesSource))) {
2566
+ const routePath = match[1];
2567
+ const block = match[2];
2568
+ const dynamicPage = block.match(/import\(["']\.\.\/pages\/([^"']+)["']\)/)?.[1];
2569
+ const componentName = block.match(/component:\s*([A-Za-z_$][\w$]*)/)?.[1];
2570
+ const pageFile = dynamicPage
2571
+ ? `src/pages/${dynamicPage}`
2572
+ : staticImports.get(componentName) || '';
2573
+ const normalizedPageFile = pageFile.replace(/\\/g, '/');
2574
+ const routeMatchesPage = normalizedPageFile
2575
+ && pageFiles.some((candidate) => candidate.toLowerCase() === normalizedPageFile.toLowerCase());
2576
+ const framework7Path = screenInfo?.route?.framework7?.path;
2577
+ const framework7Url = screenInfo?.route?.framework7?.url;
2578
+ if (routeMatchesPage || routePath === framework7Path || routePath === framework7Url) {
2579
+ routeCandidates.push({ path: routePath, pageFile: normalizedPageFile });
2580
+ }
2581
+ }
2582
+ }
2583
+
2584
+ return {
2585
+ pageFiles,
2586
+ routesFile: fs.existsSync(routesPath) ? 'src/js/routes.js' : '',
2587
+ routeCandidates,
2588
+ };
2589
+ }
2590
+
2591
+ function saveScreenshotInfo(screenshotFileName, screenInfo) {
2592
+ const screenshotKey = path.parse(screenshotFileName).name;
2593
+ const source = readJsonAssetSource(screenshotInfoPath);
2594
+ const screenshots = source.screenshots && typeof source.screenshots === 'object'
2595
+ ? source.screenshots
2596
+ : {};
2597
+
2598
+ screenshots[screenshotKey] = {
2599
+ schemaVersion: 2,
2600
+ fileName: screenshotFileName,
2601
+ capturedAt: new Date().toISOString(),
2602
+ ...screenInfo,
2603
+ source: getScreenshotSourceDetails(screenInfo),
2604
+ };
2605
+
2606
+ fs.mkdirSync(path.dirname(screenshotInfoPath), { recursive: true });
2607
+ fs.writeFileSync(
2608
+ screenshotInfoPath,
2609
+ `${JSON.stringify({ version: 2, screenshots }, null, 2)}\n`,
2610
+ );
2611
+ console.log(`Saved screenshot information: ${screenshotInfoPath} (${screenshotKey})`);
2612
+ }
2613
+
1573
2614
  function collectProjectUiPhrases() {
1574
2615
  const phrases = [];
1575
2616
  const pageDirectory = path.join(__dirname, 'src', 'pages');
@@ -2171,24 +3212,48 @@ async function captureFramedScreenshot(
2171
3212
  profile,
2172
3213
  screenshotFileName,
2173
3214
  skipExisting = false,
3215
+ outputRootDirectory = inFrameDirectory,
2174
3216
  ) {
2175
3217
  if (profile.screenshotOnly) return false;
2176
3218
 
2177
- const framedProfileDirectory = path.join(inFrameDirectory, profile.outputDirectory);
2178
- const framedScreenshotPath = path.join(framedProfileDirectory, screenshotFileName);
3219
+ const outputExtension = profile.outputExtension || path.extname(screenshotFileName).slice(1) || 'png';
3220
+ const outputFileName = `${path.parse(screenshotFileName).name}.${outputExtension}`;
3221
+ const framedProfileDirectory = path.join(outputRootDirectory, profile.outputDirectory);
3222
+ const framedScreenshotPath = path.join(framedProfileDirectory, outputFileName);
2179
3223
 
2180
3224
  if (skipExisting && fs.existsSync(framedScreenshotPath)) {
2181
3225
  console.log(`Skipped existing framed screenshot: ${framedScreenshotPath}`);
2182
3226
  return false;
2183
3227
  }
2184
3228
 
2185
- const originalImageMetadata = profile.storeRequirements
2186
- ? await sharp(screenshotBuffer).metadata()
2187
- : null;
3229
+ const originalImageMetadata = await sharp(screenshotBuffer).metadata();
2188
3230
  const hasCustomFrameSize = Boolean(profile.framedWidth && profile.framedHeight);
2189
3231
  const frameWidth = profile.framedWidth || profile.width;
2190
3232
  const frameHeight = profile.framedHeight || profile.height;
2191
3233
  const frameScaleFactor = hasCustomFrameSize ? 1 : profile.deviceScaleFactor;
3234
+ const phonePlatform = profile.frameType === 'iphone' ? 'ios' : profile.frameType === 'android-phone' ? 'android' : null;
3235
+ const statusBarPlatform = profile.frameType === 'ipad' ? 'ipad' : phonePlatform;
3236
+ const displayTime = statusBarPlatform ? getFrameDisplayTime(statusBarPlatform) : '';
3237
+ const batteryPercentage = statusBarPlatform === 'android' || statusBarPlatform === 'ipad'
3238
+ ? getFrameBatteryPercentage()
3239
+ : null;
3240
+ const statusBarStyle = statusBarPlatform
3241
+ ? await getScreenshotBarStyle(screenshotBuffer)
3242
+ : null;
3243
+ const navigationBarStyle = statusBarPlatform
3244
+ ? await getScreenshotBarStyle(screenshotBuffer, 'bottom')
3245
+ : null;
3246
+ const screenshotCopy = getScreenshotCopy(screenshotFileName);
3247
+ const promotionalPalette = await getPromotionalPalette(screenshotBuffer, screenshotFileName);
3248
+ const screenshotKey = path.parse(screenshotFileName).name;
3249
+
3250
+ if (!screenshotCopy && !missingScreenshotCopyWarnings.has(screenshotKey)) {
3251
+ missingScreenshotCopyWarnings.add(screenshotKey);
3252
+ console.warn(
3253
+ `No promotional copy configured for screenshot ${screenshotKey}; `
3254
+ + `add it to ${screenshotCopyPath}.`,
3255
+ );
3256
+ }
2192
3257
 
2193
3258
  await client.send('Emulation.setDeviceMetricsOverride', {
2194
3259
  width: frameWidth,
@@ -2207,9 +3272,16 @@ async function captureFramedScreenshot(
2207
3272
  html: profile.name === 'vivo-store' || profile.name === 'oppo-store'
2208
3273
  ? createStoreDeviceFrameHtml(
2209
3274
  profile, screenshotBuffer, originalImageMetadata.width, originalImageMetadata.height,
2210
- frameWidth, frameHeight, originalImageMetadata.format,
3275
+ frameWidth, frameHeight, originalImageMetadata.format, displayTime,
3276
+ batteryPercentage, statusBarStyle, navigationBarStyle,
3277
+ screenshotCopy, promotionalPalette,
2211
3278
  )
2212
- : createDeviceFrameHtml(profile, screenshotBuffer, frameWidth, frameHeight),
3279
+ : createDeviceFrameHtml(
3280
+ profile, screenshotBuffer, originalImageMetadata.width, originalImageMetadata.height,
3281
+ originalImageMetadata.format,
3282
+ displayTime, batteryPercentage, statusBarStyle, navigationBarStyle,
3283
+ screenshotCopy, promotionalPalette,
3284
+ ),
2213
3285
  });
2214
3286
  await client.send('Runtime.evaluate', {
2215
3287
  expression: `new Promise((resolve) => {
@@ -2331,25 +3403,10 @@ async function captureScreenshotSet(
2331
3403
  selectedProfiles,
2332
3404
  fileNameInput,
2333
3405
  ) {
2334
- const storeProfile = selectedProfiles.find((profile) => profile.storeRequirements);
2335
- if (storeProfile) {
2336
- const profileDirectory = path.join(screenshotDirectory, storeProfile.outputDirectory);
2337
- const existingImageCount = fs.existsSync(profileDirectory)
2338
- ? fs.readdirSync(profileDirectory, { withFileTypes: true })
2339
- .filter((entry) => entry.isFile() && /\.(png|jpe?g|webp)$/i.test(entry.name))
2340
- .length
2341
- : 0;
2342
-
2343
- if (existingImageCount >= storeProfile.storeRequirements.maxImages) {
2344
- throw new Error(
2345
- `${storeProfile.label} already contains ${existingImageCount} images; the store allows a maximum of ${storeProfile.storeRequirements.maxImages}.`,
2346
- );
2347
- }
2348
- }
2349
-
2350
3406
  console.log('Capturing the current rendered page and resolved native data...');
2351
3407
  const snapshot = await capturePageSnapshot(deviceClient);
2352
3408
  const screenshotFileName = getScreenshotFileName(fileNameInput, selectedProfiles);
3409
+ saveScreenshotInfo(screenshotFileName, snapshot.screenInfo);
2353
3410
 
2354
3411
  for (const profile of selectedProfiles) {
2355
3412
  await captureProfile(replayClient, snapshot, profile, screenshotFileName);
@@ -2358,11 +3415,23 @@ async function captureScreenshotSet(
2358
3415
  console.log(`Responsive screenshot set completed: ${screenshotFileName}`);
2359
3416
  }
2360
3417
 
2361
- async function frameExistingScreenshots(selectedProfiles) {
3418
+ async function frameExistingScreenshots(
3419
+ selectedProfiles,
3420
+ overwriteExisting = false,
3421
+ screenshotNames = '',
3422
+ outputRootDirectory = inFrameDirectory,
3423
+ ) {
2362
3424
  let replayBrowser;
2363
3425
  let replayClient;
2364
3426
  let framedCount = 0;
2365
3427
  let failedCount = 0;
3428
+ const selectedScreenshotNames = new Set(
3429
+ screenshotNames
3430
+ .split(',')
3431
+ .map((name) => path.parse(name.trim()).name)
3432
+ .filter(Boolean),
3433
+ );
3434
+ const foundScreenshotNames = new Set();
2366
3435
 
2367
3436
  try {
2368
3437
  replayBrowser = await launchReplayBrowser();
@@ -2376,12 +3445,17 @@ async function frameExistingScreenshots(selectedProfiles) {
2376
3445
  }
2377
3446
 
2378
3447
  const screenshotFiles = fs.readdirSync(profileDirectory, { withFileTypes: true })
2379
- .filter((entry) => entry.isFile() && /\.png$/i.test(entry.name))
3448
+ .filter((entry) => entry.isFile() && /\.(?:png|jpe?g|webp)$/i.test(entry.name))
2380
3449
  .map((entry) => entry.name)
3450
+ .filter((fileName) => (
3451
+ !selectedScreenshotNames.size
3452
+ || selectedScreenshotNames.has(path.parse(fileName).name)
3453
+ ))
2381
3454
  .sort((first, second) => first.localeCompare(second, undefined, { numeric: true }));
2382
3455
 
2383
3456
  for (const screenshotFileName of screenshotFiles) {
2384
3457
  const screenshotPath = path.join(profileDirectory, screenshotFileName);
3458
+ foundScreenshotNames.add(path.parse(screenshotFileName).name);
2385
3459
 
2386
3460
  try {
2387
3461
  const wasCreated = await captureFramedScreenshot(
@@ -2389,7 +3463,8 @@ async function frameExistingScreenshots(selectedProfiles) {
2389
3463
  fs.readFileSync(screenshotPath),
2390
3464
  profile,
2391
3465
  screenshotFileName,
2392
- true,
3466
+ !overwriteExisting,
3467
+ outputRootDirectory,
2393
3468
  );
2394
3469
  if (wasCreated) framedCount += 1;
2395
3470
  } catch (error) {
@@ -2410,6 +3485,14 @@ async function frameExistingScreenshots(selectedProfiles) {
2410
3485
 
2411
3486
  console.log(`Existing screenshot framing completed: ${framedCount} created.`);
2412
3487
 
3488
+ const missingScreenshotNames = [...selectedScreenshotNames]
3489
+ .filter((name) => !foundScreenshotNames.has(name));
3490
+ if (missingScreenshotNames.length) {
3491
+ throw new Error(
3492
+ `Requested screenshot(s) were not found for the selected profiles: ${missingScreenshotNames.join(', ')}.`,
3493
+ );
3494
+ }
3495
+
2413
3496
  if (failedCount) {
2414
3497
  throw new Error(`${failedCount} existing screenshot(s) could not be framed.`);
2415
3498
  }
@@ -2598,6 +3681,7 @@ async function getInteractiveOptions(options) {
2598
3681
  || options.dryRun
2599
3682
  || options.amazonAssets
2600
3683
  || options.samsungAssets
3684
+ || options.refreshInfo
2601
3685
  || options.vivoExisting
2602
3686
  || options.oppoExisting
2603
3687
  ) {
@@ -2644,6 +3728,10 @@ async function main() {
2644
3728
 
2645
3729
  options = await getInteractiveOptions(options);
2646
3730
 
3731
+ if (!options.dryRun && !options.refreshInfo) {
3732
+ reconcileScreenshotRecords();
3733
+ }
3734
+
2647
3735
  if (options.amazonAssets) {
2648
3736
  let amazonBrowser;
2649
3737
  let amazonClient;
@@ -2678,6 +3766,10 @@ async function main() {
2678
3766
  return;
2679
3767
  }
2680
3768
 
3769
+ if (options.vivoExisting || options.oppoExisting) {
3770
+ await prepareFrameSelection(options);
3771
+ }
3772
+
2681
3773
  if (options.vivoExisting) {
2682
3774
  await createVivoAssetsFromExistingScreenshots();
2683
3775
  return;
@@ -2688,17 +3780,26 @@ async function main() {
2688
3780
  return;
2689
3781
  }
2690
3782
 
2691
- if (!options.dryRun) {
2692
- await createMissingStoreAssets();
2693
- }
2694
-
2695
3783
  const selectedProfiles = selectProfiles(options.profiles);
2696
3784
 
2697
- if (options.frameExisting) {
2698
- await frameExistingScreenshots(selectedProfiles);
3785
+ if (!options.dryRun && !options.refreshInfo) {
3786
+ await prepareFrameSelection(options);
3787
+ }
3788
+
3789
+ if (options.frameExisting || options.reframeExisting) {
3790
+ await frameExistingScreenshots(
3791
+ selectedProfiles,
3792
+ options.reframeExisting,
3793
+ options.screenshots,
3794
+ options.reframeExisting ? uploadDirectory : inFrameDirectory,
3795
+ );
2699
3796
  return;
2700
3797
  }
2701
3798
 
3799
+ if (!options.dryRun && !options.refreshInfo) {
3800
+ await createMissingStoreAssets();
3801
+ }
3802
+
2702
3803
  const interactiveMode = process.stdin.isTTY && !options.prefix && !options.dryRun;
2703
3804
  const deviceId = getConnectedDevice();
2704
3805
  const appId = getAppId();
@@ -2737,6 +3838,20 @@ async function main() {
2737
3838
  return;
2738
3839
  }
2739
3840
 
3841
+ if (options.refreshInfo) {
3842
+ const screenshotKey = sanitizeName(
3843
+ options.refreshInfo.trim().replace(/\.(png|jpe?g)$/i, ''),
3844
+ '',
3845
+ );
3846
+ if (!screenshotKey) {
3847
+ throw new Error('Provide a screenshot name after --refresh-info, for example: --refresh-info 1');
3848
+ }
3849
+ const snapshot = await capturePageSnapshot(deviceClient);
3850
+ saveScreenshotInfo(`${screenshotKey}.png`, snapshot.screenInfo);
3851
+ console.log(`Screenshot information refreshed without creating images: ${screenshotKey}`);
3852
+ return;
3853
+ }
3854
+
2740
3855
  fs.mkdirSync(screenshotDirectory, { recursive: true });
2741
3856
  replayBrowser = await launchReplayBrowser();
2742
3857
  replayClient = await createReplayPage(replayBrowser);