Procházet zdrojové kódy

feat: add image resize, URL loading for inpainting, and UI improvements

- Add image resize and crop API endpoints (/api/image/resize, /api/image/crop)
- Implement URL loading support for inpainting page with tabs for file/URL input
- Add width/height controls to img2img and inpainting pages with auto-resize from original image
- Remove model selection dropdown from text2img page (model must be loaded from model list first)
- Add ImageInput component with file upload and URL loading support
- Add image validation library with base64 conversion and CORS handling
- Ensure image resize operations always use the original image, not previously resized versions
- Add loading indicators during image resize operations
- Clean webui build directory before building to ensure fresh builds

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
Fszontagh před 3 měsíci
rodič
revize
fc23c0e1df

+ 3 - 0
CMakeLists.txt

@@ -109,6 +109,9 @@ if(BUILD_WEBUI)
         # Create custom target for building the web UI
         add_custom_target(webui-build
             COMMAND ${CMAKE_COMMAND} -E echo "Building Web UI..."
+            # Clean build/webui directory before building
+            COMMAND ${CMAKE_COMMAND} -E remove_directory ${WEBUI_OUTPUT_DIR}
+            COMMAND ${CMAKE_COMMAND} -E make_directory ${WEBUI_OUTPUT_DIR}
             # Generate version file
             COMMAND ${CMAKE_COMMAND} -E echo "{\"version\":\"${GIT_COMMIT_HASH}\",\"buildTime\":\"${BUILD_TIMESTAMP}\"}" > ${WEBUI_SOURCE_DIR}/public/version.json
             COMMAND ${NPM_EXECUTABLE} install

+ 16 - 1
include/server.h

@@ -201,6 +201,21 @@ private:
      */
     void handleDownloadOutput(const httplib::Request& req, httplib::Response& res);
 
+    /**
+     * @brief Download image from URL and return as base64 endpoint handler
+     */
+    void handleDownloadImageFromUrl(const httplib::Request& req, httplib::Response& res);
+
+    /**
+     * @brief Resize image endpoint handler
+     */
+    void handleImageResize(const httplib::Request& req, httplib::Response& res);
+
+    /**
+     * @brief Crop image endpoint handler
+     */
+    void handleImageCrop(const httplib::Request& req, httplib::Response& res);
+
     // Specialized generation endpoints
     /**
      * @brief Text-to-image generation endpoint handler
@@ -439,4 +454,4 @@ private:
     std::shared_ptr<AuthMiddleware> m_authMiddleware; ///< Authentication middleware instance
 };
 
-#endif // SERVER_H
+#endif // SERVER_H

+ 11 - 5
src/model_manager.cpp

@@ -82,6 +82,8 @@ public:
                 return "taesd";
             case ModelType::VAE:
                 return "vae";
+            case ModelType::DIFFUSION_MODELS:
+                return "diffusion_models";
             default:
                 return "";
         }
@@ -117,6 +119,7 @@ public:
     const std::vector<std::string>& getFileExtensions(ModelType type) const {
         switch (type) {
             case ModelType::CHECKPOINT:
+            case ModelType::DIFFUSION_MODELS:
                 return CHECKPOINT_FILE_EXTENSIONS;
             case ModelType::EMBEDDING:
                 return EMBEDDING_FILE_EXTENSIONS;
@@ -232,8 +235,8 @@ public:
                     }
                 } else if (dirName == "embeddings" || dirName == "textual-inversion") {
                 } else if (dirName == "diffusion_models" || dirName == "diffusion") {
-                    if (isExtensionMatch(extension, ModelType::CHECKPOINT)) {
-                        return ModelType::CHECKPOINT;
+                    if (isExtensionMatch(extension, ModelType::DIFFUSION_MODELS)) {
+                        return ModelType::DIFFUSION_MODELS;
                     }
                     if (isExtensionMatch(extension, ModelType::EMBEDDING)) {
                         return ModelType::EMBEDDING;
@@ -383,8 +386,8 @@ public:
                                 info.sha256 = ""; // No cached hash file
                             }
 
-                            // Detect architecture for checkpoint models
-                            if (detectedType == ModelType::CHECKPOINT) {
+                            // Detect architecture for checkpoint models (including diffusion_models)
+                            if (detectedType == ModelType::CHECKPOINT || detectedType == ModelType::DIFFUSION_MODELS) {
                                 try {
                                     ModelDetectionResult detection = ModelDetector::detectModel(info.fullPath);
 
@@ -487,7 +490,8 @@ bool ModelManager::scanModelsDirectory() {
     // Collect unique directories to scan
     std::vector<ModelType> allTypes = {
         ModelType::CHECKPOINT, ModelType::CONTROLNET, ModelType::LORA,
-        ModelType::VAE, ModelType::TAESD, ModelType::ESRGAN, ModelType::EMBEDDING
+        ModelType::VAE, ModelType::TAESD, ModelType::ESRGAN, ModelType::EMBEDDING,
+        ModelType::DIFFUSION_MODELS
     };
 
     for (const auto& type : allTypes) {
@@ -724,6 +728,8 @@ std::string ModelManager::modelTypeToString(ModelType type) {
             return "upscaler";
         case ModelType::EMBEDDING:
             return "embedding";
+        case ModelType::DIFFUSION_MODELS:
+            return "checkpoint";
         default:
             return "unknown";
     }

+ 443 - 22
src/server.cpp

@@ -303,6 +303,21 @@ void Server::registerEndpoints() {
         handleDownloadOutput(req, res);
     });
 
+    // Download image from URL endpoint (public for CORS-free image handling)
+    m_httpServer->Get("/api/image/download", [this](const httplib::Request& req, httplib::Response& res) {
+        handleDownloadImageFromUrl(req, res);
+    });
+
+    // Image resize endpoint (protected)
+    m_httpServer->Post("/api/image/resize", withAuth([this](const httplib::Request& req, httplib::Response& res) {
+        handleImageResize(req, res);
+    }));
+
+    // Image crop endpoint (protected)
+    m_httpServer->Post("/api/image/crop", withAuth([this](const httplib::Request& req, httplib::Response& res) {
+        handleImageCrop(req, res);
+    }));
+
     // Job status endpoint (now protected - require authentication)
     m_httpServer->Get("/api/queue/job/(.*)", withAuth([this](const httplib::Request& req, httplib::Response& res) {
         handleJobStatus(req, res);
@@ -949,18 +964,25 @@ void Server::handleModelsList(const httplib::Request& req, httplib::Response& re
         std::string dateFilter = req.get_param_value("date");
         std::string sizeFilter = req.get_param_value("size");
 
-        // Pagination parameters
+        // Pagination parameters - only apply if limit is explicitly provided
         int page = 1;
         int limit = 50;
+        bool usePagination = false;
+
         try {
-            if (!req.get_param_value("page").empty()) {
-                page = std::stoi(req.get_param_value("page"));
-                if (page < 1) page = 1;
-            }
             if (!req.get_param_value("limit").empty()) {
                 limit = std::stoi(req.get_param_value("limit"));
-                if (limit < 1) limit = 1;
-                if (limit > 200) limit = 200; // Max limit to prevent performance issues
+                // Special case: limit<=0 means return all models (no pagination)
+                if (limit <= 0) {
+                    usePagination = false;
+                    limit = INT_MAX; // Set to very large number to effectively disable pagination
+                } else {
+                    usePagination = true;
+                    if (!req.get_param_value("page").empty()) {
+                        page = std::stoi(req.get_param_value("page"));
+                        if (page < 1) page = 1;
+                    }
+                }
             }
         } catch (const std::exception& e) {
             sendErrorResponse(res, "Invalid pagination parameters", 400, "INVALID_PAGINATION", requestId);
@@ -1097,28 +1119,47 @@ void Server::handleModelsList(const httplib::Request& req, httplib::Response& re
             });
         }
 
-        // Apply pagination
+        // Apply pagination only if limit parameter was provided
         int totalCount = models.size();
-        int totalPages = (totalCount + limit - 1) / limit;
-        int startIndex = (page - 1) * limit;
-        int endIndex = std::min(startIndex + limit, totalCount);
-
         json paginatedModels = json::array();
-        for (int i = startIndex; i < endIndex; ++i) {
-            paginatedModels.push_back(models[i]);
-        }
+        json paginationInfo = json::object();
 
-        // Build comprehensive response
-        json response = {
-            {"models", paginatedModels},
-            {"pagination", {
+        if (usePagination) {
+            // Apply pagination
+            int totalPages = (totalCount + limit - 1) / limit;
+            int startIndex = (page - 1) * limit;
+            int endIndex = std::min(startIndex + limit, totalCount);
+
+            for (int i = startIndex; i < endIndex; ++i) {
+                paginatedModels.push_back(models[i]);
+            }
+
+            paginationInfo = {
                 {"page", page},
                 {"limit", limit},
                 {"total_count", totalCount},
                 {"total_pages", totalPages},
                 {"has_next", page < totalPages},
                 {"has_prev", page > 1}
-            }},
+            };
+        } else {
+            // Return all models without pagination
+            paginatedModels = models;
+
+            paginationInfo = {
+                {"page", 1},
+                {"limit", totalCount},
+                {"total_count", totalCount},
+                {"total_pages", 1},
+                {"has_next", false},
+                {"has_prev", false}
+            };
+        }
+
+        // Build comprehensive response
+        json response = {
+            {"models", paginatedModels},
+            {"pagination", paginationInfo},
             {"filters_applied", {
                 {"type", typeFilter.empty() ? json(nullptr) : json(typeFilter)},
                 {"search", searchQuery.empty() ? json(nullptr) : json(searchQuery)},
@@ -1343,6 +1384,7 @@ void Server::handleClearQueue(const httplib::Request& req, httplib::Response& re
     }
 }
 
+
 void Server::handleDownloadOutput(const httplib::Request& req, httplib::Response& res) {
     try {
         // Extract job ID and filename from URL path
@@ -1447,6 +1489,367 @@ void Server::handleDownloadOutput(const httplib::Request& req, httplib::Response
     }
 }
 
+void Server::handleImageResize(const httplib::Request& req, httplib::Response& res) {
+    std::string requestId = generateRequestId();
+
+    try {
+        // Parse JSON request body
+        json requestJson = json::parse(req.body);
+
+        // Validate required fields
+        if (!requestJson.contains("image") || !requestJson["image"].is_string()) {
+            sendErrorResponse(res, "Missing or invalid 'image' field", 400, "INVALID_PARAMETERS", requestId);
+            return;
+        }
+
+        if (!requestJson.contains("width") || !requestJson["width"].is_number_integer()) {
+            sendErrorResponse(res, "Missing or invalid 'width' field", 400, "INVALID_PARAMETERS", requestId);
+            return;
+        }
+
+        if (!requestJson.contains("height") || !requestJson["height"].is_number_integer()) {
+            sendErrorResponse(res, "Missing or invalid 'height' field", 400, "INVALID_PARAMETERS", requestId);
+            return;
+        }
+
+        std::string imageInput = requestJson["image"];
+        int targetWidth = requestJson["width"];
+        int targetHeight = requestJson["height"];
+
+        // Validate dimensions
+        if (targetWidth < 1 || targetWidth > 4096) {
+            sendErrorResponse(res, "Width must be between 1 and 4096", 400, "INVALID_DIMENSIONS", requestId);
+            return;
+        }
+
+        if (targetHeight < 1 || targetHeight > 4096) {
+            sendErrorResponse(res, "Height must be between 1 and 4096", 400, "INVALID_DIMENSIONS", requestId);
+            return;
+        }
+
+        // Load the source image
+        auto [imageData, sourceWidth, sourceHeight, sourceChannels, success, loadError] = loadImageFromInput(imageInput);
+
+        if (!success) {
+            sendErrorResponse(res, "Failed to load image: " + loadError, 400, "IMAGE_LOAD_ERROR", requestId);
+            return;
+        }
+
+        // Convert image data to stb_image format for processing
+        int channels = 3; // Force RGB
+        size_t sourceSize = sourceWidth * sourceHeight * channels;
+        std::vector<uint8_t> sourcePixels(sourceSize);
+        std::memcpy(sourcePixels.data(), imageData.data(), std::min(imageData.size(), sourceSize));
+
+        // Resize the image using stb_image_resize if available, otherwise use simple scaling
+        std::vector<uint8_t> resizedPixels(targetWidth * targetHeight * channels);
+
+        // Simple nearest-neighbor scaling for now (can be improved with better algorithms)
+        float xScale = static_cast<float>(sourceWidth) / targetWidth;
+        float yScale = static_cast<float>(sourceHeight) / targetHeight;
+
+        for (int y = 0; y < targetHeight; y++) {
+            for (int x = 0; x < targetWidth; x++) {
+                int sourceX = static_cast<int>(x * xScale);
+                int sourceY = static_cast<int>(y * yScale);
+
+                // Clamp to source bounds
+                sourceX = std::min(sourceX, sourceWidth - 1);
+                sourceY = std::min(sourceY, sourceHeight - 1);
+
+                for (int c = 0; c < channels; c++) {
+                    resizedPixels[(y * targetWidth + x) * channels + c] =
+                        sourcePixels[(sourceY * sourceWidth + sourceX) * channels + c];
+                }
+            }
+        }
+
+        // Convert resized image to base64
+        std::string base64Data = Utils::base64Encode(resizedPixels);
+
+        // Determine MIME type based on input
+        std::string mimeType = "image/jpeg"; // default
+        if (Utils::startsWith(imageInput, "data:image/png")) {
+            mimeType = "image/png";
+        } else if (Utils::startsWith(imageInput, "data:image/gif")) {
+            mimeType = "image/gif";
+        } else if (Utils::startsWith(imageInput, "data:image/webp")) {
+            mimeType = "image/webp";
+        } else if (Utils::startsWith(imageInput, "data:image/bmp")) {
+            mimeType = "image/bmp";
+        }
+
+        // Create data URL format
+        std::string dataUrl = "data:" + mimeType + ";base64," + base64Data;
+
+        // Build response
+        json response = {
+            {"success", true},
+            {"original_width", sourceWidth},
+            {"original_height", sourceHeight},
+            {"resized_width", targetWidth},
+            {"resized_height", targetHeight},
+            {"mime_type", mimeType},
+            {"base64_data", dataUrl},
+            {"file_size_bytes", resizedPixels.size()},
+            {"request_id", requestId}
+        };
+
+        sendJsonResponse(res, response, 200);
+
+        std::cout << "Successfully resized image from " << sourceWidth << "x" << sourceHeight
+                  << " to " << targetWidth << "x" << targetHeight
+                  << " (" << resizedPixels.size() << " bytes)" << std::endl;
+
+    } catch (const json::parse_error& e) {
+        sendErrorResponse(res, std::string("Invalid JSON: ") + e.what(), 400, "JSON_PARSE_ERROR", requestId);
+    } catch (const std::exception& e) {
+        std::cerr << "Exception in handleImageResize: " << e.what() << std::endl;
+        sendErrorResponse(res, std::string("Failed to resize image: ") + e.what(), 500, "INTERNAL_ERROR", requestId);
+    }
+}
+
+void Server::handleImageCrop(const httplib::Request& req, httplib::Response& res) {
+    std::string requestId = generateRequestId();
+
+    try {
+        // Parse JSON request body
+        json requestJson = json::parse(req.body);
+
+        // Validate required fields
+        if (!requestJson.contains("image") || !requestJson["image"].is_string()) {
+            sendErrorResponse(res, "Missing or invalid 'image' field", 400, "INVALID_PARAMETERS", requestId);
+            return;
+        }
+
+        if (!requestJson.contains("x") || !requestJson["x"].is_number_integer()) {
+            sendErrorResponse(res, "Missing or invalid 'x' field", 400, "INVALID_PARAMETERS", requestId);
+            return;
+        }
+
+        if (!requestJson.contains("y") || !requestJson["y"].is_number_integer()) {
+            sendErrorResponse(res, "Missing or invalid 'y' field", 400, "INVALID_PARAMETERS", requestId);
+            return;
+        }
+
+        if (!requestJson.contains("width") || !requestJson["width"].is_number_integer()) {
+            sendErrorResponse(res, "Missing or invalid 'width' field", 400, "INVALID_PARAMETERS", requestId);
+            return;
+        }
+
+        if (!requestJson.contains("height") || !requestJson["height"].is_number_integer()) {
+            sendErrorResponse(res, "Missing or invalid 'height' field", 400, "INVALID_PARAMETERS", requestId);
+            return;
+        }
+
+        std::string imageInput = requestJson["image"];
+        int cropX = requestJson["x"];
+        int cropY = requestJson["y"];
+        int cropWidth = requestJson["width"];
+        int cropHeight = requestJson["height"];
+
+        // Load the source image
+        auto [imageData, sourceWidth, sourceHeight, sourceChannels, success, loadError] = loadImageFromInput(imageInput);
+
+        if (!success) {
+            sendErrorResponse(res, "Failed to load image: " + loadError, 400, "IMAGE_LOAD_ERROR", requestId);
+            return;
+        }
+
+        // Validate crop dimensions
+        if (cropX < 0 || cropY < 0) {
+            sendErrorResponse(res, "Crop coordinates must be non-negative", 400, "INVALID_CROP_AREA", requestId);
+            return;
+        }
+
+        if (cropX + cropWidth > sourceWidth || cropY + cropHeight > sourceHeight) {
+            sendErrorResponse(res, "Crop area exceeds image dimensions", 400, "INVALID_CROP_AREA", requestId);
+            return;
+        }
+
+        if (cropWidth < 1 || cropHeight < 1) {
+            sendErrorResponse(res, "Crop width and height must be at least 1", 400, "INVALID_CROP_AREA", requestId);
+            return;
+        }
+
+        // Convert image data to stb_image format for processing
+        int channels = 3; // Force RGB
+        size_t sourceSize = sourceWidth * sourceHeight * channels;
+        std::vector<uint8_t> sourcePixels(sourceSize);
+        std::memcpy(sourcePixels.data(), imageData.data(), std::min(imageData.size(), sourceSize));
+
+        // Crop the image
+        std::vector<uint8_t> croppedPixels(cropWidth * cropHeight * channels);
+
+        for (int y = 0; y < cropHeight; y++) {
+            for (int x = 0; x < cropWidth; x++) {
+                int sourceX = cropX + x;
+                int sourceY = cropY + y;
+
+                for (int c = 0; c < channels; c++) {
+                    croppedPixels[(y * cropWidth + x) * channels + c] =
+                        sourcePixels[(sourceY * sourceWidth + sourceX) * channels + c];
+                }
+            }
+        }
+
+        // Convert cropped image to base64
+        std::string base64Data = Utils::base64Encode(croppedPixels);
+
+        // Determine MIME type based on input
+        std::string mimeType = "image/jpeg"; // default
+        if (Utils::startsWith(imageInput, "data:image/png")) {
+            mimeType = "image/png";
+        } else if (Utils::startsWith(imageInput, "data:image/gif")) {
+            mimeType = "image/gif";
+        } else if (Utils::startsWith(imageInput, "data:image/webp")) {
+            mimeType = "image/webp";
+        } else if (Utils::startsWith(imageInput, "data:image/bmp")) {
+            mimeType = "image/bmp";
+        }
+
+        // Create data URL format
+        std::string dataUrl = "data:" + mimeType + ";base64," + base64Data;
+
+        // Build response
+        json response = {
+            {"success", true},
+            {"original_width", sourceWidth},
+            {"original_height", sourceHeight},
+            {"crop_x", cropX},
+            {"crop_y", cropY},
+            {"cropped_width", cropWidth},
+            {"cropped_height", cropHeight},
+            {"mime_type", mimeType},
+            {"base64_data", dataUrl},
+            {"file_size_bytes", croppedPixels.size()},
+            {"request_id", requestId}
+        };
+
+        sendJsonResponse(res, response, 200);
+
+        std::cout << "Successfully cropped image from " << sourceWidth << "x" << sourceHeight
+                  << " to " << cropWidth << "x" << cropHeight
+                  << " at (" << cropX << "," << cropY << ")"
+                  << " (" << croppedPixels.size() << " bytes)" << std::endl;
+
+    } catch (const json::parse_error& e) {
+        sendErrorResponse(res, std::string("Invalid JSON: ") + e.what(), 400, "JSON_PARSE_ERROR", requestId);
+    } catch (const std::exception& e) {
+        std::cerr << "Exception in handleImageCrop: " << e.what() << std::endl;
+        sendErrorResponse(res, std::string("Failed to crop image: ") + e.what(), 500, "INTERNAL_ERROR", requestId);
+    }
+}
+
+void Server::handleDownloadImageFromUrl(const httplib::Request& req, httplib::Response& res) {
+    std::string requestId = generateRequestId();
+
+    try {
+        // Parse query parameters
+        std::string imageUrl = req.get_param_value("url");
+
+        if (imageUrl.empty()) {
+            sendErrorResponse(res, "Missing 'url' parameter", 400, "MISSING_URL", requestId);
+            return;
+        }
+
+        // Basic URL format validation
+        if (!Utils::startsWith(imageUrl, "http://") && !Utils::startsWith(imageUrl, "https://")) {
+            sendErrorResponse(res, "Invalid URL format. URL must start with http:// or https://", 400, "INVALID_URL_FORMAT", requestId);
+            return;
+        }
+
+        // Extract filename from URL for content type detection
+        std::string filename = imageUrl;
+        size_t lastSlash = imageUrl.find_last_of('/');
+        if (lastSlash != std::string::npos) {
+            filename = imageUrl.substr(lastSlash + 1);
+        }
+
+        // Remove query parameters and fragments
+        size_t questionMark = filename.find('?');
+        if (questionMark != std::string::npos) {
+            filename = filename.substr(0, questionMark);
+        }
+        size_t hashMark = filename.find('#');
+        if (hashMark != std::string::npos) {
+            filename = filename.substr(0, hashMark);
+        }
+
+        // Check if URL has image extension
+        std::string extension;
+        size_t lastDot = filename.find_last_of('.');
+        if (lastDot != std::string::npos) {
+            extension = filename.substr(lastDot + 1);
+            std::transform(extension.begin(), extension.end(), extension.begin(), ::tolower);
+        }
+
+        // Validate image extension
+        const std::vector<std::string> validExtensions = {"jpg", "jpeg", "png", "gif", "webp", "bmp"};
+        if (extension.empty() || std::find(validExtensions.begin(), validExtensions.end(), extension) == validExtensions.end()) {
+            sendErrorResponse(res, "URL must point to an image file with a valid extension: " +
+                             std::accumulate(validExtensions.begin(), validExtensions.end(), std::string(),
+                                           [](const std::string& a, const std::string& b) {
+                                               return a.empty() ? b : a + ", " + b;
+                                           }), 400, "INVALID_IMAGE_EXTENSION", requestId);
+            return;
+        }
+
+        // Load image using existing loadImageFromInput function
+        auto [imageData, width, height, channels, success, error] = loadImageFromInput(imageUrl);
+
+        if (!success) {
+            sendErrorResponse(res, "Failed to download image from URL: " + error, 400, "IMAGE_DOWNLOAD_FAILED", requestId);
+            return;
+        }
+
+        // Convert image data to base64
+        std::string base64Data = Utils::base64Encode(imageData);
+
+        // Determine MIME type based on extension
+        std::string mimeType = "image/jpeg"; // default
+        if (extension == "png") {
+            mimeType = "image/png";
+        } else if (extension == "gif") {
+            mimeType = "image/gif";
+        } else if (extension == "webp") {
+            mimeType = "image/webp";
+        } else if (extension == "bmp") {
+            mimeType = "image/bmp";
+        } else if (extension == "jpg" || extension == "jpeg") {
+            mimeType = "image/jpeg";
+        }
+
+        // Create data URL format
+        std::string dataUrl = "data:" + mimeType + ";base64," + base64Data;
+
+        // Build response
+        json response = {
+            {"success", true},
+            {"url", imageUrl},
+            {"filename", filename},
+            {"width", width},
+            {"height", height},
+            {"channels", channels},
+            {"mime_type", mimeType},
+            {"base64_data", dataUrl},
+            {"file_size_bytes", imageData.size()},
+            {"request_id", requestId}
+        };
+
+        sendJsonResponse(res, response, 200);
+
+        std::cout << "Successfully downloaded and encoded image from URL: " << imageUrl
+                  << " (" << width << "x" << height << ", " << imageData.size() << " bytes)" << std::endl;
+
+    } catch (const json::parse_error& e) {
+        sendErrorResponse(res, std::string("Invalid JSON: ") + e.what(), 400, "JSON_PARSE_ERROR", requestId);
+    } catch (const std::exception& e) {
+        std::cerr << "Exception in handleDownloadImageFromUrl: " << e.what() << std::endl;
+        sendErrorResponse(res, std::string("Failed to download image from URL: ") + e.what(), 500, "INTERNAL_ERROR", requestId);
+    }
+}
+
 void Server::sendJsonResponse(httplib::Response& res, const nlohmann::json& json, int status_code) {
     res.set_header("Content-Type", "application/json");
     res.status = status_code;
@@ -4488,7 +4891,17 @@ void Server::serverThreadFunction(const std::string& host, int port) {
         if (test_socket >= 0) {
             // Set SO_REUSEADDR to avoid TIME_WAIT issues
             int opt = 1;
-            setsockopt(test_socket, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
+            if (setsockopt(test_socket, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) {
+                std::cerr << "Warning: Failed to set SO_REUSEADDR on test socket: " << strerror(errno) << std::endl;
+            }
+
+            // Also set SO_REUSEPORT if available (for better concurrent binding handling)
+            #ifdef SO_REUSEPORT
+            int reuseport = 1;
+            if (setsockopt(test_socket, SOL_SOCKET, SO_REUSEPORT, &reuseport, sizeof(reuseport)) < 0) {
+                std::cerr << "Warning: Failed to set SO_REUSEPORT on test socket: " << strerror(errno) << std::endl;
+            }
+            #endif
 
             struct sockaddr_in addr;
             addr.sin_family = AF_INET;
@@ -4499,7 +4912,15 @@ void Server::serverThreadFunction(const std::string& host, int port) {
             if (bind(test_socket, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
                 close(test_socket);
                 std::cerr << "ERROR: Port " << port << " is already in use! Cannot start server." << std::endl;
-                std::cerr << "Please stop the existing instance or use a different port." << std::endl;
+                std::cerr << "This could be due to:" << std::endl;
+                std::cerr << "1. Another instance is already running on this port" << std::endl;
+                std::cerr << "2. A previous instance crashed and the socket is in TIME_WAIT state" << std::endl;
+                std::cerr << "3. The port is being used by another application" << std::endl;
+                std::cerr << std::endl;
+                std::cerr << "Solutions:" << std::endl;
+                std::cerr << "- Wait 30-60 seconds for TIME_WAIT to expire (if server crashed)" << std::endl;
+                std::cerr << "- Kill any existing processes: sudo lsof -ti:" << port << " | xargs kill -9" << std::endl;
+                std::cerr << "- Use a different port with -p <port>" << std::endl;
                 m_isRunning.store(false);
                 m_startupFailed.store(true);
                 return;

+ 301 - 0
webui/app/demo/page.tsx

@@ -0,0 +1,301 @@
+'use client';
+
+import { useState } from 'react';
+import ImageInput from '../../components/ui/image-input';
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
+import { Button } from '../../components/ui/button';
+import { Label } from '../../components/ui/label';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../components/ui/tabs';
+import { ImageValidationResult } from '../../lib/image-validation';
+
+export default function DemoPage() {
+  const [input1, setInput1] = useState<File | string | null>(null);
+  const [input2, setInput2] = useState<File | string | null>(null);
+  const [validation1, setValidation1] = useState<ImageValidationResult | null>(null);
+  const [validation2, setValidation2] = useState<ImageValidationResult | null>(null);
+
+  const handleValidation1 = (result: ImageValidationResult) => {
+    setValidation1(result);
+    console.log('Input 1 validation:', result);
+  };
+
+  const handleValidation2 = (result: ImageValidationResult) => {
+    setValidation2(result);
+    console.log('Input 2 validation:', result);
+  };
+
+  const clearAll = () => {
+    setInput1(null);
+    setInput2(null);
+    setValidation1(null);
+    setValidation2(null);
+  };
+
+  return (
+    <div className="min-h-screen bg-gray-50 p-8">
+      <div className="max-w-4xl mx-auto space-y-8">
+        <div className="text-center space-y-2">
+          <h1 className="text-3xl font-bold text-gray-900">ImageInput Component Demo</h1>
+          <p className="text-gray-600">Demonstration of the ImageInput component with both file upload and URL input modes</p>
+        </div>
+
+        <Tabs defaultValue="demo" className="w-full">
+          <TabsList className="grid w-full grid-cols-3">
+            <TabsTrigger value="demo">Demo</TabsTrigger>
+            <TabsTrigger value="api">API Reference</TabsTrigger>
+            <TabsTrigger value="examples">Examples</TabsTrigger>
+          </TabsList>
+
+          <TabsContent value="demo" className="space-y-6">
+            {/* First ImageInput - Default Configuration */}
+            <Card>
+              <CardHeader>
+                <CardTitle>Image Input 1 - Default Configuration</CardTitle>
+                <CardDescription>
+                  File upload and URL input with default 10MB size limit
+                </CardDescription>
+              </CardHeader>
+              <CardContent className="space-y-4">
+                <ImageInput
+                  value={input1}
+                  onChange={setInput1}
+                  onValidation={handleValidation1}
+                />
+                
+                {validation1 && (
+                  <div className="mt-4 p-4 bg-gray-100 rounded-lg">
+                    <Label className="font-semibold">Validation Result:</Label>
+                    <pre className="text-sm mt-2 overflow-auto">
+                      {JSON.stringify(validation1, null, 2)}
+                    </pre>
+                  </div>
+                )}
+              </CardContent>
+            </Card>
+
+            {/* Second ImageInput - Custom Configuration */}
+            <Card>
+              <CardHeader>
+                <CardTitle>Image Input 2 - Custom Configuration</CardTitle>
+                <CardDescription>
+                  Custom size limit (2MB) and without preview
+                </CardDescription>
+              </CardHeader>
+              <CardContent className="space-y-4">
+                <ImageInput
+                  value={input2}
+                  onChange={setInput2}
+                  onValidation={handleValidation2}
+                  maxSize={2 * 1024 * 1024} // 2MB
+                  showPreview={false}
+                  accept="image/jpeg,image/png,image/gif"
+                  placeholder="Enter a valid image URL or select a JPEG/PNG/GIF file"
+                />
+                
+                {validation2 && (
+                  <div className="mt-4 p-4 bg-gray-100 rounded-lg">
+                    <Label className="font-semibold">Validation Result:</Label>
+                    <pre className="text-sm mt-2 overflow-auto">
+                      {JSON.stringify(validation2, null, 2)}
+                    </pre>
+                  </div>
+                )}
+              </CardContent>
+            </Card>
+
+            {/* Control Panel */}
+            <Card>
+              <CardHeader>
+                <CardTitle>Current Values</CardTitle>
+                <CardDescription>
+                  Shows the current state of both inputs
+                </CardDescription>
+              </CardHeader>
+              <CardContent className="space-y-4">
+                <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+                  <div>
+                    <Label>Input 1 Value:</Label>
+                    <div className="mt-1 p-2 bg-gray-100 rounded text-sm">
+                      {input1 instanceof File 
+                        ? `File: ${input1.name} (${(input1.size / 1024 / 1024).toFixed(2)} MB)`
+                        : input1 || 'null'
+                      }
+                    </div>
+                  </div>
+                  <div>
+                    <Label>Input 2 Value:</Label>
+                    <div className="mt-1 p-2 bg-gray-100 rounded text-sm">
+                      {input2 instanceof File 
+                        ? `File: ${input2.name} (${(input2.size / 1024 / 1024).toFixed(2)} MB)`
+                        : input2 || 'null'
+                      }
+                    </div>
+                  </div>
+                </div>
+                
+                <Button onClick={clearAll} variant="outline" className="w-full">
+                  Clear All Inputs
+                </Button>
+              </CardContent>
+            </Card>
+          </TabsContent>
+
+          <TabsContent value="api" className="space-y-6">
+            <Card>
+              <CardHeader>
+                <CardTitle>Props</CardTitle>
+                <CardDescription>
+                  Available props for the ImageInput component
+                </CardDescription>
+              </CardHeader>
+              <CardContent>
+                <div className="overflow-x-auto">
+                  <table className="w-full text-sm">
+                    <thead>
+                      <tr className="border-b">
+                        <th className="text-left p-2">Prop</th>
+                        <th className="text-left p-2">Type</th>
+                        <th className="text-left p-2">Default</th>
+                        <th className="text-left p-2">Description</th>
+                      </tr>
+                    </thead>
+                    <tbody className="space-y-2">
+                      <tr className="border-b">
+                        <td className="p-2 font-mono">value</td>
+                        <td className="p-2 font-mono">File | string | null</td>
+                        <td className="p-2 font-mono">undefined</td>
+                        <td className="p-2">Current value of the input</td>
+                      </tr>
+                      <tr className="border-b">
+                        <td className="p-2 font-mono">onChange</td>
+                        <td className="p-2 font-mono">(File | string | null) =&gt; void</td>
+                        <td className="p-2 font-mono">-</td>
+                        <td className="p-2">Callback when value changes</td>
+                      </tr>
+                      <tr className="border-b">
+                        <td className="p-2 font-mono">onValidation</td>
+                        <td className="p-2 font-mono">(ImageValidationResult) =&gt; void</td>
+                        <td className="p-2 font-mono">-</td>
+                        <td className="p-2">Callback for validation results</td>
+                      </tr>
+                      <tr className="border-b">
+                        <td className="p-2 font-mono">disabled</td>
+                        <td className="p-2 font-mono">boolean</td>
+                        <td className="p-2 font-mono">false</td>
+                        <td className="p-2">Disable the input</td>
+                      </tr>
+                      <tr className="border-b">
+                        <td className="p-2 font-mono">maxSize</td>
+                        <td className="p-2 font-mono">number</td>
+                        <td className="p-2 font-mono">10MB</td>
+                        <td className="p-2">Maximum file size in bytes</td>
+                      </tr>
+                      <tr className="border-b">
+                        <td className="p-2 font-mono">accept</td>
+                        <td className="p-2 font-mono">string</td>
+                        <td className="p-2 font-mono">image/*</td>
+                        <td className="p-2">Accepted file types</td>
+                      </tr>
+                      <tr className="border-b">
+                        <td className="p-2 font-mono">placeholder</td>
+                        <td className="p-2 font-mono">string</td>
+                        <td className="p-2 font-mono">-</td>
+                        <td className="p-2">Placeholder for URL input</td>
+                      </tr>
+                      <tr className="border-b">
+                        <td className="p-2 font-mono">showPreview</td>
+                        <td className="p-2 font-mono">boolean</td>
+                        <td className="p-2 font-mono">true</td>
+                        <td className="p-2">Show image preview</td>
+                      </tr>
+                      <tr>
+                        <td className="p-2 font-mono">className</td>
+                        <td className="p-2 font-mono">string</td>
+                        <td className="p-2 font-mono">-</td>
+                        <td className="p-2">Additional CSS classes</td>
+                      </tr>
+                    </tbody>
+                  </table>
+                </div>
+              </CardContent>
+            </Card>
+          </TabsContent>
+
+          <TabsContent value="examples" className="space-y-6">
+            <Card>
+              <CardHeader>
+                <CardTitle>Usage Examples</CardTitle>
+                <CardDescription>
+                  Common patterns for using the ImageInput component
+                </CardDescription>
+              </CardHeader>
+              <CardContent className="space-y-6">
+                <div>
+                  <Label className="text-base font-semibold">Basic Usage</Label>
+                  <pre className="mt-2 p-4 bg-gray-100 rounded-lg text-sm overflow-x-auto">
+{`import ImageInput from './components/ui/image-input';
+
+const [image, setImage] = useState<File | string | null>(null);
+
+<ImageInput
+  value={image}
+  onChange={setImage}
+/>`}
+                  </pre>
+                </div>
+
+                <div>
+                  <Label className="text-base font-semibold">With Validation Callback</Label>
+                  <pre className="mt-2 p-4 bg-gray-100 rounded-lg text-sm overflow-x-auto">
+{`const [image, setImage] = useState<File | string | null>(null);
+const [validation, setValidation] = useState<ImageValidationResult | null>(null);
+
+const handleValidation = (result: ImageValidationResult) => {
+  setValidation(result);
+  console.log('Is valid:', result.isValid);
+  console.log('File name:', result.filename);
+};
+
+<ImageInput
+  value={image}
+  onChange={setImage}
+  onValidation={handleValidation}
+/>`}
+                  </pre>
+                </div>
+
+                <div>
+                  <Label className="text-base font-semibold">Custom Configuration</Label>
+                  <pre className="mt-2 p-4 bg-gray-100 rounded-lg text-sm overflow-x-auto">
+{`<ImageInput
+  value={image}
+  onChange={setImage}
+  maxSize={5 * 1024 * 1024} // 5MB
+  accept="image/jpeg,image/png"
+  showPreview={true}
+  placeholder="Enter image URL or select JPEG/PNG file"
+  className="custom-image-input"
+/>`}
+                  </pre>
+                </div>
+
+                <div>
+                  <Label className="text-base font-semibold">Disabled State</Label>
+                  <pre className="mt-2 p-4 bg-gray-100 rounded-lg text-sm overflow-x-auto">
+{`const [isUploading, setIsUploading] = useState(false);
+
+<ImageInput
+  value={image}
+  onChange={setImage}
+  disabled={isUploading}
+/>`}
+                  </pre>
+                </div>
+              </CardContent>
+            </Card>
+          </TabsContent>
+        </Tabs>
+      </div>
+    </div>
+  );
+}

+ 133 - 40
webui/app/img2img/page.tsx

@@ -9,8 +9,9 @@ import { Textarea } from '@/components/ui/textarea';
 import { PromptTextarea } from '@/components/prompt-textarea';
 import { Label } from '@/components/ui/label';
 import { Card, CardContent } from '@/components/ui/card';
+import { ImageInput } from '@/components/ui/image-input';
 import { apiClient, type JobInfo } from '@/lib/api';
-import { Loader2, Download, X, Upload } from 'lucide-react';
+import { Loader2, Download, X } from 'lucide-react';
 import { downloadImage, downloadAuthenticatedImage, fileToBase64 } from '@/lib/utils';
 import { useLocalStorage } from '@/lib/hooks';
 
@@ -23,6 +24,8 @@ type Img2ImgFormData = {
   cfg_scale: number;
   seed: string;
   sampling_method: string;
+  width?: number;
+  height?: number;
 };
 
 const defaultFormData: Img2ImgFormData = {
@@ -34,6 +37,8 @@ const defaultFormData: Img2ImgFormData = {
   cfg_scale: 7.5,
   seed: '',
   sampling_method: 'euler_a',
+  width: 512,
+  height: 512,
 };
 
 export default function Img2ImgPage() {
@@ -49,7 +54,10 @@ export default function Img2ImgPage() {
   const [previewImage, setPreviewImage] = useState<string | null>(null);
   const [loraModels, setLoraModels] = useState<string[]>([]);
   const [embeddings, setEmbeddings] = useState<string[]>([]);
-  const fileInputRef = useRef<HTMLInputElement>(null);
+  const [selectedImage, setSelectedImage] = useState<File | string | null>(null);
+  const [imageValidation, setImageValidation] = useState<any>(null);
+  const [originalImage, setOriginalImage] = useState<string | null>(null);
+  const [isResizing, setIsResizing] = useState(false);
 
   useEffect(() => {
     const loadModels = async () => {
@@ -79,17 +87,76 @@ export default function Img2ImgPage() {
     }));
   };
 
-  const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
-    const file = e.target.files?.[0];
-    if (!file) return;
+  const handleImageChange = async (image: File | string | null) => {
+    setSelectedImage(image);
+    setError(null);
+
+    if (!image) {
+      setFormData(prev => ({ ...prev, image: '' }));
+      setPreviewImage(null);
+      setImageValidation(null);
+      setOriginalImage(null);
+      return;
+    }
 
     try {
-      const base64 = await fileToBase64(file);
-      setFormData((prev) => ({ ...prev, image: base64 }));
-      setPreviewImage(base64);
-      setError(null);
+      let imageBase64: string;
+      let previewUrl: string;
+
+      if (image instanceof File) {
+        // Convert File to base64
+        imageBase64 = await fileToBase64(image);
+        previewUrl = imageBase64;
+      } else {
+        // Use URL directly
+        imageBase64 = image;
+        previewUrl = image;
+      }
+
+      // Store original image for resizing
+      setOriginalImage(imageBase64);
+      setFormData(prev => ({ ...prev, image: imageBase64 }));
+      setPreviewImage(previewUrl);
     } catch (err) {
-      setError('Failed to load image');
+      setError('Failed to process image');
+      console.error('Image processing error:', err);
+    }
+  };
+
+  // Auto-resize image when width or height changes
+  useEffect(() => {
+    const resizeImage = async () => {
+      if (!originalImage || !formData.width || !formData.height) {
+        return;
+      }
+
+      // Don't resize if we're already resizing
+      if (isResizing) {
+        return;
+      }
+
+      try {
+        setIsResizing(true);
+        const result = await apiClient.resizeImage(originalImage, formData.width, formData.height);
+        setFormData(prev => ({ ...prev, image: result.image }));
+        setPreviewImage(result.image);
+      } catch (err) {
+        console.error('Failed to resize image:', err);
+        setError('Failed to resize image');
+      } finally {
+        setIsResizing(false);
+      }
+    };
+
+    resizeImage();
+  }, [formData.width, formData.height, originalImage]);
+
+  const handleImageValidation = (result: any) => {
+    setImageValidation(result);
+    if (!result.isValid) {
+      setError(result.error || 'Invalid image');
+    } else {
+      setError(null);
     }
   };
 
@@ -157,7 +224,13 @@ export default function Img2ImgPage() {
     e.preventDefault();
 
     if (!formData.image) {
-      setError('Please upload an image first');
+      setError('Please upload or select an image first');
+      return;
+    }
+
+    // Check if image validation passed
+    if (imageValidation && !imageValidation.isValid) {
+      setError('Please fix the image validation errors before generating');
       return;
     }
 
@@ -206,33 +279,16 @@ export default function Img2ImgPage() {
               <form onSubmit={handleGenerate} className="space-y-4">
                 <div className="space-y-2">
                   <Label>Source Image *</Label>
-                  <div className="space-y-4">
-                    {previewImage && (
-                      <div className="relative">
-                        <img
-                          src={previewImage}
-                          alt="Source"
-                          className="w-full rounded-lg border border-border"
-                        />
-                      </div>
-                    )}
-                    <Button
-                      type="button"
-                      variant="outline"
-                      onClick={() => fileInputRef.current?.click()}
-                      className="w-full"
-                    >
-                      <Upload className="h-4 w-4" />
-                      {previewImage ? 'Change Image' : 'Upload Image'}
-                    </Button>
-                    <input
-                      ref={fileInputRef}
-                      type="file"
-                      accept="image/*"
-                      onChange={handleImageUpload}
-                      className="hidden"
-                    />
-                  </div>
+                  <ImageInput
+                    value={selectedImage}
+                    onChange={handleImageChange}
+                    onValidation={handleImageValidation}
+                    disabled={loading}
+                    maxSize={10 * 1024 * 1024} // 10MB
+                    accept="image/*"
+                    placeholder="Enter image URL or select a file"
+                    showPreview={true}
+                  />
                 </div>
 
                 <div className="space-y-2">
@@ -281,6 +337,43 @@ export default function Img2ImgPage() {
                   </p>
                 </div>
 
+                <div className="grid grid-cols-2 gap-4">
+                  <div className="space-y-2">
+                    <Label htmlFor="width">Width</Label>
+                    <Input
+                      id="width"
+                      name="width"
+                      type="number"
+                      value={formData.width}
+                      onChange={handleInputChange}
+                      step={64}
+                      min={256}
+                      max={2048}
+                      disabled={isResizing}
+                    />
+                  </div>
+                  <div className="space-y-2">
+                    <Label htmlFor="height">Height</Label>
+                    <Input
+                      id="height"
+                      name="height"
+                      type="number"
+                      value={formData.height}
+                      onChange={handleInputChange}
+                      step={64}
+                      min={256}
+                      max={2048}
+                      disabled={isResizing}
+                    />
+                  </div>
+                </div>
+                {isResizing && (
+                  <div className="text-sm text-muted-foreground flex items-center gap-2">
+                    <Loader2 className="h-4 w-4 animate-spin" />
+                    Resizing image...
+                  </div>
+                )}
+
                 <div className="grid grid-cols-2 gap-4">
                   <div className="space-y-2">
                     <Label htmlFor="steps">Steps</Label>
@@ -341,7 +434,7 @@ export default function Img2ImgPage() {
                 </div>
 
                 <div className="flex gap-2">
-                  <Button type="submit" disabled={loading || !formData.image} className="flex-1">
+                  <Button type="submit" disabled={loading || !formData.image || (imageValidation && !imageValidation.isValid)} className="flex-1">
                     {loading ? (
                       <>
                         <Loader2 className="h-4 w-4 animate-spin" />
@@ -426,4 +519,4 @@ export default function Img2ImgPage() {
       </div>
     </AppLayout>
   );
-}
+}

+ 35 - 0
webui/app/inpainting/page.tsx

@@ -25,6 +25,8 @@ type InpaintingFormData = {
   seed: string;
   sampling_method: string;
   strength: number;
+  width?: number;
+  height?: number;
 };
 
 const defaultFormData: InpaintingFormData = {
@@ -37,6 +39,8 @@ const defaultFormData: InpaintingFormData = {
   seed: '',
   sampling_method: 'euler_a',
   strength: 0.75,
+  width: 512,
+  height: 512,
 };
 
 export default function InpaintingPage() {
@@ -207,6 +211,8 @@ export default function InpaintingPage() {
             <InpaintingCanvas
               onSourceImageChange={handleSourceImageChange}
               onMaskImageChange={handleMaskImageChange}
+              targetWidth={formData.width}
+              targetHeight={formData.height}
             />
 
             <Card>
@@ -258,6 +264,35 @@ export default function InpaintingPage() {
                     </p>
                   </div>
 
+                  <div className="grid grid-cols-2 gap-4">
+                    <div className="space-y-2">
+                      <Label htmlFor="width">Width</Label>
+                      <Input
+                        id="width"
+                        name="width"
+                        type="number"
+                        value={formData.width}
+                        onChange={handleInputChange}
+                        step={64}
+                        min={256}
+                        max={2048}
+                      />
+                    </div>
+                    <div className="space-y-2">
+                      <Label htmlFor="height">Height</Label>
+                      <Input
+                        id="height"
+                        name="height"
+                        type="number"
+                        value={formData.height}
+                        onChange={handleInputChange}
+                        step={64}
+                        min={256}
+                        max={2048}
+                      />
+                    </div>
+                  </div>
+
                   <div className="grid grid-cols-2 gap-4">
                     <div className="space-y-2">
                       <Label htmlFor="steps">Steps</Label>

+ 9 - 3
webui/app/text2img/page.tsx

@@ -47,7 +47,7 @@ export default function Text2ImgPage() {
   useEffect(() => {
     const loadOptions = async () => {
       try {
-        const [samplersData, schedulersData, models, loras, embeds] = await Promise.all([
+        const [samplersData, schedulersData, vaeData, loras, embeds] = await Promise.all([
           apiClient.getSamplers(),
           apiClient.getSchedulers(),
           apiClient.getModels('vae'),
@@ -56,7 +56,7 @@ export default function Text2ImgPage() {
         ]);
         setSamplers(samplersData);
         setSchedulers(schedulersData);
-        setVaeModels(models.models);
+        setVaeModels(vaeData.models);
         setLoraModels(loras.models.map(m => m.name));
         setEmbeddings(embeds.models.map(m => m.name));
       } catch (err) {
@@ -140,13 +140,19 @@ export default function Text2ImgPage() {
 
   const handleGenerate = async (e: React.FormEvent) => {
     e.preventDefault();
+
     setLoading(true);
     setError(null);
     setGeneratedImages([]);
     setJobInfo(null);
 
     try {
-      const job = await apiClient.text2img(formData);
+      const requestData = {
+        ...formData,
+        vae: selectedVae || undefined,
+      };
+
+      const job = await apiClient.text2img(requestData);
       setJobInfo(job);
       const jobId = job.request_id || job.id;
       if (jobId) {

+ 199 - 56
webui/components/inpainting-canvas.tsx

@@ -4,29 +4,43 @@ import { useRef, useEffect, useState, useCallback } from 'react';
 import { Button } from '@/components/ui/button';
 import { Card, CardContent } from '@/components/ui/card';
 import { Label } from '@/components/ui/label';
-import { Upload, Download, Eraser, Brush, RotateCcw } from 'lucide-react';
+import { Input } from '@/components/ui/input';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Upload, Download, Eraser, Brush, RotateCcw, Link as LinkIcon, Loader2 } from 'lucide-react';
 import { fileToBase64 } from '@/lib/utils';
+import { validateImageUrlWithBase64 } from '@/lib/image-validation';
+import { apiClient } from '@/lib/api';
 
 interface InpaintingCanvasProps {
   onSourceImageChange: (image: string) => void;
   onMaskImageChange: (image: string) => void;
   className?: string;
+  targetWidth?: number;
+  targetHeight?: number;
 }
 
 export function InpaintingCanvas({
   onSourceImageChange,
   onMaskImageChange,
-  className
+  className,
+  targetWidth,
+  targetHeight
 }: InpaintingCanvasProps) {
   const canvasRef = useRef<HTMLCanvasElement>(null);
   const maskCanvasRef = useRef<HTMLCanvasElement>(null); // Keep for mask generation
   const fileInputRef = useRef<HTMLInputElement>(null);
 
   const [sourceImage, setSourceImage] = useState<string | null>(null);
+  const [originalSourceImage, setOriginalSourceImage] = useState<string | null>(null);
   const [isDrawing, setIsDrawing] = useState(false);
   const [brushSize, setBrushSize] = useState(20);
   const [isEraser, setIsEraser] = useState(false);
   const [canvasSize, setCanvasSize] = useState({ width: 512, height: 512 });
+  const [inputMode, setInputMode] = useState<'file' | 'url'>('file');
+  const [urlInput, setUrlInput] = useState('');
+  const [isLoadingUrl, setIsLoadingUrl] = useState(false);
+  const [urlError, setUrlError] = useState<string | null>(null);
+  const [isResizing, setIsResizing] = useState(false);
 
   // Initialize canvases
   useEffect(() => {
@@ -62,22 +76,27 @@ export function InpaintingCanvas({
     onMaskImageChange(maskDataUrl);
   }, [onMaskImageChange]);
 
-  const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
-    const file = e.target.files?.[0];
-    if (!file) return;
-
-    try {
-      const base64 = await fileToBase64(file);
-      setSourceImage(base64);
-      onSourceImageChange(base64);
-
-      // Load image to get dimensions and update canvas size
-      const img = new Image();
-      img.onload = () => {
+  const loadImageToCanvas = useCallback((base64Image: string) => {
+    // Store original image for resizing
+    setOriginalSourceImage(base64Image);
+    setSourceImage(base64Image);
+    onSourceImageChange(base64Image);
+
+    // Load image to get dimensions and update canvas size
+    const img = new Image();
+    img.onload = () => {
+      // Use target dimensions if provided, otherwise fit within 512x512
+      let width: number;
+      let height: number;
+
+      if (targetWidth && targetHeight) {
+        width = targetWidth;
+        height = targetHeight;
+      } else {
         // Calculate scaled dimensions to fit within 512x512 while maintaining aspect ratio
         const maxSize = 512;
-        let width = img.width;
-        let height = img.height;
+        width = img.width;
+        height = img.height;
 
         if (width > maxSize || height > maxSize) {
           const aspectRatio = width / height;
@@ -89,41 +108,105 @@ export function InpaintingCanvas({
             width = maxSize * aspectRatio;
           }
         }
+      }
 
-        const newCanvasSize = { width: Math.round(width), height: Math.round(height) };
-        setCanvasSize(newCanvasSize);
+      const newCanvasSize = { width: Math.round(width), height: Math.round(height) };
+      setCanvasSize(newCanvasSize);
 
-        // Draw image on main canvas
-        const canvas = canvasRef.current;
-        if (!canvas) return;
+      // Draw image on main canvas
+      const canvas = canvasRef.current;
+      if (!canvas) return;
 
-        const ctx = canvas.getContext('2d');
-        if (!ctx) return;
+      const ctx = canvas.getContext('2d');
+      if (!ctx) return;
 
-        canvas.width = width;
-        canvas.height = height;
-        ctx.drawImage(img, 0, 0, width, height);
+      canvas.width = width;
+      canvas.height = height;
+      ctx.drawImage(img, 0, 0, width, height);
 
-        // Update mask canvas size
-        const maskCanvas = maskCanvasRef.current;
-        if (!maskCanvas) return;
+      // Update mask canvas size
+      const maskCanvas = maskCanvasRef.current;
+      if (!maskCanvas) return;
 
-        const maskCtx = maskCanvas.getContext('2d');
-        if (!maskCtx) return;
+      const maskCtx = maskCanvas.getContext('2d');
+      if (!maskCtx) return;
 
-        maskCanvas.width = width;
-        maskCanvas.height = height;
-        maskCtx.fillStyle = 'black';
-        maskCtx.fillRect(0, 0, width, height);
+      maskCanvas.width = width;
+      maskCanvas.height = height;
+      maskCtx.fillStyle = 'black';
+      maskCtx.fillRect(0, 0, width, height);
 
-        updateMaskImage();
-      };
-      img.src = base64;
+      updateMaskImage();
+    };
+    img.src = base64Image;
+  }, [onSourceImageChange, updateMaskImage, targetWidth, targetHeight]);
+
+  // Auto-resize image when target dimensions change
+  useEffect(() => {
+    const resizeImage = async () => {
+      if (!originalSourceImage || !targetWidth || !targetHeight) {
+        return;
+      }
+
+      // Don't resize if we're already resizing
+      if (isResizing) {
+        return;
+      }
+
+      try {
+        setIsResizing(true);
+        const result = await apiClient.resizeImage(originalSourceImage, targetWidth, targetHeight);
+        loadImageToCanvas(result.image);
+      } catch (err) {
+        console.error('Failed to resize image:', err);
+      } finally {
+        setIsResizing(false);
+      }
+    };
+
+    resizeImage();
+  }, [targetWidth, targetHeight, originalSourceImage]);
+
+  const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
+    const file = e.target.files?.[0];
+    if (!file) return;
+
+    try {
+      const base64 = await fileToBase64(file);
+      loadImageToCanvas(base64);
     } catch (err) {
       console.error('Failed to load image:', err);
     }
   };
 
+  const handleUrlLoad = async () => {
+    if (!urlInput.trim()) {
+      setUrlError('Please enter a URL');
+      return;
+    }
+
+    setIsLoadingUrl(true);
+    setUrlError(null);
+
+    try {
+      const result = await validateImageUrlWithBase64(urlInput);
+
+      if (!result.isValid) {
+        setUrlError(result.error || 'Failed to load image from URL');
+        setIsLoadingUrl(false);
+        return;
+      }
+
+      // Use base64 data if available, otherwise use the URL directly
+      const imageData = result.base64Data || urlInput;
+      loadImageToCanvas(imageData);
+      setIsLoadingUrl(false);
+    } catch (err) {
+      setUrlError(err instanceof Error ? err.message : 'Failed to load image from URL');
+      setIsLoadingUrl(false);
+    }
+  };
+
   const startDrawing = (e: React.MouseEvent<HTMLCanvasElement>) => {
     if (!sourceImage) return;
 
@@ -248,26 +331,86 @@ export function InpaintingCanvas({
           <div className="space-y-4">
             <div className="space-y-2">
               <Label>Source Image</Label>
-              <div className="space-y-4">
-                <Button
-                  type="button"
-                  variant="outline"
-                  onClick={() => fileInputRef.current?.click()}
-                  className="w-full"
-                >
-                  <Upload className="h-4 w-4 mr-2" />
-                  {sourceImage ? 'Change Image' : 'Upload Image'}
-                </Button>
-                <input
-                  ref={fileInputRef}
-                  type="file"
-                  accept="image/*"
-                  onChange={handleImageUpload}
-                  className="hidden"
-                />
-              </div>
+              <Tabs value={inputMode} onValueChange={(value) => setInputMode(value as 'file' | 'url')}>
+                <TabsList className="grid w-full grid-cols-2">
+                  <TabsTrigger value="file">
+                    <Upload className="w-4 h-4 mr-2" />
+                    Upload File
+                  </TabsTrigger>
+                  <TabsTrigger value="url">
+                    <LinkIcon className="w-4 h-4 mr-2" />
+                    From URL
+                  </TabsTrigger>
+                </TabsList>
+
+                <TabsContent value="file" className="space-y-4 mt-4">
+                  <Button
+                    type="button"
+                    variant="outline"
+                    onClick={() => fileInputRef.current?.click()}
+                    className="w-full"
+                  >
+                    <Upload className="h-4 w-4 mr-2" />
+                    {sourceImage ? 'Change Image' : 'Upload Image'}
+                  </Button>
+                  <input
+                    ref={fileInputRef}
+                    type="file"
+                    accept="image/*"
+                    onChange={handleImageUpload}
+                    className="hidden"
+                  />
+                </TabsContent>
+
+                <TabsContent value="url" className="space-y-4 mt-4">
+                  <div className="space-y-2">
+                    <Input
+                      type="url"
+                      value={urlInput}
+                      onChange={(e) => {
+                        setUrlInput(e.target.value);
+                        setUrlError(null);
+                      }}
+                      placeholder="https://example.com/image.png"
+                      disabled={isLoadingUrl}
+                    />
+                    <p className="text-xs text-muted-foreground">
+                      Enter a URL that ends with an image extension (.jpg, .png, .gif, etc.)
+                    </p>
+                  </div>
+                  <Button
+                    type="button"
+                    variant="outline"
+                    onClick={handleUrlLoad}
+                    disabled={isLoadingUrl || !urlInput.trim()}
+                    className="w-full"
+                  >
+                    {isLoadingUrl ? (
+                      <>
+                        <Download className="h-4 w-4 mr-2 animate-spin" />
+                        Loading...
+                      </>
+                    ) : (
+                      <>
+                        <Download className="h-4 w-4 mr-2" />
+                        Load from URL
+                      </>
+                    )}
+                  </Button>
+                  {urlError && (
+                    <p className="text-sm text-destructive">{urlError}</p>
+                  )}
+                </TabsContent>
+              </Tabs>
             </div>
 
+            {isResizing && (
+              <div className="text-sm text-muted-foreground flex items-center gap-2">
+                <Loader2 className="h-4 w-4 animate-spin" />
+                Resizing image...
+              </div>
+            )}
+
             {sourceImage && (
               <>
                 <div className="space-y-2">

+ 152 - 87
webui/components/model-list.tsx

@@ -1,11 +1,11 @@
 'use client';
 
-import { useState, useMemo } from 'react';
+import { useState, useMemo, useEffect } from 'react';
 import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
 import { Button } from '@/components/ui/button';
 import { Input } from '@/components/ui/input';
 import { Label } from '@/components/ui/label';
-import { type ModelInfo } from '@/lib/api';
+import { type ModelInfo, apiClient } from '@/lib/api';
 import {
   Search,
   RefreshCw,
@@ -38,18 +38,11 @@ interface ModelListProps {
   onUnloadModel: (modelId: string) => void;
   onConvertModel?: (modelName: string, quantizationType: string) => void;
   actionLoading: string | null;
-  pagination?: {
-    page: number;
-    limit: number;
-    total_count: number;
-    total_pages: number;
-    has_next: boolean;
-    has_prev: boolean;
-  };
   statistics?: any;
   searchTerm?: string;
   onSearchChange?: (term: string) => void;
   onLoadPage?: (page: number) => void;
+  pagination?: any;
 }
 
 type ViewMode = 'grid' | 'list';
@@ -62,7 +55,6 @@ export function ModelList({
   onUnloadModel,
   onConvertModel,
   actionLoading,
-  pagination,
   statistics,
   searchTerm: externalSearchTerm = '',
   onSearchChange,
@@ -72,11 +64,27 @@ export function ModelList({
   const [selectedType, setSelectedType] = useState<string>('all');
   const [showFullPaths, setShowFullPaths] = useState(false);
   const [viewMode, setViewMode] = useState<ViewMode>('grid');
+  const [availableTypes, setAvailableTypes] = useState<Array<{ type: string; description: string }>>([]);
+  const [convertingModel, setConvertingModel] = useState<string | null>(null);
+  const [selectedQuantization, setSelectedQuantization] = useState<string>('q4_0');
 
   // Use external search term if provided, otherwise use internal state
   const currentSearchTerm = externalSearchTerm || internalSearchTerm;
   const setSearchTerm = onSearchChange || setInternalSearchTerm;
 
+  // Fetch available model types from API
+  useEffect(() => {
+    const fetchModelTypes = async () => {
+      try {
+        const types = await apiClient.getModelTypes();
+        setAvailableTypes(types);
+      } catch (error) {
+        console.error('Failed to fetch model types:', error);
+      }
+    };
+    fetchModelTypes();
+  }, []);
+
   // Calculate model type statistics
   const modelStats = useMemo(() => {
     const stats = models.reduce((acc, model) => {
@@ -85,12 +93,30 @@ export function ModelList({
       return acc;
     }, {} as Record<string, number>);
 
+    // Combine available types from API with actual model counts
+    const typesWithCounts = availableTypes.map(typeInfo => ({
+      type: typeInfo.type,
+      count: stats[typeInfo.type] || 0,
+      description: typeInfo.description
+    }));
+
+    // Add any types that exist in models but not in the API response
+    Object.keys(stats).forEach(type => {
+      if (!availableTypes.find(t => t.type === type)) {
+        typesWithCounts.push({
+          type,
+          count: stats[type],
+          description: ''
+        });
+      }
+    });
+
     return {
       total: models.length,
       loaded: models.filter(m => m.loaded).length,
-      types: Object.entries(stats).sort(([a], [b]) => a.localeCompare(b))
+      types: typesWithCounts.sort((a, b) => a.type.localeCompare(b.type))
     };
-  }, [models]);
+  }, [models, availableTypes]);
 
   // Filter models
   const filteredModels = useMemo(() => {
@@ -110,6 +136,12 @@ export function ModelList({
     return model.name;
   };
 
+  // Check if model type can be loaded
+  const canLoadModel = (modelType: string): boolean => {
+    const loadableTypes = ['checkpoint', 'upscaler'];
+    return loadableTypes.includes(modelType.toLowerCase());
+  };
+
   // Get model type icon
   const getTypeIcon = (type: string) => {
     switch (type.toLowerCase()) {
@@ -208,16 +240,17 @@ export function ModelList({
               >
                 All ({modelStats.total})
               </Button>
-              {modelStats.types.map(([type, count]) => (
+              {modelStats.types.map((typeInfo) => (
                 <Button
-                  key={type}
-                  variant={selectedType === type ? 'default' : 'outline'}
+                  key={typeInfo.type}
+                  variant={selectedType === typeInfo.type ? 'default' : 'outline'}
                   size="sm"
-                  onClick={() => setSelectedType(type)}
+                  onClick={() => setSelectedType(typeInfo.type)}
                   className="flex items-center gap-2"
+                  title={typeInfo.description}
                 >
-                  {getTypeIcon(type)}
-                  {type} ({count})
+                  {getTypeIcon(typeInfo.type)}
+                  {typeInfo.type} ({typeInfo.count})
                 </Button>
               ))}
             </div>
@@ -291,31 +324,85 @@ export function ModelList({
                         Unload
                       </Button>
                     ) : (
-                      <Button
-                        variant="default"
-                        size="sm"
-                        className="flex-1"
-                        onClick={() => onLoadModel(model.id || model.name)}
-                        disabled={actionLoading === model.id}
-                      >
-                        {actionLoading === model.id ? (
-                          <Loader2 className="h-3 w-3 animate-spin" />
+                      <>
+                        {canLoadModel(model.type) ? (
+                          <Button
+                            variant="default"
+                            size="sm"
+                            className="flex-1"
+                            onClick={() => onLoadModel(model.id || model.name)}
+                            disabled={actionLoading === model.id}
+                          >
+                            {actionLoading === model.id ? (
+                              <Loader2 className="h-3 w-3 animate-spin" />
+                            ) : (
+                              <Download className="h-3 w-3" />
+                            )}
+                            Load
+                          </Button>
                         ) : (
-                          <Download className="h-3 w-3" />
+                          <Button
+                            variant="outline"
+                            size="sm"
+                            className="flex-1"
+                            disabled={true}
+                            title={`Cannot load ${model.type} models`}
+                          >
+                            <Download className="h-3 w-3" />
+                            Load
+                          </Button>
                         )}
-                        Load
-                      </Button>
+                      </>
                     )}
                     {onConvertModel && (
-                      <Button
-                        variant="outline"
-                        size="sm"
-                        onClick={() => onConvertModel(model.name, 'q4_0')}
-                        disabled={actionLoading === model.id}
-                      >
-                        <Zap className="h-3 w-3" />
-                        Convert
-                      </Button>
+                      convertingModel === model.name ? (
+                        <div className="flex gap-2 items-center flex-1">
+                          <select
+                            value={selectedQuantization}
+                            onChange={(e) => setSelectedQuantization(e.target.value)}
+                            className="flex-1 h-8 rounded-md border border-input bg-background px-2 text-sm"
+                          >
+                            <option value="f32">F32 (Full precision)</option>
+                            <option value="f16">F16 (Half precision)</option>
+                            <option value="q8_0">Q8_0 (8-bit)</option>
+                            <option value="q5_1">Q5_1 (5-bit)</option>
+                            <option value="q5_0">Q5_0 (5-bit)</option>
+                            <option value="q4_1">Q4_1 (4-bit)</option>
+                            <option value="q4_0">Q4_0 (4-bit)</option>
+                            <option value="q4_K">Q4_K (4-bit K-quant)</option>
+                            <option value="q3_K">Q3_K (3-bit K-quant)</option>
+                            <option value="q2_K">Q2_K (2-bit K-quant)</option>
+                          </select>
+                          <Button
+                            variant="default"
+                            size="sm"
+                            onClick={() => {
+                              onConvertModel(model.name, selectedQuantization);
+                              setConvertingModel(null);
+                            }}
+                            disabled={actionLoading === model.id}
+                          >
+                            <CheckCircle2 className="h-3 w-3" />
+                          </Button>
+                          <Button
+                            variant="outline"
+                            size="sm"
+                            onClick={() => setConvertingModel(null)}
+                          >
+                            <XCircle className="h-3 w-3" />
+                          </Button>
+                        </div>
+                      ) : (
+                        <Button
+                          variant="outline"
+                          size="sm"
+                          onClick={() => setConvertingModel(model.name)}
+                          disabled={actionLoading === model.id}
+                        >
+                          <Zap className="h-3 w-3" />
+                          Convert
+                        </Button>
+                      )
                     )}
                   </div>
                 </div>
@@ -379,19 +466,33 @@ export function ModelList({
                         Unload
                       </Button>
                     ) : (
-                      <Button
-                        variant="default"
-                        size="sm"
-                        onClick={() => onLoadModel(model.id || model.name)}
-                        disabled={actionLoading === model.id}
-                      >
-                        {actionLoading === model.id ? (
-                          <Loader2 className="h-4 w-4 animate-spin" />
+                      <>
+                        {canLoadModel(model.type) ? (
+                          <Button
+                            variant="default"
+                            size="sm"
+                            onClick={() => onLoadModel(model.id || model.name)}
+                            disabled={actionLoading === model.id}
+                          >
+                            {actionLoading === model.id ? (
+                              <Loader2 className="h-4 w-4 animate-spin" />
+                            ) : (
+                              <Download className="h-4 w-4" />
+                            )}
+                            Load
+                          </Button>
                         ) : (
-                          <Download className="h-4 w-4" />
+                          <Button
+                            variant="outline"
+                            size="sm"
+                            disabled={true}
+                            title={`Cannot load ${model.type} models`}
+                          >
+                            <Download className="h-4 w-4" />
+                            Load
+                          </Button>
                         )}
-                        Load
-                      </Button>
+                      </>
                     )}
                     {onConvertModel && (
                       <Button
@@ -412,42 +513,6 @@ export function ModelList({
         </div>
       )}
 
-      {/* Pagination */}
-      {pagination && pagination.total_pages > 1 && (
-        <Card>
-          <CardContent className="flex items-center justify-between p-4">
-            <div className="text-sm text-muted-foreground">
-              Showing {((pagination.page - 1) * pagination.limit) + 1} to{' '}
-              {Math.min(pagination.page * pagination.limit, pagination.total_count)} of{' '}
-              {pagination.total_count} models
-            </div>
-            <div className="flex items-center gap-2">
-              <Button
-                variant="outline"
-                size="sm"
-                onClick={() => onLoadPage && onLoadPage(pagination.page - 1)}
-                disabled={!pagination.has_prev}
-              >
-                <ChevronLeft className="h-4 w-4" />
-                Previous
-              </Button>
-              <div className="text-sm">
-                Page {pagination.page} of {pagination.total_pages}
-              </div>
-              <Button
-                variant="outline"
-                size="sm"
-                onClick={() => onLoadPage && onLoadPage(pagination.page + 1)}
-                disabled={!pagination.has_next}
-              >
-                Next
-                <ChevronRight className="h-4 w-4" />
-              </Button>
-            </div>
-          </CardContent>
-        </Card>
-      )}
-
       {filteredModels.length === 0 && (
         <Card>
           <CardContent className="text-center py-12">

+ 158 - 0
webui/components/ui/image-input.demo.tsx

@@ -0,0 +1,158 @@
+import React, { useState } from 'react';
+import ImageInput from './image-input';
+
+export function ImageInputDemo() {
+  const [selectedImage, setSelectedImage] = useState<File | string | null>(null);
+  const [validationResult, setValidationResult] = useState<any>(null);
+  const [demoLog, setDemoLog] = useState<string[]>([]);
+
+  const log = (message: string) => {
+    const timestamp = new Date().toLocaleTimeString();
+    setDemoLog(prev => [`[${timestamp}] ${message}`, ...prev.slice(0, 4)]);
+  };
+
+  const handleImageChange = (value: File | string | null) => {
+    setSelectedImage(value);
+    log(`Image ${value ? 'selected' : 'cleared'}: ${value instanceof File ? value.name : value}`);
+  };
+
+  const handleValidation = (result: any) => {
+    setValidationResult(result);
+    log(`Validation: ${result.isValid ? 'Valid' : 'Invalid'} - ${result.error || 'OK'}`);
+  };
+
+  const handleUploadToServer = async () => {
+    if (!selectedImage) return;
+
+    log('Uploading to server...');
+    
+    // Simulate server upload
+    await new Promise(resolve => setTimeout(resolve, 2000));
+    
+    log('Upload completed successfully!');
+  };
+
+  return (
+    <div className="max-w-2xl mx-auto p-6 space-y-6">
+      <div>
+        <h1 className="text-2xl font-bold mb-2">ImageInput Component Demo</h1>
+        <p className="text-gray-600">
+          This demo showcases the ImageInput component with both file upload and URL input modes.
+          Try selecting an image file or entering an image URL to see validation and preview in action.
+        </p>
+      </div>
+
+      {/* ImageInput Component */}
+      <div className="border rounded-lg p-6">
+        <ImageInput
+          value={selectedImage}
+          onChange={handleImageChange}
+          onValidation={handleValidation}
+          disabled={false}
+          showPreview={true}
+          className="space-y-4"
+        />
+      </div>
+
+      {/* Demo Actions */}
+      {selectedImage && (
+        <div className="flex gap-4">
+          <button
+            onClick={handleUploadToServer}
+            className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
+          >
+            Simulate Upload
+          </button>
+          <button
+            onClick={() => {
+              setSelectedImage(null);
+              setValidationResult(null);
+              setDemoLog([]);
+            }}
+            className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors"
+          >
+            Reset Demo
+          </button>
+        </div>
+      )}
+
+      {/* Status Display */}
+      <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
+        {/* Validation Status */}
+        <div className="border rounded-lg p-4">
+          <h3 className="font-semibold mb-2">Validation Status</h3>
+          {validationResult ? (
+            <div className="space-y-2 text-sm">
+              <div>
+                <span className="font-medium">Valid:</span>{' '}
+                <span className={validationResult.isValid ? 'text-green-600' : 'text-red-600'}>
+                  {validationResult.isValid ? 'Yes' : 'No'}
+                </span>
+              </div>
+              {validationResult.error && (
+                <div>
+                  <span className="font-medium">Error:</span>{' '}
+                  <span className="text-red-600">{validationResult.error}</span>
+                </div>
+              )}
+              {validationResult.detectedType && (
+                <div>
+                  <span className="font-medium">Type:</span> {validationResult.detectedType}
+                </div>
+              )}
+              {validationResult.filename && (
+                <div>
+                  <span className="font-medium">Filename:</span> {validationResult.filename}
+                </div>
+              )}
+            </div>
+          ) : (
+            <p className="text-gray-500 text-sm">No validation performed yet</p>
+          )}
+        </div>
+
+        {/* Activity Log */}
+        <div className="border rounded-lg p-4">
+          <h3 className="font-semibold mb-2">Activity Log</h3>
+          <div className="space-y-1 text-sm font-mono bg-gray-50 p-2 rounded max-h-32 overflow-y-auto">
+            {demoLog.length > 0 ? (
+              demoLog.map((entry, index) => (
+                <div key={index} className="text-gray-700">
+                  {entry}
+                </div>
+              ))
+            ) : (
+              <p className="text-gray-500">No activity yet</p>
+            )}
+          </div>
+        </div>
+      </div>
+
+      {/* Code Example */}
+      <div className="border rounded-lg p-4">
+        <h3 className="font-semibold mb-2">Usage Example</h3>
+        <pre className="bg-gray-100 p-3 rounded text-sm overflow-x-auto">
+{`import { ImageInput } from './components/ui/image-input';
+
+function MyComponent() {
+  const [image, setImage] = useState<File | string | null>(null);
+
+  return (
+    <ImageInput
+      value={image}
+      onChange={setImage}
+      onValidation={(result) => {
+        console.log('Validation:', result);
+      }}
+      showPreview={true}
+      maxSize={5 * 1024 * 1024} // 5MB
+    />
+  );
+}`}
+        </pre>
+      </div>
+    </div>
+  );
+}
+
+export default ImageInputDemo;

+ 373 - 0
webui/components/ui/image-input.tsx

@@ -0,0 +1,373 @@
+import React, { useState, useRef, useEffect } from 'react';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from './tabs';
+import { Button } from './button';
+import { Input } from './input';
+import { Label } from './label';
+import { Alert, AlertDescription } from './alert';
+import { 
+  Upload, 
+  Link as LinkIcon, 
+  X, 
+  Loader2, 
+  CheckCircle, 
+  AlertCircle,
+  Image as ImageIcon,
+  Info
+} from 'lucide-react';
+import {
+  validateImageInput,
+  validateImageUrlWithBase64,
+  getImageDisplayName,
+  fileToDataURL,
+  type ImageValidationResult
+} from '../../lib/image-validation';
+
+export interface ImageInputProps {
+  value?: File | string | null;
+  onChange: (file: File | string | null) => void;
+  onValidation?: (result: ImageValidationResult) => void;
+  disabled?: boolean;
+  className?: string;
+  maxSize?: number; // in bytes, default 10MB
+  accept?: string; // file accept attribute
+  placeholder?: string;
+  showPreview?: boolean;
+  previewClassName?: string;
+}
+
+export interface ImageInputState {
+  mode: 'file' | 'url';
+  validation: ImageValidationResult | null;
+  isValidating: boolean;
+  error: string | null;
+  previewUrl: string | null;
+}
+
+export function ImageInput({
+  value,
+  onChange,
+  onValidation,
+  disabled = false,
+  className = '',
+  maxSize = 10 * 1024 * 1024, // 10MB
+  accept = 'image/*',
+  placeholder = 'Enter image URL or select a file',
+  showPreview = true,
+  previewClassName = ''
+}: ImageInputProps) {
+  const [state, setState] = useState<ImageInputState>({
+    mode: 'file',
+    validation: null,
+    isValidating: false,
+    error: null,
+    previewUrl: null
+  });
+
+  const [urlInput, setUrlInput] = useState('');
+  const fileInputRef = useRef<HTMLInputElement>(null);
+  const validationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
+
+  // Handle external value changes
+  useEffect(() => {
+    if (value === null) {
+      setState(prev => ({
+        ...prev,
+        validation: null,
+        error: null,
+        previewUrl: null
+      }));
+      setUrlInput('');
+      return;
+    }
+
+    if (value instanceof File) {
+      // File mode
+      setState(prev => ({
+        ...prev,
+        mode: 'file',
+        validation: null,
+        error: null,
+        previewUrl: null
+      }));
+      setUrlInput('');
+
+      // Validate file immediately
+      handleFileValidation(value);
+    } else {
+      // URL mode - value is a string (not File, not null)
+      // Keep the existing preview URL while validation is in progress
+      setState(prev => ({
+        ...prev,
+        mode: 'url',
+        validation: null,
+        error: null,
+        previewUrl: typeof value === 'string' ? value : null
+      }));
+      // value should be a string here, but cast it to be safe
+      setUrlInput(value || '');
+
+      // Validate URL (with debounce)
+      if (validationTimeoutRef.current) {
+        clearTimeout(validationTimeoutRef.current);
+      }
+      validationTimeoutRef.current = setTimeout(() => {
+        // Only validate if we have a non-empty string
+        const urlValue = typeof value === 'string' ? value : null;
+        if (urlValue && urlValue.trim()) {
+          handleUrlValidation(urlValue);
+        } else {
+          handleUrlValidation(null);
+        }
+      }, 500);
+    }
+  }, [value, maxSize]);
+
+  // Cleanup timeout on unmount
+  useEffect(() => {
+    return () => {
+      if (validationTimeoutRef.current) {
+        clearTimeout(validationTimeoutRef.current);
+      }
+    };
+  }, []);
+
+  const handleFileValidation = async (file: File) => {
+    setState(prev => ({ ...prev, isValidating: true, error: null }));
+    
+    const result = await validateImageInput(file);
+    
+    let previewUrl: string | null = null;
+    if (result.isValid) {
+      try {
+        previewUrl = await fileToDataURL(file);
+      } catch (error) {
+        console.error('Failed to create preview URL:', error);
+      }
+    }
+    
+    setState(prev => ({
+      ...prev,
+      isValidating: false,
+      validation: result,
+      error: result.isValid ? null : (result.error || null),
+      previewUrl
+    }));
+
+    onValidation?.(result);
+  };
+
+  const handleUrlValidation = async (url: string | null) => {
+    if (!url || !url.trim()) {
+      setState(prev => ({
+        ...prev,
+        validation: null,
+        error: null,
+        previewUrl: null
+      }));
+      onValidation?.({ isValid: false, error: 'Please enter a URL' });
+      return;
+    }
+
+    setState(prev => ({ ...prev, isValidating: true, error: null }));
+    
+    const result = await validateImageUrlWithBase64(url);
+    
+    // Use base64 data for preview if available, otherwise use original URL
+    const previewUrl = result.isValid ? (result.base64Data || url) : null;
+    
+    setState(prev => ({
+      ...prev,
+      isValidating: false,
+      validation: result,
+      error: result.isValid ? null : (result.error || null),
+      previewUrl
+    }));
+
+    onValidation?.(result);
+  };
+
+  const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
+    const file = event.target.files?.[0];
+    if (file) {
+      onChange(file);
+    }
+  };
+
+  const handleUrlInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+    const url = event.target.value;
+    setUrlInput(url);
+    onChange(url || null);
+  };
+
+  const handleModeChange = (mode: 'file' | 'url') => {
+    setState(prev => ({
+      ...prev,
+      mode,
+      validation: null,
+      error: null,
+      previewUrl: null
+    }));
+    onChange(null);
+    setUrlInput('');
+  };
+
+  const handleClear = () => {
+    onChange(null);
+    setUrlInput('');
+    setState(prev => ({
+      ...prev,
+      validation: null,
+      error: null,
+      previewUrl: null
+    }));
+  };
+
+  const isValid = state.validation?.isValid;
+  const hasError = state.error && !isValid;
+  const canPreview = isValid && state.previewUrl;
+  const isCorsBlocked = state.validation?.isCorsBlocked;
+  const hasBase64Data = !!state.validation?.base64Data;
+
+  return (
+    <div className={`image-input ${className}`}>
+      <Tabs value={state.mode} onValueChange={(value) => handleModeChange(value as 'file' | 'url')}>
+        <TabsList className="grid w-full grid-cols-2">
+          <TabsTrigger value="file" disabled={disabled}>
+            <Upload className="w-4 h-4 mr-2" />
+            Upload File
+          </TabsTrigger>
+          <TabsTrigger value="url" disabled={disabled}>
+            <LinkIcon className="w-4 h-4 mr-2" />
+            From URL
+          </TabsTrigger>
+        </TabsList>
+
+        <TabsContent value="file" className="space-y-4">
+          <div className="space-y-2">
+            <Label htmlFor="file-upload">Choose Image File</Label>
+            <div className="flex gap-2">
+              <Input
+                ref={fileInputRef}
+                id="file-upload"
+                type="file"
+                accept={accept}
+                onChange={handleFileSelect}
+                disabled={disabled}
+                className="flex-1"
+              />
+              {value instanceof File && (
+                <Button
+                  type="button"
+                  variant="outline"
+                  onClick={() => fileInputRef.current?.click()}
+                  disabled={disabled}
+                >
+                  Browse
+                </Button>
+              )}
+            </div>
+            {typeof value === 'string' && value && (
+              <p className="text-sm text-gray-500">
+                Current: {getImageDisplayName(value)}
+              </p>
+            )}
+          </div>
+        </TabsContent>
+
+        <TabsContent value="url" className="space-y-4">
+          <div className="space-y-2">
+            <Label htmlFor="url-input">Image URL</Label>
+            <Input
+              id="url-input"
+              type="url"
+              value={urlInput}
+              onChange={handleUrlInputChange}
+              placeholder={placeholder}
+              disabled={disabled}
+              className="flex-1"
+            />
+            <p className="text-xs text-gray-500">
+              Enter a URL that ends with an image extension (.jpg, .png, .gif, etc.)
+            </p>
+          </div>
+        </TabsContent>
+      </Tabs>
+
+      {/* Validation Status */}
+      {state.isValidating && (
+        <div className="flex items-center gap-2 text-sm text-gray-500">
+          <Loader2 className="w-4 h-4 animate-spin" />
+          Validating and downloading image...
+        </div>
+      )}
+
+      {isValid && (
+        <Alert className="mt-4">
+          <CheckCircle className="h-4 w-4" />
+          <AlertDescription>
+            Image is valid and ready to use
+            {state.validation?.filename && ` (${state.validation.filename})`}
+            {hasBase64Data && (
+              <span className="block mt-1 text-xs text-green-600">
+                ✓ Image downloaded and cached for preview
+              </span>
+            )}
+            {isCorsBlocked && (
+              <span className="block mt-1 text-xs text-yellow-600">
+                Note: Downloaded using fallback method due to CORS restrictions
+              </span>
+            )}
+          </AlertDescription>
+        </Alert>
+      )}
+
+      {hasError && (
+        <Alert variant="destructive" className="mt-4">
+          <AlertCircle className="h-4 w-4" />
+          <AlertDescription>
+            {state.error}
+            {state.error?.includes('CORS') && (
+              <span className="block mt-1 text-xs">
+                Try using a different image URL or upload the file directly
+              </span>
+            )}
+          </AlertDescription>
+        </Alert>
+      )}
+
+      {/* Image Preview */}
+      {showPreview && canPreview && (
+        <div className={`mt-4 ${previewClassName}`}>
+          <Label>Preview</Label>
+          <div className="mt-2 border rounded-lg p-4 bg-gray-50">
+            <div className="flex items-center justify-center h-48 bg-white rounded border">
+              <img
+                src={state.previewUrl || ''}
+                alt="Image preview"
+                className="max-w-full max-h-full object-contain"
+              />
+            </div>
+          </div>
+        </div>
+      )}
+
+      {/* Clear Button */}
+      {value && (
+        <div className="mt-4 flex justify-end">
+          <Button
+            type="button"
+            variant="outline"
+            onClick={handleClear}
+            disabled={disabled}
+            className="text-red-600 hover:text-red-700"
+          >
+            <X className="w-4 h-4 mr-2" />
+            Clear Selection
+          </Button>
+        </div>
+      )}
+    </div>
+  );
+}
+
+export default ImageInput;

