diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c78308..6d2ea85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,16 @@ The changelog is release-oriented and uses a small fixed set of categories: ### Added +- 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. ### Fixed - Explicit quaternion authoring stores all four static components instead of discarding W and reconstructing it from rounded XYZ values. + +### Performance + +- Up to 14x faster repeated YCD sampling with compiled animation samplers, keeping typed outputs and cached-quaternion interpolation order. - YCD precision diagnostics distinguish integer-frame and subframe component errors, report their frame locations and include quaternion angular error independently of component limits. - YCD precision limits cover scalar, vector and quaternion components at subframes, including physical sequence overlaps; component-only policies no longer omit between-frame quaternion checks. - Facial translation, rotation and scale honor the RETAIL channel policy with 16-bit compression, preserving static channels and per-track RAW_FLOAT overrides. diff --git a/fivefury/__init__.py b/fivefury/__init__.py index 4e8aaa0..fd93b42 100644 --- a/fivefury/__init__.py +++ b/fivefury/__init__.py @@ -773,6 +773,7 @@ YCD_VERSION, Ycd, YcdAnimation, + YcdAnimationSampler, YcdAnimationBoneId, YcdAnimationTrack, YcdAnimSequence, @@ -2025,6 +2026,7 @@ "Ycd", "YcdAnimSequence", "YcdAnimation", + "YcdAnimationSampler", "YcdAnimationBoneId", "YcdAnimationTrack", "YcdCameraAnimationSample", diff --git a/fivefury/ycd/__init__.py b/fivefury/ycd/__init__.py index ca7d4a3..5bad023 100644 --- a/fivefury/ycd/__init__.py +++ b/fivefury/ycd/__init__.py @@ -52,6 +52,7 @@ get_ycd_track_format, parse_ycd_uv_clip_binding, ) +from .playback import YcdAnimationSampler from .reader import read_ycd, read_ycd_embedded_resource from .runtime_headers import ( GEN9_YCD_RUNTIME_PROFILE, @@ -73,6 +74,7 @@ "Ycd", "YcdAnimSequence", "YcdAnimation", + "YcdAnimationSampler", "YcdAnimationBoneId", "YcdAnimationTrack", "YcdCameraAnimationSample", diff --git a/fivefury/ycd/channel_samples.py b/fivefury/ycd/channel_samples.py new file mode 100644 index 0000000..b819261 --- /dev/null +++ b/fivefury/ycd/channel_samples.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import numpy as np + +from .sequence_channels import ( + YcdAnimChannel, + YcdCachedQuaternionChannel, + YcdIndirectQuantizeFloatChannel, + YcdLinearFloatChannel, + YcdQuantizeFloatChannel, + YcdRawFloatChannel, +) + + +def decoded_channel_components(channel: YcdAnimChannel) -> np.ndarray: + """Decoded component cycles; no quantization or interpolation is repeated.""" + if isinstance(channel, YcdCachedQuaternionChannel): + raise TypeError("Cached quaternion opcodes require a sequence context") + if isinstance(channel, YcdIndirectQuantizeFloatChannel): + if not len(channel.frames) or not len(channel.values): + return np.array([[channel.offset]], dtype=np.float64) + indices = np.asarray(channel.frames, dtype=np.int64) + values = np.asarray(channel.values, dtype=np.float64) + result = np.full(len(indices), channel.offset, dtype=np.float64) + valid = indices < len(values) + result[valid] = values[indices[valid]] + return result[:, None] + if isinstance( + channel, (YcdRawFloatChannel, YcdQuantizeFloatChannel, YcdLinearFloatChannel) + ): + values = np.asarray(channel.values, dtype=np.float64) + if not len(values): + values = np.array( + [0.0 if isinstance(channel, YcdRawFloatChannel) else channel.offset], + dtype=np.float64, + ) + return values.reshape(-1, 1) + return np.asarray(channel.evaluate_components(0), dtype=np.float64).reshape(1, -1) diff --git a/fivefury/ycd/channel_validation.py b/fivefury/ycd/channel_validation.py index 4e4ef37..3f6b41f 100644 --- a/fivefury/ycd/channel_validation.py +++ b/fivefury/ycd/channel_validation.py @@ -11,15 +11,12 @@ AuthoringStage, ValidationReport, ) +from .channel_samples import decoded_channel_components from .reader import read_ycd from .sequence_channels import ( YcdAnimSequence, YcdCachedQuaternionChannel, YcdChannelType, - YcdIndirectQuantizeFloatChannel, - YcdLinearFloatChannel, - YcdQuantizeFloatChannel, - YcdRawFloatChannel, ) from .write import build_ycd_bytes @@ -43,26 +40,10 @@ def _packed_samples(sequence: YcdAnimSequence, count: int) -> tuple[np.ndarray, width = channel.component_count if component + width > 4: raise ValueError("YCD sequence exceeds four stored components") - if isinstance(channel, YcdIndirectQuantizeFloatChannel): - if channel.frames and len(channel.values): - indices = np.resize(np.asarray(channel.frames, dtype=np.int64), count) - values = np.asarray(channel.values, dtype=np.float64) - samples[:, component] = values[indices] - else: - samples[:, component] = channel.offset - elif isinstance( - channel, - (YcdRawFloatChannel, YcdQuantizeFloatChannel, YcdLinearFloatChannel), - ): - values = np.asarray(channel.values, dtype=np.float64) - if len(values): - samples[:, component] = ( - values if len(values) == count else np.resize(values, count) - ) - else: - samples[:, component] = channel.evaluate_float(0) - else: - samples[:, component : component + width] = channel.evaluate_components(0) + values = decoded_channel_components(channel) + samples[:, component : component + width] = ( + values if len(values) in (1, count) else np.resize(values, (count, width)) + ) component += width return samples, layout diff --git a/fivefury/ycd/model.py b/fivefury/ycd/model.py index aaa5ea5..ef3f985 100644 --- a/fivefury/ycd/model.py +++ b/fivefury/ycd/model.py @@ -38,6 +38,7 @@ if TYPE_CHECKING: from ..authoring.context import BuildContext + from .playback import YcdAnimationSampler YCD_UV_CLIP_MARKER = "_uv_" YCD_UV_UNKNOWN1C = 0x6B002400 @@ -546,6 +547,12 @@ def get_frame_position(self, frame: float) -> YcdFramePosition: frame0=frame0, frame1=frame1, alpha0=alpha0, alpha1=alpha1 ) + def compile(self) -> YcdAnimationSampler: + """Snapshot channels for repeated, stateless native playback.""" + from .playback import YcdAnimationSampler + + return YcdAnimationSampler(self) + def evaluate_tracks( self, frame: float, diff --git a/fivefury/ycd/playback.py b/fivefury/ycd/playback.py new file mode 100644 index 0000000..176cb77 --- /dev/null +++ b/fivefury/ycd/playback.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import numpy as np + +from .._native import _ffi +from ..vector import Quaternion, Vector4 +from .channel_samples import decoded_channel_components +from .sequence_channels import YcdCachedQuaternionChannel, YcdChannelType +from .sequence_tracks import YcdAnimationTrack, is_ycd_rotation_track + +if TYPE_CHECKING: + from .model import YcdAnimation + + +def _sequence_snapshot(sequence): + lanes = [] + layout = -1 + for channel in sequence.channels: + if isinstance(channel, YcdCachedQuaternionChannel): + if not sequence.is_cached_quaternion: + raise ValueError( + "Compile requires cached quaternion opcodes to have a cached sequence layout" + ) + layout = ( + -2 + if channel.channel_type is YcdChannelType.CACHED_QUATERNION2 + else int(channel.quat_index) + ) + elif len(lanes) < 4: + components = decoded_channel_components(channel) + lanes.extend( + np.ascontiguousarray(values) + for values in components.T[: 4 - len(lanes)] + ) + if sequence.is_cached_quaternion and layout == -1: + raise ValueError( + "Cached quaternion sequence is missing its reconstruction channel" + ) + if layout >= 0 and (layout > 3 or len(lanes) != 3): + raise ValueError( + "Cached quaternion reconstruction requires three components and a valid omitted index" + ) + if layout == -2 and len(lanes) != 4: + raise ValueError("Cached quaternion normalization requires four components") + return layout, tuple(lanes) + + +@dataclass(frozen=True, slots=True, init=False) +class YcdAnimationSampler: + """An owned, immutable snapshot. Recompile explicitly after source edits.""" + + frames: int + sequence_frame_limit: int + sample_bytes: int + _plan: object + + def __init__(self, animation: YcdAnimation): + keys = dict.fromkeys( + (int(s.bone_id.bone_id), int(s.bone_id.track)) + for block in animation.sequences + for s in block.anim_sequences + if s.bone_id is not None + ) + index = {key: i for i, key in enumerate(keys)} + blocks = [] + sample_bytes = 0 + for block in animation.sequences: + entries = [] + for sequence in block.anim_sequences: + if sequence.bone_id is None: + continue + key = (int(sequence.bone_id.bone_id), int(sequence.bone_id.track)) + layout, lanes = _sequence_snapshot(sequence) + if layout != -1 and not is_ycd_rotation_track(key[1]): + raise ValueError( + "Cached quaternion layout requires a rotation track" + ) + entries.append((index[key], layout, lanes)) + sample_bytes += sum(lane.nbytes for lane in lanes) + blocks.append(tuple(entries)) + plan = _ffi.ycd_playback_compile( + tuple(blocks), + tuple(keys), + tuple(is_ycd_rotation_track(k[1]) for k in keys), + Vector4, + Quaternion, + ) + object.__setattr__(self, "frames", int(animation.frames)) + object.__setattr__( + self, "sequence_frame_limit", max(int(animation.sequence_frame_limit), 1) + ) + object.__setattr__(self, "sample_bytes", sample_bytes) + object.__setattr__(self, "_plan", plan) + + def evaluate_tracks( + self, + frame: float, + *, + track: int | YcdAnimationTrack | None = None, + interpolate: bool = True, + ) -> dict[tuple[int, int], Vector4 | Quaternion]: + value = float(frame) + if not math.isfinite(value): + raise ValueError("YCD sample frame must be finite") + return _ffi.ycd_playback_evaluate( + self._plan, + value, + not interpolate or isinstance(frame, int), + self.frames, + self.sequence_frame_limit, + None if track is None else int(track), + ) diff --git a/native/animation/bindings.h b/native/animation/bindings.h index e9e062a..4773122 100644 --- a/native/animation/bindings.h +++ b/native/animation/bindings.h @@ -14,5 +14,7 @@ PyObject* mod_ycd_track_sampler_new(PyObject*, PyObject* args); PyObject* mod_ycd_track_sampler_window(PyObject*, PyObject* args); PyObject* mod_ycd_track_sampler_retained_count(PyObject*, PyObject* args); PyObject* mod_ycd_compare_samples(PyObject*, PyObject* args); +PyObject* mod_ycd_playback_compile(PyObject*, PyObject* args); +PyObject* mod_ycd_playback_evaluate(PyObject*, PyObject* args); } // namespace fivefury_py diff --git a/native/animation/ycd_playback.cpp b/native/animation/ycd_playback.cpp new file mode 100644 index 0000000..f64e4e4 --- /dev/null +++ b/native/animation/ycd_playback.cpp @@ -0,0 +1,186 @@ +#include "animation/bindings.h" +#include "math/vector.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace fivefury_py { +namespace { +using namespace fivefury_native; +constexpr const char* name = "fivefury.YcdPlayback"; + +struct Lane { std::size_t offset = 0, count = 0; }; +struct Track { + bool present = false; + int layout = -1; + std::array lanes{}; +}; +struct Block { std::vector tracks, cached; }; +struct Plan { + Plan(PyObject* k, PyObject* v, PyObject* q) + : keys(Py_NewRef(k)), vector_type(Py_NewRef(v)), quaternion_type(Py_NewRef(q)) {} + PyHandle keys, vector_type, quaternion_type; + std::vector track_ids; + std::vector rotations; + std::vector values; + std::vector blocks; +}; + +void destroy(PyObject* capsule) { + delete static_cast(PyCapsule_GetPointer(capsule, name)); +} + +Vec4 packed(const Plan& plan, const Track& track, std::size_t frame) { + Vec4 result; + for (std::size_t i = 0; i < 4; ++i) { + const auto lane = track.lanes[i]; + if (lane.count) result[i] = plan.values[lane.offset + frame % lane.count]; + } + return result; +} + +Vec4 reconstruct(const Vec4& value, int layout) { + if (layout >= 0) return quat_reconstruct(value, static_cast(layout)); + return layout == -2 ? quat_normalize(value) : value; +} + +struct Sample { std::size_t key; Vec4 value; }; + +std::vector evaluate(const Plan& plan, double frame, bool integer, std::size_t frames, + std::size_t limit, bool filtered, int track_id) { + std::vector result; + if (plan.blocks.empty()) return result; + frame = std::max(frame, 0.0); + auto first = static_cast(frame); + if (!integer && frames) first = std::min(first, frames - 1); + auto second = first; + double alpha = 0.0; + if (!integer) { + second = first + 1; + if (frames) second = std::min(second, frames - 1); + alpha = frame - static_cast(first); + } + const bool blend = first != second && alpha > 0.0; + const auto& left = plan.blocks[std::min(first / limit, plan.blocks.size() - 1)]; + const auto& right = plan.blocks[std::min(second / limit, plan.blocks.size() - 1)]; + result.reserve(plan.track_ids.size()); + for (std::size_t key = 0; key < plan.track_ids.size(); ++key) { + if (filtered && plan.track_ids[key] != track_id) continue; + const auto& a = left.tracks[key]; + const auto& b = right.tracks[key]; + if (!a.present && !(blend && b.present)) continue; + Vec4 start = a.present ? reconstruct(packed(plan, a, first % limit), a.layout) + : reconstruct(packed(plan, b, second % limit), b.layout); + Vec4 value = start; + if (blend) { + const auto& cached = left.cached[key]; + if (cached.present) { + const auto local = first % limit; + value = reconstruct(vec4_lerp(packed(plan, cached, local), + packed(plan, cached, local + 1), alpha), cached.layout); + } else { + const Vec4 end = b.present ? reconstruct(packed(plan, b, second % limit), b.layout) : start; + value = plan.rotations[key] ? quat_nlerp(start, end, alpha) : vec4_lerp(start, end, alpha); + } + } + result.push_back({key, value}); + } + return result; +} + +PyObject* typed_result(const Plan& plan, const std::vector& values) { + PyHandle result(PyDict_New()); + if (!result) return nullptr; + for (const auto& sample : values) { + const auto& v = sample.value; + PyHandle value(PyObject_CallFunction( + plan.rotations[sample.key] ? plan.quaternion_type.get() : plan.vector_type.get(), + "dddd", v.x, v.y, v.z, v.w)); + if (!value || PyDict_SetItem(result.get(), PyTuple_GetItem(plan.keys.get(), sample.key), value.get()) < 0) return nullptr; + } + return result.release(); +} +} + +PyObject* mod_ycd_playback_compile(PyObject*, PyObject* args) { + PyObject *blocks, *keys, *rotations, *vector_type, *quaternion_type; + if (!PyArg_ParseTuple(args, "OOOOO:ycd_playback_compile", &blocks, &keys, &rotations, + &vector_type, &quaternion_type)) return nullptr; + const auto count = PyTuple_Size(keys); + const auto block_count = PyTuple_Size(blocks); + if (count < 0 || block_count < 0) return nullptr; + if (PyTuple_Size(rotations) != count || !PyCallable_Check(vector_type) || !PyCallable_Check(quaternion_type)) { + PyErr_SetString(PyExc_ValueError, "Invalid YCD playback key or type layout"); return nullptr; + } + auto plan = std::make_unique(keys, vector_type, quaternion_type); + for (Py_ssize_t key = 0; key < count; ++key) { + PyObject* pair = PyTuple_GetItem(keys, key); + if (PyTuple_Size(pair) != 2) { PyErr_SetString(PyExc_ValueError, "Invalid YCD track key"); return nullptr; } + const long id = PyLong_AsLong(PyTuple_GetItem(pair, 1)); + const int rotation = PyObject_IsTrue(PyTuple_GetItem(rotations, key)); + if (PyErr_Occurred() || rotation < 0) return nullptr; + if (id < std::numeric_limits::min() || id > std::numeric_limits::max()) { + PyErr_SetString(PyExc_OverflowError, "YCD track ID is out of range"); return nullptr; + } + plan->track_ids.push_back(static_cast(id)); + plan->rotations.push_back(rotation != 0); + } + for (Py_ssize_t bi = 0; bi < block_count; ++bi) { + Block block; + block.tracks.resize(count); block.cached.resize(count); + PyObject* entries = PyTuple_GetItem(blocks, bi); + const auto size = PyTuple_Size(entries); + if (size < 0) return nullptr; + for (Py_ssize_t i = 0; i < size; ++i) { + Py_ssize_t key; int layout; PyObject* lanes; + if (!PyArg_ParseTuple(PyTuple_GetItem(entries, i), "niO", &key, &layout, &lanes)) return nullptr; + const auto width = PyTuple_Size(lanes); + if (key < 0 || key >= count || layout < -2 || layout > 3 || width < 0 || width > 4) { + PyErr_SetString(PyExc_ValueError, "Invalid YCD playback sequence"); return nullptr; + } + Track track; track.present = true; track.layout = layout; + for (Py_ssize_t lane = 0; lane < width; ++lane) { + Buffer data; + if (!data.acquire(PyTuple_GetItem(lanes, lane), PyBUF_FORMAT | PyBUF_C_CONTIGUOUS)) return nullptr; + if (!data.len || data.len % sizeof(double) || data.itemsize != sizeof(double) || + data.ndim != 1 || !data.format || std::strcmp(data.format, "d") != 0) { + PyErr_SetString(PyExc_ValueError, "YCD component buffer requires float64 samples"); return nullptr; + } + const auto offset = plan->values.size(); + const auto samples = static_cast(data.len) / sizeof(double); + plan->values.resize(offset + samples); + std::memcpy(plan->values.data() + offset, data.buf, data.len); + track.lanes[lane] = {offset, samples}; + } + block.tracks[key] = track; + if (layout != -1) block.cached[key] = track; + } + plan->blocks.push_back(std::move(block)); + } + return owned_capsule(std::move(plan), name, destroy); +} + +PyObject* mod_ycd_playback_evaluate(PyObject*, PyObject* args) { + PyObject *capsule, *filter; double frame; int integer; Py_ssize_t frames, limit; + if (!PyArg_ParseTuple(args, "OdpnnO:ycd_playback_evaluate", &capsule, &frame, &integer, &frames, &limit, &filter)) return nullptr; + auto* plan = static_cast(PyCapsule_GetPointer(capsule, name)); + if (!plan) return nullptr; + if (!std::isfinite(frame) || frame >= static_cast(PY_SSIZE_T_MAX - 1) || limit <= 0) { + PyErr_SetString(PyExc_ValueError, "Invalid YCD sampling frame or sequence limit"); return nullptr; + } + const long track = filter == Py_None ? 0 : PyLong_AsLong(filter); + if (PyErr_Occurred()) return nullptr; + std::vector values; + { + GilRelease released; + values = evaluate(*plan, frame, integer != 0, std::max(frames, 0), limit, + filter != Py_None, static_cast(track)); + } + return typed_result(*plan, values); +} +} diff --git a/native/python/module.cpp b/native/python/module.cpp index fb21ef9..36d3c45 100644 --- a/native/python/module.cpp +++ b/native/python/module.cpp @@ -106,6 +106,8 @@ PyMethodDef module_methods[] = { {"ycd_track_sampler_window", guarded_call, METH_VARARGS, nullptr}, {"ycd_track_sampler_retained_count", guarded_call, METH_VARARGS, nullptr}, {"ycd_compare_samples", guarded_call, METH_VARARGS, nullptr}, + {"ycd_playback_compile", guarded_call, METH_VARARGS, nullptr}, + {"ycd_playback_evaluate", guarded_call, METH_VARARGS, nullptr}, {"vector_interpolate_many", guarded_call, METH_VARARGS, nullptr}, {nullptr, nullptr, 0, nullptr}, }; diff --git a/setup.py b/setup.py index 8f2ce42..8eca562 100644 --- a/setup.py +++ b/setup.py @@ -19,6 +19,7 @@ def finalize_options(self) -> None: "animation": ( "ycd_bindings.cpp", "ycd_sampling.cpp", + "ycd_playback.cpp", "ycd_precision.cpp", "yed_bindings.cpp", "yed_vm.cpp", diff --git a/tests/animation/ycd/test_ycd_playback.py b/tests/animation/ycd/test_ycd_playback.py new file mode 100644 index 0000000..16397d3 --- /dev/null +++ b/tests/animation/ycd/test_ycd_playback.py @@ -0,0 +1,172 @@ +import copy +from concurrent.futures import ThreadPoolExecutor + +import numpy as np +import pytest + +from fivefury import ( + GameTarget, + Quaternion, + Vector4, + YcdAnimationTrack, + YcdChannelEncoding, + build_ycd_bytes, + read_ycd, +) +from fivefury.ycd.sequence_channels import ( + YcdAnimSequence, + YcdChannelType, + YcdIndirectQuantizeFloatChannel, + YcdLinearFloatChannel, + YcdQuantizeFloatChannel, + YcdRawFloatChannel, +) +from tests.animation.ycd.samples import PAIRS, packed_channels +from tests.support.ycd import facial_rig_builder + + +def assert_tracks(actual, expected): + 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) + + +@pytest.mark.parametrize("game", [GameTarget.GTA5, GameTarget.GTA5_ENHANCED]) +def test_native_plan_matches_mutable_sampling_and_owns_its_snapshot(game, monkeypatch): + builder, _ = facial_rig_builder(game, YcdChannelEncoding.RETAIL, controls=4) + animation = read_ycd(build_ycd_bytes(builder.build_ycds()[0])).animations[0] + original = copy.deepcopy(animation) + sampler = animation.compile() + assert sampler.sample_bytes > 0 + times = [ + -3, + -0.25, + 0, + 0.0, + 0.125, + 16.99, + 286.25, + 286.75, + 287, + 287.5, + 300, + 300.5, + 9999, + ] + for track in (None, YcdAnimationTrack.FACIAL_ROTATION, 999): + for interpolate in (True, False): + for frame in times: + assert_tracks( + sampler.evaluate_tracks( + frame, track=track, interpolate=interpolate + ), + original.evaluate_tracks( + frame, track=track, interpolate=interpolate + ), + ) + animation.sequences.clear() + assert sampler.evaluate_tracks(0) + assert animation.compile().evaluate_tracks(0) == {} + del animation + monkeypatch.setattr( + YcdAnimSequence, + "evaluate_quaternion", + lambda *a: pytest.fail("Python sequence sampling"), + ) + monkeypatch.setattr( + YcdAnimSequence, + "evaluate_vector4", + lambda *a: pytest.fail("Python sequence sampling"), + ) + with ThreadPoolExecutor(4) as pool: + actual = list(pool.map(sampler.evaluate_tracks, times * 3)) + assert all(actual) + + +@pytest.mark.parametrize("layout,left,right", PAIRS) +def test_cached_reconstruction_runs_after_interpolation_in_native_plan( + layout, left, right +): + builder, _ = facial_rig_builder( + GameTarget.GTA5, YcdChannelEncoding.RAW_FLOAT, controls=1 + ) + animation = builder.build_ycds()[0].animations[0] + sequence = next( + s for s in animation.sequences[0].anim_sequences if s.is_rotation_track + ) + sequence.channels = packed_channels((left, right), layout) + sequence.is_cached_quaternion = True + sampler = animation.compile() + for time in (0, 0.25, 0.5, 0.75, 1, 2.25): + assert_tracks(sampler.evaluate_tracks(time), animation.evaluate_tracks(time)) + + +@pytest.mark.parametrize( + "channel", + [ + YcdRawFloatChannel(YcdChannelType.RAW_FLOAT, values=[2, 3, 4]), + YcdQuantizeFloatChannel(YcdChannelType.QUANTIZE_FLOAT, offset=7), + YcdLinearFloatChannel(YcdChannelType.LINEAR_FLOAT, values=[0.1, 0.2]), + YcdIndirectQuantizeFloatChannel( + YcdChannelType.INDIRECT_QUANTIZE_FLOAT, + offset=9, + values=[1, 2], + frames=[1, 99, 0, -1], + ), + ], +) +def test_decoded_channel_cycles_missing_components_and_keys(channel): + builder, _ = facial_rig_builder( + GameTarget.GTA5, YcdChannelEncoding.RAW_FLOAT, controls=1 + ) + animation = builder.build_ycds()[0].animations[0] + seq = animation.sequences[0].anim_sequences[0] + seq.channels = [copy.deepcopy(channel)] + seq.is_cached_quaternion = False + animation.sequences[0].anim_sequences = [seq] + last = copy.deepcopy(seq) + last.bone_id.bone_id += 100 + animation.sequences[1].anim_sequences = [last] + sampler = animation.compile() + for frame in (0, 1, 2, 3, 20.7, 286.5, 287, 300, 2000): + assert_tracks(sampler.evaluate_tracks(frame), animation.evaluate_tracks(frame)) + + +def test_native_sampling_constructs_only_final_public_values(monkeypatch): + builder, _ = facial_rig_builder( + GameTarget.GTA5, YcdChannelEncoding.RETAIL, controls=2 + ) + animation = builder.build_ycds()[0].animations[0] + sampler = animation.compile() + calls = [] + for cls in (Vector4, Quaternion): + original = cls.__post_init__ + + def counted(self, original=original): + calls.append(type(self)) + original(self) + + monkeypatch.setattr(cls, "__post_init__", counted) + result = sampler.evaluate_tracks(18.25) + assert len(calls) == len(result) + for frame in (float("nan"), float("inf")): + with pytest.raises(ValueError, match="finite"): + sampler.evaluate_tracks(frame) + + +def test_edits_to_channel_arrays_and_ids_require_explicit_recompile(): + builder, _ = facial_rig_builder( + GameTarget.GTA5, YcdChannelEncoding.RAW_FLOAT, controls=1 + ) + animation = builder.build_ycds()[0].animations[0] + seq = animation.sequences[0].anim_sequences[0] + seq.channels = [ + YcdRawFloatChannel(YcdChannelType.RAW_FLOAT, values=np.arange(3, dtype=float)) + ] + sampler = animation.compile() + before = sampler.evaluate_tracks(0) + seq.channels[0].values[0] = 999 + seq.bone_id.bone_id = 456 + assert sampler.evaluate_tracks(0) == before + assert animation.compile().evaluate_tracks(0) != before