---
title: "Image studio"
description: "The Bild-Studio block: a picture with its description, prompt blocks, references, previous generations and chat. The node, its Markdown form, the state reducers, the images backend port and the dialog for your own store."
canonical_url: "https://skaile-ai.github.io/forge-editor/guides/image-studio"
---
# Image studio

> The Bild-Studio block: a picture with its description, prompt blocks, references, previous generations and chat. The node, its Markdown form, the state reducers, the images backend port and the dialog for your own store.

The **Bild-Studio** is a block, not a mode. It holds the text that describes the picture (a placeholder until you write one), opens into a dialog with a chat, and keeps every reference image and every previous generation. One of them is **selected** and is the block's picture.

<table>
<thead>
  <tr>
    <th>
      Part
    </th>
    
    <th>
      What it is
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Description
    </td>
    
    <td>
      The text describing the image. Empty shows „Noch keine Beschreibung“ in the block and „Beschreibe das Bild …“ in the dialog.
    </td>
  </tr>
  
  <tr>
    <td>
      Prompt blocks
    </td>
    
    <td>
      A list of style, brand, … texts that <strong>
        the host manages
      </strong>
      
       (label, text, order). Each can be switched on or off per block.
    </td>
  </tr>
  
  <tr>
    <td>
      References
    </td>
    
    <td>
      Uploaded or picked images. An <em>
        active
      </em>
      
       one goes into the next generation.
    </td>
  </tr>
  
  <tr>
    <td>
      Previous generations
    </td>
    
    <td>
      Every image the chat produced. An <em>
        active
      </em>
      
       one goes into the next generation as a reference.
    </td>
  </tr>
  
  <tr>
    <td>
      Selected image
    </td>
    
    <td>
      The one reference or generation that is the block's picture (<code>
        src
      </code>
      
       of the node).
    </td>
  </tr>
  
  <tr>
    <td>
      Chat
    </td>
    
    <td>
      The log of instructions, each with the image it produced or its problem.
    </td>
  </tr>
</tbody>
</table>

Removing a reference or a generation removes it **from this block only**; the host keeps the file. The package never deletes files.

This guide covers the feature, the node and its Markdown form, the state and its reducers, the backend port, and the dialog for a host that owns the state itself. The framework-free and Vue entries ship since 3.5.0, the React entry (dialog and node view) since 3.5.1.

<table>
<thead>
  <tr>
    <th>
      Entry
    </th>
    
    <th>
      Contents
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        @skaile/forge-editor/image-studio/vue
      </code>
    </td>
    
    <td>
      <code>
        imageStudioFeature()
      </code>
      
      , <code>
        ImageStudioDialog
      </code>
      
      , <code>
        createImageStudioNodeStore
      </code>
      
      , the state and reducers
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/image-studio/react
      </code>
    </td>
    
    <td>
      <code>
        imageStudioFeature()
      </code>
      
      , <code>
        ImageStudioDialog
      </code>
      
      , <code>
        useImageStudioNodeStore
      </code>
      
      , <code>
        ImageStudioReactStore
      </code>
      
      , the state and reducers
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/image-studio
      </code>
    </td>
    
    <td>
      Framework-free: <code>
        imageStudioFeatureCore
      </code>
      
      , the state and reducers
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        @skaile/forge-editor/image-studio/schema
      </code>
    </td>
    
    <td>
      Vue-free <code>
        imageStudioSchemaExtensions()
      </code>
      
       and <code>
        ImageStudioNode
      </code>
      
       for servers
    </td>
  </tr>
</tbody>
</table>

## Enabling it

Add `imageStudioFeature()` to `defineEditor({ features })` next to the core features. The feature has no other dependency and imports no other feature.

```ts
import { coreContentFeature, coreUiFeature, defineEditor } from '@skaile/forge-editor/core'
import { imageStudioFeature } from '@skaile/forge-editor/image-studio/vue'

const preset = defineEditor({ features: [coreContentFeature(), coreUiFeature(), imageStudioFeature()] })
```

On the React surface (shadcn) import `imageStudioFeature` from `@skaile/forge-editor/image-studio/react` instead; everything else, the block, the Markdown form, the backend port and the behaviour described below, is the same.

The feature adds:

