mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 04:14:39 +03:00
Added scene preview generator
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
65
ffmpeg/encoder_scene_preview_chunk.go
Normal file
65
ffmpeg/encoder_scene_preview_chunk.go
Normal file
@@ -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
|
||||
}
|
||||
32
ffmpeg/encoder_screenshot.go
Normal file
32
ffmpeg/encoder_screenshot.go
Normal file
@@ -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)
|
||||
}
|
||||
20
ffmpeg/encoder_transcode.go
Normal file
20
ffmpeg/encoder_transcode.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type ffprobeJSON struct {
|
||||
type FFProbeJSON struct {
|
||||
Format struct {
|
||||
BitRate string `json:"bit_rate"`
|
||||
Duration string `json:"duration"`
|
||||
@@ -24,7 +24,14 @@ type ffprobeJSON struct {
|
||||
MinorVersion string `json:"minor_version"`
|
||||
} `json:"tags"`
|
||||
} `json:"format"`
|
||||
Streams []struct {
|
||||
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"`
|
||||
@@ -82,9 +89,4 @@ type ffprobeJSON struct {
|
||||
MaxBitRate string `json:"max_bit_rate,omitempty"`
|
||||
SampleFmt string `json:"sample_fmt,omitempty"`
|
||||
SampleRate string `json:"sample_rate,omitempty"`
|
||||
} `json:"streams"`
|
||||
Error struct {
|
||||
Code int `json:"code"`
|
||||
String string `json:"string"`
|
||||
} `json:"error"`
|
||||
}
|
||||
62
manager/generator.go
Normal file
62
manager/generator.go
Normal file
@@ -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
|
||||
}
|
||||
127
manager/generator_preview.go
Normal file
127
manager/generator_preview.go
Normal file
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
58
manager/task_generate_preview.go
Normal file
58
manager/task_generate_preview.go
Normal file
@@ -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"
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user