This commit is contained in:
mike
2026-06-18 00:06:15 +02:00
parent 2d0322465d
commit 1dead1c666
32 changed files with 3332 additions and 65 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
*.png
*.jpeg
*.jpg
.idea/

View File

@@ -0,0 +1,13 @@
{
"baseUrl": "http://localhost:11434/v1/responses",
"id": "qwen3-coder:30b",
"apiType": "OpenAIResponses",
"temperature": 0.3,
"primaryModel": {
"id": "qwen3-coder:30b",
"temperature": 0.3
},
"fasterModel": {
"id": "qwen2.5-coder:1.5b"
}
}

View File

@@ -0,0 +1,14 @@
{
"baseUrl": "http://localhost:11434/v1/responses",
"id": "qwen25-coder-32b-64k:latest",
"apiType": "OpenAIResponses",
"temperature": 0.3,
"primaryModel": {
"id": "qwen25-coder-32b-64k:latest",
"temperature": 0.2
},
"fasterModel": {
"id": "qwen25-coder-32b-64k:latest",
"temperature": 0.2
}
}

View File

@@ -0,0 +1,5 @@
FROM qwen2.5-coder:32b
PARAMETER num_ctx 64000
PARAMETER temperature 0.15
PARAMETER top_p 0.9

72
a6000-comfy/bootstrap.sh Executable file
View File

@@ -0,0 +1,72 @@
#!/bin/bash
# One-time (idempotent) host setup for the Qwen-Image-Edit service on the CUDA box.
# Runs as the service user (NO sudo). Safe to re-run: existing pieces are skipped.
#
# Builds, under $BASE (default ~/comfyui, outside the git repo):
# venv/ CUDA torch + ComfyUI deps (RTX A6000 / sm_86)
# ComfyUI/ latest master + ComfyUI-GGUF custom node
# ComfyUI/models/{unet,text_encoders,vae}/ the v23 Q8 GGUF + encoder + vae
#
# CUDA lifts every MI50 workaround: no rocm wheel, no v0.3.77 pin (that pin only
# existed because rocm-torch 2.3.1 lacks torch.library.custom_op), no lowvram.
set -e
source "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/env.sh"
GGUF_NODE="$COMFY/custom_nodes/ComfyUI-GGUF"
echo "[bootstrap] BASE=$BASE VENV=$VENV"
mkdir -p "$BASE"
# --- ComfyUI (latest) -------------------------------------------------------
if [ ! -d "$COMFY/.git" ]; then
echo "[bootstrap] cloning ComfyUI (latest master) ..."
git clone --depth 1 https://github.com/comfyanonymous/ComfyUI.git "$COMFY"
fi
# --- venv + python deps -----------------------------------------------------
# Build from the real system interpreter, NOT whatever venv is currently active.
if [ ! -d "$VENV" ]; then
echo "[bootstrap] creating venv at $VENV ..."
/usr/bin/python3.13 -m venv "$VENV"
fi
source "$VENV/bin/activate"
python -m pip install --upgrade pip wheel
echo "[bootstrap] installing torch (CUDA cu124) ..."
# torchaudio MUST come from the cu124 index too. Latest ComfyUI imports torchaudio
# at startup (comfy/sd.py -> audio_vae); the PyPI default build links libcudart.so.13
# (CUDA 13) and crashes against our cu124 torch. The matching +cu124 build fixes it.
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu124
echo "[bootstrap] installing ComfyUI requirements ..."
pip install -r "$COMFY/requirements.txt"
# ComfyUI's requirements can re-pull a mismatched torchaudio from PyPI; re-pin it.
pip install torchaudio --index-url https://download.pytorch.org/whl/cu124
# --- ComfyUI-GGUF custom node ----------------------------------------------
if [ ! -d "$GGUF_NODE" ]; then
echo "[bootstrap] cloning ComfyUI-GGUF ..."
git clone --depth 1 https://github.com/city96/ComfyUI-GGUF.git "$GGUF_NODE"
fi
pip install -r "$GGUF_NODE/requirements.txt" || pip install gguf
# --- API deps ---------------------------------------------------------------
pip install fastapi "uvicorn[standard]" websocket-client python-multipart pillow requests
# --- models (resume-safe; skipped if already complete) ----------------------
# Identical files/URLs to tour-comfy/bootstrap.sh.
M="$COMFY/models"
mkdir -p "$M/unet" "$M/text_encoders" "$M/vae"
dl() { # url dest
if [ -s "$2" ]; then echo "[bootstrap] have $(basename "$2")"; return; fi
echo "[bootstrap] downloading $(basename "$2") ..."
wget -c -q --show-progress -O "$2" "$1"
}
dl "https://huggingface.co/Novice25/Qwen-Image-Edit-Rapid-AIO-GGUF/resolve/main/v23/Qwen-Rapid-NSFW-v23_Q8_0.gguf" \
"$M/unet/Qwen-Rapid-NSFW-v23_Q8_0.gguf"
dl "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors" \
"$M/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors"
dl "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/vae/qwen_image_vae.safetensors" \
"$M/vae/qwen_image_vae.safetensors"
echo "[bootstrap] verifying torch + GPU ..."
python -c "import torch; print('torch', torch.__version__, 'cuda', torch.cuda.is_available(), torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'NO GPU')"
echo "[bootstrap] BOOTSTRAP_DONE"

45
a6000-comfy/deploy.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/bin/bash
# Install/refresh systemd services for Qwen-Image-Edit on the A6000 (CUDA) box.
# Installs 3 services + 1 target; run with sudo.
set -e
if [[ $EUID -ne 0 ]]; then
echo "This script must be run as root (use sudo)"
exit 1
fi
A6K="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" # a6000-comfy/
REPO="$( cd "$A6K/.." && pwd )" # repo root
TOUR="$REPO/tour-comfy"
TEMPLATES="$A6K/systemd"
SVC_USER="${SUDO_USER:-$(stat -c '%U' "$A6K")}"
SVC_GROUP="$(id -gn "$SVC_USER")"
echo "Installing services: user=$SVC_USER group=$SVC_GROUP"
echo " a6k=$A6K"
echo " tour=$TOUR"
for unit in comfyui-backend comfyui-api comfyui-watcher; do
sed -e "s|__USER__|$SVC_USER|g" \
-e "s|__GROUP__|$SVC_GROUP|g" \
-e "s|__A6K__|$A6K|g" \
-e "s|__TOUR__|$TOUR|g" \
"$TEMPLATES/$unit.service" > "/etc/systemd/system/$unit.service"
echo " wrote /etc/systemd/system/$unit.service"
done
cp "$TEMPLATES/comfyui.target" /etc/systemd/system/comfyui.target
echo " wrote /etc/systemd/system/comfyui.target"
echo "Reloading systemd daemon..."
systemctl daemon-reload
echo "Enabling services + target..."
systemctl enable comfyui-backend.service comfyui-api.service comfyui-watcher.service comfyui.target
echo "Starting system..."
systemctl start comfyui.target
echo "Deployment complete."
echo "Check status with: systemctl status comfyui.target comfyui-backend comfyui-api comfyui-watcher"

17
a6000-comfy/env.sh Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/bash
# Shared path resolver for the Qwen-Image-Edit service on the CUDA box (RTX A6000).
# Sourced by bootstrap.sh / run_comfyui.sh / start_api.sh.
#
# Unlike tour-comfy/env.sh (which derives BASE from the script location and dodges
# an NTFS venv), this box installs into a dedicated dir OUTSIDE the git repo so the
# ~31GB model tree + ComfyUI checkout never land in `git status`. Override with
# COMFY_BASE / COMFY_VENV if you want it elsewhere.
ENV_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" # repo/a6000-comfy
BASE="${COMFY_BASE:-$HOME/comfyui}" # install root (ext4)
COMFY="$BASE/ComfyUI"
VENV="${COMFY_VENV:-$BASE/venv}"
# The FastAPI service + workflow are backend-agnostic; reuse them from tour-comfy
# (pure HTTP to ComfyUI on :8188 — nothing ROCm-specific in there).
API_DIR="$( cd "$ENV_DIR/../tour-comfy" && pwd )"

16
a6000-comfy/run_comfyui.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/bash
# Launch the ComfyUI backend (headless) for the Qwen-Image-Edit API on the A6000.
# CUDA / Ampere: none of the MI50 OOM workarounds are needed. The 46GB card holds
# the 20B Q8 model + encoder + reference latents resident, so we skip --lowvram and
# --use-split-cross-attention (both only existed to fit 32GB) -> faster.
set -e
source "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/env.sh"
cd "$COMFY"
source "$VENV/bin/activate"
# --highvram keeps weights pinned on-GPU between requests (we have the headroom).
exec python main.py \
--listen 127.0.0.1 \
--port 8188 \
--highvram \
"$@"

16
a6000-comfy/start_api.sh Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/bash
# Launch the FastAPI edit service (talks to the local ComfyUI on :8188).
# Reuses tour-comfy/edit_api.py + workflow verbatim (they're backend-agnostic).
set -e
source "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/env.sh"
source "$VENV/bin/activate"
cd "$API_DIR"
export COMFY_URL="http://127.0.0.1:8188"
export HOST="0.0.0.0"
export PORT="8500"
# The A6000 is fast and not VRAM-bound on this model, so default to a full ~1MP
# output budget (tour caps at 0.65MP to survive the MI50). Override via MAX_AREA.
export MAX_AREA="${MAX_AREA:-1048576}"
exec python edit_api.py

View File

@@ -0,0 +1,17 @@
[Unit]
Description=Qwen-Image-Edit FastAPI (A6000)
After=comfyui-backend.service
Requires=comfyui-backend.service
[Service]
Type=simple
User=__USER__
Group=__GROUP__
ExecStart=/bin/bash __A6K__/start_api.sh
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=comfyui.target

View File

@@ -0,0 +1,16 @@
[Unit]
Description=ComfyUI Backend (A6000 / CUDA)
After=network.target
[Service]
Type=simple
User=__USER__
Group=__GROUP__
ExecStart=/bin/bash __A6K__/run_comfyui.sh
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=comfyui.target

View File

