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 { // #2496 - generate a single preview video for videos shorter than segments * segment duration if videoDuration < options.SegmentDuration*float64(options.Segments) { return g.previewVideoSingle(input, videoDuration, options, fallback) } 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) } } func (g *Generator) previewVideoSingle(input string, videoDuration float64, options PreviewOptions, fallback bool) generateFn { return func(lockCtx *fsutil.LockContext, tmpFn string) error { chunkOptions := previewChunkOptions{ StartTime: 0, Duration: videoDuration, OutputPath: tmpFn, Audio: options.Audio, Preset: options.Preset, } return g.previewVideoChunk(lockCtx, input, chunkOptions, fallback) } } 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) } }