feat: add OpenVINO NPU worker support and frontend UI components for model management and image generation
This commit is contained in:
1 parent
4a8b129c07
commit
d4cdc6ae28
13 files changed
+1125
-48
No files matched your search
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
</h3>
|
||||
|
||||
{isSD15OrCustom && (
|
||||
{isOpenVinoNpu ? (
|
||||
<div style={{
|
||||
background: "rgba(99, 102, 241, 0.1)",
|
||||
border: "1px solid var(--md-sys-color-primary)",
|
||||
color: "var(--md-sys-color-on-surface)",
|
||||
padding: "12px",
|
||||
borderRadius: "8px",
|
||||
marginBottom: "16px",
|
||||
fontSize: "0.85rem",
|
||||
display: "flex",
|
||||
gap: "8px",
|
||||
alignItems: "flex-start"
|
||||
}}>
|
||||
<Info size={16} style={{ color: "var(--md-sys-color-primary)", flexShrink: 0, marginTop: "2px" }} />
|
||||
<div>
|
||||
<strong>OpenVINO NPU Resolution:</strong> The NPU generates at its stable 512x512 resolution. HD mode produces a 1024x1024 output using high-quality Lanczos upscaling without recompiling the model.
|
||||
</div>
|
||||
</div>
|
||||
) : isSD15OrCustom && (
|
||||
<div style={{
|
||||
background: "rgba(251, 191, 36, 0.1)",
|
||||
border: "1px solid rgb(251, 191, 36)",
|
||||
@@ -104,7 +196,7 @@ function ImageConstraints({ constraints, setConstraints, activeModel, specs, bac
|
||||
<div className="m3-text-field">
|
||||
<label className="m3-text-field-label">AI Generation Engine Optimization</label>
|
||||
<div className="m3-segmented-button">
|
||||
{isSD15OrCustom ? (
|
||||
{forceStandardMode ? (
|
||||
<div
|
||||
className="m3-segment-item disabled"
|
||||
style={{
|
||||
@@ -115,7 +207,7 @@ function ImageConstraints({ constraints, setConstraints, activeModel, specs, bac
|
||||
}}
|
||||
title="High Quality Mode is disabled for SD 1.x models."
|
||||
>
|
||||
High Quality Mode (1024px)
|
||||
{isOpenVinoNpu ? "HD Upscale (1024px)" : "High Quality Mode (1024px)"}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
@@ -125,11 +217,11 @@ function ImageConstraints({ constraints, setConstraints, activeModel, specs, bac
|
||||
updateConstraint("height", 1024);
|
||||
}}
|
||||
>
|
||||
High Quality Mode (1024px)
|
||||
{isOpenVinoNpu ? "HD Upscale (1024px)" : "High Quality Mode (1024px)"}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`m3-segment-item ${constraints.width < 1024 || isSD15OrCustom ? "active" : ""}`}
|
||||
className={`m3-segment-item ${constraints.width < 1024 ? "active" : ""}`}
|
||||
onClick={() => {
|
||||
updateConstraint("width", 512);
|
||||
updateConstraint("height", 512);
|
||||
@@ -139,7 +231,9 @@ function ImageConstraints({ constraints, setConstraints, activeModel, specs, bac
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", marginTop: "4px" }}>
|
||||
{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."}
|
||||
</span>
|
||||
@@ -151,10 +245,12 @@ function ImageConstraints({ constraints, setConstraints, activeModel, specs, bac
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "8px" }}>
|
||||
{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 (
|
||||
<button
|
||||
key={ratio.id}
|
||||
className={`m3-btn m3-btn-outlined aspect-ratio-btn ${isActive ? "active" : ""}`}
|
||||
disabled={isUnsupportedOpenVinoShape}
|
||||
style={{
|
||||
height: "86px",
|
||||
display: "flex",
|
||||
@@ -165,12 +261,14 @@ function ImageConstraints({ constraints, setConstraints, activeModel, specs, bac
|
||||
borderRadius: "var(--md-shape-corner-medium)",
|
||||
borderColor: "var(--md-sys-color-outline-variant)"
|
||||
}}
|
||||
onClick={() => handleAspectRatioChange(ratio.id, isSD15OrCustom ? "sd15" : (constraints.width >= 1024 ? "sdxl" : "sd15"))}
|
||||
onClick={() => handleAspectRatioChange(ratio.id, forceStandardMode ? "sd15" : (constraints.width >= 1024 ? "sdxl" : "sd15"))}
|
||||
>
|
||||
<div className={`aspect-ratio-preview ratio-${ratio.id.replace(":", "-")}`}></div>
|
||||
<span style={{ fontWeight: 600, fontSize: "0.85rem", marginTop: "2px" }}>{ratio.label}</span>
|
||||
<span style={{ fontSize: "0.68rem", opacity: 0.7 }}>
|
||||
{isSD15OrCustom
|
||||
{isUnsupportedOpenVinoShape
|
||||
? "Not yet supported on NPU"
|
||||
: forceStandardMode
|
||||
? `Max: ${ratio.id === "1:1" ? "512x512" : ratio.id === "4:3" ? "512x384" : ratio.id === "16:9" ? "512x288" : "288x512"}`
|
||||
: ratio.desc}
|
||||
</span>
|
||||
@@ -202,10 +300,12 @@ function ImageConstraints({ constraints, setConstraints, activeModel, specs, bac
|
||||
value={constraints.steps}
|
||||
onChange={(e) => updateConstraint("steps", parseInt(e.target.value))}
|
||||
min="1"
|
||||
max="60"
|
||||
max={isOpenVinoNpu ? "8" : "60"}
|
||||
/>
|
||||
<span style={{ fontSize: "0.75rem", color: "var(--md-sys-color-outline)", lineHeight: "1.3" }}>
|
||||
The number of times the AI cleans up the image. More steps = sharper details, but takes longer.
|
||||
{isOpenVinoNpu
|
||||
? "LCM OpenVINO uses 1-8 fast inference steps. Progress is reported after each completed NPU step."
|
||||
: "The number of times the AI cleans up the image. More steps = sharper details, but takes longer."}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "2px" }}>
|
||||
<span style={{ fontWeight: 600, fontSize: "0.95rem" }}>{filename}</span>
|
||||
<span style={{ fontSize: "0.75rem", opacity: 0.8 }}>
|
||||
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)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -654,7 +706,7 @@ function ModelManager({ activeModel, setActiveModel, serverRunning, setServerRun
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
|
||||
{MODEL_LIBRARY.map((section) => (
|
||||
{visibleModelLibrary.map((section) => (
|
||||
<div key={section.group} className="m3-card" style={{ margin: 0 }}>
|
||||
<h4 style={{ fontWeight: 700, marginBottom: "12px" }}>{section.group}</h4>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: "12px" }}>
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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 ──────────────────────────────
|
||||
|
||||
Binary file not shown.
@@ -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()
|
||||
+476
-15
@@ -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}` });
|
||||
|
||||
@@ -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 ""
|
||||
@@ -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 ""
|
||||
+13
-1
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user