diff --git a/.github/workflows/comfyui-base.yaml b/.github/workflows/comfyui-base.yaml deleted file mode 100644 index 82281031..00000000 --- a/.github/workflows/comfyui-base.yaml +++ /dev/null @@ -1,76 +0,0 @@ -name: Build and push comfyui-base docker image - -on: - pull_request: - paths-ignore: - - "ui/*" - branches: - - main - push: - paths-ignore: - - "ui/*" - branches: - - main - tags: - - "v*" - -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} - cancel-in-progress: true - -jobs: - docker: - name: docker builds - if: ${{ github.repository == 'livepeer/comfystream' }} - permissions: - packages: write - contents: read - runs-on: [self-hosted, linux, gpu] - steps: - - name: Check out code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha }} - - - name: Login to DockerHub - uses: docker/login-action@v3 - with: - username: ${{ secrets.CI_DOCKERHUB_USERNAME }} - password: ${{ secrets.CI_DOCKERHUB_TOKEN }} - - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@v5 - with: - images: | - livepeer/comfyui-base - tags: | - type=sha - type=ref,event=pr - type=ref,event=tag - type=sha,format=long - type=ref,event=branch - type=semver,pattern={{version}},prefix=v - type=semver,pattern={{major}}.{{minor}},prefix=v - type=raw,value=latest,enable={{is_default_branch}} - type=raw,value=${{ github.event.pull_request.head.ref }} - type=raw,value=stable,enable=${{ startsWith(github.event.ref, 'refs/tags/v') }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build and push livepeer docker image - timeout-minutes: 200 - uses: docker/build-push-action@v6 - with: - context: . - provenance: mode=max - sbom: true - push: true - tags: ${{ steps.meta.outputs.tags }} - file: docker/Dockerfile.base - labels: ${{ steps.meta.outputs.labels }} - annotations: ${{ steps.meta.outputs.annotations }} - cache-from: type=registry,ref=livepeer/comfyui-base:build-cache - cache-to: type=registry,mode=max,ref=livepeer/comfyui-base:build-cache diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index ac41173a..2b15a277 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -15,13 +15,16 @@ concurrency: cancel-in-progress: true jobs: - build: - name: docker builds + base: + name: comfyui-base image if: ${{ github.repository == 'livepeer/comfystream' }} + outputs: + repository: ${{ steps.repo.outputs.repository }} + image-digest: ${{ steps.build.outputs.digest }} permissions: packages: write contents: read - runs-on: [self-hosted, linux, amd64] + runs-on: [self-hosted, linux, gpu] steps: - name: Check out code uses: actions/checkout@v4 @@ -29,12 +32,92 @@ jobs: fetch-depth: 0 ref: ${{ github.event.pull_request.head.sha }} + - name: Output image repository + id: repo + shell: bash + run: | + echo "repository=livepeer/comfyui-base" >> "$GITHUB_OUTPUT" + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ${{ steps.repo.outputs.repository }} + tags: | + type=sha + type=ref,event=pr + type=ref,event=tag + type=sha,format=long + type=ref,event=branch + type=semver,pattern={{version}},prefix=v + type=semver,pattern={{major}}.{{minor}},prefix=v + type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=${{ github.event.pull_request.head.ref }} + type=raw,value=stable,enable=${{ startsWith(github.event.ref, 'refs/tags/v') }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to DockerHub uses: docker/login-action@v3 with: username: ${{ secrets.CI_DOCKERHUB_USERNAME }} password: ${{ secrets.CI_DOCKERHUB_TOKEN }} + - name: Build and push livepeer docker image + id: build + timeout-minutes: 200 + uses: docker/build-push-action@v6 + with: + context: . + provenance: mode=max + sbom: true + push: true + tags: ${{ steps.meta.outputs.tags }} + file: docker/Dockerfile.base + labels: ${{ steps.meta.outputs.labels }} + annotations: ${{ steps.meta.outputs.annotations }} + cache-from: type=registry,ref=livepeer/comfyui-base:build-cache + cache-to: type=registry,mode=max,ref=livepeer/comfyui-base:build-cache + + trigger: + name: Trigger ai-runner workflow + needs: base + if: ${{ github.repository == 'livepeer/comfystream' }} + runs-on: ubuntu-latest + steps: + - name: Send workflow dispatch event to ai-runner + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.CI_GITHUB_TOKEN }} + script: | + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: "ai-runner", + workflow_id: "comfyui-trigger.yaml", + ref: "main", + inputs: { + "comfyui-base-digest": "${{ needs.base.outputs.image-digest }}", + "triggering-branch": "${{ github.head_ref || github.ref_name }}", + }, + }); + + comfystream: + name: comfystream image + needs: base + if: ${{ github.repository == 'livepeer/comfystream' }} + permissions: + packages: write + contents: read + runs-on: [self-hosted, linux, amd64] + steps: + - name: Check out code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v5 @@ -56,6 +139,12 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Login to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.CI_DOCKERHUB_USERNAME }} + password: ${{ secrets.CI_DOCKERHUB_TOKEN }} + - name: Build and push livepeer docker image uses: docker/build-push-action@v6 with: @@ -65,6 +154,8 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} file: docker/Dockerfile + build-args: | + BASE_IMAGE=${{ needs.base.outputs.repository }}@${{ needs.base.outputs.image-digest }} labels: ${{ steps.meta.outputs.labels }} annotations: ${{ steps.meta.outputs.annotations }} cache-from: type=registry,ref=${{ github.repository }}:build-cache diff --git a/README.md b/README.md index 7aeb197a..3b0dcd9e 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ Make sure you have [PyTorch](https://pytorch.org/get-started/locally/) installed Install `comfystream`: ```bash -pip install git+https://github.com/yondonfu/comfystream.git +pip install git+https://github.com/livepeer/comfystream.git # This can be used to install from a local repo # pip install . diff --git a/configs/nodes.yaml b/configs/nodes.yaml index 38138249..73a26c9a 100644 --- a/configs/nodes.yaml +++ b/configs/nodes.yaml @@ -7,7 +7,7 @@ nodes: type: "tensorrt" dependencies: - "onnxruntime-gpu>=1.17.0" - - "onnx>=1.17.0" + - "onnx==1.17.0" comfyui-depthanything-tensorrt: name: "ComfyUI DepthAnything TensorRT" diff --git a/docker/Dockerfile b/docker/Dockerfile index 10d9ba95..39301d28 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -23,16 +23,13 @@ RUN bash -c "source $NVM_DIR/nvm.sh && \ ENV NODE_PATH="$NVM_DIR/v$NODE_VERSION/lib/node_modules" \ PATH="$NVM_DIR/versions/node/v$NODE_VERSION/bin:$PATH" - # Create the supervisor configuration file for ComfyUI and ComfyStream COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf WORKDIR /workspace/comfystream COPY --chmod=0755 docker/entrypoint.sh /workspace/comfystream/docker/entrypoint.sh -EXPOSE 8188 -EXPOSE 8889 -EXPOSE 3000 +EXPOSE 8188 8889 3000 EXPOSE 1024-65535/udp ENTRYPOINT [ "/workspace/comfystream/docker/entrypoint.sh" ] diff --git a/docker/Dockerfile.base b/docker/Dockerfile.base index 00cab374..a67587c8 100644 --- a/docker/Dockerfile.base +++ b/docker/Dockerfile.base @@ -31,7 +31,7 @@ RUN mkdir -p /workspace/comfystream && \ rm /tmp/miniconda.sh && echo 'export LD_LIBRARY_PATH=/workspace/miniconda3/envs/comfystream/lib:$LD_LIBRARY_PATH' >> ~/.bashrc # Clone ComfyUI -RUN git clone https://github.com/comfyanonymous/ComfyUI.git /workspace/ComfyUI +RUN git clone --branch v0.3.27 --depth 1 https://github.com/comfyanonymous/ComfyUI.git /workspace/ComfyUI # Copy only files needed for setup COPY ./src/comfystream/scripts /workspace/comfystream/src/comfystream/scripts diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 009f4bda..5c8a1199 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -3,17 +3,19 @@ set -e eval "$(conda shell.bash hook)" -# Handle workspace mounting -if [ -d "/app" ] && [ ! -d "/app/miniconda3" ]; then - echo "Initializing workspace in /app..." - cp -r /workspace/* /app -fi +if [ "$1" = "--server" ]; then + # Handle workspace mounting + if [ -d "/app" ] && [ ! -d "/app/miniconda3" ]; then + echo "Initializing workspace in /app..." + cp -r /workspace/* /app + fi -if [ -d "/app" ] && [ ! -L "/workspace" ]; then - echo "Starting from volume mount /app..." - cd / && rm -rf /workspace - ln -sf /app /workspace - cd /workspace/comfystream + if [ -d "/app" ] && [ ! -L "/workspace" ]; then + echo "Starting from volume mount /app..." + cd / && rm -rf /workspace + ln -sf /app /workspace + cd /workspace/comfystream + fi fi # Add help command to show usage diff --git a/nodes/api/__init__.py b/nodes/api/__init__.py index 85c565e9..22e017c1 100644 --- a/nodes/api/__init__.py +++ b/nodes/api/__init__.py @@ -8,6 +8,7 @@ import aiohttp from ..server_manager import LocalComfyStreamServer from .. import settings_storage +import subprocess routes = None server_manager = None @@ -215,3 +216,45 @@ async def manage_configuration(request): logging.error(f"Error managing configuration: {str(e)}") return web.json_response({"error": str(e)}, status=500) + @routes.post('/comfystream/settings/manage') + async def manage_comfystream(request): + """Manage ComfyStream server settings""" + #check if server is running + server_status = server_manager.get_status() + if not server_status["running"]: + return web.json_response({"error": "ComfyStream Server is not running"}, status=503) + + try: + data = await request.json() + action_type = data.get("action_type") + action = data.get("action") + payload = data.get("payload") + url_host = server_status.get("host", "localhost") + url_port = server_status.get("port", "8889") + mgmt_url = f"http://{url_host}:{url_port}/settings/{action_type}/{action}" + + async with aiohttp.ClientSession() as session: + async with session.post( + mgmt_url, + json=payload, + headers={"Content-Type": "application/json"} + ) as response: + if not response.ok: + return web.json_response( + {"error": f"Server error: {response.status}"}, + status=response.status + ) + return web.json_response(await response.json()) + except Exception as e: + logging.error(f"Error managing ComfyStream: {str(e)}") + return web.json_response({"error": str(e)}, status=500) + + @routes.post('/comfyui/restart') + async def manage_configuration(request): + server_status = server_manager.get_status() + if server_status["running"]: + await server_manager.stop() + logging.info("Restarting ComfyUI...") + subprocess.run(["supervisorctl", "restart", "comfyui"]) + logging.info("Restarting ComfyUI...in process") + return web.json_response({"success": True}, status=200) diff --git a/nodes/web/js/launcher.js b/nodes/web/js/launcher.js index 7caf6e4f..a2a5e58a 100644 --- a/nodes/web/js/launcher.js +++ b/nodes/web/js/launcher.js @@ -98,6 +98,38 @@ document.addEventListener('comfy-extension-registered', (event) => { } }); +async function restartComfyUI() { + try { + const response = await fetch('/comfyui/restart', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: "" // No body needed + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error("[ComfyStream] ComfyUI restart returned error response:", response.status, errorText); + try { + const errorData = JSON.parse(errorText); + throw new Error(errorData.error || `Server error: ${response.status}`); + } catch (e) { + throw new Error(`Server error: ${response.status} - ${errorText}`); + } + } + + const data = await response.json(); + + return data; + } catch (error) { + console.error('[ComfyStream] Error restarting ComfyUI:', error); + app.ui.dialog.show('Error', error.message || 'Failed to restart ComfyUI'); + throw error; + } +} + async function controlServer(action) { try { // Get settings from the settings manager @@ -256,6 +288,12 @@ const extension = { icon: "pi pi-cog", label: "Server Settings", function: openSettings + }, + { + id: "ComfyStream.RestartComfyUI", + icon: "pi pi-refresh", + label: "Restart ComfyUI", + function: restartComfyUI } ], @@ -270,7 +308,9 @@ const extension = { "ComfyStream.StopServer", "ComfyStream.RestartServer", null, // Separator - "ComfyStream.Settings" + "ComfyStream.Settings", + null, // Separator + "ComfyStream.RestartComfyUI" ] } ], @@ -300,6 +340,8 @@ const extension = { comfyStreamMenu.addItem("Restart Server", () => controlServer('restart'), { icon: "pi pi-refresh" }); comfyStreamMenu.addSeparator(); comfyStreamMenu.addItem("Server Settings", openSettings, { icon: "pi pi-cog" }); + comfyStreamMenu.addSeparator(); + comfyStreamMenu.addItem("Restart ComfyUI", () => restartComfyUI(), { icon: "pi pi-refresh" }); } // New menu system is handled automatically by the menuCommands registration diff --git a/nodes/web/js/settings.js b/nodes/web/js/settings.js index 888917e8..34ae539e 100644 --- a/nodes/web/js/settings.js +++ b/nodes/web/js/settings.js @@ -1,5 +1,6 @@ // ComfyStream Settings Manager console.log("[ComfyStream Settings] Initializing settings module"); +const app = window.comfyAPI?.app?.app; const DEFAULT_SETTINGS = { host: "0.0.0.0", @@ -12,6 +13,7 @@ class ComfyStreamSettings { constructor() { this.settings = DEFAULT_SETTINGS; this.loadSettings(); + } async loadSettings() { @@ -235,6 +237,32 @@ class ComfyStreamSettings { } return null; } + + async manageComfystream(action_type, action, data) { + try { + const response = await fetch('/comfystream/settings/manage', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + action_type: action_type, + action: action, + payload: data + }) + }); + + const result = await response.json(); + + if (response.ok) { + return result; + } else { + throw new Error(`${result.error}`); + } + } catch (error) { + throw error; + } + } } // Create a single instance of the settings manager @@ -257,7 +285,7 @@ async function showSettingsModal() { const style = document.createElement("style"); style.id = styleId; style.textContent = ` - #comfystream-settings-modal { + .comfystream-settings-modal { position: fixed; z-index: 10000; left: 0; @@ -506,6 +534,73 @@ async function showSettingsModal() { 25% { transform: translateX(-5px); } 75% { transform: translateX(5px); } } + + .cs-help-text { + display: none; + width: 400px; + padding-left: 80px; + padding-bottom: 10px; + padding-top: 0px; + margin-top: 0px; + font-size: 0.75em; + overflow-wrap: break-word; + font-style: italic; + } + + .loader { + width: 20px; + height: 20px; + border-radius: 50%; + display: inline-block; + position: relative; + border: 2px solid; + border-color: #FFF #FFF transparent transparent; + box-sizing: border-box; + animation: rotation 1s linear infinite; + } + .loader::after, + .loader::before { + content: ''; + box-sizing: border-box; + position: absolute; + left: 0; + right: 0; + top: 0; + bottom: 0; + margin: auto; + border: 2px solid; + border-color: transparent transparent #FF3D00 #FF3D00; + width: 16px; + height: 16px; + border-radius: 50%; + box-sizing: border-box; + animation: rotationBack 0.5s linear infinite; + transform-origin: center center; + } + .loader::before { + width: 13px; + height: 13px; + border-color: #FFF #FFF transparent transparent; + animation: rotation 1.5s linear infinite; + } + + @keyframes rotation { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } + @keyframes rotationBack { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(-360deg); + } + } + `; document.head.appendChild(style); } @@ -513,6 +608,7 @@ async function showSettingsModal() { // Create modal container const modal = document.createElement("div"); modal.id = "comfystream-settings-modal"; + modal.className = "comfystream-settings-modal"; // Create modal content const modalContent = document.createElement("div"); @@ -586,6 +682,90 @@ async function showSettingsModal() { portGroup.appendChild(portLabel); portGroup.appendChild(portInput); + // Comfystream mgmt api actions + // Nodes management group + const nodesGroup = document.createElement("div"); + nodesGroup.className = "cs-input-group"; + + const nodesLabel = document.createElement("label"); + nodesLabel.textContent = "Nodes:"; + nodesLabel.className = "cs-label"; + + const installNodeButton = document.createElement("button"); + installNodeButton.textContent = "Install"; + installNodeButton.className = "cs-button"; + + const updateNodeButton = document.createElement("button"); + updateNodeButton.textContent = "Update"; + updateNodeButton.className = "cs-button"; + + const deleteNodeButton = document.createElement("button"); + deleteNodeButton.textContent = "Delete"; + deleteNodeButton.className = "cs-button"; + + const toggleNodeButton = document.createElement("button"); + toggleNodeButton.textContent = "Enable/Disable"; + toggleNodeButton.className = "cs-button"; + + const loadingNodes = document.createElement("span"); + loadingNodes.id = "comfystream-loading-nodes-spinner"; + loadingNodes.className = "loader"; + loadingNodes.style.display = "none"; // Initially hidden + + nodesGroup.appendChild(nodesLabel); + nodesGroup.appendChild(installNodeButton); + nodesGroup.appendChild(updateNodeButton); + nodesGroup.appendChild(deleteNodeButton); + nodesGroup.appendChild(toggleNodeButton); + nodesGroup.appendChild(loadingNodes); + + // Models management group + const modelsGroup = document.createElement("div"); + modelsGroup.className = "cs-input-group"; + + const modelsLabel = document.createElement("label"); + modelsLabel.textContent = "Models:"; + modelsLabel.className = "cs-label"; + + const addModelButton = document.createElement("button"); + addModelButton.textContent = "Add"; + addModelButton.className = "cs-button"; + + const deleteModelButton = document.createElement("button"); + deleteModelButton.textContent = "Delete"; + deleteModelButton.className = "cs-button"; + + const loadingModels = document.createElement("span"); + loadingModels.id = "comfystream-loading-models-spinner"; + loadingModels.className = "loader"; + loadingModels.style.display = "none"; // Initially hidden + + modelsGroup.appendChild(modelsLabel); + modelsGroup.appendChild(addModelButton); + modelsGroup.appendChild(deleteModelButton); + modelsGroup.appendChild(loadingModels); + + // turn server creds group + const turnServerCredsGroup = document.createElement("div"); + turnServerCredsGroup.className = "cs-input-group"; + + const turnServerCredsLabel = document.createElement("label"); + turnServerCredsLabel.textContent = "TURN Creds:"; + turnServerCredsLabel.className = "cs-label"; + + const setButton = document.createElement("button"); + setButton.textContent = "Set"; + setButton.className = "cs-button"; + + const turnServerCredsLoading = document.createElement("span"); + turnServerCredsLoading.id = "comfystream-loading-turn-server-creds-spinner"; + turnServerCredsLoading.className = "loader"; + turnServerCredsLoading.style.display = "none"; // Initially hidden + + turnServerCredsGroup.appendChild(turnServerCredsLabel); + turnServerCredsGroup.appendChild(setButton); + turnServerCredsGroup.appendChild(turnServerCredsLoading); + // Configurations section const configsSection = document.createElement("div"); configsSection.className = "cs-section"; @@ -659,6 +839,10 @@ async function showSettingsModal() { modal.remove(); }; + const msgTxt = document.createElement("div"); + msgTxt.id = "comfystream-msg-txt"; + msgTxt.className = "cs-msg-text"; + footer.appendChild(cancelButton); footer.appendChild(saveButton); @@ -666,18 +850,107 @@ async function showSettingsModal() { form.appendChild(currentConfigDiv); form.appendChild(hostGroup); form.appendChild(portGroup); + form.appendChild(nodesGroup); + form.appendChild(modelsGroup); + form.appendChild(turnServerCredsGroup); form.appendChild(configsSection); modalContent.appendChild(closeButton); modalContent.appendChild(title); modalContent.appendChild(form); modalContent.appendChild(footer); + modalContent.appendChild(msgTxt); modal.appendChild(modalContent); // Add to document document.body.appendChild(modal); + async function manageNodes(action) { + //show the spinner to provide feedback + const loadingSpinner = document.getElementById("comfystream-loading-nodes-spinner"); + loadingSpinner.style.display = "inline-block"; + + try { + if (action === "install") { + await showInstallNodesModal(); + } else if (action === "update") { + await showUpdateNodesModal(); + } else if (action === "delete") { + await showDeleteNodesModal(); + } else if (action === "toggle") { + await showToggleNodesModal(); + } + + // Hide the spinner after action + loadingSpinner.style.display = "none"; + } catch (error) { + console.error("[ComfyStream] Error installing node:", error); + app.ui.dialog.show('Error', `Failed to install node: ${error.message}`); + } + } + async function manageModels(action) { + //show the spinner to provide feedback + const loadingSpinner = document.getElementById("comfystream-loading-models-spinner"); + loadingSpinner.style.display = "inline-block"; + + try { + if (action === "add") { + await showAddModelsModal(); + } else if (action === "delete") { + await showDeleteModelsModal(); + } + // Hide the spinner after action + loadingSpinner.style.display = "none"; + } catch (error) { + console.error("[ComfyStream] Error managing models:", error); + app.ui.dialog.show('Error', `Failed to manage models: ${error.message}`); + } + } + async function manageTurnServerCredentials(action) { + //show the spinner to provide feedback + const loadingSpinner = document.getElementById("comfystream-loading-turn-server-creds-spinner"); + loadingSpinner.style.display = "inline-block"; + + try { + if (action === "set") { + await showSetTurnServerCredsModal(); + } else if (action === "clear") { + await showClearTurnServerCredsModal(); + } + // Hide the spinner after action + loadingSpinner.style.display = "none"; + } catch (error) { + console.error("[ComfyStream] Error managing TURN server credentials:", error); + app.ui.dialog.show('Error', `Failed to manage TURN server credentials: ${error.message}`); + } + + // Hide the spinner after action + loadingSpinner.style.display = "none"; + } + // Add event listeners for nodes management buttons + installNodeButton.addEventListener("click", () => { + manageNodes("install"); + }); + updateNodeButton.addEventListener("click", () => { + manageNodes("update"); + }); + deleteNodeButton.addEventListener("click", () => { + manageNodes("delete"); + }); + toggleNodeButton.addEventListener("click", () => { + manageNodes("toggle"); + }); + // Add event listeners for models management buttons + addModelButton.addEventListener("click", () => { + manageModels("add"); + }); + deleteModelButton.addEventListener("click", () => { + manageModels("delete"); + }); + setButton.addEventListener("click", async () => { + await showSetTurnServerCredsModal(); + }); // Update configurations list async function updateConfigsList() { configsList.innerHTML = ""; @@ -850,11 +1123,1124 @@ async function showSettingsModal() { hostInput.focus(); } +async function showSetTurnServerCredsModal() { + // Check if modal already exists and remove it + const existingModal = document.getElementById("comfystream-settings-set-turn-creds-modal"); + if (existingModal) { + existingModal.remove(); + } + + // Create nodes mgmt modal container + const modal = document.createElement("div"); + modal.id = "comfystream-settings-set-turn-creds-modal"; + modal.className = "comfystream-settings-modal"; + + // Create close button + const closeButton = document.createElement("button"); + closeButton.textContent = "×"; + closeButton.className = "cs-close-button"; + closeButton.onclick = () => { + modal.remove(); + }; + + // Create modal content + const modalContent = document.createElement("div"); + modalContent.className = "cs-modal-content"; + + // Create title + const title = document.createElement("h3"); + title.textContent = "Set TURN Server Credentials"; + title.className = "cs-title"; + + // Create settings form + const form = document.createElement("div"); + + // account type + const accountTypeGroup = document.createElement("div"); + accountTypeGroup.className = "cs-input-group"; + + const accountTypeLabel = document.createElement("label"); + accountTypeLabel.textContent = "Account Type:"; + accountTypeLabel.className = "cs-label"; + + const accountTypeSelect = document.createElement("select"); + const accountItem = document.createElement("option"); + accountItem.value = "twilio"; + accountItem.textContent = "Twilio"; + accountTypeSelect.appendChild(accountItem); + accountTypeSelect.id = "comfystream-selected-turn-server-account-type"; + + const accountTypeHelpIcon = document.createElement("span"); + accountTypeHelpIcon.innerHTML = "🛈"; // Unicode for a help icon (ℹ️) + accountTypeHelpIcon.style.cursor = "pointer"; + accountTypeHelpIcon.style.marginLeft = "5px"; + accountTypeHelpIcon.title = "Click for help"; + + const accountTypeHelp = document.createElement("div"); + accountTypeHelp.textContent = "Specify the account type to use"; + accountTypeHelp.className = "cs-help-text"; + accountTypeHelp.style.display = "none"; + + accountTypeHelpIcon.addEventListener("click", () => { + if (accountTypeHelp.style.display == "none") { + accountTypeHelp.style.display = "block"; + } else { + accountTypeHelp.style.display = "none"; + } + }); + + accountTypeGroup.appendChild(accountTypeLabel); + accountTypeGroup.appendChild(accountTypeSelect); + accountTypeGroup.appendChild(accountTypeHelpIcon); + + // account id + const accountIdGroup = document.createElement("div"); + accountIdGroup.className = "cs-input-group"; + + const accountIdLabel = document.createElement("label"); + accountIdLabel.textContent = "Account ID:"; + accountIdLabel.className = "cs-label"; + + const accountIdInput = document.createElement("input"); + accountIdInput.id = "turn-server-creds-account-id"; + accountIdInput.className = "cs-input"; + + + const accountIdHelpIcon = document.createElement("span"); + accountIdHelpIcon.innerHTML = "🛈"; // Unicode for a help icon (ℹ️) + accountIdHelpIcon.style.cursor = "pointer"; + accountIdHelpIcon.style.marginLeft = "5px"; + accountIdHelpIcon.title = "Click for help"; + + const accountIdHelp = document.createElement("div"); + accountIdHelp.textContent = "Specify the account id for Twilio TURN server credentials"; + accountIdHelp.className = "cs-help-text"; + accountIdHelp.style.display = "none"; + + accountIdHelpIcon.addEventListener("click", () => { + if (accountIdHelp.style.display == "none") { + accountIdHelp.style.display = "block"; + } else { + accountIdHelp.style.display = "none"; + } + }); + + accountIdGroup.appendChild(accountIdLabel); + accountIdGroup.appendChild(accountIdInput); + accountIdGroup.appendChild(accountIdHelpIcon); + + // auth token + const accountAuthTokenGroup = document.createElement("div"); + accountAuthTokenGroup.className = "cs-input-group"; + + const accountAuthTokenLabel = document.createElement("label"); + accountAuthTokenLabel.textContent = "Auth Token:"; + accountAuthTokenLabel.className = "cs-label"; + + const accountAuthTokenInput = document.createElement("input"); + accountAuthTokenInput.id = "turn-server-creds-auth-token"; + accountAuthTokenInput.className = "cs-input"; + + const accountAuthTokenHelpIcon = document.createElement("span"); + accountAuthTokenHelpIcon.innerHTML = "🛈"; // Unicode for a help icon (ℹ️) + accountAuthTokenHelpIcon.style.cursor = "pointer"; + accountAuthTokenHelpIcon.style.marginLeft = "5px"; + accountAuthTokenHelpIcon.title = "Click for help"; + accountAuthTokenHelpIcon.style.position = "relative"; + + const accountAuthTokenHelp = document.createElement("div"); + accountAuthTokenHelp.textContent = "Specify the auth token provided by Twilio for TURN server credentials"; + accountAuthTokenHelp.className = "cs-help-text"; + accountAuthTokenHelp.style.display = "none"; + + accountAuthTokenHelpIcon.addEventListener("click", () => { + if (accountAuthTokenHelp.style.display == "none") { + accountAuthTokenHelp.style.display = "block"; + } else { + accountAuthTokenHelp.style.display = "none"; + } + }); + + accountAuthTokenGroup.appendChild(accountAuthTokenLabel); + accountAuthTokenGroup.appendChild(accountAuthTokenInput); + accountAuthTokenGroup.appendChild(accountAuthTokenHelpIcon); + + // Footer with buttons + const footer = document.createElement("div"); + footer.className = "cs-footer"; + + const msgTxt = document.createElement("div"); + msgTxt.id = "comfystream-manage-turn-server-creds-msg-txt"; + msgTxt.style.fontSize = "0.75em"; + msgTxt.style.fontStyle = "italic"; + msgTxt.style.overflowWrap = "break-word"; + + const cancelButton = document.createElement("button"); + cancelButton.textContent = "Cancel"; + cancelButton.className = "cs-button"; + cancelButton.onclick = () => { + modal.remove(); + }; + const clearButton = document.createElement("button"); + clearButton.textContent = "Clear"; + clearButton.className = "cs-button"; + clearButton.onclick = () => { + const accountType = accountTypeSelect.options[accountTypeSelect.selectedIndex].value; + msgTxt.textContent = setTurnSeverCreds(accountType, "", ""); + }; + + const setButton = document.createElement("button"); + setButton.textContent = "Set"; + setButton.className = "cs-button primary"; + setButton.onclick = async () => { + const accountId = accountIdInput.value; + const authToken = accountAuthTokenInput.value; + const accountType = accountTypeSelect.options[accountTypeSelect.selectedIndex].value; + msgTxt.textContent = setTurnSeverCreds(accountType, accountId, authToken); + }; + + footer.appendChild(msgTxt); + footer.appendChild(cancelButton); + footer.appendChild(setButton); + + // Assemble the modal + form.appendChild(accountTypeGroup); + form.appendChild(accountTypeHelp); + form.appendChild(accountIdGroup); + form.appendChild(accountIdHelp); + form.appendChild(accountAuthTokenGroup); + form.appendChild(accountAuthTokenHelp); + + modalContent.appendChild(closeButton); + modalContent.appendChild(title); + modalContent.appendChild(form); + modalContent.appendChild(footer); + + modal.appendChild(modalContent); + + // Add to document + document.body.appendChild(modal); +} + +async function setTurnSeverCreds(accountType, accountId, authToken) { + try { + const payload = { + type: accountType, + account_id: accountId, + auth_token: authToken + } + + await settingsManager.manageComfystream( + "turn/server", + "set/account", + [payload] + ); + return "TURN server credentials updated successfully"; + } catch (error) { + console.error("[ComfyStream] Error adding model:", error); + msgTxt.textContent = error; + } +} + +async function showAddModelsModal() { + // Check if modal already exists and remove it + const existingModal = document.getElementById("comfystream-settings-add-model-modal"); + if (existingModal) { + existingModal.remove(); + } + + // Create nodes mgmt modal container + const modal = document.createElement("div"); + modal.id = "comfystream-settings-add-model-modal"; + modal.className = "comfystream-settings-modal"; + + // Create close button + const closeButton = document.createElement("button"); + closeButton.textContent = "×"; + closeButton.className = "cs-close-button"; + closeButton.onclick = () => { + modal.remove(); + }; + + // Create modal content + const modalContent = document.createElement("div"); + modalContent.className = "cs-modal-content"; + + // Create title + const title = document.createElement("h3"); + title.textContent = "Add Model"; + title.className = "cs-title"; + + // Create settings form + const form = document.createElement("div"); + + // URL of node to add + const modelUrlGroup = document.createElement("div"); + modelUrlGroup.className = "cs-input-group"; + + const modelUrlLabel = document.createElement("label"); + modelUrlLabel.textContent = "Url:"; + modelUrlLabel.className = "cs-label"; + + const modelUrlInput = document.createElement("input"); + modelUrlInput.id = "add-model-url"; + modelUrlInput.className = "cs-input"; + + + const modelUrlHelpIcon = document.createElement("span"); + modelUrlHelpIcon.innerHTML = "🛈"; // Unicode for a help icon (ℹ️) + modelUrlHelpIcon.style.cursor = "pointer"; + modelUrlHelpIcon.style.marginLeft = "5px"; + modelUrlHelpIcon.title = "Click for help"; + + const modelUrlHelp = document.createElement("div"); + modelUrlHelp.textContent = "Specify the url of the model download url"; + modelUrlHelp.className = "cs-help-text"; + modelUrlHelp.style.display = "none"; + + modelUrlHelpIcon.addEventListener("click", () => { + if (modelUrlHelp.style.display == "none") { + modelUrlHelp.style.display = "block"; + } else { + modelUrlHelp.style.display = "none"; + } + }); + + modelUrlGroup.appendChild(modelUrlLabel); + modelUrlGroup.appendChild(modelUrlInput); + modelUrlGroup.appendChild(modelUrlHelpIcon); + + // branch of node to add + const modelTypeGroup = document.createElement("div"); + modelTypeGroup.className = "cs-input-group"; + + const modelTypeLabel = document.createElement("label"); + modelTypeLabel.textContent = "Type:"; + modelTypeLabel.className = "cs-label"; + + const modelTypeInput = document.createElement("input"); + modelTypeInput.id = "add-node-branch"; + modelTypeInput.className = "cs-input"; + + const modelTypeHelpIcon = document.createElement("span"); + modelTypeHelpIcon.innerHTML = "🛈"; // Unicode for a help icon (ℹ️) + modelTypeHelpIcon.style.cursor = "pointer"; + modelTypeHelpIcon.style.marginLeft = "5px"; + modelTypeHelpIcon.title = "Click for help"; + modelTypeHelpIcon.style.position = "relative"; + + const modelTypeHelp = document.createElement("div"); + modelTypeHelp.textContent = "Specify the type of model that is the top level folder under 'models' folder (e.g. 'checkpoints' = models/checkpoints)"; + modelTypeHelp.className = "cs-help-text"; + modelTypeHelp.style.display = "none"; + + modelTypeHelpIcon.addEventListener("click", () => { + if (modelTypeHelp.style.display == "none") { + modelTypeHelp.style.display = "block"; + } else { + modelTypeHelp.style.display = "none"; + } + }); + + modelTypeGroup.appendChild(modelTypeLabel); + modelTypeGroup.appendChild(modelTypeInput); + modelTypeGroup.appendChild(modelTypeHelpIcon); + + + // dependencies of node to add + const modelPathGroup = document.createElement("div"); + modelPathGroup.className = "cs-input-group"; + + const modelPathLabel = document.createElement("label"); + modelPathLabel.textContent = "Path:"; + modelPathLabel.className = "cs-label"; + + const modelPathInput = document.createElement("input"); + modelPathInput.id = "add-model-path"; + modelPathInput.className = "cs-input"; + + const modelPathHelpIcon = document.createElement("span"); + modelPathHelpIcon.innerHTML = "🛈"; // Unicode for a help icon (ℹ️) + modelPathHelpIcon.style.cursor = "pointer"; + modelPathHelpIcon.style.marginLeft = "5px"; + modelPathHelpIcon.title = "Click for help"; + + const modelPathHelp = document.createElement("div"); + modelPathHelp.textContent = "Input the path of the model file (including file name, 'SD1.5/model.safetensors' = checkpoints/SD1.5/model.safetensors)"; + modelPathHelp.className = "cs-help-text"; + modelPathHelp.style.display = "none"; + + modelPathHelpIcon.addEventListener("click", () => { + if (modelPathHelp.style.display == "none") { + modelPathHelp.style.display = "block"; + } else { + modelPathHelp.style.display = "none"; + } + }); + + modelPathGroup.appendChild(modelPathLabel); + modelPathGroup.appendChild(modelPathInput); + modelPathGroup.appendChild(modelPathHelpIcon); + + // Footer with buttons + const footer = document.createElement("div"); + footer.className = "cs-footer"; + + const msgTxt = document.createElement("div"); + msgTxt.id = "comfystream-manage-models-add-msg-txt"; + msgTxt.style.fontSize = "0.75em"; + msgTxt.style.fontStyle = "italic"; + msgTxt.style.overflowWrap = "break-word"; + + const cancelButton = document.createElement("button"); + cancelButton.textContent = "Cancel"; + cancelButton.className = "cs-button"; + cancelButton.onclick = () => { + modal.remove(); + }; + + const addButton = document.createElement("button"); + addButton.textContent = "Add"; + addButton.className = "cs-button primary"; + addButton.onclick = async () => { + const modelUrl = modelUrlInput.value; + const modelType = modelTypeInput.value; + const modelPath = modelPathInput.value; + const payload = { + url: modelUrl, + type: modelType, + path: modelPath + }; + + try { + await settingsManager.manageComfystream( + "models", + "add", + [payload] + ); + msgTxt.textContent = "Model added successfully"; + } catch (error) { + console.error("[ComfyStream] Error adding model:", error); + msgTxt.textContent = error; + } + }; + + footer.appendChild(msgTxt); + footer.appendChild(cancelButton); + footer.appendChild(addButton); + + // Assemble the modal + form.appendChild(modelUrlGroup); + form.appendChild(modelUrlHelp); + form.appendChild(modelTypeGroup); + form.appendChild(modelTypeHelp); + form.appendChild(modelPathGroup); + form.appendChild(modelPathHelp); + + modalContent.appendChild(closeButton); + modalContent.appendChild(title); + modalContent.appendChild(form); + modalContent.appendChild(footer); + + modal.appendChild(modalContent); + + // Add to document + document.body.appendChild(modal); +} + +async function showDeleteModelsModal() { + // Check if modal already exists and remove it + const existingModal = document.getElementById("comfystream-settings-delete-model-modal"); + if (existingModal) { + existingModal.remove(); + } + + // Create nodes mgmt modal container + const modal = document.createElement("div"); + modal.id = "comfystream-settings-delete-model-modal"; + modal.className = "comfystream-settings-modal"; + + // Create close button + const closeButton = document.createElement("button"); + closeButton.textContent = "×"; + closeButton.className = "cs-close-button"; + closeButton.onclick = () => { + modal.remove(); + }; + + // Create modal content + const modalContent = document.createElement("div"); + modalContent.className = "cs-modal-content"; + + // Create title + const title = document.createElement("h3"); + title.textContent = "Delete Model"; + title.className = "cs-title"; + + // Create settings form + const form = document.createElement("div"); + + const msgTxt = document.createElement("div"); + msgTxt.id = "comfystream-manage-models-delete-msg-txt"; + msgTxt.style.fontSize = "0.75em"; + msgTxt.style.fontStyle = "italic"; + msgTxt.style.overflowWrap = "break-word"; + + //Get the nodes + const modelSelect = document.createElement("select"); + modelSelect.id = "comfystream-selected-model"; + try { + const models = await settingsManager.manageComfystream( + "models", + "list", + "" + ); + for (const model_type in models.models) { + for (const model of models.models[model_type]) { + const modelItem = document.createElement("option"); + modelItem.setAttribute("model-type", model.type); + modelItem.value = model.path; + modelItem.textContent = model.type + " | " + model.path; + modelSelect.appendChild(modelItem); + } + } + } catch (error) { + msgTxt.textContent = error; + } + + // Footer with buttons + const footer = document.createElement("div"); + footer.className = "cs-footer"; + + + const cancelButton = document.createElement("button"); + cancelButton.textContent = "Cancel"; + cancelButton.className = "cs-button"; + cancelButton.onclick = () => { + modal.remove(); + }; + + const deleteButton = document.createElement("button"); + deleteButton.textContent = "Delete"; + deleteButton.className = "cs-button primary"; + deleteButton.onclick = async () => { + const modelPath = modelSelect.options[modelSelect.selectedIndex].value; + const modelType = modelSelect.options[modelSelect.selectedIndex].getAttribute("model-type"); + const payload = { + type: modelType, + path: modelPath + }; + + try { + await settingsManager.manageComfystream( + "models", + "delete", + [payload] + ); + msgTxt.textContent = "Model deleted successfully"; + } catch (error) { + console.error("[ComfyStream] Error deleting model:", error); + msgTxt.textContent = error; + } + + + }; + + footer.appendChild(msgTxt); + footer.appendChild(cancelButton); + footer.appendChild(deleteButton); + + // Assemble the modal + form.appendChild(modelSelect); + + modalContent.appendChild(closeButton); + modalContent.appendChild(title); + modalContent.appendChild(form); + modalContent.appendChild(footer); + + modal.appendChild(modalContent); + + // Add to document + document.body.appendChild(modal); +} + +async function showToggleNodesModal() { + // Check if modal already exists and remove it + const existingModal = document.getElementById("comfystream-settings-toggle-nodes-modal"); + if (existingModal) { + existingModal.remove(); + } + + // Create nodes mgmt modal container + const modal = document.createElement("div"); + modal.id = "comfystream-settings-toggle-nodes-modal"; + modal.className = "comfystream-settings-modal"; + + // Create close button + const closeButton = document.createElement("button"); + closeButton.textContent = "×"; + closeButton.className = "cs-close-button"; + closeButton.onclick = () => { + modal.remove(); + }; + + // Create modal content + const modalContent = document.createElement("div"); + modalContent.className = "cs-modal-content"; + + // Create title + const title = document.createElement("h3"); + title.textContent = "Enable/Disable Custom Nodes"; + title.className = "cs-title"; + + // Create settings form + const form = document.createElement("div"); + const toggleNodesModalContent = document.createElement("div"); + toggleNodesModalContent.className = "cs-modal-content"; + + const msgTxt = document.createElement("div"); + msgTxt.id = "comfystream-manage-nodes-toggle-msg-txt"; + msgTxt.style.fontSize = "0.75em"; + msgTxt.style.fontStyle = "italic"; + msgTxt.style.overflowWrap = "break-word"; + + //Get the nodes + const nodeSelect = document.createElement("select"); + let initialAction = "Enable"; + nodeSelect.id = "comfystream-selected-node"; + try { + const nodes = await settingsManager.manageComfystream( + "nodes", + "list", + "" + ); + for (const node of nodes.nodes) { + const nodeItem = document.createElement("option"); + nodeItem.value = node.name; + nodeItem.textContent = node.name; + nodeItem.setAttribute("node-is-disabled", node.disabled); + if (!node.disabled) { + initialAction = "Disable"; + } + nodeSelect.appendChild(nodeItem); + } + } catch (error) { + msgTxt.textContent = error; + } + + // Footer with buttons + const footer = document.createElement("div"); + footer.className = "cs-footer"; + + + const cancelButton = document.createElement("button"); + cancelButton.textContent = "Cancel"; + cancelButton.className = "cs-button"; + cancelButton.onclick = () => { + modal.remove(); + }; + + const toggleButton = document.createElement("button"); + toggleButton.textContent = initialAction; + toggleButton.className = "cs-button primary"; + toggleButton.onclick = async () => { + const nodeName = nodeSelect.options[nodeSelect.selectedIndex].value; + const payload = { + name: nodeName, + }; + const action = toggleButton.textContent === "Enable" ? "enable" : "disable"; + + try { + await settingsManager.manageComfystream( + "nodes", + "toggle", + [payload] + ); + msgTxt.textContent = `Node ${action} successfully`; + } catch (error) { + + } + + + }; + + //update the action based on if the node is disabled or not currently + nodeSelect.onchange = () => { + const selectedNode = nodeSelect.options[nodeSelect.selectedIndex]; + const isDisabled = selectedNode.getAttribute("node-is-disabled") === "true"; + if (isDisabled) { + toggleButton.textContent = "Enable"; + } else { + toggleButton.textContent = "Disable"; + } + }; + + footer.appendChild(msgTxt); + footer.appendChild(cancelButton); + footer.appendChild(toggleButton); + + // Assemble the modal + form.appendChild(nodeSelect); + + modalContent.appendChild(closeButton); + modalContent.appendChild(title); + modalContent.appendChild(form); + modalContent.appendChild(footer); + + modal.appendChild(modalContent); + + // Add to document + document.body.appendChild(modal); +} + +async function showDeleteNodesModal() { + // Check if modal already exists and remove it + const existingModal = document.getElementById("comfystream-settings-delete-nodes-modal"); + if (existingModal) { + existingModal.remove(); + } + + // Create nodes mgmt modal container + const modal = document.createElement("div"); + modal.id = "comfystream-settings-delete-nodes-modal"; + modal.className = "comfystream-settings-modal"; + + // Create close button + const closeButton = document.createElement("button"); + closeButton.textContent = "×"; + closeButton.className = "cs-close-button"; + closeButton.onclick = () => { + modal.remove(); + }; + + // Create modal content + const modalContent = document.createElement("div"); + modalContent.className = "cs-modal-content"; + + // Create title + const title = document.createElement("h3"); + title.textContent = "Delete Custom Nodes"; + title.className = "cs-title"; + + // Create settings form + const form = document.createElement("div"); + + const msgTxt = document.createElement("div"); + msgTxt.id = "comfystream-manage-nodes-delete-msg-txt"; + msgTxt.style.fontSize = "0.75em"; + msgTxt.style.fontStyle = "italic"; + msgTxt.style.overflowWrap = "break-word"; + + //Get the nodes + const nodeSelect = document.createElement("select"); + nodeSelect.id = "comfystream-selected-node"; + try { + const nodes = await settingsManager.manageComfystream( + "nodes", + "list", + "" + ); + for (const node of nodes.nodes) { + const nodeItem = document.createElement("option"); + nodeItem.value = node.name; + nodeItem.textContent = node.name; + nodeSelect.appendChild(nodeItem); + } + } catch (error) { + msgTxt.textContent = error; + } + + // Footer with buttons + const footer = document.createElement("div"); + footer.className = "cs-footer"; + + + const cancelButton = document.createElement("button"); + cancelButton.textContent = "Cancel"; + cancelButton.className = "cs-button"; + cancelButton.onclick = () => { + modal.remove(); + }; + + const deleteButton = document.createElement("button"); + deleteButton.textContent = "Delete"; + deleteButton.className = "cs-button primary"; + deleteButton.onclick = async () => { + const nodeName = nodeSelect.options[nodeSelect.selectedIndex].value; + const payload = { + name: nodeName, + }; + + try { + await settingsManager.manageComfystream( + "nodes", + "delete", + [payload] + ); + msgTxt.textContent = "Node deleted successfully"; + } catch (error) { + console.error("[ComfyStream] Error deleting node:", error); + msgTxt.textContent = error; + } + + + }; + + footer.appendChild(msgTxt); + footer.appendChild(cancelButton); + footer.appendChild(deleteButton); + + // Assemble the modal + form.appendChild(nodeSelect); + + modalContent.appendChild(closeButton); + modalContent.appendChild(title); + modalContent.appendChild(form); + modalContent.appendChild(footer); + + modal.appendChild(modalContent); + + // Add to document + document.body.appendChild(modal); +} + +async function showUpdateNodesModal() { + // Check if modal already exists and remove it + const existingModal = document.getElementById("comfystream-settings-update-nodes-modal"); + if (existingModal) { + existingModal.remove(); + } + + // Create nodes mgmt modal container + const modal = document.createElement("div"); + modal.id = "comfystream-settings-update-nodes-modal"; + modal.className = "comfystream-settings-modal"; + + // Create close button + const closeButton = document.createElement("button"); + closeButton.textContent = "×"; + closeButton.className = "cs-close-button"; + closeButton.onclick = () => { + modal.remove(); + }; + + // Create modal content + const modalContent = document.createElement("div"); + modalContent.className = "cs-modal-content"; + + // Create title + const title = document.createElement("h3"); + title.textContent = "Update Custom Nodes"; + title.className = "cs-title"; + + // Create settings form + const form = document.createElement("div"); + + const msgTxt = document.createElement("div"); + msgTxt.id = "comfystream-manage-nodes-update-msg-txt"; + msgTxt.style.fontSize = "0.75em"; + msgTxt.style.fontStyle = "italic"; + msgTxt.style.overflowWrap = "break-word"; + + //Get the nodes + const nodeSelect = document.createElement("select"); + nodeSelect.id = "comfystream-selected-node"; + try { + const nodes = await settingsManager.manageComfystream( + "nodes", + "list", + "" + ); + let updateAvailable = false; + for (const node of nodes.nodes) { + if (node.update_available && node.update_available != "unknown" && node.url != "unknown") { + updateAvailable = true; + const nodeItem = document.createElement("option"); + nodeItem.value = node.url; + nodeItem.textContent = node.name; + nodeSelect.appendChild(nodeItem); + } + } + if (!updateAvailable) { + msgTxt.textContent = "No updates available for any nodes."; + } + } catch (error) { + msgTxt.textContent = error; + } + + // Footer with buttons + const footer = document.createElement("div"); + footer.className = "cs-footer"; + + + const cancelButton = document.createElement("button"); + cancelButton.textContent = "Cancel"; + cancelButton.className = "cs-button"; + cancelButton.onclick = () => { + modal.remove(); + }; + + const installButton = document.createElement("button"); + installButton.textContent = "Update"; + installButton.className = "cs-button primary"; + installButton.onclick = async () => { + const nodeUrl = nodeSelect.options[nodeSelect.selectedIndex].value; + const payload = { + url: nodeUrl, + }; + + try { + await settingsManager.manageComfystream( + "nodes", + "install", + [payload] + ); + msgTxt.textContent = "Node updated successfully"; + } catch (error) { + console.error("[ComfyStream] Error updating node:", error); + msgTxt.textContent = error; + } + + + }; + + footer.appendChild(msgTxt); + footer.appendChild(cancelButton); + footer.appendChild(installButton); + + // Assemble the modal + form.appendChild(nodeSelect); + + modalContent.appendChild(closeButton); + modalContent.appendChild(title); + modalContent.appendChild(form); + modalContent.appendChild(footer); + + modal.appendChild(modalContent); + + // Add to document + document.body.appendChild(modal); +} + +async function showInstallNodesModal() { + // Check if modal already exists and remove it + const existingModal = document.getElementById("comfystream-settings-add-nodes-modal"); + if (existingModal) { + existingModal.remove(); + } + + // Create nodes mgmt modal container + const modal = document.createElement("div"); + modal.id = "comfystream-settings-add-nodes-modal"; + modal.className = "comfystream-settings-modal"; + + // Create close button + const closeButton = document.createElement("button"); + closeButton.textContent = "×"; + closeButton.className = "cs-close-button"; + closeButton.onclick = () => { + modal.remove(); + }; + + // Create modal content + const modalContent = document.createElement("div"); + modalContent.className = "cs-modal-content"; + + // Create title + const title = document.createElement("h3"); + title.textContent = "Add Custom Nodes"; + title.className = "cs-title"; + + // Create settings form + const form = document.createElement("div"); + + // URL of node to add + const nodeUrlGroup = document.createElement("div"); + nodeUrlGroup.className = "cs-input-group"; + + const nodeUrlLabel = document.createElement("label"); + nodeUrlLabel.textContent = "Url:"; + nodeUrlLabel.className = "cs-label"; + + const nodeUrlInput = document.createElement("input"); + nodeUrlInput.id = "add-node-url"; + nodeUrlInput.className = "cs-input"; + + + const nodeUrlHelpIcon = document.createElement("span"); + nodeUrlHelpIcon.innerHTML = "🛈"; // Unicode for a help icon (ℹ️) + nodeUrlHelpIcon.style.cursor = "pointer"; + nodeUrlHelpIcon.style.marginLeft = "5px"; + nodeUrlHelpIcon.title = "Click for help"; + + const nodeUrlHelp = document.createElement("div"); + nodeUrlHelp.textContent = "Specify the url of the github repo for the custom node want to install (can have .git at end of url)"; + nodeUrlHelp.className = "cs-help-text"; + nodeUrlHelp.style.display = "none"; + + nodeUrlHelpIcon.addEventListener("click", () => { + if (nodeUrlHelp.style.display == "none") { + nodeUrlHelp.style.display = "block"; + } else { + nodeUrlHelp.style.display = "none"; + } + }); + + nodeUrlGroup.appendChild(nodeUrlLabel); + nodeUrlGroup.appendChild(nodeUrlInput); + nodeUrlGroup.appendChild(nodeUrlHelpIcon); + + // branch of node to add + const nodeBranchGroup = document.createElement("div"); + nodeBranchGroup.className = "cs-input-group"; + + const nodeBranchLabel = document.createElement("label"); + nodeBranchLabel.textContent = "Branch:"; + nodeBranchLabel.className = "cs-label"; + + const nodeBranchInput = document.createElement("input"); + nodeBranchInput.id = "add-node-branch"; + nodeBranchInput.className = "cs-input"; + + const nodeBranchHelpIcon = document.createElement("span"); + nodeBranchHelpIcon.innerHTML = "🛈"; // Unicode for a help icon (ℹ️) + nodeBranchHelpIcon.style.cursor = "pointer"; + nodeBranchHelpIcon.style.marginLeft = "5px"; + nodeBranchHelpIcon.title = "Click for help"; + nodeBranchHelpIcon.style.position = "relative"; + + const nodeBranchHelp = document.createElement("div"); + nodeBranchHelp.textContent = "Specify the branch of the node you want to add. For example, 'main' or 'develop'."; + nodeBranchHelp.className = "cs-help-text"; + nodeBranchHelp.style.display = "none"; + + nodeBranchHelpIcon.addEventListener("click", () => { + if (nodeBranchHelp.style.display == "none") { + nodeBranchHelp.style.display = "block"; + } else { + nodeBranchHelp.style.display = "none"; + } + }); + + nodeBranchGroup.appendChild(nodeBranchLabel); + nodeBranchGroup.appendChild(nodeBranchInput); + nodeBranchGroup.appendChild(nodeBranchHelpIcon); + + + // dependencies of node to add + const nodeDepsGroup = document.createElement("div"); + nodeDepsGroup.className = "cs-input-group"; + + const nodeDepsLabel = document.createElement("label"); + nodeDepsLabel.textContent = "Deps:"; + nodeDepsLabel.className = "cs-label"; + + const nodeDepsInput = document.createElement("input"); + nodeDepsInput.id = "add-node-deps"; + nodeDepsInput.className = "cs-input"; + + const nodeDepsHelpIcon = document.createElement("span"); + nodeDepsHelpIcon.innerHTML = "🛈"; // Unicode for a help icon (ℹ️) + nodeDepsHelpIcon.style.cursor = "pointer"; + nodeDepsHelpIcon.style.marginLeft = "5px"; + nodeDepsHelpIcon.title = "Click for help"; + + const nodeDepsHelp = document.createElement("div"); + nodeDepsHelp.textContent = "Comma separated list of python packages to install with pip (required packages outside requirements.txt)"; + nodeDepsHelp.className = "cs-help-text"; + nodeDepsHelp.style.display = "none"; + + nodeDepsHelpIcon.addEventListener("click", () => { + if (nodeDepsHelp.style.display == "none") { + nodeDepsHelp.style.display = "block"; + } else { + nodeDepsHelp.style.display = "none"; + } + }); + + nodeDepsGroup.appendChild(nodeDepsLabel); + nodeDepsGroup.appendChild(nodeDepsInput); + nodeDepsGroup.appendChild(nodeDepsHelpIcon); + + // Footer with buttons + const footer = document.createElement("div"); + footer.className = "cs-footer"; + + const msgTxt = document.createElement("div"); + msgTxt.id = "comfystream-manage-nodes-install-msg-txt"; + msgTxt.style.fontSize = "0.75em"; + msgTxt.style.fontStyle = "italic"; + msgTxt.style.overflowWrap = "break-word"; + + const cancelButton = document.createElement("button"); + cancelButton.textContent = "Cancel"; + cancelButton.className = "cs-button"; + cancelButton.onclick = () => { + modal.remove(); + }; + + const installButton = document.createElement("button"); + installButton.textContent = "Install"; + installButton.className = "cs-button primary"; + installButton.onclick = async () => { + const nodeUrl = nodeUrlInput.value; + const nodeBranch = nodeBranchInput.value; + const nodeDeps = nodeDepsInput.value; + const payload = { + url: nodeUrl, + branch: nodeBranch, + dependencies: nodeDeps + }; + + try { + await settingsManager.manageComfystream( + "nodes", + "install", + [payload] + ); + msgTxt.textContent = "Node installed successfully!"; + } catch (error) { + console.error("[ComfyStream] Error installing node:", error); + msgTxt.textContent = error; + } + + + }; + + footer.appendChild(msgTxt); + footer.appendChild(cancelButton); + footer.appendChild(installButton); + + // Assemble the modal + form.appendChild(nodeUrlGroup); + form.appendChild(nodeUrlHelp); + form.appendChild(nodeBranchGroup); + form.appendChild(nodeBranchHelp); + form.appendChild(nodeDepsGroup); + form.appendChild(nodeDepsHelp); + + modalContent.appendChild(closeButton); + modalContent.appendChild(title); + modalContent.appendChild(form); + modalContent.appendChild(footer); + + modal.appendChild(modalContent); + + // Add to document + document.body.appendChild(modal); +} // Export for use in other modules -export { settingsManager, showSettingsModal }; +export { settingsManager, showSettingsModal, showInstallNodesModal, showUpdateNodesModal, showDeleteNodesModal, showToggleNodesModal, showAddModelsModal, showDeleteModelsModal, showSetTurnServerCredsModal }; // Also keep the global for backward compatibility window.comfyStreamSettings = { settingsManager, - showSettingsModal + showSettingsModal, + showInstallNodesModal, + showUpdateNodesModal, + showDeleteNodesModal, + showToggleNodesModal, + showAddModelsModal, + showDeleteModelsModal, + showSetTurnServerCredsModal }; \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 4c748e99..3e8d67a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "comfystream" description = "Build Live AI Video with ComfyUI" -version = "0.0.5" +version = "0.1.0" license = { file = "LICENSE" } dependencies = [ "asyncio", @@ -15,7 +15,8 @@ dependencies = [ "toml", "twilio", "prometheus_client", - "librosa" + "librosa", + "GitPython" ] [project.optional-dependencies] diff --git a/requirements.txt b/requirements.txt index f94f3345..e708cd4e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ toml twilio prometheus_client librosa +GitPython \ No newline at end of file diff --git a/scripts/README.md b/scripts/README.md index 4653f04e..b065a7d4 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -3,114 +3,13 @@ This directory contains helper scripts to simplify the deployment and management of **ComfyStream**: - **Ansible Playbook (`ansible/plays/setup_comfystream.yml`)** – Deploys ComfyStream on any cloud provider. -- **`spinup_comfystream_tensordock.py`** – Fully automates VM creation and ComfyStream setup on a [TensorDock server](https://tensordock.com/). - `monitor_pid_resources.py`: Monitors and profiles the resource usage of a running ComfyStream server. ## Usage Instructions ### Ansible Playbook (Cloud-Agnostic Deployment) -This repository includes an [Ansible playbook](https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_intro.html) to deploy ComfyStream on any cloud provider. Follow the steps below: - -1. **Create a VM**: - Deploy a VM on a cloud provider such as [TensorDock](https://marketplace.tensordock.com/deploy?gpu=geforcertx4090-pcie-24gb&gpuCount=1&ramAmount=16&vcpuCount=4&storage=100&os=Ubuntu-22.04-LTS), AWS, Google Cloud, or Azure with the following minimum specifications: - - **GPU**: 20GB VRAM - - **RAM**: 16GB - - **CPU**: 4 vCPUs - - **Storage**: 100GB - - > [!TIP] - > You can use the `spinup_comfystream_tensordock.py --bare-vm` script to create a [compatible VM on TensorDock](https://marketplace.tensordock.com/deploy?gpu=geforcertx4090-pcie-24gb&gpuCount=1&ramAmount=16&vcpuCount=4&storage=100&os=Ubuntu-22.04-LTS) - -2. **Open Required Ports**: - Ensure the following ports are open **inbound and outbound** on the VM's firewall/security group: - - **SSH (Port 22)** – Remote access - - **HTTPS (Port 8189)** – ComfyStream access - -3. **Install Ansible**: - Follow the [official Ansible installation guide](https://docs.ansible.com/ansible/latest/installation_guide/index.html). - -4. **Configure the Inventory File**: - Add the VM’s public IP to `ansible/inventory.yml`. - -5. **Change the ComfyUI Password:** - Open the `ansible/plays/setup_comfystream.yaml` file and replace the `comfyui_password` value with your own secure password. - -6. **Run the Playbook**: - Execute: - - ```bash - ansible-playbook -i ansible/inventory.yaml ansible/plays/setup_comfystream.yaml - ``` - - > [!IMPORTANT] - > When using a non-sudo user, add `--ask-become-pass` to provide the sudo password or use an Ansible vault for secure storage. - -7. **Access the Server**: - After the playbook completes, **ComfyStream** will start, and you can access **ComfyUI** at `https://:`. Credentials are shown in the output and regenerated each time. To persist the password, set the `comfyui_password` variable when running the playbook: - - ```bash - ansible-playbook -i ansible/inventory.yaml ansible/plays/setup_comfystream.yaml -e "comfyui_password=YourSecurePasswordHere" - ``` - -> [!IMPORTANT] -> If you encounter a `toomanyrequests` error while pulling the Docker image, either wait a few minutes or provide your Docker credentials when running the playbook: -> -> ```bash -> ansible-playbook -i ansible/inventory.yaml ansible/plays/setup_comfystream.yaml -e "docker_hub_username=your_dockerhub_username docker_hub_password=your_dockerhub_pat" -> ``` - -> [!TIP] -> The [ComfyStream Docker image](https://hub.docker.com/r/livepeer/comfystream/tags) is **~20GB**. To check download progress, SSH into the VM and run: -> -> ```bash -> docker pull livepeer/comfystream:latest -> ``` - -### TensorDock Spinup Script (Fully Automated) - -The `spinup_comfystream_tensordock.py` script automates VM provisioning, setup, and server launch on [TensorDock](https://tensordock.com/). Follow the steps below: - -1. **Create a TensorDock Account**: Sign up at [Tensordock](https://dashboard.tensordock.com/register), add a payment method, and generate API credentials. - -2. **Set Up a Python Virtual Environment**: - To prevent dependency conflicts, create and activate a virtual environment with [Conda](https://docs.anaconda.com/miniconda/) and install the required dependencies: - - ```bash - conda create -n comfystream python=3.8 - conda activate comfystream - pip install -r requirements.txt - ``` - -3. **View Available Script Options** *(Optional)*: - To see all available options, run: - - ```bash - python spinup_comfystream_tensordock.py --help - ``` - -4. **Run the Script**: - Execute the following command to provision a VM and set up ComfyStream automatically: - - ```bash - python spinup_comfystream_tensordock.py --api-key --api-token - ``` - -5. **Access the Server**: - Once the setup is complete, the script will display the URLs to access ComfyStream. - -6. **Stop & Delete the VM** *(When No Longer Needed)*: - To stop and remove the instance, run: - - ```bash - python spinup_comfystream_tensordock.py --delete - ``` - - Replace `` with the VM ID found in the script logs or the [TensorDock dashboard](https://dashboard.tensordock.com/instances). - -> [!WARNING] -> If you encounter `max retries exceeded with URL` errors, the VM might have been created but is inaccessible. -> Check the [TensorDock dashboard](https://dashboard.tensordock.com/instances), delete the VM manually, wait **2-3 minutes**, then rerun the script. +This repository provides an [Ansible playbook](https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_intro.html) to streamline the deployment of **ComfyStream** across any cloud provider. For comprehensive setup instructions, see the Ansible section in the [ComfyStream Installation Guide](https://docs.comfystream.org/technical/get-started/install#deploy-with-ansible). ### Profiling a Running ComfyStream Server diff --git a/scripts/ansible/plays/setup_comfystream.yaml b/scripts/ansible/plays/setup_comfystream.yaml index b5b6ead4..eb47d8a1 100644 --- a/scripts/ansible/plays/setup_comfystream.yaml +++ b/scripts/ansible/plays/setup_comfystream.yaml @@ -3,259 +3,326 @@ hosts: all become: yes vars: - docker_image: "livepeer/comfystream:0.0.3" + docker_image: "livepeer/comfystream:v0.1.0" comfyui_username: "comfyadmin" - tasks: - # Retrieve ComfyUI server password + ################################################################### + # 0. Ensure Cloud Init Completion and APT Readiness + ################################################################### + - block: + # TODO: Uncomment when tensordock cloud-init is fixed. + - name: Ensure cloud-init has completed successfully + command: cloud-init status --wait + register: cloud_init_result + retries: 30 + delay: 30 + changed_when: false + failed_when: cloud_init_result.rc != 0 + - name: Wait for apt to be available + apt: + update_cache: yes + register: apt_result + retries: 50 + delay: 10 + until: apt_result is success + ################################################################### + # 1. Password Generation & Connection Check + ################################################################### - name: Set ComfyUI password (static if provided, random if empty) set_fact: comfyui_password: "{{ comfyui_password | default(lookup('password', '/dev/null length=32 chars=ascii_letters,digits')) }}" - # Wait till VM is ready - - name: Check if we can connect to the VM + - name: Wait for VM to be ready wait_for_connection: timeout: 300 delay: 5 - - name: Ensure cloud-init has completed successfully - command: cloud-init status --wait - register: cloud_init_result - retries: 30 - delay: 30 - changed_when: false - failed_when: cloud_init_result.rc != 0 - - name: Wait for apt to be available - shell: timeout 300 bash -c 'until apt-get update 2>/dev/null; do sleep 5; done' - register: apt_result - changed_when: apt_result.rc == 0 - failed_when: apt_result.rc != 0 - - name: Wait for apt to be available - apt: - update_cache: yes - register: apt_result - retries: 50 - delay: 10 - until: apt_result is success - # Check if nvidia-smi is working and try to reboot if not - - name: Check if nvidia-smi is working - command: nvidia-smi - register: nvidia_smi_result - changed_when: false - ignore_errors: yes - - name: Reboot the machine if nvidia-smi fails - reboot: - msg: "Rebooting to initialize NVIDIA drivers" - connect_timeout: 5 - reboot_timeout: 600 - pre_reboot_delay: 0 - post_reboot_delay: 30 - test_command: whoami - when: nvidia_smi_result.rc != 0 - - name: Wait for the machine to come back online - wait_for_connection: - timeout: 300 - delay: 5 - when: nvidia_smi_result.rc != 0 - - name: Re-check if nvidia-smi is working after reboot - command: nvidia-smi - register: nvidia_smi_post_reboot_result - retries: 6 - delay: 10 - until: nvidia_smi_post_reboot_result.rc == 0 - changed_when: false - failed_when: nvidia_smi_post_reboot_result.rc != 0 - when: nvidia_smi_result.rc != 0 - # Setup auth proxy for ComfyUI (requires port 8189 to be open) - - name: Install python3-passlib for ComfyUI password hashing - apt: - name: python3-passlib - state: present - update_cache: yes - retries: 5 - delay: 30 - - name: Generate bcrypt hash on remote server - shell: python3 -c "from passlib.hash import bcrypt; print(bcrypt.hash('{{ comfyui_password }}'))" - register: remote_hash_result - changed_when: false - no_log: true - - name: Set password hash from remote result - set_fact: - password_hash: "{{ remote_hash_result.stdout }}" - - name: Display access credentials - debug: - msg: - - "ComfyUI username: {{ comfyui_username }}" - - "ComfyUI password: {{ comfyui_password }}" - - name: Add Caddy GPG key - apt_key: - url: https://dl.cloudsmith.io/public/caddy/stable/gpg.key - keyring: /usr/share/keyrings/caddy-stable-archive-keyring.gpg - state: present - - name: Download Caddy repository definition - get_url: - url: https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt - dest: /etc/apt/sources.list.d/caddy-stable.list - mode: "0644" - - name: Install Caddy and OpenSSL - apt: - name: - - caddy - - openssl - update_cache: yes - state: present - - name: Create certificates directory - file: - path: /etc/caddy/certificates - state: directory - mode: "0750" - owner: caddy - group: caddy - - name: Generate self-signed SSL certificate - command: > - openssl req -x509 -newkey rsa:4096 - -keyout /etc/caddy/certificates/selfsigned.key - -out /etc/caddy/certificates/selfsigned.crt - -days 365 -nodes - -subj "/C=US/ST=State/L=City/O=Company/OU=Org/CN=localhost" - args: - creates: /etc/caddy/certificates/selfsigned.crt - notify: restart caddy - - name: Set proper ownership for SSL certificates - file: - path: "{{ item }}" - owner: caddy - group: caddy - mode: "0640" - loop: - - /etc/caddy/certificates/selfsigned.key - - /etc/caddy/certificates/selfsigned.crt - notify: restart caddy - - name: Create Caddy configuration for ComfyUI server - template: - src: ../../templates/comfyui.caddy.j2 - dest: /etc/caddy/comfyui.caddy - owner: caddy - group: caddy - mode: "0644" - notify: restart caddy - - name: Ensure Caddyfile includes ComfyUI server configuration - lineinfile: - path: /etc/caddy/Caddyfile - line: "import /etc/caddy/comfyui.caddy" - create: yes - notify: restart caddy - # Ensure NVIDIA Container Toolkit is installed and configured - - name: Check if NVIDIA Container Toolkit is installed - shell: dpkg -l | grep nvidia-container-toolkit - register: nvidia_toolkit_installed - ignore_errors: yes - changed_when: false - - name: Check if NVIDIA runtime is configured in Docker - shell: docker info | grep -i nvidia - register: nvidia_runtime_configured - ignore_errors: yes - changed_when: false - - name: Add NVIDIA Container Toolkit repository key - apt_key: - url: https://nvidia.github.io/libnvidia-container/gpgkey - keyring: /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg - state: present - when: nvidia_toolkit_installed.rc != 0 - - name: Add NVIDIA Container Toolkit repository - get_url: - url: https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list - dest: /etc/apt/sources.list.d/nvidia-container-toolkit.list - mode: "0644" - when: nvidia_toolkit_installed.rc != 0 - - name: Ensure NVIDIA repository uses correct signing key - replace: - path: /etc/apt/sources.list.d/nvidia-container-toolkit.list - regexp: "^deb https://" - replace: "deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://" - when: nvidia_toolkit_installed.rc != 0 - - name: Install NVIDIA Container Toolkit - apt: - name: nvidia-container-toolkit - update_cache: yes - state: present - retries: 5 - delay: 30 - register: nvidia_toolkit_result - until: nvidia_toolkit_result is success - when: nvidia_toolkit_installed.rc != 0 - - name: Configure Docker to use NVIDIA runtime - command: nvidia-ctk runtime configure --runtime=docker - register: nvidia_ctk_result - changed_when: nvidia_ctk_result.rc == 0 - when: nvidia_runtime_configured.rc != 0 - - name: Restart Docker service - systemd: - name: docker - state: restarted - enabled: yes - when: nvidia_runtime_configured.rc != 0 - # Install, configure, and start ComfyUI with Comfystream - - name: Install community.docker collection - command: ansible-galaxy collection install community.docker - delegate_to: localhost - run_once: true - become: no - - name: Create directories for ComfyUI models and output - file: - path: "{{ item }}" - state: directory - mode: "0755" - loop: - - "{{ ansible_env.HOME }}/models/ComfyUI--models" - - "{{ ansible_env.HOME }}/models/ComfyUI--output" - - name: Check if Docker Hub credentials are provided - set_fact: - docker_login_required: "{{ docker_hub_username | default('') | length > 0 and docker_hub_password | default('') | length > 0 }}" - - name: Log in to Docker Hub (if credentials exist) - community.docker.docker_login: - username: "{{ docker_hub_username }}" - password: "{{ docker_hub_password }}" - become: yes - when: docker_login_required - register: docker_login_result - ignore_errors: yes - - name: Pull Docker image for ComfyStream (may take a while) - community.docker.docker_image: - name: "{{ docker_image }}" - source: pull - - name: Log out from Docker Hub after pulling image - community.docker.docker_login: - state: absent - become: yes - when: docker_login_required and docker_login_result is succeeded - - name: Run Comfystream Docker container - community.docker.docker_container: - name: comfystream - image: "{{ docker_image }}" - state: started - restart_policy: unless-stopped - stop_timeout: 300 - device_requests: - - driver: nvidia - count: -1 # Use all GPUs - capabilities: - - [gpu] - volumes: - - "{{ ansible_env.HOME }}/models/ComfyUI--models:/workspace/ComfyUI/models" - - "{{ ansible_env.HOME }}/models/ComfyUI--output:/workspace/ComfyUI/output" - ports: - - "3000:3000" - - "8188:8188" - - "8889:8889" - command: "--download-models --build-engines --server" - - name: Display Ansible completion message - debug: - msg: "ComfyStream is starting up, downloading models, and building TensorRT engines—this may take a while. Access ComfyUI when ready at https://{{ ansible_default_ipv4.address }}:." - + ################################################################### + # 2. Docker CE Installation (Only if not installed) + ################################################################### + - name: Check if Docker is installed + stat: + path: /usr/bin/docker + register: docker_check + - name: Install Docker CE (official method) if not installed + block: + - name: Install Docker prerequisites + apt: + name: + - ca-certificates + - curl + state: present + update_cache: yes + - name: Create Docker keyring directory + file: + path: /etc/apt/keyrings + state: directory + mode: "0755" + - name: Download Docker GPG key + get_url: + url: https://download.docker.com/linux/ubuntu/gpg + dest: /etc/apt/keyrings/docker.asc + mode: "0644" + force: yes + - name: Add Docker APT repository + shell: | + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list + args: + executable: /bin/bash + - name: Update APT cache + apt: + update_cache: yes + - name: Install Docker CE and related plugins + apt: + name: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-buildx-plugin + - docker-compose-plugin + state: present + - name: Ensure Docker is running and enabled + systemd: + name: docker + state: started + enabled: yes + - name: Add user to Docker group + user: + name: "{{ ansible_user }}" + groups: docker + append: yes + # when: docker_check.rc != 0 + when: not docker_check.stat.exists + ################################################################### + # 3. NVIDIA Driver Check + ################################################################### + - block: + - name: Check if nvidia-smi is working + command: nvidia-smi + register: nvidia_smi_result + changed_when: false + ignore_errors: yes + - name: Reboot machine if NVIDIA drivers not ready + reboot: + msg: "Rebooting to initialize NVIDIA drivers" + connect_timeout: 5 + reboot_timeout: 600 + pre_reboot_delay: 0 + post_reboot_delay: 30 + test_command: whoami + when: nvidia_smi_result.rc != 0 + - name: Wait for system after reboot + wait_for_connection: + timeout: 300 + delay: 5 + when: nvidia_smi_result.rc != 0 + - name: Confirm nvidia-smi post-reboot + command: nvidia-smi + register: nvidia_smi_post_reboot_result + retries: 6 + delay: 10 + until: nvidia_smi_post_reboot_result.rc == 0 + changed_when: false + failed_when: nvidia_smi_post_reboot_result.rc != 0 + when: nvidia_smi_result.rc != 0 + ################################################################### + # 4. ComfyUI Auth Setup + ################################################################### + - block: + - name: Install python3-passlib + apt: + name: python3-passlib + state: present + update_cache: yes + retries: 5 + delay: 30 + - name: Generate bcrypt hash on remote + shell: python3 -c "from passlib.hash import bcrypt; print(bcrypt.hash('{{ comfyui_password }}'))" + register: remote_hash_result + changed_when: false + no_log: true + - name: Set password hash fact + set_fact: + password_hash: "{{ remote_hash_result.stdout }}" + - name: Display access credentials + debug: + msg: + - "ComfyUI username: {{ comfyui_username }}" + - "ComfyUI password: {{ comfyui_password }}" + ################################################################### + # 5. Caddy Reverse Proxy Setup + ################################################################### + - block: + - name: Add Caddy GPG key + apt_key: + url: https://dl.cloudsmith.io/public/caddy/stable/gpg.key + keyring: /usr/share/keyrings/caddy-stable-archive-keyring.gpg + state: present + - name: Download Caddy repo definition + get_url: + url: https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt + dest: /etc/apt/sources.list.d/caddy-stable.list + mode: "0644" + - name: Install Caddy and OpenSSL + apt: + name: + - caddy + - openssl + update_cache: yes + state: present + - name: Create cert directory + file: + path: /etc/caddy/certificates + state: directory + mode: "0750" + owner: caddy + group: caddy + - name: Generate self-signed SSL certificate + command: > + openssl req -x509 -newkey rsa:4096 + -keyout /etc/caddy/certificates/selfsigned.key + -out /etc/caddy/certificates/selfsigned.crt + -days 365 -nodes + -subj "/C=US/ST=State/L=City/O=Company/OU=Org/CN=localhost" + args: + creates: /etc/caddy/certificates/selfsigned.crt + notify: restart caddy + - name: Set ownership on certs + file: + path: "{{ item }}" + owner: caddy + group: caddy + mode: "0640" + loop: + - /etc/caddy/certificates/selfsigned.key + - /etc/caddy/certificates/selfsigned.crt + notify: restart caddy + - name: Add ComfyUI Caddy config + template: + src: ../../templates/comfyui.caddy.j2 + dest: /etc/caddy/comfyui.caddy + owner: caddy + group: caddy + mode: "0644" + notify: restart caddy + - name: Ensure Caddyfile imports ComfyUI config + lineinfile: + path: /etc/caddy/Caddyfile + line: "import /etc/caddy/comfyui.caddy" + create: yes + notify: restart caddy + ################################################################### + # 6. NVIDIA Container Toolkit + ################################################################### + - block: + - name: Check if NVIDIA Container Toolkit is installed + stat: + path: /usr/bin/nvidia-container-toolkit + register: nvidia_toolkit_installed + - name: Check if NVIDIA runtime is configured in Docker + shell: docker info | grep -i nvidia + register: nvidia_runtime_configured + changed_when: false + failed_when: false + - name: Add NVIDIA repo key + apt_key: + url: https://nvidia.github.io/libnvidia-container/gpgkey + keyring: /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg + state: present + when: not nvidia_toolkit_installed.stat.exists + - name: Add NVIDIA repo list + get_url: + url: https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list + dest: /etc/apt/sources.list.d/nvidia-container-toolkit.list + mode: "0644" + when: not nvidia_toolkit_installed.stat.exists + - name: Patch NVIDIA repo to use signed key + replace: + path: /etc/apt/sources.list.d/nvidia-container-toolkit.list + regexp: "^deb https://" + replace: "deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://" + when: not nvidia_toolkit_installed.stat.exists + - name: Install NVIDIA Container Toolkit + apt: + name: nvidia-container-toolkit + update_cache: yes + state: present + retries: 5 + delay: 30 + register: nvidia_toolkit_result + until: nvidia_toolkit_result is success + when: not nvidia_toolkit_installed.stat.exists + - name: Configure Docker to use NVIDIA runtime + command: nvidia-ctk runtime configure --runtime=docker + register: nvidia_ctk_result + changed_when: nvidia_ctk_result.rc == 0 + when: nvidia_runtime_configured.rc != 0 + - name: Restart Docker + systemd: + name: docker + state: restarted + enabled: yes + when: nvidia_runtime_configured.rc != 0 + ################################################################### + # 7. ComfyStream Docker Setup + ################################################################### + - block: + - name: Install community.docker collection + command: ansible-galaxy collection install community.docker + delegate_to: localhost + run_once: true + become: no + - name: Create ComfyUI model and output directories + file: + path: "{{ item }}" + state: directory + mode: "0755" + loop: + - "{{ ansible_env.HOME }}/models/ComfyUI--models" + - "{{ ansible_env.HOME }}/models/ComfyUI--output" + - name: Check Docker Hub credentials + set_fact: + docker_login_required: "{{ docker_hub_username | default('') | length > 0 and docker_hub_password | default('') | length > 0 }}" + - name: Docker login (if needed) + community.docker.docker_login: + username: "{{ docker_hub_username }}" + password: "{{ docker_hub_password }}" + become: yes + when: docker_login_required + register: docker_login_result + ignore_errors: yes + - name: Pull ComfyStream image (may take a while) + community.docker.docker_image: + name: "{{ docker_image }}" + source: pull + - name: Docker logout (if logged in) + community.docker.docker_login: + state: absent + become: yes + when: docker_login_required and docker_login_result is succeeded + - name: Run ComfyStream container + community.docker.docker_container: + name: comfystream + image: "{{ docker_image }}" + state: started + restart_policy: unless-stopped + stop_timeout: 300 + device_requests: + - driver: nvidia + count: -1 # Use all GPUs + capabilities: + - [gpu] + volumes: + - "{{ ansible_env.HOME }}/models/ComfyUI--models:/workspace/ComfyUI/models" + - "{{ ansible_env.HOME }}/models/ComfyUI--output:/workspace/ComfyUI/output" + ports: + - "3000:3000" + - "8188:8188" + - "8889:8889" + command: "--download-models --build-engines --server" + - name: Display ComfyUI access message + debug: + msg: "ComfyStream is starting up. downloading models, and building TensorRT engines—this may take a while. Access ComfyUI when ready at https://{{ ansible_default_ipv4.address }}:." handlers: - name: restart caddy systemd: name: caddy state: restarted - - name: update apt cache - apt: - update_cache: yes diff --git a/scripts/spinup_comfystream_tensordock.py b/scripts/spinup_comfystream_tensordock.py deleted file mode 100644 index 684364f4..00000000 --- a/scripts/spinup_comfystream_tensordock.py +++ /dev/null @@ -1,914 +0,0 @@ -"""Script used to spin up Comfystream and ComfyUI on a suitable VM on TensorDock close -to the user's location. -""" - -import base64 -import logging -import os -import secrets -import string -import sys -import time -from pathlib import Path -from typing import Dict, List, Optional, Tuple - -import bcrypt -import click -import requests -from colorama import Fore, Style, init -from geopy.distance import geodesic -from geopy.exc import GeocoderTimedOut -from geopy.geocoders import Nominatim -from rich.console import Console -from rich.panel import Panel -from rich.text import Text - -TENSORDOCK_ENDPOINTS = { - "auth_test": "https://marketplace.tensordock.com/api/v0/auth/test", - "hostnodes": "https://marketplace.tensordock.com/api/v0/client/deploy/hostnodes", - "deploy": "https://marketplace.tensordock.com/api/v0/client/deploy/single", - "delete": "https://marketplace.tensordock.com/api/v0/client/delete/single", -} - - -# Requirements for host nodes. -DEFAULT_MAX_PRICE = 0.5 # USD per hour -MIN_REQUIREMENTS = { - "minvCPUs": 4, - "minRAM": 16, # GB - "minStorage": 100, # GB - "minVRAM": 20, # GB - "minGPUCount": 1, - "requiresRTX": True, - "requiresGTX": False, - "maxGPUCount": 1, -} -VM_SPECS = { - "gpu_count": MIN_REQUIREMENTS["minGPUCount"], - "vcpus": MIN_REQUIREMENTS["minvCPUs"], - "ram": MIN_REQUIREMENTS["minRAM"], - "storage": MIN_REQUIREMENTS["minStorage"], - "internal_ports": [22, 8189], - "operating_system": "Ubuntu 22.04 LTS", -} -CADDY_TEMPLATE_PATH = os.path.join( - os.path.dirname(__file__), "templates", "comfyui.caddy.j2" -) -CLOUD_INIT_TEMPLATE_PATH = os.path.join( - os.path.dirname(__file__), "templates", "cloud_init_comfystream.yaml.j2" -) -PASSWORD_PLACEHOLDER = "{{ password_hash }}" -COMFYSTREAM_CADDY_PLACEHOLDER = "{{ comfystream_caddy_placeholder }}" -DOCKER_IMAGE_PLACEHOLDER = "{{ docker_image_placeholder }}" - - -class ColorFormatter(logging.Formatter): - """Custom log formatter to add color to log messages based on log level.""" - - COLORS = { - "DEBUG": Fore.CYAN, - "INFO": Fore.RESET, - "WARNING": Fore.YELLOW, - "ERROR": Fore.RED, - "CRITICAL": Fore.MAGENTA, - "SUCCESS": Fore.GREEN, # Custom log level (not built-in). - } - - def __init__(self, fmt="%(levelname)s - %(message)s"): - """Initialize formatter with optional format. - - Args: - fmt (str): The format string for the log messages. - """ - super().__init__(fmt) - init(autoreset=True) # Initialize colorama for cross-platform support. - - def format(self, record): - """Apply color to log messages dynamically based on log level. - - Args: - record: The log record to format. - - Returns: - str: The formatted log message with color. - """ - log_color = self.COLORS.get(record.levelname, Fore.RESET) - record.msg = f"{log_color}{record.msg}{Style.RESET_ALL}" - return super().format(record) - - -logger = logging.getLogger(__name__) -logger.setLevel(logging.INFO) -handler = logging.StreamHandler() -handler.setFormatter(ColorFormatter()) -logger.handlers = [] -logger.addHandler(handler) - - -geolocator = Nominatim(user_agent="tensordock_locator", timeout=5) -console = Console() - - -def display_login_info( - comfyui_url: str = None, - comfyui_username: str = None, - comfyui_password: str = None, - ssh_command: str = None, -): - """Display VM login information, but only for values that are provided. - - Args: - comfyui_url: The ComfyUI URL. - comfyui_username: The ComfyUI username. - comfyui_password: The ComfyUI password. - ssh_command: The SSH command. - """ - labels_and_styles = { - "ComfyUI url: ": (comfyui_url, "yellow"), - "ComfyUI username: ": (comfyui_username, "cyan"), - "ComfyUI password: ": (comfyui_password, "cyan"), - "SSH Command: ": (ssh_command, "green"), - } - - content_elements = [] - for label, (value, style) in labels_and_styles.items(): - if value: - text = Text(label, style=style) - text.append(value, style="white") - content_elements.append(text) - - if not content_elements: - logger.warning("No access information available to display.") - return - - final_content = Text.assemble( - *(sum(zip(content_elements, ["\n"] * len(content_elements)), ())[:-1]) - ) - console.print( - Panel( - final_content, - title="[blue]Access Information[/blue]", - border_style="blue", - expand=False, - ) - ) - - -def generate_strong_password(length: int = 60) -> str: - """Generate a strong password with a mix of letters, digits, and special characters. - - Args: - length: The length of the password. - - Returns: - A strong password. - """ - characters = string.ascii_letters + string.digits + string.punctuation - password = "".join(secrets.choice(characters) for _ in range(length)) - return password - - -def is_strong_password(password: str, min_length: int = 32) -> bool: - """Check if a password is strong enough. - - Args: - password: The password to check. - min_length: The minimum length of the password. - - Returns: - True if the password is strong enough, otherwise False. - """ - return ( - any(char.isupper() for char in password) - and any(char.islower() for char in password) - and any(char.isdigit() for char in password) - and len(password) >= min_length - ) - - -def hash_password(password: str) -> str: - """Create hash a password using bcrypt. - - Args: - password: The password to hash. - - Returns: - The hashed password. - """ - return bcrypt.hashpw(password.encode(), bcrypt.gensalt(10)).decode().strip() - - -def get_cloud_init_script( - comfyui_password: str, docker_image: str = "livepeer/comfystream:latest" -) -> str: - """Generate the cloud-init script using the template and replace placeholders. - - Args: - comfyui_password: The password used to protect the ComfUI interface. - docker_image: The Docker image to use for the Comfystream deployment (e.g. - 'repository/image:tag'). - - Returns: - The cloud-init script as a string. - """ - # Open cloud init template and read its content. - try: - with open(CLOUD_INIT_TEMPLATE_PATH, "r", encoding="utf-8") as file: - cloud_init_content = file.read() - except FileNotFoundError: - raise FileNotFoundError( - f"Cloud-init template not found: {CLOUD_INIT_TEMPLATE_PATH}" - ) - cloud_init_content = cloud_init_content.replace("\r\n", "\n").replace( - "\r", "\n" - ) # Normalize line endings - - # Open Caddyfile template and read its content. - try: - with open(CADDY_TEMPLATE_PATH, "r", encoding="utf-8") as file: - caddyfile_content = file.read() - except FileNotFoundError: - raise FileNotFoundError(f"Caddyfile template not found: {CADDY_TEMPLATE_PATH}") - - # Inject ComfyUI password and convert to base64. - encoded_password = hash_password(comfyui_password) - caddyfile_content = caddyfile_content.replace( - PASSWORD_PLACEHOLDER, encoded_password - ) - caddy_config_b64 = base64.b64encode(caddyfile_content.encode()).decode() - - # Replace placeholders in the cloud-init script and return the final content. - replacements = { - COMFYSTREAM_CADDY_PLACEHOLDER: caddy_config_b64, - DOCKER_IMAGE_PLACEHOLDER: docker_image, - } - for placeholder, value in replacements.items(): - cloud_init_content = cloud_init_content.replace(placeholder, value) - return cloud_init_content - - -def format_ports_as_set(ports: List) -> str: - """Format a list of ports as a string that looks like a set. - - Args: - ports: List of ports. - - Returns: - A string that looks like a set. - """ - return "{" + ", ".join(map(str, ports)) + "}" - - -def filter_nodes_by_price(host_nodes: Dict, max_price: float) -> Dict: - """Filter host nodes based on the maximum price. - - Args: - host_nodes: Dictionary of host nodes. - max_price: Maximum price per hour. - - Returns: - Dictionary of filtered host nodes. - """ - filtered_nodes = {} - for node_id, node in host_nodes.items(): - total_price = ( - node["specs"]["cpu"]["price"] - + sum(gpu["price"] for gpu in node["specs"]["gpu"].values()) - + node["specs"]["ram"]["price"] - + node["specs"]["storage"]["price"] - ) - if total_price <= max_price: - filtered_nodes[node_id] = node - return filtered_nodes - - -def filter_nodes_by_gpu_availability(host_nodes: Dict) -> Dict: - """Filter host nodes based on the availability of GPUs and the minimum VRAM - requirements. Remove GPUs that do not meet the requirements from the specs. - - Args: - host_nodes: Dictionary of host nodes. - - Returns: - Dictionary of filtered host nodes. - """ - filtered_nodes = {} - for node_id, node in host_nodes.items(): - gpu_models = node["specs"]["gpu"] - compatible_gpus = { - gpu_id: gpu - for gpu_id, gpu in gpu_models.items() - if gpu["amount"] > 0 and gpu["vram"] >= MIN_REQUIREMENTS["minVRAM"] - } - if compatible_gpus: - node["specs"]["gpu"] = compatible_gpus - filtered_nodes[node_id] = node - return filtered_nodes - - -def filter_nodes_by_min_system_requirements( - host_nodes: Dict, min_requirements: Dict -) -> Dict: - """Filter host nodes based on the minimum system requirements. - - Args: - host_nodes: Dictionary of host nodes. - min_requirements: Dictionary of minimum requirements. - - Returns: - Dictionary of filtered host nodes. - """ - filtered_nodes = {} - for node_id, node in host_nodes.items(): - restrictions = node["specs"]["restrictions"] - for restriction in restrictions.values(): - if ( - min_requirements["minvCPUs"] >= restriction.get("cpu", {}).get("min", 0) - and min_requirements["minRAM"] - >= restriction.get("ram", {}).get("min", 0) - and min_requirements["minStorage"] - >= restriction.get("storage", {}).get("min", 0) - ): - filtered_nodes[node_id] = node - return filtered_nodes - - -def get_current_location() -> Tuple: - """Fetch the current location (latitude and longitude) using an IP geolocation API. - - Returns: - Latitude and Longitude as a tuple (lat, lon). - """ - try: - response = requests.get("http://ip-api.com/json/") - response.raise_for_status() - data = response.json() - return data["lat"], data["lon"] - except requests.RequestException as e: - logger.error(f"Error fetching current location: {e}") - return None, None - - -def geocode_location( - location_str: str, retries: int = 3, backoff_factor: float = 1 -) -> Tuple[Optional[float], Optional[float]]: - """Geocode a location using Nominatim with retries and caching. - - Args: - location_str: Location string to geocode (e.g. 'City, Country, Region)'). - retries: Number of retries for geocoding. - backoff_factor: Backoff factor for retries. - - Returns: - Tuple of (latitude, longitude) or (None, None) if not found. - """ - for attempt in range(retries): - try: - location = geolocator.geocode(location_str) - if location: - return location.latitude, location.longitude - except GeocoderTimedOut: - logger.warning( - f"Geocoder timed out. Retrying in {backoff_factor * (2 ** attempt)} " - f"seconds..." - ) - time.sleep(backoff_factor * (2**attempt)) - except Exception as e: - logger.error(f"Geocoding error: {e}") - break # Don't retry if it's a non-timeout error. - return None, None - - -def sort_nodes_by_distance( - host_nodes: Dict, location: Optional[Tuple[float, float]] -) -> List: - """Sort host nodes by distance from the current location. - - Args: - host_nodes: Dictionary of host nodes. - location: Location to sort the nodes relative to (latitude, longitude). - - Returns: - List of host nodes sorted by distance. - """ - if not location: - location = get_current_location() - if not location: - logger.error("Could not determine current location.") - return [] - - nodes_with_distance = [] - for node_id, node in host_nodes.items(): - location_parts = [ - node["location"].get("city"), - node["location"].get("region"), - node["location"].get("country"), - ] - location_str = ", ".join(filter(None, location_parts)) - node_location = geocode_location(location_str=location_str) - if node_location: - distance = geodesic(location, node_location).kilometers - node["id"] = node_id - nodes_with_distance.append((distance, node)) - - nodes_with_distance.sort(key=lambda x: x[0]) - return [node for _, node in nodes_with_distance] - - -def read_ssh_key(public_ssh_key: str) -> str: - """Retrieve the public SSH key from a file or directly. - - Args: - public_ssh_key: The public SSH key or file path. - - Returns: - The public SSH key as a string. - """ - if public_ssh_key: - key_path = Path(public_ssh_key) - if key_path.is_file(): - try: - with open(public_ssh_key, "r") as key_file: - return key_file.read().strip() - except Exception as e: - logger.error(f"Failed to read SSH key file: {e}") - sys.exit(1) - else: - return public_ssh_key.strip() # Use the provided key directly. - return None - - -def get_vm_access_info(node_info: Dict) -> Tuple[str, str]: - """Get SSH access command and ComfyUI URL for a deployed VM. - - Args: - node_info: Dictionary of node information. - - Returns: - Tuple of SSH command and ComfyUI URL. - """ - available_ports = list(node_info["port_forwards"].keys()) - ssh_command = f"ssh -p {available_ports[0]} user@{node_info['ip']}" - comfyui_url = f"https://{node_info['ip']}:{available_ports[1]}" - return ssh_command, comfyui_url - - -def generate_qr_code(url: str): - """Generates QR codes for a given URL. - - Args: - comfystream_ui_url: URL to the Comfystream UI. - comfystream_server_url: URL to the Comfystream Server. - """ - try: - import qrcode_terminal - - qrcode_terminal.draw(url) - except ImportError: - logger.warning( - "qrcode_terminal module is not installed. Skipping QR code generation." - ) - - -class TensorDockController: - """Controller class for interacting with the TensorDock API.""" - - def __init__(self, api_key: str, api_token: str): - """Initialize the TensorDockController with the API key and token. - - Args: - api_key: The TensorDock API key. - api_token: The TensorDock API token. - """ - self.api_key = api_key - self.api_token = api_token - self._ensure_auth() - - def _ensure_auth(self): - """Test the authentication with the TensorDock API. - - Raises: - requests.HTTPError: If the authentication fails. - """ - response = requests.post( - TENSORDOCK_ENDPOINTS["auth_test"], - data={"api_key": self.api_key, "api_token": self.api_token}, - ) - response.raise_for_status() - if not response.json()["success"]: - raise requests.HTTPError("Authentication failed.") - - def _fetch_host_nodes(self, min_host_requirements: Dict) -> Dict: - """Fetch host nodes from TensorDock API with specified minimum settings. - - Args: - min_host_requirements: Dictionary of minimum requirements. - - Returns: - dict: Dictionary of compatible host nodes. - """ - try: - response = requests.get( - TENSORDOCK_ENDPOINTS["hostnodes"], - params=min_host_requirements, - headers={"Authorization": f"Bearer {self.api_key}:{self.api_token}"}, - ) - response.raise_for_status() - if response.json()["success"]: - return response.json()["hostnodes"] - except requests.RequestException as e: - logger.error(f"Error fetching compatible host nodes: {e}") - return {} - - def fetch_compatible_host_nodes( - self, min_host_requirements: Dict, max_price: float - ) -> Dict: - """Fetch compatible host nodes based on the minimum requirements and maximum - price. - - Args: - min_host_requirements: Dictionary of minimum requirements. - max_price: Maximum price per hour. - - Returns: - Dictionary of compatible host nodes. - """ - host_nodes = self._fetch_host_nodes(min_host_requirements) - logger.debug(f"Initial host nodes count: {len(host_nodes)}") - host_nodes = filter_nodes_by_price(host_nodes, max_price) - logger.debug(f"Host nodes within price range: {len(host_nodes)}") - host_nodes = filter_nodes_by_gpu_availability(host_nodes) - logger.debug(f"Host nodes with available GPUs: {len(host_nodes)}") - host_nodes = filter_nodes_by_min_system_requirements( - host_nodes, min_host_requirements - ) - logger.debug(f"Host nodes meeting minimum requirements: {len(host_nodes)}") - return host_nodes - - def deploy_vm( - self, - name: str, - hostnode_id: str, - gpu_model: str, - internal_ports: List[int], - external_ports: List[int], - cloud_init_script: str = None, - password: str = None, - public_ssh_key: str = None, - ) -> Dict: - """Deploy a VM on a host node with the specified settings and cloud init script. - - Args: - name: The name of the VM. - hostnode_id: The ID of the host node. - gpu_model: The GPU model of the VM. - internal_ports: List of internal ports to open. - external_ports: List of external ports to open. - cloud_init_script: The cloud-init script for the VM (if provided). - password: The password for the VM (if provided). - public_ssh_key: The public SSH key for the VM (if provided). - - Returns: - The response from the TensorDock API if successful, otherwise an empty - dictionary. - """ - vm_specs = { - **VM_SPECS, - "api_key": self.api_key, - "api_token": self.api_token, - "name": name, - "hostnode": hostnode_id, - "gpu_model": gpu_model, - "internal_ports": format_ports_as_set(internal_ports), - "external_ports": format_ports_as_set(external_ports), - } - if public_ssh_key: - vm_specs["public_ssh_key"] = public_ssh_key - if password: - vm_specs["password"] = password - if cloud_init_script: - vm_specs["cloudinit_script"] = cloud_init_script - - try: - response = requests.post(TENSORDOCK_ENDPOINTS["deploy"], data=vm_specs) - response.raise_for_status() - return response.json() - except requests.RequestException as e: - error_message = response.json().get("error", None) - error_str = ( - f"Error deploying VM: {error_message}" if error_message else str(e) - ) - logger.error(error_str) - return {} - - def delete_vm(self, vm_id: str) -> bool: - """Delete a VM with the specified ID. - - Args: - vm_id: The ID of the VM to delete. - - Returns: - True if the VM was deleted successfully, otherwise False. - """ - try: - response = requests.post( - TENSORDOCK_ENDPOINTS["delete"], - data={ - "api_key": self.api_key, - "api_token": self.api_token, - "server": vm_id, - }, - ) - response.raise_for_status() - return response.json().get("success", False) - except requests.RequestException as e: - logger.error(f"Error deleting VM: {e}") - return False - - def deploy_vm_on_tensordock( - self, - host_nodes: Dict, - vm_name: str, - password: str, - public_ssh_key: str, - comfyui_password: str, - location: Tuple[int, int] = None, - docker_image: str = "livepeer/comfystream:latest", - bare_vm: bool = False, - ): - """Deploys a VM on TensorDock, optionally with Comfystream. - - Args: - host_nodes: List of compatible host nodes. - vm_name: Name of the VM. - password: Password for the VM (if provided). - public_ssh_key: Public SSH key for the VM (if provided). - comfyui_password: Password for the ComfyUI interface (ignored if - bare_vm=True). - location: Location to search for host nodes close to. - docker_image: Docker image to use for Comfystream (ignored if - bare_vm=True). - bare_vm: If True, deploy a clean VM without ComfyStream. - - Returns: - Information about the deployed node, or None if deployment failed. - """ - logger.info("Sorting nodes by distance from current location...") - sorted_host_nodes = sort_nodes_by_distance( - host_nodes=host_nodes, location=location - ) - if not sorted_host_nodes: - logger.error("Something went wrong while sorting host nodes by distance.") - return None - - # Loop through sorted host nodes and try to deploy on the closest one. - logger.info( - f"Attempting VM deployment on {len(sorted_host_nodes)} closest node..." - ) - cloud_init_script = None - if not bare_vm: - cloud_init_script = get_cloud_init_script( - comfyui_password=comfyui_password, - docker_image=docker_image, - ) - for node_idx, node in enumerate(sorted_host_nodes): - compatible_gpus = [ - gpu - for gpu, details in node["specs"]["gpu"].items() - if details["vram"] >= MIN_REQUIREMENTS["minVRAM"] - ] - if not compatible_gpus: - logger.warning( - f"No compatible GPU found on {node['id']} " - f"({node['location']['city']}). Skipping." - ) - continue - - # Loop through compatible GPUs and try to deploy on the node. - internal_ports = VM_SPECS["internal_ports"] - available_ports = node["networking"]["ports"][: len(internal_ports)] - for gpu_idx, gpu in enumerate(compatible_gpus): - logger.info( - f"Attempting deployment on node '{node['id']}' in " - f"{node['location']['city']}, {node['location']['country']} using " - f"GPU '{gpu}'." - ) - node_info = self.deploy_vm( - name=vm_name, - hostnode_id=node["id"], - gpu_model=gpu, - internal_ports=internal_ports, - external_ports=available_ports, - cloud_init_script=cloud_init_script, - password=password, - public_ssh_key=public_ssh_key, - ) - - if node_info: - logger.info( - f"{ColorFormatter.COLORS['SUCCESS']}VM successfully deployed " - f"on '{node['id']}' ({node['location']['city']})." - f"{Style.RESET_ALL}" - ) - return node_info - if gpu_idx < len(compatible_gpus) - 1: - logger.warning( - f"Deployment failed on {node['location']['city']} using GPU " - f"'{gpu}'. Trying next GPU..." - ) - if node_idx < len(sorted_host_nodes) - 1: - logger.warning( - f"Deployment failed on {node['location']['city']} for all GPUs. " - "Trying next node..." - ) - logger.error("All deployment attempts failed. No VM was deployed.") - return None - - -@click.command() -@click.option( - "--api-key", - default=lambda: os.environ.get("TENSORDOCK_API_KEY", ""), - prompt="TensorDock API Key", - help="Your TensorDock API key.", - hide_input=True, - prompt_required=False, -) -@click.option( - "--api-token", - default=lambda: os.environ.get("TENSORDOCK_API_TOKEN", ""), - prompt="TensorDock API Token", - help="Your TensorDock API token.", - hide_input=True, - prompt_required=False, -) -@click.option( - "--delete", - default=None, - help="Delete the VM with the specified ID.", -) -@click.option( - "--max-price", - default=DEFAULT_MAX_PRICE, - help="Maximum price per hour.", -) -@click.option( - "--vm-name", - default=f"comfystream-{int(time.time())}", - help="Name of the VM.", -) -@click.option( - "--password", - default=None, - help="Password for the VM.", -) -@click.option( - "--public-ssh-key", - default=None, - help="Public SSH key for the VM.", -) -@click.option( - "--qr-code", - is_flag=True, - help="Generate ComfyUI QR code for easy access. Ignored for bare VMs.", -) -@click.option( - "--location", - default=None, - help=( - "Where to search for host nodes (e.g. City, Country, Region). If not provided, " - "the current location is used." - ), -) -@click.option( - "--docker-image", - default="livepeer/comfystream:latest", - help=( - "Docker image to use for the Comfystream deployment (e.g. " - "'repository/image:tag')." - ), -) -@click.option( - "--bare-vm", - is_flag=True, - help="Spin up a VM without setting up ComfyStream (creates a clean VM).", -) -def main( - api_key, - api_token, - delete, - vm_name, - max_price, - password, - public_ssh_key, - qr_code, - location, - docker_image, - bare_vm, -): - """Main function that collects command line arguments and deploys or deletes a VM - with Comfystream on TensorDock close to the user's location. - - Args: - api_key: The TensorDock API key. - api_token: The TensorDock API token. - delete: The ID of the VM to delete. - vm_name: The name of the VM. - max_price: The maximum price per hour. - password: The password for the VM. - public_ssh_key: The public SSH key for the VM. - qr_code: Whether to generate QR codes for easy access. - location: The location to search for host nodes (e.g. City, Country, Region). - docker_image: The Docker image to use for the Comfystream deployment. - bare_vm: Whether to spin up a VM without setting up ComfyStream. - """ - api_key = api_key or click.prompt("TensorDock API Key", hide_input=True) - api_token = api_token or click.prompt("TensorDock API Token", hide_input=True) - vm_type = "ComfyStream" if not bare_vm else "bare" - logger.info( - f"Starting {Fore.BLUE}{vm_type}{Style.RESET_ALL} TensorDock deployment..." - ) - - controller = TensorDockController(api_key, api_token) - - if location: - location = geocode_location(location_str=location) - - if delete: - logger.info(f"Deleting VM '{delete}'...") - if controller.delete_vm(delete): - logger.info( - f"{ColorFormatter.COLORS['SUCCESS']}Successfully deleted VM '{delete}'." - f"{Style.RESET_ALL}" - ) - else: - logger.error(f"Failed to delete VM '{delete}'.") - sys.exit(0) - - public_ssh_key = read_ssh_key(public_ssh_key) - if not password and not public_ssh_key: - logger.error("You must provide either a password or a public SSH key.") - sys.exit(1) - if password and public_ssh_key: - logger.error("You cannot provide both a password and a public SSH key.") - sys.exit(1) - if password and not is_strong_password(password, min_length=32): - logger.error( - "Password strength insufficient: must be at least 32 characters long and " - "include uppercase, lowercase, digits, and special characters." - ) - sys.exit(1) - - logger.info(f"Looking for a suitable host within ${max_price} per hour...") - logger.info("Fetching host nodes and filtering by requirements...") - filtered_nodes = controller.fetch_compatible_host_nodes(MIN_REQUIREMENTS, max_price) - if not filtered_nodes: - logger.error("No suitable host nodes found.") - sys.exit(1) - logger.info(f"Found {len(filtered_nodes)} suitable host nodes.") - - logger.info(f"Attempting {vm_type} VM deployment on the close host nodes...") - comfyui_password = generate_strong_password() if not bare_vm else None - node_info = controller.deploy_vm_on_tensordock( - host_nodes=filtered_nodes, - vm_name=vm_name, - password=password, - public_ssh_key=public_ssh_key, - comfyui_password=comfyui_password, - location=location, - docker_image=docker_image, - bare_vm=bare_vm, - ) - if not node_info: - vm_type = "ComfyStream" if not bare_vm else "bare" - logger.error(f"Failed to deploy {vm_type} VM.") - sys.exit(1) - - # Print access information. - ssh_command, comfyui_url = get_vm_access_info(node_info) - if not bare_vm: - logger.info( - f"{Fore.BLUE}Provisioning Comfystream and ComfyUI. This may take up to `" - f"30 minutes.{Style.RESET_ALL}" - ) - comfyui_username = "comfyadmin" - else: - comfyui_username, comfyui_url = None, None - display_login_info( - comfyui_url=comfyui_url, - comfyui_username=comfyui_username, - comfyui_password=comfyui_password, - ssh_command=ssh_command, - ) - if qr_code and comfyui_url: - logger.info("Generating QR codes for easy access:") - generate_qr_code(comfyui_url) - logger.warning( - "Remember to remove the VM after use to avoid unnecessary costs. Run " - f"'spinup_comfystream_tensordock.py --delete {node_info['server']}' to remove " - "the VM." - ) - - -if __name__ == "__main__": - main() diff --git a/scripts/templates/cloud_init_comfystream.yaml.j2 b/scripts/templates/cloud_init_comfystream.yaml.j2 deleted file mode 100644 index 8b24493e..00000000 --- a/scripts/templates/cloud_init_comfystream.yaml.j2 +++ /dev/null @@ -1,24 +0,0 @@ -write_files: - # Add reverse proxy configuration for ComfyUI with basic authentication - - path: /etc/caddy/comfystream.caddy - encoding: b64 - content: {{ comfystream_caddy_placeholder }} - -runcmd: - # Setup auth proxy for ComfyUI (requires port 8189 to be open) - - curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg - - curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list - - sudo apt update - - sudo apt install -y caddy openssl - - sudo mkdir -p /etc/caddy/certificates - - | - sudo openssl req -x509 -newkey rsa:4096 -keyout /etc/caddy/certificates/selfsigned.key \ - -out /etc/caddy/certificates/selfsigned.crt -days 365 -nodes \ - -subj "/C=US/ST=State/L=City/O=Company/OU=Org/CN=localhost" - - sudo chown -R caddy:caddy /etc/caddy/certificates - - grep -qxF 'import /etc/caddy/comfystream.caddy' /etc/caddy/Caddyfile || echo 'import /etc/caddy/comfystream.caddy' | sudo tee -a /etc/caddy/Caddyfile > /dev/null - - sudo systemctl restart caddy - # Install, configure, and start ComfyUI with Comfystream - - docker pull {{ docker_image_placeholder }} - - mkdir -p ~/models/ComfyUI--models ~/models/ComfyUI--output - - docker run --restart unless-stopped --stop-timeout 300 --gpus all --name comfystream -v ${HOME}/models/ComfyUI--models:/workspace/ComfyUI/models -v ${HOME}/models/ComfyUI--output:/workspace/ComfyUI/output -p 3000:3000 -p 8188:8188 -p 8889:8889 {{ docker_image_placeholder }} --download-models --build-engines --server diff --git a/server/__init__.py b/server/api/__init__.py similarity index 100% rename from server/__init__.py rename to server/api/__init__.py diff --git a/server/api/api.py b/server/api/api.py new file mode 100644 index 00000000..ac4a623f --- /dev/null +++ b/server/api/api.py @@ -0,0 +1,363 @@ +from aiohttp import web +import asyncio + +from api.nodes.nodes import list_nodes, install_node, delete_node, toggle_node +from api.models.models import list_models, add_model, delete_model +from api.settings.settings import set_twilio_account_info, restart_comfyui +from comfystream.pipeline import Pipeline + +from comfy.nodes.package import _comfy_nodes, import_all_nodes_in_workspace + +from api.nodes.nodes import force_import_all_nodes_in_workspace +#use a different node import +import_all_nodes_in_workspace = force_import_all_nodes_in_workspace + +def add_mgmt_api_routes(app): + app.router.add_get("/settings/nodes/list", nodes) + app.router.add_post("/settings/nodes/list", nodes) + app.router.add_post("/settings/nodes/install", install_nodes) + app.router.add_post("/settings/nodes/delete", delete_nodes) + app.router.add_post("/settings/nodes/toggle", toggle_nodes) + + app.router.add_get("/settings/models/list", models) + app.router.add_post("/settings/models/list", models) + app.router.add_post("/settings/models/add", add_models) + app.router.add_post("/settings/models/delete", delete_models) + + app.router.add_post("/settings/comfystream/reload", reload) + app.router.add_post("/settings/comfyui/restart", restart_comfyui_process) + app.router.add_post("/settings/turn/server/set/account", set_account_info) + + +async def reload(request): + ''' + Reload ComfyUI environment + + ''' + + #reset embedded client + await request.app["pipeline"].cleanup() + + #reset imports to clear imported nodes + global _comfy_nodes + import_all_nodes_in_workspace = force_import_all_nodes_in_workspace + _comfy_nodes = import_all_nodes_in_workspace() + + #reload pipeline + request.app["pipeline"] = Pipeline(cwd=request.app["workspace"], disable_cuda_malloc=True, gpu_only=True) + + #reset webrtc connections + pcs = request.app["pcs"] + coros = [pc.close() for pc in pcs] + await asyncio.gather(*coros) + pcs.clear() + + return web.json_response({"success": True, "error": None}) + +async def nodes(request): + ''' + List all custom nodes in the workspace + + # Example response: + { + "error": null, + "nodes": + [ + { + "name": ComfyUI-Custom-Node, + "version": "0.0.1", + "url": "https://github.com/custom-node-maker/ComfyUI-Custom-Node", + "branch": "main", + "commit": "uasfg98", + "update_available": false, + }, + { + ... + }, + { + ... + } + ] + } + + ''' + workspace_dir = request.app["workspace"] + try: + nodes = await list_nodes(workspace_dir) + return web.json_response({"success": True, "error": None, "nodes": nodes}) + except Exception as e: + return web.json_response({"success": False, "error": str(e), "nodes": nodes}, status=500) + +async def install_nodes(request): + ''' + Install ComfyUI custom node from git repository. + + Installs requirements.txt from repository if present + + # Parameters: + url: url of the git repository + branch: branch of the git repository + depdenencies: comma separated list of dependencies to install with pip (optional) + + # Example request: + [ + { + "url": "https://github.com/custom-node-maker/ComfyUI-Custom-Node", + "branch": "main" + }, + { + "url": "https://github.com/custom-node-maker/ComfyUI-Custom-Node", + "branch": "main", + "dependencies": "requests, numpy" + } + ] + ''' + workspace_dir = request.app["workspace"] + try: + nodes = await request.json() + installed_nodes = [] + for node in nodes: + await install_node(node, workspace_dir) + installed_nodes.append(node['url']) + + return web.json_response({"success": True, "error": None, "installed_nodes": installed_nodes}) + except Exception as e: + return web.json_response({"success": False, "error": str(e), "installed_nodes": installed_nodes}, status=500) + +async def delete_nodes(request): + ''' + Delete ComfyUI custom node + + # Parameters: + name: name of the repository (e.g. ComfyUI-Custom-Node for url "https://github.com/custom-node-maker/ComfyUI-Custom-Node") + + # Example request: + [ + { + "name": "ComfyUI-Custom-Node" + }, + { + ... + } + ] + ''' + workspace_dir = request.app["workspace"] + try: + nodes = await request.json() + deleted_nodes = [] + for node in nodes: + await delete_node(node, workspace_dir) + deleted_nodes.append(node['name']) + return web.json_response({"success": True, "error": None, "deleted_nodes": deleted_nodes}) + except Exception as e: + return web.json_response({"success": False, "error": str(e), "deleted_nodes": deleted_nodes}, status=500) + +async def toggle_nodes(request): + ''' + Enable/Disable ComfyUI custom node + + # Parameters: + name: name of the node (e.g. ComfyUI-Custom-Node) + + # Example request: + [ + { + "name": "ComfyUI-Custom-Node" + }, + { + ... + } + ] + ''' + workspace_dir = request.app["workspace"] + try: + nodes = await request.json() + toggled_nodes = [] + for node in nodes: + await toggle_node(node, workspace_dir) + toggled_nodes.append(node['name']) + return web.json_response({"success": True, "error": None, "toggled_nodes": toggled_nodes}) + except Exception as e: + return web.json_response({"success": False, "error": str(e), "toggled_nodes": toggled_nodes}, status=500) + +async def models(request): + ''' + List all custom models in the workspace + + # Example response: + { + "error": null, + "models": + { + "checkpoints": [ + { + "name": "dreamshaper-8.safetensors", + "path": "SD1.5/dreamshaper-8.safetensors", + "type": "checkpoint", + "downloading": false" + } + ], + "controlnet": [ + { + "name": "controlnet.sd15.safetensors", + "path": "SD1.5/controlnet.sd15.safetensors", + "type": "controlnet", + "downloading": false" + } + ], + "unet": [ + { + "name": "unet.sd15.safetensors", + "path": "SD1.5/unet.sd15.safetensors", + "type": "unet", + "downloading": false" + } + ], + "vae": [ + { + "name": "vae.safetensors", + "path": "vae.safetensors", + "type": "vae", + "downloading": false" + } + ], + "tensorrt": [ + { + "name": "model.trt", + "path": "model.trt", + "type": "tensorrt", + "downloading": false" + } + ] + } + } + + ''' + workspace_dir = request.app["workspace"] + try: + models = await list_models(workspace_dir) + return web.json_response({"error": None, "models": models}) + except Exception as e: + return web.json_response({"error": str(e), "models": {}}, status=500) + +async def add_models(request): + ''' + Download models from url + + # Parameters: + url: url of the git repository + type: type of model (e.g. checkpoints, controlnet, unet, vae, onnx, tensorrt) + path: path of the model. supports up to 1 subfolder (e.g. SD1.5/newmodel.safetensors) + + # Example request: + [ + { + "url": "http://url.to/model.safetensors", + "type": "checkpoints" + }, + { + "url": "http://url.to/controlnet.super.safetensors", + "type": "controlnet", + "path": "SD1.5/controlnet.super.safetensors" + } + ] + ''' + workspace_dir = request.app["workspace"] + try: + models = await request.json() + added_models = [] + for model in models: + await add_model(model, workspace_dir) + added_models.append(model['url']) + return web.json_response({"success": True, "error": None, "added_models": added_models}) + except Exception as e: + return web.json_response({"success": False, "error": str(e), "added_nodes": added_models}, status=500) + +async def delete_models(request): + ''' + Delete model + + # Parameters: + type: type of model (e.g. checkpoints, controlnet, unet, vae, onnx, tensorrt) + path: path of the model. supports up to 1 subfolder (e.g. SD1.5/newmodel.safetensors) + + # Example request: + [ + { + "type": "checkpoints", + "path": "model.safetensors" + }, + { + "type": "controlnet", + "path": "SD1.5/controlnet.super.safetensors" + } + ] + ''' + workspace_dir = request.app["workspace"] + try: + models = await request.json() + deleted_models = [] + for model in models: + await delete_model(model, workspace_dir) + deleted_models.append(model['path']) + return web.json_response({"success": True, "error": None, "deleted_models": deleted_models}) + except Exception as e: + return web.json_response({"success": False, "error": str(e), "deleted_models": deleted_models}, status=500) + +async def set_account_info(request): + ''' + Set account info for ice server providers + + # Parameters: + type: account type (e.g. twilio) + account_id: account id from provider + auth_token: auth token from provider + + # Example request: + [ + { + "type": "twilio", + "account_id": "ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + "auth_token": "your_auth_token" + }, + { + ... + } + ] + + ''' + try: + accounts = await request.json() + accounts_updated = [] + for account in accounts: + if 'type' in account: + if account['type'] == 'twilio': + await set_twilio_account_info(account) + accounts_updated.append(account['type']) + return web.json_response({"success": True, "error": None, "accounts_updated": accounts_updated}) + except Exception as e: + return web.json_response({"success": False, "error": str(e), "accounts_updated": accounts_updated}, status=500) + +async def restart_comfyui_process(request): + ''' + Restart comfyui process + + # Parameters: + None + + # Example request: + [ + { + "restart": "comfyui", + } + ] + + ''' + print("restarting comfyui process") + try: + restart_process = await request.json() + if restart_process["restart"] == "comfyui": + await restart_comfyui(request.app["workspace"]) + return web.json_response({"success": True, "error": None}) + except Exception as e: + return web.json_response({"success": False, "error": str(e)}, status=500) \ No newline at end of file diff --git a/server/api/models/__init__.py b/server/api/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/api/models/models.py b/server/api/models/models.py new file mode 100644 index 00000000..e7d2e4a6 --- /dev/null +++ b/server/api/models/models.py @@ -0,0 +1,137 @@ +import asyncio +from pathlib import Path +import os +import logging +from aiohttp import ClientSession + +logger = logging.getLogger(__name__) + +async def list_models(workspace_dir): + models_path = Path(os.path.join(workspace_dir, "models")) + models_path.mkdir(parents=True, exist_ok=True) + os.chdir(models_path) + + model_types = ["checkpoints", "controlnet", "unet", "vae", "onnx", "tensorrt"] + + models = {} + try: + for model_type in models_path.iterdir(): + model_name = "" + model_subfolder = "" + model_type_name = model_type.name + if model_type.is_dir(): + models[model_type_name] = [] + for model in model_type.iterdir(): + if model.is_dir(): + #models in subfolders (e.g. checkpoints/sd1.5/model.safetensors) + for submodel in model.iterdir(): + if submodel.is_file(): + model_name = submodel.name + model_subfolder = model.name + else: + #models not in subfolders (e.g. checkpoints/model.safetensors) + logger.info(f"model: {model.name}") + model_name = model.name + model_subfolder = "" + + #add model to list + model_info = await create_model_info(model_name, model_subfolder, model_type_name) + models[model_type_name].append(model_info) + else: + if not model_type.name in model_types: + models["none"] = [] + + model_name = model_type_name + model_subfolder = "" + + #add model to list + model_info = await create_model_info(model_name, model_subfolder, model_type_name) + models[model_type_name].append(model_info) + except Exception as e: + logger.error(f"error listing models: {e}") + raise Exception(f"error listing models: {e}") + return models + +async def create_model_info(model, model_subfolder, model_type): + model_path = f"{model_subfolder}/{model}" if model_subfolder else model + logger.info(f"adding info for model: {model_type}/{model_path}") + model_info = { + "name": model, + "path": model_path, + "type": model_type, + "downloading": os.path.exists(f"{model_path}.downloading") + } + return model_info + +async def add_model(model, workspace_dir): + if not 'url' in model: + raise Exception("model url is required") + if not 'type' in model: + raise Exception("model type is required (e.g. checkpoints, controlnet, unet, vae, onnx, tensorrt)") + + try: + model_name = model['url'].split("/")[-1] + model_path = Path(os.path.join(workspace_dir, "models", model['type'], model_name)) + #if specified, use the model path from the model dict (e.g. sd1.5/model.safetensors will put model.safetensors in models/checkpoints/sd1.5) + if 'path' in model: + model_path = Path(os.path.join(workspace_dir, "models", model['type'], model['path'])) + logger.info(f"model path: {model_path}") + + # check path is in workspace_dir, raises value error if not + model_path.resolve().relative_to(Path(os.path.join(workspace_dir, "models"))) + os.makedirs(model_path.parent, exist_ok=True) + # start downloading the model in background without blocking + asyncio.create_task(download_model(model['url'], model_path)) + except Exception as e: + os.remove(model_path)+".downloading" + raise Exception(f"error downloading model: {e}") + +async def delete_model(model, workspace_dir): + if not 'type' in model: + raise Exception("model type is required (e.g. checkpoints, controlnet, unet, vae, onnx, tensorrt)") + if not 'path' in model: + raise Exception("model path is required") + try: + model_path = Path(os.path.join(workspace_dir, "models", model['type'], model['path'])) + #check path is in workspace_dir, raises value error if not + model_path.resolve().relative_to(Path(os.path.join(workspace_dir, "models"))) + + os.remove(model_path) + except Exception as e: + raise Exception(f"error deleting model: {e}") + +async def download_model(url: str, save_path: Path): + try: + temp_file = save_path.with_suffix(save_path.suffix + ".downloading") + print("downloading") + async with ClientSession() as session: + logger.info(f"downloading model from {url} to {save_path}") + # Create empty file to track download in process + model_name = os.path.basename(save_path) + + open(temp_file, "w").close() + + async with session.get(url) as response: + if response.status == 200: + total_size = int(response.headers.get('Content-Length', 0)) + total_downloaded = 0 + last_logged_percentage = -1 # Ensures first log at 1% + with open(save_path, "wb") as f: + while chunk := await response.content.read(4096): # Read in chunks of 1KB + f.write(chunk) + total_downloaded += len(chunk) + # Calculate percentage and log only if it has increased by 1% + percentage = (total_downloaded / total_size) * 100 + if int(percentage) > last_logged_percentage: + last_logged_percentage = int(percentage) + logger.info(f"Downloaded {total_downloaded} of {total_size} bytes ({percentage:.2f}%) of {model_name}") + + #remove download in process file + os.remove(temp_file) + logger.info(f"Model downloaded and saved to {save_path}") + else: + raise print(f"Failed to download model. HTTP Status: {response.status}") + except Exception as e: + #remove download in process file + logger.error(f"error downloading model: {str(e)}") + os.remove(temp_file) \ No newline at end of file diff --git a/server/api/nodes/__init__.py b/server/api/nodes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/api/nodes/nodes.py b/server/api/nodes/nodes.py new file mode 100644 index 00000000..8e40984c --- /dev/null +++ b/server/api/nodes/nodes.py @@ -0,0 +1,227 @@ +from pathlib import Path +import os +import json +from git import Repo +import logging +import subprocess +import shutil + +logger = logging.getLogger(__name__) + +async def list_nodes(workspace_dir): + custom_nodes_path = Path(os.path.join(workspace_dir, "custom_nodes")) + custom_nodes_path.mkdir(parents=True, exist_ok=True) + os.chdir(custom_nodes_path) + + nodes = [] + for node in custom_nodes_path.iterdir(): + if node.name == "__pycache__": + continue + + if node.is_dir(): + logger.info(f"getting info for node: { node.name}") + node_info = { + "name": node.name, + "version": "unknown", + "url": "unknown", + "branch": "unknown", + "commit": "unknown", + "update_available": "unknown", + "disabled": False, + } + if node.name.endswith(".disabled"): + node_info["disabled"] = True + node_info["name"] = node.name[:-9] + + #include VERSION if set in file + version_file = os.path.join(custom_nodes_path, node.name, "VERSION") + if os.path.exists(version_file): + node_info["version"] = json.dumps(open(version_file).readline().strip()) + + #include git info if available + try: + repo = Repo(node) + node_info["url"] = repo.remotes.origin.url.replace(".git","") + node_info["commit"] = repo.head.commit.hexsha[:7] + if not repo.head.is_detached: + node_info["branch"] = repo.active_branch.name + fetch_info = repo.remotes.origin.fetch(repo.active_branch.name) + node_info["update_available"] = repo.head.commit.hexsha[:7] != fetch_info[0].commit.hexsha[:7] + else: + node_info["branch"] = "detached" + + except Exception as e: + logger.info(f"error getting repo info for {node.name} {e}") + + nodes.append(node_info) + + return nodes + +async def install_node(node, workspace_dir): + ''' + install ComfyUI custom node in git repository. + + installs requirements.txt from repository if present + + # Paramaters + url: url of the git repository + branch: branch to install + dependencies: comma separated list of pip dependencies to install + ''' + + custom_nodes_path = Path(os.path.join(workspace_dir, "custom_nodes")) + custom_nodes_path.mkdir(parents=True, exist_ok=True) + os.chdir(custom_nodes_path) + node_url = node.get("url", "") + if node_url == "": + raise ValueError("url is required") + + if not ".git" in node_url: + node_url = f"{node_url}.git" + + try: + dir_name = node_url.split("/")[-1].replace(".git", "") + node_path = custom_nodes_path / dir_name + if not node_path.exists(): + # Clone and install the repository if it doesn't already exist + logger.info(f"installing {dir_name}...") + repo = Repo.clone_from(node["url"], node_path, depth=1) + if "branch" in node and node["branch"] != "": + repo.git.checkout(node['branch']) + else: + # Update the repository if it already exists + logger.info(f"updating node {dir_name}") + repo = Repo(node_path) + repo.remotes.origin.fetch() + branch = node.get("branch", "") + if branch == "": + branch = repo.remotes.origin.refs[0].remote_head + + repo.git.checkout(branch) + + # Install requirements if present + requirements_file = node_path / "requirements.txt" + if requirements_file.exists(): + subprocess.run(["conda", "run", "-n", "comfystream", "pip", "install", "-r", str(requirements_file)], check=True) + + # Install additional dependencies if specified + if "dependencies" in node and node["dependencies"] != "": + for dep in node["dependencies"].split(','): + subprocess.run(["conda", "run", "-n", "comfystream", "pip", "install", dep.strip()], check=True) + + except Exception as e: + logger.error(f"Error installing {dir_name} {e}") + raise e + +async def delete_node(node, workspace_dir): + custom_nodes_path = Path(os.path.join(workspace_dir, "custom_nodes")) + custom_nodes_path.mkdir(parents=True, exist_ok=True) + os.chdir(custom_nodes_path) + if "name" not in node: + raise ValueError("name is required") + + node_path = custom_nodes_path / node["name"] + if not node_path.exists(): + raise ValueError(f"node {node['name']} does not exist") + try: + #delete the folder and all its contents. ignore_errors allows readonly files to be deleted + logger.info(f"deleting node {node['name']}") + shutil.rmtree(node_path, ignore_errors=True) + except Exception as e: + logger.error(f"error deleting node {node['name']}") + raise Exception(f"error deleting node: {e}") + +async def toggle_node(node, workspace_dir): + custom_nodes_path = Path(os.path.join(workspace_dir, "custom_nodes")) + custom_nodes_path.mkdir(parents=True, exist_ok=True) + os.chdir(custom_nodes_path) + if "name" not in node: + logger.error("name is required") + raise ValueError("name is required") + + node_path = custom_nodes_path / node["name"] + is_disabled = False + #confirm if enabled node exists + logger.info(f"toggling node { node['name'] }") + if not node_path.exists(): + #try with .disabled + node_path = custom_nodes_path / str(node['name']+".disabled") + logger.info(f"checking if node { node['name'] } is disabled") + if not node_path.exists(): + #node does not exist as enabled or disabled + logger.info(f"node { node['name'] }.disabled does not exist") + raise ValueError(f"node { node['name'] } does not exist") + else: + #node is disabled, so we need to enable it + logger.error(f"node { node['name'] } is disabled") + is_disabled = True + else: + logger.info(f"node { node['name'] } is enabled") + + try: + if is_disabled: + #rename the folder to remove .disabled + logger.info(f"enabling node {node['name']}") + new_name = node_path.with_name(node["name"]) + shutil.move(str(node_path), str(new_name)) + else: + #rename the folder to add .disabled + logger.info(f"disbling node {node['name']}") + new_name = node_path.with_name(node["name"]+".disabled") + shutil.move(str(node_path), str(new_name)) + except Exception as e: + logger.error(f"error {action} node {node['name']}: {e}") + raise Exception(f"error {action} node: {e}") + +from comfy.nodes.package import ExportedNodes +from comfy.nodes.package import _comfy_nodes, _import_and_enumerate_nodes_in_module +from functools import reduce +from importlib.metadata import entry_points +import types + +def force_import_all_nodes_in_workspace(vanilla_custom_nodes=True, raise_on_failure=False) -> ExportedNodes: + # now actually import the nodes, to improve control of node loading order + from comfy_extras import nodes as comfy_extras_nodes # pylint: disable=absolute-import-used + from comfy.cli_args import args + from comfy.nodes import base_nodes + from comfy.nodes.vanilla_node_importing import mitigated_import_of_vanilla_custom_nodes + + base_and_extra = reduce(lambda x, y: x.update(y), + map(lambda module_inner: _import_and_enumerate_nodes_in_module(module_inner, raise_on_failure=raise_on_failure), [ + # this is the list of default nodes to import + base_nodes, + comfy_extras_nodes + ]), + ExportedNodes()) + custom_nodes_mappings = ExportedNodes() + + if args.disable_all_custom_nodes: + logging.info("Loading custom nodes was disabled, only base and extra nodes were loaded") + _comfy_nodes.update(base_and_extra) + return _comfy_nodes + + # load from entrypoints + for entry_point in entry_points().select(group='comfyui.custom_nodes'): + # Load the module associated with the current entry point + try: + module = entry_point.load() + except ModuleNotFoundError as module_not_found_error: + logging.error(f"A module was not found while importing nodes via an entry point: {entry_point}. Please ensure the entry point in setup.py is named correctly", exc_info=module_not_found_error) + continue + + # Ensure that what we've loaded is indeed a module + if isinstance(module, types.ModuleType): + custom_nodes_mappings.update( + _import_and_enumerate_nodes_in_module(module, print_import_times=True)) + + # load the vanilla custom nodes last + if vanilla_custom_nodes: + custom_nodes_mappings += mitigated_import_of_vanilla_custom_nodes() + + # don't allow custom nodes to overwrite base nodes + custom_nodes_mappings -= base_and_extra + + _comfy_nodes.update(base_and_extra + custom_nodes_mappings) + + return _comfy_nodes + diff --git a/server/api/settings/__init__.py b/server/api/settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/api/settings/settings.py b/server/api/settings/settings.py new file mode 100644 index 00000000..6cf40824 --- /dev/null +++ b/server/api/settings/settings.py @@ -0,0 +1,14 @@ +import os +from pathlib import Path +import psutil +import subprocess +import sys + +async def set_twilio_account_info(account_sid, auth_token): + if not account_sid is None: + os.environ["TWILIO_ACCOUNT_SID"] = account_sid + if not auth_token is None: + os.environ["TWILIO_AUTH_TOKEN"] = auth_token + +async def restart_comfyui(workspace_dir): + subprocess.run(["supervisorctl", "restart", "comfyui"], check=False) diff --git a/server/app.py b/server/app.py index 83bc943f..e0921aae 100644 --- a/server/app.py +++ b/server/app.py @@ -4,14 +4,12 @@ import logging import os import sys - import torch # Initialize CUDA before any other imports to prevent core dump. if torch.cuda.is_available(): torch.cuda.init() - from aiohttp import web from aiortc import ( MediaStreamTrack, @@ -22,10 +20,10 @@ ) from aiortc.codecs import h264 from aiortc.rtcrtpsender import RTCRtpSender -from pipeline import Pipeline +from comfystream.pipeline import Pipeline from twilio.rest import Client -from utils import patch_loop_datagram, add_prefix_to_app_routes, FPSMeter -from metrics import MetricsManager, StreamStatsManager +from comfystream.server.utils import patch_loop_datagram, add_prefix_to_app_routes, FPSMeter +from comfystream.server.metrics import MetricsManager, StreamStatsManager import time logger = logging.getLogger(__name__) @@ -470,6 +468,10 @@ async def on_shutdown(app: web.Application): ) app.router.add_get("/metrics", app["metrics_manager"].metrics_handler) + #add management api routes + from api.api import add_mgmt_api_routes + add_mgmt_api_routes(app) + # Add hosted platform route prefix. # NOTE: This ensures that the local and hosted experiences have consistent routes. add_prefix_to_app_routes(app, "/live") @@ -485,4 +487,5 @@ def force_print(*args, **kwargs): if args.comfyui_inference_log_level: app["comfui_inference_log_level"] = args.comfyui_inference_log_level + web.run_app(app, host=args.host, port=int(args.port), print=force_print) diff --git a/src/comfystream/__init__.py b/src/comfystream/__init__.py index e69de29b..b58bf2e4 100644 --- a/src/comfystream/__init__.py +++ b/src/comfystream/__init__.py @@ -0,0 +1,14 @@ +from .client import ComfyStreamClient +from .pipeline import Pipeline +from .server.utils import temporary_log_level +from .server.utils import FPSMeter +from .server.metrics import MetricsManager, StreamStatsManager + +__all__ = [ + 'ComfyStreamClient', + 'Pipeline', + 'temporary_log_level', + 'FPSMeter', + 'MetricsManager', + 'StreamStatsManager' +] diff --git a/server/pipeline.py b/src/comfystream/pipeline.py similarity index 61% rename from server/pipeline.py rename to src/comfystream/pipeline.py index d781639e..a5776dfc 100644 --- a/server/pipeline.py +++ b/src/comfystream/pipeline.py @@ -3,10 +3,10 @@ import numpy as np import asyncio import logging +from typing import Any, Dict, Union, List, Optional -from typing import Any, Dict, Union, List from comfystream.client import ComfyStreamClient -from utils import temporary_log_level +from comfystream.server.utils import temporary_log_level WARMUP_RUNS = 5 @@ -14,16 +14,27 @@ class Pipeline: - def __init__(self, width=512, height=512, comfyui_inference_log_level: int = None, **kwargs): + """A pipeline for processing video and audio frames using ComfyUI. + + This class provides a high-level interface for processing video and audio frames + through a ComfyUI-based processing pipeline. It handles frame preprocessing, + postprocessing, and queue management. + """ + + def __init__(self, width: int = 512, height: int = 512, + comfyui_inference_log_level: Optional[int] = None, **kwargs): """Initialize the pipeline with the given configuration. + Args: + width: Width of the video frames (default: 512) + height: Height of the video frames (default: 512) comfyui_inference_log_level: The logging level for ComfyUI inference. Defaults to None, using the global ComfyUI log level. **kwargs: Additional arguments to pass to the ComfyStreamClient """ self.client = ComfyStreamClient(**kwargs) - self.width = kwargs.get("width", 512) - self.height = kwargs.get("height", 512) + self.width = width + self.height = height self.video_incoming_frames = asyncio.Queue() self.audio_incoming_frames = asyncio.Queue() @@ -33,7 +44,8 @@ def __init__(self, width=512, height=512, comfyui_inference_log_level: int = Non self._comfyui_inference_log_level = comfyui_inference_log_level async def warm_video(self): - # Create dummy frame with the CURRENT resolution settings (which might have been updated via control channel) + """Warm up the video processing pipeline with dummy frames.""" + # Create dummy frame with the CURRENT resolution settings dummy_frame = av.VideoFrame() dummy_frame.side_data.input = torch.randn(1, self.height, self.width, 3) @@ -44,6 +56,7 @@ async def warm_video(self): await self.client.get_video_output() async def warm_audio(self): + """Warm up the audio processing pipeline with dummy frames.""" dummy_frame = av.AudioFrame() dummy_frame.side_data.input = np.random.randint(-32768, 32767, int(48000 * 0.5), dtype=np.int16) # TODO: adds a lot of delay if it doesn't match the buffer size, is warmup needed? dummy_frame.sample_rate = 48000 @@ -53,60 +66,121 @@ async def warm_audio(self): await self.client.get_audio_output() async def set_prompts(self, prompts: Union[Dict[Any, Any], List[Dict[Any, Any]]]): + """Set the processing prompts for the pipeline. + + Args: + prompts: Either a single prompt dictionary or a list of prompt dictionaries + """ if isinstance(prompts, list): await self.client.set_prompts(prompts) else: await self.client.set_prompts([prompts]) async def update_prompts(self, prompts: Union[Dict[Any, Any], List[Dict[Any, Any]]]): + """Update the existing processing prompts. + + Args: + prompts: Either a single prompt dictionary or a list of prompt dictionaries + """ if isinstance(prompts, list): await self.client.update_prompts(prompts) else: await self.client.update_prompts([prompts]) async def put_video_frame(self, frame: av.VideoFrame): + """Queue a video frame for processing. + + Args: + frame: The video frame to process + """ frame.side_data.input = self.video_preprocess(frame) frame.side_data.skipped = True self.client.put_video_input(frame) await self.video_incoming_frames.put(frame) async def put_audio_frame(self, frame: av.AudioFrame): + """Queue an audio frame for processing. + + Args: + frame: The audio frame to process + """ frame.side_data.input = self.audio_preprocess(frame) frame.side_data.skipped = True self.client.put_audio_input(frame) await self.audio_incoming_frames.put(frame) def video_preprocess(self, frame: av.VideoFrame) -> Union[torch.Tensor, np.ndarray]: + """Preprocess a video frame before processing. + + Args: + frame: The video frame to preprocess + + Returns: + The preprocessed frame as a tensor or numpy array + """ frame_np = frame.to_ndarray(format="rgb24").astype(np.float32) / 255.0 return torch.from_numpy(frame_np).unsqueeze(0) def audio_preprocess(self, frame: av.AudioFrame) -> Union[torch.Tensor, np.ndarray]: + """Preprocess an audio frame before processing. + + Args: + frame: The audio frame to preprocess + + Returns: + The preprocessed frame as a tensor or numpy array + """ return frame.to_ndarray().ravel().reshape(-1, 2).mean(axis=1).astype(np.int16) def video_postprocess(self, output: Union[torch.Tensor, np.ndarray]) -> av.VideoFrame: + """Postprocess a video frame after processing. + + Args: + output: The processed output tensor or numpy array + + Returns: + The postprocessed video frame + """ return av.VideoFrame.from_ndarray( (output * 255.0).clamp(0, 255).to(dtype=torch.uint8).squeeze(0).cpu().numpy() ) def audio_postprocess(self, output: Union[torch.Tensor, np.ndarray]) -> av.AudioFrame: + """Postprocess an audio frame after processing. + + Args: + output: The processed output tensor or numpy array + + Returns: + The postprocessed audio frame + """ return av.AudioFrame.from_ndarray(np.repeat(output, 2).reshape(1, -1)) - async def get_processed_video_frame(self): - # TODO: make it generic to support purely generative video cases + # TODO: make it generic to support purely generative video cases + async def get_processed_video_frame(self) -> av.VideoFrame: + """Get the next processed video frame. + + Returns: + The processed video frame + """ async with temporary_log_level("comfy", self._comfyui_inference_log_level): out_tensor = await self.client.get_video_output() frame = await self.video_incoming_frames.get() while frame.side_data.skipped: frame = await self.video_incoming_frames.get() - processed_frame = self.video_postprocess(out_tensor) + processed_frame = self.video_postprocess(out_tensor) processed_frame.pts = frame.pts processed_frame.time_base = frame.time_base return processed_frame - async def get_processed_audio_frame(self): - # TODO: make it generic to support purely generative audio cases and also add frame skipping + async def get_processed_audio_frame(self) -> av.AudioFrame: + """Get the next processed audio frame. + + Returns: + The processed audio frame + """ frame = await self.audio_incoming_frames.get() if frame.samples > len(self.processed_audio_buffer): async with temporary_log_level("comfy", self._comfyui_inference_log_level): @@ -123,9 +197,14 @@ async def get_processed_audio_frame(self): return processed_frame async def get_nodes_info(self) -> Dict[str, Any]: - """Get information about all nodes in the current prompt including metadata.""" + """Get information about all nodes in the current prompt including metadata. + + Returns: + Dictionary containing node information + """ nodes_info = await self.client.get_available_nodes() return nodes_info async def cleanup(self): - await self.client.cleanup() + """Clean up resources used by the pipeline.""" + await self.client.cleanup() \ No newline at end of file diff --git a/src/comfystream/scripts/setup_models.py b/src/comfystream/scripts/setup_models.py index f99d7c13..d03018b4 100644 --- a/src/comfystream/scripts/setup_models.py +++ b/src/comfystream/scripts/setup_models.py @@ -79,7 +79,17 @@ def setup_directories(workspace_dir): # Create base directories workspace_dir.mkdir(parents=True, exist_ok=True) models_dir = workspace_dir / "models" - models_dir.mkdir(parents=True, exist_ok=True) + + # Check if models directory exists or is a symbolic link + if not models_dir.exists() and not models_dir.is_symlink(): + print(f"Creating models directory at {models_dir}") + models_dir.mkdir(parents=True, exist_ok=True) + else: + print(f"Models directory already exists or is a symbolic link at {models_dir}") + + # Resolve the target of the symbolic link if it exists + if models_dir.is_symlink(): + models_dir = models_dir.resolve() # Create model subdirectories model_dirs = [ @@ -91,7 +101,8 @@ def setup_directories(workspace_dir): "LLM", ] for dir_name in model_dirs: - (models_dir / dir_name).mkdir(parents=True, exist_ok=True) + subdir = models_dir / dir_name + subdir.mkdir(parents=True, exist_ok=True) def setup_models(): args = parse_args() diff --git a/src/comfystream/server/__init__.py b/src/comfystream/server/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/metrics/__init__.py b/src/comfystream/server/metrics/__init__.py similarity index 100% rename from server/metrics/__init__.py rename to src/comfystream/server/metrics/__init__.py diff --git a/server/metrics/prometheus_metrics.py b/src/comfystream/server/metrics/prometheus_metrics.py similarity index 100% rename from server/metrics/prometheus_metrics.py rename to src/comfystream/server/metrics/prometheus_metrics.py diff --git a/server/metrics/stream_stats.py b/src/comfystream/server/metrics/stream_stats.py similarity index 100% rename from server/metrics/stream_stats.py rename to src/comfystream/server/metrics/stream_stats.py diff --git a/server/utils/__init__.py b/src/comfystream/server/utils/__init__.py similarity index 100% rename from server/utils/__init__.py rename to src/comfystream/server/utils/__init__.py diff --git a/server/utils/fps_meter.py b/src/comfystream/server/utils/fps_meter.py similarity index 98% rename from server/utils/fps_meter.py rename to src/comfystream/server/utils/fps_meter.py index ce94317b..87e75d46 100644 --- a/server/utils/fps_meter.py +++ b/src/comfystream/server/utils/fps_meter.py @@ -4,7 +4,7 @@ import logging import time from collections import deque -from metrics import MetricsManager +from comfystream.server.metrics import MetricsManager logger = logging.getLogger(__name__) diff --git a/server/utils/utils.py b/src/comfystream/server/utils/utils.py similarity index 99% rename from server/utils/utils.py rename to src/comfystream/server/utils/utils.py index c7a7ac30..716ce333 100644 --- a/server/utils/utils.py +++ b/src/comfystream/server/utils/utils.py @@ -5,6 +5,7 @@ import types import logging from aiohttp import web + from typing import List, Tuple from contextlib import asynccontextmanager @@ -65,7 +66,6 @@ def add_prefix_to_app_routes(app: web.Application, prefix: str): new_path = prefix + route.resource.canonical app.router.add_route(route.method, new_path, route.handler) - @asynccontextmanager async def temporary_log_level(logger_name: str, level: int): """Temporarily set the log level of a logger. @@ -83,3 +83,4 @@ async def temporary_log_level(logger_name: str, level: int): finally: if level is not None: logger.setLevel(original_level) + diff --git a/ui/package-lock.json b/ui/package-lock.json index 69712e5a..d260fddd 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "ui", - "version": "0.0.5", + "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ui", - "version": "0.0.5", + "version": "0.1.0", "dependencies": { "@hookform/resolvers": "^3.9.1", "@radix-ui/react-dialog": "^1.1.6", diff --git a/ui/package.json b/ui/package.json index a718fba1..eb610b7a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "ui", - "version": "0.0.5", + "version": "0.1.0", "private": true, "scripts": { "dev": "cross-env NEXT_PUBLIC_DEV=true next dev",