feat(gallery): packing
This commit is contained in:
1 parent
9282f1f634
commit
8a72b96e66
12 files changed
+126
-19
No files matched your search
@@ -30,11 +30,7 @@ func main() {
|
||||
slog.Error("could not read configuaration", "err", err)
|
||||
}
|
||||
|
||||
slog.SetDefault(
|
||||
slog.New(
|
||||
slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: cfg.LogLevel}),
|
||||
),
|
||||
)
|
||||
setupLogger(&cfg)
|
||||
|
||||
err = server.Start(ctx, cfg)
|
||||
if err != nil {
|
||||
@@ -42,3 +38,22 @@ func main() {
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/InfinityTools/go-binpack2d v1.0.0 // indirect
|
||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
|
||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
|
||||
@@ -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/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
|
||||
github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg=
|
||||
|
||||
@@ -3,11 +3,27 @@ package gallery
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"math"
|
||||
"path"
|
||||
|
||||
"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) {
|
||||
files, err := s.fs.ReadDir(s.cfg.Path)
|
||||
if err != nil {
|
||||
@@ -17,11 +33,67 @@ func (s *Service) GetImages(ctx context.Context) (imgs []images.Image, err error
|
||||
for _, file := range files {
|
||||
imagePath := path.Join(s.cfg.Path, file.Name())
|
||||
img, err := s.img.GetImage(imagePath)
|
||||
if err != nil {
|
||||
slog.Warn("can not read image in gallery", "path", imagePath, "err", err)
|
||||
if err != nil || img.Image == nil {
|
||||
continue
|
||||
}
|
||||
imgs = append(imgs, img)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
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 {
|
||||
slog.Error("could not get images", "err", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"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/pkg/config"
|
||||
"github.com/a-h/templ"
|
||||
@@ -34,6 +35,7 @@ func New(renderer renderer, srv galleryService, cfg *config.Gallery) *Handler {
|
||||
|
||||
type galleryService interface {
|
||||
GetImages(ctx context.Context) (imgs []images.Image, err error)
|
||||
GetGallery(ctx context.Context, width int) (gallery gallery.Gallery, err error)
|
||||
}
|
||||
type renderer interface {
|
||||
Render(ctx context.Context, w io.Writer, r *http.Request, component templ.Component)
|
||||
|
||||
@@ -18,6 +18,8 @@ func Default() Cfg {
|
||||
},
|
||||
Cache: &Valkey{
|
||||
RefreshTime: time.Minute * 10,
|
||||
LifeTime: time.Hour * 48,
|
||||
},
|
||||
LogJSON: true,
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ type Cfg struct {
|
||||
Gallery *Gallery `env:", prefix=GALLERY_"`
|
||||
|
||||
LogLevel slog.Level `env:"LOG_LEVEL, default=DEBUG"`
|
||||
LogJSON bool `env:"LOG_JSON"`
|
||||
}
|
||||
|
||||
type WebdavConfig struct {
|
||||
@@ -30,6 +31,7 @@ type Valkey struct {
|
||||
Password string `env:"PASSWORD"`
|
||||
|
||||
RefreshTime time.Duration `env:"REFRESH_TIME"`
|
||||
LifeTime time.Duration `env:"REFRESH_TIME"`
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -23,11 +23,11 @@ type CachedClient struct {
|
||||
}
|
||||
|
||||
// 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{
|
||||
impl: impl,
|
||||
cache: client,
|
||||
ttl: refreshtime * 5,
|
||||
ttl: lifetime,
|
||||
}
|
||||
|
||||
go func() {
|
||||
|
||||
@@ -61,7 +61,7 @@ func (s *Server) dynamicRoutes() (h http.Handler, err error) {
|
||||
}
|
||||
if err != nil {
|
||||
} 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)
|
||||
|
||||
|
||||
+18
-7
@@ -1,13 +1,24 @@
|
||||
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) {
|
||||
<div class="grid gap-5">
|
||||
for _, img := range images {
|
||||
if uid, err := img.UID(); err == nil {
|
||||
<img src={ "/images/" + uid }/>
|
||||
}
|
||||
templ Gallery(g gallery.Gallery) {
|
||||
<div
|
||||
class="grid gap-5 m-5"
|
||||
style={ fmt.Sprintf("grid-template-columns: repeat(%d, 1fr); grid-template-rows: repeat(%d, auto);", g.GridWith, g.GridHeight) }
|
||||
>
|
||||
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>
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
/*! tailwindcss v4.1.18 | MIT License | https://tailwindcss.com */
|
||||
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-text-shadow-color:initial;--tw-text-shadow-alpha:100%}}}@layer theme{:root,:host{--font-sans:"Outfit","outfit",sans-serif;--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-6xl:72rem;--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--font-weight-bold:700;--text-shadow-lg:0px 0px 20px #000;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components{.prose img{max-height:90vh;margin-left:auto;margin-right:auto;display:block}}@layer utilities{.pointer-events-none{pointer-events:none}.absolute{position:absolute}.fixed{positLine truncated
|
||||
@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-font-weight:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-text-shadow-color:initial;--tw-text-shadow-alpha:100%}}}@layer theme{:root,:host{--font-sans:"Outfit","outfit",sans-serif;--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-6xl:72rem;--text-2xl:1.5rem;--text-2xl--line-height:calc(2/1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25/1.875);--font-weight-bold:700;--text-shadow-lg:0px 0px 20px #000;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components{.prose img{max-height:90vh;margin-left:auto;margin-right:auto;display:block}}@layer utilities{.pointer-events-none{pointer-events:none}.absolute{position:absolute}.fixed{positLine truncated
|
||||
Reference in new issue
Block a user