← back to Letsbegin

croppy_glm.js

756 lines

#!/usr/bin/env node
/**
 * Croppy GLM — Smart Image Cropper for Glitter Walls GLM-* Products
 *
 * Uses Gemini Vision to detect vendor logos, header/footer bars, and
 * repeating patterns, then intelligently crops images before uploading
 * back to Shopify.
 *
 * Flow per product:
 *   1. Download image
 *   2. Gemini Vision analysis (header/footer detection, repeat detection)
 *   3. Crop header/footer if detected
 *   4. Square-crop from bottom-up if repeating pattern
 *   5. Upload cropped image, set position 1, delete old, update alt tag
 *
 * Usage: node croppy_glm.js [--dry-run] [--start-at=INDEX]
 */

const https = require('https');
const http = require('http');
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');

// ── Config ──────────────────────────────────────────────────────────────────
const STORE = 'designer-laboratory-sandbox.myshopify.com';
const TOKEN = process.env.SHOPIFY_PRODUCT_TOKEN;
const API_VER = '2024-10';
const LOG_FILE = '/tmp/croppy_glm.log';
const TMP_DIR = '/tmp/croppy_glm_tmp';
const GEMINI_KEY = process.env.GEMINI_API_KEY;
if (!GEMINI_KEY) {
  throw new Error('GEMINI_API_KEY environment variable is required');
}
if (!TOKEN) {
  throw new Error('SHOPIFY_PRODUCT_TOKEN environment variable is required');
}
const GEMINI_MODEL = 'gemini-2.0-flash';
const GEMINI_HOST = 'generativelanguage.googleapis.com';
const DRY_RUN = process.argv.includes('--dry-run');
const START_AT = (() => {
  const arg = process.argv.find(a => a.startsWith('--start-at='));
  return arg ? parseInt(arg.split('=')[1], 10) : 0;
})();

// Ensure tmp dir exists
if (!fs.existsSync(TMP_DIR)) fs.mkdirSync(TMP_DIR, { recursive: true });

// ── Logging ─────────────────────────────────────────────────────────────────
function log(msg) {
  const line = `[${new Date().toISOString()}] ${msg}`;
  console.log(line);
  fs.appendFileSync(LOG_FILE, line + '\n');
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// ── Shopify GraphQL (native https) ──────────────────────────────────────────
function gql(query, variables) {
  return new Promise((resolve, reject) => {
    const body = JSON.stringify({ query, variables });
    const req = https.request({
      hostname: STORE,
      path: `/admin/api/${API_VER}/graphql.json`,
      method: 'POST',
      headers: {
        'X-Shopify-Access-Token': TOKEN,
        'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(body),
      },
    }, (res) => {
      let data = '';
      res.on('data', (chunk) => data += chunk);
      res.on('end', () => {
        try {
          resolve(JSON.parse(data));
        } catch (e) {
          reject(new Error(`JSON parse error: ${data.slice(0, 300)}`));
        }
      });
    });
    req.on('error', reject);
    req.setTimeout(30000, () => { req.destroy(); reject(new Error('GraphQL request timeout')); });
    req.write(body);
    req.end();
  });
}

// ── Shopify REST API (native https) ─────────────────────────────────────────
function shopifyRest(method, endpoint, body) {
  return new Promise((resolve, reject) => {
    const payload = body ? JSON.stringify(body) : null;
    const headers = {
      'X-Shopify-Access-Token': TOKEN,
      'Content-Type': 'application/json',
    };
    if (payload) headers['Content-Length'] = Buffer.byteLength(payload);

    const req = https.request({
      hostname: STORE,
      path: `/admin/api/${API_VER}${endpoint}`,
      method,
      headers,
    }, (res) => {
      let data = '';
      res.on('data', (chunk) => data += chunk);
      res.on('end', () => {
        try {
          resolve({ status: res.statusCode, headers: res.headers, data: data ? JSON.parse(data) : {} });
        } catch (e) {
          resolve({ status: res.statusCode, headers: res.headers, data: data });
        }
      });
    });
    req.on('error', reject);
    req.setTimeout(30000, () => { req.destroy(); reject(new Error('REST request timeout')); });
    if (payload) req.write(payload);
    req.end();
  });
}

// ── Download file following redirects ───────────────────────────────────────
function download(url, dest) {
  return new Promise((resolve, reject) => {
    const mod = url.startsWith('https') ? https : http;
    const file = fs.createWriteStream(dest);
    mod.get(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }, (res) => {
      if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
        file.close();
        try { fs.unlinkSync(dest); } catch (_) {}
        return download(res.headers.location, dest).then(resolve).catch(reject);
      }
      if (res.statusCode !== 200) {
        file.close();
        return reject(new Error(`HTTP ${res.statusCode} downloading ${url}`));
      }
      res.pipe(file);
      file.on('finish', () => { file.close(); resolve(); });
    }).on('error', (e) => { file.close(); reject(e); });
  });
}

