township: build the LongCat training dataset from the clips it rendered (v0.2.42)

Step 4b failed in production:

  RuntimeError: longcat LoRA training needs `dataset_config` — a path to a
  SimpleTuner data-backend config describing your captioned video clips

The refusal is right: LongCat trains through SimpleTuner, which wants captioned
VIDEO clips, and synthesising a dataset from a fighter's reference stills would
train something other than a video LoRA. But the UI offered no way to supply one,
so the button could not succeed — and the tool was already sitting on exactly the
corpus SimpleTuner asks for. Every clip it renders was rendered FROM a prompt, and
that prompt is kept beside it in videos/prompts.json. So it now assembles the
dataset rather than demanding it.

Per fighter and per location, against a LongCat video model:

  <out>/video_datasets/character_<name>__<slug>/
    clips/0000.mp4 -> ../../../videos/match_a_vs_b_clip03.mp4
    clips/0000.txt            the prompt that produced those pixels
    cache/vae  cache/text
    data_backend.json         sent as dataset_config
    manifest.json             which render each clip came from

Decisions worth stating:

- SYMLINKS, not copies. A fighter appears in dozens of clips; a dataset per
  fighter would otherwise duplicate the whole render per fighter.
- Captions come from the PLAN, never from filenames, so a caption is always the
  text that actually produced the pixels.
- _2x/_2xfps derivatives are never picked up. They are the upscaler's and the
  frame interpolator's output; training on them teaches their artefacts.
- An outcome clip is training data for BOTH fighters — they are both on screen.
- Clips shorter than the training frame count are filtered out HERE, because
  SimpleTuner's loader drops them silently. A dataset where every clip is too
  short refuses with the numbers: "all 12 clips are shorter than 93 frames
  (longest 70)". Likewise "only 3 usable clips, need 8" and "no rendered clips
  yet (run the Videos step first)" — a skip with a reason, not a stack trace.
- Rebuilt from scratch each time, so a symlink to a deleted render cannot fail
  deep inside SimpleTuner's loader instead of here.

Two knobs on the Run page, in the config and on the CLI: Training clip frames
(default 93 — one whole LongCat segment, and 4n+1 as the VAE requires) and
Minimum usable clips (default 8; upstream suggests 50-100, this is a floor so a
part-rendered show does not quietly train on three).

The backend schema is upstream's documented one, including exactly one
text_embeds backend carrying default: true. The client sends dataset_config only
when it has one; the server has always accepted the field and the in-process Wan
path ignores it.

