From a07c18e946c8c1c1c73c60a3d66398c399ed37b2 Mon Sep 17 00:00:00 2001 From: jackspirou Date: Wed, 6 Aug 2025 18:58:27 -0500 Subject: [PATCH 1/2] fix: resolve SVG keyframe collisions with dynamic precision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes jerky/incorrect animations in large SVG files by implementing dynamic percentage precision based on keyframe count. Problem: - Large animations (4000+ keyframes) had multiple keyframes with same percentage values due to 1 decimal precision limit - Browser couldn't determine which transform to apply, causing jerky rendering Solution: - Implemented formatPercentage() with dynamic precision: - < 100 keyframes: 1 decimal - < 1,000 keyframes: 2 decimals - < 10,000 keyframes: 3 decimals - < 100,000 keyframes: 4 decimals - >= 100,000 keyframes: 5 decimals - Ensures unique percentages for all keyframes - Optimizes file size by using minimal required precision Results: - Smooth animations for recordings of any size - Optimal file sizes (small demos stay small) - No configuration needed - automatically adapts 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- svg.go | 42 +++++++++++++- svg_test.go | 161 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 202 insertions(+), 1 deletion(-) diff --git a/svg.go b/svg.go index dbb9364e..9f04d0d4 100644 --- a/svg.go +++ b/svg.go @@ -502,10 +502,11 @@ func (g *SVGGenerator) generateStyles() string { g.writeNewline(&sb) // Build optimized keyframes from timeline + keyframeCount := len(g.timeline) for _, stop := range g.timeline { offset := -float64(stop.StateIndex) * g.frameSpacing sb.WriteString(fmt.Sprintf(" %s%% { transform: translateX(%spx); }", - formatCoord(stop.Percentage), formatCoord(offset))) + formatPercentage(stop.Percentage, keyframeCount), formatCoord(offset))) g.writeNewline(&sb) } @@ -1069,6 +1070,45 @@ func formatCoord(val float64) string { return formatted } +// formatPercentage formats a percentage value with appropriate precision +// to avoid keyframe collisions in large animations. +// The precision is dynamically calculated based on the number of keyframes. +func formatPercentage(val float64, keyframeCount int) string { + // For whole numbers, keep minimal format + if val == float64(int(val)) { + return fmt.Sprintf("%d", int(val)) + } + + // Dynamically determine precision based on keyframe count + // This ensures we have enough precision to avoid collisions + // while keeping the output as compact as possible + var precision int + switch { + case keyframeCount < 100: + precision = 1 // Up to 100 unique values + case keyframeCount < 1000: + precision = 2 // Up to 1,000 unique values + case keyframeCount < 10000: + precision = 3 // Up to 10,000 unique values + case keyframeCount < 100000: + precision = 4 // Up to 100,000 unique values + default: + precision = 5 // Up to 1,000,000 unique values + } + + // Format with calculated precision + formatStr := fmt.Sprintf("%%.%df", precision) + formatted := fmt.Sprintf(formatStr, val) + + // Remove trailing zeros but keep at least 1 decimal for consistency + formatted = strings.TrimRight(formatted, "0") + if strings.HasSuffix(formatted, ".") { + formatted = formatted[:len(formatted)-1] + } + + return formatted +} + // formatDuration formats a duration value with minimal decimal places. func formatDuration(seconds float64) string { // If it's a whole number, don't include decimals diff --git a/svg_test.go b/svg_test.go index 8b6fb7fd..c5937022 100644 --- a/svg_test.go +++ b/svg_test.go @@ -3,6 +3,7 @@ package main import ( "fmt" "os" + "regexp" "runtime" "strings" "testing" @@ -1030,3 +1031,163 @@ func TestFormatDuration(t *testing.T) { } } } + +// Test formatPercentage function with dynamic precision +func TestFormatPercentage(t *testing.T) { + testCases := []struct { + name string + input float64 + keyframeCount int + expected string + }{ + // Whole numbers always minimal + {"whole number 0", 0.0, 100, "0"}, + {"whole number 100", 100.0, 1000, "100"}, + {"whole number 50", 50.0, 10000, "50"}, + + // Small frame counts (< 100) - 1 decimal + {"small count decimal", 12.345, 50, "12.3"}, + {"small count trailing", 10.5000, 75, "10.5"}, + + // Medium frame counts (< 1000) - 2 decimals + {"medium count decimal", 0.024038, 500, "0.02"}, + {"medium count precision", 2.456, 800, "2.46"}, + {"medium count trailing", 10.1000, 900, "10.1"}, + + // Large frame counts (< 10000) - 3 decimals + {"large count decimal", 0.024038, 4161, "0.024"}, + {"large count precision", 2.451923, 5000, "2.452"}, + {"large count trailing", 99.9000, 8000, "99.9"}, + + // Very large frame counts (< 100000) - 4 decimals + {"very large decimal", 0.00012, 50000, "0.0001"}, + {"very large precision", 33.33333, 75000, "33.3333"}, + + // Huge frame counts (>= 100000) - 5 decimals + {"huge count decimal", 0.000012, 150000, "0.00001"}, + {"huge count precision", 12.345678, 200000, "12.34568"}, + + // Edge cases + {"negative percentage", -5.5, 100, "-5.5"}, + {"zero with decimals", 0.0001, 1000, "0"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := formatPercentage(tc.input, tc.keyframeCount) + if result != tc.expected { + t.Errorf("formatPercentage(%f, %d) = %s; want %s", + tc.input, tc.keyframeCount, result, tc.expected) + } + }) + } +} + +// Test dynamic precision prevents collisions +func TestFormatPercentageDynamicPrecision(t *testing.T) { + testCases := []struct { + name string + frameCount int + }{ + {"small animation", 50}, + {"medium animation", 500}, + {"large animation", 4161}, + {"very large animation", 50000}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + percentages := make(map[string]bool) + + for i := 0; i < tc.frameCount; i++ { + percentage := float64(i) / float64(tc.frameCount-1) * 100 + formatted := formatPercentage(percentage, tc.frameCount) + + if percentages[formatted] { + t.Errorf("Duplicate percentage at frame %d/%d: %s (%.6f%%)", + i, tc.frameCount, formatted, percentage) + } + percentages[formatted] = true + } + + if len(percentages) != tc.frameCount { + t.Errorf("Expected %d unique percentages, got %d", + tc.frameCount, len(percentages)) + } + }) + } +} + +// Test that large animations produce unique percentages +func TestLargeAnimationPercentages(t *testing.T) { + // Test that 4161 frames produce unique percentages + percentages := make(map[string]bool) + totalFrames := 4161 + + for i := 0; i < totalFrames; i++ { + percentage := float64(i) / float64(totalFrames-1) * 100 + formatted := formatPercentage(percentage, totalFrames) + + if percentages[formatted] { + t.Errorf("Duplicate percentage at frame %d: %s (%.6f%%)", i, formatted, percentage) + } + percentages[formatted] = true + } + + if len(percentages) != totalFrames { + t.Errorf("Expected %d unique percentages, got %d", + totalFrames, len(percentages)) + } +} + +// Test SVG keyframe generation with many frames +func TestSVGKeyframeGeneration(t *testing.T) { + // Create config with many frames to test keyframe collision + opts := createTestSVGConfig() + opts.Frames = make([]SVGFrame, 1000) + for i := range opts.Frames { + opts.Frames[i] = SVGFrame{ + Lines: []string{fmt.Sprintf("Frame %d", i)}, + CharWidth: 8.8, + CharHeight: 20, + CursorX: i % 10, + CursorY: 0, + } + } + + gen := NewSVGGenerator(opts) + svg := gen.Generate() + + // Verify no duplicate keyframe percentages + keyframeRegex := regexp.MustCompile(`(\d+(?:\.\d+)?)\%\s*\{`) + matches := keyframeRegex.FindAllStringSubmatch(svg, -1) + + seen := make(map[string]bool) + duplicates := 0 + for _, match := range matches { + if seen[match[1]] { + duplicates++ + if duplicates <= 5 { // Only report first 5 duplicates + t.Errorf("Duplicate keyframe percentage found: %s%%", match[1]) + } + } + seen[match[1]] = true + } + + if duplicates > 0 { + t.Errorf("Found %d duplicate keyframe percentages out of %d total keyframes", + duplicates, len(matches)) + } + + // Verify animation is present + if !strings.Contains(svg, "@keyframes slide") { + t.Error("SVG missing @keyframes slide animation") + } + + // Verify reasonable number of keyframes were generated + // With deduplication, we should have fewer keyframes than frames + if len(matches) > len(opts.Frames) { + t.Errorf("Too many keyframes generated: %d keyframes for %d frames", + len(matches), len(opts.Frames)) + } +} From ff5828157373ac79895ec073b7a9cda4a5775ec4 Mon Sep 17 00:00:00 2001 From: jackspirou Date: Wed, 6 Aug 2025 19:03:55 -0500 Subject: [PATCH 2/2] fix: address linter suggestion - use strings.TrimSuffix Replace if statement with strings.TrimSuffix as suggested by staticcheck (S1017) --- svg.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/svg.go b/svg.go index 9f04d0d4..589f98ba 100644 --- a/svg.go +++ b/svg.go @@ -1102,9 +1102,7 @@ func formatPercentage(val float64, keyframeCount int) string { // Remove trailing zeros but keep at least 1 decimal for consistency formatted = strings.TrimRight(formatted, "0") - if strings.HasSuffix(formatted, ".") { - formatted = formatted[:len(formatted)-1] - } + formatted = strings.TrimSuffix(formatted, ".") return formatted }