stash/pkg/ffmpeg/ffprobe.go

231 lines
7.0 KiB
Go
Raw Normal View History

2019-02-09 12:30:49 +00:00
package ffmpeg
import (
"encoding/json"
"fmt"
"math"
"os"
"strconv"
"strings"
"time"
"github.com/stashapp/stash/pkg/exec"
"github.com/stashapp/stash/pkg/logger"
)
// VideoFile represents the ffprobe output for a video file.
2019-02-10 05:30:54 +00:00
type VideoFile struct {
JSON FFProbeJSON
2019-02-10 05:30:54 +00:00
AudioStream *FFProbeStream
VideoStream *FFProbeStream
2019-02-09 12:30:49 +00:00
Path string
Title string
Comment string
Container string
// FileDuration is the declared (meta-data) duration of the *file*.
// In most cases (sprites, previews, etc.) we actually care about the duration of the video stream specifically,
// because those two can differ slightly (e.g. audio stream longer than the video stream, making the whole file
// longer).
FileDuration float64
VideoStreamDuration float64
StartTime float64
Bitrate int64
Size int64
CreationTime time.Time
2019-02-09 12:30:49 +00:00
VideoCodec string
VideoBitrate int64
Width int
Height int
FrameRate float64
Rotation int64
FrameCount int64
2019-02-09 12:30:49 +00:00
AudioCodec string
}
// TranscodeScale calculates the dimension scaling for a transcode, where maxSize is the maximum size of the longest dimension of the input video.
// If no scaling is required, then returns 0, 0.
// Returns -2 for the dimension that will scale to maintain aspect ratio.
func (v *VideoFile) TranscodeScale(maxSize int) (int, int) {
// get the smaller dimension of the video file
videoSize := v.Height
if v.Width < videoSize {
videoSize = v.Width
}
// if our streaming resolution is larger than the video dimension
// or we are streaming the original resolution, then just set the
// input width
if maxSize >= videoSize || maxSize == 0 {
return 0, 0
}
// we're setting either the width or height
// we'll set the smaller dimesion
if v.Width > v.Height {
// set the height
return -2, maxSize
}
return maxSize, -2
}
// FFProbe provides an interface to the ffprobe executable.
type FFProbe string
// NewVideoFile runs ffprobe on the given path and returns a VideoFile.
func (f *FFProbe) NewVideoFile(videoPath string) (*VideoFile, error) {
2019-02-10 05:30:54 +00:00
args := []string{"-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", "-show_error", videoPath}
cmd := exec.Command(string(*f), args...)
out, err := cmd.Output()
2019-02-09 12:30:49 +00:00
if err != nil {
2019-02-10 05:30:54 +00:00
return nil, fmt.Errorf("FFProbe encountered an error with <%s>.\nError JSON:\n%s\nError: %s", videoPath, string(out), err.Error())
2019-02-09 12:30:49 +00:00
}
2019-02-10 05:30:54 +00:00
probeJSON := &FFProbeJSON{}
2019-02-09 12:30:49 +00:00
if err := json.Unmarshal(out, probeJSON); err != nil {
return nil, fmt.Errorf("error unmarshalling video data for <%s>: %s", videoPath, err.Error())
2019-02-09 12:30:49 +00:00
}
return parse(videoPath, probeJSON)
2019-02-09 12:30:49 +00:00
}
// GetReadFrameCount counts the actual frames of the video file.
// Used when the frame count is missing or incorrect.
func (f *FFProbe) GetReadFrameCount(path string) (int64, error) {
args := []string{"-v", "quiet", "-print_format", "json", "-count_frames", "-show_format", "-show_streams", "-show_error", 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", 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", path, err.Error())
}
fc, err := parse(path, probeJSON)
return fc.FrameCount, err
}
func parse(filePath string, probeJSON *FFProbeJSON) (*VideoFile, error) {
2019-02-10 05:30:54 +00:00
if probeJSON == nil {
return nil, fmt.Errorf("failed to get ffprobe json for <%s>", filePath)
2019-02-10 05:30:54 +00:00
}
result := &VideoFile{}
result.JSON = *probeJSON
if result.JSON.Error.Code != 0 {
return nil, fmt.Errorf("ffprobe error code %d: %s", result.JSON.Error.Code, result.JSON.Error.String)
}
2019-02-09 12:30:49 +00:00
result.Path = filePath
result.Title = probeJSON.Format.Tags.Title
result.Comment = probeJSON.Format.Tags.Comment
2019-02-10 05:30:54 +00:00
result.Bitrate, _ = strconv.ParseInt(probeJSON.Format.BitRate, 10, 64)
2019-02-10 05:30:54 +00:00
result.Container = probeJSON.Format.FormatName
duration, _ := strconv.ParseFloat(probeJSON.Format.Duration, 64)
result.FileDuration = math.Round(duration*100) / 100
fileStat, err := os.Stat(filePath)
if err != nil {
Errcheck phase 1 (#1715) * Avoid redundant logging in migrations Return the error and let the caller handle the logging of the error if needed. While here, defer m.Close() to the function boundary. * Treat errors as values Use %v rather than %s and pass the errors directly. * Generate a wrapped error on stat-failure * Log 3 unchecked errors Rather than ignore errors, log them at the WARNING log level. The server has been functioning without these, so assume they are not at the ERROR level. * Propagate errors upward Failure in path generation was ignored. Propagate the errors upward the call stack, so it can be handled at the level of orchestration. * Warn on errors Log errors rather than quenching them. Errors are logged at the Warn-level for now. * Check error when creating test databases Use the builtin log package and stop the program fatally on error. * Add warnings to uncheck task errors Focus on the task system in a single commit, logging unchecked errors as warnings. * Warn-on-error in API routes Look through the API routes, and make sure errors are being logged if they occur. Prefer the Warn-log-level because none of these has proven to be fatal in the system up until now. * Propagate error when adding Util API * Propagate error on adding util API * Return unhandled error * JS log API: propagate and log errors * JS Plugins: log GQL addition failures. * Warn on failure to write to stdin * Warn on failure to stop task * Wrap viper.BindEnv The current viper code only errors if no name is provided, so it should never fail. Rewrite the code flow to factor through a panic-function. This removes error warnings from this part of the code. * Log errors in concurrency test If we can't initialize the configuration, treat the test as a failure. * Warn on errors in configuration code * Plug an unchecked error in gallery zip walking * Warn on screenshot serving failure * Warn on encoder screenshot failure * Warn on errors in path-handling code * Undo the errcheck on configurations for now. * Use one-line initializers where applicable rather than using err := f() if err!= nil { .. prefer the shorter if err := f(); err != nil { .. If f() isn't too long of a name, or wraps a function with a body.
2021-09-20 23:34:25 +00:00
statErr := fmt.Errorf("error statting file <%s>: %w", filePath, err)
logger.Errorf("%v", statErr)
return nil, statErr
}
2019-02-09 12:30:49 +00:00
result.Size = fileStat.Size()
2019-02-10 05:30:54 +00:00
result.StartTime, _ = strconv.ParseFloat(probeJSON.Format.StartTime, 64)
result.CreationTime = probeJSON.Format.Tags.CreationTime.Time
2019-02-10 05:30:54 +00:00
audioStream := result.getAudioStream()
2019-02-10 05:30:54 +00:00
if audioStream != nil {
result.AudioCodec = audioStream.CodecName
result.AudioStream = audioStream
}
2019-02-09 12:30:49 +00:00
videoStream := result.getVideoStream()
2019-02-10 05:30:54 +00:00
if videoStream != nil {
result.VideoStream = videoStream
2019-02-09 12:30:49 +00:00
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)
}
}
2019-02-09 12:30:49 +00:00
result.VideoBitrate, _ = strconv.ParseInt(videoStream.BitRate, 10, 64)
var framerate float64
if strings.Contains(videoStream.AvgFrameRate, "/") {
frameRateSplit := strings.Split(videoStream.AvgFrameRate, "/")
numerator, _ := strconv.ParseFloat(frameRateSplit[0], 64)
denominator, _ := strconv.ParseFloat(frameRateSplit[1], 64)
framerate = numerator / denominator
} else {
framerate, _ = strconv.ParseFloat(videoStream.AvgFrameRate, 64)
}
File storage rewrite (#2676) * Restructure data layer part 2 (#2599) * Refactor and separate image model * Refactor image query builder * Handle relationships in image query builder * Remove relationship management methods * Refactor gallery model/query builder * Add scenes to gallery model * Convert scene model * Refactor scene models * Remove unused methods * Add unit tests for gallery * Add image tests * Add scene tests * Convert unnecessary scene value pointers to values * Convert unnecessary pointer values to values * Refactor scene partial * Add scene partial tests * Refactor ImagePartial * Add image partial tests * Refactor gallery partial update * Add partial gallery update tests * Use zero/null package for null values * Add files and scan system * Add sqlite implementation for files/folders * Add unit tests for files/folders * Image refactors * Update image data layer * Refactor gallery model and creation * Refactor scene model * Refactor scenes * Don't set title from filename * Allow galleries to freely add/remove images * Add multiple scene file support to graphql and UI * Add multiple file support for images in graphql/UI * Add multiple file for galleries in graphql/UI * Remove use of some deprecated fields * Remove scene path usage * Remove gallery path usage * Remove path from image * Move funscript to video file * Refactor caption detection * Migrate existing data * Add post commit/rollback hook system * Lint. Comment out import/export tests * Add WithDatabase read only wrapper * Prepend tasks to list * Add 32 pre-migration * Add warnings in release and migration notes
2022-07-13 06:30:54 +00:00
if math.IsNaN(framerate) {
framerate = 0
}
result.FrameRate = math.Round(framerate*100) / 100
2019-02-09 12:30:49 +00:00
if rotate, err := strconv.ParseInt(videoStream.Tags.Rotate, 10, 64); err == nil && rotate != 180 {
result.Width = videoStream.Height
result.Height = videoStream.Width
} else {
result.Width = videoStream.Width
result.Height = videoStream.Height
}
result.VideoStreamDuration, err = strconv.ParseFloat(videoStream.Duration, 64)
if err != nil {
// Revert to the historical behaviour, which is still correct in the vast majority of cases.
result.VideoStreamDuration = result.FileDuration
}
2019-02-09 12:30:49 +00:00
}
2019-02-10 05:30:54 +00:00
return result, nil
}
func (v *VideoFile) getAudioStream() *FFProbeStream {
2019-02-10 05:30:54 +00:00
index := v.getStreamIndex("audio", v.JSON)
if index != -1 {
return &v.JSON.Streams[index]
2019-02-09 12:30:49 +00:00
}
2019-02-10 05:30:54 +00:00
return nil
}
2019-02-09 12:30:49 +00:00
func (v *VideoFile) getVideoStream() *FFProbeStream {
2019-02-10 05:30:54 +00:00
index := v.getStreamIndex("video", v.JSON)
if index != -1 {
return &v.JSON.Streams[index]
}
return nil
2019-02-09 12:30:49 +00:00
}
func (v *VideoFile) getStreamIndex(fileType string, probeJSON FFProbeJSON) int {
ret := -1
for i, stream := range probeJSON.Streams {
// skip cover art/thumbnails
if stream.CodecType == fileType && stream.Disposition.AttachedPic == 0 {
// prefer default stream
if stream.Disposition.Default == 1 {
return i
}
// backwards compatible behaviour - fallback to first matching stream
if ret == -1 {
ret = i
}
2019-02-09 12:30:49 +00:00
}
}
return ret
}