longcat: i2v, continuation, long video — against the real pipeline API (v0.2.33)

Step 4. It also CORRECTS step 2, whose generate() was written against an assumed
interface. The real one, read from upstream's demo scripts, differs in four ways that all
mattered:

- there is no single callable: generate_t2v(height, width, …), generate_i2v(image,
  resolution, …), generate_vc(video, num_cond_frames, use_kv_cache, offload_kv_cache, …)
  and generate_refine(stage1_video, spatial_refine_only, …) are separate methods, and
  t2v takes height/width where i2v and vc take a resolution NAME;
- each returns [0]-indexed frames normalised to [0,1], not an object with .frames —
  upstream converts with (tensor * 255).clamp(0,255).to(uint8);
- the distilled stage is a use_distill=True FLAG on the same method, not a LoRA to fuse
  by hand, so _apply_stage_lora is gone;
- the 50-step passes take a negative_prompt and the distilled one does not (its guidance
  is 1.0, so it would do nothing).

On top of that:
- The segment loop is upstream's: a call emits num_frames but its first num_cond_frames
  re-render the previous tail, so only the remainder is new and the fresh frames become
  the next call's conditioning. torch_gc() between segments, as upstream does.
- Modes map onto the tasks: t2v, i2v/ti2v -> i2v, extend/vc/v2v -> vc. Continuation
  reuses the EXISTING cond_frames field, so a client that already chains clips through
  the VACE path needs no new vocabulary — and gets native pretrained continuation
  instead, which is what stops the colour drift chained conditioning suffers.
- Long video: num_segments or duration_seconds, and the SERVER owns the loop. The
  response carries the tail a caller needs to continue by hand if it prefers to.
- The segment boundary is now a real handover point. POST /yield asks a generation to
  stop at the next boundary; the releaser calls it before waiting, so an eviction
  arriving mid-request costs one segment instead of the whole generation, and the request
  still returns — shorter, with a warning that reaches the caller — rather than failing.

Unsupported modes (the audio-driven avatar tasks) are still refused with a 400 rather
than downgraded to t2v, which would read as a quality bug rather than a gap.

