UNPKG

cast-avatar

Version:

Dependency-free deterministic SVG avatar generator for browsers.

374 lines (331 loc) 19.4 kB
import { createRandom, hashString } from '../hash.js'; import { resolvePalette } from '../palettes.js'; import { svgFrame, eyeGroup } from './common.js'; // A semi-realistic, sculpted "studio portrait". It shares the face trait set and // feature layout with `portrait` (so a given seed reads as the same person), but // pushes fidelity much further: a faceShape-driven head silhouette, multi-layer // color-agnostic skin modelling (vertical light, upper-left key light, rim-shadow // vignette, cheekbone highlights, jaw/temple core shadows), a sculpted nose, // volumetric upper/lower lips, almond lidded eyes with a gradient iris and // catch-light, and hair with a gradient sheen. // // All shading is color-agnostic: shadows are translucent black, highlights // translucent white, composited over whatever palette color sits underneath, so // every skin/hair color shades correctly. Tunable magic numbers live in the // constants below for easy visual iteration. const STROKE = '#5b4a42'; // soft brown line color (nose/brows) const LASH = '#2b211c'; // upper-lid lash line const LIP_FILL = '#c2877d'; // lower-lip color const LIP_DARK = '#a86b62'; // upper-lip color (in shadow) const LIP_LINE = '#7d4b46'; // lip seam const TEETH = '#fbf6f0'; const IRIS_LIGHT = '#9a7350'; // iris center const IRIS_MID = '#6d4d33'; const IRIS_DARK = '#3a2a1c'; // iris rim const PUPIL = '#1a1209'; const FRECKLE = '#8a5a3b'; const BLUSH = '#e8918a'; const METAL = '#e9d8a6'; // earrings // Single upper-left light source, shared by the key light and hair sheen. const KEY_CX = 0.34; const KEY_CY = 0.28; const KEY_LIGHT = 0.22; // upper-left highlight strength const RIM_SHADOW = 0.26; // face vignette darkness at the rim // Head silhouettes per faceShape. Feature coordinates (eyes/nose/mouth/ears) // are intentionally shared across all three so the same seed stays recognizable // when only the face outline changes. const FACE = { oval: 'M64 30C81 30 92 44 91 64C90 82 80 98 64 104C48 98 38 82 37 64C36 44 47 30 64 30Z', round: 'M64 32C83 32 95 46 95 65C95 80 82 99 64 101C46 99 33 80 33 65C33 46 45 32 64 32Z', soft: 'M64 31C82 31 93 45 93 64C93 81 81 100 64 103C47 100 35 81 35 64C35 45 46 31 64 31Z' }; function defs(uid) { return '<defs>' // vertical skin modelling: lit forehead → neutral mid → shadowed jaw. + `<linearGradient id="cast-st-skin-${uid}" x1="0" y1="0" x2="0" y2="1">` + '<stop offset="0" stop-color="#fff" stop-opacity="0.16"/>' + '<stop offset="0.42" stop-color="#fff" stop-opacity="0"/>' + '<stop offset="1" stop-color="#000" stop-opacity="0.18"/>' + '</linearGradient>' // upper-left key light. + `<radialGradient id="cast-st-key-${uid}" cx="${KEY_CX}" cy="${KEY_CY}" r="0.62">` + `<stop offset="0" stop-color="#fff" stop-opacity="${KEY_LIGHT}"/>` + '<stop offset="1" stop-color="#fff" stop-opacity="0"/>' + '</radialGradient>' // rim-shadow vignette to round the cheeks off. + `<radialGradient id="cast-st-rim-${uid}" cx="0.5" cy="0.5" r="0.5">` + '<stop offset="0" stop-color="#000" stop-opacity="0"/>' + '<stop offset="0.62" stop-color="#000" stop-opacity="0"/>' + `<stop offset="1" stop-color="#000" stop-opacity="${RIM_SHADOW}"/>` + '</radialGradient>' // gradient iris. + `<radialGradient id="cast-st-iris-${uid}" cx="0.5" cy="0.42" r="0.6">` + `<stop offset="0" stop-color="${IRIS_LIGHT}"/>` + `<stop offset="0.6" stop-color="${IRIS_MID}"/>` + `<stop offset="1" stop-color="${IRIS_DARK}"/>` + '</radialGradient>' // hair volume: crown sheen → neutral → root shadow. + `<linearGradient id="cast-st-hair-${uid}" x1="0.2" y1="0" x2="0.4" y2="1">` + '<stop offset="0" stop-color="#fff" stop-opacity="0.22"/>' + '<stop offset="0.4" stop-color="#fff" stop-opacity="0"/>' + '<stop offset="1" stop-color="#000" stop-opacity="0.22"/>' + '</linearGradient>' + '</defs>'; } function renderHair(hairStyle, fill, uid) { if (hairStyle === 'none' || hairStyle === 'hijab') { return ''; } let path; let bun = ''; if (hairStyle === 'long') { path = 'M31 104c-5-27 0-72 33-72s38 45 33 72c-7-5-9-16-9-33-6 5-13 7-24 7s-18-2-24-7c0 17-2 28-9 33Z'; } else if (hairStyle === 'bun') { path = 'M35 60c0-25 13-39 29-39s29 14 29 39c-6-15-9-20-29-20s-23 5-29 20Z'; bun = `<circle cx="64" cy="24" r="10" fill="${fill}"/>` + `<circle cx="64" cy="24" r="10" fill="url(#cast-st-hair-${uid})"/>`; } else if (hairStyle === 'afro' || hairStyle === 'curly' || hairStyle === 'coily') { path = 'M28 66c-3-31 16-48 36-48s39 17 36 48c-8-19-21-26-36-26s-28 7-36 26Z'; } else if (hairStyle === 'mohawk') { path = 'M57 22q7-8 14 0c2 14 1 30-7 40c-8-10-9-26-7-40Z'; } else if (hairStyle === 'spiky') { path = 'M35 60 42 34 48 52 56 30 64 50 72 30 80 52 86 34 93 60Q64 50 35 60Z'; } else { // short / stubble / textured collapse to a smooth, close cap. path = 'M35 62c0-25 13-40 29-40s29 15 29 40c-6-15-9-21-29-21s-23 6-29 21Z'; } // base hair, a gradient sheen overlay for volume, then a crown strand light. return `${bun}<path d="${path}" fill="${fill}"/>` + `<path d="${path}" fill="url(#cast-st-hair-${uid})"/>` + '<path d="M46 34q18-12 36 0" fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" opacity="0.16"/>'; } // Hats drawn on top of any hair. `turban` fully hides the hair (see main). function renderHeadwear(headwear, clothing, uid) { if (headwear === 'beanie') { const cap = 'M33 53c0-22 14-35 31-35s31 13 31 35c-10-8-21-12-31-12s-21 4-31 12Z'; return `<path d="${cap}" fill="${clothing}"/>` + `<path d="${cap}" fill="url(#cast-st-hair-${uid})"/>` + `<rect x="32" y="49" width="64" height="8" rx="4" fill="${clothing}"/>` + '<rect x="32" y="49" width="64" height="8" rx="4" fill="#000" opacity="0.10"/>'; } if (headwear === 'cap') { const dome = 'M34 55c0-21 14-33 30-33s30 12 30 33c-10-7-20-10-30-10s-20 3-30 10Z'; return `<path d="${dome}" fill="${clothing}"/>` + `<path d="${dome}" fill="url(#cast-st-hair-${uid})"/>` + `<path d="M30 54q34-7 68 0q-3 6 -12 7 q-22 3 -44 0 q-9 -1 -12 -7Z" fill="${clothing}"/>` + '<path d="M30 54q34-7 68 0" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" opacity="0.12"/>'; } if (headwear === 'bucket') { const dome = 'M37 51c0-18 12-29 27-29s27 11 27 29Z'; return `<path d="${dome}" fill="${clothing}"/>` + `<path d="${dome}" fill="url(#cast-st-hair-${uid})"/>` + `<path d="M27 51q37 11 74 0q-4 9 -17 12 q-20 4 -40 0 q-13 -3 -17 -12Z" fill="${clothing}"/>` + '<path d="M27 51q37 11 74 0" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" opacity="0.10"/>'; } if (headwear === 'turban') { const wrap = 'M30 56c-2-25 13-41 34-41s36 16 34 41c-6-11-10-31-34-31s-28 20-34 31Z'; return `<path d="${wrap}" fill="${clothing}"/>` + `<path d="${wrap}" fill="url(#cast-st-hair-${uid})"/>` + '<path d="M32 52q14-22 32-15" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" opacity="0.12"/>' + '<path d="M40 40q20-12 40 6" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" opacity="0.12"/>'; } return ''; } function renderBrows(eyebrows, color) { let left; let right; if (eyebrows === 'raised') { left = 'M45 55q7-4.5 14-1'; right = 'M83 55q-7-4.5 -14 -1'; } else if (eyebrows === 'angled') { left = 'M45 54q7 1 14 4'; right = 'M83 54q-7 1 -14 4'; } else { left = 'M45 56q7-2.5 14 0'; right = 'M83 56q-7-2.5 -14 0'; } return `<path d="${left}" fill="none" stroke="${color}" stroke-width="2.6" stroke-linecap="round"/>` + `<path d="${right}" fill="none" stroke="${color}" stroke-width="2.6" stroke-linecap="round"/>`; } function renderEye(cx, eyes, uid) { if (eyes === 'wink' && cx < 64) { return `<path d="M${cx - 7} 64q7 4 14 0" fill="none" stroke="${LASH}" stroke-width="2.2" stroke-linecap="round"/>`; } if (eyes === 'sleepy') { return `<path d="M${cx - 8} 62q8-5 16 1" fill="none" stroke="#000" stroke-width="1.4" stroke-linecap="round" opacity="0.10"/>` + `<path d="M${cx - 7} 64q7-2 14 0" fill="none" stroke="${LASH}" stroke-width="2.2" stroke-linecap="round"/>` + `<circle cx="${cx}" cy="64.5" r="2.1" fill="${IRIS_DARK}"/>`; } if (eyes === 'smile') { return `<path d="M${cx - 7} 65q7-6 14 0" fill="none" stroke="${LASH}" stroke-width="2.2" stroke-linecap="round"/>`; } // round (default): crease, almond sclera, gradient iris, pupil, catch-light, // lash line, soft lower lid. return `<path d="M${cx - 8} 61.5q8-6 16 1" fill="none" stroke="#000" stroke-width="1.6" stroke-linecap="round" opacity="0.12"/>` + `<path d="M${cx - 7} 64q7-5.5 14 0q-7 5.5 -14 0Z" fill="#fbfaf8"/>` + `<circle cx="${cx}" cy="64" r="3.4" fill="url(#cast-st-iris-${uid})"/>` + `<circle cx="${cx}" cy="64" r="1.7" fill="${PUPIL}"/>` + `<circle cx="${cx - 1.2}" cy="62.7" r="0.9" fill="#fff" opacity="0.92"/>` + `<path d="M${cx - 7} 64q7-5.5 14 0" fill="none" stroke="${LASH}" stroke-width="1.8" stroke-linecap="round"/>` + `<path d="M${cx - 6} 65.6q6 2.4 12 0" fill="none" stroke="#000" stroke-width="1" stroke-linecap="round" opacity="0.12"/>`; } function renderNose(nose) { if (nose === 'button') { return '<path d="M64 60v13" fill="none" stroke="#fff" stroke-width="1.4" stroke-linecap="round" opacity="0.14"/>' + '<ellipse cx="64" cy="75" rx="4" ry="2" fill="#000" opacity="0.12"/>' + '<path d="M61 75q-1.5 1.5 -0.5 2.6" fill="none" stroke="#000" stroke-width="1.2" stroke-linecap="round" opacity="0.18"/>' + '<path d="M67 75q1.5 1.5 0.5 2.6" fill="none" stroke="#000" stroke-width="1.2" stroke-linecap="round" opacity="0.18"/>'; } if (nose === 'wide') { return '<path d="M64 59v15" fill="none" stroke="#fff" stroke-width="1.6" stroke-linecap="round" opacity="0.14"/>' + '<path d="M68 60q4 9 1 15" fill="none" stroke="#000" stroke-width="1.6" stroke-linecap="round" opacity="0.12"/>' + '<ellipse cx="64" cy="77" rx="6.5" ry="2.6" fill="#000" opacity="0.12"/>' + '<ellipse cx="59" cy="77.5" rx="1.8" ry="1.3" fill="#000" opacity="0.22"/>' + '<ellipse cx="69" cy="77.5" rx="1.8" ry="1.3" fill="#000" opacity="0.22"/>'; } // soft (default): bridge highlight, one-sided bridge shadow, tip shadow, nostrils. return '<path d="M64 59v15" fill="none" stroke="#fff" stroke-width="1.5" stroke-linecap="round" opacity="0.14"/>' + '<path d="M67 60q3 9 1 15" fill="none" stroke="#000" stroke-width="1.6" stroke-linecap="round" opacity="0.12"/>' + '<path d="M63 65q-3 9 -1 12q2 2 6 1" fill="none" stroke="' + STROKE + '" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" opacity="0.4"/>' + '<ellipse cx="64" cy="76.5" rx="5" ry="2.2" fill="#000" opacity="0.12"/>' + '<ellipse cx="60.5" cy="76.8" rx="1.4" ry="1.1" fill="#000" opacity="0.2"/>' + '<ellipse cx="67.5" cy="76.8" rx="1.4" ry="1.1" fill="#000" opacity="0.2"/>'; } function renderMouth(mouth) { if (mouth === 'open') { return '<ellipse cx="64" cy="86" rx="5.5" ry="4" fill="#7a3b39"/>' + '<path d="M59 83.5q5 2 10 0v2q-5 1.4 -10 0Z" fill="' + TEETH + '"/>' + '<path d="M57 84q7-2.5 14 0q-7 3 -14 0Z" fill="' + LIP_DARK + '"/>' + '<path d="M57.5 89q6.5 3.5 13 0q-6.5 3.5 -13 0Z" fill="' + LIP_FILL + '"/>' + '<path d="M60 91q4 1.5 8 0" fill="none" stroke="#fff" stroke-width="1.1" stroke-linecap="round" opacity="0.18"/>'; } if (mouth === 'smile') { return '<path d="M54 84q10 5 20 0q-10 1.5 -20 0Z" fill="' + TEETH + '"/>' + '<path d="M54 84q4-2.5 10-1q6-1.5 10 1q-10 3 -20 0Z" fill="' + LIP_DARK + '"/>' + '<path d="M54 84q10 6 20 0q-10 4 -20 0Z" fill="' + LIP_FILL + '"/>' + '<path d="M54 84q10 5 20 0" fill="none" stroke="' + LIP_LINE + '" stroke-width="1.4" stroke-linecap="round"/>' + '<path d="M59 88q5 2 10 0" fill="none" stroke="#fff" stroke-width="1.2" stroke-linecap="round" opacity="0.2"/>'; } // neutral: cupid's-bow upper lip + fuller lower lip + seam + highlight. return '<path d="M56 84q4-2.5 8-1q4-1.5 8 1q-8 2.5 -16 0Z" fill="' + LIP_DARK + '"/>' + '<path d="M56 84q8 4.5 16 0q-8 2.5 -16 0Z" fill="' + LIP_FILL + '"/>' + '<path d="M56 84q8 2.5 16 0" fill="none" stroke="' + LIP_LINE + '" stroke-width="1.4" stroke-linecap="round"/>' + '<path d="M60 87.5q4 1.5 8 0" fill="none" stroke="#fff" stroke-width="1.2" stroke-linecap="round" opacity="0.2"/>'; } function renderFacialHair(facialHair, fill) { if (facialHair === 'beard' || facialHair === 'fullBeard') { return `<path d="M39 80c4 17 13 26 25 26s21-9 25-26c-6 11-12 16-25 16s-19-5-25-16Z" fill="${fill}"/>` + '<path d="M39 80c4 17 13 26 25 26s21-9 25-26c-6 11-12 16-25 16s-19-5-25-16Z" fill="#000" opacity="0.12"/>'; } if (facialHair === 'goatee') { return `<path d="M57 88c0 8 3 13 7 13s7-5 7-13c-4 3-10 3-14 0Z" fill="${fill}"/>`; } if (facialHair === 'mustache') { return `<path d="M55 82q9-4 18 0q-3 4 -9 4q-6 0 -9 -4Z" fill="${fill}"/>`; } if (facialHair === 'stubble' || facialHair === 'sideburns') { return `<path d="M39 80c4 17 13 26 25 26s21-9 25-26c-6 11-12 16-25 16s-19-5-25-16Z" fill="${fill}" opacity="0.22"/>`; } return ''; } function renderAccessories(accessories) { if (accessories === 'glasses') { return '<g fill="none" stroke="#3a3a3a" stroke-width="1.6"><rect x="44" y="58" width="16" height="12" rx="5"/><rect x="68" y="58" width="16" height="12" rx="5"/><path d="M60 63h8"/><path d="M44 62l-8-2"/><path d="M84 62l6-2"/></g>' + '<g fill="#fff" opacity="0.12"><rect x="44" y="58" width="16" height="12" rx="5"/><rect x="68" y="58" width="16" height="12" rx="5"/></g>'; } if (accessories === 'sunglasses') { return '<g fill="#26262b"><rect x="43" y="57" width="18" height="13" rx="5"/><rect x="67" y="57" width="18" height="13" rx="5"/></g>' + '<path d="M61 62h6" stroke="#26262b" stroke-width="3"/>' + '<path d="M46 60l8 0" stroke="#fff" stroke-width="2" stroke-linecap="round" opacity="0.2"/>'; } return ''; } function renderEarrings(earrings) { if (earrings === 'studs') { return `<circle cx="37" cy="76" r="1.9" fill="${METAL}"/><circle cx="91" cy="76" r="1.9" fill="${METAL}"/>`; } if (earrings === 'hoops') { return `<ellipse cx="37" cy="78" rx="2.6" ry="3.6" fill="none" stroke="${METAL}" stroke-width="1.6"/>` + `<ellipse cx="91" cy="78" rx="2.6" ry="3.6" fill="none" stroke="${METAL}" stroke-width="1.6"/>`; } return ''; } function renderFreckles(freckles, seed) { if (freckles !== 'light' && freckles !== 'heavy') { return ''; } const random = createRandom(`${seed}:studiofreckles`); const count = freckles === 'heavy' ? 16 : 9; let dots = ''; for (let i = 0; i < count; i += 1) { const side = random() < 0.5 ? 50 : 78; const x = Math.round(side + (random() * 12 - 6)); const y = Math.round(70 + random() * 8); dots += `<circle cx="${x}" cy="${y}" r="0.9" fill="${FRECKLE}" opacity="0.5"/>`; } return dots; } export function renderStudioAvatar(config) { const traits = config.traits; const palette = resolvePalette(config.palette); const skin = palette.skinTones[traits.skinTone] || palette.skinTones.medium; const hairFill = palette.hairColors[traits.hairColor] || palette.hairColors.brown; const browColor = palette.hairColors[traits.hairColor] || STROKE; const clothing = traits.clothing || config.clothing || '#64748b'; const isHijab = traits.headwear === 'hijab' || traits.hairStyle === 'hijab'; const hidesHair = isHijab || traits.headwear === 'turban'; const shoulderSpread = traits.gender === 'masculine' ? 40 : traits.gender === 'feminine' ? 34 : 37; const uid = hashString(`${config.seed}:studio`).toString(36); const facePath = FACE[traits.faceShape] || FACE.soft; const parts = [ defs(uid), // shoulders + collar shadow `<path d="M${64 - shoulderSpread} 128c3-19 17-30 ${shoulderSpread} -30s${shoulderSpread - 2} 11 ${shoulderSpread} 30Z" fill="${clothing}"/>`, `<path d="M${64 - shoulderSpread + 6} 128c2-12 12-20 ${shoulderSpread - 6} -20" fill="none" stroke="#000" stroke-width="6" stroke-linecap="round" opacity="0.1"/>`, // neck + chin-cast shadow `<rect x="57" y="94" width="14" height="15" rx="5" fill="${skin}"/>`, `<path d="M50 96q14 9 28 0" fill="none" stroke="#000" stroke-width="4" stroke-linecap="round" opacity="0.16"/>`, // ears + inner shadow `<ellipse cx="36" cy="68" rx="4.5" ry="7.5" fill="${skin}"/>`, `<ellipse cx="92" cy="68" rx="4.5" ry="7.5" fill="${skin}"/>`, `<path d="M35 64q3 5 0 9" fill="none" stroke="#000" stroke-width="1.4" stroke-linecap="round" opacity="0.14"/>`, `<path d="M93 64q-3 5 0 9" fill="none" stroke="#000" stroke-width="1.4" stroke-linecap="round" opacity="0.14"/>` ]; if (isHijab) { // a head wrap behind a smaller exposed face oval. parts.push(`<path d="M30 112c-5-27-2-60 7-77 7-12 16-18 27-18s20 6 27 18c9 17 12 50 7 77Z" fill="${clothing}"/>`); parts.push('<path d="M30 112c-5-27-2-60 7-77 7-12 16-18 27-18" fill="none" stroke="#000" stroke-width="5" stroke-linecap="round" opacity="0.08"/>'); parts.push(`<ellipse cx="64" cy="68" rx="22" ry="28" fill="${skin}"/>`); parts.push(`<ellipse cx="64" cy="68" rx="22" ry="28" fill="url(#cast-st-key-${uid})"/>`); parts.push(`<ellipse cx="64" cy="68" rx="22" ry="28" fill="url(#cast-st-rim-${uid})"/>`); } else { // sculpted face: flat skin, vertical light, key light, rim vignette, then // cheekbone highlights and jaw/temple core shadows. parts.push(`<path d="${facePath}" fill="${skin}"/>`); parts.push(`<path d="${facePath}" fill="url(#cast-st-skin-${uid})"/>`); parts.push(`<path d="${facePath}" fill="url(#cast-st-key-${uid})"/>`); parts.push(`<path d="${facePath}" fill="url(#cast-st-rim-${uid})"/>`); parts.push(`<ellipse cx="49" cy="71" rx="9" ry="6" fill="#fff" opacity="0.07" transform="rotate(-18 49 71)"/>`); parts.push(`<ellipse cx="79" cy="71" rx="9" ry="6" fill="#fff" opacity="0.055" transform="rotate(18 79 71)"/>`); parts.push(`<ellipse cx="64" cy="96" rx="14" ry="6" fill="#000" opacity="0.08"/>`); parts.push(`<ellipse cx="40" cy="60" rx="4.5" ry="9" fill="#000" opacity="0.07"/>`); parts.push(`<ellipse cx="88" cy="60" rx="4.5" ry="9" fill="#000" opacity="0.07"/>`); if (!hidesHair) { parts.push(renderHair(traits.hairStyle, hairFill, uid)); } parts.push(renderHeadwear(traits.headwear, clothing, uid)); } parts.push(renderBrows(traits.eyebrows, browColor)); if (traits.blush === 'soft') { parts.push(`<ellipse cx="49" cy="75" rx="6" ry="4" fill="${BLUSH}" opacity="0.18"/>`); parts.push(`<ellipse cx="79" cy="75" rx="6" ry="4" fill="${BLUSH}" opacity="0.18"/>`); } parts.push(renderFreckles(traits.freckles, config.seed)); parts.push(eyeGroup(config, renderEye(52, traits.eyes, uid) + renderEye(76, traits.eyes, uid))); parts.push(renderNose(traits.nose)); parts.push(renderMouth(traits.mouth)); parts.push(renderFacialHair(traits.facialHair, hairFill)); parts.push(renderAccessories(traits.accessories)); parts.push(renderEarrings(traits.earrings)); return svgFrame(config, parts.join('')); }