import AppKit
import AVFoundation
import Foundation
import ImageIO
import Vision

private struct RenderOutcome {
    let outputURL: URL
    let project: GrowthLapseProject
}

@MainActor
final class VideoProcessor: ObservableObject {
    @Published var progress: Double = 0
    @Published var progressText: String = "Bereit"
    @Published var logs: [String] = []
    @Published var state: RenderState = .idle
    @Published var warningText: String?
    @Published var project: GrowthLapseProject?

    private let service = FFmpegService()
    private var renderTask: Task<Void, Never>?
    private var temporaryDirectory: URL?

    func start(settings: RenderSettings) {
        guard !state.isRunning else {
            return
        }

        logs.removeAll()
        warningText = nil
        progress = 0
        progressText = "Vorbereitung"
        state = .running

        let settingsCopy = settings
        renderTask = Task {
            do {
                let outcome = try await render(settings: settingsCopy, existingProject: nil)
                guard !Task.isCancelled else {
                    throw VideoProcessingError.cancelled
                }
                project = settingsCopy.renderMode == .photoMorphing ? nil : outcome.project
                state = .finished(outcome.outputURL)
                progress = 1
                progressText = "Fertig"
                appendLog("Fertig: \(outcome.outputURL.path)")
            } catch is CancellationError {
                handleCancellation()
            } catch VideoProcessingError.cancelled {
                handleCancellation()
            } catch {
                state = .failed(error.localizedDescription)
                progressText = "Fehler"
                appendLog("Fehler: \(error.localizedDescription)")
            }
            renderTask = nil
        }
    }

    func rerenderProject(settings: RenderSettings) {
        guard !state.isRunning, let project else {
            return
        }

        logs.removeAll()
        warningText = nil
        progress = 0
        progressText = "Render aus Projekt"
        state = .running

        let settingsCopy = settings
        let projectCopy = reorderedProject(project, sortMode: settings.sortMode)
        renderTask = Task {
            do {
                let outcome = try await render(settings: settingsCopy, existingProject: projectCopy)
                guard !Task.isCancelled else {
                    throw VideoProcessingError.cancelled
                }
                self.project = outcome.project
                state = .finished(outcome.outputURL)
                progress = 1
                progressText = "Fertig"
                appendLog("Fertig: \(outcome.outputURL.path)")
            } catch is CancellationError {
                handleCancellation()
            } catch VideoProcessingError.cancelled {
                handleCancellation()
            } catch {
                state = .failed(error.localizedDescription)
                progressText = "Fehler"
                appendLog("Fehler: \(error.localizedDescription)")
            }
            renderTask = nil
        }
    }

    func updateClip(_ clip: GrowthLapseProjectClip, segmentStart: Double) {
        guard var project,
              let index = project.clips.firstIndex(where: { $0.id == clip.id }) else {
            return
        }
        let maxStart = max(0, project.clips[index].duration - project.clips[index].segmentLength)
        project.clips[index].segmentStart = clamp(segmentStart, min: 0, max: maxStart)
        project.clips[index].needsRender = true
        self.project = project
        saveProject(project)
    }

    func switchClip(_ clip: GrowthLapseProjectClip, to variant: GrowthLapseClipVariant) {
        guard var project,
              let index = project.clips.firstIndex(where: { $0.id == clip.id }) else {
            return
        }
        var updated = project.clips[index]
        updated.sourcePath = variant.sourcePath
        updated.displayName = variant.displayName
        updated.duration = variant.duration
        updated.width = variant.width
        updated.height = variant.height
        updated.ageWeeks = variant.ageWeeks
        updated.segmentLength = min(updated.segmentLength, variant.duration)
        updated.segmentStart = clamp(updated.segmentStart, min: 0, max: max(0, variant.duration - updated.segmentLength))
        updated.needsRender = true
        project.clips[index] = updated
        self.project = project
        saveProject(project)
        appendLog("Variante gewechselt: Clip \(updated.index) -> \(updated.displayName)")
    }

    func reorderProject(sortMode: SortMode) {
        guard let project else {
            return
        }
        let reordered = reorderedProject(project, sortMode: sortMode)
        self.project = reordered
        saveProject(reordered)
    }

    func saveCurrentProject() {
        guard let project else {
            appendLog("Kein Projekt zum Speichern geladen.")
            return
        }
        saveProject(project)
        appendLog("Projekt gespeichert: \(GrowthLapseProject.url(for: project.outputFileURL).path)")
    }

    func loadProject(from url: URL) -> GrowthLapseProject? {
        do {
            let data = try Data(contentsOf: url)
            var loadedProject = try JSONDecoder().decode(GrowthLapseProject.self, from: data)
            loadedProject.clips = loadedProject.clips.map { clip in
                var updated = clip
                if !FileManager.default.fileExists(atPath: updated.normalizedClipPath) {
                    updated.needsRender = true
                }
                return updated
            }
            project = loadedProject
            state = .finished(loadedProject.outputFileURL)
            progress = 1
            progressText = "Projekt geladen"
            appendLog("Projekt geladen: \(url.path)")
            if loadedProject.clips.contains(where: \.needsRender) {
                appendLog("Hinweis: Einige Cache-Clips fehlen und werden beim nächsten Render neu erzeugt.")
            }
            return loadedProject
        } catch {
            state = .failed("Projekt konnte nicht geladen werden: \(error.localizedDescription)")
            appendLog("Fehler: Projekt konnte nicht geladen werden: \(error.localizedDescription)")
            return nil
        }
    }

    func confirmProjectAndDeleteCache() {
        guard let project else {
            return
        }
        try? FileManager.default.removeItem(at: project.cacheDirectoryURL)
        appendLog("Render-Cache gelöscht: \(project.cacheDirectoryPath)")
    }

    func cancel() {
        guard state.isRunning else {
            return
        }
        renderTask?.cancel()
        Task {
            await service.cancel()
        }
        handleCancellation()
    }

    func showInFinder(url: URL) {
        NSWorkspace.shared.activateFileViewerSelecting([url])
    }

    private func render(settings: RenderSettings, existingProject: GrowthLapseProject?) async throws -> RenderOutcome {
        var effectiveSettings = settings
        try validate(effectiveSettings)
        let tools = try await service.validateTools(
            ffmpegOverride: effectiveSettings.ffmpegExecutable,
            ffprobeOverride: effectiveSettings.ffprobeExecutable
        )
        appendLog("ffmpeg: \(tools.ffmpeg.path)")
        appendLog("ffprobe: \(tools.ffprobe.path)")

        if effectiveSettings.renderMode == .photoMorphing {
            return try await renderPhotoMorph(settings: effectiveSettings, ffmpeg: tools.ffmpeg, ffprobe: tools.ffprobe)
        }

        if effectiveSettings.stabilizationEnabled || effectiveSettings.burnInClipNumbers {
            let filters = try await service.availableFilters(ffmpeg: tools.ffmpeg)
            if effectiveSettings.burnInClipNumbers, !filters.hasDrawtext {
                throw VideoProcessingError.invalidSettings("Clipnummern können nicht eingeblendet werden, weil dieser ffmpeg-Build den Filter drawtext nicht enthält.")
            }
            if effectiveSettings.stabilizationEnabled {
                effectiveSettings.stabilizationMethod = resolveStabilizationMethod(
                    requested: effectiveSettings.stabilizationMethod,
                    availability: filters
                )
                appendLog("Stabilisierung: \(effectiveSettings.stabilizationMethod.rawValue), \(effectiveSettings.stabilizationStrength.rawValue)")
            }
            if effectiveSettings.burnInClipNumbers {
                appendLog("Clipnummern: Einblendung unten links aktiv.")
            }
        }

        let inputFolder = settings.inputFolder!
        let outputFile = settings.outputFile!
        let useReviewCache = settings.keepRenderCacheForReview || existingProject != nil
        let tempDirectory = try createWorkingDirectory(
            keepDebugFiles: settings.keepIntermediateClips,
            keepReviewCache: useReviewCache,
            outputFile: outputFile,
            resetCache: existingProject == nil
        )
        temporaryDirectory = tempDirectory
        appendLog("Temporärer Ordner: \(tempDirectory.path)")

        let videos: [VideoFile]
        if let existingProject {
            videos = existingProject.clips.map {
                VideoFile(url: $0.sourceURL, duration: $0.duration, width: $0.width, height: $0.height, variants: $0.availableVariants)
            }
            appendLog("Projekt geladen: \(existingProject.clips.count) Clip(s).")
        } else {
            videos = try await discoverVideos(
                in: inputFolder,
                sortMode: settings.sortMode,
                duplicateClipSelection: settings.duplicateClipSelection,
                ffprobe: tools.ffprobe,
                excluding: outputFile
            )
        }
        appendLog("\(videos.count) Video(s) gefunden.")
        let targetDimensions = outputDimensions(for: effectiveSettings, firstVideo: videos[0])
        appendLog("Zielauflösung der Zwischenclips: \(targetDimensions.width)x\(targetDimensions.height)")

        var renderProject: GrowthLapseProject
        if let existingProject {
            renderProject = existingProject
        } else {
            renderProject = try await makeProject(
                outputFile: outputFile,
                cacheDirectory: tempDirectory,
                targetDimensions: targetDimensions,
                videos: videos,
                settings: effectiveSettings
            )
        }

        let normalizedClips = try await normalizeVideos(
            videos,
            project: &renderProject,
            settings: effectiveSettings,
            targetDimensions: targetDimensions,
            ffmpeg: tools.ffmpeg,
            tempDirectory: tempDirectory
        )

        if effectiveSettings.transitionLength <= 0 {
            try await concatenate(clips: normalizedClips, outputFile: outputFile, ffmpeg: tools.ffmpeg, crf: effectiveSettings.crf)
        } else {
            try await crossfadeIteratively(
                clips: normalizedClips,
                settings: effectiveSettings,
                targetDimensions: targetDimensions,
                outputFile: outputFile,
                ffmpeg: tools.ffmpeg,
                tempDirectory: tempDirectory,
                keepReviewCache: useReviewCache
            )
        }

        let reportURL = writeRenderReport(
            settings: effectiveSettings,
            outputFile: outputFile,
            targetDimensions: targetDimensions,
            project: renderProject
        )
        appendLog("Render-Protokoll: \(reportURL.path)")
        saveProject(renderProject)
        appendLog("Projektdatei: \(GrowthLapseProject.url(for: outputFile).path)")

        if !settings.keepIntermediateClips && !useReviewCache {
            try? FileManager.default.removeItem(at: tempDirectory)
        } else {
            cleanupTransformFiles(in: tempDirectory)
        }

        return RenderOutcome(outputURL: outputFile, project: renderProject)
    }