23 new tests pinning the upstream call contract so it cannot drift back, plus the segment
arithmetic and the yield behaviour. Still no real generation: that needs the venv, the
repo checkout and the ~83 GB checkpoint.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182DhR1eFNPQyGmDrpHbedY
parent 409880f3
......@@ -16,7 +16,7 @@
# Canonical product version for CoderAI — single source of truth. Both the API
# metadata and the admin web UI read from here.
__version__ = "0.2.32"
__version__ = "0.2.33"
__tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
......@@ -556,6 +556,20 @@ def generate(model_name: str, payload: dict, config: dict = None,
# ── eviction: giving the card back ───────────────────────────────────────────
def _request_yield(key: str) -> bool:
"""Ask a service to stop at its next segment boundary. Best-effort."""
import requests
with _lock:
svc = _services.get(key)
url = svc["url"] if svc else ""
if not url:
return False
try:
return bool(requests.post(url + "/yield", json={}, timeout=5).ok)
except Exception:
return False
def _drain_timeout_s() -> float:
sec = _cfg_section()
try:
......@@ -585,8 +599,14 @@ def release_vram(needed_gb: float = 999.0) -> float:
return 0.0
if busy:
budget = _drain_timeout_s()
print(f"[longcat] {busy} generation(s) in flight — waiting up to {budget:.0f}s "
f"for a clean handover before releasing the card", flush=True)
# Ask the services to stop at their next SEGMENT boundary. Upstream already calls
# torch_gc() there, so it is a genuinely safe point, and it turns the wait from
# "however long the whole request takes" into "one more segment" — the in-flight
# request still returns, shorter, with a warning, instead of being lost.
for key in keys:
_request_yield(key)
print(f"[longcat] {busy} generation(s) in flight — asked them to yield at the "
f"next segment boundary, waiting up to {budget:.0f}s", flush=True)
_idle.clear()
if not _idle.wait(budget):
print(f"[longcat] still busy after {budget:.0f}s — releasing anyway; the "
......
......@@ -4121,23 +4121,57 @@ async def _generate_longcat(request: VideoGenerationRequest, model_name: str,
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
# coderai's request modes map onto upstream's three entry points. 'extend' is the
# existing VACE vocabulary for "continue from these frames", and LongCat serves it
# natively — generate_vc is pretrained for continuation, which is why it does not
# drift in colour or quality the way chained frame-tail conditioning does.
mode = (request.mode or "t2v").lower()
if mode not in ("t2v", ""):
# Honest refusal beats a silent downgrade: the payload for these is designed but
# the worker does not implement them yet.
_task = {"t2v": "t2v", "": "t2v",
"i2v": "i2v", "ti2v": "i2v",
"extend": "vc", "vc": "vc", "v2v": "vc"}.get(mode)
if _task is None:
raise HTTPException(status_code=400, detail=(
f"LongCat-Video mode '{mode}' is not implemented yet — this build supports "
f"text-to-video. Image-to-video, continuation and the avatar tasks are the "
f"next increment."))
f"LongCat-Video does not support mode '{mode}'. Use 't2v', 'i2v' or "
f"'extend' (continuation); the audio-driven avatar tasks are a separate "
f"increment."))
payload = {
"prompt": request.prompt or "",
"quality": quality,
"stage": stage,
"model": model_path,
"task": _task,
"num_frames": int(request.num_frames) if request.num_frames
else lc.DEFAULT_NUM_FRAMES,
}
if request.negative_prompt:
payload["negative_prompt"] = request.negative_prompt
# i2v takes one conditioning image; coderai accepts it as init_image or image.
if _task == "i2v":
_first = request.init_image or request.image
if not _first:
raise HTTPException(status_code=400, detail=(
"mode 'i2v' needs init_image (or image)"))
payload["image"] = _as_b64(_first)
# Continuation takes the previous clip's tail — the same `cond_frames` field the VACE
# path uses, so a client that already chains clips needs no new vocabulary.
if _task == "vc":
if not request.cond_frames:
raise HTTPException(status_code=400, detail=(
"mode 'extend' needs cond_frames — the previous clip's last frames"))
payload["cond_frames_b64"] = [_as_b64(x) for x in request.cond_frames]
# Long video: ask for a duration or a segment count and the server owns the loop,
# rather than the caller chaining requests and tracking tails itself.
_segments = getattr(request, "num_segments", None)
if _segments:
payload["num_segments"] = int(_segments)
elif getattr(request, "duration_seconds", None):
payload["total_frames"] = int(float(request.duration_seconds) * lc.DEFAULT_FPS)
if getattr(request, "num_cond_frames", None):
payload["num_cond_frames"] = int(request.num_cond_frames)
if not payload["prompt"]:
raise HTTPException(status_code=400, detail="prompt is required")
if request.width and request.height:
......@@ -4195,6 +4229,10 @@ async def _generate_longcat(request: VideoGenerationRequest, model_name: str,
frames_made = data.get("num_frames")
fps_used = int(data.get("fps") or lc.DEFAULT_FPS)
stages_run = data.get("stages") or list(stages)
if data.get("warning"):
# e.g. it yielded the GPU at a segment boundary and the video is shorter than
# asked for. The caller gets the video AND the reason, not a silent surprise.
warnings.append(str(data["warning"]))
if not mp4_bytes:
raise HTTPException(status_code=500, detail="LongCat-Video returned no video")
print(f" [longcat] {'+'.join(stages_run)}: {frames_made} frames "
......
......@@ -87,6 +87,21 @@ class VideoGenerationRequest(BaseModel):
# 480p, a distilled 16-step pass, then a 720p refinement that takes the earlier
# frames as input — so a caller either picks a preset and lets the server run the
# stages, or drives one stage per request and keeps the intermediate frames.
# Long video. LongCat generates in segments whose first `num_cond_frames` re-render
# the previous segment's tail, so asking for a duration or a segment count lets the
# SERVER own the loop — a client no longer has to chain requests and track tails,
# which is what the VACE 'extend' path requires.
num_segments: Optional[int] = Field(None, description=(
"LongCat-Video: how many segments to generate. Each adds "
"(num_frames - num_cond_frames) new frames; ~11 segments is a minute. "
"Overrides `duration_seconds`."))
duration_seconds: Optional[float] = Field(None, description=(
"LongCat-Video: target length in seconds, converted to a segment count. "
"Ignored when `num_segments` is given."))
num_cond_frames: Optional[int] = Field(None, description=(
"LongCat-Video: how many frames of the previous segment each new segment "
"conditions on (default 13 of 93). More = stronger motion continuity, fewer "
"new frames per segment."))
quality: Optional[str] = Field(None, description=(
"LongCat-Video quality preset: 'draft' (base stage only, fastest), 'fast' "
"(distilled, 16 steps — the default), 'best' (base + 720p refinement). Ignored "
......
This diff is collapsed.
......@@ -273,10 +273,12 @@ def test_the_request_model_accepts_the_staging_fields():
assert VideoGenerationRequest(model="m", prompt="p").quality is None
def test_unimplemented_modes_are_refused_not_downgraded():
"""Step 2 is t2v only. A silent downgrade would look like a quality bug."""
def test_unsupported_modes_are_refused_not_downgraded():
"""t2v, i2v and continuation are implemented; the audio-driven avatar tasks are not.
A silent downgrade to t2v would look like a quality bug rather than a gap."""
src = (ROOT / "codai/api/video.py").read_text()
fn = src[src.index("async def _generate_longcat"):]
fn = fn[:fn.index("\nasync def ")]
assert "is not implemented yet" in fn
assert "does not support mode" in fn
assert "status_code=400" in fn
assert "avatar" in fn.lower() # named as the separate increment it is
This diff is collapsed.
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