simple-photo-gallery 2.0.16 → 2.0.18

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.
@@ -124,6 +124,30 @@ async function createVideoThumbnails(inputPath, videoDimensions, outputPath, out
124
124
  });
125
125
  });
126
126
  }
127
+ function wrapText(text, maxCharsPerLine) {
128
+ const words = text.split(" ");
129
+ const lines = [];
130
+ let currentLine = "";
131
+ for (const word of words) {
132
+ const testLine = currentLine ? `${currentLine} ${word}` : word;
133
+ if (word.length > maxCharsPerLine) {
134
+ if (currentLine) {
135
+ lines.push(currentLine);
136
+ currentLine = "";
137
+ }
138
+ lines.push(word);
139
+ } else if (testLine.length > maxCharsPerLine && currentLine) {
140
+ lines.push(currentLine);
141
+ currentLine = word;
142
+ } else {
143
+ currentLine = testLine;
144
+ }
145
+ }
146
+ if (currentLine) {
147
+ lines.push(currentLine);
148
+ }
149
+ return lines;
150
+ }
127
151
  async function createGallerySocialMediaCardImage(headerPhotoPath, title, ouputPath, ui) {
128
152
  ui?.start(`Creating social media card image`);
129
153
  const headerBasename = path__default.default.basename(headerPhotoPath, path__default.default.extname(headerPhotoPath));
@@ -135,14 +159,38 @@ async function createGallerySocialMediaCardImage(headerPhotoPath, title, ouputPa
135
159
  const resizedImageBuffer = await image.resize(1200, 631, { fit: "cover" }).jpeg({ quality: 90 }).toBuffer();
136
160
  const outputPath = ouputPath;
137
161
  await sharp3__default.default(resizedImageBuffer).toFile(outputPath);
162
+ const CANVAS_WIDTH = 1200;
163
+ const CANVAS_HEIGHT = 631;
164
+ const FONT_SIZE = 72;
165
+ const MARGIN = 50;
166
+ const CHAR_WIDTH_RATIO = 0.6;
167
+ const usableWidth = CANVAS_WIDTH - 2 * MARGIN;
168
+ const maxCharsPerLine = Math.floor(usableWidth / (FONT_SIZE * CHAR_WIDTH_RATIO));
169
+ const lines = wrapText(title, maxCharsPerLine);
170
+ const lineHeight = FONT_SIZE * 1.2;
171
+ const totalTextHeight = FONT_SIZE + (lines.length - 1) * lineHeight;
172
+ const startY = CANVAS_HEIGHT - MARGIN - totalTextHeight + FONT_SIZE;
173
+ const leftX = MARGIN;
174
+ const tspanElements = lines.map((line, index) => {
175
+ const yPosition = startY + index * lineHeight;
176
+ const escapedLine = line.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
177
+ return `<tspan x="${leftX}" y="${yPosition}">${escapedLine}</tspan>`;
178
+ }).join("\n ");
138
179
  const svgText = `
139
- <svg width="1200" height="631" xmlns="http://www.w3.org/2000/svg">
180
+ <svg width="${CANVAS_WIDTH}" height="${CANVAS_HEIGHT}" xmlns="http://www.w3.org/2000/svg">
140
181
  <defs>
182
+ <linearGradient id="darkGradient" x1="0%" y1="0%" x2="0%" y2="100%">
183
+ <stop offset="0%" style="stop-color:rgb(0,0,0);stop-opacity:0" />
184
+ <stop offset="100%" style="stop-color:rgb(0,0,0);stop-opacity:0.65" />
185
+ </linearGradient>
141
186
  <style>
142
- .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; }
187
+ .title { font-family: 'Arial, sans-serif'; font-size: ${FONT_SIZE}px; font-weight: bold; fill: white; text-anchor: start; }
143
188
  </style>
144
189
  </defs>
145
- <text x="600" y="250" class="title">${title}</text>
190
+ <rect x="0" y="0" width="${CANVAS_WIDTH}" height="${CANVAS_HEIGHT}" fill="url(#darkGradient)" />
191
+ <text x="${leftX}" class="title">
192
+ ${tspanElements}
193
+ </text>
146
194
  </svg>
147
195
  `;
148
196
  const finalImageBuffer = await sharp3__default.default(resizedImageBuffer).composite([{ input: buffer.Buffer.from(svgText), top: 0, left: 0 }]).jpeg({ quality: 90 }).toBuffer();
@@ -1 +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/**\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"]}
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":";;;;;;;;;;;;;;;;;;AAUA,eAAsB,UAAU,SAAA,EAAmC;AACjE,EAAA,OAAOA,uBAAA,CAAM,SAAS,CAAA,CAAE,MAAA,EAAO;AACjC;AAOA,eAAsB,sBAAsB,SAAA,EAA+C;AACzF,EAAA,MAAM,KAAA,GAAQA,wBAAM,SAAS,CAAA;AAC7B,EAAA,MAAM,QAAA,GAAW,MAAM,KAAA,CAAM,QAAA,EAAS;AAGtC,EAAA,KAAA,CAAM,MAAA,EAAO;AAGb,EAAA,MAAM,qBAAqB,QAAA,CAAS,WAAA,IAAe,SAAS,WAAA,IAAe,CAAA,IAAK,SAAS,WAAA,IAAe,CAAA;AAGxG,EAAA,IAAI,kBAAA,EAAoB;AACtB,IAAA,MAAM,gBAAgB,QAAA,CAAS,KAAA;AAC/B,IAAA,QAAA,CAAS,QAAQ,QAAA,CAAS,MAAA;AAC1B,IAAA,QAAA,CAAS,MAAA,GAAS,aAAA;AAAA,EACpB;AAEA,EAAA,OAAO,EAAE,OAAO,QAAA,EAAS;AAC3B;AASA,eAAsB,YACpB,KAAA,EACA,UAAA,EACA,KAAA,EACA,MAAA,EACA,SAA2B,MAAA,EACZ;AAEf,EAAA,MAAM,KAAA,CAAM,MAAA,CAAO,KAAA,EAAO,MAAA,EAAQ,EAAE,kBAAA,EAAoB,IAAA,EAAM,CAAA,CAAE,QAAA,CAAS,MAAM,CAAA,CAAE,OAAO,UAAU,CAAA;AACpG;AASA,eAAsB,mBACpB,KAAA,EACA,UAAA,EACA,KAAA,EACA,MAAA,EACA,SAA2B,MAAA,EACZ;AAEf,EAAA,MAAM,KAAA,CACH,MAAA,CAAO,KAAA,EAAO,MAAA,EAAQ;AAAA,IACrB,GAAA,EAAK,OAAA;AAAA,IACL,kBAAA,EAAoB;AAAA,GACrB,CAAA,CACA,QAAA,CAAS,MAAM,CAAA,CACf,OAAO,UAAU,CAAA;AACtB;AAWA,eAAsB,qBAAA,CACpB,KAAA,EACA,QAAA,EACA,UAAA,EACA,kBACA,IAAA,EACqB;AAErB,EAAA,MAAM,aAAA,GAAgB,SAAS,KAAA,IAAS,CAAA;AACxC,EAAA,MAAM,cAAA,GAAiB,SAAS,MAAA,IAAU,CAAA;AAE1C,EAAA,IAAI,aAAA,KAAkB,CAAA,IAAK,cAAA,KAAmB,CAAA,EAAG;AAC/C,IAAA,MAAM,IAAI,MAAM,0BAA0B,CAAA;AAAA,EAC5C;AAGA,EAAA,MAAM,cAAc,aAAA,GAAgB,cAAA;AAEpC,EAAA,IAAI,KAAA;AACJ,EAAA,IAAI,MAAA;AAEJ,EAAA,IAAI,gBAAgB,cAAA,EAAgB;AAClC,IAAA,KAAA,GAAQ,IAAA;AACR,IAAA,MAAA,GAAS,IAAA,CAAK,KAAA,CAAM,IAAA,GAAO,WAAW,CAAA;AAAA,EACxC,CAAA,MAAO;AACL,IAAA,KAAA,GAAQ,IAAA,CAAK,KAAA,CAAM,IAAA,GAAO,WAAW,CAAA;AACrC,IAAA,MAAA,GAAS,IAAA;AAAA,EACX;AAGA,EAAA,MAAM,WAAA,CAAY,KAAA,EAAO,UAAA,EAAY,KAAA,EAAO,MAAM,CAAA;AAClD,EAAA,MAAM,YAAY,KAAA,EAAO,gBAAA,EAAkB,KAAA,GAAQ,CAAA,EAAG,SAAS,CAAC,CAAA;AAGhE,EAAA,OAAO,EAAE,OAAO,MAAA,EAAO;AACzB;;;AClHA,eAAsB,gBAAA,CAAiB,SAAA,EAAmB,UAAA,GAAqB,CAAA,EAAG,aAAqB,CAAA,EAAoB;AACzH,EAAA,MAAM,KAAA,GAAQ,MAAM,SAAA,CAAU,SAAS,CAAA;AAIvC,EAAA,MAAM,EAAE,MAAM,IAAA,EAAK,GAAI,MAAM,KAAA,CAC1B,MAAA,CAAO,EAAA,EAAI,EAAA,EAAI,EAAE,GAAA,EAAK,UAAU,CAAA,CAChC,aAAY,CACZ,GAAA,GACA,QAAA,CAAS,EAAE,iBAAA,EAAmB,IAAA,EAAM,CAAA;AAGvC,EAAA,MAAM,MAAA,GAAS,IAAI,iBAAA,CAAkB,IAAA,CAAK,MAAM,CAAA;AAGhD,EAAA,OAAOC,gBAAO,MAAA,EAAQ,IAAA,CAAK,OAAO,IAAA,CAAK,MAAA,EAAQ,YAAY,UAAU,CAAA;AACvE;ACVA,eAAsB,mBAAmB,QAAA,EAAuC;AAC9E,EAAA,MAAM,IAAA,GAAO,MAAMC,wBAAA,CAAQ,QAAQ,CAAA;AACnC,EAAA,MAAM,WAAA,GAAc,KAAK,OAAA,CAAQ,IAAA,CAAK,CAAC,MAAA,KAAW,MAAA,CAAO,eAAe,OAAO,CAAA;AAE/E,EAAA,IAAI,CAAC,WAAA,EAAa;AAChB,IAAA,MAAM,IAAI,MAAM,uBAAuB,CAAA;AAAA,EACzC;AAEA,EAAA,MAAM,UAAA,GAAa;AAAA,IACjB,KAAA,EAAO,YAAY,KAAA,IAAS,CAAA;AAAA,IAC5B,MAAA,EAAQ,YAAY,MAAA,IAAU;AAAA,GAChC;AAEA,EAAA,IAAI,UAAA,CAAW,KAAA,KAAU,CAAA,IAAK,UAAA,CAAW,WAAW,CAAA,EAAG;AACrD,IAAA,MAAM,IAAI,MAAM,0BAA0B,CAAA;AAAA,EAC5C;AAEA,EAAA,OAAO,UAAA;AACT;AAYA,eAAsB,sBACpB,SAAA,EACA,eAAA,EACA,YACA,gBAAA,EACA,MAAA,EACA,UAAmB,KAAA,EACE;AAErB,EAAA,MAAM,WAAA,GAAc,eAAA,CAAgB,KAAA,GAAQ,eAAA,CAAgB,MAAA;AAC5D,EAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,KAAA,CAAM,MAAA,GAAS,WAAW,CAAA;AAG7C,EAAA,MAAM,aAAA,GAAgB,GAAG,UAAU,CAAA,SAAA,CAAA;AAEnC,EAAA,OAAO,IAAI,OAAA,CAAQ,CAAC,OAAA,EAAS,MAAA,KAAW;AAEtC,IAAA,MAAM,MAAA,GAASC,oBAAM,QAAA,EAAU;AAAA,MAC7B,IAAA;AAAA,MACA,SAAA;AAAA,MACA,UAAA;AAAA,MACA,GAAA;AAAA,MACA,IAAA;AAAA,MACA,WAAA;AAAA,MACA,UAAU,OAAA,GAAU,OAAA;AAAA,MACpB;AAAA,KACD,CAAA;AAED,IAAA,MAAA,CAAO,MAAA,CAAO,EAAA,CAAG,MAAA,EAAQ,CAAC,IAAA,KAAiB;AAEzC,MAAA,OAAA,CAAQ,GAAA,CAAI,CAAA,QAAA,EAAW,IAAA,CAAK,QAAA,EAAU,CAAA,CAAE,CAAA;AAAA,IAC1C,CAAC,CAAA;AAED,IAAA,MAAA,CAAO,EAAA,CAAG,OAAA,EAAS,OAAO,IAAA,KAAiB;AACzC,MAAA,IAAI,SAAS,CAAA,EAAG;AACd,QAAA,IAAI;AAEF,UAAA,MAAM,UAAA,GAAaH,wBAAM,aAAa,CAAA;AACtC,UAAA,MAAM,WAAA,CAAY,UAAA,EAAY,UAAA,EAAY,KAAA,EAAO,MAAM,CAAA;AACvD,UAAA,MAAM,YAAY,UAAA,EAAY,gBAAA,EAAkB,KAAA,GAAQ,CAAA,EAAG,SAAS,CAAC,CAAA;AAGrE,UAAA,IAAI;AACF,YAAA,MAAMI,YAAA,CAAG,OAAO,aAAa,CAAA;AAAA,UAC/B,CAAA,CAAA,MAAQ;AAAA,UAER;AAEA,UAAA,OAAA,CAAQ,EAAE,KAAA,EAAO,MAAA,EAAQ,CAAA;AAAA,QAC3B,SAAS,UAAA,EAAY;AACnB,UAAA,MAAA,CAAO,IAAI,KAAA,CAAM,CAAA,mCAAA,EAAsC,UAAU,EAAE,CAAC,CAAA;AAAA,QACtE;AAAA,MACF,CAAA,MAAO;AACL,QAAA,MAAA,CAAO,IAAI,KAAA,CAAM,CAAA,wBAAA,EAA2B,IAAI,EAAE,CAAC,CAAA;AAAA,MACrD;AAAA,IACF,CAAC,CAAA;AAED,IAAA,MAAA,CAAO,EAAA,CAAG,OAAA,EAAS,CAAC,KAAA,KAAiB;AACnC,MAAA,MAAA,CAAO,IAAI,KAAA,CAAM,CAAA,wBAAA,EAA2B,KAAA,CAAM,OAAO,EAAE,CAAC,CAAA;AAAA,IAC9D,CAAC,CAAA;AAAA,EACH,CAAC,CAAA;AACH;AC1FA,SAAS,QAAA,CAAS,MAAc,eAAA,EAAmC;AACjE,EAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,KAAA,CAAM,GAAG,CAAA;AAC5B,EAAA,MAAM,QAAkB,EAAC;AACzB,EAAA,IAAI,WAAA,GAAc,EAAA;AAElB,EAAA,KAAA,MAAW,QAAQ,KAAA,EAAO;AACxB,IAAA,MAAM,WAAW,WAAA,GAAc,CAAA,EAAG,WAAW,CAAA,CAAA,EAAI,IAAI,CAAA,CAAA,GAAK,IAAA;AAG1D,IAAA,IAAI,IAAA,CAAK,SAAS,eAAA,EAAiB;AACjC,MAAA,IAAI,WAAA,EAAa;AACf,QAAA,KAAA,CAAM,KAAK,WAAW,CAAA;AACtB,QAAA,WAAA,GAAc,EAAA;AAAA,MAChB;AACA,MAAA,KAAA,CAAM,KAAK,IAAI,CAAA;AAAA,IACjB,CAAA,MAAA,IAAW,QAAA,CAAS,MAAA,GAAS,eAAA,IAAmB,WAAA,EAAa;AAE3D,MAAA,KAAA,CAAM,KAAK,WAAW,CAAA;AACtB,MAAA,WAAA,GAAc,IAAA;AAAA,IAChB,CAAA,MAAO;AACL,MAAA,WAAA,GAAc,QAAA;AAAA,IAChB;AAAA,EACF;AAGA,EAAA,IAAI,WAAA,EAAa;AACf,IAAA,KAAA,CAAM,KAAK,WAAW,CAAA;AAAA,EACxB;AAEA,EAAA,OAAO,KAAA;AACT;AAUA,eAAsB,iCAAA,CACpB,eAAA,EACA,KAAA,EACA,SAAA,EACA,EAAA,EACiB;AACjB,EAAA,EAAA,EAAI,MAAM,CAAA,gCAAA,CAAkC,CAAA;AAE5C,EAAA,MAAM,iBAAiBC,qBAAA,CAAK,QAAA,CAAS,iBAAiBA,qBAAA,CAAK,OAAA,CAAQ,eAAe,CAAC,CAAA;AAEnF,EAAA,IAAID,oBAAAA,CAAG,UAAA,CAAW,SAAS,CAAA,EAAG;AAC5B,IAAA,EAAA,EAAI,QAAQ,CAAA,sCAAA,CAAwC,CAAA;AACpD,IAAA,OAAO,cAAA;AAAA,EACT;AAGA,EAAA,MAAM,KAAA,GAAQ,MAAM,SAAA,CAAU,eAAe,CAAA;AAC7C,EAAA,MAAM,qBAAqB,MAAM,KAAA,CAAM,MAAA,CAAO,IAAA,EAAM,KAAK,EAAE,GAAA,EAAK,OAAA,EAAS,EAAE,IAAA,CAAK,EAAE,SAAS,EAAA,EAAI,EAAE,QAAA,EAAS;AAG1G,EAAA,MAAM,UAAA,GAAa,SAAA;AACnB,EAAA,MAAMJ,uBAAAA,CAAM,kBAAkB,CAAA,CAAE,MAAA,CAAO,UAAU,CAAA;AAGjD,EAAA,MAAM,YAAA,GAAe,IAAA;AACrB,EAAA,MAAM,aAAA,GAAgB,GAAA;AACtB,EAAA,MAAM,SAAA,GAAY,EAAA;AAClB,EAAA,MAAM,MAAA,GAAS,EAAA;AACf,EAAA,MAAM,gBAAA,GAAmB,GAAA;AAGzB,EAAA,MAAM,WAAA,GAAc,eAAe,CAAA,GAAI,MAAA;AACvC,EAAA,MAAM,eAAA,GAAkB,IAAA,CAAK,KAAA,CAAM,WAAA,IAAe,YAAY,gBAAA,CAAiB,CAAA;AAC/E,EAAA,MAAM,KAAA,GAAQ,QAAA,CAAS,KAAA,EAAO,eAAe,CAAA;AAG7C,EAAA,MAAM,aAAa,SAAA,GAAY,GAAA;AAC/B,EAAA,MAAM,eAAA,GAAkB,SAAA,GAAA,CAAa,KAAA,CAAM,MAAA,GAAS,CAAA,IAAK,UAAA;AACzD,EAAA,MAAM,MAAA,GAAS,aAAA,GAAgB,MAAA,GAAS,eAAA,GAAkB,SAAA;AAG1D,EAAA,MAAM,KAAA,GAAQ,MAAA;AACd,EAAA,MAAM,aAAA,GAAgB,KAAA,CACnB,GAAA,CAAI,CAAC,MAAM,KAAA,KAAU;AACpB,IAAA,MAAM,SAAA,GAAY,SAAS,KAAA,GAAQ,UAAA;AAGnC,IAAA,MAAM,WAAA,GAAc,KACjB,OAAA,CAAQ,IAAA,EAAM,OAAO,CAAA,CACrB,OAAA,CAAQ,MAAM,MAAM,CAAA,CACpB,QAAQ,IAAA,EAAM,MAAM,EACpB,OAAA,CAAQ,IAAA,EAAM,QAAQ,CAAA,CACtB,OAAA,CAAQ,MAAM,QAAQ,CAAA;AAEzB,IAAA,OAAO,CAAA,UAAA,EAAa,KAAK,CAAA,KAAA,EAAQ,SAAS,KAAK,WAAW,CAAA,QAAA,CAAA;AAAA,EAC5D,CAAC,CAAA,CACA,IAAA,CAAK,UAAU,CAAA;AAElB,EAAA,MAAM,OAAA,GAAU;AAAA,gBAAA,EACA,YAAY,aAAa,aAAa,CAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,gEAAA,EAOU,SAAS,CAAA;AAAA;AAAA;AAAA,+BAAA,EAG1C,YAAY,aAAa,aAAa,CAAA;AAAA,eAAA,EACtD,KAAK,CAAA;AAAA,MAAA,EACd,aAAa;AAAA;AAAA;AAAA,EAAA,CAAA;AAMnB,EAAA,MAAM,gBAAA,GAAmB,MAAMA,uBAAAA,CAAM,kBAAkB,CAAA,CACpD,SAAA,CAAU,CAAC,EAAE,KAAA,EAAOM,aAAA,CAAO,IAAA,CAAK,OAAO,CAAA,EAAG,KAAK,CAAA,EAAG,IAAA,EAAM,CAAA,EAAG,CAAC,CAAA,CAC5D,IAAA,CAAK,EAAE,OAAA,EAAS,EAAA,EAAI,CAAA,CACpB,QAAA,EAAS;AAGZ,EAAA,MAAMN,uBAAAA,CAAM,gBAAgB,CAAA,CAAE,MAAA,CAAO,UAAU,CAAA;AAE/C,EAAA,EAAA,EAAI,QAAQ,CAAA,4CAAA,CAA8C,CAAA;AAC1D,EAAA,OAAO,cAAA;AACT","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/**\n * Wraps text into multiple lines based on a maximum character width\n * @param text - The text to wrap\n * @param maxCharsPerLine - Maximum number of characters per line (approximate)\n * @returns Array of text lines\n */\nfunction wrapText(text: string, maxCharsPerLine: number): string[] {\n const words = text.split(' ');\n const lines: string[] = [];\n let currentLine = '';\n\n for (const word of words) {\n const testLine = currentLine ? `${currentLine} ${word}` : word;\n\n // If a single word is longer than max, force it on its own line\n if (word.length > maxCharsPerLine) {\n if (currentLine) {\n lines.push(currentLine);\n currentLine = '';\n }\n lines.push(word);\n } else if (testLine.length > maxCharsPerLine && currentLine) {\n // If the test line is too long and we have words in current line, start new line\n lines.push(currentLine);\n currentLine = word;\n } else {\n currentLine = testLine;\n }\n }\n\n // Add the last line\n if (currentLine) {\n lines.push(currentLine);\n }\n\n return lines;\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 // Configuration for text rendering\n const CANVAS_WIDTH = 1200;\n const CANVAS_HEIGHT = 631;\n const FONT_SIZE = 72;\n const MARGIN = 50; // Margin from edges\n const CHAR_WIDTH_RATIO = 0.6; // Approximate ratio of character width to font size for Arial bold\n\n // Calculate maximum characters per line based on canvas width and font size\n const usableWidth = CANVAS_WIDTH - 2 * MARGIN;\n const maxCharsPerLine = Math.floor(usableWidth / (FONT_SIZE * CHAR_WIDTH_RATIO));\n const lines = wrapText(title, maxCharsPerLine);\n\n // Calculate vertical positioning for bottom-left alignment\n const lineHeight = FONT_SIZE * 1.2; // 20% spacing between lines\n const totalTextHeight = FONT_SIZE + (lines.length - 1) * lineHeight; // First line + spacing for additional lines\n const startY = CANVAS_HEIGHT - MARGIN - totalTextHeight + FONT_SIZE; // Bottom aligned with margin\n\n // Create SVG with title split into multiple lines using tspan elements (left aligned)\n const leftX = MARGIN;\n const tspanElements = lines\n .map((line, index) => {\n const yPosition = startY + index * lineHeight;\n // Escape special XML characters in the line text\n /* eslint-disable unicorn/prefer-string-replace-all */\n const escapedLine = line\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&apos;');\n /* eslint-enable unicorn/prefer-string-replace-all */\n return `<tspan x=\"${leftX}\" y=\"${yPosition}\">${escapedLine}</tspan>`;\n })\n .join('\\n ');\n\n const svgText = `\n <svg width=\"${CANVAS_WIDTH}\" height=\"${CANVAS_HEIGHT}\" xmlns=\"http://www.w3.org/2000/svg\">\n <defs>\n <linearGradient id=\"darkGradient\" x1=\"0%\" y1=\"0%\" x2=\"0%\" y2=\"100%\">\n <stop offset=\"0%\" style=\"stop-color:rgb(0,0,0);stop-opacity:0\" />\n <stop offset=\"100%\" style=\"stop-color:rgb(0,0,0);stop-opacity:0.65\" />\n </linearGradient>\n <style>\n .title { font-family: 'Arial, sans-serif'; font-size: ${FONT_SIZE}px; font-weight: bold; fill: white; text-anchor: start; }\n </style>\n </defs>\n <rect x=\"0\" y=\"0\" width=\"${CANVAS_WIDTH}\" height=\"${CANVAS_HEIGHT}\" fill=\"url(#darkGradient)\" />\n <text x=\"${leftX}\" class=\"title\">\n ${tspanElements}\n </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"]}
package/dist/lib/index.js CHANGED
@@ -115,6 +115,30 @@ async function createVideoThumbnails(inputPath, videoDimensions, outputPath, out
115
115
  });
116
116
  });
117
117
  }
