Parcourir la source

fix: update .gitignore to allow webui source files

- Add explicit allow patterns for webui source directories
- Prevent webui/app/, webui/components/, webui/lib/ from being ignored
- Keep build artifacts and node_modules ignored properly
- Allow all TypeScript, CSS, and config files in webui
Fszontagh il y a 3 mois
Parent
commit
5e4c6d8914

+ 17 - 0
.gitignore

@@ -30,6 +30,23 @@ webui/.env.*.local
 webui/.vercel
 webui/public/version.json
 
+# Allow webui source files (they were being ignored by broad patterns above)
+!webui/app/
+!webui/components/
+!webui/lib/
+!webui/contexts/
+!webui/app/**/
+!webui/components/**/
+!webui/lib/**/
+!webui/contexts/**/
+!webui/*.json
+!webui/*.config.*
+!webui/*.js
+!webui/*.ts
+!webui/*.tsx
+!webui/*.css
+!webui/globals.css
+
 # Model files (too large for git)
 models/
 *.safetensors

+ 10 - 1
CMakeLists.txt

@@ -17,6 +17,15 @@ if(NOT CMAKE_BUILD_TYPE)
     set(CMAKE_BUILD_TYPE Release)
 endif()
 
+find_program(CCACHE_FOUND ccache)
+if(CCACHE_FOUND)
+   set_property(GLOBAL PROPERTY RULE_LAUNCH_COMPILE ccache)
+   set(ENV{CCACHE_SLOPPINESS} time_macros)
+   message(STATUS "ccache found, compilation results will be cached")
+endif()
+
+
+
 # Add cmake modules path
 list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake")
 
@@ -283,4 +292,4 @@ message(STATUS "  Build Web UI: ${BUILD_WEBUI}")
 message(STATUS "  PAM Authentication: ${ENABLE_PAM_AUTH}")
 if(BUILD_WEBUI AND WEBUI_BUILT)
     message(STATUS "  Web UI Path: ${WEBUI_PATH}")
-endif()
+endif()

+ 9 - 0
include/generation_queue.h

@@ -269,8 +269,17 @@ struct JobInfo {
 
     // Image data for complex operations (base64 encoded)
     std::string initImageData;     ///< Init image data for img2img (base64)
+    int initImageWidth = 0;       ///< Init image width
+    int initImageHeight = 0;      ///< Init image height
+    int initImageChannels = 3;      ///< Init image channels
     std::string controlImageData;  ///< Control image data for ControlNet (base64)
+    int controlImageWidth = 0;      ///< Control image width
+    int controlImageHeight = 0;     ///< Control image height
+    int controlImageChannels = 3;     ///< Control image channels
     std::string maskImageData;     ///< Mask image data for inpainting (base64)
+    int maskImageWidth = 0;        ///< Mask image width
+    int maskImageHeight = 0;       ///< Mask image height
+    int maskImageChannels = 1;       ///< Mask image channels (grayscale)
 
     // Model paths for advanced usage
     std::string clipLPath;       ///< Path to CLIP-L model

BIN
opencodetmp/opencode-linux-x64.zip


+ 254 - 83
src/generation_queue.cpp

@@ -12,6 +12,7 @@
 #include "logger.h"
 #include "model_manager.h"
 #include "stable_diffusion_wrapper.h"
+#include "utils.h"
 
 #define STB_IMAGE_WRITE_IMPLEMENTATION
 #include "../stable-diffusion.cpp-src/thirdparty/stb_image_write.h"
@@ -319,7 +320,12 @@ public:
             return result;
         }
 
-        if (!modelManager->isModelLoaded(request.modelName)) {
+        // Special handling for upscaler requests - they don't use modelManager loading
+        if (request.requestType != GenerationRequest::RequestType::UPSCALER && !modelManager->isModelLoaded(request.modelName)) {
+            // Debug: log model name for upscaler requests
+            if (request.requestType == GenerationRequest::RequestType::UPSCALER) {
+                LOG_DEBUG("Upscaler request - modelName: '" + request.modelName + "'");
+            }
             // Update status to show model is being loaded
             {
                 std::lock_guard<std::mutex> lock(jobsMutex);
@@ -375,10 +381,14 @@ public:
         }
 
         // Get the model wrapper from the shared model manager
-        auto* modelWrapper = modelManager->getModel(request.modelName);
-        if (!modelWrapper) {
-            result.errorMessage = "Model not found or not loaded: " + request.modelName;
-            return result;
+        // For upscaler requests, we don't use modelManager - the upscaleImage function handles its own loading
+        StableDiffusionWrapper* modelWrapper = nullptr;
+        if (request.requestType != GenerationRequest::RequestType::UPSCALER) {
+            modelWrapper = modelManager->getModel(request.modelName);
+            if (!modelWrapper) {
+                result.errorMessage = "Model not found or not loaded: " + request.modelName;
+                return result;
+            }
         }
 
         // Prepare generation parameters
@@ -403,8 +413,10 @@ public:
         params.diffusionConvDirect = request.diffusionConvDirect;
         params.vaeConvDirect       = request.vaeConvDirect;
 
-        // Set model paths if provided
-        params.modelPath      = modelManager->getModelInfo(request.modelName).path;
+        // Set model paths if provided (only for non-upscaler jobs)
+        if (request.requestType != GenerationRequest::RequestType::UPSCALER) {
+            params.modelPath = modelManager->getModelInfo(request.modelName).path;
+        }
         params.clipLPath      = request.clipLPath;
         params.clipGPath      = request.clipGPath;
         params.vaePath        = request.vaePath;
@@ -489,7 +501,22 @@ public:
                         return result;
                     }
                     {
-                        auto upscaledImage = modelWrapper->upscaleImage(
+                        // For upscaler, create a progress callback and temporary wrapper instance
+                        StableDiffusionWrapper tempWrapper;
+                        
+                        // Create progress callback for upscaler (no model loading phase)
+                        auto progressCallback = [this, jobId = request.id](int step, int totalSteps, float stepTime, void* userData) {
+                            // For upscaler, totalSteps is 0 (no generation steps), so we show progress based on time
+                            auto currentTime     = std::chrono::system_clock::now();
+                            uint64_t timeElapsed = std::chrono::duration_cast<std::chrono::milliseconds>(currentTime - *static_cast<std::chrono::system_clock::time_point*>(userData)).count();
+                            
+                            // Estimate progress based on time (upscaling typically takes a few seconds)
+                            float estimatedProgress = std::min(0.95f, static_cast<float>(timeElapsed) / 5000.0f); // Assume 5 seconds max
+                            
+                            updateGenerationProgress(jobId, 0, 0, stepTime, timeElapsed);
+                        };
+                        
+                        auto upscaledImage = tempWrapper.upscaleImage(
                             request.esrganPath,
                             request.initImageData,
                             request.initImageWidth,
@@ -903,15 +930,31 @@ public:
             jobJson["vae_conv_direct"]       = job.vaeConvDirect;
             jobJson["generation_time"]       = job.generationTime;
 
-            // Image paths for complex operations
+            // Image data for complex operations (store as base64 to preserve binary data)
             if (!job.initImageData.empty()) {
-                jobJson["init_image_path"] = job.initImageData;
+                // Convert binary image data back to base64 for JSON serialization
+                std::vector<uint8_t> binaryData(job.initImageData.begin(), job.initImageData.end());
+                std::string base64Data = Utils::base64Encode(binaryData);
+                jobJson["init_image_data"] = base64Data;
+                jobJson["init_image_width"] = job.initImageWidth;
+                jobJson["init_image_height"] = job.initImageHeight;
+                jobJson["init_image_channels"] = job.initImageChannels;
             }
             if (!job.controlImageData.empty()) {
-                jobJson["control_image_path"] = job.controlImageData;
+                std::vector<uint8_t> binaryData(job.controlImageData.begin(), job.controlImageData.end());
+                std::string base64Data = Utils::base64Encode(binaryData);
+                jobJson["control_image_data"] = base64Data;
+                jobJson["control_image_width"] = job.controlImageWidth;
+                jobJson["control_image_height"] = job.controlImageHeight;
+                jobJson["control_image_channels"] = job.controlImageChannels;
             }
             if (!job.maskImageData.empty()) {
-                jobJson["mask_image_path"] = job.maskImageData;
+                std::vector<uint8_t> binaryData(job.maskImageData.begin(), job.maskImageData.end());
+                std::string base64Data = Utils::base64Encode(binaryData);
+                jobJson["mask_image_data"] = base64Data;
+                jobJson["mask_image_width"] = job.maskImageWidth;
+                jobJson["mask_image_height"] = job.maskImageHeight;
+                jobJson["mask_image_channels"] = job.maskImageChannels;
             }
 
             // Model paths for advanced usage
@@ -1084,54 +1127,24 @@ public:
                     if (jobJson.contains("generation_time"))
                         job.generationTime = jobJson["generation_time"];
 
-                    // Image paths for complex operations
-                    if (jobJson.contains("init_image_path"))
-                        job.initImageData = jobJson["init_image_path"];
-                    if (jobJson.contains("control_image_path"))
-                        job.controlImageData = jobJson["control_image_path"];
-                    if (jobJson.contains("mask_image_path"))
-                        job.maskImageData = jobJson["mask_image_path"];
-
-                    // Model paths for advanced usage
-                    if (jobJson.contains("clip_l_path"))
-                        job.clipLPath = jobJson["clip_l_path"];
-                    if (jobJson.contains("clip_g_path"))
-                        job.clipGPath = jobJson["clip_g_path"];
-                    if (jobJson.contains("vae_path"))
-                        job.vaePath = jobJson["vae_path"];
-                    if (jobJson.contains("taesd_path"))
-                        job.taesdPath = jobJson["taesd_path"];
-                    if (jobJson.contains("controlnet_path"))
-                        job.controlNetPath = jobJson["controlnet_path"];
-                    if (jobJson.contains("embedding_dir"))
-                        job.embeddingDir = jobJson["embedding_dir"];
-                    if (jobJson.contains("lora_model_dir"))
-                        job.loraModelDir = jobJson["lora_model_dir"];
-                    if (jobJson.contains("esrgan_path"))
-                        job.esrganPath = jobJson["esrgan_path"];
-                    if (jobJson.contains("upscale_factor"))
-                        job.upscaleFactor = jobJson["upscale_factor"];
-
-                    // Progress and timing information
-                    if (jobJson.contains("progress"))
-                        job.progress = jobJson["progress"];
-                    if (jobJson.contains("model_load_progress"))
-                        job.modelLoadProgress = jobJson["model_load_progress"];
-                    if (jobJson.contains("current_step"))
-                        job.currentStep = jobJson["current_step"];
-                    if (jobJson.contains("total_steps"))
-                        job.totalSteps = jobJson["total_steps"];
-                    if (jobJson.contains("time_elapsed"))
-                        job.timeElapsed = jobJson["time_elapsed"];
-                    if (jobJson.contains("time_remaining"))
-                        job.timeRemaining = jobJson["time_remaining"];
-                    if (jobJson.contains("speed"))
-                        job.speed = jobJson["speed"];
-                    if (jobJson.contains("first_generation_callback")) {
-                        job.firstGenerationCallback = jobJson["first_generation_callback"];
-                    } else {
-                        // For backward compatibility, default to true for loaded jobs
-                        job.firstGenerationCallback = true;
+                    // Load image data for complex operations (from base64)
+                    if (jobJson.contains("init_image_data")) {
+                        job.initImageData = jobJson["init_image_data"];
+                        job.initImageWidth = jobJson.value("init_image_width", 0);
+                        job.initImageHeight = jobJson.value("init_image_height", 0);
+                        job.initImageChannels = jobJson.value("init_image_channels", 3);
+                    }
+                    if (jobJson.contains("control_image_data")) {
+                        job.controlImageData = jobJson["control_image_data"];
+                        job.controlImageWidth = jobJson.value("control_image_width", 0);
+                        job.controlImageHeight = jobJson.value("control_image_height", 0);
+                        job.controlImageChannels = jobJson.value("control_image_channels", 3);
+                    }
+                    if (jobJson.contains("mask_image_data")) {
+                        job.maskImageData = jobJson["mask_image_data"];
+                        job.maskImageWidth = jobJson.value("mask_image_width", 0);
+                        job.maskImageHeight = jobJson.value("mask_image_height", 0);
+                        job.maskImageChannels = jobJson.value("mask_image_channels", 1);
                     }
 
                     // Clean up stale processing jobs from server restart
@@ -1371,39 +1384,65 @@ public:
                 break;
             case GenerationRequest::RequestType::IMG2IMG:
                 jobInfo.requestType = "img2img";
-                // Store path to init image instead of the image data
-                jobInfo.initImageData = request.initImagePath;  // Store path, not base64
+                // Store actual image data as base64
+                if (!request.initImageData.empty()) {
+                    std::vector<uint8_t> binaryData(request.initImageData.begin(), request.initImageData.end());
+                    jobInfo.initImageData = Utils::base64Encode(binaryData);
+                    jobInfo.initImageWidth = request.initImageWidth;
+                    jobInfo.initImageHeight = request.initImageHeight;
+                    jobInfo.initImageChannels = request.initImageChannels;
+                } else {
+                    jobInfo.initImageData = request.initImagePath;  // Fallback to path
+                }
                 break;
             case GenerationRequest::RequestType::CONTROLNET:
                 jobInfo.requestType = "controlnet";
-                // Store path to control image instead of the image data
-                jobInfo.controlImageData = request.controlImagePath;  // Store path, not base64
+                // Store actual control image data as base64
+                if (!request.controlImageData.empty()) {
+                    std::vector<uint8_t> binaryData(request.controlImageData.begin(), request.controlImageData.end());
+                    jobInfo.controlImageData = Utils::base64Encode(binaryData);
+                    jobInfo.controlImageWidth = request.controlImageWidth;
+                    jobInfo.controlImageHeight = request.controlImageHeight;
+                    jobInfo.controlImageChannels = request.controlImageChannels;
+                } else {
+                    jobInfo.controlImageData = request.controlImagePath;  // Fallback to path
+                }
                 break;
             case GenerationRequest::RequestType::UPSCALER:
                 jobInfo.requestType = "upscaler";
-                // Store path to input image instead of the image data
-                jobInfo.initImageData = request.initImagePath;  // Store path, not base64
+                // Store actual image data as base64
+                if (!request.initImageData.empty()) {
+                    std::vector<uint8_t> binaryData(request.initImageData.begin(), request.initImageData.end());
+                    jobInfo.initImageData = Utils::base64Encode(binaryData);
+                    jobInfo.initImageWidth = request.initImageWidth;
+                    jobInfo.initImageHeight = request.initImageHeight;
+                    jobInfo.initImageChannels = request.initImageChannels;
+                } else {
+                    jobInfo.initImageData = request.initImagePath;  // Fallback to path
+                }
                 break;
             case GenerationRequest::RequestType::INPAINTING:
                 jobInfo.requestType = "inpainting";
-                // Store paths to images instead of the image data
-                jobInfo.initImageData = request.initImagePath;  // Store path, not base64
-                jobInfo.maskImageData = request.maskImagePath;  // Store path, not base64
+                // Store actual image data as base64
+                if (!request.initImageData.empty()) {
+                    std::vector<uint8_t> binaryData(request.initImageData.begin(), request.initImageData.end());
+                    jobInfo.initImageData = Utils::base64Encode(binaryData);
+                    jobInfo.initImageWidth = request.initImageWidth;
+                    jobInfo.initImageHeight = request.initImageHeight;
+                    jobInfo.initImageChannels = request.initImageChannels;
+                } else {
+                    jobInfo.initImageData = request.initImagePath;  // Fallback to path
+                }
+                if (!request.maskImageData.empty()) {
+                    std::vector<uint8_t> binaryData(request.maskImageData.begin(), request.maskImageData.end());
+                    jobInfo.maskImageData = Utils::base64Encode(binaryData);
+                    jobInfo.maskImageWidth = request.maskImageWidth;
+                    jobInfo.maskImageHeight = request.maskImageHeight;
+                    jobInfo.maskImageChannels = request.maskImageChannels;
+                } else {
+                    jobInfo.maskImageData = request.maskImagePath;  // Fallback to path
+                }
                 break;
-        }
-
-        // Model paths
-        jobInfo.clipLPath      = request.clipLPath;
-        jobInfo.clipGPath      = request.clipGPath;
-        jobInfo.vaePath        = request.vaePath;
-        jobInfo.taesdPath      = request.taesdPath;
-        jobInfo.controlNetPath = request.controlNetPath;
-        jobInfo.embeddingDir   = request.embeddingDir;
-        jobInfo.loraModelDir   = request.loraModelDir;
-        jobInfo.esrganPath     = request.esrganPath;
-        jobInfo.upscaleFactor  = request.upscaleFactor;
-    }
-};
 
 GenerationQueue::GenerationQueue(ModelManager* modelManager, int maxConcurrentGenerations, const std::string& queueDir, const std::string& outputDir)
     : pImpl(std::make_unique<Impl>()) {
@@ -1419,6 +1458,138 @@ GenerationQueue::GenerationQueue(ModelManager* modelManager, int maxConcurrentGe
 
     // Load any existing jobs from disk
     pImpl->loadJobsFromDisk();
+    
+    // Process any jobs that were loaded from disk and are in PROCESSING status
+    // These jobs need to be converted from JobInfo back to GenerationRequest
+    std::vector<std::string> processingJobs;
+    {
+        std::lock_guard<std::mutex> lock(pImpl->jobsMutex);
+        for (const auto& [jobId, jobInfo] : pImpl->activeJobs) {
+            if (jobInfo.status == GenerationStatus::PROCESSING) {
+                processingJobs.push_back(jobId);
+            }
+        }
+    }
+    
+    // Convert processing jobs back to GenerationRequest and process them
+    for (const std::string& jobId : processingJobs) {
+        auto it = pImpl->activeJobs.find(jobId);
+        if (it != pImpl->activeJobs.end()) {
+            const JobInfo& jobInfo = it->second;
+            
+            // Create GenerationRequest from JobInfo
+            GenerationRequest request;
+            request.id = jobInfo.id;
+            request.modelName = jobInfo.modelName;
+            request.prompt = jobInfo.prompt;
+            request.negativePrompt = jobInfo.negativePrompt;
+            request.width = jobInfo.width;
+            request.height = jobInfo.height;
+            request.batchCount = jobInfo.batchCount;
+            request.steps = jobInfo.steps;
+            request.cfgScale = jobInfo.cfgScale;
+            request.samplingMethod = jobInfo.samplingMethod;
+            request.scheduler = jobInfo.scheduler;
+            request.seed = jobInfo.seed;
+            request.strength = jobInfo.strength;
+            request.controlStrength = jobInfo.controlStrength;
+            request.clipSkip = jobInfo.clipSkip;
+            request.nThreads = jobInfo.nThreads;
+            request.offloadParamsToCpu = jobInfo.offloadParamsToCpu;
+            request.clipOnCpu = jobInfo.clipOnCpu;
+            request.vaeOnCpu = jobInfo.vaeOnCpu;
+            request.diffusionFlashAttn = jobInfo.diffusionFlashAttn;
+            request.diffusionConvDirect = jobInfo.diffusionConvDirect;
+            request.vaeConvDirect = jobInfo.vaeConvDirect;
+            
+            // Set request type based on jobInfo.requestType
+            if (jobInfo.requestType == "text2img") {
+                request.requestType = GenerationRequest::RequestType::TEXT2IMG;
+            } else if (jobInfo.requestType == "img2img") {
+                request.requestType = GenerationRequest::RequestType::IMG2IMG;
+                // Convert base64 image data back to binary
+                if (!jobInfo.initImageData.empty()) {
+                    std::string base64Data = jobInfo.initImageData;
+                    // Remove data URI prefix if present
+                    size_t commaPos = base64Data.find(',');
+                    if (commaPos != std::string::npos) {
+                        base64Data = base64Data.substr(commaPos + 1);
+                    }
+                    request.initImageData = Utils::base64Decode(base64Data);
+                    request.initImageWidth = jobInfo.initImageWidth;
+                    request.initImageHeight = jobInfo.initImageHeight;
+                    request.initImageChannels = jobInfo.initImageChannels;
+                }
+            } else if (jobInfo.requestType == "controlnet") {
+                request.requestType = GenerationRequest::RequestType::CONTROLNET;
+                // Convert base64 image data back to binary
+                if (!jobInfo.controlImageData.empty()) {
+                    std::string base64Data = jobInfo.controlImageData;
+                    size_t commaPos = base64Data.find(',');
+                    if (commaPos != std::string::npos) {
+                        base64Data = base64Data.substr(commaPos + 1);
+                    }
+                    request.controlImageData = Utils::base64Decode(base64Data);
+                    request.controlImageWidth = jobInfo.controlImageWidth;
+                    request.controlImageHeight = jobInfo.controlImageHeight;
+                    request.controlImageChannels = jobInfo.controlImageChannels;
+                }
+            } else if (jobInfo.requestType == "upscaler") {
+                request.requestType = GenerationRequest::RequestType::UPSCALER;
+                // Convert base64 image data back to binary
+                if (!jobInfo.initImageData.empty()) {
+                    std::string base64Data = jobInfo.initImageData;
+                    size_t commaPos = base64Data.find(',');
+                    if (commaPos != std::string::npos) {
+                        base64Data = base64Data.substr(commaPos + 1);
+                    }
+                    request.initImageData = Utils::base64Decode(base64Data);
+                    request.initImageWidth = jobInfo.initImageWidth;
+                    request.initImageHeight = jobInfo.initImageHeight;
+                    request.initImageChannels = jobInfo.initImageChannels;
+                }
+                request.esrganPath = jobInfo.esrganPath;
+                request.upscaleFactor = jobInfo.upscaleFactor;
+            } else if (jobInfo.requestType == "inpainting") {
+                request.requestType = GenerationRequest::RequestType::INPAINTING;
+                // Convert base64 image data back to binary
+                if (!jobInfo.initImageData.empty()) {
+                    std::string base64Data = jobInfo.initImageData;
+                    size_t commaPos = base64Data.find(',');
+                    if (commaPos != std::string::npos) {
+                        base64Data = base64Data.substr(commaPos + 1);
+                    }
+                    request.initImageData = Utils::base64Decode(base64Data);
+                    request.initImageWidth = jobInfo.initImageWidth;
+                    request.initImageHeight = jobInfo.initImageHeight;
+                    request.initImageChannels = jobInfo.initImageChannels;
+                }
+                if (!jobInfo.maskImageData.empty()) {
+                    std::string base64Data = jobInfo.maskImageData;
+                    size_t commaPos = base64Data.find(',');
+                    if (commaPos != std::string::npos) {
+                        base64Data = base64Data.substr(commaPos + 1);
+                    }
+                    request.maskImageData = Utils::base64Decode(base64Data);
+                    request.maskImageWidth = jobInfo.maskImageWidth;
+                    request.maskImageHeight = jobInfo.maskImageHeight;
+                    request.maskImageChannels = jobInfo.maskImageChannels;
+                }
+            }
+            
+            // Set model paths
+            request.clipLPath = jobInfo.clipLPath;
+            request.clipGPath = jobInfo.clipGPath;
+            request.vaePath = jobInfo.vaePath;
+            request.taesdPath = jobInfo.taesdPath;
+            request.controlNetPath = jobInfo.controlNetPath;
+            request.embeddingDir = jobInfo.embeddingDir;
+            request.loraModelDir = jobInfo.loraModelDir;
+            
+            LOG_INFO("Resuming processing job from disk: " + jobId);
+            pImpl->processRequest(request);
+        }
+    }
 }
 
 GenerationQueue::~GenerationQueue() {

+ 17 - 1
src/server.cpp

@@ -2332,8 +2332,15 @@ void Server::handleDownloadImageFromUrl(const httplib::Request& req, httplib::Re
         // Load image using existing loadImageFromInput function
         auto [imageData, width, height, channels, success, error] = loadImageFromInput(imageUrl);
 
+        LOG_DEBUG("Image load result - success: " + std::string(success ? "true" : "false") + 
+                  ", width: " + std::to_string(imgWidth) + 
+                  ", height: " + std::to_string(imgHeight) + 
+                  ", channels: " + std::to_string(imgChannels) + 
+                  ", data_size: " + std::to_string(imageData.size()) +
+                  ", error: " + loadError);
+        
         if (!success) {
-            sendErrorResponse(res, "Failed to download image from URL: " + error, 400, "IMAGE_DOWNLOAD_FAILED", requestId);
+            sendErrorResponse(res, "Failed to load image: " + loadError, 400, "IMAGE_LOAD_ERROR", requestId);
             return;
         }
 
@@ -3425,6 +3432,15 @@ void Server::handleUpscale(const httplib::Request& req, httplib::Response& res)
 
         // Load the input image
         std::string imageInput                                                 = requestJson["image"];
+        
+        // Debug logging to see what we received
+        LOG_DEBUG("Upscale request - image input length: " + std::to_string(imageInput.length()));
+        if (imageInput.length() > 100) {
+            LOG_DEBUG("Upscale request - image prefix: " + imageInput.substr(0, 100) + "...");
+        } else {
+            LOG_DEBUG("Upscale request - image data: " + imageInput);
+        }
+        
         auto [imageData, imgWidth, imgHeight, imgChannels, success, loadError] = loadImageFromInput(imageInput);
 
         if (!success) {

+ 21 - 2
webui/app/upscaler/page.tsx

@@ -163,11 +163,15 @@ function UpscalerForm() {
     if (!file) return;
 
     try {
+      console.log("Processing file:", file.name, file.size, file.type);
       const base64 = await fileToBase64(file);
+      console.log("Base64 result length:", base64.length);
+      console.log("Base64 prefix:", base64.substring(0, 100));
       setUploadedImage(base64);
       setPreviewImage(base64);
       setError(null);
-    } catch {
+    } catch (error) {
+      console.error("Image upload error:", error);
       setError("Failed to load image");
     }
   };
@@ -320,9 +324,24 @@ function UpscalerForm() {
         return;
       }
 
+      console.log("About to send upscale request:", {
+        imageLength: uploadedImage.length,
+        imagePrefix: uploadedImage.substring(0, 100),
+        model: modelId,
+        upscale_factor: formData.upscale_factor,
+        hasImage: !!uploadedImage,
+        imageStartsWith: uploadedImage.startsWith('data:image') ? 'data-uri' : 'other'
+      });
+      
+      if (!uploadedImage || uploadedImage.length === 0) {
+        setError("No image data available. Please re-upload the image.");
+        setLoading(false);
+        return;
+      }
+      
       const job = await apiClient.upscale({
         image: uploadedImage,
-        model: modelId, // Use the hash ID instead of name
+        model: modelId, // Use hash ID instead of name
         upscale_factor: formData.upscale_factor,
       });
       setJobInfo(job);

+ 0 - 0
webui/components/features/queue/image-modal.tsx


+ 5 - 0
webui/components/features/queue/index.ts

@@ -0,0 +1,5 @@
+/**
+ * Queue Components
+ */
+
+export { EnhancedQueueList } from "./enhanced-queue-list";

