package transcoder import ( "bufio" "path/filepath" "seanime/internal/mediastream/videofile" "seanime/internal/util" "seanime/internal/util/result" "strconv" "strings" "sync" "github.com/rs/zerolog" ) type Keyframe struct { Sha string Keyframes []float64 IsDone bool info *KeyframeInfo } type KeyframeInfo struct { mutex sync.RWMutex ready sync.WaitGroup listeners []func(keyframes []float64) } func (kf *Keyframe) Get(idx int32) float64 { kf.info.mutex.RLock() defer kf.info.mutex.RUnlock() return kf.Keyframes[idx] } func (kf *Keyframe) Slice(start int32, end int32) []float64 { if end <= start { return []float64{} } kf.info.mutex.RLock() defer kf.info.mutex.RUnlock() ref := kf.Keyframes[start:end] ret := make([]float64, end-start) copy(ret, ref) return ret } func (kf *Keyframe) Length() (int32, bool) { kf.info.mutex.RLock() defer kf.info.mutex.RUnlock() return int32(len(kf.Keyframes)), kf.IsDone } func (kf *Keyframe) add(values []float64) { kf.info.mutex.Lock() defer kf.info.mutex.Unlock() kf.Keyframes = append(kf.Keyframes, values...) for _, listener := range kf.info.listeners { listener(kf.Keyframes) } } func (kf *Keyframe) AddListener(callback func(keyframes []float64)) { kf.info.mutex.Lock() defer kf.info.mutex.Unlock() kf.info.listeners = append(kf.info.listeners, callback) } var keyframes = result.NewResultMap[string, *Keyframe]() func GetKeyframes( path string, hash string, logger *zerolog.Logger, settings *Settings, ) *Keyframe { ret, _ := keyframes.GetOrSet(hash, func() (*Keyframe, error) { kf := &Keyframe{ Sha: hash, IsDone: false, info: &KeyframeInfo{}, } kf.info.ready.Add(1) go func() { keyframesPath := filepath.Join(settings.StreamDir, hash, "keyframes.json") if err := getSavedInfo(keyframesPath, kf); err == nil { logger.Trace().Msgf("transcoder: Keyframes Cache HIT") kf.info.ready.Done() return } err := getKeyframes(settings.FfprobePath, path, kf, hash, logger) if err == nil { saveInfo(keyframesPath, kf) } }() return kf, nil }) ret.info.ready.Wait() return ret } func getKeyframes(ffprobePath string, path string, kf *Keyframe, hash string, logger *zerolog.Logger) error { defer printExecTime(logger, "ffprobe analysis for %s", path)() // Execute ffprobe to retrieve all IFrames. IFrames are specific points in the video we can divide it into segments. // We instruct ffprobe to return the timestamp and flags of each frame. // Although it's possible to request ffprobe to return only i-frames (keyframes) using the -skip_frame nokey option, this approach is highly inefficient. // The inefficiency arises because when this option is used, ffmpeg processes every single frame, which significantly slows down the operation. cmd := util.NewCmd( "ffprobe", "-loglevel", "error", "-select_streams", "v:0", "-show_entries", "packet=pts_time,flags", "-of", "csv=print_section=0", path, ) stdout, err := cmd.StdoutPipe() if err != nil { return err } err = cmd.Start() if err != nil { return err } scanner := bufio.NewScanner(stdout) ret := make([]float64, 0, 1000) max := 100 done := 0 for scanner.Scan() { frame := scanner.Text() if frame == "" { continue } x := strings.Split(frame, ",") pts, flags := x[0], x[1] // if no video track if pts == "N/A" { break } // Only take keyframes if flags[0] != 'K' { continue } fpts, err := strconv.ParseFloat(pts, 64) if err != nil { return err } // Previously, the aim was to save only those keyframes that had a minimum gap of 3 seconds between them. // This was to avoid creating segments as short as 0.2 seconds. // However, there were instances where the -f segment muxer would ignore the specified segment time and choose a random keyframe to cut at. // To counter this, treat every keyframe as a potential segment. //if done == 0 && len(ret) == 0 { // // // There are instances where videos may not start exactly at 0:00. This needs to be considered, // // and we should only include keyframes that occur after the video's start time. If not done so, // // it can lead to a discrepancy in our segment count and potentially duplicate the same segment in the stream. // // // For simplicity in code comprehension, we designate 0 as the initial keyframe, even though it's not genuine. // // This value is never actually passed to ffmpeg. // ret = append(ret, 0) // continue //} ret = append(ret, fpts) if len(ret) == max { kf.add(ret) if done == 0 { kf.info.ready.Done() } else if done >= 500 { max = 500 } done += max // clear the array without reallocing it ret = ret[:0] } } // If there is less than 2 (i.e. equals 0 or 1 (it happens for audio files with poster)) if len(ret) < 2 { dummy, err := getDummyKeyframes(ffprobePath, path, hash) if err != nil { return err } ret = dummy } kf.add(ret) if done == 0 { kf.info.ready.Done() } kf.IsDone = true return nil } func getDummyKeyframes(ffprobePath string, path string, sha string) ([]float64, error) { dummyKeyframeDuration := float64(2) info, err := videofile.FfprobeGetInfo(ffprobePath, path, sha) if err != nil { return nil, err } segmentCount := int((float64(info.Duration) / dummyKeyframeDuration) + 1) ret := make([]float64, segmentCount) for segmentIndex := 0; segmentIndex < segmentCount; segmentIndex += 1 { ret[segmentIndex] = float64(segmentIndex) * dummyKeyframeDuration } return ret, nil }