Skip to content

Commit a07c18e

Browse files
jackspirouclaude
andcommitted
fix: resolve SVG keyframe collisions with dynamic precision
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 <noreply@anthropic.com>
1 parent 9516182 commit a07c18e

2 files changed

Lines changed: 202 additions & 1 deletion

File tree

svg.go

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -502,10 +502,11 @@ func (g *SVGGenerator) generateStyles() string {
502502
g.writeNewline(&sb)
503503

504504
// Build optimized keyframes from timeline
505+
keyframeCount := len(g.timeline)
505506
for _, stop := range g.timeline {
506507
offset := -float64(stop.StateIndex) * g.frameSpacing
507508
sb.WriteString(fmt.Sprintf(" %s%% { transform: translateX(%spx); }",
508-
formatCoord(stop.Percentage), formatCoord(offset)))
509+
formatPercentage(stop.Percentage, keyframeCount), formatCoord(offset)))
509510
g.writeNewline(&sb)
510511
}
511512

@@ -1069,6 +1070,45 @@ func formatCoord(val float64) string {
10691070
return formatted
10701071
}
10711072

1073+
// formatPercentage formats a percentage value with appropriate precision
1074+
// to avoid keyframe collisions in large animations.
1075+
// The precision is dynamically calculated based on the number of keyframes.
1076+
func formatPercentage(val float64, keyframeCount int) string {
1077+
// For whole numbers, keep minimal format
1078+
if val == float64(int(val)) {
1079+
return fmt.Sprintf("%d", int(val))
1080+
}
1081+
1082+
// Dynamically determine precision based on keyframe count
1083+
// This ensures we have enough precision to avoid collisions
1084+
// while keeping the output as compact as possible
1085+
var precision int
1086+
switch {
1087+
case keyframeCount < 100:
1088+
precision = 1 // Up to 100 unique values
1089+
case keyframeCount < 1000:
1090+
precision = 2 // Up to 1,000 unique values
1091+
case keyframeCount < 10000:
1092+
precision = 3 // Up to 10,000 unique values
1093+
case keyframeCount < 100000:
1094+
precision = 4 // Up to 100,000 unique values
1095+
default:
1096+
precision = 5 // Up to 1,000,000 unique values
1097+
}
1098+
1099+
// Format with calculated precision
1100+
formatStr := fmt.Sprintf("%%.%df", precision)
1101+
formatted := fmt.Sprintf(formatStr, val)
1102+
1103+
// Remove trailing zeros but keep at least 1 decimal for consistency
1104+
formatted = strings.TrimRight(formatted, "0")
1105+
if strings.HasSuffix(formatted, ".") {
1106+
formatted = formatted[:len(formatted)-1]
1107+
}
1108+
1109+
return formatted
1110+
}
1111+
10721112
// formatDuration formats a duration value with minimal decimal places.
10731113
func formatDuration(seconds float64) string {
10741114
// If it's a whole number, don't include decimals

svg_test.go

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
44
"fmt"
55
"os"
6+
"regexp"
67
"runtime"
78
"strings"
89
"testing"
@@ -1030,3 +1031,163 @@ func TestFormatDuration(t *testing.T) {
10301031
}
10311032
}
10321033
}
1034+
1035+
// Test formatPercentage function with dynamic precision
1036+
func TestFormatPercentage(t *testing.T) {
1037+
testCases := []struct {
1038+
name string
1039+
input float64
1040+
keyframeCount int
1041+
expected string
1042+
}{
1043+
// Whole numbers always minimal
1044+
{"whole number 0", 0.0, 100, "0"},
1045+
{"whole number 100", 100.0, 1000, "100"},
1046+
{"whole number 50", 50.0, 10000, "50"},
1047+
1048+
// Small frame counts (< 100) - 1 decimal
1049+
{"small count decimal", 12.345, 50, "12.3"},
1050+
{"small count trailing", 10.5000, 75, "10.5"},
1051+
1052+
// Medium frame counts (< 1000) - 2 decimals
1053+
{"medium count decimal", 0.024038, 500, "0.02"},
1054+
{"medium count precision", 2.456, 800, "2.46"},
1055+
{"medium count trailing", 10.1000, 900, "10.1"},
1056+
1057+
// Large frame counts (< 10000) - 3 decimals
1058+
{"large count decimal", 0.024038, 4161, "0.024"},
1059+
{"large count precision", 2.451923, 5000, "2.452"},
1060+
{"large count trailing", 99.9000, 8000, "99.9"},
1061+
1062+
// Very large frame counts (< 100000) - 4 decimals
1063+
{"very large decimal", 0.00012, 50000, "0.0001"},
1064+
{"very large precision", 33.33333, 75000, "33.3333"},
1065+
1066+
// Huge frame counts (>= 100000) - 5 decimals
1067+
{"huge count decimal", 0.000012, 150000, "0.00001"},
1068+
{"huge count precision", 12.345678, 200000, "12.34568"},
1069+
1070+
// Edge cases
1071+
{"negative percentage", -5.5, 100, "-5.5"},
1072+
{"zero with decimals", 0.0001, 1000, "0"},
1073+
}
1074+
1075+
for _, tc := range testCases {
1076+
t.Run(tc.name, func(t *testing.T) {
1077+
result := formatPercentage(tc.input, tc.keyframeCount)
1078+
if result != tc.expected {
1079+
t.Errorf("formatPercentage(%f, %d) = %s; want %s",
1080+
tc.input, tc.keyframeCount, result, tc.expected)
1081+
}
1082+
})
1083+
}
1084+
}
1085+
1086+
// Test dynamic precision prevents collisions
1087+
func TestFormatPercentageDynamicPrecision(t *testing.T) {
1088+
testCases := []struct {
1089+
name string
1090+
frameCount int
1091+
}{
1092+
{"small animation", 50},
1093+
{"medium animation", 500},
1094+
{"large animation", 4161},
1095+
{"very large animation", 50000},
1096+
}
1097+
1098+
for _, tc := range testCases {
1099+
t.Run(tc.name, func(t *testing.T) {
1100+
percentages := make(map[string]bool)
1101+
1102+
for i := 0; i < tc.frameCount; i++ {
1103+
percentage := float64(i) / float64(tc.frameCount-1) * 100
1104+
formatted := formatPercentage(percentage, tc.frameCount)
1105+
1106+
if percentages[formatted] {
1107+
t.Errorf("Duplicate percentage at frame %d/%d: %s (%.6f%%)",
1108+
i, tc.frameCount, formatted, percentage)
1109+
}
1110+
percentages[formatted] = true
1111+
}
1112+
1113+
if len(percentages) != tc.frameCount {
1114+
t.Errorf("Expected %d unique percentages, got %d",
1115+
tc.frameCount, len(percentages))
1116+
}
1117+
})
1118+
}
1119+
}
1120+
1121+
// Test that large animations produce unique percentages
1122+
func TestLargeAnimationPercentages(t *testing.T) {
1123+
// Test that 4161 frames produce unique percentages
1124+
percentages := make(map[string]bool)
1125+
totalFrames := 4161
1126+
1127+
for i := 0; i < totalFrames; i++ {
1128+
percentage := float64(i) / float64(totalFrames-1) * 100
1129+
formatted := formatPercentage(percentage, totalFrames)
1130+
1131+
if percentages[formatted] {
1132+
t.Errorf("Duplicate percentage at frame %d: %s (%.6f%%)", i, formatted, percentage)
1133+
}
1134+
percentages[formatted] = true
1135+
}
1136+
1137+
if len(percentages) != totalFrames {
1138+
t.Errorf("Expected %d unique percentages, got %d",
1139+
totalFrames, len(percentages))
1140+
}
1141+
}
1142+
1143+
// Test SVG keyframe generation with many frames
1144+
func TestSVGKeyframeGeneration(t *testing.T) {
1145+
// Create config with many frames to test keyframe collision
1146+
opts := createTestSVGConfig()
1147+
opts.Frames = make([]SVGFrame, 1000)
1148+
for i := range opts.Frames {
1149+
opts.Frames[i] = SVGFrame{
1150+
Lines: []string{fmt.Sprintf("Frame %d", i)},
1151+
CharWidth: 8.8,
1152+
CharHeight: 20,
1153+
CursorX: i % 10,
1154+
CursorY: 0,
1155+
}
1156+
}
1157+
1158+
gen := NewSVGGenerator(opts)
1159+
svg := gen.Generate()
1160+
1161+
// Verify no duplicate keyframe percentages
1162+
keyframeRegex := regexp.MustCompile(`(\d+(?:\.\d+)?)\%\s*\{`)
1163+
matches := keyframeRegex.FindAllStringSubmatch(svg, -1)
1164+
1165+
seen := make(map[string]bool)
1166+
duplicates := 0
1167+
for _, match := range matches {
1168+
if seen[match[1]] {
1169+
duplicates++
1170+
if duplicates <= 5 { // Only report first 5 duplicates
1171+
t.Errorf("Duplicate keyframe percentage found: %s%%", match[1])
1172+
}
1173+
}
1174+
seen[match[1]] = true
1175+
}
1176+
1177+
if duplicates > 0 {
1178+
t.Errorf("Found %d duplicate keyframe percentages out of %d total keyframes",
1179+
duplicates, len(matches))
1180+
}
1181+
1182+
// Verify animation is present
1183+
if !strings.Contains(svg, "@keyframes slide") {
1184+
t.Error("SVG missing @keyframes slide animation")
1185+
}
1186+
1187+
// Verify reasonable number of keyframes were generated
1188+
// With deduplication, we should have fewer keyframes than frames
1189+
if len(matches) > len(opts.Frames) {
1190+
t.Errorf("Too many keyframes generated: %d keyframes for %d frames",
1191+
len(matches), len(opts.Frames))
1192+
}
1193+
}

0 commit comments

Comments
 (0)