mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 12:54:38 +03:00
Fix scrubber sprite creation for short video files (#2167)
* Fix scrubber sprite creation for small files * accept only valid ffprobe nbReadFrames
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, "/") {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 (<ChunkCount) g.Info.NthFrame can be zero
|
||||
// so recalculate from scratch
|
||||
stepSize = float64(g.Info.VideoFile.FrameCount-1) / float64(g.Info.ChunkCount)
|
||||
stepSize /= g.Info.FrameRate
|
||||
}
|
||||
|
||||
vttLines := []string{"WEBVTT", ""}
|
||||
for index := 0; index < g.Info.ChunkCount; index++ {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
### 🐛 Bug fixes
|
||||
* Generate sprites for short video files. ([#2167](https://github.com/stashapp/stash/pull/2167))
|
||||
* Fix stash-box scraping including underscores in ethnicity. ([#2191](https://github.com/stashapp/stash/pull/2191))
|
||||
* Fix stash-box batch performer task not setting birthdate. ([#2189](https://github.com/stashapp/stash/pull/2189))
|
||||
* Fix error when scanning symlinks. ([#2196](https://github.com/stashapp/stash/issues/2196))
|
||||
|
||||
Reference in New Issue
Block a user