feat: full generation workflow with static perlin params

This commit is contained in:
ae 2025-06-01 18:21:18 +03:00
parent 81664d6bf8
commit de35974407
Signed by: ae
GPG Key ID: 995EFD5C1B532B3E
7 changed files with 810 additions and 0 deletions

327
banner.go Normal file
View File

@ -0,0 +1,327 @@
package main
import (
"fmt"
"image"
"image/color"
"image/png"
"math/rand/v2"
"os"
"path/filepath"
"time"
"github.com/charmbracelet/log"
"golang.org/x/image/font"
"golang.org/x/image/math/fixed"
)
const maxPositioningAttempts = 150
type TextData struct {
Text []string
Size float64
Positioned bool
X int // left edge of bounding box
Y int // top edge of bounding box
W int // width of bounding box
H int // height of bounding box
}
func NewTextData(text string, size float64, multiline bool) *TextData {
var splits []string
if multiline {
cutPos := int(len(text) / 2)
splits = []string{text[:cutPos], text[cutPos:]}
} else {
splits = []string{text}
}
return &TextData{splits, size, false, -1, -1, -1, -1}
}
// Consistent outline thickness based on font size
func getOutlineThickness(size float64) int {
thickness := int(size / 8)
if thickness < 1 {
thickness = 1
} else if thickness > 3 {
thickness = 3
}
return thickness
}
// Actual bounding box for text including outline
func getTextBounds(text []string, size float64) (width, height int) {
face, err := createFontFace(size)
if err != nil {
// fallback
charWidth := int(size * 0.6)
lineHeight := int(size * 1.3)
maxWidth := 0
for _, line := range text {
w := len(line) * charWidth
if w > maxWidth {
maxWidth = w
}
}
return maxWidth, len(text) * lineHeight
}
// dimensions for multiline text
drawer := &font.Drawer{Face: face}
metrics := face.Metrics()
lineHeight := int(metrics.Height >> 6)
maxWidth := 0
for _, line := range text {
advance := drawer.MeasureString(line)
w := int(advance >> 6)
if w > maxWidth {
maxWidth = w
}
}
// add outline padding
outlineThickness := getOutlineThickness(size)
padding := outlineThickness * 2
// add line spacing
totalHeight := len(text)*lineHeight + (len(text)-1)*int(size*0.3)
return maxWidth + padding, totalHeight + padding
}
func rectsOverlap(r1, r2 struct{ x, y, w, h int }) bool {
return !(r1.x+r1.w <= r2.x || r2.x+r2.w <= r1.x ||
r1.y+r1.h <= r2.y || r2.y+r2.h <= r1.y)
}
func drawTextWithOutline(canvas *image.RGBA, text []string, x, y int, textColor, outlineColor color.RGBA, size float64) {
face, err := createFontFace(size)
if err != nil {
log.Fatalf("Error creating font face: %s", err)
return
}
outlineThickness := getOutlineThickness(size)
metrics := face.Metrics()
ascent := int(metrics.Ascent >> 6)
lineHeight := int(metrics.Height >> 6)
for i, line := range text {
// baseline position for this line
baselineY := y + ascent + i*(lineHeight+int(size*0.3)) + outlineThickness
baselineX := x + outlineThickness
// draw outline
for dy := -outlineThickness; dy <= outlineThickness; dy++ {
for dx := -outlineThickness; dx <= outlineThickness; dx++ {
if dx != 0 || dy != 0 {
d := &font.Drawer{
Dst: canvas,
Src: image.NewUniform(outlineColor),
Face: face,
Dot: fixed.Point26_6{
X: fixed.I(baselineX + dx),
Y: fixed.I(baselineY + dy),
},
}
d.DrawString(line)
}
}
}
// draw main text
d := &font.Drawer{
Dst: canvas,
Src: image.NewUniform(textColor),
Face: face,
Dot: fixed.Point26_6{
X: fixed.I(baselineX),
Y: fixed.I(baselineY),
},
}
d.DrawString(line)
}
}
// Place all elements to canvas without overlaps
func findTextPositions(textData []*TextData) {
padding := 10
for i, data := range textData {
// get actual bounding box dimensions
w, h := getTextBounds(data.Text, data.Size)
data.W = w
data.H = h
placed := false
// safe bounds for positioning
minX := padding
maxX := config.BannerWidth - w - padding
minY := padding
maxY := config.BannerHeight - h - padding
// check if text can fit
if maxX < minX || maxY < minY {
log.Warnf("Text '%v' is too large for canvas", data.Text)
// place at top-left as fallback
data.X = minX
data.Y = minY
data.Positioned = true
continue
}
// try random positions
for attempt := 0; attempt < maxPositioningAttempts && !placed; attempt++ {
// gen. random pos.
x := rand.IntN(maxX-minX+1) + minX
y := rand.IntN(maxY-minY+1) + minY
// bounding box for cur. text
currentRect := struct{ x, y, w, h int }{
x: x,
y: y,
w: w,
h: h,
}
// checks of overlaps with already placed texts
overlaps := false
for j := 0; j < i; j++ {
other := textData[j]
if !other.Positioned {
continue
}
otherRect := struct{ x, y, w, h int }{
x: other.X - padding/2,
y: other.Y - padding/2,
w: other.W + padding,
h: other.H + padding,
}
if rectsOverlap(currentRect, otherRect) {
overlaps = true
break
}
}
if !overlaps {
data.X = x
data.Y = y
data.Positioned = true
placed = true
log.Debugf("Placed text '%v' at (%d, %d)", data.Text, x, y)
}
}
// fallback: use grid positioning
if !placed {
log.Warnf("Using fallback position for text '%v'", data.Text)
gridCols := 2
col := i % gridCols
row := i / gridCols
cellWidth := config.BannerWidth / gridCols
cellHeight := config.BannerHeight / 3 // NOTE: assuming max 3 rows
data.X = col*cellWidth + (cellWidth-w)/2
data.Y = row*cellHeight + (cellHeight-h)/2
// ensure within bounds
if data.X < padding {
data.X = padding
}
if data.Y < padding {
data.Y = padding
}
if data.X+w > config.BannerWidth-padding {
data.X = config.BannerWidth - w - padding
}
if data.Y+h > config.BannerHeight-padding {
data.Y = config.BannerHeight - h - padding
}
data.Positioned = true
}
}
}
func genPerlinBG() *image.RGBA {
palette, theme := getRandomThemePalette()
img := image.NewRGBA(image.Rect(0, 0, config.BannerWidth, config.BannerHeight))
// noise params for interesting patterns
scale1 := 0.01 + rand.Float64()*0.02 // primary pattern scale (adjusted by lib)
scale2 := 0.03 + rand.Float64()*0.03 // secondary detail
scale3 := 0.08 + rand.Float64()*0.05 // fine detail
// variation offsets
offsetX := rand.Float64() * 1000
offsetY := rand.Float64() * 1000
log.Infof("Generating background with theme: %s, scales: %.4f, %.4f, %.4f", theme, scale1, scale2, scale3)
for y := range config.BannerHeight {
for x := range config.BannerWidth {
// multi-octave noise
fx := (float64(x) + offsetX) * scale1
fy := (float64(y) + offsetY) * scale1
// noise values at different scales
noise1 := perlinNoise.Noise2D(fx, fy)
noise2 := perlinNoise.Noise2D(fx/scale1*scale2, fy/scale1*scale2)
noise3 := perlinNoise.Noise2D(fx/scale1*scale3, fy/scale1*scale3)
// combined noise layers with different weights
combined := noise1*0.6 + noise2*0.3 + noise3*0.1
// position-based gradient for a more dynamic look
gradientX := float64(x) / float64(config.BannerWidth) * 0.2
gradientY := float64(y) / float64(config.BannerHeight) * 0.1
// combined and normalized to [0, 1] range
noise := (combined + gradientX + gradientY + 1) / 2
if noise < 0 {
noise = 0
}
if noise > 1 {
noise = 1
}
pixelColor := palette.interpolate(noise)
img.Set(x, y, pixelColor)
}
}
return img
}
func genBanner(textData []*TextData) error {
canvas := genPerlinBG()
findTextPositions(textData)
for _, td := range textData {
if td.Positioned {
textColor, outlineColor := getRandomTextColors()
drawTextWithOutline(canvas, td.Text, td.X, td.Y, textColor, outlineColor, td.Size)
}
}
fn := fmt.Sprintf("banner_%d.png", time.Now().UnixMicro())
fp := filepath.Join(bannersDir, fn)
file, err := os.Create(fp)
if err != nil {
return err
}
defer file.Close()
return png.Encode(file, canvas)
}
// TODO: functions for banner rotation implementation

75
font.go Normal file
View File

@ -0,0 +1,75 @@
package main
import (
"errors"
"os"
"github.com/charmbracelet/log"
"github.com/golang/freetype/truetype"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
)
const defaultFontSize = 24.0
var fontManager *FontManager
type FontManager struct {
fontData []byte
fontType string // opentype or truetype
}
func initFontManager() error {
fontBytes, err := os.ReadFile(fontPath)
if err != nil {
return err
}
// initially try to parse as opentype/truetype
if _, err := opentype.Parse(fontBytes); err == nil {
fontManager = &FontManager{
fontData: fontBytes,
fontType: "opentype",
}
log.Debug("Initialized OpenType font manager")
} else if _, err := truetype.Parse(fontBytes); err == nil {
fontManager = &FontManager{
fontData: fontBytes,
fontType: "truetype",
}
log.Debug("Initialized TrueType font manager")
} else {
return err
}
return nil
}
func createFontFace(size float64) (font.Face, error) {
if fontManager == nil {
return nil, errors.New("font manager not initialized")
}
switch fontManager.fontType {
case "opentype":
parsedFont, err := opentype.Parse(fontManager.fontData)
if err != nil {
return nil, err
}
return opentype.NewFace(parsedFont, &opentype.FaceOptions{
Size: size,
DPI: 72,
})
case "truetype":
ttf, err := truetype.Parse(fontManager.fontData)
if err != nil {
return nil, err
}
return truetype.NewFace(ttf, &truetype.Options{
Size: size,
DPI: 72,
}), nil
default:
return nil, errors.New("unknown font type")
}
}

Binary file not shown.

30
go.mod Normal file
View File

@ -0,0 +1,30 @@
module git.umbrella.haus/ae/mandala
go 1.24.2
require (
github.com/aquilax/go-perlin v1.1.0
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/log v0.4.2 // indirect
github.com/charmbracelet/x/ansi v0.8.0 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/fogleman/gg v1.3.0 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/image v0.27.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.25.0 // indirect
)

44
go.sum Normal file
View File

@ -0,0 +1,44 @@
github.com/aquilax/go-perlin v1.1.0 h1:Gg+3jQ24wT4Y5GI7TCRLmYarzUG0k+n/JATFqOimb7s=
github.com/aquilax/go-perlin v1.1.0/go.mod h1:z9Rl7EM4BZY0Ikp2fEN1I5mKSOJ26HQpk0O2TBdN2HE=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig=
github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=

95
main.go Normal file
View File

@ -0,0 +1,95 @@
package main
import (
"encoding/json"
"math/rand"
"os"
"time"
"github.com/aquilax/go-perlin"
"github.com/charmbracelet/log"
)
const (
bannersDir = "./banners" // mountable
fontPath = "./fonts/PixelOperator-Bold.ttf"
configFile = "./config.json" // mountable
)
var (
config Config
perlinNoise perlin.Perlin
)
type Config struct {
ClearwebURL string `json:"clearweb_url"`
OnionURL string `json:"onion_url"`
ColorThemes []string `json:"color_themes"`
MaxBanners uint `json:"max_banners"`
BannerWidth int `json:"banner_width"`
BannerHeight int `json:"banner_height"`
LogLevel log.Level `json:"log_level"` // -4=DBG, 0=INFO, 4=WARN, 8=ERR, 12=FATAL
}
type BannerInfo struct {
Filename string
CreatedAt time.Time
}
func init() {
config = Config{
ClearwebURL: "https://example.com",
OnionURL: "http://example.onion",
ColorThemes: []string{"vibrant", "sunset", "ocean", "neon", "forest", "cosmic"},
MaxBanners: 50,
BannerWidth: 600,
BannerHeight: 120,
LogLevel: log.InfoLevel,
}
if data, err := os.ReadFile(configFile); err == nil {
if err := json.Unmarshal(data, &config); err != nil {
log.Fatalf("Error parsing config: %s", err)
os.Exit(1)
}
} else {
data, _ := json.MarshalIndent(config, "", " ")
os.WriteFile(configFile, data, 0644)
log.Info("Created default config - please update your URLs into it")
os.Exit(0)
}
log.SetLevel(config.LogLevel)
if err := os.MkdirAll(bannersDir, 0755); err != nil {
log.Fatalf("Error creating banner output directory: %s", err)
os.Exit(1)
}
if err := initFontManager(); err != nil {
log.Fatalf("Error initializing font manager: %s", err)
os.Exit(1)
}
// perlin input params: alpha, beta, octaves, seed
perlinNoise = *perlin.NewPerlin(2.0, 2.0, 3, rand.Int63())
}
func main() {
// TODO: cli param `--portable <n>` for instant generation of n banners
// TODO: ticker setup for banner rotation
// NOTE: makes the life a lot easier to draw the bigger element first,
// then find a place for the smaller one
textData := []*TextData{
NewTextData(config.OnionURL, defaultFontSize, true),
NewTextData(config.ClearwebURL, defaultFontSize+4.0, false),
}
for range 10 {
if err := genBanner(textData); err != nil {
log.Errorf("Error generating banner: %s", err)
}
}
}

