255 lines
8.0 KiB
JavaScript
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,
|
|
};
|