Save thumbnail with alpha if there is one.

This commit is contained in:
Jaret Burkett committed 2026-09-20 15:16:15 -06:00
1 parent 086b663bba
commit a8dfcf7d7e
3 files changed
+81 -48

No files matched your search

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