pabal-resource-mcp 1.5.3 → 1.5.5

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.
@@ -3432,6 +3432,87 @@ import { GoogleGenAI } from "@google/genai";
3432
3432
  import fs10 from "fs";
3433
3433
  import path10 from "path";
3434
3434
  import sharp from "sharp";
3435
+ var GEMINI_ASPECT_RATIOS = {
3436
+ "1:1": { ratio: 1 / 1, width: 2048, height: 2048 },
3437
+ "2:3": { ratio: 2 / 3, width: 1696, height: 2528 },
3438
+ "3:2": { ratio: 3 / 2, width: 2528, height: 1696 },
3439
+ "3:4": { ratio: 3 / 4, width: 1792, height: 2400 },
3440
+ "4:3": { ratio: 4 / 3, width: 2400, height: 1792 },
3441
+ "4:5": { ratio: 4 / 5, width: 1856, height: 2304 },
3442
+ "5:4": { ratio: 5 / 4, width: 2304, height: 1856 },
3443
+ "9:16": { ratio: 9 / 16, width: 1536, height: 2752 },
3444
+ "16:9": { ratio: 16 / 9, width: 2752, height: 1536 },
3445
+ "21:9": { ratio: 21 / 9, width: 1584, height: 672 }
3446
+ };
3447
+ var DEVICE_ASPECT_RATIOS = {
3448
+ phone: "9:16",
3449
+ tablet: "3:4"
3450
+ };
3451
+ var GEMINI_SUPPORTED_LOCALES = {
3452
+ // English variants
3453
+ "en": "EN",
3454
+ "en-US": "EN",
3455
+ "en-GB": "EN",
3456
+ "en-AU": "EN",
3457
+ "en-CA": "EN",
3458
+ // Arabic
3459
+ "ar": "ar-EG",
3460
+ "ar-EG": "ar-EG",
3461
+ "ar-SA": "ar-EG",
3462
+ // German
3463
+ "de": "de-DE",
3464
+ "de-DE": "de-DE",
3465
+ // Spanish
3466
+ "es": "es-MX",
3467
+ "es-MX": "es-MX",
3468
+ "es-ES": "es-MX",
3469
+ "es-419": "es-MX",
3470
+ // French
3471
+ "fr": "fr-FR",
3472
+ "fr-FR": "fr-FR",
3473
+ "fr-CA": "fr-FR",
3474
+ // Hindi
3475
+ "hi": "hi-IN",
3476
+ "hi-IN": "hi-IN",
3477
+ // Indonesian
3478
+ "id": "id-ID",
3479
+ "id-ID": "id-ID",
3480
+ // Italian
3481
+ "it": "it-IT",
3482
+ "it-IT": "it-IT",
3483
+ // Japanese
3484
+ "ja": "ja-JP",
3485
+ "ja-JP": "ja-JP",
3486
+ // Korean
3487
+ "ko": "ko-KR",
3488
+ "ko-KR": "ko-KR",
3489
+ // Portuguese
3490
+ "pt": "pt-BR",
3491
+ "pt-BR": "pt-BR",
3492
+ "pt-PT": "pt-BR",
3493
+ // Russian
3494
+ "ru": "ru-RU",
3495
+ "ru-RU": "ru-RU",
3496
+ // Ukrainian
3497
+ "uk": "ua-UA",
3498
+ "uk-UA": "ua-UA",
3499
+ "ua-UA": "ua-UA",
3500
+ // Vietnamese
3501
+ "vi": "vi-VN",
3502
+ "vi-VN": "vi-VN",
3503
+ // Chinese
3504
+ "zh": "zh-CN",
3505
+ "zh-CN": "zh-CN",
3506
+ "zh-Hans": "zh-CN",
3507
+ "zh-TW": "zh-CN",
3508
+ "zh-Hant": "zh-CN"
3509
+ };
3510
+ function isGeminiSupportedLocale(locale) {
3511
+ return locale in GEMINI_SUPPORTED_LOCALES;
3512
+ }
3513
+ function getUnsupportedLocales(locales) {
3514
+ return locales.filter((locale) => !isGeminiSupportedLocale(locale));
3515
+ }
3435
3516
  var LANGUAGE_NAMES = {
3436
3517
  "en-US": "English (US)",
3437
3518
  "en-GB": "English (UK)",
@@ -3507,30 +3588,18 @@ function readImageAsBase64(imagePath) {
3507
3588
  }
3508
3589
  return { data: base64, mimeType };
3509
3590
  }
