Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
Expand Down
58 changes: 43 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
@@ -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`

---

Expand Down Expand Up @@ -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é

Expand Down Expand Up @@ -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)).
Expand Down
10 changes: 10 additions & 0 deletions scripts/build-dnglab.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions scripts/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
40 changes: 40 additions & 0 deletions src/Sources/ToFujiRaw/BundledTools.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
23 changes: 21 additions & 2 deletions src/Sources/ToFujiRaw/CLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -65,10 +81,13 @@ enum CLI {

private static func printUsage() {
let usage = """
Usage : ToFujiRaw --cli [--mapping <id>] <file1> [<file2> ...]
Usage : ToFujiRaw --cli [--mapping <id>] [--donor <file.3fr>] <file1> [<file2> ...]

Options :
--mapping <id> ID de mapping (défaut : \(CameraMapping.default.id))
--donor <file> 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 :
Expand Down
64 changes: 56 additions & 8 deletions src/Sources/ToFujiRaw/CameraMapping.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading