Extract stills for photo morphing
This commit is contained in:
@@ -18,14 +18,14 @@ struct ContentView: View {
|
|||||||
Text(mode.rawValue).tag(mode)
|
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(
|
pickerRow(
|
||||||
title: "Input-Ordner",
|
title: "Input-Ordner",
|
||||||
value: settings.inputFolder?.path(percentEncoded: false) ?? "Nicht gewählt",
|
value: settings.inputFolder?.path(percentEncoded: false) ?? "Nicht gewählt",
|
||||||
buttonTitle: "Auswählen",
|
buttonTitle: "Auswählen",
|
||||||
help: settings.renderMode == .photoMorphing
|
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."
|
: "Ordner mit den chronologisch sortierten Quellvideos. Die gewählte Output-Datei wird automatisch aus der Input-Liste ausgeschlossen."
|
||||||
) {
|
) {
|
||||||
selectInputFolder()
|
selectInputFolder()
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ final class VideoProcessor: ObservableObject {
|
|||||||
appendLog("ffprobe: \(tools.ffprobe.path)")
|
appendLog("ffprobe: \(tools.ffprobe.path)")
|
||||||
|
|
||||||
if effectiveSettings.renderMode == .photoMorphing {
|
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 {
|
if effectiveSettings.stabilizationEnabled {
|
||||||
@@ -269,7 +269,7 @@ final class VideoProcessor: ObservableObject {
|
|||||||
return RenderOutcome(outputURL: outputFile, project: renderProject)
|
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 inputFolder = settings.inputFolder!
|
||||||
let outputFile = settings.outputFile!
|
let outputFile = settings.outputFile!
|
||||||
let tempDirectory = try createWorkingDirectory(
|
let tempDirectory = try createWorkingDirectory(
|
||||||
@@ -281,19 +281,28 @@ final class VideoProcessor: ObservableObject {
|
|||||||
temporaryDirectory = tempDirectory
|
temporaryDirectory = tempDirectory
|
||||||
appendLog("Foto-Morphing: Temporärer Ordner \(tempDirectory.path)")
|
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 targetDimensions = outputDimensionsForStillMode(settings)
|
||||||
let framesDirectory = tempDirectory.appendingPathComponent("morph_frames", isDirectory: true)
|
let framesDirectory = tempDirectory.appendingPathComponent("morph_frames", isDirectory: true)
|
||||||
try FileManager.default.createDirectory(at: framesDirectory, withIntermediateDirectories: 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"
|
progressText = "Analysiere Bilder"
|
||||||
let morphImages = try await PhotoMorphRenderer.prepareImages(
|
let morphImages = try await PhotoMorphRenderer.prepareImages(
|
||||||
urls: images,
|
urls: imageSources,
|
||||||
targetDimensions: targetDimensions,
|
targetDimensions: targetDimensions,
|
||||||
targetFaceHeightRatio: settings.targetFaceHeightRatio
|
targetFaceHeightRatio: settings.targetFaceHeightRatio
|
||||||
) { [weak self] index, total, name, usedLandmarks in
|
) { [weak self] index, total, name, usedLandmarks in
|
||||||
@@ -331,7 +340,7 @@ final class VideoProcessor: ObservableObject {
|
|||||||
settings: settings,
|
settings: settings,
|
||||||
outputFile: outputFile,
|
outputFile: outputFile,
|
||||||
targetDimensions: targetDimensions,
|
targetDimensions: targetDimensions,
|
||||||
images: images,
|
images: imageSources,
|
||||||
frameCount: frameCount
|
frameCount: frameCount
|
||||||
)
|
)
|
||||||
appendLog("Render-Protokoll: \(reportURL.path)")
|
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] {
|
private func discoverImages(in folder: URL, sortMode: SortMode) throws -> [URL] {
|
||||||
let allowed = Set(["jpg", "jpeg", "png", "heic", "heif", "tif", "tiff"])
|
let allowed = Set(["jpg", "jpeg", "png", "heic", "heif", "tif", "tiff"])
|
||||||
var urls = try FileManager.default.contentsOfDirectory(
|
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
|
return urls
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user