Руководство по сборке локального AI‑стека
Ссылки на все части:
Часть 1. Общий план
Часть 2. Запуск llama‑cpp
Часть 3. Наполнение моделями
Часть 4. Запуск сервера генерации изображений
Часть 5. Open WebUI поверх llama.cpp
Часть 6. Конвертация моделей Hugging Face в GGUF
Часть 7. Финал. Офлайн‑развёртывание
Часть 4. Запуск сервера генерации изображений
На этом шаге мы соберём stable-diffusion.cpp, разберёмся, почему для картинок нужен не один файл, а три, разложим компоненты по папкам и запустим sd-server на порту 8888 с собственным веб‑интерфейсом. После этого у вас будет генератор изображений, доступный из LAN.
Что нужно понять до начала
stable-diffusion.cpp (sd.cpp) — это «младший брат» llama.cpp для диффузионных моделей. Он построен на том же ggml и умеет:
Работать на CPU — с теми же
mmap,--offload-to-cpu,systemd-run.Загружать модели в трёх форматах:
.gguf(квантованные),.safetensors(полная точность),.ckpt/.pt(старый PyTorch).Использовать разные архитектуры: SD 1.5, SDXL, FLUX, Qwen‑Image, Z‑Image, Mage‑Flow, Wan и другие.
Ключевое отличие от LLM: для генерации изображений почти никогда не достаточно одного файла. Модель состоит из трёх компонентов, и все три нужно явно указать при запуске.
Три компонента диффузионной модели
Компонент |
Что делает |
Флаг |
Размер (типично) |
|---|---|---|---|
Diffusion model (denoiser) |
Основная модель: «проявляет» изображение из шума |
|
2–12 ГБ |
VAE |
Декодирует латентное представление в реальные пиксели |
|
150–350 МБ |
Text encoder (LLM) |
Превращает промпт в вектор, понятный модели |
|
1.5–7 ГБ |
Почему так. Диффузионная модель работает не с пикселями, а с латентами — сжатым представлением изображения (например, 64×64×16 для картинки 512×512). VAE переводит латенты в пиксели (и обратно, если нужен img2img). Текстовый энкодер — это отдельная LLM (Qwen, T5, UMT5, Qwen3-VL), которая «понимает» промпт и передаёт его модели.
Что это значит на практике:
Нельзя запустить только
.gguf‑модель — без VAE и LLMsd.cppупадёт с ошибкой «VAE not loaded» или «text encoder not loaded».VAE и LLM можно переиспользовать между моделями одной архитектуры. Например,
z_image_turboиFLUXиспользуют один и тот же VAE (ae.safetensorsот FLUX). АQwen3-4B-Instructподходит для Z‑Image, но не для Mage‑Flow (там нуженQwen3VL-4B).Размер на диске = модель + VAE + LLM. Для Z‑Image: ~3.8 ГБ (модель) + ~330 МБ (VAE) + ~2.5 ГБ (Qwen3-4B) ≈ 6.6 ГБ.
Структура папок
models/ ├── Qwen3-4B-Instruct-2507-Q4_K_M.gguf <- LLM для Z-Image (общая с чатом) ├── Qwen3VL-4B-Instruct-Q4_K_M/ <- LLM для Mage-Flow (vision-модель) │ └── Qwen3VL-4B-Instruct-Q4_K_M.gguf │ └── media/ <- diffusion-модели ├── z_image_turbo-Q4_K.gguf <- diffusion ├── mage_flow_turbo_bf16.safetensors <- diffusion (полная точность) └── vae/ ├── Z-Image/ │ └── ae.safetensors <- VAE для Z-Image (от FLUX) └── mage/ └── mage_flow_vae_bf16.safetensors <- VAE для Mage-Flow
Правила раскладки:
Diffusion‑модели — в
models/media/(плоско, скрипт находит по расширению).VAE — в
models/media/vae/<модель>/, чтобы не путаться. Скрипт находит по явному пути вMODEL_MAP.Text encoder (LLM) — в
models/(общая папка с чат‑моделями). Один и тот же файл используется и для чата, и для генерации картинок — экономия места.Tokenizer (только для некоторых моделей, например MOSS) — можно положить рядом с VAE и указать через
--tokenizerвEXTRA_FLAGS.
Подсказки что и где скачать есть в репозитории сервера: https://github.com/leejet/stable‑diffusion.cpp — клик на название модели и откроется справка со ссылками.
Модели: что оставлено и почему
В процессе тестирования были опробованы пять диффузионных моделей. Осталось две — остальные удалены по объективным причинам.
Оставлены
1. z_image_turbo-Q4_K.gguf (Z‑Image Turbo, ~3.8 ГБ)
Скорость: ~11 минут на картинку 576×1024.
Сильные стороны: отличная композиция, точная передача цвета/одежды, хорошее разделение планов. Рабочая лошадка для быстрых итераций.
Слабые стороны: не интегрирует источники света (голубые камни не светятся), guohua‑стиль «уваливается» в гуашь.
Когда использовать: 3D‑сцены.
2. mage_flow_turbo_bf16.safetensors (Mage‑Flow Turbo, ~8.2 ГБ)
Скорость: ~7 минут на картинку (4 шага, но модель тяжелее).
Сильные стороны: лучший epic‑жанр (дракон, кристаллы, свет — единственная модель, которая справилась), канон в anime. Хорошо держит композицию.
Слабые стороны: guohua — «коллаж», 3D — теряет средний план.
Когда использовать: повседневная генерация, финальные epic‑сцены, anime‑постеры.
Удалены (и почему)
3. qwen-image-2.1-turbo-Q4_K_M.gguf (~6 ГБ)
Тесты: anime — «паспортный анфас» (скучная композиция), 3D — лучший свет, но потерян средний план, epic — дракон «как трафарет», guohua — OOM при лимите 6 ГБ (VAE‑декод ест ~5 ГБ).
Скорость: ~15-20 минут на картинку (в 3 раза медленнее Z‑Image).
Почему удалена: медленно, слабая композиция, OOM в половине случаев. Единственный плюс — свет в 3D‑сценах, но это не оправдывает 15 минут ожидания.
4. flux3-image-klein-Q4_0.gguf (~4.5 ГБ)
Тесты: anime — лучший «постер» (динамика, эмоции), guohua — канонический (направление штрихов, туман, прозрачность), 3D — артефакты (балка слилась со стулом), epic — «плавающие острова» вместо «летающих».
Скорость: ~33 минуты на картинку (в 3 раза медленнее Z‑Image).
Почему удалена: 33 минуты — это не «чуть медленнее», а другой режим работы. Для 10 итераций промпта FLUX съест 5.5 часов, Z‑Image — 50 минут. Качество в guohua/aниме выше, но не настолько, чтобы оправдать ожидание.
5. MOSS-Image-Base (~22 ГБ, не тестировали)
Почему удалена: 20+ шагов на модели 22 ГБ — генерация займёт дольше часа на CPU. Даже не запускали.
Итог: оставлены две модели, которые покрывают 90% основных задач и работают довольно быстро и сравнительно качественно (Mage‑Flow, Z‑Image). Если понадобится guohua — можно вернуть FLUX, но запускать его «на ночь» или под конкретный заказ.
Ключевые опции sd‑server / sd‑cli
‑diffusion‑model, ‑vae, ‑llm — три обязательных флага
--diffusion-model models/media/z_image_turbo-Q4_K.gguf --vae models/media/vae/Z-Image/ae.safetensors --llm models/Qwen3-4B-Instruct-2507-Q4_K_M.gguf
Без любого из них — ошибка загрузки. Проверить, что все три на месте, можно командой:
ls -lh models/media/*.gguf models/media/vae/*/*.safetensors models/Qwen3-4B*.gguf
‑use‑mmap — экономия RAM
--use-mmap
Аналог --load-mode mmap в llama.cpp: модель не грузится в RAM целиком, страницы подгружаются по требованию. Важно: в отличие от LLM, где mmap даёт большой выигрыш (модель 20 ГБ на 10 ГБ RAM), для diffusion‑моделей VAE‑декод всё равно требует много анонимной памяти (~5 ГБ на пике). То есть mmap помогает с загрузкой весов, но не спасает от OOM при высоком разрешении.
Почему VAE ест память. Декодирование латентов в пиксели — это одна большая свёртка, которая создаёт промежуточные тензоры размером с итоговое изображение, умноженное на число каналов. Для 1024×1024 это ~50 МБ на канал × 128 каналов × несколько слоёв = гигабайты. Отсюда и пик ~5 ГБ.
‑offload‑to‑cpu — для гибридных систем
--offload-to-cpu
В sd.cpp это аналог «выгрузить всё на CPU» для систем, где есть GPU, но он слабый. Для чисто CPU‑инференса флаг не нужен (всё и так на CPU), но и не вредит. Скрипт добавляет его автоматически, если находит в --help.
‑steps и ‑cfg‑scale — параметры сэмплинга
--steps 8 --cfg-scale 1.0 # для turbo-моделей --steps 20 --cfg-scale 7.0 # для base-моделей
Ключевое правило: параметры зависят от модели, а не от вашего желания.
Модель |
|
|
Почему |
|---|---|---|---|
Z‑Image Turbo |
8 |
1.0 |
Дистиллирована под 8 шагов, CFG «запечён» в веса |
Mage‑Flow Turbo |
4 |
1.0 |
Дистиллирована под 4 шага |
Z‑Image (base) |
28–50 |
5.0–7.0 |
Полная модель, требует много шагов и CFG |
FLUX.3 Klein |
16 |
1.0 |
Distilled |
Qwen‑Image Turbo |
8 (через |
1.0 |
Специальное расписание сигм |
Подсказки по параметрам можно подсмотреть в примерах, которые приведены в репозитории сервера: https://github.com/leejet/stable‑diffusion.cpp — кликните нужную модель и смотрите параметры в примерах.
Что будет, если перепутать. Если дать turbo‑модели 30 шагов — качество не улучшится, а может ухудшиться (траектория денойзинга рассчитана на крупные шаги). Если дать base‑модели 8 шагов и CFG 1.0 — получите размытый кадр (реальный случай с Z‑Image base: «намек на изображение, жёсткий песок»). Параметры хранятся в MODEL_MAP скрипта, менять их «на лету» в UI — плохая идея.
‑sampling‑method — алгоритм сэмплинга
--sampling-method euler # по умолчанию, стандарт --sampling-method heun # чуть чище, но медленнее --sampling-method euler_a # больше вариативности
Для 95% случаев euler — правильный выбор. Остальные — для экспериментов.
‑sigmas — для Qwen‑Image и подобных
--sigmas qwen-image-2.1-turbo # пресет из 8 значений --sigmas 1.0,0.978,0.954,...,0.0 # вручную
Некоторые модели требуют фиксированного расписания сигм вместо --steps. Количество сигм = количество шагов. Если указать --sigmas и --steps одновременно — движок использует сигмы и проигнорирует шаги. Пресет qwen-image-2.1-turbo — это просто строковый псевдоним, он подставляется в список чисел (см. docs/ sd.cpp).
‑s -1 — случайный seed
-s -1
-1 означает «новый seed на каждый запрос» — каждый раз новая картинка. Если нужна воспроизводимость — задайте конкретное число (-s 42).
Веб‑интерфейс web/index.html
UI для sd-server — это собственная статическая страница (не родной UI sd.cpp), которую мы сделали в Material Dark стиле. Она:
Автоматически определяет имя модели через
GET /sdapi/v1/options(полеsd_model_checkpoint).Даёт поле промпта, выбор размера (группировка по соотношениям: 1:1, 16:9, 9:16, 4:3, 3:2, до 2048 px) и кнопку «Сгенерировать».
Показывает статус («Работаю… (N шагов)» → «Готово» / «Ошибка»).
Выводит картинку с кнопкой «Скачать PNG».
Адаптивна — на телефоне всё в один столбец.
Чего в UI нет (и почему):
Выбора модели —
sd-serverкэширует одну модель, смена требует перезапуска. Имя показывается, но не переключается.Настройки шагов/CFG — они заданы в
MODEL_MAPпод конкретную модель. Если дать пользователю крутить шаги, он неизбежно сломает turbo‑модель.Прогресс‑бара в реальном времени — эндпоинт
/v1/images/generationsсинхронный, сервер отвечает только когда готово. Индикатор «Работаю…» просто висит. Для прогресса нужен асинхронный/sdcpp/v1/img_gen+ polling, но это избыточно для первой итерации.
Код запуска run_sd_media.sh
#!/usr/bin/env bash # Генерация изображений через stable-diffusion.cpp. # По умолчанию запускается веб-сервер (sd-server) с UI. # Для одиночной генерации через CLI — флаг -c/--cli. # # Usage: # ./run_sd_media.sh # сервер с моделью по умолчанию # ./run_sd_media.sh -m z_image_turbo-Q4_K # сервер с явной моделью # ./run_sd_media.sh --port 8081 --host 127.0.0.1 # другой порт/адрес # ./run_sd_media.sh -c "a cat on the moon" # CLI-генерация # ./run_sd_media.sh -c -m z_image_turbo-Q4_K -W 1024 -H 768 "a cat" set -euo pipefail # --- Конфигурация --- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SD_DIR="$SCRIPT_DIR/stable-diffusion.cpp" MEDIA_MODELS_DIR="$SCRIPT_DIR/models/media" TEXT_MODELS_DIR="$SCRIPT_DIR/models" SD_BUILD_BIN="$SD_DIR/build/bin" # Поддерживаемые форматы весов (порядок = приоритет при неоднозначности). # Используется в нормализации -m и в сканировании MEDIA_MODELS_DIR. SUPPORTED_EXTS=(gguf safetensors ckpt pt pth) SERVER_PORT="${SERVER_PORT:-8888}" SERVER_HOST="${SERVER_HOST:-0.0.0.0}" SERVER_HTML="${SERVER_HTML:-$SCRIPT_DIR/web/index.html}" MEM_MAX="${MEM_MAX:-6G}" # Жёсткий лимит памяти для сервера MEM_HIGH="${MEM_HIGH:-5G}" # Мягкий лимит (активное вытеснение) # --- 1. Разбор аргументов --- MODE="server" MODEL_ARG="" PROMPT_ARG="" STEPS_OVERRIDE="" CFG_OVERRIDE="" WIDTH_ARG="" HEIGHT_ARG="" PORT_ARG="" HOST_ARG="" HTML_ARG="" usage() { cat <<EOF Usage: $(basename "$0") [OPTIONS] ["prompt"] Режимы: (по умолчанию) запуск веб-сервера sd-server -c, --cli режим CLI (одна генерация) Модель: -m, --model NAME имя модели (с .gguf, .safetensors, .ckpt, .pt, .pth или без) CLI-режим (-c): --steps N переопределить число шагов --cfg-scale X переопределить CFG scale -W, --width N ширина (требует -H) -H, --height N высота (требует -W) Сервер (по умолчанию): --port N порт (по умолчанию $SERVER_PORT) --host HOST адрес (по умолчанию $SERVER_HOST) --html PATH путь к index.html (по умолчанию $SERVER_HTML) Прочее: -h, --help эта справка EOF } while [ $# -gt 0 ]; do case "$1" in -c|--cli) MODE="cli"; shift ;; -m|--model) MODEL_ARG="$2"; shift 2 ;; --steps) STEPS_OVERRIDE="--steps $2"; shift 2 ;; --cfg-scale) CFG_OVERRIDE="--cfg-scale $2"; shift 2 ;; -W|--width) WIDTH_ARG="$2"; shift 2 ;; -H|--height) HEIGHT_ARG="$2"; shift 2 ;; --port) PORT_ARG="$2"; shift 2 ;; --host) HOST_ARG="$2"; shift 2 ;; --html) HTML_ARG="$2"; shift 2 ;; -h|--help) usage; exit 0 ;; --) shift; [ $# -gt 0 ] && PROMPT_ARG="$*"; break ;; -*) echo "[!] Неизвестный флаг: $1"; exit 1 ;; *) if [ -z "$PROMPT_ARG" ]; then PROMPT_ARG="$1"; else PROMPT_ARG="$PROMPT_ARG $1"; fi; shift ;; esac done [ -n "$PORT_ARG" ] && SERVER_PORT="$PORT_ARG" [ -n "$HOST_ARG" ] && SERVER_HOST="$HOST_ARG" [ -n "$HTML_ARG" ] && SERVER_HTML="$HTML_ARG" # Валидация -W/-H (только для CLI) if [ "$MODE" = "cli" ]; then if { [ -n "$WIDTH_ARG" ] && [ -z "$HEIGHT_ARG" ]; } || { [ -z "$WIDTH_ARG" ] && [ -n "$HEIGHT_ARG" ]; }; then echo "[!] -W и -H нужно указывать вместе."; exit 1 fi if [ -n "$WIDTH_ARG" ]; then [[ "$WIDTH_ARG" =~ ^[1-9][0-9]*$ ]] || { echo "[!] -W: положительное целое"; exit 1; } [[ "$HEIGHT_ARG" =~ ^[1-9][0-9]*$ ]] || { echo "[!] -H: положительное целое"; exit 1; } fi fi # Нормализация имени модели: если расширение не указано — подбираем по факту наличия файла. # Поддерживаемые форматы: .gguf, .safetensors, .ckpt, .pt, .pth (см. docs stable-diffusion.cpp). if [ -n "$MODEL_ARG" ]; then # Есть ли у имени уже известное расширение? HAS_KNOWN_EXT=0 for ext in "${SUPPORTED_EXTS[@]}"; do if [[ "$MODEL_ARG" == *".$ext" ]]; then HAS_KNOWN_EXT=1 break fi done # Если расширения нет — ищем файл по приоритету if [ "$HAS_KNOWN_EXT" -eq 0 ]; then for ext in "${SUPPORTED_EXTS[@]}"; do if [ -f "$MEDIA_MODELS_DIR/$MODEL_ARG.$ext" ]; then MODEL_ARG="$MODEL_ARG.$ext" break fi done # Если ни один файл не найден — оставляем как есть. # Ошибка всплывёт ниже, на шаге 6, со списком доступных моделей. fi fi # --- 2. Сборка --- SD_CLI="$SD_BUILD_BIN/sd-cli" SD_SERVER="$SD_BUILD_BIN/sd-server" if [ ! -x "$SD_CLI" ] || [ ! -x "$SD_SERVER" ]; then echo "[*] Собираю stable-diffusion.cpp..." [ ! -d "$SD_DIR/.git" ] && git clone --recursive https://github.com/leejet/stable-diffusion.cpp.git "$SD_DIR" cmake -S "$SD_DIR" -B "$SD_DIR/build" -DCMAKE_BUILD_TYPE=Release cmake --build "$SD_DIR/build" --config Release -j "$(nproc)" fi [ -x "$SD_CLI" ] && [ -x "$SD_SERVER" ] || { echo "[!] Сборка не удалась"; exit 1; } echo "[*] sd.cpp: $(git -C "$SD_DIR" rev-parse --short HEAD 2>/dev/null || echo 'unknown')" # --- 3. Хелперы --- resolve_path() { local base="$1" path="$2" if [[ "$path" = /* ]]; then echo "$path"; else echo "$base/$path"; fi } # --- 4. Маппинг моделей --- # Ключ — имя .gguf файла (с расширением). # Значение: "VAE(от MEDIA_MODELS_DIR)|LLM(от TEXT_MODELS_DIR)|доп.флаги" declare -A MODEL_MAP # z_image_turbo — быстрая (8 шагов), отличная композиция и планы, точная передача # цвета/одежды; слаба в интеграции источников света и в «воздушных» техниках (гохуа). MODEL_MAP["z_image_turbo-Q4_K.gguf"]="vae/Z-Image/ae.safetensors|Qwen3-4B-Instruct-2507-Q4_K_M.gguf|--steps 8 --cfg-scale 1.0" # mage_flow_turbo — лучший баланс (~8 мин, 2 ГБ resident через mmap): сильна в epic # (дракон, кристаллы, свет), канон в anime; слаба в guohua (коллаж) и 3D (нет среднего плана). MODEL_MAP["mage_flow_turbo_bf16.safetensors"]="vae/mage/mage_flow_vae_bf16.safetensors|Qwen3VL-4B-Instruct-Q4_K_M/Qwen3VL-4B-Instruct-Q4_K_M.gguf|--cfg-scale 1.0 --steps 4" # Заготовки (раскомментировать после скачивания VAE/LLM): # MODEL_MAP["MOSS-Image-SFT-Q4_0.gguf"]="vae/moss/flux2-vae.safetensors|Qwen3.5-9B-Q4_K_M.gguf|--tokenizer $MEDIA_MODELS_DIR/vae/moss/tokenizer.json --cfg-scale 3.0" # MODEL_MAP["Wan2.2-TI2V-5B-Q4_K_M.gguf"]="vae/Wan/wan2.2_vae.safetensors|umt5-xxl-encoder-Q5_K_M.gguf|--steps 20" # sigmas можно вписать вручную, вместо строкого параметра, пример: # --sigmas 1.0,0.978453,0.95418,0.926626,0.89508,0.845148,0.704534,0.414568,0.0 # qwen-image-2.1-turbo — медленная (~15 мин/картинку), но лучший реализм и свет; # слаба в композиции/среднем плане, OOM при лимите 6 ГБ (VAE-декод ест ~5 ГБ). # MODEL_MAP["qwen-image-2.1-turbo-Q4_K_M.gguf"]="vae/qwen-image/qwen_image_2.1_vae_bf16.safetensors|Qwen3VL-8B-Instruct-Q4_K_M.gguf|--sigmas qwen-image-2.1-turbo --steps 8 --cfg-scale 1.0 --sampling-method euler" # flux3-image-klein — самый медленный (~33 мин), но лучшая композиция: аниме-постеры # и канонический guohua; слаб в epic (кристаллы/дракон не интегрированы в сцену). # MODEL_MAP["flux3-image-klein-Q4_0.gguf"]="vae/FLUX/ae.safetensors|Qwen3VL-4B-Instruct-Q4_K_M.gguf|--cfg-scale 1.0 --steps 16 --sampling-method euler" # --- 5. Доступные модели --- # Собираем все файлы поддерживаемых расширений через find. # Это надёжнее wildcard-раскрытия и корректно работает с любыми путями. FIND_EXPR=() for ext in "${SUPPORTED_EXTS[@]}"; do [ ${#FIND_EXPR[@]} -gt 0 ] && FIND_EXPR+=(-o) FIND_EXPR+=(-name "*.$ext") done mapfile -t MODEL_FILES < <( find "$MEDIA_MODELS_DIR" -maxdepth 1 -type f \( "${FIND_EXPR[@]}" \) 2>/dev/null | sort ) MODELS_AVAILABLE=() for f in "${MODEL_FILES[@]}"; do [ -f "$f" ] || continue base=$(basename "$f") [ -n "${MODEL_MAP[$base]:-}" ] || continue IFS='|' read -r VAE_REL LLM_REL _ <<< "${MODEL_MAP[$base]}" VAE_CHECK="$(resolve_path "$MEDIA_MODELS_DIR" "$VAE_REL")" LLM_CHECK="$(resolve_path "$TEXT_MODELS_DIR" "$LLM_REL")" if [ -f "$VAE_CHECK" ] && [ -f "$LLM_CHECK" ]; then MODELS_AVAILABLE+=("$base") else # Отладка: почему модель отброшена (только при явном запросе) if [ "${DEBUG:-0}" = "1" ]; then echo "[debug] '$base' отброшена:" >&2 [ -f "$VAE_CHECK" ] || echo " нет VAE: $VAE_CHECK" >&2 [ -f "$LLM_CHECK" ] || echo " нет LLM: $LLM_CHECK" >&2 fi fi done # --- 6. Выбор модели --- if [ -n "$MODEL_ARG" ]; then [[ " ${MODELS_AVAILABLE[*]} " =~ " $MODEL_ARG " ]] || { echo "[!] Модель '$MODEL_ARG' недоступна"; exit 1; } MODEL_NAME="$MODEL_ARG" elif [ ${#MODELS_AVAILABLE[@]} -eq 1 ]; then MODEL_NAME="${MODELS_AVAILABLE[0]}" else DEFAULT_MODEL="${DEFAULT_MODEL:-z_image_turbo-Q4_K.gguf}" [[ " ${MODELS_AVAILABLE[*]} " =~ " $DEFAULT_MODEL " ]] || DEFAULT_MODEL="${MODELS_AVAILABLE[0]}" echo "[*] Доступные модели:" for i in "${!MODELS_AVAILABLE[@]}"; do printf ' %d) %s\n' "$((i+1))" "${MODELS_AVAILABLE[$i]}"; done while true; do read -r -p "Выбор [Enter = $DEFAULT_MODEL]: " choice [ -z "$choice" ] && MODEL_NAME="$DEFAULT_MODEL" && break [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le ${#MODELS_AVAILABLE[@]} ] && MODEL_NAME="${MODELS_AVAILABLE[$((choice-1))]}" && break echo " Неверно. Введите 1..${#MODELS_AVAILABLE[@]} или Enter." done fi IFS='|' read -r VAE_REL LLM_REL EXTRA_FLAGS <<< "${MODEL_MAP[$MODEL_NAME]}" MODEL_PATH="$MEDIA_MODELS_DIR/$MODEL_NAME" VAE_PATH="$(resolve_path "$MEDIA_MODELS_DIR" "$VAE_REL")" LLM_PATH="$(resolve_path "$TEXT_MODELS_DIR" "$LLM_REL")" echo "[*] Модель: $MODEL_NAME" echo "[*] VAE: $VAE_PATH" echo "[*] LLM: $LLM_PATH" # --- 7. Опциональные флаги --- HELP_OUT="$("$SD_CLI" --help 2>&1 || true)" MMAP_FLAG="" echo "$HELP_OUT" | grep -q -- "--use-mmap" && MMAP_FLAG="--use-mmap" [ -z "$MMAP_FLAG" ] && echo "$HELP_OUT" | grep -q -- "--mmap" && MMAP_FLAG="--mmap" OFFLOAD_FLAG="" echo "$HELP_OUT" | grep -q -- "--offload-to-cpu" && OFFLOAD_FLAG="--offload-to-cpu" DIM_FLAGS="" [ -n "$WIDTH_ARG" ] && DIM_FLAGS="-W $WIDTH_ARG -H $HEIGHT_ARG" # ================================================================== # CLI-режим # ================================================================== if [ "$MODE" = "cli" ]; then # --- 9a. Промпт --- PROMPT="$PROMPT_ARG" if [ -z "$PROMPT" ]; then if [ ! -t 0 ]; then echo "[!] Промпт не передан, а stdin не терминал. Передайте промпт аргументом." >&2 exit 1 fi read -r -p "Введите промпт: " PROMPT fi [ -n "$PROMPT" ] || { echo "[!] Пустой промпт."; exit 1; } # --- 10a. Выходной файл --- OUTPUT_FILE="output_$(date +%Y%m%d_%H%M%S)_$$.png" echo "[*] Генерация в $OUTPUT_FILE ..." echo "[*] Лимит памяти: $MEM_MAX (soft $MEM_HIGH, swap off)" echo "------------------------------------------------------------" # --- 11a. Запуск (foreground, с лимитом памяти через systemd-run --scope) --- # --scope = синхронный режим: терминал блокируется до завершения sd-cli, # cgroup-лимиты при этом действуют (в отличие от --service). # shellcheck disable=SC2086 CLI_CMD=( "$SD_CLI" --diffusion-model "$MODEL_PATH" --vae "$VAE_PATH" --llm "$LLM_PATH" -p "$PROMPT" -o "$OUTPUT_FILE" ) [ -n "$MMAP_FLAG" ] && CLI_CMD+=($MMAP_FLAG) [ -n "$OFFLOAD_FLAG" ] && CLI_CMD+=($OFFLOAD_FLAG) [ -n "$EXTRA_FLAGS" ] && CLI_CMD+=($EXTRA_FLAGS) [ -n "$DIM_FLAGS" ] && CLI_CMD+=($DIM_FLAGS) [ -n "$STEPS_OVERRIDE" ] && CLI_CMD+=($STEPS_OVERRIDE) [ -n "$CFG_OVERRIDE" ] && CLI_CMD+=($CFG_OVERRIDE) if command -v systemd-run &>/dev/null; then systemd-run --user --scope \ -p MemoryMax="$MEM_MAX" \ -p MemoryHigh="$MEM_HIGH" \ -p MemorySwapMax=0 \ -- "${CLI_CMD[@]}" else echo "[!] systemd-run не найден, запускаю без лимита памяти" "${CLI_CMD[@]}" fi echo "------------------------------------------------------------" echo "[*] Готово: $OUTPUT_FILE" exit 0 fi # ================================================================== # Серверный режим (по умолчанию) # ================================================================== [ -f "$SERVER_HTML" ] || { echo "[!] Не найден HTML: $SERVER_HTML"; exit 1; } # Проверка порта if (echo >/dev/tcp/127.0.0.1/"$SERVER_PORT") 2>/dev/null; then echo "[!] Порт $SERVER_PORT занят"; exit 1 fi LOG_FILE="$(mktemp -t sd-server.XXXXXX.log)" SERVER_PID="" TAIL_PID="" cleanup() { echo ""; echo "[*] Завершение..." [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null || true [ -n "$TAIL_PID" ] && kill "$TAIL_PID" 2>/dev/null || true rm -f "$LOG_FILE" echo "[*] Готово." } trap cleanup EXIT INT TERM SERVER_CMD=( "$SD_SERVER" --listen-port "$SERVER_PORT" --listen-ip "$SERVER_HOST" --serve-html-path "$SERVER_HTML" --diffusion-model "$MODEL_PATH" --vae "$VAE_PATH" --llm "$LLM_PATH" -s -1 ) [ -n "$MMAP_FLAG" ] && SERVER_CMD+=($MMAP_FLAG) [ -n "$OFFLOAD_FLAG" ] && SERVER_CMD+=($OFFLOAD_FLAG) [ -n "$EXTRA_FLAGS" ] && SERVER_CMD+=($EXTRA_FLAGS) [ -n "$STEPS_OVERRIDE" ] && SERVER_CMD+=($STEPS_OVERRIDE) [ -n "$CFG_OVERRIDE" ] && SERVER_CMD+=($CFG_OVERRIDE) echo "[*] Запуск sd-server (лимит памяти: $MEM_MAX, soft: $MEM_HIGH)..." # Оборачиваем в systemd-run для лимита памяти if command -v systemd-run &>/dev/null; then systemd-run --user --scope \ -p MemoryMax="$MEM_MAX" \ -p MemoryHigh="$MEM_HIGH" \ -p MemorySwapMax=0 \ -- "${SERVER_CMD[@]}" >"$LOG_FILE" 2>&1 & else echo "[!] systemd-run не найден, запускаю без лимита памяти" "${SERVER_CMD[@]}" >"$LOG_FILE" 2>&1 & fi SERVER_PID=$! tail -n +1 -f "$LOG_FILE" & TAIL_PID=$! echo "[*] Жду готовности (до 180 с)..." READY=0 for i in $(seq 1 180); do kill -0 "$SERVER_PID" 2>/dev/null || { echo "[!] Сервер умер:"; tail -n 40 "$LOG_FILE"; exit 1; } if (echo >/dev/tcp/127.0.0.1/"$SERVER_PORT") 2>/dev/null; then READY=1; break; fi [ $((i % 15)) -eq 0 ] && echo " ... ждём ($i с)" sleep 1 done [ "$READY" -eq 1 ] || { echo "[!] Сервер не поднялся"; exit 1; } echo "" echo "============================================================" echo " sd-server is UP" echo "------------------------------------------------------------" echo " Модель: $MODEL_NAME" echo " Listen: $SERVER_HOST:$SERVER_PORT" echo " Web UI: http://127.0.0.1:$SERVER_PORT/" if [ "$SERVER_HOST" = "0.0.0.0" ]; then LAN_IP="$(hostname -I 2>/dev/null | awk '{print $1}')" [ -n "$LAN_IP" ] && echo " LAN: http://$LAN_IP:$SERVER_PORT/" fi echo " Лимит: $MEM_MAX (soft $MEM_HIGH, swap off)" echo "------------------------------------------------------------" echo " Ctrl+C — остановить." echo "============================================================" echo "" while true; do kill -0 "$SERVER_PID" 2>/dev/null || { echo "[!] Сервер остановлен."; break; } sleep 2 done
Код HTML‑интерфейса web/index.html
<!DOCTYPE html> <html lang="ru"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>sd.cpp — генерация</title> <style> :root { --bg: #121212; --surface: #1e1e1e; --primary: #bb86fc; --primary-dim: #9a67ea; --text: #e1e1e1; --text-dim: #9e9e9e; --error: #cf6679; } * { box-sizing: border-box; margin: 0; padding: 0; } body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; min-height: 100vh; display: flex; justify-content: center; padding: 24px 16px; } .container { width: 100%; max-width: 1000px; } header { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin-bottom: 24px; border-bottom: 1px solid #333; padding-bottom: 16px; } h1 { font-size: 1.4rem; font-weight: 400; } .model-info { font-size: 0.85rem; color: var(--text-dim); background: var(--surface); padding: 6px 12px; border-radius: 16px; word-break: break-all; } .card { background: var(--surface); border-radius: 12px; padding: 20px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.4); } label { display: block; font-size: 0.85rem; color: var(--text-dim); margin-bottom: 6px; } textarea, select, input[type="number"], button { font-family: inherit; font-size: 1rem; border: none; border-radius: 8px; } textarea { width: 100%; min-height: 100px; padding: 12px; background: var(--bg); color: var(--text); resize: vertical; border: 1px solid #333; transition: border-color 0.2s; } textarea:focus { outline: none; border-color: var(--primary); } .row { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; } .field { flex: 1 1 180px; min-width: 0; } select, input[type="number"] { width: 100%; padding: 10px 12px; background: var(--bg); color: var(--text); border: 1px solid #333; } select:focus, input[type="number"]:focus { outline: none; border-color: var(--primary); } button { padding: 10px 24px; background: var(--primary); color: #000; font-weight: 600; cursor: pointer; transition: background 0.2s, opacity 0.2s; white-space: nowrap; } button:hover:not(:disabled) { background: var(--primary-dim); } button:disabled { opacity: 0.5; cursor: not-allowed; } .status { margin-top: 16px; min-height: 24px; font-size: 0.9rem; color: var(--text-dim); display: flex; align-items: center; gap: 8px; } .status.working { color: var(--primary); } .status.error { color: var(--error); } .spinner { width: 16px; height: 16px; border: 2px solid #444; border-top-color: var(--primary); border-radius: 50%; animation: spin 0.8s linear infinite; display: none; } .status.working .spinner { display: inline-block; } @keyframes spin { to { transform: rotate(360deg); } } .result { margin-top: 16px; text-align: center; } .result img { max-width: 100%; height: auto; border-radius: 8px; box-shadow: 0 4px 16px rgba(0,0,0,0.6); } .result a { display: inline-block; margin-top: 12px; color: var(--primary); text-decoration: none; font-size: 0.9rem; } .result a:hover { text-decoration: underline; } .hint { font-size: 0.8rem; color: var(--text-dim); margin-top: 8px; } /* Адаптивность */ @media (max-width: 600px) { body { padding: 16px 8px; } .card { padding: 16px; } h1 { font-size: 1.2rem; } .row { flex-direction: column; align-items: stretch; } .field { flex: 1 1 auto; } button { width: 100%; padding: 12px; } .model-info { font-size: 0.75rem; padding: 4px 8px; } textarea { min-height: 80px; font-size: 0.95rem; } select, input[type="number"] { font-size: 0.95rem; } } </style> </head> <body> <div class="container"> <header> <h1>sd.cpp — генерация</h1> <span class="model-info" id="model-info">загрузка…</span> </header> <div class="card"> <div class="row"> <div class="field" style="flex: 3 1 300px;"> <label for="prompt">Промпт</label> <textarea id="prompt" placeholder="a cozy cabin in snowy forest, cinematic lighting">a cozy cabin in snowy forest, cinematic lighting</textarea> </div> <div class="field"> <label for="size">Размер</label> <select id="size"> <optgroup label="1:1 — квадрат"> <option value="512x512">512 × 512</option> <option value="768x768">768 × 768</option> <option value="1024x1024">1024 × 1024</option> <option value="1536x1536">1536 × 1536</option> <option value="2048x2048">2048 × 2048</option> </optgroup> <optgroup label="16:9 — широкий"> <option value="1024x576">1024 × 576</option> <option value="1280x720">1280 × 720</option> <option value="1536x864">1536 × 864</option> <option value="2048x1152">2048 × 1152</option> </optgroup> <optgroup label="9:16 — портрет"> <option value="576x1024" selected>576 × 1024</option> <option value="720x1280">720 × 1280</option> <option value="864x1536">864 × 1536</option> <option value="1152x2048">1152 × 2048</option> </optgroup> <optgroup label="4:3 / 3:4"> <option value="1024x768">1024 × 768</option> <option value="768x1024">768 × 1024</option> <option value="1152x864">1152 × 864</option> <option value="864x1152">864 × 1152</option> </optgroup> <optgroup label="3:2 / 2:3"> <option value="1216x832">1216 × 832</option> <option value="832x1216">832 × 1216</option> </optgroup> </select> </div> </div> <div class="row" style="margin-top: 16px;"> <button id="genBtn" onclick="generate()">Сгенерировать</button> </div> <div class="status" id="status"> <span class="spinner"></span> <span id="status-text">Готов к работе</span> </div> <div class="result" id="result"></div> <div class="hint" id="hint"></div> </div> </div> <script> // --- Автоподгрузка имени модели --- async function fetchModelName() { try { const res = await fetch('/sdapi/v1/options'); if (!res.ok) throw new Error(); const data = await res.json(); const name = data.sd_model_checkpoint || 'неизвестно'; document.getElementById('model-info').textContent = 'Модель: ' + name; } catch { document.getElementById('model-info').textContent = 'Модель: не удалось определить'; } } fetchModelName(); const btn = document.getElementById('genBtn'); const status = document.getElementById('status'); const statusText = document.getElementById('status-text'); const resultDiv = document.getElementById('result'); function setStatus(text, state) { status.className = 'status' + (state ? ' ' + state : ''); statusText.textContent = text; } async function generate() { const prompt = document.getElementById('prompt').value.trim(); const size = document.getElementById('size').value; if (!prompt) { setStatus('Введите промпт', 'error'); return; } const fullPrompt = `${prompt}`; btn.disabled = true; resultDiv.innerHTML = ''; setStatus(`Работаю…`, 'working'); const startTime = Date.now(); try { const response = await fetch('/v1/images/generations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: fullPrompt, n: 1, size, response_format: 'b64_json' }) }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const json = await response.json(); if (json.data?.[0]?.b64_json) { const imgSrc = `data:image/png;base64,${json.data[0].b64_json}`; const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); resultDiv.innerHTML = ` <img src="${imgSrc}" alt="Результат"> <br><a href="${imgSrc}" download="generated.png">Скачать PNG</a> <div class="hint">Готово за ${elapsed} с</div>`; setStatus('Готово'); } else { throw new Error('Пустой ответ'); } } catch (err) { setStatus('Ошибка: ' + err.message, 'error'); } finally { btn.disabled = false; } } document.getElementById('prompt').addEventListener('keydown', e => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && !btn.disabled) { e.preventDefault(); generate(); } }); </script> </body> </html>
Проверка после запуска
1. Сервер поднялся:
./run_sd_media.sh -m z_image_turbo-Q4_K
Плашка:
============================================================ sd-server is UP ------------------------------------------------------------ Модель: z_image_turbo-Q4_K.gguf Listen: 0.0.0.0:8888 Web UI: http://127.0.0.1:8888/ LAN: http://<IP>:8888/ Лимит: 6G (soft 5G, swap off)
2. UI открывается: http://127.0.0.1:8888/ — поле промпта, размер, кнопка.
3. Генерация работает: введите «a cozy cabin in snowy forest», размер 576×1024, нажмите. Через 3–8 минут появится картинка.
4. Модель видна в UI: в шапке — Модель: z_image_turbo-Q4_K.gguf (или имя из sd_model_checkpoint).
5. Память:
ps -o pid,rss,%mem -C sd-server
RSS может показывать 6–8 ГБ (виртуальное адресное пространство через mmap), но реально занято ~2–3 ГБ. Проверить через cgroup:
cat /sys/fs/cgroup/user.slice/user-$(id -u).slice/user@$(id -u).service/*/memory.current
Про OOM. При MEM_MAX=6G и высоком разрешении (1024×1024+) VAE‑декод может упереться в лимит. Решения:
Уменьшить размер (
-W 768 -H 768или-W 512 -H 512).Поднять лимит (
MEM_MAX=8G ./run_sd_media.sh).Использовать
--vae-tiling(если поддерживается в вашей сборке) — декодирует по тайлам, снижая пик памяти.
SaemonZixel
Интересная статья, познавательная. Поделюсь своим опытом.
Z-Image-Turbo генерирует хуже Z-Image на 10% по моим субъективным наблюдениям (может лицо кривоватое сделать). Z-Image в принципе не очень как модель, по моим наблюдениям.
Дистиллированные модели (distilled, turbo, lightning и т.д.) могут и за меньшее количество шагов генерировать изображение приемлемого качества. Например Flux.2 Klein 4B за 2 шага уже выдаёт неплохой результат.
Euler семплер даёт “мягкий” шум. DPM++ 2M даёт более резкий и контрастный шум. Соответственно изображение получается либо “мягкое”, либо контрастное и чёткое.
Оно, вроде, по умолчанию всегда работает в режиме mmap. И плохо, что sd.cpp не выгружает модели (llm, denoiser), когда они не нужны. При Z‑Image-Turbo может прийти OOM killer неожиданно в конце(
У меня она появится через 3-8 часов))
Почему-то на практике sd.cpp медленнее чем PyTorch. Где-то в 2-3 раза. Но PyTorch есть много RAM т.к. для процессора всё грузит в f32.
Есть ещё iris.c. Он умеет работать с Z-Image-Turbo и Klein 4B/9B. И ощутимо быстрее чем sd.cpp, если скомпилировать с OpenBLAS.
sd.cpp тоже можно собрать с OpenBLAS, но оно валится с ошибкой про “GET_ROWS”.
PS. Длинные простыни с кодом, конечно, страшат. Может, их свернуть как-то?