465 lines
12 KiB
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
|