feat(gallery): packing

This commit is contained in:
2026-05-31 15:48:18 +02:00
parent 9282f1f634
commit 8a72b96e66
12 changed files with 126 additions and 19 deletions
+20 -5
View File
@@ -30,11 +30,7 @@ func main() {
slog.Error("could not read configuaration", "err", err) slog.Error("could not read configuaration", "err", err)
} }
slog.SetDefault( setupLogger(&cfg)
slog.New(
slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.LogLevel}),
),
)
err = server.Start(ctx, cfg) err = server.Start(ctx, cfg)
if err != nil { if err != nil {
@@ -42,3 +38,22 @@ func main() {
return return
} }
} }
func setupLogger(cfg *config.Cfg) {
opts := slog.HandlerOptions{
Level: cfg.LogLevel,
}
var handler slog.Handler
if cfg.LogJSON {
handler = slog.NewJSONHandler(os.Stdout, &opts)
} else {
handler = slog.NewTextHandler(os.Stdout, &opts)
}
slog.SetDefault(
slog.New(
handler,
),
)
}
+1
View File
@@ -18,6 +18,7 @@ require (
) )
require ( require (
github.com/InfinityTools/go-binpack2d v1.0.0 // indirect
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
github.com/andybalholm/brotli v1.1.0 // indirect github.com/andybalholm/brotli v1.1.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect
+2
View File
@@ -1,3 +1,5 @@
github.com/InfinityTools/go-binpack2d v1.0.0 h1:l15wC3zDlR0FoffIcPze0qbszshmGCpMncDiAxpWnE8=
github.com/InfinityTools/go-binpack2d v1.0.0/go.mod h1:3hwEmVO6ffChGwW55BVr9k4rzSYGow0OH2B8bjEdO6o=
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo= github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ= github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg= github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg=
+74 -2
View File
@@ -3,11 +3,27 @@ package gallery
import ( import (
"context" "context"
"log/slog" "log/slog"
"math"
"path" "path"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
"github.com/InfinityTools/go-binpack2d"
) )
type Gallery struct {
GridWith int
GridHeight int
Images []Image
}
type Image struct {
UID string
X int
Y int
Width int
Height int
}
func (s *Service) GetImages(ctx context.Context) (imgs []images.Image, err error) { func (s *Service) GetImages(ctx context.Context) (imgs []images.Image, err error) {
files, err := s.fs.ReadDir(s.cfg.Path) files, err := s.fs.ReadDir(s.cfg.Path)
if err != nil { if err != nil {
@@ -17,11 +33,67 @@ func (s *Service) GetImages(ctx context.Context) (imgs []images.Image, err error
for _, file := range files { for _, file := range files {
imagePath := path.Join(s.cfg.Path, file.Name()) imagePath := path.Join(s.cfg.Path, file.Name())
img, err := s.img.GetImage(imagePath) img, err := s.img.GetImage(imagePath)
if err != nil { if err != nil || img.Image == nil {
slog.Warn("can not read image in gallery", "path", imagePath, "err", err) continue
} }
imgs = append(imgs, img) imgs = append(imgs, img)
} }
return return
} }
func (s *Service) GetGallery(ctx context.Context, width int) (gallery Gallery, err error) {
imgs, err := s.GetImages(ctx)
if err != nil {
return
}
packer := binpack2d.Create(50, 999999)
for len(imgs) > 0 {
unused := make([]images.Image, 0, len(imgs))
for _, img := range imgs {
width, height := adjustImageSize(img, 200)
rect, ok := packer.Insert(width, height, binpack2d.RULE_BEST_AREA_FIT)
if !ok {
unused = append(unused, img)
continue
}
uid, err := img.UID()
if err != nil {
return gallery, err
}
gallery.Images = append(gallery.Images, Image{
UID: uid,
X: rect.X,
Y: rect.Y,
Width: width,
Height: height,
})
}
imgs = unused
}
packer.ShrinkBin(false)
gallery.GridHeight = packer.GetHeight()
gallery.GridWith = packer.GetWidth()
slog.Info("serving gallery", "g", gallery)
return
}
// adjustImageSize adjusts the image size so that the aspect ratio stays the same
// but the image area is the given parameter.
func adjustImageSize(img images.Image, area int) (width, height int) {
// Calculate the original aspect ratio (W / H)
aspectRatio := float64(img.Bounds().Dx()) / float64(img.Bounds().Dy())
// Calculate new height and width using the derived formulas
newHeight := math.Sqrt(float64(area) / aspectRatio)
newWidth := newHeight * aspectRatio
// Round to the nearest integer to minimize precision loss
return int(math.Round(newWidth)), int(math.Round(newHeight))
}
+1 -1
View File
@@ -8,7 +8,7 @@ import (
) )
func (h *Handler) gallery(w http.ResponseWriter, r *http.Request) { func (h *Handler) gallery(w http.ResponseWriter, r *http.Request) {
imgs, err := h.srv.GetImages(r.Context()) imgs, err := h.srv.GetGallery(r.Context(), 5)
if err != nil { if err != nil {
slog.Error("could not get images", "err", err) slog.Error("could not get images", "err", err)
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
@@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/gallery"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images"
"git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config" "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/pkg/config"
"github.com/a-h/templ" "github.com/a-h/templ"
@@ -34,6 +35,7 @@ func New(renderer renderer, srv galleryService, cfg *config.Gallery) *Handler {
type galleryService interface { type galleryService interface {
GetImages(ctx context.Context) (imgs []images.Image, err error) GetImages(ctx context.Context) (imgs []images.Image, err error)
GetGallery(ctx context.Context, width int) (gallery gallery.Gallery, err error)
} }
type renderer interface { type renderer interface {
Render(ctx context.Context, w io.Writer, r *http.Request, component templ.Component) Render(ctx context.Context, w io.Writer, r *http.Request, component templ.Component)
+2
View File
@@ -18,6 +18,8 @@ func Default() Cfg {
}, },
Cache: &Valkey{ Cache: &Valkey{
RefreshTime: time.Minute * 10, RefreshTime: time.Minute * 10,
LifeTime: time.Hour * 48,
}, },
LogJSON: true,
} }
} }
+2
View File
@@ -16,6 +16,7 @@ type Cfg struct {
Gallery *Gallery `env:", prefix=GALLERY_"` Gallery *Gallery `env:", prefix=GALLERY_"`
LogLevel slog.Level `env:"LOG_LEVEL, default=DEBUG"` LogLevel slog.Level `env:"LOG_LEVEL, default=DEBUG"`
LogJSON bool `env:"LOG_JSON"`
} }
type WebdavConfig struct { type WebdavConfig struct {
@@ -30,6 +31,7 @@ type Valkey struct {
Password string `env:"PASSWORD"` Password string `env:"PASSWORD"`
RefreshTime time.Duration `env:"REFRESH_TIME"` RefreshTime time.Duration `env:"REFRESH_TIME"`
LifeTime time.Duration `env:"REFRESH_TIME"`
// InitAddress point to valkey nodes. // InitAddress point to valkey nodes.
// Valkey will connect to them one by one and issue a CLUSTER SLOT command to initialize the cluster client until success. // Valkey will connect to them one by one and issue a CLUSTER SLOT command to initialize the cluster client until success.
+2 -2
View File
@@ -23,11 +23,11 @@ type CachedClient struct {
} }
// NewCachedClient creates a new CachedClient. // NewCachedClient creates a new CachedClient.
func NewCachedClient(impl FS, client valkey.Client, refreshtime time.Duration) *CachedClient { func NewCachedClient(impl FS, client valkey.Client, refreshtime time.Duration, lifetime time.Duration) *CachedClient {
c := &CachedClient{ c := &CachedClient{
impl: impl, impl: impl,
cache: client, cache: client,
ttl: refreshtime * 5, ttl: lifetime,
} }
go func() { go func() {
+1 -1
View File
@@ -61,7 +61,7 @@ func (s *Server) dynamicRoutes() (h http.Handler, err error) {
} }
if err != nil { if err != nil {
} else { } else {
fs = filesystem.NewCachedClient(webdavClient, valkeyClient, s.cfg.Cache.RefreshTime) fs = filesystem.NewCachedClient(webdavClient, valkeyClient, s.cfg.Cache.RefreshTime, s.cfg.Cache.LifeTime)
} }
imageSrv := images.New(fs, s.cfg.Image, valkeyClient) imageSrv := images.New(fs, s.cfg.Image, valkeyClient)
+18 -7
View File
@@ -1,13 +1,24 @@
package pages package pages
import "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/images" import "git.schreifuchs.ch/schreifuchs/schreifuchs.ch/internal/components/gallery"
import "fmt"
templ Gallery(images []images.Image) { templ Gallery(g gallery.Gallery) {
<div class="grid gap-5"> <div
for _, img := range images { class="grid gap-5 m-5"
if uid, err := img.UID(); err == nil { style={ fmt.Sprintf("grid-template-columns: repeat(%d, 1fr); grid-template-rows: repeat(%d, auto);", g.GridWith, g.GridHeight) }
<img src={ "/images/" + uid }/> >
} for _, img := range g.Images {
<div
style={ fmt.Sprintf("grid-column: %d / span %d; grid-row: %d / span %d;", img.X+1, img.Width, img.Y+1, img.Height) }
class="overflow-hidden shadow-sm"
>
<img
src={ "/images/" + img.UID }
class="w-full h-full object-cover"
alt="Gallery item"
/>
</div>
} }
</div> </div>
} }
File diff suppressed because one or more lines are too long