// ── ImageMagick helpers ─────────────────────────────────────────────────────
function getImageDimensions(filePath) {
  // Use [0] to get ONLY the first frame dimensions (important for animated GIFs
  // which otherwise report stacked height of all frames combined)
  const output = execFileSync('identify', ['-format', '%wx%h', `${filePath}[0]`], {
    timeout: 10000,
    encoding: 'utf8',
  }).trim();
  const firstFrame = output.split('\n')[0].split(/\s/)[0];
  const [w, h] = firstFrame.split('x').map(Number);
  return { width: w, height: h };
}

function isAnimatedGif(filePath) {
  try {
    const output = execFileSync('identify', [filePath], {
      timeout: 10000,
      encoding: 'utf8',
    }).trim();
    // Multiple lines = multiple frames = animated GIF
    return output.split('\n').length > 1;
  } catch (_) {
    return false;
  }
}

function cropImage(inputPath, outputPath, width, height, xOffset, yOffset) {
  // For animated GIFs, extract only the first frame first, then crop
  // This avoids ImageMagick trying to process all frames
  const isGif = inputPath.toLowerCase().endsWith('.gif') || isAnimatedGif(inputPath);

  if (isGif) {
    // Extract first frame only, convert to JPG, then crop
    const firstFrameFile = outputPath.replace(/\.[^.]+$/, '_frame0.jpg');
    execFileSync('convert', [
      `${inputPath}[0]`,
      '-quality', '93',
      firstFrameFile,
    ], { timeout: 30000 });

    // Now crop the single frame
    execFileSync('convert', [
      firstFrameFile,
      '-crop', `${width}x${height}+${xOffset}+${yOffset}`,
      '+repage',
      '-quality', '93',
      outputPath,
    ], { timeout: 30000 });

    // Cleanup intermediate file
    try { fs.unlinkSync(firstFrameFile); } catch (_) {}
  } else {
    execFileSync('convert', [
      inputPath,
      '-crop', `${width}x${height}+${xOffset}+${yOffset}`,
      '+repage',
      '-quality', '93',
      outputPath,
    ], { timeout: 60000 });
  }
}

// ── Gemini Vision API (native https — avoids ENOBUFS) ───────────────────────
function geminiAnalyze(imageFilePath) {
  return new Promise((resolve, reject) => {
    // Read image and convert to base64
    const imageBuffer = fs.readFileSync(imageFilePath);
    const base64Image = imageBuffer.toString('base64');

    // Determine MIME type
    const ext = path.extname(imageFilePath).toLowerCase();
    let mimeType = 'image/jpeg';
    if (ext === '.png') mimeType = 'image/png';
    else if (ext === '.webp') mimeType = 'image/webp';

    const prompt = `Analyze this wallcovering product image. Answer these 3 questions in JSON only:
1. Does it have a vendor logo, header text, footer text, or info bar at the top or bottom? (yes/no, and which: top/bottom/both)
2. What percentage of the image height is the header/footer that should be cropped? (0 if none)
3. Does this wallcovering have a clearly repeating pattern? (yes/no)
Return ONLY JSON: {"hasHeader": true/false, "headerPosition": "top"/"bottom"/"both"/"none", "cropPercent": 0-20, "hasRepeat": true/false}`;

    const requestBody = JSON.stringify({
      contents: [{
        parts: [
          {
            inlineData: {
              mimeType,
              data: base64Image,
            },
          },
          {
            text: prompt,
          },
        ],
      }],
      generationConfig: {
        temperature: 0.1,
        maxOutputTokens: 256,
      },
    });

    const req = https.request({
      hostname: GEMINI_HOST,
      path: `/v1beta/models/${GEMINI_MODEL}:generateContent?key=${GEMINI_KEY}`,
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Content-Length': Buffer.byteLength(requestBody),
      },
    }, (res) => {
      let data = '';
      res.on('data', (chunk) => data += chunk);
      res.on('end', () => {
        try {
          const parsed = JSON.parse(data);
          if (parsed.error) {
            return reject(new Error(`Gemini API error: ${parsed.error.message || JSON.stringify(parsed.error)}`));
          }
          const textContent = parsed?.candidates?.[0]?.content?.parts?.[0]?.text || '';
          // Extract JSON from response (may be wrapped in ```json ... ```)
          const jsonMatch = textContent.match(/\{[\s\S]*?\}/);
          if (!jsonMatch) {
            return reject(new Error(`No JSON in Gemini response: ${textContent.slice(0, 200)}`));
          }
          const result = JSON.parse(jsonMatch[0]);
          resolve(result);
        } catch (e) {
          reject(new Error(`Gemini parse error: ${e.message} | raw: ${data.slice(0, 300)}`));
        }
      });
    });

    req.on('error', reject);
    req.setTimeout(60000, () => { req.destroy(); reject(new Error('Gemini request timeout')); });
    req.write(requestBody);
    req.end();
  });
}

