From 960df876ede9b5428a55d55ed37ce449ece39b64 Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Mon, 2 Nov 2026 10:52:05 +0800 Subject: [PATCH 1/4] feat(retexture): --ai-model picks Meshy N1; its texture defaults follow the model [ENG-5144] Meshy N1 (ai_model "meshy-n1") makes a 2K base color texture from reference images only: one --image-style-url (read as the front view) or 1-4 --multiview-image-urls, front first. Before this change it was reachable only through --data, and every such request drew a 400: the CLI's retexture defaults sent enable_pbr true and texture_resolution 4k, and N1 takes neither. - New --ai-model on `retexture create`: latest | meshy-7 | meshy-6 | meshy-n1, a commander choice list like the Smart Topology flag. The retired meshy-5 is not offered; meshy-6-lite stays --data only, as before, since it refuses the 4k default too. - For meshy-n1 the defaults layer sends enable_pbr false and texture_resolution 2k and pins no model, so N1 with multi-view stays N1. Every other model keeps PBR on, 4k and the meshy-7 multi-view pin. The model is read from the flag, else --data, the same precedence as the merged payload, so N1 picked through --data gets the N1 defaults. - Explicit asks N1 cannot honour are refused before the POST (exit 2): a text prompt, enable_pbr true, 4k/8k, hd_texture true, remove_lighting. A request without a reference image says N1 needs one. This follows the CLI's rule for documented API refusals (D-076), and runs on the merged payload so --data meets the same checks (D-078, D-083). The asks are not rewritten to 2k / no PBR: a quiet downgrade would return something other than what was asked for. - --ai-model latest or meshy-6 with --multiview-image-urls is refused locally; an ai_model in --data beside multi-view stays the server's call, like the pin it overrides. Docs: decision D-088, docs/usage.md, the bundled skill (N1 costs 10 credits; the face limit is 5,000,000). Tests: tests/retexture.test.ts drives the built CLI against a loopback API and asserts the POST body (failure list F1-F12 in its header). `pnpm typecheck`, `pnpm test` (594 pass) and `pnpm build` pass. Co-Authored-By: Claude Opus 5.5 --- docs/decisions.md | 41 ++++++ docs/usage.md | 9 ++ skills/meshy-cli/SKILL.md | 12 +- src/cmd/retexture.ts | 102 ++++++++++++--- tests/retexture.test.ts | 269 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 414 insertions(+), 19 deletions(-) create mode 100644 tests/retexture.test.ts diff --git a/docs/decisions.md b/docs/decisions.md index 2d95175..187345b 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1413,3 +1413,44 @@ behind it, and what a reviewer should check. IDs are stable; append, do not renu plugin add only `DSH_*` variables, so its Meshy plugin cannot set `MESHY_CLI_CLIENT`. Without that variable, such a call is tagged `dsh` in the User-Agent. An explicit `MESHY_CLI_CLIENT` still wins. + +## D-088 Retexture picks Meshy N1 with `--ai-model`; N1 flips the texture defaults (ENG-5144) + +- Meshy N1 (`ai_model: "meshy-n1"`) is a retexture model. It makes a 2K base + color texture from reference images only: one `image_style_url`, read as the + front view, or 1–4 `multiview_image_urls`, the first the front and the rest in + any order. It costs 10 credits and takes models within the face limit in the + API docs (5,000,000 faces at launch, to be raised). The + API answers 400 for N1 with a text prompt and no image, `enable_pbr: true`, + `texture_resolution` 4k or 8k, `hd_texture: true` without a resolution, and + `remove_lighting` with any value. +- `--ai-model` is a choice list, like the Smart Topology flag (D-078). It offers + `latest`, `meshy-7`, `meshy-6` and `meshy-n1`: the models the CLI's defaults + work with. The retired meshy-5 is not offered; the API refuses it. + meshy-6-lite stays reachable through `--data` only, as before. It refuses the + 4k default too, and giving it a 2k default is a separate change. +- Every other model keeps the game-ready defaults: PBR maps on, a 4k base color, + and the meshy-7 pin for multi-view. For N1 the defaults layer sends + `enable_pbr: false` and `texture_resolution: "2k"` and pins no model, so N1 + with multi-view stays N1. The defaults flip per model instead of changing for + everyone. They exist so a game-engine import carries the full map set at 4k. + Dropping them globally would hand every Meshy 7 caller a 2k base color only, + to suit a model that nobody gets by accident: N1 runs only when it is named, + so its defaults can follow its name. +- The defaults read the model where the merged payload takes it from: the + flag, else `--data`. Before this change, `--data '{"ai_model":"meshy-n1"}'` + reached the API but carried the PBR and 4k defaults, a 400. Now it gets the + N1 defaults, so `--data` stays the escape hatch it is documented to be. +- An explicit ask N1 cannot honour is refused before the POST (exit 2), as in + D-076: a text prompt, `enable_pbr: true`, 4k or 8k, `hd_texture: true`, + `remove_lighting`. A prompt beside an image in `--data` is refused too: v1 + would drop it without a word. A request with no reference image says N1 needs + one. The checks run on the merged payload, so `--data` meets the same rules + (D-078, D-083). Nothing is rewritten to what N1 can make: a quiet downgrade + would return something other than what was asked for. +- `--ai-model latest` or `meshy-6` with `--multiview-image-urls` is refused + locally: those values are the flag's own. An `ai_model` in `--data` beside + multi-view is left to the server's gate, like the meshy-7 pin it overrides. +- The face limit is not checked locally. The CLI cannot count a remote model, + and the API refuses a known count over the limit before the task starts. + `meshy inspect faces --model` measures a local GLB. diff --git a/docs/usage.md b/docs/usage.md index 2b4b13d..25c0955 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -136,6 +136,15 @@ meshy-cli multi-color-print create --input-task-id --style cartoon --printe # the web app's Ultra 2K geometry setting meshy-cli multi-image-to-3d create --image-urls front.png,side.png,back.png --geometry-resolution 2k +# retexture an existing model from a prompt or a reference image (PBR maps, 4k base color). +# --ai-model meshy-n1 (Meshy N1, 10 credits) makes a 2k base color texture from reference +# images only — one image, read as the front view, or 1-4 views with the front first — +# with no text prompt and no PBR maps, on models within the face limit in the API docs; the CLI sends +# enable_pbr false and 2k for it, and refuses a prompt, PBR, 4k/8k or --remove-lighting. +meshy-cli retexture create --input-task-id --image-style-url look.png +meshy-cli retexture create --input-task-id --ai-model meshy-n1 --image-style-url front.png +meshy-cli retexture create --input-task-id --ai-model meshy-n1 --multiview-image-urls front.png,back.png + # fire-and-forget (--async): exactly one POST, returns the task_id, query later TASK=$(meshy-cli text-to-image create --prompt "mountain landscape" --async | jq -r .result.submission.task_id) meshy-cli text-to-image get "$TASK" diff --git a/skills/meshy-cli/SKILL.md b/skills/meshy-cli/SKILL.md index 6d65b8c..371cc81 100644 --- a/skills/meshy-cli/SKILL.md +++ b/skills/meshy-cli/SKILL.md @@ -178,8 +178,16 @@ These are API rules, not preferences — ignoring them produces failed tasks: `--model-type smart-topology` (text-to-3d and image-to-3d) has a native `--target-polycount`: meshy-t2.5 is the CLI default, 100-30000 (default 15000); `--ai-model meshy-t2` selects T2, 100-15000 (default 10000). `retexture`'s - `--multiview-image-urls` needs Meshy 7 — it takes 1-4 views of **the same - object**, not style references. + `--multiview-image-urls` runs on Meshy 7 (pinned by default) or Meshy N1 — it + takes 1-4 views of **the same object**, front first, not style references. +- **`retexture --ai-model meshy-n1`** (Meshy N1, 10 credits) makes a 2K base color + texture from a reference image only: `--image-style-url` (read as the front view) + or 1-4 `--multiview-image-urls` (front first, the rest in any order). No text + prompt, no PBR maps, no 4K/8K: the CLI sends `enable_pbr: false` and `2k` for it + and refuses `--text-style-prompt`, `--enable-pbr true`, `--texture-resolution + 4k|8k` and `--remove-lighting`. Models over the Meshy N1 face limit in the + API docs are refused; check a local GLB with `meshy inspect faces --model + --max-faces `. - Printing: `analyze-`/`repair-printability` take `--printer-type fdm|sla|full_color` and one `--intended-print-height-mm` / `--intended-print-longest-side-mm`; `multi-color-print` takes `--style diff --git a/src/cmd/retexture.ts b/src/cmd/retexture.ts index ffa34d0..a8dd8a9 100644 --- a/src/cmd/retexture.ts +++ b/src/cmd/retexture.ts @@ -5,13 +5,19 @@ * --multiview-image-urls (1-4 views OF THE SAME OBJECT, element 0 being the * front reference). * - * Multi-view is the one place this CLI names a model. The endpoint's gate is - * literal: `multiview_image_urls requires ai_model meshy-7` is returned even - * for ai_model "latest" and for an omitted ai_model, so leaving the model to - * the server — what every other 3D command here does — is a guaranteed 400. - * (image-to-3d's comparable geometry_resolution gate accepts both, which is - * why only this command pins a version.) It is pinned in the defaults layer, so - * `--data '{"ai_model":"..."}'` still wins if the gate ever loosens. + * Multi-view pins a model. The endpoint's gate is literal: multiview_image_urls + * takes an explicit meshy-7 or meshy-n1 and is refused even for ai_model + * "latest" and for an omitted ai_model, so leaving the model to the server — + * what every other 3D command here does — is a guaranteed 400. (image-to-3d's + * comparable geometry_resolution gate accepts both, which is why only this + * command pins a version.) The meshy-7 pin rides the defaults layer, so + * --ai-model and `--data '{"ai_model":"..."}'` still win. + * + * --ai-model meshy-n1 (Meshy N1) makes a 2k base color texture from reference + * images only: one style image (the front view) or 1-4 views. The API refuses + * it a prompt alone, PBR maps, 4k/8k and remove_lighting, so the game-ready + * defaults (PBR on, 4k) flip for it, and an explicit ask for any of those is + * refused before the POST (decisions.md D-088). */ import { Option } from "commander"; @@ -19,6 +25,47 @@ import { parseBool, parseCsv } from "../internal/flags.js"; import { UsageError } from "../internal/errors.js"; import { buildResourceCommand, type ResourceCommandSpec } from "../internal/task-command.js"; +/** + * The models --ai-model offers: the ones the CLI's defaults work with. The + * retired meshy-5 is gone; meshy-6-lite (2k only) stays reachable through --data. + */ +export const RETEXTURE_MODELS = ["latest", "meshy-7", "meshy-6", "meshy-n1"] as const; +const MESHY_N1 = "meshy-n1"; +/** The only models the API lets take multiview_image_urls. */ +const MULTIVIEW_MODELS: readonly string[] = ["meshy-7", MESHY_N1]; + +const N1_NEEDS_IMAGE = + "meshy-n1 needs a reference image: pass --image-style-url (read as the front view) or --multiview-image-urls (1-4 views, the front first) — it does not texture from a text prompt"; + +/** The model the request runs on: the flag beats --data, as in the merged payload. */ +function requestedModel(opts: Record, data: Record): unknown { + return opts.aiModel ?? data["ai_model"]; +} + +/** + * Refuse what meshy-n1 cannot make, on the merged payload, so a field sent + * through --data meets the same rule as its flag. Defaults never trip this: + * for N1 they are enable_pbr false and 2k. + */ +function refuseMeshyN1Conflicts(payload: Record): void { + if (payload["ai_model"] !== MESHY_N1) return; + const views = payload["multiview_image_urls"]; + if (!payload["image_style_url"] && !(Array.isArray(views) && views.length > 0)) { + throw new UsageError(N1_NEEDS_IMAGE); + } + const conflicts: string[] = []; + // v1 drops a prompt sent beside the image; refuse rather than let it vanish. + if (payload["text_style_prompt"]) conflicts.push("--text-style-prompt (it textures from the reference images only)"); + if (payload["enable_pbr"] === true) conflicts.push("--enable-pbr true (it makes a base color texture only)"); + const resolution = payload["texture_resolution"]; + if (resolution === "4k" || resolution === "8k") conflicts.push(`--texture-resolution ${resolution} (it makes 2k only)`); + if (payload["hd_texture"] === true) conflicts.push("hd_texture true (that is 4k; it makes 2k only)"); + if (payload["remove_lighting"] !== undefined) conflicts.push("--remove-lighting (the API refuses it with meshy-n1)"); + if (conflicts.length > 0) { + throw new UsageError(`meshy-n1 cannot be used with ${conflicts.join(", ")}; drop them or pick another --ai-model`); + } +} + const spec: ResourceCommandSpec = { name: "retexture", description: @@ -30,24 +77,30 @@ const spec: ResourceCommandSpec = { return cmd .option("--input-task-id ", "source Meshy task id (takes priority over --model-url)") .option("--model-url ", "3D model: an http(s) URL (.glb/.gltf/.obj/.fbx/.stl) or a local .glb/.gltf file — the API takes inline uploads as glTF only, so host .stl/.obj/.fbx at a URL") - .option("--text-style-prompt ", "text style description; mutually exclusive with --image-style-url") + .option("--text-style-prompt ", "text style description; mutually exclusive with --image-style-url; not with meshy-n1") .option( "--image-style-url ", - "style reference image (URL or local path); mutually exclusive with --text-style-prompt", + "style reference image (URL or local path); mutually exclusive with --text-style-prompt. meshy-n1 reads it as the front view", ) .option( "--multiview-image-urls ", - "1-4 views of the SAME object as CSV, first entry is the front reference; each is a URL or local file path. Mutually exclusive with the two style flags", + "1-4 views of the SAME object as CSV, first entry is the front reference; each is a URL or local file path. Mutually exclusive with the two style flags. Runs on meshy-7 (pinned by default) or --ai-model meshy-n1", parseCsv, ) + .addOption( + new Option( + "--ai-model ", + "latest (default; Meshy 7) | meshy-7 | meshy-6 | meshy-n1 — Meshy N1: a 2k base color texture from a reference image only (--image-style-url or --multiview-image-urls), no text prompt, no PBR maps, models within the face limit in the API docs", + ).choices([...RETEXTURE_MODELS]), + ) .option("--enable-original-uv ", "keep the model's existing UV layout — true suits Meshy outputs and models with good UVs (default: false, fresh UVs)", parseBool) .option( "--enable-pbr ", - "generate normal/metallic/roughness maps, plus an emission map on meshy-6 below 8k (default: true, so game-engine imports carry the full map set)", + "generate normal/metallic/roughness maps, plus an emission map on meshy-6 below 8k (default: true, so game-engine imports carry the full map set; false on meshy-n1, which makes a base color texture only)", parseBool, ) .addOption( - new Option("--texture-resolution ", "base color texture size (default: 4k)").choices([ + new Option("--texture-resolution ", "base color texture size (default: 4k; 2k on meshy-n1, its only size)").choices([ "2k", "4k", "8k", @@ -55,7 +108,7 @@ const spec: ResourceCommandSpec = { ) .option( "--remove-lighting ", - "strip baked lighting from the base color (default: true)", + "strip baked lighting from the base color (default: true); not with meshy-n1, which the API refuses it for", parseBool, ) .option("--target-formats ", "glb,obj,fbx,stl,usdz,3mf (default: glb)", parseCsv) @@ -68,7 +121,7 @@ const spec: ResourceCommandSpec = { const multiview = opts.multiviewImageUrls as string[] | undefined; if (!opts.textStylePrompt && !opts.imageStyleUrl && !multiview?.length && !opts.data) { throw new UsageError( - "provide --text-style-prompt, --image-style-url or --multiview-image-urls", + opts.aiModel === MESHY_N1 ? N1_NEEDS_IMAGE : "provide --text-style-prompt, --image-style-url or --multiview-image-urls", ); } // The API takes exactly one style input; picking for the caller would @@ -85,12 +138,19 @@ const spec: ResourceCommandSpec = { if (multiview && (multiview.length < 1 || multiview.length > 4)) { throw new UsageError("--multiview-image-urls takes 1-4 images of the same object"); } + // The flag's own values are the CLI's to check; an ai_model in --data is + // left to the server's gate, like the pin it overrides. + const model = opts.aiModel as string | undefined; + if (multiview?.length && model !== undefined && !MULTIVIEW_MODELS.includes(model)) { + throw new UsageError(`--multiview-image-urls runs on meshy-7 (the default with it) or meshy-n1; the API refuses it with --ai-model ${model}`); + } return { input_task_id: opts.inputTaskId, model_url: opts.modelUrl, text_style_prompt: opts.textStylePrompt, image_style_url: opts.imageStyleUrl, multiview_image_urls: multiview, + ai_model: model, enable_original_uv: opts.enableOriginalUv, enable_pbr: opts.enablePbr, texture_resolution: opts.textureResolution, @@ -99,10 +159,17 @@ const spec: ResourceCommandSpec = { alpha_thumbnail: opts.alphaThumbnail, }; }, - toDefaults(opts) { + toDefaults(opts, data) { + // meshy-n1 makes a 2k base color only: the game-ready defaults below + // would be a 400 on every N1 request, so they flip for it. The model is + // read where the merged payload takes it from (flag, then --data), so + // N1 picked either way gets these defaults. + if (requestedModel(opts, data) === MESHY_N1) { + return { enable_pbr: false, texture_resolution: "2k", target_formats: ["glb"] }; + } // Game-ready defaults (agent-ts parity); see image-to-3d. The one model - // pin in the 3D surface rides here — see the header note on the literal - // meshy-7 gate. + // pin in the 3D surface rides here — see the header note on the + // multi-view gate. const multiview = (opts.multiviewImageUrls as string[] | undefined)?.length; return { ai_model: multiview ? "meshy-7" : undefined, @@ -111,6 +178,7 @@ const spec: ResourceCommandSpec = { target_formats: ["glb"], }; }, + validatePayload: refuseMeshyN1Conflicts, }, endpointOf: (c) => c.retexture, }; diff --git a/tests/retexture.test.ts b/tests/retexture.test.ts new file mode 100644 index 0000000..b27d2c8 --- /dev/null +++ b/tests/retexture.test.ts @@ -0,0 +1,269 @@ +/** + * retexture's model choice, asserted on the wire: a loopback API records the + * create POST (and answers the unauthenticated HEAD preflight of the image + * URLs), so every test checks what the server would receive. No network. + * + * Meshy N1 (`ai_model: "meshy-n1"`) makes a 2K base color texture from + * reference images only: one image, or 1-4 views with the front first. The + * API answers 400 for N1 with a text prompt alone, `enable_pbr: true`, 4k/8k + * (or `hd_texture: true`) and any `remove_lighting`, and only meshy-7 or + * meshy-n1 take `multiview_image_urls`. + * + * Failure modes these tests guard: + * F1 N1 inherits the game-ready `enable_pbr: true` default → a 400 on every + * N1 request. + * F2 N1 inherits the `texture_resolution: "4k"` default → a 400 on every + * N1 request. + * F3 The multi-view meshy-7 pin overrides an N1 pick, so a meshy-7 task + * runs and bills instead of the N1 one that was asked for. + * F4 The CLI reorders, drops or dedupes the views, so the API reads the + * wrong image as the front view. + * F5 N1 picked through `--data` (the escape hatch) keeps the Meshy 7 + * defaults and 400s — the defaults must follow the model wherever it + * was set. + * F6 An N1 request with a text prompt and no image is sent (a 400), or is + * refused with a message that does not say N1 needs a reference image. + * F7 An N1 prompt beside an image is sent through `--data`; the API drops + * the prompt silently, so the caller never learns it was ignored. + * F8 An explicit PBR / 4k / 8k / hd_texture / remove_lighting ask with N1 + * — by flag or by `--data` — is sent (a 400) instead of refused before + * the POST, or is silently rewritten to what N1 can do. + * F9 The other models lose their defaults: no `--ai-model`, meshy-7 and + * meshy-6 must keep PBR on and 4k; multi-view without a model must + * keep the meshy-7 pin. + * F10 `--ai-model` accepts a value the endpoint refuses (the retired + * meshy-5, a typo) and spends a request on it. + * F11 `--ai-model latest|meshy-6` with `--multiview-image-urls` is sent; + * the API refuses multi-view on every model but meshy-7 and meshy-n1. + * F12 The flag stops winning over `--data`: `--ai-model` must beat a + * `--data` ai_model, and the defaults must follow the winner. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { jsonReply, runCli, startMockApi, type MockApi, type RunResult } from "./helpers/cli.js"; + +async function withApi(fn: (api: MockApi) => Promise): Promise { + const api = await startMockApi((req, res) => { + // The unauthenticated preflight of an http(s) image URL. + if (req.method === "HEAD" && req.path.startsWith("/views/")) return void res.writeHead(200, { "content-type": "image/png" }).end(); + if (req.method === "POST" && req.path === "/openapi/v1/retexture") return jsonReply(res, 200, { result: "task-new" }); + return jsonReply(res, 500, { message: "unexpected" }); + }); + try { + await fn(api); + } finally { + await api.close(); + } +} + +const view = (api: MockApi, name: string) => `${api.origin}/views/${name}.png`; +const posts = (api: MockApi) => api.requests.filter((q) => q.method === "POST"); +const lastBody = (api: MockApi) => posts(api).at(-1)!.json as Record; + +function retexture(api: MockApi, ...args: string[]): Promise { + return runCli(["retexture", "create", "--input-task-id", "src-1", "--async", "--format", "json", ...args], { env: api.env() }); +} + +/** Refused before the POST (exit 2) with a message matching `message`. */ +async function assertRefused(api: MockApi, args: string[], message: RegExp): Promise { + const before = posts(api).length; + const r = await retexture(api, ...args); + assert.equal(r.code, 2, `${args.join(" ")}: expected a usage error\n${r.stdout}${r.stderr}`); + assert.match(r.stdout + r.stderr, message, args.join(" ")); + assert.equal(posts(api).length, before, `${args.join(" ")}: nothing may be sent`); +} + +// --------------------------------------------------------------------------- +// Meshy N1: what reaches the wire +// --------------------------------------------------------------------------- + +test("N1 with one image: ai_model meshy-n1, enable_pbr false, texture_resolution 2k (F1, F2)", async () => { + await withApi(async (api) => { + const r = await retexture(api, "--ai-model", "meshy-n1", "--image-style-url", view(api, "front")); + assert.equal(r.code, 0, r.stderr); + assert.deepEqual(lastBody(api), { + input_task_id: "src-1", + ai_model: "meshy-n1", + image_style_url: view(api, "front"), + enable_pbr: false, + texture_resolution: "2k", + target_formats: ["glb"], + }); + }); +}); + +test("N1 multi-view: 1, 2 and 4 views keep their order and stay on meshy-n1 (F3, F4)", async () => { + await withApi(async (api) => { + for (const names of [["front"], ["front", "back"], ["front", "left", "back", "right"]]) { + const urls = names.map((n) => view(api, n)); + const r = await retexture(api, "--ai-model", "meshy-n1", "--multiview-image-urls", urls.join(",")); + assert.equal(r.code, 0, `${names.join(",")}: ${r.stderr}`); + const body = lastBody(api); + assert.equal(body["ai_model"], "meshy-n1", `${names.join(",")}: the meshy-7 pin must not replace N1`); + assert.deepEqual(body["multiview_image_urls"], urls, `${names.join(",")}: the front view stays first, the rest as given`); + assert.equal(body["enable_pbr"], false); + assert.equal(body["texture_resolution"], "2k"); + assert.ok(!("image_style_url" in body) && !("text_style_prompt" in body)); + } + }); +}); + +test("N1 picked through --data gets the same defaults as the flag (F5)", async () => { + await withApi(async (api) => { + const single = await retexture(api, "--image-style-url", view(api, "front"), "--data", '{"ai_model":"meshy-n1"}'); + assert.equal(single.code, 0, single.stderr); + assert.equal(lastBody(api)["ai_model"], "meshy-n1"); + assert.equal(lastBody(api)["enable_pbr"], false); + assert.equal(lastBody(api)["texture_resolution"], "2k"); + + const multi = await retexture(api, "--multiview-image-urls", `${view(api, "front")},${view(api, "back")}`, "--data", '{"ai_model":"meshy-n1"}'); + assert.equal(multi.code, 0, multi.stderr); + assert.equal(lastBody(api)["ai_model"], "meshy-n1"); + assert.equal(lastBody(api)["enable_pbr"], false); + assert.equal(lastBody(api)["texture_resolution"], "2k"); + + // Everything in --data: the style input too. + const allData = await retexture(api, "--data", JSON.stringify({ ai_model: "meshy-n1", image_style_url: view(api, "front") })); + assert.equal(allData.code, 0, allData.stderr); + assert.equal(lastBody(api)["enable_pbr"], false); + assert.equal(lastBody(api)["texture_resolution"], "2k"); + }); +}); + +test("N1 takes what it can make when asked explicitly: --enable-pbr false, --texture-resolution 2k", async () => { + await withApi(async (api) => { + const r = await retexture(api, "--ai-model", "meshy-n1", "--image-style-url", view(api, "front"), "--enable-pbr", "false", "--texture-resolution", "2k"); + assert.equal(r.code, 0, r.stderr); + assert.equal(lastBody(api)["enable_pbr"], false); + assert.equal(lastBody(api)["texture_resolution"], "2k"); + }); +}); + +// --------------------------------------------------------------------------- +// Meshy N1: refused before the POST +// --------------------------------------------------------------------------- + +test("N1 without a reference image is refused: it does not texture from a prompt (F6)", async () => { + await withApi(async (api) => { + const needsImage = /meshy-n1 needs a reference image/; + await assertRefused(api, ["--ai-model", "meshy-n1", "--text-style-prompt", "rusty metal"], needsImage); + await assertRefused(api, ["--ai-model", "meshy-n1"], needsImage); + await assertRefused(api, ["--text-style-prompt", "rusty metal", "--data", '{"ai_model":"meshy-n1"}'], needsImage); + }); +}); + +test("N1 with a prompt beside an image is refused, not sent for the API to drop (F7)", async () => { + await withApi(async (api) => { + await assertRefused( + api, + ["--ai-model", "meshy-n1", "--image-style-url", view(api, "front"), "--data", '{"text_style_prompt":"rusty metal"}'], + /meshy-n1.*--text-style-prompt/, + ); + }); +}); + +test("N1 with PBR, 4k, 8k, hd_texture or remove_lighting is refused — by flag or by --data (F8)", async () => { + await withApi(async (api) => { + const image = ["--ai-model", "meshy-n1", "--image-style-url", view(api, "front")]; + const views = ["--ai-model", "meshy-n1", "--multiview-image-urls", `${view(api, "front")},${view(api, "back")}`]; + const cases: Array<[string[], RegExp]> = [ + [[...image, "--enable-pbr", "true"], /meshy-n1.*--enable-pbr/], + [[...image, "--texture-resolution", "4k"], /meshy-n1.*--texture-resolution 4k/], + [[...image, "--texture-resolution", "8k"], /meshy-n1.*--texture-resolution 8k/], + [[...views, "--texture-resolution", "4k"], /meshy-n1.*--texture-resolution 4k/], + [[...image, "--remove-lighting", "true"], /meshy-n1.*--remove-lighting/], + [[...image, "--remove-lighting", "false"], /meshy-n1.*--remove-lighting/], + [[...image, "--data", '{"enable_pbr":true}'], /meshy-n1.*--enable-pbr/], + [[...image, "--data", '{"texture_resolution":"8k"}'], /meshy-n1.*--texture-resolution 8k/], + [[...image, "--data", '{"hd_texture":true}'], /meshy-n1.*hd_texture/], + [["--image-style-url", view(api, "front"), "--data", '{"ai_model":"meshy-n1","enable_pbr":true}'], /meshy-n1.*--enable-pbr/], + ]; + for (const [args, message] of cases) await assertRefused(api, args, message); + }); +}); + +// --------------------------------------------------------------------------- +// Every other model keeps its defaults +// --------------------------------------------------------------------------- + +test("without --ai-model the defaults are unchanged: PBR on, 4k, no model; multi-view pins meshy-7 (F9)", async () => { + await withApi(async (api) => { + const prompt = await retexture(api, "--text-style-prompt", "rusty metal"); + assert.equal(prompt.code, 0, prompt.stderr); + assert.deepEqual(lastBody(api), { + input_task_id: "src-1", + text_style_prompt: "rusty metal", + enable_pbr: true, + texture_resolution: "4k", + target_formats: ["glb"], + }); + + const image = await retexture(api, "--image-style-url", view(api, "front")); + assert.equal(image.code, 0, image.stderr); + assert.ok(!("ai_model" in lastBody(api)), "a single style input leaves the model to the server"); + assert.equal(lastBody(api)["enable_pbr"], true); + assert.equal(lastBody(api)["texture_resolution"], "4k"); + + const urls = [view(api, "front"), view(api, "back")]; + const multi = await retexture(api, "--multiview-image-urls", urls.join(",")); + assert.equal(multi.code, 0, multi.stderr); + assert.equal(lastBody(api)["ai_model"], "meshy-7"); + assert.deepEqual(lastBody(api)["multiview_image_urls"], urls); + assert.equal(lastBody(api)["enable_pbr"], true); + assert.equal(lastBody(api)["texture_resolution"], "4k"); + }); +}); + +test("--ai-model meshy-7, meshy-6 and latest keep PBR on and 4k (F9)", async () => { + await withApi(async (api) => { + for (const model of ["meshy-7", "meshy-6", "latest"]) { + const r = await retexture(api, "--ai-model", model, "--image-style-url", view(api, "front")); + assert.equal(r.code, 0, `${model}: ${r.stderr}`); + assert.equal(lastBody(api)["ai_model"], model); + assert.equal(lastBody(api)["enable_pbr"], true, model); + assert.equal(lastBody(api)["texture_resolution"], "4k", model); + } + const multi = await retexture(api, "--ai-model", "meshy-7", "--multiview-image-urls", view(api, "front")); + assert.equal(multi.code, 0, multi.stderr); + assert.equal(lastBody(api)["ai_model"], "meshy-7"); + assert.equal(lastBody(api)["enable_pbr"], true); + assert.equal(lastBody(api)["texture_resolution"], "4k"); + }); +}); + +// --------------------------------------------------------------------------- +// The flag itself +// --------------------------------------------------------------------------- + +test("--ai-model refuses a model the endpoint does not run, before any request (F10)", async () => { + await withApi(async (api) => { + for (const model of ["meshy-5", "meshy-7.1", "meshy-n2"]) { + await assertRefused(api, ["--ai-model", model, "--image-style-url", view(api, "front")], /--ai-model/); + } + }); +}); + +test("--ai-model latest or meshy-6 with --multiview-image-urls is refused: multi-view needs meshy-7 or meshy-n1 (F11)", async () => { + await withApi(async (api) => { + for (const model of ["latest", "meshy-6"]) { + await assertRefused(api, ["--ai-model", model, "--multiview-image-urls", view(api, "front")], /--multiview-image-urls.*meshy-7.*meshy-n1/); + } + }); +}); + +test("--ai-model beats a --data ai_model, and the defaults follow the winner (F12)", async () => { + await withApi(async (api) => { + const toN1 = await retexture(api, "--ai-model", "meshy-n1", "--image-style-url", view(api, "front"), "--data", '{"ai_model":"meshy-7"}'); + assert.equal(toN1.code, 0, toN1.stderr); + assert.equal(lastBody(api)["ai_model"], "meshy-n1"); + assert.equal(lastBody(api)["enable_pbr"], false); + assert.equal(lastBody(api)["texture_resolution"], "2k"); + + const fromN1 = await retexture(api, "--ai-model", "meshy-7", "--image-style-url", view(api, "front"), "--data", '{"ai_model":"meshy-n1"}'); + assert.equal(fromN1.code, 0, fromN1.stderr); + assert.equal(lastBody(api)["ai_model"], "meshy-7"); + assert.equal(lastBody(api)["enable_pbr"], true); + assert.equal(lastBody(api)["texture_resolution"], "4k"); + }); +}); From 7b47cbc9a4ab3df48311c07bb28333ad21d93da1 Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Mon, 2 Nov 2026 17:04:00 +0800 Subject: [PATCH 2/4] test(retexture): an expired OAuth login refreshes before the meshy-n1 refusal [ENG-5144] Review follow-up on Meshy N1 (ENG-5144), non-blocking: - A test: with an expired OAuth profile, a meshy-n1 request the CLI refuses (--enable-pbr true) refreshes the login once and saves the new token, then exits 2 before any create is sent. - The hd_texture refusal's comment says it is the CLI's own rule: the API takes an explicit texture_resolution over hd_texture. - The test file says its loopback API answers every create with 200, so the tests prove the request body and the CLI's refusals, not backend behavior. Co-Authored-By: Claude Opus 5.5 --- src/cmd/retexture.ts | 3 +++ tests/retexture.test.ts | 39 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/cmd/retexture.ts b/src/cmd/retexture.ts index a8dd8a9..6bddd25 100644 --- a/src/cmd/retexture.ts +++ b/src/cmd/retexture.ts @@ -59,6 +59,9 @@ function refuseMeshyN1Conflicts(payload: Record): void { if (payload["enable_pbr"] === true) conflicts.push("--enable-pbr true (it makes a base color texture only)"); const resolution = payload["texture_resolution"]; if (resolution === "4k" || resolution === "8k") conflicts.push(`--texture-resolution ${resolution} (it makes 2k only)`); + // The API takes an explicit texture_resolution over hd_texture, so + // hd_texture true beside "2k" would run at 2k there; refusing it is the + // CLI's own rule, since the caller asked for an HD texture N1 cannot make. if (payload["hd_texture"] === true) conflicts.push("hd_texture true (that is 4k; it makes 2k only)"); if (payload["remove_lighting"] !== undefined) conflicts.push("--remove-lighting (the API refuses it with meshy-n1)"); if (conflicts.length > 0) { diff --git a/tests/retexture.test.ts b/tests/retexture.test.ts index b27d2c8..d01e645 100644 --- a/tests/retexture.test.ts +++ b/tests/retexture.test.ts @@ -2,6 +2,9 @@ * retexture's model choice, asserted on the wire: a loopback API records the * create POST (and answers the unauthenticated HEAD preflight of the image * URLs), so every test checks what the server would receive. No network. + * The loopback API answers every create with 200: these tests prove the + * request body and the CLI's own refusals, not what the backend does with a + * body it accepts or refuses. * * Meshy N1 (`ai_model: "meshy-n1"`) makes a 2K base color texture from * reference images only: one image, or 1-4 views with the front first. The @@ -41,7 +44,9 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { jsonReply, runCli, startMockApi, type MockApi, type RunResult } from "./helpers/cli.js"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { jsonReply, runCli, startMockApi, tmpDir, type MockApi, type RunResult } from "./helpers/cli.js"; async function withApi(fn: (api: MockApi) => Promise): Promise { const api = await startMockApi((req, res) => { @@ -267,3 +272,35 @@ test("--ai-model beats a --data ai_model, and the defaults follow the winner (F1 assert.equal(lastBody(api)["texture_resolution"], "4k"); }); }); + +test("an expired OAuth login is refreshed first, then the meshy-n1 refusal exits before any create", async () => { + let refreshes = 0; + const api = await startMockApi((req, res) => { + if (req.path === "/openapi/v1/oauth/token") { + refreshes += 1; + return jsonReply(res, 200, { access_token: "refreshed-token", token_type: "Bearer", expires_in: 3600, refresh_token: "fixture-refresh" }); + } + if (req.method === "HEAD" && req.path.startsWith("/views/")) return void res.writeHead(200, { "content-type": "image/png" }).end(); + return jsonReply(res, 200, { result: "task-new" }); + }); + try { + const dir = tmpDir(); + const creds = join(dir, "oauth-credentials.json"); + writeFileSync(creds, JSON.stringify({ auth_version: 1, active_profile: "default", profiles: { default: { + kind: "oauth", access_token: "expired-token", refresh_token: "fixture-refresh", expires_at: Date.now() - 60_000, created_at: 1, + } } })); + const env: Record = { ...api.env(), MESHY_CREDENTIALS_PATH: creds }; + delete env["MESHY_API_KEY"]; + + const r = await runCli(["retexture", "create", "--input-task-id", "src-1", "--async", "--format", "json", + "--ai-model", "meshy-n1", "--image-style-url", view(api, "front"), "--enable-pbr", "true"], { env, cwd: dir }); + assert.equal(r.code, 2, `${r.stdout}${r.stderr}`); + assert.match(r.stdout + r.stderr, /meshy-n1 cannot be used with --enable-pbr true/); + assert.equal(refreshes, 1, "the expired login was refreshed once, before the refusal"); + const saved = JSON.parse(readFileSync(creds, "utf8")) as { profiles: { default: { access_token: string } } }; + assert.equal(saved.profiles.default.access_token, "refreshed-token", "the refreshed token was saved"); + assert.equal(api.requests.filter((q) => q.method === "POST" && !q.path.endsWith("/oauth/token")).length, 0, "no create was sent"); + } finally { + await api.close(); + } +}); From ae3982ec8d28225b64b318b5385a0a867bad034a Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Tue, 3 Nov 2026 10:48:26 +0800 Subject: [PATCH 3/4] docs(retexture): say where the CLI is stricter than the API on N1 inputs [ENG-5144] Review follow-up on Meshy N1 (ENG-5144). Comments no longer present the CLI's own refusals as API 400s: - hd_texture true is a 400 on N1 only without a texture_resolution; beside an explicit "2k" the API runs at 2k, so refusing it is the CLI's rule. - The API does not take "exactly one style input": views beside a prompt or an image are a 400, but v1 clears a prompt beside one image. The CLI refuses every combination so no flag is dropped without a word. Co-Authored-By: Claude Opus 5.5 --- src/cmd/retexture.ts | 6 ++++-- tests/retexture.test.ts | 12 ++++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/cmd/retexture.ts b/src/cmd/retexture.ts index 6bddd25..57726f6 100644 --- a/src/cmd/retexture.ts +++ b/src/cmd/retexture.ts @@ -127,8 +127,10 @@ const spec: ResourceCommandSpec = { opts.aiModel === MESHY_N1 ? N1_NEEDS_IMAGE : "provide --text-style-prompt, --image-style-url or --multiview-image-urls", ); } - // The API takes exactly one style input; picking for the caller would - // silently drop the other one, so refuse instead. + // Exactly one style flag. The API is looser: views beside a prompt or an + // image are a 400 there, but v1 clears a prompt beside one image and + // textures from the image alone. Picking for the caller would drop one + // flag without a word, so the CLI refuses every combination. const styleFlags = [ ["--text-style-prompt", Boolean(opts.textStylePrompt)], ["--image-style-url", Boolean(opts.imageStyleUrl)], diff --git a/tests/retexture.test.ts b/tests/retexture.test.ts index d01e645..9e38520 100644 --- a/tests/retexture.test.ts +++ b/tests/retexture.test.ts @@ -9,8 +9,11 @@ * Meshy N1 (`ai_model: "meshy-n1"`) makes a 2K base color texture from * reference images only: one image, or 1-4 views with the front first. The * API answers 400 for N1 with a text prompt alone, `enable_pbr: true`, 4k/8k - * (or `hd_texture: true`) and any `remove_lighting`, and only meshy-7 or - * meshy-n1 take `multiview_image_urls`. + * (or `hd_texture: true` with no texture_resolution) and any + * `remove_lighting`, and only meshy-7 or meshy-n1 take + * `multiview_image_urls`. Two refusals here are the CLI's own, stricter than + * the API: a prompt beside one image (the API drops it) and `hd_texture: true` + * beside an explicit "2k" (the API takes the 2k). * * Failure modes these tests guard: * F1 N1 inherits the game-ready `enable_pbr: true` default → a 400 on every @@ -29,8 +32,9 @@ * F7 An N1 prompt beside an image is sent through `--data`; the API drops * the prompt silently, so the caller never learns it was ignored. * F8 An explicit PBR / 4k / 8k / hd_texture / remove_lighting ask with N1 - * — by flag or by `--data` — is sent (a 400) instead of refused before - * the POST, or is silently rewritten to what N1 can do. + * — by flag or by `--data` — is sent instead of refused before the POST + * (a 400 for all but hd_texture beside an explicit 2k, which the API + * would quietly run at 2k), or is silently rewritten to what N1 can do. * F9 The other models lose their defaults: no `--ai-model`, meshy-7 and * meshy-6 must keep PBR on and 4k; multi-view without a model must * keep the meshy-7 pin. From 4794205b21520effffed271341b45b5b5ce97724 Mon Sep 17 00:00:00 2001 From: ArkXiong Date: Tue, 3 Nov 2026 16:55:03 +0800 Subject: [PATCH 4/4] test(retexture): read the refreshed login through the sealed store [ENG-5144] main now seals secrets in the credential store (ENG-5285), so the N1 expired-login test reads the saved access token with openStoredProfile, as the other refresh tests do, instead of parsing the file. Co-Authored-By: Claude Opus 5.5 --- tests/retexture.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/retexture.test.ts b/tests/retexture.test.ts index 9e38520..6d0150e 100644 --- a/tests/retexture.test.ts +++ b/tests/retexture.test.ts @@ -48,9 +48,10 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { readFileSync, writeFileSync } from "node:fs"; +import { writeFileSync } from "node:fs"; import { join } from "node:path"; import { jsonReply, runCli, startMockApi, tmpDir, type MockApi, type RunResult } from "./helpers/cli.js"; +import { openStoredProfile } from "./helpers/credentials.js"; async function withApi(fn: (api: MockApi) => Promise): Promise { const api = await startMockApi((req, res) => { @@ -301,8 +302,7 @@ test("an expired OAuth login is refreshed first, then the meshy-n1 refusal exits assert.equal(r.code, 2, `${r.stdout}${r.stderr}`); assert.match(r.stdout + r.stderr, /meshy-n1 cannot be used with --enable-pbr true/); assert.equal(refreshes, 1, "the expired login was refreshed once, before the refusal"); - const saved = JSON.parse(readFileSync(creds, "utf8")) as { profiles: { default: { access_token: string } } }; - assert.equal(saved.profiles.default.access_token, "refreshed-token", "the refreshed token was saved"); + assert.equal(String(openStoredProfile(creds).access_token), "refreshed-token", "the refreshed token was saved"); assert.equal(api.requests.filter((q) => q.method === "POST" && !q.path.endsWith("/oauth/token")).length, 0, "no create was sent"); } finally { await api.close();