Files
Art-gallery/server/image-service.js
T
Danila Khodjaef 99b8559607 Speed up timeline load with portrait thumbs, bootstrap API, and caching.
Add catalog bootstrap endpoint, portrait thumbnail pipeline, lazy queued timeline images, gzip compression, and 3D texture throttling with code-split VirtualGallery.
2026-07-06 14:27:29 +03:00

493 lines
16 KiB
JavaScript

const fs = require('fs');
const path = require('path');
const pool = require('./db');
const {
savePaintingImages,
downloadImageForFix,
generateThumbnailFromFull,
pickExt,
} = require('../scripts/image-fetcher');
const IMAGE_DIR = path.resolve(process.env.IMAGE_DIR || './data/images');
const FETCH_TIMEOUT_MS = 15000;
const PORTRAIT_THUMB_WIDTH = 256;
const inflight = new Map();
function safePaintingBase(artistName, title) {
return `${artistName}_${title}`.replace(/[^a-zA-Z0-9_-]/g, '_');
}
function safeArtistPortraitBase(artistName) {
return artistName.replace(/[^a-zA-Z0-9_-]/g, '_');
}
function unlinkIfExists(absPath) {
if (absPath && fs.existsSync(absPath)) {
try {
fs.unlinkSync(absPath);
} catch {
// ignore
}
}
}
function withTimeout(promise, ms) {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error('Image fetch timeout')), ms)),
]);
}
function localFileExists(relPath) {
if (!relPath) return false;
return fs.existsSync(path.join(IMAGE_DIR, relPath));
}
function syncPaintingFromDisk(row) {
const safeBase = `${row.artist_name}_${row.title}`.replace(/[^a-zA-Z0-9_-]/g, '_');
let imagePath = row.image_path;
let thumbPath = row.thumbnail_path;
for (const ext of ['.jpg', '.jpeg', '.png', '.webp', '.JPG']) {
const full = path.join(IMAGE_DIR, 'paintings', safeBase + ext);
if (!imagePath && fs.existsSync(full)) {
imagePath = `paintings/${safeBase}${ext}`;
}
const thumb = path.join(IMAGE_DIR, 'paintings', 'thumbs', safeBase + '_thumb' + ext);
if (!thumbPath && fs.existsSync(thumb)) {
thumbPath = `paintings/thumbs/${safeBase}_thumb${ext}`;
}
}
if (!thumbPath && imagePath) thumbPath = imagePath;
return { imagePath, thumbPath };
}
/** Fast preload: link local files only, no external API calls */
async function preloadArtistImagesLocal(artistId) {
const rows = await pool.query(
`SELECT p.id, p.title, p.image_path, p.thumbnail_path, a.name AS artist_name
FROM paintings p
JOIN artists a ON a.id = p.artist_id
WHERE p.artist_id = $1`,
[artistId]
);
let linked = 0;
for (const row of rows.rows) {
const hasLocal =
localFileExists(row.thumbnail_path) || localFileExists(row.image_path);
if (hasLocal) {
linked++;
continue;
}
const synced = syncPaintingFromDisk(row);
if (synced.imagePath || synced.thumbPath) {
await pool.query(
`UPDATE paintings SET image_path = COALESCE($1, image_path), thumbnail_path = COALESCE($2, thumbnail_path) WHERE id = $3`,
[synced.imagePath, synced.thumbPath, row.id]
);
linked++;
}
}
return { fetched: linked, total: rows.rows.length };
}
async function ensurePaintingImages(paintingId, size = 'thumb') {
const key = `${paintingId}:${size}`;
if (inflight.has(key)) return inflight.get(key);
const promise = (async () => {
const result = await pool.query(
`SELECT p.id, p.title, p.wikipedia_title, p.image_path, p.thumbnail_path, a.name AS artist_name
FROM paintings p
JOIN artists a ON a.id = p.artist_id
WHERE p.id = $1`,
[paintingId]
);
if (result.rows.length === 0) return null;
const row = result.rows[0];
const wantThumb = size !== 'full';
if (wantThumb && localFileExists(row.thumbnail_path)) return row.thumbnail_path;
if (!wantThumb && localFileExists(row.image_path)) return row.image_path;
if (wantThumb && localFileExists(row.image_path)) return row.image_path;
const synced = syncPaintingFromDisk(row);
if (synced.imagePath || synced.thumbPath) {
await pool.query(
`UPDATE paintings
SET image_path = COALESCE($1, image_path),
thumbnail_path = COALESCE($2, thumbnail_path)
WHERE id = $3`,
[synced.imagePath, synced.thumbPath, paintingId]
);
if (wantThumb && localFileExists(synced.thumbPath)) return synced.thumbPath;
if (wantThumb && localFileExists(synced.imagePath)) return synced.imagePath;
if (!wantThumb && localFileExists(synced.imagePath)) return synced.imagePath;
}
if (!row.wikipedia_title) return null;
const saved = await withTimeout(
savePaintingImages(
row.wikipedia_title,
`${row.artist_name}_${row.title}`,
IMAGE_DIR,
{ artistName: row.artist_name, paintingTitle: row.title, type: 'painting' }
),
FETCH_TIMEOUT_MS
).catch(() => ({ imagePath: null, thumbnailPath: null }));
if (saved.imagePath || saved.thumbnailPath) {
await pool.query(
`UPDATE paintings
SET image_path = COALESCE($1, image_path),
thumbnail_path = COALESCE($2, thumbnail_path)
WHERE id = $3`,
[saved.imagePath, saved.thumbnailPath, paintingId]
);
}
if (wantThumb) return saved.thumbnailPath || saved.imagePath;
return saved.imagePath || saved.thumbnailPath;
})().finally(() => inflight.delete(key));
inflight.set(key, promise);
return promise;
}
function pickExtFromMime(mimeType) {
const map = {
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/png': '.png',
'image/webp': '.webp',
'image/gif': '.gif',
};
return map[String(mimeType || '').toLowerCase()] || '.jpg';
}
function unlinkPaintingFiles(row, safeBase) {
unlinkIfExists(row.image_path ? path.join(IMAGE_DIR, row.image_path) : null);
unlinkIfExists(row.thumbnail_path ? path.join(IMAGE_DIR, row.thumbnail_path) : null);
const paintingsDir = path.join(IMAGE_DIR, 'paintings');
const thumbsDir = path.join(IMAGE_DIR, 'paintings', 'thumbs');
for (const ext of ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.JPG']) {
unlinkIfExists(path.join(paintingsDir, safeBase + ext));
unlinkIfExists(path.join(thumbsDir, safeBase + '_thumb' + ext));
unlinkIfExists(path.join(thumbsDir, safeBase + '_thumb.jpg'));
}
}
function unlinkPortraitFiles(row, safeBase) {
unlinkIfExists(row.portrait_path ? path.join(IMAGE_DIR, row.portrait_path) : null);
unlinkIfExists(row.portrait_thumb_path ? path.join(IMAGE_DIR, row.portrait_thumb_path) : null);
const portraitsDir = path.join(IMAGE_DIR, 'portraits');
const thumbsDir = path.join(portraitsDir, 'thumbs');
for (const ext of ['.jpg', '.jpeg', '.png', '.webp', '.gif', '.JPG']) {
unlinkIfExists(path.join(portraitsDir, safeBase + ext));
}
unlinkIfExists(path.join(thumbsDir, safeBase + '_thumb.jpg'));
unlinkIfExists(path.join(thumbsDir, safeBase + '_thumb' + '.jpeg'));
}
async function writePortraitThumb(fullPath, safeBase) {
const thumbsDir = path.join(IMAGE_DIR, 'portraits', 'thumbs');
if (!fs.existsSync(thumbsDir)) fs.mkdirSync(thumbsDir, { recursive: true });
const thumbDest = path.join(thumbsDir, `${safeBase}_thumb.jpg`);
try {
await generateThumbnailFromFull(fullPath, thumbDest, PORTRAIT_THUMB_WIDTH);
return path.join('portraits', 'thumbs', `${safeBase}_thumb.jpg`).replace(/\\/g, '/');
} catch {
return null;
}
}
async function updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath) {
await pool.query(
`UPDATE artists SET portrait_path = $1, portrait_thumb_path = $2 WHERE id = $3`,
[portraitPath, portraitThumbPath, artistId]
);
return { portraitPath, portraitThumbPath };
}
async function deletePainting(paintingId) {
const result = await pool.query(
`SELECT p.id, p.title, p.image_path, p.thumbnail_path, p.artist_id, a.name AS artist_name
FROM paintings p
JOIN artists a ON a.id = p.artist_id
WHERE p.id = $1`,
[paintingId]
);
if (result.rows.length === 0) {
throw new Error('Painting not found');
}
const row = result.rows[0];
const safeBase = safePaintingBase(row.artist_name, row.title);
unlinkPaintingFiles(row, safeBase);
inflight.delete(`${paintingId}:thumb`);
inflight.delete(`${paintingId}:full`);
await pool.query(`DELETE FROM paintings WHERE id = $1`, [paintingId]);
return { id: row.id, artistId: row.artist_id, title: row.title };
}
async function clearPaintingImage(paintingId) {
const result = await pool.query(
`SELECT p.id, p.title, p.image_path, p.thumbnail_path, a.name AS artist_name
FROM paintings p
JOIN artists a ON a.id = p.artist_id
WHERE p.id = $1`,
[paintingId]
);
if (result.rows.length === 0) {
throw new Error('Painting not found');
}
const row = result.rows[0];
const safeBase = safePaintingBase(row.artist_name, row.title);
unlinkPaintingFiles(row, safeBase);
await pool.query(
`UPDATE paintings SET image_path = NULL, thumbnail_path = NULL WHERE id = $1`,
[paintingId]
);
return { imagePath: null, thumbnailPath: null };
}
async function clearArtistPortrait(artistId) {
const result = await pool.query(
`SELECT id, name, portrait_path, portrait_thumb_path FROM artists WHERE id = $1`,
[artistId]
);
if (result.rows.length === 0) {
throw new Error('Artist not found');
}
const row = result.rows[0];
const safeBase = safeArtistPortraitBase(row.name);
unlinkPortraitFiles(row, safeBase);
await pool.query(
`UPDATE artists SET portrait_path = NULL, portrait_thumb_path = NULL WHERE id = $1`,
[artistId]
);
return { portraitPath: null, portraitThumbPath: null };
}
async function replacePaintingImageFromBuffer(paintingId, buffer, mimeType) {
if (!buffer?.length) {
throw new Error('Empty image data');
}
const sharp = require('sharp');
try {
await sharp(buffer).metadata();
} catch {
throw new Error('Invalid image file');
}
const result = await pool.query(
`SELECT p.id, p.title, p.image_path, p.thumbnail_path, a.name AS artist_name
FROM paintings p
JOIN artists a ON a.id = p.artist_id
WHERE p.id = $1`,
[paintingId]
);
if (result.rows.length === 0) {
throw new Error('Painting not found');
}
const row = result.rows[0];
const safeBase = safePaintingBase(row.artist_name, row.title);
const paintingsDir = path.join(IMAGE_DIR, 'paintings');
const thumbsDir = path.join(IMAGE_DIR, 'paintings', 'thumbs');
if (!fs.existsSync(paintingsDir)) fs.mkdirSync(paintingsDir, { recursive: true });
if (!fs.existsSync(thumbsDir)) fs.mkdirSync(thumbsDir, { recursive: true });
const fullExt = pickExtFromMime(mimeType);
const fullDest = path.join(paintingsDir, safeBase + fullExt);
const thumbDest = path.join(thumbsDir, safeBase + '_thumb.jpg');
unlinkPaintingFiles(row, safeBase);
fs.writeFileSync(fullDest, buffer);
let thumbnailPath = null;
try {
await generateThumbnailFromFull(fullDest, thumbDest);
thumbnailPath = path.join('paintings', 'thumbs', safeBase + '_thumb.jpg').replace(/\\/g, '/');
} catch {
thumbnailPath = path.join('paintings', safeBase + fullExt).replace(/\\/g, '/');
}
const imagePath = path.join('paintings', safeBase + fullExt).replace(/\\/g, '/');
await pool.query(
`UPDATE paintings SET image_path = $1, thumbnail_path = $2 WHERE id = $3`,
[imagePath, thumbnailPath, paintingId]
);
return { imagePath, thumbnailPath };
}
async function replaceArtistPortraitFromBuffer(artistId, buffer, mimeType) {
if (!buffer?.length) {
throw new Error('Empty image data');
}
const sharp = require('sharp');
try {
await sharp(buffer).metadata();
} catch {
throw new Error('Invalid image file');
}
const result = await pool.query(
`SELECT id, name, portrait_path, portrait_thumb_path FROM artists WHERE id = $1`,
[artistId]
);
if (result.rows.length === 0) {
throw new Error('Artist not found');
}
const row = result.rows[0];
const safeBase = safeArtistPortraitBase(row.name);
const portraitsDir = path.join(IMAGE_DIR, 'portraits');
if (!fs.existsSync(portraitsDir)) fs.mkdirSync(portraitsDir, { recursive: true });
unlinkPortraitFiles(row, safeBase);
const jpgDest = path.join(portraitsDir, safeBase + '.jpg');
try {
await sharp(buffer)
.rotate()
.resize({ width: 900, height: 1100, fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 88 })
.toFile(jpgDest);
} catch {
const fullExt = pickExtFromMime(mimeType);
const fullDest = path.join(portraitsDir, safeBase + fullExt);
fs.writeFileSync(fullDest, buffer);
const portraitPath = path.join('portraits', safeBase + fullExt).replace(/\\/g, '/');
const portraitThumbPath = (await writePortraitThumb(fullDest, safeBase)) || portraitPath;
return updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath);
}
const portraitPath = path.join('portraits', safeBase + '.jpg').replace(/\\/g, '/');
const portraitThumbPath = (await writePortraitThumb(jpgDest, safeBase)) || portraitPath;
return updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath);
}
async function replacePaintingImageFromUrl(paintingId, imageUrl, context = {}) {
const result = await pool.query(
`SELECT p.id, p.title, p.image_path, p.thumbnail_path, a.name AS artist_name
FROM paintings p
JOIN artists a ON a.id = p.artist_id
WHERE p.id = $1`,
[paintingId]
);
if (result.rows.length === 0) {
throw new Error('Painting not found');
}
const row = result.rows[0];
const safeBase = safePaintingBase(row.artist_name, row.title);
const paintingsDir = path.join(IMAGE_DIR, 'paintings');
const thumbsDir = path.join(IMAGE_DIR, 'paintings', 'thumbs');
if (!fs.existsSync(paintingsDir)) fs.mkdirSync(paintingsDir, { recursive: true });
if (!fs.existsSync(thumbsDir)) fs.mkdirSync(thumbsDir, { recursive: true });
const fullExt = pickExt(imageUrl);
const fullDest = path.join(paintingsDir, safeBase + fullExt);
const thumbDest = path.join(thumbsDir, safeBase + '_thumb.jpg');
unlinkIfExists(row.image_path ? path.join(IMAGE_DIR, row.image_path) : null);
unlinkIfExists(row.thumbnail_path ? path.join(IMAGE_DIR, row.thumbnail_path) : null);
unlinkIfExists(fullDest);
unlinkIfExists(thumbDest);
await downloadImageForFix(imageUrl, fullDest, context);
let thumbnailPath = null;
try {
await generateThumbnailFromFull(fullDest, thumbDest);
thumbnailPath = path.join('paintings', 'thumbs', safeBase + '_thumb.jpg').replace(/\\/g, '/');
} catch {
thumbnailPath = path.join('paintings', safeBase + fullExt).replace(/\\/g, '/');
}
const imagePath = path.join('paintings', safeBase + fullExt).replace(/\\/g, '/');
await pool.query(
`UPDATE paintings SET image_path = $1, thumbnail_path = $2 WHERE id = $3`,
[imagePath, thumbnailPath, paintingId]
);
return { imagePath, thumbnailPath };
}
async function replaceArtistPortraitFromUrl(artistId, imageUrl, context = {}) {
const result = await pool.query(
`SELECT id, name, portrait_path, portrait_thumb_path FROM artists WHERE id = $1`,
[artistId]
);
if (result.rows.length === 0) {
throw new Error('Artist not found');
}
const row = result.rows[0];
const safeBase = safeArtistPortraitBase(row.name);
const portraitsDir = path.join(IMAGE_DIR, 'portraits');
if (!fs.existsSync(portraitsDir)) fs.mkdirSync(portraitsDir, { recursive: true });
const fullExt = pickExt(imageUrl);
const fullDest = path.join(portraitsDir, safeBase + fullExt);
unlinkPortraitFiles(row, safeBase);
await downloadImageForFix(imageUrl, fullDest, context);
let portraitPath = path.join('portraits', safeBase + fullExt).replace(/\\/g, '/');
const jpgDest = path.join(portraitsDir, safeBase + '.jpg');
try {
const sharp = require('sharp');
await sharp(fullDest)
.rotate()
.resize({ width: 900, height: 1100, fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 88 })
.toFile(jpgDest);
if (fullDest !== jpgDest && fs.existsSync(fullDest)) {
fs.unlinkSync(fullDest);
}
portraitPath = path.join('portraits', safeBase + '.jpg').replace(/\\/g, '/');
} catch {
// keep downloaded file as-is
}
const sourceForThumb = fs.existsSync(jpgDest) ? jpgDest : path.join(IMAGE_DIR, portraitPath);
const portraitThumbPath = (await writePortraitThumb(sourceForThumb, safeBase)) || portraitPath;
return updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath);
}
module.exports = {
ensurePaintingImages,
preloadArtistImagesLocal,
replacePaintingImageFromUrl,
replaceArtistPortraitFromUrl,
clearPaintingImage,
deletePainting,
clearArtistPortrait,
replacePaintingImageFromBuffer,
replaceArtistPortraitFromBuffer,
IMAGE_DIR,
};