feat: image variants engine
This commit is contained in:
1 parent
f0845de643
commit
f1d56ef50e
80 files changed
+7312
-84
No files matched your search
@@ -0,0 +1,60 @@
|
||||
package imaging
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/image"
|
||||
"bytes"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
|
||||
"github.com/gen2brain/webp"
|
||||
)
|
||||
|
||||
type encoder interface {
|
||||
contentType() string
|
||||
flattensTransparency() bool
|
||||
encode(output *bytes.Buffer, source image.Image, quality int) error
|
||||
}
|
||||
|
||||
type jpegEncoder struct{}
|
||||
|
||||
func (jpegEncoder) contentType() string { return "image/jpeg" }
|
||||
|
||||
func (jpegEncoder) flattensTransparency() bool { return true }
|
||||
|
||||
func (jpegEncoder) encode(output *bytes.Buffer, source image.Image, quality int) error {
|
||||
return jpeg.Encode(output, source, &jpeg.Options{Quality: quality})
|
||||
}
|
||||
|
||||
type pngEncoder struct{}
|
||||
|
||||
func (pngEncoder) contentType() string { return "image/png" }
|
||||
|
||||
func (pngEncoder) flattensTransparency() bool { return false }
|
||||
|
||||
func (pngEncoder) encode(output *bytes.Buffer, source image.Image, _ int) error {
|
||||
return png.Encode(output, source)
|
||||
}
|
||||
|
||||
type webpEncoder struct{}
|
||||
|
||||
func (webpEncoder) contentType() string { return "image/webp" }
|
||||
|
||||
func (webpEncoder) flattensTransparency() bool { return false }
|
||||
|
||||
func (webpEncoder) encode(output *bytes.Buffer, source image.Image, quality int) error {
|
||||
return webp.Encode(output, source, webp.Options{Quality: quality})
|
||||
}
|
||||
|
||||
var knownEncoders = map[domain.VariantFormat]encoder{
|
||||
domain.FormatJPEG: jpegEncoder{},
|
||||
domain.FormatPNG: pngEncoder{},
|
||||
domain.FormatWebP: webpEncoder{},
|
||||
}
|
||||
|
||||
var sourceFormats = map[string]domain.VariantFormat{
|
||||
"jpeg": domain.FormatJPEG,
|
||||
"png": domain.FormatPNG,
|
||||
"webp": domain.FormatWebP,
|
||||
"gif": domain.FormatPNG,
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package imaging
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/image"
|
||||
"image"
|
||||
"math"
|
||||
)
|
||||
|
||||
type layout struct {
|
||||
canvas image.Point
|
||||
source image.Rectangle
|
||||
content image.Rectangle
|
||||
}
|
||||
|
||||
func layoutFor(source image.Point, settings domain.VariantSettings) layout {
|
||||
if settings.Width == nil || settings.Height == nil {
|
||||
return proportionalLayout(source, settings)
|
||||
}
|
||||
box := image.Pt(*settings.Width, *settings.Height)
|
||||
switch settings.Fit {
|
||||
case domain.FitContain:
|
||||
return containLayout(source, box)
|
||||
case domain.FitInside:
|
||||
return insideLayout(source, box)
|
||||
default:
|
||||
return coverLayout(source, box)
|
||||
}
|
||||
}
|
||||
|
||||
func proportionalLayout(source image.Point, settings domain.VariantSettings) layout {
|
||||
scale := 1.0
|
||||
if settings.Width != nil {
|
||||
scale = math.Min(scale, ratio(*settings.Width, source.X))
|
||||
}
|
||||
if settings.Height != nil {
|
||||
scale = math.Min(scale, ratio(*settings.Height, source.Y))
|
||||
}
|
||||
return wholeSourceLayout(source, scaled(source, scale))
|
||||
}
|
||||
|
||||
func insideLayout(source image.Point, box image.Point) layout {
|
||||
scale := math.Min(1, math.Min(ratio(box.X, source.X), ratio(box.Y, source.Y)))
|
||||
return wholeSourceLayout(source, scaled(source, scale))
|
||||
}
|
||||
|
||||
func wholeSourceLayout(source image.Point, output image.Point) layout {
|
||||
return layout{canvas: output, source: image.Rectangle{Max: source}, content: image.Rectangle{Max: output}}
|
||||
}
|
||||
|
||||
func coverLayout(source image.Point, box image.Point) layout {
|
||||
shrink := math.Min(1, math.Min(ratio(source.X, box.X), ratio(source.Y, box.Y)))
|
||||
output := scaled(box, shrink)
|
||||
return layout{canvas: output, source: centeredCrop(source, output), content: image.Rectangle{Max: output}}
|
||||
}
|
||||
|
||||
func containLayout(source image.Point, box image.Point) layout {
|
||||
fit := math.Min(1, math.Min(ratio(box.X, source.X), ratio(box.Y, source.Y)))
|
||||
content := scaled(source, fit)
|
||||
content = image.Pt(min(content.X, box.X), min(content.Y, box.Y))
|
||||
origin := image.Pt((box.X-content.X)/2, (box.Y-content.Y)/2)
|
||||
return layout{canvas: box, source: image.Rectangle{Max: source}, content: image.Rectangle{Min: origin, Max: origin.Add(content)}}
|
||||
}
|
||||
|
||||
func centeredCrop(source image.Point, output image.Point) image.Rectangle {
|
||||
crop := source
|
||||
if source.X*output.Y >= source.Y*output.X {
|
||||
crop.X = min(source.X, roundedDivision(source.Y*output.X, output.Y))
|
||||
} else {
|
||||
crop.Y = min(source.Y, roundedDivision(source.X*output.Y, output.X))
|
||||
}
|
||||
origin := image.Pt((source.X-crop.X)/2, (source.Y-crop.Y)/2)
|
||||
return image.Rectangle{Min: origin, Max: origin.Add(crop)}
|
||||
}
|
||||
|
||||
func ratio(numerator int, denominator int) float64 {
|
||||
return float64(numerator) / float64(denominator)
|
||||
}
|
||||
|
||||
func roundedDivision(numerator int, denominator int) int {
|
||||
return int(math.Round(ratio(numerator, denominator)))
|
||||
}
|
||||
|
||||
func scaled(size image.Point, factor float64) image.Point {
|
||||
return image.Pt(scaledLength(size.X, factor), scaledLength(size.Y, factor))
|
||||
}
|
||||
|
||||
func scaledLength(length int, factor float64) int {
|
||||
return max(1, int(math.Round(float64(length)*factor)))
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package imaging
|
||||
|
||||
import (
|
||||
domain "RenewCMS/internal/domain/image"
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"io"
|
||||
"slices"
|
||||
|
||||
xdraw "golang.org/x/image/draw"
|
||||
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
type VariantRenderer struct {
|
||||
encoders map[domain.VariantFormat]encoder
|
||||
}
|
||||
|
||||
var _ domain.VariantRenderer = &VariantRenderer{}
|
||||
|
||||
func NewVariantRenderer() *VariantRenderer {
|
||||
return NewVariantRendererEncoding(domain.FormatOriginal, domain.FormatJPEG, domain.FormatPNG, domain.FormatWebP)
|
||||
}
|
||||
|
||||
func NewVariantRendererEncoding(formats ...domain.VariantFormat) *VariantRenderer {
|
||||
encoders := map[domain.VariantFormat]encoder{}
|
||||
for format, candidate := range knownEncoders {
|
||||
if slices.Contains(formats, format) {
|
||||
encoders[format] = candidate
|
||||
}
|
||||
}
|
||||
return &VariantRenderer{encoders}
|
||||
}
|
||||
|
||||
func (r *VariantRenderer) EncodableFormats() []domain.VariantFormat {
|
||||
formats := []domain.VariantFormat{domain.FormatOriginal}
|
||||
for _, format := range domain.VariantFormats() {
|
||||
if _, encodable := r.encoders[format]; encodable {
|
||||
formats = append(formats, format)
|
||||
}
|
||||
}
|
||||
return formats
|
||||
}
|
||||
|
||||
func (r *VariantRenderer) DeclaredSize(source io.Reader) (int, int, error) {
|
||||
header, _, err := image.DecodeConfig(source)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return header.Width, header.Height, nil
|
||||
}
|
||||
|
||||
func (r *VariantRenderer) Render(source io.Reader, settings domain.VariantSettings) (domain.RenderedVariant, error) {
|
||||
decoded, sourceFormat, err := image.Decode(source)
|
||||
if err != nil {
|
||||
return domain.RenderedVariant{}, err
|
||||
}
|
||||
chosen := r.encoderFor(settings.Format, sourceFormat)
|
||||
arranged := arrange(decoded, layoutFor(decoded.Bounds().Size(), settings), chosen.flattensTransparency())
|
||||
|
||||
var output bytes.Buffer
|
||||
if err := chosen.encode(&output, arranged, qualityOf(settings)); err != nil {
|
||||
return domain.RenderedVariant{}, err
|
||||
}
|
||||
return domain.RenderedVariant{Content: output.Bytes(), ContentType: chosen.contentType()}, nil
|
||||
}
|
||||
|
||||
func (r *VariantRenderer) encoderFor(format domain.VariantFormat, sourceFormat string) encoder {
|
||||
if format == domain.FormatOriginal {
|
||||
format = sourceFormats[sourceFormat]
|
||||
}
|
||||
if chosen, encodable := r.encoders[format]; encodable {
|
||||
return chosen
|
||||
}
|
||||
return pngEncoder{}
|
||||
}
|
||||
|
||||
func qualityOf(settings domain.VariantSettings) int {
|
||||
if settings.Quality == nil {
|
||||
return domain.DefaultVariantQuality
|
||||
}
|
||||
return *settings.Quality
|
||||
}
|
||||
|
||||
func arrange(decoded image.Image, plan layout, onWhite bool) image.Image {
|
||||
canvas := image.NewRGBA(image.Rectangle{Max: plan.canvas})
|
||||
if onWhite {
|
||||
xdraw.Draw(canvas, canvas.Bounds(), image.NewUniform(color.White), image.Point{}, xdraw.Src)
|
||||
}
|
||||
sourceRegion := plan.source.Add(decoded.Bounds().Min)
|
||||
if plan.source.Size() == plan.content.Size() {
|
||||
xdraw.Draw(canvas, plan.content, decoded, sourceRegion.Min, xdraw.Over)
|
||||
return canvas
|
||||
}
|
||||
xdraw.CatmullRom.Scale(canvas, plan.content, decoded, sourceRegion, xdraw.Over, nil)
|
||||
return canvas
|
||||
}
|
||||
Reference in new issue
Block a user