diff --git a/.gitignore b/.gitignore index 716fb3f..9ae4126 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,8 @@ src/Package.resolved dist/ # Vendor binaries (fetched via scripts/fetch-deps.sh) -vendor/ +vendor/* +!vendor/hasselblad_x2d_header.3fr # Sources dnglab clonées au build (scripts/build-dnglab.sh) .build-dnglab/ diff --git a/README.md b/README.md index 272ff0b..fe4461d 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,12 @@ # 2FujiRaw -App macOS qui convertit des RAW **Hasselblad X2D II 100C** en DNG "maquillés" en -**Fuji GFX 100S II**, pour débloquer les **Film Simulations Fuji natives** -(Provia, Velvia, Astia, Classic Chrome, Classic Neg, Eterna, Acros, Pro Neg Hi/Std, -Nostalgic Neg, Reala Ace…) dans Lightroom Classic sur des fichiers qui ne viennent -pas d'un boîtier Fuji. +App macOS qui convertit des RAW non-Fuji en DNG ou 3FR compatibles avec les +pipelines Fuji et Hasselblad actuellement supportés. + +Mappings disponibles : +- `Hasselblad X2D II 100C → Fuji GFX 100S II` +- `Leica DNG → Hasselblad X2D` +- `Leica DNG → Fuji GFX 100S II` --- @@ -43,20 +45,35 @@ sur des données **Bayer** → artefacts garantis. ## Comment ça marche ``` -.3FR Hasselblad +Hasselblad X2D / X2D II .3FR + │ + ▼ [1] dnglab convert +.dng Hasselblad │ - ▼ [1] dnglab convert (patché pour X2D II 100C) -.dng (Make="Hasselblad", Model="X2D II 100C") + ▼ [2] exiftool : spoof Fuji +.dng Fuji GFX 100S II + +Leica .DNG │ - ▼ [2] exiftool : spoof des tags + marquage preview DNG 1.4 -.dng (Make="FUJIFILM", Model="GFX 100S II", preview marqué valide) + ▼ [1] writer Swift natif : Leica → X2D .3FR +.3fr Hasselblad X2D │ - ▼ [3] import dans Lightroom Classic -Profils Fuji natifs disponibles (onglet "Camera Matching") + ├── sortie directe Hasselblad + │ + ▼ [2] dnglab convert + spoof Fuji +.dng Fuji GFX 100S II ``` -Les binaires `dnglab` (patché) et `exiftool` sont bundlés dans le `.app` et -invoqués en tant que sous-process. +Les binaires `dnglab` et `exiftool` sont bundlés dans le `.app` et invoqués en +tant que sous-process. La chaîne Leica → X2D est native Swift. + +### Template X2D bundlé + +Les conversions Leica n'ont plus besoin d'un donor `.3FR` externe. L'app bundle +un template X2D tronqué à son en-tête utile +(`Contents/Resources/templates/hasselblad_x2d_header.3fr`, 16 Kio) et l'utilise +par défaut. L'option `--donor` côté CLI, et le sélecteur donor dans l'UI, +servent uniquement d'override. ### Pourquoi un dnglab patché @@ -108,12 +125,23 @@ Prérequis : ./scripts/build.sh # 3. Tester depuis le CLI sans GUI -./src/.build/release/ToFujiRaw --cli /path/to/mon_fichier.3FR +./src/.build/release/ToFujiRaw --cli --mapping hassy-x2d2-to-fuji-gfx100s2 /path/to/mon_fichier.3FR +./src/.build/release/ToFujiRaw --cli --mapping leica-dng-to-hasselblad-x2d2 /path/to/mon_fichier.DNG +./src/.build/release/ToFujiRaw --cli --mapping leica-dng-to-fuji-gfx100s2 /path/to/mon_fichier.DNG # 4. Générer le .dmg final ./scripts/make-dmg.sh ``` +Le flag `--donor` reste disponible si vous voulez override le template X2D bundlé : + +```bash +./src/.build/release/ToFujiRaw --cli \ + --mapping leica-dng-to-hasselblad-x2d2 \ + --donor /path/to/custom_donor.3FR \ + /path/to/mon_fichier.DNG +``` + ## Licence 2FujiRaw est distribué sous **GPL-3.0-or-later** (cf [`LICENSE`](./LICENSE)). diff --git a/scripts/build-dnglab.sh b/scripts/build-dnglab.sh index 09635af..5915339 100755 --- a/scripts/build-dnglab.sh +++ b/scripts/build-dnglab.sh @@ -47,6 +47,16 @@ else echo "Patch : déjà appliqué, skip." fi +# 2b. Compat cargo : certains manifests du tag v0.7.2 demandent edition=2024, +# mais le cargo installé sur cette machine est plus ancien. On downgrade +# localement vers edition=2021 pour permettre le build. +echo "Patch : downgrade édition Cargo 2024 -> 2021..." +find "$BUILD_DIR" -name Cargo.toml -print0 | while IFS= read -r -d '' manifest; do + if grep -q 'edition = "2024"' "$manifest"; then + sed -i '' 's/edition = "2024"/edition = "2021"/g' "$manifest" + fi +done + # 3. Build release echo "Compilation dnglab (cargo release)..." (cd "$BUILD_DIR" && cargo build --release --bin dnglab) diff --git a/scripts/build.sh b/scripts/build.sh index 2dd584a..3436e66 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -16,10 +16,16 @@ swift build -c release --arch arm64 rm -rf "$APP" mkdir -p "$APP/Contents/MacOS" mkdir -p "$APP/Contents/Resources/bin" +mkdir -p "$APP/Contents/Resources/templates" cp "$ROOT/src/.build/release/ToFujiRaw" "$APP/Contents/MacOS/2FujiRaw" cp "$ROOT/vendor/dnglab" "$APP/Contents/Resources/bin/dnglab" cp -R "$ROOT/vendor/exiftool" "$APP/Contents/Resources/bin/exiftool" +cp "$ROOT/vendor/hasselblad_x2d_header.3fr" "$APP/Contents/Resources/templates/hasselblad_x2d_header.3fr" + +# Les outils copiés depuis Dropbox/téléchargements arrivent souvent avec +# `com.apple.quarantine`, ce qui bloque leur exécution depuis l'app. +xattr -dr com.apple.quarantine "$APP" || true # 4. Écrire Info.plist cat > "$APP/Contents/Info.plist" <<'PLIST' diff --git a/src/Sources/ToFujiRaw/BundledTools.swift b/src/Sources/ToFujiRaw/BundledTools.swift index 2bad161..fbd6e90 100644 --- a/src/Sources/ToFujiRaw/BundledTools.swift +++ b/src/Sources/ToFujiRaw/BundledTools.swift @@ -13,16 +13,39 @@ enum BundledTools { /// avec un dossier `lib/` à côté. On pointe vers le script directement. static var exiftool: URL { resolve(bundleRelative: "bin/exiftool/exiftool", devRelative: "exiftool/exiftool") } + /// Template donor X2D tronqué à l'en-tête utile. + static var embeddedX2DDonorTemplate: URL { + resolveResource( + bundleRelative: "templates/hasselblad_x2d_header.3fr", + devRelative: "hasselblad_x2d_header.3fr" + ) + } + + static var hasEmbeddedX2DDonorTemplate: Bool { + FileManager.default.fileExists(atPath: embeddedX2DDonorTemplate.path) + } + static func verifyAll() throws { let fm = FileManager.default guard fm.isExecutableFile(atPath: dnglab.path) else { throw ToolError.missing("dnglab", path: dnglab.path) } + try verifyExiftool() + } + + static func verifyExiftool() throws { + let fm = FileManager.default guard fm.isExecutableFile(atPath: exiftool.path) else { throw ToolError.missing("exiftool", path: exiftool.path) } } + static func verifyEmbeddedX2DDonorTemplate() throws { + guard hasEmbeddedX2DDonorTemplate else { + throw ToolError.missing("embedded X2D donor template", path: embeddedX2DDonorTemplate.path) + } + } + /// Cherche le binaire d'abord dans `Bundle.main.resourceURL`, sinon fallback /// sur un `vendor/` dérivé du cwd (dev) ou du path de l'exécutable. private static func resolve(bundleRelative: String, devRelative: String) -> URL { @@ -44,6 +67,23 @@ enum BundledTools { .appendingPathComponent(bundleRelative) } + private static func resolveResource(bundleRelative: String, devRelative: String) -> URL { + if let res = Bundle.main.resourceURL { + let bundled = res.appendingPathComponent(bundleRelative) + if FileManager.default.fileExists(atPath: bundled.path) { + return bundled + } + } + for base in devSearchRoots() { + let candidate = base.appendingPathComponent("vendor").appendingPathComponent(devRelative) + if FileManager.default.fileExists(atPath: candidate.path) { + return candidate + } + } + return (Bundle.main.resourceURL ?? URL(fileURLWithPath: "/")) + .appendingPathComponent(bundleRelative) + } + private static func devSearchRoots() -> [URL] { var roots: [URL] = [] let fm = FileManager.default diff --git a/src/Sources/ToFujiRaw/CLI.swift b/src/Sources/ToFujiRaw/CLI.swift index b2ec806..88f97cc 100644 --- a/src/Sources/ToFujiRaw/CLI.swift +++ b/src/Sources/ToFujiRaw/CLI.swift @@ -11,12 +11,18 @@ enum CLI { args.removeAll { $0 == "--cli" } var mappingID: String? = nil + var donorPath: String? = nil + var preserveOriginalLeicaBodyInfo = false var inputs: [String] = [] var it = args.makeIterator() while let arg = it.next() { switch arg { case "--mapping": mappingID = it.next() + case "--donor": + donorPath = it.next() + case "--preserve-leica-body-info": + preserveOriginalLeicaBodyInfo = true case "--help", "-h": printUsage() exit(0) @@ -38,10 +44,20 @@ enum CLI { } let urls = inputs.map { URL(fileURLWithPath: $0) } - let engine = ConversionEngine(mapping: mapping) + let donorURL = donorPath.map { URL(fileURLWithPath: $0) } + let engine = ConversionEngine( + mapping: mapping, + donorURL: donorURL, + options: ConversionOptions( + preserveOriginalLeicaBodyInfo: preserveOriginalLeicaBodyInfo + ) + ) print("2FujiRaw CLI — mapping: \(mapping.label)") print("→ \(urls.count) fichier(s) à convertir") + if let donorURL { + print("→ donor: \(donorURL.path)") + } let sem = DispatchSemaphore(value: 0) var exitCode: Int32 = 0 @@ -65,10 +81,13 @@ enum CLI { private static func printUsage() { let usage = """ - Usage : ToFujiRaw --cli [--mapping ] [ ...] + Usage : ToFujiRaw --cli [--mapping ] [--donor ] [ ...] Options : --mapping ID de mapping (défaut : \(CameraMapping.default.id)) + --donor Override le template X2D bundlé pour les mappings Leica + --preserve-leica-body-info + Remplace uniquement la chaîne Model par le modèle Leica source --help, -h Affiche cette aide Mappings disponibles : diff --git a/src/Sources/ToFujiRaw/CameraMapping.swift b/src/Sources/ToFujiRaw/CameraMapping.swift index 724adf8..497c638 100644 --- a/src/Sources/ToFujiRaw/CameraMapping.swift +++ b/src/Sources/ToFujiRaw/CameraMapping.swift @@ -1,26 +1,74 @@ import Foundation +enum ConversionPipeline: String, Hashable { + case nativeHasselbladToFuji + case leicaViaHasselbladToFuji + case nativeLeicaToHasselblad +} + struct CameraMapping: Identifiable, Hashable { let id: String let label: String - let sourceExtensions: [String] // ex ["3FR", "FFF"] - let targetMake: String - let targetModel: String - let targetUniqueCameraModel: String + let sourceExtensions: [String] + let outputExtension: String + let outputDirectoryName: String + let pipeline: ConversionPipeline + let targetMake: String? + let targetModel: String? + let targetUniqueCameraModel: String? + let requiresDonor: Bool + let donorLabel: String? static let all: [CameraMapping] = [ CameraMapping( id: "hassy-x2d2-to-fuji-gfx100s2", label: "Hasselblad X2D II → Fuji GFX 100S II", sourceExtensions: ["3FR", "FFF"], + outputExtension: "dng", + outputDirectoryName: "DNG-Fuji-Converted", + pipeline: .nativeHasselbladToFuji, targetMake: "FUJIFILM", targetModel: "GFX 100S II", - targetUniqueCameraModel: "Fujifilm GFX 100S II" + targetUniqueCameraModel: "Fujifilm GFX 100S II", + requiresDonor: false, + donorLabel: nil + ), + CameraMapping( + id: "leica-dng-to-fuji-gfx100s2", + label: "Leica DNG → Fuji GFX 100S II", + sourceExtensions: ["DNG"], + outputExtension: "dng", + outputDirectoryName: "DNG-Fuji-Converted", + pipeline: .leicaViaHasselbladToFuji, + targetMake: "FUJIFILM", + targetModel: "GFX 100S II", + targetUniqueCameraModel: "Fujifilm GFX 100S II", + requiresDonor: true, + donorLabel: "X2D Template" + ), + CameraMapping( + id: "leica-dng-to-hasselblad-x2d2", + label: "Leica DNG → Hasselblad X2D", + sourceExtensions: ["DNG"], + outputExtension: "3fr", + outputDirectoryName: "3FR-Hasselblad-Converted", + pipeline: .nativeLeicaToHasselblad, + targetMake: nil, + targetModel: nil, + targetUniqueCameraModel: nil, + requiresDonor: true, + donorLabel: "X2D Template" ), - // Futurs mappings : - // .init(id: "ricoh-griii-to-fuji-xh2s", label: "Ricoh GR III → Fuji X-H2S", ...) - // .init(id: "leica-q3-to-fuji-gfx100ii", label: "Leica Q3 → Fuji GFX 100 II", ...) ] static var `default`: CameraMapping { all[0] } + + var hasLeicaSource: Bool { + switch pipeline { + case .leicaViaHasselbladToFuji, .nativeLeicaToHasselblad: + return true + case .nativeHasselbladToFuji: + return false + } + } } diff --git a/src/Sources/ToFujiRaw/ContentView.swift b/src/Sources/ToFujiRaw/ContentView.swift index 593df25..9a49854 100644 --- a/src/Sources/ToFujiRaw/ContentView.swift +++ b/src/Sources/ToFujiRaw/ContentView.swift @@ -5,12 +5,24 @@ import AppKit struct ContentView: View { @State private var files: [URL] = [] @State private var mapping: CameraMapping = .default + @State private var donorFile: URL? + @State private var preserveOriginalLeicaBodyInfo = false @State private var isConverting = false - @State private var progress: (done: Int, total: Int) = (0, 0) + @State private var progress = ConversionProgress( + processed: 0, + total: 0, + currentPhase: nil, + currentFileFraction: 0, + lastOutput: nil + ) @State private var errorMessage: String? @State private var lastOutputDir: URL? @State private var totalConverted: Int = 0 + private var hasBundledDonorTemplate: Bool { + BundledTools.hasEmbeddedX2DDonorTemplate + } + var body: some View { ZStack { Theme.background.ignoresSafeArea() @@ -34,6 +46,22 @@ struct ContentView: View { .padding(.horizontal, 22) .padding(.vertical, 14) + if mapping.requiresDonor { + divider + + donorSection + .padding(.horizontal, 22) + .padding(.vertical, 14) + } + + if mapping.hasLeicaSource { + divider + + preserveBodyInfoSection + .padding(.horizontal, 22) + .padding(.vertical, 14) + } + divider actionSection @@ -54,7 +82,7 @@ struct ContentView: View { .padding(.vertical, 12) } } - .frame(width: 540, height: 560) + .frame(width: 540, height: 620) } // MARK: - Separator @@ -74,7 +102,7 @@ struct ContentView: View { .font(Theme.monoLarge) .foregroundStyle(Theme.magenta) .tracking(1) - Text("HASSELBLAD → FUJI LOOK // LIGHTROOM PROFILES") + Text("RAW CAMERA SPOOFER // FUJI + HASSELBLAD TARGETS") .font(Theme.monoCaption) .foregroundStyle(Theme.inkSoft) } @@ -124,19 +152,77 @@ struct ContentView: View { } } + private var donorSection: some View { + HStack(spacing: 12) { + Text(mapping.donorLabel?.uppercased() ?? "DONOR") + .font(Theme.monoCaption) + .foregroundStyle(Theme.inkSoft) + .frame(width: 70, alignment: .leading) + + Button(donorButtonLabel) { + pickDonor() + } + .buttonStyle(RetroButtonStyle( + color: Theme.cream, + textColor: Theme.ink, + isEnabled: !isConverting)) + .disabled(isConverting) + + if donorFile != nil || hasBundledDonorTemplate { + Button(donorFile == nil ? "RESET" : "CLEAR") { + donorFile = nil + } + .buttonStyle(RetroButtonStyle( + color: Theme.cream, + textColor: Theme.ink, + isEnabled: !isConverting)) + .disabled(isConverting) + } + + Spacer() + } + } + + private var preserveBodyInfoSection: some View { + HStack(spacing: 12) { + Text("METADATA") + .font(Theme.monoCaption) + .foregroundStyle(Theme.inkSoft) + .frame(width: 70, alignment: .leading) + + Toggle(isOn: $preserveOriginalLeicaBodyInfo) { + VStack(alignment: .leading, spacing: 2) { + Text("PRESERVE ORIGINAL LEICA BODY NAME") + .font(Theme.monoBody) + .foregroundStyle(Theme.ink) + Text("Replace only the displayed camera model string") + .font(Theme.monoCaption) + .foregroundStyle(Theme.inkSoft) + } + } + .toggleStyle(.checkbox) + .disabled(isConverting) + + Spacer() + } + } + // MARK: - Action section (bouton + progression) private var actionSection: some View { Group { if isConverting { VStack(alignment: .leading, spacing: 10) { - SegmentedProgressBar(done: progress.done, total: progress.total) + SegmentedProgressBar( + done: Int(progress.overallFraction * 1000), + total: 1000 + ) HStack { - Text("CONVERTING \(progress.done) / \(progress.total)") + Text(progressLabel) .font(Theme.monoCaption) .foregroundStyle(Theme.inkSoft) Spacer() - Text("PLEASE WAIT…") + Text(progress.currentPhase ?? "PLEASE WAIT…") .font(Theme.monoCaption) .foregroundStyle(Theme.magenta) } @@ -149,8 +235,8 @@ struct ContentView: View { .buttonStyle(RetroButtonStyle( color: Theme.magenta, textColor: .white, - isEnabled: !files.isEmpty)) - .disabled(files.isEmpty) + isEnabled: canConvert)) + .disabled(!canConvert) if let dir = lastOutputDir { Button(action: { NSWorkspace.shared.activateFileViewerSelecting([dir]) }) { @@ -165,7 +251,7 @@ struct ContentView: View { Spacer() if !files.isEmpty { - Text("\(files.count) FILE\(files.count > 1 ? "S" : "")") + Text(statusSummary) .font(Theme.monoCaption) .foregroundStyle(Theme.inkSoft) } @@ -206,7 +292,7 @@ struct ContentView: View { Spacer() - Text("dnglab + exiftool") + Text("swift + exiftool + dnglab") .font(Theme.monoCaption) .foregroundStyle(Theme.inkSoft) } @@ -214,16 +300,70 @@ struct ContentView: View { // MARK: - Logic + private var canConvert: Bool { + !files.isEmpty && (!mapping.requiresDonor || donorFile != nil || hasBundledDonorTemplate) + } + + private var statusSummary: String { + if mapping.requiresDonor && donorFile == nil && !hasBundledDonorTemplate { + return "\(files.count) FILE\(files.count > 1 ? "S" : "") · X2D TEMPLATE NEEDED" + } + if mapping.requiresDonor && donorFile == nil && hasBundledDonorTemplate { + return "\(files.count) FILE\(files.count > 1 ? "S" : "") · USING BUNDLED X2D TEMPLATE" + } + return "\(files.count) FILE\(files.count > 1 ? "S" : "") READY" + } + + private var donorButtonLabel: String { + if let donorFile { + return donorFile.lastPathComponent + } + if hasBundledDonorTemplate { + return "BUNDLED X2D TEMPLATE" + } + return "SELECT TEMPLATE OVERRIDE" + } + + private var progressLabel: String { + guard progress.total > 0 else { return "CONVERTING" } + let percent = Int((progress.overallFraction * 100).rounded()) + let activeFile = min(progress.processed + 1, progress.total) + return "FILE \(activeFile) / \(progress.total) · \(percent)%" + } + + private func pickDonor() { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowedContentTypes = [.item] + if panel.runModal() == .OK { + donorFile = panel.url + } + } + func runConversion() async { errorMessage = nil isConverting = true - progress = (0, files.count) - - let engine = ConversionEngine(mapping: mapping) + progress = ConversionProgress( + processed: 0, + total: files.count, + currentPhase: "STARTING", + currentFileFraction: 0, + lastOutput: nil + ) + + let engine = ConversionEngine( + mapping: mapping, + donorURL: donorFile, + options: ConversionOptions( + preserveOriginalLeicaBodyInfo: preserveOriginalLeicaBodyInfo + ) + ) let batchCount = files.count do { for try await p in engine.convertBatch(files) { - progress = (p.processed, p.total) + progress = p if let out = p.lastOutput { lastOutputDir = out.deletingLastPathComponent() } } totalConverted += batchCount diff --git a/src/Sources/ToFujiRaw/ConversionEngine.swift b/src/Sources/ToFujiRaw/ConversionEngine.swift index 51b1271..7710c47 100644 --- a/src/Sources/ToFujiRaw/ConversionEngine.swift +++ b/src/Sources/ToFujiRaw/ConversionEngine.swift @@ -3,11 +3,19 @@ import Foundation struct ConversionProgress { let processed: Int let total: Int + let currentPhase: String? + let currentFileFraction: Double let lastOutput: URL? + + var overallFraction: Double { + guard total > 0 else { return 0 } + return min(1, (Double(processed) + currentFileFraction) / Double(total)) + } } enum ConversionError: LocalizedError { case tools(ToolError) + case missingDonor case dngConvertFailed(source: URL, stderr: String) case spoofFailed(dng: URL, stderr: String) case outputDirectory(URL, underlying: Error) @@ -16,6 +24,8 @@ enum ConversionError: LocalizedError { switch self { case .tools(let e): return e.errorDescription + case .missingDonor: + return "Le template donor X2D est introuvable." case .dngConvertFailed(let src, let stderr): return "Échec conversion DNG pour \(src.lastPathComponent) : \(stderr)" case .spoofFailed(let dng, let stderr): @@ -28,14 +38,28 @@ enum ConversionError: LocalizedError { struct ConversionEngine { let mapping: CameraMapping + let donorURL: URL? + let options: ConversionOptions + enum OverwriteStrategy { case skip, overwrite, suffix } var overwriteStrategy: OverwriteStrategy = .suffix - /// Convertit un RAW source en DNG spoofé. - /// - Sortie: /DNG-Fuji-Converted/.dng - func convert(_ source: URL) async throws -> URL { + init(mapping: CameraMapping, donorURL: URL?, options: ConversionOptions = .default) { + self.mapping = mapping + self.donorURL = donorURL + self.options = options + } + + private var effectiveDonorURL: URL? { + donorURL ?? (BundledTools.hasEmbeddedX2DDonorTemplate ? BundledTools.embeddedX2DDonorTemplate : nil) + } + + func convert( + _ source: URL, + progressHandler: ((String, Double) -> Void)? = nil + ) async throws -> URL { let outputDir = source.deletingLastPathComponent() - .appendingPathComponent("DNG-Fuji-Converted", isDirectory: true) + .appendingPathComponent(mapping.outputDirectoryName, isDirectory: true) do { try FileManager.default.createDirectory( @@ -44,8 +68,82 @@ struct ConversionEngine { throw ConversionError.outputDirectory(outputDir, underlying: error) } + let outputFile = try nextOutputFile(for: source, in: outputDir) + + switch mapping.pipeline { + case .nativeHasselbladToFuji: + return try convertNativeHasselbladToFuji( + source: source, + outputFile: outputFile, + progressHandler: progressHandler + ) + case .leicaViaHasselbladToFuji: + guard let donorURL = effectiveDonorURL else { throw ConversionError.missingDonor } + return try convertLeicaViaHasselbladToFuji( + source: source, + donorURL: donorURL, + outputFile: outputFile, + progressHandler: progressHandler + ) + case .nativeLeicaToHasselblad: + guard let donorURL = effectiveDonorURL else { throw ConversionError.missingDonor } + return try convertNativeLeicaToHasselblad( + source: source, + donorURL: donorURL, + outputFile: outputFile, + progressHandler: progressHandler + ) + } + } + + func convertBatch(_ sources: [URL]) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + Task { + do { + try verifyRequiredTools() + for (index, source) in sources.enumerated() { + let output = try await convert(source) { phase, fraction in + continuation.yield(ConversionProgress( + processed: index, + total: sources.count, + currentPhase: phase, + currentFileFraction: max(0, min(1, fraction)), + lastOutput: nil + )) + } + continuation.yield(ConversionProgress( + processed: index + 1, + total: sources.count, + currentPhase: "DONE", + currentFileFraction: 0, + lastOutput: output + )) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + } + } + + private func verifyRequiredTools() throws { + switch mapping.pipeline { + case .nativeHasselbladToFuji: + try BundledTools.verifyAll() + case .leicaViaHasselbladToFuji: + try BundledTools.verifyAll() + try BundledTools.verifyEmbeddedX2DDonorTemplate() + case .nativeLeicaToHasselblad: + try BundledTools.verifyExiftool() + try BundledTools.verifyEmbeddedX2DDonorTemplate() + } + } + + private func nextOutputFile(for source: URL, in outputDir: URL) throws -> URL { let baseName = source.deletingPathExtension().lastPathComponent - var outputFile = outputDir.appendingPathComponent("\(baseName).dng") + let ext = mapping.outputExtension + var outputFile = outputDir.appendingPathComponent("\(baseName).\(ext)") if FileManager.default.fileExists(atPath: outputFile.path) { switch overwriteStrategy { @@ -54,90 +152,147 @@ struct ConversionEngine { case .overwrite: try? FileManager.default.removeItem(at: outputFile) case .suffix: - var i = 1 + var index = 1 repeat { - outputFile = outputDir.appendingPathComponent("\(baseName)_spoofed\(i == 1 ? "" : "-\(i)").dng") - i += 1 + outputFile = outputDir.appendingPathComponent( + "\(baseName)_converted\(index == 1 ? "" : "-\(index)").\(ext)" + ) + index += 1 } while FileManager.default.fileExists(atPath: outputFile.path) } } - // 1. dnglab convert - let dngArgs = [ + return outputFile + } + + private func convertNativeHasselbladToFuji( + source: URL, + outputFile: URL, + progressHandler: ((String, Double) -> Void)? + ) throws -> URL { + progressHandler?("CONVERTING WITH DNGLAB", 0.15) + let dng = try run(BundledTools.dnglab, args: [ "convert", "--compression", "lossless", source.path, outputFile.path, - ] - let dng = try run(BundledTools.dnglab, args: dngArgs) + ]) if dng.exitCode != 0 { throw ConversionError.dngConvertFailed( source: source, - stderr: dng.stderr.isEmpty ? dng.stdout : dng.stderr) + stderr: dng.stderr.isEmpty ? dng.stdout : dng.stderr + ) + } + progressHandler?("PATCHING FUJI TAGS", 0.8) + let result = try spoofDNGIdentity(at: outputFile) + progressHandler?("FINALIZING", 1.0) + return result + } + + private func convertLeicaViaHasselbladToFuji( + source: URL, + donorURL: URL, + outputFile: URL, + progressHandler: ((String, Double) -> Void)? + ) throws -> URL { + let sourceMetadata: LeicaSourceMetadata? + if options.preserveOriginalLeicaBodyInfo { + do { + sourceMetadata = try LeicaSourceMetadataExtractor.extract(from: source, exiftoolURL: BundledTools.exiftool) + } catch { + FileHandle.standardError.write( + Data("Warning: impossible d'extraire les métadonnées Leica pour \(source.lastPathComponent): \(error.localizedDescription)\n".utf8) + ) + sourceMetadata = nil + } + } else { + sourceMetadata = nil + } + let tempRoot = FileManager.default.temporaryDirectory + .appendingPathComponent("2FujiRaw-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempRoot, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempRoot) } + + let intermediate3FR = tempRoot.appendingPathComponent("\(source.deletingPathExtension().lastPathComponent).3fr") + progressHandler?("BUILDING X2D 3FR", 0.02) + try LeicaX2DWriter.write( + sourceURL: source, + donorURL: donorURL, + outputURL: intermediate3FR, + progressHandler: { phase, fraction in + progressHandler?(phase, min(0.72, fraction * 0.72)) + }, + options: .default + ) + + progressHandler?("CONVERTING 3FR WITH DNGLAB", 0.8) + let dng = try run(BundledTools.dnglab, args: [ + "convert", + "--compression", "lossless", + intermediate3FR.path, + outputFile.path, + ]) + if dng.exitCode != 0 { + throw ConversionError.dngConvertFailed( + source: intermediate3FR, + stderr: dng.stderr.isEmpty ? dng.stdout : dng.stderr + ) } - // 2. exiftool : - // - Spoof Make / Model / UniqueCameraModel pour faire passer le DNG - // pour un Fuji aux yeux de LrC - // - Marquer le preview comme "valide" via les tags DNG 1.4 : - // sans PreviewApplicationName/Version/DateTime, LrC tente de - // régénérer le preview à chaque import et reste bloqué sur - // un carré gris. Avec ces tags, il accepte le preview bundlé - // par dnglab. - let exifArgs = [ - "-Make=\(mapping.targetMake)", - "-Model=\(mapping.targetModel)", - "-UniqueCameraModel=\(mapping.targetUniqueCameraModel)", + progressHandler?("PATCHING FUJI TAGS", 0.92) + let result = try spoofDNGIdentity( + at: outputFile, + sourceLeicaModel: sourceMetadata?.model + ) + progressHandler?("FINALIZING", 1.0) + return result + } + + private func convertNativeLeicaToHasselblad( + source: URL, + donorURL: URL, + outputFile: URL, + progressHandler: ((String, Double) -> Void)? + ) throws -> URL { + try LeicaX2DWriter.write( + sourceURL: source, + donorURL: donorURL, + outputURL: outputFile, + progressHandler: progressHandler, + options: options + ) + return outputFile + } + + private func spoofDNGIdentity( + at outputFile: URL, + sourceLeicaModel: String? = nil + ) throws -> URL { + var args = [ + "-Make=\(mapping.targetMake ?? "FUJIFILM")", + "-Model=\((options.preserveOriginalLeicaBodyInfo ? sourceLeicaModel : nil) ?? (mapping.targetModel ?? "GFX 100S II"))", + "-UniqueCameraModel=\(mapping.targetUniqueCameraModel ?? "Fujifilm GFX 100S II")", "-PreviewApplicationName=2FujiRaw", "-PreviewApplicationVersion=0.1.0", "-PreviewDateTime=now", "-PreviewColorSpace=sRGB", - "-overwrite_original", - outputFile.path, ] - let exif = try run(BundledTools.exiftool, args: exifArgs) + args.append("-overwrite_original") + args.append(outputFile.path) + let exif = try run(BundledTools.exiftool, args: args) if exif.exitCode != 0 { throw ConversionError.spoofFailed( dng: outputFile, - stderr: exif.stderr.isEmpty ? exif.stdout : exif.stderr) + stderr: exif.stderr.isEmpty ? exif.stdout : exif.stderr + ) } - return outputFile } - /// Traite un batch en série, publie progress via AsyncStream. - func convertBatch(_ sources: [URL]) -> AsyncThrowingStream { - AsyncThrowingStream { continuation in - Task { - do { - try BundledTools.verifyAll() - for (i, src) in sources.enumerated() { - let out = try await convert(src) - continuation.yield(ConversionProgress( - processed: i + 1, - total: sources.count, - lastOutput: out - )) - } - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - } - } - private func run(_ binary: URL, args: [String]) throws -> (stdout: String, stderr: String, exitCode: Int32) { - let process = Process() - process.executableURL = binary - process.arguments = args - let outPipe = Pipe(), errPipe = Pipe() - process.standardOutput = outPipe - process.standardError = errPipe - try process.run() - process.waitUntilExit() - let out = String(data: outPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - let err = String(data: errPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" - return (out, err, process.terminationStatus) + let result = try ProcessRunner.run(executableURL: binary, arguments: args) + let out = String(data: result.stdout, encoding: .utf8) ?? "" + let err = String(data: result.stderr, encoding: .utf8) ?? "" + return (out, err, result.exitCode) } } diff --git a/src/Sources/ToFujiRaw/ConversionOptions.swift b/src/Sources/ToFujiRaw/ConversionOptions.swift new file mode 100644 index 0000000..1357d48 --- /dev/null +++ b/src/Sources/ToFujiRaw/ConversionOptions.swift @@ -0,0 +1,7 @@ +import Foundation + +struct ConversionOptions { + var preserveOriginalLeicaBodyInfo: Bool = false + + static let `default` = ConversionOptions() +} diff --git a/src/Sources/ToFujiRaw/DropZoneView.swift b/src/Sources/ToFujiRaw/DropZoneView.swift index 955f31b..28d4acc 100644 --- a/src/Sources/ToFujiRaw/DropZoneView.swift +++ b/src/Sources/ToFujiRaw/DropZoneView.swift @@ -24,7 +24,7 @@ struct DropZoneView: View { .foregroundStyle(borderColor) if files.isEmpty { - Text("DROP .3FR / .FFF HERE") + Text("DROP \(mapping.sourceExtensions.map { ".\($0.uppercased())" }.joined(separator: " / ")) HERE") .font(Theme.monoTitle) .foregroundStyle(Theme.ink) Button("+ ADD FILES") { openPanel() } @@ -46,7 +46,7 @@ struct DropZoneView: View { .animation(.easeOut(duration: 0.15), value: isTargeted) .onDrop(of: [.fileURL], isTargeted: $isTargeted) { providers in guard !isDisabled else { return false } - Task { await handleDrop(providers) } + handleDrop(providers) return true } } @@ -62,14 +62,38 @@ struct DropZoneView: View { } } - private func handleDrop(_ providers: [NSItemProvider]) async { + private func handleDrop(_ providers: [NSItemProvider]) { var urls: [URL] = [] + let lock = NSLock() + let group = DispatchGroup() + var hasFinished = false + + func finish() { + lock.lock() + defer { lock.unlock() } + guard !hasFinished else { return } + hasFinished = true + let dropped = urls + DispatchQueue.main.async { + addFiles(dropped) + } + } + for provider in providers { - if let url = try? await provider.loadFileURL() { - urls.append(url) + group.enter() + provider.loadFileURL { url in + if let url { + lock.lock() + urls.append(url) + lock.unlock() + } + group.leave() } } - await MainActor.run { addFiles(urls) } + group.notify(queue: .global(qos: .userInitiated)) { finish() } + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 5) { + finish() + } } private func addFiles(_ urls: [URL]) { @@ -82,12 +106,9 @@ struct DropZoneView: View { } private extension NSItemProvider { - func loadFileURL() async throws -> URL? { - try await withCheckedThrowingContinuation { cont in - _ = self.loadObject(ofClass: URL.self) { url, error in - if let error = error { cont.resume(throwing: error) } - else { cont.resume(returning: url) } - } + func loadFileURL(_ completion: @escaping (URL?) -> Void) { + _ = self.loadObject(ofClass: URL.self) { url, _ in + completion(url) } } } diff --git a/src/Sources/ToFujiRaw/LeicaHeaderBuilder.swift b/src/Sources/ToFujiRaw/LeicaHeaderBuilder.swift new file mode 100644 index 0000000..77e643c --- /dev/null +++ b/src/Sources/ToFujiRaw/LeicaHeaderBuilder.swift @@ -0,0 +1,451 @@ +import Foundation + +struct LeicaX2DOutputLayout { + let rawOffset: Int + let rawLength: Int + let previewOffset: Int + let previewSlot: Int + let fileSize: Int +} + +enum HeaderBuildError: LocalizedError { + case missingIFD(UInt16) + case payloadMismatch(tag: UInt16) + case invalidMakerNote + + var errorDescription: String? { + switch self { + case .missingIFD(let tag): + return String(format: "IFD/tag manquant: 0x%04X", tag) + case .payloadMismatch(let tag): + return String(format: "Payload invalide pour tag 0x%04X", tag) + case .invalidMakerNote: + return "MakerNote Hasselblad invalide." + } + } +} + +struct LeicaHeaderBuilder { + static let dropMakerNoteTags: Set = [HasselbladMakerNoteTag.hncsBlocker] + static let outputCFARGGBBytes = Data([0x00, 0x01, 0x01, 0x02]) + + let donor: TIFFFile + let source: TIFFFile + let layout: LeicaX2DOutputLayout + let geometry: LeicaSourceGeometry + let options: ConversionOptions + + private var header: Data + private let ifd0: [UInt16: TIFFEntry] + private let rawIFD: [UInt16: TIFFEntry] + private let exifIFD: [UInt16: TIFFEntry] + private let sourceIFD0: [UInt16: TIFFEntry] + private let sourceRawIFD: [UInt16: TIFFEntry] + private var nextFree: Int + + init( + donorURL: URL, + sourceURL: URL, + layout: LeicaX2DOutputLayout, + geometry: LeicaSourceGeometry, + options: ConversionOptions + ) throws { + let donor = try TIFFFile(url: donorURL) + let source = try TIFFFile(url: sourceURL) + let donorRootIFDOffset = try donor.rootIFDOffset() + let ifd0 = try donor.ifdMap(at: donorRootIFDOffset) + guard + let donorSubIFDs = ifd0[TIFFTag.subIFDs], + let rawIFDOffset = try donor.longValues(for: donorSubIFDs).first, + let exifEntry = ifd0[TIFFTag.exifIFD] + else { + throw HeaderBuildError.missingIFD(TIFFTag.subIFDs) + } + let rawIFD = try donor.ifdMap(at: Int(rawIFDOffset)) + let exifIFD = try donor.ifdMap(at: Int(exifEntry.value)) + + let sourceIFD0 = try source.ifdMap(at: source.rootIFDOffset()) + guard + let sourceSubIFDs = sourceIFD0[TIFFTag.subIFDs], + let sourceRawIFDOffset = try source.longValues(for: sourceSubIFDs).first + else { + throw HeaderBuildError.missingIFD(TIFFTag.subIFDs) + } + let sourceRawIFD = try source.ifdMap(at: Int(sourceRawIFDOffset)) + + self.donor = donor + self.source = source + self.layout = layout + self.geometry = geometry + self.options = options + self.header = donor.data.prefix(layout.rawOffset) + self.ifd0 = ifd0 + self.rawIFD = rawIFD + self.exifIFD = exifIFD + self.sourceIFD0 = sourceIFD0 + self.sourceRawIFD = sourceRawIFD + self.nextFree = self.header.maxHeaderPayloadEnd( + ifdMaps: [ifd0, rawIFD, exifIFD], + rootIFDOffset: donorRootIFDOffset + ) + } + + mutating func build(sourceMetadata: LeicaSourceMetadata) throws -> Data { + try patchGeometry(sourceMetadata: sourceMetadata) + try patchBodyDisplayName(sourceMetadata: sourceMetadata) + try patchDateTime(sourceMetadata: sourceMetadata) + try patchExif(sourceMetadata: sourceMetadata) + try patchWhiteBalance() + try patchLensIdentity(sourceMetadata: sourceMetadata) + try rewriteRawIFD() + try patchMakerNote() + try patchXMP() + return header + } + + private mutating func patchBodyDisplayName(sourceMetadata: LeicaSourceMetadata) throws { + guard options.preserveOriginalLeicaBodyInfo, + let model = sourceMetadata.model, + let entry = ifd0[TIFFTag.model] + else { return } + try patchASCIIRelocate(entry: entry, text: model) + } + + private mutating func patchGeometry(sourceMetadata: LeicaSourceMetadata) throws { + if let stripOffsets = ifd0[TIFFTag.stripOffsets] { + header.patchLongInline(stripOffsets, value: UInt32(layout.previewOffset)) + } + if let stripByteCounts = ifd0[TIFFTag.stripByteCounts] { + header.patchLongInline(stripByteCounts, value: UInt32(layout.previewSlot)) + } + if let orientation = sourceMetadata.orientation, let entry = ifd0[TIFFTag.orientation] { + header.patchShortInline(entry, value: UInt16(orientation)) + } + + if let entry = rawIFD[TIFFTag.imageWidth] { + try header.patchScalarInline(entry, value: UInt32(geometry.fullSize.width)) + } + if let entry = rawIFD[TIFFTag.imageHeight] { + try header.patchScalarInline(entry, value: UInt32(geometry.fullSize.height)) + } + if let entry = rawIFD[TIFFTag.stripOffsets] { + try header.patchScalarInline(entry, value: UInt32(layout.rawOffset)) + } + if let entry = rawIFD[TIFFTag.rowsPerStrip] { + try header.patchScalarInline(entry, value: UInt32(geometry.fullSize.height)) + } + if let entry = rawIFD[TIFFTag.stripByteCounts] { + try header.patchScalarInline(entry, value: UInt32(layout.rawLength)) + } + if let entry = rawIFD[TIFFTag.defaultCropOrigin] { + var raw = Data() + raw.appendLE(UInt16(geometry.cropOrigin.x)) + raw.appendLE(UInt16(geometry.cropOrigin.y)) + try header.patchEntryPayload(entry, raw: raw) + } + if let entry = rawIFD[TIFFTag.defaultCropSize] { + var raw = Data() + raw.appendLE(UInt16(geometry.cropSize.width)) + raw.appendLE(UInt16(geometry.cropSize.height)) + try header.patchEntryPayload(entry, raw: raw) + } + if let entry = rawIFD[TIFFTag.maskedAreas] { + try header.patchEntryPayload(entry, raw: maskedAreasPayload()) + } + } + + private mutating func patchDateTime(sourceMetadata: LeicaSourceMetadata) throws { + guard let modifyDate = sourceMetadata.modifyDate, let entry = ifd0[TIFFTag.dateTime] else { + return + } + try header.patchASCII(entry, text: modifyDate) + } + + private mutating func patchExif(sourceMetadata: LeicaSourceMetadata) throws { + if let exposureTime = sourceMetadata.exposureTime, let entry = exifIFD[TIFFTag.exposureTime] { + let rational = floatToRational(exposureTime, denominator: 1_000_000) + header.patchRational(entry, numerator: rational.0, denominator: rational.1) + } + if let fNumber = sourceMetadata.fNumber, let entry = exifIFD[TIFFTag.fNumber] { + let rational = floatToRational(fNumber, denominator: 10_000) + header.patchRational(entry, numerator: rational.0, denominator: rational.1) + } + if let exposureProgram = sourceMetadata.exposureProgram, let entry = exifIFD[TIFFTag.exposureProgram] { + header.patchShortInline(entry, value: UInt16(exposureProgram)) + } + if let iso = sourceMetadata.iso, let entry = exifIFD[TIFFTag.iso] { + header.patchShortInline(entry, value: UInt16(iso)) + } + if let dateTimeOriginal = sourceMetadata.dateTimeOriginal, let entry = exifIFD[TIFFTag.dateTimeOriginal] { + try header.patchASCII(entry, text: dateTimeOriginal) + } + if let exposureBias = sourceMetadata.exposureCompensation, let entry = exifIFD[TIFFTag.exposureBias] { + let rational = floatToRational(exposureBias, denominator: 10_000) + header.patchRational(entry, numerator: rational.0, denominator: rational.1, signed: true) + } + if let maxAperture = sourceMetadata.maxApertureValue, let entry = exifIFD[TIFFTag.maxAperture] { + let rational = floatToRational(maxAperture, denominator: 10_000) + header.patchRational(entry, numerator: rational.0, denominator: rational.1) + } + if let meteringMode = sourceMetadata.meteringMode, let entry = exifIFD[TIFFTag.meteringMode] { + header.patchShortInline(entry, value: UInt16(meteringMode)) + } + if let flash = sourceMetadata.flash, let entry = exifIFD[TIFFTag.flash] { + header.patchShortInline(entry, value: UInt16(flash)) + } + if let focalLength = sourceMetadata.focalLength, let entry = exifIFD[TIFFTag.focalLength] { + let rational = floatToRational(focalLength, denominator: 1000) + header.patchRational(entry, numerator: rational.0, denominator: rational.1) + } + if let focalLength35 = sourceMetadata.focalLengthIn35mmFormat, let entry = exifIFD[TIFFTag.focalLength35MM] { + header.patchShortInline(entry, value: UInt16(focalLength35)) + } + if let entry = exifIFD[TIFFTag.imageUniqueID] { + try header.patchASCII(entry, text: UUID().uuidString.replacingOccurrences(of: "-", with: "").uppercased()) + } + } + + private mutating func patchWhiteBalance() throws { + guard + let sourceEntry = sourceIFD0[TIFFTag.asShotNeutral], + let donorEntry = ifd0[TIFFTag.asShotNeutral] + else { return } + try header.patchEntryPayload(donorEntry, raw: try source.payloadBytes(for: sourceEntry)) + } + + private mutating func patchLensIdentity(sourceMetadata: LeicaSourceMetadata) throws { + if let lensMake = sourceMetadata.lensMake, let entry = exifIFD[TIFFTag.lensMake] { + try patchASCIIRelocate(entry: entry, text: lensMake) + } + if let lensModel = sourceMetadata.lensModel, let entry = exifIFD[TIFFTag.lensModel] { + try patchASCIIRelocate(entry: entry, text: lensModel) + } + } + + private mutating func rewriteRawIFD() throws { + var additions: [(tag: UInt16, type: UInt16, count: UInt32, payload: Data)] = [] + + for tag in [TIFFTag.cfaRepeatPatternDim, TIFFTag.cfaPattern, TIFFTag.activeArea] { + guard let entry = sourceRawIFD[tag] else { continue } + var payload = try source.payloadBytes(for: entry) + if tag == TIFFTag.cfaPattern { + payload = Self.outputCFARGGBBytes + } else if tag == TIFFTag.activeArea { + var raw = Data() + raw.appendLE(0) + raw.appendLE(0) + raw.appendLE(UInt16(geometry.activeSize.height)) + raw.appendLE(UInt16(geometry.activeSize.width)) + payload = raw + } + additions.append((tag, entry.type, entry.count, payload)) + } + + for tag in [TIFFTag.opcodeList1, TIFFTag.opcodeList3] { + guard let entry = sourceRawIFD[tag] else { continue } + additions.append((tag, entry.type, entry.count, try source.payloadBytes(for: entry))) + } + + guard !additions.isEmpty else { return } + + struct MutableIFDEntry { + let tag: UInt16 + let type: UInt16 + let count: UInt32 + let payload: Data + } + + var entries = rawIFD.values.map { + MutableIFDEntry(tag: $0.tag, type: $0.type, count: $0.count, payload: header.entryPayload($0)) + } + for addition in additions { + entries.removeAll { $0.tag == addition.tag } + entries.append(MutableIFDEntry(tag: addition.tag, type: addition.type, count: addition.count, payload: addition.payload)) + } + entries.sort { $0.tag < $1.tag } + + let ifdOffset = alignUp(nextFree, alignment: 2) + let ifdSize = 2 + entries.count * 12 + 4 + var cursor = ifdOffset + ifdSize + guard cursor <= layout.rawOffset else { + throw TIFFError.headerSpaceExhausted(requested: ifdSize, limit: layout.rawOffset) + } + + var countLE = UInt16(entries.count).littleEndian + header.replaceBytes(at: ifdOffset, with: Data(bytes: &countLE, count: 2)) + var entryCursor = ifdOffset + 2 + for entry in entries { + let size = TIFFType.size(of: entry.type, count: entry.count) + guard entry.payload.count == size else { + throw HeaderBuildError.payloadMismatch(tag: entry.tag) + } + var raw = Data() + var tag = entry.tag.littleEndian + var type = entry.type.littleEndian + var count = entry.count.littleEndian + raw.append(Data(bytes: &tag, count: 2)) + raw.append(Data(bytes: &type, count: 2)) + raw.append(Data(bytes: &count, count: 4)) + if size <= 4 { + var inline = entry.payload + if inline.count < 4 { + inline.append(Data(repeating: 0, count: 4 - inline.count)) + } + raw.append(inline.prefix(4)) + } else { + let payloadOffset = try header.allocateHeaderBytes( + nextFreeOffset: alignUp(cursor, alignment: 2), + limitOffset: layout.rawOffset, + raw: entry.payload + ) + cursor = payloadOffset + size + var value = UInt32(payloadOffset).littleEndian + raw.append(Data(bytes: &value, count: 4)) + } + header.replaceBytes(at: entryCursor, with: raw) + entryCursor += 12 + } + var nextIFD: UInt32 = 0 + header.replaceBytes(at: entryCursor, with: Data(bytes: &nextIFD, count: 4)) + if let subIFDsEntry = ifd0[TIFFTag.subIFDs] { + var value = UInt32(ifdOffset).littleEndian + try header.patchEntryPayload(subIFDsEntry, raw: Data(bytes: &value, count: 4)) + } + nextFree = cursor + } + + private mutating func patchMakerNote() throws { + guard let makerNote = exifIFD[TIFFTag.makerNote] else { return } + let mnStart = Int(makerNote.value) + let limit = layout.rawOffset + guard let countValue = donor.u16(at: mnStart) else { + throw HeaderBuildError.invalidMakerNote + } + let count = Int(countValue) + + for index in 0..= 0, value + itemCount <= limit { + header.zeroRegion(start: value, length: itemCount) + } + } + + var cropPayload = Data() + cropPayload.appendLE(1) + cropPayload.appendLE(UInt16(geometry.cropOrigin.x)) + cropPayload.appendLE(UInt16(geometry.cropOrigin.y)) + cropPayload.appendLE(UInt16(geometry.cropSize.width)) + cropPayload.appendLE(UInt16(geometry.cropSize.height)) + try patchMakerNoteTag(HasselbladMakerNoteTag.cropInfo, raw: cropPayload) + header.rewriteIFDWithoutTags(at: mnStart, dropTags: Self.dropMakerNoteTags) + } + + private mutating func patchMakerNoteTag(_ tag: UInt16, raw: Data) throws { + guard let makerNote = exifIFD[TIFFTag.makerNote] else { return } + let mnStart = Int(makerNote.value) + guard let countValue = donor.u16(at: mnStart) else { + throw HeaderBuildError.invalidMakerNote + } + let count = Int(countValue) + for index in 0..01 +""".utf8) + let xmpStart = try header.allocateHeaderBytes(nextFreeOffset: nextFree, limitOffset: layout.rawOffset, raw: newXMP) + var count = UInt32(newXMP.count).littleEndian + var offset = UInt32(xmpStart).littleEndian + header.replaceBytes(at: xmpEntry.entryOffset + 4, with: Data(bytes: &count, count: 4)) + header.replaceBytes(at: xmpEntry.entryOffset + 8, with: Data(bytes: &offset, count: 4)) + nextFree = xmpStart + newXMP.count + } + + private func maskedAreasPayload() -> Data { + let fullW = geometry.fullSize.width + let fullH = geometry.fullSize.height + let cropX = geometry.cropOrigin.x + let cropY = geometry.cropOrigin.y + let cropW = geometry.cropSize.width + let cropH = geometry.cropSize.height + let values: [UInt16] = [ + 0, 0, UInt16(cropY), UInt16(fullW), + UInt16(cropY), 0, UInt16(cropY + cropH), UInt16(cropX), + UInt16(cropY + cropH), 0, UInt16(fullH), UInt16(fullW), + UInt16(cropY), UInt16(cropX + cropW), UInt16(cropY + cropH), UInt16(fullW), + ] + var raw = Data() + values.forEach { raw.appendLE($0) } + return raw + } + + private mutating func patchASCIIRelocate(entry: TIFFEntry, text: String) throws { + let encoded = (text.data(using: .ascii, allowLossyConversion: true) ?? Data()) + Data([0]) + if encoded.count <= Int(entry.count) { + try header.patchASCII(entry, text: text) + return + } + let start = try header.allocateHeaderBytes(nextFreeOffset: nextFree, limitOffset: layout.rawOffset, raw: encoded) + var count = UInt32(encoded.count).littleEndian + var offset = UInt32(start).littleEndian + header.replaceBytes(at: entry.entryOffset + 4, with: Data(bytes: &count, count: 4)) + header.replaceBytes(at: entry.entryOffset + 8, with: Data(bytes: &offset, count: 4)) + nextFree = start + encoded.count + } + + private func floatToRational(_ value: Double, denominator: Int32) -> (Int32, Int32) { + let numerator = Int32((value * Double(denominator)).rounded()) + let divisor = gcd(abs(numerator), denominator) + return (numerator / divisor, denominator / divisor) + } + + private func gcd(_ a: Int32, _ b: Int32) -> Int32 { + var x = a + var y = b + while y != 0 { + let r = x % y + x = y + y = r + } + return x == 0 ? 1 : x + } + + private func alignUp(_ value: Int, alignment: Int) -> Int { + ((value + alignment - 1) / alignment) * alignment + } +} diff --git a/src/Sources/ToFujiRaw/LeicaRawProcessing.swift b/src/Sources/ToFujiRaw/LeicaRawProcessing.swift new file mode 100644 index 0000000..7802548 --- /dev/null +++ b/src/Sources/ToFujiRaw/LeicaRawProcessing.swift @@ -0,0 +1,234 @@ +import Foundation + +enum LeicaRawProcessingError: LocalizedError { + case missingRawIFD(URL) + case missingDimensions(URL) + case unsupportedCFAPattern(UInt8) + case invalidPlaneDimensions + case invalidPackedRowLength + + var errorDescription: String? { + switch self { + case .missingRawIFD(let url): + return "Impossible de lire l'IFD RAW dans \(url.path)." + case .missingDimensions(let url): + return "Dimensions RAW manquantes ou invalides dans \(url.path)." + case .unsupportedCFAPattern(let value): + return "Motif CFA non supporté: \(value)" + case .invalidPlaneDimensions: + return "Dimensions des plans Bayer invalides." + case .invalidPackedRowLength: + return "Longueur de ligne RAW packée invalide." + } + } +} + +struct LeicaSourceGeometry { + let fullSize: (width: Int, height: Int) + let cropOrigin: (x: Int, y: Int) + let cropSize: (width: Int, height: Int) + let activeSize: (width: Int, height: Int) + let cfaTopLeft: UInt8 + let blackLevel: Int + let whiteLevel: Int +} + +enum LeicaRawProcessing { + static let donorBlack = 4096 + static let donorWhite = 65535 + + static let cfaRGGB: UInt8 = 0 + static let cfaBGGR: UInt8 = 2 + + static func readSourceGeometry(sourceURL: URL) throws -> LeicaSourceGeometry { + let source = try TIFFFile(url: sourceURL) + let ifd0 = try source.ifdMap(at: source.rootIFDOffset()) + guard + let subIFDs = ifd0[TIFFTag.subIFDs], + let rawIFDOffset = try source.longValues(for: subIFDs).first + else { + throw LeicaRawProcessingError.missingRawIFD(sourceURL) + } + let rawIFD = try source.ifdMap(at: Int(rawIFDOffset)) + + let fullWidth = Int(rawIFD[TIFFTag.imageWidth]?.value ?? 0) + let fullHeight = Int(rawIFD[TIFFTag.imageHeight]?.value ?? 0) + guard fullWidth > 0, fullHeight > 0 else { + throw LeicaRawProcessingError.missingDimensions(sourceURL) + } + + let cropOriginBytes = try rawIFD[TIFFTag.defaultCropOrigin].map { try source.payloadBytes(for: $0) } ?? Data() + let cropSizeBytes = try rawIFD[TIFFTag.defaultCropSize].map { try source.payloadBytes(for: $0) } ?? Data() + + let cropX = cropOriginBytes.readUInt16LE(at: 0).map(Int.init) ?? 0 + let cropY = cropOriginBytes.readUInt16LE(at: 2).map(Int.init) ?? 0 + let cropWidth = cropSizeBytes.readUInt16LE(at: 0).map(Int.init) ?? fullWidth + let cropHeight = cropSizeBytes.readUInt16LE(at: 2).map(Int.init) ?? fullHeight + + let activeSize: (width: Int, height: Int) + if let activeArea = rawIFD[TIFFTag.activeArea] { + let payload = try source.payloadBytes(for: activeArea) + let top = Int(payload.readUInt16LE(at: 0) ?? 0) + let left = Int(payload.readUInt16LE(at: 2) ?? 0) + let bottom = Int(payload.readUInt16LE(at: 4) ?? UInt16(fullHeight)) + let right = Int(payload.readUInt16LE(at: 6) ?? UInt16(fullWidth)) + activeSize = (right - left, bottom - top) + } else { + activeSize = (fullWidth, fullHeight) + } + + let cfaTopLeft = rawIFD[TIFFTag.cfaPattern] + .flatMap { try? source.payloadBytes(for: $0).first } ?? cfaBGGR + + let blackLevel: Int + if let blackEntry = rawIFD[TIFFTag.blackLevel] { + let payload = try source.payloadBytes(for: blackEntry) + if blackEntry.type == TIFFType.short { + blackLevel = Int(payload.readUInt16LE(at: 0) ?? 512) + } else { + blackLevel = Int(payload.readUInt32LE(at: 0) ?? 512) + } + } else { + blackLevel = 512 + } + + let whiteLevel: Int + if let whiteEntry = rawIFD[TIFFTag.whiteLevel] { + let payload = try source.payloadBytes(for: whiteEntry) + whiteLevel = Int(payload.readUInt32LE(at: 0) ?? 16383) + } else { + whiteLevel = 16383 + } + + return LeicaSourceGeometry( + fullSize: (fullWidth, fullHeight), + cropOrigin: (cropX, cropY), + cropSize: (cropWidth, cropHeight), + activeSize: activeSize, + cfaTopLeft: cfaTopLeft, + blackLevel: blackLevel, + whiteLevel: whiteLevel + ) + } + + static func extractLeicaPlanes(sourceURL: URL, geometry: LeicaSourceGeometry) throws -> [[UInt16]] { + let source = try TIFFFile(url: sourceURL) + let ifd0 = try source.ifdMap(at: source.rootIFDOffset()) + guard + let subIFDs = ifd0[TIFFTag.subIFDs], + let rawIFDOffset = try source.longValues(for: subIFDs).first + else { + throw LeicaRawProcessingError.missingRawIFD(sourceURL) + } + let rawIFD = try source.ifdMap(at: Int(rawIFDOffset)) + let stripOffset = Int(rawIFD[TIFFTag.stripOffsets]?.value ?? 0) + let stripLength = Int(rawIFD[TIFFTag.stripByteCounts]?.value ?? 0) + + let fullWidth = geometry.fullSize.width + let fullHeight = geometry.fullSize.height + let rowBytes = fullWidth * 14 / 8 + guard stripOffset + stripLength <= source.data.count else { + throw LeicaRawProcessingError.invalidPackedRowLength + } + let raw = source.data.subdata(in: stripOffset..<(stripOffset + stripLength)) + + let planeWidth = fullWidth / 2 + let planeHeight = fullHeight / 2 + var planes = Array( + repeating: Array(repeating: UInt16(0), count: planeWidth * planeHeight), + count: 4 + ) + + for y in 0.. Data { + let fullWidth = geometry.fullSize.width + let fullHeight = geometry.fullSize.height + let planeWidth = fullWidth / 2 + let planeHeight = fullHeight / 2 + + guard planes.count == 4, planes.allSatisfy({ $0.count == planeWidth * planeHeight }) else { + throw LeicaRawProcessingError.invalidPlaneDimensions + } + + let evenRow: (Int, Int) + let oddRow: (Int, Int) + switch geometry.cfaTopLeft { + case cfaBGGR: + evenRow = (3, 1) + oddRow = (2, 0) + case cfaRGGB: + evenRow = (0, 1) + oddRow = (2, 3) + default: + throw LeicaRawProcessingError.unsupportedCFAPattern(geometry.cfaTopLeft) + } + + var output = Data(capacity: fullWidth * fullHeight * 2) + for y in 0.. Int { + let clamped = Swift.max(black, Swift.min(white, value)) + let scale = Double(clamped - black) / Double(white - black) + let mapped = Double(donorBlack) + scale * Double(donorWhite - donorBlack) + return Swift.max(0, Swift.min(65535, Int(mapped.rounded()))) + } + + static func unpackRow14BE(_ rowBytes: Data, width: Int) throws -> [UInt16] { + var values: [UInt16] = [] + values.reserveCapacity(width) + var index = 0 + while index + 7 <= rowBytes.count { + let base = rowBytes.startIndex + index + let c0 = UInt16(rowBytes[base + 0]) + let c1 = UInt16(rowBytes[base + 1]) + let c2 = UInt16(rowBytes[base + 2]) + let c3 = UInt16(rowBytes[base + 3]) + let c4 = UInt16(rowBytes[base + 4]) + let c5 = UInt16(rowBytes[base + 5]) + let c6 = UInt16(rowBytes[base + 6]) + + values.append((c0 << 6) | (c1 >> 2)) + values.append(((c1 & 0x03) << 12) | (c2 << 4) | (c3 >> 4)) + values.append(((c3 & 0x0F) << 10) | (c4 << 2) | (c5 >> 6)) + values.append(((c5 & 0x3F) << 8) | c6) + index += 7 + } + guard values.count == width else { + throw LeicaRawProcessingError.invalidPackedRowLength + } + return values + } +} diff --git a/src/Sources/ToFujiRaw/LeicaSourceMetadata.swift b/src/Sources/ToFujiRaw/LeicaSourceMetadata.swift new file mode 100644 index 0000000..a175a18 --- /dev/null +++ b/src/Sources/ToFujiRaw/LeicaSourceMetadata.swift @@ -0,0 +1,112 @@ +import Foundation + +struct LeicaSourceMetadata { + let make: String? + let model: String? + let uniqueCameraModel: String? + let serialNumber: String? + let bodySerialNumber: String? + let cameraSerialNumber: String? + let orientation: Int? + let modifyDate: String? + let exposureTime: Double? + let fNumber: Double? + let exposureProgram: Int? + let iso: Int? + let dateTimeOriginal: String? + let exposureCompensation: Double? + let maxApertureValue: Double? + let meteringMode: Int? + let flash: Int? + let focalLength: Double? + let focalLengthIn35mmFormat: Int? + let lensMake: String? + let lensModel: String? +} + +enum LeicaSourceMetadataExtractor { + static func extract(from sourceURL: URL, exiftoolURL: URL) throws -> LeicaSourceMetadata { + let tags = [ + "Make", + "Model", + "UniqueCameraModel", + "SerialNumber", + "BodySerialNumber", + "CameraSerialNumber", + "Orientation", + "ModifyDate", + "ExposureTime", + "FNumber", + "ExposureProgram", + "ISO", + "DateTimeOriginal", + "ExposureCompensation", + "MaxApertureValue", + "MeteringMode", + "Flash", + "FocalLength", + "FocalLengthIn35mmFormat", + "LensMake", + "LensModel", + ] + + let result = try ProcessRunner.run( + executableURL: exiftoolURL, + arguments: ["-j", "-n"] + tags.map { "-\($0)" } + [sourceURL.path] + ) + let out = result.stdout + let err = result.stderr + guard result.exitCode == 0 else { + let message = String(data: err.isEmpty ? out : err, encoding: .utf8) ?? "exiftool failed" + throw NSError(domain: "LeicaSourceMetadataExtractor", code: Int(result.exitCode), userInfo: [ + NSLocalizedDescriptionKey: message + ]) + } + + let json = try JSONSerialization.jsonObject(with: out) as? [[String: Any]] + let payload = json?.first ?? [:] + + func intValue(_ key: String) -> Int? { + if let n = payload[key] as? NSNumber { return n.intValue } + if let s = payload[key] as? String { return Int(s) } + return nil + } + + func doubleValue(_ key: String) -> Double? { + if let n = payload[key] as? NSNumber { return n.doubleValue } + if let s = payload[key] as? String { return Double(s) } + return nil + } + + func stringValue(_ key: String) -> String? { + if let s = payload[key] as? String, !s.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return s + } + return nil + } + + return LeicaSourceMetadata( + make: stringValue("Make"), + model: stringValue("Model"), + uniqueCameraModel: stringValue("UniqueCameraModel"), + serialNumber: stringValue("SerialNumber"), + bodySerialNumber: stringValue("BodySerialNumber"), + cameraSerialNumber: stringValue("CameraSerialNumber"), + orientation: intValue("Orientation"), + modifyDate: stringValue("ModifyDate"), + exposureTime: doubleValue("ExposureTime"), + fNumber: doubleValue("FNumber"), + exposureProgram: intValue("ExposureProgram"), + iso: intValue("ISO"), + dateTimeOriginal: stringValue("DateTimeOriginal"), + exposureCompensation: doubleValue("ExposureCompensation"), + maxApertureValue: doubleValue("MaxApertureValue"), + meteringMode: intValue("MeteringMode"), + flash: intValue("Flash"), + focalLength: doubleValue("FocalLength"), + focalLengthIn35mmFormat: intValue("FocalLengthIn35mmFormat"), + lensMake: stringValue("LensMake"), + lensModel: stringValue("LensModel") + ) + } +} diff --git a/src/Sources/ToFujiRaw/LeicaX2DWriter.swift b/src/Sources/ToFujiRaw/LeicaX2DWriter.swift new file mode 100644 index 0000000..3ae4694 --- /dev/null +++ b/src/Sources/ToFujiRaw/LeicaX2DWriter.swift @@ -0,0 +1,222 @@ +import Foundation + +enum LeicaX2DWriterError: LocalizedError { + case invalidDonorLayout(String) + case missingPreviewIFD + case rawPayloadSizeMismatch(Int, Int) + case headerOverflow(Int, Int) + case rawOverflow(Int, Int) + + var errorDescription: String? { + switch self { + case .invalidDonorLayout(let message): + return message + case .missingPreviewIFD: + return "IFD preview introuvable dans le DNG source." + case .rawPayloadSizeMismatch(let actual, let expected): + return "Taille payload RAW invalide: \(actual) != \(expected)" + case .headerOverflow(let actual, let limit): + return "L'en-tête dépasse l'offset RAW du donor: \(actual) > \(limit)" + case .rawOverflow(let actual, let limit): + return "Le payload RAW dépasse l'offset preview: \(actual) > \(limit)" + } + } +} + +struct LeicaX2DWriter { + static let donorFullSize = (width: 11904, height: 8842) + static let donorRawLength = donorFullSize.width * donorFullSize.height * 2 + static let previewLongEdge = 3888 + + static func write( + sourceURL: URL, + donorURL: URL, + outputURL: URL, + progressHandler: ((String, Double) -> Void)? = nil, + options: ConversionOptions = .default + ) throws { + progressHandler?("READING SOURCE GEOMETRY", 0.06) + let geometry = try LeicaRawProcessing.readSourceGeometry(sourceURL: sourceURL) + progressHandler?("READING SOURCE METADATA", 0.14) + let sourceMetadata = try LeicaSourceMetadataExtractor.extract(from: sourceURL, exiftoolURL: BundledTools.exiftool) + progressHandler?("BUILDING PREVIEW", 0.24) + let previewJPEG = try buildPreview(sourceURL: sourceURL, geometry: geometry) + progressHandler?("ANALYZING DONOR LAYOUT", 0.34) + let layout = try outputLayout(donorURL: donorURL, fullSize: geometry.fullSize, previewSize: previewJPEG.count) + + progressHandler?("PATCHING X2D HEADER", 0.48) + var headerBuilder = try LeicaHeaderBuilder( + donorURL: donorURL, + sourceURL: sourceURL, + layout: layout, + geometry: geometry, + options: options + ) + let header = try headerBuilder.build(sourceMetadata: sourceMetadata) + + progressHandler?("EXTRACTING BAYER PLANES", 0.66) + let planes = try LeicaRawProcessing.extractLeicaPlanes(sourceURL: sourceURL, geometry: geometry) + progressHandler?("ASSEMBLING RAW PAYLOAD", 0.82) + let rawPayload = try LeicaRawProcessing.assembleRawPayload(planes: planes, geometry: geometry) + guard rawPayload.count == layout.rawLength else { + throw LeicaX2DWriterError.rawPayloadSizeMismatch(rawPayload.count, layout.rawLength) + } + + progressHandler?("WRITING 3FR", 0.93) + var out = Data(capacity: layout.fileSize) + out.append(header) + if out.count > layout.rawOffset { + throw LeicaX2DWriterError.headerOverflow(out.count, layout.rawOffset) + } + if out.count < layout.rawOffset { + out.append(Data(repeating: 0, count: layout.rawOffset - out.count)) + } + out.append(rawPayload) + if out.count > layout.previewOffset { + throw LeicaX2DWriterError.rawOverflow(out.count, layout.previewOffset) + } + if out.count < layout.previewOffset { + out.append(Data(repeating: 0, count: layout.previewOffset - out.count)) + } + out.append(previewJPEG) + if previewJPEG.count < layout.previewSlot { + out.append(Data(repeating: 0, count: layout.previewSlot - previewJPEG.count)) + } + + try FileManager.default.createDirectory( + at: outputURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try out.write(to: outputURL, options: .atomic) + progressHandler?("DONE", 1.0) + } + + static func donorLayout(donorURL: URL) throws -> LeicaX2DOutputLayout { + let donor = try TIFFFile(url: donorURL) + let ifd0 = try donor.ifdMap(at: donor.rootIFDOffset()) + guard + let subIFDs = ifd0[TIFFTag.subIFDs], + let rawIFDOffset = try donor.longValues(for: subIFDs).first + else { + throw LeicaX2DWriterError.invalidDonorLayout("SubIFD donor introuvable.") + } + let rawIFD = try donor.ifdMap(at: Int(rawIFDOffset)) + let rawOffset = Int(rawIFD[TIFFTag.stripOffsets]?.value ?? 0) + let rawLength = Int(rawIFD[TIFFTag.stripByteCounts]?.value ?? 0) + let previewOffset = Int(ifd0[TIFFTag.stripOffsets]?.value ?? 0) + let previewSlot = Int(ifd0[TIFFTag.stripByteCounts]?.value ?? 0) + + guard rawLength == donorRawLength else { + throw LeicaX2DWriterError.invalidDonorLayout("Longueur RAW donor inattendue: \(rawLength)") + } + guard rawOffset >= 8 else { + throw LeicaX2DWriterError.invalidDonorLayout("Offset RAW donor inattendu: \(rawOffset)") + } + guard previewOffset > rawOffset + rawLength else { + throw LeicaX2DWriterError.invalidDonorLayout("Le preview donor doit suivre le payload RAW.") + } + + return LeicaX2DOutputLayout( + rawOffset: rawOffset, + rawLength: rawLength, + previewOffset: previewOffset, + previewSlot: previewSlot, + fileSize: donor.data.count + ) + } + + static func outputLayout( + donorURL: URL, + fullSize: (width: Int, height: Int), + previewSize: Int + ) throws -> LeicaX2DOutputLayout { + let donor = try donorLayout(donorURL: donorURL) + let pixelCount = fullSize.width.multipliedReportingOverflow(by: fullSize.height) + guard !pixelCount.overflow else { + throw LeicaX2DWriterError.invalidDonorLayout("Dimensions source trop grandes pour calculer le payload RAW.") + } + let rawLengthResult = pixelCount.partialValue.multipliedReportingOverflow(by: 2) + guard !rawLengthResult.overflow else { + throw LeicaX2DWriterError.invalidDonorLayout("Longueur RAW source trop grande.") + } + let rawLength = rawLengthResult.partialValue + let previewOffset = alignUp(donor.rawOffset + rawLength, alignment: 4096) + guard previewOffset >= donor.rawOffset + rawLength else { + throw LeicaX2DWriterError.invalidDonorLayout("Overflow lors du calcul de l'offset preview.") + } + return LeicaX2DOutputLayout( + rawOffset: donor.rawOffset, + rawLength: rawLength, + previewOffset: previewOffset, + previewSlot: previewSize, + fileSize: previewOffset + previewSize + ) + } + + static func buildPreview(sourceURL: URL, geometry: LeicaSourceGeometry) throws -> Data { + let source = try TIFFFile(url: sourceURL) + let ifd0 = try source.ifdMap(at: source.rootIFDOffset()) + guard + let subIFDsEntry = ifd0[TIFFTag.subIFDs] + else { + throw LeicaX2DWriterError.missingPreviewIFD + } + let subIFDs = try source.longValues(for: subIFDsEntry) + guard subIFDs.count > 2 else { + throw LeicaX2DWriterError.missingPreviewIFD + } + let previewIFD = try source.ifdMap(at: Int(subIFDs[2])) + let previewStart = Int(previewIFD[TIFFTag.stripOffsets]?.value ?? 0) + let previewLength = Int(previewIFD[TIFFTag.stripByteCounts]?.value ?? 0) + guard previewStart >= 0, previewLength > 0, previewStart <= source.data.count, previewLength <= source.data.count - previewStart else { + throw LeicaX2DWriterError.missingPreviewIFD + } + let previewBytes = source.data.subdata(in: previewStart..<(previewStart + previewLength)) + + let cropWidth = geometry.cropSize.width + let cropHeight = geometry.cropSize.height + let targetWidth: Int + let targetHeight: Int + if cropWidth >= cropHeight { + targetWidth = previewLongEdge + targetHeight = Int((Double(previewLongEdge) * Double(cropHeight) / Double(cropWidth)).rounded()) + } else { + targetHeight = previewLongEdge + targetWidth = Int((Double(previewLongEdge) * Double(cropWidth) / Double(cropHeight)).rounded()) + } + + guard let magick = resolveMagick() else { + return previewBytes + } + + do { + let result = try ProcessRunner.run( + executableURL: magick, + arguments: ["jpg:-", "-resize", "\(targetWidth)x\(targetHeight)", "-quality", "92", "jpg:-"], + input: previewBytes + ) + if result.exitCode == 0 { + return result.stdout + } + return previewBytes + } catch { + return previewBytes + } + } + + static func resolveMagick() -> URL? { + let candidates = [ + "/opt/homebrew/bin/magick", + "/usr/local/bin/magick", + "/usr/bin/magick", + ] + for path in candidates where FileManager.default.isExecutableFile(atPath: path) { + return URL(fileURLWithPath: path) + } + return nil + } + + static func alignUp(_ value: Int, alignment: Int) -> Int { + ((value + alignment - 1) / alignment) * alignment + } +} diff --git a/src/Sources/ToFujiRaw/ProcessRunner.swift b/src/Sources/ToFujiRaw/ProcessRunner.swift new file mode 100644 index 0000000..dad6392 --- /dev/null +++ b/src/Sources/ToFujiRaw/ProcessRunner.swift @@ -0,0 +1,89 @@ +import Foundation + +struct ProcessOutput { + let stdout: Data + let stderr: Data + let exitCode: Int32 +} + +enum ProcessRunner { + static func run( + executableURL: URL, + arguments: [String], + input: Data? = nil + ) throws -> ProcessOutput { + let process = Process() + process.executableURL = executableURL + process.arguments = arguments + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + let stdinPipe: Pipe? + if input != nil { + let pipe = Pipe() + process.standardInput = pipe + stdinPipe = pipe + } else { + stdinPipe = nil + } + + let stdoutCollector = DataCollector() + let stderrCollector = DataCollector() + stdoutCollector.attach(to: stdoutPipe.fileHandleForReading) + stderrCollector.attach(to: stderrPipe.fileHandleForReading) + + try process.run() + + if let input, let stdinPipe { + stdinPipe.fileHandleForWriting.write(input) + stdinPipe.fileHandleForWriting.closeFile() + } + + process.waitUntilExit() + stdoutCollector.waitForEOF() + stderrCollector.waitForEOF() + + return ProcessOutput( + stdout: stdoutCollector.data, + stderr: stderrCollector.data, + exitCode: process.terminationStatus + ) + } +} + +private final class DataCollector { + private let lock = NSLock() + private let eof = DispatchSemaphore(value: 0) + private(set) var data = Data() + private var didSignalEOF = false + + func attach(to handle: FileHandle) { + handle.readabilityHandler = { [weak self] fh in + guard let self else { return } + let chunk = fh.availableData + if chunk.isEmpty { + fh.readabilityHandler = nil + self.signalEOF() + return + } + self.lock.lock() + self.data.append(chunk) + self.lock.unlock() + } + } + + func waitForEOF() { + eof.wait() + } + + private func signalEOF() { + lock.lock() + defer { lock.unlock() } + guard !didSignalEOF else { return } + didSignalEOF = true + eof.signal() + } +} diff --git a/src/Sources/ToFujiRaw/TIFFPrimitives.swift b/src/Sources/ToFujiRaw/TIFFPrimitives.swift new file mode 100644 index 0000000..05240e1 --- /dev/null +++ b/src/Sources/ToFujiRaw/TIFFPrimitives.swift @@ -0,0 +1,343 @@ +import Foundation + +enum TIFFError: LocalizedError { + case notLittleEndian(URL) + case malformedOffset(URL, Int) + case malformedIFD(URL, Int) + case payloadOutOfBounds(URL, UInt16, Int, Int) + case payloadSizeMismatch(tag: UInt16, expected: Int, actual: Int) + case unexpectedLongPayload(tag: UInt16) + case unsupportedScalarPatch(tag: UInt16, type: UInt16, count: UInt32) + case headerSpaceExhausted(requested: Int, limit: Int) + + var errorDescription: String? { + switch self { + case .notLittleEndian(let url): + return "\(url.path) n'est pas un TIFF little-endian." + case .malformedOffset(let url, let offset): + return "\(url.path) contient un offset TIFF invalide: \(offset)." + case .malformedIFD(let url, let offset): + return "\(url.path) contient une IFD TIFF invalide à l'offset \(offset)." + case .payloadOutOfBounds(let url, let tag, let offset, let size): + return String(format: "%@ contient un payload hors limites pour le tag 0x%04X (offset=%d size=%d).", url.path, tag, offset, size) + case .payloadSizeMismatch(let tag, let expected, let actual): + return String(format: "Payload TIFF invalide pour le tag 0x%04X : %d != %d", tag, actual, expected) + case .unexpectedLongPayload(let tag): + return String(format: "Payload LONG inattendu pour le tag 0x%04X", tag) + case .unsupportedScalarPatch(let tag, let type, let count): + return String(format: "Patch scalaire non supporté pour tag 0x%04X (type=%d count=%d)", tag, type, count) + case .headerSpaceExhausted(let requested, let limit): + return "Pas assez d'espace d'en-tête pour allouer \(requested) octets avant \(limit)." + } + } +} + +enum TIFFType { + static let byte: UInt16 = 1 + static let ascii: UInt16 = 2 + static let short: UInt16 = 3 + static let long: UInt16 = 4 + static let rational: UInt16 = 5 + static let undefined: UInt16 = 7 + static let srational: UInt16 = 10 + + static func size(of tagType: UInt16, count: UInt32) -> Int { + let unit: Int + switch tagType { + case byte, ascii, undefined: + unit = 1 + case short: + unit = 2 + case long: + unit = 4 + case rational, srational: + unit = 8 + default: + unit = 1 + } + return unit * Int(count) + } +} + +struct TIFFEntry: Hashable { + let tag: UInt16 + let type: UInt16 + let count: UInt32 + let value: UInt32 + let entryOffset: Int +} + +struct TIFFFile { + static let maxIFDEntries = 10_000 + + let url: URL + let data: Data + + init(url: URL) throws { + self.url = url + self.data = try Data(contentsOf: url) + guard data.count >= 2, data[0] == 0x49, data[1] == 0x49 else { + throw TIFFError.notLittleEndian(url) + } + } + + func u16(at offset: Int) -> UInt16? { + data.readUInt16LE(at: offset) + } + + func u32(at offset: Int) -> UInt32? { + data.readUInt32LE(at: offset) + } + + func rootIFDOffset() throws -> Int { + guard let rootOffset = u32(at: 4) else { + throw TIFFError.malformedOffset(url, 4) + } + let offset = Int(rootOffset) + guard offset >= 0, offset < data.count else { + throw TIFFError.malformedOffset(url, offset) + } + return offset + } + + func ifdEntries(at offset: Int) throws -> [TIFFEntry] { + guard offset >= 0, offset + 2 <= data.count else { + throw TIFFError.malformedOffset(url, offset) + } + guard let countValue = u16(at: offset) else { + throw TIFFError.malformedIFD(url, offset) + } + let count = Int(countValue) + guard count <= Self.maxIFDEntries else { + throw TIFFError.malformedIFD(url, offset) + } + let entriesStart = offset + 2 + let entriesByteCount = count * 12 + guard entriesByteCount <= data.count - entriesStart else { + throw TIFFError.malformedIFD(url, offset) + } + + return try (0.. [UInt16: TIFFEntry] { + Dictionary(uniqueKeysWithValues: try ifdEntries(at: offset).map { ($0.tag, $0) }) + } + + func payloadBytes(for entry: TIFFEntry) throws -> Data { + let size = TIFFType.size(of: entry.type, count: entry.count) + if size <= 4 { + var value = entry.value.littleEndian + let full = Data(bytes: &value, count: 4) + return full.prefix(size) + } + let start = Int(entry.value) + guard start >= 0, size >= 0, start <= data.count, size <= data.count - start else { + throw TIFFError.payloadOutOfBounds(url, entry.tag, start, size) + } + return data.subdata(in: start..<(start + size)) + } + + func longValues(for entry: TIFFEntry) throws -> [UInt32] { + let payload = try payloadBytes(for: entry) + let expected = Int(entry.count) * 4 + guard payload.count == expected else { + throw TIFFError.unexpectedLongPayload(tag: entry.tag) + } + return stride(from: 0, to: payload.count, by: 4).map { offset in + (UInt32(payload[offset + 3]) << 24) + | (UInt32(payload[offset + 2]) << 16) + | (UInt32(payload[offset + 1]) << 8) + | UInt32(payload[offset]) + } + } +} + +extension Data { + mutating func replaceBytes(at offset: Int, with replacement: Data) { + replaceSubrange(offset..<(offset + replacement.count), with: replacement) + } + + mutating func patchLongInline(_ entry: TIFFEntry, value: UInt32) { + var le = value.littleEndian + let raw = Data(bytes: &le, count: 4) + replaceBytes(at: entry.entryOffset + 8, with: raw) + } + + mutating func patchShortInline(_ entry: TIFFEntry, value: UInt16) { + var le = value.littleEndian + var raw = Data(bytes: &le, count: 2) + raw.append(contentsOf: [0, 0]) + replaceBytes(at: entry.entryOffset + 8, with: raw) + } + + mutating func patchASCII(_ entry: TIFFEntry, text: String) throws { + let count = Int(entry.count) + let payload = text.data(using: .ascii, allowLossyConversion: true) ?? Data() + let encoded = payload.prefix(Swift.max(0, count - 1)) + var final = Data(encoded) + final.append(0) + if final.count < count { + final.append(Data(repeating: 0, count: count - final.count)) + } + if count <= 4 { + if final.count < 4 { + final.append(Data(repeating: 0, count: 4 - final.count)) + } + replaceBytes(at: entry.entryOffset + 8, with: final.prefix(4)) + } else { + replaceBytes(at: Int(entry.value), with: final.prefix(count)) + } + } + + mutating func patchScalarInline(_ entry: TIFFEntry, value: UInt32) throws { + switch (entry.type, entry.count) { + case (TIFFType.short, 1): + patchShortInline(entry, value: UInt16(truncatingIfNeeded: value)) + case (TIFFType.long, 1): + patchLongInline(entry, value: value) + default: + throw TIFFError.unsupportedScalarPatch(tag: entry.tag, type: entry.type, count: entry.count) + } + } + + mutating func patchRational(_ entry: TIFFEntry, numerator: Int32, denominator: Int32, signed: Bool = false) { + var raw = Data() + if signed { + var num = numerator.littleEndian + var den = denominator.littleEndian + raw.append(Data(bytes: &num, count: 4)) + raw.append(Data(bytes: &den, count: 4)) + } else { + var num = UInt32(bitPattern: numerator).littleEndian + var den = UInt32(bitPattern: denominator).littleEndian + raw.append(Data(bytes: &num, count: 4)) + raw.append(Data(bytes: &den, count: 4)) + } + replaceBytes(at: Int(entry.value), with: raw) + } + + mutating func patchEntryPayload(_ entry: TIFFEntry, raw: Data) throws { + let size = TIFFType.size(of: entry.type, count: entry.count) + guard raw.count == size else { + throw TIFFError.payloadSizeMismatch(tag: entry.tag, expected: size, actual: raw.count) + } + if size <= 4 { + replaceBytes(at: entry.entryOffset + 8, with: raw + Data(repeating: 0, count: 4 - raw.count)) + } else { + replaceBytes(at: Int(entry.value), with: raw) + } + } + + func entryPayload(_ entry: TIFFEntry) -> Data { + let size = TIFFType.size(of: entry.type, count: entry.count) + if size <= 4 { + return subdata(in: (entry.entryOffset + 8)..<(entry.entryOffset + 8 + size)) + } + return subdata(in: Int(entry.value)..<(Int(entry.value) + size)) + } + + mutating func zeroRegion(start: Int, length: Int) { + replaceBytes(at: start, with: Data(repeating: 0, count: length)) + } + + mutating func rewriteIFDWithoutTags(at ifdOffset: Int, dropTags: Set) { + let count = Int(readUInt16LE(at: ifdOffset) ?? 0) + var kept: [TIFFEntry] = [] + for index in 0.. Int { + let end = nextFreeOffset + raw.count + guard end <= limitOffset else { + throw TIFFError.headerSpaceExhausted(requested: raw.count, limit: limitOffset) + } + replaceBytes(at: nextFreeOffset, with: raw) + return nextFreeOffset + } + + func maxHeaderPayloadEnd(ifdMaps: [[UInt16: TIFFEntry]], rootIFDOffset: Int) -> Int { + var maxEnd = rootIFDOffset + for ifd in ifdMaps { + maxEnd = Swift.max(maxEnd, rootIFDOffset + 2 + ifd.count * 12 + 4) + for entry in ifd.values { + let size = TIFFType.size(of: entry.type, count: entry.count) + if size > 4 { + maxEnd = Swift.max(maxEnd, Int(entry.value) + size) + } + } + } + return maxEnd + } +} + +extension Data { + mutating func appendLE(_ value: UInt16) { + var le = value.littleEndian + append(Data(bytes: &le, count: 2)) + } + + func readUInt16LE(at offset: Int) -> UInt16? { + guard offset + 2 <= count else { return nil } + return (UInt16(self[offset + 1]) << 8) | UInt16(self[offset]) + } + + func readUInt32LE(at offset: Int) -> UInt32? { + guard offset + 4 <= count else { return nil } + return (UInt32(self[offset + 3]) << 24) + | (UInt32(self[offset + 2]) << 16) + | (UInt32(self[offset + 1]) << 8) + | UInt32(self[offset]) + } +} diff --git a/src/Sources/ToFujiRaw/TIFFTags.swift b/src/Sources/ToFujiRaw/TIFFTags.swift new file mode 100644 index 0000000..f64117a --- /dev/null +++ b/src/Sources/ToFujiRaw/TIFFTags.swift @@ -0,0 +1,56 @@ +import Foundation + +enum TIFFTag { + static let make: UInt16 = 0x010F + static let model: UInt16 = 0x0110 + static let subIFDs: UInt16 = 0x014A + static let imageWidth: UInt16 = 0x0100 + static let imageHeight: UInt16 = 0x0101 + static let stripOffsets: UInt16 = 0x0111 + static let orientation: UInt16 = 0x0112 + static let rowsPerStrip: UInt16 = 0x0116 + static let stripByteCounts: UInt16 = 0x0117 + static let dateTime: UInt16 = 0x0132 + static let xmp: UInt16 = 0x02BC + static let exifIFD: UInt16 = 0x8769 + + static let cfaRepeatPatternDim: UInt16 = 0x828D + static let cfaPattern: UInt16 = 0x828E + static let blackLevel: UInt16 = 0xC61A + static let whiteLevel: UInt16 = 0xC61D + static let defaultCropOrigin: UInt16 = 0xC61F + static let defaultCropSize: UInt16 = 0xC620 + static let colorMatrix1: UInt16 = 0xC621 + static let colorMatrix2: UInt16 = 0xC622 + static let asShotNeutral: UInt16 = 0xC628 + static let calibrationIlluminant1: UInt16 = 0xC65A + static let calibrationIlluminant2: UInt16 = 0xC65B + static let activeArea: UInt16 = 0xC68D + static let maskedAreas: UInt16 = 0xC68E + static let opcodeList1: UInt16 = 0xC740 + static let opcodeList3: UInt16 = 0xC74E + + static let exposureTime: UInt16 = 0x829A + static let fNumber: UInt16 = 0x829D + static let exposureProgram: UInt16 = 0x8822 + static let iso: UInt16 = 0x8827 + static let makerNote: UInt16 = 0x927C + static let dateTimeOriginal: UInt16 = 0x9003 + static let exposureBias: UInt16 = 0x9204 + static let maxAperture: UInt16 = 0x9205 + static let meteringMode: UInt16 = 0x9207 + static let flash: UInt16 = 0x9209 + static let focalLength: UInt16 = 0x920A + static let focalLength35MM: UInt16 = 0xA405 + static let imageUniqueID: UInt16 = 0xA420 + static let lensMake: UInt16 = 0xA433 + static let lensModel: UInt16 = 0xA434 +} + +enum HasselbladMakerNoteTag { + static let lensCode: UInt16 = 0x0045 + static let cropInfo: UInt16 = 0x0059 + static let serial1: UInt16 = 0x0060 + static let serial2: UInt16 = 0x0061 + static let hncsBlocker: UInt16 = 0x0017 +} diff --git a/src/Sources/ToFujiRaw/ToFujiRawApp.swift b/src/Sources/ToFujiRaw/ToFujiRawApp.swift index ce2ab25..62609b9 100644 --- a/src/Sources/ToFujiRaw/ToFujiRawApp.swift +++ b/src/Sources/ToFujiRaw/ToFujiRawApp.swift @@ -17,6 +17,6 @@ struct ToFujiRawApp: App { ContentView() } .windowResizability(.contentSize) - .defaultSize(width: 540, height: 560) + .defaultSize(width: 540, height: 620) } } diff --git a/vendor/hasselblad_x2d_header.3fr b/vendor/hasselblad_x2d_header.3fr new file mode 100644 index 0000000..320e951 Binary files /dev/null and b/vendor/hasselblad_x2d_header.3fr differ