Compare commits

...

5 Commits

Author SHA1 Message Date
Samuel Zielke 6a8d07c22b Versionsnummer im Bundle aus MARKETING_VERSION/CURRENT_PROJECT_VERSION
2 weeks ago
Samuel Zielke 9ba821ecb2 Version 1.0.0 (Build 2), README nachgezogen
2 weeks ago
Samuel Zielke c7fcd670cc Website: öffentliche Version mit FAQ
2 weeks ago
Samuel Zielke 4276d6feb3 App: natives Fenster, Ausschnitt-Editor, Hinweise statt stiller Abbrüche, Sparkle
2 weeks ago
Samuel Zielke 8547621cac Core: Ausschnitt verschiebbar, Positionsmarken, lockere Dateinamen, iPhone-Drehung
2 weeks ago

@ -41,12 +41,15 @@ Somos-Pendant-9x16-v3-DE.mp4
└──── Projekt ────┘ └Fmt┘└Ver┘└Spr┘
```
Für die Zielversion wird ausschließlich das Format-Segment ersetzt: `9x16` → `4x5`. Alle anderen Segmente bleiben zeichengleich erhalten.
Für die Zielversion wird ausschließlich das Format-Segment ersetzt: `9x16` → `4x5`. Alle anderen Zeichen bleiben gleich erhalten.
**Schreibweisen:** Das Format darf groß oder klein (`9x16`, `9X16`, `9×16`) und mit `-`, `_` oder Leerzeichen abgetrennt sein; die Trenner bleiben in der Zielfassung erhalten (`Kampagne_9X16_v2` → `Kampagne_4x5_v2`). Ein Rest hinter dem Format ist nicht nötig.
**Kein Format im Namen:** Die App zeigt das schon beim Reinziehen und bietet „4x5 anhängen“ an. Dann wird der ganze Dateistamm zum Projekt, und `4x5` wird mit dem Trenner angehängt, den der Name schon benutzt (`Sommerfest Teaser.mov` → `Sommerfest Teaser/Sommerfest Teaser 4x5.mp4`). Ohne diese Wahl wird die Datei übersprungen.
**Abbruchbedingungen** — die App verarbeitet nicht und meldet es sichtbar:
- Dateiname entspricht nicht dem Schema (weniger als vier Segmente)
- Format-Segment ist nicht `9x16`
- Kein `9x16` mit Projektnamen davor im Namen (Ausweg: „4x5 anhängen“, s. o.)
- Quelldatei hat kein 9:16-Seitenverhältnis
- Zieldatei existiert bereits (überschreiben nur nach Rückfrage)
@ -57,15 +60,17 @@ Der 4:5-Ausschnitt behält die **volle Breite** und beschneidet oben und unten:
```
Breite bleibt unverändert
Höhe = Breite × 5 ÷ 4 (auf gerade Zahl gerundet)
Anker vertikal zentriert (Offset später konfigurierbar)
Anker vertikal zentriert (pro Datei verschiebbar, s. u.)
```
**Ausschnitt verschieben:** In der Review-Ansicht lässt sich der 4:5-Rahmen mit der Maus nach oben oder unten ziehen. Für Clips mit Szenenwechsel gibt es **Positionsmarken**: an die Stelle spulen, *Marke setzen* (Taste `M`), Rahmen ziehen. Jede Marke gilt ab ihrem Frame bis zur nächsten, als harter Schnitt ohne Fahrt dazwischen. Tasten: Leertaste spielt ab, `←`/`→` springen um ein Frame. Der Plan gilt pro Datei und wird mit *Übernehmen* gesetzt; die Dateiliste zeigt angepasste Ausschnitte in Gold.
| Quelle | Ziel |
|---|---|
| 1080 × 1920 | 1080 × 1350 |
| 2160 × 3840 | 2160 × 2700 |
**Codec-Verhalten:** Der Quell-Codec wird erkannt und passend ausgespielt — ProRes bleibt ProRes, H.264 bleibt H.264 mit vergleichbarer Bitrate. Die Audiospur läuft per Passthrough durch, ohne Neukodierung. Framerate, Farbraum und Dauer bleiben unangetastet.
**Codec-Verhalten:** Die 4:5-Fassung ist immer MP4 mit H.264 (High Profile) und AAC 128 kbit/s, das Zielformat für Feed und Meta Ads, egal ob ProRes, H.264 oder HEVC reinkommt. Die Bitrate richtet sich nach Auflösung und Framerate. Framerate und Dauer bleiben unangetastet.
## Funktionsumfang
@ -82,11 +87,11 @@ Anker vertikal zentriert (Offset später konfigurierbar)
- [x] Review-Ansicht: 9:16-Master mit eingeblendetem 4:5-Rahmen zur Framing-Kontrolle, scrubbar
- [ ] Menu-Bar-Betrieb mit Drop-Target
- [ ] Vertikaler Crop-Offset pro Datei justierbar
- [ ] In-App-Updates über Sparkle
- [x] Vertikaler Crop-Offset pro Datei justierbar, mit Positionsmarken für Szenenwechsel
- [x] In-App-Updates über Sparkle (automatisch plus „Nach Updates suchen …“ im App-Menü)
- [ ] Batch-Verlauf der letzten Durchläufe
- [ ] Hilfe-Bereich: klickbare Kurzanleitung zur Bedienung der App
- [ ] Rechtlich erforderliche Angaben (Impressum o.ä.) einbinden — Absicherung, wo genau (in-App, Download-Seite) noch offen
- [x] Rechtliche Angaben: Website verlinkt Impressum & Datenschutz von zielke.studio (wie Patchcord)
## Tech-Stack
@ -112,6 +117,7 @@ Anker vertikal zentriert (Offset später konfigurierbar)
DropTwoCore Framework, komplett UI-frei
├── FileNameParser zerlegt und baut Dateinamen
├── VideoInspector liest Dimension, Codec, Framerate, Dauer
├── CropPlan Ausschnitt-Position(en) über die Laufzeit
├── CropEngine AVAsset → VideoComposition → Export
└── JobQueue async/await, 1–2 parallele Exports
@ -198,7 +204,14 @@ DMG und `appcast.xml` landen anschließend auf dem eigenen Server. Der Code lieg
Die Seite unter [droptwo.zielke.studio](https://droptwo.zielke.studio) liegt in `site/` und wird nicht von `publish-release.sh` hochgeladen. Die Versionsnummer liest sie selbst aus `appcast.xml`, nach einem Release muss sie also nicht angefasst werden. Nach Änderungen an der Seite:
```bash
rsync -a --chmod=F644 site/index.html site/favicon.svg samuelzielke@10.0.5.223:/var/www/updates/droptwo/
rsync -a --chmod=D755,F644 site/index.html site/favicon.svg site/fonts site/og samuelzielke@10.0.5.223:/var/www/updates/droptwo/
```
Schriften liegen selbst gehostet in `site/fonts/` (keine Anfragen an Google). Das Vorschaubild für geteilte Links `site/og/og-de.png` entsteht aus `og-de.html`:
```bash
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --hide-scrollbars \
--window-size=1200,630 --screenshot="$PWD/site/og/og-de.png" "file://$PWD/site/og/og-de.html"
```
Die Übersicht aller Apps und das Statistik-Dashboard liegen im Repo `zielke-apps`.
@ -222,7 +235,6 @@ Schritt 3 entscheidet über das Projekt. Liefert AVFoundation nicht die nötige
- Verhalten bei bereits vorhandenem Zielordner: ergänzen oder warnen
- Ob das 9:16-Original kopiert oder verschoben wird
- Bitrate-Strategie bei H.264: Quellbitrate übernehmen oder fester Qualitätsfaktor
- Vertikaler Offset — global, pro Projekt oder pro Datei
## Gestaltung

@ -3,11 +3,21 @@ import SwiftUI
@main
struct DropTwoApp: App {
@StateObject private var settings = AppSettings()
@StateObject private var updater = Updater()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(settings)
.tint(.gold)
}
.windowToolbarStyle(.unified)
.defaultSize(width: 820, height: 520)
.commands {
CommandGroup(after: .appInfo) {
Button("Nach Updates suchen …") { updater.checkForUpdates() }
.disabled(!updater.canCheckForUpdates)
}
}
}
}

@ -17,9 +17,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>LSUIElement</key>

@ -34,6 +34,20 @@ final class ConversionCoordinator: ObservableObject {
items.filter { $0.status.needsAttention }
}
/// Dateien ohne Format im Namen, für die noch keine Wahl getroffen ist.
var itemsMissingFormat: [DropItem] {
items.filter { $0.isMissingFormat }
}
var finishedCount: Int {
items.filter {
switch $0.status {
case .waiting, .running: return false
default: return true
}
}.count
}
/// README: "Dateiauswahl vorsortiert nach zuletzt geändert" — neueste zuerst.
func addFiles(_ urls: [URL]) {
let existing = Set(items.map(\.sourceURL))
@ -43,6 +57,28 @@ final class ConversionCoordinator: ObservableObject {
items.append(contentsOf: newURLs.map(DropItem.init))
}
func setCropPlan(_ plan: CropPlan, for itemID: UUID) {
guard let index = items.firstIndex(where: { $0.id == itemID }) else { return }
items[index].cropPlan = plan
}
/// "4x5" an den Namen anhängen, wo kein Format drinsteht. Setzt einen
/// schon abgelehnten Eintrag zurück auf "wartet".
func setAppendFormat(_ append: Bool, for itemIDs: [UUID]) {
for id in itemIDs {
guard let index = items.firstIndex(where: { $0.id == id }) else { continue }
items[index].appendFormatWhenMissing = append
if case .rejected(.formatSegmentMissing) = items[index].status {
items[index].status = .waiting
}
}
}
func remove(_ itemID: UUID) {
guard !isRunning else { return }
items.removeAll { $0.id == itemID }
}
func removeAll() {
guard !isRunning else { return }
items.removeAll()
@ -55,7 +91,11 @@ final class ConversionCoordinator: ObservableObject {
guard !isRunning, !items.isEmpty else { return }
let conflicts = items.compactMap { item -> OverwriteConflict? in
guard let destination = ConversionPipeline.previewDestination(sourceURL: item.sourceURL, outputRootDirectory: targetFolder),
guard let destination = ConversionPipeline.previewDestination(
sourceURL: item.sourceURL,
outputRootDirectory: targetFolder,
appendFormatWhenMissing: item.appendFormatWhenMissing
),
FileManager.default.fileExists(atPath: destination.path) else {
return nil
}
@ -95,7 +135,14 @@ final class ConversionCoordinator: ObservableObject {
private func runBatch(targetFolder: URL, overwrite: Bool, skipping skipIDs: Set<UUID>) {
let jobs = items
.filter { !skipIDs.contains($0.id) }
.map { JobQueue.Job(id: $0.id, sourceURL: $0.sourceURL) }
.map {
JobQueue.Job(
id: $0.id,
sourceURL: $0.sourceURL,
cropPlan: $0.cropPlan,
appendFormatWhenMissing: $0.appendFormatWhenMissing
)
}
guard !jobs.isEmpty else { return }
isRunning = true

@ -8,6 +8,10 @@ struct DropItem: Identifiable {
let id: UUID
let sourceURL: URL
var status: Status
/// Ausschnitt für diese Datei, in der Review-Ansicht eingestellt.
var cropPlan = CropPlan()
/// Nutzer hat bei fehlendem Format im Namen "4x5 anhängen" gewählt.
var appendFormatWhenMissing = false
enum Status {
case waiting
@ -39,4 +43,27 @@ struct DropItem: Identifiable {
}
var fileName: String { sourceURL.lastPathComponent }
/// Was der Dateiname vor dem Lauf schon verrät — so sieht man Probleme
/// beim Reinziehen, nicht erst nach dem Start.
var nameOutcome: FileNameParseResult.Outcome {
FileNameParser.parse(fileName).outcome
}
/// true = im Namen steht kein Format, und der Nutzer hat noch nicht
/// entschieden, "4x5" anzuhängen.
var isMissingFormat: Bool {
guard !appendFormatWhenMissing, case .rejected(.formatSegmentMissing) = nameOutcome else { return false }
return true
}
/// Name der 4:5-Fassung, wie er entstehen wird. nil = wird so nicht verarbeitet.
var plannedTargetName: String? {
let root = URL(fileURLWithPath: "/", isDirectory: true)
return ConversionPipeline.previewDestination(
sourceURL: sourceURL,
outputRootDirectory: root,
appendFormatWhenMissing: appendFormatWhenMissing
)?.lastPathComponent
}
}

@ -0,0 +1,33 @@
import AppKit
import UniformTypeIdentifiers
enum FileImport {
/// Dateiauswahl für Videos, startet im Quellordner. Leer = abgebrochen.
@MainActor
static func chooseVideos(startingAt sourceFolder: URL?) -> [URL] {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = true
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.allowedContentTypes = [.movie, .quickTimeMovie, .mpeg4Movie]
if let sourceFolder { panel.directoryURL = sourceFolder }
guard panel.runModal() == .OK else { return [] }
return panel.urls
}
/// Liest die Datei-URLs aus einem Drop und liefert sie gesammelt auf dem Main-Thread.
static func loadURLs(from providers: [NSItemProvider], completion: @escaping ([URL]) -> Void) {
let group = DispatchGroup()
var urls: [URL] = []
for provider in providers {
group.enter()
_ = provider.loadObject(ofClass: URL.self) { url, _ in
if let url {
DispatchQueue.main.async { urls.append(url) }
}
group.leave()
}
}
group.notify(queue: .main) { completion(urls) }
}
}

@ -0,0 +1,17 @@
import AppKit
enum FolderPicker {
/// Ordnerauswahl, startet bei `current`. nil = abgebrochen.
@MainActor
static func choose(startingAt current: URL?, prompt: String = String(localized: "Wählen")) -> URL? {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.canCreateDirectories = true
panel.allowsMultipleSelection = false
panel.prompt = prompt
if let current { panel.directoryURL = current }
guard panel.runModal() == .OK else { return nil }
return panel.url
}
}

@ -0,0 +1,39 @@
import AppKit
import SwiftUI
/// Gestaltung: ein normales Mac-Fenster in Systemfarben. Gold ist der einzige
/// Akzent (README, "Gestaltung") und markiert nur zwei Dinge: die Hauptaktion
/// und den Ausschnitt.
extension Color {
/// #C8A96A im Dunkeln; im Hellen dunkler, damit Text darauf lesbar bleibt.
static let gold = Color(nsColor: NSColor(name: nil) { appearance in
appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
? NSColor(srgbRed: 0.784, green: 0.663, blue: 0.416, alpha: 1)
: NSColor(srgbRed: 0.604, green: 0.482, blue: 0.235, alpha: 1)
})
/// Schrift auf Gold: dunkel auf hellem Gold, weiß auf dunklem Gold.
static let onGold = Color(nsColor: NSColor(name: nil) { appearance in
appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
? NSColor(srgbRed: 0.043, green: 0.043, blue: 0.047, alpha: 1)
: .white
})
static let attention = Color(nsColor: .systemOrange)
}
/// Die eine Hauptaktion eines Fensters (Start, Übernehmen).
struct ProminentButtonStyle: ButtonStyle {
@Environment(\.isEnabled) private var isEnabled
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(isEnabled ? Color.onGold : Color.secondary)
.padding(.horizontal, 14)
.padding(.vertical, 4)
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(isEnabled ? Color.gold.opacity(configuration.isPressed ? 0.8 : 1) : Color.secondary.opacity(0.15))
)
.contentShape(Rectangle())
}
}

@ -0,0 +1,21 @@
import Combine
import Sparkle
/// Sparkle-Updates gegen `SUFeedURL` aus der Info.plist: automatische Prüfung
/// im Hintergrund plus „Nach Updates suchen …“ im App-Menü.
final class Updater: ObservableObject {
@Published private(set) var canCheckForUpdates = false
private let controller = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil)
private var cancellable: AnyCancellable?
init() {
cancellable = controller.updater.publisher(for: \.canCheckForUpdates)
.receive(on: DispatchQueue.main)
.sink { [weak self] in self?.canCheckForUpdates = $0 }
}
func checkForUpdates() {
controller.checkForUpdates(nil)
}
}

@ -4,43 +4,43 @@ struct ContentView: View {
@EnvironmentObject private var settings: AppSettings
@StateObject private var coordinator = ConversionCoordinator()
@State private var showSettings = false
@State private var isDropTargeted = false
var body: some View {
VStack(spacing: 16) {
DropZoneView()
if !coordinator.items.isEmpty {
VStack(spacing: 0) {
if coordinator.items.isEmpty {
emptyState
} else {
FileListView()
if !coordinator.problemItems.isEmpty {
Text("\(coordinator.problemItems.count) Datei(en) brauchen Aufmerksamkeit.")
.font(.footnote)
.foregroundStyle(.orange)
}
HStack {
ProgressView(value: coordinator.overallProgress)
Button("Leeren") { coordinator.removeAll() }
.disabled(coordinator.isRunning)
}
}
Divider()
HStack {
Button("Einstellungen…") { showSettings = true }
Spacer()
Button("Start") {
guard let target = settings.targetFolder else { return }
coordinator.start(targetFolder: target)
}
.keyboardShortcut(.defaultAction)
.disabled(coordinator.items.isEmpty || coordinator.isRunning || settings.targetFolder == nil)
statusBar
}
.frame(minWidth: 680, minHeight: 420)
.navigationTitle("drop.two")
.toolbar { toolbarContent }
.overlay {
if isDropTargeted {
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color.gold, lineWidth: 2)
.padding(4)
.allowsHitTesting(false)
}
}
.padding(20)
.frame(minWidth: 520, minHeight: 460)
.onDrop(of: [.fileURL], isTargeted: $isDropTargeted) { providers in
FileImport.loadURLs(from: providers) { coordinator.addFiles($0) }
return true
}
.environmentObject(coordinator)
#if DEBUG
// Nur zum Ausprobieren: DROPTWO_DEBUG_FILES="/a.mp4:/b.mov" lädt
// Dateien beim Start, ohne Dialog.
.onAppear {
if let paths = ProcessInfo.processInfo.environment["DROPTWO_DEBUG_FILES"] {
coordinator.addFiles(paths.split(separator: ":").map { URL(fileURLWithPath: String($0)) })
}
}
#endif
.sheet(isPresented: $showSettings) {
SettingsView()
}
@ -61,6 +61,131 @@ struct ContentView: View {
}
}
// MARK: - Toolbar
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItemGroup(placement: .primaryAction) {
Button(action: addFiles) {
Label("Dateien hinzufügen", systemImage: "plus")
}
.help("Videos hinzufügen (⌘O)")
.keyboardShortcut("o")
Button(action: chooseTargetFolder) {
Label {
Text(settings.targetFolder?.lastPathComponent ?? String(localized: "Zielordner wählen …"))
} icon: {
Image(systemName: "folder")
}
.labelStyle(.titleAndIcon)
}
.help(settings.targetFolder.map { "Zielordner: \($0.path)" } ?? String(localized: "Hier entsteht pro Projekt ein Unterordner mit beiden Fassungen."))
Button("Start", action: start)
.buttonStyle(ProminentButtonStyle())
.keyboardShortcut(.defaultAction)
.disabled(coordinator.items.isEmpty || coordinator.isRunning)
}
ToolbarItem(placement: .automatic) {
Button { showSettings = true } label: {
Label("Einstellungen", systemImage: "gearshape")
}
.help("Einstellungen")
}
}
// MARK: - Leerer Zustand
/// Kein Symbolbild, keine Box: nur ein Satz. Das ganze Fenster nimmt Dateien an.
private var emptyState: some View {
VStack(spacing: 10) {
Text("9:16-Videos hierher ziehen.")
.font(.system(size: 22, weight: .medium))
HStack(spacing: 4) {
Text("Oder")
Button("Dateien wählen …", action: addFiles)
.buttonStyle(.link)
.tint(.gold)
Text("· Daraus wird je eine 4:5-Fassung im Projektordner.")
}
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - Statusleiste
private var statusBar: some View {
HStack(spacing: 6) {
if coordinator.items.isEmpty {
Text(settings.targetFolder.map { "Ziel: \($0.path)" } ?? String(localized: "Noch kein Zielordner gewählt."))
.foregroundStyle(settings.targetFolder == nil ? Color.attention : .secondary)
} else {
Text(summary)
.foregroundStyle(.secondary)
if settings.targetFolder == nil {
Text("· Kein Zielordner – wird beim Start abgefragt.")
.foregroundStyle(Color.attention)
}
let missing = coordinator.itemsMissingFormat
if missing.count > 1 && !coordinator.isRunning {
Text("·").foregroundStyle(.secondary)
Button("Bei allen \(missing.count) ohne Format „4x5“ anhängen") {
coordinator.setAppendFormat(true, for: missing.map(\.id))
}
.buttonStyle(.link)
.tint(.gold)
}
}
Spacer()
if !coordinator.items.isEmpty {
Button("Liste leeren") { coordinator.removeAll() }
.buttonStyle(.link)
.foregroundStyle(.secondary)
.disabled(coordinator.isRunning)
}
}
.font(.system(size: 11))
.lineLimit(1)
.padding(.horizontal, 16)
.frame(height: 28)
.background(.bar)
}
private var summary: String {
let total = coordinator.items.count
let done = coordinator.finishedCount
let files = total == 1 ? String(localized: "1 Datei") : String(localized: "\(total) Dateien")
if coordinator.isRunning {
return String(localized: "\(files) · \(done) fertig · rendert …")
}
return done > 0
? String(localized: "\(files) · \(done) fertig")
: files
}
// MARK: - Aktionen
private func addFiles() {
coordinator.addFiles(FileImport.chooseVideos(startingAt: settings.sourceFolder))
}
private func chooseTargetFolder() {
if let url = FolderPicker.choose(startingAt: settings.targetFolder, prompt: String(localized: "Als Zielordner wählen")) {
settings.setTargetFolder(url)
}
}
private func start() {
if settings.targetFolder == nil {
chooseTargetFolder()
}
guard let target = settings.targetFolder else { return }
coordinator.start(targetFolder: target)
}
private var conflictMessage: String {
let names = coordinator.pendingConflicts.map(\.fileName).joined(separator: ", ")
return "Für folgende Datei(en) existiert die Zieldatei schon: \(names). Ersetzen oder überspringen?"

@ -1,56 +0,0 @@
import SwiftUI
import UniformTypeIdentifiers
struct DropZoneView: View {
@EnvironmentObject private var settings: AppSettings
@EnvironmentObject private var coordinator: ConversionCoordinator
@State private var isTargeted = false
var body: some View {
VStack(spacing: 12) {
Image(systemName: "arrow.down.doc")
.font(.system(size: 36))
.foregroundStyle(.secondary)
Text("9:16-Dateien hierher ziehen")
.font(.headline)
Button("Datei(en) wählen…", action: chooseFiles)
}
.frame(maxWidth: .infinity, minHeight: 140)
.background(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [6]))
.foregroundStyle(isTargeted ? Color.accentColor : Color.secondary.opacity(0.35))
)
.onDrop(of: [.fileURL], isTargeted: $isTargeted, perform: handleDrop)
}
private func chooseFiles() {
let panel = NSOpenPanel()
panel.allowsMultipleSelection = true
panel.canChooseDirectories = false
panel.canChooseFiles = true
panel.allowedContentTypes = [.movie, .quickTimeMovie, .mpeg4Movie]
if let sourceFolder = settings.sourceFolder {
panel.directoryURL = sourceFolder
}
guard panel.runModal() == .OK else { return }
coordinator.addFiles(panel.urls)
}
private func handleDrop(providers: [NSItemProvider]) -> Bool {
guard !providers.isEmpty else { return false }
let group = DispatchGroup()
var urls: [URL] = []
for provider in providers {
group.enter()
_ = provider.loadObject(ofClass: URL.self) { url, _ in
if let url { urls.append(url) }
group.leave()
}
}
group.notify(queue: .main) {
coordinator.addFiles(urls)
}
return true
}
}

@ -1,81 +1,256 @@
import AVFoundation
import DropTwoCore
import SwiftUI
/// Die Dateien als schlichte Tabelle: Standbild mit Ausschnitt · Datei ·
/// wird zu · Status. Klick aufs Standbild öffnet die Ausschnitt-Ansicht.
struct FileListView: View {
@EnvironmentObject private var coordinator: ConversionCoordinator
@State private var previewTarget: PreviewTarget?
var body: some View {
List(coordinator.items) { item in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(item.fileName)
.lineLimit(1)
.truncationMode(.middle)
statusText(for: item.status)
.font(.caption)
.foregroundStyle(item.status.needsAttention ? Color.orange : .secondary)
VStack(spacing: 0) {
header
Divider()
ScrollView {
LazyVStack(spacing: 0) {
ForEach(coordinator.items) { item in
FileRow(item: item) {
previewTarget = PreviewTarget(itemID: item.id)
}
Divider().padding(.leading, 16)
}
}
Spacer()
Button {
previewTarget = PreviewTarget(url: item.sourceURL)
} label: {
Image(systemName: "eye")
}
.buttonStyle(.borderless)
.help("Vorschau mit 4:5-Rahmen zur Framing-Kontrolle")
statusIndicator(for: item.status)
}
.padding(.vertical, 2)
}
#if DEBUG
.onAppear {
if ProcessInfo.processInfo.environment["DROPTWO_DEBUG_REVIEW"] != nil, let first = coordinator.items.last {
previewTarget = PreviewTarget(itemID: first.id)
}
}
#endif
.sheet(item: $previewTarget) { target in
ReviewSheet(sourceURL: target.url)
if let item = coordinator.items.first(where: { $0.id == target.itemID }) {
ReviewSheet(sourceURL: item.sourceURL, initialPlan: item.cropPlan) { plan in
coordinator.setCropPlan(plan, for: item.id)
}
}
}
}
private var header: some View {
HStack(spacing: FileRow.columnSpacing) {
Color.clear.frame(width: FileRow.thumbnailWidth)
Text("Datei").frame(maxWidth: .infinity, alignment: .leading)
Text("wird zu").frame(maxWidth: .infinity, alignment: .leading)
Text("Ausschnitt").frame(width: FileRow.framingWidth, alignment: .leading)
Text("Status").frame(width: FileRow.statusWidth, alignment: .leading)
}
.font(.system(size: 11))
.foregroundStyle(.secondary)
.padding(.horizontal, 16)
.frame(height: 26)
}
private struct PreviewTarget: Identifiable {
let url: URL
var id: URL { url }
let itemID: UUID
var id: UUID { itemID }
}
}
private struct FileRow: View {
static let thumbnailWidth: CGFloat = 27
static let statusWidth: CGFloat = 170
static let framingWidth: CGFloat = 130
static let columnSpacing: CGFloat = 16
let item: DropItem
let onReview: () -> Void
@EnvironmentObject private var coordinator: ConversionCoordinator
@State private var thumbnail: CGImage?
var body: some View {
HStack(spacing: Self.columnSpacing) {
Button(action: onReview) {
CropThumbnail(image: thumbnail, offset: item.cropPlan.keyframes[0].offset, dimmed: item.isMissingFormat)
}
.buttonStyle(.plain)
.help("Ausschnitt prüfen und verschieben")
Text(item.fileName)
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: .infinity, alignment: .leading)
targetColumn
.lineLimit(1)
.truncationMode(.middle)
.frame(maxWidth: .infinity, alignment: .leading)
framingButton
.frame(width: Self.framingWidth, alignment: .leading)
statusColumn
.frame(width: Self.statusWidth, alignment: .leading)
}
.font(.system(size: 13))
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(item.isMissingFormat ? Color.attention.opacity(0.07) : Color.clear)
.contentShape(Rectangle())
.contextMenu { contextMenu }
.task(id: item.sourceURL) { thumbnail = await Self.makeThumbnail(for: item.sourceURL) }
}
// MARK: - Spalten
@ViewBuilder
private func statusText(for status: DropItem.Status) -> some View {
switch status {
private var targetColumn: some View {
if let target = item.plannedTargetName {
Text(target).foregroundStyle(.secondary)
} else {
switch item.nameOutcome {
case .skipped:
Text("ist schon 4:5").foregroundStyle(.secondary)
case .rejected(.formatSegmentMissing):
Text("kein Format im Namen").foregroundStyle(Color.attention)
case .rejected(.finderDuplicate):
Text("Finder-Duplikat").foregroundStyle(Color.attention)
case .parsed:
Text("–").foregroundStyle(.secondary)
}
}
}
@ViewBuilder
private var statusColumn: some View {
switch item.status {
case .waiting:
Text("Wartet …")
if item.isMissingFormat && !coordinator.isRunning {
Button("„4x5“ anhängen") { coordinator.setAppendFormat(true, for: [item.id]) }
.controlSize(.small)
.help(appendHelp)
} else if item.plannedTargetName == nil {
Text("wird übersprungen").foregroundStyle(.secondary)
} else {
Text("wartet").foregroundStyle(.secondary)
}
case .running(let progress):
Text("\(Int(progress * 100)) %")
HStack(spacing: 8) {
Text("rendert \(Int(progress * 100)) %")
.monospacedDigit()
.foregroundStyle(Color.gold)
ProgressView(value: progress)
.progressViewStyle(.linear)
.tint(.gold)
.frame(width: 60)
}
case .succeeded(let destination):
HStack(spacing: 4) {
Text("fertig ·")
Button("im Finder") { NSWorkspace.shared.activateFileViewerSelecting([destination]) }
.buttonStyle(.link)
.tint(.gold)
}
case .skipped(let reason):
Text(reason.errorDescription ?? "Übersprungen")
Text(reason.errorDescription ?? "übersprungen").foregroundStyle(.secondary).help(reason.errorDescription ?? "")
case .skippedByUser(let reason):
Text("übersprungen").foregroundStyle(.secondary).help(reason)
case .rejected(let reason):
Text(reason.errorDescription ?? "Abgelehnt")
case .succeeded(let destination):
Text("Fertig — \(destination.lastPathComponent)")
Text(reason.errorDescription ?? "abgelehnt").foregroundStyle(Color.attention).help(reason.errorDescription ?? "")
case .failed(let message):
Text(message)
case .skippedByUser(let reason):
Text(reason)
Text(message).foregroundStyle(Color.attention).help(message)
}
}
@ViewBuilder
private func statusIndicator(for status: DropItem.Status) -> some View {
switch status {
case .waiting:
Image(systemName: "clock")
.foregroundStyle(.secondary)
case .running(let progress):
ProgressView(value: progress)
.frame(width: 60)
case .skipped, .skippedByUser:
Image(systemName: "arrow.uturn.forward.circle")
.foregroundStyle(.secondary)
case .rejected, .failed:
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
case .succeeded:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
private var contextMenu: some View {
Button("Ausschnitt anpassen …", action: onReview)
if item.isMissingFormat {
Button("„4x5“ an den Namen anhängen") { coordinator.setAppendFormat(true, for: [item.id]) }
}
if case .succeeded(let destination) = item.status {
Button("Im Finder zeigen") { NSWorkspace.shared.activateFileViewerSelecting([destination]) }
}
Divider()
Button("Aus der Liste entfernen") { coordinator.remove(item.id) }
.disabled(coordinator.isRunning)
}
private var appendHelp: String {
guard let preview = FileNameParser.appendingTargetFormat(to: item.fileName) else { return "" }
return "Ergibt „\(preview.projectName)/\(ConversionPipeline.mp4Name(preview.targetFileName))“"
}
/// Zeigt, wie der Ausschnitt steht, und öffnet ihn — derselbe Weg wie
/// der Klick aufs Standbild, nur als sichtbarer Knopf.
private var framingButton: some View {
Button(action: onReview) {
Label(framingText, systemImage: "crop")
}
.controlSize(.small)
.tint(item.cropPlan.isDefault ? nil : .gold)
.foregroundStyle(item.cropPlan.isDefault ? Color.primary : Color.gold)
.help("Ausschnitt prüfen und verschieben")
}
private var framingText: String {
let plan = item.cropPlan
if plan.keyframes.count > 1 {
return String(localized: "\(plan.keyframes.count) Positionen")
}
switch plan.keyframes[0].offset {
case 0: return String(localized: "mittig")
case ..<0: return String(localized: "weiter oben")
default: return String(localized: "weiter unten")
}
}
private static func makeThumbnail(for url: URL) async -> CGImage? {
let generator = AVAssetImageGenerator(asset: AVURLAsset(url: url))
generator.appliesPreferredTrackTransform = true
generator.maximumSize = CGSize(width: 120, height: 120)
let time = CMTime(seconds: 0.5, preferredTimescale: 600)
return await withCheckedContinuation { continuation in
generator.generateCGImagesAsynchronously(forTimes: [NSValue(time: time)]) { _, image, _, _, _ in
continuation.resume(returning: image)
}
}
}
}
/// Mini-Standbild im 9:16-Format mit dem 4:5-Rahmen an seiner Position.
private struct CropThumbnail: View {
let image: CGImage?
let offset: CGFloat
var dimmed = false
private let size = CGSize(width: 27, height: 48)
var body: some View {
let reference = CGSize(width: 1080, height: 1920)
let rect = CropEngine.cropRect(forSourceSize: reference, offset: offset)
let scale = size.height / reference.height
ZStack(alignment: .top) {
Rectangle().fill(Color.secondary.opacity(0.2))
if let image {
Image(decorative: image, scale: 1)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: size.width, height: size.height)
}
// Abgedunkelt, was wegfällt; Goldrahmen, was bleibt.
VStack(spacing: 0) {
Rectangle().fill(Color.black.opacity(0.5)).frame(height: rect.minY * scale)
Rectangle()
.strokeBorder(Color.gold.opacity(dimmed ? 0.5 : 1), lineWidth: 1.5)
.frame(height: rect.height * scale)
Rectangle().fill(Color.black.opacity(0.5))
}
}
.frame(width: size.width, height: size.height)
.clipShape(RoundedRectangle(cornerRadius: 2))
}
}

@ -11,11 +11,12 @@ import SwiftUI
/// direkt per `NSViewRepresentable` einzubinden umgeht den kaputten Codepfad.
struct PlayerView: NSViewRepresentable {
let player: AVPlayer
var controlsStyle: AVPlayerViewControlsStyle = .inline
func makeNSView(context: Context) -> AVPlayerView {
let view = AVPlayerView()
view.player = player
view.controlsStyle = .inline
view.controlsStyle = controlsStyle
view.showsFullScreenToggleButton = false
return view
}

@ -2,38 +2,84 @@ import AVKit
import DropTwoCore
import SwiftUI
/// Schritt 5: 9:16-Master mit eingeblendetem 4:5-Rahmen zur Framing-Kontrolle.
/// Scrubbar über die nativen Kontrollen von `PlayerView` (AppKits
/// `AVPlayerView`, nicht AVKits SwiftUI-`VideoPlayer` — siehe dort für den
/// Grund). Der Rahmen nutzt exakt dieselbe Geometrie wie
/// `CropEngine.targetSize` — was hier zu sehen ist, ist tatsächlich das, was
/// beim Crop rauskommt. Rein zur Kontrolle: der Offset ist hier noch nicht
/// verstellbar (README, "Danach"-Liste).
/// 9:16-Master mit eingeblendetem 4:5-Rahmen — zur Framing-Kontrolle und zum
/// Verschieben des Ausschnitts. Der Rahmen lässt sich mit der Maus nach oben
/// oder unten ziehen. Für Clips mit Szenenwechsel setzt man Positionsmarken:
/// ab jeder Marke gilt eine eigene Rahmenposition (`CropPlan`).
///
/// Rahmen und Render nutzen dieselbe Geometrie (`CropEngine.cropRect`) — was
/// hier zu sehen ist, ist tatsächlich das, was beim Crop rauskommt.
///
/// Eigene Transportleiste statt der nativen `AVPlayerView`-Kontrollen: die
/// liegen im Bild und würden mit dem Ziehen des Rahmens kollidieren, und
/// die Marken müssen auf der Zeitleiste sichtbar sein.
struct ReviewSheet: View {
let sourceURL: URL
let onApply: (CropPlan) -> Void
@Environment(\.dismiss) private var dismiss
@State private var plan: CropPlan
@State private var player: AVPlayer?
@State private var videoSize: CGSize?
@State private var info: VideoInfo?
@State private var loadError: String?
@State private var currentTime: Double = 0
@State private var isPlaying = false
@State private var timeObserver: Any?
/// Offset beim Beginn eines Rahmen-Drags; nil = gerade kein Drag.
@State private var dragStartOffset: CGFloat?
/// Marke unter dem Mauszeiger auf der Zeitleiste.
@State private var hoveredKeyframeID: CropPlan.Keyframe.ID?
init(sourceURL: URL, initialPlan: CropPlan, onApply: @escaping (CropPlan) -> Void) {
self.sourceURL = sourceURL
self.onApply = onApply
_plan = State(initialValue: initialPlan)
}
var body: some View {
VStack(spacing: 12) {
HStack {
HStack(spacing: 8) {
Image(systemName: "crop")
.foregroundStyle(Color.gold)
Text(sourceURL.lastPathComponent)
.font(.headline)
.font(.system(size: 14, weight: .semibold))
.lineLimit(1)
.truncationMode(.middle)
Spacer()
Button("Schließen") { dismiss() }
}
.frame(maxWidth: .infinity, alignment: .leading)
content
if let info, player != nil {
transportBar(info: info)
markerBar
}
HStack(spacing: 10) {
Button("Zurücksetzen") { plan = CropPlan() }
.disabled(plan.isDefault)
.help("Mittig, alle Marken entfernen")
Spacer()
Button("Abbrechen", role: .cancel) { dismiss() }
.keyboardShortcut(.cancelAction)
Button("Übernehmen") {
onApply(plan)
dismiss()
}
.buttonStyle(ProminentButtonStyle())
.keyboardShortcut(.defaultAction)
.disabled(info == nil)
}
.padding(.top, 4)
}
.padding(16)
.frame(minWidth: 420, minHeight: 520)
.padding(18)
.frame(minWidth: 500, idealWidth: 540, minHeight: 700, idealHeight: 800)
.task { await load() }
.onDisappear(perform: tearDown)
}
// MARK: - Vorschau
@ViewBuilder
private var content: some View {
if let loadError {
@ -44,50 +90,394 @@ struct ReviewSheet: View {
}
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let player, let videoSize, videoSize.height > 0 {
} else if let player, let info, info.pixelSize.height > 0 {
GeometryReader { proxy in
ZStack {
PlayerView(player: player)
cropOverlay(sourceSize: videoSize, viewSize: proxy.size)
PlayerView(player: player, controlsStyle: .none)
cropOverlay(sourceSize: info.pixelSize, viewSize: proxy.size)
Color.clear
.contentShape(Rectangle())
.gesture(frameDrag(sourceSize: info.pixelSize, viewSize: proxy.size))
.onTapGesture(count: 2, perform: centerFrame)
.onHover { inside in
if inside { NSCursor.openHand.push() } else { NSCursor.pop() }
}
}
}
.aspectRatio(videoSize.width / videoSize.height, contentMode: .fit)
.aspectRatio(info.pixelSize.width / info.pixelSize.height, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.strokeBorder(Color.secondary.opacity(0.3))
)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ProgressView("Lädt Vorschau …")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
/// Zwei abgedunkelte Balken oben/unten markieren, was beim Crop wegfällt;
/// der helle Rahmen zeigt die bleibende 4:5-Fläche — volle Breite,
/// vertikal zentriert, exakt wie `CropEngine` es tatsächlich schneidet.
/// Abgedunkelte Balken oben/unten markieren, was beim Crop wegfällt; der
/// goldene Rahmen zeigt die bleibende 4:5-Fläche an der Position, die
/// zum aktuellen Zeitpunkt gilt.
@ViewBuilder
private func cropOverlay(sourceSize: CGSize, viewSize: CGSize) -> some View {
let cropSize = CropEngine.targetSize(forSourceSize: sourceSize)
let marginFraction = max(0, (1 - cropSize.height / sourceSize.height) / 2)
let marginHeight = viewSize.height * marginFraction
let rect = CropEngine.cropRect(forSourceSize: sourceSize, offset: plan.offset(at: currentTime))
let scale = viewSize.height / sourceSize.height
let topHeight = rect.minY * scale
let cropHeight = rect.height * scale
let bottomHeight = max(viewSize.height - topHeight - cropHeight, 0)
VStack(spacing: 0) {
Rectangle()
.fill(Color.black.opacity(0.55))
.frame(height: marginHeight)
.frame(height: topHeight)
Rectangle()
.strokeBorder(Color(red: 0.784, green: 0.663, blue: 0.416), lineWidth: 2)
.frame(height: max(viewSize.height - marginHeight * 2, 0))
.strokeBorder(Color.gold, lineWidth: dragStartOffset == nil ? 2 : 3)
.frame(height: cropHeight)
Rectangle()
.fill(Color.black.opacity(0.55))
.frame(height: marginHeight)
.frame(height: bottomHeight)
}
.allowsHitTesting(false)
}
/// Ziehen irgendwo im Bild verschiebt den Rahmen des gerade geltenden
/// Abschnitts. Die Wiedergabe pausiert dabei, sonst wechselt der
/// Abschnitt mitten im Ziehen.
private func frameDrag(sourceSize: CGSize, viewSize: CGSize) -> some Gesture {
DragGesture(minimumDistance: 1)
.onChanged { value in
if dragStartOffset == nil {
pause()
dragStartOffset = plan.offset(at: currentTime)
NSCursor.closedHand.push()
}
let cropHeight = CropEngine.targetSize(forSourceSize: sourceSize).height
let slackInView = (sourceSize.height - cropHeight) * viewSize.height / sourceSize.height
guard slackInView > 0, let start = dragStartOffset else { return }
// Offset -1…1 überspannt den gesamten Spielraum, also 2 Einheiten.
plan.setOffset(start + value.translation.height / slackInView * 2, at: currentTime)
}
.onEnded { _ in
dragStartOffset = nil
NSCursor.pop()
}
}
// MARK: - Transport
private func transportBar(info: VideoInfo) -> some View {
let duration = CMTimeGetSeconds(info.duration)
return HStack(spacing: 8) {
Button(action: togglePlayback) {
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
}
.keyboardShortcut(.space, modifiers: [])
.help("Abspielen/Pause (Leertaste)")
.buttonStyle(.borderless)
Button { step(by: -1) } label: { Image(systemName: "backward.frame.fill") }
.keyboardShortcut(.leftArrow, modifiers: [])
.help("Ein Frame zurück (←)")
.buttonStyle(.borderless)
Button { step(by: 1) } label: { Image(systemName: "forward.frame.fill") }
.keyboardShortcut(.rightArrow, modifiers: [])
.help("Ein Frame vor (→)")
.buttonStyle(.borderless)
Text(timecode(currentTime))
.monospacedDigit()
.font(.caption)
TimelineBar(
duration: duration,
currentTime: currentTime,
keyframes: plan.keyframes,
activeIndex: plan.keyframeIndex(at: currentTime),
hoveredID: $hoveredKeyframeID,
onScrub: { seconds, isFinal in seek(to: seconds, precise: isFinal) },
onDelete: deleteKeyframe
)
Text(timecode(duration))
.monospacedDigit()
.font(.caption)
.foregroundStyle(.secondary)
}
}
private var markerBar: some View {
let activeIndex = plan.keyframeIndex(at: currentTime)
let active = plan.keyframes[activeIndex]
return VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 10) {
Button(action: addMarker) {
Label("Marke setzen", systemImage: "plus.diamond")
}
.keyboardShortcut("m", modifiers: [])
.help("Ab hier gilt eine eigene Rahmenposition (M)")
Button(action: centerFrame) {
Label("Mittig setzen", systemImage: "align.vertical.center")
}
.keyboardShortcut("z", modifiers: [])
.disabled(plan.offset(at: currentTime) == 0)
.help(plan.keyframes.count > 1
? "Rahmen in diesem Abschnitt wieder mittig (Z oder Doppelklick ins Bild)"
: "Rahmen wieder mittig (Z oder Doppelklick ins Bild)")
// Unsichtbar, nur für die Taste: löscht die Marke unter dem
// Mauszeiger, sonst die am Abspielkopf.
Button("Marke löschen", action: deleteMarkerFromKeyboard)
.keyboardShortcut(.delete, modifiers: [])
.opacity(0)
.frame(width: 0, height: 0)
.accessibilityHidden(true)
Spacer()
if plan.keyframes.count > 1 {
Text("Position \(activeIndex + 1) von \(plan.keyframes.count) · ab \(timecode(active.time))")
.font(.system(size: 11.5))
.monospacedDigit()
.foregroundStyle(.secondary)
}
}
Text("Rahmen im Bild nach oben oder unten ziehen, Doppelklick setzt ihn wieder mittig. Bei einem Szenenwechsel an die Stelle spulen und eine Marke setzen – ab dort gilt eine eigene Position. Zum Löschen mit der Maus auf eine Marke zeigen und auf × klicken (oder ⌫).")
.font(.system(size: 11.5))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
/// Setzt den Rahmen des gerade geltenden Abschnitts zurück in die Mitte.
private func centerFrame() {
plan.setOffset(0, at: currentTime)
}
private func deleteKeyframe(_ id: CropPlan.Keyframe.ID) {
plan.removeKeyframe(id: id)
if hoveredKeyframeID == id { hoveredKeyframeID = nil }
}
private func deleteMarkerFromKeyboard() {
if let hoveredKeyframeID {
deleteKeyframe(hoveredKeyframeID)
return
}
let frameTime = displayedFrameTime(currentTime)
if let atPlayhead = plan.keyframes.dropFirst().first(where: { abs($0.time - frameTime) <= CropPlan.timeTolerance }) {
deleteKeyframe(atPlayhead.id)
}
}
// MARK: - Player
private func load() async {
do {
let info = try await VideoInspector.inspect(sourceURL)
videoSize = info.pixelSize
player = AVPlayer(url: sourceURL)
let inspected = try await VideoInspector.inspect(sourceURL)
let newPlayer = AVPlayer(url: sourceURL)
let interval = CMTime(value: 1, timescale: 30)
timeObserver = newPlayer.addPeriodicTimeObserver(forInterval: interval, queue: .main) { time in
currentTime = CMTimeGetSeconds(time)
isPlaying = newPlayer.rate != 0
}
info = inspected
player = newPlayer
} catch {
loadError = "Konnte Vorschau nicht laden."
}
}
private func tearDown() {
player?.pause()
if let timeObserver {
player?.removeTimeObserver(timeObserver)
}
timeObserver = nil
}
private func togglePlayback() {
guard let player else { return }
if player.rate != 0 {
pause()
return
}
if let info, currentTime >= CMTimeGetSeconds(info.duration) - 0.05 {
seek(to: 0, precise: true)
}
player.play()
isPlaying = true
}
private func pause() {
player?.pause()
isPlaying = false
}
private func seek(to seconds: Double, precise: Bool) {
guard let player else { return }
let time = CMTime(seconds: seconds, preferredTimescale: 600)
let tolerance = precise ? CMTime.zero : CMTime(seconds: 0.1, preferredTimescale: 600)
player.seek(to: time, toleranceBefore: tolerance, toleranceAfter: tolerance)
currentTime = seconds
}
private func step(by frames: Int) {
guard let info, info.frameRate > 0 else { return }
pause()
let duration = CMTimeGetSeconds(info.duration)
let target = displayedFrameTime(currentTime) + Double(frames) / Double(info.frameRate)
seek(to: min(max(target, 0), duration), precise: true)
}
/// Marke auf den Beginn des angezeigten Frames legen, damit genau dieser
/// Frame schon die neue Position bekommt.
private func addMarker() {
pause()
plan.addKeyframe(at: displayedFrameTime(currentTime))
}
/// Startzeit des Frames, der bei `seconds` zu sehen ist.
private func displayedFrameTime(_ seconds: Double) -> Double {
guard let info, info.frameRate > 0 else { return seconds }
let fps = Double(info.frameRate)
return ((seconds * fps) + CropPlan.timeTolerance).rounded(.down) / fps
}
/// `m:ss:ff` — Minuten, Sekunden, Frame.
private func timecode(_ seconds: Double) -> String {
let fps = max(Int((info?.frameRate ?? 25).rounded()), 1)
let totalFrames = Int((max(seconds, 0) * Double(fps) + CropPlan.timeTolerance).rounded(.down))
let frames = totalFrames % fps
let totalSeconds = totalFrames / fps
return String(format: "%d:%02d:%02d", totalSeconds / 60, totalSeconds % 60, frames)
}
}
/// Zeitleiste mit Abspielkopf und Positionsmarken. Klicken oder Ziehen
/// spult; nahe an einer Marke rastet der Kopf auf ihr ein. Zeigt die Maus auf
/// eine Marke, erscheint darüber ein × zum Löschen; Klick auf die Marke
/// springt zu ihr, Rechtsklick bietet ebenfalls Löschen an.
private struct TimelineBar: View {
let duration: Double
let currentTime: Double
let keyframes: [CropPlan.Keyframe]
let activeIndex: Int
@Binding var hoveredID: CropPlan.Keyframe.ID?
let onScrub: (_ seconds: Double, _ isFinal: Bool) -> Void
let onDelete: (CropPlan.Keyframe.ID) -> Void
private let snapDistance: CGFloat = 6
private let height: CGFloat = 40
/// Höhe der Spur innerhalb der Leiste; darüber ist Platz für das ×.
private let trackY: CGFloat = 28
var body: some View {
GeometryReader { proxy in
let width = proxy.size.width
ZStack(alignment: .topLeading) {
// Spur und Scrub-Fläche
Rectangle()
.fill(Color.clear)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in onScrub(time(at: value.location.x, width: width), false) }
.onEnded { value in onScrub(time(at: value.location.x, width: width), true) }
)
Capsule()
.fill(Color.secondary.opacity(0.25))
.frame(width: width, height: 4)
.position(x: width / 2, y: trackY)
.allowsHitTesting(false)
activeSegment(width: width)
Capsule()
.fill(Color.primary)
.frame(width: 2, height: 18)
.position(x: x(for: currentTime, width: width), y: trackY)
.allowsHitTesting(false)
ForEach(Array(keyframes.enumerated()), id: \.element.id) { index, keyframe in
marker(keyframe, index: index)
.position(x: x(for: keyframe.time, width: width), y: height / 2)
}
}
}
.frame(height: height)
}
/// Marke samt Löschknopf darüber — beides eine Hover-Fläche, damit das ×
/// nicht verschwindet, sobald die Maus von der Raute zu ihm wandert.
private func marker(_ keyframe: CropPlan.Keyframe, index: Int) -> some View {
let isHovered = hoveredID == keyframe.id
let isDeletable = index > 0
return VStack(spacing: 0) {
Button { onDelete(keyframe.id) } label: {
Image(systemName: "xmark")
.font(.system(size: 7, weight: .bold))
.foregroundStyle(Color(nsColor: .windowBackgroundColor))
.frame(width: 14, height: 14)
.background(Circle().fill(Color.primary))
}
.buttonStyle(.plain)
.opacity(isHovered && isDeletable ? 1 : 0)
.allowsHitTesting(isHovered && isDeletable)
.help("Marke löschen")
.frame(height: 18)
Rectangle()
.fill(index == activeIndex || isHovered ? Color.gold : Color.gold.opacity(0.6))
.frame(width: isHovered ? 11 : 9, height: isHovered ? 11 : 9)
.rotationEffect(.degrees(45))
.frame(width: 22, height: 22)
.contentShape(Rectangle())
.onTapGesture { onScrub(keyframe.time, true) }
}
.frame(width: 22, height: height)
.contentShape(Rectangle())
.onHover { inside in
if inside {
hoveredID = keyframe.id
} else if hoveredID == keyframe.id {
hoveredID = nil
}
}
.contextMenu {
Button("Zu dieser Marke springen") { onScrub(keyframe.time, true) }
if isDeletable {
Button("Marke löschen", role: .destructive) { onDelete(keyframe.id) }
}
}
.animation(.easeOut(duration: 0.12), value: isHovered)
}
private func activeSegment(width: CGFloat) -> some View {
let start = keyframes[activeIndex].time
let end = activeIndex + 1 < keyframes.count ? keyframes[activeIndex + 1].time : duration
let startX = x(for: start, width: width)
let segmentWidth = max(x(for: end, width: width) - startX, 0)
return Capsule()
.fill(Color.gold.opacity(0.55))
.frame(width: segmentWidth, height: 4)
.position(x: startX + segmentWidth / 2, y: trackY)
.allowsHitTesting(false)
}
private func x(for seconds: Double, width: CGFloat) -> CGFloat {
guard duration > 0 else { return 0 }
return CGFloat(min(max(seconds / duration, 0), 1)) * width
}
private func time(at x: CGFloat, width: CGFloat) -> Double {
if let snapped = keyframes.first(where: { abs(self.x(for: $0.time, width: width) - x) <= snapDistance }) {
return snapped.time
}
guard width > 0 else { return 0 }
return Double(min(max(x / width, 0), 1)) * duration
}
}

@ -6,54 +6,43 @@ struct SettingsView: View {
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Einstellungen")
.font(.title2.bold())
Form {
folderRow(
title: "Zielordner",
caption: "Hier entsteht pro Projekt ein Unterordner mit Original und 4:5-Fassung.",
current: settings.targetFolder
) { settings.setTargetFolder($0) }
VStack(alignment: .leading, spacing: 6) {
Text("Quellordner").font(.headline)
Text("Der Dateiauswahl-Dialog öffnet sich hier.")
.font(.caption)
.foregroundStyle(.secondary)
folderRow(current: settings.sourceFolder) { settings.setSourceFolder($0) }
folderRow(
title: "Quellordner",
caption: "„Dateien wählen …“ öffnet sich hier. Optional.",
current: settings.sourceFolder
) { settings.setSourceFolder($0) }
}
VStack(alignment: .leading, spacing: 6) {
Text("Zielordner").font(.headline)
Text("Hier entsteht pro Projekt ein Unterordner mit Original und 4:5-Fassung.")
.font(.caption)
.foregroundStyle(.secondary)
folderRow(current: settings.targetFolder) { settings.setTargetFolder($0) }
}
Spacer()
.formStyle(.grouped)
HStack {
Spacer()
Button("Fertig") { dismiss() }
.buttonStyle(ProminentButtonStyle())
.keyboardShortcut(.defaultAction)
}
.padding([.horizontal, .bottom], 20)
}
.padding(20)
.frame(minWidth: 440, minHeight: 280)
.frame(width: 520, height: 300)
}
@ViewBuilder
private func folderRow(current: URL?, onChoose: @escaping (URL) -> Void) -> some View {
HStack {
Text(current?.path ?? "Nicht gesetzt")
private func folderRow(title: LocalizedStringKey, caption: LocalizedStringKey, current: URL?, onChoose: @escaping (URL) -> Void) -> some View {
LabeledContent {
Button("Wählen …") {
if let url = FolderPicker.choose(startingAt: current) { onChoose(url) }
}
} label: {
Text(title)
Text(current?.path ?? String(localized: "Nicht gesetzt"))
.lineLimit(1)
.truncationMode(.middle)
.foregroundStyle(current == nil ? .secondary : .primary)
Spacer()
Button("Wählen…") {
let panel = NSOpenPanel()
panel.canChooseDirectories = true
panel.canChooseFiles = false
panel.allowsMultipleSelection = false
if let current { panel.directoryURL = current }
guard panel.runModal() == .OK, let url = panel.url else { return }
onChoose(url)
}
Text(caption)
}
}
}

@ -10,10 +10,18 @@ public enum ConversionPipeline {
/// true = das unveränderte 9:16-Original wird zusätzlich in den
/// Projektordner kopiert (README: "Dort landen beide Fassungen").
public var copyOriginal: Bool
/// true = Dateien ohne Format im Namen werden trotzdem verarbeitet;
/// "4x5" wird an den Namen angehängt (`FileNameParser.appendingTargetFormat`).
public var appendFormatWhenMissing: Bool
public init(cropOptions: CropEngine.Options = CropEngine.Options(), copyOriginal: Bool = true) {
public init(
cropOptions: CropEngine.Options = CropEngine.Options(),
copyOriginal: Bool = true,
appendFormatWhenMissing: Bool = false
) {
self.cropOptions = cropOptions
self.copyOriginal = copyOriginal
self.appendFormatWhenMissing = appendFormatWhenMissing
}
}
@ -36,8 +44,13 @@ public enum ConversionPipeline {
/// kann (dann übernimmt `process` die Skip/Reject-Meldung). Für die
/// Oberfläche gedacht: vor dem eigentlichen Lauf prüfen, ob eine Zieldatei
/// schon existiert, und bei Bedarf eine Rückfrage stellen.
public static func previewDestination(sourceURL: URL, outputRootDirectory: URL) -> URL? {
guard case .parsed(let parsed) = FileNameParser.parse(sourceURL.lastPathComponent).outcome else {
public static func previewDestination(
sourceURL: URL,
outputRootDirectory: URL,
appendFormatWhenMissing: Bool = false
) -> URL? {
let outcome = resolve(sourceURL.lastPathComponent, appendFormatWhenMissing: appendFormatWhenMissing)
guard case .parsed(let parsed) = outcome else {
return nil
}
let projectDirectory = outputRootDirectory.appendingPathComponent(parsed.projectName, isDirectory: true)
@ -51,9 +64,8 @@ public enum ConversionPipeline {
progress: ((Double) -> Void)? = nil
) async -> Outcome {
let fileName = sourceURL.lastPathComponent
let result = FileNameParser.parse(fileName)
switch result.outcome {
switch resolve(fileName, appendFormatWhenMissing: options.appendFormatWhenMissing) {
case .skipped(let reason):
return .skipped(fileName: fileName, reason: reason)
case .rejected(let reason):
@ -68,6 +80,18 @@ public enum ConversionPipeline {
}
}
/// Wie `FileNameParser.parse`, nur dass ein fehlendes Format auf Wunsch
/// angehängt statt abgelehnt wird.
private static func resolve(_ fileName: String, appendFormatWhenMissing: Bool) -> FileNameParseResult.Outcome {
let outcome = FileNameParser.parse(fileName).outcome
if appendFormatWhenMissing,
case .rejected(.formatSegmentMissing) = outcome,
let appended = FileNameParser.appendingTargetFormat(to: fileName) {
return .parsed(appended)
}
return outcome
}
private static func convert(
sourceURL: URL,
parsed: ParsedFileName,
@ -107,6 +131,11 @@ public enum ConversionPipeline {
)
}
/// Wie die 4:5-Fassung tatsächlich heißt: Name aus dem Parser, Endung fest `.mp4`.
public static func mp4Name(_ fileName: String) -> String {
mp4FileName(from: fileName)
}
/// Zielformat ist fest MP4 (siehe CropEngine) — die Endung aus dem
/// geparsten Dateinamen wird dafür überschrieben, nicht übernommen.
private static func mp4FileName(from fileName: String) -> String {

@ -21,9 +21,9 @@ import CoreVideo
/// über `Options.videoBitRate`/`Options.audioBitRate` überschreibbar.
public enum CropEngine {
public struct Options {
/// -1...1, 0 = vertikal zentriert. Platzhalter für den in der README
/// als "Offene Punkte" genannten, später pro Datei einstellbaren Offset.
public var verticalOffset: CGFloat
/// Wo der Ausschnitt über die Laufzeit sitzt. Standard: mittig, ohne
/// Positionsmarken.
public var cropPlan: CropPlan
/// false = eine bestehende Zieldatei wird nie stillschweigend überschrieben.
public var overwrite: Bool
/// Video-Zielbitrate in bit/s. nil = automatisch aus Auflösung und
@ -33,12 +33,12 @@ public enum CropEngine {
public var audioBitRate: Int?
public init(
verticalOffset: CGFloat = 0,
cropPlan: CropPlan = CropPlan(),
overwrite: Bool = false,
videoBitRate: Int? = nil,
audioBitRate: Int? = nil
) {
self.verticalOffset = verticalOffset
self.cropPlan = cropPlan
self.overwrite = overwrite
self.videoBitRate = videoBitRate
self.audioBitRate = audioBitRate
@ -53,6 +53,19 @@ public enum CropEngine {
return CGSize(width: sourceSize.width, height: evenHeight)
}
/// Der 4:5-Ausschnitt in Bildkoordinaten mit Ursprung **oben links** —
/// so, wie ihn Vorschau und Nutzer sehen. `offset` wie in `CropPlan`:
/// -1 = ganz oben, 0 = mittig, 1 = ganz unten. Die Oberkante landet auf
/// einer geraden Pixelzeile.
public static func cropRect(forSourceSize sourceSize: CGSize, offset: CGFloat) -> CGRect {
let size = targetSize(forSourceSize: sourceSize)
let slack = max(sourceSize.height - size.height, 0)
let clampedOffset = min(max(offset, -1), 1)
let top = slack / 2 + clampedOffset * slack / 2
let evenTop = min((top / 2).rounded() * 2, slack)
return CGRect(x: 0, y: evenTop, width: size.width, height: size.height)
}
public static func crop(
sourceURL: URL,
destinationURL: URL,
@ -70,12 +83,6 @@ public enum CropEngine {
}
let cropSize = targetSize(forSourceSize: info.pixelSize)
let cropOriginY = verticalCropOrigin(
sourceHeight: info.pixelSize.height,
targetHeight: cropSize.height,
verticalOffset: options.verticalOffset
)
let cropRect = CGRect(x: 0, y: cropOriginY, width: cropSize.width, height: cropSize.height)
let asset = AVURLAsset(url: sourceURL)
let tracks = try await asset.load(.tracks)
@ -83,6 +90,10 @@ public enum CropEngine {
throw CropEngineError.readerSetupFailed(underlying: VideoInspectorError.noVideoTrack)
}
let audioTrack = tracks.first(where: { $0.mediaType == .audio })
// iPhone-Clips liegen oft quer im Container und werden nur über die
// Track-Transformation hochkant angezeigt. Der Reader liefert die
// Frames ungedreht — die Drehung muss vor dem Crop angewendet werden.
let orientation = try await imageOrientation(of: videoTrack)
let videoSettings = videoEncoderSettings(size: cropSize, frameRate: info.frameRate, bitRateOverride: options.videoBitRate)
if options.overwrite {
@ -177,7 +188,9 @@ public enum CropEngine {
output: videoOutput,
input: videoInput,
adaptor: pixelBufferAdaptor,
cropRect: cropRect,
sourceSize: info.pixelSize,
orientation: orientation,
cropPlan: options.cropPlan,
ciContext: ciContext,
durationSeconds: durationSeconds,
progress: progress
@ -205,12 +218,34 @@ public enum CropEngine {
// MARK: - Geometrie
private static func verticalCropOrigin(sourceHeight: CGFloat, targetHeight: CGFloat, verticalOffset: CGFloat) -> CGFloat {
let slack = max(sourceHeight - targetHeight, 0)
let centered = slack / 2
let shifted = centered + (verticalOffset * slack / 2)
let clamped = min(max(shifted, 0), slack)
return (clamped / 2).rounded() * 2
/// Übersetzt die Track-Transformation in eine Bildausrichtung. Erkannt
/// werden die vier Drehungen um 90°; alles andere gilt als unverändert.
static func orientation(for transform: CGAffineTransform) -> CGImagePropertyOrientation {
switch (transform.a.rounded(), transform.b.rounded(), transform.c.rounded(), transform.d.rounded()) {
case (0, 1, -1, 0): return .right
case (0, -1, 1, 0): return .left
case (-1, 0, 0, -1): return .down
default: return .up
}
}
private static func imageOrientation(of track: AVAssetTrack) async throws -> CGImagePropertyOrientation {
orientation(for: try await track.load(.preferredTransform))
}
/// Dreht ein Frame aufrecht und legt es wieder auf den Ursprung, damit
/// `coreImageCropRect` (Ursprung 0/0, Größe = angezeigte Größe) passt.
private static func upright(_ image: CIImage, orientation: CGImagePropertyOrientation) -> CIImage {
guard orientation != .up else { return image }
let oriented = image.oriented(orientation)
return oriented.transformed(by: CGAffineTransform(translationX: -oriented.extent.minX, y: -oriented.extent.minY))
}
/// `cropRect` in CoreImage-Koordinaten: Ursprung **unten links**, die
/// oberste Pixelzeile eines `CIImage(cvPixelBuffer:)` liegt bei y = Höhe.
private static func coreImageCropRect(sourceSize: CGSize, offset: CGFloat) -> CGRect {
let rect = cropRect(forSourceSize: sourceSize, offset: offset)
return CGRect(x: rect.minX, y: sourceSize.height - rect.maxY, width: rect.width, height: rect.height)
}
// MARK: - Container & Codec
@ -273,7 +308,9 @@ public enum CropEngine {
output: AVAssetReaderTrackOutput,
input: AVAssetWriterInput,
adaptor: AVAssetWriterInputPixelBufferAdaptor,
cropRect: CGRect,
sourceSize: CGSize,
orientation: CGImagePropertyOrientation,
cropPlan: CropPlan,
ciContext: CIContext,
durationSeconds: Double,
progress: ((Double) -> Void)?
@ -312,7 +349,11 @@ public enum CropEngine {
return
}
let croppedImage = CIImage(cvPixelBuffer: sourceBuffer)
let cropRect = coreImageCropRect(
sourceSize: sourceSize,
offset: cropPlan.offset(at: CMTimeGetSeconds(presentationTime))
)
let croppedImage = upright(CIImage(cvPixelBuffer: sourceBuffer), orientation: orientation)
.cropped(to: cropRect)
.transformed(by: CGAffineTransform(translationX: -cropRect.origin.x, y: -cropRect.origin.y))
ciContext.render(croppedImage, to: outBuffer)

@ -0,0 +1,94 @@
import CoreGraphics
import Foundation
/// Wo der 4:5-Ausschnitt über die Laufzeit eines Clips sitzt. Eine Liste von
/// Positionsmarken: jede Marke gilt ab ihrem Zeitpunkt bis zur nächsten —
/// harte Schnitte, keine Kamerafahrt dazwischen. Gedacht für Clips mit ein,
/// zwei Szenen, in denen das Framing jeweils anders sitzen muss.
///
/// Invariante: Es gibt immer mindestens eine Marke, und die erste liegt bei 0.
/// Ein Plan mit genau einer Marke ist damit schlicht ein fester Offset für
/// die ganze Datei.
public struct CropPlan: Equatable, Sendable {
public struct Keyframe: Equatable, Sendable, Identifiable {
public let id: UUID
/// Sekunden ab Clip-Beginn.
public var time: Double
/// -1 = Ausschnitt ganz oben, 0 = mittig, 1 = ganz unten.
public var offset: CGFloat
public init(id: UUID = UUID(), time: Double, offset: CGFloat) {
self.id = id
self.time = time
self.offset = offset
}
}
/// Zwei Marken näher beieinander als das gelten als dieselbe Stelle.
/// Fängt auch Rundungsrauschen zwischen Player-Zeit und Frame-PTS ab.
public static let timeTolerance: Double = 0.001
public private(set) var keyframes: [Keyframe]
public init(offset: CGFloat = 0) {
keyframes = [Keyframe(time: 0, offset: Self.clamp(offset))]
}
/// Baut einen Plan aus beliebigen Marken: sortiert, Offsets geklemmt, die
/// früheste Marke auf 0 gezogen. Leere Liste = mittig.
public init(keyframes: [Keyframe]) {
var sorted = keyframes
.map { Keyframe(id: $0.id, time: max($0.time, 0), offset: Self.clamp($0.offset)) }
.sorted { $0.time < $1.time }
if sorted.isEmpty {
sorted = [Keyframe(time: 0, offset: 0)]
}
sorted[0].time = 0
self.keyframes = sorted
}
/// true = mittig, keine Marken — also genau das bisherige Verhalten.
public var isDefault: Bool {
keyframes.count == 1 && keyframes[0].offset == 0
}
/// Index der Marke, die zum Zeitpunkt `seconds` gilt.
public func keyframeIndex(at seconds: Double) -> Int {
keyframes.lastIndex { $0.time <= seconds + Self.timeTolerance } ?? 0
}
public func offset(at seconds: Double) -> CGFloat {
keyframes[keyframeIndex(at: seconds)].offset
}
/// Verschiebt den Ausschnitt der Marke, die zum Zeitpunkt `seconds` gilt —
/// also des ganzen Abschnitts bis zur nächsten Marke.
public mutating func setOffset(_ offset: CGFloat, at seconds: Double) {
keyframes[keyframeIndex(at: seconds)].offset = Self.clamp(offset)
}
/// Setzt eine neue Marke bei `seconds`; sie übernimmt den Offset, der dort
/// bisher galt, damit sich am Bild erst mal nichts ändert. Liegt dort schon
/// eine Marke, passiert nichts.
@discardableResult
public mutating func addKeyframe(at seconds: Double) -> Keyframe? {
let time = max(seconds, 0)
guard !keyframes.contains(where: { abs($0.time - time) <= Self.timeTolerance }) else {
return nil
}
let keyframe = Keyframe(time: time, offset: offset(at: time))
keyframes.append(keyframe)
keyframes.sort { $0.time < $1.time }
return keyframe
}
/// Die erste Marke (bei 0) bleibt immer erhalten.
public mutating func removeKeyframe(id: Keyframe.ID) {
guard let index = keyframes.firstIndex(where: { $0.id == id }), index > 0 else { return }
keyframes.remove(at: index)
}
private static func clamp(_ offset: CGFloat) -> CGFloat {
min(max(offset, -1), 1)
}
}

@ -15,7 +15,8 @@ public struct FileNameParseResult: Equatable {
/// Erfolgreich zerlegter Dateiname nach dem Schema
/// `[Projekt]-[Format]-[Version]-[Sprache].[ext]`, wobei das Format-Segment
/// (`9x16`) als Anker dient und nicht die Position.
/// (`9x16`) als Anker dient und nicht die Position. Als Trenner zählen `-`,
/// `_` und Leerzeichen; sie bleiben in der Zielfassung zeichengleich erhalten.
public struct ParsedFileName: Equatable {
/// Wörtlich wie übergeben — wird nicht aus den Segmenten rekonstruiert,
/// damit ein Rundtrip-Fehler (Groß-/Kleinschreibung, Sonderzeichen) nie
@ -23,17 +24,23 @@ public struct ParsedFileName: Equatable {
public let originalFileName: String
public let projectName: String
/// Das Format-Segment, wie es im Original vorgefunden wurde (z.B. "9X16").
/// Leer, wenn das Format per `FileNameParser.appendingTargetFormat` erst
/// angehängt wird.
public let sourceFormatToken: String
/// Alles nach dem Format-Segment, zeichengleich, z.B. "v3-DE" oder "v3-EN-US".
/// Alles nach dem Format-Segment ohne den Trenner davor, zeichengleich,
/// z.B. "v3-DE" oder "v3-EN-US". Darf leer sein.
public let remainder: String
/// Ohne führenden Punkt, unverändert übernommen (Groß-/Kleinschreibung inklusive).
public let fileExtension: String
/// Alles vor bzw. nach dem Format-Segment, inklusive der Trenner.
let head: String
let tail: String
/// Zieldateiname mit ausschließlich dem Format-Segment ersetzt (immer
/// klein geschrieben als "4x5"). Alle anderen Segmente bleiben zeichengleich.
/// klein geschrieben als "4x5"). Alle anderen Zeichen bleiben gleich.
public var targetFileName: String {
let ext = fileExtension.isEmpty ? "" : ".\(fileExtension)"
return "\(projectName)-\(FileNameParser.targetFormatToken)-\(remainder)\(ext)"
return "\(head)\(FileNameParser.targetFormatToken)\(tail)\(ext)"
}
}
@ -45,8 +52,8 @@ public enum SkipReason: Equatable {
public enum RejectionReason: Equatable {
/// Dateistamm endet auf ein Finder-Duplikat-Suffix wie " (1)".
case finderDuplicate
/// Kein nutzbares Format-Segment gefunden: weder "9x16" noch "4x5" als
/// eigenständiges Segment mit mindestens einem Segment davor und danach.
/// Kein nutzbares Format-Segment gefunden: weder "9x16" mit einem
/// Projektnamen davor noch "4x5". Ausweg: `appendingTargetFormat`.
case formatSegmentMissing
}
@ -54,9 +61,15 @@ public enum RejectionReason: Equatable {
/// und zustandslos — reine Zeichenketten rein, reine Zeichenketten (bzw.
/// Ergebnistypen) raus.
public enum FileNameParser {
fileprivate static let sourceFormatToken = "9x16"
fileprivate static let targetFormatToken = "4x5"
/// Format-Token als eigenständiges Segment: `9x16`, `9X16`, `9×16`, auch
/// mit Leerzeichen um das x. Davor und danach darf kein Buchstabe und keine
/// Ziffer stehen — `-`, `_`, Leerzeichen, Klammern oder Namensanfang/-ende.
private static let sourceFormatPattern = #"(?<![\p{L}\p{N}])9 ?[xX×] ?16(?![\p{L}\p{N}])"#
private static let targetFormatPattern = #"(?<![\p{L}\p{N}])4 ?[xX×] ?5(?![\p{L}\p{N}])"#
private static let separators = CharacterSet(charactersIn: "-_ ()[]")
public static func parse(_ fileName: String) -> FileNameParseResult {
let (stem, fileExtension) = splitExtension(fileName)
@ -67,45 +80,68 @@ public enum FileNameParser {
return FileNameParseResult(fileName: fileName, outcome: .rejected(.finderDuplicate))
}
let segments = stem.components(separatedBy: "-")
if let index = lastIndex(ofSegmentMatching: sourceFormatToken, in: segments),
hasProjectAndRemainder(atIndex: index, segmentCount: segments.count) {
let parsed = ParsedFileName(
originalFileName: fileName,
projectName: segments[..<index].joined(separator: "-"),
sourceFormatToken: segments[index],
remainder: segments[(index + 1)...].joined(separator: "-"),
fileExtension: fileExtension
)
return FileNameParseResult(fileName: fileName, outcome: .parsed(parsed))
if let range = lastRange(of: sourceFormatPattern, in: stem) {
let head = String(stem[..<range.lowerBound])
let tail = String(stem[range.upperBound...])
let projectName = head.trimmingCharacters(in: separators)
if !projectName.isEmpty {
let parsed = ParsedFileName(
originalFileName: fileName,
projectName: projectName,
sourceFormatToken: String(stem[range]),
remainder: tail.trimmingCharacters(in: separators),
fileExtension: fileExtension,
head: head,
tail: tail
)
return FileNameParseResult(fileName: fileName, outcome: .parsed(parsed))
}
}
if let index = lastIndex(ofSegmentMatching: targetFormatToken, in: segments),
hasProjectAndRemainder(atIndex: index, segmentCount: segments.count) {
if lastRange(of: targetFormatPattern, in: stem) != nil {
return FileNameParseResult(fileName: fileName, outcome: .skipped(.alreadyTargetFormat))
}
return FileNameParseResult(fileName: fileName, outcome: .rejected(.formatSegmentMissing))
}
/// Ausweg für Dateien ohne Format im Namen: der ganze Dateistamm wird zum
/// Projekt, "4x5" wird hinten angehängt — mit dem Trenner, den der Name
/// schon benutzt. `nil` für Finder-Duplikate und Namen, die regulär
/// geparst werden können.
public static func appendingTargetFormat(to fileName: String) -> ParsedFileName? {
guard case .rejected(.formatSegmentMissing) = parse(fileName).outcome else { return nil }
let (stem, fileExtension) = splitExtension(fileName)
let projectName = stem.trimmingCharacters(in: separators)
guard !projectName.isEmpty else { return nil }
return ParsedFileName(
originalFileName: fileName,
projectName: projectName,
sourceFormatToken: "",
remainder: "",
fileExtension: fileExtension,
head: projectName + preferredSeparator(in: projectName),
tail: ""
)
}
/// Sucht von rechts, damit bei mehrfachem Vorkommen (z.B. weil der
/// Projektname selbst "9x16" enthält) das echte Format-Segment gewinnt —
/// das direkt vor Version/Sprache.
private static func lastIndex(ofSegmentMatching token: String, in segments: [String]) -> Int? {
for index in stride(from: segments.count - 1, through: 0, by: -1) {
if segments[index].caseInsensitiveCompare(token) == .orderedSame {
return index
}
}
return nil
private static func lastRange(of pattern: String, in stem: String) -> Range<String.Index>? {
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let fullRange = NSRange(stem.startIndex..<stem.endIndex, in: stem)
guard let match = regex.matches(in: stem, range: fullRange).last else { return nil }
return Range(match.range, in: stem)
}
/// Mindestens ein Segment davor (Projekt) und mindestens eins danach
/// (Rest). Bewusst NICHT auf genau ein Segment danach geprüft — sonst
/// bricht ein mehrteiliges Sprachkürzel wie "EN-US".
private static func hasProjectAndRemainder(atIndex index: Int, segmentCount: Int) -> Bool {
index > 0 && index < segmentCount - 1
/// Der Trenner, den ein Name schon benutzt: `_` oder Leerzeichen, wenn
/// nur dieser vorkommt, sonst `-` wie in der Namenskonvention.
private static func preferredSeparator(in name: String) -> String {
let hasHyphen = name.contains("-")
if !hasHyphen && name.contains("_") { return "_" }
if !hasHyphen && name.contains(" ") { return " " }
return "-"
}
private static func splitExtension(_ fileName: String) -> (stem: String, extension: String) {

@ -8,10 +8,18 @@ public enum JobQueue {
public struct Job: Sendable {
public let id: UUID
public let sourceURL: URL
/// Ausschnitt nur für diese Datei. nil = der aus den gemeinsamen
/// `options` des Batches.
public let cropPlan: CropPlan?
/// Format anhängen, falls es im Namen fehlt — nur für diese Datei.
/// nil = wie in den gemeinsamen `options`.
public let appendFormatWhenMissing: Bool?
public init(id: UUID = UUID(), sourceURL: URL) {
public init(id: UUID = UUID(), sourceURL: URL, cropPlan: CropPlan? = nil, appendFormatWhenMissing: Bool? = nil) {
self.id = id
self.sourceURL = sourceURL
self.cropPlan = cropPlan
self.appendFormatWhenMissing = appendFormatWhenMissing
}
}
@ -34,11 +42,18 @@ public enum JobQueue {
func startNext() {
guard let job = iterator.next() else { return }
group.addTask {
var jobOptions = options
if let cropPlan = job.cropPlan {
jobOptions.cropOptions.cropPlan = cropPlan
}
if let appendFormatWhenMissing = job.appendFormatWhenMissing {
jobOptions.appendFormatWhenMissing = appendFormatWhenMissing
}
group.addTask { [jobOptions] in
let outcome = await ConversionPipeline.process(
sourceURL: job.sourceURL,
outputRootDirectory: outputRootDirectory,
options: options
options: jobOptions
) { value in
progress?(job, value)
}

@ -109,4 +109,26 @@ final class ConversionPipelineTests: XCTestCase {
let contents = try FileManager.default.contentsOfDirectory(atPath: projectDirectory.path)
XCTAssertEqual(contents, ["SOMOS-4x5-v3-DE.mp4"])
}
func testAppendsFormatWhenMissingAndAllowed() async throws {
let clip = try TestVideoFactory.makeNineBySixteenClip(fileName: "Sommerfest Teaser.mov", in: tempDirectory)
let preview = ConversionPipeline.previewDestination(
sourceURL: clip.url,
outputRootDirectory: outputDirectory,
appendFormatWhenMissing: true
)
let outcome = await ConversionPipeline.process(
sourceURL: clip.url,
outputRootDirectory: outputDirectory,
options: .init(appendFormatWhenMissing: true)
)
guard case .succeeded(let success) = outcome else {
return XCTFail("Erwartete .succeeded, bekam \(outcome)")
}
XCTAssertEqual(success.croppedDestination.lastPathComponent, "Sommerfest Teaser 4x5.mp4")
XCTAssertEqual(success.croppedDestination.deletingLastPathComponent().lastPathComponent, "Sommerfest Teaser")
XCTAssertEqual(preview, success.croppedDestination)
}
}

@ -34,8 +34,88 @@ final class CropEngineTests: XCTestCase {
XCTAssertEqual(size.height.truncatingRemainder(dividingBy: 2), 0)
}
func testCropRectMapsOffsetToTopCenterBottom() {
let source = CGSize(width: 1080, height: 1920)
XCTAssertEqual(CropEngine.cropRect(forSourceSize: source, offset: -1).minY, 0)
XCTAssertEqual(CropEngine.cropRect(forSourceSize: source, offset: 0).minY, 286)
XCTAssertEqual(CropEngine.cropRect(forSourceSize: source, offset: 1).maxY, 1920)
XCTAssertEqual(CropEngine.cropRect(forSourceSize: source, offset: 5).maxY, 1920)
}
func testCropRectTopEdgeIsEven() {
let rect = CropEngine.cropRect(forSourceSize: CGSize(width: 1080, height: 1920), offset: 0.3333)
XCTAssertEqual(rect.minY.truncatingRemainder(dividingBy: 2), 0)
}
// MARK: - End-to-End gegen einen synthetischen Clip
/// Quelle: obere Hälfte weiß, untere schwarz. Ausschnitt ganz oben muss
/// also deutlich heller sein als ganz unten — prüft die Richtung des
/// Offsets im tatsächlichen Render, nicht nur in der Geometrie.
func testOffsetMovesRenderedCropUpAndDown() async throws {
let clip = try TestVideoFactory.makeNineBySixteenClip(includeAudio: false, pattern: .brightTopDarkBottom, in: tempDirectory)
let top = tempDirectory.appendingPathComponent("top.mp4")
let bottom = tempDirectory.appendingPathComponent("bottom.mp4")
try await CropEngine.crop(sourceURL: clip.url, destinationURL: top, options: .init(cropPlan: CropPlan(offset: -1)))
try await CropEngine.crop(sourceURL: clip.url, destinationURL: bottom, options: .init(cropPlan: CropPlan(offset: 1)))
let topBrightness = try await TestVideoFactory.averageBrightness(of: top, at: 0.5)
let bottomBrightness = try await TestVideoFactory.averageBrightness(of: bottom, at: 0.5)
// 384er Quelle, 270er Ausschnitt: oben 192/270 ≈ 0.71 weiß, unten 78/270 ≈ 0.29.
XCTAssertEqual(topBrightness, 0.71, accuracy: 0.08)
XCTAssertEqual(bottomBrightness, 0.29, accuracy: 0.08)
}
/// iPhone-Fall: Container quer (384×216), per 90°-Transformation hochkant.
/// Links im gespeicherten Bild ist nach der Drehung oben — der Ausschnitt
/// "ganz oben" muss also hell sein, und das Ergebnis darf nicht quer sein.
func testRotatedSourceIsCroppedUpright() async throws {
let clip = try TestVideoFactory.makeClip(
size: CGSize(width: 384, height: 216),
includeAudio: false,
pattern: .brightLeftDarkRight,
transform: CGAffineTransform(a: 0, b: 1, c: -1, d: 0, tx: 216, ty: 0),
in: tempDirectory
)
let top = tempDirectory.appendingPathComponent("top.mp4")
let bottom = tempDirectory.appendingPathComponent("bottom.mp4")
try await CropEngine.crop(sourceURL: clip.url, destinationURL: top, options: .init(cropPlan: CropPlan(offset: -1)))
try await CropEngine.crop(sourceURL: clip.url, destinationURL: bottom, options: .init(cropPlan: CropPlan(offset: 1)))
let resultInfo = try await VideoInspector.inspect(top)
XCTAssertEqual(resultInfo.pixelSize, CGSize(width: 216, height: 270))
let topBrightness = try await TestVideoFactory.averageBrightness(of: top, at: 0.5)
let bottomBrightness = try await TestVideoFactory.averageBrightness(of: bottom, at: 0.5)
XCTAssertEqual(topBrightness, 0.71, accuracy: 0.08)
XCTAssertEqual(bottomBrightness, 0.29, accuracy: 0.08)
}
func testOrientationFromTrackTransform() {
XCTAssertEqual(CropEngine.orientation(for: .identity), .up)
XCTAssertEqual(CropEngine.orientation(for: CGAffineTransform(a: 0, b: 1, c: -1, d: 0, tx: 1080, ty: 0)), .right)
XCTAssertEqual(CropEngine.orientation(for: CGAffineTransform(a: 0, b: -1, c: 1, d: 0, tx: 0, ty: 1920)), .left)
XCTAssertEqual(CropEngine.orientation(for: CGAffineTransform(a: -1, b: 0, c: 0, d: -1, tx: 1080, ty: 1920)), .down)
}
func testKeyframeSwitchesCropMidClip() async throws {
let clip = try TestVideoFactory.makeNineBySixteenClip(includeAudio: false, pattern: .brightTopDarkBottom, in: tempDirectory)
let destination = tempDirectory.appendingPathComponent("out.mp4")
var plan = CropPlan(offset: -1)
plan.addKeyframe(at: 1)
plan.setOffset(1, at: 1)
try await CropEngine.crop(sourceURL: clip.url, destinationURL: destination, options: .init(cropPlan: plan))
let before = try await TestVideoFactory.averageBrightness(of: destination, at: 0.5)
let after = try await TestVideoFactory.averageBrightness(of: destination, at: 1.5)
XCTAssertEqual(before, 0.71, accuracy: 0.08)
XCTAssertEqual(after, 0.29, accuracy: 0.08)
}
func testCropsToTargetSizeAndKeepsAudioAndDuration() async throws {
let clip = try TestVideoFactory.makeNineBySixteenClip(in: tempDirectory)
let destination = tempDirectory.appendingPathComponent("out.mp4")

@ -0,0 +1,85 @@
import XCTest
@testable import DropTwoCore
final class CropPlanTests: XCTestCase {
func testDefaultPlanIsCenteredWithSingleKeyframe() {
let plan = CropPlan()
XCTAssertTrue(plan.isDefault)
XCTAssertEqual(plan.keyframes.count, 1)
XCTAssertEqual(plan.offset(at: 0), 0)
XCTAssertEqual(plan.offset(at: 100), 0)
}
func testOffsetIsClamped() {
XCTAssertEqual(CropPlan(offset: 3).offset(at: 0), 1)
XCTAssertEqual(CropPlan(offset: -3).offset(at: 0), -1)
}
func testKeyframeHoldsUntilNextOne() {
var plan = CropPlan(offset: -1)
plan.addKeyframe(at: 2)
plan.setOffset(1, at: 2)
XCTAssertEqual(plan.offset(at: 0), -1)
XCTAssertEqual(plan.offset(at: 1.99), -1)
XCTAssertEqual(plan.offset(at: 2), 1)
XCTAssertEqual(plan.offset(at: 10), 1)
XCTAssertFalse(plan.isDefault)
}
func testNewKeyframeTakesOverCurrentOffset() {
var plan = CropPlan(offset: 0.4)
let keyframe = plan.addKeyframe(at: 3)
XCTAssertEqual(keyframe?.offset, 0.4)
}
func testKeyframeAtExistingTimeIsIgnored() {
var plan = CropPlan()
plan.addKeyframe(at: 2)
XCTAssertNil(plan.addKeyframe(at: 2.0005))
XCTAssertNil(plan.addKeyframe(at: 0))
XCTAssertEqual(plan.keyframes.count, 2)
}
func testKeyframesStaySorted() {
var plan = CropPlan()
plan.addKeyframe(at: 5)
plan.addKeyframe(at: 1)
plan.addKeyframe(at: 3)
XCTAssertEqual(plan.keyframes.map(\.time), [0, 1, 3, 5])
}
func testFirstKeyframeCannotBeRemoved() {
var plan = CropPlan()
let second = plan.addKeyframe(at: 2)!
plan.removeKeyframe(id: plan.keyframes[0].id)
XCTAssertEqual(plan.keyframes.count, 2)
plan.removeKeyframe(id: second.id)
XCTAssertEqual(plan.keyframes.count, 1)
}
func testInitFromKeyframesPullsEarliestToZero() {
let plan = CropPlan(keyframes: [
.init(time: 4, offset: 1),
.init(time: 1.5, offset: -1)
])
XCTAssertEqual(plan.keyframes.map(\.time), [0, 4])
XCTAssertEqual(plan.offset(at: 0), -1)
}
func testToleranceAbsorbsPlayerRoundingNoise() {
var plan = CropPlan()
plan.addKeyframe(at: 1.0)
plan.setOffset(1, at: 1.0)
// Frame-PTS minimal vor der Marke, wie es aus CMTime-Rundung kommt.
XCTAssertEqual(plan.offset(at: 0.9999), 1)
}
}

@ -123,10 +123,69 @@ final class FileNameParserTests: XCTestCase {
XCTAssertEqual(result.outcome, .rejected(.formatSegmentMissing))
}
func testRejectsWhenNoRemainderSegment() {
func testParsesWithoutRemainderSegment() {
let result = FileNameParser.parse("SOMOS-9x16.mp4")
XCTAssertEqual(result.outcome, .rejected(.formatSegmentMissing))
guard case .parsed(let parsed) = result.outcome else {
return XCTFail("Erwartete .parsed, bekam \(result.outcome)")
}
XCTAssertEqual(parsed.projectName, "SOMOS")
XCTAssertEqual(parsed.remainder, "")
XCTAssertEqual(parsed.targetFileName, "SOMOS-4x5.mp4")
}
// MARK: - d) Lockere Schreibweisen
func testUnderscoreAndSpaceSeparatorsArePreserved() {
let cases = [
("SOMOS_9x16_v3_DE.mp4", "SOMOS", "v3_DE", "SOMOS_4x5_v3_DE.mp4"),
("SOMOS 9x16 v3 DE.mov", "SOMOS", "v3 DE", "SOMOS 4x5 v3 DE.mov"),
("Somos Pendant - 9X16_v3-DE.mp4", "Somos Pendant", "v3-DE", "Somos Pendant - 4x5_v3-DE.mp4"),
("SOMOS-9×16-v3.mp4", "SOMOS", "v3", "SOMOS-4x5-v3.mp4"),
("SOMOS (9 x 16) v3.mp4", "SOMOS", "v3", "SOMOS (4x5) v3.mp4")
]
for (fileName, project, remainder, target) in cases {
guard case .parsed(let parsed) = FileNameParser.parse(fileName).outcome else {
XCTFail("Erwartete .parsed für \(fileName)")
continue
}
XCTAssertEqual(parsed.projectName, project, fileName)
XCTAssertEqual(parsed.remainder, remainder, fileName)
XCTAssertEqual(parsed.targetFileName, target, fileName)
}
}
func testTokenInsideWordIsNotAFormat() {
XCTAssertEqual(FileNameParser.parse("SOMOS-19x160-v3.mp4").outcome, .rejected(.formatSegmentMissing))
XCTAssertEqual(FileNameParser.parse("SOMOSx9x16v3.mp4").outcome, .rejected(.formatSegmentMissing))
}
func testTargetFormatInAnySpellingIsSkipped() {
for fileName in ["SOMOS_4X5_v3.mp4", "SOMOS 4x5.mp4", "SOMOS-4×5-v3-DE.mp4"] {
XCTAssertEqual(FileNameParser.parse(fileName).outcome, .skipped(.alreadyTargetFormat), fileName)
}
}
// MARK: - e) Format anhängen, wenn es fehlt
func testAppendingFormatUsesWholeStemAsProject() {
let parsed = FileNameParser.appendingTargetFormat(to: "Sommerfest Teaser.mov")
XCTAssertEqual(parsed?.projectName, "Sommerfest Teaser")
XCTAssertEqual(parsed?.targetFileName, "Sommerfest Teaser 4x5.mov")
XCTAssertEqual(parsed?.originalFileName, "Sommerfest Teaser.mov")
}
func testAppendingFormatPicksSeparatorFromName() {
XCTAssertEqual(FileNameParser.appendingTargetFormat(to: "SOMOS-v3-DE.mp4")?.targetFileName, "SOMOS-v3-DE-4x5.mp4")
XCTAssertEqual(FileNameParser.appendingTargetFormat(to: "SOMOS_v3.mp4")?.targetFileName, "SOMOS_v3_4x5.mp4")
XCTAssertEqual(FileNameParser.appendingTargetFormat(to: "SOMOS.mp4")?.targetFileName, "SOMOS-4x5.mp4")
}
func testAppendingFormatOnlyForMissingFormat() {
XCTAssertNil(FileNameParser.appendingTargetFormat(to: "SOMOS-9x16-v3-DE.mp4"))
XCTAssertNil(FileNameParser.appendingTargetFormat(to: "SOMOS-4x5-v3-DE.mp4"))
XCTAssertNil(FileNameParser.appendingTargetFormat(to: "SOMOS-v3-DE (1).mp4"))
}
// MARK: - c) Format-Segment in der Ausgabe immer klein geschrieben

@ -12,6 +12,18 @@ enum TestVideoFactory {
let hasAudio: Bool
}
/// Bildinhalt der erzeugten Frames.
enum Pattern {
/// Ganze Fläche einfarbig grau, Helligkeit wechselt von Frame zu Frame.
case flatByFrameIndex
/// Obere Bildhälfte weiß, untere schwarz — damit lässt sich prüfen,
/// welcher vertikale Ausschnitt tatsächlich gerendert wurde.
case brightTopDarkBottom
/// Linke Bildhälfte weiß, rechte schwarz — für quer gespeicherte,
/// per Transformation gedrehte Clips (iPhone).
case brightLeftDarkRight
}
enum FactoryError: Error {
case writerSetupFailed(underlying: Error?)
case pixelBufferPoolUnavailable
@ -25,6 +37,7 @@ enum TestVideoFactory {
frameRate: Int32 = 10,
seconds: Double = 2,
includeAudio: Bool = true,
pattern: Pattern = .flatByFrameIndex,
fileName: String? = nil,
in directory: URL
) throws -> GeneratedVideo {
@ -33,6 +46,7 @@ enum TestVideoFactory {
frameRate: frameRate,
seconds: seconds,
includeAudio: includeAudio,
pattern: pattern,
fileName: fileName,
in: directory
)
@ -43,6 +57,8 @@ enum TestVideoFactory {
frameRate: Int32 = 10,
seconds: Double = 2,
includeAudio: Bool = true,
pattern: Pattern = .flatByFrameIndex,
transform: CGAffineTransform = .identity,
fileName: String? = nil,
in directory: URL
) throws -> GeneratedVideo {
@ -63,6 +79,7 @@ enum TestVideoFactory {
]
let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
videoInput.expectsMediaDataInRealTime = false
videoInput.transform = transform
let pixelBufferAttributes: [String: Any] = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
@ -104,7 +121,7 @@ enum TestVideoFactory {
writer.startSession(atSourceTime: .zero)
let frameCount = Int(seconds * Double(frameRate))
try appendFrames(count: frameCount, frameRate: frameRate, input: videoInput, adaptor: adaptor)
try appendFrames(count: frameCount, frameRate: frameRate, pattern: pattern, input: videoInput, adaptor: adaptor)
if let audioInput {
try appendAudio(seconds: seconds, sampleRate: 44_100, input: audioInput)
@ -131,6 +148,7 @@ enum TestVideoFactory {
private static func appendFrames(
count: Int,
frameRate: Int32,
pattern: Pattern,
input: AVAssetWriterInput,
adaptor: AVAssetWriterInputPixelBufferAdaptor
) throws {
@ -160,7 +178,7 @@ enum TestVideoFactory {
semaphore.signal()
return
}
fill(pixelBuffer: pixelBuffer, frameIndex: nextFrameIndex)
fill(pixelBuffer: pixelBuffer, frameIndex: nextFrameIndex, pattern: pattern)
let presentationTime = CMTime(value: CMTimeValue(nextFrameIndex), timescale: frameRate)
adaptor.append(pixelBuffer, withPresentationTime: presentationTime)
nextFrameIndex += 1
@ -173,14 +191,66 @@ enum TestVideoFactory {
}
}
private static func fill(pixelBuffer: CVPixelBuffer, frameIndex: Int) {
private static func fill(pixelBuffer: CVPixelBuffer, frameIndex: Int, pattern: Pattern) {
CVPixelBufferLockBaseAddress(pixelBuffer, [])
defer { CVPixelBufferUnlockBaseAddress(pixelBuffer, []) }
guard let base = CVPixelBufferGetBaseAddress(pixelBuffer) else { return }
let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer)
let height = CVPixelBufferGetHeight(pixelBuffer)
let value = Int32(UInt8((frameIndex * 37) % 256))
memset(base, value, bytesPerRow * height)
switch pattern {
case .flatByFrameIndex:
let value = Int32(UInt8((frameIndex * 37) % 256))
memset(base, value, bytesPerRow * height)
case .brightTopDarkBottom:
let topRows = height / 2
memset(base, 255, bytesPerRow * topRows)
memset(base + bytesPerRow * topRows, 0, bytesPerRow * (height - topRows))
case .brightLeftDarkRight:
let width = CVPixelBufferGetWidth(pixelBuffer)
for row in 0..<height {
let rowStart = base + row * bytesPerRow
memset(rowStart, 255, (width / 2) * 4)
memset(rowStart + (width / 2) * 4, 0, bytesPerRow - (width / 2) * 4)
}
}
}
// MARK: - Auswertung
/// Mittlere Helligkeit (0…1) des Frames bei `seconds` — gerechnet über
/// alle Pixel, auf ein kleines Graubild heruntergerechnet.
static func averageBrightness(of url: URL, at seconds: Double) async throws -> Double {
let generator = AVAssetImageGenerator(asset: AVURLAsset(url: url))
generator.requestedTimeToleranceBefore = .zero
generator.requestedTimeToleranceAfter = .zero
let time = CMTime(seconds: seconds, preferredTimescale: 600)
let image = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<CGImage, Error>) in
generator.generateCGImagesAsynchronously(forTimes: [NSValue(time: time)]) { _, image, _, _, error in
if let image {
continuation.resume(returning: image)
} else {
continuation.resume(throwing: error ?? FactoryError.pixelBufferCreationFailed)
}
}
}
let side = 16
var pixels = [UInt8](repeating: 0, count: side * side)
let drawn = pixels.withUnsafeMutableBytes { buffer -> Bool in
guard let context = CGContext(
data: buffer.baseAddress,
width: side,
height: side,
bitsPerComponent: 8,
bytesPerRow: side,
space: CGColorSpaceCreateDeviceGray(),
bitmapInfo: CGImageAlphaInfo.none.rawValue
) else { return false }
context.draw(image, in: CGRect(x: 0, y: 0, width: side, height: side))
return true
}
guard drawn else { throw FactoryError.pixelBufferCreationFailed }
return Double(pixels.reduce(0) { $0 + Int($1) }) / Double(pixels.count * 255)
}
// MARK: - Audio

@ -9,8 +9,8 @@ options:
settings:
base:
SWIFT_VERSION: "5.9"
MARKETING_VERSION: "0.1.0"
CURRENT_PROJECT_VERSION: "1"
MARKETING_VERSION: "1.0.0"
CURRENT_PROJECT_VERSION: "2"
configs:
Debug:
# Ad-hoc-Signatur: laeuft lokal auf dem eigenen Rechner, braucht keine Team-ID.
@ -58,6 +58,9 @@ targets:
properties:
CFBundleName: drop.two
CFBundleDisplayName: drop.two
# Sonst schreibt XcodeGen feste Werte (1.0 / 1) — Sparkle vergleicht CFBundleVersion.
CFBundleShortVersionString: $(MARKETING_VERSION)
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
LSMinimumSystemVersion: "13.0"
LSUIElement: false # true = reiner Menu-Bar-Betrieb ohne Dock-Icon
NSHumanReadableCopyright: "(c) Samuel Zielke"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

@ -0,0 +1,192 @@
Kanit
=====
Copyright 2020 The Kanit Project Authors (https://github.com/cadsondemak/kanit)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Instrument Serif
================
Copyright 2022 The Instrument Serif Project Authors (https://github.com/Instrument/instrument-serif)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

@ -0,0 +1,92 @@
/* Kanit and Instrument Serif (SIL Open Font License 1.1), self-hosted:
no requests to Google. Latin and Latin Extended only. */
/* latin-ext */
@font-face {
font-family: 'Instrument Serif';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url(InstrumentSerif-400-italic-latin-ext.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Instrument Serif';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url(InstrumentSerif-400-italic-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Kanit';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(Kanit-300-latin-ext.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Kanit';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url(Kanit-300-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Kanit';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(Kanit-400-latin-ext.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Kanit';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(Kanit-400-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Kanit';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(Kanit-500-latin-ext.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Kanit';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(Kanit-500-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Kanit';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(Kanit-600-latin-ext.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Kanit';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(Kanit-600-latin.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@ -3,15 +3,38 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>drop.two</title>
<meta name="description" content="9:16 rein. 9:16 und 4:5 raus.">
<title>drop.two – 4:5-Fassung aus 9:16-Videos für den Mac</title>
<meta name="description" content="9:16 rein. 9:16 und 4:5 raus. drop.two schneidet aus fertigen Hochkant-Videos die 4:5-Fassung für Feed und Ads und legt beide sauber im Projektordner ab. Kostenlos für macOS.">
<link rel="canonical" href="https://droptwo.zielke.studio/">
<meta property="og:type" content="website">
<meta property="og:site_name" content="drop.two">
<meta property="og:locale" content="de_DE">
<meta property="og:url" content="https://droptwo.zielke.studio/">
<meta property="og:title" content="drop.two – 9:16 rein. 9:16 und 4:5 raus.">
<meta property="og:description" content="Fertige Hochkant-Videos reinziehen, 4:5-Fassung raus. Ausschnitt verschiebbar, auch pro Szene. Kostenlos für macOS.">
<meta property="og:image" content="https://droptwo.zielke.studio/og/og-de.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" type="image/svg+xml" href="favicon.svg">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Kanit:wght@300;400;500;600&family=Instrument+Serif:ital@1&display=swap" rel="stylesheet">
<link href="fonts/fonts.css" rel="stylesheet">
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "drop.two",
"operatingSystem": "macOS 13 oder neuer",
"applicationCategory": "MultimediaApplication",
"description": "Schneidet aus 9:16-Videos die 4:5-Fassung und legt beide Fassungen im Projektordner ab.",
"url": "https://droptwo.zielke.studio/",
"downloadUrl": "https://droptwo.zielke.studio/drop.two-latest.dmg",
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "EUR" },
"publisher": { "@type": "Organization", "name": "Zielke Studio", "url": "https://zielke.studio/" }
}
</script>
<!-- drop.two landing page, /var/www/updates/droptwo (droptwo.zielke.studio).
Source: drop.two repo, site/. Version from appcast.xml. -->
Source: drop.two repo, site/. Version from appcast.xml. Fonts are
self-hosted (fonts/), no requests to third parties. -->
<style>
:root {
--dark: #0B0B0C;
@ -24,14 +47,14 @@
--gold-hi: #E4C98F;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html { background: var(--dark); }
html { background: var(--dark); scroll-behavior: smooth; }
body {
color: var(--light);
font-family: "Kanit", "Poppins", -apple-system, sans-serif;
font-weight: 300;
line-height: 1.65;
-webkit-font-smoothing: antialiased;
padding: 0 16px 64px;
padding: 0 16px 48px;
background:
radial-gradient(38vw 30vw at 85% 12%, rgba(200, 169, 106, .16), transparent 70%),
radial-gradient(30vw 30vw at 0% 60%, rgba(200, 169, 106, .07), transparent 70%),
@ -39,12 +62,14 @@
background-repeat: no-repeat;
}
main { max-width: 1000px; margin: 0 auto; }
a { color: inherit; }
nav { display: flex; justify-content: space-between; align-items: center; padding: 26px 0; }
nav { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 26px 0; }
.brand { display: inline-flex; align-items: center; gap: 10px; font-weight: 500; font-size: 1.05rem; color: var(--light); text-decoration: none; }
.brand svg { width: 30px; height: 30px; }
nav a.back { font-size: .85rem; color: var(--gray); text-decoration: none; }
nav a.back:hover { color: var(--light); }
.links { display: flex; gap: 22px; align-items: center; }
.links a { font-size: .85rem; color: var(--gray); text-decoration: none; transition: color .15s ease; }
.links a:hover { color: var(--light); }
/* Hero */
.hero { display: grid; grid-template-columns: 1.05fr 1fr; gap: 48px; align-items: center; padding: 64px 0 88px; }
@ -61,6 +86,7 @@
}
@keyframes shimmer { 50% { background-position: 100% 0; } }
.lede { color: var(--mid); font-size: 1.08rem; max-width: 30em; margin-bottom: 34px; }
.cta-row { display: flex; flex-wrap: wrap; align-items: center; gap: 14px 22px; }
.download {
display: inline-flex; align-items: center; gap: 10px;
background: var(--gold); color: var(--dark); text-decoration: none; font-weight: 500;
@ -69,44 +95,51 @@
transition: transform .15s ease, box-shadow .15s ease, background .15s ease;
}
.download:hover { background: var(--gold-hi); transform: translateY(-2px); box-shadow: 0 14px 40px rgba(200, 169, 106, .38); }
.free { font-size: .88rem; color: var(--mid); }
.free b { color: var(--light); font-weight: 400; }
.version { font-size: .8rem; color: var(--gray); margin-top: 14px; min-height: 1.2em; }
/* Demo: 9:16 master, the 4:5 frame lands on it, the 4:5 cut slides out. */
/* Demo: 9:16 master, the 4:5 frame lands on it, moves to the subject, the 4:5 cut follows. */
.demo { display: flex; align-items: center; justify-content: center; gap: 26px; position: relative; }
.frame { position: relative; border-radius: 18px; overflow: hidden; background: #1a1a1d; }
.master { width: 176px; aspect-ratio: 9 / 16; border: 3px solid var(--light); }
.cut { width: 176px; aspect-ratio: 4 / 5; border: 3px solid var(--gold); box-shadow: 0 0 40px rgba(200, 169, 106, .35); animation: slide 5s ease-in-out infinite; }
.scene { position: absolute; inset: 0; }
.master .scene { background: linear-gradient(180deg, #2b2f45 0%, #7a5a48 45%, #d19b5b 62%, #2a221c 100%); }
/* The 4:5 cut shows the middle of the same scene (full width, centered). */
.cut .scene { inset: -21.1% 0; background: linear-gradient(180deg, #2b2f45 0%, #7a5a48 45%, #d19b5b 62%, #2a221c 100%); }
.sun { position: absolute; left: 50%; width: 34%; aspect-ratio: 1; border-radius: 50%; background: #ffd9a0; box-shadow: 0 0 40px #ffcf87; transform: translateX(-50%); animation: sun 5s ease-in-out infinite; }
.master .sun { top: 36%; }
.cut .sun { top: 36%; }
.cut { width: 176px; aspect-ratio: 4 / 5; border: 3px solid var(--gold); box-shadow: 0 0 40px rgba(200, 169, 106, .35); animation: slide 8s ease-in-out infinite; }
.scene { position: absolute; inset: 0; background: linear-gradient(180deg, #2b2f45 0%, #7a5a48 45%, #d19b5b 62%, #2a221c 100%); }
/* The 4:5 cut shows the same scene, shifted with the frame. */
.cut .scene { inset: -21.1% 0; animation: follow 8s ease-in-out infinite; }
.sun { position: absolute; left: 50%; top: 36%; width: 34%; aspect-ratio: 1; border-radius: 50%; background: #ffd9a0; box-shadow: 0 0 40px #ffcf87; transform: translateX(-50%); }
.person { position: absolute; bottom: 22%; left: 50%; width: 30%; height: 34%; transform: translateX(-50%); background: #151418; border-radius: 40% 40% 12% 12% / 30% 30% 10% 10%; }
.person::before { content: ""; position: absolute; top: -34%; left: 50%; width: 62%; aspect-ratio: 1; transform: translateX(-50%); background: #151418; border-radius: 50%; }
.crop {
position: absolute; left: 0; right: 0; top: 50%; aspect-ratio: 4 / 5; transform: translateY(-50%);
border: 3px solid var(--gold); border-radius: 12px;
box-shadow: 0 0 0 999px rgba(11, 11, 12, .55);
animation: crop 5s ease-in-out infinite;
animation: crop 8s ease-in-out infinite;
}
.label { position: absolute; bottom: -30px; left: 0; right: 0; text-align: center; font-size: .78rem; letter-spacing: .12em; color: var(--gray); }
.wrap-master, .wrap-cut { position: relative; }
.arrow { color: var(--gold); font-size: 1.6rem; animation: nudge 5s ease-in-out infinite; }
.arrow { color: var(--gold); font-size: 1.6rem; animation: nudge 8s ease-in-out infinite; }
/* Frame moves down by 12% of its own height (max. 21.1% fits), the cut
scene moves up by the same distance: 12% × 1.25 ÷ 1.778 ≈ 8.44% of its height. */
@keyframes crop {
0%, 12% { opacity: 0; transform: translateY(-50%) scale(1.25); }
26%, 88% { opacity: 1; transform: translateY(-50%) scale(1); }
0%, 8% { opacity: 0; transform: translateY(-50%) scale(1.25); }
18%, 40% { opacity: 1; transform: translateY(-50%) scale(1); }
52%, 80% { opacity: 1; transform: translateY(calc(-50% + 12%)) scale(1); }
92% { opacity: 1; transform: translateY(-50%) scale(1); }
100% { opacity: 0; transform: translateY(-50%) scale(1); }
}
@keyframes follow {
0%, 40% { transform: none; }
52%, 80% { transform: translateY(-8.44%); }
92%, 100% { transform: none; }
}
@keyframes slide {
0%, 30% { opacity: 0; transform: translateX(-40px) scale(.9); }
44%, 88% { opacity: 1; transform: translateX(0) scale(1); }
0%, 18% { opacity: 0; transform: translateX(-40px) scale(.9); }
28%, 90% { opacity: 1; transform: translateX(0) scale(1); }
100% { opacity: 0; transform: translateX(0) scale(1); }
}
@keyframes nudge { 0%, 30% { opacity: .2; transform: translateX(-6px); } 44%, 88% { opacity: 1; transform: none; } 100% { opacity: .2; } }
@keyframes sun { 50% { transform: translateX(-50%) translateY(-6%); } }
@keyframes nudge { 0%, 18% { opacity: .2; transform: translateX(-6px); } 28%, 90% { opacity: 1; transform: none; } 100% { opacity: .2; } }
/* Filename */
.file {
@ -123,33 +156,68 @@
.file .to { color: var(--gold); }
.file .out { display: grid; gap: 6px; color: var(--mid); }
.legend { margin-top: 16px; font-family: "Kanit", sans-serif; font-size: .85rem; color: var(--gray); }
.legend code { font-size: .8rem; color: var(--mid); }
/* Steps & facts */
section.how { padding: 88px 0 24px; }
h2 { font-size: 1.6rem; font-weight: 500; letter-spacing: -0.01em; margin-bottom: 26px; }
.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 56px; }
.step { padding: 24px; border-radius: 18px; background: var(--surface); border: 1px solid var(--rule); transition: border-color .15s ease, transform .15s ease; }
.step:hover { border-color: rgba(200, 169, 106, .5); transform: translateY(-2px); }
/* Sections */
section { padding: 88px 0 0; scroll-margin-top: 16px; }
h2 { font-size: 1.6rem; font-weight: 500; letter-spacing: -0.01em; margin-bottom: 8px; }
.sub { color: var(--mid); margin-bottom: 30px; max-width: 36em; }
.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
.step, .feature { padding: 24px; border-radius: 18px; background: var(--surface); border: 1px solid var(--rule); transition: border-color .15s ease, transform .15s ease; }
.step:hover, .feature:hover { border-color: rgba(200, 169, 106, .5); transform: translateY(-2px); }
.step .num { font-family: "Instrument Serif", Georgia, serif; font-style: italic; font-size: 2.4rem; line-height: 1; color: var(--gold); display: block; margin-bottom: 12px; }
.step h3 { font-weight: 500; font-size: 1.05rem; margin-bottom: 4px; }
.step p { color: var(--mid); font-size: .92rem; }
.facts { display: flex; flex-wrap: wrap; gap: 10px; }
.step h3, .feature h3 { font-weight: 500; font-size: 1.05rem; margin-bottom: 4px; }
.step p, .feature p { color: var(--mid); font-size: .92rem; }
.features { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
.feature { display: flex; gap: 16px; align-items: flex-start; }
.feature svg { width: 40px; height: 40px; flex: none; }
.facts { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 26px; }
.fact { padding: 8px 16px; border-radius: 999px; border: 1px solid var(--rule); color: var(--mid); font-size: .88rem; }
.fact b { color: var(--light); font-weight: 400; }
/* Info */
.info { max-width: 560px; margin: 0 auto; padding: 72px 0 0; }
.info h2 { margin-bottom: 24px; }
dl { display: grid; gap: 24px; }
dt { font-size: .74rem; font-weight: 500; letter-spacing: .12em; text-transform: uppercase; color: var(--gray); margin-bottom: 4px; }
dd { color: var(--mid); }
dd strong { color: var(--light); font-weight: 400; }
footer { margin-top: 56px; font-size: .78rem; color: var(--gray); }
/* FAQ */
.faq { max-width: 720px; }
details { border-bottom: 1px solid var(--rule); }
details:first-of-type { border-top: 1px solid var(--rule); }
summary {
list-style: none; cursor: pointer; padding: 18px 40px 18px 0; position: relative;
font-weight: 400; font-size: 1.02rem; transition: color .15s ease;
}
summary::-webkit-details-marker { display: none; }
summary::after {
content: "+"; position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
color: var(--gold); font-size: 1.4rem; font-weight: 300; transition: transform .2s ease;
}
details[open] summary::after { transform: translateY(-50%) rotate(45deg); }
summary:hover { color: var(--gold-hi); }
details p { color: var(--mid); padding: 0 40px 20px 0; font-size: .95rem; }
details p + p { margin-top: -8px; }
/* Closing */
.closing {
margin-top: 96px; padding: 48px 32px; border-radius: 24px; text-align: center;
background: radial-gradient(60% 120% at 50% 0%, rgba(200, 169, 106, .14), transparent 70%), var(--surface);
border: 1px solid var(--rule);
}
.closing h2 { margin-bottom: 10px; }
.closing p { color: var(--mid); margin-bottom: 26px; }
footer {
margin-top: 64px; padding-top: 24px; border-top: 1px solid var(--rule);
display: flex; flex-wrap: wrap; justify-content: space-between; gap: 10px 24px;
font-size: .82rem; color: var(--gray);
}
footer nav { padding: 0; gap: 18px; flex-wrap: wrap; justify-content: flex-start; }
footer a { color: var(--gray); text-decoration: none; }
footer a:hover { color: var(--light); }
@media (max-width: 800px) {
.hero { grid-template-columns: 1fr; gap: 64px; padding-top: 32px; }
.master, .cut { width: 132px; }
.steps { grid-template-columns: 1fr; }
.steps, .features { grid-template-columns: 1fr; }
.links a:not(.back) { display: none; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation: none !important; }
@ -167,19 +235,26 @@
</svg>
drop.two
</a>
<a class="back" href="https://apps.zielke.studio/">← Alle Apps</a>
<div class="links">
<a href="#funktionen">Funktionen</a>
<a href="#faq">FAQ</a>
<a class="back" href="https://apps.zielke.studio/">← Alle Apps</a>
</div>
</nav>
<section class="hero">
<div>
<p class="eyebrow">Beta · für macOS</p>
<p class="eyebrow">Kostenlos · für macOS</p>
<h1>9:16 rein. <span class="em">9:16 und 4:5 raus.</span></h1>
<p class="lede">Fertige Hochkant-Master reinziehen – drop.two schneidet die 4:5-Fassung sauber aus der Mitte und legt beide ordentlich im Projektordner ab. Keine Timeline, kein Export-Dialog.</p>
<a class="download" href="drop.two-latest.dmg">Für macOS laden <span aria-hidden="true">↓</span></a>
<p class="version" id="version"></p>
<p class="lede">Fertige Hochkant-Videos reinziehen – drop.two schneidet die 4:5-Fassung für Feed und Ads und legt beide Fassungen ordentlich im Projektordner ab. Ausschnitt mittig oder genau da, wo er hingehört. Keine Timeline, kein Export-Dialog.</p>
<div class="cta-row">
<a class="download" href="drop.two-latest.dmg">Für macOS laden <span aria-hidden="true">↓</span></a>
<span class="free"><b>Kostenlos</b> · ohne Konto · ohne Abo</span>
</div>
<p class="version" id="version">macOS 13 oder neuer · Apple Silicon &amp; Intel</p>
</div>
<div class="demo" aria-label="Animation: aus einem 9:16-Video entsteht ein 4:5-Ausschnitt">
<div class="demo" aria-label="Animation: aus einem 9:16-Video entsteht ein 4:5-Ausschnitt, der Rahmen lässt sich verschieben">
<div class="wrap-master">
<div class="frame master">
<div class="scene"><div class="sun"></div><div class="person"></div></div>
@ -206,47 +281,106 @@
<span><span class="seg project">SOMOS</span>/SOMOS-<span class="seg new">4x5</span>-v3-DE.mp4</span>
</span>
</div>
<p class="legend">Das Projekt steht im Dateinamen: <code>[Projekt]-[Format]-[Version]-[Sprache]</code>. drop.two liest von rechts, damit auch Projekte mit Bindestrich heil bleiben.</p>
<p class="legend">Das Projekt steht im Dateinamen, das Format ist der Anker: <code>9x16</code>, <code>9X16</code>, getrennt mit <code>-</code>, <code>_</code> oder Leerzeichen. Steht kein Format drin, fragt drop.two nach und hängt <code>4x5</code> auf Wunsch an.</p>
</div>
<section class="how">
<section id="ablauf">
<h2>So einfach geht's</h2>
<p class="sub">Drei Schritte, und der zweite ist optional.</p>
<div class="steps">
<div class="step"><span class="num">1</span><h3>Reinziehen</h3><p>Eine oder gleich viele 9:16-Dateien per Drag &amp; Drop – oder im Dialog auswählen.</p></div>
<div class="step"><span class="num">2</span><h3>Erkannt</h3><p>drop.two liest Projekt, Version und Sprache aus dem Namen und prüft das Seitenverhältnis.</p></div>
<div class="step"><span class="num">3</span><h3>Abgelegt</h3><p>Original und 4:5-Fassung landen zusammen im Projektordner. Fertig zum Weitergeben.</p></div>
</div>
<div class="facts">
<span class="fact"><b>ProRes</b> bleibt ProRes</span>
<span class="fact"><b>Ton</b> ohne Neukodierung</span>
<span class="fact"><b>Volle Breite</b>, mittig beschnitten</span>
<span class="fact"><b>1080×1920</b> → 1080×1350</span>
<span class="fact"><b>Fehler</b> werden gezeigt, nicht verschluckt</span>
<div class="step"><span class="num">1</span><h3>Reinziehen</h3><p>Eine oder gleich viele 9:16-Dateien per Drag &amp; Drop – oder im Dialog auswählen. drop.two zeigt sofort, was daraus wird.</p></div>
<div class="step"><span class="num">2</span><h3>Ausschnitt prüfen</h3><p>Der 4:5-Rahmen liegt über dem Video. Passt er nicht, ziehst du ihn einfach dahin, wo das Bild spielt.</p></div>
<div class="step"><span class="num">3</span><h3>Abgelegt</h3><p>Original und 4:5-Fassung landen zusammen im Projektordner. Fertig zum Hochladen.</p></div>
</div>
</section>
<section class="info">
<h2>Loslegen</h2>
<dl>
<div>
<dt>Installation</dt>
<dd>DMG öffnen, <strong>drop.two</strong> in den Programme-Ordner ziehen. Beim ersten Start kann macOS kurz prüfen – das dauert einen Moment und passiert nur einmal.</dd>
<section id="funktionen">
<h2>Was drop.two kann</h2>
<p class="sub">Ein Werkzeug für genau einen Handgriff – der dafür sitzt.</p>
<div class="features">
<div class="feature">
<svg viewBox="0 0 40 40" aria-hidden="true"><rect x="11" y="3" width="18" height="34" rx="3" fill="none" stroke="#F5F5F4" stroke-width="2"/><rect x="12" y="13" width="16" height="20" rx="2" fill="#C8A96A"/><path d="M34 15l2.5 3-2.5 3M34 25l2.5-3" fill="none" stroke="#C8A96A" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
<div><h3>Ausschnitt verschieben</h3>
<p>Rahmen in der Vorschau nach oben oder unten ziehen. Was du siehst, wird gerendert – pixelgenau.</p></div>
</div>
<div>
<dt>Updates</dt>
<dd>Kommen automatisch. Die App meldet sich, wenn eine neue Version bereitsteht. Diese Seite brauchst du danach nicht mehr.</dd>
<div class="feature">
<svg viewBox="0 0 40 40" aria-hidden="true"><path d="M4 26h32" stroke="#F5F5F4" stroke-opacity=".35" stroke-width="2.5" stroke-linecap="round"/><path d="M15 26h21" stroke="#C8A96A" stroke-width="2.5" stroke-linecap="round"/><rect x="2.5" y="22.5" width="7" height="7" transform="rotate(45 6 26)" fill="#C8A96A"/><rect x="11.5" y="22.5" width="7" height="7" transform="rotate(45 15 26)" fill="#E4C98F"/><rect x="25.5" y="10" width="2" height="16" rx="1" fill="#F5F5F4"/></svg>
<div><h3>Marken für Szenenwechsel</h3>
<p>Zwei Szenen, zwei Bildausschnitte? Marke setzen, Rahmen ziehen – ab dort gilt die neue Position. Harter Schnitt, kein Wackeln.</p></div>
</div>
<div>
<dt>Voraussetzung</dt>
<dd>macOS 13 oder neuer.</dd>
<div class="feature">
<svg viewBox="0 0 40 40" aria-hidden="true"><rect x="5" y="9" width="30" height="22" rx="4" fill="none" stroke="#F5F5F4" stroke-width="2"/><path d="M17 15.5v9l7.5-4.5z" fill="#C8A96A"/></svg>
<div><h3>Bereit für Feed und Ads</h3>
<p>MP4 mit H.264 und AAC – das Format, das Instagram, Facebook und Meta Ads ohne Umweg nehmen. Egal ob ProRes, H.264 oder HEVC reinkommt.</p></div>
</div>
<div>
<dt>Fehler gefunden?</dt>
<dd>Kurze Nachricht an Samuel oder an <a href="mailto:app-feedback@zielke.studio" style="color: var(--light)">app-feedback@zielke.studio</a> – am besten mit dem Dateinamen, bei dem es geklemmt hat.</dd>
<div class="feature">
<svg viewBox="0 0 40 40" aria-hidden="true"><path d="M5 12h12l3 3h15v17a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2z" fill="none" stroke="#F5F5F4" stroke-width="2" stroke-linejoin="round"/><rect x="13" y="20" width="6" height="10" rx="1" fill="none" stroke="#F5F5F4" stroke-width="1.5"/><rect x="22" y="21" width="7" height="8.5" rx="1" fill="#C8A96A"/></svg>
<div><h3>Ordnung von allein</h3>
<p>Projektname aus dem Dateinamen, Unterordner automatisch, Original unverändert daneben. Ganze Stapel auf einmal.</p></div>
</div>
</dl>
<footer>Interne Beta &middot; bitte nicht weitergeben</footer>
</div>
<div class="facts">
<span class="fact"><b>Volle Breite</b>, 1080×1920 → 1080×1350</span>
<span class="fact"><b>Framerate</b> und Dauer bleiben</span>
<span class="fact"><b>Lokal</b> auf deinem Mac, kein Upload</span>
<span class="fact"><b>Fehler</b> werden gezeigt, nicht verschluckt</span>
<span class="fact"><b>Updates</b> kommen automatisch</span>
</div>
</section>
<section id="faq" class="faq">
<h2>Häufige Fragen</h2>
<p class="sub">Kurz beantwortet. Fehlt etwas? Schreib an <a href="mailto:app-feedback@zielke.studio">app-feedback@zielke.studio</a>.</p>
<details>
<summary>Was kostet drop.two?</summary>
<p>Nichts. drop.two ist kostenlos, ohne Konto, ohne Abo und ohne Wasserzeichen.</p>
</details>
<details>
<summary>Welche Videos kann ich reinwerfen?</summary>
<p>Alles im Seitenverhältnis 9:16, was dein Mac abspielen kann – zum Beispiel ProRes, H.264 oder HEVC in .mov oder .mp4. Dateien in einem anderen Seitenverhältnis lehnt drop.two mit einem Hinweis ab, statt sie zu verzerren.</p>
</details>
<details>
<summary>In welchem Format kommt die 4:5-Fassung raus?</summary>
<p>Als MP4 mit H.264 (High Profile) und AAC-Ton mit 128 kbit/s. Die Breite bleibt, die Höhe wird auf 4:5 beschnitten; Framerate und Dauer bleiben unverändert. Die Bitrate passt drop.two an Auflösung und Framerate an – passend für Feed-Posts und Meta Ads.</p>
</details>
<details>
<summary>Und wenn der Ausschnitt nicht in der Mitte sitzen soll?</summary>
<p>Klick in der Liste auf das Ausschnitt-Symbol. In der Vorschau ziehst du den goldenen Rahmen dahin, wo er hingehört. Wechselt die Szene, spulst du an die Stelle und setzt eine Marke (Taste M) – ab dort gilt eine eigene Position.</p>
</details>
<details>
<summary>Muss ich meine Dateien umbenennen?</summary>
<p>Nein. drop.two erkennt das Format im Namen in vielen Schreibweisen, etwa <code>Projekt-9x16-v3-DE</code>, <code>Projekt_9X16_v3</code> oder <code>Projekt 9x16</code>, und ersetzt dort nur das Format durch 4x5. Steht kein Format im Namen, fragt die App nach und hängt „4x5“ auf Wunsch einfach an.</p>
</details>
<details>
<summary>Wird mein Original verändert oder hochgeladen?</summary>
<p>Weder noch. Das Original wird unverändert in den Projektordner kopiert, gerendert wird komplett auf deinem Mac. Die App schickt keine Videos und keine Nutzungsdaten irgendwohin; sie fragt nur nach, ob es eine neue Version gibt.</p>
</details>
<details>
<summary>Welchen Mac brauche ich?</summary>
<p>macOS 13 Ventura oder neuer, auf Apple Silicon und Intel. Gerendert wird hardwarebeschleunigt mit den Videobausteinen von macOS.</p>
</details>
<details>
<summary>Wie installiere und aktualisiere ich die App?</summary>
<p>DMG öffnen und drop.two in den Programme-Ordner ziehen. Die App ist von Apple notarisiert, macOS prüft sie beim ersten Start einmal kurz. Neue Versionen meldet die App selbst; alternativ im App-Menü „Nach Updates suchen …“.</p>
</details>
</section>
<div class="closing">
<h2>Nie wieder zweite Timeline.</h2>
<p>Laden, Video reinziehen, fertig. Kostenlos für macOS 13 oder neuer.</p>
<a class="download" href="drop.two-latest.dmg">Für macOS laden <span aria-hidden="true">↓</span></a>
</div>
<footer>
<span>&copy; Zielke Studio</span>
<nav>
<a href="#faq">FAQ</a>
<a href="mailto:app-feedback@zielke.studio">Feedback</a>
<a href="https://zielke.studio/">Impressum &amp; Datenschutz</a>
<a href="https://apps.zielke.studio/">Weitere Apps</a>
</nav>
</footer>
</main>
<script>
@ -259,9 +393,9 @@ fetch("appcast.xml")
if (!items.length) return;
var el = items[0].getElementsByTagName("sparkle:shortVersionString")[0]
|| items[0].getElementsByTagName("title")[0];
if (el) document.getElementById("version").textContent = "Version " + el.textContent.trim() + " · macOS 13+";
if (el) document.getElementById("version").textContent = "Version " + el.textContent.trim() + " · macOS 13+ · Apple Silicon & Intel";
})
.catch(function () { /* stay quiet, the link works anyway */ });
.catch(function () { /* stay quiet, the default line stays */ });
</script>
</body>
</html>

@ -0,0 +1,29 @@
<!DOCTYPE html>
<!-- Source of og-de.png (1200×630). Render: Chrome headless, see README. -->
<html lang="de"><head><meta charset="utf-8">
<link href="../fonts/fonts.css" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { width: 1200px; height: 630px; overflow: hidden; color: #F5F5F4; font-family: "Kanit", sans-serif; font-weight: 300;
background: radial-gradient(520px 420px at 88% 8%, rgba(200,169,106,.22), transparent 70%), #0B0B0C;
display: grid; grid-template-columns: 1fr 380px; align-items: center; padding: 0 90px; }
.brand { display: flex; align-items: center; gap: 14px; font-weight: 500; font-size: 30px; margin-bottom: 44px; }
.brand svg { width: 52px; height: 52px; }
h1 { font-size: 76px; font-weight: 500; letter-spacing: -0.03em; line-height: 1.02; }
.em { font-family: "Instrument Serif", serif; font-style: italic; font-weight: 400; color: #E4C98F; }
p { margin-top: 30px; font-size: 26px; color: #B8B5B0; }
.art { display: flex; align-items: center; gap: 26px; justify-content: flex-end; }
.master { width: 170px; aspect-ratio: 9/16; border: 4px solid #F5F5F4; border-radius: 20px; position: relative; overflow: hidden;
background: linear-gradient(180deg, #2b2f45 0%, #7a5a48 45%, #d19b5b 62%, #2a221c 100%); }
.crop { position: absolute; left: 0; right: 0; top: 50%; aspect-ratio: 4/5; transform: translateY(-50%); border: 4px solid #C8A96A; border-radius: 12px; box-shadow: 0 0 0 999px rgba(11,11,12,.55); }
.cut { width: 150px; aspect-ratio: 4/5; border-radius: 16px; background: #C8A96A; box-shadow: 0 0 60px rgba(200,169,106,.45); }
.arrow { color: #C8A96A; font-size: 40px; }
</style></head>
<body>
<div>
<div class="brand"><svg viewBox="0 0 1024 1024"><rect x="310" y="152" width="404" height="718" rx="44" fill="none" stroke="#F5F5F4" stroke-width="44"/><rect x="328" y="281" width="368" height="460" rx="26" fill="#C8A96A"/></svg>drop.two</div>
<h1>9:16 rein.<br><span class="em">9:16 und 4:5 raus.</span></h1>
<p>Kostenlos für macOS</p>
</div>
<div class="art"><div class="master"><div class="crop"></div></div><span class="arrow">→</span><div class="cut"></div></div>
</body></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Loading…
Cancel
Save

Powered by TurnKey Linux.