From cf0653b005e9339a8cd0f200a7ef4a7e6f028f75 Mon Sep 17 00:00:00 2001 From: Tautvydas Derzinskas Date: Mon, 19 Oct 2026 21:01:50 +0200 Subject: [PATCH] fix: models with images should use them as thumbnails Uploaded images now replace generated renders for cover and thumbnail. --- backend/src/routes/previewImages.ts | 13 ++++++----- backend/src/services/consumeService.ts | 10 +++++---- backend/src/services/importService.ts | 7 +++--- backend/src/services/previewImageService.ts | 12 ++++++++++ backend/tests/previewImageThumb.test.ts | 25 +++++++++++++++++++++ 5 files changed, 53 insertions(+), 14 deletions(-) diff --git a/backend/src/routes/previewImages.ts b/backend/src/routes/previewImages.ts index 194bb13..e7fcb67 100644 --- a/backend/src/routes/previewImages.ts +++ b/backend/src/routes/previewImages.ts @@ -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) }); }), diff --git a/backend/src/services/consumeService.ts b/backend/src/services/consumeService.ts index 9e90795..f483a7a 100644 --- a/backend/src/services/consumeService.ts +++ b/backend/src/services/consumeService.ts @@ -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 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 { + await removeGeneratedPreviewImages(printId); + if (plateId && (await plateThumbReplaceable(plateId))) await saveThumbFromBytes(plateId, buffer); +} + export async function deleteAllPreviewImages(printId: string): Promise { const rows = await prisma.previewImage.findMany({ where: { printId } }); await prisma.previewImage.deleteMany({ where: { printId } }); diff --git a/backend/tests/previewImageThumb.test.ts b/backend/tests/previewImageThumb.test.ts index f2efa77..7c950bb 100644 --- a/backend/tests/previewImageThumb.test.ts +++ b/backend/tests/previewImageThumb.test.ts @@ -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(() => {