township: generate a match shot with LongCat in one request (v0.2.38)

Plan item I, the last of the LongCat work.

The tool chains clips because the server generates one clip per request and VACE
frame-tail conditioning is the only continuation a Wan model has. That machinery exists
to fight drift — the dual-expert and VACE-extension notes are both about chained parts
going wrong — and every join is a re-encode.

LongCat was pretrained on continuation, so:

- the client-side split is LIFTED for it (_chunk_max becomes unbounded): a clip's whole
  frame budget goes in ONE request and the server runs the segment loop. Chaining it from
  here would reintroduce exactly the drift the chaining was invented to avoid. Wan/VACE
  keeps the cap it always had;
- between DELIBERATE shots the tool still chains, because a cut is a cut — but LongCat is
  tail-capable too, so it takes the same cond_frames handover. What differs is underneath,
  not the call;
- the tail length follows the model: 13 of 93 frames, which is what LongCat's continuation
  pretraining used, against VACE's 5. Handing it 5 would condition it on less than it
  expects. The constant is asserted against tools/longcat_common.py so the two cannot
  drift apart;
- the log says which continuation produced a shot, because diagnosing a seam starts
  there.

Server side, so a client asks for the frames it wants rather than reimplementing the
loop: num_frames is the PER-CALL count, and a request for more than one call's worth now
derives total_frames and lets the segment loop cover it. An explicit num_segments or
duration_seconds still wins. Passing 400 straight through would have asked the pipeline
for a 400-frame single call.

The uploader and the odds generation are untouched — they are downstream of the frames.