239
palette.go Normal file
View File

@ -0,0 +1,239 @@
package main
import (
"image/color"
"math/rand/v2"
)
type Palette []color.RGBA
var textColors = []color.RGBA{
// basic bright colors
{255, 100, 100, 255}, // red
{100, 255, 100, 255}, // green
{100, 100, 255, 255}, // blue
{255, 255, 100, 255}, // yellow
{255, 100, 255, 255}, // magenta
{100, 255, 255, 255}, // cyan
{255, 255, 255, 255}, // white
{255, 150, 50, 255}, // orange
{150, 255, 50, 255}, // lime
{255, 50, 150, 255}, // pink
{50, 150, 255, 255}, // sky blue
{200, 200, 255, 255}, // light blue
// extended primary variations
{255, 50, 50, 255}, // bright red
{255, 150, 150, 255}, // light red
{50, 255, 50, 255}, // bright green
{150, 255, 150, 255}, // light green
{50, 50, 255, 255}, // bright blue
{150, 150, 255, 255}, // light blue variant
// warm colors
{255, 200, 50, 255}, // golden yellow
{255, 175, 0, 255}, // amber
{255, 69, 0, 255}, // red orange
{255, 20, 147, 255}, // deep pink
{255, 105, 180, 255}, // hot pink
{255, 182, 193, 255}, // light pink
{255, 160, 122, 255}, // light salmon
{255, 99, 71, 255}, // tomato
{255, 127, 80, 255}, // coral
// cool colors
{0, 255, 127, 255}, // spring green
{0, 250, 154, 255}, // medium spring green
{64, 224, 208, 255}, // turquoise
{72, 209, 204, 255}, // medium turquoise
{135, 206, 250, 255}, // light sky blue
{70, 130, 180, 255}, // steel blue
{100, 149, 237, 255}, // cornflower blue
{123, 104, 238, 255}, // medium slate blue
{138, 43, 226, 255}, // blue violet
// purple spectrum
{147, 112, 219, 255}, // medium purple
{186, 85, 211, 255}, // medium orchid
{218, 112, 214, 255}, // orchid
{221, 160, 221, 255}, // plum
{238, 130, 238, 255}, // violet
{255, 0, 255, 255}, // pure magenta
{199, 21, 133, 255}, // medium violet red
{219, 112, 147, 255}, // pale violet red
// neon/electric colors
{57, 255, 20, 255}, // electric green
{255, 20, 147, 255}, // electric pink
{0, 255, 255, 255}, // electric cyan
{255, 255, 0, 255}, // electric yellow
{255, 0, 127, 255}, // electric rose
{127, 255, 0, 255}, // electric lime
{255, 127, 0, 255}, // electric orange
{0, 127, 255, 255}, // electric blue
// pastel variants
{255, 218, 185, 255}, // peach puff
{255, 228, 196, 255}, // bisque
{255, 239, 213, 255}, // papaya whip
{240, 248, 255, 255}, // alice blue
{230, 230, 250, 255}, // lavender
{255, 240, 245, 255}, // lavender blush
{255, 228, 225, 255}, // misty rose
{245, 255, 250, 255}, // mint cream
// earth tones (brighter versions)
{210, 180, 140, 255}, // tan
{222, 184, 135, 255}, // burlywood
{238, 203, 173, 255}, // navajo white
{255, 218, 185, 255}, // peach
{255, 165, 79, 255}, // sandy brown
{205, 133, 63, 255}, // peru
{160, 82, 45, 255}, // saddle brown (lighter)
{210, 105, 30, 255}, // chocolate (lighter)
// jewel tones
{50, 205, 50, 255}, // lime green
{220, 20, 60, 255}, // crimson
{75, 0, 130, 255}, // indigo (brightened)
{128, 0, 128, 255}, // purple (brightened)
{165, 42, 42, 255}, // brown (brightened)
{128, 128, 0, 255}, // olive (brightened)
{0, 128, 128, 255}, // teal (brightened)
{128, 0, 0, 255}, // maroon (brightened)
// unique/exotic colors
{255, 215, 0, 255}, // gold
{192, 192, 192, 255}, // silver
{255, 20, 147, 255}, // deep pink
{0, 191, 255, 255}, // deep sky blue
{30, 144, 255, 255}, // dodger blue
{255, 69, 0, 255}, // orange red
{154, 205, 50, 255}, // yellow green
{32, 178, 170, 255}, // light sea green
{60, 179, 113, 255}, // medium sea green
// vibrant spectrum
{127, 255, 212, 255}, // aquamarine
{240, 230, 140, 255}, // khaki
{238, 232, 170, 255}, // pale goldenrod
{250, 240, 230, 255}, // linen
{253, 245, 230, 255}, // old lace
{255, 250, 240, 255}, // floral white
{248, 248, 255, 255}, // ghost white
{245, 245, 220, 255}, // beige
}
var themePalettes = map[string]Palette{
"vibrant": {
{255, 0, 150, 255}, // hot pink
{255, 100, 0, 255}, // orange
{255, 255, 0, 255}, // yellow
{0, 255, 100, 255}, // lime
{0, 150, 255, 255}, // sky blue
{150, 0, 255, 255}, // purple
},
"sunset": {
{255, 94, 77, 255}, // coral
{255, 154, 0, 255}, // orange
{255, 206, 84, 255}, // yellow
{255, 118, 117, 255}, // light coral
{240, 147, 43, 255}, // dark orange
{235, 77, 75, 255}, // red
},
"ocean": {
{0, 123, 255, 255}, // blue
{0, 200, 255, 255}, // light blue
{0, 255, 200, 255}, // cyan
{100, 200, 255, 255}, // sky blue
{0, 150, 200, 255}, // dark blue
{50, 255, 150, 255}, // aqua
},
"neon": {
{255, 0, 255, 255}, // magenta
{0, 255, 255, 255}, // cyan
{255, 255, 0, 255}, // yellow
{255, 0, 100, 255}, // hot pink
{100, 255, 0, 255}, // lime
{150, 0, 255, 255}, // purple
},
"forest": {
{34, 139, 34, 255}, // forest green
{107, 142, 35, 255}, // olive
{154, 205, 50, 255}, // yellow green
{50, 205, 50, 255}, // lime green
{0, 128, 0, 255}, // green
{85, 107, 47, 255}, // dark olive
},
"cosmic": {
{138, 43, 226, 255}, // blue violet
{75, 0, 130, 255}, // indigo
{148, 0, 211, 255}, // dark violet
{255, 20, 147, 255}, // deep pink
{255, 0, 255, 255}, // magenta
{72, 61, 139, 255}, // dark slate blue
},
}
func getRandomTextColors() (color.RGBA, color.RGBA) {
textColor := textColors[rand.IntN(len(textColors))]
// calculate perceived brightness using luminance formula
// std. weights for human eye sensitivity: 0.299*R + 0.587*G + 0.114*B
brightness := 0.299*float64(textColor.R) + 0.587*float64(textColor.G) + 0.114*float64(textColor.B)
var outlineColor color.RGBA
if brightness < 128 { // Dark text - use white outline
outlineColor = color.RGBA{255, 255, 255, 255}
} else { // Bright text - use black outline
outlineColor = color.RGBA{0, 0, 0, 255}
}
return textColor, outlineColor
}
func getThemePalette(name string) Palette {
if palette, exists := themePalettes[name]; exists {
return palette
}
return themePalettes["vibrant"] // default
}
func getRandomThemePalette() (Palette, string) {
name := config.ColorThemes[rand.IntN(len(config.ColorThemes))]
return getThemePalette(name), name
}
func (p Palette) interpolate(t float64) color.RGBA {
if len(p) == 0 {
return color.RGBA{128, 128, 128, 255}
}
// clamp to to [0, 1]
if t < 0 {
t = 0
}
if t > 1 {
t = 1
}
// calculate which colors to interpolate between
scaledT := t * float64(len(p)-1)
index := int(scaledT)
localT := scaledT - float64(index)
if index >= len(p)-1 {
return p[len(p)-1]
}
c1 := p[index]
c2 := p[index+1]
// linear interpolation between the two colors
r := uint8(float64(c1.R)*(1-localT) + float64(c2.R)*localT)
g := uint8(float64(c1.G)*(1-localT) + float64(c2.G)*localT)
b := uint8(float64(c1.B)*(1-localT) + float64(c2.B)*localT)
a := uint8(float64(c1.A)*(1-localT) + float64(c2.A)*localT)
return color.RGBA{r, g, b, a}
}