243 lines
6.2 KiB
Go
243 lines
6.2 KiB
Go
package platform
|
|
|
|
import (
|
|
"bytes"
|
|
"container/list"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"image/jpeg"
|
|
"image/png"
|
|
"sync"
|
|
|
|
"golang.org/x/image/draw"
|
|
)
|
|
|
|
type assetPreviewResult struct {
|
|
data []byte
|
|
mime string
|
|
}
|
|
type assetPreviewEntry struct {
|
|
key string
|
|
value assetPreviewResult
|
|
}
|
|
|
|
var previewMemory = struct {
|
|
sync.Mutex
|
|
entries map[string]*list.Element
|
|
order *list.List
|
|
bytes int
|
|
}{entries: make(map[string]*list.Element), order: list.New()}
|
|
var previewSlots = make(chan struct{}, 1)
|
|
|
|
func previewWidth(variant, width string) (int, error) {
|
|
if variant == "" && width == "" {
|
|
return 0, nil
|
|
}
|
|
if variant == "preview" && (width == "" || width == "1024") {
|
|
return 1024, nil
|
|
}
|
|
return 0, errors.New("invalid_asset_variant")
|
|
}
|
|
|
|
// Cache only immutable bytes, after the caller checks current asset permission.
|
|
// Bound both decoded concurrency and retained encoded data.
|
|
func cachedAssetPreview(ctx context.Context, encoded []byte, mime string, width int) (assetPreviewResult, error) {
|
|
key := fmt.Sprintf("%x:%d", sha256.Sum256(encoded), width)
|
|
previewMemory.Lock()
|
|
if entry := previewMemory.entries[key]; entry != nil {
|
|
previewMemory.order.MoveToFront(entry)
|
|
result := entry.Value.(assetPreviewEntry).value
|
|
previewMemory.Unlock()
|
|
return result, nil
|
|
}
|
|
previewMemory.Unlock()
|
|
select {
|
|
case previewSlots <- struct{}{}:
|
|
case <-ctx.Done():
|
|
return assetPreviewResult{}, ctx.Err()
|
|
}
|
|
defer func() { <-previewSlots }()
|
|
// Another request may have finished this same preview while we waited.
|
|
previewMemory.Lock()
|
|
if entry := previewMemory.entries[key]; entry != nil {
|
|
result := entry.Value.(assetPreviewEntry).value
|
|
previewMemory.order.MoveToFront(entry)
|
|
previewMemory.Unlock()
|
|
return result, nil
|
|
}
|
|
previewMemory.Unlock()
|
|
result, err := makeAssetPreview(encoded, mime, width)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
if len(result.data) > 8<<20 {
|
|
return result, nil
|
|
}
|
|
previewMemory.Lock()
|
|
defer previewMemory.Unlock()
|
|
for previewMemory.bytes+len(result.data) > 32<<20 || previewMemory.order.Len() >= 128 {
|
|
tail := previewMemory.order.Back()
|
|
if tail == nil {
|
|
break
|
|
}
|
|
old := tail.Value.(assetPreviewEntry)
|
|
previewMemory.bytes -= len(old.value.data)
|
|
delete(previewMemory.entries, old.key)
|
|
previewMemory.order.Remove(tail)
|
|
}
|
|
previewMemory.entries[key] = previewMemory.order.PushFront(assetPreviewEntry{key, result})
|
|
previewMemory.bytes += len(result.data)
|
|
return result, nil
|
|
}
|
|
|
|
func makeAssetPreview(encoded []byte, mime string, width int) (assetPreviewResult, error) {
|
|
original := assetPreviewResult{encoded, mime}
|
|
// Keep animation intact. Original bytes remain the canonical AI/download input.
|
|
if mime == "image/gif" {
|
|
return original, nil
|
|
}
|
|
config, _, err := image.DecodeConfig(bytes.NewReader(encoded))
|
|
if err != nil {
|
|
return original, err
|
|
}
|
|
if config.Width <= 0 || config.Height <= 0 || int64(config.Width)*int64(config.Height) > assetPixelLimit {
|
|
return original, errors.New("invalid_image_dimensions")
|
|
}
|
|
if config.Width <= width && config.Height <= width && len(encoded) <= 64<<10 {
|
|
return original, nil
|
|
}
|
|
source, _, err := image.Decode(bytes.NewReader(encoded))
|
|
if err != nil {
|
|
return original, err
|
|
}
|
|
orientation := jpegOrientation(encoded)
|
|
if orientation != 1 {
|
|
source = orientedAsset{source, orientation}
|
|
}
|
|
bounds := source.Bounds()
|
|
w, h := bounds.Dx(), bounds.Dy()
|
|
if w > width || h > width {
|
|
if w >= h {
|
|
h = max(1, h*width/w)
|
|
w = width
|
|
} else {
|
|
w = max(1, w*width/h)
|
|
h = width
|
|
}
|
|
}
|
|
resized := image.NewNRGBA(image.Rect(0, 0, w, h))
|
|
draw.CatmullRom.Scale(resized, resized.Bounds(), source, source.Bounds(), draw.Src, nil)
|
|
var output bytes.Buffer
|
|
outputMime := "image/png"
|
|
if resized.Opaque() {
|
|
err = jpeg.Encode(&output, resized, &jpeg.Options{Quality: 82})
|
|
outputMime = "image/jpeg"
|
|
} else {
|
|
err = png.Encode(&output, resized)
|
|
}
|
|
if err != nil {
|
|
return original, err
|
|
}
|
|
if output.Len() >= len(encoded) && orientation == 1 {
|
|
return original, nil
|
|
}
|
|
return assetPreviewResult{output.Bytes(), outputMime}, nil
|
|
}
|
|
|
|
// JPEG EXIF orientation is applied to previews instead of silently rotating a
|
|
// phone photograph after its EXIF metadata is removed by the image encoder.
|
|
func jpegOrientation(data []byte) int {
|
|
if len(data) < 4 || data[0] != 0xff || data[1] != 0xd8 {
|
|
return 1
|
|
}
|
|
for at := 2; at+4 <= len(data); {
|
|
if data[at] != 0xff {
|
|
return 1
|
|
}
|
|
marker := data[at+1]
|
|
if marker == 0xda || marker == 0xd9 {
|
|
return 1
|
|
}
|
|
length := int(binary.BigEndian.Uint16(data[at+2 : at+4]))
|
|
if length < 2 || at+2+length > len(data) {
|
|
return 1
|
|
}
|
|
part := data[at+4 : at+2+length]
|
|
if marker == 0xe1 && len(part) >= 14 && string(part[:6]) == "Exif\x00\x00" {
|
|
tiff := part[6:]
|
|
var order binary.ByteOrder
|
|
switch string(tiff[:2]) {
|
|
case "II":
|
|
order = binary.LittleEndian
|
|
case "MM":
|
|
order = binary.BigEndian
|
|
default:
|
|
return 1
|
|
}
|
|
if order.Uint16(tiff[2:4]) != 42 {
|
|
return 1
|
|
}
|
|
offset := int(order.Uint32(tiff[4:8]))
|
|
if offset < 8 || offset+2 > len(tiff) {
|
|
return 1
|
|
}
|
|
count := int(order.Uint16(tiff[offset : offset+2]))
|
|
for i := 0; i < count; i++ {
|
|
entry := offset + 2 + i*12
|
|
if entry+12 > len(tiff) {
|
|
return 1
|
|
}
|
|
if order.Uint16(tiff[entry:entry+2]) == 0x112 && order.Uint16(tiff[entry+2:entry+4]) == 3 && order.Uint32(tiff[entry+4:entry+8]) == 1 {
|
|
value := int(order.Uint16(tiff[entry+8 : entry+10]))
|
|
if value >= 1 && value <= 8 {
|
|
return value
|
|
}
|
|
return 1
|
|
}
|
|
}
|
|
}
|
|
at += 2 + length
|
|
}
|
|
return 1
|
|
}
|
|
|
|
type orientedAsset struct {
|
|
image.Image
|
|
orientation int
|
|
}
|
|
|
|
func (im orientedAsset) Bounds() image.Rectangle {
|
|
b := im.Image.Bounds()
|
|
if im.orientation >= 5 {
|
|
return image.Rect(0, 0, b.Dy(), b.Dx())
|
|
}
|
|
return image.Rect(0, 0, b.Dx(), b.Dy())
|
|
}
|
|
func (im orientedAsset) ColorModel() color.Model { return im.Image.ColorModel() }
|
|
func (im orientedAsset) At(x, y int) color.Color {
|
|
b := im.Image.Bounds()
|
|
w, h := b.Dx(), b.Dy()
|
|
switch im.orientation {
|
|
case 2:
|
|
x = w - 1 - x
|
|
case 3:
|
|
x, y = w-1-x, h-1-y
|
|
case 4:
|
|
y = h - 1 - y
|
|
case 5:
|
|
x, y = y, x
|
|
case 6:
|
|
x, y = y, h-1-x
|
|
case 7:
|
|
x, y = w-1-y, h-1-x
|
|
case 8:
|
|
x, y = w-1-y, x
|
|
}
|
|
return im.Image.At(b.Min.X+x, b.Min.Y+y)
|
|
}
|