refactor: ♻️ Rewrite media management code

This commit is contained in:
Jesse Wierzbinski 2024-06-28 20:10:02 -10:00
parent d09f74e58a
commit faf829437d
No known key found for this signature in database
34 changed files with 1195 additions and 904 deletions

View file

@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, mock } from "bun:test";
import sharp from "sharp";
import { BlurhashPreprocessor } from "./blurhash";
describe("BlurhashPreprocessor", () => {
let preprocessor: BlurhashPreprocessor;
beforeEach(() => {
preprocessor = new BlurhashPreprocessor();
});
it("should calculate blurhash for a valid image", async () => {
const inputBuffer = await sharp({
create: {
width: 100,
height: 100,
channels: 3,
background: { r: 255, g: 0, b: 0 },
},
})
.png()
.toBuffer();
const inputFile = new File([inputBuffer], "test.png", {
type: "image/png",
});
const result = await preprocessor.process(inputFile);
expect(result.file).toBe(inputFile);
expect(result.blurhash).toBeTypeOf("string");
expect(result.blurhash).not.toBe("");
});
it("should return null blurhash for an invalid image", async () => {
const invalidFile = new File(["invalid image data"], "invalid.png", {
type: "image/png",
});
const result = await preprocessor.process(invalidFile);
expect(result.file).toBe(invalidFile);
expect(result.blurhash).toBeNull();
});
it("should handle errors during blurhash calculation", async () => {
const inputBuffer = await sharp({
create: {
width: 100,
height: 100,
channels: 3,
background: { r: 255, g: 0, b: 0 },
},
})
.png()
.toBuffer();
const inputFile = new File([inputBuffer], "test.png", {
type: "image/png",
});
mock.module("blurhash", () => ({
encode: () => {
throw new Error("Test error");
},
}));
const result = await preprocessor.process(inputFile);
expect(result.file).toBe(inputFile);
expect(result.blurhash).toBeNull();
});
});

View file

@ -0,0 +1,45 @@
import { encode } from "blurhash";
import sharp from "sharp";
import type { MediaPreprocessor } from "./media-preprocessor";
export class BlurhashPreprocessor implements MediaPreprocessor {
public async process(
file: File,
): Promise<{ file: File; blurhash: string | null }> {
try {
const arrayBuffer = await file.arrayBuffer();
const metadata = await sharp(arrayBuffer).metadata();
const blurhash = await new Promise<string | null>((resolve) => {
(async () =>
sharp(arrayBuffer)
.raw()
.ensureAlpha()
.toBuffer((err, buffer) => {
if (err) {
resolve(null);
return;
}
try {
resolve(
encode(
new Uint8ClampedArray(buffer),
metadata?.width ?? 0,
metadata?.height ?? 0,
4,
4,
) as string,
);
} catch {
resolve(null);
}
}))();
});
return { file, blurhash };
} catch {
return { file, blurhash: null };
}
}
}

View file

@ -0,0 +1,156 @@
import { beforeEach, describe, expect, it } from "bun:test";
import type { Config } from "config-manager";
import sharp from "sharp";
import { ImageConversionPreprocessor } from "./image-conversion";
describe("ImageConversionPreprocessor", () => {
let preprocessor: ImageConversionPreprocessor;
let mockConfig: Config;
beforeEach(() => {
mockConfig = {
media: {
conversion: {
convert_images: true,
convert_to: "image/webp",
convert_vector: false,
},
},
} as Config;
preprocessor = new ImageConversionPreprocessor(mockConfig);
});
it("should convert a JPEG image to WebP", async () => {
const inputBuffer = await sharp({
create: {
width: 100,
height: 100,
channels: 3,
background: { r: 255, g: 0, b: 0 },
},
})
.jpeg()
.toBuffer();
const inputFile = new File([inputBuffer], "test.jpg", {
type: "image/jpeg",
});
const result = await preprocessor.process(inputFile);
expect(result.file.type).toBe("image/webp");
expect(result.file.name).toBe("test.webp");
const resultBuffer = await result.file.arrayBuffer();
const metadata = await sharp(resultBuffer).metadata();
expect(metadata.format).toBe("webp");
});
it("should not convert SVG when convert_vector is false", async () => {
const svgContent =
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100" fill="red"/></svg>';
const inputFile = new File([svgContent], "test.svg", {
type: "image/svg+xml",
});
const result = await preprocessor.process(inputFile);
expect(result.file).toBe(inputFile);
});
it("should convert SVG when convert_vector is true", async () => {
mockConfig.media.conversion.convert_vector = true;
preprocessor = new ImageConversionPreprocessor(mockConfig);
const svgContent =
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100" fill="red"/></svg>';
const inputFile = new File([svgContent], "test.svg", {
type: "image/svg+xml",
});
const result = await preprocessor.process(inputFile);
expect(result.file.type).toBe("image/webp");
expect(result.file.name).toBe("test.webp");
});
it("should not convert unsupported file types", async () => {
const inputFile = new File(["test content"], "test.txt", {
type: "text/plain",
});
const result = await preprocessor.process(inputFile);
expect(result.file).toBe(inputFile);
});
it("should throw an error for unsupported output format", async () => {
mockConfig.media.conversion.convert_to = "image/bmp";
preprocessor = new ImageConversionPreprocessor(mockConfig);
const inputBuffer = await sharp({
create: {
width: 100,
height: 100,
channels: 3,
background: { r: 255, g: 0, b: 0 },
},
})
.png()
.toBuffer();
const inputFile = new File([inputBuffer], "test.png", {
type: "image/png",
});
await expect(preprocessor.process(inputFile)).rejects.toThrow(
"Unsupported output format: image/bmp",
);
});
it("should convert animated GIF to WebP while preserving animation", async () => {
// Create a simple animated GIF
const inputBuffer = await sharp({
create: {
width: 100,
height: 100,
channels: 4,
background: { r: 255, g: 0, b: 0, alpha: 1 },
},
})
.gif()
.toBuffer();
const inputFile = new File([inputBuffer], "animated.gif", {
type: "image/gif",
});
const result = await preprocessor.process(inputFile);
expect(result.file.type).toBe("image/webp");
expect(result.file.name).toBe("animated.webp");
const resultBuffer = await result.file.arrayBuffer();
const metadata = await sharp(resultBuffer).metadata();
expect(metadata.format).toBe("webp");
});
it("should handle files with spaces in the name", async () => {
const inputBuffer = await sharp({
create: {
width: 100,
height: 100,
channels: 3,
background: { r: 255, g: 0, b: 0 },
},
})
.png()
.toBuffer();
const inputFile = new File(
[inputBuffer],
"test image with spaces.png",
{ type: "image/png" },
);
const result = await preprocessor.process(inputFile);
expect(result.file.type).toBe("image/webp");
expect(result.file.name).toBe("test image with spaces.webp");
});
});

