video: AI upscale/interpolate, township outcomes, configurable tmp dir

Make video enhancement fully AI-on-CoderAI and rework township outcomes.

Upscaling (Real-ESRGAN / SD upscalers):
- Support diffusers-style .safetensors weights + config.json (e.g.
  hlky/RealESRGAN_*), not just classic .pth; infer RRDBNet arch/scale from
  config. fp16 + tiling for performance.
- AI-or-fail: no ffmpeg fallback. Auto-select a configured upscaler when the
  request omits a model (find_capable_model).
- Fix a registry-pollution bug: cache upscalers in a private dict, never under
  a synthetic 'upscale:<id>' key in multi_model_manager.models (which made a
  later request_model() resolve/reload the bogus key -> 400).
- Per-frame progress + a first-class "upscale" task (pause/cancel/thermal),
  with a periodic thermal re-check through the frame loop.

Interpolation (RIFE):
- AI-or-fail: removed the ffmpeg minterpolate fallback. Resolve the
  rife-ncnn-vulkan binary + bundled model robustly, pass exact -n frame count,
  and pin -g to the SAME GPU CoderAI uses (matched by CUDA device name, not a
  hardcoded index). Progress + "interpolate" task + thermal guard.

Township generator:
- One draw per match (not per fighter); longer, configurable outcome videos
  built as a finish -> victory two-shot sequence; richer, more brutal,
  camera-aware prompts (finish/victory templates editable on the Prompts page).
- Stream large results via response_format=url instead of base64-in-JSON;
  per-frame progress for both upscale and interpolate.

Configurable temp dir:
- New --tmp CLI flag and config.tmp_dir (+ admin Settings field, applied live).
  Sets tempfile.tempdir and TMPDIR/TMP/TEMP so all scratch (frame extraction,
  upscaling, interpolation) and child processes use it — fixes
  "[Errno 28] No space left on device" when /tmp is small.
