Руководство по сборке локального AI-стека. Часть 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, до 2048px) и кнопку «Сгенерировать».
Показывает статус («Работаю… (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(если поддерживается в вашей сборке) — декодирует по тайлам, снижая пик памяти.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.