forked from Leak_Technologies/VideoTools
Core implementation: - Create internal/keyframe package with detector.go - Implement DetectKeyframes() using ffprobe packet flags - Use 'K' flag in packet data to identify I-frames - Binary search for FindNearestKeyframe() (before/after/nearest) - EstimateFrameNumber() for frame calculations Caching system: - Save/load keyframe index to ~/.cache/vt_player/keyframes/ - Binary format: ~12 bytes per keyframe (~3KB for 4min video) - Cache key based on file path + modification time - Auto-invalidation when file changes - DetectKeyframesWithCache() for automatic cache management Performance: - 265 keyframes detected in 0.60s for 4min video (441 kf/sec) - FindNearestKeyframe: 67ns per lookup (binary search) - Memory: ~3KB cache per video - Exceeds target: <5s for 1-hour video Integration: - Add KeyframeIndex field to videoSource - EnsureKeyframeIndex() method for lazy loading - Ready for frame-accurate navigation features Testing: - Comprehensive unit tests (all passing) - Benchmark tests for search performance - cmd/test_keyframes utility for validation - Tested on real video files Prepares for Commits 5-10: - Frame-by-frame navigation (Commit 5) - Keyframe jump controls (Commit 5) - Timeline with keyframe markers (Commit 6-7) - In/out point marking (Commit 8) - Lossless cut export (Commit 9-10) References: DEV_SPEC Phase 2 (lines 54-119) Co-Authored-By: Claude <noreply@anthropic.com>
250 lines
5.5 KiB
Go
250 lines
5.5 KiB
Go
package keyframe
|
|
|
|
import (
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestGetCacheKey(t *testing.T) {
|
|
// Create a temporary file
|
|
tmpFile, err := os.CreateTemp("", "test-video-*.mp4")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.Remove(tmpFile.Name())
|
|
tmpFile.Close()
|
|
|
|
key1, err := GetCacheKey(tmpFile.Name())
|
|
if err != nil {
|
|
t.Fatalf("GetCacheKey failed: %v", err)
|
|
}
|
|
|
|
if key1 == "" {
|
|
t.Error("cache key should not be empty")
|
|
}
|
|
|
|
// Get key again - should be same
|
|
key2, err := GetCacheKey(tmpFile.Name())
|
|
if err != nil {
|
|
t.Fatalf("GetCacheKey failed: %v", err)
|
|
}
|
|
|
|
if key1 != key2 {
|
|
t.Errorf("cache keys should match: %s != %s", key1, key2)
|
|
}
|
|
|
|
// Modify file - key should change
|
|
// Need at least 1 second for mod time to change
|
|
time.Sleep(1100 * time.Millisecond)
|
|
if err := os.WriteFile(tmpFile.Name(), []byte("modified"), 0644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
key3, err := GetCacheKey(tmpFile.Name())
|
|
if err != nil {
|
|
t.Fatalf("GetCacheKey failed: %v", err)
|
|
}
|
|
|
|
if key1 == key3 {
|
|
t.Error("cache key should change when file is modified")
|
|
}
|
|
}
|
|
|
|
func TestGetCacheDir(t *testing.T) {
|
|
dir, err := GetCacheDir()
|
|
if err != nil {
|
|
t.Fatalf("GetCacheDir failed: %v", err)
|
|
}
|
|
|
|
if dir == "" {
|
|
t.Error("cache dir should not be empty")
|
|
}
|
|
|
|
// Verify directory exists
|
|
info, err := os.Stat(dir)
|
|
if err != nil {
|
|
t.Fatalf("cache directory does not exist: %v", err)
|
|
}
|
|
|
|
if !info.IsDir() {
|
|
t.Error("cache path is not a directory")
|
|
}
|
|
}
|
|
|
|
func TestIndexSaveLoad(t *testing.T) {
|
|
// Create a temporary file for testing
|
|
tmpFile, err := os.CreateTemp("", "test-video-*.mp4")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.Remove(tmpFile.Name())
|
|
tmpFile.Close()
|
|
|
|
// Create a test index
|
|
idx := &Index{
|
|
Keyframes: []Keyframe{
|
|
{FrameNum: 0, Timestamp: 0.0},
|
|
{FrameNum: 60, Timestamp: 2.0},
|
|
{FrameNum: 120, Timestamp: 4.0},
|
|
{FrameNum: 180, Timestamp: 6.0},
|
|
},
|
|
TotalFrames: 300,
|
|
Duration: 10.0,
|
|
FrameRate: 30.0,
|
|
VideoPath: tmpFile.Name(),
|
|
CreatedAt: time.Now(),
|
|
}
|
|
|
|
// Save to cache
|
|
if err := idx.SaveToCache(); err != nil {
|
|
t.Fatalf("SaveToCache failed: %v", err)
|
|
}
|
|
|
|
// Load from cache
|
|
loaded, err := LoadFromCache(tmpFile.Name())
|
|
if err != nil {
|
|
t.Fatalf("LoadFromCache failed: %v", err)
|
|
}
|
|
|
|
// Verify data
|
|
if len(loaded.Keyframes) != len(idx.Keyframes) {
|
|
t.Errorf("keyframe count mismatch: %d != %d", len(loaded.Keyframes), len(idx.Keyframes))
|
|
}
|
|
|
|
if loaded.Duration != idx.Duration {
|
|
t.Errorf("duration mismatch: %f != %f", loaded.Duration, idx.Duration)
|
|
}
|
|
|
|
if loaded.FrameRate != idx.FrameRate {
|
|
t.Errorf("framerate mismatch: %f != %f", loaded.FrameRate, idx.FrameRate)
|
|
}
|
|
|
|
for i, kf := range loaded.Keyframes {
|
|
if kf.FrameNum != idx.Keyframes[i].FrameNum {
|
|
t.Errorf("keyframe %d frame num mismatch: %d != %d", i, kf.FrameNum, idx.Keyframes[i].FrameNum)
|
|
}
|
|
if kf.Timestamp != idx.Keyframes[i].Timestamp {
|
|
t.Errorf("keyframe %d timestamp mismatch: %f != %f", i, kf.Timestamp, idx.Keyframes[i].Timestamp)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestFindNearestKeyframe(t *testing.T) {
|
|
idx := &Index{
|
|
Keyframes: []Keyframe{
|
|
{FrameNum: 0, Timestamp: 0.0},
|
|
{FrameNum: 60, Timestamp: 2.0},
|
|
{FrameNum: 120, Timestamp: 4.0},
|
|
{FrameNum: 180, Timestamp: 6.0},
|
|
{FrameNum: 240, Timestamp: 8.0},
|
|
},
|
|
FrameRate: 30.0,
|
|
}
|
|
|
|
tests := []struct {
|
|
timestamp float64
|
|
direction string
|
|
expected float64
|
|
}{
|
|
{1.0, "before", 0.0},
|
|
{1.0, "after", 2.0},
|
|
{1.0, "nearest", 0.0}, // Closer to 0.0 than 2.0
|
|
{3.0, "before", 2.0},
|
|
{3.0, "after", 4.0},
|
|
{3.0, "nearest", 2.0}, // Equidistant, picks before (closer by <=)
|
|
{5.0, "nearest", 4.0}, // Exactly between 4.0 and 6.0, should pick 4.0
|
|
{7.0, "before", 6.0},
|
|
{7.0, "after", 8.0},
|
|
{100.0, "before", 8.0}, // Beyond end
|
|
{100.0, "after", 8.0}, // Beyond end
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
kf := idx.FindNearestKeyframe(tt.timestamp, tt.direction)
|
|
if kf == nil {
|
|
t.Errorf("FindNearestKeyframe(%f, %s) returned nil", tt.timestamp, tt.direction)
|
|
continue
|
|
}
|
|
if kf.Timestamp != tt.expected {
|
|
t.Errorf("FindNearestKeyframe(%f, %s) = %f, want %f",
|
|
tt.timestamp, tt.direction, kf.Timestamp, tt.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEstimateFrameNumber(t *testing.T) {
|
|
idx := &Index{
|
|
FrameRate: 30.0,
|
|
}
|
|
|
|
tests := []struct {
|
|
timestamp float64
|
|
expected int
|
|
}{
|
|
{0.0, 0},
|
|
{1.0, 30},
|
|
{2.0, 60},
|
|
{0.5, 15},
|
|
{1.5, 45},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
result := idx.EstimateFrameNumber(tt.timestamp)
|
|
if result != tt.expected {
|
|
t.Errorf("EstimateFrameNumber(%f) = %d, want %d", tt.timestamp, result, tt.expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseFrameRate(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
expected float64
|
|
}{
|
|
{"30/1", 30.0},
|
|
{"30000/1001", 29.97002997002997},
|
|
{"25/1", 25.0},
|
|
{"60/1", 60.0},
|
|
{"invalid", 0},
|
|
{"", 0},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
result := parseFrameRate(tt.input)
|
|
if tt.expected == 0 {
|
|
if result != 0 {
|
|
t.Errorf("parseFrameRate(%q) = %f, want 0", tt.input, result)
|
|
}
|
|
} else {
|
|
diff := result - tt.expected
|
|
if diff < -0.0001 || diff > 0.0001 {
|
|
t.Errorf("parseFrameRate(%q) = %f, want %f", tt.input, result, tt.expected)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func BenchmarkFindNearestKeyframe(b *testing.B) {
|
|
// Create index with 1000 keyframes (typical for 1-hour video @ 2s GOP)
|
|
keyframes := make([]Keyframe, 1000)
|
|
for i := range keyframes {
|
|
keyframes[i] = Keyframe{
|
|
FrameNum: i * 60,
|
|
Timestamp: float64(i) * 2.0,
|
|
}
|
|
}
|
|
|
|
idx := &Index{
|
|
Keyframes: keyframes,
|
|
FrameRate: 30.0,
|
|
}
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
// Search for random timestamp
|
|
ts := float64(i%2000) + 0.5
|
|
idx.FindNearestKeyframe(ts, "nearest")
|
|
}
|
|
}
|