mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 04:44:37 +03:00
Restructure ffmpeg (#2392)
* Refactor transcode generation * Move phash generation into separate package * Refactor image thumbnail generation * Move JSONTime to separate package * Ffmpeg refactoring * Refactor live transcoding * Refactor scene marker preview generation * Refactor preview generation * Refactor screenshot generation * Refactor sprite generation * Change ffmpeg.IsStreamable to return error * Move frame rate calculation into ffmpeg * Refactor file locking * Refactor title set during scan * Add missing lockmanager instance * Return error instead of logging in MatchContainer
This commit is contained in:
136
pkg/ffmpeg/browser.go
Normal file
136
pkg/ffmpeg/browser.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// only support H264 by default, since Safari does not support VP8/VP9
|
||||
var defaultSupportedCodecs = []string{H264, H265}
|
||||
|
||||
var validForH264Mkv = []Container{Mp4, Matroska}
|
||||
var validForH264 = []Container{Mp4}
|
||||
var validForH265Mkv = []Container{Mp4, Matroska}
|
||||
var validForH265 = []Container{Mp4}
|
||||
var validForVp8 = []Container{Webm}
|
||||
var validForVp9Mkv = []Container{Webm, Matroska}
|
||||
var validForVp9 = []Container{Webm}
|
||||
var validForHevcMkv = []Container{Mp4, Matroska}
|
||||
var validForHevc = []Container{Mp4}
|
||||
|
||||
var validAudioForMkv = []ProbeAudioCodec{Aac, Mp3, Vorbis, Opus}
|
||||
var validAudioForWebm = []ProbeAudioCodec{Vorbis, Opus}
|
||||
var validAudioForMp4 = []ProbeAudioCodec{Aac, Mp3}
|
||||
|
||||
var (
|
||||
// ErrUnsupportedVideoCodecForBrowser is returned when the video codec is not supported for browser streaming.
|
||||
ErrUnsupportedVideoCodecForBrowser = errors.New("unsupported video codec for browser")
|
||||
|
||||
// ErrUnsupportedVideoCodecContainer is returned when the video codec/container combination is not supported for browser streaming.
|
||||
ErrUnsupportedVideoCodecContainer = errors.New("video codec/container combination is unsupported for browser streaming")
|
||||
|
||||
// ErrUnsupportedAudioCodecContainer is returned when the audio codec/container combination is not supported for browser streaming.
|
||||
ErrUnsupportedAudioCodecContainer = errors.New("audio codec/container combination is unsupported for browser streaming")
|
||||
)
|
||||
|
||||
// IsStreamable returns nil if the file is streamable, or an error if it is not.
|
||||
func IsStreamable(videoCodec string, audioCodec ProbeAudioCodec, container Container) error {
|
||||
supportedVideoCodecs := defaultSupportedCodecs
|
||||
|
||||
// check if the video codec matches the supported codecs
|
||||
if !isValidCodec(videoCodec, supportedVideoCodecs) {
|
||||
return fmt.Errorf("%w: %s", ErrUnsupportedVideoCodecForBrowser, videoCodec)
|
||||
}
|
||||
|
||||
if !isValidCombo(videoCodec, container, supportedVideoCodecs) {
|
||||
return fmt.Errorf("%w: %s/%s", ErrUnsupportedVideoCodecContainer, videoCodec, container)
|
||||
}
|
||||
|
||||
if !IsValidAudioForContainer(audioCodec, container) {
|
||||
return fmt.Errorf("%w: %s/%s", ErrUnsupportedAudioCodecContainer, audioCodec, container)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isValidCodec(codecName string, supportedCodecs []string) bool {
|
||||
for _, c := range supportedCodecs {
|
||||
if c == codecName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isValidAudio(audio ProbeAudioCodec, validCodecs []ProbeAudioCodec) bool {
|
||||
// if audio codec is missing or unsupported by ffmpeg we can't do anything about it
|
||||
// report it as valid so that the file can at least be streamed directly if the video codec is supported
|
||||
if audio == MissingUnsupported {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, c := range validCodecs {
|
||||
if c == audio {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// IsValidAudioForContainer returns true if the audio codec is valid for the container.
|
||||
func IsValidAudioForContainer(audio ProbeAudioCodec, format Container) bool {
|
||||
switch format {
|
||||
case Matroska:
|
||||
return isValidAudio(audio, validAudioForMkv)
|
||||
case Webm:
|
||||
return isValidAudio(audio, validAudioForWebm)
|
||||
case Mp4:
|
||||
return isValidAudio(audio, validAudioForMp4)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isValidCombo checks if a codec/container combination is valid.
|
||||
// Returns true on validity, false otherwise
|
||||
func isValidCombo(codecName string, format Container, supportedVideoCodecs []string) bool {
|
||||
supportMKV := isValidCodec(Mkv, supportedVideoCodecs)
|
||||
supportHEVC := isValidCodec(Hevc, supportedVideoCodecs)
|
||||
|
||||
switch codecName {
|
||||
case H264:
|
||||
if supportMKV {
|
||||
return isValidForContainer(format, validForH264Mkv)
|
||||
}
|
||||
return isValidForContainer(format, validForH264)
|
||||
case H265:
|
||||
if supportMKV {
|
||||
return isValidForContainer(format, validForH265Mkv)
|
||||
}
|
||||
return isValidForContainer(format, validForH265)
|
||||
case Vp8:
|
||||
return isValidForContainer(format, validForVp8)
|
||||
case Vp9:
|
||||
if supportMKV {
|
||||
return isValidForContainer(format, validForVp9Mkv)
|
||||
}
|
||||
return isValidForContainer(format, validForVp9)
|
||||
case Hevc:
|
||||
if supportHEVC {
|
||||
if supportMKV {
|
||||
return isValidForContainer(format, validForHevcMkv)
|
||||
}
|
||||
return isValidForContainer(format, validForHevc)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isValidForContainer(format Container, validContainers []Container) bool {
|
||||
for _, fmt := range validContainers {
|
||||
if fmt == format {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
38
pkg/ffmpeg/codec.go
Normal file
38
pkg/ffmpeg/codec.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package ffmpeg
|
||||
|
||||
type VideoCodec string
|
||||
|
||||
func (c VideoCodec) Args() []string {
|
||||
if c == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []string{"-c:v", string(c)}
|
||||
}
|
||||
|
||||
var (
|
||||
VideoCodecLibX264 VideoCodec = "libx264"
|
||||
VideoCodecLibWebP VideoCodec = "libwebp"
|
||||
VideoCodecBMP VideoCodec = "bmp"
|
||||
VideoCodecMJpeg VideoCodec = "mjpeg"
|
||||
VideoCodecVP9 VideoCodec = "libvpx-vp9"
|
||||
VideoCodecVPX VideoCodec = "libvpx"
|
||||
VideoCodecLibX265 VideoCodec = "libx265"
|
||||
VideoCodecCopy VideoCodec = "copy"
|
||||
)
|
||||
|
||||
type AudioCodec string
|
||||
|
||||
func (c AudioCodec) Args() []string {
|
||||
if c == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []string{"-c:a", string(c)}
|
||||
}
|
||||
|
||||
var (
|
||||
AudioCodecAAC AudioCodec = "aac"
|
||||
AudioCodecLibOpus AudioCodec = "libopus"
|
||||
AudioCodecCopy AudioCodec = "copy"
|
||||
)
|
||||
59
pkg/ffmpeg/container.go
Normal file
59
pkg/ffmpeg/container.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package ffmpeg
|
||||
|
||||
type Container string
|
||||
type ProbeAudioCodec string
|
||||
|
||||
const (
|
||||
Mp4 Container = "mp4"
|
||||
M4v Container = "m4v"
|
||||
Mov Container = "mov"
|
||||
Wmv Container = "wmv"
|
||||
Webm Container = "webm"
|
||||
Matroska Container = "matroska"
|
||||
Avi Container = "avi"
|
||||
Flv Container = "flv"
|
||||
Mpegts Container = "mpegts"
|
||||
|
||||
Aac ProbeAudioCodec = "aac"
|
||||
Mp3 ProbeAudioCodec = "mp3"
|
||||
Opus ProbeAudioCodec = "opus"
|
||||
Vorbis ProbeAudioCodec = "vorbis"
|
||||
MissingUnsupported ProbeAudioCodec = ""
|
||||
|
||||
Mp4Ffmpeg string = "mov,mp4,m4a,3gp,3g2,mj2" // browsers support all of them
|
||||
M4vFfmpeg string = "mov,mp4,m4a,3gp,3g2,mj2" // so we don't care that ffmpeg
|
||||
MovFfmpeg string = "mov,mp4,m4a,3gp,3g2,mj2" // can't differentiate between them
|
||||
WmvFfmpeg string = "asf"
|
||||
WebmFfmpeg string = "matroska,webm"
|
||||
MatroskaFfmpeg string = "matroska,webm"
|
||||
AviFfmpeg string = "avi"
|
||||
FlvFfmpeg string = "flv"
|
||||
MpegtsFfmpeg string = "mpegts"
|
||||
H264 string = "h264"
|
||||
H265 string = "h265" // found in rare cases from a faulty encoder
|
||||
Hevc string = "hevc"
|
||||
Vp8 string = "vp8"
|
||||
Vp9 string = "vp9"
|
||||
Mkv string = "mkv" // only used from the browser to indicate mkv support
|
||||
Hls string = "hls" // only used from the browser to indicate hls support
|
||||
)
|
||||
|
||||
var ffprobeToContainer = map[string]Container{
|
||||
Mp4Ffmpeg: Mp4,
|
||||
WmvFfmpeg: Wmv,
|
||||
AviFfmpeg: Avi,
|
||||
FlvFfmpeg: Flv,
|
||||
MpegtsFfmpeg: Mpegts,
|
||||
MatroskaFfmpeg: Matroska,
|
||||
}
|
||||
|
||||
func MatchContainer(format string, filePath string) (Container, error) { // match ffprobe string to our Container
|
||||
container := ffprobeToContainer[format]
|
||||
if container == Matroska {
|
||||
return magicContainer(filePath) // use magic number instead of ffprobe for matroska,webm
|
||||
}
|
||||
if container == "" { // if format is not in our Container list leave it as ffprobes reported format_name
|
||||
container = Container(format)
|
||||
}
|
||||
return container, nil
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
stashExec "github.com/stashapp/stash/pkg/exec"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
)
|
||||
|
||||
type Encoder string
|
||||
|
||||
var (
|
||||
runningEncoders = make(map[string][]*os.Process)
|
||||
runningEncodersMutex = sync.RWMutex{}
|
||||
)
|
||||
|
||||
func registerRunningEncoder(path string, process *os.Process) {
|
||||
runningEncodersMutex.Lock()
|
||||
processes := runningEncoders[path]
|
||||
|
||||
runningEncoders[path] = append(processes, process)
|
||||
runningEncodersMutex.Unlock()
|
||||
}
|
||||
|
||||
func deregisterRunningEncoder(path string, process *os.Process) {
|
||||
runningEncodersMutex.Lock()
|
||||
defer runningEncodersMutex.Unlock()
|
||||
processes := runningEncoders[path]
|
||||
|
||||
for i, v := range processes {
|
||||
if v == process {
|
||||
runningEncoders[path] = append(processes[:i], processes[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitAndDeregister(path string, cmd *exec.Cmd) error {
|
||||
err := cmd.Wait()
|
||||
deregisterRunningEncoder(path, cmd.Process)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func KillRunningEncoders(path string) {
|
||||
runningEncodersMutex.RLock()
|
||||
processes := runningEncoders[path]
|
||||
runningEncodersMutex.RUnlock()
|
||||
|
||||
for _, process := range processes {
|
||||
// assume it worked, don't check for error
|
||||
logger.Infof("Killing encoder process for file: %s", path)
|
||||
if err := process.Kill(); err != nil {
|
||||
logger.Warnf("failed to kill process %v: %v", process.Pid, err)
|
||||
}
|
||||
|
||||
// wait for the process to die before returning
|
||||
// don't wait more than a few seconds
|
||||
done := make(chan error)
|
||||
go func() {
|
||||
_, err := process.Wait()
|
||||
done <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-time.After(5 * time.Second):
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FFmpeg runner with progress output, used for transcodes
|
||||
func (e *Encoder) runTranscode(probeResult VideoFile, args []string) (string, error) {
|
||||
cmd := stashExec.Command(string(*e), args...)
|
||||
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
logger.Error("FFMPEG stderr not available: " + err.Error())
|
||||
}
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if nil != err {
|
||||
logger.Error("FFMPEG stdout not available: " + err.Error())
|
||||
}
|
||||
|
||||
if err = cmd.Start(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
buf := make([]byte, 80)
|
||||
lastProgress := 0.0
|
||||
var errBuilder strings.Builder
|
||||
for {
|
||||
n, err := stderr.Read(buf)
|
||||
if n > 0 {
|
||||
data := string(buf[0:n])
|
||||
time := GetTimeFromRegex(data)
|
||||
if time > 0 && probeResult.Duration > 0 {
|
||||
progress := time / probeResult.Duration
|
||||
|
||||
if progress > lastProgress+0.01 {
|
||||
logger.Infof("Progress %.2f", progress)
|
||||
lastProgress = progress
|
||||
}
|
||||
}
|
||||
|
||||
errBuilder.WriteString(data)
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
stdoutData, _ := io.ReadAll(stdout)
|
||||
stdoutString := string(stdoutData)
|
||||
|
||||
registerRunningEncoder(probeResult.Path, cmd.Process)
|
||||
err = waitAndDeregister(probeResult.Path, cmd)
|
||||
|
||||
if err != nil {
|
||||
// error message should be in the stderr stream
|
||||
logger.Errorf("ffmpeg error when running command <%s>: %s", strings.Join(cmd.Args, " "), errBuilder.String())
|
||||
return stdoutString, err
|
||||
}
|
||||
|
||||
return stdoutString, nil
|
||||
}
|
||||
|
||||
func (e *Encoder) run(sourcePath string, args []string, stdin io.Reader) (string, error) {
|
||||
cmd := stashExec.Command(string(*e), args...)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
cmd.Stdin = stdin
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var err error
|
||||
if sourcePath != "" {
|
||||
registerRunningEncoder(sourcePath, cmd.Process)
|
||||
err = waitAndDeregister(sourcePath, cmd)
|
||||
} else {
|
||||
err = cmd.Wait()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// error message should be in the stderr stream
|
||||
logger.Errorf("ffmpeg error when running command <%s>: %s", strings.Join(cmd.Args, " "), stderr.String())
|
||||
return stdout.String(), err
|
||||
}
|
||||
|
||||
return stdout.String(), nil
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type SceneMarkerOptions struct {
|
||||
ScenePath string
|
||||
Seconds int
|
||||
Width int
|
||||
OutputPath string
|
||||
Audio bool
|
||||
}
|
||||
|
||||
func (e *Encoder) SceneMarkerVideo(probeResult VideoFile, options SceneMarkerOptions) error {
|
||||
|
||||
argsAudio := []string{
|
||||
"-c:a", "aac",
|
||||
"-b:a", "64k",
|
||||
}
|
||||
|
||||
if !options.Audio {
|
||||
argsAudio = []string{
|
||||
"-an",
|
||||
}
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-v", "error",
|
||||
"-ss", strconv.Itoa(options.Seconds),
|
||||
"-t", "20",
|
||||
"-i", probeResult.Path,
|
||||
"-max_muxing_queue_size", "1024", // https://trac.ffmpeg.org/ticket/6375
|
||||
"-c:v", "libx264",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-profile:v", "high",
|
||||
"-level", "4.2",
|
||||
"-preset", "veryslow",
|
||||
"-crf", "24",
|
||||
"-movflags", "+faststart",
|
||||
"-threads", "4",
|
||||
"-vf", fmt.Sprintf("scale=%v:-2", options.Width),
|
||||
"-sws_flags", "lanczos",
|
||||
"-strict", "-2",
|
||||
}
|
||||
args = append(args, argsAudio...)
|
||||
args = append(args, options.OutputPath)
|
||||
_, err := e.run(probeResult.Path, args, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *Encoder) SceneMarkerImage(probeResult VideoFile, options SceneMarkerOptions) error {
|
||||
args := []string{
|
||||
"-v", "error",
|
||||
"-ss", strconv.Itoa(options.Seconds),
|
||||
"-t", "5",
|
||||
"-i", probeResult.Path,
|
||||
"-c:v", "libwebp",
|
||||
"-lossless", "1",
|
||||
"-q:v", "70",
|
||||
"-compression_level", "6",
|
||||
"-preset", "default",
|
||||
"-loop", "0",
|
||||
"-threads", "4",
|
||||
"-vf", fmt.Sprintf("scale=%v:-2,fps=12", options.Width),
|
||||
"-an",
|
||||
options.OutputPath,
|
||||
}
|
||||
_, err := e.run(probeResult.Path, args, nil)
|
||||
return err
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ScenePreviewChunkOptions struct {
|
||||
StartTime float64
|
||||
Duration float64
|
||||
Width int
|
||||
OutputPath string
|
||||
Audio bool
|
||||
}
|
||||
|
||||
func (e *Encoder) ScenePreviewVideoChunk(probeResult VideoFile, options ScenePreviewChunkOptions, preset string, fallback bool) error {
|
||||
var fastSeek float64
|
||||
var slowSeek float64
|
||||
fallbackMinSlowSeek := 20.0
|
||||
|
||||
args := []string{
|
||||
"-v", "error",
|
||||
}
|
||||
|
||||
argsAudio := []string{
|
||||
"-c:a", "aac",
|
||||
"-b:a", "128k",
|
||||
}
|
||||
|
||||
if !options.Audio {
|
||||
argsAudio = []string{
|
||||
"-an",
|
||||
}
|
||||
}
|
||||
|
||||
// Non-fallback: enable xerror.
|
||||
// "-xerror" causes ffmpeg to fail on warnings, often the preview is fine but could be broken.
|
||||
if !fallback {
|
||||
args = append(args, "-xerror")
|
||||
fastSeek = options.StartTime
|
||||
slowSeek = 0
|
||||
} else {
|
||||
// In fallback mode, disable "-xerror" and try a combination of fast/slow seek instead of just fastseek
|
||||
// Commonly with avi/wmv ffmpeg doesn't seem to always predict the right start point to begin decoding when
|
||||
// using fast seek. If you force ffmpeg to decode more, it avoids the "blocky green artifact" issue.
|
||||
if options.StartTime > fallbackMinSlowSeek {
|
||||
// Handle seeks longer than fallbackMinSlowSeek with fast/slow seeks
|
||||
// Allow for at least fallbackMinSlowSeek seconds of slow seek
|
||||
fastSeek = options.StartTime - fallbackMinSlowSeek
|
||||
slowSeek = fallbackMinSlowSeek
|
||||
} else {
|
||||
// Handle seeks shorter than fallbackMinSlowSeek with only slow seeks.
|
||||
slowSeek = options.StartTime
|
||||
fastSeek = 0
|
||||
}
|
||||
}
|
||||
|
||||
if fastSeek > 0 {
|
||||
args = append(args, "-ss")
|
||||
args = append(args, strconv.FormatFloat(fastSeek, 'f', 2, 64))
|
||||
}
|
||||
|
||||
args = append(args, "-i")
|
||||
args = append(args, probeResult.Path)
|
||||
|
||||
if slowSeek > 0 {
|
||||
args = append(args, "-ss")
|
||||
args = append(args, strconv.FormatFloat(slowSeek, 'f', 2, 64))
|
||||
}
|
||||
|
||||
args2 := []string{
|
||||
"-t", strconv.FormatFloat(options.Duration, 'f', 2, 64),
|
||||
"-max_muxing_queue_size", "1024", // https://trac.ffmpeg.org/ticket/6375
|
||||
"-y",
|
||||
"-c:v", "libx264",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-profile:v", "high",
|
||||
"-level", "4.2",
|
||||
"-preset", preset,
|
||||
"-crf", "21",
|
||||
"-threads", "4",
|
||||
"-vf", fmt.Sprintf("scale=%v:-2", options.Width),
|
||||
"-strict", "-2",
|
||||
}
|
||||
|
||||
args = append(args, args2...)
|
||||
args = append(args, argsAudio...)
|
||||
args = append(args, options.OutputPath)
|
||||
|
||||
_, err := e.run(probeResult.Path, args, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// fixWindowsPath replaces \ with / in the given path because the \ isn't recognized as valid on windows ffmpeg
|
||||
func fixWindowsPath(str string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.ReplaceAll(str, `\`, "/")
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
func (e *Encoder) ScenePreviewVideoChunkCombine(probeResult VideoFile, concatFilePath string, outputPath string) error {
|
||||
args := []string{
|
||||
"-v", "error",
|
||||
"-f", "concat",
|
||||
"-i", fixWindowsPath(concatFilePath),
|
||||
"-y",
|
||||
"-c", "copy",
|
||||
outputPath,
|
||||
}
|
||||
_, err := e.run(probeResult.Path, args, nil)
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *Encoder) ScenePreviewVideoToImage(probeResult VideoFile, width int, videoPreviewPath string, outputPath string) error {
|
||||
args := []string{
|
||||
"-v", "error",
|
||||
"-i", videoPreviewPath,
|
||||
"-y",
|
||||
"-c:v", "libwebp",
|
||||
"-lossless", "1",
|
||||
"-q:v", "70",
|
||||
"-compression_level", "6",
|
||||
"-preset", "default",
|
||||
"-loop", "0",
|
||||
"-threads", "4",
|
||||
"-vf", fmt.Sprintf("scale=%v:-2,fps=12", width),
|
||||
"-an",
|
||||
outputPath,
|
||||
}
|
||||
_, err := e.run(probeResult.Path, args, nil)
|
||||
return err
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package ffmpeg
|
||||
|
||||
import "fmt"
|
||||
|
||||
type ScreenshotOptions struct {
|
||||
OutputPath string
|
||||
Quality int
|
||||
Time float64
|
||||
Width int
|
||||
Verbosity string
|
||||
}
|
||||
|
||||
func (e *Encoder) Screenshot(probeResult VideoFile, options ScreenshotOptions) error {
|
||||
if options.Verbosity == "" {
|
||||
options.Verbosity = "error"
|
||||
}
|
||||
if options.Quality == 0 {
|
||||
options.Quality = 1
|
||||
}
|
||||
args := []string{
|
||||
"-v", options.Verbosity,
|
||||
"-ss", fmt.Sprintf("%v", options.Time),
|
||||
"-y",
|
||||
"-i", probeResult.Path,
|
||||
"-vframes", "1",
|
||||
"-q:v", fmt.Sprintf("%v", options.Quality),
|
||||
"-vf", fmt.Sprintf("scale=%v:-1", options.Width),
|
||||
"-f", "image2",
|
||||
options.OutputPath,
|
||||
}
|
||||
_, err := e.run(probeResult.Path, args, nil)
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type SpriteScreenshotOptions struct {
|
||||
Time float64
|
||||
Frame int
|
||||
Width int
|
||||
}
|
||||
|
||||
func (e *Encoder) SpriteScreenshot(probeResult VideoFile, options SpriteScreenshotOptions) (image.Image, error) {
|
||||
args := []string{
|
||||
"-v", "error",
|
||||
"-ss", fmt.Sprintf("%v", options.Time),
|
||||
"-i", probeResult.Path,
|
||||
"-vframes", "1",
|
||||
"-vf", fmt.Sprintf("scale=%v:-1", options.Width),
|
||||
"-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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
type TranscodeOptions struct {
|
||||
OutputPath string
|
||||
MaxTranscodeSize models.StreamingResolutionEnum
|
||||
}
|
||||
|
||||
func calculateTranscodeScale(probeResult VideoFile, maxTranscodeSize models.StreamingResolutionEnum) string {
|
||||
maxSize := 0
|
||||
switch maxTranscodeSize {
|
||||
case models.StreamingResolutionEnumLow:
|
||||
maxSize = 240
|
||||
case models.StreamingResolutionEnumStandard:
|
||||
maxSize = 480
|
||||
case models.StreamingResolutionEnumStandardHd:
|
||||
maxSize = 720
|
||||
case models.StreamingResolutionEnumFullHd:
|
||||
maxSize = 1080
|
||||
case models.StreamingResolutionEnumFourK:
|
||||
maxSize = 2160
|
||||
}
|
||||
|
||||
// get the smaller dimension of the video file
|
||||
videoSize := probeResult.Height
|
||||
if probeResult.Width < videoSize {
|
||||
videoSize = probeResult.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 "iw:-2"
|
||||
}
|
||||
|
||||
// we're setting either the width or height
|
||||
// we'll set the smaller dimesion
|
||||
if probeResult.Width > probeResult.Height {
|
||||
// set the height
|
||||
return "-2:" + strconv.Itoa(maxSize)
|
||||
}
|
||||
|
||||
return strconv.Itoa(maxSize) + ":-2"
|
||||
}
|
||||
|
||||
func (e *Encoder) Transcode(probeResult VideoFile, options TranscodeOptions) {
|
||||
scale := calculateTranscodeScale(probeResult, options.MaxTranscodeSize)
|
||||
args := []string{
|
||||
"-i", probeResult.Path,
|
||||
"-c:v", "libx264",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-profile:v", "high",
|
||||
"-level", "4.2",
|
||||
"-preset", "superfast",
|
||||
"-crf", "23",
|
||||
"-vf", "scale=" + scale,
|
||||
"-c:a", "aac",
|
||||
"-strict", "-2",
|
||||
options.OutputPath,
|
||||
}
|
||||
_, _ = e.runTranscode(probeResult, args)
|
||||
}
|
||||
|
||||
// TranscodeVideo transcodes the video, and removes the audio.
|
||||
// In some videos where the audio codec is not supported by ffmpeg,
|
||||
// ffmpeg fails if you try to transcode the audio
|
||||
func (e *Encoder) TranscodeVideo(probeResult VideoFile, options TranscodeOptions) {
|
||||
scale := calculateTranscodeScale(probeResult, options.MaxTranscodeSize)
|
||||
args := []string{
|
||||
"-i", probeResult.Path,
|
||||
"-an",
|
||||
"-c:v", "libx264",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-profile:v", "high",
|
||||
"-level", "4.2",
|
||||
"-preset", "superfast",
|
||||
"-crf", "23",
|
||||
"-vf", "scale=" + scale,
|
||||
options.OutputPath,
|
||||
}
|
||||
_, _ = e.runTranscode(probeResult, args)
|
||||
}
|
||||
|
||||
// TranscodeAudio will copy the video stream as is, and transcode audio.
|
||||
func (e *Encoder) TranscodeAudio(probeResult VideoFile, options TranscodeOptions) {
|
||||
args := []string{
|
||||
"-i", probeResult.Path,
|
||||
"-c:v", "copy",
|
||||
"-c:a", "aac",
|
||||
"-strict", "-2",
|
||||
options.OutputPath,
|
||||
}
|
||||
_, _ = e.runTranscode(probeResult, args)
|
||||
}
|
||||
|
||||
// CopyVideo will copy the video stream as is, and drop the audio stream.
|
||||
func (e *Encoder) CopyVideo(probeResult VideoFile, options TranscodeOptions) {
|
||||
args := []string{
|
||||
"-i", probeResult.Path,
|
||||
"-an",
|
||||
"-c:v", "copy",
|
||||
options.OutputPath,
|
||||
}
|
||||
_, _ = e.runTranscode(probeResult, args)
|
||||
}
|
||||
17
pkg/ffmpeg/ffmpeg.go
Normal file
17
pkg/ffmpeg/ffmpeg.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Package ffmpeg provides a wrapper around the ffmpeg and ffprobe executables.
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
|
||||
stashExec "github.com/stashapp/stash/pkg/exec"
|
||||
)
|
||||
|
||||
// FFMpeg provides an interface to ffmpeg.
|
||||
type FFMpeg string
|
||||
|
||||
// Returns an exec.Cmd that can be used to run ffmpeg using args.
|
||||
func (f *FFMpeg) Command(ctx context.Context, args []string) *exec.Cmd {
|
||||
return stashExec.CommandContext(ctx, string(*f), args...)
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -14,188 +13,7 @@ import (
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
)
|
||||
|
||||
type Container string
|
||||
type AudioCodec string
|
||||
|
||||
const (
|
||||
Mp4 Container = "mp4"
|
||||
M4v Container = "m4v"
|
||||
Mov Container = "mov"
|
||||
Wmv Container = "wmv"
|
||||
Webm Container = "webm"
|
||||
Matroska Container = "matroska"
|
||||
Avi Container = "avi"
|
||||
Flv Container = "flv"
|
||||
Mpegts Container = "mpegts"
|
||||
Aac AudioCodec = "aac"
|
||||
Mp3 AudioCodec = "mp3"
|
||||
Opus AudioCodec = "opus"
|
||||
Vorbis AudioCodec = "vorbis"
|
||||
MissingUnsupported AudioCodec = ""
|
||||
Mp4Ffmpeg string = "mov,mp4,m4a,3gp,3g2,mj2" // browsers support all of them
|
||||
M4vFfmpeg string = "mov,mp4,m4a,3gp,3g2,mj2" // so we don't care that ffmpeg
|
||||
MovFfmpeg string = "mov,mp4,m4a,3gp,3g2,mj2" // can't differentiate between them
|
||||
WmvFfmpeg string = "asf"
|
||||
WebmFfmpeg string = "matroska,webm"
|
||||
MatroskaFfmpeg string = "matroska,webm"
|
||||
AviFfmpeg string = "avi"
|
||||
FlvFfmpeg string = "flv"
|
||||
MpegtsFfmpeg string = "mpegts"
|
||||
H264 string = "h264"
|
||||
H265 string = "h265" // found in rare cases from a faulty encoder
|
||||
Hevc string = "hevc"
|
||||
Vp8 string = "vp8"
|
||||
Vp9 string = "vp9"
|
||||
Mkv string = "mkv" // only used from the browser to indicate mkv support
|
||||
Hls string = "hls" // only used from the browser to indicate hls support
|
||||
MimeWebm string = "video/webm"
|
||||
MimeMkv string = "video/x-matroska"
|
||||
MimeMp4 string = "video/mp4"
|
||||
MimeHLS string = "application/vnd.apple.mpegurl"
|
||||
MimeMpegts string = "video/MP2T"
|
||||
)
|
||||
|
||||
// only support H264 by default, since Safari does not support VP8/VP9
|
||||
var DefaultSupportedCodecs = []string{H264, H265}
|
||||
|
||||
var validForH264Mkv = []Container{Mp4, Matroska}
|
||||
var validForH264 = []Container{Mp4}
|
||||
var validForH265Mkv = []Container{Mp4, Matroska}
|
||||
var validForH265 = []Container{Mp4}
|
||||
var validForVp8 = []Container{Webm}
|
||||
var validForVp9Mkv = []Container{Webm, Matroska}
|
||||
var validForVp9 = []Container{Webm}
|
||||
var validForHevcMkv = []Container{Mp4, Matroska}
|
||||
var validForHevc = []Container{Mp4}
|
||||
|
||||
var validAudioForMkv = []AudioCodec{Aac, Mp3, Vorbis, Opus}
|
||||
var validAudioForWebm = []AudioCodec{Vorbis, Opus}
|
||||
var validAudioForMp4 = []AudioCodec{Aac, Mp3}
|
||||
|
||||
// ContainerToFfprobe maps user readable container strings to ffprobe's format_name.
|
||||
// On some formats ffprobe can't differentiate
|
||||
var ContainerToFfprobe = map[Container]string{
|
||||
Mp4: Mp4Ffmpeg,
|
||||
M4v: M4vFfmpeg,
|
||||
Mov: MovFfmpeg,
|
||||
Wmv: WmvFfmpeg,
|
||||
Webm: WebmFfmpeg,
|
||||
Matroska: MatroskaFfmpeg,
|
||||
Avi: AviFfmpeg,
|
||||
Flv: FlvFfmpeg,
|
||||
Mpegts: MpegtsFfmpeg,
|
||||
}
|
||||
|
||||
var FfprobeToContainer = map[string]Container{
|
||||
Mp4Ffmpeg: Mp4,
|
||||
WmvFfmpeg: Wmv,
|
||||
AviFfmpeg: Avi,
|
||||
FlvFfmpeg: Flv,
|
||||
MpegtsFfmpeg: Mpegts,
|
||||
MatroskaFfmpeg: Matroska,
|
||||
}
|
||||
|
||||
func MatchContainer(format string, filePath string) Container { // match ffprobe string to our Container
|
||||
|
||||
container := FfprobeToContainer[format]
|
||||
if container == Matroska {
|
||||
container = magicContainer(filePath) // use magic number instead of ffprobe for matroska,webm
|
||||
}
|
||||
if container == "" { // if format is not in our Container list leave it as ffprobes reported format_name
|
||||
container = Container(format)
|
||||
}
|
||||
return container
|
||||
}
|
||||
|
||||
func isValidCodec(codecName string, supportedCodecs []string) bool {
|
||||
for _, c := range supportedCodecs {
|
||||
if c == codecName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isValidAudio(audio AudioCodec, validCodecs []AudioCodec) bool {
|
||||
// if audio codec is missing or unsupported by ffmpeg we can't do anything about it
|
||||
// report it as valid so that the file can at least be streamed directly if the video codec is supported
|
||||
if audio == MissingUnsupported {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, c := range validCodecs {
|
||||
if c == audio {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func IsValidAudioForContainer(audio AudioCodec, format Container) bool {
|
||||
switch format {
|
||||
case Matroska:
|
||||
return isValidAudio(audio, validAudioForMkv)
|
||||
case Webm:
|
||||
return isValidAudio(audio, validAudioForWebm)
|
||||
case Mp4:
|
||||
return isValidAudio(audio, validAudioForMp4)
|
||||
}
|
||||
return false
|
||||
|
||||
}
|
||||
|
||||
func isValidForContainer(format Container, validContainers []Container) bool {
|
||||
for _, fmt := range validContainers {
|
||||
if fmt == format {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isValidCombo checks if a codec/container combination is valid.
|
||||
// Returns true on validity, false otherwise
|
||||
func isValidCombo(codecName string, format Container, supportedVideoCodecs []string) bool {
|
||||
supportMKV := isValidCodec(Mkv, supportedVideoCodecs)
|
||||
supportHEVC := isValidCodec(Hevc, supportedVideoCodecs)
|
||||
|
||||
switch codecName {
|
||||
case H264:
|
||||
if supportMKV {
|
||||
return isValidForContainer(format, validForH264Mkv)
|
||||
}
|
||||
return isValidForContainer(format, validForH264)
|
||||
case H265:
|
||||
if supportMKV {
|
||||
return isValidForContainer(format, validForH265Mkv)
|
||||
}
|
||||
return isValidForContainer(format, validForH265)
|
||||
case Vp8:
|
||||
return isValidForContainer(format, validForVp8)
|
||||
case Vp9:
|
||||
if supportMKV {
|
||||
return isValidForContainer(format, validForVp9Mkv)
|
||||
}
|
||||
return isValidForContainer(format, validForVp9)
|
||||
case Hevc:
|
||||
if supportHEVC {
|
||||
if supportMKV {
|
||||
return isValidForContainer(format, validForHevcMkv)
|
||||
}
|
||||
return isValidForContainer(format, validForHevc)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func IsStreamable(videoCodec string, audioCodec AudioCodec, container Container) bool {
|
||||
supportedVideoCodecs := DefaultSupportedCodecs
|
||||
|
||||
// check if the video codec matches the supported codecs
|
||||
return isValidCodec(videoCodec, supportedVideoCodecs) && isValidCombo(videoCodec, container, supportedVideoCodecs) && IsValidAudioForContainer(audioCodec, container)
|
||||
}
|
||||
|
||||
// VideoFile represents the ffprobe output for a video file.
|
||||
type VideoFile struct {
|
||||
JSON FFProbeJSON
|
||||
AudioStream *FFProbeStream
|
||||
@@ -222,11 +40,38 @@ type VideoFile struct {
|
||||
AudioCodec string
|
||||
}
|
||||
|
||||
// FFProbe
|
||||
// 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
|
||||
|
||||
// Execute exec command and bind result to struct.
|
||||
func (f *FFProbe) NewVideoFile(videoPath string, stripExt bool) (*VideoFile, error) {
|
||||
// NewVideoFile runs ffprobe on the given path and returns a VideoFile.
|
||||
func (f *FFProbe) NewVideoFile(videoPath string) (*VideoFile, error) {
|
||||
args := []string{"-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", "-show_error", videoPath}
|
||||
cmd := exec.Command(string(*f), args...)
|
||||
out, err := cmd.Output()
|
||||
@@ -240,28 +85,29 @@ func (f *FFProbe) NewVideoFile(videoPath string, stripExt bool) (*VideoFile, err
|
||||
return nil, fmt.Errorf("error unmarshalling video data for <%s>: %s", videoPath, err.Error())
|
||||
}
|
||||
|
||||
return parse(videoPath, probeJSON, stripExt)
|
||||
return parse(videoPath, probeJSON)
|
||||
}
|
||||
|
||||
// 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}
|
||||
// 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", vf.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())
|
||||
}
|
||||
|
||||
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())
|
||||
return 0, fmt.Errorf("error unmarshalling video data for <%s>: %s", path, err.Error())
|
||||
}
|
||||
|
||||
fc, err := parse(vf.Path, probeJSON, false)
|
||||
fc, err := parse(path, probeJSON)
|
||||
return fc.FrameCount, err
|
||||
}
|
||||
|
||||
func parse(filePath string, probeJSON *FFProbeJSON, stripExt bool) (*VideoFile, error) {
|
||||
func parse(filePath string, probeJSON *FFProbeJSON) (*VideoFile, error) {
|
||||
if probeJSON == nil {
|
||||
return nil, fmt.Errorf("failed to get ffprobe json for <%s>", filePath)
|
||||
}
|
||||
@@ -276,11 +122,6 @@ func parse(filePath string, probeJSON *FFProbeJSON, stripExt bool) (*VideoFile,
|
||||
result.Path = filePath
|
||||
result.Title = probeJSON.Format.Tags.Title
|
||||
|
||||
if result.Title == "" {
|
||||
// default title to filename
|
||||
result.SetTitleFromPath(stripExt)
|
||||
}
|
||||
|
||||
result.Comment = probeJSON.Format.Tags.Comment
|
||||
result.Bitrate, _ = strconv.ParseInt(probeJSON.Format.BitRate, 10, 64)
|
||||
|
||||
@@ -364,11 +205,3 @@ func (v *VideoFile) getStreamIndex(fileType string, probeJSON FFProbeJSON) int {
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
func (v *VideoFile) SetTitleFromPath(stripExtension bool) {
|
||||
v.Title = filepath.Base(v.Path)
|
||||
if stripExtension {
|
||||
ext := filepath.Ext(v.Title)
|
||||
v.Title = strings.TrimSuffix(v.Title, ext)
|
||||
}
|
||||
}
|
||||
|
||||
78
pkg/ffmpeg/filter.go
Normal file
78
pkg/ffmpeg/filter.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package ffmpeg
|
||||
|
||||
import "fmt"
|
||||
|
||||
// VideoFilter represents video filter parameters to be passed to ffmpeg.
|
||||
type VideoFilter string
|
||||
|
||||
// Args converts the video filter parameters to a slice of arguments to be passed to ffmpeg.
|
||||
// Returns an empty slice if the filter is empty.
|
||||
func (f VideoFilter) Args() []string {
|
||||
if f == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []string{"-vf", string(f)}
|
||||
}
|
||||
|
||||
// ScaleWidth returns a VideoFilter scaling the width to the given width, maintaining aspect ratio and a height as a multiple of 2.
|
||||
func (f VideoFilter) ScaleWidth(w int) VideoFilter {
|
||||
return f.ScaleDimensions(w, -2)
|
||||
}
|
||||
|
||||
func (f VideoFilter) ScaleHeight(h int) VideoFilter {
|
||||
return f.ScaleDimensions(-2, h)
|
||||
}
|
||||
|
||||
// ScaleDimesions returns a VideoFilter scaling using w and h. Use -n to maintain aspect ratio and maintain as multiple of n.
|
||||
func (f VideoFilter) ScaleDimensions(w, h int) VideoFilter {
|
||||
return f.Append(fmt.Sprintf("scale=%v:%v", w, h))
|
||||
}
|
||||
|
||||
// ScaleMaxSize returns a VideoFilter scaling to maxDimensions, maintaining aspect ratio using force_original_aspect_ratio=decrease.
|
||||
func (f VideoFilter) ScaleMaxSize(maxDimensions int) VideoFilter {
|
||||
return f.Append(fmt.Sprintf("scale=%v:%v:force_original_aspect_ratio=decrease", maxDimensions, maxDimensions))
|
||||
}
|
||||
|
||||
// ScaleMax returns a VideoFilter scaling to maxSize. It will scale width if it is larger than height, otherwise it will scale height.
|
||||
func (f VideoFilter) ScaleMax(inputWidth, inputHeight, maxSize int) VideoFilter {
|
||||
// get the smaller dimension of the input
|
||||
videoSize := inputHeight
|
||||
if inputWidth < videoSize {
|
||||
videoSize = inputWidth
|
||||
}
|
||||
|
||||
// if maxSize is larger than the video dimension, then no-op
|
||||
if maxSize >= videoSize || maxSize == 0 {
|
||||
return f
|
||||
}
|
||||
|
||||
// we're setting either the width or height
|
||||
// we'll set the smaller dimesion
|
||||
if inputWidth > inputHeight {
|
||||
// set the height
|
||||
return f.ScaleDimensions(-2, maxSize)
|
||||
}
|
||||
|
||||
return f.ScaleDimensions(maxSize, -2)
|
||||
}
|
||||
|
||||
// Fps returns a VideoFilter setting the frames per second.
|
||||
func (f VideoFilter) Fps(fps int) VideoFilter {
|
||||
return f.Append(fmt.Sprintf("fps=%v", fps))
|
||||
}
|
||||
|
||||
// Select returns a VideoFilter to select the given frame.
|
||||
func (f VideoFilter) Select(frame int) VideoFilter {
|
||||
return f.Append(fmt.Sprintf("select=eq(n\\,%d)", frame))
|
||||
}
|
||||
|
||||
// Append returns a VideoFilter appending the given string.
|
||||
func (f VideoFilter) Append(s string) VideoFilter {
|
||||
// if filter is empty, then just set
|
||||
if f == "" {
|
||||
return VideoFilter(s)
|
||||
}
|
||||
|
||||
return VideoFilter(fmt.Sprintf("%s,%s", f, s))
|
||||
}
|
||||
43
pkg/ffmpeg/format.go
Normal file
43
pkg/ffmpeg/format.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package ffmpeg
|
||||
|
||||
// Format represents the input/output format for ffmpeg.
|
||||
type Format string
|
||||
|
||||
// Args converts the Format to a slice of arguments to be passed to ffmpeg.
|
||||
func (f Format) Args() []string {
|
||||
if f == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []string{"-f", string(f)}
|
||||
}
|
||||
|
||||
var (
|
||||
FormatConcat Format = "concat"
|
||||
FormatImage2 Format = "image2"
|
||||
FormatRawVideo Format = "rawvideo"
|
||||
FormatMpegTS Format = "mpegts"
|
||||
FormatMP4 Format = "mp4"
|
||||
FormatWebm Format = "webm"
|
||||
FormatMatroska Format = "matroska"
|
||||
)
|
||||
|
||||
// ImageFormat represents the input format for an image for ffmpeg.
|
||||
type ImageFormat string
|
||||
|
||||
// Args converts the ImageFormat to a slice of arguments to be passed to ffmpeg.
|
||||
func (f ImageFormat) Args() []string {
|
||||
if f == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []string{"-f", string(f)}
|
||||
}
|
||||
|
||||
var (
|
||||
ImageFormatJpeg ImageFormat = "mjpeg"
|
||||
ImageFormatPng ImageFormat = "png_pipe"
|
||||
ImageFormatWebp ImageFormat = "webp_pipe"
|
||||
|
||||
ImageFormatImage2Pipe ImageFormat = "image2pipe"
|
||||
)
|
||||
76
pkg/ffmpeg/frame_rate.go
Normal file
76
pkg/ffmpeg/frame_rate.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// FrameInfo contains the number of frames and the frame rate for a video file.
|
||||
type FrameInfo struct {
|
||||
FrameRate float64
|
||||
NumberOfFrames int
|
||||
}
|
||||
|
||||
// CalculateFrameRate calculates the frame rate and number of frames of the video file.
|
||||
// Used where the frame rate or NbFrames is missing or invalid in the ffprobe output.
|
||||
func (f FFMpeg) CalculateFrameRate(ctx context.Context, v *VideoFile) (*FrameInfo, error) {
|
||||
var args Args
|
||||
args = append(args, "-nostats")
|
||||
args = args.Input(v.Path).
|
||||
VideoCodec(VideoCodecCopy).
|
||||
Format(FormatRawVideo).
|
||||
Overwrite().
|
||||
NullOutput()
|
||||
|
||||
command := f.Command(ctx, args)
|
||||
var stdErrBuffer bytes.Buffer
|
||||
command.Stderr = &stdErrBuffer // Frames go to stderr rather than stdout
|
||||
err := command.Run()
|
||||
if err == nil {
|
||||
var ret FrameInfo
|
||||
stdErrString := stdErrBuffer.String()
|
||||
ret.NumberOfFrames = getFrameFromRegex(stdErrString)
|
||||
|
||||
time := getTimeFromRegex(stdErrString)
|
||||
ret.FrameRate = math.Round((float64(ret.NumberOfFrames)/time)*100) / 100
|
||||
|
||||
return &ret, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var timeRegex = regexp.MustCompile(`time=\s*(\d+):(\d+):(\d+.\d+)`)
|
||||
var frameRegex = regexp.MustCompile(`frame=\s*([0-9]+)`)
|
||||
|
||||
func getTimeFromRegex(str string) float64 {
|
||||
regexResult := timeRegex.FindStringSubmatch(str)
|
||||
|
||||
// Bail early if we don't have the results we expect
|
||||
if len(regexResult) != 4 {
|
||||
return 0
|
||||
}
|
||||
|
||||
h, _ := strconv.ParseFloat(regexResult[1], 64)
|
||||
m, _ := strconv.ParseFloat(regexResult[2], 64)
|
||||
s, _ := strconv.ParseFloat(regexResult[3], 64)
|
||||
hours := h * 3600
|
||||
minutes := m * 60
|
||||
seconds := s
|
||||
return hours + minutes + seconds
|
||||
}
|
||||
|
||||
func getFrameFromRegex(str string) int {
|
||||
regexResult := frameRegex.FindStringSubmatch(str)
|
||||
|
||||
// Bail early if we don't have the results we expect
|
||||
if len(regexResult) < 2 {
|
||||
return 0
|
||||
}
|
||||
|
||||
result, _ := strconv.Atoi(regexResult[1])
|
||||
return result
|
||||
}
|
||||
42
pkg/ffmpeg/generate.go
Normal file
42
pkg/ffmpeg/generate.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Generate runs ffmpeg with the given args and waits for it to finish.
|
||||
// Returns an error if the command fails. If the command fails, the return
|
||||
// value will be of type *exec.ExitError.
|
||||
func (f FFMpeg) Generate(ctx context.Context, args Args) error {
|
||||
cmd := f.Command(ctx, args)
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("error starting command: %w", err)
|
||||
}
|
||||
|
||||
if err := cmd.Wait(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
exitErr.Stderr = stderr.Bytes()
|
||||
err = exitErr
|
||||
}
|
||||
return fmt.Errorf("error running ffmpeg command <%s>: %w", strings.Join(args, " "), err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateOutput runs ffmpeg with the given args and returns it standard output.
|
||||
func (f FFMpeg) GenerateOutput(ctx context.Context, args []string) ([]byte, error) {
|
||||
cmd := f.Command(ctx, args)
|
||||
|
||||
return cmd.Output()
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import (
|
||||
|
||||
const hlsSegmentLength = 10.0
|
||||
|
||||
func WriteHLSPlaylist(probeResult VideoFile, baseUrl string, w io.Writer) {
|
||||
// WriteHLSPlaylist writes a HLS playlist to w using baseUrl as the base URL for TS streams.
|
||||
func WriteHLSPlaylist(duration float64, baseUrl string, w io.Writer) {
|
||||
fmt.Fprint(w, "#EXTM3U\n")
|
||||
fmt.Fprint(w, "#EXT-X-VERSION:3\n")
|
||||
fmt.Fprint(w, "#EXT-X-MEDIA-SEQUENCE:0\n")
|
||||
@@ -16,8 +17,6 @@ func WriteHLSPlaylist(probeResult VideoFile, baseUrl string, w io.Writer) {
|
||||
fmt.Fprintf(w, "#EXT-X-TARGETDURATION:%d\n", int(hlsSegmentLength))
|
||||
fmt.Fprint(w, "#EXT-X-PLAYLIST-TYPE:VOD\n")
|
||||
|
||||
duration := probeResult.Duration
|
||||
|
||||
leftover := duration
|
||||
upTo := 0.0
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func (e *Encoder) ImageThumbnail(image *bytes.Buffer, format string, maxDimensions int, path string) ([]byte, error) {
|
||||
// ffmpeg spends a long sniffing image format when data is piped through stdio, so we pass the format explicitly instead
|
||||
var ffmpegformat string
|
||||
|
||||
switch format {
|
||||
case "jpeg":
|
||||
ffmpegformat = "mjpeg"
|
||||
case "png":
|
||||
ffmpegformat = "png_pipe"
|
||||
case "webp":
|
||||
ffmpegformat = "webp_pipe"
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"-f", ffmpegformat,
|
||||
"-i", "-",
|
||||
"-vf", fmt.Sprintf("scale=%v:%v:force_original_aspect_ratio=decrease", maxDimensions, maxDimensions),
|
||||
"-c:v", "mjpeg",
|
||||
"-q:v", "5",
|
||||
"-f", "image2pipe",
|
||||
"-",
|
||||
}
|
||||
|
||||
data, err := e.run(path, args, image)
|
||||
|
||||
return []byte(data), err
|
||||
}
|
||||
@@ -3,8 +3,6 @@ package ffmpeg
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
)
|
||||
|
||||
// detect file format from magic file number
|
||||
@@ -42,11 +40,10 @@ func containsMatroskaSignature(buf, subType []byte) bool {
|
||||
// Returns the zero-value on errors or no-match. Implements mkv or
|
||||
// webm only, as ffprobe can't distinguish between them and not all
|
||||
// browsers support mkv
|
||||
func magicContainer(filePath string) Container {
|
||||
func magicContainer(filePath string) (Container, error) {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
logger.Errorf("[magicfile] %v", err)
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
|
||||
defer file.Close()
|
||||
@@ -54,15 +51,14 @@ func magicContainer(filePath string) Container {
|
||||
buf := make([]byte, 4096)
|
||||
_, err = file.Read(buf)
|
||||
if err != nil {
|
||||
logger.Errorf("[magicfile] %v", err)
|
||||
return ""
|
||||
return "", err
|
||||
}
|
||||
|
||||
if webm(buf) {
|
||||
return Webm
|
||||
return Webm, nil
|
||||
}
|
||||
if mkv(buf) {
|
||||
return Matroska
|
||||
return Matroska, nil
|
||||
}
|
||||
return ""
|
||||
return "", nil
|
||||
}
|
||||
|
||||
178
pkg/ffmpeg/options.go
Normal file
178
pkg/ffmpeg/options.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// Arger is an interface that can be used to append arguments to an Args slice.
|
||||
type Arger interface {
|
||||
Args() []string
|
||||
}
|
||||
|
||||
// Args represents a slice of arguments to be passed to ffmpeg.
|
||||
type Args []string
|
||||
|
||||
// LogLevel sets the LogLevel to l and returns the result.
|
||||
func (a Args) LogLevel(l LogLevel) Args {
|
||||
if l == "" {
|
||||
return a
|
||||
}
|
||||
|
||||
return append(a, l.Args()...)
|
||||
}
|
||||
|
||||
// XError adds the -xerror flag and returns the result.
|
||||
func (a Args) XError() Args {
|
||||
return append(a, "-xerror")
|
||||
}
|
||||
|
||||
// Overwrite adds the overwrite flag (-y) and returns the result.
|
||||
func (a Args) Overwrite() Args {
|
||||
return append(a, "-y")
|
||||
}
|
||||
|
||||
// Seek adds a seek (-ss) to the given seconds and returns the result.
|
||||
func (a Args) Seek(seconds float64) Args {
|
||||
return append(a, "-ss", fmt.Sprint(seconds))
|
||||
}
|
||||
|
||||
// Duration sets the duration (-t) to the given seconds and returns the result.
|
||||
func (a Args) Duration(seconds float64) Args {
|
||||
return append(a, "-t", fmt.Sprint(seconds))
|
||||
}
|
||||
|
||||
// Input adds the input (-i) and returns the result.
|
||||
func (a Args) Input(i string) Args {
|
||||
return append(a, "-i", i)
|
||||
}
|
||||
|
||||
// Output adds the output o and returns the result.
|
||||
func (a Args) Output(o string) Args {
|
||||
return append(a, o)
|
||||
}
|
||||
|
||||
// NullOutput adds a null output and returns the result.
|
||||
// On Windows, this outputs to NUL, on everything else, /dev/null.
|
||||
func (a Args) NullOutput() Args {
|
||||
var output string
|
||||
if runtime.GOOS == "windows" {
|
||||
output = "nul" // https://stackoverflow.com/questions/313111/is-there-a-dev-null-on-windows
|
||||
} else {
|
||||
output = "/dev/null"
|
||||
}
|
||||
|
||||
return a.Output(output)
|
||||
}
|
||||
|
||||
// VideoFrames adds the -frames:v with f and returns the result.
|
||||
func (a Args) VideoFrames(f int) Args {
|
||||
return append(a, "-frames:v", fmt.Sprint(f))
|
||||
}
|
||||
|
||||
// FixedQualityScaleVideo adds the -q:v argument with q and returns the result.
|
||||
func (a Args) FixedQualityScaleVideo(q int) Args {
|
||||
return append(a, "-q:v", fmt.Sprint(q))
|
||||
}
|
||||
|
||||
// VideoFilter adds the vf video filter and returns the result.
|
||||
func (a Args) VideoFilter(vf VideoFilter) Args {
|
||||
return append(a, vf.Args()...)
|
||||
}
|
||||
|
||||
// VSync adds the VsyncMethod and returns the result.
|
||||
func (a Args) VSync(m VSyncMethod) Args {
|
||||
return append(a, m.Args()...)
|
||||
}
|
||||
|
||||
// AudioBitrate adds the -b:a argument with b and returns the result.
|
||||
func (a Args) AudioBitrate(b string) Args {
|
||||
return append(a, "-b:a", b)
|
||||
}
|
||||
|
||||
// MaxMuxingQueueSize adds the -max_muxing_queue_size argument with s and returns the result.
|
||||
func (a Args) MaxMuxingQueueSize(s int) Args {
|
||||
// https://trac.ffmpeg.org/ticket/6375
|
||||
return append(a, "-max_muxing_queue_size", fmt.Sprint(s))
|
||||
}
|
||||
|
||||
// SkipAudio adds the skip audio flag (-an) and returns the result.
|
||||
func (a Args) SkipAudio() Args {
|
||||
return append(a, "-an")
|
||||
}
|
||||
|
||||
// VideoCodec adds the given video codec and returns the result.
|
||||
func (a Args) VideoCodec(c VideoCodec) Args {
|
||||
return append(a, c.Args()...)
|
||||
}
|
||||
|
||||
// AudioCodec adds the given audio codec and returns the result.
|
||||
func (a Args) AudioCodec(c AudioCodec) Args {
|
||||
return append(a, c.Args()...)
|
||||
}
|
||||
|
||||
// Format adds the format flag with f and returns the result.
|
||||
func (a Args) Format(f Format) Args {
|
||||
return append(a, f.Args()...)
|
||||
}
|
||||
|
||||
// ImageFormat adds the image format (using -f) and returns the result.
|
||||
func (a Args) ImageFormat(f ImageFormat) Args {
|
||||
return append(a, f.Args()...)
|
||||
}
|
||||
|
||||
// AppendArgs appends the given Arger to the Args and returns the result.
|
||||
func (a Args) AppendArgs(o Arger) Args {
|
||||
return append(a, o.Args()...)
|
||||
}
|
||||
|
||||
// Args returns a string slice of the arguments.
|
||||
func (a Args) Args() []string {
|
||||
return []string(a)
|
||||
}
|
||||
|
||||
// LogLevel represents the log level of ffmpeg.
|
||||
type LogLevel string
|
||||
|
||||
// Args returns the arguments to set the log level in ffmpeg.
|
||||
func (l LogLevel) Args() []string {
|
||||
if l == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []string{"-v", string(l)}
|
||||
}
|
||||
|
||||
// LogLevels for ffmpeg. See -v entry under https://ffmpeg.org/ffmpeg.html#Generic-options
|
||||
var (
|
||||
LogLevelQuiet LogLevel = "quiet"
|
||||
LogLevelPanic LogLevel = "panic"
|
||||
LogLevelFatal LogLevel = "fatal"
|
||||
LogLevelError LogLevel = "error"
|
||||
LogLevelWarning LogLevel = "warning"
|
||||
LogLevelInfo LogLevel = "info"
|
||||
LogLevelVerbose LogLevel = "verbose"
|
||||
LogLevelDebug LogLevel = "debug"
|
||||
LogLevelTrace LogLevel = "trace"
|
||||
)
|
||||
|
||||
// VSyncMethod represents the vsync method of ffmpeg.
|
||||
type VSyncMethod string
|
||||
|
||||
// Args returns the arguments to set the vsync method in ffmpeg.
|
||||
func (m VSyncMethod) Args() []string {
|
||||
if m == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []string{"-vsync", string(m)}
|
||||
}
|
||||
|
||||
// Video sync methods for ffmpeg. See -vsync entry under https://ffmpeg.org/ffmpeg.html#Advanced-options
|
||||
var (
|
||||
VSyncMethodPassthrough VSyncMethod = "0"
|
||||
VSyncMethodCFR VSyncMethod = "1"
|
||||
VSyncMethodVFR VSyncMethod = "2"
|
||||
VSyncMethodDrop VSyncMethod = "drop"
|
||||
VSyncMethodAuto VSyncMethod = "-1"
|
||||
)
|
||||
@@ -1,38 +0,0 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
var TimeRegex = regexp.MustCompile(`time=\s*(\d+):(\d+):(\d+.\d+)`)
|
||||
var FrameRegex = regexp.MustCompile(`frame=\s*([0-9]+)`)
|
||||
|
||||
func GetTimeFromRegex(str string) float64 {
|
||||
regexResult := TimeRegex.FindStringSubmatch(str)
|
||||
|
||||
// Bail early if we don't have the results we expect
|
||||
if len(regexResult) != 4 {
|
||||
return 0
|
||||
}
|
||||
|
||||
h, _ := strconv.ParseFloat(regexResult[1], 64)
|
||||
m, _ := strconv.ParseFloat(regexResult[2], 64)
|
||||
s, _ := strconv.ParseFloat(regexResult[3], 64)
|
||||
hours := h * 3600
|
||||
minutes := m * 60
|
||||
seconds := s
|
||||
return hours + minutes + seconds
|
||||
}
|
||||
|
||||
func GetFrameFromRegex(str string) int {
|
||||
regexResult := FrameRegex.FindStringSubmatch(str)
|
||||
|
||||
// Bail early if we don't have the results we expect
|
||||
if len(regexResult) < 2 {
|
||||
return 0
|
||||
}
|
||||
|
||||
result, _ := strconv.Atoi(regexResult[1])
|
||||
return result
|
||||
}
|
||||
@@ -1,40 +1,38 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
stashExec "github.com/stashapp/stash/pkg/exec"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
const CopyStreamCodec = "copy"
|
||||
const (
|
||||
MimeWebm string = "video/webm"
|
||||
MimeMkv string = "video/x-matroska"
|
||||
MimeMp4 string = "video/mp4"
|
||||
MimeHLS string = "application/vnd.apple.mpegurl"
|
||||
MimeMpegts string = "video/MP2T"
|
||||
)
|
||||
|
||||
// Stream represents an ongoing transcoded stream.
|
||||
type Stream struct {
|
||||
Stdout io.ReadCloser
|
||||
Process *os.Process
|
||||
options TranscodeStreamOptions
|
||||
Cmd *exec.Cmd
|
||||
mimeType string
|
||||
}
|
||||
|
||||
// Serve is an http handler function that serves the stream.
|
||||
func (s *Stream) Serve(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", s.mimeType)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
logger.Infof("[stream] transcoding video file to %s", s.mimeType)
|
||||
|
||||
// handle if client closes the connection
|
||||
notify := r.Context().Done()
|
||||
go func() {
|
||||
<-notify
|
||||
if err := s.Process.Kill(); err != nil {
|
||||
logger.Warnf("unable to kill os process %v: %v", s.Process.Pid, err)
|
||||
}
|
||||
}()
|
||||
// process killing should be handled by command context
|
||||
|
||||
_, err := io.Copy(w, s.Stdout)
|
||||
if err != nil {
|
||||
@@ -42,148 +40,137 @@ func (s *Stream) Serve(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
type Codec struct {
|
||||
Codec string
|
||||
format string
|
||||
// StreamFormat represents a transcode stream format.
|
||||
type StreamFormat struct {
|
||||
MimeType string
|
||||
codec VideoCodec
|
||||
format Format
|
||||
extraArgs []string
|
||||
hls bool
|
||||
}
|
||||
|
||||
var CodecHLS = Codec{
|
||||
Codec: "libx264",
|
||||
format: "mpegts",
|
||||
MimeType: MimeMpegts,
|
||||
extraArgs: []string{
|
||||
"-acodec", "aac",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-preset", "veryfast",
|
||||
"-crf", "25",
|
||||
},
|
||||
hls: true,
|
||||
}
|
||||
var (
|
||||
StreamFormatHLS = StreamFormat{
|
||||
codec: VideoCodecLibX264,
|
||||
format: FormatMpegTS,
|
||||
MimeType: MimeMpegts,
|
||||
extraArgs: []string{
|
||||
"-acodec", "aac",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-preset", "veryfast",
|
||||
"-crf", "25",
|
||||
},
|
||||
hls: true,
|
||||
}
|
||||
|
||||
var CodecH264 = Codec{
|
||||
Codec: "libx264",
|
||||
format: "mp4",
|
||||
MimeType: MimeMp4,
|
||||
extraArgs: []string{
|
||||
"-movflags", "frag_keyframe+empty_moov",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-preset", "veryfast",
|
||||
"-crf", "25",
|
||||
},
|
||||
}
|
||||
StreamFormatH264 = StreamFormat{
|
||||
codec: VideoCodecLibX264,
|
||||
format: FormatMP4,
|
||||
MimeType: MimeMp4,
|
||||
extraArgs: []string{
|
||||
"-movflags", "frag_keyframe+empty_moov",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-preset", "veryfast",
|
||||
"-crf", "25",
|
||||
},
|
||||
}
|
||||
|
||||
var CodecVP9 = Codec{
|
||||
Codec: "libvpx-vp9",
|
||||
format: "webm",
|
||||
MimeType: MimeWebm,
|
||||
extraArgs: []string{
|
||||
"-deadline", "realtime",
|
||||
"-cpu-used", "5",
|
||||
"-row-mt", "1",
|
||||
"-crf", "30",
|
||||
"-b:v", "0",
|
||||
"-pix_fmt", "yuv420p",
|
||||
},
|
||||
}
|
||||
StreamFormatVP9 = StreamFormat{
|
||||
codec: VideoCodecVP9,
|
||||
format: FormatWebm,
|
||||
MimeType: MimeWebm,
|
||||
extraArgs: []string{
|
||||
"-deadline", "realtime",
|
||||
"-cpu-used", "5",
|
||||
"-row-mt", "1",
|
||||
"-crf", "30",
|
||||
"-b:v", "0",
|
||||
"-pix_fmt", "yuv420p",
|
||||
},
|
||||
}
|
||||
|
||||
var CodecVP8 = Codec{
|
||||
Codec: "libvpx",
|
||||
format: "webm",
|
||||
MimeType: MimeWebm,
|
||||
extraArgs: []string{
|
||||
"-deadline", "realtime",
|
||||
"-cpu-used", "5",
|
||||
"-crf", "12",
|
||||
"-b:v", "3M",
|
||||
"-pix_fmt", "yuv420p",
|
||||
},
|
||||
}
|
||||
StreamFormatVP8 = StreamFormat{
|
||||
codec: VideoCodecVPX,
|
||||
format: FormatWebm,
|
||||
MimeType: MimeWebm,
|
||||
extraArgs: []string{
|
||||
"-deadline", "realtime",
|
||||
"-cpu-used", "5",
|
||||
"-crf", "12",
|
||||
"-b:v", "3M",
|
||||
"-pix_fmt", "yuv420p",
|
||||
},
|
||||
}
|
||||
|
||||
var CodecHEVC = Codec{
|
||||
Codec: "libx265",
|
||||
format: "mp4",
|
||||
MimeType: MimeMp4,
|
||||
extraArgs: []string{
|
||||
"-movflags", "frag_keyframe",
|
||||
"-preset", "veryfast",
|
||||
"-crf", "30",
|
||||
},
|
||||
}
|
||||
StreamFormatHEVC = StreamFormat{
|
||||
codec: VideoCodecLibX265,
|
||||
format: FormatMP4,
|
||||
MimeType: MimeMp4,
|
||||
extraArgs: []string{
|
||||
"-movflags", "frag_keyframe",
|
||||
"-preset", "veryfast",
|
||||
"-crf", "30",
|
||||
},
|
||||
}
|
||||
|
||||
// it is very common in MKVs to have just the audio codec unsupported
|
||||
// copy the video stream, transcode the audio and serve as Matroska
|
||||
var CodecMKVAudio = Codec{
|
||||
Codec: CopyStreamCodec,
|
||||
format: "matroska",
|
||||
MimeType: MimeMkv,
|
||||
extraArgs: []string{
|
||||
"-c:a", "libopus",
|
||||
"-b:a", "96k",
|
||||
"-vbr", "on",
|
||||
},
|
||||
}
|
||||
// it is very common in MKVs to have just the audio codec unsupported
|
||||
// copy the video stream, transcode the audio and serve as Matroska
|
||||
StreamFormatMKVAudio = StreamFormat{
|
||||
codec: VideoCodecCopy,
|
||||
format: FormatMatroska,
|
||||
MimeType: MimeMkv,
|
||||
extraArgs: []string{
|
||||
"-c:a", "libopus",
|
||||
"-b:a", "96k",
|
||||
"-vbr", "on",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// TranscodeStreamOptions represents options for live transcoding a video file.
|
||||
type TranscodeStreamOptions struct {
|
||||
ProbeResult VideoFile
|
||||
Codec Codec
|
||||
StartTime string
|
||||
MaxTranscodeSize models.StreamingResolutionEnum
|
||||
Input string
|
||||
Codec StreamFormat
|
||||
StartTime float64
|
||||
MaxTranscodeSize int
|
||||
|
||||
// original video dimensions
|
||||
VideoWidth int
|
||||
VideoHeight int
|
||||
|
||||
// transcode the video, remove the audio
|
||||
// in some videos where the audio codec is not supported by ffmpeg
|
||||
// ffmpeg fails if you try to transcode the audio
|
||||
VideoOnly bool
|
||||
}
|
||||
|
||||
func GetTranscodeStreamOptions(probeResult VideoFile, videoCodec Codec, audioCodec AudioCodec) TranscodeStreamOptions {
|
||||
options := TranscodeStreamOptions{
|
||||
ProbeResult: probeResult,
|
||||
Codec: videoCodec,
|
||||
}
|
||||
func (o TranscodeStreamOptions) getStreamArgs() Args {
|
||||
var args Args
|
||||
args = append(args, "-hide_banner")
|
||||
args = args.LogLevel(LogLevelError)
|
||||
|
||||
if audioCodec == MissingUnsupported {
|
||||
// ffmpeg fails if it trys to transcode a non supported audio codec
|
||||
options.VideoOnly = true
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
func (o TranscodeStreamOptions) getStreamArgs() []string {
|
||||
args := []string{
|
||||
"-hide_banner",
|
||||
"-v", "error",
|
||||
}
|
||||
|
||||
if o.StartTime != "" {
|
||||
args = append(args, "-ss", o.StartTime)
|
||||
if o.StartTime != 0 {
|
||||
args = args.Seek(o.StartTime)
|
||||
}
|
||||
|
||||
if o.Codec.hls {
|
||||
// we only serve a fixed segment length
|
||||
args = append(args, "-t", strconv.Itoa(int(hlsSegmentLength)))
|
||||
args = args.Duration(hlsSegmentLength)
|
||||
}
|
||||
|
||||
args = append(args,
|
||||
"-i", o.ProbeResult.Path,
|
||||
)
|
||||
args = args.Input(o.Input)
|
||||
|
||||
if o.VideoOnly {
|
||||
args = append(args, "-an")
|
||||
args = args.SkipAudio()
|
||||
}
|
||||
|
||||
args = append(args,
|
||||
"-c:v", o.Codec.Codec,
|
||||
)
|
||||
args = args.VideoCodec(o.Codec.codec)
|
||||
|
||||
// don't set scale when copying video stream
|
||||
if o.Codec.Codec != CopyStreamCodec {
|
||||
scale := calculateTranscodeScale(o.ProbeResult, o.MaxTranscodeSize)
|
||||
args = append(args,
|
||||
"-vf", "scale="+scale,
|
||||
)
|
||||
if o.Codec.codec != VideoCodecCopy {
|
||||
var videoFilter VideoFilter
|
||||
videoFilter = videoFilter.ScaleMax(o.VideoWidth, o.VideoHeight, o.MaxTranscodeSize)
|
||||
args = args.VideoFilter(videoFilter)
|
||||
}
|
||||
|
||||
if len(o.Codec.extraArgs) > 0 {
|
||||
@@ -193,20 +180,18 @@ func (o TranscodeStreamOptions) getStreamArgs() []string {
|
||||
args = append(args,
|
||||
// this is needed for 5-channel ac3 files
|
||||
"-ac", "2",
|
||||
"-f", o.Codec.format,
|
||||
"pipe:",
|
||||
)
|
||||
|
||||
args = args.Format(o.Codec.format)
|
||||
args = args.Output("pipe:")
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
func (e *Encoder) GetTranscodeStream(options TranscodeStreamOptions) (*Stream, error) {
|
||||
return e.stream(options.ProbeResult, options)
|
||||
}
|
||||
|
||||
func (e *Encoder) stream(probeResult VideoFile, options TranscodeStreamOptions) (*Stream, error) {
|
||||
// GetTranscodeStream starts the live transcoding process using ffmpeg and returns a stream.
|
||||
func (f *FFMpeg) GetTranscodeStream(ctx context.Context, options TranscodeStreamOptions) (*Stream, error) {
|
||||
args := options.getStreamArgs()
|
||||
cmd := stashExec.Command(string(*e), args...)
|
||||
cmd := f.Command(ctx, args)
|
||||
logger.Debugf("Streaming via: %s", strings.Join(cmd.Args, " "))
|
||||
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
@@ -225,13 +210,6 @@ func (e *Encoder) stream(probeResult VideoFile, options TranscodeStreamOptions)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
registerRunningEncoder(probeResult.Path, cmd.Process)
|
||||
go func() {
|
||||
if err := waitAndDeregister(probeResult.Path, cmd); err != nil {
|
||||
logger.Warnf("Error while deregistering ffmpeg stream: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// stderr must be consumed or the process deadlocks
|
||||
go func() {
|
||||
stderrData, _ := io.ReadAll(stderr)
|
||||
@@ -243,8 +221,7 @@ func (e *Encoder) stream(probeResult VideoFile, options TranscodeStreamOptions)
|
||||
|
||||
ret := &Stream{
|
||||
Stdout: stdout,
|
||||
Process: cmd.Process,
|
||||
options: options,
|
||||
Cmd: cmd,
|
||||
mimeType: options.Codec.MimeType,
|
||||
}
|
||||
return ret, nil
|
||||
|
||||
38
pkg/ffmpeg/transcoder/image.go
Normal file
38
pkg/ffmpeg/transcoder/image.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package transcoder
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/stashapp/stash/pkg/ffmpeg"
|
||||
)
|
||||
|
||||
var ErrUnsupportedFormat = errors.New("unsupported image format")
|
||||
|
||||
type ImageThumbnailOptions struct {
|
||||
InputFormat ffmpeg.ImageFormat
|
||||
OutputPath string
|
||||
MaxDimensions int
|
||||
Quality int
|
||||
}
|
||||
|
||||
func ImageThumbnail(input string, options ImageThumbnailOptions) ffmpeg.Args {
|
||||
var videoFilter ffmpeg.VideoFilter
|
||||
videoFilter = videoFilter.ScaleMaxSize(options.MaxDimensions)
|
||||
|
||||
var args ffmpeg.Args
|
||||
|
||||
args = args.Overwrite().
|
||||
ImageFormat(options.InputFormat).
|
||||
Input(input).
|
||||
VideoFilter(videoFilter).
|
||||
VideoCodec(ffmpeg.VideoCodecMJpeg)
|
||||
|
||||
if options.Quality > 0 {
|
||||
args = args.FixedQualityScaleVideo(options.Quality)
|
||||
}
|
||||
|
||||
args = args.ImageFormat(ffmpeg.ImageFormatImage2Pipe).
|
||||
Output(options.OutputPath)
|
||||
|
||||
return args
|
||||
}
|
||||
109
pkg/ffmpeg/transcoder/screenshot.go
Normal file
109
pkg/ffmpeg/transcoder/screenshot.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package transcoder
|
||||
|
||||
import "github.com/stashapp/stash/pkg/ffmpeg"
|
||||
|
||||
type ScreenshotOptions struct {
|
||||
OutputPath string
|
||||
OutputType ScreenshotOutputType
|
||||
|
||||
// Quality is the quality scale. See https://ffmpeg.org/ffmpeg.html#Main-options
|
||||
Quality int
|
||||
|
||||
Width int
|
||||
|
||||
// Verbosity is the logging verbosity. Defaults to LogLevelError if not set.
|
||||
Verbosity ffmpeg.LogLevel
|
||||
|
||||
UseSelectFilter bool
|
||||
}
|
||||
|
||||
func (o *ScreenshotOptions) setDefaults() {
|
||||
if o.Verbosity == "" {
|
||||
o.Verbosity = ffmpeg.LogLevelError
|
||||
}
|
||||
}
|
||||
|
||||
type ScreenshotOutputType struct {
|
||||
codec ffmpeg.VideoCodec
|
||||
format ffmpeg.Format
|
||||
}
|
||||
|
||||
func (t ScreenshotOutputType) Args() []string {
|
||||
var ret []string
|
||||
if t.codec != "" {
|
||||
ret = append(ret, t.codec.Args()...)
|
||||
}
|
||||
if t.format != "" {
|
||||
ret = append(ret, t.format.Args()...)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
var (
|
||||
ScreenshotOutputTypeImage2 = ScreenshotOutputType{
|
||||
format: "image2",
|
||||
}
|
||||
ScreenshotOutputTypeBMP = ScreenshotOutputType{
|
||||
codec: ffmpeg.VideoCodecBMP,
|
||||
format: "rawvideo",
|
||||
}
|
||||
)
|
||||
|
||||
func ScreenshotTime(input string, t float64, options ScreenshotOptions) ffmpeg.Args {
|
||||
options.setDefaults()
|
||||
|
||||
var args ffmpeg.Args
|
||||
args = args.LogLevel(options.Verbosity)
|
||||
args = args.Overwrite()
|
||||
args = args.Seek(t)
|
||||
|
||||
args = args.Input(input)
|
||||
args = args.VideoFrames(1)
|
||||
|
||||
if options.Quality > 0 {
|
||||
args = args.FixedQualityScaleVideo(options.Quality)
|
||||
}
|
||||
|
||||
var vf ffmpeg.VideoFilter
|
||||
|
||||
if options.Width > 0 {
|
||||
vf = vf.ScaleWidth(options.Width)
|
||||
args = args.VideoFilter(vf)
|
||||
}
|
||||
|
||||
args = args.AppendArgs(options.OutputType)
|
||||
args = args.Output(options.OutputPath)
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
// ScreenshotFrame uses the select filter to get a single frame from the video.
|
||||
// It is very slow and should only be used for files with very small duration in secs / frame count.
|
||||
func ScreenshotFrame(input string, frame int, options ScreenshotOptions) ffmpeg.Args {
|
||||
options.setDefaults()
|
||||
|
||||
var args ffmpeg.Args
|
||||
args = args.LogLevel(options.Verbosity)
|
||||
args = args.Overwrite()
|
||||
|
||||
args = args.Input(input)
|
||||
args = args.VideoFrames(1)
|
||||
|
||||
args = args.VSync(ffmpeg.VSyncMethodPassthrough)
|
||||
|
||||
var vf ffmpeg.VideoFilter
|
||||
// keep only frame number options.Frame)
|
||||
vf = vf.Select(frame)
|
||||
|
||||
if options.Width > 0 {
|
||||
vf = vf.ScaleWidth(options.Width)
|
||||
}
|
||||
|
||||
args = args.VideoFilter(vf)
|
||||
|
||||
args = args.AppendArgs(options.OutputType)
|
||||
args = args.Output(options.OutputPath)
|
||||
|
||||
return args
|
||||
}
|
||||
67
pkg/ffmpeg/transcoder/splice.go
Normal file
67
pkg/ffmpeg/transcoder/splice.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package transcoder
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/pkg/ffmpeg"
|
||||
)
|
||||
|
||||
type SpliceOptions struct {
|
||||
OutputPath string
|
||||
Format ffmpeg.Format
|
||||
|
||||
VideoCodec ffmpeg.VideoCodec
|
||||
VideoArgs ffmpeg.Args
|
||||
|
||||
AudioCodec ffmpeg.AudioCodec
|
||||
AudioArgs ffmpeg.Args
|
||||
|
||||
// Verbosity is the logging verbosity. Defaults to LogLevelError if not set.
|
||||
Verbosity ffmpeg.LogLevel
|
||||
}
|
||||
|
||||
func (o *SpliceOptions) setDefaults() {
|
||||
if o.Verbosity == "" {
|
||||
o.Verbosity = ffmpeg.LogLevelError
|
||||
}
|
||||
}
|
||||
|
||||
// fixWindowsPath replaces \ with / in the given path because the \ isn't recognized as valid on windows ffmpeg
|
||||
func fixWindowsPath(str string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.ReplaceAll(str, `\`, "/")
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
func Splice(concatFile string, options SpliceOptions) ffmpeg.Args {
|
||||
options.setDefaults()
|
||||
|
||||
var args ffmpeg.Args
|
||||
args = args.LogLevel(options.Verbosity)
|
||||
args = args.Format(ffmpeg.FormatConcat)
|
||||
args = args.Input(fixWindowsPath(concatFile))
|
||||
args = args.Overwrite()
|
||||
|
||||
// if video codec is not provided, then use copy
|
||||
if options.VideoCodec == "" {
|
||||
options.VideoCodec = ffmpeg.VideoCodecCopy
|
||||
}
|
||||
|
||||
args = args.VideoCodec(options.VideoCodec)
|
||||
args = args.AppendArgs(options.VideoArgs)
|
||||
|
||||
// if audio codec is not provided, then skip it
|
||||
if options.AudioCodec == "" {
|
||||
args = args.SkipAudio()
|
||||
} else {
|
||||
args = args.AudioCodec(options.AudioCodec)
|
||||
}
|
||||
args = args.AppendArgs(options.AudioArgs)
|
||||
|
||||
args = args.Format(options.Format)
|
||||
args = args.Output(options.OutputPath)
|
||||
|
||||
return args
|
||||
}
|
||||
99
pkg/ffmpeg/transcoder/transcode.go
Normal file
99
pkg/ffmpeg/transcoder/transcode.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package transcoder
|
||||
|
||||
import "github.com/stashapp/stash/pkg/ffmpeg"
|
||||
|
||||
type TranscodeOptions struct {
|
||||
OutputPath string
|
||||
Format ffmpeg.Format
|
||||
|
||||
VideoCodec ffmpeg.VideoCodec
|
||||
VideoArgs ffmpeg.Args
|
||||
|
||||
AudioCodec ffmpeg.AudioCodec
|
||||
AudioArgs ffmpeg.Args
|
||||
|
||||
// if XError is true, then ffmpeg will fail on warnings
|
||||
XError bool
|
||||
|
||||
StartTime float64
|
||||
SlowSeek bool
|
||||
Duration float64
|
||||
|
||||
// Verbosity is the logging verbosity. Defaults to LogLevelError if not set.
|
||||
Verbosity ffmpeg.LogLevel
|
||||
}
|
||||
|
||||
func (o *TranscodeOptions) setDefaults() {
|
||||
if o.Verbosity == "" {
|
||||
o.Verbosity = ffmpeg.LogLevelError
|
||||
}
|
||||
}
|
||||
|
||||
func Transcode(input string, options TranscodeOptions) ffmpeg.Args {
|
||||
options.setDefaults()
|
||||
|
||||
// TODO - this should probably be generalised and applied to all operations. Need to verify impact on phash algorithm.
|
||||
const fallbackMinSlowSeek = 20.0
|
||||
|
||||
var fastSeek float64
|
||||
var slowSeek float64
|
||||
|
||||
if !options.SlowSeek {
|
||||
fastSeek = options.StartTime
|
||||
slowSeek = 0
|
||||
} else {
|
||||
// In slowseek mode, try a combination of fast/slow seek instead of just fastseek
|
||||
// Commonly with avi/wmv ffmpeg doesn't seem to always predict the right start point to begin decoding when
|
||||
// using fast seek. If you force ffmpeg to decode more, it avoids the "blocky green artifact" issue.
|
||||
if options.StartTime > fallbackMinSlowSeek {
|
||||
// Handle seeks longer than fallbackMinSlowSeek with fast/slow seeks
|
||||
// Allow for at least fallbackMinSlowSeek seconds of slow seek
|
||||
fastSeek = options.StartTime - fallbackMinSlowSeek
|
||||
slowSeek = fallbackMinSlowSeek
|
||||
} else {
|
||||
// Handle seeks shorter than fallbackMinSlowSeek with only slow seeks.
|
||||
slowSeek = options.StartTime
|
||||
fastSeek = 0
|
||||
}
|
||||
}
|
||||
|
||||
var args ffmpeg.Args
|
||||
args = args.LogLevel(options.Verbosity).Overwrite()
|
||||
|
||||
if options.XError {
|
||||
args = args.XError()
|
||||
}
|
||||
|
||||
if fastSeek > 0 {
|
||||
args = args.Seek(fastSeek)
|
||||
}
|
||||
|
||||
args = args.Input(input)
|
||||
|
||||
if slowSeek > 0 {
|
||||
args = args.Seek(slowSeek)
|
||||
}
|
||||
|
||||
if options.Duration > 0 {
|
||||
args = args.Duration(options.Duration)
|
||||
}
|
||||
|
||||
// https://trac.ffmpeg.org/ticket/6375
|
||||
args = args.MaxMuxingQueueSize(1024)
|
||||
|
||||
args = args.VideoCodec(options.VideoCodec)
|
||||
args = args.AppendArgs(options.VideoArgs)
|
||||
|
||||
// if audio codec is not provided, then skip it
|
||||
if options.AudioCodec == "" {
|
||||
args = args.SkipAudio()
|
||||
} else {
|
||||
args = args.AudioCodec(options.AudioCodec)
|
||||
}
|
||||
args = args.AppendArgs(options.AudioArgs)
|
||||
|
||||
args = args.Format(options.Format)
|
||||
args = args.Output(options.OutputPath)
|
||||
|
||||
return args
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
package ffmpeg
|
||||
|
||||
import (
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/models/json"
|
||||
)
|
||||
|
||||
// FFProbeJSON is the JSON output of ffprobe.
|
||||
type FFProbeJSON struct {
|
||||
Format struct {
|
||||
BitRate string `json:"bit_rate"`
|
||||
@@ -17,13 +18,13 @@ type FFProbeJSON struct {
|
||||
Size string `json:"size"`
|
||||
StartTime string `json:"start_time"`
|
||||
Tags struct {
|
||||
CompatibleBrands string `json:"compatible_brands"`
|
||||
CreationTime models.JSONTime `json:"creation_time"`
|
||||
Encoder string `json:"encoder"`
|
||||
MajorBrand string `json:"major_brand"`
|
||||
MinorVersion string `json:"minor_version"`
|
||||
Title string `json:"title"`
|
||||
Comment string `json:"comment"`
|
||||
CompatibleBrands string `json:"compatible_brands"`
|
||||
CreationTime json.JSONTime `json:"creation_time"`
|
||||
Encoder string `json:"encoder"`
|
||||
MajorBrand string `json:"major_brand"`
|
||||
MinorVersion string `json:"minor_version"`
|
||||
Title string `json:"title"`
|
||||
Comment string `json:"comment"`
|
||||
} `json:"tags"`
|
||||
} `json:"format"`
|
||||
Streams []FFProbeStream `json:"streams"`
|
||||
@@ -33,6 +34,7 @@ type FFProbeJSON struct {
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// FFProbeStream is a JSON representation of an ffmpeg stream.
|
||||
type FFProbeStream struct {
|
||||
AvgFrameRate string `json:"avg_frame_rate"`
|
||||
BitRate string `json:"bit_rate"`
|
||||
@@ -79,10 +81,10 @@ type FFProbeStream struct {
|
||||
StartPts int `json:"start_pts"`
|
||||
StartTime string `json:"start_time"`
|
||||
Tags struct {
|
||||
CreationTime models.JSONTime `json:"creation_time"`
|
||||
HandlerName string `json:"handler_name"`
|
||||
Language string `json:"language"`
|
||||
Rotate string `json:"rotate"`
|
||||
CreationTime json.JSONTime `json:"creation_time"`
|
||||
HandlerName string `json:"handler_name"`
|
||||
Language string `json:"language"`
|
||||
Rotate string `json:"rotate"`
|
||||
} `json:"tags"`
|
||||
TimeBase string `json:"time_base"`
|
||||
Width int `json:"width,omitempty"`
|
||||
|
||||
Reference in New Issue
Block a user