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:
+41
-17
@@ -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
@@ -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' });
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user