// ── Shopify: Staged Upload ──────────────────────────────────────────────────
async function stagedUpload(filePath, filename) {
  const fileSize = fs.statSync(filePath).size;
  const mimeType = filename.endsWith('.png') ? 'image/png' : 'image/jpeg';

  const mutation = `mutation {
    stagedUploadsCreate(input: [{
      resource: IMAGE,
      filename: "${filename}",
      mimeType: "${mimeType}",
      httpMethod: POST,
      fileSize: "${fileSize}"
    }]) {
      stagedTargets {
        url
        parameters { name value }
        resourceUrl
      }
      userErrors { message }
    }
  }`;

  const res = await gql(mutation);
  const target = res?.data?.stagedUploadsCreate?.stagedTargets?.[0];
  if (!target) {
    throw new Error(`Staged upload failed: ${JSON.stringify(res?.data?.stagedUploadsCreate?.userErrors || res?.errors)}`);
  }

  // Upload via curl (multipart form upload — curl is fine for single large uploads)
  const curlArgs = ['-s', '-X', 'POST', target.url];
  for (const param of target.parameters) {
    curlArgs.push('-F', `${param.name}=${param.value}`);
  }
  curlArgs.push('-F', `file=@${filePath}`);
  execFileSync('curl', curlArgs, { timeout: 120000 });

  return target.resourceUrl;
}

