EventBuzz_Backend/src/utils/photoWallFrameRender.js

255 lines
8.0 KiB
JavaScript

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,
};