Руководство по сборке локального AI-стека. Часть 4. Запуск сервера генерации изображений

—

от автора

Руководство по сборке локального 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: для генерации изображений почти никогда не достаточно одного файла. Модель состоит из трёх компонентов, и все три нужно явно указать при запуске.

Три компонента диффузионной модели

Компонент

Что делает

Флаг sd.cpp

Размер (типично)

Diffusion model (denoiser)

Основная модель: «проявляет» изображение из шума

--diffusion-model

2–12 ГБ

VAE

Декодирует латентное представление в реальные пиксели

--vae

150–350 МБ

Text encoder (LLM)

Превращает промпт в вектор, понятный модели

--llm

1.5–7 ГБ

Почему так. Диффузионная модель работает не с пикселями, а с латентами — сжатым представлением изображения (например, 64×64×16 для картинки 512×512). VAE переводит латенты в пиксели (и обратно, если нужен img2img). Текстовый энкодер — это отдельная LLM (Qwen, T5, UMT5, Qwen3-VL), которая «понимает» промпт и передаёт его модели.

Что это значит на практике:

  • Нельзя запустить только .gguf-модель — без VAE и LLM sd.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

Правила раскладки:

  1. Diffusion-модели — в models/media/ (плоско, скрипт находит по расширению).

  2. VAE — в models/media/vae/<модель>/, чтобы не путаться. Скрипт находит по явному пути в MODEL_MAP.

  3. Text encoder (LLM) — в models/ (общая папка с чат-моделями). Один и тот же файл используется и для чата, и для генерации картинок — экономия места.

  4. 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-моделей

Ключевое правило: параметры зависят от модели, а не от вашего желания.

Модель

steps

cfg-scale

Почему

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 (через --sigmas)

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 <<EOFUsage: $(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 ;;    esacdone[ -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; }    fifi# Нормализация имени модели: если расширение не указано — подбираем по факту наличия файла.# Поддерживаемые форматы: .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, со списком доступных моделей.    fifi# --- 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")donemapfile -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    fidone# --- 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."    donefiIFS='|' 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 0fi# ==================================================================# Серверный режим (по умолчанию)# ==================================================================[ -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 1fiLOG_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 TERMSERVER_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 &fiSERVER_PID=$!tail -n +1 -f "$LOG_FILE" &TAIL_PID=$!echo "[*] Жду готовности (до 180 с)..."READY=0for 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 1done[ "$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/"fiecho "  Лимит:   $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 2done

Код 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 (если поддерживается в вашей сборке) — декодирует по тайлам, снижая пик памяти.

ссылка на оригинал статьи https://habr.com/ru/articles/1099504/