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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

All notable public changes to `sprite-gen` are recorded here. Versions track the `version:` field in `SKILL.md` and `pyproject.toml`.

## v2.5.1 - More consistent automatic walk and run loops

- Automatic gait cuts now score whether neighbouring poses repeat one cycle later as well as the last-to-first transition. This reduces selection of irregular motion that happens to have a plausible seam, without preferring an earlier or later part of the clip.
- Loop reports expose the selected neighbourhood, normalized repeat error and combined score in `cycle.selection`. Existing period detection, manual cuts, non-gait states and source playback speed are preserved.
- Synthetic regressions cover steady and irregular motion in both time directions. The repeat metric measures temporal consistency; it does not identify anatomical left/right limbs or repair malformed source motion.

## v2.5.0 - API image generation and smoother loop cuts

- New `openai` image provider: `sprite-gen gen --provider openai` calls the OpenAI Images REST API with nothing but `OPENAI_API_KEY` — the credential a headless container (a Modal worker, CI, a SaaS backend) can have, where the `codex` route's interactive ChatGPT login cannot exist. New images go to `/v1/images/generations`, `--ref` switches to `/v1/images/edits` as multipart with the references as repeated `image[]` parts in order (up to 16), and gpt-image's inline base64 is decoded and published as a verified PNG without resizing. Default model `gpt-image-2.5-flare`. `--transparent` asks for `background: transparent` with `output_format: png` — the same measured `native` strategy as codex, and a live run came back 84 % alpha-0 with the subject at alpha 251–254. `--aspect-ratio` maps to one of the gpt-image `size` values that satisfy the API's constraints (both sides divisible by 16, ratio within 1:3..3:1, 655,360–8,294,400 pixels); a ratio with no exact size is refused rather than rounded to a nearby one you would be billed for. A missing or empty key, a rejected key and a failed request are all terminal: this provider never falls back to codex, to another credential, or to a retry.
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.0
version: 2.5.1
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
20 changes: 15 additions & 5 deletions docs/video-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,11 +173,22 @@ was 17). `video-loop` therefore:
2. reads the **global period profile** `P[L] = mean_j |f[j] − f[j+L]|` and takes the
*smallest* local minimum that is within 15 % of the deepest one — exact repeats dip
again at 2× and 3× the period, the half-period look-alike dips noticeably less;
3. only then picks the **start** with the best seam for that period (± 1 frame):
3. only then ranks **starts** for that period (± 1 frame):
`seam = D[i+L-1][i]` over the mean adjacent distance inside the cycle.
Choose the ratio closest to 1 in log space, so a repeated pose at the wrap does
not win just because its distance is small. `next_frame_distance` retains the
distance to the frame after the cycle for diagnostics.
Penalise distance from 1 in log space, so a repeated pose at the wrap does
not win just because its distance is small. For walks and runs, also compare
corresponding frames one cycle apart in the neighbourhood of the cut: a
quarter-cycle on either side, clipped to available source pairs. Add their
mean distance divided by the candidate's mean adjacent distance to the wrap
penalty. This favours a coherent repeating region over an accidental endpoint
match, without preferring an early or late start. Other states keep wrap-only
ranking. `cycle.selection` records the half-open source-pair range, repeat
error, normalised error, wrap penalty and combined score; `next_frame_distance`
retains the single-frame diagnostic. Fixed cuts do not use this ranking.

This neighbourhood check measures temporal consistency, not anatomical leg
identity. A consistently repeated malformed motion can still score well; visual
review remains necessary when correct limb alternation matters.

Windows come from the state profile (`STATE_PROFILES`). **Gait states take theirs in
seconds**, because a stride is a fact about the body, not about the clip length: walk
Expand Down Expand Up @@ -327,4 +338,3 @@ reported) and `foot_x` (the mean foot column inside every cell), plus the spec l
`anchor` as `[foot_x, h]` so a scene stands the sprite on its foot line; `video-set`'s
table row carries `drift_px`. The default stays `none`: existing strips do not change,
and `drift_px` / `foot_sway_px` are 0 when they were not measured.

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.0"
version = "2.5.1"
description = "Component-row pipeline for clean 2D game sprites and animation atlases"
readme = "README.md"
license = "Apache-2.0"
Expand Down
46 changes: 40 additions & 6 deletions sprite_gen/video/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
was 28, run picked 25 where it was 17). So the true period is read from the
GLOBAL profile `P[L] = mean_j |f[j] - f[j+L]|` — its deepest local minimum inside
the state's window — and only then is the start chosen as the best seam for that
period. Idle motion is tiny and not strictly periodic: its window is opened to
period. Gaits also compare the motion around the two cycle boundaries so an
accidental single-frame seam cannot outrank a coherent repeat. Idle motion is
tiny and not strictly periodic: its window is opened to
most of the clip, where the seam is lowest.