Co-Authored-By: 's avatarClaude Opus 4.8 <noreply@anthropic.com>
parent 06e61257
...@@ -2420,6 +2420,7 @@ async def api_get_settings(username: str = Depends(require_admin)): ...@@ -2420,6 +2420,7 @@ async def api_get_settings(username: str = Depends(require_admin)):
"tools_closer_prompt": c.tools_closer_prompt, "tools_closer_prompt": c.tools_closer_prompt,
"grammar_guided": c.grammar_guided, "grammar_guided": c.grammar_guided,
"parser": c.parser, "parser": c.parser,
"tmp_dir": c.tmp_dir,
} }
...@@ -2491,6 +2492,20 @@ async def api_save_settings(request: Request, username: str = Depends(require_ad ...@@ -2491,6 +2492,20 @@ async def api_save_settings(request: Request, username: str = Depends(require_ad
c.grammar_guided = bool(data["grammar_guided"]) c.grammar_guided = bool(data["grammar_guided"])
if "parser" in data: if "parser" in data:
c.parser = data["parser"] c.parser = data["parser"]
if "tmp_dir" in data:
# Persisted now and applied live so it takes effect without a restart.
c.tmp_dir = (data["tmp_dir"] or "").strip() or None
if c.tmp_dir:
try:
import tempfile as _tf, os as _os
_td = _os.path.abspath(_os.path.expanduser(c.tmp_dir))
_os.makedirs(_td, exist_ok=True)
_tf.tempdir = _td
_os.environ["TMPDIR"] = _td
_os.environ["TMP"] = _td
_os.environ["TEMP"] = _td
except Exception:
pass
if "archive" in data: if "archive" in data:
import os as _os import os as _os
......
...@@ -68,6 +68,11 @@ ...@@ -68,6 +68,11 @@
<input type="text" id="s-offload-dir" class="form-input" placeholder="./offload"> <input type="text" id="s-offload-dir" class="form-input" placeholder="./offload">
<span class="form-hint">Models will inherit this as default when configured</span> <span class="form-hint">Models will inherit this as default when configured</span>
</div> </div>
<div class="form-row" style="margin-top:.75rem">
<label class="form-label">Temporary working directory <span class="muted">(default: system /tmp)</span></label>
<input type="text" id="s-tmp-dir" class="form-input" placeholder="e.g. /data/tmp">
<span class="form-hint">Scratch space for frame extraction, upscaling and interpolation. Point it at a large volume — 4× upscaling can exhaust a small /tmp ("No space left on device"). Applied live on save.</span>
</div>
</div> </div>
<!-- Archive --> <!-- Archive -->
...@@ -330,6 +335,7 @@ async function loadSettings(){ ...@@ -330,6 +335,7 @@ async function loadSettings(){
document.getElementById('s-hf-cache').value = d.models?.hf_cache_dir ?? ''; document.getElementById('s-hf-cache').value = d.models?.hf_cache_dir ?? '';
document.getElementById('s-gguf-cache').value = d.models?.gguf_cache_dir ?? ''; document.getElementById('s-gguf-cache').value = d.models?.gguf_cache_dir ?? '';
document.getElementById('s-offload-dir').value = d.offload?.directory ?? './offload'; document.getElementById('s-offload-dir').value = d.offload?.directory ?? './offload';
document.getElementById('s-tmp-dir').value = d.tmp_dir ?? '';
toggleHttps(); toggleHttps();
// Archive // Archive
const arc = d.archive || {}; const arc = d.archive || {};
...@@ -397,6 +403,7 @@ async function saveSettings(){ ...@@ -397,6 +403,7 @@ async function saveSettings(){
offload:{ offload:{
directory: document.getElementById('s-offload-dir').value.trim() || './offload', directory: document.getElementById('s-offload-dir').value.trim() || './offload',
}, },
tmp_dir: strOrNull('s-tmp-dir'),
archive:{ archive:{
enabled: document.getElementById('s-arc-enabled').checked, enabled: document.getElementById('s-arc-enabled').checked,
directory: document.getElementById('s-arc-dir').value.trim(), directory: document.getElementById('s-arc-dir').value.trim(),
......
...@@ -76,7 +76,7 @@ function fmtTime(s) { ...@@ -76,7 +76,7 @@ function fmtTime(s) {
} catch { return ''; } } catch { return ''; }
} }
const KIND_LABEL = {training:'Training', image:'Image', video:'Video', audio:'Audio', text:'Text', pipeline:'Pipeline', request:'Request', loading:'Loading'}; const KIND_LABEL = {training:'Training', image:'Image', video:'Video', upscale:'Upscale', interpolate:'Interpolate', audio:'Audio', text:'Text', pipeline:'Pipeline', request:'Request', loading:'Loading'};
const STATUS_BADGE = { const STATUS_BADGE = {
running:'badge-admin', queued:'badge-user', done:'badge-ok', error:'badge-err', running:'badge-admin', queued:'badge-user', done:'badge-ok', error:'badge-err',
cancelled:'badge-user', interrupted:'badge-warn' cancelled:'badge-user', interrupted:'badge-warn'
......
...@@ -1759,28 +1759,173 @@ class ImageUpscaleRequest(BaseModel): ...@@ -1759,28 +1759,173 @@ class ImageUpscaleRequest(BaseModel):
extra = "allow" extra = "allow"
def _resolve_esrgan_weights(model_name: str):
"""Return local weights for an (Real-)ESRGAN model id. Accepts a local .pth /
.safetensors file, a local directory containing one, or a Hugging Face repo id
(the weights are downloaded via the HF cache). diffusers-style repos ship the
RRDBNet weights as `diffusion_pytorch_model.safetensors` + a `config.json`, so
we also fetch the config alongside. Returns the weights path, or None."""
import os
if os.path.isfile(model_name) and model_name.lower().endswith(('.pth', '.safetensors')):
return model_name
if os.path.isdir(model_name):
cands = [f for f in sorted(os.listdir(model_name))
if f.lower().endswith(('.pth', '.safetensors'))]
# Prefer .pth, then a full (non-fp16) safetensors.
cands.sort(key=lambda f: (not f.lower().endswith('.pth'), '.fp16.' in f.lower(), len(f)))
return os.path.join(model_name, cands[0]) if cands else None
# Treat as an HF repo id → find and fetch a weight file (.pth or .safetensors).
try:
from huggingface_hub import list_repo_files, hf_hub_download
files = list_repo_files(model_name)
weights = [f for f in files if f.lower().endswith(('.pth', '.safetensors'))]
if not weights:
return None
# Prefer: real .pth > full safetensors > fp16 safetensors; then a
# general-purpose x4plus over anime/x2 specialised; then shorter name.
def _rank(f):
fl = f.lower()
return (not fl.endswith('.pth'), '.fp16.' in fl,
'anime' in fl, 'x2' in fl, len(f))
weights.sort(key=_rank)
wp = hf_hub_download(model_name, weights[0])
# Best-effort: co-locate config.json (diffusers repos carry the arch there).
if 'config.json' in files:
try:
hf_hub_download(model_name, 'config.json')
except Exception:
pass
return wp
except Exception:
return None
def _esrgan_state_dict(weights_path: str):
"""Load an (Real-)ESRGAN RRDBNet state dict from a .pth or .safetensors file,
unwrapping the `params_ema` / `params` container used by the original .pth
checkpoints (diffusers safetensors store the bare RRDBNet keys)."""
if weights_path.lower().endswith('.safetensors'):
from safetensors.torch import load_file
return load_file(weights_path)
import torch
loadnet = torch.load(weights_path, map_location='cpu')
if isinstance(loadnet, dict) and 'params_ema' in loadnet:
return loadnet['params_ema']
if isinstance(loadnet, dict) and 'params' in loadnet:
return loadnet['params']
return loadnet
def _build_realesrgan(weights_path: str, device):
"""Build a RealESRGANer from .pth or .safetensors weights. The RRDBNet
architecture + native scale come from a sibling config.json when present
(diffusers repos), otherwise they are inferred from the filename."""
import os, json, hashlib, tempfile
from basicsr.archs.rrdbnet_arch import RRDBNet
from realesrgan import RealESRGANer
num_in_ch, num_out_ch, num_feat, num_grow_ch, num_block, scale = 3, 3, 64, 32, 23, 4
cfg_path = os.path.join(os.path.dirname(weights_path), 'config.json')
cfg = None
if os.path.isfile(cfg_path):
try:
cfg = json.load(open(cfg_path))
except Exception:
cfg = None
if cfg:
num_in_ch = int(cfg.get('num_in_ch', num_in_ch))
num_out_ch = int(cfg.get('num_out_ch', num_out_ch))
num_feat = int(cfg.get('num_feat', num_feat))
num_grow_ch = int(cfg.get('num_grow_ch', num_grow_ch))
num_block = int(cfg.get('num_block', num_block))
scale = int(cfg.get('scale', scale))
else:
fn = os.path.basename(weights_path).lower()
if 'anime' in fn or '6b' in fn: # RealESRGAN_x4plus_anime_6B
num_block, scale = 6, 4
elif 'x2' in fn: # RealESRGAN_x2plus
num_block, scale = 23, 2
else: # RealESRGAN_x4plus (default)
num_block, scale = 23, 4
model_obj = RRDBNet(num_in_ch=num_in_ch, num_out_ch=num_out_ch, num_feat=num_feat,
num_block=num_block, num_grow_ch=num_grow_ch, scale=scale)
# RealESRGANer always torch.load()s model_path; a .safetensors won't load that
# way, so convert once to a cached .pth wrapped as {'params_ema': sd}.
load_path = weights_path
if weights_path.lower().endswith('.safetensors'):
import torch
sd = _esrgan_state_dict(weights_path)
h = hashlib.sha1(os.path.abspath(weights_path).encode()).hexdigest()[:16]
load_path = os.path.join(tempfile.gettempdir(), f"realesrgan_{h}.pth")
if not os.path.isfile(load_path):
torch.save({'params_ema': sd}, load_path)
half = 'cuda' in str(device)
# tile>0 keeps VRAM bounded on large frames (no visible seams at this size).
return RealESRGANer(scale=scale, model_path=load_path, model=model_obj,
tile=512, tile_pad=10, pre_pad=0, half=half, device=device)
# Private cache of loaded super-resolution upscalers, keyed by resolved model id.
# Deliberately NOT stored in multi_model_manager.models — that registry is keyed
# by real model ids, so a synthetic 'upscale:<id>' key there makes a later
# request_model() resolve to the bogus key and try to (re)load it.
_UPSCALER_CACHE: dict = {}
def _load_upscaler(model_name: str, global_args): def _load_upscaler(model_name: str, global_args):
import logging
_log = logging.getLogger(__name__)
device = _derive_diffusers_device(global_args) device = _derive_diffusers_device(global_args)
n = model_name.lower() n = model_name.lower()
try: try:
from diffusers import StableDiffusionUpscalePipeline import torch
if 'upscal' in n or 'esrgan' in n: _dtype = torch.float16 if 'cuda' in str(device) else torch.float32
pipe = StableDiffusionUpscalePipeline.from_pretrained(model_name)
return ('diffusers', pipe.to(device))
except Exception:
pass
# Try basicsr / Real-ESRGAN
try:
from basicsr.archs.rrdbnet_arch import RRDBNet
from realesrgan import RealESRGANer
model_obj = RRDBNet(num_in_ch=3, num_out_ch=3, num_feat=64,
num_block=23, num_grow_ch=32, scale=4)
upsampler = RealESRGANer(scale=4, model_path=model_name,
model=model_obj, device=device)
return ('realesrgan', upsampler)
except Exception: except Exception:
pass _dtype = None
# Fallback: PIL LANCZOS _kw = {} if _dtype is None else {"torch_dtype": _dtype}
# 1. Real-ESRGAN / ESRGAN — GAN super-resolution from a .pth (fast, one
# forward pass per frame; ideal for video). Resolves local or HF weights.
if 'esrgan' in n:
try:
wp = _resolve_esrgan_weights(model_name)
if wp:
return ('realesrgan', _build_realesrgan(wp, device))
_log.warning("no .pth weights found for ESRGAN model '%s'", model_name)
except Exception as e:
_log.warning("Real-ESRGAN load failed for '%s': %s", model_name, e)
# 2. Latent upscaler (StableDiffusionLatentUpscalePipeline, fixed ×2).
if 'latent' in n and ('upscal' in n or 'x2' in n):
try:
from diffusers import StableDiffusionLatentUpscalePipeline
pipe = StableDiffusionLatentUpscalePipeline.from_pretrained(model_name, **_kw)
return ('diffusers_latent', pipe.to(device))
except Exception as e:
_log.warning("latent upscaler load failed for '%s': %s", model_name, e)
# 3. x4 SD super-res upscaler (StableDiffusionUpscalePipeline).
if 'upscal' in n:
try:
from diffusers import StableDiffusionUpscalePipeline
pipe = StableDiffusionUpscalePipeline.from_pretrained(model_name, **_kw)
return ('diffusers', pipe.to(device))
except Exception:
pass
# Generic fallback: let diffusers pick the right pipeline class.
try:
from diffusers import DiffusionPipeline
pipe = DiffusionPipeline.from_pretrained(model_name, **_kw)
cls = type(pipe).__name__.lower()
kind = 'diffusers_latent' if 'latent' in cls else 'diffusers'
return (kind, pipe.to(device))
except Exception as e:
_log.warning("diffusers upscaler load failed for '%s': %s", model_name, e)
# Not a recognised upscaler — callers treat 'pil' as "no real model".
return ('pil', None) return ('pil', None)
...@@ -1792,6 +1937,12 @@ def _run_upscale(upscaler, image_bytes: bytes, scale: int): ...@@ -1792,6 +1937,12 @@ def _run_upscale(upscaler, image_bytes: bytes, scale: int):
if backend == 'realesrgan': if backend == 'realesrgan':
out_arr, _ = model.enhance(np.array(img), outscale=scale) out_arr, _ = model.enhance(np.array(img), outscale=scale)
return PILImage.fromarray(out_arr) return PILImage.fromarray(out_arr)
if backend == 'diffusers_latent':
# Latent upscaler: fixed ×2; needs guidance_scale=0 for an unconditioned
# detail-preserving upscale of an arbitrary input image.
result = model(prompt="", image=img, num_inference_steps=20,
guidance_scale=0)
return result.images[0]
if backend == 'diffusers': if backend == 'diffusers':
result = model(prompt="", image=img, num_inference_steps=20) result = model(prompt="", image=img, num_inference_steps=20)
return result.images[0] return result.images[0]
...@@ -1808,15 +1959,15 @@ async def create_image_upscale(request: ImageUpscaleRequest, http_request: Reque ...@@ -1808,15 +1959,15 @@ async def create_image_upscale(request: ImageUpscaleRequest, http_request: Reque
model_info = await asyncio.to_thread( model_info = await asyncio.to_thread(
multi_model_manager.request_model, request.model, model_type="image") multi_model_manager.request_model, request.model, model_type="image")
model_name = model_info.get('model_name') or request.model model_name = model_info.get('model_name') or request.model
model_key = f"upscale:{model_name}" upscaler = _UPSCALER_CACHE.get(model_name)
upscaler = multi_model_manager.models.get(model_key)
if upscaler is None: if upscaler is None:
try: try:
upscaler = await asyncio.get_event_loop().run_in_executor( upscaler = await asyncio.get_event_loop().run_in_executor(
None, _load_upscaler, model_name, global_args) None, _load_upscaler, model_name, global_args)
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to load upscaler: {e}") raise HTTPException(status_code=500, detail=f"Failed to load upscaler: {e}")
multi_model_manager.models[model_key] = upscaler if upscaler[0] != 'pil':
_UPSCALER_CACHE[model_name] = upscaler
raw = base64.b64decode(request.image.split(',', 1)[-1] if ',' in request.image else request.image) raw = base64.b64decode(request.image.split(',', 1)[-1] if ',' in request.image else request.image)
try: try:
out_img = await asyncio.get_event_loop().run_in_executor( out_img = await asyncio.get_event_loop().run_in_executor(
......
...@@ -1994,7 +1994,17 @@ def _postprocess_video(mp4_bytes: bytes, request: VideoGenerationRequest, ...@@ -1994,7 +1994,17 @@ def _postprocess_video(mp4_bytes: bytes, request: VideoGenerationRequest,
temp_paths.append(path) temp_paths.append(path)
if request.upscale_output: if request.upscale_output:
path = _ffmpeg_upscale(path, request.upscale_factor or 2, temp_paths) model_name = (getattr(request, 'upscale_model', None) or '').strip() \
or multi_model_manager.find_capable_model(
"video_upscaling", "image_upscaling") or ''
if not model_name:
raise HTTPException(
status_code=400,
detail="upscale_output requested but no AI upscaling model is "
"configured — add a Real-ESRGAN / SwinIR / diffusers "
"upscaler on the model page. There is no ffmpeg fallback.")
path = _model_upscale_video(model_name, path,
request.upscale_factor or 2, temp_paths)
if request.interpolate_output and request.fps_multiplier: if request.interpolate_output and request.fps_multiplier:
path = _rife_interpolate(path, request.fps_multiplier, temp_paths) path = _rife_interpolate(path, request.fps_multiplier, temp_paths)
...@@ -2035,40 +2045,340 @@ def _ffmpeg_upscale(path: str, factor: int, temps: list) -> str: ...@@ -2035,40 +2045,340 @@ def _ffmpeg_upscale(path: str, factor: int, temps: list) -> str:
return out return out
def _video_fps(path: str) -> float:
"""Return the video's frame rate via ffprobe (falls back to 8.0)."""
try:
r = subprocess.run(
['ffprobe', '-v', 'quiet', '-select_streams', 'v:0',
'-show_entries', 'stream=r_frame_rate',
'-of', 'default=noprint_wrappers=1:nokey=1', path],
capture_output=True, text=True)
num, _, den = r.stdout.strip().partition('/')
fps = float(num) / float(den) if den else float(num)
return fps if fps > 0 else 8.0
except Exception:
return 8.0
def _model_upscale_video(model_name: str, in_path: str, factor: int,
temps: list):
"""Super-resolve a video frame-by-frame with the configured upscaler model.
Resolves ``model_name`` through the model manager (so it loads / evicts and
shows up in the model page exactly like any other model), then runs the
Real-ESRGAN / diffusers upscaler on every frame and reassembles the clip at
its original FPS, preserving audio. Returns the output path.
Raises on any failure so the caller can fall back to ffmpeg.
"""
import logging
from codai.api.images import _load_upscaler, _run_upscale, _UPSCALER_CACHE
_log = logging.getLogger(__name__)
# Resolve the upscaler through the manager (thermal + VRAM accounting) but DO
# NOT store the loaded object in manager.models — that registry is keyed by
# real model ids, and a synthetic 'upscale:<id>' key there makes a later
# request_model() resolve to the bogus key and try to reload it. Keep our own
# private cache instead.
info = multi_model_manager.request_model(model_name, model_type="image")
if info.get('error'):
raise RuntimeError(info['error'])
resolved = info.get('model_name') or model_name
upscaler = _UPSCALER_CACHE.get(resolved)
if upscaler is None:
upscaler = _load_upscaler(resolved, global_args)
backend = upscaler[0]
if backend == 'pil':
# No real super-res model resolved → AI-or-fail (no ffmpeg upscale).
raise RuntimeError(
f"'{resolved}' is not a video/image upscaling model")
# Cache only a real, loaded upscaler so it's reused across frames/requests.
_UPSCALER_CACHE[resolved] = upscaler
fps = _video_fps(in_path)
frames_dir = tempfile.mkdtemp()
out_dir = tempfile.mkdtemp()
temps += [frames_dir, out_dir]
r = subprocess.run(['ffmpeg', '-y', '-i', in_path, f'{frames_dir}/%08d.png'],
capture_output=True)
if r.returncode != 0:
raise RuntimeError(
f"frame extraction failed: {r.stderr.decode(errors='replace')}")
import glob
frame_files = sorted(glob.glob(f'{frames_dir}/*.png'))
if not frame_files:
raise RuntimeError("no frames extracted from video")
_log.info("video upscale ×%d via %s (%s) — %d frames",
factor, resolved, backend, len(frame_files))
# Register as a first-class task so it shows in the CoderAI task list and can
# be paused/cancelled like any other model job. (Thermal protection already
# ran at request_model() time, same as every other model.)
_tid = task_registry.register(
"upscale", title=f"Upscale ×{factor} ({len(frame_files)} frames)",
model=resolved, total=len(frame_files))
task_registry.start(_tid)
# Publish per-frame progress through the shared video-progress channel so
# clients polling /v1/video/progress can show "frame X/Y" while we work.
_vid_progress_reset(len(frame_files))
_vid_progress["phase"] = "upscaling"
_vid_progress["model"] = resolved
# Re-check thermals periodically through the frame loop: request_model()
# only waited once at job start, but an upscale runs the GPU hard for many
# frames over minutes, so a long clip could heat up mid-run. Pause to cool
# every _THERMAL_EVERY frames, exactly like a fresh request would.
_THERMAL_EVERY = 32
try:
from codai.models.thermal import wait_until_safe as _wait_until_safe
except Exception:
_wait_until_safe = None
try:
for i, fp in enumerate(frame_files):
task_registry.raise_if_cancelled(_tid)
task_registry.wait_if_paused(_tid)
if _wait_until_safe and i and i % _THERMAL_EVERY == 0:
try:
_wait_until_safe(context=f"upscale:{resolved}")
except Exception:
pass # never let thermal monitoring block the upscale
with open(fp, 'rb') as f:
out_img = _run_upscale(upscaler, f.read(), factor)
out_img.save(os.path.join(out_dir, os.path.basename(fp)))
task_registry.step(_tid, i + 1)
_vid_progress_step(i + 1)
except TaskCancelled:
task_registry.finish(_tid, "cancelled")
raise
except Exception as e:
task_registry.finish(_tid, "error", str(e)[:200])
raise
else:
task_registry.finish(_tid, "done")
finally:
_vid_progress_done()
out = tempfile.mktemp(suffix='_up.mp4')
temps.append(out)
cmd = ['ffmpeg', '-y', '-framerate', f'{fps}', '-i', f'{out_dir}/%08d.png']
# Carry the original audio track if present.
cmd += ['-i', in_path, '-map', '0:v:0', '-map', '1:a:0?',
'-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'copy',
'-shortest', out]
r = subprocess.run(cmd, capture_output=True)
if r.returncode != 0:
raise RuntimeError(
f"reassembly failed: {r.stderr.decode(errors='replace')}")
return out
def _count_video_frames(path: str) -> int:
"""Best-effort total frame count of a video (0 if unknown)."""
try:
r = subprocess.run(
['ffprobe', '-v', 'error', '-select_streams', 'v:0',
'-count_packets', '-show_entries', 'stream=nb_read_packets',
'-of', 'csv=p=0', path], capture_output=True, text=True)
return int(r.stdout.strip())
except Exception:
return 0
def _find_rife_binary():
"""Locate the rife-ncnn-vulkan executable: PATH first, then the common
user/system install dirs (the prebuilt release may not be on the server's
PATH). Returns the path or None."""
import shutil
exe = shutil.which('rife-ncnn-vulkan')
if exe:
return exe
for d in (os.path.expanduser('~/.local/share/rife-ncnn-vulkan'),
os.path.expanduser('~/.local/bin'),
'/opt/rife-ncnn-vulkan', '/usr/local/share/rife-ncnn-vulkan',
'/usr/local/bin'):
p = os.path.join(d, 'rife-ncnn-vulkan')
if os.path.isfile(p) and os.access(p, os.X_OK):
return p
return None
_RIFE_GPU_CACHE: dict = {"id": None}
def _rife_list_devices(rife_bin: str) -> dict:
"""Enumerate the Vulkan devices rife sees, as {ncnn_index: name}. rife prints
these (e.g. '[0 NVIDIA GeForce RTX 3090]') on stderr when it inits the GPU, so
we trigger a tiny 2-frame run and parse them. ncnn's own indexing (not raw
Vulkan order) is what -g expects, which is exactly what this captures."""
import tempfile as _tf, re, glob as _glob
from PIL import Image as _Img
devs = {}
try:
d_in, d_out = _tf.mkdtemp(), _tf.mkdtemp()
_Img.new('RGB', (16, 16)).save(os.path.join(d_in, '00000001.png'))
_Img.new('RGB', (16, 16), (255, 255, 255)).save(os.path.join(d_in, '00000002.png'))
root = os.path.dirname(os.path.realpath(rife_bin))
md = os.path.join(root, 'rife-v4')
r = subprocess.run([rife_bin, '-i', d_in, '-o', d_out, '-n', '2',
'-m', md if os.path.isdir(md) else 'rife-v4'],
capture_output=True, text=True, timeout=120)
for line in (r.stderr or '').splitlines():
m = re.match(r'\[(\d+)\s+(.+?)\]', line.strip())
if m:
devs.setdefault(int(m.group(1)), m.group(2).strip())
for f in _glob.glob(os.path.join(d_out, '*')):
try: os.remove(f)
except Exception: pass
except Exception:
pass
return devs
def _rife_gpu_id(rife_bin: str) -> int:
"""Vulkan/ncnn device id rife should run on — resolved to the SAME physical
GPU coderai uses (by matching the CUDA device name to rife's device list),
not a hardcoded index. Falls back to the first real (non-CPU) GPU, then to the
configured index. Cached after the first resolve."""
if _RIFE_GPU_CACHE["id"] is not None:
return _RIFE_GPU_CACHE["id"]
# The GPU coderai actually runs its diffusers/upscaler models on.
cuda_idx, target = 0, None
try:
v = getattr(global_args, 'image_vulkan_device', None)
if v is None:
v = getattr(global_args, 'vulkan_device', 0)
cuda_idx = int(v or 0)
except Exception:
cuda_idx = 0
try:
import torch
if torch.cuda.is_available():
target = torch.cuda.get_device_name(cuda_idx)
except Exception:
target = None
devs = _rife_list_devices(rife_bin)
gid = None
if devs and target:
t = target.strip().lower()
# Exact name match first, then a loose token match (e.g. '3090').
for i, name in sorted(devs.items()):
if name.strip().lower() == t:
gid = i; break
if gid is None:
tok = t.split()[-1] if t.split() else t
for i, name in sorted(devs.items()):
if tok and tok in name.lower():
gid = i; break
if gid is None and devs:
# No name match → first non-software, non-CPU device.
for i, name in sorted(devs.items()):
nl = name.lower()
if 'llvmpipe' not in nl and 'software' not in nl and 'cpu' not in nl:
gid = i; break
if gid is None:
gid = cuda_idx # last resort: the configured index
import logging
logging.getLogger(__name__).info(
"rife GPU resolved to ncnn device %s (%s) matching coderai GPU '%s'",
gid, devs.get(gid, '?'), target or 'unknown')
_RIFE_GPU_CACHE["id"] = gid
return gid
def _rife_interpolate(path: str, multiplier: int, temps: list) -> str: def _rife_interpolate(path: str, multiplier: int, temps: list) -> str:
"""Raise a video's FPS via the RIFE neural frame interpolator (AI, on the GPU).
AI-or-fail by design: there is NO ffmpeg/minterpolate fallback. If no neural
interpolator is available the call raises so the caller surfaces the error,
exactly like the AI upscaler — interpolation must be a real AI op on CoderAI."""
out = tempfile.mktemp(suffix='_rife.mp4') out = tempfile.mktemp(suffix='_rife.mp4')
temps.append(out) temps.append(out)
import logging, shutil import logging
_log = logging.getLogger(__name__) _log = logging.getLogger(__name__)
if shutil.which('rife-ncnn-vulkan'):
rife_bin = _find_rife_binary()
if not rife_bin:
raise RuntimeError(
"No AI frame interpolator available: 'rife-ncnn-vulkan' is not installed. "
"FPS interpolation must run on an AI model (no ffmpeg fallback) — install "
"rife-ncnn-vulkan, or disable FPS interpolation (fps_multiplier).")
# The release ships its model folders next to the binary; resolve an absolute
# model path so it's found regardless of the server's CWD.
_rife_root = os.path.dirname(os.path.realpath(rife_bin))
_model_dir = os.path.join(_rife_root, 'rife-v4')
_model_arg = _model_dir if os.path.isdir(_model_dir) else 'rife-v4'
in_frames = _count_video_frames(path)
out_frames = in_frames * multiplier if in_frames else 0
# Thermal parity: pause if the machine is hot before a heavy interpolation
# (same guard request_model() applies to model loads).
try:
from codai.models.thermal import wait_until_safe
wait_until_safe(context=f"interpolate:x{multiplier}")
except Exception:
pass
_tid = task_registry.register(
"interpolate", title=f"Interpolate ×{multiplier} FPS"
+ (f" ({out_frames} frames)" if out_frames else ""),
model="rife-ncnn-vulkan", total=out_frames or 1)
task_registry.start(_tid)
try:
frames_dir = tempfile.mkdtemp() frames_dir = tempfile.mkdtemp()
out_dir = tempfile.mkdtemp() out_dir = tempfile.mkdtemp()
temps += [frames_dir, out_dir] temps += [frames_dir, out_dir]
# ffmpeg here only extracts/encodes frames (a muxer), never interpolates —
# the actual interpolation is done by the RIFE neural network.
r = subprocess.run(['ffmpeg', '-y', '-i', path, f'{frames_dir}/%08d.png'], r = subprocess.run(['ffmpeg', '-y', '-i', path, f'{frames_dir}/%08d.png'],
capture_output=True) capture_output=True)
if r.returncode != 0: if r.returncode != 0:
_log.warning("ffmpeg frame extraction failed: %s", r.stderr.decode(errors='replace')) raise RuntimeError(
else: f"frame extraction failed: {r.stderr.decode(errors='replace')}")
r = subprocess.run(['rife-ncnn-vulkan', '-i', frames_dir, '-o', out_dir, # Watch the output frame dir so the (opaque) rife subprocess still
'-m', 'rife-v4'], capture_output=True) # reports progress as it writes interpolated frames.
if r.returncode != 0: _vid_progress_reset(max(1, out_frames))
_log.warning("rife-ncnn-vulkan failed: %s", r.stderr.decode(errors='replace')) _vid_progress["phase"] = "interpolating"
else: import threading, glob as _glob
r = subprocess.run(['ffmpeg', '-y', '-r', str(multiplier * 8), '-i', _stop = threading.Event()
f'{out_dir}/%08d.png', '-c:v', 'libx264', out],
capture_output=True) def _watch():
if r.returncode != 0: while not _stop.is_set():
_log.warning("ffmpeg reassembly failed: %s", r.stderr.decode(errors='replace')) n = len(_glob.glob(f'{out_dir}/*.png'))
elif os.path.exists(out): _vid_progress_step(min(n, out_frames) if out_frames else n)
return out if out_frames:
# Simple ffmpeg minterpolate fallback try: task_registry.step(_tid, min(n, out_frames))
cmd = ['ffmpeg', '-y', '-i', path, '-filter:v', except Exception: pass
f'minterpolate=fps={multiplier * 8}', '-c:a', 'copy', out] _stop.wait(0.5)
r = subprocess.run(cmd, capture_output=True) _w = threading.Thread(target=_watch, daemon=True); _w.start()
if r.returncode != 0: # -n sets the exact target frame count so any multiplier works (rife's
_log.warning("ffmpeg minterpolate failed: %s", r.stderr.decode(errors='replace')) # default only doubles); -g pins the GPU to the configured NVIDIA device.
return path _cmd = [rife_bin, '-i', frames_dir, '-o', out_dir, '-m', _model_arg,
return out '-g', str(_rife_gpu_id(rife_bin))]
if out_frames:
_cmd += ['-n', str(out_frames)]
r = subprocess.run(_cmd, capture_output=True)
_stop.set(); _w.join(timeout=1)
if r.returncode != 0:
raise RuntimeError(
f"rife-ncnn-vulkan failed: {r.stderr.decode(errors='replace')}")
r = subprocess.run(['ffmpeg', '-y', '-r', str(multiplier * 8), '-i',
f'{out_dir}/%08d.png', '-c:v', 'libx264', out],
capture_output=True)
if r.returncode != 0 or not os.path.exists(out):
raise RuntimeError(
f"reassembly failed: {r.stderr.decode(errors='replace')}")
task_registry.finish(_tid, "done")
return out
except TaskCancelled:
task_registry.finish(_tid, "cancelled")
raise
except Exception as e:
task_registry.finish(_tid, "error", str(e)[:200])
raise
finally:
_vid_progress_done()
def _add_audio_to_video(path: str, request: VideoGenerationRequest, def _add_audio_to_video(path: str, request: VideoGenerationRequest,
...@@ -2761,22 +3071,56 @@ async def video_generations(request: VideoGenerationRequest, ...@@ -2761,22 +3071,56 @@ async def video_generations(request: VideoGenerationRequest,
@router.post("/v1/video/upscale", summary="Upscale a video") @router.post("/v1/video/upscale", summary="Upscale a video")
async def video_upscale(request: VideoUpscaleRequest, http_request: Request = None): async def video_upscale(request: VideoUpscaleRequest, http_request: Request = None):
""" """
Upscale a video using ffmpeg lanczos or Real-ESRGAN. Upscale a video with a real super-resolution model.
The model field can be 'realesrgan' or any registered video_upscaling model.
If ``model`` is given it must name an AI upscaler (Real-ESRGAN / SwinIR / a
diffusers upscaler, etc.). If it is omitted, the server auto-selects the
first configured model with a video/image upscaling capability. The model is
loaded through the model manager — exactly like any other model on the model
page — and run on the GPU frame-by-frame. There is **no ffmpeg/CPU
fallback**: if no usable upscaling model is configured the request fails so
the caller knows the operation did not run.
""" """
raw = _decode_b64_or_url(request.video) raw = _decode_b64_or_url(request.video)
factor = request.upscale_factor or 2
model_name = (request.model or "").strip()
if not model_name:
# No model supplied — let CoderAI pick a configured upscaler.
model_name = multi_model_manager.find_capable_model(
"video_upscaling", "image_upscaling") or ""
if not model_name:
raise HTTPException(
status_code=400,
detail="no AI upscaling model is configured — add a Real-ESRGAN / "
"SwinIR / diffusers upscaler on the model page. There is no "
"ffmpeg fallback.")
temps = [] temps = []
try:
def _do_upscale():
in_path = _tmp_write(raw, '.mp4') in_path = _tmp_write(raw, '.mp4')
temps.append(in_path) temps.append(in_path)
out_path = await asyncio.get_event_loop().run_in_executor( return _model_upscale_video(model_name, in_path, factor, temps)
None, _ffmpeg_upscale, in_path, request.upscale_factor or 2, temps)
try:
try:
out_path = await asyncio.get_event_loop().run_in_executor(
None, _do_upscale)
except HTTPException:
raise
except Exception as e:
raise HTTPException(
status_code=400,
detail=f"video upscale failed for model '{model_name}': {e}")
with open(out_path, 'rb') as f: with open(out_path, 'rb') as f:
out_bytes = f.read() out_bytes = f.read()
finally: finally:
import shutil
for t in temps: for t in temps:
try: try:
os.unlink(t) if os.path.isdir(t):
shutil.rmtree(t, ignore_errors=True)
else:
os.unlink(t)
except Exception: except Exception:
pass pass
......
...@@ -184,6 +184,16 @@ configuration directory (--config DIR, default: OS-specific CoderAI directory). ...@@ -184,6 +184,16 @@ configuration directory (--config DIR, default: OS-specific CoderAI directory).
default=default_config, default=default_config,
help=f"Configuration directory (default: {default_config})", help=f"Configuration directory (default: {default_config})",
) )
parser.add_argument(
"--tmp",
type=str,
default=None,
metavar="DIR",
help="Base directory for temporary working files (frame extraction, "
"upscaling, interpolation). Overrides config.tmp_dir. Use a large "
"volume when /tmp is small — 4x upscaling can exhaust a small /tmp "
"('No space left on device').",
)
parser.add_argument( parser.add_argument(
"--debug", "--debug",
action="store_true", action="store_true",
......
...@@ -163,6 +163,11 @@ class Config: ...@@ -163,6 +163,11 @@ class Config:
tools_closer_prompt: bool = False tools_closer_prompt: bool = False
grammar_guided: bool = False grammar_guided: bool = False
file_path: Optional[str] = None file_path: Optional[str] = None
# Base directory for temporary working files (frame extraction, upscaling,
# interpolation, etc.). None/empty = the OS default (usually /tmp). Point it at
# a large-capacity volume when /tmp is small — 4× upscaling extracts many large
# frames and can exhaust a small /tmp ("No space left on device").
tmp_dir: Optional[str] = None
hf_chat_templates: list = field(default_factory=list) hf_chat_templates: list = field(default_factory=list)
reasoning_options: list = field(default_factory=list) reasoning_options: list = field(default_factory=list)
parser: str = "auto" parser: str = "auto"
...@@ -318,6 +323,7 @@ class ConfigManager: ...@@ -318,6 +323,7 @@ class ConfigManager:
tools_closer_prompt=config_data.get("tools_closer_prompt", False), tools_closer_prompt=config_data.get("tools_closer_prompt", False),
grammar_guided=config_data.get("grammar_guided", False), grammar_guided=config_data.get("grammar_guided", False),
file_path=config_data.get("file_path"), file_path=config_data.get("file_path"),
tmp_dir=config_data.get("tmp_dir"),
hf_chat_templates=config_data.get("hf_chat_templates", []), hf_chat_templates=config_data.get("hf_chat_templates", []),
reasoning_options=config_data.get("reasoning_options", []), reasoning_options=config_data.get("reasoning_options", []),
parser=config_data.get("parser", "auto") parser=config_data.get("parser", "auto")
...@@ -457,6 +463,7 @@ class ConfigManager: ...@@ -457,6 +463,7 @@ class ConfigManager:
"tools_closer_prompt": self.config.tools_closer_prompt, "tools_closer_prompt": self.config.tools_closer_prompt,
"grammar_guided": self.config.grammar_guided, "grammar_guided": self.config.grammar_guided,
"file_path": self.config.file_path, "file_path": self.config.file_path,
"tmp_dir": self.config.tmp_dir,
"hf_chat_templates": self.config.hf_chat_templates, "hf_chat_templates": self.config.hf_chat_templates,
"reasoning_options": self.config.reasoning_options, "reasoning_options": self.config.reasoning_options,
"parser": self.config.parser "parser": self.config.parser
......
...@@ -272,6 +272,24 @@ def main(): ...@@ -272,6 +272,24 @@ def main():
config_mgr = ConfigManager(config_dir) config_mgr = ConfigManager(config_dir)
config = config_mgr.load() config = config_mgr.load()
# Configure the temporary-working-files directory as early as possible (before
# any tempfile.* call). CLI --tmp overrides config.tmp_dir. Setting both
# tempfile.tempdir and TMPDIR/TMP/TEMP makes every tempfile.* call AND child
# processes (ffmpeg, rife) use it — so 4x upscaling doesn't fill a small /tmp.
_tmp_dir = getattr(args, "tmp", None) or getattr(config, "tmp_dir", None)
if _tmp_dir:
try:
_tmp_dir = os.path.abspath(os.path.expanduser(_tmp_dir))
os.makedirs(_tmp_dir, exist_ok=True)
import tempfile as _tempfile
_tempfile.tempdir = _tmp_dir
os.environ["TMPDIR"] = _tmp_dir
os.environ["TMP"] = _tmp_dir
os.environ["TEMP"] = _tmp_dir
print(f"Temporary working directory: {_tmp_dir}")
except Exception as _e:
print(f"WARNING: could not use tmp dir '{_tmp_dir}': {_e} — using OS default")
# Apply cache directory overrides from config before any cache module is used. # Apply cache directory overrides from config before any cache module is used.
# We set env vars AND patch huggingface_hub.constants in case the library was # We set env vars AND patch huggingface_hub.constants in case the library was
# already imported (constants are computed once at import time from env vars). # already imported (constants are computed once at import time from env vars).
......
...@@ -3263,6 +3263,22 @@ class MultiModelManager: ...@@ -3263,6 +3263,22 @@ class MultiModelManager:
return self.models.get(self.default_model) return self.models.get(self.default_model)
return None return None
def find_capable_model(self, *capabilities: str) -> Optional[str]:
"""Return the id of the first configured model whose capabilities include
any of the requested ones, in the priority order given. Used to auto-pick
a default for endpoints that have no per-type default list (e.g. video
upscaling). Returns None when nothing configured matches."""
try:
infos = self.list_models()
except Exception:
return None
for want in capabilities:
for info in infos:
caps = getattr(info, "capabilities", None) or []
if want in caps:
return info.id
return None
def list_models(self) -> List[ModelInfo]: def list_models(self) -> List[ModelInfo]:
"""List all available models (configured + runtime aliases) with type/capability metadata.""" """List all available models (configured + runtime aliases) with type/capability metadata."""
from codai.models.capabilities import detect_model_capabilities, ModelCapabilities from codai.models.capabilities import detect_model_capabilities, ModelCapabilities
......
...@@ -162,7 +162,7 @@ class VideoGenerationResponse(BaseModel): ...@@ -162,7 +162,7 @@ class VideoGenerationResponse(BaseModel):
# ── Standalone operation requests ───────────────────────────────────────────── # ── Standalone operation requests ─────────────────────────────────────────────
class VideoUpscaleRequest(BaseModel): class VideoUpscaleRequest(BaseModel):
model: str model: Optional[str] = None # omit → server auto-selects a configured upscaler
video: str # base64/URL input video video: str # base64/URL input video
upscale_factor: Optional[int] = 2 upscale_factor: Optional[int] = 2
response_format: Optional[str] = "url" response_format: Optional[str] = "url"
...@@ -182,7 +182,7 @@ class VideoSubtitleRequest(BaseModel): ...@@ -182,7 +182,7 @@ class VideoSubtitleRequest(BaseModel):
class VideoInterpolateRequest(BaseModel): class VideoInterpolateRequest(BaseModel):
model: str model: Optional[str] = None # interpolation runs via RIFE/ffmpeg; model is advisory
video: Optional[str] = None # base64/URL input video (mutually exclusive with init/end) video: Optional[str] = None # base64/URL input video (mutually exclusive with init/end)
init_image: Optional[str] = None # first frame init_image: Optional[str] = None # first frame
end_image: Optional[str] = None # last frame end_image: Optional[str] = None # last frame
......
This source diff could not be displayed because it is too large. You can view the blob instead.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment