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:
WithoutPants 2022-04-18 10:50:10 +10:00 committed by GitHub
parent cdaa191155
commit aacf07feef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
89 changed files with 3208 additions and 2004 deletions

View File

@ -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)

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}
}
}

View File

@ -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
View 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))
}
}

View File

@ -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)
}

View File

@ -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

View File

@ -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) {

View File

@ -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

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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++

View File

@ -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

View File

@ -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
}

View File

@ -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"
}

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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)

View File

@ -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()

View File

@ -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 {

View File

@ -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
View 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
View 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
View 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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
View 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...)
}

View File

@ -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
View 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
View 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
View 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
View 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()
}

View File

@ -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

View File

@ -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
}

View File

@ -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
View 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"
)

View File

@ -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
}

View File

@ -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

View 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
}

View 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
}

View 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
}

View 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
}

View File

@ -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
View 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()
}
}

View File

@ -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 {

View File

@ -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,
},
}

View File

@ -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,
},
},

View 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
}

View File

@ -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 {

View File

@ -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,
},
}

View File

@ -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)
}

View File

@ -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]
}

View File

@ -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
}

View File

@ -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) {

View File

@ -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) {

View File

@ -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) {

View File

@ -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"`

View File

@ -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"`
}

View File

@ -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) {

View File

@ -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"`

View File

@ -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) {

View File

@ -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)
}

View File

@ -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")
}

View File

@ -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")
}

View File

@ -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 {

View File

@ -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,
},
}

View File

@ -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 {

View File

@ -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,
},
}

View File

@ -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

View File

@ -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)

View File

@ -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,
},
},

View 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
}

View 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)
}
}

View 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)
}
}

View 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)
}
}

View 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)
// }
// }

View 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)
}
}

View File

@ -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)

View File

@ -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)
}
}
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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,
},
}

View File

@ -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)

View File

@ -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,