diff --git a/README.md b/README.md index 4b69955..91bd63b 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ 1. **Check compatibility:** Prebuilt Linux backends are built on Ubuntu 24.04 and require **glibc 2.38+**. Run `ldd --version` to verify. 2. **Launch:** Open a terminal in the project folder and run **`./linux.sh`** (downloads portable Node.js and pre-compiled GPU backend binaries on first run). - For maximum AMD GPU performance, use **`./linux.sh --max-perf`** on first setup (adds the ROCm backend). + - For Intel Core Ultra NPU support, install the Intel Linux NPU driver, then run **`./linux.sh --setup-openvino`**. 3. **Add Models:** Drop `.safetensors`, `.gguf`, or `.ckpt` weights into `app/models/` (or download them via the **Model Manager** tab in the UI). 4. **Generate:** Open `http://localhost:1420` in your browser, select your model, and write a prompt. @@ -100,6 +101,7 @@ local-ai-image-generator/ | **NVIDIA** | Vulkan | CPU | No reliable prebuilt Linux CUDA binary is currently available. NVIDIA GPUs use Vulkan. | | **AMD Radeon** | ROCm | Vulkan | ROCm provides best AMD performance when host ROCm drivers are available. | | **Intel Arc / integrated** | Vulkan | CPU | Cross-vendor Vulkan support. | +| **Intel Core Ultra NPU** | OpenVINO NPU | CPU | Requires the Intel Linux NPU driver, kernel 6.6+, Python 3, and `./linux.sh --setup-openvino`. | | **Integrated / None** | CPU | — | Runs on logical CPU threads (slow). | ### macOS @@ -110,13 +112,16 @@ local-ai-image-generator/ | **Intel Mac** | Source build required | CPU | Official prebuilt macOS backend is Apple Silicon only. | **System requirements:** +- **64-bit Windows 10 or Windows 11** is required for the portable Node.js 22 runtime used by the Windows launcher. - **glibc 2.38 or newer** is required for the prebuilt Linux backends (Ubuntu 24.04, Fedora 40+, etc.). - The setup script will warn you if your glibc is older. You can still run setup, but the prebuilt backends will not start. +- **Linux OpenVINO NPU:** Intel Core Ultra, x86_64 Linux, kernel 6.6+, a working `/dev/accel/accel0` device, Python 3 with `venv`, and the Intel Linux NPU driver are required. - **Apple Silicon (M1 or newer)** is required for the prebuilt macOS Metal backend. **Linux setup modes:** - **Default (`./linux.sh`)**: Downloads CPU + Vulkan backends (~120–150 MB). - **Maximum Performance (`./linux.sh --max-perf`)**: Also downloads the ROCm backend. Total download ~1.3 GB. +- **Intel NPU (`./linux.sh --setup-openvino`)**: Creates a local OpenVINO Python environment and verifies that the Intel NPU driver exposes an `NPU` device. --- diff --git a/app/frontend/src/App.jsx b/app/frontend/src/App.jsx index 817bfb1..5063f1f 100644 --- a/app/frontend/src/App.jsx +++ b/app/frontend/src/App.jsx @@ -97,6 +97,8 @@ function App() { width: 1024, height: 1024, steps: 4, // Recommended default steps for Flux.1 Schnell/Lightning + npuSteps: 4, + standardSteps: 20, cfgScale: 1.0, // Recommended default CFG for Flux.1 Schnell sampler: "euler_a", seed: -1, @@ -277,10 +279,22 @@ function App() { useEffect(() => { if (activeModel) { const name = activeModel.toLowerCase(); - if (name.includes("flux") || name.includes("schnell")) { + if (name.includes("lcm-dreamshaper-v7-fp16")) { setConstraints((prev) => ({ ...prev, steps: 4, + npuSteps: 4, + cfgScale: 1.0, + width: 512, + height: 512, + backendType: "openvino-npu", + useGpu: true, + })); + } else if (name.includes("flux") || name.includes("schnell")) { + setConstraints((prev) => ({ + ...prev, + steps: 4, + standardSteps: 4, cfgScale: 1.0, width: 1024, height: 1024, @@ -289,6 +303,7 @@ function App() { setConstraints((prev) => ({ ...prev, steps: 4, + standardSteps: 4, cfgScale: 1.5, width: 1024, height: 1024, @@ -297,6 +312,7 @@ function App() { setConstraints((prev) => ({ ...prev, steps: 25, + standardSteps: 25, cfgScale: 7.0, width: 512, height: 512, @@ -305,6 +321,7 @@ function App() { setConstraints((prev) => ({ ...prev, steps: 20, + standardSteps: 20, cfgScale: 4.5, width: 1024, height: 1024, @@ -314,6 +331,7 @@ function App() { setConstraints((prev) => ({ ...prev, steps: 20, + standardSteps: 20, cfgScale: 7.0, width: 512, height: 512, @@ -353,6 +371,7 @@ function App() { serverRunning={serverRunning} setServerRunning={setServerRunning} constraints={constraints} + backendOptions={backendOptions} showAlert={showAlert} showConfirm={showConfirm} /> @@ -365,6 +384,11 @@ function App() { activeModel={activeModel} specs={specs} backendOptions={backendOptions} + serverRunning={serverRunning} + setServerRunning={setServerRunning} + setActiveModel={setActiveModel} + showAlert={showAlert} + showConfirm={showConfirm} /> ); default: diff --git a/app/frontend/src/components/Generator.jsx b/app/frontend/src/components/Generator.jsx index e9e8e68..8fc9048 100644 --- a/app/frontend/src/components/Generator.jsx +++ b/app/frontend/src/components/Generator.jsx @@ -98,6 +98,8 @@ function Generator({ settings.sampler !== constraints.sampler || parseInt(settings.threads) !== parseInt(constraints.threads) || Boolean(settings.useGpu) !== (constraints.useGpu !== false) || + parseInt(settings.width || 512) !== parseInt(constraints.width || 512) || + parseInt(settings.height || 512) !== parseInt(constraints.height || 512) || (settings.backendType || (settings.useGpu === false ? "cpu" : "auto")) !== (constraints.backendType || (constraints.useGpu === false ? "cpu" : "auto"))) { needsRestart = true; } @@ -115,7 +117,8 @@ function Generator({ let isReady = false; let crashError = null; - for (let i = 0; i < 240; i++) { // Poll for up to 120 seconds (240 * 500ms) + const maxStartupPolls = constraints.backendType === "openvino-npu" ? 1200 : 240; + for (let i = 0; i < maxStartupPolls; i++) { const status = await getBackendStatus(); if (status.loading) { setRestartLoadProgress({ diff --git a/app/frontend/src/components/ImageConstraints.jsx b/app/frontend/src/components/ImageConstraints.jsx index 8633db3..5f23b0d 100644 --- a/app/frontend/src/components/ImageConstraints.jsx +++ b/app/frontend/src/components/ImageConstraints.jsx @@ -1,5 +1,6 @@ -import React, { memo } from "react"; +import React, { memo, useEffect } from "react"; import { Crop, Sliders, Cpu, Info } from "lucide-react"; +import { stopServer } from "../services/api"; const ASPECT_RATIOS = [ { id: "1:1", label: "1:1 Square", width: 512, height: 512, sdxl_width: 1024, sdxl_height: 1024, desc: "Social posts & avatars" }, @@ -17,26 +18,55 @@ const isSD15OrCustomModel = (modelName) => { return true; }; -function ImageConstraints({ constraints, setConstraints, activeModel, specs, backendOptions }) { +function ImageConstraints({ + constraints, + setConstraints, + activeModel, + specs, + backendOptions, + serverRunning, + setServerRunning, + setActiveModel, + showAlert = async ({ message }) => window.alert(message), + showConfirm = async ({ message }) => window.confirm(message), +}) { const isSD15OrCustom = activeModel ? isSD15OrCustomModel(activeModel) : false; + const isOpenVinoNpu = constraints.backendType === "openvino-npu"; + const forceStandardMode = isSD15OrCustom && !isOpenVinoNpu; const availableBackends = backendOptions?.options?.length ? backendOptions.options : [{ id: "cpu", label: "CPU", available: true }]; + useEffect(() => { + if (isOpenVinoNpu && constraints.steps > 8) { + setConstraints((prev) => ({ ...prev, steps: 8, npuSteps: 8 })); + } + }, [constraints.steps, isOpenVinoNpu, setConstraints]); + const updateConstraint = (key, value) => { setConstraints((prev) => ({ ...prev, - [key]: value + [key]: value, + ...(key === "steps" + ? isOpenVinoNpu + ? { npuSteps: value } + : { standardSteps: value } + : {}), })); }; const handleAspectRatioChange = (ratio, sizeType) => { + if (isOpenVinoNpu && ratio !== "1:1") return; const isSDXL = sizeType === "sdxl" && !isSD15OrCustom; const selected = ASPECT_RATIOS.find((r) => r.id === ratio); if (selected) { let w = isSDXL ? selected.sdxl_width : selected.width; let h = isSDXL ? selected.sdxl_height : selected.height; - if (isSD15OrCustom) { + if (isOpenVinoNpu) { + const size = constraints.width >= 1024 ? 1024 : 512; + w = size; + h = size; + } else if (isSD15OrCustom) { if (w > h) { h = Math.round((h * 512) / w); w = 512; @@ -52,11 +82,55 @@ function ImageConstraints({ constraints, setConstraints, activeModel, specs, bac } }; - const handleBackendChange = (backendType) => { + const handleBackendChange = async (backendType) => { + const currentBackend = constraints.backendType || "cpu"; + if (backendType === currentBackend) return; + + const isGpuBackend = (type) => !["cpu", "openvino-npu"].includes(type); + const switchesAccelerator = + (currentBackend === "openvino-npu" && isGpuBackend(backendType)) || + (isGpuBackend(currentBackend) && backendType === "openvino-npu"); + + if (serverRunning && switchesAccelerator) { + const leavingNpu = currentBackend === "openvino-npu"; + const confirmed = await showConfirm({ + title: leavingNpu ? "Unload NPU Model?" : "Unload GPU Model?", + message: leavingNpu + ? "The OpenVINO NPU model must be unloaded before switching to the GPU backend." + : "The GPU model must be unloaded before switching to the OpenVINO NPU backend.", + confirmLabel: "Unload", + cancelLabel: "Cancel", + danger: true, + }); + if (!confirmed) return; + + try { + await stopServer(); + setServerRunning(false); + setActiveModel(null); + } catch (err) { + await showAlert({ + title: "Unload Failed", + message: err.message || String(err), + danger: true, + }); + return; + } + } + setConstraints((prev) => ({ ...prev, backendType, useGpu: backendType !== "cpu", + steps: backendType === "openvino-npu" + ? Math.max(1, Math.min(8, prev.npuSteps || 4)) + : Math.max(1, Math.min(60, prev.standardSteps || 20)), + ...(backendType === "openvino-npu" + ? { + width: prev.width >= 1024 ? 1024 : 512, + height: prev.width >= 1024 ? 1024 : 512, + } + : {}), })); }; @@ -79,7 +153,25 @@ function ImageConstraints({ constraints, setConstraints, activeModel, specs, bac 1. Picture Size & Shape - {isSD15OrCustom && ( + {isOpenVinoNpu ? ( +
+ +
+ OpenVINO NPU Resolution: The NPU generates at its stable 512x512 resolution. HD mode produces a 1024x1024 output using high-quality Lanczos upscaling without recompiling the model. +
+
+ ) : isSD15OrCustom && (
- {isSD15OrCustom ? ( + {forceStandardMode ? (
- High Quality Mode (1024px) + {isOpenVinoNpu ? "HD Upscale (1024px)" : "High Quality Mode (1024px)"}
) : (
- High Quality Mode (1024px) + {isOpenVinoNpu ? "HD Upscale (1024px)" : "High Quality Mode (1024px)"}
)}
{ updateConstraint("width", 512); updateConstraint("height", 512); @@ -139,7 +231,9 @@ function ImageConstraints({ constraints, setConstraints, activeModel, specs, bac
- {isSD15OrCustom + {isOpenVinoNpu + ? "*HD mode generates at 512x512 on the NPU, then upscales to 1024x1024. It improves output size, but does not add native diffusion detail." + : isSD15OrCustom ? "*Standard Speed Mode (512px) is forced for SD 1.5 / custom models to ensure stable local generations." : "*Use High Quality (1024px) for Flux or SDXL models. Use Standard (512px) for SD 1.5."} @@ -151,10 +245,12 @@ function ImageConstraints({ constraints, setConstraints, activeModel, specs, bac
{ASPECT_RATIOS.map((ratio) => { const isActive = Math.abs((constraints.width / constraints.height) - (ratio.width / ratio.height)) < 0.1; + const isUnsupportedOpenVinoShape = isOpenVinoNpu && ratio.id !== "1:1"; return (
diff --git a/app/frontend/src/components/ModelManager.jsx b/app/frontend/src/components/ModelManager.jsx index fa20bc8..bb78cc1 100644 --- a/app/frontend/src/components/ModelManager.jsx +++ b/app/frontend/src/components/ModelManager.jsx @@ -1,6 +1,6 @@ import React, { memo, useState, useEffect } from "react"; import { FolderOpen, DownloadCloud, RefreshCw, Database, Trash2, Square, HardDrive, Library, AlertTriangle } from "lucide-react"; -import { listLocalModels, startServer, stopServer, importModelFile, deleteModel, downloadModel, cancelModelDownload, getDownloadProgress, getBackendStatus, pingServer, formatBytes, normalizeModel } from "../services/api"; +import { listLocalModels, startServer, stopServer, importModelFile, deleteModel, downloadModel, downloadOpenVinoModel, cancelModelDownload, getDownloadProgress, getBackendStatus, pingServer, formatBytes, normalizeModel } from "../services/api"; const MODEL_LIBRARY = [ { @@ -60,7 +60,26 @@ const MODEL_LIBRARY = [ }, ]; -function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRunning, constraints, showAlert = async ({ message }) => window.alert(message), showConfirm = async ({ message }) => window.confirm(message) }) { +const OPENVINO_MODEL_LIBRARY = [ + { + group: "Intel NPU - OpenVINO Test", + items: [ + { + id: "lcm-dreamshaper-v7-fp16", + name: "LCM DreamShaper v7 FP16", + filename: "lcm-dreamshaper-v7-fp16", + format: "OpenVINO", + backendType: "openvino-npu", + approxSize: "2.0 GB", + resolution: "512x512", + notes: "NPU test model for Windows and Linux. Runs the text encoder on CPU, UNet on Intel NPU, and VAE decoder on Intel GPU or CPU fallback.", + url: "https://huggingface.co/OpenVINO/LCM_Dreamshaper_v7-fp16-ov", + }, + ], + }, +]; + +function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRunning, constraints, backendOptions, showAlert = async ({ message }) => window.alert(message), showConfirm = async ({ message }) => window.confirm(message) }) { const [localModels, setLocalModels] = useState([]); const [downloadingModelId, setDownloadingModelId] = useState(null); const [downloadProgress, setDownloadProgress] = useState(0); @@ -78,6 +97,9 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun const modelNames = localModels.map((model) => normalizeModel(model).filename); const isBusy = loadingModelId !== null || isUnloading; + const openvinoSupported = Boolean(backendOptions?.openvinoNpu?.supported); + const visibleModelLibrary = openvinoSupported ? [...MODEL_LIBRARY, ...OPENVINO_MODEL_LIBRARY] : MODEL_LIBRARY; + const getLocalModelInfo = (modelId) => localModels.map(normalizeModel).find((model) => model.filename === modelId); useEffect(() => { fetchModels(); @@ -217,7 +239,21 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun setDownloadUrl(""); }; - const handleLibraryDownload = (model) => { + const handleLibraryDownload = async (model) => { + if (model.backendType === "openvino-npu") { + if (downloadingModelId) return; + try { + const res = await downloadOpenVinoModel(model.id); + if (res.ok) { + startProgressPolling(model.filename); + } else { + showAlert({ title: "Download Failed", message: res.error || "Unknown error", danger: true }); + } + } catch (err) { + showAlert({ title: "Download Error", message: err.message, danger: true }); + } + return; + } downloadByUrl(model.url, model.filename); }; @@ -246,7 +282,11 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun if (!isTauriDesktop && !isLocalServerMode) { const isAlreadyRunning = await pingServer(); - const cmd = `.\\backend\\win\\vulkan\\sd-vulkan.exe --listen-port ${backendPort} --model .\\models\\${modelId} --threads 8`; + const modelInfo = getLocalModelInfo(modelId); + const isOpenVinoModel = modelInfo?.backendType === "openvino-npu"; + const cmd = isOpenVinoModel + ? `Run the platform launcher and load "${modelId}" from Model Manager on a configured Intel NPU machine.` + : `.\\backend\\win\\vulkan\\sd-vulkan.exe --listen-port ${backendPort} --model .\\models\\${modelId} --threads 8`; if (isAlreadyRunning) { if (await showConfirm({ title: "Web Browser Mode", message: `To load "${modelId}" on the active GPU server, restart your terminal backend with:\n\n${cmd}\n\nUpdate the UI status anyway?`, confirmLabel: "Update UI" })) { @@ -275,7 +315,19 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun device: "", }); try { - const response = await startServer(modelId, constraints); + const modelInfo = getLocalModelInfo(modelId); + const loadConstraints = modelInfo?.backendType === "openvino-npu" + ? { + ...constraints, + backendType: "openvino-npu", + useGpu: true, + width: constraints.width >= 1024 ? 1024 : 512, + height: constraints.height >= 1024 ? 1024 : 512, + steps: Math.max(1, Math.min(8, constraints.steps || 4)), + cfgScale: constraints.cfgScale || 1, + } + : constraints; + const response = await startServer(modelId, loadConstraints); console.log(response); let isReady = false; @@ -601,7 +653,7 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
{filename} - Local Weights File • {model.size || formatBytes(model.sizeBytes)} + {model.backendType === "openvino-npu" ? "OpenVINO NPU Model" : model.format || "Local Weights File"} • {model.size || formatBytes(model.sizeBytes)}
@@ -654,7 +706,7 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
- {MODEL_LIBRARY.map((section) => ( + {visibleModelLibrary.map((section) => (

{section.group}

diff --git a/app/frontend/src/services/api.js b/app/frontend/src/services/api.js index 035982b..88ea70e 100644 --- a/app/frontend/src/services/api.js +++ b/app/frontend/src/services/api.js @@ -48,8 +48,12 @@ export function normalizeModel(model) { } return { filename: model?.filename || model?.name || "", + name: model?.name || model?.filename || "", sizeBytes: Number(model?.sizeBytes || 0), size: model?.size || (model?.sizeBytes ? formatBytes(model.sizeBytes) : "Unknown"), + format: model?.format || "Local Weights File", + backendType: model?.backendType || "", + resolution: model?.resolution || "", }; } @@ -254,6 +258,8 @@ export async function startServer(modelPath, constraints) { threads: constraints.threads || 8, use_gpu: constraints.useGpu !== false, backend_type: constraints.backendType || (constraints.useGpu === false ? "cpu" : "auto"), + width: constraints.width || 512, + height: constraints.height || 512, vae_tiling: constraints.vaeTiling !== false, vae_on_cpu: constraints.vaeOnCpu === true, }), @@ -330,14 +336,34 @@ export async function deleteGeneratedOutputs(outputs) { // List model files from the models folder (via management API in web mode) export async function listModelsFromDisk() { try { - const r = await fetch("/api/models"); - const data = await r.json(); - return (data.models || []).map(normalizeModel); + const [modelRes, openvino] = await Promise.all([ + fetch("/api/models"), + listOpenVinoModels().catch(() => ({ supported: false, models: [] })), + ]); + const data = await modelRes.json(); + const normalModels = (data.models || []).map(normalizeModel); + const openvinoModels = openvino.supported + ? (openvino.models || []).filter((model) => model.installed).map((model) => normalizeModel({ + filename: model.id, + name: model.name, + sizeBytes: model.sizeBytes, + size: model.size, + format: "OpenVINO", + backendType: "openvino-npu", + resolution: model.resolution, + })) + : []; + return [...normalModels, ...openvinoModels]; } catch (_) { return []; } } +export async function listOpenVinoModels() { + const res = await fetch("/api/openvino-models"); + return await readJsonResponse(res, "The local server returned invalid OpenVINO model data."); +} + // Generate image (T2I / I2I) // Handles API calls to sd-server. If the server is unreachable or returns an error, // we surface that error to the UI instead of silently returning a fake placeholder. @@ -345,8 +371,6 @@ export async function generateImage(prompt, negativePrompt, constraints, activeM console.log("Initiating image generation:", { prompt, negativePrompt, constraints, activeModelName }); const startTime = Date.now(); - const baseUrl = await getBackendBaseUrl(); - // Prepare payload based on standard stable-diffusion.cpp REST endpoint schemas const payload = { prompt: prompt, @@ -361,6 +385,45 @@ export async function generateImage(prompt, negativePrompt, constraints, activeM denoising_strength: constraints.denoisingStrength || 0.7, }; + if (constraints.backendType === "openvino-npu") { + if (inputImageBase64) { + throw new Error("OpenVINO NPU test mode currently supports text-to-image only."); + } + const response = await fetch("/api/openvino-generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + signal, + body: JSON.stringify({ + prompt: payload.prompt, + negative_prompt: payload.negative_prompt, + steps: payload.steps, + cfg_scale: payload.cfg_scale, + seed: payload.seed, + width: payload.width, + height: payload.height, + }), + }); + const data = await readJsonResponse(response, "The local server returned an invalid OpenVINO generation response."); + const imgB64 = data?.data?.[0]?.b64_json; + if (!imgB64) throw new Error("OpenVINO generation did not return an image."); + const normalizedB64 = String(imgB64).replace(/^data:[^;]+;base64,/, ""); + const header = atob(normalizedB64.slice(0, 24)); + const isPng = header.charCodeAt(0) === 0x89 && header.slice(1, 4) === "PNG"; + const isJpeg = header.charCodeAt(0) === 0xff && header.charCodeAt(1) === 0xd8 && header.charCodeAt(2) === 0xff; + const isWebp = header.slice(0, 4) === "RIFF" && header.slice(8, 12) === "WEBP"; + if (!isPng && !isJpeg && !isWebp) { + throw new Error("OpenVINO generation returned an invalid image payload instead of a real PNG/JPEG/WebP."); + } + const durationSec = Number(data.duration_sec) || parseFloat(((Date.now() - startTime) / 1000).toFixed(1)); + return { + image: `data:image/png;base64,${normalizedB64}`, + seed: data.data?.[0]?.seed ?? payload.seed, + duration_sec: durationSec, + }; + } + + const baseUrl = await getBackendBaseUrl(); + // txt2img uses /v1/images/generations; img2img uses /sdapi/v1/img2img. const isImg2Img = !!payload.image; @@ -416,9 +479,17 @@ export async function generateImage(prompt, negativePrompt, constraints, activeM // Response: { data: [{ b64_json: "..." }] } const imgB64 = data?.data?.[0]?.b64_json ?? data?.images?.[0]; if (imgB64) { + const normalizedB64 = String(imgB64).replace(/^data:[^;]+;base64,/, ""); + const header = atob(normalizedB64.slice(0, 24)); + const isPng = header.charCodeAt(0) === 0x89 && header.slice(1, 4) === "PNG"; + const isJpeg = header.charCodeAt(0) === 0xff && header.charCodeAt(1) === 0xd8 && header.charCodeAt(2) === 0xff; + const isWebp = header.slice(0, 4) === "RIFF" && header.slice(8, 12) === "WEBP"; + if (!isPng && !isJpeg && !isWebp) { + throw new Error("Generation returned an invalid image payload instead of a real PNG/JPEG/WebP."); + } const durationSec = parseFloat(((Date.now() - startTime) / 1000).toFixed(1)); return { - image: `data:image/png;base64,${imgB64}`, + image: `data:image/png;base64,${normalizedB64}`, seed: data.data?.[0]?.seed ?? payload.seed, duration_sec: durationSec, }; @@ -654,6 +725,20 @@ export async function downloadModel(url) { } } +export async function downloadOpenVinoModel(modelId) { + try { + const res = await fetch("/api/download-openvino-model", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model_id: modelId }) + }); + return await readJsonResponse(res, "The local server returned an invalid OpenVINO download response."); + } catch (e) { + console.error("Failed to start OpenVINO model download:", e); + return { ok: false, error: e.message }; + } +} + export async function cancelModelDownload() { try { const res = await fetch("/api/cancel-download", { method: "POST" }); diff --git a/linux.sh b/linux.sh index 5eccac2..f7ee797 100755 --- a/linux.sh +++ b/linux.sh @@ -30,10 +30,27 @@ FRONTEND_PORT="${FRONTEND_PORT:-1420}" SETUP_REASON="" SETUP_MODE="Repair" MAX_PERF_FLAG="" +SETUP_OPENVINO=0 # Parse args -if [[ "${1:-}" == "--max-perf" ]]; then - MAX_PERF_FLAG="--max-perf" +for arg in "$@"; do + case "$arg" in + --max-perf) + MAX_PERF_FLAG="--max-perf" + ;; + --setup-openvino) + SETUP_OPENVINO=1 + ;; + *) + echo "[ERROR] Unknown option: $arg" >&2 + echo "Usage: ./linux.sh [--max-perf] [--setup-openvino]" >&2 + exit 1 + ;; + esac +done + +if [[ $SETUP_OPENVINO -eq 1 ]]; then + bash "$SCRIPT_DIR/scripts/setup-openvino-npu.sh" fi # ── Symlink node_modules to avoid OS conflicts ────────────────────────────── diff --git a/scripts/__pycache__/openvino_npu_worker.cpython-313.pyc b/scripts/__pycache__/openvino_npu_worker.cpython-313.pyc new file mode 100644 index 0000000..4ca397f Binary files /dev/null and b/scripts/__pycache__/openvino_npu_worker.cpython-313.pyc differ diff --git a/scripts/openvino_npu_worker.py b/scripts/openvino_npu_worker.py new file mode 100644 index 0000000..e5a0309 --- /dev/null +++ b/scripts/openvino_npu_worker.py @@ -0,0 +1,187 @@ +import argparse +import base64 +import json +import sys +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from io import BytesIO +from pathlib import Path + +import openvino as ov +import openvino_genai as genai +from PIL import Image + + +class WorkerState: + def __init__(self, model_dir: Path, width: int, height: int, cache_dir: Path): + self.model_dir = model_dir + self.width = width + self.height = height + self.cache_dir = cache_dir + self.ready = False + self.error = None + self.pipeline = None + self.devices = {} + self.started_at = time.time() + + def load(self) -> None: + try: + available_devices = ov.Core().available_devices + if "NPU" not in available_devices: + raise RuntimeError( + f"Intel NPU is not available to OpenVINO. Devices: {', '.join(available_devices)}" + ) + vae_device = "GPU" if "GPU" in available_devices else "CPU" + self.devices = { + "text_encoder": "CPU", + "unet": "NPU", + "vae_decoder": vae_device, + } + print(f"[openvino-npu] Loading model: {self.model_dir}", flush=True) + print( + "[openvino-npu] Devices: " + f"text_encoder=CPU, unet=NPU, vae_decoder={vae_device}", + flush=True, + ) + self.cache_dir.mkdir(parents=True, exist_ok=True) + pipe = genai.Text2ImagePipeline(str(self.model_dir)) + pipe.reshape(1, self.height, self.width, 1.0) + pipe.compile( + "CPU", + "NPU", + vae_device, + config={"CACHE_DIR": str(self.cache_dir)}, + ) + self.pipeline = pipe + self.ready = True + print("[openvino-npu] READY", flush=True) + except Exception as exc: + self.error = str(exc) + print(f"[openvino-npu] ERROR: {self.error}", file=sys.stderr, flush=True) + raise + + +def make_handler(state: WorkerState): + class Handler(BaseHTTPRequestHandler): + def _json(self, code: int, payload: dict) -> None: + body = json.dumps(payload).encode("utf-8") + self.send_response(code) + self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_OPTIONS(self) -> None: + self.send_response(204) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.send_header("Access-Control-Allow-Methods", "GET,POST,OPTIONS") + self.end_headers() + + def do_GET(self) -> None: + if self.path == "/health": + self._json(200, { + "ok": state.ready and not state.error, + "ready": state.ready, + "error": state.error, + "model": str(state.model_dir), + "devices": state.devices, + "uptime_sec": round(time.time() - state.started_at, 1), + }) + return + self._json(404, {"ok": False, "error": "Unknown endpoint"}) + + def do_POST(self) -> None: + if self.path != "/generate": + self._json(404, {"ok": False, "error": "Unknown endpoint"}) + return + if not state.ready or state.pipeline is None: + self._json(503, {"ok": False, "error": state.error or "OpenVINO NPU worker is not ready"}) + return + + length = int(self.headers.get("Content-Length", "0") or "0") + try: + request = json.loads(self.rfile.read(length).decode("utf-8") or "{}") + prompt = str(request.get("prompt") or "").strip() + if not prompt: + raise ValueError("Prompt is required") + steps = max(1, min(8, int(request.get("steps") or 4))) + guidance_scale = float(request.get("guidance_scale") or request.get("cfg_scale") or 1.0) + width = int(request.get("width") or state.width) + height = int(request.get("height") or state.height) + if (width, height) not in ((512, 512), (1024, 1024)): + raise ValueError("OpenVINO NPU supports 512x512 generation or 1024x1024 HD upscale.") + + started = time.time() + last_step_at = started + + def progress_callback(step: int, num_steps: int, _latent) -> bool: + nonlocal last_step_at + now = time.time() + step_duration = max(0.001, now - last_step_at) + last_step_at = now + print( + f"[openvino-npu] PROGRESS {step + 1}/{num_steps} {1.0 / step_duration:.2f} it/s", + flush=True, + ) + return False + + generate_args = { + "width": state.width, + "height": state.height, + "num_inference_steps": steps, + "guidance_scale": guidance_scale, + "rng_seed": int(request.get("seed")) if request.get("seed") not in (None, -1) else int(time.time_ns() % (2**32)), + "callback": progress_callback, + } + negative_prompt = str(request.get("negative_prompt") or "").strip() + if negative_prompt and guidance_scale > 1.0: + generate_args["negative_prompt"] = negative_prompt + image_tensor = state.pipeline.generate(prompt, **generate_args) + print("[openvino-npu] DECODING", flush=True) + image = Image.fromarray(image_tensor.data[0]) + upscaled = width != state.width or height != state.height + if upscaled: + image = image.resize((width, height), Image.Resampling.LANCZOS) + buf = BytesIO() + image.save(buf, format="PNG") + encoded = base64.b64encode(buf.getvalue()).decode("ascii") + self._json(200, { + "ok": True, + "data": [{ + "b64_json": encoded, + "seed": request.get("seed"), + }], + "generated_size": f"{state.width}x{state.height}", + "output_size": f"{width}x{height}", + "upscaled": upscaled, + "duration_sec": round(time.time() - started, 2), + }) + except Exception as exc: + self._json(500, {"ok": False, "error": str(exc)}) + + def log_message(self, fmt: str, *args) -> None: + print(f"[openvino-npu-http] {fmt % args}", flush=True) + + return Handler + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--model-dir", required=True, type=Path) + parser.add_argument("--port", required=True, type=int) + parser.add_argument("--width", type=int, default=512) + parser.add_argument("--height", type=int, default=512) + parser.add_argument("--cache-dir", type=Path, default=Path("app/tools/openvino-cache")) + args = parser.parse_args() + + state = WorkerState(args.model_dir, args.width, args.height, args.cache_dir) + state.load() + server = ThreadingHTTPServer(("127.0.0.1", args.port), make_handler(state)) + print(f"[openvino-npu] Listening on http://127.0.0.1:{args.port}", flush=True) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/scripts/serve.cjs b/scripts/serve.cjs index c8b901a..6b74f06 100644 --- a/scripts/serve.cjs +++ b/scripts/serve.cjs @@ -68,15 +68,38 @@ const MODELS = path.join(ROOT, "app", "models"); if (!fs.existsSync(MODELS)) { fs.mkdirSync(MODELS, { recursive: true }); } +const OPENVINO_MODELS = path.join(ROOT, "app", "openvino-models"); +if (!fs.existsSync(OPENVINO_MODELS)) { + fs.mkdirSync(OPENVINO_MODELS, { recursive: true }); +} const OUTPUTS = path.join(ROOT, "app", "outputs"); if (!fs.existsSync(OUTPUTS)) { fs.mkdirSync(OUTPUTS, { recursive: true }); } +const OPENVINO_NPU_MODELS = [ + { + id: "lcm-dreamshaper-v7-fp16", + name: "LCM DreamShaper v7 FP16", + repo: "OpenVINO/LCM_Dreamshaper_v7-fp16-ov", + folder: "LCM_Dreamshaper_v7-fp16-ov", + approxSize: "2.0 GB", + resolution: "512x512", + notes: "OpenVINO LCM image model. Uses CPU text encoder, NPU UNet, and GPU or CPU VAE decoder.", + }, +]; + // ── Backend process state ───────────────────────────────────────────────────── let backendProc = null; let backendReady = false; let backendError = null; +let openvinoProc = null; +let openvinoReady = false; +let openvinoError = null; +let openvinoPort = null; +let openvinoModel = null; +let openvinoWidth = null; +let openvinoHeight = null; let backendLoadState = { active: false, phase: "", @@ -104,6 +127,8 @@ let currentSettings = { backendType: "auto", vaeTiling: true, vaeOnCpu: false, + width: 512, + height: 512, }; let lastCpuSample = null; @@ -360,6 +385,98 @@ function getPathSize(targetPath) { } } +function getOpenVinoPythonCandidates() { + const candidates = []; + if (process.env.OPENVINO_PYTHON) candidates.push(process.env.OPENVINO_PYTHON); + if (osPlatform === "win32") { + candidates.push(path.join(ROOT, "app", "tools", "openvino-venv", "Scripts", "python.exe")); + candidates.push("C:\\tmp\\npu-test-venv\\Scripts\\python.exe"); + candidates.push("python"); + } else { + candidates.push(path.join(ROOT, "app", "tools", "openvino-venv", "bin", "python")); + candidates.push("python3"); + candidates.push("python"); + } + return [...new Set(candidates)]; +} + +function getOpenVinoPython() { + for (const candidate of getOpenVinoPythonCandidates()) { + try { + const result = spawnSync(candidate, [ + "-c", + "import openvino, openvino_genai, PIL; print(openvino.__version__)", + ], { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"] }); + if (result.status === 0) return candidate; + } catch (_) {} + } + return null; +} + +function getOpenVinoNpuInfo() { + if (osPlatform !== "win32" && osPlatform !== "linux") { + return { supported: false, reason: "OpenVINO NPU backend is supported on Windows and Linux Intel Core Ultra systems." }; + } + const python = getOpenVinoPython(); + if (!python) { + const setupScript = osPlatform === "win32" + ? "scripts/setup-openvino-npu.ps1" + : "bash scripts/setup-openvino-npu.sh"; + return { + supported: false, + reason: `OpenVINO GenAI runtime is not installed. Run ${setupScript} first.`, + }; + } + try { + const script = [ + "import json, openvino as ov", + "core=ov.Core()", + "devices=core.available_devices", + "info={'devices':devices,'npu':None}", + "if 'NPU' in devices:", + " info['npu']={'name':core.get_property('NPU','FULL_DEVICE_NAME'),'capabilities':core.get_property('NPU','OPTIMIZATION_CAPABILITIES')}", + "print(json.dumps(info))", + ].join("\n"); + const result = spawnSync(python, ["-c", script], { encoding: "utf8", timeout: 15000, stdio: ["ignore", "pipe", "pipe"] }); + if (result.status !== 0) { + return { supported: false, python, reason: result.stderr.trim() || "OpenVINO NPU probe failed." }; + } + const info = JSON.parse(result.stdout.trim()); + if (!info.devices.includes("NPU")) { + return { supported: false, python, reason: `OpenVINO is installed, but NPU is not available. Devices: ${info.devices.join(", ")}` }; + } + return { supported: true, platform: osPlatform, python, devices: info.devices, npu: info.npu }; + } catch (err) { + return { supported: false, python, reason: err.message || String(err) }; + } +} + +function getOpenVinoModelInfo() { + return OPENVINO_NPU_MODELS.map((model) => { + const modelPath = path.join(OPENVINO_MODELS, model.folder); + const requiredFiles = [ + path.join(modelPath, "model_index.json"), + path.join(modelPath, "text_encoder", "openvino_model.xml"), + path.join(modelPath, "unet", "openvino_model.xml"), + path.join(modelPath, "vae_decoder", "openvino_model.xml"), + ]; + const installed = requiredFiles.every((file) => fs.existsSync(file)); + return { + ...model, + filename: model.id, + path: modelPath, + installed, + sizeBytes: getPathSize(modelPath), + size: formatBytes(getPathSize(modelPath)), + format: "OpenVINO", + }; + }); +} + +function findOpenVinoModel(modelId) { + return getOpenVinoModelInfo().find((model) => model.id === modelId || model.folder === modelId || model.name === modelId); +} + function pathInside(childPath, parentPath) { const relative = path.relative(path.resolve(parentPath), path.resolve(childPath)); return relative === "" || (!!relative && !relative.startsWith("..") && !path.isAbsolute(relative)); @@ -468,7 +585,7 @@ async function getHealth() { const ports = { frontend: { ...(await checkPort(PORT_FRONTEND)), expectedInUse: true }, - backend: { ...(await checkPort(PORT_BACKEND)), expectedInUse: backendProc !== null }, + backend: { ...(await checkPort(PORT_BACKEND)), expectedInUse: backendProc !== null || openvinoProc !== null }, }; ports.frontend.ok = !ports.frontend.available; ports.backend.preferred = PREFERRED_BACKEND_PORT; @@ -489,8 +606,8 @@ async function getHealth() { checks, ports, backend: { - ready: backendReady, - running: backendProc !== null, + ready: backendReady || openvinoReady, + running: backendProc !== null || openvinoProc !== null, error: backendError, settings: currentSettings, options: getBackendOptions(), @@ -609,12 +726,16 @@ function getBackendOptions() { // as Metal-capable so the UI does not default to CPU if a probe flag changes // upstream. const metalAvailable = metalInstalled; + const openvinoNpu = getOpenVinoNpuInfo(); + const openvinoModels = getOpenVinoModelInfo(); + const openvinoNpuAvailable = openvinoNpu.supported && openvinoModels.some((model) => model.installed); const options = [{ id: "cpu", label: "CPU", available: true }]; if (metalAvailable) options.push({ id: "metal", label: "Metal GPU", available: true }); if (vulkanAvailable) options.push({ id: "vulkan", label: "Vulkan GPU", available: true }); if (rocmAvailable) options.push({ id: "rocm", label: "ROCm GPU (AMD)", available: true }); if (cudaAvailable) options.push({ id: "cuda", label: "CUDA GPU", available: true }); + if (openvinoNpuAvailable) options.push({ id: "openvino-npu", label: "NPU (OpenVINO)", available: true }); const unavailable = []; if (vulkanInstalled && !vulkanAvailable) { @@ -629,6 +750,11 @@ function getBackendOptions() { if (metalInstalled && !metalAvailable) { unavailable.push({ id: "metal", label: "Metal GPU", reason: "Installed, but Metal backend validation failed." }); } + if (openvinoNpu.supported && !openvinoNpuAvailable) { + unavailable.push({ id: "openvino-npu", label: "NPU (OpenVINO)", reason: "Runtime is ready, but no OpenVINO NPU model is downloaded." }); + } else if (!openvinoNpu.supported && (osPlatform === "win32" || osPlatform === "linux")) { + unavailable.push({ id: "openvino-npu", label: "NPU (OpenVINO)", reason: openvinoNpu.reason }); + } let defaultBackend = "cpu"; if (metalAvailable) { @@ -654,6 +780,9 @@ function getBackendOptions() { vulkanAvailable, rocmAvailable, metalAvailable, + openvinoNpuAvailable, + openvinoNpu, + openvinoModels, defaultBackendType: defaultBackend, }; return cachedBackendOptions; @@ -795,6 +924,208 @@ function pingBackendReady() { }); } +function requestJson(url, payload = null, timeoutMs = 120000) { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const data = payload ? Buffer.from(JSON.stringify(payload), "utf8") : null; + const req = http.request({ + hostname: parsed.hostname, + port: parsed.port, + path: parsed.pathname + parsed.search, + method: data ? "POST" : "GET", + headers: data ? { + "Content-Type": "application/json", + "Content-Length": data.length, + } : {}, + timeout: timeoutMs, + }, (res) => { + let body = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { body += chunk; }); + res.on("end", () => { + try { + const jsonBody = JSON.parse(body || "{}"); + if (res.statusCode < 200 || res.statusCode >= 300 || jsonBody.ok === false) { + reject(new Error(jsonBody.error || `HTTP ${res.statusCode}`)); + return; + } + resolve(jsonBody); + } catch (err) { + reject(err); + } + }); + }); + req.on("error", reject); + req.on("timeout", () => req.destroy(new Error("Request timed out"))); + if (data) req.write(data); + req.end(); + }); +} + +async function killOpenVinoWorker() { + if (!openvinoProc) { + openvinoReady = false; + return; + } + console.log(" [openvino-npu] Stopping worker..."); + try { openvinoProc.kill("SIGTERM"); } catch (_) {} + await new Promise((resolve) => setTimeout(resolve, 800)); + try { openvinoProc.kill("SIGKILL"); } catch (_) {} + openvinoProc = null; + openvinoReady = false; +} + +function getOpenVinoResolution(settings = {}) { + const width = Number(settings.width) || 512; + const height = Number(settings.height) || 512; + const supported = (width === 512 && height === 512) || (width === 1024 && height === 1024); + if (!supported) { + throw new Error("OpenVINO NPU supports 512x512 generation or 1024x1024 HD upscale."); + } + return { width, height }; +} + +async function startOpenVinoWorker(settings = {}) { + const npuInfo = getOpenVinoNpuInfo(); + if (!npuInfo.supported) throw new Error(npuInfo.reason || "OpenVINO NPU is not available."); + const model = findOpenVinoModel(settings.model) || getOpenVinoModelInfo().find((item) => item.installed); + if (!model || !model.installed) { + throw new Error("OpenVINO NPU model is not downloaded. Download an OpenVINO NPU model from Model Manager first."); + } + const { width, height } = getOpenVinoResolution(settings); + if (openvinoProc && + openvinoReady && + openvinoModel === model.id) { + currentSettings = { ...currentSettings, ...settings, model: model.id, width, height }; + return; + } + + await killBackend(); + await killOpenVinoWorker(); + openvinoError = null; + openvinoReady = false; + openvinoModel = model.id; + openvinoWidth = 512; + openvinoHeight = 512; + openvinoPort = await findAvailableBackendPort(); + PORT_BACKEND = openvinoPort; + + currentSettings = { ...currentSettings, ...settings }; + currentSettings.backendType = "openvino-npu"; + currentSettings.useGpu = true; + currentSettings.backendMode = "NPU (OpenVINO)"; + currentSettings.backendBinary = "openvino_npu_worker.py"; + currentSettings.backendDevice = npuInfo.npu?.name || "Intel AI Boost"; + currentSettings.model = model.id; + currentSettings.width = width; + currentSettings.height = height; + + backendError = null; + backendLoadState = { + active: true, + phase: "Compiling OpenVINO NPU pipeline (512x512)...", + progress: 5, + current: 0, + total: 0, + speed: "", + model: model.name, + backendMode: "NPU (OpenVINO)", + backendBinary: "openvino_npu_worker.py", + device: currentSettings.backendDevice, + }; + + const workerPath = path.join(ROOT, "scripts", "openvino_npu_worker.py"); + const cacheDir = path.join(ROOT, "app", "tools", "openvino-cache", "512x512"); + console.log(` [openvino-npu] Starting 512x512 worker on port ${openvinoPort}`); + openvinoProc = spawn(npuInfo.python, [ + workerPath, + "--model-dir", model.path, + "--port", String(openvinoPort), + "--width", "512", + "--height", "512", + "--cache-dir", cacheDir, + ], { stdio: "pipe" }); + + let openvinoStdoutBuffer = ""; + openvinoProc.stdout.on("data", (data) => { + const text = data.toString(); + process.stdout.write(" " + text); + openvinoStdoutBuffer += text; + const lines = openvinoStdoutBuffer.split(/\r?\n/); + openvinoStdoutBuffer = lines.pop() || ""; + for (const line of lines) { + if (line.includes("READY")) { + openvinoReady = true; + backendReady = true; + backendLoadState = { ...backendLoadState, active: false, phase: "OpenVINO NPU model ready", progress: 100 }; + } + const progressMatch = line.match(/PROGRESS\s+(\d+)\/(\d+)\s+(.+)$/); + if (progressMatch) { + generationState.active = true; + generationState.step = Number(progressMatch[1]); + generationState.steps = Number(progressMatch[2]); + generationState.speed = progressMatch[3].trim(); + generationState.decoding = false; + } else if (line.includes("DECODING") && generationState.active) { + generationState.step = generationState.steps; + generationState.decoding = true; + generationState.speed = ""; + } + } + }); + openvinoProc.stderr.on("data", (data) => { + const text = data.toString(); + process.stderr.write(" " + text); + const cleanText = stripAnsi(text).trim(); + if (cleanText && (cleanText.includes("ERROR") || cleanText.includes("Traceback") || cleanText.includes("Exception"))) { + openvinoError = cleanText; + backendError = openvinoError; + } + }); + openvinoProc.on("exit", (code) => { + console.log(" [openvino-npu] worker exited with code", code); + openvinoProc = null; + openvinoReady = false; + backendReady = false; + if (code !== 0 && code !== null && !backendError) backendError = `OpenVINO NPU worker exited with code ${code}`; + backendLoadState.active = false; + }); + + const startedAt = Date.now(); + while (Date.now() - startedAt < 360000) { + if (openvinoReady) return; + if (backendError) throw new Error(backendError); + if (!openvinoProc) throw new Error("OpenVINO NPU worker exited during startup."); + const elapsed = Math.round((Date.now() - startedAt) / 1000); + backendLoadState.progress = Math.min(95, 5 + Math.round(elapsed / 2)); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + await killOpenVinoWorker(); + throw new Error("OpenVINO NPU 512x512 compile timed out after 6 minutes."); +} + +async function generateWithOpenVino(body) { + if (!openvinoReady || !openvinoPort) throw new Error("OpenVINO NPU worker is not ready. Load an OpenVINO NPU model first."); + const steps = Math.max(1, Math.min(8, Number(body.steps) || 4)); + generationState = { active: true, step: 0, steps, speed: "", decoding: false }; + try { + const result = await requestJson(`http://127.0.0.1:${openvinoPort}/generate`, { + prompt: body.prompt, + negative_prompt: body.negative_prompt || "", + width: Number(body.width) || currentSettings.width || 512, + height: Number(body.height) || currentSettings.height || 512, + steps, + cfg_scale: Number(body.cfg_scale) || 1.0, + seed: body.seed, + }, 300000); + resetGenerationState(); + return result; + } catch (err) { + resetGenerationState(); + throw err; + } +} + function startBackendReadyPoll() { let attempts = 0; const interval = setInterval(async () => { @@ -839,6 +1170,11 @@ function killBackend() { } async function startBackend(settings = {}) { + if (settings.backendType === "openvino-npu") { + await startOpenVinoWorker(settings); + return; + } + await killOpenVinoWorker(); backendError = null; currentSettings = { ...currentSettings, ...settings }; if (!currentSettings.model) currentSettings.model = getDefaultModel(); @@ -1118,6 +1454,54 @@ let downloadState = { }; let activeDownload = null; +function startOpenVinoModelDownload(modelId) { + if (downloadState.active) return; + const npuInfo = getOpenVinoNpuInfo(); + if (!npuInfo.supported) throw new Error(npuInfo.reason || "OpenVINO NPU runtime is not available."); + const model = OPENVINO_NPU_MODELS.find((item) => item.id === modelId); + if (!model) throw new Error("Unknown OpenVINO model."); + + const destDir = path.join(OPENVINO_MODELS, model.folder); + fs.mkdirSync(destDir, { recursive: true }); + downloadState = { + active: true, + filename: model.name, + progress: -1, + speed: "Downloading snapshot", + eta: 0, + totalBytes: 0, + downloadedBytes: 0, + error: null, + }; + + const script = [ + "from huggingface_hub import snapshot_download", + `snapshot_download(repo_id=${JSON.stringify(model.repo)}, local_dir=${JSON.stringify(destDir)}, ignore_patterns=['safety_checker/*','feature_extractor/*','*.safetensors'])`, + "print('DONE')", + ].join("\n"); + console.log(` [openvino-download] Downloading ${model.repo} -> ${destDir}`); + const proc = spawn(npuInfo.python, ["-c", script], { stdio: "pipe" }); + activeDownload = { process: proc, destPath: destDir, openvino: true }; + proc.stdout.on("data", (data) => process.stdout.write(" [openvino-download] " + data.toString())); + proc.stderr.on("data", (data) => process.stderr.write(" [openvino-download] " + data.toString())); + proc.on("exit", (code) => { + activeDownload = null; + if (String(downloadState.error || "").toLowerCase().includes("cancelled")) { + return; + } + downloadState.active = false; + if (code === 0) { + downloadState.progress = 100; + downloadState.downloadedBytes = getPathSize(destDir); + downloadState.totalBytes = downloadState.downloadedBytes; + downloadState.speed = "Complete"; + cachedBackendOptions = null; + } else { + downloadState.error = `OpenVINO model download failed with code ${code}`; + } + }); +} + // ── Generation State (Real-time progress parser) ───────────────────────────── let generationState = { active: false, @@ -1342,9 +1726,14 @@ function cancelModelDownload() { } const filename = downloadState.filename; if (activeDownload) { - try { activeDownload.request.destroy(new Error("Download cancelled by user")); } catch (_) {} - try { activeDownload.fileStream.destroy(); } catch (_) {} - try { fs.unlinkSync(activeDownload.tempPath || activeDownload.destPath); } catch (_) {} + if (activeDownload.process) { + try { activeDownload.process.kill("SIGTERM"); } catch (_) {} + setTimeout(() => { try { activeDownload?.process?.kill("SIGKILL"); } catch (_) {} }, 1000); + } else { + try { activeDownload.request.destroy(new Error("Download cancelled by user")); } catch (_) {} + try { activeDownload.fileStream.destroy(); } catch (_) {} + try { fs.unlinkSync(activeDownload.tempPath || activeDownload.destPath); } catch (_) {} + } } else if (filename) { try { fs.unlinkSync(`${path.join(MODELS, filename)}.part`); } catch (_) {} } @@ -1514,9 +1903,9 @@ function safeOutputName(value) { } function saveGeneratedOutput(imageDataUrl, metadata = {}) { - const match = String(imageDataUrl || "").match(/^data:(image\/(?:png|jpeg|jpg|webp|svg\+xml));base64,(.+)$/); + const match = String(imageDataUrl || "").match(/^data:(image\/(?:png|jpeg|jpg|webp));base64,(.+)$/); if (!match) { - throw new Error("Expected a base64 image data URL"); + throw new Error("Expected a real base64 PNG, JPEG, or WebP image data URL"); } const mime = match[1]; @@ -1525,9 +1914,26 @@ function saveGeneratedOutput(imageDataUrl, metadata = {}) { "image/jpeg": ".jpg", "image/jpg": ".jpg", "image/webp": ".webp", - "image/svg+xml": ".svg", }; const ext = extByMime[mime] || ".png"; + const imageBuffer = Buffer.from(match[2], "base64"); + const isPng = imageBuffer.length > 8 && + imageBuffer[0] === 0x89 && + imageBuffer[1] === 0x50 && + imageBuffer[2] === 0x4e && + imageBuffer[3] === 0x47; + const isJpeg = imageBuffer.length > 3 && + imageBuffer[0] === 0xff && + imageBuffer[1] === 0xd8 && + imageBuffer[2] === 0xff; + const isWebp = imageBuffer.length > 12 && + imageBuffer.toString("ascii", 0, 4) === "RIFF" && + imageBuffer.toString("ascii", 8, 12) === "WEBP"; + if ((mime === "image/png" && !isPng) || + ((mime === "image/jpeg" || mime === "image/jpg") && !isJpeg) || + (mime === "image/webp" && !isWebp)) { + throw new Error("Generated image payload did not match its declared image format."); + } const createdAt = new Date().toISOString(); const stamp = createdAt.replace(/[:.]/g, "-"); const seed = metadata.seed !== undefined && metadata.seed !== null ? `-${safeOutputName(metadata.seed)}` : ""; @@ -1537,7 +1943,7 @@ function saveGeneratedOutput(imageDataUrl, metadata = {}) { const imagePath = path.join(OUTPUTS, imageFilename); const metadataPath = path.join(OUTPUTS, metadataFilename); - fs.writeFileSync(imagePath, Buffer.from(match[2], "base64")); + fs.writeFileSync(imagePath, imageBuffer); const savedMetadata = { ...metadata, createdAt, @@ -1703,9 +2109,9 @@ const server = http.createServer(async (req, res) => { // GET /api/backend-status if (req.url === "/api/backend-status" && req.method === "GET") { - return json(res, 200, { - ready: backendReady, - running: backendProc !== null, + return json(res, 200, { + ready: backendReady || openvinoReady, + running: backendProc !== null || openvinoProc !== null, port: PORT_BACKEND, preferredPort: PREFERRED_BACKEND_PORT, error: backendError, @@ -1742,6 +2148,17 @@ const server = http.createServer(async (req, res) => { } catch (_) { return json(res, 200, { models: [] }); } } + if (req.url === "/api/openvino-models" && req.method === "GET") { + const npuInfo = getOpenVinoNpuInfo(); + return json(res, 200, { + supported: npuInfo.supported, + reason: npuInfo.reason || "", + npu: npuInfo.npu || null, + python: npuInfo.python || "", + models: npuInfo.supported ? getOpenVinoModelInfo() : [], + }); + } + // POST /api/restart-backend — restart with new settings if (req.url === "/api/restart-backend" && req.method === "POST") { const body = await readJsonBody(req, res); @@ -1750,11 +2167,13 @@ const server = http.createServer(async (req, res) => { await killBackend(); await new Promise(r => setTimeout(r, 500)); const newSettings = {}; - if (body.model) newSettings.model = path.join(MODELS, body.model); + if (body.model) newSettings.model = body.backend_type === "openvino-npu" ? String(body.model) : path.join(MODELS, body.model); if (body.steps) newSettings.steps = parseInt(body.steps); if (body.cfgScale) newSettings.cfgScale = parseFloat(body.cfgScale); if (body.sampler) newSettings.sampler = body.sampler; if (body.threads) newSettings.threads = parseInt(body.threads); + if (body.width) newSettings.width = parseInt(body.width); + if (body.height) newSettings.height = parseInt(body.height); if (typeof body.use_gpu === "boolean") newSettings.useGpu = body.use_gpu; if (body.backend_type) { newSettings.backendType = String(body.backend_type); @@ -1763,6 +2182,18 @@ const server = http.createServer(async (req, res) => { if (typeof body.vae_tiling === "boolean") newSettings.vaeTiling = body.vae_tiling; if (typeof body.vae_on_cpu === "boolean") newSettings.vaeOnCpu = body.vae_on_cpu; try { + if (newSettings.backendType === "openvino-npu") { + startBackend(newSettings).catch((err) => { + backendError = err.message || String(err); + backendLoadState = { + ...backendLoadState, + active: false, + phase: "OpenVINO NPU model load failed", + }; + console.error(" [openvino-npu] Startup failed:", backendError); + }); + return json(res, 200, { ok: true, message: "OpenVINO NPU backend starting...", settings: currentSettings, port: PORT_BACKEND }); + } await startBackend(newSettings); return json(res, 200, { ok: true, message: "Backend restarting...", settings: currentSettings, port: PORT_BACKEND }); } catch (err) { @@ -1774,6 +2205,7 @@ const server = http.createServer(async (req, res) => { // POST /api/stop-backend if (req.url === "/api/stop-backend" && req.method === "POST") { await killBackend(); + await killOpenVinoWorker(); return json(res, 200, { ok: true }); } @@ -1788,6 +2220,19 @@ const server = http.createServer(async (req, res) => { return json(res, 200, { ok: true, message: "Download started" }); } + if (req.url === "/api/download-openvino-model" && req.method === "POST") { + const body = await readJsonBody(req, res); + if (!body) return; + const modelId = String(body.model_id || body.modelId || ""); + if (!modelId) return json(res, 400, { ok: false, error: "model_id is required" }); + try { + startOpenVinoModelDownload(modelId); + return json(res, 200, { ok: true, message: "OpenVINO model download started" }); + } catch (err) { + return json(res, 500, { ok: false, error: err.message || String(err) }); + } + } + // GET /api/download-progress if (req.url === "/api/download-progress" && req.method === "GET") { return json(res, 200, downloadState); @@ -1804,6 +2249,16 @@ const server = http.createServer(async (req, res) => { return json(res, 200, generationState); } + if (req.url === "/api/openvino-generate" && req.method === "POST") { + const body = await readJsonBody(req, res); + if (!body) return; + try { + return json(res, 200, await generateWithOpenVino(body)); + } catch (err) { + return json(res, 500, { ok: false, error: err.message || String(err) }); + } + } + // GET /api/outputs if (req.url === "/api/outputs" && req.method === "GET") { return json(res, 200, { outputs: listGeneratedOutputs() }); @@ -1871,11 +2326,17 @@ const server = http.createServer(async (req, res) => { const { filename } = body; if (!filename) return json(res, 400, { error: "Filename is required" }); + const openvinoModel = findOpenVinoModel(filename); const safeFilename = path.basename(filename); const modelPath = path.join(MODELS, safeFilename); try { - if (fs.existsSync(modelPath)) { + if (openvinoModel?.installed && openvinoModel.path && pathInside(openvinoModel.path, OPENVINO_MODELS)) { + fs.rmSync(openvinoModel.path, { recursive: true, force: true }); + cachedBackendOptions = null; + console.log(` [api] Deleted OpenVINO model: ${openvinoModel.id}`); + return json(res, 200, { ok: true, message: `Deleted ${openvinoModel.name}` }); + } else if (fs.existsSync(modelPath)) { fs.unlinkSync(modelPath); console.log(` [api] Deleted model file: ${safeFilename}`); return json(res, 200, { ok: true, message: `Deleted ${safeFilename}` }); diff --git a/scripts/setup-openvino-npu.ps1 b/scripts/setup-openvino-npu.ps1 new file mode 100644 index 0000000..da5f70e --- /dev/null +++ b/scripts/setup-openvino-npu.ps1 @@ -0,0 +1,46 @@ +# Optional OpenVINO NPU setup for Intel AI Boost systems. +# This does not modify the existing stable-diffusion.cpp backends. + +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$rootDir = Split-Path -Parent $scriptDir +$venvDir = Join-Path $rootDir "app\tools\openvino-venv" +$pythonExe = Join-Path $venvDir "Scripts\python.exe" + +Write-Host "" +Write-Host " ============================================================" +Write-Host " Local AI Image Generator - OpenVINO NPU Setup" +Write-Host " ============================================================" +Write-Host "" + +$npu = Get-CimInstance Win32_PnPEntity -ErrorAction SilentlyContinue | + Where-Object { $_.Name -like "*Intel(R) AI Boost*" -or ($_.Name -match "NPU" -and $_.PNPClass -eq "ComputeAccelerator") } | + Select-Object -First 1 + +if (-not $npu) { + Write-Host " [ERROR] Intel AI Boost NPU was not detected on this system." -ForegroundColor Red + exit 1 +} + +Write-Host " Detected: $($npu.Name)" + +if (-not (Test-Path $pythonExe)) { + Write-Host " Creating Python environment: $venvDir" + python -m venv $venvDir +} + +Write-Host " Installing OpenVINO GenAI runtime..." +& $pythonExe -m pip install --upgrade pip +& $pythonExe -m pip install openvino openvino-genai pillow huggingface-hub + +Write-Host "" +Write-Host " Verifying OpenVINO NPU device..." +& $pythonExe -c "import openvino as ov; core=ov.Core(); print('Available devices:', core.available_devices); raise SystemExit(0 if 'NPU' in core.available_devices else 2)" + +Write-Host "" +Write-Host " ============================================================" +Write-Host " OpenVINO NPU setup complete." +Write-Host " Restart windows.bat, then download an OpenVINO NPU model." +Write-Host " ============================================================" +Write-Host "" diff --git a/scripts/setup-openvino-npu.sh b/scripts/setup-openvino-npu.sh new file mode 100644 index 0000000..fc74d9d --- /dev/null +++ b/scripts/setup-openvino-npu.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# +# Optional OpenVINO NPU setup for Linux Intel Core Ultra systems. +# The Intel NPU kernel/user-mode driver must already be installed. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" +VENV_DIR="$ROOT_DIR/app/tools/openvino-venv" +PYTHON_BIN="$VENV_DIR/bin/python" + +echo "" +echo " ============================================================" +echo " Local AI Image Generator - Linux OpenVINO NPU Setup" +echo " ============================================================" +echo "" + +if [[ "$(uname -s)" != "Linux" ]]; then + echo " [ERROR] This setup script is for Linux only." >&2 + exit 1 +fi + +if [[ "$(uname -m)" != "x86_64" ]]; then + echo " [ERROR] OpenVINO NPU support requires x86_64 Linux." >&2 + exit 1 +fi + +if ! command -v python3 >/dev/null 2>&1; then + echo " [ERROR] python3 is required. Install Python 3 and the venv module." >&2 + exit 1 +fi + +KERNEL_VERSION="$(uname -r)" +KERNEL_MAJOR="${KERNEL_VERSION%%.*}" +KERNEL_REST="${KERNEL_VERSION#*.}" +KERNEL_MINOR="${KERNEL_REST%%.*}" +if (( KERNEL_MAJOR < 6 || (KERNEL_MAJOR == 6 && KERNEL_MINOR < 6) )); then + echo " [ERROR] Intel NPU support requires Linux kernel 6.6 or newer." + echo " Current kernel: $KERNEL_VERSION" + exit 1 +fi + +if [[ ! -e /dev/accel/accel0 ]]; then + echo " [ERROR] Intel NPU device /dev/accel/accel0 was not found." + echo " Install the Intel Linux NPU driver and reboot first." + echo " Driver: https://github.com/intel/linux-npu-driver" + exit 1 +fi + +if [[ ! -x "$PYTHON_BIN" ]]; then + echo " Creating Python environment: $VENV_DIR" + if ! python3 -m venv "$VENV_DIR"; then + echo " [ERROR] Could not create the virtual environment." + echo " On Ubuntu, install python3-venv and retry." + exit 1 + fi +fi + +echo " Installing OpenVINO GenAI runtime..." +"$PYTHON_BIN" -m pip install --upgrade pip +"$PYTHON_BIN" -m pip install openvino openvino-genai pillow huggingface-hub + +echo "" +echo " Verifying OpenVINO NPU device..." +"$PYTHON_BIN" - <<'PY' +import openvino as ov + +core = ov.Core() +devices = core.available_devices +print(" Available devices:", ", ".join(devices)) +if "NPU" not in devices: + raise SystemExit( + " [ERROR] OpenVINO is installed, but NPU is unavailable. " + "Verify the Intel NPU driver and device permissions." + ) +print(" NPU:", core.get_property("NPU", "FULL_DEVICE_NAME")) +PY + +echo "" +echo " ============================================================" +echo " OpenVINO NPU setup complete." +echo " Run ./linux.sh, then download an OpenVINO NPU model." +echo " ============================================================" +echo "" diff --git a/scripts/setup.ps1 b/scripts/setup.ps1 index cfa6836..feabd06 100644 --- a/scripts/setup.ps1 +++ b/scripts/setup.ps1 @@ -47,6 +47,18 @@ function Format-Speed { return "{0:N0} KB/s" -f ($bps / 1KB) } +function Enable-Tls12 { + try { + # Use the protocol's numeric value because older .NET enum definitions + # do not expose the Tls12 member even when Windows supports TLS 1.2. + $tls12 = [Enum]::ToObject([Net.SecurityProtocolType], 3072) + [Net.ServicePointManager]::SecurityProtocol = + [Net.ServicePointManager]::SecurityProtocol -bor $tls12 + } catch { + throw "TLS 1.2 could not be enabled. This setup requires 64-bit Windows 10 or Windows 11 with current system updates. $($_.Exception.Message)" + } +} + function Invoke-RichDownload { param([string]$Url, [string]$Dest, [string]$Label) Print-Info "Downloading: $Label" @@ -57,7 +69,7 @@ function Invoke-RichDownload { $lastTime = [DateTime]::Now try { - [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Enable-Tls12 $req = [System.Net.HttpWebRequest]::Create($Url) $req.UserAgent = "Mozilla/5.0" $resp = $req.GetResponse()