fix: Makerworld profile thumbnails being all the same

This commit is contained in:
Tautvydas Derzinskas committed 2026-10-19 20:06:07 +02:00
1 parent 8125d80c27
commit c6c8161a74
6 files changed
+145 -10

No files matched your search

+29 -3
View File
@@ -504,7 +504,27 @@ export type ImportedPageMetadata = {
makerworldProfile?: MakerworldProfileRef;
};
export type MakerworldProfileRef = { instanceId: string | null };
/** `coverUrl`: the profile's own picture on MakerWorld, which becomes its plate's thumbnail. */
export type MakerworldProfileRef = { instanceId: string | null; coverUrl?: string | null };
/** A profile's own picture: its `cover`, else the first of its `pictures`. */
export function makerworldInstanceCover(design: unknown, instanceId: string | null): string | null {
if (!instanceId || !design || typeof design !== "object") return null;
const instances = (design as Record<string, unknown>).instances;
if (!Array.isArray(instances)) return null;
const inst = instances.find(
(candidate) =>
candidate && typeof candidate === "object" && String((candidate as Record<string, unknown>).id) === instanceId,
) as Record<string, unknown> | undefined;
if (!inst) return null;
const firstPicture = Array.isArray(inst.pictures)
? (inst.pictures[0] as Record<string, unknown> | undefined)
: undefined;
for (const url of [inst.cover, firstPicture?.url]) {
if (typeof url === "string" && /^https:\/\//i.test(url.trim())) return url.trim();
}
return null;
}
export type ImportedAuthorInfo = {
provider: string;
@@ -632,8 +652,13 @@ export async function resolveMakerworldDownloadUrl(
let nonce: string | null = null;
let instanceId: string | null = null;
let defaultInstanceId: string | null = null;
const asDefault = (downloadUrl: string) => ({ downloadUrl, profile: { instanceId: defaultInstanceId } });
let design: unknown = null;
const asDefault = (downloadUrl: string) => ({
downloadUrl,
profile: { instanceId: defaultInstanceId, coverUrl: makerworldInstanceCover(design, defaultInstanceId) },
});
if (nextData) {
design = getPath(nextData, "props", "pageProps", "design");
instanceId = makerworldInstanceIdFromNextData(nextData, requestedInstanceId);
defaultInstanceId = makerworldInstanceIdFromNextData(nextData, null);
// A generic URL in the page data belongs to the default profile.
@@ -647,7 +672,8 @@ export async function resolveMakerworldDownloadUrl(
if (instanceId) {
const url = await fetchMakerworldInstanceDownloadUrl(instanceId, pageUrl, makerworldCookie);
if (url) return { downloadUrl: url, profile: { instanceId } };
if (url)
return { downloadUrl: url, profile: { instanceId, coverUrl: makerworldInstanceCover(design, instanceId) } };
}
const modelId = designId || makerworldModelIdFromUrl(pageUrl);
+40 -2
View File
@@ -37,12 +37,14 @@ import {
extractPageMetadata,
findDownloadUrl,
makerworldHtmlHeaders,
makerworldInstanceCover,
makerworldMetaFromDesign,
resolveMakerworldCookie,
resolveMakerworldDownloadUrl,
type ImportCookies,
type ImportedAuthorInfo,
type ImportedPageMetadata,
type MakerworldProfileRef,
} from "./importResolvers";
import {
extractMakerworldBearerToken,
@@ -336,10 +338,38 @@ async function makerworldMetaFromExtension(url: string, body: ImportRequestBody)
...meta,
author,
creator: author?.name ?? meta.creator,
makerworldProfile: { instanceId: body.resolved_instance_id ?? null },
makerworldProfile: { instanceId: body.resolved_instance_id ?? null, coverUrl: extensionProfileCover(url, body) },
};
}
/** The picked profile's own picture from the design the extension sent, limited to MakerWorld's CDN as it's
* client-supplied. */
function extensionProfileCover(url: string, body: ImportRequestBody): string | null {
const design = body.makerworld_design;
if (!design || String(design.id) !== parseMakerworldModelUrl(url)?.designId) return null;
const cover = makerworldInstanceCover(design, body.resolved_instance_id ?? null);
return isMakerworldCdnUrl(cover) ? cover : null;
}
/** Puts a MakerWorld profile's own picture on its plate, in place of the 3MF's embedded thumbnail: designers often
* ship every profile with the same one, so the profiles would all look alike. Best-effort; never throws. */
async function applyMakerworldProfileCover(
printId: string,
profile: MakerworldProfileRef | null | undefined,
): Promise<void> {
if (!profile?.instanceId || !profile.coverUrl) return;
try {
const plate = await prisma.plate.findFirst({
where: { printId, sourceInstanceId: profile.instanceId },
select: { id: true },
});
const buf = plate ? await fetchImageBytes(profile.coverUrl) : null;
if (plate && buf) await saveThumbFromBytes(plate.id, buf);
} catch {
// The embedded thumbnail stays.
}
}
type MakerworldCloudShortcut = { downloadUrl: string; meta: ImportedPageMetadata };
/** Null means fall back to page scraping; auth and CAPTCHA failures are rethrown as HttpErrors. */
@@ -795,6 +825,8 @@ async function addMakerworldProfileToPrint(
? await openImportResponse(body.resolved_download_url!, { ...body, resolved_download_url: null }, url)
: await openImportResponse(url, body);
const instanceId = presolved ? body.resolved_instance_id! : (opened.meta.makerworldProfile?.instanceId ?? null);
// The presolved download is fetched bare, so its profile's picture comes from the design the extension sent.
const coverUrl = presolved ? extensionProfileCover(url, body) : (opened.meta.makerworldProfile?.coverUrl ?? null);
// Unknown profile, or the resolver fell back to one already on the print.
if (!instanceId || hasProfile(instanceId)) {
await opened.response.body?.cancel().catch(() => undefined);
@@ -802,7 +834,12 @@ async function addMakerworldProfileToPrint(
}
const { tempPath, filename, mime } = await saveImportResponseToTemp(opened, body);
return addProfilePlate(existing, { filename, mime, tempFilePath: tempPath }, instanceId);
const result = await addProfilePlate(existing, { filename, mime, tempFilePath: tempPath }, instanceId);
if (result.profileAdded) {
await applyMakerworldProfileCover(existing.print.id, { instanceId, coverUrl });
return { ...result, plates: await platesOf(existing.print.id) };
}
return result;
}
/** Adds a downloaded profile's file to an imported print as another plate, or, when an untagged plate has the same
@@ -1901,6 +1938,7 @@ async function importNewOrExisting(userId: string, url: string, body: ImportRequ
meta.galleryImages,
body.makerworldPaceMs,
);
await applyMakerworldProfileCover(result.print.id, meta.makerworldProfile);
await localizeDescriptionImages(result.print.id);
const previewImages = await prisma.previewImage.findMany({
where: { printId: result.print.id },
+5 -1
View File
@@ -7,6 +7,7 @@ import {
} from "./flaresolverr";
import {
makerworldAuthorFromDesignCreator,
makerworldInstanceCover,
makerworldMetaFromDesign,
type ImportedAuthorInfo,
type ImportedPageMetadata,
@@ -386,7 +387,10 @@ export async function resolveMakerworldViaCloudApi(
creator: author?.name ?? meta.creator,
filename: pickString(body, ["filename"]),
author,
makerworldProfile: { instanceId: selected.id != null ? String(selected.id) : null },
makerworldProfile: {
instanceId: selected.id != null ? String(selected.id) : null,
coverUrl: makerworldInstanceCover(design, selected.id != null ? String(selected.id) : null),
},
},
};
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { makerworldInstanceIdFromNextData } from "../src/services/importResolvers";
import { makerworldInstanceCover, makerworldInstanceIdFromNextData } from "../src/services/importResolvers";
function nextData(design: Record<string, unknown>) {
return { props: { pageProps: { design } } };
@@ -27,3 +27,25 @@ describe("makerworldInstanceIdFromNextData", () => {
expect(makerworldInstanceIdFromNextData(nextData({ defaultInstanceId: 5, instances: [] }), null)).toBeNull();
});
});
describe("makerworldInstanceCover", () => {
const design = {
instances: [
{ id: 1, cover: "https://makerworld.bblmw.com/a/instance/one.png" },
{ id: 2, cover: "", pictures: [{ url: "https://makerworld.bblmw.com/a/instance/two.png" }] },
{ id: 3, cover: "javascript:alert(1)" },
],
};
it("takes the profile's cover, else its first picture", () => {
expect(makerworldInstanceCover(design, "1")).toBe("https://makerworld.bblmw.com/a/instance/one.png");
expect(makerworldInstanceCover(design, "2")).toBe("https://makerworld.bblmw.com/a/instance/two.png");
});
it("answers null for an unknown profile, no profile or a non-https address", () => {
expect(makerworldInstanceCover(design, "9")).toBeNull();
expect(makerworldInstanceCover(design, null)).toBeNull();
expect(makerworldInstanceCover(design, "3")).toBeNull();
expect(makerworldInstanceCover({}, "1")).toBeNull();
});
});
+41 -2
View File
@@ -1,5 +1,6 @@
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import request from "supertest";
import sharp from "sharp";
// No pacing or DNS here. config.ts reads the pace on load, so it's hoisted.
vi.hoisted(() => {
@@ -17,6 +18,7 @@ vi.mock("node:dns/promises", () => ({
import { createApp } from "../src/app";
import { prisma } from "../src/db";
import { selectMakerworldProfiles } from "../src/services/makerworldCloudApi";
import { plateThumbPath } from "../src/services/printService";
const app = createApp();
const stamp = Date.now();
@@ -41,14 +43,42 @@ function design(designId: string) {
designCreator: DESIGNER,
defaultInstanceId: 2,
instances: [
{ id: 1, profileId: 101, title: "Single-colour version", instanceCreator: DESIGNER },
{ id: 2, profileId: 102, title: "multi-colored version", instanceCreator: DESIGNER },
{
id: 1,
profileId: 101,
title: "Single-colour version",
instanceCreator: DESIGNER,
cover: "https://makerworld.bblmw.com/phoenix/101/instance/red.png",
},
{
id: 2,
profileId: 102,
title: "multi-colored version",
instanceCreator: DESIGNER,
// No cover of its own: the first of its pictures stands in.
pictures: [{ url: "https://makerworld.bblmw.com/phoenix/102/instance/blue.png" }],
},
{ id: 3, profileId: 103, title: "Tail Color", instanceCreator: { uid: 740193827, name: "BlomsD" } },
{ id: 4, profileId: 104, title: "0.16mm layer", instanceCreator: { uid: 356629145, name: "gekkoace" } },
],
};
}
const solid = (r: number, g: number, b: number) =>
sharp({ create: { width: 8, height: 8, channels: 3, background: { r, g, b } } })
.png()
.toBuffer();
const PROFILE_COVERS: Record<string, Promise<Buffer>> = {
"https://makerworld.bblmw.com/phoenix/101/instance/red.png": solid(255, 0, 0),
"https://makerworld.bblmw.com/phoenix/102/instance/blue.png": solid(0, 0, 255),
};
/** The thumbnail's average colour, which survives the JPEG re-encode. */
async function thumbColour(plateId: string): Promise<[number, number, number]> {
const { channels } = await sharp(plateThumbPath(plateId)).stats();
return [channels[0].mean, channels[1].mean, channels[2].mean].map(Math.round) as [number, number, number];
}
describe("selectMakerworldProfiles", () => {
const d = design("1");
it("takes the link's profile, else the default -- a plain single import", () => {
@@ -102,6 +132,9 @@ describe("importing several MakerWorld print profiles", () => {
return new Response(`solid phoenix-${file[1]}\nendsolid\n`, {
headers: { "content-type": "application/octet-stream" },
});
if (PROFILE_COVERS[url]) {
return new Response(await PROFILE_COVERS[url], { headers: { "content-type": "image/png" } });
}
if (url === "https://makerworld.bblmw.com/phoenix/cover.jpg") {
return new Response(
Buffer.from(
@@ -158,6 +191,12 @@ describe("importing several MakerWorld print profiles", () => {
["2", "phoenix-102.stl"],
["1", "phoenix-101.stl"],
]);
// Each profile's file shows that profile's own picture, not the model's cover.
const [blue, red] = await Promise.all(print.plates.map((p) => thumbColour(p.id)));
expect(blue[2]).toBeGreaterThan(200);
expect(blue[0]).toBeLessThan(60);
expect(red[0]).toBeGreaterThan(200);
expect(red[2]).toBeLessThan(60);
});
it("adds the community profiles to a model that already has the designer's", async () => {
+7 -1
View File
@@ -161,7 +161,13 @@ export function makerworldDesignForImport(design: MakerworldDesign): Record<stri
}
if (Array.isArray(source.categories))
out.categories = source.categories.map((category) => ({ id: getPath(category, "id") }));
return JSON.stringify(out).length <= MAX_IMPORT_DESIGN_CHARS ? out : null;
if (JSON.stringify(out).length > MAX_IMPORT_DESIGN_CHARS) return null;
// Each profile's own picture, for its file's thumbnail; left out rather than lose the rest when there are too many.
if (Array.isArray(source.instances)) {
const instances = source.instances.map((inst) => ({ id: getPath(inst, "id"), cover: getPath(inst, "cover") }));
if (JSON.stringify({ ...out, instances }).length <= MAX_IMPORT_DESIGN_CHARS) out.instances = instances;
}
return out;
}
export function currentMakerworldProfileTitle(pageUrl: string): string | null {