From aacf07feefb6f6b73ad05205241792be335decb9 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 18 Apr 2022 10:50:10 +1000 Subject: [PATCH] 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 --- internal/api/routes_scene.go | 92 +++--- internal/manager/generator.go | 79 +---- internal/manager/generator_phash.go | 99 ------ internal/manager/generator_preview.go | 174 ---------- internal/manager/generator_sprite.go | 85 ++--- internal/manager/log.go | 15 + internal/manager/manager.go | 15 +- internal/manager/manager_tasks.go | 28 -- internal/manager/running_streams.go | 68 +--- internal/manager/scene.go | 6 +- internal/manager/screenshot.go | 20 -- internal/manager/task_export.go | 3 +- internal/manager/task_generate.go | 76 ++++- internal/manager/task_generate_markers.go | 76 ++--- internal/manager/task_generate_phash.go | 13 +- internal/manager/task_generate_preview.go | 68 ++-- internal/manager/task_generate_screenshot.go | 19 +- internal/manager/task_generate_sprite.go | 3 +- internal/manager/task_import.go | 3 +- internal/manager/task_scan.go | 27 +- internal/manager/task_scan_scene.go | 32 +- internal/manager/task_transcode.go | 59 ++-- pkg/ffmpeg/browser.go | 136 ++++++++ pkg/ffmpeg/codec.go | 38 +++ pkg/ffmpeg/container.go | 59 ++++ pkg/ffmpeg/encoder.go | 164 --------- pkg/ffmpeg/encoder_marker.go | 72 ---- pkg/ffmpeg/encoder_scene_preview_chunk.go | 135 -------- pkg/ffmpeg/encoder_screenshot.go | 34 -- pkg/ffmpeg/encoder_sprite_screenshot.go | 67 ---- pkg/ffmpeg/encoder_transcode.go | 111 ------- pkg/ffmpeg/ffmpeg.go | 17 + pkg/ffmpeg/ffprobe.go | 247 +++----------- pkg/ffmpeg/filter.go | 78 +++++ pkg/ffmpeg/format.go | 43 +++ pkg/ffmpeg/frame_rate.go | 76 +++++ pkg/ffmpeg/generate.go | 42 +++ pkg/ffmpeg/hls.go | 5 +- pkg/ffmpeg/image.go | 34 -- pkg/ffmpeg/media_detection.go | 16 +- pkg/ffmpeg/options.go | 178 ++++++++++ pkg/ffmpeg/regex.go | 38 --- pkg/ffmpeg/stream.go | 263 +++++++-------- pkg/ffmpeg/transcoder/image.go | 38 +++ pkg/ffmpeg/transcoder/screenshot.go | 109 ++++++ pkg/ffmpeg/transcoder/splice.go | 67 ++++ pkg/ffmpeg/transcoder/transcode.go | 99 ++++++ pkg/ffmpeg/types.go | 26 +- pkg/fsutil/lock_manager.go | 101 ++++++ pkg/gallery/export.go | 7 +- pkg/gallery/export_test.go | 5 +- pkg/gallery/import_test.go | 5 +- pkg/hash/videophash/phash.go | 103 ++++++ pkg/image/export.go | 7 +- pkg/image/export_test.go | 5 +- pkg/image/thumbnail.go | 36 +- pkg/models/extension_resolution.go | 43 ++- pkg/models/{ => json}/json_time.go | 10 +- pkg/models/jsonschema/gallery.go | 32 +- pkg/models/jsonschema/image.go | 34 +- pkg/models/jsonschema/movie.go | 28 +- pkg/models/jsonschema/performer.go | 5 +- pkg/models/jsonschema/scene.go | 37 ++- pkg/models/jsonschema/scraped.go | 33 +- pkg/models/jsonschema/studio.go | 5 +- pkg/models/jsonschema/tag.go | 16 +- pkg/models/paths/paths_generated.go | 6 + pkg/models/paths/paths_scene_markers.go | 19 +- pkg/models/paths/paths_scenes.go | 27 +- pkg/movie/export.go | 5 +- pkg/movie/export_test.go | 9 +- pkg/performer/export.go | 5 +- pkg/performer/export_test.go | 9 +- pkg/scene/delete.go | 10 +- pkg/scene/export.go | 11 +- pkg/scene/export_test.go | 17 +- pkg/scene/generate/generator.go | 146 ++++++++ pkg/scene/generate/marker_preview.go | 187 +++++++++++ pkg/scene/generate/preview.go | 289 ++++++++++++++++ pkg/scene/generate/screenshot.go | 101 ++++++ pkg/scene/generate/sprite.go | 330 +++++++++++++++++++ pkg/scene/generate/transcode.go | 167 ++++++++++ pkg/scene/migrate_hash.go | 8 +- pkg/scene/scan.go | 60 +++- pkg/scene/screenshot.go | 18 +- pkg/studio/export.go | 5 +- pkg/studio/export_test.go | 9 +- pkg/tag/export.go | 5 +- pkg/tag/export_test.go | 5 +- 89 files changed, 3208 insertions(+), 2004 deletions(-) delete mode 100644 internal/manager/generator_phash.go delete mode 100644 internal/manager/generator_preview.go create mode 100644 internal/manager/log.go delete mode 100644 internal/manager/screenshot.go create mode 100644 pkg/ffmpeg/browser.go create mode 100644 pkg/ffmpeg/codec.go create mode 100644 pkg/ffmpeg/container.go delete mode 100644 pkg/ffmpeg/encoder.go delete mode 100644 pkg/ffmpeg/encoder_marker.go delete mode 100644 pkg/ffmpeg/encoder_scene_preview_chunk.go delete mode 100644 pkg/ffmpeg/encoder_screenshot.go delete mode 100644 pkg/ffmpeg/encoder_sprite_screenshot.go delete mode 100644 pkg/ffmpeg/encoder_transcode.go create mode 100644 pkg/ffmpeg/ffmpeg.go create mode 100644 pkg/ffmpeg/filter.go create mode 100644 pkg/ffmpeg/format.go create mode 100644 pkg/ffmpeg/frame_rate.go create mode 100644 pkg/ffmpeg/generate.go delete mode 100644 pkg/ffmpeg/image.go create mode 100644 pkg/ffmpeg/options.go delete mode 100644 pkg/ffmpeg/regex.go create mode 100644 pkg/ffmpeg/transcoder/image.go create mode 100644 pkg/ffmpeg/transcoder/screenshot.go create mode 100644 pkg/ffmpeg/transcoder/splice.go create mode 100644 pkg/ffmpeg/transcoder/transcode.go create mode 100644 pkg/fsutil/lock_manager.go create mode 100644 pkg/hash/videophash/phash.go rename pkg/models/{ => json}/json_time.go (77%) create mode 100644 pkg/scene/generate/generator.go create mode 100644 pkg/scene/generate/marker_preview.go create mode 100644 pkg/scene/generate/preview.go create mode 100644 pkg/scene/generate/screenshot.go create mode 100644 pkg/scene/generate/sprite.go create mode 100644 pkg/scene/generate/transcode.go diff --git a/internal/api/routes_scene.go b/internal/api/routes_scene.go index c638b7407..61e517252 100644 --- a/internal/api/routes_scene.go +++ b/internal/api/routes_scene.go @@ -54,25 +54,6 @@ func (rs sceneRoutes) Routes() chi.Router { // region Handlers -func getSceneFileContainer(scene *models.Scene) ffmpeg.Container { - var container ffmpeg.Container - if scene.Format.Valid { - container = ffmpeg.Container(scene.Format.String) - } else { // container isn't in the DB - // shouldn't happen, fallback to ffprobe - ffprobe := manager.GetInstance().FFProbe - tmpVideoFile, err := ffprobe.NewVideoFile(scene.Path, false) - if err != nil { - logger.Errorf("[transcode] error reading video file: %v", err) - return ffmpeg.Container("") - } - - container = ffmpeg.MatchContainer(tmpVideoFile.Container, scene.Path) - } - - return container -} - func (rs sceneRoutes) StreamDirect(w http.ResponseWriter, r *http.Request) { scene := r.Context().Value(sceneKey).(*models.Scene) @@ -86,7 +67,11 @@ func (rs sceneRoutes) StreamMKV(w http.ResponseWriter, r *http.Request) { // only allow mkv streaming if the scene container is an mkv already scene := r.Context().Value(sceneKey).(*models.Scene) - container := getSceneFileContainer(scene) + container, err := manager.GetSceneFileContainer(scene) + if err != nil { + logger.Errorf("[transcode] error getting container: %v", err) + } + if container != ffmpeg.Matroska { w.WriteHeader(http.StatusBadRequest) if _, err := w.Write([]byte("not an mkv file")); err != nil { @@ -95,22 +80,22 @@ func (rs sceneRoutes) StreamMKV(w http.ResponseWriter, r *http.Request) { return } - rs.streamTranscode(w, r, ffmpeg.CodecMKVAudio) + rs.streamTranscode(w, r, ffmpeg.StreamFormatMKVAudio) } func (rs sceneRoutes) StreamWebM(w http.ResponseWriter, r *http.Request) { - rs.streamTranscode(w, r, ffmpeg.CodecVP9) + rs.streamTranscode(w, r, ffmpeg.StreamFormatVP9) } func (rs sceneRoutes) StreamMp4(w http.ResponseWriter, r *http.Request) { - rs.streamTranscode(w, r, ffmpeg.CodecH264) + rs.streamTranscode(w, r, ffmpeg.StreamFormatH264) } func (rs sceneRoutes) StreamHLS(w http.ResponseWriter, r *http.Request) { scene := r.Context().Value(sceneKey).(*models.Scene) ffprobe := manager.GetInstance().FFProbe - videoFile, err := ffprobe.NewVideoFile(scene.Path, false) + videoFile, err := ffprobe.NewVideoFile(scene.Path) if err != nil { logger.Errorf("[stream] error reading video file: %v", err) return @@ -122,7 +107,7 @@ func (rs sceneRoutes) StreamHLS(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", ffmpeg.MimeHLS) var str strings.Builder - ffmpeg.WriteHLSPlaylist(*videoFile, r.URL.String(), &str) + ffmpeg.WriteHLSPlaylist(videoFile.Duration, r.URL.String(), &str) requestByteRange := createByteRange(r.Header.Get("Range")) if requestByteRange.RawString != "" { @@ -139,45 +124,50 @@ func (rs sceneRoutes) StreamHLS(w http.ResponseWriter, r *http.Request) { } func (rs sceneRoutes) StreamTS(w http.ResponseWriter, r *http.Request) { - rs.streamTranscode(w, r, ffmpeg.CodecHLS) + rs.streamTranscode(w, r, ffmpeg.StreamFormatHLS) } -func (rs sceneRoutes) streamTranscode(w http.ResponseWriter, r *http.Request, videoCodec ffmpeg.Codec) { - logger.Debugf("Streaming as %s", videoCodec.MimeType) +func (rs sceneRoutes) streamTranscode(w http.ResponseWriter, r *http.Request, streamFormat ffmpeg.StreamFormat) { + logger.Debugf("Streaming as %s", streamFormat.MimeType) scene := r.Context().Value(sceneKey).(*models.Scene) - // needs to be transcoded - ffprobe := manager.GetInstance().FFProbe - videoFile, err := ffprobe.NewVideoFile(scene.Path, false) - if err != nil { - logger.Errorf("[stream] error reading video file: %v", err) - return - } - // start stream based on query param, if provided - if err = r.ParseForm(); err != nil { + if err := r.ParseForm(); err != nil { logger.Warnf("[stream] error parsing query form: %v", err) } startTime := r.Form.Get("start") + ss, _ := strconv.ParseFloat(startTime, 64) requestedSize := r.Form.Get("resolution") - var stream *ffmpeg.Stream - audioCodec := ffmpeg.MissingUnsupported if scene.AudioCodec.Valid { - audioCodec = ffmpeg.AudioCodec(scene.AudioCodec.String) + audioCodec = ffmpeg.ProbeAudioCodec(scene.AudioCodec.String) + } + + options := ffmpeg.TranscodeStreamOptions{ + Input: scene.Path, + Codec: streamFormat, + VideoOnly: audioCodec == ffmpeg.MissingUnsupported, + + VideoWidth: int(scene.Width.Int64), + VideoHeight: int(scene.Height.Int64), + + StartTime: ss, + MaxTranscodeSize: config.GetInstance().GetMaxStreamingTranscodeSize().GetMaxResolution(), } - options := ffmpeg.GetTranscodeStreamOptions(*videoFile, videoCodec, audioCodec) - options.StartTime = startTime - options.MaxTranscodeSize = config.GetInstance().GetMaxStreamingTranscodeSize() if requestedSize != "" { - options.MaxTranscodeSize = models.StreamingResolutionEnum(requestedSize) + options.MaxTranscodeSize = models.StreamingResolutionEnum(requestedSize).GetMaxResolution() } encoder := manager.GetInstance().FFMPEG - stream, err = encoder.GetTranscodeStream(options) + + lm := manager.GetInstance().ReadLockManager + lockCtx := lm.ReadLock(r.Context(), scene.Path) + defer lockCtx.Cancel() + + stream, err := encoder.GetTranscodeStream(lockCtx, options) if err != nil { logger.Errorf("[stream] error transcoding video file: %v", err) @@ -188,6 +178,8 @@ func (rs sceneRoutes) streamTranscode(w http.ResponseWriter, r *http.Request, vi return } + lockCtx.AttachCommand(stream.Cmd) + stream.Serve(w, r) } @@ -202,7 +194,7 @@ func (rs sceneRoutes) Screenshot(w http.ResponseWriter, r *http.Request) { func (rs sceneRoutes) Preview(w http.ResponseWriter, r *http.Request) { scene := r.Context().Value(sceneKey).(*models.Scene) - filepath := manager.GetInstance().Paths.Scene.GetStreamPreviewPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())) + filepath := manager.GetInstance().Paths.Scene.GetVideoPreviewPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())) serveFileNoCache(w, r, filepath) } @@ -216,7 +208,7 @@ func serveFileNoCache(w http.ResponseWriter, r *http.Request, filepath string) { func (rs sceneRoutes) Webp(w http.ResponseWriter, r *http.Request) { scene := r.Context().Value(sceneKey).(*models.Scene) - filepath := manager.GetInstance().Paths.Scene.GetStreamPreviewImagePath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())) + filepath := manager.GetInstance().Paths.Scene.GetWebpPreviewPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm())) http.ServeFile(w, r, filepath) } @@ -324,7 +316,7 @@ func (rs sceneRoutes) SceneMarkerStream(w http.ResponseWriter, r *http.Request) return } - filepath := manager.GetInstance().Paths.SceneMarkers.GetStreamPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds)) + filepath := manager.GetInstance().Paths.SceneMarkers.GetVideoPreviewPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds)) http.ServeFile(w, r, filepath) } @@ -347,7 +339,7 @@ func (rs sceneRoutes) SceneMarkerPreview(w http.ResponseWriter, r *http.Request) return } - filepath := manager.GetInstance().Paths.SceneMarkers.GetStreamPreviewImagePath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds)) + filepath := manager.GetInstance().Paths.SceneMarkers.GetWebpPreviewPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds)) // If the image doesn't exist, send the placeholder exists, _ := fsutil.FileExists(filepath) @@ -380,7 +372,7 @@ func (rs sceneRoutes) SceneMarkerScreenshot(w http.ResponseWriter, r *http.Reque return } - filepath := manager.GetInstance().Paths.SceneMarkers.GetStreamScreenshotPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds)) + filepath := manager.GetInstance().Paths.SceneMarkers.GetScreenshotPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds)) // If the image doesn't exist, send the placeholder exists, _ := fsutil.FileExists(filepath) diff --git a/internal/manager/generator.go b/internal/manager/generator.go index 7e0514b43..0b14941e2 100644 --- a/internal/manager/generator.go +++ b/internal/manager/generator.go @@ -1,20 +1,17 @@ package manager import ( - "bytes" + "context" "fmt" "math" - "runtime" "strconv" - "strings" - "github.com/stashapp/stash/pkg/exec" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" ) -type GeneratorInfo struct { +type generatorInfo struct { ChunkCount int FrameRate float64 NumberOfFrames int @@ -22,27 +19,21 @@ type GeneratorInfo struct { // NthFrame used for sprite generation NthFrame int - ChunkDuration float64 - ExcludeStart string - ExcludeEnd string - VideoFile ffmpeg.VideoFile - - Audio bool // used for preview generation } -func newGeneratorInfo(videoFile ffmpeg.VideoFile) (*GeneratorInfo, error) { +func newGeneratorInfo(videoFile ffmpeg.VideoFile) (*generatorInfo, error) { exists, err := fsutil.FileExists(videoFile.Path) if !exists { logger.Errorf("video file not found") return nil, err } - generator := &GeneratorInfo{VideoFile: videoFile} + generator := &generatorInfo{VideoFile: videoFile} return generator, nil } -func (g *GeneratorInfo) calculateFrameRate(videoStream *ffmpeg.FFProbeStream) error { +func (g *generatorInfo) calculateFrameRate(videoStream *ffmpeg.FFProbeStream) error { var framerate float64 if g.VideoFile.FrameRate == 0 { framerate, _ = strconv.ParseFloat(videoStream.RFrameRate, 64) @@ -58,30 +49,15 @@ func (g *GeneratorInfo) calculateFrameRate(videoStream *ffmpeg.FFProbeStream) er // If we are missing the frame count or frame rate then seek through the file and extract the info with regex if numberOfFrames == 0 || !isValidFloat64(framerate) { - args := []string{ - "-nostats", - "-i", g.VideoFile.Path, - "-vcodec", "copy", - "-f", "rawvideo", - "-y", - } - if runtime.GOOS == "windows" { - args = append(args, "nul") // https://stackoverflow.com/questions/313111/is-there-a-dev-null-on-windows + info, err := instance.FFMPEG.CalculateFrameRate(context.TODO(), &g.VideoFile) + if err != nil { + logger.Errorf("error calculating frame rate: %v", err) } else { - args = append(args, "/dev/null") - } - - command := exec.Command(string(instance.FFMPEG), args...) - var stdErrBuffer bytes.Buffer - command.Stderr = &stdErrBuffer // Frames go to stderr rather than stdout - if err := command.Run(); err == nil { - stdErrString := stdErrBuffer.String() if numberOfFrames == 0 { - numberOfFrames = ffmpeg.GetFrameFromRegex(stdErrString) + numberOfFrames = info.NumberOfFrames } if !isValidFloat64(framerate) { - time := ffmpeg.GetTimeFromRegex(stdErrString) - framerate = math.Round((float64(numberOfFrames)/time)*100) / 100 + framerate = info.FrameRate } } } @@ -107,7 +83,7 @@ func isValidFloat64(value float64) bool { return !math.IsNaN(value) && value != 0 } -func (g *GeneratorInfo) configure() error { +func (g *generatorInfo) configure() error { videoStream := g.VideoFile.VideoStream if videoStream == nil { return fmt.Errorf("missing video stream") @@ -127,36 +103,3 @@ func (g *GeneratorInfo) configure() error { return nil } - -func (g GeneratorInfo) getExcludeValue(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 * g.VideoFile.Duration - } - - 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 GeneratorInfo) getStepSizeAndOffset() (stepSize float64, offset float64) { - duration := g.VideoFile.Duration - excludeStart := g.getExcludeValue(g.ExcludeStart) - excludeEnd := g.getExcludeValue(g.ExcludeEnd) - - if duration > excludeStart+excludeEnd { - duration = duration - excludeStart - excludeEnd - offset = excludeStart - } - - stepSize = duration / float64(g.ChunkCount) - return -} diff --git a/internal/manager/generator_phash.go b/internal/manager/generator_phash.go deleted file mode 100644 index 5ee5695a5..000000000 --- a/internal/manager/generator_phash.go +++ /dev/null @@ -1,99 +0,0 @@ -package manager - -import ( - "fmt" - "image" - "image/color" - "math" - - "github.com/corona10/goimagehash" - "github.com/disintegration/imaging" - - "github.com/stashapp/stash/pkg/ffmpeg" - "github.com/stashapp/stash/pkg/fsutil" - "github.com/stashapp/stash/pkg/logger" -) - -type PhashGenerator struct { - Info *GeneratorInfo - - VideoChecksum string - Columns int - Rows int -} - -func NewPhashGenerator(videoFile ffmpeg.VideoFile, checksum string) (*PhashGenerator, error) { - exists, err := fsutil.FileExists(videoFile.Path) - if !exists { - return nil, err - } - - generator, err := newGeneratorInfo(videoFile) - if err != nil { - return nil, err - } - - return &PhashGenerator{ - Info: generator, - VideoChecksum: checksum, - Columns: 5, - Rows: 5, - }, nil -} - -func (g *PhashGenerator) Generate() (*uint64, error) { - encoder := instance.FFMPEG - - sprite, err := g.generateSprite(&encoder) - if err != nil { - return nil, err - } - - hash, err := goimagehash.PerceptionHash(sprite) - if err != nil { - return nil, err - } - hashValue := hash.GetHash() - return &hashValue, nil -} - -func (g *PhashGenerator) generateSprite(encoder *ffmpeg.Encoder) (image.Image, error) { - logger.Infof("[generator] generating phash sprite for %s", g.Info.VideoFile.Path) - - // Generate sprite image offset by 5% on each end to avoid intro/outros - chunkCount := g.Columns * g.Rows - offset := 0.05 * g.Info.VideoFile.Duration - stepSize := (0.9 * g.Info.VideoFile.Duration) / float64(chunkCount) - var images []image.Image - for i := 0; i < chunkCount; i++ { - time := offset + (float64(i) * stepSize) - - options := ffmpeg.SpriteScreenshotOptions{ - Time: time, - Width: 160, - } - img, err := encoder.SpriteScreenshot(g.Info.VideoFile, options) - if err != nil { - return nil, err - } - images = append(images, img) - } - - // Combine all of the thumbnails into a sprite image - if len(images) == 0 { - return nil, fmt.Errorf("images slice is empty, failed to generate phash sprite for %s", g.Info.VideoFile.Path) - } - width := images[0].Bounds().Size().X - height := images[0].Bounds().Size().Y - canvasWidth := width * g.Columns - canvasHeight := height * g.Rows - montage := imaging.New(canvasWidth, canvasHeight, color.NRGBA{}) - for index := 0; index < len(images); index++ { - x := width * (index % g.Columns) - y := height * int(math.Floor(float64(index)/float64(g.Rows))) - img := images[index] - montage = imaging.Paste(montage, img, image.Pt(x, y)) - } - - return montage, nil -} diff --git a/internal/manager/generator_preview.go b/internal/manager/generator_preview.go deleted file mode 100644 index 1a3672aba..000000000 --- a/internal/manager/generator_preview.go +++ /dev/null @@ -1,174 +0,0 @@ -package manager - -import ( - "bufio" - "fmt" - "os" - "path/filepath" - - "github.com/stashapp/stash/pkg/ffmpeg" - "github.com/stashapp/stash/pkg/fsutil" - "github.com/stashapp/stash/pkg/logger" -) - -type PreviewGenerator struct { - Info *GeneratorInfo - - VideoChecksum string - VideoFilename string - ImageFilename string - OutputDirectory string - - GenerateVideo bool - GenerateImage bool - - PreviewPreset string - - Overwrite bool -} - -func NewPreviewGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, videoFilename string, imageFilename string, outputDirectory string, generateVideo bool, generateImage bool, previewPreset string) (*PreviewGenerator, error) { - exists, err := fsutil.FileExists(videoFile.Path) - if !exists { - return nil, err - } - generator, err := newGeneratorInfo(videoFile) - if err != nil { - return nil, err - } - generator.ChunkCount = 12 // 12 segments to the preview - - return &PreviewGenerator{ - Info: generator, - VideoChecksum: videoChecksum, - VideoFilename: videoFilename, - ImageFilename: imageFilename, - OutputDirectory: outputDirectory, - GenerateVideo: generateVideo, - GenerateImage: generateImage, - PreviewPreset: previewPreset, - }, nil -} - -func (g *PreviewGenerator) Generate() error { - logger.Infof("[generator] generating scene preview for %s", g.Info.VideoFile.Path) - - if err := g.Info.configure(); err != nil { - return err - } - - encoder := instance.FFMPEG - if g.GenerateVideo { - if err := g.generateVideo(&encoder, false); err != nil { - logger.Warnf("[generator] failed generating scene preview, trying fallback") - if err := g.generateVideo(&encoder, true); err != nil { - return err - } - } - } - if g.GenerateImage { - if err := g.generateImage(&encoder); err != nil { - return err - } - } - return nil -} - -func (g *PreviewGenerator) generateConcatFile() error { - f, err := os.Create(g.getConcatFilePath()) - if err != nil { - return err - } - defer f.Close() - - w := bufio.NewWriter(f) - for i := 0; i < g.Info.ChunkCount; i++ { - num := fmt.Sprintf("%.3d", i) - filename := "preview_" + g.VideoChecksum + "_" + num + ".mp4" - _, _ = w.WriteString(fmt.Sprintf("file '%s'\n", filename)) - } - return w.Flush() -} - -func (g *PreviewGenerator) generateVideo(encoder *ffmpeg.Encoder, fallback bool) error { - outputPath := filepath.Join(g.OutputDirectory, g.VideoFilename) - outputExists, _ := fsutil.FileExists(outputPath) - if !g.Overwrite && outputExists { - return nil - } - err := g.generateConcatFile() - if err != nil { - return err - } - - var tmpFiles []string // a list of tmp files used during the preview generation - tmpFiles = append(tmpFiles, g.getConcatFilePath()) // add concat filename to tmpFiles - defer func() { removeFiles(tmpFiles) }() // remove tmpFiles when done - - stepSize, offset := g.Info.getStepSizeAndOffset() - - durationSegment := g.Info.ChunkDuration - if durationSegment < 0.75 { // a very short duration can create files without a video stream - durationSegment = 0.75 // use 0.75 in that case - logger.Warnf("[generator] Segment duration (%f) too short.Using 0.75 instead.", g.Info.ChunkDuration) - } - - includeAudio := g.Info.Audio - - for i := 0; i < g.Info.ChunkCount; i++ { - time := offset + (float64(i) * stepSize) - num := fmt.Sprintf("%.3d", i) - filename := "preview_" + g.VideoChecksum + "_" + num + ".mp4" - chunkOutputPath := instance.Paths.Generated.GetTmpPath(filename) - tmpFiles = append(tmpFiles, chunkOutputPath) // add chunk filename to tmpFiles - options := ffmpeg.ScenePreviewChunkOptions{ - StartTime: time, - Duration: durationSegment, - Width: 640, - OutputPath: chunkOutputPath, - Audio: includeAudio, - } - if err := encoder.ScenePreviewVideoChunk(g.Info.VideoFile, options, g.PreviewPreset, fallback); err != nil { - return err - } - } - - videoOutputPath := filepath.Join(g.OutputDirectory, g.VideoFilename) - if err := encoder.ScenePreviewVideoChunkCombine(g.Info.VideoFile, g.getConcatFilePath(), videoOutputPath); err != nil { - return err - } - logger.Debug("created video preview: ", videoOutputPath) - return nil -} - -func (g *PreviewGenerator) generateImage(encoder *ffmpeg.Encoder) error { - outputPath := filepath.Join(g.OutputDirectory, g.ImageFilename) - outputExists, _ := fsutil.FileExists(outputPath) - if !g.Overwrite && outputExists { - return nil - } - - videoPreviewPath := filepath.Join(g.OutputDirectory, g.VideoFilename) - tmpOutputPath := instance.Paths.Generated.GetTmpPath(g.ImageFilename) - if err := encoder.ScenePreviewVideoToImage(g.Info.VideoFile, 640, videoPreviewPath, tmpOutputPath); err != nil { - return err - } - if err := fsutil.SafeMove(tmpOutputPath, outputPath); err != nil { - return err - } - logger.Debug("created video preview image: ", outputPath) - - return nil -} - -func (g *PreviewGenerator) getConcatFilePath() string { - return instance.Paths.Generated.GetTmpPath(fmt.Sprintf("files_%s.txt", g.VideoChecksum)) -} - -func removeFiles(list []string) { - for _, f := range list { - if err := os.Remove(f); err != nil { - logger.Warnf("[generator] Delete error: %s", err) - } - } -} diff --git a/internal/manager/generator_sprite.go b/internal/manager/generator_sprite.go index 70622d832..72138b387 100644 --- a/internal/manager/generator_sprite.go +++ b/internal/manager/generator_sprite.go @@ -1,25 +1,22 @@ package manager import ( + "context" "errors" "fmt" "image" - "image/color" "math" - "os" - "path/filepath" - "strings" "github.com/disintegration/imaging" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" - "github.com/stashapp/stash/pkg/utils" + "github.com/stashapp/stash/pkg/scene/generate" ) type SpriteGenerator struct { - Info *GeneratorInfo + Info *generatorInfo VideoChecksum string ImageOutputPath string @@ -29,6 +26,8 @@ type SpriteGenerator struct { SlowSeek bool // use alternate seek function, very slow! Overwrite bool + + g *generate.Generator } func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageOutputPath string, vttOutputPath string, rows int, cols int) (*SpriteGenerator, error) { @@ -49,7 +48,7 @@ func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageO slowSeek = true // do an actual frame count of the file ( number of frames = read frames) ffprobe := GetInstance().FFProbe - fc, err := ffprobe.GetReadFrameCount(&videoFile) + fc, err := ffprobe.GetReadFrameCount(videoFile.Path) if err == nil { if fc != videoFile.FrameCount { logger.Warnf("[generator] updating framecount (%d) for %s with read frames count (%d)", videoFile.FrameCount, videoFile.Path, fc) @@ -75,22 +74,25 @@ func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageO Rows: rows, SlowSeek: slowSeek, Columns: cols, + g: &generate.Generator{ + Encoder: instance.FFMPEG, + LockManager: instance.ReadLockManager, + ScenePaths: instance.Paths.Scene, + }, }, nil } func (g *SpriteGenerator) Generate() error { - encoder := instance.FFMPEG - - if err := g.generateSpriteImage(&encoder); err != nil { + if err := g.generateSpriteImage(); err != nil { return err } - if err := g.generateSpriteVTT(&encoder); err != nil { + if err := g.generateSpriteVTT(); err != nil { return err } return nil } -func (g *SpriteGenerator) generateSpriteImage(encoder *ffmpeg.Encoder) error { +func (g *SpriteGenerator) generateSpriteImage() error { if !g.Overwrite && g.imageExists() { return nil } @@ -105,13 +107,7 @@ func (g *SpriteGenerator) generateSpriteImage(encoder *ffmpeg.Encoder) error { for i := 0; i < g.Info.ChunkCount; i++ { time := float64(i) * stepSize - options := ffmpeg.SpriteScreenshotOptions{ - Time: time, - Width: 160, - } - - img, err := encoder.SpriteScreenshot(g.Info.VideoFile, options) - + img, err := g.g.SpriteScreenshot(context.TODO(), g.Info.VideoFile.Path, time) if err != nil { return err } @@ -128,11 +124,8 @@ func (g *SpriteGenerator) generateSpriteImage(encoder *ffmpeg.Encoder) error { if frame >= math.MaxInt || frame <= math.MinInt { return errors.New("invalid frame number conversion") } - options := ffmpeg.SpriteScreenshotOptions{ - Frame: int(frame), - Width: 160, - } - img, err := encoder.SpriteScreenshotSlow(g.Info.VideoFile, options) + + img, err := g.g.SpriteScreenshotSlow(context.TODO(), g.Info.VideoFile.Path, int(frame)) if err != nil { return err } @@ -144,41 +137,16 @@ func (g *SpriteGenerator) generateSpriteImage(encoder *ffmpeg.Encoder) error { if len(images) == 0 { return fmt.Errorf("images slice is empty, failed to generate sprite images for %s", g.Info.VideoFile.Path) } - // Combine all of the thumbnails into a sprite image - width := images[0].Bounds().Size().X - height := images[0].Bounds().Size().Y - canvasWidth := width * g.Columns - canvasHeight := height * g.Rows - montage := imaging.New(canvasWidth, canvasHeight, color.NRGBA{}) - for index := 0; index < len(images); index++ { - x := width * (index % g.Columns) - y := height * int(math.Floor(float64(index)/float64(g.Rows))) - img := images[index] - montage = imaging.Paste(montage, img, image.Pt(x, y)) - } - return imaging.Save(montage, g.ImageOutputPath) + return imaging.Save(g.g.CombineSpriteImages(images), g.ImageOutputPath) } -func (g *SpriteGenerator) generateSpriteVTT(encoder *ffmpeg.Encoder) error { +func (g *SpriteGenerator) generateSpriteVTT() error { if !g.Overwrite && g.vttExists() { return nil } logger.Infof("[generator] generating sprite vtt for %s", g.Info.VideoFile.Path) - spriteImage, err := os.Open(g.ImageOutputPath) - if err != nil { - return err - } - defer spriteImage.Close() - spriteImageName := filepath.Base(g.ImageOutputPath) - image, _, err := image.DecodeConfig(spriteImage) - if err != nil { - return err - } - width := image.Width / g.Columns - height := image.Height / g.Rows - var stepSize float64 if !g.SlowSeek { stepSize = float64(g.Info.NthFrame) / g.Info.FrameRate @@ -189,20 +157,7 @@ func (g *SpriteGenerator) generateSpriteVTT(encoder *ffmpeg.Encoder) error { stepSize /= g.Info.FrameRate } - vttLines := []string{"WEBVTT", ""} - for index := 0; index < g.Info.ChunkCount; index++ { - x := width * (index % g.Columns) - y := height * int(math.Floor(float64(index)/float64(g.Rows))) - startTime := utils.GetVTTTime(float64(index) * stepSize) - endTime := utils.GetVTTTime(float64(index+1) * stepSize) - - vttLines = append(vttLines, startTime+" --> "+endTime) - vttLines = append(vttLines, fmt.Sprintf("%s#xywh=%d,%d,%d,%d", spriteImageName, x, y, width, height)) - vttLines = append(vttLines, "") - } - vtt := strings.Join(vttLines, "\n") - - return os.WriteFile(g.VTTOutputPath, []byte(vtt), 0644) + return g.g.SpriteVTT(context.TODO(), g.VTTOutputPath, g.ImageOutputPath, stepSize) } func (g *SpriteGenerator) imageExists() bool { diff --git a/internal/manager/log.go b/internal/manager/log.go new file mode 100644 index 000000000..e2f622c1a --- /dev/null +++ b/internal/manager/log.go @@ -0,0 +1,15 @@ +package manager + +import ( + "errors" + "os/exec" + + "github.com/stashapp/stash/pkg/logger" +) + +func logErrorOutput(err error) { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + logger.Errorf("command stderr: %v", string(exitErr.Stderr)) + } +} diff --git a/internal/manager/manager.go b/internal/manager/manager.go index 5fe5b8d68..75a1754dc 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -37,9 +37,11 @@ type singleton struct { Paths *paths.Paths - FFMPEG ffmpeg.Encoder + FFMPEG ffmpeg.FFMpeg FFProbe ffmpeg.FFProbe + ReadLockManager *fsutil.ReadLockManager + SessionStore *session.Store JobManager *job.Manager @@ -77,10 +79,11 @@ func Initialize() *singleton { initProfiling(cfg.GetCPUProfilePath()) instance = &singleton{ - Config: cfg, - Logger: l, - DownloadStore: NewDownloadStore(), - PluginCache: plugin.NewCache(cfg), + Config: cfg, + Logger: l, + ReadLockManager: fsutil.NewReadLockManager(), + DownloadStore: NewDownloadStore(), + PluginCache: plugin.NewCache(cfg), TxnManager: sqlite.NewTransactionManager(), @@ -218,7 +221,7 @@ func initFFMPEG(ctx context.Context) error { } } - instance.FFMPEG = ffmpeg.Encoder(ffmpegPath) + instance.FFMPEG = ffmpeg.FFMpeg(ffmpegPath) instance.FFProbe = ffmpeg.FFProbe(ffprobePath) } diff --git a/internal/manager/manager_tasks.go b/internal/manager/manager_tasks.go index a81614fa2..8796b2f38 100644 --- a/internal/manager/manager_tasks.go +++ b/internal/manager/manager_tasks.go @@ -126,34 +126,6 @@ func (s *singleton) RunSingleTask(ctx context.Context, t Task) int { return s.JobManager.Add(ctx, t.GetDescription(), j) } -func setGeneratePreviewOptionsInput(optionsInput *models.GeneratePreviewOptionsInput) { - config := config.GetInstance() - if optionsInput.PreviewSegments == nil { - val := config.GetPreviewSegments() - optionsInput.PreviewSegments = &val - } - - if optionsInput.PreviewSegmentDuration == nil { - val := config.GetPreviewSegmentDuration() - optionsInput.PreviewSegmentDuration = &val - } - - if optionsInput.PreviewExcludeStart == nil { - val := config.GetPreviewExcludeStart() - optionsInput.PreviewExcludeStart = &val - } - - if optionsInput.PreviewExcludeEnd == nil { - val := config.GetPreviewExcludeEnd() - optionsInput.PreviewExcludeEnd = &val - } - - if optionsInput.PreviewPreset == nil { - val := config.GetPreviewPreset() - optionsInput.PreviewPreset = &val - } -} - func (s *singleton) Generate(ctx context.Context, input models.GenerateMetadataInput) (int, error) { if err := s.validateFFMPEG(); err != nil { return 0, err diff --git a/internal/manager/running_streams.go b/internal/manager/running_streams.go index 1babc1e3a..57b659ffb 100644 --- a/internal/manager/running_streams.go +++ b/internal/manager/running_streams.go @@ -2,51 +2,16 @@ package manager import ( "net/http" - "sync" "github.com/stashapp/stash/internal/manager/config" - "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/utils" ) -var ( - streamingFiles = make(map[string][]*http.ResponseWriter) - streamingFilesMutex = sync.RWMutex{} -) - -func RegisterStream(filepath string, w *http.ResponseWriter) { - streamingFilesMutex.Lock() - streams := streamingFiles[filepath] - streamingFiles[filepath] = append(streams, w) - streamingFilesMutex.Unlock() -} - -func deregisterStream(filepath string, w *http.ResponseWriter) { - streamingFilesMutex.Lock() - defer streamingFilesMutex.Unlock() - streams := streamingFiles[filepath] - - for i, v := range streams { - if v == w { - streamingFiles[filepath] = append(streams[:i], streams[i+1:]...) - return - } - } -} - -func WaitAndDeregisterStream(filepath string, w *http.ResponseWriter, r *http.Request) { - notify := r.Context().Done() - go func() { - <-notify - deregisterStream(filepath, w) - }() -} - func KillRunningStreams(scene *models.Scene, fileNamingAlgo models.HashAlgorithm) { - killRunningStreams(scene.Path) + instance.ReadLockManager.Cancel(scene.Path) sceneHash := scene.GetHash(fileNamingAlgo) @@ -55,32 +20,7 @@ func KillRunningStreams(scene *models.Scene, fileNamingAlgo models.HashAlgorithm } transcodePath := GetInstance().Paths.Scene.GetTranscodePath(sceneHash) - killRunningStreams(transcodePath) -} - -func killRunningStreams(path string) { - ffmpeg.KillRunningEncoders(path) - - streamingFilesMutex.RLock() - streams := streamingFiles[path] - streamingFilesMutex.RUnlock() - - for _, w := range streams { - hj, ok := (*w).(http.Hijacker) - if !ok { - // if we can't close the connection can't really do anything else - logger.Warnf("cannot close running stream for: %s", path) - return - } - - // hijack and close the connection - conn, _, err := hj.Hijack() - if err != nil { - logger.Errorf("cannot close running stream for '%s' due to error: %s", path, err.Error()) - } else { - conn.Close() - } - } + instance.ReadLockManager.Cancel(transcodePath) } type SceneServer struct { @@ -91,9 +31,9 @@ func (s *SceneServer) StreamSceneDirect(scene *models.Scene, w http.ResponseWrit fileNamingAlgo := config.GetInstance().GetVideoFileNamingAlgorithm() filepath := GetInstance().Paths.Scene.GetStreamPath(scene.Path, scene.GetHash(fileNamingAlgo)) - RegisterStream(filepath, &w) + lockCtx := GetInstance().ReadLockManager.ReadLock(r.Context(), filepath) + defer lockCtx.Cancel() http.ServeFile(w, r, filepath) - WaitAndDeregisterStream(filepath, &w, r) } func (s *SceneServer) ServeScreenshot(scene *models.Scene, w http.ResponseWriter, r *http.Request) { diff --git a/internal/manager/scene.go b/internal/manager/scene.go index 677be9908..e0aee54ea 100644 --- a/internal/manager/scene.go +++ b/internal/manager/scene.go @@ -16,12 +16,12 @@ func GetSceneFileContainer(scene *models.Scene) (ffmpeg.Container, error) { } else { // container isn't in the DB // shouldn't happen, fallback to ffprobe ffprobe := GetInstance().FFProbe - tmpVideoFile, err := ffprobe.NewVideoFile(scene.Path, false) + tmpVideoFile, err := ffprobe.NewVideoFile(scene.Path) if err != nil { return ffmpeg.Container(""), fmt.Errorf("error reading video file: %v", err) } - container = ffmpeg.MatchContainer(tmpVideoFile.Container, scene.Path) + return ffmpeg.MatchContainer(tmpVideoFile.Container, scene.Path) } return container, nil @@ -74,7 +74,7 @@ func GetSceneStreamPaths(scene *models.Scene, directStreamURL string, maxStreami // direct stream should only apply when the audio codec is supported audioCodec := ffmpeg.MissingUnsupported if scene.AudioCodec.Valid { - audioCodec = ffmpeg.AudioCodec(scene.AudioCodec.String) + audioCodec = ffmpeg.ProbeAudioCodec(scene.AudioCodec.String) } // don't care if we can't get the container diff --git a/internal/manager/screenshot.go b/internal/manager/screenshot.go deleted file mode 100644 index 9167d1d81..000000000 --- a/internal/manager/screenshot.go +++ /dev/null @@ -1,20 +0,0 @@ -package manager - -import ( - "github.com/stashapp/stash/pkg/ffmpeg" - "github.com/stashapp/stash/pkg/logger" -) - -func makeScreenshot(probeResult ffmpeg.VideoFile, outputPath string, quality int, width int, time float64) { - encoder := instance.FFMPEG - options := ffmpeg.ScreenshotOptions{ - OutputPath: outputPath, - Quality: quality, - Time: time, - Width: width, - } - - if err := encoder.Screenshot(probeResult, options); err != nil { - logger.Warnf("[encoder] failure to generate screenshot: %v", err) - } -} diff --git a/internal/manager/task_export.go b/internal/manager/task_export.go index c94932ed3..32141f3ea 100644 --- a/internal/manager/task_export.go +++ b/internal/manager/task_export.go @@ -18,6 +18,7 @@ import ( "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/paths" "github.com/stashapp/stash/pkg/movie" @@ -1038,7 +1039,7 @@ func (t *ExportTask) ExportScrapedItems(repo models.ReaderRepository) { } newScrapedItemJSON.Studio = studioName - updatedAt := models.JSONTime{Time: scrapedItem.UpdatedAt.Timestamp} // TODO keeping ruby format + updatedAt := json.JSONTime{Time: scrapedItem.UpdatedAt.Timestamp} // TODO keeping ruby format newScrapedItemJSON.UpdatedAt = updatedAt scraped = append(scraped, newScrapedItemJSON) diff --git a/internal/manager/task_generate.go b/internal/manager/task_generate.go index 8299eb74f..3addf7bec 100644 --- a/internal/manager/task_generate.go +++ b/internal/manager/task_generate.go @@ -12,6 +12,7 @@ import ( "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" + "github.com/stashapp/stash/pkg/scene/generate" "github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stashapp/stash/pkg/utils" ) @@ -67,15 +68,23 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) { logger.Error(err.Error()) } + g := &generate.Generator{ + Encoder: instance.FFMPEG, + LockManager: instance.ReadLockManager, + MarkerPaths: instance.Paths.SceneMarkers, + ScenePaths: instance.Paths.Scene, + Overwrite: j.overwrite, + } + if err := j.txnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error { qb := r.Scene() if len(j.input.SceneIDs) == 0 && len(j.input.MarkerIDs) == 0 { - totals = j.queueTasks(ctx, queue) + totals = j.queueTasks(ctx, g, queue) } else { if len(j.input.SceneIDs) > 0 { scenes, err = qb.FindMany(sceneIDs) for _, s := range scenes { - j.queueSceneJobs(ctx, s, queue, &totals) + j.queueSceneJobs(ctx, g, s, queue, &totals) } } @@ -85,7 +94,7 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) { return err } for _, m := range markers { - j.queueMarkerJob(m, queue, &totals) + j.queueMarkerJob(g, m, queue, &totals) } } } @@ -142,7 +151,7 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) { logger.Info(fmt.Sprintf("Generate finished (%s)", elapsed)) } -func (j *GenerateJob) queueTasks(ctx context.Context, queue chan<- Task) totalsGenerate { +func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, queue chan<- Task) totalsGenerate { var totals totalsGenerate const batchSize = 1000 @@ -165,7 +174,7 @@ func (j *GenerateJob) queueTasks(ctx context.Context, queue chan<- Task) totalsG return context.Canceled } - j.queueSceneJobs(ctx, ss, queue, &totals) + j.queueSceneJobs(ctx, g, ss, queue, &totals) } if len(scenes) != batchSize { @@ -185,7 +194,42 @@ func (j *GenerateJob) queueTasks(ctx context.Context, queue chan<- Task) totalsG return totals } -func (j *GenerateJob) queueSceneJobs(ctx context.Context, scene *models.Scene, queue chan<- Task, totals *totalsGenerate) { +func getGeneratePreviewOptions(optionsInput models.GeneratePreviewOptionsInput) generate.PreviewOptions { + config := config.GetInstance() + + ret := generate.PreviewOptions{ + Segments: config.GetPreviewSegments(), + SegmentDuration: config.GetPreviewSegmentDuration(), + ExcludeStart: config.GetPreviewExcludeStart(), + ExcludeEnd: config.GetPreviewExcludeEnd(), + Preset: config.GetPreviewPreset().String(), + Audio: config.GetPreviewAudio(), + } + + if optionsInput.PreviewSegments != nil { + ret.Segments = *optionsInput.PreviewSegments + } + + if optionsInput.PreviewSegmentDuration != nil { + ret.SegmentDuration = *optionsInput.PreviewSegmentDuration + } + + if optionsInput.PreviewExcludeStart != nil { + ret.ExcludeStart = *optionsInput.PreviewExcludeStart + } + + if optionsInput.PreviewExcludeEnd != nil { + ret.ExcludeEnd = *optionsInput.PreviewExcludeEnd + } + + if optionsInput.PreviewPreset != nil { + ret.Preset = optionsInput.PreviewPreset.String() + } + + return ret +} + +func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, scene *models.Scene, queue chan<- Task, totals *totalsGenerate) { if utils.IsTrue(j.input.Sprites) { task := &GenerateSpriteTask{ Scene: *scene, @@ -200,19 +244,21 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, scene *models.Scene, q } } + generatePreviewOptions := j.input.PreviewOptions + if generatePreviewOptions == nil { + generatePreviewOptions = &models.GeneratePreviewOptionsInput{} + } + options := getGeneratePreviewOptions(*generatePreviewOptions) + if utils.IsTrue(j.input.Previews) { - generatePreviewOptions := j.input.PreviewOptions - if generatePreviewOptions == nil { - generatePreviewOptions = &models.GeneratePreviewOptionsInput{} - } - setGeneratePreviewOptionsInput(generatePreviewOptions) task := &GeneratePreviewTask{ Scene: *scene, ImagePreview: utils.IsTrue(j.input.ImagePreviews), - Options: *generatePreviewOptions, + Options: options, Overwrite: j.overwrite, fileNamingAlgorithm: j.fileNamingAlgo, + generator: g, } sceneHash := scene.GetHash(task.fileNamingAlgorithm) @@ -241,6 +287,8 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, scene *models.Scene, q fileNamingAlgorithm: j.fileNamingAlgo, ImagePreview: utils.IsTrue(j.input.MarkerImagePreviews), Screenshot: utils.IsTrue(j.input.MarkerScreenshots), + + generator: g, } markers := task.markersNeeded(ctx) @@ -259,6 +307,7 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, scene *models.Scene, q Overwrite: j.overwrite, Force: forceTranscode, fileNamingAlgorithm: j.fileNamingAlgo, + g: g, } if task.isTranscodeNeeded() { totals.transcodes++ @@ -298,12 +347,13 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, scene *models.Scene, q } } -func (j *GenerateJob) queueMarkerJob(marker *models.SceneMarker, queue chan<- Task, totals *totalsGenerate) { +func (j *GenerateJob) queueMarkerJob(g *generate.Generator, marker *models.SceneMarker, queue chan<- Task, totals *totalsGenerate) { task := &GenerateMarkersTask{ TxnManager: j.txnManager, Marker: marker, Overwrite: j.overwrite, fileNamingAlgorithm: j.fileNamingAlgo, + generator: g, } totals.markers++ totals.tasks++ diff --git a/internal/manager/task_generate_markers.go b/internal/manager/task_generate_markers.go index 285ad0680..3ef53ddd0 100644 --- a/internal/manager/task_generate_markers.go +++ b/internal/manager/task_generate_markers.go @@ -4,12 +4,12 @@ import ( "context" "fmt" "path/filepath" - "strconv" "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/scene/generate" ) type GenerateMarkersTask struct { @@ -21,6 +21,8 @@ type GenerateMarkersTask struct { ImagePreview bool Screenshot bool + + generator *generate.Generator } func (t *GenerateMarkersTask) GetDescription() string { @@ -55,7 +57,7 @@ func (t *GenerateMarkersTask) Start(ctx context.Context) { } ffprobe := instance.FFProbe - videoFile, err := ffprobe.NewVideoFile(t.Scene.Path, false) + videoFile, err := ffprobe.NewVideoFile(t.Scene.Path) if err != nil { logger.Errorf("error reading video file: %s", err.Error()) return @@ -81,7 +83,7 @@ func (t *GenerateMarkersTask) generateSceneMarkers(ctx context.Context) { } ffprobe := instance.FFProbe - videoFile, err := ffprobe.NewVideoFile(t.Scene.Path, false) + videoFile, err := ffprobe.NewVideoFile(t.Scene.Path) if err != nil { logger.Errorf("error reading video file: %s", err.Error()) return @@ -107,62 +109,24 @@ func (t *GenerateMarkersTask) generateMarker(videoFile *ffmpeg.VideoFile, scene sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm) seconds := int(sceneMarker.Seconds) - videoExists := t.videoExists(sceneHash, seconds) - imageExists := !t.ImagePreview || t.imageExists(sceneHash, seconds) - screenshotExists := !t.Screenshot || t.screenshotExists(sceneHash, seconds) + g := t.generator - baseFilename := strconv.Itoa(seconds) - - options := ffmpeg.SceneMarkerOptions{ - ScenePath: scene.Path, - Seconds: seconds, - Width: 640, - Audio: instance.Config.GetPreviewAudio(), + if err := g.MarkerPreviewVideo(context.TODO(), videoFile.Path, sceneHash, seconds, instance.Config.GetPreviewAudio()); err != nil { + logger.Errorf("[generator] failed to generate marker video: %v", err) + logErrorOutput(err) } - encoder := instance.FFMPEG - - if t.Overwrite || !videoExists { - videoFilename := baseFilename + ".mp4" - videoPath := instance.Paths.SceneMarkers.GetStreamPath(sceneHash, seconds) - - options.OutputPath = instance.Paths.Generated.GetTmpPath(videoFilename) // tmp output in case the process ends abruptly - if err := encoder.SceneMarkerVideo(*videoFile, options); err != nil { - logger.Errorf("[generator] failed to generate marker video: %s", err) - } else { - _ = fsutil.SafeMove(options.OutputPath, videoPath) - logger.Debug("created marker video: ", videoPath) + if t.ImagePreview { + if err := g.SceneMarkerWebp(context.TODO(), videoFile.Path, sceneHash, seconds); err != nil { + logger.Errorf("[generator] failed to generate marker image: %v", err) + logErrorOutput(err) } } - if t.ImagePreview && (t.Overwrite || !imageExists) { - imageFilename := baseFilename + ".webp" - imagePath := instance.Paths.SceneMarkers.GetStreamPreviewImagePath(sceneHash, seconds) - - options.OutputPath = instance.Paths.Generated.GetTmpPath(imageFilename) // tmp output in case the process ends abruptly - if err := encoder.SceneMarkerImage(*videoFile, options); err != nil { - logger.Errorf("[generator] failed to generate marker image: %s", err) - } else { - _ = fsutil.SafeMove(options.OutputPath, imagePath) - logger.Debug("created marker image: ", imagePath) - } - } - - if t.Screenshot && (t.Overwrite || !screenshotExists) { - screenshotFilename := baseFilename + ".jpg" - screenshotPath := instance.Paths.SceneMarkers.GetStreamScreenshotPath(sceneHash, seconds) - - screenshotOptions := ffmpeg.ScreenshotOptions{ - OutputPath: instance.Paths.Generated.GetTmpPath(screenshotFilename), // tmp output in case the process ends abruptly - Quality: 2, - Width: videoFile.Width, - Time: float64(seconds), - } - if err := encoder.Screenshot(*videoFile, screenshotOptions); err != nil { - logger.Errorf("[generator] failed to generate marker screenshot: %s", err) - } else { - _ = fsutil.SafeMove(screenshotOptions.OutputPath, screenshotPath) - logger.Debug("created marker screenshot: ", screenshotPath) + if t.Screenshot { + if err := g.SceneMarkerScreenshot(context.TODO(), videoFile.Path, sceneHash, seconds, videoFile.Width); err != nil { + logger.Errorf("[generator] failed to generate marker screenshot: %v", err) + logErrorOutput(err) } } } @@ -212,7 +176,7 @@ func (t *GenerateMarkersTask) videoExists(sceneChecksum string, seconds int) boo return false } - videoPath := instance.Paths.SceneMarkers.GetStreamPath(sceneChecksum, seconds) + videoPath := instance.Paths.SceneMarkers.GetVideoPreviewPath(sceneChecksum, seconds) videoExists, _ := fsutil.FileExists(videoPath) return videoExists @@ -223,7 +187,7 @@ func (t *GenerateMarkersTask) imageExists(sceneChecksum string, seconds int) boo return false } - imagePath := instance.Paths.SceneMarkers.GetStreamPreviewImagePath(sceneChecksum, seconds) + imagePath := instance.Paths.SceneMarkers.GetWebpPreviewPath(sceneChecksum, seconds) imageExists, _ := fsutil.FileExists(imagePath) return imageExists @@ -234,7 +198,7 @@ func (t *GenerateMarkersTask) screenshotExists(sceneChecksum string, seconds int return false } - screenshotPath := instance.Paths.SceneMarkers.GetStreamScreenshotPath(sceneChecksum, seconds) + screenshotPath := instance.Paths.SceneMarkers.GetScreenshotPath(sceneChecksum, seconds) screenshotExists, _ := fsutil.FileExists(screenshotPath) return screenshotExists diff --git a/internal/manager/task_generate_phash.go b/internal/manager/task_generate_phash.go index 899256ed4..880bb7794 100644 --- a/internal/manager/task_generate_phash.go +++ b/internal/manager/task_generate_phash.go @@ -5,6 +5,7 @@ import ( "database/sql" "fmt" + "github.com/stashapp/stash/pkg/hash/videophash" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" ) @@ -26,22 +27,16 @@ func (t *GeneratePhashTask) Start(ctx context.Context) { } ffprobe := instance.FFProbe - videoFile, err := ffprobe.NewVideoFile(t.Scene.Path, false) + videoFile, err := ffprobe.NewVideoFile(t.Scene.Path) if err != nil { logger.Errorf("error reading video file: %s", err.Error()) return } - sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm) - generator, err := NewPhashGenerator(*videoFile, sceneHash) - - if err != nil { - logger.Errorf("error creating phash generator: %s", err.Error()) - return - } - hash, err := generator.Generate() + hash, err := videophash.Generate(instance.FFMPEG, videoFile) if err != nil { logger.Errorf("error generating phash: %s", err.Error()) + logErrorOutput(err) return } diff --git a/internal/manager/task_generate_preview.go b/internal/manager/task_generate_preview.go index 3806fd221..2e39a6d7c 100644 --- a/internal/manager/task_generate_preview.go +++ b/internal/manager/task_generate_preview.go @@ -4,20 +4,22 @@ import ( "context" "fmt" - "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/scene/generate" ) type GeneratePreviewTask struct { Scene models.Scene ImagePreview bool - Options models.GeneratePreviewOptionsInput + Options generate.PreviewOptions Overwrite bool fileNamingAlgorithm models.HashAlgorithm + + generator *generate.Generator } func (t *GeneratePreviewTask) GetDescription() string { @@ -25,43 +27,51 @@ func (t *GeneratePreviewTask) GetDescription() string { } func (t *GeneratePreviewTask) Start(ctx context.Context) { - videoFilename := t.videoFilename() - videoChecksum := t.Scene.GetHash(t.fileNamingAlgorithm) - imageFilename := t.imageFilename() - if !t.Overwrite && !t.required() { return } ffprobe := instance.FFProbe - videoFile, err := ffprobe.NewVideoFile(t.Scene.Path, false) + videoFile, err := ffprobe.NewVideoFile(t.Scene.Path) if err != nil { - logger.Errorf("error reading video file: %s", err.Error()) + logger.Errorf("error reading video file: %v", err) return } - const generateVideo = true - generator, err := NewPreviewGenerator(*videoFile, videoChecksum, videoFilename, imageFilename, instance.Paths.Generated.Screenshots, generateVideo, t.ImagePreview, t.Options.PreviewPreset.String()) + videoChecksum := t.Scene.GetHash(t.fileNamingAlgorithm) - if err != nil { - logger.Errorf("error creating preview generator: %s", err.Error()) + if err := t.generateVideo(videoChecksum, videoFile.Duration); err != nil { + logger.Errorf("error generating preview: %v", err) + logErrorOutput(err) return } - generator.Overwrite = t.Overwrite - // set the preview generation configuration from the global config - generator.Info.ChunkCount = *t.Options.PreviewSegments - generator.Info.ChunkDuration = *t.Options.PreviewSegmentDuration - generator.Info.ExcludeStart = *t.Options.PreviewExcludeStart - generator.Info.ExcludeEnd = *t.Options.PreviewExcludeEnd - generator.Info.Audio = config.GetInstance().GetPreviewAudio() - - if err := generator.Generate(); err != nil { - logger.Errorf("error generating preview: %s", err.Error()) - return + if t.ImagePreview { + if err := t.generateWebp(videoChecksum); err != nil { + logger.Errorf("error generating preview webp: %v", err) + logErrorOutput(err) + } } } +func (t GeneratePreviewTask) generateVideo(videoChecksum string, videoDuration float64) error { + videoFilename := t.Scene.Path + + if err := t.generator.PreviewVideo(context.TODO(), videoFilename, videoDuration, videoChecksum, t.Options, true); err != nil { + logger.Warnf("[generator] failed generating scene preview, trying fallback") + if err := t.generator.PreviewVideo(context.TODO(), videoFilename, videoDuration, videoChecksum, t.Options, true); err != nil { + return err + } + } + + return nil +} + +func (t GeneratePreviewTask) generateWebp(videoChecksum string) error { + videoFilename := t.Scene.Path + return t.generator.PreviewWebp(context.TODO(), videoFilename, videoChecksum) +} + func (t GeneratePreviewTask) required() bool { sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm) videoExists := t.doesVideoPreviewExist(sceneHash) @@ -74,7 +84,7 @@ func (t *GeneratePreviewTask) doesVideoPreviewExist(sceneChecksum string) bool { return false } - videoExists, _ := fsutil.FileExists(instance.Paths.Scene.GetStreamPreviewPath(sceneChecksum)) + videoExists, _ := fsutil.FileExists(instance.Paths.Scene.GetVideoPreviewPath(sceneChecksum)) return videoExists } @@ -83,14 +93,6 @@ func (t *GeneratePreviewTask) doesImagePreviewExist(sceneChecksum string) bool { return false } - imageExists, _ := fsutil.FileExists(instance.Paths.Scene.GetStreamPreviewImagePath(sceneChecksum)) + imageExists, _ := fsutil.FileExists(instance.Paths.Scene.GetWebpPreviewPath(sceneChecksum)) return imageExists } - -func (t *GeneratePreviewTask) videoFilename() string { - return t.Scene.GetHash(t.fileNamingAlgorithm) + ".mp4" -} - -func (t *GeneratePreviewTask) imageFilename() string { - return t.Scene.GetHash(t.fileNamingAlgorithm) + ".webp" -} diff --git a/internal/manager/task_generate_screenshot.go b/internal/manager/task_generate_screenshot.go index fd5c2f517..80ef9e40d 100644 --- a/internal/manager/task_generate_screenshot.go +++ b/internal/manager/task_generate_screenshot.go @@ -10,6 +10,7 @@ import ( "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" + "github.com/stashapp/stash/pkg/scene/generate" ) type GenerateScreenshotTask struct { @@ -22,7 +23,7 @@ type GenerateScreenshotTask struct { func (t *GenerateScreenshotTask) Start(ctx context.Context) { scenePath := t.Scene.Path ffprobe := instance.FFProbe - probeResult, err := ffprobe.NewVideoFile(scenePath, false) + probeResult, err := ffprobe.NewVideoFile(scenePath) if err != nil { logger.Error(err.Error()) @@ -44,7 +45,21 @@ func (t *GenerateScreenshotTask) Start(ctx context.Context) { // which also generates the thumbnail logger.Debugf("Creating screenshot for %s", scenePath) - makeScreenshot(*probeResult, normalPath, 2, probeResult.Width, at) + + g := generate.Generator{ + Encoder: instance.FFMPEG, + LockManager: instance.ReadLockManager, + ScenePaths: instance.Paths.Scene, + Overwrite: true, + } + + if err := g.Screenshot(context.TODO(), probeResult.Path, checksum, probeResult.Width, probeResult.Duration, generate.ScreenshotOptions{ + At: &at, + }); err != nil { + logger.Errorf("Error generating screenshot: %v", err) + logErrorOutput(err) + return + } f, err := os.Open(normalPath) if err != nil { diff --git a/internal/manager/task_generate_sprite.go b/internal/manager/task_generate_sprite.go index cf4ae4cd3..d7cde2c44 100644 --- a/internal/manager/task_generate_sprite.go +++ b/internal/manager/task_generate_sprite.go @@ -25,7 +25,7 @@ func (t *GenerateSpriteTask) Start(ctx context.Context) { } ffprobe := instance.FFProbe - videoFile, err := ffprobe.NewVideoFile(t.Scene.Path, false) + videoFile, err := ffprobe.NewVideoFile(t.Scene.Path) if err != nil { logger.Errorf("error reading video file: %s", err.Error()) return @@ -44,6 +44,7 @@ func (t *GenerateSpriteTask) Start(ctx context.Context) { if err := generator.Generate(); err != nil { logger.Errorf("error generating sprite: %s", err.Error()) + logErrorOutput(err) return } } diff --git a/internal/manager/task_import.go b/internal/manager/task_import.go index 51424a14a..ce6e22366 100644 --- a/internal/manager/task_import.go +++ b/internal/manager/task_import.go @@ -18,6 +18,7 @@ import ( "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/paths" "github.com/stashapp/stash/pkg/movie" @@ -613,7 +614,7 @@ func (t *ImportTask) ImportImages(ctx context.Context) { var currentLocation = time.Now().Location() -func (t *ImportTask) getTimeFromJSONTime(jsonTime models.JSONTime) time.Time { +func (t *ImportTask) getTimeFromJSONTime(jsonTime json.JSONTime) time.Time { if currentLocation != nil { if jsonTime.IsZero() { return time.Now().In(currentLocation) diff --git a/internal/manager/task_scan.go b/internal/manager/task_scan.go index c48949ce5..9f3decec5 100644 --- a/internal/manager/task_scan.go +++ b/internal/manager/task_scan.go @@ -16,6 +16,7 @@ import ( "github.com/stashapp/stash/pkg/job" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/scene/generate" "github.com/stashapp/stash/pkg/utils" ) @@ -318,28 +319,24 @@ func (t *ScanTask) Start(ctx context.Context) { iwg.Add() go t.progress.ExecuteTask(fmt.Sprintf("Generating preview for %s", path), func() { - config := config.GetInstance() - var previewSegmentDuration = config.GetPreviewSegmentDuration() - var previewSegments = config.GetPreviewSegments() - var previewExcludeStart = config.GetPreviewExcludeStart() - var previewExcludeEnd = config.GetPreviewExcludeEnd() - var previewPresent = config.GetPreviewPreset() + options := getGeneratePreviewOptions(models.GeneratePreviewOptionsInput{}) + const overwrite = false - // NOTE: the reuse of this model like this is painful. - previewOptions := models.GeneratePreviewOptionsInput{ - PreviewSegments: &previewSegments, - PreviewSegmentDuration: &previewSegmentDuration, - PreviewExcludeStart: &previewExcludeStart, - PreviewExcludeEnd: &previewExcludeEnd, - PreviewPreset: &previewPresent, + g := &generate.Generator{ + Encoder: instance.FFMPEG, + LockManager: instance.ReadLockManager, + MarkerPaths: instance.Paths.SceneMarkers, + ScenePaths: instance.Paths.Scene, + Overwrite: overwrite, } taskPreview := GeneratePreviewTask{ Scene: *s, ImagePreview: t.GenerateImagePreview, - Options: previewOptions, - Overwrite: false, + Options: options, + Overwrite: overwrite, fileNamingAlgorithm: t.fileNamingAlgorithm, + generator: g, } taskPreview.Start(ctx) iwg.Done() diff --git a/internal/manager/task_scan_scene.go b/internal/manager/task_scan_scene.go index 75db244da..ca65d1e42 100644 --- a/internal/manager/task_scan_scene.go +++ b/internal/manager/task_scan_scene.go @@ -3,12 +3,26 @@ package manager import ( "context" + "github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" + "github.com/stashapp/stash/pkg/scene/generate" ) +type sceneScreenshotter struct { + g *generate.Generator +} + +func (ss *sceneScreenshotter) GenerateScreenshot(ctx context.Context, probeResult *ffmpeg.VideoFile, hash string) error { + return ss.g.Screenshot(ctx, probeResult.Path, hash, probeResult.Width, probeResult.Duration, generate.ScreenshotOptions{}) +} + +func (ss *sceneScreenshotter) GenerateThumbnail(ctx context.Context, probeResult *ffmpeg.VideoFile, hash string) error { + return ss.g.Screenshot(ctx, probeResult.Path, hash, probeResult.Width, probeResult.Duration, generate.ScreenshotOptions{}) +} + func (t *ScanTask) scanScene(ctx context.Context) *models.Scene { logError := func(err error) *models.Scene { logger.Error(err.Error()) @@ -27,17 +41,25 @@ func (t *ScanTask) scanScene(ctx context.Context) *models.Scene { return nil } + g := &generate.Generator{ + Encoder: instance.FFMPEG, + LockManager: instance.ReadLockManager, + ScenePaths: instance.Paths.Scene, + } + scanner := scene.Scanner{ Scanner: scene.FileScanner(&file.FSHasher{}, t.fileNamingAlgorithm, t.calculateMD5), StripFileExtension: t.StripFileExtension, FileNamingAlgorithm: t.fileNamingAlgorithm, TxnManager: t.TxnManager, Paths: GetInstance().Paths, - Screenshotter: &instance.FFMPEG, - VideoFileCreator: &instance.FFProbe, - PluginCache: instance.PluginCache, - MutexManager: t.mutexManager, - UseFileMetadata: t.UseFileMetadata, + Screenshotter: &sceneScreenshotter{ + g: g, + }, + VideoFileCreator: &instance.FFProbe, + PluginCache: instance.PluginCache, + MutexManager: t.mutexManager, + UseFileMetadata: t.UseFileMetadata, } if s != nil { diff --git a/internal/manager/task_transcode.go b/internal/manager/task_transcode.go index a051274dd..a3d24dcde 100644 --- a/internal/manager/task_transcode.go +++ b/internal/manager/task_transcode.go @@ -6,9 +6,9 @@ import ( "github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/pkg/ffmpeg" - "github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/scene/generate" ) type GenerateTranscodeTask struct { @@ -18,6 +18,8 @@ type GenerateTranscodeTask struct { // is true, generate even if video is browser-supported Force bool + + g *generate.Generator } func (t *GenerateTranscodeTask) GetDescription() string { @@ -33,65 +35,60 @@ func (t *GenerateTranscodeTask) Start(ctc context.Context) { ffprobe := instance.FFProbe var container ffmpeg.Container - if t.Scene.Format.Valid { - container = ffmpeg.Container(t.Scene.Format.String) - } else { // container isn't in the DB - // shouldn't happen unless user hasn't scanned after updating to PR#384+ version - tmpVideoFile, err := ffprobe.NewVideoFile(t.Scene.Path, false) - if err != nil { - logger.Errorf("[transcode] error reading video file: %s", err.Error()) - return - } - - container = ffmpeg.MatchContainer(tmpVideoFile.Container, t.Scene.Path) + var err error + container, err = GetSceneFileContainer(&t.Scene) + if err != nil { + logger.Errorf("[transcode] error getting scene container: %s", err.Error()) + return } videoCodec := t.Scene.VideoCodec.String audioCodec := ffmpeg.MissingUnsupported if t.Scene.AudioCodec.Valid { - audioCodec = ffmpeg.AudioCodec(t.Scene.AudioCodec.String) + audioCodec = ffmpeg.ProbeAudioCodec(t.Scene.AudioCodec.String) } - if !t.Force && ffmpeg.IsStreamable(videoCodec, audioCodec, container) { + if !t.Force && ffmpeg.IsStreamable(videoCodec, audioCodec, container) == nil { return } - videoFile, err := ffprobe.NewVideoFile(t.Scene.Path, false) + // TODO - move transcode generation logic elsewhere + + videoFile, err := ffprobe.NewVideoFile(t.Scene.Path) if err != nil { logger.Errorf("[transcode] error reading video file: %s", err.Error()) return } sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm) - outputPath := instance.Paths.Generated.GetTmpPath(sceneHash + ".mp4") transcodeSize := config.GetInstance().GetMaxTranscodeSize() - options := ffmpeg.TranscodeOptions{ - OutputPath: outputPath, - MaxTranscodeSize: transcodeSize, + + w, h := videoFile.TranscodeScale(transcodeSize.GetMaxResolution()) + + options := generate.TranscodeOptions{ + Width: w, + Height: h, } - encoder := instance.FFMPEG if videoCodec == ffmpeg.H264 { // for non supported h264 files stream copy the video part if audioCodec == ffmpeg.MissingUnsupported { - encoder.CopyVideo(*videoFile, options) + err = t.g.TranscodeCopyVideo(context.TODO(), videoFile.Path, sceneHash, options) } else { - encoder.TranscodeAudio(*videoFile, options) + err = t.g.TranscodeAudio(context.TODO(), videoFile.Path, sceneHash, options) } } else { if audioCodec == ffmpeg.MissingUnsupported { - // ffmpeg fails if it trys to transcode an unsupported audio codec - encoder.TranscodeVideo(*videoFile, options) + // ffmpeg fails if it tries to transcode an unsupported audio codec + err = t.g.TranscodeVideo(context.TODO(), videoFile.Path, sceneHash, options) } else { - encoder.Transcode(*videoFile, options) + err = t.g.Transcode(context.TODO(), videoFile.Path, sceneHash, options) } } - if err := fsutil.SafeMove(outputPath, instance.Paths.Scene.GetTranscodePath(sceneHash)); err != nil { - logger.Errorf("[transcode] error generating transcode: %s", err.Error()) + if err != nil { + logger.Errorf("[transcode] error generating transcode: %v", err) return } - - logger.Debugf("[transcode] <%s> created transcode: %s", sceneHash, outputPath) } // return true if transcode is needed @@ -111,14 +108,14 @@ func (t *GenerateTranscodeTask) isTranscodeNeeded() bool { container := "" audioCodec := ffmpeg.MissingUnsupported if t.Scene.AudioCodec.Valid { - audioCodec = ffmpeg.AudioCodec(t.Scene.AudioCodec.String) + audioCodec = ffmpeg.ProbeAudioCodec(t.Scene.AudioCodec.String) } if t.Scene.Format.Valid { container = t.Scene.Format.String } - if ffmpeg.IsStreamable(videoCodec, audioCodec, ffmpeg.Container(container)) { + if ffmpeg.IsStreamable(videoCodec, audioCodec, ffmpeg.Container(container)) == nil { return false } diff --git a/pkg/ffmpeg/browser.go b/pkg/ffmpeg/browser.go new file mode 100644 index 000000000..5e34a5f14 --- /dev/null +++ b/pkg/ffmpeg/browser.go @@ -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 +} diff --git a/pkg/ffmpeg/codec.go b/pkg/ffmpeg/codec.go new file mode 100644 index 000000000..bb7734030 --- /dev/null +++ b/pkg/ffmpeg/codec.go @@ -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" +) diff --git a/pkg/ffmpeg/container.go b/pkg/ffmpeg/container.go new file mode 100644 index 000000000..308666b15 --- /dev/null +++ b/pkg/ffmpeg/container.go @@ -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 +} diff --git a/pkg/ffmpeg/encoder.go b/pkg/ffmpeg/encoder.go deleted file mode 100644 index 97676713f..000000000 --- a/pkg/ffmpeg/encoder.go +++ /dev/null @@ -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 -} diff --git a/pkg/ffmpeg/encoder_marker.go b/pkg/ffmpeg/encoder_marker.go deleted file mode 100644 index 61b02bce8..000000000 --- a/pkg/ffmpeg/encoder_marker.go +++ /dev/null @@ -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 -} diff --git a/pkg/ffmpeg/encoder_scene_preview_chunk.go b/pkg/ffmpeg/encoder_scene_preview_chunk.go deleted file mode 100644 index 1e631859e..000000000 --- a/pkg/ffmpeg/encoder_scene_preview_chunk.go +++ /dev/null @@ -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 -} diff --git a/pkg/ffmpeg/encoder_screenshot.go b/pkg/ffmpeg/encoder_screenshot.go deleted file mode 100644 index 636092ac0..000000000 --- a/pkg/ffmpeg/encoder_screenshot.go +++ /dev/null @@ -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 -} diff --git a/pkg/ffmpeg/encoder_sprite_screenshot.go b/pkg/ffmpeg/encoder_sprite_screenshot.go deleted file mode 100644 index d0068cb2a..000000000 --- a/pkg/ffmpeg/encoder_sprite_screenshot.go +++ /dev/null @@ -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 -} diff --git a/pkg/ffmpeg/encoder_transcode.go b/pkg/ffmpeg/encoder_transcode.go deleted file mode 100644 index 920051b96..000000000 --- a/pkg/ffmpeg/encoder_transcode.go +++ /dev/null @@ -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) -} diff --git a/pkg/ffmpeg/ffmpeg.go b/pkg/ffmpeg/ffmpeg.go new file mode 100644 index 000000000..3a961bbed --- /dev/null +++ b/pkg/ffmpeg/ffmpeg.go @@ -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...) +} diff --git a/pkg/ffmpeg/ffprobe.go b/pkg/ffmpeg/ffprobe.go index 3f454874b..67b1351e6 100644 --- a/pkg/ffmpeg/ffprobe.go +++ b/pkg/ffmpeg/ffprobe.go @@ -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) - } -} diff --git a/pkg/ffmpeg/filter.go b/pkg/ffmpeg/filter.go new file mode 100644 index 000000000..8b9c94122 --- /dev/null +++ b/pkg/ffmpeg/filter.go @@ -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)) +} diff --git a/pkg/ffmpeg/format.go b/pkg/ffmpeg/format.go new file mode 100644 index 000000000..8b7b8e898 --- /dev/null +++ b/pkg/ffmpeg/format.go @@ -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" +) diff --git a/pkg/ffmpeg/frame_rate.go b/pkg/ffmpeg/frame_rate.go new file mode 100644 index 000000000..07585b67e --- /dev/null +++ b/pkg/ffmpeg/frame_rate.go @@ -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 +} diff --git a/pkg/ffmpeg/generate.go b/pkg/ffmpeg/generate.go new file mode 100644 index 000000000..ac27640c7 --- /dev/null +++ b/pkg/ffmpeg/generate.go @@ -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() +} diff --git a/pkg/ffmpeg/hls.go b/pkg/ffmpeg/hls.go index f0f6b5205..f3b421c52 100644 --- a/pkg/ffmpeg/hls.go +++ b/pkg/ffmpeg/hls.go @@ -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 diff --git a/pkg/ffmpeg/image.go b/pkg/ffmpeg/image.go deleted file mode 100644 index 86f199c44..000000000 --- a/pkg/ffmpeg/image.go +++ /dev/null @@ -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 -} diff --git a/pkg/ffmpeg/media_detection.go b/pkg/ffmpeg/media_detection.go index b563cc845..7cb6eaa52 100644 --- a/pkg/ffmpeg/media_detection.go +++ b/pkg/ffmpeg/media_detection.go @@ -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 } diff --git a/pkg/ffmpeg/options.go b/pkg/ffmpeg/options.go new file mode 100644 index 000000000..9846192f4 --- /dev/null +++ b/pkg/ffmpeg/options.go @@ -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" +) diff --git a/pkg/ffmpeg/regex.go b/pkg/ffmpeg/regex.go deleted file mode 100644 index 1fcc4aa78..000000000 --- a/pkg/ffmpeg/regex.go +++ /dev/null @@ -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 -} diff --git a/pkg/ffmpeg/stream.go b/pkg/ffmpeg/stream.go index 6cc71160b..a024f8aa6 100644 --- a/pkg/ffmpeg/stream.go +++ b/pkg/ffmpeg/stream.go @@ -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 diff --git a/pkg/ffmpeg/transcoder/image.go b/pkg/ffmpeg/transcoder/image.go new file mode 100644 index 000000000..b6e7642f1 --- /dev/null +++ b/pkg/ffmpeg/transcoder/image.go @@ -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 +} diff --git a/pkg/ffmpeg/transcoder/screenshot.go b/pkg/ffmpeg/transcoder/screenshot.go new file mode 100644 index 000000000..a1ddef6b6 --- /dev/null +++ b/pkg/ffmpeg/transcoder/screenshot.go @@ -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 +} diff --git a/pkg/ffmpeg/transcoder/splice.go b/pkg/ffmpeg/transcoder/splice.go new file mode 100644 index 000000000..b7978b787 --- /dev/null +++ b/pkg/ffmpeg/transcoder/splice.go @@ -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 +} diff --git a/pkg/ffmpeg/transcoder/transcode.go b/pkg/ffmpeg/transcoder/transcode.go new file mode 100644 index 000000000..8be24c540 --- /dev/null +++ b/pkg/ffmpeg/transcoder/transcode.go @@ -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 +} diff --git a/pkg/ffmpeg/types.go b/pkg/ffmpeg/types.go index d239c6cdf..a816f5171 100644 --- a/pkg/ffmpeg/types.go +++ b/pkg/ffmpeg/types.go @@ -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"` diff --git a/pkg/fsutil/lock_manager.go b/pkg/fsutil/lock_manager.go new file mode 100644 index 000000000..f70266d7a --- /dev/null +++ b/pkg/fsutil/lock_manager.go @@ -0,0 +1,101 @@ +package fsutil + +import ( + "context" + "os/exec" + "sync" + "time" +) + +type LockContext struct { + context.Context + cancel context.CancelFunc + + cmd *exec.Cmd +} + +func (c *LockContext) AttachCommand(cmd *exec.Cmd) { + c.cmd = cmd +} + +func (c *LockContext) Cancel() { + c.cancel() + + if c.cmd != nil { + // wait for the process to die before returning + // don't wait more than a few seconds + done := make(chan error) + go func() { + err := c.cmd.Wait() + done <- err + }() + + select { + case <-done: + return + case <-time.After(5 * time.Second): + return + } + } +} + +// ReadLockManager manages read locks on file paths. +type ReadLockManager struct { + readLocks map[string][]*LockContext + mutex sync.RWMutex +} + +// NewReadLockManager creates a new ReadLockManager. +func NewReadLockManager() *ReadLockManager { + return &ReadLockManager{ + readLocks: make(map[string][]*LockContext), + } +} + +// ReadLock adds a pending file read lock for fn to its storage, returning a context and cancel function. +// Per standard WithCancel usage, cancel must be called when the lock is freed. +func (m *ReadLockManager) ReadLock(ctx context.Context, fn string) *LockContext { + retCtx, cancel := context.WithCancel(ctx) + + m.mutex.Lock() + defer m.mutex.Unlock() + + locks := m.readLocks[fn] + + cc := &LockContext{ + Context: retCtx, + cancel: cancel, + } + m.readLocks[fn] = append(locks, cc) + + go m.waitAndUnlock(fn, cc) + + return cc +} + +func (m *ReadLockManager) waitAndUnlock(fn string, cc *LockContext) { + <-cc.Done() + + m.mutex.Lock() + defer m.mutex.Unlock() + + locks := m.readLocks[fn] + for i, v := range locks { + if v == cc { + m.readLocks[fn] = append(locks[:i], locks[i+1:]...) + return + } + } +} + +// Cancel cancels all read lock contexts associated with fn. +func (m *ReadLockManager) Cancel(fn string) { + m.mutex.RLock() + locks := m.readLocks[fn] + m.mutex.RUnlock() + + for _, l := range locks { + l.Cancel() + <-l.Done() + } +} diff --git a/pkg/gallery/export.go b/pkg/gallery/export.go index f861918cd..f24660e60 100644 --- a/pkg/gallery/export.go +++ b/pkg/gallery/export.go @@ -2,6 +2,7 @@ package gallery import ( "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/utils" ) @@ -12,8 +13,8 @@ func ToBasicJSON(gallery *models.Gallery) (*jsonschema.Gallery, error) { newGalleryJSON := jsonschema.Gallery{ Checksum: gallery.Checksum, Zip: gallery.Zip, - CreatedAt: models.JSONTime{Time: gallery.CreatedAt.Timestamp}, - UpdatedAt: models.JSONTime{Time: gallery.UpdatedAt.Timestamp}, + CreatedAt: json.JSONTime{Time: gallery.CreatedAt.Timestamp}, + UpdatedAt: json.JSONTime{Time: gallery.UpdatedAt.Timestamp}, } if gallery.Path.Valid { @@ -21,7 +22,7 @@ func ToBasicJSON(gallery *models.Gallery) (*jsonschema.Gallery, error) { } if gallery.FileModTime.Valid { - newGalleryJSON.FileModTime = models.JSONTime{Time: gallery.FileModTime.Timestamp} + newGalleryJSON.FileModTime = json.JSONTime{Time: gallery.FileModTime.Timestamp} } if gallery.Title.Valid { diff --git a/pkg/gallery/export_test.go b/pkg/gallery/export_test.go index 81961f9b8..80418d7e0 100644 --- a/pkg/gallery/export_test.go +++ b/pkg/gallery/export_test.go @@ -4,6 +4,7 @@ import ( "errors" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" @@ -79,10 +80,10 @@ func createFullJSONGallery() *jsonschema.Gallery { Rating: rating, Organized: organized, URL: url, - CreatedAt: models.JSONTime{ + CreatedAt: json.JSONTime{ Time: createTime, }, - UpdatedAt: models.JSONTime{ + UpdatedAt: json.JSONTime{ Time: updateTime, }, } diff --git a/pkg/gallery/import_test.go b/pkg/gallery/import_test.go index 4acc8c449..d50fd16d1 100644 --- a/pkg/gallery/import_test.go +++ b/pkg/gallery/import_test.go @@ -6,6 +6,7 @@ import ( "time" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" @@ -65,10 +66,10 @@ func TestImporterPreImport(t *testing.T) { Rating: rating, Organized: organized, URL: url, - CreatedAt: models.JSONTime{ + CreatedAt: json.JSONTime{ Time: createdAt, }, - UpdatedAt: models.JSONTime{ + UpdatedAt: json.JSONTime{ Time: updatedAt, }, }, diff --git a/pkg/hash/videophash/phash.go b/pkg/hash/videophash/phash.go new file mode 100644 index 000000000..6cd96d8bd --- /dev/null +++ b/pkg/hash/videophash/phash.go @@ -0,0 +1,103 @@ +package videophash + +import ( + "bytes" + "context" + "fmt" + "image" + "image/color" + "math" + + "github.com/corona10/goimagehash" + "github.com/disintegration/imaging" + + "github.com/stashapp/stash/pkg/ffmpeg" + "github.com/stashapp/stash/pkg/ffmpeg/transcoder" + "github.com/stashapp/stash/pkg/logger" +) + +const ( + screenshotSize = 160 + columns = 5 + rows = 5 +) + +func Generate(encoder ffmpeg.FFMpeg, videoFile *ffmpeg.VideoFile) (*uint64, error) { + sprite, err := generateSprite(encoder, videoFile) + if err != nil { + return nil, err + } + + hash, err := goimagehash.PerceptionHash(sprite) + if err != nil { + return nil, fmt.Errorf("computing phash from sprite: %w", err) + } + hashValue := hash.GetHash() + return &hashValue, nil +} + +func generateSpriteScreenshot(encoder ffmpeg.FFMpeg, input string, t float64) (image.Image, error) { + options := transcoder.ScreenshotOptions{ + Width: screenshotSize, + OutputPath: "-", + OutputType: transcoder.ScreenshotOutputTypeBMP, + } + + args := transcoder.ScreenshotTime(input, t, options) + data, err := encoder.GenerateOutput(context.Background(), args) + if err != nil { + return nil, err + } + + reader := bytes.NewReader(data) + + img, _, err := image.Decode(reader) + if err != nil { + return nil, fmt.Errorf("decoding image: %w", err) + } + + return img, nil +} + +func combineImages(images []image.Image) image.Image { + width := images[0].Bounds().Size().X + height := images[0].Bounds().Size().Y + canvasWidth := width * columns + canvasHeight := height * rows + montage := imaging.New(canvasWidth, canvasHeight, color.NRGBA{}) + for index := 0; index < len(images); index++ { + x := width * (index % columns) + y := height * int(math.Floor(float64(index)/float64(rows))) + img := images[index] + montage = imaging.Paste(montage, img, image.Pt(x, y)) + } + + return montage +} + +func generateSprite(encoder ffmpeg.FFMpeg, videoFile *ffmpeg.VideoFile) (image.Image, error) { + logger.Infof("[generator] generating phash sprite for %s", videoFile.Path) + + // Generate sprite image offset by 5% on each end to avoid intro/outros + chunkCount := columns * rows + offset := 0.05 * videoFile.Duration + stepSize := (0.9 * videoFile.Duration) / float64(chunkCount) + var images []image.Image + for i := 0; i < chunkCount; i++ { + time := offset + (float64(i) * stepSize) + + img, err := generateSpriteScreenshot(encoder, videoFile.Path, time) + if err != nil { + return nil, fmt.Errorf("generating sprite screenshot: %w", err) + } + + images = append(images, img) + } + + // Combine all of the thumbnails into a sprite image + if len(images) == 0 { + return nil, fmt.Errorf("images slice is empty, failed to generate phash sprite for %s", videoFile.Path) + } + + return combineImages(images), nil +} diff --git a/pkg/image/export.go b/pkg/image/export.go index 647bf041d..3938a39bf 100644 --- a/pkg/image/export.go +++ b/pkg/image/export.go @@ -2,6 +2,7 @@ package image import ( "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" ) @@ -11,8 +12,8 @@ import ( func ToBasicJSON(image *models.Image) *jsonschema.Image { newImageJSON := jsonschema.Image{ Checksum: image.Checksum, - CreatedAt: models.JSONTime{Time: image.CreatedAt.Timestamp}, - UpdatedAt: models.JSONTime{Time: image.UpdatedAt.Timestamp}, + CreatedAt: json.JSONTime{Time: image.CreatedAt.Timestamp}, + UpdatedAt: json.JSONTime{Time: image.UpdatedAt.Timestamp}, } if image.Title.Valid { @@ -35,7 +36,7 @@ func getImageFileJSON(image *models.Image) *jsonschema.ImageFile { ret := &jsonschema.ImageFile{} if image.FileModTime.Valid { - ret.ModTime = models.JSONTime{Time: image.FileModTime.Timestamp} + ret.ModTime = json.JSONTime{Time: image.FileModTime.Timestamp} } if image.Size.Valid { diff --git a/pkg/image/export_test.go b/pkg/image/export_test.go index 25c13f4ea..0a449c443 100644 --- a/pkg/image/export_test.go +++ b/pkg/image/export_test.go @@ -4,6 +4,7 @@ import ( "errors" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" @@ -90,10 +91,10 @@ func createFullJSONImage() *jsonschema.Image { Size: size, Width: width, }, - CreatedAt: models.JSONTime{ + CreatedAt: json.JSONTime{ Time: createTime, }, - UpdatedAt: models.JSONTime{ + UpdatedAt: json.JSONTime{ Time: updateTime, }, } diff --git a/pkg/image/thumbnail.go b/pkg/image/thumbnail.go index c364b04c0..ed4b2b5ba 100644 --- a/pkg/image/thumbnail.go +++ b/pkg/image/thumbnail.go @@ -2,6 +2,7 @@ package image import ( "bytes" + "context" "errors" "fmt" "image" @@ -10,19 +11,24 @@ import ( "sync" "github.com/stashapp/stash/pkg/ffmpeg" + "github.com/stashapp/stash/pkg/ffmpeg/transcoder" "github.com/stashapp/stash/pkg/models" ) +const ffmpegImageQuality = 5 + var vipsPath string var once sync.Once var ( + ErrUnsupportedImageFormat = errors.New("unsupported image format") + // ErrNotSupportedForThumbnail is returned if the image format is not supported for thumbnail generation ErrNotSupportedForThumbnail = errors.New("unsupported image format for thumbnail") ) type ThumbnailEncoder struct { - ffmpeg ffmpeg.Encoder + ffmpeg ffmpeg.FFMpeg vips *vipsEncoder } @@ -33,7 +39,7 @@ func GetVipsPath() string { return vipsPath } -func NewThumbnailEncoder(ffmpegEncoder ffmpeg.Encoder) ThumbnailEncoder { +func NewThumbnailEncoder(ffmpegEncoder ffmpeg.FFMpeg) ThumbnailEncoder { ret := ThumbnailEncoder{ ffmpeg: ffmpegEncoder, } @@ -86,6 +92,30 @@ func (e *ThumbnailEncoder) GetThumbnail(img *models.Image, maxSize int) ([]byte, if e.vips != nil && runtime.GOOS != "windows" { return e.vips.ImageThumbnail(buf, maxSize) } else { - return e.ffmpeg.ImageThumbnail(buf, format, maxSize, img.Path) + return e.ffmpegImageThumbnail(buf, format, maxSize) } } + +func (e *ThumbnailEncoder) ffmpegImageThumbnail(image *bytes.Buffer, format string, maxSize int) ([]byte, error) { + var ffmpegFormat ffmpeg.ImageFormat + + switch format { + case "jpeg": + ffmpegFormat = ffmpeg.ImageFormatJpeg + case "png": + ffmpegFormat = ffmpeg.ImageFormatPng + case "webp": + ffmpegFormat = ffmpeg.ImageFormatWebp + default: + return nil, ErrUnsupportedImageFormat + } + + args := transcoder.ImageThumbnail("-", transcoder.ImageThumbnailOptions{ + InputFormat: ffmpegFormat, + OutputPath: "-", + MaxDimensions: maxSize, + Quality: ffmpegImageQuality, + }) + + return e.ffmpeg.GenerateOutput(context.TODO(), args) +} diff --git a/pkg/models/extension_resolution.go b/pkg/models/extension_resolution.go index 6890ddac3..a52d4a784 100644 --- a/pkg/models/extension_resolution.go +++ b/pkg/models/extension_resolution.go @@ -5,19 +5,19 @@ type ResolutionRange struct { } var resolutionRanges = map[ResolutionEnum]ResolutionRange{ - ResolutionEnum("VERY_LOW"): {144, 239}, - ResolutionEnum("LOW"): {240, 359}, - ResolutionEnum("R360P"): {360, 479}, - ResolutionEnum("STANDARD"): {480, 539}, - ResolutionEnum("WEB_HD"): {540, 719}, - ResolutionEnum("STANDARD_HD"): {720, 1079}, - ResolutionEnum("FULL_HD"): {1080, 1439}, - ResolutionEnum("QUAD_HD"): {1440, 1919}, - ResolutionEnum("VR_HD"): {1920, 2159}, - ResolutionEnum("FOUR_K"): {2160, 2879}, - ResolutionEnum("FIVE_K"): {2880, 3383}, - ResolutionEnum("SIX_K"): {3384, 4319}, - ResolutionEnum("EIGHT_K"): {4320, 8639}, + ResolutionEnumVeryLow: {144, 239}, + ResolutionEnumLow: {240, 359}, + ResolutionEnumR360p: {360, 479}, + ResolutionEnumStandard: {480, 539}, + ResolutionEnumWebHd: {540, 719}, + ResolutionEnumStandardHd: {720, 1079}, + ResolutionEnumFullHd: {1080, 1439}, + ResolutionEnumQuadHd: {1440, 1919}, + ResolutionEnumVrHd: {1920, 2159}, + ResolutionEnumFourK: {2160, 2879}, + ResolutionEnumFiveK: {2880, 3383}, + ResolutionEnumSixK: {3384, 4319}, + ResolutionEnumEightK: {4320, 8639}, } // GetMaxResolution returns the maximum width or height that media must be @@ -28,6 +28,19 @@ func (r *ResolutionEnum) GetMaxResolution() int { // GetMinResolution returns the minimum width or height that media must be // to qualify as this resolution. -func (r *ResolutionEnum) GetMinResolution() int { - return resolutionRanges[*r].min +func (r ResolutionEnum) GetMinResolution() int { + return resolutionRanges[r].min +} + +var streamingResolutionMax = map[StreamingResolutionEnum]int{ + StreamingResolutionEnumLow: resolutionRanges[ResolutionEnumLow].min, + StreamingResolutionEnumStandard: resolutionRanges[ResolutionEnumStandard].min, + StreamingResolutionEnumStandardHd: resolutionRanges[ResolutionEnumStandardHd].min, + StreamingResolutionEnumFullHd: resolutionRanges[ResolutionEnumFullHd].min, + StreamingResolutionEnumFourK: resolutionRanges[ResolutionEnumFourK].min, + StreamingResolutionEnumOriginal: 0, +} + +func (r StreamingResolutionEnum) GetMaxResolution() int { + return streamingResolutionMax[r] } diff --git a/pkg/models/json_time.go b/pkg/models/json/json_time.go similarity index 77% rename from pkg/models/json_time.go rename to pkg/models/json/json_time.go index d1a400d3e..134bc69c6 100644 --- a/pkg/models/json_time.go +++ b/pkg/models/json/json_time.go @@ -1,11 +1,10 @@ -package models +package json import ( "fmt" "strings" "time" - "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/utils" ) @@ -23,12 +22,7 @@ func (jt *JSONTime) UnmarshalJSON(b []byte) error { } // #731 - returning an error here causes the entire JSON parse to fail for ffprobe. - // Changing so that it logs a warning instead. - var err error - jt.Time, err = utils.ParseDateStringAsTime(s) - if err != nil { - logger.Warnf("error unmarshalling JSONTime: %s", err.Error()) - } + jt.Time, _ = utils.ParseDateStringAsTime(s) return nil } diff --git a/pkg/models/jsonschema/gallery.go b/pkg/models/jsonschema/gallery.go index e633c021f..6885f001f 100644 --- a/pkg/models/jsonschema/gallery.go +++ b/pkg/models/jsonschema/gallery.go @@ -5,25 +5,25 @@ import ( "os" jsoniter "github.com/json-iterator/go" - "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/json" ) type Gallery struct { - Path string `json:"path,omitempty"` - Checksum string `json:"checksum,omitempty"` - Zip bool `json:"zip,omitempty"` - Title string `json:"title,omitempty"` - URL string `json:"url,omitempty"` - Date string `json:"date,omitempty"` - Details string `json:"details,omitempty"` - Rating int `json:"rating,omitempty"` - Organized bool `json:"organized,omitempty"` - Studio string `json:"studio,omitempty"` - Performers []string `json:"performers,omitempty"` - Tags []string `json:"tags,omitempty"` - FileModTime models.JSONTime `json:"file_mod_time,omitempty"` - CreatedAt models.JSONTime `json:"created_at,omitempty"` - UpdatedAt models.JSONTime `json:"updated_at,omitempty"` + Path string `json:"path,omitempty"` + Checksum string `json:"checksum,omitempty"` + Zip bool `json:"zip,omitempty"` + Title string `json:"title,omitempty"` + URL string `json:"url,omitempty"` + Date string `json:"date,omitempty"` + Details string `json:"details,omitempty"` + Rating int `json:"rating,omitempty"` + Organized bool `json:"organized,omitempty"` + Studio string `json:"studio,omitempty"` + Performers []string `json:"performers,omitempty"` + Tags []string `json:"tags,omitempty"` + FileModTime json.JSONTime `json:"file_mod_time,omitempty"` + CreatedAt json.JSONTime `json:"created_at,omitempty"` + UpdatedAt json.JSONTime `json:"updated_at,omitempty"` } func LoadGalleryFile(filePath string) (*Gallery, error) { diff --git a/pkg/models/jsonschema/image.go b/pkg/models/jsonschema/image.go index 82cf23cdc..dc4f7f525 100644 --- a/pkg/models/jsonschema/image.go +++ b/pkg/models/jsonschema/image.go @@ -5,29 +5,29 @@ import ( "os" jsoniter "github.com/json-iterator/go" - "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/json" ) type ImageFile struct { - ModTime models.JSONTime `json:"mod_time,omitempty"` - Size int `json:"size"` - Width int `json:"width"` - Height int `json:"height"` + ModTime json.JSONTime `json:"mod_time,omitempty"` + Size int `json:"size"` + Width int `json:"width"` + Height int `json:"height"` } type Image struct { - Title string `json:"title,omitempty"` - Checksum string `json:"checksum,omitempty"` - Studio string `json:"studio,omitempty"` - Rating int `json:"rating,omitempty"` - Organized bool `json:"organized,omitempty"` - OCounter int `json:"o_counter,omitempty"` - Galleries []string `json:"galleries,omitempty"` - Performers []string `json:"performers,omitempty"` - Tags []string `json:"tags,omitempty"` - File *ImageFile `json:"file,omitempty"` - CreatedAt models.JSONTime `json:"created_at,omitempty"` - UpdatedAt models.JSONTime `json:"updated_at,omitempty"` + Title string `json:"title,omitempty"` + Checksum string `json:"checksum,omitempty"` + Studio string `json:"studio,omitempty"` + Rating int `json:"rating,omitempty"` + Organized bool `json:"organized,omitempty"` + OCounter int `json:"o_counter,omitempty"` + Galleries []string `json:"galleries,omitempty"` + Performers []string `json:"performers,omitempty"` + Tags []string `json:"tags,omitempty"` + File *ImageFile `json:"file,omitempty"` + CreatedAt json.JSONTime `json:"created_at,omitempty"` + UpdatedAt json.JSONTime `json:"updated_at,omitempty"` } func LoadImageFile(filePath string) (*Image, error) { diff --git a/pkg/models/jsonschema/movie.go b/pkg/models/jsonschema/movie.go index 585627019..4c33da38f 100644 --- a/pkg/models/jsonschema/movie.go +++ b/pkg/models/jsonschema/movie.go @@ -5,23 +5,23 @@ import ( "os" jsoniter "github.com/json-iterator/go" - "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/json" ) type Movie struct { - Name string `json:"name,omitempty"` - Aliases string `json:"aliases,omitempty"` - Duration int `json:"duration,omitempty"` - Date string `json:"date,omitempty"` - Rating int `json:"rating,omitempty"` - Director string `json:"director,omitempty"` - Synopsis string `json:"sypnopsis,omitempty"` - FrontImage string `json:"front_image,omitempty"` - BackImage string `json:"back_image,omitempty"` - URL string `json:"url,omitempty"` - Studio string `json:"studio,omitempty"` - CreatedAt models.JSONTime `json:"created_at,omitempty"` - UpdatedAt models.JSONTime `json:"updated_at,omitempty"` + Name string `json:"name,omitempty"` + Aliases string `json:"aliases,omitempty"` + Duration int `json:"duration,omitempty"` + Date string `json:"date,omitempty"` + Rating int `json:"rating,omitempty"` + Director string `json:"director,omitempty"` + Synopsis string `json:"sypnopsis,omitempty"` + FrontImage string `json:"front_image,omitempty"` + BackImage string `json:"back_image,omitempty"` + URL string `json:"url,omitempty"` + Studio string `json:"studio,omitempty"` + CreatedAt json.JSONTime `json:"created_at,omitempty"` + UpdatedAt json.JSONTime `json:"updated_at,omitempty"` } func LoadMovieFile(filePath string) (*Movie, error) { diff --git a/pkg/models/jsonschema/performer.go b/pkg/models/jsonschema/performer.go index e7901b760..89677d715 100644 --- a/pkg/models/jsonschema/performer.go +++ b/pkg/models/jsonschema/performer.go @@ -6,6 +6,7 @@ import ( jsoniter "github.com/json-iterator/go" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/json" ) type Performer struct { @@ -28,8 +29,8 @@ type Performer struct { Favorite bool `json:"favorite,omitempty"` Tags []string `json:"tags,omitempty"` Image string `json:"image,omitempty"` - CreatedAt models.JSONTime `json:"created_at,omitempty"` - UpdatedAt models.JSONTime `json:"updated_at,omitempty"` + CreatedAt json.JSONTime `json:"created_at,omitempty"` + UpdatedAt json.JSONTime `json:"updated_at,omitempty"` Rating int `json:"rating,omitempty"` Details string `json:"details,omitempty"` DeathDate string `json:"death_date,omitempty"` diff --git a/pkg/models/jsonschema/scene.go b/pkg/models/jsonschema/scene.go index 72ccc53e1..1984cf130 100644 --- a/pkg/models/jsonschema/scene.go +++ b/pkg/models/jsonschema/scene.go @@ -6,28 +6,29 @@ import ( jsoniter "github.com/json-iterator/go" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/json" ) type SceneMarker struct { - Title string `json:"title,omitempty"` - Seconds string `json:"seconds,omitempty"` - PrimaryTag string `json:"primary_tag,omitempty"` - Tags []string `json:"tags,omitempty"` - CreatedAt models.JSONTime `json:"created_at,omitempty"` - UpdatedAt models.JSONTime `json:"updated_at,omitempty"` + Title string `json:"title,omitempty"` + Seconds string `json:"seconds,omitempty"` + PrimaryTag string `json:"primary_tag,omitempty"` + Tags []string `json:"tags,omitempty"` + CreatedAt json.JSONTime `json:"created_at,omitempty"` + UpdatedAt json.JSONTime `json:"updated_at,omitempty"` } type SceneFile struct { - ModTime models.JSONTime `json:"mod_time,omitempty"` - Size string `json:"size"` - Duration string `json:"duration"` - VideoCodec string `json:"video_codec"` - AudioCodec string `json:"audio_codec"` - Format string `json:"format"` - Width int `json:"width"` - Height int `json:"height"` - Framerate string `json:"framerate"` - Bitrate int `json:"bitrate"` + ModTime json.JSONTime `json:"mod_time,omitempty"` + Size string `json:"size"` + Duration string `json:"duration"` + VideoCodec string `json:"video_codec"` + AudioCodec string `json:"audio_codec"` + Format string `json:"format"` + Width int `json:"width"` + Height int `json:"height"` + Framerate string `json:"framerate"` + Bitrate int `json:"bitrate"` } type SceneMovie struct { @@ -54,8 +55,8 @@ type Scene struct { Markers []SceneMarker `json:"markers,omitempty"` File *SceneFile `json:"file,omitempty"` Cover string `json:"cover,omitempty"` - CreatedAt models.JSONTime `json:"created_at,omitempty"` - UpdatedAt models.JSONTime `json:"updated_at,omitempty"` + CreatedAt json.JSONTime `json:"created_at,omitempty"` + UpdatedAt json.JSONTime `json:"updated_at,omitempty"` StashIDs []models.StashID `json:"stash_ids,omitempty"` } diff --git a/pkg/models/jsonschema/scraped.go b/pkg/models/jsonschema/scraped.go index 9837a1210..c6444a484 100644 --- a/pkg/models/jsonschema/scraped.go +++ b/pkg/models/jsonschema/scraped.go @@ -2,26 +2,27 @@ package jsonschema import ( "fmt" - "github.com/json-iterator/go" - "github.com/stashapp/stash/pkg/models" "os" + + jsoniter "github.com/json-iterator/go" + "github.com/stashapp/stash/pkg/models/json" ) type ScrapedItem struct { - Title string `json:"title,omitempty"` - Description string `json:"description,omitempty"` - URL string `json:"url,omitempty"` - Date string `json:"date,omitempty"` - Rating string `json:"rating,omitempty"` - Tags string `json:"tags,omitempty"` - Models string `json:"models,omitempty"` - Episode int `json:"episode,omitempty"` - GalleryFilename string `json:"gallery_filename,omitempty"` - GalleryURL string `json:"gallery_url,omitempty"` - VideoFilename string `json:"video_filename,omitempty"` - VideoURL string `json:"video_url,omitempty"` - Studio string `json:"studio,omitempty"` - UpdatedAt models.JSONTime `json:"updated_at,omitempty"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + URL string `json:"url,omitempty"` + Date string `json:"date,omitempty"` + Rating string `json:"rating,omitempty"` + Tags string `json:"tags,omitempty"` + Models string `json:"models,omitempty"` + Episode int `json:"episode,omitempty"` + GalleryFilename string `json:"gallery_filename,omitempty"` + GalleryURL string `json:"gallery_url,omitempty"` + VideoFilename string `json:"video_filename,omitempty"` + VideoURL string `json:"video_url,omitempty"` + Studio string `json:"studio,omitempty"` + UpdatedAt json.JSONTime `json:"updated_at,omitempty"` } func LoadScrapedFile(filePath string) ([]ScrapedItem, error) { diff --git a/pkg/models/jsonschema/studio.go b/pkg/models/jsonschema/studio.go index 142ace053..dad65a569 100644 --- a/pkg/models/jsonschema/studio.go +++ b/pkg/models/jsonschema/studio.go @@ -6,6 +6,7 @@ import ( jsoniter "github.com/json-iterator/go" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/json" ) type Studio struct { @@ -13,8 +14,8 @@ type Studio struct { URL string `json:"url,omitempty"` ParentStudio string `json:"parent_studio,omitempty"` Image string `json:"image,omitempty"` - CreatedAt models.JSONTime `json:"created_at,omitempty"` - UpdatedAt models.JSONTime `json:"updated_at,omitempty"` + CreatedAt json.JSONTime `json:"created_at,omitempty"` + UpdatedAt json.JSONTime `json:"updated_at,omitempty"` Rating int `json:"rating,omitempty"` Details string `json:"details,omitempty"` Aliases []string `json:"aliases,omitempty"` diff --git a/pkg/models/jsonschema/tag.go b/pkg/models/jsonschema/tag.go index 9b247e763..6be4643bd 100644 --- a/pkg/models/jsonschema/tag.go +++ b/pkg/models/jsonschema/tag.go @@ -5,17 +5,17 @@ import ( "os" jsoniter "github.com/json-iterator/go" - "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/json" ) type Tag struct { - Name string `json:"name,omitempty"` - Aliases []string `json:"aliases,omitempty"` - Image string `json:"image,omitempty"` - Parents []string `json:"parents,omitempty"` - IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"` - CreatedAt models.JSONTime `json:"created_at,omitempty"` - UpdatedAt models.JSONTime `json:"updated_at,omitempty"` + Name string `json:"name,omitempty"` + Aliases []string `json:"aliases,omitempty"` + Image string `json:"image,omitempty"` + Parents []string `json:"parents,omitempty"` + IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"` + CreatedAt json.JSONTime `json:"created_at,omitempty"` + UpdatedAt json.JSONTime `json:"updated_at,omitempty"` } func LoadTagFile(filePath string) (*Tag, error) { diff --git a/pkg/models/paths/paths_generated.go b/pkg/models/paths/paths_generated.go index ff6d122be..aa65ea918 100644 --- a/pkg/models/paths/paths_generated.go +++ b/pkg/models/paths/paths_generated.go @@ -40,6 +40,12 @@ func (gp *generatedPaths) GetTmpPath(fileName string) string { return filepath.Join(gp.Tmp, fileName) } +// TempFile creates a temporary file using os.CreateTemp. +// It is the equivalent of calling os.CreateTemp using Tmp and pattern. +func (gp *generatedPaths) TempFile(pattern string) (*os.File, error) { + return os.CreateTemp(gp.Tmp, pattern) +} + func (gp *generatedPaths) EnsureTmpDir() error { return fsutil.EnsureDir(gp.Tmp) } diff --git a/pkg/models/paths/paths_scene_markers.go b/pkg/models/paths/paths_scene_markers.go index 3d9dbd6a6..7524c1713 100644 --- a/pkg/models/paths/paths_scene_markers.go +++ b/pkg/models/paths/paths_scene_markers.go @@ -6,23 +6,24 @@ import ( ) type sceneMarkerPaths struct { - generated generatedPaths + generatedPaths } func newSceneMarkerPaths(p Paths) *sceneMarkerPaths { - sp := sceneMarkerPaths{} - sp.generated = *p.Generated + sp := sceneMarkerPaths{ + generatedPaths: *p.Generated, + } return &sp } -func (sp *sceneMarkerPaths) GetStreamPath(checksum string, seconds int) string { - return filepath.Join(sp.generated.Markers, checksum, strconv.Itoa(seconds)+".mp4") +func (sp *sceneMarkerPaths) GetVideoPreviewPath(checksum string, seconds int) string { + return filepath.Join(sp.Markers, checksum, strconv.Itoa(seconds)+".mp4") } -func (sp *sceneMarkerPaths) GetStreamPreviewImagePath(checksum string, seconds int) string { - return filepath.Join(sp.generated.Markers, checksum, strconv.Itoa(seconds)+".webp") +func (sp *sceneMarkerPaths) GetWebpPreviewPath(checksum string, seconds int) string { + return filepath.Join(sp.Markers, checksum, strconv.Itoa(seconds)+".webp") } -func (sp *sceneMarkerPaths) GetStreamScreenshotPath(checksum string, seconds int) string { - return filepath.Join(sp.generated.Markers, checksum, strconv.Itoa(seconds)+".jpg") +func (sp *sceneMarkerPaths) GetScreenshotPath(checksum string, seconds int) string { + return filepath.Join(sp.Markers, checksum, strconv.Itoa(seconds)+".jpg") } diff --git a/pkg/models/paths/paths_scenes.go b/pkg/models/paths/paths_scenes.go index 01135ca45..d54fbfb59 100644 --- a/pkg/models/paths/paths_scenes.go +++ b/pkg/models/paths/paths_scenes.go @@ -7,25 +7,26 @@ import ( ) type scenePaths struct { - generated generatedPaths + generatedPaths } func newScenePaths(p Paths) *scenePaths { - sp := scenePaths{} - sp.generated = *p.Generated + sp := scenePaths{ + generatedPaths: *p.Generated, + } return &sp } func (sp *scenePaths) GetScreenshotPath(checksum string) string { - return filepath.Join(sp.generated.Screenshots, checksum+".jpg") + return filepath.Join(sp.Screenshots, checksum+".jpg") } func (sp *scenePaths) GetThumbnailScreenshotPath(checksum string) string { - return filepath.Join(sp.generated.Screenshots, checksum+".thumb.jpg") + return filepath.Join(sp.Screenshots, checksum+".thumb.jpg") } func (sp *scenePaths) GetTranscodePath(checksum string) string { - return filepath.Join(sp.generated.Transcodes, checksum+".mp4") + return filepath.Join(sp.Transcodes, checksum+".mp4") } func (sp *scenePaths) GetStreamPath(scenePath string, checksum string) string { @@ -37,22 +38,22 @@ func (sp *scenePaths) GetStreamPath(scenePath string, checksum string) string { return scenePath } -func (sp *scenePaths) GetStreamPreviewPath(checksum string) string { - return filepath.Join(sp.generated.Screenshots, checksum+".mp4") +func (sp *scenePaths) GetVideoPreviewPath(checksum string) string { + return filepath.Join(sp.Screenshots, checksum+".mp4") } -func (sp *scenePaths) GetStreamPreviewImagePath(checksum string) string { - return filepath.Join(sp.generated.Screenshots, checksum+".webp") +func (sp *scenePaths) GetWebpPreviewPath(checksum string) string { + return filepath.Join(sp.Screenshots, checksum+".webp") } func (sp *scenePaths) GetSpriteImageFilePath(checksum string) string { - return filepath.Join(sp.generated.Vtt, checksum+"_sprite.jpg") + return filepath.Join(sp.Vtt, checksum+"_sprite.jpg") } func (sp *scenePaths) GetSpriteVttFilePath(checksum string) string { - return filepath.Join(sp.generated.Vtt, checksum+"_thumbs.vtt") + return filepath.Join(sp.Vtt, checksum+"_thumbs.vtt") } func (sp *scenePaths) GetInteractiveHeatmapPath(checksum string) string { - return filepath.Join(sp.generated.InteractiveHeatmap, checksum+".png") + return filepath.Join(sp.InteractiveHeatmap, checksum+".png") } diff --git a/pkg/movie/export.go b/pkg/movie/export.go index fa682bdc4..a70e30290 100644 --- a/pkg/movie/export.go +++ b/pkg/movie/export.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/utils" ) @@ -11,8 +12,8 @@ import ( // ToJSON converts a Movie into its JSON equivalent. func ToJSON(reader models.MovieReader, studioReader models.StudioReader, movie *models.Movie) (*jsonschema.Movie, error) { newMovieJSON := jsonschema.Movie{ - CreatedAt: models.JSONTime{Time: movie.CreatedAt.Timestamp}, - UpdatedAt: models.JSONTime{Time: movie.UpdatedAt.Timestamp}, + CreatedAt: json.JSONTime{Time: movie.CreatedAt.Timestamp}, + UpdatedAt: json.JSONTime{Time: movie.UpdatedAt.Timestamp}, } if movie.Name.Valid { diff --git a/pkg/movie/export_test.go b/pkg/movie/export_test.go index 7e5567ae8..11be97b7b 100644 --- a/pkg/movie/export_test.go +++ b/pkg/movie/export_test.go @@ -5,6 +5,7 @@ import ( "errors" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" @@ -118,10 +119,10 @@ func createFullJSONMovie(studio, frontImage, backImage string) *jsonschema.Movie Studio: studio, FrontImage: frontImage, BackImage: backImage, - CreatedAt: models.JSONTime{ + CreatedAt: json.JSONTime{ Time: createTime, }, - UpdatedAt: models.JSONTime{ + UpdatedAt: json.JSONTime{ Time: updateTime, }, } @@ -129,10 +130,10 @@ func createFullJSONMovie(studio, frontImage, backImage string) *jsonschema.Movie func createEmptyJSONMovie() *jsonschema.Movie { return &jsonschema.Movie{ - CreatedAt: models.JSONTime{ + CreatedAt: json.JSONTime{ Time: createTime, }, - UpdatedAt: models.JSONTime{ + UpdatedAt: json.JSONTime{ Time: updateTime, }, } diff --git a/pkg/performer/export.go b/pkg/performer/export.go index 4c0ce1560..240d0fc28 100644 --- a/pkg/performer/export.go +++ b/pkg/performer/export.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/utils" ) @@ -12,8 +13,8 @@ import ( func ToJSON(reader models.PerformerReader, performer *models.Performer) (*jsonschema.Performer, error) { newPerformerJSON := jsonschema.Performer{ IgnoreAutoTag: performer.IgnoreAutoTag, - CreatedAt: models.JSONTime{Time: performer.CreatedAt.Timestamp}, - UpdatedAt: models.JSONTime{Time: performer.UpdatedAt.Timestamp}, + CreatedAt: json.JSONTime{Time: performer.CreatedAt.Timestamp}, + UpdatedAt: json.JSONTime{Time: performer.UpdatedAt.Timestamp}, } if performer.Name.Valid { diff --git a/pkg/performer/export_test.go b/pkg/performer/export_test.go index 44d5b3ffe..7cfdabb7f 100644 --- a/pkg/performer/export_test.go +++ b/pkg/performer/export_test.go @@ -6,6 +6,7 @@ import ( "github.com/stashapp/stash/pkg/hash/md5" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" @@ -142,10 +143,10 @@ func createFullJSONPerformer(name string, image string) *jsonschema.Performer { Piercings: piercings, Tattoos: tattoos, Twitter: twitter, - CreatedAt: models.JSONTime{ + CreatedAt: json.JSONTime{ Time: createTime, }, - UpdatedAt: models.JSONTime{ + UpdatedAt: json.JSONTime{ Time: updateTime, }, Rating: rating, @@ -163,10 +164,10 @@ func createFullJSONPerformer(name string, image string) *jsonschema.Performer { func createEmptyJSONPerformer() *jsonschema.Performer { return &jsonschema.Performer{ - CreatedAt: models.JSONTime{ + CreatedAt: json.JSONTime{ Time: createTime, }, - UpdatedAt: models.JSONTime{ + UpdatedAt: json.JSONTime{ Time: updateTime, }, } diff --git a/pkg/scene/delete.go b/pkg/scene/delete.go index 45456171b..3a31d6f60 100644 --- a/pkg/scene/delete.go +++ b/pkg/scene/delete.go @@ -48,13 +48,13 @@ func (d *FileDeleter) MarkGeneratedFiles(scene *models.Scene) error { files = append(files, normalPath) } - streamPreviewPath := d.Paths.Scene.GetStreamPreviewPath(sceneHash) + streamPreviewPath := d.Paths.Scene.GetVideoPreviewPath(sceneHash) exists, _ = fsutil.FileExists(streamPreviewPath) if exists { files = append(files, streamPreviewPath) } - streamPreviewImagePath := d.Paths.Scene.GetStreamPreviewImagePath(sceneHash) + streamPreviewImagePath := d.Paths.Scene.GetWebpPreviewPath(sceneHash) exists, _ = fsutil.FileExists(streamPreviewImagePath) if exists { files = append(files, streamPreviewImagePath) @@ -90,9 +90,9 @@ func (d *FileDeleter) MarkGeneratedFiles(scene *models.Scene) error { // MarkMarkerFiles deletes generated files for a scene marker with the // provided scene and timestamp. func (d *FileDeleter) MarkMarkerFiles(scene *models.Scene, seconds int) error { - videoPath := d.Paths.SceneMarkers.GetStreamPath(scene.GetHash(d.FileNamingAlgo), seconds) - imagePath := d.Paths.SceneMarkers.GetStreamPreviewImagePath(scene.GetHash(d.FileNamingAlgo), seconds) - screenshotPath := d.Paths.SceneMarkers.GetStreamScreenshotPath(scene.GetHash(d.FileNamingAlgo), seconds) + videoPath := d.Paths.SceneMarkers.GetVideoPreviewPath(scene.GetHash(d.FileNamingAlgo), seconds) + imagePath := d.Paths.SceneMarkers.GetWebpPreviewPath(scene.GetHash(d.FileNamingAlgo), seconds) + screenshotPath := d.Paths.SceneMarkers.GetScreenshotPath(scene.GetHash(d.FileNamingAlgo), seconds) var files []string diff --git a/pkg/scene/export.go b/pkg/scene/export.go index 38ae5f0b1..c5bda2c47 100644 --- a/pkg/scene/export.go +++ b/pkg/scene/export.go @@ -6,6 +6,7 @@ import ( "strconv" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/sliceutil/intslice" "github.com/stashapp/stash/pkg/utils" @@ -16,8 +17,8 @@ import ( // of cover image. func ToBasicJSON(reader models.SceneReader, scene *models.Scene) (*jsonschema.Scene, error) { newSceneJSON := jsonschema.Scene{ - CreatedAt: models.JSONTime{Time: scene.CreatedAt.Timestamp}, - UpdatedAt: models.JSONTime{Time: scene.UpdatedAt.Timestamp}, + CreatedAt: json.JSONTime{Time: scene.CreatedAt.Timestamp}, + UpdatedAt: json.JSONTime{Time: scene.UpdatedAt.Timestamp}, } if scene.Checksum.Valid { @@ -85,7 +86,7 @@ func getSceneFileJSON(scene *models.Scene) *jsonschema.SceneFile { ret := &jsonschema.SceneFile{} if scene.FileModTime.Valid { - ret.ModTime = models.JSONTime{Time: scene.FileModTime.Timestamp} + ret.ModTime = json.JSONTime{Time: scene.FileModTime.Timestamp} } if scene.Size.Valid { @@ -268,8 +269,8 @@ func GetSceneMarkersJSON(markerReader models.SceneMarkerReader, tagReader models Seconds: getDecimalString(sceneMarker.Seconds), PrimaryTag: primaryTag.Name, Tags: getTagNames(sceneMarkerTags), - CreatedAt: models.JSONTime{Time: sceneMarker.CreatedAt.Timestamp}, - UpdatedAt: models.JSONTime{Time: sceneMarker.UpdatedAt.Timestamp}, + CreatedAt: json.JSONTime{Time: sceneMarker.CreatedAt.Timestamp}, + UpdatedAt: json.JSONTime{Time: sceneMarker.UpdatedAt.Timestamp}, } results = append(results, sceneMarkerJSON) diff --git a/pkg/scene/export_test.go b/pkg/scene/export_test.go index d87a9fadd..aa8b7fb52 100644 --- a/pkg/scene/export_test.go +++ b/pkg/scene/export_test.go @@ -5,6 +5,7 @@ import ( "errors" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stashapp/stash/pkg/utils" @@ -175,10 +176,10 @@ func createFullJSONScene(image string) *jsonschema.Scene { VideoCodec: videoCodec, Width: width, }, - CreatedAt: models.JSONTime{ + CreatedAt: json.JSONTime{ Time: createTime, }, - UpdatedAt: models.JSONTime{ + UpdatedAt: json.JSONTime{ Time: updateTime, }, Cover: image, @@ -191,10 +192,10 @@ func createFullJSONScene(image string) *jsonschema.Scene { func createEmptyJSONScene() *jsonschema.Scene { return &jsonschema.Scene{ File: &jsonschema.SceneFile{}, - CreatedAt: models.JSONTime{ + CreatedAt: json.JSONTime{ Time: createTime, }, - UpdatedAt: models.JSONTime{ + UpdatedAt: json.JSONTime{ Time: updateTime, }, } @@ -508,10 +509,10 @@ var getSceneMarkersJSONScenarios = []sceneMarkersTestScenario{ validTagName1, validTagName2, }, - CreatedAt: models.JSONTime{ + CreatedAt: json.JSONTime{ Time: createTime, }, - UpdatedAt: models.JSONTime{ + UpdatedAt: json.JSONTime{ Time: updateTime, }, }, @@ -522,10 +523,10 @@ var getSceneMarkersJSONScenarios = []sceneMarkersTestScenario{ Tags: []string{ validTagName2, }, - CreatedAt: models.JSONTime{ + CreatedAt: json.JSONTime{ Time: createTime, }, - UpdatedAt: models.JSONTime{ + UpdatedAt: json.JSONTime{ Time: updateTime, }, }, diff --git a/pkg/scene/generate/generator.go b/pkg/scene/generate/generator.go new file mode 100644 index 000000000..1caaf6799 --- /dev/null +++ b/pkg/scene/generate/generator.go @@ -0,0 +1,146 @@ +package generate + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/stashapp/stash/pkg/ffmpeg" + "github.com/stashapp/stash/pkg/fsutil" +) + +const ( + mp4Pattern = "*.mp4" + webpPattern = "*.webp" + jpgPattern = "*.jpg" + txtPattern = "*.txt" + vttPattern = "*.vtt" +) + +type Paths interface { + TempFile(pattern string) (*os.File, error) +} + +type MarkerPaths interface { + Paths + + GetVideoPreviewPath(checksum string, seconds int) string + GetWebpPreviewPath(checksum string, seconds int) string + GetScreenshotPath(checksum string, seconds int) string +} + +type ScenePaths interface { + Paths + + GetVideoPreviewPath(checksum string) string + GetWebpPreviewPath(checksum string) string + + GetScreenshotPath(checksum string) string + GetThumbnailScreenshotPath(checksum string) string + + GetSpriteImageFilePath(checksum string) string + GetSpriteVttFilePath(checksum string) string + + GetTranscodePath(checksum string) string +} + +type Generator struct { + Encoder ffmpeg.FFMpeg + LockManager *fsutil.ReadLockManager + MarkerPaths MarkerPaths + ScenePaths ScenePaths + Overwrite bool +} + +type generateFn func(lockCtx *fsutil.LockContext, tmpFn string) error + +func (g Generator) tempFile(p Paths, pattern string) (*os.File, error) { + tmpFile, err := p.TempFile(pattern) // tmp output in case the process ends abruptly + if err != nil { + return nil, fmt.Errorf("creating temporary file: %w", err) + } + _ = tmpFile.Close() + return tmpFile, err +} + +// generateFile performs a generate operation by generating a temporary file using p and pattern, then +// moving it to output on success. +func (g Generator) generateFile(lockCtx *fsutil.LockContext, p Paths, pattern string, output string, generateFn generateFn) error { + tmpFile, err := g.tempFile(p, pattern) // tmp output in case the process ends abruptly + if err != nil { + return err + } + + tmpFn := tmpFile.Name() + defer func() { + _ = os.Remove(tmpFn) + }() + + if err := generateFn(lockCtx, tmpFn); err != nil { + return err + } + + if err := fsutil.SafeMove(tmpFn, output); err != nil { + return fmt.Errorf("moving %s to %s", tmpFn, output) + } + + return nil +} + +// 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 (g Generator) generate(ctx *fsutil.LockContext, args []string) error { + cmd := g.Encoder.Command(ctx, args) + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Start(); err != nil { + return fmt.Errorf("error starting command: %w", err) + } + + ctx.AttachCommand(cmd) + + 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 (g Generator) generateOutput(lockCtx *fsutil.LockContext, args []string) ([]byte, error) { + cmd := g.Encoder.Command(lockCtx, args) + + var stdout bytes.Buffer + cmd.Stdout = &stdout + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("error starting command: %w", err) + } + + lockCtx.AttachCommand(cmd) + + if err := cmd.Wait(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + exitErr.Stderr = stderr.Bytes() + err = exitErr + } + return nil, fmt.Errorf("error running ffmpeg command <%s>: %w", strings.Join(args, " "), err) + } + + return stdout.Bytes(), nil +} diff --git a/pkg/scene/generate/marker_preview.go b/pkg/scene/generate/marker_preview.go new file mode 100644 index 000000000..d3656eae5 --- /dev/null +++ b/pkg/scene/generate/marker_preview.go @@ -0,0 +1,187 @@ +package generate + +import ( + "context" + + "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 ( + markerPreviewWidth = 640 + markerPreviewDuration = 20 + markerPreviewAudioBitrate = "64k" + + markerImageDuration = 5 + markerWebpFPS = 12 + + markerScreenshotQuality = 2 +) + +func (g Generator) MarkerPreviewVideo(ctx context.Context, input string, hash string, seconds int, includeAudio bool) error { + lockCtx := g.LockManager.ReadLock(ctx, input) + defer lockCtx.Cancel() + + output := g.MarkerPaths.GetVideoPreviewPath(hash, seconds) + if !g.Overwrite { + if exists, _ := fsutil.FileExists(output); exists { + return nil + } + } + + if err := g.generateFile(lockCtx, g.MarkerPaths, mp4Pattern, output, g.markerPreviewVideo(input, sceneMarkerOptions{ + Seconds: seconds, + Audio: includeAudio, + })); err != nil { + return err + } + + logger.Debug("created marker video: ", output) + + return nil +} + +type sceneMarkerOptions struct { + Seconds int + Audio bool +} + +func (g Generator) markerPreviewVideo(input string, options sceneMarkerOptions) generateFn { + return func(lockCtx *fsutil.LockContext, tmpFn string) error { + var videoFilter ffmpeg.VideoFilter + videoFilter = videoFilter.ScaleWidth(markerPreviewWidth) + + var videoArgs ffmpeg.Args + videoArgs = videoArgs.VideoFilter(videoFilter) + + videoArgs = append(videoArgs, + "-pix_fmt", "yuv420p", + "-profile:v", "high", + "-level", "4.2", + "-preset", "veryslow", + "-crf", "24", + "-movflags", "+faststart", + "-threads", "4", + "-sws_flags", "lanczos", + "-strict", "-2", + ) + + trimOptions := transcoder.TranscodeOptions{ + Duration: markerPreviewDuration, + StartTime: float64(options.Seconds), + OutputPath: tmpFn, + VideoCodec: ffmpeg.VideoCodecLibX264, + VideoArgs: videoArgs, + } + + if options.Audio { + var audioArgs ffmpeg.Args + audioArgs = audioArgs.AudioBitrate(markerPreviewAudioBitrate) + + trimOptions.AudioCodec = ffmpeg.AudioCodecAAC + trimOptions.AudioArgs = audioArgs + } + + args := transcoder.Transcode(input, trimOptions) + + return g.generate(lockCtx, args) + } +} + +func (g Generator) SceneMarkerWebp(ctx context.Context, input string, hash string, seconds int) error { + lockCtx := g.LockManager.ReadLock(ctx, input) + defer lockCtx.Cancel() + + output := g.MarkerPaths.GetWebpPreviewPath(hash, seconds) + if !g.Overwrite { + if exists, _ := fsutil.FileExists(output); exists { + return nil + } + } + + if err := g.generateFile(lockCtx, g.MarkerPaths, webpPattern, output, g.sceneMarkerWebp(input, sceneMarkerOptions{ + Seconds: seconds, + })); err != nil { + return err + } + + logger.Debug("created marker image: ", output) + + return nil +} + +func (g Generator) sceneMarkerWebp(input string, options sceneMarkerOptions) generateFn { + return func(lockCtx *fsutil.LockContext, tmpFn string) error { + var videoFilter ffmpeg.VideoFilter + videoFilter = videoFilter.ScaleWidth(markerPreviewWidth) + videoFilter = videoFilter.Fps(markerWebpFPS) + + 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", + ) + + trimOptions := transcoder.TranscodeOptions{ + Duration: markerImageDuration, + StartTime: float64(options.Seconds), + OutputPath: tmpFn, + VideoCodec: ffmpeg.VideoCodecLibWebP, + VideoArgs: videoArgs, + } + + args := transcoder.Transcode(input, trimOptions) + + return g.generate(lockCtx, args) + } +} + +func (g Generator) SceneMarkerScreenshot(ctx context.Context, input string, hash string, seconds int, width int) error { + lockCtx := g.LockManager.ReadLock(ctx, input) + defer lockCtx.Cancel() + + output := g.MarkerPaths.GetScreenshotPath(hash, seconds) + if !g.Overwrite { + if exists, _ := fsutil.FileExists(output); exists { + return nil + } + } + + if err := g.generateFile(lockCtx, g.MarkerPaths, jpgPattern, output, g.sceneMarkerScreenshot(input, SceneMarkerScreenshotOptions{ + Seconds: seconds, + Width: width, + })); err != nil { + return err + } + + logger.Debug("created marker screenshot: ", output) + + return nil +} + +type SceneMarkerScreenshotOptions struct { + Seconds int + Width int +} + +func (g Generator) sceneMarkerScreenshot(input string, options SceneMarkerScreenshotOptions) generateFn { + return func(lockCtx *fsutil.LockContext, tmpFn string) error { + ssOptions := transcoder.ScreenshotOptions{ + OutputPath: tmpFn, + OutputType: transcoder.ScreenshotOutputTypeImage2, + Quality: markerScreenshotQuality, + Width: options.Width, + } + + args := transcoder.ScreenshotTime(input, float64(options.Seconds), ssOptions) + + return g.generate(lockCtx, args) + } +} diff --git a/pkg/scene/generate/preview.go b/pkg/scene/generate/preview.go new file mode 100644 index 000000000..019c94817 --- /dev/null +++ b/pkg/scene/generate/preview.go @@ -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) + } +} diff --git a/pkg/scene/generate/screenshot.go b/pkg/scene/generate/screenshot.go new file mode 100644 index 000000000..0ab0c6006 --- /dev/null +++ b/pkg/scene/generate/screenshot.go @@ -0,0 +1,101 @@ +package generate + +import ( + "context" + + "github.com/stashapp/stash/pkg/ffmpeg/transcoder" + "github.com/stashapp/stash/pkg/fsutil" + "github.com/stashapp/stash/pkg/logger" +) + +const ( + thumbnailWidth = 320 + thumbnailQuality = 5 + + screenshotQuality = 2 + + screenshotDurationProportion = 0.2 +) + +type ScreenshotOptions struct { + At *float64 +} + +func (g Generator) Screenshot(ctx context.Context, input string, hash string, videoWidth int, videoDuration float64, options ScreenshotOptions) error { + lockCtx := g.LockManager.ReadLock(ctx, input) + defer lockCtx.Cancel() + + output := g.ScenePaths.GetScreenshotPath(hash) + if !g.Overwrite { + if exists, _ := fsutil.FileExists(output); exists { + return nil + } + } + + at := screenshotDurationProportion * videoDuration + if options.At != nil { + at = *options.At + } + + if err := g.generateFile(lockCtx, g.ScenePaths, jpgPattern, output, g.screenshot(input, screenshotOptions{ + Time: at, + Quality: screenshotQuality, + // default Width is video width + })); err != nil { + return err + } + + logger.Debug("created screenshot: ", output) + + return nil +} + +func (g Generator) Thumbnail(ctx context.Context, input string, hash string, videoDuration float64, options ScreenshotOptions) error { + lockCtx := g.LockManager.ReadLock(ctx, input) + defer lockCtx.Cancel() + + output := g.ScenePaths.GetThumbnailScreenshotPath(hash) + if !g.Overwrite { + if exists, _ := fsutil.FileExists(output); exists { + return nil + } + } + + at := screenshotDurationProportion * videoDuration + if options.At != nil { + at = *options.At + } + + if err := g.generateFile(lockCtx, g.ScenePaths, jpgPattern, output, g.screenshot(input, screenshotOptions{ + Time: at, + Quality: thumbnailQuality, + Width: thumbnailWidth, + })); err != nil { + return err + } + + logger.Debug("created thumbnail: ", output) + + return nil +} + +type screenshotOptions struct { + Time float64 + Width int + Quality int +} + +func (g Generator) screenshot(input string, options screenshotOptions) generateFn { + return func(lockCtx *fsutil.LockContext, tmpFn string) error { + ssOptions := transcoder.ScreenshotOptions{ + OutputPath: tmpFn, + OutputType: transcoder.ScreenshotOutputTypeImage2, + Quality: options.Quality, + Width: options.Width, + } + + args := transcoder.ScreenshotTime(input, options.Time, ssOptions) + + return g.generate(lockCtx, args) + } +} diff --git a/pkg/scene/generate/sprite.go b/pkg/scene/generate/sprite.go new file mode 100644 index 000000000..c3b10f680 --- /dev/null +++ b/pkg/scene/generate/sprite.go @@ -0,0 +1,330 @@ +package generate + +import ( + "bytes" + "context" + "fmt" + "image" + "image/color" + "math" + "os" + "path/filepath" + "strings" + + "github.com/disintegration/imaging" + "github.com/stashapp/stash/pkg/ffmpeg" + "github.com/stashapp/stash/pkg/ffmpeg/transcoder" + "github.com/stashapp/stash/pkg/fsutil" + "github.com/stashapp/stash/pkg/utils" +) + +const ( + spriteScreenshotWidth = 160 + + spriteRows = 9 + spriteCols = 9 + spriteChunks = spriteRows * spriteCols +) + +func (g Generator) SpriteScreenshot(ctx context.Context, input string, seconds float64) (image.Image, error) { + lockCtx := g.LockManager.ReadLock(ctx, input) + defer lockCtx.Cancel() + + ssOptions := transcoder.ScreenshotOptions{ + OutputPath: "-", + OutputType: transcoder.ScreenshotOutputTypeBMP, + Width: spriteScreenshotWidth, + } + + args := transcoder.ScreenshotTime(input, seconds, ssOptions) + + return g.generateImage(lockCtx, args) +} + +func (g Generator) SpriteScreenshotSlow(ctx context.Context, input string, frame int) (image.Image, error) { + lockCtx := g.LockManager.ReadLock(ctx, input) + defer lockCtx.Cancel() + + ssOptions := transcoder.ScreenshotOptions{ + OutputPath: "-", + OutputType: transcoder.ScreenshotOutputTypeBMP, + Width: spriteScreenshotWidth, + } + + args := transcoder.ScreenshotFrame(input, frame, ssOptions) + + return g.generateImage(lockCtx, args) +} + +func (g Generator) generateImage(lockCtx *fsutil.LockContext, args ffmpeg.Args) (image.Image, error) { + out, err := g.generateOutput(lockCtx, args) + if err != nil { + return nil, err + } + + img, _, err := image.Decode(bytes.NewReader(out)) + if err != nil { + return nil, fmt.Errorf("decoding image from ffmpeg: %w", err) + } + + return img, nil +} + +func (g Generator) CombineSpriteImages(images []image.Image) image.Image { + // Combine all of the thumbnails into a sprite image + width := images[0].Bounds().Size().X + height := images[0].Bounds().Size().Y + canvasWidth := width * spriteCols + canvasHeight := height * spriteRows + montage := imaging.New(canvasWidth, canvasHeight, color.NRGBA{}) + for index := 0; index < len(images); index++ { + x := width * (index % spriteCols) + y := height * int(math.Floor(float64(index)/float64(spriteRows))) + img := images[index] + montage = imaging.Paste(montage, img, image.Pt(x, y)) + } + + return montage +} + +func (g Generator) SpriteVTT(ctx context.Context, output string, spritePath string, stepSize float64) error { + lockCtx := g.LockManager.ReadLock(ctx, spritePath) + defer lockCtx.Cancel() + + return g.generateFile(lockCtx, g.ScenePaths, vttPattern, output, g.spriteVTT(spritePath, stepSize)) +} + +func (g Generator) spriteVTT(spritePath string, stepSize float64) generateFn { + return func(lockCtx *fsutil.LockContext, tmpFn string) error { + spriteImage, err := os.Open(spritePath) + if err != nil { + return err + } + defer spriteImage.Close() + spriteImageName := filepath.Base(spritePath) + image, _, err := image.DecodeConfig(spriteImage) + if err != nil { + return err + } + width := image.Width / spriteCols + height := image.Height / spriteRows + + vttLines := []string{"WEBVTT", ""} + for index := 0; index < spriteChunks; index++ { + x := width * (index % spriteCols) + y := height * int(math.Floor(float64(index)/float64(spriteRows))) + startTime := utils.GetVTTTime(float64(index) * stepSize) + endTime := utils.GetVTTTime(float64(index+1) * stepSize) + + vttLines = append(vttLines, startTime+" --> "+endTime) + vttLines = append(vttLines, fmt.Sprintf("%s#xywh=%d,%d,%d,%d", spriteImageName, x, y, width, height)) + vttLines = append(vttLines, "") + } + vtt := strings.Join(vttLines, "\n") + + return os.WriteFile(tmpFn, []byte(vtt), 0644) + } +} + +// TODO - move all sprite generation code here +// WIP +// func (g Generator) Sprite(ctx context.Context, videoFile *ffmpeg.VideoFile, hash string) error { +// input := videoFile.Path +// if err := g.generateSpriteImage(ctx, videoFile, hash); err != nil { +// return fmt.Errorf("generating sprite image for %s: %w", input, err) +// } + +// output := g.ScenePaths.GetSpriteVttFilePath(hash) +// if !g.Overwrite { +// if exists, _ := fsutil.FileExists(output); exists { +// return nil +// } +// } + +// if err := g.generateFile(ctx, g.ScenePaths, vttPattern, output, g.spriteVtt(input, screenshotOptions{ +// Time: at, +// Quality: screenshotQuality, +// // default Width is video width +// })); err != nil { +// return err +// } + +// logger.Debug("created screenshot: ", output) + +// return nil +// } + +// func (g Generator) generateSpriteImage(ctx context.Context, videoFile *ffmpeg.VideoFile, hash string) error { +// output := g.ScenePaths.GetSpriteImageFilePath(hash) +// if !g.Overwrite { +// if exists, _ := fsutil.FileExists(output); exists { +// return nil +// } +// } + +// var images []image.Image +// var err error +// if options.VideoDuration > 0 { +// images, err = g.generateSprites(ctx, input, options.VideoDuration) +// } else { +// images, err = g.generateSpritesSlow(ctx, input, options.FrameCount) +// } + +// if len(images) == 0 { +// return errors.New("images slice is empty") +// } + +// montage, err := g.combineSpriteImages(images) +// if err != nil { +// return err +// } + +// if err := imaging.Save(montage, output); err != nil { +// return err +// } + +// logger.Debug("created sprite image: ", output) + +// return nil +// } + +// func useSlowSeek(videoFile *ffmpeg.VideoFile) (bool, error) { +// // For files with small duration / low frame count try to seek using frame number intead of seconds +// // some files can have FrameCount == 0, only use SlowSeek if duration < 5 +// if videoFile.Duration < 5 || (videoFile.FrameCount > 0 && videoFile.FrameCount <= int64(spriteChunks)) { +// if videoFile.Duration <= 0 { +// return false, fmt.Errorf("duration(%.3f)/frame count(%d) invalid", videoFile.Duration, videoFile.FrameCount) +// } + +// logger.Warnf("[generator] video %s too short (%.3fs, %d frames), using frame seeking", videoFile.Path, videoFile.Duration, videoFile.FrameCount) +// return true, nil +// } +// } + +// func (g Generator) combineSpriteImages(images []image.Image) (image.Image, error) { +// // Combine all of the thumbnails into a sprite image +// width := images[0].Bounds().Size().X +// height := images[0].Bounds().Size().Y +// canvasWidth := width * spriteCols +// canvasHeight := height * spriteRows +// montage := imaging.New(canvasWidth, canvasHeight, color.NRGBA{}) +// for index := 0; index < len(images); index++ { +// x := width * (index % spriteCols) +// y := height * int(math.Floor(float64(index)/float64(spriteRows))) +// img := images[index] +// montage = imaging.Paste(montage, img, image.Pt(x, y)) +// } + +// return montage, nil +// } + +// func (g Generator) generateSprites(ctx context.Context, input string, videoDuration float64) ([]image.Image, error) { +// logger.Infof("[generator] generating sprite image for %s", input) +// // generate `ChunkCount` thumbnails +// stepSize := videoDuration / float64(spriteChunks) + +// var images []image.Image +// for i := 0; i < spriteChunks; i++ { +// time := float64(i) * stepSize + +// img, err := g.spriteScreenshot(ctx, input, time) +// if err != nil { +// return nil, err +// } +// images = append(images, img) +// } + +// return images, nil +// } + +// func (g Generator) generateSpritesSlow(ctx context.Context, input string, frameCount int) ([]image.Image, error) { +// logger.Infof("[generator] generating sprite image for %s (%d frames)", input, frameCount) + +// stepFrame := float64(frameCount-1) / float64(spriteChunks) + +// var images []image.Image +// for i := 0; i < spriteChunks; i++ { +// // generate exactly `ChunkCount` thumbnails, using duplicate frames if needed +// frame := math.Round(float64(i) * stepFrame) +// if frame >= math.MaxInt || frame <= math.MinInt { +// return nil, errors.New("invalid frame number conversion") +// } + +// img, err := g.spriteScreenshotSlow(ctx, input, int(frame)) +// if err != nil { +// return nil, err +// } +// images = append(images, img) +// } + +// return images, nil +// } + +// func (g Generator) spriteScreenshot(ctx context.Context, input string, seconds float64) (image.Image, error) { +// ssOptions := transcoder.ScreenshotOptions{ +// OutputPath: "-", +// OutputType: transcoder.ScreenshotOutputTypeBMP, +// Width: spriteScreenshotWidth, +// } + +// args := transcoder.ScreenshotTime(input, seconds, ssOptions) + +// return g.generateImage(ctx, args) +// } + +// func (g Generator) spriteScreenshotSlow(ctx context.Context, input string, frame int) (image.Image, error) { +// ssOptions := transcoder.ScreenshotOptions{ +// OutputPath: "-", +// OutputType: transcoder.ScreenshotOutputTypeBMP, +// Width: spriteScreenshotWidth, +// } + +// args := transcoder.ScreenshotFrame(input, frame, ssOptions) + +// return g.generateImage(ctx, args) +// } + +// func (g Generator) spriteVTT(videoFile ffmpeg.VideoFile, spriteImagePath string, slowSeek bool) generateFn { +// return func(ctx context.Context, tmpFn string) error { +// logger.Infof("[generator] generating sprite vtt for %s", input) + +// spriteImage, err := os.Open(spriteImagePath) +// if err != nil { +// return err +// } +// defer spriteImage.Close() +// spriteImageName := filepath.Base(spriteImagePath) +// image, _, err := image.DecodeConfig(spriteImage) +// if err != nil { +// return err +// } +// width := image.Width / spriteCols +// height := image.Height / spriteRows + +// var stepSize float64 +// if !slowSeek { +// nthFrame = g.NumberOfFrames / g.ChunkCount +// stepSize = float64(g.Info.NthFrame) / g.Info.FrameRate +// } else { +// // for files with a low framecount ( "+endTime) +// vttLines = append(vttLines, fmt.Sprintf("%s#xywh=%d,%d,%d,%d", spriteImageName, x, y, width, height)) +// vttLines = append(vttLines, "") +// } +// vtt := strings.Join(vttLines, "\n") + +// return os.WriteFile(tmpFn, []byte(vtt), 0644) +// } +// } diff --git a/pkg/scene/generate/transcode.go b/pkg/scene/generate/transcode.go new file mode 100644 index 000000000..b772a735b --- /dev/null +++ b/pkg/scene/generate/transcode.go @@ -0,0 +1,167 @@ +package generate + +import ( + "context" + + "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" +) + +type TranscodeOptions struct { + Width int + Height int +} + +func (g Generator) Transcode(ctx context.Context, input string, hash string, options TranscodeOptions) error { + lockCtx := g.LockManager.ReadLock(ctx, input) + defer lockCtx.Cancel() + + return g.makeTranscode(lockCtx, hash, g.transcode(input, options)) +} + +// 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 (g Generator) TranscodeVideo(ctx context.Context, input string, hash string, options TranscodeOptions) error { + lockCtx := g.LockManager.ReadLock(ctx, input) + defer lockCtx.Cancel() + + return g.makeTranscode(lockCtx, hash, g.transcodeVideo(input, options)) +} + +// TranscodeAudio will copy the video stream as is, and transcode audio. +func (g Generator) TranscodeAudio(ctx context.Context, input string, hash string, options TranscodeOptions) error { + lockCtx := g.LockManager.ReadLock(ctx, input) + defer lockCtx.Cancel() + + return g.makeTranscode(lockCtx, hash, g.transcodeAudio(input, options)) +} + +// TranscodeCopyVideo will copy the video stream as is, and drop the audio stream. +func (g Generator) TranscodeCopyVideo(ctx context.Context, input string, hash string, options TranscodeOptions) error { + lockCtx := g.LockManager.ReadLock(ctx, input) + defer lockCtx.Cancel() + + return g.makeTranscode(lockCtx, hash, g.transcodeCopyVideo(input, options)) +} + +func (g Generator) makeTranscode(lockCtx *fsutil.LockContext, hash string, generateFn generateFn) error { + output := g.ScenePaths.GetTranscodePath(hash) + if !g.Overwrite { + if exists, _ := fsutil.FileExists(output); exists { + return nil + } + } + + if err := g.generateFile(lockCtx, g.ScenePaths, mp4Pattern, output, generateFn); err != nil { + return err + } + + logger.Debug("created transcode: ", output) + + return nil +} + +func (g Generator) transcode(input string, options TranscodeOptions) generateFn { + return func(lockCtx *fsutil.LockContext, tmpFn string) error { + var videoArgs ffmpeg.Args + if options.Width != 0 && options.Height != 0 { + var videoFilter ffmpeg.VideoFilter + videoFilter = videoFilter.ScaleDimensions(options.Width, options.Height) + videoArgs = videoArgs.VideoFilter(videoFilter) + } + + videoArgs = append(videoArgs, + "-pix_fmt", "yuv420p", + "-profile:v", "high", + "-level", "4.2", + "-preset", "superfast", + "-crf", "23", + ) + + args := transcoder.Transcode(input, transcoder.TranscodeOptions{ + OutputPath: tmpFn, + VideoCodec: ffmpeg.VideoCodecLibX264, + VideoArgs: videoArgs, + AudioCodec: ffmpeg.AudioCodecAAC, + }) + + return g.generate(lockCtx, args) + } +} + +func (g Generator) transcodeVideo(input string, options TranscodeOptions) generateFn { + return func(lockCtx *fsutil.LockContext, tmpFn string) error { + var videoArgs ffmpeg.Args + if options.Width != 0 && options.Height != 0 { + var videoFilter ffmpeg.VideoFilter + videoFilter = videoFilter.ScaleDimensions(options.Width, options.Height) + videoArgs = videoArgs.VideoFilter(videoFilter) + } + + videoArgs = append(videoArgs, + "-pix_fmt", "yuv420p", + "-profile:v", "high", + "-level", "4.2", + "-preset", "superfast", + "-crf", "23", + ) + + var audioArgs ffmpeg.Args + audioArgs = audioArgs.SkipAudio() + + args := transcoder.Transcode(input, transcoder.TranscodeOptions{ + OutputPath: tmpFn, + VideoCodec: ffmpeg.VideoCodecLibX264, + VideoArgs: videoArgs, + AudioArgs: audioArgs, + }) + + return g.generate(lockCtx, args) + } +} + +func (g Generator) transcodeAudio(input string, options TranscodeOptions) generateFn { + return func(lockCtx *fsutil.LockContext, tmpFn string) error { + var videoArgs ffmpeg.Args + if options.Width != 0 && options.Height != 0 { + var videoFilter ffmpeg.VideoFilter + videoFilter = videoFilter.ScaleDimensions(options.Width, options.Height) + videoArgs = videoArgs.VideoFilter(videoFilter) + } + + args := transcoder.Transcode(input, transcoder.TranscodeOptions{ + OutputPath: tmpFn, + VideoCodec: ffmpeg.VideoCodecCopy, + VideoArgs: videoArgs, + AudioCodec: ffmpeg.AudioCodecAAC, + }) + + return g.generate(lockCtx, args) + } +} + +func (g Generator) transcodeCopyVideo(input string, options TranscodeOptions) generateFn { + return func(lockCtx *fsutil.LockContext, tmpFn string) error { + var videoArgs ffmpeg.Args + if options.Width != 0 && options.Height != 0 { + var videoFilter ffmpeg.VideoFilter + videoFilter = videoFilter.ScaleDimensions(options.Width, options.Height) + videoArgs = videoArgs.VideoFilter(videoFilter) + } + + var audioArgs ffmpeg.Args + audioArgs = audioArgs.SkipAudio() + + args := transcoder.Transcode(input, transcoder.TranscodeOptions{ + OutputPath: tmpFn, + VideoCodec: ffmpeg.VideoCodecCopy, + VideoArgs: videoArgs, + AudioArgs: audioArgs, + }) + + return g.generate(lockCtx, args) + } +} diff --git a/pkg/scene/migrate_hash.go b/pkg/scene/migrate_hash.go index ebc00bfb8..0a45f1456 100644 --- a/pkg/scene/migrate_hash.go +++ b/pkg/scene/migrate_hash.go @@ -23,12 +23,12 @@ func MigrateHash(p *paths.Paths, oldHash string, newHash string) { newPath = scenePaths.GetScreenshotPath(newHash) migrateSceneFiles(oldPath, newPath) - oldPath = scenePaths.GetStreamPreviewPath(oldHash) - newPath = scenePaths.GetStreamPreviewPath(newHash) + oldPath = scenePaths.GetVideoPreviewPath(oldHash) + newPath = scenePaths.GetVideoPreviewPath(newHash) migrateSceneFiles(oldPath, newPath) - oldPath = scenePaths.GetStreamPreviewImagePath(oldHash) - newPath = scenePaths.GetStreamPreviewImagePath(newHash) + oldPath = scenePaths.GetWebpPreviewPath(oldHash) + newPath = scenePaths.GetWebpPreviewPath(newHash) migrateSceneFiles(oldPath, newPath) oldPath = scenePaths.GetTranscodePath(oldHash) diff --git a/pkg/scene/scan.go b/pkg/scene/scan.go index 96de4d6ff..bb0acab77 100644 --- a/pkg/scene/scan.go +++ b/pkg/scene/scan.go @@ -5,6 +5,7 @@ import ( "database/sql" "fmt" "os" + "path/filepath" "strconv" "strings" "time" @@ -22,7 +23,7 @@ import ( const mutexType = "scene" type videoFileCreator interface { - NewVideoFile(path string, stripFileExtension bool) (*ffmpeg.VideoFile, error) + NewVideoFile(path string) (*ffmpeg.VideoFile, error) } type Scanner struct { @@ -70,12 +71,14 @@ func (scanner *Scanner) ScanExisting(ctx context.Context, existing file.FileBase s.SetFile(*scanned.New) - videoFile, err = scanner.VideoFileCreator.NewVideoFile(path, scanner.StripFileExtension) + videoFile, err = scanner.VideoFileCreator.NewVideoFile(path) if err != nil { return err } - videoFileToScene(s, videoFile) + if err := videoFileToScene(s, videoFile); err != nil { + return err + } changed = true } else if scanned.FileUpdated() || s.Interactive != interactive { logger.Infof("Updated scene file %s", path) @@ -88,12 +91,15 @@ func (scanner *Scanner) ScanExisting(ctx context.Context, existing file.FileBase // check for container if !s.Format.Valid { if videoFile == nil { - videoFile, err = scanner.VideoFileCreator.NewVideoFile(path, scanner.StripFileExtension) + videoFile, err = scanner.VideoFileCreator.NewVideoFile(path) if err != nil { return err } } - container := ffmpeg.MatchContainer(videoFile.Container, path) + container, err := ffmpeg.MatchContainer(videoFile.Container, path) + if err != nil { + return fmt.Errorf("getting container for %s: %w", path, err) + } logger.Infof("Adding container %s to file %s", container, path) s.Format = models.NullString(string(container)) changed = true @@ -233,14 +239,18 @@ func (scanner *Scanner) ScanNew(ctx context.Context, file file.SourceFile) (retS logger.Infof("%s doesn't exist. Creating new item...", path) currentTime := time.Now() - videoFile, err := scanner.VideoFileCreator.NewVideoFile(path, scanner.StripFileExtension) + videoFile, err := scanner.VideoFileCreator.NewVideoFile(path) if err != nil { return nil, err } - // Override title to be filename if UseFileMetadata is false - if !scanner.UseFileMetadata { - videoFile.SetTitleFromPath(scanner.StripFileExtension) + title := filepath.Base(path) + if scanner.StripFileExtension { + title = stripExtension(title) + } + + if scanner.UseFileMetadata && videoFile.Title != "" { + title = videoFile.Title } newScene := models.Scene{ @@ -251,13 +261,15 @@ func (scanner *Scanner) ScanNew(ctx context.Context, file file.SourceFile) (retS Timestamp: scanned.FileModTime, Valid: true, }, - Title: sql.NullString{String: videoFile.Title, Valid: true}, + Title: sql.NullString{String: title, Valid: true}, CreatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime}, Interactive: interactive, } - videoFileToScene(&newScene, videoFile) + if err := videoFileToScene(&newScene, videoFile); err != nil { + return nil, err + } if scanner.UseFileMetadata { newScene.Details = sql.NullString{String: videoFile.Comment, Valid: true} @@ -279,8 +291,16 @@ func (scanner *Scanner) ScanNew(ctx context.Context, file file.SourceFile) (retS return retScene, nil } -func videoFileToScene(s *models.Scene, videoFile *ffmpeg.VideoFile) { - container := ffmpeg.MatchContainer(videoFile.Container, s.Path) +func stripExtension(path string) string { + ext := filepath.Ext(path) + return strings.TrimSuffix(path, ext) +} + +func videoFileToScene(s *models.Scene, videoFile *ffmpeg.VideoFile) error { + container, err := ffmpeg.MatchContainer(videoFile.Container, s.Path) + if err != nil { + return fmt.Errorf("matching container: %w", err) + } s.Duration = sql.NullFloat64{Float64: videoFile.Duration, Valid: true} s.VideoCodec = sql.NullString{String: videoFile.VideoCodec, Valid: true} @@ -291,6 +311,8 @@ func videoFileToScene(s *models.Scene, videoFile *ffmpeg.VideoFile) { s.Framerate = sql.NullFloat64{Float64: videoFile.FrameRate, Valid: true} s.Bitrate = sql.NullInt64{Int64: videoFile.Bitrate, Valid: true} s.Size = sql.NullString{String: strconv.FormatInt(videoFile.Size, 10), Valid: true} + + return nil } func (scanner *Scanner) makeScreenshots(path string, probeResult *ffmpeg.VideoFile, checksum string) { @@ -306,7 +328,7 @@ func (scanner *Scanner) makeScreenshots(path string, probeResult *ffmpeg.VideoFi if probeResult == nil { var err error - probeResult, err = scanner.VideoFileCreator.NewVideoFile(path, scanner.StripFileExtension) + probeResult, err = scanner.VideoFileCreator.NewVideoFile(path) if err != nil { logger.Error(err.Error()) @@ -315,16 +337,18 @@ func (scanner *Scanner) makeScreenshots(path string, probeResult *ffmpeg.VideoFi logger.Infof("Regenerating images for %s", path) } - at := float64(probeResult.Duration) * 0.2 - if !thumbExists { logger.Debugf("Creating thumbnail for %s", path) - makeScreenshot(scanner.Screenshotter, *probeResult, thumbPath, 5, 320, at) + if err := scanner.Screenshotter.GenerateThumbnail(context.TODO(), probeResult, checksum); err != nil { + logger.Errorf("Error creating thumbnail for %s: %v", err) + } } if !normalExists { logger.Debugf("Creating screenshot for %s", path) - makeScreenshot(scanner.Screenshotter, *probeResult, normalPath, 2, probeResult.Width, at) + if err := scanner.Screenshotter.GenerateScreenshot(context.TODO(), probeResult, checksum); err != nil { + logger.Errorf("Error creating screenshot for %s: %v", err) + } } } diff --git a/pkg/scene/screenshot.go b/pkg/scene/screenshot.go index dca044de3..36f301b51 100644 --- a/pkg/scene/screenshot.go +++ b/pkg/scene/screenshot.go @@ -2,12 +2,12 @@ package scene import ( "bytes" + "context" "image" "image/jpeg" "os" "github.com/stashapp/stash/pkg/ffmpeg" - "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/paths" @@ -19,20 +19,8 @@ import ( ) type screenshotter interface { - Screenshot(probeResult ffmpeg.VideoFile, options ffmpeg.ScreenshotOptions) error -} - -func makeScreenshot(encoder screenshotter, probeResult ffmpeg.VideoFile, outputPath string, quality int, width int, time float64) { - options := ffmpeg.ScreenshotOptions{ - OutputPath: outputPath, - Quality: quality, - Time: time, - Width: width, - } - - if err := encoder.Screenshot(probeResult, options); err != nil { - logger.Warnf("[encoder] failure to generate screenshot: %v", err) - } + GenerateScreenshot(ctx context.Context, probeResult *ffmpeg.VideoFile, hash string) error + GenerateThumbnail(ctx context.Context, probeResult *ffmpeg.VideoFile, hash string) error } type ScreenshotSetter interface { diff --git a/pkg/studio/export.go b/pkg/studio/export.go index 82ebc9f40..951a60417 100644 --- a/pkg/studio/export.go +++ b/pkg/studio/export.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/utils" ) @@ -12,8 +13,8 @@ import ( func ToJSON(reader models.StudioReader, studio *models.Studio) (*jsonschema.Studio, error) { newStudioJSON := jsonschema.Studio{ IgnoreAutoTag: studio.IgnoreAutoTag, - CreatedAt: models.JSONTime{Time: studio.CreatedAt.Timestamp}, - UpdatedAt: models.JSONTime{Time: studio.UpdatedAt.Timestamp}, + CreatedAt: json.JSONTime{Time: studio.CreatedAt.Timestamp}, + UpdatedAt: json.JSONTime{Time: studio.UpdatedAt.Timestamp}, } if studio.Name.Valid { diff --git a/pkg/studio/export_test.go b/pkg/studio/export_test.go index f4e17e59e..a1f261254 100644 --- a/pkg/studio/export_test.go +++ b/pkg/studio/export_test.go @@ -4,6 +4,7 @@ import ( "errors" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" @@ -95,10 +96,10 @@ func createFullJSONStudio(parentStudio, image string, aliases []string) *jsonsch Name: studioName, URL: url, Details: details, - CreatedAt: models.JSONTime{ + CreatedAt: json.JSONTime{ Time: createTime, }, - UpdatedAt: models.JSONTime{ + UpdatedAt: json.JSONTime{ Time: updateTime, }, ParentStudio: parentStudio, @@ -114,10 +115,10 @@ func createFullJSONStudio(parentStudio, image string, aliases []string) *jsonsch func createEmptyJSONStudio() *jsonschema.Studio { return &jsonschema.Studio{ - CreatedAt: models.JSONTime{ + CreatedAt: json.JSONTime{ Time: createTime, }, - UpdatedAt: models.JSONTime{ + UpdatedAt: json.JSONTime{ Time: updateTime, }, } diff --git a/pkg/tag/export.go b/pkg/tag/export.go index bc9ba309f..e70392379 100644 --- a/pkg/tag/export.go +++ b/pkg/tag/export.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/utils" ) @@ -13,8 +14,8 @@ func ToJSON(reader models.TagReader, tag *models.Tag) (*jsonschema.Tag, error) { newTagJSON := jsonschema.Tag{ Name: tag.Name, IgnoreAutoTag: tag.IgnoreAutoTag, - CreatedAt: models.JSONTime{Time: tag.CreatedAt.Timestamp}, - UpdatedAt: models.JSONTime{Time: tag.UpdatedAt.Timestamp}, + CreatedAt: json.JSONTime{Time: tag.CreatedAt.Timestamp}, + UpdatedAt: json.JSONTime{Time: tag.UpdatedAt.Timestamp}, } aliases, err := reader.GetAliases(tag.ID) diff --git a/pkg/tag/export_test.go b/pkg/tag/export_test.go index b7d60fa9d..930c0fdb1 100644 --- a/pkg/tag/export_test.go +++ b/pkg/tag/export_test.go @@ -4,6 +4,7 @@ import ( "errors" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/mocks" "github.com/stretchr/testify/assert" @@ -48,10 +49,10 @@ func createJSONTag(aliases []string, image string, parents []string) *jsonschema Name: tagName, Aliases: aliases, IgnoreAutoTag: autoTagIgnored, - CreatedAt: models.JSONTime{ + CreatedAt: json.JSONTime{ Time: createTime, }, - UpdatedAt: models.JSONTime{ + UpdatedAt: json.JSONTime{ Time: updateTime, }, Image: image,