mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 20:34: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:
289
pkg/scene/generate/preview.go
Normal file
289
pkg/scene/generate/preview.go
Normal file
@@ -0,0 +1,289 @@
|
||||
package generate
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/pkg/ffmpeg"
|
||||
"github.com/stashapp/stash/pkg/ffmpeg/transcoder"
|
||||
"github.com/stashapp/stash/pkg/fsutil"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
scenePreviewWidth = 640
|
||||
scenePreviewAudioBitrate = "128k"
|
||||
|
||||
scenePreviewImageFPS = 12
|
||||
|
||||
minSegmentDuration = 0.75
|
||||
)
|
||||
|
||||
type PreviewOptions struct {
|
||||
Segments int
|
||||
SegmentDuration float64
|
||||
ExcludeStart string
|
||||
ExcludeEnd string
|
||||
|
||||
Preset string
|
||||
|
||||
Audio bool
|
||||
}
|
||||
|
||||
func getExcludeValue(videoDuration float64, v string) float64 {
|
||||
if strings.HasSuffix(v, "%") && len(v) > 1 {
|
||||
// proportion of video duration
|
||||
v = v[0 : len(v)-1]
|
||||
prop, _ := strconv.ParseFloat(v, 64)
|
||||
return prop / 100.0 * videoDuration
|
||||
}
|
||||
|
||||
prop, _ := strconv.ParseFloat(v, 64)
|
||||
return prop
|
||||
}
|
||||
|
||||
// getStepSizeAndOffset calculates the step size for preview generation and
|
||||
// the starting offset.
|
||||
//
|
||||
// Step size is calculated based on the duration of the video file, minus the
|
||||
// excluded duration. The offset is based on the ExcludeStart. If the total
|
||||
// excluded duration exceeds the duration of the video, then offset is 0, and
|
||||
// the video duration is used to calculate the step size.
|
||||
func (g PreviewOptions) getStepSizeAndOffset(videoDuration float64) (stepSize float64, offset float64) {
|
||||
excludeStart := getExcludeValue(videoDuration, g.ExcludeStart)
|
||||
excludeEnd := getExcludeValue(videoDuration, g.ExcludeEnd)
|
||||
|
||||
duration := videoDuration
|
||||
if videoDuration > excludeStart+excludeEnd {
|
||||
duration = duration - excludeStart - excludeEnd
|
||||
offset = excludeStart
|
||||
}
|
||||
|
||||
stepSize = duration / float64(g.Segments)
|
||||
return
|
||||
}
|
||||
|
||||
func (g Generator) PreviewVideo(ctx context.Context, input string, videoDuration float64, hash string, options PreviewOptions, fallback bool) error {
|
||||
lockCtx := g.LockManager.ReadLock(ctx, input)
|
||||
defer lockCtx.Cancel()
|
||||
|
||||
output := g.ScenePaths.GetVideoPreviewPath(hash)
|
||||
if !g.Overwrite {
|
||||
if exists, _ := fsutil.FileExists(output); exists {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("[generator] generating video preview for %s", input)
|
||||
|
||||
if err := g.generateFile(lockCtx, g.ScenePaths, mp4Pattern, output, g.previewVideo(input, videoDuration, options, fallback)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Debug("created video preview: ", output)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Generator) previewVideo(input string, videoDuration float64, options PreviewOptions, fallback bool) generateFn {
|
||||
return func(lockCtx *fsutil.LockContext, tmpFn string) error {
|
||||
// a list of tmp files used during the preview generation
|
||||
var tmpFiles []string
|
||||
|
||||
// remove tmpFiles when done
|
||||
defer func() { removeFiles(tmpFiles) }()
|
||||
|
||||
stepSize, offset := options.getStepSizeAndOffset(videoDuration)
|
||||
|
||||
segmentDuration := options.SegmentDuration
|
||||
// TODO - move this out into calling function
|
||||
// a very short duration can create files without a video stream
|
||||
if segmentDuration < minSegmentDuration {
|
||||
segmentDuration = minSegmentDuration
|
||||
logger.Warnf("[generator] Segment duration (%f) too short. Using %f instead.", options.SegmentDuration, minSegmentDuration)
|
||||
}
|
||||
|
||||
for i := 0; i < options.Segments; i++ {
|
||||
chunkFile, err := g.tempFile(g.ScenePaths, mp4Pattern)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generating video preview chunk file: %w", err)
|
||||
}
|
||||
|
||||
tmpFiles = append(tmpFiles, chunkFile.Name())
|
||||
|
||||
time := offset + (float64(i) * stepSize)
|
||||
|
||||
chunkOptions := previewChunkOptions{
|
||||
StartTime: time,
|
||||
Duration: segmentDuration,
|
||||
OutputPath: chunkFile.Name(),
|
||||
Audio: options.Audio,
|
||||
Preset: options.Preset,
|
||||
}
|
||||
|
||||
if err := g.previewVideoChunk(lockCtx, input, chunkOptions, fallback); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// generate concat file based on generated video chunks
|
||||
concatFilePath, err := g.generateConcatFile(tmpFiles)
|
||||
if concatFilePath != "" {
|
||||
tmpFiles = append(tmpFiles, concatFilePath)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return g.previewVideoChunkCombine(lockCtx, concatFilePath, tmpFn)
|
||||
}
|
||||
}
|
||||
|
||||
type previewChunkOptions struct {
|
||||
StartTime float64
|
||||
Duration float64
|
||||
OutputPath string
|
||||
Audio bool
|
||||
Preset string
|
||||
}
|
||||
|
||||
func (g Generator) previewVideoChunk(lockCtx *fsutil.LockContext, fn string, options previewChunkOptions, fallback bool) error {
|
||||
var videoFilter ffmpeg.VideoFilter
|
||||
videoFilter = videoFilter.ScaleWidth(scenePreviewWidth)
|
||||
|
||||
var videoArgs ffmpeg.Args
|
||||
videoArgs = videoArgs.VideoFilter(videoFilter)
|
||||
|
||||
videoArgs = append(videoArgs,
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-profile:v", "high",
|
||||
"-level", "4.2",
|
||||
"-preset", options.Preset,
|
||||
"-crf", "21",
|
||||
"-threads", "4",
|
||||
"-strict", "-2",
|
||||
)
|
||||
|
||||
trimOptions := transcoder.TranscodeOptions{
|
||||
OutputPath: options.OutputPath,
|
||||
StartTime: options.StartTime,
|
||||
Duration: options.Duration,
|
||||
|
||||
XError: !fallback,
|
||||
SlowSeek: fallback,
|
||||
|
||||
VideoCodec: ffmpeg.VideoCodecLibX264,
|
||||
VideoArgs: videoArgs,
|
||||
}
|
||||
|
||||
if options.Audio {
|
||||
var audioArgs ffmpeg.Args
|
||||
audioArgs = audioArgs.AudioBitrate(scenePreviewAudioBitrate)
|
||||
|
||||
trimOptions.AudioCodec = ffmpeg.AudioCodecAAC
|
||||
trimOptions.AudioArgs = audioArgs
|
||||
}
|
||||
|
||||
args := transcoder.Transcode(fn, trimOptions)
|
||||
|
||||
return g.generate(lockCtx, args)
|
||||
}
|
||||
|
||||
func (g Generator) generateConcatFile(chunkFiles []string) (fn string, err error) {
|
||||
concatFile, err := g.ScenePaths.TempFile(txtPattern)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating concat file: %w", err)
|
||||
}
|
||||
defer concatFile.Close()
|
||||
|
||||
w := bufio.NewWriter(concatFile)
|
||||
for _, f := range chunkFiles {
|
||||
// files in concat file should be relative to concat
|
||||
relFile := filepath.Base(f)
|
||||
if _, err := w.WriteString(fmt.Sprintf("file '%s'\n", relFile)); err != nil {
|
||||
return concatFile.Name(), fmt.Errorf("writing concat file: %w", err)
|
||||
}
|
||||
}
|
||||
return concatFile.Name(), w.Flush()
|
||||
}
|
||||
|
||||
func (g Generator) previewVideoChunkCombine(lockCtx *fsutil.LockContext, concatFilePath string, outputPath string) error {
|
||||
spliceOptions := transcoder.SpliceOptions{
|
||||
OutputPath: outputPath,
|
||||
}
|
||||
|
||||
args := transcoder.Splice(concatFilePath, spliceOptions)
|
||||
|
||||
return g.generate(lockCtx, args)
|
||||
}
|
||||
|
||||
func removeFiles(list []string) {
|
||||
for _, f := range list {
|
||||
if err := os.Remove(f); err != nil {
|
||||
logger.Warnf("[generator] Delete error: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PreviewWebp generates a webp file based on the preview video input.
|
||||
// TODO - this should really generate a new webp using chunks.
|
||||
func (g Generator) PreviewWebp(ctx context.Context, input string, hash string) error {
|
||||
lockCtx := g.LockManager.ReadLock(ctx, input)
|
||||
defer lockCtx.Cancel()
|
||||
|
||||
output := g.ScenePaths.GetWebpPreviewPath(hash)
|
||||
if !g.Overwrite {
|
||||
if exists, _ := fsutil.FileExists(output); exists {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
logger.Infof("[generator] generating webp preview for %s", input)
|
||||
|
||||
src := g.ScenePaths.GetVideoPreviewPath(hash)
|
||||
|
||||
if err := g.generateFile(lockCtx, g.ScenePaths, webpPattern, output, g.previewVideoToImage(src)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Debug("created video preview: ", output)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g Generator) previewVideoToImage(input string) generateFn {
|
||||
return func(lockCtx *fsutil.LockContext, tmpFn string) error {
|
||||
var videoFilter ffmpeg.VideoFilter
|
||||
videoFilter = videoFilter.ScaleWidth(scenePreviewWidth)
|
||||
videoFilter = videoFilter.Fps(scenePreviewImageFPS)
|
||||
|
||||
var videoArgs ffmpeg.Args
|
||||
videoArgs = videoArgs.VideoFilter(videoFilter)
|
||||
|
||||
videoArgs = append(videoArgs,
|
||||
"-lossless", "1",
|
||||
"-q:v", "70",
|
||||
"-compression_level", "6",
|
||||
"-preset", "default",
|
||||
"-loop", "0",
|
||||
"-threads", "4",
|
||||
)
|
||||
|
||||
encodeOptions := transcoder.TranscodeOptions{
|
||||
OutputPath: tmpFn,
|
||||
|
||||
VideoCodec: ffmpeg.VideoCodecLibWebP,
|
||||
VideoArgs: videoArgs,
|
||||
}
|
||||
|
||||
args := transcoder.Transcode(input, encodeOptions)
|
||||
|
||||
return g.generate(lockCtx, args)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user