From e9e427772b98d2fce79abd3edf6680651e593b3f Mon Sep 17 00:00:00 2001 From: Speck Pratt Date: Wed, 22 Apr 2026 07:08:15 -0400 Subject: [PATCH 1/3] 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) --- graphql/schema/types/config.graphql | 4 +++ internal/api/resolver_mutation_configure.go | 1 + internal/manager/config/config.go | 11 ++++++++ internal/manager/task_generate_markers.go | 2 +- pkg/scene/generate/marker_preview.go | 26 ++++++++++++++----- ui/v2.5/graphql/data/config.graphql | 1 + .../Settings/SettingsSystemPanel.tsx | 8 ++++++ ui/v2.5/src/locales/en-GB.json | 2 ++ 8 files changed, 48 insertions(+), 7 deletions(-) diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index 5ab7fdfea..c91c5d231 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -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" diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index 3df1c9114..b0c6dcf5f 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -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 { diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 19e263810..05dcf97a4 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -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) diff --git a/internal/manager/task_generate_markers.go b/internal/manager/task_generate_markers.go index 1da458ba8..709a1015b 100644 --- a/internal/manager/task_generate_markers.go +++ b/internal/manager/task_generate_markers.go @@ -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) } diff --git a/pkg/scene/generate/marker_preview.go b/pkg/scene/generate/marker_preview.go index f14613591..83faec2fa 100644 --- a/pkg/scene/generate/marker_preview.go +++ b/pkg/scene/generate/marker_preview.go @@ -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{ diff --git a/ui/v2.5/graphql/data/config.graphql b/ui/v2.5/graphql/data/config.graphql index ba8215fe3..f940d6666 100644 --- a/ui/v2.5/graphql/data/config.graphql +++ b/ui/v2.5/graphql/data/config.graphql @@ -25,6 +25,7 @@ fragment ConfigGeneralData on ConfigGeneralResult { previewExcludeStart previewExcludeEnd previewPreset + maxMarkerPreviewDuration transcodeHardwareAcceleration maxTranscodeSize maxStreamingTranscodeSize diff --git a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx index 446ad09a1..618ccd1cf 100644 --- a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx @@ -425,6 +425,14 @@ export const SettingsConfigurationPanel: React.FC = () => { return <>; }} /> + + saveGeneral({ maxMarkerPreviewDuration: v })} + /> diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 4974c06ca..3cc754928 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -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.", From ebca98b46fc217fbad51c594d8cda869c74a2e79 Mon Sep 17 00:00:00 2001 From: Speck Pratt Date: Wed, 22 Apr 2026 08:03:37 -0400 Subject: [PATCH 2/3] Honor marker end times by default; skip zero-interval previews Follow-up to the initial ceiling-configurable change, incorporating product-review feedback. Two design shifts plus a read-side bug fix: - Default MaxMarkerPreviewDuration flipped from 20 to 0 (ceiling disabled). User-set end times are now honored by default; the ceiling becomes an opt-in safety for imports or untrusted data. A buried setting at its old 20s default was unlikely to be found by users hitting the silent-truncation case. - Non-positive intervals (endSeconds <= seconds) now skip preview generation entirely rather than falling back to the 20s default. A user who sets zero or negative duration either made a mistake (don't invent output) or marked a point without wanting a video (don't generate one they didn't ask for). Either way, skip with a warning log. - Fix missing ConfigGeneralResult field wiring in resolver_query_configuration.go. The schema and mutation sides were wired in the initial commit but the query read path was missed, causing the Settings UI to display Go's zero-value (0) regardless of the stored config value. Behavior change on upgrade: existing preview files remain unchanged. To refresh previews for markers whose end times were previously truncated at 20s, run Generate > Marker Previews with "Overwrite Existing" enabled. To keep the old 20s ceiling, set Max marker preview duration to 20 under Settings > System > Preview Generation. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/api/resolver_query_configuration.go | 1 + internal/manager/config/config.go | 2 +- pkg/scene/generate/marker_preview.go | 17 ++++++++++------- ui/v2.5/src/locales/en-GB.json | 2 +- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/internal/api/resolver_query_configuration.go b/internal/api/resolver_query_configuration.go index cf2c0e3cc..0098c5769 100644 --- a/internal/api/resolver_query_configuration.go +++ b/internal/api/resolver_query_configuration.go @@ -104,6 +104,7 @@ func makeConfigGeneralResult() *ConfigGeneralResult { PreviewAudio: config.GetPreviewAudio(), PreviewSegments: config.GetPreviewSegments(), PreviewSegmentDuration: config.GetPreviewSegmentDuration(), + MaxMarkerPreviewDuration: config.GetMaxMarkerPreviewDuration(), PreviewExcludeStart: config.GetPreviewExcludeStart(), PreviewExcludeEnd: config.GetPreviewExcludeEnd(), PreviewPreset: config.GetPreviewPreset(), diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 05dcf97a4..1c75f4719 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -120,7 +120,7 @@ const ( previewExcludeEndDefault = "0" MaxMarkerPreviewDuration = "max_marker_preview_duration" - maxMarkerPreviewDurationDefault = 20 + maxMarkerPreviewDurationDefault = 0 WriteImageThumbnails = "write_image_thumbnails" writeImageThumbnailsDefault = true diff --git a/pkg/scene/generate/marker_preview.go b/pkg/scene/generate/marker_preview.go index 83faec2fa..0f7e612a6 100644 --- a/pkg/scene/generate/marker_preview.go +++ b/pkg/scene/generate/marker_preview.go @@ -39,16 +39,19 @@ func (g Generator) MarkerPreviewVideo(ctx context.Context, input string, hash st // Honor the marker's explicit interval when present and positive, capped // by the configured safety ceiling. maxDuration <= 0 disables the ceiling. + // Non-positive intervals are treated as "no video wanted" and skipped — + // if the user intentionally set end = start they didn't want a preview, + // and if it's a data mistake we'd rather surface it than silently default. if endSeconds != nil { interval := *endSeconds - seconds - if interval > 0 { - if maxDuration <= 0 || interval <= float64(maxDuration) { - duration = interval - } else { - duration = float64(maxDuration) - } + if interval <= 0 { + logger.Warnf("[generator] marker at %.2fs has non-positive interval (end=%.2f); skipping video preview generation", seconds, *endSeconds) + return nil + } + if maxDuration <= 0 || interval <= float64(maxDuration) { + duration = interval } else { - logger.Warnf("[generator] marker at %.2fs has non-positive interval (end=%.2f); falling back to %ds default", seconds, *endSeconds, markerPreviewDefaultDuration) + duration = float64(maxDuration) } } diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 3cc754928..d0e044dcc 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -418,7 +418,7 @@ "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_desc": "Optional ceiling (in seconds) for marker preview videos with explicit end times. Default is 0 (no ceiling, the marker's end time is honored verbatim). Set a positive value to cap preview duration as a safety against imports or data entry mistakes. Markers without an end time use a fixed 20-second default, unaffected by this setting.", "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", From 9f8acb2b793d5d2cde22a7a970af006b7360ebb9 Mon Sep 17 00:00:00 2001 From: Speck Pratt Date: Wed, 29 Apr 2026 08:34:44 -0400 Subject: [PATCH 3/3] 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) --- pkg/scene/generate/marker_preview.go | 16 +++++++--------- ui/v2.5/src/locales/en-GB.json | 2 +- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/pkg/scene/generate/marker_preview.go b/pkg/scene/generate/marker_preview.go index 0f7e612a6..316816093 100644 --- a/pkg/scene/generate/marker_preview.go +++ b/pkg/scene/generate/marker_preview.go @@ -37,18 +37,16 @@ func (g Generator) MarkerPreviewVideo(ctx context.Context, input string, hash st duration := float64(markerPreviewDefaultDuration) - // Honor the marker's explicit interval when present and positive, capped - // by the configured safety ceiling. maxDuration <= 0 disables the ceiling. - // Non-positive intervals are treated as "no video wanted" and skipped — - // if the user intentionally set end = start they didn't want a preview, - // and if it's a data mistake we'd rather surface it than silently default. + // 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); skipping video preview generation", seconds, *endSeconds) - return nil - } - if maxDuration <= 0 || interval <= float64(maxDuration) { + 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) diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index d0e044dcc..330fa423f 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -418,7 +418,7 @@ "include_audio_desc": "Includes audio stream when generating previews.", "include_audio_head": "Include audio", "logging": "Logging", - "max_marker_preview_duration_desc": "Optional ceiling (in seconds) for marker preview videos with explicit end times. Default is 0 (no ceiling, the marker's end time is honored verbatim). Set a positive value to cap preview duration as a safety against imports or data entry mistakes. Markers without an end time use a fixed 20-second default, unaffected by this setting.", + "max_marker_preview_duration_desc": "Maximum duration (seconds) for marker previews. 0 disables the limit.", "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",