118
+ function wrapText(text, maxCharsPerLine) {
119
+ const words = text.split(" ");
120
+ const lines = [];
121
+ let currentLine = "";
122
+ for (const word of words) {
123
+ const testLine = currentLine ? `${currentLine} ${word}` : word;
124
+ if (word.length > maxCharsPerLine) {
125
+ if (currentLine) {
126
+ lines.push(currentLine);
127
+ currentLine = "";
128
+ }
129
+ lines.push(word);
130
+ } else if (testLine.length > maxCharsPerLine && currentLine) {
131
+ lines.push(currentLine);
132
+ currentLine = word;
133
+ } else {
134
+ currentLine = testLine;
135
+ }
136
+ }
137
+ if (currentLine) {
138
+ lines.push(currentLine);
139
+ }
140
+ return lines;
141
+ }
118
142
  async function createGallerySocialMediaCardImage(headerPhotoPath, title, ouputPath, ui) {
119
143
  ui?.start(`Creating social media card image`);
120
144
  const headerBasename = path.basename(headerPhotoPath, path.extname(headerPhotoPath));
@@ -126,14 +150,38 @@ async function createGallerySocialMediaCardImage(headerPhotoPath, title, ouputPa
126
150
  const resizedImageBuffer = await image.resize(1200, 631, { fit: "cover" }).jpeg({ quality: 90 }).toBuffer();
127
151
  const outputPath = ouputPath;
128
152
  await sharp3(resizedImageBuffer).toFile(outputPath);
153
+ const CANVAS_WIDTH = 1200;
154
+ const CANVAS_HEIGHT = 631;
155
+ const FONT_SIZE = 72;
156
+ const MARGIN = 50;
157
+ const CHAR_WIDTH_RATIO = 0.6;
158
+ const usableWidth = CANVAS_WIDTH - 2 * MARGIN;
159
+ const maxCharsPerLine = Math.floor(usableWidth / (FONT_SIZE * CHAR_WIDTH_RATIO));
160
+ const lines = wrapText(title, maxCharsPerLine);
161
+ const lineHeight = FONT_SIZE * 1.2;
162
+ const totalTextHeight = FONT_SIZE + (lines.length - 1) * lineHeight;
163
+ const startY = CANVAS_HEIGHT - MARGIN - totalTextHeight + FONT_SIZE;
164
+ const leftX = MARGIN;
165
+ const tspanElements = lines.map((line, index) => {
166
+ const yPosition = startY + index * lineHeight;
167
+ const escapedLine = line.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
168
+ return `<tspan x="${leftX}" y="${yPosition}">${escapedLine}</tspan>`;
169
+ }).join("\n ");
129
170
  const svgText = `
130
- <svg width="1200" height="631" xmlns="http://www.w3.org/2000/svg">
171
+ <svg width="${CANVAS_WIDTH}" height="${CANVAS_HEIGHT}" xmlns="http://www.w3.org/2000/svg">
131
172
  <defs>
173
+ <linearGradient id="darkGradient" x1="0%" y1="0%" x2="0%" y2="100%">
174
+ <stop offset="0%" style="stop-color:rgb(0,0,0);stop-opacity:0" />
175
+ <stop offset="100%" style="stop-color:rgb(0,0,0);stop-opacity:0.65" />
176
+ </linearGradient>
132
177
  <style>
133
- .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; }
178
+ .title { font-family: 'Arial, sans-serif'; font-size: ${FONT_SIZE}px; font-weight: bold; fill: white; text-anchor: start; }
134
179
  </style>
135
180
  </defs>
136
- <text x="600" y="250" class="title">${title}</text>
181
+ <rect x="0" y="0" width="${CANVAS_WIDTH}" height="${CANVAS_HEIGHT}" fill="url(#darkGradient)" />
182
+ <text x="${leftX}" class="title">
183
+ ${tspanElements}
184
+ </text>
137
185
  </svg>
138
186
  `;
139
187
  const finalImageBuffer = await sharp3(resizedImageBuffer).composite([{ input: Buffer.from(svgText), top: 0, left: 0 }]).jpeg({ quality: 90 }).toBuffer();
@@ -1 +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/**\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"]}
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/**\n * Wraps text into multiple lines based on a maximum character width\n * @param text - The text to wrap\n * @param maxCharsPerLine - Maximum number of characters per line (approximate)\n * @returns Array of text lines\n */\nfunction wrapText(text: string, maxCharsPerLine: number): string[] {\n const words = text.split(' ');\n const lines: string[] = [];\n let currentLine = '';\n\n for (const word of words) {\n const testLine = currentLine ? `${currentLine} ${word}` : word;\n\n // If a single word is longer than max, force it on its own line\n if (word.length > maxCharsPerLine) {\n if (currentLine) {\n lines.push(currentLine);\n currentLine = '';\n }\n lines.push(word);\n } else if (testLine.length > maxCharsPerLine && currentLine) {\n // If the test line is too long and we have words in current line, start new line\n lines.push(currentLine);\n currentLine = word;\n } else {\n currentLine = testLine;\n }\n }\n\n // Add the last line\n if (currentLine) {\n lines.push(currentLine);\n }\n\n return lines;\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 // Configuration for text rendering\n const CANVAS_WIDTH = 1200;\n const CANVAS_HEIGHT = 631;\n const FONT_SIZE = 72;\n const MARGIN = 50; // Margin from edges\n const CHAR_WIDTH_RATIO = 0.6; // Approximate ratio of character width to font size for Arial bold\n\n // Calculate maximum characters per line based on canvas width and font size\n const usableWidth = CANVAS_WIDTH - 2 * MARGIN;\n const maxCharsPerLine = Math.floor(usableWidth / (FONT_SIZE * CHAR_WIDTH_RATIO));\n const lines = wrapText(title, maxCharsPerLine);\n\n // Calculate vertical positioning for bottom-left alignment\n const lineHeight = FONT_SIZE * 1.2; // 20% spacing between lines\n const totalTextHeight = FONT_SIZE + (lines.length - 1) * lineHeight; // First line + spacing for additional lines\n const startY = CANVAS_HEIGHT - MARGIN - totalTextHeight + FONT_SIZE; // Bottom aligned with margin\n\n // Create SVG with title split into multiple lines using tspan elements (left aligned)\n const leftX = MARGIN;\n const tspanElements = lines\n .map((line, index) => {\n const yPosition = startY + index * lineHeight;\n // Escape special XML characters in the line text\n /* eslint-disable unicorn/prefer-string-replace-all */\n const escapedLine = line\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&apos;');\n /* eslint-enable unicorn/prefer-string-replace-all */\n return `<tspan x=\"${leftX}\" y=\"${yPosition}\">${escapedLine}</tspan>`;\n })\n .join('\\n ');\n\n const svgText = `\n <svg width=\"${CANVAS_WIDTH}\" height=\"${CANVAS_HEIGHT}\" xmlns=\"http://www.w3.org/2000/svg\">\n <defs>\n <linearGradient id=\"darkGradient\" x1=\"0%\" y1=\"0%\" x2=\"0%\" y2=\"100%\">\n <stop offset=\"0%\" style=\"stop-color:rgb(0,0,0);stop-opacity:0\" />\n <stop offset=\"100%\" style=\"stop-color:rgb(0,0,0);stop-opacity:0.65\" />\n </linearGradient>\n <style>\n .title { font-family: 'Arial, sans-serif'; font-size: ${FONT_SIZE}px; font-weight: bold; fill: white; text-anchor: start; }\n </style>\n </defs>\n <rect x=\"0\" y=\"0\" width=\"${CANVAS_WIDTH}\" height=\"${CANVAS_HEIGHT}\" fill=\"url(#darkGradient)\" />\n <text x=\"${leftX}\" class=\"title\">\n ${tspanElements}\n </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"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "simple-photo-gallery",
3
- "version": "2.0.16",
3
+ "version": "2.0.18",
4
4
  "description": "Simple Photo Gallery CLI",
5
5
  "license": "MIT",
6
6
  "author": "Vladimir Haltakov, Tomasz Rusin",
@@ -47,8 +47,8 @@
47
47
  "prepublish": "yarn build"
48
48
  },
49
49
  "dependencies": {
50
- "@simple-photo-gallery/common": "1.0.5",
51
- "@simple-photo-gallery/theme-modern": "2.0.16",
50
+ "@simple-photo-gallery/common": "1.0.6",
51
+ "@simple-photo-gallery/theme-modern": "2.0.18",
52
52
  "axios": "^1.12.2",
53
53
  "blurhash": "^2.0.5",
54
54
  "commander": "^12.0.0",