ESPNNBA breakout tiers: 30 players ready to ascendESPN DeportesManning no es candidato para la oficina de ColtsPunchEkiti community asks Ooni to intervene in monarch selectionThe Jerusalem PostHouthi attacks ground nearly 90% of Riyadh flights, forcing cancellations, paralyzing nationBollywood HungamaCBFC faces reported data security scare as confidential documents linked to over 500 films are allegedly exposedDaily MaverickCYBER SAFETY: SA Human Rights Commission urges stronger laws to protect children onlineInquirerRidon to VP Duterte: Explain over P100-M in assets not in SALNZDF heuteAktuelle Pressemitteilungen des ZDFInquirer EntertainmentTuldok, October 26, 2026ESPN CricinfoBumrah and Pant likely to play for India A in third unofficial Test against New Zealand ANotJustOkAbike Lyrics by Dwin, The Stoic, Rhaffy & DOTTi The DietyRMF24Złe wieści dla kierowców. Jest decyzja w sprawie cen paliw
The Daily Newsstand · Free, Always
Monday, October 26, 2026

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

Translate

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.

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