    private func renderPhotoMorph(settings: RenderSettings, ffmpeg: URL, ffprobe: URL) async throws -> RenderOutcome {
        let inputFolder = settings.inputFolder!
        let outputFile = settings.outputFile!
        let tempDirectory = try createWorkingDirectory(
            keepDebugFiles: settings.keepIntermediateClips,
            keepReviewCache: false,
            outputFile: outputFile,
            resetCache: true
        )
        temporaryDirectory = tempDirectory
        appendLog("Foto-Morphing: Temporärer Ordner \(tempDirectory.path)")

        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: imageSources,
            targetDimensions: targetDimensions,
            targetFaceHeightRatio: settings.targetFaceHeightRatio
        ) { [weak self] index, total, name, usedLandmarks in
            Task { @MainActor in
                self?.progressText = "Bildanalyse \(index) von \(total)"
                self?.appendLog("Foto-Morphing Analyse \(index)/\(total): \(name)\(usedLandmarks ? "" : " (Fallback ohne Gesicht)")")
                self?.progress = Double(index) / Double(total) * 0.25
            }
        }

        progressText = "Erzeuge Morph-Frames"
        let frameCount = try await PhotoMorphRenderer.renderFrames(
            images: morphImages,
            outputDirectory: framesDirectory,
            fps: settings.fps,
            secondsPerTransition: settings.targetClipLength
        ) { [weak self] frameIndex, totalFrames in
            Task { @MainActor in
                self?.progressText = "Morph-Frame \(frameIndex) von \(totalFrames)"
                self?.progress = 0.25 + Double(frameIndex) / Double(max(1, totalFrames)) * 0.55
            }
        }
        appendLog("Foto-Morphing: \(frameCount) Frame(s) erzeugt.")

        progressText = "Kodieren"
        try await encodeImageSequence(
            framesDirectory: framesDirectory,
            outputFile: outputFile,
            settings: settings,
            ffmpeg: ffmpeg
        )

        progress = 0.95
        let reportURL = writePhotoMorphReport(
            settings: settings,
            outputFile: outputFile,
            targetDimensions: targetDimensions,
            images: imageSources,
            frameCount: frameCount
        )
        appendLog("Render-Protokoll: \(reportURL.path)")

        if !settings.keepIntermediateClips {
            try? FileManager.default.removeItem(at: tempDirectory)
        }

        return RenderOutcome(outputURL: outputFile, project: GrowthLapseProject(
            outputFilePath: outputFile.path,
            cacheDirectoryPath: tempDirectory.path,
            targetWidth: targetDimensions.width,
            targetHeight: targetDimensions.height,
            clips: []
        ))
    }

    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(
            at: folder,
            includingPropertiesForKeys: [.contentModificationDateKey],
            options: [.skipsHiddenFiles]
        )
        .filter { allowed.contains($0.pathExtension.lowercased()) }

        switch sortMode {
        case .filenameAscending, .ageWeeksAscending:
            urls.sort {
                $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending
            }
        case .modificationDateAscending:
            urls.sort {
                let left = (try? $0.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
                let right = (try? $1.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
                return left < right
            }
        }

        return urls
    }

    private func outputDimensionsForStillMode(_ settings: RenderSettings) -> VideoDimensions {
        switch settings.outputFormat {
        case .landscape1920x1080:
            return VideoDimensions(width: 1920, height: 1080)
        case .portrait1080x1920:
            return VideoDimensions(width: 1080, height: 1920)
        case .keepOriginal:
            return VideoDimensions(width: 1920, height: 1080)
        }
    }

    private func encodeImageSequence(framesDirectory: URL, outputFile: URL, settings: RenderSettings, ffmpeg: URL) async throws {
        var arguments = [
            "-y",
            "-nostdin",
            "-framerate", "\(settings.fps)",
            "-i", framesDirectory.appendingPathComponent("frame_%06d.png").path
        ]
        arguments.append(contentsOf: videoEncoderArguments(settings))
        arguments.append(contentsOf: [
            "-pix_fmt", "yuv420p",
            "-movflags", "+faststart",
            outputFile.path
        ])

        let result = try await runFFmpeg(ffmpeg, arguments: arguments)
        guard result.exitCode == 0 else {
            throw VideoProcessingError.ffmpegFailed("Foto-Morphing kodieren", result.output)
        }
    }

    private func resolveStabilizationMethod(requested: StabilizationMethod, availability: FFmpegFilterAvailability) -> StabilizationMethod {
        switch requested {
        case .vidstab:
            if availability.hasVidstab {
                return .vidstab
            }
            let warning = "VidStab ist in diesem ffmpeg nicht verfügbar. GrowthLapse nutzt automatisch Deshake als Fallback."
            warningText = warning
            appendLog("Warnung: \(warning)")
            return .deshake
        case .deshake:
            if !availability.hasDeshake {
                let warning = "Deshake wurde in ffmpeg nicht gefunden. Der Render kann bei aktivierter Stabilisierung fehlschlagen."
                warningText = warning
                appendLog("Warnung: \(warning)")
            }
            return .deshake
        }
    }

    private func validate(_ settings: RenderSettings) throws {
        guard settings.inputFolder != nil else {
            throw VideoProcessingError.invalidSettings("Bitte wähle einen Input-Ordner aus.")
        }
        guard settings.outputFile != nil else {
            throw VideoProcessingError.invalidSettings("Bitte wähle eine Output-Datei aus.")
        }
        guard settings.segmentLength > 0 else {
            throw VideoProcessingError.invalidSettings("Die Ausschnittlänge muss größer als 0 sein.")
        }
        guard settings.targetClipLength > 0 else {
            throw VideoProcessingError.invalidSettings("Die Ziellänge pro Clip muss größer als 0 sein.")
        }
        guard settings.transitionLength >= 0 else {
            throw VideoProcessingError.invalidSettings("Die Übergangslänge darf nicht negativ sein.")
        }
        guard settings.fps > 0 else {
            throw VideoProcessingError.invalidSettings("FPS muss größer als 0 sein.")
        }
        guard settings.crf >= 0, settings.crf <= 51 else {
            throw VideoProcessingError.invalidSettings("CRF muss zwischen 0 und 51 liegen.")
        }
        guard settings.hardwareBitrateMbps > 0 else {
            throw VideoProcessingError.invalidSettings("Die Hardware-Bitrate muss größer als 0 sein.")
        }
        guard settings.targetFaceHeightRatio > 0, settings.targetFaceHeightRatio < 1 else {
            throw VideoProcessingError.invalidSettings("Die Ziel-Gesichtshöhe muss zwischen 0 und 100 Prozent liegen.")
        }
        if settings.transitionLength >= settings.targetClipLength {
            throw VideoProcessingError.invalidSettings("Die Übergangslänge muss kleiner als die Ziellänge pro Clip sein.")
        }
    }

    private func discoverVideos(
        in folder: URL,
        sortMode: SortMode,
        duplicateClipSelection: DuplicateClipSelection,
        ffprobe: URL,
        excluding outputFile: URL?
    ) async throws -> [VideoFile] {
        let supportedExtensions = Set(["mp4", "mov", "m4v", "avi", "mkv", "webm"])
        let excludedPath = outputFile?.standardizedFileURL.path
        let contents = try FileManager.default.contentsOfDirectory(
            at: folder,
            includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey],
            options: [.skipsHiddenFiles]
        )

        var urls = contents.filter { url in
            supportedExtensions.contains(url.pathExtension.lowercased())
                && url.standardizedFileURL.path != excludedPath
        }
        if let excludedPath, contents.contains(where: { $0.standardizedFileURL.path == excludedPath }) {
            appendLog("Output-Datei aus Input-Liste ausgeschlossen: \(URL(fileURLWithPath: excludedPath).lastPathComponent)")
        }
        switch sortMode {
        case .filenameAscending:
            urls.sort { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending }
        case .ageWeeksAscending:
            urls.sort { left, right in
                let leftAge = ageInWeeks(from: left)
                let rightAge = ageInWeeks(from: right)
                if leftAge != rightAge {
                    return (leftAge ?? Int.max) < (rightAge ?? Int.max)
                }
                return left.lastPathComponent.localizedStandardCompare(right.lastPathComponent) == .orderedAscending
            }
        case .modificationDateAscending:
            urls.sort {
                let left = (try? $0.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
                let right = (try? $1.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
                return left < right
            }
        }

        guard !urls.isEmpty else {
            throw VideoProcessingError.noVideosFound
        }

        var result: [VideoFile] = []
        for url in urls {
            try Task.checkCancellation()
            let duration = try await probeDuration(url: url, ffprobe: ffprobe)
            let dimensions = try await probeDimensions(url: url, ffprobe: ffprobe)
            result.append(VideoFile(url: url, duration: duration, width: dimensions.width, height: dimensions.height))
            appendLog("Dauer \(url.lastPathComponent): \(formatSeconds(duration)), \(dimensions.width)x\(dimensions.height)")
        }
        return selectDuplicateVariants(result, selection: duplicateClipSelection)
    }

    private func selectDuplicateVariants(_ videos: [VideoFile], selection: DuplicateClipSelection) -> [VideoFile] {
        var groups: [String: [VideoFile]] = [:]
        for video in videos {
            guard let key = duplicateGroupKey(for: video.url) else {
                continue
            }
            groups[key, default: []].append(video)
        }

        var selected: [VideoFile] = []
        var handledGroups = Set<String>()

        for video in videos {
            guard let key = duplicateGroupKey(for: video.url),
                  let variants = groups[key],
                  variants.count > 1 else {
                selected.append(video)
                continue
            }

            guard !handledGroups.contains(key) else {
                continue
            }
            handledGroups.insert(key)

            let chosen = chooseDuplicateVariant(from: variants, selection: selection)
            selected.append(chosen.withVariants(variants.map { projectVariant(from: $0) }))
            let skipped = variants
                .filter { $0.url != chosen.url }
                .map(\.displayName)
                .joined(separator: ", ")
            appendLog("Mehrfach-Version: \(duplicateDisplayName(for: key)) -> \(chosen.displayName) (\(selection.rawValue)); übersprungen: \(skipped)")
        }

        return selected
    }

    private func projectVariant(from video: VideoFile) -> GrowthLapseClipVariant {
        GrowthLapseClipVariant(
            sourcePath: video.url.path,
            displayName: video.displayName,
            duration: video.duration,
            width: video.width,
            height: video.height,
            ageWeeks: ageInWeeks(from: video.url)
        )
    }

    private func ageInWeeks(from url: URL) -> Int? {
        let name = url.deletingPathExtension().lastPathComponent
        let pattern = #"(\d{1,4})\s*Wochen\s+alt"#
        guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]),
              let match = regex.firstMatch(in: name, range: NSRange(name.startIndex..., in: name)),
              match.numberOfRanges == 2,
              let range = Range(match.range(at: 1), in: name) else {
            return nil
        }
        return Int(name[range])
    }

    private func chooseDuplicateVariant(from variants: [VideoFile], selection: DuplicateClipSelection) -> VideoFile {
        let sortedVariants = variants.sorted { left, right in
            let leftVersion = duplicateVersionNumber(for: left.url) ?? Int.max
            let rightVersion = duplicateVersionNumber(for: right.url) ?? Int.max
            if leftVersion != rightVersion {
                return leftVersion < rightVersion
            }
            return left.url.lastPathComponent.localizedStandardCompare(right.url.lastPathComponent) == .orderedAscending
        }

        switch selection {
        case .first:
            return variant(number: 1, in: sortedVariants) ?? sortedVariants[0]
        case .second:
            return variant(number: 2, in: sortedVariants) ?? sortedVariants[0]
        case .thirdIfAvailable:
            return variant(number: 3, in: sortedVariants) ?? sortedVariants.last ?? sortedVariants[0]
        case .longest:
            return sortedVariants.max { left, right in
                if left.duration != right.duration {
                    return left.duration < right.duration
                }
                let leftVersion = duplicateVersionNumber(for: left.url) ?? Int.max
                let rightVersion = duplicateVersionNumber(for: right.url) ?? Int.max
                return leftVersion > rightVersion
            } ?? sortedVariants[0]
        }
    }

    private func variant(number: Int, in variants: [VideoFile]) -> VideoFile? {
        variants.first { duplicateVersionNumber(for: $0.url) == number }
    }

    private func duplicateGroupKey(for url: URL) -> String? {
        let name = url.deletingPathExtension().lastPathComponent
        guard let parsed = parseDuplicateSuffix(from: name) else {
            return nil
        }
        return parsed.base.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
    }

    private func duplicateDisplayName(for key: String) -> String {
        key.isEmpty ? "Unbenannte Gruppe" : key
    }

    private func duplicateVersionNumber(for url: URL) -> Int? {
        parseDuplicateSuffix(from: url.deletingPathExtension().lastPathComponent)?.version
    }

    private func parseDuplicateSuffix(from filenameWithoutExtension: String) -> (base: String, version: Int)? {
        let pattern = #"^(.*\S)\s+(?:V)?(\d{1,3})$"#
        guard let regex = try? NSRegularExpression(pattern: pattern),
              let match = regex.firstMatch(
                in: filenameWithoutExtension,
                range: NSRange(filenameWithoutExtension.startIndex..., in: filenameWithoutExtension)
              ),
              match.numberOfRanges == 3,
              let baseRange = Range(match.range(at: 1), in: filenameWithoutExtension),
              let versionRange = Range(match.range(at: 2), in: filenameWithoutExtension),
              let version = Int(filenameWithoutExtension[versionRange]),
              version > 0 else {
            return nil
        }
        return (String(filenameWithoutExtension[baseRange]), version)
    }

    private func probeDuration(url: URL, ffprobe: URL) async throws -> Double {
        let result = try await service.run(
            executable: ffprobe,
            arguments: [
                "-v", "error",
                "-show_entries", "format=duration",
                "-of", "default=noprint_wrappers=1:nokey=1",
                url.path
            ]
        )

        guard result.exitCode == 0 else {
            throw VideoProcessingError.ffprobeFailed(url, result.output)
        }

        let trimmed = result.output.trimmingCharacters(in: .whitespacesAndNewlines)
        guard let duration = Double(trimmed), duration.isFinite, duration > 0 else {
            throw VideoProcessingError.ffprobeFailed(url, result.output)
        }
        return duration
    }

    private func probeDimensions(url: URL, ffprobe: URL) async throws -> VideoDimensions {
        let result = try await service.run(
            executable: ffprobe,
            arguments: [
                "-v", "error",
                "-select_streams", "v:0",
                "-show_entries", "stream=width,height",
                "-of", "csv=s=x:p=0",
                url.path
            ]
        )

        guard result.exitCode == 0 else {
            throw VideoProcessingError.ffprobeFailed(url, result.output)
        }

        let firstLine = result.output
            .split(separator: "\n")
            .map(String.init)
            .first?
            .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
        let parts = firstLine.split(separator: "x")

        guard parts.count == 2,
              let width = Int(parts[0]),
              let height = Int(parts[1]),
              width > 0,
              height > 0 else {
            throw VideoProcessingError.ffprobeFailed(url, result.output)
        }

        return VideoDimensions(width: evenDimension(width), height: evenDimension(height))
    }

    private func normalizeVideos(
        _ videos: [VideoFile],
        project: inout GrowthLapseProject,
        settings: RenderSettings,
        targetDimensions: VideoDimensions,
        ffmpeg: URL,
        tempDirectory: URL
    ) async throws -> [URL] {
        var outputURLs: [URL] = []
        let normalizationWeight = settings.transitionLength > 0 ? 0.75 : 0.9

        for (index, video) in videos.enumerated() {
            try Task.checkCancellation()
            let clipNumber = index + 1
            var projectClip = project.clips[index]
            let outputURL = projectClip.normalizedClipURL
            let actualSegmentLength = projectClip.segmentLength
            let safeStart = min(projectClip.segmentStart, max(0, video.duration - actualSegmentLength))
            let speedFactor = max(0.0001, actualSegmentLength / settings.targetClipLength)

            if !projectClip.needsRender, FileManager.default.fileExists(atPath: outputURL.path) {
                appendLog("Cache verwendet: \(projectClip.displayName) -> \(outputURL.lastPathComponent)")
                outputURLs.append(outputURL)
                progress = Double(index + 1) / Double(videos.count) * normalizationWeight
                continue
            }

            let faceCrop = try await detectFaceCropIfNeeded(
                source: video.url,
                start: safeStart,
                duration: actualSegmentLength,
                settings: settings,
                targetDimensions: targetDimensions,
                clipNumber: clipNumber,
                totalClips: videos.count
            )

            if settings.stabilizationEnabled {
                let segmentURL = tempDirectory.appendingPathComponent(String(format: "segment_%04d.mp4", clipNumber))
                let stabilizedURL = tempDirectory.appendingPathComponent(String(format: "stabilized_%04d.mp4", clipNumber))
                try await extractSegment(
                    source: video.url,
                    output: segmentURL,
                    start: safeStart,
                    duration: actualSegmentLength,
                    settings: settings,
                    ffmpeg: ffmpeg,
                    clipNumber: clipNumber,
                    totalClips: videos.count
                )
                let sourceForNormalization = try await stabilizeSegment(
                    segmentURL,
                    output: stabilizedURL,
                    video: video,
                    settings: settings,
                    ffmpeg: ffmpeg,
                    tempDirectory: tempDirectory,
                    clipNumber: clipNumber,
                    totalClips: videos.count
                )
                try await normalizePreparedSegment(
                    source: sourceForNormalization,
                    output: outputURL,
                    actualSegmentLength: actualSegmentLength,
                    speedFactor: speedFactor,
                    faceCrop: faceCrop,
                    settings: settings,
                    targetDimensions: targetDimensions,
                    clipIndex: projectClip.index,
                    ffmpeg: ffmpeg,
                    clipNumber: clipNumber,
                    totalClips: videos.count
                )
                if !settings.keepIntermediateClips {
                    removeTemporaryFile(segmentURL)
                    removeTemporaryFile(stabilizedURL)
                    removeTemporaryFile(tempDirectory.appendingPathComponent(String(format: "clip_%04d.trf", clipNumber)))
                }
            } else {
                try await normalizeSourceSegment(
                    source: video.url,
                    output: outputURL,
                    start: safeStart,
                    duration: actualSegmentLength,
                    speedFactor: speedFactor,
                    faceCrop: faceCrop,
                    settings: settings,
                    targetDimensions: targetDimensions,
                    clipIndex: projectClip.index,
                    ffmpeg: ffmpeg,
                    clipNumber: clipNumber,
                    totalClips: videos.count,
                    displayName: video.displayName
                )
            }

            projectClip.segmentStart = safeStart
            projectClip.segmentLength = actualSegmentLength
            projectClip.needsRender = false
            projectClip.normalizedClipPath = outputURL.path
            project.clips[index] = projectClip
            outputURLs.append(outputURL)
            progress = Double(index + 1) / Double(videos.count) * normalizationWeight
        }

        return outputURLs
    }

    private func extractSegment(
        source: URL,
        output: URL,
        start: Double,
        duration: Double,
        settings: RenderSettings,
        ffmpeg: URL,
        clipNumber: Int,
        totalClips: Int
    ) async throws {
        progressText = "Segment \(clipNumber) von \(totalClips)"
        appendLog("Schneide Segment \(source.lastPathComponent) @ \(formatSeconds(start)) für \(formatSeconds(duration)) -> \(output.lastPathComponent)")
        var arguments = [
            "-y",
            "-nostdin",
            "-ss", decimal(start),
            "-t", decimal(duration),
            "-i", source.path,
            "-map", "0:v:0",
            "-an"
        ]
        arguments.append(contentsOf: videoEncoderArguments(settings))
        arguments.append(contentsOf: ["-movflags", "+faststart", output.path])

        let result = try await runFFmpeg(ffmpeg, arguments: arguments)
        guard result.exitCode == 0 else {
            throw VideoProcessingError.ffmpegFailed("Segment schneiden \(source.lastPathComponent)", result.output)
        }
    }

    private func stabilizeSegment(
        _ segment: URL,
        output: URL,
        video: VideoFile,
        settings: RenderSettings,
        ffmpeg: URL,
        tempDirectory: URL,
        clipNumber: Int,
        totalClips: Int
    ) async throws -> URL {
        switch settings.stabilizationMethod {
        case .vidstab:
            do {
                return try await stabilizeWithVidStab(
                    segment,
                    output: output,
                    video: video,
                    settings: settings,
                    ffmpeg: ffmpeg,
                    tempDirectory: tempDirectory,
                    clipNumber: clipNumber,
                    totalClips: totalClips
                )
            } catch {
                appendLog("Warnung: VidStab für Clip \(clipNumber) fehlgeschlagen: \(error.localizedDescription)")
                appendLog("Fallback: Deshake für Clip \(clipNumber).")
                do {
                    return try await stabilizeWithDeshake(
                        segment,
                        output: output,
                        settings: settings,
                        ffmpeg: ffmpeg,
                        clipNumber: clipNumber,
                        totalClips: totalClips
                    )
                } catch {
                    appendLog("Warnung: Deshake für Clip \(clipNumber) ebenfalls fehlgeschlagen: \(error.localizedDescription)")
                    appendLog("Fallback: Clip \(clipNumber) wird unstabilisiert weiterverarbeitet.")
                    return segment
                }
            }
        case .deshake:
            do {
                return try await stabilizeWithDeshake(
                    segment,
                    output: output,
                    settings: settings,
                    ffmpeg: ffmpeg,
                    clipNumber: clipNumber,
                    totalClips: totalClips
                )
            } catch {
                appendLog("Warnung: Deshake für Clip \(clipNumber) fehlgeschlagen: \(error.localizedDescription)")
                appendLog("Fallback: Clip \(clipNumber) wird unstabilisiert weiterverarbeitet.")
                return segment
            }
        }
    }

    private func stabilizeWithVidStab(
        _ segment: URL,
        output: URL,
        video: VideoFile,
        settings: RenderSettings,
        ffmpeg: URL,
        tempDirectory: URL,
        clipNumber: Int,
        totalClips: Int
    ) async throws -> URL {
        let transformFileName = String(format: "clip_%04d.trf", clipNumber)
        let strength = settings.stabilizationStrength
        let detectFilter = vidstabDetectFilter(settings: settings, video: video, transformFileName: transformFileName)

        progressText = "VidStab Analyse \(clipNumber) von \(totalClips)"
        appendLog("VidStab Analyse nur auf Segment \(segment.lastPathComponent) -> \(transformFileName)")
        if settings.stabilizationAnchorEnabled {
            appendLog("VidStab Hintergrund-Anker: X \(Int(settings.stabilizationAnchorX * 100))%, Y \(Int(settings.stabilizationAnchorY * 100))%, Bereich \(Int(settings.stabilizationAnchorSize * 100))%")
        }
        let detectArguments = [
            "-y",
            "-nostdin",
            "-i", segment.path,
            "-vf", detectFilter,
            "-an",
            "-f", "null",
            "-"
        ]
        let detectResult = try await runFFmpeg(ffmpeg, arguments: detectArguments, currentDirectory: tempDirectory)
        guard detectResult.exitCode == 0 else {
            throw VideoProcessingError.ffmpegFailed("VidStab Analyse Clip \(clipNumber)", detectResult.output)
        }

        progressText = "VidStab Stabilisierung \(clipNumber) von \(totalClips)"
        appendLog("VidStab Transform auf Segment \(segment.lastPathComponent) -> \(output.lastPathComponent)")
        let transformFilter = [
            "vidstabtransform=input=\(transformFileName)",
            "smoothing=\(strength.smoothing)",
            "maxshift=\(strength.maxShift)",
            "maxangle=\(decimal(strength.maxAngle))",
            "optzoom=2",
            "interpol=bicubic"
        ].joined(separator: ":")
        var transformArguments = [
            "-y",
            "-nostdin",
            "-i", segment.path,
            "-vf", transformFilter,
            "-an"
        ]
        transformArguments.append(contentsOf: videoEncoderArguments(settings))
        transformArguments.append(contentsOf: ["-movflags", "+faststart", output.path])

        let transformResult = try await runFFmpeg(ffmpeg, arguments: transformArguments, currentDirectory: tempDirectory)
        guard transformResult.exitCode == 0 else {
            throw VideoProcessingError.ffmpegFailed("VidStab Stabilisierung Clip \(clipNumber)", transformResult.output)
        }

        return output
    }

    private func stabilizeWithDeshake(
        _ segment: URL,
        output: URL,
        settings: RenderSettings,
        ffmpeg: URL,
        clipNumber: Int,
        totalClips: Int
    ) async throws -> URL {
        progressText = "Deshake \(clipNumber) von \(totalClips)"
        appendLog("Deshake Stabilisierung auf Segment \(segment.lastPathComponent) -> \(output.lastPathComponent)")
        let search = settings.stabilizationStrength.deshakeSearch
        var arguments = [
            "-y",
            "-nostdin",
            "-i", segment.path,
            "-vf", "deshake=x=\(search):y=\(search):w=32:h=32",
            "-an"
        ]
        arguments.append(contentsOf: videoEncoderArguments(settings))
        arguments.append(contentsOf: ["-movflags", "+faststart", output.path])

        let result = try await runFFmpeg(ffmpeg, arguments: arguments)
        guard result.exitCode == 0 else {
            throw VideoProcessingError.ffmpegFailed("Deshake Stabilisierung Clip \(clipNumber)", result.output)
        }

        return output
    }

    private func normalizePreparedSegment(
        source: URL,
        output: URL,
        actualSegmentLength: Double,
        speedFactor: Double,
        faceCrop: FaceCrop?,
        settings: RenderSettings,
        targetDimensions: VideoDimensions,
        clipIndex: Int,
        ffmpeg: URL,
        clipNumber: Int,
        totalClips: Int
    ) async throws {
        progressText = "Normalisiere Clip \(clipNumber) von \(totalClips)"
        appendLog("Normalisiere stabilisierten Clip -> \(output.lastPathComponent)")
        var arguments = [
            "-y",
            "-nostdin",
            "-t", decimal(actualSegmentLength),
            "-i", source.path,
            "-vf", normalizationFilter(speedFactor: speedFactor, fps: settings.fps, targetDimensions: targetDimensions, faceCrop: faceCrop, clipIndex: clipIndex, burnInClipNumber: settings.burnInClipNumbers),
            "-t", decimal(settings.targetClipLength),
            "-an"
        ]
        arguments.append(contentsOf: videoEncoderArguments(settings))
        arguments.append(contentsOf: ["-movflags", "+faststart", output.path])

        let result = try await runFFmpeg(ffmpeg, arguments: arguments)
        guard result.exitCode == 0 else {
            throw VideoProcessingError.ffmpegFailed("Normalisieren Clip \(clipNumber)", result.output)
        }
    }

    private func normalizeSourceSegment(
        source: URL,
        output: URL,
        start: Double,
        duration: Double,
        speedFactor: Double,
        faceCrop: FaceCrop?,
        settings: RenderSettings,
        targetDimensions: VideoDimensions,
        clipIndex: Int,
        ffmpeg: URL,
        clipNumber: Int,
        totalClips: Int,
        displayName: String
    ) async throws {
        progressText = "Clip \(clipNumber) von \(totalClips)"
        appendLog("Normalisiere \(displayName) -> \(output.lastPathComponent)")
        var arguments = [
            "-y",
            "-nostdin",
            "-ss", decimal(start),
            "-t", decimal(duration),
            "-i", source.path,
            "-vf", normalizationFilter(speedFactor: speedFactor, fps: settings.fps, targetDimensions: targetDimensions, faceCrop: faceCrop, clipIndex: clipIndex, burnInClipNumber: settings.burnInClipNumbers),
            "-t", decimal(settings.targetClipLength),
        ]
        arguments.append(contentsOf: videoEncoderArguments(settings))
        arguments.append(contentsOf: ["-movflags", "+faststart"])
        if settings.removeAudio {
            arguments.append("-an")
        } else {
            arguments.append(contentsOf: ["-af", "atempo=\(audioTempoChain(speedFactor))", "-c:a", "aac", "-b:a", "192k"])
        }
        arguments.append(output.path)

        let result = try await runFFmpeg(ffmpeg, arguments: arguments)
        guard result.exitCode == 0 else {
            throw VideoProcessingError.ffmpegFailed("Normalisieren von \(displayName)", result.output)
        }
    }

    private func concatenate(clips: [URL], outputFile: URL, ffmpeg: URL, crf: Int) async throws {
        try Task.checkCancellation()
        guard clips.count > 1 else {
            try replaceOutput(with: clips[0], outputFile: outputFile)
            return
        }

        progressText = "Verbinde Clips"
        let listFile = (temporaryDirectory ?? FileManager.default.temporaryDirectory).appendingPathComponent("concat.txt")
        let list = clips.map { "file '\(escapeConcatPath($0.path))'" }.joined(separator: "\n")
        try list.write(to: listFile, atomically: true, encoding: .utf8)

        let arguments = [
            "-y",
            "-nostdin",
            "-f", "concat",
            "-safe", "0",
            "-i", listFile.path,
            "-c", "copy",
            "-movflags", "+faststart",
            outputFile.path
        ]

        appendLog("Concat ohne Übergänge.")
        let result = try await service.run(executable: ffmpeg, arguments: arguments) { [weak self] chunk in
            Task { @MainActor in
                self?.appendProcessOutput(chunk)
            }
        }

        guard result.exitCode == 0 else {
            throw VideoProcessingError.ffmpegFailed("Concat", result.output)
        }
        progress = 1
    }

    private func crossfadeIteratively(
        clips: [URL],
        settings: RenderSettings,
        targetDimensions: VideoDimensions,
        outputFile: URL,
        ffmpeg: URL,
        tempDirectory: URL,
        keepReviewCache: Bool
    ) async throws {
        try Task.checkCancellation()
        guard clips.count > 1 else {
            try replaceOutput(with: clips[0], outputFile: outputFile)
            return
        }

        var current = clips[0]
        var currentDuration = settings.targetClipLength
        let totalSteps = clips.count - 1

        for index in 1..<clips.count {
            try Task.checkCancellation()
            let isFinal = index == clips.count - 1
            let next = clips[index]
            let out = isFinal ? outputFile : tempDirectory.appendingPathComponent(String(format: "xfade_%04d.mp4", index))
            let offset = max(0, currentDuration - settings.transitionLength)

            progressText = "Übergang \(index) von \(totalSteps)"
            appendLog("Xfade \(current.lastPathComponent) + \(next.lastPathComponent)")

            let prep = "scale=\(targetDimensions.width):\(targetDimensions.height):force_original_aspect_ratio=decrease,pad=\(targetDimensions.width):\(targetDimensions.height):(ow-iw)/2:(oh-ih)/2,setsar=1,format=yuv420p"
            let filter = "[0:v]\(prep)[v0];[1:v]\(prep)[v1];[v0][v1]xfade=transition=fade:duration=\(decimal(settings.transitionLength)):offset=\(decimal(offset)),format=yuv420p,setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709[v]"
            let result = try await service.run(
                executable: ffmpeg,
                arguments: [
                    "-y",
                    "-nostdin",
                    "-i", current.path,
                    "-i", next.path,
                    "-filter_complex", filter,
                    "-map", "[v]",
                    "-an",
                ] + videoEncoderArguments(settings) + [
                    "-movflags", "+faststart",
                    out.path
                ]
            ) { [weak self] chunk in
                Task { @MainActor in
                    self?.appendProcessOutput(chunk)
                }
            }

            guard result.exitCode == 0 else {
                throw VideoProcessingError.ffmpegFailed("Xfade Übergang \(index)", result.output)
            }

            if !settings.keepIntermediateClips {
                if current.lastPathComponent.hasPrefix("xfade_") || !keepReviewCache {
                    removeTemporaryFile(current)
                }
                if !keepReviewCache {
                    removeTemporaryFile(next)
                }
            }
            current = out
            currentDuration += settings.targetClipLength - settings.transitionLength
            progress = 0.75 + (Double(index) / Double(totalSteps)) * 0.25
        }
    }

    private func createWorkingDirectory(keepDebugFiles: Bool, keepReviewCache: Bool, outputFile: URL, resetCache: Bool) throws -> URL {
        if keepReviewCache {
            let directory = GrowthLapseProject.cacheURL(for: outputFile)
            if resetCache, FileManager.default.fileExists(atPath: directory.path) {
                try FileManager.default.removeItem(at: directory)
            }
            try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
            return directory
        }

        return try createTemporaryDirectory(keep: keepDebugFiles, outputFile: outputFile)
    }

    private func createTemporaryDirectory(keep: Bool, outputFile: URL) throws -> URL {
        let base: URL
        if keep {
            base = outputFile.deletingPathExtension().deletingLastPathComponent()
        } else {
            base = FileManager.default.temporaryDirectory
        }

        let directory = base.appendingPathComponent("GrowthLapse-\(UUID().uuidString)", isDirectory: true)
        try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
        return directory
    }

    private func makeProject(
        outputFile: URL,
        cacheDirectory: URL,
        targetDimensions: VideoDimensions,
        videos: [VideoFile],
        settings: RenderSettings
    ) async throws -> GrowthLapseProject {
        var clips: [GrowthLapseProjectClip] = []

        for (index, video) in videos.enumerated() {
            try Task.checkCancellation()
            let segmentLength = min(settings.segmentLength, video.duration)
            let safeStart = try await initialSegmentStart(
                for: video,
                segmentLength: segmentLength,
                settings: settings,
                clipNumber: index + 1,
                totalClips: videos.count
            )
            let normalizedClipURL = cacheDirectory.appendingPathComponent(String(format: "clip_%04d.mp4", index + 1))
            clips.append(GrowthLapseProjectClip(
                index: index + 1,
                sourcePath: video.url.path,
                displayName: video.displayName,
                duration: video.duration,
                width: video.width,
                height: video.height,
                ageWeeks: ageInWeeks(from: video.url),
                segmentStart: safeStart,
                segmentLength: segmentLength,
                normalizedClipPath: normalizedClipURL.path,
                needsRender: true,
                variants: video.variants.isEmpty ? nil : video.variants
            ))
        }

        return GrowthLapseProject(
            outputFilePath: outputFile.path,
            cacheDirectoryPath: cacheDirectory.path,
            targetWidth: targetDimensions.width,
            targetHeight: targetDimensions.height,
            clips: clips
        )
    }

    private func initialSegmentStart(
        for video: VideoFile,
        segmentLength: Double,
        settings: RenderSettings,
        clipNumber: Int,
        totalClips: Int
    ) async throws -> Double {
        let fallbackStart: Double
        if settings.takeMiddleSegment, video.duration > settings.segmentLength {
            fallbackStart = max(0, (video.duration - settings.segmentLength) / 2 + settings.startOffset)
        } else {
            fallbackStart = max(0, settings.startOffset)
        }
        let safeFallback = min(fallbackStart, max(0, video.duration - segmentLength))

        guard settings.bestSegmentDetectionEnabled, video.duration > segmentLength else {
            return safeFallback
        }

        progressText = "Bestes Segment \(clipNumber) von \(totalClips)"
        appendLog("Bestes Segment: analysiere Augen/Gesicht in \(video.displayName)")
        do {
            let result = try await BestSegmentAnalyzer.detectBestSegment(
                source: video.url,
                duration: video.duration,
                segmentLength: segmentLength
            )
            guard let result else {
                appendLog("Bestes Segment Clip \(clipNumber): nichts Sicheres gefunden, nutze Fallback @ \(formatSeconds(safeFallback))")
                return safeFallback
            }
            appendLog("Bestes Segment Clip \(clipNumber): Start \(formatSeconds(result.start)), gute Frames \(result.goodFrameCount)/\(result.totalFrameCount), Score \(String(format: "%.2f", locale: Locale(identifier: "en_US_POSIX"), result.score))")
            return result.start
        } catch {
            appendLog("Warnung: Bestes Segment Clip \(clipNumber) fehlgeschlagen: \(error.localizedDescription). Nutze Fallback @ \(formatSeconds(safeFallback))")
            return safeFallback
        }
    }

    private func reorderedProject(_ project: GrowthLapseProject, sortMode: SortMode) -> GrowthLapseProject {
        var copy = project
        copy.clips = sortedProjectClips(project.clips, sortMode: sortMode).enumerated().map { offset, clip in
            var updated = clip
            updated.index = offset + 1
            return updated
        }
        return copy
    }

    private func sortedProjectClips(_ clips: [GrowthLapseProjectClip], sortMode: SortMode) -> [GrowthLapseProjectClip] {
        clips.sorted { left, right in
            switch sortMode {
            case .filenameAscending:
                return left.displayName.localizedStandardCompare(right.displayName) == .orderedAscending
            case .ageWeeksAscending:
                if left.ageWeeks != right.ageWeeks {
                    return (left.ageWeeks ?? Int.max) < (right.ageWeeks ?? Int.max)
                }
                return left.displayName.localizedStandardCompare(right.displayName) == .orderedAscending
            case .modificationDateAscending:
                let leftDate = modificationDate(for: left.sourceURL)
                let rightDate = modificationDate(for: right.sourceURL)
                if leftDate != rightDate {
                    return leftDate < rightDate
                }
                return left.displayName.localizedStandardCompare(right.displayName) == .orderedAscending
            }
        }
    }

    private func modificationDate(for url: URL) -> Date {
        (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
    }

    private func saveProject(_ project: GrowthLapseProject) {
        let url = GrowthLapseProject.url(for: project.outputFileURL)
        do {
            let encoder = JSONEncoder()
            encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
            let data = try encoder.encode(project)
            try data.write(to: url, options: [.atomic])
        } catch {
            appendLog("Warnung: Projektdatei konnte nicht geschrieben werden: \(error.localizedDescription)")
        }
    }

    private func replaceOutput(with source: URL, outputFile: URL) throws {
        if FileManager.default.fileExists(atPath: outputFile.path) {
            try FileManager.default.removeItem(at: outputFile)
        }
        try FileManager.default.copyItem(at: source, to: outputFile)
    }

    private func removeTemporaryFile(_ url: URL) {
        guard FileManager.default.fileExists(atPath: url.path) else {
            return
        }
        do {
            try FileManager.default.removeItem(at: url)
        } catch {
            appendLog("Warnung: Temporäre Datei konnte nicht gelöscht werden: \(url.lastPathComponent) (\(error.localizedDescription))")
        }
    }

    private func escapeConcatPath(_ path: String) -> String {
        path.replacingOccurrences(of: "'", with: "'\\''")
    }

    private func vidstabDetectFilter(settings: RenderSettings, video: VideoFile, transformFileName: String) -> String {
        let strength = settings.stabilizationStrength
        let detect = "vidstabdetect=shakiness=\(strength.shakiness):accuracy=\(strength.accuracy):result=\(transformFileName)"

        guard settings.stabilizationAnchorEnabled else {
            return detect
        }

        let width = max(2, evenDimension(Int(Double(video.width) * settings.stabilizationAnchorSize)))
        let height = max(2, evenDimension(Int(Double(video.height) * settings.stabilizationAnchorSize)))
        let centerX = Double(video.width) * settings.stabilizationAnchorX
        let centerY = Double(video.height) * settings.stabilizationAnchorY
        let x = evenDimension(Int(clamp(centerX - Double(width) / 2, min: 0, max: Double(max(0, video.width - width)))))
        let y = evenDimension(Int(clamp(centerY - Double(height) / 2, min: 0, max: Double(max(0, video.height - height)))))

        return "crop=\(width):\(height):\(x):\(y),\(detect)"
    }

    private func clamp(_ value: Double, min minimum: Double, max maximum: Double) -> Double {
        Swift.max(minimum, Swift.min(maximum, value))
    }

    private func normalizationFilter(
        speedFactor: Double,
        fps: Int,
        targetDimensions: VideoDimensions,
        faceCrop: FaceCrop?,
        clipIndex: Int,
        burnInClipNumber: Bool
    ) -> String {
        var filters: [String] = []
        if let faceCrop {
            filters.append("crop=\(faceCrop.width):\(faceCrop.height):\(faceCrop.x):\(faceCrop.y)")
        }
        filters.append("setpts=PTS/\(decimal(speedFactor))")
        filters.append("fps=\(fps)")
        filters.append("scale=\(targetDimensions.width):\(targetDimensions.height):force_original_aspect_ratio=decrease")
        filters.append("pad=\(targetDimensions.width):\(targetDimensions.height):(ow-iw)/2:(oh-ih)/2")
        filters.append("setsar=1")
        filters.append(contentsOf: sdrFilters())
        if burnInClipNumber {
            filters.append(clipNumberOverlayFilter(clipIndex))
        }
        return filters.joined(separator: ",")
    }

    private func clipNumberOverlayFilter(_ clipIndex: Int) -> String {
        let label = String(format: "%03d", clipIndex)
        return [
            "drawtext=text='\(label)'",
            "x=36",
            "y=h-th-32",
            "fontsize=54",
            "fontcolor=white",
            "box=1",
            "boxcolor=black@0.55",
            "boxborderw=16"
        ].joined(separator: ":")
    }

    private func detectFaceCropIfNeeded(
        source: URL,
        start: Double,
        duration: Double,
        settings: RenderSettings,
        targetDimensions: VideoDimensions,
        clipNumber: Int,
        totalClips: Int
    ) async throws -> FaceCrop? {
        guard settings.faceNormalizationEnabled else {
            return nil
        }

        progressText = "Gesichtsanalyse \(clipNumber) von \(totalClips)"
        appendLog("Vision Gesichtsanalyse: \(source.lastPathComponent)")

        let crop = try await FaceCropAnalyzer.detectCrop(
            source: source,
            start: start,
            duration: duration,
            targetDimensions: targetDimensions,
            targetFaceHeightRatio: settings.targetFaceHeightRatio
        )

        if let crop {
            appendLog("Face Crop Clip \(clipNumber): crop=\(crop.width)x\(crop.height)+\(crop.x)+\(crop.y), Gesicht \(Int(crop.faceHeightRatio * 100))%")
        } else {
            appendLog("Kein Gesicht in Clip \(clipNumber) erkannt. Render ohne Face-Normalisierung.")
        }

        return crop
    }

    private func runFFmpeg(_ ffmpeg: URL, arguments: [String], currentDirectory: URL? = nil) async throws -> ProcessResult {
        try await service.run(executable: ffmpeg, arguments: arguments, currentDirectory: currentDirectory) { [weak self] chunk in
            Task { @MainActor in
                self?.appendProcessOutput(chunk)
            }
        }
    }

    private func cleanupTransformFiles(in directory: URL) {
        guard let files = try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) else {
            return
        }

        for file in files where file.pathExtension.lowercased() == "trf" {
            try? FileManager.default.removeItem(at: file)
        }
    }

    private func writeRenderReport(
        settings: RenderSettings,
        outputFile: URL,
        targetDimensions: VideoDimensions,
        project: GrowthLapseProject
    ) -> URL {
        let reportURL = outputFile
            .deletingPathExtension()
            .appendingPathExtension("growthlapse-report.txt")
        let selectedClipLines = project.clips.map { clip in
            let age = clip.ageWeeks.map { ", \($0) Wochen" } ?? ""
            return String(
                format: "%03d. %@ - Dauer %@, Segment @ %@ für %@, %dx%d%@",
                clip.index,
                clip.displayName,
                formatSeconds(clip.duration),
                formatSeconds(clip.segmentStart),
                formatSeconds(clip.segmentLength),
                clip.width,
                clip.height,
                age
            )
        }

        let summary = [
            "GrowthLapse Render-Protokoll",
            "Datum: \(Date())",
            "Input-Ordner: \(settings.inputFolder?.path ?? "-")",
            "Output: \(outputFile.path)",
            "Input-Clips: \(project.clips.count)",
            "Zielauflösung: \(targetDimensions.width)x\(targetDimensions.height)",
            "Format: \(settings.outputFormat.rawValue)",
            "Encoder: \(settings.videoEncoder.rawValue)",
            "FPS: \(settings.fps)",
            "Sortierung: \(settings.sortMode.rawValue)",
            "Mehrfach-Versionen: \(settings.duplicateClipSelection.rawValue)",
            "Segmentlänge: \(settings.segmentLength)s",
            "Ziellänge pro Clip: \(settings.targetClipLength)s",
            "Übergangslänge: \(settings.transitionLength)s",
            "Audio entfernen: \(settings.removeAudio ? "ja" : "nein")",
            "Segment aus Mitte: \(settings.takeMiddleSegment ? "ja" : "nein")",
            "Clipnummern eingeblendet: \(settings.burnInClipNumbers ? "ja" : "nein")",
            "Bestes Segment automatisch: \(settings.bestSegmentDetectionEnabled ? "an, Gesicht/Augen sichtbar" : "aus")",
            "Stabilisierung: \(settings.stabilizationEnabled ? settings.stabilizationMethod.rawValue : "aus")",
            "Stabilisierungsstärke: \(settings.stabilizationStrength.rawValue)",
            "Stabilisierung Hintergrund-Anker: \(settings.stabilizationAnchorEnabled ? "an, X \(Int(settings.stabilizationAnchorX * 100))%, Y \(Int(settings.stabilizationAnchorY * 100))%, Bereich \(Int(settings.stabilizationAnchorSize * 100))%" : "aus")",
            "Face Size Normalization: \(settings.faceNormalizationEnabled ? "an, Ziel \(Int(settings.targetFaceHeightRatio * 100))%" : "aus")",
            "",
            "Ausgewählte Clip-Reihenfolge:",
            selectedClipLines.joined(separator: "\n"),
            "",
            "Log:",
            logs.joined(separator: "\n")
        ].joined(separator: "\n")

        do {
            try summary.write(to: reportURL, atomically: true, encoding: .utf8)
        } catch {
            appendLog("Warnung: Render-Protokoll konnte nicht geschrieben werden: \(error.localizedDescription)")
        }

        return reportURL
    }

    private func writePhotoMorphReport(
        settings: RenderSettings,
        outputFile: URL,
        targetDimensions: VideoDimensions,
        images: [URL],
        frameCount: Int
    ) -> URL {
        let reportURL = outputFile
            .deletingPathExtension()
            .appendingPathExtension("growthlapse-report.txt")
        let imageLines = images.enumerated().map { index, url in
            String(format: "%03d. %@", index + 1, url.lastPathComponent)
        }

        let summary = [
            "GrowthLapse Foto-Morphing Protokoll",
            "Datum: \(Date())",
            "Input-Ordner: \(settings.inputFolder?.path ?? "-")",
            "Output: \(outputFile.path)",
            "Modus: \(settings.renderMode.rawValue)",
            "Input-Bilder: \(images.count)",
            "Frames: \(frameCount)",
            "Zielauflösung: \(targetDimensions.width)x\(targetDimensions.height)",
            "FPS: \(settings.fps)",
            "Morph-Dauer pro Übergang: \(settings.targetClipLength)s",
            "Ziel-Gesichtshöhe: \(Int(settings.targetFaceHeightRatio * 100))%",
            "",
            "Bild-Reihenfolge:",
            imageLines.joined(separator: "\n"),
            "",
            "Log:",
            logs.joined(separator: "\n")
        ].joined(separator: "\n")

        do {
            try summary.write(to: reportURL, atomically: true, encoding: .utf8)
        } catch {
            appendLog("Warnung: Foto-Morphing-Protokoll konnte nicht geschrieben werden: \(error.localizedDescription)")
        }

        return reportURL
    }

    private func decimal(_ value: Double) -> String {
        String(format: "%.6f", locale: Locale(identifier: "en_US_POSIX"), value)
    }

    private func formatSeconds(_ value: Double) -> String {
        String(format: "%.2f s", locale: Locale(identifier: "de_DE"), value)
    }

    private func outputDimensions(for settings: RenderSettings, firstVideo: VideoFile) -> VideoDimensions {
        switch settings.outputFormat {
        case .landscape1920x1080:
            return VideoDimensions(width: 1920, height: 1080)
        case .portrait1080x1920:
            return VideoDimensions(width: 1080, height: 1920)
        case .keepOriginal:
            return VideoDimensions(width: evenDimension(firstVideo.width), height: evenDimension(firstVideo.height))
        }
    }

    private func sdrFilters() -> [String] {
        [
            "format=yuv420p",
            "colorspace=all=bt709:iall=bt709:fast=1",
            "setparams=color_primaries=bt709:color_trc=bt709:colorspace=bt709"
        ]
    }

    private func evenDimension(_ value: Int) -> Int {
        max(2, value - (value % 2))
    }

    private func videoEncoderArguments(_ settings: RenderSettings) -> [String] {
        switch settings.videoEncoder {
        case .x264:
            return [
                "-c:v", "libx264",
                "-preset", "medium",
                "-crf", "\(settings.crf)",
                "-pix_fmt", "yuv420p"
            ]
        case .videoToolbox:
            return [
                "-c:v", "h264_videotoolbox",
                "-b:v", "\(settings.hardwareBitrateMbps)M",
                "-profile:v", "high",
                "-allow_sw", "0",
                "-realtime", "0",
                "-power_efficient", "1",
                "-pix_fmt", "yuv420p"
            ]
        }
    }

    private func audioTempoChain(_ speedFactor: Double) -> String {
        var remaining = speedFactor
        var filters: [String] = []
        while remaining > 2.0 {
            filters.append("2.0")
            remaining /= 2.0
        }
        while remaining < 0.5 {
            filters.append("0.5")
            remaining /= 0.5
        }
        filters.append(decimal(remaining))
        return filters.joined(separator: ",atempo=")
    }

    private func appendLog(_ message: String) {
        let formatter = DateFormatter()
        formatter.dateFormat = "HH:mm:ss"
        logs.append("[\(formatter.string(from: Date()))] \(message)")
    }

    private func appendProcessOutput(_ chunk: String) {
        let cleaned = chunk
            .replacingOccurrences(of: "\r", with: "\n")
            .split(separator: "\n")
            .map(String.init)
            .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }

        guard !cleaned.isEmpty else {
            return
        }

        for line in cleaned.suffix(8) {
            logs.append(line)
        }

        if logs.count > 1_500 {
            logs.removeFirst(logs.count - 1_500)
        }
    }

    private func handleCancellation() {
        Task {
            await service.cancel()
        }
        state = .cancelled
        progressText = "Abgebrochen"
        appendLog("Rendern abgebrochen.")
        if let temporaryDirectory, FileManager.default.fileExists(atPath: temporaryDirectory.path) {
            cleanupTransformFiles(in: temporaryDirectory)
            try? FileManager.default.removeItem(at: temporaryDirectory)
        }
    }
}