@@ -0,0 +1,17 @@
[Unit]
Description=Qwen-Image-Edit Folder Watcher (A6000)
After=comfyui-api.service
Requires=comfyui-api.service
[Service]
Type=simple
User=__USER__
Group=__GROUP__
ExecStart=/bin/bash __TOUR__/start_watcher.sh
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=comfyui.target

View File

@@ -0,0 +1,7 @@
[Unit]
Description=Qwen-Image-Edit Complete System (A6000)
Wants=comfyui-backend.service comfyui-api.service comfyui-watcher.service
After=comfyui-watcher.service
[Install]
WantedBy=multi-user.target

23
example.py Normal file
View File

@@ -0,0 +1,23 @@
def calculate_area(length, width):
"""
Calculate the area of a rectangle.
Args:
length (float): The length of the rectangle
width (float): The width of the rectangle
Returns:
float: The area of the rectangle
"""
if length <= 0 or width <= 0:
raise ValueError("Length and width must be positive numbers")
return length * width
# Example usage
if __name__ == "__main__":
try:
area = calculate_area(5, 3)
print(f"The area is: {area}")
except ValueError as e:
print(f"Error: {e}")

View File

@@ -59,7 +59,16 @@ Compute-bound — the 20.8GB unet stays resident; only the text encoder swaps.
- The audio custom-node import errors in the ComfyUI log (`libcudart.so.13`) are - The audio custom-node import errors in the ComfyUI log (`libcudart.so.13`) are
harmless — a CUDA `torchaudio` got pulled in; image editing doesn't use it. harmless — a CUDA `torchaudio` got pulled in; image editing doesn't use it.
## Manage (systemd) ## Manage (local machine)
From this machine, use the `deploy_api.sh` script:
```bash
cd tour-comfy
./deploy_api.sh deploy tour # sync files and (re)start services
./deploy_api.sh stop tour # stop and disable services
./deploy_api.sh status tour # check service status and logs
```
## Manage (on `tour`)
```bash ```bash
sudo bash /media/tour/APPS/comfyui/api/deploy.sh # (re)install and start services sudo bash /media/tour/APPS/comfyui/api/deploy.sh # (re)install and start services
sudo bash /media/tour/APPS/comfyui/api/stop.sh # stop and disable services sudo bash /media/tour/APPS/comfyui/api/stop.sh # stop and disable services

64
tour-comfy/bootstrap.sh Executable file
View File

@@ -0,0 +1,64 @@
#!/bin/bash
# One-time (idempotent) host setup for the Qwen-Image-Edit service.
# Runs as the service user (NO sudo). Safe to re-run: existing pieces are skipped.
#
# Builds, under the project BASE (the parent of this api/ dir):
# venv/ torch 2.3.1+rocm5.7 + ComfyUI deps (gfx906 / ROCm 5.7)
# ComfyUI/ pinned to v0.3.77 + ComfyUI-GGUF custom node
# ComfyUI/models/{unet,text_encoders,vae}/ the v23 Q8 GGUF + encoder + vae
set -e
source "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/env.sh"
GGUF_NODE="$COMFY/custom_nodes/ComfyUI-GGUF"
COMFY_TAG="v0.3.77" # newest tag that runs on torch 2.3.1 (no comfy_kitchen)
echo "[bootstrap] BASE=$BASE VENV=$VENV"
# --- ComfyUI (pinned) -------------------------------------------------------
if [ ! -d "$COMFY/.git" ]; then
echo "[bootstrap] cloning ComfyUI @ $COMFY_TAG ..."
git clone --depth 1 --branch "$COMFY_TAG" \
https://github.com/comfyanonymous/ComfyUI.git "$COMFY"
fi
# --- venv + python deps -----------------------------------------------------
if [ ! -d "$VENV" ]; then
echo "[bootstrap] creating venv at $VENV ..."
python3 -m venv "$VENV"
fi
source "$VENV/bin/activate"
python -m pip install --upgrade pip wheel
echo "[bootstrap] installing torch (rocm5.7) ..."
pip install torch==2.3.1+rocm5.7 torchvision==0.18.1+rocm5.7 \
--index-url https://download.pytorch.org/whl/rocm5.7
echo "[bootstrap] installing ComfyUI requirements ..."
pip install -r "$COMFY/requirements.txt"
# --- ComfyUI-GGUF custom node ----------------------------------------------
if [ ! -d "$GGUF_NODE" ]; then
echo "[bootstrap] cloning ComfyUI-GGUF ..."
git clone --depth 1 https://github.com/city96/ComfyUI-GGUF.git "$GGUF_NODE"
fi
pip install -r "$GGUF_NODE/requirements.txt" || pip install gguf
# --- API deps ---------------------------------------------------------------
pip install fastapi "uvicorn[standard]" websocket-client python-multipart pillow requests
# --- models (resume-safe; skipped if already complete) ----------------------
M="$COMFY/models"
mkdir -p "$M/unet" "$M/text_encoders" "$M/vae"
dl() { # url dest
if [ -s "$2" ]; then echo "[bootstrap] have $(basename "$2")"; return; fi
echo "[bootstrap] downloading $(basename "$2") ..."
wget -c -q -O "$2" "$1"
}
dl "https://huggingface.co/Novice25/Qwen-Image-Edit-Rapid-AIO-GGUF/resolve/main/v23/Qwen-Rapid-NSFW-v23_Q8_0.gguf" \
"$M/unet/Qwen-Rapid-NSFW-v23_Q8_0.gguf"
dl "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors" \
"$M/text_encoders/qwen_2.5_vl_7b_fp8_scaled.safetensors"
dl "https://huggingface.co/Comfy-Org/Qwen-Image_ComfyUI/resolve/main/split_files/vae/qwen_image_vae.safetensors" \
"$M/vae/qwen_image_vae.safetensors"
echo "[bootstrap] verifying torch + GPU ..."
python -c "import torch; print('torch', torch.__version__, 'cuda', torch.cuda.is_available(), torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'NO GPU')"
echo "[bootstrap] BOOTSTRAP_DONE"

15
tour-comfy/config.json Normal file
View File

@@ -0,0 +1,15 @@
{
"api_url": "http://127.0.0.1:8500/edit",
"prompt": "high quality, detailed, female nude",
"seed": -1,
"max_area": 655360,
"margin": 10,
"top_margin": 20,
"headroom": 0.05,
"poll_interval": 2,
"stage_dir": "./tour-comfy/stage",
"output_dir": "./tour-comfy/output",
"failed_dir": "./tour-comfy/failed",
"processed_file": "./tour-comfy/processed.json",
"log_file": "./tour-comfy/watcher.log"
}

View File

@@ -1,30 +1,41 @@
#!/bin/bash #!/bin/bash
# Deploy systemd services for Qwen-Image-Edit # Install/refresh the systemd services for Qwen-Image-Edit on THIS host.
# Host-agnostic: the service user, group and install path are derived at run
# time, so the same file works on tour, hubby, etc.
#
# Run with sudo (needs to write /etc/systemd/system). Assumes bootstrap.sh has
# already created venv/, ComfyUI/ and the models under BASE.
set -e set -e
# Must be run with sudo to copy to /etc/systemd/system
if [[ $EUID -ne 0 ]]; then if [[ $EUID -ne 0 ]]; then
echo "This script must be run as root (use sudo)" echo "This script must be run as root (use sudo)"
exit 1 exit 1
fi fi
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" # .../comfyui/api
SYSTEMD_DIR="$SCRIPT_DIR/systemd" BASE="$( cd "$SCRIPT_DIR/.." && pwd )" # .../comfyui
TEMPLATES="$SCRIPT_DIR/systemd"
echo "Copying service files..." # The service should run as the owner of the project, not root.
cp "$SYSTEMD_DIR/comfyui-backend.service" /etc/systemd/system/ SVC_USER="${SUDO_USER:-$(stat -c '%U' "$SCRIPT_DIR")}"
cp "$SYSTEMD_DIR/comfyui-api.service" /etc/systemd/system/ SVC_GROUP="$(id -gn "$SVC_USER")"
echo "Installing services: user=$SVC_USER group=$SVC_GROUP base=$BASE"
for unit in comfyui-backend comfyui-api; do
sed -e "s|__USER__|$SVC_USER|g" \
-e "s|__GROUP__|$SVC_GROUP|g" \
-e "s|__BASE__|$BASE|g" \
"$TEMPLATES/$unit.service" > "/etc/systemd/system/$unit.service"
echo " wrote /etc/systemd/system/$unit.service"
done
echo "Reloading systemd daemon..." echo "Reloading systemd daemon..."
systemctl daemon-reload systemctl daemon-reload
echo "Enabling services..." echo "Enabling + (re)starting services..."
systemctl enable comfyui-backend.service systemctl enable comfyui-backend.service comfyui-api.service
systemctl enable comfyui-api.service systemctl restart comfyui-backend.service comfyui-api.service
echo "Starting services..."
systemctl restart comfyui-backend.service
systemctl restart comfyui-api.service
echo "Deployment complete." echo "Deployment complete."
echo "Check status with: systemctl status comfyui-backend comfyui-api" echo "Check status with: systemctl status comfyui-backend comfyui-api"

169
tour-comfy/deploy_api.sh Executable file
View File

