316 lines
10 KiB
Swift
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
|
|
}
|
|
}
|