private struct DetectedFaceSample {
    let rect: CGRect
    let imageWidth: Int
    let imageHeight: Int
}

private struct PreparedMorphImage {
    let url: URL
    let image: CGImage
    let points: [CGPoint]
    let usedLandmarks: Bool
}

private struct MorphTriangle: Hashable {
    let a: Int
    let b: Int
    let c: Int
}

private enum PhotoMorphRenderer {
    static func prepareImages(
        urls: [URL],
        targetDimensions: VideoDimensions,
        targetFaceHeightRatio: Double,
        progress: @escaping (Int, Int, String, Bool) -> Void
    ) async throws -> [PreparedMorphImage] {
        try await Task.detached(priority: .userInitiated) {
            var result: [PreparedMorphImage] = []
            for (index, url) in urls.enumerated() {
                try Task.checkCancellation()
                let source = try loadImage(url)
                let landmarks = try detectLandmarks(in: source)
                let normalized = normalizeImage(
                    source,
                    landmarks: landmarks,
                    targetDimensions: targetDimensions,
                    targetFaceHeightRatio: targetFaceHeightRatio
                )
                result.append(normalized)
                progress(index + 1, urls.count, url.lastPathComponent, normalized.usedLandmarks)
            }
            return result
        }.value
    }

    static func renderFrames(
        images: [PreparedMorphImage],
        outputDirectory: URL,
        fps: Int,
        secondsPerTransition: Double,
        progress: @escaping (Int, Int) -> Void
    ) async throws -> Int {
        try await Task.detached(priority: .userInitiated) {
            guard images.count >= 2 else {
                return 0
            }

            let framesPerTransition = max(2, Int((secondsPerTransition * Double(fps)).rounded()))
            let totalFrames = (images.count - 1) * framesPerTransition + 1
            var written = 0

            for pairIndex in 0..<(images.count - 1) {
                try Task.checkCancellation()
                let left = images[pairIndex]
                let right = images[pairIndex + 1]
                let triangles = triangulate(left.points)

                for frameInPair in 0..<framesPerTransition {
                    if pairIndex > 0, frameInPair == 0 {
                        continue
                    }
                    try Task.checkCancellation()
                    let t = Double(frameInPair) / Double(framesPerTransition)
                    let frame = try renderMorphFrame(left: left, right: right, triangles: triangles, t: t)
                    written += 1
                    try writePNG(frame, to: outputDirectory.appendingPathComponent(String(format: "frame_%06d.png", written)))
                    progress(written, totalFrames)
                }
            }

            let last = images[images.count - 1].image
            written += 1
            try writePNG(last, to: outputDirectory.appendingPathComponent(String(format: "frame_%06d.png", written)))
            progress(written, totalFrames)
            return written
        }.value
    }

