diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 09e6b2cb..c78efecf 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -40,6 +40,10 @@ The REPL uses a **persistent worker subprocess** for workflow execution to maint **Result References:** Use `"previous_result:step_name"` to pipe outputs between steps. System automatically generates all combinations when multiple results exist. +**Constant References:** Use `"constant:module.NAME"` to read a value declared in Python instead of copying it into JSON. + +**Prompt References:** Use `"prompt:name"` or `"prompt:folder/name"` to load a stored prompt's text from the `prompts/` library at run time (see `dw/prompts.py`). + **Pipeline Configuration:** ```json { diff --git a/.vscode/launch.json b/.vscode/launch.json index df9ab0c8..bf2b31a2 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -3,10 +3,10 @@ // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 // - // The examples tree has subfolders (flux/, tasks/, archive/, projects/), so + // The workflows tree has subfolders (flux/, ltx2/, minimax/, tasks/, archive/), so // the "current workflow file" configurations below are the ones to reach for: // open the JSON and run it, wherever it lives. The prompting configurations - // take a path relative to examples/, subfolders included - "flux/FluxDev". + // take a path relative to workflows/, subfolders included - "flux/FluxDev". "version": "0.2.0", "configurations": [ { @@ -71,7 +71,7 @@ "cwd": "${workspaceFolder}/", "justMyCode": true, "args": [ - "./examples/${input:workflowName}.json" + "./workflows/${input:workflowName}.json" ] }, { @@ -105,7 +105,7 @@ "args": [ "--log_level", "INFO", - "./examples/${input:workflowName}.json" + "./workflows/${input:workflowName}.json" ] } ], @@ -113,7 +113,7 @@ { "id": "workflowName", "type": "promptString", - "description": "Workflow path relative to examples/, without .json (e.g. ZImage or flux/FluxDev):", + "description": "Workflow path relative to workflows/, without .json (e.g. ZImage or flux/FluxDev):", "default": "??" } ] diff --git a/CLAUDE.md b/CLAUDE.md index 4923bef8..7bf0a1e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,11 +13,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co bash ./install.sh && source ./activate # Run a workflow -python -m dw.run examples/ZImage.json -python -m dw.run examples/ZImage.json prompt="a cat" num_images_per_prompt=4 +python -m dw.run workflows/ZImage.json +python -m dw.run workflows/ZImage.json prompt="a cat" num_images_per_prompt=4 # Validate a workflow against schema -python -m dw.validate examples/ZImage.json +python -m dw.validate workflows/ZImage.json # Basic system test (torch, diffusers import check) python -m dw.test @@ -28,7 +28,7 @@ python -m dw.repl # HTTP server + web UI (http://127.0.0.1:8765, API docs at /docs) python -m dw.serve -# Run all tests (580+ tests) +# Run all tests (1,600+ tests) pytest -v # Run a single test file or test @@ -52,7 +52,10 @@ over SSE, and persists history to `~/.diffusers_helper/jobs.sqlite`. The SPA liv `ui/` (Svelte 5 + Vite; `npm run build` outputs `ui/dist`, which the server serves). Introspection endpoints (`dw/introspection.py`) describe pipeline/class signatures for the editor's forms. `dw/hub_cache.py` inventories and deletes from the HF hub cache -(the UI's Models page). Front-end checks from `ui/`: `npm run check`, `npm run lint`, +(the UI's Models page). `dw/prompts.py` + `dw/server/enhancers.py` back the +Prompts page: a stored-prompt library (CRUD under `--prompt-dir`) with an +enhance-with-AI panel that queues an inline workflow as an ordinary job. +Front-end checks from `ui/`: `npm run check`, `npm run lint`, `npm test`, `npx playwright test` (e2e, starts its own server). See docs/SERVER.md. Packaging: `pyproject.toml` (console scripts dw-run/dw-validate/dw-repl/dw-serve/dw-test); @@ -75,6 +78,11 @@ The REPL (`dw/repl.py`) uses a **persistent worker subprocess** (`dw/worker.py`) - Values prefixed with `constant:` read a value declared in python rather than copying it into JSON: `"constant:diffusers.pipelines.ltx2.utils.DISTILLED_SIGMA_VALUES"`. Resolved in `realize_args`, validated by `validate_constant_name()`; anything callable is refused +- Values prefixed with `prompt:` load a stored prompt's `text` from the prompt library: + `"prompt:name"` or `"prompt:folder/name"`. Resolved in `realize_args` (`dw/prompts.py`), + rooted at the library rather than the workflow file. The library is `DW_PROMPT_DIR` / + `--prompt-dir`, else `./prompts` if it exists, else found by walking up from the + workflow file's directory ### Quantization Support @@ -115,6 +123,7 @@ All entry points use `dw/security.py`. When adding features: - **Built-in workflows** need explicit argument mapping: `"prompt": "variable:prompt"` - **MPS differences from CUDA**: no autocast, no bitsandbytes, no flash_attn, no triton, no torch.compile. Model offloading has less benefit on unified memory. - **`{}`-escaped strings** in JSON arguments: `"{nf4}"` stays as string `"nf4"`, without braces it would try to load as a type +- **A stored prompt's `text` may not begin with a reference prefix** (`variable:`, `previous_result:`, `constant:`, `prompt:`) — the engine rejects it to prevent double resolution or iteration expansion - **Audio+video muxing**: pipelines that generate audio alongside video (LTX-2) have the two muxed into one `video/mp4` file with PyAV in `result.py` ## JSON Workflow Structure @@ -146,4 +155,4 @@ Steps can also have `"task"` (with `command` + `arguments`) or `"workflow"` (wit Pipeline configuration options include: `pre_load_modules` (import modules before loading), `sdnq_optimize` (SDNQ quantized matmul), `enable_attention_slicing`, `disable_attention_slicing`, `attention_backend`, `group_offload`, `enable_layerwise_casting`. -File paths in workflows are relative to the workflow file. Built-in workflows use `"builtin:filename.json"` (resolves to `dw/workflows/`). +File paths in workflows are relative to the workflow file. Built-in workflows use `"builtin:filename.json"` (resolves to the packaged `dw/workflows/` — distinct from the top-level `workflows/` folder of runnable examples). diff --git a/README.md b/README.md index 6db4a02e..d83db523 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ A declarative workflow engine and web UI for the [Hugging Face Diffusers library - **Quantization** — BitsAndBytes, TorchAO, GGUF, SDNQ, optimum-quanto - **Inference acceleration** — TeaCache, FirstBlockCache, FasterCache, MagCache, TaylorSeerCache - **Prompt weighting** — A1111-style `(word:1.5)` syntax with long prompt support +- **Prompt library** — store prompts once in `prompts/` and reference them from any workflow as `prompt:name` or `prompt:folder/name`, with a web UI for browsing, editing, and AI-enhancing them - **LoRA and IP-Adapter** support - **Composable workflows** from multiple JSON files with `builtin:` references - **Utility tasks** — upscaling, face restoration, segmentation, captioning, frame interpolation, QR codes, and more @@ -64,6 +65,8 @@ Everything the engine does, in a browser backed by a persistent GPU worker — m ![The gallery with generated images and videos](docs/img/ui-gallery.jpg) +**A prompt library shared by every workflow.** Store a prompt once, reference it anywhere as `prompt:name` — the Prompts page browses, edits, and filters the library, and an *Enhance with AI* panel expands an idea into a full prompt with a local language model. + **A model manager for the disk your models actually consume.** The Hugging Face hub cache, inventoried: sizes, revisions, last-used dates, free space — download new models by id with live progress, delete with one click. ![The model manager listing cached models with sizes](docs/img/ui-models.png) @@ -75,14 +78,14 @@ Jobs queue, stream progress live (per denoising step), cancel cooperatively, and ### Run a Workflow ```bash -python -m dw.run examples/flux/FluxDev.json -python -m dw.run examples/flux/FluxDev.json prompt="a cat" num_images_per_prompt=4 +python -m dw.run workflows/flux/FluxDev.json +python -m dw.run workflows/flux/FluxDev.json prompt="a cat" num_images_per_prompt=4 ``` ### Validate a Workflow ```bash -python -m dw.validate examples/flux/FluxDev.json +python -m dw.validate workflows/flux/FluxDev.json ``` ### Interactive REPL @@ -242,7 +245,7 @@ a (photorealistic:1.4) portrait with (bright red hair:1.3) and [freckles] **Interactive schema browser:** [View Schema](https://json-schema.app/view/%23?url=https%3A%2F%2Fraw.githubusercontent.com%2Fdkackman%2Fdiffusers-workflow%2Frefs%2Fheads%2Fmaster%2Fdw%2Fworkflow_schema.json) -See [examples/](examples/) for more workflow files. +See [workflows/](workflows/) for more workflow files. ## Documentation @@ -256,6 +259,7 @@ See [examples/](examples/) for more workflow files. - [LoRA](docs/LORAS.md) — Loading and stacking LoRA adapters - [IP-Adapter](docs/IP_ADAPTER.md) — Image-prompt conditioning - [Prompt Weighting](docs/PROMPT_WEIGHTING.md) — A1111-style syntax +- [Prompt References](docs/WORKFLOW_GUIDE.md#prompt-references) — The stored prompt library and `prompt:` references - [Tasks](docs/TASKS.md) — Image processing, ControlNet preprocessors, utilities ### Reference diff --git a/docs/ACCELERATION.md b/docs/ACCELERATION.md index 4ce1ac9e..48db9c16 100644 --- a/docs/ACCELERATION.md +++ b/docs/ACCELERATION.md @@ -42,7 +42,7 @@ Simplest and broadest support. Compares first-block residuals to decide whether Higher threshold = more speedup, more quality loss. Start with `0.05` and increase to taste. -**Example:** [FluxDevFirstBlockCache.json](../examples/flux/FluxDevFirstBlockCache.json) +**Example:** [FluxDevFirstBlockCache.json](../workflows/flux/FluxDevFirstBlockCache.json) ### MagCache @@ -165,7 +165,7 @@ Some models have multiple variants with different coefficients: } ``` -**Example:** [FluxDevTeaCache.json](../examples/flux/FluxDevTeaCache.json) +**Example:** [FluxDevTeaCache.json](../workflows/flux/FluxDevTeaCache.json) ## Cache vs TeaCache @@ -206,7 +206,7 @@ A component can also pin its backend persistently instead, via `set_attention_ba Prefer the pinned form for a compiled component - the per-call context manager switches implementations under the compiled graph and forces a recompile on every run. -**Example:** [Flux2Dev.json](../examples/flux/Flux2Dev.json), [hunyuan15.json](../examples/archive/hunyuan15.json), [Wan22TI2V5B.json](../examples/archive/Wan22TI2V5B.json) +**Example:** [Flux2Dev.json](../workflows/flux/Flux2Dev.json), [hunyuan15.json](../workflows/archive/hunyuan15.json), [Wan22TI2V5B.json](../workflows/archive/Wan22TI2V5B.json) ## Attention Slicing @@ -252,7 +252,7 @@ Typical gains are 1.3-1.5x on diffusion transformers, and compilation stacks wit - **Don't combine `fullgraph` with a `cache`**: the cache hooks decide skip-or-compute per step, a data-dependent branch diffusers wraps in `torch.compiler.disable` - it needs the graph break that `fullgraph: true` forbids. Compile with the default (partial) graph mode when a cache is active. - **TorchAO quantization needs compile to be fast** - see [QUANTIZATION.md](QUANTIZATION.md#torchao). -**Example:** [FluxDevFast.json](../examples/flux/FluxDevFast.json), [FluxTorchAO.json](../examples/flux/FluxTorchAO.json) +**Example:** [FluxDevFast.json](../workflows/flux/FluxDevFast.json), [FluxTorchAO.json](../workflows/flux/FluxTorchAO.json) ## Layerwise Casting @@ -277,7 +277,7 @@ Both `storage_dtype` and `compute_dtype` are required. Applied via the component `"residency": "on_demand"` on a component is the cheap case of the same trade: the model rests in system memory and is moved to the device whole around each of its own calls. That is a bad deal for anything called once per step, and a good one for a VAE called twice a run - it frees the VAE's VRAM for the denoise loop at the cost of two transfers, where group offloading the same VAE would restream it once per decode tile. See [On-demand components](WORKFLOW_GUIDE.md#on-demand-components). -**Example:** [FluxDev.json](../examples/flux/FluxDev.json) (`"offload": "model"`), [ZImage.json](../examples/ZImage.json) (`"offload": "sequential"`), [MiniMaxH3.json](../examples/MiniMaxH3.json) (`group_offload` per component), [MiniMaxH3Ref2VA.json](../examples/MiniMaxH3Ref2VA.json) (`group_offload` for the transformer, `on_demand` for the VAEs) +**Example:** [FluxDev.json](../workflows/flux/FluxDev.json) (`"offload": "model"`), [ZImage.json](../workflows/ZImage.json) (`"offload": "sequential"`), [MiniMaxH3.json](../workflows/minimax/MiniMaxH3.json) (`group_offload` per component), [MiniMaxH3Ref2VA.json](../workflows/minimax/MiniMaxH3Ref2VA.json) (`group_offload` for the transformer, `on_demand` for the VAEs) ## TF32 and cuDNN diff --git a/docs/IP_ADAPTER.md b/docs/IP_ADAPTER.md index 3f05328e..dfcd6d46 100644 --- a/docs/IP_ADAPTER.md +++ b/docs/IP_ADAPTER.md @@ -77,7 +77,7 @@ Some base models (e.g. Stable Diffusion 3.5) don't ship a default image encoder, } ``` -See [sd35ip.json](../examples/archive/sd35ip.json) for the full workflow. +See [sd35ip.json](../workflows/archive/sd35ip.json) for the full workflow. ## Image Argument @@ -105,6 +105,6 @@ Can also reference a previous step's output: ## Examples -- [FluxIP.json](../examples/flux/FluxIP.json) — Flux with IP-Adapter for style transfer -- [sd35ip.json](../examples/archive/sd35ip.json) — SD 3.5 (quantized) with an explicit `image_encoder`/`feature_extractor` pair -- [ip-adapter.json](../examples/archive/ip-adapter.json) — SD 1.5 and SDXL, each with an `AutoPipelineForText2Image` step +- [FluxIP.json](../workflows/flux/FluxIP.json) — Flux with IP-Adapter for style transfer +- [sd35ip.json](../workflows/archive/sd35ip.json) — SD 3.5 (quantized) with an explicit `image_encoder`/`feature_extractor` pair +- [ip-adapter.json](../workflows/archive/ip-adapter.json) — SD 1.5 and SDXL, each with an `AutoPipelineForText2Image` step diff --git a/docs/LORAS.md b/docs/LORAS.md index 744c6cbf..d598a7a9 100644 --- a/docs/LORAS.md +++ b/docs/LORAS.md @@ -126,6 +126,6 @@ python -m dw.run workflow.json lora="other-user/other-lora" ## Examples -- [FluxLora.json](../examples/flux/FluxLora.json) — Flux with realism LoRA and variables -- [lora.json](../examples/lora.json) — SD 3.5 with yarn art style LoRA -- [bnb_quant.json](../examples/archive/bnb_quant.json) — Quantized model with LoRA +- [FluxLora.json](../workflows/flux/FluxLora.json) — Flux with realism LoRA and variables +- [lora.json](../workflows/lora.json) — SD 3.5 with yarn art style LoRA +- [bnb_quant.json](../workflows/archive/bnb_quant.json) — Quantized model with LoRA diff --git a/docs/PROMPT_WEIGHTING.md b/docs/PROMPT_WEIGHTING.md index 12db64d4..ccbeb1a9 100644 --- a/docs/PROMPT_WEIGHTING.md +++ b/docs/PROMPT_WEIGHTING.md @@ -71,8 +71,8 @@ Any other pipeline whose class name starts with `Flux` (e.g. `FluxKontextPipelin ## Example ```bash -python -m dw.run examples/flux/FluxSchnellWeighted.json \ +python -m dw.run workflows/flux/FluxSchnellWeighted.json \ prompt="a (cinematic:1.5) shot of a (dragon:1.3) breathing [smoke] over a (medieval:0.8) castle" ``` -See [FluxSchnellWeighted.json](../examples/flux/FluxSchnellWeighted.json). +See [FluxSchnellWeighted.json](../workflows/flux/FluxSchnellWeighted.json). diff --git a/docs/QUANTIZATION.md b/docs/QUANTIZATION.md index 270ad15c..64d7af90 100644 --- a/docs/QUANTIZATION.md +++ b/docs/QUANTIZATION.md @@ -55,7 +55,7 @@ For 8-bit: "arguments": { "load_in_8bit": true } ``` -**Example:** [bnb_quant.json](../examples/archive/bnb_quant.json) +**Example:** [bnb_quant.json](../workflows/archive/bnb_quant.json) ## TorchAO @@ -78,7 +78,7 @@ Common choices: `Int8WeightOnlyConfig` (any CUDA card), `Int4WeightOnlyConfig` ( `Float8DynamicActivationFloat8WeightConfig` (fastest, requires compute capability 8.9+ - RTX 40-series/Ada or newer). -**Example:** [FluxTorchAO.json](../examples/flux/FluxTorchAO.json) +**Example:** [FluxTorchAO.json](../workflows/flux/FluxTorchAO.json) **Pair TorchAO with `torch.compile`.** Int8 weight-only and float8 dynamic-activation quant types get their fused-kernel speedups only under compilation - uncompiled they are @@ -117,7 +117,7 @@ GGUF models load from single files using `from_single_file`: } ``` -**Example:** [FluxGGUF.json](../examples/flux/FluxGGUF.json) +**Example:** [FluxGGUF.json](../workflows/flux/FluxGGUF.json) ## SDNQ (SD.Next Quantization) @@ -146,7 +146,7 @@ examples use for their large transformers and text encoders: - `quantization_device` / `return_device` — where the quantization pass runs and where the finished component lands; quantizing on `cuda` is much faster than on CPU - `use_quantized_matmul` — quantized matmul kernels (CUDA/XPU only) -**Examples:** [LTX2.json](../examples/LTX2.json), [MiniMaxH3.json](../examples/MiniMaxH3.json) +**Examples:** [LTX2.json](../workflows/ltx2/LTX2.json), [MiniMaxH3.json](../workflows/minimax/MiniMaxH3.json) ### Pre-quantized models @@ -171,7 +171,7 @@ Pre-quantized SDNQ repos load as complete pipelines. The `sdnq` module must be i - `pre_load_modules` — Imports sdnq before pipeline loading (registers quantization method) - `sdnq_optimize` — Applies quantized matmul to listed components (CUDA/XPU only, skipped on MPS/CPU) -**Example:** [ZImageSDNQ.json](../examples/ZImageSDNQ.json) +**Example:** [ZImageSDNQ.json](../workflows/ZImageSDNQ.json) ## Modular Pipelines diff --git a/docs/RECIPES_24GB.md b/docs/RECIPES_24GB.md index 2c44753a..87eaad29 100644 --- a/docs/RECIPES_24GB.md +++ b/docs/RECIPES_24GB.md @@ -20,26 +20,26 @@ The bf16 transformer is ~24GB - it does not fit alongside the T5 encoder. Two go | **float8 TorchAO** (RTX 40-series+) | same, with `Float8DynamicActivationFloat8WeightConfig` | Fastest, but fp8 needs compute capability 8.9+ (Ada). | | **GGUF Q8** | `from_single_file` Q8_0 transformer + `offload: "model"` | Simplest, best quality retention, slower than TorchAO+compile. | -**Examples:** [FluxDevFast.json](../examples/flux/FluxDevFast.json) (the int8 recipe - swap in `Float8DynamicActivationFloat8WeightConfig` on Ada or newer), [FluxGGUF.json](../examples/flux/FluxGGUF.json), [FluxDevFirstBlockCache.json](../examples/flux/FluxDevFirstBlockCache.json) +**Examples:** [FluxDevFast.json](../workflows/flux/FluxDevFast.json) (the int8 recipe - swap in `Float8DynamicActivationFloat8WeightConfig` on Ada or newer), [FluxGGUF.json](../workflows/flux/FluxGGUF.json), [FluxDevFirstBlockCache.json](../workflows/flux/FluxDevFirstBlockCache.json) ## Qwen-Image (20B) Too large for bf16 on 24GB. Quantize the transformer (TorchAO int8/float8 or GGUF Q4/Q5) and model-offload the rest; add `compile` + `first_block` cache as for Flux. The Qwen2.5-VL text encoder is large - quantize it too, or group-offload it (`"text_encoder.model"` with `leaf_level`). -**Example:** [QwenImageEdit.json](../examples/archive/QwenImageEdit.json) +**Example:** [QwenImageEdit.json](../workflows/archive/QwenImageEdit.json) ## Wan 2.2 - **TI2V-5B**: fits in bf16 on 24GB. No quantization needed - just `compile` + `cache` + VAE tiling for longer clips. - **T2V/I2V-A14B**: two 14B transformers (`transformer` + `transformer_2`). Quantize both (GGUF Q4/Q5 or TorchAO int8) and use `offload: "model"`; enable `vae.enable_tiling`. -**Examples:** [Wan22TI2V5B.json](../examples/archive/Wan22TI2V5B.json), [Wan22T2V14B.json](../examples/archive/Wan22T2V14B.json) +**Examples:** [Wan22TI2V5B.json](../workflows/archive/Wan22TI2V5B.json), [Wan22T2V14B.json](../workflows/archive/Wan22T2V14B.json) ## HunyuanVideo (13B) Same shape as Flux: quantize the transformer (GGUF Q6/Q8, or TorchAO int8/float8 per the Flux table) + `offload: "model"` + `vae.enable_tiling`. The Llama text encoder benefits from group offload. Use `first_block` cache - video steps are expensive, caching pays off more than on images. -**Examples:** [HunyuanVideoGguf.json](../examples/archive/HunyuanVideoGguf.json), [hunyuan15.json](../examples/archive/hunyuan15.json) +**Examples:** [HunyuanVideoGguf.json](../workflows/archive/HunyuanVideoGguf.json), [hunyuan15.json](../workflows/archive/hunyuan15.json) ## MiniMax-H3 @@ -81,7 +81,18 @@ encoder's leaf offload was backed out with it for the same host-memory reason. O faster GPU (or a host with more RAM) both are worth re-testing; the step budget there may actually expose the transfer time. -**Examples:** [MiniMaxH3Ref2VA.json](../examples/MiniMaxH3Ref2VA.json), [MiniMaxH3Ref2VAChained.json](../examples/MiniMaxH3Ref2VAChained.json), [MiniMaxH3I2V.json](../examples/MiniMaxH3I2V.json) +Length costs VRAM but the configuration holds to the model's full range: a single +345-frame take (14.4s, the `17n+5` maximum) peaks at 23.6GiB reserved at 960x544 - +inside 24GB with nothing to spare - and denoises in ~13 minutes on a 3090 with the +9-step turbo schedule (~85-100s a step once warm, against ~15s at 124 frames). + +Host RAM is the tighter budget than VRAM on a 64GiB box. Loading H3 peaks around +59GiB RSS and a running ref2va shot sits at 45-53GiB, so a workflow that ran another +model first (Z-Image drawing a subject, Music3 writing a song) must free it with +`release_pipeline` before H3 loads - with it, the multi-model digital-short +workflows below fit; without it, the load is an OOM kill, not a slowdown. + +**Examples:** [MiniMaxH3Ref2VA.json](../workflows/minimax/MiniMaxH3Ref2VA.json), [MiniMaxH3Ref2VAChained.json](../workflows/minimax/MiniMaxH3Ref2VAChained.json), [MiniMaxH3I2V.json](../workflows/minimax/MiniMaxH3I2V.json), [MiniMaxH3SitcomShort.json](../workflows/minimax/MiniMaxH3SitcomShort.json) (five ref2va shots + two Z-Image portraits in ~35 minutes end to end) ## LTX-2.5 (22B, video + audio) @@ -124,19 +135,19 @@ upsample the latents 2x, and the result is sharper than a single pass at 1536x89 fits where that would not. The base step shares its `vae` into the upsampler and sets `release_pipeline`, which frees the 11GB transformer before the 2x decode runs. -**Examples:** [LTX2.json](../examples/LTX2.json) (t2v), -[LTX2TwoStage.json](../examples/LTX2TwoStage.json) (base -> latent upsample -> mux), -[LTX2Keyframes.json](../examples/LTX2Keyframes.json) (first and -last frame), [LTX2Extend.json](../examples/LTX2Extend.json) (continue a clip), -[LTX2ICLora.json](../examples/LTX2ICLora.json) (generative 2x upscale via IC-LoRA), -[LTX2I2VEnhancePrompt.json](../examples/LTX2I2VEnhancePrompt.json) (native prompt +**Examples:** [LTX2.json](../workflows/ltx2/LTX2.json) (t2v), +[LTX2TwoStage.json](../workflows/ltx2/LTX2TwoStage.json) (base -> latent upsample -> mux), +[LTX2Keyframes.json](../workflows/ltx2/LTX2Keyframes.json) (first and +last frame), [LTX2Extend.json](../workflows/ltx2/LTX2Extend.json) (continue a clip), +[LTX2ICLora.json](../workflows/ltx2/LTX2ICLora.json) (generative 2x upscale via IC-LoRA), +[LTX2I2VEnhancePrompt.json](../workflows/ltx2/LTX2I2VEnhancePrompt.json) (native prompt enhancer and duration head) ## SDXL (2.6B UNet) Fits several times over in 24GB. Skip quantization and offloading entirely; `compile` the UNet if you generate many images per session. Use `num_images_per_prompt` batching with `vae.enable_slicing`. -**Example:** [sdxl.json](../examples/archive/sdxl.json) +**Example:** [sdxl.json](../workflows/archive/sdxl.json) ## Multi-model workflows diff --git a/docs/REPL_COMMANDS.md b/docs/REPL_COMMANDS.md index 3798a00e..63a0fcf4 100644 --- a/docs/REPL_COMMANDS.md +++ b/docs/REPL_COMMANDS.md @@ -24,9 +24,9 @@ workflow run ask Prompt for one argument's value, then run workflow restart Restart the worker process (clears GPU cache) ``` -`workflow load` resolves names against the workflow directory (`./examples` +`workflow load` resolves names against the workflow directory (`./workflows` by default), including subfolders - `workflow load flux/FluxDev` loads -`examples/flux/FluxDev.json`. `workflow list` shows the available names. +`workflows/flux/FluxDev.json`. `workflow list` shows the available names. `workflow run ask ` prompts you interactively for ``'s value (the value is not saved to shell/readline history) before running — a shortcut diff --git a/docs/SERVER.md b/docs/SERVER.md index 1c3daf45..0ed78d1f 100644 --- a/docs/SERVER.md +++ b/docs/SERVER.md @@ -7,7 +7,7 @@ past generations in a gallery, and manage the models on disk. ```bash python -m dw.serve # http://127.0.0.1:8765 -python -m dw.serve --port 8000 --workflow-dir ./examples --output-dir ./outputs +python -m dw.serve --port 8000 --workflow-dir ./workflows --output-dir ./outputs --prompt-dir ./prompts ``` Installed as a package, the same server is `dw-serve`. Interactive API docs @@ -23,6 +23,16 @@ load entirely. descriptions, output kinds, and variable counts. Folders one level deep become sections. Click through to a run form generated from the workflow's variables, with the raw JSON alongside. +- **Prompts** — the prompt library under `--prompt-dir` (default: discovered + the way a CLI run discovers it, then pinned for every job, so the page and + `prompt:` resolution always agree): stored prompts as + cards with descriptions, intended-model badges, and tags, foldered the + same way workflows are. Each opens in an editor with form, split, and + schema-aware JSON views, and an **Enhance with AI** panel that expands an + idea into a full prompt with a local language model (a preset per target + model family; the model runs as an ordinary queued job). A workflow + argument written as `prompt:name` loads the stored text at run time, + and deleting a prompt warns which workflows reference it. - **Jobs** — the queue and full run history (persisted in `~/.diffusers_helper/jobs.sqlite`). A running job streams step-by-step progress, per-step denoising ticks, and its result files as they land. @@ -90,6 +100,14 @@ The editor's forms come from these; they are just as usable from scripts: - `GET/PUT/DELETE /api/workflows/{name}` — read, save, delete workflow files (confined to `--workflow-dir`) +- `GET /api/prompts`, `GET/PUT/DELETE /api/prompts/{name}` — the prompt + library (confined to `--prompt-dir`, names held to what a `prompt:` + reference can load); saves are validated against the prompt schema, + served at `GET /api/prompt-schema` +- `GET /api/enhancers`, `POST /api/enhance` — prompt-enhancement presets, + and `{"idea": ..., "preset": ..., "model_name": ..., "device": ...}` to + queue an enhancement as an ordinary job whose saved text file is the + result - `GET /api/gallery`, `GET /api/gallery/{name}/metadata`, `DELETE /api/gallery/{name}` — outputs and their embedded metadata - `GET /api/models`, `DELETE /api/models?repo={repo_id}` — hub cache diff --git a/docs/TASKS.md b/docs/TASKS.md index f89e6d47..906b8ddd 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -230,7 +230,7 @@ An argument that goes through diffusers' video processor instead - LTX-2's IC-LoRA references - wants the `[0, 1]` frames the pipeline returned rather than this array; hand those over with `previous_result:step.frames`. -**Example:** [LTX2Extend.json](../examples/LTX2Extend.json) +**Example:** [LTX2Extend.json](../workflows/ltx2/LTX2Extend.json) ### pair_audio @@ -258,7 +258,7 @@ returns frames without it, and this puts it back: | `audio` | Yes | The soundtrack - a waveform, or the earlier step whose video carried one, which brings its sample rate along | | `sample_rate` | No | Sample rate of the waveform. Required unless `audio` carries one; given here it wins | -**Example:** [LTX2TwoStage.json](../examples/LTX2TwoStage.json) +**Example:** [LTX2TwoStage.json](../workflows/ltx2/LTX2TwoStage.json) ### slice_audio @@ -361,7 +361,7 @@ Upscale images using spandrel-compatible super-resolution models (ESRGAN, SwinIR Large images are automatically tiled to avoid GPU memory issues. Models can be loaded from HuggingFace Hub repos or local `.pth`/`.safetensors` files. -**Example:** [SpandrelUpscale.json](../examples/tasks/SpandrelUpscale.json) — Generate at 512px, then 4x upscale to 2048px. +**Example:** [SpandrelUpscale.json](../workflows/tasks/SpandrelUpscale.json) — Generate at 512px, then 4x upscale to 2048px. ## Diffusion Upscaling @@ -397,7 +397,7 @@ Two modes are available: | `noise_level` | No | Noise level for x4 mode (default: 20, ignored for x2) | **Examples:** -- [DiffusionUpscale.json](../examples/tasks/DiffusionUpscale.json) — Generate at 512px, then upscale. `mode` selects which: `x4` (the default) reaches 2048px, `x2` reaches 1024px through the latent upscaler. +- [DiffusionUpscale.json](../workflows/tasks/DiffusionUpscale.json) — Generate at 512px, then upscale. `mode` selects which: `x4` (the default) reaches 2048px, `x2` reaches 1024px through the latent upscaler. ## Face Restoration @@ -431,7 +431,7 @@ Restore and enhance faces in images using spandrel-compatible face restoration m Models are loaded via spandrel, so any `.pth`/`.safetensors` face restoration weights work. CodeFormer requires `pip install spandrel-extra-arches` (non-commercial license). -**Example:** [FaceRestore.json](../examples/tasks/FaceRestore.json) — Generate a portrait, then restore faces with GFPGAN v1.4. +**Example:** [FaceRestore.json](../workflows/tasks/FaceRestore.json) — Generate a portrait, then restore faces with GFPGAN v1.4. ### Combining with Upscaling @@ -507,8 +507,8 @@ Returns a grayscale PIL Image (mode "L") — white (255) for detected objects, b **Examples:** -- [Segment.json](../examples/tasks/Segment.json) — Segment an object from an image -- [SegmentAndInpaint.json](../examples/tasks/SegmentAndInpaint.json) — Segment, then inpaint the masked region +- [Segment.json](../workflows/tasks/Segment.json) — Segment an object from an image +- [SegmentAndInpaint.json](../workflows/tasks/SegmentAndInpaint.json) — Segment, then inpaint the masked region ## Image Captioning @@ -555,9 +555,9 @@ For Florence-2's advanced task-token captioning (detailed captions, object detec **Examples:** -- [ImageToText.json](../examples/tasks/ImageToText.json) — Basic captioning with the default model, saves as `.txt` -- [ImageToTextVLM.json](../examples/tasks/ImageToTextVLM.json) — Larger VLM answering a specific question -- [CaptionToImage.json](../examples/tasks/CaptionToImage.json) — Caption an image, then regenerate with Flux +- [ImageToText.json](../workflows/tasks/ImageToText.json) — Basic captioning with the default model, saves as `.txt` +- [ImageToTextVLM.json](../workflows/tasks/ImageToTextVLM.json) — Larger VLM answering a specific question +- [CaptionToImage.json](../workflows/tasks/CaptionToImage.json) — Caption an image, then regenerate with Flux ## Extracting Sections @@ -656,8 +656,8 @@ It is merged after everything else, so it can override `repetition_penalty` and **Examples:** -- [ExpandPrompt.json](../examples/tasks/ExpandPrompt.json) — Expand a short prompt and save as `.txt` -- [ExpandAndGenerate.json](../examples/tasks/ExpandAndGenerate.json) — Expand prompt, then generate with Flux +- [ExpandPrompt.json](../workflows/tasks/ExpandPrompt.json) — Expand a short prompt and save as `.txt` +- [ExpandAndGenerate.json](../workflows/tasks/ExpandAndGenerate.json) — Expand prompt, then generate with Flux ## Frame Interpolation @@ -685,7 +685,7 @@ Increase video frame rate using RIFE (Real-Time Intermediate Flow Estimation). T Uses vendored IFNet v4.13 architecture. Weights are downloaded from HuggingFace Hub on first use. -**Example:** [InterpolateFrames.json](../examples/InterpolateFrames.json) — Generate video with Mochi, then 2x interpolate from 30fps to 60fps. +**Example:** [InterpolateFrames.json](../workflows/InterpolateFrames.json) — Generate video with Mochi, then 2x interpolate from 30fps to 60fps. ## Metadata Embedding @@ -707,7 +707,7 @@ Embed generation parameters in saved images. Enable by setting `embed_metadata: Metadata includes step name, model name, and generation arguments (prompt, steps, guidance scale, etc.) as JSON. -**Example:** [MetadataEmbed.json](../examples/tasks/MetadataEmbed.json) — Generate with Flux and embed parameters in PNG. +**Example:** [MetadataEmbed.json](../workflows/tasks/MetadataEmbed.json) — Generate with Flux and embed parameters in PNG. ## QR Code Generation @@ -730,7 +730,7 @@ Metadata includes step name, model name, and generation arguments (prompt, steps The QR code is generated then resampled to `max(height, width)`, aligned to the nearest 64px multiple. -**Example:** [qr_code.json](../examples/archive/qr_code.json) — QR code with artistic ControlNet +**Example:** [qr_code.json](../workflows/archive/qr_code.json) — QR code with artistic ControlNet ## Chat/Dict Plumbing @@ -853,17 +853,17 @@ Canny edge detection followed by ControlNet generation: ## Examples -- [FluxCanny.json](../examples/flux/FluxCanny.json) — Canny edge ControlNet -- [FluxDepth.json](../examples/flux/FluxDepth.json) — Depth-guided generation -- [qr_code.json](../examples/archive/qr_code.json) — QR code with artistic ControlNet -- [SpandrelUpscale.json](../examples/tasks/SpandrelUpscale.json) — Generate + spandrel 4x upscale -- [FaceRestore.json](../examples/tasks/FaceRestore.json) — Generate portrait + GFPGAN face restoration -- [Segment.json](../examples/tasks/Segment.json) — Text-prompted object segmentation -- [SegmentAndInpaint.json](../examples/tasks/SegmentAndInpaint.json) — Segment + inpaint -- [ImageToText.json](../examples/tasks/ImageToText.json) — image captioning with the SmolVLM default -- [ImageToTextVLM.json](../examples/tasks/ImageToTextVLM.json) — VLM captioning with a specific question -- [CaptionToImage.json](../examples/tasks/CaptionToImage.json) — Caption then regenerate -- [InterpolateFrames.json](../examples/InterpolateFrames.json) — RIFE frame interpolation -- [MetadataEmbed.json](../examples/tasks/MetadataEmbed.json) — Embed generation parameters in PNG -- [ExpandPrompt.json](../examples/tasks/ExpandPrompt.json) — LLM prompt expansion -- [ExpandAndGenerate.json](../examples/tasks/ExpandAndGenerate.json) — Expand prompt + generate image +- [FluxCanny.json](../workflows/flux/FluxCanny.json) — Canny edge ControlNet +- [FluxDepth.json](../workflows/flux/FluxDepth.json) — Depth-guided generation +- [qr_code.json](../workflows/archive/qr_code.json) — QR code with artistic ControlNet +- [SpandrelUpscale.json](../workflows/tasks/SpandrelUpscale.json) — Generate + spandrel 4x upscale +- [FaceRestore.json](../workflows/tasks/FaceRestore.json) — Generate portrait + GFPGAN face restoration +- [Segment.json](../workflows/tasks/Segment.json) — Text-prompted object segmentation +- [SegmentAndInpaint.json](../workflows/tasks/SegmentAndInpaint.json) — Segment + inpaint +- [ImageToText.json](../workflows/tasks/ImageToText.json) — image captioning with the SmolVLM default +- [ImageToTextVLM.json](../workflows/tasks/ImageToTextVLM.json) — VLM captioning with a specific question +- [CaptionToImage.json](../workflows/tasks/CaptionToImage.json) — Caption then regenerate +- [InterpolateFrames.json](../workflows/InterpolateFrames.json) — RIFE frame interpolation +- [MetadataEmbed.json](../workflows/tasks/MetadataEmbed.json) — Embed generation parameters in PNG +- [ExpandPrompt.json](../workflows/tasks/ExpandPrompt.json) — LLM prompt expansion +- [ExpandAndGenerate.json](../workflows/tasks/ExpandAndGenerate.json) — Expand prompt + generate image diff --git a/docs/TESTING.md b/docs/TESTING.md index cdd79e24..9628abce 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -48,7 +48,7 @@ Around 57 files and 1,500+ tests as of this writing (`pytest tests/ --collect-on | test_model_cache.py | Shared task model cache | | test_prompt_weighting.py, test_teacache.py | Prompt weighting device handling, TeaCache forward guard | | test_argument_updates.py | Cached pipelines pick up fresh arguments across runs | -| test_examples.py | Validates every workflow in `examples/` against the schema | +| test_examples.py | Validates every workflow in `workflows/` against the schema | ## conftest.py @@ -61,5 +61,5 @@ An autouse `_clear_task_model_cache` fixture clears `dw.tasks.model_cache` befor python -m dw.test # Validate a workflow against schema -python -m dw.validate examples/flux/FluxDev.json +python -m dw.validate workflows/flux/FluxDev.json ``` diff --git a/docs/WORKFLOW_GUIDE.md b/docs/WORKFLOW_GUIDE.md index cfe52272..2a3bb448 100644 --- a/docs/WORKFLOW_GUIDE.md +++ b/docs/WORKFLOW_GUIDE.md @@ -72,7 +72,7 @@ inverted latents: ``` `reference_name` must name a step earlier in the same workflow that has a `pipeline`. -See [examples/flux/FluxRFInversion.json](../examples/flux/FluxRFInversion.json) for a full example. +See [workflows/flux/FluxRFInversion.json](../workflows/flux/FluxRFInversion.json) for a full example. ### Task Steps @@ -396,8 +396,8 @@ cache and succeeds on the retry - but each one is a synchronising stall, and a r close to the limit fails outright on any workload that needs slightly more. Every MiniMax H3 example uses on-demand VAEs for this reason. -**Example:** [MiniMaxH3Ref2VA.json](../examples/MiniMaxH3Ref2VA.json), -[MiniMaxH3I2V.json](../examples/MiniMaxH3I2V.json) +**Example:** [MiniMaxH3Ref2VA.json](../workflows/minimax/MiniMaxH3Ref2VA.json), +[MiniMaxH3I2V.json](../workflows/minimax/MiniMaxH3I2V.json) #### Releasing a pipeline mid-workflow @@ -439,7 +439,7 @@ The flag applies to any step type, and on a `workflow` step it fires once the wh sub-workflow has finished. It clears every cached task model, not only this step's, and a later step needing one of them reloads it. -**Example:** [MiniMaxH3EnhancePrompt.json](../examples/MiniMaxH3EnhancePrompt.json) +**Example:** [MiniMaxH3EnhancePrompt.json](../workflows/minimax/MiniMaxH3EnhancePrompt.json) ### VAE Options @@ -471,7 +471,7 @@ Attach one or more LoRAs to a pipeline with `loras`, a sibling of `configuration - `adapter_name` — name passed to `set_adapters()`. Defaults to the LoRA's index in the list. - `scale` — the adapter's weight, passed to `set_adapters()`. Defaults to `1.0`. -See [examples/flux/FluxLora.json](../examples/flux/FluxLora.json) for a full example. +See [workflows/flux/FluxLora.json](../workflows/flux/FluxLora.json) for a full example. ### IP-Adapter @@ -485,7 +485,7 @@ See [examples/flux/FluxLora.json](../examples/flux/FluxLora.json) for a full exa `model_name` is required; `weight_name`, `subfolder` and `scale` are optional. The adapter image itself is passed as a normal `ip_adapter_image` pipeline argument. See -[examples/archive/ip-adapter.json](../examples/archive/ip-adapter.json). +[workflows/archive/ip-adapter.json](../workflows/archive/ip-adapter.json). ### Sharing Components Across Steps @@ -544,7 +544,7 @@ stays alive for the steps that reuse it. don't accept one. - `inversion` — run the pipeline's `invert()` instead of the pipeline itself; the step returns the inverted/image latents for a later step to consume (see - [FluxRFInversion.json](../examples/flux/FluxRFInversion.json)). + [FluxRFInversion.json](../workflows/flux/FluxRFInversion.json)). - `generate` — run the pipeline's `generate()` instead, for components with a generation head (the step returns `generated_ids`). @@ -563,7 +563,7 @@ Two mutually exclusive ways to speed up inference by skipping redundant computat `num_inference_steps`, `max_skip_steps`, `retention_ratio`, `cache_interval`, `max_order`, `mag_ratios`, `calibrate` — see [dw/workflow_schema.json](../dw/workflow_schema.json) for which fields apply to which type). See -[examples/flux/FluxDevFirstBlockCache.json](../examples/flux/FluxDevFirstBlockCache.json). +[workflows/flux/FluxDevFirstBlockCache.json](../workflows/flux/FluxDevFirstBlockCache.json). ```json "configuration": { @@ -675,11 +675,11 @@ downloaded and loaded: A task is chosen by the arguments the step passes, so one `workflow` name can cover more than one of them: MiniMax-H3's `fl2va` takes an `image`, a `last_image`, or both. Given only a `last_image` it generates *up to* that frame, inventing everything that leads to -it — see [examples/MiniMaxH3L2V.json](../examples/MiniMaxH3L2V.json) beside -[examples/MiniMaxH3FL2VA.json](../examples/MiniMaxH3FL2VA.json). +it — see [workflows/minimax/MiniMaxH3L2V.json](../workflows/minimax/MiniMaxH3L2V.json) beside +[workflows/minimax/MiniMaxH3FL2VA.json](../workflows/minimax/MiniMaxH3FL2VA.json). -See [examples/MiniMaxMusic.json](../examples/MiniMaxMusic.json) and -[examples/MiniMaxH3.json](../examples/MiniMaxH3.json) for full examples. +See [workflows/minimax/MiniMaxMusic.json](../workflows/minimax/MiniMaxMusic.json) and +[workflows/minimax/MiniMaxH3.json](../workflows/minimax/MiniMaxH3.json) for full examples. ### Chained Video Generation @@ -755,9 +755,9 @@ chain. Expect some visual drift across many segments with `last_frame` continuit it is single-frame conditioning; `last_segment` continuity exists for exactly that, where the pipeline can take a video reference. -See [examples/LTX2I2VChained.json](../examples/LTX2I2VChained.json), -[examples/MiniMaxH3I2VChained.json](../examples/MiniMaxH3I2VChained.json), and -[examples/MiniMaxH3Ref2VAChained.json](../examples/MiniMaxH3Ref2VAChained.json) +See [workflows/ltx2/LTX2I2VChained.json](../workflows/ltx2/LTX2I2VChained.json), +[workflows/minimax/MiniMaxH3I2VChained.json](../workflows/minimax/MiniMaxH3I2VChained.json), and +[workflows/minimax/MiniMaxH3Ref2VAChained.json](../workflows/minimax/MiniMaxH3Ref2VAChained.json) (audio-matched lip-sync of arbitrary length). ## Schedulers @@ -850,6 +850,50 @@ The value the library declares is the value the workflow gets, which is the poin constant that changes upstream changes here, and one that is renamed or moved fails loudly rather than leaving a stale copy behind. +### Prompt References + +A prompt worth keeping is worth keeping once. Stored prompts live as JSON files in a +prompt library - the `prompts/` folder by default - and a workflow argument written as +`prompt:` plus the file's name (without `.json`, optionally one folder deep) loads its +text at run time: + +```json +"prompt": "prompt:scenic_landscape", +"prompt": "prompt:minimax/fox_dawn_t2va" +``` + +A prompt file holds the text plus the metadata the server's Prompts page shows: + +```json +{ + "text": "A sweeping alpine valley at golden hour...", + "description": "General-purpose scenic landscape", + "intended_model": "z-image", + "negative_prompt": "blurry, low quality", + "tags": ["landscape", "golden-hour"] +} +``` + +Only `text` is required, and it is what the reference resolves to. `intended_model` is +informational - the engine ignores it, but the library badges and filters by it, and +the server's prompt enhancer uses it to preselect a preset. + +The library's location is resolved in order: the `DW_PROMPT_DIR` environment +variable (which `--prompt-dir` on both `dw.run` and `dw.serve` sets), then +`./prompts` in the working directory when it exists, then the first `prompts/` +folder found walking up from the workflow file's own directory - which is how +a repo workflow run from any working directory still reaches the library beside +it. `dw.serve` resolves the directory once at startup with this same order +(anchored at its workflow directory) and pins it for every job, so the Prompts +page and `prompt:` resolution always agree on one library. +References are rooted at that one directory - not at the workflow file - so +the same reference means the same text from every workflow. Like `constant:`, a reference +resolves anywhere in a workflow's arguments, including a `variables` default, and it +always resolves to exactly one string: it never multiplies a step's iterations the way +`previous_result:` references do. A prompt's text may not itself begin with a +reference prefix such as `variable:` - the engine refuses it rather than resolving +text as syntax. + ### Objects Built From a File Some pipelines take arguments that are objects rather than plain media. An argument that @@ -897,7 +941,7 @@ its own 24 fps, so a wrong rate is a request conditioned at the wrong speed — motion and camera alone. A name that is neither an argument of `from_file()` nor a field of the object raises, with the fields it does have. -See [examples/MiniMaxH3Ref2VA.json](../examples/MiniMaxH3Ref2VA.json) for a full example. +See [workflows/minimax/MiniMaxH3Ref2VA.json](../workflows/minimax/MiniMaxH3Ref2VA.json) for a full example. ### Objects Built From an Earlier Step @@ -926,14 +970,14 @@ lands in comes from the type's own `kind`: | ------- | ------------------------------------------------------------------------- | | `image` | The generated image | | `video` | The generated frames, and the soundtrack generated with them if there was one | -| `audio` | The generated soundtrack | +| `audio` | The generated soundtrack - or, for a step that produced audio alone (a music pipeline, a `slice_audio` task), the waveform itself, which carries no rate of its own, so declare `sample_rate` beside `from_previous_result` | Any other key is a field of the object and wins over what the media carried — `"fps": 30.0` where the producing pipeline generated at a rate the consuming one does not share, for instance. A step that produced several artifacts fans out the same way every `previous_result` reference does: four images in, four videos out. -See [examples/MiniMaxH3Ref2VAGeneratedSubject.json](../examples/MiniMaxH3Ref2VAGeneratedSubject.json) +See [workflows/minimax/MiniMaxH3Ref2VAGeneratedSubject.json](../workflows/minimax/MiniMaxH3Ref2VAGeneratedSubject.json) for a full example. ### Objects Built From Named Arguments @@ -980,8 +1024,8 @@ Which of the three forms a type wants is decided by the type, not by preference: | `from_previous_result` | declares a media `kind`, so a step's output lands in the right field on its own | | `from_arguments` | is a plain record of fields - no `from_file()`, no `kind` (LTX-2's conditions and references) | -See [examples/LTX2Keyframes.json](../examples/LTX2Keyframes.json) for the file form and -[examples/LTX2Extend.json](../examples/LTX2Extend.json) for the one built from an +See [workflows/ltx2/LTX2Keyframes.json](../workflows/ltx2/LTX2Keyframes.json) for the file form and +[workflows/ltx2/LTX2Extend.json](../workflows/ltx2/LTX2Extend.json) for the one built from an earlier step. ### Frames Across a Step Boundary @@ -1019,4 +1063,4 @@ array, while an IC-LoRA reference goes through the video processor, which expect `[0, 1]` frames the pipeline returned - `previous_result:step.frames` hands those over untouched. -**Example:** [examples/LTX2TwoStage.json](../examples/LTX2TwoStage.json) +**Example:** [workflows/ltx2/LTX2TwoStage.json](../workflows/ltx2/LTX2TwoStage.json) diff --git a/dw/arguments.py b/dw/arguments.py index 4c8a61ce..284f1f05 100644 --- a/dw/arguments.py +++ b/dw/arguments.py @@ -3,6 +3,7 @@ import logging from inspect import Parameter, signature from .type_helpers import load_type_from_name, load_constant_from_name, has_method +from .prompts import PROMPT_PREFIX, fetch_prompt from diffusers.utils import load_image, load_video from .security import ( validate_path, @@ -92,6 +93,11 @@ def realize_args(arg, base_dir=None): # conventions below - what it holds is the value, not a file to load if is_constant_reference(v): arg[k] = fetch_constant(v) + # A stored prompt resolves the same way - to its text, under any + # name, before the media conventions could mistake it for a file. + # The workflow's directory anchors prompt-library discovery + elif is_prompt_reference(v): + arg[k] = fetch_prompt(v, base_dir=base_dir) # An explicit media reference loads under any argument name - the # key conventions below only cover arguments named like their media elif is_media_reference(v): @@ -140,6 +146,9 @@ def realize_args(arg, base_dir=None): if is_constant_reference(item): arg[i] = fetch_constant(item) continue + if is_prompt_reference(item): + arg[i] = fetch_prompt(item, base_dir=base_dir) + continue if is_media_reference(item): arg[i] = fetch_media(item, base_dir) continue @@ -152,6 +161,11 @@ def is_constant_reference(value): return isinstance(value, str) and value.startswith(CONSTANT_PREFIX) +def is_prompt_reference(value): + """Whether a value references a stored prompt in the prompt library.""" + return isinstance(value, str) and value.startswith(PROMPT_PREFIX) + + def fetch_constant(reference): """Read the value a 'constant:' reference names. @@ -703,12 +717,22 @@ def media_arguments(object_type, artifact): arguments["sample_rate"] = sample_rate return arguments + if audio is None and hasattr(artifact, "ndim") and artifact.ndim <= 3: + # A step that generates audio alone - a music pipeline, a slice_audio + # task - produces the waveform itself rather than an AudioVideo. It + # carries no rate of its own, so the reference declares 'sample_rate' + # alongside 'from_previous_result' + audio = torch.as_tensor(as_channels_samples(artifact)) + if audio is None: raise ValueError( f"{object_type.__name__} holds audio, but the step it names produced " f"none - reference a step that generates a soundtrack" ) - return {"audio": audio, "sample_rate": sample_rate} + arguments = {"audio": audio} + if sample_rate is not None: + arguments["sample_rate"] = sample_rate + return arguments def validate_media_location(location, base_dir=None): diff --git a/dw/hub_cache.py b/dw/hub_cache.py index 8a6f6688..8bc5b775 100644 --- a/dw/hub_cache.py +++ b/dw/hub_cache.py @@ -17,6 +17,16 @@ from huggingface_hub import constants, scan_cache_dir from huggingface_hub.utils import CacheNotFound +try: + # Xet-backed downloads aggregate into two bars built from our tracker + # class: reconstruction (file bytes written) and transfer (network + # bytes). Only reconstruction matches the file-size total the manager + # reports against - counting both would double the progress - and the + # transfer bar is recognizable by the bar_format it is created with + from huggingface_hub.utils._xet_progress_reporting import XET_TRANSFER_BAR_FORMAT +except ImportError: # older huggingface_hub - no xet aggregation + XET_TRANSFER_BAR_FORMAT = None + logger = logging.getLogger("dw") @@ -250,13 +260,20 @@ class Tracker: def __init__(self, *args, **kwargs): self.n = 0 self.total = kwargs.get("total") + # The xet transfer bar still ticks (cancellation works through + # it) but its network bytes stay out of the shared counters + self._counted = ( + XET_TRANSFER_BAR_FORMAT is None + or kwargs.get("bar_format") != XET_TRANSFER_BAR_FORMAT + ) def update(self, n=1): if cancel_event.is_set(): raise DownloadCancelled() if n: self.n += n - manager._add_progress(entry, n) + if self._counted: + manager._add_progress(entry, n) return True def close(self): @@ -271,6 +288,15 @@ def set_description(self, *args, **kwargs): def set_postfix(self, *args, **kwargs): pass + def set_postfix_str(self, *args, **kwargs): + pass + + @property + def format_dict(self): + # What tqdm exposes for rate rendering; huggingface_hub reads + # 'rate' from it when composing the xet speed postfix + return {"n": self.n, "total": self.total, "elapsed": 0, "rate": None} + def __enter__(self): return self diff --git a/dw/prompt_schema.json b/dw/prompt_schema.json new file mode 100644 index 00000000..b58b9edb --- /dev/null +++ b/dw/prompt_schema.json @@ -0,0 +1,48 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "prompt_schema.json", + "title": "Stored prompt", + "description": "One prompt in the prompt library - the text a workflow loads with a 'prompt:' reference, plus the metadata the library pages show.", + "type": "object", + "required": ["text"], + "additionalProperties": false, + "properties": { + "text": { + "type": "string", + "minLength": 1, + "description": "The prompt text itself - what a 'prompt:' reference resolves to. May not begin with a reference prefix ('previous_result:', 'variable:', 'constant:', 'prompt:')." + }, + "description": { + "type": "string", + "description": "Short summary shown on the prompt's library card." + }, + "intended_model": { + "type": "string", + "description": "The model or model family the prompt was written for, such as 'minimax-h3'. Informational - the engine ignores it, but the library badges and filters by it and the enhancer uses it to preselect a preset." + }, + "negative_prompt": { + "type": "string", + "description": "A negative prompt paired with the text, for models that take one." + }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "description": "Free-form labels for filtering the library." + }, + "enhanced": { + "type": "object", + "additionalProperties": false, + "description": "Provenance when the text came from the AI enhancer - which model expanded which idea.", + "properties": { + "model": { + "type": "string", + "description": "The LLM that generated the text." + }, + "idea": { + "type": "string", + "description": "The idea the text was expanded from." + } + } + } + } +} diff --git a/dw/prompts.py b/dw/prompts.py new file mode 100644 index 00000000..6e892e97 --- /dev/null +++ b/dw/prompts.py @@ -0,0 +1,145 @@ +"""The prompt library: stored prompts a workflow references by name. + +A prompt is one JSON file under the prompt directory - its text plus the +metadata the library pages show (description, intended model, tags). A +workflow argument written as 'prompt:name' or 'prompt:folder/name' loads +the file's text at run time, so the prompt is shared by reference rather +than copied into every workflow that uses it. +""" + +import json +import logging +import os + +from .schema import load_schema, validate_data +from .security import validate_prompt_path, validate_prompt_reference + +logger = logging.getLogger("dw") + +# The prefix marking a value as a reference to a stored prompt. The name after it +# is rooted at the prompt directory, not the workflow file - prompts are a shared +# library, and the same reference means the same text from every workflow +PROMPT_PREFIX = "prompt:" + +# The prefixes a stored prompt's text may not begin with. Resolved text is +# substituted where the reference stood, so text that itself looks like a +# reference would be resolved again - or worse, expand a step's iterations +RESERVED_TEXT_PREFIXES = ("previous_result:", "variable:", "constant:", PROMPT_PREFIX) + + +def get_prompt_dir(base_dir=None): + """The directory stored prompts are rooted at. + + DW_PROMPT_DIR names it explicitly - the server sets it from --prompt-dir, + and the spawned worker inherits it. Without one it is ./prompts in the + working directory when that exists; otherwise the walk from the workflow + file's directory up toward the filesystem root finds the prompts/ folder + of the tree the workflow lives in - which is how a repo workflow run from + any working directory still reaches the library beside it. Read at call + time, not import time, so a test or worker sees the current value. + + Args: + base_dir: The workflow file's directory, when one anchors the search + """ + explicit = os.environ.get("DW_PROMPT_DIR") + if explicit: + return explicit + default = os.path.abspath("./prompts") + if os.path.isdir(default): + return default + if base_dir: + current = os.path.abspath(base_dir) + while True: + candidate = os.path.join(current, "prompts") + if os.path.isdir(candidate): + return candidate + parent = os.path.dirname(current) + if parent == current: + break + current = parent + return default + + +def resolve_prompt_reference(reference, prompt_dir=None, base_dir=None): + """Resolve a 'prompt:' reference to the file it names. + + Args: + reference: The 'prompt:name' or 'prompt:folder/name' string + prompt_dir: Directory the name is rooted at; defaults to get_prompt_dir() + base_dir: The workflow file's directory, anchoring discovery when no + prompt directory is configured + + Returns: + The validated absolute path of the prompt file + + Raises: + InvalidInputError: If the name is not a valid prompt name + ValueError: If no prompt file exists under that name + """ + name = validate_prompt_reference(reference.removeprefix(PROMPT_PREFIX).strip()) + prompt_dir = prompt_dir or get_prompt_dir(base_dir) + path = os.path.join(prompt_dir, name + ".json") + if not os.path.isfile(path): + raise ValueError( + f"No prompt named '{name}' in {prompt_dir} - a prompt reference names " + f"a .json file under the prompt directory, without the extension" + ) + return validate_prompt_path(path, prompt_dir) + + +def load_prompt(path): + """Read and validate one prompt file. + + Args: + path: Path of the prompt file, already validated + + Returns: + The prompt as a dict + + Raises: + ValueError: If the file is not JSON or does not match the prompt schema + """ + try: + with open(path, "r", encoding="utf-8") as file: + data = json.load(file) + except json.JSONDecodeError as error: + raise ValueError(f"Prompt file {path} is not valid JSON: {error}") from error + + status, message = validate_data(data, load_schema("prompt")) + if not status: + raise ValueError(f"Prompt file {path} is not a valid prompt: {message}") + + return data + + +def fetch_prompt(reference, prompt_dir=None, base_dir=None): + """Read the text a 'prompt:' reference names. + + Args: + reference: The 'prompt:name' or 'prompt:folder/name' string + prompt_dir: Directory the name is rooted at; defaults to get_prompt_dir() + base_dir: The workflow file's directory, anchoring discovery when no + prompt directory is configured + + Returns: + The prompt file's text field + + Raises: + ValueError: If the prompt is missing, invalid, or its text is itself + a reference + """ + path = resolve_prompt_reference(reference, prompt_dir, base_dir) + text = load_prompt(path)["text"] + + # Arguments are realized more than once, and iteration expansion scans the + # realized template - text that begins like a reference would be treated + # as one on the next pass, so it is data that may not masquerade as syntax + if text.startswith(RESERVED_TEXT_PREFIXES): + raise ValueError( + f"Prompt '{reference}' has text beginning with a reference prefix " + f"({', '.join(RESERVED_TEXT_PREFIXES)}) - a prompt's text may not " + f"itself be a reference" + ) + + logger.info(f"Loaded prompt {reference} from {path}") + return text diff --git a/dw/repl.py b/dw/repl.py index 23480893..bb289d1a 100644 --- a/dw/repl.py +++ b/dw/repl.py @@ -50,7 +50,7 @@ def __init__(self): self.globals = { "output_dir": "./outputs", # Default output directory "log_level": "INFO", # Default log level - "workflow_dir": "./examples", # Default workflow directory + "workflow_dir": "./workflows", # Default workflow directory } self.current_workflow = None self.workflow_args = {} # Store workflow arguments diff --git a/dw/repl_commands.py b/dw/repl_commands.py index 16f7f37c..b89fb010 100644 --- a/dw/repl_commands.py +++ b/dw/repl_commands.py @@ -47,7 +47,7 @@ def do_config(self, arg: str): " log_level - Logging level: DEBUG, INFO, WARNING, ERROR, CRITICAL" ) print(" workflow_dir - Where 'workflow load ' and 'workflow list'") - print(" look for workflows (default: ./examples)") + print(" look for workflows (default: ./workflows)") print() print("These apply to this REPL session. Standing settings (device,") print("log file, TF32) live in ~/.diffusers_helper/settings.json") diff --git a/dw/run.py b/dw/run.py index 11b8a8d9..5aac3e46 100644 --- a/dw/run.py +++ b/dw/run.py @@ -36,8 +36,19 @@ def main(): default="INFO", help="Set the logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)", ) + parser.add_argument( + "--prompt-dir", + type=str, + default=None, + help="Directory 'prompt:' references resolve against (default: " + "DW_PROMPT_DIR, else ./prompts if it exists, else the nearest " + "prompts/ above the workflow file)", + ) args = parser.parse_args() + if args.prompt_dir: + os.environ["DW_PROMPT_DIR"] = os.path.abspath(args.prompt_dir) + # Parse key-value pairs with validation variables = {} for variable in args.variables: diff --git a/dw/security.py b/dw/security.py index b181c242..bbe3bc77 100644 --- a/dw/security.py +++ b/dw/security.py @@ -162,6 +162,12 @@ def validate_workflow_path(path: str, workflow_dir: str = None) -> str: return validate_file_extension(validated, ALLOWED_JSON_EXTENSIONS) +def validate_prompt_path(path: str, prompt_dir: str) -> str: + """Validate stored prompt file paths, confined to the prompt directory.""" + validated = validate_path(path, prompt_dir, allow_create=False) + return validate_file_extension(validated, ALLOWED_JSON_EXTENSIONS) + + def validate_output_path(path: str, output_dir: str) -> str: """Validate output file paths.""" return validate_path(path, output_dir, allow_create=True) @@ -258,6 +264,48 @@ def validate_variable_name(name: str) -> str: return name +# A stored prompt's name: a file name, optionally under one folder. Each +# segment starts with a word character, which precludes '..', hidden files, +# and absolute paths without a second scan +PROMPT_REFERENCE_PATTERN = r"^[\w][\w.-]*(/[\w][\w.-]*)?$" +MAX_PROMPT_REFERENCE_LENGTH = 200 + + +def validate_prompt_reference(name: str) -> str: + """ + Validate the name a 'prompt:' reference points at. + + The name is joined onto the prompt directory to find the file, so it is + checked before anything touches the filesystem - a plain name or one + folder deep, matching how the prompt library is organized. + + Args: + name: Prompt name to validate + + Returns: + The validated name + + Raises: + InvalidInputError: If name is invalid + """ + if not name: + raise InvalidInputError("Prompt name cannot be empty") + + if not re.match(PROMPT_REFERENCE_PATTERN, name): + raise InvalidInputError( + f"Invalid prompt name: {name} - a prompt is named by its file under " + f"the prompt directory, at most one folder deep, like " + f"'scenic_landscape' or 'minimax/fox_dawn'" + ) + + if len(name) > MAX_PROMPT_REFERENCE_LENGTH: + raise InvalidInputError( + f"Prompt name too long: {len(name)} > {MAX_PROMPT_REFERENCE_LENGTH}" + ) + + return name + + # A dotted python name: identifiers separated by dots, and nothing else CONSTANT_NAME_PATTERN = r"^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)*$" diff --git a/dw/serve.py b/dw/serve.py index 43518c49..9c1dbb00 100644 --- a/dw/serve.py +++ b/dw/serve.py @@ -1,7 +1,7 @@ """Run the workflow engine as a local HTTP server. python -m dw.serve - python -m dw.serve --port 8765 --workflow-dir ./examples + python -m dw.serve --port 8765 --workflow-dir ./workflows Binds to localhost by default - this serves your GPU to your own tools, not to the network. Interactive API docs at http://127.0.0.1:8765/docs @@ -9,6 +9,7 @@ import argparse import multiprocessing +import os # Spawn start method before anything touches multiprocessing (CUDA/MPS) if multiprocessing.get_start_method(allow_none=True) != "spawn": @@ -25,11 +26,18 @@ def main(): ) parser.add_argument("--port", type=int, default=8765, help="Port (default: 8765)") parser.add_argument( - "--workflow-dir", default="./examples", help="Directory of workflow JSON files" + "--workflow-dir", default="./workflows", help="Directory of workflow JSON files" ) parser.add_argument( "--output-dir", default="./outputs", help="Directory results are written to" ) + parser.add_argument( + "--prompt-dir", + default=None, + help="Directory of stored prompt files (default: discovered the way " + "a CLI run discovers it - DW_PROMPT_DIR, else ./prompts if it " + "exists, else the nearest prompts/ above the workflow directory)", + ) parser.add_argument( "-l", "--log_level", @@ -38,6 +46,17 @@ def main(): ) args = parser.parse_args() + # Resolve the prompt directory once, with the same discovery a CLI run + # uses, anchored at the workflow directory - then pin it, so the library + # the Prompts page edits and the one 'prompt:' references resolve against + # are always the same directory. The spawned worker inherits the variable. + from .prompts import get_prompt_dir + + prompt_dir = os.path.abspath( + args.prompt_dir or get_prompt_dir(base_dir=os.path.abspath(args.workflow_dir)) + ) + os.environ["DW_PROMPT_DIR"] = prompt_dir + try: import uvicorn except ImportError: @@ -58,6 +77,7 @@ def main(): workflow_dir=args.workflow_dir, output_dir=args.output_dir, log_level=args.log_level, + prompt_dir=prompt_dir, ) ui = " - UI at /" if default_ui_dir() else "" print( diff --git a/dw/server/app.py b/dw/server/app.py index 914156cc..d2d0072d 100644 --- a/dw/server/app.py +++ b/dw/server/app.py @@ -19,7 +19,12 @@ from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field -from ..security import validate_path, SecurityError +from ..security import ( + validate_path, + validate_prompt_reference, + InvalidInputError, + SecurityError, +) from ..introspection import ( describe_class, list_classes, @@ -29,8 +34,10 @@ describe_task, workflow_argument_warnings, ) -from ..schema import load_schema +from ..schema import load_schema, validate_data +from ..prompts import PROMPT_PREFIX, RESERVED_TEXT_PREFIXES from ..workflow import Workflow, workflow_from_definition +from .enhancers import build_enhance_workflow, preset_descriptions from ..result import read_embedded_metadata from ..hub_cache import scan_models, delete_model, DownloadManager from .jobs import JobManager, TERMINAL_STATES @@ -75,6 +82,22 @@ def workflow_names(workflow_dir): _workflow_detail_cache = {} +def collect_prompt_references(value): + """Every stored-prompt name a definition references, at any depth - so + deleting a prompt can warn which workflows would break.""" + references = set() + if isinstance(value, str): + if value.startswith(PROMPT_PREFIX): + references.add(value.removeprefix(PROMPT_PREFIX).strip()) + elif isinstance(value, dict): + for item in value.values(): + references |= collect_prompt_references(item) + elif isinstance(value, list): + for item in value: + references |= collect_prompt_references(item) + return references + + def workflow_details(workflow_dir, names): """Per-workflow card metadata: output kinds and variable count.""" details = {} @@ -103,9 +126,15 @@ def workflow_details(workflow_dir, names): "kinds": kinds, "variables": len(definition.get("variables", {})), "description": str(definition.get("description", "") or ""), + "prompt_refs": sorted(collect_prompt_references(definition)), } except Exception: - detail = {"kinds": [], "variables": 0, "description": ""} + detail = { + "kinds": [], + "variables": 0, + "description": "", + "prompt_refs": [], + } _workflow_detail_cache[path] = (mtime, detail) details[name] = detail return details @@ -123,6 +152,63 @@ def resolve_workflow_name(workflow_dir, name, allow_create=False): raise HTTPException(status_code=404, detail=f"Unknown workflow: {e}") +# What each prompt says about itself, for listing cards - cached by mtime +_prompt_detail_cache = {} + + +def prompt_details(prompt_dir, names): + """Per-prompt card metadata: description, intended model, tags - and + the text itself, which the editors show as the tooltip wherever a + prompt: reference stands in for it.""" + details = {} + for name in names: + path = os.path.join(prompt_dir, f"{name}.json") + try: + mtime = os.path.getmtime(path) + except OSError: + continue + cached = _prompt_detail_cache.get(path) + if cached and cached[0] == mtime: + details[name] = cached[1] + continue + try: + with open(path, "r") as file: + definition = json.load(file) + detail = { + "description": str(definition.get("description", "") or ""), + "intended_model": str(definition.get("intended_model", "") or ""), + "tags": [str(tag) for tag in definition.get("tags", []) or []], + "text": str(definition.get("text", "") or ""), + } + except Exception: + detail = {"description": "", "intended_model": "", "tags": [], "text": ""} + _prompt_detail_cache[path] = (mtime, detail) + details[name] = detail + return details + + +def resolve_prompt_name(prompt_dir, name, allow_create=False): + """The on-disk path for a prompt name, confined to prompt_dir. + + The name is held to the same rule 'prompt:' references enforce - a save + the API accepted but no workflow could ever reference would be a trap. A + save is told what is wrong with the name; a read just misses.""" + bare = name.removesuffix(".json") + try: + validate_prompt_reference(bare) + except InvalidInputError as e: + status = 400 if allow_create else 404 + raise HTTPException(status_code=status, detail=str(e)) + try: + return validate_path( + os.path.join(prompt_dir, f"{bare}.json"), + prompt_dir, + allow_create=allow_create, + ) + except SecurityError as e: + raise HTTPException(status_code=404, detail=f"Unknown prompt: {e}") + + def default_ui_dir(): """Where the built SPA lives: ui/dist in a checkout (the copy npm just built), else the copy packaged into the wheel at dw/server/ui, else None.""" @@ -138,13 +224,14 @@ def default_ui_dir(): def create_app( - workflow_dir="./examples", + workflow_dir="./workflows", output_dir="./outputs", log_level="INFO", job_manager=None, ui_dir=None, download_manager=None, diffusers_updater=None, + prompt_dir="./prompts", ): """Build the application. A caller (tests) can inject a JobManager.""" manager = job_manager or JobManager(output_dir, log_level=log_level) @@ -162,6 +249,7 @@ async def lifespan(app): ) app.state.job_manager = manager app.state.workflow_dir = workflow_dir + app.state.prompt_dir = prompt_dir @app.middleware("http") async def reject_foreign_origins(request, call_next): @@ -423,6 +511,107 @@ def get_workflow(name: str): except (OSError, json.JSONDecodeError) as e: raise HTTPException(status_code=500, detail=f"Could not read workflow: {e}") + # --------------------------------------------------------------- prompts + + class PromptRequest(BaseModel): + prompt: Dict[str, Any] = Field(description="The prompt definition to save") + + @app.get("/api/prompt-schema") + def get_prompt_schema(): + """The JSON schema for stored prompts - the editor's diagnostics. + Its own path, so a prompt named 'schema' cannot shadow it.""" + return JSONResponse(load_schema("prompt")) + + def referenceable(name): + try: + validate_prompt_reference(name) + return True + except InvalidInputError: + return False + + @app.get("/api/prompts") + def list_prompts(): + # A stray file too deep or oddly named can sit in the directory, but + # no workflow could reference it - listing it would only invite that + names = [n for n in workflow_names(app.state.prompt_dir) if referenceable(n)] + return { + "prompt_dir": app.state.prompt_dir, + "prompts": names, + "details": prompt_details(app.state.prompt_dir, names), + } + + @app.put("/api/prompts/{name:path}") + def save_prompt(name: str, request: PromptRequest): + """Write a prompt into the prompt directory. Like a workflow save, + the definition must be schema-valid before it lands on disk.""" + status, message = validate_data(request.prompt, load_schema("prompt")) + if not status: + raise HTTPException(status_code=400, detail=message) + if str(request.prompt.get("text", "")).startswith(RESERVED_TEXT_PREFIXES): + raise HTTPException( + status_code=400, + detail="A prompt's text may not itself begin with a reference " + f"prefix ({', '.join(RESERVED_TEXT_PREFIXES)})", + ) + path = resolve_prompt_name(app.state.prompt_dir, name, allow_create=True) + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w") as file: + json.dump(request.prompt, file, indent=2) + file.write("\n") + logger.info(f"Saved prompt {name} to {path}") + return {"name": name, "path": path} + + @app.delete("/api/prompts/{name:path}") + def delete_prompt(name: str): + """Remove a prompt file from the prompt directory.""" + path = resolve_prompt_name(app.state.prompt_dir, name) + os.remove(path) + logger.info(f"Deleted prompt {name} ({path})") + return {"name": name, "deleted": True} + + @app.get("/api/prompts/{name:path}") + def get_prompt(name: str): + path = resolve_prompt_name(app.state.prompt_dir, name) + try: + with open(path, "r") as file: + return JSONResponse(json.load(file)) + except (OSError, json.JSONDecodeError) as e: + raise HTTPException(status_code=500, detail=f"Could not read prompt: {e}") + + # ------------------------------------------------------------- enhancers + + class EnhanceRequest(BaseModel): + idea: str = Field(description="The idea to expand into a full prompt") + preset: str = Field(default="h3", description="Enhancer preset key") + model_name: Optional[str] = Field( + default=None, description="LLM repo id; the preset's default when omitted" + ) + device: Optional[str] = Field( + default=None, + description="Device for the language model; defaults to cpu, " + "keeping VRAM free for generation", + ) + + @app.get("/api/enhancers") + def list_enhancers(): + return {"presets": preset_descriptions()} + + @app.post("/api/enhance", status_code=201) + def enhance(request: EnhanceRequest): + """Queue a prompt enhancement as an ordinary job. The enhanced text + is the job's single manifest file once it succeeds.""" + try: + definition = build_enhance_workflow( + request.preset, + request.idea, + model_name=request.model_name, + device=request.device, + ) + job = manager.submit(workflow=definition, arguments={}) + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + return job.detail() + # --------------------------------------------------------------- gallery # Built from the security layer's allowlists so a new format is added diff --git a/dw/server/enhancers.py b/dw/server/enhancers.py new file mode 100644 index 00000000..fbdab84a --- /dev/null +++ b/dw/server/enhancers.py @@ -0,0 +1,129 @@ +"""Prompt enhancement presets and the inline workflows that run them. + +The enhancer is not a new execution path - each preset builds a one-step +inline workflow that the ordinary job queue runs, so progress streaming, +cancellation, history and the worker's model cache all apply unchanged. +The step saves its text result, which is how the enhanced prompt comes +back: as the single file in the job's manifest. +""" + +import uuid + +# A generic system prompt for expanding an idea into an image-generation +# prompt - the counterpart of the H3 preset's Context-IR spec, which lives +# in the builtin workflow rather than here +_T2I_SYSTEM_PROMPT = ( + "You expand a user's idea into a single detailed text-to-image prompt. " + "Describe the subject, setting, lighting, mood, composition and style " + "concretely, in one flowing paragraph of comma-separated phrases. " + "Reply with the prompt text only - no preamble, no quotes, no headings." +) + +# Each preset names what the UI shows (label, placeholder, curated models) +# and what the builder needs (which workflow or task runs the enhancement). +# 'intended_models' are the stored-prompt intended_model values the preset +# is preselected for +PRESETS = { + "h3": { + "label": "MiniMax-H3 Context-IR", + "workflow": "builtin:h3_context_ir.json", + "default_model": "Qwen/Qwen3-4B-Instruct-2507", + "models": [ + "Qwen/Qwen3-4B-Instruct-2507", + "Qwen/Qwen2.5-7B-Instruct", + "Qwen/Qwen2.5-1.5B-Instruct", + ], + "intended_models": ["minimax-h3"], + "placeholder": ( + "Task: T2VA. Duration: 5.17 seconds. " + "Idea: a red fox trotting through a snowy pine forest at dawn." + ), + }, + "t2i": { + "label": "Text-to-image augmenter", + "task": "text_generation", + "system_prompt": _T2I_SYSTEM_PROMPT, + "default_model": "Qwen/Qwen2.5-1.5B-Instruct", + "models": [ + "Qwen/Qwen2.5-1.5B-Instruct", + "Qwen/Qwen3-4B-Instruct-2507", + ], + "intended_models": ["z-image", "flux"], + "placeholder": "a cat portrait in a sunlit window", + }, +} + + +def preset_descriptions(): + """The presets as the UI consumes them - everything but how they run.""" + return [ + { + "key": key, + "label": preset["label"], + "default_model": preset["default_model"], + "models": preset["models"], + "intended_models": preset["intended_models"], + "placeholder": preset["placeholder"], + } + for key, preset in PRESETS.items() + ] + + +def build_enhance_workflow(preset_key, idea, model_name=None, device=None): + """An inline workflow that enhances one idea with one preset. + + The workflow id is unique per call: output file names derive from the id, + so two enhancements never overwrite each other's text in the output + directory. + + Args: + preset_key: Key into PRESETS + idea: The idea text to expand + model_name: LLM repo id; the preset's default when omitted + device: Device override for the language model; the workflow or + task default (cpu) when omitted + + Returns: + The inline workflow definition as a dict + + Raises: + ValueError: If the preset is unknown or the idea is empty + """ + preset = PRESETS.get(preset_key) + if preset is None: + raise ValueError( + f"Unknown enhancer preset '{preset_key}' - one of {sorted(PRESETS)}" + ) + if not idea or not idea.strip(): + raise ValueError("Provide an idea to enhance") + + model = model_name or preset["default_model"] + + if "workflow" in preset: + arguments = {"prompt": idea, "model_name": model} + if device: + arguments["device"] = device + action = {"workflow": {"path": preset["workflow"], "arguments": arguments}} + else: + arguments = { + "prompt": idea, + "model_name": model, + "system_prompt": preset["system_prompt"], + "max_new_tokens": 400, + } + if device: + arguments["device"] = device + action = {"task": {"command": preset["task"], "arguments": arguments}} + + return { + "id": f"enhance_{uuid.uuid4().hex[:8]}", + "steps": [ + { + "name": "enhance", + **action, + # The save is the return channel: the builtin's own steps stay + # save:false, so the manifest holds exactly this text file + "result": {"content_type": "text/plain", "save": True}, + } + ], + } diff --git a/dw/workflow.py b/dw/workflow.py index 336ddbf7..7dc3fff8 100644 --- a/dw/workflow.py +++ b/dw/workflow.py @@ -107,7 +107,9 @@ def referenced_result_names(steps): """Every previous_result reference the given steps make, as full names. Scans nested dicts and lists, so references inside pipeline arguments, - task arguments and sub-workflow argument maps are all found. + task arguments and sub-workflow argument maps are all found - including a + constructed object's 'from_previous_result', which names a step without + the 'previous_result:' prefix. """ prefix = "previous_result:" names = set() @@ -116,6 +118,9 @@ def scan(value): if isinstance(value, str) and value.startswith(prefix): names.add(value[len(prefix) :]) elif isinstance(value, dict): + reference = value.get("from_previous_result") + if isinstance(reference, str): + names.add(reference) for item in value.values(): scan(item) elif isinstance(value, list): @@ -338,6 +343,13 @@ def run( logger.info(f"Releasing pipeline for step: {step.name}") pipelines.pop(self._pipeline_keys_by_step.get(step.name), None) + # The loop's own locals are the last references to this step's + # action and result - a released pipeline would otherwise stay + # resident through the next step's load, which is exactly when + # both models would be in memory at once + step_action = None + result = None + # Task models are cached for the life of the process - the cache # exists so a step's cartesian product loads its model once, and # nothing else evicts it. A prompt-expanding language model diff --git a/examples/Krea2.json b/examples/Krea2.json deleted file mode 100644 index f2126ed8..00000000 --- a/examples/Krea2.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "variables": { - "prompt": "Photorealistic interior blending a sleek minimalist tech retail store with a clinical waiting room — white walls, polished floors, warm recessed lighting, rows of modern waiting chairs along one side (avoid actual logos or trademarked branding). A long line of well-dressed, genuinely happy customers waits cheerfully, chatting, each holding a numbered ticket and a clipboard with a simple consent form. A digital display board on the wall reads 'Now Serving: Extraction Bay 3' in clean sans-serif signage matching the store's branding. Nearby, a curtained doorway with a small backlit sign reading 'Extraction' in the same minimalist font, though nothing behind the curtain is visible. At the front of the room, a product pedestal with a spotlighted Mac Studio display. A small wall sign reads 'Trade-In Special: One Kidney = One Mac Studio.' Shot on a 35mm lens, natural daylight mixed with warm interior lighting, shallow depth of field, documentary/editorial photography style. Mood: upbeat, calm, slightly surreal — everyone looks relaxed and unbothered, as if this were completely routine. No visible medical equipment, no blood, no gore, no clinical procedure, no exposed body parts — the dark humor comes entirely from the signage and premise, not from anything depicted." - }, - "id": "Krea2", - "description": "Text-to-image with Krea 2 Turbo - fast, few-step photorealistic generation.", - "steps": [ - { - "name": "txt2img", - "pipeline": { - "configuration": { - "component_type": "Krea2Pipeline", - "offload": "sequential" - }, - "from_pretrained_arguments": { - "model_name": "krea/Krea-2-Turbo", - "torch_dtype": "torch.bfloat16" - }, - "arguments": { - "prompt": "variable:prompt", - "height": 1024, - "width": 1024, - "num_inference_steps": 8, - "guidance_scale": 0.0 - } - }, - "result": { - "content_type": "image/jpeg", - "embed_metadata": true - } - } - ] -} \ No newline at end of file diff --git a/prompts/flux/biomechanical_daffodil.json b/prompts/flux/biomechanical_daffodil.json new file mode 100644 index 00000000..fee59181 --- /dev/null +++ b/prompts/flux/biomechanical_daffodil.json @@ -0,0 +1,10 @@ +{ + "text": "A photorealistic, cinematic wide shot of a futuristic, bio-mechanical daffodil housed inside a massive, transparent glass dome on the dark ocean floor. The daffodil has iridescent, metallic petals and glowing fiber-optic stamens that emit a soft golden light. Outside the thick glass of the dome, the deep sea is filled with glowing, bioluminescent jellyfish and shimmering blue sea creatures, casting ethereal light patterns through the water. The ocean floor is covered in dark sand and ancient coral. Dramatic lighting with caustic light refractions from the water, high detail, 8k resolution, hyper-realistic.", + "description": "Bio-mechanical daffodil in a glass dome on the ocean floor, cinematic wide shot", + "intended_model": "flux", + "tags": [ + "sci-fi", + "underwater", + "cinematic" + ] +} diff --git a/prompts/flux/weighted_portrait.json b/prompts/flux/weighted_portrait.json new file mode 100644 index 00000000..cc68e590 --- /dev/null +++ b/prompts/flux/weighted_portrait.json @@ -0,0 +1,9 @@ +{ + "text": "a (photorealistic:1.4) portrait of a woman with (bright red hair:1.3) and [freckles], standing in a (misty:0.8) forest, (golden hour lighting:1.5), sharp focus", + "description": "Golden-hour forest portrait using A1111 (word:1.5) weighting syntax", + "intended_model": "flux", + "tags": [ + "portrait", + "prompt-weighting" + ] +} diff --git a/prompts/ltx2/fox_dawn_choir.json b/prompts/ltx2/fox_dawn_choir.json new file mode 100644 index 00000000..5d82e3c9 --- /dev/null +++ b/prompts/ltx2/fox_dawn_choir.json @@ -0,0 +1,10 @@ +{ + "text": "A cinematic shot of a fox walking through a snowy forest at dawn, the camera tracking alongside, snow crunching underfoot. a choir singing 'l t x' in the background, cinematic lighting, 8k, ultra-detailed, photorealistic in the distance", + "description": "Fox tracking shot through snowy forest with a choir singing 'l t x'", + "intended_model": "ltx-2", + "tags": [ + "t2v", + "audio", + "wildlife" + ] +} diff --git a/prompts/ltx2/hummingbird_garden.json b/prompts/ltx2/hummingbird_garden.json new file mode 100644 index 00000000..86159b63 --- /dev/null +++ b/prompts/ltx2/hummingbird_garden.json @@ -0,0 +1,9 @@ +{ + "text": "A hummingbird hovers at a red trumpet flower in a sunlit garden, wings blurred, tongue flicking into the bloom. Insects drone in the background and leaves rustle in a light breeze.", + "description": "Hummingbird at a trumpet flower, wings blurred, garden ambience", + "intended_model": "ltx-2", + "tags": [ + "t2v", + "nature" + ] +} diff --git a/prompts/ltx2/lighthouse_keeper.json b/prompts/ltx2/lighthouse_keeper.json new file mode 100644 index 00000000..c6002cc9 --- /dev/null +++ b/prompts/ltx2/lighthouse_keeper.json @@ -0,0 +1,9 @@ +{ + "text": "A lighthouse keeper in a yellow oilskin climbs the spiral stair of a lighthouse, lamplight flaring on the wet brass rail. Waves break far below and the wind hums through the tower.", + "description": "Lighthouse keeper climbing the spiral stair - opening segment", + "intended_model": "ltx-2", + "tags": [ + "t2v", + "lighthouse" + ] +} diff --git a/prompts/ltx2/lighthouse_keeper_gallery.json b/prompts/ltx2/lighthouse_keeper_gallery.json new file mode 100644 index 00000000..32aa372c --- /dev/null +++ b/prompts/ltx2/lighthouse_keeper_gallery.json @@ -0,0 +1,10 @@ +{ + "text": "A lighthouse keeper in a yellow oilskin climbs the spiral stair of a lighthouse, lamplight flaring on the wet brass rail, then steps out onto the gallery. The beam sweeps past him across low cloud, gulls call, and he says 'still burning' as the wind hums through the tower.", + "description": "Continuation of lighthouse_keeper: out onto the gallery ('still burning') - for Extend workflows", + "intended_model": "ltx-2", + "tags": [ + "t2v", + "lighthouse", + "continuation" + ] +} diff --git a/prompts/ltx2/marmot_robot_overlords.json b/prompts/ltx2/marmot_robot_overlords.json new file mode 100644 index 00000000..8a47b869 --- /dev/null +++ b/prompts/ltx2/marmot_robot_overlords.json @@ -0,0 +1,10 @@ +{ + "text": "The marmot stands looking directly at the camera, singing 'i for one welcome our robot overloards', while wearing a top hat and monocle. The background is a whimsical forest with glowing mushrooms and twinkling fireflies. The scene is illuminated by soft, warm light filtering through the trees, creating a magical atmosphere. The animal's eyes are expressive and convey a sense of intelligence and curiosity. The image is highly detailed, with realistic textures and vibrant colors, capturing the essence of a fantastical woodland setting.", + "description": "Marmot in top hat and monocle sings to camera in a glowing woodland (I2V)", + "intended_model": "ltx-2", + "tags": [ + "i2v", + "marmot", + "whimsical" + ] +} diff --git a/prompts/ltx2/polaroid_lighthouse.json b/prompts/ltx2/polaroid_lighthouse.json new file mode 100644 index 00000000..9bd61002 --- /dev/null +++ b/prompts/ltx2/polaroid_lighthouse.json @@ -0,0 +1,10 @@ +{ + "text": "A polaroid photograph of a lighthouse on a rocky shore lies on a table. The camera pushes in until the photograph fills the frame and the scene comes alive: waves break against the rocks, the beam sweeps across low cloud, gulls call overhead.", + "description": "Push-in on a polaroid until the photographed lighthouse comes alive", + "intended_model": "ltx-2", + "tags": [ + "t2v", + "keyframes", + "lighthouse" + ] +} diff --git a/prompts/marmot_forest_walk.json b/prompts/marmot_forest_walk.json new file mode 100644 index 00000000..b52090b0 --- /dev/null +++ b/prompts/marmot_forest_walk.json @@ -0,0 +1,9 @@ +{ + "text": "A lone marmot walks across vast, green forest landscape under a clear blue sky. The camera begins with a wide shot, capturing the endless forests, then slowly zooms in on the marmot as it moves gracefully. The soft wind blows sand into gentle waves, adding motion to the scene. The atmosphere is serene and timeless, emphasizing the beauty of the forest. Bright sunlight casts sharp shadows on the trees, creating striking contrasts. The shot transitions from a wide view of the forest to a medium shot of the marmot, framed against the soaring trees.", + "description": "Wide-to-medium cinematic marmot walk through forest - works across video models", + "tags": [ + "t2v", + "marmot", + "cinematic" + ] +} diff --git a/prompts/minimax/acoustic_pop_song.json b/prompts/minimax/acoustic_pop_song.json new file mode 100644 index 00000000..cdde6da4 --- /dev/null +++ b/prompts/minimax/acoustic_pop_song.json @@ -0,0 +1,9 @@ +{ + "text": "Genre: acoustic pop, folk-tinged. BPM: 96. Key: C major. Emotional progression: warm and intimate in the verses, lifting into an open, anthemic chorus. Listening scenario: solo reflective listening. Production profile: close, organic, minimal reverb. Vocals: soft female lead, close-mic breathy timbre, restrained delivery in verses; light stacked harmonies doubling the melody in the chorus. Arrangement: verses — fingerpicked banjo and electric piano, sparse and spacious. Chorus — brushed drums and upright bass enter, subtle string pad swells underneath. Groove stays gentle throughout; no heavy percussion. Outro returns to solo banjo and voice.", + "description": "Folk-tinged acoustic pop spec: banjo and electric piano verses, anthemic chorus", + "intended_model": "minimax-music", + "tags": [ + "music", + "folk-pop" + ] +} diff --git a/prompts/minimax/fox_dawn_context_ir.json b/prompts/minimax/fox_dawn_context_ir.json new file mode 100644 index 00000000..07d189e1 --- /dev/null +++ b/prompts/minimax/fox_dawn_context_ir.json @@ -0,0 +1,10 @@ +{ + "text": "integrated_multimodal_description: [Shot 1] Cinematic, live-action, a medium tracking shot moves alongside a red fox at ground level through a snow-covered pine forest at dawn. Low golden light rakes between the trunks and throws long blue shadows across untouched snow, and fine ice crystals drift through the air. The fox trots steadily from frame left to frame right, its breath condensing in small white puffs, its brush held level behind it, each paw breaking through the crust. The camera holds a tracking shot at slow speed, matching the animal's pace as pine trunks pass through the foreground. An unseen mixed choir (S1) sings in sustained close harmony, [English] MiniMax H3. [Shot 2] At 00:03.000, the camera cuts to a low close-up of the fox's paws sinking into the powder, then tilts up with small amplitude at slow speed to its face as it pauses, ears swivelling toward an off-screen sound, and looks directly into the lens before continuing forward.\n\noverall_soundscape: A quiet, wide forest room tone under a faint high-frequency hiss of wind moving through pine needles. Every footfall lands as a crisp, dry crunch of compacted snow, evenly spaced with the fox's trot and closer in the second shot. Soft nasal breathing from the animal, and the occasional dry rustle of a laden branch releasing snow in the distance.\n\nnon_diegetic_music: A distant wordless choir sustains open, close-harmony vowels at a slow tempo, mixed low and reverberant behind the ambience, swelling gently as the camera rises to the fox's face and settling back as the shot ends.", + "description": "Full Context-IR for the fox-at-dawn T2VA - the enhanced counterpart of fox_dawn_t2va", + "intended_model": "minimax-h3", + "tags": [ + "t2va", + "context-ir", + "wildlife" + ] +} diff --git a/prompts/minimax/fox_dawn_t2va.json b/prompts/minimax/fox_dawn_t2va.json new file mode 100644 index 00000000..37183f3e --- /dev/null +++ b/prompts/minimax/fox_dawn_t2va.json @@ -0,0 +1,10 @@ +{ + "text": "Task: T2VA. Duration: 5.17 seconds. Idea: a red fox trotting through a snowy pine forest at dawn, breath visible in the cold air, low sun flickering between the trunks, soft crunch of snow underfoot.", + "description": "H3 Context-IR idea for a T2VA run - enhance before generating", + "intended_model": "minimax-h3", + "tags": ["t2va", "wildlife"], + "enhanced": { + "model": "Qwen/Qwen3-4B-Instruct-2507", + "idea": "a red fox trotting through a snowy pine forest at dawn" + } +} diff --git a/prompts/minimax/glass_figure_t2va_idea.json b/prompts/minimax/glass_figure_t2va_idea.json new file mode 100644 index 00000000..14d1a9fc --- /dev/null +++ b/prompts/minimax/glass_figure_t2va_idea.json @@ -0,0 +1,9 @@ +{ + "text": "Task: T2VA. Duration: 5.17 seconds. Idea: a blown glass figure's tentacles writhe as it speaks in a deep, echoing voice, calling forth the ancient ones.", + "description": "Raw idea in Task: format, ready for the Context-IR enhancer", + "intended_model": "minimax-h3", + "tags": [ + "t2va", + "idea" + ] +} diff --git a/prompts/minimax/otter_soul_song.json b/prompts/minimax/otter_soul_song.json new file mode 100644 index 00000000..3ead1715 --- /dev/null +++ b/prompts/minimax/otter_soul_song.json @@ -0,0 +1,9 @@ +{ + "text": "Genre: retro soul jingle, doo-wop tinged. BPM: 92. Key: E-flat major. Emotional progression: warm and wry throughout, swelling on the hook. Listening scenario: a late-night variety show number. Production profile: vintage, close-mic, plate reverb, slightly warm tape saturation. Vocals: smooth male crooner lead, velvety and unhurried, small melisma on the hook. Arrangement: upright bass and brushed drums under electric piano, a two-note baritone sax riff answering each line, finger snaps on the backbeat. The hook lands twice and the last line is held.", + "description": "Retro soul jingle spec (genre/BPM/key/arrangement) - the MusicVideo example's song", + "intended_model": "minimax-music", + "tags": [ + "music", + "soul" + ] +} diff --git a/prompts/scenic_landscape.json b/prompts/scenic_landscape.json new file mode 100644 index 00000000..b96cdc61 --- /dev/null +++ b/prompts/scenic_landscape.json @@ -0,0 +1,5 @@ +{ + "text": "A sweeping alpine valley at golden hour, jagged snow-capped peaks catching warm light, a winding river reflecting the sky, wildflower meadows in the foreground, volumetric haze between ridgelines, crisp detail, cinematic wide-angle composition", + "description": "General-purpose scenic landscape, works across image models", + "tags": ["landscape", "golden-hour"] +} diff --git a/prompts/zimage/cat_portrait.json b/prompts/zimage/cat_portrait.json new file mode 100644 index 00000000..9dbe8ab5 --- /dev/null +++ b/prompts/zimage/cat_portrait.json @@ -0,0 +1,7 @@ +{ + "text": "Close-up portrait of a silver tabby cat in a sunlit window, soft rim lighting through sheer curtains, detailed fur texture, amber eyes in sharp focus, shallow depth of field, warm morning tones", + "description": "Window-lit cat portrait for text-to-image models", + "intended_model": "z-image", + "negative_prompt": "blurry, low quality, distorted anatomy, oversaturated", + "tags": ["portrait", "animal"] +} diff --git a/prompts/zimage/deep_sea_diver_portrait.json b/prompts/zimage/deep_sea_diver_portrait.json new file mode 100644 index 00000000..5916039d --- /dev/null +++ b/prompts/zimage/deep_sea_diver_portrait.json @@ -0,0 +1,10 @@ +{ + "text": "a full-body studio portrait of a weathered deep-sea diver in a brass helmet with the faceplate open, heavy canvas suit, warm rim light against a dark background, sharp and evenly lit", + "description": "Studio portrait of a brass-helmet diver - a clean reference subject for Ref2VA", + "intended_model": "z-image", + "tags": [ + "portrait", + "character", + "reference-subject" + ] +} diff --git a/prompts/zimage/hanfu_portrait.json b/prompts/zimage/hanfu_portrait.json new file mode 100644 index 00000000..05e821f2 --- /dev/null +++ b/prompts/zimage/hanfu_portrait.json @@ -0,0 +1,10 @@ +{ + "text": "Young Chinese woman in red Hanfu, intricate embroidery. Impeccable makeup, red floral forehead pattern. Elaborate high bun, golden phoenix headdress, red flowers, beads. Holds round folding fan with lady, trees, bird. Neon lightning-bolt lamp (⚡️), bright yellow glow, above extended left palm. Soft-lit outdoor night background, silhouetted tiered pagoda (西安大雁塔), blurred colorful distant lights.", + "description": "Woman in red Hanfu with phoenix headdress, neon accent, pagoda night background", + "intended_model": "z-image", + "tags": [ + "portrait", + "hanfu", + "night" + ] +} diff --git a/prompts/zimage/kidney_trade_in.json b/prompts/zimage/kidney_trade_in.json new file mode 100644 index 00000000..71bf32a0 --- /dev/null +++ b/prompts/zimage/kidney_trade_in.json @@ -0,0 +1,11 @@ +{ + "text": "Photorealistic interior blending a sleek minimalist tech retail store with a clinical waiting room — white walls, polished floors, warm recessed lighting, rows of modern waiting chairs along one side (avoid actual logos or trademarked branding). A long line of well-dressed, genuinely happy customers waits cheerfully, chatting, each holding a numbered ticket and a clipboard with a simple consent form. A digital display board on the wall reads 'Now Serving: Extraction Bay 3' in clean sans-serif signage matching the store's branding. Nearby, a curtained doorway with a small backlit sign reading 'Extraction' in the same minimalist font, though nothing behind the curtain is visible. At the front of the room, a product pedestal with a spotlighted Mac Studio display. A small wall sign reads 'Trade-In Special: One Kidney = One Mac Studio.' Shot on a 35mm lens, natural daylight mixed with warm interior lighting, shallow depth of field, documentary/editorial photography style. Mood: upbeat, calm, slightly surreal — everyone looks relaxed and unbothered, as if this were completely routine. No visible medical equipment, no blood, no gore, no clinical procedure, no exposed body parts — the dark humor comes entirely from the signage and premise, not from anything depicted.", + "description": "Surreal minimalist tech-store interior; the dark humor lives entirely in the signage", + "intended_model": "z-image", + "negative_prompt": "colorful, photorealistic faces, modern UI, flat design, white background, sharp clean edges, high saturation, lens flare, bloom overexposed", + "tags": [ + "interior", + "satire", + "photoreal" + ] +} diff --git a/prompts/zimage/otter_singer_portrait.json b/prompts/zimage/otter_singer_portrait.json new file mode 100644 index 00000000..b0510f99 --- /dev/null +++ b/prompts/zimage/otter_singer_portrait.json @@ -0,0 +1,10 @@ +{ + "text": "a 3/4-length portrait of an anthropomorphic river otter lounge singer in a burgundy velvet dinner jacket and black bow tie, standing at a vintage chrome ribbon microphone on a small nightclub stage, a single warm spotlight from above, deep blue darkness behind, photographic, sharp focus", + "description": "Anthropomorphic otter lounge singer at a ribbon mic - the MusicVideo example's subject portrait", + "intended_model": "z-image", + "tags": [ + "portrait", + "character", + "stage" + ] +} diff --git a/pytest.ini b/pytest.ini index e98df9ff..be8c2c11 100644 --- a/pytest.ini +++ b/pytest.ini @@ -14,3 +14,7 @@ filterwarnings = # The regex covers both spellings: "is deprecated" and, on Python 3.14+, # "is not supported". ignore:`torch\.jit\.script_method` is:DeprecationWarning + # transformers' VITS model calls torch.jit.script at import time, reached + # from diffusers' auto_pipeline via the audioldm2 pipeline whenever a + # pipeline class is loaded lazily - no dw code calls the deprecated API. + ignore:`torch\.jit\.script` is:DeprecationWarning diff --git a/tests/README.md b/tests/README.md index e7b052c6..2fe25d7e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -6,7 +6,7 @@ Comprehensive test suite for the diffusers-workflow project covering core functi ## Test Organization -37 files, ~580 tests as of this writing (`pytest tests/ --collect-only -q` for the current count). +57 files, ~1,600 tests as of this writing (`pytest tests/ --collect-only -q` for the current count). ### Unit Tests - `test_security.py` - Security validation and input sanitization @@ -35,7 +35,7 @@ Comprehensive test suite for the diffusers-workflow project covering core functi - `test_integration.py` - End-to-end workflow execution scenarios ### Test Examples -- `test_examples.py` - Validates every workflow in `examples/` against the schema (one parametrized test per file) +- `test_examples.py` - Validates every workflow in `workflows/` against the schema (one parametrized test per file) ## Running Tests @@ -116,8 +116,7 @@ Available in `conftest.py`: ## Test Data Test data files in `tests/test_data/`: -- `workflows/valid_workflow.json` - Valid test workflow -- `workflows/invalid_workflow.json` - Invalid test workflow +- valid_workflow.json and invalid_workflow.json under its workflows/ subfolder - Sample images for image processing tests ## Best Practices diff --git a/tests/test_arguments.py b/tests/test_arguments.py index 257f3995..7f04fcfd 100644 --- a/tests/test_arguments.py +++ b/tests/test_arguments.py @@ -725,6 +725,25 @@ def test_an_audio_reference_takes_the_soundtrack(self): assert reference.audio.shape == (2, 50) assert reference.sample_rate == 24000 + def test_an_audio_reference_takes_a_bare_waveform(self): + # A step that generates audio alone - a music pipeline, a slice_audio + # task - produces the waveform itself, which carries no rate, so the + # reference declares one alongside it + import numpy + + arguments = { + "reference": { + "reference_type": AudioReference, + "from_previous_result": numpy.zeros((50, 2), dtype="float32"), + "sample_rate": 44100, + } + } + + reference = build_objects(arguments)["reference"] + + assert reference.audio.shape == (2, 50) + assert reference.sample_rate == 44100 + def test_a_named_field_wins_over_the_one_the_media_carried(self): # A step that generated at another rate than the consuming pipeline reads arguments = { diff --git a/tests/test_docs_links.py b/tests/test_docs_links.py index 80563771..7837f19e 100644 --- a/tests/test_docs_links.py +++ b/tests/test_docs_links.py @@ -1,6 +1,6 @@ """Documentation points at example workflows that exist. -The examples tree has subfolders, so a doc's path to an example is a path that +The workflows tree has subfolders, so a doc's path to an example is a path that can go stale the moment a file moves - and nothing else in the suite reads the docs. Dated design records under docs/superpowers describe the tree as it was when they were written and are left out. @@ -14,8 +14,10 @@ REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) EXCLUDED = ("venv", "node_modules", os.path.join("docs", "superpowers")) -# 'examples/ZImage.json' or '../examples/flux/FluxDev.json', in prose or a link -EXAMPLE_PATH = re.compile(r"(?:\.\./)?examples/([A-Za-z0-9_.\-/]+\.json)") +# 'workflows/ZImage.json' or '../workflows/flux/FluxDev.json', in prose or a +# link. The lookbehind keeps 'workflows/' as the start of the path, so +# 'dw/workflows/...' (builtins) and 'tests/test_data/workflows/...' don't match +EXAMPLE_PATH = re.compile(r"(? { @@ -104,6 +109,37 @@ test('editor validates, saves into a folder, and deletes', async ({ page }) => { await expect(page.getByRole('link', { name: 'E2EScratch' })).toHaveCount(0) }) +test('prompts page lists, creates into a folder, and deletes', async ({ + page, +}) => { + test.setTimeout(60_000) + await page.goto('/#/prompts') + await expect(page.getByRole('heading', { name: 'Prompts' })).toBeVisible() + // the starter library renders, with folder grouping and metadata badges + await expect(page.getByRole('link', { name: /scenic_landscape/ })).toBeVisible() + await expect(page.getByText('minimax/')).toBeVisible() + + // create a scratch prompt through the editor + await page.goto('/#/prompt-edit') + await page.locator('#prompt-text').fill('an e2e scratch prompt') + await page.getByPlaceholder('MyPrompt').fill('E2EScratchPrompt') + await page.getByRole('button', { name: 'Save' }).click() + await expect(page.getByText(/Saved to/)).toBeVisible({ timeout: 30_000 }) + + // reopen it from the library, then clean up through the UI's own delete + await page.goto('/#/prompt-edit/E2EScratchPrompt') + await expect(page.locator('#prompt-text')).toHaveValue( + 'an e2e scratch prompt', + { timeout: 15_000 }, + ) + page.once('dialog', (dialog) => dialog.accept()) + await page.getByRole('button', { name: /Delete/ }).click() + await expect(page.getByRole('heading', { name: 'Prompts' })).toBeVisible() + await expect( + page.getByRole('link', { name: 'E2EScratchPrompt' }), + ).toHaveCount(0) +}) + test('jobs and gallery pages render', async ({ page }) => { await page.goto('/#/jobs') await expect(page.getByRole('heading', { name: 'Jobs' })).toBeVisible() diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 00fde044..9493a1a5 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -7,6 +7,7 @@ Layers, ListTodo, ListTree, + MessageSquareText, Moon, MonitorCog, SquarePen, @@ -20,6 +21,8 @@ import JobsPage from './lib/pages/JobsPage.svelte' import JobPage from './lib/pages/JobPage.svelte' import EditorPage from './lib/pages/EditorPage.svelte' + import PromptsPage from './lib/pages/PromptsPage.svelte' + import PromptEditorPage from './lib/pages/PromptEditorPage.svelte' import GalleryPage from './lib/pages/GalleryPage.svelte' import ModelsPage from './lib/pages/ModelsPage.svelte' import SchemaPage from './lib/pages/SchemaPage.svelte' @@ -87,6 +90,13 @@ Workflows + + Prompts + Jobs @@ -164,7 +174,9 @@ {/if} -
+
{#if route.parts[0] === 'schema'} {:else if route.parts[0] === 'models'} @@ -173,6 +185,10 @@ {:else if route.parts[0] === 'edit'} + {:else if route.parts[0] === 'prompt-edit'} + + {:else if route.parts[0] === 'prompts'} + {:else if route.parts[0] === 'jobs' && route.parts[1]} {:else if route.parts[0] === 'jobs'} diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index db58145a..f669824e 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -1,5 +1,6 @@ import type { DiffusersStatus, + EnhancerPreset, JobDetail, ModelCache, ModelDownload, @@ -8,6 +9,8 @@ import type { GalleryFile, MemoryInfo, PipelineDescription, + PromptDefinition, + PromptDetail, ValidationResult, WorkflowDefinition, } from './types' @@ -37,7 +40,12 @@ export const api = { workflows: string[] details: Record< string, - { kinds: string[]; variables: number; description: string } + { + kinds: string[] + variables: number + description: string + prompt_refs?: string[] + } > }>('/api/workflows'), getWorkflow: (name: string) => @@ -150,6 +158,50 @@ export const api = { body: JSON.stringify({ workflow }), }, ), + listPrompts: () => + request<{ + prompt_dir: string + prompts: string[] + details: Record + }>('/api/prompts'), + getPrompt: (name: string) => + request(`/api/prompts/${encodePath(name)}`), + savePrompt: (name: string, prompt: PromptDefinition) => + request<{ name: string; path: string }>( + `/api/prompts/${encodePath(name)}`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt }), + }, + ), + deletePrompt: (name: string) => + request<{ name: string; deleted: boolean }>( + `/api/prompts/${encodePath(name)}`, + { method: 'DELETE' }, + ), + getPromptSchema: () => request>('/api/prompt-schema'), + listEnhancers: () => request<{ presets: EnhancerPreset[] }>('/api/enhancers'), + enhance: (body: { + idea: string + preset: string + model_name?: string + device?: string + }) => + request('/api/enhance', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), +} + +/** Fetch the text of a saved output file - how an enhancement's result + * comes back, since the manifest only names files. */ +export async function fetchOutputText(path: string): Promise { + const name = path.split('/').pop() ?? '' + const response = await fetch('/outputs/' + encodeURIComponent(name)) + if (!response.ok) throw new Error(`Could not read ${name}`) + return response.text() } const TERMINAL_STATUSES = ['succeeded', 'failed', 'cancelled'] diff --git a/ui/src/lib/editor.test.ts b/ui/src/lib/editor.test.ts index e6f685b1..f684ba50 100644 --- a/ui/src/lib/editor.test.ts +++ b/ui/src/lib/editor.test.ts @@ -57,7 +57,12 @@ describe('coerce', () => { describe('isReference', () => { it('recognizes every engine reference prefix', () => { - for (const prefix of ['variable:', 'previous_result:', 'constant:']) { + for (const prefix of [ + 'variable:', + 'previous_result:', + 'constant:', + 'prompt:', + ]) { expect(isReference(prefix + 'x')).toBe(true) } expect(isReference('plain text')).toBe(false) @@ -105,6 +110,16 @@ describe('referenceSuggestions', () => { expect(forSecond).toContain('previous_result:gen.audio') expect(forSecond).not.toContain('previous_result:refine.frames') }) + + it('offers every stored prompt when the library is supplied', () => { + const suggestions = referenceSuggestions(workflow, 0, [ + 'scenic', + 'minimax/fox', + ]) + expect(suggestions).toContain('prompt:scenic') + expect(suggestions).toContain('prompt:minimax/fox') + expect(referenceSuggestions(workflow, 0)).not.toContain('prompt:scenic') + }) }) describe('danglingReferences', () => { @@ -151,4 +166,18 @@ describe('danglingReferences', () => { } expect(danglingReferences(workflow)).toEqual([]) }) + + it('checks prompt references only against a supplied library', () => { + const workflow = { + steps: [ + { name: 'gen', pipeline: { arguments: { prompt: 'prompt:scenic' } } }, + ], + } + // no listing - the server resolves these at run time, nothing to flag + expect(danglingReferences(workflow)).toEqual([]) + expect(danglingReferences(workflow, ['scenic'])).toEqual([]) + const problems = danglingReferences(workflow, ['other']) + expect(problems).toHaveLength(1) + expect(problems[0]).toContain('prompt:scenic') + }) }) diff --git a/ui/src/lib/editor.ts b/ui/src/lib/editor.ts index 55f2c618..56287edc 100644 --- a/ui/src/lib/editor.ts +++ b/ui/src/lib/editor.ts @@ -24,9 +24,6 @@ export function classDescription( return descriptions.get(key)! } -export const pipelineDescription = (name: string) => - classDescription(name, 'call') - export type Widget = 'number' | 'boolean' | 'text' | 'textarea' | 'json' /** Reference strings the engine resolves later - always edited as text. */ @@ -35,7 +32,8 @@ export function isReference(value: unknown): boolean { typeof value === 'string' && (value.startsWith('variable:') || value.startsWith('previous_result:') || - value.startsWith('constant:')) + value.startsWith('constant:') || + value.startsWith('prompt:')) ) } @@ -222,16 +220,21 @@ export function emptyWorkflowStep() { } /** Reference completions available to a value input in step `stepIndex`: - * every declared variable, and every EARLIER step's result - with media - * property suffixes for steps whose result is a video. */ + * every declared variable, every EARLIER step's result - with media + * property suffixes for steps whose result is a video - and every stored + * prompt the library lists. */ export function referenceSuggestions( workflow: Record, stepIndex: number, + promptNames: string[] = [], ): string[] { const suggestions: string[] = [] for (const name of Object.keys(workflow.variables ?? {})) { suggestions.push(`variable:${name}`) } + for (const name of promptNames) { + suggestions.push(`prompt:${name}`) + } const steps: Array> = workflow.steps ?? [] for (const step of steps.slice(0, Math.max(0, stepIndex))) { if (!step.name) continue @@ -248,11 +251,17 @@ export function referenceSuggestions( } /** References the workflow makes that nothing declares: variable: names - * missing from variables, and previous_result: names that match no EARLIER - * step. The engine would fail these at run time; the editor says so now. */ -export function danglingReferences(workflow: Record): string[] { + * missing from variables, previous_result: names that match no EARLIER + * step, and - when the prompt library's listing is supplied - prompt: + * names it does not hold. The engine would fail these at run time; the + * editor says so now. */ +export function danglingReferences( + workflow: Record, + promptNames?: string[], +): string[] { const problems: string[] = [] const variables = new Set(Object.keys(workflow.variables ?? {})) + const prompts = promptNames === undefined ? null : new Set(promptNames) const steps: Array> = workflow.steps ?? [] steps.forEach((step, index) => { @@ -279,6 +288,15 @@ export function danglingReferences(workflow: Record): string[] { `Step '${step.name}': previous_result:${name} - no earlier step has that name`, ) } + } else if (value.startsWith('prompt:')) { + // Without a listing the server resolves these at run time - only + // a supplied library can say a name is missing + const name = value.slice('prompt:'.length) + if (prompts !== null && !prompts.has(name)) { + problems.push( + `Step '${step.name}': prompt:${name} - the prompt library has no such prompt`, + ) + } } } else if (Array.isArray(value)) { value.forEach(scan) diff --git a/ui/src/lib/editor/ArgumentsEditor.svelte b/ui/src/lib/editor/ArgumentsEditor.svelte index ee964919..b6167382 100644 --- a/ui/src/lib/editor/ArgumentsEditor.svelte +++ b/ui/src/lib/editor/ArgumentsEditor.svelte @@ -7,6 +7,8 @@ isReference, widgetFor, } from '../editor' + import { promptLibrary } from '../promptlib.svelte' + import { promptTooltip } from '../prompts' import type { PipelineDescription } from '../types' let { @@ -99,19 +101,40 @@ {:else if widget === 'textarea' || widget === 'json'} + + {#if widget === 'textarea' && promptLibrary.names?.length} + + + {/if} {:else} update(key, e.currentTarget.value)} /> @@ -206,6 +229,12 @@ margin-top: 0.15rem; max-width: 60ch; } + .promptpick { + display: block; + margin-top: 0.3rem; + max-width: 260px; + font-size: 0.8rem; + } .icon { display: inline-flex; align-items: center; diff --git a/ui/src/lib/editor/JsonEditor.svelte b/ui/src/lib/editor/JsonEditor.svelte index c7689481..b088803f 100644 --- a/ui/src/lib/editor/JsonEditor.svelte +++ b/ui/src/lib/editor/JsonEditor.svelte @@ -6,11 +6,13 @@ onchange, readonly = false, height = '460px', + schema = 'workflow', }: { value: string onchange?: (value: string) => void readonly?: boolean height?: string + schema?: 'workflow' | 'prompt' } = $props() let container: HTMLDivElement @@ -19,13 +21,23 @@ $effect(() => { let disposed = false + let model: Monaco.editor.ITextModel | null = null import('../monaco').then(async ({ setupMonaco, currentTheme }) => { const monaco = await setupMonaco() if (disposed) return loading = false - editor = monaco.editor.create(container, { + // The model's name is what routes the schema to it - setupMonaco + // registers one schema per prefix. The random suffix keeps two open + // editors (the split view) from fighting over one URI + model = monaco.editor.createModel( value, - language: 'json', + 'json', + monaco.Uri.parse( + `inmemory://dw/${schema}-${Math.random().toString(36).slice(2)}.json`, + ), + ) + editor = monaco.editor.create(container, { + model, theme: currentTheme(), readOnly: readonly, minimap: { enabled: false }, @@ -45,6 +57,8 @@ disposed = true editor?.dispose() editor = null + model?.dispose() + model = null } }) diff --git a/ui/src/lib/editor/LorasEditor.svelte b/ui/src/lib/editor/LorasEditor.svelte index 2cb93f7f..21d8e641 100644 --- a/ui/src/lib/editor/LorasEditor.svelte +++ b/ui/src/lib/editor/LorasEditor.svelte @@ -1,4 +1,5 @@
diff --git a/ui/src/lib/editor/MappingEditor.svelte b/ui/src/lib/editor/MappingEditor.svelte index deff2b3e..a995f278 100644 --- a/ui/src/lib/editor/MappingEditor.svelte +++ b/ui/src/lib/editor/MappingEditor.svelte @@ -1,6 +1,8 @@ + + + + + {#each intendedModels as model (model)}{/each} + + + {#each preset?.models ?? [] as model (model)}{/each} + + +
+ ← prompts +

{name || 'New prompt'}

+ +
+ + + +
+ {#if name} + + + {/if} + +
+ +
+ saving as + + {#if folder === '__new__'} + + / + {/if} + + .json in {promptDir} + + {#if savePath()} + prompt:{savePath()} + {/if} +
+ +{#if error}

{error}

{/if} +{#if status} +

{status}

+{/if} + +{#if view === 'json'} + +

+ Schema-aware: completion, hover docs and validation come from the prompt + schema. Changes apply when the editor loses focus. +

+{:else} +
+
+
+

Prompt

+ + + + +
+ + setField('description', e.currentTarget.value)} + /> + + { + setField('intended_model', e.currentTarget.value) + preselect() + }} + /> + + setTags(e.currentTarget.value)} + /> +
+ {#if doc.enhanced?.model} +

+ enhanced by {doc.enhanced.model} +

+ {/if} +
+ +
+

Enhance with AI

+

+ Expand an idea into a full prompt with a local language model. Runs as + an ordinary job - it waits its turn behind anything generating. +

+ {#if enhancersDown} +

+ Enhancement is unavailable - the server reported no enhancer + presets. Editing and saving still work. +

+ {/if} +
+ + + + + + {#if enhanceModel} + {#if modelCached} + cached + {:else if downloading} + downloading… + {:else} + + {/if} + {/if} + + + + + +
+
+ + {#if enhanceBusy && enhanceJobId} + + watch job + {/if} + {#if enhanceStatus} + {enhanceStatus} + {/if} +
+ {#if enhanceError}

{enhanceError}

{/if} + {#if enhanceResult} + +
+ + +
+ {/if} +
+
+ {#if view === 'split'} +
+ +
+ {/if} +
+{/if} + + diff --git a/ui/src/lib/pages/PromptsPage.svelte b/ui/src/lib/pages/PromptsPage.svelte new file mode 100644 index 00000000..9acaec55 --- /dev/null +++ b/ui/src/lib/pages/PromptsPage.svelte @@ -0,0 +1,354 @@ + + +
+

Prompts

+ {promptDir} + + +
+ +{#if error} +

Could not load prompts: {error}

+{/if} + +{#if showHint} +
+ Write a prompt once, reuse it anywhere: a workflow argument set to + prompt:name loads its text at run time. + +
+{/if} + +{#each groups as group (group)} + {#if group} +
+ + sessionStorage.setItem('dw-prompt-editor-folder', group)} + title="new prompt in {group}/" + aria-label="new prompt in {group}/" + > + + +
+ {/if} + {#if isOpen(group)} +
+ {#each inGroup(group) as name (name)} + {@const detail = details[name]} + +
+ + + {group ? name.split('/').slice(1).join('/') : name} + {#if detail?.intended_model} + + {/if} + + {#if detail?.description || detail?.text} + {detail.description || detail.text} + {/if} + {#if detail?.tags?.length} + + {#each detail.tags as tag (tag)} + + {/each} + + {/if} +
+ {/each} +
+ {/if} +{/each} + +{#if loaded && prompts.length === 0} +

No prompts yet - the + above creates the first one.

+{:else if loaded && visible.length === 0} +

Nothing matches "{filter}".

+{/if} + + diff --git a/ui/src/lib/pages/WorkflowPage.svelte b/ui/src/lib/pages/WorkflowPage.svelte index f4a96582..7025f75b 100644 --- a/ui/src/lib/pages/WorkflowPage.svelte +++ b/ui/src/lib/pages/WorkflowPage.svelte @@ -3,6 +3,9 @@ import JsonEditor from '../editor/JsonEditor.svelte' import { api } from '../api' import { go } from '../router.svelte' + import { displayValue as display, isReference } from '../editor' + import { loadPromptLibrary, promptLibrary } from '../promptlib.svelte' + import { PROMPT_LIST_ID, promptListId, promptTooltip } from '../prompts' import type { WorkflowDefinition } from '../types' let { name }: { name: string } = $props() @@ -18,6 +21,7 @@ overrides = {} workflow = null api.listWorkflows().then((r) => (workflowDir = r.workflow_dir)) + loadPromptLibrary() api .getWorkflow(name) .then((definition) => (workflow = definition)) @@ -26,8 +30,6 @@ const variables = $derived(Object.entries(workflow?.variables ?? {})) - import { displayValue as display } from '../editor' - function newFrom() { if (!workflow) return sessionStorage.setItem('dw-editor-import', JSON.stringify(workflow)) @@ -106,12 +108,18 @@ class="withicon" onclick={run} disabled={submitting || !workflow} - title="queue this workflow with the arguments below" + title="queue this workflow with the variables below" > {submitting ? 'Submitting…' : 'Run'}
+ + {#each promptLibrary.names ?? [] as promptName (promptName)}{/each} + + {#if error}

{error}

{/if} {#if workflow} @@ -120,20 +128,55 @@ {/if} {#if variables.length}
-

Arguments (blank = workflow default)

+

+ Variables + (blank = workflow default · type prompt: to use a stored prompt) +

{#each variables as [key, defaultValue] (key)} {#if display(defaultValue).length > 60} - +
+ + {#if promptLibrary.names?.length} + + {/if} +
{:else} @@ -195,6 +238,19 @@ font-weight: 600; color: var(--muted); } + .fieldcol { + display: flex; + flex-direction: column; + gap: 0.3rem; + align-items: flex-start; + } + .fieldcol textarea { + width: 100%; + } + .promptpick { + max-width: 260px; + font-size: 0.8rem; + } .json { margin-top: 1rem; } diff --git a/ui/src/lib/pages/WorkflowsPage.svelte b/ui/src/lib/pages/WorkflowsPage.svelte index a381045a..4fc535ba 100644 --- a/ui/src/lib/pages/WorkflowsPage.svelte +++ b/ui/src/lib/pages/WorkflowsPage.svelte @@ -17,6 +17,7 @@ let workflowDir = $state('') let filter = $state('') let error = $state('') + let loaded = $state(false) const STORAGE_KEY = 'dw-collapsed-folders' @@ -65,6 +66,7 @@ workflows = result.workflows details = result.details ?? {} workflowDir = result.workflow_dir + loaded = true }) .catch((e) => (error = e.message)) }) @@ -107,7 +109,7 @@ {#if showHint}
Pick a workflow → tweak its arguments → Run. Every image saves its recipe + >Pick a workflow → tweak its variables → Run. Every image saves its recipe — reopen it from the Gallery.