Initial commit: OmniSVG-Lite MVP before live-streaming work
- LM Studio client (httpx-based, OpenAI-compatible) - SVG validator (lxml, whitelist tags, no <script>/<foreignObject>/http refs) - PNG renderer (resvg-py primary, cairosvg fallback - no native cairo dep) - History (SQLite, tracks raw/validated/preview paths) - Gradio UI on 127.0.0.1:8788 with: * mode radio (icon/illustration) * n_candidates slider (default 1) * image upload for image-to-SVG * LM Studio URL/token inputs * model dropdown + refresh button - prompts/ with system_icon.txt, system_illustration.txt, few_shot_examples.txt - docs/spec.md, docs/design.md - 122 unit/integration tests passing
This commit is contained in:
+395
@@ -0,0 +1,395 @@
|
||||
# OmniSVG-Lite — Design
|
||||
|
||||
> Архитектура MVP. Backend-dev может взять этот документ + `prompts/` и писать код без уточнений.
|
||||
|
||||
---
|
||||
|
||||
## 1. Стек и обоснование
|
||||
|
||||
| Компонент | Выбор | Зачем |
|
||||
|---|---|---|
|
||||
| Язык | Python 3.11 | Зафиксировано пользователем. |
|
||||
| UI | Gradio 5 | `Blocks` + встроенный Gallery. Альтернативы (Streamlit, NiceGUI) хуже по лайауту. |
|
||||
| HTTP | httpx (sync) | Проще openai SDK, JSON+timeouts из коробки. |
|
||||
| Парсинг/валидация | lxml | Строгий, `XMLSyntaxError` с координатами, `iter()` для обхода. |
|
||||
| PNG-рендер | cairosvg | Декларативный, синхронный. Pillow+svglib — хуже на градиентах. |
|
||||
| БД | sqlite3 (stdlib) | WAL, 1 таблица. SQLAlchemy — overkill. |
|
||||
| Логирование | logging (stdlib) | Достаточно. |
|
||||
| Concurrency | threading (stdlib) | UI не фризит во время LM-вызова. |
|
||||
| Тесты | pytest | validator/renderer/prompts покрыты. |
|
||||
|
||||
**Не берём:** `openai` SDK (httpx достаточно), `pydantic` (dataclass хватит), `tenacity` (одна попытка в MVP).
|
||||
|
||||
---
|
||||
|
||||
## 2. Структура модулей
|
||||
|
||||
```
|
||||
OmniSVG-Lite/
|
||||
├── app.py # Gradio UI, callbacks, main()
|
||||
├── lm_client.py # httpx-обёртка над LM Studio
|
||||
├── prompts.py # Сборка messages[], загрузка шаблонов
|
||||
├── validator.py # lxml-парсинг + проверка правил SVG
|
||||
├── renderer.py # cairosvg → PNG bytes + сохранение
|
||||
├── history.py # SQLite-схема, CRUD
|
||||
├── data/{omnisvg.db, previews/<id>_<n>.png}
|
||||
├── prompts/{system_icon.txt, system_illustration.txt, few_shot_examples.txt}
|
||||
├── docs/{spec.md, design.md}
|
||||
└── tests/{test_validator.py, test_renderer.py, test_prompts.py}
|
||||
```
|
||||
|
||||
### 2.1. `lm_client.py` — httpx → LM Studio
|
||||
|
||||
Импорт: `app.py`. Зависимости: httpx, base64, json, logging.
|
||||
|
||||
```python
|
||||
class LMTurnResult:
|
||||
raw_texts: list[str] # N строк, по одной на кандидат
|
||||
elapsed_s: float
|
||||
model: str
|
||||
usage: dict | None
|
||||
|
||||
def chat(*, base_url, api_key, model, messages, n, temperature,
|
||||
max_tokens=4096, timeout_s=60.0) -> LMTurnResult: ...
|
||||
```
|
||||
|
||||
### 2.2. `prompts.py` — сборка messages
|
||||
|
||||
Импорт: `app.py`. Зависимости: pathlib, prompts/.
|
||||
|
||||
```python
|
||||
def load_system_prompt(mode: Literal["icon","illustration"]) -> str: ...
|
||||
def load_few_shot() -> list[dict]: ... # [{role,content}, ...]
|
||||
def build_messages(*, mode, prompt, image_b64: str|None,
|
||||
palette: str|None, n: int, temperature: float) -> list[dict]: ...
|
||||
```
|
||||
|
||||
### 2.3. `validator.py` — lxml + правила
|
||||
|
||||
Импорт: `app.py`. Зависимости: lxml.etree, re.
|
||||
|
||||
```python
|
||||
class ValidatorError(Exception): # code: malformed_xml|not_svg|missing_viewbox|bad_viewbox|disallowed_tag|unknown_tag|external_ref|too_large
|
||||
code: str; detail: str
|
||||
|
||||
ALLOWED_TAGS = {"svg","g","defs","symbol","use","path","rect","circle","ellipse",
|
||||
"line","polygon","polyline","linearGradient","radialGradient","stop",
|
||||
"filter","feGaussianBlur","feOffset","feBlend","feMerge","feMergeNode",
|
||||
"feFlood","feComposite","feColorMatrix","clipPath","mask","pattern",
|
||||
"text","tspan","textPath","title","desc"}
|
||||
DISALLOWED_TAGS = {"script","foreignObject","image"}
|
||||
MAX_BYTES = {"icon": 32*1024, "illustration": 256*1024}
|
||||
|
||||
def extract_svg(text: str) -> str: ... # regex
|
||||
def validate(svg_text: str, *, mode) -> etree._Element: ... # raises ValidatorError
|
||||
```
|
||||
|
||||
### 2.4. `renderer.py` — cairosvg
|
||||
|
||||
Импорт: `app.py`. Зависимости: cairosvg, pathlib, io.
|
||||
|
||||
```python
|
||||
def svg_to_png_bytes(svg_text: str, *, output_width: int) -> bytes: ...
|
||||
# добавляет xmlns если нет, output_width=256 (icon) / 512 (illustration)
|
||||
def save_preview(png_bytes, *, job_id: int, candidate_index: int) -> Path: ...
|
||||
def to_pil_image(png_bytes: bytes) -> "PIL.Image.Image": ...
|
||||
```
|
||||
|
||||
### 2.5. `history.py` — SQLite CRUD
|
||||
|
||||
Импорт: `app.py`. Зависимости: sqlite3, json, time, pathlib.
|
||||
|
||||
```python
|
||||
DB_PATH = Path("data/omnisvg.db")
|
||||
|
||||
def init_db() -> None: ... # WAL mode
|
||||
def insert_job(*, prompt, mode, model, n_candidates, status,
|
||||
raw_outputs: list[str], validated_outputs: list[str],
|
||||
preview_paths: list[str], error: str|None = None) -> int: ...
|
||||
def list_jobs(limit: int = 50) -> list[dict]: ... # для History table
|
||||
def get_job(job_id: int) -> dict | None: ...
|
||||
```
|
||||
|
||||
### 2.6. `app.py` — UI + wiring
|
||||
|
||||
Импорт: всё перечисленное + gradio.
|
||||
|
||||
```python
|
||||
def on_generate(prompt, mode, n, temperature, image, palette, model) -> \
|
||||
tuple[list[tuple[str,str]], list[dict], str]: ...
|
||||
def on_history_select(evt: gr.SelectData): ... # row click
|
||||
def main() -> None: ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. LM Studio client — детали
|
||||
|
||||
- **POST** `http://127.0.0.1:1234/v1/chat/completions`, заголовки: `Content-Type: application/json`, `Authorization: Bearer lm-studio` (значение игнорируется, но требуется OpenAI-совместимым клиентом).
|
||||
- **`stream=false`** в payload: n>1 плохо сочетается со streaming, плюс инкрементальный парсинг сложнее.
|
||||
- **Payload (text-to-SVG):** `{"model", "messages":[{"role":"system","content":...}], "n", "temperature", "max_tokens":4096, "stream":false}`.
|
||||
- **Payload (image-to-SVG):** `content` это `[{type:"text", text:...}, {type:"image_url", image_url:{url:"data:image/png;base64,..."}}]` — текст **первым**, картинка **второй**.
|
||||
- **Парсинг ответа:** `choices[i].message.content` — N строк. `tool_calls` не передаём; если модель их всё же вернула, игнорируем (если `content` пустой — кандидат invalid).
|
||||
- **Кодирование картинки:** convert в RGB, проверка `max(size) <= 4096`, PIL save PNG optimize → base64 в data-URL. JPEG **не используем** (LM Studio VLM обучены на PNG, артефактов на границах не будет).
|
||||
|
||||
---
|
||||
|
||||
## 4. Промпт-стратегия
|
||||
|
||||
**Структура messages[]:**
|
||||
|
||||
```
|
||||
system: <system_icon.txt | system_illustration.txt>
|
||||
user: <few-shot user 1>
|
||||
assistant: <few-shot svg 1>
|
||||
user: <few-shot user 2>
|
||||
assistant: <few-shot svg 2>
|
||||
... (3-5 пар)
|
||||
user: <actual user prompt + (опц.) image>
|
||||
```
|
||||
|
||||
**Требования к SVG в ответе модели:**
|
||||
- Один корневой `<svg>...</svg>`, **ничего больше** (никаких ``` ``` ```, никаких пояснений).
|
||||
- `viewBox` обязателен и совпадает с mode: `0 0 64 64` (icon) или `0 0 512 512` (illustration).
|
||||
- Без `<script>`, `<foreignObject>`, `<image>`, `http://`, `https://`, `data:` в href.
|
||||
- Размер файла ≤ лимита mode (32/256 KB).
|
||||
- `xmlns="http://www.w3.org/2000/svg"` желателен (валидатор примет и без, но cairosvg ругается — добавляем автоматически в renderer).
|
||||
|
||||
**Few-shot (готов в `prompts/few_shot_examples.txt`):** 2 иконки (filled magnifying glass, outline battery), 2 иллюстрации (flat fox, isometric server room), 1 пример refusal (NSFW-отказ без SVG).
|
||||
|
||||
---
|
||||
|
||||
## 5. Валидация — что проверяет lxml
|
||||
|
||||
```python
|
||||
def validate(svg_text, *, mode):
|
||||
svg_text = extract_svg(svg_text) # вытащить <svg>...</svg>
|
||||
try:
|
||||
root = etree.fromstring(svg_text.encode("utf-8"))
|
||||
except etree.XMLSyntaxError as e:
|
||||
raise ValidatorError("malformed_xml", str(e))
|
||||
local_root = root.tag.split("}")[-1]
|
||||
if local_root != "svg": raise ValidatorError("not_svg", root.tag)
|
||||
if not root.get("viewBox"): raise ValidatorError("missing_viewbox", "")
|
||||
try:
|
||||
vb = [float(x) for x in root.get("viewBox").split()]
|
||||
if len(vb) != 4: raise ValueError
|
||||
except ValueError:
|
||||
raise ValidatorError("bad_viewbox", root.get("viewBox"))
|
||||
for el in root.iter():
|
||||
local = el.tag.split("}")[-1]
|
||||
if local in DISALLOWED_TAGS: raise ValidatorError("disallowed_tag", local)
|
||||
if local not in ALLOWED_TAGS: raise ValidatorError("unknown_tag", local)
|
||||
for attr, val in el.attrib.items():
|
||||
if val and ("http://" in val or "https://" in val):
|
||||
if attr.endswith("href") or "url(" in val:
|
||||
raise ValidatorError("external_ref", f"{attr}={val[:50]}")
|
||||
if len(svg_text.encode("utf-8")) > MAX_BYTES[mode]:
|
||||
raise ValidatorError("too_large", f"{len(svg_text)} bytes")
|
||||
return root
|
||||
```
|
||||
|
||||
`SVG_NS = "http://www.w3.org/2000/svg"`. lxml возвращает теги в Clark-notation `{ns}local`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Рендер
|
||||
|
||||
```python
|
||||
def svg_to_png_bytes(svg_text, *, output_width):
|
||||
if "xmlns=" not in svg_text.split(">", 1)[0]:
|
||||
svg_text = svg_text.replace("<svg", '<svg xmlns="http://www.w3.org/2000/svg"', 1)
|
||||
return cairosvg.svg2png(
|
||||
bytestring=svg_text.encode("utf-8"),
|
||||
output_width=output_width, # 256 icon, 512 illustration
|
||||
background_color="white",
|
||||
)
|
||||
```
|
||||
|
||||
PNG bytes → `save_preview` (файл) + `to_pil_image` (для Gallery). Лучший получает caption `"★ best — #1"`.
|
||||
|
||||
**Windows + Cairo:** если `pycairo` поставлен через conda — работает. Через pip — может потребоваться GTK runtime. В README отметить, **в MVP не чинить**.
|
||||
|
||||
---
|
||||
|
||||
## 7. История — SQLite схема
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS jobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at REAL NOT NULL, -- time.time()
|
||||
prompt TEXT NOT NULL,
|
||||
mode TEXT NOT NULL CHECK (mode IN ('icon','illustration')),
|
||||
model TEXT NOT NULL,
|
||||
n_candidates INTEGER NOT NULL,
|
||||
temperature REAL NOT NULL,
|
||||
status TEXT NOT NULL, -- ok | all_invalid | error
|
||||
error TEXT,
|
||||
raw_outputs TEXT NOT NULL, -- JSON list[str]
|
||||
validated_outputs TEXT NOT NULL, -- JSON list[str]
|
||||
preview_paths TEXT NOT NULL, -- JSON list[str] (rel paths)
|
||||
best_index INTEGER -- индекс лучшего или NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_created_at ON jobs(created_at DESC);
|
||||
```
|
||||
|
||||
WAL mode в `init_db()`: `PRAGMA journal_mode=WAL`. JSON через `json.dumps/loads`. 1 запись = 1 запуск, N кандидатов внутри.
|
||||
|
||||
---
|
||||
|
||||
## 8. Gradio UI
|
||||
|
||||
### 8.1. Layout (ASCII)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ OmniSVG-Lite [Model: ▼] │
|
||||
├──────────────────┬──────────────────────────────────────────────┤
|
||||
│ Prompt │ Gallery (PNG превью) │
|
||||
│ [textbox] │ [★best] [#2] [#3] │
|
||||
│ Reference image │ │
|
||||
│ [image upload] │ Selected SVG: │
|
||||
│ Palette (opt) │ [code block] │
|
||||
│ [textbox] │ │
|
||||
│ Mode │ History │
|
||||
│ (•) icon ( ) ill │ [gr.Dataframe] │
|
||||
│ Candidates 1..8 │ │
|
||||
│ [slider] │ │
|
||||
│ Temperature │ │
|
||||
│ [slider] │ │
|
||||
│ [Generate] │ │
|
||||
│ Status: [md] │ │
|
||||
└──────────────────┴──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 8.2. Компоненты
|
||||
|
||||
| gr.X | Имя | Параметры |
|
||||
|---|---|---|
|
||||
| `Textbox` | `prompt_tb` | lines=4, max_lines=8 |
|
||||
| `Image` | `image_in` | type="pil", sources=["upload","clipboard"] |
|
||||
| `Textbox` | `palette_tb` | lines=1 |
|
||||
| `Radio` | `mode_radio` | ["icon","illustration"], value="icon" |
|
||||
| `Slider` | `n_slider` | 1..8, step=1, value=4 (icon)/2 (illustration) |
|
||||
| `Slider` | `temp_slider` | 0..1.5, step=0.05, value=0.4 |
|
||||
| `Button` | `gen_btn` | "Generate", variant="primary" |
|
||||
| `Gallery` | `gallery` | columns=3, height=320, object_fit="contain" |
|
||||
| `Code` | `svg_viewer` | language="xml" |
|
||||
| `Dataframe` | `history_df` | id/created_at/mode/model/n/status |
|
||||
| `Markdown` | `status_md` | для статус-строки |
|
||||
| `Dropdown` | `model_dd` | из DEFAULT_MODEL env |
|
||||
|
||||
### 8.3. Callbacks (контракт)
|
||||
|
||||
```python
|
||||
def on_mode_change(mode): return gr.update(value=4 if mode=="icon" else 2)
|
||||
|
||||
def on_generate(prompt, mode, n, temp, image, palette, model):
|
||||
if not (1 <= len(prompt.strip()) <= 1000):
|
||||
raise gr.Warning("Prompt must be 1–1000 characters")
|
||||
if image is not None: _check_image(image) # size/format
|
||||
messages = build_messages(mode=mode, prompt=prompt,
|
||||
image_b64=encode_image(image) if image else None,
|
||||
palette=palette or None, n=n, temperature=temp)
|
||||
result = chat(base_url=..., model=model, messages=messages, n=n, temperature=temp)
|
||||
previews, validated = [], []
|
||||
for i, raw in enumerate(result.raw_texts):
|
||||
try:
|
||||
svg = extract_svg(raw); validate(svg, mode=mode)
|
||||
png = svg_to_png_bytes(svg, output_width=256 if mode=="icon" else 512)
|
||||
path = save_preview(png, job_id=0, candidate_index=i)
|
||||
validated.append(svg); previews.append((str(path), f"#{i+1}"))
|
||||
except ValidatorError as e:
|
||||
log.warning("invalid candidate %d: %s", i, e.code)
|
||||
best_idx = None
|
||||
if validated:
|
||||
sizes = [len(s.encode("utf-8")) for s in validated]
|
||||
best_idx = min(range(len(sizes)), key=lambda i: sizes[i])
|
||||
previews[best_idx] = (previews[best_idx][0], f"★ best — #{best_idx+1}")
|
||||
job_id = insert_job(prompt=prompt, mode=mode, model=model, n_candidates=n,
|
||||
temperature=temp, status="ok" if validated else "all_invalid",
|
||||
raw_outputs=result.raw_texts, validated_outputs=validated,
|
||||
preview_paths=[p[0] for p in previews],
|
||||
best_index=best_idx)
|
||||
return previews, validated[best_idx] if validated else "", _refresh_history(), \
|
||||
f"Generated {len(validated)}/{n}"
|
||||
|
||||
def on_history_select(evt: gr.SelectData):
|
||||
job = get_job(evt.value[0])
|
||||
return [(p, "") for p in job["preview_paths"]], \
|
||||
job["validated_outputs"][job["best_index"] or 0]
|
||||
```
|
||||
|
||||
Gradio сам сериализует вызовы одного callback; `gen_btn.interactive = False` через `.then()` достаточно.
|
||||
|
||||
---
|
||||
|
||||
## 9. Обработка ошибок
|
||||
|
||||
| Слой | Что ловим | UI | Лог | history.status |
|
||||
|---|---|---|---|---|
|
||||
| UI pre-check | пустой/длинный prompt, плохая картинка | `gr.Warning` | INFO | — (запись не создаётся) |
|
||||
| LM Studio | timeout, 5xx, network | `gr.Error` | ERROR+tb | `error` |
|
||||
| Парсер | модель вернула текст без `<svg>` | — | WARNING | `all_invalid` / частично `ok` |
|
||||
| Валидатор | malformed, viewBox, tag, ref, size | — | WARNING (с code) | как выше |
|
||||
| Renderer | cairosvg бросил | invalid | WARNING | как выше |
|
||||
| History | SQLite locked / disk full | `gr.Error("DB error")` | ERROR | — |
|
||||
|
||||
**Принцип:** всё, что ниже UI pre-check, **не блокирует** показ уже валидных кандидатов. Из 4 валиден 1 → показываем 1, статус `ok`, в `raw_outputs` все 4 (включая невалидные).
|
||||
|
||||
---
|
||||
|
||||
## 10. Запуск
|
||||
|
||||
### 10.1. Команды
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
.venv\Scripts\activate # Windows
|
||||
pip install -r requirements.txt
|
||||
python app.py
|
||||
```
|
||||
|
||||
UI поднимется на `http://127.0.0.1:7860` (Gradio default).
|
||||
|
||||
### 10.2. requirements.txt (стартовая точка)
|
||||
|
||||
```
|
||||
gradio>=5.0,<6
|
||||
httpx>=0.27
|
||||
lxml>=5.0
|
||||
cairosvg>=2.7
|
||||
Pillow>=10.0
|
||||
pytest>=8.0 # dev only
|
||||
```
|
||||
|
||||
### 10.3. Env-переменные (опционально, с дефолтами)
|
||||
|
||||
| Переменная | Дефолт | Назначение |
|
||||
|---|---|---|
|
||||
| `LM_STUDIO_BASE_URL` | `http://127.0.0.1:1234/v1` | endpoint |
|
||||
| `LM_STUDIO_API_KEY` | `lm-studio` | Authorization header |
|
||||
| `DEFAULT_MODEL` | `qwen/qwen3.5-35b-a3b` | Dropdown default |
|
||||
| `REQUEST_TIMEOUT_S` | `60` | httpx timeout |
|
||||
| `OMNISVG_DB_PATH` | `data/omnisvg.db` | SQLite |
|
||||
| `OMNISVG_PREVIEW_DIR` | `data/previews` | PNG-превью |
|
||||
| `LOG_LEVEL` | `INFO` | DEBUG/INFO/WARNING |
|
||||
|
||||
### 10.4. Логи при старте
|
||||
|
||||
```
|
||||
INFO omnisvg starting model=qwen/qwen3.5-35b-a3b base_url=http://127.0.0.1:1234/v1
|
||||
INFO omnisvg db initialized at data/omnisvg.db (WAL)
|
||||
INFO omnisvg ui on http://127.0.0.1:7860
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. Open Questions
|
||||
|
||||
- **Streaming** — не идём, non-stream. Если понадобится: второй endpoint в `lm_client.py` (`stream=true` + SSE-парсер) + callback с `gr.update(...)` для прогресс-бара.
|
||||
- **Per-mode `n` default** — решено: 4 для icon, 2 для illustration (§8.2).
|
||||
- **PNG-превью лучшего крупнее** — нет, все одинаковые. Акцент через caption "★ best".
|
||||
- **Regenerate** — нет в MVP. Если нужен: ~20 строк, кнопка копирует параметры в поля и дёргает `on_generate`.
|
||||
- **cairosvg на Windows** — README предупреждает про GTK runtime. Не блокер MVP.
|
||||
- **Cleanup старых превью** — нет, диск заполняется. В прод — TTL/джоба.
|
||||
+251
@@ -0,0 +1,251 @@
|
||||
# OmniSVG-Lite — Spec
|
||||
|
||||
> MVP-спецификация. Цель: дать пользователю веб-приложение, в котором можно
|
||||
> из текста или картинки получить N кандидатов SVG в одном из двух режимов
|
||||
> (icon / illustration), посмотреть PNG-превью и сохранить историю генераций.
|
||||
|
||||
---
|
||||
|
||||
## 1. Цели и не-цели
|
||||
|
||||
**Цель MVP:**
|
||||
- Локально запускаемый Gradio UI, который ходит в LM Studio (OpenAI-compatible)
|
||||
и возвращает валидный SVG + PNG-превью.
|
||||
- Один пользователь, без авторизации, без облака.
|
||||
- История генераций хранится в локальной SQLite.
|
||||
|
||||
**Не-цели (Out of Scope для MVP):**
|
||||
- Редактирование SVG в браузере (paint / drag).
|
||||
- Экспорт в Lottie / GIF / видео.
|
||||
- Batch-генерация (CSV промптов, генерация по расписанию).
|
||||
- Авторизация, multi-user, разграничение прав.
|
||||
- Деплой / Docker / CI.
|
||||
- Per-mode температура — единый слайдер на UI.
|
||||
- Fine-tune, RLHF, оценка качества моделей.
|
||||
- Поддержка моделей, отличных от LM Studio (Ollama, vLLM, OpenAI cloud) — клиент
|
||||
пишется под OpenAI-compatible chat completions, но другие провайдеры не
|
||||
тестируются.
|
||||
|
||||
---
|
||||
|
||||
## 2. Режимы генерации
|
||||
|
||||
### 2.1. Mode: `icon`
|
||||
|
||||
**Назначение:** маленькие, узнаваемые, плоские или с лёгкой стилизацией иконки
|
||||
для UI. Допустимы монохром и 2–3 цвета. Геометрия упрощённая, без мелких деталей.
|
||||
|
||||
**Требования к SVG:**
|
||||
- `viewBox="0 0 64 64"` (фиксировано, чтобы иконки были консистентны).
|
||||
- Геометрия привязана к пиксельной сетке (целочисленные координаты ±0.5).
|
||||
- Без градиентов со множеством stops, без фильтров (`feGaussianBlur` и т.п.).
|
||||
- Допустимы: `<path>`, `<rect>`, `<circle>`, `<ellipse>`, `<line>`, `<polygon>`,
|
||||
`<polyline>`, `<g>`, `<text>`, базовые `<linearGradient>`/`<radialGradient>`.
|
||||
|
||||
**Промпт-стиль (направление для пользователя):**
|
||||
"outline battery", "filled magnifying glass", "settings gear, line style".
|
||||
|
||||
### 2.2. Mode: `illustration`
|
||||
|
||||
**Назначение:** полноценные сцены / объекты / паттерны. Больше деталей, больше
|
||||
цветов, допустимы градиенты и простые фильтры.
|
||||
|
||||
**Требования к SVG:**
|
||||
- `viewBox="0 0 512 512"` (фиксировано).
|
||||
- Допустимы все валидные теги SVG 1.1 кроме `<script>` и `<foreignObject>`.
|
||||
- Допустимы простые `<filter>` (без `<feImage>` на внешний URL).
|
||||
- Палитра ограничена заявленной пользователем (если указана).
|
||||
|
||||
**Промпт-стиль:**
|
||||
"flat illustration of a fox in a forest, autumn colors",
|
||||
"isometric server room, blue and purple palette".
|
||||
|
||||
### 2.3. Сводная таблица
|
||||
|
||||
| Параметр | icon | illustration |
|
||||
|----------------------|---------------------------------|----------------------------------|
|
||||
| viewBox | `0 0 64 64` | `0 0 512 512` |
|
||||
| Размер файла (лимит) | 32 KB | 256 KB |
|
||||
| Допустимые теги | Базовые shapes + g + text | Полный SVG 1.1 минус опасные |
|
||||
| Градиенты | ≤ 2 stops | без ограничений |
|
||||
| Фильтры | нет | простые (без feImage на URL) |
|
||||
| Текст | допустим, но не основной канал | допустим |
|
||||
| Дефолтный n_candidates | 4 | 2 |
|
||||
|
||||
---
|
||||
|
||||
## 3. Типы задач
|
||||
|
||||
### 3.1. text-to-SVG
|
||||
|
||||
- Вход: `prompt: str` (1–1000 символов), `mode ∈ {icon, illustration}`,
|
||||
`n_candidates: int ∈ [1, 8]`.
|
||||
- Опционально: палитра (поле в UI, не обязательное).
|
||||
- Модель получает только текст.
|
||||
|
||||
### 3.2. image-to-SVG
|
||||
|
||||
- Вход: `prompt: str` (1–1000 символов), `image: PIL.Image | file path`,
|
||||
`mode ∈ {icon, illustration}`, `n_candidates: int ∈ [1, 8]`.
|
||||
- Допустимые форматы картинки: PNG, JPEG, WEBP. Максимум 10 MB, до 4096×4096.
|
||||
- Картинка передаётся в модель как `image_url` (data: URL, base64).
|
||||
- В промпте добавляется явная инструкция: "Recreate the visual content of the
|
||||
attached image as SVG. Do not describe, just generate the markup."
|
||||
|
||||
---
|
||||
|
||||
## 4. Поведение N кандидатов
|
||||
|
||||
**Логика выбора лучшего кандидата (для MVP — простая, детерминированная):**
|
||||
|
||||
1. Из N ответов модели берём только валидные (прошедшие lxml-валидацию).
|
||||
2. Если валидных 0 — показываем ошибку `"no_valid_candidates"` (см. §6).
|
||||
3. Каждому валидному кандидату считаем **score = размер_файла_KB** (инвертированный:
|
||||
меньше = лучше). Это прокси "компактность", не качество.
|
||||
4. Лучший = кандидат с минимальным score.
|
||||
5. **Показ в UI:**
|
||||
- `Gallery` отображает PNG-превью всех валидных кандидатов в сетке.
|
||||
- Лучший помечается визуально: бейдж "★ best" над PNG.
|
||||
- В выпадашке/таблице истории сохраняются все валидные SVG + пути к PNG.
|
||||
|
||||
**Замечание:** для MVP не используем VLM-as-judge или pairwise — это сильно
|
||||
увеличит latency и стоимость. Выбор по размеру файла — намеренно простой.
|
||||
|
||||
---
|
||||
|
||||
## 5. User Stories + Acceptance Criteria
|
||||
|
||||
### US-1. Сгенерировать иконку по текстовому промпту
|
||||
|
||||
**As a** дизайнер,
|
||||
**I want** ввести текстовое описание иконки и получить N вариантов SVG,
|
||||
**so that** я могу быстро подобрать визуал для кнопки/меню.
|
||||
|
||||
**Acceptance Criteria (Given/When/Then):**
|
||||
|
||||
- **Given** UI запущен и LM Studio отвечает, **when** я выбираю mode=`icon`,
|
||||
ввожу `prompt="filled magnifying glass"`, `n_candidates=4`, **then**:
|
||||
- отправляется ровно 1 запрос в LM Studio с `n=4`,
|
||||
- получаю 4 PNG-превью в Gallery (или меньше, если часть невалидна),
|
||||
- у лучшего есть бейдж "★ best",
|
||||
- история в SQLite содержит запись со всеми raw/validated SVG.
|
||||
- **Given** промпт пустой или длиннее 1000 символов, **when** я нажимаю
|
||||
Generate, **then** вижу `gr.Warning` "Prompt must be 1–1000 characters",
|
||||
запрос в LM Studio НЕ отправляется.
|
||||
- **Given** LM Studio не отвечает 30+ секунд, **when** идёт генерация, **then**
|
||||
запрос отменяется, UI показывает ошибку "LM Studio timeout".
|
||||
|
||||
### US-2. Сгенерировать иллюстрацию по картинке
|
||||
|
||||
**As a** иллюстратор,
|
||||
**I want** загрузить референс-картинку и текстовое описание, получить N SVG,
|
||||
**so that** я могу быстро сделать vector-версию понравившегося эскиза.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
- **Given** UI запущен, **when** я загружаю PNG/JPEG/WEBP, ввожу
|
||||
`prompt="flat illustration, blue and teal palette"`, выбираю
|
||||
mode=`illustration`, `n_candidates=2`, **then**:
|
||||
- картинка кодируется в base64 data: URL и отправляется в LM Studio,
|
||||
- получаю до 2 PNG в Gallery,
|
||||
- все raw-ответы и валидные SVG попадают в историю.
|
||||
- **Given** загружена картинка > 10 MB или > 4096×4096, **when** я нажимаю
|
||||
Generate, **then** UI отклоняет с ошибкой валидации до отправки в LM Studio.
|
||||
|
||||
### US-3. Посмотреть и скачать результат
|
||||
|
||||
**As a** пользователь,
|
||||
**I want** увидеть PNG-превью, скачать выбранный SVG и PNG,
|
||||
**so that** я могу сразу использовать результат в своих задачах.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
- **Given** генерация завершилась с ≥ 1 валидным кандидатом, **when** я смотрю
|
||||
на Gallery, **then**:
|
||||
- каждая картинка кликабельна,
|
||||
- под/возле картинки — кнопки `Download SVG` и `Download PNG`,
|
||||
- скачиваются именно те файлы, которые видны на превью (source of truth —
|
||||
путь в `preview_paths`).
|
||||
- **Given** я нажимаю `Download SVG`, **then** браузер получает файл с MIME
|
||||
`image/svg+xml` и расширением `.svg`.
|
||||
|
||||
### US-4. Просмотреть историю генераций
|
||||
|
||||
**As a** пользователь,
|
||||
**I want** видеть таблицу последних генераций и кликнуть строку, чтобы
|
||||
повторно открыть результаты,
|
||||
**so that** я могу вернуться к удачному варианту.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
- **Given** UI запущен, **when** я смотрю на History, **then**:
|
||||
- таблица показывает последние 50 записей (id, prompt, mode, model, n,
|
||||
status, created_at),
|
||||
- новые записи появляются вверху,
|
||||
- строки сортируются по `created_at DESC`.
|
||||
- **Given** я кликаю строку в History, **when** выбираю запись, **then**
|
||||
Gallery обновляется превью из `preview_paths` этой записи, raw SVG
|
||||
подгружается в отдельный code-block для копирования.
|
||||
- **Given** приложение перезапущено, **when** я открываю History, **then**
|
||||
данные сохранились (SQLite на диске).
|
||||
|
||||
### US-5. Контроль параметров (температура, n)
|
||||
|
||||
**As a** пользователь,
|
||||
**I want** управлять количеством кандидатов и креативностью,
|
||||
**so that** балансировать скорость/стоимость и разнообразие.
|
||||
|
||||
**Acceptance Criteria:**
|
||||
|
||||
- **Given** UI запущен, **when** я двигаю `n_candidates` slider, **then**
|
||||
диапазон 1–8, шаг 1, дефолт зависит от mode (icon=4, illustration=2).
|
||||
- **Given** UI запущен, **when** я двигаю `temperature` slider, **then**
|
||||
диапазон 0.0–1.5, шаг 0.05, дефолт 0.4.
|
||||
- **Given** `temperature=0`, **when** идёт генерация, **then** отправляется
|
||||
параметр `temperature=0` (детерминированный режим).
|
||||
|
||||
---
|
||||
|
||||
## 6. Edge Cases (таблица)
|
||||
|
||||
| # | Сценарий | Поведение |
|
||||
|---|---------------------------------------------|--------------------------------------------------------------------------------------------------------|
|
||||
| 1 | Пустой промпт | `gr.Warning` "Prompt must be 1–1000 characters", запрос не отправляется. |
|
||||
| 2 | Промпт > 1000 символов | То же, что (1). |
|
||||
| 3 | Картинка > 10 MB / > 4096×4096 / не PNG/JPEG/WEBP | Отклоняется до LM Studio, `gr.Warning` с указанием ограничения. |
|
||||
| 4 | LM Studio не отвечает (timeout 30s) | `gr.Error` "LM Studio timeout at <url>", статус генерации `error`, в лог пишется traceback. |
|
||||
| 5 | LM Studio вернул 5xx | `gr.Error` "LM Studio error: <code> <body>", статус `error`. |
|
||||
| 6 | Модель вернула не-SVG (текст/JSON/мусор) | Парсер пытается вытащить `<svg>...</svg>` regex'ом; если не нашёл — кандидат помечается invalid, идёт в history со status=`invalid`, в Gallery не попадает. |
|
||||
| 7 | SVG не well-formed XML | lxml бросает `XMLSyntaxError` → кандидат invalid, в лог `WARNING`, в history. |
|
||||
| 8 | Отсутствует / кривой `viewBox` | `ValidatorError("missing_viewbox")` → invalid. |
|
||||
| 9 | Внутри `<script>` или `<foreignObject>` | `ValidatorError("disallowed_tag")` → invalid. |
|
||||
| 10| `http://` / `https://` в `href` / `xlink:href` / `url(...)` | `ValidatorError("external_ref")` → invalid. |
|
||||
| 11| Размер файла > лимита (32 KB / 256 KB) | `ValidatorError("too_large")` → invalid. |
|
||||
| 12| Все N кандидатов invalid | `gr.Info` "All N candidates failed validation. See history for details.", в history — запись со status=`all_invalid`. |
|
||||
| 13| Картинка в image-to-SVG повреждена | PIL при открытии бросает `UnidentifiedImageError` → `gr.Warning` "Cannot read image", запрос не отправляется. |
|
||||
| 14| Пользователь жмёт Generate повторно во время генерации | Кнопка `interactive=False` на время выполнения, повторный клик игнорируется. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Не-функциональные требования
|
||||
|
||||
| Категория | Требование |
|
||||
|----------------|------------------------------------------------------------------------------------------|
|
||||
| Performance | Генерация 1 иконки (n=1) — p95 ≤ 20 секунд на одной 5090 / Ryzen 9. |
|
||||
| Performance | PNG-рендер одного SVG — p95 ≤ 200 ms. |
|
||||
| Privacy | Никакие промпты/картинки не уходят за пределы LM Studio на этой машине. |
|
||||
| Reliability | Падение LM Studio не валит UI: ошибка показывается, остальные кнопки работают. |
|
||||
| Storage | SQLite WAL mode, `data/omnisvg.db`. Превью PNG — `data/previews/<id>_<n>.png`. |
|
||||
| Logging | `INFO` для старта/конца генерации, `WARNING` для invalid, `ERROR` для LM Studio errors. |
|
||||
| Concurrency | MVP — однопользовательский, очередь не требуется. UI блокирует кнопку на время генерации. |
|
||||
|
||||
---
|
||||
|
||||
## 8. Открытые вопросы
|
||||
|
||||
- Требуется ли streaming в UI (как в LM Studio)? **Для MVP — нет**, выбран non-stream
|
||||
режим: n>1 плохо сочетается со streaming, плюс парсить инкрементально тяжелее.
|
||||
- Нужна ли кнопка "regenerate" с теми же параметрами? **Не вошла в MVP**, но UI
|
||||
должен позволять скопировать промпт обратно в textbox.
|
||||
- Нужен ли экспорт PNG в высоком разрешении (1024×1024)? **Нет**, достаточно
|
||||
рендера в нативный viewBox.
|
||||
Reference in New Issue
Block a user