mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 12:24:38 +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) {
|
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) {
|
func (r *queryResolver) MetadataClean(ctx context.Context) (string, error) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package ffmpeg
|
package ffmpeg
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"github.com/stashapp/stash/logger"
|
"github.com/stashapp/stash/logger"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -11,65 +10,17 @@ import (
|
|||||||
|
|
||||||
var progressRegex = regexp.MustCompile(`time=(\d+):(\d+):(\d+.\d+)`)
|
var progressRegex = regexp.MustCompile(`time=(\d+):(\d+):(\d+.\d+)`)
|
||||||
|
|
||||||
type encoder struct {
|
type Encoder struct {
|
||||||
Path string
|
Path string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEncoder(ffmpegPath string) encoder {
|
func NewEncoder(ffmpegPath string) Encoder {
|
||||||
return encoder{
|
return Encoder{
|
||||||
Path: ffmpegPath,
|
Path: ffmpegPath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ScreenshotOptions struct {
|
func (e *Encoder) run(probeResult VideoFile, args []string) (string, error) {
|
||||||
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) {
|
|
||||||
cmd := exec.Command(e.Path, args...)
|
cmd := exec.Command(e.Path, args...)
|
||||||
|
|
||||||
stderr, err := cmd.StderrPipe()
|
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"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ffprobeExecutable struct {
|
var ValidCodecs = []string{"h264", "h265", "vp8", "vp9"}
|
||||||
Path string
|
|
||||||
|
func IsValidCodec(codecName string) bool {
|
||||||
|
for _, c := range ValidCodecs {
|
||||||
|
if c == codecName {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
type FFProbeResult struct {
|
type VideoFile struct {
|
||||||
JSON ffprobeJSON
|
JSON FFProbeJSON
|
||||||
|
AudioStream *FFProbeStream
|
||||||
|
VideoStream *FFProbeStream
|
||||||
|
|
||||||
Path string
|
Path string
|
||||||
Container string
|
Container string
|
||||||
@@ -37,52 +45,62 @@ type FFProbeResult struct {
|
|||||||
AudioCodec string
|
AudioCodec string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFFProbe(ffprobePath string) ffprobeExecutable {
|
|
||||||
return ffprobeExecutable{
|
|
||||||
Path: ffprobePath,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute exec command and bind result to struct.
|
// Execute exec command and bind result to struct.
|
||||||
func (ffp *ffprobeExecutable) ProbeVideo(filePath string) (*FFProbeResult, error) {
|
func NewVideoFile(ffprobePath string, videoPath string) (*VideoFile, error) {
|
||||||
args := []string{"-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", "-show_error", filePath}
|
args := []string{"-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", "-show_error", videoPath}
|
||||||
// Extremely slow on windows for some reason
|
//// Extremely slow on windows for some reason
|
||||||
if runtime.GOOS != "windows" {
|
//if runtime.GOOS != "windows" {
|
||||||
args = append(args, "-count_frames")
|
// args = append(args, "-count_frames")
|
||||||
}
|
//}
|
||||||
out, err := exec.Command(ffp.Path, args...).Output()
|
out, err := exec.Command(ffprobePath, args...).Output()
|
||||||
|
|
||||||
if err != nil {
|
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 {
|
if err := json.Unmarshal(out, probeJSON); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
result := ffp.newProbeResult(filePath, *probeJSON)
|
return parse(videoPath, probeJSON)
|
||||||
return result, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ffp *ffprobeExecutable) newProbeResult(filePath string, probeJson ffprobeJSON) *FFProbeResult {
|
func parse(filePath string, probeJSON *FFProbeJSON) (*VideoFile, error) {
|
||||||
videoStreamIndex := ffp.getStreamIndex("video", probeJson)
|
if probeJSON == nil {
|
||||||
audioStreamIndex := ffp.getStreamIndex("audio", probeJson)
|
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.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.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)
|
fileStat, _ := os.Stat(filePath)
|
||||||
result.Size = fileStat.Size()
|
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 {
|
audioStream := result.GetAudioStream()
|
||||||
videoStream := probeJson.Streams[videoStreamIndex]
|
if audioStream != nil {
|
||||||
|
result.AudioCodec = audioStream.CodecName
|
||||||
|
result.AudioStream = audioStream
|
||||||
|
}
|
||||||
|
|
||||||
|
videoStream := result.GetVideoStream()
|
||||||
|
if videoStream != nil {
|
||||||
|
result.VideoStream = videoStream
|
||||||
result.VideoCodec = videoStream.CodecName
|
result.VideoCodec = videoStream.CodecName
|
||||||
result.VideoBitrate, _ = strconv.ParseInt(videoStream.BitRate, 10, 64)
|
result.VideoBitrate, _ = strconv.ParseInt(videoStream.BitRate, 10, 64)
|
||||||
var framerate float64
|
var framerate float64
|
||||||
@@ -104,14 +122,26 @@ func (ffp *ffprobeExecutable) newProbeResult(filePath string, probeJson ffprobeJ
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if audioStreamIndex != -1 {
|
return result, nil
|
||||||
result.AudioCodec = probeJson.Streams[audioStreamIndex].CodecName
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
for i, stream := range probeJson.Streams {
|
||||||
if stream.CodecType == fileType {
|
if stream.CodecType == fileType {
|
||||||
return i
|
return i
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ffprobeJSON struct {
|
type FFProbeJSON struct {
|
||||||
Format struct {
|
Format struct {
|
||||||
BitRate string `json:"bit_rate"`
|
BitRate string `json:"bit_rate"`
|
||||||
Duration string `json:"duration"`
|
Duration string `json:"duration"`
|
||||||
@@ -24,7 +24,14 @@ type ffprobeJSON struct {
|
|||||||
MinorVersion string `json:"minor_version"`
|
MinorVersion string `json:"minor_version"`
|
||||||
} `json:"tags"`
|
} `json:"tags"`
|
||||||
} `json:"format"`
|
} `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"`
|
AvgFrameRate string `json:"avg_frame_rate"`
|
||||||
BitRate string `json:"bit_rate"`
|
BitRate string `json:"bit_rate"`
|
||||||
BitsPerRawSample string `json:"bits_per_raw_sample,omitempty"`
|
BitsPerRawSample string `json:"bits_per_raw_sample,omitempty"`
|
||||||
@@ -82,9 +89,4 @@ type ffprobeJSON struct {
|
|||||||
MaxBitRate string `json:"max_bit_rate,omitempty"`
|
MaxBitRate string `json:"max_bit_rate,omitempty"`
|
||||||
SampleFmt string `json:"sample_fmt,omitempty"`
|
SampleFmt string `json:"sample_fmt,omitempty"`
|
||||||
SampleRate string `json:"sample_rate,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/bmatcuk/doublestar"
|
||||||
"github.com/stashapp/stash/logger"
|
"github.com/stashapp/stash/logger"
|
||||||
"github.com/stashapp/stash/manager/paths"
|
"github.com/stashapp/stash/manager/paths"
|
||||||
|
"github.com/stashapp/stash/models"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"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() {
|
func (s *singleton) returnToIdleState() {
|
||||||
if r := recover(); r!= nil {
|
if r := recover(); r!= nil {
|
||||||
logger.Info("recovered from ", r)
|
logger.Info("recovered from ", r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.Status == Generate {
|
||||||
|
instance.Paths.Generated.RemoveTmpDir()
|
||||||
|
}
|
||||||
s.Status = Idle
|
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() {
|
func (t *ScanTask) scanScene() {
|
||||||
ffprobe := ffmpeg.NewFFProbe(instance.Paths.FixedPaths.FFProbe)
|
videoFile, err := ffmpeg.NewVideoFile(instance.Paths.FixedPaths.FFProbe, t.FilePath)
|
||||||
ffprobeResult, err := ffprobe.ProbeVideo(t.FilePath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error(err.Error())
|
logger.Error(err.Error())
|
||||||
return
|
return
|
||||||
@@ -90,7 +89,7 @@ func (t *ScanTask) scanScene() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
t.makeScreenshots(*ffprobeResult, checksum)
|
t.makeScreenshots(*videoFile, checksum)
|
||||||
|
|
||||||
scene, _ = qb.FindByChecksum(checksum)
|
scene, _ = qb.FindByChecksum(checksum)
|
||||||
ctx := context.TODO()
|
ctx := context.TODO()
|
||||||
@@ -105,14 +104,14 @@ func (t *ScanTask) scanScene() {
|
|||||||
newScene := models.Scene{
|
newScene := models.Scene{
|
||||||
Checksum: checksum,
|
Checksum: checksum,
|
||||||
Path: t.FilePath,
|
Path: t.FilePath,
|
||||||
Duration: sql.NullFloat64{Float64: ffprobeResult.Duration, Valid: true },
|
Duration: sql.NullFloat64{Float64: videoFile.Duration, Valid: true },
|
||||||
VideoCodec: sql.NullString{ String: ffprobeResult.VideoCodec, Valid: true},
|
VideoCodec: sql.NullString{ String: videoFile.VideoCodec, Valid: true},
|
||||||
AudioCodec: sql.NullString{ String: ffprobeResult.AudioCodec, Valid: true},
|
AudioCodec: sql.NullString{ String: videoFile.AudioCodec, Valid: true},
|
||||||
Width: sql.NullInt64{ Int64: int64(ffprobeResult.Width), Valid: true },
|
Width: sql.NullInt64{ Int64: int64(videoFile.Width), Valid: true },
|
||||||
Height: sql.NullInt64{ Int64: int64(ffprobeResult.Height), Valid: true },
|
Height: sql.NullInt64{ Int64: int64(videoFile.Height), Valid: true },
|
||||||
Framerate: sql.NullFloat64{ Float64: ffprobeResult.FrameRate, Valid: true },
|
Framerate: sql.NullFloat64{ Float64: videoFile.FrameRate, Valid: true },
|
||||||
Bitrate: sql.NullInt64{ Int64: ffprobeResult.Bitrate, Valid: true },
|
Bitrate: sql.NullInt64{ Int64: videoFile.Bitrate, Valid: true },
|
||||||
Size: sql.NullString{ String: strconv.Itoa(int(ffprobeResult.Size)), Valid: true },
|
Size: sql.NullString{ String: strconv.Itoa(int(videoFile.Size)), Valid: true },
|
||||||
CreatedAt: models.SQLiteTimestamp{ Timestamp: currentTime },
|
CreatedAt: models.SQLiteTimestamp{ Timestamp: currentTime },
|
||||||
UpdatedAt: 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)
|
thumbPath := instance.Paths.Scene.GetThumbnailScreenshotPath(checksum)
|
||||||
normalPath := instance.Paths.Scene.GetScreenshotPath(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)
|
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)
|
encoder := ffmpeg.NewEncoder(instance.Paths.FixedPaths.FFMPEG)
|
||||||
options := ffmpeg.ScreenshotOptions{
|
options := ffmpeg.ScreenshotOptions{
|
||||||
OutputPath: outputPath,
|
OutputPath: outputPath,
|
||||||
|
|||||||
Reference in New Issue
Block a user