Everything downstream is measured, never assumed: the seam ratio (wrap distance
Expand Down Expand Up @@ -153,13 +155,35 @@ def distance_matrix(files: list[Path]) -> np.ndarray:
return D


def _repeat_context(D: np.ndarray, start: int, length: int, step: float) -> dict[str, Any]:
"""Compare a neighbourhood at the cut with the same poses one cycle later.

A quarter-cycle on either side samples half a cycle of motion instead of one
coincident pose. At a clip edge only real pairs participate; no padding or
synthetic wrap is evidence of repetition. Normalise by the candidate's
playback step so the cost is independent of character size and contrast.
This measures temporal consistency, not limb identity.
"""
radius = max(1, length // 4)
first = max(0, start - radius)
stop = min(len(D) - length, start + radius + 1)
error = float(np.mean([D[j, j + length] for j in range(first, stop)]))
return {
"context_pair_range": [first, stop], # half-open source indices
"context_repeat_error": error,
"context_repeat_over_step": error / step if step > 0 else math.inf,
}


def detect_cycle(D: np.ndarray, *, min_len: int, max_len: int, gait_floor: int | None = None) -> dict[str, Any]:
"""Global period (deepest local minimum of the averaged profile) then the best-seam start.
"""Global period, then a wrap-compatible start with coherent gait context.

`gait_floor` (frames) turns on the half-period guard: a period below it is one step
of a two-step gait, so the doubled period is taken when it repeats about as well
(see GAIT_DOUBLE_TOL). Above the floor, ambiguous non-exact harmonics may also
retain two phase occurrences; the report flags that decision for visual review."""
retain two phase occurrences; the report flags that decision for visual review.
Gait starts balance the wrap step with observed repetition around the cut;
other states keep their wrap-only ranking."""
n = D.shape[0]
max_len = min(max_len, n - 2)
if min_len < 2 or max_len < min_len:
Expand Down Expand Up @@ -217,6 +241,7 @@ def detect_cycle(D: np.ndarray, *, min_len: int, max_len: int, gait_floor: int |
# playback step. Minimising distance alone rewards a repeated pose (a stall).
# Log distance penalises steps that are too short or too long symmetrically.
best: dict[str, Any] | None = None
best_score = math.inf
for L in (period - 1, period, period + 1):
if L < min_len or L > max_len:
continue
Expand All @@ -225,12 +250,21 @@ def detect_cycle(D: np.ndarray, *, min_len: int, max_len: int, gait_floor: int |
inner = float(adjacent[i : i + L - 1].mean())
ratio = seam / inner if inner > 0 else math.inf
score = abs(math.log(ratio)) if ratio > 0 else math.inf
if best is None or score < best["wrap_score"]:
selection = None
if gait_floor is not None:
selection = _repeat_context(D, i, L, inner)
selection["method"] = "repeat-context-and-wrap"
selection["wrap_log_error"] = score
score += selection["context_repeat_over_step"]
selection["score"] = score
if best is None or score < best_score:
best_score = score
best = {"start": i, "length": L, "seam": seam, "inner_mean_adjacent": inner,
"ratio": ratio, "wrap_score": score,
"ratio": ratio,
"next_frame_distance": float(D[i, i + L]) if i + L < n else None}
if selection is not None:
best["selection"] = selection
assert best is not None
best.pop("wrap_score")
best["period_global"] = period
best["half_period_guard"] = guard
best["review_recommended"] = review_recommended
Expand Down
69 changes: 69 additions & 0 deletions tests/video/test_gait_window_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""A locally convincing seam must not outrank a coherent repeating trajectory."""
from __future__ import annotations

import math

import pytest

from sprite_gen._deps import np
from sprite_gen.video import loop


def _changing_cadence(*, reverse: bool) -> np.ndarray:
"""Steady fractional-period motion followed by an irregular cadence.

A chance endpoint match in the irregular section looks like a normal playback
step, although the neighbouring poses do not repeat at that interval.
Reversing time places the coherent region at the end instead.
"""
t = np.arange(150, dtype=float)
phase = t * 2 * math.pi / 24.5
phase[75:] += 0.7 * np.sin(np.arange(75) * 0.63)
points = np.stack([np.cos(phase), np.sin(phase)], axis=1)
if reverse:
points = points[::-1]
return np.linalg.norm(points[:, None] - points[None, :], axis=-1).astype(np.float32)


@pytest.mark.parametrize("reverse", [False, True])
def test_gait_prefers_a_coherent_region_over_a_lucky_seam(reverse: bool) -> None:
D = _changing_cadence(reverse=reverse)
result = loop.detect_cycle(D, min_len=20, max_len=28, gait_floor=8)
start, length = result["start"], result["length"]
if reverse:
assert start >= 75, "the clean region can be at the end, not only at the beginning"
else:
assert start + length <= 75, "an accidental seam in irregular motion must lose"
assert length == 24
assert 0.75 < result["ratio"] < 2.0


def test_context_measurement_is_reported_at_the_selected_boundary() -> None:
D = _changing_cadence(reverse=False)
result = loop.detect_cycle(D, min_len=20, max_len=28, gait_floor=8)
evidence = result["selection"]
start, length = result["start"], result["length"]
first, stop = evidence["context_pair_range"]
assert 0 <= first <= start < stop <= len(D) - length
measured = float(np.mean([D[j, j + length] for j in range(first, stop)]))
assert evidence["context_repeat_error"] == pytest.approx(measured)
assert evidence["context_repeat_over_step"] == pytest.approx(measured / result["inner_mean_adjacent"])
assert evidence["method"] == "repeat-context-and-wrap"


def test_non_gait_keeps_its_wrap_only_selection() -> None:
result = loop.detect_cycle(_changing_cadence(reverse=False), min_len=20, max_len=28)
assert result["start"] == 102
assert result["length"] == 23
assert "selection" not in result


def test_exact_gait_remains_a_single_period() -> None:
t = np.arange(96) % 24
phase = t * 2 * math.pi / 24
points = np.stack([np.cos(phase), np.sin(phase)], axis=1)
D = np.linalg.norm(points[:, None] - points[None, :], axis=-1).astype(np.float32)
result = loop.detect_cycle(D, min_len=10, max_len=40, gait_floor=8)
assert result["length"] == 24
assert not result["half_period_guard"]["applied"]
assert result["ratio"] == pytest.approx(1.0)
Loading