    private static func loadImage(_ url: URL) throws -> CGImage {
        guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
              let image = CGImageSourceCreateImageAtIndex(source, 0, [
                kCGImageSourceShouldCache: true
              ] as CFDictionary) else {
            throw VideoProcessingError.invalidSettings("Bild konnte nicht geladen werden: \(url.lastPathComponent)")
        }
        return image
    }

    private static func detectLandmarks(in image: CGImage) throws -> [CGPoint]? {
        let request = VNDetectFaceLandmarksRequest()
        let handler = VNImageRequestHandler(cgImage: image, options: [:])
        try handler.perform([request])
        guard let face = request.results?.max(by: { faceScore($0) < faceScore($1) }),
              let landmarks = face.landmarks else {
            return nil
        }

        var points: [CGPoint] = []
        let regions: [VNFaceLandmarkRegion2D?] = [
            landmarks.faceContour,
            landmarks.leftEye,
            landmarks.rightEye,
            landmarks.leftEyebrow,
            landmarks.rightEyebrow,
            landmarks.nose,
            landmarks.noseCrest,
            landmarks.outerLips,
            landmarks.innerLips,
            landmarks.medianLine
        ]

        for region in regions.compactMap({ $0 }) {
            for index in 0..<region.pointCount {
                let point = region.normalizedPoints[index]
                points.append(pointInImage(point, face: face, image: image))
            }
        }

        guard points.count >= 8 else {
            return nil
        }
        return points
    }

