image.js
1 import * as os from 'node:os'; 2 import * as path from 'node:path'; 3 import { cli, Strategy } from '@jackwener/opencli/registry'; 4 import { saveBase64ToFile } from '@jackwener/opencli/utils'; 5 import { GEMINI_DOMAIN, exportGeminiImages, getGeminiVisibleImageUrls, sendGeminiMessage, startNewGeminiChat, waitForGeminiImages } from './utils.js'; 6 function extFromMime(mime) { 7 if (mime.includes('png')) 8 return '.png'; 9 if (mime.includes('webp')) 10 return '.webp'; 11 if (mime.includes('gif')) 12 return '.gif'; 13 return '.jpg'; 14 } 15 function normalizeBooleanFlag(value) { 16 if (typeof value === 'boolean') 17 return value; 18 const normalized = String(value ?? '').trim().toLowerCase(); 19 return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on'; 20 } 21 function displayPath(filePath) { 22 const home = os.homedir(); 23 return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath; 24 } 25 function buildImagePrompt(prompt, options) { 26 const extras = []; 27 if (options.ratio) 28 extras.push(`aspect ratio ${options.ratio}`); 29 if (options.style) 30 extras.push(`style ${options.style}`); 31 if (extras.length === 0) 32 return prompt; 33 return `${prompt} 34 35 Image requirements: ${extras.join(', ')}.`; 36 } 37 function normalizeRatio(value) { 38 const normalized = value.trim(); 39 const allowed = new Set(['1:1', '16:9', '9:16', '4:3', '3:4', '3:2', '2:3']); 40 return allowed.has(normalized) ? normalized : '1:1'; 41 } 42 async function currentGeminiLink(page) { 43 const url = await page.evaluate('window.location.href').catch(() => ''); 44 return typeof url === 'string' && url ? url : 'https://gemini.google.com/app'; 45 } 46 export const imageCommand = cli({ 47 site: 'gemini', 48 name: 'image', 49 description: 'Generate images with Gemini web and save them locally', 50 domain: GEMINI_DOMAIN, 51 strategy: Strategy.COOKIE, 52 browser: true, 53 navigateBefore: false, 54 defaultFormat: 'plain', 55 timeoutSeconds: 240, 56 args: [ 57 { name: 'prompt', positional: true, required: true, help: 'Image prompt to send to Gemini' }, 58 { name: 'rt', default: '1:1', help: 'Ratio shorthand for aspect ratio (1:1, 16:9, 9:16, 4:3, 3:4, 3:2, 2:3)' }, 59 { name: 'st', default: '', help: 'Style shorthand, e.g. anime, icon, watercolor' }, 60 { name: 'op', default: '~/tmp/gemini-images', help: 'Output directory shorthand' }, 61 { name: 'sd', type: 'boolean', default: false, help: 'Skip download shorthand; only show Gemini page link' }, 62 ], 63 columns: ['status', 'file', 'link'], 64 func: async (page, kwargs) => { 65 const prompt = kwargs.prompt; 66 const ratio = normalizeRatio(String(kwargs.rt ?? '1:1')); 67 const style = String(kwargs.st ?? '').trim(); 68 const outputDir = kwargs.op || path.join(os.homedir(), 'tmp', 'gemini-images'); 69 const timeout = 120; 70 const startFresh = true; 71 const skipDownloadRaw = kwargs.sd; 72 const skipDownload = skipDownloadRaw === '' || skipDownloadRaw === true || normalizeBooleanFlag(skipDownloadRaw); 73 const effectivePrompt = buildImagePrompt(prompt, { 74 ratio, 75 style: style || undefined, 76 }); 77 if (startFresh) 78 await startNewGeminiChat(page); 79 const beforeUrls = await getGeminiVisibleImageUrls(page); 80 await sendGeminiMessage(page, effectivePrompt); 81 const urls = await waitForGeminiImages(page, beforeUrls, timeout); 82 const link = await currentGeminiLink(page); 83 if (!urls.length) { 84 return [{ status: '⚠️ no-images', file: '📁 -', link: `🔗 ${link}` }]; 85 } 86 if (skipDownload) { 87 return [{ status: '🎨 generated', file: '📁 -', link: `🔗 ${link}` }]; 88 } 89 const assets = await exportGeminiImages(page, urls); 90 if (!assets.length) { 91 return [{ status: '⚠️ export-failed', file: '📁 -', link: `🔗 ${link}` }]; 92 } 93 const stamp = Date.now(); 94 const results = []; 95 for (let index = 0; index < assets.length; index += 1) { 96 const asset = assets[index]; 97 const base64 = asset.dataUrl.replace(/^data:[^;]+;base64,/, ''); 98 const suffix = assets.length > 1 ? `_${index + 1}` : ''; 99 const filePath = path.join(outputDir, `gemini_${stamp}${suffix}${extFromMime(asset.mimeType)}`); 100 await saveBase64ToFile(base64, filePath); 101 results.push({ status: '✅ saved', file: `📁 ${displayPath(filePath)}`, link: `🔗 ${link}` }); 102 } 103 return results; 104 }, 105 });