simple-photo-gallery 2.0.10 → 2.0.11-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,166 @@
1
+ 'use strict';
2
+
3
+ var blurhash = require('blurhash');
4
+ var sharp3 = require('sharp');
5
+ var child_process = require('child_process');
6
+ var fs2 = require('fs');
7
+ var ffprobe = require('node-ffprobe');
8
+ var buffer = require('buffer');
9
+ var path = require('path');
10
+
11
+ var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
12
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
13
+
14
+ var sharp3__default = /*#__PURE__*/_interopDefault(sharp3);
15
+ var fs2__default = /*#__PURE__*/_interopDefault(fs2);
16
+ var ffprobe__default = /*#__PURE__*/_interopDefault(ffprobe);
17
+ var path__default = /*#__PURE__*/_interopDefault(path);
18
+
19
+ // src/utils/blurhash.ts
20
+ async function loadImage(imagePath) {
21
+ return sharp3__default.default(imagePath).rotate();
22
+ }
23
+ async function loadImageWithMetadata(imagePath) {
24
+ const image = sharp3__default.default(imagePath);
25
+ const metadata = await image.metadata();
26
+ image.rotate();
27
+ const needsDimensionSwap = metadata.orientation && metadata.orientation >= 5 && metadata.orientation <= 8;
28
+ if (needsDimensionSwap) {
29
+ const originalWidth = metadata.width;
30
+ metadata.width = metadata.height;
31
+ metadata.height = originalWidth;
32
+ }
33
+ return { image, metadata };
34
+ }
35
+ async function resizeImage(image, outputPath, width, height, format = "avif") {
36
+ await image.resize(width, height, { withoutEnlargement: true }).toFormat(format).toFile(outputPath);
37
+ }
38
+ async function cropAndResizeImage(image, outputPath, width, height, format = "avif") {
39
+ await image.resize(width, height, {
40
+ fit: "cover",
41
+ withoutEnlargement: true
42
+ }).toFormat(format).toFile(outputPath);
43
+ }
44
+ async function createImageThumbnails(image, metadata, outputPath, outputPathRetina, size) {
45
+ const originalWidth = metadata.width || 0;
46
+ const originalHeight = metadata.height || 0;
47
+ if (originalWidth === 0 || originalHeight === 0) {
48
+ throw new Error("Invalid image dimensions");
49
+ }
50
+ const aspectRatio = originalWidth / originalHeight;
51
+ let width;
52
+ let height;
53
+ if (originalWidth > originalHeight) {
54
+ width = size;
55
+ height = Math.round(size / aspectRatio);
56
+ } else {
57
+ width = Math.round(size * aspectRatio);
58
+ height = size;
59
+ }
60
+ await resizeImage(image, outputPath, width, height);
61
+ await resizeImage(image, outputPathRetina, width * 2, height * 2);
62
+ return { width, height };
63
+ }
64
+
65
+ // src/utils/blurhash.ts
66
+ async function generateBlurHash(imagePath, componentX = 4, componentY = 3) {
67
+ const image = await loadImage(imagePath);
68
+ const { data, info } = await image.resize(32, 32, { fit: "inside" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
69
+ const pixels = new Uint8ClampedArray(data.buffer);
70
+ return blurhash.encode(pixels, info.width, info.height, componentX, componentY);
71
+ }
72
+ async function getVideoDimensions(filePath) {
73
+ const data = await ffprobe__default.default(filePath);
74
+ const videoStream = data.streams.find((stream) => stream.codec_type === "video");
75
+ if (!videoStream) {
76
+ throw new Error("No video stream found");
77
+ }
78
+ const dimensions = {
79
+ width: videoStream.width || 0,
80
+ height: videoStream.height || 0
81
+ };
82
+ if (dimensions.width === 0 || dimensions.height === 0) {
83
+ throw new Error("Invalid video dimensions");
84
+ }
85
+ return dimensions;
86
+ }
87
+ async function createVideoThumbnails(inputPath, videoDimensions, outputPath, outputPathRetina, height, verbose = false) {
88
+ const aspectRatio = videoDimensions.width / videoDimensions.height;
89
+ const width = Math.round(height * aspectRatio);
90
+ const tempFramePath = `${outputPath}.temp.png`;
91
+ return new Promise((resolve, reject) => {
92
+ const ffmpeg = child_process.spawn("ffmpeg", [
93
+ "-i",
94
+ inputPath,
95
+ "-vframes",
96
+ "1",
97
+ "-y",
98
+ "-loglevel",
99
+ verbose ? "error" : "quiet",
100
+ tempFramePath
101
+ ]);
102
+ ffmpeg.stderr.on("data", (data) => {
103
+ console.log(`ffmpeg: ${data.toString()}`);
104
+ });
105
+ ffmpeg.on("close", async (code) => {
106
+ if (code === 0) {
107
+ try {
108
+ const frameImage = sharp3__default.default(tempFramePath);
109
+ await resizeImage(frameImage, outputPath, width, height);
110
+ await resizeImage(frameImage, outputPathRetina, width * 2, height * 2);
111
+ try {
112
+ await fs2.promises.unlink(tempFramePath);
113
+ } catch {
114
+ }
115
+ resolve({ width, height });
116
+ } catch (sharpError) {
117
+ reject(new Error(`Failed to process extracted frame: ${sharpError}`));
118
+ }
119
+ } else {
120
+ reject(new Error(`ffmpeg exited with code ${code}`));
121
+ }
122
+ });
123
+ ffmpeg.on("error", (error) => {
124
+ reject(new Error(`Failed to start ffmpeg: ${error.message}`));
125
+ });
126
+ });
127
+ }
128
+ path__default.default.dirname(new URL((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href))).pathname);
129
+ async function createGallerySocialMediaCardImage(headerPhotoPath, title, ouputPath, ui) {
130
+ ui?.start(`Creating social media card image`);
131
+ const headerBasename = path__default.default.basename(headerPhotoPath, path__default.default.extname(headerPhotoPath));
132
+ if (fs2__default.default.existsSync(ouputPath)) {
133
+ ui?.success(`Social media card image already exists`);
134
+ return headerBasename;
135
+ }
136
+ const image = await loadImage(headerPhotoPath);
137
+ const resizedImageBuffer = await image.resize(1200, 631, { fit: "cover" }).jpeg({ quality: 90 }).toBuffer();
138
+ const outputPath = ouputPath;
139
+ await sharp3__default.default(resizedImageBuffer).toFile(outputPath);
140
+ const svgText = `
141
+ <svg width="1200" height="631" xmlns="http://www.w3.org/2000/svg">
142
+ <defs>
143
+ <style>
144
+ .title { font-family: Arial, sans-serif; font-size: 96px; font-weight: bold; fill: white; stroke: black; stroke-width: 5; paint-order: stroke; text-anchor: middle; }
145
+ </style>
146
+ </defs>
147
+ <text x="600" y="250" class="title">${title}</text>
148
+ </svg>
149
+ `;
150
+ const finalImageBuffer = await sharp3__default.default(resizedImageBuffer).composite([{ input: buffer.Buffer.from(svgText), top: 0, left: 0 }]).jpeg({ quality: 90 }).toBuffer();
151
+ await sharp3__default.default(finalImageBuffer).toFile(outputPath);
152
+ ui?.success(`Created social media card image successfully`);
153
+ return headerBasename;
154
+ }
155
+
156
+ exports.createGallerySocialMediaCardImage = createGallerySocialMediaCardImage;
157
+ exports.createImageThumbnails = createImageThumbnails;
158
+ exports.createVideoThumbnails = createVideoThumbnails;
159
+ exports.cropAndResizeImage = cropAndResizeImage;
160
+ exports.generateBlurHash = generateBlurHash;
161
+ exports.getVideoDimensions = getVideoDimensions;
162
+ exports.loadImage = loadImage;
163
+ exports.loadImageWithMetadata = loadImageWithMetadata;
164
+ exports.resizeImage = resizeImage;
165
+ //# sourceMappingURL=index.cjs.map
166
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utils/image.ts","../../src/utils/blurhash.ts","../../src/utils/video.ts","../../src/modules/build/utils/index.ts"],"names":["sharp","encode","ffprobe","spawn","fs","path","Buffer"],"mappings":"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","file":"index.cjs","sourcesContent":["import sharp from 'sharp';\n\nimport type { Dimensions, ImageWithMetadata } from '../types';\nimport type { FormatEnum, Metadata, Sharp } from 'sharp';\n\n/**\n * Loads an image and auto-rotates it based on EXIF orientation.\n * @param imagePath - Path to the image file\n * @returns Promise resolving to Sharp image instance\n */\nexport async function loadImage(imagePath: string): Promise<Sharp> {\n return sharp(imagePath).rotate();\n}\n\n/**\n * Loads an image and its metadata, auto-rotating it based on EXIF orientation and swapping dimensions if needed.\n * @param imagePath - Path to the image file\n * @returns Promise resolving to ImageWithMetadata object containing Sharp image instance and metadata\n */\nexport async function loadImageWithMetadata(imagePath: string): Promise<ImageWithMetadata> {\n const image = sharp(imagePath);\n const metadata = await image.metadata();\n\n // Auto-rotate based on EXIF orientation\n image.rotate();\n\n // EXIF orientation values 5, 6, 7, 8 require dimension swap after rotation\n const needsDimensionSwap = metadata.orientation && metadata.orientation >= 5 && metadata.orientation <= 8;\n\n // Update metadata with swapped dimensions if needed\n if (needsDimensionSwap) {\n const originalWidth = metadata.width;\n metadata.width = metadata.height;\n metadata.height = originalWidth;\n }\n\n return { image, metadata };\n}\n\n/**\n * Utility function to resize and save thumbnail using Sharp. The functions avoids upscaling the image and only reduces the size if necessary.\n * @param image - Sharp image instance\n * @param outputPath - Path where thumbnail should be saved\n * @param width - Target width for thumbnail\n * @param height - Target height for thumbnail\n */\nexport async function resizeImage(\n image: Sharp,\n outputPath: string,\n width: number,\n height: number,\n format: keyof FormatEnum = 'avif',\n): Promise<void> {\n // Resize the image without enlarging it\n await image.resize(width, height, { withoutEnlargement: true }).toFormat(format).toFile(outputPath);\n}\n\n/**\n * Crops and resizes an image to a target aspect ratio, avoiding upscaling the image.\n * @param image - Sharp image instance\n * @param outputPath - Path where the image should be saved\n * @param width - Target width for the image\n * @param height - Target height for the image\n */\nexport async function cropAndResizeImage(\n image: Sharp,\n outputPath: string,\n width: number,\n height: number,\n format: keyof FormatEnum = 'avif',\n): Promise<void> {\n // Apply resize with cover fit and without enlargement\n await image\n .resize(width, height, {\n fit: 'cover',\n withoutEnlargement: true,\n })\n .toFormat(format)\n .toFile(outputPath);\n}\n\n/**\n * Creates regular and retina thumbnails for an image while maintaining aspect ratio\n * @param image - Sharp image instance\n * @param metadata - Image metadata containing dimensions\n * @param outputPath - Path where thumbnail should be saved\n * @param outputPathRetina - Path where retina thumbnail should be saved\n * @param size - Target size of the longer side of the thumbnail\n * @returns Promise resolving to thumbnail dimensions\n */\nexport async function createImageThumbnails(\n image: Sharp,\n metadata: Metadata,\n outputPath: string,\n outputPathRetina: string,\n size: number,\n): Promise<Dimensions> {\n // Get the original dimensions\n const originalWidth = metadata.width || 0;\n const originalHeight = metadata.height || 0;\n\n if (originalWidth === 0 || originalHeight === 0) {\n throw new Error('Invalid image dimensions');\n }\n\n // Calculate the new size maintaining aspect ratio\n const aspectRatio = originalWidth / originalHeight;\n\n let width: number;\n let height: number;\n\n if (originalWidth > originalHeight) {\n width = size;\n height = Math.round(size / aspectRatio);\n } else {\n width = Math.round(size * aspectRatio);\n height = size;\n }\n\n // Resize the image and create the thumbnails\n await resizeImage(image, outputPath, width, height);\n await resizeImage(image, outputPathRetina, width * 2, height * 2);\n\n // Return the dimensions of the thumbnail\n return { width, height };\n}\n","import { encode } from 'blurhash';\n\nimport { loadImage } from './image';\n\n/**\n * Generates a BlurHash from an image file or Sharp instance\n * @param imagePath - Path to image file or Sharp instance\n * @param componentX - Number of x components (default: 4)\n * @param componentY - Number of y components (default: 3)\n * @returns Promise resolving to BlurHash string\n */\nexport async function generateBlurHash(imagePath: string, componentX: number = 4, componentY: number = 3): Promise<string> {\n const image = await loadImage(imagePath);\n\n // Resize to small size for BlurHash computation to improve performance\n // BlurHash doesn't need high resolution\n const { data, info } = await image\n .resize(32, 32, { fit: 'inside' })\n .ensureAlpha()\n .raw()\n .toBuffer({ resolveWithObject: true });\n\n // Convert to Uint8ClampedArray format expected by blurhash\n const pixels = new Uint8ClampedArray(data.buffer);\n\n // Generate BlurHash\n return encode(pixels, info.width, info.height, componentX, componentY);\n}\n","import { spawn } from 'node:child_process';\nimport { promises as fs } from 'node:fs';\n\nimport ffprobe from 'node-ffprobe';\nimport sharp from 'sharp';\n\nimport { resizeImage } from './image';\n\nimport type { Dimensions } from '../types';\nimport type { Buffer } from 'node:buffer';\n\n/**\n * Gets video dimensions using ffprobe\n * @param filePath - Path to the video file\n * @returns Promise resolving to video dimensions\n * @throws Error if no video stream found or invalid dimensions\n */\nexport async function getVideoDimensions(filePath: string): Promise<Dimensions> {\n const data = await ffprobe(filePath);\n const videoStream = data.streams.find((stream) => stream.codec_type === 'video');\n\n if (!videoStream) {\n throw new Error('No video stream found');\n }\n\n const dimensions = {\n width: videoStream.width || 0,\n height: videoStream.height || 0,\n };\n\n if (dimensions.width === 0 || dimensions.height === 0) {\n throw new Error('Invalid video dimensions');\n }\n\n return dimensions;\n}\n\n/**\n * Creates regular and retina thumbnails for a video by extracting the first frame\n * @param inputPath - Path to the video file\n * @param videoDimensions - Original video dimensions\n * @param outputPath - Path where thumbnail should be saved\n * @param outputPathRetina - Path where retina thumbnail should be saved\n * @param height - Target height for thumbnail\n * @param verbose - Whether to enable verbose ffmpeg output\n * @returns Promise resolving to thumbnail dimensions\n */\nexport async function createVideoThumbnails(\n inputPath: string,\n videoDimensions: Dimensions,\n outputPath: string,\n outputPathRetina: string,\n height: number,\n verbose: boolean = false,\n): Promise<Dimensions> {\n // Calculate width maintaining aspect ratio\n const aspectRatio = videoDimensions.width / videoDimensions.height;\n const width = Math.round(height * aspectRatio);\n\n // Use ffmpeg to extract first frame as a temporary file, then process with sharp\n const tempFramePath = `${outputPath}.temp.png`;\n\n return new Promise((resolve, reject) => {\n // Extract first frame using ffmpeg\n const ffmpeg = spawn('ffmpeg', [\n '-i',\n inputPath,\n '-vframes',\n '1',\n '-y',\n '-loglevel',\n verbose ? 'error' : 'quiet',\n tempFramePath,\n ]);\n\n ffmpeg.stderr.on('data', (data: Buffer) => {\n // FFmpeg writes normal output to stderr, so we don't treat this as an error\n console.log(`ffmpeg: ${data.toString()}`);\n });\n\n ffmpeg.on('close', async (code: number) => {\n if (code === 0) {\n try {\n // Process the extracted frame with sharp\n const frameImage = sharp(tempFramePath);\n await resizeImage(frameImage, outputPath, width, height);\n await resizeImage(frameImage, outputPathRetina, width * 2, height * 2);\n\n // Clean up temporary file\n try {\n await fs.unlink(tempFramePath);\n } catch {\n // Ignore cleanup errors\n }\n\n resolve({ width, height });\n } catch (sharpError) {\n reject(new Error(`Failed to process extracted frame: ${sharpError}`));\n }\n } else {\n reject(new Error(`ffmpeg exited with code ${code}`));\n }\n });\n\n ffmpeg.on('error', (error: Error) => {\n reject(new Error(`Failed to start ffmpeg: ${error.message}`));\n });\n });\n}\n","import { Buffer } from 'node:buffer';\nimport fs from 'node:fs';\nimport path from 'node:path';\n\nimport sharp from 'sharp';\n\nimport { HEADER_IMAGE_LANDSCAPE_WIDTHS, HEADER_IMAGE_PORTRAIT_WIDTHS } from '../../../config';\nimport { generateBlurHash } from '../../../utils/blurhash';\nimport { cropAndResizeImage, loadImage } from '../../../utils/image';\n\nimport type { ConsolaInstance } from 'consola';\n\n/** __dirname workaround for ESM modules */\nconst __dirname = path.dirname(new URL(import.meta.url).pathname);\n\n/**\n * Helper function to resolve paths relative to current file\n * @param segments - Path segments to resolve relative to current directory\n * @returns Resolved absolute path\n */\nexport function resolveFromCurrentDir(...segments: string[]): string {\n return path.resolve(__dirname, ...segments);\n}\n\n/**\n * Creates a social media card image for a gallery\n * @param headerPhotoPath - Path to the header photo\n * @param title - Title of the gallery\n * @param ouputPath - Output path for the social media card image\n * @param ui - ConsolaInstance for logging\n * @returns The basename of the header photo used\n */\nexport async function createGallerySocialMediaCardImage(\n headerPhotoPath: string,\n title: string,\n ouputPath: string,\n ui?: ConsolaInstance,\n): Promise<string> {\n ui?.start(`Creating social media card image`);\n\n const headerBasename = path.basename(headerPhotoPath, path.extname(headerPhotoPath));\n\n if (fs.existsSync(ouputPath)) {\n ui?.success(`Social media card image already exists`);\n return headerBasename;\n }\n\n // Read and resize the header image to 1200x631 using fit\n const image = await loadImage(headerPhotoPath);\n const resizedImageBuffer = await image.resize(1200, 631, { fit: 'cover' }).jpeg({ quality: 90 }).toBuffer();\n\n // Save the resized image as social media card\n const outputPath = ouputPath;\n await sharp(resizedImageBuffer).toFile(outputPath);\n\n // Create SVG with title and description\n const svgText = `\n <svg width=\"1200\" height=\"631\" xmlns=\"http://www.w3.org/2000/svg\">\n <defs>\n <style>\n .title { font-family: Arial, sans-serif; font-size: 96px; font-weight: bold; fill: white; stroke: black; stroke-width: 5; paint-order: stroke; text-anchor: middle; }\n </style>\n </defs>\n <text x=\"600\" y=\"250\" class=\"title\">${title}</text>\n </svg>\n `;\n\n // Composite the text overlay on top of the resized image\n const finalImageBuffer = await sharp(resizedImageBuffer)\n .composite([{ input: Buffer.from(svgText), top: 0, left: 0 }])\n .jpeg({ quality: 90 })\n .toBuffer();\n\n // Save the final image with text overlay\n await sharp(finalImageBuffer).toFile(outputPath);\n\n ui?.success(`Created social media card image successfully`);\n return headerBasename;\n}\n\n/**\n * Creates optimized header images for different orientations and sizes\n * @param headerPhotoPath - Path to the header photo\n * @param outputFolder - Folder where header images should be saved\n * @param ui - ConsolaInstance for logging\n * @returns Object containing the header basename, array of generated file paths, and blurhash\n */\nexport async function createOptimizedHeaderImage(\n headerPhotoPath: string,\n outputFolder: string,\n ui?: ConsolaInstance,\n): Promise<{ headerBasename: string; generatedFiles: string[]; blurHash: string }> {\n ui?.start(`Creating optimized header images`);\n\n const image = await loadImage(headerPhotoPath);\n const headerBasename = path.basename(headerPhotoPath, path.extname(headerPhotoPath));\n const generatedFiles: string[] = [];\n\n // Generate blurhash for the header image\n ui?.debug('Generating blurhash for header image');\n const blurHash = await generateBlurHash(headerPhotoPath);\n\n // Create landscape header images\n const landscapeYFactor = 3 / 4;\n for (const width of HEADER_IMAGE_LANDSCAPE_WIDTHS) {\n ui?.debug(`Creating landscape header image ${width}`);\n\n const avifFilename = `${headerBasename}_landscape_${width}.avif`;\n const jpgFilename = `${headerBasename}_landscape_${width}.jpg`;\n\n if (fs.existsSync(path.join(outputFolder, avifFilename))) {\n ui?.debug(`Landscape header image ${width} AVIF already exists`);\n } else {\n await cropAndResizeImage(\n image.clone(),\n path.join(outputFolder, avifFilename),\n width,\n width * landscapeYFactor,\n 'avif',\n );\n }\n generatedFiles.push(avifFilename);\n\n if (fs.existsSync(path.join(outputFolder, jpgFilename))) {\n ui?.debug(`Landscape header image ${width} JPG already exists`);\n } else {\n await cropAndResizeImage(image.clone(), path.join(outputFolder, jpgFilename), width, width * landscapeYFactor, 'jpg');\n }\n generatedFiles.push(jpgFilename);\n }\n\n // Create portrait header images\n const portraitYFactor = 4 / 3;\n for (const width of HEADER_IMAGE_PORTRAIT_WIDTHS) {\n ui?.debug(`Creating portrait header image ${width}`);\n\n const avifFilename = `${headerBasename}_portrait_${width}.avif`;\n const jpgFilename = `${headerBasename}_portrait_${width}.jpg`;\n\n if (fs.existsSync(path.join(outputFolder, avifFilename))) {\n ui?.debug(`Portrait header image ${width} AVIF already exists`);\n } else {\n await cropAndResizeImage(image.clone(), path.join(outputFolder, avifFilename), width, width * portraitYFactor, 'avif');\n }\n generatedFiles.push(avifFilename);\n\n if (fs.existsSync(path.join(outputFolder, jpgFilename))) {\n ui?.debug(`Portrait header image ${width} JPG already exists`);\n } else {\n await cropAndResizeImage(image.clone(), path.join(outputFolder, jpgFilename), width, width * portraitYFactor, 'jpg');\n }\n generatedFiles.push(jpgFilename);\n }\n\n ui?.success(`Created optimized header image successfully`);\n return { headerBasename, generatedFiles, blurHash };\n}\n\n/**\n * Checks if there are old header images with a different basename than the current one\n * @param outputFolder - Folder containing the header images\n * @param currentHeaderBasename - Basename of the current header image\n * @returns True if old header images with different basename exist, false otherwise\n */\nexport function hasOldHeaderImages(outputFolder: string, currentHeaderBasename: string): boolean {\n if (!fs.existsSync(outputFolder)) {\n return false;\n }\n\n const files = fs.readdirSync(outputFolder);\n\n for (const file of files) {\n // Check if file is a header image (landscape or portrait) with different basename\n const landscapeMatch = file.match(/^(.+)_landscape_\\d+\\.(avif|jpg)$/);\n const portraitMatch = file.match(/^(.+)_portrait_\\d+\\.(avif|jpg)$/);\n\n if (\n (landscapeMatch && landscapeMatch[1] !== currentHeaderBasename) ||\n (portraitMatch && portraitMatch[1] !== currentHeaderBasename)\n ) {\n return true;\n }\n }\n\n return false;\n}\n\n/**\n * Cleans up old header images that don't match the current header image\n * @param outputFolder - Folder containing the header images\n * @param currentHeaderBasename - Basename of the current header image\n * @param ui - ConsolaInstance for logging\n */\nexport function cleanupOldHeaderImages(outputFolder: string, currentHeaderBasename: string, ui?: ConsolaInstance): void {\n ui?.start(`Cleaning up old header images`);\n\n if (!fs.existsSync(outputFolder)) {\n ui?.debug(`Output folder ${outputFolder} does not exist, skipping cleanup`);\n return;\n }\n\n const files = fs.readdirSync(outputFolder);\n let deletedCount = 0;\n\n for (const file of files) {\n // Check if file is a header image (landscape or portrait) with different basename\n const landscapeMatch = file.match(/^(.+)_landscape_\\d+\\.(avif|jpg)$/);\n const portraitMatch = file.match(/^(.+)_portrait_\\d+\\.(avif|jpg)$/);\n\n if (landscapeMatch && landscapeMatch[1] !== currentHeaderBasename) {\n const filePath = path.join(outputFolder, file);\n ui?.debug(`Deleting old landscape header image: ${file}`);\n fs.unlinkSync(filePath);\n deletedCount++;\n } else if (portraitMatch && portraitMatch[1] !== currentHeaderBasename) {\n const filePath = path.join(outputFolder, file);\n ui?.debug(`Deleting old portrait header image: ${file}`);\n fs.unlinkSync(filePath);\n deletedCount++;\n }\n }\n\n if (deletedCount > 0) {\n ui?.success(`Deleted ${deletedCount} old header image(s)`);\n } else {\n ui?.debug(`No old header images to clean up`);\n }\n}\n"]}
@@ -0,0 +1,97 @@
1
+ export { GalleryData, GalleryMetadata, GallerySection, MediaFile, MediaFileWithPath, SubGallery, Thumbnail } from '@simple-photo-gallery/common';
2
+ import { Sharp, Metadata, FormatEnum } from 'sharp';
3
+ import { ConsolaInstance } from 'consola';
4
+
5
+ /**
6
+ * Generates a BlurHash from an image file or Sharp instance
7
+ * @param imagePath - Path to image file or Sharp instance
8
+ * @param componentX - Number of x components (default: 4)
9
+ * @param componentY - Number of y components (default: 3)
10
+ * @returns Promise resolving to BlurHash string
11
+ */
12
+ declare function generateBlurHash(imagePath: string, componentX?: number, componentY?: number): Promise<string>;
13
+
14
+ /** Represents width and height dimensions */
15
+ interface Dimensions {
16
+ /** Width in pixels */
17
+ width: number;
18
+ /** Height in pixels */
19
+ height: number;
20
+ }
21
+ /** Represents an image with metadata */
22
+ interface ImageWithMetadata {
23
+ /** The image */
24
+ image: Sharp;
25
+ /** The metadata */
26
+ metadata: Metadata;
27
+ }
28
+
29
+ /**
30
+ * Loads an image and auto-rotates it based on EXIF orientation.
31
+ * @param imagePath - Path to the image file
32
+ * @returns Promise resolving to Sharp image instance
33
+ */
34
+ declare function loadImage(imagePath: string): Promise<Sharp>;
35
+ /**
36
+ * Loads an image and its metadata, auto-rotating it based on EXIF orientation and swapping dimensions if needed.
37
+ * @param imagePath - Path to the image file
38
+ * @returns Promise resolving to ImageWithMetadata object containing Sharp image instance and metadata
39
+ */
40
+ declare function loadImageWithMetadata(imagePath: string): Promise<ImageWithMetadata>;
41
+ /**
42
+ * Utility function to resize and save thumbnail using Sharp. The functions avoids upscaling the image and only reduces the size if necessary.
43
+ * @param image - Sharp image instance
44
+ * @param outputPath - Path where thumbnail should be saved
45
+ * @param width - Target width for thumbnail
46
+ * @param height - Target height for thumbnail
47
+ */
48
+ declare function resizeImage(image: Sharp, outputPath: string, width: number, height: number, format?: keyof FormatEnum): Promise<void>;
49
+ /**
50
+ * Crops and resizes an image to a target aspect ratio, avoiding upscaling the image.
51
+ * @param image - Sharp image instance
52
+ * @param outputPath - Path where the image should be saved
53
+ * @param width - Target width for the image
54
+ * @param height - Target height for the image
55
+ */
56
+ declare function cropAndResizeImage(image: Sharp, outputPath: string, width: number, height: number, format?: keyof FormatEnum): Promise<void>;
57
+ /**
58
+ * Creates regular and retina thumbnails for an image while maintaining aspect ratio
59
+ * @param image - Sharp image instance
60
+ * @param metadata - Image metadata containing dimensions
61
+ * @param outputPath - Path where thumbnail should be saved
62
+ * @param outputPathRetina - Path where retina thumbnail should be saved
63
+ * @param size - Target size of the longer side of the thumbnail
64
+ * @returns Promise resolving to thumbnail dimensions
65
+ */
66
+ declare function createImageThumbnails(image: Sharp, metadata: Metadata, outputPath: string, outputPathRetina: string, size: number): Promise<Dimensions>;
67
+
68
+ /**
69
+ * Gets video dimensions using ffprobe
70
+ * @param filePath - Path to the video file
71
+ * @returns Promise resolving to video dimensions
72
+ * @throws Error if no video stream found or invalid dimensions
73
+ */
74
+ declare function getVideoDimensions(filePath: string): Promise<Dimensions>;
75
+ /**
76
+ * Creates regular and retina thumbnails for a video by extracting the first frame
77
+ * @param inputPath - Path to the video file
78
+ * @param videoDimensions - Original video dimensions
79
+ * @param outputPath - Path where thumbnail should be saved
80
+ * @param outputPathRetina - Path where retina thumbnail should be saved
81
+ * @param height - Target height for thumbnail
82
+ * @param verbose - Whether to enable verbose ffmpeg output
83
+ * @returns Promise resolving to thumbnail dimensions
84
+ */
85
+ declare function createVideoThumbnails(inputPath: string, videoDimensions: Dimensions, outputPath: string, outputPathRetina: string, height: number, verbose?: boolean): Promise<Dimensions>;
86
+
87
+ /**
88
+ * Creates a social media card image for a gallery
89
+ * @param headerPhotoPath - Path to the header photo
90
+ * @param title - Title of the gallery
91
+ * @param ouputPath - Output path for the social media card image
92
+ * @param ui - ConsolaInstance for logging
93
+ * @returns The basename of the header photo used
94
+ */
95
+ declare function createGallerySocialMediaCardImage(headerPhotoPath: string, title: string, ouputPath: string, ui?: ConsolaInstance): Promise<string>;
96
+
97
+ export { type Dimensions, type ImageWithMetadata, createGallerySocialMediaCardImage, createImageThumbnails, createVideoThumbnails, cropAndResizeImage, generateBlurHash, getVideoDimensions, loadImage, loadImageWithMetadata, resizeImage };
@@ -0,0 +1,97 @@
1
+ export { GalleryData, GalleryMetadata, GallerySection, MediaFile, MediaFileWithPath, SubGallery, Thumbnail } from '@simple-photo-gallery/common';
2
+ import { Sharp, Metadata, FormatEnum } from 'sharp';
3
+ import { ConsolaInstance } from 'consola';
4
+
5
+ /**
6
+ * Generates a BlurHash from an image file or Sharp instance
7
+ * @param imagePath - Path to image file or Sharp instance
8
+ * @param componentX - Number of x components (default: 4)
9
+ * @param componentY - Number of y components (default: 3)
10
+ * @returns Promise resolving to BlurHash string
11
+ */
12
+ declare function generateBlurHash(imagePath: string, componentX?: number, componentY?: number): Promise<string>;
13
+
14
+ /** Represents width and height dimensions */
15
+ interface Dimensions {
16
+ /** Width in pixels */
17
+ width: number;
18
+ /** Height in pixels */
19
+ height: number;
20
+ }
21
+ /** Represents an image with metadata */
22
+ interface ImageWithMetadata {
23
+ /** The image */
24
+ image: Sharp;
25
+ /** The metadata */
26
+ metadata: Metadata;
27
+ }
28
+
29
+ /**
30
+ * Loads an image and auto-rotates it based on EXIF orientation.
31
+ * @param imagePath - Path to the image file
32
+ * @returns Promise resolving to Sharp image instance
33
+ */
34
+ declare function loadImage(imagePath: string): Promise<Sharp>;
35
+ /**
36
+ * Loads an image and its metadata, auto-rotating it based on EXIF orientation and swapping dimensions if needed.
37
+ * @param imagePath - Path to the image file
38
+ * @returns Promise resolving to ImageWithMetadata object containing Sharp image instance and metadata
39
+ */
40
+ declare function loadImageWithMetadata(imagePath: string): Promise<ImageWithMetadata>;
41
+ /**
42
+ * Utility function to resize and save thumbnail using Sharp. The functions avoids upscaling the image and only reduces the size if necessary.
43
+ * @param image - Sharp image instance
44
+ * @param outputPath - Path where thumbnail should be saved
45
+ * @param width - Target width for thumbnail
46
+ * @param height - Target height for thumbnail
47
+ */
48
+ declare function resizeImage(image: Sharp, outputPath: string, width: number, height: number, format?: keyof FormatEnum): Promise<void>;
49
+ /**
50
+ * Crops and resizes an image to a target aspect ratio, avoiding upscaling the image.
51
+ * @param image - Sharp image instance
52
+ * @param outputPath - Path where the image should be saved
53
+ * @param width - Target width for the image
54
+ * @param height - Target height for the image
55
+ */
56
+ declare function cropAndResizeImage(image: Sharp, outputPath: string, width: number, height: number, format?: keyof FormatEnum): Promise<void>;
57
+ /**
58
+ * Creates regular and retina thumbnails for an image while maintaining aspect ratio
59
+ * @param image - Sharp image instance
60
+ * @param metadata - Image metadata containing dimensions
61
+ * @param outputPath - Path where thumbnail should be saved
62
+ * @param outputPathRetina - Path where retina thumbnail should be saved
63
+ * @param size - Target size of the longer side of the thumbnail
64
+ * @returns Promise resolving to thumbnail dimensions
65
+ */
66
+ declare function createImageThumbnails(image: Sharp, metadata: Metadata, outputPath: string, outputPathRetina: string, size: number): Promise<Dimensions>;
67
+
68
+ /**
69
+ * Gets video dimensions using ffprobe
70
+ * @param filePath - Path to the video file
71
+ * @returns Promise resolving to video dimensions
72
+ * @throws Error if no video stream found or invalid dimensions
73
+ */
74
+ declare function getVideoDimensions(filePath: string): Promise<Dimensions>;
75
+ /**
76
+ * Creates regular and retina thumbnails for a video by extracting the first frame
77
+ * @param inputPath - Path to the video file
78
+ * @param videoDimensions - Original video dimensions
79
+ * @param outputPath - Path where thumbnail should be saved
80
+ * @param outputPathRetina - Path where retina thumbnail should be saved
81
+ * @param height - Target height for thumbnail
82
+ * @param verbose - Whether to enable verbose ffmpeg output
83
+ * @returns Promise resolving to thumbnail dimensions
84
+ */
85
+ declare function createVideoThumbnails(inputPath: string, videoDimensions: Dimensions, outputPath: string, outputPathRetina: string, height: number, verbose?: boolean): Promise<Dimensions>;
86
+
87
+ /**
88
+ * Creates a social media card image for a gallery
89
+ * @param headerPhotoPath - Path to the header photo
90
+ * @param title - Title of the gallery
91
+ * @param ouputPath - Output path for the social media card image
92
+ * @param ui - ConsolaInstance for logging
93
+ * @returns The basename of the header photo used
94
+ */
95
+ declare function createGallerySocialMediaCardImage(headerPhotoPath: string, title: string, ouputPath: string, ui?: ConsolaInstance): Promise<string>;
96
+
97
+ export { type Dimensions, type ImageWithMetadata, createGallerySocialMediaCardImage, createImageThumbnails, createVideoThumbnails, cropAndResizeImage, generateBlurHash, getVideoDimensions, loadImage, loadImageWithMetadata, resizeImage };
@@ -0,0 +1,148 @@
1
+ import { encode } from 'blurhash';
2
+ import sharp3 from 'sharp';
3
+ import { spawn } from 'child_process';
4
+ import fs2, { promises } from 'fs';
5
+ import ffprobe from 'node-ffprobe';
6
+ import { Buffer } from 'buffer';
7
+ import path from 'path';
8
+
9
+ // src/utils/blurhash.ts
10
+ async function loadImage(imagePath) {
11
+ return sharp3(imagePath).rotate();
12
+ }
13
+ async function loadImageWithMetadata(imagePath) {
14
+ const image = sharp3(imagePath);
15
+ const metadata = await image.metadata();
16
+ image.rotate();
17
+ const needsDimensionSwap = metadata.orientation && metadata.orientation >= 5 && metadata.orientation <= 8;
18
+ if (needsDimensionSwap) {
19
+ const originalWidth = metadata.width;
20
+ metadata.width = metadata.height;
21
+ metadata.height = originalWidth;
22
+ }
23
+ return { image, metadata };
24
+ }
25
+ async function resizeImage(image, outputPath, width, height, format = "avif") {
26
+ await image.resize(width, height, { withoutEnlargement: true }).toFormat(format).toFile(outputPath);
27
+ }
28
+ async function cropAndResizeImage(image, outputPath, width, height, format = "avif") {
29
+ await image.resize(width, height, {
30
+ fit: "cover",
31
+ withoutEnlargement: true
32
+ }).toFormat(format).toFile(outputPath);
33
+ }
34
+ async function createImageThumbnails(image, metadata, outputPath, outputPathRetina, size) {
35
+ const originalWidth = metadata.width || 0;
36
+ const originalHeight = metadata.height || 0;
37
+ if (originalWidth === 0 || originalHeight === 0) {
38
+ throw new Error("Invalid image dimensions");
39
+ }
40
+ const aspectRatio = originalWidth / originalHeight;
41
+ let width;
42
+ let height;
43
+ if (originalWidth > originalHeight) {
44
+ width = size;
45
+ height = Math.round(size / aspectRatio);
46
+ } else {
47
+ width = Math.round(size * aspectRatio);
48
+ height = size;
49
+ }
50
+ await resizeImage(image, outputPath, width, height);
51
+ await resizeImage(image, outputPathRetina, width * 2, height * 2);
52
+ return { width, height };
53
+ }
54
+
55
+ // src/utils/blurhash.ts
56
+ async function generateBlurHash(imagePath, componentX = 4, componentY = 3) {
57
+ const image = await loadImage(imagePath);
58
+ const { data, info } = await image.resize(32, 32, { fit: "inside" }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
59
+ const pixels = new Uint8ClampedArray(data.buffer);
60
+ return encode(pixels, info.width, info.height, componentX, componentY);
61
+ }
62
+ async function getVideoDimensions(filePath) {
63
+ const data = await ffprobe(filePath);
64
+ const videoStream = data.streams.find((stream) => stream.codec_type === "video");
65
+ if (!videoStream) {
66
+ throw new Error("No video stream found");
67
+ }
68
+ const dimensions = {
69
+ width: videoStream.width || 0,
70
+ height: videoStream.height || 0
71
+ };
72
+ if (dimensions.width === 0 || dimensions.height === 0) {
73
+ throw new Error("Invalid video dimensions");
74
+ }
75
+ return dimensions;
76
+ }
77
+ async function createVideoThumbnails(inputPath, videoDimensions, outputPath, outputPathRetina, height, verbose = false) {
78
+ const aspectRatio = videoDimensions.width / videoDimensions.height;
79
+ const width = Math.round(height * aspectRatio);
80
+ const tempFramePath = `${outputPath}.temp.png`;
81
+ return new Promise((resolve, reject) => {
82
+ const ffmpeg = spawn("ffmpeg", [
83
+ "-i",
84
+ inputPath,
85
+ "-vframes",
86
+ "1",
87
+ "-y",
88
+ "-loglevel",
89
+ verbose ? "error" : "quiet",
90
+ tempFramePath
91
+ ]);
92
+ ffmpeg.stderr.on("data", (data) => {
93
+ console.log(`ffmpeg: ${data.toString()}`);
94
+ });
95
+ ffmpeg.on("close", async (code) => {
96
+ if (code === 0) {
97
+ try {
98
+ const frameImage = sharp3(tempFramePath);
99
+ await resizeImage(frameImage, outputPath, width, height);
100
+ await resizeImage(frameImage, outputPathRetina, width * 2, height * 2);
101
+ try {
102
+ await promises.unlink(tempFramePath);
103
+ } catch {
104
+ }
105
+ resolve({ width, height });
106
+ } catch (sharpError) {
107
+ reject(new Error(`Failed to process extracted frame: ${sharpError}`));
108
+ }
109
+ } else {
110
+ reject(new Error(`ffmpeg exited with code ${code}`));
111
+ }
112
+ });
113
+ ffmpeg.on("error", (error) => {
114
+ reject(new Error(`Failed to start ffmpeg: ${error.message}`));
115
+ });
116
+ });
117
+ }
118
+ path.dirname(new URL(import.meta.url).pathname);
119
+ async function createGallerySocialMediaCardImage(headerPhotoPath, title, ouputPath, ui) {
120
+ ui?.start(`Creating social media card image`);
121
+ const headerBasename = path.basename(headerPhotoPath, path.extname(headerPhotoPath));
122
+ if (fs2.existsSync(ouputPath)) {
123
+ ui?.success(`Social media card image already exists`);
124
+ return headerBasename;
125
+ }
126
+ const image = await loadImage(headerPhotoPath);
127
+ const resizedImageBuffer = await image.resize(1200, 631, { fit: "cover" }).jpeg({ quality: 90 }).toBuffer();
128
+ const outputPath = ouputPath;
129
+ await sharp3(resizedImageBuffer).toFile(outputPath);
130
+ const svgText = `
131
+ <svg width="1200" height="631" xmlns="http://www.w3.org/2000/svg">
132
+ <defs>
133
+ <style>
134
+ .title { font-family: Arial, sans-serif; font-size: 96px; font-weight: bold; fill: white; stroke: black; stroke-width: 5; paint-order: stroke; text-anchor: middle; }
135
+ </style>
136
+ </defs>
137
+ <text x="600" y="250" class="title">${title}</text>
138
+ </svg>
139
+ `;
140
+ const finalImageBuffer = await sharp3(resizedImageBuffer).composite([{ input: Buffer.from(svgText), top: 0, left: 0 }]).jpeg({ quality: 90 }).toBuffer();
141
+ await sharp3(finalImageBuffer).toFile(outputPath);
142
+ ui?.success(`Created social media card image successfully`);
143
+ return headerBasename;
144
+ }
145
+
146
+ export { createGallerySocialMediaCardImage, createImageThumbnails, createVideoThumbnails, cropAndResizeImage, generateBlurHash, getVideoDimensions, loadImage, loadImageWithMetadata, resizeImage };
147
+ //# sourceMappingURL=index.js.map
148
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/utils/image.ts","../../src/utils/blurhash.ts","../../src/utils/video.ts","../../src/modules/build/utils/index.ts"],"names":["sharp","fs"],"mappings":"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","file":"index.js","sourcesContent":["import sharp from 'sharp';\n\nimport type { Dimensions, ImageWithMetadata } from '../types';\nimport type { FormatEnum, Metadata, Sharp } from 'sharp';\n\n/**\n * Loads an image and auto-rotates it based on EXIF orientation.\n * @param imagePath - Path to the image file\n * @returns Promise resolving to Sharp image instance\n */\nexport async function loadImage(imagePath: string): Promise<Sharp> {\n return sharp(imagePath).rotate();\n}\n\n/**\n * Loads an image and its metadata, auto-rotating it based on EXIF orientation and swapping dimensions if needed.\n * @param imagePath - Path to the image file\n * @returns Promise resolving to ImageWithMetadata object containing Sharp image instance and metadata\n */\nexport async function loadImageWithMetadata(imagePath: string): Promise<ImageWithMetadata> {\n const image = sharp(imagePath);\n const metadata = await image.metadata();\n\n // Auto-rotate based on EXIF orientation\n image.rotate();\n\n // EXIF orientation values 5, 6, 7, 8 require dimension swap after rotation\n const needsDimensionSwap = metadata.orientation && metadata.orientation >= 5 && metadata.orientation <= 8;\n\n // Update metadata with swapped dimensions if needed\n if (needsDimensionSwap) {\n const originalWidth = metadata.width;\n metadata.width = metadata.height;\n metadata.height = originalWidth;\n }\n\n return { image, metadata };\n}\n\n/**\n * Utility function to resize and save thumbnail using Sharp. The functions avoids upscaling the image and only reduces the size if necessary.\n * @param image - Sharp image instance\n * @param outputPath - Path where thumbnail should be saved\n * @param width - Target width for thumbnail\n * @param height - Target height for thumbnail\n */\nexport async function resizeImage(\n image: Sharp,\n outputPath: string,\n width: number,\n height: number,\n format: keyof FormatEnum = 'avif',\n): Promise<void> {\n // Resize the image without enlarging it\n await image.resize(width, height, { withoutEnlargement: true }).toFormat(format).toFile(outputPath);\n}\n\n/**\n * Crops and resizes an image to a target aspect ratio, avoiding upscaling the image.\n * @param image - Sharp image instance\n * @param outputPath - Path where the image should be saved\n * @param width - Target width for the image\n * @param height - Target height for the image\n */\nexport async function cropAndResizeImage(\n image: Sharp,\n outputPath: string,\n width: number,\n height: number,\n format: keyof FormatEnum = 'avif',\n): Promise<void> {\n // Apply resize with cover fit and without enlargement\n await image\n .resize(width, height, {\n fit: 'cover',\n withoutEnlargement: true,\n })\n .toFormat(format)\n .toFile(outputPath);\n}\n\n/**\n * Creates regular and retina thumbnails for an image while maintaining aspect ratio\n * @param image - Sharp image instance\n * @param metadata - Image metadata containing dimensions\n * @param outputPath - Path where thumbnail should be saved\n * @param outputPathRetina - Path where retina thumbnail should be saved\n * @param size - Target size of the longer side of the thumbnail\n * @returns Promise resolving to thumbnail dimensions\n */\nexport async function createImageThumbnails(\n image: Sharp,\n metadata: Metadata,\n outputPath: string,\n outputPathRetina: string,\n size: number,\n): Promise<Dimensions> {\n // Get the original dimensions\n const originalWidth = metadata.width || 0;\n const originalHeight = metadata.height || 0;\n\n if (originalWidth === 0 || originalHeight === 0) {\n throw new Error('Invalid image dimensions');\n }\n\n // Calculate the new size maintaining aspect ratio\n const aspectRatio = originalWidth / originalHeight;\n\n let width: number;\n let height: number;\n\n if (originalWidth > originalHeight) {\n width = size;\n height = Math.round(size / aspectRatio);\n } else {\n width = Math.round(size * aspectRatio);\n height = size;\n }\n\n // Resize the image and create the thumbnails\n await resizeImage(image, outputPath, width, height);\n await resizeImage(image, outputPathRetina, width * 2, height * 2);\n\n // Return the dimensions of the thumbnail\n return { width, height };\n}\n","import { encode } from 'blurhash';\n\nimport { loadImage } from './image';\n\n/**\n * Generates a BlurHash from an image file or Sharp instance\n * @param imagePath - Path to image file or Sharp instance\n * @param componentX - Number of x components (default: 4)\n * @param componentY - Number of y components (default: 3)\n * @returns Promise resolving to BlurHash string\n */\nexport async function generateBlurHash(imagePath: string, componentX: number = 4, componentY: number = 3): Promise<string> {\n const image = await loadImage(imagePath);\n\n // Resize to small size for BlurHash computation to improve performance\n // BlurHash doesn't need high resolution\n const { data, info } = await image\n .resize(32, 32, { fit: 'inside' })\n .ensureAlpha()\n .raw()\n .toBuffer({ resolveWithObject: true });\n\n // Convert to Uint8ClampedArray format expected by blurhash\n const pixels = new Uint8ClampedArray(data.buffer);\n\n // Generate BlurHash\n return encode(pixels, info.width, info.height, componentX, componentY);\n}\n","import { spawn } from 'node:child_process';\nimport { promises as fs } from 'node:fs';\n\nimport ffprobe from 'node-ffprobe';\nimport sharp from 'sharp';\n\nimport { resizeImage } from './image';\n\nimport type { Dimensions } from '../types';\nimport type { Buffer } from 'node:buffer';\n\n/**\n * Gets video dimensions using ffprobe\n * @param filePath - Path to the video file\n * @returns Promise resolving to video dimensions\n * @throws Error if no video stream found or invalid dimensions\n */\nexport async function getVideoDimensions(filePath: string): Promise<Dimensions> {\n const data = await ffprobe(filePath);\n const videoStream = data.streams.find((stream) => stream.codec_type === 'video');\n\n if (!videoStream) {\n throw new Error('No video stream found');\n }\n\n const dimensions = {\n width: videoStream.width || 0,\n height: videoStream.height || 0,\n };\n\n if (dimensions.width === 0 || dimensions.height === 0) {\n throw new Error('Invalid video dimensions');\n }\n\n return dimensions;\n}\n\n/**\n * Creates regular and retina thumbnails for a video by extracting the first frame\n * @param inputPath - Path to the video file\n * @param videoDimensions - Original video dimensions\n * @param outputPath - Path where thumbnail should be saved\n * @param outputPathRetina - Path where retina thumbnail should be saved\n * @param height - Target height for thumbnail\n * @param verbose - Whether to enable verbose ffmpeg output\n * @returns Promise resolving to thumbnail dimensions\n */\nexport async function createVideoThumbnails(\n inputPath: string,\n videoDimensions: Dimensions,\n outputPath: string,\n outputPathRetina: string,\n height: number,\n verbose: boolean = false,\n): Promise<Dimensions> {\n // Calculate width maintaining aspect ratio\n const aspectRatio = videoDimensions.width / videoDimensions.height;\n const width = Math.round(height * aspectRatio);\n\n // Use ffmpeg to extract first frame as a temporary file, then process with sharp\n const tempFramePath = `${outputPath}.temp.png`;\n\n return new Promise((resolve, reject) => {\n // Extract first frame using ffmpeg\n const ffmpeg = spawn('ffmpeg', [\n '-i',\n inputPath,\n '-vframes',\n '1',\n '-y',\n '-loglevel',\n verbose ? 'error' : 'quiet',\n tempFramePath,\n ]);\n\n ffmpeg.stderr.on('data', (data: Buffer) => {\n // FFmpeg writes normal output to stderr, so we don't treat this as an error\n console.log(`ffmpeg: ${data.toString()}`);\n });\n\n ffmpeg.on('close', async (code: number) => {\n if (code === 0) {\n try {\n // Process the extracted frame with sharp\n const frameImage = sharp(tempFramePath);\n await resizeImage(frameImage, outputPath, width, height);\n await resizeImage(frameImage, outputPathRetina, width * 2, height * 2);\n\n // Clean up temporary file\n try {\n await fs.unlink(tempFramePath);\n } catch {\n // Ignore cleanup errors\n }\n\n resolve({ width, height });\n } catch (sharpError) {\n reject(new Error(`Failed to process extracted frame: ${sharpError}`));\n }\n } else {\n reject(new Error(`ffmpeg exited with code ${code}`));\n }\n });\n\n ffmpeg.on('error', (error: Error) => {\n reject(new Error(`Failed to start ffmpeg: ${error.message}`));\n });\n });\n}\n","import { Buffer } from 'node:buffer';\nimport fs from 'node:fs';\nimport path from 'node:path';\n\nimport sharp from 'sharp';\n\nimport { HEADER_IMAGE_LANDSCAPE_WIDTHS, HEADER_IMAGE_PORTRAIT_WIDTHS } from '../../../config';\nimport { generateBlurHash } from '../../../utils/blurhash';\nimport { cropAndResizeImage, loadImage } from '../../../utils/image';\n\nimport type { ConsolaInstance } from 'consola';\n\n/** __dirname workaround for ESM modules */\nconst __dirname = path.dirname(new URL(import.meta.url).pathname);\n\n/**\n * Helper function to resolve paths relative to current file\n * @param segments - Path segments to resolve relative to current directory\n * @returns Resolved absolute path\n */\nexport function resolveFromCurrentDir(...segments: string[]): string {\n return path.resolve(__dirname, ...segments);\n}\n\n/**\n * Creates a social media card image for a gallery\n * @param headerPhotoPath - Path to the header photo\n * @param title - Title of the gallery\n * @param ouputPath - Output path for the social media card image\n * @param ui - ConsolaInstance for logging\n * @returns The basename of the header photo used\n */\nexport async function createGallerySocialMediaCardImage(\n headerPhotoPath: string,\n title: string,\n ouputPath: string,\n ui?: ConsolaInstance,\n): Promise<string> {\n ui?.start(`Creating social media card image`);\n\n const headerBasename = path.basename(headerPhotoPath, path.extname(headerPhotoPath));\n\n if (fs.existsSync(ouputPath)) {\n ui?.success(`Social media card image already exists`);\n return headerBasename;\n }\n\n // Read and resize the header image to 1200x631 using fit\n const image = await loadImage(headerPhotoPath);\n const resizedImageBuffer = await image.resize(1200, 631, { fit: 'cover' }).jpeg({ quality: 90 }).toBuffer();\n\n // Save the resized image as social media card\n const outputPath = ouputPath;\n await sharp(resizedImageBuffer).toFile(outputPath);\n\n // Create SVG with title and description\n const svgText = `\n <svg width=\"1200\" height=\"631\" xmlns=\"http://www.w3.org/2000/svg\">\n <defs>\n <style>\n .title { font-family: Arial, sans-serif; font-size: 96px; font-weight: bold; fill: white; stroke: black; stroke-width: 5; paint-order: stroke; text-anchor: middle; }\n </style>\n </defs>\n <text x=\"600\" y=\"250\" class=\"title\">${title}</text>\n </svg>\n `;\n\n // Composite the text overlay on top of the resized image\n const finalImageBuffer = await sharp(resizedImageBuffer)\n .composite([{ input: Buffer.from(svgText), top: 0, left: 0 }])\n .jpeg({ quality: 90 })\n .toBuffer();\n\n // Save the final image with text overlay\n await sharp(finalImageBuffer).toFile(outputPath);\n\n ui?.success(`Created social media card image successfully`);\n return headerBasename;\n}\n\n/**\n * Creates optimized header images for different orientations and sizes\n * @param headerPhotoPath - Path to the header photo\n * @param outputFolder - Folder where header images should be saved\n * @param ui - ConsolaInstance for logging\n * @returns Object containing the header basename, array of generated file paths, and blurhash\n */\nexport async function createOptimizedHeaderImage(\n headerPhotoPath: string,\n outputFolder: string,\n ui?: ConsolaInstance,\n): Promise<{ headerBasename: string; generatedFiles: string[]; blurHash: string }> {\n ui?.start(`Creating optimized header images`);\n\n const image = await loadImage(headerPhotoPath);\n const headerBasename = path.basename(headerPhotoPath, path.extname(headerPhotoPath));\n const generatedFiles: string[] = [];\n\n // Generate blurhash for the header image\n ui?.debug('Generating blurhash for header image');\n const blurHash = await generateBlurHash(headerPhotoPath);\n\n // Create landscape header images\n const landscapeYFactor = 3 / 4;\n for (const width of HEADER_IMAGE_LANDSCAPE_WIDTHS) {\n ui?.debug(`Creating landscape header image ${width}`);\n\n const avifFilename = `${headerBasename}_landscape_${width}.avif`;\n const jpgFilename = `${headerBasename}_landscape_${width}.jpg`;\n\n if (fs.existsSync(path.join(outputFolder, avifFilename))) {\n ui?.debug(`Landscape header image ${width} AVIF already exists`);\n } else {\n await cropAndResizeImage(\n image.clone(),\n path.join(outputFolder, avifFilename),\n width,\n width * landscapeYFactor,\n 'avif',\n );\n }\n generatedFiles.push(avifFilename);\n\n if (fs.existsSync(path.join(outputFolder, jpgFilename))) {\n ui?.debug(`Landscape header image ${width} JPG already exists`);\n } else {\n await cropAndResizeImage(image.clone(), path.join(outputFolder, jpgFilename), width, width * landscapeYFactor, 'jpg');\n }\n generatedFiles.push(jpgFilename);\n }\n\n // Create portrait header images\n const portraitYFactor = 4 / 3;\n for (const width of HEADER_IMAGE_PORTRAIT_WIDTHS) {\n ui?.debug(`Creating portrait header image ${width}`);\n\n const avifFilename = `${headerBasename}_portrait_${width}.avif`;\n const jpgFilename = `${headerBasename}_portrait_${width}.jpg`;\n\n if (fs.existsSync(path.join(outputFolder, avifFilename))) {\n ui?.debug(`Portrait header image ${width} AVIF already exists`);\n } else {\n await cropAndResizeImage(image.clone(), path.join(outputFolder, avifFilename), width, width * portraitYFactor, 'avif');\n }\n generatedFiles.push(avifFilename);\n\n if (fs.existsSync(path.join(outputFolder, jpgFilename))) {\n ui?.debug(`Portrait header image ${width} JPG already exists`);\n } else {\n await cropAndResizeImage(image.clone(), path.join(outputFolder, jpgFilename), width, width * portraitYFactor, 'jpg');\n }\n generatedFiles.push(jpgFilename);\n }\n\n ui?.success(`Created optimized header image successfully`);\n return { headerBasename, generatedFiles, blurHash };\n}\n\n/**\n * Checks if there are old header images with a different basename than the current one\n * @param outputFolder - Folder containing the header images\n * @param currentHeaderBasename - Basename of the current header image\n * @returns True if old header images with different basename exist, false otherwise\n */\nexport function hasOldHeaderImages(outputFolder: string, currentHeaderBasename: string): boolean {\n if (!fs.existsSync(outputFolder)) {\n return false;\n }\n\n const files = fs.readdirSync(outputFolder);\n\n for (const file of files) {\n // Check if file is a header image (landscape or portrait) with different basename\n const landscapeMatch = file.match(/^(.+)_landscape_\\d+\\.(avif|jpg)$/);\n const portraitMatch = file.match(/^(.+)_portrait_\\d+\\.(avif|jpg)$/);\n\n if (\n (landscapeMatch && landscapeMatch[1] !== currentHeaderBasename) ||\n (portraitMatch && portraitMatch[1] !== currentHeaderBasename)\n ) {\n return true;\n }\n }\n\n return false;\n}\n\n/**\n * Cleans up old header images that don't match the current header image\n * @param outputFolder - Folder containing the header images\n * @param currentHeaderBasename - Basename of the current header image\n * @param ui - ConsolaInstance for logging\n */\nexport function cleanupOldHeaderImages(outputFolder: string, currentHeaderBasename: string, ui?: ConsolaInstance): void {\n ui?.start(`Cleaning up old header images`);\n\n if (!fs.existsSync(outputFolder)) {\n ui?.debug(`Output folder ${outputFolder} does not exist, skipping cleanup`);\n return;\n }\n\n const files = fs.readdirSync(outputFolder);\n let deletedCount = 0;\n\n for (const file of files) {\n // Check if file is a header image (landscape or portrait) with different basename\n const landscapeMatch = file.match(/^(.+)_landscape_\\d+\\.(avif|jpg)$/);\n const portraitMatch = file.match(/^(.+)_portrait_\\d+\\.(avif|jpg)$/);\n\n if (landscapeMatch && landscapeMatch[1] !== currentHeaderBasename) {\n const filePath = path.join(outputFolder, file);\n ui?.debug(`Deleting old landscape header image: ${file}`);\n fs.unlinkSync(filePath);\n deletedCount++;\n } else if (portraitMatch && portraitMatch[1] !== currentHeaderBasename) {\n const filePath = path.join(outputFolder, file);\n ui?.debug(`Deleting old portrait header image: ${file}`);\n fs.unlinkSync(filePath);\n deletedCount++;\n }\n }\n\n if (deletedCount > 0) {\n ui?.success(`Deleted ${deletedCount} old header image(s)`);\n } else {\n ui?.debug(`No old header images to clean up`);\n }\n}\n"]}