HextaUI

A prompt-to-image workspace for apps built on image models. Results come into focus as the model works, and any image can be refined from the composer.

Kategorie
KI
Genutzte Komponenten
18
Dateien
12
Anwendungsbeispiele
3
Zugang
HextaUI Pro (55 Blöcke)
Quellcode aktualisiert
Open in new tab

Teil von HextaUI Pro, alle 55 Blöcke, $49 einmalig, Pläne vergleichen

Image models take seconds per image, fail one image at a time and return results people want to keep working on. Image Studio covers that loop the way a chat does: history reads down to a composer pinned at the bottom, and each prompt lands as a new batch right above it.

While the model works, every placeholder sits at the exact final shape and comes into focus. A blue dither field (or ASCII glyphs with developEffect) flows across each tile while the image draws itself in its own colours from top to bottom behind a wavy edge, driven by the progress you report. The pointer swirls and lights the dots it passes over, a click or tap sends a ripple, and when the final image lands a bulge rolls up from the bottom, then the dither eases through pixel blocks into the real image. One line above the batch names the phase, like Composing or Adding detail, with the percentage and elapsed time, the same pattern as the Thinking block.

Providers stream differently, and the field only draws what you yield. If your API sends partial images (OpenAI partial_images, ComfyUI or fal previews), yield each one as preview on a progress event. If it reports steps, yield step and steps. If it only says pending, yield an estimated progress and the field stays a quiet blue wait until done. The default scan reveal is stylised: models don't paint from top to bottom, so it maps progress onto rows as a look, not a readout. Set developReveal="refine" to show only what the model returned: partial images dissolve in across the whole frame and sharpen from coarse blocks as progress rises, the way diffusion refines all of an image at once. Both modes read the same events.

The common path stays one gesture each: type and press Enter, hover a result and press Refine, describe the change. Aspect ratio and style sit in the composer, and count, model, seed and a negative prompt wait in one Options popover. Vary, upscale, region edits, reuse, download and delete live in the image menu and the viewer.

Rows keep every batch next to its prompt, never shift while loading, and move in reading order. Failed images show the reason and a Retry that reuses their seed and the batch’s own runner.

  1. HextaUI Pro holen

    Eine einmalige Zahlung schaltet jeden Block frei, auch künftige. Preise ansehen.

  2. Die Pro-Registry zu components.json hinzufügen

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  3. Token hinzufügen

    Erstelle auf deiner Kontoseite einen Token und trage ihn in .env.local als HEXTAUI_PRO_TOKEN ein.

  4. Den Block hinzufügen

    pnpm dlx shadcn@latest add @hextaui-pro/image-studio

Polling an image API

Write onGenerate as an async generator. Start a job, poll it with sleep(ms, signal) and yield queued, progress, done or error per image. Cancel aborts the signal and calls onCancel, and onComplete hands you each finished batch to save.

"use client"

import * as React from "react"

import { ImageStudio, sleep, type Generation, type ImageEvent, type ImageRunner } from "@/components/blocks/image-studio/image-studio"

type Job = {
  id: string
  images: {
    status: "queued" | "running" | "succeeded" | "failed"
    progress?: number
    url?: string
    width?: number
    height?: number
    seed?: number
    error?: string
  }[]
}

async function json<T>(response: Response): Promise<T> {
  if (!response.ok) throw new Error((await response.text()) || "The image service didn’t respond.")
  return response.json() as Promise<T>
}

const generate: ImageRunner = async function* (request, { signal }) {
  const job = await json<Job>(
    await fetch("/api/images", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        id: request.id,
        kind: request.kind,
        prompt: request.settings.prompt,
        negativePrompt: request.settings.negativePrompt,
        aspectRatio: request.settings.aspectRatio,
        model: request.settings.model,
        style: request.settings.preset,
        seeds: request.seeds,
        sourceUrl: request.source?.image.fullSrc ?? request.source?.image.src,
        strength: request.strength,
      }),
      signal,
    })
  )

  const finished = new Set<number>()
  while (finished.size < request.seeds.length) {
    await sleep(1000, signal)
    const current = await json<Job>(await fetch(`/api/images/${job.id}`, { signal }))
    for (const [index, image] of current.images.entries()) {
      if (finished.has(index)) continue
      let event: ImageEvent
      if (image.status === "succeeded" && image.url) {
        finished.add(index)
        event = {
          type: "done",
          index,
          image: { src: image.url, width: image.width, height: image.height, seed: image.seed },
        }
      } else if (image.status === "failed") {
        finished.add(index)
        event = { type: "error", index, message: image.error ?? "This image failed.", retryable: true }
      } else if (image.status === "queued") {
        event = { type: "queued", index }
      } else {
        event = { type: "progress", index, progress: image.progress }
      }
      yield event
    }
  }
}

export function Studio({ initial }: { initial: Generation[] }) {
  const [generations, setGenerations] = React.useState(initial)

  return (
    <ImageStudio
      generations={generations}
      models={[{ value: "image-2", label: "Image 2", credits: 2 }]}
      onGenerate={generate}
      onVary={generate}
      onUpscale={generate}
      onCancel={async (request) => {
        await fetch(`/api/images/${request.id}`, { method: "DELETE" })
      }}
      onComplete={(generation) => {
        setGenerations((list) => [generation, ...list.filter((item) => item.id !== generation.id)])
        void fetch("/api/generations", { method: "PUT", body: JSON.stringify(generation) })
      }}
      onFavorite={async (image, favorite) => {
        await json(await fetch(`/api/images/${image.id}/favorite`, { method: favorite ? "PUT" : "DELETE" }))
      }}
      onDelete={async (image) => {
        await json(await fetch(`/api/images/${image.id}`, { method: "DELETE" }))
      }}
    />
  )
}

Editing a region

onEdit runs both edits. Refine sends request.source and the new prompt with no mask. Edit a region adds a black-and-white PNG mask at the image’s full size, ready for an inpainting endpoint. Use MaskEditor on its own to add region edits to any image you already show.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"

import {
  createImageEventQueue,
  ImageStudio,
  MaskEditor,
  type GeneratedImage,
  type Generation,
  type ImageRunner,
} from "@/components/blocks/image-studio/image-studio"

const edit: ImageRunner = (request, { signal }) => {
  const queue = createImageEventQueue()
  const body = new FormData()
  body.set("prompt", request.settings.prompt)
  body.set("image", request.source?.image.fullSrc ?? request.source?.image.src ?? "")
  body.set("seeds", request.seeds.join(","))
  if (request.mask) body.set("mask", request.mask, "mask.png")

  fetch("/api/images/edit", { method: "POST", body, signal })
    .then(async (response) => {
      if (!response.ok) throw new Error("The edit didn’t go through.")
      const { urls } = (await response.json()) as { urls: string[] }
      urls.forEach((src, index) => queue.push({ type: "done", index, image: { src } }))
      queue.end()
    })
    .catch((error) => queue.fail(error))

  return queue
}

export function StudioWithEdits({ generations }: { generations: Generation[] }) {
  return (
    <ImageStudio
      generations={generations}
      models={[{ value: "image-2", label: "Image 2" }]}
      onGenerate={edit}
      onEdit={edit}
    />
  )
}

export function EditButton({ image, generation }: { image: GeneratedImage; generation: Generation }) {
  const [open, setOpen] = React.useState(false)

  return (
    <>
      <Button variant="outline" onClick={() => setOpen(true)}>
        Edit region
      </Button>
      <MaskEditor
        open={open}
        onOpenChange={setOpen}
        image={image}
        generation={generation}
        onSubmit={async ({ prompt, mask }) => {
          const body = new FormData()
          body.set("prompt", prompt)
          body.set("image", image.fullSrc ?? image.src ?? "")
          body.set("mask", mask, "mask.png")
          const response = await fetch("/api/images/edit", { method: "POST", body })
          if (!response.ok) throw new Error("The edit didn’t go through. Try again.")
        }}
      />
    </>
  )
}

As a chat tool result

useImageRuns, ImageGeneration and ImageComposer work without the full studio. Render them in a tool result and stream events from Server-Sent Events through createImageEventQueue.

"use client"

import * as React from "react"

import {
  createImageEventQueue,
  defaultSettings,
  ImageComposer,
  ImageGeneration,
  useImageRuns,
  type ImageEvent,
  type ImageRunner,
  type ImageSettings,
} from "@/components/blocks/image-studio/image-studio"

const models = [{ value: "image-2", label: "Image 2", credits: 2 }]