    private static func normalizeImage(
        _ source: CGImage,
        landmarks: [CGPoint]?,
        targetDimensions: VideoDimensions,
        targetFaceHeightRatio: Double
    ) -> PreparedMorphImage {
        let targetSize = CGSize(width: targetDimensions.width, height: targetDimensions.height)
        let sourceSize = CGSize(width: source.width, height: source.height)

        let transform: CGAffineTransform
        if let landmarks, !landmarks.isEmpty {
            let bounds = landmarks.reduce(CGRect.null) { $0.union(CGRect(origin: $1, size: .zero)) }
            let desiredFaceHeight = targetSize.height * targetFaceHeightRatio
            let scale = desiredFaceHeight / max(1, bounds.height)
            let desiredCenter = CGPoint(x: targetSize.width * 0.5, y: targetSize.height * 0.43)
            transform = CGAffineTransform(translationX: desiredCenter.x, y: desiredCenter.y)
                .scaledBy(x: scale, y: scale)
                .translatedBy(x: -bounds.midX, y: -bounds.midY)
        } else {
            let scale = min(targetSize.width / sourceSize.width, targetSize.height / sourceSize.height)
            let x = (targetSize.width - sourceSize.width * scale) / 2
            let y = (targetSize.height - sourceSize.height * scale) / 2
            transform = CGAffineTransform(translationX: x, y: y).scaledBy(x: scale, y: scale)
        }

        let image = drawNormalized(source, targetSize: targetSize, transform: transform)
        let transformedLandmarks = landmarks?.map { $0.applying(transform) }
        let points = morphPoints(landmarks: transformedLandmarks, targetSize: targetSize)
        return PreparedMorphImage(url: URL(fileURLWithPath: ""), image: image, points: points, usedLandmarks: transformedLandmarks != nil)
    }

