package keyframe import ( "context" "crypto/md5" "encoding/binary" "fmt" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "time" "git.leaktechnologies.dev/stu/VT_Player/internal/logging" ) // Keyframe represents an I-frame position in a video type Keyframe struct { FrameNum int // Frame number (0-indexed) Timestamp float64 // Time in seconds } // Index holds keyframe positions for a video // Only stores I-frames for memory efficiency (~1KB per minute of video) type Index struct { Keyframes []Keyframe // Only I-frames, not all frames TotalFrames int // Total number of frames in video Duration float64 // Duration in seconds FrameRate float64 // Average frame rate VideoPath string // Path to source video CreatedAt time.Time // When index was created } // DetectKeyframes uses ffprobe to find I-frames (keyframes) in a video // Performance target: <5s for 1-hour video, <10MB memory overhead func DetectKeyframes(videoPath string) (*Index, error) { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() logging.Debug(logging.CatFFMPEG, "detecting keyframes for %s", videoPath) startTime := time.Now() // Get video metadata first (duration, framerate) metadata, err := getVideoMetadata(ctx, videoPath) if err != nil { return nil, fmt.Errorf("failed to get video metadata: %w", err) } // Use ffprobe to extract keyframes via packet flags // Packets with 'K' flag are keyframes (I-frames) // This is faster than decoding frames cmd := exec.CommandContext(ctx, "ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "packet=pts_time,flags", "-of", "csv=p=0", videoPath, ) output, err := cmd.Output() if err != nil { return nil, fmt.Errorf("ffprobe keyframe detection failed: %w", err) } // Parse keyframe timestamps // Format: pts_time,flags (e.g., "0.000000,K_" for keyframe or "0.033367,__" for non-keyframe) lines := strings.Split(strings.TrimSpace(string(output)), "\n") keyframes := make([]Keyframe, 0, len(lines)/10) // Estimate: ~10% are keyframes for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } parts := strings.Split(line, ",") if len(parts) != 2 { continue } // Check if this is a keyframe (flags contains 'K') isKeyframe := strings.Contains(parts[1], "K") if !isKeyframe { continue } timestamp, err := strconv.ParseFloat(parts[0], 64) if err != nil { logging.Debug(logging.CatFFMPEG, "failed to parse keyframe timestamp '%s': %v", parts[0], err) continue } // Calculate frame number from timestamp frameNum := int(timestamp * metadata.FrameRate) keyframes = append(keyframes, Keyframe{ FrameNum: frameNum, Timestamp: timestamp, }) } elapsed := time.Since(startTime) logging.Debug(logging.CatFFMPEG, "detected %d keyframes in %.2fs (%.0f keyframes/sec)", len(keyframes), elapsed.Seconds(), float64(len(keyframes))/elapsed.Seconds()) idx := &Index{ Keyframes: keyframes, TotalFrames: int(metadata.Duration * metadata.FrameRate), Duration: metadata.Duration, FrameRate: metadata.FrameRate, VideoPath: videoPath, CreatedAt: time.Now(), } return idx, nil } // videoMetadata holds basic video information needed for keyframe detection type videoMetadata struct { Duration float64 FrameRate float64 } // getVideoMetadata extracts duration and framerate from video func getVideoMetadata(ctx context.Context, videoPath string) (*videoMetadata, error) { // Get duration from format (more reliable than stream duration) durationCmd := exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", videoPath, ) durationOut, err := durationCmd.Output() if err != nil { return nil, fmt.Errorf("failed to get duration: %w", err) } duration, err := strconv.ParseFloat(strings.TrimSpace(string(durationOut)), 64) if err != nil { return nil, fmt.Errorf("invalid duration: %w", err) } // Get frame rate from stream framerateCmd := exec.CommandContext(ctx, "ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=avg_frame_rate", "-of", "csv=p=0", videoPath, ) framerateOut, err := framerateCmd.Output() if err != nil { return nil, fmt.Errorf("failed to get framerate: %w", err) } // Parse frame rate (format: "num/den" like "30000/1001") frameRate := parseFrameRate(strings.TrimSpace(string(framerateOut))) if frameRate <= 0 { frameRate = 30.0 // Default fallback } return &videoMetadata{ Duration: duration, FrameRate: frameRate, }, nil } // parseFrameRate parses ffprobe frame rate format "num/den" func parseFrameRate(rateStr string) float64 { parts := strings.Split(rateStr, "/") if len(parts) != 2 { return 0 } num, err1 := strconv.ParseFloat(parts[0], 64) den, err2 := strconv.ParseFloat(parts[1], 64) if err1 != nil || err2 != nil || den == 0 { return 0 } return num / den } // FindNearestKeyframe returns the closest keyframe to the given timestamp // direction: "before" (<=), "after" (>=), "nearest" (closest) func (idx *Index) FindNearestKeyframe(timestamp float64, direction string) *Keyframe { if len(idx.Keyframes) == 0 { return nil } switch direction { case "before": return idx.findBefore(timestamp) case "after": return idx.findAfter(timestamp) case "nearest": return idx.findNearest(timestamp) default: return idx.findNearest(timestamp) } } // findBefore finds the last keyframe at or before timestamp (binary search) func (idx *Index) findBefore(timestamp float64) *Keyframe { if len(idx.Keyframes) == 0 { return nil } // Binary search for insertion point i := sort.Search(len(idx.Keyframes), func(i int) bool { return idx.Keyframes[i].Timestamp > timestamp }) // i is the first keyframe after timestamp // We want the one before it if i == 0 { // All keyframes are after timestamp, return first one return &idx.Keyframes[0] } return &idx.Keyframes[i-1] } // findAfter finds the first keyframe at or after timestamp (binary search) func (idx *Index) findAfter(timestamp float64) *Keyframe { if len(idx.Keyframes) == 0 { return nil } // Binary search for insertion point i := sort.Search(len(idx.Keyframes), func(i int) bool { return idx.Keyframes[i].Timestamp >= timestamp }) if i >= len(idx.Keyframes) { // All keyframes are before timestamp, return last one return &idx.Keyframes[len(idx.Keyframes)-1] } return &idx.Keyframes[i] } // findNearest finds the closest keyframe to timestamp func (idx *Index) findNearest(timestamp float64) *Keyframe { if len(idx.Keyframes) == 0 { return nil } before := idx.findBefore(timestamp) after := idx.findAfter(timestamp) // If they're the same, return it if before == after { return before } // Return whichever is closer beforeDist := timestamp - before.Timestamp afterDist := after.Timestamp - timestamp if beforeDist <= afterDist { return before } return after } // EstimateFrameNumber calculates frame number from timestamp func (idx *Index) EstimateFrameNumber(timestamp float64) int { if idx.FrameRate <= 0 { return 0 } return int(timestamp*idx.FrameRate + 0.5) } // GetKeyframeAt returns the keyframe at the given index, or nil if out of range func (idx *Index) GetKeyframeAt(i int) *Keyframe { if i < 0 || i >= len(idx.Keyframes) { return nil } return &idx.Keyframes[i] } // NumKeyframes returns the total number of keyframes func (idx *Index) NumKeyframes() int { return len(idx.Keyframes) } // GetCacheKey generates a unique cache key for a video file // Based on file path and modification time to invalidate cache when file changes func GetCacheKey(videoPath string) (string, error) { info, err := os.Stat(videoPath) if err != nil { return "", err } // Create hash of path + mod time h := md5.New() h.Write([]byte(videoPath)) binary.Write(h, binary.LittleEndian, info.ModTime().Unix()) return fmt.Sprintf("%x", h.Sum(nil)), nil } // GetCacheDir returns the directory for keyframe cache files func GetCacheDir() (string, error) { homeDir, err := os.UserHomeDir() if err != nil { return "", err } cacheDir := filepath.Join(homeDir, ".cache", "vt_player", "keyframes") if err := os.MkdirAll(cacheDir, 0755); err != nil { return "", err } return cacheDir, nil } // SaveToCache saves the keyframe index to disk cache func (idx *Index) SaveToCache() error { cacheKey, err := GetCacheKey(idx.VideoPath) if err != nil { return err } cacheDir, err := GetCacheDir() if err != nil { return err } cachePath := filepath.Join(cacheDir, cacheKey+".kf") f, err := os.Create(cachePath) if err != nil { return err } defer f.Close() // Write binary format: // [num_keyframes:4][duration:8][framerate:8] // [timestamp:8][frame_num:4]... (repeated for each keyframe) if err := binary.Write(f, binary.LittleEndian, int32(len(idx.Keyframes))); err != nil { return err } if err := binary.Write(f, binary.LittleEndian, idx.Duration); err != nil { return err } if err := binary.Write(f, binary.LittleEndian, idx.FrameRate); err != nil { return err } for _, kf := range idx.Keyframes { if err := binary.Write(f, binary.LittleEndian, kf.Timestamp); err != nil { return err } if err := binary.Write(f, binary.LittleEndian, int32(kf.FrameNum)); err != nil { return err } } logging.Debug(logging.CatFFMPEG, "saved keyframe cache: %s (%d keyframes, %.1fKB)", cachePath, len(idx.Keyframes), float64(len(idx.Keyframes)*12)/1024.0) return nil } // LoadFromCache loads keyframe index from disk cache func LoadFromCache(videoPath string) (*Index, error) { cacheKey, err := GetCacheKey(videoPath) if err != nil { return nil, err } cacheDir, err := GetCacheDir() if err != nil { return nil, err } cachePath := filepath.Join(cacheDir, cacheKey+".kf") f, err := os.Open(cachePath) if err != nil { return nil, err } defer f.Close() var numKeyframes int32 if err := binary.Read(f, binary.LittleEndian, &numKeyframes); err != nil { return nil, err } var duration, frameRate float64 if err := binary.Read(f, binary.LittleEndian, &duration); err != nil { return nil, err } if err := binary.Read(f, binary.LittleEndian, &frameRate); err != nil { return nil, err } keyframes := make([]Keyframe, numKeyframes) for i := range keyframes { if err := binary.Read(f, binary.LittleEndian, &keyframes[i].Timestamp); err != nil { return nil, err } var frameNum int32 if err := binary.Read(f, binary.LittleEndian, &frameNum); err != nil { return nil, err } keyframes[i].FrameNum = int(frameNum) } idx := &Index{ Keyframes: keyframes, TotalFrames: int(duration * frameRate), Duration: duration, FrameRate: frameRate, VideoPath: videoPath, CreatedAt: time.Now(), // Cache load time } logging.Debug(logging.CatFFMPEG, "loaded keyframe cache: %s (%d keyframes)", cachePath, len(keyframes)) return idx, nil } // DetectKeyframesWithCache attempts to load from cache, falls back to detection func DetectKeyframesWithCache(videoPath string) (*Index, error) { // Try cache first idx, err := LoadFromCache(videoPath) if err == nil { logging.Debug(logging.CatFFMPEG, "using cached keyframes for %s", videoPath) return idx, nil } // Cache miss or error, detect keyframes logging.Debug(logging.CatFFMPEG, "cache miss, detecting keyframes for %s", videoPath) idx, err = DetectKeyframes(videoPath) if err != nil { return nil, err } // Save to cache for next time if err := idx.SaveToCache(); err != nil { logging.Debug(logging.CatFFMPEG, "failed to save keyframe cache: %v", err) // Don't fail if cache save fails } return idx, nil } // CleanCache removes old cache files (older than maxAge) func CleanCache(maxAge time.Duration) error { cacheDir, err := GetCacheDir() if err != nil { return err } entries, err := os.ReadDir(cacheDir) if err != nil { return err } now := time.Now() removed := 0 for _, entry := range entries { if entry.IsDir() { continue } if !strings.HasSuffix(entry.Name(), ".kf") { continue } info, err := entry.Info() if err != nil { continue } age := now.Sub(info.ModTime()) if age > maxAge { path := filepath.Join(cacheDir, entry.Name()) if err := os.Remove(path); err != nil { logging.Debug(logging.CatFFMPEG, "failed to remove old cache file %s: %v", path, err) } else { removed++ } } } if removed > 0 { logging.Debug(logging.CatFFMPEG, "cleaned %d old keyframe cache files", removed) } return nil } // GetCacheSize returns total size of cache directory in bytes func GetCacheSize() (int64, error) { cacheDir, err := GetCacheDir() if err != nil { return 0, err } var totalSize int64 err = filepath.Walk(cacheDir, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if !info.IsDir() { totalSize += info.Size() } return nil }) return totalSize, err }