aa
This commit is contained in:
@@ -32,6 +32,7 @@ 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
|
||||
@@ -59,6 +60,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"}
|
||||
|
||||
@@ -115,6 +118,18 @@ def _watch_car_html():
|
||||
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"))
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def on_startup():
|
||||
try:
|
||||
@@ -123,6 +138,14 @@ def on_startup():
|
||||
print(f"DB migration warning: {e}")
|
||||
_sync_car_html()
|
||||
threading.Thread(target=_watch_car_html, 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}")
|
||||
|
||||
|
||||
# --- helpers ----------------------------------------------------------------
|
||||
@@ -311,6 +334,379 @@ def _apply_transparency(png_bytes: bytes) -> bytes:
|
||||
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()
|
||||
|
||||
_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 `<stem>.jpg`) so the gallery can show a
|
||||
thumbnail for a video via a plain <img> (file:// can't render <video> as a
|
||||
thumb). Returns the poster path on success, else None."""
|
||||
import subprocess
|
||||
poster_path = os.path.splitext(video_path)[0] + '.jpg'
|
||||
try:
|
||||
r = subprocess.run([
|
||||
'ffmpeg', '-y', '-ss', '1', '-i', video_path,
|
||||
'-frames:v', '1', '-q:v', '3', poster_path,
|
||||
], capture_output=True, timeout=120)
|
||||
if r.returncode == 0 and os.path.exists(poster_path):
|
||||
return poster_path
|
||||
# -ss 1 can overshoot very short clips; retry from the first frame
|
||||
r = subprocess.run([
|
||||
'ffmpeg', '-y', '-i', video_path,
|
||||
'-frames:v', '1', '-q:v', '3', poster_path,
|
||||
], capture_output=True, timeout=120)
|
||||
if r.returncode == 0 and os.path.exists(poster_path):
|
||||
return poster_path
|
||||
except Exception as pe:
|
||||
print(f'[poster] failed for {video_path}: {pe}')
|
||||
return None
|
||||
|
||||
|
||||
def _faceswap_worker(job_id: str, model_filename: str, video_name: str, enhance: bool = True):
|
||||
"""Frame-by-frame face swap: model face → every face in template video."""
|
||||
output_dir = _load_output_dir()
|
||||
wireframe_dir = _load_wireframe_dir()
|
||||
|
||||
try:
|
||||
import cv2
|
||||
import numpy as np
|
||||
app, swapper = _load_faceswapper()
|
||||
except Exception as e:
|
||||
jobs[job_id]["status"] = "error"
|
||||
jobs[job_id]["error"] = str(e)
|
||||
return
|
||||
|
||||
gfpgan_restorer = None
|
||||
if enhance:
|
||||
try:
|
||||
r = _load_gfpgan()
|
||||
if r is not False:
|
||||
gfpgan_restorer = r
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
# 1. Load source (model) face
|
||||
src_path = os.path.join(output_dir, model_filename)
|
||||
src_bgr = cv2.imread(src_path)
|
||||
if src_bgr is None:
|
||||
raise ValueError(f"Cannot read model image: {model_filename}")
|
||||
src_faces = app.get(src_bgr)
|
||||
if not src_faces:
|
||||
raise ValueError(f"No face detected in: {model_filename}")
|
||||
# Use the largest face as source
|
||||
src_face = max(src_faces, key=lambda f: (f.bbox[2] - f.bbox[0]) * (f.bbox[3] - f.bbox[1]))
|
||||
|
||||
# 2. Open template video
|
||||
video_path = os.path.join(wireframe_dir, video_name)
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
if not cap.isOpened():
|
||||
raise ValueError(f"Cannot open video: {video_name}")
|
||||
|
||||
fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
|
||||
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
jobs[job_id]["total"] = max(total, 1)
|
||||
|
||||
# 3. Write frame-swapped temp video
|
||||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||
vid_stem = os.path.splitext(video_name)[0]
|
||||
base_name = naming.get_base_name(model_filename)
|
||||
tmp_name = f"{ts}_fs_tmp_{vid_stem}_{base_name}.mp4"
|
||||
out_name = f"{ts}_fs_{vid_stem}_{base_name}.mp4"
|
||||
tmp_path = os.path.join(output_dir, tmp_name)
|
||||
out_path = os.path.join(output_dir, out_name)
|
||||
|
||||
fourcc = cv2.VideoWriter_fourcc(*'mp4v')
|
||||
writer = cv2.VideoWriter(tmp_path, fourcc, fps, (width, height))
|
||||
|
||||
frame_idx = 0
|
||||
while True:
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
break
|
||||
tgt_faces = app.get(frame)
|
||||
result = frame
|
||||
if tgt_faces:
|
||||
result = frame.copy()
|
||||
for face in tgt_faces:
|
||||
try:
|
||||
result = swapper.get(result, face, src_face, paste_back=True)
|
||||
except Exception:
|
||||
pass
|
||||
if gfpgan_restorer is not None:
|
||||
try:
|
||||
_, _, result = gfpgan_restorer.enhance(
|
||||
result, has_aligned=False, only_center_face=False, paste_back=True
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
writer.write(result)
|
||||
frame_idx += 1
|
||||
jobs[job_id]["done"] = frame_idx
|
||||
|
||||
cap.release()
|
||||
writer.release()
|
||||
|
||||
# 4. Remux with original audio via ffmpeg
|
||||
try:
|
||||
import subprocess
|
||||
r = subprocess.run([
|
||||
'ffmpeg', '-y',
|
||||
'-i', tmp_path,
|
||||
'-i', video_path,
|
||||
'-map', '0:v:0', '-map', '1:a?',
|
||||
'-c:v', 'libx264', '-preset', 'fast', '-crf', '18',
|
||||
'-c:a', 'aac', '-movflags', '+faststart',
|
||||
out_path,
|
||||
], capture_output=True, timeout=600)
|
||||
if r.returncode == 0:
|
||||
os.remove(tmp_path)
|
||||
else:
|
||||
os.rename(tmp_path, out_path)
|
||||
print(f"[faceswap] ffmpeg failed ({r.returncode}), using raw mp4v output")
|
||||
except Exception as fe:
|
||||
os.rename(tmp_path, out_path)
|
||||
print(f"[faceswap] ffmpeg error: {fe}")
|
||||
|
||||
# 5. Snapshot poster + register output in DB under same group as model
|
||||
_make_video_poster(out_path)
|
||||
person = database.get_person(model_filename)
|
||||
group_id = (person[1] if person and person[1] else naming.get_base_name(model_filename))
|
||||
database.upsert_person(
|
||||
out_name,
|
||||
filepath=out_path,
|
||||
group_id=group_id,
|
||||
content_type='video',
|
||||
faceswap_source_video=video_name,
|
||||
source_refs=json.dumps([model_filename]),
|
||||
)
|
||||
|
||||
jobs[job_id]["status"] = "done"
|
||||
jobs[job_id]["output"] = out_name
|
||||
|
||||
except Exception as e:
|
||||
print(f"[faceswap] error: {e}")
|
||||
jobs[job_id]["status"] = "error"
|
||||
jobs[job_id]["error"] = str(e)
|
||||
|
||||
|
||||
def _faceswap_worker_ff(job_id: str, model_filename: str, video_name: str,
|
||||
hair: bool = True, enhance: bool = True):
|
||||
"""High-quality faceswap via FaceFusion CLI (supports hair_swapper + ghost model)."""
|
||||
import subprocess as sp
|
||||
import sys
|
||||
import re as _re
|
||||
|
||||
output_dir = _load_output_dir()
|
||||
wireframe_dir = _load_wireframe_dir()
|
||||
|
||||
with open(CONFIG_PATH, 'r') as f:
|
||||
conf = json.load(f)
|
||||
ff_dir = os.path.expanduser(conf.get('facefusion_dir', '~/facefusion'))
|
||||
ff_venv = os.path.expanduser(conf.get('facefusion_venv', '~/facefusion-venv'))
|
||||
|
||||
ff_script = os.path.join(ff_dir, 'facefusion.py')
|
||||
ff_py = os.path.join(ff_venv, 'bin', 'python')
|
||||
if not os.path.exists(ff_py):
|
||||
ff_py = sys.executable
|
||||
|
||||
if not os.path.exists(ff_script):
|
||||
jobs[job_id]['status'] = 'error'
|
||||
jobs[job_id]['error'] = (
|
||||
f'FaceFusion not found at {ff_dir}. '
|
||||
'Run: bash tour-comfy/install_facefusion.sh'
|
||||
)
|
||||
return
|
||||
|
||||
src_path = os.path.join(output_dir, model_filename)
|
||||
video_path = os.path.join(wireframe_dir, video_name)
|
||||
ts = time.strftime('%Y%m%d_%H%M%S')
|
||||
vid_stem = os.path.splitext(video_name)[0]
|
||||
base_name = naming.get_base_name(model_filename)
|
||||
out_name = f'{ts}_fs_{vid_stem}_{base_name}.mp4'
|
||||
out_path = os.path.join(output_dir, out_name)
|
||||
|
||||
processors = ['face_swapper']
|
||||
# hair_swapper is not available in this FaceFusion version; use face_enhancer for quality
|
||||
if enhance:
|
||||
processors.append('face_enhancer')
|
||||
|
||||
cmd = [
|
||||
ff_py, ff_script, 'headless-run',
|
||||
'--source-paths', src_path,
|
||||
'--target-path', video_path,
|
||||
'--output-path', out_path,
|
||||
'--processors', *processors,
|
||||
'--execution-providers', 'cuda',
|
||||
'--face-swapper-model', 'ghost_3_256',
|
||||
# The default yolo_face detector at score 0.5 misses the extreme-angle /
|
||||
# cropped close-up faces common in these POV template clips, so the swap
|
||||
# silently no-ops. scrfd at a lower score + multi-angle detection reliably
|
||||
# finds them; 'many' selector swaps every detected face per frame.
|
||||
'--face-detector-model', 'scrfd',
|
||||
'--face-detector-score', '0.3',
|
||||
'--face-detector-angles', '0', '90', '270',
|
||||
'--face-selector-mode', 'many',
|
||||
]
|
||||
if enhance:
|
||||
cmd += ['--face-enhancer-model', 'gfpgan_1.4']
|
||||
|
||||
jobs[job_id]['total'] = 100
|
||||
jobs[job_id]['done'] = 0
|
||||
|
||||
# Ensure CUDA libs are on LD_LIBRARY_PATH for the subprocess (inherited from parent,
|
||||
# but also add nvidia package libs as fallback if running outside start_api.sh)
|
||||
import site as _site
|
||||
_sp_pkgs = next((p for p in _site.getsitepackages() if 'site-packages' in p), '')
|
||||
_nv_base = os.path.join(_sp_pkgs, 'nvidia')
|
||||
_extra_libs = ':'.join(
|
||||
os.path.join(_nv_base, pkg, 'lib')
|
||||
for pkg in ('cuda_runtime', 'cublas', 'cudnn', 'curand', 'cufft', 'cusolver', 'cusparse', 'nvjitlink', 'cuda_nvrtc')
|
||||
if os.path.isdir(os.path.join(_nv_base, pkg, 'lib'))
|
||||
)
|
||||
_env = os.environ.copy()
|
||||
if _extra_libs:
|
||||
_env['LD_LIBRARY_PATH'] = _extra_libs + (':' + _env['LD_LIBRARY_PATH'] if _env.get('LD_LIBRARY_PATH') else '')
|
||||
|
||||
try:
|
||||
output_lines = []
|
||||
proc = sp.Popen(
|
||||
cmd, cwd=ff_dir, env=_env,
|
||||
stdout=sp.PIPE, stderr=sp.PIPE,
|
||||
text=True, errors='replace',
|
||||
)
|
||||
# Read stdout for progress, stderr for error info
|
||||
import threading as _thr
|
||||
def _drain_stderr():
|
||||
for ln in proc.stderr:
|
||||
output_lines.append(ln.rstrip())
|
||||
print(f'[facefusion] {ln.rstrip()}')
|
||||
_thr.Thread(target=_drain_stderr, daemon=True).start()
|
||||
for line in proc.stdout:
|
||||
print(f'[facefusion] {line.rstrip()}')
|
||||
m = _re.search(r'(\d+)\s*/\s*(\d+)', line)
|
||||
if m:
|
||||
done, total = int(m.group(1)), int(m.group(2))
|
||||
if total > 0:
|
||||
jobs[job_id]['done'] = done
|
||||
jobs[job_id]['total'] = total
|
||||
proc.wait()
|
||||
|
||||
if proc.returncode != 0:
|
||||
tail = '\n'.join(output_lines[-10:])
|
||||
raise RuntimeError(f'FaceFusion exited with code {proc.returncode}: {tail}')
|
||||
if not os.path.exists(out_path):
|
||||
raise RuntimeError('FaceFusion produced no output file')
|
||||
|
||||
_make_video_poster(out_path)
|
||||
person = database.get_person(model_filename)
|
||||
group_id = (person[1] if person and person[1] else naming.get_base_name(model_filename))
|
||||
database.upsert_person(
|
||||
out_name, filepath=out_path, group_id=group_id,
|
||||
content_type='video', faceswap_source_video=video_name,
|
||||
source_refs=json.dumps([model_filename]),
|
||||
)
|
||||
jobs[job_id]['status'] = 'done'
|
||||
jobs[job_id]['output'] = out_name
|
||||
|
||||
except Exception as e:
|
||||
print(f'[faceswap-ff] error: {e}')
|
||||
jobs[job_id]['status'] = 'error'
|
||||
jobs[job_id]['error'] = str(e)
|
||||
|
||||
|
||||
# --- pipeline helper ---------------------------------------------------------
|
||||
|
||||
def _load_poses():
|
||||
@@ -691,30 +1087,33 @@ def get_batch(job_id: str):
|
||||
@app.get("/images")
|
||||
def list_images():
|
||||
output_dir = _load_output_dir()
|
||||
extensions = ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg')
|
||||
all_extensions = ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg') + VIDEO_EXTENSIONS
|
||||
try:
|
||||
# Try to get from DB first
|
||||
try:
|
||||
persons = database.list_persons()
|
||||
# persons: (filename, name, group_id, clip_description, prompt, pose, sort_order, group_name, hidden, has_background, source_refs, has_clothing)
|
||||
# list_persons cols: filename, name, group_id, clip_description,
|
||||
# prompt, pose, sort_order, group_name, hidden, has_background,
|
||||
# source_refs, has_clothing, content_type, faceswap_source_video
|
||||
db_images = []
|
||||
for p in persons:
|
||||
fpath = os.path.join(output_dir, p[0])
|
||||
if not os.path.exists(fpath):
|
||||
continue # skip orphan DB records whose file no longer exists
|
||||
continue
|
||||
db_images.append({
|
||||
"filename": p[0],
|
||||
"name": p[1],
|
||||
"group_id": p[2],
|
||||
"clip_description":p[3],
|
||||
"prompt": p[4],
|
||||
"pose": p[5],
|
||||
"sort_order": p[6],
|
||||
"group_name": p[7],
|
||||
"hidden": bool(p[8]) if p[8] else False,
|
||||
"has_background": bool(p[9]) if p[9] is not None else True,
|
||||
"source_refs": p[10],
|
||||
"has_clothing": p[11],
|
||||
"filename": p[0],
|
||||
"name": p[1],
|
||||
"group_id": p[2],
|
||||
"clip_description": p[3],
|
||||
"prompt": p[4],
|
||||
"pose": p[5],
|
||||
"sort_order": p[6],
|
||||
"group_name": p[7],
|
||||
"hidden": bool(p[8]) if p[8] else False,
|
||||
"has_background": bool(p[9]) if p[9] is not None else True,
|
||||
"source_refs": p[10],
|
||||
"has_clothing": p[11],
|
||||
"content_type": p[12] or "image",
|
||||
"faceswap_source_video":p[13],
|
||||
})
|
||||
db_images.sort(
|
||||
key=lambda x: os.path.getmtime(os.path.join(output_dir, x["filename"])),
|
||||
@@ -723,14 +1122,181 @@ def list_images():
|
||||
return {"images": db_images}
|
||||
except Exception as db_err:
|
||||
print(f"DB error in list_images: {db_err}")
|
||||
# Fallback to filesystem
|
||||
files = [f for f in os.listdir(output_dir) if f.lower().endswith(extensions)]
|
||||
listing = os.listdir(output_dir)
|
||||
# video poster snapshots share a video sibling's stem — don't list them as items
|
||||
video_stems = {os.path.splitext(f)[0] for f in listing if f.lower().endswith(VIDEO_EXTENSIONS)}
|
||||
files = [
|
||||
f for f in listing
|
||||
if f.lower().endswith(all_extensions)
|
||||
and not (f.lower().endswith('.jpg') and os.path.splitext(f)[0] in video_stems)
|
||||
]
|
||||
files.sort(key=lambda x: os.path.getmtime(os.path.join(output_dir, x)), reverse=True)
|
||||
return {"images": [{"filename": f} for f in files]}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@app.get("/videos")
|
||||
def list_videos():
|
||||
"""Return available wireframe template videos."""
|
||||
wireframe_dir = _load_wireframe_dir()
|
||||
if not os.path.isdir(wireframe_dir):
|
||||
return {"videos": []}
|
||||
videos = [
|
||||
f for f in sorted(os.listdir(wireframe_dir))
|
||||
if f.lower().endswith(VIDEO_EXTENSIONS) and not f.startswith('.')
|
||||
]
|
||||
return {"videos": videos, "wireframe_dir": wireframe_dir}
|
||||
|
||||
|
||||
@app.get("/wireframe/duration/{video_name}")
|
||||
def wireframe_duration(video_name: str):
|
||||
"""Return duration (seconds) of a wireframe video via ffprobe."""
|
||||
import subprocess
|
||||
wireframe_dir = _load_wireframe_dir()
|
||||
video_path = os.path.join(wireframe_dir, video_name)
|
||||
if not os.path.exists(video_path):
|
||||
raise HTTPException(404, f"Video not found: {video_name}")
|
||||
try:
|
||||
r = subprocess.run(
|
||||
['ffprobe', '-v', 'error', '-show_entries', 'format=duration',
|
||||
'-of', 'json', video_path],
|
||||
capture_output=True, timeout=10,
|
||||
)
|
||||
info = json.loads(r.stdout)
|
||||
duration = float(info.get('format', {}).get('duration', 0))
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"ffprobe error: {e}")
|
||||
return {'video_name': video_name, 'duration': duration}
|
||||
|
||||
|
||||
class TrimRequest(BaseModel):
|
||||
video_name: str
|
||||
start: float # seconds
|
||||
end: float # seconds
|
||||
output_name: str | None = None # auto-generated if None
|
||||
|
||||
|
||||
@app.post("/wireframe/trim")
|
||||
def trim_wireframe(req: TrimRequest):
|
||||
"""Trim a wireframe video to [start, end] seconds using ffmpeg stream copy."""
|
||||
import subprocess
|
||||
wireframe_dir = _load_wireframe_dir()
|
||||
video_path = os.path.join(wireframe_dir, req.video_name)
|
||||
if not os.path.exists(video_path):
|
||||
raise HTTPException(404, f"Video not found: {req.video_name}")
|
||||
if req.start < 0 or req.end <= req.start:
|
||||
raise HTTPException(400, "Invalid start/end: end must be > start ≥ 0")
|
||||
|
||||
stem = os.path.splitext(req.video_name)[0]
|
||||
if req.output_name:
|
||||
out_name = req.output_name if req.output_name.lower().endswith('.mp4') else req.output_name + '.mp4'
|
||||
else:
|
||||
out_name = f"{stem}_{int(req.start)}s-{int(req.end)}s.mp4"
|
||||
|
||||
out_path = os.path.join(wireframe_dir, out_name)
|
||||
if os.path.exists(out_path):
|
||||
raise HTTPException(409, f"File already exists: {out_name}")
|
||||
|
||||
r = subprocess.run(
|
||||
['ffmpeg', '-y',
|
||||
'-ss', str(req.start), '-to', str(req.end),
|
||||
'-i', video_path,
|
||||
'-c', 'copy',
|
||||
out_path],
|
||||
capture_output=True, timeout=120,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
raise HTTPException(500, f"ffmpeg error: {r.stderr.decode(errors='replace')[:500]}")
|
||||
|
||||
return {'output_name': out_name, 'start': req.start, 'end': req.end}
|
||||
|
||||
|
||||
class FrameExtractRequest(BaseModel):
|
||||
video_name: str
|
||||
time: float = 0.0
|
||||
|
||||
|
||||
@app.post("/wireframe/frame")
|
||||
def wireframe_extract_frame(req: FrameExtractRequest):
|
||||
"""Extract a single frame at a given timestamp and return it as base64 PNG."""
|
||||
import base64
|
||||
wireframe_dir = _load_wireframe_dir()
|
||||
video_path = os.path.join(wireframe_dir, req.video_name)
|
||||
if not os.path.exists(video_path):
|
||||
raise HTTPException(404, f"Video not found: {req.video_name}")
|
||||
try:
|
||||
img = _extract_frame_at(video_path, req.time)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
return {"frame_b64": base64.b64encode(buf.getvalue()).decode()}
|
||||
|
||||
|
||||
class FaceswapRequest(BaseModel):
|
||||
model_filename: str # image from output_dir to use as face source
|
||||
video_name: str # filename of template video in wireframe_dir
|
||||
enhance: bool = True # GFPGAN face restoration after each frame swap
|
||||
hair: bool = False # use FaceFusion with hair_swapper (requires FaceFusion install)
|
||||
|
||||
|
||||
@app.get("/faceswap/check")
|
||||
def faceswap_check():
|
||||
"""Report which enhancement backends are available."""
|
||||
gfpgan_ok = False
|
||||
try:
|
||||
import gfpgan # noqa
|
||||
gfpgan_ok = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
with open(CONFIG_PATH, 'r') as f:
|
||||
conf = json.load(f)
|
||||
ff_dir = os.path.expanduser(conf.get('facefusion_dir', '~/facefusion'))
|
||||
ff_script = os.path.join(ff_dir, 'facefusion.py')
|
||||
|
||||
return {'gfpgan': gfpgan_ok, 'facefusion': os.path.exists(ff_script)}
|
||||
|
||||
|
||||
@app.post("/faceswap")
|
||||
def start_faceswap(req: FaceswapRequest):
|
||||
output_dir = _load_output_dir()
|
||||
wireframe_dir = _load_wireframe_dir()
|
||||
|
||||
src_path = os.path.join(output_dir, req.model_filename)
|
||||
video_path = os.path.join(wireframe_dir, req.video_name)
|
||||
|
||||
if not os.path.exists(src_path):
|
||||
raise HTTPException(404, f"Model image not found: {req.model_filename}")
|
||||
if not os.path.exists(video_path):
|
||||
raise HTTPException(404, f"Template video not found: {req.video_name}")
|
||||
|
||||
job_id = uuid.uuid4().hex[:8]
|
||||
jobs[job_id] = {
|
||||
"status": "running", "type": "faceswap",
|
||||
"total": 1, "done": 0, "failed": 0,
|
||||
"model": req.model_filename, "video": req.video_name,
|
||||
}
|
||||
|
||||
if req.hair:
|
||||
t = threading.Thread(
|
||||
target=_faceswap_worker_ff,
|
||||
args=(job_id, req.model_filename, req.video_name),
|
||||
kwargs={'hair': True, 'enhance': req.enhance},
|
||||
daemon=True,
|
||||
)
|
||||
else:
|
||||
t = threading.Thread(
|
||||
target=_faceswap_worker,
|
||||
args=(job_id, req.model_filename, req.video_name),
|
||||
kwargs={'enhance': req.enhance},
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
return {"job_id": job_id, "model": req.model_filename, "video": req.video_name}
|
||||
|
||||
|
||||
# --- tagging routes ----------------------------------------------------------
|
||||
|
||||
class TagRequest(BaseModel):
|
||||
@@ -1167,6 +1733,203 @@ def remove_background_group(group_id: str, background_tasks: BackgroundTasks):
|
||||
return {"status": "processing", "group_id": group_id}
|
||||
|
||||
|
||||
# --- scenery generation -------------------------------------------------------
|
||||
|
||||
def _extract_frame_at(video_path: str, t: float) -> Image.Image:
|
||||
"""Extract a single frame at time t (seconds) from a video via ffmpeg."""
|
||||
import subprocess as _sp
|
||||
r = _sp.run(
|
||||
['ffmpeg', '-y', '-ss', str(t), '-i', video_path,
|
||||
'-frames:v', '1', '-f', 'image2pipe', '-vcodec', 'png', 'pipe:1'],
|
||||
capture_output=True, timeout=15,
|
||||
)
|
||||
if r.returncode != 0 or not r.stdout:
|
||||
raise ValueError(f"ffmpeg frame extract failed: {r.stderr.decode(errors='replace')[:300]}")
|
||||
return Image.open(io.BytesIO(r.stdout)).convert("RGB")
|
||||
|
||||
|
||||
class SceneryRequest(BaseModel):
|
||||
model_filename: str # person image in output_dir
|
||||
scene_bytes: str | None = None # base64-encoded PNG/JPEG of the reference scene
|
||||
scene_video: str | None = None # wireframe video name to extract frame from
|
||||
scene_time: float = 0.0 # timestamp (seconds) to extract from video
|
||||
prompt: str | None = None # override; auto-built if None
|
||||
seed: int = -1
|
||||
|
||||
|
||||
def _scenery_worker(job_id: str, model_filename: str, scene_pil: Image.Image,
|
||||
prompt: str, seed: int):
|
||||
output_dir = _load_output_dir()
|
||||
try:
|
||||
model_path = os.path.join(output_dir, model_filename)
|
||||
model_pil = Image.open(model_path).convert("RGB")
|
||||
png_bytes = _run_pipeline(model_pil, prompt, seed, MAX_AREA,
|
||||
extra_images=[scene_pil])
|
||||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||
base_name = naming.get_base_name(model_filename)
|
||||
out_name = f"{ts}_sc_{base_name}"
|
||||
out_path = os.path.join(output_dir, out_name)
|
||||
with open(out_path, "wb") as f:
|
||||
f.write(png_bytes)
|
||||
person = database.get_person(model_filename)
|
||||
group_id = person[1] if person and person[1] else naming.get_base_name(model_filename)
|
||||
try:
|
||||
embedding = embeddings.generate_embedding(out_path)
|
||||
next_order = database.get_next_sort_order(group_id)
|
||||
database.upsert_person(out_name, filepath=out_path, embedding=embedding,
|
||||
group_id=group_id, prompt=prompt,
|
||||
sort_order=next_order,
|
||||
source_refs=json.dumps([model_filename]))
|
||||
except Exception as db_err:
|
||||
print(f"[scenery] DB error: {db_err}")
|
||||
jobs[job_id]["status"] = "done"
|
||||
jobs[job_id]["output"] = out_name
|
||||
except Exception as e:
|
||||
print(f"[scenery] error: {e}")
|
||||
jobs[job_id]["status"] = "error"
|
||||
jobs[job_id]["error"] = str(e)
|
||||
|
||||
|
||||
@app.post("/generate-scenery")
|
||||
def generate_scenery(req: SceneryRequest):
|
||||
output_dir = _load_output_dir()
|
||||
wireframe_dir = _load_wireframe_dir()
|
||||
|
||||
model_path = os.path.join(output_dir, req.model_filename)
|
||||
if not os.path.exists(model_path):
|
||||
raise HTTPException(404, f"Model image not found: {req.model_filename}")
|
||||
|
||||
# Resolve scene image
|
||||
if req.scene_bytes:
|
||||
import base64
|
||||
raw = base64.b64decode(req.scene_bytes)
|
||||
scene_pil = Image.open(io.BytesIO(raw)).convert("RGB")
|
||||
elif req.scene_video:
|
||||
video_path = os.path.join(wireframe_dir, req.scene_video)
|
||||
if not os.path.exists(video_path):
|
||||
raise HTTPException(404, f"Scene video not found: {req.scene_video}")
|
||||
try:
|
||||
scene_pil = _extract_frame_at(video_path, req.scene_time)
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Frame extraction failed: {e}")
|
||||
else:
|
||||
raise HTTPException(400, "Provide scene_bytes or scene_video")
|
||||
|
||||
prompt = req.prompt or (
|
||||
"Place this person into the provided scene. Keep the person's appearance, "
|
||||
"lighting, and pose natural and consistent with the environment. "
|
||||
"Photorealistic, high quality."
|
||||
)
|
||||
|
||||
job_id = uuid.uuid4().hex[:8]
|
||||
jobs[job_id] = {"status": "running", "type": "scenery", "total": 1, "done": 0, "failed": 0}
|
||||
threading.Thread(
|
||||
target=_scenery_worker,
|
||||
args=(job_id, req.model_filename, scene_pil, prompt, req.seed),
|
||||
daemon=True,
|
||||
).start()
|
||||
return {"job_id": job_id, "model": req.model_filename}
|
||||
|
||||
|
||||
# --- SAM2 background removal --------------------------------------------------
|
||||
|
||||
_sam2_predictor = None
|
||||
_sam2_predictor_lock = threading.Lock()
|
||||
|
||||
|
||||
def _load_sam2():
|
||||
"""Lazy-load SAM2 AutomaticMaskGenerator. Returns generator or False if unavailable."""
|
||||
global _sam2_predictor
|
||||
if _sam2_predictor is not None:
|
||||
return _sam2_predictor
|
||||
with _sam2_predictor_lock:
|
||||
if _sam2_predictor is not None:
|
||||
return _sam2_predictor
|
||||
try:
|
||||
from sam2.build_sam import build_sam2
|
||||
from sam2.automatic_mask_generator import SAM2AutomaticMaskGenerator
|
||||
with open(CONFIG_PATH) as f:
|
||||
conf = json.load(f)
|
||||
ckpt = os.path.expanduser(conf.get("sam2_checkpoint", "~/.sam2/sam2.1_hiera_tiny.pt"))
|
||||
cfg = conf.get("sam2_config", "configs/sam2.1/sam2.1_hiera_t.yaml")
|
||||
if not os.path.exists(ckpt):
|
||||
raise FileNotFoundError(f"SAM2 checkpoint not found: {ckpt}")
|
||||
model = build_sam2(cfg, ckpt, device="cuda")
|
||||
_sam2_predictor = SAM2AutomaticMaskGenerator(model)
|
||||
print(f"[sam2] loaded from {ckpt}")
|
||||
except Exception as e:
|
||||
print(f"[sam2] not available: {e}")
|
||||
_sam2_predictor = False
|
||||
return _sam2_predictor
|
||||
|
||||
|
||||
def _apply_transparency_sam2(png_bytes: bytes) -> bytes:
|
||||
"""Remove background using SAM2 (largest-area mask = subject), fallback to rembg."""
|
||||
predictor = _load_sam2()
|
||||
if predictor is False:
|
||||
return _apply_transparency(png_bytes)
|
||||
try:
|
||||
import numpy as np
|
||||
img = Image.open(io.BytesIO(png_bytes)).convert("RGB")
|
||||
arr = np.array(img)
|
||||
masks = predictor.generate(arr)
|
||||
if not masks:
|
||||
return _apply_transparency(png_bytes)
|
||||
best = max(masks, key=lambda m: m["area"])
|
||||
mask_np = (best["segmentation"].astype(np.uint8) * 255)
|
||||
rgba = img.convert("RGBA")
|
||||
r, g, b, _ = rgba.split()
|
||||
alpha = Image.fromarray(mask_np, mode="L")
|
||||
out = Image.merge("RGBA", (r, g, b, alpha))
|
||||
buf = io.BytesIO()
|
||||
out.save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
except Exception as e:
|
||||
print(f"[sam2] inference error, falling back to rembg: {e}")
|
||||
return _apply_transparency(png_bytes)
|
||||
|
||||
|
||||
@app.post("/remove-background-sam/{filename}")
|
||||
def remove_background_sam(filename: str):
|
||||
"""SAM2-based background removal (RGBA PNG). Falls back to rembg if SAM2 unavailable."""
|
||||
person = database.get_person(filename)
|
||||
if not person or not person[5] or not os.path.exists(person[5]):
|
||||
raise HTTPException(404, "Image file not found")
|
||||
path = person[5]
|
||||
with open(path, "rb") as f:
|
||||
png_bytes = f.read()
|
||||
transparent_png = _apply_transparency_sam2(png_bytes)
|
||||
with open(path, "wb") as f:
|
||||
f.write(transparent_png)
|
||||
used_sam2 = _sam2_predictor is not False and _sam2_predictor is not None
|
||||
return {"status": "success", "filename": filename, "used_sam2": used_sam2}
|
||||
|
||||
|
||||
@app.post("/restore-background/{filename}")
|
||||
def restore_background(filename: str):
|
||||
"""Flatten RGBA → RGB (white composite), making the image opaque again."""
|
||||
person = database.get_person(filename)
|
||||
if not person or not person[5] or not os.path.exists(person[5]):
|
||||
raise HTTPException(404, "Image file not found")
|
||||
path = person[5]
|
||||
img = Image.open(path)
|
||||
if img.mode == "RGBA":
|
||||
bg = Image.new("RGB", img.size, (255, 255, 255))
|
||||
bg.paste(img, mask=img.split()[3])
|
||||
buf = io.BytesIO()
|
||||
bg.save(buf, format="PNG")
|
||||
with open(path, "wb") as f:
|
||||
f.write(buf.getvalue())
|
||||
return {"status": "success", "filename": filename}
|
||||
|
||||
|
||||
@app.get("/sam2/check")
|
||||
def sam2_check():
|
||||
"""Return whether SAM2 is available."""
|
||||
predictor = _load_sam2()
|
||||
return {"sam2": predictor is not False and predictor is not None}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host=os.environ.get("HOST", "0.0.0.0"),
|
||||
|
||||
Reference in New Issue
Block a user