pabal-resource-mcp 1.8.12 → 1.9.1
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.
- package/README.md +2 -2
- package/dist/bin/mcp-server.js +329 -289
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Build synced websites from App Store Connect and Google Play Console data.
|
|
|
6
6
|
|
|
7
7
|
> 💡 **Example**: [labs.quartz.best](https://labs.quartz.best/)
|
|
8
8
|
|
|
9
|
-
[](https://pabal.quartz.best/
|
|
9
|
+
[](https://pabal.quartz.best/en-US/docs/pabal-resource-mcp/README) [](https://pabal.quartz.best/ko-KR/docs/pabal-resource-mcp/README)
|
|
10
10
|
|
|
11
11
|
## Installation
|
|
12
12
|
|
|
@@ -61,7 +61,7 @@ Set `dataDir` in `~/.config/pabal-mcp/config.json`:
|
|
|
61
61
|
| App Icon | `generate-app-icons` |
|
|
62
62
|
| Content | `create-blog-html` |
|
|
63
63
|
|
|
64
|
-
See [documentation](
|
|
64
|
+
See [documentation](https://pabal.quartz.best/en-US/docs/pabal-resource-mcp/README) for details.
|
|
65
65
|
|
|
66
66
|
## License
|
|
67
67
|
|
package/dist/bin/mcp-server.js
CHANGED
|
@@ -3398,8 +3398,8 @@ Context around ${pos}: ${context}`
|
|
|
3398
3398
|
// src/tools/screenshots/translate-screenshots.ts
|
|
3399
3399
|
import { z as z7 } from "zod";
|
|
3400
3400
|
import { zodToJsonSchema as zodToJsonSchema7 } from "zod-to-json-schema";
|
|
3401
|
-
import
|
|
3402
|
-
import
|
|
3401
|
+
import fs12 from "fs";
|
|
3402
|
+
import path12 from "path";
|
|
3403
3403
|
|
|
3404
3404
|
// src/tools/screenshots/utils/scan-screenshots.util.ts
|
|
3405
3405
|
import fs9 from "fs";
|
|
@@ -3481,10 +3481,161 @@ function scanRawScreenshots(slug, locale) {
|
|
|
3481
3481
|
}
|
|
3482
3482
|
|
|
3483
3483
|
// src/tools/screenshots/utils/gemini-image-translator.util.ts
|
|
3484
|
+
import fs11 from "fs";
|
|
3485
|
+
import path11 from "path";
|
|
3486
|
+
import sharp from "sharp";
|
|
3487
|
+
|
|
3488
|
+
// src/utils/gemini-image-model.util.ts
|
|
3489
|
+
var GEMINI_IMAGE_MODEL_PRESETS = {
|
|
3490
|
+
flash: "gemini-3.1-flash-image-preview",
|
|
3491
|
+
pro: "gemini-3-pro-image-preview"
|
|
3492
|
+
};
|
|
3493
|
+
var GEMINI_IMAGE_MODEL_VALUES = Object.keys(
|
|
3494
|
+
GEMINI_IMAGE_MODEL_PRESETS
|
|
3495
|
+
);
|
|
3496
|
+
var RETRYABLE_STATUS_CODES = /* @__PURE__ */ new Set([429, 500, 503, 504]);
|
|
3497
|
+
function parseCsv(value) {
|
|
3498
|
+
if (!value) {
|
|
3499
|
+
return [];
|
|
3500
|
+
}
|
|
3501
|
+
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
3502
|
+
}
|
|
3503
|
+
function getStatusCode(error) {
|
|
3504
|
+
if (typeof error !== "object" || error === null) {
|
|
3505
|
+
return void 0;
|
|
3506
|
+
}
|
|
3507
|
+
const status = error.status;
|
|
3508
|
+
return typeof status === "number" ? status : void 0;
|
|
3509
|
+
}
|
|
3510
|
+
function getDefaultModelOrder(preference) {
|
|
3511
|
+
if (preference === "pro") {
|
|
3512
|
+
return [
|
|
3513
|
+
GEMINI_IMAGE_MODEL_PRESETS.pro,
|
|
3514
|
+
GEMINI_IMAGE_MODEL_PRESETS.flash
|
|
3515
|
+
];
|
|
3516
|
+
}
|
|
3517
|
+
return [
|
|
3518
|
+
GEMINI_IMAGE_MODEL_PRESETS.flash,
|
|
3519
|
+
GEMINI_IMAGE_MODEL_PRESETS.pro
|
|
3520
|
+
];
|
|
3521
|
+
}
|
|
3522
|
+
function getGeminiImageModelCandidates(preference = "flash") {
|
|
3523
|
+
const defaultOrder = getDefaultModelOrder(preference);
|
|
3524
|
+
const preferredModel = defaultOrder[0];
|
|
3525
|
+
const envModelOverride = process.env.GEMINI_IMAGE_MODEL?.trim();
|
|
3526
|
+
const fallbackModels = parseCsv(process.env.GEMINI_IMAGE_FALLBACK_MODELS);
|
|
3527
|
+
const orderedModels = [
|
|
3528
|
+
preferredModel,
|
|
3529
|
+
...envModelOverride ? [envModelOverride] : [],
|
|
3530
|
+
...defaultOrder,
|
|
3531
|
+
...fallbackModels
|
|
3532
|
+
];
|
|
3533
|
+
return Array.from(
|
|
3534
|
+
new Set(orderedModels.filter((model) => Boolean(model)))
|
|
3535
|
+
);
|
|
3536
|
+
}
|
|
3537
|
+
function getGeminiErrorMessage(error) {
|
|
3538
|
+
const defaultMessage = error instanceof Error ? error.message : String(error);
|
|
3539
|
+
const normalizedMessage = defaultMessage.replace(/^exception\s*/i, "").trim();
|
|
3540
|
+
try {
|
|
3541
|
+
const parsed = JSON.parse(normalizedMessage);
|
|
3542
|
+
const apiMessage = parsed?.error?.message;
|
|
3543
|
+
if (typeof apiMessage === "string" && apiMessage.trim()) {
|
|
3544
|
+
return apiMessage.trim();
|
|
3545
|
+
}
|
|
3546
|
+
} catch {
|
|
3547
|
+
}
|
|
3548
|
+
return normalizedMessage || "Unknown Gemini API error";
|
|
3549
|
+
}
|
|
3550
|
+
function shouldTryNextGeminiImageModel(error) {
|
|
3551
|
+
const status = getStatusCode(error);
|
|
3552
|
+
if (status && RETRYABLE_STATUS_CODES.has(status)) {
|
|
3553
|
+
return true;
|
|
3554
|
+
}
|
|
3555
|
+
const message = getGeminiErrorMessage(error).toLowerCase();
|
|
3556
|
+
return message.includes("high demand") || message.includes("temporarily unavailable") || message.includes("try again later") || message.includes("overloaded") || message.includes("resource_exhausted") || message.includes("unavailable");
|
|
3557
|
+
}
|
|
3558
|
+
|
|
3559
|
+
// src/utils/gemini-client.util.ts
|
|
3484
3560
|
import { GoogleGenAI } from "@google/genai";
|
|
3561
|
+
function createGeminiClient() {
|
|
3562
|
+
const apiKey = getGeminiApiKey();
|
|
3563
|
+
return new GoogleGenAI({ apiKey });
|
|
3564
|
+
}
|
|
3565
|
+
|
|
3566
|
+
// src/utils/image-file.util.ts
|
|
3485
3567
|
import fs10 from "fs";
|
|
3486
3568
|
import path10 from "path";
|
|
3487
|
-
|
|
3569
|
+
function readImageAsBase64(imagePath) {
|
|
3570
|
+
const buffer = fs10.readFileSync(imagePath);
|
|
3571
|
+
const base64 = buffer.toString("base64");
|
|
3572
|
+
const ext = path10.extname(imagePath).toLowerCase();
|
|
3573
|
+
let mimeType = "image/png";
|
|
3574
|
+
if (ext === ".jpg" || ext === ".jpeg") {
|
|
3575
|
+
mimeType = "image/jpeg";
|
|
3576
|
+
} else if (ext === ".webp") {
|
|
3577
|
+
mimeType = "image/webp";
|
|
3578
|
+
}
|
|
3579
|
+
return { data: base64, mimeType };
|
|
3580
|
+
}
|
|
3581
|
+
|
|
3582
|
+
// src/utils/gemini-image-generation.util.ts
|
|
3583
|
+
async function generateImageWithFallback(input) {
|
|
3584
|
+
const models = getGeminiImageModelCandidates(input.imageModel);
|
|
3585
|
+
let lastError;
|
|
3586
|
+
for (const model of models) {
|
|
3587
|
+
try {
|
|
3588
|
+
const chat = input.client.chats.create({
|
|
3589
|
+
model,
|
|
3590
|
+
config: {
|
|
3591
|
+
responseModalities: ["TEXT", "IMAGE"]
|
|
3592
|
+
}
|
|
3593
|
+
});
|
|
3594
|
+
const response = await chat.sendMessage({
|
|
3595
|
+
message: [
|
|
3596
|
+
{ text: input.prompt },
|
|
3597
|
+
{
|
|
3598
|
+
inlineData: {
|
|
3599
|
+
mimeType: input.image.mimeType,
|
|
3600
|
+
data: input.image.data
|
|
3601
|
+
}
|
|
3602
|
+
}
|
|
3603
|
+
],
|
|
3604
|
+
config: {
|
|
3605
|
+
responseModalities: ["TEXT", "IMAGE"],
|
|
3606
|
+
...input.aspectRatio ? {
|
|
3607
|
+
imageConfig: {
|
|
3608
|
+
aspectRatio: input.aspectRatio
|
|
3609
|
+
}
|
|
3610
|
+
} : {}
|
|
3611
|
+
}
|
|
3612
|
+
});
|
|
3613
|
+
const parts = response.candidates?.[0]?.content?.parts;
|
|
3614
|
+
if (!parts) {
|
|
3615
|
+
throw new Error("No content parts in Gemini response");
|
|
3616
|
+
}
|
|
3617
|
+
for (const part of parts) {
|
|
3618
|
+
if (part.inlineData?.data) {
|
|
3619
|
+
return {
|
|
3620
|
+
model,
|
|
3621
|
+
imageBase64: part.inlineData.data
|
|
3622
|
+
};
|
|
3623
|
+
}
|
|
3624
|
+
}
|
|
3625
|
+
throw new Error("No image data in Gemini response");
|
|
3626
|
+
} catch (error) {
|
|
3627
|
+
lastError = error;
|
|
3628
|
+
if (!shouldTryNextGeminiImageModel(error)) {
|
|
3629
|
+
break;
|
|
3630
|
+
}
|
|
3631
|
+
}
|
|
3632
|
+
}
|
|
3633
|
+
throw new Error(
|
|
3634
|
+
`All Gemini image models failed (${models.join(", ")}): ${getGeminiErrorMessage(lastError)}`
|
|
3635
|
+
);
|
|
3636
|
+
}
|
|
3637
|
+
|
|
3638
|
+
// src/tools/screenshots/utils/gemini-image-translator.util.ts
|
|
3488
3639
|
var GEMINI_ASPECT_RATIOS = {
|
|
3489
3640
|
"1:1": { ratio: 1 / 1, width: 2048, height: 2048 },
|
|
3490
3641
|
"2:3": { ratio: 2 / 3, width: 1696, height: 2528 },
|
|
@@ -3524,35 +3675,18 @@ function getLanguageName(locale) {
|
|
|
3524
3675
|
}
|
|
3525
3676
|
return locale;
|
|
3526
3677
|
}
|
|
3527
|
-
function getGeminiClient() {
|
|
3528
|
-
const apiKey = getGeminiApiKey();
|
|
3529
|
-
return new GoogleGenAI({ apiKey });
|
|
3530
|
-
}
|
|
3531
|
-
function readImageAsBase64(imagePath) {
|
|
3532
|
-
const buffer = fs10.readFileSync(imagePath);
|
|
3533
|
-
const base64 = buffer.toString("base64");
|
|
3534
|
-
const ext = path10.extname(imagePath).toLowerCase();
|
|
3535
|
-
let mimeType = "image/png";
|
|
3536
|
-
if (ext === ".jpg" || ext === ".jpeg") {
|
|
3537
|
-
mimeType = "image/jpeg";
|
|
3538
|
-
} else if (ext === ".webp") {
|
|
3539
|
-
mimeType = "image/webp";
|
|
3540
|
-
}
|
|
3541
|
-
return { data: base64, mimeType };
|
|
3542
|
-
}
|
|
3543
3678
|
function getAspectRatioForDevice(deviceType) {
|
|
3544
3679
|
return DEVICE_ASPECT_RATIOS[deviceType];
|
|
3545
3680
|
}
|
|
3546
|
-
async function translateImage(sourcePath, sourceLocale, targetLocale, outputPaths, deviceType, preserveWords) {
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
const preserveInstruction = preserveWords && preserveWords.length > 0 ? `
|
|
3681
|
+
async function translateImage(sourcePath, sourceLocale, targetLocale, outputPaths, deviceType, preserveWords, imageModel = "flash") {
|
|
3682
|
+
const client = createGeminiClient();
|
|
3683
|
+
const sourceLanguage = getLanguageName(sourceLocale);
|
|
3684
|
+
const targetLanguage = getLanguageName(targetLocale);
|
|
3685
|
+
const aspectRatio = getAspectRatioForDevice(deviceType);
|
|
3686
|
+
const { data: imageData, mimeType } = readImageAsBase64(sourcePath);
|
|
3687
|
+
const preserveInstruction = preserveWords && preserveWords.length > 0 ? `
|
|
3554
3688
|
- Do NOT translate these words, keep them exactly as-is: ${preserveWords.join(", ")}` : "";
|
|
3555
|
-
|
|
3689
|
+
const prompt = `This is an app screenshot with text in ${sourceLanguage}.
|
|
3556
3690
|
Please translate ONLY the text/words in this image to ${targetLanguage}.
|
|
3557
3691
|
|
|
3558
3692
|
IMPORTANT INSTRUCTIONS:
|
|
@@ -3562,73 +3696,38 @@ IMPORTANT INSTRUCTIONS:
|
|
|
3562
3696
|
- Do NOT add any new elements or remove existing design elements
|
|
3563
3697
|
- The output should look identical except the text language is ${targetLanguage}
|
|
3564
3698
|
- Preserve all icons, images, and graphical elements exactly as they are${preserveInstruction}`;
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
inlineData: {
|
|
3576
|
-
mimeType,
|
|
3577
|
-
data: imageData
|
|
3578
|
-
}
|
|
3579
|
-
}
|
|
3580
|
-
],
|
|
3581
|
-
config: {
|
|
3582
|
-
responseModalities: ["TEXT", "IMAGE"],
|
|
3583
|
-
imageConfig: {
|
|
3584
|
-
aspectRatio
|
|
3585
|
-
}
|
|
3586
|
-
}
|
|
3699
|
+
try {
|
|
3700
|
+
const generated = await generateImageWithFallback({
|
|
3701
|
+
client,
|
|
3702
|
+
prompt,
|
|
3703
|
+
image: {
|
|
3704
|
+
mimeType,
|
|
3705
|
+
data: imageData
|
|
3706
|
+
},
|
|
3707
|
+
aspectRatio,
|
|
3708
|
+
imageModel
|
|
3587
3709
|
});
|
|
3588
|
-
const
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
};
|
|
3594
|
-
}
|
|
3595
|
-
const parts = candidates[0].content?.parts;
|
|
3596
|
-
if (!parts) {
|
|
3597
|
-
return {
|
|
3598
|
-
success: false,
|
|
3599
|
-
error: "No content parts in response"
|
|
3600
|
-
};
|
|
3601
|
-
}
|
|
3602
|
-
for (const part of parts) {
|
|
3603
|
-
if (part.inlineData?.data) {
|
|
3604
|
-
const imageBuffer = Buffer.from(part.inlineData.data, "base64");
|
|
3605
|
-
for (const outputPath of outputPaths) {
|
|
3606
|
-
const outputDir = path10.dirname(outputPath);
|
|
3607
|
-
if (!fs10.existsSync(outputDir)) {
|
|
3608
|
-
fs10.mkdirSync(outputDir, { recursive: true });
|
|
3609
|
-
}
|
|
3610
|
-
await sharp(imageBuffer).png().toFile(outputPath);
|
|
3611
|
-
}
|
|
3612
|
-
return {
|
|
3613
|
-
success: true,
|
|
3614
|
-
outputPath: outputPaths[0]
|
|
3615
|
-
// Return primary path
|
|
3616
|
-
};
|
|
3710
|
+
const imageBuffer = Buffer.from(generated.imageBase64, "base64");
|
|
3711
|
+
for (const outputPath of outputPaths) {
|
|
3712
|
+
const outputDir = path11.dirname(outputPath);
|
|
3713
|
+
if (!fs11.existsSync(outputDir)) {
|
|
3714
|
+
fs11.mkdirSync(outputDir, { recursive: true });
|
|
3617
3715
|
}
|
|
3716
|
+
await sharp(imageBuffer).png().toFile(outputPath);
|
|
3618
3717
|
}
|
|
3619
3718
|
return {
|
|
3620
|
-
success:
|
|
3621
|
-
|
|
3719
|
+
success: true,
|
|
3720
|
+
outputPath: outputPaths[0]
|
|
3721
|
+
// Return primary path
|
|
3622
3722
|
};
|
|
3623
3723
|
} catch (error) {
|
|
3624
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
3625
3724
|
return {
|
|
3626
3725
|
success: false,
|
|
3627
|
-
error: message
|
|
3726
|
+
error: error instanceof Error ? error.message : String(error)
|
|
3628
3727
|
};
|
|
3629
3728
|
}
|
|
3630
3729
|
}
|
|
3631
|
-
async function translateImagesWithProgress(translations, onProgress, preserveWords) {
|
|
3730
|
+
async function translateImagesWithProgress(translations, onProgress, preserveWords, imageModel = "flash") {
|
|
3632
3731
|
let successful = 0;
|
|
3633
3732
|
let failed = 0;
|
|
3634
3733
|
const errors = [];
|
|
@@ -3652,7 +3751,8 @@ async function translateImagesWithProgress(translations, onProgress, preserveWor
|
|
|
3652
3751
|
translation.targetLocale,
|
|
3653
3752
|
translation.outputPaths,
|
|
3654
3753
|
translation.deviceType,
|
|
3655
|
-
preserveWords
|
|
3754
|
+
preserveWords,
|
|
3755
|
+
imageModel
|
|
3656
3756
|
);
|
|
3657
3757
|
if (result.success) {
|
|
3658
3758
|
successful++;
|
|
@@ -3671,6 +3771,9 @@ async function translateImagesWithProgress(translations, onProgress, preserveWor
|
|
|
3671
3771
|
}
|
|
3672
3772
|
return { successful, failed, errors };
|
|
3673
3773
|
}
|
|
3774
|
+
function getImageModelLabel(imageModel) {
|
|
3775
|
+
return `${imageModel} (${GEMINI_IMAGE_MODEL_PRESETS[imageModel]})`;
|
|
3776
|
+
}
|
|
3674
3777
|
|
|
3675
3778
|
// src/tools/screenshots/utils/locale-mapping.constants.ts
|
|
3676
3779
|
var GEMINI_LOCALE_GROUPS = {
|
|
@@ -3773,6 +3876,9 @@ var translateScreenshotsInputSchema = z7.object({
|
|
|
3773
3876
|
),
|
|
3774
3877
|
preserveWords: z7.array(z7.string()).optional().describe(
|
|
3775
3878
|
'Words to keep untranslated (e.g., brand names, product names). Example: ["Pabal", "Pro", "AI"]'
|
|
3879
|
+
),
|
|
3880
|
+
imageModel: z7.enum(GEMINI_IMAGE_MODEL_VALUES).optional().default("flash").describe(
|
|
3881
|
+
"Gemini image model preference. 'flash' (default) is faster/cheaper, 'pro' prioritizes quality."
|
|
3776
3882
|
)
|
|
3777
3883
|
});
|
|
3778
3884
|
var jsonSchema7 = zodToJsonSchema7(translateScreenshotsInputSchema, {
|
|
@@ -3798,6 +3904,10 @@ Use \`resize-screenshots\` after this tool to resize images to final dimensions.
|
|
|
3798
3904
|
- Screenshots must be in: public/products/{slug}/screenshots/{locale}/phone/ and /tablet/
|
|
3799
3905
|
- Locale files must exist in: public/products/{slug}/locales/
|
|
3800
3906
|
|
|
3907
|
+
**Model Selection:**
|
|
3908
|
+
- \`imageModel: "flash"\` (default) for speed/cost
|
|
3909
|
+
- \`imageModel: "pro"\` for higher instruction fidelity
|
|
3910
|
+
|
|
3801
3911
|
**Example output structure:**
|
|
3802
3912
|
\`\`\`
|
|
3803
3913
|
public/products/my-app/screenshots/
|
|
@@ -3873,14 +3983,14 @@ function buildTranslationTasks(slug, screenshots, primaryLocale, targetLocales,
|
|
|
3873
3983
|
for (const screenshot of screenshots) {
|
|
3874
3984
|
const outputPaths = [];
|
|
3875
3985
|
for (const locale of outputLocales) {
|
|
3876
|
-
const outputPath =
|
|
3986
|
+
const outputPath = path12.join(
|
|
3877
3987
|
screenshotsDir,
|
|
3878
3988
|
locale,
|
|
3879
3989
|
screenshot.type,
|
|
3880
3990
|
"raw",
|
|
3881
3991
|
screenshot.filename
|
|
3882
3992
|
);
|
|
3883
|
-
if (!skipExisting || !
|
|
3993
|
+
if (!skipExisting || !fs12.existsSync(outputPath)) {
|
|
3884
3994
|
outputPaths.push(outputPath);
|
|
3885
3995
|
}
|
|
3886
3996
|
}
|
|
@@ -3907,7 +4017,8 @@ async function handleTranslateScreenshots(input) {
|
|
|
3907
4017
|
dryRun = false,
|
|
3908
4018
|
skipExisting = true,
|
|
3909
4019
|
screenshotNumbers,
|
|
3910
|
-
preserveWords
|
|
4020
|
+
preserveWords,
|
|
4021
|
+
imageModel = "flash"
|
|
3911
4022
|
} = input;
|
|
3912
4023
|
const results = [];
|
|
3913
4024
|
let appInfo;
|
|
@@ -3960,6 +4071,7 @@ async function handleTranslateScreenshots(input) {
|
|
|
3960
4071
|
};
|
|
3961
4072
|
}
|
|
3962
4073
|
results.push(`\u{1F3AF} Target locales to translate: ${targetLocales.join(", ")}`);
|
|
4074
|
+
results.push(`\u{1F9E0} Image model: ${getImageModelLabel(imageModel)}`);
|
|
3963
4075
|
if (groupedLocales.length > 0) {
|
|
3964
4076
|
results.push(
|
|
3965
4077
|
`\u{1F4CB} Grouped locales (saved together): ${groupedLocales.join(", ")}`
|
|
@@ -4087,9 +4199,9 @@ ${screenshotsDir2}/${primaryLocale}/tablet/1.png, 2.png, ...`
|
|
|
4087
4199
|
}
|
|
4088
4200
|
for (const task of tasks) {
|
|
4089
4201
|
for (const outputPath of task.outputPaths) {
|
|
4090
|
-
const outputDir =
|
|
4091
|
-
if (!
|
|
4092
|
-
|
|
4202
|
+
const outputDir = path12.dirname(outputPath);
|
|
4203
|
+
if (!fs12.existsSync(outputDir)) {
|
|
4204
|
+
fs12.mkdirSync(outputDir, { recursive: true });
|
|
4093
4205
|
}
|
|
4094
4206
|
}
|
|
4095
4207
|
}
|
|
@@ -4111,7 +4223,8 @@ ${screenshotsDir2}/${primaryLocale}/tablet/1.png, 2.png, ...`
|
|
|
4111
4223
|
);
|
|
4112
4224
|
}
|
|
4113
4225
|
},
|
|
4114
|
-
preserveWords
|
|
4226
|
+
preserveWords,
|
|
4227
|
+
imageModel
|
|
4115
4228
|
);
|
|
4116
4229
|
results.push(`
|
|
4117
4230
|
\u{1F4CA} Translation Results:`);
|
|
@@ -4121,7 +4234,7 @@ ${screenshotsDir2}/${primaryLocale}/tablet/1.png, 2.png, ...`
|
|
|
4121
4234
|
results.push(`
|
|
4122
4235
|
\u26A0\uFE0F Errors:`);
|
|
4123
4236
|
for (const err of translationResult.errors.slice(0, 5)) {
|
|
4124
|
-
results.push(` - ${
|
|
4237
|
+
results.push(` - ${path12.basename(err.path)}: ${err.error}`);
|
|
4125
4238
|
}
|
|
4126
4239
|
if (translationResult.errors.length > 5) {
|
|
4127
4240
|
results.push(
|
|
@@ -4149,12 +4262,12 @@ ${screenshotsDir2}/${primaryLocale}/tablet/1.png, 2.png, ...`
|
|
|
4149
4262
|
// src/tools/screenshots/resize-screenshots.ts
|
|
4150
4263
|
import { z as z8 } from "zod";
|
|
4151
4264
|
import { zodToJsonSchema as zodToJsonSchema8 } from "zod-to-json-schema";
|
|
4152
|
-
import
|
|
4153
|
-
import
|
|
4265
|
+
import fs14 from "fs";
|
|
4266
|
+
import path13 from "path";
|
|
4154
4267
|
|
|
4155
4268
|
// src/tools/screenshots/utils/image-resizer.util.ts
|
|
4156
4269
|
import sharp2 from "sharp";
|
|
4157
|
-
import
|
|
4270
|
+
import fs13 from "fs";
|
|
4158
4271
|
var SCREENSHOT_DIMENSIONS = {
|
|
4159
4272
|
phone: { width: 1242, height: 2688 },
|
|
4160
4273
|
tablet: { width: 2048, height: 2732 }
|
|
@@ -4253,7 +4366,7 @@ async function resizeImage(inputPath, outputPath, targetDimensions, bgColor) {
|
|
|
4253
4366
|
kernel: "lanczos3"
|
|
4254
4367
|
// High-quality downscaling algorithm
|
|
4255
4368
|
}).flatten({ background: backgroundColor }).png().toFile(outputPath + ".tmp");
|
|
4256
|
-
|
|
4369
|
+
fs13.renameSync(outputPath + ".tmp", outputPath);
|
|
4257
4370
|
}
|
|
4258
4371
|
|
|
4259
4372
|
// src/tools/screenshots/resize-screenshots.ts
|
|
@@ -4349,6 +4462,10 @@ function buildResizeTasks(slug, sourceScreenshots, rawLocales, deviceTypes, scre
|
|
|
4349
4462
|
const key = `${screenshot.type}/${screenshot.filename}`;
|
|
4350
4463
|
sourceRefMap.set(key, screenshot.fullPath);
|
|
4351
4464
|
}
|
|
4465
|
+
const sourceHasByDevice = {
|
|
4466
|
+
phone: sourceScreenshots.some((s) => s.type === "phone"),
|
|
4467
|
+
tablet: sourceScreenshots.some((s) => s.type === "tablet")
|
|
4468
|
+
};
|
|
4352
4469
|
for (const locale of rawLocales) {
|
|
4353
4470
|
const rawScreenshots = scanRawScreenshots(slug, locale);
|
|
4354
4471
|
let filteredScreenshots = rawScreenshots.filter(
|
|
@@ -4372,16 +4489,16 @@ function buildResizeTasks(slug, sourceScreenshots, rawLocales, deviceTypes, scre
|
|
|
4372
4489
|
for (const screenshot of filteredScreenshots) {
|
|
4373
4490
|
const key = `${screenshot.type}/${screenshot.filename}`;
|
|
4374
4491
|
const sourceReferencePath = sourceRefMap.get(key);
|
|
4375
|
-
if (!sourceReferencePath) {
|
|
4492
|
+
if (!sourceReferencePath && sourceHasByDevice[screenshot.type]) {
|
|
4376
4493
|
continue;
|
|
4377
4494
|
}
|
|
4378
|
-
const outputPath =
|
|
4495
|
+
const outputPath = path13.join(
|
|
4379
4496
|
screenshotsDir,
|
|
4380
4497
|
locale,
|
|
4381
4498
|
screenshot.type,
|
|
4382
4499
|
screenshot.filename
|
|
4383
4500
|
);
|
|
4384
|
-
if (skipExisting &&
|
|
4501
|
+
if (skipExisting && fs14.existsSync(outputPath)) {
|
|
4385
4502
|
continue;
|
|
4386
4503
|
}
|
|
4387
4504
|
tasks.push({
|
|
@@ -4413,7 +4530,7 @@ async function batchResizeFromRaw(tasks, bgColor, onProgress) {
|
|
|
4413
4530
|
};
|
|
4414
4531
|
onProgress?.(progress);
|
|
4415
4532
|
try {
|
|
4416
|
-
if (!
|
|
4533
|
+
if (!fs14.existsSync(task.rawPath)) {
|
|
4417
4534
|
progress.status = "skipped";
|
|
4418
4535
|
onProgress?.(progress);
|
|
4419
4536
|
skippedCount++;
|
|
@@ -4421,9 +4538,9 @@ async function batchResizeFromRaw(tasks, bgColor, onProgress) {
|
|
|
4421
4538
|
}
|
|
4422
4539
|
const targetDimensions = SCREENSHOT_DIMENSIONS[task.deviceType];
|
|
4423
4540
|
const rawDimensions = await getImageDimensions(task.rawPath);
|
|
4424
|
-
const outputDir =
|
|
4425
|
-
if (!
|
|
4426
|
-
|
|
4541
|
+
const outputDir = path13.dirname(task.outputPath);
|
|
4542
|
+
if (!fs14.existsSync(outputDir)) {
|
|
4543
|
+
fs14.mkdirSync(outputDir, { recursive: true });
|
|
4427
4544
|
}
|
|
4428
4545
|
await resizeImage(task.rawPath, task.outputPath, targetDimensions, bgColor);
|
|
4429
4546
|
progress.status = "completed";
|
|
@@ -4650,7 +4767,7 @@ Available locales with raw/: ${allRawLocales.join(", ")}`
|
|
|
4650
4767
|
results.push(`
|
|
4651
4768
|
\u26A0\uFE0F Errors:`);
|
|
4652
4769
|
for (const err of resizeResult.errors.slice(0, 5)) {
|
|
4653
|
-
results.push(` - ${
|
|
4770
|
+
results.push(` - ${path13.basename(err.path)}: ${err.error}`);
|
|
4654
4771
|
}
|
|
4655
4772
|
if (resizeResult.errors.length > 5) {
|
|
4656
4773
|
results.push(` ... and ${resizeResult.errors.length - 5} more errors`);
|
|
@@ -4674,10 +4791,9 @@ Available locales with raw/: ${allRawLocales.join(", ")}`
|
|
|
4674
4791
|
// src/tools/screenshots/phone-to-tablet.ts
|
|
4675
4792
|
import { z as z9 } from "zod";
|
|
4676
4793
|
import { zodToJsonSchema as zodToJsonSchema9 } from "zod-to-json-schema";
|
|
4677
|
-
import
|
|
4678
|
-
import
|
|
4794
|
+
import fs15 from "fs";
|
|
4795
|
+
import path14 from "path";
|
|
4679
4796
|
import sharp3 from "sharp";
|
|
4680
|
-
import { GoogleGenAI as GoogleGenAI2 } from "@google/genai";
|
|
4681
4797
|
var TOOL_NAME5 = "phone-to-tablet";
|
|
4682
4798
|
var TABLET_ASPECT_RATIO = "3:4";
|
|
4683
4799
|
var phoneToTabletInputSchema = z9.object({
|
|
@@ -4698,6 +4814,9 @@ var phoneToTabletInputSchema = z9.object({
|
|
|
4698
4814
|
),
|
|
4699
4815
|
preserveWords: z9.array(z9.string()).optional().describe(
|
|
4700
4816
|
'Words to keep exactly as-is in the generated image (e.g., brand names). Example: ["Pabal", "Pro", "AI"]'
|
|
4817
|
+
),
|
|
4818
|
+
imageModel: z9.enum(GEMINI_IMAGE_MODEL_VALUES).optional().default("flash").describe(
|
|
4819
|
+
"Gemini image model preference. 'flash' (default) is faster/cheaper, 'pro' prioritizes quality."
|
|
4701
4820
|
)
|
|
4702
4821
|
});
|
|
4703
4822
|
var jsonSchema9 = zodToJsonSchema9(phoneToTabletInputSchema, {
|
|
@@ -4711,8 +4830,8 @@ var phoneToTabletTool = {
|
|
|
4711
4830
|
|
|
4712
4831
|
**PURPOSE:** Generate tablet-sized screenshots from existing phone screenshots by:
|
|
4713
4832
|
1. Reading phone screenshots from the source locale
|
|
4714
|
-
2. Using Gemini to
|
|
4715
|
-
3.
|
|
4833
|
+
2. Using Gemini to keep the same UI while adapting to a tablet canvas
|
|
4834
|
+
3. Preserving original content/layout without creating new UI
|
|
4716
4835
|
|
|
4717
4836
|
**OUTPUT:** Saves generated tablet images to raw/ folder: \`{locale}/tablet/raw/{filename}\`
|
|
4718
4837
|
|
|
@@ -4728,6 +4847,10 @@ Run \`resize-screenshots --deviceTypes tablet\` after this tool to resize images
|
|
|
4728
4847
|
- Phone screenshots must exist in: public/products/{slug}/screenshots/{locale}/phone/
|
|
4729
4848
|
- Locale files must exist in: public/products/{slug}/locales/
|
|
4730
4849
|
|
|
4850
|
+
**Model Selection:**
|
|
4851
|
+
- \`imageModel: "flash"\` (default) for speed/cost
|
|
4852
|
+
- \`imageModel: "pro"\` for higher instruction fidelity
|
|
4853
|
+
|
|
4731
4854
|
**Example output structure:**
|
|
4732
4855
|
\`\`\`
|
|
4733
4856
|
public/products/my-app/screenshots/
|
|
@@ -4779,14 +4902,14 @@ function buildConversionTasks(slug, phoneScreenshots, locale, screenshotNumbers,
|
|
|
4779
4902
|
});
|
|
4780
4903
|
}
|
|
4781
4904
|
for (const screenshot of filteredScreenshots) {
|
|
4782
|
-
const tabletRawPath =
|
|
4905
|
+
const tabletRawPath = path14.join(
|
|
4783
4906
|
screenshotsDir,
|
|
4784
4907
|
locale,
|
|
4785
4908
|
"tablet",
|
|
4786
4909
|
"raw",
|
|
4787
4910
|
screenshot.filename
|
|
4788
4911
|
);
|
|
4789
|
-
if (skipExisting &&
|
|
4912
|
+
if (skipExisting && fs15.existsSync(tabletRawPath)) {
|
|
4790
4913
|
continue;
|
|
4791
4914
|
}
|
|
4792
4915
|
tasks.push({
|
|
@@ -4798,103 +4921,51 @@ function buildConversionTasks(slug, phoneScreenshots, locale, screenshotNumbers,
|
|
|
4798
4921
|
}
|
|
4799
4922
|
return tasks;
|
|
4800
4923
|
}
|
|
4801
|
-
function
|
|
4802
|
-
const
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
function readImageAsBase642(imagePath) {
|
|
4806
|
-
const buffer = fs14.readFileSync(imagePath);
|
|
4807
|
-
const base64 = buffer.toString("base64");
|
|
4808
|
-
const ext = path13.extname(imagePath).toLowerCase();
|
|
4809
|
-
let mimeType = "image/png";
|
|
4810
|
-
if (ext === ".jpg" || ext === ".jpeg") {
|
|
4811
|
-
mimeType = "image/jpeg";
|
|
4812
|
-
} else if (ext === ".webp") {
|
|
4813
|
-
mimeType = "image/webp";
|
|
4814
|
-
}
|
|
4815
|
-
return { data: base64, mimeType };
|
|
4816
|
-
}
|
|
4817
|
-
async function convertPhoneToTablet(phonePath, tabletRawPath, preserveWords) {
|
|
4818
|
-
try {
|
|
4819
|
-
const client = getGeminiClient2();
|
|
4820
|
-
const { data: imageData, mimeType } = readImageAsBase642(phonePath);
|
|
4821
|
-
const preserveInstruction = preserveWords && preserveWords.length > 0 ? `
|
|
4924
|
+
async function convertPhoneToTablet(phonePath, tabletRawPath, preserveWords, imageModel = "flash") {
|
|
4925
|
+
const client = createGeminiClient();
|
|
4926
|
+
const { data: imageData, mimeType } = readImageAsBase64(phonePath);
|
|
4927
|
+
const preserveInstruction = preserveWords && preserveWords.length > 0 ? `
|
|
4822
4928
|
- Do NOT change these words, keep them exactly as-is: ${preserveWords.join(", ")}` : "";
|
|
4823
|
-
|
|
4929
|
+
const prompt = `Convert this PHONE app screenshot into a TABLET screenshot.
|
|
4824
4930
|
|
|
4825
4931
|
IMPORTANT INSTRUCTIONS:
|
|
4826
|
-
-
|
|
4827
|
-
-
|
|
4828
|
-
-
|
|
4829
|
-
-
|
|
4830
|
-
-
|
|
4831
|
-
-
|
|
4832
|
-
-
|
|
4833
|
-
-
|
|
4834
|
-
-
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4838
|
-
|
|
4839
|
-
|
|
4840
|
-
|
|
4841
|
-
|
|
4842
|
-
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
|
|
4847
|
-
inlineData: {
|
|
4848
|
-
mimeType,
|
|
4849
|
-
data: imageData
|
|
4850
|
-
}
|
|
4851
|
-
}
|
|
4852
|
-
],
|
|
4853
|
-
config: {
|
|
4854
|
-
responseModalities: ["TEXT", "IMAGE"],
|
|
4855
|
-
imageConfig: {
|
|
4856
|
-
aspectRatio: TABLET_ASPECT_RATIO
|
|
4857
|
-
}
|
|
4858
|
-
}
|
|
4932
|
+
- Preserve the original UI exactly: same components, text, icons, colors, and visual hierarchy
|
|
4933
|
+
- Do NOT redesign, recompose, or invent any new UI
|
|
4934
|
+
- Do NOT add/remove/reorder elements
|
|
4935
|
+
- Do NOT create side-by-side layouts, new panels, or alternative arrangements
|
|
4936
|
+
- Keep the same screen content and structure from the phone screenshot
|
|
4937
|
+
- Only adapt to tablet aspect ratio (3:4) by extending canvas width as needed
|
|
4938
|
+
- Keep the original content centered and unchanged as much as possible
|
|
4939
|
+
- Use matching background fill/empty space for extra horizontal area
|
|
4940
|
+
- If a device frame exists, keep the same frame style and avoid changing its design${preserveInstruction}
|
|
4941
|
+
|
|
4942
|
+
Output one tablet screenshot that looks like the original phone screenshot placed on a wider tablet canvas.`;
|
|
4943
|
+
try {
|
|
4944
|
+
const generated = await generateImageWithFallback({
|
|
4945
|
+
client,
|
|
4946
|
+
prompt,
|
|
4947
|
+
image: {
|
|
4948
|
+
mimeType,
|
|
4949
|
+
data: imageData
|
|
4950
|
+
},
|
|
4951
|
+
aspectRatio: TABLET_ASPECT_RATIO,
|
|
4952
|
+
imageModel
|
|
4859
4953
|
});
|
|
4860
|
-
const
|
|
4861
|
-
|
|
4862
|
-
|
|
4863
|
-
|
|
4864
|
-
error: "No response from Gemini API"
|
|
4865
|
-
};
|
|
4866
|
-
}
|
|
4867
|
-
const parts = candidates[0].content?.parts;
|
|
4868
|
-
if (!parts) {
|
|
4869
|
-
return {
|
|
4870
|
-
success: false,
|
|
4871
|
-
error: "No content parts in response"
|
|
4872
|
-
};
|
|
4954
|
+
const imageBuffer = Buffer.from(generated.imageBase64, "base64");
|
|
4955
|
+
const outputDir = path14.dirname(tabletRawPath);
|
|
4956
|
+
if (!fs15.existsSync(outputDir)) {
|
|
4957
|
+
fs15.mkdirSync(outputDir, { recursive: true });
|
|
4873
4958
|
}
|
|
4874
|
-
|
|
4875
|
-
|
|
4876
|
-
const imageBuffer = Buffer.from(part.inlineData.data, "base64");
|
|
4877
|
-
const outputDir = path13.dirname(tabletRawPath);
|
|
4878
|
-
if (!fs14.existsSync(outputDir)) {
|
|
4879
|
-
fs14.mkdirSync(outputDir, { recursive: true });
|
|
4880
|
-
}
|
|
4881
|
-
await sharp3(imageBuffer).png().toFile(tabletRawPath);
|
|
4882
|
-
return { success: true };
|
|
4883
|
-
}
|
|
4884
|
-
}
|
|
4885
|
-
return {
|
|
4886
|
-
success: false,
|
|
4887
|
-
error: "No image data in Gemini response"
|
|
4888
|
-
};
|
|
4959
|
+
await sharp3(imageBuffer).png().toFile(tabletRawPath);
|
|
4960
|
+
return { success: true };
|
|
4889
4961
|
} catch (error) {
|
|
4890
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
4891
4962
|
return {
|
|
4892
4963
|
success: false,
|
|
4893
|
-
error: message
|
|
4964
|
+
error: error instanceof Error ? error.message : String(error)
|
|
4894
4965
|
};
|
|
4895
4966
|
}
|
|
4896
4967
|
}
|
|
4897
|
-
async function convertWithProgress(tasks, onProgress, preserveWords) {
|
|
4968
|
+
async function convertWithProgress(tasks, onProgress, preserveWords, imageModel = "flash") {
|
|
4898
4969
|
let successful = 0;
|
|
4899
4970
|
let failed = 0;
|
|
4900
4971
|
const errors = [];
|
|
@@ -4913,7 +4984,8 @@ async function convertWithProgress(tasks, onProgress, preserveWords) {
|
|
|
4913
4984
|
const result = await convertPhoneToTablet(
|
|
4914
4985
|
task.phonePath,
|
|
4915
4986
|
task.tabletRawPath,
|
|
4916
|
-
preserveWords
|
|
4987
|
+
preserveWords,
|
|
4988
|
+
imageModel
|
|
4917
4989
|
);
|
|
4918
4990
|
if (result.success) {
|
|
4919
4991
|
successful++;
|
|
@@ -4939,7 +5011,8 @@ async function handlePhoneToTablet(input) {
|
|
|
4939
5011
|
screenshotNumbers,
|
|
4940
5012
|
dryRun = false,
|
|
4941
5013
|
skipExisting = true,
|
|
4942
|
-
preserveWords
|
|
5014
|
+
preserveWords,
|
|
5015
|
+
imageModel = "flash"
|
|
4943
5016
|
} = input;
|
|
4944
5017
|
const results = [];
|
|
4945
5018
|
let appInfo;
|
|
@@ -4989,6 +5062,9 @@ async function handlePhoneToTablet(input) {
|
|
|
4989
5062
|
}
|
|
4990
5063
|
}
|
|
4991
5064
|
results.push(`\u{1F3AF} Locales to process: ${localesToProcess.join(", ")}`);
|
|
5065
|
+
results.push(
|
|
5066
|
+
`\u{1F9E0} Image model: ${imageModel} (${GEMINI_IMAGE_MODEL_PRESETS[imageModel]})`
|
|
5067
|
+
);
|
|
4992
5068
|
const allTasks = [];
|
|
4993
5069
|
for (const locale of localesToProcess) {
|
|
4994
5070
|
const phoneScreenshots = scanLocaleScreenshots(appInfo.slug, locale).filter(
|
|
@@ -5086,7 +5162,8 @@ Expected phone screenshots in: ${screenshotsDir2}/{locale}/phone/`
|
|
|
5086
5162
|
);
|
|
5087
5163
|
}
|
|
5088
5164
|
},
|
|
5089
|
-
preserveWords
|
|
5165
|
+
preserveWords,
|
|
5166
|
+
imageModel
|
|
5090
5167
|
);
|
|
5091
5168
|
results.push(`
|
|
5092
5169
|
\u{1F4CA} Conversion Results:`);
|
|
@@ -5096,7 +5173,7 @@ Expected phone screenshots in: ${screenshotsDir2}/{locale}/phone/`
|
|
|
5096
5173
|
results.push(`
|
|
5097
5174
|
\u26A0\uFE0F Errors:`);
|
|
5098
5175
|
for (const err of conversionResult.errors.slice(0, 5)) {
|
|
5099
|
-
results.push(` - ${
|
|
5176
|
+
results.push(` - ${path14.basename(err.path)}: ${err.error}`);
|
|
5100
5177
|
}
|
|
5101
5178
|
if (conversionResult.errors.length > 5) {
|
|
5102
5179
|
results.push(
|
|
@@ -5126,12 +5203,12 @@ Expected phone screenshots in: ${screenshotsDir2}/{locale}/phone/`
|
|
|
5126
5203
|
// src/tools/app-icon/generate-app-icons.ts
|
|
5127
5204
|
import { z as z10 } from "zod";
|
|
5128
5205
|
import { zodToJsonSchema as zodToJsonSchema10 } from "zod-to-json-schema";
|
|
5129
|
-
import
|
|
5130
|
-
import
|
|
5206
|
+
import fs16 from "fs";
|
|
5207
|
+
import path16 from "path";
|
|
5131
5208
|
import sharp5 from "sharp";
|
|
5132
5209
|
|
|
5133
5210
|
// src/tools/app-icon/utils/icon-specs.util.ts
|
|
5134
|
-
import
|
|
5211
|
+
import path15 from "path";
|
|
5135
5212
|
var ICON_FILENAMES = {
|
|
5136
5213
|
BASE: "icon.png",
|
|
5137
5214
|
IOS_LIGHT: "ios-light.png",
|
|
@@ -5172,14 +5249,14 @@ var ALL_ICON_TYPES = Object.keys(
|
|
|
5172
5249
|
);
|
|
5173
5250
|
function getIconsDir(slug, styleFolder) {
|
|
5174
5251
|
const productsDir = getProductsDir();
|
|
5175
|
-
const baseDir =
|
|
5176
|
-
return styleFolder ?
|
|
5252
|
+
const baseDir = path15.join(productsDir, slug, "icons");
|
|
5253
|
+
return styleFolder ? path15.join(baseDir, styleFolder) : baseDir;
|
|
5177
5254
|
}
|
|
5178
5255
|
function getBaseIconPath(slug, styleFolder) {
|
|
5179
|
-
return
|
|
5256
|
+
return path15.join(getIconsDir(slug, styleFolder), ICON_FILENAMES.BASE);
|
|
5180
5257
|
}
|
|
5181
5258
|
function getIconOutputPath(slug, iconType, styleFolder) {
|
|
5182
|
-
return
|
|
5259
|
+
return path15.join(getIconsDir(slug, styleFolder), ICON_SPECS[iconType].filename);
|
|
5183
5260
|
}
|
|
5184
5261
|
|
|
5185
5262
|
// src/tools/app-icon/utils/icon-resizer.util.ts
|
|
@@ -5489,11 +5566,11 @@ function validateApp4(appName) {
|
|
|
5489
5566
|
);
|
|
5490
5567
|
}
|
|
5491
5568
|
const productsDir = getProductsDir();
|
|
5492
|
-
const configPath =
|
|
5569
|
+
const configPath = path16.join(productsDir, app.slug, "config.json");
|
|
5493
5570
|
let config;
|
|
5494
|
-
if (
|
|
5571
|
+
if (fs16.existsSync(configPath)) {
|
|
5495
5572
|
try {
|
|
5496
|
-
const configData =
|
|
5573
|
+
const configData = fs16.readFileSync(configPath, "utf-8");
|
|
5497
5574
|
config = JSON.parse(configData);
|
|
5498
5575
|
} catch (error) {
|
|
5499
5576
|
console.warn(
|
|
@@ -5512,7 +5589,7 @@ function buildGenerationTasks(slug, iconTypes, skipExisting, styleFolder) {
|
|
|
5512
5589
|
const tasks = [];
|
|
5513
5590
|
const baseIconPath = getBaseIconPath(slug, styleFolder);
|
|
5514
5591
|
const iconsDir = getIconsDir(slug, styleFolder);
|
|
5515
|
-
if (!
|
|
5592
|
+
if (!fs16.existsSync(baseIconPath)) {
|
|
5516
5593
|
throw new Error(
|
|
5517
5594
|
`Base icon not found: ${baseIconPath}
|
|
5518
5595
|
|
|
@@ -5521,7 +5598,7 @@ Please place your base icon at this location first.`
|
|
|
5521
5598
|
}
|
|
5522
5599
|
for (const iconType of iconTypes) {
|
|
5523
5600
|
const outputPath = getIconOutputPath(slug, iconType, styleFolder);
|
|
5524
|
-
if (skipExisting &&
|
|
5601
|
+
if (skipExisting && fs16.existsSync(outputPath)) {
|
|
5525
5602
|
continue;
|
|
5526
5603
|
}
|
|
5527
5604
|
tasks.push({
|
|
@@ -5547,9 +5624,9 @@ async function generateIcons(tasks, backgroundColor, logoAlignment, onProgress)
|
|
|
5547
5624
|
};
|
|
5548
5625
|
onProgress?.(progress);
|
|
5549
5626
|
try {
|
|
5550
|
-
const outputDir =
|
|
5551
|
-
if (!
|
|
5552
|
-
|
|
5627
|
+
const outputDir = path16.dirname(task.outputPath);
|
|
5628
|
+
if (!fs16.existsSync(outputDir)) {
|
|
5629
|
+
fs16.mkdirSync(outputDir, { recursive: true });
|
|
5553
5630
|
}
|
|
5554
5631
|
if (task.iconType === "android-notification-icon") {
|
|
5555
5632
|
const whiteMask = await convertToWhiteMask(task.inputPath);
|
|
@@ -5753,24 +5830,8 @@ import path17 from "path";
|
|
|
5753
5830
|
import sharp6 from "sharp";
|
|
5754
5831
|
|
|
5755
5832
|
// src/tools/app-icon/utils/gemini.util.ts
|
|
5756
|
-
|
|
5757
|
-
|
|
5758
|
-
import { GoogleGenAI as GoogleGenAI3 } from "@google/genai";
|
|
5759
|
-
function getGeminiClient3() {
|
|
5760
|
-
const apiKey = getGeminiApiKey();
|
|
5761
|
-
return new GoogleGenAI3({ apiKey });
|
|
5762
|
-
}
|
|
5763
|
-
function readImageAsBase643(imagePath) {
|
|
5764
|
-
const buffer = fs16.readFileSync(imagePath);
|
|
5765
|
-
const base64 = buffer.toString("base64");
|
|
5766
|
-
const ext = path16.extname(imagePath).toLowerCase();
|
|
5767
|
-
let mimeType = "image/png";
|
|
5768
|
-
if (ext === ".jpg" || ext === ".jpeg") {
|
|
5769
|
-
mimeType = "image/jpeg";
|
|
5770
|
-
} else if (ext === ".webp") {
|
|
5771
|
-
mimeType = "image/webp";
|
|
5772
|
-
}
|
|
5773
|
-
return { data: base64, mimeType };
|
|
5833
|
+
function getGeminiClient() {
|
|
5834
|
+
return createGeminiClient();
|
|
5774
5835
|
}
|
|
5775
5836
|
|
|
5776
5837
|
// src/tools/app-icon/stylize-app-icon.ts
|
|
@@ -5788,6 +5849,9 @@ var stylizeAppIconInputSchema = z11.object({
|
|
|
5788
5849
|
preserveShape: z11.boolean().optional().default(true).describe(
|
|
5789
5850
|
"Preserve the original icon shape and structure (default: true). When true, only applies style elements without changing the core design."
|
|
5790
5851
|
),
|
|
5852
|
+
imageModel: z11.enum(GEMINI_IMAGE_MODEL_VALUES).optional().default("flash").describe(
|
|
5853
|
+
"Gemini image model preference. 'flash' (default) is faster/cheaper, 'pro' prioritizes quality."
|
|
5854
|
+
),
|
|
5791
5855
|
dryRun: z11.boolean().optional().default(false).describe(
|
|
5792
5856
|
"Preview mode - shows what would be generated without actually calling API or saving files"
|
|
5793
5857
|
)
|
|
@@ -5816,6 +5880,10 @@ var stylizeAppIconTool = {
|
|
|
5816
5880
|
- GEMINI_API_KEY or GOOGLE_API_KEY environment variable
|
|
5817
5881
|
- Base icon exists at {slug}/icons/icon.png
|
|
5818
5882
|
|
|
5883
|
+
**Model Selection:**
|
|
5884
|
+
- \`imageModel: "flash"\` (default) for speed/cost
|
|
5885
|
+
- \`imageModel: "pro"\` for higher instruction fidelity
|
|
5886
|
+
|
|
5819
5887
|
**Example Flow:**
|
|
5820
5888
|
\`\`\`
|
|
5821
5889
|
Step 1: Stylize icon
|
|
@@ -5850,10 +5918,10 @@ function validateApp5(appName) {
|
|
|
5850
5918
|
name: app.name || app.slug
|
|
5851
5919
|
};
|
|
5852
5920
|
}
|
|
5853
|
-
async function stylizeIconWithAI(inputPath, outputPath, stylePrompt, preserveShape) {
|
|
5921
|
+
async function stylizeIconWithAI(inputPath, outputPath, stylePrompt, preserveShape, imageModel = "flash") {
|
|
5854
5922
|
try {
|
|
5855
|
-
const client =
|
|
5856
|
-
const { data: imageData, mimeType } =
|
|
5923
|
+
const client = getGeminiClient();
|
|
5924
|
+
const { data: imageData, mimeType } = readImageAsBase64(inputPath);
|
|
5857
5925
|
const shapeInstruction = preserveShape ? "IMPORTANT: Preserve the original icon's shape, structure, and core design elements. Only add style-specific decorations and color adjustments." : "You can modify the icon structure as needed to achieve the style.";
|
|
5858
5926
|
const fullPrompt = `You are an expert app icon designer. Transform this app icon with the following style:
|
|
5859
5927
|
|
|
@@ -5870,56 +5938,23 @@ Requirements:
|
|
|
5870
5938
|
6. Ensure the result is visually appealing and brand-appropriate
|
|
5871
5939
|
|
|
5872
5940
|
Generate the stylized icon with transparent background now.`;
|
|
5873
|
-
const
|
|
5874
|
-
|
|
5875
|
-
|
|
5876
|
-
|
|
5877
|
-
|
|
5878
|
-
|
|
5879
|
-
|
|
5880
|
-
|
|
5881
|
-
{ text: fullPrompt },
|
|
5882
|
-
{
|
|
5883
|
-
inlineData: {
|
|
5884
|
-
mimeType,
|
|
5885
|
-
data: imageData
|
|
5886
|
-
}
|
|
5887
|
-
}
|
|
5888
|
-
],
|
|
5889
|
-
config: {
|
|
5890
|
-
responseModalities: ["TEXT", "IMAGE"]
|
|
5891
|
-
}
|
|
5941
|
+
const generated = await generateImageWithFallback({
|
|
5942
|
+
client,
|
|
5943
|
+
prompt: fullPrompt,
|
|
5944
|
+
image: {
|
|
5945
|
+
mimeType,
|
|
5946
|
+
data: imageData
|
|
5947
|
+
},
|
|
5948
|
+
imageModel
|
|
5892
5949
|
});
|
|
5893
|
-
const
|
|
5894
|
-
|
|
5895
|
-
|
|
5896
|
-
|
|
5897
|
-
|
|
5898
|
-
|
|
5899
|
-
|
|
5900
|
-
|
|
5901
|
-
if (!parts) {
|
|
5902
|
-
return {
|
|
5903
|
-
success: false,
|
|
5904
|
-
error: "No content parts in response"
|
|
5905
|
-
};
|
|
5906
|
-
}
|
|
5907
|
-
for (const part of parts) {
|
|
5908
|
-
if (part.inlineData?.data) {
|
|
5909
|
-
const imageBuffer = Buffer.from(part.inlineData.data, "base64");
|
|
5910
|
-
const outputDir = path17.dirname(outputPath);
|
|
5911
|
-
if (!fs17.existsSync(outputDir)) {
|
|
5912
|
-
fs17.mkdirSync(outputDir, { recursive: true });
|
|
5913
|
-
}
|
|
5914
|
-
const processedImage = await sharp6(imageBuffer).ensureAlpha().png().toBuffer();
|
|
5915
|
-
await sharp6(processedImage).toFile(outputPath);
|
|
5916
|
-
return { success: true };
|
|
5917
|
-
}
|
|
5918
|
-
}
|
|
5919
|
-
return {
|
|
5920
|
-
success: false,
|
|
5921
|
-
error: "No image data in Gemini response"
|
|
5922
|
-
};
|
|
5950
|
+
const imageBuffer = Buffer.from(generated.imageBase64, "base64");
|
|
5951
|
+
const outputDir = path17.dirname(outputPath);
|
|
5952
|
+
if (!fs17.existsSync(outputDir)) {
|
|
5953
|
+
fs17.mkdirSync(outputDir, { recursive: true });
|
|
5954
|
+
}
|
|
5955
|
+
const processedImage = await sharp6(imageBuffer).ensureAlpha().png().toBuffer();
|
|
5956
|
+
await sharp6(processedImage).toFile(outputPath);
|
|
5957
|
+
return { success: true };
|
|
5923
5958
|
} catch (error) {
|
|
5924
5959
|
const message = error instanceof Error ? error.message : String(error);
|
|
5925
5960
|
return {
|
|
@@ -5934,6 +5969,7 @@ async function handleStylizeAppIcon(input) {
|
|
|
5934
5969
|
styleFolder,
|
|
5935
5970
|
stylePrompt,
|
|
5936
5971
|
preserveShape = true,
|
|
5972
|
+
imageModel = "flash",
|
|
5937
5973
|
dryRun = false
|
|
5938
5974
|
} = input;
|
|
5939
5975
|
const results = [];
|
|
@@ -5975,6 +6011,9 @@ Please place your base icon at this location first.`
|
|
|
5975
6011
|
results.push(
|
|
5976
6012
|
`\u{1F527} Shape preservation: ${preserveShape ? "enabled" : "disabled"}`
|
|
5977
6013
|
);
|
|
6014
|
+
results.push(
|
|
6015
|
+
`\u{1F9E0} Image model: ${imageModel} (${GEMINI_IMAGE_MODEL_PRESETS[imageModel]})`
|
|
6016
|
+
);
|
|
5978
6017
|
if (dryRun) {
|
|
5979
6018
|
results.push(`
|
|
5980
6019
|
\u{1F50D} DRY RUN - No actual generation will be performed`);
|
|
@@ -5997,7 +6036,7 @@ Next step: Run generate-app-icons with styleFolder='${styleFolder}' to create pl
|
|
|
5997
6036
|
};
|
|
5998
6037
|
}
|
|
5999
6038
|
try {
|
|
6000
|
-
|
|
6039
|
+
getGeminiClient();
|
|
6001
6040
|
} catch (error) {
|
|
6002
6041
|
return {
|
|
6003
6042
|
content: [
|
|
@@ -6014,7 +6053,8 @@ Next step: Run generate-app-icons with styleFolder='${styleFolder}' to create pl
|
|
|
6014
6053
|
baseIconPath,
|
|
6015
6054
|
outputPath,
|
|
6016
6055
|
stylePrompt,
|
|
6017
|
-
preserveShape
|
|
6056
|
+
preserveShape,
|
|
6057
|
+
imageModel
|
|
6018
6058
|
);
|
|
6019
6059
|
if (!stylizeResult.success) {
|
|
6020
6060
|
return {
|