    private static func drawNormalized(_ source: CGImage, targetSize: CGSize, transform: CGAffineTransform) -> CGImage {
        let context = makeContext(width: Int(targetSize.width), height: Int(targetSize.height))
        context.setFillColor(NSColor.black.cgColor)
        context.fill(CGRect(origin: .zero, size: targetSize))
        context.saveGState()
        context.concatenate(transform)
        context.draw(source, in: CGRect(x: 0, y: 0, width: source.width, height: source.height))
        context.restoreGState()
        return context.makeImage()!
    }

    private static func morphPoints(landmarks: [CGPoint]?, targetSize: CGSize) -> [CGPoint] {
        let w = targetSize.width
        let h = targetSize.height
        let boundary = [
            CGPoint(x: 0, y: 0), CGPoint(x: w * 0.25, y: 0), CGPoint(x: w * 0.5, y: 0), CGPoint(x: w * 0.75, y: 0), CGPoint(x: w, y: 0),
            CGPoint(x: 0, y: h * 0.25), CGPoint(x: w, y: h * 0.25),
            CGPoint(x: 0, y: h * 0.5), CGPoint(x: w, y: h * 0.5),
            CGPoint(x: 0, y: h * 0.75), CGPoint(x: w, y: h * 0.75),
            CGPoint(x: 0, y: h), CGPoint(x: w * 0.25, y: h), CGPoint(x: w * 0.5, y: h), CGPoint(x: w * 0.75, y: h), CGPoint(x: w, y: h)
        ]

        guard let landmarks, !landmarks.isEmpty else {
            return boundary
        }

        let clipped = landmarks.map {
            CGPoint(x: min(max(0, $0.x), w), y: min(max(0, $0.y), h))
        }
        return clipped + boundary
    }

    private static func renderMorphFrame(
        left: PreparedMorphImage,
        right: PreparedMorphImage,
        triangles: [MorphTriangle],
        t: Double
    ) throws -> CGImage {
        let width = left.image.width
        let height = left.image.height
        let context = makeContext(width: width, height: height)
        context.setFillColor(NSColor.black.cgColor)
        context.fill(CGRect(x: 0, y: 0, width: width, height: height))

        let count = min(left.points.count, right.points.count)
        let targetPoints = (0..<count).map { index in
            interpolate(left.points[index], right.points[index], t)
        }

        for triangle in triangles where triangle.a < count && triangle.b < count && triangle.c < count {
            let target = [targetPoints[triangle.a], targetPoints[triangle.b], targetPoints[triangle.c]]
            let leftTriangle = [left.points[triangle.a], left.points[triangle.b], left.points[triangle.c]]
            let rightTriangle = [right.points[triangle.a], right.points[triangle.b], right.points[triangle.c]]
            drawWarped(left.image, sourceTriangle: leftTriangle, targetTriangle: target, alpha: CGFloat(1 - t), in: context)
            drawWarped(right.image, sourceTriangle: rightTriangle, targetTriangle: target, alpha: CGFloat(t), in: context)
        }

        guard let image = context.makeImage() else {
            throw VideoProcessingError.invalidSettings("Morph-Frame konnte nicht erzeugt werden.")
        }
        return image
    }

    private static func drawWarped(
        _ image: CGImage,
        sourceTriangle: [CGPoint],
        targetTriangle: [CGPoint],
        alpha: CGFloat,
        in context: CGContext
    ) {
        guard alpha > 0.001,
              let transform = affineTransform(from: sourceTriangle, to: targetTriangle) else {
            return
        }

        context.saveGState()
        let path = CGMutablePath()
        path.move(to: targetTriangle[0])
        path.addLine(to: targetTriangle[1])
        path.addLine(to: targetTriangle[2])
        path.closeSubpath()
        context.addPath(path)
        context.clip()
        context.setAlpha(alpha)
        context.concatenate(transform)
        context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height))
        context.restoreGState()
    }

    private static func triangulate(_ points: [CGPoint]) -> [MorphTriangle] {
        guard points.count >= 3 else {
            return []
        }

        var triangles = Set<MorphTriangle>()
        for a in 0..<(points.count - 2) {
            for b in (a + 1)..<(points.count - 1) {
                for c in (b + 1)..<points.count {
                    guard abs(area(points[a], points[b], points[c])) > 0.5 else {
                        continue
                    }
                    guard let circle = circumcircle(points[a], points[b], points[c]) else {
                        continue
                    }
                    var containsPoint = false
                    for index in 0..<points.count where index != a && index != b && index != c {
                        let distance = hypot(points[index].x - circle.center.x, points[index].y - circle.center.y)
                        if distance < circle.radius - 0.01 {
                            containsPoint = true
                            break
                        }
                    }
                    if !containsPoint {
                        triangles.insert(MorphTriangle(a: a, b: b, c: c))
                    }
                }
            }
        }

        return Array(triangles)
    }

    private static func area(_ a: CGPoint, _ b: CGPoint, _ c: CGPoint) -> CGFloat {
        (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)
    }

    private static func circumcircle(_ a: CGPoint, _ b: CGPoint, _ c: CGPoint) -> (center: CGPoint, radius: CGFloat)? {
        let d = 2 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y))
        guard abs(d) > 0.0001 else {
            return nil
        }

        let a2 = a.x * a.x + a.y * a.y
        let b2 = b.x * b.x + b.y * b.y
        let c2 = c.x * c.x + c.y * c.y
        let ux = (a2 * (b.y - c.y) + b2 * (c.y - a.y) + c2 * (a.y - b.y)) / d
        let uy = (a2 * (c.x - b.x) + b2 * (a.x - c.x) + c2 * (b.x - a.x)) / d
        let center = CGPoint(x: ux, y: uy)
        return (center, hypot(center.x - a.x, center.y - a.y))
    }

    private static func affineTransform(from source: [CGPoint], to target: [CGPoint]) -> CGAffineTransform? {
        let x1 = source[0].x, y1 = source[0].y
        let x2 = source[1].x, y2 = source[1].y
        let x3 = source[2].x, y3 = source[2].y
        let u1 = target[0].x, v1 = target[0].y
        let u2 = target[1].x, v2 = target[1].y
        let u3 = target[2].x, v3 = target[2].y

        let determinant = x1 * (y2 - y3) + x2 * (y3 - y1) + x3 * (y1 - y2)
        guard abs(determinant) > 0.0001 else {
            return nil
        }

        let a = (u1 * (y2 - y3) + u2 * (y3 - y1) + u3 * (y1 - y2)) / determinant
        let c = (u1 * (x3 - x2) + u2 * (x1 - x3) + u3 * (x2 - x1)) / determinant
        let tx = (u1 * (x2 * y3 - x3 * y2) + u2 * (x3 * y1 - x1 * y3) + u3 * (x1 * y2 - x2 * y1)) / determinant
        let b = (v1 * (y2 - y3) + v2 * (y3 - y1) + v3 * (y1 - y2)) / determinant
        let d = (v1 * (x3 - x2) + v2 * (x1 - x3) + v3 * (x2 - x1)) / determinant
        let ty = (v1 * (x2 * y3 - x3 * y2) + v2 * (x3 * y1 - x1 * y3) + v3 * (x1 * y2 - x2 * y1)) / determinant

        return CGAffineTransform(a: a, b: b, c: c, d: d, tx: tx, ty: ty)
    }

    private static func pointInImage(_ point: CGPoint, face: VNFaceObservation, image: CGImage) -> CGPoint {
        let x = (face.boundingBox.minX + point.x * face.boundingBox.width) * CGFloat(image.width)
        let y = (1 - (face.boundingBox.minY + point.y * face.boundingBox.height)) * CGFloat(image.height)
        return CGPoint(x: x, y: y)
    }

    private static func faceScore(_ face: VNFaceObservation) -> CGFloat {
        let area = face.boundingBox.width * face.boundingBox.height
        let center = hypot(face.boundingBox.midX - 0.5, face.boundingBox.midY - 0.5)
        return area - center * 0.08
    }

    private static func interpolate(_ left: CGPoint, _ right: CGPoint, _ t: Double) -> CGPoint {
        CGPoint(
            x: left.x + (right.x - left.x) * CGFloat(t),
            y: left.y + (right.y - left.y) * CGFloat(t)
        )
    }

    private static func makeContext(width: Int, height: Int) -> CGContext {
        let colorSpace = CGColorSpace(name: CGColorSpace.sRGB)!
        let context = CGContext(
            data: nil,
            width: width,
            height: height,
            bitsPerComponent: 8,
            bytesPerRow: width * 4,
            space: colorSpace,
            bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
        )!
        return context
    }

    private static func writePNG(_ image: CGImage, to url: URL) throws {
        guard let destination = CGImageDestinationCreateWithURL(url as CFURL, UTType.png.identifier as CFString, 1, nil) else {
            throw VideoProcessingError.invalidSettings("PNG konnte nicht geschrieben werden: \(url.lastPathComponent)")
        }
        CGImageDestinationAddImage(destination, image, nil)
        guard CGImageDestinationFinalize(destination) else {
            throw VideoProcessingError.invalidSettings("PNG konnte nicht finalisiert werden: \(url.lastPathComponent)")
        }
    }
}

private struct BestSegmentResult {
    let start: Double
    let score: Double
    let goodFrameCount: Int
    let totalFrameCount: Int
}

private struct EyeVisibilitySample {
    let time: Double
    let score: Double
    let isGood: Bool
}