const stream: ImageRunner = (request, { signal }) => {
  const queue = createImageEventQueue()
  const params = new URLSearchParams({
    prompt: request.settings.prompt,
    aspectRatio: request.settings.aspectRatio,
    seeds: request.seeds.join(","),
    kind: request.kind,
  })
  const source = new EventSource(`/api/images/stream?${params}`)
  source.onmessage = (message) => queue.push(JSON.parse(message.data) as ImageEvent)
  source.addEventListener("end", () => {
    source.close()
    queue.end()
  })
  source.onerror = () => {
    source.close()
    queue.fail(new Error("Lost the connection to the image service."))
  }
  signal.addEventListener("abort", () => source.close(), { once: true })
  return queue
}

export function ImageToolResult({ input }: { input: Partial<ImageSettings> & { prompt: string } }) {
  const [settings, setSettings] = React.useState<ImageSettings>({ ...defaultSettings, count: 2, ...input })
  const runs = useImageRuns()
  const started = React.useRef(false)

  React.useEffect(() => {
    if (started.current) return
    started.current = true
    runs.start(stream, { kind: "generate", settings })
  }, [runs, settings])

  return (
    <div className="flex flex-col gap-4 rounded-2xl border p-3">
      {runs.live.map((generation) => (
        <ImageGeneration
          key={generation.id}
          generation={generation}
          models={models}
          headingLevel={3}
          onCancel={() => runs.cancel(generation.id)}
          onRetry={(image) => runs.retry(stream, generation, image)}
          onVary={(image, from, strength) =>
            runs.start(stream, { kind: "variation", settings: from.settings, source: { generation: from, image }, strength })
          }
          onUpscale={(image, from) =>
            runs.start(stream, {
              kind: "upscale",
              settings: { ...from.settings, count: 1 },
              source: { generation: from, image },
            })
          }
        />
      ))}
      <ImageComposer
        settings={settings}
        onSettingsChange={setSettings}
        models={models}
        placeholder="Refine this image"
        onSubmit={(next, reference) => {
          runs.start(stream, { kind: "generate", settings: next, reference })
        }}
      />
    </div>
  )
}

Aufbau

Die Teile, die du zusammensetzt, von außen nach innen.

PartBeschreibung
ImageStudioThe workspace: a header slot with search and favorites, the day-grouped feed, the bottom composer, the viewer and the region editor. Owns runs, refinement, optimistic favorites and undoable deletes.
ImageComposerPrompt input with a reference image, aspect ratio, style, the Options popover (count, model, seed, avoid) and a credits hint. Shows a Refining chip when it is editing an image.
ImageGenerationOne batch: prompt, a live phase line with the orb while it runs, then settings, palette and duration, above a grid at the batch’s aspect ratio.
ImageTileOne image. Comes into focus as progress rises, reveals the result, shows errors with Retry, and offers Refine, Vary and more on hover or focus.
DevelopFieldThe developing surface: a canvas of dithered dots, or monospace glyphs, in the theme's info blue that renders streamed previews in their own colours, settling from the top or, with refine, sharpening across the frame. Canvas only, paused off-screen, still under reduced motion.
ImageLightboxFull-size viewer with zoom, pan, arrow keys between results, every setting, and Refine as the primary action.
MaskEditorPaint or erase a mask with a mouse, finger or the keyboard, then describe what goes there.
useImageRunsThe run engine. Starts, retries and cancels runs, applies events, times each batch and reports finished batches.

ImageStudio

Akzeptiert auch alle div-Props außer children.