+ 63 - 0
webui/components/ui/image-input.types.ts

@@ -0,0 +1,63 @@
+// Type definitions for the ImageInput component and related utilities
+
+export interface ImageInputProps {
+  /** Current value (file, URL string, or null) */
+  value?: File | string | null;
+  /** Callback when value changes */
+  onChange: (file: File | string | null) => void;
+  /** Callback for validation results */
+  onValidation?: (result: ImageValidationResult) => void;
+  /** Whether the input is disabled */
+  disabled?: boolean;
+  /** Additional CSS classes */
+  className?: string;
+  /** Maximum file size in bytes (default: 10MB) */
+  maxSize?: number;
+  /** File input accept attribute */
+  accept?: string;
+  /** Placeholder text for URL input */
+  placeholder?: string;
+  /** Whether to show image preview */
+  showPreview?: boolean;
+  /** CSS classes for preview container */
+  previewClassName?: string;
+}
+
+export interface ImageInputState {
+  /** Current input mode ('file' or 'url') */
+  mode: 'file' | 'url';
+  /** Validation result */
+  validation: ImageValidationResult | null;
+  /** Whether validation is in progress */
+  isValidating: boolean;
+  /** Error message */
+  error: string | null;
+  /** Preview data URL */
+  previewUrl: string | null;
+}
+
+export interface ImageValidationResult {
+  /** Whether the input is valid */
+  isValid: boolean;
+  /** Error message if invalid */
+  error?: string;
+  /** Detected MIME type */
+  detectedType?: string;
+  /** Original filename */
+  filename?: string;
+}
+
+export interface ImageInputMode {
+  /** Type of input */
+  type: 'file' | 'url' | null;
+  /** Input value */
+  value?: File | string;
+}
+
+// Types from validation module
+export type ImageExtension = 
+  | 'jpg' | 'jpeg' | 'png' | 'gif' | 'webp' | 'bmp' | 'svg' | 'tiff';
+
+export type ImageMimeType = 
+  | 'image/jpeg' | 'image/jpg' | 'image/png' | 'image/gif' 
+  | 'image/webp' | 'image/bmp' | 'image/svg+xml' | 'image/tiff';

