From 7eb544211ebdb42ccb7db75f039c59ce67686839 Mon Sep 17 00:00:00 2001 From: mike Date: Sat, 27 Jun 2026 00:44:56 +0200 Subject: [PATCH] reorder --- tour-comfy/bootstrap.sh | 64 - tour-comfy/car.html | 7241 ---------------------------- tour-comfy/config.json | 30 - tour-comfy/database.py | 388 -- tour-comfy/deploy.sh | 41 - tour-comfy/edit_api.py | 4432 ----------------- tour-comfy/embeddings.py | 30 - tour-comfy/env.sh | 30 - tour-comfy/groups.json | 321 -- tour-comfy/install_facefusion.sh | 52 - tour-comfy/install_gfpgan.sh | 83 - tour-comfy/naming.py | 215 - tour-comfy/orbit_module.py | 433 -- tour-comfy/orbit_poc.py | 141 - tour-comfy/orbit_qwen.py | 373 -- tour-comfy/orbit_qwen_poc.py | 71 - tour-comfy/poses.md | 4546 ----------------- tour-comfy/privacy-lock-watcher.sh | 41 - tour-comfy/processed.json | 106 - tour-comfy/run_comfyui.sh | 23 - tour-comfy/start_api.sh | 23 - tour-comfy/start_watcher.sh | 9 - tour-comfy/stop.sh | 18 - tour-comfy/systemd/comfyui.target | 8 - tour-comfy/test_transparency.py | 191 - tour-comfy/turntable_cache.py | 151 - tour-comfy/watcher.bak | 2147 --------- tour-comfy/watcher.lock | 0 tour-comfy/watcher.py | 314 -- tour-comfy/workflow_qwen_edit.json | 75 - 30 files changed, 21597 deletions(-) delete mode 100755 tour-comfy/bootstrap.sh delete mode 100644 tour-comfy/car.html delete mode 100644 tour-comfy/config.json delete mode 100644 tour-comfy/database.py delete mode 100755 tour-comfy/deploy.sh delete mode 100644 tour-comfy/edit_api.py delete mode 100644 tour-comfy/embeddings.py delete mode 100644 tour-comfy/env.sh delete mode 100644 tour-comfy/groups.json delete mode 100644 tour-comfy/install_facefusion.sh delete mode 100644 tour-comfy/install_gfpgan.sh delete mode 100644 tour-comfy/naming.py delete mode 100644 tour-comfy/orbit_module.py delete mode 100755 tour-comfy/orbit_poc.py delete mode 100644 tour-comfy/orbit_qwen.py delete mode 100644 tour-comfy/orbit_qwen_poc.py delete mode 100644 tour-comfy/poses.md delete mode 100755 tour-comfy/privacy-lock-watcher.sh delete mode 100644 tour-comfy/processed.json delete mode 100755 tour-comfy/run_comfyui.sh delete mode 100755 tour-comfy/start_api.sh delete mode 100755 tour-comfy/start_watcher.sh delete mode 100755 tour-comfy/stop.sh delete mode 100644 tour-comfy/systemd/comfyui.target delete mode 100644 tour-comfy/test_transparency.py delete mode 100644 tour-comfy/turntable_cache.py delete mode 100644 tour-comfy/watcher.bak delete mode 100644 tour-comfy/watcher.lock delete mode 100644 tour-comfy/watcher.py delete mode 100644 tour-comfy/workflow_qwen_edit.json diff --git a/tour-comfy/bootstrap.sh b/tour-comfy/bootstrap.sh deleted file mode 100755 index 8853be9..0000000 --- a/tour-comfy/bootstrap.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/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" diff --git a/tour-comfy/car.html b/tour-comfy/car.html deleted file mode 100644 index 6c83ff4..0000000 --- a/tour-comfy/car.html +++ /dev/null @@ -1,7241 +0,0 @@ - - - - - - Studio Monitor - - - -
-
- - AI Image Processing Assistant · Qwen-Image-Edit Rapid-AIO v23 -
-
-
-
-
You
-
What is this system and what does it run on?
-
-
-
-
-
AI
-
- This is a self-hosted AI image studio for generative editing, multi-view character rendering, background removal, face swapping, and group-based asset management. It runs on a local RTX A6000 GPU (CUDA 12.4, 48 GB VRAM) using the Qwen 2.5 VL GGUF model (Rapid-NSFW-v23 Q8_0) via ComfyUI. The frontend is a single-page browser app (car.html) talking to a FastAPI backend on :8500. -
-
-
-
-
-
You
-
Walk me through the image generation flow.
-
-
-
-
-
AI
-
- When a generation is triggered the pipeline is:

- 1. Prompt cleaning — if the prompt contains "transparent background", that keyword is stripped so Qwen renders a clean solid-BG image instead of producing washed-out halos.

- 2. ComfyUI queue — the reference image is uploaded to ComfyUI (:8188) and injected into image1. An optional wireframe frame extracted from a pose-guide video goes into image2. The workflow graph (workflow_qwen_edit.json) is patched with the prompt, dimensions, seed, and sampler params, then submitted via POST /prompt.

- 3. Qwen inference — the GGUF model (≈8 GB VRAM), text encoder (FP8), and VAE decode the latent into a PNG.

- 4. SAM2 post-processing — if transparent was requested, SAM2 point-prompt segmentation removes the background using 12 foreground seed points (hair → shoes, shoulder / hip lateral spread) and 7 background points (corners + edge midpoints). A coverage sanity check (5–92 %) falls back to rembg if the mask is implausible. -
-
-
-
-
-
You
-
What are the main API endpoints?
-
-
-
-
-
AI
-
- The FastAPI service (:8500) exposes:

