stash/pkg/scene/generate/marker_preview.go
Speck Pratt 9f8acb2b79 Use default duration on zero-interval markers; trim setting copy
The marker form already permits end == start, so refusing to render in
the generator was an inconsistent gate. Treat non-positive intervals the
same as a missing end (fall back to markerPreviewDefaultDuration) while
keeping the warning log for visibility into unexpected data.

Also trims the max_marker_preview_duration_desc copy to match the tone
of neighboring setting descriptions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 08:34:44 -04:00

211 lines
5.7 KiB
Go

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
markerPreviewAudioBitrate = "64k"
// Fallback duration for markers that lack a usable explicit end time
// (nil EndSeconds, or EndSeconds <= start). Not user-configurable;
// markers wanting a specific duration should set an end time.
markerPreviewDefaultDuration = 20
markerImageDuration = 5
markerWebpFPS = 12
markerScreenshotQuality = 2
)
func (g Generator) MarkerPreviewVideo(ctx context.Context, input string, hash string, seconds float64, endSeconds *float64, includeAudio bool, maxDuration int) error {
lockCtx := g.LockManager.ReadLock(ctx, input)
defer lockCtx.Cancel()
output := g.MarkerPaths.GetVideoPreviewPath(hash, int(seconds))
if !g.Overwrite {
if exists, _ := fsutil.FileExists(output); exists {
return nil
}
}
duration := float64(markerPreviewDefaultDuration)
// Honor the marker's explicit interval when present, capped by the configured
// safety ceiling. maxDuration <= 0 disables the ceiling. The marker form
// permits end == start, so a non-positive interval is treated the same as
// a missing end (fall back to the default duration) rather than refusing to
// render; the warning surfaces unexpected data without breaking generation.
if endSeconds != nil {
interval := *endSeconds - seconds
if interval <= 0 {
logger.Warnf("[generator] marker at %.2fs has non-positive interval (end=%.2f); using default duration", seconds, *endSeconds)
} else if maxDuration <= 0 || interval <= float64(maxDuration) {
duration = interval
} else {
duration = float64(maxDuration)
}
}
if err := g.generateFile(lockCtx, g.MarkerPaths, mp4Pattern, output, g.markerPreviewVideo(input, sceneMarkerOptions{
Seconds: seconds,
Duration: duration,
Audio: includeAudio,
})); err != nil {
return err
}
logger.Debug("created marker video: ", output)
return nil
}
type sceneMarkerOptions struct {
Seconds float64
Duration float64
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: options.Duration,
StartTime: 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 float64) error {
lockCtx := g.LockManager.ReadLock(ctx, input)
defer lockCtx.Cancel()
output := g.MarkerPaths.GetWebpPreviewPath(hash, int(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 float64, width int) error {
lockCtx := g.LockManager.ReadLock(ctx, input)
defer lockCtx.Cancel()
output := g.MarkerPaths.GetScreenshotPath(hash, int(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 float64
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, options.Seconds, ssOptions)
return g.generate(lockCtx, args)
}
}