mirror of
https://github.com/versia-pub/server.git
synced 2026-03-13 05:49:16 +01:00
refactor: ♻️ Rewrite media management code
This commit is contained in:
parent
d09f74e58a
commit
faf829437d
34 changed files with 1195 additions and 904 deletions
71
classes/media/preprocessors/blurhash.test.ts
Normal file
71
classes/media/preprocessors/blurhash.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
45
classes/media/preprocessors/blurhash.ts
Normal file
45
classes/media/preprocessors/blurhash.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
156
classes/media/preprocessors/image-conversion.test.ts
Normal file
156
classes/media/preprocessors/image-conversion.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
122
classes/media/preprocessors/image-conversion.ts
Normal file
122
classes/media/preprocessors/image-conversion.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
16
classes/media/preprocessors/media-preprocessor.ts
Normal file
16
classes/media/preprocessors/media-preprocessor.ts
Normal 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>>;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue