diff --git a/Sources/GrowthLapse/ContentView.swift b/Sources/GrowthLapse/ContentView.swift index 765f74e..dc975ea 100644 --- a/Sources/GrowthLapse/ContentView.swift +++ b/Sources/GrowthLapse/ContentView.swift @@ -18,14 +18,14 @@ struct ContentView: View { Text(mode.rawValue).tag(mode) } } - .help("Video-Zeitraffer verarbeitet MOV/MP4-Clips. Foto-Morphing verarbeitet Bilder aus dem Input-Ordner und erzeugt Landmark-basierte Morph-Übergänge.") + .help("Video-Zeitraffer verarbeitet MOV/MP4-Clips. Foto-Morphing nutzt Bilder aus dem Input-Ordner oder extrahiert passende Standbilder aus Videos und erzeugt Landmark-basierte Morph-Übergänge.") pickerRow( title: "Input-Ordner", value: settings.inputFolder?.path(percentEncoded: false) ?? "Nicht gewählt", buttonTitle: "Auswählen", help: settings.renderMode == .photoMorphing - ? "Ordner mit chronologisch sortierten Bildern für Foto-Morphing." + ? "Ordner mit chronologisch sortierten Bildern oder Videos. Wenn keine Bilder gefunden werden, extrahiert GrowthLapse Standbilder aus den Videos." : "Ordner mit den chronologisch sortierten Quellvideos. Die gewählte Output-Datei wird automatisch aus der Input-Liste ausgeschlossen." ) { selectInputFolder() diff --git a/Sources/GrowthLapse/VideoProcessor.swift b/Sources/GrowthLapse/VideoProcessor.swift index c20ff71..58d2dcd 100644 --- a/Sources/GrowthLapse/VideoProcessor.swift +++ b/Sources/GrowthLapse/VideoProcessor.swift @@ -171,7 +171,7 @@ final class VideoProcessor: ObservableObject { appendLog("ffprobe: \(tools.ffprobe.path)") if effectiveSettings.renderMode == .photoMorphing { - return try await renderPhotoMorph(settings: effectiveSettings, ffmpeg: tools.ffmpeg) + return try await renderPhotoMorph(settings: effectiveSettings, ffmpeg: tools.ffmpeg, ffprobe: tools.ffprobe) } if effectiveSettings.stabilizationEnabled { @@ -269,7 +269,7 @@ final class VideoProcessor: ObservableObject { return RenderOutcome(outputURL: outputFile, project: renderProject) } - private func renderPhotoMorph(settings: RenderSettings, ffmpeg: URL) async throws -> RenderOutcome { + private func renderPhotoMorph(settings: RenderSettings, ffmpeg: URL, ffprobe: URL) async throws -> RenderOutcome { let inputFolder = settings.inputFolder! let outputFile = settings.outputFile! let tempDirectory = try createWorkingDirectory( @@ -281,19 +281,28 @@ final class VideoProcessor: ObservableObject { temporaryDirectory = tempDirectory appendLog("Foto-Morphing: Temporärer Ordner \(tempDirectory.path)") - let images = try discoverImages(in: inputFolder, sortMode: settings.sortMode) - guard images.count >= 2 else { - throw VideoProcessingError.invalidSettings("Für Foto-Morphing braucht GrowthLapse mindestens zwei Bilder im Input-Ordner.") - } - appendLog("Foto-Morphing: \(images.count) Bild(er) gefunden.") - let targetDimensions = outputDimensionsForStillMode(settings) let framesDirectory = tempDirectory.appendingPathComponent("morph_frames", isDirectory: true) try FileManager.default.createDirectory(at: framesDirectory, withIntermediateDirectories: true) + let stillsDirectory = tempDirectory.appendingPathComponent("morph_stills", isDirectory: true) + try FileManager.default.createDirectory(at: stillsDirectory, withIntermediateDirectories: true) + + let imageSources = try await photoMorphSources( + inputFolder: inputFolder, + outputFile: outputFile, + settings: settings, + ffmpeg: ffmpeg, + ffprobe: ffprobe, + stillsDirectory: stillsDirectory + ) + guard imageSources.count >= 2 else { + throw VideoProcessingError.invalidSettings("Für Foto-Morphing braucht GrowthLapse mindestens zwei Bilder oder Videos im Input-Ordner.") + } + appendLog("Foto-Morphing: \(imageSources.count) Standbild(er) bereit.") progressText = "Analysiere Bilder" let morphImages = try await PhotoMorphRenderer.prepareImages( - urls: images, + urls: imageSources, targetDimensions: targetDimensions, targetFaceHeightRatio: settings.targetFaceHeightRatio ) { [weak self] index, total, name, usedLandmarks in @@ -331,7 +340,7 @@ final class VideoProcessor: ObservableObject { settings: settings, outputFile: outputFile, targetDimensions: targetDimensions, - images: images, + images: imageSources, frameCount: frameCount ) appendLog("Render-Protokoll: \(reportURL.path)") @@ -349,6 +358,89 @@ final class VideoProcessor: ObservableObject { )) } + private func photoMorphSources( + inputFolder: URL, + outputFile: URL, + settings: RenderSettings, + ffmpeg: URL, + ffprobe: URL, + stillsDirectory: URL + ) async throws -> [URL] { + let images = try discoverImages(in: inputFolder, sortMode: settings.sortMode) + if !images.isEmpty { + appendLog("Foto-Morphing: \(images.count) Bild(er) im Input-Ordner gefunden.") + return images + } + + appendLog("Foto-Morphing: Keine Bilder gefunden, extrahiere Standbilder aus Videos.") + let videos = try await discoverVideos( + in: inputFolder, + sortMode: settings.sortMode, + duplicateClipSelection: settings.duplicateClipSelection, + ffprobe: ffprobe, + excluding: outputFile + ) + var stills: [URL] = [] + for (index, video) in videos.enumerated() { + try Task.checkCancellation() + let stillURL = stillsDirectory.appendingPathComponent(String(format: "still_%04d.png", index + 1)) + let time = try await stillTime(for: video, settings: settings, clipNumber: index + 1, totalClips: videos.count) + try await extractStillFrame(source: video.url, output: stillURL, time: time, ffmpeg: ffmpeg, index: index + 1, total: videos.count) + stills.append(stillURL) + } + return stills + } + + private func stillTime( + for video: VideoFile, + settings: RenderSettings, + clipNumber: Int, + totalClips: Int + ) async throws -> Double { + if settings.bestSegmentDetectionEnabled, video.duration > 0.5 { + progressText = "Standbild suchen \(clipNumber) von \(totalClips)" + appendLog("Foto-Morphing: suche Standbild aus \(video.displayName)") + if let result = try await BestSegmentAnalyzer.detectBestSegment( + source: video.url, + duration: video.duration, + segmentLength: min(settings.segmentLength, video.duration) + ) { + let time = min(video.duration, result.start + min(settings.segmentLength, video.duration) / 2) + appendLog("Foto-Morphing Standbild \(clipNumber): \(formatSeconds(time)), gute Frames \(result.goodFrameCount)/\(result.totalFrameCount)") + return time + } + } + + let fallback = settings.takeMiddleSegment ? video.duration / 2 : max(0, min(video.duration, settings.startOffset)) + appendLog("Foto-Morphing Standbild \(clipNumber): Fallback \(formatSeconds(fallback))") + return fallback + } + + private func extractStillFrame( + source: URL, + output: URL, + time: Double, + ffmpeg: URL, + index: Int, + total: Int + ) async throws { + progressText = "Standbild \(index) von \(total)" + let arguments = [ + "-y", + "-nostdin", + "-ss", decimal(max(0, time)), + "-i", source.path, + "-frames:v", "1", + "-vf", "format=rgb24", + output.path + ] + let result = try await runFFmpeg(ffmpeg, arguments: arguments) + guard result.exitCode == 0 else { + throw VideoProcessingError.ffmpegFailed("Standbild extrahieren \(source.lastPathComponent)", result.output) + } + appendLog("Foto-Morphing Standbild: \(source.lastPathComponent) -> \(output.lastPathComponent)") + } + private func discoverImages(in folder: URL, sortMode: SortMode) throws -> [URL] { let allowed = Set(["jpg", "jpeg", "png", "heic", "heif", "tif", "tiff"]) var urls = try FileManager.default.contentsOfDirectory( @@ -371,9 +463,6 @@ final class VideoProcessor: ObservableObject { } } - guard !urls.isEmpty else { - throw VideoProcessingError.invalidSettings("Im Input-Ordner wurden keine Bilder gefunden. Unterstützt: JPG, PNG, HEIC, TIFF.") - } return urls }