codeplay-common 4.7.3 → 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,15 +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');
13
- const { frameStyles, createFrameSamples } = require('./scripts/frame-samples.js');
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');
14
32
 
15
33
  const screenshotDirectory = path.join(__dirname, 'Auto-Screenshot', 'Store-image-Screenshot');
16
34
  const inFrameDirectory = path.join(__dirname, 'Auto-Screenshot', 'Store-image-Frame');
35
+ const uploadDirectory = path.join(__dirname, 'Auto-Screenshot', 'Store-upload');
17
36
  const autoScreenshotDirectory = path.join(__dirname, 'Auto-Screenshot');
18
37
  const aiAssetsDirectory = path.join(__dirname, 'AI-Assets');
19
38
  const amazonAssetsDirectory = path.join(aiAssetsDirectory, 'Amazon-Assets');
@@ -22,8 +41,12 @@ const playstoreAssetsDirectory = path.join(aiAssetsDirectory, 'Playstore-Assets'
22
41
  const capacitorConfigPath = path.join(__dirname, 'capacitor.config.json');
23
42
  const amazonIconPath = path.join(__dirname, 'resources', 'icon-only.png');
24
43
  const temporaryDirectory = path.join(__dirname, 'agent-temp');
25
- let selectedFrameStyles = { android: 'cobalt', ios: 'midnight' };
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' };
26
47
  let frameCaptureSequence = 0;
48
+ let screenshotCopyConfig;
49
+ const missingScreenshotCopyWarnings = new Set();
27
50
  const frameTimeBase = Date.now();
28
51
  const amazonAssetDefinitions = [
29
52
  { fileName: 'icon-1280x720.png', type: 'icon', width: 1280, height: 720 },
@@ -150,7 +173,7 @@ const deviceProfiles = [
150
173
  name: 'ipad',
151
174
  label: 'iPad 13-inch App Store viewport',
152
175
  outputDirectory: 'iPad',
153
- frameType: 'tablet',
176
+ frameType: 'ipad',
154
177
  width: 1032,
155
178
  height: 1376,
156
179
  deviceScaleFactor: 2,
@@ -467,6 +490,7 @@ function parseArguments() {
467
490
  dryRun: false,
468
491
  frameExisting: false,
469
492
  reframeExisting: false,
493
+ refreshInfo: '',
470
494
  vivoExisting: false,
471
495
  oppoExisting: false,
472
496
  help: false,
@@ -475,6 +499,7 @@ function parseArguments() {
475
499
  deleteNames: '',
476
500
  prefix: '',
477
501
  profiles: '',
502
+ screenshots: '',
478
503
  androidFrame: '',
479
504
  iosFrame: '',
480
505
  };
@@ -492,6 +517,9 @@ function parseArguments() {
492
517
  options.frameExisting = true;
493
518
  } else if (argument === '--reframe-existing') {
494
519
  options.reframeExisting = true;
520
+ } else if (argument === '--refresh-info') {
521
+ options.refreshInfo = args[index + 1] || '';
522
+ index += 1;
495
523
  } else if (argument === '--vivo-existing') {
496
524
  options.vivoExisting = true;
497
525
  } else if (argument === '--oppo-existing') {
@@ -510,6 +538,9 @@ function parseArguments() {
510
538
  } else if (argument === '--profiles') {
511
539
  options.profiles = args[index + 1] || '';
512
540
  index += 1;
541
+ } else if (argument === '--screenshots') {
542
+ options.screenshots = args[index + 1] || '';
543
+ index += 1;
513
544
  } else if (argument === '--android-frame') {
514
545
  options.androidFrame = args[index + 1] || '';
515
546
  index += 1;
@@ -559,6 +590,8 @@ function printHelp() {
559
590
  console.log(' node take-screen-image.js --prefix home --profiles android-phone,ipad');
560
591
  console.log(' node take-screen-image.js --frame-existing');
561
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');
562
595
  console.log(' node take-screen-image.js --vivo-existing');
563
596
  console.log(' node take-screen-image.js --oppo-existing');
564
597
  console.log(' node take-screen-image.js --amazon-assets');
@@ -568,12 +601,12 @@ function printHelp() {
568
601
  console.log(' node take-screen-image.js --delete 3,4,5');
569
602
  console.log('');
570
603
  console.log('A normal capture automatically creates any missing Amazon, Samsung, and Play Store assets.');
571
- console.log('Frame defaults: Android cobalt (B), iPhone midnight (D).');
604
+ console.log('Frame defaults: Android graphite (A), iPhone midnight (D).');
572
605
  }
573
606
 
574
607
  async function chooseFrameStyles(options) {
575
608
  const choices = { android: options.androidFrame, ios: options.iosFrame };
576
- const defaults = { android: 'cobalt', ios: 'midnight' };
609
+ const defaults = { android: 'graphite', ios: 'midnight' };
577
610
  const aliases = {
578
611
  android: { a: 'graphite', b: 'cobalt', 1: 'graphite', 2: 'cobalt' },
579
612
  ios: { c: 'titanium', d: 'midnight', 1: 'titanium', 2: 'midnight' },
@@ -604,18 +637,40 @@ async function chooseFrameStyles(options) {
604
637
  }
605
638
 
606
639
  async function prepareFrameSelection(options) {
607
- const samplePaths = await createFrameSamples(autoScreenshotDirectory);
608
- for (const samplePath of samplePaths) console.log(`Frame choices: ${samplePath}`);
640
+ if (process.stdin.isTTY) {
641
+ const samplePaths = await createFrameSamples(autoScreenshotDirectory);
642
+ for (const samplePath of samplePaths) console.log(`Frame choices: ${samplePath}`);
643
+ }
609
644
  selectedFrameStyles = await chooseFrameStyles(options);
610
645
  }
611
646
 
612
647
  function getFrameDisplayTime(platform) {
613
648
  const time = new Date(frameTimeBase + frameCaptureSequence * 60000);
614
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
+ }
615
656
  const hour = platform === 'ios' ? (time.getHours() % 12 || 12) : time.getHours();
616
657
  return `${String(hour).padStart(2, '0')}:${String(time.getMinutes()).padStart(2, '0')}`;
617
658
  }
618
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;
672
+ }
673
+
619
674
  function deleteMatchingImages(input) {
620
675
  const names = [...new Set(input.split(',').map((name) => name.trim()).filter(Boolean))];
621
676
 
@@ -648,10 +703,92 @@ function deleteMatchingImages(input) {
648
703
  }
649
704
 
650
705
  scanDirectory(autoScreenshotDirectory);
706
+ removeScreenshotRecords(nameSet);
651
707
  console.log(`Deleted ${deletedFiles.length} matching image(s) from ${autoScreenshotDirectory}.`);
652
708
  for (const filePath of deletedFiles) console.log(`Deleted: ${filePath}`);
653
709
  }
654
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
+
655
792
  function selectProfiles(input) {
656
793
  const orientation = getConfiguredOrientation();
657
794
  const names = input
@@ -813,6 +950,266 @@ async function capturePageSnapshot(client) {
813
950
  return canvas.toDataURL('image/png');
814
951
  };
815
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
+
816
1213
  const serializeDocument = async (sourceDocument) => {
817
1214
  const clonedRoot = sourceDocument.documentElement.cloneNode(true);
818
1215
  const originalElements = [
@@ -955,6 +1352,7 @@ async function capturePageSnapshot(client) {
955
1352
  return {
956
1353
  html: '<!doctype html>' + html,
957
1354
  scrollY,
1355
+ screenInfo,
958
1356
  };
959
1357
  })()`,
960
1358
  awaitPromise: true,
@@ -1264,6 +1662,16 @@ function getFrameAdornment(frameType) {
1264
1662
  return '<div class="tablet-camera"></div>';
1265
1663
  }
1266
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
+
1267
1675
  if (frameType === 'tv' || frameType === 'apple-tv') {
1268
1676
  return '<div class="tv-stand"></div>';
1269
1677
  }
@@ -1283,10 +1691,166 @@ function getFrameAdornment(frameType) {
1283
1691
  return '<div class="xr-status"></div>';
1284
1692
  }
1285
1693
 
1286
- function createStoreDeviceFrameHtml(profile, screenshotBuffer, imageWidth, imageHeight, canvasWidth, canvasHeight, imageFormat, displayTime) {
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
+ ) {
1287
1851
  const screenshotDataUrl = `data:image/${imageFormat === 'jpeg' ? 'jpeg' : 'png'};base64,${screenshotBuffer.toString('base64')}`;
1288
- const screenWidth = Math.floor(Math.min(canvasWidth - 160, (canvasHeight - 180) * imageWidth / imageHeight));
1289
- 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));
1290
1854
  const frameStyle = frameStyles.android[selectedFrameStyles.android];
1291
1855
 
1292
1856
  return `<!doctype html>
@@ -1299,18 +1863,40 @@ function createStoreDeviceFrameHtml(profile, screenshotBuffer, imageWidth, image
1299
1863
  body {
1300
1864
  display: grid;
1301
1865
  place-items: center;
1302
- 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});
1303
1871
  }
1304
1872
  body::before {
1305
1873
  content: '';
1306
1874
  position: absolute;
1307
- width: 82%;
1308
- height: 62%;
1309
- border-radius: 50%;
1310
- background: ${accent};
1311
- opacity: 0.11;
1312
- 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);
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;
1313
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%); }
1314
1900
  .device {
1315
1901
  position: relative;
1316
1902
  z-index: 1;
@@ -1331,12 +1917,13 @@ function createStoreDeviceFrameHtml(profile, screenshotBuffer, imageWidth, image
1331
1917
  box-shadow: 0 0 0 2px #03060c;
1332
1918
  }
1333
1919
  .screen img { display: block; width: 100%; height: 0; min-height: 0; flex: 1 1 0; object-fit: fill; }
1334
- .phone-statusbar { position: relative; flex: none; height: 5%; display: flex; align-items: center; background: #9075ed; color: #fff; font-family: Arial, sans-serif; }
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; }
1335
1921
  .status-time { margin-left: 5%; font-size: ${Math.max(11, Math.round(screenWidth * 0.03))}px; white-space: nowrap; }
1336
- .android-notification { width: 5%; height: 45%; margin-left: 2%; }
1337
- .android-status-icons { width: 42%; height: 72%; margin-left: auto; margin-right: 2%; }
1338
- .android-navigation { flex: none; height: 2.2%; display: grid; place-items: center; background: #f4f7f8; }
1339
- .gesture-pill { width: 40%; height: 14%; border-radius: 999px; background: #7a7b7c; }
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}; }
1340
1927
  .camera {
1341
1928
  position: absolute;
1342
1929
  top: 11px;
@@ -1359,61 +1946,112 @@ function createStoreDeviceFrameHtml(profile, screenshotBuffer, imageWidth, image
1359
1946
  }
1360
1947
  </style>
1361
1948
  </head>
1362
- <body>
1949
+ <body class="${screenshotCopy ? 'has-copy' : ''}">
1950
+ ${createPromotionalDecorations(palette)}
1951
+ ${createScreenshotCopyMarkup(screenshotCopy)}
1363
1952
  <main class="device">
1364
1953
  <div class="camera"></div>
1365
- <div class="screen">${getPhoneStatusBar('android', displayTime)}<img src="${screenshotDataUrl}" /><div class="android-navigation"><div class="gesture-pill"></div></div></div>
1954
+ <div class="screen">${getPhoneStatusBar('android', displayTime, batteryPercentage)}<img src="${screenshotDataUrl}" /><div class="android-navigation"><div class="gesture-pill"></div></div></div>
1366
1955
  <div class="side-button"></div>
1367
1956
  </main>
1368
1957
  </body>
1369
1958
  </html>`;
1370
1959
  }
1371
1960
 
1372
- function getPhoneStatusBar(platform, time) {
1961
+ function getPhoneStatusBar(platform, time, batteryPercentage = null) {
1962
+ const batteryLevel = Number.isInteger(batteryPercentage) ? batteryPercentage : 75;
1963
+
1373
1964
  if (platform === 'ios') {
1374
1965
  return `<div class="phone-statusbar ios-statusbar">
1375
1966
  <span class="status-time">${time}</span>
1376
1967
  <div class="dynamic-island"></div>
1377
1968
  <svg viewBox="0 0 90 28" aria-hidden="true">
1378
- <circle cx="5" cy="14" r="2" fill="#c5c7cb"></circle><circle cx="13" cy="14" r="2" fill="#c5c7cb"></circle>
1379
- <circle cx="21" cy="14" r="2" fill="#c5c7cb"></circle><circle cx="29" cy="14" r="2" fill="#c5c7cb"></circle>
1380
- <path d="M36 10 Q46 2 56 10 M39 14 Q46 8 53 14 M44 18 Q46 16 48 18" fill="none" stroke="#050505" stroke-width="2.5" stroke-linecap="round"></path>
1381
- <rect x="61" y="7" width="22" height="15" rx="4" fill="none" stroke="#a7aaae" stroke-width="1.5"></rect>
1382
- <rect x="64" y="10" width="16" height="9" rx="2" fill="#050505"></rect>
1383
- <rect x="85" y="12" width="2" height="5" rx="1" fill="#a7aaae"></rect>
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>
1384
1975
  </svg>
1385
1976
  </div>`;
1386
1977
  }
1387
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
+
1388
2002
  return `<div class="phone-statusbar android-statusbar">
1389
2003
  <span class="status-time">${time}</span>
1390
- <svg class="android-notification" viewBox="0 0 18 18" aria-hidden="true">
1391
- <circle cx="9" cy="9" r="6" fill="none" stroke="#ffffff" stroke-width="1.5"></circle>
1392
- <circle cx="7" cy="8" r="1" fill="#ffffff"></circle><circle cx="11" cy="8" r="1" fill="#ffffff"></circle>
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>
1393
2010
  </svg>
1394
- <svg class="android-status-icons" viewBox="0 0 132 28" aria-hidden="true">
1395
- <text x="1" y="11" font-size="8" font-weight="700" fill="#ffffff">0.26</text>
1396
- <text x="1" y="19" font-size="7" font-weight="700" fill="#ffffff">KB/s</text>
1397
- <path d="M24 20 L37 9 L37 20 Z M42 20 L54 9 L54 20 Z" fill="#ffffff"></path>
1398
- <text x="57" y="12" font-size="8" font-weight="700" fill="#ffffff">Vo</text>
1399
- <text x="57" y="20" font-size="7" font-weight="700" fill="#ffffff">WiFi</text>
1400
- <path d="M76 11 Q85 3 94 11 M79 15 Q85 9 91 15 M83 19 Q85 17 87 19" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round"></path>
1401
- <rect x="97" y="8" width="18" height="13" rx="2" fill="none" stroke="#ffffff" stroke-width="1.4"></rect>
1402
- <path d="M106 7 L102 14 L107 14 L105 22 L113 13 L108 13 L111 7 Z" fill="#ffffff"></path>
1403
- <text x="117" y="18" font-size="8" fill="#ffffff">87%</text>
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>
1404
2025
  </svg>
1405
2026
  </div>`;
1406
2027
  }
1407
2028
 
1408
- function createDeviceFrameHtml(profile, screenshotBuffer, imageWidth = profile.width, imageHeight = profile.height, imageFormat = 'png', displayTime = '') {
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
+ ) {
1409
2042
  const screenshotDataUrl = `data:image/${imageFormat === 'jpeg' ? 'jpeg' : 'png'};base64,${screenshotBuffer.toString('base64')}`;
1410
2043
  const aspectRatio = `${imageWidth} / ${imageHeight}`;
1411
2044
  const phonePlatform = profile.frameType === 'iphone' ? 'ios' : profile.frameType === 'android-phone' ? 'android' : null;
2045
+ const statusBarPlatform = profile.frameType === 'ipad' ? 'ipad' : phonePlatform;
1412
2046
  const phoneStyle = phonePlatform && frameStyles[phonePlatform][selectedFrameStyles[phonePlatform]];
1413
- const phoneStatusBar = phonePlatform ? getPhoneStatusBar(phonePlatform, displayTime) : '';
2047
+ const phoneStatusBar = statusBarPlatform
2048
+ ? getPhoneStatusBar(statusBarPlatform, displayTime, batteryPercentage)
2049
+ : '';
1414
2050
  const phoneNavigation = phonePlatform === 'android'
1415
2051
  ? '<div class="android-navigation"><div class="gesture-pill"></div></div>'
1416
- : '';
2052
+ : statusBarPlatform === 'ios' || statusBarPlatform === 'ipad'
2053
+ ? `<div class="ios-navigation${statusBarPlatform === 'ipad' ? ' ipad-navigation' : ''}"><div class="ios-gesture-pill"></div></div>`
2054
+ : '';
1417
2055
 
1418
2056
  return `<!doctype html>
1419
2057
  <html>
@@ -1427,24 +2065,33 @@ function createDeviceFrameHtml(profile, screenshotBuffer, imageWidth = profile.w
1427
2065
  place-items: center;
1428
2066
  position: relative;
1429
2067
  background:
1430
- radial-gradient(circle at 14% 18%, rgba(45, 213, 255, 0.24), transparent 30%),
1431
- radial-gradient(circle at 86% 82%, rgba(196, 255, 61, 0.16), transparent 28%),
1432
- 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});
1433
2071
  font-family: Arial, sans-serif;
1434
2072
  }
1435
- body.phone-frame { background: #eef2f9; }
1436
- body.phone-frame::before { display: none; }
1437
2073
  body::before {
1438
2074
  content: '';
1439
2075
  position: absolute;
1440
2076
  inset: 0;
1441
- opacity: 0.18;
1442
- background-image:
1443
- linear-gradient(rgba(255,255,255,0.045) 1px, transparent 1px),
1444
- linear-gradient(90deg, rgba(255,255,255,0.045) 1px, transparent 1px);
1445
- background-size: 7vw 7vw;
1446
- 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);
1447
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;
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; }
1448
2095
  .device {
1449
2096
  position: relative;
1450
2097
  z-index: 1;
@@ -1456,6 +2103,9 @@ function createDeviceFrameHtml(profile, screenshotBuffer, imageWidth = profile.w
1456
2103
  0 0 0 1px rgba(255, 255, 255, 0.18) inset,
1457
2104
  0 0 5vh rgba(45, 213, 255, 0.12);
1458
2105
  }
2106
+ .device:not(.android-phone):not(.iphone) {
2107
+ box-sizing: content-box;
2108
+ }
1459
2109
  .screen {
1460
2110
  position: relative;
1461
2111
  width: 100%;
@@ -1509,7 +2159,7 @@ function createDeviceFrameHtml(profile, screenshotBuffer, imageWidth = profile.w
1509
2159
  font-size: clamp(11px, 3vw, 40px);
1510
2160
  white-space: nowrap;
1511
2161
  }
1512
- .ios-statusbar { background: #f5f7fc; color: #050505; font-weight: 700; }
2162
+ .ios-statusbar { background: ${statusBarStyle?.background || 'transparent'}; color: ${statusBarStyle?.foreground || '#050505'}; font-weight: 700; }
1513
2163
  .ios-statusbar svg { width: 27%; height: 62%; margin-left: auto; margin-right: 3%; }
1514
2164
  .ios-statusbar .dynamic-island {
1515
2165
  position: absolute;
@@ -1521,17 +2171,38 @@ function createDeviceFrameHtml(profile, screenshotBuffer, imageWidth = profile.w
1521
2171
  border-radius: 999px;
1522
2172
  background: #000;
1523
2173
  }
1524
- .android-statusbar { background: #9075ed; color: #fff; }
1525
- .android-notification { width: 5%; height: 45%; margin-left: 2%; }
1526
- .android-status-icons { width: 42%; height: 72%; margin-left: auto; margin-right: 2%; }
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%; }
1527
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;
1528
2192
  flex: none;
1529
2193
  height: 2.2%;
1530
2194
  display: grid;
1531
2195
  place-items: center;
1532
- background: #f4f7f8;
2196
+ background: ${navigationBarStyle?.background || 'transparent'};
1533
2197
  }
1534
- .gesture-pill { width: 40%; height: 14%; border-radius: 999px; background: #7a7b7c; }
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%; }
1535
2206
  .camera-dot {
1536
2207
  position: absolute;
1537
2208
  z-index: 3;
@@ -1564,6 +2235,85 @@ function createDeviceFrameHtml(profile, screenshotBuffer, imageWidth = profile.w
1564
2235
  .device.tablet .screen {
1565
2236
  border-radius: clamp(13px, 2.5vw, 38px);
1566
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
+ }
1567
2317
  .device.apple-watch {
1568
2318
  height: 82%;
1569
2319
  padding: 2.2%;
@@ -1695,9 +2445,49 @@ function createDeviceFrameHtml(profile, screenshotBuffer, imageWidth = profile.w
1695
2445
  background: #6fe8ff;
1696
2446
  box-shadow: 0 0 1.8vh #2dd5ff;
1697
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
+ }
1698
2486
  </style>
1699
2487
  </head>
1700
- <body class="${phonePlatform ? 'phone-frame' : ''}">
2488
+ <body class="${phonePlatform ? 'phone-frame' : ''} ${screenshotCopy ? 'has-copy' : ''}">
2489
+ ${createPromotionalDecorations(palette)}
2490
+ ${createScreenshotCopyMarkup(screenshotCopy)}
1701
2491
  <main class="device ${profile.frameType}">
1702
2492
  <div class="screen">${phoneStatusBar}<img src="${screenshotDataUrl}" />${phoneNavigation}</div>
1703
2493
  ${getFrameAdornment(profile.frameType)}
@@ -1739,6 +2529,88 @@ function readJsonAssetSource(filePath) {
1739
2529
  }
1740
2530
  }
1741
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
+
1742
2614
  function collectProjectUiPhrases() {
1743
2615
  const phrases = [];
1744
2616
  const pageDirectory = path.join(__dirname, 'src', 'pages');
@@ -2340,26 +3212,48 @@ async function captureFramedScreenshot(
2340
3212
  profile,
2341
3213
  screenshotFileName,
2342
3214
  skipExisting = false,
3215
+ outputRootDirectory = inFrameDirectory,
2343
3216
  ) {
2344
3217
  if (profile.screenshotOnly) return false;
2345
3218
 
2346
- const framedProfileDirectory = path.join(inFrameDirectory, profile.outputDirectory);
2347
- 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);
2348
3223
 
2349
3224
  if (skipExisting && fs.existsSync(framedScreenshotPath)) {
2350
3225
  console.log(`Skipped existing framed screenshot: ${framedScreenshotPath}`);
2351
3226
  return false;
2352
3227
  }
2353
3228
 
2354
- const originalImageMetadata = profile.storeRequirements
2355
- ? await sharp(screenshotBuffer).metadata()
2356
- : null;
3229
+ const originalImageMetadata = await sharp(screenshotBuffer).metadata();
2357
3230
  const hasCustomFrameSize = Boolean(profile.framedWidth && profile.framedHeight);
2358
3231
  const frameWidth = profile.framedWidth || profile.width;
2359
3232
  const frameHeight = profile.framedHeight || profile.height;
2360
3233
  const frameScaleFactor = hasCustomFrameSize ? 1 : profile.deviceScaleFactor;
2361
3234
  const phonePlatform = profile.frameType === 'iphone' ? 'ios' : profile.frameType === 'android-phone' ? 'android' : null;
2362
- const displayTime = phonePlatform ? getFrameDisplayTime(phonePlatform) : '';
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
+ }
2363
3257
 
2364
3258
  await client.send('Emulation.setDeviceMetricsOverride', {
2365
3259
  width: frameWidth,
@@ -2379,8 +3273,15 @@ async function captureFramedScreenshot(
2379
3273
  ? createStoreDeviceFrameHtml(
2380
3274
  profile, screenshotBuffer, originalImageMetadata.width, originalImageMetadata.height,
2381
3275
  frameWidth, frameHeight, originalImageMetadata.format, displayTime,
3276
+ batteryPercentage, statusBarStyle, navigationBarStyle,
3277
+ screenshotCopy, promotionalPalette,
2382
3278
  )
2383
- : createDeviceFrameHtml(profile, screenshotBuffer, frameWidth, frameHeight, 'png', displayTime),
3279
+ : createDeviceFrameHtml(
3280
+ profile, screenshotBuffer, originalImageMetadata.width, originalImageMetadata.height,
3281
+ originalImageMetadata.format,
3282
+ displayTime, batteryPercentage, statusBarStyle, navigationBarStyle,
3283
+ screenshotCopy, promotionalPalette,
3284
+ ),
2384
3285
  });
2385
3286
  await client.send('Runtime.evaluate', {
2386
3287
  expression: `new Promise((resolve) => {
@@ -2502,25 +3403,10 @@ async function captureScreenshotSet(
2502
3403
  selectedProfiles,
2503
3404
  fileNameInput,
2504
3405
  ) {
2505
- const storeProfile = selectedProfiles.find((profile) => profile.storeRequirements);
2506
- if (storeProfile) {
2507
- const profileDirectory = path.join(screenshotDirectory, storeProfile.outputDirectory);
2508
- const existingImageCount = fs.existsSync(profileDirectory)
2509
- ? fs.readdirSync(profileDirectory, { withFileTypes: true })
2510
- .filter((entry) => entry.isFile() && /\.(png|jpe?g|webp)$/i.test(entry.name))
2511
- .length
2512
- : 0;
2513
-
2514
- if (existingImageCount >= storeProfile.storeRequirements.maxImages) {
2515
- throw new Error(
2516
- `${storeProfile.label} already contains ${existingImageCount} images; the store allows a maximum of ${storeProfile.storeRequirements.maxImages}.`,
2517
- );
2518
- }
2519
- }
2520
-
2521
3406
  console.log('Capturing the current rendered page and resolved native data...');
2522
3407
  const snapshot = await capturePageSnapshot(deviceClient);
2523
3408
  const screenshotFileName = getScreenshotFileName(fileNameInput, selectedProfiles);
3409
+ saveScreenshotInfo(screenshotFileName, snapshot.screenInfo);
2524
3410
 
2525
3411
  for (const profile of selectedProfiles) {
2526
3412
  await captureProfile(replayClient, snapshot, profile, screenshotFileName);
@@ -2529,11 +3415,23 @@ async function captureScreenshotSet(
2529
3415
  console.log(`Responsive screenshot set completed: ${screenshotFileName}`);
2530
3416
  }
2531
3417
 
2532
- async function frameExistingScreenshots(selectedProfiles, overwriteExisting = false) {
3418
+ async function frameExistingScreenshots(
3419
+ selectedProfiles,
3420
+ overwriteExisting = false,
3421
+ screenshotNames = '',
3422
+ outputRootDirectory = inFrameDirectory,
3423
+ ) {
2533
3424
  let replayBrowser;
2534
3425
  let replayClient;
2535
3426
  let framedCount = 0;
2536
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();
2537
3435
 
2538
3436
  try {
2539
3437
  replayBrowser = await launchReplayBrowser();
@@ -2547,12 +3445,17 @@ async function frameExistingScreenshots(selectedProfiles, overwriteExisting = fa
2547
3445
  }
2548
3446
 
2549
3447
  const screenshotFiles = fs.readdirSync(profileDirectory, { withFileTypes: true })
2550
- .filter((entry) => entry.isFile() && /\.png$/i.test(entry.name))
3448
+ .filter((entry) => entry.isFile() && /\.(?:png|jpe?g|webp)$/i.test(entry.name))
2551
3449
  .map((entry) => entry.name)
3450
+ .filter((fileName) => (
3451
+ !selectedScreenshotNames.size
3452
+ || selectedScreenshotNames.has(path.parse(fileName).name)
3453
+ ))
2552
3454
  .sort((first, second) => first.localeCompare(second, undefined, { numeric: true }));
2553
3455
 
2554
3456
  for (const screenshotFileName of screenshotFiles) {
2555
3457
  const screenshotPath = path.join(profileDirectory, screenshotFileName);
3458
+ foundScreenshotNames.add(path.parse(screenshotFileName).name);
2556
3459
 
2557
3460
  try {
2558
3461
  const wasCreated = await captureFramedScreenshot(
@@ -2561,6 +3464,7 @@ async function frameExistingScreenshots(selectedProfiles, overwriteExisting = fa
2561
3464
  profile,
2562
3465
  screenshotFileName,
2563
3466
  !overwriteExisting,
3467
+ outputRootDirectory,
2564
3468
  );
2565
3469
  if (wasCreated) framedCount += 1;
2566
3470
  } catch (error) {
@@ -2581,6 +3485,14 @@ async function frameExistingScreenshots(selectedProfiles, overwriteExisting = fa
2581
3485
 
2582
3486
  console.log(`Existing screenshot framing completed: ${framedCount} created.`);
2583
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
+
2584
3496
  if (failedCount) {
2585
3497
  throw new Error(`${failedCount} existing screenshot(s) could not be framed.`);
2586
3498
  }
@@ -2769,6 +3681,7 @@ async function getInteractiveOptions(options) {
2769
3681
  || options.dryRun
2770
3682
  || options.amazonAssets
2771
3683
  || options.samsungAssets
3684
+ || options.refreshInfo
2772
3685
  || options.vivoExisting
2773
3686
  || options.oppoExisting
2774
3687
  ) {
@@ -2815,6 +3728,10 @@ async function main() {
2815
3728
 
2816
3729
  options = await getInteractiveOptions(options);
2817
3730
 
3731
+ if (!options.dryRun && !options.refreshInfo) {
3732
+ reconcileScreenshotRecords();
3733
+ }
3734
+
2818
3735
  if (options.amazonAssets) {
2819
3736
  let amazonBrowser;
2820
3737
  let amazonClient;
@@ -2865,16 +3782,21 @@ async function main() {
2865
3782
 
2866
3783
  const selectedProfiles = selectProfiles(options.profiles);
2867
3784
 
2868
- if (!options.dryRun) {
3785
+ if (!options.dryRun && !options.refreshInfo) {
2869
3786
  await prepareFrameSelection(options);
2870
3787
  }
2871
3788
 
2872
3789
  if (options.frameExisting || options.reframeExisting) {
2873
- await frameExistingScreenshots(selectedProfiles, options.reframeExisting);
3790
+ await frameExistingScreenshots(
3791
+ selectedProfiles,
3792
+ options.reframeExisting,
3793
+ options.screenshots,
3794
+ options.reframeExisting ? uploadDirectory : inFrameDirectory,
3795
+ );
2874
3796
  return;
2875
3797
  }
2876
3798
 
2877
- if (!options.dryRun) {
3799
+ if (!options.dryRun && !options.refreshInfo) {
2878
3800
  await createMissingStoreAssets();
2879
3801
  }
2880
3802
 
@@ -2916,6 +3838,20 @@ async function main() {
2916
3838
  return;
2917
3839
  }
2918
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
+
2919
3855
  fs.mkdirSync(screenshotDirectory, { recursive: true });
2920
3856
  replayBrowser = await launchReplayBrowser();
2921
3857
  replayClient = await createReplayPage(replayBrowser);