perf(dingtalk): compress outbound screenshots, reuse uploaded objects, raise rewrite budget
- outboundimage: re-encode large PNG/JPEG screenshots as downscaled JPEG (1600px long edge, q82, transparency flattened onto white) before upload; undecodable, small, GIF/WebP payloads are published untouched - outboundimage: StableHashPublisher skips the upload when the content-addressed object already exists (ObjectStater); OSS store implements it via IsObjectExist - cmd/dobotai: DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT default 60s -> 180s; DOBOTAI_OUTBOUND_IMAGE_COMPRESS (on by default, off restores raw bytes)
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.zheromiku.top/Weknora/dobotai-platform/internal/audit"
|
||||
)
|
||||
@@ -184,6 +185,36 @@ func TestOutboundOSSConfiguredRequiresStablePublicBaseURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboundImageBudgetAndCompressionDefaults(t *testing.T) {
|
||||
if outboundImageRewriteTimeoutDefault != 180*time.Second {
|
||||
t.Fatalf("default rewrite budget=%s", outboundImageRewriteTimeoutDefault)
|
||||
}
|
||||
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT", "")
|
||||
if got := outboundImageRewriteTimeout(); got != outboundImageRewriteTimeoutDefault {
|
||||
t.Fatalf("unset budget=%s", got)
|
||||
}
|
||||
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT", "2m30s")
|
||||
if got := outboundImageRewriteTimeout(); got != 150*time.Second {
|
||||
t.Fatalf("parsed budget=%s", got)
|
||||
}
|
||||
for _, invalid := range []string{"-1s", "180", "soon"} {
|
||||
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_REWRITE_TIMEOUT", invalid)
|
||||
if got := outboundImageRewriteTimeout(); got != outboundImageRewriteTimeoutDefault {
|
||||
t.Fatalf("invalid budget %q resolved to %s", invalid, got)
|
||||
}
|
||||
}
|
||||
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_COMPRESS", "")
|
||||
if !outboundImageCompressionEnabled() {
|
||||
t.Fatal("compression must default to on")
|
||||
}
|
||||
for _, disabled := range []string{"off", "0", "false", "no"} {
|
||||
t.Setenv("DOBOTAI_OUTBOUND_IMAGE_COMPRESS", disabled)
|
||||
if outboundImageCompressionEnabled() {
|
||||
t.Fatalf("compression must be off for %q", disabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainDoesNotExposeWebAdministrationOrSetupOnlyMode(t *testing.T) {
|
||||
data, err := os.ReadFile("main.go")
|
||||
if err != nil {
|
||||
|
||||
@@ -20,9 +20,10 @@ const (
|
||||
outboundImagePrefix = "dobotai/im-images"
|
||||
outboundImageRoute = "/dingtalk/i/"
|
||||
// outboundImageRewriteTimeoutDefault is the budget for one answer's image
|
||||
// rewrite. Manual screenshots are multi-megabyte uploads, so the historical
|
||||
// 15s delivery timeout truncated long answers.
|
||||
outboundImageRewriteTimeoutDefault = 60 * time.Second
|
||||
// rewrite. Manual screenshots are multi-megabyte uploads and the container's
|
||||
// uplink is the real ceiling (measured ~0.6MB/s), so long answers need a
|
||||
// budget with headroom: the historical 15s delivery timeout truncated them.
|
||||
outboundImageRewriteTimeoutDefault = 180 * time.Second
|
||||
)
|
||||
|
||||
// outboundImageMaxCount resolves how many images one answer may rewrite.
|
||||
@@ -56,6 +57,21 @@ func outboundImageRewriteTimeout() time.Duration {
|
||||
return duration
|
||||
}
|
||||
|
||||
// outboundImageCompressionEnabled reports whether published screenshots are
|
||||
// re-encoded as smaller JPEGs before upload. It is on by default because the
|
||||
// container uplink, not the rewrite code, caps how many images fit in the
|
||||
// delivery budget; DOBOTAI_OUTBOUND_IMAGE_COMPRESS=off restores the original
|
||||
// bytes exactly as fetched.
|
||||
func outboundImageCompressionEnabled() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(os.Getenv("DOBOTAI_OUTBOUND_IMAGE_COMPRESS"))) {
|
||||
case "off", "0", "false", "no":
|
||||
log.Printf("outbound image: compression disabled by configuration")
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
type outboundImageConfig struct {
|
||||
WeKnoraBaseURL string
|
||||
WeKnoraAPIKey string
|
||||
@@ -125,7 +141,7 @@ func buildOutboundImageRuntime(cfg outboundImageConfig) (outboundImageRuntime, e
|
||||
return outboundImageRuntime{}, err
|
||||
}
|
||||
return outboundImageRuntime{
|
||||
rewriter: outboundimage.NewRewriter(fetcher, publisher, outboundImageMaxCount()),
|
||||
rewriter: outboundimage.NewRewriter(fetcher, publisher, outboundImageMaxCount(), outboundimage.WithImageCompression(outboundImageCompressionEnabled())),
|
||||
publisher: publisher,
|
||||
handler: handler,
|
||||
rewriteTimeout: outboundImageRewriteTimeout(),
|
||||
|
||||
@@ -49,6 +49,19 @@ func (s *AliyunOSSStore) Put(ctx context.Context, key string, data []byte, conte
|
||||
return nil
|
||||
}
|
||||
|
||||
// Exists reports whether an object is already stored under key, so identical
|
||||
// content is never uploaded twice.
|
||||
func (s *AliyunOSSStore) Exists(ctx context.Context, key string) (bool, error) {
|
||||
if s == nil || s.client == nil || strings.TrimSpace(key) == "" {
|
||||
return false, errors.New("outbound image: invalid OSS exists request")
|
||||
}
|
||||
exists, err := s.client.IsObjectExist(ctx, s.bucket, key)
|
||||
if err != nil {
|
||||
return false, errors.New("outbound image: OSS exists check failed")
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func (s *AliyunOSSStore) Get(ctx context.Context, key string, maxBytes int64) ([]byte, string, error) {
|
||||
if s == nil || s.client == nil || strings.TrimSpace(key) == "" || maxBytes <= 0 {
|
||||
return nil, "", errors.New("outbound image: invalid OSS get request")
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package outboundimage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/jpeg"
|
||||
_ "image/png"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// compressMaxEdge bounds the longest side of a published screenshot. Manual
|
||||
// screenshots are captured at 2x-3x, so capping the longest side keeps text
|
||||
// readable while cutting the upload size by an order of magnitude.
|
||||
compressMaxEdge = 1600
|
||||
// compressJPEGQuality is the re-encode quality for compressed screenshots.
|
||||
compressJPEGQuality = 82
|
||||
// compressMinBytes skips images that are already small: re-encoding them
|
||||
// saves little upload time and could only lose detail.
|
||||
compressMinBytes = 128 << 10
|
||||
// compressMaxPixels refuses to decode huge images so one oversized
|
||||
// screenshot cannot exhaust the container's memory.
|
||||
compressMaxPixels = 24 << 20
|
||||
)
|
||||
|
||||
// CompressImage re-encodes an outbound screenshot as a smaller JPEG. It returns
|
||||
// the payload to publish together with its content type, and reports whether
|
||||
// compression applied. Images that are undecodable, already small, animated, or
|
||||
// not smaller after re-encoding are returned untouched, so publishing always
|
||||
// has a usable payload.
|
||||
func CompressImage(data []byte, contentType string) ([]byte, string, bool) {
|
||||
if !compressibleImageType(contentType) || len(data) < compressMinBytes {
|
||||
return data, contentType, false
|
||||
}
|
||||
decoded, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return data, contentType, false
|
||||
}
|
||||
bounds := decoded.Bounds()
|
||||
if bounds.Dx() <= 0 || bounds.Dy() <= 0 || bounds.Dx()*bounds.Dy() > compressMaxPixels {
|
||||
return data, contentType, false
|
||||
}
|
||||
encoded := encodeCompressedImage(downscaleRGBA(flattenToRGBA(decoded), compressMaxEdge))
|
||||
if encoded == nil || len(encoded) >= len(data) {
|
||||
return data, contentType, false
|
||||
}
|
||||
return encoded, "image/jpeg", true
|
||||
}
|
||||
|
||||
// compressibleImageType reports whether the content type is a still raster
|
||||
// format this package can decode and re-encode. GIF and WebP are left alone to
|
||||
// preserve animation and format fidelity.
|
||||
func compressibleImageType(contentType string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0])) {
|
||||
case "image/png", "image/jpeg", "image/jpg":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func encodeCompressedImage(source image.Image) []byte {
|
||||
var encoded bytes.Buffer
|
||||
if err := jpeg.Encode(&encoded, source, &jpeg.Options{Quality: compressJPEGQuality}); err != nil {
|
||||
return nil
|
||||
}
|
||||
return encoded.Bytes()
|
||||
}
|
||||
|
||||
// flattenToRGBA copies an image onto an opaque white canvas. JPEG has no alpha
|
||||
// channel, so transparent regions must be composited before encoding or they
|
||||
// would be rendered black.
|
||||
func flattenToRGBA(source image.Image) *image.RGBA {
|
||||
bounds := source.Bounds()
|
||||
target := image.NewRGBA(image.Rect(0, 0, bounds.Dx(), bounds.Dy()))
|
||||
draw.Draw(target, target.Bounds(), image.NewUniform(color.White), image.Point{}, draw.Src)
|
||||
draw.Draw(target, target.Bounds(), source, bounds.Min, draw.Over)
|
||||
return target
|
||||
}
|
||||
|
||||
// downscaleRGBA shrinks an image so neither side exceeds maxEdge, averaging the
|
||||
// source pixels that fall into each destination pixel (box filter) so
|
||||
// screenshot text stays legible.
|
||||
func downscaleRGBA(source *image.RGBA, maxEdge int) *image.RGBA {
|
||||
width, height := source.Rect.Dx(), source.Rect.Dy()
|
||||
if width <= maxEdge && height <= maxEdge {
|
||||
return source
|
||||
}
|
||||
targetWidth, targetHeight := width, height
|
||||
if width >= height {
|
||||
targetWidth = maxEdge
|
||||
targetHeight = height * maxEdge / width
|
||||
} else {
|
||||
targetHeight = maxEdge
|
||||
targetWidth = width * maxEdge / height
|
||||
}
|
||||
if targetWidth < 1 {
|
||||
targetWidth = 1
|
||||
}
|
||||
if targetHeight < 1 {
|
||||
targetHeight = 1
|
||||
}
|
||||
target := image.NewRGBA(image.Rect(0, 0, targetWidth, targetHeight))
|
||||
for y := 0; y < targetHeight; y++ {
|
||||
startY := y * height / targetHeight
|
||||
endY := (y + 1) * height / targetHeight
|
||||
if endY <= startY {
|
||||
endY = startY + 1
|
||||
}
|
||||
for x := 0; x < targetWidth; x++ {
|
||||
startX := x * width / targetWidth
|
||||
endX := (x + 1) * width / targetWidth
|
||||
if endX <= startX {
|
||||
endX = startX + 1
|
||||
}
|
||||
var red, green, blue, count uint32
|
||||
for row := startY; row < endY; row++ {
|
||||
line := source.Pix[row*source.Stride:]
|
||||
for column := startX; column < endX; column++ {
|
||||
offset := column * 4
|
||||
red += uint32(line[offset])
|
||||
green += uint32(line[offset+1])
|
||||
blue += uint32(line[offset+2])
|
||||
count++
|
||||
}
|
||||
}
|
||||
offset := y*target.Stride + x*4
|
||||
target.Pix[offset] = uint8(red / count)
|
||||
target.Pix[offset+1] = uint8(green / count)
|
||||
target.Pix[offset+2] = uint8(blue / count)
|
||||
target.Pix[offset+3] = 0xff
|
||||
}
|
||||
}
|
||||
return target
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package outboundimage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func pngFixture(t *testing.T, width, height int, alpha bool) []byte {
|
||||
t.Helper()
|
||||
source := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
seed := uint32(12345)
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
// A pseudo-random pattern keeps the PNG as large as a real manual
|
||||
// screenshot, so the size thresholds are exercised for real.
|
||||
seed = seed*1664525 + 1013904223
|
||||
alphaValue := uint8(0xff)
|
||||
if alpha && x < width/2 {
|
||||
alphaValue = 0x00
|
||||
}
|
||||
source.SetRGBA(x, y, color.RGBA{
|
||||
R: uint8(seed >> 24),
|
||||
G: uint8(seed >> 16),
|
||||
B: uint8(seed >> 8),
|
||||
A: alphaValue,
|
||||
})
|
||||
}
|
||||
}
|
||||
var encoded bytes.Buffer
|
||||
if err := png.Encode(&encoded, source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return encoded.Bytes()
|
||||
}
|
||||
|
||||
func TestCompressImageDownscalesLargeScreenshotToSmallerJPEG(t *testing.T) {
|
||||
original := pngFixture(t, 3200, 1800, false)
|
||||
|
||||
data, contentType, applied := CompressImage(original, "image/png")
|
||||
if !applied {
|
||||
t.Fatal("expected a large screenshot to be compressed")
|
||||
}
|
||||
if contentType != "image/jpeg" {
|
||||
t.Fatalf("contentType=%q", contentType)
|
||||
}
|
||||
if len(data) >= len(original) {
|
||||
t.Fatalf("compressed size=%d original=%d", len(data), len(original))
|
||||
}
|
||||
decoded, err := jpeg.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatalf("compressed payload is not a JPEG: %v", err)
|
||||
}
|
||||
if decoded.Bounds().Dx() != compressMaxEdge || decoded.Bounds().Dy() != compressMaxEdge*1800/3200 {
|
||||
t.Fatalf("bounds=%v", decoded.Bounds())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompressImageFlattensTransparencyInsteadOfBlack(t *testing.T) {
|
||||
original := pngFixture(t, 2000, 2000, true)
|
||||
|
||||
data, _, applied := CompressImage(original, "image/png")
|
||||
if !applied {
|
||||
t.Fatal("expected a transparent screenshot to be compressed")
|
||||
}
|
||||
decoded, err := jpeg.Decode(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
red, green, blue, _ := decoded.At(10, 10).RGBA()
|
||||
if red < 0xf000 || green < 0xf000 || blue < 0xf000 {
|
||||
t.Fatalf("transparent area rendered as %v instead of white", []uint32{red, green, blue})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompressImageLeavesSmallOrUndecodablePayloadsUntouched(t *testing.T) {
|
||||
small := pngFixture(t, 64, 64, false)
|
||||
if len(small) >= compressMinBytes {
|
||||
t.Fatalf("fixture is not small: %d", len(small))
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
data []byte
|
||||
contentType string
|
||||
}{
|
||||
{name: "small", data: small, contentType: "image/png"},
|
||||
{name: "not an image", data: bytes.Repeat([]byte("x"), compressMinBytes+1), contentType: "image/png"},
|
||||
{name: "gif stays animated", data: bytes.Repeat([]byte("x"), compressMinBytes+1), contentType: "image/gif"},
|
||||
{name: "text", data: bytes.Repeat([]byte("x"), compressMinBytes+1), contentType: "text/plain"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
data, contentType, applied := CompressImage(test.data, test.contentType)
|
||||
if applied || contentType != test.contentType || !bytes.Equal(data, test.data) {
|
||||
t.Fatalf("payload changed: applied=%v contentType=%q bytes=%d", applied, contentType, len(data))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type capturePublisher struct {
|
||||
data []byte
|
||||
contentType string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (p *capturePublisher) Publish(_ context.Context, data []byte, contentType string) (string, error) {
|
||||
p.calls++
|
||||
p.data = append([]byte(nil), data...)
|
||||
p.contentType = contentType
|
||||
return "https://img.example/1.png", nil
|
||||
}
|
||||
|
||||
type pngFetcher struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (f *pngFetcher) Fetch(_ context.Context, _ string) ([]byte, string, error) {
|
||||
return f.data, "image/png", nil
|
||||
}
|
||||
|
||||
func TestRewritePublishesCompressedScreenshots(t *testing.T) {
|
||||
original := pngFixture(t, 3200, 1800, false)
|
||||
reference := "resource://AbCdEfGhIjKlMnOpQrStUv"
|
||||
input := "步骤一\n\n"
|
||||
|
||||
publisher := &capturePublisher{}
|
||||
NewRewriter(&pngFetcher{data: original}, publisher, 5).Rewrite(context.Background(), input)
|
||||
if publisher.calls != 1 || publisher.contentType != "image/jpeg" {
|
||||
t.Fatalf("calls=%d contentType=%q", publisher.calls, publisher.contentType)
|
||||
}
|
||||
if len(publisher.data) >= len(original) {
|
||||
t.Fatalf("published bytes=%d original=%d", len(publisher.data), len(original))
|
||||
}
|
||||
|
||||
disabled := &capturePublisher{}
|
||||
NewRewriter(&pngFetcher{data: original}, disabled, 5, WithImageCompression(false)).Rewrite(context.Background(), input)
|
||||
if disabled.contentType != "image/png" || !bytes.Equal(disabled.data, original) {
|
||||
t.Fatalf("compression opt-out changed the payload: contentType=%q bytes=%d", disabled.contentType, len(disabled.data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStableHashPublisherReusesExistingObject(t *testing.T) {
|
||||
store := &existingObjectStore{}
|
||||
publisher, err := NewStableHashPublisher(store, "dobotai/im-images", "https://img.example/i")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, err := publisher.Publish(context.Background(), []byte("same-image"), "image/jpeg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if store.puts != 1 {
|
||||
t.Fatalf("puts=%d", store.puts)
|
||||
}
|
||||
store.exists = true
|
||||
second, err := publisher.Publish(context.Background(), []byte("same-image"), "image/jpeg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first != second {
|
||||
t.Fatalf("url1=%q url2=%q", first, second)
|
||||
}
|
||||
if store.puts != 1 {
|
||||
t.Fatalf("existing object was uploaded again: puts=%d", store.puts)
|
||||
}
|
||||
if !strings.HasPrefix(second, "https://img.example/i/") {
|
||||
t.Fatalf("url=%q", second)
|
||||
}
|
||||
}
|
||||
|
||||
type existingObjectStore struct {
|
||||
exists bool
|
||||
puts int
|
||||
}
|
||||
|
||||
func (s *existingObjectStore) Put(_ context.Context, _ string, _ []byte, _ string) error {
|
||||
s.puts++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *existingObjectStore) Exists(_ context.Context, _ string) (bool, error) {
|
||||
return s.exists, nil
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
@@ -18,6 +19,13 @@ type ObjectWriter interface {
|
||||
Put(ctx context.Context, key string, data []byte, contentType string) error
|
||||
}
|
||||
|
||||
// ObjectStater reports whether an object already exists at key. Stores that
|
||||
// implement it let the publisher skip re-uploading identical content, which is
|
||||
// what makes a repeated question instant: object keys are content hashes.
|
||||
type ObjectStater interface {
|
||||
Exists(ctx context.Context, key string) (bool, error)
|
||||
}
|
||||
|
||||
type StableHashPublisher struct {
|
||||
store ObjectWriter
|
||||
prefix string
|
||||
@@ -52,6 +60,12 @@ func (p *StableHashPublisher) Publish(ctx context.Context, data []byte, contentT
|
||||
idHex := hex.EncodeToString(idBytes)
|
||||
id := fmt.Sprintf("%s-%s-%s-%s-%s", idHex[:8], idHex[8:12], idHex[12:16], idHex[16:20], idHex[20:])
|
||||
key := path.Join(p.prefix, id)
|
||||
if stater, ok := p.store.(ObjectStater); ok {
|
||||
if exists, err := stater.Exists(ctx, key); err == nil && exists {
|
||||
log.Printf("outbound image: existing object reused key=%s bytes=%d contentType=%s", key, len(data), contentType)
|
||||
return p.publicBaseURL + "/" + id, nil
|
||||
}
|
||||
}
|
||||
if err := p.store.Put(ctx, key, data, contentType); err != nil {
|
||||
return "", errors.New("outbound image: publish object failed")
|
||||
}
|
||||
|
||||
@@ -42,10 +42,26 @@ type Rewriter struct {
|
||||
fetcher Fetcher
|
||||
publisher Publisher
|
||||
maxImages int
|
||||
compress bool
|
||||
}
|
||||
|
||||
func NewRewriter(fetcher Fetcher, publisher Publisher, maxImages int) *Rewriter {
|
||||
return &Rewriter{fetcher: fetcher, publisher: publisher, maxImages: maxImages}
|
||||
// RewriterOption customises how one answer's images are prepared for delivery.
|
||||
type RewriterOption func(*Rewriter)
|
||||
|
||||
// WithImageCompression switches the pre-publish JPEG re-encode. It is enabled by
|
||||
// default because multi-megabyte screenshots dominate the delivery budget.
|
||||
func WithImageCompression(enabled bool) RewriterOption {
|
||||
return func(r *Rewriter) { r.compress = enabled }
|
||||
}
|
||||
|
||||
func NewRewriter(fetcher Fetcher, publisher Publisher, maxImages int, options ...RewriterOption) *Rewriter {
|
||||
rewriter := &Rewriter{fetcher: fetcher, publisher: publisher, maxImages: maxImages, compress: true}
|
||||
for _, option := range options {
|
||||
if option != nil {
|
||||
option(rewriter)
|
||||
}
|
||||
}
|
||||
return rewriter
|
||||
}
|
||||
|
||||
// rewriteConcurrency bounds how many images are fetched and published in
|
||||
@@ -177,6 +193,13 @@ func (r *Rewriter) publishReference(ctx context.Context, reference string) strin
|
||||
log.Printf("outbound image: fetch failed, image dropped reference=%s error=%v", reference, err)
|
||||
return ""
|
||||
}
|
||||
if r.compress {
|
||||
if compressed, compressedType, applied := CompressImage(data, contentType); applied {
|
||||
log.Printf("outbound image: compressed reference=%s bytes=%d contentType=%s compressed_bytes=%d compressed_content_type=%s",
|
||||
reference, len(data), contentType, len(compressed), compressedType)
|
||||
data, contentType = compressed, compressedType
|
||||
}
|
||||
}
|
||||
publishedURL, err := r.publisher.Publish(ctx, data, contentType)
|
||||
if err != nil || !strings.HasPrefix(publishedURL, "https://") {
|
||||
log.Printf("outbound image: publish failed, image dropped reference=%s bytes=%d contentType=%s error=%v",
|
||||
|
||||
Reference in New Issue
Block a user