452 lines
14 KiB
Go
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)))
|
|
}
|