From bf49b6fdf513d38f9a9757c3d5f2a2834cc824c3 Mon Sep 17 00:00:00 2001 From: Soohong Kim Date: Tue, 22 Sep 2026 13:25:37 +0900 Subject: [PATCH 1/3] fix(video-loop): refuse a lone step under the gait floor instead of looping half a stride When the deepest repeat sat under the gait floor and nothing near twice it repeated about as well, the selector recorded that the double was worse and then kept the step anyway. A quadruped whose near and far legs read alike produces exactly that profile, so its walk looped half a stride without a word (measured on a production cat clip: 12 frames under a 14-frame floor, best double 22 at 1.37x worse). Now the double is looked for among the profile's own minima within three frames of 2p, since a real stride rarely lands on exactly 2p, and when none repeats within GAIT_DOUBLE_TOL the selector refuses under the same words as a flat profile, so a caller's regenerate-on-no-period rule covers both. The failure report carries the guard and the minima it weighed. --- sprite_gen/video/loop.py | 38 +++++++++++++++++----- tests/video/test_gait_guard_anchor_wide.py | 36 ++++++++++++++++---- 2 files changed, 60 insertions(+), 14 deletions(-) diff --git a/sprite_gen/video/loop.py b/sprite_gen/video/loop.py index 4c0048f..3059f45 100644 --- a/sprite_gen/video/loop.py +++ b/sprite_gen/video/loop.py @@ -67,6 +67,7 @@ # taken when it repeats about as well. The cost is asymmetric — a wrongly doubled cycle # is still a clean two-cycle loop, a halved one walks on one leg. GAIT_DOUBLE_TOL = 0.25 +GAIT_DOUBLE_SEARCH = 3 # frames either side of 2x the step where the full gait's own minimum may sit GAIT_NEAR_EXACT_STEP_FRACTION = 0.10 # no ambiguity extension when repeat error is tiny compared with a playback step ANCHOR_MODES = ("none", "feet") FOOT_BAND = 0.08 # fraction of the frame's own height, measured up from its lowest opaque row @@ -204,14 +205,35 @@ def detect_cycle(D: np.ndarray, *, min_len: int, max_len: int, gait_floor: int | period = min(L for L in cands if prof[L] <= deepest * 1.15 + 1e-4) # abs floor: exact repeats sit at ~0 guard: dict[str, Any] = {"applied": False} if gait_floor is not None and period < gait_floor: - doubles = [L for L in (2 * period - 1, 2 * period, 2 * period + 1) if L in prof and L <= max_len] - if doubles: - L2 = min(doubles, key=lambda L: prof[L]) - if prof[L2] <= prof[period] * (1 + GAIT_DOUBLE_TOL) + 1e-4: - guard = {"applied": True, "from": period, "to": L2, "gait_floor": gait_floor, "depth_ratio": round(prof[L2] / prof[period], 3) if prof[period] > 0 else None} - period = L2 - else: - guard = {"applied": False, "below_floor": period, "gait_floor": gait_floor, "why": "the doubled period repeats too much worse to be the same gait"} + # A period under the gait floor is one step, not a gait. The full gait is the + # repeat at about twice that; the profile's own minimum near 2x (within + # GAIT_DOUBLE_SEARCH) is the candidate, since a real cycle rarely lands on + # exactly 2p. If no such repeat exists, the clip holds one step only — a + # quadruped whose near and far legs read alike does this — and keeping the + # step would loop half a stride without a word. Refuse instead, under the + # same words as a flat profile, so a caller's regenerate-on-no-period rule + # covers both. + doubles = [L for L in cands if abs(L - 2 * period) <= GAIT_DOUBLE_SEARCH and L <= max_len] + if not doubles: + doubles = [L for L in (2 * period - 1, 2 * period, 2 * period + 1) if L in prof and L <= max_len] + L2 = min(doubles, key=lambda L: prof[L]) if doubles else None + if L2 is not None and prof[L2] <= prof[period] * (1 + GAIT_DOUBLE_TOL) + 1e-4: + guard = {"applied": True, "from": period, "to": L2, "gait_floor": gait_floor, "depth_ratio": round(prof[L2] / prof[period], 3) if prof[period] > 0 else None} + period = L2 + else: + guard = {"applied": False, "below_floor": period, "gait_floor": gait_floor, + "double_candidate": L2, "double_depth_ratio": round(prof[L2] / prof[period], 3) if L2 is not None and prof[period] > 0 else None, + "why": "the only repeat is one step: nothing near twice the period repeats about as well"} + exc = SystemExit( + f"video-loop: no periodic cycle found — the only repeat is one step ({period} frames, under the gait floor of " + f"{gait_floor}) and nothing near twice that ({2 * period - GAIT_DOUBLE_SEARCH}..{2 * period + GAIT_DOUBLE_SEARCH}) " + f"repeats within {round(GAIT_DOUBLE_TOL * 100)} % of it" + + (f" (best {L2}: {round(prof[L2] / prof[period], 2)}x worse)" if L2 is not None and prof[period] > 0 else "") + + "; the clip shows a half stride, regenerate it" + ) + exc.diagnostics = {"kind": "periodic", "period_global": period, "half_period_guard": guard, + "profile_minima": [[L, round(prof[L], 5)] for L in sorted(cands, key=lambda L: prof[L])[:6]]} + raise exc # A plausible duration does not prove that a gait contains both phases. If a # second local minimum at twice the period is similarly good, retain both # occurrences at the original fps. This is a conservative ambiguity policy, diff --git a/tests/video/test_gait_guard_anchor_wide.py b/tests/video/test_gait_guard_anchor_wide.py index e38c819..bdd1e79 100644 --- a/tests/video/test_gait_guard_anchor_wide.py +++ b/tests/video/test_gait_guard_anchor_wide.py @@ -9,6 +9,7 @@ from __future__ import annotations import math +import pytest from pathlib import Path import numpy as np @@ -72,17 +73,40 @@ def test_gait_guard_respects_a_period_at_or_above_the_floor(tmp_path: Path) -> N assert gait["half_period_guard"]["applied"] is False -def test_gait_guard_does_not_double_when_the_double_repeats_much_worse() -> None: +def test_gait_guard_refuses_a_lone_step_when_the_double_repeats_much_worse() -> None: # A distance matrix from a scalar signal: a 6-frame beat riding on a steady drift. # 6 repeats best; 12 repeats about twice as badly (twice the drift) — well outside - # GAIT_DOUBLE_TOL — so a gait floor of 10 must leave 6 alone and say why. + # GAIT_DOUBLE_TOL — so under a gait floor of 10 the clip holds one step only. + # Keeping 6 would loop half a stride without a word (a quadruped whose near and + # far legs read alike does exactly this); the selector refuses under the same + # words as a flat profile and says which double it weighed. n = 120 f = np.array([math.sin(2 * math.pi * t / 6) + 0.05 * t for t in range(n)]) D = np.abs(f[:, None] - f[None, :]).astype(np.float32) - c = loop_mod.detect_cycle(D, min_len=4, max_len=40, gait_floor=10) - g = c["half_period_guard"] - assert c["period_global"] == 6 - assert g["applied"] is False and g["below_floor"] == 6 and "why" in g + with pytest.raises(SystemExit) as exc: + loop_mod.detect_cycle(D, min_len=4, max_len=40, gait_floor=10) + assert str(exc.value).startswith("video-loop: no periodic cycle found") + assert "one step (6 frames" in str(exc.value) + g = exc.value.diagnostics["half_period_guard"] + assert g["applied"] is False and g["below_floor"] == 6 and g["double_candidate"] in (11, 12, 13) and "why" in g + + +def test_gait_guard_takes_the_full_gaits_own_minimum_near_twice_the_step() -> None: + # A stationary profile shaped like the cat clip: the one-step repeat dips at 12, + # the full stride's own minimum sits at 22 — not at 24, which is no minimum at + # all — and repeats about as well. Under a 14-frame floor the guard must look + # past 2p +- 1, find 22, and take it. + n = 96 + g = np.full(n, 0.05, dtype=np.float32) + g[12] = 0.02 + g[22] = 0.024 + g[11] = g[13] = g[21] = g[23] = 0.045 + D = np.abs(np.subtract.outer(np.arange(n), np.arange(n))) + D = g[D].astype(np.float32) + c = loop_mod.detect_cycle(D, min_len=12, max_len=36, gait_floor=14) + g2 = c["half_period_guard"] + assert g2["applied"] is True and g2["from"] == 12 and g2["to"] == 22 + assert c["period_global"] == 22 and c["length"] in (21, 22, 23) def test_feet_anchor_declares_the_foot_pivot_for_the_spec_loader(tmp_path: Path) -> None: From 1ad18a226902d31ee5974c88c636704eca3d15bc Mon Sep 17 00:00:00 2001 From: Soohong Kim Date: Tue, 22 Sep 2026 13:25:38 +0900 Subject: [PATCH 2/3] docs(changelog): v2.5.5 unreleased section --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60e9c9a..e4dcc2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable public changes to `sprite-gen` are recorded here. Versions track the `version:` field in `SKILL.md` and `pyproject.toml`. +## Unreleased (v2.5.5) + +- Gait loop selection no longer keeps a lone step. When the deepest repeat sits under the gait floor and no repeat near twice it is about as good, `video-loop` refuses with `no periodic cycle found` (the same words as a flat profile) instead of looping half a stride; the failure report carries the guard's verdict and the minima it weighed. A quadruped whose near and far legs read alike produced exactly this on a walk clip. The doubled period is now searched among the profile's own minima within three frames of twice the step, since a real stride rarely lands on exactly 2x. Clips whose full gait repeats are selected as before. + ## v2.5.4 - Room behind the attack canvas - Attack canvases keep 20 % of their width empty behind the subject (`--trail`, a new wide-canvas margin next to `--lead`). A long weapon drawn back before the strike reached the back edge of the old layout and the frames gate refused the clip; the samurai and katana samples that needed hand-padded stills in v2.5.3 now pass from the raw still. Headroom, lead and every other profile are unchanged, and `--trail 0` restores the previous placement. From 36b792f6cba75a18d85087c0bb311f39715e53d9 Mon Sep 17 00:00:00 2001 From: Soohong Kim Date: Tue, 22 Sep 2026 14:06:58 +0900 Subject: [PATCH 3/3] release: v2.5.5 --- CHANGELOG.md | 2 +- SKILL.md | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4dcc2a..9b8e71a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable public changes to `sprite-gen` are recorded here. Versions track the `version:` field in `SKILL.md` and `pyproject.toml`. -## Unreleased (v2.5.5) +## v2.5.5 - A lone step under the gait floor is refused, not looped - Gait loop selection no longer keeps a lone step. When the deepest repeat sits under the gait floor and no repeat near twice it is about as good, `video-loop` refuses with `no periodic cycle found` (the same words as a flat profile) instead of looping half a stride; the failure report carries the guard's verdict and the minima it weighed. A quadruped whose near and far legs read alike produced exactly this on a walk clip. The doubled period is now searched among the profile's own minima within three frames of twice the step, since a real stride rarely lands on exactly 2x. Clips whose full gait repeats are selected as before. diff --git a/SKILL.md b/SKILL.md index 361a5cc..33dd427 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: sprite-gen -version: 2.5.4 +version: 2.5.5 description: "Generates images and game sprites through GPT or Grok with guided provider choices, separate saved defaults, automatic cleanup and optional curation. Handles sprite requests, ordinary image generation/editing, standalone image-to-video clips (i2v, animate this still, 그록 영상, 이매진 비디오, 스틸 움직여줘, first/last frame, reference-to-video, 영상 이어붙이기, 영상 편집, extend/edit a clip), chroma removal, animation atlases, video loops, 큐레이션뷰, image candidates, 팔레트 스왑, palette swap, recolor, rig layers, engine exports, repeating backgrounds, projected shadows, motion/contact inspection and optional scene composition from existing assets." license: Apache-2.0 depends_on: diff --git a/pyproject.toml b/pyproject.toml index 6c3e1f5..0c24261 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ build-backend = "setuptools.build_meta" name = "sprite-gen" # Release discipline: keep this package metadata version synchronized with # SKILL.md's `version:` field in the same release commit. -version = "2.5.4" +version = "2.5.5" description = "Component-row pipeline for clean 2D game sprites and animation atlases" readme = "README.md" license = "Apache-2.0"