+ 82 - 3
webui/lib/api.ts

@@ -491,6 +491,40 @@ class ApiClient {
     return response.blob();
   }
 
+  // Download image from URL with server-side proxy to avoid CORS issues
+  async downloadImageFromUrl(url: string): Promise<{
+    mimeType: string;
+    filename: string;
+    base64Data: string;
+  }> {
+    const apiUrl = `${this.getBaseUrl()}/image/download?url=${encodeURIComponent(url)}`;
+
+    const response = await fetch(apiUrl);
+
+    if (!response.ok) {
+      const errorData = await response.json().catch(() => ({
+        error: { message: response.statusText },
+      }));
+
+      // Handle nested error structure: { error: { message: "..." } }
+      const errorMessage =
+        errorData.error?.message ||
+        errorData.message ||
+        errorData.error ||
+        'Failed to download image from URL';
+
+      throw new Error(errorMessage);
+    }
+
+    const result = await response.json();
+
+    return {
+      mimeType: result.mime_type,
+      filename: result.filename,
+      base64Data: result.base64_data
+    };
+  }
+
   async cancelJob(jobId: string): Promise<void> {
     // Clear job status cache when cancelling
     cache.delete(`job_status_${jobId}`);
@@ -533,7 +567,7 @@ class ApiClient {
   }
 
   // Model management
-  async getModels(type?: string, loaded?: boolean, page: number = 1, limit: number = 50, search?: string): Promise<{ models: ModelInfo[]; pagination: any; statistics: any }> {
+  async getModels(type?: string, loaded?: boolean, page: number = 1, limit: number = -1, search?: string): Promise<{ models: ModelInfo[]; pagination: any; statistics: any }> {
     const cacheKey = `models_${type || 'all'}_${loaded ? 'loaded' : 'all'}_${page}_${limit}_${search || 'all'}`;
     const cachedResult = cache.get(cacheKey);
     if (cachedResult) {
@@ -544,8 +578,16 @@ class ApiClient {
     const params = [];
     if (type && type !== 'loaded') params.push(`type=${type}`);
     if (type === 'loaded' || loaded) params.push('loaded=true');
-    params.push(`page=${page}`);
-    params.push(`limit=${limit}`);
+
+    // Only add page parameter if we're using pagination (limit > 0)
+    if (limit > 0) {
+      params.push(`page=${page}`);
+      params.push(`limit=${limit}`);
+    } else {
+      // When limit is 0 (default), we want all models, so add limit=0 to disable pagination
+      params.push('limit=0');
+    }
+
     if (search) params.push(`search=${encodeURIComponent(search)}`);
 
     // Add include_metadata for additional information
@@ -644,6 +686,18 @@ class ApiClient {
     });
   }
 
+  async getModelTypes(): Promise<Array<{ type: string; description: string; extensions: string[]; capabilities: string[]; requires?: string[]; recommended_for: string }>> {
+    const cacheKey = 'model_types';
+    const cachedResult = cache.get(cacheKey);
+    if (cachedResult) {
+      return cachedResult;
+    }
+
+    const response = await this.request<{ model_types: Array<{ type: string; description: string; extensions: string[]; capabilities: string[]; requires?: string[]; recommended_for: string }> }>('/models/types');
+    cache.set(cacheKey, response.model_types, 60000); // Cache for 1 minute
+    return response.model_types;
+  }
+
   async convertModel(modelName: string, quantizationType: string, outputPath?: string): Promise<{ request_id: string; message: string }> {
     return this.request<{ request_id: string; message: string }>('/models/convert', {
       method: 'POST',
@@ -675,6 +729,31 @@ class ApiClient {
     });
   }
 
+  // Image manipulation endpoints
+  async resizeImage(image: string, width: number, height: number): Promise<{ image: string }> {
+    return this.request<{ image: string }>('/image/resize', {
+      method: 'POST',
+      body: JSON.stringify({
+        image,
+        width,
+        height,
+      }),
+    });
+  }
+
+  async cropImage(image: string, x: number, y: number, width: number, height: number): Promise<{ image: string }> {
+    return this.request<{ image: string }>('/image/crop', {
+      method: 'POST',
+      body: JSON.stringify({
+        image,
+        x,
+        y,
+        width,
+        height,
+      }),
+    });
+  }
+
   // Configuration endpoints with caching
   async getSamplers(): Promise<Array<{ name: string; description: string; recommended_steps: number }>> {
     const cacheKey = 'samplers';

+ 191 - 0
webui/lib/image-validation.ts

@@ -0,0 +1,191 @@
+// Image URL validation utilities for the ImageInput component
+
+import { apiClient } from './api';
+
+export interface ImageValidationResult {
+  isValid: boolean;
+  error?: string;
+  detectedType?: string;
+  filename?: string;
+  isCorsBlocked?: boolean;
+  base64Data?: string;
+}
+
+export interface ImageInputMode {
+  type: 'file' | 'url' | null;
+  value?: File | string;
+}
+
+// Supported image MIME types and extensions
+export const IMAGE_MIME_TYPES = [
+  'image/jpeg',
+  'image/jpg',
+  'image/png',
+  'image/gif',
+  'image/webp',
+  'image/bmp',
+  'image/svg+xml',
+  'image/tiff'
+] as const;
+
+export const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'tiff'] as const;
+
+export type ImageExtension = typeof IMAGE_EXTENSIONS[number];
+
+// Extract extension from filename or URL
+export function extractExtension(filenameOrUrl: string): string | null {
+  // Remove query parameters and fragments
+  const cleanUrl = filenameOrUrl.split('?')[0].split('#')[0];
+
+  // Get the last part after dot
+  const extension = cleanUrl.split('.').pop()?.toLowerCase();
+
+  return extension || null;
+}
+
+// Check if URL contains image extension (more flexible than just checking the end)
+export function hasImageExtension(url: string): boolean {
+  const extension = extractExtension(url);
+  return extension ? isValidImageExtension(extension) : false;
+}
+
+// Check if extension is a valid image extension
+export function isValidImageExtension(extension: string): extension is ImageExtension {
+  return IMAGE_EXTENSIONS.includes(extension as ImageExtension);
+}
+
+// Get MIME type from extension
+export function getMimeTypeFromExtension(extension: string): string {
+  const extToMime: Record<ImageExtension, string> = {
+    'jpg': 'image/jpeg',
+    'jpeg': 'image/jpeg',
+    'png': 'image/png',
+    'gif': 'image/gif',
+    'webp': 'image/webp',
+    'bmp': 'image/bmp',
+    'svg': 'image/svg+xml',
+    'tiff': 'image/tiff'
+  };
+
+  return extToMime[extension as ImageExtension] || 'image/unknown';
+}
+
+// Validate file object
+export function validateFile(file: File): ImageValidationResult {
+  // Check if file is empty
+  if (!file || file.size === 0) {
+    return {
+      isValid: false,
+      error: 'File is empty or invalid'
+    };
+  }
+
+  // Check file size (max 10MB)
+  const maxSize = 10 * 1024 * 1024; // 10MB
+  if (file.size > maxSize) {
+    return {
+      isValid: false,
+      error: `File size (${(file.size / 1024 / 1024).toFixed(1)}MB) exceeds 10MB limit`
+    };
+  }
+
+  // Check MIME type
+  if (!IMAGE_MIME_TYPES.includes(file.type as any)) {
+    return {
+      isValid: false,
+      error: `File type '${file.type}' is not supported. Supported formats: ${IMAGE_EXTENSIONS.join(', ')}`
+    };
+  }
+
+  return {
+    isValid: true,
+    detectedType: file.type,
+    filename: file.name
+  };
+}
+
+// Basic URL validation without network requests
+export function validateUrlFormat(url: string): ImageValidationResult {
+  try {
+    new URL(url);
+  } catch {
+    return {
+      isValid: false,
+      error: 'Invalid URL format'
+    };
+  }
+
+  // Check if it has a valid image extension
+  if (!hasImageExtension(url)) {
+    return {
+      isValid: false,
+      error: 'URL does not point to a supported image format. Supported formats: ' + IMAGE_EXTENSIONS.join(', ')
+    };
+  }
+
+  // Basic validation passed - return the extension-based result
+  const extension = extractExtension(url);
+  return {
+    isValid: true,
+    detectedType: extension ? getMimeTypeFromExtension(extension) : undefined,
+    filename: url.split('/').pop() || url
+  };
+}
+
+// Validate image URL using server-side download endpoint
+export async function validateImageUrlWithBase64(url: string): Promise<ImageValidationResult> {
+  // First validate URL format and extension
+  const formatValidation = validateUrlFormat(url);
+  if (!formatValidation.isValid) {
+    return formatValidation;
+  }
+
+  try {
+    // Use server-side download endpoint to avoid CORS issues
+    const result = await apiClient.downloadImageFromUrl(url);
+
+    return {
+      isValid: true,
+      detectedType: result.mimeType,
+      filename: result.filename,
+      base64Data: result.base64Data
+    };
+
+  } catch (error) {
+    return {
+      isValid: false,
+      error: `Failed to download image from URL: ${error instanceof Error ? error.message : 'Unknown error'}`
+    };
+  }
+}
+
+// Validate image input (file or URL) with base64 conversion
+export async function validateImageInput(
+  input: File | string
+): Promise<ImageValidationResult> {
+  if (input instanceof File) {
+    return validateFile(input);
+  } else {
+    return await validateImageUrlWithBase64(input);
+  }
+}
+
+// Get display name for image input
+export function getImageDisplayName(input: File | string): string {
+  if (input instanceof File) {
+    return input.name;
+  } else {
+    const url = new URL(input);
+    return url.pathname.split('/').pop() || input;
+  }
+}
+
+// Convert file to data URL for preview
+export function fileToDataURL(file: File): Promise<string> {
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader();
+    reader.onload = () => resolve(reader.result as string);
+    reader.onerror = reject;
+    reader.readAsDataURL(file);
+  });
+}