> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lazycloud.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Image studio

> A Next.js and FastAPI app that generates images on a GPU, streams progress over a WebSocket and keeps a gallery.

A web page takes a prompt and returns up to four images from FLUX.2 \[klein]
4B, showing each denoising step as it runs. Images stay in a gallery for seven
days, and any of them can become a public link. You get one URL to open, a
studio key to type once, and a system that runs nothing between jobs except a
nightly cleanup.

## How it fits together

| Workload | Kind | What it does | Why it runs there |
| - | - | - | - |
| `studio` | ASGI app | Serves the Next.js page, the JSON API and the progress WebSocket | Page and API share one origin, so one key guards both; a quarter CPU covers it |
| `generate_images` | GPU function | Runs the FLUX.2 pipeline for one job | The model needs about 16 GB of GPU memory, so an L4 or A10G fits; one call per job gives each its own task and queue slot |
| `download_weights` | CPU function | Fetches 16 GB of weights into a volume once | Downloading is network work, not GPU work |
| `share_image` | CPU function | Saves one image as an artifact and returns a public link | An artifact belongs to the task that saves it |
| `delete_expired_images` | schedule | Deletes jobs older than seven days at 04:00 UTC | Keeps the volume and the gallery listing bounded |

Two volumes hold the weights and the finished images, a map holds one
progress record per running job, and the `IMAGE_STUDIO_KEY` secret is the
studio key. Idle, the system pays for storage only, about 16 GB of weights
plus the last seven days of images.

## Run it

**1. Download**

The CLI installs once with `uv tool install lazycloud-client`. The commands
below download a standalone project and install its dependencies; no
repository checkout is needed. The guide then runs from that directory through
`uv run`, which uses the project's pinned SDK.

```bash theme={null}
lazycloud example download image-studio
cd image-studio
uv sync
uv run lazycloud login
```

PyTorch and diffusers sit in the `gpu` dependency group, which only the
remote image installs.

**2. Configure**

The script asks for a studio key, stores it as a secret and runs
`download_weights`.

```bash theme={null}
uv run python -m image_studio.configure
```

**3. Deploy**

```bash theme={null}
uv run lazycloud deploy image_studio.app:app
```

One deploy covers all five workloads and prints the `studio` URL. The
first deploy builds three images.

**4. Use**

Open the URL, enter the key, and generate. Each requested image gets a
tile in the gallery that shows why the job waits, then each step. Select an
image to download it, copy a share link or reuse its settings. The API
also works without the page:

```bash theme={null}
curl --fail-with-body "<studio-url>/api/jobs" \
  -H "Authorization: Bearer $IMAGE_STUDIO_KEY" \
  -H "Content-Type: application/json" \
  -d '{"prompt": "a red fox in fresh snow, morning light", "style": "photo", "count": 2}'
```

`uv run lazycloud task list --app image_studio` lists the generation
tasks, and `uv run lazycloud task logs <job-id>` prints one's output.

## A GPU function that loads once