@@ -0,0 +1,169 @@
#!/bin/bash
# Deploy/stop/restart/status tour-comfy project on the tour machine.
#
# Usage:
# ./deploy_api.sh
# ./deploy_api.sh deploy tour|hubby|all
# ./deploy_api.sh stop tour|hubby|all
# ./deploy_api.sh restart tour|hubby|all
# ./deploy_api.sh status tour|hubby|all
set -euo pipefail
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
ACTION="${1:-deploy}"
TARGET="${2:-tour}"
REMOTES=()
TOUR_SPEC="tour@192.168.1.160:/media/tour/APPS/comfyui/api"
HUBBY_SPEC="hubby@192.168.1.171:/home/hubby/comfyui/api"
case "$TARGET" in
tour) REMOTES=("$TOUR_SPEC") ;;
hubby) REMOTES=("$HUBBY_SPEC") ;;
all) REMOTES=("$TOUR_SPEC" "$HUBBY_SPEC") ;;
*)
echo "Usage: $0 [deploy|stop|restart|status] [tour|hubby|all]"
exit 1
;;
esac
print_header() {
local title="$1"
echo
echo "============================================================"
echo "$title"
echo "============================================================"
}
stop_one() {
local REMOTE_SPEC="$1"
local REMOTE="${REMOTE_SPEC%%:*}"
local REMOTE_DIR="${REMOTE_SPEC##*:}"
print_header "Stopping services on $REMOTE"
ssh "$REMOTE" "sudo bash $REMOTE_DIR/stop.sh"
}
deploy_one() {
local REMOTE_SPEC="$1"
local REMOTE="${REMOTE_SPEC%%:*}"
local REMOTE_DIR="${REMOTE_SPEC##*:}"
print_header "Deploying to $REMOTE"
echo "==> Creating remote directory..."
ssh "$REMOTE" "mkdir -p $REMOTE_DIR"
echo "==> Syncing project files..."
# Ship code + config only; never the local test images, watcher data dirs,
# logs, or runtime state.
rsync -avz \
--exclude 'deploy_api.sh' \
--exclude '*.png' \
--exclude '*.log' \
--exclude 'processed.json' \
--exclude 'stage/' --exclude 'out/' --exclude 'output/' --exclude 'failed/' \
--exclude '__pycache__/' --exclude '*.pyc' --exclude '*.tmp' \
"$SCRIPT_DIR/" "$REMOTE:$REMOTE_DIR/"
echo "==> Bootstrapping host (venv/ComfyUI/models; idempotent — first run downloads ~31GB)..."
ssh "$REMOTE" "bash $REMOTE_DIR/bootstrap.sh"
echo "==> Installing/refreshing systemd services..."
ssh "$REMOTE" "sudo bash $REMOTE_DIR/deploy.sh"
echo "==> Waiting for API startup (port 8500)..."
for i in {1..60}; do
if ssh "$REMOTE" "curl -fsS http://localhost:8500/health >/dev/null 2>&1"; then
echo "==> API is ready"
break
fi
if [ "$i" -eq 60 ]; then
echo
echo "ERROR: API failed to become healthy on $REMOTE"
echo
echo "Check logs with: ssh $REMOTE 'journalctl -u comfyui-api -n 50'"
exit 1
fi
sleep 2
done
echo
echo "==> Health check:"
ssh "$REMOTE" "curl -s http://localhost:8500/health | python3 -m json.tool"
echo
echo "==> Service Status:"
ssh "$REMOTE" "systemctl status comfyui-backend comfyui-api --no-pager || true"
}
status_one() {
local REMOTE_SPEC="$1"
local REMOTE="${REMOTE_SPEC%%:*}"
local REMOTE_DIR="${REMOTE_SPEC##*:}"
print_header "Status on $REMOTE"
ssh "$REMOTE" "
echo '==> Systemd Services:'
systemctl status comfyui-backend comfyui-api --no-pager || true
echo
echo '==> Health (8500):'
curl -fsS http://localhost:8500/health 2>/dev/null | python3 -m json.tool || echo 'not healthy'
echo
echo '==> Last 10 backend logs:'
journalctl -u comfyui-backend -n 10 --no-pager
echo
echo '==> Last 10 API logs:'
journalctl -u comfyui-api -n 10 --no-pager
"
}
case "$ACTION" in
deploy)
for REMOTE in "${REMOTES[@]}"; do
deploy_one "$REMOTE"
done
echo
echo "All deployments completed successfully."
;;
stop)
for REMOTE in "${REMOTES[@]}"; do
stop_one "$REMOTE"
done
echo
echo "All services stopped."
;;
restart)
for REMOTE in "${REMOTES[@]}"; do
# deploy_one already handles restart via deploy.sh
deploy_one "$REMOTE"
done
echo
echo "All services restarted successfully."
;;
status)
for REMOTE in "${REMOTES[@]}"; do
status_one "$REMOTE"
done
;;
*)
echo "Usage: $0 [deploy|stop|restart|status] [tour|hubby|all]"
exit 1
;;
esac

View File

