feat: replace text watermark with BPB logo SVG overlay on gallery uploads

Loads bpb-watermark.svg at startup, converts black fills to semi-transparent
white (35% opacity), and composites it centered at 45% image width over every
uploaded photo. Removes the old diagonal "BEACH PARTY BALLOONS" text overlay.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chris 2026-05-20 15:39:03 -04:00
parent 92cf44e5f5
commit 2723a6d954
2 changed files with 90 additions and 42 deletions

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 55 KiB

View File

@ -40,6 +40,19 @@ const VARIANTS = {
thumb: { size: 640, quality: 76, suffix: '-sm' }, thumb: { size: 640, quality: 76, suffix: '-sm' },
}; };
// Watermark logo — loaded once at startup, black fills replaced with semi-transparent white
let WM_LOGO_BUF = null;
(function loadWatermarkLogo() {
const wmPath = path.join(__dirname, '..', 'bpb-watermark.svg');
try {
const raw = fs.readFileSync(wmPath, 'utf8');
const styled = raw.replace(/style="fill:#000000"/g, 'style="fill:#ffffff;fill-opacity:0.35"');
WM_LOGO_BUF = Buffer.from(styled);
} catch (e) {
console.warn('[watermark] bpb-watermark.svg not found — watermark disabled.');
}
}());
const HEIF_BRANDS = new Set([ const HEIF_BRANDS = new Set([
'heic', 'heix', 'hevc', 'heim', 'heis', 'hevm', 'hevs', 'mif1', 'msf1', 'avif', 'avis' 'heic', 'heix', 'hevc', 'heim', 'heis', 'hevm', 'hevs', 'mif1', 'msf1', 'avif', 'avis'
]); ]);
@ -191,48 +204,27 @@ router.route('/upload').post(requireAuth, upload.array('photos'), async (req, re
parseInt(hash.substring(4, 6), 16), parseInt(hash.substring(4, 6), 16),
]; ];
const diagonalOverlay = Buffer.from(`
<svg width="2400" height="2400" viewBox="0 0 2400 2400" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="diagGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="rgba(255,255,255,0.22)" />
<stop offset="50%" stop-color="rgba(255,255,255,0.33)" />
<stop offset="100%" stop-color="rgba(255,255,255,0.22)" />
</linearGradient>
</defs>
<g transform="translate(1200 1200) rotate(-32)">
<text x="0" y="-80" text-anchor="middle" dominant-baseline="middle"
fill="url(#diagGrad)" stroke="rgba(0,0,0,0.16)" stroke-width="8"
font-family="Arial Black, Arial, sans-serif" font-size="260" letter-spacing="6" textLength="1800" lengthAdjust="spacingAndGlyphs">
BEACH PARTY
<tspan x="0" dy="280">BALLOONS</tspan>
</text>
</g>
</svg>
`);
let buffer; let buffer;
try { try {
// Prepare base image first so we know its post-resize dimensions, then scale overlay slightly smaller to avoid size conflicts
const base = sharp(inputBuffer) const base = sharp(inputBuffer)
.rotate() .rotate()
.resize({ width: VARIANTS.main.size, height: VARIANTS.main.size, fit: 'inside', withoutEnlargement: true }) .resize({ width: VARIANTS.main.size, height: VARIANTS.main.size, fit: 'inside', withoutEnlargement: true })
.toColorspace('srgb'); .toColorspace('srgb');
const { data: baseBuffer, info } = await base.toBuffer({ resolveWithObject: true }); 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);
// Scale the diagonal overlay to slightly smaller than the image to ensure it composites cleanly const compositeInputs = [];
const overlayBuffer = await sharp(diagonalOverlay, { density: 300 }) if (WM_LOGO_BUF) {
.resize({ width: targetWidth, height: targetHeight, fit: 'cover' }) const wmW = Math.max(Math.round(info.width * 0.45), 150);
const wmBuf = await sharp(WM_LOGO_BUF, { density: 300 })
.resize({ width: wmW, fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
.png() .png()
.toBuffer(); .toBuffer();
compositeInputs.push({ input: wmBuf, gravity: 'center' });
}
buffer = await sharp(baseBuffer) buffer = await sharp(baseBuffer)
.composite([ .composite(compositeInputs)
{ input: overlayBuffer, gravity: 'center' },
])
.toFormat('webp', { quality: VARIANTS.main.quality, effort: 5 }) .toFormat('webp', { quality: VARIANTS.main.quality, effort: 5 })
.toBuffer(); .toBuffer();
} catch (err) { } catch (err) {
@ -247,19 +239,19 @@ router.route('/upload').post(requireAuth, upload.array('photos'), async (req, re
.toColorspace('srgb'); .toColorspace('srgb');
const { data: baseBufferRetry, info: infoRetry } = await baseRetry.toBuffer({ resolveWithObject: true }); const { data: baseBufferRetry, info: infoRetry } = await baseRetry.toBuffer({ resolveWithObject: true });
const overlayRetry = await sharp(diagonalOverlay, { density: 300 })
.resize({ const compositeRetry = [];
width: Math.max(Math.floor((infoRetry.width || VARIANTS.main.size) * 0.98), 1), if (WM_LOGO_BUF) {
height: Math.max(Math.floor((infoRetry.height || VARIANTS.main.size) * 0.98), 1), const wmWRetry = Math.max(Math.round(infoRetry.width * 0.45), 150);
fit: 'cover' const wmBufRetry = await sharp(WM_LOGO_BUF, { density: 300 })
}) .resize({ width: wmWRetry, fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
.png() .png()
.toBuffer(); .toBuffer();
compositeRetry.push({ input: wmBufRetry, gravity: 'center' });
}
buffer = await sharp(baseBufferRetry) buffer = await sharp(baseBufferRetry)
.composite([ .composite(compositeRetry)
{ input: overlayRetry, gravity: 'center' },
])
.toFormat('webp', { quality: VARIANTS.main.quality, effort: 5 }) .toFormat('webp', { quality: VARIANTS.main.quality, effort: 5 })
.toBuffer(); .toBuffer();
} catch (secondErr) { } catch (secondErr) {