diff --git a/toolkit/config_modules.py b/toolkit/config_modules.py index df6b86d..c39a4a9 100644 --- a/toolkit/config_modules.py +++ b/toolkit/config_modules.py @@ -1284,16 +1284,19 @@ class GenerateImageConfig: for file in files: tmp_thumb = os.path.join(tmp_folder, file + '.thumb') try: - if self._generate_thumbnail(os.path.join(tmp_folder, file), tmp_thumb): + thumb_ext = self._generate_thumbnail(os.path.join(tmp_folder, file), tmp_thumb) + if thumb_ext: os.makedirs(thumbs_folder, exist_ok=True) - os.replace(tmp_thumb, os.path.join(thumbs_folder, file + '.jpg')) + os.replace(tmp_thumb, os.path.join(thumbs_folder, file + thumb_ext)) except Exception as e: print(f"Failed to generate thumbnail for {file}: {e}") for file in files: os.replace(os.path.join(tmp_folder, file), os.path.join(real_folder, file)) def _generate_thumbnail(self, media_path, thumb_path): - # 300x300 center-cropped 90% jpg. Returns True if one was written. + # 300x300 center-cropped thumb. Returns the extension it wrote ('.png' + # when the source has alpha, which jpg cannot carry, else '.jpg'), or + # None when the format is not thumbnailable. from PIL import Image as PILImage ext = os.path.splitext(media_path)[1].lower() img = None @@ -1311,15 +1314,23 @@ class GenerateImageConfig: from toolkit.audio.album_artwork import create_artwork, load_waveform img = create_artwork(load_waveform(media_path), size=300) if img is None: - return False - img = img.convert('RGB') + return None + # without this the RGB under a transparent pixel shows through as a + # garbage color, which is what an RGBA sample's thumb used to look like + has_alpha = img.mode in ('RGBA', 'LA') or ( + img.mode == 'P' and 'transparency' in img.info + ) + img = img.convert('RGBA' if has_alpha else 'RGB') w, h = img.size side = min(w, h) left = (w - side) // 2 top = (h - side) // 2 img = img.crop((left, top, left + side, top + side)).resize((300, 300), PILImage.LANCZOS) + if has_alpha: + img.save(thumb_path, format='PNG', optimize=True) + return '.png' img.save(thumb_path, format='JPEG', quality=90) - return True + return '.jpg' def save_image(self, image, count: int = 0, max_count=0): # make parent dirs diff --git a/ui/cron/fileServer.ts b/ui/cron/fileServer.ts index f115747..a4c4106 100644 --- a/ui/cron/fileServer.ts +++ b/ui/cron/fileServer.ts @@ -86,8 +86,10 @@ async function getRoots(forceFresh = false): Promise { // --------------------------------------------------------------------------- // Thumbnail generation for ?thumb=1 requests whose thumb doesn't exist yet. // Output matches the Python generator (SampleConfig._generate_thumbnail in -// toolkit/config_modules.py): 300x300 center-cropped q90 jpg written -// atomically into the sibling .thumbs folder as ..jpg. +// toolkit/config_modules.py): a 300x300 center-cropped thumb written +// atomically into the sibling .thumbs folder as ..jpg, or +// ..png when the source has alpha (jpg cannot carry it, and the +// RGB under a transparent pixel shows through as a garbage color). // --------------------------------------------------------------------------- const THUMB_SIZE = 300; const IMAGE_THUMB_EXTS = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.bmp']); @@ -125,7 +127,7 @@ let ffmpegMissing = false; // re-written file retries) — the gallery re-requests thumbs constantly and // must not re-run ffmpeg/sharp against a broken file on every poll. const failedThumbs = new Set(); -const inFlightThumbs = new Map>(); +const inFlightThumbs = new Map>(); // A gallery burst can request hundreds of missing thumbs at once; cap how // many decodes/ffmpeg spawns run concurrently per worker. @@ -168,24 +170,25 @@ function runFfmpeg(args: string[]): Promise { const SQUARE_THUMB_VF = `crop='min(iw,ih)':'min(iw,ih)',scale=${THUMB_SIZE}:${THUMB_SIZE}`; -async function generateThumb(sourcePath: string, thumbPath: string): Promise { +async function generateThumb(sourcePath: string, thumbBase: string): Promise { const ext = path.extname(sourcePath).toLowerCase(); const isImage = IMAGE_THUMB_EXTS.has(ext); const isVideo = VIDEO_THUMB_EXTS.has(ext); const isAudio = AUDIO_THUMB_EXTS.has(ext); - if ((isImage && !sharp) || ((isVideo || isAudio) && ffmpegMissing) || (!isImage && !isVideo && !isAudio)) return false; + if ((isImage && !sharp) || ((isVideo || isAudio) && ffmpegMissing) || (!isImage && !isVideo && !isAudio)) return null; + // only images can carry alpha; video frames and audio waveforms never do + const hasAlpha = isImage && (await sharp(sourcePath).metadata().catch(() => null))?.hasAlpha === true; + const thumbPath = `${thumbBase}${hasAlpha ? '.png' : '.jpg'}`; await fs.promises.mkdir(path.dirname(thumbPath), { recursive: true }); // Write to a per-process tmp name, then atomically rename into place (same // as the Python generator) so a concurrent request never reads a partial - // thumb. The .jpg suffix is required for ffmpeg's output format detection. - const tmpPath = `${thumbPath}.${process.pid}.tmp.jpg`; + // thumb. The suffix is required for ffmpeg's output format detection. + const tmpPath = `${thumbPath}.${process.pid}.tmp${hasAlpha ? '.png' : '.jpg'}`; try { if (isImage) { // sharp opens animated formats on the first frame by default - await sharp(sourcePath) - .resize(THUMB_SIZE, THUMB_SIZE, { fit: 'cover' }) - .jpeg({ quality: 90 }) - .toFile(tmpPath); + const pipeline = sharp(sourcePath).resize(THUMB_SIZE, THUMB_SIZE, { fit: 'cover' }); + await (hasAlpha ? pipeline.png() : pipeline.jpeg({ quality: 90 })).toFile(tmpPath); } else if (isVideo) { await runFfmpeg(['-i', sourcePath, '-frames:v', '1', '-vf', SQUARE_THUMB_VF, '-q:v', '2', tmpPath]); } else { @@ -205,29 +208,31 @@ async function generateThumb(sourcePath: string, thumbPath: string): Promise { }); throw err; } } -function ensureThumb(sourcePath: string, thumbPath: string, sourceMtimeMs: number): Promise { - const failKey = `${thumbPath}:${sourceMtimeMs}`; - if (failedThumbs.has(failKey)) return Promise.resolve(false); - let pending = inFlightThumbs.get(thumbPath); +// Resolves to the thumb path it wrote (the extension depends on alpha), or +// null when none could be generated. +function ensureThumb(sourcePath: string, thumbBase: string, sourceMtimeMs: number): Promise { + const failKey = `${thumbBase}:${sourceMtimeMs}`; + if (failedThumbs.has(failKey)) return Promise.resolve(null); + let pending = inFlightThumbs.get(thumbBase); if (!pending) { - pending = withThumbGenSlot(() => generateThumb(sourcePath, thumbPath)) + pending = withThumbGenSlot(() => generateThumb(sourcePath, thumbBase)) .catch(err => { console.warn(`Failed to generate thumbnail for ${sourcePath}: ${err?.message || err}`); - return false; + return null; }) - .then(ok => { - if (!ok) failedThumbs.add(failKey); - return ok; + .then(written => { + if (!written) failedThumbs.add(failKey); + return written; }) - .finally(() => inFlightThumbs.delete(thumbPath)); - inFlightThumbs.set(thumbPath, pending); + .finally(() => inFlightThumbs.delete(thumbBase)); + inFlightThumbs.set(thumbBase, pending); } return pending; } @@ -305,21 +310,34 @@ async function serveFile(req: http.IncomingMessage, res: http.ServerResponse, pr return true; } - // ?thumb=1 serves the 300x300 jpg from the sibling .thumbs folder - // (..jpg), generating and saving it on the fly when missing. - // Falls through to the full file only if generation isn't possible - // (unsupported format, no ffmpeg, corrupt file). Album art is always the - // thumb. + // ?thumb=1 serves the 300x300 thumb from the sibling .thumbs folder + // (..png when the source has alpha, else ..jpg), + // generating and saving it on the fly when missing. Falls through to the + // full file only if generation isn't possible (unsupported format, no + // ffmpeg, corrupt file). Album art is always the thumb. if (isArt || (isImg && new URL(req.url || '', 'http://localhost').searchParams.has('thumb'))) { - const thumbPath = path.join(path.dirname(resolvedFilePath), '.thumbs', path.basename(resolvedFilePath) + '.jpg'); - let thumbStat = await fs.promises.stat(thumbPath).catch(() => null); - if (!(thumbStat && thumbStat.isFile())) { - const srcStat = await fs.promises.stat(resolvedFilePath).catch(() => null); - if (srcStat && srcStat.isFile() && (await ensureThumb(resolvedFilePath, thumbPath, srcStat.mtimeMs))) { - thumbStat = await fs.promises.stat(thumbPath).catch(() => null); + const thumbBase = path.join(path.dirname(resolvedFilePath), '.thumbs', path.basename(resolvedFilePath)); + let thumbPath: string | null = null; + let thumbStat: fs.Stats | null = null; + for (const candidate of [`${thumbBase}.png`, `${thumbBase}.jpg`]) { + const stat = await fs.promises.stat(candidate).catch(() => null); + if (stat && stat.isFile()) { + thumbPath = candidate; + thumbStat = stat; + break; } } - if (thumbStat && thumbStat.isFile()) { + if (!thumbStat) { + const srcStat = await fs.promises.stat(resolvedFilePath).catch(() => null); + if (srcStat && srcStat.isFile()) { + const written = await ensureThumb(resolvedFilePath, thumbBase, srcStat.mtimeMs); + if (written) { + thumbPath = written; + thumbStat = await fs.promises.stat(written).catch(() => null); + } + } + } + if (thumbPath && thumbStat && thumbStat.isFile()) { resolvedFilePath = thumbPath; } else if (isArt) { return false; diff --git a/ui/src/app/api/img/[...imagePath]/route.ts b/ui/src/app/api/img/[...imagePath]/route.ts index fa32832..ec4a9d2 100644 --- a/ui/src/app/api/img/[...imagePath]/route.ts +++ b/ui/src/app/api/img/[...imagePath]/route.ts @@ -57,14 +57,18 @@ export async function GET(request: NextRequest, { params }: { params: { imagePat return new NextResponse('Access denied', { status: 403 }); } - // ?thumb=1 serves the pre-generated 300x300 jpg from the sibling .thumbs - // folder (..jpg) when it exists; otherwise falls through to - // the full file exactly as before. + // ?thumb=1 serves the pre-generated 300x300 thumb from the sibling .thumbs + // folder when it exists; otherwise falls through to the full file exactly + // as before. A source with alpha gets a ..png thumb so the + // transparency survives, everything else stays ..jpg. if (request.nextUrl.searchParams.has('thumb')) { - const thumbPath = path.join(path.dirname(resolved), '.thumbs', path.basename(resolved) + '.jpg'); - const thumbStat = await fs.promises.stat(thumbPath).catch(() => null); - if (thumbStat && thumbStat.isFile()) { - resolved = thumbPath; + const thumbBase = path.join(path.dirname(resolved), '.thumbs', path.basename(resolved)); + for (const thumbPath of [`${thumbBase}.png`, `${thumbBase}.jpg`]) { + const thumbStat = await fs.promises.stat(thumbPath).catch(() => null); + if (thumbStat && thumbStat.isFile()) { + resolved = thumbPath; + break; + } } }