import type { ShareMetadata } from './share'; export interface CardSize { width: number; height: number } export interface CardRect { x: number; y: number; width: number; height: number } export const CARD = { width: 1080, height: 1920 } as const; export const CARD_SIZES = { story: CARD, post: { width: 1080, height: 1350 } } as const; /** Reserve the paper caption from the bottom, then give the photo all remaining space. */ export function cardLayout({ width, height }: CardSize) { if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) throw new Error('invalid canvas dimensions'); const scale = Math.min(width / 1080, height / 1350); const rect = (x: number, y: number, w: number, h: number): CardRect => ({ x: x * scale, y, width: w * scale, height: h * scale }); const margin = 44 * scale, inset = 72 * scale; const strip = { x: margin, y: height - 152 * scale, width: width - 2 * margin, height: 100 * scale }; // 110px accommodates the eyebrow and two lines without moving any UI over the photo. const tag = rect(72, strip.y - 150 * scale, 520, 110); const chips = [rect(width / scale - 456, tag.y + 13 * scale, 194, 84), rect(width / scale - 246, tag.y + 13 * scale, 174, 84)]; const caption = { x: inset, y: tag.y - 114 * scale, width: width - 2 * inset, height: 88 * scale }; const photo = { x: margin, y: margin, width: width - 2 * margin, height: caption.y - 44 * scale - margin }; const watermark = rect(width / scale - 224, height - 42 * scale, 200, 30); return { scale, photo, caption, tag, chips, strip, watermark }; } /** Whole source image, one uniform fit scale, no source crop or person detection. */ export function fitPhoto(image: HTMLImageElement, window: CardRect): CardRect { const iw = image.naturalWidth || image.width, ih = image.naturalHeight || image.height; if (iw <= 0 || ih <= 0) throw new Error('invalid image dimensions'); const scale = Math.min(window.width / iw, window.height / ih); const width = iw * scale, height = ih * scale; return { x: window.x + (window.width - width) / 2, y: window.y + (window.height - height) / 2, width, height }; } const FONT = '"Helvetica Neue", Helvetica, Arial, sans-serif'; const normal = (text: string) => text.trim().replace(/\s+/g, ' '); function width(ctx: CanvasRenderingContext2D, text: string, spacing = 0): number { return ctx.measureText(text).width + Math.max(0, Array.from(text).length - 1) * spacing; } export function fitText(ctx: CanvasRenderingContext2D, text: string, max: number, spacing = 0): string { if (width(ctx, text, spacing) <= max) return text; const chars = Array.from(text); while (chars.length && width(ctx, chars.join('') + '…', spacing) > max) chars.pop(); return chars.join('').trimEnd() + '…'; } export function wrapTwoLines(ctx: CanvasRenderingContext2D, value: string, max: number): string[] { const words = normal(value).split(' '); let first = ''; while (words.length && width(ctx, [first, words[0]].filter(Boolean).join(' ')) <= max) { first = [first, words.shift()].filter(Boolean).join(' '); } if (!first) return [fitText(ctx, words.join(' '), max)]; return words.length ? [first, fitText(ctx, words.join(' '), max)] : [first]; } export function drawSpacedText(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, spacing: number): void { if (typeof ctx.letterSpacing === 'string') { const spaced = ctx as CanvasRenderingContext2D & { letterSpacing: string }; spaced.letterSpacing = `${spacing}px`; ctx.fillText(text, x, y); spaced.letterSpacing = '0px'; } else { for (const glyph of Array.from(text)) { ctx.fillText(glyph, x, y); x += ctx.measureText(glyph).width + spacing; } } } function rounded(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void { ctx.beginPath(); ctx.roundRect(x, y, w, h, r); } function disc(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, color: string): void { ctx.fillStyle = color; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); } function contain(ctx: CanvasRenderingContext2D, image: HTMLImageElement, window: CardRect): void { const r = fitPhoto(image, window); ctx.drawImage(image, r.x, r.y, r.width, r.height); } export function drawShareCard(ctx: CanvasRenderingContext2D, photo: HTMLImageElement, thumb: HTMLImageElement | null, metadata: ShareMetadata): void { const { scale: s, photo: window, caption, tag, chips, strip } = cardLayout(ctx.canvas); ctx.save(); ctx.textAlign = 'left'; ctx.textBaseline = 'top'; const paper = ctx.createLinearGradient(0, 0, 0, ctx.canvas.height); paper.addColorStop(0, '#f8f5f0'); paper.addColorStop(1, '#efe9e1'); ctx.fillStyle = paper; ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height); // Paper fills the letterbox gaps. Do not clip even the corners of the source photo. ctx.fillStyle = '#e7e2da'; rounded(ctx, window.x, window.y, window.width, window.height, 10 * s); ctx.fill(); contain(ctx, photo, window); ctx.font = `800 ${80 * s}px ${FONT}`; ctx.fillStyle = '#161513'; drawSpacedText(ctx, 'Should I get it?', caption.x, caption.y, -2 * s); const item = normal(metadata.itemName), brand = normal(metadata.brandName || '').toUpperCase(); if (item) { const ty = tag.y + (tag.height - 92 * s) / 2; const thumbnail = { x: tag.x, y: ty, width: 76 * s, height: 92 * s }; ctx.save(); rounded(ctx, thumbnail.x, ty, thumbnail.width, thumbnail.height, 14 * s); ctx.clip(); const tile = ctx.createLinearGradient(tag.x, ty, tag.x + 26 * s, ty + 92 * s); tile.addColorStop(0, '#e7e2da'); tile.addColorStop(1, '#cfc7bb'); ctx.fillStyle = tile; ctx.fillRect(tag.x, ty, thumbnail.width, thumbnail.height); if (thumb) contain(ctx, thumb, thumbnail); else { ctx.save(); ctx.translate(tag.x + 12 * s, ty + 20 * s); ctx.scale(52 * s / 64, 52 * s / 64); ctx.beginPath(); [[22,8],[32,14],[42,8],[58,18],[52,30],[46,27],[46,58],[18,58],[18,27],[12,30],[6,18]].forEach(([x,y], i) => i ? ctx.lineTo(x,y) : ctx.moveTo(x,y)); ctx.closePath(); ctx.fillStyle = '#a69d8f'; ctx.fill(); ctx.restore(); } ctx.restore(); const tx = tag.x + 96 * s, max = tag.width - 96 * s; ctx.font = `700 ${34 * s}px ${FONT}`; const lines = wrapTwoLines(ctx, item, max); const textHeight = lines.length * 37.4 * s + (brand ? 30 * s : 0); let y = tag.y + (tag.height - textHeight) / 2; if (brand) { ctx.font = `600 ${20 * s}px ${FONT}`; ctx.fillStyle = '#8a8378'; drawSpacedText(ctx, fitText(ctx, brand, max, 3.6 * s), tx, y, 3.6 * s); y += 30 * s; } ctx.font = `700 ${34 * s}px ${FONT}`; ctx.fillStyle = '#161513'; lines.forEach((line, i) => ctx.fillText(line, tx, y + i * 37.4 * s)); } ctx.textBaseline = 'middle'; [['❤️', 'yes'], ['😢', 'no']].forEach(([emoji, label], i) => { const pill = chips[i], cy = pill.y + pill.height / 2; // Inset the stroke so its painted bounds stay inside the chip rect. rounded(ctx, pill.x + s, pill.y + s, pill.width - 2 * s, pill.height - 2 * s, 42 * s); ctx.fillStyle = '#fff'; ctx.fill(); ctx.lineWidth = 2 * s; ctx.strokeStyle = '#e2dbd0'; ctx.stroke(); ctx.font = `${40 * s}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`; ctx.fillStyle = '#161513'; ctx.fillText(emoji, pill.x + 26 * s, cy); ctx.font = `600 ${38 * s}px ${FONT}`; ctx.fillText(label, pill.x + 90 * s, cy); }); rounded(ctx, strip.x, strip.y, strip.width, strip.height, 28 * s); ctx.fillStyle = '#161513'; ctx.fill(); const sy = strip.y + strip.height / 2, sx = strip.x + 40 * s; ctx.font = `400 ${24 * s}px ${FONT}`; ctx.fillStyle = '#b9b2a7'; ctx.fillText('powered by', sx, sy); const byWidth = ctx.measureText('powered by').width; ctx.font = `700 ${40 * s}px ${FONT}`; ctx.fillStyle = '#fff'; drawSpacedText(ctx, 'aisthetix', sx + byWidth + 8 * s, sy, -.4 * s); ctx.font = `600 ${28 * s}px ${FONT}`; const cta = 'Try it on your own photo', left = strip.x + strip.width - 40 * s - ctx.measureText(cta).width; disc(ctx, left - 18 * s, sy, 11 * s, 'rgba(94,224,138,.25)'); disc(ctx, left - 18 * s, sy, 6 * s, '#5ee08a'); ctx.fillStyle = '#f2eee8'; ctx.fillText(cta, left, sy); ctx.restore(); } /** Deliberately the final draw: every successful export carries this disclosure on the paper lip. */ export function stampAiGeneratedWatermark(ctx: CanvasRenderingContext2D): void { const { scale, watermark } = cardLayout(ctx.canvas); ctx.font = `500 ${22 * scale}px ${FONT}`; ctx.fillStyle = 'rgba(22,21,19,.48)'; ctx.shadowColor = 'transparent'; ctx.shadowBlur = 0; ctx.shadowOffsetY = 0; ctx.textAlign = 'right'; ctx.textBaseline = 'bottom'; ctx.fillText('ai generated', watermark.x + watermark.width, ctx.canvas.height - 16 * scale); }