← back to Exo
Ciaran/image edit api (#1500)
f54c80b1217701e92fbeb8a5d8992c527a3821f9 · 2026-02-18 16:05:39 +0000 · ciaranbor
## Motivation
- Image editing previously ignored input image dimensions, always
defaulting to 1024x1024
- Size dropdown was hidden in edit mode, giving users no control over
output dimensions
- Portrait/landscape presets used non-standard aspect ratios (1024x1365
/ 1365x1024)
## Changes
- Added "auto" size option that uses input image dimensions for edits,
defaults to 1024x1024 for generation
- Introduced ImageSize Literal type and normalize_image_size() validator
(replaces raw str size fields)
- Updated portrait/landscape presets to standard 1024x1536 / 1536x1024
- Made size selector visible in edit mode (previously hidden)
- Default size changed from "1024x1024" to "auto"
## Why It Works
- "auto" reads actual input image dimensions via PIL at generation time,
so edits preserve the original aspect ratio
- Pydantic field_validator on both ImageGenerationTaskParams and
ImageEditsTaskParams normalizes None → "auto", keeping the API
backward-compatible
## Test Plan
### Manual Testing
- Verify image edits output at the input image's native resolution when
size is "auto"
- Verify size dropdown appears and works in both generate and edit modes
Files touched
M dashboard/src/lib/components/ImageParamsPanel.svelteM dashboard/src/lib/stores/app.svelte.tsM src/exo/master/api.pyM src/exo/shared/types/api.pyM src/exo/worker/engines/image/generate.py
Diff
commit f54c80b1217701e92fbeb8a5d8992c527a3821f9
Author: ciaranbor <81697641+ciaranbor@users.noreply.github.com>
Date: Wed Feb 18 16:05:39 2026 +0000
Ciaran/image edit api (#1500)
## Motivation
- Image editing previously ignored input image dimensions, always
defaulting to 1024x1024
- Size dropdown was hidden in edit mode, giving users no control over
output dimensions
- Portrait/landscape presets used non-standard aspect ratios (1024x1365
/ 1365x1024)
## Changes
- Added "auto" size option that uses input image dimensions for edits,
defaults to 1024x1024 for generation
- Introduced ImageSize Literal type and normalize_image_size() validator
(replaces raw str size fields)
- Updated portrait/landscape presets to standard 1024x1536 / 1536x1024
- Made size selector visible in edit mode (previously hidden)
- Default size changed from "1024x1024" to "auto"
## Why It Works
- "auto" reads actual input image dimensions via PIL at generation time,
so edits preserve the original aspect ratio
- Pydantic field_validator on both ImageGenerationTaskParams and
ImageEditsTaskParams normalizes None → "auto", keeping the API
backward-compatible
## Test Plan
### Manual Testing
- Verify image edits output at the input image's native resolution when
size is "auto"
- Verify size dropdown appears and works in both generate and edit modes
---
.../src/lib/components/ImageParamsPanel.svelte | 167 ++++++++++-----------
dashboard/src/lib/stores/app.svelte.ts | 7 +-
src/exo/master/api.py | 29 ++--
src/exo/shared/types/api.py | 39 ++++-
src/exo/worker/engines/image/generate.py | 8 +-
5 files changed, 145 insertions(+), 105 deletions(-)
diff --git a/dashboard/src/lib/components/ImageParamsPanel.svelte b/dashboard/src/lib/components/ImageParamsPanel.svelte
index 8da07559..e4536d09 100644
--- a/dashboard/src/lib/components/ImageParamsPanel.svelte
+++ b/dashboard/src/lib/components/ImageParamsPanel.svelte
@@ -59,13 +59,14 @@
}
const sizeOptions: ImageGenerationParams["size"][] = [
+ "auto",
"512x512",
"768x768",
"1024x1024",
"1024x768",
"768x1024",
- "1024x1365",
- "1365x1024",
+ "1024x1536",
+ "1536x1024",
];
const qualityOptions: ImageGenerationParams["quality"][] = [
@@ -176,92 +177,90 @@
<div class="border-b border-exo-medium-gray/30 px-3 py-2">
<!-- Basic params row -->
<div class="flex items-center gap-3 flex-wrap">
- <!-- Size (hidden in edit mode - output size comes from input image) -->
- {#if !isEditMode}
- <div class="flex items-center gap-1.5">
- <span class="text-xs text-exo-light-gray uppercase tracking-wider"
- >SIZE:</span
+ <!-- Size -->
+ <div class="flex items-center gap-1.5">
+ <span class="text-xs text-exo-light-gray uppercase tracking-wider"
+ >SIZE:</span
+ >
+ <div class="relative">
+ <button
+ bind:this={sizeButtonRef}
+ type="button"
+ onclick={() => (isSizeDropdownOpen = !isSizeDropdownOpen)}
+ class="bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-2 pr-6 py-1 text-xs font-mono text-exo-yellow cursor-pointer transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70 {isSizeDropdownOpen
+ ? 'border-exo-yellow/70'
+ : ''}"
>
- <div class="relative">
- <button
- bind:this={sizeButtonRef}
- type="button"
- onclick={() => (isSizeDropdownOpen = !isSizeDropdownOpen)}
- class="bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-2 pr-6 py-1 text-xs font-mono text-exo-yellow cursor-pointer transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70 {isSizeDropdownOpen
- ? 'border-exo-yellow/70'
- : ''}"
- >
- {params.size}
- </button>
- <div
- class="absolute right-1.5 top-1/2 -translate-y-1/2 pointer-events-none transition-transform duration-200 {isSizeDropdownOpen
- ? 'rotate-180'
- : ''}"
+ {params.size.toUpperCase()}
+ </button>
+ <div
+ class="absolute right-1.5 top-1/2 -translate-y-1/2 pointer-events-none transition-transform duration-200 {isSizeDropdownOpen
+ ? 'rotate-180'
+ : ''}"
+ >
+ <svg
+ class="w-3 h-3 text-exo-yellow/60"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke="currentColor"
>
- <svg
- class="w-3 h-3 text-exo-yellow/60"
- fill="none"
- viewBox="0 0 24 24"
- stroke="currentColor"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- stroke-width="2"
- d="M19 9l-7 7-7-7"
- />
- </svg>
- </div>
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="2"
+ d="M19 9l-7 7-7-7"
+ />
+ </svg>
</div>
+ </div>
- {#if isSizeDropdownOpen}
- <!-- Backdrop to close dropdown -->
- <button
- type="button"
- class="fixed inset-0 z-[9998] cursor-default"
- onclick={() => (isSizeDropdownOpen = false)}
- aria-label="Close dropdown"
- ></button>
-
- <!-- Dropdown Panel - fixed positioning to escape overflow:hidden -->
- <div
- class="fixed bg-exo-dark-gray border border-exo-yellow/30 rounded shadow-lg shadow-black/50 z-[9999] max-h-48 overflow-y-auto min-w-max"
- style="bottom: calc(100vh - {sizeDropdownPosition()
- .top}px + 4px); left: {sizeDropdownPosition().left}px;"
- >
- <div class="py-1">
- {#each sizeOptions as size}
- <button
- type="button"
- onclick={() => selectSize(size)}
- class="w-full px-3 py-1.5 text-left text-xs font-mono tracking-wide transition-colors duration-100 flex items-center gap-2 {params.size ===
- size
- ? 'bg-transparent text-exo-yellow'
- : 'text-exo-light-gray hover:text-exo-yellow'}"
- >
- {#if params.size === size}
- <svg
- class="w-3 h-3 flex-shrink-0"
- fill="currentColor"
- viewBox="0 0 20 20"
- >
- <path
- fill-rule="evenodd"
- d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
- clip-rule="evenodd"
- />
- </svg>
- {:else}
- <span class="w-3"></span>
- {/if}
- <span>{size}</span>
- </button>
- {/each}
- </div>
+ {#if isSizeDropdownOpen}
+ <!-- Backdrop to close dropdown -->
+ <button
+ type="button"
+ class="fixed inset-0 z-[9998] cursor-default"
+ onclick={() => (isSizeDropdownOpen = false)}
+ aria-label="Close dropdown"
+ ></button>
+
+ <!-- Dropdown Panel - fixed positioning to escape overflow:hidden -->
+ <div
+ class="fixed bg-exo-dark-gray border border-exo-yellow/30 rounded shadow-lg shadow-black/50 z-[9999] max-h-48 overflow-y-auto overflow-x-hidden min-w-max"
+ style="bottom: calc(100vh - {sizeDropdownPosition()
+ .top}px + 4px); left: {sizeDropdownPosition().left}px;"
+ >
+ <div class="py-1">
+ {#each sizeOptions as size}
+ <button
+ type="button"
+ onclick={() => selectSize(size)}
+ class="w-full px-3 py-1.5 text-left text-xs font-mono tracking-wide transition-colors duration-100 flex items-center gap-2 {params.size ===
+ size
+ ? 'bg-transparent text-exo-yellow'
+ : 'text-exo-light-gray hover:text-exo-yellow'}"
+ >
+ {#if params.size === size}
+ <svg
+ class="w-3 h-3 flex-shrink-0"
+ fill="currentColor"
+ viewBox="0 0 20 20"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ {:else}
+ <span class="w-3"></span>
+ {/if}
+ <span>{size.toUpperCase()}</span>
+ </button>
+ {/each}
</div>
- {/if}
- </div>
- {/if}
+ </div>
+ {/if}
+ </div>
<!-- Quality -->
<div class="flex items-center gap-1.5">
@@ -311,7 +310,7 @@
<!-- Dropdown Panel - fixed positioning to escape overflow:hidden -->
<div
- class="fixed bg-exo-dark-gray border border-exo-yellow/30 rounded shadow-lg shadow-black/50 z-[9999] max-h-48 overflow-y-auto min-w-max"
+ class="fixed bg-exo-dark-gray border border-exo-yellow/30 rounded shadow-lg shadow-black/50 z-[9999] max-h-48 overflow-y-auto overflow-x-hidden min-w-max"
style="bottom: calc(100vh - {qualityDropdownPosition()
.top}px + 4px); left: {qualityDropdownPosition().left}px;"
>
diff --git a/dashboard/src/lib/stores/app.svelte.ts b/dashboard/src/lib/stores/app.svelte.ts
index e5dbf902..1a6b1e3d 100644
--- a/dashboard/src/lib/stores/app.svelte.ts
+++ b/dashboard/src/lib/stores/app.svelte.ts
@@ -306,13 +306,14 @@ const IMAGE_PARAMS_STORAGE_KEY = "exo-image-generation-params";
export interface ImageGenerationParams {
// Basic params
size:
+ | "auto"
| "512x512"
| "768x768"
| "1024x1024"
| "1024x768"
| "768x1024"
- | "1024x1365"
- | "1365x1024";
+ | "1024x1536"
+ | "1536x1024";
quality: "low" | "medium" | "high";
outputFormat: "png" | "jpeg";
numImages: number;
@@ -336,7 +337,7 @@ export interface EditingImage {
}
const DEFAULT_IMAGE_PARAMS: ImageGenerationParams = {
- size: "1024x1024",
+ size: "auto",
quality: "medium",
outputFormat: "png",
numImages: 1,
diff --git a/src/exo/master/api.py b/src/exo/master/api.py
index b8476334..3c29b041 100644
--- a/src/exo/master/api.py
+++ b/src/exo/master/api.py
@@ -85,6 +85,7 @@ from exo.shared.types.api import (
ImageGenerationTaskParams,
ImageListItem,
ImageListResponse,
+ ImageSize,
ModelList,
ModelListModel,
PlaceInstanceParams,
@@ -100,6 +101,7 @@ from exo.shared.types.api import (
TraceRankStats,
TraceResponse,
TraceStatsResponse,
+ normalize_image_size,
)
from exo.shared.types.chunks import (
ErrorChunk,
@@ -751,9 +753,11 @@ class API:
When stream=True and partial_images > 0, returns a StreamingResponse
with SSE-formatted events for partial and final images.
"""
- payload.model = await self._validate_image_model(ModelId(payload.model))
payload = payload.model_copy(
- update={"advanced_params": _ensure_seed(payload.advanced_params)}
+ update={
+ "model": await self._validate_image_model(ModelId(payload.model)),
+ "advanced_params": _ensure_seed(payload.advanced_params),
+ }
)
command = ImageGeneration(
@@ -1009,12 +1013,13 @@ class API:
async def bench_image_generations(
self, request: Request, payload: BenchImageGenerationTaskParams
) -> BenchImageGenerationResponse:
- payload.model = await self._validate_image_model(ModelId(payload.model))
-
- payload.stream = False
- payload.partial_images = 0
payload = payload.model_copy(
- update={"advanced_params": _ensure_seed(payload.advanced_params)}
+ update={
+ "model": await self._validate_image_model(ModelId(payload.model)),
+ "stream": False,
+ "partial_images": 0,
+ "advanced_params": _ensure_seed(payload.advanced_params),
+ }
)
command = ImageGeneration(
@@ -1035,7 +1040,7 @@ class API:
prompt: str,
model: ModelId,
n: int,
- size: str,
+ size: ImageSize,
response_format: Literal["url", "b64_json"],
input_fidelity: Literal["low", "high"],
stream: bool,
@@ -1105,7 +1110,7 @@ class API:
prompt: str = Form(...),
model: str = Form(...),
n: int = Form(1),
- size: str = Form("1024x1024"),
+ size: str | None = Form(None),
response_format: Literal["url", "b64_json"] = Form("b64_json"),
input_fidelity: Literal["low", "high"] = Form("low"),
stream: str = Form("false"),
@@ -1131,7 +1136,7 @@ class API:
prompt=prompt,
model=ModelId(model),
n=n,
- size=size,
+ size=normalize_image_size(size),
response_format=response_format,
input_fidelity=input_fidelity,
stream=stream_bool,
@@ -1167,7 +1172,7 @@ class API:
prompt: str = Form(...),
model: str = Form(...),
n: int = Form(1),
- size: str = Form("1024x1024"),
+ size: str | None = Form(None),
response_format: Literal["url", "b64_json"] = Form("b64_json"),
input_fidelity: Literal["low", "high"] = Form("low"),
quality: Literal["high", "medium", "low"] = Form("medium"),
@@ -1187,7 +1192,7 @@ class API:
prompt=prompt,
model=ModelId(model),
n=n,
- size=size,
+ size=normalize_image_size(size),
response_format=response_format,
input_fidelity=input_fidelity,
stream=False,
diff --git a/src/exo/shared/types/api.py b/src/exo/shared/types/api.py
index 2756f0d4..824c2c91 100644
--- a/src/exo/shared/types/api.py
+++ b/src/exo/shared/types/api.py
@@ -1,9 +1,9 @@
import time
from collections.abc import Generator
-from typing import Annotated, Any, Literal
+from typing import Annotated, Any, Literal, get_args
from uuid import uuid4
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, field_validator
from exo.shared.models.model_cards import ModelCard, ModelId
from exo.shared.types.common import CommandId, NodeId
@@ -262,6 +262,27 @@ class DeleteInstanceResponse(BaseModel):
instance_id: InstanceId
+ImageSize = Literal[
+ "auto",
+ "512x512",
+ "768x768",
+ "1024x768",
+ "768x1024",
+ "1024x1024",
+ "1024x1536",
+ "1536x1024",
+]
+
+
+def normalize_image_size(v: object) -> ImageSize:
+ """Shared validator for ImageSize fields: maps None → "auto" and rejects invalid values."""
+ if v is None:
+ return "auto"
+ if v not in get_args(ImageSize):
+ raise ValueError(f"Invalid size: {v!r}. Must be one of {get_args(ImageSize)}")
+ return v # pyright: ignore[reportReturnType]
+
+
class AdvancedImageParams(BaseModel):
seed: Annotated[int, Field(ge=0)] | None = None
num_inference_steps: Annotated[int, Field(ge=1, le=100)] | None = None
@@ -281,7 +302,7 @@ class ImageGenerationTaskParams(BaseModel):
partial_images: int | None = 0
quality: Literal["high", "medium", "low"] | None = "medium"
response_format: Literal["url", "b64_json"] | None = "b64_json"
- size: str | None = "1024x1024"
+ size: ImageSize = "auto"
stream: bool | None = False
style: str | None = "vivid"
user: str | None = None
@@ -289,6 +310,11 @@ class ImageGenerationTaskParams(BaseModel):
# Internal flag for benchmark mode - set by API, preserved through serialization
bench: bool = False
+ @field_validator("size", mode="before")
+ @classmethod
+ def normalize_size(cls, v: object) -> ImageSize:
+ return normalize_image_size(v)
+
class BenchImageGenerationTaskParams(ImageGenerationTaskParams):
bench: bool = True
@@ -305,13 +331,18 @@ class ImageEditsTaskParams(BaseModel):
quality: Literal["high", "medium", "low"] | None = "medium"
output_format: Literal["png", "jpeg", "webp"] = "png"
response_format: Literal["url", "b64_json"] | None = "b64_json"
- size: str | None = "1024x1024"
+ size: ImageSize = "auto"
image_strength: float | None = 0.7
stream: bool = False
partial_images: int | None = 0
advanced_params: AdvancedImageParams | None = None
bench: bool = False
+ @field_validator("size", mode="before")
+ @classmethod
+ def normalize_size(cls, v: object) -> ImageSize:
+ return normalize_image_size(v)
+
def __repr_args__(self) -> Generator[tuple[str, Any], None, None]:
for name, value in super().__repr_args__(): # pyright: ignore[reportAny]
if name == "image_data":
diff --git a/src/exo/worker/engines/image/generate.py b/src/exo/worker/engines/image/generate.py
index a59e4eed..f5526c8f 100644
--- a/src/exo/worker/engines/image/generate.py
+++ b/src/exo/worker/engines/image/generate.py
@@ -14,6 +14,7 @@ from exo.shared.types.api import (
ImageEditsTaskParams,
ImageGenerationStats,
ImageGenerationTaskParams,
+ ImageSize,
)
from exo.shared.types.memory import Memory
from exo.shared.types.worker.runner_response import (
@@ -23,9 +24,9 @@ from exo.shared.types.worker.runner_response import (
from exo.worker.engines.image.distributed_model import DistributedImageModel
-def parse_size(size_str: str | None) -> tuple[int, int]:
+def parse_size(size_str: ImageSize) -> tuple[int, int]:
"""Parse size parameter like '1024x1024' to (width, height) tuple."""
- if not size_str:
+ if size_str == "auto":
return (1024, 1024)
try:
@@ -109,6 +110,9 @@ def generate_image(
# Decode base64 image data and save to temp file
image_path = Path(tmpdir) / "input.png"
image_path.write_bytes(base64.b64decode(task.image_data))
+ if task.size == "auto":
+ with Image.open(image_path) as img:
+ width, height = img.size
for image_num in range(num_images):
# Increment seed for each image to ensure unique results
← 48b8f863 Add support for GLM 5 (#1526)
·
back to Exo
·
feat: add explicit --offline mode for air-gapped clusters (# 2ebe6216 →