From 8cb1f11e244309266a4f51ec84fcdfdbdd2f59aa Mon Sep 17 00:00:00 2001 From: Thomas Date: Sat, 18 Apr 2026 19:13:14 +0200 Subject: [PATCH] Initial commit of ComfyUI Model Download Manager --- .gitignore | 24 + .gradle/8.7/checksums/checksums.lock | Bin 0 -> 17 bytes .gradle/8.7/fileChanges/last-build.bin | Bin 0 -> 1 bytes .gradle/8.7/fileHashes/fileHashes.lock | Bin 0 -> 17 bytes .gradle/8.7/gc.properties | 0 .gradle/vcs-1/gc.properties | 0 README.md | 39 ++ pom.xml | 54 ++ .../java/com/example/comfymodels/Main.java | 499 ++++++++++++++++++ 9 files changed, 616 insertions(+) create mode 100644 .gitignore create mode 100644 .gradle/8.7/checksums/checksums.lock create mode 100644 .gradle/8.7/fileChanges/last-build.bin create mode 100644 .gradle/8.7/fileHashes/fileHashes.lock create mode 100644 .gradle/8.7/gc.properties create mode 100644 .gradle/vcs-1/gc.properties create mode 100644 README.md create mode 100644 pom.xml create mode 100644 src/main/java/com/example/comfymodels/Main.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..404d785 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Build artifacts +target/ +*.jar +*.class + +# IDE files +.idea/ +*.iml +.settings/ +.project +.classpath +*.shfbproj +*.user +*.suo +*.userosscache +*.sln.docstates + +# OS files +Thumbs.db +.DS_Store + +# Temporary files +temp/ +dependency-reduced-pom.xml diff --git a/.gradle/8.7/checksums/checksums.lock b/.gradle/8.7/checksums/checksums.lock new file mode 100644 index 0000000000000000000000000000000000000000..81748f8ed2f294853678d847ad087d4ebc3a8279 GIT binary patch literal 17 TcmZQRm;dMAZN=Dw3{U_7L~R9C literal 0 HcmV?d00001 diff --git a/.gradle/8.7/fileChanges/last-build.bin b/.gradle/8.7/fileChanges/last-build.bin new file mode 100644 index 0000000000000000000000000000000000000000..f76dd238ade08917e6712764a16a22005a50573d GIT binary patch literal 1 IcmZPo000310RR91 literal 0 HcmV?d00001 diff --git a/.gradle/8.7/fileHashes/fileHashes.lock b/.gradle/8.7/fileHashes/fileHashes.lock new file mode 100644 index 0000000000000000000000000000000000000000..e14cee129f3caa443a2b76ca09d1ccd80a111470 GIT binary patch literal 17 ScmZP$_B_xmrRD3(00jUep8~-E literal 0 HcmV?d00001 diff --git a/.gradle/8.7/gc.properties b/.gradle/8.7/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/.gradle/vcs-1/gc.properties b/.gradle/vcs-1/gc.properties new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..edc3991 --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# ComfyUI Model Download Manager + +A robust Java Swing application to identify and download missing models for ComfyUI workflows. + +## Features +- **Advanced JSON Parsing**: Recursively scans ComfyUI JSON workflows (including subgraphs and `widgets_values`) for model filenames. +- **Model Metadata Support**: Detects and uses embedded model download metadata (URLs, directories). +- **Download Manager**: + - **Resume Capability**: Uses HTTP Range requests to resume interrupted downloads. + - **Pause/Resume/Stop**: Full control over the download queue. + - **Overwrite Protection**: Prompts before overwriting existing local files. + - **Real-time Status**: Shows download speed (MB/s) and per-file progress. +- **Intelligent Sorting**: Automatically infers the correct subfolder (e.g., `checkpoints`, `loras`, `vae`) based on node types. + +## Requirements +- **Java 11 or higher** +- **Maven** (to build) + +## How to Build +1. Clone the repository. +2. Run the following command: + ```powershell + mvn clean package + ``` +3. The runnable JAR will be created in the `target/` directory: + `target/comfymodeldownloader-1.0-SNAPSHOT.jar` + +## How to Use +1. Run the JAR: + ```powershell + java -jar target/comfymodeldownloader-1.0-SNAPSHOT.jar + ``` +2. Set your ComfyUI models directory path (e.g., `C:\ComfyUI\models`). +3. Paste a ComfyUI JSON workflow or load a `.json` file. +4. Click **Analyze Models**. +5. Select the models you want to download and click **Start Queue**. + +## License +MIT diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..51232f7 --- /dev/null +++ b/pom.xml @@ -0,0 +1,54 @@ + + + 4.0.0 + + com.example + comfymodeldownloader + 1.0-SNAPSHOT + + + 11 + 11 + UTF-8 + + + + + org.json + json + 20240303 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.1 + + + package + + shade + + + + + com.example.comfymodels.Main + + + + + + + + + diff --git a/src/main/java/com/example/comfymodels/Main.java b/src/main/java/com/example/comfymodels/Main.java new file mode 100644 index 0000000..543f627 --- /dev/null +++ b/src/main/java/com/example/comfymodels/Main.java @@ -0,0 +1,499 @@ +package com.example.comfymodels; + +import org.json.JSONArray; +import org.json.JSONObject; + +import javax.swing.*; +import javax.swing.filechooser.FileNameExtensionFilter; +import javax.swing.table.DefaultTableModel; +import java.awt.*; +import java.io.*; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.*; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class Main extends JFrame { + private JTextField modelsPathField; + private JTextArea jsonInputArea; + private JTable modelTable; + private DefaultTableModel tableModel; + private JProgressBar progressBar; + private JLabel statusLabel; + private JButton downloadButton; + private JButton pauseButton; + private JButton stopButton; + + private List modelsToDownload = new ArrayList<>(); + private final Set processedModelNames = new HashSet<>(); + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final HttpClient httpClient = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.ALWAYS).build(); + + private volatile boolean isPaused = false; + private volatile boolean isStopped = false; + private static final Pattern MODEL_FILE_PATTERN = Pattern.compile("([^\\\\/]+\\.(?:safetensors|ckpt|pt|bin|pth|onnx))", Pattern.CASE_INSENSITIVE); + + public Main() { + setTitle("ComfyUI Model Download Manager (Pause/Resume/Stop)"); + setSize(1200, 850); + setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); + setLayout(new BorderLayout()); + + // Top Panel: Models Directory + JPanel topPanel = new JPanel(new BorderLayout()); + topPanel.setBorder(BorderFactory.createTitledBorder("Models Directory")); + modelsPathField = new JTextField("C:\\pinokio\\api\\comfy.git\\app\\models"); + JButton browsePathButton = new JButton("Browse..."); + browsePathButton.addActionListener(e -> chooseDirectory()); + topPanel.add(modelsPathField, BorderLayout.CENTER); + topPanel.add(browsePathButton, BorderLayout.EAST); + + // Center Panel: JSON Input and Model List + JSplitPane splitPane = new JSplitPane(JSplitPane.VERTICAL_SPLIT); + + // JSON Input + JPanel jsonPanel = new JPanel(new BorderLayout()); + jsonPanel.setBorder(BorderFactory.createTitledBorder("ComfyUI JSON Workflow")); + jsonInputArea = new JTextArea(); + JScrollPane jsonScroll = new JScrollPane(jsonInputArea); + JPanel jsonButtons = new JPanel(new FlowLayout(FlowLayout.RIGHT)); + JButton loadJsonButton = new JButton("Load File..."); + loadJsonButton.addActionListener(e -> chooseJsonFile()); + JButton analyzeButton = new JButton("Analyze Models"); + analyzeButton.addActionListener(e -> analyzeJson()); + jsonButtons.add(loadJsonButton); + jsonButtons.add(analyzeButton); + jsonPanel.add(jsonScroll, BorderLayout.CENTER); + jsonPanel.add(jsonButtons, BorderLayout.SOUTH); + + // Model List + String[] columnNames = {"Select", "Type", "Name", "URL", "Status"}; + tableModel = new DefaultTableModel(columnNames, 0) { + @Override + public Class getColumnClass(int columnIndex) { + return columnIndex == 0 ? Boolean.class : String.class; + } + @Override + public boolean isCellEditable(int row, int column) { + return column == 0; + } + }; + modelTable = new JTable(tableModel); + modelTable.getColumnModel().getColumn(0).setMaxWidth(60); + modelTable.getColumnModel().getColumn(1).setPreferredWidth(100); + modelTable.getColumnModel().getColumn(2).setPreferredWidth(250); + modelTable.getColumnModel().getColumn(4).setPreferredWidth(300); + JScrollPane tableScroll = new JScrollPane(modelTable); + JPanel tablePanel = new JPanel(new BorderLayout()); + tablePanel.setBorder(BorderFactory.createTitledBorder("Identified Models")); + + JPanel selectionPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); + JButton selectAllBtn = new JButton("Select All"); + selectAllBtn.addActionListener(e -> setAllSelected(true)); + JButton deselectAllBtn = new JButton("Deselect All"); + deselectAllBtn.addActionListener(e -> setAllSelected(false)); + selectionPanel.add(selectAllBtn); + selectionPanel.add(deselectAllBtn); + + tablePanel.add(selectionPanel, BorderLayout.NORTH); + tablePanel.add(tableScroll, BorderLayout.CENTER); + + splitPane.setTopComponent(jsonPanel); + splitPane.setBottomComponent(tablePanel); + splitPane.setDividerLocation(300); + + // Bottom Panel: Actions and Progress + JPanel bottomPanel = new JPanel(new BorderLayout()); + progressBar = new JProgressBar(0, 100); + statusLabel = new JLabel("Ready"); + + JPanel actionButtons = new JPanel(new FlowLayout(FlowLayout.RIGHT)); + downloadButton = new JButton("Start Queue"); + downloadButton.setEnabled(false); + downloadButton.addActionListener(e -> startDownloads()); + + pauseButton = new JButton("Pause"); + pauseButton.setEnabled(false); + pauseButton.addActionListener(e -> togglePause()); + + stopButton = new JButton("Stop"); + stopButton.setEnabled(false); + stopButton.addActionListener(e -> stopDownloads()); + + actionButtons.add(downloadButton); + actionButtons.add(pauseButton); + actionButtons.add(stopButton); + + JPanel progressPanel = new JPanel(new GridLayout(2, 1)); + progressPanel.add(statusLabel); + progressPanel.add(progressBar); + + bottomPanel.add(progressPanel, BorderLayout.CENTER); + bottomPanel.add(actionButtons, BorderLayout.EAST); + + add(topPanel, BorderLayout.NORTH); + add(splitPane, BorderLayout.CENTER); + add(bottomPanel, BorderLayout.SOUTH); + } + + private void togglePause() { + isPaused = !isPaused; + pauseButton.setText(isPaused ? "Resume" : "Pause"); + if (!isPaused) { + statusLabel.setText("Resuming..."); + } else { + statusLabel.setText("Paused."); + } + } + + private void stopDownloads() { + isStopped = true; + isPaused = false; + pauseButton.setText("Pause"); + statusLabel.setText("Stopping queue..."); + } + + private void setAllSelected(boolean selected) { + for (int i = 0; i < tableModel.getRowCount(); i++) { + tableModel.setValueAt(selected, i, 0); + } + } + + private void chooseDirectory() { + JFileChooser chooser = new JFileChooser(); + chooser.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + if (chooser.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) { + modelsPathField.setText(chooser.getSelectedFile().getAbsolutePath()); + } + } + + private void chooseJsonFile() { + JFileChooser chooser = new JFileChooser(); + chooser.setFileFilter(new FileNameExtensionFilter("JSON Files", "json")); + if (chooser.showOpenDialog(this) == JFileChooser.APPROVE_OPTION) { + try { + String content = Files.readString(chooser.getSelectedFile().toPath()); + jsonInputArea.setText(content); + analyzeJson(); + } catch (IOException e) { + JOptionPane.showMessageDialog(this, "Error reading file: " + e.getMessage()); + } + } + } + + private void analyzeJson() { + String jsonText = jsonInputArea.getText(); + if (jsonText.isEmpty()) return; + + try { + modelsToDownload.clear(); + processedModelNames.clear(); + tableModel.setRowCount(0); + + JSONObject jsonObject = new JSONObject(jsonText); + findModelsMetadata(jsonObject); + scanForModelFiles(jsonObject); + + for (ModelInfo info : modelsToDownload) { + tableModel.addRow(new Object[]{true, info.type, info.name, info.url, "Pending"}); + } + + downloadButton.setEnabled(!modelsToDownload.isEmpty()); + statusLabel.setText("Found " + modelsToDownload.size() + " models."); + + } catch (Exception e) { + JOptionPane.showMessageDialog(this, "Error analyzing JSON: " + e.getMessage()); + } + } + + private void findModelsMetadata(Object obj) { + if (obj instanceof JSONObject) { + JSONObject jo = (JSONObject) obj; + if (jo.has("models") && jo.get("models") instanceof JSONArray) { + JSONArray models = jo.getJSONArray("models"); + for (int i = 0; i < models.length(); i++) { + Object mObj = models.get(i); + if (mObj instanceof JSONObject) { + JSONObject m = (JSONObject) mObj; + String name = m.optString("name"); + if (name != null && !name.isEmpty()) { + String url = m.optString("url", "MISSING"); + String dir = m.optString("directory", "checkpoints"); + addModelInfo(new ModelInfo(dir, name, url)); + } + } + } + } + for (String key : jo.keySet()) { + findModelsMetadata(jo.get(key)); + } + } else if (obj instanceof JSONArray) { + JSONArray ja = (JSONArray) obj; + for (int i = 0; i < ja.length(); i++) { + findModelsMetadata(ja.get(i)); + } + } + } + + private void scanForModelFiles(Object obj) { + if (obj instanceof JSONObject) { + JSONObject jo = (JSONObject) obj; + for (String key : jo.keySet()) { + Object val = jo.get(key); + if (val instanceof String) { + checkAndAddModelFromFileName((String) val, inferTypeFromKey(key)); + } else { + scanForModelFiles(val); + } + } + } else if (obj instanceof JSONArray) { + JSONArray ja = (JSONArray) obj; + for (int i = 0; i < ja.length(); i++) { + Object val = ja.get(i); + if (val instanceof String) { + checkAndAddModelFromFileName((String) val, "checkpoints"); + } else { + scanForModelFiles(val); + } + } + } + } + + private void checkAndAddModelFromFileName(String text, String type) { + Matcher m = MODEL_FILE_PATTERN.matcher(text); + while (m.find()) { + String fileName = m.group(1); + if (!processedModelNames.contains(fileName)) { + addModelInfo(new ModelInfo(type, fileName, "MISSING (Needs manual URL)")); + } + } + } + + private String inferTypeFromKey(String key) { + if (key.contains("ckpt") || key.contains("checkpoint")) return "checkpoints"; + if (key.contains("lora")) return "loras"; + if (key.contains("vae")) return "vae"; + if (key.contains("control") && key.contains("net")) return "controlnet"; + if (key.contains("upscale")) return "upscale_models"; + if (key.contains("clip")) return "clip"; + if (key.contains("unet") || key.contains("diffusion")) return "unet"; + return "checkpoints"; + } + + private void addModelInfo(ModelInfo info) { + for (int i = 0; i < modelsToDownload.size(); i++) { + ModelInfo existing = modelsToDownload.get(i); + if (existing.name.equals(info.name)) { + if (existing.url.startsWith("MISSING") && !info.url.startsWith("MISSING")) { + modelsToDownload.set(i, info); + } + return; + } + } + modelsToDownload.add(info); + processedModelNames.add(info.name); + } + + private void startDownloads() { + String baseDir = modelsPathField.getText(); + if (baseDir.isEmpty()) { + JOptionPane.showMessageDialog(this, "Please specify a models directory."); + return; + } + + isStopped = false; + isPaused = false; + downloadButton.setEnabled(false); + pauseButton.setEnabled(true); + stopButton.setEnabled(true); + + executor.submit(() -> { + try { + for (int i = 0; i < tableModel.getRowCount(); i++) { + if (isStopped) { + updateStatus(i, "Stopped"); + continue; + } + + boolean selected = (Boolean) tableModel.getValueAt(i, 0); + if (!selected) continue; + + ModelInfo info = modelsToDownload.get(i); + if (info.url.startsWith("MISSING")) { + updateStatus(i, "Skipped (No URL)"); + continue; + } + + try { + processDownload(info, baseDir, i); + } catch (Exception e) { + updateStatus(i, "Error: " + e.getMessage()); + } + } + } finally { + SwingUtilities.invokeLater(() -> { + statusLabel.setText(isStopped ? "Queue stopped." : "Queue finished."); + downloadButton.setEnabled(true); + pauseButton.setEnabled(false); + stopButton.setEnabled(false); + pauseButton.setText("Pause"); + }); + } + }); + } + + private void updateStatus(int row, String status) { + SwingUtilities.invokeLater(() -> tableModel.setValueAt(status, row, 4)); + } + + private void processDownload(ModelInfo info, String baseDir, int rowIndex) throws Exception { + Path targetDir = Paths.get(baseDir, info.type); + Files.createDirectories(targetDir); + Path targetFile = targetDir.resolve(info.name); + + long existingSize = 0; + if (Files.exists(targetFile)) { + existingSize = Files.size(targetFile); + long totalSize = getServerFileSize(info.url); + + if (totalSize != -1 && existingSize == totalSize) { + updateStatus(rowIndex, "Already exists (Complete)"); + return; + } + + final long fTotalSize = totalSize; + final long fExistingSize = existingSize; + + int choice = showChoiceDialog(info.name, fExistingSize, fTotalSize); + + if (choice == 2) { // Skip + updateStatus(rowIndex, "Skipped"); + return; + } else if (choice == 1) { // Overwrite + Files.delete(targetFile); + existingSize = 0; + } + } + + downloadWithResume(info, targetFile, existingSize, rowIndex); + } + + private long getServerFileSize(String url) { + try { + HttpRequest headRequest = HttpRequest.newBuilder() + .uri(URI.create(url)) + .method("HEAD", HttpRequest.BodyPublishers.noBody()) + .build(); + HttpResponse response = httpClient.send(headRequest, HttpResponse.BodyHandlers.discarding()); + return response.headers().firstValueAsLong("Content-Length").orElse(-1L); + } catch (Exception e) { + return -1; + } + } + + private int showChoiceDialog(String name, long current, long total) throws Exception { + final int[] result = new int[1]; + String totalStr = total == -1 ? "unknown" : (total / 1024 / 1024) + " MB"; + String currentStr = (current / 1024 / 1024) + " MB"; + + SwingUtilities.invokeAndWait(() -> { + String message = String.format("File '%s' already exists.\nLocal size: %s\nRemote size: %s", name, currentStr, totalStr); + Object[] options = {"Resume", "Overwrite", "Skip"}; + result[0] = JOptionPane.showOptionDialog(this, message, "Existing File Found", + JOptionPane.YES_NO_CANCEL_OPTION, JOptionPane.QUESTION_MESSAGE, null, options, options[0]); + }); + return result[0]; + } + + private void downloadWithResume(ModelInfo info, Path targetFile, long startByte, int rowIndex) throws IOException, InterruptedException { + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(URI.create(info.url)); + if (startByte > 0) { + requestBuilder.header("Range", "bytes=" + startByte + "-"); + } + + HttpRequest request = requestBuilder.build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream()); + + if (response.statusCode() != 200 && response.statusCode() != 206) { + throw new IOException("HTTP " + response.statusCode()); + } + + long contentLen = response.headers().firstValueAsLong("Content-Length").orElse(-1L); + long totalBytes = startByte + (contentLen != -1 ? contentLen : 0); + + try (InputStream is = response.body(); + OutputStream os = new FileOutputStream(targetFile.toFile(), startByte > 0)) { + + byte[] buffer = new byte[16384]; + long downloaded = startByte; + long lastUpdate = System.currentTimeMillis(); + long lastDownloaded = startByte; + int read; + + while ((read = is.read(buffer)) != -1) { + if (isStopped) { + updateStatus(rowIndex, "Stopped at " + (downloaded * 100 / (totalBytes > 0 ? totalBytes : 1)) + "%"); + return; + } + + while (isPaused) { + Thread.sleep(500); + if (isStopped) return; + } + + os.write(buffer, 0, read); + downloaded += read; + + long now = System.currentTimeMillis(); + if (now - lastUpdate > 500) { + long diff = downloaded - lastDownloaded; + double speed = diff / ((now - lastUpdate) / 1000.0) / 1024.0 / 1024.0; + + final long fDownloaded = downloaded; + final double fSpeed = speed; + final int progress = totalBytes > 0 ? (int) (fDownloaded * 100 / totalBytes) : 0; + + SwingUtilities.invokeLater(() -> { + progressBar.setValue(progress); + String statusText = String.format("Downloading: %d%% (%.2f MB/s)", progress, fSpeed); + tableModel.setValueAt(statusText, rowIndex, 4); + statusLabel.setText("Current: " + info.name + " [" + statusText + "]"); + }); + + lastUpdate = now; + lastDownloaded = downloaded; + } + } + } + if (!isStopped) updateStatus(rowIndex, "Finished"); + } + + public static void main(String[] args) { + SwingUtilities.invokeLater(() -> { + Main main = new Main(); + main.setLocationRelativeTo(null); + main.setVisible(true); + }); + } + + static class ModelInfo { + String type; + String name; + String url; + + public ModelInfo(String type, String name, String url) { + this.type = type; + this.name = name; + this.url = url; + } + } +}