const https = require('https'); const http = require('http'); const { createCanvas, loadImage } = require('@napi-rs/canvas'); const sharp = require('sharp'); const { uploadPhotoWallPhotoToS3, getEventPhotoBufferFromS3, } = require('./s3EventPhotos'); const { normalizeFrameImageLayout, normalizeFrameLayout } = require('./photoWallFrameLayout'); function fetchUrlBuffer(url) { return new Promise((resolve, reject) => { const lib = String(url).startsWith('https') ? https : http; const req = lib.get(url, { timeout: 20000 }, (res) => { if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { fetchUrlBuffer(res.headers.location).then(resolve, reject); return; } if (res.statusCode !== 200) { reject(new Error(`HTTP ${res.statusCode} fetching image`)); res.resume(); return; } const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => resolve(Buffer.concat(chunks))); }); req.on('error', reject); req.on('timeout', () => { req.destroy(); reject(new Error('Image fetch timeout')); }); }); } async function loadImageFromUrlOrKey(url, s3Key) { let buf = null; if (s3Key) { try { buf = await getEventPhotoBufferFromS3(s3Key); } catch { buf = null; } } if (!buf && url) { buf = await fetchUrlBuffer(String(url).trim()); } if (!buf?.length) throw new Error('Could not load image'); try { return await loadImage(buf); } catch { const png = await sharp(buf).png().toBuffer(); return loadImage(png); } } function clipPhotoPath(ctx, boxX, boxY, boxW, boxH, shape, borderRadiusPx) { ctx.beginPath(); if (shape === 'circle') { const r = Math.min(boxW, boxH) / 2; const cx = boxX + boxW / 2; const cy = boxY + boxH / 2; ctx.arc(cx, cy, r, 0, Math.PI * 2); } else if (shape === 'rounded') { const r = Math.min(borderRadiusPx || 24, boxW / 2, boxH / 2); const x = boxX; const y = boxY; const w = boxW; const h = boxH; ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } else { ctx.rect(boxX, boxY, boxW, boxH); } } function coverDraw(ctx, img, boxX, boxY, boxW, boxH, shape = 'rect', borderRadiusPx = 24) { const scale = Math.max(boxW / img.width, boxH / img.height); const dw = img.width * scale; const dh = img.height * scale; const dx = boxX + (boxW - dw) / 2; const dy = boxY + (boxH - dh) / 2; ctx.save(); clipPhotoPath(ctx, boxX, boxY, boxW, boxH, shape, borderRadiusPx); ctx.clip(); ctx.drawImage(img, dx, dy, dw, dh); ctx.restore(); } function formDataPlain(formData) { if (!formData) return {}; if (formData instanceof Map) return Object.fromEntries(formData); if (typeof formData === 'object') return formData; return {}; } function resolveText(el, photo) { const prefix = el.prefix != null ? String(el.prefix) : ''; const postfix = el.postfix != null ? String(el.postfix) : ''; let base = ''; if (el.kind === 'custom_text') { base = String(el.text || '').trim(); } else if (el.field === 'name') { base = photo.displayName ? String(photo.displayName).trim() : ''; } else if (el.field === 'form' && el.fieldKey) { const data = formDataPlain(photo.formData); const v = data[el.fieldKey]; base = v == null ? '' : String(v).trim(); } if (!base) return ''; return `${prefix}${base}${postfix}`; } function drawText(ctx, el, text, canvasW) { if (!text) return; const boxX = Number(el.x) || 0; const boxY = Number(el.y) || 0; const boxW = el.w != null ? Number(el.w) : Math.max(40, canvasW - boxX); let fontSize = el.fontSizePx != null ? Number(el.fontSizePx) : 36; fontSize = Math.max(8, fontSize); const family = el.fontFamily || 'sans-serif'; const weight = el.fontWeight === 'bold' ? 'bold' : 'normal'; const color = el.color || '#ffffff'; const align = el.textAlign || 'center'; ctx.fillStyle = color; ctx.textBaseline = 'top'; ctx.textAlign = align === 'left' ? 'left' : align === 'right' ? 'right' : 'center'; const shrink = el.shrinkToFit !== false; ctx.font = `${weight} ${fontSize}px ${family}`; if (shrink) { while (fontSize > 8 && ctx.measureText(text).width > boxW * 0.98) { fontSize -= 1; ctx.font = `${weight} ${fontSize}px ${family}`; } } let tx = boxX + boxW / 2; if (align === 'left') tx = boxX; if (align === 'right') tx = boxX + boxW; ctx.fillText(text, tx, boxY, boxW); } async function drawFrameBackground(ctx, wall, canvasW, canvasH) { const url = String(wall.frameImageUrl || '').trim(); if (!url) { ctx.fillStyle = '#1a1d24'; ctx.fillRect(0, 0, canvasW, canvasH); return; } try { const img = await loadImageFromUrlOrKey(url, null); const layout = normalizeFrameImageLayout(wall.frameImageLayout); const boxX = (layout.x / 100) * canvasW; const boxY = (layout.y / 100) * canvasH; const boxW = (layout.w / 100) * canvasW; const boxH = (layout.h / 100) * canvasH; coverDraw(ctx, img, boxX, boxY, boxW, boxH, 'rect'); } catch (err) { console.error('[photoWallFrame] background failed', err?.message); ctx.fillStyle = '#1a1d24'; ctx.fillRect(0, 0, canvasW, canvasH); } } /** * Composite framed image for a photo wall entry. * @returns {{ framedUrl: string, framedS3Key: string }} */ async function renderAndUploadFramedPhoto({ wall, photo }) { if (!wall?.frameEnabled) { throw new Error('Frame not enabled'); } const canvasW = Math.min(4096, Math.max(200, Math.round(Number(wall.frameCanvasWidth) || 1080))); const canvasH = Math.min(4096, Math.max(200, Math.round(Number(wall.frameCanvasHeight) || 1080))); const canvas = createCanvas(canvasW, canvasH); const ctx = canvas.getContext('2d'); await drawFrameBackground(ctx, wall, canvasW, canvasH); const layout = normalizeFrameLayout(wall.frameLayout, canvasW, canvasH); let selfieImg = null; try { selfieImg = await loadImageFromUrlOrKey(photo.url, photo.s3Key); } catch (err) { console.error('[photoWallFrame] selfie load failed', err?.message); } for (const el of layout) { try { if (el.kind === 'field' && el.field === 'photo') { if (!selfieImg) continue; const boxX = Number(el.x) || 0; const boxY = Number(el.y) || 0; const boxW = el.w != null ? Number(el.w) : Math.round(canvasW * 0.4); const boxH = el.h != null ? Number(el.h) : Math.round(canvasH * 0.4); coverDraw( ctx, selfieImg, boxX, boxY, boxW, boxH, el.photoShape || 'rect', el.borderRadiusPx ?? 24, ); continue; } if (el.kind === 'custom_image' && el.imageUrl) { const img = await loadImageFromUrlOrKey(el.imageUrl, null); const boxX = Number(el.x) || 0; const boxY = Number(el.y) || 0; const boxW = el.w != null ? Number(el.w) : Math.round(canvasW * 0.2); const boxH = el.h != null ? Number(el.h) : Math.round(canvasH * 0.2); coverDraw( ctx, img, boxX, boxY, boxW, boxH, el.photoShape || 'rect', el.borderRadiusPx ?? 24, ); continue; } const text = resolveText(el, photo); if (text) drawText(ctx, el, text, canvasW); } catch (err) { console.error('[photoWallFrame] element failed', el.id, err?.message); } } const pngBuf = canvas.toBuffer('image/png'); const jpegBuf = await sharp(pngBuf).jpeg({ quality: 90 }).toBuffer(); const filename = `framed-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.jpg`; const uploaded = await uploadPhotoWallPhotoToS3({ buffer: jpegBuf, contentType: 'image/jpeg', eventId: String(wall.event), filename, framed: true, }); return { framedUrl: uploaded.url, framedS3Key: uploaded.s3Key }; } module.exports = { renderAndUploadFramedPhoto, };