← 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);
});