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");