Make marker preview duration ceiling configurable

Replaces the hardcoded maxMarkerPreviewDuration = 20 constant in
pkg/scene/generate/marker_preview.go with a configurable setting on
ConfigGeneral, mirroring the PreviewSegments wiring pattern.

- Default 20 preserves existing behavior for all installs
- Positive N sets the ceiling in seconds
- 0 (or any value <= 0) disables the ceiling, honoring explicit
  endSeconds verbatim
- Adds logger.Warnf for non-positive intervals to aid diagnosis of
  imported or malformed marker data
- Incidentally prevents zero/negative-duration ffmpeg calls that
  were possible under the previous logic

Context: closed issue #6852 (template non-conformance), Discussion
#6851 (mobile-first direction).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Speck Pratt 2026-04-22 07:08:15 -04:00
parent 22d2dbc46b
commit e9e427772b
8 changed files with 48 additions and 7 deletions

View file

@ -101,6 +101,8 @@ input ConfigGeneralInput {
previewSegments: Int
"Preview segment duration, in seconds"
previewSegmentDuration: Float
"Maximum duration (in seconds) for generated marker preview videos. 0 disables the ceiling."
maxMarkerPreviewDuration: Int
"Duration of start of video to exclude when generating previews"
previewExcludeStart: String
"Duration of end of video to exclude when generating previews"
@ -239,6 +241,8 @@ type ConfigGeneralResult {
previewSegments: Int!
"Preview segment duration, in seconds"
previewSegmentDuration: Float!
"Maximum duration (in seconds) for generated marker preview videos. 0 disables the ceiling."
maxMarkerPreviewDuration: Int!
"Duration of start of video to exclude when generating previews"
previewExcludeStart: String!
"Duration of end of video to exclude when generating previews"

View file

@ -289,6 +289,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
r.setConfigBool(config.PreviewAudio, input.PreviewAudio)
r.setConfigInt(config.PreviewSegments, input.PreviewSegments)
r.setConfigFloat(config.PreviewSegmentDuration, input.PreviewSegmentDuration)
r.setConfigInt(config.MaxMarkerPreviewDuration, input.MaxMarkerPreviewDuration)
r.setConfigString(config.PreviewExcludeStart, input.PreviewExcludeStart)
r.setConfigString(config.PreviewExcludeEnd, input.PreviewExcludeEnd)
if input.PreviewPreset != nil {

View file

@ -119,6 +119,9 @@ const (
PreviewExcludeEnd = "preview_exclude_end"
previewExcludeEndDefault = "0"
MaxMarkerPreviewDuration = "max_marker_preview_duration"
maxMarkerPreviewDurationDefault = 20
WriteImageThumbnails = "write_image_thumbnails"
writeImageThumbnailsDefault = true
@ -1079,6 +1082,13 @@ func (i *Config) GetTranscodeHardwareAcceleration() bool {
return i.getBool(TranscodeHardwareAcceleration)
}
// GetMaxMarkerPreviewDuration returns the ceiling in seconds applied to
// generated marker preview videos when the marker has an explicit end time.
// Any value <= 0 disables the ceiling, honoring the marker's end time verbatim.
func (i *Config) GetMaxMarkerPreviewDuration() int {
return i.getInt(MaxMarkerPreviewDuration)
}
func (i *Config) GetMaxTranscodeSize() models.StreamingResolutionEnum {
ret := i.getString(MaxTranscodeSize)
@ -1918,6 +1928,7 @@ func (i *Config) setDefaultValues() {
i.setDefault(PreviewExcludeStart, previewExcludeStartDefault)
i.setDefault(PreviewExcludeEnd, previewExcludeEndDefault)
i.setDefault(PreviewAudio, previewAudioDefault)
i.setDefault(MaxMarkerPreviewDuration, maxMarkerPreviewDurationDefault)
i.setDefault(SoundOnPreview, false)
i.setDefault(UseCustomSpriteInterval, UseCustomSpriteIntervalDefault)

View file

@ -117,7 +117,7 @@ func (t *GenerateMarkersTask) generateMarker(videoFile *models.VideoFile, scene
g := t.generator
if t.VideoPreview {
if err := g.MarkerPreviewVideo(context.TODO(), videoFile.Path, sceneHash, seconds, sceneMarker.EndSeconds, instance.Config.GetPreviewAudio()); err != nil {
if err := g.MarkerPreviewVideo(context.TODO(), videoFile.Path, sceneHash, seconds, sceneMarker.EndSeconds, instance.Config.GetPreviewAudio(), instance.Config.GetMaxMarkerPreviewDuration()); err != nil {
logger.Errorf("[generator] failed to generate marker video: %v", err)
logErrorOutput(err)
}

View file

@ -11,16 +11,20 @@ import (
const (
markerPreviewWidth = 640
maxMarkerPreviewDuration = 20
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) error {
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()
@ -31,11 +35,21 @@ func (g Generator) MarkerPreviewVideo(ctx context.Context, input string, hash st
}
}
duration := float64(maxMarkerPreviewDuration)
duration := float64(markerPreviewDefaultDuration)
// don't allow preview to exceed max duration
if endSeconds != nil && *endSeconds-seconds < maxMarkerPreviewDuration {
duration = float64(*endSeconds) - seconds
// Honor the marker's explicit interval when present and positive, capped
// by the configured safety ceiling. maxDuration <= 0 disables the ceiling.
if endSeconds != nil {
interval := *endSeconds - seconds
if interval > 0 {
if maxDuration <= 0 || interval <= float64(maxDuration) {
duration = interval
} else {
duration = float64(maxDuration)
}
} else {
logger.Warnf("[generator] marker at %.2fs has non-positive interval (end=%.2f); falling back to %ds default", seconds, *endSeconds, markerPreviewDefaultDuration)
}
}
if err := g.generateFile(lockCtx, g.MarkerPaths, mp4Pattern, output, g.markerPreviewVideo(input, sceneMarkerOptions{

View file

@ -25,6 +25,7 @@ fragment ConfigGeneralData on ConfigGeneralResult {
previewExcludeStart
previewExcludeEnd
previewPreset
maxMarkerPreviewDuration
transcodeHardwareAcceleration
maxTranscodeSize
maxStreamingTranscodeSize

View file

@ -425,6 +425,14 @@ export const SettingsConfigurationPanel: React.FC = () => {
return <></>;
}}
/>
<NumberSetting
id="max-marker-preview-duration"
headingID="config.general.max_marker_preview_duration_head"
subHeadingID="config.general.max_marker_preview_duration_desc"
value={general.maxMarkerPreviewDuration ?? 20}
onChange={(v) => saveGeneral({ maxMarkerPreviewDuration: v })}
/>
</SettingSection>
<SettingSection headingID="config.general.sprite_generation_head">

View file

@ -418,6 +418,8 @@
"include_audio_desc": "Includes audio stream when generating previews.",
"include_audio_head": "Include audio",
"logging": "Logging",
"max_marker_preview_duration_desc": "Ceiling (in seconds) applied to generated marker preview videos when the marker has an explicit end time. Protects against unexpectedly long previews from imports or data entry mistakes. Set to 0 to disable the ceiling and honor the marker's end time verbatim.",
"max_marker_preview_duration_head": "Max marker preview duration",
"maximum_streaming_transcode_size_desc": "Maximum size for transcoded streams.",
"maximum_streaming_transcode_size_head": "Maximum streaming transcode size",
"maximum_transcode_size_desc": "Maximum size for generated transcodes.",