View file

@ -0,0 +1,122 @@
/**
* @packageDocumentation
* @module MediaManager/Preprocessors
*/
import type { Config } from "config-manager";
import sharp from "sharp";
import type { MediaPreprocessor } from "./media-preprocessor";
/**
* Supported input media formats.
*/
const supportedInputFormats = [
"image/png",
"image/jpeg",
"image/webp",
"image/avif",
"image/svg+xml",
"image/gif",
"image/tiff",
];
/**
* Supported output media formats.
*/
const supportedOutputFormats = [
"image/jpeg",
"image/png",
"image/webp",
"image/avif",
"image/gif",
"image/tiff",
];
/**
* Implements the MediaPreprocessor interface for image conversion.
*/
export class ImageConversionPreprocessor implements MediaPreprocessor {
/**
* Creates a new ImageConversionPreprocessor instance.
* @param config - The configuration object.
*/
constructor(private config: Config) {}
/**
* @inheritdoc
*/
public async process(file: File): Promise<{ file: File }> {
if (!this.isConvertible(file)) {
return { file };
}
const targetFormat = this.config.media.conversion.convert_to;
if (!supportedOutputFormats.includes(targetFormat)) {
throw new Error(`Unsupported output format: ${targetFormat}`);
}
const sharpCommand = sharp(await file.arrayBuffer(), {
animated: true,
});
const commandName = targetFormat.split("/")[1] as
| "jpeg"
| "png"
| "webp"
| "avif"
| "gif"
| "tiff";
const convertedBuffer = await sharpCommand[commandName]().toBuffer();
return {
file: new File(
[convertedBuffer],
this.getReplacedFileName(file.name, commandName),
{
type: targetFormat,
lastModified: Date.now(),
},
),
};
}
/**
* Checks if a file is convertible.
* @param file - The file to check.
* @returns True if the file is convertible, false otherwise.
*/
private isConvertible(file: File): boolean {
if (
file.type === "image/svg+xml" &&
!this.config.media.conversion.convert_vector
) {
return false;
}
return supportedInputFormats.includes(file.type);
}
/**
* Replaces the file extension in the filename.
* @param fileName - The original filename.
* @param newExtension - The new extension.
* @returns The filename with the new extension.
*/
private getReplacedFileName(
fileName: string,
newExtension: string,
): string {
return this.extractFilenameFromPath(fileName).replace(
/\.[^/.]+$/,
`.${newExtension}`,
);
}
/**
* Extracts the filename from a path.
* @param path - The path to extract the filename from.
* @returns The extracted filename.
*/
private extractFilenameFromPath(path: string): string {
const pathParts = path.split(/(?<!\\)\//);
return pathParts[pathParts.length - 1];
}
}

View file

@ -0,0 +1,16 @@
/**
* @packageDocumentation
* @module MediaManager/Preprocessors
*/
/**
* Represents a media preprocessor.
*/
export interface MediaPreprocessor {
/**
* Processes a file before it's stored.
* @param file - The file to process.
* @returns A promise that resolves to the processed file.
*/
process(file: File): Promise<{ file: File } & Record<string, unknown>>;
}