outpaint orbit
This commit is contained in:
@@ -36,9 +36,11 @@ from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
import shutil
|
||||
import re
|
||||
from typing import Union, Any
|
||||
|
||||
# --- config -----------------------------------------------------------------
|
||||
CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json")
|
||||
_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(
|
||||
@@ -90,6 +92,22 @@ app.add_middleware(
|
||||
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):
|
||||
@@ -130,6 +148,150 @@ def _load_faceswap_model_path() -> str:
|
||||
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:
|
||||
@@ -138,6 +300,7 @@ def on_startup():
|
||||
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()
|
||||
@@ -1084,6 +1247,81 @@ def _write_all_static() -> None:
|
||||
except Exception as e:
|
||||
print(f"[static] write_all error: {e}")
|
||||
|
||||
# Turntable static is cheap and independent; write outside the main lock
|
||||
_write_turntable_static()
|
||||
|
||||
|
||||
def _write_turntable_static() -> None:
|
||||
"""Write _data/turntables.json with per-group turntable state + frame URLs."""
|
||||
try:
|
||||
import turntable_cache as tc
|
||||
output_dir = _load_output_dir()
|
||||
data_dir = os.path.join(output_dir, "_data")
|
||||
os.makedirs(data_dir, exist_ok=True)
|
||||
|
||||
# Load group names from DB for display
|
||||
try:
|
||||
group_names = database.get_all_group_names()
|
||||
except Exception:
|
||||
group_names = {}
|
||||
|
||||
# Preferred filename per group (sort_order=0)
|
||||
preferred: dict = {}
|
||||
try:
|
||||
for row in database.list_persons():
|
||||
fname, gid, sort_order = row[0], row[2], row[6]
|
||||
if not gid:
|
||||
continue
|
||||
if gid not in preferred:
|
||||
preferred[gid] = (fname, sort_order)
|
||||
else:
|
||||
cur = preferred[gid][1]
|
||||
if sort_order is not None and (cur is None or sort_order < cur):
|
||||
preferred[gid] = (fname, sort_order)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
turntables = []
|
||||
for gid in tc.list_cached_group_ids(output_dir):
|
||||
state = tc.load_state(output_dir, gid)
|
||||
if not state:
|
||||
continue
|
||||
# Build ordered frame URL list (relative to output_dir, served via /output/)
|
||||
angles = state.get("angles", [])
|
||||
views = state.get("views", {})
|
||||
frames = []
|
||||
for deg in angles:
|
||||
dk = tc.deg_key(deg)
|
||||
if dk in views:
|
||||
abs_path = views[dk]
|
||||
if os.path.exists(abs_path):
|
||||
rel = os.path.relpath(abs_path, output_dir)
|
||||
frames.append(rel.replace("\\", "/"))
|
||||
|
||||
video_rel = None
|
||||
vp = state.get("video_path")
|
||||
if vp and os.path.exists(vp):
|
||||
video_rel = os.path.relpath(vp, output_dir).replace("\\", "/")
|
||||
|
||||
turntables.append({
|
||||
"group_id": gid,
|
||||
"group_name": group_names.get(gid, ""),
|
||||
"preferred_filename": state.get("preferred_filename", ""),
|
||||
"completed": state.get("completed", False),
|
||||
"n_done": len(frames),
|
||||
"n_total": state.get("n_views", 24),
|
||||
"frames": frames,
|
||||
"video_path": video_rel,
|
||||
"started_at": state.get("started_at"),
|
||||
"completed_at": state.get("completed_at"),
|
||||
})
|
||||
|
||||
# Sort: complete first, then by most views done
|
||||
turntables.sort(key=lambda t: (-int(t["completed"]), -t["n_done"]))
|
||||
_write_json(os.path.join(data_dir, "turntables.json"), {"turntables": turntables})
|
||||
except Exception as e:
|
||||
print(f"[static] write_turntable error: {e}")
|
||||
|
||||
|
||||
def _invalidate_static() -> None:
|
||||
"""Coalesce rapid invalidation calls — restarts a 0.3 s debounce timer each time.
|
||||
@@ -1103,7 +1341,10 @@ def _invalidate_static() -> None:
|
||||
|
||||
def _batch_worker(job_id: str, filenames: list, prompts: list[str], poses: list,
|
||||
seed: int, max_area: int, group_id: str | None = None,
|
||||
wireframe_ref: str | None = None, wireframe_time: float = 0.5):
|
||||
wireframe_ref: str | None = None, wireframe_time: float = 0.5,
|
||||
pad_top: int = 0, pad_right: int = 0,
|
||||
pad_bottom: int = 0, pad_left: int = 0, pad_fill: str = "black",
|
||||
pad_outpaint: bool = False):
|
||||
output_dir = _load_output_dir()
|
||||
for fname in filenames:
|
||||
actual_gid = None
|
||||
@@ -1127,6 +1368,8 @@ def _batch_worker(job_id: str, filenames: list, prompts: list[str], poses: list,
|
||||
|
||||
try:
|
||||
base_pil = Image.open(fpath).convert("RGB")
|
||||
if any([pad_top, pad_right, pad_bottom, pad_left]):
|
||||
base_pil = _apply_manual_pad(base_pil, pad_top, pad_right, pad_bottom, pad_left, pad_fill)
|
||||
|
||||
# Extract wireframe pose reference frame once per filename
|
||||
pose_guide_pil = None
|
||||
@@ -1150,12 +1393,20 @@ def _batch_worker(job_id: str, filenames: list, prompts: list[str], poses: list,
|
||||
return
|
||||
try:
|
||||
pil = base_pil
|
||||
actual_prompt = prompt
|
||||
if pad_outpaint:
|
||||
out_instr = "Naturally outpaint and extend the borders of the image to complete the scene."
|
||||
if not actual_prompt.strip():
|
||||
actual_prompt = out_instr
|
||||
elif out_instr.lower() not in actual_prompt.lower():
|
||||
actual_prompt = f"{actual_prompt}. {out_instr}"
|
||||
|
||||
# Rotate 180° for poses that work better upside-down
|
||||
if pose and pose.lower().strip() in ROTATE_180_POSES:
|
||||
pil = pil.rotate(180)
|
||||
|
||||
extra_imgs = [pose_guide_pil] if pose_guide_pil else None
|
||||
png = _run_pipeline(pil, prompt, seed, max_area, extra_images=extra_imgs)
|
||||
png = _run_pipeline(pil, actual_prompt, seed, max_area, extra_images=extra_imgs)
|
||||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||
clean_fname = naming.get_base_name(fname)
|
||||
out_name = f"{ts}_{clean_fname}"
|
||||
@@ -1176,7 +1427,7 @@ def _batch_worker(job_id: str, filenames: list, prompts: list[str], poses: list,
|
||||
next_order = database.get_next_sort_order(actual_gid)
|
||||
database.upsert_person(
|
||||
out_name, filepath=out_path, embedding=embedding,
|
||||
group_id=actual_gid, prompt=prompt, pose=pose,
|
||||
group_id=actual_gid, prompt=actual_prompt, pose=pose,
|
||||
has_background=has_bg, sort_order=next_order,
|
||||
source_refs=json.dumps([fname]),
|
||||
)
|
||||
@@ -1199,7 +1450,10 @@ def _batch_worker(job_id: str, filenames: list, prompts: list[str], poses: list,
|
||||
|
||||
|
||||
def _multi_ref_worker(job_id: str, filenames: list[str], prompts: list[str], poses: list,
|
||||
seed: int, max_area: int):
|
||||
seed: int, max_area: int,
|
||||
pad_top: int = 0, pad_right: int = 0,
|
||||
pad_bottom: int = 0, pad_left: int = 0,
|
||||
pad_fill: str = "black", pad_outpaint: bool = False):
|
||||
"""Generate one output image per prompt using filenames[0] as primary and the rest as extra refs."""
|
||||
output_dir = _load_output_dir()
|
||||
|
||||
@@ -1213,6 +1467,10 @@ def _multi_ref_worker(job_id: str, filenames: list[str], prompts: list[str], pos
|
||||
jobs[job_id]["status"] = "done"
|
||||
return
|
||||
|
||||
# Apply padding to the primary image if requested
|
||||
if any([pad_top, pad_right, pad_bottom, pad_left]):
|
||||
pils[0] = (pils[0][0], _apply_manual_pad(pils[0][1], pad_top, pad_right, pad_bottom, pad_left, pad_fill))
|
||||
|
||||
# Output group: reuse shared group if all sources belong to the same one, else new group
|
||||
source_groups = set()
|
||||
for fname, _ in pils:
|
||||
@@ -1234,10 +1492,18 @@ def _multi_ref_worker(job_id: str, filenames: list[str], prompts: list[str], pos
|
||||
for prompt, pose in zip(prompts, poses):
|
||||
try:
|
||||
work_pil = primary_pil
|
||||
actual_prompt = prompt
|
||||
if pad_outpaint:
|
||||
out_instr = "Naturally outpaint and extend the borders of the image to complete the scene."
|
||||
if not actual_prompt.strip():
|
||||
actual_prompt = out_instr
|
||||
elif out_instr.lower() not in actual_prompt.lower():
|
||||
actual_prompt = f"{actual_prompt}. {out_instr}"
|
||||
|
||||
if pose and pose.lower().strip() in ROTATE_180_POSES:
|
||||
work_pil = work_pil.rotate(180)
|
||||
|
||||
png = _run_pipeline(work_pil, prompt, seed, max_area, extra_images=extra_pils)
|
||||
png = _run_pipeline(work_pil, actual_prompt, seed, max_area, extra_images=extra_pils)
|
||||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||
clean = naming.get_base_name(primary_fname)
|
||||
out_name = f"{ts}_mr_{clean}"
|
||||
@@ -1257,7 +1523,7 @@ def _multi_ref_worker(job_id: str, filenames: list[str], prompts: list[str], pos
|
||||
embedding = embeddings.generate_embedding(out_path)
|
||||
next_order = database.get_next_sort_order(output_gid)
|
||||
database.upsert_person(out_name, filepath=out_path, embedding=embedding,
|
||||
group_id=output_gid, prompt=prompt, pose=pose,
|
||||
group_id=output_gid, prompt=actual_prompt, pose=pose,
|
||||
has_background=has_bg, sort_order=next_order,
|
||||
source_refs=json.dumps([f for f, _ in pils]))
|
||||
except Exception as db_err:
|
||||
@@ -1311,6 +1577,12 @@ class BatchRequest(BaseModel):
|
||||
poses: list[str | None] | None = None # pose name per prompt (same index), or None; None entries = no pose
|
||||
wireframe_ref: str | None = None # wireframe video name to use as pose guide (image2 slot)
|
||||
wireframe_time: float = 0.5 # normalized time (0–1) to extract the pose frame from
|
||||
pad_top: int | float | str = 0
|
||||
pad_right: int | float | str = 0
|
||||
pad_bottom: int | float | str = 0
|
||||
pad_left: int | float | str = 0
|
||||
pad_fill: str = "black"
|
||||
pad_outpaint: bool = False
|
||||
|
||||
|
||||
@app.post("/batch")
|
||||
@@ -1327,7 +1599,13 @@ def start_batch(req: BatchRequest):
|
||||
t = threading.Thread(
|
||||
target=_batch_worker,
|
||||
args=(job_id, req.filenames, prompts, poses, req.seed, req.max_area, req.group_id),
|
||||
kwargs={"wireframe_ref": req.wireframe_ref, "wireframe_time": req.wireframe_time},
|
||||
kwargs={
|
||||
"wireframe_ref": req.wireframe_ref, "wireframe_time": req.wireframe_time,
|
||||
"pad_top": req.pad_top, "pad_right": req.pad_right,
|
||||
"pad_bottom": req.pad_bottom, "pad_left": req.pad_left,
|
||||
"pad_fill": req.pad_fill,
|
||||
"pad_outpaint": req.pad_outpaint,
|
||||
},
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
@@ -1349,6 +1627,12 @@ class MultiRefRequest(BaseModel):
|
||||
poses: list[str | None] | None = None
|
||||
seed: int = -1
|
||||
max_area: int = 0
|
||||
pad_top: int | float | str = 0
|
||||
pad_right: int | float | str = 0
|
||||
pad_bottom: int | float | str = 0
|
||||
pad_left: int | float | str = 0
|
||||
pad_fill: str = "black"
|
||||
pad_outpaint: bool = False
|
||||
|
||||
|
||||
@app.post("/multi-ref")
|
||||
@@ -1366,6 +1650,11 @@ def start_multi_ref(req: MultiRefRequest):
|
||||
t = threading.Thread(
|
||||
target=_multi_ref_worker,
|
||||
args=(job_id, filenames, prompts, poses, req.seed, req.max_area),
|
||||
kwargs={
|
||||
"pad_top": req.pad_top, "pad_right": req.pad_right,
|
||||
"pad_bottom": req.pad_bottom, "pad_left": req.pad_left,
|
||||
"pad_fill": req.pad_fill, "pad_outpaint": req.pad_outpaint
|
||||
},
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
@@ -2935,6 +3224,129 @@ def manual_crop_image(filename: str, req: CropRequest):
|
||||
"box": [x1, y1, x2, y2]}
|
||||
|
||||
|
||||
class PadRequest(BaseModel):
|
||||
top: int | float | str = 0
|
||||
right: int | float | str = 0
|
||||
bottom: int | float | str = 0
|
||||
left: int | float | str = 0
|
||||
as_copy: bool = True
|
||||
fill: str = "black" # "black", "white", "transparent"
|
||||
outpaint: bool = False
|
||||
prompt: str | None = None
|
||||
|
||||
|
||||
def _apply_manual_pad(pil: Image.Image, top, right, bottom, left,
|
||||
fill: str = "black") -> Image.Image:
|
||||
"""Expand canvas by padding pixels on each side. Fill with black, white, or transparency.
|
||||
Supports pixel values (int) or percentages (str like "10%" or float < 1.0).
|
||||
"""
|
||||
w, h = pil.size
|
||||
|
||||
def resolve(val, total):
|
||||
if not val:
|
||||
return 0
|
||||
if isinstance(val, str) and "%" in val:
|
||||
try:
|
||||
return int(float(val.replace("%", "")) * total / 100)
|
||||
except:
|
||||
return 0
|
||||
try:
|
||||
f = float(val)
|
||||
if 0 < f < 1.0:
|
||||
return int(f * total)
|
||||
return int(f)
|
||||
except:
|
||||
return 0
|
||||
|
||||
top = max(0, resolve(top, h))
|
||||
right = max(0, resolve(right, w))
|
||||
bottom = max(0, resolve(bottom, h))
|
||||
left = max(0, resolve(left, w))
|
||||
|
||||
if not any([top, right, bottom, left]):
|
||||
return pil
|
||||
new_w = w + left + right
|
||||
new_h = h + top + bottom
|
||||
if fill == "transparent":
|
||||
canvas = Image.new("RGBA", (new_w, new_h), (0, 0, 0, 0))
|
||||
pil = pil.convert("RGBA")
|
||||
elif fill == "white":
|
||||
canvas = Image.new("RGB", (new_w, new_h), (255, 255, 255))
|
||||
pil = pil.convert("RGB")
|
||||
else:
|
||||
canvas = Image.new("RGB", (new_w, new_h), (0, 0, 0))
|
||||
pil = pil.convert("RGB")
|
||||
canvas.paste(pil, (left, top))
|
||||
return canvas
|
||||
|
||||
|
||||
@app.post("/images/{filename}/pad")
|
||||
def pad_image(filename: str, req: PadRequest):
|
||||
"""Expand the image canvas by adding blank padding on each side.
|
||||
|
||||
as_copy=True (default) creates a new image referencing the original;
|
||||
as_copy=False modifies in-place.
|
||||
"""
|
||||
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")
|
||||
src_path = person[5]
|
||||
|
||||
if req.as_copy:
|
||||
from datetime import datetime as _dt
|
||||
output_dir = os.path.dirname(src_path)
|
||||
ext = os.path.splitext(filename)[1] or ".png"
|
||||
stem = os.path.splitext(filename)[0]
|
||||
ts = _dt.now().strftime("%Y%m%d_%H%M%S")
|
||||
new_filename = f"{ts}_pad_{stem}{ext}"
|
||||
path = os.path.join(output_dir, new_filename)
|
||||
shutil.copy2(src_path, path)
|
||||
database.upsert_person(
|
||||
new_filename, filepath=path, group_id=person[1],
|
||||
prompt=person[6], pose=person[7],
|
||||
has_background=person[11], has_clothing=person[13],
|
||||
source_refs=json.dumps([filename]),
|
||||
)
|
||||
else:
|
||||
new_filename = filename
|
||||
path = src_path
|
||||
|
||||
img = Image.open(path)
|
||||
padded = _apply_manual_pad(img, req.top, req.right, req.bottom, req.left, req.fill)
|
||||
if padded is img:
|
||||
raise HTTPException(400, "No padding specified (all sides are 0)")
|
||||
|
||||
if req.outpaint:
|
||||
# Trigger Qwen outpainting
|
||||
out_instr = "Naturally outpaint and extend the borders of the image to complete the scene."
|
||||
actual_prompt = req.prompt or out_instr
|
||||
if req.prompt and out_instr.lower() not in actual_prompt.lower():
|
||||
actual_prompt = f"{actual_prompt}. {out_instr}"
|
||||
elif not req.prompt and person[6]: # original prompt
|
||||
actual_prompt = f"{person[6]}. {out_instr}"
|
||||
|
||||
try:
|
||||
png_bytes = _run_pipeline(padded, actual_prompt)
|
||||
padded = Image.open(io.BytesIO(png_bytes))
|
||||
# Register the outpaint prompt and set has_background=True in DB
|
||||
database.upsert_person(new_filename, prompt=actual_prompt, has_background=True)
|
||||
except Exception as e:
|
||||
print(f"Outpaint error: {e}")
|
||||
raise HTTPException(500, f"Outpaint failed: {e}")
|
||||
|
||||
fmt = "PNG" if path.lower().endswith(".png") else "JPEG"
|
||||
if req.fill == "transparent":
|
||||
fmt = "PNG" # JPEG cannot store alpha
|
||||
padded.save(path, format=fmt)
|
||||
if req.as_copy:
|
||||
_invalidate_static()
|
||||
return {
|
||||
"status": "success", "filename": filename, "new_filename": new_filename,
|
||||
"new_url": f"/output/{new_filename}", "as_copy": req.as_copy,
|
||||
"size": list(padded.size),
|
||||
}
|
||||
|
||||
|
||||
class RotateRequest(BaseModel):
|
||||
degrees: int = 90 # clockwise rotation; must be a multiple of 90
|
||||
|
||||
@@ -3781,6 +4193,259 @@ def generate_with_pose(req: PoseGenRequest):
|
||||
return {"job_id": job_id, "model": req.model_filename}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orbit preview — depth-card (fast) or Qwen turntable (quality)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class OrbitRequest(BaseModel):
|
||||
filename: str # single image filename relative to output_dir
|
||||
engine: str = "depth" # "depth" (fast depth-card) or "qwen" (near-real)
|
||||
# depth-card params
|
||||
n_frames: int = 36
|
||||
parallax: float = 0.08
|
||||
mode: str = "swing" # "swing" or "orbit"
|
||||
fps: int = 24
|
||||
max_angle_deg: float = 35.0
|
||||
# qwen turntable params
|
||||
n_views: int = 24
|
||||
steps: int = 8
|
||||
seed: int = 42
|
||||
|
||||
|
||||
@app.post("/orbit")
|
||||
def create_orbit(req: OrbitRequest):
|
||||
"""
|
||||
Build an orbit preview for one gallery image.
|
||||
|
||||
engine='depth': fast 2.5D depth-card parallax (seconds)
|
||||
engine='qwen': near-real Qwen turntable — checks turntable cache first;
|
||||
if a complete turntable exists, returns cached video immediately.
|
||||
Otherwise queues generation (~9 min for 24 views).
|
||||
|
||||
Returns {job_id} for polling via GET /batch/{job_id}.
|
||||
"""
|
||||
output_dir = _load_output_dir()
|
||||
img_path = os.path.join(output_dir, req.filename)
|
||||
if not os.path.exists(img_path):
|
||||
raise HTTPException(status_code=400, detail=f"Image not found: {req.filename}")
|
||||
|
||||
if req.engine == "qwen":
|
||||
return _create_qwen_orbit(req, output_dir, img_path)
|
||||
|
||||
# --- depth-card ---
|
||||
job_id = uuid.uuid4().hex[:8]
|
||||
jobs[job_id] = {"status": "running", "type": "orbit", "total": 1, "done": 0, "failed": 0}
|
||||
orbit_out = os.path.join(output_dir, f"orbit_{job_id}")
|
||||
|
||||
def _worker():
|
||||
try:
|
||||
try:
|
||||
from orbit_module import run_orbit_pipeline
|
||||
except ImportError:
|
||||
sys.path.insert(0, _HERE)
|
||||
from orbit_module import run_orbit_pipeline
|
||||
|
||||
result = run_orbit_pipeline(
|
||||
image_path=img_path,
|
||||
output_dir=orbit_out,
|
||||
n_frames=req.n_frames,
|
||||
parallax_strength=req.parallax,
|
||||
mode=req.mode,
|
||||
fps=req.fps,
|
||||
max_angle_deg=req.max_angle_deg,
|
||||
debug=True,
|
||||
)
|
||||
mp4_dst_name = f"orbit_{job_id}.mp4"
|
||||
shutil.copy2(result["video_path"], os.path.join(output_dir, mp4_dst_name))
|
||||
jobs[job_id]["status"] = "done"
|
||||
jobs[job_id]["done"] = 1
|
||||
jobs[job_id]["video_filename"] = mp4_dst_name
|
||||
jobs[job_id]["has_alpha"] = bool(result.get("has_alpha", False))
|
||||
jobs[job_id]["orbit_dir"] = orbit_out
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"[orbit] error: {e}\n{traceback.format_exc()}")
|
||||
jobs[job_id]["status"] = "error"
|
||||
jobs[job_id]["error"] = str(e)
|
||||
|
||||
threading.Thread(target=_worker, daemon=True).start()
|
||||
return {"job_id": job_id}
|
||||
|
||||
|
||||
def _create_qwen_orbit(req: OrbitRequest, output_dir: str, img_path: str) -> dict:
|
||||
"""
|
||||
Qwen turntable orbit: check cache first, else generate in background.
|
||||
The group_id is derived from the DB row for the requested image.
|
||||
If no group, falls back to a per-file cache keyed on the bare filename.
|
||||
"""
|
||||
import turntable_cache as tc
|
||||
|
||||
# Resolve group_id for this image
|
||||
group_id = None
|
||||
try:
|
||||
persons = database.list_persons()
|
||||
for row in persons:
|
||||
if row[0] == req.filename:
|
||||
group_id = row[2]
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
cache_key = str(group_id) if group_id else f"file_{req.filename}"
|
||||
|
||||
# Serve cached turntable immediately if already complete
|
||||
cached_video = tc.get_group_video(output_dir, cache_key)
|
||||
if cached_video and os.path.exists(cached_video):
|
||||
job_id = uuid.uuid4().hex[:8]
|
||||
rel = os.path.relpath(cached_video, output_dir)
|
||||
# Build ordered frame list for the frame-flipper player
|
||||
state = tc.load_state(output_dir, cache_key)
|
||||
frames = []
|
||||
if state:
|
||||
for deg in state.get("angles", []):
|
||||
dk = tc.deg_key(deg)
|
||||
p = state.get("views", {}).get(dk)
|
||||
if p and os.path.exists(p):
|
||||
frames.append(os.path.relpath(p, output_dir).replace("\\", "/"))
|
||||
jobs[job_id] = {
|
||||
"status": "done", "type": "orbit_qwen",
|
||||
"total": 1, "done": 1, "failed": 0,
|
||||
"video_filename": rel,
|
||||
"frames": frames,
|
||||
"cached": True,
|
||||
}
|
||||
return {"job_id": job_id, "cached": True}
|
||||
|
||||
# Otherwise generate in a background thread
|
||||
job_id = uuid.uuid4().hex[:8]
|
||||
jobs[job_id] = {
|
||||
"status": "running", "type": "orbit_qwen",
|
||||
"total": req.n_views, "done": 0, "failed": 0,
|
||||
}
|
||||
|
||||
def _qwen_worker():
|
||||
try:
|
||||
import sys as _s
|
||||
if _HERE not in _s.path:
|
||||
_s.path.insert(0, _HERE)
|
||||
from orbit_qwen import run_qwen_orbit
|
||||
|
||||
qwen_out = os.path.join(tc.cache_dir(output_dir, cache_key))
|
||||
os.makedirs(qwen_out, exist_ok=True)
|
||||
|
||||
def _prog(i, n, deg):
|
||||
jobs[job_id]["done"] = i
|
||||
jobs[job_id]["status_detail"] = f"{i}/{n} views ({int(deg)}°)"
|
||||
|
||||
result = run_qwen_orbit(
|
||||
image_path=img_path,
|
||||
output_dir=qwen_out,
|
||||
n_views=req.n_views,
|
||||
seed=req.seed,
|
||||
mode="turntable",
|
||||
steps=req.steps,
|
||||
on_progress=_prog,
|
||||
)
|
||||
video_path = result["video_path"]
|
||||
|
||||
# Also update the persistent cache state so idle daemon knows it's done
|
||||
state = tc.load_state(output_dir, cache_key) or tc.init_state(
|
||||
output_dir, cache_key, img_path, req.filename,
|
||||
n_views=req.n_views, seed=req.seed, steps=req.steps,
|
||||
)
|
||||
for v in result["views"]:
|
||||
tc.mark_view_done(output_dir, cache_key, state, v["deg"], v["path"])
|
||||
tc.mark_completed(output_dir, cache_key, state, video_path)
|
||||
_write_turntable_static()
|
||||
|
||||
rel = os.path.relpath(video_path, output_dir)
|
||||
# Include frame URLs in job result for frame-flipper player
|
||||
frames = []
|
||||
for v in result["views"]:
|
||||
frames.append(os.path.relpath(v["path"], output_dir).replace("\\", "/"))
|
||||
jobs[job_id]["status"] = "done"
|
||||
jobs[job_id]["done"] = req.n_views
|
||||
jobs[job_id]["video_filename"] = rel
|
||||
jobs[job_id]["frames"] = frames
|
||||
except Exception as e:
|
||||
import traceback
|
||||
print(f"[orbit-qwen] error: {e}\n{traceback.format_exc()}")
|
||||
jobs[job_id]["status"] = "error"
|
||||
jobs[job_id]["error"] = str(e)
|
||||
|
||||
threading.Thread(target=_qwen_worker, daemon=True).start()
|
||||
return {"job_id": job_id, "cached": False}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Turntable status / control endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@app.get("/turntable/status")
|
||||
def get_turntable_status():
|
||||
"""
|
||||
Return background turntable generation state for all groups.
|
||||
Used by the frontend to show a global progress badge.
|
||||
"""
|
||||
import turntable_cache as tc
|
||||
output_dir = _load_output_dir()
|
||||
summary = tc.get_status_summary(output_dir)
|
||||
n_complete = sum(1 for v in summary.values() if v["completed"])
|
||||
return {
|
||||
"groups": summary,
|
||||
"n_complete": n_complete,
|
||||
"n_total": len(summary),
|
||||
"is_generating": _idle_turntable_busy,
|
||||
"is_paused": _idle_turntable_paused,
|
||||
"idle_seconds": round(time.time() - _last_request_time, 1),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/turntable/status/{group_id}")
|
||||
def get_turntable_status_group(group_id: str):
|
||||
"""Return turntable state for a specific group."""
|
||||
import turntable_cache as tc
|
||||
output_dir = _load_output_dir()
|
||||
state = tc.load_state(output_dir, group_id)
|
||||
if not state:
|
||||
return {"exists": False, "group_id": group_id}
|
||||
return {
|
||||
"exists": True,
|
||||
"completed": state.get("completed", False),
|
||||
"n_done": len(state.get("views", {})),
|
||||
"n_total": state.get("n_views", 24),
|
||||
"video_path": state.get("video_path"),
|
||||
"preferred_filename": state.get("preferred_filename"),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/turntable/pause")
|
||||
def pause_turntable():
|
||||
"""Pause idle background generation."""
|
||||
global _idle_turntable_paused
|
||||
_idle_turntable_paused = True
|
||||
return {"paused": True}
|
||||
|
||||
|
||||
@app.post("/turntable/resume")
|
||||
def resume_turntable():
|
||||
"""Resume idle background generation."""
|
||||
global _idle_turntable_paused
|
||||
_idle_turntable_paused = False
|
||||
return {"paused": False}
|
||||
|
||||
|
||||
@app.delete("/turntable/{group_id}")
|
||||
def reset_turntable(group_id: str):
|
||||
"""Delete cached turntable state for a group (forces full re-generation)."""
|
||||
import turntable_cache as tc
|
||||
import shutil as _shutil
|
||||
output_dir = _load_output_dir()
|
||||
cd = tc.cache_dir(output_dir, group_id)
|
||||
if os.path.isdir(cd):
|
||||
_shutil.rmtree(cd)
|
||||
return {"deleted": True, "group_id": group_id}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host=os.environ.get("HOST", "0.0.0.0"),
|
||||
|
||||
Reference in New Issue
Block a user