Save thumbnail with alpha if there is one.
This commit is contained in:
1 parent
086b663bba
commit
a8dfcf7d7e
3 files changed
+81
-48
No files matched your search
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user