video: a model says how long its clips can be (v0.3.4)

Township's clip lengths were tuned when the only video model could hold ~81
frames in one call, so a 70-second match became a dozen short cuts. LongCat was
pretrained on continuation and the server renders a long shot in segments, so
the same match wants a handful of long takes — and the frame numbers that suit
one are wrong for the other by a factor of four. Only the two starter templates
knew that, and only because someone typed it in.

Worse, nothing snapped a picked count onto what the model can emit:
random.randint handed the server lengths like 250, which is not 4n+1 for any
VAE and not on LongCat's 93+80k segment grid, so the server rounded and the
plan's own numbers became fiction. On LongCat that also silently costs
block-sparse attention, because every point of 93+80k is a 16n+13 count BSA
can tile and nothing between them is.

Server — codai/models/video_geometry.py is the geometry once: native rate, the
legal frame grid, how much ONE render holds, whether continuation is native,
and the side multiple below which the fast attention path is lost. Published
per model on /v1/models (ModelInfo.video) and used by the render path itself,
so what the server advertises and what it rounds to cannot drift. The numbers
were spread across three modules that could not tell a client; the literals
here are pinned to each source by tests, since the LongCat and H3 workers run
in 3.10 venvs this cannot import. models.json overrides every field.

Township — the five frame fields now default to 0 = AUTO: derive the budget
from the model's geometry and this tool's seconds band, then snap both ends and
every pick onto the model's grid. Seconds, not frames, because clip count falls
out of long_target / (frames/fps). An explicit number still wins, so a saved
config keeps exactly the budget it had. Entrances and the face-off get their own
shorter band instead of borrowing the fight budget (on LongCat that made every
entrance an 11-22s take). Outcome shares are snapped per shot and the total
rewritten, the per-render chunk comes from the model (Wan keeps its
conservative 50), and the chaining decision reads the published continuation
rather than matching the model's name.

At the defaults a 70s match goes from 9-11 Wan cuts to 4-6 LongCat takes, each
one request with native continuation, with BSA still on.

