printhive/lib/thumbnails.js
dlawler489 ea2afeac0e Make thumbnails on the server in the background
- The background worker now also gives models without a thumbnail one:
  an embedded 3MF/plate preview, else a picture uploaded with the model,
  else a 3MF preview or picture inside one of its archives, else a render
  of an STL/OBJ/3MF (loose or inside an archive).
- tools/render_thumbnail.py renders without a GPU: meshes are simplified
  per distinct part (the 8M-face lion project renders in ~8 s / ~200 MB),
  3MF projects show their first plate in filament colours, and only
  camera-facing faces are painted (inside-out meshes and mirrored parts are
  re-oriented first).
- models.thumbnail_status stops retries when there's nothing to use; adding
  files clears it. The browser now only renders FBX/STEP/3DM thumbnails.
- The library checks back while fingerprints or thumbnails are pending and
  refreshes when they land.
- Python sidecar needs pillow and fast-simplification; compose keeps the
  archive extraction cache in a volume. README covers the phase 2 features.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 07:54:26 +10:00

104 lines
4.5 KiB
JavaScript

// Background thumbnails for models that don't have one. Tries, in order: an
// embedded 3MF preview, a picture uploaded with the model, a 3MF preview or
// picture inside one of its archives, then a server-side render of an
// STL/OBJ/3MF (or one inside an archive). Models whose only 3D files are
// FBX/STEP/3DM are left to the browser, which can render those.
const fs = require('fs');
const path = require('path');
const db = require('./db');
const archives = require('./archives');
const fileProcessor = require('./fileProcessor');
const { runScript } = require('./python');
const THUMBNAIL_DIR = './thumbnails';
const PICTURES = ['png', 'jpg', 'jpeg', 'gif', 'webp'];
const RENDERABLE = ['3mf', 'stl', 'obj']; // 3MF first: slicer projects usually embed a preview
const MAX_ATTEMPTS = 3; // per kind of source, so a huge pack doesn't stall the queue
const BATCH = 5;
// Models still waiting: no thumbnail, not given up on, and archives already
// indexed (so what's inside them can be considered).
const PENDING = `
m.thumbnail_path IS NULL AND m.thumbnail_status IS NULL AND m.deleted_at IS NULL
AND NOT EXISTS (SELECT 1 FROM files f WHERE f.model_id = m.id AND f.format = ANY($1) AND f.archive_status IS NULL)`;
async function render(input, base) {
const out = path.join(THUMBNAIL_DIR, `${base}.png`);
fs.mkdirSync(THUMBNAIL_DIR, { recursive: true });
const { err } = await runScript('render_thumbnail.py', [input, path.resolve(out)],
{ timeout: 180000, isBackground: true });
if (err && err.code === 'ENOENT') throw err;
return !err && fs.existsSync(out) ? out : null;
}
// Returns a path under thumbnails/ or null.
async function thumbnailFor(modelId) {
const { rows: files } = await db.query(
'SELECT id, file_path, format FROM files WHERE model_id = $1 ORDER BY id', [modelId]);
const base = `model-${modelId}-${Date.now()}`;
for (const f of files.filter((x) => x.format === '3mf').slice(0, MAX_ATTEMPTS)) {
const out = fileProcessor.extract3mfThumbnail(path.resolve(f.file_path), base);
if (out) return out;
}
const picture = files.find((f) => PICTURES.includes(f.format));
if (picture) return fileProcessor.copyAsThumbnail(path.resolve(picture.file_path), base);
const archiveFiles = files.filter((f) => fileProcessor.ARCHIVE_FORMATS.includes(f.format));
const { rows: entries } = archiveFiles.length
? await db.query('SELECT id, file_id, path, format FROM archive_entries WHERE file_id = ANY($1::int[]) ORDER BY path',
[archiveFiles.map((f) => f.id)])
: { rows: [] };
const extract = (entry) => archives.extractEntry(archiveFiles.find((f) => f.id === entry.file_id), entry);
for (const e of entries.filter((x) => x.format === '3mf').slice(0, MAX_ATTEMPTS)) {
const out = fileProcessor.extract3mfThumbnail(await extract(e), base);
if (out) return out;
}
const entryPicture = entries.find((e) => PICTURES.includes(e.format));
if (entryPicture) return fileProcessor.copyAsThumbnail(await extract(entryPicture), base);
const sources = [
...RENDERABLE.flatMap((fmt) => files.filter((f) => f.format === fmt).map((f) => () => path.resolve(f.file_path))),
...RENDERABLE.flatMap((fmt) => entries.filter((e) => e.format === fmt).map((e) => () => extract(e))),
];
for (const source of sources.slice(0, MAX_ATTEMPTS)) {
const out = await render(await source(), base);
if (out) return out;
}
return null;
}
// One batch; returns whether there was anything to do.
async function generate() {
const { rows } = await db.query(
`SELECT m.id FROM models m WHERE ${PENDING} ORDER BY m.id LIMIT $2`, [fileProcessor.ARCHIVE_FORMATS, BATCH]);
for (const { id } of rows) {
let out = null;
let status = 'none';
try {
out = await thumbnailFor(id);
} catch (err) {
console.warn(`Thumbnail failed for model ${id}:`, err.message);
status = 'error';
}
if (out) {
// Don't overwrite a thumbnail set meanwhile (e.g. rendered by a browser).
const { rowCount } = await db.query(
'UPDATE models SET thumbnail_path = $2 WHERE id = $1 AND thumbnail_path IS NULL', [id, out]);
if (rowCount === 0) fs.rmSync(out, { force: true });
} else {
await db.query('UPDATE models SET thumbnail_status = $2 WHERE id = $1', [id, status]);
}
}
return rows.length > 0;
}
async function pendingCount() {
const { rows } = await db.query(`SELECT COUNT(*)::int AS n FROM models m WHERE ${PENDING}`,
[fileProcessor.ARCHIVE_FORMATS]);
return rows[0].n;
}
module.exports = { generate, pendingCount };