From c7ae3fcc597f0210ebb5d7f60258371a980adf0e Mon Sep 17 00:00:00 2001 From: Mikei386 <44135113+Mikei386@users.noreply.github.com> Date: Thu, 4 Jun 2026 19:59:01 +0200 Subject: [PATCH] Add clip number burn-in option --- Sources/GrowthLapse/ContentView.swift | 2 + Sources/GrowthLapse/FFmpegService.swift | 3 +- Sources/GrowthLapse/RenderSettings.swift | 5 +++ Sources/GrowthLapse/VideoProcessor.swift | 55 ++++++++++++++++++++---- 4 files changed, 55 insertions(+), 10 deletions(-) diff --git a/Sources/GrowthLapse/ContentView.swift b/Sources/GrowthLapse/ContentView.swift index dc975ea..3293d75 100644 --- a/Sources/GrowthLapse/ContentView.swift +++ b/Sources/GrowthLapse/ContentView.swift @@ -152,6 +152,8 @@ struct ContentView: View { .help("Behält segment_*, stabilized_*, clip_* und xfade_* Dateien zur Kontrolle. Sonst wird der Temp-Ordner nach Erfolg gelöscht.") Toggle("Render-Cache für Nachbearbeitung behalten", isOn: $settings.keepRenderCacheForReview) .help("Behält die normalisierten clip_*.mp4 Dateien und eine Projektdatei neben dem Output. So können einzelne Segmentfenster später angepasst und schneller neu gerendert werden.") + Toggle("Clipnummer einblenden", isOn: $settings.burnInClipNumbers) + .help("Brennt die laufende Clipnummer unten links ins Video ein. Praktisch, um im fertigen Film problematische Clips schnell in der Nachbearbeitung zu finden.") } .disabled(processor.state.isRunning) diff --git a/Sources/GrowthLapse/FFmpegService.swift b/Sources/GrowthLapse/FFmpegService.swift index 13df718..f610c63 100644 --- a/Sources/GrowthLapse/FFmpegService.swift +++ b/Sources/GrowthLapse/FFmpegService.swift @@ -155,7 +155,8 @@ actor FFmpegService { return FFmpegFilterAvailability( hasVidstabDetect: result.output.contains("vidstabdetect"), hasVidstabTransform: result.output.contains("vidstabtransform"), - hasDeshake: result.output.contains("deshake") + hasDeshake: result.output.contains("deshake"), + hasDrawtext: result.output.contains("drawtext") ) } diff --git a/Sources/GrowthLapse/RenderSettings.swift b/Sources/GrowthLapse/RenderSettings.swift index 4ce086a..1daa0e3 100644 --- a/Sources/GrowthLapse/RenderSettings.swift +++ b/Sources/GrowthLapse/RenderSettings.swift @@ -111,6 +111,7 @@ struct FFmpegFilterAvailability: Equatable { let hasVidstabDetect: Bool let hasVidstabTransform: Bool let hasDeshake: Bool + let hasDrawtext: Bool var hasVidstab: Bool { hasVidstabDetect && hasVidstabTransform @@ -135,6 +136,7 @@ struct RenderSettings: Equatable { var startOffset: Double = 0 var keepIntermediateClips: Bool = false var keepRenderCacheForReview: Bool = true + var burnInClipNumbers: Bool = false var videoEncoder: VideoEncoder = .videoToolbox var crf: Int = 20 var hardwareBitrateMbps: Int = 12 @@ -168,6 +170,7 @@ struct PersistedRenderSettings: Codable { var startOffset: Double var keepIntermediateClips: Bool var keepRenderCacheForReview: Bool? + var burnInClipNumbers: Bool? var videoEncoder: VideoEncoder var crf: Int var hardwareBitrateMbps: Int @@ -200,6 +203,7 @@ struct PersistedRenderSettings: Codable { startOffset = settings.startOffset keepIntermediateClips = settings.keepIntermediateClips keepRenderCacheForReview = settings.keepRenderCacheForReview + burnInClipNumbers = settings.burnInClipNumbers videoEncoder = settings.videoEncoder crf = settings.crf hardwareBitrateMbps = settings.hardwareBitrateMbps @@ -234,6 +238,7 @@ struct PersistedRenderSettings: Codable { startOffset: startOffset, keepIntermediateClips: keepIntermediateClips, keepRenderCacheForReview: keepRenderCacheForReview ?? true, + burnInClipNumbers: burnInClipNumbers ?? false, videoEncoder: videoEncoder, crf: crf, hardwareBitrateMbps: hardwareBitrateMbps, diff --git a/Sources/GrowthLapse/VideoProcessor.swift b/Sources/GrowthLapse/VideoProcessor.swift index 58d2dcd..4995013 100644 --- a/Sources/GrowthLapse/VideoProcessor.swift +++ b/Sources/GrowthLapse/VideoProcessor.swift @@ -174,13 +174,21 @@ final class VideoProcessor: ObservableObject { return try await renderPhotoMorph(settings: effectiveSettings, ffmpeg: tools.ffmpeg, ffprobe: tools.ffprobe) } - if effectiveSettings.stabilizationEnabled { + if effectiveSettings.stabilizationEnabled || effectiveSettings.burnInClipNumbers { let filters = try await service.availableFilters(ffmpeg: tools.ffmpeg) - effectiveSettings.stabilizationMethod = resolveStabilizationMethod( - requested: effectiveSettings.stabilizationMethod, - availability: filters - ) - appendLog("Stabilisierung: \(effectiveSettings.stabilizationMethod.rawValue), \(effectiveSettings.stabilizationStrength.rawValue)") + if effectiveSettings.burnInClipNumbers, !filters.hasDrawtext { + throw VideoProcessingError.invalidSettings("Clipnummern können nicht eingeblendet werden, weil dieser ffmpeg-Build den Filter drawtext nicht enthält.") + } + if effectiveSettings.stabilizationEnabled { + effectiveSettings.stabilizationMethod = resolveStabilizationMethod( + requested: effectiveSettings.stabilizationMethod, + availability: filters + ) + appendLog("Stabilisierung: \(effectiveSettings.stabilizationMethod.rawValue), \(effectiveSettings.stabilizationStrength.rawValue)") + } + if effectiveSettings.burnInClipNumbers { + appendLog("Clipnummern: Einblendung unten links aktiv.") + } } let inputFolder = settings.inputFolder! @@ -857,6 +865,7 @@ final class VideoProcessor: ObservableObject { faceCrop: faceCrop, settings: settings, targetDimensions: targetDimensions, + clipIndex: projectClip.index, ffmpeg: ffmpeg, clipNumber: clipNumber, totalClips: videos.count @@ -876,6 +885,7 @@ final class VideoProcessor: ObservableObject { faceCrop: faceCrop, settings: settings, targetDimensions: targetDimensions, + clipIndex: projectClip.index, ffmpeg: ffmpeg, clipNumber: clipNumber, totalClips: videos.count, @@ -1082,6 +1092,7 @@ final class VideoProcessor: ObservableObject { faceCrop: FaceCrop?, settings: RenderSettings, targetDimensions: VideoDimensions, + clipIndex: Int, ffmpeg: URL, clipNumber: Int, totalClips: Int @@ -1093,7 +1104,7 @@ final class VideoProcessor: ObservableObject { "-nostdin", "-t", decimal(actualSegmentLength), "-i", source.path, - "-vf", normalizationFilter(speedFactor: speedFactor, fps: settings.fps, targetDimensions: targetDimensions, faceCrop: faceCrop), + "-vf", normalizationFilter(speedFactor: speedFactor, fps: settings.fps, targetDimensions: targetDimensions, faceCrop: faceCrop, clipIndex: clipIndex, burnInClipNumber: settings.burnInClipNumbers), "-t", decimal(settings.targetClipLength), "-an" ] @@ -1115,6 +1126,7 @@ final class VideoProcessor: ObservableObject { faceCrop: FaceCrop?, settings: RenderSettings, targetDimensions: VideoDimensions, + clipIndex: Int, ffmpeg: URL, clipNumber: Int, totalClips: Int, @@ -1128,7 +1140,7 @@ final class VideoProcessor: ObservableObject { "-ss", decimal(start), "-t", decimal(duration), "-i", source.path, - "-vf", normalizationFilter(speedFactor: speedFactor, fps: settings.fps, targetDimensions: targetDimensions, faceCrop: faceCrop), + "-vf", normalizationFilter(speedFactor: speedFactor, fps: settings.fps, targetDimensions: targetDimensions, faceCrop: faceCrop, clipIndex: clipIndex, burnInClipNumber: settings.burnInClipNumbers), "-t", decimal(settings.targetClipLength), ] arguments.append(contentsOf: videoEncoderArguments(settings)) @@ -1452,7 +1464,14 @@ final class VideoProcessor: ObservableObject { Swift.max(minimum, Swift.min(maximum, value)) } - private func normalizationFilter(speedFactor: Double, fps: Int, targetDimensions: VideoDimensions, faceCrop: FaceCrop?) -> String { + private func normalizationFilter( + speedFactor: Double, + fps: Int, + targetDimensions: VideoDimensions, + faceCrop: FaceCrop?, + clipIndex: Int, + burnInClipNumber: Bool + ) -> String { var filters: [String] = [] if let faceCrop { filters.append("crop=\(faceCrop.width):\(faceCrop.height):\(faceCrop.x):\(faceCrop.y)") @@ -1463,9 +1482,26 @@ final class VideoProcessor: ObservableObject { filters.append("pad=\(targetDimensions.width):\(targetDimensions.height):(ow-iw)/2:(oh-ih)/2") filters.append("setsar=1") filters.append(contentsOf: sdrFilters()) + if burnInClipNumber { + filters.append(clipNumberOverlayFilter(clipIndex)) + } return filters.joined(separator: ",") } + private func clipNumberOverlayFilter(_ clipIndex: Int) -> String { + let label = String(format: "%03d", clipIndex) + return [ + "drawtext=text='\(label)'", + "x=36", + "y=h-th-32", + "fontsize=54", + "fontcolor=white", + "box=1", + "boxcolor=black@0.55", + "boxborderw=16" + ].joined(separator: ":") + } + private func detectFaceCropIfNeeded( source: URL, start: Double, @@ -1558,6 +1594,7 @@ final class VideoProcessor: ObservableObject { "Übergangslänge: \(settings.transitionLength)s", "Audio entfernen: \(settings.removeAudio ? "ja" : "nein")", "Segment aus Mitte: \(settings.takeMiddleSegment ? "ja" : "nein")", + "Clipnummern eingeblendet: \(settings.burnInClipNumbers ? "ja" : "nein")", "Bestes Segment automatisch: \(settings.bestSegmentDetectionEnabled ? "an, Gesicht/Augen sichtbar" : "aus")", "Stabilisierung: \(settings.stabilizationEnabled ? settings.stabilizationMethod.rawValue : "aus")", "Stabilisierungsstärke: \(settings.stabilizationStrength.rawValue)",