private enum BestSegmentAnalyzer {
    static func detectBestSegment(
        source: URL,
        duration: Double,
        segmentLength: Double
    ) async throws -> BestSegmentResult? {
        try await Task.detached(priority: .userInitiated) {
            try Task.checkCancellation()

            let sampleInterval = duration <= 60 ? 0.5 : 1.0
            let samples = try analyzeSamples(
                source: source,
                duration: duration,
                interval: sampleInterval
            )
            let goodSamples = samples.filter(\.isGood)
            guard !samples.isEmpty, !goodSamples.isEmpty else {
                return nil
            }

            let maxStart = max(0, duration - segmentLength)
            var best: BestSegmentResult?
            var start = 0.0

            while start <= maxStart + 0.0001 {
                try Task.checkCancellation()
                let end = start + segmentLength
                let windowSamples = samples.filter { $0.time >= start && $0.time <= end }
                guard !windowSamples.isEmpty else {
                    start += sampleInterval
                    continue
                }

                let goodCount = windowSamples.filter(\.isGood).count
                let averageScore = windowSamples.map(\.score).reduce(0, +) / Double(windowSamples.count)
                let goodRatio = Double(goodCount) / Double(windowSamples.count)
                let longestRun = longestGoodRun(in: windowSamples)
                let windowScore = Double(goodCount) * 1_000
                    + Double(longestRun) * 180
                    + goodRatio * 120
                    + averageScore * 25

                let result = BestSegmentResult(
                    start: min(max(0, start), maxStart),
                    score: windowScore,
                    goodFrameCount: goodCount,
                    totalFrameCount: windowSamples.count
                )

                if let currentBest = best {
                    if result.score > currentBest.score {
                        best = result
                    }
                } else {
                    best = result
                }

                start += sampleInterval
            }

            guard let best, best.goodFrameCount > 0 else {
                return nil
            }
            return best
        }.value
    }

    private static func analyzeSamples(source: URL, duration: Double, interval: Double) throws -> [EyeVisibilitySample] {
        let asset = AVAsset(url: source)
        let generator = AVAssetImageGenerator(asset: asset)
        generator.appliesPreferredTrackTransform = true
        generator.maximumSize = CGSize(width: 960, height: 960)
        generator.requestedTimeToleranceBefore = CMTime(seconds: 0.12, preferredTimescale: 600)
        generator.requestedTimeToleranceAfter = CMTime(seconds: 0.12, preferredTimescale: 600)

        var samples: [EyeVisibilitySample] = []
        var time = 0.0
        while time <= duration {
            try Task.checkCancellation()
            let cmTime = CMTime(seconds: time, preferredTimescale: 600)
            let cgImage = try generator.copyCGImage(at: cmTime, actualTime: nil)
            let score = try eyeVisibilityScore(in: cgImage)
            samples.append(EyeVisibilitySample(time: time, score: score, isGood: score >= 0.62))
            time += interval
        }
        return samples
    }

    private static func eyeVisibilityScore(in image: CGImage) throws -> Double {
        let request = VNDetectFaceLandmarksRequest()
        let handler = VNImageRequestHandler(cgImage: image, options: [:])
        try handler.perform([request])

        guard let faces = request.results, !faces.isEmpty else {
            return 0
        }

        return faces.map(scoreFace).max() ?? 0
    }

    private static func scoreFace(_ face: VNFaceObservation) -> Double {
        let faceArea = Double(face.boundingBox.width * face.boundingBox.height)
        let faceSizeScore = clamp(faceArea / 0.035, min: 0, max: 1)
        let centerDistance = hypot(Double(face.boundingBox.midX - 0.5), Double(face.boundingBox.midY - 0.5))
        let centerScore = clamp(1 - centerDistance * 1.4, min: 0, max: 1)

        let leftEyePoints = face.landmarks?.leftEye?.pointCount ?? 0
        let rightEyePoints = face.landmarks?.rightEye?.pointCount ?? 0
        let hasBothEyes = leftEyePoints >= 4 && rightEyePoints >= 4
        let eyePointScore = clamp(Double(min(leftEyePoints, rightEyePoints)) / 8.0, min: 0, max: 1)

        let yawPenalty = min(abs(face.yaw?.doubleValue ?? 0) / 0.55, 1)
        let rollPenalty = min(abs(face.roll?.doubleValue ?? 0) / 0.45, 1)
        let poseScore = clamp(1 - yawPenalty * 0.85 - rollPenalty * 0.35, min: 0, max: 1)
        let qualityScore = face.faceCaptureQuality.map(Double.init) ?? 0.55

        guard hasBothEyes else {
            return faceSizeScore * 0.20 + centerScore * 0.08 + poseScore * 0.12
        }

        return eyePointScore * 0.46
            + poseScore * 0.22
            + faceSizeScore * 0.14
            + qualityScore * 0.12
            + centerScore * 0.06
    }

    private static func longestGoodRun(in samples: [EyeVisibilitySample]) -> Int {
        var best = 0
        var current = 0
        for sample in samples {
            if sample.isGood {
                current += 1
                best = max(best, current)
            } else {
                current = 0
            }
        }
        return best
    }

    private static func clamp(_ value: Double, min minimum: Double, max maximum: Double) -> Double {
        Swift.max(minimum, Swift.min(maximum, value))
    }
}

private enum FaceCropAnalyzer {
    static func detectCrop(
        source: URL,
        start: Double,
        duration: Double,
        targetDimensions: VideoDimensions,
        targetFaceHeightRatio: Double
    ) async throws -> FaceCrop? {
        try await Task.detached(priority: .userInitiated) {
            try Task.checkCancellation()

            let asset = AVAsset(url: source)
            let generator = AVAssetImageGenerator(asset: asset)
            generator.appliesPreferredTrackTransform = true
            generator.requestedTimeToleranceBefore = CMTime(seconds: 0.15, preferredTimescale: 600)
            generator.requestedTimeToleranceAfter = CMTime(seconds: 0.15, preferredTimescale: 600)

            let sampleTimes = sampleTimes(start: start, duration: duration)
            var samples: [DetectedFaceSample] = []

            for time in sampleTimes {
                try Task.checkCancellation()
                let cgImage = try generator.copyCGImage(at: time, actualTime: nil)
                let request = VNDetectFaceRectanglesRequest()
                let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
                try handler.perform([request])

                guard let observations = request.results, !observations.isEmpty else {
                    continue
                }

                let selected = selectFace(from: observations)
                samples.append(
                    DetectedFaceSample(
                        rect: selected.boundingBox,
                        imageWidth: cgImage.width,
                        imageHeight: cgImage.height
                    )
                )
            }

            guard !samples.isEmpty else {
                return nil
            }

            return cropFromMedianFace(
                samples,
                targetDimensions: targetDimensions,
                targetFaceHeightRatio: targetFaceHeightRatio
            )
        }.value
    }

    private static func sampleTimes(start: Double, duration: Double) -> [CMTime] {
        let count = max(3, min(9, Int(duration.rounded(.down)) + 1))
        guard count > 1 else {
            return [CMTime(seconds: start, preferredTimescale: 600)]
        }

        return (0..<count).map { index in
            let fraction = Double(index) / Double(count - 1)
            let seconds = start + max(0, duration * 0.08) + max(0.01, duration * 0.84) * fraction
            return CMTime(seconds: seconds, preferredTimescale: 600)
        }
    }

    private static func selectFace(from observations: [VNFaceObservation]) -> VNFaceObservation {
        observations.max { left, right in
            score(left.boundingBox) < score(right.boundingBox)
        } ?? observations[0]
    }

    private static func score(_ rect: CGRect) -> Double {
        let area = rect.width * rect.height
        let dx = rect.midX - 0.5
        let dy = rect.midY - 0.5
        let centralityPenalty = sqrt(dx * dx + dy * dy) * 0.18
        return area - centralityPenalty
    }

    private static func cropFromMedianFace(
        _ samples: [DetectedFaceSample],
        targetDimensions: VideoDimensions,
        targetFaceHeightRatio: Double
    ) -> FaceCrop? {
        let imageWidth = samples[samples.count / 2].imageWidth
        let imageHeight = samples[samples.count / 2].imageHeight

        let medianX = median(samples.map { Double($0.rect.origin.x) })
        let medianY = median(samples.map { Double($0.rect.origin.y) })
        let medianWidth = median(samples.map { Double($0.rect.size.width) })
        let medianHeight = median(samples.map { Double($0.rect.size.height) })

        guard medianWidth > 0, medianHeight > 0 else {
            return nil
        }

        let faceCenterX = (medianX + medianWidth / 2) * Double(imageWidth)
        let faceCenterYFromTop = (1 - (medianY + medianHeight / 2)) * Double(imageHeight)
        let faceHeightPixels = medianHeight * Double(imageHeight)
        guard faceHeightPixels > 4 else {
            return nil
        }

        let outputAspect = Double(targetDimensions.width) / Double(targetDimensions.height)
        var cropHeight = faceHeightPixels / targetFaceHeightRatio
        var cropWidth = cropHeight * outputAspect

        if cropWidth > Double(imageWidth) {
            cropWidth = Double(imageWidth)
            cropHeight = cropWidth / outputAspect
        }
        if cropHeight > Double(imageHeight) {
            cropHeight = Double(imageHeight)
            cropWidth = cropHeight * outputAspect
        }

        cropWidth = min(cropWidth, Double(imageWidth))
        cropHeight = min(cropHeight, Double(imageHeight))

        let desiredFaceCenterY = 0.43
        var cropX = faceCenterX - cropWidth / 2
        var cropY = faceCenterYFromTop - cropHeight * desiredFaceCenterY

        cropX = clamp(cropX, min: 0, max: Double(imageWidth) - cropWidth)
        cropY = clamp(cropY, min: 0, max: Double(imageHeight) - cropHeight)

        let evenX = even(Int(cropX.rounded(FloatingPointRoundingRule.down)))
        let evenY = even(Int(cropY.rounded(FloatingPointRoundingRule.down)))
        let evenWidth = max(2, even(Int(cropWidth.rounded(FloatingPointRoundingRule.down))))
        let evenHeight = max(2, even(Int(cropHeight.rounded(FloatingPointRoundingRule.down))))

        return FaceCrop(
            x: min(evenX, max(0, imageWidth - evenWidth)),
            y: min(evenY, max(0, imageHeight - evenHeight)),
            width: min(evenWidth, imageWidth),
            height: min(evenHeight, imageHeight),
            faceHeightRatio: faceHeightPixels / max(1, cropHeight)
        )
    }

    private static func median(_ values: [Double]) -> Double {
        guard !values.isEmpty else {
            return 0
        }
        let sorted = values.sorted()
        let middle = sorted.count / 2
        if sorted.count.isMultiple(of: 2) {
            return (sorted[middle - 1] + sorted[middle]) / 2
        }
        return sorted[middle]
    }

    private static func clamp(_ value: Double, min minimum: Double, max maximum: Double) -> Double {
        Swift.max(minimum, Swift.min(maximum, value))
    }

    private static func even(_ value: Int) -> Int {
        max(0, value - (value % 2))
    }
}
