Scan video orientation (#5189)

* Adjust video dimensions for side data rotation
* Warn user when ffprobe version < 5. Only get rotation data on version >= 5
This commit is contained in:
WithoutPants 2024-09-03 16:33:15 +10:00 committed by GitHub
parent 899ee713ab
commit c21ded028a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 163 additions and 53 deletions

View File

@ -18,7 +18,7 @@ func customUsage() {
flag.PrintDefaults() flag.PrintDefaults()
} }
func printPhash(ff *ffmpeg.FFMpeg, ffp ffmpeg.FFProbe, inputfile string, quiet *bool) error { func printPhash(ff *ffmpeg.FFMpeg, ffp *ffmpeg.FFProbe, inputfile string, quiet *bool) error {
ffvideoFile, err := ffp.NewVideoFile(inputfile) ffvideoFile, err := ffp.NewVideoFile(inputfile)
if err != nil { if err != nil {
return err return err
@ -80,7 +80,7 @@ func main() {
ffmpegPath, ffprobePath := getPaths() ffmpegPath, ffprobePath := getPaths()
encoder := ffmpeg.NewEncoder(ffmpegPath) encoder := ffmpeg.NewEncoder(ffmpegPath)
// don't need to InitHWSupport, phashing doesn't use hw acceleration // don't need to InitHWSupport, phashing doesn't use hw acceleration
ffprobe := ffmpeg.FFProbe(ffprobePath) ffprobe := ffmpeg.NewFFProbe(ffprobePath)
for _, item := range args { for _, item := range args {
if err := printPhash(encoder, ffprobe, item, quiet); err != nil { if err := printPhash(encoder, ffprobe, item, quiet); err != nil {

View File

@ -311,7 +311,7 @@ func (s *Manager) RefreshFFMpeg(ctx context.Context) {
logger.Debugf("using ffprobe: %s", ffprobePath) logger.Debugf("using ffprobe: %s", ffprobePath)
s.FFMpeg = ffmpeg.NewEncoder(ffmpegPath) s.FFMpeg = ffmpeg.NewEncoder(ffmpegPath)
s.FFProbe = ffmpeg.FFProbe(ffprobePath) s.FFProbe = ffmpeg.NewFFProbe(ffprobePath)
s.FFMpeg.InitHWSupport(ctx) s.FFMpeg.InitHWSupport(ctx)
} }

View File

@ -43,7 +43,7 @@ type Manager struct {
Paths *paths.Paths Paths *paths.Paths
FFMpeg *ffmpeg.FFMpeg FFMpeg *ffmpeg.FFMpeg
FFProbe ffmpeg.FFProbe FFProbe *ffmpeg.FFProbe
StreamManager *ffmpeg.StreamManager StreamManager *ffmpeg.StreamManager
JobManager *job.Manager JobManager *job.Manager
@ -300,7 +300,7 @@ func (s *Manager) Setup(ctx context.Context, input SetupInput) error {
} }
func (s *Manager) validateFFmpeg() error { func (s *Manager) validateFFmpeg() error {
if s.FFMpeg == nil || s.FFProbe == "" { if s.FFMpeg == nil || s.FFProbe == nil {
return errors.New("missing ffmpeg and/or ffprobe") return errors.New("missing ffmpeg and/or ffprobe")
} }
return nil return nil
@ -400,7 +400,7 @@ func (s *Manager) GetSystemStatus() *SystemStatus {
} }
ffprobePath := "" ffprobePath := ""
if s.FFProbe != "" { if s.FFProbe != nil {
ffprobePath = s.FFProbe.Path() ffprobePath = s.FFProbe.Path()
} }

View File

@ -277,15 +277,15 @@ func (f *FFMpeg) hwCodecFilter(args VideoFilter, codec VideoCodec, vf *models.Vi
func (f *FFMpeg) hwApplyFullHWFilter(args VideoFilter, codec VideoCodec, fullhw bool) VideoFilter { func (f *FFMpeg) hwApplyFullHWFilter(args VideoFilter, codec VideoCodec, fullhw bool) VideoFilter {
switch codec { switch codec {
case VideoCodecN264, VideoCodecN264H: case VideoCodecN264, VideoCodecN264H:
if fullhw && f.version.Gteq(FFMpegVersion{major: 5}) { // Added in FFMpeg 5 if fullhw && f.version.Gteq(Version{major: 5}) { // Added in FFMpeg 5
args = args.Append("scale_cuda=format=yuv420p") args = args.Append("scale_cuda=format=yuv420p")
} }
case VideoCodecV264, VideoCodecVVP9: case VideoCodecV264, VideoCodecVVP9:
if fullhw && f.version.Gteq(FFMpegVersion{major: 3, minor: 1}) { // Added in FFMpeg 3.1 if fullhw && f.version.Gteq(Version{major: 3, minor: 1}) { // Added in FFMpeg 3.1
args = args.Append("scale_vaapi=format=nv12") args = args.Append("scale_vaapi=format=nv12")
} }
case VideoCodecI264, VideoCodecI264C, VideoCodecIVP9: case VideoCodecI264, VideoCodecI264C, VideoCodecIVP9:
if fullhw && f.version.Gteq(FFMpegVersion{major: 3, minor: 3}) { // Added in FFMpeg 3.3 if fullhw && f.version.Gteq(Version{major: 3, minor: 3}) { // Added in FFMpeg 3.3
args = args.Append("scale_qsv=format=nv12") args = args.Append("scale_qsv=format=nv12")
} }
} }
@ -300,17 +300,17 @@ func (f *FFMpeg) hwApplyScaleTemplate(sargs string, codec VideoCodec, match []in
switch codec { switch codec {
case VideoCodecN264, VideoCodecN264H: case VideoCodecN264, VideoCodecN264H:
template = "scale_cuda=$value" template = "scale_cuda=$value"
if fullhw && f.version.Gteq(FFMpegVersion{major: 5}) { // Added in FFMpeg 5 if fullhw && f.version.Gteq(Version{major: 5}) { // Added in FFMpeg 5
template += ":format=yuv420p" template += ":format=yuv420p"
} }
case VideoCodecV264, VideoCodecVVP9: case VideoCodecV264, VideoCodecVVP9:
template = "scale_vaapi=$value" template = "scale_vaapi=$value"
if fullhw && f.version.Gteq(FFMpegVersion{major: 3, minor: 1}) { // Added in FFMpeg 3.1 if fullhw && f.version.Gteq(Version{major: 3, minor: 1}) { // Added in FFMpeg 3.1
template += ":format=nv12" template += ":format=nv12"
} }
case VideoCodecI264, VideoCodecI264C, VideoCodecIVP9: case VideoCodecI264, VideoCodecI264C, VideoCodecIVP9:
template = "scale_qsv=$value" template = "scale_qsv=$value"
if fullhw && f.version.Gteq(FFMpegVersion{major: 3, minor: 3}) { // Added in FFMpeg 3.3 if fullhw && f.version.Gteq(Version{major: 3, minor: 3}) { // Added in FFMpeg 3.3
template += ":format=nv12" template += ":format=nv12"
} }
case VideoCodecM264: case VideoCodecM264:

View File

@ -145,6 +145,8 @@ func ResolveFFMpeg(path string, fallbackPath string) string {
return ret return ret
} }
var version_re = regexp.MustCompile(`ffmpeg version n?((\d+)\.(\d+)(?:\.(\d+))?)`)
func (f *FFMpeg) getVersion() error { func (f *FFMpeg) getVersion() error {
var args Args var args Args
args = append(args, "-version") args = append(args, "-version")
@ -158,7 +160,6 @@ func (f *FFMpeg) getVersion() error {
return err return err
} }
version_re := regexp.MustCompile(`ffmpeg version n?((\d+)\.(\d+)(?:\.(\d+))?)`)
stdoutStr := stdout.String() stdoutStr := stdout.String()
match := version_re.FindStringSubmatchIndex(stdoutStr) match := version_re.FindStringSubmatchIndex(stdoutStr)
if match == nil { if match == nil {
@ -183,20 +184,20 @@ func (f *FFMpeg) getVersion() error {
if i, err := strconv.Atoi(patchS); err == nil { if i, err := strconv.Atoi(patchS); err == nil {
f.version.patch = i f.version.patch = i
} }
logger.Debugf("FFMpeg version %d.%d.%d detected", f.version.major, f.version.minor, f.version.patch) logger.Debugf("FFMpeg version %s detected", f.version.String())
return nil return nil
} }
// FFMpeg version params // FFMpeg version params
type FFMpegVersion struct { type Version struct {
major int major int
minor int minor int
patch int patch int
} }
// Gteq returns true if the version is greater than or equal to the other version. // Gteq returns true if the version is greater than or equal to the other version.
func (v FFMpegVersion) Gteq(other FFMpegVersion) bool { func (v Version) Gteq(other Version) bool {
if v.major > other.major { if v.major > other.major {
return true return true
} }
@ -209,10 +210,14 @@ func (v FFMpegVersion) Gteq(other FFMpegVersion) bool {
return false return false
} }
func (v Version) String() string {
return fmt.Sprintf("%d.%d.%d", v.major, v.minor, v.patch)
}
// FFMpeg provides an interface to ffmpeg. // FFMpeg provides an interface to ffmpeg.
type FFMpeg struct { type FFMpeg struct {
ffmpeg string ffmpeg string
version FFMpegVersion version Version
hwCodecSupport []VideoCodec hwCodecSupport []VideoCodec
} }

View File

@ -6,62 +6,62 @@ import "testing"
func TestFFMpegVersion_GreaterThan(t *testing.T) { func TestFFMpegVersion_GreaterThan(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
this FFMpegVersion this Version
other FFMpegVersion other Version
want bool want bool
}{ }{
{ {
"major greater, minor equal, patch equal", "major greater, minor equal, patch equal",
FFMpegVersion{2, 0, 0}, Version{2, 0, 0},
FFMpegVersion{1, 0, 0}, Version{1, 0, 0},
true, true,
}, },
{ {
"major greater, minor less, patch less", "major greater, minor less, patch less",
FFMpegVersion{2, 1, 1}, Version{2, 1, 1},
FFMpegVersion{1, 0, 0}, Version{1, 0, 0},
true, true,
}, },
{ {
"major equal, minor greater, patch equal", "major equal, minor greater, patch equal",
FFMpegVersion{1, 1, 0}, Version{1, 1, 0},
FFMpegVersion{1, 0, 0}, Version{1, 0, 0},
true, true,
}, },
{ {
"major equal, minor equal, patch greater", "major equal, minor equal, patch greater",
FFMpegVersion{1, 0, 1}, Version{1, 0, 1},
FFMpegVersion{1, 0, 0}, Version{1, 0, 0},
true, true,
}, },
{ {
"major equal, minor equal, patch equal", "major equal, minor equal, patch equal",
FFMpegVersion{1, 0, 0}, Version{1, 0, 0},
FFMpegVersion{1, 0, 0}, Version{1, 0, 0},
true, true,
}, },
{ {
"major less, minor equal, patch equal", "major less, minor equal, patch equal",
FFMpegVersion{1, 0, 0}, Version{1, 0, 0},
FFMpegVersion{2, 0, 0}, Version{2, 0, 0},
false, false,
}, },
{ {
"major equal, minor less, patch equal", "major equal, minor less, patch equal",
FFMpegVersion{1, 0, 0}, Version{1, 0, 0},
FFMpegVersion{1, 1, 0}, Version{1, 1, 0},
false, false,
}, },
{ {
"major equal, minor equal, patch less", "major equal, minor equal, patch less",
FFMpegVersion{1, 0, 0}, Version{1, 0, 0},
FFMpegVersion{1, 0, 1}, Version{1, 0, 1},
false, false,
}, },
{ {
"major less, minor less, patch less", "major less, minor less, patch less",
FFMpegVersion{1, 0, 0}, Version{1, 0, 0},
FFMpegVersion{2, 1, 1}, Version{2, 1, 1},
false, false,
}, },
} }

View File

@ -1,12 +1,14 @@
package ffmpeg package ffmpeg
import ( import (
"bytes"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"math" "math"
"os" "os"
"os/exec" "os/exec"
"regexp"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -16,6 +18,8 @@ import (
"github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/logger"
) )
const minimumFFProbeVersion = 5
func ValidateFFProbe(ffprobePath string) error { func ValidateFFProbe(ffprobePath string) error {
cmd := stashExec.Command(ffprobePath, "-h") cmd := stashExec.Command(ffprobePath, "-h")
bytes, err := cmd.CombinedOutput() bytes, err := cmd.CombinedOutput()
@ -139,16 +143,94 @@ func (v *VideoFile) TranscodeScale(maxSize int) (int, int) {
} }
// FFProbe provides an interface to the ffprobe executable. // FFProbe provides an interface to the ffprobe executable.
type FFProbe string type FFProbe struct {
path string
version Version
}
func (f *FFProbe) Path() string { func (f *FFProbe) Path() string {
return string(*f) return f.path
}
var ffprobeVersionRE = regexp.MustCompile(`ffprobe version n?((\d+)\.(\d+)(?:\.(\d+))?)`)
func (f *FFProbe) getVersion() error {
var args []string
args = append(args, "-version")
cmd := stashExec.Command(f.path, args...)
var stdout bytes.Buffer
cmd.Stdout = &stdout
var err error
if err = cmd.Run(); err != nil {
return err
}
stdoutStr := stdout.String()
match := ffprobeVersionRE.FindStringSubmatchIndex(stdoutStr)
if match == nil {
return errors.New("version string malformed")
}
majorS := stdoutStr[match[4]:match[5]]
minorS := stdoutStr[match[6]:match[7]]
// patch is optional
var patchS string
if match[8] != -1 && match[9] != -1 {
patchS = stdoutStr[match[8]:match[9]]
}
if i, err := strconv.Atoi(majorS); err == nil {
f.version.major = i
}
if i, err := strconv.Atoi(minorS); err == nil {
f.version.minor = i
}
if i, err := strconv.Atoi(patchS); err == nil {
f.version.patch = i
}
logger.Debugf("FFProbe version %s detected", f.version.String())
return nil
}
// Creates a new FFProbe instance.
func NewFFProbe(path string) *FFProbe {
ret := &FFProbe{
path: path,
}
if err := ret.getVersion(); err != nil {
logger.Warnf("FFProbe version not detected %v", err)
}
if ret.version.major != 0 && ret.version.major < minimumFFProbeVersion {
logger.Warnf("FFProbe version %d.%d.%d detected, but %d.x or later is required", ret.version.major, ret.version.minor, ret.version.patch, minimumFFProbeVersion)
}
return ret
} }
// NewVideoFile runs ffprobe on the given path and returns a VideoFile. // NewVideoFile runs ffprobe on the given path and returns a VideoFile.
func (f *FFProbe) NewVideoFile(videoPath string) (*VideoFile, error) { func (f *FFProbe) NewVideoFile(videoPath string) (*VideoFile, error) {
args := []string{"-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", "-show_error", videoPath} args := []string{
cmd := stashExec.Command(string(*f), args...) "-v",
"quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
"-show_error",
}
// show_entries stream_side_data=rotation requires 5.x or later ffprobe
if f.version.major >= 5 {
args = append(args, "-show_entries", "stream_side_data=rotation")
}
args = append(args, videoPath)
cmd := stashExec.Command(f.path, args...)
out, err := cmd.Output() out, err := cmd.Output()
if err != nil { if err != nil {
@ -167,7 +249,7 @@ func (f *FFProbe) NewVideoFile(videoPath string) (*VideoFile, error) {
// Used when the frame count is missing or incorrect. // Used when the frame count is missing or incorrect.
func (f *FFProbe) GetReadFrameCount(path string) (int64, error) { func (f *FFProbe) GetReadFrameCount(path string) (int64, error) {
args := []string{"-v", "quiet", "-print_format", "json", "-count_frames", "-show_format", "-show_streams", "-show_error", path} args := []string{"-v", "quiet", "-print_format", "json", "-count_frames", "-show_format", "-show_streams", "-show_error", path}
out, err := stashExec.Command(string(*f), args...).Output() out, err := stashExec.Command(f.path, args...).Output()
if err != nil { if err != nil {
return 0, fmt.Errorf("FFProbe encountered an error with <%s>.\nError JSON:\n%s\nError: %s", path, string(out), err.Error()) return 0, fmt.Errorf("FFProbe encountered an error with <%s>.\nError JSON:\n%s\nError: %s", path, string(out), err.Error())
@ -246,13 +328,14 @@ func parse(filePath string, probeJSON *FFProbeJSON) (*VideoFile, error) {
framerate = 0 framerate = 0
} }
result.FrameRate = math.Round(framerate*100) / 100 result.FrameRate = math.Round(framerate*100) / 100
if rotate, err := strconv.ParseInt(videoStream.Tags.Rotate, 10, 64); err == nil && rotate != 180 { result.Width = videoStream.Width
result.Height = videoStream.Height
if isRotated(videoStream) {
result.Width = videoStream.Height result.Width = videoStream.Height
result.Height = videoStream.Width result.Height = videoStream.Width
} else {
result.Width = videoStream.Width
result.Height = videoStream.Height
} }
result.VideoStreamDuration, err = strconv.ParseFloat(videoStream.Duration, 64) result.VideoStreamDuration, err = strconv.ParseFloat(videoStream.Duration, 64)
if err != nil { if err != nil {
// Revert to the historical behaviour, which is still correct in the vast majority of cases. // Revert to the historical behaviour, which is still correct in the vast majority of cases.
@ -263,6 +346,25 @@ func parse(filePath string, probeJSON *FFProbeJSON) (*VideoFile, error) {
return result, nil return result, nil
} }
func isRotated(s *FFProbeStream) bool {
rotate, _ := strconv.ParseInt(s.Tags.Rotate, 10, 64)
if rotate != 180 && rotate != 0 {
return true
}
for _, sd := range s.SideDataList {
r := sd.Rotation
if r < 0 {
r = -r
}
if r != 0 && r != 180 {
return true
}
}
return false
}
func (v *VideoFile) getAudioStream() *FFProbeStream { func (v *VideoFile) getAudioStream() *FFProbeStream {
index := v.getStreamIndex("audio", v.JSON) index := v.getStreamIndex("audio", v.JSON)
if index != -1 { if index != -1 {

View File

@ -23,7 +23,7 @@ const (
type StreamManager struct { type StreamManager struct {
cacheDir string cacheDir string
encoder *FFMpeg encoder *FFMpeg
ffprobe FFProbe ffprobe *FFProbe
config StreamManagerConfig config StreamManagerConfig
lockManager *fsutil.ReadLockManager lockManager *fsutil.ReadLockManager
@ -42,7 +42,7 @@ type StreamManagerConfig interface {
GetTranscodeHardwareAcceleration() bool GetTranscodeHardwareAcceleration() bool
} }
func NewStreamManager(cacheDir string, encoder *FFMpeg, ffprobe FFProbe, config StreamManagerConfig, lockManager *fsutil.ReadLockManager) *StreamManager { func NewStreamManager(cacheDir string, encoder *FFMpeg, ffprobe *FFProbe, config StreamManagerConfig, lockManager *fsutil.ReadLockManager) *StreamManager {
if cacheDir == "" { if cacheDir == "" {
logger.Warn("cache directory is not set. Live HLS/DASH transcoding will be disabled") logger.Warn("cache directory is not set. Live HLS/DASH transcoding will be disabled")
} }

View File

@ -94,4 +94,7 @@ type FFProbeStream struct {
MaxBitRate string `json:"max_bit_rate,omitempty"` MaxBitRate string `json:"max_bit_rate,omitempty"`
SampleFmt string `json:"sample_fmt,omitempty"` SampleFmt string `json:"sample_fmt,omitempty"`
SampleRate string `json:"sample_rate,omitempty"` SampleRate string `json:"sample_rate,omitempty"`
SideDataList []struct {
Rotation int `json:"rotation"`
} `json:"side_data_list"`
} }

View File

@ -19,7 +19,7 @@ import (
// Decorator adds image specific fields to a File. // Decorator adds image specific fields to a File.
type Decorator struct { type Decorator struct {
FFProbe ffmpeg.FFProbe FFProbe *ffmpeg.FFProbe
} }
func (d *Decorator) Decorate(ctx context.Context, fs models.FS, f models.File) (models.File, error) { func (d *Decorator) Decorate(ctx context.Context, fs models.FS, f models.File) (models.File, error) {

View File

@ -12,11 +12,11 @@ import (
// Decorator adds video specific fields to a File. // Decorator adds video specific fields to a File.
type Decorator struct { type Decorator struct {
FFProbe ffmpeg.FFProbe FFProbe *ffmpeg.FFProbe
} }
func (d *Decorator) Decorate(ctx context.Context, fs models.FS, f models.File) (models.File, error) { func (d *Decorator) Decorate(ctx context.Context, fs models.FS, f models.File) (models.File, error) {
if d.FFProbe == "" { if d.FFProbe == nil {
return f, errors.New("ffprobe not configured") return f, errors.New("ffprobe not configured")
} }

View File

@ -31,7 +31,7 @@ var (
type ThumbnailEncoder struct { type ThumbnailEncoder struct {
FFMpeg *ffmpeg.FFMpeg FFMpeg *ffmpeg.FFMpeg
FFProbe ffmpeg.FFProbe FFProbe *ffmpeg.FFProbe
ClipPreviewOptions ClipPreviewOptions ClipPreviewOptions ClipPreviewOptions
vips *vipsEncoder vips *vipsEncoder
} }
@ -49,7 +49,7 @@ func GetVipsPath() string {
return vipsPath return vipsPath
} }
func NewThumbnailEncoder(ffmpegEncoder *ffmpeg.FFMpeg, ffProbe ffmpeg.FFProbe, clipPreviewOptions ClipPreviewOptions) ThumbnailEncoder { func NewThumbnailEncoder(ffmpegEncoder *ffmpeg.FFMpeg, ffProbe *ffmpeg.FFProbe, clipPreviewOptions ClipPreviewOptions) ThumbnailEncoder {
ret := ThumbnailEncoder{ ret := ThumbnailEncoder{
FFMpeg: ffmpegEncoder, FFMpeg: ffmpegEncoder,
FFProbe: ffProbe, FFProbe: ffProbe,