- GET /images — full image list with PostgreSQL metadata (group, prompt, pose, sort order, archived flag).
- POST /upload — add an image; with group_id + skip_poses it silently joins an existing group without triggering pose generation.
- POST /batch — async multi-prompt generation job (filenames, prompts, poses, wireframe_ref). Poll progress via GET /jobs/{id}.
- POST /faceswap — insightface video face swap with optional GFPGAN restoration.
- POST /remove-background-sam/{f} — SAM2 BG removal to a .nobg.png sidecar.
- POST /images/{f}/set-preferred — moves image to group slot 0 and queues face extraction in the background.
- GET /wireframe/frame/{name}?t= — extract a frame at normalised time t∈[0,1] from a wireframe pose video. -
-
-
-
-
-
You
-
How does the face-book and multi-view workflow work?
-
-
-
-
-
AI
-
- Face-book: marking an image as "preferred" (star button) moves it to sort_order=0 in the group and triggers a background task. insightface detects the largest face, adds 50 % padding on the sides and 200 % headroom above, and saves a {group_id}_face.png crop. This appears as a 72 px thumbnail in the studio Info tab for fast face-reference lookup.

- Multi-view / camera angles: the Generate tab offers 12 camera angle presets — 8 absolute (Front, ¾ Left/Right, Side, Back, High, Low) and 4 relative (±45°, ±90°). Each fires a /batch request with the angle prompt injected and pose=null. The wireframe pose guide (a .mp4 video scrubbed to any frame) is passed as image2 in the Qwen workflow to constrain body layout without ControlNet — the controlnet model folder is empty by design.

- Group management: all images belong to groups stored in PostgreSQL. Clipboard paste (Ctrl+V) while a group is open offers "add to this group — no poses" or "new group with pose generation". Archive hides images from the default view without deleting them. -
-
-
-
-
-
-
Ask a follow-up question…
-
-
-
Press P or click to resume • Qwen-Image-Edit Rapid-AIO v23
-
- - - - -
- -
-

- - - - - - Studio Monitor AI -

-
- - -
-
- 0 images - - - - - - - -
-
- - Auto-refresh: 2m -
-
- - -
-
- - - -
- - -
- -
-
- -
-
- - - -
-
- -
-
- - -
- - -
-
- -
- - -
Hidden from preview
-
-
- -
- -
- -
- - -
-
- - -
- - -
-
-
- - -
-
- -
Orbit 3D preview
- - -
- -
- - -
- - -
- - -
- - -
- - -
- - - -
- - -
- - -
- - - -
-
- -
-
- 0 selected -
- - -
- - - - - - - - - -
- - - -
-
-
Select reference image
-
-
- - -
-
-
- - - - \ No newline at end of file diff --git a/tour-comfy/config.json b/tour-comfy/config.json deleted file mode 100644 index 9feac35..0000000 --- a/tour-comfy/config.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "api_url": "http://127.0.0.1:8500/edit", - "prompt": "high quality. hyper realistic. detailed, detailed skin, detailed female nude. realistic, high quality. realistic. detailed. female nude. realistic", - "base_prompts": [ - "Head-on full-nude-body three-quarter female portrait, realistic, black void background", - "Head-on straight-on full-nude-body female portrait, realistic, black void background", - "Head-on straight-on full-body female portrait, realistic, black void background", - "high quality, full-nude-body, female, masterpiece, realistic, photo, looking at viewer", - "high quality, full-nude-body, female, masterpiece, realistic, photo, detailed skin, professional lighting, black void background", - "high quality, full-nude-body, female, masterpiece, realistic, photo, cinematic lighting, dramatic shadows, sharp focus, black void background" - ], - "seed": -1, - "max_area": 655360, - "margin": 10, - "top_margin": 20, - "headroom": 0.05, - "poll_interval": 2, - "stage_dir": "/mnt/zim/tour-comfy/stage", - "output_dir": "/mnt/zim/tour-comfy/output", - "failed_dir": "/mnt/zim/tour-comfy/failed", - "processed_file": "./tour-comfy/processed.json", - "log_file": "./tour-comfy/watcher.log", - "wireframe_dir": "/mnt/zim/tour-comfy/wireframe", - "faceswap_model": "~/.insightface/models/inswapper_128.onnx", - "facefusion_dir": "~/facefusion", - "facefusion_venv": "~/facefusion-venv", - "sam2_checkpoint": "~/.sam/sam2.1_hiera_base_plus.pt", - "sam2_config": "configs/sam2.1/sam2.1_hiera_b+.yaml", - "bg_removal": "sam2" -} \ No newline at end of file diff --git a/tour-comfy/database.py b/tour-comfy/database.py deleted file mode 100644 index 4818a4d..0000000 --- a/tour-comfy/database.py +++ /dev/null @@ -1,388 +0,0 @@ -import psycopg2 -from psycopg2 import pool as _pgpool -import threading -import json - -DB_CONFIG = { - "host": "192.168.1.160", - "port": 5433, - "dbname": "dv", - "user": "dev", - "password": "dev" -} - -# A pooled connection is reused across requests instead of paying a fresh TCP + -# auth round-trip (~40 ms) on every single query. Under load (a generation -# running, a burst of reorders) the old open-per-call design starved the web -# threadpool and occasionally tripped Postgres' connection ceiling → 500s. -# The pool bounds connections and keeps them warm. If the pool can't be -# created or is momentarily exhausted, get_db_connection() falls back to a -# direct connect so callers never block or fail. -# Cover the web server's sync threadpool (uvicorn/anyio default = 40 workers) -# with headroom for background workers, while staying well under Postgres' -# max_connections (100). -_POOL_MIN = 2 -_POOL_MAX = 48 -_pool = None -_pool_lock = threading.Lock() - - -def _get_pool(): - global _pool - if _pool is not None: - return _pool - with _pool_lock: - if _pool is None: - try: - _pool = _pgpool.ThreadedConnectionPool( - _POOL_MIN, _POOL_MAX, **DB_CONFIG) - except Exception as e: - print(f"[db] pool init failed, using direct connections: {e}") - _pool = False # sentinel: don't retry on every call - return _pool - - -def get_db_connection(): - """Return a live DB connection, preferring the pool. - - Always pair with _put_db_connection() (the existing finally: conn.close() - callsites are rewritten to call it) so pooled connections are returned - rather than dropped. - """ - p = _get_pool() - if p: - try: - conn = p.getconn() - # Guard against a stale/dead pooled connection. - if getattr(conn, "closed", 0): - try: - p.putconn(conn, close=True) - except Exception: - pass - else: - return conn - except _pgpool.PoolError: - # Pool momentarily exhausted — expected under burst; fall back to a - # direct connection silently rather than blocking or failing. - pass - except Exception as e: - print(f"[db] pool getconn failed, direct connect: {e}") - return psycopg2.connect(**DB_CONFIG) - - -def _put_db_connection(conn): - """Return a connection to the pool (rolling back any open txn) or close it. - - Safe for both pooled and direct/fallback connections: putconn raises for a - connection the pool doesn't own, in which case we just close it. - """ - if conn is None: - return - p = _pool - try: - if p: - try: - conn.rollback() # clear any aborted/idle-in-txn state before reuse - except Exception: - pass - p.putconn(conn) - return - except Exception: - pass - try: - conn.close() - except Exception: - pass - -def migrate_schema(): - """Add new columns to person table if they don't exist. Safe to call repeatedly.""" - conn = get_db_connection() - cur = conn.cursor() - try: - for sql in [ - "ALTER TABLE person ADD COLUMN IF NOT EXISTS prompt TEXT", - "ALTER TABLE person ADD COLUMN IF NOT EXISTS pose TEXT", - "ALTER TABLE person ADD COLUMN IF NOT EXISTS sort_order INTEGER", - "ALTER TABLE person ADD COLUMN IF NOT EXISTS group_name TEXT", - "ALTER TABLE person ADD COLUMN IF NOT EXISTS hidden BOOLEAN DEFAULT FALSE", - "ALTER TABLE person ADD COLUMN IF NOT EXISTS has_background BOOLEAN DEFAULT TRUE", - "ALTER TABLE person ADD COLUMN IF NOT EXISTS source_refs TEXT", - "ALTER TABLE person ADD COLUMN IF NOT EXISTS has_clothing BOOLEAN DEFAULT NULL", - "ALTER TABLE person ADD COLUMN IF NOT EXISTS content_type TEXT DEFAULT 'image'", - "ALTER TABLE person ADD COLUMN IF NOT EXISTS faceswap_source_video TEXT", - "ALTER TABLE person ADD COLUMN IF NOT EXISTS archived BOOLEAN DEFAULT FALSE", - "ALTER TABLE person ADD COLUMN IF NOT EXISTS face_embedding vector(512)", - ]: - cur.execute(sql) - conn.commit() - finally: - cur.close() - _put_db_connection(conn) - -def upsert_person(filename, filepath=None, name=None, group_id=None, tags=None, - embedding=None, clip_description=None, prompt=None, pose=None, - sort_order=None, group_name=None, hidden=None, - has_background=None, source_refs=None, has_clothing=None, - content_type=None, faceswap_source_video=None, archived=None, - face_embedding=None): - conn = get_db_connection() - cur = conn.cursor() - face_embedding_str = ("[" + ",".join(map(str, face_embedding)) + "]") if face_embedding is not None else None - try: - cur.execute(""" - INSERT INTO person (filename, filepath, name, group_id, tags, embedding, - clip_description, prompt, pose, sort_order, group_name, hidden, - has_background, source_refs, has_clothing, - content_type, faceswap_source_video, archived, face_embedding) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - ON CONFLICT (filename) DO UPDATE - SET filepath = COALESCE(EXCLUDED.filepath, person.filepath), - name = COALESCE(EXCLUDED.name, person.name), - group_id = COALESCE(EXCLUDED.group_id, person.group_id), - tags = COALESCE(EXCLUDED.tags, person.tags), - embedding = COALESCE(EXCLUDED.embedding, person.embedding), - clip_description = COALESCE(EXCLUDED.clip_description, person.clip_description), - prompt = COALESCE(EXCLUDED.prompt, person.prompt), - pose = COALESCE(EXCLUDED.pose, person.pose), - sort_order = COALESCE(EXCLUDED.sort_order, person.sort_order), - group_name = COALESCE(EXCLUDED.group_name, person.group_name), - hidden = COALESCE(EXCLUDED.hidden, person.hidden), - has_background = COALESCE(EXCLUDED.has_background, person.has_background), - source_refs = COALESCE(EXCLUDED.source_refs, person.source_refs), - has_clothing = COALESCE(EXCLUDED.has_clothing, person.has_clothing), - content_type = COALESCE(EXCLUDED.content_type, person.content_type), - faceswap_source_video = COALESCE(EXCLUDED.faceswap_source_video, person.faceswap_source_video), - archived = COALESCE(EXCLUDED.archived, person.archived), - face_embedding = COALESCE(EXCLUDED.face_embedding, person.face_embedding); - """, (filename, filepath, name, group_id, - json.dumps(tags) if tags else None, - embedding, clip_description, prompt, pose, sort_order, group_name, hidden, - has_background, source_refs, has_clothing, - content_type, faceswap_source_video, archived, face_embedding_str)) - conn.commit() - finally: - cur.close() - _put_db_connection(conn) - -def set_archived(filename, archived: bool): - conn = get_db_connection() - cur = conn.cursor() - try: - cur.execute("UPDATE person SET archived = %s WHERE filename = %s", (archived, filename)) - conn.commit() - finally: - cur.close() - _put_db_connection(conn) - -def set_hidden(filename, hidden: bool): - conn = get_db_connection() - cur = conn.cursor() - try: - cur.execute("UPDATE person SET hidden = %s WHERE filename = %s", (hidden, filename)) - conn.commit() - finally: - cur.close() - _put_db_connection(conn) - -def get_person(filename): - conn = get_db_connection() - cur = conn.cursor() - try: - cur.execute(""" - SELECT name, group_id, tags, embedding, clip_description, filepath, - prompt, pose, sort_order, group_name, hidden, has_background, source_refs, - has_clothing - FROM person WHERE filename = %s - """, (filename,)) - return cur.fetchone() - finally: - cur.close() - _put_db_connection(conn) - -def list_persons(include_archived=False): - conn = get_db_connection() - cur = conn.cursor() - try: - where = "" if include_archived else "WHERE archived IS NOT TRUE" - cur.execute(f""" - SELECT filename, name, group_id, clip_description, - prompt, pose, sort_order, group_name, hidden, has_background, source_refs, - has_clothing, content_type, faceswap_source_video, archived - FROM person - {where} - """) - return cur.fetchall() - finally: - cur.close() - _put_db_connection(conn) - -def search_similar(embedding, limit=10): - conn = get_db_connection() - cur = conn.cursor() - try: - embedding_str = "[" + ",".join(map(str, embedding)) + "]" - cur.execute(""" - SELECT filename, name, group_id, clip_description, embedding <=> %s AS distance - FROM person - WHERE embedding IS NOT NULL - ORDER BY distance ASC - LIMIT %s; - """, (embedding_str, limit)) - return cur.fetchall() - finally: - cur.close() - _put_db_connection(conn) - -def delete_person(filename): - conn = get_db_connection() - cur = conn.cursor() - try: - cur.execute("DELETE FROM person WHERE filename = %s", (filename,)) - conn.commit() - finally: - cur.close() - _put_db_connection(conn) - -def delete_group(group_id): - conn = get_db_connection() - cur = conn.cursor() - try: - cur.execute("DELETE FROM person WHERE group_id = %s", (group_id,)) - conn.commit() - finally: - cur.close() - _put_db_connection(conn) - -def get_group_files(group_id): - conn = get_db_connection() - cur = conn.cursor() - try: - cur.execute("SELECT filename, filepath FROM person WHERE group_id = %s", (group_id,)) - return cur.fetchall() - finally: - cur.close() - _put_db_connection(conn) - -def set_group_order(group_id, ordered_filenames): - """Assign sort_order 0,1,2,... to filenames in the given order.""" - conn = get_db_connection() - cur = conn.cursor() - try: - for idx, fname in enumerate(ordered_filenames): - cur.execute( - "UPDATE person SET sort_order = %s WHERE filename = %s", - (idx, fname) - ) - conn.commit() - finally: - cur.close() - _put_db_connection(conn) - -def get_group_order(group_id): - """Return [(filename, sort_order), ...] sorted by sort_order NULLS LAST.""" - conn = get_db_connection() - cur = conn.cursor() - try: - cur.execute(""" - SELECT filename, sort_order - FROM person - WHERE group_id = %s - ORDER BY sort_order NULLS LAST, filename - """, (group_id,)) - return cur.fetchall() - finally: - cur.close() - _put_db_connection(conn) - -def set_group_name(group_id, name): - """Set group_name for every file in the group.""" - conn = get_db_connection() - cur = conn.cursor() - try: - cur.execute("UPDATE person SET group_name = %s WHERE group_id = %s", (name, group_id)) - conn.commit() - finally: - cur.close() - _put_db_connection(conn) - -def get_next_sort_order(group_id): - """Return max(sort_order)+1 for the group, or 1 if no sorted members exist.""" - conn = get_db_connection() - cur = conn.cursor() - try: - cur.execute( - "SELECT COALESCE(MAX(sort_order), 0) + 1 FROM person WHERE group_id = %s", - (group_id,) - ) - return cur.fetchone()[0] - finally: - cur.close() - _put_db_connection(conn) - - -def get_all_group_names(): - """Return {group_id: group_name} for groups that have a name set.""" - conn = get_db_connection() - cur = conn.cursor() - try: - cur.execute(""" - SELECT DISTINCT ON (group_id) group_id, group_name - FROM person - WHERE group_id IS NOT NULL AND group_name IS NOT NULL - """) - return {row[0]: row[1] for row in cur.fetchall()} - finally: - cur.close() - _put_db_connection(conn) - - -def get_face_embedding(filename): - """Return the face_embedding as a list of floats for a filename, or None.""" - conn = get_db_connection() - cur = conn.cursor() - try: - cur.execute("SELECT face_embedding FROM person WHERE filename = %s", (filename,)) - row = cur.fetchone() - if row and row[0] is not None: - val = row[0] - # psycopg2 without a pgvector adapter returns vectors as plain strings "[f,f,...]" - if isinstance(val, str): - return [float(x) for x in val.strip("[]").split(",")] - return list(val) - return None - finally: - cur.close() - _put_db_connection(conn) - - -def search_similar_face(embedding, limit=12, exclude_group_id=None): - """Cosine search on face_embedding (stored only for *_face.png rows). - - Returns [(filename, group_id, distance), ...] sorted ascending by distance. - Rows belonging to exclude_group_id are skipped so a group doesn't match itself. - """ - conn = get_db_connection() - cur = conn.cursor() - try: - embedding_str = "[" + ",".join(map(str, embedding)) + "]" - if exclude_group_id: - cur.execute(""" - SELECT filename, group_id, face_embedding <=> %s AS distance - FROM person - WHERE face_embedding IS NOT NULL - AND (group_id IS NULL OR group_id != %s) - ORDER BY distance ASC - LIMIT %s - """, (embedding_str, exclude_group_id, limit)) - else: - cur.execute(""" - SELECT filename, group_id, face_embedding <=> %s AS distance - FROM person - WHERE face_embedding IS NOT NULL - ORDER BY distance ASC - LIMIT %s - """, (embedding_str, limit)) - return cur.fetchall() - finally: - cur.close() - _put_db_connection(conn) diff --git a/tour-comfy/deploy.sh b/tour-comfy/deploy.sh deleted file mode 100755 index 0db3fbc..0000000 --- a/tour-comfy/deploy.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/bin/bash -# 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 - -if [[ $EUID -ne 0 ]]; then - echo "This script must be run as root (use sudo)" - exit 1 -fi - -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" # .../comfyui/api -BASE="$( cd "$SCRIPT_DIR/.." && pwd )" # .../comfyui -TEMPLATES="$SCRIPT_DIR/systemd" - -# The service should run as the owner of the project, not root. -SVC_USER="${SUDO_USER:-$(stat -c '%U' "$SCRIPT_DIR")}" -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..." -systemctl daemon-reload - -echo "Enabling + (re)starting services..." -systemctl enable comfyui-backend.service comfyui-api.service -systemctl restart comfyui-backend.service comfyui-api.service - -echo "Deployment complete." -echo "Check status with: systemctl status comfyui-backend comfyui-api" diff --git a/tour-comfy/edit_api.py b/tour-comfy/edit_api.py deleted file mode 100644 index b446ead..0000000 --- a/tour-comfy/edit_api.py +++ /dev/null @@ -1,4432 +0,0 @@ -""" -edit_api.py — headless throughput API for Qwen-Image-Edit Rapid-AIO (v23 Q8 GGUF) -running on top of a local ComfyUI server. - -Flow per request: image + prompt -> upload to ComfyUI -> inject into the -workflow graph -> queue -> poll until done -> return the edited PNG. - -Run ComfyUI first (run_comfyui.sh), then this service (start_api.sh). -""" - -import io -import os -import json -import time -import uuid -import random -import copy -import threading -import csv - -try: - from . import database - from . import embeddings - from . import naming -except ImportError: - import database - import embeddings - import naming - -import requests -from PIL import Image -from fastapi import FastAPI, UploadFile, File, Form, HTTPException, BackgroundTasks -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import Response -from fastapi.staticfiles import StaticFiles -from pydantic import BaseModel -import shutil -import re -from typing import Union, Any - -# --- config ----------------------------------------------------------------- -_HERE = os.path.dirname(os.path.abspath(__file__)) -CONFIG_PATH = os.path.join(_HERE, "config.json") -WD_MODEL = os.environ.get("WD_MODEL", "SmilingWolf/wd-vit-tagger-v3") -COMFY = os.environ.get("COMFY_URL", "http://127.0.0.1:8188").rstrip("/") -WORKFLOW_PATH = os.environ.get( - "WORKFLOW_PATH", - os.path.join(os.path.dirname(os.path.abspath(__file__)), "workflow_qwen_edit.json"), -) -# Default target pixel area for the output latent. The MI50 is not fast, so we -# cap at ~1MP by default; raise via MAX_AREA env if you want bigger output. -MAX_AREA = int(os.environ.get("MAX_AREA", str(1024 * 1024))) -GEN_TIMEOUT = int(os.environ.get("GEN_TIMEOUT", "600")) # seconds per request - -# Node ids in workflow_qwen_edit.json (kept stable on purpose). -NODE_LOADIMAGE = "4" -NODE_POSITIVE = "5" -NODE_NEGATIVE = "6" -NODE_LATENT = "7" -NODE_KSAMPLER = "8" -NODE_SAVE = "10" - -MAX_SEED = 2**32 - 1 - -VIDEO_EXTENSIONS = ('.mp4', '.mov', '.avi', '.webm', '.mkv') - -# Poses where the source image should be rotated 180° before pipeline for better results -ROTATE_180_POSES = {"the dragon", "dragon", "the draak", "draak"} - -# WD tagger tags that indicate the subject is wearing clothes -CLOTHING_TAGS = { - "dress", "skirt", "shirt", "top", "pants", "jeans", "jacket", "coat", - "swimsuit", "bikini", "shorts", "uniform", "hoodie", "sweater", "blouse", - "leggings", "stockings", "tights", "lingerie", "miniskirt", "pleated_skirt", - "school_uniform", "maid_dress", "bodysuit", "sailor_uniform", "leotard", - "corset", "kimono", "yukata", "cheongsam", "t-shirt", "tank_top", "crop_top", - "tube_top", "halter_top", "negligee", "nightgown", "pajamas", "trench_coat", - "vest", "bra", "underwear", "panties", "thong", "g-string", "bikini_top", - "bikini_bottom", "one-piece_swimsuit", "sports_bra", "gym_clothes", -} - -UNDRESS_PROMPT = "completely nude, bare skin, no clothing, naked body, natural skin texture" - -with open(WORKFLOW_PATH, "r", encoding="utf-8") as f: - BASE_WORKFLOW = json.load(f) - -app = FastAPI(title="Qwen-Image-Edit Rapid-AIO API", version="1.0") -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_methods=["GET", "POST", "DELETE"], - allow_headers=["*"], -) - -# --- Activity tracking for idle-background turntable generation --------------- -_last_request_time: float = time.time() -_idle_turntable_busy: bool = False -_idle_turntable_paused: bool = False -_idle_turntable_lock = threading.Lock() - -IDLE_THRESHOLD = 45 # seconds of inactivity before background gen starts -IDLE_CHECK_INTERVAL = 12 # polling interval (seconds) - - -@app.middleware("http") -async def _track_activity(request, call_next): - global _last_request_time - _last_request_time = time.time() - return await call_next(request) - -def _sync_car_html(): - src = os.path.join(os.path.dirname(os.path.abspath(__file__)), "car.html") - if not os.path.exists(src): - return - try: - dest = os.path.join(_load_output_dir(), "car.html") - shutil.copy2(src, dest) - print(f"[car.html] synced → {dest}") - except Exception as e: - print(f"[car.html] sync warning: {e}") - - -def _watch_car_html(): - src = os.path.join(os.path.dirname(os.path.abspath(__file__)), "car.html") - last_mtime = os.path.getmtime(src) if os.path.exists(src) else 0 - while True: - time.sleep(1) - try: - mtime = os.path.getmtime(src) - if mtime != last_mtime: - last_mtime = mtime - dest = os.path.join(_load_output_dir(), "car.html") - shutil.copy2(src, dest) - print(f"[car.html] change detected → synced to {dest}") - except Exception: - pass - - -def _load_wireframe_dir() -> str: - with open(CONFIG_PATH, "r") as f: - conf = json.load(f) - return conf.get("wireframe_dir", "/mnt/zim/tour-comfy/wireframe") - - -def _load_faceswap_model_path() -> str: - with open(CONFIG_PATH, "r") as f: - conf = json.load(f) - return os.path.expanduser(conf.get("faceswap_model", "~/.insightface/models/inswapper_128.onnx")) - - -def _idle_turntable_daemon(): - """ - Background daemon: when the API has been idle > IDLE_THRESHOLD seconds, - generate the next missing turntable view for the next group. Yields after - each view so activity check can stop it promptly. - """ - global _idle_turntable_busy - import sys as _sys - if _HERE not in _sys.path: - _sys.path.insert(0, _HERE) - - time.sleep(60) # wait for full startup before touching ComfyUI - - while True: - time.sleep(IDLE_CHECK_INTERVAL) - - if _idle_turntable_paused: - continue - if time.time() - _last_request_time < IDLE_THRESHOLD: - continue - - try: - output_dir = _load_output_dir() - persons = database.list_persons() - except Exception as e: - print(f"[turntable-bg] db/config error: {e}") - continue - - # Build {group_id: (preferred_filename, best_sort_order)} - groups: dict = {} - for row in persons: - fname, group_id, sort_order = row[0], row[2], row[6] - if not group_id: - continue - if group_id not in groups: - groups[group_id] = (fname, sort_order) - else: - cur_sort = groups[group_id][1] - if sort_order is not None and (cur_sort is None or sort_order < cur_sort): - groups[group_id] = (fname, sort_order) - - import turntable_cache as tc - - generated_one = False - for group_id, (preferred_fname, _) in groups.items(): - src_path = os.path.join(output_dir, preferred_fname) - if not os.path.exists(src_path): - continue - - state = tc.load_state(output_dir, group_id) - - if state is None: - state = tc.init_state(output_dir, group_id, src_path, preferred_fname) - elif state.get("completed"): - continue - elif state.get("preferred_filename") != preferred_fname: - # Preferred image changed — restart turntable for this group - print(f"[turntable-bg] {group_id}: preferred changed, resetting") - state = tc.init_state(output_dir, group_id, src_path, preferred_fname) - - deg = tc.next_missing_angle(state) - if deg is None: - continue # all angles done but no video yet — build it below - - # Re-check idle right before the expensive generation - if time.time() - _last_request_time < IDLE_THRESHOLD or _idle_turntable_paused: - break - - print(f"[turntable-bg] {group_id}: rendering {deg:.0f}° " - f"({len(state['views'])}/{state['n_views']})…") - - with _idle_turntable_lock: - _idle_turntable_busy = True - - try: - from orbit_qwen import yaw_prompt, _autocrop_alpha - import io as _io - from PIL import Image as _Image - - base_pil = _Image.open(src_path).convert("RGB") - prompt = yaw_prompt(deg) - png = _run_pipeline( - base_pil, prompt, state["seed"], MAX_AREA, steps=state["steps"] - ) - view_pil = _Image.open(_io.BytesIO(png)).convert("RGBA") - view_pil = _autocrop_alpha(view_pil) - - views_dir = os.path.join(tc.cache_dir(output_dir, group_id), "views") - os.makedirs(views_dir, exist_ok=True) - angle_idx = state["angles"].index(deg) - vpath = os.path.join(views_dir, f"view_{angle_idx:03d}_{int(deg):03d}deg.png") - view_pil.save(vpath) - - tc.mark_view_done(output_dir, group_id, state, deg, vpath) - n_done = len(state["views"]) - print(f"[turntable-bg] {group_id}: {deg:.0f}° saved " - f"({n_done}/{state['n_views']})") - generated_one = True - - if n_done >= state["n_views"]: - _finalize_turntable(output_dir, group_id, state) - - except Exception as e: - import traceback - print(f"[turntable-bg] {group_id}: error at {deg:.0f}°: {e}") - print(traceback.format_exc()) - finally: - with _idle_turntable_lock: - _idle_turntable_busy = False - - break # one view per cycle; re-check idle on next loop - - -def _finalize_turntable(output_dir: str, group_id: str, state: dict): - """Build the final MP4 from all cached views and mark state completed.""" - import turntable_cache as tc - try: - from orbit_qwen import interpolate_views, build_video - from PIL import Image as _Image - - angles = state["angles"] - views_data = [] - for deg in angles: - dk = tc.deg_key(deg) - if dk not in state["views"]: - print(f"[turntable-bg] {group_id}: missing view {deg}° — skip finalize") - return - p = state["views"][dk] - if not os.path.exists(p): - print(f"[turntable-bg] {group_id}: view file missing: {p}") - return - views_data.append({"deg": deg, "pil": _Image.open(p).convert("RGBA")}) - - frames = interpolate_views(views_data, factor=1, loop=True, smooth=False) - video_path = os.path.join(tc.cache_dir(output_dir, group_id), "turntable.mp4") - build_video(frames, video_path, fps=12) - tc.mark_completed(output_dir, group_id, state, video_path) - print(f"[turntable-bg] {group_id}: complete → {video_path}") - _write_turntable_static() - except Exception as e: - import traceback - print(f"[turntable-bg] {group_id}: finalize error: {e}\n{traceback.format_exc()}") - - -@app.on_event("startup") -def on_startup(): - try: - database.migrate_schema() - except Exception as e: - print(f"DB migration warning: {e}") - _sync_car_html() - threading.Thread(target=_watch_car_html, daemon=True).start() - threading.Thread(target=_idle_turntable_daemon, daemon=True).start() - # Mount wireframe static dir for browser video preview - try: - wf_dir = _load_wireframe_dir() - if os.path.isdir(wf_dir): - app.mount("/wireframe", StaticFiles(directory=wf_dir), name="wireframe") - print(f"[wireframe] mounted {wf_dir} → /wireframe") - except Exception as e: - print(f"[wireframe] mount warning: {e}") - # Mount output dir so images can be served via HTTP (/output/filename.png) - try: - out_dir = _load_output_dir() - if os.path.isdir(out_dir): - app.mount("/output", StaticFiles(directory=out_dir), name="output") - print(f"[output] mounted {out_dir} → /output") - except Exception as e: - print(f"[output] mount warning: {e}") - # Write initial static data files (synchronous — ensures files exist before first request) - _write_all_static() - - -# --- helpers ---------------------------------------------------------------- -def _round16(x: int) -> int: - return max(16, int(round(x / 16.0)) * 16) - - -def _target_size(w: int, h: int, max_area: int) -> tuple[int, int]: - """Scale (w, h) to ~max_area preserving aspect, rounded to /16.""" - scale = (max_area / float(w * h)) ** 0.5 - 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. Scale (up or down) to fit area while preserving aspect. - 2. Ensure dimensions are rounded to 16. - """ - w, h = pil.width, pil.height - 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: - """Upload an image to ComfyUI's input dir; return the stored name.""" - r = requests.post( - f"{COMFY}/upload/image", - files={"image": (filename, img_bytes, "image/png")}, - data={"overwrite": "true", "type": "input"}, - timeout=60, - ) - r.raise_for_status() - j = r.json() - name = j["name"] - sub = j.get("subfolder", "") - return f"{sub}/{name}" if sub else name - - -def _comfy_queue(graph: dict, client_id: str) -> str: - r = requests.post( - f"{COMFY}/prompt", - json={"prompt": graph, "client_id": client_id}, - timeout=60, - ) - if r.status_code != 200: - raise HTTPException(502, f"ComfyUI rejected workflow: {r.text}") - return r.json()["prompt_id"] - - -def _comfy_wait(prompt_id: str, deadline: float) -> dict: - """Poll /history until the prompt finishes; return its outputs dict.""" - while time.time() < deadline: - r = requests.get(f"{COMFY}/history/{prompt_id}", timeout=30) - if r.status_code == 200: - hist = r.json() - if prompt_id in hist: - entry = hist[prompt_id] - status = entry.get("status", {}) - if status.get("status_str") == "error": - raise HTTPException(500, f"ComfyUI execution error: {json.dumps(status)}") - outputs = entry.get("outputs", {}) - if outputs: - return outputs - time.sleep(0.5) - raise HTTPException(504, f"Generation timed out after {GEN_TIMEOUT}s") - - -def _comfy_fetch_image(outputs: dict) -> bytes: - node_out = outputs.get(NODE_SAVE) or next( - (v for v in outputs.values() if "images" in v), None - ) - if not node_out or not node_out.get("images"): - raise HTTPException(500, "No output image produced") - img = node_out["images"][0] - r = requests.get( - f"{COMFY}/view", - params={ - "filename": img["filename"], - "subfolder": img.get("subfolder", ""), - "type": img.get("type", "output"), - }, - timeout=60, - ) - r.raise_for_status() - return r.content - - -# --- WD tagger (lazy) ------------------------------------------------------- - -_tagger = None # (model, transform, labels) once loaded -_tagger_lock = threading.Lock() - - -def _load_tagger(): - global _tagger - if _tagger is not None: - return _tagger - with _tagger_lock: - if _tagger is not None: - return _tagger - import torch - import timm - from timm.data import create_transform, resolve_data_config - import huggingface_hub - - model = timm.create_model(f"hf_hub:{WD_MODEL}", pretrained=True).eval() - if torch.cuda.is_available(): - model = model.cuda() - - cfg = resolve_data_config(model.pretrained_cfg, model=model) - transform = create_transform(**cfg) - - lpath = huggingface_hub.hf_hub_download(WD_MODEL, "selected_tags.csv") - with open(lpath, newline="") as f: - rows = list(csv.DictReader(f)) - # category 0=general 4=character 9=rating - labels = [(r["name"], int(r.get("category", 9))) for r in rows] - - _tagger = (model, transform, labels) - return _tagger - - -def _run_tagger(pil_img: Image.Image, threshold: float = 0.35): - import torch - model, transform, labels = _load_tagger() - tensor = transform(pil_img.convert("RGB")).unsqueeze(0) - if torch.cuda.is_available(): - tensor = tensor.cuda() - with torch.no_grad(): - scores = torch.sigmoid(model(tensor))[0].cpu().tolist() - tags = [ - {"tag": name, "score": round(score, 3), "cat": cat} - for (name, cat), score in zip(labels, scores) - if score >= threshold - ] - tags.sort(key=lambda x: -x["score"]) - return tags - - -def _tags_to_name(tags: list, max_tags: int = 8) -> str: - content = [t["tag"] for t in tags if t["cat"] in (0, 4)][:max_tags] - return " ".join(content).replace("_", " ") - - -def _apply_transparency(png_bytes: bytes) -> bytes: - """Use rembg to remove background and return PNG bytes with Alpha channel.""" - try: - from rembg import remove - import io - from PIL import Image - img = Image.open(io.BytesIO(png_bytes)) - # rembg works best on RGB - if img.mode != "RGB": - img = img.convert("RGB") - out = remove(img) - buf = io.BytesIO() - out.save(buf, format="PNG") - return buf.getvalue() - except Exception as e: - print(f"Error in transparency post-processing: {e}") - return png_bytes - - -# --- faceswapper (insightface + inswapper_128) -------------------------------- -# Setup: pip install insightface onnxruntime-gpu opencv-python-headless -# Download: place inswapper_128.onnx at ~/.insightface/models/inswapper_128.onnx -# Source: https://huggingface.co/deepinsight/inswapper - -_faceswapper = None -_faceswapper_lock = threading.Lock() - -# Dedicated single-worker pool for face-crop extraction. Running it here -# instead of via FastAPI BackgroundTasks keeps the heavy insightface inference -# (and its one-time model load) off the shared request threadpool, so quick -# endpoints like /order stay responsive right after a "set preferred" click. -# A single worker also serializes face jobs so a burst can't thrash the GPU. -from concurrent.futures import ThreadPoolExecutor as _ThreadPoolExecutor -_face_executor = _ThreadPoolExecutor(max_workers=1, thread_name_prefix="face") - -_gfpgan = None -_gfpgan_lock = threading.Lock() - - -def _load_faceswapper(): - global _faceswapper - if _faceswapper is not None: - return _faceswapper - with _faceswapper_lock: - if _faceswapper is not None: - return _faceswapper - try: - import insightface - from insightface.app import FaceAnalysis - except ImportError: - raise RuntimeError("insightface not installed. Run: pip install insightface onnxruntime-gpu") - - providers = ['CUDAExecutionProvider', 'CPUExecutionProvider'] - app = FaceAnalysis(name='buffalo_l', providers=providers) - app.prepare(ctx_id=0, det_size=(640, 640)) - - model_path = _load_faceswap_model_path() - if not os.path.exists(model_path): - # Try HuggingFace download as fallback - try: - import huggingface_hub - model_path = huggingface_hub.hf_hub_download( - 'deepinsight/inswapper', 'inswapper_128.onnx', - local_dir=os.path.dirname(model_path), - ) - print(f"[faceswap] Downloaded inswapper_128.onnx to {model_path}") - except Exception as de: - raise RuntimeError( - f"inswapper_128.onnx not found at {model_path}. " - f"Download from https://huggingface.co/deepinsight/inswapper and place it there. " - f"Download error: {de}" - ) - - swapper = insightface.model_zoo.get_model(model_path, providers=providers) - _faceswapper = (app, swapper) - print(f"[faceswap] loaded insightface buffalo_l + inswapper_128 from {model_path}") - return _faceswapper - - -def _load_gfpgan(): - """Lazy-load GFPGAN face restorer. Returns restorer or False if unavailable.""" - global _gfpgan - if _gfpgan is not None: - return _gfpgan - with _gfpgan_lock: - if _gfpgan is not None: - return _gfpgan - try: - from gfpgan import GFPGANer - # Main GFPGAN model - model_path = os.path.expanduser('~/.gfpgan/weights/GFPGANv1.4.pth') - os.makedirs(os.path.dirname(model_path), exist_ok=True) - if not os.path.exists(model_path): - import urllib.request - print('[gfpgan] Downloading GFPGANv1.4.pth (~333 MB)...') - tmp = model_path + '.tmp' - urllib.request.urlretrieve( - 'https://github.com/TencentARC/GFPGAN/releases/download/v1.3.4/GFPGANv1.4.pth', - tmp - ) - os.rename(tmp, model_path) - # GFPGANer hardcodes facexlib download path to CWD/gfpgan/weights/ - # → change CWD to ~ so models land at ~/gfpgan/weights/ (stable across runs) - home = os.path.expanduser('~') - os.makedirs(os.path.join(home, 'gfpgan', 'weights'), exist_ok=True) - _orig_cwd = os.getcwd() - os.chdir(home) - try: - restorer = GFPGANer(model_path=model_path, upscale=1, arch='clean', - channel_multiplier=2, bg_upsampler=None) - finally: - os.chdir(_orig_cwd) - _gfpgan = restorer - print('[gfpgan] GFPGANv1.4 loaded') - except Exception as e: - print(f'[gfpgan] not available: {e}') - _gfpgan = False - return _gfpgan - - -def _make_video_poster(video_path: str) -> str | None: - """Extract a poster JPG (sibling `.jpg`) so the gallery can show a - thumbnail for a video via a plain (file:// can't render