fix: models with images should use them as thumbnails

Uploaded images now replace generated renders for cover and thumbnail.
This commit is contained in:
Tautvydas Derzinskas committed 2026-10-19 21:01:50 +02:00
1 parent 03fdd836a5
commit cf0653b005
5 files changed
+53 -14

No files matched your search

+7 -6
View File
@@ -7,9 +7,8 @@ import { HttpError } from "../utils/fileUtils";
import { parseBody } from "../utils/validate";
import { asyncHandler } from "../utils/asyncHandler";
import { thumbnailUpload } from "../uploadMiddleware";
import { addPreviewImage, previewImagePath } from "../services/previewImageService";
import { addPreviewImage, preferImageOverRenders, previewImagePath } from "../services/previewImageService";
import { printOutById } from "../services/printLoader";
import { plateThumbExists, saveThumbFromBytes } from "../services/printService";
const router = Router();
router.use(requireAuth);
@@ -22,14 +21,16 @@ router.post(
if (!files.length) throw new HttpError(400, "No files uploaded");
const print = await prisma.print.findFirst({ where: { id: req.params.id, userId: req.userId } });
if (!print) throw new HttpError(404, "Print not found");
// As with an import's gallery, the first image becomes the card thumbnail when the first plate has
// none, so the model isn't rendered for one.
// As with an import's gallery, the first image becomes the cover and card thumbnail in place of our render.
const firstPlate = await prisma.plate.findFirst({ where: { printId: print.id }, orderBy: { position: "asc" } });
let thumbSeeded = !firstPlate || plateThumbExists(firstPlate.id);
let preferred = false;
// Undecodable files are skipped rather than failing the batch.
for (const file of files) {
const added = await addPreviewImage(print.id, file.buffer);
if (added && !thumbSeeded) thumbSeeded = await saveThumbFromBytes(firstPlate!.id, file.buffer);
if (added && !preferred) {
preferred = true;
await preferImageOverRenders(print.id, firstPlate?.id, file.buffer);
}
}
res.json({ print: await printOutById(req.userId!, print.id) });
}),
+6 -4
View File
@@ -10,8 +10,7 @@ import { listZipEntries, readZipEntry } from "../utils/zipReader";
import { normalizeZipEntryPath, resolveZipCategoryId } from "./zipService";
import { createPrint, type NewPlateInput } from "./printCreation";
import { saveFileFromTemp } from "./printFileService";
import { addPreviewImage } from "./previewImageService";
import { plateThumbExists, saveThumbFromBytes } from "./printService";
import { addPreviewImage, preferImageOverRenders } from "./previewImageService";
import { createNotification } from "./notificationService";
export const CONSUME_MODES = ["separate", "folder"] as const;
@@ -273,13 +272,16 @@ export async function consumeFolder(dir = CONSUME_DIR): Promise<ConsumeSummary |
summary.files += plates.length;
summary.printIds.push(printId);
let thumbSeeded = !firstPlateId || plateThumbExists(firstPlateId);
let preferred = false;
for (const item of rest) {
try {
if (PREVIEW_IMAGE_EXTS.has(extOf(item.rel)) && item.size <= PREVIEW_IMAGE_MAX_BYTES) {
const buffer = await fs.readFile(item.path);
if (await addPreviewImage(printId, buffer)) {
if (!thumbSeeded) thumbSeeded = await saveThumbFromBytes(firstPlateId!, buffer);
if (!preferred) {
preferred = true;
await preferImageOverRenders(printId, firstPlateId, buffer);
}
await fs.rm(item.path, { force: true });
summary.files += 1;
continue;
+3 -4
View File
@@ -151,8 +151,8 @@ import {
type NewPlateInput,
type PrintMetaInput,
} from "./printCreation";
import { availablePlateFilename, plateThumbReplaceable, relocatePrint, saveThumbFromBytes } from "./printService";
import { addPreviewImage, removeGeneratedPreviewImages } from "./previewImageService";
import { availablePlateFilename, relocatePrint, saveThumbFromBytes } from "./printService";
import { addPreviewImage, preferImageOverRenders } from "./previewImageService";
import { localizeDescriptionImages } from "./descriptionImageService";
import { prisma } from "../db";
import { recordActivity } from "./activityService";
@@ -782,8 +782,7 @@ export async function attachImportedPreviewImages(
const added = await addPreviewImage(printId, buf);
if (added && !firstAdded) {
firstAdded = true;
await removeGeneratedPreviewImages(printId);
if (plateId && (await plateThumbReplaceable(plateId))) await saveThumbFromBytes(plateId, buf);
await preferImageOverRenders(printId, plateId, buf);
}
}
}
@@ -4,6 +4,7 @@ import path from "node:path";
import sharp from "sharp";
import { PREVIEWS } from "../config";
import { prisma } from "../db";
import { plateThumbReplaceable, saveThumbFromBytes } from "./printService";
import type { PreviewImage } from "@prisma/client";
export function previewImagePath(id: string): string {
@@ -72,6 +73,17 @@ export async function removeGeneratedPreviewImages(printId: string): Promise<voi
for (const img of generated) await fs.rm(previewImagePath(img.id), { force: true }).catch(() => undefined);
}
/** Call once a real image is added: it takes over from our own render, as the cover and, when the plate's thumb is
* only that render (it can land first while the image is still uploading), as the thumb too. */
export async function preferImageOverRenders(
printId: string,
plateId: string | null | undefined,
buffer: Buffer,
): Promise<void> {
await removeGeneratedPreviewImages(printId);
if (plateId && (await plateThumbReplaceable(plateId))) await saveThumbFromBytes(plateId, buffer);
}
export async function deleteAllPreviewImages(printId: string): Promise<void> {
const rows = await prisma.previewImage.findMany({ where: { printId } });
await prisma.previewImage.deleteMany({ where: { printId } });
+25
View File
@@ -60,6 +60,31 @@ describe("preview images", () => {
});
});
describe("a 3D render saved before uploaded images land", () => {
it("gives way to the first image, as thumbnail and cover", async () => {
const print = await uploadStl();
const plate = (await prisma.plate.findFirst({ where: { printId: print.id } }))!;
await request(app)
.post(`/api/plate/${plate.id}/thumbnail-generated`)
.set(auth())
.attach("file", await png(10), "render.png");
const res = await request(app)
.post(`/api/print/${print.id}/preview-images`)
.set(auth())
.attach("files", await png(200), "a.png")
.attach("files", await png(120), "b.png");
expect(res.status).toBe(200);
const previews = await prisma.previewImage.findMany({ where: { printId: print.id }, orderBy: { position: "asc" } });
expect(previews.map((img) => [img.position, img.generated])).toEqual([
[0, false],
[1, false],
]);
expect(await redOf(previewImagePath(previews[0].id))).toBeGreaterThan(180);
expect(await redOf(plateThumbPath(plate.id))).toBeGreaterThan(180);
});
});
describe("a 3D render made while an import runs", () => {
const originalFetch = global.fetch;
afterEach(() => {