apples/main.go
2025-06-22 19:39:14 +02:00

452 lines
14 KiB
Go

package main
import (
"errors"
"flag"
"fmt"
"image"
"io"
"log"
"math/rand"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"github.com/OneOfOne/xxhash"
"github.com/disintegration/imaging"
"github.com/kouhin/envflag"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
etag "github.com/pablor21/echo-etag/v4"
"image/color"
"image/jpeg"
_ "image/jpeg"
)
type tagSize struct {
x int
y int
}
var frameCount = 1337
var frameFiles = []string{}
var reSampler = imaging.Lanczos
func hashFile(fileName string) uint64 {
h := xxhash.New64()
f, err := os.Open(fileName)
if err != nil {
log.Fatal(err)
}
defer f.Close()
io.Copy(h, f)
return h.Sum64()
}
func imageToNRGBA(input image.Image) image.NRGBA {
out := image.NewNRGBA(input.Bounds())
for x := 0; x < input.Bounds().Dx(); x++ {
for y := 0; y < input.Bounds().Dy(); y++ {
out.Set(x, y, input.At(x, y))
}
}
return *out
}
func NRGBAToImage(input image.NRGBA) image.Image {
out := image.NewRGBA(input.Bounds())
for x := 0; x < input.Bounds().Dx(); x++ {
for y := 0; y < input.Bounds().Dy(); y++ {
out.Set(x, y, input.At(x, y))
}
}
return out
}
func autoCropLR(input image.NRGBA) image.NRGBA {
notOneColorLine := false
LeftColumn := 0
RightColumn := input.Bounds().Dy()
for x := 0; x < input.Bounds().Dx(); x++ {
startingColour := input.At(x, 0)
for y := 0; y < input.Bounds().Dy(); y++ {
if input.At(x, y) != startingColour {
notOneColorLine = true
log.Printf("Autocrop found first non-identical line at %d ", x)
break
}
}
if notOneColorLine {
LeftColumn = x
break
}
}
notOneColorLine = false
for x := input.Bounds().Dx(); x >= 0; x-- {
startingColour := input.At(x, 0)
for y := 0; y < input.Bounds().Dy(); y++ {
if input.At(x, y) != startingColour {
notOneColorLine = true
log.Printf("Autocrop found last non-identical line at %d ", x)
break
}
}
if notOneColorLine {
RightColumn = x
break
}
}
rect := image.Rect(LeftColumn, 0, RightColumn, input.Bounds().Dy())
log.Printf("Autocrop used rect %d,%d,%d,%d ", LeftColumn, 0, RightColumn, input.Bounds().Dy())
return *imaging.Crop(input.SubImage(input.Bounds()), rect)
}
func autoCropTB(input image.NRGBA) image.NRGBA {
notOneColorLine := false
TopColumn := 0
BottomColumn := input.Bounds().Dx()
for y := 0; y < input.Bounds().Dy(); y++ {
startingColour := input.At(y, 0)
for x := 0; x < input.Bounds().Dx(); x++ {
if input.At(x, y) != startingColour {
notOneColorLine = true
log.Printf("Autocrop found first non-identical line at %d ", y)
break
}
}
if notOneColorLine {
TopColumn = y
break
}
}
notOneColorLine = false
for y := input.Bounds().Dy(); y >= 0; y-- {
startingColour := input.At(y, 0)
for x := 0; x < input.Bounds().Dx(); x++ {
if input.At(x, y) != startingColour {
notOneColorLine = true
log.Printf("Autocrop found last non-identical line at %d ", y)
break
}
}
if notOneColorLine {
BottomColumn = y
break
}
}
rect := image.Rect(0, TopColumn, input.Bounds().Dx(), BottomColumn)
log.Printf("Autocrop used rect %d,%d,%d,%d ", 0, TopColumn, input.Bounds().Dx(), BottomColumn)
return *imaging.Crop(input.SubImage(input.Bounds()), rect)
}
func autoCrop(input image.NRGBA, size tagSize, cropType string) image.NRGBA {
if cropType == "LR" {
return autoCropLR(input)
} else if cropType == "TB" {
return autoCropTB(input)
} else if cropType == "requestSize" {
if size.x > size.y {
return autoCropLR(input)
} else if size.x < size.y {
return autoCropTB(input)
}
}
return autoCropTB(autoCropLR(input))
}
func resizeImageFile(fileName string, size tagSize, bwBack bool, outDir string, cropType string) string {
reader, err := os.Open(fileName)
if err != nil {
log.Fatal(err)
}
defer reader.Close()
im, _, err := image.Decode(reader)
if err != nil {
log.Fatal(err)
}
var file *os.File
if outDir == "%TEMP%" {
// temp dir cannot be reliably cached, we should just continue as if we have to recreate the file.
file, err = os.CreateTemp("", "resized") // empty string means create file in temporary directory.
if err != nil {
log.Fatal(err)
}
} else {
outpath := filepath.Join(outDir, strconv.Itoa(size.x)+"x"+strconv.Itoa(size.y))
if err != nil {
log.Fatal(err)
}
err := os.MkdirAll(outpath, os.ModePerm)
if err != nil {
log.Fatal(err)
}
outFileName := filepath.Join(outpath, filepath.Base(fileName))
if _, err = os.Stat(outFileName); !errors.Is(err, os.ErrNotExist) {
// file exists (silly go has no erros.IsNot)
log.Printf("File `%s` exists, not doing image manipulation stuff.", outFileName)
return outFileName
}
file, err = os.Create(outFileName)
if err != nil {
log.Fatal(err)
}
}
defer file.Close()
croppedImage := autoCrop(imageToNRGBA(im), size, cropType)
var resizedImage image.NRGBA
if (croppedImage.Bounds().Dx() < size.x) && (croppedImage.Bounds().Dy() < size.y) {
log.Printf("Image is smaller in both dimentions than requested (image %dx%d, requested %d,%d), scaling up", croppedImage.Bounds().Dx(), croppedImage.Bounds().Dy(), size.x, size.y)
if croppedImage.Bounds().Dx() > croppedImage.Bounds().Dy() {
resizedImage = *imaging.Resize(NRGBAToImage(croppedImage), size.x, 0, reSampler)
} else {
resizedImage = *imaging.Resize(NRGBAToImage(croppedImage), 0, size.y, reSampler)
}
} else {
log.Printf("Image is bigger or perfect in at least one dimention than requested (image %dx%d, requested %d,%d), fitting (scale down with ratio preserve)", croppedImage.Bounds().Dx(), croppedImage.Bounds().Dy(), size.x, size.y)
resizedImage = *imaging.Fit(NRGBAToImage(croppedImage), size.x, size.y, reSampler)
}
log.Printf("Deciding if to center (image %dx%d, requested %d,%d)", resizedImage.Bounds().Dx(), resizedImage.Bounds().Dy(), size.x, size.y)
if (resizedImage.Bounds().Dx() < size.x) || (resizedImage.Bounds().Dy() < size.y) {
if resizedImage.Bounds().Dx() == size.x {
log.Printf("Centering frame in Y (%d == %d so therefore %d must be < %d)", resizedImage.Bounds().Dx(), size.x, resizedImage.Bounds().Dy(), size.y)
} else {
log.Printf("Centering frame in Y (%d != %d so therefore %d must be < %d)", resizedImage.Bounds().Dx(), size.x, resizedImage.Bounds().Dx(), size.x)
}
upLeft := image.Point{0, 0}
lowRight := image.Point{size.x, size.y}
emptyImage := image.NewRGBA(image.Rectangle{upLeft, lowRight})
fillColour := resizedImage.At(0, 0)
topLeftRed, topLeftGreen, topLeftBlue, _ := fillColour.RGBA()
luminance, _, _ := color.RGBToYCbCr(uint8(topLeftRed), uint8(topLeftGreen), uint8(topLeftBlue))
if bwBack {
fillColour = color.White
if luminance < 128 {
fillColour = color.Black
}
}
for x := 0; x < size.x; x++ {
for y := 0; y < size.y; y++ {
emptyImage.Set(x, y, fillColour)
//emptyImage.Set(x, y, color.RGBA{255, 0, 0, 255})
}
}
point := image.Point{0, 0}
if resizedImage.Bounds().Dx() == size.x {
point.Y = (size.y - resizedImage.Bounds().Dy()) / 2
} else {
point.X = (size.x - resizedImage.Bounds().Dx()) / 2
}
outputImage := imaging.Overlay(emptyImage, NRGBAToImage(resizedImage), point, 1)
resizedImage = *outputImage
}
if err = jpeg.Encode(file, NRGBAToImage(resizedImage), nil); err != nil {
log.Printf("failed to encode: %v", err)
}
if err != nil {
log.Fatal(err)
}
return file.Name()
}
func randomNumberOfTheDay() int {
epoch := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
today := time.Now().Truncate(24 * time.Hour)
daysSinceEpoch := today.Sub(epoch)
log.Printf("Days since 2000-01-01: %d", daysSinceEpoch)
r := rand.New(rand.NewSource(int64(daysSinceEpoch + 1)))
return r.Intn(frameCount-1) + 1
}
func rfc2616Midnight() string {
midnight := time.Now().Truncate(24 * time.Hour)
return midnight.Format("Mon, 2 Jan 2006 15:04:05 MST")
}
func HeadToGetMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if c.Request().Method == http.MethodHead {
// Set the method to GET temporarily to reuse the handler
c.Request().Method = http.MethodGet
defer func() {
c.Request().Method = http.MethodHead
}() // Restore method after
// Call the next handler and then clear the response body
if err := next(c); err != nil {
if err.Error() == echo.ErrMethodNotAllowed.Error() {
fullResponseHeaders := c.Response().Header()
log.Printf("headers set: %v", fullResponseHeaders)
for k, v := range fullResponseHeaders {
c.Response().Header().Del(k)
log.Printf("header: %s (%d): %v", k, len(v), v)
for _, vv := range v {
log.Printf("value: %s", vv)
c.Response().Header().Add(k, vv)
}
}
ret := c.String(http.StatusOK, "HEAD success")
return ret
}
return err
}
}
return next(c)
}
}
func OncePerDayPlease(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
c.Response().Before(func() {
c.Response().Header().Del("Last-Modified")
c.Response().Header().Add("Last-Modified", rfc2616Midnight())
})
if err := next(c); err != nil { //exec main process
c.Error(err)
}
return nil
}
}
func responder(c echo.Context, size tagSize, dataDir *string, outDir *string, bwBack *bool, cropType string) error {
frameNumber := randomNumberOfTheDay()
//frameNumber = rand.Intn(frameCount-1) + 1
log.Printf("Frame number: %d", frameNumber)
outFileName := resizeImageFile(filepath.Join(*dataDir, frameFiles[frameNumber]), size, *bwBack, *outDir, cropType)
log.Printf("Serving: %v", outFileName)
ret := c.File(outFileName)
if *outDir == "%TEMP%" {
defer os.Remove(outFileName)
}
hash := hashFile(outFileName)
log.Printf("Setting ETag to: %-20d", hash)
c.Response().Header().Del("ETag")
c.Response().Header().Add("ETag", fmt.Sprintf("%-20d", hash))
return ret
}
func parseSize(c echo.Context, maxWidth int, maxHeight int) tagSize {
x, err := strconv.ParseUint(c.Param("x"), 10, 32)
if err != nil {
panic(err)
}
y, err := strconv.ParseUint(c.Param("y"), 10, 32)
if err != nil {
panic(err)
}
if int(x) > maxWidth {
panic(errors.New(fmt.Sprintf("Requested X (%d) larger than maximum (%d)", x, maxWidth)))
}
if int(y) > maxHeight {
panic(errors.New(fmt.Sprintf("Requested Y (%d) larger than maximum (%d)", y, maxHeight)))
}
return tagSize{int(x), int(y)}
}
func main() {
var (
dataDir = flag.String("data-dir", filepath.Join(".", "kodata", "frames"), "Data directory, where the images are")
outDir = flag.String("output-dir", filepath.Join(".", "output"), "Output directory (used as cache) for resized files - set to `%TEMP%` to disable caching and use temp dir, default is DataDir/output")
bwBack = flag.Bool("black-white-back", true, "Fill background with black or white (whichever is closest) - good for eInk, if false, uses top-left pixel of image's colour")
listenPort = flag.Int("listen-port", 8080, "Port to listen on")
listenAddr = flag.String("listen-addr", "127.0.0.1", "Address to listen on")
defaultWidth = flag.Int("default-width", 296, "Default width for /tag endpoint")
defaultHeight = flag.Int("default-height", 128, "Default height for /tag endpoint")
maxWidth = flag.Int("max-width", 1000, "max width for /tag/x/y endpoint")
maxHeight = flag.Int("max-height", 1000, "max height for /tag/x/y endpoint")
autoCropType = flag.String("autocrop-type", "full", "Type of autocropping to apply (valid options: `full`,`LR`,`TB`,`requestSize`) - Full Autocrop, Left/Right, Top/Bottom and By Request Size, which means landscape will autocrop L/R, portrait T/B and square full")
resizerType = flag.String("resizer-type", "full", "Type of resampling to apply (valid options: `Lanczos`,`CatmullRom`,`MitchellNetravali`,`Linear`,`Box`,`NearestNeighbor`) https://pkg.go.dev/github.com/disintegration/imaging#ResampleFilter for details")
)
if err := envflag.Parse(); err != nil {
panic(err)
}
var validAutoCropType = false
for _, t := range [5]string{"full", "LR", "TB", "requestSize"} {
if *autoCropType == t {
validAutoCropType = true
}
}
if !validAutoCropType {
*autoCropType = "full"
}
switch *resizerType {
case "Lanczos":
reSampler = imaging.Lanczos
case "CatmullRom":
reSampler = imaging.CatmullRom
case "MitchellNetravali":
reSampler = imaging.MitchellNetravali
case "Linear":
reSampler = imaging.Linear
case "Box":
reSampler = imaging.Box
case "NearestNeighbor":
reSampler = imaging.NearestNeighbor
case "BSpline":
reSampler = imaging.BSpline
case "Bartlett":
reSampler = imaging.Bartlett
case "Cosine":
reSampler = imaging.Cosine
case "Hamming":
reSampler = imaging.Hamming
case "Hann":
reSampler = imaging.Hann
case "Hermite":
reSampler = imaging.Hermite
case "Welch":
reSampler = imaging.Welch
default:
reSampler = imaging.Lanczos
}
var defaultSize = tagSize{*defaultWidth, *defaultHeight}
entries, err := os.ReadDir(*dataDir)
if err != nil {
log.Fatal(err)
}
for _, e := range entries {
frameFiles = append(frameFiles, e.Name())
}
frameCount = len(frameFiles)
log.Printf("Loaded %d frames from %s", frameCount, *dataDir)
if len(frameFiles) <= 0 {
panic("Looks like we can't find any files to display!")
}
e := echo.New()
e.Use(OncePerDayPlease)
e.Use(etag.Etag())
e.Use(middleware.RecoverWithConfig(middleware.RecoverConfig{
StackSize: 1 << 10, // 1 KB
LogLevel: 1,
}))
e.GET("/", func(c echo.Context) error {
return c.String(http.StatusOK, "Hello, World!")
})
e.GET("/tag", func(c echo.Context) error {
return responder(c, defaultSize, dataDir, outDir, bwBack, *autoCropType)
})
e.HEAD("/tag", func(c echo.Context) error {
return responder(c, defaultSize, dataDir, outDir, bwBack, *autoCropType)
})
e.GET("/tag/:x/:y", func(c echo.Context) error {
size := parseSize(c, *maxWidth, *maxHeight)
return responder(c, size, dataDir, outDir, bwBack, *autoCropType)
})
e.HEAD("/tag/:x/:y", func(c echo.Context) error {
size := parseSize(c, *maxWidth, *maxHeight)
return responder(c, size, dataDir, outDir, bwBack, *autoCropType)
})
e.Logger.Fatal(e.Start(*listenAddr + ":" + strconv.Itoa(*listenPort)))
}