+ 456 - 0
webui/components/features/queue/index.tsx

@@ -0,0 +1,456 @@
+"use client";
+
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Progress } from "@/components/ui/progress";
+import { type QueueStatus, type JobInfo, type JobDetailsResponse, apiClient } from "@/lib/api";
+import {
+  Download,
+  RefreshCw,
+  Image as ImageIcon,
+  X,
+  ZoomIn,
+
+  Clock,
+  CheckCircle,
+  XCircle,
+  Play,
+  Activity,
+} from "lucide-react";
+import { downloadAuthenticatedImage } from "@/lib/utils";
+
+interface QueueJob extends JobInfo {
+  details?: JobDetailsResponse;
+  thumbnailUrl?: string;
+  fullSizeUrl?: string;
+}
+
+interface ImageModalProps {
+  image: { url: string; filename: string; jobId: string; job?: QueueJob } | null;
+  isOpen: boolean;
+  onClose: () => void;
+}
+
+function ImageModal({ image, isOpen, onClose }: ImageModalProps) {
+  if (!isOpen || !image) return null;
+
+  const handleBackdropClick = (e: React.MouseEvent) => {
+    if (e.target === e.currentTarget) {
+      onClose();
+    }
+  };
+
+  return (
+    <div
+      className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
+      onClick={handleBackdropClick}
+    >
+      <div className="relative max-w-[90vw] max-h-[90vh] flex flex-col">
+        {/* Header with image info */}
+        <div className="bg-background border-b border-border p-4 rounded-t-lg">
+          <div className="flex items-center justify-between">
+            <div className="flex items-center gap-2">
+              <ImageIcon className="h-5 w-5" />
+              <span className="font-medium">Generated Image</span>
+              <Badge variant="secondary">{image.job?.prompt ? "Completed" : "Unknown"}</Badge>
+            </div>
+            <Button
+              size="icon"
+              variant="ghost"
+              onClick={onClose}
+              className="h-8 w-8"
+            >
+              <X className="h-4 w-4" />
+            </Button>
+          </div>
+
+          {/* Job metadata */}
+          <div className="mt-2 text-sm text-muted-foreground grid grid-cols-2 md:grid-cols-4 gap-2">
+            <span>Job ID: {image.jobId}</span>
+            <span>Status: {image.job?.status}</span>
+            {image.job?.created_at && (
+              <span>Created: {new Date(image.job.created_at).toLocaleString()}</span>
+            )}
+            {image.job?.end_time && (
+              <span>Duration: {Math.round((image.job.end_time - (image.job.start_time || image.job.queued_time || 0)) / 1000)}s</span>
+            )}
+          </div>
+
+          {image.job?.prompt && (
+            <div className="mt-2">
+              <p className="text-sm font-medium">Prompt:</p>
+              <p className="text-sm text-muted-foreground line-clamp-2">
+                {image.job.prompt}
+              </p>
+            </div>
+          )}
+        </div>
+
+        {/* Image container */}
+        <div className="flex items-center justify-center bg-muted/50 p-4 rounded-b-lg">
+          <img
+            src={image.url}
+            alt="Generated image"
+            className="max-w-full max-h-[70vh] object-contain rounded-lg shadow-lg"
+          />
+        </div>
+
+        {/* Download button */}
+        <div className="absolute bottom-4 right-4">
+          <Button
+            size="sm"
+            onClick={() => {
+              const authToken = localStorage.getItem("auth_token");
+              const unixUser = localStorage.getItem("unix_user");
+              downloadAuthenticatedImage(
+                image.url,
+                `queue-${image.jobId}-${image.filename}`,
+                authToken || undefined,
+                unixUser || undefined,
+              );
+            }}
+          >
+            <Download className="h-4 w-4 mr-2" />
+            Download
+          </Button>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function getStatusIcon(status: string) {
+  switch (status) {
+    case "completed":
+      return <CheckCircle className="h-4 w-4 text-green-500" />;
+    case "processing":
+      return <Play className="h-4 w-4 text-blue-500" />;
+    case "queued":
+      return <Clock className="h-4 w-4 text-yellow-500" />;
+    case "failed":
+      return <XCircle className="h-4 w-4 text-red-500" />;
+    case "cancelled":
+      return <XCircle className="h-4 w-4 text-gray-500" />;
+    default:
+      return <Clock className="h-4 w-4 text-gray-500" />;
+  }
+}
+
+function getStatusColor(status: string) {
+  switch (status) {
+    case "completed":
+      return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200";
+    case "processing":
+      return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200";
+    case "queued":
+      return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200";
+    case "failed":
+      return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200";
+    case "cancelled":
+      return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200";
+    default:
+      return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200";
+  }
+}
+
+interface EnhancedQueueListProps {
+  queueStatus: QueueStatus | null;
+  loading: boolean;
+  actionLoading: boolean;
+}
+
+export function EnhancedQueueList({
+  queueStatus,
+  loading,
+  actionLoading,
+}: EnhancedQueueListProps) {
+  const [jobs, setJobs] = useState<QueueJob[]>([]);
+  const [selectedImage, setSelectedImage] = useState<{ url: string; filename: string; jobId: string; job?: QueueJob } | null>(null);
+  const [isModalOpen, setIsModalOpen] = useState(false);
+
+  // Enrich jobs with details and image URLs
+  const enrichJobs = async (jobList: JobInfo[]) => {
+    const enrichedJobs: QueueJob[] = await Promise.all(
+      jobList.map(async (job) => {
+        const jobId = job.request_id || job.id || "";
+        let details: JobDetailsResponse | undefined;
+        let thumbnailUrl: string | undefined;
+        let fullSizeUrl: string | undefined;
+
+        if (jobId && (job.status === "completed" || job.status === "processing")) {
+          try {
+            details = await apiClient.getJobStatus(jobId);
+            
+            // If job has outputs, create URLs for the first image
+            if (details?.job?.outputs && details.job.outputs.length > 0) {
+              const firstOutput = details.job.outputs[0];
+              thumbnailUrl = apiClient.getImageUrl(jobId, firstOutput.filename);
+              fullSizeUrl = apiClient.getImageUrl(jobId, firstOutput.filename);
+            }
+          } catch (err) {
+            console.warn(`Failed to fetch details for job ${jobId}:`, err);
+          }
+        }
+
+        return {
+          ...job,
+          details,
+          thumbnailUrl,
+          fullSizeUrl,
+        };
+      })
+    );
+
+    setJobs(enrichedJobs);
+  };
+
+  // Update jobs when queue status changes
+  if (queueStatus && queueStatus.jobs !== jobs.map(j => ({ ...j, details: undefined, thumbnailUrl: undefined, fullSizeUrl: undefined }))) {
+    enrichJobs(queueStatus.jobs);
+  }
+
+  const handleImageClick = (job: QueueJob, url: string, filename: string) => {
+    setSelectedImage({ url, filename, jobId: job.request_id || job.id || "", job });
+    setIsModalOpen(true);
+  };
+
+  const handleModalClose = () => {
+    setIsModalOpen(false);
+    setSelectedImage(null);
+  };
+
+  const handleDownload = (job: QueueJob, url: string, filename: string, e: React.MouseEvent) => {
+    e.stopPropagation();
+    const authToken = localStorage.getItem("auth_token");
+    const unixUser = localStorage.getItem("unix_user");
+    downloadAuthenticatedImage(
+      url,
+      `queue-${job.request_id || job.id}-${filename}`,
+      authToken || undefined,
+      unixUser || undefined,
+    );
+  };
+
+  const formatDuration = useCallback((job: QueueJob) => {
+    if (!job.start_time) return "N/A";
+    
+    const now = Date.now();
+    const endTime = job.end_time || now;
+    const duration = Math.round((endTime - job.start_time) / 1000);
+    
+    if (duration < 60) return `${duration}s`;
+    if (duration < 3600) return `${Math.floor(duration / 60)}m ${duration % 60}s`;
+    return `${Math.floor(duration / 3600)}h ${Math.floor((duration % 3600) / 60)}m`;
+  }, []);
+
+  const formatDate = (dateString: string) => {
+    return new Date(dateString).toLocaleString();
+  };
+
+  if (loading) {
+    return (
+      <div className="flex items-center justify-center h-96">
+        <div className="flex items-center gap-2">
+          <RefreshCw className="h-6 w-6 animate-spin" />
+          <span>Loading queue...</span>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <>
+      {/* Queue Stats */}
+      <div className="grid gap-4 md:grid-cols-4 mb-6">
+        <Card>
+          <CardContent className="p-4">
+            <div className="flex items-center gap-2">
+              <Activity className="h-5 w-5 text-blue-500" />
+              <div>
+                <p className="text-sm font-medium">Total Jobs</p>
+                <p className="text-2xl font-bold">{queueStatus?.jobs.length || 0}</p>
+              </div>
+            </div>
+          </CardContent>
+        </Card>
+        <Card>
+          <CardContent className="p-4">
+            <div className="flex items-center gap-2">
+              <Play className="h-5 w-5 text-blue-500" />
+              <div>
+                <p className="text-sm font-medium">Processing</p>
+                <p className="text-2xl font-bold">{jobs.filter(j => j.status === "processing").length}</p>
+              </div>
+            </div>
+          </CardContent>
+        </Card>
+        <Card>
+          <CardContent className="p-4">
+            <div className="flex items-center gap-2">
+              <Clock className="h-5 w-5 text-yellow-500" />
+              <div>
+                <p className="text-sm font-medium">Queued</p>
+                <p className="text-2xl font-bold">{jobs.filter(j => j.status === "queued").length}</p>
+              </div>
+            </div>
+          </CardContent>
+        </Card>
+        <Card>
+          <CardContent className="p-4">
+            <div className="flex items-center gap-2">
+              <CheckCircle className="h-5 w-5 text-green-500" />
+              <div>
+                <p className="text-sm font-medium">Completed</p>
+                <p className="text-2xl font-bold">{jobs.filter(j => j.status === "completed").length}</p>
+              </div>
+            </div>
+          </CardContent>
+        </Card>
+      </div>
+
+      {/* Queue Items */}
+      {jobs.length === 0 ? (
+        <div className="flex flex-col items-center justify-center h-96 border-2 border-dashed border-border rounded-lg">
+          <ImageIcon className="h-12 w-12 text-muted-foreground mb-4" />
+          <h3 className="text-lg font-medium text-muted-foreground mb-2">
+            No jobs in queue
+          </h3>
+          <p className="text-sm text-muted-foreground text-center max-w-md">
+            Generate some images first using the Text to Image, Image to Image, or Inpainting tools.
+          </p>
+        </div>
+      ) : (
+        <div className="space-y-4">
+          {jobs.map((job, index) => (
+            <Card
+              key={`${job.request_id || job.id}-${index}`}
+              className="group overflow-hidden hover:shadow-lg transition-all duration-200"
+            >
+              <CardContent className="p-4">
+                <div className="flex gap-4">
+                  {/* Thumbnail */}
+                  {job.thumbnailUrl ? (
+                    <div className="flex-shrink-0">
+                      <div className="relative w-24 h-24 rounded-lg overflow-hidden cursor-pointer"
+                           onClick={() => handleImageClick(job, job.fullSizeUrl!, job.details?.job?.outputs?.[0]?.filename || "image.png")}>
+                        <img
+                          src={job.thumbnailUrl}
+                          alt="Generated image thumbnail"
+                          className="w-full h-full object-cover"
+                        />
+                        
+                        {/* Overlay with actions */}
+                        <div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-1">
+                          <Button
+                            size="icon"
+                            variant="secondary"
+                            className="h-6 w-6"
+                            onClick={(e) => {
+                              e.stopPropagation();
+                              handleImageClick(job, job.fullSizeUrl!, job.details?.job?.outputs?.[0]?.filename || "image.png");
+                            }}
+                            title="View full size"
+                          >
+                            <ZoomIn className="h-3 w-3" />
+                          </Button>
+                          <Button
+                            size="icon"
+                            variant="secondary"
+                            className="h-6 w-6"
+                            onClick={(e) => handleDownload(job, job.fullSizeUrl!, job.details?.job?.outputs?.[0]?.filename || "image.png", e)}
+                            title="Download image"
+                          >
+                            <Download className="h-3 w-3" />
+                          </Button>
+                        </div>
+                      </div>
+                    </div>
+                  ) : (
+                    <div className="flex-shrink-0 w-24 h-24 rounded-lg bg-muted flex items-center justify-center">
+                      <ImageIcon className="h-8 w-8 text-muted-foreground" />
+                    </div>
+                  )}
+
+                  {/* Job Info */}
+                  <div className="flex-1 min-w-0">
+                    <div className="flex items-start justify-between gap-4">
+                      <div className="flex-1 min-w-0">
+                        <div className="flex items-center gap-2 mb-2">
+                          {getStatusIcon(job.status)}
+                          <Badge className={getStatusColor(job.status)}>
+                            {job.status}
+                          </Badge>
+                          <span className="text-sm text-muted-foreground font-mono">
+                            {job.request_id || job.id}
+                          </span>
+                          {job.position !== undefined && job.status === "queued" && (
+                            <Badge variant="outline">Position: {job.position}</Badge>
+                          )}
+                        </div>
+
+                        {job.prompt && (
+                          <p className="text-sm text-muted-foreground line-clamp-2 mb-2">
+                            {job.prompt}
+                          </p>
+                        )}
+
+                        <div className="flex items-center gap-4 text-xs text-muted-foreground">
+                          {job.created_at && (
+                            <span>Created: {formatDate(job.created_at)}</span>
+                          )}
+                          <span>Duration: {formatDuration(job)}</span>
+                          {job.progress !== undefined && job.status === "processing" && (
+                            <span>Progress: {job.progress}%</span>
+                          )}
+                        </div>
+
+                        {job.progress !== undefined && job.status === "processing" && (
+                          <div className="mt-2">
+                            <Progress value={job.progress} className="h-2" />
+                          </div>
+                        )}
+
+                        {job.error_message && (
+                          <div className="mt-2 text-sm text-destructive">
+                            Error: {job.error_message}
+                          </div>
+                        )}
+                      </div>
+
+                      {/* Actions */}
+                      <div className="flex items-center gap-2">
+                        {(job.status === "queued" || job.status === "processing") && (
+                          <Button
+                            size="sm"
+                            variant="outline"
+                            onClick={(e) => {
+                              e.stopPropagation();
+                              onCancelJob(job.request_id || job.id || "");
+                            }}
+                            disabled={actionLoading}
+                          >
+                            <X className="h-4 w-4 mr-2" />
+                            Cancel
+                          </Button>
+                        )}
+                      </div>
+                    </div>
+                  </div>
+                </div>
+              </CardContent>
+            </Card>
+          ))}
+        </div>
+      )}
+
+      {/* Image Modal */}
+      <ImageModal
+        image={selectedImage}
+        isOpen={isModalOpen}
+        onClose={handleModalClose}
+      />
+    </>
+  );
+}