mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 06:09:56 -05:00
fix: Makerworld profile thumbnails being all the same
This commit is contained in:
1 parent
8125d80c27
commit
c6c8161a74
6 files changed
+145
-10
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user