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:
agent
2026-09-18 11:14:13 +08:00
parent 8a9c1eaf82
commit 882ac62433
7 changed files with 428 additions and 6 deletions
+31
View File
@@ -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 -4
View File
@@ -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(),
+13
View File
@@ -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")
+137
View File
@@ -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
}
+188
View File
@@ -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![图1](" + reference + ")\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
}
+14
View File
@@ -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")
}
+25 -2
View File
@@ -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",