3510
- async function getImageDimensions(imagePath) {
3511
- const metadata = await sharp(imagePath).metadata();
3512
- return {
3513
- width: metadata.width || 1080,
3514
- height: metadata.height || 1920
3515
- };
3516
- }
3517
- function calculateAspectRatio(width, height) {
3518
- const ratio = width / height;
3519
- if (Math.abs(ratio - 1) < 0.1) return "1:1";
3520
- if (Math.abs(ratio - 9 / 16) < 0.1) return "9:16";
3521
- if (Math.abs(ratio - 16 / 9) < 0.1) return "16:9";
3522
- if (Math.abs(ratio - 3 / 4) < 0.1) return "3:4";
3523
- if (Math.abs(ratio - 4 / 3) < 0.1) return "4:3";
3524
- return ratio < 1 ? "9:16" : "16:9";
3591
+ function getAspectRatioForDevice(deviceType) {
3592
+ return DEVICE_ASPECT_RATIOS[deviceType];
3525
3593
  }
3526
- async function translateImage(sourcePath, sourceLocale, targetLocale, outputPath) {
3594
+ async function translateImage(sourcePath, sourceLocale, targetLocale, outputPath, deviceType, preserveWords) {
3527
3595
  try {
3528
3596
  const client = getGeminiClient();
3529
3597
  const sourceLanguage = getLanguageName(sourceLocale);
3530
3598
  const targetLanguage = getLanguageName(targetLocale);
3531
- const { width, height } = await getImageDimensions(sourcePath);
3532
- const aspectRatio = calculateAspectRatio(width, height);
3599
+ const aspectRatio = getAspectRatioForDevice(deviceType);
3533
3600
  const { data: imageData, mimeType } = readImageAsBase64(sourcePath);
3601
+ const preserveInstruction = preserveWords && preserveWords.length > 0 ? `
3602
+ - Do NOT translate these words, keep them exactly as-is: ${preserveWords.join(", ")}` : "";
3534
3603
  const prompt = `This is an app screenshot with text in ${sourceLanguage}.
3535
3604
  Please translate ONLY the text/words in this image to ${targetLanguage}.
3536
3605
 
@@ -3540,7 +3609,7 @@ IMPORTANT INSTRUCTIONS:
3540
3609
  - Maintain the same font style and text positioning as much as possible
3541
3610
  - Do NOT add any new elements or remove existing design elements
3542
3611
  - The output should look identical except the text language is ${targetLanguage}
3543
- - Preserve all icons, images, and graphical elements exactly as they are`;
3612
+ - Preserve all icons, images, and graphical elements exactly as they are${preserveInstruction}`;
3544
3613
  const chat = client.chats.create({
3545
3614
  model: "gemini-3-pro-image-preview",
3546
3615
  config: {
@@ -3604,7 +3673,7 @@ IMPORTANT INSTRUCTIONS:
3604
3673
  };
3605
3674
  }
3606
3675
  }
3607
- async function translateImagesWithProgress(translations, onProgress) {
3676
+ async function translateImagesWithProgress(translations, onProgress, preserveWords) {
3608
3677
  let successful = 0;
3609
3678
  let failed = 0;
3610
3679
  const errors = [];
@@ -3626,7 +3695,9 @@ async function translateImagesWithProgress(translations, onProgress) {
3626
3695
  translation.sourcePath,
3627
3696
  translation.sourceLocale,
3628
3697
  translation.targetLocale,
3629
- translation.outputPath
3698
+ translation.outputPath,
3699
+ translation.deviceType,
3700
+ preserveWords
3630
3701
  );
3631
3702
  if (result.success) {
3632
3703
  successful++;
@@ -3649,7 +3720,7 @@ async function translateImagesWithProgress(translations, onProgress) {
3649
3720
  // src/tools/aso/utils/localize-screenshots/image-resizer.util.ts
3650
3721
  import sharp2 from "sharp";
3651
3722
  import fs11 from "fs";
3652
- async function getImageDimensions2(imagePath) {
3723
+ async function getImageDimensions(imagePath) {
3653
3724
  const metadata = await sharp2(imagePath).metadata();
3654
3725
  if (!metadata.width || !metadata.height) {
3655
3726
  throw new Error(`Unable to read dimensions from ${imagePath}`);
@@ -3659,7 +3730,7 @@ async function getImageDimensions2(imagePath) {
3659
3730
  height: metadata.height
3660
3731
  };
3661
3732
  }
3662
- async function detectEdgeColor(imagePath) {
3733
+ async function detectCornerColor(imagePath) {
3663
3734
  const image = sharp2(imagePath);
3664
3735
  const metadata = await image.metadata();
3665
3736
  const width = metadata.width || 100;
@@ -3667,14 +3738,17 @@ async function detectEdgeColor(imagePath) {
3667
3738
  const { data, info } = await image.raw().toBuffer({ resolveWithObject: true });
3668
3739
  const channels = info.channels;
3669
3740
  const colorCounts = /* @__PURE__ */ new Map();
3670
- const sampleEdgePixel = (x, y) => {
3741
+ const cornerWidth = Math.min(100, Math.max(10, Math.floor(width * 0.05)));
3742
+ const cornerHeight = Math.min(100, Math.max(10, Math.floor(height * 0.05)));
3743
+ const samplePixel = (x, y) => {
3744
+ if (x < 0 || x >= width || y < 0 || y >= height) return;
3671
3745
  const idx = (y * width + x) * channels;
3672
3746
  const r = data[idx];
3673
3747
  const g = data[idx + 1];
3674
3748
  const b = data[idx + 2];
3675
- const qr = Math.round(r / 16) * 16;
3676
- const qg = Math.round(g / 16) * 16;
3677
- const qb = Math.round(b / 16) * 16;
3749
+ const qr = Math.round(r / 8) * 8;
3750
+ const qg = Math.round(g / 8) * 8;
3751
+ const qb = Math.round(b / 8) * 8;
3678
3752
  const key = `${qr},${qg},${qb}`;
3679
3753
  const existing = colorCounts.get(key);
3680
3754
  if (existing) {
@@ -3683,13 +3757,22 @@ async function detectEdgeColor(imagePath) {
3683
3757
  colorCounts.set(key, { count: 1, color: { r: qr, g: qg, b: qb } });
3684
3758
  }
3685
3759
  };
3686
- for (let x = 0; x < width; x += 2) {
3687
- sampleEdgePixel(x, 0);
3688
- sampleEdgePixel(x, height - 1);
3689
- }
3690
- for (let y = 0; y < height; y += 2) {
3691
- sampleEdgePixel(0, y);
3692
- sampleEdgePixel(width - 1, y);
3760
+ const corners = [
3761
+ { startX: 0, startY: 0 },
3762
+ // Top-left
3763
+ { startX: width - cornerWidth, startY: 0 },
3764
+ // Top-right
3765
+ { startX: 0, startY: height - cornerHeight },
3766
+ // Bottom-left
3767
+ { startX: width - cornerWidth, startY: height - cornerHeight }
3768
+ // Bottom-right
3769
+ ];
3770
+ for (const corner of corners) {
3771
+ for (let y = corner.startY; y < corner.startY + cornerHeight; y += 2) {
3772
+ for (let x = corner.startX; x < corner.startX + cornerWidth; x += 2) {
3773
+ samplePixel(x, y);
3774
+ }
3775
+ }
3693
3776
  }
3694
3777
  let maxCount = 0;
3695
3778
  let dominantColor = { r: 255, g: 255, b: 255 };
@@ -3702,20 +3785,22 @@ async function detectEdgeColor(imagePath) {
3702
3785
  return dominantColor;
3703
3786
  }
3704
3787
  async function resizeImage(inputPath, outputPath, targetDimensions) {
3705
- const bgColor = await detectEdgeColor(inputPath);
3788
+ const bgColor = await detectCornerColor(inputPath);
3706
3789
  await sharp2(inputPath).resize(targetDimensions.width, targetDimensions.height, {
3707
3790
  fit: "contain",
3708
3791
  // Preserve aspect ratio
3709
3792
  withoutEnlargement: false,
3710
3793
  // Allow enlargement if needed
3711
- background: bgColor
3794
+ background: bgColor,
3712
3795
  // Use detected edge color
3796
+ kernel: "lanczos3"
3797
+ // High-quality downscaling algorithm
3713
3798
  }).flatten({ background: bgColor }).png().toFile(outputPath + ".tmp");
3714
3799
  fs11.renameSync(outputPath + ".tmp", outputPath);
3715
3800
  }
3716
3801
  async function validateAndResizeImage(sourcePath, translatedPath) {
3717
- const sourceDimensions = await getImageDimensions2(sourcePath);
3718
- const translatedDimensions = await getImageDimensions2(translatedPath);
3802
+ const sourceDimensions = await getImageDimensions(sourcePath);
3803
+ const translatedDimensions = await getImageDimensions(translatedPath);
3719
3804
  const needsResize = sourceDimensions.width !== translatedDimensions.width || sourceDimensions.height !== translatedDimensions.height;
3720
3805
  if (needsResize) {
3721
3806
  await resizeImage(translatedPath, translatedPath, sourceDimensions);
@@ -3763,8 +3848,17 @@ var localizeScreenshotsInputSchema = z7.object({
3763
3848
  deviceTypes: z7.array(z7.enum(["phone", "tablet"])).optional().default(["phone", "tablet"]).describe("Device types to process (default: both phone and tablet)"),
3764
3849
  dryRun: z7.boolean().optional().default(false).describe("Preview mode - shows what would be translated without actually translating"),
3765
3850
  skipExisting: z7.boolean().optional().default(true).describe("Skip translation if target file already exists (default: true)"),
3766
- screenshotNumbers: z7.array(z7.number().int().positive()).optional().describe(
3767
- "Specific screenshot numbers to process (e.g., [1, 3, 5]). If not provided, all screenshots will be processed."
3851
+ screenshotNumbers: z7.union([
3852
+ z7.array(z7.number().int().positive()),
3853
+ z7.object({
3854
+ phone: z7.array(z7.number().int().positive()).optional(),
3855
+ tablet: z7.array(z7.number().int().positive()).optional()
3856
+ })
3857
+ ]).optional().describe(
3858
+ "Specific screenshot numbers to process. Can be:\n- Array for all devices: [1, 3, 5]\n- Object for per-device: { phone: [1, 2], tablet: [1, 3, 5] }\nIf not provided, all screenshots will be processed."
3859
+ ),
3860
+ preserveWords: z7.array(z7.string()).optional().describe(
3861
+ 'Words to keep untranslated (e.g., brand names, product names). Example: ["Pabal", "Pro", "AI"]'
3768
3862
  )
3769
3863
  });
3770
3864
  var jsonSchema7 = zodToJsonSchema7(localizeScreenshotsInputSchema, {
@@ -3841,12 +3935,14 @@ function getTargetLocales(allLocales, primaryLocale, requestedTargets) {
3841
3935
  );
3842
3936
  if (invalidTargets.length > 0) {
3843
3937
  console.warn(
3844
- `Warning: Some requested locales are not supported: ${invalidTargets.join(", ")}`
3938
+ `Warning: Some requested locales are not in product: ${invalidTargets.join(", ")}`
3845
3939
  );
3846
3940
  }
3847
3941
  targets = validTargets.filter((t) => t !== primaryLocale);
3848
3942
  }
3849
- return targets;
3943
+ const skippedLocales = getUnsupportedLocales(targets);
3944
+ const supportedTargets = targets.filter((t) => isGeminiSupportedLocale(t));
3945
+ return { targets: supportedTargets, skippedLocales };
3850
3946
  }
3851
3947
  function buildTranslationTasks(slug, screenshots, primaryLocale, targetLocales, skipExisting) {
3852
3948
  const tasks = [];
@@ -3881,7 +3977,8 @@ async function handleLocalizeScreenshots(input) {
3881
3977
  deviceTypes = ["phone", "tablet"],
3882
3978
  dryRun = false,
3883
3979
  skipExisting = true,
3884
- screenshotNumbers
3980
+ screenshotNumbers,
3981
+ preserveWords
3885
3982
  } = input;
3886
3983
  const results = [];
3887
3984
  let appInfo;
@@ -3916,36 +4013,58 @@ async function handleLocalizeScreenshots(input) {
3916
4013
  ]
3917
4014
  };
3918
4015
  }
3919
- const targetLocales = getTargetLocales(
4016
+ const { targets: targetLocales, skippedLocales } = getTargetLocales(
3920
4017
  allLocales,
3921
4018
  primaryLocale,
3922
4019
  requestedTargetLocales
3923
4020
  );
3924
4021
  if (targetLocales.length === 0) {
4022
+ const skippedMsg = skippedLocales.length > 0 ? ` (Skipped due to Gemini limitation: ${skippedLocales.join(", ")})` : "";
3925
4023
  return {
3926
4024
  content: [
3927
4025
  {
3928
4026
  type: "text",
3929
- text: `\u274C No target locales to translate to. Primary locale: ${primaryLocale}, Available: ${allLocales.join(", ")}`
4027
+ text: `\u274C No target locales to translate to. Primary locale: ${primaryLocale}, Available: ${allLocales.join(", ")}${skippedMsg}`
3930
4028
  }
3931
4029
  ]
3932
4030
  };
3933
4031
  }
3934
4032
  results.push(`\u{1F3AF} Target locales: ${targetLocales.join(", ")}`);
4033
+ if (skippedLocales.length > 0) {
4034
+ results.push(`\u26A0\uFE0F Skipped locales (not supported by Gemini): ${skippedLocales.join(", ")}`);
4035
+ }
3935
4036
  const sourceScreenshots = scanLocaleScreenshots(appInfo.slug, primaryLocale);
3936
4037
  let filteredScreenshots = sourceScreenshots.filter(
3937
4038
  (s) => deviceTypes.includes(s.type)
3938
4039
  );
3939
- if (screenshotNumbers && screenshotNumbers.length > 0) {
4040
+ if (screenshotNumbers) {
4041
+ const isArray = Array.isArray(screenshotNumbers);
4042
+ const phoneNumbers = isArray ? screenshotNumbers : screenshotNumbers.phone;
4043
+ const tabletNumbers = isArray ? screenshotNumbers : screenshotNumbers.tablet;
3940
4044
  filteredScreenshots = filteredScreenshots.filter((s) => {
3941
4045
  const match = s.filename.match(/^(\d+)\./);
3942
- if (match) {
3943
- const num = parseInt(match[1], 10);
3944
- return screenshotNumbers.includes(num);
4046
+ if (!match) return false;
4047
+ const num = parseInt(match[1], 10);
4048
+ const numbersForDevice = s.type === "phone" ? phoneNumbers : tabletNumbers;
4049
+ if (!numbersForDevice || numbersForDevice.length === 0) {
4050
+ return true;
3945
4051
  }
3946
- return false;
4052
+ return numbersForDevice.includes(num);
3947
4053
  });
3948
- results.push(`\u{1F522} Filtering screenshots: ${screenshotNumbers.join(", ")}`);
4054
+ const filterParts = [];
4055
+ if (isArray) {
4056
+ filterParts.push(`all: ${screenshotNumbers.join(", ")}`);
4057
+ } else {
4058
+ if (phoneNumbers && phoneNumbers.length > 0) {
4059
+ filterParts.push(`phone: ${phoneNumbers.join(", ")}`);
4060
+ }
4061
+ if (tabletNumbers && tabletNumbers.length > 0) {
4062
+ filterParts.push(`tablet: ${tabletNumbers.join(", ")}`);
4063
+ }
4064
+ }
4065
+ if (filterParts.length > 0) {
4066
+ results.push(`\u{1F522} Filtering screenshots: ${filterParts.join(" | ")}`);
4067
+ }
3949
4068
  }
3950
4069
  if (filteredScreenshots.length === 0) {
3951
4070
  const screenshotsDir2 = getScreenshotsDir(appInfo.slug);
@@ -4015,6 +4134,9 @@ ${screenshotsDir2}/${primaryLocale}/tablet/1.png, 2.png, ...`
4015
4134
  }
4016
4135
  results.push(`
4017
4136
  \u{1F680} Starting translations...`);
4137
+ if (preserveWords && preserveWords.length > 0) {
4138
+ results.push(`\u{1F512} Preserving words: ${preserveWords.join(", ")}`);
4139
+ }
4018
4140
  const translationResult = await translateImagesWithProgress(
4019
4141
  tasks,
4020
4142
  (progress) => {
@@ -4032,7 +4154,8 @@ ${screenshotsDir2}/${primaryLocale}/tablet/1.png, 2.png, ...`
4032
4154
  `\u274C ${progressPrefix} ${progress.targetLocale}/${progress.deviceType}/${progress.filename}: ${progress.error}`
4033
4155
  );
4034
4156
  }
4035
- }
4157
+ },
4158
+ preserveWords
4036
4159
  );
4037
4160
  results.push(`
4038
4161
  \u{1F4CA} Translation Results:`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pabal-resource-mcp",
3
- "version": "1.5.3",
3
+ "version": "1.5.5",
4
4
  "type": "module",
5
5
  "description": "MCP server for ASO data management with shared types and utilities",
6
6
  "author": "skyu",