Simple hardware encoding (#3419)

* HW Accel
* CUDA Docker build and adjust the NVENC encoder
* Removed NVENC preset

Using legacy presets is removed in SDK 12 and deprecated since SDK 10.
This commit removed the preset to allow ffmpeg to select the default one.

---------

Co-authored-by: Nodude <>
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
NodudeWasTaken
2023-03-10 01:25:55 +01:00
committed by GitHub
parent d4fb6b2acf
commit 0c1b02380e
24 changed files with 537 additions and 112 deletions

View File

@@ -278,3 +278,8 @@ validate-backend: lint it
.PHONY: docker-build .PHONY: docker-build
docker-build: pre-build docker-build: pre-build
docker build --build-arg GITHASH=$(GITHASH) --build-arg STASH_VERSION=$(STASH_VERSION) -t stash/build -f docker/build/x86_64/Dockerfile . docker build --build-arg GITHASH=$(GITHASH) --build-arg STASH_VERSION=$(STASH_VERSION) -t stash/build -f docker/build/x86_64/Dockerfile .
# locally builds and tags a 'stash/cuda-build' docker image
.PHONY: docker-cuda-build
docker-cuda-build: pre-build
docker build --build-arg GITHASH=$(GITHASH) --build-arg STASH_VERSION=$(STASH_VERSION) -t stash/cuda-build -f docker/build/x86_64/Dockerfile-CUDA .

View File

@@ -0,0 +1,53 @@
# This dockerfile should be built with `make docker-cuda-build` from the stash root.
# Build Frontend
FROM node:alpine as frontend
RUN apk add --no-cache make
## cache node_modules separately
COPY ./ui/v2.5/package.json ./ui/v2.5/yarn.lock /stash/ui/v2.5/
WORKDIR /stash
RUN yarn --cwd ui/v2.5 install --frozen-lockfile.
COPY Makefile /stash/
COPY ./graphql /stash/graphql/
COPY ./ui /stash/ui/
RUN make generate-frontend
ARG GITHASH
ARG STASH_VERSION
RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui
# Build Backend
FROM golang:1.19-bullseye as backend
RUN apt update && apt install -y build-essential golang
WORKDIR /stash
COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/
COPY ./scripts /stash/scripts/
COPY ./vendor /stash/vendor/
COPY ./pkg /stash/pkg/
COPY ./cmd /stash/cmd
COPY ./internal /stash/internal
COPY --from=frontend /stash /stash/
RUN make generate-backend
ARG GITHASH
ARG STASH_VERSION
RUN make build
# Final Runnable Image
FROM nvidia/cuda:12.0.1-runtime-ubuntu22.04
RUN apt update
RUN apt upgrade -y
RUN apt install -y ca-certificates libvips-tools ffmpeg wget
RUN rm -rf /var/lib/apt/lists/*
COPY --from=backend /stash/stash /usr/bin/
# NVENC Patch
RUN mkdir -p /usr/local/bin /patched-lib
RUN wget https://raw.githubusercontent.com/keylase/nvidia-patch/master/patch.sh -O /usr/local/bin/patch.sh
RUN wget https://raw.githubusercontent.com/keylase/nvidia-patch/master/docker-entrypoint.sh -O /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/patch.sh /usr/local/bin/docker-entrypoint.sh /usr/bin/stash
ENV LANG C.UTF-8
ENV NVIDIA_VISIBLE_DEVICES all
ENV NVIDIA_DRIVER_CAPABILITIES=video,utility
ENV STASH_CONFIG_FILE=/root/.stash/config.yml
EXPOSE 9999
ENTRYPOINT ["docker-entrypoint.sh", "stash"]

View File

@@ -19,6 +19,7 @@ fragment ConfigGeneralData on ConfigGeneralResult {
previewExcludeStart previewExcludeStart
previewExcludeEnd previewExcludeEnd
previewPreset previewPreset
transcodeHardwareAcceleration
maxTranscodeSize maxTranscodeSize
maxStreamingTranscodeSize maxStreamingTranscodeSize
writeImageThumbnails writeImageThumbnails

View File

@@ -67,6 +67,8 @@ input ConfigGeneralInput {
previewExcludeEnd: String previewExcludeEnd: String
"""Preset when generating preview""" """Preset when generating preview"""
previewPreset: PreviewPreset previewPreset: PreviewPreset
"""Transcode Hardware Acceleration"""
transcodeHardwareAcceleration: Boolean
"""Max generated transcode size""" """Max generated transcode size"""
maxTranscodeSize: StreamingResolutionEnum maxTranscodeSize: StreamingResolutionEnum
"""Max streaming transcode size""" """Max streaming transcode size"""
@@ -170,6 +172,8 @@ type ConfigGeneralResult {
previewExcludeEnd: String! previewExcludeEnd: String!
"""Preset when generating preview""" """Preset when generating preview"""
previewPreset: PreviewPreset! previewPreset: PreviewPreset!
"""Transcode Hardware Acceleration"""
transcodeHardwareAcceleration: Boolean!
"""Max generated transcode size""" """Max generated transcode size"""
maxTranscodeSize: StreamingResolutionEnum maxTranscodeSize: StreamingResolutionEnum
"""Max streaming transcode size""" """Max streaming transcode size"""

View File

@@ -181,6 +181,9 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
c.Set(config.PreviewPreset, input.PreviewPreset.String()) c.Set(config.PreviewPreset, input.PreviewPreset.String())
} }
if input.TranscodeHardwareAcceleration != nil {
c.Set(config.TranscodeHardwareAcceleration, *input.TranscodeHardwareAcceleration)
}
if input.MaxTranscodeSize != nil { if input.MaxTranscodeSize != nil {
c.Set(config.MaxTranscodeSize, input.MaxTranscodeSize.String()) c.Set(config.MaxTranscodeSize, input.MaxTranscodeSize.String())
} }

View File

@@ -83,52 +83,53 @@ func makeConfigGeneralResult() *ConfigGeneralResult {
scraperCDPPath := config.GetScraperCDPPath() scraperCDPPath := config.GetScraperCDPPath()
return &ConfigGeneralResult{ return &ConfigGeneralResult{
Stashes: config.GetStashPaths(), Stashes: config.GetStashPaths(),
DatabasePath: config.GetDatabasePath(), DatabasePath: config.GetDatabasePath(),
BackupDirectoryPath: config.GetBackupDirectoryPath(), BackupDirectoryPath: config.GetBackupDirectoryPath(),
GeneratedPath: config.GetGeneratedPath(), GeneratedPath: config.GetGeneratedPath(),
MetadataPath: config.GetMetadataPath(), MetadataPath: config.GetMetadataPath(),
ConfigFilePath: config.GetConfigFile(), ConfigFilePath: config.GetConfigFile(),
ScrapersPath: config.GetScrapersPath(), ScrapersPath: config.GetScrapersPath(),
CachePath: config.GetCachePath(), CachePath: config.GetCachePath(),
CalculateMd5: config.IsCalculateMD5(), CalculateMd5: config.IsCalculateMD5(),
VideoFileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(), VideoFileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(),
ParallelTasks: config.GetParallelTasks(), ParallelTasks: config.GetParallelTasks(),
PreviewAudio: config.GetPreviewAudio(), PreviewAudio: config.GetPreviewAudio(),
PreviewSegments: config.GetPreviewSegments(), PreviewSegments: config.GetPreviewSegments(),
PreviewSegmentDuration: config.GetPreviewSegmentDuration(), PreviewSegmentDuration: config.GetPreviewSegmentDuration(),
PreviewExcludeStart: config.GetPreviewExcludeStart(), PreviewExcludeStart: config.GetPreviewExcludeStart(),
PreviewExcludeEnd: config.GetPreviewExcludeEnd(), PreviewExcludeEnd: config.GetPreviewExcludeEnd(),
PreviewPreset: config.GetPreviewPreset(), PreviewPreset: config.GetPreviewPreset(),
MaxTranscodeSize: &maxTranscodeSize, TranscodeHardwareAcceleration: config.GetTranscodeHardwareAcceleration(),
MaxStreamingTranscodeSize: &maxStreamingTranscodeSize, MaxTranscodeSize: &maxTranscodeSize,
WriteImageThumbnails: config.IsWriteImageThumbnails(), MaxStreamingTranscodeSize: &maxStreamingTranscodeSize,
GalleryCoverRegex: config.GetGalleryCoverRegex(), WriteImageThumbnails: config.IsWriteImageThumbnails(),
APIKey: config.GetAPIKey(), GalleryCoverRegex: config.GetGalleryCoverRegex(),
Username: config.GetUsername(), APIKey: config.GetAPIKey(),
Password: config.GetPasswordHash(), Username: config.GetUsername(),
MaxSessionAge: config.GetMaxSessionAge(), Password: config.GetPasswordHash(),
LogFile: &logFile, MaxSessionAge: config.GetMaxSessionAge(),
LogOut: config.GetLogOut(), LogFile: &logFile,
LogLevel: config.GetLogLevel(), LogOut: config.GetLogOut(),
LogAccess: config.GetLogAccess(), LogLevel: config.GetLogLevel(),
VideoExtensions: config.GetVideoExtensions(), LogAccess: config.GetLogAccess(),
ImageExtensions: config.GetImageExtensions(), VideoExtensions: config.GetVideoExtensions(),
GalleryExtensions: config.GetGalleryExtensions(), ImageExtensions: config.GetImageExtensions(),
CreateGalleriesFromFolders: config.GetCreateGalleriesFromFolders(), GalleryExtensions: config.GetGalleryExtensions(),
Excludes: config.GetExcludes(), CreateGalleriesFromFolders: config.GetCreateGalleriesFromFolders(),
ImageExcludes: config.GetImageExcludes(), Excludes: config.GetExcludes(),
CustomPerformerImageLocation: &customPerformerImageLocation, ImageExcludes: config.GetImageExcludes(),
ScraperUserAgent: &scraperUserAgent, CustomPerformerImageLocation: &customPerformerImageLocation,
ScraperCertCheck: config.GetScraperCertCheck(), ScraperUserAgent: &scraperUserAgent,
ScraperCDPPath: &scraperCDPPath, ScraperCertCheck: config.GetScraperCertCheck(),
StashBoxes: config.GetStashBoxes(), ScraperCDPPath: &scraperCDPPath,
PythonPath: config.GetPythonPath(), StashBoxes: config.GetStashBoxes(),
TranscodeInputArgs: config.GetTranscodeInputArgs(), PythonPath: config.GetPythonPath(),
TranscodeOutputArgs: config.GetTranscodeOutputArgs(), TranscodeInputArgs: config.GetTranscodeInputArgs(),
LiveTranscodeInputArgs: config.GetLiveTranscodeInputArgs(), TranscodeOutputArgs: config.GetTranscodeOutputArgs(),
LiveTranscodeOutputArgs: config.GetLiveTranscodeOutputArgs(), LiveTranscodeInputArgs: config.GetLiveTranscodeInputArgs(),
DrawFunscriptHeatmapRange: config.GetDrawFunscriptHeatmapRange(), LiveTranscodeOutputArgs: config.GetLiveTranscodeOutputArgs(),
DrawFunscriptHeatmapRange: config.GetDrawFunscriptHeatmapRange(),
} }
} }

View File

@@ -69,11 +69,12 @@ const (
ParallelTasks = "parallel_tasks" ParallelTasks = "parallel_tasks"
parallelTasksDefault = 1 parallelTasksDefault = 1
PreviewPreset = "preview_preset"
TranscodeHardwareAcceleration = "ffmpeg.hardware_acceleration"
SequentialScanning = "sequential_scanning" SequentialScanning = "sequential_scanning"
SequentialScanningDefault = false SequentialScanningDefault = false
PreviewPreset = "preview_preset"
PreviewAudio = "preview_audio" PreviewAudio = "preview_audio"
previewAudioDefault = true previewAudioDefault = true
@@ -803,6 +804,10 @@ func (i *Instance) GetPreviewPreset() models.PreviewPreset {
return models.PreviewPreset(ret) return models.PreviewPreset(ret)
} }
func (i *Instance) GetTranscodeHardwareAcceleration() bool {
return i.getBool(TranscodeHardwareAcceleration)
}
func (i *Instance) GetMaxTranscodeSize() models.StreamingResolutionEnum { func (i *Instance) GetMaxTranscodeSize() models.StreamingResolutionEnum {
ret := i.getString(MaxTranscodeSize) ret := i.getString(MaxTranscodeSize)

View File

@@ -110,7 +110,7 @@ type Manager struct {
Paths *paths.Paths Paths *paths.Paths
FFMPEG ffmpeg.FFMpeg FFMPEG *ffmpeg.FFMpeg
FFProbe ffmpeg.FFProbe FFProbe ffmpeg.FFProbe
StreamManager *ffmpeg.StreamManager StreamManager *ffmpeg.StreamManager
@@ -431,8 +431,10 @@ func initFFMPEG(ctx context.Context) error {
} }
} }
instance.FFMPEG = ffmpeg.FFMpeg(ffmpegPath) instance.FFMPEG = ffmpeg.NewEncoder(ffmpegPath)
instance.FFProbe = ffmpeg.FFProbe(ffprobePath) instance.FFProbe = ffmpeg.FFProbe(ffprobePath)
instance.FFMPEG.InitHWSupport(ctx)
instance.RefreshStreamManager() instance.RefreshStreamManager()
} }
@@ -681,7 +683,7 @@ func (s *Manager) Setup(ctx context.Context, input SetupInput) error {
} }
func (s *Manager) validateFFMPEG() error { func (s *Manager) validateFFMPEG() error {
if s.FFMPEG == "" || s.FFProbe == "" { if s.FFMPEG == nil || s.FFProbe == "" {
return errors.New("missing ffmpeg and/or ffprobe") return errors.New("missing ffmpeg and/or ffprobe")
} }

View File

@@ -11,6 +11,7 @@ func (c VideoCodec) Args() []string {
} }
var ( var (
// Software codec's
VideoCodecLibX264 VideoCodec = "libx264" VideoCodecLibX264 VideoCodec = "libx264"
VideoCodecLibWebP VideoCodec = "libwebp" VideoCodecLibWebP VideoCodec = "libwebp"
VideoCodecBMP VideoCodec = "bmp" VideoCodecBMP VideoCodec = "bmp"

View File

@@ -0,0 +1,207 @@
package ffmpeg
import (
"bytes"
"context"
"fmt"
"regexp"
"strings"
"github.com/stashapp/stash/pkg/logger"
)
var (
// Hardware codec's
VideoCodecN264 VideoCodec = "h264_nvenc"
VideoCodecI264 VideoCodec = "h264_qsv"
VideoCodecA264 VideoCodec = "h264_amf"
VideoCodecM264 VideoCodec = "h264_videotoolbox"
VideoCodecV264 VideoCodec = "h264_vaapi"
VideoCodecR264 VideoCodec = "h264_v4l2m2m"
VideoCodecO264 VideoCodec = "h264_omx"
VideoCodecIVP9 VideoCodec = "vp9_qsv"
VideoCodecVVP9 VideoCodec = "vp9_vaapi"
VideoCodecVVPX VideoCodec = "vp8_vaapi"
)
// Tests all (given) hardware codec's
func (f *FFMpeg) InitHWSupport(ctx context.Context) {
var hwCodecSupport []VideoCodec
for _, codec := range []VideoCodec{
VideoCodecN264,
VideoCodecI264,
VideoCodecV264,
VideoCodecR264,
VideoCodecIVP9,
VideoCodecVVP9,
} {
var args Args
args = append(args, "-hide_banner")
args = args.LogLevel(LogLevelWarning)
args = f.hwDeviceInit(args, codec)
args = args.Format("lavfi")
args = args.Input("color=c=red")
args = args.Duration(0.1)
videoFilter := f.hwFilterInit(codec)
// Test scaling
videoFilter = videoFilter.ScaleDimensions(-2, 160)
videoFilter = f.hwCodecFilter(videoFilter, codec)
args = append(args, CodecInit(codec)...)
args = args.VideoFilter(videoFilter)
args = args.Format("null")
args = args.Output("-")
cmd := f.Command(ctx, args)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
logger.Debugf("[InitHWSupport] error starting command: %w", err)
continue
}
if err := cmd.Wait(); err != nil {
errOutput := stderr.String()
if len(errOutput) == 0 {
errOutput = err.Error()
}
logger.Debugf("[InitHWSupport] Codec %s not supported. Error output:\n%s", codec, errOutput)
} else {
hwCodecSupport = append(hwCodecSupport, codec)
}
}
outstr := "[InitHWSupport] Supported HW codecs:\n"
for _, codec := range hwCodecSupport {
outstr += fmt.Sprintf("\t%s\n", codec)
}
logger.Info(outstr)
f.hwCodecSupport = hwCodecSupport
}
// Prepend input for hardware encoding only
func (f *FFMpeg) hwDeviceInit(args Args, codec VideoCodec) Args {
switch codec {
case VideoCodecN264:
args = append(args, "-hwaccel_device")
args = append(args, "0")
case VideoCodecV264,
VideoCodecVVP9:
args = append(args, "-vaapi_device")
args = append(args, "/dev/dri/renderD128")
case VideoCodecI264,
VideoCodecIVP9:
args = append(args, "-init_hw_device")
args = append(args, "qsv=hw")
args = append(args, "-filter_hw_device")
args = append(args, "hw")
}
return args
}
// Initialise a video filter for HW encoding
func (f *FFMpeg) hwFilterInit(codec VideoCodec) VideoFilter {
var videoFilter VideoFilter
switch codec {
case VideoCodecV264,
VideoCodecVVP9:
videoFilter = videoFilter.Append("format=nv12")
videoFilter = videoFilter.Append("hwupload")
case VideoCodecN264:
videoFilter = videoFilter.Append("format=nv12")
videoFilter = videoFilter.Append("hwupload_cuda")
case VideoCodecI264,
VideoCodecIVP9:
videoFilter = videoFilter.Append("hwupload=extra_hw_frames=64")
videoFilter = videoFilter.Append("format=qsv")
}
return videoFilter
}
// Replace video filter scaling with hardware scaling for full hardware transcoding
func (f *FFMpeg) hwCodecFilter(args VideoFilter, codec VideoCodec) VideoFilter {
sargs := string(args)
if strings.Contains(sargs, "scale=") {
switch codec {
case VideoCodecN264:
args = VideoFilter(strings.Replace(sargs, "scale=", "scale_cuda=", 1))
case VideoCodecV264,
VideoCodecVVP9:
args = VideoFilter(strings.Replace(sargs, "scale=", "scale_vaapi=", 1))
case VideoCodecI264,
VideoCodecIVP9:
// BUG: [scale_qsv]: Size values less than -1 are not acceptable.
// Fix: Replace all instances of -2 with -1 in a scale operation
re := regexp.MustCompile(`(scale=)([\d:]*)(-2)(.*)`)
sargs = re.ReplaceAllString(sargs, "scale=$2-1$4")
args = VideoFilter(strings.Replace(sargs, "scale=", "scale_qsv=", 1))
}
}
return args
}
// Returns the max resolution for a given codec, or a default
func (f *FFMpeg) hwCodecMaxRes(codec VideoCodec, dW int, dH int) (int, int) {
if codec == VideoCodecN264 {
return 4096, 4096
}
return dW, dH
}
// Return a maxres filter
func (f *FFMpeg) hwMaxResFilter(codec VideoCodec, width int, height int, max int) VideoFilter {
videoFilter := f.hwFilterInit(codec)
maxWidth, maxHeight := f.hwCodecMaxRes(codec, width, height)
videoFilter = videoFilter.ScaleMaxLM(width, height, max, maxWidth, maxHeight)
return f.hwCodecFilter(videoFilter, codec)
}
// Return if a hardware accelerated for HLS is available
func (f *FFMpeg) hwCodecHLSCompatible() *VideoCodec {
for _, element := range f.hwCodecSupport {
switch element {
case VideoCodecN264,
VideoCodecI264,
VideoCodecV264,
VideoCodecR264:
return &element
}
}
return nil
}
// Return if a hardware accelerated codec for MP4 is available
func (f *FFMpeg) hwCodecMP4Compatible() *VideoCodec {
for _, element := range f.hwCodecSupport {
switch element {
case VideoCodecN264,
VideoCodecI264:
return &element
}
}
return nil
}
// Return if a hardware accelerated codec for WebM is available
func (f *FFMpeg) hwCodecWEBMCompatible() *VideoCodec {
for _, element := range f.hwCodecSupport {
switch element {
case VideoCodecIVP9,
VideoCodecVVP9:
return &element
}
}
return nil
}

View File

@@ -9,9 +9,21 @@ import (
) )
// FFMpeg provides an interface to ffmpeg. // FFMpeg provides an interface to ffmpeg.
type FFMpeg string type FFMpeg struct {
ffmpeg string
hwCodecSupport []VideoCodec
}
// Creates a new FFMpeg encoder
func NewEncoder(ffmpegPath string) *FFMpeg {
ret := &FFMpeg{
ffmpeg: ffmpegPath,
}
return ret
}
// Returns an exec.Cmd that can be used to run ffmpeg using args. // Returns an exec.Cmd that can be used to run ffmpeg using args.
func (f *FFMpeg) Command(ctx context.Context, args []string) *exec.Cmd { func (f *FFMpeg) Command(ctx context.Context, args []string) *exec.Cmd {
return stashExec.CommandContext(ctx, string(*f), args...) return stashExec.CommandContext(ctx, string(f.ffmpeg), args...)
} }

View File

@@ -1,6 +1,8 @@
package ffmpeg package ffmpeg
import "fmt" import (
"fmt"
)
// VideoFilter represents video filter parameters to be passed to ffmpeg. // VideoFilter represents video filter parameters to be passed to ffmpeg.
type VideoFilter string type VideoFilter string
@@ -57,6 +59,35 @@ func (f VideoFilter) ScaleMax(inputWidth, inputHeight, maxSize int) VideoFilter
return f.ScaleDimensions(maxSize, -2) return f.ScaleDimensions(maxSize, -2)
} }
// ScaleMaxLM returns a VideoFilter scaling to maxSize with respect to a max size.
func (f VideoFilter) ScaleMaxLM(width int, height int, reqHeight int, maxWidth int, maxHeight int) VideoFilter {
// calculate the aspect ratio of the current resolution
aspectRatio := width / height
// find the max height
desiredHeight := reqHeight
if desiredHeight == 0 {
desiredHeight = height
}
// calculate the desired width based on the desired height and the aspect ratio
desiredWidth := int(desiredHeight * aspectRatio)
// check which dimension to scale based on the maximum resolution
if desiredHeight > maxHeight || desiredWidth > maxWidth {
if desiredHeight-maxHeight > desiredWidth-maxWidth {
// scale the height down to the maximum height
return f.ScaleDimensions(-2, maxHeight)
} else {
// scale the width down to the maximum width
return f.ScaleDimensions(maxWidth, -2)
}
}
// the current resolution can be scaled to the desired height without exceeding the maximum resolution
return f.ScaleMax(width, height, reqHeight)
}
// Fps returns a VideoFilter setting the frames per second. // Fps returns a VideoFilter setting the frames per second.
func (f VideoFilter) Fps(fps int) VideoFilter { func (f VideoFilter) Fps(fps int) VideoFilter {
return f.Append(fmt.Sprintf("fps=%v", fps)) return f.Append(fmt.Sprintf("fps=%v", fps))

View File

@@ -16,7 +16,7 @@ type FrameInfo struct {
// CalculateFrameRate calculates the frame rate and number of frames of the video file. // 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. // 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) { func (f *FFMpeg) CalculateFrameRate(ctx context.Context, v *VideoFile) (*FrameInfo, error) {
var args Args var args Args
args = append(args, "-nostats") args = append(args, "-nostats")
args = args.Input(v.Path). args = args.Input(v.Path).

View File

@@ -13,7 +13,7 @@ import (
// Generate runs ffmpeg with the given args and waits for it to finish. // 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 // Returns an error if the command fails. If the command fails, the return
// value will be of type *exec.ExitError. // value will be of type *exec.ExitError.
func (f FFMpeg) Generate(ctx context.Context, args Args) error { func (f *FFMpeg) Generate(ctx context.Context, args Args) error {
cmd := f.Command(ctx, args) cmd := f.Command(ctx, args)
var stderr bytes.Buffer var stderr bytes.Buffer
@@ -36,7 +36,7 @@ func (f FFMpeg) Generate(ctx context.Context, args Args) error {
} }
// GenerateOutput runs ffmpeg with the given args and returns it standard output. // GenerateOutput runs ffmpeg with the given args and returns it standard output.
func (f FFMpeg) GenerateOutput(ctx context.Context, args []string, stdin io.Reader) ([]byte, error) { func (f *FFMpeg) GenerateOutput(ctx context.Context, args []string, stdin io.Reader) ([]byte, error) {
cmd := f.Command(ctx, args) cmd := f.Command(ctx, args)
cmd.Stdin = stdin cmd.Stdin = stdin

View File

@@ -22,7 +22,7 @@ const (
type StreamManager struct { type StreamManager struct {
cacheDir string cacheDir string
encoder FFMpeg encoder *FFMpeg
ffprobe FFProbe ffprobe FFProbe
config StreamManagerConfig config StreamManagerConfig
@@ -39,9 +39,10 @@ type StreamManagerConfig interface {
GetMaxStreamingTranscodeSize() models.StreamingResolutionEnum GetMaxStreamingTranscodeSize() models.StreamingResolutionEnum
GetLiveTranscodeInputArgs() []string GetLiveTranscodeInputArgs() []string
GetLiveTranscodeOutputArgs() []string GetLiveTranscodeOutputArgs() []string
GetTranscodeHardwareAcceleration() bool
} }
func NewStreamManager(cacheDir string, encoder FFMpeg, ffprobe FFProbe, config StreamManagerConfig, lockManager *fsutil.ReadLockManager) *StreamManager { func NewStreamManager(cacheDir string, encoder *FFMpeg, ffprobe FFProbe, config StreamManagerConfig, lockManager *fsutil.ReadLockManager) *StreamManager {
if cacheDir == "" { if cacheDir == "" {
logger.Warn("cache directory is not set. Live HLS transcoding will be disabled") logger.Warn("cache directory is not set. Live HLS transcoding will be disabled")
} }

View File

@@ -51,7 +51,7 @@ type StreamType struct {
Name string Name string
SegmentType *SegmentType SegmentType *SegmentType
ServeManifest func(sm *StreamManager, w http.ResponseWriter, r *http.Request, vf *file.VideoFile, resolution string) ServeManifest func(sm *StreamManager, w http.ResponseWriter, r *http.Request, vf *file.VideoFile, resolution string)
Args func(segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) Args Args func(codec VideoCodec, segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) Args
} }
var ( var (
@@ -59,15 +59,11 @@ var (
Name: "hls", Name: "hls",
SegmentType: SegmentTypeTS, SegmentType: SegmentTypeTS,
ServeManifest: serveHLSManifest, ServeManifest: serveHLSManifest,
Args: func(segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) (args Args) { Args: func(codec VideoCodec, segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) (args Args) {
args = CodecInit(codec)
args = append(args, args = append(args,
"-c:v", "libx264",
"-pix_fmt", "yuv420p",
"-preset", "veryfast",
"-crf", "25",
"-flags", "+cgop", "-flags", "+cgop",
"-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%d)", segmentLength), "-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%d)", segmentLength),
"-sc_threshold", "0",
) )
args = args.VideoFilter(videoFilter) args = args.VideoFilter(videoFilter)
if videoOnly { if videoOnly {
@@ -97,10 +93,8 @@ var (
Name: "hls-copy", Name: "hls-copy",
SegmentType: SegmentTypeTS, SegmentType: SegmentTypeTS,
ServeManifest: serveHLSManifest, ServeManifest: serveHLSManifest,
Args: func(segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) (args Args) { Args: func(codec VideoCodec, segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) (args Args) {
args = append(args, args = CodecInit(codec)
"-c:v", "copy",
)
if videoOnly { if videoOnly {
args = append(args, "-an") args = append(args, "-an")
} else { } else {
@@ -128,23 +122,19 @@ var (
Name: "dash-v", Name: "dash-v",
SegmentType: SegmentTypeWEBMVideo, SegmentType: SegmentTypeWEBMVideo,
ServeManifest: serveDASHManifest, ServeManifest: serveDASHManifest,
Args: func(segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) (args Args) { Args: func(codec VideoCodec, segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) (args Args) {
// only generate the actual init segment (init_v.webm) // only generate the actual init segment (init_v.webm)
// when generating the first segment // when generating the first segment
init := ".init" init := ".init"
if segment == 0 { if segment == 0 {
init = "init" init = "init"
} }
args = CodecInit(codec)
args = append(args, args = append(args,
"-c:v", "libvpx-vp9",
"-pix_fmt", "yuv420p",
"-deadline", "realtime",
"-cpu-used", "5",
"-row-mt", "1",
"-crf", "30",
"-b:v", "0",
"-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%d)", segmentLength), "-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%d)", segmentLength),
) )
args = args.VideoFilter(videoFilter) args = args.VideoFilter(videoFilter)
args = append(args, args = append(args,
"-copyts", "-copyts",
@@ -162,7 +152,7 @@ var (
Name: "dash-a", Name: "dash-a",
SegmentType: SegmentTypeWEBMAudio, SegmentType: SegmentTypeWEBMAudio,
ServeManifest: serveDASHManifest, ServeManifest: serveDASHManifest,
Args: func(segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) (args Args) { Args: func(codec VideoCodec, segment int, videoFilter VideoFilter, videoOnly bool, outputDir string) (args Args) {
// only generate the actual init segment (init_a.webm) // only generate the actual init segment (init_a.webm)
// when generating the first segment // when generating the first segment
init := ".init" init := ".init"
@@ -310,6 +300,25 @@ func (t StreamType) FileDir(hash string, maxTranscodeSize int) string {
} }
} }
func HLSGetCodec(sm *StreamManager, name string) (codec VideoCodec) {
switch name {
case "hls":
codec = VideoCodecLibX264
if hwcodec := sm.encoder.hwCodecHLSCompatible(); hwcodec != nil && sm.config.GetTranscodeHardwareAcceleration() {
codec = *hwcodec
}
case "dash-v":
codec = VideoCodecVP9
if hwcodec := sm.encoder.hwCodecWEBMCompatible(); hwcodec != nil && sm.config.GetTranscodeHardwareAcceleration() {
codec = *hwcodec
}
case "hls-copy":
codec = VideoCodecCopy
}
return codec
}
func (s *runningStream) makeStreamArgs(sm *StreamManager, segment int) Args { func (s *runningStream) makeStreamArgs(sm *StreamManager, segment int) Args {
extraInputArgs := sm.config.GetLiveTranscodeInputArgs() extraInputArgs := sm.config.GetLiveTranscodeInputArgs()
extraOutputArgs := sm.config.GetLiveTranscodeOutputArgs() extraOutputArgs := sm.config.GetLiveTranscodeOutputArgs()
@@ -317,6 +326,9 @@ func (s *runningStream) makeStreamArgs(sm *StreamManager, segment int) Args {
args := Args{"-hide_banner"} args := Args{"-hide_banner"}
args = args.LogLevel(LogLevelError) args = args.LogLevel(LogLevelError)
codec := HLSGetCodec(sm, s.streamType.Name)
args = sm.encoder.hwDeviceInit(args, codec)
args = append(args, extraInputArgs...) args = append(args, extraInputArgs...)
if segment > 0 { if segment > 0 {
@@ -327,10 +339,9 @@ func (s *runningStream) makeStreamArgs(sm *StreamManager, segment int) Args {
videoOnly := ProbeAudioCodec(s.vf.AudioCodec) == MissingUnsupported videoOnly := ProbeAudioCodec(s.vf.AudioCodec) == MissingUnsupported
var videoFilter VideoFilter videoFilter := sm.encoder.hwMaxResFilter(codec, s.vf.Width, s.vf.Height, s.maxTranscodeSize)
videoFilter = videoFilter.ScaleMax(s.vf.Width, s.vf.Height, s.maxTranscodeSize)
args = append(args, s.streamType.Args(segment, videoFilter, videoOnly, s.outputDir)...) args = append(args, s.streamType.Args(codec, segment, videoFilter, videoOnly, s.outputDir)...)
args = append(args, extraOutputArgs...) args = append(args, extraOutputArgs...)

View File

@@ -16,20 +16,78 @@ import (
type StreamFormat struct { type StreamFormat struct {
MimeType string MimeType string
Args func(videoFilter VideoFilter, videoOnly bool) Args Args func(codec VideoCodec, videoFilter VideoFilter, videoOnly bool) Args
}
func CodecInit(codec VideoCodec) (args Args) {
args = args.VideoCodec(codec)
switch codec {
// CPU Codecs
case VideoCodecLibX264:
args = append(args,
"-pix_fmt", "yuv420p",
"-preset", "veryfast",
"-crf", "25",
"-sc_threshold", "0",
)
case VideoCodecVP9:
args = append(args,
"-pix_fmt", "yuv420p",
"-deadline", "realtime",
"-cpu-used", "5",
"-row-mt", "1",
"-crf", "30",
"-b:v", "0",
)
// HW Codecs
case VideoCodecN264:
args = append(args,
"-rc", "vbr",
"-cq", "15",
)
case VideoCodecI264:
args = append(args,
"-global_quality", "20",
"-preset", "faster",
)
case VideoCodecV264:
args = append(args,
"-qp", "20",
)
case VideoCodecA264:
args = append(args,
"-quality", "speed",
)
case VideoCodecM264:
args = append(args,
"-prio_speed", "1",
)
case VideoCodecO264:
args = append(args,
"-preset", "superfast",
"-crf", "25",
)
case VideoCodecIVP9:
args = append(args,
"-global_quality", "20",
"-preset", "faster",
)
case VideoCodecVVP9:
args = append(args,
"-qp", "20",
)
}
return args
} }
var ( var (
StreamTypeMP4 = StreamFormat{ StreamTypeMP4 = StreamFormat{
MimeType: MimeMp4Video, MimeType: MimeMp4Video,
Args: func(videoFilter VideoFilter, videoOnly bool) (args Args) { Args: func(codec VideoCodec, videoFilter VideoFilter, videoOnly bool) (args Args) {
args = args.VideoCodec(VideoCodecLibX264) args = CodecInit(codec)
args = append(args, args = append(args, "-movflags", "frag_keyframe+empty_moov")
"-movflags", "frag_keyframe+empty_moov",
"-pix_fmt", "yuv420p",
"-preset", "veryfast",
"-crf", "25",
)
args = args.VideoFilter(videoFilter) args = args.VideoFilter(videoFilter)
if videoOnly { if videoOnly {
args = args.SkipAudio() args = args.SkipAudio()
@@ -42,16 +100,8 @@ var (
} }
StreamTypeWEBM = StreamFormat{ StreamTypeWEBM = StreamFormat{
MimeType: MimeWebmVideo, MimeType: MimeWebmVideo,
Args: func(videoFilter VideoFilter, videoOnly bool) (args Args) { Args: func(codec VideoCodec, videoFilter VideoFilter, videoOnly bool) (args Args) {
args = args.VideoCodec(VideoCodecVP9) args = CodecInit(codec)
args = append(args,
"-pix_fmt", "yuv420p",
"-deadline", "realtime",
"-cpu-used", "5",
"-row-mt", "1",
"-crf", "30",
"-b:v", "0",
)
args = args.VideoFilter(videoFilter) args = args.VideoFilter(videoFilter)
if videoOnly { if videoOnly {
args = args.SkipAudio() args = args.SkipAudio()
@@ -64,8 +114,8 @@ var (
} }
StreamTypeMKV = StreamFormat{ StreamTypeMKV = StreamFormat{
MimeType: MimeMkvVideo, MimeType: MimeMkvVideo,
Args: func(videoFilter VideoFilter, videoOnly bool) (args Args) { Args: func(codec VideoCodec, videoFilter VideoFilter, videoOnly bool) (args Args) {
args = args.VideoCodec(VideoCodecCopy) args = CodecInit(codec)
if videoOnly { if videoOnly {
args = args.SkipAudio() args = args.SkipAudio()
} else { } else {
@@ -89,6 +139,25 @@ type TranscodeOptions struct {
StartTime float64 StartTime float64
} }
func FileGetCodec(sm *StreamManager, mimetype string) (codec VideoCodec) {
switch mimetype {
case MimeMp4Video:
codec = VideoCodecLibX264
if hwcodec := sm.encoder.hwCodecMP4Compatible(); hwcodec != nil && sm.config.GetTranscodeHardwareAcceleration() {
codec = *hwcodec
}
case MimeWebmVideo:
codec = VideoCodecVP9
if hwcodec := sm.encoder.hwCodecWEBMCompatible(); hwcodec != nil && sm.config.GetTranscodeHardwareAcceleration() {
codec = *hwcodec
}
case MimeMkvVideo:
codec = VideoCodecCopy
}
return codec
}
func (o TranscodeOptions) makeStreamArgs(sm *StreamManager) Args { func (o TranscodeOptions) makeStreamArgs(sm *StreamManager) Args {
maxTranscodeSize := sm.config.GetMaxStreamingTranscodeSize().GetMaxResolution() maxTranscodeSize := sm.config.GetMaxStreamingTranscodeSize().GetMaxResolution()
if o.Resolution != "" { if o.Resolution != "" {
@@ -100,6 +169,9 @@ func (o TranscodeOptions) makeStreamArgs(sm *StreamManager) Args {
args := Args{"-hide_banner"} args := Args{"-hide_banner"}
args = args.LogLevel(LogLevelError) args = args.LogLevel(LogLevelError)
codec := FileGetCodec(sm, o.StreamType.MimeType)
args = sm.encoder.hwDeviceInit(args, codec)
args = append(args, extraInputArgs...) args = append(args, extraInputArgs...)
if o.StartTime != 0 { if o.StartTime != 0 {
@@ -110,10 +182,9 @@ func (o TranscodeOptions) makeStreamArgs(sm *StreamManager) Args {
videoOnly := ProbeAudioCodec(o.VideoFile.AudioCodec) == MissingUnsupported videoOnly := ProbeAudioCodec(o.VideoFile.AudioCodec) == MissingUnsupported
var videoFilter VideoFilter videoFilter := sm.encoder.hwMaxResFilter(codec, o.VideoFile.Width, o.VideoFile.Height, maxTranscodeSize)
videoFilter = videoFilter.ScaleMax(o.VideoFile.Width, o.VideoFile.Height, maxTranscodeSize)
args = append(args, o.StreamType.Args(videoFilter, videoOnly)...) args = append(args, o.StreamType.Args(codec, videoFilter, videoOnly)...)
args = append(args, extraOutputArgs...) args = append(args, extraOutputArgs...)

View File

@@ -23,7 +23,7 @@ const (
rows = 5 rows = 5
) )
func Generate(encoder ffmpeg.FFMpeg, videoFile *file.VideoFile) (*uint64, error) { func Generate(encoder *ffmpeg.FFMpeg, videoFile *file.VideoFile) (*uint64, error) {
sprite, err := generateSprite(encoder, videoFile) sprite, err := generateSprite(encoder, videoFile)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -37,7 +37,7 @@ func Generate(encoder ffmpeg.FFMpeg, videoFile *file.VideoFile) (*uint64, error)
return &hashValue, nil return &hashValue, nil
} }
func generateSpriteScreenshot(encoder ffmpeg.FFMpeg, input string, t float64) (image.Image, error) { func generateSpriteScreenshot(encoder *ffmpeg.FFMpeg, input string, t float64) (image.Image, error) {
options := transcoder.ScreenshotOptions{ options := transcoder.ScreenshotOptions{
Width: screenshotSize, Width: screenshotSize,
OutputPath: "-", OutputPath: "-",
@@ -76,7 +76,7 @@ func combineImages(images []image.Image) image.Image {
return montage return montage
} }
func generateSprite(encoder ffmpeg.FFMpeg, videoFile *file.VideoFile) (image.Image, error) { func generateSprite(encoder *ffmpeg.FFMpeg, videoFile *file.VideoFile) (image.Image, error) {
logger.Infof("[generator] generating phash sprite for %s", videoFile.Path) logger.Infof("[generator] generating phash sprite for %s", videoFile.Path)
// Generate sprite image offset by 5% on each end to avoid intro/outros // Generate sprite image offset by 5% on each end to avoid intro/outros

View File

@@ -32,7 +32,7 @@ type ThumbnailGenerator interface {
} }
type ThumbnailEncoder struct { type ThumbnailEncoder struct {
ffmpeg ffmpeg.FFMpeg ffmpeg *ffmpeg.FFMpeg
vips *vipsEncoder vips *vipsEncoder
} }
@@ -43,7 +43,7 @@ func GetVipsPath() string {
return vipsPath return vipsPath
} }
func NewThumbnailEncoder(ffmpegEncoder ffmpeg.FFMpeg) ThumbnailEncoder { func NewThumbnailEncoder(ffmpegEncoder *ffmpeg.FFMpeg) ThumbnailEncoder {
ret := ThumbnailEncoder{ ret := ThumbnailEncoder{
ffmpeg: ffmpegEncoder, ffmpeg: ffmpegEncoder,
} }

View File

@@ -53,7 +53,7 @@ type FFMpegConfig interface {
} }
type Generator struct { type Generator struct {
Encoder ffmpeg.FFMpeg Encoder *ffmpeg.FFMpeg
FFMpegConfig FFMpegConfig FFMpegConfig FFMpegConfig
LockManager *fsutil.ReadLockManager LockManager *fsutil.ReadLockManager
MarkerPaths MarkerPaths MarkerPaths MarkerPaths

View File

@@ -228,6 +228,14 @@ export const SettingsConfigurationPanel: React.FC = () => {
))} ))}
</SelectSetting> </SelectSetting>
<BooleanSetting
id="hardware-encoding"
headingID="config.general.ffmpeg.hardware_acceleration.heading"
subHeadingID="config.general.ffmpeg.hardware_acceleration.desc"
checked={general.transcodeHardwareAcceleration ?? false}
onChange={(v) => saveGeneral({ transcodeHardwareAcceleration: v })}
/>
<StringListSetting <StringListSetting
id="transcode-input-args" id="transcode-input-args"
headingID="config.general.ffmpeg.transcode.input_args.heading" headingID="config.general.ffmpeg.transcode.input_args.heading"

View File

@@ -1,6 +1,7 @@
##### 💥 Note: The cache directory is now required if using HLS streaming. Please set the cache directory in the System Settings page. ##### 💥 Note: The cache directory is now required if using HLS streaming. Please set the cache directory in the System Settings page.
### ✨ New Features ### ✨ New Features
* Added hardware acceleration support (for a limited number of encoders) for transcoding. ([#3419](https://github.com/stashapp/stash/pull/3419))
* Added support for DASH streaming. ([#3275](https://github.com/stashapp/stash/pull/3275)) * Added support for DASH streaming. ([#3275](https://github.com/stashapp/stash/pull/3275))
* Added configuration option for the maximum number of items in selector drop-downs. ([#3277](https://github.com/stashapp/stash/pull/3277)) * Added configuration option for the maximum number of items in selector drop-downs. ([#3277](https://github.com/stashapp/stash/pull/3277))
* Added configuration option to perform generation operations sequentially after scanning a new video file. ([#3378](https://github.com/stashapp/stash/pull/3378)) * Added configuration option to perform generation operations sequentially after scanning a new video file. ([#3378](https://github.com/stashapp/stash/pull/3378))

View File

@@ -77,6 +77,10 @@ This setting can be used to increase/decrease overall CPU utilisation in two sce
Note: If this is set too high it will decrease overall performance and causes failures (out of memory). Note: If this is set too high it will decrease overall performance and causes failures (out of memory).
## Hardware Accelerated Live Transcoding
Hardware accelerated live transcoding can be enabled by setting the `FFmpeg hardware encoding` setting. Stash outputs the supported hardware encoders to the log file on startup at the Info log level. If a given hardware encoder is not supported, it's error message is logged to the Debug log level for debugging purposes.
## HLS Streaming ## HLS Streaming
If using HLS streaming (such as on Apple devices), the Cache path must be set. This directory is used to store temporary files during the live-transcoding process. The Cache path can be set in the System settings page. If using HLS streaming (such as on Apple devices), the Cache path must be set. This directory is used to store temporary files during the live-transcoding process. The Cache path can be set in the System settings page.

View File

@@ -294,6 +294,10 @@
"heading": "FFmpeg Live Transcode Output Args", "heading": "FFmpeg Live Transcode Output Args",
"desc": "Advanced: Additional arguments to pass to ffmpeg before the output field when live transcoding video." "desc": "Advanced: Additional arguments to pass to ffmpeg before the output field when live transcoding video."
} }
},
"hardware_acceleration": {
"heading": "FFmpeg hardware encoding",
"desc": "Uses available hardware to encode video for live transcoding."
} }
}, },
"funscript_heatmap_draw_range": "Include range in generated heatmaps", "funscript_heatmap_draw_range": "Include range in generated heatmaps",