- 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>
104 lines
4.5 KiB
JavaScript
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 };
|