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/image-processing/transformation-engine/edit-applicator.test.ts b/source/container/src/services/image-processing/transformation-engine/edit-applicator.test.ts index 6f43f9a54..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 @@ -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 () => { @@ -187,7 +191,44 @@ 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 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 () => { @@ -198,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 3250f4468..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,10 +113,46 @@ 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; + } + + // 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 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..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 @@ -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,423 @@ 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; + + 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; + + const result = applyAutoOptimizations(baseTransformations, mockRequest as Request, mockPolicy, imageRequest); + + expect(result).toHaveLength(1); + 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; + + 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 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 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 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 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 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: 'webp', 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" }); + }); + + 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 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 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: 'avif', source: 'auto' }); + expect(result[0]).toEqual({ type: "format", value: "webp", 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/png' } as ImageProcessingRequest; + 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: 'jpeg', source: 'auto' }); + 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', () => { - 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..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 @@ -1,81 +1,131 @@ // 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"]); +// 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', - '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 []; + } + } + + // 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'; + const sourceIsGif = imageRequest?.sourceImageContentType === "image/gif"; if (sourceIsGif && !ANIMATION_CAPABLE_FORMATS.has(selectedFormat)) { return []; } @@ -88,45 +138,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 +187,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 +}