implement modular frontend components, backend services, and multi-platform configuration scripts for the local AI image generator
This commit is contained in:
1 parent
c280683a9d
commit
4a840679d6
14 files changed
+1114
-2089
No files matched your search
@@ -11,10 +11,10 @@ Write-Host " Resetting Local AI Studio..." -ForegroundColor Yellow
|
||||
Write-Host " ============================================================" -ForegroundColor Yellow
|
||||
Write-Host ""
|
||||
|
||||
# Delete tools/node
|
||||
# Delete portable tools/runtime folder
|
||||
$toolsDir = Join-Path $appDir "tools"
|
||||
if (Test-Path $toolsDir) {
|
||||
Write-Host " >> Removing portable tools/ node folder..." -ForegroundColor Cyan
|
||||
Write-Host " >> Removing portable tools/runtime folder..." -ForegroundColor Cyan
|
||||
Remove-Item $toolsDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ $backendDir = Join-Path $appDir "backend"
|
||||
if (Test-Path $backendDir) {
|
||||
Write-Host " >> Removing image backend binaries..." -ForegroundColor Cyan
|
||||
Remove-Item $backendDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Write-Host " Preserving source workers in scripts/workers." -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
# Delete llama.cpp backend
|
||||
|
||||
@@ -16,9 +16,9 @@ echo " Resetting Local AI Studio..."
|
||||
echo " ============================================================"
|
||||
echo ""
|
||||
|
||||
# Delete tools/node
|
||||
# Delete portable tools/runtime folder
|
||||
if [[ -d "$APP_DIR/tools" ]]; then
|
||||
echo " >> Removing portable tools/ node folder..."
|
||||
echo " >> Removing portable tools/runtime folder..."
|
||||
rm -rf "$APP_DIR/tools"
|
||||
fi
|
||||
|
||||
|
||||
+399
-24
@@ -41,10 +41,12 @@ const MAX_JSON_BODY_BYTES = 64 * 1024 * 1024;
|
||||
const SERVER_BUILD = "text-image-v1";
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const DIST = path.join(ROOT, "app", "dist");
|
||||
const TOOLS = path.join(ROOT, "app", "tools");
|
||||
const osPlatform = process.platform;
|
||||
const BACKEND_PATHS = {
|
||||
cuda: path.join(ROOT, "app", "backend", "win", "cuda", "sd-cuda.exe"),
|
||||
vulkan: path.join(ROOT, "app", "backend", "win", "vulkan", "sd-vulkan.exe"),
|
||||
cpu: path.join(ROOT, "app", "backend", "win", "cpu", "sd-cpu.exe"),
|
||||
mac: path.join(ROOT, "app", "backend", "mac", "sd"),
|
||||
linuxCpu: path.join(ROOT, "app", "backend", "linux", "cpu", "sd-server-cpu"),
|
||||
linuxVulkan: path.join(ROOT, "app", "backend", "linux", "vulkan", "sd-server-vulkan"),
|
||||
@@ -67,6 +69,8 @@ if (osPlatform === "win32") {
|
||||
|
||||
if (hasNvidia && fs.existsSync(BACKEND_PATHS.cuda)) {
|
||||
BACKEND_PATH = BACKEND_PATHS.cuda;
|
||||
} else if (fs.existsSync(BACKEND_PATHS.cpu)) {
|
||||
BACKEND_PATH = BACKEND_PATHS.cpu;
|
||||
} else {
|
||||
BACKEND_PATH = BACKEND_PATHS.vulkan;
|
||||
}
|
||||
@@ -224,6 +228,7 @@ const OPENVINO_NPU_MODELS = [
|
||||
|
||||
// ── Backend process state ─────────────────────────────────────────────────────
|
||||
let backendProc = null;
|
||||
let backendProcSeq = 0;
|
||||
let backendReady = false;
|
||||
let backendError = null;
|
||||
let openvinoProc = null;
|
||||
@@ -404,6 +409,24 @@ function getCpuUsagePercent() {
|
||||
return Math.max(0, Math.min(100, Math.round((1 - idleDelta / totalDelta) * 1000) / 10));
|
||||
}
|
||||
|
||||
function hasVulkanRuntime() {
|
||||
if (osPlatform !== "win32") return true;
|
||||
try {
|
||||
const sysPaths = [
|
||||
path.join(process.env.SystemRoot || "C:\\Windows", "System32", "vulkan-1.dll"),
|
||||
path.join(process.env.SystemRoot || "C:\\Windows", "SysWOW64", "vulkan-1.dll"),
|
||||
];
|
||||
for (const dllPath of sysPaths) {
|
||||
if (fs.existsSync(dllPath)) return true;
|
||||
}
|
||||
try {
|
||||
execSync("where vulkan-1.dll", { stdio: "ignore" });
|
||||
return true;
|
||||
} catch (_) {}
|
||||
} catch (_) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
function detectLinuxGpuFromSysfs() {
|
||||
try {
|
||||
const pciDir = "/sys/bus/pci/devices";
|
||||
@@ -776,6 +799,74 @@ function getGpuInfo() {
|
||||
|
||||
// ── NPU Detection ─────────────────────────────────────────────────────────────
|
||||
let cachedNpuInfo = null;
|
||||
let cachedHasNpuHardware = null;
|
||||
|
||||
function hasNpuHardware() {
|
||||
if (cachedHasNpuHardware !== null) return cachedHasNpuHardware;
|
||||
|
||||
if (osPlatform !== "win32" && osPlatform !== "linux") {
|
||||
cachedHasNpuHardware = false;
|
||||
return cachedHasNpuHardware;
|
||||
}
|
||||
|
||||
try {
|
||||
if (osPlatform === "win32") {
|
||||
const deviceResult = spawnSync("wmic", [
|
||||
"path", "win32_pnpentity", "where", "Name like '%NPU%' or Name like '%Intel%AI%' or Name like '%VPU%'", "get", "Name", "/value"
|
||||
], { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"] });
|
||||
if (deviceResult.status === 0 && deviceResult.stdout && /NPU|AI Boost|VPU/i.test(deviceResult.stdout)) {
|
||||
cachedHasNpuHardware = true;
|
||||
return cachedHasNpuHardware;
|
||||
}
|
||||
|
||||
const cpuResult = spawnSync("wmic", ["cpu", "get", "Name", "/value"], {
|
||||
encoding: "utf8",
|
||||
timeout: 10000,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
if (cpuResult.status === 0 && cpuResult.stdout) {
|
||||
const cpuName = cpuResult.stdout.toLowerCase();
|
||||
if (cpuName.includes("ultra") && (cpuName.match(/ultra\s*\d/) || cpuName.includes("core(tm) ultra"))) {
|
||||
cachedHasNpuHardware = true;
|
||||
return cachedHasNpuHardware;
|
||||
}
|
||||
}
|
||||
} else if (osPlatform === "linux") {
|
||||
const lspciResult = spawnSync("lspci", [], { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"] });
|
||||
if (lspciResult.status === 0 && lspciResult.stdout && lspciResult.stdout.toLowerCase().includes("npu")) {
|
||||
cachedHasNpuHardware = true;
|
||||
return cachedHasNpuHardware;
|
||||
}
|
||||
|
||||
try {
|
||||
const sysPci = fs.readdirSync("/sys/bus/pci/devices");
|
||||
for (const dev of sysPci) {
|
||||
const vendorPath = `/sys/bus/pci/devices/${dev}/vendor`;
|
||||
const classPath = `/sys/bus/pci/devices/${dev}/class`;
|
||||
if (!fs.existsSync(vendorPath) || !fs.existsSync(classPath)) continue;
|
||||
const vendor = fs.readFileSync(vendorPath, "utf8").trim();
|
||||
const devClass = fs.readFileSync(classPath, "utf8").trim();
|
||||
if (vendor === "0x8086" && (devClass.includes("1180") || devClass.includes("acce"))) {
|
||||
cachedHasNpuHardware = true;
|
||||
return cachedHasNpuHardware;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
const cpuInfo = fs.readFileSync("/proc/cpuinfo", "utf8").toLowerCase();
|
||||
if (cpuInfo.includes("ultra") && cpuInfo.includes("intel")) {
|
||||
cachedHasNpuHardware = true;
|
||||
return cachedHasNpuHardware;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
cachedHasNpuHardware = false;
|
||||
return cachedHasNpuHardware;
|
||||
}
|
||||
|
||||
function detectNpu() {
|
||||
if (cachedNpuInfo !== null) return cachedNpuInfo;
|
||||
cachedNpuInfo = { detected: false, vendor: null, name: null };
|
||||
@@ -1372,15 +1463,10 @@ 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-win", "Scripts", "python.exe"));
|
||||
candidates.push(path.join(ROOT, "app", "tools", "openvino-venv", "Scripts", "python.exe")); // legacy fallback
|
||||
candidates.push("C:\\tmp\\npu-test-venv\\Scripts\\python.exe");
|
||||
candidates.push("python");
|
||||
candidates.push(path.join(ROOT, "app", "tools", "python-win", "python.exe"));
|
||||
} else {
|
||||
candidates.push(path.join(ROOT, "app", "tools", "openvino-venv-linux", "bin", "python"));
|
||||
candidates.push(path.join(ROOT, "app", "tools", "openvino-venv", "bin", "python")); // legacy fallback
|
||||
candidates.push("python3");
|
||||
candidates.push("python");
|
||||
}
|
||||
return [...new Set(candidates)];
|
||||
}
|
||||
@@ -2432,6 +2518,7 @@ function getSetupPaths() {
|
||||
distIndex: path.join(DIST, "index.html"),
|
||||
cudaBackend: BACKEND_PATHS.cuda,
|
||||
vulkanBackend: BACKEND_PATHS.vulkan,
|
||||
cpuBackend: BACKEND_PATHS.cpu,
|
||||
models: MODELS,
|
||||
outputs: OUTPUTS,
|
||||
};
|
||||
@@ -2472,6 +2559,7 @@ async function getHealth() {
|
||||
if (osPlatform === "win32") {
|
||||
checks.push(getPathInfo("CUDA backend", paths.cudaBackend));
|
||||
checks.push(getPathInfo("Vulkan backend", paths.vulkanBackend));
|
||||
checks.push(getPathInfo("CPU backend", paths.cpuBackend));
|
||||
} else if (osPlatform === "darwin") {
|
||||
checks.push(getPathInfo("Mac backend", paths.macBackend));
|
||||
} else {
|
||||
@@ -2483,7 +2571,8 @@ async function getHealth() {
|
||||
let backendInstalled = false;
|
||||
if (osPlatform === "win32") {
|
||||
backendInstalled = checks.find((check) => check.label === "CUDA backend")?.exists ||
|
||||
checks.find((check) => check.label === "Vulkan backend")?.exists;
|
||||
checks.find((check) => check.label === "Vulkan backend")?.exists ||
|
||||
checks.find((check) => check.label === "CPU backend")?.exists;
|
||||
} else if (osPlatform === "darwin") {
|
||||
backendInstalled = checks.find((check) => check.label === "Mac backend")?.exists;
|
||||
} else {
|
||||
@@ -2495,7 +2584,7 @@ async function getHealth() {
|
||||
}
|
||||
|
||||
const criticalOk = checks
|
||||
.filter((check) => !["CUDA backend", "Vulkan backend", "Linux CPU backend", "Linux Vulkan backend", "Linux ROCm backend", "Mac backend"].includes(check.label))
|
||||
.filter((check) => !["CUDA backend", "Vulkan backend", "CPU backend", "Linux CPU backend", "Linux Vulkan backend", "Linux ROCm backend", "Mac backend"].includes(check.label))
|
||||
.every((check) => check.ok) && backendInstalled;
|
||||
|
||||
const ports = {
|
||||
@@ -2508,7 +2597,7 @@ async function getHealth() {
|
||||
ports.backend.ok = true;
|
||||
|
||||
const issues = checks
|
||||
.filter((check) => !check.ok && !["CUDA backend", "Vulkan backend", "Linux CPU backend", "Linux Vulkan backend", "Linux ROCm backend", "Mac backend"].includes(check.label))
|
||||
.filter((check) => !check.ok && !["CUDA backend", "Vulkan backend", "CPU backend", "Linux CPU backend", "Linux Vulkan backend", "Linux ROCm backend", "Mac backend"].includes(check.label))
|
||||
.map((check) => `${check.label} is missing or not writable.`);
|
||||
if (!backendInstalled) {
|
||||
issues.push(`No ${osPlatform === "win32" ? "Windows" : osPlatform === "darwin" ? "macOS" : "Linux"} backend binary is installed.`);
|
||||
@@ -2645,6 +2734,9 @@ function isRunningInWSL() {
|
||||
}
|
||||
|
||||
function getVulkanUnavailableReason() {
|
||||
if (osPlatform === "win32" && !hasVulkanRuntime()) {
|
||||
return "The Vulkan runtime (vulkan-1.dll) is missing. Install or update your GPU vendor driver with Vulkan support, then run setup again so the Vulkan backend folder is repaired.";
|
||||
}
|
||||
if (osPlatform === "linux" && isRunningInWSL()) {
|
||||
const gpuName = getGpuInfo().name;
|
||||
const lowerGpu = gpuName.toLowerCase();
|
||||
@@ -2764,13 +2856,15 @@ function getBackendOptions() {
|
||||
);
|
||||
const vulkanInstalled = (osPlatform === "win32" && fs.existsSync(BACKEND_PATHS.vulkan)) ||
|
||||
(osPlatform === "linux" && fs.existsSync(BACKEND_PATHS.linuxVulkan));
|
||||
const vulkanAvailable = vulkanInstalled && backendAccepts(
|
||||
const vulkanRuntimeAvailable = osPlatform !== "win32" || hasVulkanRuntime();
|
||||
const vulkanAvailable = vulkanInstalled && vulkanRuntimeAvailable && backendAccepts(
|
||||
osPlatform === "win32" ? BACKEND_PATHS.vulkan : BACKEND_PATHS.linuxVulkan,
|
||||
"vulkan"
|
||||
);
|
||||
const rocmInstalled = osPlatform === "linux" && fs.existsSync(BACKEND_PATHS.linuxRocm);
|
||||
const rocmAvailable = rocmInstalled && hasAmdGpu() && backendAccepts(BACKEND_PATHS.linuxRocm, "rocm");
|
||||
const cpuInstalled = osPlatform === "linux" && fs.existsSync(BACKEND_PATHS.linuxCpu);
|
||||
const cpuInstalled = (osPlatform === "linux" && fs.existsSync(BACKEND_PATHS.linuxCpu)) ||
|
||||
(osPlatform === "win32" && fs.existsSync(BACKEND_PATHS.cpu));
|
||||
const metalInstalled = osPlatform === "darwin" && fs.existsSync(BACKEND_PATHS.mac);
|
||||
const metalAvailable = metalInstalled;
|
||||
const coremlNpu = getCoreMLNpuInfo();
|
||||
@@ -2778,7 +2872,8 @@ function getBackendOptions() {
|
||||
const openvinoModels = getOpenVinoModelInfo();
|
||||
const openvinoNpuAvailable = openvinoNpu.supported && openvinoModels.some((model) => model.installed);
|
||||
|
||||
const options = [{ id: "cpu", label: "CPU", available: true }];
|
||||
const options = [];
|
||||
if (cpuInstalled) options.push({ id: "cpu", label: "CPU", available: true });
|
||||
if (metalAvailable) options.push({ id: "metal", label: "Metal GPU", available: true });
|
||||
if (coremlNpu.supported && coremlNpu.ready) options.push({ id: "apple-npu", label: "Apple Neural Engine (NPU)", available: true });
|
||||
if (vulkanAvailable) options.push({ id: "vulkan", label: "Vulkan GPU", available: true });
|
||||
@@ -2802,9 +2897,18 @@ function getBackendOptions() {
|
||||
if (coremlNpu.supported && !coremlNpu.ready) {
|
||||
unavailable.push({ id: "apple-npu", label: "Apple Neural Engine (NPU)", reason: coremlNpu.reason });
|
||||
}
|
||||
if (!cpuInstalled && (osPlatform === "win32" || osPlatform === "linux")) {
|
||||
unavailable.push({
|
||||
id: "cpu",
|
||||
label: "CPU",
|
||||
reason: osPlatform === "win32"
|
||||
? "Windows CPU backend is not installed. Run scripts/setup/setup.ps1 to install app/backend/win/cpu."
|
||||
: "Linux CPU backend is not installed. Run setup again to install app/backend/linux/cpu.",
|
||||
});
|
||||
}
|
||||
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")) {
|
||||
} else if (!openvinoNpu.supported && hasNpuHardware() && (osPlatform === "win32" || osPlatform === "linux")) {
|
||||
unavailable.push({ id: "openvino-npu", label: "NPU (OpenVINO)", reason: openvinoNpu.reason });
|
||||
}
|
||||
|
||||
@@ -2908,6 +3012,10 @@ function selectBackendPath(useGpu, backendType = "auto", modelPath = "") {
|
||||
const resolvedType = resolveBackendType(useGpu, backendType, modelPath);
|
||||
if (osPlatform === "win32") {
|
||||
if (resolvedType === "cuda" && fs.existsSync(BACKEND_PATHS.cuda)) return BACKEND_PATHS.cuda;
|
||||
if (resolvedType === "cpu" && fs.existsSync(BACKEND_PATHS.cpu)) return BACKEND_PATHS.cpu;
|
||||
if (resolvedType === "vulkan" && fs.existsSync(BACKEND_PATHS.vulkan)) return BACKEND_PATHS.vulkan;
|
||||
if (fs.existsSync(BACKEND_PATHS.cuda)) return BACKEND_PATHS.cuda;
|
||||
if (fs.existsSync(BACKEND_PATHS.cpu)) return BACKEND_PATHS.cpu;
|
||||
if (fs.existsSync(BACKEND_PATHS.vulkan)) return BACKEND_PATHS.vulkan;
|
||||
return BACKEND_PATH;
|
||||
}
|
||||
@@ -3347,7 +3455,11 @@ function getOpenVinoResolution(settings = {}) {
|
||||
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);
|
||||
const requestedModel = settings.model ? findOpenVinoModel(settings.model) : null;
|
||||
if (settings.model && !requestedModel) {
|
||||
throw new Error("OpenVINO NPU requires a downloaded OpenVINO model. Standard .safetensors/.ckpt weights must use Vulkan, CUDA, or CPU.");
|
||||
}
|
||||
const model = requestedModel || 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.");
|
||||
}
|
||||
@@ -3512,18 +3624,19 @@ function getDefaultModel() {
|
||||
function killBackend() {
|
||||
return new Promise(resolve => {
|
||||
resetGenerationState();
|
||||
if (!backendProc) {
|
||||
const proc = backendProc;
|
||||
if (!proc) {
|
||||
backendUnloadState = { active: false, phase: "", progress: 0 };
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
backendUnloadState = { active: true, phase: "Stopping backend process...", progress: 10 };
|
||||
backendReady = false;
|
||||
backendProc.kill("SIGTERM");
|
||||
try { proc.kill("SIGTERM"); } catch (_) {}
|
||||
backendUnloadState = { active: true, phase: "Waiting for process exit...", progress: 50 };
|
||||
setTimeout(() => {
|
||||
try { backendProc.kill("SIGKILL"); } catch (_) {}
|
||||
backendProc = null;
|
||||
try { proc.kill("SIGKILL"); } catch (_) {}
|
||||
if (backendProc === proc) backendProc = null;
|
||||
backendUnloadState = { active: false, phase: "Backend unloaded", progress: 100 };
|
||||
resolve();
|
||||
}, 2000);
|
||||
@@ -3958,6 +4071,14 @@ async function startBackend(settings = {}) {
|
||||
currentSettings.backendType = resolvedBackendType;
|
||||
currentSettings.useGpu = resolvedBackendType !== "cpu";
|
||||
const backendPath = selectBackendPath(currentSettings.useGpu, currentSettings.backendType, currentSettings.model);
|
||||
if (!backendPath) {
|
||||
const setupHint = resolvedBackendType === "apple-npu"
|
||||
? "CoreML Python environment is not set up. Run scripts/setup/setup-coreml-npu.sh first."
|
||||
: resolvedBackendType === "cpu" && osPlatform === "win32"
|
||||
? "Windows CPU backend is not installed. Run scripts/setup.ps1 again so app/backend/win/cpu is created."
|
||||
: "No compatible backend executable was found.";
|
||||
throw new Error(setupHint);
|
||||
}
|
||||
const backendMode = getBackendMode(backendPath, currentSettings.useGpu, currentSettings.backendType);
|
||||
currentSettings.backendMode = backendMode;
|
||||
currentSettings.backendBinary = path.basename(backendPath);
|
||||
@@ -3987,7 +4108,7 @@ async function startBackend(settings = {}) {
|
||||
|
||||
if (requestedBackend === "apple-npu") {
|
||||
args = [
|
||||
path.join(ROOT, "app", "backend", "mac", "coreml_server.py"),
|
||||
path.join(ROOT, "scripts", "workers", "coreml_server.py"),
|
||||
"--listen-port", String(PORT_BACKEND),
|
||||
"--model", currentSettings.model,
|
||||
"--steps", String(currentSettings.steps),
|
||||
@@ -4072,10 +4193,12 @@ async function startBackend(settings = {}) {
|
||||
console.log(" [backend] Starting:", path.basename(backendPath), args.join(" "));
|
||||
backendReady = false;
|
||||
|
||||
backendProc = spawn(backendPath, args, { stdio: "pipe", env: spawnEnv });
|
||||
const procSeq = ++backendProcSeq;
|
||||
const proc = spawn(backendPath, args, { stdio: "pipe", env: spawnEnv });
|
||||
backendProc = proc;
|
||||
startBackendReadyPoll();
|
||||
|
||||
backendProc.stdout.on("data", d => {
|
||||
proc.stdout.on("data", d => {
|
||||
const output = d.toString();
|
||||
process.stdout.write(" [sd] " + output);
|
||||
const cleanOutput = stripAnsi(output);
|
||||
@@ -4155,7 +4278,7 @@ async function startBackend(settings = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
backendProc.stderr.on("data", d => {
|
||||
proc.stderr.on("data", d => {
|
||||
const output = d.toString();
|
||||
process.stderr.write(" [sd-err] " + output);
|
||||
const cleanOutput = stripAnsi(output);
|
||||
@@ -4194,10 +4317,14 @@ async function startBackend(settings = {}) {
|
||||
}
|
||||
}
|
||||
});
|
||||
backendProc.on("exit", code => {
|
||||
proc.on("exit", (code, signal) => {
|
||||
if (backendProc !== proc) {
|
||||
console.log(" [backend] stale process exited with code", code, signal ? `(signal ${signal})` : "", `(process ${procSeq})`);
|
||||
return;
|
||||
}
|
||||
backendReady = false;
|
||||
backendProc = null;
|
||||
console.log(" [backend] exited with code", code);
|
||||
console.log(" [backend] exited with code", code, signal ? `(signal ${signal})` : "", `(process ${procSeq})`);
|
||||
if (code !== null && code !== 0) {
|
||||
if (!backendError) {
|
||||
backendError = describeBackendExitCode(code, currentSettings.backendBinary || BACKEND_PATH);
|
||||
@@ -4276,6 +4403,221 @@ function startOpenVinoModelDownload(modelId) {
|
||||
});
|
||||
}
|
||||
|
||||
const IMAGE_BACKEND_DOWNLOADS = {
|
||||
"cpu": {
|
||||
id: "cpu",
|
||||
label: "CPU",
|
||||
url: "https://github.com/leejet/stable-diffusion.cpp/releases/download/master-669-2d40a8b/sd-master-2d40a8b-bin-win-avx2-x64.zip",
|
||||
destDir: path.join(ROOT, "app", "backend", "win", "cpu"),
|
||||
exeName: "sd-cpu.exe",
|
||||
requiredDll: "stable-diffusion.dll",
|
||||
},
|
||||
"vulkan": {
|
||||
id: "vulkan",
|
||||
label: "Vulkan GPU",
|
||||
url: "https://github.com/leejet/stable-diffusion.cpp/releases/download/master-669-2d40a8b/sd-master-2d40a8b-bin-win-vulkan-x64.zip",
|
||||
destDir: path.join(ROOT, "app", "backend", "win", "vulkan"),
|
||||
exeName: "sd-vulkan.exe",
|
||||
requiredDll: "stable-diffusion.dll",
|
||||
},
|
||||
"cuda": {
|
||||
id: "cuda",
|
||||
label: "CUDA GPU",
|
||||
url: "https://github.com/leejet/stable-diffusion.cpp/releases/download/master-669-2d40a8b/sd-master-2d40a8b-bin-win-cuda12-x64.zip",
|
||||
destDir: path.join(ROOT, "app", "backend", "win", "cuda"),
|
||||
exeName: "sd-cuda.exe",
|
||||
requiredDll: "stable-diffusion.dll",
|
||||
},
|
||||
};
|
||||
|
||||
function listFilesRecursive(dir) {
|
||||
const out = [];
|
||||
if (!fs.existsSync(dir)) return out;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...listFilesRecursive(full));
|
||||
} else if (entry.isFile()) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function installImageBackendArchive(zipPath, backend) {
|
||||
const tempDir = path.join(TOOLS, `image-backend-${backend.id}-extract-${Date.now()}`);
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
fs.mkdirSync(backend.destDir, { recursive: true });
|
||||
|
||||
const tar = spawnSync("tar.exe", ["-xf", zipPath, "-C", tempDir], { encoding: "utf8" });
|
||||
if (tar.status !== 0) {
|
||||
throw new Error((tar.stderr || tar.stdout || "Could not extract backend archive.").trim());
|
||||
}
|
||||
|
||||
const files = listFilesRecursive(tempDir);
|
||||
const exeSource = files.find((file) => ["sd-server.exe", "sd.exe"].includes(path.basename(file).toLowerCase()));
|
||||
const dllSource = files.find((file) => path.basename(file).toLowerCase() === backend.requiredDll.toLowerCase());
|
||||
if (!exeSource || !dllSource) {
|
||||
throw new Error("Backend archive did not contain the expected stable-diffusion executable and DLL.");
|
||||
}
|
||||
|
||||
fs.copyFileSync(exeSource, path.join(backend.destDir, backend.exeName));
|
||||
fs.copyFileSync(dllSource, path.join(backend.destDir, backend.requiredDll));
|
||||
for (const file of files) {
|
||||
const ext = path.extname(file).toLowerCase();
|
||||
if (ext !== ".dll" && ext !== ".exe") continue;
|
||||
if (file === exeSource) continue;
|
||||
fs.copyFileSync(file, path.join(backend.destDir, path.basename(file)));
|
||||
}
|
||||
|
||||
if (!fs.existsSync(path.join(backend.destDir, backend.exeName)) || !fs.existsSync(path.join(backend.destDir, backend.requiredDll))) {
|
||||
throw new Error(`Backend install did not create ${backend.exeName} and ${backend.requiredDll}.`);
|
||||
}
|
||||
cachedBackendOptions = null;
|
||||
} finally {
|
||||
try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
function startImageBackendDownload(backendId, redirectCount = 0, redirectUrl = "") {
|
||||
if (downloadState.active && redirectCount === 0) {
|
||||
throw new Error("Another download is already active.");
|
||||
}
|
||||
if (osPlatform !== "win32") {
|
||||
throw new Error("In-app backend downloads are currently available for Windows image backends.");
|
||||
}
|
||||
const backend = IMAGE_BACKEND_DOWNLOADS[String(backendId || "").toLowerCase()];
|
||||
if (!backend) {
|
||||
throw new Error("This backend cannot be installed from the app yet. Use the platform setup script.");
|
||||
}
|
||||
|
||||
fs.mkdirSync(TOOLS, { recursive: true });
|
||||
const url = redirectUrl || backend.url;
|
||||
const filename = `${backend.id}-backend.zip`;
|
||||
const destPath = path.join(TOOLS, filename);
|
||||
const tempPath = `${destPath}.part`;
|
||||
if (redirectCount === 0) {
|
||||
try { fs.unlinkSync(tempPath); } catch (_) {}
|
||||
downloadState = {
|
||||
active: true,
|
||||
filename: backend.label,
|
||||
progress: 0,
|
||||
speed: "0 MB/s",
|
||||
eta: 0,
|
||||
totalBytes: 0,
|
||||
downloadedBytes: 0,
|
||||
error: null,
|
||||
kind: "backend",
|
||||
backendId: backend.id,
|
||||
};
|
||||
}
|
||||
|
||||
console.log(` [backend-download] Downloading ${backend.label} backend from ${url}`);
|
||||
let downloadFinalized = false;
|
||||
const fileStream = fs.createWriteStream(tempPath);
|
||||
const failDownload = (message, err = null) => {
|
||||
if (downloadFinalized) return;
|
||||
downloadFinalized = true;
|
||||
downloadState.active = false;
|
||||
downloadState.error = message;
|
||||
if (err) console.error(" [backend-download]", message, err);
|
||||
else console.error(" [backend-download] Failed:", message);
|
||||
try { fileStream.close(); } catch (_) {}
|
||||
try { fs.unlinkSync(tempPath); } catch (_) {}
|
||||
activeDownload = null;
|
||||
};
|
||||
|
||||
fileStream.on("error", (err) => failDownload(`Could not write ${filename}: ${err.message}`, err));
|
||||
const client = url.startsWith("https") ? https : http;
|
||||
const request = client.get(url, {
|
||||
headers: {
|
||||
"User-Agent": "Local-AI-Image-Generator/1.0 (+https://github.com/techjarves/Local-AI-Image-Generator)",
|
||||
"Accept": "application/zip, application/octet-stream, */*",
|
||||
},
|
||||
}, (response) => {
|
||||
activeDownload = { request, fileStream, destPath, tempPath };
|
||||
if ([301, 302, 303, 307, 308].includes(response.statusCode)) {
|
||||
const nextUrl = response.headers.location ? new URL(response.headers.location, url).toString() : "";
|
||||
if (!nextUrl) return failDownload("Redirect response did not include a Location header.");
|
||||
if (redirectCount > 10) return failDownload("Too many redirects.");
|
||||
downloadState.speed = "Following redirect";
|
||||
request.removeAllListeners("error");
|
||||
request.destroy();
|
||||
response.resume();
|
||||
fileStream.close();
|
||||
try { fs.unlinkSync(tempPath); } catch (_) {}
|
||||
activeDownload = null;
|
||||
startImageBackendDownload(backend.id, redirectCount + 1, nextUrl);
|
||||
return;
|
||||
}
|
||||
if (response.statusCode !== 200) {
|
||||
return failDownload(describeDownloadHttpError(response.statusCode, url, response.headers));
|
||||
}
|
||||
|
||||
const totalBytes = parseInt(response.headers["content-length"], 10) || 0;
|
||||
downloadState.totalBytes = totalBytes;
|
||||
let downloadedBytes = 0;
|
||||
let lastTime = Date.now();
|
||||
let lastDownloaded = 0;
|
||||
|
||||
response.on("data", (chunk) => {
|
||||
downloadedBytes += chunk.length;
|
||||
downloadState.downloadedBytes = downloadedBytes;
|
||||
fileStream.write(chunk);
|
||||
const now = Date.now();
|
||||
const elapsed = (now - lastTime) / 1000;
|
||||
if (elapsed >= 0.5) {
|
||||
const chunkSpeed = (downloadedBytes - lastDownloaded) / elapsed;
|
||||
lastDownloaded = downloadedBytes;
|
||||
lastTime = now;
|
||||
downloadState.speed = `${(chunkSpeed / (1024 * 1024)).toFixed(1)} MB/s`;
|
||||
if (totalBytes > 0) {
|
||||
downloadState.progress = Math.round((downloadedBytes / totalBytes) * 100);
|
||||
downloadState.eta = Math.round((totalBytes - downloadedBytes) / Math.max(1, chunkSpeed));
|
||||
} else {
|
||||
downloadState.progress = -1;
|
||||
downloadState.eta = -1;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
response.on("aborted", () => failDownload(`Download interrupted before ${backend.label} backend finished.`));
|
||||
response.on("error", (err) => failDownload(`Download stream failed before ${backend.label} backend finished.`, err));
|
||||
response.on("end", () => {
|
||||
if (downloadFinalized) return;
|
||||
if (totalBytes > 0 && downloadedBytes !== totalBytes) {
|
||||
failDownload(`Download incomplete for ${backend.label}: received ${formatBytes(downloadedBytes)} of ${formatBytes(totalBytes)}.`);
|
||||
return;
|
||||
}
|
||||
fileStream.end(() => {
|
||||
try {
|
||||
try { fs.unlinkSync(destPath); } catch (_) {}
|
||||
fs.renameSync(tempPath, destPath);
|
||||
downloadState.speed = "Installing";
|
||||
downloadState.progress = 95;
|
||||
installImageBackendArchive(destPath, backend);
|
||||
try { fs.unlinkSync(destPath); } catch (_) {}
|
||||
downloadFinalized = true;
|
||||
downloadState.active = false;
|
||||
downloadState.progress = 100;
|
||||
downloadState.downloadedBytes = downloadedBytes;
|
||||
downloadState.error = null;
|
||||
downloadState.speed = "Complete";
|
||||
activeDownload = null;
|
||||
console.log(` [backend-download] Installed ${backend.label} backend`);
|
||||
} catch (err) {
|
||||
failDownload(`Could not install ${backend.label} backend: ${err.message}`, err);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
request.on("error", (err) => failDownload(err.message, err));
|
||||
activeDownload = { request, fileStream, destPath, tempPath };
|
||||
}
|
||||
|
||||
// ── Generation State (Real-time progress parser) ─────────────────────────────
|
||||
let generationState = {
|
||||
active: false,
|
||||
@@ -5903,6 +6245,20 @@ async function getLlmfitRecommendations(useCase = "chat", limit = 10) {
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
// POST /api/download-backend
|
||||
if (req.url === "/api/download-backend" && req.method === "POST") {
|
||||
const body = await readJsonBody(req, res);
|
||||
if (!body) return;
|
||||
if (downloadState.active) return json(res, 409, { ok: false, error: "Another download is already active." });
|
||||
try {
|
||||
const backendId = String(body.backend_id || body.backendId || body.id || "");
|
||||
startImageBackendDownload(backendId);
|
||||
return json(res, 200, { ok: true, message: "Backend download started", backendId });
|
||||
} catch (err) {
|
||||
return json(res, 400, { ok: false, error: err.message || String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/download-model
|
||||
if (req.url === "/api/download-model" && req.method === "POST") {
|
||||
const body = await readJsonBody(req, res);
|
||||
@@ -5958,6 +6314,25 @@ async function getLlmfitRecommendations(useCase = "chat", limit = 10) {
|
||||
|
||||
// GET /api/download-progress
|
||||
if (req.url === "/api/download-progress" && req.method === "GET") {
|
||||
if (!downloadState.active && downloadState.kind === "backend" && downloadState.backendId) {
|
||||
const backend = IMAGE_BACKEND_DOWNLOADS[downloadState.backendId];
|
||||
const installed = backend &&
|
||||
fs.existsSync(path.join(backend.destDir, backend.exeName)) &&
|
||||
fs.existsSync(path.join(backend.destDir, backend.requiredDll));
|
||||
if (installed && !downloadState.error) {
|
||||
cachedBackendOptions = null;
|
||||
downloadState = {
|
||||
active: false,
|
||||
filename: "",
|
||||
progress: 0,
|
||||
speed: "0 MB/s",
|
||||
eta: 0,
|
||||
totalBytes: 0,
|
||||
downloadedBytes: 0,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
return json(res, 200, downloadState);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,37 @@ ROOT_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
|
||||
VENV_DIR="$ROOT_DIR/app/backend/mac/coreml_venv"
|
||||
PYTHON_BIN="$VENV_DIR/bin/python"
|
||||
|
||||
is_coreml_python_supported() {
|
||||
"$1" - <<'PY' >/dev/null 2>&1
|
||||
import sys
|
||||
raise SystemExit(0 if (3, 9) <= sys.version_info[:2] < (3, 12) else 1)
|
||||
PY
|
||||
}
|
||||
|
||||
find_coreml_python() {
|
||||
local candidate
|
||||
if [[ -n "${COREML_SETUP_PYTHON:-}" ]]; then
|
||||
if is_coreml_python_supported "$COREML_SETUP_PYTHON"; then
|
||||
echo "$COREML_SETUP_PYTHON"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
for candidate in python3.11 python3.10 python3.9 python3; do
|
||||
if command -v "$candidate" >/dev/null 2>&1 && is_coreml_python_supported "$candidate"; then
|
||||
command -v "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
coreml_venv_ready() {
|
||||
[[ -x "$PYTHON_BIN" ]] && "$PYTHON_BIN" -c "import sys; assert (3, 9) <= sys.version_info[:2] < (3, 12); import python_coreml_stable_diffusion, diffusers, transformers" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo " ============================================================"
|
||||
echo " Local AI Image Generator - Apple Silicon CoreML NPU Setup"
|
||||
@@ -27,14 +58,22 @@ if [[ "$(uname -m)" != "arm64" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
echo " [ERROR] python3 is required. Install Python 3 first (via Homebrew or Xcode)." >&2
|
||||
SETUP_PYTHON="$(find_coreml_python || true)"
|
||||
if [[ -z "$SETUP_PYTHON" ]]; then
|
||||
echo " [ERROR] CoreML setup requires Python 3.9, 3.10, or 3.11." >&2
|
||||
echo " Apple's ml-stable-diffusion dependency stack is not compatible with Python 3.12+." >&2
|
||||
echo " Install python@3.11 with Homebrew, or set COREML_SETUP_PYTHON to a supported Python." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -x "$PYTHON_BIN" ]] && ! coreml_venv_ready; then
|
||||
echo " Existing CoreML environment is incomplete or uses Python 3.12+. Recreating it..."
|
||||
rm -rf "$VENV_DIR"
|
||||
fi
|
||||
|
||||
if [[ ! -x "$PYTHON_BIN" ]]; then
|
||||
echo " Creating Python environment: $VENV_DIR"
|
||||
if ! python3 -m venv "$VENV_DIR"; then
|
||||
if ! "$SETUP_PYTHON" -m venv "$VENV_DIR"; then
|
||||
echo " [ERROR] Could not create the virtual environment."
|
||||
exit 1
|
||||
fi
|
||||
@@ -42,7 +81,7 @@ fi
|
||||
|
||||
echo " Installing CoreML Stable Diffusion dependencies (numpy, coremltools, diffusers)..."
|
||||
"$PYTHON_BIN" -m pip install --upgrade pip
|
||||
"$PYTHON_BIN" -m pip install numpy coremltools diffusers transformers huggingface-hub pillow
|
||||
"$PYTHON_BIN" -m pip install "numpy<1.24" coremltools diffusers transformers huggingface-hub pillow
|
||||
|
||||
echo " Installing Apple's python-coreml-stable-diffusion package..."
|
||||
if ! "$PYTHON_BIN" -m pip install "git+https://github.com/apple/ml-stable-diffusion.git"; then
|
||||
|
||||
@@ -1,16 +1,105 @@
|
||||
# Optional OpenVINO NPU setup for Intel AI Boost systems.
|
||||
# This does not modify the existing stable-diffusion.cpp backends.
|
||||
# Installs a portable Python runtime under app/tools/python-win.
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$rootDir = Split-Path -Parent (Split-Path -Parent $scriptDir)
|
||||
$venvDir = Join-Path $rootDir "app\tools\openvino-venv-win"
|
||||
$pythonExe = Join-Path $venvDir "Scripts\python.exe"
|
||||
$appDir = Join-Path $rootDir "app"
|
||||
$toolsDir = Join-Path $appDir "tools"
|
||||
$pythonDir = Join-Path $toolsDir "python-win"
|
||||
$pythonExe = Join-Path $pythonDir "python.exe"
|
||||
$pythonZip = Join-Path $toolsDir "python-win.zip"
|
||||
$getPip = Join-Path $toolsDir "get-pip.py"
|
||||
|
||||
$pythonVersion = "3.11.9"
|
||||
$pythonZipUrl = "https://www.python.org/ftp/python/$pythonVersion/python-$pythonVersion-embed-amd64.zip"
|
||||
$getPipUrl = "https://bootstrap.pypa.io/get-pip.py"
|
||||
|
||||
function Enable-Tls12 {
|
||||
try {
|
||||
$tls12 = [Enum]::ToObject([Net.SecurityProtocolType], 3072)
|
||||
[Net.ServicePointManager]::SecurityProtocol =
|
||||
[Net.ServicePointManager]::SecurityProtocol -bor $tls12
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function Download-File {
|
||||
param([string]$Url, [string]$Dest, [string]$Label)
|
||||
Write-Host " Downloading $Label..."
|
||||
Enable-Tls12
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $Dest) | Out-Null
|
||||
$client = New-Object System.Net.WebClient
|
||||
$client.Headers.Add("User-Agent", "Local-AI-Image-Generator-Setup")
|
||||
$client.DownloadFile($Url, $Dest)
|
||||
}
|
||||
|
||||
function Expand-Zip {
|
||||
param([string]$ZipPath, [string]$Destination)
|
||||
if (Test-Path $Destination) {
|
||||
Remove-Item $Destination -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path $Destination | Out-Null
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
[System.IO.Compression.ZipFile]::ExtractToDirectory($ZipPath, $Destination)
|
||||
}
|
||||
|
||||
function Enable-EmbeddedPythonSitePackages {
|
||||
$pth = Get-ChildItem $pythonDir -Filter "python*._pth" | Select-Object -First 1
|
||||
if (-not $pth) { throw "Embedded Python ._pth file not found in $pythonDir" }
|
||||
|
||||
$content = Get-Content $pth.FullName
|
||||
$hasSitePackages = $content | Where-Object { $_.Trim() -eq "Lib\site-packages" } | Select-Object -First 1
|
||||
$addedSitePackages = $false
|
||||
$next = foreach ($line in $content) {
|
||||
if (-not $hasSitePackages -and -not $addedSitePackages -and $line.Trim() -eq "import site") {
|
||||
"Lib\site-packages"
|
||||
$addedSitePackages = $true
|
||||
}
|
||||
if ($line.Trim() -eq "#import site") {
|
||||
if (-not $hasSitePackages -and -not $addedSitePackages) {
|
||||
"Lib\site-packages"
|
||||
$addedSitePackages = $true
|
||||
}
|
||||
"import site"
|
||||
} else {
|
||||
$line
|
||||
}
|
||||
}
|
||||
Set-Content -Path $pth.FullName -Value $next -Encoding ASCII
|
||||
}
|
||||
|
||||
function Ensure-PortablePython {
|
||||
if (Test-Path $pythonExe) {
|
||||
try {
|
||||
& $pythonExe -c "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 11) else 1)"
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host " Portable Python already ready: $pythonExe"
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
|
||||
Write-Host " Existing portable Python is invalid. Reinstalling..."
|
||||
Remove-Item $pythonDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Download-File -Url $pythonZipUrl -Dest $pythonZip -Label "Portable Python $pythonVersion"
|
||||
Expand-Zip -ZipPath $pythonZip -Destination $pythonDir
|
||||
Remove-Item $pythonZip -Force -ErrorAction SilentlyContinue
|
||||
Enable-EmbeddedPythonSitePackages
|
||||
|
||||
Download-File -Url $getPipUrl -Dest $getPip -Label "pip bootstrap"
|
||||
& $pythonExe $getPip --no-warn-script-location
|
||||
if ($LASTEXITCODE -ne 0) { throw "pip bootstrap failed for portable Python." }
|
||||
Remove-Item $getPip -Force -ErrorAction SilentlyContinue
|
||||
|
||||
& $pythonExe -m pip --version | Out-Host
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ============================================================"
|
||||
Write-Host " Local AI Image Generator - OpenVINO NPU Setup"
|
||||
Write-Host " Portable Python runtime: app/tools/python-win"
|
||||
Write-Host " ============================================================"
|
||||
Write-Host ""
|
||||
|
||||
@@ -25,22 +114,23 @@ if (-not $npu) {
|
||||
|
||||
Write-Host " Detected: $($npu.Name)"
|
||||
|
||||
if (-not (Test-Path $pythonExe)) {
|
||||
Write-Host " Creating Python environment: $venvDir"
|
||||
python -m venv $venvDir
|
||||
}
|
||||
Ensure-PortablePython
|
||||
|
||||
Write-Host " Installing OpenVINO GenAI runtime..."
|
||||
& $pythonExe -m pip install --upgrade pip
|
||||
& $pythonExe -m pip install openvino openvino-genai pillow huggingface-hub
|
||||
Write-Host " Installing OpenVINO GenAI runtime into portable Python..."
|
||||
& $pythonExe -m pip install --upgrade pip --no-warn-script-location --progress-bar off
|
||||
if ($LASTEXITCODE -ne 0) { throw "pip upgrade failed." }
|
||||
& $pythonExe -m pip install openvino openvino-genai pillow huggingface-hub --no-warn-script-location --progress-bar off
|
||||
if ($LASTEXITCODE -ne 0) { throw "OpenVINO package install failed." }
|
||||
|
||||
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)"
|
||||
if ($LASTEXITCODE -ne 0) { throw "OpenVINO installed, but NPU was not available to the portable runtime." }
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " ============================================================"
|
||||
Write-Host " OpenVINO NPU setup complete."
|
||||
Write-Host " Portable Python: $pythonExe"
|
||||
Write-Host " Restart windows.bat, then download an OpenVINO NPU model."
|
||||
Write-Host " ============================================================"
|
||||
Write-Host ""
|
||||
@@ -39,7 +39,7 @@ $oldPath = $env:PATH
|
||||
try {
|
||||
$env:PATH = "$nodeDir;$env:PATH"
|
||||
Print-Info "Installing kokoro-js into app/tts-runtime..."
|
||||
& $npmCmd install --prefer-offline 2>&1
|
||||
& $npmCmd install --prefer-offline --loglevel=error
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Print-Fail "kokoro-js install failed."
|
||||
exit 1
|
||||
|
||||
+70
-5
@@ -158,7 +158,7 @@ function Expand-WithProgress {
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
Print-Header
|
||||
|
||||
$steps = 7
|
||||
$steps = 8
|
||||
|
||||
# ── Step 1: Portable Node.js ──────────────────────────────────────────────────
|
||||
Print-Step 1 $steps "Setting up portable Node.js (app/tools/node-win/)"
|
||||
@@ -242,6 +242,56 @@ if (-not $hasNvidia) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
Print-Step 2 $steps "Setting up stable-diffusion.cpp CPU backend (app/backend/win/cpu/)"
|
||||
$cpuBackendDest = Join-Path $appDir "backend\win\cpu"
|
||||
$cpuBackendExe = Join-Path $cpuBackendDest "sd-cpu.exe"
|
||||
$cpuBackendDll = Join-Path $cpuBackendDest "stable-diffusion.dll"
|
||||
|
||||
if ((Test-Path $cpuBackendExe) -and (Test-Path $cpuBackendDll)) {
|
||||
Print-OK "CPU backend binaries already ready."
|
||||
} else {
|
||||
$cpuBackendZip = Join-Path $toolsDir "sd-cpu.zip"
|
||||
New-Item -ItemType Directory -Force -Path $toolsDir | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path $cpuBackendDest | Out-Null
|
||||
|
||||
$ok = Invoke-RichDownload `
|
||||
-Url "https://github.com/leejet/stable-diffusion.cpp/releases/download/master-669-2d40a8b/sd-master-2d40a8b-bin-win-avx2-x64.zip" `
|
||||
-Dest $cpuBackendZip `
|
||||
-Label "stable-diffusion.cpp CPU Backend (Windows x64 AVX2)"
|
||||
|
||||
if (-not $ok) { Print-Fail "Cannot download CPU backend binaries."; Read-Host; exit 1 }
|
||||
|
||||
$tempExt = Join-Path $toolsDir "sd-cpu-temp"
|
||||
Expand-WithProgress -ZipPath $cpuBackendZip -Destination $tempExt -Label "CPU Backend"
|
||||
Remove-Item $cpuBackendZip -Force
|
||||
|
||||
if (Test-Path $tempExt) {
|
||||
$extractedExe = Join-Path $tempExt "bin\sd-server.exe"
|
||||
if (-not (Test-Path $extractedExe)) { $extractedExe = Join-Path $tempExt "sd-server.exe" }
|
||||
if (-not (Test-Path $extractedExe)) { $extractedExe = Join-Path $tempExt "bin\sd.exe" }
|
||||
if (-not (Test-Path $extractedExe)) { $extractedExe = Join-Path $tempExt "sd.exe" }
|
||||
|
||||
$extractedDll = Join-Path $tempExt "bin\stable-diffusion.dll"
|
||||
if (-not (Test-Path $extractedDll)) { $extractedDll = Join-Path $tempExt "stable-diffusion.dll" }
|
||||
|
||||
if (Test-Path $extractedExe) { Copy-Item $extractedExe $cpuBackendExe -Force }
|
||||
if (Test-Path $extractedDll) { Copy-Item $extractedDll $cpuBackendDll -Force }
|
||||
|
||||
Get-ChildItem $tempExt -Filter "*.dll" -Recurse | ForEach-Object { Copy-Item $_.FullName $cpuBackendDest -Force }
|
||||
Get-ChildItem $tempExt -Filter "*.exe" -Recurse | ForEach-Object {
|
||||
if ($_.FullName -ne $extractedExe) { Copy-Item $_.FullName $cpuBackendDest -Force }
|
||||
}
|
||||
Remove-Item $tempExt -Recurse -Force
|
||||
}
|
||||
|
||||
if ((Test-Path $cpuBackendExe) -and (Test-Path $cpuBackendDll)) {
|
||||
Print-OK "CPU backend binaries installed successfully!"
|
||||
} else {
|
||||
Print-Fail "Failed to copy backend binaries to app/backend/win/cpu/."
|
||||
Read-Host; exit 1
|
||||
}
|
||||
}
|
||||
|
||||
if ($hasNvidia) {
|
||||
Print-Step 2 $steps "Setting up stable-diffusion.cpp CUDA GPU backend (app/backend/win/cuda/)"
|
||||
$backendDest = Join-Path $appDir "backend\win\cuda"
|
||||
@@ -441,7 +491,22 @@ if (-not $?) {
|
||||
Read-Host; exit 1
|
||||
}
|
||||
|
||||
Print-Step 6 $steps "Installing frontend dependencies (app/frontend/)"
|
||||
$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
|
||||
|
||||
Print-Step 6 $steps "Setting up portable OpenVINO NPU runtime"
|
||||
if ($npu) {
|
||||
& (Join-Path $scriptDir "setup-openvino-npu.ps1")
|
||||
if (-not $?) {
|
||||
Print-Fail "OpenVINO NPU setup failed."
|
||||
Read-Host; exit 1
|
||||
}
|
||||
} else {
|
||||
Print-Info "Intel AI Boost NPU not detected. Skipping OpenVINO NPU runtime."
|
||||
}
|
||||
|
||||
Print-Step 7 $steps "Installing frontend dependencies (app/frontend/)"
|
||||
Write-Host ""
|
||||
|
||||
if (-not (Test-Path $npmCmd)) {
|
||||
@@ -486,7 +551,7 @@ Push-Location $frontendDir
|
||||
$oldPath = $env:PATH
|
||||
try {
|
||||
$env:PATH = "$nodeDir;$env:PATH"
|
||||
& $npmCmd install --prefer-offline 2>&1
|
||||
& $npmCmd install --prefer-offline --loglevel=error
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Print-Fail "npm install failed."
|
||||
Read-Host; exit 1
|
||||
@@ -495,10 +560,10 @@ try {
|
||||
Print-OK "Dependencies installed!"
|
||||
|
||||
# ── Step 4: Build frontend ────────────────────────────────────────────────
|
||||
Print-Step 7 $steps "Building frontend -> app/dist/"
|
||||
Print-Step 8 $steps "Building frontend -> app/dist/"
|
||||
Write-Host ""
|
||||
|
||||
& $npmCmd run build 2>&1
|
||||
& $npmCmd run build
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Print-Fail "Frontend build failed."
|
||||
Read-Host; exit 1
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODEL_VERSION_BY_NAME = {
|
||||
"coreml-stable-diffusion-v1-5": "runwayml/stable-diffusion-v1-5",
|
||||
"stable-diffusion-v1-5": "runwayml/stable-diffusion-v1-5",
|
||||
"stable-diffusion-v1-4": "CompVis/stable-diffusion-v1-4",
|
||||
"cyberrealistic": "runwayml/stable-diffusion-v1-5",
|
||||
}
|
||||
|
||||
|
||||
def json_response(handler, code, payload):
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
handler.send_response(code)
|
||||
handler.send_header("Content-Type", "application/json")
|
||||
handler.send_header("Access-Control-Allow-Origin", "*")
|
||||
handler.send_header("Content-Length", str(len(body)))
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
|
||||
|
||||
def find_coreml_resource_dir(model_path: Path) -> Path:
|
||||
candidates = [
|
||||
model_path,
|
||||
model_path / "Resources",
|
||||
model_path / "compiled",
|
||||
model_path / "packages",
|
||||
model_path / "split_einsum_v2" / "compiled",
|
||||
model_path / "split_einsum" / "compiled",
|
||||
model_path / "original" / "compiled",
|
||||
model_path / "split_einsum_v2" / "packages",
|
||||
model_path / "split_einsum" / "packages",
|
||||
model_path / "original" / "packages",
|
||||
]
|
||||
|
||||
required_names = {
|
||||
"text_encoder.mlmodelc",
|
||||
"text_encoder.mlpackage",
|
||||
"textencoder.mlmodelc",
|
||||
"textencoder.mlpackage",
|
||||
}
|
||||
for candidate in candidates:
|
||||
if not candidate.is_dir():
|
||||
continue
|
||||
names = {entry.name.lower() for entry in candidate.iterdir()}
|
||||
has_text_encoder = bool(names & required_names)
|
||||
has_unet = "unet.mlmodelc" in names or "unet.mlpackage" in names
|
||||
if has_text_encoder and has_unet:
|
||||
return candidate
|
||||
|
||||
for candidate in model_path.rglob("*"):
|
||||
if not candidate.is_dir():
|
||||
continue
|
||||
names = {entry.name.lower() for entry in candidate.iterdir()}
|
||||
has_text_encoder = bool(names & required_names)
|
||||
has_unet = "unet.mlmodelc" in names or "unet.mlpackage" in names
|
||||
if has_text_encoder and has_unet:
|
||||
return candidate
|
||||
|
||||
raise FileNotFoundError(
|
||||
f"Could not find CoreML resources under {model_path}. "
|
||||
"Expected a folder containing text_encoder and unet .mlmodelc/.mlpackage files."
|
||||
)
|
||||
|
||||
|
||||
def infer_model_version(model_path: Path) -> str:
|
||||
override = os.environ.get("COREML_MODEL_VERSION")
|
||||
if override:
|
||||
return override
|
||||
lower = model_path.name.lower()
|
||||
for needle, version in MODEL_VERSION_BY_NAME.items():
|
||||
if needle in lower:
|
||||
return version
|
||||
return "runwayml/stable-diffusion-v1-5"
|
||||
|
||||
|
||||
def infer_model_sources(resource_dir: Path) -> str:
|
||||
names = {entry.name.lower() for entry in resource_dir.iterdir()}
|
||||
if any(name.endswith(".mlmodelc") for name in names):
|
||||
return "compiled"
|
||||
return "packages"
|
||||
|
||||
|
||||
def latest_png(root: Path) -> Path:
|
||||
images = sorted(root.rglob("*.png"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
if not images:
|
||||
raise FileNotFoundError("CoreML generation finished but did not write a PNG output.")
|
||||
return images[0]
|
||||
|
||||
|
||||
class CoreMLServerState:
|
||||
def __init__(self, model: Path, steps: int, cfg_scale: float):
|
||||
self.model = model
|
||||
self.resources = find_coreml_resource_dir(model)
|
||||
self.model_version = infer_model_version(model)
|
||||
self.steps = steps
|
||||
self.cfg_scale = cfg_scale
|
||||
self.started_at = time.time()
|
||||
|
||||
|
||||
def make_handler(state: CoreMLServerState):
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_OPTIONS(self):
|
||||
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):
|
||||
if self.path == "/health":
|
||||
json_response(self, 200, {
|
||||
"ok": True,
|
||||
"ready": True,
|
||||
"model": str(state.model),
|
||||
"resources": str(state.resources),
|
||||
"model_version": state.model_version,
|
||||
"uptime_sec": round(time.time() - state.started_at, 1),
|
||||
})
|
||||
return
|
||||
if self.path == "/v1/models":
|
||||
json_response(self, 200, {
|
||||
"object": "list",
|
||||
"data": [{
|
||||
"id": state.model.name,
|
||||
"object": "model",
|
||||
"owned_by": "local-coreml",
|
||||
}],
|
||||
})
|
||||
return
|
||||
json_response(self, 404, {"ok": False, "error": "Unknown endpoint"})
|
||||
|
||||
def do_POST(self):
|
||||
if self.path != "/v1/images/generations":
|
||||
json_response(self, 404, {"ok": False, "error": "Unknown endpoint"})
|
||||
return
|
||||
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
payload = json.loads(self.rfile.read(length).decode("utf-8") or "{}")
|
||||
prompt = str(payload.get("prompt") or "").strip()
|
||||
if not prompt:
|
||||
raise ValueError("Prompt is required")
|
||||
|
||||
size = str(payload.get("size") or "512x512").lower().split("x")
|
||||
if len(size) == 2 and size != ["512", "512"]:
|
||||
raise ValueError("CoreML models in this app currently generate at their compiled resolution, normally 512x512.")
|
||||
|
||||
steps = max(1, int(payload.get("steps") or state.steps or 30))
|
||||
guidance = float(payload.get("cfg_scale") or state.cfg_scale or 7.0)
|
||||
seed = int(payload.get("seed")) if payload.get("seed") not in (None, -1) else int(time.time_ns() % (2 ** 32))
|
||||
negative_prompt = str(payload.get("negative_prompt") or "").strip()
|
||||
|
||||
started = time.time()
|
||||
out_dir = Path(tempfile.mkdtemp(prefix="portable-diffusion-coreml-"))
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"python_coreml_stable_diffusion.pipeline",
|
||||
"--prompt",
|
||||
prompt,
|
||||
"-i",
|
||||
str(state.resources),
|
||||
"-o",
|
||||
str(out_dir),
|
||||
"--compute-unit",
|
||||
os.environ.get("COREML_COMPUTE_UNIT", "CPU_AND_NE"),
|
||||
"--seed",
|
||||
str(seed),
|
||||
"--model-version",
|
||||
state.model_version,
|
||||
"--num-inference-steps",
|
||||
str(steps),
|
||||
"--guidance-scale",
|
||||
str(guidance),
|
||||
"--model-sources",
|
||||
infer_model_sources(state.resources),
|
||||
]
|
||||
if negative_prompt:
|
||||
cmd.extend(["--negative-prompt", negative_prompt])
|
||||
|
||||
print("[coreml-npu] generating image", flush=True)
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
output_lines = []
|
||||
assert proc.stdout is not None
|
||||
for line in proc.stdout:
|
||||
output_lines.append(line.rstrip())
|
||||
print(line, end="", flush=True)
|
||||
code = proc.wait()
|
||||
if code != 0:
|
||||
raise RuntimeError("CoreML generation failed with code %s:\n%s" % (code, "\n".join(output_lines[-30:])))
|
||||
|
||||
image_path = latest_png(out_dir)
|
||||
encoded = base64.b64encode(image_path.read_bytes()).decode("ascii")
|
||||
shutil.rmtree(out_dir, ignore_errors=True)
|
||||
print("[coreml-npu] decoding complete", flush=True)
|
||||
json_response(self, 200, {
|
||||
"created": int(time.time()),
|
||||
"data": [{
|
||||
"b64_json": encoded,
|
||||
"seed": seed,
|
||||
}],
|
||||
"duration_sec": round(time.time() - started, 2),
|
||||
})
|
||||
except Exception as exc:
|
||||
json_response(self, 500, {"ok": False, "error": str(exc)})
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
print("[coreml-npu-http] " + fmt % args, flush=True)
|
||||
|
||||
return Handler
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--listen-port", required=True, type=int)
|
||||
parser.add_argument("--model", required=True, type=Path)
|
||||
parser.add_argument("--steps", type=int, default=30)
|
||||
parser.add_argument("--cfg-scale", type=float, default=7.0)
|
||||
args = parser.parse_args()
|
||||
|
||||
state = CoreMLServerState(args.model, args.steps, args.cfg_scale)
|
||||
print(f"[coreml-npu] Model: {state.model}", flush=True)
|
||||
print(f"[coreml-npu] Resources: {state.resources}", flush=True)
|
||||
print(f"[coreml-npu] Model version: {state.model_version}", flush=True)
|
||||
print("| 1/1 - CoreML resources ready", flush=True)
|
||||
server = ThreadingHTTPServer(("127.0.0.1", args.listen_port), make_handler(state))
|
||||
print(f"[coreml-npu] listening on http://127.0.0.1:{args.listen_port}", flush=True)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user