@@ -15,13 +15,17 @@ import time
import uuid import uuid
import random import random
import copy import copy
import threading
import requests import requests
from PIL import Image from PIL import Image
from fastapi import FastAPI, UploadFile, File, Form, HTTPException from fastapi import FastAPI, UploadFile, File, Form, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import Response from fastapi.responses import Response
from pydantic import BaseModel
# --- config ----------------------------------------------------------------- # --- config -----------------------------------------------------------------
CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
COMFY = os.environ.get("COMFY_URL", "http://127.0.0.1:8188").rstrip("/") COMFY = os.environ.get("COMFY_URL", "http://127.0.0.1:8188").rstrip("/")
WORKFLOW_PATH = os.environ.get( WORKFLOW_PATH = os.environ.get(
"WORKFLOW_PATH", "WORKFLOW_PATH",
@@ -45,6 +49,12 @@ with open(WORKFLOW_PATH, "r", encoding="utf-8") as f:
BASE_WORKFLOW = json.load(f) BASE_WORKFLOW = json.load(f)
app = FastAPI(title="Qwen-Image-Edit Rapid-AIO API", version="1.0") app = FastAPI(title="Qwen-Image-Edit Rapid-AIO API", version="1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["GET", "POST"],
allow_headers=["*"],
)
# --- helpers ---------------------------------------------------------------- # --- helpers ----------------------------------------------------------------
@@ -58,6 +68,41 @@ def _target_size(w: int, h: int, max_area: int) -> tuple[int, int]:
return _round16(w * scale), _round16(h * scale) return _round16(w * scale), _round16(h * scale)
def _prep_image(pil: Image.Image, max_area: int) -> tuple[Image.Image, int, int]:
"""
Prepare image for ComfyUI:
1. If area > max_area, crop from bottom if height remains >= 256.
2. Otherwise scale (up or down) to fit area while preserving aspect.
3. Ensure dimensions are rounded to 16.
"""
w, h = pil.width, pil.height
if w * h > max_area:
# Try to keep width and crop height from bottom
rw = _round16(w)
th = max_area // rw
if th >= 256:
rh = (th // 16) * 16
if rh < 16: rh = 16
# To avoid black bars from .crop((0,0,rw,rh)) when rw > w,
# we crop to original w first, then resize to rw.
pil = pil.crop((0, 0, w, min(h, (rh * w) // rw)))
pil = pil.resize((rw, rh), resample=Image.LANCZOS)
return pil, rw, rh
else:
# Too wide to keep width and have decent height, scale both down
rw, rh = _target_size(w, h, max_area)
pil = pil.resize((rw, rh), resample=Image.LANCZOS)
return pil, rw, rh
else:
# Fits or is too small: scale UP to match the max_area budget
# (Legacy behavior that gives better model performance)
rw, rh = _target_size(w, h, max_area)
if rw != w or rh != h:
pil = pil.resize((rw, rh), resample=Image.LANCZOS)
return pil, rw, rh
def _comfy_upload(img_bytes: bytes, filename: str) -> str: def _comfy_upload(img_bytes: bytes, filename: str) -> str:
"""Upload an image to ComfyUI's input dir; return the stored name.""" """Upload an image to ComfyUI's input dir; return the stored name."""
r = requests.post( r = requests.post(
@@ -122,7 +167,122 @@ def _comfy_fetch_image(outputs: dict) -> bytes:
return r.content return r.content
# --- pipeline helper ---------------------------------------------------------
def _run_pipeline(
pil: Image.Image,
prompt: str,
seed: int = -1,
max_area: int = 0,
steps: int = 4,
cfg: float = 1.0,
sampler_name: str = "euler_ancestral",
scheduler: str = "beta",
) -> bytes:
area = max_area if max_area > 0 else MAX_AREA
pil, w, h = _prep_image(pil, area)
buf = io.BytesIO()
pil.save(buf, format="PNG")
stored = _comfy_upload(buf.getvalue(), f"in_{uuid.uuid4().hex[:8]}.png")
if seed is None or seed < 0:
seed = random.randint(0, MAX_SEED)
graph = copy.deepcopy(BASE_WORKFLOW)
graph[NODE_LOADIMAGE]["inputs"]["image"] = stored
graph[NODE_POSITIVE]["inputs"]["prompt"] = prompt
graph[NODE_LATENT]["inputs"]["width"] = w
graph[NODE_LATENT]["inputs"]["height"] = h
ks = graph[NODE_KSAMPLER]["inputs"]
ks.update(seed=seed, steps=steps, cfg=cfg, sampler_name=sampler_name, scheduler=scheduler)
client_id = uuid.uuid4().hex
prompt_id = _comfy_queue(graph, client_id)
outputs = _comfy_wait(prompt_id, time.time() + GEN_TIMEOUT)
return _comfy_fetch_image(outputs)
# --- batch state -------------------------------------------------------------
jobs: dict[str, dict] = {}
def _load_output_dir() -> str:
with open(CONFIG_PATH, "r") as f:
conf = json.load(f)
d = conf["output_dir"]
if not os.path.isabs(d):
d = os.path.normpath(os.path.join(os.path.dirname(CONFIG_PATH), "..", d))
return d
def _batch_worker(job_id: str, filenames: list, prompt: str, seed: int, max_area: int):
output_dir = _load_output_dir()
for fname in filenames:
fpath = os.path.join(output_dir, fname)
try:
pil = Image.open(fpath).convert("RGB")
png = _run_pipeline(pil, prompt, seed, max_area)
ts = time.strftime("%Y%m%d_%H%M%S")
out_name = f"{ts}_{fname}"
with open(os.path.join(output_dir, out_name), "wb") as f:
f.write(png)
jobs[job_id]["done"] += 1
except Exception as e:
jobs[job_id]["failed"] += 1
jobs[job_id]["status"] = "done"
# --- routes ----------------------------------------------------------------- # --- routes -----------------------------------------------------------------
class ConfigUpdate(BaseModel):
prompt: str | None = None
seed: int | None = None
@app.get("/config")
def get_config():
with open(CONFIG_PATH, "r") as f:
return json.load(f)
@app.post("/config")
def update_config(update: ConfigUpdate):
with open(CONFIG_PATH, "r") as f:
conf = json.load(f)
if update.prompt is not None:
conf["prompt"] = update.prompt
if update.seed is not None:
conf["seed"] = update.seed
with open(CONFIG_PATH, "w") as f:
json.dump(conf, f, indent=2)
return {"prompt": conf["prompt"], "seed": conf["seed"]}
class BatchRequest(BaseModel):
filenames: list[str]
prompt: str
seed: int = -1
max_area: int = 0
@app.post("/batch")
def start_batch(req: BatchRequest):
job_id = uuid.uuid4().hex[:8]
jobs[job_id] = {"status": "running", "total": len(req.filenames), "done": 0, "failed": 0}
t = threading.Thread(
target=_batch_worker,
args=(job_id, req.filenames, req.prompt, req.seed, req.max_area),
daemon=True,
)
t.start()
return {"job_id": job_id, "total": len(req.filenames)}
@app.get("/batch/{job_id}")
def get_batch(job_id: str):
if job_id not in jobs:
raise HTTPException(404, "Job not found")
return jobs[job_id]
@app.get("/health") @app.get("/health")
def health(): def health():
try: try:
@@ -149,40 +309,8 @@ async def edit(
except Exception as e: except Exception as e:
raise HTTPException(400, f"Invalid image: {e}") raise HTTPException(400, f"Invalid image: {e}")
area = max_area if max_area > 0 else MAX_AREA png = _run_pipeline(pil, prompt, seed, max_area, steps, cfg, sampler_name, scheduler)
w, h = _target_size(pil.width, pil.height, area) return Response(content=png, media_type="image/png")
buf = io.BytesIO()
pil.save(buf, format="PNG")
stored = _comfy_upload(buf.getvalue(), f"in_{uuid.uuid4().hex[:8]}.png")
if seed is None or seed < 0:
seed = random.randint(0, MAX_SEED)
graph = copy.deepcopy(BASE_WORKFLOW)
graph[NODE_LOADIMAGE]["inputs"]["image"] = stored
graph[NODE_POSITIVE]["inputs"]["prompt"] = prompt
graph[NODE_LATENT]["inputs"]["width"] = w
graph[NODE_LATENT]["inputs"]["height"] = h
ks = graph[NODE_KSAMPLER]["inputs"]
ks.update(seed=seed, steps=steps, cfg=cfg,
sampler_name=sampler_name, scheduler=scheduler)
client_id = uuid.uuid4().hex
prompt_id = _comfy_queue(graph, client_id)
outputs = _comfy_wait(prompt_id, time.time() + GEN_TIMEOUT)
png = _comfy_fetch_image(outputs)
return Response(
content=png,
media_type="image/png",
headers={
"X-Seed": str(seed),
"X-Width": str(w),
"X-Height": str(h),
"X-Prompt-Id": prompt_id,
},
)
if __name__ == "__main__": if __name__ == "__main__":

25
tour-comfy/env.sh Normal file
View File

@@ -0,0 +1,25 @@
#!/bin/bash
# Shared path resolver for the Qwen-Image-Edit service scripts.
# Sourced by bootstrap.sh / run_comfyui.sh / start_api.sh.
#
# Why this exists: a Python venv CANNOT live on the NTFS (fuseblk) mount used
# on tour (/media/tour/APPS). Its interpreter symlinks turn into
# "unsupported reparse tag 0x..." after a reboot/remount, so `python`
# vanishes and every service dies. ComfyUI code and the model files are plain
# files and are fine on NTFS -- only the venv must be on a native fs.
#
# So: if BASE is on a non-native filesystem, the venv goes under $HOME (ext4);
# otherwise it stays at $BASE/venv. Override explicitly with COMFY_VENV.
ENV_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" # .../comfyui/api
API_DIR="$ENV_DIR"
BASE="$( cd "$ENV_DIR/.." && pwd )" # .../comfyui
COMFY="$BASE/ComfyUI"
_basefs="$(stat -f -c %T "$BASE" 2>/dev/null || echo unknown)"
case "$_basefs" in
fuseblk|ntfs|ntfs3|exfat|vfat|msdos|9p|cifs|smb*)
VENV="${COMFY_VENV:-$HOME/comfyui-venv}" ;; # NTFS-ish BASE -> venv on home
*)
VENV="${COMFY_VENV:-$BASE/venv}" ;; # native fs -> venv beside code
esac

931
tour-comfy/output/car.html Normal file
View File

@@ -0,0 +1,931 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Live Image Monitor</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #0f0f0f;
color: #e0e0e0;
min-height: 100vh;
}
.header {
position: fixed;
top: 0; left: 0; right: 0;
background: rgba(15, 15, 15, 0.95);
backdrop-filter: blur(10px);
border-bottom: 1px solid #333;
padding: 16px 24px;
z-index: 100;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
font-size: 18px;
font-weight: 600;
color: #fff;
display: flex;
align-items: center;
gap: 10px;
}
.status {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: #888;
}
.status-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: #22c55e;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.status-dot.updating {
background: #f59e0b;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.controls {
display: flex;
gap: 12px;
align-items: center;
}
.btn {
background: #1a1a1a;
border: 1px solid #333;
color: #ccc;
padding: 6px 14px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.btn:hover {
background: #252525;
border-color: #444;
color: #fff;
}
.btn.primary {
background: #2563eb;
border-color: #2563eb;
color: #fff;
}
.btn.primary:hover {
background: #3b82f6;
}
.prompt-bar {
display: flex;
gap: 8px;
align-items: center;
}
.prompt-input {
background: #1a1a1a;
border: 1px solid #333;
color: #e0e0e0;
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
width: 320px;
outline: none;
transition: border-color 0.2s;
}
.prompt-input:focus {
border-color: #2563eb;
}
.prompt-input::placeholder {
color: #555;
}
.count {
font-size: 13px;
color: #666;
font-variant-numeric: tabular-nums;
}
.gallery {
padding: 100px 24px 40px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
max-width: 1800px;
margin: 0 auto;
}
.image-card {
background: #1a1a1a;
border: 1px solid #2a2a2a;
border-radius: 12px;
overflow: hidden;
transition: transform 0.2s, border-color 0.2s;
position: relative;
}
.image-card:hover {
transform: translateY(-2px);
border-color: #444;
}
.image-card.new {
border-color: #2563eb;
animation: highlight 1s ease;
}
@keyframes highlight {
from { box-shadow: 0 0 0 2px #2563eb; }
to { box-shadow: none; }
}
.image-wrapper {
position: relative;
padding-top: 300%; /* 1:3 aspect ratio (3x taller than wide) */
background: #111;
overflow: hidden;
}
.image-wrapper img {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
object-fit: cover;
transition: transform 0.3s;
}
.image-card:hover .image-wrapper img {
transform: scale(1.03);
}
.image-info {
padding: 14px 16px;
}
.image-name {
font-size: 14px;
font-weight: 500;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 6px;
}
.image-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: #666;
}
.image-time {
display: flex;
align-items: center;
gap: 4px;
}
.badge {
background: #2563eb;
color: #fff;
font-size: 10px;
padding: 2px 8px;
border-radius: 4px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge.recent {
background: #22c55e;
}
.empty-state {
text-align: center;
padding: 120px 20px;
color: #555;
}
.empty-state svg {
width: 64px; height: 64px;
margin-bottom: 16px;
opacity: 0.3;
}
.empty-state h2 {
font-size: 20px;
color: #777;
margin-bottom: 8px;
}
.empty-state p {
font-size: 14px;
}
.toast {
position: fixed;
bottom: 24px; right: 24px;
background: #1a1a1a;
border: 1px solid #333;
padding: 12px 20px;
border-radius: 8px;
font-size: 13px;
color: #ccc;
transform: translateY(100px);
opacity: 0;
transition: all 0.3s;
z-index: 200;
}
.toast.show {
transform: translateY(0);
opacity: 1;
}
.toast.success { border-left: 3px solid #22c55e; }
.toast.info { border-left: 3px solid #2563eb; }
.btn.active {
background: #2563eb;
border-color: #2563eb;
color: #fff;
}
.image-card.selectable {
cursor: pointer;
user-select: none;
}
.image-card.selected {
border-color: #2563eb;
box-shadow: 0 0 0 2px rgba(37,99,235,0.35);
}
.select-indicator {
position: absolute;
top: 8px; right: 8px;
width: 22px; height: 22px;
border-radius: 50%;
border: 2px solid rgba(255,255,255,0.5);
background: rgba(0,0,0,0.45);
display: none;
align-items: center;
justify-content: center;
z-index: 10;
font-size: 13px;
font-weight: 700;
color: #fff;
transition: background 0.15s, border-color 0.15s;
}
.selectable .select-indicator { display: flex; }
.selected .select-indicator {
background: #2563eb;
border-color: #2563eb;
}
.selected .select-indicator::after { content: '✓'; }
.batch-bar {
position: fixed;
bottom: 0; left: 0; right: 0;
background: rgba(12,12,12,0.97);
backdrop-filter: blur(12px);
border-top: 1px solid #2a2a2a;
padding: 14px 24px;
display: flex;
align-items: center;
gap: 12px;
z-index: 100;
transform: translateY(100%);
transition: transform 0.25s ease;
}
.batch-bar.visible { transform: translateY(0); }
.batch-count {
font-size: 13px;
color: #888;
min-width: 80px;
white-space: nowrap;
}
.batch-prompt-wrap { flex: 1; position: relative; }
.batch-prompt {
width: 100%;
background: #1a1a1a;
border: 1px solid #333;
color: #e0e0e0;
padding: 7px 12px;
border-radius: 6px;
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
.batch-prompt:focus { border-color: #2563eb; }
.batch-prompt::placeholder { color: #555; }
.batch-progress {
font-size: 12px;
color: #666;
min-width: 90px;
text-align: right;
white-space: nowrap;
}
</style>
</head>
<body>
<div class="header">
<h1>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="M21 15l-5-5L5 21"/>
</svg>
Live Image Monitor
</h1>
<div class="prompt-bar">
<input class="prompt-input" id="promptInput" type="text" placeholder="prompt..." onkeydown="if(event.key==='Enter')setPrompt()" />
<button class="btn primary" onclick="setPrompt()">Set Prompt</button>
</div>
<div class="controls">
<span class="count" id="count">0 images</span>
<button class="btn" onclick="setIntervalTime(30)">30s</button>
<button class="btn" onclick="setIntervalTime(120)">2m</button>
<button class="btn primary" onclick="refreshNow()">Refresh Now</button>
<button class="btn" id="selectBtn" onclick="toggleSelectMode()">Select</button>
</div>
<div class="status">
<span class="status-dot" id="statusDot"></span>
<span id="statusText">Auto-refresh: 2m</span>
</div>
</div>
<div class="gallery" id="gallery">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="M21 15l-5-5L5 21"/>
</svg>
<h2>No images found</h2>
<p>Place this HTML file in your image folder, or configure the path below.</p>
</div>
</div>
<div class="toast" id="toast"></div>
<div class="batch-bar" id="batchBar">
<span class="batch-count" id="batchCount">0 selected</span>
<div class="batch-prompt-wrap">
<input class="batch-prompt" id="batchPromptInput" list="promptHistory"
placeholder="temporal prompt..."
onkeydown="if(event.key==='Enter')processSelected()" />
<datalist id="promptHistory"></datalist>
</div>
<button class="btn" onclick="selectAll()">All</button>
<button class="btn" onclick="deselectAll()">None</button>
<button class="btn primary" id="processBtn" onclick="processSelected()">Process</button>
<span class="batch-progress" id="batchProgress"></span>
<button class="btn" onclick="toggleSelectMode()">Done</button>
</div>
<script>
// ============================================
// CONFIGURATION - EDIT THIS SECTION
// ============================================
// Set this to your image folder path relative to this HTML file
// Examples: './' (same folder), './images/', '../screenshots/'
const IMAGE_FOLDER = './';
// --- HYDRATION_START ---
const PRELOADED_IMAGES = [
"20260617_215245_img_87.png",
"20260617_134615_img_86.png",
"20260617_134229_img_83.png",
"20260617_134119_img_85.png",
"20260617_134041_img_84.png",
"20260617_133917_img_82.png",
"20260617_133832_img_81.png",
"20260617_133519_img_80.png",
"20260617_133411_img_79.png",
"20260617_133154_img_78.png",
"20260617_133129_img_77.png",
"20260617_132851_img_76.png",
"20260617_074613_img_75.png",
"20260617_074556_img_74.png",
"20260617_074413_img_73.png",
"20260617_074223_img_72.png",
"20260617_015946_img_71.png",
"20260617_015728_img_70.png",
"20260617_015611_img_69.png",
"20260617_015007_img_68.png",
"20260617_014351_img_66.png",
"20260617_013327_img_67.png",
"20260617_013231_img_68.png",
"20260617_013211_img_65.png",
"20260617_013150_img_66.png",
"20260617_013111_img_63.png",
"20260617_013035_img_64.png",
"20260617_012709_img_62.png",
"20260617_011132_img_61.png",
"20260617_005512_img_60.png",
"20260617_005200_img_59.png",
"20260617_005040_img_56.png",
"20260617_005026_img_55.png",
"20260617_005008_img_54.png",
"20260617_004942_img_53.png",
"20260617_004814_img_57.png",
"20260616_023306_img_52.png",
"20260616_023209_img_51.png",
"20260616_022543_img_50.png",
"20260616_022349_img_48.png",
"20260616_021938_20160903_200935.jpg",
"20260616_021235_img_47.png",
"20260616_021214_img_46.png",
"20260616_021150_img_44.png",
"20260616_021116_img_43.png",
"20260616_021056_img_45.png",
"20260616_021003_img_41.png",
"20260616_020908_img_40.png",
"20260616_020403_img_39.png",
"20260616_020059_img_38.png",
"20260616_020035_img_36.png",
"20260616_020020_img_35.png",
"20260616_015949_img_37.png",
"20260616_015919_img_33.png",
"20260616_015850_img_34.png",
"20260616_015341_img_32.png",
"20260616_014757_img_31.png",
"20260616_014225_img_30.png",
"20260616_014057_img_29.png",
"20260616_013755_img_28.png",
"20260616_013603_img_27.png",
"20260616_013013_img_26.png",
"20260616_012939_img_25.png",
"20260616_011823_imgxxxx.png",
"20260616_011447_img_24.png",
"20260616_010228_img_22.png",
"20260616_005752_img_21.png",
"20260616_005727_img_19.png",
"20260616_005202_img_20.png",
"20260616_004250_img_18.png",
"20260616_004220_img_17.png",
"20260616_004001_img_16.png",
"20260616_003916_img_15.png",
"20260616_003803_img_14.png",
"20260616_003629_img_13.png",
"20260616_003548_img.png",
"20260616_002456_test123.jpeg",
"20260616_002302_image.png",
"20260615_155756_img_6v1.png",
"20260615_155354_others.jpeg",
"20260615_154852_other.jpeg",
"20260615_154333_other.jpeg",
"20260615_153749_img_11.png",
"20260615_153426_img_12.png",
"20260615_153125_img_10.png",
"20260615_152826_img.png",
"20260615_152252_imgxxx.png",
"20260615_151829_img_92.png",
"20260615_151614_img_93.png",
"20260615_150812_img_19_2.png",
"20260615_150340_test.png",
"20260615_145017_img_93.png",
"img_9.png",
"b1.png",
"jb3.png",
"jb.png",
"jb1.png",
"img.png",
"out7.png",
"out5.png",
"out4.png",
"out3.png",
"out2.png",
"out.png",
"bb_01.png",
"tp236b.png",
"pass-1.png",
"t159zr-1.png",
"kbk99v.png",
"kk563t.png",
"Pasted image (9).png",
"Pasted image (7).png",
"Pasted image (5).png",
"Pasted image (4).png",
"Pasted image (3).png",
"Pasted image (2).png",
"Pasted image.png",
"pa01.png",
"pa0.png",
"p13.png",
"p12-2.png",
"p12.png",
"p11.png",
"p10.png",
"p1.png",
"img_2.png",
"img_3.png"
];
// --- HYDRATION_END ---
// Supported image extensions
const EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg'];
// Auto-refresh interval in seconds (default: 120 = 2 minutes)
let REFRESH_INTERVAL = 120;
// ============================================
let autoRefreshTimer = null;
let knownFiles = new Set();
let currentFiles = [];
function showToast(message, type = 'info') {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = `toast ${type} show`;
setTimeout(() => toast.classList.remove('show'), 3000);
}
function formatTime(date) {
const now = new Date();
const diff = Math.floor((now - date) / 1000);
if (diff < 60) return 'Just now';
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return date.toLocaleDateString();
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
// Try to get file info using HEAD requests (works with some local servers)
async function getFileInfo(filename) {
try {
const response = await fetch(IMAGE_FOLDER + filename, { method: 'HEAD' });
const lastMod = response.headers.get('last-modified');
const size = response.headers.get('content-length');
return {
modified: lastMod ? new Date(lastMod) : new Date(),
size: size ? parseInt(size) : 0
};
} catch {
return { modified: new Date(), size: 0 };
}
}
async function scanFolder() {
// For local file:// protocol, we can't list directories.
// This works when served via HTTP or you can manually list files.
// METHOD 1: If you have a simple file server, it might return directory listing
try {
const response = await fetch(IMAGE_FOLDER);
const text = await response.text();
// Try to parse common directory listing formats
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'text/html');
const links = Array.from(doc.querySelectorAll('a'))
.map(a => a.getAttribute('href'))
.filter(href => href && EXTENSIONS.some(ext => href.toLowerCase().endsWith(ext)))
.map(href => href.split('/').pop()); // Just the filename
if (links.length > 0) return links;
} catch (e) {
// Directory listing not available
}
// METHOD 2: Manual file list (fallback)
// If you can't use a server, list your files here or use the manual input below
return null;
}
async function loadImages() {
const gallery = document.getElementById('gallery');
const statusDot = document.getElementById('statusDot');
statusDot.classList.add('updating');
let files = null;
// Use preloaded images if available
if (typeof PRELOADED_IMAGES !== 'undefined' && PRELOADED_IMAGES.length > 0) {
files = PRELOADED_IMAGES;
} else {
files = await scanFolder();
}
// FALLBACK: If auto-scan doesn't work, use this manual approach
if (!files) {
files = await discoverImages();
}
// Sort by newest first (we use a cache-busting parameter to force reload)
const fileObjects = files.map(name => ({
name: name,
url: IMAGE_FOLDER + name + '?t=' + Date.now(),
cleanName: name.split('?')[0]
}));
// Check for new files
const newFiles = fileObjects.filter(f => !knownFiles.has(f.cleanName));
newFiles.forEach(f => knownFiles.add(f.cleanName));
currentFiles = fileObjects;
if (fileObjects.length === 0) {
gallery.innerHTML = `
<div class="empty-state" style="grid-column: 1 / -1;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="M21 15l-5-5L5 21"/>
</svg>
<h2>No images found in "${IMAGE_FOLDER}"</h2>
<p>Make sure this HTML file is in the same folder as your images,<br>or update the IMAGE_FOLDER path in the script.</p>
</div>
`;
statusDot.classList.remove('updating');
return;
}
// Render gallery
gallery.innerHTML = fileObjects.map((file, index) => {
const isNew = index < newFiles.length;
const isRecent = index < 3;
return `
<div class="image-card ${isNew ? 'new' : ''}" data-name="${file.cleanName}">
<div class="image-wrapper">
<div class="select-indicator"></div>
<img src="${file.url}"
alt="${file.cleanName}"
loading="lazy"
onerror="this.parentElement.innerHTML='<div style=\\'position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#444;font-size:13px;\\'>Failed to load</div>'">
</div>
<div class="image-info">
<div class="image-name" title="${file.cleanName}">${file.cleanName}</div>
<div class="image-meta">
<span class="image-time">#${String(fileObjects.length - index).padStart(3, '0')}</span>
${isRecent ? '<span class="badge recent">Latest</span>' : ''}
</div>
</div>
</div>
`;
}).join('');
updateCardSelection();
document.getElementById('count').textContent = `${fileObjects.length} image${fileObjects.length !== 1 ? 's' : ''}`;
if (newFiles.length > 0) {
showToast(`${newFiles.length} new image${newFiles.length !== 1 ? 's' : ''} detected`, 'success');
}
statusDot.classList.remove('updating');
}
// Discover images by trying common patterns (fallback for file:// protocol)
async function discoverImages() {
const found = [];
// Try to find images by testing if they exist
// This is a best-effort approach for local file access
// If you know your naming pattern, add it here:
const patterns = [];
// Try to extract from page if there are any references
const images = document.querySelectorAll('img[src]');
images.forEach(img => {
const src = img.getAttribute('src');
if (src && EXTENSIONS.some(ext => src.toLowerCase().includes(ext))) {
found.push(src.split('/').pop().split('?')[0]);
}
});
// Remove duplicates
return [...new Set(found)];
}
function setIntervalTime(seconds) {
REFRESH_INTERVAL = seconds;
document.getElementById('statusText').textContent = `Auto-refresh: ${seconds < 60 ? seconds + 's' : (seconds / 60) + 'm'}`;
clearInterval(autoRefreshTimer);
autoRefreshTimer = setInterval(refreshNow, REFRESH_INTERVAL * 1000);
showToast(`Refresh interval set to ${seconds < 60 ? seconds + ' seconds' : (seconds / 60) + ' minutes'}`, 'info');
}
function refreshNow() {
loadImages();
}
const API = 'http://127.0.0.1:8500';
// --- selection state ---
let selectionMode = false;
const selectedFiles = new Set();
function toggleSelectMode() {
selectionMode = !selectionMode;
if (!selectionMode) selectedFiles.clear();
document.getElementById('selectBtn').classList.toggle('active', selectionMode);
const bar = document.getElementById('batchBar');
bar.classList.toggle('visible', selectionMode);
document.querySelector('.gallery').style.paddingBottom = selectionMode ? '90px' : '40px';
updateCardSelection();
updateBatchBar();
}
function toggleFile(name) {
if (selectedFiles.has(name)) selectedFiles.delete(name);
else selectedFiles.add(name);
updateCardSelection();
updateBatchBar();
}
function updateCardSelection() {
document.querySelectorAll('.image-card').forEach(card => {
const name = card.dataset.name;
card.classList.toggle('selectable', selectionMode);
card.classList.toggle('selected', selectionMode && selectedFiles.has(name));
});
}
function updateBatchBar() {
const n = selectedFiles.size;
document.getElementById('batchCount').textContent = `${n} selected`;
document.getElementById('processBtn').textContent = n > 0 ? `Process (${n})` : 'Process';
}
function selectAll() {
currentFiles.forEach(f => selectedFiles.add(f.cleanName));
updateCardSelection();
updateBatchBar();
}
function deselectAll() {
selectedFiles.clear();
updateCardSelection();
updateBatchBar();
}
// --- batch processing ---
let pollTimer = null;
async function processSelected() {
const prompt = document.getElementById('batchPromptInput').value.trim();
if (!prompt) { showToast('Enter a prompt first', 'info'); return; }
if (selectedFiles.size === 0) { showToast('No images selected', 'info'); return; }
savePromptHistory(prompt);
const filenames = [...selectedFiles];
document.getElementById('batchProgress').textContent = 'Submitting…';
try {
const r = await fetch(`${API}/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filenames, prompt, seed: -1, max_area: 0 }),
});
if (!r.ok) { showToast('Batch failed to start', 'info'); document.getElementById('batchProgress').textContent = ''; return; }
const { job_id, total } = await r.json();
showToast(`Processing ${total} image${total !== 1 ? 's' : ''}`, 'info');
pollJob(job_id, total);
} catch (e) {
showToast('API not reachable', 'info');
document.getElementById('batchProgress').textContent = '';
}
}
function pollJob(job_id, total) {
clearTimeout(pollTimer);
pollTimer = setTimeout(async () => {
try {
const r = await fetch(`${API}/batch/${job_id}`);
if (!r.ok) return;
const job = await r.json();
const done = job.done + job.failed;
document.getElementById('batchProgress').textContent = `${done}/${total}`;
if (job.status === 'running') {
pollJob(job_id, total);
} else {
const msg = job.failed > 0
? `Done: ${job.done} ok, ${job.failed} failed`
: `Done: ${job.done} processed`;
document.getElementById('batchProgress').textContent = msg;
showToast(msg, 'success');
loadImages();
}
} catch (e) { /* ignore transient errors */ pollJob(job_id, total); }
}, 2000);
}
// --- prompt history (localStorage) ---
function loadPromptHistory() {
const hist = JSON.parse(localStorage.getItem('batchPromptHistory') || '[]');
const dl = document.getElementById('promptHistory');
dl.innerHTML = hist.map(p => `<option value="${p.replace(/"/g, '&quot;')}"></option>`).join('');
}
function savePromptHistory(prompt) {
let hist = JSON.parse(localStorage.getItem('batchPromptHistory') || '[]');
hist = [prompt, ...hist.filter(p => p !== prompt)].slice(0, 20);
localStorage.setItem('batchPromptHistory', JSON.stringify(hist));
loadPromptHistory();
}
async function loadCurrentPrompt() {
try {
const r = await fetch(`${API}/config`);
if (r.ok) {
const conf = await r.json();
document.getElementById('promptInput').value = conf.prompt || '';
}
} catch (e) {
// API not reachable yet
}
}
async function setPrompt() {
const prompt = document.getElementById('promptInput').value.trim();
if (!prompt) return;
try {
const r = await fetch(`${API}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
});
if (r.ok) {
showToast('Prompt updated', 'success');
} else {
showToast('Failed to update prompt', 'info');
}
} catch (e) {
showToast('API not reachable', 'info');
}
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
loadCurrentPrompt();
loadPromptHistory();
loadImages();
autoRefreshTimer = setInterval(refreshNow, REFRESH_INTERVAL * 1000);
// Selection click delegation
document.getElementById('gallery').addEventListener('click', e => {
if (!selectionMode) return;
const card = e.target.closest('.image-card');
if (card) toggleFile(card.dataset.name);
});
});
// Handle visibility change - refresh when tab becomes active
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
loadImages();
}
});
</script>
</body>
</html>

108
tour-comfy/processed.json Normal file
View File

@@ -0,0 +1,108 @@
{
"img_2.png": "2a721fdedc31f4ee11fd7c8ab85a4b33",
"img_19.png": "82660461b10e0545165fe8dade87d4d2",
"img.png": "6023644b2237fe301e43431e28a9d2e9",
"img_4.png": "3c87ca4219c2ed6f275c044fa888afc0",
"img_3.png": "3c87ca4219c2ed6f275c044fa888afc0",
"img_5.png": "3c87ca4219c2ed6f275c044fa888afc0",
"imgxx.png": "861b244b0aa205b8fdff4015a128a5a4",
"img_1.png": "4c09437df56a8beb2a620f420a4d4d5a",
"img_6.png": "7ad3913d8fea47ea120a8e958dd2fc62",
"img_7.png": "2ad545906c6b99cac1a78305a37a5eed",
"img_8.png": "4c453e8e332e92478bf0d49e663dedc9",
"img_9.png": "7e35f295100f51a9a58533c8d1f1fc80",
"test_timestamp.png": "d41b2ed22d4562d6aa0b7dd2248f18a2",
"img_91.png": "7e35f295100f51a9a58533c8d1f1fc80",
"test.png": "e569b50c015080e05e36f21307550e1a",
"img_19_2.png": "e745ea158afbf416df6c53835c11f4c7",
"img_93.png": "ab83477ac7782d7a7b38369ccfa7df80",
"img_92.png": "7e35f295100f51a9a58533c8d1f1fc80",
"imgxxx.png": "8965337a6abd7bfec6cb774978b4198b",
"img_10.png": "10635b82a1cfcefb2c97af2ebd61d889",
"img_12.png": "3b126df55d41bb48829650a766e3c6f3",
"img_11.png": "c5e036773ead71f40a1f1966f74abc2b",
"other.jpeg": "b89b5886901ba89c5d3fcd97430904e8",
"others.jpeg": "b89b5886901ba89c5d3fcd97430904e8",
"img_6v1.png": "7ad3913d8fea47ea120a8e958dd2fc62",
"image.png": "7ad3913d8fea47ea120a8e958dd2fc62",
"test123.jpeg": "b89b5886901ba89c5d3fcd97430904e8",
"img_13.png": "5fb9fc29ca1d32c4ab6816160fcfdfe1",
"img_14.png": "759b2e7532f7bf3a0d956a7f1c4cb7af",
"img_15.png": "cbd90cb2e2edf2e4eff746c586657ad5",
"img_16.png": "9c76b232aacf1f18739b323f5b6887d7",
"img_17.png": "9955e08008340d6b2acd945cc4d9505a",
"img_18.png": "5a50839c5d5a726b5b0e770bb266f6d6",
"img_20.png": "29d28ef9f53d71c066798c46341d30a9",
"img_run.png": "72ddfe64a1cea5611ea61425f4f61fd2",
"img_21.png": "82660461b10e0545165fe8dade87d4d2",
"img_22.png": "4c9adb619b5f191d9b76d1493df211c6",
"img_23.png": "2ab193e0c19cec124764f9f54383ef2d",
"img_24.png": "942d5409a0e4c70b4f960bc6468269e2",
"imgxxxx.png": "8965337a6abd7bfec6cb774978b4198b",
"img_25.png": "1d51cc605017423c0ae114f0b883bfea",
"img_26.png": "23ef7f416f21ca3d5d237ecfdc88833e",
"img_27.png": "9bb13a4ca7292c964fedd91065af64a0",
"img_28.png": "37a7ac25c45ec63a87e04c9fe3b73aab",
"img_29.png": "5ed1e8acd413beb7165da9880d6b052d",
"img_30.png": "d907cf83d022af0545b55842f2573581",
"img_31.png": "312874252c58cbb89c8cad46bc17e7e7",
"img_32.png": "4d5ca98a255d51d5775725136f13a162",
"img_34.png": "c5f814c539acc7cdef0da3279544e55f",
"img_33.png": "c5f814c539acc7cdef0da3279544e55f",
"img_37.png": "fefa5a1bb755fb0d4f03d966ac04dae3",
"img_35.png": "ed1e95446696a97f46d84b246d01e0f7",
"img_36.png": "ed1e95446696a97f46d84b246d01e0f7",
"img_38.png": "dad813a6e3e919454ba1ff0fb3f8df22",
"img_39.png": "732cda29c3f658a9847239d23cbe759b",
"img_40.png": "6022fd6b8a4580ef4451c3c87e469b85",
"img_42.png": "a4578fb780c2424581d72c862e14af6c",
"img_41.png": "8871248c6da117c9b4318bcc6942c17a",
"img_45.png": "2329402c6d8c5b7555a7432eb1aeccd9",
"img_43.png": "e1d75f3326dd89c39fa91b9aaa0b54f9",
"img_44.png": "6196e5ba2bb6fb46459c44543e0eeb64",
"img_46.png": "3c73192c04ca22cc4ecb450565315798",
"img_47.png": "2fb600fb8f3717bd79a9f56fa32efb0b",
"20150913_211324.jpg": "bb75b92835207e81287a392f17f88eaf",
"20160903_200935.jpg": "1cf5a582c8a40610640898ebaee2ade3",
"20160903_200728.jpg": "7c93ad8f71045b07aeb390e0c906d5f2",
"img_49.png": "bd72afa8e00ffff04dc8e860af058d0d",
"img_48.png": "b6daebc286bc1c22a586ee1233c5b420",
"img_50.png": "6f8b764e0973cb5c34b0c02f79b202ca",
"img_51.png": "851612780583d06942c119b2c99b7b06",
"img_52.png": "935946efb74fc333f41146691a61cc8f",
"img_57.png": "514f89464e3c79bba7928b69ed01650e",
"img_53.png": "ce7e90270592666da95b0c638d10d745",
"img_58.png": "4fae0cf0017c9be9a550792167d60ce3",
"img_54.png": "0d70e4782c2823f2f8d2b4c149a38e0b",
"img_55.png": "ce7e90270592666da95b0c638d10d745",
"img_56.png": "1501a3128f19392124e727121a8f2bd4",
"img_59.png": "4fae0cf0017c9be9a550792167d60ce3",
"img_60.png": "2c31cb016cad120daaf4e441a720a56c",
"img_61.png": "68b1c7a9596ab2f0ff038bb585d7c4d4",
"img_62.png": "c2c444a31001421a69dbc9ecf8588149",
"img_64.png": "d5b235b57e6ec07e790f35b9cda399fb",
"img_63.png": "b454d1869a8fc62ed1dc988467b541db",
"img_66.png": "59e227fc6b06a2cd27659a9facf43c0d",
"img_65.png": "c442724f5a29468eed4e9563e25e06e8",
"img_68.png": "4c7fbe72509ab5b08ccdab726b5ff035",
"img_67.png": "1970b9952e14688c22f8f54ea7d7b4ec",
"img_69.png": "0bcf667d5603a157159052baddbb6e50",
"img_70.png": "0bcf667d5603a157159052baddbb6e50",
"img_71.png": "b3e79f4ff2882360b24f77a295f16650",
"img_72.png": "ebd8cd52977b7fccc6ecff7ee2f392b5",
"img_73.png": "28c91d6bc883ed6de89b62755d5417b9",
"img_74.png": "b7c41d0f4c062cc7d7da240173a1f075",
"img_75.png": "7b405fc4a4e022a272e5f09c7c485712",
"img_76.png": "29b9c219a777155d576b9f35a3f41cca",
"img_77.png": "6db31602d0ed36abc2d92a88fdcc21b0",
"img_78.png": "15e83866d2f2afb2ee1e8d2a06a70b9d",
"img_79.png": "ab18eb4ce243db0f81f608d744596885",
"img_80.png": "de8dfcb0d6eeda8187783e8417f04b8b",
"img_81.png": "072948a36c4ba79f4e761a9491f3d8eb",
"img_82.png": "724fcb641da5f3294aa800ce0a9b93e4",
"img_84.png": "f5dea766c46abfa4011c23d3c466d8dc",
"img_85.png": "f48059d59efd33ec8cce4daa44bbd46d",
"img_83.png": "501207e02d72776b65d705db9f28a179",
"img_86.png": "9e831c994a69ee1e18a6d279d69c072f",
"img_87.png": "4ce165b53df962d9e371124bfdec64bd"
}

12
tour-comfy/run_comfyui.sh Normal file → Executable file
View File

@@ -2,18 +2,22 @@
# Launch the ComfyUI backend (headless) for the Qwen-Image-Edit API. # Launch the ComfyUI backend (headless) for the Qwen-Image-Edit API.
# gfx906 (MI50) has no flash-attention, so use the pytorch cross-attention path. # gfx906 (MI50) has no flash-attention, so use the pytorch cross-attention path.
set -e set -e
BASE=/media/tour/APPS/comfyui # env.sh resolves BASE/COMFY/VENV (and keeps the venv off NTFS). Portable
cd "$BASE/ComfyUI" # across hosts (tour: /media/tour/APPS/comfyui, hubby: /home/hubby/comfyui).
source "$BASE/venv/bin/activate" source "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/env.sh"
cd "$COMFY"
source "$VENV/bin/activate"
# MI50 / Vega20 is happiest in fp16; avoid bf16 emulation. # MI50 / Vega20 is happiest in fp16; avoid bf16 emulation.
export PYTORCH_HIP_ALLOC_CONF=expandable_segments:True export PYTORCH_HIP_ALLOC_CONF="expandable_segments:True,garbage_collection_threshold:0.8"
export HSA_ENABLE_SDMA=0 export HSA_ENABLE_SDMA=0
# Split cross-attention chunks the attention matmul -> much lower peak VRAM, # Split cross-attention chunks the attention matmul -> much lower peak VRAM,
# which is what lets the 20B Q8 edit model + reference-image sequence fit in 32GB. # which is what lets the 20B Q8 edit model + reference-image sequence fit in 32GB.
# --lowvram offloads models to CPU RAM when not in use, preventing OOM.
exec python main.py \ exec python main.py \
--listen 127.0.0.1 \ --listen 127.0.0.1 \
--port 8188 \ --port 8188 \
--use-split-cross-attention \ --use-split-cross-attention \
--lowvram \
"$@" "$@"

10
tour-comfy/start_api.sh Normal file → Executable file
View File

@@ -1,9 +1,10 @@
#!/bin/bash #!/bin/bash
# Launch the FastAPI edit service (talks to the local ComfyUI on :8188). # Launch the FastAPI edit service (talks to the local ComfyUI on :8188).
set -e set -e
BASE=/media/tour/APPS/comfyui # env.sh resolves API_DIR/VENV (and keeps the venv off NTFS).
source "$BASE/venv/bin/activate" source "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/env.sh"
cd "$BASE/api" source "$VENV/bin/activate"
cd "$API_DIR"
export COMFY_URL="http://127.0.0.1:8188" export COMFY_URL="http://127.0.0.1:8188"
export HOST="0.0.0.0" export HOST="0.0.0.0"
@@ -11,6 +12,7 @@ export PORT="8500"
# Output pixel budget. MI50 is compute-bound on this 20B model: # Output pixel budget. MI50 is compute-bound on this 20B model:
# ~0.59MP -> ~110s ~0.79MP -> ~140s ~1.0MP -> ~180s (4 steps) # ~0.59MP -> ~110s ~0.79MP -> ~140s ~1.0MP -> ~180s (4 steps)
# 0.79MP is a sane speed/quality default; raise for bigger output. # 0.79MP is a sane speed/quality default; raise for bigger output.
export MAX_AREA="${MAX_AREA:-786432}" # Lowered to 0.65MP to help prevent GPU OOM on MI50.
export MAX_AREA="${MAX_AREA:-655360}"
exec python edit_api.py exec python edit_api.py

13
tour-comfy/start_watcher.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/bash
# Launch the folder watcher service.
set -e
API_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
BASE="$( cd "$API_DIR/.." && pwd )"
# Try to activate venv if it exists, otherwise use system python
if [ -d "$BASE/venv" ]; then
source "$BASE/venv/bin/activate"
fi
cd "$API_DIR"
exec python3 watcher.py

View File

@@ -1,13 +1,14 @@
[Unit] [Unit]
Description=Qwen-Image-Edit FastAPI Service Description=Qwen-Image-Edit FastAPI Service
After=comfyui-backend.service After=comfyui-backend.service
Requires=comfyui-backend.service
[Service] [Service]
Type=simple Type=simple
User=tour User=__USER__
Group=tour Group=__GROUP__
WorkingDirectory=/media/tour/APPS/comfyui/api WorkingDirectory=__BASE__/api
ExecStart=/bin/bash /media/tour/APPS/comfyui/api/start_api.sh ExecStart=/bin/bash __BASE__/api/start_api.sh
Restart=always Restart=always
RestartSec=5 RestartSec=5
StandardOutput=journal StandardOutput=journal

View File

@@ -4,10 +4,10 @@ After=network.target
[Service] [Service]
Type=simple Type=simple
User=tour User=__USER__
Group=tour Group=__GROUP__
WorkingDirectory=/media/tour/APPS/comfyui WorkingDirectory=__BASE__
ExecStart=/bin/bash /media/tour/APPS/comfyui/api/run_comfyui.sh ExecStart=/bin/bash __BASE__/api/run_comfyui.sh
Restart=always Restart=always
RestartSec=5 RestartSec=5
StandardOutput=journal StandardOutput=journal

0
tour-comfy/watcher.lock Normal file
View File

1139
tour-comfy/watcher.log Normal file

File diff suppressed because one or more lines are too long

291
tour-comfy/watcher.py Normal file
View File

@@ -0,0 +1,291 @@
import os
import time
import json
import shutil
import requests
from PIL import Image
import logging
import hashlib
import sys
import fcntl
import re
# Load configuration
CONFIG_PATH = os.path.join(os.path.dirname(__file__), "config.json")
def load_config():
with open(CONFIG_PATH, 'r') as f:
conf = json.load(f)
# Resolve relative paths relative to this script's directory
base_dir = os.path.dirname(os.path.abspath(__file__))
for key in ["stage_dir", "output_dir", "failed_dir", "processed_file", "log_file"]:
if not os.path.isabs(conf[key]):
conf[key] = os.path.normpath(os.path.join(base_dir, "..", conf[key]))
return conf
CONF = load_config()
# Setup logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(CONF["log_file"]),
logging.StreamHandler()
]
)
def get_processed_files():
if os.path.exists(CONF["processed_file"]):
try:
with open(CONF["processed_file"], 'r') as f:
data = json.load(f)
if isinstance(data, list):
# Migration: convert old list format to dict
return {name: None for name in data}
return data
except Exception as e:
logging.error(f"Error reading processed file: {e}")
return {}
return {}
def save_processed_files(processed):
try:
with open(CONF["processed_file"], 'w') as f:
json.dump(processed, f, indent=2)
except Exception as e:
logging.error(f"Error saving processed file: {e}")
def calculate_hash(filepath):
"""Calculate MD5 hash of a file."""
hasher = hashlib.md5()
try:
with open(filepath, 'rb') as f:
for chunk in iter(lambda: f.read(4096), b""):
hasher.update(chunk)
return hasher.hexdigest()
except Exception as e:
logging.error(f"Error calculating hash for {filepath}: {e}")
return None
def crop_to_bbox(image_path, margin, top_margin=None, headroom=0.0):
try:
img = Image.open(image_path)
if img.mode != 'RGBA':
logging.info(f"Image {image_path} is mode {img.mode}, not RGBA. Skipping crop.")
return img
alpha = img.split()[-1]
bbox = alpha.getbbox()
if not bbox:
logging.info(f"No non-transparent bbox found for {image_path}. Returning original.")
return img
if top_margin is None:
top_margin = margin
# Add margin
left, upper, right, lower = bbox
left = max(0, left - margin)
upper = max(0, upper - top_margin)
right = min(img.width, right + margin)
lower = min(img.height, lower + margin)
logging.info(f"Cropping {image_path} to {left, upper, right, lower} (margin={margin}, top_margin={top_margin})")
cropped = img.crop((left, upper, right, lower))
if headroom > 0:
h_px = int(cropped.height * headroom)
if h_px > 0:
logging.info(f"Adding {h_px}px headroom to {image_path}")
new_img = Image.new("RGBA", (cropped.width, cropped.height + h_px), (0, 0, 0, 0))
new_img.paste(cropped, (0, h_px))
return new_img
return cropped
except Exception as e:
logging.error(f"Failed to crop {image_path}: {e}")
raise
def is_file_stable(filepath):
"""Check if file size is stable for at least 1 second."""
try:
size1 = os.path.getsize(filepath)
time.sleep(1)
size2 = os.path.getsize(filepath)
return size1 == size2 and size1 > 0
except OSError:
return False
def flag_image(filename):
input_path = os.path.join(CONF["stage_dir"], filename)
timestamp = time.strftime("%Y%m%d_%H%M%S")
failed_filename = f"{timestamp}_{filename}"
failed_path = os.path.join(CONF["failed_dir"], failed_filename)
try:
os.makedirs(CONF["failed_dir"], exist_ok=True)
logging.info(f"Flagging image {filename} (moving to failed directory as {failed_filename})")
shutil.move(input_path, failed_path)
except Exception as e:
logging.error(f"Failed to move {filename} to failed directory: {e}")
def process_image(filename):
# Reload config in case it changed
global CONF
try:
CONF = load_config()
except:
pass
input_path = os.path.join(CONF["stage_dir"], filename)
timestamp = time.strftime("%Y%m%d_%H%M%S")
output_filename = f"{timestamp}_{filename}"
output_path = os.path.join(CONF["output_dir"], output_filename)
try:
logging.info(f"Starting processing for {filename}...")
cropped_img = crop_to_bbox(
input_path,
CONF["margin"],
top_margin=CONF.get("top_margin"),
headroom=CONF.get("headroom", 0.0)
)
# Save temporary cropped image for upload
temp_path = input_path + ".tmp.png"
cropped_img.save(temp_path, format="PNG")
with open(temp_path, 'rb') as f:
files = {'image': (filename, f, 'image/png')}
data = {
'prompt': CONF["prompt"],
'seed': CONF.get("seed", -1),
'max_area': CONF.get("max_area", 0)
}
logging.info(f"Calling API for {filename} -> {output_filename} with prompt: {CONF['prompt']}")
response = requests.post(CONF["api_url"], files=files, data=data, timeout=600)
if response.status_code == 200:
with open(output_path, 'wb') as f:
f.write(response.content)
logging.info(f"Successfully processed {filename} -> {output_path}")
if os.path.exists(temp_path):
os.remove(temp_path)
return True
else:
logging.error(f"API failed for {filename}: {response.status_code} - {response.text}")
if os.path.exists(temp_path):
os.remove(temp_path)
return False
except requests.exceptions.ConnectionError as e:
logging.error(f"Connection error while processing {filename}: {e}")
if os.path.exists(temp_path):
os.remove(temp_path)
return None
except Exception as e:
logging.error(f"Error processing {filename}: {str(e)}", exc_info=True)
if os.path.exists(temp_path):
os.remove(temp_path)
return False
def update_car_html():
output_dir = CONF["output_dir"]
car_html_path = os.path.join(output_dir, "car.html")
if not os.path.exists(car_html_path):
logging.warning(f"car.html not found at {car_html_path}")
return
try:
# List images in output_dir
extensions = ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg')
images = [f for f in os.listdir(output_dir) if f.lower().endswith(extensions) and f != "car.html"]
# Sort by mtime, newest first
images.sort(key=lambda x: os.path.getmtime(os.path.join(output_dir, x)), reverse=True)
with open(car_html_path, 'r') as f:
content = f.read()
images_json = json.dumps(images, indent=12).strip()
# Ensure it looks nice in the JS
images_json = images_json.replace('\n', '\n ')
pattern = r'// --- HYDRATION_START ---.*?// --- HYDRATION_END ---'
replacement = f'// --- HYDRATION_START ---\n const PRELOADED_IMAGES = {images_json};\n // --- HYDRATION_END ---'
new_content = re.sub(pattern, replacement, content, flags=re.DOTALL)
with open(car_html_path, 'w') as f:
f.write(new_content)
logging.info(f"Updated {car_html_path} with {len(images)} images")
except Exception as e:
logging.error(f"Failed to update car.html: {e}")
def main():
# Prevent multiple instances
lock_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "watcher.lock")
fp = open(lock_file, 'w')
try:
fcntl.lockf(fp, fcntl.LOCK_EX | fcntl.LOCK_NB)
except IOError:
print("Another instance of watcher.py is already running. Exiting.")
sys.exit(1)
processed = get_processed_files()
# Ensure directories exist
os.makedirs(CONF["stage_dir"], exist_ok=True)
os.makedirs(CONF["output_dir"], exist_ok=True)
os.makedirs(CONF["failed_dir"], exist_ok=True)
logging.info(f"Watcher started. Monitoring {CONF['stage_dir']}...")
logging.info(f"Output directory: {CONF['output_dir']}")
logging.info(f"API URL: {CONF['api_url']}")
while True:
try:
files = [f for f in os.listdir(CONF["stage_dir"])
if f.lower().endswith(('.png', '.jpg', '.jpeg'))
and not f.endswith('.tmp.png')]
for f in files:
input_path = os.path.join(CONF["stage_dir"], f)
# Check if file is stable (not still being copied)
if not is_file_stable(input_path):
continue
# Calculate current file hash
current_hash = calculate_hash(input_path)
if not current_hash:
continue
# Check if already processed
if f in processed:
stored_hash = processed[f]
if stored_hash == current_hash:
continue
if stored_hash is None:
# Migration case: filename exists but no hash.
# Skip to avoid mass re-processing, but update the hash.
logging.info(f"Updating hash for previously processed {f}")
processed[f] = current_hash
save_processed_files(processed)
continue
res = process_image(f)
if res is True:
processed[f] = current_hash
save_processed_files(processed)
update_car_html()
elif res is False:
flag_image(f)
# We don't add to processed here so that if the user
# moves the file back to stage, it will be retried.
except Exception as e:
logging.error(f"Main loop error: {e}")
time.sleep(CONF["poll_interval"])
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,75 @@
{
"1": {
"class_type": "UnetLoaderGGUF",
"inputs": { "unet_name": "Qwen-Rapid-NSFW-v23_Q8_0.gguf" },
"_meta": { "title": "unet (gguf)" }
},
"2": {
"class_type": "CLIPLoader",
"inputs": {
"clip_name": "qwen_2.5_vl_7b_fp8_scaled.safetensors",
"type": "qwen_image"
},
"_meta": { "title": "text encoder" }
},
"3": {
"class_type": "VAELoader",
"inputs": { "vae_name": "qwen_image_vae.safetensors" },
"_meta": { "title": "vae" }
},
"4": {
"class_type": "LoadImage",
"inputs": { "image": "input.png" },
"_meta": { "title": "input image" }
},
"5": {
"class_type": "TextEncodeQwenImageEditPlus",
"inputs": {
"clip": ["2", 0],
"vae": ["3", 0],
"image1": ["4", 0],
"prompt": "edit instruction goes here"
},
"_meta": { "title": "positive" }
},
"6": {
"class_type": "TextEncodeQwenImageEditPlus",
"inputs": {
"clip": ["2", 0],
"vae": ["3", 0],
"prompt": " "
},
"_meta": { "title": "negative" }
},
"7": {
"class_type": "EmptySD3LatentImage",
"inputs": { "width": 1024, "height": 1024, "batch_size": 1 },
"_meta": { "title": "latent" }
},
"8": {
"class_type": "KSampler",
"inputs": {
"model": ["1", 0],
"positive": ["5", 0],
"negative": ["6", 0],
"latent_image": ["7", 0],
"seed": 0,
"steps": 4,
"cfg": 1.0,
"sampler_name": "euler_ancestral",
"scheduler": "beta",
"denoise": 1.0
},
"_meta": { "title": "sampler" }
},
"9": {
"class_type": "VAEDecode",
"inputs": { "samples": ["8", 0], "vae": ["3", 0] },
"_meta": { "title": "decode" }
},
"10": {
"class_type": "SaveImage",
"inputs": { "images": ["9", 0], "filename_prefix": "qwenedit" },
"_meta": { "title": "save" }
}
}