// ── Shopify: Create media on product ────────────────────────────────────────
async function createMedia(productGid, resourceUrl, altText) {
  const safeAlt = altText.replace(/"/g, '\\"').replace(/\n/g, ' ');
  const mutation = `mutation {
    productCreateMedia(productId: "${productGid}", media: [{
      originalSource: "${resourceUrl}",
      mediaContentType: IMAGE,
      alt: "${safeAlt}"
    }]) {
      media {
        id
      }
      mediaUserErrors {
        message
      }
    }
  }`;

  const res = await gql(mutation);
  const errors = res?.data?.productCreateMedia?.mediaUserErrors;
  if (errors && errors.length > 0) {
    throw new Error(`Create media error: ${errors.map(e => e.message).join(', ')}`);
  }
  const media = res?.data?.productCreateMedia?.media;
  if (!media || media.length === 0) {
    throw new Error(`No media returned: ${JSON.stringify(res)}`);
  }
  return media[0].id;
}

// ── Shopify: Wait for media READY ───────────────────────────────────────────
async function waitForMediaReady(productGid, mediaGid, maxWait = 45000) {
  const start = Date.now();
  while (Date.now() - start < maxWait) {
    const query = `{
      product(id: "${productGid}") {
        media(first: 20) {
          edges {
            node {
              ... on MediaImage {
                id
                status
              }
            }
          }
        }
      }
    }`;
    const res = await gql(query);
    const edges = res?.data?.product?.media?.edges || [];
    for (const edge of edges) {
      if (edge.node.id === mediaGid) {
        if (edge.node.status === 'READY') return true;
        if (edge.node.status === 'FAILED') return false;
      }
    }
    await sleep(2000);
  }
  return false;
}

// ── Shopify: Reorder media ─────────────────────────────────────────────────
async function reorderMedia(productGid, allMediaIds) {
  const moves = allMediaIds.map((id, idx) => `{id: "${id}", newPosition: "${idx}"}`);
  const mutation = `mutation {
    productReorderMedia(id: "${productGid}", moves: [${moves.join(',')}]) {
      mediaUserErrors {
        message
      }
    }
  }`;

  const res = await gql(mutation);
  const errors = res?.data?.productReorderMedia?.mediaUserErrors;
  if (errors && errors.length > 0) {
    throw new Error(`Reorder error: ${errors.map(e => e.message).join(', ')}`);
  }
}

// ── Shopify: Delete media ──────────────────────────────────────────────────
async function deleteMedia(productGid, mediaIds) {
  const idsStr = mediaIds.map(id => `"${id}"`).join(',');
  const mutation = `mutation {
    productDeleteMedia(productId: "${productGid}", mediaIds: [${idsStr}]) {
      deletedMediaIds
      mediaUserErrors {
        message
      }
    }
  }`;

  const res = await gql(mutation);
  const errors = res?.data?.productDeleteMedia?.mediaUserErrors;
  if (errors && errors.length > 0) {
    throw new Error(`Delete media error: ${errors.map(e => e.message).join(', ')}`);
  }
  return res?.data?.productDeleteMedia?.deletedMediaIds || [];
}

// ── Shopify: Update product alt text for image via REST ─────────────────────
async function updateImageAlt(productId, imageId, altText) {
  const numericProductId = String(productId).replace(/\D/g, '') || productId;
  const numericImageId = String(imageId).replace(/\D/g, '') || imageId;
  const result = await shopifyRest('PUT', `/products/${numericProductId}/images/${numericImageId}.json`, {
    image: { id: parseInt(numericImageId), alt: altText },
  });
  return result;
}

// ── Fetch ALL Glitter Walls products via GraphQL pagination ─────────────────
async function fetchAllProducts() {
  const products = [];
  let cursor = null;
  let page = 0;

  while (true) {
    page++;
    const afterClause = cursor ? `, after: "${cursor}"` : '';
    const query = `{
      products(first: 50, query: "vendor:'Glitter Walls'"${afterClause}) {
        edges {
          cursor
          node {
            id
            title
            variants(first: 5) {
              edges {
                node {
                  sku
                }
              }
            }
            media(first: 10) {
              edges {
                node {
                  ... on MediaImage {
                    id
                    image {
                      url
                      width
                      height
                    }
                  }
                }
              }
            }
          }
        }
        pageInfo {
          hasNextPage
        }
      }
    }`;

    const res = await gql(query);
    if (res.errors) {
      log(`GraphQL error on page ${page}: ${JSON.stringify(res.errors)}`);
      break;
    }

    const edges = res?.data?.products?.edges || [];
    for (const edge of edges) {
      products.push(edge.node);
      cursor = edge.cursor;
    }

    log(`Fetched page ${page}: ${edges.length} products (total: ${products.length})`);

    if (!res?.data?.products?.pageInfo?.hasNextPage) break;
    await sleep(400);
  }

  return products;
}

// ── Determine crop parameters from Gemini analysis ──────────────────────────
function determineCrop(geminiResult, imgWidth, imgHeight) {
  const { hasHeader, headerPosition, cropPercent, hasRepeat } = geminiResult;
  const isSquare = Math.abs(imgWidth - imgHeight) <= (imgWidth * 0.05); // within 5%

  // Case 5: Already square and no header → SKIP
  if (isSquare && !hasHeader) {
    return { action: 'skip', reason: 'already square, no header' };
  }

  let cropTop = 0;
  let cropBottom = 0;

  // Case 3: Has header/footer → crop it off
  if (hasHeader && cropPercent > 0) {
    const cropPixels = Math.round(imgHeight * (cropPercent / 100));

    if (headerPosition === 'top') {
      cropTop = cropPixels;
    } else if (headerPosition === 'bottom') {
      cropBottom = cropPixels;
    } else if (headerPosition === 'both') {
      // Split evenly between top and bottom
      cropTop = Math.round(cropPixels / 2);
      cropBottom = Math.round(cropPixels / 2);
    }
  }

  // After header/footer removal, calculate remaining dimensions
  const remainingHeight = imgHeight - cropTop - cropBottom;
  const yOffset = cropTop;

  // Now determine if we need to square-crop
  if (remainingHeight <= imgWidth) {
    // Already shorter than or equal to width after header crop
    if (cropTop === 0 && cropBottom === 0) {
      return { action: 'skip', reason: 'wider than tall, no header to crop' };
    }
    return {
      action: 'crop',
      reason: `crop header/footer (${headerPosition}, ${cropPercent}%)`,
      width: imgWidth,
      height: remainingHeight,
      xOffset: 0,
      yOffset,
    };
  }

  // Image is still taller than wide after header removal
  if (hasHeader || hasRepeat) {
    // Crop from bottom-up to make square (width x width)
    const finalHeight = imgWidth;
    // yOffset stays at cropTop (keep top content, cut from bottom)
    return {
      action: 'crop',
      reason: hasHeader
        ? `crop header/footer (${headerPosition}, ${cropPercent}%) + square from bottom`
        : 'repeating pattern, square from bottom',
      width: imgWidth,
      height: finalHeight,
      xOffset: 0,
      yOffset,
    };
  }

  // Case 4b: No header, no repeat → leave as-is
  return { action: 'skip', reason: 'no header, no repeat, leave as-is' };
}

// ── Main ────────────────────────────────────────────────────────────────────
async function main() {
  log('');
  log('='.repeat(70));
  log('  CROPPY GLM — Smart Image Cropper for Glitter Walls');
  log(`  Mode: ${DRY_RUN ? 'DRY RUN (no changes)' : 'LIVE'}${START_AT > 0 ? ` | Starting at index ${START_AT}` : ''}`);
  log('='.repeat(70));
  log('');

  // 1. Fetch all Glitter Walls products
  log('Fetching all Glitter Walls products from Shopify...');
  const allProducts = await fetchAllProducts();
  log(`Total Glitter Walls products: ${allProducts.length}`);

  // 2. Filter to ALL GLM-* SKU products (including SAMPLE variants)
  const glmProducts = allProducts.filter(p => {
    const sku = p.variants?.edges?.[0]?.node?.sku || '';
    return sku.startsWith('GLM-');
  });
  log(`GLM-* products (all): ${glmProducts.length}`);

  if (glmProducts.length === 0) {
    log('FATAL: No GLM-* products found. Exiting.');
    return;
  }

  // Stats
  let cropped = 0, skipped = 0, skippedNoImage = 0, errors = 0, geminiErrors = 0;
  const startIdx = Math.max(0, START_AT);

  for (let i = startIdx; i < glmProducts.length; i++) {
    const product = glmProducts[i];
    const sku = product.variants?.edges?.[0]?.node?.sku || 'NO_SKU';
    const shortTitle = product.title.slice(0, 60);
    const tag = `[${i + 1}/${glmProducts.length}]`;

    // Get first MediaImage
    const mediaEdges = product.media?.edges || [];
    const firstImage = mediaEdges.find(e => e.node.image);
    if (!firstImage) {
      log(`${tag} ${sku} — NO IMAGE, skip`);
      skippedNoImage++;
      continue;
    }

    const imgNode = firstImage.node;
    const imgUrl = imgNode.image.url;
    const origW = imgNode.image.width;
    const origH = imgNode.image.height;
    const oldMediaId = imgNode.id;

    // Detect file extension from URL
    const urlExt = imgUrl.match(/\.(gif|png|webp|jpg|jpeg)/i)?.[1]?.toLowerCase() || 'jpg';
    const inputExt = urlExt === 'jpeg' ? 'jpg' : urlExt;
    const inputFile = path.join(TMP_DIR, `input_${i}.${inputExt}`);
    const croppedFile = path.join(TMP_DIR, `cropped_${i}.jpg`); // Always output as JPG

    try {
      // ── Step 1: Download image ──
      await download(imgUrl, inputFile);
      const dims = getImageDimensions(inputFile);

      // ── Step 2: Gemini Vision analysis ──
      let geminiResult;
      try {
        geminiResult = await geminiAnalyze(inputFile);
        log(`${tag} ${sku} — Gemini: header=${geminiResult.hasHeader}, pos=${geminiResult.headerPosition}, crop=${geminiResult.cropPercent}%, repeat=${geminiResult.hasRepeat}`);
      } catch (gemErr) {
        log(`${tag} ${sku} — Gemini FAILED: ${gemErr.message}. Falling back to simple analysis.`);
        geminiErrors++;
        // Fallback: no header, check if likely repeating based on aspect ratio
        const ratio = dims.width / dims.height;
        geminiResult = {
          hasHeader: false,
          headerPosition: 'none',
          cropPercent: 0,
          hasRepeat: ratio < 0.85, // tall images likely have repeating patterns
        };
      }

      // ── Step 3: Determine crop action ──
      const cropAction = determineCrop(geminiResult, dims.width, dims.height);

      if (cropAction.action === 'skip') {
        log(`${tag} ${sku} — SKIP: ${cropAction.reason} (${dims.width}x${dims.height})`);
        skipped++;
        if (fs.existsSync(inputFile)) fs.unlinkSync(inputFile);
        await sleep(500);
        continue;
      }

      // ── Step 4: Crop the image ──
      log(`${tag} ${sku} — CROP: ${cropAction.reason} | ${dims.width}x${dims.height} → ${cropAction.width}x${cropAction.height} (offset +${cropAction.xOffset}+${cropAction.yOffset})`);

      if (DRY_RUN) {
        log(`${tag} ${sku} — DRY RUN: would crop and upload`);
        skipped++;
        if (fs.existsSync(inputFile)) fs.unlinkSync(inputFile);
        await sleep(500);
        continue;
      }

      cropImage(inputFile, croppedFile, cropAction.width, cropAction.height, cropAction.xOffset, cropAction.yOffset);

      // Verify cropped dimensions
      const croppedDims = getImageDimensions(croppedFile);
      log(`${tag} ${sku} — Cropped file: ${croppedDims.width}x${croppedDims.height}`);

      // ── Step 5: Staged upload ──
      const ext = imgUrl.includes('.png') ? '.png' : '.jpg';
      const filename = `croppy_${sku.replace(/[^a-zA-Z0-9-]/g, '_')}${ext}`;
      const resourceUrl = await stagedUpload(croppedFile, filename);
      await sleep(500);

      // ── Step 6: Create media with updated alt text ──
      const titlePart = product.title.split('|')[0].trim();
      const altText = `${sku} ${titlePart} designer-wallcoverings-los-angeles`;
      const newMediaId = await createMedia(product.id, resourceUrl, altText);
      log(`${tag} ${sku} — New media created: ${newMediaId}`);

      // ── Step 7: Wait for media to be READY ──
      const ready = await waitForMediaReady(product.id, newMediaId, 45000);
      if (!ready) {
        log(`${tag} ${sku} — WARNING: Media not READY after 45s, proceeding anyway`);
      }

      // ── Step 8: Reorder — new image first ──
      const mediaQuery = `{
        product(id: "${product.id}") {
          media(first: 20) {
            edges {
              node {
                ... on MediaImage { id }
              }
            }
          }
        }
      }`;
      const mediaRes = await gql(mediaQuery);
      const currentMedia = (mediaRes?.data?.product?.media?.edges || [])
        .map(e => e.node.id)
        .filter(id => id);

      const reordered = [newMediaId, ...currentMedia.filter(id => id !== newMediaId && id !== oldMediaId)];
      await reorderMedia(product.id, reordered);
      log(`${tag} ${sku} — Reordered: new image at position 1`);

      // ── Step 9: Delete old image ──
      const deleted = await deleteMedia(product.id, [oldMediaId]);
      log(`${tag} ${sku} — Deleted old image: ${deleted.join(', ')}`);

      cropped++;

      // Cleanup tmp files
      if (fs.existsSync(inputFile)) fs.unlinkSync(inputFile);
      if (fs.existsSync(croppedFile)) fs.unlinkSync(croppedFile);

    } catch (err) {
      log(`${tag} ${sku} — ERROR: ${err.message}`);
      errors++;
      if (fs.existsSync(inputFile)) try { fs.unlinkSync(inputFile); } catch (_) {}
      if (fs.existsSync(croppedFile)) try { fs.unlinkSync(croppedFile); } catch (_) {}
    }

    // Rate limit: 500ms between products, 1200ms extra after Gemini calls (already waited in geminiAnalyze)
    await sleep(500);

    // Extra Gemini cooldown every product (1200ms between Gemini calls)
    await sleep(1200);
  }

  // ── Summary ──
  log('');
  log('='.repeat(70));
  log('  CROPPY GLM — SUMMARY');
  log('='.repeat(70));
  log(`  Total GLM products:     ${glmProducts.length}`);
  log(`  Cropped & uploaded:     ${cropped}`);
  log(`  Skipped (no change):    ${skipped}`);
  log(`  Skipped (no image):     ${skippedNoImage}`);
  log(`  Gemini fallbacks:       ${geminiErrors}`);
  log(`  Errors:                 ${errors}`);
  log(`  Mode:                   ${DRY_RUN ? 'DRY RUN' : 'LIVE'}`);
  log('='.repeat(70));
}

main().catch((e) => {
  log(`FATAL: ${e.message}`);
  console.error(e);
  process.exit(1);
});