Repository: nfrechette/acl (cpp)

Issue:
Allow frame stripping per clip
Frame stripping is currently only implemented at the database level. But, we could also support it per clip very easily.
We should expose a new flag in the compression settings to optionally drop whole key frames up to some percentage.

We should always remove whole key frames that keep us below the current error threshold. These are safe to remove. However, doing so will increase the compression time perhaps considerably compared to what we have now. The current 'Medium' compression level should become 'Low' with 'Medium' and up now performing the safe key frame removal automatically.


Discussion:
> We should always remove whole key frames that keep us below the current error threshold.

If you're actively working on this part now, we're happy to wait for 2.1.  Otherwise, I can take a stab at it.  LMK.
I haven't begun working towards 2.1 yet so go ahead!
I will begin work on this shortly, stay tuned!

Patch:
diff --git a/includes/acl/compression/compression_settings.h b/includes/acl/compression/compression_settings.h
--- a/includes/acl/compression/compression_settings.h
+++ b/includes/acl/compression/compression_settings.h
@@ -131,6 +131,56 @@ namespace acl
 		error_result is_valid() const;
 	};
 
+	//////////////////////////////////////////////////////////////////////////
+	// Encapsulates all frame stripping compression settings.
+	// A keyframe represents all samples at a particular point in time.
+	// Keyframe stripping is a destructive process and visual fidelity can degrade
+	// considerably. If a keyframe is identified as a stripping candidate, it is
+	// entirely removed and reconstructed through linear interpolation of its
+	// neighbors. Removing whole keyframes ensures that decompression remains
+	// very fast.
+	//
+	// Note that stripping is not always appropriate and it may yield unacceptable
+	// results. Here are known problematic scenarios:
+	//    - high velocity animations can lose a lot of momentum, yielding a swimming-
+	//      like motion
+	//    - synchronized animations can become out of sync if keyframes are unevenly
+	//      removed leading to missed contacts
+	//    - contacts and motion apex can be missed if key keyframes are removed
+	//
+	// Transform tracks only.
+	struct compression_keyframe_stripping_settings
+	{
+		//////////////////////////////////////////////////////////////////////////
+		// Whether or not to enable keyframe stripping.
+		// See [compression_keyframe_stripping_settings] for details.
+		// Defaults to 'false'
+		bool enable_stripping = false;
+
+		//////////////////////////////////////////////////////////////////////////
+		// The minimum proportion of keyframes that should be stripped.
+		// Proportion value must be between 0.0 and 1.0.
+		// Defaults to '0.0' (no stripping)
+		float proportion = 0.0F;
+
+		//////////////////////////////////////////////////////////////////////////
+		// The threshold error below which to strip keyframes.
+		// Keyframes that yield an error below or equal to this threshold will
+		// be removed. Keyframes that contribute more error than the threshold
+		// are retained.
+		// Defaults to '0.0' centimeters (no stripping)
+		float threshold = 0.0F;
+
+		//////////////////////////////////////////////////////////////////////////
+		// Calculates a hash from the internal state to uniquely identify a configuration.
+		uint32_t get_hash() const;
+
+		//////////////////////////////////////////////////////////////////////////
+		// Checks if everything is valid and if it isn't, returns an error string.
+		// Returns nullptr if the settings are valid.
+		error_result is_valid() const;
+	};
+
 	//////////////////////////////////////////////////////////////////////////
 	// Encapsulates all the compression settings.
 	struct compression_settings
@@ -170,6 +220,11 @@ namespace acl
 		// See `sample_looping_policy` for details.
 		bool optimize_loops = false;
 
+		//////////////////////////////////////////////////////////////////////////
+		// Keyframe stripping related settings. See [compression_keyframe_stripping_settings].
+		// Transform tracks only.
+		compression_keyframe_stripping_settings keyframe_stripping;
+
 		//////////////////////////////////////////////////////////////////////////
 		// These are optional metadata that can be added to compressed clips.
 		compression_metadata_settings metadata;
diff --git a/includes/acl/compression/impl/clip_context.h b/includes/acl/compression/impl/clip_context.h
--- a/includes/acl/compression/impl/clip_context.h
+++ b/includes/acl/compression/impl/clip_context.h
@@ -199,6 +199,7 @@ namespace acl
 			bool are_scales_normalized					= false;
 			bool has_scale								= false;
 			bool has_additive_base						= false;
+			bool has_stripped_keyframes					= false;
 
 			uint32_t num_leaf_transforms				= 0;
 
diff --git a/includes/acl/compression/impl/compress.database.impl.h b/includes/acl/compression/impl/compress.database.impl.h
--- a/includes/acl/compression/impl/compress.database.impl.h
+++ b/includes/acl/compression/impl/compress.database.impl.h
@@ -444,7 +444,7 @@ namespace acl
 			return sample_indices;
 		}
 
