fix: tell print profiles apart by name and picture on every provider

Profile files often come named alike, so the files on a model read the
same. A profile whose file clashes with another profile's is now named
after its profile, or by its title alone when that already holds the
file's name; profiles with the same title keep the site's name.

Each profile's own picture now becomes its file's thumbnail on Snapmaker
Space, MakerOnline, Creality Cloud and NexPrint too, not just MakerWorld.

Earlier profiles are renamed before the new file is placed, and a preview
render that loses its file to a rename retries from where it went.
This commit is contained in:
Tautvydas Derzinskas committed 2026-10-19 20:34:24 +02:00
1 parent c6c8161a74
commit f34a488fb2
15 files changed
+466 -84

No files matched your search

+9 -1
View File
@@ -176,7 +176,14 @@ export async function checkCrealitycloudAuth(
}
}
export type CrealitycloudProfile = { id: string; title: string; creatorId: string | null; designers: boolean };
/** `coverUrl`: the profile's own picture, which becomes its file's thumbnail. */
export type CrealitycloudProfile = {
id: string;
title: string;
creatorId: string | null;
designers: boolean;
coverUrl: string | null;
};
export type CrealitycloudModelResolution = {
meta: Partial<ImportedPageMetadata>;
@@ -285,6 +292,7 @@ export function crealitycloudMetaFromDetail(
title: profileTitle(profile, profileId),
creatorId: owner,
designers: profile.isAuth === true || (!!owner && owner === creatorId),
coverUrl: coverUrls(profile.covers)[0] ?? str(profile.thumbnail),
},
];
}),
+28 -5
View File
@@ -504,8 +504,25 @@ export type ImportedPageMetadata = {
makerworldProfile?: MakerworldProfileRef;
};
/** `coverUrl`: the profile's own picture on MakerWorld, which becomes its plate's thumbnail. */
export type MakerworldProfileRef = { instanceId: string | null; coverUrl?: string | null };
/** `coverUrl`: the profile's own picture on MakerWorld, which becomes its plate's thumbnail. `titles`: every profile's
* name by id, which tells the design's profile files apart when they share a filename. */
export type MakerworldProfileRef = {
instanceId: string | null;
coverUrl?: string | null;
titles?: Record<string, string>;
};
/** Every profile's name, by id. */
export function makerworldInstanceTitles(design: unknown): Record<string, string> {
const instances = design && typeof design === "object" ? (design as Record<string, unknown>).instances : null;
const titles: Record<string, string> = {};
for (const inst of Array.isArray(instances) ? instances : []) {
if (!inst || typeof inst !== "object") continue;
const { id, title } = inst as Record<string, unknown>;
if (id != null && typeof title === "string" && title.trim()) titles[String(id)] = title.trim();
}
return titles;
}
/** A profile's own picture: its `cover`, else the first of its `pictures`. */
export function makerworldInstanceCover(design: unknown, instanceId: string | null): string | null {
@@ -655,7 +672,11 @@ export async function resolveMakerworldDownloadUrl(
let design: unknown = null;
const asDefault = (downloadUrl: string) => ({
downloadUrl,
profile: { instanceId: defaultInstanceId, coverUrl: makerworldInstanceCover(design, defaultInstanceId) },
profile: {
instanceId: defaultInstanceId,
coverUrl: makerworldInstanceCover(design, defaultInstanceId),
titles: makerworldInstanceTitles(design),
},
});
if (nextData) {
design = getPath(nextData, "props", "pageProps", "design");
@@ -672,8 +693,10 @@ export async function resolveMakerworldDownloadUrl(
if (instanceId) {
const url = await fetchMakerworldInstanceDownloadUrl(instanceId, pageUrl, makerworldCookie);
if (url)
return { downloadUrl: url, profile: { instanceId, coverUrl: makerworldInstanceCover(design, instanceId) } };
if (url) {
const coverUrl = makerworldInstanceCover(design, instanceId);
return { downloadUrl: url, profile: { instanceId, coverUrl, titles: makerworldInstanceTitles(design) } };
}
}
const modelId = designId || makerworldModelIdFromUrl(pageUrl);
+234 -45
View File
@@ -38,6 +38,7 @@ import {
findDownloadUrl,
makerworldHtmlHeaders,
makerworldInstanceCover,
makerworldInstanceTitles,
makerworldMetaFromDesign,
resolveMakerworldCookie,
resolveMakerworldDownloadUrl,
@@ -145,11 +146,12 @@ import { upsertAuthorFromImport } from "./authorService";
import {
addPlatesToPrint,
createPrint,
refreshAutoPreparedMetadata,
resolvePlateFilePath,
type NewPlateInput,
type PrintMetaInput,
} from "./printCreation";
import { plateThumbReplaceable, saveThumbFromBytes } from "./printService";
import { availablePlateFilename, plateThumbReplaceable, relocatePrint, saveThumbFromBytes } from "./printService";
import { addPreviewImage, removeGeneratedPreviewImages } from "./previewImageService";
import { localizeDescriptionImages } from "./descriptionImageService";
import { prisma } from "../db";
@@ -338,36 +340,173 @@ async function makerworldMetaFromExtension(url: string, body: ImportRequestBody)
...meta,
author,
creator: author?.name ?? meta.creator,
makerworldProfile: { instanceId: body.resolved_instance_id ?? null, coverUrl: extensionProfileCover(url, body) },
makerworldProfile: extensionProfile(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 {
/** The picked profile from the design the extension sent: its picture, limited to MakerWorld's CDN as it's
* client-supplied, and every profile's name. */
function extensionProfile(url: string, body: ImportRequestBody): MakerworldProfileRef {
const instanceId = body.resolved_instance_id ?? 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;
if (!design || String(design.id) !== parseMakerworldModelUrl(url)?.designId) return { instanceId };
const cover = makerworldInstanceCover(design, instanceId);
return {
instanceId,
coverUrl: isMakerworldCdnUrl(cover) ? cover : null,
titles: makerworldInstanceTitles(design),
};
}
/** 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.
/** What tells a model's print profiles apart, by profile id: their names, for files that come named alike, and their
* own pictures, for their files' thumbnails. */
type ProfileDetails = { titles?: Record<string, string>; covers?: Record<string, string> };
/** `names: false` for a provider whose profile files are already named after their profiles. */
function profileDetailsOf(
profiles: { id: string; title: string; coverUrl?: string | null }[],
{ names = true } = {},
): ProfileDetails {
return {
titles: names ? Object.fromEntries(profiles.map((p) => [p.id, p.title])) : undefined,
covers: Object.fromEntries(profiles.flatMap((p) => (p.coverUrl ? [[p.id, p.coverUrl]] : []))),
};
}
function makerworldProfileDetails(profile: MakerworldProfileRef | null | undefined): ProfileDetails {
const { instanceId, coverUrl, titles } = profile ?? {};
return { titles, covers: instanceId && coverUrl ? { [instanceId]: coverUrl } : undefined };
}
/** A profile's file named after the profile: "Phoenix - Multi-colour.3mf". */
function profileFilename(filename: string, profileTitle: string): string {
const ext = path.extname(filename);
const stem = path.basename(filename, ext);
// A title that already holds the file's name ("vase_2_1plates" for "vase_2") says it all on its own.
return sanitizeFilename(
looseName(profileTitle).includes(looseName(stem)) ? `${profileTitle}${ext}` : `${stem} - ${profileTitle}${ext}`,
);
}
/** For comparing names: case, spaces, dashes and underscores aside. */
function looseName(name: string): string {
return name
.toLowerCase()
.replace(/[\s_-]+/g, " ")
.trim();
}
/** Sites often give every profile's file the same name, so the files on a model can't be told apart. A new profile
* whose file shares an earlier profile's name is named after its profile instead; so is each earlier one it clashes
* with, which is renamed (and moved on disk) once the new one is in. */
function profilePlateNaming(
plates: Plate[],
filename: string,
instanceId: string,
titles: Record<string, string> | undefined,
): { filename: string; renames: { plate: Plate; filename: string }[] } {
const title = titles?.[instanceId];
if (!title) return { filename, renames: [] };
const name = sanitizeFilename(filename).toLowerCase();
const ext = path.extname(name);
// Earlier profiles of the file that were already named apart count as the same name.
const namedApart = `${path.basename(name, ext)} - `;
const clashing = plates.filter((plate) => {
const other = plate.filename.toLowerCase();
return other === name || (plate.sourceInstanceId != null && other.startsWith(namedApart) && other.endsWith(ext));
});
if (!clashing.length) return { filename, renames: [] };
// Profiles named alike can't be told apart by their names either: the site's own name, suffixed, will do.
const sameTitle = clashing.some((plate) => {
const otherTitle = plate.sourceInstanceId ? titles?.[plate.sourceInstanceId] : undefined;
return otherTitle !== undefined && looseName(otherTitle) === looseName(title);
});
if (sameTitle) return { filename, renames: [] };
const renames = clashing.flatMap((plate) => {
const otherTitle = plate.sourceInstanceId ? titles?.[plate.sourceInstanceId] : undefined;
return otherTitle && plate.filename.toLowerCase() === name
? [{ plate, filename: profileFilename(filename, otherTitle) }]
: [];
});
return { filename: profileFilename(filename, title), renames };
}
/** The same for profiles that come in together, before any is on the model. */
function nameInputsApart(inputs: NewPlateInput[], titles: Record<string, string> | undefined): NewPlateInput[] {
if (!titles) return inputs;
const titleOf = (input: NewPlateInput) => (input.sourceInstanceId ? titles[input.sourceInstanceId] : undefined);
const key = (input: NewPlateInput) => sanitizeFilename(input.filename).toLowerCase();
// Per file name, how many profiles have it, and how many of those share each title.
const counts = new Map<string, number>();
for (const input of inputs) {
if (!input.sourceInstanceId) continue;
counts.set(key(input), (counts.get(key(input)) ?? 0) + 1);
const title = titleOf(input);
if (title)
counts.set(`${key(input)}\0${looseName(title)}`, (counts.get(`${key(input)}\0${looseName(title)}`) ?? 0) + 1);
}
return inputs.map((input) => {
const title = titleOf(input);
// Only a title that tells this one apart from the others with its name.
const apart = title && (counts.get(`${key(input)}\0${looseName(title)}`) ?? 0) === 1;
return apart && (counts.get(key(input)) ?? 0) > 1
? { ...input, filename: profileFilename(input.filename, title) }
: input;
});
}
async function renameProfilePlates(printId: string, renames: { plate: Plate; filename: string }[]): Promise<void> {
if (!renames.length) return;
const print = await prisma.print.findUnique({ where: { id: printId } });
if (!print) return;
for (const { plate, filename } of renames) {
const next = await availablePlateFilename(printId, filename, plate.id);
await prisma.plate.update({ where: { id: plate.id }, data: { filename: next } });
await relocatePrint(print, [{ ...plate, filename: next }]);
if (plate.position === 0) await refreshAutoPreparedMetadata(printId);
}
}
/** Puts each 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. Only `instanceIds` when given. Best-effort; never
* throws. */
async function applyProfileCovers(
printId: string,
covers: Record<string, string> | undefined,
instanceIds?: string[],
): Promise<void> {
const wanted = Object.keys(covers ?? {}).filter((id) => !instanceIds || instanceIds.includes(id));
if (!covers || !wanted.length) return;
try {
const plates = await prisma.plate.findMany({
where: { printId, sourceInstanceId: { in: wanted } },
select: { id: true, sourceInstanceId: true },
});
for (const plate of plates) {
const buf = await fetchImageBytes(covers[plate.sourceInstanceId!]);
if (buf) await saveThumbFromBytes(plate.id, buf);
}
} catch {
// The embedded thumbnails stay.
}
}
/** addProfilePlate, with the new profile told apart from the others by its name and picture. */
async function addProfilePlateWithDetails(
existing: ExistingImportedPrint,
file: NewPlateInput,
instanceId: string,
details: ProfileDetails,
): Promise<ExistingImportedPrint & { alreadyImported: boolean; profileAdded?: boolean }> {
const naming = profilePlateNaming(existing.plates, file.filename, instanceId, details.titles);
// The earlier profiles first, so the new file is placed under its own name once and never moved while its preview
// renders. Only profiles' own plates are renamed, so the content match addProfilePlate makes on untagged ones holds.
// Best-effort: the profile comes in either way.
await renameProfilePlates(existing.print.id, naming.renames).catch(() => undefined);
const result = await addProfilePlate(existing, { ...file, filename: naming.filename }, instanceId);
if (!result.profileAdded) return result;
await applyProfileCovers(existing.print.id, details.covers, [instanceId]);
return { ...result, plates: await platesOf(existing.print.id) };
}
type MakerworldCloudShortcut = { downloadUrl: string; meta: ImportedPageMetadata };
@@ -824,9 +963,9 @@ async function addMakerworldProfileToPrint(
const opened = presolved
? 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);
// The presolved download is fetched bare, so its profile's details come from the design the extension sent.
const profile = presolved ? extensionProfile(url, body) : opened.meta.makerworldProfile;
const instanceId = profile?.instanceId ?? 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);
@@ -834,12 +973,12 @@ async function addMakerworldProfileToPrint(
}
const { tempPath, filename, mime } = await saveImportResponseToTemp(opened, body);
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;
return addProfilePlateWithDetails(
existing,
{ filename, mime, tempFilePath: tempPath },
instanceId,
makerworldProfileDetails(profile),
);
}
/** Adds a downloaded profile's file to an imported print as another plate, or, when an untagged plate has the same
@@ -1442,7 +1581,15 @@ async function importSnapmakerModel(
}
if (!downloaded.length) throw new HttpError(400, "This Snapmaker model has no files Thingport can import.");
return createSourcedPrint(userId, source, body, resolved, downloaded, `snapmaker-${source.externalId}`);
return createSourcedPrint(
userId,
source,
body,
resolved,
downloaded,
`snapmaker-${source.externalId}`,
profileDetailsOf(resolved.profiles),
);
}
/** A new print from a provider model whose files are downloaded: its metadata, author, category and gallery. Removes
@@ -1454,6 +1601,7 @@ async function createSourcedPrint(
resolved: { meta: Partial<ImportedPageMetadata>; galleryImages: { url: string; filename: string }[] },
downloaded: NewPlateInput[],
nameHint: string,
profiles: ProfileDetails = {},
): Promise<ImportResult> {
const { meta, galleryImages } = resolved;
try {
@@ -1474,7 +1622,12 @@ async function createSourcedPrint(
};
let created: { print: Print; plates: Plate[] };
try {
created = await createPrint(userId, printMeta, meta.title || nameHint, downloaded);
created = await createPrint(
userId,
printMeta,
meta.title || nameHint,
nameInputsApart(downloaded, profiles.titles),
);
} catch (err) {
// Race guard: a concurrent import of the same source model won.
if (isUniqueConstraintError(err)) {
@@ -1489,6 +1642,7 @@ async function createSourcedPrint(
meta.previewImageUrl ?? null,
galleryImages,
);
await applyProfileCovers(created.print.id, profiles.covers);
await localizeDescriptionImages(created.print.id);
const previewImages = await prisma.previewImage.findMany({
where: { printId: created.print.id },
@@ -1524,7 +1678,9 @@ async function addSnapmakerProfileToPrint(
throw await snapmakerFailure(userId, err);
}
if (!profile) throw new HttpError(404, "This Snapmaker model has no such print profile.");
return addProfilePlate(existing, profile, wanted);
// Its public details name and picture the profiles; without them the file still comes in.
const model = await resolveSnapmakerModel(existing.print.sourceExternalId!).catch(() => null);
return addProfilePlateWithDetails(existing, profile, wanted, model ? profileDetailsOf(model.profiles) : {});
}
const CREALITYCLOUD_BLOCKED =
@@ -1613,7 +1769,16 @@ async function importCrealitycloudModel(
}
if (!downloaded.length) throw new HttpError(400, "This Creality Cloud model has no files Thingport can import.");
return createSourcedPrint(userId, source, body, resolved, downloaded, `crealitycloud-${source.externalId}`);
// Its profile files are named after their profiles already.
return createSourcedPrint(
userId,
source,
body,
resolved,
downloaded,
`crealitycloud-${source.externalId}`,
profileDetailsOf(resolved.profiles, { names: false }),
);
}
/** Re-import of a Creality Cloud model naming a profile it doesn't have yet: that profile's 3MF becomes another plate.
@@ -1631,15 +1796,17 @@ async function addCrealitycloudProfileToPrint(
const userId = existing.print.userId;
const auth = await requireCrealitycloudAuth(userId);
let profile: NewPlateInput | null;
let model: CrealitycloudModelResolution | null;
try {
const model = resolved ?? (await resolveCrealitycloudModel(existing.print.sourceExternalId!));
model = resolved ?? (await resolveCrealitycloudModel(existing.print.sourceExternalId!));
if (!model) throw new HttpError(404, CREALITYCLOUD_MISSING);
profile = await downloadCrealitycloudProfile(model, wanted, auth);
} catch (err) {
throw await crealitycloudFailure(userId, err);
}
if (!profile) throw new HttpError(404, "This Creality Cloud model has no such print profile.");
return addProfilePlate(existing, profile, wanted);
// Its profile files are named after their profiles already.
return addProfilePlateWithDetails(existing, profile, wanted, profileDetailsOf(model.profiles, { names: false }));
}
const NEXPRINT_BLOCKED =
@@ -1719,7 +1886,16 @@ async function importNexprintModel(
}
if (!downloaded.length) throw new HttpError(400, "This NexPrint model has no files Thingport can import.");
return createSourcedPrint(userId, source, body, resolved, downloaded, `nexprint-${source.externalId}`);
// Its profile files are named after their profiles already.
return createSourcedPrint(
userId,
source,
body,
resolved,
downloaded,
`nexprint-${source.externalId}`,
profileDetailsOf(resolved.profiles, { names: false }),
);
}
/** Re-import of a NexPrint model naming a profile it doesn't have yet: that profile's 3MF becomes another plate. A
@@ -1736,15 +1912,17 @@ async function addNexprintProfileToPrint(
const userId = existing.print.userId;
const token = await requireNexprintToken(userId);
let profile: NewPlateInput | null;
let model: NexprintModelResolution | null;
try {
const model = await resolveNexprintModel(existing.print.sourceExternalId!);
model = await resolveNexprintModel(existing.print.sourceExternalId!);
if (!model) throw new HttpError(404, NEXPRINT_MISSING);
profile = await downloadNexprintProfile(model, wanted, token);
} catch (err) {
throw await nexprintFailure(userId, err);
}
if (!profile) throw new HttpError(404, "This NexPrint model has no such print profile.");
return addProfilePlate(existing, profile, wanted);
// Its profile files are named after their profiles already.
return addProfilePlateWithDetails(existing, profile, wanted, profileDetailsOf(model.profiles, { names: false }));
}
const MAKERONLINE_BLOCKED =
@@ -1794,7 +1972,15 @@ async function importMakeronlineModel(
const resolved = await resolveMakeronlineModel(source.externalId);
if (!resolved) throw new HttpError(404, "This MakerOnline model could not be found, or isn't public.");
const downloaded = await downloadMakeronlineFiles(body.resolved_files);
return createSourcedPrint(userId, source, body, resolved, downloaded, `makeronline-${source.externalId}`);
return createSourcedPrint(
userId,
source,
body,
resolved,
downloaded,
`makeronline-${source.externalId}`,
profileDetailsOf(resolved.profiles),
);
}
/** Re-import of a MakerOnline model with print profiles it doesn't have yet: each becomes another plate. Anything
@@ -1807,11 +1993,14 @@ async function addMakeronlineProfilesToPrint(
const wanted = (body.resolved_files ?? []).filter((file) => file.profile_id && !have.has(file.profile_id));
if (!wanted.length) return { ...existing, alreadyImported: true };
const inputs = await downloadMakeronlineFiles(wanted);
// Its public details name and picture the profiles; without them the files still come in.
const model = await resolveMakeronlineModel(existing.print.sourceExternalId!).catch(() => null);
const details = model ? profileDetailsOf(model.profiles) : {};
let current: ExistingImportedPrint = existing;
let profileAdded = false;
try {
for (const input of inputs) {
const result = await addProfilePlate(current, input, input.sourceInstanceId!);
const result = await addProfilePlateWithDetails(current, input, input.sourceInstanceId!, details);
profileAdded ||= Boolean(result.profileAdded);
current = result;
}
@@ -1938,7 +2127,7 @@ async function importNewOrExisting(userId: string, url: string, body: ImportRequ
meta.galleryImages,
body.makerworldPaceMs,
);
await applyMakerworldProfileCover(result.print.id, meta.makerworldProfile);
await applyProfileCovers(result.print.id, makerworldProfileDetails(meta.makerworldProfile).covers);
await localizeDescriptionImages(result.print.id);
const previewImages = await prisma.previewImage.findMany({
where: { printId: result.print.id },
+3
View File
@@ -128,6 +128,8 @@ export type MakeronlineProfile = {
/** The profile's 3MF; all its plates share it. */
fileKey: string | null;
fileName: string | null;
/** The profile's own picture, which becomes its file's thumbnail. */
coverUrl: string | null;
};
/** One of the model's own files (STLs and the like), as the download call names it. */
@@ -208,6 +210,7 @@ export function makeronlineMetaFromDetail(detail: Record<string, unknown>): Make
creatorId: str(profile.user_id),
fileKey: str(file?.key),
fileName: str(file?.file_name),
coverUrl: str(profile.main_image),
},
];
},
@@ -8,6 +8,7 @@ import {
import {
makerworldAuthorFromDesignCreator,
makerworldInstanceCover,
makerworldInstanceTitles,
makerworldMetaFromDesign,
type ImportedAuthorInfo,
type ImportedPageMetadata,
@@ -390,6 +391,7 @@ export async function resolveMakerworldViaCloudApi(
makerworldProfile: {
instanceId: selected.id != null ? String(selected.id) : null,
coverUrl: makerworldInstanceCover(design, selected.id != null ? String(selected.id) : null),
titles: makerworldInstanceTitles(design),
},
},
};
+46 -16
View File
@@ -2,7 +2,8 @@ import fs from "node:fs/promises";
import fsSync from "node:fs";
import path from "node:path";
import { Worker } from "node:worker_threads";
import { MODEL_PREVIEWS, MODEL_PREVIEW_MAX_MEMORY_MB, MODEL_PREVIEW_TIMEOUT_SECONDS } from "../config";
import { MODEL_PREVIEWS, MODEL_PREVIEW_MAX_MEMORY_MB, MODEL_PREVIEW_TIMEOUT_SECONDS, STORAGE } from "../config";
import { prisma } from "../db";
import type { ModelPreviewWorkerInput, ModelPreviewWorkerResult } from "./modelPreviewWorker";
import { getSimplifyPreviews } from "./settingsService";
import { workerBootstrap } from "../utils/workerBootstrap";
@@ -198,12 +199,14 @@ async function runGeneration(plateId: string, srcPath: string): Promise<void> {
try {
await fs.writeFile(pendingPath, new Date().toISOString());
const simplify = await getSimplifyPreviews();
const outcome = await renderInWorker({
kind: "glb",
srcPath,
destPath: tmp,
options: { simplifyTo: simplify ? SIMPLIFY_TARGET_TRIANGLES : null },
});
const outcome = await renderPlateFile(plateId, srcPath, (from) =>
renderInWorker({
kind: "glb",
srcPath: from,
destPath: tmp,
options: { simplifyTo: simplify ? SIMPLIFY_TARGET_TRIANGLES : null },
}),
);
switch (outcome.status) {
case "ok":
await fs.rename(tmp, dest);
@@ -236,6 +239,31 @@ async function runGeneration(plateId: string, srcPath: string): Promise<void> {
}
}
/** The plate's file where it is when its render starts: renders wait in a queue, and the file may have been renamed
* (and so moved) since, as when a newly imported print profile is named apart from another. */
async function currentPlatePath(plateId: string, queuedPath: string): Promise<string> {
if (fsSync.existsSync(queuedPath)) return queuedPath;
const plate = await prisma.plate
.findUnique({ where: { id: plateId }, select: { storagePath: true, sourcePath: true } })
.catch(() => null);
const managed = plate ? path.join(STORAGE, plate.storagePath) : null;
if (managed && fsSync.existsSync(managed)) return managed;
return plate?.sourcePath && fsSync.existsSync(plate.sourcePath) ? plate.sourcePath : queuedPath;
}
/** Renders from the plate's current file, and once more from where it went if it was moved away mid-render. */
async function renderPlateFile<T extends { status: string }>(
plateId: string,
queuedPath: string,
render: (srcPath: string) => Promise<T>,
): Promise<T> {
const srcPath = await currentPlatePath(plateId, queuedPath);
const outcome = await render(srcPath);
if (outcome.status !== "error" || fsSync.existsSync(srcPath)) return outcome;
const moved = await currentPlatePath(plateId, srcPath);
return moved !== srcPath && fsSync.existsSync(moved) ? render(moved) : outcome;
}
// Pictures of a model for small slots like the dashboard lists: near-black on transparent.
// They share the GLB queue and memory limit, so the two never render at once, but wait behind GLBs.
@@ -273,15 +301,17 @@ export async function generateModelImage(plateId: string, srcPath: string, filen
const dest = modelImagePath(plateId);
const tmp = `${dest}.${process.pid}.${Date.now()}.tmp`;
try {
const outcome = await renderInWorker(
{
kind: "image",
srcPath,
destPath: tmp,
ext: path.extname(filename).toLowerCase(),
image: { size: MODEL_IMAGE_SIZE, color: MODEL_IMAGE_COLOR },
},
MODEL_IMAGE_TIMEOUT_SECONDS,
const outcome = await renderPlateFile(plateId, srcPath, (from) =>
renderInWorker(
{
kind: "image",
srcPath: from,
destPath: tmp,
ext: path.extname(filename).toLowerCase(),
image: { size: MODEL_IMAGE_SIZE, color: MODEL_IMAGE_COLOR },
},
MODEL_IMAGE_TIMEOUT_SECONDS,
),
);
if (outcome.status === "ok") {
await fs.rename(tmp, dest);
+3
View File
@@ -171,6 +171,8 @@ export type NexprintProfile = {
fileId: string;
title: string;
designers: boolean;
/** The profile's own picture, which becomes its file's thumbnail. */
coverUrl: string | null;
};
export type NexprintModelFile = { fileId: string; name: string };
@@ -263,6 +265,7 @@ export function nexprintMetaFromDetail(data: Record<string, unknown>): NexprintM
fileId,
title: str(setting.settingName) ?? `Profile ${profileId}`,
designers: setting.isCreator === true || (!!authorId && id(setting.userId) === authorId),
coverUrl: str(setting.settingCoverImgUrl),
},
];
});
+3 -1
View File
@@ -112,7 +112,8 @@ export async function checkSnapmakerToken(token: string): Promise<"ok" | "reject
}
}
export type SnapmakerProfile = { id: string; title: string; creatorId: string | null };
/** `coverUrl`: the profile's own picture, which becomes its file's thumbnail. */
export type SnapmakerProfile = { id: string; title: string; creatorId: string | null; coverUrl: string | null };
export type SnapmakerModelResolution = {
meta: Partial<ImportedPageMetadata>;
@@ -194,6 +195,7 @@ export function snapmakerMetaFromDetail(detail: Record<string, unknown>): Snapma
id: id(profile.id)!,
title: str(profile.translatedTitle) ?? str(profile.title) ?? `Profile ${String(profile.id)}`,
creatorId: id(profile.creatorId),
coverUrl: Array.isArray(profile.pictures) ? str(profile.pictures[0]) : null,
},
];
})
+8
View File
@@ -165,6 +165,8 @@ const PROFILES = [
secondName: "0.2mm layer, 2 walls",
printerName: "Ender-3 V3 Plus",
isAuth: true,
covers: [{ url: "https://pic2-cdn.creality.com/crealityCloud/upload/profile.webp" }],
thumbnail: "https://pic2-cdn.creality.com/pic/plate.png",
userInfo: { userId: 4247093750 },
},
{
@@ -172,6 +174,7 @@ const PROFILES = [
secondName: "0.16mm layer",
printerName: "SPARKX i7",
isAuth: false,
thumbnail: "https://pic2-cdn.creality.com/pic/other-plate.png",
userInfo: { userId: 4055423748 },
},
];
@@ -198,6 +201,11 @@ describe("Creality Cloud model details", () => {
["0.2mm layer, 2 walls (Ender-3 V3 Plus)", true],
["0.16mm layer (SPARKX i7)", false],
]);
// A profile's own picture, else its plate's render.
expect(resolved.profiles.map((p) => p.coverUrl)).toEqual([
"https://pic2-cdn.creality.com/crealityCloud/upload/profile.webp",
"https://pic2-cdn.creality.com/pic/other-plate.png",
]);
});
it("picks profiles like MakerWorld", () => {
+4 -3
View File
@@ -104,6 +104,7 @@ const DETAIL = {
id: 176077,
title: "Kobra X profile",
user_id: "designer-uuid",
main_image: "https://cdn-acop.makeronline.com/asop/profile.png",
files: [
{ key: "13d314", file_name: "scraper.3mf", print_profile_id: 176077 },
{ key: "13d314", file_name: "scraper.3mf", print_profile_id: 176077 },
@@ -134,9 +135,9 @@ describe("MakerOnline model details", () => {
handle: "3DPrintOfficial_1",
links: ["https://www.makeronline.com/en/user/personalInfo/designer-uuid.html"],
});
expect(profiles.map((p) => [p.id, p.fileKey])).toEqual([
["176077", "13d314"],
["215390", "d1aee5"],
expect(profiles.map((p) => [p.id, p.fileKey, p.coverUrl])).toEqual([
["176077", "13d314", "https://cdn-acop.makeronline.com/asop/profile.png"],
["215390", "d1aee5", null],
]);
expect(modelFiles).toHaveLength(2);
});
+27 -2
View File
@@ -1,4 +1,6 @@
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import path from "node:path";
import request from "supertest";
import sharp from "sharp";
@@ -16,6 +18,7 @@ vi.mock("node:dns/promises", () => ({
}));
import { createApp } from "../src/app";
import { STORAGE } from "../src/config";
import { prisma } from "../src/db";
import { selectMakerworldProfiles } from "../src/services/makerworldCloudApi";
import { plateThumbPath } from "../src/services/printService";
@@ -114,7 +117,8 @@ describe("importing several MakerWorld print profiles", () => {
global.fetch = originalFetch;
});
function mockMakerworld(designId: string) {
/** `sameName`: every profile's file comes named alike, as MakerWorld often does. */
function mockMakerworld(designId: string, { sameName = false } = {}) {
const fetched: string[] = [];
global.fetch = vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async (input) => {
const url = String(input);
@@ -125,7 +129,7 @@ describe("importing several MakerWorld print profiles", () => {
return json({
message: "success",
url: `https://s3.example.com/phoenix-${profile[1]}.stl`,
filename: `phoenix-${profile[1]}.stl`,
filename: sameName ? "phoenix.stl" : `phoenix-${profile[1]}.stl`,
});
const file = url.match(/^https:\/\/s3\.example\.com\/phoenix-(\d+)\.stl$/);
if (file)
@@ -208,6 +212,27 @@ describe("importing several MakerWorld print profiles", () => {
expect(plates.map((p) => p.sourceInstanceId).toSorted()).toEqual(["1", "2", "3", "4"]);
});
it("names profile files that come named alike after their profiles", async () => {
const designId = String((stamp % 1_000_000_000) + 1);
mockMakerworld(designId, { sameName: true });
await runJob(designId, "designer");
const job = await runJob(designId, "all");
expect(job).toMatchObject({ status: "DONE", failed_count: 0 });
const plates = await prisma.plate.findMany({
where: { printId: job.result_print_id },
orderBy: { position: "asc" },
});
expect(plates.map((p) => [p.sourceInstanceId, p.filename])).toEqual([
["2", "phoenix - multi-colored version.stl"],
["1", "phoenix - Single-colour version.stl"],
["3", "phoenix - Tail Color.stl"],
["4", "phoenix - 0.16mm layer.stl"],
]);
// The renamed first file moved on disk with its new name.
expect(plates.every((p) => fs.existsSync(path.join(STORAGE, p.storagePath)))).toBe(true);
expect(plates[0].storagePath.endsWith("phoenix - multi-colored version.stl")).toBe(true);
});
it("refuses a link that isn't a MakerWorld model", async () => {
const res = await request(app)
.post("/api/import/makerworld-profiles")
+4 -3
View File
@@ -162,6 +162,7 @@ const DETAIL = {
id: "2109988470411608064",
settingOutId: "G6354239",
settingName: "0.2mm layer, 2 walls, 15% infill",
settingCoverImgUrl: "https://np.nexprint.com/images/1/profile.png",
userId: "2109966715818881024",
isCreator: true,
open: true,
@@ -196,9 +197,9 @@ describe("NexPrint model details", () => {
name: "Cute Animals",
links: ["https://www.nexprint.com/en/U0030208991/home"],
});
expect(resolved.profiles.map((p) => [p.id, p.designers])).toEqual([
["G6354239", true],
["G6354240", false],
expect(resolved.profiles.map((p) => [p.id, p.designers, p.coverUrl])).toEqual([
["G6354239", true, "https://np.nexprint.com/images/1/profile.png"],
["G6354240", false, null],
]);
// The profile's 3MF isn't counted again as a model file.
expect(resolved.files).toEqual([{ fileId: "2109986422412034999", name: "Dog.stl" }]);
+15 -3
View File
@@ -112,7 +112,13 @@ const DETAIL = {
creatorName: "Decorartor",
creatorAvatar: "https://cdn-model.snapmaker.com/snapmaker/avatar/119349/avatar.jpg",
printProfiles: [
{ id: "37940", title: "snapmaker_2plates", translatedTitle: "2 plates", creatorId: "119349" },
{
id: "37940",
title: "snapmaker_2plates",
translatedTitle: "2 plates",
creatorId: "119349",
pictures: ["https://cdn-model.snapmaker.com/snapmaker/model/1/a.jpg", "https://cdn-model.snapmaker.com/b.jpg"],
},
{ id: "40001", title: "remix", creatorId: "555" },
],
};
@@ -135,8 +141,14 @@ describe("Snapmaker model details", () => {
expect(galleryImages.map((image) => image.filename)).toEqual(["a.jpg", "b.jpg"]);
expect(creatorId).toBe("119349");
expect(profiles).toEqual([
{ id: "37940", title: "2 plates", creatorId: "119349" },
{ id: "40001", title: "remix", creatorId: "555" },
// A profile's own picture is its first.
{
id: "37940",
title: "2 plates",
creatorId: "119349",
coverUrl: "https://cdn-model.snapmaker.com/snapmaker/model/1/a.jpg",
},
{ id: "40001", title: "remix", creatorId: "555", coverUrl: null },
]);
});
+73 -3
View File
@@ -1,9 +1,11 @@
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import request from "supertest";
import sharp from "sharp";
import { createApp } from "../src/app";
import { importPrintFromUrl } from "../src/services/importService";
import { prisma } from "../src/db";
import { getSnapmakerConnection, setUserSnapmakerToken } from "../src/services/snapmakerSessionService";
import { plateThumbPath } from "../src/services/printService";
// Snapmaker Space import end to end: details and signed downloads in the shapes its site gets, fetch mocked.
@@ -20,11 +22,24 @@ const ONE_PIXEL_PNG = Buffer.from(
"base64",
);
const PROFILES = [
type Profile = { id: string; title: string; creatorId: string; pictures?: string[] };
const PROFILES: Profile[] = [
{ id: "501", title: "Designer's", creatorId: "777" },
{ id: "502", title: "Community remix", creatorId: "888" },
];
const RED_URL = "https://cdn-model.snapmaker.com/snapmaker/model/test/501-red.png";
const BLUE_URL = "https://cdn-model.snapmaker.com/snapmaker/model/test/502-blue.png";
const solid = (r: number, g: number, b: number) =>
sharp({ create: { width: 8, height: 8, channels: 3, background: { r, g, b } } })
.png()
.toBuffer();
/** The thumbnail's average colour, which survives the JPEG re-encode. */
async function thumbColour(plateId: string): Promise<number[]> {
const { channels } = await sharp(plateThumbPath(plateId)).stats();
return channels.slice(0, 3).map((c) => Math.round(c.mean));
}
function detail(profiles = PROFILES) {
return {
id: MODEL_ID,
@@ -52,7 +67,8 @@ function apiResponse(code: number, data: unknown = null): Response {
});
}
function mockSnapmaker(opts: { profiles?: typeof PROFILES; downloadCode?: number } = {}) {
/** `sameName`: every profile's file comes named alike, as Snapmaker often does. */
function mockSnapmaker(opts: { profiles?: Profile[]; downloadCode?: number; sameName?: boolean } = {}) {
return vi.fn<(input: RequestInfo | URL) => Promise<Response>>(async (input) => {
const url = String(input);
if (url === `${API}/model/detail/${MODEL_ID}`) return apiResponse(200, detail(opts.profiles));
@@ -62,7 +78,7 @@ function mockSnapmaker(opts: { profiles?: typeof PROFILES; downloadCode?: number
modelFiles: [{ fileName: "princess.stl", downloadUrl: MODEL_STL }],
printProfiles: (opts.profiles ?? PROFILES).map((profile) => ({
profileId: profile.id,
fileName: `${profile.id}.3mf`,
fileName: opts.sameName ? "princess.3mf" : `${profile.id}.3mf`,
downloadUrl: profile.id === "501" ? DESIGNER_3MF : REMIX_3MF,
})),
});
@@ -72,6 +88,8 @@ function mockSnapmaker(opts: { profiles?: typeof PROFILES; downloadCode?: number
return new Response("designer 3mf", { status: 200, headers: { "content-type": "text/html" } });
if (url === REMIX_3MF) return new Response("remix 3mf", { status: 200, headers: { "content-type": "text/html" } });
if (url === MODEL_STL) return new Response("solid princess\nendsolid princess\n", { status: 200 });
if (url === RED_URL) return new Response(await solid(255, 0, 0), { headers: { "content-type": "image/png" } });
if (url === BLUE_URL) return new Response(await solid(0, 0, 255), { headers: { "content-type": "image/png" } });
if (url === IMAGE_URL)
return new Response(ONE_PIXEL_PNG, { status: 200, headers: { "content-type": "image/png" } });
throw new Error(`Unexpected fetch to ${url}`);
@@ -153,6 +171,58 @@ describe("importPrintFromUrl -- Snapmaker Space", () => {
expect(added.plates.map((plate) => plate.sourceInstanceId).toSorted()).toEqual(["501", "502"]);
});
it("tells profiles apart by their own names and pictures when their files come named alike", async () => {
await setUserSnapmakerToken(userId, token());
const profiles = [
{ ...PROFILES[0], pictures: [RED_URL] },
{ ...PROFILES[1], pictures: [BLUE_URL] },
];
global.fetch = mockSnapmaker({ profiles, sameName: true });
const first = await importPrintFromUrl(userId, MODEL_URL, { url: MODEL_URL, tags: [] });
// Alone, it keeps the site's name.
expect(first.plates.map((plate) => plate.filename)).toEqual(["princess.3mf"]);
const remixUrl = `${MODEL_URL}#profileId-502`;
const added = await importPrintFromUrl(userId, remixUrl, { url: remixUrl, tags: [] });
expect(added.plates.map((plate) => [plate.sourceInstanceId, plate.filename])).toEqual([
["501", "princess - Designer's.3mf"],
["502", "princess - Community remix.3mf"],
]);
const [red, blue] = await Promise.all(added.plates.map((plate) => thumbColour(plate.id)));
expect(red[0]).toBeGreaterThan(200);
expect(red[2]).toBeLessThan(60);
expect(blue[2]).toBeGreaterThan(200);
expect(blue[0]).toBeLessThan(60);
});
// As on model 33211: titles that already hold the file's name are names enough on their own.
it("names a profile by its title alone when the title holds the file's name", async () => {
await setUserSnapmakerToken(userId, token());
const profiles = [
{ ...PROFILES[0], title: "princess_1plates" },
{ ...PROFILES[1], title: "princess" },
];
global.fetch = mockSnapmaker({ profiles, sameName: true });
await importPrintFromUrl(userId, MODEL_URL, { url: MODEL_URL, tags: [] });
const remixUrl = `${MODEL_URL}#profileId-502`;
const added = await importPrintFromUrl(userId, remixUrl, { url: remixUrl, tags: [] });
expect(added.plates.map((plate) => plate.filename)).toEqual(["princess_1plates.3mf", "princess.3mf"]);
});
// As on model 185: profiles named alike can't be told apart by name, so the site's name stays, suffixed.
it("leaves the names be when the profiles' titles are alike too", async () => {
await setUserSnapmakerToken(userId, token());
const profiles = [
{ ...PROFILES[0], title: "Princess" },
{ ...PROFILES[1], title: "Princess" },
];
global.fetch = mockSnapmaker({ profiles, sameName: true });
await importPrintFromUrl(userId, MODEL_URL, { url: MODEL_URL, tags: [] });
const remixUrl = `${MODEL_URL}#profileId-502`;
const added = await importPrintFromUrl(userId, remixUrl, { url: remixUrl, tags: [] });
expect(added.plates.map((plate) => plate.filename)).toEqual(["princess.3mf", "princess (2).3mf"]);
});
it("brings the model files when the model has no print profiles", async () => {
await setUserSnapmakerToken(userId, token());
global.fetch = mockSnapmaker({ profiles: [] });
+7 -2
View File
@@ -162,9 +162,14 @@ export function makerworldDesignForImport(design: MakerworldDesign): Record<stri
if (Array.isArray(source.categories))
out.categories = source.categories.map((category) => ({ id: getPath(category, "id") }));
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.
// Each profile's own picture and name, for its file's thumbnail and name; 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") }));
const instances = source.instances.map((inst) => ({
id: getPath(inst, "id"),
title: getPath(inst, "title"),
cover: getPath(inst, "cover"),
}));
if (JSON.stringify({ ...out, instances }).length <= MAX_IMPORT_DESIGN_CHARS) out.instances = instances;
}
return out;