Binary file not shown.
|
After Width: | Height: | Size: 188 KiB |
@@ -0,0 +1,116 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
package artifact
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const collectionPresetRaccoon = "raccoon_full_art_v1"
|
||||
|
||||
const characterAnchorPrompt = `Create the canonical character reference for an original collectible-art series.
|
||||
|
||||
CANONICAL CHARACTER — LOCK THIS IDENTITY:
|
||||
Create one original anthropomorphic silver-gray raccoon named RIFT.
|
||||
Permanent visual features:
|
||||
- charcoal-black raccoon eye mask with a clean, recognizable shape
|
||||
- creamy white muzzle, cheeks and chest
|
||||
- large expressive amber-gold eyes
|
||||
- small rounded ears; one tiny notch in the RIGHT ear
|
||||
- short broad muzzle and a subtle confident half-smile
|
||||
- oversized head with compact athletic humanoid proportions
|
||||
- thick striped raccoon tail with exactly five dark rings
|
||||
- soft sculpted stylized fur clumps, never photographic fur
|
||||
- friendly, clever, confident personality
|
||||
|
||||
WARDROBE FOR THE ANCHOR ONLY:
|
||||
A simple charcoal utility overshirt with no logos, no writing and no theme-specific accessories. Keep the outfit neutral so future images can replace it completely.
|
||||
|
||||
COMPOSITION:
|
||||
Vertical 3/4 character portrait, full torso and most of the tail visible, neutral elegant studio background, clean readable silhouette, centered, no props obscuring the face or tail.
|
||||
|
||||
STYLE:
|
||||
Neutral high-end stylized 3D character illustration, deliberately non-photorealistic, premium animated-feature quality, rounded shapes, rich material detail, cinematic soft key light plus rim light, subtle depth of field, controlled color, polished collectible character reference. Keep the rendering neutral enough that future task-specific style references can deliberately restyle RIFT without changing his identity.
|
||||
|
||||
STRICT:
|
||||
Exactly one raccoon character. No human face. No tiger features. No dreadlocks. No text, numbers, logo, trademark, watermark, card border, UI, duplicate character, photorealistic wildlife or realistic human anatomy.`
|
||||
|
||||
type themeProfile struct {
|
||||
Name string
|
||||
Outfit string
|
||||
Accessories string
|
||||
Scene string
|
||||
Mood string
|
||||
}
|
||||
|
||||
var collectionThemes = []themeProfile{
|
||||
{
|
||||
Name: "Neon Courier",
|
||||
Outfit: "oversized midnight technical bomber, black utility vest with reflective piping, loose futuristic cargo trousers, high-top sneakers and fingerless gloves",
|
||||
Accessories: "compact cross-body courier satchel and a translucent visor pushed above the eyes",
|
||||
Scene: "rain-slick future city alley, glowing shop fronts, cables overhead, thin steam and floating light particles",
|
||||
Mood: "energetic, adventurous and cool; electric night light with warm facial highlights",
|
||||
},
|
||||
{
|
||||
Name: "Sakura Ronin",
|
||||
Outfit: "layered modern ronin jacket, asymmetrical sash, armored fabric panels, tapered trousers and split-toe boots",
|
||||
Accessories: "ornamental sheathed short sword worn as a costume prop, braided cord and a small lacquer charm",
|
||||
Scene: "windy hill path beneath flowering cherry trees, distant lanterns and drifting petals at blue hour",
|
||||
Mood: "calm, disciplined and cinematic with warm lantern rim light",
|
||||
},
|
||||
{
|
||||
Name: "Lunar Botanist",
|
||||
Outfit: "cream exploration suit with padded joints, soft graphite harness, compact boots and botanical specimen pockets",
|
||||
Accessories: "clear bubble helmet carried under one arm and a glowing seed capsule",
|
||||
Scene: "moon greenhouse with alien plants, curved windows, pale regolith outside and tiny floating pollen motes",
|
||||
Mood: "curious, serene and optimistic with silver-blue light and soft green bioluminescence",
|
||||
},
|
||||
{
|
||||
Name: "Apex Racer",
|
||||
Outfit: "sleek original motorsport suit with angular panels, quilted shoulders, racing gloves and lightweight boots; absolutely no real-world team branding",
|
||||
Accessories: "custom unbranded racing helmet tucked at the hip and a small timing wrist display",
|
||||
Scene: "futuristic pit lane after sunset, abstract light streaks, glossy track surface and atmospheric haze",
|
||||
Mood: "focused, fast and triumphant with hard edge lighting and motion energy",
|
||||
},
|
||||
{
|
||||
Name: "Sky Captain",
|
||||
Outfit: "tailored deep-green flight coat, brass-toned fasteners, layered leather-like harness, high boots and a long scarf",
|
||||
Accessories: "round aviator goggles on the forehead, miniature compass and folded flight chart",
|
||||
Scene: "fantasy airship observation deck above glowing clouds, propeller silhouettes and warm sunrise",
|
||||
Mood: "adventurous, noble and windswept with golden backlight",
|
||||
},
|
||||
{
|
||||
Name: "Deep Salvager",
|
||||
Outfit: "stylized pressure-diver jacket, segmented shoulder guards, flexible utility trousers and heavy magnetic boots",
|
||||
Accessories: "compact breathing collar, antique-looking lamp and a small salvage pouch",
|
||||
Scene: "underwater ruin with rays of blue light, bubbles, coral growth, old machinery and drifting particles",
|
||||
Mood: "mysterious, brave and exploratory with teal caustics and warm lamp glow",
|
||||
},
|
||||
{
|
||||
Name: "Rooftop Skater",
|
||||
Outfit: "oversized graphic-free hoodie under a cropped windbreaker, wide technical pants, knee pads and chunky skate shoes",
|
||||
Accessories: "original blank skateboard with abstract geometric deck art and wireless headphones around the neck",
|
||||
Scene: "sunset rooftop skate spot, concrete shapes, distant skyline, spray mist and long shadows",
|
||||
Mood: "playful, rebellious and relaxed with saturated sunset gradients",
|
||||
},
|
||||
{
|
||||
Name: "Arcane Archivist",
|
||||
Outfit: "layered indigo scholar robe, structured vest, embroidered star-map patterns without readable glyphs, soft boots and fingerless reading gloves",
|
||||
Accessories: "floating crystal lens, chained key and a closed oversized book with no text on the cover",
|
||||
Scene: "towering fantasy archive, suspended shelves, luminous dust and circular windows showing a starry sky",
|
||||
Mood: "wise, curious and magical with violet-gold volumetric light",
|
||||
},
|
||||
{
|
||||
Name: "Desert Nomad",
|
||||
Outfit: "sand-colored hooded field jacket, layered scarf, reinforced trousers, wrapped boots and lightweight shoulder guards",
|
||||
Accessories: "goggles hanging at the collar, water canteen and a compact navigation device",
|
||||
Scene: "vast stylized dunes around a half-buried futuristic relay station, windblown dust and distant heat shimmer",
|
||||
Mood: "resilient, solitary and heroic with warm amber sunlight and cool shadow fill",
|
||||
},
|
||||
{
|
||||
Name: "Harbor Corsair",
|
||||
Outfit: "navy captain coat with exaggerated collar, striped sash, fitted waistcoat, weathered trousers and tall deck boots",
|
||||
Accessories: "ornamental brass spyglass, rope bracelet and a broad captain hat with no insignia",
|
||||
Scene: "fantasy harbor at dawn, stylized sailing vessel, gull silhouettes, rolling mist and bright spray",
|
||||
Mood: "mischievous, bold and charismatic with crisp sea light",
|
||||
},
|
||||
{
|
||||
Name: "Alpine Rider",
|
||||
Outfit: "bright technical snow jacket, insulated bib trousers, soft neck gaiter, mittens and oversized snowboard boots",
|
||||
Accessories: "reflective snow goggles lifted above the eyes and a custom blank snowboard with geometric patterning",
|
||||
Scene: "high alpine ridge after fresh snowfall, powder plume, distant peaks and sparkling ice crystals",
|
||||
Mood: "joyful, fearless and fresh with clean winter sunlight and blue shadows",
|
||||
},
|
||||
{
|
||||
Name: "Noir Detective",
|
||||
Outfit: "charcoal trench coat over a fitted waistcoat and shirt, narrow tie, tailored trousers and polished ankle boots",
|
||||
Accessories: "soft-brim hat, small notebook with blank pages and an old-fashioned camera",
|
||||
Scene: "stylized rainy midnight street, glowing windows, puddle reflections, fog and dramatic architectural shadows",
|
||||
Mood: "clever, suspicious and composed with high-contrast noir lighting plus a single warm practical light",
|
||||
},
|
||||
{
|
||||
Name: "Jungle Soundmaster",
|
||||
Outfit: "bold woven-pattern jacket, relaxed dark trousers, colorful knit cap and layered fabric wristbands; original patterns only",
|
||||
Accessories: "large studio headphones, portable sampler with abstract unlabelled controls and bead accents",
|
||||
Scene: "lush tropical night stage tucked into giant leaves, warm string lights, mist and floating firefly-like particles",
|
||||
Mood: "musical, warm and charismatic with saturated green, gold and red accents",
|
||||
},
|
||||
{
|
||||
Name: "Solar Guardian",
|
||||
Outfit: "ceremonial cream-and-gold mantle over fitted plated fabric armor, broad belt, tapered trousers and soft armored boots",
|
||||
Accessories: "floating sun-disc ornament, geometric shoulder charms and a staff-shaped decorative prop",
|
||||
Scene: "monumental desert temple terrace, shafts of sunlight, drifting sand and abstract solar motifs",
|
||||
Mood: "regal, radiant and protective with intense gold rim light and deep cyan shadows",
|
||||
},
|
||||
{
|
||||
Name: "Retro Astronaut",
|
||||
Outfit: "chunky retro-future space suit with rounded padding, orange utility tabs, ribbed joints and oversized moon boots",
|
||||
Accessories: "dome helmet carried at the side, analog-style wrist module with no readable numbers and tether clips",
|
||||
Scene: "colorful retro orbital station window, star field, distant planet and floating equipment shapes",
|
||||
Mood: "wonder-filled, optimistic and iconic with soft cosmic blues and warm cabin light",
|
||||
},
|
||||
{
|
||||
Name: "Storm Mechanic",
|
||||
Outfit: "rolled-sleeve heavy work jacket, dark overalls, reinforced gloves, tool belt and rugged lace-up boots",
|
||||
Accessories: "large wrench-shaped prop, protective goggles on the head and a compact diagnostic tablet with abstract graphics",
|
||||
Scene: "open-air repair platform during an approaching electrical storm, turbine parts, sparks and rain mist",
|
||||
Mood: "resourceful, gritty and confident with cool storm light and warm sparks",
|
||||
},
|
||||
}
|
||||
|
||||
var poseOptions = []string{
|
||||
"standing in a relaxed three-quarter stance with one shoulder slightly forward",
|
||||
"mid-step with a confident turn toward camera",
|
||||
"leaning casually against a thematic prop while keeping the full facial mask visible",
|
||||
"slightly crouched in an energetic ready pose with the tail creating a strong S-curve",
|
||||
"standing upright with one hand adjusting an accessory and the other relaxed",
|
||||
"heroic low-angle three-quarter pose, expressive but not aggressive",
|
||||
}
|
||||
|
||||
var atmosphereOptions = []string{
|
||||
"subtle floating particles and soft volumetric haze",
|
||||
"small foreground elements crossing the lower frame for depth",
|
||||
"gentle wind motion in fabric and fur",
|
||||
"bright rim-light bloom plus restrained lens glow",
|
||||
"layered foreground bokeh and crisp facial focus",
|
||||
"tiny environmental sparks or motes that echo the outfit materials",
|
||||
}
|
||||
|
||||
type collectionTraits struct {
|
||||
ThemeName string `json:"theme"`
|
||||
Outfit string `json:"outfit"`
|
||||
Accessories string `json:"accessories"`
|
||||
Scene string `json:"scene"`
|
||||
Mood string `json:"mood"`
|
||||
Pose string `json:"pose"`
|
||||
Atmosphere string `json:"atmosphere"`
|
||||
Rarity string `json:"rarity"`
|
||||
AccentA string `json:"accent_a"`
|
||||
AccentB string `json:"accent_b"`
|
||||
EditionCode string `json:"edition_code"`
|
||||
}
|
||||
|
||||
var accentPairs = [][2]string{
|
||||
{"#79F7FF", "#7D6CFF"},
|
||||
{"#FFB84D", "#FF5D8F"},
|
||||
{"#9BFF9E", "#16D7C5"},
|
||||
{"#F8E16C", "#D979FF"},
|
||||
{"#6AE4FF", "#FF7AD9"},
|
||||
{"#FFD36A", "#53A8FF"},
|
||||
{"#B7FF6A", "#FF8A5B"},
|
||||
{"#C7B2FF", "#6CFFD7"},
|
||||
}
|
||||
|
||||
func deriveCollectionTraits(x win) collectionTraits {
|
||||
h := sha256.Sum256([]byte(x.ID + "|" + x.Winner + "|" + x.Seed + "|" + x.Guess))
|
||||
theme := collectionThemes[int(h[0])%len(collectionThemes)]
|
||||
pair := accentPairs[int(h[1])%len(accentPairs)]
|
||||
rarityRoll := binary.BigEndian.Uint16(h[2:4]) % 10000
|
||||
rarity := "RARE"
|
||||
switch {
|
||||
case rarityRoll < 35:
|
||||
rarity = "MYTHIC"
|
||||
case rarityRoll < 260:
|
||||
rarity = "LEGENDARY"
|
||||
case rarityRoll < 1250:
|
||||
rarity = "EPIC"
|
||||
case rarityRoll < 4200:
|
||||
rarity = "RARE"
|
||||
default:
|
||||
rarity = "SIGNATURE"
|
||||
}
|
||||
return collectionTraits{
|
||||
ThemeName: theme.Name,
|
||||
Outfit: theme.Outfit,
|
||||
Accessories: theme.Accessories,
|
||||
Scene: theme.Scene,
|
||||
Mood: theme.Mood,
|
||||
Pose: poseOptions[int(h[4])%len(poseOptions)],
|
||||
Atmosphere: atmosphereOptions[int(h[5])%len(atmosphereOptions)],
|
||||
Rarity: rarity,
|
||||
AccentA: pair[0],
|
||||
AccentB: pair[1],
|
||||
EditionCode: strings.ToUpper(fmt.Sprintf("NH-%02X%02X-%02X%02X", h[6], h[7], h[8], h[9])),
|
||||
}
|
||||
}
|
||||
|
||||
func buildCollectionPrompt(x win, traits collectionTraits) string {
|
||||
taskDirection := strings.TrimSpace(x.PromptInstructions)
|
||||
if taskDirection == "" {
|
||||
taskDirection = "No additional task-specific direction."
|
||||
}
|
||||
avoid := strings.TrimSpace(x.NegativePrompt)
|
||||
if avoid == "" {
|
||||
avoid = "No additional task-specific avoid list."
|
||||
}
|
||||
return fmt.Sprintf(`Create one original premium full-art collectible character illustration.
|
||||
|
||||
REFERENCE IMAGES — SEPARATE IDENTITY FROM STYLE:
|
||||
Image 1 is the canonical RIFT CHARACTER reference. Use Image 1 only to preserve identity: silver-gray raccoon species, charcoal eye-mask shape, creamy muzzle/chest, amber-gold eyes, right-ear notch, muzzle proportions, head-to-body proportions, five-ring tail and overall face identity. Do NOT treat Image 1's neutral wardrobe, background, lighting or rendering style as fixed.
|
||||
Image 2 is the TASK STYLE reference. Use Image 2 only for visual language: rendering technique, shape language, material treatment, color behavior, lighting character, texture/detail level and overall illustration mood. Do NOT copy Image 2's subject, species, pose, clothing, cultural symbols, logos, text or composition literally.
|
||||
Priority rule: identity comes from Image 1; visual style comes from Image 2; wardrobe/theme/scene come from this prompt. If the references conflict, preserve RIFT's identity first and adopt the style reference second.
|
||||
|
||||
SUBJECT:
|
||||
RIFT, one anthropomorphic raccoon character. The character must remain unmistakably animal-like and deliberately stylized rather than realistic or human-faced.
|
||||
|
||||
THEME:
|
||||
%s
|
||||
|
||||
CLOTHING — PRIMARY VARIABLE:
|
||||
%s.
|
||||
The clothing is the main collectible trait. It must instantly communicate the theme, look custom-tailored to RIFT, use premium materials and retain a clear silhouette. No logos or recognizable real-world brand marks.
|
||||
|
||||
ACCESSORIES:
|
||||
%s.
|
||||
|
||||
SCENE:
|
||||
%s.
|
||||
|
||||
POSE:
|
||||
%s.
|
||||
|
||||
MOOD / LIGHTING:
|
||||
%s.
|
||||
|
||||
ATMOSPHERE:
|
||||
%s.
|
||||
|
||||
COMPOSITION:
|
||||
Vertical 1024x1536 full-art composition. Exactly one dominant character occupying about 65–75%% of the frame. Three-quarter body view with the face and right-ear notch clearly visible and at least most of the striped tail visible. Put the eyes near the upper third. Use foreground/background layering and dynamic depth, but keep the silhouette immediately readable. Leave natural darker breathing room near the very top and bottom so a programmatic collectible-card overlay can remain legible.
|
||||
|
||||
VISUAL LANGUAGE — TASK STYLE HAS AUTHORITY:
|
||||
Follow Image 2 decisively for the rendering language. Recreate its level of stylization, line/edge treatment, brushwork or surface treatment, material abstraction, shading model, texture density, palette behavior, lighting language and finish. The Task Style may be 3D-cartoon, cel-shaded, graphic comic, painterly, watercolor-like, clay/toy-like, low-poly, retro-game-inspired or another deliberately illustrated collectible-art language. Do NOT force RIFT back into the neutral 3D look of Image 1. Preserve the permanent identity markers from Image 1 while translating them naturally into Image 2's visual language. Keep the result premium, coherent, richly art-directed and clearly non-photorealistic.
|
||||
|
||||
FULL-ART ENERGY:
|
||||
The environment should visually merge with the character through thematic particles, light, fabric motion, mist, sparks, petals, snow, dust or other scene-appropriate elements. Create many small visual discoveries while keeping RIFT instantly readable.
|
||||
|
||||
TASK-SPECIFIC CREATIVE DIRECTION:
|
||||
%s
|
||||
|
||||
TASK-SPECIFIC AVOID LIST:
|
||||
%s
|
||||
|
||||
STRICT CONSTRAINTS:
|
||||
Original character design only. Exactly one raccoon. No human face. No photorealistic wildlife. No card frame or UI inside the generated artwork. No readable words, letters, numbers, signatures, logos, trademarks or watermarks. No duplicate character. No extra tails. Tail must retain exactly five dark rings. Do not copy the style-reference subject or its clothing. Do not add random costume elements unrelated to the theme.
|
||||
|
||||
Deterministic creative fingerprint only, never render it as text: task=%s winner=%s difficulty=%d-bit seed=%s.`,
|
||||
traits.ThemeName,
|
||||
traits.Outfit,
|
||||
traits.Accessories,
|
||||
traits.Scene,
|
||||
traits.Pose,
|
||||
traits.Mood,
|
||||
traits.Atmosphere,
|
||||
taskDirection,
|
||||
avoid,
|
||||
shortHash(x.ID), shortHash(x.Winner), x.RangeBits, shortHash(x.Seed),
|
||||
)
|
||||
}
|
||||
|
||||
func collectionNegativePrompt(x win) string {
|
||||
return joinPrompt(
|
||||
"photorealistic, realistic wildlife, human face, human skin, duplicate character, two characters, extra tail, wrong tail rings, unreadable anatomy, malformed paws, text, letters, numbers, logo, trademark, watermark, signature, card border, UI, low detail, blur",
|
||||
x.NegativePrompt,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package artifact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"neuralhunt/internal/settings"
|
||||
)
|
||||
|
||||
func sampleWin() win {
|
||||
return win{
|
||||
ID: "task_1234567890abcdef",
|
||||
Seed: "public-seed-42",
|
||||
Winner: "client_abcdef1234567890",
|
||||
Signature: "signature",
|
||||
Guess: "777",
|
||||
DisplayName: "Aurora Vault",
|
||||
RangeBits: 28,
|
||||
Completed: time.Date(2026, 8, 10, 12, 0, 0, 0, time.UTC),
|
||||
PromptInstructions: "Add a subtle aurora motif to the environmental lighting.",
|
||||
NegativePrompt: "no giant hat",
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionTraitsDeterministic(t *testing.T) {
|
||||
x := sampleWin()
|
||||
a := deriveCollectionTraits(x)
|
||||
b := deriveCollectionTraits(x)
|
||||
if a != b {
|
||||
t.Fatalf("traits are not deterministic: %#v != %#v", a, b)
|
||||
}
|
||||
if a.ThemeName == "" || a.Outfit == "" || a.EditionCode == "" || a.AccentA == "" {
|
||||
t.Fatalf("missing dynamic traits: %#v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCollectionPromptUsesIdentityLockAndVariables(t *testing.T) {
|
||||
x := sampleWin()
|
||||
traits := deriveCollectionTraits(x)
|
||||
p := buildCollectionPrompt(x, traits)
|
||||
for _, want := range []string{
|
||||
"Image 1 is the canonical RIFT CHARACTER reference",
|
||||
"Image 2 is the TASK STYLE reference",
|
||||
"identity comes from Image 1; visual style comes from Image 2",
|
||||
"VISUAL LANGUAGE — TASK STYLE HAS AUTHORITY",
|
||||
"Do NOT force RIFT back into the neutral 3D look of Image 1",
|
||||
traits.ThemeName,
|
||||
traits.Outfit,
|
||||
x.PromptInstructions,
|
||||
x.NegativePrompt,
|
||||
"exactly five dark rings",
|
||||
"No card frame or UI inside the generated artwork",
|
||||
} {
|
||||
if !strings.Contains(p, want) {
|
||||
t.Fatalf("prompt missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCardSVG(t *testing.T) {
|
||||
x := sampleWin()
|
||||
traits := deriveCollectionTraits(x)
|
||||
art := []byte("fake-png-bytes")
|
||||
card := string(renderCardSVG(art, "png", x, traits))
|
||||
for _, want := range []string{
|
||||
`width="1024" height="1536"`,
|
||||
"NEURAL HUNT",
|
||||
strings.ToUpper(traits.ThemeName),
|
||||
"WINNING EDITION",
|
||||
base64.StdEncoding.EncodeToString(art),
|
||||
"data:image/png;base64,",
|
||||
} {
|
||||
if !strings.Contains(card, want) {
|
||||
t.Fatalf("card missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGPTImage2PortraitSize(t *testing.T) {
|
||||
if err := validateOpenAIImageSize("gpt-image-2", 1024, 1536); err != nil {
|
||||
t.Fatalf("1024x1536 should be valid: %v", err)
|
||||
}
|
||||
if err := validateOpenAIImageSize("gpt-image-2", 1000, 1536); err == nil {
|
||||
t.Fatal("non-multiple-of-16 width should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRIFTDefaultQualityIsMedium(t *testing.T) {
|
||||
t.Setenv("ARTIFACT_QUALITY", "")
|
||||
if got := settings.Defaults().ArtifactQuality; got != "medium" {
|
||||
t.Fatalf("default artifact quality = %q, want medium", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIRequestSendsReferenceAsImageEdit(t *testing.T) {
|
||||
var sawImage bool
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/images/edits" {
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
|
||||
t.Errorf("bad auth: %q", got)
|
||||
}
|
||||
if err := r.ParseMultipartForm(2 << 20); err != nil {
|
||||
t.Fatalf("parse multipart: %v", err)
|
||||
}
|
||||
if r.FormValue("model") != "gpt-image-2" || r.FormValue("size") != "1024x1536" || r.FormValue("quality") != "medium" {
|
||||
t.Errorf("unexpected fields: model=%q size=%q quality=%q", r.FormValue("model"), r.FormValue("size"), r.FormValue("quality"))
|
||||
}
|
||||
files := r.MultipartForm.File["image[]"]
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("expected one reference image, got %d", len(files))
|
||||
}
|
||||
f, err := files[0].Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := io.ReadAll(f)
|
||||
_ = f.Close()
|
||||
sawImage = string(b) == "reference-bytes"
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("x-request-id", "req_test")
|
||||
fmt.Fprintf(w, `{"data":[{"b64_json":%q}],"usage":{"total_tokens":6000,"input_tokens":3000,"output_tokens":3000,"input_tokens_details":{"text_tokens":1000,"image_tokens":2000}}}`, base64.StdEncoding.EncodeToString([]byte("generated-png")))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
t.Setenv("OPENAI_API_KEY", "test-key")
|
||||
t.Setenv("OPENAI_BASE_URL", ts.URL)
|
||||
w := &Worker{http: ts.Client()}
|
||||
cfg := settings.Defaults()
|
||||
cfg.ArtifactModel = "gpt-image-2"
|
||||
cfg.ArtifactWidth = 1024
|
||||
cfg.ArtifactHeight = 1536
|
||||
cfg.ArtifactQuality = "medium"
|
||||
res, err := w.openAIRequest(context.Background(), cfg, "test prompt", []referenceImage{{Name: "reference.png", Bytes: []byte("reference-bytes")}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sawImage {
|
||||
t.Fatal("reference image was not transmitted")
|
||||
}
|
||||
if string(res.Bytes) != "generated-png" || res.Meta["endpoint"] != "images/edits" {
|
||||
t.Fatalf("unexpected response: %#v", res)
|
||||
}
|
||||
if res.Usage == nil || res.Usage.InputTokens != 3000 || res.Usage.TextInputTokens != 1000 || res.Usage.ImageInputTokens != 2000 || res.Usage.OutputTokens != 3000 {
|
||||
t.Fatalf("unexpected usage: %#v", res.Usage)
|
||||
}
|
||||
if res.EstimatedCostUSD == nil || *res.EstimatedCostUSD < 0.110999 || *res.EstimatedCostUSD > 0.111001 {
|
||||
t.Fatalf("unexpected cost estimate: %#v", res.EstimatedCostUSD)
|
||||
}
|
||||
}
|
||||
func TestEstimateOpenAIImageCost(t *testing.T) {
|
||||
u := imageUsage{InputTokens: 3000, TextInputTokens: 1000, ImageInputTokens: 2000, OutputTokens: 3000, TotalTokens: 6000}
|
||||
cost, basis, ok := estimateOpenAIImageCost("gpt-image-2", u)
|
||||
if !ok || basis == "" {
|
||||
t.Fatalf("expected priced usage, got ok=%v basis=%q", ok, basis)
|
||||
}
|
||||
if cost < 0.110999 || cost > 0.111001 {
|
||||
t.Fatalf("unexpected gpt-image-2 cost: %.9f", cost)
|
||||
}
|
||||
if _, _, ok := estimateOpenAIImageCost("future-image-model", u); ok {
|
||||
t.Fatal("unknown model must not get an invented price")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskStyleReferenceDefaultsAndCustom(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
w := &Worker{dir: dir}
|
||||
def, err := w.loadStyleReference("")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if def.Custom || def.Name != "default_style_reference.jpg" || len(def.Bytes) < 1000 || def.SHA256 == "" {
|
||||
t.Fatalf("unexpected default style: %#v", def)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(dir, "_styles"), 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
customBytes := append([]byte(nil), styleReferenceJPEG...)
|
||||
customName := "custom.jpg"
|
||||
if err := os.WriteFile(filepath.Join(dir, "_styles", customName), customBytes, 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
custom, err := w.loadStyleReference(customName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !custom.Custom || custom.Name != customName || custom.SHA256 != def.SHA256 {
|
||||
t.Fatalf("unexpected custom style: %#v", custom)
|
||||
}
|
||||
if _, err := w.loadStyleReference("../escape.jpg"); err == nil {
|
||||
t.Fatal("path traversal style reference should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIRequestSendsCharacterAndStyleReferences(t *testing.T) {
|
||||
var names []string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(4 << 20); err != nil {
|
||||
t.Fatalf("parse multipart: %v", err)
|
||||
}
|
||||
files := r.MultipartForm.File["image[]"]
|
||||
if len(files) != 2 {
|
||||
t.Fatalf("expected two references, got %d", len(files))
|
||||
}
|
||||
for _, f := range files {
|
||||
names = append(names, f.Filename)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"data":[{"b64_json":%q}]}`, base64.StdEncoding.EncodeToString([]byte("generated")))
|
||||
}))
|
||||
defer ts.Close()
|
||||
t.Setenv("OPENAI_API_KEY", "test-key")
|
||||
t.Setenv("OPENAI_BASE_URL", ts.URL)
|
||||
w := &Worker{http: ts.Client()}
|
||||
cfg := settings.Defaults()
|
||||
_, err := w.openAIRequest(context.Background(), cfg, "prompt", []referenceImage{
|
||||
{Name: "character_anchor.png", Bytes: []byte("anchor")},
|
||||
{Name: "task_style.jpg", Bytes: []byte("style")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Join(names, ",") != "character_anchor.png,task_style.jpg" {
|
||||
t.Fatalf("unexpected reference order: %v", names)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
package artifact
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"neuralhunt/internal/settings"
|
||||
)
|
||||
|
||||
type referenceImage struct {
|
||||
Name string
|
||||
Bytes []byte
|
||||
}
|
||||
|
||||
var collectionAnchorMu sync.Mutex
|
||||
|
||||
var ErrCharacterAnchorExists = errors.New("character anchor already exists")
|
||||
|
||||
func validateOpenAIImageSize(model string, width, height int) error {
|
||||
if !strings.EqualFold(strings.TrimSpace(model), "gpt-image-2") {
|
||||
return nil
|
||||
}
|
||||
pixels := width * height
|
||||
longEdge, shortEdge := width, height
|
||||
if height > width {
|
||||
longEdge, shortEdge = height, width
|
||||
}
|
||||
if width%16 != 0 || height%16 != 0 || longEdge > 3840 || shortEdge <= 0 || longEdge > 3*shortEdge || pixels < 655360 || pixels > 8294400 {
|
||||
return fmt.Errorf("gpt-image-2 size %dx%d is invalid: edges must be multiples of 16, max edge 3840, ratio <=3:1, pixels 655360..8294400", width, height)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) characterAnchorPath() string {
|
||||
return filepath.Join(w.dir, "_collection", "character_anchor.png")
|
||||
}
|
||||
|
||||
func (w *Worker) createCharacterAnchorLocked(ctx context.Context, cfg settings.Runtime, failIfExists bool) ([]byte, bool, error) {
|
||||
anchorPath := w.characterAnchorPath()
|
||||
if b, err := os.ReadFile(anchorPath); err == nil && len(b) > 1024 {
|
||||
if failIfExists {
|
||||
return nil, false, ErrCharacterAnchorExists
|
||||
}
|
||||
return b, false, nil
|
||||
}
|
||||
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(cfg.ArtifactModel)), "gpt-image-") {
|
||||
return nil, false, fmt.Errorf("collection character consistency requires a GPT Image model; got %q", cfg.ArtifactModel)
|
||||
}
|
||||
if err := validateOpenAIImageSize(cfg.ArtifactModel, cfg.ArtifactWidth, cfg.ArtifactHeight); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(anchorPath), 0o750); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
// The global anchor is intentionally generated without a style image. It is
|
||||
// an identity reference only; visual style is supplied independently per
|
||||
// task as Image 2 during actual card generation.
|
||||
res, err := w.openAIRequest(ctx, cfg, characterAnchorPrompt, nil)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("create canonical RIFT anchor: %w", err)
|
||||
}
|
||||
if err := w.recordOpenAIUsage(ctx, "", "character_anchor", res); err != nil {
|
||||
if res.Meta == nil {
|
||||
res.Meta = map[string]any{}
|
||||
}
|
||||
res.Meta["usage_log_error"] = err.Error()
|
||||
}
|
||||
if len(res.Bytes) == 0 {
|
||||
return nil, false, errors.New("create canonical RIFT anchor: OpenAI returned empty image")
|
||||
}
|
||||
if err := atomicWrite(anchorPath, res.Bytes, 0o640); err != nil {
|
||||
return nil, false, fmt.Errorf("store canonical RIFT anchor: %w", err)
|
||||
}
|
||||
return res.Bytes, true, nil
|
||||
}
|
||||
|
||||
// CreateCharacterAnchor creates the collection-wide RIFT identity reference on
|
||||
// demand from the admin UI. It deliberately refuses to overwrite an existing
|
||||
// anchor so the identity cannot be changed accidentally after a collection has
|
||||
// started.
|
||||
func (w *Worker) CreateCharacterAnchor(ctx context.Context) error {
|
||||
collectionAnchorMu.Lock()
|
||||
defer collectionAnchorMu.Unlock()
|
||||
_, _, err := w.createCharacterAnchorLocked(ctx, w.settings.Get(), true)
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *Worker) ensureCharacterAnchor(ctx context.Context, cfg settings.Runtime) ([]byte, bool, error) {
|
||||
collectionAnchorMu.Lock()
|
||||
defer collectionAnchorMu.Unlock()
|
||||
return w.createCharacterAnchorLocked(ctx, cfg, false)
|
||||
}
|
||||
|
||||
func (w *Worker) openAI(ctx context.Context, cfg settings.Runtime, x win, prompt string) (imageResult, error) {
|
||||
if err := validateOpenAIImageSize(cfg.ArtifactModel, cfg.ArtifactWidth, cfg.ArtifactHeight); err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(cfg.ArtifactPreset), collectionPresetRaccoon) {
|
||||
res, err := w.openAIRequest(ctx, cfg, prompt, nil)
|
||||
if err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
if err := w.recordOpenAIUsage(ctx, x.ID, "artifact", res); err != nil {
|
||||
if res.Meta == nil {
|
||||
res.Meta = map[string]any{}
|
||||
}
|
||||
res.Meta["usage_log_error"] = err.Error()
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
anchor, created, err := w.ensureCharacterAnchor(ctx, cfg)
|
||||
if err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
styleRef, err := w.loadStyleReference(x.StyleReference)
|
||||
if err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
res, err := w.openAIRequest(ctx, cfg, prompt, []referenceImage{
|
||||
{Name: "character_anchor.png", Bytes: anchor},
|
||||
{Name: styleRef.Name, Bytes: styleRef.Bytes},
|
||||
})
|
||||
if err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
h := sha256.Sum256(anchor)
|
||||
if res.Meta == nil {
|
||||
res.Meta = map[string]any{}
|
||||
}
|
||||
res.Meta["character_anchor_sha256"] = hex.EncodeToString(h[:])
|
||||
res.Meta["character_anchor_created"] = created
|
||||
res.Meta["style_reference_sha256"] = styleRef.SHA256
|
||||
res.Meta["style_reference_name"] = styleRef.Name
|
||||
res.Meta["style_reference_custom"] = styleRef.Custom
|
||||
res.Meta["reference_mode"] = "character-plus-task-style"
|
||||
if err := w.recordOpenAIUsage(ctx, x.ID, "artifact", res); err != nil {
|
||||
res.Meta["usage_log_error"] = err.Error()
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (w *Worker) openAIRequest(ctx context.Context, cfg settings.Runtime, prompt string, refs []referenceImage) (imageResult, error) {
|
||||
key := strings.TrimSpace(os.Getenv("OPENAI_API_KEY"))
|
||||
if key == "" {
|
||||
return imageResult{}, errors.New("OPENAI_API_KEY is not configured")
|
||||
}
|
||||
base := strings.TrimRight(strings.TrimSpace(os.Getenv("OPENAI_BASE_URL")), "/")
|
||||
if base == "" {
|
||||
base = "https://api.openai.com"
|
||||
}
|
||||
size := fmt.Sprintf("%dx%d", cfg.ArtifactWidth, cfg.ArtifactHeight)
|
||||
quality := strings.ToLower(strings.TrimSpace(cfg.ArtifactQuality))
|
||||
if quality == "" {
|
||||
quality = "medium"
|
||||
}
|
||||
|
||||
var req *http.Request
|
||||
var err error
|
||||
endpoint := ""
|
||||
if len(refs) > 0 {
|
||||
endpoint = base + "/v1/images/edits"
|
||||
if strings.HasSuffix(base, "/v1") {
|
||||
endpoint = base + "/images/edits"
|
||||
}
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
fields := map[string]string{
|
||||
"model": cfg.ArtifactModel,
|
||||
"prompt": prompt,
|
||||
"n": "1",
|
||||
"size": size,
|
||||
"quality": quality,
|
||||
"output_format": "png",
|
||||
}
|
||||
for k, v := range fields {
|
||||
if err := mw.WriteField(k, v); err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
}
|
||||
for i, ref := range refs {
|
||||
if len(ref.Bytes) == 0 {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(ref.Name)
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("reference_%d.png", i+1)
|
||||
}
|
||||
part, err := mw.CreateFormFile("image[]", name)
|
||||
if err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
if _, err := part.Write(ref.Bytes); err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
req, err = http.NewRequestWithContext(ctx, http.MethodPost, endpoint, &body)
|
||||
if err == nil {
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
}
|
||||
} else {
|
||||
endpoint = base + "/v1/images/generations"
|
||||
if strings.HasSuffix(base, "/v1") {
|
||||
endpoint = base + "/images/generations"
|
||||
}
|
||||
payload := map[string]any{
|
||||
"model": cfg.ArtifactModel,
|
||||
"prompt": prompt,
|
||||
"n": 1,
|
||||
"size": size,
|
||||
"quality": quality,
|
||||
"output_format": "png",
|
||||
}
|
||||
body, marshalErr := json.Marshal(payload)
|
||||
if marshalErr != nil {
|
||||
return imageResult{}, marshalErr
|
||||
}
|
||||
req, err = http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err == nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
resp, err := w.http.Do(req)
|
||||
if err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 128<<20))
|
||||
if err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
requestID := strings.TrimSpace(resp.Header.Get("x-request-id"))
|
||||
if resp.StatusCode/100 != 2 {
|
||||
if requestID != "" {
|
||||
return imageResult{}, fmt.Errorf("OpenAI images API HTTP %d (request %s): %s", resp.StatusCode, requestID, truncate(string(raw), 1400))
|
||||
}
|
||||
return imageResult{}, fmt.Errorf("OpenAI images API HTTP %d: %s", resp.StatusCode, truncate(string(raw), 1400))
|
||||
}
|
||||
var out struct {
|
||||
Data []struct {
|
||||
B64 string `json:"b64_json"`
|
||||
} `json:"data"`
|
||||
Usage *struct {
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
InputDetails struct {
|
||||
TextTokens int64 `json:"text_tokens"`
|
||||
ImageTokens int64 `json:"image_tokens"`
|
||||
} `json:"input_tokens_details"`
|
||||
} `json:"usage"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return imageResult{}, fmt.Errorf("decode OpenAI image response: %w", err)
|
||||
}
|
||||
if len(out.Data) == 0 || out.Data[0].B64 == "" {
|
||||
return imageResult{}, errors.New("OpenAI images API returned no b64_json")
|
||||
}
|
||||
img, err := base64.StdEncoding.DecodeString(out.Data[0].B64)
|
||||
if err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
meta := map[string]any{
|
||||
"model": cfg.ArtifactModel,
|
||||
"size": size,
|
||||
"quality": quality,
|
||||
"endpoint": map[bool]string{true: "images/edits", false: "images/generations"}[len(refs) > 0],
|
||||
"references": len(refs),
|
||||
}
|
||||
if requestID != "" {
|
||||
meta["request_id"] = requestID
|
||||
}
|
||||
result := imageResult{Bytes: img, Ext: "png", Provider: "openai", Meta: meta}
|
||||
if out.Usage != nil {
|
||||
u := imageUsage{
|
||||
TotalTokens: out.Usage.TotalTokens,
|
||||
InputTokens: out.Usage.InputTokens,
|
||||
OutputTokens: out.Usage.OutputTokens,
|
||||
TextInputTokens: out.Usage.InputDetails.TextTokens,
|
||||
ImageInputTokens: out.Usage.InputDetails.ImageTokens,
|
||||
}
|
||||
if u.available() {
|
||||
result.Usage = &u
|
||||
meta["usage"] = map[string]any{
|
||||
"total_tokens": u.TotalTokens,
|
||||
"input_tokens": u.InputTokens,
|
||||
"input_text_tokens": u.TextInputTokens,
|
||||
"input_image_tokens": u.ImageInputTokens,
|
||||
"output_tokens": u.OutputTokens,
|
||||
}
|
||||
if cost, basis, ok := estimateOpenAIImageCost(cfg.ArtifactModel, u); ok {
|
||||
result.EstimatedCostUSD = &cost
|
||||
result.PricingBasis = basis
|
||||
meta["estimated_cost_usd"] = cost
|
||||
meta["pricing_basis"] = basis
|
||||
} else if basis != "" {
|
||||
result.PricingBasis = basis
|
||||
meta["pricing_basis"] = basis
|
||||
meta["estimated_cost_available"] = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package artifact
|
||||
|
||||
import _ "embed"
|
||||
|
||||
// styleReferenceJPEG is the bundled fallback style used for tasks that do not
|
||||
// have their own uploaded style reference. In the RIFT v2.8 pipeline it is no
|
||||
// longer used to define the global character identity: character_anchor.png is
|
||||
// generated independently, while every card receives the global character
|
||||
// anchor plus a task-specific (or this default) style reference.
|
||||
//
|
||||
//go:embed assets/style_reference.jpg
|
||||
var styleReferenceJPEG []byte
|
||||
|
||||
// DefaultStyleReferenceJPEG returns a copy of the bundled fallback reference so
|
||||
// the authenticated/admin and public task-style preview handlers can render the
|
||||
// same fallback that the image worker will use.
|
||||
func DefaultStyleReferenceJPEG() []byte {
|
||||
out := make([]byte, len(styleReferenceJPEG))
|
||||
copy(out, styleReferenceJPEG)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package artifact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TaskStyleReference is the style-only reference used as Image 2 for RIFT
|
||||
// generation. Custom style assets live below <ARTIFACT_DIR>/_styles; an empty
|
||||
// task reference resolves to the bundled default image.
|
||||
type TaskStyleReference struct {
|
||||
Bytes []byte `json:"-"`
|
||||
Name string `json:"name"`
|
||||
ContentType string `json:"content_type"`
|
||||
Custom bool `json:"custom"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
func styleContentType(name string, b []byte) string {
|
||||
ct := http.DetectContentType(b)
|
||||
if ct == "application/octet-stream" {
|
||||
if ext := filepath.Ext(name); ext != "" {
|
||||
if v := mime.TypeByExtension(ext); v != "" {
|
||||
ct = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Split(ct, ";")[0]
|
||||
}
|
||||
|
||||
func (w *Worker) loadStyleReference(ref string) (TaskStyleReference, error) {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if ref == "" {
|
||||
b := DefaultStyleReferenceJPEG()
|
||||
h := sha256.Sum256(b)
|
||||
return TaskStyleReference{
|
||||
Bytes: b,
|
||||
Name: "default_style_reference.jpg",
|
||||
ContentType: "image/jpeg",
|
||||
Custom: false,
|
||||
SHA256: hex.EncodeToString(h[:]),
|
||||
}, nil
|
||||
}
|
||||
if filepath.Base(ref) != ref || strings.ContainsAny(ref, `/\\`) {
|
||||
return TaskStyleReference{}, fmt.Errorf("invalid task style reference %q", ref)
|
||||
}
|
||||
p := filepath.Join(w.dir, "_styles", ref)
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return TaskStyleReference{}, fmt.Errorf("read task style reference %q: %w", ref, err)
|
||||
}
|
||||
if len(b) < 128 {
|
||||
return TaskStyleReference{}, fmt.Errorf("task style reference %q is empty or invalid", ref)
|
||||
}
|
||||
h := sha256.Sum256(b)
|
||||
return TaskStyleReference{
|
||||
Bytes: b,
|
||||
Name: ref,
|
||||
ContentType: styleContentType(ref, b),
|
||||
Custom: true,
|
||||
SHA256: hex.EncodeToString(h[:]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TaskStyleReference reads the configured style for a task. It is used by the
|
||||
// UI preview endpoints and intentionally returns the bundled default when the
|
||||
// task has no custom style assigned.
|
||||
func (w *Worker) TaskStyleReference(ctx context.Context, taskID string) (TaskStyleReference, error) {
|
||||
var ref string
|
||||
if err := w.db.QueryRowContext(ctx, `SELECT nft_style_reference FROM tasks WHERE id=?`, taskID).Scan(&ref); err != nil {
|
||||
return TaskStyleReference{}, err
|
||||
}
|
||||
return w.loadStyleReference(ref)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package artifact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CreatePipelineTestCard exercises the local, post-generation RIFT pipeline
|
||||
// without calling any image provider. The existing character anchor is used as
|
||||
// the mock subject and the selected task style image as the background.
|
||||
func (w *Worker) CreatePipelineTestCard(ctx context.Context, taskID string) (string, error) {
|
||||
var x win
|
||||
if err := w.db.QueryRowContext(ctx, `SELECT id,public_seed,display_name,range_bits,nft_prompt_instructions,nft_negative_prompt,nft_style_reference FROM tasks WHERE id=?`, taskID).
|
||||
Scan(&x.ID, &x.Seed, &x.DisplayName, &x.RangeBits, &x.PromptInstructions, &x.NegativePrompt, &x.StyleReference); err != nil {
|
||||
return "", err
|
||||
}
|
||||
anchor, err := os.ReadFile(w.characterAnchorPath())
|
||||
if err != nil || len(anchor) <= 1024 {
|
||||
return "", errors.New("character_anchor.png fehlt; bitte zuerst den RIFT-Anchor erzeugen")
|
||||
}
|
||||
style, err := w.loadStyleReference(x.StyleReference)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
x.Winner = "LOCAL-PIPELINE-TEST"
|
||||
x.Signature = "NO-API-NO-SIGNATURE"
|
||||
x.Guess = "LOCAL-TEST"
|
||||
x.Completed = time.Now().UTC()
|
||||
x.PublicJWK = json.RawMessage(`{}`)
|
||||
traits := deriveCollectionTraits(x)
|
||||
art := mockTestArtwork(anchor, style)
|
||||
card := renderCardSVG(art, "svg", x, traits)
|
||||
dir := filepath.Join(w.dir, "_test", taskID)
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := atomicWrite(filepath.Join(dir, "art.svg"), art, 0o640); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := atomicWrite(filepath.Join(dir, "image.svg"), card, 0o640); err != nil {
|
||||
return "", err
|
||||
}
|
||||
manifest, _ := json.MarshalIndent(map[string]any{
|
||||
"kind": "local_pipeline_test", "api_calls": 0, "estimated_cost_usd": 0,
|
||||
"task_id": taskID, "style_reference": style.Name, "style_custom": style.Custom,
|
||||
"collection_character": "RIFT", "collection_traits": traits,
|
||||
"note": "Local test only. No OpenAI/API request was made and no task artifact status was changed.",
|
||||
}, "", " ")
|
||||
_ = atomicWrite(filepath.Join(dir, "manifest.json"), manifest, 0o640)
|
||||
return filepath.Join(dir, "image.svg"), nil
|
||||
}
|
||||
|
||||
func mockTestArtwork(anchor []byte, style TaskStyleReference) []byte {
|
||||
a := base64.StdEncoding.EncodeToString(anchor)
|
||||
s := base64.StdEncoding.EncodeToString(style.Bytes)
|
||||
styleMime := html.EscapeString(style.ContentType)
|
||||
if styleMime == "" {
|
||||
styleMime = "image/jpeg"
|
||||
}
|
||||
return []byte(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1536" viewBox="0 0 1024 1536">
|
||||
<defs>
|
||||
<filter id="blur"><feGaussianBlur stdDeviation="18"/></filter>
|
||||
<linearGradient id="shade" x1="0" y1="0" x2="0" y2="1"><stop stop-color="#07101d" stop-opacity=".18"/><stop offset="1" stop-color="#07101d" stop-opacity=".72"/></linearGradient>
|
||||
</defs>
|
||||
<image href="data:%s;base64,%s" x="-70" y="-50" width="1164" height="1636" preserveAspectRatio="xMidYMid slice" filter="url(#blur)" opacity=".62"/>
|
||||
<rect width="1024" height="1536" fill="url(#shade)"/>
|
||||
<image href="data:image/png;base64,%s" x="92" y="120" width="840" height="1180" preserveAspectRatio="xMidYMid meet"/>
|
||||
</svg>`, styleMime, s, a))
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package artifact
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// imageUsage mirrors the usage object returned by GPT Image endpoints. Keeping
|
||||
// it typed avoids losing precision when provider metadata is persisted.
|
||||
type imageUsage struct {
|
||||
TotalTokens int64 `json:"total_tokens"`
|
||||
InputTokens int64 `json:"input_tokens"`
|
||||
OutputTokens int64 `json:"output_tokens"`
|
||||
TextInputTokens int64 `json:"text_input_tokens"`
|
||||
ImageInputTokens int64 `json:"image_input_tokens"`
|
||||
}
|
||||
|
||||
func (u imageUsage) available() bool {
|
||||
return u.TotalTokens > 0 || u.InputTokens > 0 || u.OutputTokens > 0 || u.TextInputTokens > 0 || u.ImageInputTokens > 0
|
||||
}
|
||||
|
||||
type imagePricing struct {
|
||||
TextInputPerMTok float64
|
||||
ImageInputPerMTok float64
|
||||
ImageOutputPerMTok float64
|
||||
Basis string
|
||||
}
|
||||
|
||||
// openAIImagePricing intentionally supports only models whose public standard
|
||||
// token rates are pinned here. Unknown/future model names still get full usage
|
||||
// logging, but their local cost remains NULL instead of inventing a price.
|
||||
func openAIImagePricing(model string) (imagePricing, bool) {
|
||||
m := strings.ToLower(strings.TrimSpace(model))
|
||||
switch {
|
||||
case m == "gpt-image-2" || strings.HasPrefix(m, "gpt-image-2-"):
|
||||
return imagePricing{
|
||||
TextInputPerMTok: 5.00,
|
||||
ImageInputPerMTok: 8.00,
|
||||
ImageOutputPerMTok: 30.00,
|
||||
Basis: "openai-standard-2026-08-10:gpt-image-2:text-in-5,image-in-8,image-out-30-usd-per-mtok",
|
||||
}, true
|
||||
case m == "gpt-image-1.5" || strings.HasPrefix(m, "gpt-image-1.5-"):
|
||||
return imagePricing{
|
||||
TextInputPerMTok: 5.00,
|
||||
ImageInputPerMTok: 8.00,
|
||||
ImageOutputPerMTok: 32.00,
|
||||
Basis: "openai-standard-2026-08-10:gpt-image-1.5:text-in-5,image-in-8,image-out-32-usd-per-mtok",
|
||||
}, true
|
||||
case m == "chatgpt-image-latest":
|
||||
return imagePricing{
|
||||
TextInputPerMTok: 5.00,
|
||||
ImageInputPerMTok: 8.00,
|
||||
ImageOutputPerMTok: 32.00,
|
||||
Basis: "openai-standard-2026-08-10:chatgpt-image-latest:text-in-5,image-in-8,image-out-32-usd-per-mtok",
|
||||
}, true
|
||||
case m == "gpt-image-1":
|
||||
return imagePricing{
|
||||
TextInputPerMTok: 5.00,
|
||||
ImageInputPerMTok: 10.00,
|
||||
ImageOutputPerMTok: 40.00,
|
||||
Basis: "openai-standard-2026-08-10:gpt-image-1:text-in-5,image-in-10,image-out-40-usd-per-mtok",
|
||||
}, true
|
||||
case m == "gpt-image-1-mini":
|
||||
return imagePricing{
|
||||
TextInputPerMTok: 2.00,
|
||||
ImageInputPerMTok: 2.50,
|
||||
ImageOutputPerMTok: 8.00,
|
||||
Basis: "openai-standard-2026-08-10:gpt-image-1-mini:text-in-2,image-in-2.5,image-out-8-usd-per-mtok",
|
||||
}, true
|
||||
default:
|
||||
return imagePricing{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func estimateOpenAIImageCost(model string, u imageUsage) (float64, string, bool) {
|
||||
p, ok := openAIImagePricing(model)
|
||||
if !ok || !u.available() {
|
||||
return 0, "", false
|
||||
}
|
||||
// GPT Image reports the text/image input split. It matters because the two
|
||||
// modalities have different rates. If the split is missing, store usage but
|
||||
// do not claim a precise local cost.
|
||||
if u.InputTokens > 0 && u.TextInputTokens+u.ImageInputTokens <= 0 {
|
||||
return 0, p.Basis, false
|
||||
}
|
||||
if split := u.TextInputTokens + u.ImageInputTokens; u.InputTokens > 0 && split > 0 && split != u.InputTokens {
|
||||
return 0, p.Basis, false
|
||||
}
|
||||
cost := (float64(u.TextInputTokens)*p.TextInputPerMTok +
|
||||
float64(u.ImageInputTokens)*p.ImageInputPerMTok +
|
||||
float64(u.OutputTokens)*p.ImageOutputPerMTok) / 1_000_000
|
||||
return cost, p.Basis, true
|
||||
}
|
||||
|
||||
func (w *Worker) recordOpenAIUsage(ctx context.Context, taskID, kind string, res imageResult) error {
|
||||
// Log every successful OpenAI image call, even if a provider response omits
|
||||
// the optional usage block. That keeps the call/card counters truthful while
|
||||
// leaving the local cost NULL when there is not enough data to calculate it.
|
||||
u := imageUsage{}
|
||||
if res.Usage != nil {
|
||||
u = *res.Usage
|
||||
}
|
||||
model := metaString(res.Meta, "model")
|
||||
endpoint := metaString(res.Meta, "endpoint")
|
||||
size := metaString(res.Meta, "size")
|
||||
quality := metaString(res.Meta, "quality")
|
||||
requestID := metaString(res.Meta, "request_id")
|
||||
|
||||
var cost any
|
||||
if res.EstimatedCostUSD != nil {
|
||||
cost = *res.EstimatedCostUSD
|
||||
}
|
||||
metaJSON, _ := json.Marshal(res.Meta)
|
||||
_, err := w.db.ExecContext(ctx, `INSERT INTO artifact_api_usage(
|
||||
created_at,task_id,kind,provider,model,endpoint,size,quality,request_id,
|
||||
input_tokens,input_text_tokens,input_image_tokens,output_tokens,total_tokens,
|
||||
estimated_cost_usd,pricing_basis,meta_json)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
time.Now().UTC().UnixMilli(), nullableString(taskID), kind, res.Provider, model, endpoint, size, quality, nullableString(requestID),
|
||||
u.InputTokens, u.TextInputTokens, u.ImageInputTokens, u.OutputTokens, u.TotalTokens,
|
||||
cost, res.PricingBasis, string(metaJSON))
|
||||
return err
|
||||
}
|
||||
|
||||
func nullableString(s string) any {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func metaString(meta map[string]any, key string) string {
|
||||
if meta == nil {
|
||||
return ""
|
||||
}
|
||||
v, ok := meta[key]
|
||||
if !ok || v == nil {
|
||||
return ""
|
||||
}
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
package artifact
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"neuralhunt/internal/settings"
|
||||
)
|
||||
|
||||
type Worker struct {
|
||||
db *sql.DB
|
||||
dir string
|
||||
publicBase string
|
||||
settings *settings.Manager
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func New(db *sql.DB, dir string, sm *settings.Manager) (*Worker, error) {
|
||||
if dir == "" {
|
||||
dir = "./data/artifacts"
|
||||
}
|
||||
abs, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(abs, 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Worker{
|
||||
db: db,
|
||||
dir: abs,
|
||||
publicBase: strings.TrimRight(os.Getenv("ARTIFACT_PUBLIC_BASE_URL"), "/"),
|
||||
settings: sm,
|
||||
http: &http.Client{Timeout: envDuration("ARTIFACT_HTTP_TIMEOUT", 4*time.Minute)},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func envDuration(k string, d time.Duration) time.Duration {
|
||||
if raw := strings.TrimSpace(os.Getenv(k)); raw != "" {
|
||||
if v, err := time.ParseDuration(raw); err == nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
type win struct {
|
||||
ID, Seed, Winner, Signature, Guess string
|
||||
DisplayName string
|
||||
RangeBits int
|
||||
Completed time.Time
|
||||
PublicJWK json.RawMessage
|
||||
PromptInstructions string
|
||||
NegativePrompt string
|
||||
StyleReference string
|
||||
}
|
||||
|
||||
func (w *Worker) Run(ctx context.Context) {
|
||||
t := time.NewTicker(3 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
_ = w.one(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) claim(ctx context.Context) (win, error) {
|
||||
tx, err := w.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return win{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var x win
|
||||
var completedMS int64
|
||||
var raw string
|
||||
err = tx.QueryRowContext(ctx, `SELECT t.id,t.public_seed,t.winner_client_id,t.winner_signature,t.winning_guess,t.display_name,t.range_bits,t.completed_at,c.public_jwk,t.nft_prompt_instructions,t.nft_negative_prompt,t.nft_style_reference
|
||||
FROM tasks t JOIN clients c ON c.id=t.winner_client_id
|
||||
WHERE t.artifact_status='pending' ORDER BY t.completed_at LIMIT 1`).Scan(&x.ID, &x.Seed, &x.Winner, &x.Signature, &x.Guess, &x.DisplayName, &x.RangeBits, &completedMS, &raw, &x.PromptInstructions, &x.NegativePrompt, &x.StyleReference)
|
||||
if err != nil {
|
||||
return win{}, err
|
||||
}
|
||||
x.Completed = time.UnixMilli(completedMS).UTC()
|
||||
x.PublicJWK = json.RawMessage(raw)
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE tasks SET artifact_status='generating',artifact_error=NULL WHERE id=? AND artifact_status='pending'`, x.ID); err != nil {
|
||||
return win{}, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return win{}, err
|
||||
}
|
||||
return x, nil
|
||||
}
|
||||
|
||||
type imageResult struct {
|
||||
Bytes []byte
|
||||
Ext string
|
||||
Provider string
|
||||
Meta map[string]any
|
||||
Usage *imageUsage
|
||||
EstimatedCostUSD *float64
|
||||
PricingBasis string
|
||||
}
|
||||
|
||||
func (w *Worker) one(ctx context.Context) error {
|
||||
x, err := w.claim(ctx)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := w.settings.Get()
|
||||
preset := strings.ToLower(strings.TrimSpace(cfg.ArtifactPreset))
|
||||
traits := deriveCollectionTraits(x)
|
||||
prompt := ""
|
||||
negativePrompt := ""
|
||||
if preset == collectionPresetRaccoon {
|
||||
prompt = buildCollectionPrompt(x, traits)
|
||||
negativePrompt = collectionNegativePrompt(x)
|
||||
} else {
|
||||
prompt = buildPrompt(cfg.ArtifactPrompt, x.PromptInstructions, x)
|
||||
negativePrompt = joinPrompt(cfg.ArtifactNegativePrompt, x.NegativePrompt)
|
||||
}
|
||||
img, err := w.generate(ctx, cfg, x, prompt, negativePrompt)
|
||||
if err != nil {
|
||||
w.fail(ctx, x.ID, err)
|
||||
return err
|
||||
}
|
||||
if len(img.Bytes) == 0 {
|
||||
err := errors.New("image provider returned empty output")
|
||||
w.fail(ctx, x.ID, err)
|
||||
return err
|
||||
}
|
||||
if img.Ext == "" {
|
||||
img.Ext = "png"
|
||||
}
|
||||
|
||||
idSum := sha256.Sum256([]byte(x.ID + "|" + x.Winner + "|" + x.Signature))
|
||||
artifactID := "artifact_" + hex.EncodeToString(idSum[:12])
|
||||
promptSum := sha256.Sum256([]byte(prompt))
|
||||
rawArtSum := sha256.Sum256(img.Bytes)
|
||||
|
||||
finalBytes := img.Bytes
|
||||
finalExt := strings.TrimPrefix(strings.ToLower(img.Ext), ".")
|
||||
if preset == collectionPresetRaccoon {
|
||||
finalBytes = renderCardSVG(img.Bytes, img.Ext, x, traits)
|
||||
finalExt = "svg"
|
||||
}
|
||||
finalSum := sha256.Sum256(finalBytes)
|
||||
|
||||
manifest := map[string]any{
|
||||
"artifact_id": artifactID,
|
||||
"artifact_preset": preset,
|
||||
"task_id": x.ID,
|
||||
"task_display_name": x.DisplayName,
|
||||
"task_range_bits": x.RangeBits,
|
||||
"winner_client_id": x.Winner,
|
||||
"winner_public_jwk": json.RawMessage(x.PublicJWK),
|
||||
"winning_guess": x.Guess,
|
||||
"winner_guess_signature": x.Signature,
|
||||
"completed_at": x.Completed,
|
||||
"image_sha256": hex.EncodeToString(finalSum[:]),
|
||||
"raw_art_sha256": hex.EncodeToString(rawArtSum[:]),
|
||||
"prompt_sha256": hex.EncodeToString(promptSum[:]),
|
||||
"task_prompt_instructions": x.PromptInstructions,
|
||||
"task_style_reference": x.StyleReference,
|
||||
"provider": img.Provider,
|
||||
"provider_meta": img.Meta,
|
||||
"note": "winner_guess_signature authenticates the winning guess; image_sha256 binds the final programmatically laid-out collectible card into the server manifest",
|
||||
}
|
||||
if preset == collectionPresetRaccoon {
|
||||
manifest["collection_character"] = "RIFT"
|
||||
manifest["collection_traits"] = traits
|
||||
manifest["layout"] = map[string]any{
|
||||
"format": "svg",
|
||||
"width": 1024,
|
||||
"height": 1536,
|
||||
"mode": "programmatic-full-art-card-v1",
|
||||
}
|
||||
}
|
||||
mb, _ := json.MarshalIndent(manifest, "", " ")
|
||||
|
||||
relDir := filepath.Join("artifacts", artifactID)
|
||||
outDir := filepath.Join(w.dir, artifactID)
|
||||
if err := os.MkdirAll(outDir, 0o750); err != nil {
|
||||
w.fail(ctx, x.ID, err)
|
||||
return err
|
||||
}
|
||||
if preset == collectionPresetRaccoon {
|
||||
rawName := "art." + strings.TrimPrefix(strings.ToLower(img.Ext), ".")
|
||||
if err := atomicWrite(filepath.Join(outDir, rawName), img.Bytes, 0o640); err != nil {
|
||||
w.fail(ctx, x.ID, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
imageName := "image." + finalExt
|
||||
if err := atomicWrite(filepath.Join(outDir, imageName), finalBytes, 0o640); err != nil {
|
||||
w.fail(ctx, x.ID, err)
|
||||
return err
|
||||
}
|
||||
if err := atomicWrite(filepath.Join(outDir, "manifest.json"), mb, 0o640); err != nil {
|
||||
w.fail(ctx, x.ID, err)
|
||||
return err
|
||||
}
|
||||
imgURI := w.uri(filepath.ToSlash(filepath.Join(relDir, imageName)))
|
||||
manURI := w.uri(filepath.ToSlash(filepath.Join(relDir, "manifest.json")))
|
||||
_, err = w.db.ExecContext(ctx, `UPDATE tasks SET artifact_status='ready',artifact_uri=?,artifact_manifest_uri=?,artifact_error=NULL WHERE id=?`, imgURI, manURI, x.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
func joinPrompt(parts ...string) string {
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
func buildPrompt(base, taskInstructions string, x win) string {
|
||||
winner := x.Winner
|
||||
if len(winner) > 24 {
|
||||
winner = winner[:24]
|
||||
}
|
||||
creative := joinPrompt(base, taskInstructions)
|
||||
return creative + fmt.Sprintf("\nTask fingerprint: %s. Winner fingerprint: %s. Difficulty: %d-bit probability space. Public seed fingerprint: %s. Treat these values only as deterministic creative seeds; do not render them as readable text.", x.ID, winner, x.RangeBits, shortHash(x.Seed))
|
||||
}
|
||||
|
||||
func shortHash(s string) string {
|
||||
h := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(h[:8])
|
||||
}
|
||||
|
||||
func (w *Worker) generate(ctx context.Context, cfg settings.Runtime, x win, prompt, negativePrompt string) (imageResult, error) {
|
||||
provider := strings.ToLower(strings.TrimSpace(cfg.ArtifactProvider))
|
||||
switch provider {
|
||||
case "local":
|
||||
return imageResult{Bytes: procedural(x), Ext: "svg", Provider: "local-procedural", Meta: map[string]any{"model": "deterministic-svg"}}, nil
|
||||
case "openai":
|
||||
return w.openAI(ctx, cfg, x, prompt)
|
||||
case "comfyui":
|
||||
return w.comfyUI(ctx, cfg, x, prompt, negativePrompt)
|
||||
case "a1111":
|
||||
return w.a1111(ctx, cfg, x, prompt, negativePrompt)
|
||||
case "auto":
|
||||
var errs []string
|
||||
if strings.TrimSpace(os.Getenv("OPENAI_API_KEY")) != "" {
|
||||
if r, err := w.openAI(ctx, cfg, x, prompt); err == nil {
|
||||
return r, nil
|
||||
} else {
|
||||
errs = append(errs, "openai: "+err.Error())
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(os.Getenv("COMFYUI_URL")) != "" && strings.TrimSpace(os.Getenv("COMFYUI_WORKFLOW_PATH")) != "" {
|
||||
if r, err := w.comfyUI(ctx, cfg, x, prompt, negativePrompt); err == nil {
|
||||
return r, nil
|
||||
} else {
|
||||
errs = append(errs, "comfyui: "+err.Error())
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(os.Getenv("A1111_URL")) != "" {
|
||||
if r, err := w.a1111(ctx, cfg, x, prompt, negativePrompt); err == nil {
|
||||
return r, nil
|
||||
} else {
|
||||
errs = append(errs, "a1111: "+err.Error())
|
||||
}
|
||||
}
|
||||
meta := map[string]any{"model": "deterministic-svg"}
|
||||
if len(errs) > 0 {
|
||||
meta["fallback_errors"] = errs
|
||||
}
|
||||
return imageResult{Bytes: procedural(x), Ext: "svg", Provider: "local-procedural-fallback", Meta: meta}, nil
|
||||
default:
|
||||
return imageResult{}, fmt.Errorf("unknown artifact provider %q", provider)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) comfyUI(ctx context.Context, cfg settings.Runtime, x win, prompt, negativePrompt string) (imageResult, error) {
|
||||
base := strings.TrimRight(strings.TrimSpace(os.Getenv("COMFYUI_URL")), "/")
|
||||
workflowPath := strings.TrimSpace(os.Getenv("COMFYUI_WORKFLOW_PATH"))
|
||||
if base == "" || workflowPath == "" {
|
||||
return imageResult{}, errors.New("COMFYUI_URL and COMFYUI_WORKFLOW_PATH are required")
|
||||
}
|
||||
raw, err := os.ReadFile(workflowPath)
|
||||
if err != nil {
|
||||
return imageResult{}, fmt.Errorf("read ComfyUI workflow: %w", err)
|
||||
}
|
||||
var workflow any
|
||||
if err := json.Unmarshal(raw, &workflow); err != nil {
|
||||
return imageResult{}, fmt.Errorf("parse ComfyUI workflow: %w", err)
|
||||
}
|
||||
seed := deterministicSeed(x)
|
||||
replacements := map[string]string{
|
||||
"{{PROMPT}}": prompt,
|
||||
"{{NEGATIVE_PROMPT}}": negativePrompt,
|
||||
"{{SEED}}": strconv.FormatInt(seed, 10),
|
||||
"{{WIDTH}}": strconv.Itoa(cfg.ArtifactWidth),
|
||||
"{{HEIGHT}}": strconv.Itoa(cfg.ArtifactHeight),
|
||||
"{{STEPS}}": strconv.Itoa(cfg.ArtifactSteps),
|
||||
"{{MODEL}}": cfg.ArtifactModel,
|
||||
}
|
||||
workflow = replaceJSON(workflow, replacements)
|
||||
clientID := "neuralhunt-" + shortHash(x.ID)
|
||||
payload := map[string]any{"prompt": workflow, "client_id": clientID}
|
||||
var queued struct {
|
||||
PromptID string `json:"prompt_id"`
|
||||
Error any `json:"error"`
|
||||
Nodes map[string]any `json:"node_errors"`
|
||||
}
|
||||
if err := w.doJSON(ctx, http.MethodPost, base+"/prompt", payload, &queued, "", ""); err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
if queued.PromptID == "" {
|
||||
return imageResult{}, fmt.Errorf("ComfyUI rejected workflow: error=%v node_errors=%v", queued.Error, queued.Nodes)
|
||||
}
|
||||
deadline := time.Now().Add(envDuration("COMFYUI_POLL_TIMEOUT", 4*time.Minute))
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return imageResult{}, ctx.Err()
|
||||
case <-time.After(900 * time.Millisecond):
|
||||
}
|
||||
resp, err := w.http.Get(base + "/history/" + url.PathEscape(queued.PromptID))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
b, readErr := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
resp.Body.Close()
|
||||
if readErr != nil || resp.StatusCode/100 != 2 {
|
||||
continue
|
||||
}
|
||||
var history map[string]any
|
||||
if json.Unmarshal(b, &history) != nil {
|
||||
continue
|
||||
}
|
||||
entry, ok := history[queued.PromptID].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ref, ok := findComfyImage(entry["outputs"])
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("filename", ref.Filename)
|
||||
q.Set("subfolder", ref.Subfolder)
|
||||
q.Set("type", ref.Type)
|
||||
imgResp, err := w.http.Get(base + "/view?" + q.Encode())
|
||||
if err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
img, err := io.ReadAll(io.LimitReader(imgResp.Body, 64<<20))
|
||||
imgResp.Body.Close()
|
||||
if err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
if imgResp.StatusCode/100 != 2 {
|
||||
return imageResult{}, fmt.Errorf("ComfyUI /view HTTP %d", imgResp.StatusCode)
|
||||
}
|
||||
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(ref.Filename)), ".")
|
||||
if ext == "" {
|
||||
ext = "png"
|
||||
}
|
||||
return imageResult{Bytes: img, Ext: ext, Provider: "comfyui", Meta: map[string]any{"prompt_id": queued.PromptID, "model": cfg.ArtifactModel, "seed": seed}}, nil
|
||||
}
|
||||
return imageResult{}, errors.New("ComfyUI generation timed out")
|
||||
}
|
||||
|
||||
type comfyImageRef struct{ Filename, Subfolder, Type string }
|
||||
|
||||
func findComfyImage(v any) (comfyImageRef, bool) {
|
||||
switch x := v.(type) {
|
||||
case map[string]any:
|
||||
if fn, ok := x["filename"].(string); ok && fn != "" {
|
||||
sub, _ := x["subfolder"].(string)
|
||||
typ, _ := x["type"].(string)
|
||||
if typ == "" {
|
||||
typ = "output"
|
||||
}
|
||||
return comfyImageRef{fn, sub, typ}, true
|
||||
}
|
||||
for _, child := range x {
|
||||
if r, ok := findComfyImage(child); ok {
|
||||
return r, true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range x {
|
||||
if r, ok := findComfyImage(child); ok {
|
||||
return r, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return comfyImageRef{}, false
|
||||
}
|
||||
|
||||
func replaceJSON(v any, repl map[string]string) any {
|
||||
switch x := v.(type) {
|
||||
case map[string]any:
|
||||
for k, child := range x {
|
||||
x[k] = replaceJSON(child, repl)
|
||||
}
|
||||
return x
|
||||
case []any:
|
||||
for i := range x {
|
||||
x[i] = replaceJSON(x[i], repl)
|
||||
}
|
||||
return x
|
||||
case string:
|
||||
original := x
|
||||
for from, to := range repl {
|
||||
x = strings.ReplaceAll(x, from, to)
|
||||
}
|
||||
// Exact numeric placeholders become JSON numbers where possible.
|
||||
if original == "{{SEED}}" || original == "{{WIDTH}}" || original == "{{HEIGHT}}" || original == "{{STEPS}}" {
|
||||
if n, err := strconv.ParseInt(x, 10, 64); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return x
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) a1111(ctx context.Context, cfg settings.Runtime, x win, prompt, negativePrompt string) (imageResult, error) {
|
||||
base := strings.TrimRight(strings.TrimSpace(os.Getenv("A1111_URL")), "/")
|
||||
if base == "" {
|
||||
return imageResult{}, errors.New("A1111_URL is not configured")
|
||||
}
|
||||
seed := deterministicSeed(x)
|
||||
payload := map[string]any{
|
||||
"prompt": prompt,
|
||||
"negative_prompt": negativePrompt,
|
||||
"steps": cfg.ArtifactSteps,
|
||||
"width": cfg.ArtifactWidth,
|
||||
"height": cfg.ArtifactHeight,
|
||||
"seed": seed,
|
||||
"cfg_scale": envFloat("A1111_CFG_SCALE", 7.0),
|
||||
}
|
||||
if sampler := strings.TrimSpace(os.Getenv("A1111_SAMPLER")); sampler != "" {
|
||||
payload["sampler_name"] = sampler
|
||||
}
|
||||
if model := strings.TrimSpace(cfg.ArtifactModel); model != "" && !strings.HasPrefix(strings.ToLower(model), "gpt-image") {
|
||||
payload["override_settings"] = map[string]any{"sd_model_checkpoint": model}
|
||||
}
|
||||
var out struct {
|
||||
Images []string `json:"images"`
|
||||
Info string `json:"info"`
|
||||
}
|
||||
if err := w.doJSON(ctx, http.MethodPost, base+"/sdapi/v1/txt2img", payload, &out, os.Getenv("A1111_USER"), os.Getenv("A1111_PASSWORD")); err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
if len(out.Images) == 0 {
|
||||
return imageResult{}, errors.New("A1111 returned no images")
|
||||
}
|
||||
b64 := out.Images[0]
|
||||
if i := strings.Index(b64, ","); strings.HasPrefix(b64, "data:") && i >= 0 {
|
||||
b64 = b64[i+1:]
|
||||
}
|
||||
img, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
return imageResult{}, err
|
||||
}
|
||||
return imageResult{Bytes: img, Ext: "png", Provider: "a1111", Meta: map[string]any{"seed": seed, "steps": cfg.ArtifactSteps, "model": cfg.ArtifactModel}}, nil
|
||||
}
|
||||
|
||||
func (w *Worker) doJSON(ctx context.Context, method, endpoint string, payload any, out any, user, pass string) error {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if strings.TrimSpace(user) != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
resp, err := w.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("HTTP %d from %s: %s", resp.StatusCode, endpoint, truncate(string(raw), 1200))
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, out); err != nil {
|
||||
return fmt.Errorf("decode %s: %w", endpoint, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func envFloat(k string, d float64) float64 {
|
||||
if raw := strings.TrimSpace(os.Getenv(k)); raw != "" {
|
||||
if v, err := strconv.ParseFloat(raw, 64); err == nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func deterministicSeed(x win) int64 {
|
||||
h := sha256.Sum256([]byte(x.ID + "|" + x.Winner + "|" + x.Seed))
|
||||
var n uint64
|
||||
for i := 0; i < 8; i++ {
|
||||
n = (n << 8) | uint64(h[i])
|
||||
}
|
||||
return int64(n & 0x7fffffffffffffff)
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "…"
|
||||
}
|
||||
|
||||
func (w *Worker) uri(rel string) string {
|
||||
p := "/" + strings.TrimLeft(rel, "/")
|
||||
if w.publicBase == "" {
|
||||
return p
|
||||
}
|
||||
return w.publicBase + p
|
||||
}
|
||||
|
||||
func (w *Worker) fail(ctx context.Context, taskID string, err error) {
|
||||
_, _ = w.db.ExecContext(ctx, `UPDATE tasks SET artifact_status='error',artifact_error=? WHERE id=?`, err.Error(), taskID)
|
||||
}
|
||||
|
||||
func atomicWrite(path string, data []byte, mode os.FileMode) error {
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(path)
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
func procedural(x win) []byte {
|
||||
h := sha256.Sum256([]byte(x.ID + x.Winner + x.Seed))
|
||||
a := int(h[0]) % 360
|
||||
b := int(h[1]) % 360
|
||||
c := int(h[2]) % 360
|
||||
svg := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024"><defs><radialGradient id="g"><stop stop-color="hsl(%d 100%% 70%%)"/><stop offset=".55" stop-color="hsl(%d 85%% 45%%)"/><stop offset="1" stop-color="#04111f"/></radialGradient><filter id="gl"><feGaussianBlur stdDeviation="8"/></filter></defs><rect width="1024" height="1024" fill="#04111f"/><circle cx="512" cy="512" r="390" fill="none" stroke="#6574ff" opacity=".18" stroke-width="2"/><circle cx="512" cy="512" r="290" fill="url(#g)" opacity=".22" filter="url(#gl)"/><g fill="hsl(%d 100%% 75%%)">`, a, b, c)
|
||||
for i := 0; i < 480; i++ {
|
||||
v := int(h[i%32])
|
||||
xv := 512 + ((i*73 + v*11) % 620) - 310
|
||||
yv := 512 + ((i*97 + v*7) % 620) - 310
|
||||
r := 1 + (v % 5)
|
||||
svg += fmt.Sprintf(`<circle cx="%d" cy="%d" r="%d" opacity=".65"/>`, xv, yv, r)
|
||||
}
|
||||
svg += fmt.Sprintf(`</g><text x="52" y="900" fill="#d9f7ff" font-family="monospace" font-size="28">%s</text><text x="52" y="940" fill="#7fe7ff" font-family="monospace" font-size="18">winner %s</text></svg>`, x.ID, x.Winner[:min(20, len(x.Winner))])
|
||||
return []byte(svg)
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user