From 663cabe33da9abe0f0ceaba783f092fb073b5752 Mon Sep 17 00:00:00 2001 From: MrVicho13 Date: Tue, 8 Sep 2026 19:49:50 -0300 Subject: [PATCH] Add compiled clip playback with shared timing semantics --- CHANGELOG.md | 1 + docs/YCD_PLAYBACK.md | 70 +++++++++++++++++++ fivefury/__init__.py | 2 + fivefury/ycd/__init__.py | 3 +- fivefury/ycd/model.py | 23 +++--- fivefury/ycd/playback.py | 48 +++++++++++++ fivefury/ycd/sampling.py | 12 ++++ tests/README.md | 1 + tests/animation/ycd/test_ycd_clip_sampler.py | 56 +++++++++++++++ .../animation/ycd/test_ycd_playback_corpus.py | 53 ++++++++++++++ tests/performance/bench_ycd_playback.py | 21 ++++++ 11 files changed, 279 insertions(+), 11 deletions(-) create mode 100644 docs/YCD_PLAYBACK.md create mode 100644 tests/animation/ycd/test_ycd_clip_sampler.py create mode 100644 tests/animation/ycd/test_ycd_playback_corpus.py create mode 100644 tests/performance/bench_ycd_playback.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d2ea85..d7ea2a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The changelog is release-oriented and uses a small fixed set of categories: ### Added +- Compiled YCD clip samplers preserve the public phase/time evaluation interface while sharing immutable animation plans. - Immutable compiled YCD animation samplers retain owned channel data for repeated native evaluation without stale caches after source edits. - Per-track quaternion encoding overrides allow explicit facial rotations while keeping body and camera packing unchanged. diff --git a/docs/YCD_PLAYBACK.md b/docs/YCD_PLAYBACK.md new file mode 100644 index 0000000..a8509c0 --- /dev/null +++ b/docs/YCD_PLAYBACK.md @@ -0,0 +1,70 @@ +# Compiled YCD playback + +Mutable YCD models remain the authoring interface. Compile once when beginning +repeated playback, not once per frame: + +```python +from fivefury import read_ycd + +ycd = read_ycd("scene-0.ycd") +clip = ycd.get_clip("actor_dual-0") +sampler = clip.compile() +tracks = sampler.evaluate_tracks_at_time(1.234) +``` + +`YcdAnimation.compile()` returns `YcdAnimationSampler`, with the existing +`evaluate_tracks(frame, track=None, interpolate=True)` shape. A clip returns +`YcdClipSampler`, with `evaluate_tracks_at_time` and `evaluate_tracks_at_phase`. +Results remain dictionaries keyed by `(bone_id, track_id)`, containing nominal +`Vector4` or `Quaternion` objects. There are no application-facing native handles. + +## Lifetime and edits + +- Compilation copies decoded channel cycles into owned native float64 buffers. + It does not retain the editable model, reader, RPF or external buffer. +- A sampler is immutable. Source edits deliberately do not change it; recompile + after editing channels, bindings, sequence structure or clip timing. +- Dropping the last sampler reference releases its storage. There is no global + plan cache, sampled-frame cache or retained per-seek state. +- `sample_bytes` reports copied channel data, excluding metadata and output + objects. Static channels keep one value per component rather than a full frame + array. Indirect channels retain their decoded frame cycle. +- Samplers can be shared by concurrent actors. Evaluation releases the GIL for + native sampling and allocates independent output state per call. +- Multiple `YcdClipSampler` instances can explicitly share one compiled + `animation`, with independent duration/loop metadata. + +Do not modify a model concurrently with its compilation. An already compiled +sampler is independent of subsequent mutations and seeks. + +## Sampling contract + +The compiled path uses decoded channel values; it does not requantize or change +the animation. Opcode 7/8 reconstruction or normalization occurs after scalar +interpolation, using the current block's overlapping sample. Ordinary quaternion +tracks retain their existing shortest-path normalized interpolation. + +Integer arguments and `interpolate=False` retain integer evaluation behavior, +including cyclic channel lookup. Fractional requests retain the existing endpoint +clamping and missing-track rules. Nonfinite or unrepresentably large native frame +indices are rejected. + +Clip time and phase conversion are shared with `YcdClipAnimation`. The existing +methods clamp to clip duration/phase; they do not apply an implicit rate change +or wrap solely because the loop flag is set. A caller that currently computes +looped phase or time must keep doing so. This is a performance path, not a change +to the runtime interpretation of clip timing metadata. + +The compilation boundary expects valid decoded or built channel layouts. A +cached quaternion opcode without a matching cached sequence, an invalid omitted +component or an incomplete cached component set is rejected rather than guessed. + +## Verification + +Unit tests compare mutable and compiled results at integer/fractional frames, +sequence overlaps, out-of-range seeks, channel cycles and missing keys. They also +cover edits, detached lifetime, concurrent evaluation and output construction. +Performance cases are in `tests/performance/bench_ycd_playback.py`. + +Library sampling improvements are not viewer FPS predictions: rendering, +skinning, expression evaluation and scheduling remain separate workloads. diff --git a/fivefury/__init__.py b/fivefury/__init__.py index fd93b42..b044c0a 100644 --- a/fivefury/__init__.py +++ b/fivefury/__init__.py @@ -774,6 +774,7 @@ Ycd, YcdAnimation, YcdAnimationSampler, + YcdClipSampler, YcdAnimationBoneId, YcdAnimationTrack, YcdAnimSequence, @@ -2027,6 +2028,7 @@ "YcdAnimSequence", "YcdAnimation", "YcdAnimationSampler", + "YcdClipSampler", "YcdAnimationBoneId", "YcdAnimationTrack", "YcdCameraAnimationSample", diff --git a/fivefury/ycd/__init__.py b/fivefury/ycd/__init__.py index 5bad023..b4a40a9 100644 --- a/fivefury/ycd/__init__.py +++ b/fivefury/ycd/__init__.py @@ -52,7 +52,7 @@ get_ycd_track_format, parse_ycd_uv_clip_binding, ) -from .playback import YcdAnimationSampler +from .playback import YcdAnimationSampler, YcdClipSampler from .reader import read_ycd, read_ycd_embedded_resource from .runtime_headers import ( GEN9_YCD_RUNTIME_PROFILE, @@ -75,6 +75,7 @@ "YcdAnimSequence", "YcdAnimation", "YcdAnimationSampler", + "YcdClipSampler", "YcdAnimationBoneId", "YcdAnimationTrack", "YcdCameraAnimationSample", diff --git a/fivefury/ycd/model.py b/fivefury/ycd/model.py index ef3f985..845a734 100644 --- a/fivefury/ycd/model.py +++ b/fivefury/ycd/model.py @@ -18,6 +18,7 @@ from ..metahash import MetaHash from ..resource import ResourceHeader from ..vector import Quaternion, Vector3, Vector4, interpolate_vector4_many +from .sampling import animation_frame_at_phase, animation_frame_at_time from .sequences import ( YcdAnimationTrack, YcdAnimSequence, @@ -38,7 +39,7 @@ if TYPE_CHECKING: from ..authoring.context import BuildContext - from .playback import YcdAnimationSampler + from .playback import YcdAnimationSampler, YcdClipSampler YCD_UV_CLIP_MARKER = "_uv_" YCD_UV_UNKNOWN1C = 0x6B002400 @@ -950,6 +951,15 @@ class YcdClipAnimation(YcdClip): reserved_68h: int = 0 reserved_6ch: int = 0 + def compile(self) -> YcdClipSampler: + """Snapshot clip timing and channels for stateless native playback.""" + from .playback import YcdClipSampler + + return YcdClipSampler( + self.animation.compile() if self.animation is not None else None, + self.duration, self.is_looped, + ) + @property def duration(self) -> float: return max(0.0, float(self.end_time) - float(self.start_time)) @@ -975,17 +985,10 @@ def has_facial_animation(self) -> bool: return bool(self.animation and self.animation.has_facial_animation) def get_animation_frame(self, phase: float) -> float: - if self.animation is None or self.animation.frames <= 1: - return 0.0 - phase_value = min(max(float(phase), 0.0), 1.0) - return phase_value * float(self.animation.frames - 1) + return animation_frame_at_phase(self.animation.frames if self.animation else 0, phase) def get_animation_frame_at_time(self, seconds: float) -> float: - clip_duration = max(float(self.duration), 0.0) - if clip_duration <= 0.0: - return 0.0 - phase = min(max(float(seconds) / clip_duration, 0.0), 1.0) - return self.get_animation_frame(phase) + return animation_frame_at_time(self.animation.frames if self.animation else 0, self.duration, seconds) def evaluate_tracks_at_phase( self, diff --git a/fivefury/ycd/playback.py b/fivefury/ycd/playback.py index 176cb77..9fe0f06 100644 --- a/fivefury/ycd/playback.py +++ b/fivefury/ycd/playback.py @@ -9,6 +9,7 @@ from .._native import _ffi from ..vector import Quaternion, Vector4 from .channel_samples import decoded_channel_components +from .sampling import animation_frame_at_phase, animation_frame_at_time from .sequence_channels import YcdCachedQuaternionChannel, YcdChannelType from .sequence_tracks import YcdAnimationTrack, is_ycd_rotation_track @@ -114,3 +115,50 @@ def evaluate_tracks( self.sequence_frame_limit, None if track is None else int(track), ) + + +@dataclass(frozen=True, slots=True) +class YcdClipSampler: + """Clip timing snapshot; shares an explicitly owned immutable animation plan.""" + + animation: YcdAnimationSampler | None + duration: float + is_looped: bool = False + + def get_animation_frame(self, phase: float) -> float: + return animation_frame_at_phase( + self.animation.frames if self.animation else 0, phase + ) + + def get_animation_frame_at_time(self, seconds: float) -> float: + return animation_frame_at_time( + self.animation.frames if self.animation else 0, self.duration, seconds + ) + + def evaluate_tracks_at_phase( + self, + phase: float, + *, + track: int | YcdAnimationTrack | None = None, + interpolate: bool = True, + ) -> dict[tuple[int, int], Vector4 | Quaternion]: + if self.animation is None: + return {} + return self.animation.evaluate_tracks( + self.get_animation_frame(phase), track=track, interpolate=interpolate + ) + + def evaluate_tracks_at_time( + self, + seconds: float, + *, + track: int | YcdAnimationTrack | None = None, + interpolate: bool = True, + ) -> dict[tuple[int, int], Vector4 | Quaternion]: + if self.animation is None: + return {} + return self.animation.evaluate_tracks( + self.get_animation_frame_at_time(seconds), + track=track, + interpolate=interpolate, + ) diff --git a/fivefury/ycd/sampling.py b/fivefury/ycd/sampling.py index 46e7276..346d092 100644 --- a/fivefury/ycd/sampling.py +++ b/fivefury/ycd/sampling.py @@ -11,6 +11,18 @@ YcdTrackSample = float | Vector3 | Quaternion +def animation_frame_at_phase(frames: int, phase: float) -> float: + if frames <= 1: + return 0.0 + return min(max(float(phase), 0.0), 1.0) * float(frames - 1) + + +def animation_frame_at_time(frames: int, duration: float, seconds: float) -> float: + if duration <= 0.0: + return 0.0 + return animation_frame_at_phase(frames, float(seconds) / duration) + + @dataclass(frozen=True, slots=True) class YcdSampleWindow: components: tuple[Sequence[float], ...] diff --git a/tests/README.md b/tests/README.md index 5a9ddd2..4fc2a2c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -81,6 +81,7 @@ game binaries or private samples: | `FIVEFURY_TEST_FACE_PROBE_ROOT` | External probe export with `probe-report.json` and `loose/` | | `FIVEFURY_TEST_YED_CONTRACT_CORPUS` | External `a_c_cat_01.yed`, `ambient.yed`, `player.yed` and `ig_natalia.yed` samples for the YED contract audit | | `FIVEFURY_TEST_YCD_FACIAL_CORPUS` | External lossless facial NPZ files, recursively discovered; `translations_tags`/`rotations_tags` identify arrays named `translations_N`/`rotations_N` containing time then XYZ/XYZW samples | +| `FIVEFURY_TEST_YCD_PLAYBACK_CORPUS` | External loose YCD resources for differential compiled/mutable sampling at clip times and physical sequence overlaps | The face-probe integration test also requires `FIVEFURY_TEST_FACE_PROBE_METADATA`, the exact metadata registration path relative to `loose/`. It compares resolved diff --git a/tests/animation/ycd/test_ycd_clip_sampler.py b/tests/animation/ycd/test_ycd_clip_sampler.py new file mode 100644 index 0000000..687ff2f --- /dev/null +++ b/tests/animation/ycd/test_ycd_clip_sampler.py @@ -0,0 +1,56 @@ +import pytest + +from fivefury import GameTarget, YcdChannelEncoding +from tests.support.ycd import facial_rig_builder + + +@pytest.mark.parametrize("looped", [False, True]) +@pytest.mark.parametrize("rate", [0.5, 1.0, 2.0]) +def test_clip_snapshot_matches_existing_timing_and_phase_contract(looped, rate): + builder, _ = facial_rig_builder( + GameTarget.GTA5, YcdChannelEncoding.RETAIL, controls=1 + ) + clip = builder.build_ycds()[0].clips[0] + clip.flags = int(looped) + clip.rate = rate + clip.start_time = 2 + clip.end_time = 12 + compiled = clip.compile() + assert compiled.is_looped == clip.is_looped + for seconds in (-5, 0, 0.125, 9.55, 9.575, 10, 20, 100): + assert compiled.get_animation_frame_at_time( + seconds + ) == clip.get_animation_frame_at_time(seconds) + for interpolate in (True, False): + expected = clip.evaluate_tracks_at_time(seconds, interpolate=interpolate) + actual = compiled.evaluate_tracks_at_time(seconds, interpolate=interpolate) + assert actual.keys() == expected.keys() + for key in actual: + assert tuple(actual[key]) == pytest.approx( + tuple(expected[key]), abs=2e-12 + ) + for phase in (-1, 0, 0.001, 0.5, 1, 2): + assert compiled.get_animation_frame(phase) == clip.get_animation_frame(phase) + expected = clip.evaluate_tracks_at_phase(phase) + actual = compiled.evaluate_tracks_at_phase(phase) + assert actual.keys() == expected.keys() + for key in actual: + assert tuple(actual[key]) == pytest.approx(tuple(expected[key]), abs=2e-12) + before = compiled.evaluate_tracks_at_time(2.25) + clip.end_time = 0 + clip.animation = None + assert compiled.evaluate_tracks_at_time(2.25) == before + assert clip.compile().evaluate_tracks_at_time(10) == {} + + +def test_clip_sampler_track_filter_and_stateless_seeks(): + builder, _ = facial_rig_builder( + GameTarget.GTA5_ENHANCED, YcdChannelEncoding.RETAIL, controls=1 + ) + sampler = builder.build_ycds()[0].clips[0].compile() + original = sampler.evaluate_tracks_at_time(1.234) + sampler.evaluate_tracks_at_time(9.78) + assert sampler.evaluate_tracks_at_time(1.234) == original + assert sampler.evaluate_tracks_at_time(1, track=999) == {} + rotations = sampler.evaluate_tracks_at_time(1, track=26) + assert rotations and all(key[1] == 26 for key in rotations) diff --git a/tests/animation/ycd/test_ycd_playback_corpus.py b/tests/animation/ycd/test_ycd_playback_corpus.py new file mode 100644 index 0000000..946d6b9 --- /dev/null +++ b/tests/animation/ycd/test_ycd_playback_corpus.py @@ -0,0 +1,53 @@ +import os +from pathlib import Path + +import pytest + +from fivefury import YcdClipAnimation, read_ycd + + +@pytest.mark.integration("FIVEFURY_TEST_YCD_PLAYBACK_CORPUS") +def test_compiled_playback_matches_external_clip_corpus(): + paths = sorted(Path(os.environ["FIVEFURY_TEST_YCD_PLAYBACK_CORPUS"]).rglob("*.ycd")) + assert paths + for path in paths: + ycd = read_ycd(path) + for clip in ycd.clips: + if not isinstance(clip, YcdClipAnimation): + continue + sampler = clip.compile() + frames = {0, 1, clip.get_animation_frame(1)} + if clip.animation: + frames.update( + boundary + delta + for boundary in range( + max(clip.animation.sequence_frame_limit, 1), + clip.animation.frames, + max(clip.animation.sequence_frame_limit, 1), + ) + for delta in (-0.75, -0.5, -0.25, 0, 0.25) + ) + for frame in frames: + expected = clip.animation.evaluate_tracks(frame) + actual = sampler.animation.evaluate_tracks(frame) + assert actual.keys() == expected.keys() + for key in expected: + assert type(actual[key]) is type(expected[key]) + assert tuple(actual[key]) == pytest.approx( + tuple(expected[key]), abs=2e-12 + ) + for seconds in ( + -1, + 0, + 0.017, + clip.duration / 2, + clip.duration, + clip.duration + 1, + ): + expected = clip.evaluate_tracks_at_time(seconds) + actual = sampler.evaluate_tracks_at_time(seconds) + assert actual.keys() == expected.keys() + for key in expected: + assert tuple(actual[key]) == pytest.approx( + tuple(expected[key]), abs=2e-12 + ) diff --git a/tests/performance/bench_ycd_playback.py b/tests/performance/bench_ycd_playback.py new file mode 100644 index 0000000..5b1e23e --- /dev/null +++ b/tests/performance/bench_ycd_playback.py @@ -0,0 +1,21 @@ +import pytest + +from fivefury import GameTarget, YcdChannelEncoding, build_ycd_bytes, read_ycd +from tests.support.ycd import facial_rig_builder + +pytestmark = pytest.mark.performance + + +@pytest.fixture +def playback_animation(): + builder, _ = facial_rig_builder(GameTarget.GTA5_ENHANCED, YcdChannelEncoding.RETAIL) + return read_ycd(build_ycd_bytes(builder.build_ycds()[0])).animations[0] + + +def test_mutable_ycd_playback(benchmark, playback_animation): + assert benchmark(playback_animation.evaluate_tracks, 123.37) + + +def test_compiled_ycd_playback(benchmark, playback_animation): + sampler = playback_animation.compile() + assert benchmark(sampler.evaluate_tracks, 123.37)