#!/usr/bin/env node /** * Reprocess uploads for existing Photo documents. * * - For each Photo in Mongo, find a matching source image in uploads/. * - Apply the same watermark + resize pipeline used by the upload endpoint. * - Write main/medium/thumb variants (WEBP) and update the Photo doc paths. * - Photos without a matching source file are skipped. * * Usage: * APPLY=1 node scripts/reprocess_uploads.js # actually write files + update docs * node scripts/reprocess_uploads.js # dry run (default) * * Env: * MONGO_URI (optional) - defaults to mongodb://localhost:27017/photogallery */ const fs = require('fs'); const fsPromises = fs.promises; const path = require('path'); const crypto = require('crypto'); const sharp = require('sharp'); const mongoose = require('mongoose'); const Photo = require('../models/photo'); const MONGO_URI = process.env.MONGO_URI || 'mongodb://localhost:27017/photogallery'; const APPLY = process.env.APPLY === '1'; const UPLOAD_DIR = path.join(__dirname, '..', 'uploads'); const VARIANTS = { main: { size: 2000, quality: 82, suffix: '' }, medium: { size: 1200, quality: 80, suffix: '-md' }, thumb: { size: 640, quality: 76, suffix: '-sm' }, }; const diagonalOverlay = Buffer.from(` BEACH PARTY BALLOONS `); const HEIF_BRANDS = new Set(['heic', 'heix', 'hevc', 'heim', 'heis', 'hevm', 'hevs', 'mif1', 'msf1', 'avif', 'avis']); const isHeifBuffer = (buffer) => buffer && buffer.length >= 12 && HEIF_BRANDS.has(buffer.slice(8, 12).toString('ascii').toLowerCase()); function parseBaseName(doc) { const raw = path.basename(doc.filename || doc.path || '', path.extname(doc.filename || doc.path || '')); const match = raw.match(/^(.*?)(-md|-sm)?$/); return match ? match[1] : raw; } function sourceCandidates(doc) { const baseName = parseBaseName(doc); const preferred = []; const fromDocPath = doc.path ? doc.path.replace(/^\/+/, '') : ''; const fromDocFile = doc.filename ? path.join('uploads', doc.filename) : ''; [fromDocPath, fromDocFile] .filter(Boolean) .forEach(rel => preferred.push(path.join(UPLOAD_DIR, rel.replace(/^uploads[\\/]/, '')))); preferred.push( path.join(UPLOAD_DIR, `${baseName}.webp`), path.join(UPLOAD_DIR, `${baseName}-md.webp`), path.join(UPLOAD_DIR, `${baseName}-sm.webp`) ); return preferred; } async function findExistingFile(candidates) { for (const file of candidates) { try { const stat = await fsPromises.stat(file); if (stat.isFile()) return file; } catch (_) { /* ignore missing */ } } return null; } async function stampAndVariants(inputBuffer, baseName) { // Build main stamped image const base = sharp(inputBuffer) .rotate() .resize({ width: VARIANTS.main.size, height: VARIANTS.main.size, fit: 'inside', withoutEnlargement: true }) .toColorspace('srgb'); const { data: baseBuffer, info } = await base.toBuffer({ resolveWithObject: true }); const targetWidth = Math.max(Math.floor((info.width || VARIANTS.main.size) * 0.98), 1); const targetHeight = Math.max(Math.floor((info.height || VARIANTS.main.size) * 0.98), 1); const overlayBuffer = await sharp(diagonalOverlay, { density: 300 }) .resize({ width: targetWidth, height: targetHeight, fit: 'cover' }) .png() .toBuffer(); const stamped = await sharp(baseBuffer) .composite([{ input: overlayBuffer, gravity: 'center' }]) .toFormat('webp', { quality: VARIANTS.main.quality, effort: 5 }) .toBuffer(); const outputs = { main: { filename: `${baseName}${VARIANTS.main.suffix}.webp`, buffer: stamped }, }; const createVariant = async (key, opts) => { const resized = await sharp(stamped) .resize({ width: opts.size, height: opts.size, fit: 'inside', withoutEnlargement: true }) .toFormat('webp', { quality: opts.quality, effort: 5 }) .toBuffer(); outputs[key] = { filename: `${baseName}${opts.suffix}.webp`, buffer: resized }; }; await createVariant('medium', VARIANTS.medium); await createVariant('thumb', VARIANTS.thumb); return outputs; } async function processDoc(doc) { const candidates = sourceCandidates(doc); const sourceFile = await findExistingFile(candidates); if (!sourceFile) { return { status: 'missing-source', docId: doc._id, base: parseBaseName(doc) }; } const inputBuffer = await fsPromises.readFile(sourceFile); const hash = crypto.createHash('sha256').update(inputBuffer).digest('hex'); const outputs = await stampAndVariants(inputBuffer, parseBaseName(doc)); if (APPLY) { for (const { filename, buffer } of Object.values(outputs)) { await fsPromises.writeFile(path.join(UPLOAD_DIR, filename), buffer); } doc.path = path.posix.join('uploads', outputs.main.filename); doc.variants = { medium: path.posix.join('uploads', outputs.medium.filename), thumb: path.posix.join('uploads', outputs.thumb.filename), }; doc.hash = doc.hash || hash; await doc.save(); } return { status: 'processed', docId: doc._id, base: parseBaseName(doc) }; } async function main() { await mongoose.connect(MONGO_URI); console.log(`Connected to Mongo: ${MONGO_URI}`); const docs = await Photo.find({}); console.log(`Found ${docs.length} photo docs. APPLY=${APPLY ? 'yes' : 'no (dry run)'}`); const results = { processed: 0, missing: 0 }; for (const doc of docs) { try { const res = await processDoc(doc); if (res.status === 'processed') results.processed++; else results.missing++; } catch (err) { console.error(`Error processing doc ${doc._id}:`, err.message || err); results.missing++; } } console.log(`Done. Processed: ${results.processed}. Skipped (no source/errors): ${results.missing}.`); await mongoose.disconnect(); } main().catch(err => { console.error(err); process.exit(1); });