- the `imageStudio` block (an atom, draggable; it revokes the text abilities, so no marks or text commands apply inside it),
- the `/` entry „Bild-Studio“ (command id `imageStudio`, slash id `bild-studio`), offered where the caret has the ability `insert.imageStudio` (`ABILITY.imageStudio`), and the command `insertImageStudio(attrs?)`,
- the block action `image-studio.open` („Im Bild-Studio öffnen“, in the block menu and on the block's chrome), which asks the block's view to open the dialog. The view also opens it on double-click and from its „Bild-Studio öffnen“ button.

A server that validates or stores documents containing the node builds its schema from the schema entry:

```ts
import { getSchema } from '@tiptap/core'
import { coreSchemaExtensions } from '@skaile/forge-editor/core/schema'
import { imageStudioSchemaExtensions } from '@skaile/forge-editor/image-studio/schema'

const schema = getSchema([...coreSchemaExtensions(), ...imageStudioSchemaExtensions()])
```

## The node and its Markdown form

The node `imageStudio` has the attributes `src` (the url of the selected image, `null` without one), `alt` and `studio` (the whole `ImageStudioState`). `setImageStudioState(pos, state)` replaces the state of the block at `pos` in one transaction and sets `src` from the selected image, so undo takes both back together.

<table>
<thead>
  <tr>
    <th>
      Form
    </th>
    
    <th>
      Shape
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      HTML
    </td>
    
    <td>
      <code>
        <img data-image-studio src alt data-studio="{json}">
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      Markdown
    </td>
    
    <td>
      <code>
        [Bild-Studio: <alt>](<src>) <!-- {"studio": …} -->
      </code>
      
       on its own line
    </td>
  </tr>
</tbody>
</table>

```md
[Bild-Studio: Segelboot im Abendlicht](/files/a1.png) <!-- {"studio":{"description":"Ein Segelboot im Abendlicht","promptBlockIds":["style"],"references":[],"generations":[{"id":"a1","url":"/files/a1.png","title":"Version 1","active":false}],"selected":"a1","turns":[{"id":"t1","instruction":"","imageId":"a1","createdAt":"2026-10-21T09:00:00.000Z"}]}} -->
```

The label is the literal `Bild-Studio` in every UI language, because parsing looks for it. The comment is left out while the studio is empty (no description, prompt block, image or turn). Reading a document normalises the state: a missing or mistyped field falls back to its empty value, an entry without an `id` is dropped and a `selected` that points at no image becomes `null`. A valid state round-trips unchanged.

## The state and its reducers

The state is plain JSON and knows nothing about the host: image ids are opaque (a host uses its own asset ids).

```ts
interface ImageStudioImage { id: string, url: string, title: string, active: boolean }
interface ImageStudioTurn { id: string, instruction: string, imageId: string | null, createdAt: string, problem?: string }
interface ImageStudioState {
  description: string // '' shows the placeholder
  promptBlockIds: string[] // active prompt blocks, by the host's ids
  references: ImageStudioImage[]
  generations: ImageStudioImage[]
  selected: string | null // id of the generation (or reference) that is the block's image
  turns: ImageStudioTurn[] // the chat log: instruction → generation
}
```

`EMPTY_IMAGE_STUDIO_STATE` is the state of a new block. The reducers are pure: each returns a new state and never touches its argument.

<table>
<thead>
  <tr>
    <th>
      Reducer
    </th>
    
    <th>
      Effect
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        setDescription(state, text)
      </code>
    </td>
    
    <td>
      Replaces the description.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        togglePromptBlock(state, id)
      </code>
    </td>
    
    <td>
      Adds the prompt block to the active ones, or removes it when it is active.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        toggleImage(state, id)
      </code>
    </td>
    
    <td>
      Flips <code>
        active
      </code>
      
       of the reference or generation; an unknown id changes nothing.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        select(state, id | null)
      </code>
    </td>
    
    <td>
      Makes the image the block's picture; <code>
        null
      </code>
      
       clears the choice; an unknown id changes nothing.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        removeImage(state, id)
      </code>
    </td>
    
    <td>
      Drops the image from <code>
        references
      </code>
      
       and <code>
        generations
      </code>
      
      ; clears <code>
        selected
      </code>
      
       when it was selected. The chat log keeps its turns.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        addReference(state, image)
      </code>
    </td>
    
    <td>
      Appends a reference; one with the same id is replaced where it stands.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        adoptImage(state, image)
      </code>
    </td>
    
    <td>
      Makes the image the block's picture: unchanged when it already is; an image the block holds is only selected; any other is added as a reference first.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        addGeneration(state, image, turn)
      </code>
    </td>
    
    <td>
      Appends the generation and its turn (pointing at the image) and <strong>
        selects it
      </strong>
      
      : a chat generate means „this is the picture now“; an earlier one can be selected again.
    </td>
  </tr>
</tbody>
</table>

Helpers: `selectedImage(state)` (the selected image, if the selection still points at one), `isEmptyImageStudioState(state)`, `normalizeImageStudioState(value)` (for stored JSON from outside). All of them, with the types, are exported from `@skaile/forge-editor/image-studio` and `/image-studio/vue`.

## The backend port

Generation, uploads and the list of prompt blocks go through the `images` capability of the [`EditorBackend`](https://skaile-ai.github.io/forge-editor/guides/editor-ports#capability-probe), the last entry of `BACKEND_CAPABILITIES`:

```ts
interface ImagesPort { // EditorBackend['images']
  promptBlocks: () => Promise<ImageStudioPromptBlock[]> // { id, label, text }, in the host's order
  generate: (body: ImageStudioGenerateBody, signal?: AbortSignal) => Promise<ImageStudioImage> // resolves with active: false
  upload: (documentId: string, file: File) => Promise<ImageStudioImage> // resolves with active: true
}

interface ImageStudioGenerateBody {
  description: string
  instruction: string // '' generates from the description alone
  promptBlockIds: string[] // the active prompt blocks
  referenceUrls: string[] // urls of the active references and generations
}
```

The HTTP backend (`createHttpBackend()`) maps the three members to routes. With `probe: true` it drops `images` when the server does not list the capability.

<table>
<thead>
  <tr>
    <th>
      Member
    </th>
    
    <th>
      Route
    </th>
    
    <th>
      Body
    </th>
    
    <th>
      Answer
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        promptBlocks()
      </code>
    </td>
    
    <td>
      <code>
        GET /api/editor/images/prompt-blocks
      </code>
    </td>
    
    <td>
      none
    </td>
    
    <td>
      <code>
        ImageStudioPromptBlock[]
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        generate(body, signal)
      </code>
    </td>
    
    <td>
      <code>
        POST /api/editor/images/generate
      </code>
    </td>
    
    <td>
      JSON <code>
        ImageStudioGenerateBody
      </code>
      
       (<code>
        description
      </code>
      
       ≤ 10 000, <code>
        instruction
      </code>
      
       ≤ 4 000 characters, ≤ 50 <code>
        promptBlockIds
      </code>
      
      , ≤ 20 <code>
        referenceUrls
      </code>
      
      )
    </td>
    
    <td>
      <code>
        ImageStudioImage
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        upload(documentId, file)
      </code>
    </td>
    
    <td>
      <code>
        POST /api/editor/images/upload
      </code>
    </td>
    
    <td>
      multipart: <code>
        documentId
      </code>
      
       (a document id or <code>
        scratch
      </code>
      
      ) and <code>
        file
      </code>
    </td>
    
    <td>
      <code>
        ImageStudioImage
      </code>
    </td>
  </tr>
</tbody>
</table>

The routes authenticate like every other editor route. `400` answers a malformed body, a bad `documentId` or a missing file; `413` a file over the image limit (`MAX_MEDIA_BYTES.image`); `415` a file that is no image; `501` any of the three when the server has no `images` port.

The memory backend (`createMemoryBackend()`) has **no** `images` member: image generation needs a server that holds the model key and stores the files. In such a client the block still works as a picture holder, but opening the studio (the block action, double-click or the button) shows the notice „Bildgenerierung ist nicht eingerichtet“ instead of the dialog.

### The server port

On the server the host supplies `EditorPorts.images`, an `ImageProvider`. The package owns authentication, validation and the routes; the provider owns the prompt, the model and the storage. `user` is what `identity.authenticate` returned.

```ts
import { provideEditorServer } from '@skaile/forge-editor/server/nitro'

provideEditorServer({
  ports: {
    identity,
    documents,
    images: {
      // The managed list (label, text, order), e.g. rows of your own table.
      promptBlocks: user => listPromptBlocks(user),
      // Build the prompt from description + instruction + the texts of body.promptBlockIds,
      // pass body.referenceUrls to the model, store the result and return it with active: false.
      generate: async (body, user) => {
        const file = await runImageModel(buildPrompt(body), body.referenceUrls)
        const stored = await storeImage(file, user)
        return { id: stored.id, url: stored.url, title: stored.title, active: false }
      },
      // The route has already checked documentId, size and that the file is an image.
      upload: async (documentId, file, user) => {
        const stored = await storeUpload(documentId, file, user)
        return { id: stored.id, url: stored.url, title: stored.title, active: true }
      },
    },
  },
})
```

`images` maps to the capability `images` (`PORT_CAPABILITY`), so `serverCapabilities(ports)` and `GET /api/editor/capabilities` list it only when the port is given. Without it the three routes answer `501`. The port is described with the others in [EditorPorts](https://skaile-ai.github.io/forge-editor/guides/editor-ports).

## The dialog and your own store

`ImageStudioDialog` is exported for hosts; the block's view uses the same component. The Vue dialog is a modal (`useUiKit().Modal`), the React one a shadcn `Dialog`; both have three columns: description and prompt blocks on the left (a switch per prompt block, its text underneath), references and previous generations in the middle, the selected image and the chat on the right. They use the same texts (`imageStudio.*`).

<table>
<thead>
  <tr>
    <th>
      Prop / event
    </th>
    
    <th>
      Meaning
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        open: boolean
      </code>
      
       (<code>
        v-model:open
      </code>
      
      ; React: <code>
        open
      </code>
      
       and <code>
        onOpenChange(open)
      </code>
      
      )
    </td>
    
    <td>
      Whether the dialog is shown; Vue emits <code>
        update:open
      </code>
      
      , React calls <code>
        onOpenChange
      </code>
      
      .
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        store: ImageStudioStore
      </code>
      
       (React: <code>
        ImageStudioReactStore
      </code>
      
      )
    </td>
    
    <td>
      What the dialog drives, see below.
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        title?: string
      </code>
    </td>
    
    <td>
      Dialog title; default „Bild-Studio“.
    </td>
  </tr>
</tbody>
</table>

In the middle column a click on a tile toggles `active` (a ring marks it), „Als Bild verwenden“ selects it and „Entfernen“ removes it from the block. A reference is added by the upload button or by dropping files on the zone. In the chat, `Enter` sends the instruction, `Shift+Enter` adds a line, and „Erzeugen“ is disabled while a generation runs. Closing the dialog aborts a running generation; an aborted generation adds nothing, a failed one adds a turn with `problem` set. The description is committed 500 ms after the last keystroke and at once on blur, on „Erzeugen“ and on close, so a prompt is never built from a stale text.

The dialog applies the reducers itself and hands every resulting state to `store.update`. The store only persists, generates and uploads:

```ts
interface ImageStudioStore {
  readonly state: Ref<ImageStudioState>
  readonly promptBlocks: Ref<readonly ImageStudioPromptBlock[]> // the managed list, loaded by the host
  update: (next: ImageStudioState) => void // after a reducer; the host persists it
  generate: (instruction: string, signal: AbortSignal) => Promise<ImageStudioImage> // the host builds the prompt
  upload: (file: File) => Promise<ImageStudioImage> // the dialog then calls addReference
  readonly problem: Ref<string | undefined> // a problem of the store itself, shown as a notice
  readonly library?: ImageStudioLibrary // the host's gallery; a «Galerie» tab appears only when set
  readonly placeholderUrl?: string // shown in the preview while no image is selected
}
```

`generate` reads the description, the active prompt blocks and the active images from `state` and returns **one** image; the dialog calls `addGeneration` with it. Its store methods never write `state` themselves.

For a studio inside the editor, `createImageStudioNodeStore({ editor, getPos, backend, documentId })` builds the store of one `imageStudio` node: `state` follows the node's `studio` attribute (so undo, collaboration and other writers show), `update` writes it back with `setImageStudioState`, and the rest goes to `backend.images`. Call its `dispose()` when the view goes.

A host that keeps the state **outside the document** (a sidecar next to the block, a database row) writes its own store and uses the dialog directly:

```vue
<script setup lang="ts">
import { ImageStudioDialog, type ImageStudioStore, normalizeImageStudioState } from '@skaile/forge-editor/image-studio/vue'
import { ref, shallowRef } from 'vue'

const open = ref(false)

// The block's studio state lives in the host's own record.
const state = shallowRef(normalizeImageStudioState(record.studio))
const promptBlocks = shallowRef(await fetchPromptBlocks())
const problem = shallowRef<string>()

const store: ImageStudioStore = {
  state,
  promptBlocks,
  problem,
  update(next) {
    state.value = next
    void saveStudio(record.id, next) // persist; also mirror the selected image into the block here
  },
  async generate(instruction, signal) {
    const { description, promptBlockIds, references, generations } = state.value
    const referenceIds = [...references, ...generations].filter(image => image.active).map(image => image.id)
    return await requestImage({ description, instruction, promptBlockIds, referenceIds }, signal)
  },
  upload: file => uploadReference(file),
}
</script>

<template>
  <UButton label="Bild-Studio" @click="open = true" />
  <ImageStudioDialog v-model:open="open" :store="store" />
</template>
```

### React

The React dialog takes the same contract with plain values instead of refs, `ImageStudioReactStore`: `state`, `promptBlocks` and `problem` are values, `update`, `generate` and `upload` are the same functions. The host re-renders the dialog with the new `state` after `update`; until `state` changes the dialog keeps building on the state it handed to `update`, so several uploads that finish together all land. The identity of `update` is the identity of the store: keep it stable while the dialog edits one image (`useCallback`), and hand the open dialog another `update` only with another image, so a pending description edit is committed to the image it was typed for. A host that keeps the state in a record writes it with `useState`:

```tsx
import { ImageStudioDialog, type ImageStudioPromptBlock, type ImageStudioReactStore, type ImageStudioState, normalizeImageStudioState } from '@skaile/forge-editor/image-studio/react'
import { useCallback, useMemo, useState } from 'react'

function StudioButton({ record, promptBlocks }: { record: { id: string, studio: unknown }, promptBlocks: ImageStudioPromptBlock[] }) {
  const [open, setOpen] = useState(false)
  const [state, setState] = useState(() => normalizeImageStudioState(record.studio))
  // Stable per record: a new `update` tells the dialog that another image is open.
  const update = useCallback((next: ImageStudioState) => {
    setState(next)
    void saveStudio(record.id, next) // persist; also mirror the selected image into the block here
  }, [record.id])
  const store: ImageStudioReactStore = useMemo(() => ({
    state,
    promptBlocks,
    problem: undefined,
    update,
    async generate(instruction, signal) {
      const { description, promptBlockIds, references, generations } = state
      const referenceIds = [...references, ...generations].filter(image => image.active).map(image => image.id)
      return await requestImage({ description, instruction, promptBlockIds, referenceIds }, signal)
    },
    upload: file => uploadReference(file),
  }), [state, promptBlocks, update])
  return (
    <>
      <button onClick={() => setOpen(true)}>Bild-Studio</button>
      <ImageStudioDialog open={open} onOpenChange={setOpen} store={store} />
    </>
  )
}
```

For the node's own store use `useImageStudioNodeStore({ editor, getPos, backend, documentId })`, which wraps `createImageStudioNodeCore` (the framework-free observable the Vue store wraps as well: `get()`, `subscribe(listener)`, `update`, `generate`, `upload`, `dispose()`). It returns `undefined` on the first render, because the core is built in an effect; render the dialog once there is a store.

Image `id`s can be the host's own ids (an asset id, say) and `url` any url the page can show; the library only compares ids. The host decides what a removed image means for its files, and whether `selected` is also written to wherever the block's picture is read from.

### The gallery tab

With `store.library` the middle column gets two tabs: „Dieser Block“ (references and previous generations, as above) and „Galerie“ (the host's images). The tab re-reads `facets()` each time it opens, lists with the first option of every facet and an empty search, and lists again after a facet change and 300 ms after the last keystroke of the search; a newer read aborts the running one. A tile offers „Als Bild verwenden“ (`adoptImage`), „Als Referenz“ (`addReference`, disabled once the block holds the image), and, when the host gives `rename` / `remove`, a pencil (Enter commits, Escape or blur cancels) and a trash button. A refused `remove` shows the error's message in the tab and keeps the tile; a granted one also drops the image from the block when it holds it.

```ts
interface ImageStudioLibrary {
  facets: () => readonly ImageStudioLibraryFacet[] // { id, label, options: { value, label }[] }; the first option is the default
  list: (query: ImageStudioLibraryQuery, signal: AbortSignal) => Promise<readonly ImageStudioLibraryItem[]> // query: { search, facets: { [facetId]: value } }; item: an ImageStudioImage plus an optional subtitle
  rename?: (id: string, title: string) => Promise<ImageStudioLibraryItem>
  remove?: (id: string) => Promise<void> // reject with an Error whose message the dialog shows
}
```

`placeholderUrl` replaces the text „Noch kein Bild“ in the preview with that picture while no image is selected. The node store sets neither member.

## See also

- [Public exports](https://skaile-ai.github.io/forge-editor/reference/exports) lists the three entries.
- [EditorPorts](https://skaile-ai.github.io/forge-editor/guides/editor-ports) explains the ports, the capability probe and the capability classes.
- [Media, mentions and transcript](https://skaile-ai.github.io/forge-editor/guides/media-mentions-transcript) covers the plain image block and uploads through the `assets` port.


## Sitemap

See the full [sitemap](https://skaile-ai.github.io/forge-editor/sitemap.md) for all pages.
