mirror of https://github.com/stashapp/stash.git
206 lines
5.1 KiB
Go
206 lines
5.1 KiB
Go
package ffmpeg
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"os/exec"
|
|
"strings"
|
|
"syscall"
|
|
|
|
"github.com/stashapp/stash/pkg/file"
|
|
"github.com/stashapp/stash/pkg/fsutil"
|
|
"github.com/stashapp/stash/pkg/logger"
|
|
"github.com/stashapp/stash/pkg/models"
|
|
)
|
|
|
|
type StreamFormat struct {
|
|
MimeType string
|
|
Args func(videoFilter VideoFilter, videoOnly bool) Args
|
|
}
|
|
|
|
var (
|
|
StreamTypeMP4 = StreamFormat{
|
|
MimeType: MimeMp4Video,
|
|
Args: func(videoFilter VideoFilter, videoOnly bool) (args Args) {
|
|
args = args.VideoCodec(VideoCodecLibX264)
|
|
args = append(args,
|
|
"-movflags", "frag_keyframe+empty_moov",
|
|
"-pix_fmt", "yuv420p",
|
|
"-preset", "veryfast",
|
|
"-crf", "25",
|
|
)
|
|
args = args.VideoFilter(videoFilter)
|
|
if videoOnly {
|
|
args = args.SkipAudio()
|
|
} else {
|
|
args = append(args, "-ac", "2")
|
|
}
|
|
args = args.Format(FormatMP4)
|
|
return
|
|
},
|
|
}
|
|
StreamTypeWEBM = StreamFormat{
|
|
MimeType: MimeWebmVideo,
|
|
Args: func(videoFilter VideoFilter, videoOnly bool) (args Args) {
|
|
args = args.VideoCodec(VideoCodecVP9)
|
|
args = append(args,
|
|
"-pix_fmt", "yuv420p",
|
|
"-deadline", "realtime",
|
|
"-cpu-used", "5",
|
|
"-row-mt", "1",
|
|
"-crf", "30",
|
|
"-b:v", "0",
|
|
)
|
|
args = args.VideoFilter(videoFilter)
|
|
if videoOnly {
|
|
args = args.SkipAudio()
|
|
} else {
|
|
args = append(args, "-ac", "2")
|
|
}
|
|
args = args.Format(FormatWebm)
|
|
return
|
|
},
|
|
}
|
|
StreamTypeMKV = StreamFormat{
|
|
MimeType: MimeMkvVideo,
|
|
Args: func(videoFilter VideoFilter, videoOnly bool) (args Args) {
|
|
args = args.VideoCodec(VideoCodecCopy)
|
|
if videoOnly {
|
|
args = args.SkipAudio()
|
|
} else {
|
|
args = args.AudioCodec(AudioCodecLibOpus)
|
|
args = append(args,
|
|
"-b:a", "96k",
|
|
"-vbr", "on",
|
|
"-ac", "2",
|
|
)
|
|
}
|
|
args = args.Format(FormatMatroska)
|
|
return
|
|
},
|
|
}
|
|
)
|
|
|
|
type TranscodeOptions struct {
|
|
StreamType StreamFormat
|
|
VideoFile *file.VideoFile
|
|
Resolution string
|
|
StartTime float64
|
|
}
|
|
|
|
func (o TranscodeOptions) makeStreamArgs(sm *StreamManager) Args {
|
|
maxTranscodeSize := sm.config.GetMaxStreamingTranscodeSize().GetMaxResolution()
|
|
if o.Resolution != "" {
|
|
maxTranscodeSize = models.StreamingResolutionEnum(o.Resolution).GetMaxResolution()
|
|
}
|
|
extraInputArgs := sm.config.GetLiveTranscodeInputArgs()
|
|
extraOutputArgs := sm.config.GetLiveTranscodeOutputArgs()
|
|
|
|
args := Args{"-hide_banner"}
|
|
args = args.LogLevel(LogLevelError)
|
|
|
|
args = append(args, extraInputArgs...)
|
|
|
|
if o.StartTime != 0 {
|
|
args = args.Seek(o.StartTime)
|
|
}
|
|
|
|
args = args.Input(o.VideoFile.Path)
|
|
|
|
videoOnly := ProbeAudioCodec(o.VideoFile.AudioCodec) == MissingUnsupported
|
|
|
|
var videoFilter VideoFilter
|
|
videoFilter = videoFilter.ScaleMax(o.VideoFile.Width, o.VideoFile.Height, maxTranscodeSize)
|
|
|
|
args = append(args, o.StreamType.Args(videoFilter, videoOnly)...)
|
|
|
|
args = append(args, extraOutputArgs...)
|
|
|
|
args = args.Output("pipe:")
|
|
|
|
return args
|
|
}
|
|
|
|
func (sm *StreamManager) ServeTranscode(w http.ResponseWriter, r *http.Request, options TranscodeOptions) {
|
|
streamRequestCtx := NewStreamRequestContext(w, r)
|
|
lockCtx := sm.lockManager.ReadLock(streamRequestCtx, options.VideoFile.Path)
|
|
|
|
// hijacking and closing the connection here causes video playback to hang in Chrome
|
|
// due to ERR_INCOMPLETE_CHUNKED_ENCODING
|
|
// We trust that the request context will be closed, so we don't need to call Cancel on the returned context here.
|
|
|
|
handler, err := sm.getTranscodeStream(lockCtx, options)
|
|
|
|
if err != nil {
|
|
logger.Errorf("[transcode] error transcoding video file: %v", err)
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
if _, err := w.Write([]byte(err.Error())); err != nil {
|
|
logger.Warnf("[transcode] error writing response: %v", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
handler(w, r)
|
|
}
|
|
|
|
func (sm *StreamManager) getTranscodeStream(ctx *fsutil.LockContext, options TranscodeOptions) (http.HandlerFunc, error) {
|
|
args := options.makeStreamArgs(sm)
|
|
cmd := sm.encoder.Command(ctx, args)
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
if nil != err {
|
|
logger.Errorf("[transcode] ffmpeg stdout not available: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
stderr, err := cmd.StderrPipe()
|
|
if nil != err {
|
|
logger.Errorf("[transcode] ffmpeg stderr not available: %v", err)
|
|
return nil, err
|
|
}
|
|
|
|
if err = cmd.Start(); err != nil {
|
|
return nil, err
|
|
}
|
|
ctx.AttachCommand(cmd)
|
|
|
|
// stderr must be consumed or the process deadlocks
|
|
go func() {
|
|
errStr, _ := io.ReadAll(stderr)
|
|
|
|
errCmd := cmd.Wait()
|
|
|
|
var err error
|
|
|
|
e := string(errStr)
|
|
if e != "" {
|
|
err = errors.New(e)
|
|
} else {
|
|
err = errCmd
|
|
}
|
|
|
|
// ignore ExitErrors, the process is always forcibly killed
|
|
var exitError *exec.ExitError
|
|
if err != nil && !errors.As(err, &exitError) {
|
|
logger.Errorf("[transcode] ffmpeg error when running command <%s>: %v", strings.Join(cmd.Args, " "), err)
|
|
}
|
|
}()
|
|
|
|
mimeType := options.StreamType.MimeType
|
|
handler := func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", mimeType)
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
// process killing should be handled by command context
|
|
|
|
_, err := io.Copy(w, stdout)
|
|
if err != nil && !errors.Is(err, syscall.EPIPE) {
|
|
logger.Errorf("[transcode] error serving transcoded video file: %v", err)
|
|
}
|
|
|
|
w.(http.Flusher).Flush()
|
|
}
|
|
return handler, nil
|
|
}
|