mandala/banner.go

465 lines
12 KiB
Go

package main
import (
"fmt"
"image"
"image/color"
"image/png"
"math"
"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}
}
func getOutlineThickness(size float64) int {
thickness := int(size / 8)
if thickness < 1 {
thickness = 1
} else if thickness > 3 {
thickness = 3
}
return thickness
}
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)
}
}
func abs(a int) int {
if a < 0 {
return -a
}
return a
}
func findTextPositions(textData []*TextData, filename string) {
type attemptedPos struct {
x, y int
}
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
}
// track attempted positions to avoid redundancy
attempts := make([]attemptedPos, 0, maxPositioningAttempts)
// min. distance between attempts (1/5 of text height)
minAttemptDistance := max(h/5, 10)
isTooCloseToAttempted := func(x, y int) bool {
for _, pos := range attempts {
if abs(x-pos.x) < minAttemptDistance && abs(y-pos.y) < minAttemptDistance {
return true
}
}
return false
}
// 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
// skip if too close to a previously attempt pos.
if isTooCloseToAttempted(x, y) {
continue
}
attempts = append(attempts, attemptedPos{x: x, y: y})
// bounding box for cur. text
curRect := 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 := range i {
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(curRect, 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: place to a free corner
if !placed {
log.Debugf("Using fallback position for text '%s' in '%s'", data.Text, filename)
corners := []struct {
name string
x, y int
}{
{"top-left", padding, padding},
{"top-right", config.BannerWidth - w - padding, padding},
{"bottom-left", padding, config.BannerHeight - h - padding},
{"bottom-right", config.BannerWidth - w - padding, config.BannerHeight - h - padding},
}
bestCorner := 0
minOverlapArea := int(^uint(0) >> 1) // max. int
// try each corner and find the one with least overlap
for cornerIdx, corner := range corners {
curRect := struct{ x, y, w, h int }{
x: corner.x,
y: corner.y,
w: w,
h: h,
}
totalOverlapArea := 0
// calculate total overlap area with existing texts
for j := range i {
other := textData[j]
if !other.Positioned {
continue
}
otherRect := struct{ x, y, w, h int }{
x: other.X,
y: other.Y,
w: other.W,
h: other.H,
}
if rectsOverlap(curRect, otherRect) {
// intersection rectangle
overlapLeft := max(curRect.x, otherRect.x)
overlapTop := max(curRect.y, otherRect.y)
overlapRight := min(curRect.x+curRect.w, otherRect.x+otherRect.w)
overlapBottom := min(curRect.y+curRect.h, otherRect.y+otherRect.h)
overlapWidth := overlapRight - overlapLeft
overlapHeight := overlapBottom - overlapTop
overlapArea := overlapWidth * overlapHeight
totalOverlapArea += overlapArea
}
if totalOverlapArea < minOverlapArea {
minOverlapArea = totalOverlapArea
bestCorner = cornerIdx
}
// if no overlap, use the pos. immediately
if totalOverlapArea == 0 {
break
}
}
}
// place at the best corner
data.X = corners[bestCorner].x
data.Y = corners[bestCorner].y
data.Positioned = true
if minOverlapArea > 0 {
log.Debugf("Placed text '%v' at %s corner with some overlap (area: %d)", data.Text, corners[bestCorner].name, minOverlapArea)
} else {
log.Debugf("Placed text '%v' at %s corner with no overlap", data.Text, corners[bestCorner].name)
}
}
}
}
func genPerlinBG() (*image.RGBA, string) {
palette, theme := getRandomThemePalette()
img := image.NewRGBA(image.Rect(0, 0, config.BannerWidth, config.BannerHeight))
// noise params for interesting patterns
scale1 := 0.008 + rand.Float64()*0.015 // 0.008-0.023 (large patterns)
scale2 := 0.025 + rand.Float64()*0.035 // 0.025-0.06 (medium detail)
scale3 := 0.060 + rand.Float64()*0.040 // 0.060-0.100 (fine detail, reduced grain)
// variation offsets
offsetX := rand.Float64() * 5000
offsetY := rand.Float64() * 5000
// noise layer weights for different effects
weight1 := 0.4 + rand.Float64()*0.4 // 0.4-0.8 (large patterns)
weight2 := 0.15 + rand.Float64()*0.25 // 0.15-0.4 (medium detail)
weight3 := 0.05 + rand.Float64()*0.15 // 0.15-0.4 (fine detail, reduced grain)
// normalize weights to sum to 1.0
totalWeight := weight1 + weight2 + weight3
weight1 /= totalWeight
weight2 /= totalWeight
weight3 /= totalWeight
// FIX: limit gradient strength to prevent overwhelming the noise
gradientStrengthX := rand.Float64() * 0.25 // old: 0.4
gradientStrengthY := rand.Float64() * 0.2 // old: 0.3
gradientDirectionX := 1.0
gradientDirectionY := 1.0
// sometimes reverse gradient direction for variety
if rand.Float64() < 0.3 {
gradientDirectionX = -1.0
}
if rand.Float64() < 0.3 {
gradientDirectionY = -1.0
}
// randomize noise combination method
combineMethod := rand.IntN(4)
// fix: increase distortion chance and make it more visible
useDistortion := rand.Float64() < 0.4 // old: 30%
distortionStrength := 0.05 + rand.Float64()*0.15 // 0.05-0.2 (old: 0.0-0.1)
log.Debugf("BG theme: %s / Scales: %.4f, %.4f, %.4f / Weights: %.3f, %.3f, %.3f",
theme, scale1, scale2, scale3, weight1, weight2, weight3)
log.Debugf("Gradient: X=%.3f*%.0f, Y=%.3f*%.0f / Combine method: %d / Distortion %t (%.3f)",
gradientStrengthX, gradientDirectionX, gradientStrengthY, gradientDirectionY,
combineMethod, useDistortion, distortionStrength)
for y := range config.BannerHeight {
for x := range config.BannerWidth {
baseX := float64(x) + offsetX
baseY := float64(y) + offsetY
// optional distortion for more organic patterns
if useDistortion {
baseX += math.Sin(float64(y)*0.015) * distortionStrength * 200
baseY += math.Cos(float64(x)*0.015) * distortionStrength * 200
}
// multi-octave noise with randomized scales
fx1 := baseX * scale1
fy1 := baseY * scale1
fx2 := baseX * scale2
fy2 := baseY * scale2
fx3 := baseX * scale3
fy3 := baseY * scale3
// noise values at different scales
noise1 := perlinNoise.Noise2D(fx1, fy1)
noise2 := perlinNoise.Noise2D(fx2, fy2)
noise3 := perlinNoise.Noise2D(fx3, fy3)
// different combination methods for variety
var combined float64
switch combineMethod {
case 0: // standard linear combination
combined = noise1*weight1 + noise2*weight2 + noise3*weight3
case 1: // multiplicative blend
combined = (noise1*weight1)*(1+noise2*weight2)*(1+noise3*weight3) - 1
case 2: // maximum blend (creates sharper patterns)
values := []float64{noise1 * weight1, noise2 * weight2, noise3 * weight3}
combined = math.Max(math.Max(values[0], values[1]), values[2])
case 3: // turbulence (abs. values create more chaotic patterns)
combined = math.Abs(noise1)*weight1 + math.Abs(noise2)*weight2 + math.Abs(noise3)*weight3
}
// randomized gradient with variable direction and strength
gradientX := (float64(x) / float64(config.BannerWidth)) * gradientStrengthX * gradientDirectionX
gradientY := (float64(y) / float64(config.BannerHeight)) * gradientStrengthY * gradientDirectionY
// combined and normalized to [0, 1] range
noise := min(max((combined+gradientX+gradientY+1)/2, 0), 1)
pixelColor := palette.interpolate(noise)
img.Set(x, y, pixelColor)
}
}
return img, theme
}
func genBanner(textData []*TextData) error {
fn := fmt.Sprintf("banner_%d.png", time.Now().UnixMicro())
canvas, theme := genPerlinBG()
findTextPositions(textData, fn)
for _, td := range textData {
if td.Positioned {
textColor, outlineColor := getRandomTextColors()
drawTextWithOutline(canvas, td.Text, td.X, td.Y, textColor, outlineColor, td.Size)
}
}
fp := filepath.Join(bannersDir, fn)
file, err := os.Create(fp)
if err != nil {
return err
}
defer file.Close()
if err := png.Encode(file, canvas); err != nil {
return err
}
log.Infof("Output '%s' with theme '%s'", fn, theme)
return nil
}
// TODO: functions for banner rotation implementation