diff --git a/pkg/ffmpeg/encoder_sprite_screenshot.go b/pkg/ffmpeg/encoder_sprite_screenshot.go index cba560430..d0068cb2a 100644 --- a/pkg/ffmpeg/encoder_sprite_screenshot.go +++ b/pkg/ffmpeg/encoder_sprite_screenshot.go @@ -8,6 +8,7 @@ import ( type SpriteScreenshotOptions struct { Time float64 + Frame int Width int } @@ -36,3 +37,31 @@ func (e *Encoder) SpriteScreenshot(probeResult VideoFile, options SpriteScreensh return img, err } + +// SpriteScreenshotSlow uses the select filter to get a single frame from a videofile instead of seeking +// It is very slow and should only be used for files with very small duration in secs / frame count +func (e *Encoder) SpriteScreenshotSlow(probeResult VideoFile, options SpriteScreenshotOptions) (image.Image, error) { + args := []string{ + "-v", "error", + "-i", probeResult.Path, + "-vsync", "0", // do not create/drop frames + "-vframes", "1", + "-vf", fmt.Sprintf("select=eq(n\\,%d),scale=%v:-1", options.Frame, options.Width), // keep only frame number options.Frame + "-c:v", "bmp", + "-f", "rawvideo", + "-", + } + data, err := e.run(probeResult.Path, args, nil) + if err != nil { + return nil, err + } + + reader := strings.NewReader(data) + + img, _, err := image.Decode(reader) + if err != nil { + return nil, err + } + + return img, err +} diff --git a/pkg/ffmpeg/ffprobe.go b/pkg/ffmpeg/ffprobe.go index dfde16d8b..a24d2b39d 100644 --- a/pkg/ffmpeg/ffprobe.go +++ b/pkg/ffmpeg/ffprobe.go @@ -218,6 +218,7 @@ type VideoFile struct { Height int FrameRate float64 Rotation int64 + FrameCount int64 AudioCodec string } @@ -242,6 +243,24 @@ func (f *FFProbe) NewVideoFile(videoPath string, stripExt bool) (*VideoFile, err return parse(videoPath, probeJSON, stripExt) } +// GetReadFrameCount counts the actual frames of the video file +func (f *FFProbe) GetReadFrameCount(vf *VideoFile) (int64, error) { + args := []string{"-v", "quiet", "-print_format", "json", "-count_frames", "-show_format", "-show_streams", "-show_error", vf.Path} + out, err := exec.Command(string(*f), args...).Output() + + if err != nil { + return 0, fmt.Errorf("FFProbe encountered an error with <%s>.\nError JSON:\n%s\nError: %s", vf.Path, string(out), err.Error()) + } + + probeJSON := &FFProbeJSON{} + if err := json.Unmarshal(out, probeJSON); err != nil { + return 0, fmt.Errorf("error unmarshalling video data for <%s>: %s", vf.Path, err.Error()) + } + + fc, err := parse(vf.Path, probeJSON, false) + return fc.FrameCount, err +} + func parse(filePath string, probeJSON *FFProbeJSON, stripExt bool) (*VideoFile, error) { if probeJSON == nil { return nil, fmt.Errorf("failed to get ffprobe json for <%s>", filePath) @@ -263,8 +282,8 @@ func parse(filePath string, probeJSON *FFProbeJSON, stripExt bool) (*VideoFile, } result.Comment = probeJSON.Format.Tags.Comment - result.Bitrate, _ = strconv.ParseInt(probeJSON.Format.BitRate, 10, 64) + result.Container = probeJSON.Format.FormatName duration, _ := strconv.ParseFloat(probeJSON.Format.Duration, 64) result.Duration = math.Round(duration*100) / 100 @@ -288,6 +307,15 @@ func parse(filePath string, probeJSON *FFProbeJSON, stripExt bool) (*VideoFile, if videoStream != nil { result.VideoStream = videoStream result.VideoCodec = videoStream.CodecName + result.FrameCount, _ = strconv.ParseInt(videoStream.NbFrames, 10, 64) + if videoStream.NbReadFrames != "" { // if ffprobe counted the frames use that instead + fc, _ := strconv.ParseInt(videoStream.NbReadFrames, 10, 64) + if fc > 0 { + result.FrameCount, _ = strconv.ParseInt(videoStream.NbReadFrames, 10, 64) + } else { + logger.Debugf("[ffprobe] <%s> invalid Read Frames count", videoStream.NbReadFrames) + } + } result.VideoBitrate, _ = strconv.ParseInt(videoStream.BitRate, 10, 64) var framerate float64 if strings.Contains(videoStream.AvgFrameRate, "/") { diff --git a/pkg/ffmpeg/types.go b/pkg/ffmpeg/types.go index ed3fadf67..d239c6cdf 100644 --- a/pkg/ffmpeg/types.go +++ b/pkg/ffmpeg/types.go @@ -70,6 +70,7 @@ type FFProbeStream struct { Level int `json:"level,omitempty"` NalLengthSize string `json:"nal_length_size,omitempty"` NbFrames string `json:"nb_frames"` + NbReadFrames string `json:"nb_read_frames"` PixFmt string `json:"pix_fmt,omitempty"` Profile string `json:"profile"` RFrameRate string `json:"r_frame_rate"` diff --git a/pkg/manager/generator_sprite.go b/pkg/manager/generator_sprite.go index c374217ce..72c45e124 100644 --- a/pkg/manager/generator_sprite.go +++ b/pkg/manager/generator_sprite.go @@ -25,6 +25,7 @@ type SpriteGenerator struct { VTTOutputPath string Rows int Columns int + SlowSeek bool // use alternate seek function, very slow! Overwrite bool } @@ -34,17 +35,33 @@ func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageO if !exists { return nil, err } + slowSeek := false + chunkCount := rows * cols - // FFMPEG bombs out if we try to request 89 snapshots from a 2 second video - if videoFile.Duration < 3 { - return nil, errors.New("video too short to create sprite") + // For files with small duration / low frame count try to seek using frame number intead of seconds + if videoFile.Duration < 5 || (0 < videoFile.FrameCount && videoFile.FrameCount <= int64(chunkCount)) { // some files can have FrameCount == 0, only use SlowSeek if duration < 5 + if videoFile.Duration <= 0 { + s := fmt.Sprintf("video %s: duration(%.3f)/frame count(%d) invalid, skipping sprite creation", videoFile.Path, videoFile.Duration, videoFile.FrameCount) + return nil, errors.New(s) + } + logger.Warnf("[generator] video %s too short (%.3fs, %d frames), using frame seeking", videoFile.Path, videoFile.Duration, videoFile.FrameCount) + slowSeek = true + // do an actual frame count of the file ( number of frames = read frames) + ffprobe := GetInstance().FFProbe + fc, err := ffprobe.GetReadFrameCount(&videoFile) + if err == nil { + if fc != videoFile.FrameCount { + logger.Warnf("[generator] updating framecount (%d) for %s with read frames count (%d)", videoFile.FrameCount, videoFile.Path, fc) + videoFile.FrameCount = fc + } + } } generator, err := newGeneratorInfo(videoFile) if err != nil { return nil, err } - generator.ChunkCount = rows * cols + generator.ChunkCount = chunkCount if err := generator.configure(); err != nil { return nil, err } @@ -55,6 +72,7 @@ func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageO ImageOutputPath: imageOutputPath, VTTOutputPath: vttOutputPath, Rows: rows, + SlowSeek: slowSeek, Columns: cols, }, nil } @@ -75,23 +93,51 @@ func (g *SpriteGenerator) generateSpriteImage(encoder *ffmpeg.Encoder) error { if !g.Overwrite && g.imageExists() { return nil } - logger.Infof("[generator] generating sprite image for %s", g.Info.VideoFile.Path) - // Create `this.chunkCount` thumbnails in the tmp directory - stepSize := g.Info.VideoFile.Duration / float64(g.Info.ChunkCount) var images []image.Image - for i := 0; i < g.Info.ChunkCount; i++ { - time := float64(i) * stepSize - options := ffmpeg.SpriteScreenshotOptions{ - Time: time, - Width: 160, + if !g.SlowSeek { + logger.Infof("[generator] generating sprite image for %s", g.Info.VideoFile.Path) + // generate `ChunkCount` thumbnails + stepSize := g.Info.VideoFile.Duration / float64(g.Info.ChunkCount) + + for i := 0; i < g.Info.ChunkCount; i++ { + time := float64(i) * stepSize + + options := ffmpeg.SpriteScreenshotOptions{ + Time: time, + Width: 160, + } + + img, err := encoder.SpriteScreenshot(g.Info.VideoFile, options) + + if err != nil { + return err + } + images = append(images, img) } - img, err := encoder.SpriteScreenshot(g.Info.VideoFile, options) - if err != nil { - return err + } else { + logger.Infof("[generator] generating sprite image for %s (%d frames)", g.Info.VideoFile.Path, g.Info.VideoFile.FrameCount) + + stepFrame := float64(g.Info.VideoFile.FrameCount-1) / float64(g.Info.ChunkCount) + + for i := 0; i < g.Info.ChunkCount; i++ { + // generate exactly `ChunkCount` thumbnails, using duplicate frames if needed + frame := math.Round(float64(i) * stepFrame) + if frame >= math.MaxInt || frame <= math.MinInt { + return errors.New("invalid frame number conversion") + } + options := ffmpeg.SpriteScreenshotOptions{ + Frame: int(frame), + Width: 160, + } + img, err := encoder.SpriteScreenshotSlow(g.Info.VideoFile, options) + if err != nil { + return err + } + images = append(images, img) } - images = append(images, img) + } if len(images) == 0 { @@ -132,7 +178,15 @@ func (g *SpriteGenerator) generateSpriteVTT(encoder *ffmpeg.Encoder) error { width := image.Width / g.Columns height := image.Height / g.Rows - stepSize := float64(g.Info.NthFrame) / g.Info.FrameRate + var stepSize float64 + if !g.SlowSeek { + stepSize = float64(g.Info.NthFrame) / g.Info.FrameRate + } else { + // for files with a low framecount (