1607 tests pass; the 17 failures and the test_broker_protocol hang are
pre-existing and unrelated (verified with the diff stashed).
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
parent 69240b20
......@@ -141,6 +141,48 @@ Response shape:
}
```
A **video** model carries one extra object, `video`, describing what it can actually
emit — so a client can plan around the model instead of guessing from its name:
```json
{
"id": "longcat",
"type": "video",
"capabilities": ["video_generation"],
"video": {
"family": "longcat",
"native_fps": 15,
"frame_base": 93,
"frame_step": 80,
"min_frames": 93,
"max_frames": null,
"max_frames_per_render": 93,
"segment_frames": 93,
"cond_frames": 13,
"side_multiple": 64,
"continuation": "native"
}
}
```
| Field | Meaning |
|---|---|
| `native_fps` | The rate the model was trained to produce |
| `frame_base`, `frame_step` | Legal TOTAL frame counts are `frame_base + frame_step * k`. Wan is `1 + 4k` (its VAE's 4n+1), LongCat `93 + 80k` (one 93-frame segment, each continuation re-rendering 13 of them and so adding 80), MiniMax-H3 `5 + 17k` |
| `min_frames`, `max_frames` | Limits on the total; `null` = none (LongCat's segment loop runs as long as asked) |
| `max_frames_per_render` | How much ONE model call holds. Wan is ~81; past that the frames visibly jump, so longer clips must be chained |
| `segment_frames`, `cond_frames` | For a natively-continuing model: the segment length and how much of it re-renders the previous tail |
| `side_multiple` | Width and height should be multiples of this, or the model falls back to a slower attention path (LongCat at 480 silently loses block-sparse attention) |
| `continuation` | `native` (the server generates a long shot in segments — ask for the whole length in one request), `chained` (the client extends from a frame tail), `none` |
A count off the grid is **rounded by the server**, so a request for 250 LongCat frames
comes back a different length than it asked for. Snap client-side and the plan and the
output agree. Per-model overrides in `models.json`
(`video_native_fps`, `video_frame_base`, `video_frame_step`, `video_min_frames`,
`video_max_frames`, `video_max_frames_per_render`, `video_side_multiple`,
`video_segment_frames`, `video_cond_frames`) win over the built-in family defaults, so a
checkpoint the server has never seen before is configurable rather than mis-described.
Example:
```bash
......
......@@ -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.3.3"
__version__ = "0.3.4"
__tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
......@@ -2158,10 +2158,19 @@ def _sync_video_loras(pipe, loras) -> None:
def _snap_wan_frames(n: int) -> int:
"""Snap a frame count to the Wan VAE's temporal grid (4k+1). The VACE
video/mask lists must be exactly num_frames long, and the pipeline expects a
4k+1 count, so we round to the nearest valid value (min 5)."""
n = max(5, int(n))
k = round((n - 1) / 4)
return max(5, k * 4 + 1)
4k+1 count, so we round to the nearest valid value (min 5).
Delegates to codai/models/video_geometry.py, which is the same grid /v1/models
publishes to clients — so what the server snaps to and what it advertises cannot
drift apart. The arithmetic is kept inline as a fallback because this is on the
render path and must never fail over an import."""
try:
from codai.models import video_geometry
return video_geometry.snap_frames(n, video_geometry.FAMILIES["wan"])
except Exception:
n = max(5, int(n))
k = round((n - 1) / 4)
return max(5, k * 4 + 1)
def _build_vace_conditioning(cond_frames, num_frames: int, width: int, height: int):
......
......@@ -5666,6 +5666,20 @@ class MultiModelManager:
if model_type and model_type in TYPE_MIN_CAP and not caps.to_list():
setattr(caps, TYPE_MIN_CAP[model_type], True)
resolved_type = model_type or (caps.to_list()[0].split("_")[0] if caps.to_list() else "text")
# A video model carries its geometry: the frame rate it was trained at,
# the frame counts its VAE can decode, how much one render holds and
# whether it continues natively. Without it every client plans video by
# guessing from the model NAME, which is how township ended up with a
# hand-maintained table per family. Never fatal: an unknown family
# degrades to the Wan-shaped default, and a failure here must not cost
# the whole listing.
video_geom = None
if resolved_type == "video" or "video_generation" in caps.to_list():
try:
from codai.models import video_geometry
video_geom = video_geometry.geometry_for(model_id, meta)
except Exception:
video_geom = None
models.append(ModelInfo(
id=model_id,
type=resolved_type,
......@@ -5679,6 +5693,7 @@ class MultiModelManager:
load_mode=meta.get("load_mode"),
languages=meta.get("languages") or None,
supports_translation=meta.get("supports_translation"),
video=video_geom,
))
# --- Models from config (the authoritative source) ---
......
"""What a video model can actually emit — frame rate, legal frame counts, continuation.
Three places already knew a piece of this and none of them could tell a client:
``codai/api/video.py:_snap_wan_frames`` (the Wan VAE's 4k+1 grid), ``tools/h3_common``
(MiniMax-H3's 17n+5 clip length) and ``tools/longcat_common`` (LongCat's 93-frame
segment, which a continuation extends by 80). So a caller asking for 250 frames was
silently rounded, and a caller trying to PLAN around the model — how long one scene
can be, how many scenes fill a minute — had no choice but to hardcode a table per
family and keep it in sync by hand. ``tools/gen_township_fighters.py`` did exactly
that, and drifted.
The geometry therefore lives here once, is published per model on ``/v1/models``
(``ModelInfo.video``), and is what the server itself snaps with.
A family's legal TOTAL frame counts are ``frame_base + frame_step * k`` for k >= 0:
wan 1 + 4k the VAE's 4k+1. One render holds ~81 frames; a longer clip is
chained client-side from each part's last frame.
longcat 93 + 80k one 93-frame segment, and each continuation re-renders 13 of
its frames so it only ADDS 80 (``longcat_common.frames_for``).
80 divides by 16, which is why every point on this grid is also
a count block-sparse attention can tile (16n+13): snapping to
the segment grid and keeping BSA on are the same act.
h3 5 + 17k the video VAE's clip length, clamped to 5-15 s.
The numbers are literals rather than imports because ``codai`` runs on a different
Python than the LongCat and H3 workers (3.10 venvs that cannot import ``codai.*``),
and loading their modules by path just to read six constants would drag their
dependencies into every ``/v1/models`` call. ``tests/test_video_geometry.py`` pins
each one against its source of truth so the duplication cannot drift silently.
"""
from typing import Optional
# Families, most specific first: a model is matched by the engine predicate the
# generation path itself uses (so geometry and routing can never disagree), and
# only then by name.
FAMILIES = {
"longcat": {
"family": "longcat",
"label": "LongCat-Video",
# longcat_common: DEFAULT_FPS, DEFAULT_NUM_FRAMES, DEFAULT_COND_FRAMES
"native_fps": 15,
"segment_frames": 93,
"cond_frames": 13,
"frame_base": 93,
"frame_step": 80,
"min_frames": 93,
"max_frames": None, # the server runs the segment loop; no ceiling
"max_frames_per_render": 93,
"default_frames": 93,
# Block-sparse attention tiles the latent in (4, 4, 4) chunks over a 16px
# VAE cell, so a side that is not a multiple of 64 silently falls back to
# dense attention. 16 would decode; 64 is what stays fast.
"side_multiple": 64,
"continuation": "native",
},
"h3": {
"family": "h3",
"label": "MiniMax-H3",
# h3_common: H3_FPS, H3_FRAMES_CHUNK, H3_FRAMES_REMAINDER, H3_MIN/MAX_SECONDS
"native_fps": 24,
"segment_frames": None,
"cond_frames": 0,
"frame_base": 5,
"frame_step": 17,
"min_frames": 124, # ceil(5 s) onto the grid
"max_frames": 345, # floor(15 s) onto the grid
"max_frames_per_render": 345,
"default_frames": 124,
"side_multiple": 32, # H3_CANVAS_MULTIPLE
"continuation": "none",
},
"wan": {
"family": "wan",
"label": "Wan2.x",
"native_fps": 16,
"segment_frames": None,
"cond_frames": 0,
"frame_base": 1,
"frame_step": 4,
"min_frames": 5,
"max_frames": None, # chained client-side, so no total ceiling
# Wan2.2-A14B holds coherence to ~81 frames in ONE call; past that the
# frames visibly jump, which is why longer clips are chained.
"max_frames_per_render": 81,
"default_frames": None, # the request decides; the server has no one default
"side_multiple": 16,
"continuation": "chained",
},
}
# The family used when nothing matches. Wan is the historical shape of every
# diffusers video pipeline the server runs, and its 4k+1 is the common VAE rule.
DEFAULT_FAMILY = "wan"
# models.json keys that override a published field. A new checkpoint whose family
# this table has never heard of is configurable rather than wrong — config is the
# source of truth, here as everywhere else.
CONFIG_OVERRIDES = {
"video_native_fps": ("native_fps", int),
"video_frame_base": ("frame_base", int),
"video_frame_step": ("frame_step", int),
"video_min_frames": ("min_frames", int),
"video_max_frames": ("max_frames", int),
"video_max_frames_per_render": ("max_frames_per_render", int),
"video_side_multiple": ("side_multiple", int),
"video_segment_frames": ("segment_frames", int),
"video_cond_frames": ("cond_frames", int),
}
def family_for(model_name: str = "", config: dict = None) -> str:
"""Which geometry family serves this model.
Asks the workers' own predicates first so a model routed to the LongCat worker
can never be described with Wan's geometry — the backend pin, the alias and the
path all count, exactly as they do when the request is dispatched.
"""
cfg = config or {}
try:
from codai.api import longcat_worker
if longcat_worker.is_longcat_model(model_name, cfg):
return "longcat"
except Exception:
if "longcat" in str(model_name or "").lower():
return "longcat"
try:
from codai.api import h3_worker
if h3_worker.is_h3_model(model_name, cfg):
return "h3"
except Exception:
if "minimax-h3" in str(model_name or "").lower().replace("_", "-"):
return "h3"
return DEFAULT_FAMILY
def geometry_for(model_name: str = "", config: dict = None) -> dict:
"""The published geometry for one video model: family defaults + config overrides."""
cfg = config or {}
raw = cfg.get("_raw_cfg") if isinstance(cfg.get("_raw_cfg"), dict) else None
merged = dict(raw or {})
for k, v in cfg.items():
if k != "_raw_cfg":
merged.setdefault(k, v)
geom = dict(FAMILIES[family_for(model_name, merged)])
for key, (field, cast) in CONFIG_OVERRIDES.items():
if merged.get(key) in (None, ""):
continue
try:
geom[field] = cast(merged[key])
except (TypeError, ValueError):
continue
return geom
def snap_frames(num_frames, geom: dict, mode: str = "nearest") -> int:
"""``num_frames`` moved onto the family's legal grid and into its limits.
``mode`` "nearest" is for a planner choosing a length (a 2-frame move either way
is not worth a whole extra segment); "up" is for honouring a caller who asked for
at least this much. Either way the result is a count the VAE can decode — asking
for one it cannot is how a request comes back a different length than it ordered.
"""
base = int(geom.get("frame_base") or 1)
step = max(1, int(geom.get("frame_step") or 1))
lo = int(geom.get("min_frames") or base)
hi = geom.get("max_frames")
try:
n = int(num_frames)
except (TypeError, ValueError):
n = lo
k = (n - base) / float(step)
k = int(-(-k // 1)) if mode == "up" else int(round(k))
out = base + step * max(0, k)
while out < lo:
out += step
if hi:
while out > int(hi):
out -= step
return max(base, out)
def segments_for(total_frames, geom: dict) -> int:
"""How many model calls a total of ``total_frames`` takes on this family.
LongCat's segment loop runs server-side (one request, native continuation); Wan's
chaining runs in the client. The arithmetic is the same either way: the first call
yields ``max_frames_per_render`` and each later one adds that minus the tail it
re-renders.
"""
per_call = int(geom.get("max_frames_per_render") or 0)
if per_call <= 0:
return 1
added = max(1, per_call - int(geom.get("cond_frames") or 0))
try:
total = int(total_frames)
except (TypeError, ValueError):
return 1
if total <= per_call:
return 1
return 1 + (total - per_call + added - 1) // added
def frames_for(segments, geom: dict) -> int:
"""The inverse of :func:`segments_for` — what ``segments`` calls actually yield."""
per_call = int(geom.get("max_frames_per_render") or 0) or 1
added = max(1, per_call - int(geom.get("cond_frames") or 0))
n = max(1, int(segments or 1))
return per_call + (n - 1) * added
def seconds_to_frames(seconds, geom: dict, fps: Optional[int] = None,
mode: str = "nearest") -> int:
"""A duration in seconds as a legal frame count at ``fps`` (default: the model's own).
Seconds are the honest unit for a plan: a scene is "about twelve seconds", and
pinning frames instead means the duration silently changes the moment the output
rate does.
"""
rate = int(fps or geom.get("native_fps") or 16)
try:
want = float(seconds) * max(1, rate)
except (TypeError, ValueError):
want = 0
return snap_frames(want, geom, mode=mode)
......@@ -155,6 +155,12 @@ class ModelInfo(BaseModel):
languages: Optional[List[str]] = None
supports_translation: Optional[bool] = None
speaker_diarization: Optional[bool] = None
# Video generation: what this model can actually emit — native frame rate, the
# legal frame-count grid, how long ONE render may be and whether continuation is
# native. Published so a client can PLAN around the model (how long a scene can
# be, how many scenes fill a minute) instead of hardcoding a table per family.
# See codai/models/video_geometry.py.
video: Optional[Dict] = None
class ModelList(BaseModel):
......
......@@ -113,6 +113,22 @@ three latent frames that are still noise. The attention is well-defined but it i
the model was trained on. Measure a known-good clip against the dense path before trusting
it on anything you care about.
### Why 93 + 80k is also the BSA-safe grid
A long request is rendered by the segment loop: the first call emits 93 frames and each
continuation re-renders 13 of the previous segment's frames, so it only *adds* 80. The
totals the server actually emits are therefore **93, 173, 253, 333 …** — and because 80
divides by 16, every one of them is also a `16n+13` count. Staying on the segment grid and
keeping block-sparse attention usable are the same act; a request for 250 frames is
neither, gets rounded to 253, and the plan's own numbers become fiction.
`/v1/models` publishes that grid per model (`video.frame_base`, `video.frame_step`,
`video.max_frames_per_render`, `video.side_multiple`, `video.continuation`) so a client can
snap before it asks. `codai/models/video_geometry.py` is the one table, and the render path
snaps with it too. `tools/gen_township_fighters.py` is the worked example: its clip-length
fields default to `0` = derive from the model, which turns a 70-second match from a dozen
Wan-shaped cuts into four to six LongCat takes without retuning anything by hand.
## Generating
```jsonc
......
"""Township clip lengths sized by the MODEL, not by a number typed once for Wan.
The tool was tuned when the only video model could hold ~81 frames in a single call, so
a 70-second match became a dozen short cuts. LongCat was pretrained on continuation and
the server renders a long shot in segments, so the same match wants a handful of long
takes — and the frame numbers that suit one are wrong for the other by a factor of four.
The templates knew this; nothing else did, and nothing snapped a picked count onto what
the model can actually emit, so `random.randint` handed the server lengths like 250 and
the server quietly rounded them.
So: 0 means AUTO (derive from the model's geometry and this tool's seconds band), an
explicit number still wins, and every picked count lands on the model's grid.
"""
import importlib.util
import random
import re
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
def _tool():
"""Import the tool. It does sys.modules[__name__] at import time, so it has to be
registered before exec_module."""
spec = importlib.util.spec_from_file_location(
"township_geom_tool", ROOT / "tools" / "gen_township_fighters.py")
m = importlib.util.module_from_spec(spec)
sys.modules["township_geom_tool"] = m
spec.loader.exec_module(m)
return m
TW = _tool()
SRC = (ROOT / "tools/gen_township_fighters.py").read_text()
WAN = TW.video_geometry("Wan-AI/Wan2.2-I2V-A14B-Diffusers")
LONGCAT = TW.video_geometry("longcat")
H3 = TW.video_geometry("MiniMax-H3")
class _FakeClient:
"""Stands in for CoderAIClient: /v1/models, and nothing else."""
def __init__(self, models, boom=False):
self._models = models
self.boom = boom
self.calls = 0
def list_models(self):
self.calls += 1
if self.boom:
raise RuntimeError("engine is loading")
return self._models
def list_video_models(self):
return [m for m in self.list_models()
if "video_generation" in (m.get("capabilities") or [])]
@pytest.fixture(autouse=True)
def _clear_caches():
TW._GEOM_CACHE.clear()
TW._AUTO_VIDEO_MODEL.clear()
yield
TW._GEOM_CACHE.clear()
TW._AUTO_VIDEO_MODEL.clear()
# ------------------------------------------------------------------ where it comes from
def test_the_server_is_asked_first():
"""The server knows what it actually loaded; a name is a guess. A checkpoint whose
alias this tool has never heard of is exactly the case that used to go wrong."""
client = _FakeClient([{"id": "house-style-v3",
"capabilities": ["video_generation"],
"video": {"family": "longcat", "native_fps": 15,
"frame_base": 93, "frame_step": 80,
"min_frames": 93, "max_frames": None,
"max_frames_per_render": 93, "cond_frames": 13,
"side_multiple": 64, "continuation": "native"}}])
geom = TW.video_geometry("house-style-v3", client)
assert geom["frame_step"] == 80
assert geom["continuation"] == "native"
# Name matching alone would have called this a Wan model.
assert TW._local_geometry_for("house-style-v3")["family"] == "wan"
def test_an_older_front_falls_back_to_the_local_table():
"""No published geometry, or no server at all: a plan must still be plannable."""
client = _FakeClient([{"id": "longcat", "capabilities": ["video_generation"]}])
assert TW.video_geometry("longcat", client)["frame_step"] == 80
assert TW.video_geometry("longcat", _FakeClient([], boom=True))["frame_step"] == 80
assert TW.video_geometry("longcat", None)["frame_step"] == 80
def test_a_published_geometry_missing_the_grid_is_not_trusted():
client = _FakeClient([{"id": "longcat", "capabilities": ["video_generation"],
"video": {"native_fps": 15}}])
assert TW.video_geometry("longcat", client)["frame_step"] == 80
def test_geometry_is_fetched_once_per_model():
client = _FakeClient([{"id": "longcat", "capabilities": ["video_generation"]}])
TW.video_geometry("longcat", client)
TW.video_geometry("longcat", client)
assert client.calls == 1, "the planner asks per match; /v1/models is one call"
def test_the_cached_geometry_cannot_be_mutated_by_a_caller():
client = _FakeClient([{"id": "longcat", "capabilities": ["video_generation"]}])
TW.video_geometry("longcat", client)["frame_step"] = 1
assert TW.video_geometry("longcat", client)["frame_step"] == 80
def test_a_blank_model_on_the_run_page_resolves_to_what_the_server_would_pick():
"""The Run page leaves video_model blank to mean auto-select, and planning a blank
model with Wan's geometry is the whole bug."""
class _Args:
video_model = ""
client = _FakeClient([{"id": "longcat", "capabilities": ["video_generation"]}])
assert TW.resolved_video_model(_Args(), client) == "longcat"
assert TW.web_video_geometry(_Args(), client)["family"] == "longcat"
def test_an_explicit_model_is_not_second_guessed():
class _Args:
video_model = "Wan-AI/Wan2.2-I2V-A14B-Diffusers"
client = _FakeClient([{"id": "longcat", "capabilities": ["video_generation"]}])
assert TW.resolved_video_model(_Args(), client).startswith("Wan-AI")
# --------------------------------------------------------------------- auto ranges
def test_zero_means_auto_and_a_number_still_wins():
assert TW._clip_frame_range(0, 0, LONGCAT, "clip", 15) == (173, 333)
assert TW._clip_frame_range(50, 70, LONGCAT, "clip", 15) == (50, 70)
# A saved config from before auto existed keeps the exact budget it had.
assert TW._clip_frame_range(50, 70, WAN, "clip", 8) == (50, 70)
def test_one_end_can_be_auto():
lo, hi = TW._clip_frame_range(0, 200, LONGCAT, "clip", 15)
assert (lo, hi) == (173, 200)
def test_wan_auto_lands_on_the_historical_budget():
"""Nothing should change shape for a run that was already tuned for Wan."""
assert TW._clip_frame_range(0, 0, WAN, "clip", 8) == (49, 69)
def test_longcat_auto_is_the_segment_grid():
lo, hi = TW._clip_frame_range(0, 0, LONGCAT, "clip", 15)
assert (lo - 93) % 80 == 0 and (hi - 93) % 80 == 0
assert lo == 173 and hi == 333
def test_the_intro_gets_its_own_shorter_budget():
"""Entrances and the face-off are not fight clips. Sharing the fight budget made
every LongCat entrance an 11-22 s take."""
fight = TW._clip_frame_range(0, 0, LONGCAT, "clip", 15)
intro = TW._clip_frame_range(0, 0, LONGCAT, "intro", 15)
assert intro[1] < fight[0]
assert intro == (93, 93), "one segment is the shortest honest LongCat ask"
w_fight = TW._clip_frame_range(0, 0, WAN, "clip", 8)
w_intro = TW._clip_frame_range(0, 0, WAN, "intro", 8)
assert w_intro[1] < w_fight[1]
def test_an_explicit_clip_range_still_governs_the_intro():
"""Changing that for someone who typed numbers in would be a surprise."""
class _Args:
clip_min_frames, clip_max_frames = 50, 70
assert TW._intro_frame_range(_Args(), LONGCAT, 15, (50, 70)) == (50, 70)
class _Auto:
clip_min_frames, clip_max_frames = 0, 0
assert TW._intro_frame_range(_Auto(), LONGCAT, 15, (173, 333)) == (93, 93)
def test_the_band_is_seconds_so_the_rate_moves_the_frames():
"""Pin frames instead and every scene silently changes duration when fps does."""
lo8, hi8 = TW._clip_frame_range(0, 0, WAN, "clip", 8)
lo16, hi16 = TW._clip_frame_range(0, 0, WAN, "clip", 16)
assert lo16 > lo8 and hi16 > hi8
assert abs(lo16 / 16 - lo8 / 8) < 0.6, "the same seconds, not the same frames"
def test_a_bad_range_cannot_break_the_planner():
lo, hi = TW._clip_frame_range(300, 100, WAN, "clip", 8)
assert lo <= hi, "random.randint requires lo <= hi"
assert TW._clip_frame_range("x", None, WAN, "clip", 8) == (49, 69)
assert TW._clip_frame_range(0, 0, None, "clip", 8) == (49, 69)
def test_auto_is_bounded_by_the_whole_plan_ceiling():
for geom in (WAN, LONGCAT, H3):
for kind in ("clip", "intro", "outcome"):
lo, hi = TW._clip_frame_range(0, 0, geom, kind, None)
assert 8 <= lo <= hi <= TW.MAX_PLANNED_FRAMES
# ------------------------------------------------------------------------ snapping
@pytest.mark.parametrize("n", [1, 100, 173, 250, 300, 333, 400])
def test_a_picked_count_lands_on_the_models_grid(n):
out = TW._snap_frames(n, LONGCAT)
assert (out - 93) % 80 == 0 and out >= 93
assert TW._snap_frames(n, WAN) % 4 == 1
def test_snapping_is_idempotent():
for geom in (WAN, LONGCAT, H3):
once = TW._snap_frames(207, geom)
assert TW._snap_frames(once, geom) == once
def test_snapping_respects_a_models_ceiling():
assert TW._snap_frames(10_000, H3) <= H3["max_frames"]
def test_snapping_without_geometry_assumes_the_common_vae_rule():
assert TW._snap_frames(250, None) % 4 == 1
def test_the_grid_is_also_what_keeps_block_sparse_attention_on():
"""93+80k is always 16n+13, the counts bsa_problems accepts. Landing off the grid
is what silently drops a clip onto dense attention."""
spec = importlib.util.spec_from_file_location(
"lc_common_township", ROOT / "tools/longcat_common.py")
lc = importlib.util.module_from_spec(spec)
sys.modules["lc_common_township"] = lc
spec.loader.exec_module(lc)
for n in (100, 207, 250, 300):
assert not lc.bsa_problems(TW._snap_frames(n, LONGCAT), 512, 832)
# ------------------------------------------------------------------- the clip plan
def _plan(geom, fps, target=70.0, seed=11):
random.seed(seed)
lo, hi = TW._clip_frame_range(0, 0, geom, "clip", fps)
intro = TW._clip_frame_range(0, 0, geom, "intro", fps)
return TW._build_match_clip_specs(fps, lo, hi, target, "A", "B",
geom=geom, intro_range=intro)
def test_longcat_fills_the_same_match_with_far_fewer_clips():
"""The point of the whole change: fewer, longer takes for the same long cut."""
wan = [c for c in _plan(WAN, 8) if c["role"] == "fight"]
lc = [c for c in _plan(LONGCAT, 15) if c["role"] == "fight"]
assert len(lc) * 2 <= len(wan), (len(lc), len(wan))
assert len(lc) <= 6
def test_the_long_target_is_still_filled():
for geom, fps in ((WAN, 8), (LONGCAT, 15), (H3, 24)):
fight = [c for c in _plan(geom, fps) if c["role"] == "fight"]
assert sum(c["clip_seconds"] for c in fight) >= 70.0
def test_every_planned_clip_is_a_count_the_model_can_emit():
for geom, fps in ((WAN, 8), (LONGCAT, 15), (H3, 24)):
for c in _plan(geom, fps):
assert TW._snap_frames(c["nf"], geom) == c["nf"], (geom["family"], c)
def test_the_intro_is_three_clips_and_shorter_than_the_fight():
specs = _plan(LONGCAT, 15)
intro = [c for c in specs if c["role"] in ("entrance", "faceoff")]
fight = [c for c in specs if c["role"] == "fight"]
assert len(intro) == 3
assert max(c["nf"] for c in intro) <= min(c["nf"] for c in fight)
def test_the_intro_does_not_count_toward_the_long_target():
specs = _plan(LONGCAT, 15, target=30.0)
fight = [c for c in specs if c["role"] == "fight"]
assert sum(c["clip_seconds"] for c in fight) >= 30.0
def test_without_an_intro_range_the_clip_range_is_used():
"""Back-compat: the signature grew two optional arguments."""
random.seed(3)
specs = TW._build_match_clip_specs(8, 50, 70, 20.0, "A", "B")
assert all(50 <= c["nf"] <= 70 for c in specs)
# ----------------------------------------------------------------- outcome videos
class _Prompter:
def outcome_shot(self, fighter, outcome, env, role="", opponent=None):
return f"{role} shot"
def test_each_outcome_shot_is_snapped_and_the_total_is_rewritten():
"""A 60/40 split of a legal total is two counts that usually are not, and each
shot is its own render."""
o = {"fighter": "A", "outcome": "win", "opponent": "B", "nf": 253,
"target_s": round(253 / 15, 2)}
TW._plan_outcome_shots(_Prompter(), o, {}, "B", geom=LONGCAT)
assert [sh["nf"] for sh in o["shots"]] == [173, 93]
assert o["nf"] == 266, "the plan says what will actually be rendered"
assert o["target_s"] == pytest.approx(266 / 15, abs=0.2)
def test_without_geometry_the_split_is_untouched():
o = {"fighter": "A", "outcome": "win", "opponent": "B", "nf": 100}
TW._plan_outcome_shots(_Prompter(), o, {}, "B")
assert sum(sh["nf"] for sh in o["shots"]) == 100
assert o["nf"] == 100
def test_a_draw_is_still_two_shots():
o = {"fighter": "A", "outcome": "draw", "opponent": "B", "nf": 173}
TW._plan_outcome_shots(_Prompter(), o, {}, "B", geom=LONGCAT)
assert [sh["role"] for sh in o["shots"]] == ["final_exchange", "draw_decision"]
assert all(TW._snap_frames(sh["nf"], LONGCAT) == sh["nf"] for sh in o["shots"])
# -------------------------------------------------------------- the per-render cap
def test_the_cap_comes_from_the_model():
assert TW._single_render_cap(0, H3) == H3["max_frames_per_render"]
assert TW._single_render_cap(0, LONGCAT) == 93
def test_wan_keeps_chaining_at_fifty():
"""81 is where coherence breaks; 50 is where it still looks good, and that has
been the shipped behaviour since the beginning."""
assert TW._single_render_cap(0, WAN) == TW.SINGLE_CLIP_MAX_FRAMES == 50
def test_an_explicit_cap_is_bounded_by_one_render():
assert TW._single_render_cap(120, WAN) == TW.MODEL_MAX_FRAMES
assert TW._single_render_cap(120, H3) == 120
assert TW._single_render_cap(30, WAN) == 30
def test_a_natively_continuing_model_is_never_chained_from_here():
"""Chaining LongCat would reintroduce exactly the drift its continuation avoids."""
body = SRC.split("# Max frames per SINGLE model generation", 1)[1][:1600]
assert '_geom.get("continuation") == "native"' in body
assert "_chunk_max = 1 << 30" in body
def test_the_tail_length_comes_from_the_geometry():
assert 'int(_geom.get("cond_frames") or 0) or LONGCAT_COND_FRAMES' in SRC
# ------------------------------------------------------------------ size sanity
def test_a_size_the_model_cannot_tile_is_called_out():
warn = TW._size_warning("832x480", LONGCAT)
assert "64" in warn and "832x512" in warn, warn
assert TW._size_warning("832x512", LONGCAT) == ""
assert TW._size_warning("832x480", WAN) == ""
def test_a_junk_size_is_not_an_error():
assert TW._size_warning("", LONGCAT) == ""
assert TW._size_warning("wide", LONGCAT) == ""
def test_the_size_is_never_changed_behind_the_operators_back():
"""Resolution has cost and aspect consequences; it is an explicit choice."""
assert "video_size =" not in SRC.split("def _size_warning", 1)[1][:900]
# ----------------------------------------------------------- defaults and plumbing
@pytest.mark.parametrize("field", [
"clip_min_frames", "clip_max_frames", "single_clip_max_frames",
"outcome_min_frames", "outcome_max_frames"])
def test_the_cli_defaults_to_auto(field):
flag = "--" + field.replace("_", "-")
block = SRC.split(f'"{flag}"', 1)[1][:200]
assert "default=0" in block, block
@pytest.mark.parametrize("field", [
"clip_min_frames", "clip_max_frames", "single_clip_max_frames",
"outcome_min_frames", "outcome_max_frames"])
def test_the_run_page_defaults_to_auto(field):
assert re.search(rf"name={field} type=number min=0[^>]*_v\('{field}', 0\)", SRC), field
@pytest.mark.parametrize("field", [
"clip_min_frames", "clip_max_frames", "single_clip_max_frames",
"outcome_min_frames", "outcome_max_frames"])
def test_both_form_parsers_default_to_auto(field):
"""Two of them, and a literal left in either one pins the old Wan number."""
for m in re.finditer(rf'_fv\("{field}", "(\d+)"\)', SRC):
assert m.group(1) == "0", (field, m.group(0))
assert len(re.findall(rf'_fv\("{field}", "0"\)', SRC)) == 2, field
def test_the_run_page_explains_what_zero_does():
assert "to size clips from the video" in SRC
def test_the_frame_fields_are_still_saved_in_a_config():
for field in ("clip_min_frames", "clip_max_frames", "single_clip_max_frames",
"outcome_min_frames", "outcome_max_frames"):
assert field in TW.CONFIG_FIELDS
def test_the_builtin_templates_no_longer_pin_frames():
"""They were the only model-aware thing in the tool; the model is now."""
for name, tpl in TW.BUILTIN_TEMPLATES.items():
for field in ("clip_min_frames", "clip_max_frames", "single_clip_max_frames",
"outcome_min_frames", "outcome_max_frames"):
assert tpl[field] == 0, (name, field)
# fps and size stay: they are editorial, and a model's native rate is not a
# reason to re-encode an existing show at a different one.
assert TW.BUILTIN_TEMPLATES["LongCat - few long scenes"]["fps"] == 15
assert TW.BUILTIN_TEMPLATES["Wan - many short scenes"]["fps"] == 8
def test_every_planner_entry_point_is_model_aware():
"""Four places plan clips: the CLI run (which has its own inline loop), and the
new-match, replan and full-re-render web jobs. One left name-blind plans the wrong
lengths for that job only, which is invisible until the output looks wrong."""
calls = [m for m in re.finditer(r"(def )?_build_match_clip_specs\(([^)]*)\)",
SRC, re.S) if not m.group(1)]
assert len(calls) == 3, f"{len(calls)} web call sites — did one appear or vanish?"
for call in calls:
assert "geom=" in call.group(2), call.group(2)
# The CLI run plans inline rather than through the helper; both of its picks
# (intro and fight) must still be snapped onto the model's grid.
inline = SRC.split("def stage_videos", 1)[1].split("\ndef ", 1)[0]
assert inline.count("_snap_frames(random.randint(") == 3, "intro, fight, outcome"
assert "_geom = video_geometry(video_model, client)" in inline
shots = [m for m in re.finditer(r"(def )?_plan_outcome_shots\(", SRC)
if not m.group(1)]
assert len(shots) == 5, f"{len(shots)} call sites"
for call in shots:
# Nested parens (o.get("opponent")) make the argument list awkward to match,
# so look at the statement's first two lines.
stmt = SRC[call.end():call.end() + 220].split("\n")[:2]
assert "geom=" in " ".join(stmt), stmt
def test_the_render_phase_resolves_its_own_geometry():
"""It is a separate function from the planner and decides the chaining: a geometry
resolved only in the planner is a NameError on the first render."""
body = SRC.split("def _stage_videos_render", 1)[1].split("\ndef ", 1)[0]
assert "_geom = video_geometry(video_model, client)" in body
assert "_single_render_cap(single_clip_max_frames, _geom)" in body
assert "single_clip_max_frames=0," in SRC, "the render default is auto too"
def test_the_web_render_path_does_not_force_the_wan_chunk():
"""Coercing a blank cap to 50 there would chain a model that never needs it."""
assert 'scm = int(getattr(default_args, "single_clip_max_frames", 0) or 0)' in SRC
def test_the_clip_budget_is_logged():
"""An operator who does not know what auto chose cannot sanity-check a run."""
assert "clip budget:" in SRC
......@@ -40,7 +40,10 @@ LC = _contract()
# ------------------------------------------------------------ the tool
def test_the_tool_recognises_a_longcat_model():
assert '_longcat = "longcat" in (video_model or "").lower()' in TOOL
"""By the geometry the server publishes first, and by the name as a fallback: an
entry aliased over an HF repo id is a LongCat model the name cannot identify."""
assert '_geom.get("continuation") == "native"' in TOOL
assert 'or "longcat" in (video_model or "").lower()' in TOOL
def test_the_client_side_split_is_lifted_for_longcat():
......@@ -48,15 +51,17 @@ def test_the_client_side_split_is_lifted_for_longcat():
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
# …and the non-LongCat path keeps a cap, now the model's own.
assert "_single_render_cap(single_clip_max_frames, _geom)" in seg
def test_other_models_are_unaffected():
"""A Wan/VACE run must behave exactly as before."""
"""A Wan/VACE run must behave exactly as before: still chained, still at 50 frames
per render, which is what _single_render_cap returns for the wan family."""
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
assert "_single_render_cap(single_clip_max_frames, _geom)" in seg
assert '_RENDER_CHUNK = {"wan": SINGLE_CLIP_MAX_FRAMES}' in TOOL
def test_longcat_continues_from_a_tail_like_vace_does():
......@@ -72,7 +77,10 @@ def test_the_tail_length_matches_what_longcat_was_trained_with():
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
# The model's published cond_frames wins, with the constant as the fallback for a
# front that does not publish geometry yet.
assert 'int(_geom.get("cond_frames") or 0) or LONGCAT_COND_FRAMES' in TOOL
assert "else VACE_TAIL_FRAMES" in TOOL
def test_the_vace_tail_length_is_untouched():
......
......@@ -259,24 +259,35 @@ def test_seeding_runs_before_the_management_commands():
def test_the_longcat_starter_asks_for_fewer_longer_scenes():
"""The scene lengths now come from the MODEL (0 = auto in both starters), so this
asks the same question of the geometry the planner will use."""
wan = TW.BUILTIN_TEMPLATES["Wan - many short scenes"]
lc = TW.BUILTIN_TEMPLATES["LongCat - few long scenes"]
# Longer scenes…
assert lc["clip_min_frames"] > wan["clip_max_frames"]
# …at LongCat's own frame rate, not the Wan-era compromise.
assert lc["fps"] == 15 and wan["fps"] == 8
w_lo, w_hi = TW._clip_frame_range(wan["clip_min_frames"], wan["clip_max_frames"],
TW.video_geometry("wan2.2"), "clip", wan["fps"])
l_lo, l_hi = TW._clip_frame_range(lc["clip_min_frames"], lc["clip_max_frames"],
TW.video_geometry("longcat"), "clip", lc["fps"])
# Longer scenes…
assert l_lo > w_hi
# …so the same 45s short needs far fewer of them.
scenes_wan = 45 / (wan["clip_max_frames"] / wan["fps"])
scenes_lc = 45 / (lc["clip_max_frames"] / lc["fps"])
assert scenes_lc < scenes_wan / 2
assert 45 / (l_hi / lc["fps"]) < 45 / (w_hi / wan["fps"]) / 2
def test_the_longcat_frame_counts_obey_the_vae_rule():
"""(frames - 1) divisible by 4 — the same rule the server checks before a long run,
and the one LongCat's own 93-frame segment satisfies."""
and the one LongCat's own 93-frame segment satisfies. The starter no longer carries
the numbers, so the rule is asked of what auto derives."""
lc = TW.BUILTIN_TEMPLATES["LongCat - few long scenes"]
for key in ("clip_min_frames", "clip_max_frames", "single_clip_max_frames"):
assert (lc[key] - 1) % 4 == 0, key
geom = TW.video_geometry("longcat")
for kind in ("clip", "intro", "outcome"):
for n in TW._clip_frame_range(0, 0, geom, kind, lc["fps"]):
assert (n - 1) % 4 == 0, (kind, n)
# And on the segment grid, which is stricter and is what keeps
# block-sparse attention usable.
assert (n - 93) % 80 == 0, (kind, n)
assert (TW._single_render_cap(lc["single_clip_max_frames"], geom) - 1) % 4 == 0
def test_the_longcat_starter_selects_the_model():
......@@ -309,10 +320,13 @@ def test_the_starters_cover_the_outcome_clips_too():
def test_the_longcat_outcomes_obey_the_vae_rule_as_well():
lc = TW.BUILTIN_TEMPLATES["LongCat - few long scenes"]
for key in ("outcome_min_frames", "outcome_max_frames"):
assert (lc[key] - 1) % 4 == 0, key
# 93 is one whole LongCat segment: the shortest honest ask.
assert lc["outcome_min_frames"] == 93
lo, hi = TW._clip_frame_range(lc["outcome_min_frames"], lc["outcome_max_frames"],
TW.video_geometry("longcat"), "outcome", lc["fps"])
for n in (lo, hi):
assert (n - 1) % 4 == 0, n
# An outcome is split across two shots, so its total is at least two segments —
# 93 is one whole LongCat segment and the shortest honest ask for either half.
assert lo >= 93 * 2 - 13
def test_the_starters_leave_the_image_side_stages_alone():
......
"""What a video model can emit, published per model on /v1/models.
Three modules knew a piece of this and none of them could tell a client: the Wan VAE's
4k+1 grid lived in codai/api/video.py, MiniMax-H3's 17n+5 in tools/h3_common.py, and
LongCat's 93-frame segment in tools/longcat_common.py. A client planning video therefore
guessed from the model NAME and kept its own table — which is how the township tool ended
up tuned for a model it was no longer using.
codai/models/video_geometry.py is that knowledge once. Because the LongCat and H3 workers
run in their own 3.10 venvs and cannot be imported from here, the numbers are literals,
so the first tests below pin each one against the module that owns it: duplication is
tolerable, silent drift is not.
"""
import importlib.util
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from codai.models import video_geometry as G
def _by_path(name, rel):
spec = importlib.util.spec_from_file_location(name, ROOT / rel)
m = importlib.util.module_from_spec(spec)
sys.modules[name] = m
spec.loader.exec_module(m)
return m
LC = _by_path("lc_common_geom", "tools/longcat_common.py")
H3 = _by_path("h3_common_geom", "tools/h3_common.py")
# --------------------------------------------------------------- pinned to source
def test_longcat_numbers_come_from_longcat_common():
g = G.FAMILIES["longcat"]
assert g["native_fps"] == LC.DEFAULT_FPS
assert g["segment_frames"] == LC.DEFAULT_NUM_FRAMES
assert g["cond_frames"] == LC.DEFAULT_COND_FRAMES
assert g["frame_base"] == LC.DEFAULT_NUM_FRAMES
# A continuation re-renders the tail, so a segment only ADDS num - cond.
assert g["frame_step"] == LC.DEFAULT_NUM_FRAMES - LC.DEFAULT_COND_FRAMES
def test_the_longcat_grid_is_what_the_segment_loop_actually_yields():
g = G.FAMILIES["longcat"]
for segments in (1, 2, 3, 4, 7):
assert G.frames_for(segments, g) == LC.frames_for(segments)
assert G.snap_frames(LC.frames_for(segments), g) == LC.frames_for(segments)
def test_every_longcat_grid_point_is_a_count_bsa_can_tile():
"""80 divides by 16, so 93 + 80k is always 16n+13 — the counts bsa_problems accepts.
This is the whole reason snapping to the segment grid is worth doing: a count off
the grid drops the pass onto dense attention, which is slower and OOMed a 24GB card.
"""
g = G.FAMILIES["longcat"]
for k in range(8):
frames = g["frame_base"] + g["frame_step"] * k
assert not LC.bsa_problems(frames, 512, 832), frames
def test_h3_numbers_come_from_h3_common():
g = G.FAMILIES["h3"]
assert g["native_fps"] == H3.H3_FPS
assert g["frame_step"] == H3.H3_FRAMES_CHUNK
assert g["frame_base"] == H3.H3_FRAMES_REMAINDER
assert g["side_multiple"] == H3.H3_CANVAS_MULTIPLE
assert g["min_frames"] == H3.snap_frames(H3.H3_MIN_SECONDS * H3.H3_FPS)
assert g["max_frames"] == H3.snap_frames(H3.H3_MAX_SECONDS * H3.H3_FPS)
@pytest.mark.parametrize("n", [1, 120, 150, 200, 345, 360, 400])
def test_h3_snapping_agrees_with_the_worker(n):
"""Up, not nearest: the worker snaps a request UP to what its VAE can decode."""
assert G.snap_frames(n, G.FAMILIES["h3"], mode="up") == H3.snap_frames(n)
@pytest.mark.parametrize("n", [1, 5, 6, 48, 50, 81, 250])
def test_wan_snapping_agrees_with_the_render_path(n):
from codai.api.video import _snap_wan_frames
assert G.snap_frames(n, G.FAMILIES["wan"]) == _snap_wan_frames(n)
def test_wan_holds_eighty_one_frames_in_one_call():
"""Past ~81 the frames visibly jump, which is why longer clips are chained."""
assert G.FAMILIES["wan"]["max_frames_per_render"] == 81
assert G.FAMILIES["wan"]["continuation"] == "chained"
# ------------------------------------------------------------------ family match
@pytest.mark.parametrize("name,cfg,family", [
("longcat", {}, "longcat"),
("LongCat-Video-Avatar", {}, "longcat"),
("Wan-AI/Wan2.2-I2V-A14B-Diffusers", {}, "wan"),
("MiniMax-H3", {}, "h3"),
("minimax_h3_fast", {}, "h3"),
("something-else", {}, "wan"),
])
def test_the_family_is_recognised_by_name(name, cfg, family):
assert G.family_for(name, cfg) == family
def test_a_backend_pin_beats_the_name():
"""An entry aliased over an HF repo id is routed by its backend, so it must be
described by its backend too — geometry and routing cannot be allowed to disagree."""
assert G.family_for("my-video-model", {"backend": "longcat"}) == "longcat"
assert G.family_for("my-video-model", {"backend": "h3"}) == "h3"
def test_an_alias_is_enough():
assert G.family_for("org/checkpoint-42", {"alias": "longcat"}) == "longcat"
def test_the_raw_config_is_unwrapped():
"""build_runtime_kwargs drops every engine-specific key except inside _raw_cfg,
which is exactly where a backend pin ends up on the video path."""
geom = G.geometry_for("org/x", {"_raw_cfg": {"backend": "longcat"}})
assert geom["family"] == "longcat"
def test_an_unknown_family_degrades_to_the_common_vae_rule():
geom = G.geometry_for("brand-new-video-model", {})
assert geom["family"] == G.DEFAULT_FAMILY
assert (geom["frame_base"], geom["frame_step"]) == (1, 4)
# --------------------------------------------------------------- config overrides
def test_config_overrides_win():
"""Config is the source of truth: a checkpoint this table has never heard of is
configurable rather than permanently mis-described."""
geom = G.geometry_for("mystery", {
"video_native_fps": 30, "video_frame_base": 9, "video_frame_step": 8,
"video_max_frames_per_render": 200, "video_side_multiple": 64})
assert geom["native_fps"] == 30
assert G.snap_frames(100, geom) == 97 # 9 + 8k
assert geom["max_frames_per_render"] == 200
assert geom["side_multiple"] == 64
def test_a_junk_override_is_ignored_not_fatal():
geom = G.geometry_for("longcat", {"video_native_fps": "soon"})
assert geom["native_fps"] == G.FAMILIES["longcat"]["native_fps"]
def test_geometry_for_does_not_mutate_the_table():
G.geometry_for("longcat", {"video_native_fps": 7})
assert G.FAMILIES["longcat"]["native_fps"] == LC.DEFAULT_FPS
# ------------------------------------------------------------------- the maths
def test_snapping_never_lands_below_the_minimum():
for fam in G.FAMILIES.values():
assert G.snap_frames(0, fam) >= fam["min_frames"]
assert G.snap_frames(-5, fam) >= fam["min_frames"]
def test_snapping_respects_a_ceiling():
assert G.snap_frames(10_000, G.FAMILIES["h3"]) <= G.FAMILIES["h3"]["max_frames"]
# LongCat has none: the server runs the segment loop as long as asked.
assert G.FAMILIES["longcat"]["max_frames"] is None
assert G.snap_frames(10_000, G.FAMILIES["longcat"]) > 1000
def test_nonsense_frames_do_not_raise():
assert G.snap_frames(None, G.FAMILIES["wan"]) >= 5
assert G.snap_frames("many", G.FAMILIES["wan"]) >= 5
def test_segments_and_frames_are_inverses():
for fam in ("longcat", "wan"):
g = G.FAMILIES[fam]
for k in range(1, 6):
assert G.segments_for(G.frames_for(k, g), g) == k
def test_seconds_become_a_legal_count_at_the_asked_rate():
lc = G.FAMILIES["longcat"]
assert G.seconds_to_frames(11.5, lc) == 173 # two segments at 15fps
assert G.seconds_to_frames(22.0, lc) == 333 # four
wan = G.FAMILIES["wan"]
assert G.seconds_to_frames(6.25, wan, fps=8) == 49
# The POINT of seconds: the same scene length at a different rate is more frames.
assert G.seconds_to_frames(6.25, wan, fps=16) == 101
def test_seconds_default_to_the_models_own_rate():
lc = G.FAMILIES["longcat"]
assert G.seconds_to_frames(11.5, lc) == G.seconds_to_frames(11.5, lc, fps=15)
# ---------------------------------------------------------------- what's published
def test_the_listing_can_carry_it():
from codai.pydantic.textrequest import ModelInfo
geom = G.geometry_for("longcat", {})
info = ModelInfo(id="longcat", type="video",
capabilities=["video_generation"], video=geom)
assert info.model_dump()["video"]["frame_step"] == 80
def test_a_non_video_model_carries_nothing():
from codai.pydantic.textrequest import ModelInfo
assert ModelInfo(id="some-llm", type="text").video is None
def test_the_manager_publishes_geometry_for_video_models():
"""Read from the source: building a real manager needs a GPU and a config tree."""
src = (ROOT / "codai/models/manager.py").read_text()
assert "from codai.models import video_geometry" in src
assert "video=video_geom," in src
assert 'if resolved_type == "video" or "video_generation" in caps.to_list():' in src
def test_the_render_path_snaps_with_the_published_grid():
"""Otherwise what the server advertises and what it rounds to could drift."""
src = (ROOT / "codai/api/video.py").read_text()
body = src.split("def _snap_wan_frames", 1)[1].split("\ndef ", 1)[0]
assert "video_geometry.snap_frames" in body
assert "return max(5, k * 4 + 1)" in body, "the inline fallback must stay"
......@@ -1786,23 +1786,303 @@ CLIP_MIN_FRAMES = 50
CLIP_MAX_FRAMES = 70
def _clip_frame_range(lo, hi):
"""Normalize a (min, max) fight-clip frame budget from config: clamp to
# ─────────────────────────────────────────────────────────────────────────────
# Model-based clip geometry
#
# How long a scene should be is not a matter of taste, it is a property of the
# model: Wan2.2 cannot hold coherence past ~81 frames in ONE call, so a minute of
# video is a dozen short cuts chained from a frame tail, while LongCat was
# pretrained on continuation and the server renders a long shot in segments — so
# the same minute is a handful of long takes. Carrying one frame number for both
# means the operator retunes four fields by hand every time the model changes, and
# forgetting to is invisible until the output looks wrong.
#
# Two halves, deliberately split:
#
# GEOMETRY — what the model can emit: native frame rate, the frame counts its
# VAE can decode, how much one render holds. The SERVER owns this and
# publishes it per model on /v1/models (ModelInfo.video, built by
# codai/models/video_geometry.py). _LOCAL_GEOMETRY below is only a
# fallback for an older front that does not send it yet.
# BANDS — how long a township scene should be, in SECONDS. That is this
# tool's editorial judgement, not the model's, so it stays here.
#
# Seconds, not frames, because the clip COUNT falls out of
# long_target / (frames / fps): pin frames and every scene silently changes
# duration the moment the output rate does.
# ─────────────────────────────────────────────────────────────────────────────
# Fallback geometry, mirroring codai/models/video_geometry.py. Legal total frame
# counts are frame_base + frame_step * k.
_LOCAL_GEOMETRY = {
"longcat": {"family": "longcat", "native_fps": 15, "frame_base": 93,
"frame_step": 80, "min_frames": 93, "max_frames": None,
"max_frames_per_render": 93, "cond_frames": 13,
"side_multiple": 64, "continuation": "native"},
"h3": {"family": "h3", "native_fps": 24, "frame_base": 5,
"frame_step": 17, "min_frames": 124, "max_frames": 345,
"max_frames_per_render": 345, "cond_frames": 0,
"side_multiple": 32, "continuation": "none"},
"wan": {"family": "wan", "native_fps": 16, "frame_base": 1,
"frame_step": 4, "min_frames": 5, "max_frames": None,
"max_frames_per_render": MODEL_MAX_FRAMES, "cond_frames": 0,
"side_multiple": 16, "continuation": "chained"},
}
# Scene length per family, in seconds of playback.
#
# scene — a fight clip. Wan's 6.2-8.75 s is the historical 50-70 frames at
# 8 fps, kept so nothing changes shape for existing runs. LongCat's
# 11.5-22 s is one to four segments: a 70 s match becomes 4-6 takes
# instead of 9-11 cuts.
# intro — the two entrances and the face-off. These are NOT fight clips and
# were sharing the fight budget, which on LongCat made every entrance
# an 11-22 s take. A single segment is plenty.
# outcome — the TOTAL for an outcome video, split across its finish + victory
# shots, so each shot gets about half.
_SCENE_BANDS = {
"longcat": {"scene": (11.5, 22.0), "intro": (6.0, 7.0), "outcome": (12.0, 19.0)},
"h3": {"scene": (5.0, 10.0), "intro": (5.0, 5.5), "outcome": (10.0, 15.0)},
"wan": {"scene": (6.2, 8.75), "intro": (3.0, 5.0), "outcome": (12.0, 18.75)},
}
# Where this tool chains more conservatively than the model strictly requires.
# Wan's 81-frame ceiling is where coherence breaks; 50 is where it still looks good,
# and that has been the shipped behaviour since the beginning.
_RENDER_CHUNK = {"wan": SINGLE_CLIP_MAX_FRAMES}
# Geometry already fetched, keyed by model id. /v1/models is one HTTP call but the
# planner asks per match, and a plan must not depend on the server staying up.
_GEOM_CACHE = {}
# The auto-selected video model, resolved once per process for the same reason.
_AUTO_VIDEO_MODEL = {}
def _local_geometry_for(model_id) -> dict:
"""Fallback family match by NAME, for a front that does not publish geometry."""
mid = (model_id or "").lower().replace("_", "-")
if "longcat" in mid:
return dict(_LOCAL_GEOMETRY["longcat"])
if "minimax-h3" in mid:
return dict(_LOCAL_GEOMETRY["h3"])
return dict(_LOCAL_GEOMETRY["wan"])
def video_geometry(model_id, client=None) -> dict:
"""What `model_id` can emit: the server's published geometry, else the local table.
The server is asked first because it knows what it actually loaded — a checkpoint
aliased to something this table has never heard of, or a per-model override in
models.json, is right there and guessing from the name would be wrong. Any failure
(old front, no network, model not listed) falls back silently: a plan must never
depend on the server being reachable.
"""
key = str(model_id or "")
if key in _GEOM_CACHE:
return dict(_GEOM_CACHE[key])
geom = None
if client is not None:
try:
for m in client.list_models():
if m.get("id") != key:
continue
pub = m.get("video")
if isinstance(pub, dict) and pub.get("frame_step"):
geom = dict(pub)
break
except Exception:
geom = None
if geom is None:
geom = _local_geometry_for(key)
else:
# The server publishes geometry only; the bands stay this tool's business.
geom.setdefault("family", _local_geometry_for(key).get("family", "wan"))
_GEOM_CACHE[key] = dict(geom)
return dict(geom)
def resolved_video_model(default_args, client=None):
"""The video model a web job will actually render with.
The Run page leaves `video_model` blank to mean "auto-select", and a blank model
plans with the wrong geometry — which is the whole bug this is here to avoid. So
ask the server the same question the render will: whichever video model it would
pick. Cached, because the planner asks per match.
"""
configured = (getattr(default_args, "video_model", None) or "").strip()
if configured:
return configured
if client is None:
return ""
if "id" in _AUTO_VIDEO_MODEL:
return _AUTO_VIDEO_MODEL["id"]
picked = ""
try:
models = client.list_video_models()
picked = (models[0].get("id") or "") if models else ""
except Exception:
picked = ""
_AUTO_VIDEO_MODEL["id"] = picked
return picked
def web_video_geometry(default_args, client=None) -> dict:
"""Geometry for whatever video model a web job will use (blank = auto-selected).
Builds its own client when the caller has none: several web jobs plan their frame
budgets before they reach the point of talking to the server, and both the model
id and the geometry are cached, so this costs one request per process at most.
"""
if client is None:
try:
client = CoderAIClient(default_args.base_url,
resolve_api_key(default_args))
except Exception:
client = None
return video_geometry(resolved_video_model(default_args, client), client)
def _scene_band(geom: dict, kind: str):
"""The (min, max) seconds band for a clip of `kind` on this model's family."""
fam = (geom or {}).get("family") or "wan"
bands = _SCENE_BANDS.get(fam) or _SCENE_BANDS["wan"]
return bands.get(kind) or bands["scene"]
def _snap_frames(n, geom: dict = None) -> int:
"""`n` moved onto the nearest frame count this model can actually emit.
Without this the planner hands the server counts like 250: not 4n+1 for any VAE,
and for LongCat not on the 93+80k segment grid either, so the server rounds and
the plan's own numbers become fiction. Worse on LongCat specifically — every
point of 93+80k is also a 16n+13 count that block-sparse attention can tile
(80 divides by 16), so landing OFF the grid is what silently drops the clip onto
dense attention.
"""
geom = geom or _LOCAL_GEOMETRY["wan"]
base = int(geom.get("frame_base") or 1)
step = max(1, int(geom.get("frame_step") or 1))
lo = int(geom.get("min_frames") or base)
hi = geom.get("max_frames")
try:
want = int(n)
except (TypeError, ValueError):
want = lo
out = base + step * max(0, int(round((want - base) / float(step))))
while out < lo:
out += step
if hi:
while out > int(hi):
out -= step
return max(base, min(out, MAX_PLANNED_FRAMES))
def _auto_frame_range(geom: dict, kind: str, fps: int):
"""The (min, max) frame budget for `kind` on this model at `fps`.
Derived from the family's seconds band, then snapped onto the model's own grid —
so both ends of the range are counts it can emit, and the planner cannot pick a
length between two legal ones.
"""
lo_s, hi_s = _scene_band(geom, kind)
rate = max(1, int(fps or (geom or {}).get("native_fps") or 16))
lo = _snap_frames(lo_s * rate, geom)
hi = _snap_frames(hi_s * rate, geom)
return (hi, lo) if lo > hi else (lo, hi)
def _clip_frame_range(lo, hi, geom: dict = None, kind: str = "clip", fps: int = None):
"""Normalize a (min, max) clip frame budget from config: clamp to
[8, MAX_PLANNED_FRAMES] and ensure lo <= hi, so bad UI input can't break the
planner (random.randint requires lo <= hi). A clip longer than one model call
is split + chained at render time, so the ceiling is MAX_PLANNED_FRAMES, not
the per-render MODEL_MAX_FRAMES."""
the per-render MODEL_MAX_FRAMES.
0 (or blank) means AUTO: derive the budget from the model's own geometry and this
tool's seconds band for `kind` instead. That is the default, because the frame
numbers that suit Wan are wrong for LongCat by a factor of four and nobody should
have to retune four fields to switch model. An explicit number always wins — a
saved config from before this existed keeps the exact budget it had.
"""
try:
lo = int(lo); hi = int(hi)
lo = int(lo or 0); hi = int(hi or 0)
except (TypeError, ValueError):
lo, hi = CLIP_MIN_FRAMES, CLIP_MAX_FRAMES
lo = max(8, min(lo, MAX_PLANNED_FRAMES))
hi = max(8, min(hi, MAX_PLANNED_FRAMES))
lo = hi = 0
if lo <= 0 or hi <= 0:
a_lo, a_hi = _auto_frame_range(geom or _LOCAL_GEOMETRY["wan"], kind, fps)
lo = lo if lo > 0 else a_lo
hi = hi if hi > 0 else a_hi
lo = max(8, min(int(lo), MAX_PLANNED_FRAMES))
hi = max(8, min(int(hi), MAX_PLANNED_FRAMES))
if lo > hi:
lo, hi = hi, lo
return lo, hi
def _intro_frame_range(default_args, geom: dict, fps: int, clip_range):
"""The entrance / face-off budget for a web job.
An explicitly configured clip range still governs the intro, the way it always
did; only when the clip budget is AUTO does the intro get its own shorter band.
Changing that for an operator who typed numbers in would be a surprise.
"""
if (getattr(default_args, "clip_min_frames", 0) or 0) > 0 and \
(getattr(default_args, "clip_max_frames", 0) or 0) > 0:
return tuple(clip_range)
return _clip_frame_range(0, 0, geom, "intro", fps)
def _single_render_cap(configured, geom: dict = None) -> int:
"""Max frames in ONE model generation. 0/blank = ask the model.
LongCat reports 93 but owns its continuation loop server-side, so the planner
must NOT chain it — that is handled at render time by lifting the cap; this is
the number for models whose long clips are chained here.
"""
try:
n = int(configured or 0)
except (TypeError, ValueError):
n = 0
cap = int((geom or {}).get("max_frames_per_render") or MODEL_MAX_FRAMES)
if n > 0:
# An explicit chunk is still bounded by what one render can hold: asking Wan
# for 120 frames in a single call does not make it coherent for 120 frames.
return max(8, min(n, cap, MAX_PLANNED_FRAMES))
# The model's own ceiling is the TRUTH; this tool's chunk can be smaller. Wan
# holds coherence to ~81 frames but has always been chained at 50 here, and that
# conservatism is a rendering choice, not something to drop by switching to auto.
chunk = _RENDER_CHUNK.get((geom or {}).get("family") or "")
if chunk:
cap = min(cap, int(chunk))
# MODEL_MAX_FRAMES is Wan's coherence ceiling, not a universal one: clamping an
# H3 render (345 frames in one call) to 81 would chain a model that never needs
# chaining. The geometry decides; this only bounds a whole plan.
return max(8, min(cap, MAX_PLANNED_FRAMES))
def _size_warning(video_size, geom: dict) -> str:
"""A warning when the configured frame size is not what the model wants, else "".
Not corrected automatically: the resolution is an explicit choice with cost and
aspect consequences. But LongCat at 832x480 turns block-sparse attention into a
silent fallback to dense attention, which is slower and ran a 24GB card out of
memory — worth saying out loud once per run.
"""
mult = int((geom or {}).get("side_multiple") or 0)
if not mult:
return ""
try:
w, h = (int(x) for x in str(video_size or "").lower().split("x", 1))
except (TypeError, ValueError):
return ""
if w % mult == 0 and h % mult == 0:
return ""
near = lambda v: max(mult, int(round(v / float(mult))) * mult)
return (f"video size {w}x{h} is not a multiple of {mult}, which "
f"{(geom or {}).get('family', 'this model')} needs for its fast attention "
f"path — nearest: {near(w)}x{near(h)}")
def frames_for_seconds(seconds: float, fps: int = 8) -> int:
return max(8, int(seconds * fps))
......@@ -2533,13 +2813,15 @@ BUILTIN_TEMPLATES = {
"fps_multiplier": 0,
"upscale_factor": 0,
"matches": 6,
"clip_min_frames": 50,
"clip_max_frames": 70,
"single_clip_max_frames": 50,
# Outcome stings are their own clips and go through the same video model:
# at 8fps, 96-150 frames is 12-19s.
"outcome_min_frames": 96,
"outcome_max_frames": 150,
# 0 = sized from the model. At 8fps Wan's band lands on the historical
# 49-69 frames (6-9s) per fight clip, a shorter 25-41 for the entrances and
# face-off, and 97-149 total for an outcome sting — each snapped to the 4n+1
# the VAE can decode. Type a number here to pin it instead.
"clip_min_frames": 0,
"clip_max_frames": 0,
"single_clip_max_frames": 0,
"outcome_min_frames": 0,
"outcome_max_frames": 0,
"video_size": "832x480",
"keyframe_size": "832x480",
"short_min": 40.0, "short_max": 50.0,
......@@ -2559,18 +2841,17 @@ BUILTIN_TEMPLATES = {
# Each match is 2-4 long scenes instead of 6-8 short ones, but a scene is
# ~4x the frames, so a batch is tuned down to keep one sitting finite.
"matches": 3,
# 173 = 93 + 80 (two segments) ~ 11.5s; 333 = 93 + 80x3 (four) ~ 22s. Both 4n+1.
# At these lengths a 40-50s short is 2-4 scenes rather than 6-8.
"clip_min_frames": 173,
"clip_max_frames": 333,
# The client-side split is lifted for LongCat anyway (the server owns the segment
# loop), but this keeps the number honest if the model is switched back.
"single_clip_max_frames": 333,
# Outcomes follow the same rule as the clips: 93 is one whole segment and the
# shortest honest LongCat ask, 173 is two. The Wan-era 96/150 were neither
# 4n+1 nor segment-aligned, so the server had to round them.
"outcome_min_frames": 93,
"outcome_max_frames": 173,
# 0 = sized from the model, which for LongCat means the segment grid: 93 is
# one segment (~6.2s), and each continuation adds 80, so a fight clip lands
# on 173 (~11.5s) to 333 (~22s) and an entrance on a single 93. Every point
# of 93+80k is also a 16n+13 count block-sparse attention can tile, which is
# why staying on the grid is what keeps BSA on rather than silently falling
# back to dense attention. The Wan-era 96/150 were neither.
"clip_min_frames": 0,
"clip_max_frames": 0,
"single_clip_max_frames": 0,
"outcome_min_frames": 0,
"outcome_max_frames": 0,
# 512, not LongCat's nominal 480p landscape: block-sparse attention tiles the
# latent into (4, 4, 4) chunks and asserts every axis divides evenly, so a side
# has to be a multiple of 64 (the VAE's 16px cell x 4). At 480 the flag is on
......@@ -3317,14 +3598,23 @@ def _fighter_desc_hint(name: str, char_descriptions: dict) -> str:
def _build_match_clip_specs(fps: int, cf_lo: int, cf_hi: int,
long_target: float, f1: str, f2: str) -> list:
long_target: float, f1: str, f2: str,
geom: dict = None, intro_range=None) -> list:
"""Build a match's ordered clip specs: the pre-fight INTRO (a solo entrance for
each fighter, then a referee-officiated face-off), followed by fight clips until
`long_target` playback seconds is reached. The intro clips are extra and do NOT
count toward `long_target`. Prompts are filled later by `_fill_clip_prompt`."""
count toward `long_target`. Prompts are filled later by `_fill_clip_prompt`.
`geom` is the video model's geometry: every picked frame count is snapped onto
the grid that model can actually emit, so the plan's numbers are the ones the
render will use. `intro_range` budgets the entrances and face-off separately —
they are not fight clips, and on a model whose scenes run 11-22 s an entrance
that borrows the fight budget is four times longer than it should be.
"""
specs, ci = [], 0
in_lo, in_hi = intro_range or (cf_lo, cf_hi)
for _role, _who in (("entrance", [f1]), ("entrance", [f2]), ("faceoff", [f1, f2])):
nf = random.randint(cf_lo, cf_hi)
nf = _snap_frames(random.randint(in_lo, in_hi), geom)
specs.append({"idx": ci, "clip_seconds": round(nf / max(1, fps), 2),
"nf": nf, "intensity": "introduction", "role": _role,
"fighters": list(_who), "shot": None, "prompt": None})
......@@ -3336,7 +3626,7 @@ def _build_match_clip_specs(fps: int, cf_lo: int, cf_hi: int,
intensity = ("early exchanges" if round_num == 1
else "midpoint battle" if round_num == 2
else "climactic final exchange")
nf = random.randint(cf_lo, cf_hi)
nf = _snap_frames(random.randint(cf_lo, cf_hi), geom)
# Every other fight clip is a CAMERA-MOTION clip (a bold moving-camera shot
# through the environment) so a match isn't all locked-off frames.
specs.append({"idx": ci, "clip_seconds": round(nf / max(1, fps), 2),
......@@ -3720,7 +4010,7 @@ def _outcome_segments_spec(outcome: str):
def _plan_outcome_shots(prompter, o: dict, char_descriptions: dict,
opponent: str = None) -> None:
opponent: str = None, geom: dict = None) -> None:
"""(Re)generate the multi-clip `shots` for one outcome entry in place.
Each outcome video is assembled from a decisive FINISH clip (how the match
......@@ -3728,6 +4018,11 @@ def _plan_outcome_shots(prompter, o: dict, char_descriptions: dict,
winner + referee raising their arm; both arms for a draw). The total frame
budget (`o['nf']`) is split across the clips per _outcome_segments_spec.
Each shot is its own render, so with `geom` its share is snapped onto the frame
grid the model can emit and `o['nf']` is rewritten to the real total — a 60/40
split of a legal total is two counts that usually are not, which is how an
outcome ends up a different length than it was planned.
For back-compat, o['shot'] / o['prompt'] mirror the FIRST (finish) clip —
they still feed the outcome keyframe and the editable prompt shown in the UI.
"""
......@@ -3739,6 +4034,8 @@ def _plan_outcome_shots(prompter, o: dict, char_descriptions: dict,
shots, allocated = [], 0
for i, (role, frac) in enumerate(spec):
nf = (total - allocated) if i == len(spec) - 1 else max(8, int(round(total * frac)))
if geom:
nf = _snap_frames(nf, geom)
allocated += nf
shot = prompter.outcome_shot(o["fighter"], o["outcome"],
o.get("env_desc") or "", role=role,
......@@ -3747,6 +4044,15 @@ def _plan_outcome_shots(prompter, o: dict, char_descriptions: dict,
"— African township fight, cinematic, dynamic camera, brutal")
shots.append({"role": role, "shot": shot, "prompt": prompt, "nf": int(nf)})
o["shots"] = shots
if geom:
# The snapped shares are what gets rendered; say so, so the plan and the
# output agree on how long this outcome is. target_s is the same number in
# seconds, so it moves with it (scaled rather than recomputed, since fps is
# the caller's business and is not passed here).
snapped = sum(int(sh["nf"]) for sh in shots)
if snapped != total and total > 0 and o.get("target_s"):
o["target_s"] = round(float(o["target_s"]) * snapped / total, 2)
o["nf"] = snapped
if opponent:
o["opponent"] = opponent
o["shot"] = shots[0]["shot"]
......@@ -4462,13 +4768,13 @@ def stage_videos(client: CoderAIClient, video_model: str, out_dir: Path,
env_lora_map: dict = None, env_lora_weight: float = 0.8,
upscale_factor: int = 0, fps_multiplier: int = 0,
video_lora_scale: float = 1.0,
clip_min_frames: int = CLIP_MIN_FRAMES,
clip_max_frames: int = CLIP_MAX_FRAMES,
clip_min_frames: int = 0,
clip_max_frames: int = 0,
video_size: str = "832x480",
short_min: float = 40.0, short_max: float = 50.0,
long_min: float = 65.0, long_max: float = 75.0,
single_clip_max_frames: int = SINGLE_CLIP_MAX_FRAMES,
outcome_min_frames: int = 96, outcome_max_frames: int = 150,
single_clip_max_frames: int = 0,
outcome_min_frames: int = 0, outcome_max_frames: int = 0,
playback_fps: int = 0, upscale_model: str = None,
interpolation_model: str = None):
# PLAYBACK fps decouples the encode/play rate from the model's frame budget:
......@@ -4481,6 +4787,13 @@ def stage_videos(client: CoderAIClient, video_model: str, out_dir: Path,
_log("\n" + "═" * 60)
_log(" STAGE 3 — Videos")
_log("═" * 60)
# What this model can emit, asked of the server (ModelInfo.video) and used for
# every frame budget below: a scene length that suits Wan is wrong for LongCat
# by a factor of four, and the counts off its grid are rounded behind our back.
_geom = video_geometry(video_model, client)
_warn = _size_warning(video_size, _geom)
if _warn:
_log(f" ⚠ {_warn}")
video_dir = out_dir / "videos"
video_dir.mkdir(parents=True, exist_ok=True)
prompts_file = video_dir / "prompts.json"
......@@ -4610,7 +4923,15 @@ def stage_videos(client: CoderAIClient, video_model: str, out_dir: Path,
# Fight-match plan: each match holds a list of clip specs.
fight_plan = []
_cf_lo, _cf_hi = _clip_frame_range(clip_min_frames, clip_max_frames)
_cf_lo, _cf_hi = _clip_frame_range(clip_min_frames, clip_max_frames,
_geom, "clip", fps)
# The entrances and the face-off are not fight clips and get their own, shorter
# budget; only an explicit clip range overrides it.
_in_lo, _in_hi = ((_cf_lo, _cf_hi) if (clip_min_frames and clip_max_frames)
else _clip_frame_range(0, 0, _geom, "intro", fps))
_log(f" clip budget: {_cf_lo}-{_cf_hi}f fight, {_in_lo}-{_in_hi}f intro "
f"@ {fps}fps ({_geom.get('family', '?')} grid "
f"{_geom.get('frame_base')}+{_geom.get('frame_step')}k)")
# Final-assembly duration targets (seconds). The LONG target drives how many
# clips the planner produces: it keeps adding clips (each nf/fps seconds of
# playback) until their summed duration reaches the long target, so the long
......@@ -4632,7 +4953,7 @@ def stage_videos(client: CoderAIClient, video_model: str, out_dir: Path,
for _intro_role, _intro_who in (("entrance", [f1]),
("entrance", [f2]),
("faceoff", [f1, f2])):
_nf = random.randint(_cf_lo, _cf_hi)
_nf = _snap_frames(random.randint(_in_lo, _in_hi), _geom)
clips_spec.append({
"idx": ci, "clip_seconds": round(_nf / max(1, fps), 2),
"nf": _nf, "intensity": "introduction",
......@@ -4647,7 +4968,7 @@ def stage_videos(client: CoderAIClient, video_model: str, out_dir: Path,
else "climactic final exchange")
# Budget frames directly (the model's motion budget, fps-independent),
# within the configured range. Duration = frames / playback fps.
_nf = random.randint(_cf_lo, _cf_hi)
_nf = _snap_frames(random.randint(_cf_lo, _cf_hi), _geom)
clip_seconds = round(_nf / max(1, fps), 2)
clips_spec.append({
"idx": ci, "clip_seconds": clip_seconds,
......@@ -4673,10 +4994,11 @@ def stage_videos(client: CoderAIClient, video_model: str, out_dir: Path,
# Outcome clips budget frames directly (like fight clips) within their own
# configurable range; the total is split across the finish + victory clips and
# each is chained the same way at render time. Duration = frames / playback fps.
_of_lo, _of_hi = _clip_frame_range(outcome_min_frames, outcome_max_frames)
_of_lo, _of_hi = _clip_frame_range(outcome_min_frames, outcome_max_frames,
_geom, "outcome", fps)
def _new_outcome(m, fighter, outcome, opponent):
_onf = random.randint(_of_lo, _of_hi)
_onf = _snap_frames(random.randint(_of_lo, _of_hi), _geom)
return {
"match_name": m["match_name"],
"fighter": fighter, "outcome": outcome, "opponent": opponent,
......@@ -4732,7 +5054,8 @@ def stage_videos(client: CoderAIClient, video_model: str, out_dir: Path,
_log(f" │ [{_pidx}/{_ptot}] {_who} clip{c['idx']:02d}: {shot}")
for o in outcome_plan:
_pidx += 1
_plan_outcome_shots(prompter, o, char_descriptions, o.get("opponent"))
_plan_outcome_shots(prompter, o, char_descriptions, o.get("opponent"),
geom=_geom)
_roles = " → ".join(s["role"] for s in o.get("shots", []))
_log(f" │ [{_pidx}/{_ptot}] {o['fighter']} {o['outcome']} ({_roles}): {o['shot']}")
_log(" ── Phase A complete — all prompts written ──")
......@@ -4791,7 +5114,7 @@ def _stage_videos_render(client, video_model, video_dir, fight_plan, outcome_pla
video_lora_map=None, env_video_lora_map=None,
assemble_finals=True, video_lora_scale=1.0,
video_size="832x480",
single_clip_max_frames=SINGLE_CLIP_MAX_FRAMES,
single_clip_max_frames=0,
playback_fps=0, cancel_check=None):
"""PHASE 3 — render ALL videos from pre-written prompts (video model stays loaded).
......@@ -4817,6 +5140,10 @@ def _stage_videos_render(client, video_model, video_dir, fight_plan, outcome_pla
video_lora_map = video_lora_map or {}
env_video_lora_map = env_video_lora_map or {}
video_slug = _model_slug(video_model)
# What this model can emit — the same geometry the planner budgeted with. Needed
# here for the chaining decision: a model that continues natively must be asked
# for the whole shot in ONE request, and the tail length it expects is its own.
_geom = video_geometry(video_model, client)
use_lora = "lora" in consistency
# Wan2.2 is trained on 16:9 (canonical 832×480 / 1280×720); square 512 is
# off-distribution and worsens motion + colour drift. Render at the native
......@@ -4894,12 +5221,15 @@ def _stage_videos_render(client, video_model, video_dir, fight_plan, outcome_pla
# 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()
# Decided by the model's published geometry rather than its name: a checkpoint
# aliased to something this tool has never heard of still says whether its
# continuation is native.
_longcat = (_geom.get("continuation") == "native"
or "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))
_chunk_max = _single_render_cap(single_clip_max_frames, _geom)
def _render_once(label, prompt, profiles, env, nf, out_path,
fighters=None, init_override=None, step_cb=None,
......@@ -5030,7 +5360,8 @@ def _stage_videos_render(client, video_model, video_dir, fight_plan, outcome_pla
# 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
_tail_frames = (int(_geom.get("cond_frames") or 0) or LONGCAT_COND_FRAMES
) if _longcat else VACE_TAIL_FRAMES
_nparts = len(parts_plan)
_budget = [p[1] for p in parts_plan]
_log(f" ↪ chaining {_nparts} parts {_budget} into one shot"
......@@ -5905,12 +6236,14 @@ def launch_web_ui(default_args):
"clips": []}
fight_plan.append(m)
_geom = web_video_geometry(default_args)
_of_lo, _of_hi = _clip_frame_range(
int(getattr(default_args, "outcome_min_frames", 96) or 96),
int(getattr(default_args, "outcome_max_frames", 150) or 150))
getattr(default_args, "outcome_min_frames", 0),
getattr(default_args, "outcome_max_frames", 0),
_geom, "outcome", fps)
def _new_outcome(fighter, outcome, opp):
_onf = random.randint(_of_lo, _of_hi)
_onf = _snap_frames(random.randint(_of_lo, _of_hi), _geom)
return {"match_name": name, "fighter": fighter, "outcome": outcome,
"opponent": opp, "env": m["env"], "env_desc": env_desc,
"target_s": round(_onf / max(1, fps), 2), "nf": _onf,
......@@ -5957,10 +6290,15 @@ def launch_web_ui(default_args):
prompter = PromptGenerator(client, text_model,
char_descriptions=char_descriptions)
_cf_lo, _cf_hi = _clip_frame_range(
getattr(default_args, "clip_min_frames", CLIP_MIN_FRAMES),
getattr(default_args, "clip_max_frames", CLIP_MAX_FRAMES))
getattr(default_args, "clip_min_frames", 0),
getattr(default_args, "clip_max_frames", 0),
_geom, "clip", fps)
_in_lo, _in_hi = _intro_frame_range(default_args, _geom, fps,
(_cf_lo, _cf_hi))
long_target = float(m["long_target"])
new_clips = _build_match_clip_specs(fps, _cf_lo, _cf_hi, long_target, f1, f2)
new_clips = _build_match_clip_specs(fps, _cf_lo, _cf_hi, long_target,
f1, f2, geom=_geom,
intro_range=(_in_lo, _in_hi))
_ref = m.get("referee") or _referee_for(out_dir, name)
match_avoid = []
_focus_cycle = list(FIGHT_ACTION_FOCUS)
......@@ -5988,7 +6326,8 @@ def launch_web_ui(default_args):
return
_opp = f2 if o.get("fighter") == f1 else f1
try:
_plan_outcome_shots(prompter, o, char_descriptions, _opp)
_plan_outcome_shots(prompter, o, char_descriptions, _opp,
geom=web_video_geometry(default_args, client))
except Exception:
pass
_prog(68 + int(28 * (j + 1) / max(1, len(match_outcomes))),
......@@ -6444,17 +6783,23 @@ def launch_web_ui(default_args):
prompter = PromptGenerator(client, text_model,
char_descriptions=char_descriptions)
long_target = float(m.get("long_target", 70))
_geom = web_video_geometry(default_args, client)
_cf_lo, _cf_hi = _clip_frame_range(
getattr(default_args, "clip_min_frames", CLIP_MIN_FRAMES),
getattr(default_args, "clip_max_frames", CLIP_MAX_FRAMES))
getattr(default_args, "clip_min_frames", 0),
getattr(default_args, "clip_max_frames", 0),
_geom, "clip", fps)
_in_range = _intro_frame_range(default_args, _geom, fps,
(_cf_lo, _cf_hi))
_prog(12, f"re-planning clips for {match_name} @ {fps}fps "
f"({_cf_lo}-{_cf_hi}f/clip)…")
f"({_cf_lo}-{_cf_hi}f/clip, {_in_range[0]}-{_in_range[1]}f "
f"intro, {_geom.get('family', '?')} grid)…")
# Rebuild the clip list with the same planner the full run uses:
# intro clips (entrances + face-off) then fight clips, frame budget
# within the configured range, match length counted in PLAYBACK
# seconds (nf/fps).
new_clips = _build_match_clip_specs(
fps, _cf_lo, _cf_hi, long_target, m["f1"], m["f2"])
fps, _cf_lo, _cf_hi, long_target, m["f1"], m["f2"],
geom=_geom, intro_range=_in_range)
_ref = m.get("referee") or _referee_for(out_dir, match_name)
# Write a fresh, varied prompt for each new clip.
match_avoid = []
......@@ -6593,7 +6938,8 @@ def launch_web_ui(default_args):
o["env_desc"] = m.get("env_desc", o.get("env_desc"))
_opp = m.get("f2") if o.get("fighter") == m.get("f1") else m.get("f1")
try:
_plan_outcome_shots(prompter, o, char_descriptions, _opp)
_plan_outcome_shots(prompter, o, char_descriptions, _opp,
geom=web_video_geometry(default_args, client))
except Exception as e:
_log(f" [replan-outcomes] {o.get('fighter')} {o.get('outcome')}: {e}")
_prog(8 + int(88 * (j + 1) / max(1, len(match_outcomes))),
......@@ -6656,7 +7002,8 @@ def launch_web_ui(default_args):
else f"{fr} {oc}")
_prog(40, f"rewriting {_olabel} prompts…")
try:
_plan_outcome_shots(prompter, o, char_descriptions, _opp)
_plan_outcome_shots(prompter, o, char_descriptions, _opp,
geom=web_video_geometry(default_args, client))
except Exception as e:
_fail(f"could not rewrite outcome prompts: {e}")
return
......@@ -6693,8 +7040,10 @@ def launch_web_ui(default_args):
elw = float(getattr(default_args, "env_lora_weight", 0.8))
vls = float(getattr(default_args, "video_lora_scale", 1.0))
vsz = str(getattr(default_args, "video_size", "832x480") or "832x480")
scm = int(getattr(default_args, "single_clip_max_frames", SINGLE_CLIP_MAX_FRAMES)
or SINGLE_CLIP_MAX_FRAMES)
# 0 = auto: the render phase asks the model's own geometry for its
# per-render ceiling. Forcing the Wan-era 50 here would chain a model
# that never needs chaining.
scm = int(getattr(default_args, "single_clip_max_frames", 0) or 0)
# ── Full match regeneration (text → image → video, end to end) ─────
# One click rebuilds EVERYTHING for this match in order: re-plan the
......@@ -6769,11 +7118,16 @@ def launch_web_ui(default_args):
prompter = PromptGenerator(client, text_model,
char_descriptions=char_descriptions)
long_target = float(m.get("long_target", 70))
_geom = web_video_geometry(default_args, client)
_cf_lo, _cf_hi = _clip_frame_range(
getattr(default_args, "clip_min_frames", CLIP_MIN_FRAMES),
getattr(default_args, "clip_max_frames", CLIP_MAX_FRAMES))
getattr(default_args, "clip_min_frames", 0),
getattr(default_args, "clip_max_frames", 0),
_geom, "clip", fps)
new_clips = _build_match_clip_specs(
fps, _cf_lo, _cf_hi, long_target, m["f1"], m["f2"])
fps, _cf_lo, _cf_hi, long_target, m["f1"], m["f2"],
geom=_geom,
intro_range=_intro_frame_range(default_args, _geom, fps,
(_cf_lo, _cf_hi)))
_ref = m.get("referee") or _referee_for(out_dir, match_name)
match_avoid = []
_focus_cycle = list(FIGHT_ACTION_FOCUS)
......@@ -6801,7 +7155,8 @@ def launch_web_ui(default_args):
o["env_desc"] = m.get("env_desc", o.get("env_desc"))
_opp = m.get("f2") if o.get("fighter") == m.get("f1") else m.get("f1")
try:
_plan_outcome_shots(prompter, o, char_descriptions, _opp)
_plan_outcome_shots(prompter, o, char_descriptions, _opp,
geom=web_video_geometry(default_args, client))
except Exception:
pass
try:
......@@ -8175,20 +8530,28 @@ try{ if(localStorage.getItem('tf-dock')==='1') toggleDock(true); }catch(e){}
<input name=dataset_min_clips type=number min=1 max=500 value="{_v('dataset_min_clips', DATASET_MIN_CLIPS)}"></div>
</div>
<div class=row3 style="margin-top:.4rem">
<div><label>Clip min frames <span class=hint>(per fight clip)</span></label>
<input name=clip_min_frames type=number min=8 max=480 value="{_v('clip_min_frames', 50)}"></div>
<div><label>Clip min frames <span class=hint>(per fight clip; 0 = from the model)</span></label>
<input name=clip_min_frames type=number min=0 max=480 value="{_v('clip_min_frames', 0)}"></div>
<div><label>Clip max frames <span class=hint>(dur = frames÷fps; >cap splits into one shot)</span></label>
<input name=clip_max_frames type=number min=8 max=480 value="{_v('clip_max_frames', 70)}"></div>
<div><label>Single-render cap <span class=hint>(≤81; longer = chained parts)</span></label>
<input name=single_clip_max_frames type=number min=8 max=81 value="{_v('single_clip_max_frames', 50)}"></div>
<input name=clip_max_frames type=number min=0 max=480 value="{_v('clip_max_frames', 0)}"></div>
<div><label>Single-render cap <span class=hint>(0 = the model's own, ≤81)</span></label>
<input name=single_clip_max_frames type=number min=0 max=81 value="{_v('single_clip_max_frames', 0)}"></div>
</div>
<div class=row3 style="margin-top:.4rem">
<div><label>Outcome min frames <span class=hint>(total: finish + victory)</span></label>
<input name=outcome_min_frames type=number min=8 max=480 value="{_v('outcome_min_frames', 96)}"></div>
<div><label>Outcome min frames <span class=hint>(total: finish + victory; 0 = from the model)</span></label>
<input name=outcome_min_frames type=number min=0 max=480 value="{_v('outcome_min_frames', 0)}"></div>
<div><label>Outcome max frames <span class=hint>(split across the 2 outcome clips, then chained)</span></label>
<input name=outcome_max_frames type=number min=8 max=480 value="{_v('outcome_max_frames', 150)}"></div>
<input name=outcome_max_frames type=number min=0 max=480 value="{_v('outcome_max_frames', 0)}"></div>
<div></div>
</div>
<div class=row style="margin-top:.2rem">
<div class=hint>Leave the five frame fields at <b>0</b> to size clips from the video
model itself: its native rate, the frame counts its VAE can decode and how much
one render holds. Wan gives ~6-9 s scenes chained from a frame tail; LongCat
gives 11-22 s takes in one request, so a match is a handful of long shots
instead of a dozen cuts. The entrances and face-off get their own shorter
budget. Typing a number pins it for every model.</div>
</div>
<div class=row style="margin-top:.4rem">
<div><label>Short final assembly <span class=hint>(seconds, min–max)</span></label>
<div style="display:flex;gap:.4rem">
......@@ -11763,11 +12126,11 @@ async function resetPrompts(ev){
"env_lora_weight": float(_fv("env_lora_weight", "0.8") or 0.8),
"video_lora_scale": float(_fv("video_lora_scale", "1.0") or 1.0),
"video_size": _fv("video_size", "832x480") or "832x480",
"clip_min_frames": int(_fv("clip_min_frames", "50") or 50),
"clip_max_frames": int(_fv("clip_max_frames", "70") or 70),
"single_clip_max_frames": int(_fv("single_clip_max_frames", "50") or 50),
"outcome_min_frames": int(_fv("outcome_min_frames", "96") or 96),
"outcome_max_frames": int(_fv("outcome_max_frames", "150") or 150),
"clip_min_frames": int(_fv("clip_min_frames", "0") or 0),
"clip_max_frames": int(_fv("clip_max_frames", "0") or 0),
"single_clip_max_frames": int(_fv("single_clip_max_frames", "0") or 0),
"outcome_min_frames": int(_fv("outcome_min_frames", "0") or 0),
"outcome_max_frames": int(_fv("outcome_max_frames", "0") or 0),
"short_min": float(_fv("short_min", "40") or 40),
"short_max": float(_fv("short_max", "50") or 50),
"long_min": float(_fv("long_min", "65") or 65),
......@@ -12027,11 +12390,11 @@ async function resetPrompts(ev){
ns.dataset_min_clips = int(_fv("dataset_min_clips", str(DATASET_MIN_CLIPS))
or DATASET_MIN_CLIPS)
ns.video_size = _fv("video_size", "832x480") or "832x480"
ns.clip_min_frames = int(_fv("clip_min_frames", "50"))
ns.clip_max_frames = int(_fv("clip_max_frames", "70"))
ns.single_clip_max_frames = int(_fv("single_clip_max_frames", "50"))
ns.outcome_min_frames = int(_fv("outcome_min_frames", "96"))
ns.outcome_max_frames = int(_fv("outcome_max_frames", "150"))
ns.clip_min_frames = int(_fv("clip_min_frames", "0") or 0)
ns.clip_max_frames = int(_fv("clip_max_frames", "0") or 0)
ns.single_clip_max_frames = int(_fv("single_clip_max_frames", "0") or 0)
ns.outcome_min_frames = int(_fv("outcome_min_frames", "0") or 0)
ns.outcome_max_frames = int(_fv("outcome_max_frames", "0") or 0)
ns.short_min = float(_fv("short_min", "40"))
ns.short_max = float(_fv("short_max", "50"))
ns.long_min = float(_fv("long_min", "65"))
......@@ -12442,8 +12805,8 @@ async function resetPrompts(ev){
env_lora_map=env_lora_map, env_lora_weight=_env_lora_weight,
video_lora_scale=getattr(args, "video_lora_scale", 1.0),
video_size=getattr(args, "video_size", "832x480"),
clip_min_frames=getattr(args, "clip_min_frames", CLIP_MIN_FRAMES),
clip_max_frames=getattr(args, "clip_max_frames", CLIP_MAX_FRAMES),
clip_min_frames=getattr(args, "clip_min_frames", 0),
clip_max_frames=getattr(args, "clip_max_frames", 0),
keyframes_only=True,
)
_web_log("\n✓ Keyframe step complete.")
......@@ -12466,8 +12829,8 @@ async function resetPrompts(ev){
keyframe_steps=getattr(args, "keyframe_steps", 28),
keyframe_size=getattr(args, "keyframe_size", "832x480"),
lora_weight=getattr(args, "lora_weight", 0.85),
clip_min_frames=getattr(args, "clip_min_frames", CLIP_MIN_FRAMES),
clip_max_frames=getattr(args, "clip_max_frames", CLIP_MAX_FRAMES),
clip_min_frames=getattr(args, "clip_min_frames", 0),
clip_max_frames=getattr(args, "clip_max_frames", 0),
env_lora_map=env_lora_map, env_lora_weight=_env_lora_weight,
video_lora_scale=getattr(args, "video_lora_scale", 1.0),
video_size=getattr(args, "video_size", "832x480"),
......@@ -12475,9 +12838,9 @@ async function resetPrompts(ev){
short_max=getattr(args, "short_max", 50.0),
long_min=getattr(args, "long_min", 65.0),
long_max=getattr(args, "long_max", 75.0),
single_clip_max_frames=getattr(args, "single_clip_max_frames", SINGLE_CLIP_MAX_FRAMES),
outcome_min_frames=getattr(args, "outcome_min_frames", 96),
outcome_max_frames=getattr(args, "outcome_max_frames", 150),
single_clip_max_frames=getattr(args, "single_clip_max_frames", 0),
outcome_min_frames=getattr(args, "outcome_min_frames", 0),
outcome_max_frames=getattr(args, "outcome_max_frames", 0),
playback_fps=getattr(args, "playback_fps", 0),
upscale_factor=getattr(args, "upscale_factor", 0),
fps_multiplier=getattr(args, "fps_multiplier", 0),
......@@ -12825,28 +13188,30 @@ OUTPUT LAYOUT
help="Environment LoRA rank (default: 16).")
cons_grp.add_argument("--env-lora-weight", type=float, default=0.8, metavar="F",
help="Weight applied to each environment LoRA at generation (default: 0.8).")
cons_grp.add_argument("--clip-min-frames", type=int, default=CLIP_MIN_FRAMES, metavar="N",
help=f"Minimum frames per fight clip (default: {CLIP_MIN_FRAMES}). Clip "
"duration = frames / fps; kept within the model's safe length "
f"(≤{MODEL_MAX_FRAMES}).")
cons_grp.add_argument("--clip-max-frames", type=int, default=CLIP_MAX_FRAMES, metavar="N",
help=f"Maximum frames per fight clip (default: {CLIP_MAX_FRAMES}). A clip "
f"longer than --single-clip-max-frames is split into chained, "
f"concatenated sub-renders (one continuous shot).")
cons_grp.add_argument("--single-clip-max-frames", type=int, default=SINGLE_CLIP_MAX_FRAMES,
cons_grp.add_argument("--clip-min-frames", type=int, default=0, metavar="N",
help="Minimum frames per fight clip. 0 (the default) = AUTO: derive it "
"from the video model's own geometry — Wan's ~6-9 s scenes, "
"LongCat's 11-22 s takes — and snap it to a frame count that "
"model can actually emit. Clip duration = frames / fps.")
cons_grp.add_argument("--clip-max-frames", type=int, default=0, metavar="N",
help="Maximum frames per fight clip. 0 = AUTO (see --clip-min-frames). A "
"clip longer than --single-clip-max-frames is split into chained, "
"concatenated sub-renders (one continuous shot) — unless the model "
"continues natively, in which case the server does it in one request.")
cons_grp.add_argument("--single-clip-max-frames", type=int, default=0,
metavar="N",
help=f"Max frames in ONE model generation (default: {SINGLE_CLIP_MAX_FRAMES}, "
f"≤{MODEL_MAX_FRAMES}). Clips/outcomes longer than this are rendered as "
f"multiple parts chained via each part's last frame and concatenated "
f"into a single shot; the parts are discarded.")
cons_grp.add_argument("--outcome-min-frames", type=int, default=96, metavar="N",
help="Minimum TOTAL frames per outcome video (default: 96). An outcome is "
"a two-clip sequence (finish → victory); this budget is split across "
"them.")
cons_grp.add_argument("--outcome-max-frames", type=int, default=150, metavar="N",
help="Maximum TOTAL frames per outcome video (default: 150). Split across the "
"finish + victory clips, each chained when longer than "
"--single-clip-max-frames.")
help=f"Max frames in ONE model generation (0 = AUTO: the model's own "
f"per-render ceiling, ≤{MODEL_MAX_FRAMES}). Clips/outcomes longer than "
f"this are rendered as multiple parts chained via each part's last "
f"frame and concatenated into a single shot; the parts are discarded.")
cons_grp.add_argument("--outcome-min-frames", type=int, default=0, metavar="N",
help="Minimum TOTAL frames per outcome video (0 = AUTO from the model). An "
"outcome is a two-clip sequence (finish → victory); this budget is "
"split across them and each share is snapped to the model's grid.")
cons_grp.add_argument("--outcome-max-frames", type=int, default=0, metavar="N",
help="Maximum TOTAL frames per outcome video (0 = AUTO from the model). "
"Split across the finish + victory clips, each chained when longer "
"than --single-clip-max-frames.")
cons_grp.add_argument("--short-min", type=float, default=40.0, metavar="SEC",
help="Minimum duration (s) of the SHORT final assembly (default: 40).")
cons_grp.add_argument("--short-max", type=float, default=50.0, metavar="SEC",
......@@ -13164,8 +13529,8 @@ OUTPUT LAYOUT
char_strength=getattr(args, "character_strength", 0.7),
keyframe_steps=getattr(args, "keyframe_steps", 28),
keyframe_size=getattr(args, "keyframe_size", "832x480"),
clip_min_frames=getattr(args, "clip_min_frames", CLIP_MIN_FRAMES),
clip_max_frames=getattr(args, "clip_max_frames", CLIP_MAX_FRAMES),
clip_min_frames=getattr(args, "clip_min_frames", 0),
clip_max_frames=getattr(args, "clip_max_frames", 0),
lora_weight=getattr(args, "lora_weight", 0.85),
env_lora_map=env_lora_map,
env_lora_weight=getattr(args, "env_lora_weight", 0.8),
......@@ -13175,9 +13540,9 @@ OUTPUT LAYOUT
short_max=getattr(args, "short_max", 50.0),
long_min=getattr(args, "long_min", 65.0),
long_max=getattr(args, "long_max", 75.0),
single_clip_max_frames=getattr(args, "single_clip_max_frames", SINGLE_CLIP_MAX_FRAMES),
outcome_min_frames=getattr(args, "outcome_min_frames", 96),
outcome_max_frames=getattr(args, "outcome_max_frames", 150),
single_clip_max_frames=getattr(args, "single_clip_max_frames", 0),
outcome_min_frames=getattr(args, "outcome_min_frames", 0),
outcome_max_frames=getattr(args, "outcome_max_frames", 0),
playback_fps=getattr(args, "playback_fps", 0),
upscale_factor=getattr(args, "upscale_factor", 0),
fps_multiplier=getattr(args, "fps_multiplier", 0),
......
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