diff --git a/AGENTS.md b/AGENTS.md index 89406a6..d8c870d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,12 +6,12 @@ Turn a character, style, or concept into a ready-to-train LoRA dataset. One refe ## Current state -_Last verified: 2026-10-24_ +_Last verified: 2026-10-26_ -- **Status:** in active development; main is v0.20.1 (git tag `v0.20.1`). **Every version bump must ship a GitHub Release** or the in-app update check never fires. The project was renamed twice (lora-dataset-studio → lora-distillery → dataset-deviser); older references under the previous names are stale. -- **Works:** all five stages end to end (preprocess → generate & curate → caption → export → train config); the three dataset types (character, style, concept) with their own shot plans and caption framing; local and cloud paths for every stage; gallery curation with selection carried forward between stages, including shift-click range select; advisory dedupe, quality flags and caption lint; trainer configs for ai-toolkit (incl. SDXL), kohya sd-scripts, musubi-tuner and Fizgig; opt-in private Hugging Face publish. Since 0.15.0: per-image fault isolation in ① (one bad source never ends the batch), a cooperative ⏹ Stop across ①/②/③ with documented resume paths, translated ComfyUI failures (missing node, bad input, unreachable server) and an in-app 🩺 setup check. Since 0.15.1: a ② **shot style** (default = keep the reference's own medium; eight presets + custom text) threaded into both prompt builders, the CLI, ④ metadata and ⑤'s sample prompt, a final-prompt preview, and a prev/next/save-and-next caption editor with the image on screen. Since 0.16.0: **one name box and one trigger box in the header** that ②/③/④/⑤ all read (they used to be per-tab and went stale), ④ stating the pair it stamped, an opt-in ④ **hand-off to [Idiot LoRa Builder](https://github.com/Fablestarexpanse/Idiot-Lora-Builder)** (writes its `.lora-studio/ratings.json` so its grid opens pre-triaged — nothing is launched), a ⑤ advisory when the dataset can't fill the training resolution, every bundled ComfyUI model filename overridable from `.env`, and a `cli build` preflight for the local engine. Since 0.17.0: the local ② engine is **Qwen-Image 2.1** (plain-English prompts shared with Gemini, ~26 s/shot, `LDS_QWEN21_*` filenames) instead of Qwen-Image-Edit 2511 + the Multiple-Angles LoRA, whose graph stays in `studio/comfy_workflows/legacy/`; a dead ComfyUI mid-batch now fails one shot, not the run. Since 0.17.1: the UI port is chosen by `_pick_port` in `app.py` — 7861 if bindable, else an OS-assigned free port (Windows reserves a ~500-port block around 7861), or `LDS_PORT` to pin one. Since 0.17.2: `comfy_api.upload_image` downscales a source over 90 MB before upload (ComfyUI answers 413 above its 100 MB `--max-upload-size`). Since 0.17.3: the cloud image model defaults to **`auto`** (Google shut down the `-preview` ids on 2026-06-25; `resolve_image_model` picks from the cached live list, Nano Banana 2.1 first), Groq captions use `qwen/qwen3.8-27b` (key `groq-qwen`, old key aliased), every API image goes through `config.api_image` (true mime, size cap), musubi commands include the cache steps and the per-architecture network module, ai-toolkit gains Qwen-Image 2.1 and a correct Krea 2 (Raw) preset and keeps every checkpoint, ① applies EXIF rotation, no longer 4x-upscales large JPEGs, and can jump a busy ComfyUI queue like ② (`--front` on `preprocess`/`build`). Since 0.18.0: ⑤ counts **epochs** (one checkpoint and one sample set each) with auto repeats sized to a step target and a live exposure line; every config ships a `validation/` pack (8 fixed prompts at seed 42, `validation.md`, a per-epoch score sheet); **Fizgig** is a ⑤ target (Krea 2 / Qwen Image 2.1 / Klein); ① stamps a `dd_source` provenance PNG chunk so ⑤ names upscaled sources and ④ knows real photos; ④ can hold out N real photos in a `-heldout` sibling as a likeness test. Since 0.18.1: a ⑤ optimizer dropdown, AdamW8bit by default, Prodigy (lr 1.0, its diffusion args) for ai-toolkit/musubi/kohya; Fizgig stays AdamW8bit. Since 0.19.0: ① training copies **keep their background** and the isolated copy goes to `prepped/refs/` for ② only (angle re-isolation is its own ② option, off); a header **identity policy** for characters (Identity only varies outfits and captions clothing; Signature costume keeps the outfit out of captions); four framing tiers in the shot plan and a ② coverage line with a dominance warning; a cut-off-reference warning; ② near-duplicate finder and primary-reference preview, ④ burst grouping; local ② sends several references (`LDS_QWEN21_MAX_REFS`, default 1) at `LDS_QWEN21_RESOLUTION` (1536); Gemini gets a per-shot aspect and a reference cap; an opt-in **anchor shot**; SAM3 load errors name a stale `HF_TOKEN`. Since 0.20.0: a **⚡ Quick build** tab (first tab: drop images, name, Cloud or Local, trainer; Build runs ①→②, untick, Finish runs ③→④→⑤) and `cli build` share `pipeline.build_start`/`build_finish`, and `cli build` writes ⑤ configs with `--trainer/--model`; a failed isolation keeps the photo and gives ② the whole image; builtin SAM3 retries `person` on real photos; a ComfyUI job's timeout no longer runs while it waits in the queue; `HUGGINGFACE_API_KEY` stands in when there is neither an `HF_TOKEN` nor an `hf auth login`; a caption that opens with the name in another case (`Ann` for trigger `ann`) now opens with the trigger's exact casing; `cli build` and Quick build Finish show ⑤'s undersized/upscaled warnings. Since 0.20.1: `.jfif` sources are listed (exported and uploaded as `.jpg`), ④ re-encodes BMP/TIFF to PNG because ai-toolkit silently skips them, and `validation.md` ends with a LoRA-strength sweep. +- **Status:** in active development; main is v0.21.0 (git tag `v0.21.0`). **Every version bump must ship a GitHub Release** or the in-app update check never fires. The project was renamed twice (lora-dataset-studio → lora-distillery → dataset-deviser); older references under the previous names are stale. +- **Works:** all five stages end to end (preprocess → generate & curate → caption → export → train config); the three dataset types (character, style, concept) with their own shot plans and caption framing; local and cloud paths for every stage; gallery curation with selection carried forward between stages, including shift-click range select; advisory dedupe, quality flags and caption lint; trainer configs for ai-toolkit (incl. SDXL), kohya sd-scripts, musubi-tuner and Fizgig; opt-in private Hugging Face publish. Since 0.15.0: per-image fault isolation in ① (one bad source never ends the batch), a cooperative ⏹ Stop across ①/②/③ with documented resume paths, translated ComfyUI failures (missing node, bad input, unreachable server) and an in-app 🩺 setup check. Since 0.15.1: a ② **shot style** (default = keep the reference's own medium; eight presets + custom text) threaded into both prompt builders, the CLI, ④ metadata and ⑤'s sample prompt, a final-prompt preview, and a prev/next/save-and-next caption editor with the image on screen. Since 0.16.0: **one name box and one trigger box in the header** that ②/③/④/⑤ all read (they used to be per-tab and went stale), ④ stating the pair it stamped, an opt-in ④ **hand-off to [Idiot LoRa Builder](https://github.com/Fablestarexpanse/Idiot-Lora-Builder)** (writes its `.lora-studio/ratings.json` so its grid opens pre-triaged — nothing is launched), a ⑤ advisory when the dataset can't fill the training resolution, every bundled ComfyUI model filename overridable from `.env`, and a `cli build` preflight for the local engine. Since 0.17.0: the local ② engine is **Qwen-Image 2.1** (plain-English prompts shared with Gemini, ~26 s/shot, `LDS_QWEN21_*` filenames) instead of Qwen-Image-Edit 2511 + the Multiple-Angles LoRA, whose graph stays in `studio/comfy_workflows/legacy/`; a dead ComfyUI mid-batch now fails one shot, not the run. Since 0.17.1: the UI port is chosen by `_pick_port` in `app.py` — 7861 if bindable, else an OS-assigned free port (Windows reserves a ~500-port block around 7861), or `LDS_PORT` to pin one. Since 0.17.2: `comfy_api.upload_image` downscales a source over 90 MB before upload (ComfyUI answers 413 above its 100 MB `--max-upload-size`). Since 0.17.3: the cloud image model defaults to **`auto`** (Google shut down the `-preview` ids on 2026-06-25; `resolve_image_model` picks from the cached live list, Nano Banana 2.1 first), Groq captions use `qwen/qwen3.8-27b` (key `groq-qwen`, old key aliased), every API image goes through `config.api_image` (true mime, size cap), musubi commands include the cache steps and the per-architecture network module, ai-toolkit gains Qwen-Image 2.1 and a correct Krea 2 (Raw) preset and keeps every checkpoint, ① applies EXIF rotation, no longer 4x-upscales large JPEGs, and can jump a busy ComfyUI queue like ② (`--front` on `preprocess`/`build`). Since 0.18.0: ⑤ counts **epochs** (one checkpoint and one sample set each) with auto repeats sized to a step target and a live exposure line; every config ships a `validation/` pack (8 fixed prompts at seed 42, `validation.md`, a per-epoch score sheet); **Fizgig** is a ⑤ target (Krea 2 / Qwen Image 2.1 / Klein); ① stamps a `dd_source` provenance PNG chunk so ⑤ names upscaled sources and ④ knows real photos; ④ can hold out N real photos in a `-heldout` sibling as a likeness test. Since 0.18.1: a ⑤ optimizer dropdown, AdamW8bit by default, Prodigy (lr 1.0, its diffusion args) for ai-toolkit/musubi/kohya; Fizgig stays AdamW8bit. Since 0.19.0: ① training copies **keep their background** and the isolated copy goes to `prepped/refs/` for ② only (angle re-isolation is its own ② option, off); a header **identity policy** for characters (Identity only varies outfits and captions clothing; Signature costume keeps the outfit out of captions); four framing tiers in the shot plan and a ② coverage line with a dominance warning; a cut-off-reference warning; ② near-duplicate finder and primary-reference preview, ④ burst grouping; local ② sends several references (`LDS_QWEN21_MAX_REFS`, default 1) at `LDS_QWEN21_RESOLUTION` (1536); Gemini gets a per-shot aspect and a reference cap; an opt-in **anchor shot**; SAM3 load errors name a stale `HF_TOKEN`. Since 0.20.0: a **⚡ Quick build** tab (first tab: drop images, name, Cloud or Local, trainer; Build runs ①→②, untick, Finish runs ③→④→⑤) and `cli build` share `pipeline.build_start`/`build_finish`, and `cli build` writes ⑤ configs with `--trainer/--model`; a failed isolation keeps the photo and gives ② the whole image; builtin SAM3 retries `person` on real photos; a ComfyUI job's timeout no longer runs while it waits in the queue; `HUGGINGFACE_API_KEY` stands in when there is neither an `HF_TOKEN` nor an `hf auth login`; a caption that opens with the name in another case (`Ann` for trigger `ann`) now opens with the trigger's exact casing; `cli build` and Quick build Finish show ⑤'s undersized/upscaled warnings. Since 0.20.1: `.jfif` sources are listed (exported and uploaded as `.jpg`), ④ re-encodes BMP/TIFF to PNG because ai-toolkit silently skips them, and `validation.md` ends with a LoRA-strength sweep. Since 0.21.0 (security): **Gradio 6.30** (the `<6` cap is gone; the 6.20 stuck-overlay bug does not occur on 6.30), dependency floors raised past known CVEs, every `launch()` argument in `app.LAUNCH_KWARGS` (a Host check against DNS rebinding, `.env` in `blocked_paths`, Gradio 6 run history off), Pillow limited to the PNG/JPEG/WebP/BMP/TIFF decoders, and a fix for Gradio's broken SemiBold font. Also fixed: Quick build Finish and `cli build` now use the saved Custom endpoint captioner (it failed with "no endpoint URL"; `cli build` checks it before generating), an untouched Textbox no longer reaches handlers as None under Gradio 6, and the test suite no longer overwrites `.cache/user_settings.json`. - **In progress:** nothing half-built on main. The phased LoRA best-practice update (0.17.3–0.20.0) is complete; the optional local face model (drift flag, verified coverage) awaits a go-ahead. The attention-only toggle planned for 0.18.0 was deferred (ARCHITECTURE.md → Future ideas). -- **Known gaps / next steps:** Auto's model order (`IMAGE_MODEL_PREFERENCE`, Nano Banana 2.1 then Pro) rests on one A/B subject — see ARCHITECTURE.md; the local defaults (one reference, 1536, anchor off) likewise rest on one A/B subject. Otherwise pick up from `docs/ARCHITECTURE.md` → "Future ideas" / "Deferred", and read its *Maintainer principles* before changing anything; `gradio` is pinned `<6` because of a real stuck-loading-overlay regression — unpinning needs that verified fixed upstream; training is never launched for you and is not planned to be; the default export `output_root` writes into the repo root, so a smoke-test export leaves an untracked dataset folder to delete before committing. +- **Known gaps / next steps:** Auto's model order (`IMAGE_MODEL_PREFERENCE`, Nano Banana 2.1 then Pro) rests on one A/B subject — see ARCHITECTURE.md; the local defaults (one reference, 1536, anchor off) likewise rest on one A/B subject. Otherwise pick up from `docs/ARCHITECTURE.md` → "Future ideas" / "Deferred", and read its *Maintainer principles* before changing anything; Pillow 13 (fixes its remaining CVEs) is blocked by gradio's `pillow<13` cap and needs Python 3.11+ (see ARCHITECTURE.md, Future ideas); training is never launched for you and is not planned to be; the default export `output_root` writes into the repo root, so a smoke-test export leaves an untracked dataset folder to delete before committing. - **Deep docs:** `docs/ARCHITECTURE.md` (module map, stage and backend detail, gotchas, backlog — the deep reference), `docs/comfyui-setup.md`. Worklogs under `docs/` are gitignored and local-only by design. ## Architecture in 60 seconds @@ -61,7 +61,8 @@ python cli.py keys --setup ## Conventions & gotchas -- Python ≥3.10. Gradio ≥5.0, <6 (Gradio 6 has a loading-overlay bug). +- Python >=3.10. Gradio >=6.30, <7. Dependency floors in `requirements.txt` are security floors (setup installs without `-U`); run `pip-audit -r requirements.txt` before a release. +- Every `demo.launch()` argument lives in `app.LAUNCH_KWARGS`; `tests/test_launch_security.py` pins the localhost bind, Host check, `.env` block and run-history-off. Never add `share=True`. - torch + torchvision installed separately by setup scripts (CUDA/CPU/macOS specific). - `studio/env_keys.py` is the ONE implementation of reading/editing .env API keys. Per-shell versions were replaced. - Caption encoding: `read_caption()` uses utf-8-sig + errors=replace (forgiving sidecar read). diff --git a/app.py b/app.py index 31aadda..6614c47 100644 --- a/app.py +++ b/app.py @@ -15,12 +15,13 @@ import os import re import string -import warnings from datetime import datetime from pathlib import Path import gradio as gr import pandas as pd +from starlette.middleware import Middleware +from starlette.middleware.trustedhost import TrustedHostMiddleware from studio import pipeline, shot_style from studio import user_config as _uc_boot @@ -40,6 +41,7 @@ from studio.config import ( CAPTIONERS, CAPTIONERS_BY_KEY, + REPO_ROOT, friendly_api_error, list_images, load_caption_model_cache, @@ -1917,16 +1919,8 @@ def report(msg: str): # ---------- layout ---------- -# Gradio 5 warns that `head=` moves to `launch()` in Gradio 6 — but `launch()` does not -# accept it in 5.x, and `requirements.txt` pins `gradio<6` on purpose. Suppressed -# narrowly so a start-up console that should be empty stays empty; the move is recorded -# against lifting the gradio<6 cap in docs/ARCHITECTURE.md. -with warnings.catch_warnings(): - warnings.filterwarnings("ignore", message=".*'head' parameter in the Blocks.*", - category=DeprecationWarning) - _blocks = gr.Blocks(title="Dataset Deviser", head=_PICKER_SCRIPT) - -with _blocks as demo: +# Gradio 6 takes `head=` (the picker script) at launch(); see LAUNCH_KWARGS. +with gr.Blocks(title="Dataset Deviser") as demo: gr.Markdown( "# Dataset Deviser\n" "Character, style, or concept → ready-to-train LoRA dataset. Every tab works standalone " @@ -2847,6 +2841,13 @@ def _cap_cost(key: str, model: str, selected: list[str]) -> str: demo.load(_check_for_update, None, update_notice) +# Gradio 6 starts a Textbox with no value= at None (Gradio 5: ""), so an untouched box +# (e.g. ③'s prefix inside a closed accordion) reached handlers as None and `.strip()` raised. +for _c in demo.blocks.values(): + if isinstance(_c, gr.Textbox) and _c.value is None: + _c.value = "" + + def _pick_port(preferred: int = 7861) -> int: """LDS_PORT if set, else ``preferred`` if bindable, else any free port. @@ -2868,14 +2869,32 @@ def _pick_port(preferred: int = 7861) -> int: raise OSError("No free local port; set LDS_PORT in .env.") +# Bound to localhost on purpose: no auth layer, and .env keys are reachable +# through the process. Do not expose publicly / use share=True. +# allowed_paths lets the galleries display images in user-chosen input/output +# folders on any drive (Gradio otherwise refuses paths outside the CWD/temp +# dir). It is fixed at launch, so it can't be narrowed per-request; the +# consequence is that the local file endpoint can serve any file the process +# can read. Safe ONLY because of the localhost-only, no-auth bind - see the +# Security posture note in docs/ARCHITECTURE.md. +# TrustedHostMiddleware stops DNS rebinding: Gradio's own CORS check only runs when +# the Host header is localhost, so a page on evil.example re-pointed at 127.0.0.1 +# could otherwise read any of those files. blocked_paths keeps .env out regardless. +# Gradio's bundled SourceSansPro-SemiBold.woff2 is a 55-glyph subset (it has "A" and +# "B", not "C"-"z"), so bold text drew "A" in semibold and the rest in faux-bold. +# Point weight 600 at the complete Bold file Gradio also ships. +_FONT_FIX = ('@font-face { font-family: "Source Sans Pro"; font-weight: 600; ' + 'src: url("static/fonts/SourceSansPro/SourceSansPro-Bold.woff2") format("woff2"); }') + +LAUNCH_KWARGS: dict = { + "server_name": "127.0.0.1", "inbrowser": True, "head": _PICKER_SCRIPT, "css": _FONT_FIX, + "allowed_paths": _allowed_media_paths(), + "blocked_paths": [str(REPO_ROOT / ".env")], + # Gradio 6's run history keeps every call's inputs/outputs and offers HF-bucket upload. + "run_history": False, + "app_kwargs": {"middleware": [Middleware(TrustedHostMiddleware, + allowed_hosts=["127.0.0.1", "localhost"])]}, +} + if __name__ == "__main__": - # Bound to localhost on purpose: no auth layer, and .env keys are reachable - # through the process. Do not expose publicly / use share=True. - # allowed_paths lets the galleries display images in user-chosen input/output - # folders on any drive (Gradio otherwise refuses paths outside the CWD/temp - # dir). It is fixed at launch, so it can't be narrowed per-request; the - # consequence is that the local file endpoint can serve any file the process - # can read. Safe ONLY because of the localhost-only, no-auth bind above — see - # the Security posture note in docs/ARCHITECTURE.md. - demo.launch(server_name="127.0.0.1", server_port=_pick_port(), inbrowser=True, - allowed_paths=_allowed_media_paths()) + demo.launch(server_port=_pick_port(), **LAUNCH_KWARGS) diff --git a/cli.py b/cli.py index fb1f21a..86b49ff 100644 --- a/cli.py +++ b/cli.py @@ -523,6 +523,15 @@ def build( identity = "identity" if randomize_outfits else _check_identity(identity) if trainer and trainer not in TRAINER_MODELS: raise typer.BadParameter(f"--trainer must be one of {list(TRAINER_MODELS)}") + # An unconfigured custom endpoint would otherwise fail only after generation. + from studio.captioner import CAPTIONERS_BY_KEY, CaptionerConfigError, resolve_captioner_config + if captioner not in CAPTIONERS_BY_KEY: + raise typer.BadParameter(f"--captioner must be one of {list(CAPTIONERS_BY_KEY)}") + try: + resolve_captioner_config(captioner) + except CaptionerConfigError as e: + typer.echo(str(e)) + raise typer.Exit(1) from e # Check the generation backend before spending a preprocess pass on images # it would then refuse to use. Style never generates, so it never needs one. if dtype != "style": diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9690c58..2024072 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Architecture -Version: 0.20.1 +Version: 0.21.0 ``` app.py Gradio UI — thin wiring over the stage functions (⚡ Quick @@ -402,11 +402,21 @@ quietly gets someone else's value. vanishes with no error. Conversely `gr.update()` is the right form when you want to change **one** prop, since the constructor form merges over the component's previous constructor args. Pick deliberately. -- **Gradio 5 warns that `head=` moves to `launch()` — but 5.x's `launch()` has no `head` - parameter.** Verified against 5.50. The warning is advice for Gradio 6, which - `requirements.txt` pins away from on purpose, so it is suppressed by an exact-message - filter around that one call rather than left to print on every start-up. Move the - argument when the `gradio<6` cap is lifted, not before. +- **Every `launch()` argument lives in `app.LAUNCH_KWARGS`.** Gradio 6 takes `head=` (the + gallery picker script) and `css=` at `launch()`, not on `Blocks`. Keeping them in one dict + lets `tests/test_launch_security.py` build the real app and check the security settings + without starting a server. +- **Gradio's bundled SemiBold font is a 55-glyph subset.** `SourceSansPro-SemiBold.woff2` + (6.30) has "A" and "B" but not the rest of the alphabet, so bold text drew "A" in true + semibold and every other letter in faux-bold Regular ("own API key" looked like two fonts). + `_FONT_FIX` in `app.py` maps weight 600 to the complete Bold file Gradio also ships. +- **Gradio 6 starts a Textbox with no `value=` at None** (Gradio 5: `""`). A box the user + never touched (③'s prefix inside a closed accordion) reached handlers as None and + `.strip()` raised. `app.py` sets every such Textbox to `""` after the layout is built; + `tests/test_gradio6_defaults.py` pins it. New Textboxes need nothing extra. +- **The test suite must not write the user's settings.** `tests/conftest.py` repoints + `user_config.USER_SETTINGS_FILE` at tmp; before 0.21.0 a suite run overwrote the real + `.cache/user_settings.json` (dataset type, trainer, epochs). - **A cross-tab hand-off must move the user, not just the data.** "Send kept shots to ③" wrote its confirmation into a Markdown box that lives *on the ③ tab* — invisible from ②, so the button read as doing nothing at all. Any hand-off needs three things: switch @@ -1106,14 +1116,12 @@ quietly gets someone else's value. filename now raises** `ComfyError` naming the `LDS_` setting to fix and the closest installed filenames (via `difflib`), instead of silently sending a value ComfyUI will reject anyway. Applies to LoRA/checkpoint/UNet inputs and `UpscaleModelLoader` alike. -- **Gradio is capped `<6`.** On Gradio 6.20.0, components fed by `demo.load` in ②/③ (shot plan, - cost line, character-name box, isolation subject, wardrobe note) get stuck under a loading - overlay that never clears — the value arrives but the spinner keeps counting up. Reproduced by - A/B on the same code/`.env` (6 stuck components on 6.20.0, 0 on 5.50.0). Root cause inside - Gradio 6 is still unknown — logged under Future ideas so an eventual upgrade attempt starts from - here instead of re-discovering it. **Caveat for anyone changing the pin by hand:** installing - Gradio 5 can pull `pydantic` down to a version older than `google-genai`'s floor; reinstall - `pydantic>=2.12.5` afterward if `import google.genai` breaks. +- **Gradio floor is 6.30 (0.21.0).** 0.12.1-0.20.1 capped `gradio<6` because on 6.20.0 the + components fed by `demo.load` in tabs 2 and 3 stuck under a loading overlay that never cleared. On + 6.30.0 every tab loads clean with the same code (checked in a browser, no spinner left on any + tab), so the cap went with the 0.21.0 security update: Gradio 5 had open CVEs fixed only in + 6.x, and it held Pillow and Starlette on vulnerable versions. The floor is the first tested + 6.x, not 6.0: do not lower it below 6.30 without re-checking tabs 2 and 3 in a browser. - **Alpha cutout is builtin-backend-only and is never auto-filled into ②.** `isolate_builtin`'s `alpha_cutout` flag composites the subject onto transparency (straight alpha = the mask) instead of white; `isolate_subject` raises `IsolationError` if asked for alpha cutout on @@ -1322,16 +1330,11 @@ ordered by benefit-to-cost. - **ComfyUI-backend alpha cutout.** The `alpha_cutout` toggle at ① (0.13.0) is builtin-SAM3-only — the bundled ComfyUI isolation workflows would need a graph change (a core alpha-join node) to support it. Revisit if a ComfyUI-only user asks for it. -Both items below shipped in 0.14.1. -- **Root-cause the Gradio 6 stuck-loading-overlay bug (0.12.1).** `requirements.txt` caps - `gradio<6` because 6.20.0 leaves `demo.load`-fed components stuck under a spinner that never - clears (see the Gradio gotcha). Nobody has dug into *why* Gradio 6 does this — worth a real - investigation before ever attempting to lift the cap, so the fix doesn't repeat. **When the - cap does lift**, two things move with it: pass `head=` to `launch()` instead of the `Blocks` - constructor (and drop the warning filter around it), and re-verify `_PICKER_SCRIPT` still - finds `.thumbnail-item` — a Gallery DOM change would break click-to-select silently, which - is what `tests/test_selection_flow.py`'s id-contract tests guard against on our side. A third: - the suite already warns that a tuple `row_count` is removed in Gradio 6. +- **Pillow 13 (blocked upstream).** Pillow 13.0 fixes the rest of its open CVEs but needs + Python 3.11+, and gradio 6.30 requires `pillow<13`. Until Gradio allows it, `studio/__init__.py` + limits Pillow's decoders to the formats `IMAGE_EXTS` accepts (see Security posture). When + Gradio lifts its cap: raise the floor to `pillow>=13`, drop Python 3.10 from CI and the setup + scripts, and keep the decoder allowlist (it still narrows the attack surface). ## Repo rename: lora-dataset-studio → lora-distillery (0.12.2) @@ -1493,6 +1496,25 @@ Style/Concept release): expose the port. (Considered and rejected: narrowing to the configured roots or to `$HOME` — both break the standalone "preview images from any folder" workflow for datasets stored elsewhere, for a read surface that the localhost boundary already contains.) +- **Launch hardening (0.21.0), all in `app.LAUNCH_KWARGS` and pinned by + `tests/test_launch_security.py`:** + - *DNS rebinding.* Gradio's CORS check only runs when the `Host` header is a localhost name, + so a web page whose domain re-resolves to `127.0.0.1` could read any file the endpoint + serves (including `.env`). A Starlette `TrustedHostMiddleware` (passed through + `app_kwargs`) answers 400 to any `Host` other than `127.0.0.1`/`localhost`. + - *`.env` is in `blocked_paths`,* which beats `allowed_paths`, so the keys file is 403 even + though its drive root is allowed. Before 0.21.0 it was served. + - *Run history is off* (`run_history=False`). Gradio 6 records every call's inputs and outputs + in the browser and offers to upload them to a Hugging Face bucket; the app never had it. +- **Pillow decoder allowlist.** `studio/__init__.py` drops every Pillow decoder except PNG, JPEG, + WebP, BMP and TIFF (the formats `IMAGE_EXTS` accepts). Pillow picks a decoder by content, + not extension, so without this a renamed EPS/PDF/JPEG2000/... file reaches decoders with open + CVEs that only Pillow 13 fixes (EPS also runs Ghostscript). Pinned by + `tests/test_pillow_formats.py`. +- **Dependency floors are security floors.** The setup scripts install without `-U`, so a raised + floor in `requirements.txt` is what moves an existing install off a vulnerable version. Check + with `pip-audit -r requirements.txt` before each release (and the floors themselves with + `--no-deps`). Still open in 0.21.0: only Pillow's CVEs that need 13.0 (see Future ideas). - **Costs and content are the user's responsibility**, stated in-app (header banner + "Costs & your responsibility" accordion) and in the README. Cloud calls bill the user's own key; custom endpoints bill whatever provider the user points at. No charges flow through this project. diff --git a/requirements.txt b/requirements.txt index ab4288f..d5ccec4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,19 +3,23 @@ # manually with an NVIDIA GPU: # pip install torch torchvision --index-url https://download.pytorch.org/whl/cu128 # (torchvision is required by Qwen3-VL's processor even for still images) -# Capped below 6: on Gradio 6 the components fed by demo.load in tabs ②/③ come -# up stuck under a loading overlay that never clears (values arrive, spinner -# counts up forever). Unpinned, a setup.bat run silently upgrades into it. -gradio>=5.0,<6 -transformers>=4.57 -accelerate>=1.0 +# Security floors: the minimums below are the first releases without known CVEs +# (checked with pip-audit). setup.bat/setup.sh install without -U, so a floor is +# what upgrades an existing install. 6.30 is the first Gradio 6 tested here (6.20 +# had a stuck loading overlay in tabs 2 and 3). +gradio>=6.30,<7 +starlette>=1.3.1 # via gradio; older 1.x still satisfies gradio's own floor +transformers>=5.10 +accelerate>=1.15 google-genai>=1.0 -pillow>=10.1 # has_transparency_data +# 13.0 fixes the rest of Pillow's open CVEs but gradio 6.30 caps pillow<13; +# studio/__init__.py limits Pillow to the formats this app accepts meanwhile. +pillow>=12.3 httpx>=0.27 typer>=0.12 pydantic>=2.7 pydantic-settings>=2.3 -python-dotenv>=1.0 # .env loading (studio/config.py); not a hard dep of pydantic-settings>=2.2 +python-dotenv>=1.2.2 # .env loading (studio/config.py); not a hard dep of pydantic-settings>=2.2 numpy>=1.26 pandas>=2.0 pyyaml>=6.0 @@ -28,7 +32,7 @@ huggingface-hub>=0.24 # HF dataset publishing (④) + tagger/model downloads # add it by hand: `pip install onnxruntime` (CPU) or `onnxruntime-gpu` (CUDA). # dev/test -pytest>=8.0 +pytest>=9.0.3 pytest-mock>=3.14 ruff>=0.6 # lint (config in pyproject.toml); run in CI tomli>=2.0 ; python_version < "3.11" # tests parse emitted TOML; tomllib is 3.11+ stdlib diff --git a/studio/__init__.py b/studio/__init__.py index 5a1e510..86afafa 100644 --- a/studio/__init__.py +++ b/studio/__init__.py @@ -1,3 +1,15 @@ """Dataset Deviser: turn a character, style, or concept into a ready-to-train LoRA dataset.""" -__version__ = "0.20.1" +__version__ = "0.21.0" + +# Decode only the formats config.IMAGE_EXTS accepts. Pillow content-sniffs, so a +# ".png" that is really EPS/PDF/JPEG2000/... would otherwise reach decoders with +# open CVEs (EPS even shells out to Ghostscript). +# shortcut: Pillow 13 fixes most of these but gradio 6.30 caps pillow<13; keep this after. +from PIL import Image as _Image + +_Image.init() +_DECODERS = {"PNG", "JPEG", "WEBP", "BMP", "TIFF"} +_Image.ID[:] = [f for f in _Image.ID if f in _DECODERS] +for _f in set(_Image.OPEN) - _DECODERS: + del _Image.OPEN[_f] diff --git a/studio/pipeline.py b/studio/pipeline.py index a66649d..d0ea331 100644 --- a/studio/pipeline.py +++ b/studio/pipeline.py @@ -309,10 +309,14 @@ def build_finish( Returns the dataset folder and the train command ("" without a trainer). """ - from studio.captioner import caption_images + from studio.captioner import caption_images, resolve_captioner_config from studio.package import package_dataset + # Without this the custom endpoint arrives with no base URL (the ③ tab and + # `cli caption` resolve it; Quick build and `cli build` did not). + model_override, spec_overrides = resolve_captioner_config(captioner) items = caption_images(images, captioner, name, trigger, progress=progress, + model_override=model_override, spec_overrides=spec_overrides, style=caption_style, prefix=prefix, suffix=suffix, blacklist=drop_tags, dataset_type=dataset_type, sparse=sparse, identity=identity) diff --git a/tests/conftest.py b/tests/conftest.py index 8b87798..7dc214d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,10 +17,14 @@ def _isolate_run_dirs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: some holding a full 24-shot set. Redirecting the three output roots per test makes the suite hermetic and keeps `runs/` meaning "work the user did". """ + from studio import user_config from studio.config import settings for attr in ("runs_dir", "output_root", "shot_plans_dir"): monkeypatch.setattr(settings, attr, tmp_path / attr, raising=False) + # UI handlers persist choices (dataset type, ⑤ settings); without this the suite + # overwrote the developer's own .cache/user_settings.json. + monkeypatch.setattr(user_config, "USER_SETTINGS_FILE", tmp_path / "user_settings.json") @pytest.fixture diff --git a/tests/test_gradio6_defaults.py b/tests/test_gradio6_defaults.py new file mode 100644 index 0000000..cb5a400 --- /dev/null +++ b/tests/test_gradio6_defaults.py @@ -0,0 +1,13 @@ +"""Gradio 6 defaults a Textbox to None; the app's handlers expect strings.""" +from __future__ import annotations + +import gradio as gr + +import app as A + + +def test_no_textbox_starts_as_none() -> None: + boxes = [c for c in A.demo.blocks.values() if isinstance(c, gr.Textbox)] + assert boxes and all(c.value is not None for c in boxes) + sent = {c["id"]: c["props"].get("value") for c in A.demo.get_config_file()["components"]} + assert all(sent[c._id] is not None for c in boxes) diff --git a/tests/test_launch_security.py b/tests/test_launch_security.py new file mode 100644 index 0000000..a439744 --- /dev/null +++ b/tests/test_launch_security.py @@ -0,0 +1,34 @@ +"""The launch settings that keep a localhost, no-auth app from leaking files.""" +from __future__ import annotations + +from fastapi.testclient import TestClient +from gradio.routes import App + +import app as A + + +def _client() -> TestClient: + kw = A.LAUNCH_KWARGS + A.demo.allowed_paths = kw["allowed_paths"] + A.demo.blocked_paths = kw["blocked_paths"] + return TestClient(App.create_app(A.demo, app_kwargs=dict(kw["app_kwargs"]))) + + +def test_binds_to_localhost_only() -> None: + assert A.LAUNCH_KWARGS["server_name"] == "127.0.0.1" + assert "share" not in A.LAUNCH_KWARGS + assert A.LAUNCH_KWARGS["run_history"] is False + + +def test_foreign_host_header_is_refused() -> None: + # DNS rebinding: a page on evil.example re-pointed at 127.0.0.1 sends its own Host. + c = _client() + assert c.get("/config", headers={"host": "evil.example:7861"}).status_code == 400 + assert c.get("/config", headers={"host": "127.0.0.1:7861"}).status_code == 200 + assert c.get("/config", headers={"host": "localhost:7861"}).status_code == 200 + + +def test_env_file_is_never_served() -> None: + env = A.REPO_ROOT / ".env" + r = _client().get(f"/gradio_api/file={env}", headers={"host": "127.0.0.1"}) + assert r.status_code in (403, 404) diff --git a/tests/test_pillow_formats.py b/tests/test_pillow_formats.py new file mode 100644 index 0000000..7e18600 --- /dev/null +++ b/tests/test_pillow_formats.py @@ -0,0 +1,32 @@ +"""Pillow only decodes the formats the app accepts (studio/__init__.py).""" +from __future__ import annotations + +import io + +import pytest +from PIL import Image, UnidentifiedImageError + +import studio # noqa: F401 (applies the decoder allowlist) + + +@pytest.mark.parametrize("fmt", ["PNG", "JPEG", "WEBP", "BMP", "TIFF"]) +def test_accepted_formats_still_open(fmt: str) -> None: + buf = io.BytesIO() + Image.new("RGB", (4, 4), "red").save(buf, fmt) + buf.seek(0) + assert Image.open(buf).size == (4, 4) + + +@pytest.mark.parametrize("fmt", ["GIF", "PPM", "PCX", "TGA"]) +def test_other_formats_are_refused(fmt: str) -> None: + buf = io.BytesIO() + Image.new("RGB", (4, 4), "red").save(buf, fmt) # encoders still exist + buf.seek(0) + with pytest.raises(UnidentifiedImageError): + Image.open(buf) + + +def test_eps_never_reaches_ghostscript() -> None: + eps = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 4 4\nshowpage\n" + with pytest.raises(UnidentifiedImageError): + Image.open(io.BytesIO(eps)) diff --git a/tests/test_quick_build.py b/tests/test_quick_build.py index 97ed321..cd5c519 100644 --- a/tests/test_quick_build.py +++ b/tests/test_quick_build.py @@ -109,3 +109,26 @@ def test_engine_note_prices_cloud_and_skips_style(monkeypatch: pytest.MonkeyPatc monkeypatch.setattr(A.settings, "gemini_api_key", "k") assert "$" in A.quick_engine_note("gemini", "character") assert "generate nothing" in A.quick_engine_note("gemini", "style") + + +def test_finish_uses_the_saved_custom_endpoint( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # 0.20.0-0.20.1: build_finish skipped resolve_captioner_config, so the custom + # endpoint reached the captioner with no base URL. + from studio import captioner, user_config + + monkeypatch.setattr(user_config, "get_custom_captioner", lambda: { + "base_url": "http://127.0.0.1:1/v1", "model": "m", "api_key_env": "", + "min_interval_s": 0}) + seen: dict = {} + + def fake_caption(images, key, *a, **kw): + seen.update(kw) + raise RuntimeError("stop here") + + monkeypatch.setattr(captioner, "caption_images", fake_caption) + with pytest.raises(RuntimeError, match="stop here"): + pipeline.build_finish(_start(tmp_path), [], name="Ann", trigger="ann", + captioner="custom", output_root=tmp_path) + assert seen["spec_overrides"]["base_url"] == "http://127.0.0.1:1/v1" + assert seen["model_override"] == "m" diff --git a/tests/test_selection_flow.py b/tests/test_selection_flow.py index fb550e2..c67e40d 100644 --- a/tests/test_selection_flow.py +++ b/tests/test_selection_flow.py @@ -41,7 +41,7 @@ def test_the_picker_script_embeds_those_same_ids() -> None: def test_the_picker_script_is_installed_on_the_page() -> None: - assert A.demo.head and "thumbnail-item" in A.demo.head + assert "thumbnail-item" in A.LAUNCH_KWARGS["head"] def test_the_picker_script_supports_shift_click_ranges() -> None: