feat: full generation workflow with static perlin params
This commit is contained in:
parent
81664d6bf8
commit
de35974407
327
banner.go
Normal file
327
banner.go
Normal 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
75
font.go
Normal 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")
|
||||
}
|
||||
}
|
BIN
fonts/PixelOperator-Bold.ttf
Normal file
BIN
fonts/PixelOperator-Bold.ttf
Normal file
Binary file not shown.
30
go.mod
Normal file
30
go.mod
Normal 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
44
go.sum
Normal 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
95
main.go
Normal 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
239
palette.go
Normal 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}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user