diff --git a/examples/.gitignore b/examples/.gitignore new file mode 100644 index 00000000000..513b29343f2 --- /dev/null +++ b/examples/.gitignore @@ -0,0 +1,11 @@ +# Runtime artifacts produced when amd_comfyui_rocm_tutorial.ipynb is executed. +# Everything in here is regenerable from the notebook + workflow JSONs. +outputs/ + +# Jupyter scratch. +.ipynb_checkpoints/ +*-checkpoint.ipynb +*-checkpoint.png + +# Local helpers / scratch. +_executed.ipynb diff --git a/examples/amd_comfyui_rocm_tutorial.html b/examples/amd_comfyui_rocm_tutorial.html new file mode 100644 index 00000000000..0d3e62366d9 --- /dev/null +++ b/examples/amd_comfyui_rocm_tutorial.html @@ -0,0 +1,9195 @@ + + + + + +_executed + + + + + + + + + + + + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ + +
+
+ +
+
+ +
+ + +
+
+ +
+ + +
+
+ +
+ + +
+
+ +
+ + +
+
+ +
+ + +
+
+ +
+
+ +
+ + +
+
+ +
+ + +
+
+ +
+ + +
+
+ +
+ + +
+
+ +
+ + +
+
+ +
+ + +
+
+ +
+ + +
+
+ +
+ + +
+
+ +
+ + +
+
+ +
+ + +
+
+ +
+
+ +
+ + +
+
+ + diff --git a/examples/amd_comfyui_rocm_tutorial.ipynb b/examples/amd_comfyui_rocm_tutorial.ipynb new file mode 100644 index 00000000000..4873cbe651e --- /dev/null +++ b/examples/amd_comfyui_rocm_tutorial.ipynb @@ -0,0 +1,1314 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "6d8b3d76", + "metadata": {}, + "source": [ + "\n", + "\n", + "# Running ComfyUI generative workflows from Python on AMD Instinct™ GPUs\n", + "\n", + "**Knowledge level**: Intermediate — comfortable with Python and the Linux command line; no prior ComfyUI experience required.\n", + "\n", + "**Content category**: Inference\n", + "\n", + "## Introduction\n", + "\n", + "**The goal of this tutorial is to teach you how to drive [ComfyUI](https://www.comfy.org/) generative-AI workflows entirely from Python — with no browser — on AMD Instinct™ data-center GPUs running the [ROCm™](https://rocm.docs.amd.com/) software stack.** ComfyUI ships an HTTP API, so instead of clicking around a graphical editor you launch the server, submit a workflow as a JSON document, wait for it to finish, and pull the results back into your notebook. This headless, scriptable approach is what you need on a remote server with no display, and it is the basis for automating generative pipelines in scripts, CI, or batch jobs on HPC and cloud infrastructure.\n", + "\n", + "To keep the example concrete, we run two real workflows on the same three input photographs: **Hunyuan3D v2.1** (turns a photo into a 3D mesh) and **Wan2.2 5B** (turns a photo into a short video). You submit both from Python and display their outputs inline.\n", + "\n", + "If some of these terms are new to you, don't worry — the next section (\"Key concepts\") explains each one in plain language before you run any code.\n", + "\n", + "> **Companion tutorial:** This notebook is a follow-up to [*Text-to-video generation with ComfyUI and an AMD Radeon™ GPU*](https://rocm.docs.amd.com/projects/ai-developer-hub/en/latest/notebooks/inference/t2v_comfyui_radeon.html). If you are brand new to ComfyUI, start there: it walks through the **interactive browser UI** on a single **consumer Radeon GPU**. This tutorial picks up where that one leaves off (see \"How this tutorial differs\" below).\n", + "\n", + "> **Companion blog post:** For background on ComfyUI performance work on AMD Instinct GPUs, see the AMD ROCm blog [*Accelerating ComfyUI Workflows on AMD Instinct MI355X GPUs with ROCm*](https://rocm.blogs.amd.com/artificial-intelligence/comfyui/README.html).\n", + "\n", + "### What you will learn\n", + "\n", + "The **core skills** — driving ComfyUI programmatically on AMD Instinct:\n", + "\n", + "- How to launch the ComfyUI server and confirm a ROCm + PyTorch runtime (**Part 1**).\n", + "- How ComfyUI's HTTP API works, and how a few small helpers submit a workflow, poll for completion, and retrieve outputs (**Part 1**).\n", + "- How to run two real diffusion workflows from Python and display their outputs (**Part 2**):\n", + " - **[Hunyuan3D v2.1](https://huggingface.co/tencent/Hunyuan3D-2.1)** — *image-to-3D*: a single photo becomes a 3D mesh (`.glb`).\n", + " - **[Wan2.2 5B image-to-video](https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged)** — *image-to-video*: a single photo becomes a short MP4 camera orbit.\n", + "\n", + "The **optional step**:\n", + "\n", + "- How to wrap a workflow in a small harness and measure throughput **on your own hardware** (**Part 3**).\n", + "\n", + "### Workflow at a glance\n", + "\n", + "1. **Set up** — launch the ComfyUI server inside a ROCm container and load the API helpers (Part 1).\n", + "2. **Generate** — run the Hunyuan3D and Wan2.2 workflows on three photos and visualize the results (Part 2).\n", + "3. **Measure** *(optional)* — benchmark a workflow on your own GPU (Part 3).\n", + "\n", + "### How this tutorial differs from the Radeon ComfyUI tutorial\n", + "\n", + "Both tutorials use ComfyUI on AMD GPUs, but they target different audiences and workflows:\n", + "\n", + "| | Radeon tutorial (prerequisite) | This tutorial |\n", + "|---|---|---|\n", + "| **Hardware** | Consumer **AMD Radeon™** GPU (e.g. RX 7900 XTX) | Data-center **AMD Instinct™** GPUs (MI300X / MI325X / MI350X / MI355X) |\n", + "| **How you drive ComfyUI** | Interactively, in the **browser UI** | **Headless, from Python** over the HTTP API (no browser) |\n", + "| **Workflows shown** | A single text-to-video workflow | **Two** workflows: image-to-3D and image-to-video |\n", + "| **Best for** | Learning ComfyUI hands-on at your desk | **Automating** generative workflows on remote servers and in pipelines |\n", + "\n", + "In short: the Radeon tutorial teaches you ComfyUI interactively; this one shows you how to **operate it as a service** on server-class hardware." + ] + }, + { + "cell_type": "markdown", + "id": "7c06fd00", + "metadata": {}, + "source": [ + "## Key concepts\n", + "\n", + "This section introduces the main pieces of technology used in this tutorial. Skim it once now; you can refer back to it whenever a term comes up later.\n", + "\n", + "### Diffusion models\n", + "\n", + "**Diffusion models** are the generative-AI models behind most modern image, video, and 3D generation. They learn to start from random noise and iteratively \"denoise\" it into a coherent result, guided by your prompt or input image. Each iteration is called a *step*.\n", + "\n", + "### ComfyUI and workflows\n", + "\n", + "**[ComfyUI](https://www.comfy.org/)** is an open-source, **node-based** interface for diffusion models. You build a pipeline by wiring together nodes — \"load a model\", \"encode a prompt\", \"sample\", \"decode\", \"save\" — into a graph called a **workflow**. ComfyUI can export any workflow to a JSON document (\"API format\"); this tutorial submits those JSON workflows directly, so we never need the visual editor.\n", + "\n", + "### Driving ComfyUI headlessly (the HTTP API)\n", + "\n", + "ComfyUI runs as a long-lived **HTTP server**. Besides the browser UI, it exposes an API: you `POST` a workflow to `/prompt`, poll `/history/` until it finishes, and download generated files from `/view`. \"Headless\" means using only this API, with no display attached — the standard way to run on a remote data-center node.\n", + "\n", + "### The two workflows\n", + "\n", + "- **[Hunyuan3D v2.1](https://huggingface.co/tencent/Hunyuan3D-2.1)** is an *image-to-3D* model: from one photo it produces a 3D mesh saved as a `.glb` file.\n", + "- **[Wan2.2 5B](https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged)** is an *image-to-video* model: from one photo (plus a text prompt) it synthesizes a short video where every frame is generated.\n", + "\n", + "### ROCm and AMD Instinct GPUs\n", + "\n", + "**[ROCm™](https://rocm.docs.amd.com/)** is AMD's open software platform for GPU computing — the AMD counterpart to CUDA. **AMD Instinct™** GPUs (MI300X, MI325X, MI350X, MI355X) are AMD's data-center accelerators for AI. We run everything inside a ROCm **Docker container** so the environment is reproducible." + ] + }, + { + "cell_type": "markdown", + "id": "2e7b4706", + "metadata": {}, + "source": [ + "## Prerequisites\n", + "\n", + "### Hardware\n", + "\n", + "- An AMD Instinct™ GPU: **MI300X**, **MI325X**, **MI350X**, or **MI355X**, with enough VRAM to load the model checkpoints used below.\n", + "\n", + "### Software\n", + "\n", + "The table below lists the **exact versions this tutorial was tested against**. Other versions may work but have not been validated, so we recommend matching these.\n", + "\n", + "| Component | Version (tested) | Reference |\n", + "|---|---|---|\n", + "| ROCm | 7.2.0 | [Quick-start install guide](https://rocm.docs.amd.com/projects/install-on-linux/en/latest/install/quick-start.html) |\n", + "| Docker | 24.0.7 | [Docker install guide](https://docs.docker.com/get-docker/) |\n", + "| `rocm/comfyui` image | `comfyui-0.18.2.amd0_rocm7.2.0_ubuntu24.04` | ComfyUI-on-ROCm image (pulled in Environment setup) |\n", + "| PyTorch | 2.10 (ROCm 7.2 build) | Ships inside the image |\n", + "\n", + "You do **not** need to build anything by hand — the container image ships ComfyUI and a ROCm-enabled PyTorch. The model checkpoints (~27 GB total) are **not** bundled; Part 2 can download them from Hugging Face for you when you opt in by setting `DOWNLOAD_MODELS = True` (see the configuration cell in Part 1). Make sure you have enough free disk space before enabling it.\n", + "\n", + "> **Tip:** Confirm Docker is installed and that your user can run it without `sudo` by running `docker run hello-world` in a terminal before you start." + ] + }, + { + "cell_type": "markdown", + "id": "ae7a3959", + "metadata": {}, + "source": [ + "## Environment setup\n", + "\n", + "This section gets you from \"a machine with AMD Instinct GPUs\" to \"this notebook open and running inside a ComfyUI-on-ROCm container.\" It is aimed at readers who are new to AMD GPUs, Docker, or JupyterLab — if you are already set up, feel free to skim it.\n", + "\n", + "All workflows in this notebook run **inside** the container, where the `COMFYUI_PATH` environment variable points at the ComfyUI source tree (typically `/workload/ComfyUI`)." + ] + }, + { + "cell_type": "markdown", + "id": "15eb85e1", + "metadata": {}, + "source": [ + "### 1. Pull the image and launch the container\n", + "\n", + "Pull the pre-built ComfyUI-on-ROCm image and start a container with the AMD GPUs exposed. We forward two ports to the host — **8888** for JupyterLab (next step) and **8188** for the ComfyUI server (started in Part 1) — and mount the directory that contains this repository so the notebook and its assets are visible inside the container.\n", + "\n", + "```bash\n", + "docker pull rocm/comfyui:comfyui-0.18.2.amd0_rocm7.2.0_ubuntu24.04\n", + "\n", + "# Run from the directory where you cloned this repository.\n", + "docker run -it --rm \\\n", + " --device=/dev/kfd \\\n", + " --device=/dev/dri \\\n", + " --group-add video \\\n", + " --cap-add=SYS_PTRACE \\\n", + " --security-opt seccomp=unconfined \\\n", + " --ipc=host \\\n", + " --shm-size=16g \\\n", + " -p 8888:8888 \\\n", + " -p 8188:8188 \\\n", + " -e COMFYUI_PATH=/workload/ComfyUI \\\n", + " -v \"$PWD:/work\" \\\n", + " rocm/comfyui:comfyui-0.18.2.amd0_rocm7.2.0_ubuntu24.04\n", + "```\n", + "\n", + "This drops you into a shell **inside** the container, with this repository available under `/work`. Run the remaining steps there." + ] + }, + { + "cell_type": "markdown", + "id": "8c74542e", + "metadata": {}, + "source": [ + "### 2. Open this notebook in JupyterLab\n", + "\n", + "The image does not start JupyterLab automatically. From the container shell, install it (once) and start it bound to all interfaces so Docker's port forwarding can reach it:\n", + "\n", + "```bash\n", + "pip install jupyterlab\n", + "jupyter lab --no-browser --ip=0.0.0.0 --port=8888 --allow-root --notebook-dir=/work\n", + "```\n", + "\n", + "On startup it prints a URL containing a one-time token, for example `http://127.0.0.1:8888/lab?token=`.\n", + "\n", + "**If the GPU machine is remote** (you SSH into it), open a second terminal on your laptop and forward both ports so you can reach the server and the ComfyUI UI from your local browser:\n", + "\n", + "```bash\n", + "ssh -L 8888:localhost:8888 -L 8188:localhost:8188 @\n", + "```\n", + "\n", + "Paste the printed `http://127.0.0.1:8888/lab?token=...` URL into your browser, then open this notebook at `docs/notebooks/inference/t2v_comfyui_api_mode_instinct.ipynb`. To stop the server when you are done, press `Ctrl+C` twice in the container shell." + ] + }, + { + "cell_type": "markdown", + "id": "46baee4f", + "metadata": {}, + "source": [ + "### 3. Confirm the GPUs are visible\n", + "\n", + "ROCm ships `amd-smi`, a command-line tool that lists the AMD GPUs the container can see (the AMD counterpart to `nvidia-smi`). Run the cell below; you should see one or more AMD Instinct GPUs. If the command is not found or no GPU appears, recheck the container's `--device` flags and your ROCm install before continuing." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "e1d4d178", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-25T20:00:51.993218Z", + "iopub.status.busy": "2026-06-25T20:00:51.993162Z", + "iopub.status.idle": "2026-06-25T20:00:52.284585Z", + "shell.execute_reply": "2026-06-25T20:00:52.283830Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "GPU: 0\r\n", + " BDF: 0000:09:00.0\r\n", + " UUID: 41ff74a5-0000-1000-802a-2af56d9fcea0\r\n", + " KFD_ID: 49827\r\n", + " NODE_ID: 3\r\n", + " PARTITION_ID: 0\r\n", + "\r\n", + "GPU: 1\r\n", + " BDF: 0000:1a:00.0\r\n", + " UUID: 92ff74a5-0000-1000-80b6-6ddb632f890a\r\n", + " KFD_ID: 65518\r\n", + " NODE_ID: 5\r\n", + " PARTITION_ID: 0\r\n", + "\r\n", + "GPU: 2\r\n", + " BDF: 0000:6b:00.0\r\n", + " UUID: bbff74a5-0000-1000-8024-35b25ca644e7\r\n", + " KFD_ID: 35594\r\n", + " NODE_ID: 4\r\n", + " PARTITION_ID: 0\r\n", + "\r\n", + "GPU: 3\r\n", + " BDF: 0000:79:00.0\r\n", + " UUID: 55ff74a5-0000-1000-806c-23298405fe79\r\n", + " KFD_ID: 9862\r\n", + " NODE_ID: 2\r\n", + " PARTITION_ID: 0\r\n", + "\r\n", + "\r\n" + ] + } + ], + "source": [ + "# List the AMD GPUs visible inside the container. You should see your Instinct GPU(s).\n", + "# For ROCm 6.4 and earlier, use `rocm-smi` instead of `amd-smi`.\n", + "!amd-smi list || rocm-smi" + ] + }, + { + "cell_type": "markdown", + "id": "2b4df2aa", + "metadata": {}, + "source": [ + "## Part 1 — Set up and verify ComfyUI on Instinct\n", + "\n", + "With the container running and this notebook open inside it, Part 1 establishes the runtime and the server we'll talk to for the rest of the notebook. There are three short steps:\n", + "\n", + "- Verify that PyTorch sees the GPU and is built with ROCm support, and set up the notebook's working directories.\n", + "- Stage the input photos and workflow files this tutorial needs.\n", + "- Launch the ComfyUI server as a background process and load a small set of HTTP API helpers reused in Parts 2 and 3." + ] + }, + { + "cell_type": "markdown", + "id": "465ed7a9", + "metadata": {}, + "source": [ + "### Install the notebook's Python dependencies\n", + "\n", + "The ComfyUI-on-ROCm image ships ComfyUI and a ROCm-enabled PyTorch, but a few small **pure-Python** packages this notebook uses for *visualizing* results are not included. The cell below installs them into the running kernel:\n", + "\n", + "- `trimesh` + `fast-simplification` — load and decimate the Hunyuan3D `.glb` mesh for the turntable preview (Part 2).\n", + "- `imageio` + `imageio-ffmpeg` — encode that preview to an MP4 (bundles its own `ffmpeg`, so nothing system-level is required).\n", + "- `pillow` — image handling for the inline result grid.\n", + "\n", + "None of these touch the GPU; they only render the outputs for display. Run this once per fresh container." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "be4b4256", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-25T20:00:52.287270Z", + "iopub.status.busy": "2026-06-25T20:00:52.287077Z", + "iopub.status.idle": "2026-06-25T20:00:52.809307Z", + "shell.execute_reply": "2026-06-25T20:00:52.808558Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\r\n", + "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m A new release of pip is available: \u001b[0m\u001b[31;49m26.0.1\u001b[0m\u001b[39;49m -> \u001b[0m\u001b[32;49m26.1.2\u001b[0m\r\n", + "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m To update, run: \u001b[0m\u001b[32;49mpip install --upgrade pip\u001b[0m\r\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Note: you may need to restart the kernel to use updated packages.\n" + ] + } + ], + "source": [ + "%pip install --quiet trimesh fast-simplification \"imageio[ffmpeg]\" pillow" + ] + }, + { + "cell_type": "markdown", + "id": "a8a2f191", + "metadata": {}, + "source": [ + "### Verifying the runtime: PyTorch ROCm and GPU visibility\n", + "\n", + "The next cell confirms that PyTorch was built with ROCm support, prints the detected device name, and locates `$COMFYUI_PATH`. Run it before the workflow sections; if it fails, fix the install before proceeding." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "8865ecc1", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-25T20:00:52.811736Z", + "iopub.status.busy": "2026-06-25T20:00:52.811555Z", + "iopub.status.idle": "2026-06-25T20:00:53.725726Z", + "shell.execute_reply": "2026-06-25T20:00:53.725266Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "python: 3.12.3\n", + "torch: 2.10.0a0+git449b176 | HIP: 7.2.26015\n", + "GPU 0: AMD Instinct MI325X\n", + "COMFYUI_PATH: /workload/ComfyUI\n", + "Notebook dir: /workload/ComfyUI/examples\n", + "DOWNLOAD_MODELS: False\n" + ] + } + ], + "source": [ + "import os\n", + "import sys\n", + "import json\n", + "import shutil\n", + "import time\n", + "from copy import deepcopy\n", + "from pathlib import Path\n", + "\n", + "import torch\n", + "\n", + "assert torch.cuda.is_available(), (\n", + " \"No HIP / CUDA device visible to PyTorch. Check container --device flags and ROCm install.\"\n", + ")\n", + "print(\"python:\", sys.version.split()[0])\n", + "print(\"torch: \", torch.__version__, \"| HIP:\", getattr(torch.version, \"hip\", None) or \"(not a ROCm build)\")\n", + "print(\"GPU 0: \", torch.cuda.get_device_name(0))\n", + "\n", + "COMFYUI_PATH = Path(os.environ.get(\"COMFYUI_PATH\", \"/workload/ComfyUI\"))\n", + "assert (COMFYUI_PATH / \"main.py\").is_file(), (\n", + " f\"{COMFYUI_PATH}/main.py not found - set COMFYUI_PATH to your ComfyUI checkout.\"\n", + ")\n", + "\n", + "NOTEBOOK_DIR = Path(os.getcwd()).resolve() # also where utils_comfyui.py lives\n", + "WORKFLOWS_DIR = NOTEBOOK_DIR / \"workflows\"\n", + "ASSETS_DIR = NOTEBOOK_DIR / \"assets\"\n", + "OUTPUTS_DIR = NOTEBOOK_DIR / \"outputs\"\n", + "OUTPUTS_DIR.mkdir(exist_ok=True)\n", + "\n", + "# Set to True to let Part 2 download the model checkpoints (~27 GB total) from\n", + "# Hugging Face into ComfyUI/models/. Left False by default so a first read does\n", + "# not trigger a large download; flip it once you are ready to run the workflows.\n", + "DOWNLOAD_MODELS = False\n", + "\n", + "print(\"COMFYUI_PATH:\", COMFYUI_PATH)\n", + "print(\"Notebook dir:\", NOTEBOOK_DIR)\n", + "print(\"DOWNLOAD_MODELS:\", DOWNLOAD_MODELS)" + ] + }, + { + "cell_type": "markdown", + "id": "8d9f2f54", + "metadata": {}, + "source": [ + "### Stage the tutorial inputs and workflows\n", + "\n", + "Part 2 needs two kinds of small files that are versioned with this tutorial:\n", + "\n", + "- **Input photographs** — three 768x768 PNGs (`apple`, `banana`, `pineapple`).\n", + "- **Workflow definitions** — the ComfyUI API-format JSON for each of the two workflows.\n", + "\n", + "These files — together with the `utils_comfyui.py` helper module — are versioned in the [ROCm/ComfyUI](https://github.com/ROCm/ComfyUI/tree/master/examples) repository under `examples/`, not in this docs repository. The next cell downloads the helper module next to this notebook (if missing) and stages the assets and workflows under the notebook's `assets/` and `workflows/` directories. If you are running inside a ComfyUI checkout that already ships these files, it finds them locally; otherwise it downloads them from `RAW_BASE_URL`.\n", + "\n", + "The photographs are sourced from Wikimedia Commons; see the [input photographs README](https://github.com/ROCm/ComfyUI/blob/master/examples/assets/README.md) for the per-image licences and attribution (the apple and banana images are licensed CC BY / CC BY-SA and require attribution)." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "c5111ff7", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-25T20:00:53.728069Z", + "iopub.status.busy": "2026-06-25T20:00:53.727952Z", + "iopub.status.idle": "2026-06-25T20:00:53.740908Z", + "shell.execute_reply": "2026-06-25T20:00:53.740487Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Input photographs:\n", + " present: amd_comfyui_tutorial_apple_seed.png\n", + " present: amd_comfyui_tutorial_banana_seed.png\n", + " present: amd_comfyui_tutorial_pineapple_seed.png\n", + "Workflow definitions:\n", + " present: hunyuan3d_v2_1_image_to_3d.json\n", + " present: wan2_2_5b_image_to_video.json\n", + "All inputs and workflows are in place.\n" + ] + } + ], + "source": [ + "import urllib.request\n", + "\n", + "# This tutorial's helper module and supporting files (the workflow JSONs and\n", + "# input photos) are versioned in the ROCm/ComfyUI repo under examples/, not in\n", + "# this docs repo. RAW_BASE_URL points there; the helper module is fetched next\n", + "# to this notebook if it is not already present (a no-op inside a checkout that\n", + "# already ships it).\n", + "RAW_BASE_URL = \"https://raw.githubusercontent.com/ROCm/ComfyUI/master/examples\"\n", + "\n", + "if not (NOTEBOOK_DIR / \"utils_comfyui.py\").is_file():\n", + " print(\"Fetching utils_comfyui.py from ROCm/ComfyUI ...\")\n", + " urllib.request.urlretrieve(f\"{RAW_BASE_URL}/utils_comfyui.py\",\n", + " str(NOTEBOOK_DIR / \"utils_comfyui.py\"))\n", + "\n", + "from utils_comfyui import stage_files\n", + "\n", + "ASSET_FILES = [\n", + " \"amd_comfyui_tutorial_apple_seed.png\",\n", + " \"amd_comfyui_tutorial_banana_seed.png\",\n", + " \"amd_comfyui_tutorial_pineapple_seed.png\",\n", + "]\n", + "WORKFLOW_FILES = [\n", + " \"hunyuan3d_v2_1_image_to_3d.json\",\n", + " \"wan2_2_5b_image_to_video.json\",\n", + "]\n", + "\n", + "print(\"Input photographs:\")\n", + "stage_files(ASSET_FILES, ASSETS_DIR,\n", + " [NOTEBOOK_DIR / \"assets\"], f\"{RAW_BASE_URL}/assets\")\n", + "print(\"Workflow definitions:\")\n", + "stage_files(WORKFLOW_FILES, WORKFLOWS_DIR,\n", + " [NOTEBOOK_DIR / \"workflows\"], f\"{RAW_BASE_URL}/workflows\")\n", + "\n", + "missing = [f for f in ASSET_FILES if not (ASSETS_DIR / f).is_file()]\n", + "missing += [f for f in WORKFLOW_FILES if not (WORKFLOWS_DIR / f).is_file()]\n", + "assert not missing, f\"Could not stage {missing}. Set RAW_BASE_URL or place the files manually.\"\n", + "print(\"All inputs and workflows are in place.\")" + ] + }, + { + "cell_type": "markdown", + "id": "dbc86f53", + "metadata": {}, + "source": [ + "### Launching the ComfyUI server in the background\n", + "\n", + "ComfyUI runs as a long-lived HTTP server. The next cell starts `python $COMFYUI_PATH/main.py --listen --port 8188` in the background, captures its PID, and waits for the `/system_stats` endpoint to return 200 (which signals \"ready to accept prompts\"). If a server is already running on `127.0.0.1:8188` (for example you started it manually before opening Jupyter), the cell detects that and becomes a no-op.\n", + "\n", + "The PID is stored in `server_pid`; the final cell of the notebook will terminate it." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "b0d1048d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-25T20:00:53.742436Z", + "iopub.status.busy": "2026-06-25T20:00:53.742374Z", + "iopub.status.idle": "2026-06-25T20:00:58.766826Z", + "shell.execute_reply": "2026-06-25T20:00:58.766194Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Starting ComfyUI server -> http://127.0.0.1:8188 (log: /workload/ComfyUI/examples/outputs/comfyui_server.log)\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Server ready (pid=198).\n" + ] + } + ], + "source": [ + "from utils_comfyui import start_comfyui_server\n", + "\n", + "SERVER_PORT = int(os.environ.get(\"COMFYUI_PORT_HOST\", \"8188\"))\n", + "SERVER_URL = f\"http://127.0.0.1:{SERVER_PORT}\"\n", + "\n", + "server_pid = start_comfyui_server(\n", + " COMFYUI_PATH, SERVER_URL, SERVER_PORT, OUTPUTS_DIR / \"comfyui_server.log\"\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "b0c05359", + "metadata": {}, + "source": [ + "### The ComfyUI HTTP API and a tiny client\n", + "\n", + "We drive the server through three endpoints:\n", + "\n", + "- `POST /prompt` — submit a workflow (returns a `prompt_id`).\n", + "- `GET /history/` — check whether a run has finished.\n", + "- `GET /view` — download a generated file.\n", + "\n", + "To keep this notebook readable, the small client that wraps these calls lives in the companion module [`utils_comfyui.py`](https://github.com/ROCm/ComfyUI/blob/master/examples/utils_comfyui.py), staged next to this notebook by the cell above. Its `ComfyUIClient` exposes `queue_prompt`, `wait_for_completion`, and `download_outputs`, plus a `run_workflow` convenience method that chains all three (submit → wait → download). The full API is documented in the [ComfyUI server docs](https://docs.comfy.org/development/comfyui-server/comms_overview)." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "dccd82d8", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-25T20:00:58.769282Z", + "iopub.status.busy": "2026-06-25T20:00:58.769113Z", + "iopub.status.idle": "2026-06-25T20:00:58.772165Z", + "shell.execute_reply": "2026-06-25T20:00:58.771754Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ComfyUI client ready -> http://127.0.0.1:8188\n" + ] + } + ], + "source": [ + "from utils_comfyui import ComfyUIClient\n", + "\n", + "client = ComfyUIClient(SERVER_URL)\n", + "print(\"ComfyUI client ready ->\", client.server_url)" + ] + }, + { + "cell_type": "markdown", + "id": "07381e83", + "metadata": {}, + "source": [ + "## Part 2 - Two diffusion workflows on real photographs\n", + "\n", + "Part 2 demonstrates two **independent** ComfyUI generative workflows, both driven from Python over the HTTP API we set up in Part 1, on the same set of input photographs (apple, banana, pineapple, 768x768 each). The two workflows do not share state - both consume the input photo; neither output feeds the other.\n", + "\n", + "| | Workflow | Input | AI output |\n", + "|---|---|---|---|\n", + "| Example A | **[Hunyuan3D v2.1](https://huggingface.co/tencent/Hunyuan3D-2.1)** - flow-based diffusion transformer for image-to-3D, `octree_resolution=4096` | one photograph | a watertight 3D mesh in glTF (`.glb`) format - the geometry is the AI output |\n", + "| Example B | **[Wan2.2 5B image-to-video](https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged)** (TI2V variant) - 5B-parameter video diffusion transformer | the same photograph plus a text prompt | an MP4 clip (81 frames at 24 fps) where every frame is generated by the model |\n", + "\n", + "The visualisation cell at the end of Part 2 lays out a 3-row grid:\n", + "\n", + "- **Row 1 (input photo):** the source photograph fed to both workflows.\n", + "- **Row 2 (mesh turntable):** a deterministic CPU rendering of the Hunyuan3D `.glb`. This row is *not* AI: it's a fixed-camera orthographic walk-around of the mesh that the AI produced. It tells you what the image-to-3D model *thinks* the subject's shape is, which is hard to inspect any other way without a 3D viewer.\n", + "- **Row 3 (AI camera orbit):** the Wan2.2 5B image-to-video output. This row *is* AI: every frame is generated.\n", + "\n", + "Photo credits and licences for the inputs are in the [input photographs README](https://github.com/ROCm/ComfyUI/blob/master/examples/assets/README.md).\n" + ] + }, + { + "cell_type": "markdown", + "id": "4f494aa4", + "metadata": {}, + "source": [ + "### Example A: Hunyuan3D v2.1 — image-to-3D\n", + "\n", + "Our first workflow turns each photograph into a 3D mesh. The cell below stages the three input images into ComfyUI's `input/` directory, makes sure the Hunyuan3D checkpoint (~10 GB) is available, and loads the workflow JSON staged earlier.\n", + "\n", + "If you set `DOWNLOAD_MODELS = True` in the configuration cell above, the checkpoint is downloaded from Hugging Face into `ComfyUI/models/checkpoints/` automatically (and skipped if already present). With the default `DOWNLOAD_MODELS = False`, the cell instead reports the checkpoint as missing and the generation step is skipped — flip the flag, or place the file there manually, when you are ready to run the workflow." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "d11459c7", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-25T20:00:58.774021Z", + "iopub.status.busy": "2026-06-25T20:00:58.773876Z", + "iopub.status.idle": "2026-06-25T20:00:58.799614Z", + "shell.execute_reply": "2026-06-25T20:00:58.799138Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " present: hunyuan_3d_v2.1.safetensors\n", + "Staged 3 photos; workflow has 10 nodes; checkpoint ready: True\n" + ] + } + ], + "source": [ + "from utils_comfyui import download_models\n", + "\n", + "# Three real-world subjects. Photo credits/licences: examples/assets/README.md in ROCm/ComfyUI.\n", + "INPUTS = [\n", + " (\"apple\", ASSETS_DIR / \"amd_comfyui_tutorial_apple_seed.png\"),\n", + " (\"banana\", ASSETS_DIR / \"amd_comfyui_tutorial_banana_seed.png\"),\n", + " (\"pineapple\", ASSETS_DIR / \"amd_comfyui_tutorial_pineapple_seed.png\"),\n", + "]\n", + "\n", + "# Stage the photos where ComfyUI's LoadImage node looks for them.\n", + "COMFYUI_INPUT_DIR = COMFYUI_PATH / \"input\"\n", + "COMFYUI_INPUT_DIR.mkdir(exist_ok=True)\n", + "for _, asset in INPUTS:\n", + " shutil.copy2(asset, COMFYUI_INPUT_DIR / asset.name)\n", + "\n", + "# Checkpoint -> Hugging Face URL. Downloaded only when DOWNLOAD_MODELS is True.\n", + "HUNYUAN_CKPT = COMFYUI_PATH / \"models\" / \"checkpoints\" / \"hunyuan_3d_v2.1.safetensors\"\n", + "HUNYUAN_MODELS = {\n", + " HUNYUAN_CKPT: \"https://huggingface.co/Comfy-Org/hunyuan3D_2.1_repackaged/\"\n", + " \"resolve/main/hunyuan_3d_v2.1.safetensors\",\n", + "}\n", + "HUNYUAN_READY = not download_models(HUNYUAN_MODELS, DOWNLOAD_MODELS)\n", + "\n", + "with (WORKFLOWS_DIR / \"hunyuan3d_v2_1_image_to_3d.json\").open() as f:\n", + " hunyuan_workflow = json.load(f)\n", + "print(f\"Staged {len(INPUTS)} photos; workflow has {len(hunyuan_workflow)} nodes; checkpoint ready: {HUNYUAN_READY}\")" + ] + }, + { + "cell_type": "markdown", + "id": "3c2e9543", + "metadata": {}, + "source": [ + "### Run the workflow over each input\n", + "\n", + "We run the workflow once as a warm-up (which loads ~10 GB of weights and compiles the diffusion and VAE kernels), then once per input subject. Each call swaps the `LoadImage` filename and the `SaveGLB` prefix and bumps the KSampler seed so every run is fresh rather than served from cache." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "a3be181f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-25T20:00:58.800522Z", + "iopub.status.busy": "2026-06-25T20:00:58.800464Z", + "iopub.status.idle": "2026-06-25T20:02:29.428891Z", + "shell.execute_reply": "2026-06-25T20:02:29.428335Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Warm-up (loads weights, compiles kernels)...\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "apple -> outputs/hunyuan3d/apple/hunyuan3d_v2_1_apple_00010_.glb\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "banana -> outputs/hunyuan3d/banana/hunyuan3d_v2_1_banana_00010_.glb\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "pineapple -> outputs/hunyuan3d/pineapple/hunyuan3d_v2_1_pineapple_00010_.glb\n" + ] + } + ], + "source": [ + "MESHES = {}\n", + "HUNYUAN_OUT = OUTPUTS_DIR / \"hunyuan3d\"\n", + "\n", + "\n", + "def customise_hunyuan(workflow, image_name, output_prefix):\n", + " wf = deepcopy(workflow)\n", + " wf[\"2\"][\"inputs\"][\"image\"] = image_name\n", + " wf[\"10\"][\"inputs\"][\"filename_prefix\"] = output_prefix\n", + " wf[\"7\"][\"inputs\"][\"seed\"] = int(time.time() * 1_000_000) % (2**63 - 1)\n", + " return wf\n", + "\n", + "\n", + "if HUNYUAN_READY:\n", + " print(\"Warm-up (loads weights, compiles kernels)...\")\n", + " client.run_workflow(customise_hunyuan(hunyuan_workflow, INPUTS[0][1].name, \"amd_tutorial/_warmup\"),\n", + " HUNYUAN_OUT / \"_warmup\")\n", + " for name, asset in INPUTS:\n", + " wf = customise_hunyuan(hunyuan_workflow, asset.name, f\"amd_tutorial/hunyuan3d_v2_1_{name}\")\n", + " saved, _ = client.run_workflow(wf, HUNYUAN_OUT / name)\n", + " glbs = [p for p in saved if p.suffix.lower() == \".glb\"]\n", + " MESHES[name] = glbs[0] if glbs else None\n", + " print(f\"{name:<12} -> {MESHES[name].relative_to(NOTEBOOK_DIR) if MESHES[name] else '(no .glb)'}\")\n", + "else:\n", + " print(\"Skipping run; checkpoint not present.\")" + ] + }, + { + "cell_type": "markdown", + "id": "ff323fa3", + "metadata": {}, + "source": [ + "### Visualise the AI-generated mesh: deterministic turntable render\n", + "\n", + "Hunyuan3D's output is a `.glb` mesh on disk. To inspect it inline — and confirm the model built a real 3D object, not a flat sticker — we render each mesh as a short MP4 turntable.\n", + "\n", + "This render is **deterministic and CPU-only** (it is *not* an AI step): it loads the mesh, decimates it, and rasterises 36 rotated frames with simple Lambert shading. The implementation lives in [`utils_comfyui.py`](https://github.com/ROCm/ComfyUI/blob/master/examples/utils_comfyui.py) as `render_mesh_turntable`; the cell below just calls it on each mesh produced above." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "e35f6756", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-25T20:02:29.430790Z", + "iopub.status.busy": "2026-06-25T20:02:29.430721Z", + "iopub.status.idle": "2026-06-25T20:02:35.010034Z", + "shell.execute_reply": "2026-06-25T20:02:35.009107Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "apple -> outputs/mesh_turntables/apple.mp4\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "banana -> outputs/mesh_turntables/banana.mp4\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "pineapple -> outputs/mesh_turntables/pineapple.mp4\n" + ] + } + ], + "source": [ + "from utils_comfyui import render_mesh_turntable\n", + "\n", + "TURNTABLE_OUT = OUTPUTS_DIR / \"mesh_turntables\"\n", + "TURNTABLE_VIDEOS = {}\n", + "for name, _ in INPUTS:\n", + " glb = MESHES.get(name)\n", + " if glb is None or not glb.is_file():\n", + " print(f\"{name:<12} (skip) - no mesh available\")\n", + " continue\n", + " TURNTABLE_VIDEOS[name] = render_mesh_turntable(glb, TURNTABLE_OUT / f\"{name}.mp4\")\n", + " print(f\"{name:<12} -> {TURNTABLE_VIDEOS[name].relative_to(NOTEBOOK_DIR)}\")" + ] + }, + { + "cell_type": "markdown", + "id": "ca7c2d6f", + "metadata": {}, + "source": [ + "### Example B: Wan2.2 5B — image-to-video\n", + "\n", + "The second workflow animates each photograph into a short video (81 frames at 24 fps) where every frame is generated by the diffusion model — a genuine AI camera orbit, not a rasterization of the Hunyuan3D mesh.\n", + "\n", + "Each subject gets its own positive prompt, defined in the next cell so it is easy to read and edit before re-running. The prompts deliberately emphasise *3D camera motion, not planar rotation*: Wan2.2 5B has weak parallax priors on cutout-style photos (fruit on a pure-white background) and will default to spinning the image plane unless the prompt actively asks for a 3D arc. The negative prompt rejects the failure modes we saw during prompt engineering (multiple or peeled fruit, planar rotation, fingers in frame)." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "60a60447", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-25T20:02:35.013160Z", + "iopub.status.busy": "2026-06-25T20:02:35.012566Z", + "iopub.status.idle": "2026-06-25T20:02:35.018836Z", + "shell.execute_reply": "2026-06-25T20:02:35.018252Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Defined positive prompts for 3 subjects.\n", + "Negative prompt: 462 chars.\n" + ] + } + ], + "source": [ + "# Per-subject positive prompts for Wan2.2 5B I2V, keyed by INPUTS name.\n", + "# Edit these and re-run the workflow cell to experiment.\n", + "SUBJECT_PROMPTS: dict[str, str] = {\n", + " \"apple\": (\n", + " \"Smooth slow 360 degree camera orbit around a single round red apple \"\n", + " \"on a clean white background. The camera moves steadily and \"\n", + " \"continuously around the apple in a wide 3D arc, revealing the back \"\n", + " \"side of the apple. The apple itself is a single fruit and stays \"\n", + " \"centred. Photorealistic studio product photography, sharp focus, \"\n", + " \"soft natural lighting, shallow depth of field. No people, no hands, \"\n", + " \"no text.\"\n", + " ),\n", + " \"banana\": (\n", + " \"Smooth slow 360 degree camera orbit around exactly one single yellow \"\n", + " \"banana on a clean white background. There is only one banana - one \"\n", + " \"single piece of fruit - lying on its side, stationary, intact, with \"\n", + " \"the curve clearly visible. The camera arcs around the banana to \"\n", + " \"reveal the back side of the curve in 3D. Photorealistic studio \"\n", + " \"product photography, sharp focus, soft natural lighting.\"\n", + " ),\n", + " \"pineapple\": (\n", + " \"Smooth slow 360 degree camera orbit around a single tall pineapple \"\n", + " \"with green crown leaves on a clean white background. The camera \"\n", + " \"moves steadily and continuously around the pineapple in a wide 3D \"\n", + " \"arc, revealing the back side of the pineapple body and the back of \"\n", + " \"the crown leaves. The pineapple itself is a single fruit and stays \"\n", + " \"centred. Photorealistic studio product photography, sharp focus, \"\n", + " \"soft natural lighting. No people, no hands, no text.\"\n", + " ),\n", + "}\n", + "\n", + "NEGATIVE_PROMPT = (\n", + " \"multiple bananas, banana hand, banana cluster, multiple fruits, fruit \"\n", + " \"cluster, duplicates, two apples, two bananas, two pineapples, sliced, \"\n", + " \"peeled, cut open, chopped, broken fruit, mashed, deformed fruit, \"\n", + " \"flat 2D rotation, planar rotation, sliding image, no parallax, \"\n", + " \"human, person, hands, fingers, body, face, arms, lamp, text, watermark, \"\n", + " \"logo, low quality, blurry, jpeg artifacts, distorted, deformed, extra \"\n", + " \"limbs, motion blur, tilted, falling, flying, cartoon\"\n", + ")\n", + "\n", + "print(f\"Defined positive prompts for {len(SUBJECT_PROMPTS)} subjects.\")\n", + "print(f\"Negative prompt: {len(NEGATIVE_PROMPT)} chars.\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "72d2cf78", + "metadata": {}, + "source": [ + "With the prompts defined, make sure the Wan2.2 checkpoints (~17 GB across the diffusion model, text encoder, and VAE) are available and prepare the per-subject workflow customizer. As in Example A, these are downloaded from Hugging Face only when `DOWNLOAD_MODELS = True`; otherwise the cell reports them as missing and the video step is skipped." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "7bd96ce1", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-25T20:02:35.020974Z", + "iopub.status.busy": "2026-06-25T20:02:35.020804Z", + "iopub.status.idle": "2026-06-25T20:02:35.031572Z", + "shell.execute_reply": "2026-06-25T20:02:35.031041Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " present: wan2.2_ti2v_5B_fp16.safetensors\n", + " present: umt5_xxl_fp8_e4m3fn_scaled.safetensors\n", + " present: wan2.2_vae.safetensors\n", + "Loaded Wan2.2 workflow (12 nodes); checkpoints ready: True\n" + ] + } + ], + "source": [ + "# Three checkpoints -> their Hugging Face URLs (Comfy-Org repackaged split files).\n", + "WAN_BASE = \"https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files\"\n", + "WAN_MODELS = {\n", + " COMFYUI_PATH / \"models\" / \"diffusion_models\" / \"wan2.2_ti2v_5B_fp16.safetensors\":\n", + " f\"{WAN_BASE}/diffusion_models/wan2.2_ti2v_5B_fp16.safetensors\",\n", + " COMFYUI_PATH / \"models\" / \"text_encoders\" / \"umt5_xxl_fp8_e4m3fn_scaled.safetensors\":\n", + " f\"{WAN_BASE}/text_encoders/umt5_xxl_fp8_e4m3fn_scaled.safetensors\",\n", + " COMFYUI_PATH / \"models\" / \"vae\" / \"wan2.2_vae.safetensors\":\n", + " f\"{WAN_BASE}/vae/wan2.2_vae.safetensors\",\n", + "}\n", + "WAN_I2V_READY = not download_models(WAN_MODELS, DOWNLOAD_MODELS)\n", + "\n", + "with (WORKFLOWS_DIR / \"wan2_2_5b_image_to_video.json\").open() as f:\n", + " wan_workflow = json.load(f)\n", + "\n", + "\n", + "def customise_wan(workflow, image_name, output_prefix, positive, negative=NEGATIVE_PROMPT):\n", + " wf = deepcopy(workflow)\n", + " wf[\"56\"][\"inputs\"][\"image\"] = image_name\n", + " wf[\"58\"][\"inputs\"][\"filename_prefix\"] = output_prefix\n", + " wf[\"3\"][\"inputs\"][\"seed\"] = int(time.time() * 1_000_000) % (2**63 - 1)\n", + " wf[\"6\"][\"inputs\"][\"text\"] = positive\n", + " wf[\"7\"][\"inputs\"][\"text\"] = negative\n", + " return wf\n", + "\n", + "\n", + "print(f\"Loaded Wan2.2 workflow ({len(wan_workflow)} nodes); checkpoints ready: {WAN_I2V_READY}\")" + ] + }, + { + "cell_type": "markdown", + "id": "19d196f0", + "metadata": {}, + "source": [ + "Now run the workflow for each subject:" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "72214a38", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-25T20:02:35.033818Z", + "iopub.status.busy": "2026-06-25T20:02:35.033648Z", + "iopub.status.idle": "2026-06-25T20:04:49.283806Z", + "shell.execute_reply": "2026-06-25T20:04:49.283133Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Warm-up (loads weights, compiles kernels)...\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "apple -> outputs/wan2_2_5b_i2v/apple/wan22_5b_i2v_apple_00007_.mp4\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "banana -> outputs/wan2_2_5b_i2v/banana/wan22_5b_i2v_banana_00007_.mp4\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "pineapple -> outputs/wan2_2_5b_i2v/pineapple/wan22_5b_i2v_pineapple_00007_.mp4\n" + ] + } + ], + "source": [ + "WAN_OUT = OUTPUTS_DIR / \"wan2_2_5b_i2v\"\n", + "WAN_VIDEOS = {}\n", + "if WAN_I2V_READY:\n", + " print(\"Warm-up (loads weights, compiles kernels)...\")\n", + " client.run_workflow(\n", + " customise_wan(wan_workflow, INPUTS[0][1].name, \"amd_tutorial/_wan_warmup\", SUBJECT_PROMPTS[INPUTS[0][0]]),\n", + " WAN_OUT / \"_warmup\",\n", + " )\n", + " for name, asset in INPUTS:\n", + " wf = customise_wan(wan_workflow, asset.name, f\"amd_tutorial/wan22_5b_i2v_{name}\", SUBJECT_PROMPTS[name])\n", + " saved, _ = client.run_workflow(wf, WAN_OUT / name)\n", + " mp4s = [p for p in saved if p.suffix.lower() == \".mp4\"]\n", + " WAN_VIDEOS[name] = mp4s[0] if mp4s else None\n", + " print(f\"{name:<12} -> {WAN_VIDEOS[name].relative_to(NOTEBOOK_DIR) if WAN_VIDEOS[name] else '(no .mp4)'}\")\n", + "else:\n", + " print(\"Skipping Wan2.2 5B I2V; checkpoints not present.\")" + ] + }, + { + "cell_type": "markdown", + "id": "2f0c5a92", + "metadata": {}, + "source": [ + "### Side-by-side: input photo, AI mesh turntable, AI camera orbit\n", + "\n", + "Finally, lay the results out as a grid — three rows, one column per subject:\n", + "\n", + "- **Row 1 — Input photo.** The 768x768 PNG fed to both workflows.\n", + "- **Row 2 — Mesh turntable.** The deterministic CPU render of the Hunyuan3D `.glb` (the mesh is the AI output; the rotation is fixed).\n", + "- **Row 3 — AI camera orbit.** The Wan2.2 5B output, where every frame is generated by the diffusion model.\n", + "\n", + "`result_grid_html` (in [`utils_comfyui.py`](https://github.com/ROCm/ComfyUI/blob/master/examples/utils_comfyui.py)) embeds each item as base64, so the rendered notebook is self-contained." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "92b1bee3", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-25T20:04:49.286050Z", + "iopub.status.busy": "2026-06-25T20:04:49.285977Z", + "iopub.status.idle": "2026-06-25T20:04:49.310907Z", + "shell.execute_reply": "2026-06-25T20:04:49.310460Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
Input photographs (768x768)
apple
banana
pineapple
Hunyuan3D v2.1 mesh - deterministic CPU turntable render
hunyuan3d_v2_1_apple_00010_.glb
hunyuan3d_v2_1_banana_00010_.glb
hunyuan3d_v2_1_pineapple_00010_.glb
Wan2.2 5B image-to-video - AI-generated camera orbit
81 frames @ 24 fps
81 frames @ 24 fps
81 frames @ 24 fps
" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from utils_comfyui import result_grid_html\n", + "from IPython.display import HTML, display\n", + "\n", + "display(HTML(result_grid_html(INPUTS, MESHES, TURNTABLE_VIDEOS, WAN_VIDEOS)))" + ] + }, + { + "cell_type": "markdown", + "id": "214714d5", + "metadata": {}, + "source": [ + "## Part 3 — Measure throughput on your own hardware (optional)\n", + "\n", + "This part is **opt-in**. Set `RUN_BENCHMARK = True` in the configuration cell below to run it; the default is `False` so the notebook stays a quick demo for first-time readers.\n", + "\n", + "**The goal of this experiment** is to answer a practical question on *your* hardware: *how long does a given workflow take, end to end, from submitting the prompt to saving the output?* The harness runs one warm-up pass (to load weights and compile kernels) followed by several timed iterations, then reports the **mean and standard deviation of your own measurements**. The timing logic lives in `benchmark_workflow` in [`utils_comfyui.py`](https://github.com/ROCm/ComfyUI/blob/master/examples/utils_comfyui.py).\n", + "\n", + "This notebook ships with **no pre-recorded numbers** — every figure below is produced by the run you trigger. If you would like to compare your results against AMD's published reference figures, see the AMD ROCm blog [*Accelerating ComfyUI Workflows on AMD Instinct MI355X GPUs with ROCm*](https://rocm.blogs.amd.com/artificial-intelligence/comfyui/README.html).\n", + "\n", + "By default the harness re-runs the Hunyuan3D workflow from Part 2. To benchmark another workflow (for example FLUX.1-dev or Wan2.2 14B), append an entry to `BENCH_SPECS` with its workflow JSON and checkpoint paths — it will be picked up automatically, or skipped if the files are absent." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "a4eb239a", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-25T20:04:49.336102Z", + "iopub.status.busy": "2026-06-25T20:04:49.336025Z", + "iopub.status.idle": "2026-06-25T20:04:49.338815Z", + "shell.execute_reply": "2026-06-25T20:04:49.338342Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Configured 1 workflow(s); RUN_BENCHMARK=False, ITERATIONS=3.\n" + ] + } + ], + "source": [ + "# Default OFF: flip to True to time iterations on your hardware.\n", + "RUN_BENCHMARK = False\n", + "ITERATIONS = 3 # timed passes per workflow, after one warm-up pass\n", + "\n", + "# By default we benchmark the Hunyuan3D workflow from Part 2. Append more entries\n", + "# (same shape: key / workflow / checkpoints / image_input) to measure others.\n", + "BENCH_SPECS = [\n", + " {\n", + " \"key\": \"hunyuan3d_v2_1\",\n", + " \"workflow\": WORKFLOWS_DIR / \"hunyuan3d_v2_1_image_to_3d.json\",\n", + " \"checkpoints\": [HUNYUAN_CKPT],\n", + " \"image_input\": INPUTS[0][1].name,\n", + " },\n", + "]\n", + "\n", + "BENCH_OUT = OUTPUTS_DIR / \"part3_benchmarks\"\n", + "print(f\"Configured {len(BENCH_SPECS)} workflow(s); RUN_BENCHMARK={RUN_BENCHMARK}, ITERATIONS={ITERATIONS}.\")" + ] + }, + { + "cell_type": "markdown", + "id": "e602782a", + "metadata": {}, + "source": [ + "### Run the timed iterations\n", + "\n", + "The next cell runs each configured workflow whose checkpoints are present: one warm-up pass, then `ITERATIONS` timed passes. Each pass gets a fresh seed so nothing is served from cache. Timings are wall-clock seconds measured around the API call with `time.perf_counter()`." + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "eaf0e621", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-25T20:04:49.340201Z", + "iopub.status.busy": "2026-06-25T20:04:49.340139Z", + "iopub.status.idle": "2026-06-25T20:04:49.342662Z", + "shell.execute_reply": "2026-06-25T20:04:49.342210Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "RUN_BENCHMARK=False; skipping Part 3 timed runs.\n", + "Set RUN_BENCHMARK=True in the configuration cell to enable.\n" + ] + } + ], + "source": [ + "from utils_comfyui import benchmark_workflow\n", + "\n", + "BENCH_RESULTS = {}\n", + "if RUN_BENCHMARK:\n", + " for spec in BENCH_SPECS:\n", + " print(f\"\\n=== {spec['key']} ===\")\n", + " result = benchmark_workflow(\n", + " client, spec[\"workflow\"], spec[\"checkpoints\"],\n", + " spec[\"image_input\"], ITERATIONS, BENCH_OUT / spec[\"key\"],\n", + " )\n", + " if result is not None:\n", + " BENCH_RESULTS[spec[\"key\"]] = result\n", + " print(f\"\\nFinished {len(BENCH_RESULTS)}/{len(BENCH_SPECS)} workflow(s).\")\n", + "else:\n", + " print(\"RUN_BENCHMARK=False; skipping Part 3 timed runs.\")\n", + " print(\"Set RUN_BENCHMARK=True in the configuration cell to enable.\")" + ] + }, + { + "cell_type": "markdown", + "id": "3a75efd5", + "metadata": {}, + "source": [ + "### Your results\n", + "\n", + "The table below summarizes the iterations you just ran on your own GPU. It stays empty until you set `RUN_BENCHMARK = True` and re-run the cell above." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "d3ac5e42", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-25T20:04:49.343983Z", + "iopub.status.busy": "2026-06-25T20:04:49.343925Z", + "iopub.status.idle": "2026-06-25T20:04:49.346319Z", + "shell.execute_reply": "2026-06-25T20:04:49.345936Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "workflow iterations mean (s) std (s)\n", + "--------------------------------------------------------\n", + "hunyuan3d_v2_1 (skipped)\n", + "\n", + "(no local results - set RUN_BENCHMARK=True and re-run the runner cell)\n" + ] + } + ], + "source": [ + "print(f\"{'workflow':<20} {'iterations':>10} {'mean (s)':>12} {'std (s)':>12}\")\n", + "print(\"-\" * 56)\n", + "for spec in BENCH_SPECS:\n", + " r = BENCH_RESULTS.get(spec[\"key\"])\n", + " if r is None:\n", + " print(f\"{spec['key']:<20} {'(skipped)':>10}\")\n", + " else:\n", + " print(f\"{spec['key']:<20} {len(r['times_s']):>10} {r['mean_s']:>12.2f} {r['std_s']:>12.2f}\")\n", + "\n", + "if not BENCH_RESULTS:\n", + " print(\"\\n(no local results - set RUN_BENCHMARK=True and re-run the runner cell)\")" + ] + }, + { + "cell_type": "markdown", + "id": "e15c0c09", + "metadata": {}, + "source": [ + "## Conclusion\n", + "\n", + "In this tutorial you drove ComfyUI generative workflows end-to-end from Python on AMD Instinct GPUs, with no browser:\n", + "\n", + "- **Set up** a ComfyUI-on-ROCm container, verified the PyTorch + ROCm runtime, launched the ComfyUI server in the background, and loaded a small set of HTTP API helpers (`queue_prompt`, `wait_for_completion`, `download_outputs`, `run_workflow`).\n", + "- **Generated** two kinds of output on the same three photos: a 3D mesh per subject with **Hunyuan3D v2.1** (image-to-3D), and a short camera-orbit video per subject with **Wan2.2 5B** (image-to-video), visualized inline as a grid.\n", + "- **Measured** *(optional)* the end-to-end time of a workflow on your own hardware with a small timed harness.\n", + "\n", + "From here you could try submitting your own workflow JSON (export any workflow from the ComfyUI UI in API format), swapping in different models, or wiring these helpers into a script or service through the same HTTP API.\n", + "\n", + "A natural next step is scaling beyond a single GPU. ComfyUI has no native multi-GPU support today; community tooling such as [xDiT](https://github.com/xdit-project/xDiT) (sequence parallelism, USP, PipeFusion) is a promising starting point.\n", + "\n", + "### Further reading\n", + "\n", + "| Resource | Link |\n", + "|---|---|\n", + "| Companion tutorial: ComfyUI on an AMD Radeon GPU (browser UI) | https://rocm.docs.amd.com/projects/ai-developer-hub/en/latest/notebooks/inference/t2v_comfyui_radeon.html |\n", + "| Companion blog: Accelerating ComfyUI on AMD Instinct MI355X | https://rocm.blogs.amd.com/artificial-intelligence/comfyui/README.html |\n", + "| ComfyUI project and docs | https://docs.comfy.org/ |\n", + "| ComfyUI HTTP/WebSocket API overview | https://docs.comfy.org/development/comfyui-server/comms_overview |\n", + "| ROCm AI Developer Hub | https://rocm.docs.amd.com/projects/ai-developer-hub |\n", + "| Hunyuan3D v2.1 | https://huggingface.co/tencent/Hunyuan3D-2.1 |\n", + "| Wan2.2 (Comfy-Org repackaged) | https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged |" + ] + }, + { + "cell_type": "markdown", + "id": "5ba92f41", + "metadata": {}, + "source": [ + "### Clean up\n", + "\n", + "Terminate the background ComfyUI server we launched (a no-op if a pre-existing server was reused)." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "d823f3a4", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-25T20:04:49.347645Z", + "iopub.status.busy": "2026-06-25T20:04:49.347587Z", + "iopub.status.idle": "2026-06-25T20:04:49.352593Z", + "shell.execute_reply": "2026-06-25T20:04:49.352308Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Sent SIGTERM to ComfyUI server pid 198.\n" + ] + } + ], + "source": [ + "import signal\n", + "\n", + "if server_pid is not None:\n", + " try:\n", + " os.kill(server_pid, signal.SIGTERM)\n", + " print(f\"Sent SIGTERM to ComfyUI server pid {server_pid}.\")\n", + " except ProcessLookupError:\n", + " print(f\"ComfyUI server pid {server_pid} already exited.\")\n", + "else:\n", + " print(\"No server was launched by this notebook; nothing to clean up.\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/assets/README.md b/examples/assets/README.md new file mode 100644 index 00000000000..76ee99ed021 --- /dev/null +++ b/examples/assets/README.md @@ -0,0 +1,18 @@ +# Tutorial input photographs + +These three 768x768 PNGs are the real-world inputs that Part 2 of the tutorial +notebook (`amd_comfyui_rocm_tutorial.ipynb`) feeds to **both** generative +workflows: **Hunyuan3D v2.1** (image-to-3D) and **Wan2.2 5B image-to-video**. +Each photograph was sourced from Wikimedia Commons, licence-checked, +centre-cropped to a square, and resized to 768x768 PNG. + +| File | Subject | Original | Licence | Author / Credit | +| --- | --- | --- | --- | --- | +| `amd_comfyui_tutorial_apple_seed.png` | Apple | [Red_Apple.jpg](https://commons.wikimedia.org/wiki/File:Red_Apple.jpg) | CC BY 2.0 | Abhijit Tembhekar (Mumbai, India) | +| `amd_comfyui_tutorial_banana_seed.png` | Banana | [Banana-Single.jpg](https://commons.wikimedia.org/wiki/File:Banana-Single.jpg) | CC BY-SA 3.0 | Evan-Amos | +| `amd_comfyui_tutorial_pineapple_seed.png` | Pineapple | [Pineapple_on_white_table.jpg](https://commons.wikimedia.org/wiki/File:Pineapple_on_white_table.jpg) | CC0 | Julien Pianetti (via Unsplash) | + +The licences for `amd_comfyui_tutorial_apple_seed.png` (CC BY 2.0) and +`amd_comfyui_tutorial_banana_seed.png` (CC BY-SA 3.0) require attribution; this +README is the canonical attribution. The pineapple image is dedicated to the +public domain (CC0) and may be reused without restriction. diff --git a/examples/assets/amd_comfyui_tutorial_apple_seed.png b/examples/assets/amd_comfyui_tutorial_apple_seed.png new file mode 100644 index 00000000000..7660442c7fc Binary files /dev/null and b/examples/assets/amd_comfyui_tutorial_apple_seed.png differ diff --git a/examples/assets/amd_comfyui_tutorial_banana_seed.png b/examples/assets/amd_comfyui_tutorial_banana_seed.png new file mode 100644 index 00000000000..22f328ec613 Binary files /dev/null and b/examples/assets/amd_comfyui_tutorial_banana_seed.png differ diff --git a/examples/assets/amd_comfyui_tutorial_pineapple_seed.png b/examples/assets/amd_comfyui_tutorial_pineapple_seed.png new file mode 100644 index 00000000000..ee6d2415f0a Binary files /dev/null and b/examples/assets/amd_comfyui_tutorial_pineapple_seed.png differ diff --git a/examples/launch_in_rocm_docker.sh b/examples/launch_in_rocm_docker.sh new file mode 100755 index 00000000000..d50c5f598cf --- /dev/null +++ b/examples/launch_in_rocm_docker.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 Advanced Micro Devices, Inc. +# SPDX-License-Identifier: Apache-2.0 +# +# One-shot launcher that starts a ROCm-enabled container for the +# amd_comfyui_rocm_tutorial.ipynb notebook. +# +# * Mounts the local ComfyUI checkout at /workload/ComfyUI (so the +# notebook sees up-to-date code and any models you have placed in +# ComfyUI/models/...). +# * Forwards 8888 (JupyterLab) and 8188 (ComfyUI server) to the host. +# * Inside the container: installs ComfyUI requirements (skipping torch - +# the rocm/primus image already ships PyTorch+ROCm) and JupyterLab, +# then runs run_jupyter_server.sh. +# +# Usage: +# cd /home/AMD/diptodeb/devel/ComfyUI/examples +# ./launch_in_rocm_docker.sh +# +# Overrides: +# IMAGE=rocm/comfyui:comfyui-0.18.2.amd0_rocm7.2.0_ubuntu24.04 \ +# ./launch_in_rocm_docker.sh +# Use the prebuilt ComfyUI-on-ROCm image instead of primus +# (everything is pre-installed; container starts much faster). +# +# GPU_DEVICES=0,1 ./launch_in_rocm_docker.sh +# Restrict the container to GPUs 0,1 via HIP_VISIBLE_DEVICES. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +EXAMPLES_DIR="$ROOT/examples" + +IMAGE="${IMAGE:-rocm/primus:v26.2}" +JUPYTER_PORT="${JUPYTER_PORT:-8888}" +COMFYUI_PORT="${COMFYUI_PORT:-8188}" +CONTAINER_NAME="${CONTAINER_NAME:-comfyui-tutorial-$(id -un)}" + +GPU_ENV=() +if [[ -n "${GPU_DEVICES:-}" ]]; then + GPU_ENV+=( -e "HIP_VISIBLE_DEVICES=${GPU_DEVICES}" ) +fi + +echo "Launcher settings:" +echo " IMAGE = $IMAGE" +echo " ComfyUI source = $ROOT (mounted -> /workload/ComfyUI)" +echo " JUPYTER_PORT = $JUPYTER_PORT" +echo " COMFYUI_PORT = $COMFYUI_PORT" +echo " CONTAINER_NAME = $CONTAINER_NAME" +if [[ -n "${GPU_DEVICES:-}" ]]; then + echo " GPU_DEVICES = $GPU_DEVICES (via HIP_VISIBLE_DEVICES)" +fi +echo + +if docker ps -a --format '{{.Names}}' | grep -qx "$CONTAINER_NAME"; then + echo "Removing stale container $CONTAINER_NAME ..." + docker rm -f "$CONTAINER_NAME" >/dev/null +fi + +# Inside-container script. We run it via `bash -lc "$INNER"` to keep this +# launcher self-contained (no second script file required inside the image). +INNER='set -euo pipefail +echo "[container] python: $(python --version 2>&1)" +echo "[container] torch: $(python -c "import torch,sys;print(torch.__version__,torch.version.hip)")" +echo "[container] GPU 0: $(python -c "import torch;print(torch.cuda.get_device_name(0) if torch.cuda.is_available() else \"n/a\")")" + +PIP_OPTS=( --quiet --timeout 60 --retries 3 ) + +echo "[container] Installing ComfyUI Python requirements (skipping torch*)..." +REQ=/tmp/comfyui-requirements.txt +grep -vE "^(torch|torchsde|torchvision|torchaudio)$" /workload/ComfyUI/requirements.txt > "$REQ" +pip install "${PIP_OPTS[@]}" -r "$REQ" +pip install "${PIP_OPTS[@]}" torchsde + +echo "[container] Installing JupyterLab + mesh-render deps..." +pip install "${PIP_OPTS[@]}" jupyterlab ipywidgets trimesh fast_simplification imageio "imageio[ffmpeg]" + +cd /workload/ComfyUI/examples +exec bash run_jupyter_server.sh +' + +DOCKER_FLAGS=( --rm ) +if [ -t 0 ] && [ -t 1 ]; then + DOCKER_FLAGS+=( -it ) + echo "Starting container interactively (Ctrl+C inside the JupyterLab process to stop)..." +else + DOCKER_FLAGS+=( -d ) + echo "stdin is not a TTY; starting container detached. Tail logs with:" + echo " docker logs -f $CONTAINER_NAME" +fi +echo + +exec docker run "${DOCKER_FLAGS[@]}" \ + --name "$CONTAINER_NAME" \ + --device=/dev/kfd \ + --device=/dev/dri \ + --group-add video \ + --cap-add=SYS_PTRACE \ + --security-opt seccomp=unconfined \ + --ipc=host \ + --shm-size=16G \ + -p "127.0.0.1:${JUPYTER_PORT}:8888" \ + -p "127.0.0.1:${COMFYUI_PORT}:8188" \ + -v "$ROOT:/workload/ComfyUI" \ + -e COMFYUI_PATH=/workload/ComfyUI \ + -e PYTHONUNBUFFERED=1 \ + "${GPU_ENV[@]}" \ + -w /workload/ComfyUI/examples \ + "$IMAGE" \ + bash -lc "$INNER" diff --git a/examples/run_jupyter_server.sh b/examples/run_jupyter_server.sh new file mode 100755 index 00000000000..943a2d942e6 --- /dev/null +++ b/examples/run_jupyter_server.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 Advanced Micro Devices, Inc. +# SPDX-License-Identifier: Apache-2.0 +# +# Start JupyterLab for amd_comfyui_rocm_tutorial.ipynb. +# +# Usage (from inside a ROCm-enabled Python environment): +# cd /workload/ComfyUI/examples +# ./run_jupyter_server.sh +# +# By default the server listens on 0.0.0.0 inside the container so that +# Docker port forwarding (`-p 8888:8888`) can reach it from the host. +# +# Open http://127.0.0.1:8888/lab?token=... in a browser. For remote nodes: +# ssh -L 8888:localhost:8888 -L 8188:localhost:8188 user@node +# +# Overrides: +# JUPYTER_PORT=8890 ./run_jupyter_server.sh # use a different port +# JUPYTER_IP=127.0.0.1 ./run_jupyter_server.sh # bind only to loopback +# +# Notes: +# * Port 8188 is reserved for the ComfyUI server that the notebook +# launches in cell 5 (subprocess.Popen ... main.py --listen --port 8188). +# Make sure the container is started with `-p 8188:8188` if you want +# to also reach the ComfyUI web UI from your host. + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd)" +cd "$ROOT" + +if ! python -c "import jupyterlab" 2>/dev/null; then + echo "Installing jupyterlab into the current Python environment..." + python -m pip install --quiet jupyterlab +fi + +PORT="${JUPYTER_PORT:-8888}" +IP="${JUPYTER_IP:-0.0.0.0}" + +echo "Starting JupyterLab from: $ROOT" +echo " URL: http://127.0.0.1:${PORT}/lab (use SSH -L if remote)" +echo " ComfyUI server (started by the notebook) will listen on :8188" +echo " Stop: Ctrl+C" +echo "" + +exec python -m jupyter lab \ + --no-browser \ + --ip="$IP" \ + --port="$PORT" \ + --allow-root \ + --notebook-dir="$ROOT" \ + --ServerApp.terminado_settings='{"shell_command": ["/bin/bash"]}' \ + "$@" diff --git a/examples/utils_comfyui.py b/examples/utils_comfyui.py new file mode 100644 index 00000000000..2a42bb9138c --- /dev/null +++ b/examples/utils_comfyui.py @@ -0,0 +1,445 @@ +# SPDX-FileCopyrightText: 2026 Advanced Micro Devices, Inc. +# SPDX-License-Identifier: Apache-2.0 +"""Helper plumbing for the ComfyUI-on-AMD-Instinct tutorial notebook. + +This module keeps the boilerplate out of the notebook so the cells stay short +and readable. It provides: + +- ``ComfyUIClient`` - a tiny HTTP client for a running ComfyUI server. +- ``start_comfyui_server`` - launch the server as a background process. +- ``stage_files`` - copy-or-download the tutorial inputs/workflows. +- ``require_files`` - report which expected files are missing. +- ``download_models`` - opt-in download of model checkpoints from Hugging Face. +- ``render_mesh_turntable``- deterministic CPU turntable preview of a .glb mesh. +- ``result_grid_html`` - build the inline photo / mesh / video result grid. +- ``benchmark_workflow`` - warm-up + timed iterations for Part 3. + +Only the Python standard library is imported at module load time; the heavier +mesh/render dependencies are imported lazily inside ``render_mesh_turntable``. +""" + +from __future__ import annotations + +import base64 +import json +import shutil +import subprocess +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +import uuid +from copy import deepcopy +from pathlib import Path +from typing import Any + + +# --------------------------------------------------------------------------- # +# Server lifecycle +# --------------------------------------------------------------------------- # +def server_is_up(server_url: str, timeout: float = 1.0) -> bool: + """Return True if the ComfyUI ``/system_stats`` endpoint answers with 200.""" + try: + with urllib.request.urlopen(f"{server_url}/system_stats", timeout=timeout) as r: + return r.status == 200 + except (urllib.error.URLError, TimeoutError, ConnectionError): + return False + + +def start_comfyui_server( + comfyui_path: Path, + server_url: str, + port: int, + log_path: Path, + timeout_s: float = 180.0, +) -> int | None: + """Start ``main.py --listen`` in the background and wait until it is ready. + + Returns the new process PID, or ``None`` if a server was already running at + ``server_url`` (in which case it is reused). + """ + if server_is_up(server_url): + print(f"ComfyUI already responding at {server_url}; reusing it.") + return None + + log_path = Path(log_path) + log_path.parent.mkdir(parents=True, exist_ok=True) + print(f"Starting ComfyUI server -> {server_url} (log: {log_path})") + proc = subprocess.Popen( + [sys.executable, str(Path(comfyui_path) / "main.py"), "--listen", "--port", str(port)], + stdout=open(log_path, "w"), + stderr=subprocess.STDOUT, + cwd=str(comfyui_path), + ) + deadline = time.time() + timeout_s + while time.time() < deadline: + if proc.poll() is not None: + raise RuntimeError( + f"ComfyUI server exited with code {proc.returncode}. See {log_path}." + ) + if server_is_up(server_url): + print(f"Server ready (pid={proc.pid}).") + return proc.pid + time.sleep(1.0) + raise TimeoutError(f"ComfyUI did not become ready within {timeout_s:.0f}s; see {log_path}.") + + +# --------------------------------------------------------------------------- # +# HTTP client +# --------------------------------------------------------------------------- # +class ComfyUIClient: + """Minimal client for a running ComfyUI server (``/prompt``, ``/history``, ``/view``).""" + + _OUTPUT_KEYS = ("images", "gifs", "videos", "files", "model", "meshes", "3d") + + def __init__(self, server_url: str) -> None: + self.server_url = server_url.rstrip("/") + + def queue_prompt(self, workflow: dict, client_id: str | None = None) -> str: + """POST a workflow to ``/prompt`` and return its ``prompt_id``.""" + payload: dict[str, Any] = {"prompt": workflow} + if client_id is not None: + payload["client_id"] = client_id + req = urllib.request.Request( + f"{self.server_url}/prompt", + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req) as resp: + body = json.loads(resp.read()) + if "prompt_id" not in body: + raise RuntimeError(f"/prompt did not return a prompt_id: {body}") + return body["prompt_id"] + + def wait_for_completion(self, prompt_id: str, poll_s: float = 1.0, timeout_s: float = 1800.0) -> dict: + """Poll ``/history/`` until the run finishes (or times out).""" + deadline = time.time() + timeout_s + while time.time() < deadline: + with urllib.request.urlopen(f"{self.server_url}/history/{prompt_id}") as resp: + hist = json.loads(resp.read()) + if prompt_id in hist: + entry = hist[prompt_id] + status = entry.get("status", {}) + if status.get("completed", False) or status.get("status_str") in ("success", "error"): + return entry + time.sleep(poll_s) + raise TimeoutError(f"Prompt {prompt_id} did not complete within {timeout_s}s.") + + def _view(self, filename: str, subfolder: str = "", folder_type: str = "output") -> bytes: + qs = urllib.parse.urlencode({"filename": filename, "subfolder": subfolder, "type": folder_type}) + with urllib.request.urlopen(f"{self.server_url}/view?{qs}") as resp: + return resp.read() + + def download_outputs(self, history_entry: dict, dest_dir: Path) -> list[Path]: + """Fetch every generated file referenced by ``history_entry`` into ``dest_dir``.""" + dest_dir = Path(dest_dir) + dest_dir.mkdir(parents=True, exist_ok=True) + saved: list[Path] = [] + for node_out in history_entry.get("outputs", {}).values(): + for key in self._OUTPUT_KEYS: + for item in node_out.get(key, []) or []: + fname = item.get("filename") + if not fname: + continue + data = self._view(fname, item.get("subfolder", ""), item.get("type", "output")) + out_path = dest_dir / fname + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_bytes(data) + saved.append(out_path) + return saved + + def run_workflow(self, workflow: dict, dest_dir: Path) -> tuple[list[Path], dict]: + """Submit a workflow, wait for it to finish, and download its outputs.""" + wf = deepcopy(workflow) + prompt_id = self.queue_prompt(wf, client_id=str(uuid.uuid4())) + entry = self.wait_for_completion(prompt_id) + return self.download_outputs(entry, dest_dir), entry + + +# --------------------------------------------------------------------------- # +# File staging +# --------------------------------------------------------------------------- # +def stage_files(names: list[str], dest_dir: Path, local_srcs: list[Path], base_url: str) -> None: + """Ensure each file in ``names`` exists in ``dest_dir``. + + Tries ``local_srcs`` first (a local checkout), then downloads from + ``{base_url}/{name}``. + """ + dest_dir = Path(dest_dir) + dest_dir.mkdir(parents=True, exist_ok=True) + for name in names: + dest = dest_dir / name + if dest.is_file(): + print(f" present: {name}") + continue + for src in local_srcs: + candidate = Path(src) / name + if candidate.is_file() and candidate.resolve() != dest.resolve(): + shutil.copy2(candidate, dest) + print(f" copied: {name} <- {src}") + break + else: + url = f"{base_url}/{name}" + print(f" download: {name} <- {url}") + urllib.request.urlretrieve(url, dest) + + +def require_files(paths: list[Path]) -> list[Path]: + """Return the subset of ``paths`` that do not exist on disk.""" + return [Path(p) for p in paths if not Path(p).is_file()] + + +def _download_with_progress(url: str, dest: Path) -> None: + """Stream ``url`` to ``dest`` (atomic via a .part file) with a progress line.""" + dest = Path(dest) + tmp = dest.with_suffix(dest.suffix + ".part") + + def _hook(block_num: int, block_size: int, total_size: int) -> None: + if total_size <= 0: + return + done = min(block_num * block_size, total_size) + pct = 100.0 * done / total_size + print(f"\r {pct:5.1f}% ({done / 1e9:.2f} / {total_size / 1e9:.2f} GB)", end="") + + urllib.request.urlretrieve(url, tmp, reporthook=_hook) + print() # newline after the progress line + tmp.replace(dest) + + +def download_models(models: dict[Path, str], enabled: bool) -> list[Path]: + """Fetch each ``{destination: url}`` model file that is not already present. + + Downloads are gated on ``enabled``: when it is ``False`` nothing is fetched + and the function only reports which files are missing, so the caller can + decide to skip the workflow. Existing files are always skipped, so re-running + is cheap. Returns the list of destinations still missing after the attempt. + """ + for dest, url in models.items(): + dest = Path(dest) + if dest.is_file(): + print(f" present: {dest.name}") + continue + if not enabled: + print(f" missing: {dest.name} (set DOWNLOAD_MODELS=True to fetch it automatically)") + continue + dest.parent.mkdir(parents=True, exist_ok=True) + print(f" download: {dest.name} <- {url}") + _download_with_progress(url, dest) + return require_files(list(models.keys())) + + +# --------------------------------------------------------------------------- # +# Mesh preview (deterministic, CPU-only - not an AI step) +# --------------------------------------------------------------------------- # +def render_mesh_turntable( + glb_path: Path, + out_mp4: Path, + n_frames: int = 36, + fps: int = 24, + target_faces: int = 40_000, + img_size: int = 480, + base_color: tuple[float, float, float] = (0.82, 0.55, 0.42), + elev_deg: float = 5.0, +) -> Path: + """Render a short MP4 turntable of a .glb mesh with painter's-algorithm shading. + + Pure CPU, no GL context. Heavy dependencies are imported here so that simply + importing this module does not require them. + """ + import numpy as np + import trimesh + import fast_simplification + from PIL import Image, ImageDraw + import imageio.v2 as imageio + + out_mp4 = Path(out_mp4) + out_mp4.parent.mkdir(parents=True, exist_ok=True) + mesh = trimesh.load(glb_path, force="mesh", process=True) + if len(mesh.faces) > target_faces: + verts, faces = fast_simplification.simplify( + mesh.vertices.astype(np.float32), + mesh.faces.astype(np.uint32), + target_count=target_faces, + ) + mesh = trimesh.Trimesh(vertices=verts, faces=faces, process=False) + + centre = (mesh.bounds[0] + mesh.bounds[1]) * 0.5 + mesh.vertices = mesh.vertices - centre + mesh.vertices = mesh.vertices / np.linalg.norm(mesh.vertices, axis=1).max() + + V = mesh.vertices.astype(np.float32) + F = mesh.faces.astype(np.int32) + + light = np.array([0.4, 0.6, 0.7], dtype=np.float32) + light /= np.linalg.norm(light) + base = np.array(base_color, dtype=np.float32) + + elev = np.radians(elev_deg) + Rx = np.array( + [[1, 0, 0], + [0, np.cos(elev), -np.sin(elev)], + [0, np.sin(elev), np.cos(elev)]], + dtype=np.float32, + ) + + H = W = img_size + half = (img_size - 4) * 0.5 + cx = cy = img_size * 0.5 + + frames = [] + for i in range(n_frames): + theta = np.radians(360.0 * i / n_frames) + c, s = np.cos(theta), np.sin(theta) + Ry = np.array([[c, 0, s], [0, 1, 0], [-s, 0, c]], dtype=np.float32) + VR = (V @ Ry.T) @ Rx.T + + tri = VR[F] + v0, v1, v2 = tri[:, 0], tri[:, 1], tri[:, 2] + normals = np.cross(v1 - v0, v2 - v0) + ln = np.linalg.norm(normals, axis=1, keepdims=True) + normals = normals / np.maximum(ln, 1e-8) + front = normals[:, 2] > 0.0 + if not front.any(): + frames.append(np.full((H, W, 3), 255, dtype=np.uint8)) + continue + v0f, v1f, v2f, nf = v0[front], v1[front], v2[front], normals[front] + + x0 = cx + v0f[:, 0] * half; y0 = cy - v0f[:, 1] * half; z0 = v0f[:, 2] + x1 = cx + v1f[:, 0] * half; y1 = cy - v1f[:, 1] * half; z1 = v1f[:, 2] + x2 = cx + v2f[:, 0] * half; y2 = cy - v2f[:, 1] * half; z2 = v2f[:, 2] + depth = (z0 + z1 + z2) / 3.0 + order = np.argsort(depth) + + intensity = np.clip(nf @ light, 0.18, 1.0) + face_rgb = (np.clip(base[None, :] * intensity[:, None], 0, 1) * 255).astype(np.uint8) + + img = Image.new("RGB", (W, H), (255, 255, 255)) + drw = ImageDraw.Draw(img, "RGB") + for idx in order: + r, g, b = int(face_rgb[idx, 0]), int(face_rgb[idx, 1]), int(face_rgb[idx, 2]) + drw.polygon( + [(x0[idx], y0[idx]), (x1[idx], y1[idx]), (x2[idx], y2[idx])], + fill=(r, g, b), + ) + frames.append(np.asarray(img)) + + imageio.mimsave(out_mp4, frames, fps=fps, codec="libx264", quality=8, macro_block_size=1) + return out_mp4 + + +# --------------------------------------------------------------------------- # +# Inline result grid +# --------------------------------------------------------------------------- # +def _b64(path: Path, mime: str) -> str: + return f"data:{mime};base64," + base64.b64encode(Path(path).read_bytes()).decode("ascii") + + +def result_grid_html( + inputs: list[tuple[str, Path]], + meshes: dict[str, Path], + turntable_videos: dict[str, Path], + wan_videos: dict[str, Path], +) -> str: + """Build a 3-row HTML grid (input photo / mesh turntable / AI video) as base64. + + The returned string is self-contained, so the rendered notebook needs no + external files. + """ + img_style = "width:280px;border:1px solid #ddd;border-radius:4px" + cap_style = "font-family:monospace;font-size:11px;margin-top:6px;line-height:1.5" + + def photo(name: str, asset: Path) -> str: + return ( + '' + f'' + f'
{name}
' + ) + + def video(mp4: Path | None, caption: str, empty: str) -> str: + if mp4 is None or not Path(mp4).is_file(): + return f'{empty}' + return ( + '' + f'' + f'
{caption}
' + ) + + n = len(inputs) + photo_row = "".join(photo(name, asset) for name, asset in inputs) + mesh_row = "".join( + video(turntable_videos.get(name), + f'{meshes[name].name}' if meshes.get(name) else "mesh", + "(no mesh turntable)") + for name, _ in inputs + ) + video_row = "".join( + video(wan_videos.get(name), "81 frames @ 24 fps", "(no video)") + for name, _ in inputs + ) + + def header(text: str) -> str: + return (f'{text}') + + return ( + '' + + header("Input photographs (768x768)") + f"{photo_row}" + + header("Hunyuan3D v2.1 mesh - deterministic CPU turntable render") + f"{mesh_row}" + + header("Wan2.2 5B image-to-video - AI-generated camera orbit") + f"{video_row}" + + "
" + ) + + +# --------------------------------------------------------------------------- # +# Part 3 benchmark harness (measures YOUR hardware; no published numbers) +# --------------------------------------------------------------------------- # +def _prep_workflow(wf_template: dict, image_input: str | None) -> dict: + wf = deepcopy(wf_template) + for node in wf.values(): + if image_input is not None and node.get("class_type") == "LoadImage": + node["inputs"]["image"] = image_input + if node.get("class_type") in ("KSampler", "KSamplerAdvanced") and "seed" in node.get("inputs", {}): + node["inputs"]["seed"] = int(time.time() * 1_000_000) % (2**63 - 1) + return wf + + +def benchmark_workflow( + client: ComfyUIClient, + workflow_path: Path, + checkpoints: list[Path], + image_input: str | None, + iterations: int, + out_dir: Path, +) -> dict | None: + """Run one warm-up pass plus ``iterations`` timed passes; return local stats. + + Returns ``None`` (and prints why) if the workflow JSON or any checkpoint is + missing. Timings are wall-clock seconds on the caller's own hardware. + """ + workflow_path = Path(workflow_path) + if not workflow_path.is_file(): + print(f" skipped - workflow JSON not found: {workflow_path}") + return None + missing = require_files(checkpoints) + if missing: + print(f" skipped - missing checkpoint(s): {[str(c) for c in missing]}") + return None + + with workflow_path.open() as f: + wf_template = json.load(f) + + print(" warm-up...") + client.run_workflow(_prep_workflow(wf_template, image_input), Path(out_dir) / "_warmup") + + times: list[float] = [] + for it in range(iterations): + t0 = time.perf_counter() + client.run_workflow(_prep_workflow(wf_template, image_input), Path(out_dir) / f"iter{it}") + dt = time.perf_counter() - t0 + times.append(dt) + print(f" iter {it}: {dt:.2f} s") + + mean = sum(times) / len(times) + var = sum((t - mean) ** 2 for t in times) / len(times) + return {"times_s": times, "mean_s": mean, "std_s": var ** 0.5} diff --git a/examples/workflows/hunyuan3d_v2_1_image_to_3d.json b/examples/workflows/hunyuan3d_v2_1_image_to_3d.json new file mode 100644 index 00000000000..34199569df1 --- /dev/null +++ b/examples/workflows/hunyuan3d_v2_1_image_to_3d.json @@ -0,0 +1,81 @@ +{ + "1": { + "class_type": "ImageOnlyCheckpointLoader", + "inputs": { + "ckpt_name": "hunyuan_3d_v2.1.safetensors" + } + }, + "2": { + "class_type": "LoadImage", + "inputs": { + "image": "amd_comfyui_tutorial_input.png" + } + }, + "3": { + "class_type": "ModelSamplingAuraFlow", + "inputs": { + "model": ["1", 0], + "shift": 1.0 + } + }, + "4": { + "class_type": "EmptyLatentHunyuan3Dv2", + "inputs": { + "resolution": 4096, + "batch_size": 1 + } + }, + "6": { + "class_type": "Hunyuan3Dv2Conditioning", + "inputs": { + "clip_vision_output": ["13", 0] + } + }, + "7": { + "class_type": "KSampler", + "inputs": { + "model": ["3", 0], + "positive": ["6", 0], + "negative": ["6", 1], + "latent_image": ["4", 0], + "seed": 952805179515179, + "steps": 30, + "cfg": 5.0, + "sampler_name": "euler", + "scheduler": "normal", + "denoise": 1.0 + } + }, + "8": { + "class_type": "VAEDecodeHunyuan3D", + "inputs": { + "samples": ["7", 0], + "vae": ["1", 2], + "num_chunks": 8000, + "octree_resolution": 256 + } + }, + "9": { + "class_type": "VoxelToMesh", + "inputs": { + "voxel": ["8", 0], + "algorithm": "surface net", + "threshold": 0.6 + } + }, + "10": { + "class_type": "SaveGLB", + "inputs": { + "mesh": ["9", 0], + "filename_prefix": "amd_tutorial/hunyuan3d_v2_1" + } + }, + "13": { + "class_type": "CLIPVisionEncode", + "inputs": { + "clip_vision": ["1", 1], + "image": ["2", 0], + "crop": "center" + } + } +} diff --git a/examples/workflows/wan2_2_5b_image_to_video.json b/examples/workflows/wan2_2_5b_image_to_video.json new file mode 100644 index 00000000000..7aab1ec6bb1 --- /dev/null +++ b/examples/workflows/wan2_2_5b_image_to_video.json @@ -0,0 +1,99 @@ +{ + "37": { + "class_type": "UNETLoader", + "inputs": { + "unet_name": "wan2.2_ti2v_5B_fp16.safetensors", + "weight_dtype": "default" + } + }, + "38": { + "class_type": "CLIPLoader", + "inputs": { + "clip_name": "umt5_xxl_fp8_e4m3fn_scaled.safetensors", + "type": "wan", + "device": "default" + } + }, + "39": { + "class_type": "VAELoader", + "inputs": { + "vae_name": "wan2.2_vae.safetensors" + } + }, + "48": { + "class_type": "ModelSamplingSD3", + "inputs": { + "shift": 8, + "model": ["37", 0] + } + }, + "56": { + "class_type": "LoadImage", + "inputs": { + "image": "amd_comfyui_tutorial_apple_seed.png" + } + }, + "55": { + "class_type": "Wan22ImageToVideoLatent", + "inputs": { + "width": 768, + "height": 768, + "length": 81, + "batch_size": 1, + "vae": ["39", 0], + "start_image": ["56", 0] + } + }, + "6": { + "class_type": "CLIPTextEncode", + "inputs": { + "text": "Smooth slow 360 degree camera orbit around a single piece of fruit centred on a clean white background. The camera moves steadily and continuously around the subject. The subject stays centred, stationary, and rests on its base. Photorealistic studio product photography, sharp focus, soft natural lighting, shallow depth of field. No people, no hands, no text.", + "clip": ["38", 0] + } + }, + "7": { + "class_type": "CLIPTextEncode", + "inputs": { + "text": "human, person, hands, fingers, body, face, arms, lamp, table, text, watermark, logo, low quality, blurry, jpeg artifacts, distorted, deformed, extra limbs, motion blur, multiple subjects, duplicates, tilted, falling, flying, cartoon", + "clip": ["38", 0] + } + }, + "3": { + "class_type": "KSampler", + "inputs": { + "seed": 952805179515179, + "steps": 20, + "cfg": 5, + "sampler_name": "uni_pc", + "scheduler": "simple", + "denoise": 1, + "model": ["48", 0], + "positive": ["6", 0], + "negative": ["7", 0], + "latent_image": ["55", 0] + } + }, + "8": { + "class_type": "VAEDecode", + "inputs": { + "samples": ["3", 0], + "vae": ["39", 0] + } + }, + "57": { + "class_type": "CreateVideo", + "inputs": { + "fps": 24, + "images": ["8", 0] + } + }, + "58": { + "class_type": "SaveVideo", + "inputs": { + "filename_prefix": "amd_tutorial/wan22_5b_i2v", + "format": "auto", + "codec": "auto", + "video": ["57", 0] + } + } +}