Files
neural-hunt/internal/artifact/card.go
T
jbergner 005fd6ca51
release-tag / release-image (push) Failing after 1m18s
RC-1
2026-08-10 05:48:55 +02:00

117 lines
5.8 KiB
Go

package artifact
import (
"crypto/sha256"
"encoding/base64"
"fmt"
"html"
"strings"
"unicode/utf8"
)
func imageMIME(ext string) string {
switch strings.ToLower(strings.TrimPrefix(ext, ".")) {
case "jpg", "jpeg":
return "image/jpeg"
case "webp":
return "image/webp"
case "svg":
return "image/svg+xml"
default:
return "image/png"
}
}
func trimLabel(s string, maxRunes int) string {
s = strings.Join(strings.Fields(strings.TrimSpace(s)), " ")
if s == "" {
return "NEURAL HUNT"
}
if utf8.RuneCountInString(s) <= maxRunes {
return s
}
r := []rune(s)
return string(r[:maxRunes-1]) + "…"
}
// renderCardSVG converts raw generated artwork into the deterministic final
// collectible card. Keeping the typography/layout outside the image model makes
// all cards line up exactly and avoids model-generated fake text/logos.
func renderCardSVG(art []byte, artExt string, x win, traits collectionTraits) []byte {
encoded := base64.StdEncoding.EncodeToString(art)
series := html.EscapeString(strings.ToUpper(trimLabel(x.DisplayName, 28)))
theme := html.EscapeString(strings.ToUpper(trimLabel(traits.ThemeName, 24)))
rarity := html.EscapeString(traits.Rarity)
completed := x.Completed.UTC().Format("2006.01.02")
mime := imageMIME(artExt)
var sparkles strings.Builder
h := sha256.Sum256([]byte(x.ID + "|card|" + x.Winner + "|" + x.Seed))
for i := 0; i < 42; i++ {
b0 := int(h[i%32])
b1 := int(h[(i*7+5)%32])
xv := 62 + (b0*37+i*97)%900
yv := 54 + (b1*29+i*83)%1420
r := 1 + (b0+i)%4
op := 12 + (b1 % 34)
fmt.Fprintf(&sparkles, `<circle cx="%d" cy="%d" r="%d" fill="white" opacity="0.%02d"/>`, xv, yv, r, op)
}
svg := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1536" viewBox="0 0 1024 1536" role="img" aria-label="Neural Hunt collectible card">
<defs>
<clipPath id="cardClip"><rect x="26" y="26" width="972" height="1484" rx="58"/></clipPath>
<clipPath id="artClip"><rect x="42" y="42" width="940" height="1452" rx="48"/></clipPath>
<linearGradient id="metal" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#f4f7ff"/><stop offset=".18" stop-color="%s"/><stop offset=".48" stop-color="#101725"/><stop offset=".73" stop-color="%s"/><stop offset="1" stop-color="#e9f2ff"/></linearGradient>
<linearGradient id="topShade" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#050811" stop-opacity=".82"/><stop offset="1" stop-color="#050811" stop-opacity="0"/></linearGradient>
<linearGradient id="bottomShade" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#07101d" stop-opacity="0"/><stop offset=".34" stop-color="#07101d" stop-opacity=".58"/><stop offset="1" stop-color="#030711" stop-opacity=".96"/></linearGradient>
<linearGradient id="holo" x1="0" y1="0" x2="1" y2="1"><stop stop-color="%s" stop-opacity=".72"/><stop offset=".42" stop-color="#ffffff" stop-opacity=".22"/><stop offset=".67" stop-color="%s" stop-opacity=".64"/><stop offset="1" stop-color="#ffffff" stop-opacity=".06"/></linearGradient>
<filter id="shadow"><feDropShadow dx="0" dy="18" stdDeviation="22" flood-color="#000000" flood-opacity=".55"/></filter>
</defs>
<g filter="url(#shadow)">
<rect x="18" y="18" width="988" height="1500" rx="66" fill="url(#metal)"/>
<rect x="28" y="28" width="968" height="1480" rx="56" fill="#0b101b"/>
</g>
<g clip-path="url(#artClip)">
<image x="42" y="42" width="940" height="1452" preserveAspectRatio="xMidYMid slice" href="data:%s;base64,%s"/>
<rect x="42" y="42" width="940" height="360" fill="url(#topShade)"/>
<rect x="42" y="940" width="940" height="554" fill="url(#bottomShade)"/>
<path d="M-80 270 L760 -80 L1100 80 L180 480 Z" fill="url(#holo)" opacity=".10"/>
<path d="M120 1530 L1130 1030 L1130 1190 L330 1530 Z" fill="url(#holo)" opacity=".12"/>
<g>%s</g>
</g>
<rect x="42" y="42" width="940" height="1452" rx="48" fill="none" stroke="#ffffff" stroke-opacity=".28" stroke-width="2"/>
<rect x="49" y="49" width="926" height="1438" rx="42" fill="none" stroke="url(#holo)" stroke-opacity=".72" stroke-width="3"/>
<!-- Header -->
<g font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Arial,sans-serif" fill="#ffffff">
<text x="76" y="104" font-size="22" font-weight="800" letter-spacing="5" opacity=".76">NEURAL HUNT</text>
<line x1="76" y1="118" x2="286" y2="118" stroke="#ffc400" stroke-width="5" stroke-linecap="round" opacity=".92"/>
<text x="76" y="154" font-size="17" font-weight="700" letter-spacing="4" opacity=".76">%s</text>
<g transform="translate(770 77)">
<rect width="166" height="54" rx="27" fill="#060a12" fill-opacity=".72" stroke="url(#holo)" stroke-width="2"/>
<text x="83" y="35" text-anchor="middle" font-size="17" font-weight="900" letter-spacing="2">%s</text>
</g>
</g>
<!-- Bottom collectible information panel -->
<g font-family="Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Arial,sans-serif" fill="#ffffff">
<text x="76" y="1234" font-size="15" font-weight="800" letter-spacing="5" opacity=".62">WINNING EDITION</text>
<text x="76" y="1290" font-size="42" font-weight="900" letter-spacing=".8">%s</text>
<line x1="76" y1="1320" x2="948" y2="1320" stroke="url(#holo)" stroke-width="2" opacity=".72"/>
<g transform="translate(76 1345)">
<rect width="176" height="50" rx="25" fill="#ffffff" fill-opacity=".10" stroke="#ffffff" stroke-opacity=".18"/>
<text x="88" y="32" text-anchor="middle" font-size="15" font-weight="800" letter-spacing="2">%d-BIT HUNT</text>
</g>
<text x="76" y="1442" font-size="14" font-weight="700" letter-spacing="2" opacity=".56">%s · ORIGINAL FULL-ART SERIES</text>
<circle cx="930" cy="1437" r="11" fill="%s"/><circle cx="902" cy="1437" r="7" fill="%s"/>
</g>
</svg>`,
traits.AccentA, traits.AccentB, traits.AccentA, traits.AccentB,
mime, encoded, sparkles.String(),
series, rarity, theme, x.RangeBits, completed, traits.AccentA, traits.AccentB,
)
return []byte(svg)
}