A [function](https://docs.lazycloud.dev/concepts/functions.md) runs `on_start` before its container takes
a call, so the pipeline loads once, and `keep_warm=300` holds it for five
minutes between prompts.

```python image_studio/generate.py theme={null}
@app.function(
    image=gpu_image,
    gpu=[GpuType.L4, GpuType.A10G],
    cpu=2,
    memory="12Gi",
    volumes=[models, gallery],
    on_start=load_pipeline,
    keep_warm=300,
    timeout_seconds=300,
    retries=0,
    max_pending_tasks=16,
)
def generate_images(request: GenerateRequest) -> GalleryEntry:
```

## Spawn and the task queue

`.spawn()` returns a task ID at once, the [queue](https://docs.lazycloud.dev/concepts/scaling.md#when-the-queue-is-full)
holds up to 16 waiting jobs, and a refused job becomes a `503`.

```python image_studio/web.py theme={null}
@keyed.post("/jobs", status_code=status.HTTP_202_ACCEPTED)
def create_job(request: GenerateRequest) -> JobCreated:
    try:
        call = generate_images.spawn(request)
    except SdkError as error:
        # Raised when the GPU queue is past max_pending_tasks, among other refusals.
        raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, str(error)) from error
    return JobCreated(job_id=call.task_id, events_url=signed(f"/api/jobs/{call.task_id}/events"))
```

## A map for progress

A [map](https://docs.lazycloud.dev/concepts/queues-maps.md#map) carries one small record per job from the
GPU to the WebSocket, and the [task view](https://docs.lazycloud.dev/concepts/tasks-and-logs.md) fills in
what the function cannot report, such as waiting for a machine.

```python image_studio/web.py theme={null}
def read_event(task: Task) -> JobEvent:
    raw = job_progress.get(task.task_id)
    progress = JobProgress.model_validate(raw) if raw is not None else None
    view = task.view()
    return describe_job(
        progress,
        task_status=view.status.value,
        pending_message=view.pending.message if view.pending else None,
    )
```

## Volumes for weights

A [volume](https://docs.lazycloud.dev/concepts/volumes.md) outlives any container, so the weights download
once and every GPU container reads them from the mount; the marker file goes
last so no container loads a partial download.

```python image_studio/weights.py theme={null}
@app.function(
    image=weights_image,
    cpu=2,
    memory="4Gi",
    volumes=[models],
    timeout_seconds=3600,
    retries=2,
)
def download_weights() -> str:
    from huggingface_hub import snapshot_download

    if COMPLETE_MARKER.exists():
        return f"{MODEL_REPO} is already in {WEIGHTS_DIR}"
    snapshot_download(
        MODEL_REPO,
        revision=MODEL_REVISION,
        local_dir=WEIGHTS_DIR,
        allow_patterns=WEIGHT_FILES,
    )
    COMPLETE_MARKER.write_text(MODEL_REVISION)
    return f"Downloaded {MODEL_REPO} to {WEIGHTS_DIR}"
```

## A nightly schedule

A [schedule](https://docs.lazycloud.dev/concepts/schedules.md) is a function with `cron`; this one runs on
a quarter CPU and keeps the gallery to seven days.

```python image_studio/cleanup.py theme={null}
@app.function(
    image=app_image,
    cron="0 4 * * *",
    cpu=0.25,
    memory="256Mi",
    volumes=[gallery],
    timeout_seconds=900,
    retries=2,
)
def delete_expired_images() -> int:
    expired = expired_directories(GALLERY_DIR, now=datetime.now(UTC), keep_for=KEEP_IMAGES_FOR)
    for directory in expired:
        shutil.rmtree(directory)
    print(f"Deleted {len(expired)} jobs older than {KEEP_IMAGES_FOR.days} days", flush=True)
    return len(expired)
```

Share links come from [artifacts](https://docs.lazycloud.dev/concepts/artifacts.md) with a public URL,
capped by your plan's retention.

## Adapt it

* Another diffusers model needs its repository, revision and file patterns in
  `weights.py`, the pipeline class in `generate.py`, and a GPU with enough
  memory.
* Styles and shapes live in `generation.py`; the page reads them from
  `/api/options`, so it needs no change.
* More throughput comes from `autoscaler=Autoscaler(max_containers=...)` on
  `generate_images`, since each job is independent.

## Clean up

```bash theme={null}
uv run lazycloud app delete image_studio
uv run lazycloud volume delete image-studio-models
uv run lazycloud volume delete image-studio-gallery
uv run lazycloud secret delete IMAGE_STUDIO_KEY
uv run python -c "from image_studio.resources import job_progress; job_progress.delete()"
```

Shared links stop working when their artifacts expire.
`uv run lazycloud artifact list` and `uv run lazycloud artifact delete <artifact-id>`
remove them sooner.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.