/ clis / gemini / image.js
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  });