17 tests. No match has been rendered with it: that needs the weights.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182DhR1eFNPQyGmDrpHbedY
parent 15d208a7
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
# Canonical product version for CoderAI — single source of truth. Both the API # Canonical product version for CoderAI — single source of truth. Both the API
# metadata and the admin web UI read from here. # metadata and the admin web UI read from here.
__version__ = "0.2.37" __version__ = "0.2.38"
__tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference." __tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere. # Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
...@@ -4138,15 +4138,21 @@ async def _generate_longcat(request: VideoGenerationRequest, model_name: str, ...@@ -4138,15 +4138,21 @@ async def _generate_longcat(request: VideoGenerationRequest, model_name: str,
f"LongCat-Video does not support mode '{mode}'. Use 't2v', 'i2v', " f"LongCat-Video does not support mode '{mode}'. Use 't2v', 'i2v', "
f"'extend' (continuation), or 'at2v'/'ai2v' (audio-driven avatar).")) f"'extend' (continuation), or 'at2v'/'ai2v' (audio-driven avatar)."))
# num_frames is the PER-CALL count (93 by default). A caller asking for more than
# one call's worth gets the segment loop, server-side: the alternative is every
# client reimplementing the chaining that LongCat does natively and better.
_want = int(request.num_frames) if request.num_frames else lc.DEFAULT_NUM_FRAMES
_per_call = min(_want, lc.DEFAULT_NUM_FRAMES)
payload = { payload = {
"prompt": request.prompt or "", "prompt": request.prompt or "",
"quality": quality, "quality": quality,
"stage": stage, "stage": stage,
"model": model_path, "model": model_path,
"task": _task, "task": _task,
"num_frames": int(request.num_frames) if request.num_frames "num_frames": _per_call,
else lc.DEFAULT_NUM_FRAMES,
} }
if _want > _per_call:
payload["total_frames"] = _want
if request.negative_prompt: if request.negative_prompt:
payload["negative_prompt"] = request.negative_prompt payload["negative_prompt"] = request.negative_prompt
...@@ -4202,6 +4208,7 @@ async def _generate_longcat(request: VideoGenerationRequest, model_name: str, ...@@ -4202,6 +4208,7 @@ async def _generate_longcat(request: VideoGenerationRequest, model_name: str,
_segments = getattr(request, "num_segments", None) or model_cfg.get("num_segments") _segments = getattr(request, "num_segments", None) or model_cfg.get("num_segments")
if _segments: if _segments:
payload["num_segments"] = int(_segments) payload["num_segments"] = int(_segments)
payload.pop("total_frames", None)
elif getattr(request, "duration_seconds", None): elif getattr(request, "duration_seconds", None):
payload["total_frames"] = int(float(request.duration_seconds) * lc.DEFAULT_FPS) payload["total_frames"] = int(float(request.duration_seconds) * lc.DEFAULT_FPS)
_cond = getattr(request, "num_cond_frames", None) or model_cfg.get("num_cond_frames") _cond = getattr(request, "num_cond_frames", None) or model_cfg.get("num_cond_frames")
......
"""The township match generator, upgraded for LongCat-Video.
The tool chains clips because the server generates one clip per request and VACE
frame-tail conditioning is the only continuation a Wan model has. That machinery exists
to work around drift: the `project_wan22_dual_expert_lora` and `project_vace_extension`
notes are both about chained parts going wrong.
LongCat was pretrained on continuation, so the server can generate a long shot itself —
one request, no re-encoded joins, no accumulating drift. Chaining it from the client would
reintroduce exactly what the chaining was invented to avoid.
This reads the tool's source rather than running it: a match render needs models, weights
and a GPU.
"""
import importlib.util
import re
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
TOOL = (ROOT / "tools/gen_township_fighters.py").read_text()
VIDEO = (ROOT / "codai/api/video.py").read_text()
def _contract():
spec = importlib.util.spec_from_file_location(
"longcat_common", ROOT / "tools/longcat_common.py")
m = importlib.util.module_from_spec(spec)
spec.loader.exec_module(m)
return m
LC = _contract()
# ------------------------------------------------------------ the tool
def test_the_tool_recognises_a_longcat_model():
assert '_longcat = "longcat" in (video_model or "").lower()' in TOOL
def test_the_client_side_split_is_lifted_for_longcat():
"""_split_frame_budget(sn, _chunk_max) is the chaining. LongCat does it server-side
and better, so the cap must not force parts."""
seg = TOOL[TOOL.index("_longcat = "):TOOL.index("def _render_once")]
assert "_chunk_max = 1 << 30" in seg
# …and the non-LongCat path keeps the cap it always had.
assert "SINGLE_CLIP_MAX_FRAMES" in seg and "MODEL_MAX_FRAMES" in seg
def test_other_models_are_unaffected():
"""A Wan/VACE run must behave exactly as before."""
seg = TOOL[TOOL.index("_longcat = "):TOOL.index("def _render_once")]
assert "else:" in seg
assert "max(8, min(int(single_clip_max_frames" in seg
def test_longcat_continues_from_a_tail_like_vace_does():
"""Between deliberate shots the tool still chains — a cut is a cut. What differs is
what happens underneath, not the call."""
assert "_tail_capable = _vace or _longcat" in TOOL
assert "cond_frames = prev_tail if (_tail_capable and pi > 0) else None" in TOOL
def test_the_tail_length_matches_what_longcat_was_trained_with():
"""VACE's 5 frames were tuned for its conditioning; LongCat's continuation
pretraining used 13 of 93, and handing it fewer conditions it on less than it
expects."""
got = int(re.search(r"LONGCAT_COND_FRAMES = (\d+)", TOOL).group(1))
assert got == LC.DEFAULT_COND_FRAMES
assert "_tail_frames = LONGCAT_COND_FRAMES if _longcat else VACE_TAIL_FRAMES" in TOOL
def test_the_vace_tail_length_is_untouched():
assert re.search(r"VACE_TAIL_FRAMES = 5", TOOL)
def test_the_log_names_which_continuation_is_in_use():
"""Diagnosing a seam starts with knowing which mechanism produced it."""
assert "LongCat native continuation" in TOOL
assert "VACE frame-tail extend" in TOOL
def test_the_uploader_and_odds_are_untouched():
"""They are downstream of the frames; this change must not reach them."""
for marker in ("def _scan_matches", "concat_videos"):
assert marker in TOOL
# ------------------------------------------------------------ the server side
def test_a_long_request_is_segmented_server_side():
"""So a client asks for the frames it wants instead of reimplementing the loop."""
fn = VIDEO[VIDEO.index("async def _generate_longcat"):]
fn = fn[:fn.index("\nasync def ")]
assert "_per_call = min(_want, lc.DEFAULT_NUM_FRAMES)" in fn
assert '"total_frames"' in fn
def test_the_per_call_count_is_capped_at_one_segment():
"""num_frames is the PER-CALL count; passing 400 straight through would ask the
pipeline for a 400-frame single call."""
fn = VIDEO[VIDEO.index("async def _generate_longcat"):]
fn = fn[:fn.index("\nasync def ")]
seg = fn[fn.index("_want ="):fn.index("payload = {")]
assert "min(" in seg
def test_an_explicit_segment_count_still_wins():
fn = VIDEO[VIDEO.index("async def _generate_longcat"):]
fn = fn[:fn.index("\nasync def ")]
assert 'payload.pop("total_frames", None)' in fn
@pytest.mark.parametrize("want,expected_segments", [
(93, 1), (94, 2), (173, 2), (174, 3), (893, 11),
])
def test_the_segment_arithmetic_covers_a_requested_length(want, expected_segments):
"""What the server computes from total_frames."""
assert LC.segments_for(want) == expected_segments
def test_a_minute_of_video_is_one_request():
"""The point of the change: ~900 frames used to be ~11 chained parts with a
re-encoded join between each."""
assert LC.segments_for(900) >= 11
assert LC.frames_for(LC.segments_for(900)) >= 900
...@@ -1633,6 +1633,9 @@ MAX_PLANNED_FRAMES = 480 # ceiling for a whole (possibly chained) clip ...@@ -1633,6 +1633,9 @@ MAX_PLANNED_FRAMES = 480 # ceiling for a whole (possibly chained) clip
# 'extend' conditioning. More frames = stronger motion-continuity (kills the # 'extend' conditioning. More frames = stronger motion-continuity (kills the
# forward/rewind boomerang) but more conditioning cost; ~5 carries velocity well. # forward/rewind boomerang) but more conditioning cost; ~5 carries velocity well.
VACE_TAIL_FRAMES = 5 VACE_TAIL_FRAMES = 5
# LongCat-Video conditions a continuation on 13 of its 93 frames — the number its
# continuation pretraining used. See tools/longcat_common.py (DEFAULT_COND_FRAMES).
LONGCAT_COND_FRAMES = 13
# Per fight-clip frame budget. Frame count (not seconds) is the real control: it's # Per fight-clip frame budget. Frame count (not seconds) is the real control: it's
# the model's motion budget and is fps-independent, so a clip is CLIP_*_FRAMES # the model's motion budget and is fps-independent, so a clip is CLIP_*_FRAMES
...@@ -4334,8 +4337,18 @@ def _stage_videos_render(client, video_model, video_dir, fight_plan, outcome_pla ...@@ -4334,8 +4337,18 @@ def _stage_videos_render(client, video_model, video_dir, fight_plan, outcome_pla
# Max frames per SINGLE model generation. A clip whose budget exceeds this is # Max frames per SINGLE model generation. A clip whose budget exceeds this is
# rendered as several chained sub-renders and concatenated into ONE shot. # rendered as several chained sub-renders and concatenated into ONE shot.
_chunk_max = max(8, min(int(single_clip_max_frames or SINGLE_CLIP_MAX_FRAMES), #
MODEL_MAX_FRAMES)) # LongCat-Video is the exception, and it is why this cap exists at all: it was
# pretrained on video CONTINUATION, so the server generates a long shot in segments
# itself — one request, native continuation, no re-encoded joins. Chaining it from
# here would reintroduce exactly the drift the cap works around, so the cap is
# lifted and the whole budget goes in one request.
_longcat = "longcat" in (video_model or "").lower()
if _longcat:
_chunk_max = 1 << 30
else:
_chunk_max = max(8, min(int(single_clip_max_frames or SINGLE_CLIP_MAX_FRAMES),
MODEL_MAX_FRAMES))
def _render_once(label, prompt, profiles, env, nf, out_path, def _render_once(label, prompt, profiles, env, nf, out_path,
fighters=None, init_override=None, step_cb=None, fighters=None, init_override=None, step_cb=None,
...@@ -4456,12 +4469,23 @@ def _stage_videos_render(client, video_model, video_dir, fight_plan, outcome_pla ...@@ -4456,12 +4469,23 @@ def _stage_videos_render(client, video_model, video_dir, fight_plan, outcome_pla
# (real motion → carries velocity forward), the proper fix for the # (real motion → carries velocity forward), the proper fix for the
# single-frame "boomerang". Non-VACE models fall back to single last-frame # single-frame "boomerang". Non-VACE models fall back to single last-frame
# seeding + the forward-motion prompt nudge. # seeding + the forward-motion prompt nudge.
# Both VACE and LongCat continue from a frame TAIL rather than a single frame.
# The difference is what happens underneath: VACE is conditioned frame-tail
# extension bolted onto a t2v model, LongCat was pretrained on continuation —
# which is why its long output does not drift in colour or decay the way chained
# VACE parts do. Either way the tool passes the same tail.
_vace = "vace" in (video_model or "").lower() _vace = "vace" in (video_model or "").lower()
_tail_capable = _vace or _longcat
# How much tail to hand over. VACE's 5 frames were tuned for its conditioning;
# LongCat's own default is 13 of 93, which is what its continuation was trained
# with — handing it fewer would condition it on less than it expects.
_tail_frames = LONGCAT_COND_FRAMES if _longcat else VACE_TAIL_FRAMES
_nparts = len(parts_plan) _nparts = len(parts_plan)
_budget = [p[1] for p in parts_plan] _budget = [p[1] for p in parts_plan]
_log(f" ↪ chaining {_nparts} parts {_budget} into one shot" _log(f" ↪ chaining {_nparts} parts {_budget} into one shot"
+ (" [multi-shot sequence]" if segments and len(seglist) > 1 else "") + (" [multi-shot sequence]" if segments and len(seglist) > 1 else "")
+ (" [VACE frame-tail extend]" if _vace else "")) + (" [LongCat native continuation]" if _longcat
else " [VACE frame-tail extend]" if _vace else ""))
tmpd = tempfile.mkdtemp(prefix="twshot_") tmpd = tempfile.mkdtemp(prefix="twshot_")
parts, prev_last, prev_tail = [], None, None parts, prev_last, prev_tail = [], None, None
try: try:
...@@ -4485,9 +4509,9 @@ def _stage_videos_render(client, video_model, video_dir, fight_plan, outcome_pla ...@@ -4485,9 +4509,9 @@ def _stage_videos_render(client, video_model, video_dir, fight_plan, outcome_pla
cond_frames = None cond_frames = None
else: else:
seed_img = keyframe if pi == 0 else (prev_last or keyframe) seed_img = keyframe if pi == 0 else (prev_last or keyframe)
cond_frames = prev_tail if (_vace and pi > 0) else None cond_frames = prev_tail if (_tail_capable and pi > 0) else None
if cond_frames: if cond_frames:
seed_img = None # VACE conditions via the tail, not an init frame seed_img = None # conditioned by the tail, not an init frame
if pi == 0 or anchored: if pi == 0 or anchored:
# First part, or a segment anchored to its own keyframe → render # First part, or a segment anchored to its own keyframe → render
# the shot's own prompt verbatim (a deliberate, freshly-composed # the shot's own prompt verbatim (a deliberate, freshly-composed
...@@ -4517,7 +4541,8 @@ def _stage_videos_render(client, video_model, video_dir, fight_plan, outcome_pla ...@@ -4517,7 +4541,8 @@ def _stage_videos_render(client, video_model, video_dir, fight_plan, outcome_pla
parts.append(part_path) parts.append(part_path)
# Prepare seeds for the NEXT part. # Prepare seeds for the NEXT part.
prev_last = _last_frame_png(part_path) prev_last = _last_frame_png(part_path)
prev_tail = _last_frames_png(part_path, VACE_TAIL_FRAMES) if _vace else None prev_tail = (_last_frames_png(part_path, _tail_frames)
if _tail_capable else None)
if pi < _nparts - 1 and not prev_last and not prev_tail: if pi < _nparts - 1 and not prev_last and not prev_tail:
_log(" ⚠ could not read part's tail — next part falls " _log(" ⚠ could not read part's tail — next part falls "
"back to the clip keyframe (possible visible seam)") "back to the clip keyframe (possible visible seam)")
......
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