diff --git a/.gitignore b/.gitignore index eb28f81..cfa1541 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ backups/ # Local database files mongodb_data/ +photo-gallery-app/backend/uploads/ diff --git a/photo-gallery-app/backend/scripts/reprocess_uploads.js b/photo-gallery-app/backend/scripts/reprocess_uploads.js new file mode 100644 index 0000000..089020e --- /dev/null +++ b/photo-gallery-app/backend/scripts/reprocess_uploads.js @@ -0,0 +1,183 @@ +#!/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); +});