// 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 };