printhive/test/thumbnail.test.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

79 lines
3.8 KiB
JavaScript

// Server-side thumbnail rendering (tools/render_thumbnail.py).
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync, spawnSync } = require('child_process');
const { PYTHON } = require('../lib/python');
const RENDER = path.join(__dirname, '..', 'tools', 'render_thumbnail.py');
const fixture = (name) => path.join(__dirname, 'fixtures', name);
const skip = !fs.existsSync(PYTHON) && 'Python venv not installed';
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'printhive-thumb-'));
test.after(() => fs.rmSync(tmp, { recursive: true, force: true }));
const lastJson = (stdout) => JSON.parse(stdout.trim().split('\n').pop());
// Size, how much of the image is covered, where, and how green/blue the model is.
function pixelStats(png) {
const script = `
import json, sys
from PIL import Image
im = Image.open(sys.argv[1]).convert("RGBA")
px = [p for p in im.getdata() if p[3] > 0]
opaque = Image.new("L", im.size); opaque.putdata([255 if p[3] > 0 else 0 for p in im.getdata()])
share = lambda test: sum(1 for p in px if test(p)) / max(len(px), 1)
from collections import Counter
top3 = sum(n for _, n in Counter(p[:3] for p in px if p[3] == 255).most_common(3)) / max(len(px), 1)
print(json.dumps({"size": im.size, "coverage": len(px) / (im.size[0] * im.size[1]), "bbox": opaque.getbbox(), "top3": top3,
"green": share(lambda p: p[1] > p[0] + 40 and p[1] > p[2] + 40),
"blue": share(lambda p: p[2] > p[0] + 40 and p[2] > p[1] + 40)}))`;
return JSON.parse(execFileSync(PYTHON, ['-c', script, png], { encoding: 'utf8' }));
}
test('an STL renders to a 512px PNG, fully in frame', { skip }, () => {
const out = path.join(tmp, 'cube.png');
const result = lastJson(execFileSync(PYTHON, [RENDER, fixture('cube.stl'), out], { encoding: 'utf8' }));
assert.equal(result.faces, 12);
const stats = pixelStats(out);
assert.deepEqual(stats.size, [512, 512]);
assert.ok(stats.coverage > 0.1 && stats.coverage < 0.9, `coverage ${stats.coverage}`);
const [left, top, right, bottom] = stats.bbox;
assert.ok(left > 2 && top > 2 && right < 510 && bottom < 510, `bbox ${stats.bbox}`);
});
// A cube shows three faces, each one flat shade; hidden faces painted over
// visible ones (or inside-out winding) would add more shades.
for (const inverted of [false, true]) {
test(`a cube${inverted ? ' with inside-out faces' : ''} shows just its three visible faces`, { skip }, () => {
let input = fixture('cube.stl');
if (inverted) {
input = path.join(tmp, 'inverted.stl');
execFileSync(PYTHON, ['-c', `
import sys, trimesh
m = trimesh.load(sys.argv[1], force="mesh"); m.invert(); m.export(sys.argv[2])`, fixture('cube.stl'), input]);
}
const out = path.join(tmp, `cube-${inverted}.png`);
execFileSync(PYTHON, [RENDER, input, out], { encoding: 'utf8' });
const { top3 } = pixelStats(out);
assert.ok(top3 > 0.9, `three most common colours cover ${top3}`);
});
}
test('a multi-plate 3MF renders its first plate in filament colours', { skip }, () => {
const out = path.join(tmp, 'plates.png');
lastJson(execFileSync(PYTHON, [RENDER, fixture('plates.3mf'), out], { encoding: 'utf8' }));
const stats = pixelStats(out);
assert.ok(stats.green > 0.5, `green share ${stats.green}`); // plate 1's cube prints in #00FF00
assert.ok(stats.blue < 0.01, `blue share ${stats.blue}`); // plate 2's blue tower isn't drawn
});
test('a file with no geometry fails with a JSON error', { skip }, () => {
const broken = path.join(tmp, 'broken.stl');
fs.writeFileSync(broken, 'this is not a mesh');
const run = spawnSync(PYTHON, [RENDER, broken, path.join(tmp, 'x.png')], { encoding: 'utf8' });
assert.equal(run.status, 1);
assert.match(lastJson(run.stdout).error, /could not render thumbnail/);
});