From 7d72a5c91ada49e803d3750e2f4891daf0fe6f65 Mon Sep 17 00:00:00 2001 From: adityaverm-a Date: Thu, 28 May 2026 14:59:25 +0530 Subject: [PATCH 1/6] feat: Enable mozjpeg for JPEG output to improve compression Enable mozjpeg encoder for all JPEG transformations in edit-applicator.ts. This reduces JPEG file size by 10-15% at comparable visual quality compared to Sharp's default encoder, bringing DIT output in line with Cloudinary's compression performance. - Pass { mozjpeg: true } when converting to JPEG format - Add test coverage to verify mozjpeg option is applied - Achieves expected 10-15% file size reduction for JPEG output Fixes: CloudFront DIT CDN Migration - Enable mozjpeg optimization for JPEG output Co-authored-by: Cursor --- .../transformation-engine/edit-applicator.test.ts | 11 ++++++++++- .../transformation-engine/edit-applicator.ts | 5 +++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/source/container/src/services/image-processing/transformation-engine/edit-applicator.test.ts b/source/container/src/services/image-processing/transformation-engine/edit-applicator.test.ts index 6f43f9a54..5bf71e476 100644 --- a/source/container/src/services/image-processing/transformation-engine/edit-applicator.test.ts +++ b/source/container/src/services/image-processing/transformation-engine/edit-applicator.test.ts @@ -187,7 +187,16 @@ describe('EditApplicator', () => { await EditApplicator.applyEdits(mockImage, edits, mockOriginFetcher as any); - expect(mockImage.toFormat).toHaveBeenCalledWith('jpeg', { quality: 80 }); + expect(mockImage.toFormat).toHaveBeenCalledWith('jpeg', { quality: 80, mozjpeg: true }); + }); + + it('Should enable mozjpeg for JPEG format', async () => { + const mockImage = createMockSharp(); + const edits: ImageEdits = { toFormat: 'jpeg' }; + + await EditApplicator.applyEdits(mockImage, edits, mockOriginFetcher as any); + + expect(mockImage.toFormat).toHaveBeenCalledWith('jpeg', { mozjpeg: true }); }); it('Should add compression=av1 for heif format', async () => { diff --git a/source/container/src/services/image-processing/transformation-engine/edit-applicator.ts b/source/container/src/services/image-processing/transformation-engine/edit-applicator.ts index 3250f4468..b5fb49d21 100644 --- a/source/container/src/services/image-processing/transformation-engine/edit-applicator.ts +++ b/source/container/src/services/image-processing/transformation-engine/edit-applicator.ts @@ -117,6 +117,11 @@ export class EditApplicator { try { const format = edits.toFormat || (await image.metadata()).format; const options = edits.quality ? { quality: edits.quality } : {}; + + // Enable mozjpeg for JPEG to improve compression (10-15% smaller files) + if (format === 'jpeg') { + options['mozjpeg'] = true; + } // Sharp requires an explicit compression choice when emitting the heif format. // TODO: Look into supporting hevc over av1. Requires specific libvips compilation option. // https://sharp.pixelplumbing.com/api-output/#heif From c5bac5f5593e08b7980b6ccaea67324e77c6eeac Mon Sep 17 00:00:00 2001 From: adityaverm-a Date: Thu, 28 May 2026 15:13:39 +0530 Subject: [PATCH 2/6] feat: optimize PNG output settings in transformation pipeline Enable palette quantization with maximum compression and adaptive filtering for PNG output in the Sharp format step to reduce transformed PNG payload size. Update tests to assert the new PNG output options are applied. Co-authored-by: Cursor --- .../transformation-engine/edit-applicator.test.ts | 6 +++++- .../transformation-engine/edit-applicator.ts | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/source/container/src/services/image-processing/transformation-engine/edit-applicator.test.ts b/source/container/src/services/image-processing/transformation-engine/edit-applicator.test.ts index 5bf71e476..faf2125d5 100644 --- a/source/container/src/services/image-processing/transformation-engine/edit-applicator.test.ts +++ b/source/container/src/services/image-processing/transformation-engine/edit-applicator.test.ts @@ -169,7 +169,11 @@ describe('EditApplicator', () => { await EditApplicator.applyEdits(mockImage, edits, mockOriginFetcher as any); - expect(mockImage.toFormat).toHaveBeenCalledWith('png', {}); + expect(mockImage.toFormat).toHaveBeenCalledWith('png', { + palette: true, + compressionLevel: 9, + adaptiveFiltering: true, + }); }); it('Should fallback to metadata format when toFormat not provided', async () => { diff --git a/source/container/src/services/image-processing/transformation-engine/edit-applicator.ts b/source/container/src/services/image-processing/transformation-engine/edit-applicator.ts index b5fb49d21..62175c60c 100644 --- a/source/container/src/services/image-processing/transformation-engine/edit-applicator.ts +++ b/source/container/src/services/image-processing/transformation-engine/edit-applicator.ts @@ -122,6 +122,12 @@ export class EditApplicator { if (format === 'jpeg') { options['mozjpeg'] = true; } + + if (format === 'png') { + options['palette'] = true; + options['compressionLevel'] = 9; + options['adaptiveFiltering'] = true; + } // Sharp requires an explicit compression choice when emitting the heif format. // TODO: Look into supporting hevc over av1. Requires specific libvips compilation option. // https://sharp.pixelplumbing.com/api-output/#heif From 6b9cfb4026b9a8a6b2600c63132854d9b32d3a82 Mon Sep 17 00:00:00 2001 From: adityaverm-a Date: Thu, 28 May 2026 15:34:16 +0530 Subject: [PATCH 3/6] feat: map GIF quality to Sharp optimization settings Translate generic quality input into Sharp GIF options (colours, effort, inter-frame and inter-palette error) so GIF output uses real compression controls instead of an unsupported quality field. Add tests for mapping behavior and bounds clamping. Co-authored-by: Cursor --- .../edit-applicator.test.ts | 28 +++++++++++++++++++ .../transformation-engine/edit-applicator.ts | 12 ++++++++ 2 files changed, 40 insertions(+) diff --git a/source/container/src/services/image-processing/transformation-engine/edit-applicator.test.ts b/source/container/src/services/image-processing/transformation-engine/edit-applicator.test.ts index faf2125d5..30f2d832e 100644 --- a/source/container/src/services/image-processing/transformation-engine/edit-applicator.test.ts +++ b/source/container/src/services/image-processing/transformation-engine/edit-applicator.test.ts @@ -203,6 +203,34 @@ describe('EditApplicator', () => { expect(mockImage.toFormat).toHaveBeenCalledWith('jpeg', { mozjpeg: true }); }); + it('Should map quality to GIF optimization options', async () => { + const mockImage = createMockSharp(); + const edits: ImageEdits = { toFormat: 'gif', quality: 80 }; + + await EditApplicator.applyEdits(mockImage, edits, mockOriginFetcher as any); + + expect(mockImage.toFormat).toHaveBeenCalledWith('gif', { + colours: 205, + effort: 10, + interFrameMaxError: 6, + interPaletteMaxError: 2, + }); + }); + + it('Should clamp GIF quality to valid bounds', async () => { + const mockImage = createMockSharp(); + const edits: ImageEdits = { toFormat: 'gif', quality: 0 }; + + await EditApplicator.applyEdits(mockImage, edits, mockOriginFetcher as any); + + expect(mockImage.toFormat).toHaveBeenCalledWith('gif', { + colours: 3, + effort: 10, + interFrameMaxError: 32, + interPaletteMaxError: 10, + }); + }); + it('Should add compression=av1 for heif format', async () => { const mockImage = createMockSharp(); const edits: ImageEdits = { toFormat: 'heif' }; diff --git a/source/container/src/services/image-processing/transformation-engine/edit-applicator.ts b/source/container/src/services/image-processing/transformation-engine/edit-applicator.ts index 62175c60c..d1ce3ef36 100644 --- a/source/container/src/services/image-processing/transformation-engine/edit-applicator.ts +++ b/source/container/src/services/image-processing/transformation-engine/edit-applicator.ts @@ -123,11 +123,23 @@ export class EditApplicator { options['mozjpeg'] = true; } + // Enable PNG compression settings to reduce output size if (format === 'png') { options['palette'] = true; options['compressionLevel'] = 9; options['adaptiveFiltering'] = true; } + + // Map generic quality input to Sharp GIF-specific optimization options + if (format === 'gif') { + const gifQuality = Math.max(1, Math.min(100, Number(edits.quality ?? 80))); + options['colours'] = Math.max(2, Math.min(256, Math.round(256 * (gifQuality / 100)))); + options['effort'] = 10; + options['interFrameMaxError'] = Math.max(0, Math.min(32, Math.round(32 * (1 - gifQuality / 100)))); + options['interPaletteMaxError'] = Math.max(0, Math.min(256, Math.round(10 * (1 - gifQuality / 100)))); + delete options['quality']; // not a valid GifOption + } + // Sharp requires an explicit compression choice when emitting the heif format. // TODO: Look into supporting hevc over av1. Requires specific libvips compilation option. // https://sharp.pixelplumbing.com/api-output/#heif From 812b18cd2bdfe0706a2b6d2308771518e7888666 Mon Sep 17 00:00:00 2001 From: adityaverm-a Date: Mon, 1 Jun 2026 17:53:39 +0530 Subject: [PATCH 4/6] feat: guard transparent sources against jpeg auto-selection Two-layer alpha guard so transparent PNG/WebP icons are never flattened to JPEG. 1. auto-optimizer: when the selected auto format is jpeg and the source content-type is image/png or image/webp, fall back to the next non-jpeg accepted format from FORMAT_PRIORITY; if none, return [] so the source format passes through. 2. image-processor: after sharp(buffer).metadata(), if hasAlpha is true, rewrite any format=jpeg transformation to webp (when image/webp is in dit-accept) else png. Catches URL-specified jpeg and origins whose Content-Type didn't disclose alpha. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../image-processor.service.test.ts | 337 +++++++++------ .../image-processor.service.ts | 146 ++++--- .../auto-optimization/auto-optimizer.test.ts | 394 ++++++++++-------- .../auto-optimization/auto-optimizer.ts | 141 ++++--- 4 files changed, 609 insertions(+), 409 deletions(-) diff --git a/source/container/src/services/image-processing/image-processor.service.test.ts b/source/container/src/services/image-processing/image-processor.service.test.ts index fe0ddb81d..b10eac994 100644 --- a/source/container/src/services/image-processing/image-processor.service.test.ts +++ b/source/container/src/services/image-processing/image-processor.service.test.ts @@ -1,28 +1,40 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { ImageProcessorService } from './image-processor.service'; -import { ImageProcessingRequest } from '../../types/image-processing-request'; -import { EditApplicator } from './transformation-engine/edit-applicator'; -import { ErrorMapper } from './utils/error-mapping'; -import { ImageProcessingError } from './types'; -import sharp from 'sharp'; +import { ImageProcessorService } from "./image-processor.service"; +import { ImageProcessingRequest } from "../../types/image-processing-request"; +import { EditApplicator } from "./transformation-engine/edit-applicator"; +import { ErrorMapper } from "./utils/error-mapping"; +import { ImageProcessingError } from "./types"; +import sharp from "sharp"; let TEST_JPEG_BUFFER: Buffer; let TEST_GIF_BUFFER: Buffer; +let TEST_TRANSPARENT_PNG_BUFFER: Buffer; beforeAll(async () => { // Generate valid test images using Sharp TEST_JPEG_BUFFER = await sharp({ - create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } } - }).jpeg().toBuffer(); - + create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } }, + }) + .jpeg() + .toBuffer(); + TEST_GIF_BUFFER = await sharp({ - create: { width: 100, height: 100, channels: 3, background: { r: 0, g: 0, b: 255 } } - }).gif().toBuffer(); + create: { width: 100, height: 100, channels: 3, background: { r: 0, g: 0, b: 255 } }, + }) + .gif() + .toBuffer(); + + // 4-channel RGBA with a fully-transparent background — Sharp metadata reports hasAlpha=true. + TEST_TRANSPARENT_PNG_BUFFER = await sharp({ + create: { width: 100, height: 100, channels: 4, background: { r: 0, g: 255, b: 0, alpha: 0 } }, + }) + .png() + .toBuffer(); }); -describe('ImageProcessorService', () => { +describe("ImageProcessorService", () => { let service: ImageProcessorService; beforeEach(() => { @@ -30,40 +42,40 @@ describe('ImageProcessorService', () => { service = ImageProcessorService.getInstance(); }); - describe('getInstance', () => { - it('should return singleton instance', () => { + describe("getInstance", () => { + it("should return singleton instance", () => { const instance1 = ImageProcessorService.getInstance(); const instance2 = ImageProcessorService.getInstance(); expect(instance1).toBe(instance2); }); }); - describe('process', () => { - it('should throw error for missing origin URL', async () => { + describe("process", () => { + it("should throw error for missing origin URL", async () => { const request: ImageProcessingRequest = { - requestId: 'test-123', + requestId: "test-123", timestamp: Date.now(), - origin: { url: '' }, + origin: { url: "" }, transformations: [], - response: { headers: {} } + response: { headers: {} }, }; await expect(service.process(request)).rejects.toThrow(); }); - it('should handle empty transformations array', async () => { - const mockBuffer = Buffer.from('fake-image-data'); - jest.spyOn(service['originFetcher'], 'fetchImage').mockResolvedValue({ + it("should handle empty transformations array", async () => { + const mockBuffer = Buffer.from("fake-image-data"); + jest.spyOn(service["originFetcher"], "fetchImage").mockResolvedValue({ buffer: mockBuffer, - metadata: { size: mockBuffer.length } + metadata: { size: mockBuffer.length }, }); const request: ImageProcessingRequest = { - requestId: 'test-123', + requestId: "test-123", timestamp: Date.now(), - origin: { url: 'https://example.com/image.jpg' }, + origin: { url: "https://example.com/image.jpg" }, transformations: [], - response: { headers: {} } + response: { headers: {} }, }; const result = await service.process(request); @@ -71,47 +83,47 @@ describe('ImageProcessorService', () => { }); }); - describe('overlay size calculation', () => { - it('should calculate percentage-based overlay size', () => { - const result = EditApplicator.calcOverlaySizeOption('50p', 1000, 100); + describe("overlay size calculation", () => { + it("should calculate percentage-based overlay size", () => { + const result = EditApplicator.calcOverlaySizeOption("50p", 1000, 100); expect(result).toBe(500); }); - it('should calculate absolute overlay size', () => { - const result = EditApplicator.calcOverlaySizeOption('200', 1000, 100); + it("should calculate absolute overlay size", () => { + const result = EditApplicator.calcOverlaySizeOption("200", 1000, 100); expect(result).toBe(200); }); - it('should handle negative values', () => { - const result = EditApplicator.calcOverlaySizeOption('-50', 1000, 100); + it("should handle negative values", () => { + const result = EditApplicator.calcOverlaySizeOption("-50", 1000, 100); expect(result).toBe(850); // 1000 + (-50) - 100 }); - it('should handle numeric input', () => { + it("should handle numeric input", () => { const result = EditApplicator.calcOverlaySizeOption(150, 1000, 100); expect(result).toBe(150); }); - it('should handle negative percentage values', () => { - const result = EditApplicator.calcOverlaySizeOption('-25p', 1000, 100); + it("should handle negative percentage values", () => { + const result = EditApplicator.calcOverlaySizeOption("-25p", 1000, 100); expect(result).toBe(650); // floor(1000 + (1000 * -25) / 100) - 100 = 750 - 100 }); }); - describe('process request initialization', () => { - it('should initialize timings object if missing', async () => { - const mockBuffer = Buffer.from('fake-image-data'); - jest.spyOn(service['originFetcher'], 'fetchImage').mockResolvedValue({ + describe("process request initialization", () => { + it("should initialize timings object if missing", async () => { + const mockBuffer = Buffer.from("fake-image-data"); + jest.spyOn(service["originFetcher"], "fetchImage").mockResolvedValue({ buffer: mockBuffer, - metadata: { size: mockBuffer.length } + metadata: { size: mockBuffer.length }, }); const request: ImageProcessingRequest = { - requestId: 'test-123', + requestId: "test-123", timestamp: Date.now(), - origin: { url: 'https://example.com/image.jpg' }, + origin: { url: "https://example.com/image.jpg" }, transformations: [], - response: { headers: {} } + response: { headers: {} }, }; await service.process(request); @@ -119,148 +131,148 @@ describe('ImageProcessorService', () => { expect(request.timings.imageProcessing).toBeDefined(); }); - it('should set sourceImageContentType on response for no-transform case', async () => { - const mockBuffer = Buffer.from('fake-image-data'); - jest.spyOn(service['originFetcher'], 'fetchImage').mockResolvedValue({ + it("should set sourceImageContentType on response for no-transform case", async () => { + const mockBuffer = Buffer.from("fake-image-data"); + jest.spyOn(service["originFetcher"], "fetchImage").mockResolvedValue({ buffer: mockBuffer, - metadata: { size: mockBuffer.length } + metadata: { size: mockBuffer.length }, }); const request: ImageProcessingRequest = { - requestId: 'test-123', + requestId: "test-123", timestamp: Date.now(), - origin: { url: 'https://example.com/image.jpg' }, + origin: { url: "https://example.com/image.jpg" }, transformations: [], - sourceImageContentType: 'image/jpeg', - response: { headers: {} } + sourceImageContentType: "image/jpeg", + response: { headers: {} }, }; await service.process(request); - expect(request.response.contentType).toBe('image/jpeg'); + expect(request.response.contentType).toBe("image/jpeg"); }); }); - describe('full transformation pipeline', () => { - it('should process image with transformations and set contentType from output', async () => { - jest.spyOn(service['originFetcher'], 'fetchImage').mockResolvedValue({ + describe("full transformation pipeline", () => { + it("should process image with transformations and set contentType from output", async () => { + jest.spyOn(service["originFetcher"], "fetchImage").mockResolvedValue({ buffer: TEST_JPEG_BUFFER, - metadata: { size: TEST_JPEG_BUFFER.length, format: 'jpeg' } + metadata: { size: TEST_JPEG_BUFFER.length, format: "jpeg" }, }); const request: ImageProcessingRequest = { - requestId: 'test-pipeline', + requestId: "test-pipeline", timestamp: Date.now(), - origin: { url: 'https://example.com/image.jpg' }, - transformations: [{ type: 'resize', value: { width: 1 }, source: 'url' }], - response: { headers: {} } + origin: { url: "https://example.com/image.jpg" }, + transformations: [{ type: "resize", value: { width: 1 }, source: "url" }], + response: { headers: {} }, }; const result = await service.process(request); - + expect(result).toBeInstanceOf(Buffer); expect(request.response.contentType).toMatch(/^image\//); expect(request.timings.imageProcessing.transformationApplicationMs).toBeGreaterThanOrEqual(0); }); }); - describe('preventAutoUpscaling', () => { - it('should filter out auto-resize transforms that would upscale', async () => { - jest.spyOn(service['originFetcher'], 'fetchImage').mockResolvedValue({ + describe("preventAutoUpscaling", () => { + it("should filter out auto-resize transforms that would upscale", async () => { + jest.spyOn(service["originFetcher"], "fetchImage").mockResolvedValue({ buffer: TEST_JPEG_BUFFER, - metadata: { size: TEST_JPEG_BUFFER.length } + metadata: { size: TEST_JPEG_BUFFER.length }, }); const request: ImageProcessingRequest = { - requestId: 'test-upscale', + requestId: "test-upscale", timestamp: Date.now(), - origin: { url: 'https://example.com/image.jpg' }, + origin: { url: "https://example.com/image.jpg" }, transformations: [ - { type: 'resize', value: { width: 5000 }, source: 'auto' }, // Should be filtered (upscale) - { type: 'negate', value: true, source: 'url' } // Should remain + { type: "resize", value: { width: 5000 }, source: "auto" }, // Should be filtered (upscale) + { type: "negate", value: true, source: "url" }, // Should remain ], - response: { headers: {} } + response: { headers: {} }, }; await service.process(request); - + expect(request.transformations).toHaveLength(1); - expect(request.transformations[0].type).toBe('negate'); + expect(request.transformations[0].type).toBe("negate"); }); - it('should keep auto-resize transforms that do not upscale', async () => { - jest.spyOn(service['originFetcher'], 'fetchImage').mockResolvedValue({ + it("should keep auto-resize transforms that do not upscale", async () => { + jest.spyOn(service["originFetcher"], "fetchImage").mockResolvedValue({ buffer: TEST_JPEG_BUFFER, - metadata: { size: TEST_JPEG_BUFFER.length } + metadata: { size: TEST_JPEG_BUFFER.length }, }); const request: ImageProcessingRequest = { - requestId: 'test-no-upscale', + requestId: "test-no-upscale", timestamp: Date.now(), - origin: { url: 'https://example.com/image.jpg' }, + origin: { url: "https://example.com/image.jpg" }, transformations: [ - { type: 'resize', value: { width: 1 }, source: 'auto' } // 1x1 image, width=1 is not upscaling + { type: "resize", value: { width: 1 }, source: "auto" }, // 1x1 image, width=1 is not upscaling ], - response: { headers: {} } + response: { headers: {} }, }; await service.process(request); - + expect(request.transformations).toHaveLength(1); }); - it('should not filter non-auto resize transforms', async () => { - jest.spyOn(service['originFetcher'], 'fetchImage').mockResolvedValue({ + it("should not filter non-auto resize transforms", async () => { + jest.spyOn(service["originFetcher"], "fetchImage").mockResolvedValue({ buffer: TEST_JPEG_BUFFER, - metadata: { size: TEST_JPEG_BUFFER.length } + metadata: { size: TEST_JPEG_BUFFER.length }, }); const request: ImageProcessingRequest = { - requestId: 'test-url-resize', + requestId: "test-url-resize", timestamp: Date.now(), - origin: { url: 'https://example.com/image.jpg' }, + origin: { url: "https://example.com/image.jpg" }, transformations: [ - { type: 'resize', value: { width: 5000 }, source: 'url' } // URL source, should not be filtered + { type: "resize", value: { width: 5000 }, source: "url" }, // URL source, should not be filtered ], - response: { headers: {} } + response: { headers: {} }, }; await service.process(request); - + expect(request.transformations).toHaveLength(1); }); }); - describe('instantiateSharpImage', () => { - it('should apply stripExif when specified', async () => { - jest.spyOn(service['originFetcher'], 'fetchImage').mockResolvedValue({ + describe("instantiateSharpImage", () => { + it("should apply stripExif when specified", async () => { + jest.spyOn(service["originFetcher"], "fetchImage").mockResolvedValue({ buffer: TEST_JPEG_BUFFER, - metadata: { size: TEST_JPEG_BUFFER.length } + metadata: { size: TEST_JPEG_BUFFER.length }, }); const request: ImageProcessingRequest = { - requestId: 'test-strip-exif', + requestId: "test-strip-exif", timestamp: Date.now(), - origin: { url: 'https://example.com/image.jpg' }, - transformations: [{ type: 'stripExif', value: true, source: 'url' }], - response: { headers: {} } + origin: { url: "https://example.com/image.jpg" }, + transformations: [{ type: "stripExif", value: true, source: "url" }], + response: { headers: {} }, }; const result = await service.process(request); expect(result).toBeInstanceOf(Buffer); }); - it('should apply stripIcc when specified', async () => { - jest.spyOn(service['originFetcher'], 'fetchImage').mockResolvedValue({ + it("should apply stripIcc when specified", async () => { + jest.spyOn(service["originFetcher"], "fetchImage").mockResolvedValue({ buffer: TEST_JPEG_BUFFER, - metadata: { size: TEST_JPEG_BUFFER.length } + metadata: { size: TEST_JPEG_BUFFER.length }, }); const request: ImageProcessingRequest = { - requestId: 'test-strip-icc', + requestId: "test-strip-icc", timestamp: Date.now(), - origin: { url: 'https://example.com/image.jpg' }, - transformations: [{ type: 'stripIcc', value: true, source: 'url' }], - response: { headers: {} } + origin: { url: "https://example.com/image.jpg" }, + transformations: [{ type: "stripIcc", value: true, source: "url" }], + response: { headers: {} }, }; const result = await service.process(request); @@ -268,59 +280,126 @@ describe('ImageProcessorService', () => { }); }); - describe('error handling', () => { - it('should wrap errors via ErrorMapper', async () => { - const originalError = new Error('Fetch failed'); - jest.spyOn(service['originFetcher'], 'fetchImage').mockRejectedValue(originalError); - jest.spyOn(ErrorMapper, 'mapError'); + describe("error handling", () => { + it("should wrap errors via ErrorMapper", async () => { + const originalError = new Error("Fetch failed"); + jest.spyOn(service["originFetcher"], "fetchImage").mockRejectedValue(originalError); + jest.spyOn(ErrorMapper, "mapError"); const request: ImageProcessingRequest = { - requestId: 'test-error', + requestId: "test-error", timestamp: Date.now(), - origin: { url: 'https://example.com/image.jpg' }, + origin: { url: "https://example.com/image.jpg" }, transformations: [], - response: { headers: {} } + response: { headers: {} }, }; await expect(service.process(request)).rejects.toThrow(); expect(ErrorMapper.mapError).toHaveBeenCalledWith(originalError); }); - it('should pass through ImageProcessingError unchanged', async () => { - const processingError = new ImageProcessingError(404, 'NotFound', 'Image not found'); - jest.spyOn(service['originFetcher'], 'fetchImage').mockRejectedValue(processingError); + it("should pass through ImageProcessingError unchanged", async () => { + const processingError = new ImageProcessingError(404, "NotFound", "Image not found"); + jest.spyOn(service["originFetcher"], "fetchImage").mockRejectedValue(processingError); const request: ImageProcessingRequest = { - requestId: 'test-processing-error', + requestId: "test-processing-error", timestamp: Date.now(), - origin: { url: 'https://example.com/image.jpg' }, + origin: { url: "https://example.com/image.jpg" }, transformations: [], - response: { headers: {} } + response: { headers: {} }, }; await expect(service.process(request)).rejects.toThrow(processingError); }); }); - describe('animated GIF handling', () => { - it('should re-instantiate with animated=false for single-frame GIF', async () => { - jest.spyOn(service['originFetcher'], 'fetchImage').mockResolvedValue({ + describe("alpha-to-jpeg defense in depth", () => { + it("should rewrite jpeg format to webp when source has alpha and webp is accepted", async () => { + jest.spyOn(service["originFetcher"], "fetchImage").mockResolvedValue({ + buffer: TEST_TRANSPARENT_PNG_BUFFER, + metadata: { size: TEST_TRANSPARENT_PNG_BUFFER.length, format: "png" }, + }); + + const request: ImageProcessingRequest = { + requestId: "test-alpha-webp", + timestamp: Date.now(), + origin: { url: "https://example.com/icon.png" }, + sourceImageContentType: "image/png", + clientHeaders: { "dit-accept": "image/webp,image/jpeg" }, + transformations: [{ type: "format", value: "jpeg", source: "url" }], + response: { headers: {} }, + }; + + await service.process(request); + + expect(request.transformations[0].value).toBe("webp"); + expect(request.response.contentType).toBe("image/webp"); + }); + + it("should rewrite jpeg format to png when source has alpha and webp is not accepted", async () => { + jest.spyOn(service["originFetcher"], "fetchImage").mockResolvedValue({ + buffer: TEST_TRANSPARENT_PNG_BUFFER, + metadata: { size: TEST_TRANSPARENT_PNG_BUFFER.length, format: "png" }, + }); + + const request: ImageProcessingRequest = { + requestId: "test-alpha-png", + timestamp: Date.now(), + origin: { url: "https://example.com/icon.png" }, + sourceImageContentType: "image/png", + clientHeaders: { "dit-accept": "image/jpeg" }, + transformations: [{ type: "format", value: "jpeg", source: "url" }], + response: { headers: {} }, + }; + + await service.process(request); + + expect(request.transformations[0].value).toBe("png"); + expect(request.response.contentType).toBe("image/png"); + }); + + it("should leave jpeg format unchanged when source has no alpha", async () => { + jest.spyOn(service["originFetcher"], "fetchImage").mockResolvedValue({ + buffer: TEST_JPEG_BUFFER, + metadata: { size: TEST_JPEG_BUFFER.length, format: "jpeg" }, + }); + + const request: ImageProcessingRequest = { + requestId: "test-opaque-jpeg", + timestamp: Date.now(), + origin: { url: "https://example.com/photo.jpg" }, + sourceImageContentType: "image/jpeg", + clientHeaders: { "dit-accept": "image/webp,image/jpeg" }, + transformations: [{ type: "format", value: "jpeg", source: "url" }], + response: { headers: {} }, + }; + + await service.process(request); + + expect(request.transformations[0].value).toBe("jpeg"); + expect(request.response.contentType).toBe("image/jpeg"); + }); + }); + + describe("animated GIF handling", () => { + it("should re-instantiate with animated=false for single-frame GIF", async () => { + jest.spyOn(service["originFetcher"], "fetchImage").mockResolvedValue({ buffer: TEST_GIF_BUFFER, - metadata: { size: TEST_GIF_BUFFER.length, format: 'gif' } + metadata: { size: TEST_GIF_BUFFER.length, format: "gif" }, }); const request: ImageProcessingRequest = { - requestId: 'test-single-frame-gif', + requestId: "test-single-frame-gif", timestamp: Date.now(), - origin: { url: 'https://example.com/image.gif' }, - sourceImageContentType: 'image/gif', - transformations: [{ type: 'resize', value: { width: 50 }, source: 'url' }], - response: { headers: {} } + origin: { url: "https://example.com/image.gif" }, + sourceImageContentType: "image/gif", + transformations: [{ type: "resize", value: { width: 50 }, source: "url" }], + response: { headers: {} }, }; const result = await service.process(request); expect(result).toBeInstanceOf(Buffer); }); }); - -}); \ No newline at end of file +}); diff --git a/source/container/src/services/image-processing/image-processor.service.ts b/source/container/src/services/image-processing/image-processor.service.ts index 6afc21edb..958c82a9d 100644 --- a/source/container/src/services/image-processing/image-processor.service.ts +++ b/source/container/src/services/image-processing/image-processor.service.ts @@ -1,13 +1,13 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import sharp from 'sharp'; -import { ImageProcessingRequest } from '../../types/image-processing-request'; -import { OriginFetcher } from './origin-fetcher'; -import { ImageProcessingError } from './types'; -import { ErrorMapper } from './utils/error-mapping'; -import { TransformationMapper } from './transformation-engine/transformation-mapper'; -import { EditApplicator } from './transformation-engine/edit-applicator'; +import sharp from "sharp"; +import { ImageProcessingRequest } from "../../types/image-processing-request"; +import { OriginFetcher } from "./origin-fetcher"; +import { ImageProcessingError } from "./types"; +import { ErrorMapper } from "./utils/error-mapping"; +import { TransformationMapper } from "./transformation-engine/transformation-mapper"; +import { EditApplicator } from "./transformation-engine/edit-applicator"; export class ImageProcessorService { private static instance: ImageProcessorService; @@ -43,27 +43,30 @@ export class ImageProcessorService { imageRequest.timings.imageProcessing.transformationApplicationMs = 0; return imageBuffer; } - + // Extract source dimensions to validate auto-resize transformations const metadata = await sharp(imageBuffer).metadata(); this.preventAutoUpscaling(imageRequest, metadata.width); - + this.preventAlphaToJpeg(imageRequest, metadata.hasAlpha); + // We need to map Transformations to Edits before Sharp image instantiation because it influences whether or not we strip or keep metadata const imageEdits = await TransformationMapper.mapToImageEdits(imageRequest.transformations); - - console.log(JSON.stringify({ - requestId: imageRequest.requestId, - component: 'TransformationMapper', - operation: 'edits_mapped', - editTypes: Object.keys(imageEdits), - editCount: Object.keys(imageEdits).length - })); - - const isExpectedToBeAnimated = imageRequest.sourceImageContentType == 'image/gif'; + + console.log( + JSON.stringify({ + requestId: imageRequest.requestId, + component: "TransformationMapper", + operation: "edits_mapped", + editTypes: Object.keys(imageEdits), + editCount: Object.keys(imageEdits).length, + }) + ); + + const isExpectedToBeAnimated = imageRequest.sourceImageContentType == "image/gif"; let sharpOptions = { failOnError: true, - animated: isExpectedToBeAnimated - } + animated: isExpectedToBeAnimated, + }; // Instantiate Sharp image with rotation-aware logic let image = this.instantiateSharpImage(imageBuffer, imageEdits, sharpOptions); @@ -75,24 +78,26 @@ export class ImageProcessorService { } await EditApplicator.applyEdits(image, imageEdits, this.originFetcher); - + // We need to resolve final image format from the outputted image. Obtaining this formating from image metadata prior to being outputted is unreliable. - const finalImage = await image.toBuffer({resolveWithObject: true}); - imageRequest.response.contentType = 'image/' + finalImage.info.format; + const finalImage = await image.toBuffer({ resolveWithObject: true }); + imageRequest.response.contentType = "image/" + finalImage.info.format; const totalImageProcessingMs = Date.now() - startTime; - imageRequest.timings.imageProcessing.transformationApplicationMs = + imageRequest.timings.imageProcessing.transformationApplicationMs = totalImageProcessingMs - imageRequest.timings.imageProcessing.originFetchMs; - - console.log(JSON.stringify({ - metricType: 'imageTransformation', - originImageSize: originMetadata.size, - transformedImageSize: finalImage.data.length, - originFormat: originMetadata.format || 'unknown', - transformedFormat: finalImage.info.format, - transformationTimeMs: totalImageProcessingMs, - requestId: imageRequest.requestId - })); + + console.log( + JSON.stringify({ + metricType: "imageTransformation", + originImageSize: originMetadata.size, + transformedImageSize: finalImage.data.length, + originFormat: originMetadata.format || "unknown", + transformedFormat: finalImage.info.format, + transformationTimeMs: totalImageProcessingMs, + requestId: imageRequest.requestId, + }) + ); return finalImage.data; } catch (error) { @@ -100,18 +105,51 @@ export class ImageProcessorService { } } + private preventAlphaToJpeg(imageRequest: ImageProcessingRequest, hasAlpha: boolean): void { + if (!hasAlpha || !imageRequest.transformations?.length) return; + + // Defense in depth: even after the auto-optimizer alpha guard, a format + // transformation may target jpeg (URL-specified or upstream selection on an + // image whose Content-Type didn't disclose its alpha channel). JPEG drops + // alpha and visually breaks transparent assets — rewrite to an alpha-safe + // format before Sharp is invoked. + const accept = imageRequest.clientHeaders?.["dit-accept"] || ""; + const replacement = accept.includes("image/webp") ? "webp" : "png"; + + let rewrote = false; + for (const t of imageRequest.transformations) { + if (t.type === "format" && t.value === "jpeg") { + t.value = replacement; + rewrote = true; + } + } + + if (rewrote) { + console.log( + JSON.stringify({ + requestId: imageRequest.requestId, + component: "ImageProcessor", + operation: "alpha_jpeg_override", + replacement, + }) + ); + } + } + private preventAutoUpscaling(imageRequest: ImageProcessingRequest, sourceWidth: number): void { if (!imageRequest.transformations?.length || !sourceWidth) return; - imageRequest.transformations = imageRequest.transformations.filter(t => { + imageRequest.transformations = imageRequest.transformations.filter((t) => { console.log(t); - if (t.type === 'resize' && t.source === 'auto' && t.value?.width > sourceWidth) { - console.log(JSON.stringify({ - requestId: imageRequest.requestId, - component: 'ImageProcessor', - operation: 'auto_upscale_prevented', - sourceWidth, - requestedWidth: t.value.width - })); + if (t.type === "resize" && t.source === "auto" && t.value?.width > sourceWidth) { + console.log( + JSON.stringify({ + requestId: imageRequest.requestId, + component: "ImageProcessor", + operation: "auto_upscale_prevented", + sourceWidth, + requestedWidth: t.value.width, + }) + ); return false; } return true; @@ -119,33 +157,33 @@ export class ImageProcessorService { } private instantiateSharpImage(imageBuffer: Buffer, imageEdits: any, options?: any): sharp.Sharp { - const limitInputPixels = parseInt(process.env.LIMIT_INPUT_PIXELS || '1000000000', 10); + const limitInputPixels = parseInt(process.env.LIMIT_INPUT_PIXELS || "1000000000", 10); const sharpOptions: sharp.SharpOptions = { limitInputPixels, ...options }; // Default behavior of DIT is to keep all Metadata. Sharp by default converts the ICC to sRGB. Must chain keepIcc and keepMetadata to prevent this. let returnInstance = sharp(imageBuffer, sharpOptions).keepIccProfile().keepMetadata(); try { - if(imageEdits.stripExif === true){ + if (imageEdits.stripExif === true) { // Removes all EXIF, by inserting the Software EXIF tag. Atleast 1 field is required to use Sharp.withExif(). Leaves ICC untouched. returnInstance.keepIccProfile().withExif({ IFD0: { - Software: 'Dynamic Image Transformation for Amazon CloudFront' - } + Software: "Dynamic Image Transformation for Amazon CloudFront", + }, }); - } + } if (imageEdits.stripIcc === true) { - // Strips ICC by defaulting to sRGB color space, while keeping EXIF untouched. Allows strip_exif and strip_icc to be used in combination with eachother. + // Strips ICC by defaulting to sRGB color space, while keeping EXIF untouched. Allows strip_exif and strip_icc to be used in combination with eachother. returnInstance .keepExif() // Keep EXIF - .withIccProfile('srgb'); // Force standard sRGB instead of original ICC + .withIccProfile("srgb"); // Force standard sRGB instead of original ICC } return returnInstance; } catch (error) { throw new ImageProcessingError( 500, - 'InstantiationError', - 'Input image could not be instantiated. Please choose a valid image.', + "InstantiationError", + "Input image could not be instantiated. Please choose a valid image.", error.message ); } } -} \ No newline at end of file +} diff --git a/source/container/src/services/transformation-resolver/auto-optimization/auto-optimizer.test.ts b/source/container/src/services/transformation-resolver/auto-optimization/auto-optimizer.test.ts index 513725e41..854bfcd89 100644 --- a/source/container/src/services/transformation-resolver/auto-optimization/auto-optimizer.test.ts +++ b/source/container/src/services/transformation-resolver/auto-optimization/auto-optimizer.test.ts @@ -1,12 +1,12 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { Request } from 'express'; -import { applyAutoOptimizations } from './auto-optimizer'; -import { Transformation, TransformationPolicy } from '../../../types/transformation'; -import { ImageProcessingRequest } from '../../../types/image-processing-request'; +import { Request } from "express"; +import { applyAutoOptimizations } from "./auto-optimizer"; +import { Transformation, TransformationPolicy } from "../../../types/transformation"; +import { ImageProcessingRequest } from "../../../types/image-processing-request"; -describe('applyAutoOptimizations', () => { +describe("applyAutoOptimizations", () => { let mockRequest: Partial; let baseTransformations: Transformation[]; let mockPolicy: TransformationPolicy; @@ -14,286 +14,346 @@ describe('applyAutoOptimizations', () => { beforeEach(() => { mockRequest = { header: jest.fn((name: string) => { - if (name === 'set-cookie') { + if (name === "set-cookie") { return mockRequest.headers?.[name.toLowerCase()] as string[] | undefined; } return mockRequest.headers?.[name.toLowerCase()] as string | undefined; - }) as any + }) as any, }; baseTransformations = []; - + mockPolicy = { - policyId: 'test-policy', - policyName: 'Test Policy', + policyId: "test-policy", + policyName: "Test Policy", transformations: [], - isDefault: false + isDefault: false, }; }); - describe('format optimizations', () => { - it('should optimize format when policy output format is auto', () => { - mockPolicy.outputs = [{ type: 'format', value: 'auto' }]; - mockRequest.headers = { 'dit-accept': 'text/html,image/jpg,*/*' }; - + describe("format optimizations", () => { + it("should optimize format when policy output format is auto", () => { + mockPolicy.outputs = [{ type: "format", value: "auto" }]; + mockRequest.headers = { "dit-accept": "text/html,image/jpg,*/*" }; + const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy); - + expect(result).toHaveLength(1); expect(result[0]).toEqual({ - type: 'format', - value: 'jpeg', - source: 'auto' + type: "format", + value: "jpeg", + source: "auto", }); }); - it('should prioritize formats by priority order (webp, avif, jpeg, png)', () => { - mockPolicy.outputs = [{ type: 'format', value: 'auto' }]; - mockRequest.headers = { 'dit-accept': 'image/jpeg,image/avif,image/avif,*/*' }; - + it("should prioritize formats by priority order (webp, avif, jpeg, png)", () => { + mockPolicy.outputs = [{ type: "format", value: "auto" }]; + mockRequest.headers = { "dit-accept": "image/jpeg,image/avif,image/avif,*/*" }; + const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy); - + expect(result).toHaveLength(1); - expect(result[0].value).toBe('avif'); + expect(result[0].value).toBe("avif"); }); - it('should apply static format when policy format is not auto', () => { - mockPolicy.outputs = [{ type: 'format', value: 'jpeg' }]; - mockRequest.headers = { 'dit-accept': 'image/webp,*/*' }; - + it("should apply static format when policy format is not auto", () => { + mockPolicy.outputs = [{ type: "format", value: "jpeg" }]; + mockRequest.headers = { "dit-accept": "image/webp,*/*" }; + const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy); - + expect(result).toHaveLength(1); expect(result[0]).toEqual({ - type: 'format', - value: 'jpeg', - source: 'auto' + type: "format", + value: "jpeg", + source: "auto", }); }); - it('should ignore wildcards and return no format optimization', () => { - mockPolicy.outputs = [{ type: 'format', value: 'auto' }]; - mockRequest.headers = { 'dit-accept': 'image/*,*/*' }; - + it("should ignore wildcards and return no format optimization", () => { + mockPolicy.outputs = [{ type: "format", value: "auto" }]; + mockRequest.headers = { "dit-accept": "image/*,*/*" }; + const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy); - + expect(result).toHaveLength(0); }); - it('should skip format conversion when source is GIF and selected format is not animation-capable', () => { - mockPolicy.outputs = [{ type: 'format', value: 'auto' }]; - mockRequest.headers = { 'dit-accept': 'image/jpeg' }; - const imageRequest = { sourceImageContentType: 'image/gif' } as ImageProcessingRequest; + it("should skip format conversion when source is GIF and selected format is not animation-capable", () => { + mockPolicy.outputs = [{ type: "format", value: "auto" }]; + mockRequest.headers = { "dit-accept": "image/jpeg" }; + const imageRequest = { sourceImageContentType: "image/gif" } as ImageProcessingRequest; const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy, imageRequest); expect(result).toHaveLength(0); }); - it('should allow format conversion when source is GIF and selected format is webp', () => { - mockPolicy.outputs = [{ type: 'format', value: 'auto' }]; - mockRequest.headers = { 'dit-accept': 'image/webp' }; - const imageRequest = { sourceImageContentType: 'image/gif' } as ImageProcessingRequest; + it("should allow format conversion when source is GIF and selected format is webp", () => { + mockPolicy.outputs = [{ type: "format", value: "auto" }]; + mockRequest.headers = { "dit-accept": "image/webp" }; + const imageRequest = { sourceImageContentType: "image/gif" } as ImageProcessingRequest; const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy, imageRequest); expect(result).toHaveLength(1); - expect(result[0]).toEqual({ type: 'format', value: 'webp', source: 'auto' }); + expect(result[0]).toEqual({ type: "format", value: "webp", source: "auto" }); }); - it('should allow format conversion when source is GIF and selected format is avif', () => { - mockPolicy.outputs = [{ type: 'format', value: 'auto' }]; - mockRequest.headers = { 'dit-accept': 'image/avif' }; - const imageRequest = { sourceImageContentType: 'image/gif' } as ImageProcessingRequest; + it("should allow format conversion when source is GIF and selected format is avif", () => { + mockPolicy.outputs = [{ type: "format", value: "auto" }]; + mockRequest.headers = { "dit-accept": "image/avif" }; + const imageRequest = { sourceImageContentType: "image/gif" } as ImageProcessingRequest; const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy, imageRequest); expect(result).toHaveLength(1); - expect(result[0]).toEqual({ type: 'format', value: 'avif', source: 'auto' }); + expect(result[0]).toEqual({ type: "format", value: "avif", source: "auto" }); + }); + + it("should not restrict format selection for non-GIF sources", () => { + mockPolicy.outputs = [{ type: "format", value: "auto" }]; + mockRequest.headers = { "dit-accept": "image/jpeg" }; + const imageRequest = { sourceImageContentType: "image/jpeg" } as ImageProcessingRequest; + + const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy, imageRequest); + + // Source is already jpeg, so the optimizer drops the no-op transformation. + expect(result).toHaveLength(0); + }); + + it("should skip jpeg selection when source is png (alpha guard)", () => { + mockPolicy.outputs = [{ type: "format", value: "auto" }]; + mockRequest.headers = { "dit-accept": "image/jpeg" }; + const imageRequest = { sourceImageContentType: "image/png" } as ImageProcessingRequest; + + const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy, imageRequest); + + // No non-jpeg alternative is accepted — source format passes through. + expect(result).toHaveLength(0); }); - it('should not restrict format selection for non-GIF sources', () => { - mockPolicy.outputs = [{ type: 'format', value: 'auto' }]; - mockRequest.headers = { 'dit-accept': 'image/jpeg' }; - const imageRequest = { sourceImageContentType: 'image/png' } as ImageProcessingRequest; + it("should skip jpeg selection when source is webp (alpha guard)", () => { + mockPolicy.outputs = [{ type: "format", value: "auto" }]; + mockRequest.headers = { "dit-accept": "image/jpeg" }; + const imageRequest = { sourceImageContentType: "image/webp" } as ImageProcessingRequest; + + const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy, imageRequest); + + expect(result).toHaveLength(0); + }); + + it("should fall back from jpeg to png when source is png and png is accepted", () => { + mockPolicy.outputs = [{ type: "format", value: "auto" }]; + mockRequest.headers = { "dit-accept": "image/jpeg,image/png" }; + const imageRequest = { sourceImageContentType: "image/png" } as ImageProcessingRequest; + + const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy, imageRequest); + + // png is the same as source, so the no-op short-circuit applies. + expect(result).toHaveLength(0); + }); + + it("should still pick webp over jpeg for png source when webp is accepted", () => { + mockPolicy.outputs = [{ type: "format", value: "auto" }]; + mockRequest.headers = { "dit-accept": "image/webp,image/jpeg" }; + const imageRequest = { sourceImageContentType: "image/png" } as ImageProcessingRequest; const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy, imageRequest); expect(result).toHaveLength(1); - expect(result[0]).toEqual({ type: 'format', value: 'jpeg', source: 'auto' }); + expect(result[0]).toEqual({ type: "format", value: "webp", source: "auto" }); }); + it("should still allow jpeg selection when source is not png/webp", () => { + mockPolicy.outputs = [{ type: "format", value: "auto" }]; + mockRequest.headers = { "dit-accept": "image/jpeg" }; + const imageRequest = { sourceImageContentType: "image/tiff" } as ImageProcessingRequest; + const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy, imageRequest); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ type: "format", value: "jpeg", source: "auto" }); + }); }); - describe('quality optimizations', () => { - it('should optimize quality based on DPR header with policy mappings', () => { - mockPolicy.outputs = [{ - type: 'quality', - value: [90, [1, 2, 90], [2, 3, 85], [3, 4, 80]] - }]; - mockRequest.headers = { 'dit-dpr': '2.5' }; - + describe("quality optimizations", () => { + it("should optimize quality based on DPR header with policy mappings", () => { + mockPolicy.outputs = [ + { + type: "quality", + value: [90, [1, 2, 90], [2, 3, 85], [3, 4, 80]], + }, + ]; + mockRequest.headers = { "dit-dpr": "2.5" }; + const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy); - + expect(result).toHaveLength(1); expect(result[0]).toEqual({ - type: 'quality', + type: "quality", value: 85, - source: 'auto' + source: "auto", }); }); - it('should apply static quality when policy has single quality value', () => { - mockPolicy.outputs = [{ - type: 'quality', - value: [80] - }]; - mockRequest.headers = { 'dit-dpr': '2' }; - + it("should apply static quality when policy has single quality value", () => { + mockPolicy.outputs = [ + { + type: "quality", + value: [80], + }, + ]; + mockRequest.headers = { "dit-dpr": "2" }; + const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy); - + expect(result).toHaveLength(1); expect(result[0]).toEqual({ - type: 'quality', + type: "quality", value: 80, - source: 'auto' + source: "auto", }); }); - it('should not optimize quality when quality output is missing', () => { - mockPolicy.outputs = [{ type: 'format', value: 'auto' }]; - mockRequest.headers = { 'dit-dpr': '2' }; - + it("should not optimize quality when quality output is missing", () => { + mockPolicy.outputs = [{ type: "format", value: "auto" }]; + mockRequest.headers = { "dit-dpr": "2" }; + const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy); - + expect(result).toHaveLength(0); }); - - }); - describe('size optimizations', () => { - it('should optimize size based on viewport-width with policy breakpoints', () => { - mockPolicy.outputs = [{ - type: 'autosize', - value: [480, 768, 1024, 1200] - }]; - mockRequest.headers = { 'dit-viewport-width': '800' }; - + describe("size optimizations", () => { + it("should optimize size based on viewport-width with policy breakpoints", () => { + mockPolicy.outputs = [ + { + type: "autosize", + value: [480, 768, 1024, 1200], + }, + ]; + mockRequest.headers = { "dit-viewport-width": "800" }; + const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy); - + expect(result).toHaveLength(1); expect(result[0]).toEqual({ - type: 'resize', + type: "resize", value: { width: 1024 }, - source: 'auto' + source: "auto", }); }); - it('should use largest breakpoint when viewport exceeds all breakpoints', () => { - mockPolicy.outputs = [{ - type: 'autosize', - value: [480, 768, 1024] - }]; - mockRequest.headers = { 'dit-viewport-width': '1500' }; - + it("should use largest breakpoint when viewport exceeds all breakpoints", () => { + mockPolicy.outputs = [ + { + type: "autosize", + value: [480, 768, 1024], + }, + ]; + mockRequest.headers = { "dit-viewport-width": "1500" }; + const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy); - + expect(result).toHaveLength(1); expect(result[0].value).toEqual({ width: 1024 }); }); - it('should not optimize size when viewport width header is not present', () => { - mockPolicy.outputs = [{ - type: 'autosize', - value: [480, 768, 1024] - }]; + it("should not optimize size when viewport width header is not present", () => { + mockPolicy.outputs = [ + { + type: "autosize", + value: [480, 768, 1024], + }, + ]; mockRequest.headers = {}; - + const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy); - + expect(result).toHaveLength(0); }); - it('should not optimize size when autosize output is not defined', () => { - mockPolicy.outputs = [{ type: 'format', value: 'auto' }]; - mockRequest.headers = { 'dit-viewport-width': '800' }; - + it("should not optimize size when autosize output is not defined", () => { + mockPolicy.outputs = [{ type: "format", value: "auto" }]; + mockRequest.headers = { "dit-viewport-width": "800" }; + const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy); - + expect(result).toHaveLength(0); }); - - }); - describe('optimization combination', () => { - it('should apply multiple optimizations together', () => { + describe("optimization combination", () => { + it("should apply multiple optimizations together", () => { mockPolicy.outputs = [ - { type: 'format', value: 'auto' }, - { type: 'quality', value: [90, [1, 2, 90], [2, 3, 85]] }, - { type: 'autosize', value: [480, 768, 1024] } + { type: "format", value: "auto" }, + { type: "quality", value: [90, [1, 2, 90], [2, 3, 85]] }, + { type: "autosize", value: [480, 768, 1024] }, ]; mockRequest.headers = { - 'dit-accept': 'image/webp,*/*', - 'dit-dpr': '2', - 'dit-viewport-width': '600' + "dit-accept": "image/webp,*/*", + "dit-dpr": "2", + "dit-viewport-width": "600", }; - + const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy); - + expect(result).toHaveLength(3); - expect(result.map(t => t.type)).toContain('format'); - expect(result.map(t => t.type)).toContain('quality'); - expect(result.map(t => t.type)).toContain('resize'); + expect(result.map((t) => t.type)).toContain("format"); + expect(result.map((t) => t.type)).toContain("quality"); + expect(result.map((t) => t.type)).toContain("resize"); }); - it('should preserve existing transformations', () => { - baseTransformations = [{ - type: 'rotate', - value: 90, - source: 'url' - }]; - mockPolicy.outputs = [{ type: 'format', value: 'auto' }]; - mockRequest.headers = { 'dit-accept': 'image/webp,*/*' }; - + it("should preserve existing transformations", () => { + baseTransformations = [ + { + type: "rotate", + value: 90, + source: "url", + }, + ]; + mockPolicy.outputs = [{ type: "format", value: "auto" }]; + mockRequest.headers = { "dit-accept": "image/webp,*/*" }; + const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy); - + expect(result).toHaveLength(2); - expect(result[0].type).toBe('rotate'); - expect(result[1].type).toBe('format'); + expect(result[0].type).toBe("rotate"); + expect(result[1].type).toBe("format"); }); - - }); - describe('edge cases', () => { - it('should handle invalid viewport width values', () => { - mockPolicy.outputs = [{ - type: 'autosize', - value: [480, 768, 1024] - }]; - mockRequest.headers = { 'dit-viewport-width': 'invalid' }; - + describe("edge cases", () => { + it("should handle invalid viewport width values", () => { + mockPolicy.outputs = [ + { + type: "autosize", + value: [480, 768, 1024], + }, + ]; + mockRequest.headers = { "dit-viewport-width": "invalid" }; + const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy); - + expect(result).toHaveLength(0); }); - it('should handle invalid DPR values', () => { - mockPolicy.outputs = [{ - type: 'quality', - value: [80, [1, 2, 90], [2, 3, 85]] - }]; - mockRequest.headers = { 'dit-dpr': 'invalid' }; - + it("should handle invalid DPR values", () => { + mockPolicy.outputs = [ + { + type: "quality", + value: [80, [1, 2, 90], [2, 3, 85]], + }, + ]; + mockRequest.headers = { "dit-dpr": "invalid" }; + const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy); - + expect(result).toHaveLength(1); - expect(result[0].type).toBe('quality'); + expect(result[0].type).toBe("quality"); expect(result[0].value).toBe(80); }); - - }); -}); \ No newline at end of file +}); diff --git a/source/container/src/services/transformation-resolver/auto-optimization/auto-optimizer.ts b/source/container/src/services/transformation-resolver/auto-optimization/auto-optimizer.ts index e12f7955d..9b1164d99 100644 --- a/source/container/src/services/transformation-resolver/auto-optimization/auto-optimizer.ts +++ b/source/container/src/services/transformation-resolver/auto-optimization/auto-optimizer.ts @@ -1,81 +1,104 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { Request } from 'express'; -import { Transformation, TransformationPolicy } from '../../../types/transformation'; -import { ImageProcessingRequest } from '../../../types/image-processing-request'; +import { Request } from "express"; +import { Transformation, TransformationPolicy } from "../../../types/transformation"; +import { ImageProcessingRequest } from "../../../types/image-processing-request"; -const FORMAT_PRIORITY = ['webp', 'avif', 'jpeg', 'png', 'heif', 'tiff', 'raw', 'gif']; +const FORMAT_PRIORITY = ["webp", "avif", "jpeg", "png", "heif", "tiff", "raw", "gif"]; // TODO, DISCUSS WITH TEAM FOR OPTIMAL FORMAT PRIORITIY LIST -const ANIMATION_CAPABLE_FORMATS = new Set(['webp', 'avif', 'gif']); +const ANIMATION_CAPABLE_FORMATS = new Set(["webp", "avif", "gif"]); +// Source content-types that may carry an alpha channel. JPEG output drops alpha, +// so we must not auto-select jpeg when the source is one of these. +const POTENTIALLY_TRANSPARENT_SOURCE_TYPES = new Set(["image/png", "image/webp"]); const FORMAT_MAPPING: Record = { - 'image/webp': 'webp', - 'image/png': 'png', - 'image/jpeg': 'jpeg', - 'image/jpg': 'jpeg', - 'image/avif': 'avif', - 'image/heif': 'heif', - 'image/heic': 'heif', - 'image/tiff': 'tiff', - 'image/raw': 'raw', - 'image/gif': 'gif' + "image/webp": "webp", + "image/png": "png", + "image/jpeg": "jpeg", + "image/jpg": "jpeg", + "image/avif": "avif", + "image/heif": "heif", + "image/heic": "heif", + "image/tiff": "tiff", + "image/raw": "raw", + "image/gif": "gif", }; -export function applyAutoOptimizations(transformations: Transformation[], req: Request, policy?: TransformationPolicy, imageRequest?: ImageProcessingRequest): Transformation[] { +export function applyAutoOptimizations( + transformations: Transformation[], + req: Request, + policy?: TransformationPolicy, + imageRequest?: ImageProcessingRequest +): Transformation[] { const optimizations: Transformation[] = []; - + const outputs = parseOutputs(policy); - + optimizations.push(...getFormatOptimizations(req, outputs.format, imageRequest)); optimizations.push(...getQualityOptimizations(req, outputs.quality)); optimizations.push(...getSizeOptimizations(req, outputs.autosize)); - + return [...transformations, ...optimizations]; } function parseOutputs(policy?: TransformationPolicy) { const outputs = { quality: null, format: null, autosize: null }; - + if (!policy?.outputs) { return outputs; } - + for (const output of policy.outputs) { - if (output.type === 'quality') { + if (output.type === "quality") { outputs.quality = output.value; - } else if (output.type === 'format') { + } else if (output.type === "format") { outputs.format = output.value; - } else if (output.type === 'autosize') { + } else if (output.type === "autosize") { outputs.autosize = output.value; } } - + return outputs; } -function getFormatOptimizations(req: Request, formatConfig: any, imageRequest?: ImageProcessingRequest): Transformation[] { +function getFormatOptimizations( + req: Request, + formatConfig: any, + imageRequest?: ImageProcessingRequest +): Transformation[] { if (!formatConfig) { return []; } - - if (formatConfig !== 'auto') { - return [createOptimizationTransformation('format', formatConfig)]; + + if (formatConfig !== "auto") { + return [createOptimizationTransformation("format", formatConfig)]; } - - const accept = req.header('dit-accept') || ''; - console.log('Accept header found as: ', req.header('dit-accept')) + + const accept = req.header("dit-accept") || ""; + console.log("Accept header found as: ", req.header("dit-accept")); const compatibleFormats = Object.keys(FORMAT_MAPPING) - .filter(mimeType => accept.includes(mimeType)) - .map(mimeType => FORMAT_MAPPING[mimeType]); - - const selectedFormat = FORMAT_PRIORITY.find(format => compatibleFormats.includes(format)); - + .filter((mimeType) => accept.includes(mimeType)) + .map((mimeType) => FORMAT_MAPPING[mimeType]); + + let selectedFormat = FORMAT_PRIORITY.find((format) => compatibleFormats.includes(format)); + if (!selectedFormat) { return []; } - + + // Block jpeg selection for sources that may have an alpha channel (png, webp). + // JPEG has no alpha — choosing it would flatten transparency and visually break + // logos/icons/UI overlays. Fall back to the next non-jpeg accepted format; if + // none, return [] so the source format passes through unchanged. + if (selectedFormat === "jpeg" && POTENTIALLY_TRANSPARENT_SOURCE_TYPES.has(imageRequest?.sourceImageContentType)) { + selectedFormat = FORMAT_PRIORITY.find((format) => format !== "jpeg" && compatibleFormats.includes(format)); + if (!selectedFormat) { + return []; + } + } + // Skip format conversion if source is a GIF and selected format cannot carry animation - const sourceIsGif = imageRequest?.sourceImageContentType === 'image/gif'; + const sourceIsGif = imageRequest?.sourceImageContentType === "image/gif"; if (sourceIsGif && !ANIMATION_CAPABLE_FORMATS.has(selectedFormat)) { return []; } @@ -88,45 +111,45 @@ function getFormatOptimizations(req: Request, formatConfig: any, imageRequest?: } } - return [createOptimizationTransformation('format', selectedFormat)]; + return [createOptimizationTransformation("format", selectedFormat)]; } function getQualityOptimizations(req: Request, qualityConfig: any): Transformation[] { - console.log('getQuality: ', qualityConfig) + console.log("getQuality: ", qualityConfig); if (!qualityConfig || !Array.isArray(qualityConfig) || qualityConfig.length === 0) { return []; } - + const defaultQuality = qualityConfig[0]; - + // Static quality only (no DPR ranges) if (qualityConfig.length === 1) { - return [createOptimizationTransformation('quality', defaultQuality)]; + return [createOptimizationTransformation("quality", defaultQuality)]; } - - const dpr = req.header('dit-dpr'); + + const dpr = req.header("dit-dpr"); if (!dpr) { - return [createOptimizationTransformation('quality', defaultQuality)]; + return [createOptimizationTransformation("quality", defaultQuality)]; } - + const dprValue = parseFloat(dpr); const mappings = qualityConfig.slice(1) as [number, number, number][]; - + for (const [lowerBound, upperBound, qualityValue] of mappings) { if (dprValue >= lowerBound && dprValue < upperBound) { - return [createOptimizationTransformation('quality', qualityValue)]; + return [createOptimizationTransformation("quality", qualityValue)]; } } - - return [createOptimizationTransformation('quality', defaultQuality)]; + + return [createOptimizationTransformation("quality", defaultQuality)]; } function getSizeOptimizations(req: Request, autosizeConfig: any): Transformation[] { if (!autosizeConfig || !Array.isArray(autosizeConfig)) { return []; } - - const viewportWidth = req.header('dit-viewport-width'); + + const viewportWidth = req.header("dit-viewport-width"); if (!viewportWidth) { return []; } @@ -137,15 +160,15 @@ function getSizeOptimizations(req: Request, autosizeConfig: any): Transformation } const breakpoints = autosizeConfig.sort((a, b) => a - b); - const closestBreakpoint = breakpoints.find(bp => bp > vw) || breakpoints[breakpoints.length - 1]; - - return [createOptimizationTransformation('resize', { width: closestBreakpoint })]; + const closestBreakpoint = breakpoints.find((bp) => bp > vw) || breakpoints[breakpoints.length - 1]; + + return [createOptimizationTransformation("resize", { width: closestBreakpoint })]; } function createOptimizationTransformation(type: string, value: any): Transformation { return { type, value, - source: 'auto' + source: "auto", }; -} \ No newline at end of file +} From 2159153a0f164c137041969f3f6d14706e25bc90 Mon Sep 17 00:00:00 2001 From: adityaverm-a Date: Mon, 1 Jun 2026 18:08:08 +0530 Subject: [PATCH 5/6] feat: block lossy-to-lossless format auto-selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guard against re-encoding jpeg/webp sources into a lossless container (png, tiff). Lossless can't recover quality the source already discarded, so it only inflates file size — observed up to +307% on jpeg → png. In getFormatOptimizations(), if the source content-type is in LOSSY_SOURCE_CONTENT_TYPES (image/jpeg, image/jpg, image/webp) and the selected format is in LOSSLESS_TARGET_FORMATS (png, tiff), fall back to the next accepted non-lossless format. The fallback search keeps the alpha guard's no-jpeg rule in effect for alpha-suspect sources so the two guards compose. If no safe fallback is accepted, return [] so the source format passes through unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../auto-optimization/auto-optimizer.test.ts | 77 +++++++++++++++++++ .../auto-optimization/auto-optimizer.ts | 27 +++++++ 2 files changed, 104 insertions(+) diff --git a/source/container/src/services/transformation-resolver/auto-optimization/auto-optimizer.test.ts b/source/container/src/services/transformation-resolver/auto-optimization/auto-optimizer.test.ts index 854bfcd89..ad72487e2 100644 --- a/source/container/src/services/transformation-resolver/auto-optimization/auto-optimizer.test.ts +++ b/source/container/src/services/transformation-resolver/auto-optimization/auto-optimizer.test.ts @@ -175,6 +175,83 @@ describe("applyAutoOptimizations", () => { expect(result).toHaveLength(1); expect(result[0]).toEqual({ type: "format", value: "jpeg", source: "auto" }); }); + + it("should skip png selection when source is jpeg (lossy → lossless guard)", () => { + mockPolicy.outputs = [{ type: "format", value: "auto" }]; + mockRequest.headers = { "dit-accept": "image/png" }; + const imageRequest = { sourceImageContentType: "image/jpeg" } as ImageProcessingRequest; + + const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy, imageRequest); + + // No non-lossless accepted format — source format passes through. + expect(result).toHaveLength(0); + }); + + it("should skip png selection when source is webp (lossy → lossless guard)", () => { + mockPolicy.outputs = [{ type: "format", value: "auto" }]; + mockRequest.headers = { "dit-accept": "image/png" }; + const imageRequest = { sourceImageContentType: "image/webp" } as ImageProcessingRequest; + + const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy, imageRequest); + + expect(result).toHaveLength(0); + }); + + it("should skip tiff selection when source is jpeg (lossy → lossless guard)", () => { + mockPolicy.outputs = [{ type: "format", value: "auto" }]; + mockRequest.headers = { "dit-accept": "image/tiff" }; + const imageRequest = { sourceImageContentType: "image/jpeg" } as ImageProcessingRequest; + + const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy, imageRequest); + + expect(result).toHaveLength(0); + }); + + it("should pick webp over png for jpeg source when both are accepted", () => { + mockPolicy.outputs = [{ type: "format", value: "auto" }]; + mockRequest.headers = { "dit-accept": "image/webp,image/png" }; + const imageRequest = { sourceImageContentType: "image/jpeg" } as ImageProcessingRequest; + + const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy, imageRequest); + + // webp wins on priority — lossy guard never triggers because webp is not lossless. + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ type: "format", value: "webp", source: "auto" }); + }); + + it("should pick avif over png for jpeg source when avif is accepted", () => { + mockPolicy.outputs = [{ type: "format", value: "auto" }]; + mockRequest.headers = { "dit-accept": "image/avif,image/png" }; + const imageRequest = { sourceImageContentType: "image/jpeg" } as ImageProcessingRequest; + + const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy, imageRequest); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ type: "format", value: "avif", source: "auto" }); + }); + + it("should still allow png selection when source is not lossy (png source)", () => { + // png → png hits the same-format short-circuit. This test guards that the + // lossy guard doesn't accidentally fire for png sources. + mockPolicy.outputs = [{ type: "format", value: "auto" }]; + mockRequest.headers = { "dit-accept": "image/png" }; + const imageRequest = { sourceImageContentType: "image/png" } as ImageProcessingRequest; + + const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy, imageRequest); + + expect(result).toHaveLength(0); + }); + + it("should pass through when webp source has only png+jpeg accepted (alpha + lossy both block)", () => { + mockPolicy.outputs = [{ type: "format", value: "auto" }]; + mockRequest.headers = { "dit-accept": "image/png,image/jpeg" }; + const imageRequest = { sourceImageContentType: "image/webp" } as ImageProcessingRequest; + + const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy, imageRequest); + + // Alpha guard blocks jpeg; lossy guard blocks png; nothing left → pass through. + expect(result).toHaveLength(0); + }); }); describe("quality optimizations", () => { diff --git a/source/container/src/services/transformation-resolver/auto-optimization/auto-optimizer.ts b/source/container/src/services/transformation-resolver/auto-optimization/auto-optimizer.ts index 9b1164d99..242fac3fd 100644 --- a/source/container/src/services/transformation-resolver/auto-optimization/auto-optimizer.ts +++ b/source/container/src/services/transformation-resolver/auto-optimization/auto-optimizer.ts @@ -11,6 +11,11 @@ const ANIMATION_CAPABLE_FORMATS = new Set(["webp", "avif", "gif"]); // Source content-types that may carry an alpha channel. JPEG output drops alpha, // so we must not auto-select jpeg when the source is one of these. const POTENTIALLY_TRANSPARENT_SOURCE_TYPES = new Set(["image/png", "image/webp"]); +// Source content-types that are typically lossy. Re-encoding them into a lossless +// container (png, tiff) inflates file size significantly with no quality gain. +const LOSSY_SOURCE_CONTENT_TYPES = new Set(["image/jpeg", "image/jpg", "image/webp"]); +// Output formats that are lossless and would inflate a lossy source on re-encode. +const LOSSLESS_TARGET_FORMATS = new Set(["png", "tiff"]); const FORMAT_MAPPING: Record = { "image/webp": "webp", "image/png": "png", @@ -97,6 +102,28 @@ function getFormatOptimizations( } } + // Block lossless re-encode of a lossy source (e.g. jpeg/webp → png). Lossless + // containers can't recover quality the source already lost, so they only inflate + // file size — observed up to +307% on jpeg → png. Fall back to the next + // accepted non-lossless format. The alpha guard above may have already steered + // away from jpeg for a webp source; preserve that constraint when picking a + // fallback so we don't reintroduce transparency loss. + if ( + LOSSY_SOURCE_CONTENT_TYPES.has(imageRequest?.sourceImageContentType) && + LOSSLESS_TARGET_FORMATS.has(selectedFormat) + ) { + const isAlphaSuspect = POTENTIALLY_TRANSPARENT_SOURCE_TYPES.has(imageRequest?.sourceImageContentType); + selectedFormat = FORMAT_PRIORITY.find( + (format) => + compatibleFormats.includes(format) && + !LOSSLESS_TARGET_FORMATS.has(format) && + (!isAlphaSuspect || format !== "jpeg") + ); + if (!selectedFormat) { + return []; + } + } + // Skip format conversion if source is a GIF and selected format cannot carry animation const sourceIsGif = imageRequest?.sourceImageContentType === "image/gif"; if (sourceIsGif && !ANIMATION_CAPABLE_FORMATS.has(selectedFormat)) { From 52bfe6d03c08c67bf41173e2c9ca67d1732748a3 Mon Sep 17 00:00:00 2001 From: adityaverm-a Date: Mon, 1 Jun 2026 18:17:43 +0530 Subject: [PATCH 6/6] feat: cap animated WebP quality to exploit motion masking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Animated WebP can drop quality significantly without perceptual loss — motion masking lets the eye tolerate compression artifacts between rapidly changing frames. DIT previously emitted animated WebP at the same Q80 as static content; on the 304 GIF→WebP rows shared with Cloudinary, DIT was larger on 100% of rows, average +36%. Thread the existing isAnimation flag (metadata.pages > 1, already computed in applyEdits) into applyFormat and cap webp output quality at ANIMATED_WEBP_QUALITY_CAP=50 when emitting animated content. A caller-supplied lower quality is honored as-is. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../edit-applicator.test.ts | 36 +++++++++++++++++++ .../transformation-engine/edit-applicator.ts | 19 ++++++++-- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/source/container/src/services/image-processing/transformation-engine/edit-applicator.test.ts b/source/container/src/services/image-processing/transformation-engine/edit-applicator.test.ts index 30f2d832e..9cb6848ef 100644 --- a/source/container/src/services/image-processing/transformation-engine/edit-applicator.test.ts +++ b/source/container/src/services/image-processing/transformation-engine/edit-applicator.test.ts @@ -239,6 +239,42 @@ describe('EditApplicator', () => { expect(mockImage.toFormat).toHaveBeenCalledWith('heif', { compression: 'av1' }); }); + + it('Should cap webp quality at 50 for animated content with no explicit quality', async () => { + const mockImage = createMockSharp({ width: 800, height: 600, format: 'gif', pages: 5 }); + const edits: ImageEdits = { toFormat: 'webp' }; + + await EditApplicator.applyEdits(mockImage, edits, mockOriginFetcher as any); + + expect(mockImage.toFormat).toHaveBeenCalledWith('webp', { quality: 50 }); + }); + + it('Should cap webp quality at 50 for animated content when caller-supplied quality is higher', async () => { + const mockImage = createMockSharp({ width: 800, height: 600, format: 'gif', pages: 5 }); + const edits: ImageEdits = { toFormat: 'webp', quality: 80 }; + + await EditApplicator.applyEdits(mockImage, edits, mockOriginFetcher as any); + + expect(mockImage.toFormat).toHaveBeenCalledWith('webp', { quality: 50 }); + }); + + it('Should honor caller-supplied webp quality when lower than the animated cap', async () => { + const mockImage = createMockSharp({ width: 800, height: 600, format: 'gif', pages: 5 }); + const edits: ImageEdits = { toFormat: 'webp', quality: 40 }; + + await EditApplicator.applyEdits(mockImage, edits, mockOriginFetcher as any); + + expect(mockImage.toFormat).toHaveBeenCalledWith('webp', { quality: 40 }); + }); + + it('Should not cap webp quality for static (single-frame) content', async () => { + const mockImage = createMockSharp({ width: 800, height: 600, format: 'webp', pages: 1 }); + const edits: ImageEdits = { toFormat: 'webp', quality: 80 }; + + await EditApplicator.applyEdits(mockImage, edits, mockOriginFetcher as any); + + expect(mockImage.toFormat).toHaveBeenCalledWith('webp', { quality: 80 }); + }); }); describe('calcOverlaySizeOption', () => { diff --git a/source/container/src/services/image-processing/transformation-engine/edit-applicator.ts b/source/container/src/services/image-processing/transformation-engine/edit-applicator.ts index d1ce3ef36..02ca5cece 100644 --- a/source/container/src/services/image-processing/transformation-engine/edit-applicator.ts +++ b/source/container/src/services/image-processing/transformation-engine/edit-applicator.ts @@ -59,8 +59,8 @@ export class EditApplicator { if (this.shouldDeferResizing(edits)) { image.resize(edits.resize); } - - await this.applyFormat(image, edits); + + await this.applyFormat(image, edits, isAnimation); } catch (error) { if (error instanceof ImageProcessingError) throw error; console.error('Sharp applyEdits failed:', { edits, error: error.message, stack: error.stack }); @@ -113,11 +113,24 @@ export class EditApplicator { image.sharpen(editValue === true ? undefined : editValue); } - private static async applyFormat(image: sharp.Sharp, edits: ImageEdits){ + // Animated WebP can drop quality significantly without perceptual loss — motion + // masking lets the eye tolerate compression artifacts between rapidly changing + // frames. Cap quality to this value when emitting animated webp. + private static readonly ANIMATED_WEBP_QUALITY_CAP = 50; + + private static async applyFormat(image: sharp.Sharp, edits: ImageEdits, isAnimation = false){ try { const format = edits.toFormat || (await image.metadata()).format; const options = edits.quality ? { quality: edits.quality } : {}; + // Lower quality for animated WebP. Sharp's default WebP quality is 80; cap + // it (or any caller-supplied higher value) to ANIMATED_WEBP_QUALITY_CAP. A + // caller-supplied lower value is honored as-is. + if (format === 'webp' && isAnimation) { + const currentQuality = options['quality'] ?? 80; + options['quality'] = Math.min(currentQuality, this.ANIMATED_WEBP_QUALITY_CAP); + } + // Enable mozjpeg for JPEG to improve compression (10-15% smaller files) if (format === 'jpeg') { options['mozjpeg'] = true;