Checked against the real township render: 151 sources (102 clips + 49 outcomes),
19 usable for the busiest fighter at 93 frames. 82 tests.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182DhR1eFNPQyGmDrpHbedY
parent 0395bb47
......@@ -16,7 +16,7 @@
# Canonical product version for CoderAI — single source of truth. Both the API
# metadata and the admin web UI read from here.
__version__ = "0.2.41"
__version__ = "0.2.42"
__tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
......@@ -243,6 +243,30 @@ captioned video clips (its quickstart suggests 50–100 clips of 10–30 s). cod
synthesise one from the `images` field other targets use: a still-image dataset would
train something other than a video LoRA, and silently.
**The township tool builds one for you.** It has already rendered the corpus SimpleTuner
asks for — every clip it produces was rendered FROM a prompt, and that prompt is kept
beside it in `videos/prompts.json` — so `tools/gen_township_fighters.py` assembles the
dataset instead of asking for it. Train Video LoRAs (step 4b) against a LongCat model and
it writes, per fighter and per location:
```
<out-dir>/video_datasets/character_<name>__<model-slug>/
clips/0000.mp4 -> ../../../videos/match_a_vs_b_clip03.mp4 (symlink)
clips/0000.txt (its prompt)
cache/vae/ cache/text/
data_backend.json <- this is the dataset_config that gets sent
manifest.json <- which render each clip came from
```
Clips are symlinked rather than copied: a fighter appears in dozens of them, and one
dataset per fighter would otherwise duplicate the entire render. Upscaled/interpolated
derivatives (`_2x`, `_2xfps`) are never used — they are the post-processor's output, not
the model's. Two knobs on the Run page govern it: **Training clip frames** (default 93,
one whole LongCat segment, 4n+1) and **Minimum usable clips** (default 8). A clip shorter
than the frame count is dropped by SimpleTuner's loader, so the tool filters on it first
and refuses with a reason — "all 12 clips are shorter than 93 frames (longest 70)" —
rather than handing over a dataset that would train on nothing.
**QLoRA** is `longcat.train_base_precision` — `int8-quanto` (the default),
`int4-quanto` or `fp8-torchao`, no extra installs. A 13.6B transformer plus optimiser
state does not fit a consumer card at bf16, so the quantised base is the normal path
......
"""The SimpleTuner dataset the township tool builds from its own renders.
LongCat LoRA training refuses a folder of stills — it wants captioned video clips.
The tool has already rendered exactly that: every clip came FROM a prompt, kept
beside it in videos/prompts.json. These check the dataset assembled from them is
one SimpleTuner will accept, and that the refusals stay refusals.
"""
import importlib.util
import json
import pathlib
import sys
import pytest
ROOT = pathlib.Path(__file__).resolve().parents[1]
_spec = importlib.util.spec_from_file_location(
"tw_ds", ROOT / "tools" / "gen_township_fighters.py")
TW = importlib.util.module_from_spec(_spec)
sys.modules["tw_ds"] = TW
_spec.loader.exec_module(TW)
def _render(tmp_path, clips, outcomes=(), frames=120):
"""A fake township output folder: a plan plus the mp4s it says were rendered."""
videos = tmp_path / "videos"
videos.mkdir(parents=True)
plan = {"fps": 15, "fight_plan": [], "outcome_plan": []}
match = {"match_name": "match_a_vs_b", "env": "the_yard", "clips": []}
for idx, fighters in enumerate(clips):
match["clips"].append({"idx": idx, "nf": frames, "fighters": list(fighters),
"prompt": f"clip {idx} with {'+'.join(fighters)}"})
(videos / f"match_a_vs_b_clip{idx:02d}.mp4").write_bytes(b"\x00")
plan["fight_plan"].append(match)
for fighter, opponent, outcome in outcomes:
plan["outcome_plan"].append({"match_name": "match_a_vs_b", "fighter": fighter,
"opponent": opponent, "outcome": outcome,
"env": "the_yard", "nf": frames,
"prompt": f"{fighter} {outcome}"})
(videos / f"match_a_vs_b_{fighter}_{outcome}.mp4").write_bytes(b"\x00")
(videos / "prompts.json").write_text(json.dumps(plan))
return tmp_path
def test_a_clip_is_paired_with_the_prompt_it_was_rendered_from(tmp_path):
out = _render(tmp_path, [("a",), ("a", "b")])
found = TW._dataset_clip_sources(out)
assert [f["prompt"] for f in found] == ["clip 0 with a", "clip 1 with a+b"]
def test_a_plan_entry_with_no_rendered_file_is_not_offered(tmp_path):
out = _render(tmp_path, [("a",), ("a",)])
(out / "videos" / "match_a_vs_b_clip01.mp4").unlink()
assert len(TW._dataset_clip_sources(out)) == 1
def test_upscaled_derivatives_are_never_picked_up(tmp_path):
"""_2x/_2xfps files are upscaled and frame-interpolated output, not what the
model generated; training on them teaches the upscaler's artefacts."""
out = _render(tmp_path, [("a",)])
(out / "videos" / "match_a_vs_b_clip00_2x_2xfps.mp4").write_bytes(b"\x00")
sources = TW._dataset_clip_sources(out)
assert len(sources) == 1
assert "2x" not in sources[0]["path"].name
def test_an_outcome_is_training_data_for_both_fighters(tmp_path):
out = _render(tmp_path, [], outcomes=[("a", "b", "ko_win")])
got = TW._dataset_clip_sources(out)
assert len(got) == 1
assert set(got[0]["fighters"]) == {"a", "b"}
def test_a_dataset_holds_only_the_clips_that_profile_appears_in(tmp_path):
out = _render(tmp_path, [("a",), ("b",), ("a", "b")])
cfg, why = TW.build_video_dataset(out, "character", "a", "slug",
num_frames=93, min_clips=1)
assert cfg is not None, why
clips = sorted((cfg.parent / "clips").glob("*.mp4"))
assert len(clips) == 2
captions = sorted(p.read_text().strip() for p in (cfg.parent / "clips").glob("*.txt"))
assert captions == ["clip 0 with a", "clip 2 with a+b"]
def test_an_environment_dataset_selects_by_location(tmp_path):
out = _render(tmp_path, [("a",), ("b",)])
cfg, _ = TW.build_video_dataset(out, "environment", "the_yard", "slug",
num_frames=93, min_clips=1)
assert len(list((cfg.parent / "clips").glob("*.mp4"))) == 2
assert TW.build_video_dataset(out, "environment", "elsewhere", "slug",
num_frames=93, min_clips=1)[0] is None
def test_the_config_is_the_shape_simpletuner_documents(tmp_path):
out = _render(tmp_path, [("a",), ("a",)])
cfg, _ = TW.build_video_dataset(out, "character", "a", "slug",
num_frames=93, resolution=480, min_clips=1)
backends = json.loads(cfg.read_text())
video = [b for b in backends if b["dataset_type"] == "video"]
embeds = [b for b in backends if b["dataset_type"] == "text_embeds"]
assert len(video) == 1 and len(embeds) == 1
# Exactly one text_embeds backend must carry default: true.
assert sum(1 for b in embeds if b.get("default")) == 1
v = video[0]
assert v["type"] == "local"
assert v["caption_strategy"] == "textfile" # the .txt beside each clip
assert v["video"]["num_frames"] == 93
assert v["video"]["min_frames"] == 93
assert pathlib.Path(v["instance_data_dir"]).is_dir()
assert pathlib.Path(v["cache_dir_vae"]).is_dir()
assert pathlib.Path(embeds[0]["cache_dir"]).is_dir()
def test_clips_are_symlinked_not_copied(tmp_path):
"""A fighter is in dozens of clips; copying would duplicate the whole render
once per fighter."""
out = _render(tmp_path, [("a",), ("a",)])
cfg, _ = TW.build_video_dataset(out, "character", "a", "slug",
num_frames=93, min_clips=1)
assert all(p.is_symlink() for p in (cfg.parent / "clips").glob("*.mp4"))
def test_clips_shorter_than_a_segment_are_refused_with_a_reason(tmp_path):
"""SimpleTuner silently drops a clip below min_frames, so an all-short dataset
would train on nothing at all."""
out = _render(tmp_path, [("a",), ("a",)], frames=50)
cfg, why = TW.build_video_dataset(out, "character", "a", "slug",
num_frames=93, min_clips=1)
assert cfg is None
assert "shorter than 93" in why and "longest 50" in why
def test_too_few_clips_is_refused_rather_than_trained_on(tmp_path):
out = _render(tmp_path, [("a",)])
cfg, why = TW.build_video_dataset(out, "character", "a", "slug",
num_frames=93, min_clips=8)
assert cfg is None and "need 8" in why
def test_nothing_rendered_yet_says_so(tmp_path):
(tmp_path / "videos").mkdir()
cfg, why = TW.build_video_dataset(tmp_path, "character", "a", "slug")
assert cfg is None and "no rendered clips" in why
def test_a_rebuild_drops_clips_that_no_longer_exist(tmp_path):
out = _render(tmp_path, [("a",), ("a",)])
cfg, _ = TW.build_video_dataset(out, "character", "a", "slug",
num_frames=93, min_clips=1)
assert len(list((cfg.parent / "clips").glob("*.mp4"))) == 2
(out / "videos" / "match_a_vs_b_clip01.mp4").unlink()
cfg, _ = TW.build_video_dataset(out, "character", "a", "slug",
num_frames=93, min_clips=1)
links = list((cfg.parent / "clips").glob("*.mp4"))
assert len(links) == 1 and links[0].resolve().exists()
def test_the_default_frame_count_obeys_the_vae_rule():
assert (TW.DATASET_FRAMES - 1) % 4 == 0
@pytest.mark.parametrize("model,expected", [
("longcat", True), ("meituan-longcat/LongCat-Video", True),
("Wan-AI/Wan2.2-T2V-A14B-Diffusers", False), ("", False), (None, False),
])
def test_longcat_detection(model, expected):
assert TW._is_longcat_model(model) is expected
......@@ -875,7 +875,8 @@ class CoderAIClient:
steps: int = 800, rank: int = 16,
resolution: int = 512, train_base_model: str = None,
target: str = "image", quantize_4bit: bool = True,
wait: bool = True, session: str = None) -> dict:
wait: bool = True, session: str = None,
dataset_config: str = None) -> dict:
"""Train a per-character or per-environment LoRA on the server.
Blocks until complete.
......@@ -889,6 +890,11 @@ class CoderAIClient:
body["session"] = session
if target == "video":
body["quantize_4bit"] = bool(quantize_4bit)
# A SimpleTuner data-backend config, for the architectures whose training the
# server hands to an external trainer (LongCat). Ignored by the in-process
# Wan path, so it is always safe to send.
if dataset_config:
body["dataset_config"] = str(dataset_config)
if train_base_model:
body["train_base_model"] = train_base_model
if character:
......@@ -2288,6 +2294,7 @@ CONFIG_FIELDS = [
"lora_train_base_model",
"no_env_loras", "env_lora_steps", "env_lora_rank", "env_lora_weight",
"video_loras", "video_lora_scale", "video_size",
"dataset_frames", "dataset_min_clips",
"clip_min_frames", "clip_max_frames", "single_clip_max_frames",
"outcome_min_frames", "outcome_max_frames",
"short_min", "short_max", "long_min", "long_max",
......@@ -3865,10 +3872,184 @@ def stage_env_loras(client: CoderAIClient, image_model: str, out_dir: Path,
"environment", lora_steps, lora_rank, train_base_model)
# ── SimpleTuner video datasets, built from the clips this tool already rendered ──
#
# LongCat LoRA training goes through SimpleTuner, which wants a CAPTIONED VIDEO
# dataset and refuses a folder of stills — correctly, since a still-image dataset
# trains something other than a video LoRA. coderai will not invent one from a
# character's reference images.
#
# This tool, though, has already produced exactly the corpus SimpleTuner asks for:
# every clip it renders was rendered FROM a prompt, and that prompt is kept beside
# it in videos/prompts.json. So the dataset is assembled from the render rather
# than demanded from the operator.
VIDEO_DATASET_DIR = "video_datasets"
# SimpleTuner drops any clip shorter than video.min_frames, so the dataset is built
# around one whole LongCat segment: 93 frames, and 4n+1 as the VAE requires.
DATASET_FRAMES = 93
# Enough clips that the adapter learns the subject rather than one camera move.
# Upstream's quickstart suggests 50-100; this is a floor, not a target, because a
# township render reaches it gradually and refusing at 49 would be useless.
DATASET_MIN_CLIPS = 8
def _is_longcat_model(model_id) -> bool:
return "longcat" in (model_id or "").lower()
def _dataset_clip_sources(out_dir: Path) -> list:
"""Every rendered clip paired with the prompt it was rendered from.
Read from the plan this tool writes, not guessed from filenames, so a caption is
always the text that actually produced those pixels. Post-processed derivatives
(``_2x``/``_2xfps``) are never picked up: they are upscaled and frame-interpolated
output, not what the model generated, and training on them would teach the
adapter the upscaler's artefacts."""
videos = Path(out_dir) / "videos"
plan_path = videos / "prompts.json"
if not plan_path.is_file():
return []
try:
plan = json.loads(plan_path.read_text())
except Exception:
return []
items = []
for match in plan.get("fight_plan") or []:
mname = match.get("match_name") or ""
for clip in match.get("clips") or []:
path = videos / f"{mname}_clip{int(clip.get('idx', 0)):02d}.mp4"
if path.is_file():
items.append({
"path": path,
"prompt": (clip.get("prompt") or "").strip(),
"frames": int(clip.get("nf") or 0),
"fighters": [f for f in (clip.get("fighters") or []) if f],
"env": match.get("env") or "",
})
for oc in plan.get("outcome_plan") or []:
mname = oc.get("match_name") or ""
path = videos / f"{mname}_{oc.get('fighter')}_{oc.get('outcome')}.mp4"
if path.is_file():
# Both fighters are on screen in an outcome, so it is training data for
# either of them.
who = [w for w in (oc.get("fighter"), oc.get("opponent")) if w]
items.append({
"path": path,
"prompt": (oc.get("prompt") or "").strip(),
"frames": int(oc.get("nf") or 0),
"fighters": who,
"env": oc.get("env") or "",
})
return [i for i in items if i["prompt"]]
def _dataset_select(items: list, kind: str, name: str) -> list:
if kind == "environment":
return [i for i in items if i["env"] == name]
return [i for i in items if name in i["fighters"]]
def build_video_dataset(out_dir, kind: str, name: str, slug: str,
num_frames: int = DATASET_FRAMES, resolution: int = 480,
min_clips: int = DATASET_MIN_CLIPS):
"""Write a SimpleTuner data-backend config for one profile; return its path.
Clips are SYMLINKED, not copied: a fighter appears in dozens of them and one
dataset per fighter would otherwise duplicate the entire render. Each symlink
gets a .txt caption beside it, which is what caption_strategy "textfile" reads.
Returns ``(None, reason)`` when there is nothing to train on yet, so the caller
can say which — no plan, no rendered clips, or clips too short for one segment.
"""
sources = _dataset_clip_sources(out_dir)
if not sources:
return None, "no rendered clips yet (run the Videos step first)"
picked = _dataset_select(sources, kind, name)
if not picked:
return None, f"no rendered clips feature {name}"
long_enough = [i for i in picked if i["frames"] >= int(num_frames)]
if not long_enough:
longest = max(i["frames"] for i in picked)
return None, (f"all {len(picked)} clips are shorter than {num_frames} frames "
f"(longest {longest}) — SimpleTuner would drop every one")
if len(long_enough) < max(1, int(min_clips)):
return None, (f"only {len(long_enough)} usable clip(s), need {min_clips} "
f"— render more matches first")
root = Path(out_dir) / VIDEO_DATASET_DIR / f"{kind}_{name}__{slug}"
clips = root / "clips"
# Rebuilt from scratch each time: a stale symlink to a deleted render would make
# SimpleTuner fail deep inside its loader rather than here.
if clips.exists():
for old in clips.iterdir():
try:
old.unlink()
except OSError:
pass
clips.mkdir(parents=True, exist_ok=True)
(root / "cache" / "vae").mkdir(parents=True, exist_ok=True)
(root / "cache" / "text").mkdir(parents=True, exist_ok=True)
for n, item in enumerate(sorted(long_enough, key=lambda i: str(i["path"]))):
stem = clips / f"{n:04d}"
try:
stem.with_suffix(".mp4").symlink_to(item["path"].resolve())
except OSError:
import shutil
shutil.copy2(item["path"], stem.with_suffix(".mp4"))
stem.with_suffix(".txt").write_text(item["prompt"] + "\n", encoding="utf-8")
res = int(resolution)
backend = [
{
"id": f"township-{kind}-{name}",
"type": "local",
"dataset_type": "video",
"instance_data_dir": str(clips.resolve()),
"caption_strategy": "textfile",
"metadata_backend": "discovery",
"cache_dir_vae": str((root / "cache" / "vae").resolve()),
"resolution": res,
"resolution_type": "pixel_area",
"minimum_image_size": res,
"maximum_image_size": res,
"target_downsample_size": res,
"crop": False,
"repeats": 0,
"disabled": False,
"video": {"num_frames": int(num_frames), "min_frames": int(num_frames)},
},
{
# Exactly one text_embeds backend must carry default: true.
"id": "text-embeds",
"type": "local",
"dataset_type": "text_embeds",
"default": True,
"cache_dir": str((root / "cache" / "text").resolve()),
"disabled": False,
"write_batch_size": 128,
},
]
cfg_path = root / "data_backend.json"
cfg_path.write_text(json.dumps(backend, indent=2) + "\n", encoding="utf-8")
# Kept beside it so a dataset can be traced back to the renders it came from.
(root / "manifest.json").write_text(json.dumps({
"kind": kind, "name": name, "model_slug": slug,
"num_frames": int(num_frames), "resolution": res,
"clips": [{"file": f"{n:04d}.mp4", "source": str(i["path"]), "frames": i["frames"]}
for n, i in enumerate(sorted(long_enough, key=lambda i: str(i["path"])))],
}, indent=2) + "\n", encoding="utf-8")
return cfg_path, f"{len(long_enough)} clips"
def _train_profile_video_loras(client: CoderAIClient, video_model: str, out_dir: Path,
names: list, kind: str,
lora_steps: int = 800, lora_rank: int = 16,
quantize_4bit: bool = True) -> dict:
quantize_4bit: bool = True,
dataset_frames: int = DATASET_FRAMES,
dataset_min_clips: int = DATASET_MIN_CLIPS,
video_size: str = "832x480") -> dict:
"""Train one Wan video-DiT LoRA per profile of `kind`, against `video_model`.
Returns the full nested map {name: {slug: path}}. Resumable: skips a profile
......@@ -3900,6 +4081,23 @@ def _train_profile_video_loras(client: CoderAIClient, video_model: str, out_dir:
_log(f" [{i}/{len(names)}] {name}: reusing video LoRA for this model")
continue
lora_name = f"{spec['prefix']}{name}__{slug}"
# LongCat trains through SimpleTuner, which needs a captioned VIDEO dataset.
# Build it from the clips already rendered rather than asking the operator
# for one; a model whose trainer takes reference images needs none.
dataset = None
if _is_longcat_model(video_model):
try:
_res = (video_size or "832x480").lower().split("x")
_short = min(int(_res[0]), int(_res[1]))
except Exception:
_short = 480
dataset, why = build_video_dataset(
out_dir, kind, name, slug, num_frames=dataset_frames,
resolution=_short, min_clips=dataset_min_clips)
if dataset is None:
_log(f" [{i}/{len(names)}] {name}: SKIPPED — {why}")
continue
_log(f" [{i}/{len(names)}] {name}: dataset built from {why} → {dataset}")
_log(f" [{i}/{len(names)}] {name}: training video LoRA "
f"({lora_steps} steps, rank {lora_rank}) — slow on large models…")
try:
......@@ -3907,7 +4105,8 @@ def _train_profile_video_loras(client: CoderAIClient, video_model: str, out_dir:
f"training {kind} video LoRA '{name}'",
client.train_lora, name=lora_name, base_model=video_model,
target="video", quantize_4bit=quantize_4bit,
steps=lora_steps, rank=lora_rank, **{kind: name},
steps=lora_steps, rank=lora_rank,
dataset_config=(str(dataset) if dataset else None), **{kind: name},
)
path = res.get("path")
if path:
......@@ -3927,18 +4126,24 @@ def _train_profile_video_loras(client: CoderAIClient, video_model: str, out_dir:
def stage_video_loras(client: CoderAIClient, video_model: str, out_dir: Path,
char_names: list, lora_steps: int = 800, lora_rank: int = 16,
quantize_4bit: bool = True) -> dict:
"""Train one Wan video LoRA per fighter against the video model."""
quantize_4bit: bool = True, dataset_frames: int = DATASET_FRAMES,
dataset_min_clips: int = DATASET_MIN_CLIPS,
video_size: str = "832x480") -> dict:
"""Train one video LoRA per fighter against the video model."""
return _train_profile_video_loras(client, video_model, out_dir, char_names,
"character", lora_steps, lora_rank, quantize_4bit)
"character", lora_steps, lora_rank, quantize_4bit,
dataset_frames, dataset_min_clips, video_size)
def stage_env_video_loras(client: CoderAIClient, video_model: str, out_dir: Path,
env_names: list, lora_steps: int = 800, lora_rank: int = 16,
quantize_4bit: bool = True) -> dict:
"""Train one Wan video LoRA per environment against the video model."""
quantize_4bit: bool = True, dataset_frames: int = DATASET_FRAMES,
dataset_min_clips: int = DATASET_MIN_CLIPS,
video_size: str = "832x480") -> dict:
"""Train one video LoRA per environment against the video model."""
return _train_profile_video_loras(client, video_model, out_dir, env_names,
"environment", lora_steps, lora_rank, quantize_4bit)
"environment", lora_steps, lora_rank, quantize_4bit,
dataset_frames, dataset_min_clips, video_size)
def _generate_keyframes(client: CoderAIClient, image_model: str, keyframe_dir: Path,
......@@ -7797,6 +8002,12 @@ try{ if(localStorage.getItem('tf-dock')==='1') toggleDock(true); }catch(e){}
<div><label>Video LoRA scale <span class=hint>(×char+env weight, video only)</span></label>
<input name=video_lora_scale type=number min=0 max=2 step=0.05 value="{_v('video_lora_scale', 1.0)}"></div>
</div>
<div class=row style="margin-top:.4rem">
<div><label>Training clip frames <span class=hint>(SimpleTuner/LongCat; 4n+1 — shorter renders are dropped)</span></label>
<input name=dataset_frames type=number min=5 max=333 value="{_v('dataset_frames', DATASET_FRAMES)}"></div>
<div><label>Minimum usable clips <span class=hint>(refuse to train a video LoRA on fewer)</span></label>
<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>
......@@ -11603,6 +11814,10 @@ async function resetPrompts(ev){
ns.env_lora_rank = int(_fv("env_lora_rank", "16"))
ns.env_lora_weight = float(_fv("env_lora_weight", "0.8"))
ns.video_lora_scale = float(_fv("video_lora_scale", "1.0"))
ns.dataset_frames = int(_fv("dataset_frames", str(DATASET_FRAMES))
or DATASET_FRAMES)
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"))
......@@ -11985,13 +12200,19 @@ async function resetPrompts(ev){
f"for {len(char_names)} fighter(s)…")
stage_video_loras(client, _vm, out_dir_r, char_names or [],
lora_steps=getattr(args, "lora_steps", 800),
lora_rank=getattr(args, "lora_rank", 16))
lora_rank=getattr(args, "lora_rank", 16),
dataset_frames=getattr(args, "dataset_frames", DATASET_FRAMES),
dataset_min_clips=getattr(args, "dataset_min_clips", DATASET_MIN_CLIPS),
video_size=getattr(args, "video_size", "832x480"))
if not _no_env_loras and env_names:
_web_log(f" Training environment VIDEO LoRAs ({_model_slug(_vm)}) "
f"for {len(env_names)} location(s)…")
stage_env_video_loras(client, _vm, out_dir_r, env_names or [],
lora_steps=getattr(args, "env_lora_steps", 800),
lora_rank=getattr(args, "env_lora_rank", 16))
lora_rank=getattr(args, "env_lora_rank", 16),
dataset_frames=getattr(args, "dataset_frames", DATASET_FRAMES),
dataset_min_clips=getattr(args, "dataset_min_clips", DATASET_MIN_CLIPS),
video_size=getattr(args, "video_size", "832x480"))
if getattr(args, "only_video_loras", False):
_web_log("\n✓ Video LoRA step complete.")
......@@ -12428,6 +12649,17 @@ OUTPUT LAYOUT
"so the long cut is always filled.")
cons_grp.add_argument("--long-max", type=float, default=75.0, metavar="SEC",
help="Maximum duration (s) of the LONG final assembly (default: 75).")
cons_grp.add_argument(
"--dataset-frames", type=int, default=DATASET_FRAMES, metavar="N",
help="Frames per training clip when a video LoRA is trained through "
"SimpleTuner (LongCat). Clips shorter than this are dropped by the "
"loader, so it also decides which renders qualify. Must be 4n+1 "
f"(default {DATASET_FRAMES}, one whole LongCat segment).")
cons_grp.add_argument(
"--dataset-min-clips", type=int, default=DATASET_MIN_CLIPS, metavar="N",
help="Refuse to train a video LoRA on fewer than this many usable clips "
f"(default {DATASET_MIN_CLIPS}). Upstream suggests 50-100; this is a "
"floor so a part-rendered show does not silently train on three.")
cons_grp.add_argument("--video-lora-scale", type=float, default=1.0, metavar="F",
help="Multiplier applied to the character + environment LoRA weights "
"at VIDEO render time only (keyframe LoRA weight is unaffected; "
......@@ -12685,11 +12917,17 @@ OUTPUT LAYOUT
if _vm and (char_names or []):
stage_video_loras(client, _vm, out_dir, char_names or [],
lora_steps=getattr(args, "lora_steps", 800),
lora_rank=getattr(args, "lora_rank", 16))
lora_rank=getattr(args, "lora_rank", 16),
dataset_frames=getattr(args, "dataset_frames", DATASET_FRAMES),
dataset_min_clips=getattr(args, "dataset_min_clips", DATASET_MIN_CLIPS),
video_size=getattr(args, "video_size", "832x480"))
if (_vm and not getattr(args, "no_env_loras", False) and (env_names or [])):
stage_env_video_loras(client, _vm, out_dir, env_names or [],
lora_steps=getattr(args, "env_lora_steps", 800),
lora_rank=getattr(args, "env_lora_rank", 16))
lora_rank=getattr(args, "env_lora_rank", 16),
dataset_frames=getattr(args, "dataset_frames", DATASET_FRAMES),
dataset_min_clips=getattr(args, "dataset_min_clips", DATASET_MIN_CLIPS),
video_size=getattr(args, "video_size", "832x480"))
if getattr(args, "only_video_loras", False):
_log("\n✓ Video LoRA training complete.")
return
......
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