references/fal-api.md
# fal.ai GPT Image 2.5 API reference
Contents: endpoints · auth · parameters · image sizes · transparency · response · pricing · recipes (curl, Node, Python) · edit endpoint · gotchas. Checked against fal.ai model pages on 2026-09-13.
## Endpoints
| Variant | Text-to-image | Edit (reference images + optional mask) |
|---|---|---|
| Flare | `openai/gpt-image-2.5/flare/text-to-image` | `openai/gpt-image-2.5/flare/edit` |
| Sunburst | `openai/gpt-image-2.5/sunburst/text-to-image` | `openai/gpt-image-2.5/sunburst/edit` |
Base URL: `https://fal.run/<endpoint>`. Sync API: one POST returns image URLs, no polling.
fal describes Flare as the default model, "fast, high-quality generation with natural lighting, rich textures, and support for complex layouts including transparent backgrounds", and Sunburst as "precision-focused ... extra fidelity on intricate detail, in exchange for longer generation times".
## Auth
```
Authorization: Key <FAL_KEY>
```
`Key`, not `Bearer`. Keys come from https://fal.ai/dashboard/keys. Keep the key in the `FAL_KEY` environment variable or a `.env` line; never in a prompt file or a committed script.
## Parameters (text-to-image)
| Param | Required | Values | Default |
|---|---|---|---|
| `prompt` | yes | string (JSON text is fine) | |
| `image_size` | no | `square_hd`, `square`, `portrait_4_3`, `portrait_16_9`, `landscape_4_3`, `landscape_16_9`, `auto`, or `{width, height}` | `landscape_4_3` |
| `quality` | no | `auto`, `low`, `medium`, `high`, `xhigh`, `max` | `high` on fal; this skill's script defaults to `medium` |
| `background` | no | `auto`, `transparent`, `opaque` | `auto` |
| `num_images` | no | integer ≥ 1 | `1` |
| `output_format` | no | `png`, `jpeg`, `webp` | `png` |
| `output_compression` | no | 0–100, JPEG/WebP only | |
| `sync_mode` | no | boolean; return a base64 data URI instead of a hosted URL | `false` |
## Parameters (edit)
Same as above plus:
| Param | Required | Notes |
|---|---|---|
| `image_urls` | yes | up to 16 public image URLs |
| `mask_url` | no | black/white image; white = repaint, black = keep |
| `image_size` | no | default `auto` (matches the first reference) |
Edit "changes only what's asked, keeping subject, composition, and background intact" per fal. Reference URLs must be direct, public image URLs: a previous fal output URL, fal storage (`fal.storage.upload` in the JS client, `fal_client.upload_file` in Python), or any public host.
## Custom image sizes
`image_size: {"width": W, "height": H}` must satisfy all of:
- both dimensions multiples of 16
- 655,360 ≤ W × H ≤ 8,294,400
- max edge 3840 px
- aspect ratio ≤ 3:1
Consequence for textures: a 64×64 atlas needs a 16× canvas (1024×1024); a 32×32 tile needs 32× (1024×1024); a 64×32 atlas needs 20× (1280×640). `scripts/uv_layout_prompt.mjs` computes this. Atlases wider than 3:1 (long strips) need a padded canvas and a crop afterwards.
Presets: `square_hd` = 1024×1024, `square` = 512×512 is below the minimum and is upscaled by fal, `landscape_4_3` = 1536×1024 (approx.), `portrait_16_9` = 1024×1536 range. Prefer explicit `{width, height}` so the texel scale is an integer.
## Transparency
`background: "transparent"` is a real parameter on GPT Image 2.5 (it was silently ignored on GPT Image 2). Flare documents transparent output. Sunburst exposes the parameter but its model page does not advertise transparency; check the PNG's alpha channel before depending on it. Use `output_format: "png"` or `"webp"` with transparent backgrounds; JPEG discards alpha.
If alpha is missing, chain a background remover (`fal-ai/imageutils/rembg` or `fal-ai/birefnet`) with the output URL as `image_url`, or paint the gutters transparent in Blockbench with the eraser tool.
## Response
```json
{
"images": [
{ "url": "https://v3b.fal.media/files/.../out.png", "content_type": "image/png",
"file_name": "out.png", "width": 1024, "height": 1024 }
]
}
```
Download immediately; fal file URLs expire. The URL can be fed back to `/edit` as `image_urls` while it is live.
## Pricing (fal, token-based, from the model pages)
Text tokens $5 / 1M in, $10 / 1M out. Image tokens $8 / 1M in, $30 / 1M out.
Flare, 1024×1024, per image:
| quality | approx. cost |
|---|---|
| low | $0.006 |
| medium | $0.013 |
| high | $0.053 |
| xhigh | $0.094 |
| max | $0.21 |
Sunburst at `high`: 1024×768 ≈ $0.036, 1920×1080 ≈ $0.040, 3840×2160 ≈ $0.10. Longer prompts raise cost slightly; quality is the main lever. Check https://fal.ai/pricing and the dashboard for current numbers.
## Recipes
### curl (text-to-image)
```bash
cat > body.json << 'EOF'
{ "prompt": "<JSON prompt text>", "image_size": {"width": 1024, "height": 1024},
"quality": "medium", "background": "transparent", "num_images": 1, "output_format": "png" }
EOF
curl -s -X POST "https://fal.run/openai/gpt-image-2.5/flare/text-to-image" \
-H "Authorization: Key $FAL_KEY" -H "Content-Type: application/json" -d @body.json
```
### Node (fetch)
```javascript
const res = await fetch("https://fal.run/openai/gpt-image-2.5/sunburst/text-to-image", {
method: "POST",
headers: { Authorization: `Key ${process.env.FAL_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ prompt, image_size: { width: 1024, height: 1024 }, quality: "medium", background: "transparent" }),
});
const { images } = await res.json();
```
`scripts/fal_gpt_image.mjs` wraps this with argument parsing, `.env` lookup, edit support and file saving.
### Python (requests)
```python
import os, requests
r = requests.post("https://fal.run/openai/gpt-image-2.5/flare/text-to-image",
headers={"Authorization": f"Key {os.environ['FAL_KEY']}"},
json={"prompt": prompt, "image_size": {"width": 1024, "height": 1024},
"quality": "medium", "background": "transparent"})
url = r.json()["images"][0]["url"]
open("out.png", "wb").write(requests.get(url).content)
```
### Edit with a UV template as reference
Render or export the native template texture (or `capture_screenshot` of the UV editor), host it, then:
```json
{ "prompt": "Paint this texture sheet. <same JSON prompt with the layout block>. Keep every region exactly where the colored template places it.",
"image_urls": ["https://.../template.png"], "image_size": "auto", "quality": "medium", "background": "transparent" }
```
POST to `https://fal.run/openai/gpt-image-2.5/flare/edit`. A mask (`mask_url`, white = repaint) confines a fix to one region on later rounds.
## Gotchas
- `quality` defaults to `high` on fal; set it explicitly or drafts cost 4× more than needed.
- `image_size: "auto"` on text-to-image lets the model choose; always pass explicit dimensions for atlases.
- `num_images` scales cost linearly; 2 candidates at `medium` is cheaper than 1 at `high` and usually more useful.
- `sync_mode: true` returns a data URI that can be passed straight to Blockbench `create_texture` `data`, skipping the download, but it can exceed tool payload limits for large canvases.
- The API returns full-canvas images; nothing enforces the layout. Verify against Blockbench UV data.
Links: https://fal.ai/models/openai/gpt-image-2.5/flare/text-to-image · https://fal.ai/models/openai/gpt-image-2.5/sunburst/text-to-image · https://fal.ai/models/openai/gpt-image-2.5/flare/edit · https://fal.ai/models/openai/gpt-image-2.5/sunburst/edit · https://fal.ai/dashboard
references/uv-layout-prompting.md
# Writing the UV layout into a GPT Image 2.5 prompt
Contents: coordinate conventions · material scale · prompt template · describing box-UV nets · per-face rectangles · mesh islands · shared and mirrored regions · style directives by target · worked examples (Bedrock entity, Java block tile, Generic Model atlas) · anti-patterns · iteration with the edit endpoint.
## Coordinate conventions
State these in the prompt once, under `canvas`:
- Origin is the top-left corner. `x` grows to the right, `y` grows downward.
- A region `px: [x1, y1, x2, y2]` covers columns `x1` to `x2 - 1` and rows `y1` to `y2 - 1` of the generated image.
- `texel_scale` is the size of one texture pixel in the generated image. For a 64×64 atlas at 1024×1024, one texel is a 16×16 px block.
Blockbench UV rectangles are in logical UV units. `scripts/uv_layout_prompt.mjs` converts them: `texel = uv × frame_size / uv_size`, then `px = texel × texel_scale`. Never hand-copy UV units into the prompt as pixels.
## Preserve material scale
Apply [UV scale and distortion guidance](../../blockbench-texturing/references/uv-scale-and-distortion.md) before generating the prompt. Confirm that the UV footprint is proportionate to the surface and that comparable faces sample a consistent number of final texture pixels per model unit. The script converts coordinates; it does not validate that relationship. Its generation `texel_scale` and a request for a "256x base" do not establish material density.
Keep computed rectangles unchanged, and add surface coverage, direction, and feature size to each relevant `paint` description. For example, at four final texture pixels per model unit, a face covering 32×4 model units needs 128×16 texture pixels. Describe a two-pixel grain feature as covering half a model unit; at a generation upscale of eight, that feature occupies 16 generated pixels. A 64×4 face at the same density needs 256×16 texture pixels and approximately twice as many grain repetitions along its length. Use the model's actual dimensions and chosen material scale, not these example values by default.
Match authored feature sizes across regions and PBR channels. Equal UV density can still look inconsistent if the generator paints coarse grain in one region and fine grain in another. Intentional uniform fills, directional effects, or resolution allocations may differ; describe the exception without stretching detailed channels accidentally.
## Prompt template
```json
{
"type": "UV texture atlas for a low-poly 3D game model. Flat orthographic texture sheet, not a 3D render, not a mockup.",
"subject": "<what the model is: species, character, block, material>",
"style": "<art style, palette size, shading rule, texel rule>",
"canvas": {
"width": 1024, "height": 1024, "texel_scale": 16,
"represents": "64x64 texel atlas; origin top-left, x right, y down"
},
"background": "fully transparent outside the listed regions",
"regions": [
{ "name": "<cube>/<direction> (<what it is>)", "px": [x1, y1, x2, y2], "paint": "<content of this face>" }
],
"shared": [ ["leg_left/north", "leg_left/south"] ],
"rules": [
"paint each region edge to edge, aligned to the 16 px texel grid",
"leave every pixel outside the regions transparent",
"no labels, numbers, grid lines, region outlines, drop shadows, or text",
"faces of the same cube continue seamlessly across shared edges",
"regions listed under shared receive identical pixels",
"flat, unlit colors; no baked lighting, no specular highlights"
]
}
```
Send the JSON as the prompt string verbatim. Keep `regions` in the same order the script emitted; the model reads top to bottom and later regions get less attention, so put the most visible faces first (head, front, top).
## Describing box-UV nets
A box-UV cube of size W×H×D occupies a cross-shaped net whose origin is `uv_offset`:
```
[ up ][down ] up/down: W wide, D tall, at y = 0 .. D
[east][north][west][south] side row: D, W, D, W wide; H tall, at y = D .. D+H
```
`get_cube_uv` already returns the six generated face rectangles, so use them rather than recomputing. Describe the net as one group in the prompt so the model paints it as one object:
```json
{ "name": "head (box UV net, 6 faces)", "group_px": [0, 0, 512, 256],
"faces": [
{ "name": "head/up", "px": [128, 0, 256, 128], "paint": "top of head, fur parting" },
{ "name": "head/down", "px": [256, 0, 384, 128], "paint": "underside of jaw, lighter fur" },
{ "name": "head/east", "px": [0, 128, 128, 256], "paint": "right side of head with one ear" },
{ "name": "head/north", "px": [128, 128, 256, 256], "paint": "face: two eyes, nose, mouth" },
{ "name": "head/west", "px": [256, 128, 384, 256], "paint": "left side of head with one ear" },
{ "name": "head/south", "px": [384, 128, 512, 256], "paint": "back of head" }
] }
```
Mirrored box UV (`mirror_uv: true`) or `up`/`down` faces with reversed rectangle order are reported as `mirrored_x` / `mirrored_y` by the script. Tell the model: "paint this face as if viewed in a mirror (left and right swapped)".
## Per-face rectangles
Per-face UV cubes give six independent rectangles that may overlap, rotate or reuse space. List each face with its `px` box and its `rotation` when non-zero: "content rotated 90° clockwise inside the box". Skip faces whose `texture_status` is `disabled`; the script omits them.
## Mesh islands
`get_mesh_info: include_uv=true` returns polygons per face. The script emits each face's bounding box as a region with `polygon: true`. Describe islands by what they wrap: "cloth/f1: the front of the saddle blanket, weave pattern, edge fringe along the bottom". For large unwrapped meshes, group faces that belong to one island in the prompt by their combined bounding box rather than listing dozens of triangles.
## Shared and mirrored regions
Blockbench formats often map left/right limbs to the same pixels. The script lists these under `shared_regions`. Put one region in `regions` and reference the rest in `shared`; do not list the same rectangle twice with different content or the model will average them.
Sharing a material does not require sharing an identical UV rectangle. Use proportionate subregions or suitable trim strips for differently sized faces, and preserve those separate footprints in the prompt. Reuse a complete rectangle when mapped surface dimensions are compatible, accounting for rotation, or when its scale difference is intentional. Group descriptions for readability without collapsing distinct face layouts into one stretched swatch. Verify destination tiling support before planning repeats; an atlas subregion does not wrap independently.
## Style directives by target
| Target | Style phrasing |
|---|---|
| Minecraft Bedrock/Java entity or block | "crisp pixel art, each texel a solid 16x16 px block, no anti-aliasing, ≤ 16 colors, flat unlit shading, subtle 1-texel darker outline only where the reference does" |
| Hytale | "clean stylized pixel art, painterly hand-shaded blocks allowed, soft two-tone shading per face, consistent light from top-left" |
| Generic Model / glTF | "hand-painted 1024 px atlas, PBR albedo only: no baked lighting, no specular, medium-frequency detail" |
| Skin formats (64×64 player skin) | "Minecraft player skin sheet, second layer regions transparent unless clothing is described" |
Say "texel" and "block" explicitly for pixel-art targets. Without them the model draws smooth gradients that collapse to mud when downscaled.
## Worked examples
### Bedrock entity, 64×64 atlas, generated at 1024×1024
Inspection: `list_textures` reports `frame_size [64, 64]`, `uv_size [64, 64]`; `get_cube_uv` for `head`, `body`, `leg_*`. Script output picks `texel_scale 16`, `image_size 1024×1024`, 6 head faces, 6 body faces, 4 leg faces sharing one net.
Prompt (abridged):
```json
{ "type": "UV texture atlas for a low-poly 3D game model. Flat orthographic sheet, not a render.",
"subject": "small brown alpaca, cartoon Minecraft mob",
"style": "crisp pixel art, each texel a solid 16x16 px block, no anti-aliasing, 12-color palette, flat unlit colors",
"canvas": { "width": 1024, "height": 1024, "texel_scale": 16, "represents": "64x64 texel atlas; origin top-left" },
"background": "transparent outside regions",
"regions": [
{ "name": "head/north (face)", "px": [128, 128, 256, 256], "paint": "front of head: two black 2-texel eyes at upper third, tan muzzle band along the bottom" },
{ "name": "head/up", "px": [128, 0, 256, 128], "paint": "top of head fur; darker tuft in the centre" },
{ "name": "head/east", "px": [0, 128, 128, 256], "paint": "right side of head; fur; continues from head/north" },
{ "name": "head/west", "px": [256, 128, 384, 256], "paint": "left side of head; mirror of head/east" },
{ "name": "head/south","px": [384, 128, 512, 256], "paint": "back of head, plain fur" },
{ "name": "head/down", "px": [256, 0, 384, 128], "paint": "chin, lighter fur" },
{ "name": "body/*", "px": [0, 256, 640, 512], "paint": "box-UV net of the torso: fluffy wool, saddle blanket with red and gold stripes on up and both sides" },
{ "name": "leg/*", "px": [0, 512, 256, 768], "paint": "box-UV net of one leg, reused by all four legs: brown fur with a dark hoof at the bottom 2 texels" }
],
"shared": [["leg_front_left", "leg_front_right", "leg_back_left", "leg_back_right"]],
"rules": [ "...standard rules..." ] }
```
Generate with `--background transparent --width 1024 --height 1024`, downscale with `--width 64 --height 64 --alpha-threshold 128`, import with `create_texture` `data`, apply to the root group, screenshot from front, side and top.
### Java block, 16×16 tile, generated at 1024×1024
A single-face tile has one region covering the whole canvas. Prompt for tiling explicitly: "seamless tile; the left edge continues into the right edge and the top into the bottom". Generate at `texel_scale 64`, downscale with center sampling, then check a 2×2 repeat in Blockbench by applying to four adjacent cubes.
### Generic Model, 1024×1024 hand-painted atlas
`texel_scale` is 1, so no downscale. Regions come from `get_mesh_info` islands; describe them by body part and give the model `"style": "hand-painted albedo, no baked lighting"`. Use Sunburst at `high` for the final; Flare at `medium` for layout tests. Import with `create_texture` `data` and, in `per_texture_uv_size` formats, the original `uv_width`/`uv_height`.
## Anti-patterns
- Describing the 3D scene ("an alpaca standing in a field") instead of the sheet. The model renders a picture.
- Omitting `canvas.texel_scale` and pixel-art phrasing for low-resolution targets.
- Listing the same rectangle twice with different content.
- Asking for "a UV map" or "UV template": the model draws colored placeholder squares with labels.
- Requesting outlines, labels or grid lines "to help alignment"; they end up in the texture.
- Copying UV units as pixels, or forgetting `frame_size / uv_size` in formats where they differ.
- Applying the same full swatch to every face regardless of dimensions, or fitting each face independently to generated material bounds without preserving proportions and density.
- Painting the same number of grain features into regions covering different surface sizes, making the material appear coarser on larger parts.
- Treating generation `texel_scale`, a higher image resolution, or UV bounds checks as proof of consistent material scale.
- Generating at the atlas's native size; fal rejects anything under 655,360 pixels.
## Iterating with the edit endpoint
1. Keep the accepted output URL (or upload the imported PNG).
2. Build a mask: white over the regions to redo, black elsewhere, same canvas size.
3. POST to `/edit` with `image_urls: [previous]`, `mask_url`, and a prompt that repeats the `canvas` block plus only the regions being changed, prefixed "Repaint only the masked regions; keep everything else pixel-identical".
4. Re-run the downscale and re-import under a new texture name, then `apply_texture` and compare screenshots before replacing the old texture.
Keep the planned UV footprints during cleanup. Repaint or extend drifting material boundaries into those footprints; any necessary UV resizing must retain the intended proportions and density and keep PBR channels aligned. Recheck both a temporary checker and the actual material after the edit.
scripts/fal_gpt_image.mjs
#!/usr/bin/env node
/**
* Call fal.ai GPT Image 2.5 (Flare or Sunburst) and save the results to disk.
*
* Usage:
* node fal_gpt_image.mjs --prompt-file prompt.json --model flare --width 1024 --height 1024 \
* [--quality medium] [--background transparent] [--num 1] [--format png] \
* [--out ./output] [--label atlas] [--edit-image <url> ...] [--mask <url>] [--dry-run]
*
* --model flare (default, fast) | sunburst (slower, more detail)
* --prompt-file file whose whole contents become the prompt (JSON prompts are sent verbatim)
* --prompt inline prompt string (alternative to --prompt-file)
* --size fal preset (square_hd, square, portrait_4_3, ...) instead of --width/--height
* --edit-image one or more public image URLs; switches to the /edit endpoint
* --dry-run print the request instead of sending it
*
* FAL_KEY is read from the environment, then from a .env file in the working directory.
*/
import fs from "node:fs";
import path from "node:path";
const args = process.argv.slice(2);
const flag = name => args.includes(`--${name}`);
const option = name => {
const index = args.indexOf(`--${name}`);
return index === -1 ? undefined : args[index + 1];
};
const options = name => args.flatMap((arg, index) => (arg === `--${name}` ? [args[index + 1]] : []));
const model = option("model") ?? "flare";
if (!["flare", "sunburst"].includes(model)) {
console.error(`Unknown model "${model}". Use flare or sunburst.`);
process.exit(1);
}
const promptFile = option("prompt-file");
const prompt = promptFile ? fs.readFileSync(promptFile, "utf8").trim() : option("prompt");
if (!prompt) {
console.error("Provide --prompt-file <path> or --prompt <text>.");
process.exit(1);
}
const editImages = options("edit-image");
const isEdit = editImages.length > 0;
const endpoint = `https://fal.run/openai/gpt-image-2.5/${model}/${isEdit ? "edit" : "text-to-image"}`;
const width = option("width") ? Number(option("width")) : undefined;
const height = option("height") ? Number(option("height")) : undefined;
const imageSize = width && height ? { width, height } : option("size") ?? (isEdit ? "auto" : "square_hd");
const body = {
prompt,
image_size: imageSize,
quality: option("quality") ?? "medium",
background: option("background") ?? "auto",
num_images: Number(option("num") ?? 1),
output_format: option("format") ?? "png",
...(isEdit ? { image_urls: editImages } : {}),
...(option("mask") ? { mask_url: option("mask") } : {}),
};
if (flag("dry-run")) {
console.log(JSON.stringify({ endpoint, body }, null, 2));
process.exit(0);
}
/** Resolve the fal key from the environment or a local .env file. */
function resolveKey() {
if (process.env.FAL_KEY) return process.env.FAL_KEY;
if (!fs.existsSync(".env")) return undefined;
return fs.readFileSync(".env", "utf8").match(/^FAL_KEY=(.+)$/m)?.[1]?.trim();
}
const key = resolveKey();
if (!key) {
console.error("FAL_KEY is not set. Export it or add FAL_KEY=... to .env.");
process.exit(1);
}
const response = await fetch(endpoint, {
method: "POST",
headers: { Authorization: `Key ${key}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const json = await response.json();
if (!response.ok || !Array.isArray(json.images) || json.images.length === 0) {
console.error(`fal request failed (${response.status}): ${JSON.stringify(json)}`);
process.exit(2);
}
const outDir = option("out") ?? "./output";
const label = option("label") ?? `gpt25_${model}`;
fs.mkdirSync(outDir, { recursive: true });
const stamp = Date.now();
const saved = await Promise.all(json.images.map(async (image, index) => {
const suffix = json.images.length > 1 ? `_${index + 1}` : "";
const file = path.join(outDir, `${label}_${stamp}${suffix}.${body.output_format}`);
const bytes = Buffer.from(await (await fetch(image.url)).arrayBuffer());
fs.writeFileSync(file, bytes);
return { file, width: image.width, height: image.height, url: image.url };
}));
console.log(JSON.stringify({ endpoint, saved }, null, 2));
scripts/resize_to_atlas.py
#!/usr/bin/env python3
"""Downscale a generated GPT Image canvas to the real atlas size with nearest-neighbor sampling.
Usage:
python resize_to_atlas.py generated.png atlas.png --width 64 --height 64
[--crop X Y W H] [--alpha-threshold 128] [--sample center|average]
--crop crop rectangle in generated-image pixels before resizing (for padded canvases)
--alpha-threshold binarize alpha so pixel-art textures have no half-transparent fringe
--sample center: nearest-neighbor (default, crisp pixel art); average: box filter
(better for high-resolution painterly atlases)
Requires Pillow (pip install pillow).
"""
import argparse
import sys
try:
from PIL import Image
except ImportError: # pragma: no cover
sys.exit("Pillow is required: pip install pillow")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("source")
parser.add_argument("destination")
parser.add_argument("--width", type=int, required=True)
parser.add_argument("--height", type=int, required=True)
parser.add_argument("--crop", type=int, nargs=4, metavar=("X", "Y", "W", "H"))
parser.add_argument("--alpha-threshold", type=int, default=None)
parser.add_argument("--sample", choices=("center", "average"), default="center")
return parser.parse_args()
def binarize_alpha(image: Image.Image, threshold: int) -> Image.Image:
alpha = image.getchannel("A").point(lambda value: 255 if value >= threshold else 0)
image.putalpha(alpha)
return image
def sample_centers(image: Image.Image, width: int, height: int) -> Image.Image:
"""Nearest-neighbor downscale that reads the center of each texel block, not its corner."""
source = image.load()
result = Image.new("RGBA", (width, height))
target = result.load()
scale_x = image.width / width
scale_y = image.height / height
for y in range(height):
sy = min(int((y + 0.5) * scale_y), image.height - 1)
for x in range(width):
sx = min(int((x + 0.5) * scale_x), image.width - 1)
target[x, y] = source[sx, sy]
return result
def main() -> None:
args = parse_args()
image = Image.open(args.source).convert("RGBA")
if args.crop:
x, y, w, h = args.crop
image = image.crop((x, y, x + w, y + h))
resized = (
sample_centers(image, args.width, args.height)
if args.sample == "center"
else image.resize((args.width, args.height), resample=Image.BOX)
)
if args.alpha_threshold is not None:
resized = binarize_alpha(resized, args.alpha_threshold)
resized.save(args.destination)
print(f"Saved {args.destination} ({resized.width}x{resized.height})")
if __name__ == "__main__":
main()
scripts/uv_layout_prompt.mjs
#!/usr/bin/env node
/**
* Build the "expected UV layout" block for a GPT Image 2.5 texture prompt from
* Blockbench MCP inspection output.
*
* Input JSON file shape (paste raw tool results, nothing else is required):
* {
* "textures": [ ...list_textures result... ], // optional when --atlas-size is given
* "cubes": [ ...one get_cube_uv result per cube... ],
* "meshes": [ { "name": "...", "faces": [ { "key": "...", "uv": [[u,v],...] } ] } ] // optional
* }
*
* Usage:
* node uv_layout_prompt.mjs inspection.json [--texture <name|uuid>] [--atlas-size WxH]
* [--format json|text] [--min-scale N] [--scale N]
*
* Output: a JSON object with the generation canvas, texel scale, and one region per
* mapped face, in both texel and generated-image pixel coordinates. Embed the
* `layout` object in the prompt (see references/uv-layout-prompting.md).
*/
import fs from "node:fs";
const MIN_PIXELS = 655_360;
const MAX_PIXELS = 8_294_400;
const MAX_EDGE = 3840;
const MAX_ASPECT = 3;
const MULTIPLE = 16;
const args = process.argv.slice(2);
const inputPath = args.find(arg => !arg.startsWith("--"));
const option = name => {
const index = args.indexOf(`--${name}`);
return index === -1 ? undefined : args[index + 1];
};
if (!inputPath) {
console.error("Usage: node uv_layout_prompt.mjs inspection.json [--texture <name|uuid>] [--atlas-size WxH] [--format json|text] [--min-scale N]");
process.exit(1);
}
const inspection = JSON.parse(fs.readFileSync(inputPath, "utf8"));
const textureFilter = option("texture");
const format = option("format") ?? "json";
const minScale = Number(option("min-scale") ?? 1);
/** Pick the atlas the regions refer to: explicit size, named texture, or the first listed texture. */
function resolveAtlas() {
const explicit = option("atlas-size");
if (explicit) {
const [width, height] = explicit.toLowerCase().split("x").map(Number);
return { name: "atlas", frame_size: [width, height], uv_size: [width, height] };
}
const textures = Array.isArray(inspection.textures) ? inspection.textures : [];
const match = textureFilter
? textures.find(texture => texture.uuid === textureFilter || texture.name === textureFilter || texture.id === textureFilter)
: textures[0];
if (!match) throw new Error("No atlas found. Pass --atlas-size WxH or include list_textures output under \"textures\".");
return match;
}
const isPowerOfTwo = value => value > 0 && (value & (value - 1)) === 0;
/**
* Generation size that keeps the atlas aspect and satisfies fal's size constraints.
* Prefers a power-of-two texel scale (16x for a 64x64 atlas) when it costs at most
* twice the pixels of the smallest legal canvas, then a multiple of four, then the minimum.
*/
function planCanvas(frameWidth, frameHeight) {
const aspect = frameWidth / frameHeight;
const wide = aspect > MAX_ASPECT || aspect < 1 / MAX_ASPECT;
const forced = option("scale") ? Number(option("scale")) : undefined;
const candidates = Array.from({ length: 512 }, (_, index) => index + 1)
.filter(scale => scale >= minScale && (forced === undefined || scale === forced))
.map(scale => ({ scale, width: frameWidth * scale, height: frameHeight * scale }))
.filter(({ width, height }) => width % MULTIPLE === 0 && height % MULTIPLE === 0)
.filter(({ width, height }) => width * height >= MIN_PIXELS && width * height <= MAX_PIXELS)
.filter(({ width, height }) => width <= MAX_EDGE && height <= MAX_EDGE);
if (wide || candidates.length === 0) {
return { error: `Atlas ${frameWidth}x${frameHeight} cannot be generated at its own aspect${forced ? ` with --scale ${forced}` : ""} (fal limits: aspect <= 3:1, ${MIN_PIXELS}-${MAX_PIXELS} px, edge <= ${MAX_EDGE}, multiples of ${MULTIPLE}). Generate a padded canvas and crop, or split the atlas into strips.` };
}
const budget = candidates[0].width * candidates[0].height * 2;
const affordable = candidates.filter(({ width, height }) => width * height <= budget);
return affordable.find(({ scale }) => isPowerOfTwo(scale))
?? affordable.find(({ scale }) => scale % 4 === 0)
?? candidates[0];
}
/** Normalize a UV rectangle that may be stored flipped (x1 > x2 means mirrored). */
function normalizeRect([x1, y1, x2, y2]) {
return {
rect: [Math.min(x1, x2), Math.min(y1, y2), Math.max(x1, x2), Math.max(y1, y2)],
mirrored_x: x1 > x2,
mirrored_y: y1 > y2,
};
}
const atlas = resolveAtlas();
const [frameWidth, frameHeight] = atlas.frame_size;
const [uvWidth, uvHeight] = atlas.uv_size ?? atlas.frame_size;
const canvas = planCanvas(frameWidth, frameHeight);
if (canvas.error) {
console.error(canvas.error);
process.exit(2);
}
const texelPerUv = [frameWidth / uvWidth, frameHeight / uvHeight];
/** Convert a logical UV rectangle to texel and generated-pixel rectangles. */
function toRegion(name, uvRect, extra = {}) {
const { rect, mirrored_x, mirrored_y } = normalizeRect(uvRect);
const texels = [
Math.round(rect[0] * texelPerUv[0]), Math.round(rect[1] * texelPerUv[1]),
Math.round(rect[2] * texelPerUv[0]), Math.round(rect[3] * texelPerUv[1]),
];
const px = texels.map(value => value * canvas.scale);
return {
name,
texels,
size_texels: [texels[2] - texels[0], texels[3] - texels[1]],
px,
...(mirrored_x ? { mirrored_x } : {}),
...(mirrored_y ? { mirrored_y } : {}),
...extra,
};
}
const atlasMatches = face => !textureFilter || face.effective_texture?.uuid === textureFilter || face.effective_texture?.name === textureFilter;
const cubeRegions = (inspection.cubes ?? []).flatMap(cube =>
Object.entries(cube.faces ?? {})
.filter(([, face]) => face.texture_status !== "disabled" && atlasMatches(face))
.map(([direction, face]) => toRegion(`${cube.name}/${direction}`, face.uv, face.rotation ? { rotation: face.rotation } : {}))
.filter(region => region.size_texels[0] > 0 && region.size_texels[1] > 0)
);
const meshRegions = (inspection.meshes ?? []).flatMap(mesh =>
(mesh.faces ?? []).map(face => {
const us = face.uv.map(([u]) => u);
const vs = face.uv.map(([, v]) => v);
return toRegion(`${mesh.name}/${face.key}`, [Math.min(...us), Math.min(...vs), Math.max(...us), Math.max(...vs)], { polygon: true });
})
);
const regions = [...cubeRegions, ...meshRegions];
/** Merge regions that share the same texel rectangle (mirrored parts, reused faces). */
const shared = Object.values(regions.reduce((groups, region) => {
const key = region.texels.join(",");
return { ...groups, [key]: [...(groups[key] ?? []), region.name] };
}, {})).filter(names => names.length > 1);
const layout = {
atlas: { name: atlas.name, texels: [frameWidth, frameHeight], uv_units: [uvWidth, uvHeight] },
canvas: { width: canvas.width, height: canvas.height, texel_scale: canvas.scale },
image_size: { width: canvas.width, height: canvas.height },
regions,
shared_regions: shared,
};
if (format === "text") {
const lines = [
`Canvas ${canvas.width}x${canvas.height} px = ${frameWidth}x${frameHeight} texel atlas at ${canvas.scale}x (one texel = ${canvas.scale}x${canvas.scale} px block).`,
...regions.map(region => `- ${region.name}: px x ${region.px[0]}-${region.px[2]}, y ${region.px[1]}-${region.px[3]} (${region.size_texels[0]}x${region.size_texels[1]} texels)${region.rotation ? `, rotated ${region.rotation}deg` : ""}${region.mirrored_x ? ", mirrored" : ""}`),
...(shared.length ? [`Shared pixels: ${shared.map(names => names.join(" = ")).join("; ")}`] : []),
];
console.log(lines.join("\n"));
process.exit(0);
}
console.log(JSON.stringify(layout, null, 2));
SKILL.md
---
name: blockbench-gpt-image-textures
description: Generate Blockbench texture atlases, skins and tiles with OpenAI GPT Image 2.5 (Flare or Sunburst) on fal.ai, writing the model's expected UV layout into the prompt so painted regions land on the right faces. Use when asked to AI-generate, concept, or repaint a texture for a Blockbench model, or to call openai/gpt-image-2.5/flare/text-to-image or openai/gpt-image-2.5/sunburst/text-to-image. Triggers on gpt image 2.5, gpt-image-2.5, flare, sunburst, fal.ai texture, generate texture with AI, UV-aware prompt, texture atlas prompt, AI skin, AI texture sheet.
---
# GPT Image 2.5 Textures for Blockbench
Generate a texture image with GPT Image 2.5 on fal.ai and land it on an existing Blockbench UV layout. The prompt must carry the UV layout: which pixel rectangle of the image belongs to which face. Without it the model paints a picture, not an atlas.
Load [blockbench-use](../blockbench-use/SKILL.md) first for discovery and verification rules, and [blockbench-texturing](../blockbench-texturing/SKILL.md) for UV inspection and cleanup painting.
## Pick a model
| Model | fal endpoint | Use for |
|---|---|---|
| Flare (default) | `openai/gpt-image-2.5/flare/text-to-image` | Fast drafts, most atlases, transparent backgrounds |
| Sunburst | `openai/gpt-image-2.5/sunburst/text-to-image` | Final high-resolution atlases with intricate detail; slower |
Both accept the same parameters and share `/edit` variants that take reference images. Details, pricing and recipes: [references/fal-api.md](references/fal-api.md).
## Workflow
### 0. Settle the geometry/detail tradeoff
For a new model or substantial redesign, resolve the appearance/accuracy, performance, or balanced preference through [blockbench-use](../blockbench-use/SKILL.md) before committing geometry or generating an atlas. Apply [appearance and performance planning](../blockbench-use/references/appearance-and-performance.md): establish the simplified target geometry and shared UV tiles first. An HD image request is not permission to multiply tiny detail cubes.
For braces or railing infills represented by panels, describe the repeated pattern and transparent holes inside each measured UV region, not just transparency outside the atlas islands. For fasteners represented on existing faces, include their albedo marks in those regions and use the [PBR workflow](../blockbench-pbr-materials/SKILL.md) for supported normal/height/MER detail. Verify channel data separately; a color atlas is not a PBR map. Confirm [Bedrock material bindings](../blockbench-use/references/bedrock-material-instances.md) when cutouts or multiple materials are intended for a custom block.
### 1. Establish the UV layout first
The atlas layout must exist in Blockbench before prompting. Run `get_capabilities: include_tools=true`, then `list_textures`. If no texture or UVs exist, generate a native texture template as described in the texturing skill, then continue. Do not invent a layout in the prompt that the model does not have.
Audit that layout using [UV scale and distortion guidance](../blockbench-texturing/references/uv-scale-and-distortion.md) before generation. Compare surface dimensions with sampled image pixels along both face directions, using the effective frame and logical UV sizes. Correct unintended stretching and inconsistent material scale first; an image generator cannot repair a distorted mapping just by painting inside its rectangles. Reusing the same complete swatch is appropriate only for compatible face dimensions or deliberate exceptions.
Collect the layout:
```
list_textures # frame_size and uv_size of the target atlas
get_cube_uv: id="<cube uuid>" # one call per textured cube
get_mesh_info: mesh_id="<uuid>", include_uv=true # meshes, if any
```
Save the raw results into one JSON file:
```json
{ "textures": [ ...list_textures... ], "cubes": [ ...get_cube_uv results... ],
"meshes": [ { "name": "cloth", "faces": [ { "key": "f1", "uv": [[40,40],[56,40],[56,52],[40,52]] } ] } ] }
```
### 2. Compute the canvas and regions
fal rejects images under 655,360 pixels, so a 64x64 atlas cannot be generated at native size. Generate an integer upscale and downsample afterwards. The script picks the scale and converts every face rectangle into generated-image pixels:
```bash
node scripts/uv_layout_prompt.mjs inspection.json --texture <atlas name|uuid> > layout.json
node scripts/uv_layout_prompt.mjs inspection.json --format text # human-readable check
```
Output includes `image_size` (pass to fal unchanged), `canvas.texel_scale`, one region per face with `px` and `texels` rectangles, and `shared_regions` for faces that reuse the same pixels. An exit code of 2 means the atlas aspect exceeds 3:1; generate a padded canvas and crop with `--crop` in step 5, or split the atlas.
`canvas.texel_scale` describes the generation upscale, not material density on the model. Collect surface dimensions separately from the geometry; the region list alone cannot establish pixels per model unit or detect local mesh distortion.
### 3. Write the prompt with the layout inside it
Structure the prompt as JSON. The `layout` block is mandatory and comes straight from step 2. Read [references/uv-layout-prompting.md](references/uv-layout-prompting.md) for the full template, region-description rules and worked examples. Minimum shape:
```json
{
"type": "UV texture atlas for a low-poly 3D game model, flat orthographic texture sheet, not a 3D render",
"subject": "brown alpaca, cartoon Minecraft style",
"style": "crisp pixel art, each texel a solid 16x16 px block, no anti-aliasing, 12-color palette, flat unlit colors",
"canvas": { "width": 1024, "height": 1024, "texel_scale": 16, "represents": "64x64 texel atlas" },
"background": "fully transparent outside the listed regions",
"regions": [
{ "name": "head/north (face)", "px": [128, 128, 256, 256], "paint": "front of the head: two dark eyes, muzzle, no outline" },
{ "name": "head/up", "px": [128, 0, 256, 128], "paint": "top of head fur, seam-continuous with head/north" }
],
"rules": [
"paint every region edge to edge; leave everything else transparent",
"no labels, grid lines, borders, drop shadows or text anywhere",
"adjacent faces of one cube must continue seamlessly across their shared edge",
"regions listed as shared receive identical content"
]
}
```
Describe what each face shows, not the 3D scene. Name regions by cube and direction, keep the pixel rectangles exactly as computed, and mention rotation or mirroring the script reported.
For detailed materials, describe each region's surface coverage in model units, grain direction, and consistent feature size across related regions. Larger surfaces normally show more repetitions of the same grain or pattern. Preserve individual UV footprints when grouping descriptions by material; do not map one full material rectangle onto every differently sized face. Coordinate these choices with all PBR channels.
### 4. Generate
```bash
FAL_KEY=... node scripts/fal_gpt_image.mjs --prompt-file prompt.json --model flare \
--width 1024 --height 1024 --background transparent --quality medium --num 2 --out ./output --label alpaca
```
Use `--width/--height` from `layout.json` `image_size`. `--background transparent` yields real alpha on Flare; verify alpha on Sunburst output before relying on it. Start with `medium` quality and `--num 2` to pick a candidate; `high` or `xhigh` only for finals. `--dry-run` prints the request body for review. Download happens inside the script; fal URLs expire.
### 5. Downscale to the atlas
Skip this step when `texel_scale` is 1. Otherwise sample the centre of each texel block with nearest-neighbour and snap alpha for pixel art:
```bash
python scripts/resize_to_atlas.py output/alpaca_<ts>.png output/alpaca_64.png --width 64 --height 64 --alpha-threshold 128
```
Use `--sample average` for painterly high-resolution atlases, and `--crop X Y W H` to cut a padded canvas back to the atlas aspect first.
### 6. Import, apply and verify in Blockbench
```
create_texture: name="alpaca", data="<absolute path to alpaca_64.png>"
apply_texture: id="<group or element>", texture="alpaca", applyTo="all"
get_texture: texture="alpaca"
capture_screenshot
```
In formats with `per_texture_uv_size`, pass `uv_width`/`uv_height` to `create_texture` matching the original atlas `uv_size`. Check the rendered model for region drift, seam bleeding, mirrored content, and inconsistent grain or relief scale. Use a temporary checker to distinguish mapping distortion from inconsistent detail painted into the atlas; inspect long/short faces and sides/ends at close and intended viewing distances.
When generated boundaries drift, repaint or extend the material to fit the planned UV footprint. Avoid independently fitting each face's UV rectangle to the generated content bounds: that can alter both aspect ratio and density. If UV edits are necessary, preserve proportions and intended density, update corresponding PBR regions, and repeat the scale checks. Fix small image errors with the texturing skill's paint tools. For larger errors, iterate through the `/edit` endpoint with the previous output URL and a prompt naming only the regions to change.
Once the color atlas is final, derive its normal, height and MER maps with [albedo to normal](../blockbench-albedo-to-normal/SKILL.md) rather than generating PBR channels with the image model; run it on the downscaled atlas so the maps share the texel grid.
## What to expect
- The model treats pixel rectangles as guidance, not a contract. Expect 1 to 3 texel drift at region borders and occasional content spilling into transparent gutters; the downscale and cleanup pass absorb most of it.
- Fewer, larger regions work better than many tiny ones. For atlases with more than about 20 regions, group by body part in the prompt and describe shared regions once.
- Pixel-art phrasing ("each texel a solid 16x16 px block") keeps blocks aligned to the texel grid. Without it the model anti-aliases and the downscale muddies edges.
- A rendered UV template or checker fed through `/edit` as `image_urls` anchors the layout far more reliably than text alone. Use `capture_screenshot` or the native template texture, upload it, and prompt "paint this texture sheet" with the same `layout` block.
- Long text, logos and readable labels belong in a separate pass; they compete with the layout rules.
- Every output is a full RGB(A) image; nothing prevents the model from painting outside regions. Verify against `get_cube_uv` rectangles, not against the prompt.
## Resources
- [references/fal-api.md](references/fal-api.md): endpoints, parameters, sizes, pricing, transparency, curl/Node/Python recipes, edit endpoint.
- [references/uv-layout-prompting.md](references/uv-layout-prompting.md): prompt template, how to describe box-UV nets, per-face rectangles and mesh islands, worked examples for Bedrock entity, Java block and Generic Model targets.
- `scripts/uv_layout_prompt.mjs`: inspection JSON to canvas size and pixel regions.
- `scripts/fal_gpt_image.mjs`: call Flare or Sunburst (text-to-image or edit), save results.
- `scripts/resize_to_atlas.py`: nearest-neighbour or box downscale to atlas size, optional crop and alpha snap (needs Pillow).