-		inline void rewrite_segment_headers(const database_tier_mapping& tier_mapping, uint32_t tracks_index, const transform_tracks_header& input_transforms_header, const segment_header* headers, uint32_t segment_data_base_offset, segment_tier0_header* out_headers)
+		inline void rewrite_segment_headers(const database_tier_mapping& tier_mapping, uint32_t tracks_index, const transform_tracks_header& input_transforms_header, const segment_header* headers, uint32_t segment_data_base_offset, stripped_segment_header_t* out_headers)
 		{
 			const bitset_description desc = bitset_description::make_from_num_bits<32>();
 
@@ -488,7 +488,7 @@ namespace acl
 
 		inline void rewrite_segment_data(const database_tier_mapping& tier_mapping, uint32_t tracks_index,
 			const transform_tracks_header& input_transforms_header, const segment_header* input_headers,
-			transform_tracks_header& output_transforms_header, const segment_tier0_header* output_headers)
+			transform_tracks_header& output_transforms_header, const stripped_segment_header_t* output_headers)
 		{
 			for (uint32_t segment_index = 0; segment_index < input_transforms_header.num_segments; ++segment_index)
 			{
@@ -562,7 +562,7 @@ namespace acl
 
 				// Adding an extra index at the end to delimit things, the index is always invalid: 0xFFFFFFFF
 				const uint32_t segment_start_indices_size = clip_error.num_segments > 1 ? (uint32_t(sizeof(uint32_t)) * (clip_error.num_segments + 1)) : 0;
-				const uint32_t segment_headers_size = sizeof(segment_tier0_header) * clip_error.num_segments;
+				const uint32_t segment_headers_size = sizeof(stripped_segment_header_t) * clip_error.num_segments;
 
 				// Range data follows constant data, use that to calculate our size
 				const uint32_t constant_data_size = (uint32_t)input_transforms_header.clip_range_data_offset - (uint32_t)input_transforms_header.constant_track_data_offset;
@@ -593,6 +593,8 @@ namespace acl
 				buffer_size = align_to(buffer_size, 4);								// Align range data
 				buffer_size += clip_range_data_size;								// Range data
 
+				uint32_t num_remaining_keyframes = 0;
+
 				// Per segment data
 				for (uint32_t segment_index = 0; segment_index < input_transforms_header.num_segments; ++segment_index)
 				{
@@ -618,11 +620,15 @@ namespace acl
 					const uint32_t num_animated_frames = bitset_count_set_bits(&sample_indices, desc);
 					const uint32_t animated_data_size = ((num_animated_frames * input_segment_headers[segment_index].animated_pose_bit_size) + 7) / 8;
 
+					num_remaining_keyframes += num_animated_frames;
+
 					// TODO: Variable bit rate doesn't need alignment
 					buffer_size = align_to(buffer_size, 4);					// Align animated data
 					buffer_size += animated_data_size;						// Animated track data
 				}
 
+				const uint32_t num_stripped_keyframes = input_header.num_samples - num_remaining_keyframes;
+
 				// Optional metadata
 				const uint32_t metadata_start_offset = align_to(buffer_size, 4);
 				const uint32_t metadata_track_list_name_size = get_metadata_track_list_name_size(input_metadata_header);
@@ -670,6 +676,7 @@ namespace acl
 				std::memcpy(header, &input_header, sizeof(tracks_header));
 
 				header->set_has_database(true);
+				header->set_has_stripped_keyframes(num_stripped_keyframes != 0);
 				header->set_has_metadata(metadata_size != 0);
 
 				transform_tracks_header* transforms_header = safe_ptr_cast<transform_tracks_header>(buffer);
@@ -699,7 +706,7 @@ namespace acl
 
 				// Write our new segment headers
 				const uint32_t segment_data_base_offset = transforms_header->clip_range_data_offset + clip_range_data_size;
-				rewrite_segment_headers(tier_mapping, list_index, input_transforms_header, input_segment_headers, segment_data_base_offset, transforms_header->get_segment_tier0_headers());
+				rewrite_segment_headers(tier_mapping, list_index, input_transforms_header, input_segment_headers, segment_data_base_offset, transforms_header->get_stripped_segment_headers());
 
 				// Copy our sub-track types, they do not change
 				std::memcpy(transforms_header->get_sub_track_types(), input_transforms_header.get_sub_track_types(), packed_sub_track_buffer_size);
@@ -711,7 +718,7 @@ namespace acl
 				std::memcpy(transforms_header->get_clip_range_data(), input_transforms_header.get_clip_range_data(), clip_range_data_size);
 
 				// Write our new segment data
-				rewrite_segment_data(tier_mapping, list_index, input_transforms_header, input_segment_headers, *transforms_header, transforms_header->get_segment_tier0_headers());
+				rewrite_segment_data(tier_mapping, list_index, input_transforms_header, input_segment_headers, *transforms_header, transforms_header->get_stripped_segment_headers());
 
 				if (metadata_size != 0)
 				{
@@ -1256,6 +1263,9 @@ namespace acl
 			if (tracks->has_database())
 				return error_result("Compressed track instance is already bound to a database");
 
+			if (tracks->has_stripped_keyframes())
+				return error_result("Compressed track instance has keyframes stripped");
+
 			const tracks_header& header = get_tracks_header(*tracks);
 			if (!header.get_has_metadata())
 				return error_result("Compressed track instance does not contain any metadata");
diff --git a/includes/acl/compression/impl/compress.transform.impl.h b/includes/acl/compression/impl/compress.transform.impl.h
--- a/includes/acl/compression/impl/compress.transform.impl.h
+++ b/includes/acl/compression/impl/compress.transform.impl.h
@@ -42,6 +42,7 @@
 #include "acl/compression/impl/track_stream.h"
 #include "acl/compression/impl/convert_rotation_streams.h"
 #include "acl/compression/impl/compact_constant_streams.h"
+#include "acl/compression/impl/keyframe_stripping.h"
 #include "acl/compression/impl/normalize_streams.h"
 #include "acl/compression/impl/optimize_looping.h"
 #include "acl/compression/impl/quantize_streams.h"
@@ -119,9 +120,16 @@ namespace acl
 			// Segmenting settings are an implementation detail
 			compression_segmenting_settings segmenting_settings;
 
-			// If we enable database support, include the metadata we need
+			// If we enable database support or keyframe stripping, include the metadata we need
+			bool remove_contributing_error = false;
 			if (settings.enable_database_support)
 				settings.metadata.include_contributing_error = true;
+			else if (settings.keyframe_stripping.enable_stripping)
+			{
+				// If we only enable the contributing error for keyframe stripping, make sure to strip it afterwards
+				remove_contributing_error = !settings.metadata.include_contributing_error;
+				settings.metadata.include_contributing_error = true;
+			}
 
 			// If every track is retains full precision, we disable segmenting since it provides no benefit
 			if (!is_rotation_format_variable(settings.rotation_format) && !is_vector_format_variable(settings.translation_format) && !is_vector_format_variable(settings.scale_format))
@@ -215,6 +223,14 @@ namespace acl
 			// Find how many bits we need per sub-track and quantize everything
 			quantize_streams(allocator, lossy_clip_context, settings, raw_clip_context, additive_base_clip_context, out_stats);
 
+			// Remove whole keyframes as needed
+			strip_keyframes(allocator, lossy_clip_context, settings);
+
+			// Compression is done! Time to pack things.
+
+			if (remove_contributing_error)
+				settings.metadata.include_contributing_error = false;
+
 			const bool has_trivial_defaults = has_trivial_default_values(track_list, additive_format, lossy_clip_context);
 
 			uint32_t num_output_bones = 0;
@@ -239,7 +255,8 @@ namespace acl
 
 			// Adding an extra index at the end to delimit things, the index is always invalid: 0xFFFFFFFF
 			const uint32_t segment_start_indices_size = lossy_clip_context.num_segments > 1 ? (uint32_t(sizeof(uint32_t)) * (lossy_clip_context.num_segments + 1)) : 0;
-			const uint32_t segment_headers_size = sizeof(segment_header) * lossy_clip_context.num_segments;
+			const uint32_t segment_header_type_size = lossy_clip_context.has_stripped_keyframes ? sizeof(stripped_segment_header_t) : sizeof(segment_header);
+			const uint32_t segment_headers_size = segment_header_type_size * lossy_clip_context.num_segments;
 
 			uint32_t buffer_size = 0;
 			// Per clip data
@@ -269,8 +286,8 @@ namespace acl
 			{
 				constexpr uint32_t k_cache_line_byte_size = 64;
 				lossy_clip_context.decomp_touched_bytes = clip_header_size + clip_data_size;
-				lossy_clip_context.decomp_touched_bytes += sizeof(uint32_t) * 4;		// We touch at most 4 segment start indices
-				lossy_clip_context.decomp_touched_bytes += sizeof(segment_header) * 2;	// We touch at most 2 segment headers
+				lossy_clip_context.decomp_touched_bytes += sizeof(uint32_t) * 4;			// We touch at most 4 segment start indices
+				lossy_clip_context.decomp_touched_bytes += segment_header_type_size * 2;	// We touch at most 2 segment headers
 				lossy_clip_context.decomp_touched_cache_lines = align_to(clip_header_size, k_cache_line_byte_size) / k_cache_line_byte_size;
 				lossy_clip_context.decomp_touched_cache_lines += align_to(clip_data_size, k_cache_line_byte_size) / k_cache_line_byte_size;
 				lossy_clip_context.decomp_touched_cache_lines += 1;						// All 4 segment start indices should fit in a cache line
@@ -358,6 +375,7 @@ namespace acl
 			header->set_default_scale(!is_additive || additive_format != additive_clip_format8::additive1 ? 1 : 0);
 			header->set_has_database(false);
 			header->set_has_trivial_default_values(has_trivial_defaults);
+			header->set_has_stripped_keyframes(lossy_clip_context.has_stripped_keyframes);
 			header->set_is_wrap_optimized(lossy_clip_context.looping_policy == sample_looping_policy::wrap);
 			header->set_has_metadata(metadata_size != 0);
 
diff --git a/includes/acl/compression/impl/compression_settings.impl.h b/includes/acl/compression/impl/compression_settings.impl.h
--- a/includes/acl/compression/impl/compression_settings.impl.h
+++ b/includes/acl/compression/impl/compression_settings.impl.h
@@ -86,6 +86,27 @@ namespace acl
 		return error_result();
 	}
 
+	inline uint32_t compression_keyframe_stripping_settings::get_hash() const
+	{
+		uint32_t hash_value = 0;
+		hash_value = hash_combine(hash_value, hash32(enable_stripping));
+		hash_value = hash_combine(hash_value, hash32(proportion));
+		hash_value = hash_combine(hash_value, hash32(threshold));
+
+		return hash_value;
+	}
+
+	inline error_result compression_keyframe_stripping_settings::is_valid() const
+	{
+		if (!rtm::scalar_is_finite(proportion) || proportion < 0.0F || proportion > 1.0F)
+			return error_result("proportion must be in the range [0.0, 1.0]");
+
+		if (!rtm::scalar_is_finite(threshold) || threshold < 0.0F)
+			return error_result("threshold must be positive definite");
+
+		return error_result();
+	}
+
 	inline uint32_t compression_settings::get_hash() const
 	{
 		uint32_t hash_value = 0;
@@ -99,6 +120,7 @@ namespace acl
 
 		hash_value = hash_combine(hash_value, enable_database_support);
 		hash_value = hash_combine(hash_value, optimize_loops);
+		hash_value = hash_combine(hash_value, keyframe_stripping.get_hash());
 		hash_value = hash_combine(hash_value, metadata.get_hash());
 
 		return hash_value;
@@ -109,10 +131,17 @@ namespace acl
 		if (error_metric == nullptr)
 			return error_result("error_metric cannot be NULL");
 
+		const error_result keyframe_stripping_result = keyframe_stripping.is_valid();
+		if (keyframe_stripping_result.any())
+			return keyframe_stripping_result;
+
 		const error_result metadata_result = metadata.is_valid();
 		if (metadata_result.any())
 			return metadata_result;
 
+		if (keyframe_stripping.enable_stripping && enable_database_support)
+			return error_result("Cannot enable keyframe stripping with database support");
+
 		return error_result();
 	}
 
diff --git a/includes/acl/compression/impl/keyframe_stripping.h b/includes/acl/compression/impl/keyframe_stripping.h
new file mode 100644
--- /dev/null
+++ b/includes/acl/compression/impl/keyframe_stripping.h
@@ -0,0 +1,113 @@
+#pragma once
+
+////////////////////////////////////////////////////////////////////////////////
+// The MIT License (MIT)
+//
+// Copyright (c) 2023 Nicholas Frechette & Animation Compression Library contributors
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all
+// copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+////////////////////////////////////////////////////////////////////////////////
+
+#include "acl/version.h"
+#include "acl/core/impl/compiler_utils.h"
+#include "acl/compression/compression_settings.h"
+
+#include <cstdint>
+
+ACL_IMPL_FILE_PRAGMA_PUSH
+
+namespace acl
+{
+	ACL_IMPL_VERSION_NAMESPACE_BEGIN
+
+	namespace acl_impl
+	{
+		struct clip_frame_contributing_error_t
+		{
+			uint32_t segment_index;
+			frame_contributing_error contributing_error;
+		};
+
+		inline void strip_keyframes(iallocator& allocator, clip_context& lossy_clip_context, const compression_settings& settings)
+		{
+			if (!settings.keyframe_stripping.enable_stripping)
+				return;	// We don't want to strip keyframes, nothing to do
+
+			const bitset_description hard_keyframes_desc = bitset_description::make_from_num_bits<32>();
+			const uint32_t num_keyframes = lossy_clip_context.num_samples;
+
+			clip_frame_contributing_error_t* contributing_error_per_keyframe = allocate_type_array<clip_frame_contributing_error_t>(allocator, num_keyframes);
+
+			for (segment_context& segment : lossy_clip_context.segment_iterator())
+			{
+				// Copy the contributing error of each keyframe, we'll sort them later
+				for (uint32_t keyframe_index = 0; keyframe_index < segment.num_samples; ++keyframe_index)
+					contributing_error_per_keyframe[segment.clip_sample_offset + keyframe_index] = { segment.segment_index, segment.contributing_error[keyframe_index] };
+
+				// Initialize which keyframes are retained, we'll strip them later
+				bitset_set_range(&segment.hard_keyframes, hard_keyframes_desc, 0, segment.num_samples, true);
+			}
+
+			// Sort the contributing error of every keyframe within the clip
+			auto sort_predicate = [](const clip_frame_contributing_error_t& lhs, const clip_frame_contributing_error_t& rhs) { return lhs.contributing_error.error < rhs.contributing_error.error; };
+			std::sort(contributing_error_per_keyframe, contributing_error_per_keyframe + num_keyframes, sort_predicate);
+
+			// First determine how many we wish to strip based on proportion
+			// A frame is movable if it isn't the first or last frame of a segment
+			// If we have more than 1 frame, we can remove 2 frames per segment
+			// We know that the only way to get a segment with 1 frame is if the whole clip contains
+			// a single frame and thus has one segment
+			// If we have 0 or 1 frame, none are movable
+			const uint32_t num_movable_frames = num_keyframes >= 2 ? (num_keyframes - (lossy_clip_context.num_segments * 2)) : 0;
+
+			// First estimate how many keyframes to strip using the desired minimum proportion to strip
+			uint32_t num_keyframes_to_strip = std::min<uint32_t>(num_movable_frames, uint32_t(settings.keyframe_stripping.proportion * float(num_keyframes)));
+
+			// Then scan starting until we find our threshold if its above the proportion
+			for (; num_keyframes_to_strip < num_movable_frames; ++num_keyframes_to_strip)
+			{
+				if (contributing_error_per_keyframe[num_keyframes_to_strip].contributing_error.error > settings.keyframe_stripping.threshold)
+					break;	// The error exceeds our threshold, stop stripping keyframes
+			}
+
+			ACL_ASSERT(num_keyframes_to_strip <= num_movable_frames, "Cannot strip more than the number of movable keyframes");
+
+			// Now that we know how many keyframes to strip, remove them
+			for (uint32_t index = 0; index < num_keyframes_to_strip; ++index)
+			{
+				const clip_frame_contributing_error_t& contributing_error = contributing_error_per_keyframe[index];
+
+				segment_context& keyframe_segment = lossy_clip_context.segments[contributing_error.segment_index];
+
+				const uint32_t segment_keyframe_index = contributing_error.contributing_error.index;
+				ACL_ASSERT(segment_keyframe_index != 0 && segment_keyframe_index < (keyframe_segment.num_samples - 1), "Cannot strip the first and last sample of a segment");
+
+				bitset_set(&keyframe_segment.hard_keyframes, hard_keyframes_desc, segment_keyframe_index, false);
+			}
+
+			deallocate_type_array(allocator, contributing_error_per_keyframe, num_keyframes);
+
+			lossy_clip_context.has_stripped_keyframes = num_keyframes_to_strip != 0;
+		}
+	}
+
+	ACL_IMPL_VERSION_NAMESPACE_END
+}
+
+ACL_IMPL_FILE_PRAGMA_POP
diff --git a/includes/acl/compression/impl/segment_context.h b/includes/acl/compression/impl/segment_context.h
--- a/includes/acl/compression/impl/segment_context.h
+++ b/includes/acl/compression/impl/segment_context.h
@@ -92,6 +92,7 @@ namespace acl
 
 			uint32_t clip_sample_offset						= 0;
 			uint32_t segment_index							= 0;
+			uint32_t hard_keyframes							= 0;		// Bit set of which keyframes are hard and retained when keyframe stripping is used
 
 			bool are_rotations_normalized					= false;
 			bool are_translations_normalized				= false;
diff --git a/includes/acl/compression/impl/track_error.impl.h b/includes/acl/compression/impl/track_error.impl.h
--- a/includes/acl/compression/impl/track_error.impl.h
+++ b/includes/acl/compression/impl/track_error.impl.h
@@ -437,8 +437,14 @@ namespace acl
 		args.sample_rate = raw_tracks.get_sample_rate();
 		args.track_type = raw_tracks.get_track_type();
 
-		// We use the nearest sample to accurately measure the loss that happened, if any
-		args.rounding_policy = sample_rounding_policy::nearest;
+		// We use the nearest sample to accurately measure the loss that happened, if any but only if all data is loaded
+		// If we have a database with some data missing, we can't use the nearest samples, we have to interpolate
+		// TODO: Check if all the data is loaded, always interpolate for now
+		const compressed_tracks& tracks = *context.get_compressed_tracks();
+		if (tracks.has_database() || tracks.has_stripped_keyframes())
+			args.rounding_policy = sample_rounding_policy::none;
+		else
+			args.rounding_policy = sample_rounding_policy::nearest;
 
 		return calculate_scalar_track_error(allocator, args);
 	}
@@ -535,14 +541,12 @@ namespace acl
 
 		// We use the nearest sample to accurately measure the loss that happened, if any but only if all data is loaded
 		// If we have a database with some data missing, we can't use the nearest samples, we have to interpolate
-		args.rounding_policy = sample_rounding_policy::nearest;
-
+		// TODO: Check if all the data is loaded, always interpolate for now
 		const compressed_tracks& tracks = *context.get_compressed_tracks();
-		if (tracks.has_database())
-		{
-			// TODO: Check if all the data is loaded, always interpolate for now
+		if (tracks.has_database() || tracks.has_stripped_keyframes())
 			args.rounding_policy = sample_rounding_policy::none;
-		}
+		else
+			args.rounding_policy = sample_rounding_policy::nearest;
 
 		if (raw_tracks.get_track_type() != track_type8::qvvf)
 			return calculate_scalar_track_error(allocator, args);
@@ -652,14 +656,12 @@ namespace acl
 
 		// We use the nearest sample to accurately measure the loss that happened, if any but only if all data is loaded
 		// If we have a database with some data missing, we can't use the nearest samples, we have to interpolate
-		args.rounding_policy = sample_rounding_policy::nearest;
-
+		// TODO: Check if all the data is loaded, always interpolate for now
 		const compressed_tracks& tracks = *context.get_compressed_tracks();
-		if (tracks.has_database())
-		{
-			// TODO: Check if all the data is loaded, always interpolate for now
+		if (tracks.has_database() || tracks.has_stripped_keyframes())
 			args.rounding_policy = sample_rounding_policy::none;
-		}
+		else
+			args.rounding_policy = sample_rounding_policy::nearest;
 
 		args.error_metric = &error_metric;
 
@@ -720,14 +722,12 @@ namespace acl
 
 		// We use the nearest sample to accurately measure the loss that happened, if any but only if all data is loaded
 		// If we have a database with some data missing, we can't use the nearest samples, we have to interpolate
-		args.rounding_policy = sample_rounding_policy::nearest;
-
+		// TODO: Check if all the data is loaded, always interpolate for now
 		const compressed_tracks* tracks1 = context1.get_compressed_tracks();
-		if (tracks0->has_database() || tracks1->has_database())
-		{
-			// TODO: Check if all the data is loaded, always interpolate for now
+		if (tracks0->has_database() || tracks1->has_database() || tracks0->has_stripped_keyframes() || tracks1->has_stripped_keyframes())
 			args.rounding_policy = sample_rounding_policy::none;
-		}
+		else
+			args.rounding_policy = sample_rounding_policy::nearest;
 
 		return calculate_scalar_track_error(allocator, args);
 	}
diff --git a/includes/acl/compression/impl/write_segment_data.h b/includes/acl/compression/impl/write_segment_data.h
--- a/includes/acl/compression/impl/write_segment_data.h
+++ b/includes/acl/compression/impl/write_segment_data.h
@@ -69,26 +69,53 @@ namespace acl
 			uint32_t size_written = 0;
 
 			const uint32_t format_per_track_data_size = get_format_per_track_data_size(clip, settings.rotation_format, settings.translation_format, settings.scale_format);
+			const bool has_stripped_keyframes = clip.has_stripped_keyframes;
+			stripped_segment_header_t* stripped_segment_headers = reinterpret_cast<stripped_segment_header_t*>(segment_headers);
 
 			uint32_t segment_data_offset = segment_data_start_offset;
 			for (uint32_t segment_index = 0; segment_index < clip.num_segments; ++segment_index)
 			{
 				const segment_context& segment = clip.segments[segment_index];
-				segment_header& header = segment_headers[segment_index];
 
-				ACL_ASSERT(header.animated_pose_bit_size == 0, "Buffer overrun detected");
+				if (has_stripped_keyframes)
+				{
+					stripped_segment_header_t& header = stripped_segment_headers[segment_index];
+
+					ACL_ASSERT(header.animated_pose_bit_size == 0, "Buffer overrun detected");
+
+					header.animated_pose_bit_size = segment.animated_pose_bit_size;
+					header.animated_rotation_bit_size = segment.animated_rotation_bit_size;
+					header.animated_translation_bit_size = segment.animated_translation_bit_size;
+					header.segment_data = segment_data_offset;
+					header.sample_indices = segment.hard_keyframes;
+
+					segment_data_offset = align_to(segment_data_offset + format_per_track_data_size, 2);		// Aligned to 2 bytes
+					segment_data_offset = align_to(segment_data_offset + segment.range_data_size, 4);			// Aligned to 4 bytes
+					segment_data_offset = segment_data_offset + segment.animated_data_size;
+
+					size_written += sizeof(stripped_segment_header_t);
+
+					ACL_ASSERT((segment_data_offset - (uint32_t)header.segment_data) == segment.segment_data_size, "Unexpected segment size");
+				}
+				else
+				{
+					segment_header& header = segment_headers[segment_index];
 
-				header.animated_pose_bit_size = segment.animated_pose_bit_size;
-				header.animated_rotation_bit_size = segment.animated_rotation_bit_size;
-				header.animated_translation_bit_size = segment.animated_translation_bit_size;
-				header.segment_data = segment_data_offset;
+					ACL_ASSERT(header.animated_pose_bit_size == 0, "Buffer overrun detected");
 
-				segment_data_offset = align_to(segment_data_offset + format_per_track_data_size, 2);		// Aligned to 2 bytes
-				segment_data_offset = align_to(segment_data_offset + segment.range_data_size, 4);			// Aligned to 4 bytes
-				segment_data_offset = segment_data_offset + segment.animated_data_size;
-				size_written += sizeof(segment_header);
+					header.animated_pose_bit_size = segment.animated_pose_bit_size;
+					header.animated_rotation_bit_size = segment.animated_rotation_bit_size;
+					header.animated_translation_bit_size = segment.animated_translation_bit_size;
+					header.segment_data = segment_data_offset;
 
-				ACL_ASSERT((segment_data_offset - (uint32_t)header.segment_data) == segment.segment_data_size, "Unexpected segment size");
+					segment_data_offset = align_to(segment_data_offset + format_per_track_data_size, 2);		// Aligned to 2 bytes
+					segment_data_offset = align_to(segment_data_offset + segment.range_data_size, 4);			// Aligned to 4 bytes
+					segment_data_offset = segment_data_offset + segment.animated_data_size;
+
+					size_written += sizeof(segment_header);
+
+					ACL_ASSERT((segment_data_offset - (uint32_t)header.segment_data) == segment.segment_data_size, "Unexpected segment size");
+				}
 			}
 
 			return size_written;
@@ -97,6 +124,8 @@ namespace acl
 		inline uint32_t write_segment_data(const clip_context& clip, const compression_settings& settings, range_reduction_flags8 range_reduction, segment_header* segment_headers, transform_tracks_header& header, const uint32_t* output_bone_mapping, uint32_t num_output_bones)
 		{
 			const uint32_t format_per_track_data_size = get_format_per_track_data_size(clip, settings.rotation_format, settings.translation_format, settings.scale_format);
+			const bool has_stripped_keyframes = clip.has_stripped_keyframes;
+			stripped_segment_header_t* stripped_segment_headers = reinterpret_cast<stripped_segment_header_t*>(segment_headers);
 
 			uint32_t size_written = 0;
 
@@ -104,12 +133,21 @@ namespace acl
 			for (uint32_t segment_index = 0; segment_index < num_segments; ++segment_index)
 			{
 				const segment_context& segment = clip.segments[segment_index];
-				segment_header& segment_header_ = segment_headers[segment_index];
 
 				uint8_t* format_per_track_data = nullptr;
 				uint8_t* range_data = nullptr;
 				uint8_t* animated_data = nullptr;
-				header.get_segment_data(segment_header_, format_per_track_data, range_data, animated_data);
+
+				if (has_stripped_keyframes)
+				{
+					stripped_segment_header_t& segment_header_ = stripped_segment_headers[segment_index];
+					header.get_segment_data(segment_header_, format_per_track_data, range_data, animated_data);
+				}
+				else
+				{
+					segment_header& segment_header_ = segment_headers[segment_index];
+					header.get_segment_data(segment_header_, format_per_track_data, range_data, animated_data);
+				}
 
 				ACL_ASSERT(format_per_track_data[0] == 0, "Buffer overrun detected");
 				ACL_ASSERT(range_data[0] == 0, "Buffer overrun detected");
diff --git a/includes/acl/compression/impl/write_stats.h b/includes/acl/compression/impl/write_stats.h
--- a/includes/acl/compression/impl/write_stats.h
+++ b/includes/acl/compression/impl/write_stats.h
@@ -225,7 +225,7 @@ namespace acl
 
 		inline uint32_t calculate_clip_metadata_common_size(const clip_context& clip, const compressed_tracks& compressed_clip)
 		{
-			const uint32_t segment_header_size = compressed_clip.has_database() ? sizeof(segment_tier0_header) : sizeof(segment_header);
+			const uint32_t segment_header_size = compressed_clip.has_database() || compressed_clip.has_stripped_keyframes() ? sizeof(stripped_segment_header_t) : sizeof(segment_header);
 
 			uint32_t result = 0;
 
diff --git a/includes/acl/compression/impl/write_stream_data.h b/includes/acl/compression/impl/write_stream_data.h
--- a/includes/acl/compression/impl/write_stream_data.h
+++ b/includes/acl/compression/impl/write_stream_data.h
@@ -123,6 +123,8 @@ namespace acl
 
 		inline void calculate_animated_data_size(clip_context& clip, const uint32_t* output_bone_mapping, uint32_t num_output_bones)
 		{
+			const bitset_description hard_keyframes_desc = bitset_description::make_from_num_bits<32>();
+
 			for (segment_context& segment : clip.segment_iterator())
 			{
 				uint32_t num_animated_pose_rotation_data_bits = 0;
@@ -145,7 +147,8 @@ namespace acl
 				}
 
 				const uint32_t num_animated_pose_bits = num_animated_pose_rotation_data_bits + num_animated_pose_translation_data_bits + num_animated_pose_scale_data_bits;
-				const uint32_t num_animated_data_bits = num_animated_pose_bits * segment.num_samples;
+				const uint32_t num_stored_samples = clip.has_stripped_keyframes ? bitset_count_set_bits(&segment.hard_keyframes, hard_keyframes_desc) : segment.num_samples;
+				const uint32_t num_animated_data_bits = num_animated_pose_bits * num_stored_samples;
 
 				segment.animated_rotation_bit_size = num_animated_pose_rotation_data_bits;
 				segment.animated_translation_bit_size = num_animated_pose_translation_data_bits;
@@ -372,6 +375,8 @@ namespace acl
 
 			const uint8_t* animated_track_data_start = animated_track_data;
 			const uint8_t* animated_track_data_end = add_offset_to_ptr<uint8_t>(animated_track_data, animated_data_size);
+			const bool has_stripped_keyframes = segment.clip->has_stripped_keyframes;
+			const bitset_description hard_keyframes_desc = bitset_description::make_from_num_bits<32>();
 
 			uint64_t bit_offset = 0;
 
@@ -413,9 +418,11 @@ namespace acl
 				ACL_ASSERT(animated_track_data <= animated_track_data_end, "Invalid animated track data offset. Wrote too much data."); (void)animated_track_data_end;
 			};
 
-			// TODO: Use a group writer context object to avoid alloc/free/work in loop for every sample when it doesn't change
 			for (uint32_t sample_index = 0; sample_index < segment.num_samples; ++sample_index)
 			{
+				if (has_stripped_keyframes && !bitset_test(&segment.hard_keyframes, hard_keyframes_desc, sample_index))
+					continue;	// This keyframe has been stripped, skip it
+
 				auto group_entry_action = [&segment, sample_index, &group_animated_track_data, &group_bit_offset](animation_track_type8 group_type, uint32_t group_size, uint32_t bone_index)
 				{
 					(void)group_size;
@@ -444,8 +451,12 @@ namespace acl
 			if (bit_offset != 0)
 				animated_track_data = animated_track_data_begin + ((bit_offset + 7) / 8);
 
-			ACL_ASSERT((bit_offset == 0 && segment.num_samples == 0) || ((bit_offset / segment.num_samples) == segment.animated_pose_bit_size), "Unexpected number of bits written");
+#if defined(ACL_HAS_ASSERT_CHECKS)
+			const uint32_t num_stored_samples = has_stripped_keyframes ? bitset_count_set_bits(&segment.hard_keyframes, hard_keyframes_desc) : segment.num_samples;
+			ACL_ASSERT((bit_offset == 0 && segment.num_samples == 0) || ((bit_offset / num_stored_samples) == segment.animated_pose_bit_size), "Unexpected number of bits written");
 			ACL_ASSERT(animated_track_data == animated_track_data_end, "Invalid animated track data offset. Wrote too little data.");
+#endif
+
 			return safe_static_cast<uint32_t>(animated_track_data - animated_track_data_start);
 		}
 
diff --git a/includes/acl/core/compressed_tracks.h b/includes/acl/core/compressed_tracks.h
--- a/includes/acl/core/compressed_tracks.h
+++ b/includes/acl/core/compressed_tracks.h
@@ -126,6 +126,11 @@ namespace acl
 		// Only supported with qvv transform tracks.
 		bool has_trivial_default_values() const;
 
+		//////////////////////////////////////////////////////////////////////////
+		// Returns whether or not this clip has had keyframes stripped.
+		// Only supported with qvv transform tracks.
+		bool has_stripped_keyframes() const;
+
 		//////////////////////////////////////////////////////////////////////////
 		// Returns the default scale value used during compression.
 		// Depending on the additive type, this value will either be 0 or 1.
diff --git a/includes/acl/core/impl/compressed_headers.h b/includes/acl/core/impl/compressed_headers.h
--- a/includes/acl/core/impl/compressed_headers.h
+++ b/includes/acl/core/impl/compressed_headers.h
@@ -100,7 +100,8 @@ namespace acl
 			// Bits [4, 8): rotation format (4 bits)
 			// Bit 8: has database?
 			// Bit 9: has trivial default values? Non-trivial default values indicate that extra data beyond the clip will be needed at decompression (e.g. bind pose)
-			// Bits [10, 30): unused (20 bits)
+			// Bit 10: has stripped keyframes?
+			// Bits [11, 30): unused (19 bits)
 			// Bit 30: is wrap optimized? See sample_looping_policy for details.
 			// Bit 31: has metadata?
 
@@ -119,6 +120,8 @@ namespace acl
 			void set_has_database(bool has_database) { ACL_ASSERT(track_type == track_type8::qvvf, "Transform tracks only"); misc_packed = (misc_packed & ~(1 << 8)) | (static_cast<uint32_t>(has_database) << 8); }
 			bool get_has_trivial_default_values() const { ACL_ASSERT(track_type == track_type8::qvvf, "Transform tracks only"); return (misc_packed & (1 << 9)) != 0; }
 			void set_has_trivial_default_values(bool has_trivial_default_values) { ACL_ASSERT(track_type == track_type8::qvvf, "Transform tracks only"); misc_packed = (misc_packed & ~(1 << 9)) | (static_cast<uint32_t>(has_trivial_default_values) << 9); }
+			bool get_has_stripped_keyframes() const { ACL_ASSERT(track_type == track_type8::qvvf, "Transform tracks only"); return (misc_packed & (1 << 10)) != 0; }
+			void set_has_stripped_keyframes(bool has_stripped_keyframes) { ACL_ASSERT(track_type == track_type8::qvvf, "Transform tracks only"); misc_packed = (misc_packed & ~(1 << 10)) | (static_cast<uint32_t>(has_stripped_keyframes) << 10); }
 
 			// Common
 			bool get_is_wrap_optimized() const { return (misc_packed & (1 << 30)) != 0; }
@@ -185,27 +188,11 @@ namespace acl
 		////////////////////////////////////////////////////////////////////////////////
 		// A compressed clip segment header. Each segment is built from a uniform number
 		// of samples per track. A clip is split into one or more segments.
-		// Only valid when a clip is split into a database.
+		// Only valid when a clip is split into a database or when keyframe stripping is enabled.
 		////////////////////////////////////////////////////////////////////////////////
-		struct segment_tier0_header
+		struct stripped_segment_header_t : segment_header
 		{
-			// Same layout as segment_header with new data at the end to allow safe usage under the segment_header type
-
-			// Number of bits used by a fully animated pose (excludes default/constant tracks).
-			uint32_t						animated_pose_bit_size;
-
-			// Number of bits used by a fully animated pose per sub-track type (excludes default/constant tracks).
-			uint32_t						animated_rotation_bit_size;
-			uint32_t						animated_translation_bit_size;
-
-			// Offset to the animated segment data, relative to the start of the transform_tracks_header
-			// Segment data is partitioned as follows:
-			//    - format per variable track (no alignment)
-			//    - range data per variable track (only when more than one segment) (2 byte alignment)
-			//    - track data sorted per sample then per track (4 byte alignment)
-			ptr_offset32<uint8_t>			segment_data;
-
-			// Bit set of which sample indices are stored in this clip (tier 0).
+			// Bit set of which sample indices are stored in this clip (database tier 0).
 			uint32_t						sample_indices;
 		};
 
@@ -256,11 +243,11 @@ namespace acl
 			// Offset to the database metadata header.
 			ptr_offset32<tracks_database_header>	database_header_offset;
 
-			// Offset to the segment headers data (tier 0 if split into database).
+			// Offset to the segment headers data.
 			union
 			{
-				ptr_offset32<segment_header>		segment_headers_offset;
-				ptr_offset32<segment_tier0_header>	segment_tier0_headers_offset;
+				ptr_offset32<segment_header>			segment_headers_offset;
+				ptr_offset32<stripped_segment_header_t>	stripped_segment_headers_offset;
 			};
 
 			// Offset to the packed sub-track types.
@@ -288,8 +275,8 @@ namespace acl
 			segment_header*					get_segment_headers() { return segment_headers_offset.add_to(this); }
 			const segment_header*			get_segment_headers() const { return segment_headers_offset.add_to(this); }
 
-			segment_tier0_header*			get_segment_tier0_headers() { return segment_tier0_headers_offset.add_to(this); }
-			const segment_tier0_header*		get_segment_tier0_headers() const { return segment_tier0_headers_offset.add_to(this); }
+			stripped_segment_header_t*			get_stripped_segment_headers() { return stripped_segment_headers_offset.add_to(this); }
+			const stripped_segment_header_t*	get_stripped_segment_headers() const { return stripped_segment_headers_offset.add_to(this); }
 
 			packed_sub_track_types*			get_sub_track_types() { return sub_track_types_offset.add_to(this); }
 			const packed_sub_track_types*	get_sub_track_types() const { return sub_track_types_offset.add_to(this); }
diff --git a/includes/acl/core/impl/compressed_tracks.impl.h b/includes/acl/core/impl/compressed_tracks.impl.h
--- a/includes/acl/core/impl/compressed_tracks.impl.h
+++ b/includes/acl/core/impl/compressed_tracks.impl.h
@@ -124,6 +124,8 @@ namespace acl
 
 	inline bool compressed_tracks::has_trivial_default_values() const { return acl_impl::get_tracks_header(*this).get_has_trivial_default_values(); }
 
+	inline bool compressed_tracks::has_stripped_keyframes() const { return acl_impl::get_tracks_header(*this).get_has_stripped_keyframes(); }
+
 	inline int32_t compressed_tracks::get_default_scale() const { return acl_impl::get_tracks_header(*this).get_default_scale(); }
 
 	inline sample_looping_policy compressed_tracks::get_looping_policy() const
diff --git a/includes/acl/decompression/impl/transform_track_decompression.h b/includes/acl/decompression/impl/transform_track_decompression.h
--- a/includes/acl/decompression/impl/transform_track_decompression.h
+++ b/includes/acl/decompression/impl/transform_track_decompression.h
@@ -250,7 +250,7 @@ namespace acl
 
 			// These two pointers are the same, the compiler should optimize one out, only here for type safety later
 			const segment_header* segment_headers = transform_header.get_segment_headers();
-			const segment_tier0_header* segment_tier0_headers = transform_header.get_segment_tier0_headers();
+			const stripped_segment_header_t* segment_tier0_headers = transform_header.get_stripped_segment_headers();
 
 			const uint32_t num_segments = transform_header.num_segments;
 
@@ -260,14 +260,16 @@ namespace acl
 			const bool has_database = is_database_supported && tracks->has_database();
 			const database_context_v0* db = context.db;
 
+			const bool has_stripped_keyframes = has_database || tracks->has_stripped_keyframes();
+
 			if (num_segments == 1)
 			{
 				// Key frame 0 and 1 are in the only segment present
 				// This is a really common case and when it happens, we don't store the segment start index (zero)
 
-				if (is_database_supported && has_database)
+				if (has_stripped_keyframes)
 				{
-					const segment_tier0_header* segment_tier0_header0 = segment_tier0_headers;
+					const stripped_segment_header_t* segment_tier0_header0 = segment_tier0_headers;
 
 					// This will cache miss
 					uint32_t sample_indices0 = segment_tier0_header0->sample_indices;
@@ -404,10 +406,10 @@ namespace acl
 				segment_key_frame0 = key_frame0 - segment_start_indices[segment_index0];
 				segment_key_frame1 = key_frame1 - segment_start_indices[segment_index1];
 
-				if (is_database_supported && has_database)
+				if (has_stripped_keyframes)
 				{
-					const segment_tier0_header* segment_tier0_header0 = segment_tier0_headers + segment_index0;
-					const segment_tier0_header* segment_tier0_header1 = segment_tier0_headers + segment_index1;
+					const stripped_segment_header_t* segment_tier0_header0 = segment_tier0_headers + segment_index0;
+					const stripped_segment_header_t* segment_tier0_header1 = segment_tier0_headers + segment_index1;
 
 					// This will cache miss
 					uint32_t sample_indices0 = segment_tier0_header0->sample_indices;
@@ -541,7 +543,7 @@ namespace acl
 				context.animated_track_data[1] = context.animated_track_data[0];
 			}
 
-			if (is_database_supported && has_database)
+			if (has_database)
 			{
 				// Update our pointers if the data lives within the database
 				if (db_animated_track_data0 != nullptr)
diff --git a/tools/acl_compressor/sources/acl_compressor.cpp b/tools/acl_compressor/sources/acl_compressor.cpp
--- a/tools/acl_compressor/sources/acl_compressor.cpp
+++ b/tools/acl_compressor/sources/acl_compressor.cpp
@@ -852,6 +852,18 @@ static bool read_config(iallocator& allocator, Options& options, compression_set
 	if (parser.try_read("low_importance_tier", low_importance_tier, default_database_settings.low_importance_tier_proportion))
 		out_database_settings.low_importance_tier_proportion = low_importance_tier;
 
+	bool keyframe_stripping_enable;
+	if (parser.try_read("keyframe_stripping_enable", keyframe_stripping_enable, default_settings.keyframe_stripping.enable_stripping))
+		out_settings.keyframe_stripping.enable_stripping = keyframe_stripping_enable;
+
+	float keyframe_stripping_proportion;
+	if (parser.try_read("keyframe_stripping_proportion", keyframe_stripping_proportion, default_settings.keyframe_stripping.proportion))
+		out_settings.keyframe_stripping.proportion = keyframe_stripping_proportion;
+
+	float keyframe_stripping_threshold;
+	if (parser.try_read("keyframe_stripping_threshold", keyframe_stripping_threshold, default_settings.keyframe_stripping.threshold))
+		out_settings.keyframe_stripping.threshold = keyframe_stripping_threshold;
+
 	if (!parser.is_valid() || !parser.remainder_is_comments_and_whitespace())
 	{
 		uint32_t line;
diff --git a/tools/acl_compressor/sources/validate_tracks.cpp b/tools/acl_compressor/sources/validate_tracks.cpp
--- a/tools/acl_compressor/sources/validate_tracks.cpp
+++ b/tools/acl_compressor/sources/validate_tracks.cpp
@@ -193,6 +193,11 @@ void validate_accuracy(
 		{
 			for (sample_rounding_policy policy : make_iterator(rounding_policies))
 			{
+				// When we have stripped keyframes or a partially streamed database, the floor/ceil/nearest do not behave the same way with per-track
+				// The values reconstructed may not match, no need to validate
+				if (compressed_tracks_.has_stripped_keyframes() && policy != sample_rounding_policy::none)
+					continue;
+
 				context.seek(sample_time, policy);
 				context.decompress_tracks(track_writer);
 


Test patch:
diff --git a/test_data/configs/uniformly_sampled_keyframe_stripping.config.sjson b/test_data/configs/uniformly_sampled_keyframe_stripping.config.sjson
new file mode 100644
--- /dev/null
+++ b/test_data/configs/uniformly_sampled_keyframe_stripping.config.sjson
@@ -0,0 +1,15 @@
+version = 2
+
+algorithm_name = "uniformly_sampled"
+
+level = "Medium"
+
+rotation_format = "quatf_drop_w_variable"
+translation_format = "vector3f_variable"
+scale_format = "vector3f_variable"
+
+regression_error_threshold = 0.6
+
+keyframe_stripping_enable = true
+keyframe_stripping_proportion = 0.0
+keyframe_stripping_threshold = 0.5