clone

fifteen puzzle games for the terminal, with daily puzzles, weekly ladders and xp, built on bubble tea

cover-art

Dami committed 439fd4e
commit
439fd4e24aa82924d2be6ad138f2fe3ae32cd46c
parent
5ec53c4185febd7a127ba3fb6b590b016a11af59
author
Dami <dami.etoile@gmail.com>
2 files changed, +657 -143
Mpdfexport/cover_art.go686++++++++++++++++++++++++------
Mpdfexport/cover_art_test.go114+++++
M pdfexport/cover_art.go +543 -143
@@ -115,6 +115,96 @@ type coverMarkProfile struct {
115115 cutout color.RGBA116116 }117117 118+type coverPalette struct {119+ bgTop color.RGBA120+ bgMid color.RGBA121+ bgBottom color.RGBA122+ accentA color.RGBA123+ accentB color.RGBA124+ ink color.RGBA125+ nodeOuter color.RGBA126+ nodeInner color.RGBA127+ markCutout color.RGBA128+ grainWarm color.RGBA129+ grainCool color.RGBA130+}131+132+type coverPaletteFamily struct {133+ name string134+ bgShift [3]float64135+ accentShift [2]float64136+ satBias float64137+ valueBias float64138+}139+140+type coverArchetype uint8141+142+const (143+ coverArchetypeConstellation coverArchetype = iota144+ coverArchetypeVortex145+ coverArchetypeBands146+ coverArchetypeDriftField147+ coverArchetypeRadialMesh148+ coverArchetypeSparseGlyph149+ coverArchetypeCount150+)151+152+type coverModifier uint8153+154+const (155+ coverModifierDenseFlow coverModifier = iota156+ coverModifierHighOrbit157+ coverModifierQuietLattice158+ coverModifierMicroMarks159+ coverModifierGrainHeavy160+ coverModifierNegativeSpace161+)162+163+var coverPaletteFamilies = []coverPaletteFamily{164+ {165+ name: "tropical",166+ bgShift: [3]float64{0.10, 0.02, -0.14},167+ accentShift: [2]float64{0.33, 0.52},168+ satBias: 0.08,169+ valueBias: 0.03,170+ },171+ {172+ name: "sunset",173+ bgShift: [3]float64{0.05, -0.05, -0.17},174+ accentShift: [2]float64{0.19, 0.38},175+ satBias: 0.10,176+ valueBias: 0.04,177+ },178+ {179+ name: "electric-mineral",180+ bgShift: [3]float64{-0.16, -0.08, 0.01},181+ accentShift: [2]float64{0.36, -0.28},182+ satBias: 0.13,183+ valueBias: -0.02,184+ },185+ {186+ name: "aurora",187+ bgShift: [3]float64{0.22, 0.10, -0.09},188+ accentShift: [2]float64{0.45, 0.62},189+ satBias: 0.11,190+ valueBias: 0.02,191+ },192+ {193+ name: "ember",194+ bgShift: [3]float64{0.00, -0.11, -0.22},195+ accentShift: [2]float64{-0.36, 0.11},196+ satBias: 0.09,197+ valueBias: -0.03,198+ },199+ {200+ name: "citrus-marine",201+ bgShift: [3]float64{0.14, 0.03, -0.13},202+ accentShift: [2]float64{0.30, -0.22},203+ satBias: 0.12,204+ valueBias: 0.01,205+ },206+}207+118208 type coverArtDirection struct {119209 motif int120210 top color.RGBA
@@ -183,91 +273,86 @@ func renderCoverArtworkPNG(seedText, variant string, base RGB) []byte {
183273 }184274 185275 func newCoverArtDirection(seed uint64, base RGB) coverArtDirection {186- rng := coverStreamRNG(seed, "direction")187- motif := int(seed % 5)188-189- warm := blendRGB(190- brightenRGB(base, 0.30+rng.Float64()*0.28),191- color.RGBA{R: 255, G: 230, B: 180, A: 255},192- 0.34+rng.Float64()*0.38,193- )194- cool := blendRGB(195- brightenRGB(base, 0.18+rng.Float64()*0.24),196- color.RGBA{R: 88, G: 212, B: 230, A: 255},197- 0.30+rng.Float64()*0.42,198- )199- dusk := blendRGB(200- darkenRGB(base, 0.50+rng.Float64()*0.22),201- color.RGBA{R: 9, G: 18, B: 32, A: 255},202- 0.44+rng.Float64()*0.30,203- )204- ink := blendRGB(dusk, color.RGBA{R: 22, G: 36, B: 52, A: 255}, 0.40+rng.Float64()*0.25)276+ paletteRNG := coverStreamRNG(seed, "palette")277+ structureRNG := coverStreamRNG(seed, "structure")278+ palette := newCoverPalette(base, paletteRNG)279+ primary, modifiers := pickCoverComposition(structureRNG)205280 206- top := blendRGB(warm, color.RGBA{R: 255, G: 255, B: 251, A: 255}, 0.15+rng.Float64()*0.18)207- mid := blendRGB(cool, warm, 0.16+rng.Float64()*0.26)208- bottom := blendRGB(dusk, color.RGBA{R: 4, G: 9, B: 16, A: 255}, 0.30+rng.Float64()*0.22)281+ glows := make([]coverGlow, 0, 5)282+ anchorColor := palette.accentA283+ if structureRNG.Intn(2) == 0 {284+ anchorColor = palette.accentB285+ }286+ glows = append(glows, coverGlow{287+ center: coverVec2{288+ x: 0.12 + structureRNG.Float64()*0.76,289+ y: 0.10 + structureRNG.Float64()*0.78,290+ },291+ spread: 0.24 + structureRNG.Float64()*0.30,292+ strength: 0.46 + structureRNG.Float64()*0.28,293+ col: jitterRGBA(anchorColor, structureRNG, 26),294+ })209295 210- glows := make([]coverGlow, 0, 4)211- glowCount := 3 + rng.Intn(2)296+ glowCount := 2 + structureRNG.Intn(3)212297 for i := 0; i < glowCount; i++ {213- col := warm298+ col := palette.accentA214299 if i%2 == 1 {215- col = cool300+ col = palette.accentB216301 }217- col = jitterRGBA(col, rng, 18)302+ col = jitterRGBA(col, structureRNG, 28)218303 glows = append(glows, coverGlow{219304 center: coverVec2{220- x: 0.08 + rng.Float64()*0.84,221- y: 0.10 + rng.Float64()*0.82,305+ x: 0.06 + structureRNG.Float64()*0.88,306+ y: 0.08 + structureRNG.Float64()*0.84,222307 },223- spread: 0.42 + rng.Float64()*0.70,224- strength: 0.16 + rng.Float64()*0.50,308+ spread: 0.42 + structureRNG.Float64()*0.70,309+ strength: 0.12 + structureRNG.Float64()*0.28,225310 col: col,226311 })227312 }228313 229314 field := coverFieldProfile{230- freqX: 4.0 + rng.Float64()*5.8,231- freqY: 3.8 + rng.Float64()*5.6,232- freqMixX: 0.5 + rng.Float64()*1.7,233- freqMixY: 0.5 + rng.Float64()*1.7,234- freqCurlX: 0.4 + rng.Float64()*1.9,235- freqCurlY: 0.4 + rng.Float64()*1.9,236- ampX: 0.65 + rng.Float64()*0.95,237- ampY: 0.65 + rng.Float64()*0.95,238- ampMix: 0.28 + rng.Float64()*0.86,239- ampCurl: 0.20 + rng.Float64()*0.86,240- phaseX: 0.25 + rng.Float64()*1.40,241- phaseY: 0.25 + rng.Float64()*1.40,242- phaseMix: 0.20 + rng.Float64()*1.10,243- phaseCurl: 0.20 + rng.Float64()*1.10,244- swirl: (rng.Float64()*2 - 1) * 0.34,245- shear: (rng.Float64()*2 - 1) * 0.34,246- pinch: (rng.Float64()*2 - 1) * 0.52,315+ freqX: 3.8 + structureRNG.Float64()*6.8,316+ freqY: 3.6 + structureRNG.Float64()*6.5,317+ freqMixX: 0.5 + structureRNG.Float64()*1.8,318+ freqMixY: 0.5 + structureRNG.Float64()*1.8,319+ freqCurlX: 0.4 + structureRNG.Float64()*2.0,320+ freqCurlY: 0.4 + structureRNG.Float64()*2.0,321+ ampX: 0.60 + structureRNG.Float64()*1.00,322+ ampY: 0.60 + structureRNG.Float64()*1.00,323+ ampMix: 0.26 + structureRNG.Float64()*0.92,324+ ampCurl: 0.24 + structureRNG.Float64()*0.88,325+ phaseX: 0.22 + structureRNG.Float64()*1.45,326+ phaseY: 0.22 + structureRNG.Float64()*1.45,327+ phaseMix: 0.20 + structureRNG.Float64()*1.20,328+ phaseCurl: 0.20 + structureRNG.Float64()*1.20,329+ swirl: (structureRNG.Float64()*2 - 1) * 0.40,330+ shear: (structureRNG.Float64()*2 - 1) * 0.36,331+ pinch: (structureRNG.Float64()*2 - 1) * 0.56,247332 pivot: coverVec2{248- x: 0.22 + rng.Float64()*0.56,249- y: 0.20 + rng.Float64()*0.62,333+ x: 0.20 + structureRNG.Float64()*0.60,334+ y: 0.18 + structureRNG.Float64()*0.64,250335 },251336 }252337 253338 flowLayers := make([]coverFlowLayer, 0, 4)254- layerCount := 2 + rng.Intn(3)339+ layerCount := 2 + structureRNG.Intn(3)255340 for i := 0; i < layerCount; i++ {256341 t := float64(i) / float64(maxInt(1, layerCount-1))257- a := jitterRGBA(lerpRGB(warm, cool, 0.22+t*0.54), rng, 20)258- b := jitterRGBA(lerpRGB(cool, warm, 0.18+t*0.66), rng, 20)342+ a := jitterRGBA(lerpRGB(palette.accentA, palette.bgMid, 0.28+t*0.42), structureRNG, 24)343+ b := jitterRGBA(lerpRGB(palette.accentB, palette.bgTop, 0.16+t*0.38), structureRNG, 24)259344 flowLayers = append(flowLayers, coverFlowLayer{260- count: 250 + rng.Intn(320),261- steps: 90 + rng.Intn(120),262- step: 1.18 + rng.Float64()*1.72,263- alpha: uint8(14 + rng.Intn(38)),264- radius: 0.76 + rng.Float64()*0.86,265- drift: 0.44 + rng.Float64()*1.68,266- phase: rng.Float64() * math.Pi * 2,267- waveXFreq: 5.0 + rng.Float64()*7.2,268- waveYFreq: 5.0 + rng.Float64()*7.2,269- waveXAmp: 0.05 + rng.Float64()*0.33,270- waveYAmp: 0.05 + rng.Float64()*0.33,345+ count: 220 + structureRNG.Intn(360),346+ steps: 96 + structureRNG.Intn(132),347+ step: 1.10 + structureRNG.Float64()*1.80,348+ alpha: uint8(22 + structureRNG.Intn(42)),349+ radius: 0.78 + structureRNG.Float64()*0.98,350+ drift: 0.40 + structureRNG.Float64()*1.72,351+ phase: structureRNG.Float64() * math.Pi * 2,352+ waveXFreq: 4.6 + structureRNG.Float64()*7.8,353+ waveYFreq: 4.6 + structureRNG.Float64()*7.8,354+ waveXAmp: 0.06 + structureRNG.Float64()*0.34,355+ waveYAmp: 0.06 + structureRNG.Float64()*0.34,271356 a: a,272357 b: b,273358 })
@@ -275,110 +360,349 @@ func newCoverArtDirection(seed uint64, base RGB) coverArtDirection {
275360 276361 lattice := coverLatticeProfile{277362 enabled: true,278- layout: rng.Intn(3),279- cols: 7 + rng.Intn(6),280- rows: 6 + rng.Intn(6),281- neighbors: 2 + rng.Intn(3),282- jitterX: 24 + rng.Float64()*46,283- jitterY: 28 + rng.Float64()*52,284- center: coverVec2{x: 0.30 + rng.Float64()*0.40, y: 0.26 + rng.Float64()*0.44},285- radialWarp: (rng.Float64()*2 - 1) * 0.34,286- edge: color.RGBA{R: ink.R, G: ink.G, B: ink.B, A: uint8(40 + rng.Intn(60))},287- nodeOuter: blendRGB(warm, color.RGBA{R: 255, G: 245, B: 218, A: 255}, 0.35),288- nodeInner: blendRGB(dusk, color.RGBA{R: 16, G: 30, B: 48, A: 255}, 0.25),289- nodeSize: 3.6 + rng.Float64()*3.6,290- coreSize: 1.5 + rng.Float64()*1.6,363+ layout: structureRNG.Intn(3),364+ cols: 7 + structureRNG.Intn(6),365+ rows: 6 + structureRNG.Intn(7),366+ neighbors: 2 + structureRNG.Intn(3),367+ jitterX: 24 + structureRNG.Float64()*48,368+ jitterY: 28 + structureRNG.Float64()*56,369+ center: coverVec2{x: 0.28 + structureRNG.Float64()*0.44, y: 0.24 + structureRNG.Float64()*0.50},370+ radialWarp: (structureRNG.Float64()*2 - 1) * 0.40,371+ edge: color.RGBA{R: palette.ink.R, G: palette.ink.G, B: palette.ink.B, A: uint8(52 + structureRNG.Intn(76))},372+ nodeOuter: palette.nodeOuter,373+ nodeInner: palette.nodeInner,374+ nodeSize: 3.3 + structureRNG.Float64()*3.8,375+ coreSize: 1.4 + structureRNG.Float64()*1.7,291376 }292377 293378 orbit := coverOrbitProfile{294379 enabled: true,295- center: coverVec2{x: 0.34 + rng.Float64()*0.32, y: 0.30 + rng.Float64()*0.32},296- count: 8 + rng.Intn(18),297- radiusStart: 40 + rng.Float64()*50,298- radiusStep: 15 + rng.Float64()*18,299- arcCoverage: 0.42 + rng.Float64()*0.58,300- segmentsMin: 38 + rng.Intn(34),301- segmentsJitter: 46 + rng.Intn(52),302- eccentricity: 0.70 + rng.Float64()*0.48,303- wobble: 2.8 + rng.Float64()*8.4,304- dotSize: 0.86 + rng.Float64()*0.76,305- colorA: jitterRGBA(lerpRGB(warm, cool, 0.32), rng, 16),306- colorB: jitterRGBA(lerpRGB(cool, warm, 0.70), rng, 16),307- alphaBase: uint8(12 + rng.Intn(20)),308- alphaStep: uint8(1 + rng.Intn(2)),380+ center: coverVec2{x: 0.30 + structureRNG.Float64()*0.38, y: 0.26 + structureRNG.Float64()*0.38},381+ count: 10 + structureRNG.Intn(18),382+ radiusStart: 36 + structureRNG.Float64()*52,383+ radiusStep: 13 + structureRNG.Float64()*20,384+ arcCoverage: 0.48 + structureRNG.Float64()*0.48,385+ segmentsMin: 34 + structureRNG.Intn(40),386+ segmentsJitter: 42 + structureRNG.Intn(56),387+ eccentricity: 0.66 + structureRNG.Float64()*0.56,388+ wobble: 2.6 + structureRNG.Float64()*8.8,389+ dotSize: 0.84 + structureRNG.Float64()*0.86,390+ colorA: jitterRGBA(lerpRGB(palette.accentA, palette.bgTop, 0.22), structureRNG, 18),391+ colorB: jitterRGBA(lerpRGB(palette.accentB, palette.bgMid, 0.34), structureRNG, 18),392+ alphaBase: uint8(18 + structureRNG.Intn(28)),393+ alphaStep: uint8(1 + structureRNG.Intn(3)),309394 }310395 311396 marks := coverMarkProfile{312397 enabled: true,313- count: 24 + rng.Intn(54),314- gridW: 8 + rng.Intn(8),315- gridH: 10 + rng.Intn(9),316- jitter: 8 + rng.Float64()*28,317- sizeMin: 1.8 + rng.Float64()*1.3,318- sizeMax: 3.8 + rng.Float64()*2.8,319- alphaBase: uint8(32 + rng.Intn(36)),320- alphaRange: uint8(12 + rng.Intn(32)),321- colorA: jitterRGBA(lerpRGB(warm, cool, 0.20), rng, 20),322- colorB: jitterRGBA(lerpRGB(cool, warm, 0.72), rng, 20),323- cutout: blendRGB(dusk, color.RGBA{R: 5, G: 10, B: 18, A: 255}, 0.35),398+ count: 28 + structureRNG.Intn(58),399+ gridW: 8 + structureRNG.Intn(9),400+ gridH: 10 + structureRNG.Intn(10),401+ jitter: 8 + structureRNG.Float64()*30,402+ sizeMin: 1.7 + structureRNG.Float64()*1.5,403+ sizeMax: 3.4 + structureRNG.Float64()*3.0,404+ alphaBase: uint8(40 + structureRNG.Intn(40)),405+ alphaRange: uint8(20 + structureRNG.Intn(30)),406+ colorA: jitterRGBA(lerpRGB(palette.accentA, palette.bgTop, 0.18), structureRNG, 22),407+ colorB: jitterRGBA(lerpRGB(palette.accentB, palette.bgMid, 0.30), structureRNG, 22),408+ cutout: palette.markCutout,324409 }325410 326411 direction := coverArtDirection{327- motif: motif,328- top: top,329- mid: mid,330- bottom: bottom,331- verticalCurve: 1.24 + rng.Float64()*1.08,412+ motif: int(primary),413+ top: palette.bgTop,414+ mid: palette.bgMid,415+ bottom: palette.bgBottom,416+ verticalCurve: 1.18 + structureRNG.Float64()*1.14,332417 glows: glows,333418 field: field,334419 flowLayers: flowLayers,335420 lattice: lattice,336421 orbit: orbit,337422 marks: marks,338- grainCount: 32000 + rng.Intn(34000),339- grainWarm: blendRGB(warm, color.RGBA{R: 255, G: 246, B: 226, A: 255}, 0.42),340- grainCool: blendRGB(dusk, color.RGBA{R: 5, G: 12, B: 20, A: 255}, 0.52),423+ grainCount: 34000 + structureRNG.Intn(32000),424+ grainWarm: palette.grainWarm,425+ grainCool: palette.grainCool,341426 }342427 343- switch direction.motif {344- case 0:428+ applyCoverPrimaryArchetype(&direction, primary, structureRNG)429+ for _, modifier := range modifiers {430+ applyCoverModifier(&direction, modifier, structureRNG)431+ }432+433+ return direction434+}435+436+func coverArchetypeForSeed(seed uint64) coverArchetype {437+ primary, _ := coverCompositionForSeed(seed)438+ return primary439+}440+441+func coverCompositionForSeed(seed uint64) (coverArchetype, []coverModifier) {442+ structureRNG := coverStreamRNG(seed, "structure")443+ return pickCoverComposition(structureRNG)444+}445+446+func pickCoverComposition(rng *rand.Rand) (coverArchetype, []coverModifier) {447+ primary := pickWeightedCoverArchetype(rng)448+ modifierCount := 0449+ roll := rng.Float64()450+ switch {451+ case roll < 0.58:452+ modifierCount = 1453+ default:454+ modifierCount = 2455+ }456+457+ modifierPool := []coverModifier{458+ coverModifierDenseFlow,459+ coverModifierHighOrbit,460+ coverModifierQuietLattice,461+ coverModifierMicroMarks,462+ coverModifierGrainHeavy,463+ coverModifierNegativeSpace,464+ }465+ rng.Shuffle(len(modifierPool), func(i, j int) {466+ modifierPool[i], modifierPool[j] = modifierPool[j], modifierPool[i]467+ })468+469+ modifiers := make([]coverModifier, 0, modifierCount)470+ for _, modifier := range modifierPool {471+ if len(modifiers) >= modifierCount {472+ break473+ }474+ if primary == coverArchetypeSparseGlyph && modifier == coverModifierNegativeSpace {475+ continue476+ }477+ if primary == coverArchetypeBands && modifier == coverModifierHighOrbit {478+ continue479+ }480+ modifiers = append(modifiers, modifier)481+ }482+ return primary, modifiers483+}484+485+func pickWeightedCoverArchetype(rng *rand.Rand) coverArchetype {486+ weights := [...]int{18, 16, 14, 18, 17, 17}487+ total := 0488+ for _, weight := range weights {489+ total += weight490+ }491+ pick := rng.Intn(total)492+ for idx, weight := range weights {493+ if pick < weight {494+ return coverArchetype(idx)495+ }496+ pick -= weight497+ }498+ return coverArchetypeSparseGlyph499+}500+501+func newCoverPalette(base RGB, rng *rand.Rand) coverPalette {502+ baseColor := rgbToColor(base)503+ baseHue, baseSat, baseVal := rgbToHSV(baseColor)504+ family := coverPaletteFamilies[rng.Intn(len(coverPaletteFamilies))]505+ baseHue = wrapHue(baseHue + randCentered(rng, 0.32))506+507+ baseSat = clamp(0.14, 0.50, baseSat*0.62+0.11+family.satBias*0.18+rng.Float64()*0.07)508+ baseVal = clamp(0.34, 0.86, baseVal*0.84+0.12+family.valueBias*0.15+randCentered(rng, 0.05))509+510+ top := hsvToRGB(511+ wrapHue(baseHue+family.bgShift[0]+randCentered(rng, 0.10)),512+ clamp(0.14, 0.34, baseSat*0.50+0.04+family.satBias*0.28+rng.Float64()*0.08),513+ clamp(0.74, 0.98, baseVal+0.16+family.valueBias*0.24+rng.Float64()*0.14),514+ )515+ mid := hsvToRGB(516+ wrapHue(baseHue+family.bgShift[1]+randCentered(rng, 0.14)),517+ clamp(0.20, 0.50, baseSat*0.74+0.02+family.satBias*0.40+rng.Float64()*0.10),518+ clamp(0.46, 0.88, baseVal-0.02+family.valueBias*0.12+randCentered(rng, 0.12)),519+ )520+ bottom := hsvToRGB(521+ wrapHue(baseHue+family.bgShift[2]+randCentered(rng, 0.16)),522+ clamp(0.18, 0.54, baseSat*0.82+0.01+family.satBias*0.35+rng.Float64()*0.10),523+ clamp(0.08, 0.36, baseVal*0.32+0.02+family.valueBias*0.10+randCentered(rng, 0.10)),524+ )525+ accentA := hsvToRGB(526+ wrapHue(baseHue+family.accentShift[0]+randCentered(rng, 0.18)),527+ clamp(0.30, 0.62, baseSat+0.12+family.satBias*0.40+rng.Float64()*0.15),528+ clamp(0.62, 0.96, baseVal+0.14+rng.Float64()*0.12),529+ )530+ accentB := hsvToRGB(531+ wrapHue(baseHue+family.accentShift[1]+randCentered(rng, 0.20)),532+ clamp(0.28, 0.60, baseSat+0.10+family.satBias*0.38+rng.Float64()*0.16),533+ clamp(0.56, 0.92, baseVal+0.08+rng.Float64()*0.12),534+ )535+ ink := hsvToRGB(536+ wrapHue(baseHue+0.50+family.bgShift[2]*0.35),537+ clamp(0.22, 0.44, 0.24+baseSat*0.22),538+ clamp(0.06, 0.22, 0.12+(1-baseVal)*0.10),539+ )540+541+ return coverPalette{542+ bgTop: top,543+ bgMid: mid,544+ bgBottom: bottom,545+ accentA: accentA,546+ accentB: accentB,547+ ink: ink,548+ nodeOuter: blendRGB(top, color.RGBA{R: 255, G: 244, B: 220, A: 255}, 0.26),549+ nodeInner: blendRGB(ink, bottom, 0.34),550+ markCutout: blendRGB(bottom, color.RGBA{R: 6, G: 11, B: 20, A: 255}, 0.30),551+ grainWarm: blendRGB(top, color.RGBA{R: 255, G: 248, B: 232, A: 255}, 0.36),552+ grainCool: blendRGB(bottom, ink, 0.44),553+ }554+}555+556+func applyCoverPrimaryArchetype(557+ direction *coverArtDirection,558+ primary coverArchetype,559+ rng *rand.Rand,560+) {561+ switch primary {562+ case coverArchetypeConstellation:345563 direction.lattice.layout = 0346564 direction.lattice.neighbors = minInt(5, direction.lattice.neighbors+1)347- direction.orbit.arcCoverage = clamp01(direction.orbit.arcCoverage*0.72 + 0.10)348- direction.marks.count += 10349- case 1:565+ direction.lattice.edge.A = minUint8(160, direction.lattice.edge.A+22)350566 for i := range direction.flowLayers {351- direction.flowLayers[i].count += 120352- direction.flowLayers[i].steps += 18353- direction.flowLayers[i].alpha = minUint8(70, direction.flowLayers[i].alpha+12)354- direction.flowLayers[i].drift *= 1.24567+ direction.flowLayers[i].count = maxInt(160, int(float64(direction.flowLayers[i].count)*0.76))568+ direction.flowLayers[i].alpha = maxUint8(16, direction.flowLayers[i].alpha-6)355569 }356- direction.lattice.edge.A = maxUint8(18, direction.lattice.edge.A-22)357- direction.orbit.count = maxInt(6, direction.orbit.count/2)358- direction.grainCount += 6000359- case 2:360- direction.orbit.count += 12361- direction.orbit.arcCoverage = clamp01(direction.orbit.arcCoverage*1.18 + 0.08)362- direction.orbit.radiusStep *= 0.86363- direction.lattice.enabled = rng.Float64() > 0.26570+ direction.orbit.enabled = false571+ direction.orbit.count = maxInt(4, direction.orbit.count/2)572+ direction.orbit.arcCoverage = clamp01(direction.orbit.arcCoverage*0.72 + 0.12)573+ direction.marks.count += 12574+ direction.field.swirl *= 0.88575+ case coverArchetypeVortex:576+ direction.lattice.enabled = false577+ direction.orbit.count += 14578+ direction.orbit.arcCoverage = clamp01(direction.orbit.arcCoverage*1.20 + 0.08)579+ direction.orbit.radiusStep *= 0.84580+ direction.field.swirl *= 1.42364581 for i := range direction.flowLayers {365- direction.flowLayers[i].count = maxInt(180, direction.flowLayers[i].count-60)582+ direction.flowLayers[i].alpha = minUint8(86, direction.flowLayers[i].alpha+10)583+ direction.flowLayers[i].drift *= 1.18366584 }367- case 3:585+ direction.marks.count = maxInt(8, direction.marks.count-16)586+ case coverArchetypeBands:368587 direction.lattice.layout = 2369- direction.marks.count += 30370- direction.marks.sizeMax += 1.4371- direction.orbit.arcCoverage *= 0.74372- direction.field.swirl *= 1.25373- case 4:588+ direction.lattice.enabled = false589+ direction.orbit.enabled = false590+ direction.orbit.count = maxInt(5, direction.orbit.count/2)591+ direction.field.shear *= 0.44592+ direction.field.pinch *= 0.74593+ for i := range direction.flowLayers {594+ direction.flowLayers[i].waveYFreq = 1.8 + rng.Float64()*2.3595+ direction.flowLayers[i].waveYAmp += 0.14 + rng.Float64()*0.16596+ direction.flowLayers[i].waveXAmp *= 0.66597+ direction.flowLayers[i].step *= 0.94598+ direction.flowLayers[i].count = maxInt(180, int(float64(direction.flowLayers[i].count)*0.84))599+ }600+ direction.marks.count = maxInt(8, direction.marks.count-14)601+ case coverArchetypeDriftField:602+ direction.lattice.enabled = false603+ for i := range direction.flowLayers {604+ direction.flowLayers[i].count += 130605+ direction.flowLayers[i].steps += 18606+ direction.flowLayers[i].alpha = minUint8(90, direction.flowLayers[i].alpha+12)607+ direction.flowLayers[i].drift *= 1.24608+ }609+ direction.orbit.count = maxInt(8, direction.orbit.count-4)610+ direction.lattice.neighbors = maxInt(2, direction.lattice.neighbors-1)611+ direction.marks.count = maxInt(10, direction.marks.count-12)612+ direction.grainCount += 5000613+ case coverArchetypeRadialMesh:374614 direction.lattice.layout = 1375615 direction.lattice.rows += 2376- direction.orbit.eccentricity = clamp(0.55, 1.42, direction.orbit.eccentricity*1.16)377- direction.field.pinch *= 1.34616+ direction.lattice.cols += 1617+ direction.lattice.neighbors = minInt(5, direction.lattice.neighbors+1)618+ direction.lattice.center = coverVec2{619+ x: 0.44 + rng.Float64()*0.12,620+ y: 0.38 + rng.Float64()*0.14,621+ }622+ direction.orbit.enabled = true623+ direction.orbit.count += 8624+ direction.orbit.eccentricity = clamp(0.56, 1.46, direction.orbit.eccentricity*1.16)625+ direction.field.pinch *= 1.24626+ for i := range direction.flowLayers {627+ direction.flowLayers[i].count = maxInt(110, int(float64(direction.flowLayers[i].count)*0.45))628+ direction.flowLayers[i].alpha = maxUint8(14, direction.flowLayers[i].alpha-6)629+ }630+ direction.marks.count = maxInt(10, direction.marks.count-14)631+ case coverArchetypeSparseGlyph:632+ for i := range direction.flowLayers {633+ direction.flowLayers[i].count = maxInt(120, int(float64(direction.flowLayers[i].count)*0.55))634+ direction.flowLayers[i].steps = maxInt(72, int(float64(direction.flowLayers[i].steps)*0.70))635+ direction.flowLayers[i].alpha = maxUint8(12, direction.flowLayers[i].alpha-8)636+ }637+ direction.lattice.enabled = rng.Float64() > 0.68638+ if direction.lattice.enabled {639+ direction.lattice.neighbors = 2640+ direction.lattice.edge.A = maxUint8(14, direction.lattice.edge.A-26)641+ }642+ direction.orbit.enabled = rng.Float64() > 0.80643+ direction.orbit.count = maxInt(5, direction.orbit.count/2)644+ direction.orbit.arcCoverage *= 0.66645+ direction.marks.count += 40646+ direction.marks.sizeMax += 1.5647+ direction.marks.jitter *= 0.76648+ direction.field.swirl *= 0.72378649 direction.grainCount += 9000379650 }651+}380652 381- return direction653+func applyCoverModifier(654+ direction *coverArtDirection,655+ modifier coverModifier,656+ rng *rand.Rand,657+) {658+ switch modifier {659+ case coverModifierDenseFlow:660+ for i := range direction.flowLayers {661+ direction.flowLayers[i].count += 96662+ direction.flowLayers[i].steps += 16663+ direction.flowLayers[i].alpha = minUint8(94, direction.flowLayers[i].alpha+10)664+ }665+ direction.grainCount += 3000666+ case coverModifierHighOrbit:667+ direction.orbit.enabled = true668+ direction.orbit.count += 10669+ direction.orbit.arcCoverage = clamp01(direction.orbit.arcCoverage*1.15 + 0.05)670+ direction.orbit.dotSize += 0.12671+ direction.orbit.alphaBase = minUint8(120, direction.orbit.alphaBase+6)672+ case coverModifierQuietLattice:673+ if direction.lattice.enabled {674+ direction.lattice.edge.A = maxUint8(12, direction.lattice.edge.A-30)675+ direction.lattice.neighbors = maxInt(2, direction.lattice.neighbors-1)676+ }677+ if rng.Float64() < 0.35 {678+ direction.lattice.enabled = false679+ }680+ case coverModifierMicroMarks:681+ direction.marks.count += 26682+ direction.marks.sizeMin = clamp(0.8, 4.0, direction.marks.sizeMin*0.78)683+ direction.marks.sizeMax = clamp(1.2, 5.2, direction.marks.sizeMax*0.74)684+ direction.marks.jitter *= 0.86685+ direction.marks.alphaBase = minUint8(108, direction.marks.alphaBase+8)686+ case coverModifierGrainHeavy:687+ direction.grainCount += 12000688+ direction.grainWarm = blendRGB(direction.grainWarm, color.RGBA{R: 255, G: 250, B: 240, A: 255}, 0.12)689+ case coverModifierNegativeSpace:690+ for i := range direction.flowLayers {691+ direction.flowLayers[i].count = maxInt(140, int(float64(direction.flowLayers[i].count)*0.62))692+ direction.flowLayers[i].alpha = maxUint8(12, direction.flowLayers[i].alpha-8)693+ }694+ direction.orbit.count = maxInt(4, direction.orbit.count-6)695+ direction.orbit.alphaBase = maxUint8(8, direction.orbit.alphaBase-4)696+ direction.marks.count = maxInt(10, direction.marks.count-12)697+ direction.verticalCurve = clamp(1.0, 2.8, direction.verticalCurve+0.26)698+ for i := range direction.glows {699+ direction.glows[i].strength *= 0.72700+ }701+ }702+}703+704+func randCentered(rng *rand.Rand, spread float64) float64 {705+ return (rng.Float64()*2 - 1) * spread382706 }383707 384708 func paintCoverBackground(img *image.RGBA, w, h int, direction coverArtDirection) {
@@ -712,16 +1036,92 @@ func blendPixel(img *image.RGBA, x, y int, src color.RGBA) {
7121036 })7131037 }7141038 715-func brightenRGB(base RGB, amount float64) color.RGBA {716- return blendRGB(rgbToColor(base), color.RGBA{R: 255, G: 255, B: 255, A: 255}, amount)1039+func rgbToColor(c RGB) color.RGBA {1040+ return color.RGBA{R: c.R, G: c.G, B: c.B, A: 255}7171041 }7181042 719-func darkenRGB(base RGB, amount float64) color.RGBA {720- return blendRGB(rgbToColor(base), color.RGBA{R: 0, G: 0, B: 0, A: 255}, amount)1043+func rgbToHSV(c color.RGBA) (h, s, v float64) {1044+ r := float64(c.R) / 2551045+ g := float64(c.G) / 2551046+ b := float64(c.B) / 2551047+ maxC := math.Max(r, math.Max(g, b))1048+ minC := math.Min(r, math.Min(g, b))1049+ chroma := maxC - minC1050+1051+ v = maxC1052+ if maxC == 0 {1053+ s = 01054+ } else {1055+ s = chroma / maxC1056+ }1057+ if chroma == 0 {1058+ return 0, s, v1059+ }1060+1061+ switch maxC {1062+ case r:1063+ h = math.Mod((g-b)/chroma, 6)1064+ case g:1065+ h = (b-r)/chroma + 21066+ default:1067+ h = (r-g)/chroma + 41068+ }1069+ h /= 61070+ if h < 0 {1071+ h += 11072+ }1073+ return h, s, v7211074 }7221075 723-func rgbToColor(c RGB) color.RGBA {724- return color.RGBA{R: c.R, G: c.G, B: c.B, A: 255}1076+func hsvToRGB(h, s, v float64) color.RGBA {1077+ h = wrapHue(h)1078+ s = clamp01(s)1079+ v = clamp01(v)1080+ if s == 0 {1081+ ch := uint8(math.Round(v * 255))1082+ return color.RGBA{R: ch, G: ch, B: ch, A: 255}1083+ }1084+1085+ h6 := h * 61086+ segment := math.Floor(h6)1087+ f := h6 - segment1088+ p := v * (1 - s)1089+ q := v * (1 - s*f)1090+ t := v * (1 - s*(1-f))1091+1092+ var r, g, b float641093+ switch int(segment) % 6 {1094+ case 0:1095+ r, g, b = v, t, p1096+ case 1:1097+ r, g, b = q, v, p1098+ case 2:1099+ r, g, b = p, v, t1100+ case 3:1101+ r, g, b = p, q, v1102+ case 4:1103+ r, g, b = t, p, v1104+ default:1105+ r, g, b = v, p, q1106+ }1107+1108+ return color.RGBA{1109+ R: uint8(math.Round(clamp01(r) * 255)),1110+ G: uint8(math.Round(clamp01(g) * 255)),1111+ B: uint8(math.Round(clamp01(b) * 255)),1112+ A: 255,1113+ }1114+}1115+1116+func wrapHue(h float64) float64 {1117+ if h >= 0 && h < 1 {1118+ return h1119+ }1120+ h = math.Mod(h, 1)1121+ if h < 0 {1122+ h += 11123+ }1124+ return h7251125 }7261126 7271127 func lerpRGB(a, b color.RGBA, t float64) color.RGBA {
M pdfexport/cover_art_test.go +114 -0
@@ -2,7 +2,9 @@ package pdfexport
22 33 import (44 "bytes"5+ "fmt"56 "image"7+ "image/color"68 "image/png"79 "math"810 "testing"
@@ -39,6 +41,86 @@ func TestRenderCoverArtworkPNGVariesBySeedAndVariant(t *testing.T) {
3941 assertArtworkDiff(t, frontA, backA, 0.03)4042 }4143 44+func TestCoverArtworkBatchSaturationFloor(t *testing.T) {45+ if testing.Short() {46+ t.Skip("skipping batch saturation check in short mode")47+ }48+49+ base := RGB{R: 90, G: 128, B: 154}50+ seeds := coverSeedSamples(30)51+ var total float6452+ for _, seed := range seeds {53+ img := decodePNG(t, renderCoverArtworkPNG(seed, "front", base))54+ total += sampledMeanSaturation(img, 14)55+ }56+ meanSaturation := total / float64(len(seeds))57+ if meanSaturation < 0.24 {58+ t.Fatalf("mean saturation too low: got %.4f want >= 0.2400", meanSaturation)59+ }60+ if meanSaturation > 0.36 {61+ t.Fatalf("mean saturation unexpectedly high: got %.4f want <= 0.3600", meanSaturation)62+ }63+}64+65+func TestCoverArtworkBatchDiversityFloor(t *testing.T) {66+ if testing.Short() {67+ t.Skip("skipping batch diversity check in short mode")68+ }69+70+ base := RGB{R: 90, G: 128, B: 154}71+ seeds := coverSeedSamples(30)72+ images := make([]image.Image, 0, len(seeds))73+ for _, seed := range seeds {74+ images = append(images, decodePNG(t, renderCoverArtworkPNG(seed, "front", base)))75+ }76+77+ minDiff := math.MaxFloat6478+ var total float6479+ var pairs int80+ for i := range images {81+ for j := i + 1; j < len(images); j++ {82+ diff := sampledMeanChannelDiff(images[i], images[j], 12)83+ if diff < minDiff {84+ minDiff = diff85+ }86+ total += diff87+ pairs++88+ }89+ }90+ meanDiff := total / float64(pairs)91+ if meanDiff < 0.090 {92+ t.Fatalf("mean pairwise diff too low: got %.4f want >= 0.0900", meanDiff)93+ }94+ if minDiff < 0.040 {95+ t.Fatalf("minimum pairwise diff too low: got %.4f want >= 0.0400", minDiff)96+ }97+}98+99+func TestCoverArtworkArchetypeCoverage(t *testing.T) {100+ seen := make(map[coverArchetype]bool, int(coverArchetypeCount))101+ for i := 1; i <= 120; i++ {102+ seed := fmt.Sprintf("coverage-%03d|front", i)103+ seen[coverArchetypeForSeed(coverSeedHash(seed))] = true104+ }105+106+ if len(seen) != int(coverArchetypeCount) {107+ t.Fatalf("archetype coverage incomplete: got %d want %d", len(seen), coverArchetypeCount)108+ }109+}110+111+func TestRenderCoverArtworkPNGHandlesLowAndHighChromaBase(t *testing.T) {112+ lowChroma := RGB{R: 128, G: 128, B: 128}113+ highChroma := RGB{R: 245, G: 62, B: 58}114+115+ low := renderCoverArtworkPNG("chroma-check", "front", lowChroma)116+ high := renderCoverArtworkPNG("chroma-check", "front", highChroma)117+118+ if len(low) == 0 || len(high) == 0 {119+ t.Fatalf("renderCoverArtworkPNG returned empty output for chroma check")120+ }121+ assertArtworkDiff(t, low, high, 0.03)122+}123+42124 func assertArtworkDiff(t *testing.T, left, right []byte, minMeanDiff float64) {43125 t.Helper()44126
@@ -87,3 +169,35 @@ func sampledMeanChannelDiff(a, b image.Image, step int) float64 {
87169 }88170 return total / float64(samples)89171 }172+173+func sampledMeanSaturation(img image.Image, step int) float64 {174+ bounds := img.Bounds()175+ if step < 1 {176+ step = 1177+ }178+179+ var total float64180+ var samples int181+ for y := bounds.Min.Y; y < bounds.Max.Y; y += step {182+ for x := bounds.Min.X; x < bounds.Max.X; x += step {183+ r, g, b, _ := img.At(x, y).RGBA()184+ c := color.RGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: 255}185+ _, sat, _ := rgbToHSV(c)186+ total += sat187+ samples++188+ }189+ }190+191+ if samples == 0 {192+ return 0193+ }194+ return total / float64(samples)195+}196+197+func coverSeedSamples(n int) []string {198+ out := make([]string, 0, n)199+ for i := 1; i <= n; i++ {200+ out = append(out, fmt.Sprintf("test-cover-%02d", i))201+ }202+ return out203+}