PropTypStandard
generationsSaved history, newest first or in any order. Local runs are merged in by id.
Generation[]–
modelsModels to pick from. credits drives the hint, maxCount caps the count, and supportsNegative and supportsReference hide those controls.
ImageModel[]–
presetsStyle presets. The picker hides when empty.
ImagePreset[][]
onGenerate(request, { signal }) => AsyncIterable<ImageEvent>. Yield queued (with an optional position in line), progress (progress or step and steps, plus an optional preview), done and error events per image index. Retry uses the runner that made the batch.
ImageRunner–
onVaryRuns a variation. request.source holds the image and request.strength is subtle or strong. Hidden when missing.
ImageRunner–
onUpscaleRuns an upscale of request.source. Hidden when missing.
ImageRunner–
onEditRuns edits of request.source with the new prompt: Refine sends no mask, Edit a region adds request.mask as a PNG Blob, white where the person painted. Both are hidden when missing.
ImageRunner–
onCancelCalled after Cancel aborts a run, so you can stop the job on the server.
(request: ImageRequest) => void | Promise<void>–
onCompleteCalled when a run settles, including cancelled and partly failed ones, with duration in milliseconds. Save it to your history here.
(generation: Generation) => void–
onFavoriteApplied at once. A rejection rolls it back and shows the error.
(image, favorite, generation) => void | Promise<void>–
onDeleteCalled once the Undo window closes. A rejection brings the image back with an error.
(image, generation) => void | Promise<void>–
onLoadMoreLoads older history when the top of the feed comes into view. The scroll position holds while it prepends.
() => void | Promise<void>–
hasMoreWhether older history exists.
booleanfalse
loadingShows skeleton batches while the first page of history loads.
booleanfalse
uploadUploads a reference image with progress. Without it, the file is passed in request.reference.file.
PromptUpload–
defaultSettingsStarting aspect ratio, count, model, style and seed.
Partial<ImageSettings>–
suggestionsPrompt ideas shown when the history is empty. Picking one fills the composer and its aspect ratio.
{ label, prompt, aspectRatio? }[][]
headerYour brand, credits or account, shown at the start of the top bar. Search and favorites sit at its end once there is history.
ReactNode–
headerClassNameClasses for the top bar, such as extra end padding for your own floating controls.
string–
developEffectHow a generating image draws itself: coloured ASCII glyphs or an ordered dither of coloured dots. Both take the same hover, ripple, bulge and pixel reveal.
"ascii" | "dither""dither"
developRevealscan is a stylised top-to-bottom draw driven by progress. refine shows partial images across the whole tile and sharpens them with progress, matching how models stream.
"refine" | "scan""scan"
autoFocusFocuses the composer on mount, without scrolling the page. Leave it off when the studio is embedded below other content, so the page keeps its scroll position and keyboard focus.
booleanfalse
undoTimeoutHow long Undo stays available after a delete, in milliseconds. 0 deletes at once.
number5000
nowFixes the date used for Today and Yesterday, for tests and server rendering.
Date–

useImageRuns

Returns { live, start, retry, cancel, remove }.

PropTypStandard
onCompleteCalled when a run settles.
(generation: Generation) => void–
onCancelCalled after a run is cancelled.
(request: ImageRequest) => void | Promise<void>–
announceReceives short status messages for a live region.
(message: string) => void–

createImageEventQueue

Turns callback or event-stream APIs into the async iterator a runner returns.

PropTypStandard
pushQueues an event.
(event: ImageEvent) => void–
endEnds the stream. Images still unfinished are marked as failed.
() => void–
failEnds the stream with an error shown on every unfinished image.
(error: unknown) => void–
TasteAktion
EnterGenerates from the prompt, or refines the picked image. Shift+Enter adds a line, and ↑ in an empty prompt brings back the last one.
EscIn the composer, stops refining and goes back to a fresh prompt. In search, clears and closes it.
←→↑↓Moves between images in the feed. The grid is one tab stop, and arrows follow the layout across batches.
HomeEndJumps to the oldest or newest image in the feed.
EnterOpens the focused image in the viewer, where ← and → move between results and Esc closes.
RRefines the focused image: the composer picks it up and asks what to change.
FFavorites the focused image.
VStarts a subtle variation. Shift+V, or Shift-clicking Vary, starts a strong one.
UUpscales the focused image.
EOpens Edit a region. In the editor, arrows move the brush, Space starts or stops painting, [ and ] change its size, and ⌘Z undoes a stroke.
DeleteDeletes the focused image, with Undo in the toast.
  • Every image is labelled from its prompt and position, such as “Image 2 of 4: A lone lighthouse…”, and placeholders add their state, such as Queued, In line, 2nd, Step 12 of 30 or Timed out.
  • Starts, finished batches, failures, refinements, favorites and deletes are announced through a polite live region with one short message per change, not one per step. Running batches are aria-busy.
  • Status never relies on color. The phase line is text with a percentage, tiles show step counts, errors carry an icon and text, favorites use a filled icon and aria-pressed, and the Options dot is repeated in the button’s label.
  • With a fine pointer, image actions appear on hover or focus. On touch screens tiles stay clean and a tap opens the viewer, whose actions are 44px tall. Focus moves to a neighbour after a delete and returns to the image after Undo or closing the viewer.
  • The dither or ASCII field draws every tile from one shared loop at up to 30fps, pauses off-screen and holds a still frame under reduced motion that still tracks progress. The reveal becomes a short fade, and forced-colors mode drops the field while keeping borders and focus rings.

Gebaut mit

Die kostenlosen HextaUI-Komponenten, aus denen Image Studio besteht. Jede lässt sich einzeln installieren.

Code

12 Dateien, hinzugefügt zu components/blocks/image-studio.