outpaint orbit

This commit is contained in:
mike
2026-06-26 02:34:12 +02:00
parent ce7b0e52e5
commit 80b901ac8a
12 changed files with 2858 additions and 274 deletions

View File

@@ -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 (01) 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"),