Refine image generation UI: aspect ratio sliders, info hints, action toolbar, and timestamped export filenames
This commit is contained in:
parent
eced3620fa
commit
571d506c20
4 changed files with 156 additions and 62 deletions
|
|
@ -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)")
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export interface GalleryImage {
|
|||
steps: number;
|
||||
guidance: number;
|
||||
seed: number;
|
||||
batch_index: number;
|
||||
model: string | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 256–2048, 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" />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue