mirror of
https://github.com/stashapp/stash.git
synced 2025-12-10 10:13:54 -06:00
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
This commit is contained in:
parent
cdaa191155
commit
aacf07feef
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
15
internal/manager/log.go
Normal file
15
internal/manager/log.go
Normal file
@ -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))
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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++
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
136
pkg/ffmpeg/browser.go
Normal file
136
pkg/ffmpeg/browser.go
Normal file
@ -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
|
||||
}
|
||||
38
pkg/ffmpeg/codec.go
Normal file
38
pkg/ffmpeg/codec.go
Normal file
@ -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"
|
||||
)
|
||||
59
pkg/ffmpeg/container.go
Normal file
59
pkg/ffmpeg/container.go
Normal file
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
17
pkg/ffmpeg/ffmpeg.go
Normal file
17
pkg/ffmpeg/ffmpeg.go
Normal file
@ -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...)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
78
pkg/ffmpeg/filter.go
Normal file
78
pkg/ffmpeg/filter.go
Normal file
@ -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))
|
||||
}
|
||||
43
pkg/ffmpeg/format.go
Normal file
43
pkg/ffmpeg/format.go
Normal file
@ -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"
|
||||
)
|
||||
76
pkg/ffmpeg/frame_rate.go
Normal file
76
pkg/ffmpeg/frame_rate.go
Normal file
@ -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
|
||||
}
|
||||
42
pkg/ffmpeg/generate.go
Normal file
42
pkg/ffmpeg/generate.go
Normal file
@ -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()
|
||||
}
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
178
pkg/ffmpeg/options.go
Normal file
178
pkg/ffmpeg/options.go
Normal file
@ -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"
|
||||
)
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
|
||||
38
pkg/ffmpeg/transcoder/image.go
Normal file
38
pkg/ffmpeg/transcoder/image.go
Normal file
@ -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
|
||||
}
|
||||
109
pkg/ffmpeg/transcoder/screenshot.go
Normal file
109
pkg/ffmpeg/transcoder/screenshot.go
Normal file
@ -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
|
||||
}
|
||||
67
pkg/ffmpeg/transcoder/splice.go
Normal file
67
pkg/ffmpeg/transcoder/splice.go
Normal file
@ -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
|
||||
}
|
||||
99
pkg/ffmpeg/transcoder/transcode.go
Normal file
99
pkg/ffmpeg/transcoder/transcode.go
Normal file
@ -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
|
||||
}
|
||||
@ -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"`
|
||||
|
||||
101
pkg/fsutil/lock_manager.go
Normal file
101
pkg/fsutil/lock_manager.go
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
103
pkg/hash/videophash/phash.go
Normal file
103
pkg/hash/videophash/phash.go
Normal file
@ -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
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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]
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
146
pkg/scene/generate/generator.go
Normal file
146
pkg/scene/generate/generator.go
Normal file
@ -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
|
||||
}
|
||||
187
pkg/scene/generate/marker_preview.go
Normal file
187
pkg/scene/generate/marker_preview.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
289
pkg/scene/generate/preview.go
Normal file
289
pkg/scene/generate/preview.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
101
pkg/scene/generate/screenshot.go
Normal file
101
pkg/scene/generate/screenshot.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
330
pkg/scene/generate/sprite.go
Normal file
330
pkg/scene/generate/sprite.go
Normal file
@ -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 (<ChunkCount) g.Info.NthFrame can be zero
|
||||
// // so recalculate from scratch
|
||||
// stepSize = float64(videoFile.FrameCount-1) / float64(spriteChunks)
|
||||
// stepSize /= g.Info.FrameRate
|
||||
// }
|
||||
|
||||
// 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)
|
||||
// }
|
||||
// }
|
||||
167
pkg/scene/generate/transcode.go
Normal file
167
pkg/scene/generate/transcode.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user