Refine image generation UI: aspect ratio sliders, info hints, action toolbar, and timestamped export filenames

This commit is contained in:
oobabooga 2026-06-24 18:47:33 -03:00
commit 571d506c20
4 changed files with 156 additions and 62 deletions

View file

@ -1740,6 +1740,7 @@ class GalleryImage(BaseModel):
steps: int = Field(..., description = "Denoising steps")
guidance: float = Field(..., description = "Guidance scale")
seed: int = Field(..., description = "Seed used")
batch_index: int = Field(0, description = "Position within its batch (0-based)")
model: Optional[str] = Field(None, description = "Model repo id that produced it")
created_at: float = Field(..., description = "Creation time (epoch seconds)")

View file

@ -10117,7 +10117,7 @@ async def generate_diffusion_image(
def _persist() -> list[dict]:
records = []
for image in result["images"]:
for index, image in enumerate(result["images"]):
records.append(image_gallery.save(image, {
"prompt": request.prompt,
"negative_prompt": request.negative_prompt,
@ -10126,6 +10126,9 @@ async def generate_diffusion_image(
"steps": request.steps,
"guidance": request.guidance,
"seed": result["seed"],
# Position within the batch: images here share a seed + timestamp,
# so the export filename needs this to stay unique.
"batch_index": index,
"model": result.get("repo_id"),
"created_at": created_at,
}))

View file

@ -53,6 +53,7 @@ export interface GalleryImage {
steps: number;
guidance: number;
seed: number;
batch_index: number;
model: string | null;
created_at: number;
}

View file

@ -3,6 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
ArrowLeftRightIcon,
ArrowReloadHorizontalIcon,
Delete02Icon,
Download01Icon,
@ -68,21 +69,35 @@ const MODELS: ModelOption[] = [
{ id: MODEL.repo_id, name: MODEL.label, description: "Text-to-image · GGUF", isGguf: true },
];
// Z-Image's official ~1-megapixel resolution buckets (the 1024 grid from the
// Tongyi-MAI demo app). All divisible by 16, the model's required step.
const RESOLUTIONS: Array<{ label: string; w: number; h: number }> = [
{ label: "1024 × 1024 (1:1)", w: 1024, h: 1024 },
{ label: "1152 × 896 (9:7)", w: 1152, h: 896 },
{ label: "896 × 1152 (7:9)", w: 896, h: 1152 },
{ label: "1152 × 864 (4:3)", w: 1152, h: 864 },
{ label: "864 × 1152 (3:4)", w: 864, h: 1152 },
{ label: "1248 × 832 (3:2)", w: 1248, h: 832 },
{ label: "832 × 1248 (2:3)", w: 832, h: 1248 },
{ label: "1280 × 720 (16:9)", w: 1280, h: 720 },
{ label: "720 × 1280 (9:16)", w: 720, h: 1280 },
{ label: "1344 × 576 (21:9)", w: 1344, h: 576 },
{ label: "576 × 1344 (9:21)", w: 576, h: 1344 },
];
// Common aspect ratios (landscape-oriented; the Flip button gives the portrait
// mirror). Picking one locks the W:H proportion; the sliders size it, and the
// generation snaps to Z-Image's ~1-megapixel-trained 16px grid.
const ASPECT_RATIOS: Record<string, [number, number]> = {
"1:1": [1, 1],
"3:2": [3, 2],
"4:3": [4, 3],
"16:9": [16, 9],
"21:9": [21, 9],
};
const ASPECT_OPTIONS = ["custom", ...Object.keys(ASPECT_RATIOS)];
// Z-Image accepts 2562048, in multiples of 16. Snap any value into range.
const MIN_DIM = 256;
const MAX_DIM = 2048;
function snapDim(value: number): number {
if (!Number.isFinite(value)) return 1024;
return Math.min(MAX_DIM, Math.max(MIN_DIM, Math.round(value / 16) * 16));
}
// The ratio key (compared by long:short, so it survives orientation) matching
// width/height, plus whether the current orientation is portrait.
function matchAspect(width: number, height: number): { key: string; portrait: boolean } {
const target = Math.max(width, height) / Math.min(width, height);
const found = Object.entries(ASPECT_RATIOS).find(
([, [a, b]]) => Math.abs(target - a / b) < 0.01,
);
return { key: found ? found[0] : "custom", portrait: height > width };
}
// The gallery is persisted on the backend (durable across reloads); this module
// cache only holds the last-fetched records + their object/data URLs so a tab
@ -98,10 +113,24 @@ const galleryCache: {
inflight: Set<string>;
} = { images: [], selectedId: null, quant: null, srcById: new Map(), inflight: new Set() };
function downloadImage(src: string, seed: number) {
// Export filename: app name, a compact sortable timestamp, and the seed. A batch
// shares the seed + timestamp, so a "_<n>" suffix is added past the first image.
// e.g. Unsloth_20260624-143005_123.png / Unsloth_20260624-143005_123_1.png
// (the full recipe is embedded in the PNG regardless).
function exportFilename(image: GalleryImage): string {
const d = new Date(image.created_at * 1000);
const p = (n: number) => String(n).padStart(2, "0");
const stamp =
`${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}` +
`-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
const suffix = image.batch_index > 0 ? `_${image.batch_index}` : "";
return `Unsloth_${stamp}_${image.seed}${suffix}.png`;
}
function downloadImage(src: string, image: GalleryImage) {
const link = document.createElement("a");
link.href = src;
link.download = `unsloth-${seed}.png`;
link.download = exportFilename(image);
link.click();
}
@ -275,7 +304,7 @@ function RecipePopover({
return (
<Popover>
<PopoverTrigger asChild>
<Button size="sm" variant="secondary" className="gap-1.5">
<Button size="sm" variant="ghost" className="gap-1.5">
<HugeiconsIcon icon={InformationCircleIcon} className="size-4" />
Recipe
</Button>
@ -315,7 +344,12 @@ export function ImagesPage() {
"a tiny ginger sloth coding in a sunlit treehouse, photorealistic",
);
const [negativePrompt, setNegativePrompt] = useState("");
const [resolutionIdx, setResolutionIdx] = useState(0);
// width/height are the source of truth; `aspect` locks their proportion
// ("custom" = free) and `portrait` tracks orientation, so Flip keeps the lock.
const [width, setWidth] = useState(1024);
const [height, setHeight] = useState(1024);
const [aspect, setAspect] = useState("1:1");
const [portrait, setPortrait] = useState(false);
// Z-Image-Turbo official defaults: 9 steps (= 8 DiT forwards), guidance 0
// (distilled CFG-free; a negative prompt is ignored at this guidance).
const [steps, setSteps] = useState(9);
@ -352,7 +386,6 @@ export function ImagesPage() {
galleryCache.quant = quant;
}, [images, selectedId, quant]);
const resolution = RESOLUTIONS[resolutionIdx];
const selected = useMemo(
() => images.find((i) => i.id === selectedId) ?? images[0] ?? null,
[images, selectedId],
@ -415,11 +448,44 @@ export function ImagesPage() {
setSteps(image.steps);
setGuidance(image.guidance);
setSeed(String(image.seed));
const idx = RESOLUTIONS.findIndex((r) => r.w === image.width && r.h === image.height);
if (idx >= 0) setResolutionIdx(idx);
setWidth(image.width);
setHeight(image.height);
const m = matchAspect(image.width, image.height);
setAspect(m.key);
setPortrait(m.portrait);
toast.success("Settings restored to inputs");
}, []);
// Size controls: a locked aspect ratio keeps the paired dimension in step as
// you drag a slider; "custom" frees both. Flip swaps width and height and
// keeps the lock (the ratio just applies in the other orientation).
// h/w multiplier for the locked ratio [a,b] (a:b = long:short): landscape puts
// the long side on width (h = w*b/a), portrait puts it on height (h = w*a/b).
const ratioHW = (a: number, b: number) => (portrait ? a / b : b / a);
const changeAspect = (key: string) => {
setAspect(key);
if (key === "custom") return;
const [a, b] = ASPECT_RATIOS[key];
setHeight(snapDim(width * ratioHW(a, b)));
};
const changeWidth = (v: number) => {
setWidth(v);
if (aspect === "custom") return;
const [a, b] = ASPECT_RATIOS[aspect];
setHeight(snapDim(v * ratioHW(a, b)));
};
const changeHeight = (v: number) => {
setHeight(v);
if (aspect === "custom") return;
const [a, b] = ASPECT_RATIOS[aspect];
setWidth(snapDim(v / ratioHW(a, b)));
};
const flipDimensions = () => {
setWidth(height);
setHeight(width);
setPortrait((p) => !p);
};
const refreshStatus = useCallback(async () => {
try {
setStatus(await getDiffusionStatus());
@ -535,6 +601,10 @@ export function ImagesPage() {
baseSeed = Math.floor(Math.random() * 2 ** 32);
}
// Snap custom dims to the model's grid so a half-typed value can't 422.
const w = snapDim(width);
const h = snapDim(height);
setBusy("generating");
setGenProgress({ done: 0, total: count });
try {
@ -542,8 +612,8 @@ export function ImagesPage() {
const res = await generateDiffusionImage({
prompt: prompt.trim(),
negative_prompt: negativePrompt.trim() || undefined,
width: resolution.w,
height: resolution.h,
width: w,
height: h,
steps,
guidance,
seed: baseSeed + i,
@ -561,7 +631,7 @@ export function ImagesPage() {
setBusy(null);
setGenProgress(null);
}
}, [prompt, negativePrompt, resolution, steps, guidance, seed, batchSize, count, ensureSrc]);
}, [prompt, negativePrompt, width, height, steps, guidance, seed, batchSize, count, ensureSrc]);
return (
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
@ -594,7 +664,7 @@ export function ImagesPage() {
</Field>
<Field
label="Negative prompt"
hint="Z-Image-Turbo runs guidance-free, so a negative prompt is ignored — it only takes effect when guidance is above 0."
hint="Ignored by Z-Image-Turbo, which runs with guidance off. It only has an effect when guidance is above 0."
>
<Textarea
rows={2}
@ -605,26 +675,40 @@ export function ImagesPage() {
</Field>
<Field
label="Resolution"
hint="Z-Image's official ~1-megapixel resolutions. Every option sits on the 1024 grid the model was trained on; dimensions are multiples of 16."
label="Aspect ratio"
hint="Pick a ratio to lock the proportions, then set the size with the sliders. Flip swaps width and height. Sizes run from 256 to 2048 in steps of 16. Z-Image is trained around 1 megapixel, so much larger sizes can look worse."
>
<Select value={String(resolutionIdx)} onValueChange={(v) => setResolutionIdx(Number(v))}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{RESOLUTIONS.map((r, idx) => (
<SelectItem key={r.label} value={String(idx)}>
{r.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-2">
<Select value={aspect} onValueChange={changeAspect}>
<SelectTrigger className="flex-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ASPECT_OPTIONS.map((key) => (
<SelectItem key={key} value={key}>
{key === "custom" ? "Custom" : key}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="secondary"
size="icon"
aria-label="Flip width and height"
title="Flip orientation"
onClick={flipDimensions}
>
<HugeiconsIcon icon={ArrowLeftRightIcon} className="size-4" />
</Button>
</div>
</Field>
<SliderField label="Width" value={width} min={256} max={2048} step={16} onChange={changeWidth} />
<SliderField label="Height" value={height} min={256} max={2048} step={16} onChange={changeHeight} />
<SliderField
label="Steps"
hint="Z-Image-Turbo is distilled to ~8 forward passes; 9 steps is the official setting. More steps rarely help."
hint="9 is the recommended setting for Z-Image-Turbo. More steps rarely help."
value={steps}
min={1}
max={50}
@ -633,13 +717,31 @@ export function ImagesPage() {
/>
<SliderField
label="Guidance"
hint="Z-Image-Turbo is distilled CFG-free — keep this at 0. Higher values degrade Turbo output (other models use guidance)."
hint="Keep this at 0 for Z-Image-Turbo. Higher values make its output worse. Other models use guidance."
value={guidance}
min={0}
max={15}
step={0.5}
onChange={setGuidance}
/>
<SliderField
label="Batch size"
hint="How many images to make at once. Faster than running them one by one, but uses more VRAM. They share a seed but each one is different."
value={batchSize}
min={1}
max={32}
step={1}
onChange={setBatchSize}
/>
<SliderField
label="Runs"
hint="How many times to repeat the generation, one after another. Each run uses the next seed, so the images differ and can be reproduced."
value={count}
min={1}
max={128}
step={1}
onChange={setCount}
/>
<Field label="Seed" hint="Leave empty for a fresh random seed each run.">
<Input
placeholder="Random if empty"
@ -648,25 +750,6 @@ export function ImagesPage() {
/>
</Field>
<SliderField
label="Batch size"
hint="Images generated together in one forward pass (VRAM-heavy). They share the run's seed but each comes out different."
value={batchSize}
min={1}
max={32}
step={1}
onChange={setBatchSize}
/>
<SliderField
label="Sequential count"
hint="Repeat generation this many times in a loop. Each run advances the seed (base + i), so images differ and stay reproducible."
value={count}
min={1}
max={128}
step={1}
onChange={setCount}
/>
<Button onClick={handleGenerate} disabled={busy !== null || !status?.loaded}>
{busy === "generating" ? <Spinner className="mr-2 size-4" /> : null}
{busy === "generating" && genProgress && genProgress.total > 1
@ -684,23 +767,29 @@ export function ImagesPage() {
alt={selected.prompt}
className="max-h-full max-w-full rounded-xl object-contain shadow-sm"
/>
<div className="absolute bottom-4 right-4 flex items-center gap-2">
<span className="rounded-md bg-background/80 px-2 py-1 text-xs text-muted-foreground backdrop-blur">
{selected.width}×{selected.height} · seed {selected.seed}
</span>
{/* Metadata bottom-left, so it never sits under the actions. */}
<div className="absolute bottom-4 left-4 rounded-lg bg-background/80 px-2.5 py-1 text-xs tabular-nums text-muted-foreground shadow-sm ring-1 ring-border backdrop-blur">
{selected.width}×{selected.height} · seed {selected.seed}
</div>
{/* Actions grouped in one glass toolbar so they stay legible over
any image and read as a unit instead of blending into the canvas. */}
<div className="absolute bottom-4 right-4 flex items-center gap-0.5 rounded-xl bg-background/80 p-1 shadow-lg ring-1 ring-border backdrop-blur">
<RecipePopover image={selected} onRestore={restoreSettings} />
<Button
size="sm"
variant="secondary"
onClick={() => downloadImage(selectedSrc, selected.seed)}
variant="ghost"
className="gap-1.5"
onClick={() => downloadImage(selectedSrc, selected)}
>
<HugeiconsIcon icon={Download01Icon} className="mr-1.5 size-4" />
<HugeiconsIcon icon={Download01Icon} className="size-4" />
Download
</Button>
<Button
size="sm"
variant="secondary"
variant="ghost"
aria-label="Delete image"
title="Delete"
className="text-muted-foreground hover:text-destructive"
onClick={() => void handleDelete(selected.id)}
>
<HugeiconsIcon icon={Delete02Icon} className="size-4" />