From ec7ae87f2e4cf8631c5a27454a0a2b6db493752a Mon Sep 17 00:00:00 2001 From: raybobo Date: Fri, 14 Aug 2026 09:48:10 +0800 Subject: [PATCH] feat(3d): expose the Meshy 7 surface and correct the stale model docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 3D commands were written when `latest` meant Meshy 6. It has since moved to Meshy 7 on image-to-3d, multi-image-to-3d and retexture, so the CLI has been running Meshy 7 for a while while telling users otherwise — `--model-type` help still advertised "meshy-6 full-detail generation". Corrects every one of those, and adds the two knobs Meshy 7 introduced. Verified against production with zero-credit 400 probes rather than from the changelog, which turned up two things the docs get wrong: - `ultra_mode` does NOT exist on multi-image-to-3d. Unknown fields are silently ignored there, so exposing a flag would have looked like it worked. It is single-image only, hence `--ultra-mode` on image-to-3d alone, rejected locally against `--model-type smart-topology` (the server answers "ultra_mode is only supported for meshy-7"). Omitting ai_model passes that gate, so the no-model-choice contract holds. - `multiview_image_urls` requires the *literal* `ai_model: "meshy-7"`. The endpoint returns "multiview_image_urls requires ai_model meshy-7" for an omitted model AND for "latest", the public docs notwithstanding. So multi-view retexture is the one command here that pins a version — in the defaults layer, so `--data` still wins if the gate loosens. text-to-3d is left alone on purpose: its model set is meshy-5/meshy-6/ latest with no meshy-7 at all, and its `latest` is still Meshy 6. That asymmetry is now written down where someone will read it. Also routes `--multiview-image-urls` through the file-input resolver so local paths work like every other media flag, and refuses more than one of the three mutually exclusive style inputs instead of letting the API silently drop one. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 8 +++++- skills/meshy-cli/SKILL.md | 7 +++++ src/cmd/image-to-3d.ts | 26 ++++++++++++++---- src/cmd/multi-image-to-3d.ts | 6 ++++- src/cmd/retexture.ts | 51 +++++++++++++++++++++++++++++++----- src/cmd/text-to-3d.ts | 9 ++++--- src/internal/file-input.ts | 5 ++-- tests/file-input.test.ts | 17 ++++++++++++ tests/surface.test.ts | 37 +++++++++++++++++++++++++- 9 files changed, 147 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 8cbedc3..5b6f63f 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,12 @@ meshy-cli image-to-3d create --image-url https://example.com/cat.png # smart topology: component-aware low-poly with a native polycount meshy-cli image-to-3d create --image-url cat.png --model-type smart-topology --target-polycount 10000 +# ultra: an extra Meshy 7 geometry pass for finer surface detail (standard mode, single image) +meshy-cli image-to-3d create --image-url cat.png --ultra-mode true + +# retexture from several views of the same object instead of one style reference +meshy-cli retexture create --input-task-id --multiview-image-urls front.png,side.png,back.png + # fire-and-forget (--async): returns the task_id immediately, query later TASK=$(meshy-cli text-to-image create --prompt "mountain landscape" --async | jq -r .task_id) meshy-cli text-to-image get "$TASK" @@ -217,7 +223,7 @@ overwrite. Without `-o`, the CLI keeps its pre-download JSON behavior Flags that take a media source (`--image-url`, `--image-urls`, `--reference-image-urls`, `--texture-image-url`, `--image-style-url`, -`--model-url`) accept: +`--multiview-image-urls`, `--model-url`) accept: - **http(s) URLs** — preflighted with HEAD so unreachable sources fail fast. - **Local file paths** — absolute or relative to cwd. MIME-sniffed via magic diff --git a/skills/meshy-cli/SKILL.md b/skills/meshy-cli/SKILL.md index 36d61fd..79c1c96 100644 --- a/skills/meshy-cli/SKILL.md +++ b/skills/meshy-cli/SKILL.md @@ -66,6 +66,13 @@ These are API rules, not preferences — ignoring them produces failed tasks: explicitly asked for. - `image-to-3d` defaults to an untextured draft mesh; `--should-texture true` (what `make` uses) produces a textured model in one task. +- **The model set is not the same on every endpoint.** The image-driven + endpoints run Meshy 7 by default; `text-to-3d` has no Meshy 7 at all and its + default is still Meshy 6. So `--ultra-mode` (an extra Meshy 7 geometry pass, + billed on top) exists on `image-to-3d` alone, and `retexture`'s + `--multiview-image-urls` needs Meshy 7 — it takes 1-4 views of **the same + object**, not 1-4 style references, and cannot be combined with + `--text-style-prompt` or `--image-style-url`. ## Finding an animation id diff --git a/src/cmd/image-to-3d.ts b/src/cmd/image-to-3d.ts index 464c6b5..f0886cb 100644 --- a/src/cmd/image-to-3d.ts +++ b/src/cmd/image-to-3d.ts @@ -2,11 +2,14 @@ * image-to-3d — https://docs.meshy.ai/en/api/image-to-3d * * The mode IS the model — there is no model flag: - * - standard → meshy-6 (server default "latest") — full-detail generation; + * - standard → meshy-7 (server default "latest") — full-detail generation; * polycount is remesh's job afterwards. * - smart-topology → meshy-t2 (server default) — component-aware low-poly * with a native --target-polycount; the budget pick for game-ready * geometry. + * + * --ultra-mode rides on standard only: the API gates ultra_mode on meshy-7, + * so pairing it with smart-topology is rejected here rather than on the wire. */ import { Option } from "commander"; @@ -34,7 +37,7 @@ const spec: ResourceCommandSpec = { .addOption( new Option( "--model-type ", - "standard (default; meshy-6 full-detail generation) | smart-topology (meshy-t2 component-aware low-poly: clean topology, separated parts)", + "standard (default; meshy-7 full-detail generation) | smart-topology (meshy-t2 component-aware low-poly: clean topology, separated parts)", ).choices(["standard", "smart-topology"]), ) .option( @@ -42,6 +45,11 @@ const spec: ResourceCommandSpec = { "smart-topology only: target triangle count, 100-15000 (default: 10000). For standard outputs use remesh", parseInt10, ) + .option( + "--ultra-mode ", + "standard mode only: extra Meshy 7 pass for finer surface detail (default: false; billed on top of the mesh)", + parseBool, + ) .option( "--should-texture ", "generate textures (default: false — draft white mesh, the preview stage; texture later with retexture)", @@ -87,6 +95,12 @@ const spec: ResourceCommandSpec = { "--target-polycount applies to --model-type smart-topology only; for standard outputs chain `remesh` afterwards", ); } + // ultra_mode is gated on meshy-7, which only standard mode resolves to. + if (opts.ultraMode === true && smart) { + throw new UsageError( + "--ultra-mode applies to --model-type standard only (it needs meshy-7; smart-topology runs meshy-t2)", + ); + } // The API rejects texture knobs on untextured runs; catch it before the wire. if (opts.shouldTexture !== true && !opts.data) { for (const [flag, value] of [ @@ -106,6 +120,7 @@ const spec: ResourceCommandSpec = { input_task_id: opts.inputTaskId, model_type: opts.modelType, target_polycount: opts.targetPolycount, + ultra_mode: opts.ultraMode, should_texture: opts.shouldTexture, enable_pbr: opts.enablePbr, texture_prompt: opts.texturePrompt, @@ -122,10 +137,11 @@ const spec: ResourceCommandSpec = { // texturing is its own step, and the API rejects texture knobs on // untextured runs, so they ride only when texturing is on — then the // game-ready defaults apply: full PBR map set, 4k base color. The - // model is not a choice — it follows the mode (standard → meshy-6, + // model is not a choice — it follows the mode (standard → meshy-7, // smart-topology → meshy-t2, both server defaults). Smart topology - // gets the rigging-friendly 10k polycount. GLB-only output — omitting - // target_formats makes the API produce every format. + // gets the rigging-friendly 10k polycount. ultra_mode stays off unless + // asked for: it bills extra. GLB-only output — omitting target_formats + // makes the API produce every format. const texturing = opts.shouldTexture === true; return { should_texture: false, diff --git a/src/cmd/multi-image-to-3d.ts b/src/cmd/multi-image-to-3d.ts index 9d8b1a1..be39358 100644 --- a/src/cmd/multi-image-to-3d.ts +++ b/src/cmd/multi-image-to-3d.ts @@ -1,5 +1,9 @@ /** * multi-image-to-3d — https://docs.meshy.ai/en/api/multi-image-to-3d + * + * No model flag, same as the other 3D generation commands: the server default + * "latest" applies, which is meshy-7 here. Note ultra_mode does NOT exist on + * this endpoint (the API silently ignores it) — it is single-image only. */ import { Option } from "commander"; @@ -85,7 +89,7 @@ const spec: ResourceCommandSpec = { // Draft-first, matching image-to-3d: white mesh by default, texturing // is its own step; texture knobs ride only when texturing is on (the // API rejects them otherwise), then game-ready values apply. No model - // choice — the server default (latest = meshy-6) applies. + // choice — the server default (latest = meshy-7) applies. const texturing = opts.shouldTexture === true; return { should_texture: false, diff --git a/src/cmd/retexture.ts b/src/cmd/retexture.ts index 04fa5ea..d112191 100644 --- a/src/cmd/retexture.ts +++ b/src/cmd/retexture.ts @@ -1,5 +1,17 @@ /** * retexture — https://docs.meshy.ai/en/api/retexture + * + * Three mutually exclusive style inputs: a text prompt, one style image, or + * --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 ultra_mode 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. */ import { Option } from "commander"; @@ -23,6 +35,11 @@ const spec: ResourceCommandSpec = { "--image-style-url ", "style reference image (URL or local path); mutually exclusive with --text-style-prompt", ) + .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", + parseCsv, + ) .option("--enable-original-uv ", "reuse the model's original UVs (default: true)", parseBool) .option( "--enable-pbr ", @@ -47,17 +64,32 @@ const spec: ResourceCommandSpec = { if (!opts.inputTaskId && !opts.modelUrl && !opts.data) { throw new UsageError("provide --input-task-id or --model-url"); } - if (!opts.textStylePrompt && !opts.imageStyleUrl && !opts.data) { - throw new UsageError("provide --text-style-prompt or --image-style-url"); + 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", + ); } - if (opts.textStylePrompt && opts.imageStyleUrl) { - throw new UsageError("--text-style-prompt and --image-style-url are mutually exclusive"); + // The API takes exactly one style input; picking for the caller would + // silently drop the other one, so refuse instead. + const styleFlags = [ + ["--text-style-prompt", Boolean(opts.textStylePrompt)], + ["--image-style-url", Boolean(opts.imageStyleUrl)], + ["--multiview-image-urls", Boolean(multiview?.length)], + ] as const; + const given = styleFlags.filter(([, set]) => set).map(([flag]) => flag); + if (given.length > 1) { + throw new UsageError(`${given.join(" and ")} are mutually exclusive`); + } + if (multiview && (multiview.length < 1 || multiview.length > 4)) { + throw new UsageError("--multiview-image-urls takes 1-4 images of the same object"); } return { input_task_id: opts.inputTaskId, model_url: opts.modelUrl, text_style_prompt: opts.textStylePrompt, image_style_url: opts.imageStyleUrl, + multiview_image_urls: multiview, enable_original_uv: opts.enableOriginalUv, enable_pbr: opts.enablePbr, texture_resolution: opts.textureResolution, @@ -65,9 +97,13 @@ const spec: ResourceCommandSpec = { target_formats: opts.targetFormats, }; }, - toDefaults() { - // Game-ready defaults (agent-ts parity); see image-to-3d. + toDefaults(opts) { + // 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. + const multiview = (opts.multiviewImageUrls as string[] | undefined)?.length; return { + ai_model: multiview ? "meshy-7" : undefined, enable_pbr: true, texture_resolution: "4k", target_formats: ["glb"], @@ -78,3 +114,6 @@ const spec: ResourceCommandSpec = { }; export const retextureCommand = buildResourceCommand(spec); + +/** Exported for tests: the meshy-7 pin is a wire contract, not a preference. */ +export const retextureSpec = spec; diff --git a/src/cmd/text-to-3d.ts b/src/cmd/text-to-3d.ts index 70c9162..0a849b4 100644 --- a/src/cmd/text-to-3d.ts +++ b/src/cmd/text-to-3d.ts @@ -7,8 +7,10 @@ * The flag surface is deliberately minimal: topology and polycount are * remesh's job, resizing is resize's job, deprecated parameters * (symmetry_mode, lowpoly) are gone, and there is no model choice — the - * server default (latest = meshy-6) applies. The `--data` escape hatch - * still reaches anything the API accepts. + * server default "latest" applies. Unlike the image-driven endpoints, this + * one has no meshy-7: its model set is meshy-5 / meshy-6 / latest, and + * "latest" is still Meshy 6. The `--data` escape hatch still reaches + * anything the API accepts. */ import { Option } from "commander"; @@ -82,7 +84,8 @@ const spec: ResourceCommandSpec = { toDefaults(opts) { // Game-ready refine defaults: full PBR map set and 4k textures, priced // the same as the API's bare defaults. The model is not a choice — - // the server default (latest = meshy-6) applies; override via --data. + // the server default (latest, still Meshy 6 on this endpoint) applies; + // override via --data. // GLB-only output — omitting target_formats makes the API produce // every format. const refine = opts.mode === "refine"; diff --git a/src/internal/file-input.ts b/src/internal/file-input.ts index 7ace4e6..b832c09 100644 --- a/src/internal/file-input.ts +++ b/src/internal/file-input.ts @@ -22,8 +22,9 @@ export const IMAGE_FIELDS = { "imageStyleUrl", // retexture ] as const, list: [ - "imageUrls", // multi-image-to-3d - "referenceImageUrls", // image-to-image + "imageUrls", // multi-image-to-3d + "referenceImageUrls", // image-to-image + "multiviewImageUrls", // retexture ] as const, }; diff --git a/tests/file-input.test.ts b/tests/file-input.test.ts index fb5e3b2..b361412 100644 --- a/tests/file-input.test.ts +++ b/tests/file-input.test.ts @@ -145,6 +145,23 @@ test("resolveImageFields — list flag expands each entry (mix of local + URL)", } }); +test("resolveImageFields — retexture's multi-view list resolves local paths too", async () => { + const dir = mkdtempSync(join(tmpdir(), "meshy-img-")); + const front = join(dir, "front.png"); + writeFileSync(front, await tinyPng()); + + const restore = installFetch(() => new Response(null, { status: 200 })); + try { + const opts = { multiviewImageUrls: [front, "https://example.com/back.png"] }; + await resolveImageFields(opts); + const urls = opts.multiviewImageUrls as string[]; + assert.match(urls[0]!, /^data:image\/png;base64,/); + assert.equal(urls[1], "https://example.com/back.png"); + } finally { + restore(); + } +}); + test("resolveImageFields — data: URIs rejected with helpful message", async () => { await assert.rejects( () => resolveImageFields({ imageUrl: "data:image/png;base64,iVBORw0KGgo=" }), diff --git a/tests/surface.test.ts b/tests/surface.test.ts index 3504d10..9191e41 100644 --- a/tests/surface.test.ts +++ b/tests/surface.test.ts @@ -11,6 +11,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import type { Command } from "commander"; import { buildRootCommand } from "../src/root.js"; +import { retextureSpec } from "../src/cmd/retexture.js"; const root = buildRootCommand(); @@ -84,7 +85,7 @@ test("image-to-3d gained the smart-topology surface and task chaining", () => { test("the mode is the model: no model flag on the 3D generation commands", () => { // After culling meshy-5/meshy-t1, each mode has exactly one model - // (standard → meshy-6, smart-topology → meshy-t2), so --ai-model is gone + // (standard → meshy-7, smart-topology → meshy-t2), so --ai-model is gone // from 3D generation — the 2D image commands keep theirs. text-to-3d also // lost --model-type (lowpoly was the t1-era engine). for (const resource of ["text-to-3d", "image-to-3d", "multi-image-to-3d"]) { @@ -96,6 +97,40 @@ test("the mode is the model: no model flag on the 3D generation commands", () => } }); +test("the Meshy 7 surface: ultra-mode is single-image only, multi-view is retexture only", () => { + // ultra_mode exists on /image-to-3d and nowhere else — the API silently + // ignores it on multi-image-to-3d, which is worse than rejecting it, so the + // flag must not appear there and invite the assumption that it worked. + assert.ok(createFlags("image-to-3d").has("--ultra-mode"), "image-to-3d is missing --ultra-mode"); + for (const resource of ["text-to-3d", "multi-image-to-3d", "retexture"]) { + assert.ok(!createFlags(resource).has("--ultra-mode"), `${resource} should not have --ultra-mode`); + } + // multiview_image_urls is retexture's third style input, not a generation flag. + assert.ok( + createFlags("retexture").has("--multiview-image-urls"), + "retexture is missing --multiview-image-urls", + ); + for (const resource of ["image-to-3d", "multi-image-to-3d"]) { + assert.ok( + !createFlags(resource).has("--multiview-image-urls"), + `${resource} should not have --multiview-image-urls`, + ); + } +}); + +test("multi-view retexture pins ai_model meshy-7 — the endpoint rejects 'latest'", () => { + // Verified against production: /retexture answers + // "multiview_image_urls requires ai_model meshy-7" for an omitted ai_model + // AND for "latest". Dropping this pin is a guaranteed 400, so it is a wire + // contract rather than the usual leave-it-to-the-server default. + const defaults = retextureSpec.create.toDefaults!; + assert.equal(defaults({ multiviewImageUrls: ["a.png"] }).ai_model, "meshy-7"); + // Every other style input keeps the no-model-choice contract. + assert.equal(defaults({ textStylePrompt: "gold" }).ai_model, undefined); + assert.equal(defaults({ multiviewImageUrls: [] }).ai_model, undefined); + assert.equal(defaults({}).ai_model, undefined); +}); + test("2D image commands default to gpt-image-2 and share the aspect-ratio surface", () => { for (const resource of ["text-to-image", "image-to-image"]) { const create = sub(sub(root, resource), "create");