diff --git a/api/resolver_query_metadata.go b/api/resolver_query_metadata.go index b0e9f63cf..d82c5949d 100644 --- a/api/resolver_query_metadata.go +++ b/api/resolver_query_metadata.go @@ -21,7 +21,8 @@ func (r *queryResolver) MetadataExport(ctx context.Context) (string, error) { } func (r *queryResolver) MetadataGenerate(ctx context.Context) (string, error) { - panic("not implemented") + manager.GetInstance().Generate(true, true, true, true) + return "todo", nil } func (r *queryResolver) MetadataClean(ctx context.Context) (string, error) { diff --git a/ffmpeg/encoder.go b/ffmpeg/encoder.go index b5ceff2ae..bea947558 100644 --- a/ffmpeg/encoder.go +++ b/ffmpeg/encoder.go @@ -1,7 +1,6 @@ package ffmpeg import ( - "fmt" "github.com/stashapp/stash/logger" "io/ioutil" "os/exec" @@ -11,65 +10,17 @@ import ( var progressRegex = regexp.MustCompile(`time=(\d+):(\d+):(\d+.\d+)`) -type encoder struct { +type Encoder struct { Path string } -func NewEncoder(ffmpegPath string) encoder { - return encoder{ +func NewEncoder(ffmpegPath string) Encoder { + return Encoder{ Path: ffmpegPath, } } -type ScreenshotOptions struct { - OutputPath string - Quality int - Time float64 - Width int - Verbosity string -} - -type TranscodeOptions struct { - OutputPath string -} - -func (e *encoder) Screenshot(probeResult FFProbeResult, options ScreenshotOptions) { - if options.Verbosity == "" { - options.Verbosity = "quiet" - } - if options.Quality == 0 { - options.Quality = 1 - } - args := []string{ - "-v", options.Verbosity, - "-ss", fmt.Sprintf("%v", options.Time), - "-y", - "-i", probeResult.Path, // TODO: Wrap in quotes? - "-vframes", "1", - "-q:v", fmt.Sprintf("%v", options.Quality), - "-vf", fmt.Sprintf("scale=%v:-1", options.Width), - "-f", "image2", - options.OutputPath, - } - _, _ = e.run(probeResult, args) -} - -func (e *encoder) Transcode(probeResult FFProbeResult, options TranscodeOptions) { - args := []string{ - "-i", probeResult.Path, - "-c:v", "libx264", - "-profile:v", "high", - "-level", "4.2", - "-preset", "superfast", - "-crf", "23", - "-vf", "scale=iw:-2", - "-c:a", "aac", - options.OutputPath, - } - _, _ = e.run(probeResult, args) -} - -func (e *encoder) run(probeResult FFProbeResult, args []string) (string, error) { +func (e *Encoder) run(probeResult VideoFile, args []string) (string, error) { cmd := exec.Command(e.Path, args...) stderr, err := cmd.StderrPipe() diff --git a/ffmpeg/encoder_scene_preview_chunk.go b/ffmpeg/encoder_scene_preview_chunk.go new file mode 100644 index 000000000..5d5d420c3 --- /dev/null +++ b/ffmpeg/encoder_scene_preview_chunk.go @@ -0,0 +1,65 @@ +package ffmpeg + +import ( + "fmt" + "strconv" +) + +type ScenePreviewChunkOptions struct { + Time int + Width int + OutputPath string +} + +func (e *Encoder) ScenePreviewVideoChunk(probeResult VideoFile, options ScenePreviewChunkOptions) { + args := []string{ + "-v", "quiet", + "-ss", strconv.Itoa(options.Time), + "-t", "0.75", + "-i", probeResult.Path, + "-y", + "-c:v", "libx264", + "-profile:v", "high", + "-level", "4.2", + "-preset", "veryslow", + "-crf", "21", + "-threads", "4", + "-vf", fmt.Sprintf("scale=%v:-2", options.Width), + "-c:a", "aac", + "-b:a", "128k", + options.OutputPath, + } + _, _ = e.run(probeResult, args) +} + +func (e *Encoder) ScenePreviewVideoChunkCombine(probeResult VideoFile, concatFilePath string, outputPath string) { + args := []string{ + "-v", "quiet", + "-f", "concat", + "-i", concatFilePath, + "-y", + "-c", "copy", + outputPath, + } + _, _ = e.run(probeResult, args) +} + +func (e *Encoder) ScenePreviewVideoToImage(probeResult VideoFile, width int, videoPreviewPath string, outputPath string) error { + args := []string{ + "-v", "quiet", + "-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, args) + return err +} \ No newline at end of file diff --git a/ffmpeg/encoder_screenshot.go b/ffmpeg/encoder_screenshot.go new file mode 100644 index 000000000..36fc56032 --- /dev/null +++ b/ffmpeg/encoder_screenshot.go @@ -0,0 +1,32 @@ +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) { + if options.Verbosity == "" { + options.Verbosity = "quiet" + } + if options.Quality == 0 { + options.Quality = 1 + } + args := []string{ + "-v", options.Verbosity, + "-ss", fmt.Sprintf("%v", options.Time), + "-y", + "-i", probeResult.Path, // TODO: Wrap in quotes? + "-vframes", "1", + "-q:v", fmt.Sprintf("%v", options.Quality), + "-vf", fmt.Sprintf("scale=%v:-1", options.Width), + "-f", "image2", + options.OutputPath, + } + _, _ = e.run(probeResult, args) +} \ No newline at end of file diff --git a/ffmpeg/encoder_transcode.go b/ffmpeg/encoder_transcode.go new file mode 100644 index 000000000..47e625644 --- /dev/null +++ b/ffmpeg/encoder_transcode.go @@ -0,0 +1,20 @@ +package ffmpeg + +type TranscodeOptions struct { + OutputPath string +} + +func (e *Encoder) Transcode(probeResult VideoFile, options TranscodeOptions) { + args := []string{ + "-i", probeResult.Path, + "-c:v", "libx264", + "-profile:v", "high", + "-level", "4.2", + "-preset", "superfast", + "-crf", "23", + "-vf", "scale=iw:-2", + "-c:a", "aac", + options.OutputPath, + } + _, _ = e.run(probeResult, args) +} \ No newline at end of file diff --git a/ffmpeg/ffprobe.go b/ffmpeg/ffprobe.go index 078a322e5..6f477fc08 100644 --- a/ffmpeg/ffprobe.go +++ b/ffmpeg/ffprobe.go @@ -6,18 +6,26 @@ import ( "math" "os" "os/exec" - "runtime" "strconv" "strings" "time" ) -type ffprobeExecutable struct { - Path string +var ValidCodecs = []string{"h264", "h265", "vp8", "vp9"} + +func IsValidCodec(codecName string) bool { + for _, c := range ValidCodecs { + if c == codecName { + return true + } + } + return false } -type FFProbeResult struct { - JSON ffprobeJSON +type VideoFile struct { + JSON FFProbeJSON + AudioStream *FFProbeStream + VideoStream *FFProbeStream Path string Container string @@ -37,52 +45,62 @@ type FFProbeResult struct { AudioCodec string } -func NewFFProbe(ffprobePath string) ffprobeExecutable { - return ffprobeExecutable{ - Path: ffprobePath, - } -} - // Execute exec command and bind result to struct. -func (ffp *ffprobeExecutable) ProbeVideo(filePath string) (*FFProbeResult, error) { - args := []string{"-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", "-show_error", filePath} - // Extremely slow on windows for some reason - if runtime.GOOS != "windows" { - args = append(args, "-count_frames") - } - out, err := exec.Command(ffp.Path, args...).Output() +func NewVideoFile(ffprobePath string, videoPath string) (*VideoFile, error) { + args := []string{"-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", "-show_error", videoPath} + //// Extremely slow on windows for some reason + //if runtime.GOOS != "windows" { + // args = append(args, "-count_frames") + //} + out, err := exec.Command(ffprobePath, args...).Output() if err != nil { - return nil, fmt.Errorf("FFProbe encountered an error with <%s>.\nError JSON:\n%s\nError: %s", filePath, string(out), err.Error()) + return nil, fmt.Errorf("FFProbe encountered an error with <%s>.\nError JSON:\n%s\nError: %s", videoPath, string(out), err.Error()) } - probeJSON := &ffprobeJSON{} + probeJSON := &FFProbeJSON{} if err := json.Unmarshal(out, probeJSON); err != nil { return nil, err } - result := ffp.newProbeResult(filePath, *probeJSON) - return result, nil + return parse(videoPath, probeJSON) } -func (ffp *ffprobeExecutable) newProbeResult(filePath string, probeJson ffprobeJSON) *FFProbeResult { - videoStreamIndex := ffp.getStreamIndex("video", probeJson) - audioStreamIndex := ffp.getStreamIndex("audio", probeJson) +func parse(filePath string, probeJSON *FFProbeJSON) (*VideoFile, error) { + if probeJSON == nil { + return nil, fmt.Errorf("failed to get ffprobe json") + } + + result := &VideoFile{} + result.JSON = *probeJSON + + if result.JSON.Error.Code != 0 { + return nil, fmt.Errorf("ffprobe error code %d: %s", result.JSON.Error.Code, result.JSON.Error.String) + } + //} else if (ffprobeResult.stderr.includes("could not find codec parameters")) { + // throw new Error(`FFProbe [${filePath}] -> Could not find codec parameters`); + //} // TODO nil_or_unsupported.(video_stream) && nil_or_unsupported.(audio_stream) - result := &FFProbeResult{} - result.JSON = probeJson result.Path = filePath - result.Container = probeJson.Format.FormatName - duration, _ := strconv.ParseFloat(probeJson.Format.Duration, 64) + + result.Bitrate, _ = strconv.ParseInt(probeJSON.Format.BitRate, 10, 64) + result.Container = probeJSON.Format.FormatName + duration, _ := strconv.ParseFloat(probeJSON.Format.Duration, 64) result.Duration = math.Round(duration*100)/100 - result.StartTime, _ = strconv.ParseFloat(probeJson.Format.StartTime, 64) - result.Bitrate, _ = strconv.ParseInt(probeJson.Format.BitRate, 10, 64) fileStat, _ := os.Stat(filePath) result.Size = fileStat.Size() - result.CreationTime = probeJson.Format.Tags.CreationTime + result.StartTime, _ = strconv.ParseFloat(probeJSON.Format.StartTime, 64) + result.CreationTime = probeJSON.Format.Tags.CreationTime - if videoStreamIndex != -1 { - videoStream := probeJson.Streams[videoStreamIndex] + audioStream := result.GetAudioStream() + if audioStream != nil { + result.AudioCodec = audioStream.CodecName + result.AudioStream = audioStream + } + + videoStream := result.GetVideoStream() + if videoStream != nil { + result.VideoStream = videoStream result.VideoCodec = videoStream.CodecName result.VideoBitrate, _ = strconv.ParseInt(videoStream.BitRate, 10, 64) var framerate float64 @@ -104,14 +122,26 @@ func (ffp *ffprobeExecutable) newProbeResult(filePath string, probeJson ffprobeJ } } - if audioStreamIndex != -1 { - result.AudioCodec = probeJson.Streams[audioStreamIndex].CodecName - } - - return result + return result, nil } -func (ffp *ffprobeExecutable) getStreamIndex(fileType string, probeJson ffprobeJSON) int { +func (v *VideoFile) GetAudioStream() *FFProbeStream { + index := v.getStreamIndex("audio", v.JSON) + if index != -1 { + return &v.JSON.Streams[index] + } + return nil +} + +func (v *VideoFile) GetVideoStream() *FFProbeStream { + index := v.getStreamIndex("video", v.JSON) + if index != -1 { + return &v.JSON.Streams[index] + } + return nil +} + +func (v *VideoFile) getStreamIndex(fileType string, probeJson FFProbeJSON) int { for i, stream := range probeJson.Streams { if stream.CodecType == fileType { return i diff --git a/ffmpeg/types.go b/ffmpeg/types.go index b042eddc9..8eebea1fc 100644 --- a/ffmpeg/types.go +++ b/ffmpeg/types.go @@ -4,7 +4,7 @@ import ( "time" ) -type ffprobeJSON struct { +type FFProbeJSON struct { Format struct { BitRate string `json:"bit_rate"` Duration string `json:"duration"` @@ -24,67 +24,69 @@ type ffprobeJSON struct { MinorVersion string `json:"minor_version"` } `json:"tags"` } `json:"format"` - Streams []struct { - AvgFrameRate string `json:"avg_frame_rate"` - BitRate string `json:"bit_rate"` - BitsPerRawSample string `json:"bits_per_raw_sample,omitempty"` - ChromaLocation string `json:"chroma_location,omitempty"` - CodecLongName string `json:"codec_long_name"` - CodecName string `json:"codec_name"` - CodecTag string `json:"codec_tag"` - CodecTagString string `json:"codec_tag_string"` - CodecTimeBase string `json:"codec_time_base"` - CodecType string `json:"codec_type"` - CodedHeight int `json:"coded_height,omitempty"` - CodedWidth int `json:"coded_width,omitempty"` - DisplayAspectRatio string `json:"display_aspect_ratio,omitempty"` - Disposition struct { - AttachedPic int `json:"attached_pic"` - CleanEffects int `json:"clean_effects"` - Comment int `json:"comment"` - Default int `json:"default"` - Dub int `json:"dub"` - Forced int `json:"forced"` - HearingImpaired int `json:"hearing_impaired"` - Karaoke int `json:"karaoke"` - Lyrics int `json:"lyrics"` - Original int `json:"original"` - TimedThumbnails int `json:"timed_thumbnails"` - VisualImpaired int `json:"visual_impaired"` - } `json:"disposition"` - Duration string `json:"duration"` - DurationTs int `json:"duration_ts"` - HasBFrames int `json:"has_b_frames,omitempty"` - Height int `json:"height,omitempty"` - Index int `json:"index"` - IsAvc string `json:"is_avc,omitempty"` - Level int `json:"level,omitempty"` - NalLengthSize string `json:"nal_length_size,omitempty"` - NbFrames string `json:"nb_frames"` - PixFmt string `json:"pix_fmt,omitempty"` - Profile string `json:"profile"` - RFrameRate string `json:"r_frame_rate"` - Refs int `json:"refs,omitempty"` - SampleAspectRatio string `json:"sample_aspect_ratio,omitempty"` - StartPts int `json:"start_pts"` - StartTime string `json:"start_time"` - Tags struct { - CreationTime time.Time `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"` - BitsPerSample int `json:"bits_per_sample,omitempty"` - ChannelLayout string `json:"channel_layout,omitempty"` - Channels int `json:"channels,omitempty"` - MaxBitRate string `json:"max_bit_rate,omitempty"` - SampleFmt string `json:"sample_fmt,omitempty"` - SampleRate string `json:"sample_rate,omitempty"` - } `json:"streams"` + Streams []FFProbeStream `json:"streams"` Error struct { Code int `json:"code"` String string `json:"string"` } `json:"error"` +} + +type FFProbeStream struct { + AvgFrameRate string `json:"avg_frame_rate"` + BitRate string `json:"bit_rate"` + BitsPerRawSample string `json:"bits_per_raw_sample,omitempty"` + ChromaLocation string `json:"chroma_location,omitempty"` + CodecLongName string `json:"codec_long_name"` + CodecName string `json:"codec_name"` + CodecTag string `json:"codec_tag"` + CodecTagString string `json:"codec_tag_string"` + CodecTimeBase string `json:"codec_time_base"` + CodecType string `json:"codec_type"` + CodedHeight int `json:"coded_height,omitempty"` + CodedWidth int `json:"coded_width,omitempty"` + DisplayAspectRatio string `json:"display_aspect_ratio,omitempty"` + Disposition struct { + AttachedPic int `json:"attached_pic"` + CleanEffects int `json:"clean_effects"` + Comment int `json:"comment"` + Default int `json:"default"` + Dub int `json:"dub"` + Forced int `json:"forced"` + HearingImpaired int `json:"hearing_impaired"` + Karaoke int `json:"karaoke"` + Lyrics int `json:"lyrics"` + Original int `json:"original"` + TimedThumbnails int `json:"timed_thumbnails"` + VisualImpaired int `json:"visual_impaired"` + } `json:"disposition"` + Duration string `json:"duration"` + DurationTs int `json:"duration_ts"` + HasBFrames int `json:"has_b_frames,omitempty"` + Height int `json:"height,omitempty"` + Index int `json:"index"` + IsAvc string `json:"is_avc,omitempty"` + Level int `json:"level,omitempty"` + NalLengthSize string `json:"nal_length_size,omitempty"` + NbFrames string `json:"nb_frames"` + PixFmt string `json:"pix_fmt,omitempty"` + Profile string `json:"profile"` + RFrameRate string `json:"r_frame_rate"` + Refs int `json:"refs,omitempty"` + SampleAspectRatio string `json:"sample_aspect_ratio,omitempty"` + StartPts int `json:"start_pts"` + StartTime string `json:"start_time"` + Tags struct { + CreationTime time.Time `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"` + BitsPerSample int `json:"bits_per_sample,omitempty"` + ChannelLayout string `json:"channel_layout,omitempty"` + Channels int `json:"channels,omitempty"` + MaxBitRate string `json:"max_bit_rate,omitempty"` + SampleFmt string `json:"sample_fmt,omitempty"` + SampleRate string `json:"sample_rate,omitempty"` } \ No newline at end of file diff --git a/manager/generator.go b/manager/generator.go new file mode 100644 index 000000000..7f36d5e12 --- /dev/null +++ b/manager/generator.go @@ -0,0 +1,62 @@ +package manager + +import ( + "fmt" + "github.com/stashapp/stash/ffmpeg" + "github.com/stashapp/stash/logger" + "github.com/stashapp/stash/utils" + "os/exec" + "strconv" +) + +type Generator struct { + ChunkCount int + FrameRate float64 + NumberOfFrames int + NthFrame int + + VideoFile ffmpeg.VideoFile +} + +func newGenerator(videoFile ffmpeg.VideoFile) (*Generator, error) { + exists, err := utils.FileExists(videoFile.Path) + if !exists { + logger.Errorf("video file not found") + return nil, err + } + + generator := &Generator{VideoFile: videoFile} + return generator, nil +} + +func (g *Generator) configure() error { + videoStream := g.VideoFile.VideoStream + if videoStream == nil { + return fmt.Errorf("missing video stream") + } + + var framerate float64 + if g.VideoFile.FrameRate == 0 { + framerate, _ = strconv.ParseFloat(videoStream.RFrameRate, 64) + } else { + framerate = g.VideoFile.FrameRate + } + g.FrameRate = framerate + + numberOfFrames, _ := strconv.Atoi(videoStream.NbFrames) + if numberOfFrames == 0 { + command := `ffmpeg -nostats -i `+g.VideoFile.Path+` -vcodec copy -f rawvideo -y /dev/null 2>&1 | \ + grep frame | \ + awk '{split($0,a,"fps")}END{print a[1]}' | \ + sed 's/.*= *//'` + commandResult, _ := exec.Command(command).Output() + numberOfFrames, _ := strconv.Atoi(string(commandResult)) + if numberOfFrames == 0 { // TODO: test + numberOfFrames = int(framerate * g.VideoFile.Duration) + } + } + g.NumberOfFrames = numberOfFrames + g.NthFrame = g.NumberOfFrames / g.ChunkCount + + return nil +} diff --git a/manager/generator_preview.go b/manager/generator_preview.go new file mode 100644 index 000000000..7c68e73a9 --- /dev/null +++ b/manager/generator_preview.go @@ -0,0 +1,127 @@ +package manager + +import ( + "bufio" + "fmt" + "github.com/stashapp/stash/ffmpeg" + "github.com/stashapp/stash/logger" + "github.com/stashapp/stash/utils" + "os" + "path" +) + +type PreviewGenerator struct { + generator *Generator + + VideoFilename string + ImageFilename string + OutputDirectory string +} + +func NewPreviewGenerator(videoFile ffmpeg.VideoFile, videoFilename string, imageFilename string, outputDirectory string) (*PreviewGenerator, error) { + exists, err := utils.FileExists(videoFile.Path) + if !exists { + return nil, err + } + generator, err := newGenerator(videoFile) + if err != nil { + return nil, err + } + generator.ChunkCount = 12 // 12 segments to the preview + if err := generator.configure(); err != nil { + return nil, err + } + + return &PreviewGenerator{ + generator: generator, + VideoFilename: videoFilename, + ImageFilename: imageFilename, + OutputDirectory: outputDirectory, + }, nil +} + +func (g *PreviewGenerator) Generate() error { + instance.Paths.Generated.EmptyTmpDir() + logger.Infof("[generator] generating scene preview for %s", g.generator.VideoFile.Path) + encoder := ffmpeg.NewEncoder(instance.Paths.FixedPaths.FFMPEG) + + if err := g.generateConcatFile(); err != nil { + return err + } + if err := g.generateVideo(&encoder); err != nil { + return err + } + 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.generator.ChunkCount; i++ { + num := fmt.Sprintf("%.3d", i) + filename := "preview"+num+".mp4" + _, _ = w.WriteString(fmt.Sprintf("file '%s'\n", filename)) + } + return w.Flush() +} + +func (g *PreviewGenerator) generateVideo(encoder *ffmpeg.Encoder) error { + outputPath := path.Join(g.OutputDirectory, g.VideoFilename) + outputExists, _ := utils.FileExists(outputPath) + if outputExists { + return nil + } + + stepSize := int(g.generator.VideoFile.Duration / float64(g.generator.ChunkCount)) + for i := 0; i < g.generator.ChunkCount; i++ { + time := i * stepSize + num := fmt.Sprintf("%.3d", i) + filename := "preview"+num+".mp4" + chunkOutputPath := instance.Paths.Generated.GetTmpPath(filename) + + options := ffmpeg.ScenePreviewChunkOptions{ + Time: time, + Width: 640, + OutputPath: chunkOutputPath, + } + encoder.ScenePreviewVideoChunk(g.generator.VideoFile, options) + } + + videoOutputPath := path.Join(g.OutputDirectory, g.VideoFilename) + encoder.ScenePreviewVideoChunkCombine(g.generator.VideoFile, g.getConcatFilePath(), videoOutputPath) + logger.Debug("created video preview: ", videoOutputPath) + return nil +} + +func (g *PreviewGenerator) generateImage(encoder *ffmpeg.Encoder) error { + outputPath := path.Join(g.OutputDirectory, g.ImageFilename) + outputExists, _ := utils.FileExists(outputPath) + if outputExists { + return nil + } + + videoPreviewPath := path.Join(g.OutputDirectory, g.VideoFilename) + tmpOutputPath := instance.Paths.Generated.GetTmpPath(g.ImageFilename) + if err := encoder.ScenePreviewVideoToImage(g.generator.VideoFile, 640, videoPreviewPath, tmpOutputPath); err != nil { + return err + } else { + if err := os.Rename(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("files.txt") +} \ No newline at end of file diff --git a/manager/manager.go b/manager/manager.go index 45093af7c..2401716a1 100644 --- a/manager/manager.go +++ b/manager/manager.go @@ -4,6 +4,7 @@ import ( "github.com/bmatcuk/doublestar" "github.com/stashapp/stash/logger" "github.com/stashapp/stash/manager/paths" + "github.com/stashapp/stash/models" "path/filepath" "sync" ) @@ -85,10 +86,70 @@ func (s *singleton) Export() { }() } +func (s *singleton) Generate(sprites bool, previews bool, markers bool, transcodes bool) { + if s.Status != Idle { return } + s.Status = Generate + + qb := models.NewSceneQueryBuilder() + //this.job.total = await ObjectionUtils.getCount(Scene); + instance.Paths.Generated.EnsureTmpDir() + + go func() { + defer s.returnToIdleState() + + scenes, err := qb.All() + if err != nil { + logger.Errorf("failed to get scenes for generate") + return + } + + delta := btoi(sprites) + btoi(previews) + btoi(markers) + btoi(transcodes) + var wg sync.WaitGroup + for _, scene := range scenes { + wg.Add(delta) + + if sprites { + go func() { + wg.Done() // TODO + }() + } + + if previews { + task := GeneratePreviewTask{Scene: scene} + go task.Start(&wg) + } + + if markers { + go func() { + wg.Done() // TODO + }() + } + + if transcodes { + go func() { + wg.Done() // TODO + }() + } + + wg.Wait() + } + }() +} + func (s *singleton) returnToIdleState() { if r := recover(); r!= nil { logger.Info("recovered from ", r) } + if s.Status == Generate { + instance.Paths.Generated.RemoveTmpDir() + } s.Status = Idle } + +func btoi(b bool) int { + if b { + return 1 + } + return 0 +} \ No newline at end of file diff --git a/manager/task_generate_preview.go b/manager/task_generate_preview.go new file mode 100644 index 000000000..778a6fced --- /dev/null +++ b/manager/task_generate_preview.go @@ -0,0 +1,58 @@ +package manager + +import ( + "github.com/stashapp/stash/ffmpeg" + "github.com/stashapp/stash/logger" + "github.com/stashapp/stash/models" + "github.com/stashapp/stash/utils" + "sync" +) + +type GeneratePreviewTask struct { + Scene models.Scene +} + +func (t *GeneratePreviewTask) Start(wg *sync.WaitGroup) { + videoFilename := t.videoFilename() + imageFilename := t.imageFilename() + if t.doesPreviewExist(videoFilename, imageFilename) { + wg.Done() + return + } + + videoFile, err := ffmpeg.NewVideoFile(instance.Paths.FixedPaths.FFProbe, t.Scene.Path) + if err != nil { + logger.Errorf("error reading video file: %s", err.Error()) + wg.Done() + return + } + + generator, err := NewPreviewGenerator(*videoFile, videoFilename, imageFilename, instance.Paths.Generated.Screenshots) + if err != nil { + logger.Errorf("error creating preview generator: %s", err.Error()) + wg.Done() + return + } + + if err := generator.Generate(); err != nil { + logger.Errorf("error generating preview: %s", err.Error()) + wg.Done() + return + } + + wg.Done() +} + +func (t *GeneratePreviewTask) doesPreviewExist(videoFilename string, imageFilename string) bool { + videoExists, _ := utils.FileExists(instance.Paths.Scene.GetStreamPreviewPath(videoFilename)) + imageExists, _ := utils.FileExists(instance.Paths.Scene.GetStreamPreviewImagePath(imageFilename)) + return videoExists && imageExists +} + +func (t *GeneratePreviewTask) videoFilename() string { + return t.Scene.Checksum + ".mp4" +} + +func (t *GeneratePreviewTask) imageFilename() string { + return t.Scene.Checksum + ".webp" +} \ No newline at end of file diff --git a/manager/task_scan.go b/manager/task_scan.go index 7d588a5d0..68e3081fa 100644 --- a/manager/task_scan.go +++ b/manager/task_scan.go @@ -70,8 +70,7 @@ func (t *ScanTask) scanGallery() { } func (t *ScanTask) scanScene() { - ffprobe := ffmpeg.NewFFProbe(instance.Paths.FixedPaths.FFProbe) - ffprobeResult, err := ffprobe.ProbeVideo(t.FilePath) + videoFile, err := ffmpeg.NewVideoFile(instance.Paths.FixedPaths.FFProbe, t.FilePath) if err != nil { logger.Error(err.Error()) return @@ -90,7 +89,7 @@ func (t *ScanTask) scanScene() { return } - t.makeScreenshots(*ffprobeResult, checksum) + t.makeScreenshots(*videoFile, checksum) scene, _ = qb.FindByChecksum(checksum) ctx := context.TODO() @@ -105,14 +104,14 @@ func (t *ScanTask) scanScene() { newScene := models.Scene{ Checksum: checksum, Path: t.FilePath, - Duration: sql.NullFloat64{Float64: ffprobeResult.Duration, Valid: true }, - VideoCodec: sql.NullString{ String: ffprobeResult.VideoCodec, Valid: true}, - AudioCodec: sql.NullString{ String: ffprobeResult.AudioCodec, Valid: true}, - Width: sql.NullInt64{ Int64: int64(ffprobeResult.Width), Valid: true }, - Height: sql.NullInt64{ Int64: int64(ffprobeResult.Height), Valid: true }, - Framerate: sql.NullFloat64{ Float64: ffprobeResult.FrameRate, Valid: true }, - Bitrate: sql.NullInt64{ Int64: ffprobeResult.Bitrate, Valid: true }, - Size: sql.NullString{ String: strconv.Itoa(int(ffprobeResult.Size)), Valid: true }, + Duration: sql.NullFloat64{Float64: videoFile.Duration, Valid: true }, + VideoCodec: sql.NullString{ String: videoFile.VideoCodec, Valid: true}, + AudioCodec: sql.NullString{ String: videoFile.AudioCodec, Valid: true}, + Width: sql.NullInt64{ Int64: int64(videoFile.Width), Valid: true }, + Height: sql.NullInt64{ Int64: int64(videoFile.Height), Valid: true }, + Framerate: sql.NullFloat64{ Float64: videoFile.FrameRate, Valid: true }, + Bitrate: sql.NullInt64{ Int64: videoFile.Bitrate, Valid: true }, + Size: sql.NullString{ String: strconv.Itoa(int(videoFile.Size)), Valid: true }, CreatedAt: models.SQLiteTimestamp{ Timestamp: currentTime }, UpdatedAt: models.SQLiteTimestamp{ Timestamp: currentTime }, } @@ -127,7 +126,7 @@ func (t *ScanTask) scanScene() { } } -func (t *ScanTask) makeScreenshots(probeResult ffmpeg.FFProbeResult, checksum string) { +func (t *ScanTask) makeScreenshots(probeResult ffmpeg.VideoFile, checksum string) { thumbPath := instance.Paths.Scene.GetThumbnailScreenshotPath(checksum) normalPath := instance.Paths.Scene.GetScreenshotPath(checksum) @@ -142,7 +141,7 @@ func (t *ScanTask) makeScreenshots(probeResult ffmpeg.FFProbeResult, checksum st t.makeScreenshot(probeResult, normalPath, 2, probeResult.Width) } -func (t *ScanTask) makeScreenshot(probeResult ffmpeg.FFProbeResult, outputPath string, quality int, width int) { +func (t *ScanTask) makeScreenshot(probeResult ffmpeg.VideoFile, outputPath string, quality int, width int) { encoder := ffmpeg.NewEncoder(instance.Paths.FixedPaths.FFMPEG) options := ffmpeg.ScreenshotOptions{ OutputPath: outputPath,