Files
GrowthLapse/Sources/GrowthLapse/RenderSettings.swift
T

316 lines
10 KiB
Swift

import Foundation
enum OutputFormat: String, CaseIterable, Identifiable, Codable {
case landscape1920x1080 = "Landscape 1920x1080 SDR"
case portrait1080x1920 = "Portrait 1080x1920"
case keepOriginal = "Originalformat beibehalten"
var id: String { rawValue }
}
enum SortMode: String, CaseIterable, Identifiable, Codable {
case filenameAscending = "Dateiname aufsteigend"
case ageWeeksAscending = "Alter in Wochen aufsteigend"
case modificationDateAscending = "Änderungsdatum aufsteigend"
var id: String { rawValue }
}
enum DuplicateClipSelection: String, CaseIterable, Identifiable, Codable {
case first = "Variante 1"
case second = "Variante 2"
case thirdIfAvailable = "Variante 3 wenn verfügbar"
case longest = "Längste Variante"
var id: String { rawValue }
}
enum VideoEncoder: String, CaseIterable, Identifiable, Codable {
case x264 = "CPU: libx264"
case videoToolbox = "Hardware: VideoToolbox H.264"
var id: String { rawValue }
}
enum StabilizationMethod: String, CaseIterable, Identifiable, Codable {
case vidstab = "VidStab (empfohlen)"
case deshake = "Deshake (Fallback)"
var id: String { rawValue }
}
enum StabilizationStrength: String, CaseIterable, Identifiable, Codable {
case light = "Leicht"
case medium = "Mittel"
case strong = "Stark"
case veryStrong = "Sehr stark"
var id: String { rawValue }
var shakiness: Int {
switch self {
case .light: return 4
case .medium: return 5
case .strong: return 8
case .veryStrong: return 10
}
}
var accuracy: Int {
switch self {
case .light: return 10
case .medium, .strong, .veryStrong: return 15
}
}
var smoothing: Int {
switch self {
case .light: return 6
case .medium: return 10
case .strong: return 16
case .veryStrong: return 28
}
}
var maxShift: Int {
switch self {
case .light: return 24
case .medium: return 48
case .strong: return 96
case .veryStrong: return 160
}
}
var maxAngle: Double {
switch self {
case .light: return 0.03
case .medium: return 0.06
case .strong: return 0.12
case .veryStrong: return 0.20
}
}
var deshakeSearch: Int {
switch self {
case .light: return 48
case .medium: return 64
case .strong: return 96
case .veryStrong: return 128
}
}
}
struct FFmpegFilterAvailability: Equatable {
let hasVidstabDetect: Bool
let hasVidstabTransform: Bool
let hasDeshake: Bool
var hasVidstab: Bool {
hasVidstabDetect && hasVidstabTransform
}
}
struct RenderSettings: Equatable {
var inputFolder: URL?
var outputFile: URL?
var ffmpegExecutable: URL?
var ffprobeExecutable: URL?
var segmentLength: Double = 20
var targetClipLength: Double = 3
var transitionLength: Double = 0.5
var fps: Int = 30
var outputFormat: OutputFormat = .landscape1920x1080
var sortMode: SortMode = .filenameAscending
var duplicateClipSelection: DuplicateClipSelection = .first
var removeAudio: Bool = true
var takeMiddleSegment: Bool = true
var startOffset: Double = 0
var keepIntermediateClips: Bool = false
var videoEncoder: VideoEncoder = .videoToolbox
var crf: Int = 20
var hardwareBitrateMbps: Int = 12
var stabilizationEnabled: Bool = false
var stabilizationMethod: StabilizationMethod = .vidstab
var stabilizationStrength: StabilizationStrength = .medium
var stabilizationAnchorEnabled: Bool = false
var stabilizationAnchorX: Double = 0.5
var stabilizationAnchorY: Double = 0.5
var stabilizationAnchorSize: Double = 0.35
var faceNormalizationEnabled: Bool = false
var targetFaceHeightRatio: Double = 0.28
}
struct PersistedRenderSettings: Codable {
var inputFolderPath: String?
var outputFilePath: String?
var ffmpegExecutablePath: String?
var ffprobeExecutablePath: String?
var segmentLength: Double
var targetClipLength: Double
var transitionLength: Double
var fps: Int
var outputFormat: OutputFormat
var sortMode: SortMode
var duplicateClipSelection: DuplicateClipSelection?
var removeAudio: Bool
var takeMiddleSegment: Bool
var startOffset: Double
var keepIntermediateClips: Bool
var videoEncoder: VideoEncoder
var crf: Int
var hardwareBitrateMbps: Int
var stabilizationEnabled: Bool
var stabilizationMethod: StabilizationMethod
var stabilizationStrength: StabilizationStrength
var stabilizationAnchorEnabled: Bool
var stabilizationAnchorX: Double
var stabilizationAnchorY: Double
var stabilizationAnchorSize: Double
var faceNormalizationEnabled: Bool
var targetFaceHeightRatio: Double
init(settings: RenderSettings) {
inputFolderPath = settings.inputFolder?.path
outputFilePath = settings.outputFile?.path
ffmpegExecutablePath = settings.ffmpegExecutable?.path
ffprobeExecutablePath = settings.ffprobeExecutable?.path
segmentLength = settings.segmentLength
targetClipLength = settings.targetClipLength
transitionLength = settings.transitionLength
fps = settings.fps
outputFormat = settings.outputFormat
sortMode = settings.sortMode
duplicateClipSelection = settings.duplicateClipSelection
removeAudio = settings.removeAudio
takeMiddleSegment = settings.takeMiddleSegment
startOffset = settings.startOffset
keepIntermediateClips = settings.keepIntermediateClips
videoEncoder = settings.videoEncoder
crf = settings.crf
hardwareBitrateMbps = settings.hardwareBitrateMbps
stabilizationEnabled = settings.stabilizationEnabled
stabilizationMethod = settings.stabilizationMethod
stabilizationStrength = settings.stabilizationStrength
stabilizationAnchorEnabled = settings.stabilizationAnchorEnabled
stabilizationAnchorX = settings.stabilizationAnchorX
stabilizationAnchorY = settings.stabilizationAnchorY
stabilizationAnchorSize = settings.stabilizationAnchorSize
faceNormalizationEnabled = settings.faceNormalizationEnabled
targetFaceHeightRatio = settings.targetFaceHeightRatio
}
var renderSettings: RenderSettings {
RenderSettings(
inputFolder: inputFolderPath.map { URL(fileURLWithPath: $0) },
outputFile: outputFilePath.map { URL(fileURLWithPath: $0) },
ffmpegExecutable: ffmpegExecutablePath.map { URL(fileURLWithPath: $0) },
ffprobeExecutable: ffprobeExecutablePath.map { URL(fileURLWithPath: $0) },
segmentLength: segmentLength,
targetClipLength: targetClipLength,
transitionLength: transitionLength,
fps: fps,
outputFormat: outputFormat,
sortMode: sortMode,
duplicateClipSelection: duplicateClipSelection ?? .first,
removeAudio: removeAudio,
takeMiddleSegment: takeMiddleSegment,
startOffset: startOffset,
keepIntermediateClips: keepIntermediateClips,
videoEncoder: videoEncoder,
crf: crf,
hardwareBitrateMbps: hardwareBitrateMbps,
stabilizationEnabled: stabilizationEnabled,
stabilizationMethod: stabilizationMethod,
stabilizationStrength: stabilizationStrength,
stabilizationAnchorEnabled: stabilizationAnchorEnabled,
stabilizationAnchorX: stabilizationAnchorX,
stabilizationAnchorY: stabilizationAnchorY,
stabilizationAnchorSize: stabilizationAnchorSize,
faceNormalizationEnabled: faceNormalizationEnabled,
targetFaceHeightRatio: targetFaceHeightRatio
)
}
}
enum SettingsStore {
static func load() -> RenderSettings {
let url = settingsURL()
guard let data = try? Data(contentsOf: url),
let persisted = try? JSONDecoder().decode(PersistedRenderSettings.self, from: data) else {
return RenderSettings()
}
return persisted.renderSettings
}
static func save(_ settings: RenderSettings) {
let url = settingsURL()
do {
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
let data = try JSONEncoder.prettyPrinted.encode(PersistedRenderSettings(settings: settings))
try data.write(to: url, options: [.atomic])
} catch {
// Settings persistence must not block rendering or UI changes.
}
}
static func reset() -> RenderSettings {
let defaults = RenderSettings()
save(defaults)
return defaults
}
private static func settingsURL() -> URL {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support")
return base.appendingPathComponent("GrowthLapse", isDirectory: true).appendingPathComponent("settings.json")
}
}
private extension JSONEncoder {
static var prettyPrinted: JSONEncoder {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return encoder
}
}
struct VideoFile: Identifiable {
let id = UUID()
let url: URL
let duration: Double
let width: Int
let height: Int
var displayName: String {
url.lastPathComponent
}
}
struct VideoDimensions: Equatable {
let width: Int
let height: Int
}
struct FaceCrop: Equatable {
let x: Int
let y: Int
let width: Int
let height: Int
let faceHeightRatio: Double
}
enum RenderState: Equatable {
case idle
case running
case finished(URL)
case failed(String)
case cancelled
var isRunning: Bool {
if case .running = self {
return true
}
return false
}
}