aa
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
*.png
|
||||||
|
*.jpeg
|
||||||
|
*.jpg
|
||||||
|
.idea/
|
||||||
13
.junie/models/local-ollama.json
Normal file
13
.junie/models/local-ollama.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
14
.junie/models/local-qwen25.json
Normal file
14
.junie/models/local-qwen25.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
5
Modelfile.qwen25-coder-32b-64k
Normal file
5
Modelfile.qwen25-coder-32b-64k
Normal 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
72
a6000-comfy/bootstrap.sh
Executable 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
45
a6000-comfy/deploy.sh
Executable 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
17
a6000-comfy/env.sh
Executable 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
16
a6000-comfy/run_comfyui.sh
Executable 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
16
a6000-comfy/start_api.sh
Executable 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
|
||||||
17
a6000-comfy/systemd/comfyui-api.service
Normal file
17
a6000-comfy/systemd/comfyui-api.service
Normal 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
|
||||||
16
a6000-comfy/systemd/comfyui-backend.service
Normal file
16
a6000-comfy/systemd/comfyui-backend.service
Normal 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
|
||||||
17
a6000-comfy/systemd/comfyui-watcher.service
Normal file
17
a6000-comfy/systemd/comfyui-watcher.service
Normal 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
|
||||||
7
a6000-comfy/systemd/comfyui.target
Normal file
7
a6000-comfy/systemd/comfyui.target
Normal 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
23
example.py
Normal 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}")
|
||||||
@@ -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
64
tour-comfy/bootstrap.sh
Executable 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
15
tour-comfy/config.json
Normal 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"
|
||||||
|
}
|
||||||
@@ -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
169
tour-comfy/deploy_api.sh
Executable 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
|
||||||
@@ -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
25
tour-comfy/env.sh
Normal 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
931
tour-comfy/output/car.html
Normal 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, '"')}"></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
108
tour-comfy/processed.json
Normal 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
12
tour-comfy/run_comfyui.sh
Normal file → Executable 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
10
tour-comfy/start_api.sh
Normal file → Executable 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
13
tour-comfy/start_watcher.sh
Executable 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
0
tour-comfy/watcher.lock
Normal file
1139
tour-comfy/watcher.log
Normal 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
291
tour-comfy/watcher.py
Normal 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()
|
||||||
75
tour-comfy/workflow_qwen_edit.json
Normal file
75
tour-comfy/workflow_qwen_edit.json
Normal 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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user