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 @@ ...@@ -16,7 +16,7 @@
# Canonical product version for CoderAI — single source of truth. Both the API # Canonical product version for CoderAI — single source of truth. Both the API
# metadata and the admin web UI read from here. # metadata and the admin web UI read from here.
__version__ = "0.2.41" __version__ = "0.2.42"
__tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference." __tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere. # Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
...@@ -243,6 +243,30 @@ captioned video clips (its quickstart suggests 50–100 clips of 10–30 s). cod ...@@ -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 synthesise one from the `images` field other targets use: a still-image dataset would
train something other than a video LoRA, and silently. 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), **QLoRA** is `longcat.train_base_precision` — `int8-quanto` (the default),
`int4-quanto` or `fp8-torchao`, no extra installs. A 13.6B transformer plus optimiser `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 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
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment