Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

## 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.

## 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.
Expand Down
2 changes: 1 addition & 1 deletion SKILL.md
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
38 changes: 30 additions & 8 deletions sprite_gen/video/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 30 additions & 6 deletions tests/video/test_gait_guard_anchor_wide.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from __future__ import annotations

import math
import pytest
from pathlib import Path

import numpy as np
Expand Down Expand Up @@ -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:
Expand Down
Loading