{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# ComfyUI 文生图 + 文生视频 + 图生视频（Kaggle GPU，双卡双开）\n",
    "\n",
    "能做的三件事：**文生图**（Z-Image Turbo，8 步出图）、**文生视频**（Wan2.1 T2V-1.3B）、**图生视频**（Wan2.2 TI2V-5B，传图进去再生成视频）。\n",
    "\n",
    "Kaggle 免费给两张 T4（每张 16GB 显存），这个 Notebook 可以起**两个 ComfyUI 实例**，各用一张卡、互不干扰：一个跑十几分钟的视频，另一个同时出图。\n",
    "\n",
    "**第一次跑**：按顺序跑 1～6 号单元。第 3 号单元下载约 41GB 模型（6 个基础文件约 30GB + 图生视频 Wan2.2 约 11.4GB），跑得久是正常的。第 5 号单元（第二个实例）是可选的，不需要双开就跳过。\n",
    "\n",
    "**以后再跑**：把模型发布成 Kaggle Dataset 一次（见末尾附录单元），下次开 Notebook 时在右侧 **Add data** 里挂载 `comfyui-models` 这个 Dataset，第 3 号单元会自动跳过下载、直接用挂载的模型（旧版 6 文件 Dataset 也行，只补下新增的 2 个）。\n",
    "\n",
    "**前置条件**：右侧 Settings 里 Accelerator 选 **GPU（T4 x2）**，Internet 设为 **ON**。想用固定网址的话，在 Add-ons → Secrets 里加 `CF_TUNNEL_TOKEN` 并勾选授权；不加也行，会自动用临时网址。附录发布单元另需 `KAGGLE_API_TOKEN`。\n",
    "\n",
    "**固定网址**：一条隧道 `comfy` 上挂两个路由——`comfy.goodpcb.kdns.fr` → 8188（第一个实例），`comfy2.goodpcb.kdns.fr` → 8189（第二个实例）。路由在隧道面板里配好就行，Notebook 里一个连接器两个路由一起接。\n",
    "\n",
    "**提醒**：两个固定域名目前都没有 Access 拦截，记得在 Zero Trust → Access 里加上（一个应用可加多个域名），否则谁拿到网址都能用你的 GPU 额度、看你生成的内容。\n",
    "\n",
    "**费用**：Kaggle 免费 GPU 每周约 30 小时，会话最长 12 小时。\n"
   ],
   "id": "cell-00"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# 1/6 检查 GPU 与磁盘\n",
    "import subprocess, os\n",
    "r = subprocess.run([\"nvidia-smi\", \"--query-gpu=name,memory.total\", \"--format=csv,noheader\"],\n",
    "                   capture_output=True, text=True)\n",
    "print(\"GPU:\", r.stdout.strip() or \"未检测到！请在右侧 Settings 里把 Accelerator 设为 GPU。\")\n",
    "print()\n",
    "# /kaggle/temp 默认不存在，先建出来（它是会话内的临时空间，比 20GB 的 working 大得多）\n",
    "for p in (\"/kaggle/temp\", \"/kaggle/working/comfy-output\"):\n",
    "    try:\n",
    "        os.makedirs(p, exist_ok=True)\n",
    "    except OSError as e:\n",
    "        print(\"建目录失败：\", p, e)\n",
    "subprocess.run([\"df\", \"-h\", \"/kaggle/temp\", \"/kaggle/working\"])\n",
    "print()\n",
    "print(\"模型约 41GB（8 个文件），存在 /kaggle/temp（临时空间，会话结束会清掉）；\")\n",
    "print(\"生成的图/视频存在 /kaggle/working/comfy-output（随 Notebook 版本保存）。\")\n"
   ],
   "id": "cell-01"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# 2/6 安装 ComfyUI（装到临时空间）\n",
    "import subprocess, os\n",
    "BASE = \"/kaggle/temp/comfyui-kaggle\"\n",
    "try:\n",
    "    os.makedirs(BASE, exist_ok=True)\n",
    "except OSError:\n",
    "    BASE = \"/tmp/comfyui-kaggle\"  # /kaggle/temp 不可写时改用 /tmp\n",
    "    os.makedirs(BASE, exist_ok=True)\n",
    "    print(\"改用 /tmp 存放。\")\n",
    "print(\"安装目录：\", BASE)\n",
    "if not os.path.isdir(f\"{BASE}/ComfyUI\"):\n",
    "    print(\"克隆 ComfyUI...\")\n",
    "    subprocess.run([\"git\", \"clone\", \"--depth\", \"1\",\n",
    "                    \"https://github.com/comfyanonymous/ComfyUI.git\", f\"{BASE}/ComfyUI\"], check=True)\n",
    "else:\n",
    "    print(\"ComfyUI 已存在，跳过克隆。\")\n",
    "print(\"安装依赖（Kaggle 自带 torch，一般只需补少量包）...\")\n",
    "subprocess.run([\"pip\", \"install\", \"-q\", \"--disable-pip-version-check\", \"-r\", \"requirements.txt\"],\n",
    "               cwd=f\"{BASE}/ComfyUI\", check=True)\n",
    "import torch\n",
    "print(\"torch\", torch.__version__, \"| CUDA 可用:\", torch.cuda.is_available())\n"
   ],
   "id": "cell-02"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# 3/6 模型：优先挂载 Kaggle Dataset，挂不上才下载（约 41GB，断点续传）\n",
    "import subprocess, os\n",
    "BASE = globals().get(\"BASE\", \"/kaggle/temp/comfyui-kaggle\")\n",
    "COMFY = f\"{BASE}/ComfyUI\"\n",
    "M = f\"{COMFY}/models\"\n",
    "for d in (\"diffusion_models\", \"text_encoders\", \"vae\"):\n",
    "    os.makedirs(f\"{M}/{d}\", exist_ok=True)\n",
    "\n",
    "FILES = [\n",
    "    # 文生图：Z-Image Turbo（Comfy-Org 官方重打包，非门控，无需 token）\n",
    "    (\"https://huggingface.co/Comfy-Org/Z-Image-Turbo/resolve/main/split_files/diffusion_models/z_image_turbo_bf16.safetensors\", \"diffusion_models\", \"z_image_turbo_bf16.safetensors\"),\n",
    "    (\"https://huggingface.co/Comfy-Org/Z-Image-Turbo/resolve/main/split_files/text_encoders/qwen_3_4b.safetensors\", \"text_encoders\", \"qwen_3_4b.safetensors\"),\n",
    "    (\"https://huggingface.co/Comfy-Org/Z-Image-Turbo/resolve/main/split_files/vae/ae.safetensors\", \"vae\", \"ae.safetensors\"),\n",
    "    # 文生视频：Wan2.1 T2V 1.3B（Comfy-Org 官方重打包）\n",
    "    (\"https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_t2v_1.3B_fp16.safetensors\", \"diffusion_models\", \"wan2.1_t2v_1.3B_fp16.safetensors\"),\n",
    "    (\"https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/text_encoders/umt5_xxl_fp8_e4m3fn_scaled.safetensors\", \"text_encoders\", \"umt5_xxl_fp8_e4m3fn_scaled.safetensors\"),\n",
    "    (\"https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/vae/wan_2.1_vae.safetensors\", \"vae\", \"wan_2.1_vae.safetensors\"),\n",
    "    # 图生视频：Wan2.2 TI2V 5B（Comfy-Org 官方重打包；文本编码器复用 Wan2.1 的 umt5，不用重下）\n",
    "    (\"https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_ti2v_5B_fp16.safetensors\", \"diffusion_models\", \"wan2.2_ti2v_5B_fp16.safetensors\"),\n",
    "    (\"https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/vae/wan2.2_vae.safetensors\", \"vae\", \"wan2.2_vae.safetensors\"),\n",
    "]\n",
    "\n",
    "# Dataset 挂载后的样子：/kaggle/input/comfyui-models/diffusion_models/...\n",
    "DATASET = \"/kaggle/input/comfyui-models\"\n",
    "mounted = all(os.path.isfile(f\"{DATASET}/{sub}/{name}\") for _, sub, name in FILES)\n",
    "if mounted:\n",
    "    print(\"检测到已挂载的 Dataset（comfyui-models），跳过下载。\")\n",
    "    # 写 extra_model_paths.yaml，让 ComfyUI 直接读挂载的只读文件（启动前必须写好）\n",
    "    yaml_text = (\n",
    "        \"kaggle_models:\\n\"\n",
    "        f\"    base_path: {DATASET}\\n\"\n",
    "        \"    diffusion_models: diffusion_models\\n\"\n",
    "        \"    text_encoders: text_encoders\\n\"\n",
    "        \"    vae: vae\\n\"\n",
    "    )\n",
    "    with open(f\"{COMFY}/extra_model_paths.yaml\", \"w\") as f:\n",
    "        f.write(yaml_text)\n",
    "    print(\"已写 extra_model_paths.yaml，ComfyUI 将直接读取 Dataset 里的模型。\")\n",
    "else:\n",
    "    if os.path.isdir(DATASET):\n",
    "        print(\"Dataset 已挂载但文件不全，先从 Dataset 里复制已有的，只下载缺的。\")\n",
    "        import shutil\n",
    "        for _, sub, name in FILES:\n",
    "            s, d = f\"{DATASET}/{sub}/{name}\", f\"{M}/{sub}/{name}\"\n",
    "            if os.path.isfile(s) and not os.path.isfile(d):\n",
    "                print(\"从 Dataset 复制：\", name)\n",
    "                shutil.copy(s, d)\n",
    "    for url, sub, name in FILES:\n",
    "        dest = f\"{M}/{sub}/{name}\"\n",
    "        if os.path.isfile(dest) and os.path.getsize(dest) > 0:\n",
    "            print(\"已存在，跳过：\", name)\n",
    "            continue\n",
    "        print(\"下载：\", name)\n",
    "        # 先下到 .tmp，成功后再改名：中途停掉重跑时，残文件不会被误判成“已下载完”\n",
    "        subprocess.run([\"wget\", \"-c\", \"-q\", \"--show-progress\", \"-O\", dest + \".tmp\", url], check=True)\n",
    "        os.replace(dest + \".tmp\", dest)\n",
    "    print(\"全部下载完成。\")\n"
   ],
   "id": "cell-03"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# 4/6 启动第一个 ComfyUI（GPU 0，端口 8188，输出保存到 /kaggle/working）\n",
    "import subprocess, time, urllib.request, os\n",
    "BASE = globals().get(\"BASE\", \"/kaggle/temp/comfyui-kaggle\")\n",
    "COMFY = f\"{BASE}/ComfyUI\"\n",
    "OUT = \"/kaggle/working/comfy-output\"\n",
    "os.makedirs(OUT, exist_ok=True)\n",
    "\n",
    "def alive():\n",
    "    try:\n",
    "        with urllib.request.urlopen(\"http://127.0.0.1:8188/\", timeout=3) as r:\n",
    "            return r.status == 200\n",
    "    except Exception:\n",
    "        return False\n",
    "\n",
    "if not alive():\n",
    "    log = open(f\"{BASE}/comfyui.log\", \"ab\")\n",
    "    proc = subprocess.Popen(\n",
    "        [\"python\", \"main.py\", \"--listen\", \"127.0.0.1\", \"--port\", \"8188\",\n",
    "         \"--output-directory\", OUT],\n",
    "        cwd=COMFY, stdout=log, stderr=subprocess.STDOUT, start_new_session=True)\n",
    "    print(\"ComfyUI 启动中，PID:\", proc.pid)\n",
    "    for _ in range(60):\n",
    "        if alive():\n",
    "            break\n",
    "        time.sleep(5)\n",
    "    else:\n",
    "        raise RuntimeError(\"300 秒内未就绪，请查看日志：\" + BASE + \"/comfyui.log\")\n",
    "print(\"ComfyUI 已就绪（http://127.0.0.1:8188，仅本机可达）。\")\n",
    "print(\"浏览器打不开这个地址是正常的——继续跑下一个单元，拿一个公网网址。\")\n"
   ],
   "id": "cell-04"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# 5/6 启动第二个 ComfyUI（可选：GPU 1，端口 8189）——不需要双开就跳过这个单元\n",
    "# 原理：CUDA_VISIBLE_DEVICES=1 让这个进程只看到 1 号卡（nvidia-smi 里的 GPU 1，闲置那张）；\n",
    "# 进程里它仍叫 cuda:0，ComfyUI 不用改任何配置。模型文件两个实例共用一份，不用重下。\n",
    "import subprocess, time, urllib.request, os\n",
    "BASE = globals().get(\"BASE\", \"/kaggle/temp/comfyui-kaggle\")\n",
    "COMFY = f\"{BASE}/ComfyUI\"\n",
    "OUT2 = \"/kaggle/working/comfy-output-2\"\n",
    "USER2 = f\"{BASE}/ComfyUI-user2\"\n",
    "os.makedirs(OUT2, exist_ok=True)\n",
    "os.makedirs(USER2, exist_ok=True)  # 关键：--user-directory 指向的目录必须事先存在，ComfyUI 不会自己建\n",
    "\n",
    "def alive2():\n",
    "    try:\n",
    "        with urllib.request.urlopen(\"http://127.0.0.1:8189/\", timeout=3) as r:\n",
    "            return r.status == 200\n",
    "    except Exception:\n",
    "        return False\n",
    "\n",
    "if not alive2():\n",
    "    log = open(f\"{BASE}/comfyui2.log\", \"ab\")\n",
    "    env = os.environ.copy()\n",
    "    env[\"CUDA_VISIBLE_DEVICES\"] = \"1\"\n",
    "    proc = subprocess.Popen(\n",
    "        [\"python\", \"main.py\", \"--listen\", \"127.0.0.1\", \"--port\", \"8189\",\n",
    "         \"--output-directory\", OUT2,\n",
    "         \"--user-directory\", USER2],\n",
    "        cwd=COMFY, stdout=log, stderr=subprocess.STDOUT, start_new_session=True, env=env)\n",
    "    print(\"第二个 ComfyUI 启动中，PID:\", proc.pid)\n",
    "    for _ in range(60):\n",
    "        if alive2():\n",
    "            break\n",
    "        time.sleep(5)\n",
    "    else:\n",
    "        raise RuntimeError(\"300 秒内未就绪，请查看日志：\" + BASE + \"/comfyui2.log\")\n",
    "print(\"第二个 ComfyUI 已就绪（http://127.0.0.1:8189，仅本机可达）。\")\n",
    "print(\"继续跑下一个单元拿公网网址：固定隧道是 comfy2.goodpcb.kdns.fr。\")\n"
   ],
   "id": "cell-05"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# 6/6 隧道：优先用固定隧道（Kaggle Secrets 里的 CF_TUNNEL_TOKEN），没有就用 Cloudflare 临时隧道\n",
    "import subprocess, time, re, os, urllib.request\n",
    "\n",
    "# 固定隧道的网址（只用来在输出里显示，不影响运行）。\n",
    "# 一条隧道上挂两个路由：comfy → 8188（第一个实例），comfy2 → 8189（第二个实例），路由在隧道面板里配。\n",
    "FIXED_URLS = [\"https://comfy.goodpcb.kdns.fr\", \"https://comfy2.goodpcb.kdns.fr\"]\n",
    "\n",
    "def get_secret(name):\n",
    "    v = os.environ.get(name, \"\").strip()\n",
    "    if v:\n",
    "        return v\n",
    "    try:\n",
    "        from kaggle_secrets import UserSecretsClient\n",
    "        return UserSecretsClient().get_secret(name).strip()\n",
    "    except Exception:\n",
    "        return \"\"\n",
    "\n",
    "def port_alive(port):\n",
    "    try:\n",
    "        with urllib.request.urlopen(f\"http://127.0.0.1:{port}/\", timeout=3) as r:\n",
    "            return r.status == 200\n",
    "    except Exception:\n",
    "        return False\n",
    "\n",
    "def wait_for_url(logpath, tries=30):\n",
    "    # cloudflared 会把临时网址打到日志里，轮询日志把它捞出来\n",
    "    for _ in range(tries):\n",
    "        time.sleep(2)\n",
    "        try:\n",
    "            text = open(logpath, errors=\"replace\").read()\n",
    "        except FileNotFoundError:\n",
    "            continue\n",
    "        m = re.search(r\"https://[a-zA-Z0-9-]+\\.trycloudflare\\.com\", text)\n",
    "        if m:\n",
    "            return m.group(0)\n",
    "    return None\n",
    "\n",
    "BASE = globals().get(\"BASE\", \"/kaggle/temp/comfyui-kaggle\")\n",
    "BIN = f\"{BASE}/cloudflared\"\n",
    "LOG = f\"{BASE}/cloudflared.log\"\n",
    "LOG2 = f\"{BASE}/cloudflared2.log\"\n",
    "subprocess.run([\"pkill\", \"-f\", \"cloudflared\"], capture_output=True)\n",
    "if not os.path.exists(BIN):\n",
    "    print(\"下载 cloudflared...\")\n",
    "    subprocess.run([\"wget\", \"-q\",\n",
    "                    \"https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64\",\n",
    "                    \"-O\", BIN], check=True)\n",
    "    os.chmod(BIN, 0o755)\n",
    "\n",
    "token = get_secret(\"CF_TUNNEL_TOKEN\")\n",
    "if token:\n",
    "    print(\"检测到 CF_TUNNEL_TOKEN，启动固定隧道（一个连接器，两个路由一起接）...\")\n",
    "    log = open(LOG, \"ab\")\n",
    "    env = os.environ.copy()\n",
    "    env[\"TUNNEL_TOKEN\"] = token  # 走环境变量，不出现在命令行参数里\n",
    "    proc = subprocess.Popen([BIN, \"tunnel\", \"--no-autoupdate\", \"run\"],\n",
    "                            stdout=log, stderr=subprocess.STDOUT, start_new_session=True, env=env)\n",
    "    time.sleep(8)\n",
    "    print(\"固定隧道已启动，打开：\")\n",
    "    for u in FIXED_URLS:\n",
    "        print(\" \", u)\n",
    "    print(\"如果打不开，查看日志：\", LOG)\n",
    "else:\n",
    "    print(\"没有 CF_TUNNEL_TOKEN，改用 Cloudflare 临时隧道（网址每次随机）...\")\n",
    "    log = open(LOG, \"ab\")\n",
    "    proc = subprocess.Popen([BIN, \"tunnel\", \"--url\", \"http://127.0.0.1:8188\"],\n",
    "                            stdout=log, stderr=subprocess.STDOUT, start_new_session=True)\n",
    "    url = wait_for_url(LOG)\n",
    "    if url:\n",
    "        print(\"\\n第一个 ComfyUI 网址（点开就能用）：\", url)\n",
    "    else:\n",
    "        print(\"没拿到第一个实例的隧道网址。等一分钟再跑一次这个单元，或查看日志：\", LOG)\n",
    "    if port_alive(8189):\n",
    "        print(\"检测到第二个实例在跑，给它也开一条临时隧道...\")\n",
    "        log2 = open(LOG2, \"ab\")\n",
    "        proc2 = subprocess.Popen([BIN, \"tunnel\", \"--url\", \"http://127.0.0.1:8189\"],\n",
    "                                 stdout=log2, stderr=subprocess.STDOUT, start_new_session=True)\n",
    "        url2 = wait_for_url(LOG2)\n",
    "        if url2:\n",
    "            print(\"第二个 ComfyUI 网址（点开就能用）：\", url2)\n",
    "        else:\n",
    "            print(\"没拿到第二个实例的隧道网址，查看日志：\", LOG2)\n",
    "    else:\n",
    "        print(\"第二个实例没在跑（想双开先跑 5/6 单元），只开了第一个的临时隧道。\")\n",
    "    print(\"注意：临时网址每次开机都变，会话结束就失效；不要公开它。\")\n"
   ],
   "id": "cell-06"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 打开界面后怎么用\n",
    "\n",
    "**界面地址**：固定隧道是 `https://comfy.goodpcb.kdns.fr`（第一个实例，GPU 0）和 `https://comfy2.goodpcb.kdns.fr`（第二个实例，GPU 1）；没配 Token 就用第 6 号单元输出的临时网址。\n",
    "\n",
    "### 三种模式（各用各的模板）\n",
    "\n",
    "1. 点 **Workflow → Browse Templates**（模板库），搜索并载入：\n",
    "   - 文生图搜 **Z-Image Turbo**；\n",
    "   - 文生视频搜 **Wan 2.1 Text to Video**；\n",
    "   - 图生视频搜 **Wan 2.2**，点 **TI2V 5B** 那个模板（模板里有 Load Image 节点，传图进去再写提示词）。\n",
    "2. 模板里的三个加载器（扩散模型 / 文本编码器 / VAE）从下拉里选对文件：\n",
    "   - 文生图：`z_image_turbo_bf16.safetensors`、`qwen_3_4b.safetensors`、`ae.safetensors`；\n",
    "   - 文生视频：`wan2.1_t2v_1.3B_fp16.safetensors`、`umt5_xxl_fp8_e4m3fn_scaled.safetensors`、`wan_2.1_vae.safetensors`；\n",
    "   - 图生视频：`wan2.2_ti2v_5B_fp16.safetensors`、`umt5_xxl_fp8_e4m3fn_scaled.safetensors`（和 Wan2.1 共用）、`wan2.2_vae.safetensors`（Wan2.2 专用，和 2.1 的 VAE 不通用，别混用）。\n",
    "3. 写提示词（英文更稳），点 **Queue Prompt**，等出图/出视频。成品在界面右侧队列里点开看、下载；文件也存在 `/kaggle/working/comfy-output`（第二个实例是 `comfy-output-2`）里，会随 Notebook 版本保存。\n",
    "\n",
    "**运行按钮旁边的数字是排队次数**：显示 6 就是点一次运行连跑 6 次（每次种子递增）。跑视频前先改成 1，不然 6 条连跑要烧一两个小时额度。\n",
    "\n",
    "### 双开说明\n",
    "\n",
    "- 两个实例队列、历史各自独立：一个跑视频时，另一个照常出图，互不干扰。\n",
    "- **画布是按网址存在浏览器本地的**：换个网址打开，之前搭的工作流不会自动跟过来。在原来的标签页里点菜单保存/导出工作流，再到另一个网址的标签页里打开/导入；固定用一个网址就不会岔开。\n",
    "\n",
    "### 示例：小猫跳迈克的舞\n",
    "\n",
    "先用 Z-Image 模板出图（提示词）：\n",
    "\n",
    "`An adorable fluffy orange tabby kitten standing on its hind legs on a concert stage, wearing a black fedora hat, a black sequined jacket and a single white sparkling glove on its front paw, one paw tipping the hat, dramatic spotlight from above, dark background with colorful stage lights, cute and photorealistic, full body shot, centered composition`\n",
    "\n",
    "再用 Wan2.2 TI2V 模板，把图传到 Load Image 节点（提示词）：\n",
    "\n",
    "`The kitten dances like Michael Jackson: it moonwalks backwards smoothly across the stage, then spins around on its hind legs, tips its fedora with its paw, and does a smooth 45-degree lean, tail swishing to the rhythm, stage lights flashing, smooth natural motion, full body visible throughout`\n",
    "\n",
    "负面提示词：`blurry, distorted, extra limbs, deformed paws, static image, no movement, low quality`\n",
    "\n",
    "先拿小分辨率、短帧数试一条，确认动作对了再上 720P。\n",
    "\n",
    "## 常见问题\n",
    "\n",
    "- **显存不足（OOM）**：Z-Image 的 BF16 版在 16GB 卡上很满。如果报错，把扩散模型换成同一仓库里的 `z_image_turbo_int8_convrot.safetensors`（6.2GB）、文本编码器换成 `qwen_3_4b_fp8_mixed.safetensors`（5.6GB）；视频则把分辨率/帧数调低。\n",
    "- **视频很慢**：正常。T4 上一条 5 秒 480P 短片要几分钟到十几分钟，别反复点 Queue。图生视频默认 720P，T4 上一条 5 秒做好十几二十分钟的准备；想先试效果可以把分辨率改小一半。\n",
    "- **别在不同模型的工作流之间只换文件**：Z-Image、Wan2.1、Wan2.2 各有自己的模板（加载器类型、文本编码器、潜空间节点都不一样），直接在 Z-Image 工作流里换成 Wan 的文件会报维度对不上（`mat1 and mat2 shapes cannot be multiplied` / CLIPLoader 词表大小不匹配）。换模型就换模板。\n",
    "- **实例起不来**：看日志，关键看最后一行。第一个实例：`comfyui.log`；第二个实例：`comfyui2.log`；隧道：`cloudflared.log`（都在安装目录下）。启动单元里循环等端口没反应时，先点 Stop 中断，再看日志。\n",
    "- **会话结束后**：模型文件在临时空间里会被清掉，下次开机重新跑 1～6 号单元（第 3 号单元挂载 Dataset 后会跳过下载）；生成的图/视频在 `/kaggle/working/comfy-output` 和 `comfy-output-2` 里，会跟着 Notebook 版本保存。\n",
    "- **固定隧道**：Token 放 Kaggle Secrets（`CF_TUNNEL_TOKEN`）后，每次开机网址不变。路由在隧道面板里配：`comfy` → 8188，`comfy2` → 8189。\n",
    "- **隧道网址打不开**：重新跑第 6 号单元；Cloudflare 临时隧道偶尔抽风，多试一次一般就好。\n"
   ],
   "id": "cell-07"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 附录：把模型发布成 Kaggle Dataset（只需做一次）\n",
    "\n",
    "跑完第 3 号单元（模型已下载）后，跑下面这个单元，模型会上传成你账号下的**私有** Dataset（默认私有，不公开）。6 个基础文件约 30GB；如果 Wan2.2 那 2 个文件（约 11.4GB）也已下载，会一起上传，否则只发 6 个文件、以后下完 Wan2.2 再跑一次本单元推新版本就行。以后每次开 Notebook，在右侧 **Add data** 里挂载 `comfyui-models`，第 3 号单元会自动跳过下载。\n",
    "\n",
    "前置两步：\n",
    "1. 在 Kaggle 设置页（https://www.kaggle.com/settings/api）点 **Generate New Token**，生成时页面只显示一次 token 值（`KGAT_` 开头），复制下来；没复制就作废重建一个。\n",
    "2. 把 token 加到这个 Notebook 的 Add-ons → Secrets 里，名字是 `KAGGLE_API_TOKEN`，并勾选授权；再把下面单元里的 `USERNAME` 改成你的 Kaggle 用户名（个人主页 URL `kaggle.com/<用户名>` 里那个名字）。\n"
   ],
   "id": "cell-08"
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "# 附录：把已下载的模型发布成 Kaggle 私有 Dataset（只需做一次）\n",
    "# 前置：USERNAME 改成你的 Kaggle 用户名；Add-ons → Secrets 里加 KAGGLE_API_TOKEN 并勾选授权。\n",
    "# Token 在 Kaggle 设置页（https://www.kaggle.com/settings/api）点 Generate New Token 生成，只显示一次，生成时复制下来。\n",
    "import subprocess, os, json\n",
    "\n",
    "USERNAME = \"axtopeandy\"  # 你的 Kaggle 用户名\n",
    "\n",
    "def get_secret(name):\n",
    "    v = os.environ.get(name, \"\").strip()\n",
    "    if v:\n",
    "        return v\n",
    "    try:\n",
    "        from kaggle_secrets import UserSecretsClient\n",
    "        return UserSecretsClient().get_secret(name) or \"\"\n",
    "    except Exception:\n",
    "        return \"\"\n",
    "\n",
    "API_TOKEN = get_secret(\"KAGGLE_API_TOKEN\")\n",
    "if not API_TOKEN:\n",
    "    raise SystemExit(\"没读到 KAGGLE_API_TOKEN，请先在 Add-ons → Secrets 里添加并授权给这个 Notebook。\")\n",
    "if USERNAME in (\"你的Kaggle用户名\", \"\"):\n",
    "    raise SystemExit(\"先把代码里 USERNAME 改成你的 Kaggle 用户名。\")\n",
    "\n",
    "# 新版 token 认环境变量；同时写 access_token 文件双保险\n",
    "os.environ[\"KAGGLE_API_TOKEN\"] = API_TOKEN\n",
    "kdir = os.path.expanduser(\"~/.kaggle\")\n",
    "os.makedirs(kdir, exist_ok=True)\n",
    "with open(f\"{kdir}/access_token\", \"w\") as f:\n",
    "    f.write(API_TOKEN)\n",
    "os.chmod(f\"{kdir}/access_token\", 0o600)\n",
    "\n",
    "# 新版 token 需要 kaggle CLI >= 1.8，装到最新\n",
    "print(\"确认 kaggle CLI 版本...\")\n",
    "subprocess.run([\"pip\", \"install\", \"-q\", \"--disable-pip-version-check\", \"-U\", \"kaggle\"], check=True)\n",
    "r = subprocess.run([\"kaggle\", \"--version\"], capture_output=True, text=True)\n",
    "print(\"kaggle CLI:\", (r.stdout or r.stderr).strip())\n",
    "\n",
    "BASE = globals().get(\"BASE\", \"/kaggle/temp/comfyui-kaggle\")\n",
    "MODELS = f\"{BASE}/ComfyUI/models\"\n",
    "EXPECTED = [\n",
    "    \"diffusion_models/z_image_turbo_bf16.safetensors\",\n",
    "    \"text_encoders/qwen_3_4b.safetensors\",\n",
    "    \"vae/ae.safetensors\",\n",
    "    \"diffusion_models/wan2.1_t2v_1.3B_fp16.safetensors\",\n",
    "    \"text_encoders/umt5_xxl_fp8_e4m3fn_scaled.safetensors\",\n",
    "    \"vae/wan_2.1_vae.safetensors\",\n",
    "]\n",
    "missing = [p for p in EXPECTED if not os.path.isfile(f\"{MODELS}/{p}\")]\n",
    "if missing:\n",
    "    raise SystemExit(\"以下模型文件还没下载完，先跑完下载单元再来：\\n\" + \"\\n\".join(missing))\n",
    "# 图生视频（Wan2.2）是可选的：下完了就一起发，没下完也不卡住本次发布\n",
    "OPTIONAL = [\n",
    "    \"diffusion_models/wan2.2_ti2v_5B_fp16.safetensors\",\n",
    "    \"vae/wan2.2_vae.safetensors\",\n",
    "]\n",
    "present_optional = [p for p in OPTIONAL if os.path.isfile(f\"{MODELS}/{p}\")]\n",
    "ALL_FILES = EXPECTED + present_optional\n",
    "if present_optional:\n",
    "    print(\"Wan2.2 图生视频文件也在，一起上传。\")\n",
    "else:\n",
    "    print(\"Wan2.2 图生视频文件还没下载，本次只发 6 个基础文件；以后下完再跑一次本单元推新版本。\")\n",
    "total_gb = sum(os.path.getsize(f\"{MODELS}/{p}\") for p in ALL_FILES) / 1e9\n",
    "print(f\"{len(ALL_FILES)} 个模型文件齐全，共 {total_gb:.1f} GB。\")\n",
    "\n",
    "SLUG = \"comfyui-models\"\n",
    "DATASET_ID = f\"{USERNAME}/{SLUG}\"\n",
    "meta = {\n",
    "    \"title\": \"ComfyUI Models Z-Image Turbo Wan2.1\",\n",
    "    \"id\": DATASET_ID,\n",
    "    \"licenses\": [{\"name\": \"Apache-2.0\"}],\n",
    "    \"description\": \"ComfyUI 模型文件：Z-Image Turbo（文生图）、Wan2.1 T2V-1.3B（文生视频）\" + (\"、Wan2.2 TI2V-5B（图生视频）\" if present_optional else \"\") + \"官方重打包权重，私人用途。\",\n",
    "}\n",
    "with open(f\"{MODELS}/dataset-metadata.json\", \"w\") as f:\n",
    "    json.dump(meta, f, indent=2)\n",
    "\n",
    "# 数据集已存在就推新版本，否则新建（默认私有，不加 -u）\n",
    "exists = subprocess.run([\"kaggle\", \"datasets\", \"files\", DATASET_ID],\n",
    "                        capture_output=True, text=True).returncode == 0\n",
    "if exists:\n",
    "    print(\"数据集已存在，推送新版本...\")\n",
    "    cmd = [\"kaggle\", \"datasets\", \"version\", \"-p\", MODELS, \"-r\", \"zip\",\n",
    "           \"-m\", \"ComfyUI models: Z-Image Turbo + Wan2.1\" + (\" + Wan2.2 TI2V\" if present_optional else \"\")]\n",
    "else:\n",
    "    print(\"新建私有数据集...\")\n",
    "    cmd = [\"kaggle\", \"datasets\", \"create\", \"-p\", MODELS, \"-r\", \"zip\"]\n",
    "print(f\"上传约 {total_gb:.1f}GB，需要几分钟，请耐心等这个单元跑完。\")\n",
    "# 不 capture 输出，让上传进度实时显示在单元里（30GB 要传一阵子）\n",
    "r = subprocess.run(cmd)\n",
    "if r.returncode != 0:\n",
    "    raise SystemExit(\"上传失败，检查上面的输出。\")\n",
    "\n",
    "print()\n",
    "print(\"上传完成，核对数据集里的文件：\")\n",
    "r = subprocess.run([\"kaggle\", \"datasets\", \"files\", DATASET_ID], capture_output=True, text=True)\n",
    "print(r.stdout)\n",
    "print(\"数据集地址：https://www.kaggle.com/datasets/\" + DATASET_ID)\n",
    "print(\"下次开 Notebook 时在右侧 Add data 里挂载它，再跑这个 notebook 就会自动跳过下载。\")\n"
   ],
   "id": "cell-09"
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 用完后怎么停（省 GPU 额度）\n",
    "\n",
    "直接在 Kaggle 里**停止这个会话**（编辑器里会话菜单的 Stop / 停止按钮）——会话停了才不烧 GPU 额度，光停进程不省额度。两个实例随会话结束一起停。\n",
    "\n",
    "会话结束会清掉临时空间里的模型和 ComfyUI，但**生成好的图/视频在 `/kaggle/working/comfy-output` 和 `/kaggle/working/comfy-output-2` 里，会随 Notebook 版本保存**，不会丢。下次开新会话，挂载 `comfyui-models` Dataset 再跑 1～6 号单元就行，不用重下模型。\n"
   ],
   "id": "cell-10"
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3.11.0"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}