Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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"
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions skills/meshy-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 21 additions & 5 deletions src/cmd/image-to-3d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -34,14 +37,19 @@ const spec: ResourceCommandSpec = {
.addOption(
new Option(
"--model-type <kind>",
"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(
"--target-polycount <n>",
"smart-topology only: target triangle count, 100-15000 (default: 10000). For standard outputs use remesh",
parseInt10,
)
.option(
"--ultra-mode <bool>",
"standard mode only: extra Meshy 7 pass for finer surface detail (default: false; billed on top of the mesh)",
parseBool,
)
.option(
"--should-texture <bool>",
"generate textures (default: false — draft white mesh, the preview stage; texture later with retexture)",
Expand Down Expand Up @@ -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 [
Expand All @@ -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,
Expand All @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion src/cmd/multi-image-to-3d.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down
51 changes: 45 additions & 6 deletions src/cmd/retexture.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -23,6 +35,11 @@ const spec: ResourceCommandSpec = {
"--image-style-url <src>",
"style reference image (URL or local path); mutually exclusive with --text-style-prompt",
)
.option(
"--multiview-image-urls <csv>",
"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 <bool>", "reuse the model's original UVs (default: true)", parseBool)
.option(
"--enable-pbr <bool>",
Expand All @@ -47,27 +64,46 @@ 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,
remove_lighting: opts.removeLighting,
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"],
Expand All @@ -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;
9 changes: 6 additions & 3 deletions src/cmd/text-to-3d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down
5 changes: 3 additions & 2 deletions src/internal/file-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down
17 changes: 17 additions & 0 deletions tests/file-input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=" }),
Expand Down
37 changes: 36 additions & 1 deletion tests/surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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"]) {
Expand All @@ -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");
Expand Down