A local web studio for iris.c — native FLUX.2 Klein and Z-Image image generation on Apple Silicon.
iris studio is a Go server (no JS build step, two Go dependencies) that drives
the iris binary: it builds the CLI arguments, runs one-shot or interactive
sessions, manages reference images, queues renders and streams live denoising
previews — in a browser tab, nothing sent off your machine. It runs as a
helmstudio studio and only that way: helmstudio installs it,
launches it, gives it the directory it keeps sessions in, and takes every
finished take into the library it shares with the other studios.
Image diffusion on Apple Silicon usually means PyTorch's mps backend or a
node-graph app around it — slow for this workload, and holding far more unified
memory than the model needs. iris.c is a native Metal implementation of FLUX.2
Klein and Z-Image with no PyTorch or MLX in the loop; iris studio makes it a
tool you can use, without a Python stack.
- Requirements · Installation —
Path A: the launcher ·
Path B: a checkout ·
scripts/run.sh· Troubleshooting · Debugging - Using the studio — Flags · What helmstudio adds
- Features · Sessions and state · Interactive mode
- Security · Limits · Contributing · License
macOS on Apple Silicon. The engine's Metal/MPS backend is what this is for;
iris.c also builds BLAS and generic backends for other platforms, untested
here. A checkpoint wants 16–30 GB of disk, and mmap is on by default, so
inference is often possible with less RAM than the model's size.
| Why | |
|---|---|
helmstudio's helm |
What runs iris studio. It does not start without it — see Installation. |
git, make, gcc, xxd |
Building the engine, make mps in the iris.c submodule. These are what the Makefile and helmstudio.yaml name. |
| Go 1.27+ | Building the server. The first build fetches helmstudio's runtime SDK through the Go module proxy, so it wants the network once. |
One at a time: --model points at a single checkpoint directory, which is how
iris itself works. helmstudio downloads the one you choose at install and can
fetch the others later; a checkout links one you already have. What
iris.c/download_model.sh offers:
| Name | Steps | Disk | Notes |
|---|---|---|---|
4b |
4 | ~16 GB | Distilled. The one to start with. |
4b-base |
50 | ~16 GB | Base, CFG. |
9b |
4 | ~30 GB | Distilled, non-commercial licence, gated. |
9b-base |
50 | ~30 GB | Base, CFG, non-commercial, gated. |
zimage-turbo |
9 | ~22 GB | Z-Image-Turbo 6B, Apache 2.0. |
The two 9b variants are gated on Hugging Face: accept the licence there,
then pass --token <your-token> to download_model.sh. Installing through
the launcher instead, helmstudio asks for the token itself.
Two ways in — iris studio never starts on its own, since whatever launches it also gives it a session directory and the library it records takes into.
| A — Install it | B — Run from a checkout | |
|---|---|---|
| For | using the studio | changing the studio |
| Needs | the launcher, helmstudio's daemon and web UI | the helm CLI; helm dev runs one studio, no daemon |
| Install | one click in its library | git clone, make mps, bash scripts/run.sh |
| Checkpoints | it downloads or links them | you point IRIS_MODEL at one |
The launcher clones this repository, runs the build steps in
helmstudio.yaml and fetches the checkpoint you pick.
1. Install the launcher — helmstudio itself, a daemon and a web UI. Either the Mac app from its releases, which bundles the daemon and is unsigned, so macOS calls it damaged until you clear the quarantine flag once (the release notes give the line); or a clone:
git clone https://github.com/janishar/helmstudio && cd helmstudio && make build && ./bin/helmstudioThen open http://127.0.0.1:8700; everything lives in ~/.helmstudio. Not
the helm installer — that is the studio author's CLI, with no library and no
gallery, and is what Path B wants.
2. Install iris studio from its library. It is in helmstudio's registry, so
it is already listed. Install it and it first shows every command it will run
and every weight it could fetch: the engine (make mps in the submodule), the
server (go build), and which of the five checkpoints to launch with. The
other four can be fetched later.
3. Start it. The launcher gives the studio a port, the checkpoint's path and a data directory of its own, then opens its page → Using the studio.
The developer's path: a checkout run against helmstudio's platform API, with
nothing installed into helmstudio. scripts/run.sh builds the
server, links your checkpoint and starts everything under helm dev, so the
engine is the only thing you build by hand.
# 1. helm, the CLI that runs this checkout
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/janishar/helmstudio/main/installer/install.sh)"
# 2. the checkout, with iris.c as a submodule
git clone --recurse-submodules https://github.com/janishar/iris-studio && cd iris-studio
# 3. the engine → iris.c/iris, the one thing the script does not build
make mps -C iris.c
# 4. a checkpoint, if you have none — see Checkpoints above
(cd iris.c && ./download_model.sh 4b)
# 5. start, or restart; then `stop` to end it
IRIS_MODEL=$PWD/iris.c/flux-klein-4b IRIS_WEIGHT=flux_klein_4b bash scripts/run.sh
bash scripts/run.sh stopscripts/run.sh needs a helm with dev -select, which is newer than
1.0.0-rc.3. All five checkpoints are selectable, so {models.selected} has to
be told which one to be; an install asks on its approval screen, and a checkout
has no approval screen. The script checks for the flag and says so rather than
failing on an unknown one.
- Checks that
helmis onPATH, that it hasdev -select, and thatiris.c/irisis built — naming the fix for each. - Builds
./iris-studio, every run, becausehelm devruns no build steps of its own and would otherwise launch whatever was there. - Links the checkpoint as
IRIS_WEIGHTwhenIRIS_MODELis set, per file: nothing is downloaded and the directory is never written to. - Selects that checkpoint, which is what
{models.selected}resolves to. - Runs
helm dev -f helmstudio.yaml, which supplies the platform, a data directory and the/helm/proxy. Extra arguments go tohelm dev.
| Variable | Default | What it does |
|---|---|---|
IRIS_MODEL |
(none) | Checkpoint directory to link. Needed on the first run only — helm dev records where the weight was linked. |
IRIS_WEIGHT |
flux_klein_9b |
Which of the five declared checkpoints that is, and which one to launch with. |
HELM |
helm |
A particular helm instead of the one on PATH. |
IRIS_DLV |
(none) | Port to listen for a debugger on — see below. |
Sessions and everything else the studio keeps go to .helm/, the pid file and
the debugger's shim to .cache/iris-studio; both are gitignored, and nothing is
written into the checkout. The manifest's command carries no --dev, so a
front-end edit means re-running the script, not refreshing the browser.
| What you see | What it means |
|---|---|
helm is not installed. |
Run the installer in Path B, or set HELM. |
this helm has no 'dev -select' |
The helm on PATH predates the flag. Build one from a helmstudio checkout and point HELM at it. |
iris.c/iris is not built |
git submodule update --init --recursive, then make mps -C iris.c. |
weights: no checkpoint at … |
IRIS_MODEL must name a directory holding transformer/ and vae/. |
no checkpoint is chosen for this studio |
IRIS_WEIGHT names a weight the manifest does not declare. |
iris studio runs under helmstudio. |
The binary was started by hand. Use bash scripts/run.sh, or the launcher. |
/helm/ assets 404 |
A stale ./iris-studio — re-run the script. |
dlv is not installed |
go install github.com/go-delve/delve/cmd/dlv@latest |
IRIS_MODEL=/path/to/flux-klein-4b IRIS_WEIGHT=flux_klein_4b IRIS_DLV=2345 bash scripts/run.shhelm dev hands the studio a restricted environment and the manifest names
./iris-studio, not a debugger — so the binary moves aside and that name
becomes a shim running it under Delve on the port, built -N -l so
stepping follows the source and given --continue so the studio starts instead
of waiting for a client. A run without IRIS_DLV builds over the shim.
VS Code has one launch configuration, because there is one way to run the
studio: iris studio (F5) runs debug: iris studio — the script with
IRIS_DLV=2345 — and attaches the Go debugger; ending it runs stop: iris
studio. .vscode/tasks.json holds those and four more (run: iris studio
without the debugger, build: iris studio, build: iris binary (mps),
test: go (race)) and the IRIS_MODEL each run uses.
Write a prompt, pick a size, press Generate. Choose One-shot or
Interactive (fast) in the left pane: interactive iris starts on Load
model or the first interactive render, so starting the studio never loads the
model. What each panel does is Features; where the work is kept is
Sessions and state.
You never type this — the launcher and scripts/run.sh both build it from
helmstudio.yaml's processes[0].cmd — but the flags are worth knowing:
./iris-studio --iris ./iris.c/iris --model <checkpoint> --port <port> --root <data>The paths are remembered in <data>/sessions/iris.json and model.json, so a
later launch can omit them; a flag or environment variable always wins.
There is no authentication — see Security before binding to
anything but 127.0.0.1.
| Flag | Default | Description |
|---|---|---|
--root |
(required) | Directory holding sessions/ — the data directory helmstudio gives this studio. It creates none of its own and will not start without one, and a take has to be inside it for helmstudio to adopt it. |
--iris |
$IRISSTUDIO_IRIS, else last used |
Path to the built iris binary. |
--model |
$IRISSTUDIO_MODEL, else last used |
Path to one checkpoint directory. |
--host |
127.0.0.1 |
Bind address. |
--port |
8720 |
Bind port; helmstudio passes the one it allocated. |
--dev |
false |
Reload the browser when files under static/ change. static/ is looked for beside the binary, one level up, then in the working directory — never under --root, which holds no source. |
--allow-host |
(none) | Comma-separated extra Host header names to accept. IP addresses, localhost and --host are always accepted; anything else is refused, which is what blocks DNS rebinding. Needed only if you reach the studio by a name — see Security. |
Both paths are also changeable from ⚙ Paths in the top bar while the studio runs, which is how you switch checkpoints without a relaunch.
iris studio draws its own page — form, preview, takes rail, terminal — and helmstudio adds four things around it:
| Gallery | helmstudio's own grid over this studio's takes, live: a take that finishes appears without a reload. Scoped to this studio; its library is where these sit beside other studios' work. |
| References from anywhere | from gallery, beside Reference images, picks an image out of that grid and copies it into this session's inputs/. It can be another studio's image — that is what a shared gallery is for. |
| Render log | A second Terminal tab streaming the render as helmstudio sees it; it reconnects after a dropped stream and says what it missed. Output's input line and clear hide while it shows — helm-terminal brings its own. |
| The launcher | A render appears there as a job with its progress and its log, so what this studio is doing is visible from outside it. |
All of it arrives through the same-origin /helm/ proxy the server mounts, as
do the theme and this studio's colour, so the page holds no token of
helmstudio's. If those components cannot load the page still works: the three
it puts on this page stay hidden, and everything else is untouched.
There is no timeline. A helmstudio sequence is an edit of video clips, and this studio makes stills.
- One checkpoint, switchable while it runs — set at launch by
--modeland labelled automatically for the known FLUX.2 Klein and Z-Image variants; change it from ⚙ Paths in the top bar, which opens the same kind of path-editing modal as--iris. - Text-to-image and image-to-image — upload reference images and combine up to 16 of them through iris's in-context multi-reference conditioning. References are ordered, and the model reads that order.
- One-shot or interactive — one-shot spawns a fresh
irisper generation (simplest, reloads the weights every time); interactive keeps oneirisREPL loaded, so repeated generations skip a weight load that costs seconds to minutes. - Live step preview — with Live step preview on, each denoising step's intermediate frame streams into the browser over the same Kitty graphics protocol iris uses for terminal display.
- Schedules — model default, linear, power curve (with α), shifted sigmoid for Flux, FlowMatch Euler for Z-Image.
- Sessions — each keeps its own references, takes and settings, and switching is instant. + New, ⧉ Duplicate and 🗑 Delete are in the top bar.
- Terminal panel — raw
irisoutput streams live; when interactive mode is loaded you can type REPL commands (!explore 4 a cat,!help, …) into it. That is all the input line does — it is not a shell, see Security.
A session is a directory under the data directory helmstudio gives as --root,
holding one line of work: inputs, outputs and the UI state that produced them.
Nothing is shared between sessions, so switching is instant. For an installed
studio that root is ~/.helmstudio/studios/iris-studio/data; for a checkout
under helm dev it is ./.helm/data. Nothing is written into the repository.
sessions/<name>/
├── setting.json # the session's last prompt and parameters, plus a takes history
├── terminal.log # this session's terminal output
├── inputs/ # uploaded reference images
└── outputs/ # generated PNGs, each with a <name>.json sidecar of run metadata
inputs/is the only place renders read references from. Uploads, takes pulled back with Use as reference, and images picked out of helmstudio's gallery all land here first.outputs/holds only whatirisproduced: the PNG and its sidecar. Deleting the image deletes its sidecar and prunes it from the session's takes history.sessions/last_session.jsontracks the last active session and is restored on start, creatingsession-1if empty.sessions/iris.jsonandsessions/model.jsonremember the paths, so a later launch can omit them.
A finished take becomes helmstudio's too, without changing any of the above: it
is written to outputs/ as always, and helmstudio adopts it by hardlink —
the same bytes under a second name, one inode, counted once. The gallery item
carries the sidecar's parameters plus the session name as iris_session, since
helmstudio checks session ids against its own and these are not those. Deleting
a take here does not undo that: the hardlink keeps the bytes and the gallery
item stays, so a take you want gone must go there too.
iris_cli.c's REPL works over a pipe — it falls back to plain fgets-style
line reading when stdin is not a TTY — but a few of its status lines (Seed: …, Loaded: …, Done -> …) are printf'd to stdout without an explicit
flush, so they can sit in libc's block buffer indefinitely when piped rather
than connected to a terminal. The studio works around this instead of patching
iris.c:
- Reference
$Nids are tracked deterministically — iris assigns them in the exact order!loadand generation commands are processed — so the studio never waits on theLoaded: …line. - Completion is detected by polling iris's own temp output directory
(
/tmp/iris-XXXXXX/) for a new, size-stable PNG, racing theDone ->line in case it does arrive. - The realised seed of a random request is not reliably captured in interactive mode for the same reason. One-shot captures it, because there iris prints it to stderr, which is unbuffered.
Live preview frames and step progress are unaffected — iris flushes after each
one. The two modes print progress differently ( Step 2/4 … from the one-shot
CLI, [2/4]: from the REPL) and the studio reads both.
iris studio has no authentication. Read this before binding it to anything
but 127.0.0.1.
- It binds to
127.0.0.1by default. Anyone who can reach the port can run renders and read every take in every session. Nothing in the guard below is a substitute for that: it stops a web page you visit from reaching the studio, not someone who can reach the port. - There is no shell. The terminal panel types into interactive
irisand nothing else; a lineirisdoes not recognise is an error fromiris. The studio no longer hands anything to/bin/sh, and there is no flag that brings that back. h3 studio keeps the feature behind--allow-shellbecause it is started from a command line; iris studio is started by helmstudio from the manifest, where a flag nobody can reach would only be the same hole with a longer name. - Every request is checked before it is routed
(
guard,server/handlers.go):- The
Hostheader must name this server — an IP literal,localhost,--host, or a name passed to--allow-host. This is what blocks DNS rebinding: a name the attacker controls, re-pointed at127.0.0.1. - A state-changing request must be same-origin. An
Originfrom anywhere else is refused, and with noOriginat all aSec-Fetch-Site: cross-siteis refused. - It must carry
application/json. A cross-site page cannot send that without a CORS preflight, which the browser then refuses. This is what closes thetext/plainPOST that used to reach every route: the body is no longer decoded whatever the content type says. - Two exceptions, both for requests that are preflighted anyway.
/api/uploadis a raw body named by anX-Filenameheader, and a custom header forces the preflight by itself. The/helm/proxy also takes the content types helmstudio's SDK writes in — a+jsonmerge patch, or no content type on a body-less:cancel/DELETE— neither of which a cross-site form can send.
- The
- Everything the page uses of helmstudio's comes through the same-origin
/helm/proxy, so the browser never holds helmstudio's token. The proxy forwards the studio API and the theme stream and nothing else — a launcher path (install, launch, stop) 404s and never reaches the daemon.
What is still open: ⚙ Paths changes the iris binary and the checkpoint
from the browser (/api/iris, /api/model), and the binary is then executed.
The guard puts that out of reach of another page, but not of anyone who can
reach the port. It is kept because switching checkpoints without a relaunch is
what the panel is for.
server/guard_test.go holds the cases, including the cross-site POST that
used to run a shell command.
- One render at a time, deliberately.
- Cancel sends
SIGTERMto iris's process group, thenSIGKILLafter 2 seconds; an interactive render is stopped by unloading the model. - Interactive mode does not reliably report the realised seed — see above.
- Progress lags a step: iris writes
Step 2/4without a newline, so the line only arrives when the next step begins with one. - Recording happens as a take finishes and nothing is reconciled afterwards: a take deleted here stays in helmstudio's gallery.
/api/queuedoes not answer helmstudio's busy contract yet, so its switch-studio dialog reads iris studio as unknown rather than busy or idle.- There is no test for the interactive path; it needs a live
irisREPL and is verified by hand.
Bug reports, feature requests and pull requests are all welcome. Two
conventions worth knowing: no Go dependencies beyond helmstudio's runtime SDK
and fsnotify, and no front-end build step — static/ is served as written.
Run go test -race ./... before opening one.
Engine issues — generation quality, speed, Metal kernels, checkpoint support — belong in iris.c's own repository.
MIT