Extract stills for photo morphing

This commit is contained in:
Mikei386
2026-06-04 14:26:08 +02:00
parent 0b71f7c5e5
commit 1c61c8d998
2 changed files with 104 additions and 15 deletions
+2 -2
View File
@@ -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()
+102 -13
View File
@@ -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
}