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.
This commit is contained in:
Danila Khodjaef
2026-07-06 14:27:29 +03:00
parent bdddadc4d6
commit 99b8559607
117 changed files with 646 additions and 171 deletions
+41 -17
View File
@@ -10,6 +10,7 @@ const {
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) {
@@ -183,10 +184,34 @@ function unlinkPaintingFiles(row, safeBase) {
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) {
@@ -239,7 +264,7 @@ async function clearPaintingImage(paintingId) {
async function clearArtistPortrait(artistId) {
const result = await pool.query(
`SELECT id, name, portrait_path FROM artists WHERE id = $1`,
`SELECT id, name, portrait_path, portrait_thumb_path FROM artists WHERE id = $1`,
[artistId]
);
if (result.rows.length === 0) {
@@ -250,9 +275,12 @@ async function clearArtistPortrait(artistId) {
const safeBase = safeArtistPortraitBase(row.name);
unlinkPortraitFiles(row, safeBase);
await pool.query(`UPDATE artists SET portrait_path = NULL WHERE id = $1`, [artistId]);
await pool.query(
`UPDATE artists SET portrait_path = NULL, portrait_thumb_path = NULL WHERE id = $1`,
[artistId]
);
return { portraitPath: null };
return { portraitPath: null, portraitThumbPath: null };
}
async function replacePaintingImageFromBuffer(paintingId, buffer, mimeType) {
@@ -323,7 +351,7 @@ async function replaceArtistPortraitFromBuffer(artistId, buffer, mimeType) {
}
const result = await pool.query(
`SELECT id, name, portrait_path FROM artists WHERE id = $1`,
`SELECT id, name, portrait_path, portrait_thumb_path FROM artists WHERE id = $1`,
[artistId]
);
if (result.rows.length === 0) {
@@ -349,14 +377,13 @@ async function replaceArtistPortraitFromBuffer(artistId, buffer, mimeType) {
const fullDest = path.join(portraitsDir, safeBase + fullExt);
fs.writeFileSync(fullDest, buffer);
const portraitPath = path.join('portraits', safeBase + fullExt).replace(/\\/g, '/');
await pool.query(`UPDATE artists SET portrait_path = $1 WHERE id = $2`, [portraitPath, artistId]);
return { portraitPath };
const portraitThumbPath = (await writePortraitThumb(fullDest, safeBase)) || portraitPath;
return updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath);
}
const portraitPath = path.join('portraits', safeBase + '.jpg').replace(/\\/g, '/');
await pool.query(`UPDATE artists SET portrait_path = $1 WHERE id = $2`, [portraitPath, artistId]);
return { portraitPath };
const portraitThumbPath = (await writePortraitThumb(jpgDest, safeBase)) || portraitPath;
return updateArtistPortraitPaths(artistId, portraitPath, portraitThumbPath);
}
async function replacePaintingImageFromUrl(paintingId, imageUrl, context = {}) {
@@ -409,7 +436,7 @@ async function replacePaintingImageFromUrl(paintingId, imageUrl, context = {}) {
async function replaceArtistPortraitFromUrl(artistId, imageUrl, context = {}) {
const result = await pool.query(
`SELECT id, name, portrait_path FROM artists WHERE id = $1`,
`SELECT id, name, portrait_path, portrait_thumb_path FROM artists WHERE id = $1`,
[artistId]
);
if (result.rows.length === 0) {
@@ -424,10 +451,7 @@ async function replaceArtistPortraitFromUrl(artistId, imageUrl, context = {}) {
const fullExt = pickExt(imageUrl);
const fullDest = path.join(portraitsDir, safeBase + fullExt);
unlinkIfExists(row.portrait_path ? path.join(IMAGE_DIR, row.portrait_path) : null);
for (const ext of ['.jpg', '.jpeg', '.png', '.webp', '.JPG']) {
unlinkIfExists(path.join(portraitsDir, safeBase + ext));
}
unlinkPortraitFiles(row, safeBase);
await downloadImageForFix(imageUrl, fullDest, context);
@@ -449,9 +473,9 @@ async function replaceArtistPortraitFromUrl(artistId, imageUrl, context = {}) {
// keep downloaded file as-is
}
await pool.query(`UPDATE artists SET portrait_path = $1 WHERE id = $2`, [portraitPath, artistId]);
return { portraitPath };
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 = {
+121 -39
View File
@@ -1,5 +1,7 @@
const express = require('express');
const cors = require('cors');
const compression = require('compression');
const crypto = require('crypto');
const path = require('path');
const fs = require('fs');
require('dotenv').config();
@@ -22,10 +24,98 @@ if (process.env.TRUST_PROXY === '1' || process.env.TRUST_PROXY === 'true') {
}
app.use(cors({ origin: true, credentials: true }));
app.use(compression());
app.use(express.json({ limit: '20mb' }));
app.use(createSessionMiddleware());
app.use('/api/auth', authRoutes);
app.use('/images', express.static(IMAGE_DIR));
app.use(
'/images',
express.static(IMAGE_DIR, {
maxAge: '365d',
immutable: true,
setHeaders(res) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
},
})
);
const TIMELINE_ARTIST_SELECT = `
SELECT a.id, a.name, a.birth_year, a.death_year, a.movement_id, a.portrait_path,
a.portrait_thumb_path, a.wikipedia_title, a.century,
m.name as movement_name, m.color as movement_color
FROM artists a
LEFT JOIN art_movements m ON a.movement_id = m.id
WHERE 1=1`;
async function fetchCatalogBounds() {
const result = await pool.query(`
SELECT
(SELECT MIN(start_year) FROM art_movements) as min_year,
GREATEST(
(SELECT MAX(end_year) FROM historical_eras),
(SELECT MAX(end_year) FROM art_movements),
(SELECT MAX(death_year) FROM artists WHERE death_year IS NOT NULL)
) as max_year
`);
return result.rows[0];
}
async function fetchTimelineErasAndMovements(startYear, endYear) {
const [eras, movements] = await Promise.all([
pool.query(
`SELECT * FROM historical_eras
WHERE end_year >= $1 AND start_year <= $2
ORDER BY sort_order, start_year`,
[startYear, endYear]
),
pool.query(
`SELECT DISTINCT m.*, e.name as era_name
FROM art_movements m
LEFT JOIN historical_eras e ON m.era_id = e.id
INNER JOIN artists a ON a.movement_id = m.id
WHERE m.end_year >= $1 AND m.start_year <= $2
AND (a.death_year IS NULL OR a.death_year >= $1)
AND (a.birth_year IS NULL OR a.birth_year <= $2)
ORDER BY m.start_year`,
[startYear, endYear]
),
]);
return { eras: eras.rows, movements: movements.rows };
}
async function fetchTimelineArtists(startYear, endYear) {
let query = TIMELINE_ARTIST_SELECT;
const params = [];
if (startYear != null) {
params.push(startYear);
query += ` AND (a.death_year IS NULL OR a.death_year >= $${params.length})`;
}
if (endYear != null) {
params.push(endYear);
query += ` AND (a.birth_year IS NULL OR a.birth_year <= $${params.length})`;
}
query += ' ORDER BY a.birth_year';
const result = await pool.query(query, params);
return result.rows;
}
async function catalogBootstrapEtag() {
const result = await pool.query(`
SELECT
(SELECT COUNT(*)::int FROM artists) AS artist_count,
(SELECT COUNT(*)::int FROM art_movements) AS movement_count,
(SELECT COUNT(*)::int FROM historical_eras) AS era_count,
(SELECT COUNT(*)::int FROM paintings) AS painting_count
`);
return result.rows[0];
}
function sendCatalogCacheHeaders(res, etagSource) {
const etag = `"${crypto.createHash('md5').update(JSON.stringify(etagSource)).digest('hex')}"`;
res.setHeader('Cache-Control', 'public, max-age=300');
res.setHeader('ETag', etag);
return etag;
}
const INFLUENCE_LINKS_EXISTS = `
EXISTS (
@@ -96,33 +186,39 @@ app.get('/api/timeline', async (req, res) => {
const startYear = parseInt(start) || -3000;
const endYear = parseInt(end) || 2100;
const [eras, movements] = await Promise.all([
pool.query(
`SELECT * FROM historical_eras
WHERE end_year >= $1 AND start_year <= $2
ORDER BY sort_order, start_year`,
[startYear, endYear]
),
pool.query(
`SELECT DISTINCT m.*, e.name as era_name
FROM art_movements m
LEFT JOIN historical_eras e ON m.era_id = e.id
INNER JOIN artists a ON a.movement_id = m.id
WHERE m.end_year >= $1 AND m.start_year <= $2
AND (a.death_year IS NULL OR a.death_year >= $1)
AND (a.birth_year IS NULL OR a.birth_year <= $2)
ORDER BY m.start_year`,
[startYear, endYear]
),
]);
res.json({ eras: eras.rows, movements: movements.rows });
const { eras, movements } = await fetchTimelineErasAndMovements(startYear, endYear);
res.json({ eras, movements });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to fetch timeline' });
}
});
// Single request for timeline first paint: bounds + eras + movements + slim artists
app.get('/api/catalog/bootstrap', async (req, res) => {
try {
const bounds = await fetchCatalogBounds();
const startYear = parseInt(req.query.start, 10) || bounds.min_year || -3000;
const endYear = parseInt(req.query.end, 10) || bounds.max_year || 2100;
const etagSource = await catalogBootstrapEtag();
const etag = sendCatalogCacheHeaders(res, etagSource);
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
const [{ eras, movements }, artists] = await Promise.all([
fetchTimelineErasAndMovements(startYear, endYear),
fetchTimelineArtists(startYear, endYear),
]);
res.json({ bounds, eras, movements, artists });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to fetch catalog bootstrap' });
}
});
// Movement gallery — all paintings by artists in the movement, chronological
app.get('/api/movements/:id/gallery', async (req, res) => {
try {
@@ -184,13 +280,7 @@ app.get('/api/artists', async (req, res) => {
const { start, end, movement_id, timeline } = req.query;
const timelineOnly = timeline === '1' || timeline === 'true';
let query = timelineOnly
? `
SELECT a.id, a.name, a.birth_year, a.death_year, a.movement_id, a.portrait_path,
a.bio_short, a.wikipedia_title, a.century,
m.name as movement_name, m.color as movement_color
FROM artists a
LEFT JOIN art_movements m ON a.movement_id = m.id
WHERE 1=1`
? TIMELINE_ARTIST_SELECT
: `
SELECT a.*, m.name as movement_name, m.color as movement_color
FROM artists a
@@ -941,16 +1031,8 @@ app.get('/api/paintings/:id/image', async (req, res) => {
// Year range bounds
app.get('/api/bounds', async (req, res) => {
try {
const result = await pool.query(`
SELECT
(SELECT MIN(start_year) FROM art_movements) as min_year,
GREATEST(
(SELECT MAX(end_year) FROM historical_eras),
(SELECT MAX(end_year) FROM art_movements),
(SELECT MAX(death_year) FROM artists WHERE death_year IS NOT NULL)
) as max_year
`);
res.json(result.rows[0]);
const bounds = await fetchCatalogBounds();
res.json(bounds);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to fetch bounds' });
+2
View File
@@ -10,6 +10,8 @@ const INCREMENTAL_MIGRATIONS = [
'migrate-painting-annotations.sql',
'migrate-artist-palette.sql',
'migrate-auth.sql',
'migrate-portrait-thumbs.sql',
'migrate-perf-indexes.sql',
];
async function bootstrapCurator() {