Compare commits
20 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a5aa1fdef | ||
|
|
b2ffd9edaf | ||
|
|
87e3d99290 | ||
|
|
fb1bf90d5a | ||
|
|
d3822bc672 | ||
|
|
0d283c3dec | ||
|
|
99e9efabd4 | ||
|
|
38868a2aba | ||
|
|
586f87e222 | ||
|
|
4664379340 | ||
|
|
8a88d8243d | ||
|
|
ebedbe660a | ||
|
|
2bb1b4fe12 | ||
|
|
777eef2744 | ||
|
|
6f090b1f0e | ||
|
|
7092ff16b9 | ||
|
|
2d281fcf39 | ||
|
|
a470a232e8 | ||
|
|
cddbbec233 | ||
|
|
d2b78668c3 |
52
.github/workflows/auto-tag.yml
vendored
Normal file
52
.github/workflows/auto-tag.yml
vendored
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
name: Auto Tag on Push
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths-ignore:
|
||||||
|
- '**.md'
|
||||||
|
- '.github/**'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
auto-tag:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # Récupère tout l'historique pour les tags
|
||||||
|
|
||||||
|
- name: Get latest tag
|
||||||
|
id: get_tag
|
||||||
|
run: |
|
||||||
|
# Récupère le dernier tag v0.x.x
|
||||||
|
LATEST_TAG=$(git tag -l "v0.*.*" | sort -V | tail -n 1)
|
||||||
|
|
||||||
|
if [ -z "$LATEST_TAG" ]; then
|
||||||
|
# Pas de tag existant, on commence à v0.1.0
|
||||||
|
NEW_TAG="v0.1.0"
|
||||||
|
else
|
||||||
|
# Extrait les numéros de version
|
||||||
|
VERSION=${LATEST_TAG#v}
|
||||||
|
MAJOR=$(echo $VERSION | cut -d. -f1)
|
||||||
|
MINOR=$(echo $VERSION | cut -d. -f2)
|
||||||
|
PATCH=$(echo $VERSION | cut -d. -f3)
|
||||||
|
|
||||||
|
# Incrémente le patch
|
||||||
|
NEW_PATCH=$((PATCH + 1))
|
||||||
|
NEW_TAG="v${MAJOR}.${MINOR}.${NEW_PATCH}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "new_tag=$NEW_TAG" >> $GITHUB_OUTPUT
|
||||||
|
echo "Nouveau tag: $NEW_TAG"
|
||||||
|
|
||||||
|
- name: Create and push tag
|
||||||
|
run: |
|
||||||
|
git config user.name "GitHub Actions"
|
||||||
|
git config user.email "actions@github.com"
|
||||||
|
git tag ${{ steps.get_tag.outputs.new_tag }}
|
||||||
|
git push origin ${{ steps.get_tag.outputs.new_tag }}
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
161
.github/workflows/build-release.yml
vendored
Normal file
161
.github/workflows/build-release.yml
vendored
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
name: Build and Release DMG
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v0.*.*'
|
||||||
|
- 'v[1-9].*.*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: macos-15
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Xcode
|
||||||
|
uses: maxim-lobanov/setup-xcode@v1
|
||||||
|
with:
|
||||||
|
xcode-version: '16.1'
|
||||||
|
|
||||||
|
- name: Resolve Swift Package Dependencies
|
||||||
|
run: |
|
||||||
|
xcodebuild -resolvePackageDependencies \
|
||||||
|
-scheme iDither
|
||||||
|
|
||||||
|
- name: Build app
|
||||||
|
run: |
|
||||||
|
xcodebuild -scheme iDither \
|
||||||
|
-configuration Release \
|
||||||
|
-derivedDataPath ./build \
|
||||||
|
-destination 'platform=macOS' \
|
||||||
|
clean build
|
||||||
|
|
||||||
|
- name: Create App Bundle
|
||||||
|
run: |
|
||||||
|
echo "📦 Création du bundle .app..."
|
||||||
|
|
||||||
|
# Chemins des éléments compilés
|
||||||
|
EXECUTABLE="./build/Build/Products/Release/iDither"
|
||||||
|
RESOURCES_BUNDLE="./build/Build/Products/Release/iDither_iDither.bundle"
|
||||||
|
|
||||||
|
# Vérification de l'exécutable
|
||||||
|
if [ ! -f "$EXECUTABLE" ]; then
|
||||||
|
echo "❌ Exécutable non trouvé : $EXECUTABLE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Exécutable trouvé ($(du -h "$EXECUTABLE" | cut -f1))"
|
||||||
|
|
||||||
|
# Crée la structure du bundle .app
|
||||||
|
APP_DIR="./iDither.app"
|
||||||
|
mkdir -p "$APP_DIR/Contents/MacOS"
|
||||||
|
mkdir -p "$APP_DIR/Contents/Resources"
|
||||||
|
|
||||||
|
# Copie l'exécutable
|
||||||
|
cp "$EXECUTABLE" "$APP_DIR/Contents/MacOS/iDither"
|
||||||
|
chmod +x "$APP_DIR/Contents/MacOS/iDither"
|
||||||
|
echo "✅ Exécutable copié dans le bundle"
|
||||||
|
|
||||||
|
# Copie le bundle de ressources (shaders Metal)
|
||||||
|
if [ -d "$RESOURCES_BUNDLE" ]; then
|
||||||
|
cp -R "$RESOURCES_BUNDLE" "$APP_DIR/Contents/Resources/"
|
||||||
|
echo "✅ Bundle de ressources copié (shaders Metal inclus)"
|
||||||
|
else
|
||||||
|
echo "⚠️ Bundle de ressources non trouvé (l'app pourrait ne pas fonctionner)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Crée Info.plist
|
||||||
|
cat > "$APP_DIR/Contents/Info.plist" << 'EOF'
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>iDither</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.ewengadonnaud.iDither</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>iDither</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>iDither</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>14.0</string>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<true/>
|
||||||
|
<key>LSApplicationCategoryType</key>
|
||||||
|
<string>public.app-category.graphics-design</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "✅ Info.plist créé"
|
||||||
|
echo ""
|
||||||
|
echo "📦 Structure du bundle .app :"
|
||||||
|
ls -lh "$APP_DIR/Contents/MacOS/"
|
||||||
|
ls -lh "$APP_DIR/Contents/Resources/" 2>/dev/null || echo "(pas de ressources visibles)"
|
||||||
|
|
||||||
|
- name: Create DMG
|
||||||
|
run: |
|
||||||
|
APP_PATH="./iDither.app"
|
||||||
|
|
||||||
|
if [ ! -d "$APP_PATH" ]; then
|
||||||
|
echo "❌ Bundle .app non trouvé"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Création du DMG depuis : $APP_PATH"
|
||||||
|
|
||||||
|
# Crée un dossier temporaire pour le DMG
|
||||||
|
mkdir -p dmg_content
|
||||||
|
cp -R "$APP_PATH" dmg_content/
|
||||||
|
|
||||||
|
# Ajoute un lien symbolique vers /Applications
|
||||||
|
ln -s /Applications dmg_content/Applications
|
||||||
|
|
||||||
|
# Crée le DMG
|
||||||
|
DMG_NAME="iDither-${{ github.ref_name }}.dmg"
|
||||||
|
hdiutil create -volname "iDither" \
|
||||||
|
-srcfolder dmg_content \
|
||||||
|
-ov -format UDZO \
|
||||||
|
"$DMG_NAME"
|
||||||
|
|
||||||
|
echo "✅ DMG créé :"
|
||||||
|
ls -lh "$DMG_NAME"
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
files: iDither-*.dmg
|
||||||
|
draft: false
|
||||||
|
prerelease: ${{ startsWith(github.ref, 'refs/tags/v0.') }}
|
||||||
|
body: |
|
||||||
|
## iDither ${{ github.ref_name }}
|
||||||
|
|
||||||
|
Application macOS native de dithering en temps réel, propulsée par Metal et SwiftUI.
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
1. Téléchargez le fichier `.dmg`
|
||||||
|
2. Ouvrez-le et glissez **iDither** vers **Applications**
|
||||||
|
3. Au premier lancement : **clic droit** → **Ouvrir** (contournement de la sécurité Gatekeeper)
|
||||||
|
|
||||||
|
### Algorithmes disponibles
|
||||||
|
- Matrices ordonnées (Bayer 2x2, 4x4, 8x8 / Cluster 4x4, 8x8)
|
||||||
|
- Blue Noise approximé
|
||||||
|
- Diffusion d'erreur Floyd-Steinberg
|
||||||
|
- Mode Chaos/FX avec distorsions avancées
|
||||||
|
|
||||||
|
---
|
||||||
|
**Compatibilité :** macOS 14.0+ (Sonoma)
|
||||||
|
**Architecture :** Apple Silicon (M1/M2/M3/M4) & Intel
|
||||||
|
**Build automatique** via GitHub Actions
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
54
README.md
54
README.md
|
|
@ -1,3 +1,55 @@
|
||||||
# iDither
|
# iDither
|
||||||
|
|
||||||
Image/video dithering tool built with Swift.
|
<p align="center">
|
||||||
|
<img src="https://img.shields.io/badge/Swift-6.0-F05138?style=for-the-badge&logo=swift&logoColor=white" alt="Swift 6.0">
|
||||||
|
<img src="https://img.shields.io/badge/Platform-macOS%2014.0%2B-000000?style=for-the-badge&logo=apple&logoColor=white" alt="Platform macOS">
|
||||||
|
<img src="https://img.shields.io/badge/Render-Metal-666666?style=for-the-badge&logo=apple&logoColor=white" alt="Metal">
|
||||||
|
<img src="https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge" alt="License: MIT">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="./assets/img/iDither.png" alt="iDither temporary logo" width="800">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
iDither est une application macOS native dédiée au traitement d'images par dithering. Elle exploite la puissance de Metal pour transformer des images en visuels rétro, lo-fi ou texturés avec un rendu en temps réel.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="./assets/img/idithershowcase.jpeg" width="800" title="iDither">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## Fonctionnalités
|
||||||
|
|
||||||
|
### Algorithmes de Dithering
|
||||||
|
L'application propose plusieurs méthodes de diffusion et de trammage pour s'adapter à différents styles esthétiques :
|
||||||
|
- **Matrices ordonnées** : Bayer (2x2, 4x4, 8x8) et Cluster (4x4, 8x8).
|
||||||
|
- **Bruit** : Blue Noise approximé.
|
||||||
|
- **Diffusion d'erreur** : Floyd-Steinberg pour un rendu plus organique.
|
||||||
|
|
||||||
|
### Pré-traitement et Quantisation
|
||||||
|
- Ajustement en direct de la luminosité et du contraste.
|
||||||
|
- Contrôle de l'échelle des pixels (Pixel Scale) pour un effet pixel art.
|
||||||
|
- Gestion de la profondeur des couleurs (1 à 32 niveaux) et mode niveaux de gris (1-bit).
|
||||||
|
|
||||||
|
### Mode Chaos / FX
|
||||||
|
Un moteur d'effets intégré permet d'aller au-delà du dithering classique en introduisant des imperfections contrôlées :
|
||||||
|
- **Distorsion de motif** : Rotation et décalage (jitter) des matrices de dithering.
|
||||||
|
- **Glitch spatial** : Déplacement de pixels, turbulence et aberration chromatique.
|
||||||
|
- **Manipulation de seuil** : Injection de bruit et distorsion ondulatoire.
|
||||||
|
- **Chaos de quantification** : Variation aléatoire de la profondeur de bits et de la palette.
|
||||||
|
|
||||||
|
### Exportation
|
||||||
|
- Formats supportés : PNG, TIFF, JPEG.
|
||||||
|
- Mise à l'échelle à l'export (1x, 2x, 4x) pour conserver la netteté sur les écrans haute densité.
|
||||||
|
- Options pour préserver les métadonnées et aplatir la transparence.
|
||||||
|
|
||||||
|
## Technologies
|
||||||
|
|
||||||
|
Le projet est développé en Swift 6.0 et utilise SwiftUI pour l'interface et Metal pour le pipeline de rendu graphique, assurant des performances optimales même lors de la manipulation de paramètres complexes.
|
||||||
|
|
||||||
|
## Utilisation
|
||||||
|
|
||||||
|
1. Glissez une image dans la fenêtre principale ou utilisez le bouton d'importation.
|
||||||
|
2. Sélectionnez un algorithme et ajustez les paramètres dans la barre latérale.
|
||||||
|
3. Exportez le résultat final via le bouton d'exportation situé dans la barre d'outils.
|
||||||
|
|
||||||
|
Compatible avec macOS 14.0 et versions ultérieures.
|
||||||
|
|
@ -4,12 +4,19 @@ import UniformTypeIdentifiers
|
||||||
struct ContentView: View {
|
struct ContentView: View {
|
||||||
@State private var viewModel = DitherViewModel()
|
@State private var viewModel = DitherViewModel()
|
||||||
@State private var isImporting = false
|
@State private var isImporting = false
|
||||||
@State private var isExporting = false
|
|
||||||
|
// Export State
|
||||||
|
@State private var showExportOptionsSheet = false
|
||||||
|
@State private var exportFormat: ExportFormat = .png
|
||||||
|
@State private var exportScale: CGFloat = 1.0
|
||||||
|
@State private var jpegQuality: Double = 0.85
|
||||||
|
@State private var preserveMetadata = true
|
||||||
|
@State private var flattenTransparency = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationSplitView {
|
NavigationSplitView {
|
||||||
SidebarView(viewModel: viewModel, isImporting: $isImporting, isExporting: $isExporting)
|
SidebarView(viewModel: viewModel, isImporting: $isImporting, showExportOptions: $showExportOptionsSheet)
|
||||||
.navigationSplitViewColumnWidth(min: 260, ideal: 300)
|
.navigationSplitViewColumnWidth(min: 280, ideal: 300)
|
||||||
} detail: {
|
} detail: {
|
||||||
DetailView(viewModel: viewModel, loadFromProviders: loadFromProviders)
|
DetailView(viewModel: viewModel, loadFromProviders: loadFromProviders)
|
||||||
}
|
}
|
||||||
|
|
@ -20,8 +27,11 @@ struct ContentView: View {
|
||||||
.onChange(of: viewModel.brightness) { _, _ in viewModel.processImage() }
|
.onChange(of: viewModel.brightness) { _, _ in viewModel.processImage() }
|
||||||
.onChange(of: viewModel.contrast) { _, _ in viewModel.processImage() }
|
.onChange(of: viewModel.contrast) { _, _ in viewModel.processImage() }
|
||||||
.onChange(of: viewModel.pixelScale) { _, _ in viewModel.processImage() }
|
.onChange(of: viewModel.pixelScale) { _, _ in viewModel.processImage() }
|
||||||
|
.onChange(of: viewModel.colorDepth) { _, _ in viewModel.processImage() }
|
||||||
.onChange(of: viewModel.selectedAlgorithm) { _, _ in viewModel.processImage() }
|
.onChange(of: viewModel.selectedAlgorithm) { _, _ in viewModel.processImage() }
|
||||||
.onChange(of: viewModel.isGrayscale) { _, _ in viewModel.processImage() }
|
.onChange(of: viewModel.isGrayscale) { _, _ in viewModel.processImage() }
|
||||||
|
// CHAOS / FX PARAMETERS (Grouped in modifier)
|
||||||
|
.onChaosChange(viewModel: viewModel)
|
||||||
// File Importer at the very top level
|
// File Importer at the very top level
|
||||||
.fileImporter(
|
.fileImporter(
|
||||||
isPresented: $isImporting,
|
isPresented: $isImporting,
|
||||||
|
|
@ -37,14 +47,84 @@ struct ContentView: View {
|
||||||
print("Import failed: \(error.localizedDescription)")
|
print("Import failed: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.fileExporter(
|
// Export Options Sheet
|
||||||
isPresented: $isExporting,
|
.sheet(isPresented: $showExportOptionsSheet) {
|
||||||
document: ImageDocument(image: viewModel.processedImage),
|
NavigationStack {
|
||||||
contentType: .png,
|
Form {
|
||||||
defaultFilename: "dithered_image"
|
// SECTION 1: Format
|
||||||
) { result in
|
Section("Format") {
|
||||||
if case .failure(let error) = result {
|
Picker("Format", selection: $exportFormat) {
|
||||||
print("Export failed: \(error.localizedDescription)")
|
ForEach(ExportFormat.allCases) { format in
|
||||||
|
Text(format.rawValue).tag(format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
|
||||||
|
// Show quality slider ONLY for JPEG
|
||||||
|
if exportFormat == .jpeg {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack {
|
||||||
|
Text("Quality")
|
||||||
|
Spacer()
|
||||||
|
Text("\(Int(jpegQuality * 100))%")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
}
|
||||||
|
Slider(value: $jpegQuality, in: 0.1...1.0)
|
||||||
|
.tint(.accentColor)
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECTION 2: Resolution
|
||||||
|
Section("Resolution") {
|
||||||
|
Picker("Scale", selection: $exportScale) {
|
||||||
|
Text("1× (Original)").tag(1.0)
|
||||||
|
Text("2× (Double)").tag(2.0)
|
||||||
|
Text("4× (Quadruple)").tag(4.0)
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECTION 3: Options
|
||||||
|
Section("Options") {
|
||||||
|
Toggle("Preserve metadata", isOn: $preserveMetadata)
|
||||||
|
|
||||||
|
if exportFormat == .png || exportFormat == .tiff {
|
||||||
|
Toggle("Flatten transparency", isOn: $flattenTransparency)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECTION 4: Info
|
||||||
|
Section {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
Text("Export will apply all current dithering settings")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.formStyle(.grouped)
|
||||||
|
.navigationTitle("Export Options")
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
|
Button("Cancel") {
|
||||||
|
showExportOptionsSheet = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
|
Button("Export...") {
|
||||||
|
showExportOptionsSheet = false
|
||||||
|
// Now open NSSavePanel with configured settings
|
||||||
|
performExportWithOptions()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(minWidth: 450, idealWidth: 500, minHeight: 400)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -64,90 +144,259 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Helper for FileExporter
|
func performExportWithOptions() {
|
||||||
struct ImageDocument: FileDocument {
|
let savePanel = NSSavePanel()
|
||||||
var image: CGImage?
|
savePanel.canCreateDirectories = true
|
||||||
|
savePanel.showsTagField = true
|
||||||
|
|
||||||
init(image: CGImage?) {
|
// Set filename with correct extension based on chosen format
|
||||||
self.image = image
|
let baseName = "dithered_image"
|
||||||
|
savePanel.nameFieldStringValue = "\(baseName).\(exportFormat.fileExtension)"
|
||||||
|
|
||||||
|
// Set allowed file types
|
||||||
|
savePanel.allowedContentTypes = [exportFormat.utType]
|
||||||
|
|
||||||
|
savePanel.begin { response in
|
||||||
|
guard response == .OK,
|
||||||
|
let url = savePanel.url else { return }
|
||||||
|
|
||||||
|
// Perform export with the configured settings
|
||||||
|
viewModel.exportImage(to: url,
|
||||||
|
format: exportFormat,
|
||||||
|
scale: exportScale,
|
||||||
|
jpegQuality: jpegQuality,
|
||||||
|
preserveMetadata: preserveMetadata,
|
||||||
|
flattenTransparency: flattenTransparency)
|
||||||
}
|
}
|
||||||
|
|
||||||
static var readableContentTypes: [UTType] { [.png] }
|
|
||||||
|
|
||||||
init(configuration: ReadConfiguration) throws {
|
|
||||||
// Read not implemented for export-only
|
|
||||||
self.image = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
|
|
||||||
guard let image = image else { throw CocoaError(.fileWriteUnknown) }
|
|
||||||
let nsImage = NSImage(cgImage: image, size: NSSize(width: image.width, height: image.height))
|
|
||||||
guard let tiffData = nsImage.tiffRepresentation,
|
|
||||||
let bitmap = NSBitmapImageRep(data: tiffData),
|
|
||||||
let pngData = bitmap.representation(using: .png, properties: [:]) else {
|
|
||||||
throw CocoaError(.fileWriteUnknown)
|
|
||||||
}
|
|
||||||
return FileWrapper(regularFileWithContents: pngData)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SidebarView (Updated to trigger sheet)
|
||||||
struct SidebarView: View {
|
struct SidebarView: View {
|
||||||
@Bindable var viewModel: DitherViewModel
|
@Bindable var viewModel: DitherViewModel
|
||||||
@Binding var isImporting: Bool
|
@Binding var isImporting: Bool
|
||||||
@Binding var isExporting: Bool
|
@Binding var showExportOptions: Bool
|
||||||
|
|
||||||
|
@State private var showChaosSection = false // Chaos Section State
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
ScrollView {
|
||||||
Section("Dithering Algorithm") {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
|
||||||
|
// --- ALGORITHM SECTION ---
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("ALGORITHM")
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.top, 8)
|
||||||
|
|
||||||
Picker("Algorithm", selection: $viewModel.selectedAlgorithm) {
|
Picker("Algorithm", selection: $viewModel.selectedAlgorithm) {
|
||||||
ForEach(DitherAlgorithm.allCases) { algo in
|
ForEach(DitherAlgorithm.allCases) { algo in
|
||||||
Text(algo.name).tag(algo)
|
Text(algo.name).tag(algo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
|
||||||
Toggle("Grayscale / 1-bit", isOn: $viewModel.isGrayscale)
|
Toggle("Grayscale / 1-bit", isOn: $viewModel.isGrayscale)
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
|
|
||||||
Section("Pre-Processing") {
|
Divider()
|
||||||
VStack(alignment: .leading) {
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
// --- PRE-PROCESSING SECTION ---
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text("PRE-PROCESSING")
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
// Brightness slider
|
||||||
|
VStack(spacing: 6) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Brightness")
|
Text("Brightness")
|
||||||
|
.font(.system(size: 13))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(String(format: "%.2f", viewModel.brightness))
|
Text(String(format: "%.2f", viewModel.brightness))
|
||||||
.monospacedDigit()
|
.font(.system(size: 13, weight: .medium))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
.monospacedDigit()
|
||||||
}
|
}
|
||||||
Slider(value: $viewModel.brightness, in: -1.0...1.0)
|
Slider(value: $viewModel.brightness, in: -1.0...1.0)
|
||||||
|
.tint(.accentColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading) {
|
// Contrast slider
|
||||||
|
VStack(spacing: 6) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Contrast")
|
Text("Contrast")
|
||||||
|
.font(.system(size: 13))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(String(format: "%.2f", viewModel.contrast))
|
Text(String(format: "%.2f", viewModel.contrast))
|
||||||
.monospacedDigit()
|
.font(.system(size: 13, weight: .medium))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
.monospacedDigit()
|
||||||
}
|
}
|
||||||
Slider(value: $viewModel.contrast, in: 0.0...4.0)
|
Slider(value: $viewModel.contrast, in: 0.0...4.0)
|
||||||
|
.tint(.accentColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading) {
|
// Pixel Scale slider
|
||||||
|
VStack(spacing: 6) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Pixel Scale")
|
Text("Pixel Scale")
|
||||||
|
.font(.system(size: 13))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("\(Int(viewModel.pixelScale))x")
|
Text("\(Int(viewModel.pixelScale))x")
|
||||||
.monospacedDigit()
|
.font(.system(size: 13, weight: .medium))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
.monospacedDigit()
|
||||||
}
|
}
|
||||||
Slider(value: $viewModel.pixelScale, in: 1.0...20.0, step: 1.0)
|
Slider(value: $viewModel.pixelScale, in: 1.0...20.0, step: 1.0)
|
||||||
|
.tint(.accentColor)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
// --- QUANTIZATION SECTION ---
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text("QUANTIZATION")
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
HStack {
|
||||||
|
Text("Color Depth")
|
||||||
|
.font(.system(size: 13))
|
||||||
|
Spacer()
|
||||||
|
Text("\(Int(viewModel.colorDepth))")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.monospacedDigit()
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
Slider(value: $viewModel.colorDepth, in: 1.0...32.0, step: 1.0)
|
||||||
.padding(.vertical)
|
.tint(.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
|
||||||
|
// --- CHAOS / FX SECTION ---
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Button(action: {
|
||||||
|
withAnimation(.snappy) {
|
||||||
|
showChaosSection.toggle()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
HStack {
|
||||||
|
Text("CHAOS / FX")
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: showChaosSection ? "chevron.down" : "chevron.right")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
|
||||||
|
if showChaosSection {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
// Pattern Distortion
|
||||||
|
Text("Pattern Distortion")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundStyle(.secondary.opacity(0.8))
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
|
SliderControl(label: "Offset Jitter", value: $viewModel.offsetJitter, range: 0...1, format: .percent)
|
||||||
|
SliderControl(label: "Rotation", value: $viewModel.patternRotation, range: 0...1, format: .percent)
|
||||||
|
|
||||||
|
// Error Propagation (Floyd-Steinberg only)
|
||||||
|
if viewModel.selectedAlgorithm == .floydSteinberg {
|
||||||
|
Text("Error Propagation")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundStyle(.secondary.opacity(0.8))
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
|
SliderControl(label: "Error Amplify", value: $viewModel.errorAmplify, range: 0.5...3.0, format: .multiplier)
|
||||||
|
SliderControl(label: "Random Direction", value: $viewModel.errorRandomness, range: 0...1, format: .percent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Threshold Effects
|
||||||
|
Text("Threshold Effects")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundStyle(.secondary.opacity(0.8))
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
|
SliderControl(label: "Noise Injection", value: $viewModel.thresholdNoise, range: 0...1, format: .percent)
|
||||||
|
SliderControl(label: "Wave Distortion", value: $viewModel.waveDistortion, range: 0...1, format: .percent)
|
||||||
|
|
||||||
|
// Spatial Glitch
|
||||||
|
Text("Spatial Glitch")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundStyle(.secondary.opacity(0.8))
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
|
SliderControl(label: "Pixel Displace", value: $viewModel.pixelDisplace, range: 0...100, format: .pixels)
|
||||||
|
SliderControl(label: "Turbulence", value: $viewModel.turbulence, range: 0...1, format: .percent)
|
||||||
|
SliderControl(label: "Chroma Aberration", value: $viewModel.chromaAberration, range: 0...20, format: .pixels)
|
||||||
|
|
||||||
|
// Quantization Chaos
|
||||||
|
Text("Quantization Chaos")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundStyle(.secondary.opacity(0.8))
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
|
SliderControl(label: "Bit Depth Chaos", value: $viewModel.bitDepthChaos, range: 0...1, format: .percent)
|
||||||
|
SliderControl(label: "Palette Randomize", value: $viewModel.paletteRandomize, range: 0...1, format: .percent)
|
||||||
|
|
||||||
|
// Reset Button
|
||||||
|
Button(action: {
|
||||||
|
withAnimation {
|
||||||
|
viewModel.resetChaosEffects()
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text("Reset All Chaos")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
.controlSize(.small)
|
||||||
|
.padding(.top, 12)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(12)
|
||||||
|
.background(Color(nsColor: .controlBackgroundColor))
|
||||||
|
.cornerRadius(8)
|
||||||
|
.shadow(color: .black.opacity(0.05), radius: 1, x: 0, y: 1)
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
Button("Force Refresh (Debug)") {
|
||||||
|
viewModel.forceRefresh()
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.leading, 4)
|
||||||
|
#endif
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
}
|
||||||
|
.background(.regularMaterial)
|
||||||
|
.ignoresSafeArea(edges: .top)
|
||||||
.navigationTitle("iDither")
|
.navigationTitle("iDither")
|
||||||
|
.frame(minWidth: 280, maxWidth: .infinity, alignment: .leading)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup(placement: .primaryAction) {
|
ToolbarItemGroup(placement: .primaryAction) {
|
||||||
Button(action: { isImporting = true }) {
|
Button(action: { isImporting = true }) {
|
||||||
|
|
@ -155,7 +404,7 @@ struct SidebarView: View {
|
||||||
}
|
}
|
||||||
.help("Import Image")
|
.help("Import Image")
|
||||||
|
|
||||||
Button(action: { isExporting = true }) {
|
Button(action: { showExportOptions = true }) {
|
||||||
Label("Export", systemImage: "square.and.arrow.up")
|
Label("Export", systemImage: "square.and.arrow.up")
|
||||||
}
|
}
|
||||||
.disabled(viewModel.processedImage == nil)
|
.disabled(viewModel.processedImage == nil)
|
||||||
|
|
@ -327,6 +576,80 @@ struct FloatingToolbar: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Custom Modifier to handle Chaos/FX parameter observation
|
||||||
|
// Extracts complexity from the main ContentView body to fix compiler type-check timeout
|
||||||
|
struct ChaosEffectObserver: ViewModifier {
|
||||||
|
var viewModel: DitherViewModel
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.onChange(of: viewModel.offsetJitter) { _, _ in viewModel.processImage() }
|
||||||
|
.onChange(of: viewModel.patternRotation) { _, _ in viewModel.processImage() }
|
||||||
|
.onChange(of: viewModel.errorAmplify) { _, _ in viewModel.processImage() }
|
||||||
|
.onChange(of: viewModel.errorRandomness) { _, _ in viewModel.processImage() }
|
||||||
|
.onChange(of: viewModel.thresholdNoise) { _, _ in viewModel.processImage() }
|
||||||
|
.onChange(of: viewModel.waveDistortion) { _, _ in viewModel.processImage() }
|
||||||
|
.onChange(of: viewModel.pixelDisplace) { _, _ in viewModel.processImage() }
|
||||||
|
.onChange(of: viewModel.turbulence) { _, _ in viewModel.processImage() }
|
||||||
|
.onChange(of: viewModel.chromaAberration) { _, _ in viewModel.processImage() }
|
||||||
|
.onChange(of: viewModel.bitDepthChaos) { _, _ in viewModel.processImage() }
|
||||||
|
.onChange(of: viewModel.paletteRandomize) { _, _ in viewModel.processImage() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func onChaosChange(viewModel: DitherViewModel) -> some View {
|
||||||
|
self.modifier(ChaosEffectObserver(viewModel: viewModel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SliderControl: View {
|
||||||
|
let label: String
|
||||||
|
@Binding var value: Double
|
||||||
|
let range: ClosedRange<Double>
|
||||||
|
let format: ValueFormat
|
||||||
|
|
||||||
|
enum ValueFormat {
|
||||||
|
case percent
|
||||||
|
case multiplier
|
||||||
|
case pixels
|
||||||
|
case raw
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
HStack {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 11))
|
||||||
|
Spacer()
|
||||||
|
Text(formattedValue)
|
||||||
|
.font(.system(size: 11, weight: .medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.monospacedDigit()
|
||||||
|
}
|
||||||
|
Slider(value: $value, in: range)
|
||||||
|
.tint(.accentColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var formattedValue: String {
|
||||||
|
switch format {
|
||||||
|
case .percent:
|
||||||
|
return "\(Int(value * 100))%"
|
||||||
|
case .multiplier:
|
||||||
|
return String(format: "%.1f×", value)
|
||||||
|
case .pixels:
|
||||||
|
return "\(Int(value))px"
|
||||||
|
case .raw:
|
||||||
|
return String(format: "%.2f", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct CheckeredBackground: View {
|
struct CheckeredBackground: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Canvas { context, size in
|
Canvas { context, size in
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import Metal
|
@preconcurrency import Metal
|
||||||
import MetalKit
|
import MetalKit
|
||||||
import CoreGraphics
|
import CoreGraphics
|
||||||
|
|
||||||
|
|
@ -6,14 +6,38 @@ struct RenderParameters {
|
||||||
var brightness: Float
|
var brightness: Float
|
||||||
var contrast: Float
|
var contrast: Float
|
||||||
var pixelScale: Float
|
var pixelScale: Float
|
||||||
var algorithm: Int32 // 0: None, 1: Bayer 8x8, 2: Bayer 4x4
|
var colorDepth: Float
|
||||||
var isGrayscale: Int32 // 0: false, 1: true
|
var algorithm: Int32
|
||||||
|
var isGrayscale: Int32
|
||||||
|
|
||||||
|
// CHAOS / FX PARAMETERS
|
||||||
|
var offsetJitter: Float
|
||||||
|
var patternRotation: Float
|
||||||
|
|
||||||
|
var errorAmplify: Float
|
||||||
|
var errorRandomness: Float
|
||||||
|
|
||||||
|
var thresholdNoise: Float
|
||||||
|
var waveDistortion: Float
|
||||||
|
|
||||||
|
var pixelDisplace: Float
|
||||||
|
var turbulence: Float
|
||||||
|
var chromaAberration: Float
|
||||||
|
|
||||||
|
var bitDepthChaos: Float
|
||||||
|
var paletteRandomize: Float
|
||||||
|
|
||||||
|
var randomSeed: UInt32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ✅ SUPPRESSION DE @MainActor - Metal est thread-safe en pratique
|
||||||
final class MetalImageRenderer: Sendable {
|
final class MetalImageRenderer: Sendable {
|
||||||
|
// ✅ nonisolated(unsafe) car Metal est thread-safe malgré l'absence de Sendable
|
||||||
private let device: MTLDevice
|
private let device: MTLDevice
|
||||||
private let commandQueue: MTLCommandQueue
|
private let commandQueue: MTLCommandQueue
|
||||||
private let pipelineState: MTLComputePipelineState
|
private let pipelineState: MTLComputePipelineState
|
||||||
|
private let pipelineStateFS_Pass1: MTLComputePipelineState?
|
||||||
|
private let pipelineStateFS_Pass2: MTLComputePipelineState?
|
||||||
|
|
||||||
init?() {
|
init?() {
|
||||||
guard let device = MTLCreateSystemDefaultDevice(),
|
guard let device = MTLCreateSystemDefaultDevice(),
|
||||||
|
|
@ -28,21 +52,36 @@ final class MetalImageRenderer: Sendable {
|
||||||
|
|
||||||
do {
|
do {
|
||||||
self.pipelineState = try device.makeComputePipelineState(function: function)
|
self.pipelineState = try device.makeComputePipelineState(function: function)
|
||||||
|
// Load FS Kernels
|
||||||
|
if let f1 = library.makeFunction(name: "ditherShaderFS_Pass1"),
|
||||||
|
let f2 = library.makeFunction(name: "ditherShaderFS_Pass2") {
|
||||||
|
self.pipelineStateFS_Pass1 = try device.makeComputePipelineState(function: f1)
|
||||||
|
self.pipelineStateFS_Pass2 = try device.makeComputePipelineState(function: f2)
|
||||||
|
} else {
|
||||||
|
self.pipelineStateFS_Pass1 = nil
|
||||||
|
self.pipelineStateFS_Pass2 = nil
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to create pipeline state: \(error)")
|
print("Failed to create pipeline state: \(error)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func render(input: CGImage, params: RenderParameters) -> CGImage? {
|
func render(input: CGImage, params: RenderParameters) async -> CGImage? {
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
|
autoreleasepool {
|
||||||
|
print("🎨 Metal render started - Image: \(input.width)x\(input.height), Algo: \(params.algorithm)")
|
||||||
|
|
||||||
let textureLoader = MTKTextureLoader(device: device)
|
let textureLoader = MTKTextureLoader(device: device)
|
||||||
|
|
||||||
// Load input texture
|
|
||||||
guard let inputTexture = try? textureLoader.newTexture(cgImage: input, options: [.origin: MTKTextureLoader.Origin.topLeft]) else {
|
guard let inputTexture = try? textureLoader.newTexture(cgImage: input, options: [.origin: MTKTextureLoader.Origin.topLeft]) else {
|
||||||
return nil
|
print("❌ Failed to create input texture")
|
||||||
|
continuation.resume(returning: nil)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create output texture
|
print("✅ Input texture created: \(inputTexture.width)x\(inputTexture.height)")
|
||||||
|
|
||||||
let descriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .rgba8Unorm,
|
let descriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .rgba8Unorm,
|
||||||
width: inputTexture.width,
|
width: inputTexture.width,
|
||||||
height: inputTexture.height,
|
height: inputTexture.height,
|
||||||
|
|
@ -50,20 +89,64 @@ final class MetalImageRenderer: Sendable {
|
||||||
descriptor.usage = [.shaderWrite, .shaderRead]
|
descriptor.usage = [.shaderWrite, .shaderRead]
|
||||||
|
|
||||||
guard let outputTexture = device.makeTexture(descriptor: descriptor) else {
|
guard let outputTexture = device.makeTexture(descriptor: descriptor) else {
|
||||||
return nil
|
print("❌ Failed to create output texture")
|
||||||
|
continuation.resume(returning: nil)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encode command
|
|
||||||
guard let commandBuffer = commandQueue.makeCommandBuffer(),
|
guard let commandBuffer = commandQueue.makeCommandBuffer(),
|
||||||
let computeEncoder = commandBuffer.makeComputeCommandEncoder() else {
|
let computeEncoder = commandBuffer.makeComputeCommandEncoder() else {
|
||||||
return nil
|
print("❌ Failed to create command buffer or encoder")
|
||||||
|
continuation.resume(returning: nil)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var params = params
|
||||||
|
|
||||||
|
if params.algorithm == 7, let pipe1 = pipelineStateFS_Pass1, let pipe2 = pipelineStateFS_Pass2 {
|
||||||
|
print("🔄 Using Floyd-Steinberg two-pass rendering")
|
||||||
|
|
||||||
|
let errorDesc = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .rgba16Float,
|
||||||
|
width: inputTexture.width,
|
||||||
|
height: inputTexture.height,
|
||||||
|
mipmapped: false)
|
||||||
|
errorDesc.usage = [.shaderWrite, .shaderRead]
|
||||||
|
|
||||||
|
guard let errorTexture = device.makeTexture(descriptor: errorDesc) else {
|
||||||
|
computeEncoder.endEncoding()
|
||||||
|
continuation.resume(returning: nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// PASS 1
|
||||||
|
computeEncoder.setComputePipelineState(pipe1)
|
||||||
|
computeEncoder.setTexture(inputTexture, index: 0)
|
||||||
|
computeEncoder.setTexture(outputTexture, index: 1)
|
||||||
|
computeEncoder.setTexture(errorTexture, index: 2)
|
||||||
|
computeEncoder.setBytes(¶ms, length: MemoryLayout<RenderParameters>.stride, index: 0)
|
||||||
|
|
||||||
|
let h = (inputTexture.height + 1) / 2
|
||||||
|
let threadsPerGrid = MTLSizeMake(1, h, 1)
|
||||||
|
let threadsPerThreadgroup = MTLSizeMake(1, min(h, pipe1.maxTotalThreadsPerThreadgroup), 1)
|
||||||
|
|
||||||
|
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
|
||||||
|
computeEncoder.memoryBarrier(scope: .textures)
|
||||||
|
|
||||||
|
// PASS 2
|
||||||
|
computeEncoder.setComputePipelineState(pipe2)
|
||||||
|
computeEncoder.setTexture(inputTexture, index: 0)
|
||||||
|
computeEncoder.setTexture(outputTexture, index: 1)
|
||||||
|
computeEncoder.setTexture(errorTexture, index: 2)
|
||||||
|
computeEncoder.setBytes(¶ms, length: MemoryLayout<RenderParameters>.stride, index: 0)
|
||||||
|
|
||||||
|
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
|
||||||
|
|
||||||
|
} else {
|
||||||
|
print("🔄 Using standard dithering algorithm")
|
||||||
|
|
||||||
computeEncoder.setComputePipelineState(pipelineState)
|
computeEncoder.setComputePipelineState(pipelineState)
|
||||||
computeEncoder.setTexture(inputTexture, index: 0)
|
computeEncoder.setTexture(inputTexture, index: 0)
|
||||||
computeEncoder.setTexture(outputTexture, index: 1)
|
computeEncoder.setTexture(outputTexture, index: 1)
|
||||||
|
|
||||||
var params = params
|
|
||||||
computeEncoder.setBytes(¶ms, length: MemoryLayout<RenderParameters>.stride, index: 0)
|
computeEncoder.setBytes(¶ms, length: MemoryLayout<RenderParameters>.stride, index: 0)
|
||||||
|
|
||||||
let w = pipelineState.threadExecutionWidth
|
let w = pipelineState.threadExecutionWidth
|
||||||
|
|
@ -72,14 +155,31 @@ final class MetalImageRenderer: Sendable {
|
||||||
let threadsPerGrid = MTLSizeMake(inputTexture.width, inputTexture.height, 1)
|
let threadsPerGrid = MTLSizeMake(inputTexture.width, inputTexture.height, 1)
|
||||||
|
|
||||||
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
|
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
|
||||||
|
}
|
||||||
|
|
||||||
computeEncoder.endEncoding()
|
computeEncoder.endEncoding()
|
||||||
|
|
||||||
commandBuffer.commit()
|
// ✅ Pas de Task, pas de @MainActor, juste le completion handler direct
|
||||||
commandBuffer.waitUntilCompleted()
|
commandBuffer.addCompletedHandler { [outputTexture] buffer in
|
||||||
|
if let error = buffer.error {
|
||||||
|
print("❌ Metal command buffer error: \(error)")
|
||||||
|
continuation.resume(returning: nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Convert back to CGImage (for simplicity in this iteration, though MTKView is better for display)
|
print("✅ Metal render completed successfully")
|
||||||
// We will use a helper to convert MTLTexture to CGImage
|
|
||||||
return createCGImage(from: outputTexture)
|
let result = self.createCGImage(from: outputTexture)
|
||||||
|
if result == nil {
|
||||||
|
print("❌ Failed to create CGImage from output texture")
|
||||||
|
}
|
||||||
|
|
||||||
|
continuation.resume(returning: result)
|
||||||
|
}
|
||||||
|
|
||||||
|
commandBuffer.commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createCGImage(from texture: MTLTexture) -> CGImage? {
|
private func createCGImage(from texture: MTLTexture) -> CGImage? {
|
||||||
|
|
@ -96,7 +196,8 @@ final class MetalImageRenderer: Sendable {
|
||||||
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
||||||
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
|
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
|
||||||
|
|
||||||
guard let provider = CGDataProvider(data: Data(bytes: bytes, count: length) as CFData) else { return nil }
|
guard let data = CFDataCreate(nil, bytes, length) else { return nil }
|
||||||
|
guard let provider = CGDataProvider(data: data) else { return nil }
|
||||||
|
|
||||||
return CGImage(width: width,
|
return CGImage(width: width,
|
||||||
height: height,
|
height: height,
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,164 @@
|
||||||
using namespace metal;
|
using namespace metal;
|
||||||
|
|
||||||
struct RenderParameters {
|
struct RenderParameters {
|
||||||
|
// Existing parameters
|
||||||
float brightness;
|
float brightness;
|
||||||
float contrast;
|
float contrast;
|
||||||
float pixelScale;
|
float pixelScale;
|
||||||
int algorithm; // 0: None, 1: Bayer 2x2, 2: Bayer 4x4, 3: Bayer 8x8, 4: Cluster 4x4, 5: Cluster 8x8, 6: Blue Noise
|
float colorDepth;
|
||||||
|
int algorithm; // 0: None, 1: Bayer 2x2, 2: Bayer 4x4, 3: Bayer 8x8, 4: Cluster 4x4, 5: Cluster 8x8, 6: Blue Noise, 7: Floyd-Steinberg
|
||||||
int isGrayscale;
|
int isGrayscale;
|
||||||
|
|
||||||
|
// CHAOS / FX PARAMETERS
|
||||||
|
float offsetJitter; // 0.0 to 1.0
|
||||||
|
float patternRotation; // 0.0 to 1.0
|
||||||
|
|
||||||
|
float errorAmplify; // 0.5 to 3.0 (1.0 = normal)
|
||||||
|
float errorRandomness; // 0.0 to 1.0
|
||||||
|
|
||||||
|
float thresholdNoise; // 0.0 to 1.0
|
||||||
|
float waveDistortion; // 0.0 to 1.0
|
||||||
|
|
||||||
|
float pixelDisplace; // 0.0 to 50.0 (pixels)
|
||||||
|
float turbulence; // 0.0 to 1.0
|
||||||
|
float chromaAberration; // 0.0 to 20.0 (pixels)
|
||||||
|
|
||||||
|
float bitDepthChaos; // 0.0 to 1.0
|
||||||
|
float paletteRandomize; // 0.0 to 1.0
|
||||||
|
|
||||||
|
uint randomSeed;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==================================================================================
|
||||||
|
// CHAOS HELPER FUNCTIONS
|
||||||
|
// ==================================================================================
|
||||||
|
|
||||||
|
float random(float2 st, uint seed) {
|
||||||
|
return fract(sin(dot(st.xy + float2(seed * 0.001), float2(12.9898, 78.233))) * 43758.5453);
|
||||||
|
}
|
||||||
|
|
||||||
|
float2 random2(float2 st, uint seed) {
|
||||||
|
float2 s = float2(seed * 0.001, seed * 0.002);
|
||||||
|
return float2(
|
||||||
|
fract(sin(dot(st.xy + s, float2(12.9898, 78.233))) * 43758.5453),
|
||||||
|
fract(sin(dot(st.xy + s, float2(93.9898, 67.345))) * 23421.6312)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
float noise(float2 st, uint seed) {
|
||||||
|
float2 i = floor(st);
|
||||||
|
float2 f = fract(st);
|
||||||
|
|
||||||
|
float a = random(i, seed);
|
||||||
|
float b = random(i + float2(1.0, 0.0), seed);
|
||||||
|
float c = random(i + float2(0.0, 1.0), seed);
|
||||||
|
float d = random(i + float2(1.0, 1.0), seed);
|
||||||
|
|
||||||
|
float2 u = f * f * (3.0 - 2.0 * f);
|
||||||
|
|
||||||
|
return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
float2 applySpatialChaos(float2 coord, constant RenderParameters ¶ms, uint2 gid) {
|
||||||
|
float2 chaosCoord = coord;
|
||||||
|
|
||||||
|
if (params.pixelDisplace > 0.0) {
|
||||||
|
float2 offset = random2(coord * 0.01, params.randomSeed) - 0.5;
|
||||||
|
chaosCoord += offset * params.pixelDisplace * 2.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.turbulence > 0.0) {
|
||||||
|
float scale = 0.02;
|
||||||
|
float offsetX = noise(coord * scale, params.randomSeed) * 2.0 - 1.0;
|
||||||
|
float offsetY = noise(coord * scale + float2(100.0), params.randomSeed) * 2.0 - 1.0;
|
||||||
|
chaosCoord += float2(offsetX, offsetY) * params.turbulence * 50.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return chaosCoord;
|
||||||
|
}
|
||||||
|
|
||||||
|
float applyThresholdChaos(float threshold, float2 coord, constant RenderParameters ¶ms) {
|
||||||
|
float chaosThreshold = threshold;
|
||||||
|
|
||||||
|
if (params.thresholdNoise > 0.0) {
|
||||||
|
float noise = random(coord, params.randomSeed);
|
||||||
|
chaosThreshold = mix(chaosThreshold, noise, params.thresholdNoise);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.waveDistortion > 0.0) {
|
||||||
|
float wave = sin(coord.x * 0.1) * cos(coord.y * 0.1) * 0.5 + 0.5;
|
||||||
|
chaosThreshold = mix(chaosThreshold, wave, params.waveDistortion * 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
return chaosThreshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint2 applyPatternChaos(uint2 matrixCoord, float2 pixelCoord, constant RenderParameters ¶ms, uint matrixSize) {
|
||||||
|
uint2 chaosCoord = matrixCoord;
|
||||||
|
|
||||||
|
if (params.offsetJitter > 0.0) {
|
||||||
|
float2 jitter = random2(pixelCoord * 0.1, params.randomSeed) * params.offsetJitter * float(matrixSize);
|
||||||
|
chaosCoord = uint2((float2(chaosCoord) + jitter)) % matrixSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.patternRotation > 0.0) {
|
||||||
|
float rotRandom = random(pixelCoord * 0.05, params.randomSeed);
|
||||||
|
if (rotRandom < params.patternRotation) {
|
||||||
|
uint temp = chaosCoord.x;
|
||||||
|
chaosCoord.x = matrixSize - 1 - chaosCoord.y;
|
||||||
|
chaosCoord.y = temp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chaosCoord;
|
||||||
|
}
|
||||||
|
|
||||||
|
float3 applyChromaAberration(texture2d<float, access::read> inputTexture,
|
||||||
|
float2 coord,
|
||||||
|
float amount,
|
||||||
|
uint2 texSize) {
|
||||||
|
if (amount == 0.0) {
|
||||||
|
uint2 pixelCoord = uint2(clamp(coord, float2(0), float2(texSize) - 1.0));
|
||||||
|
return inputTexture.read(pixelCoord).rgb;
|
||||||
|
}
|
||||||
|
|
||||||
|
float2 redOffset = coord + float2(amount, amount * 0.5);
|
||||||
|
float2 blueOffset = coord - float2(amount, amount * 0.5);
|
||||||
|
|
||||||
|
uint2 redCoord = uint2(clamp(redOffset, float2(0), float2(texSize) - 1.0));
|
||||||
|
uint2 greenCoord = uint2(clamp(coord, float2(0), float2(texSize) - 1.0));
|
||||||
|
uint2 blueCoord = uint2(clamp(blueOffset, float2(0), float2(texSize) - 1.0));
|
||||||
|
|
||||||
|
float r = inputTexture.read(redCoord).r;
|
||||||
|
float g = inputTexture.read(greenCoord).g;
|
||||||
|
float b = inputTexture.read(blueCoord).b;
|
||||||
|
|
||||||
|
return float3(r, g, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
float applyQuantizationChaos(float value, float2 coord, constant RenderParameters ¶ms) {
|
||||||
|
float chaosValue = value;
|
||||||
|
|
||||||
|
if (params.bitDepthChaos > 0.0) {
|
||||||
|
float randVal = random(coord * 0.05, params.randomSeed);
|
||||||
|
if (randVal < params.bitDepthChaos) {
|
||||||
|
float reducedDepth = floor(randVal * 1.5) + 2.0;
|
||||||
|
chaosValue = floor(value * reducedDepth) / reducedDepth;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.paletteRandomize > 0.0) {
|
||||||
|
float randShift = (random(coord, params.randomSeed) - 0.5) * params.paletteRandomize * 0.5;
|
||||||
|
chaosValue = clamp(value + randShift, 0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return chaosValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================================================================================
|
||||||
|
// DITHERING MATRICES
|
||||||
|
// ==================================================================================
|
||||||
|
|
||||||
// Bayer 2x2 Matrix
|
// Bayer 2x2 Matrix
|
||||||
constant float bayer2x2[2][2] = {
|
constant float bayer2x2[2][2] = {
|
||||||
{0.0/4.0, 2.0/4.0},
|
{0.0/4.0, 2.0/4.0},
|
||||||
|
|
@ -67,6 +218,11 @@ constant float blueNoise8x8[8][8] = {
|
||||||
{35.0/64.0, 24.0/64.0, 0.0/64.0, 41.0/64.0, 15.0/64.0, 52.0/64.0, 20.0/64.0, 37.0/64.0}
|
{35.0/64.0, 24.0/64.0, 0.0/64.0, 41.0/64.0, 15.0/64.0, 52.0/64.0, 20.0/64.0, 37.0/64.0}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
float ditherChannel(float value, float threshold, float limit) {
|
||||||
|
float ditheredValue = value + (threshold - 0.5) * (1.0 / (limit - 1.0));
|
||||||
|
return floor(ditheredValue * (limit - 1.0) + 0.5) / (limit - 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
kernel void ditherShader(texture2d<float, access::read> inputTexture [[texture(0)]],
|
kernel void ditherShader(texture2d<float, access::read> inputTexture [[texture(0)]],
|
||||||
texture2d<float, access::write> outputTexture [[texture(1)]],
|
texture2d<float, access::write> outputTexture [[texture(1)]],
|
||||||
constant RenderParameters ¶ms [[buffer(0)]],
|
constant RenderParameters ¶ms [[buffer(0)]],
|
||||||
|
|
@ -104,6 +260,7 @@ kernel void ditherShader(texture2d<float, access::read> inputTexture [[texture(0
|
||||||
if (shouldDither) {
|
if (shouldDither) {
|
||||||
uint x, y;
|
uint x, y;
|
||||||
|
|
||||||
|
// Fetch threshold from matrix
|
||||||
switch (params.algorithm) {
|
switch (params.algorithm) {
|
||||||
case 1: // Bayer 2x2
|
case 1: // Bayer 2x2
|
||||||
x = uint(sourceCoord.x / scale) % 2;
|
x = uint(sourceCoord.x / scale) % 2;
|
||||||
|
|
@ -139,8 +296,253 @@ kernel void ditherShader(texture2d<float, access::read> inputTexture [[texture(0
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
rgb = (luma > threshold) ? float3(1.0) : float3(0.0);
|
// Apply Quantized Dithering
|
||||||
|
if (params.isGrayscale > 0) {
|
||||||
|
// Apply only to luma (which is already in rgb)
|
||||||
|
rgb.r = ditherChannel(rgb.r, threshold, params.colorDepth);
|
||||||
|
rgb.g = rgb.r;
|
||||||
|
rgb.b = rgb.r;
|
||||||
|
} else {
|
||||||
|
// Apply to each channel
|
||||||
|
rgb.r = ditherChannel(rgb.r, threshold, params.colorDepth);
|
||||||
|
rgb.g = ditherChannel(rgb.g, threshold, params.colorDepth);
|
||||||
|
rgb.b = ditherChannel(rgb.b, threshold, params.colorDepth);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
outputTexture.write(float4(rgb, color.a), gid);
|
outputTexture.write(float4(rgb, color.a), gid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================================================================================
|
||||||
|
// FLOYD-STEINBERG ERROR DIFFUSION HELPERS & KERNELS (Algorithm ID 7)
|
||||||
|
// ==================================================================================
|
||||||
|
|
||||||
|
// Helper to get luminance for error calculation in grayscale mode
|
||||||
|
float getLuma(float3 rgb) {
|
||||||
|
return dot(rgb, float3(0.299, 0.587, 0.114));
|
||||||
|
}
|
||||||
|
|
||||||
|
// PASS 1: EVEN ROWS (Left -> Right)
|
||||||
|
kernel void ditherShaderFS_Pass1(texture2d<float, access::read> inputTexture [[texture(0)]],
|
||||||
|
texture2d<float, access::write> outputTexture [[texture(1)]],
|
||||||
|
texture2d<float, access::write> errorTexture [[texture(2)]],
|
||||||
|
constant RenderParameters ¶ms [[buffer(0)]],
|
||||||
|
uint2 gid [[thread_position_in_grid]]) {
|
||||||
|
|
||||||
|
uint y = gid.y * 2;
|
||||||
|
if (y >= inputTexture.get_height()) return;
|
||||||
|
|
||||||
|
uint width = inputTexture.get_width();
|
||||||
|
float3 currentError = float3(0.0);
|
||||||
|
|
||||||
|
float scale = max(1.0, params.pixelScale);
|
||||||
|
|
||||||
|
for (uint x = 0; x < width; x++) {
|
||||||
|
uint2 coords = uint2(x, y);
|
||||||
|
|
||||||
|
// Pixelate Input Read with Chaos
|
||||||
|
uint2 mappedCoords = uint2(floor(float(x) / scale) * scale, floor(float(y) / scale) * scale);
|
||||||
|
|
||||||
|
if (params.pixelDisplace > 0.0 || params.turbulence > 0.0) {
|
||||||
|
float2 chaosC = applySpatialChaos(float2(mappedCoords), params, coords);
|
||||||
|
mappedCoords = uint2(clamp(chaosC, float2(0), float2(inputTexture.get_width()-1, inputTexture.get_height()-1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
mappedCoords.x = min(mappedCoords.x, inputTexture.get_width() - 1);
|
||||||
|
mappedCoords.y = min(mappedCoords.y, inputTexture.get_height() - 1);
|
||||||
|
|
||||||
|
float4 colorRaw = inputTexture.read(mappedCoords);
|
||||||
|
float3 originalColor = colorRaw.rgb;
|
||||||
|
|
||||||
|
// Color Adjust
|
||||||
|
originalColor = originalColor + params.brightness;
|
||||||
|
originalColor = (originalColor - 0.5) * params.contrast + 0.5;
|
||||||
|
|
||||||
|
// Grayscale
|
||||||
|
if (params.isGrayscale > 0) {
|
||||||
|
float l = getLuma(originalColor);
|
||||||
|
originalColor = float3(l);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error Diffusion Core
|
||||||
|
float3 pixelIn = originalColor + currentError;
|
||||||
|
|
||||||
|
// Apply Quantization Chaos
|
||||||
|
if (params.isGrayscale > 0) {
|
||||||
|
pixelIn.r = applyQuantizationChaos(pixelIn.r, float2(coords), params);
|
||||||
|
pixelIn.g = pixelIn.r;
|
||||||
|
pixelIn.b = pixelIn.r;
|
||||||
|
} else {
|
||||||
|
pixelIn.r = applyQuantizationChaos(pixelIn.r, float2(coords), params);
|
||||||
|
pixelIn.g = applyQuantizationChaos(pixelIn.g, float2(coords), params);
|
||||||
|
pixelIn.b = applyQuantizationChaos(pixelIn.b, float2(coords), params);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quantize
|
||||||
|
float3 pixelOut = float3(0.0);
|
||||||
|
float levels = max(1.0, params.colorDepth);
|
||||||
|
if (levels <= 1.0) levels = 2.0;
|
||||||
|
|
||||||
|
pixelOut.r = floor(pixelIn.r * (levels - 1.0) + 0.5) / (levels - 1.0);
|
||||||
|
pixelOut.g = floor(pixelIn.g * (levels - 1.0) + 0.5) / (levels - 1.0);
|
||||||
|
pixelOut.b = floor(pixelIn.b * (levels - 1.0) + 0.5) / (levels - 1.0);
|
||||||
|
|
||||||
|
pixelOut = clamp(pixelOut, 0.0, 1.0);
|
||||||
|
|
||||||
|
// Calculate Error
|
||||||
|
float3 diff = pixelIn - pixelOut;
|
||||||
|
|
||||||
|
// Chaos: Error Amplify
|
||||||
|
if (params.errorAmplify != 1.0) {
|
||||||
|
diff *= params.errorAmplify;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store RAW error for Pass 2
|
||||||
|
if (y + 1 < inputTexture.get_height()) {
|
||||||
|
errorTexture.write(float4(diff, 1.0), coords);
|
||||||
|
}
|
||||||
|
|
||||||
|
outputTexture.write(float4(pixelOut, colorRaw.a), coords);
|
||||||
|
|
||||||
|
// Chaos: Error Randomness in Propagation
|
||||||
|
float weight = 7.0 / 16.0;
|
||||||
|
if (params.errorRandomness > 0.0) {
|
||||||
|
float r = random(float2(coords), params.randomSeed);
|
||||||
|
if (r < params.errorRandomness * 1.5) {
|
||||||
|
float r1 = random(float2(coords) + float2(1.0), params.randomSeed);
|
||||||
|
float r2 = random(float2(coords) + float2(2.0), params.randomSeed);
|
||||||
|
r1 = pow(r1, 0.5);
|
||||||
|
r2 = pow(r2, 0.5);
|
||||||
|
weight = mix(weight, r1, params.errorRandomness);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentError = diff * weight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PASS 2: ODD ROWS (Right -> Left Serpentine)
|
||||||
|
kernel void ditherShaderFS_Pass2(texture2d<float, access::read> inputTexture [[texture(0)]],
|
||||||
|
texture2d<float, access::write> outputTexture [[texture(1)]],
|
||||||
|
texture2d<float, access::read> errorTexture [[texture(2)]],
|
||||||
|
constant RenderParameters ¶ms [[buffer(0)]],
|
||||||
|
uint2 gid [[thread_position_in_grid]]) {
|
||||||
|
|
||||||
|
uint y = gid.y * 2 + 1;
|
||||||
|
if (y >= inputTexture.get_height()) return;
|
||||||
|
|
||||||
|
uint width = inputTexture.get_width();
|
||||||
|
float3 currentError = float3(0.0);
|
||||||
|
|
||||||
|
float scale = max(1.0, params.pixelScale);
|
||||||
|
|
||||||
|
for (int x_int = int(width) - 1; x_int >= 0; x_int--) {
|
||||||
|
uint x = uint(x_int);
|
||||||
|
uint2 coords = uint2(x, y);
|
||||||
|
|
||||||
|
// 1. Calculate Incoming Error from Row Above
|
||||||
|
float3 errorFromAbove = float3(0.0);
|
||||||
|
uint prevY = y - 1;
|
||||||
|
|
||||||
|
// Weights
|
||||||
|
float w_tr = 3.0 / 16.0;
|
||||||
|
float w_t = 5.0 / 16.0;
|
||||||
|
float w_tl = 1.0 / 16.0;
|
||||||
|
|
||||||
|
// Chaos: Error Randomness
|
||||||
|
if (params.errorRandomness > 0.0) {
|
||||||
|
float r = random(float2(coords) + float2(10.0), params.randomSeed);
|
||||||
|
if (r < params.errorRandomness * 1.5) {
|
||||||
|
float r1 = random(float2(coords) + float2(1.0), params.randomSeed);
|
||||||
|
float r2 = random(float2(coords) + float2(2.0), params.randomSeed);
|
||||||
|
float r3 = random(float2(coords) + float2(3.0), params.randomSeed);
|
||||||
|
float r4 = random(float2(coords) + float2(4.0), params.randomSeed);
|
||||||
|
|
||||||
|
r1 = pow(r1, 0.5);
|
||||||
|
r2 = pow(r2, 0.5);
|
||||||
|
r3 = pow(r3, 0.5);
|
||||||
|
r4 = pow(r4, 0.5);
|
||||||
|
|
||||||
|
float sum = r1 + r2 + r3 + r4 + 0.001;
|
||||||
|
w_tr = r1 / sum;
|
||||||
|
w_t = r2 / sum;
|
||||||
|
w_tl = r3 / sum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read neighbors
|
||||||
|
if (x + 1 < width) {
|
||||||
|
float3 e = errorTexture.read(uint2(x+1, prevY)).rgb;
|
||||||
|
errorFromAbove += e * w_tr;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
float3 e = errorTexture.read(uint2(x, prevY)).rgb;
|
||||||
|
errorFromAbove += e * w_t;
|
||||||
|
}
|
||||||
|
if (x >= 1) {
|
||||||
|
float3 e = errorTexture.read(uint2(x-1, prevY)).rgb;
|
||||||
|
errorFromAbove += e * w_tl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Read Pixel
|
||||||
|
uint2 mappedCoords = uint2(floor(float(x) / scale) * scale, floor(float(y) / scale) * scale);
|
||||||
|
|
||||||
|
if (params.pixelDisplace > 0.0 || params.turbulence > 0.0) {
|
||||||
|
float2 chaosC = applySpatialChaos(float2(mappedCoords), params, coords);
|
||||||
|
mappedCoords = uint2(clamp(chaosC, float2(0), float2(inputTexture.get_width()-1, inputTexture.get_height()-1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
mappedCoords.x = min(mappedCoords.x, inputTexture.get_width() - 1);
|
||||||
|
mappedCoords.y = min(mappedCoords.y, inputTexture.get_height() - 1);
|
||||||
|
|
||||||
|
float4 colorRaw = inputTexture.read(mappedCoords);
|
||||||
|
float3 originalColor = colorRaw.rgb;
|
||||||
|
originalColor = originalColor + params.brightness;
|
||||||
|
originalColor = (originalColor - 0.5) * params.contrast + 0.5;
|
||||||
|
|
||||||
|
if (params.isGrayscale > 0) {
|
||||||
|
float l = getLuma(originalColor);
|
||||||
|
originalColor = float3(l);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Combine
|
||||||
|
float3 pixelIn = originalColor + currentError + errorFromAbove;
|
||||||
|
|
||||||
|
// 4. Quantize
|
||||||
|
if (params.isGrayscale > 0) {
|
||||||
|
pixelIn.r = applyQuantizationChaos(pixelIn.r, float2(coords), params);
|
||||||
|
pixelIn.g = pixelIn.r;
|
||||||
|
pixelIn.b = pixelIn.r;
|
||||||
|
} else {
|
||||||
|
pixelIn.r = applyQuantizationChaos(pixelIn.r, float2(coords), params);
|
||||||
|
pixelIn.g = applyQuantizationChaos(pixelIn.g, float2(coords), params);
|
||||||
|
pixelIn.b = applyQuantizationChaos(pixelIn.b, float2(coords), params);
|
||||||
|
}
|
||||||
|
|
||||||
|
float3 pixelOut = float3(0.0);
|
||||||
|
float levels = max(1.0, params.colorDepth);
|
||||||
|
if (levels <= 1.0) levels = 2.0;
|
||||||
|
|
||||||
|
pixelOut.r = floor(pixelIn.r * (levels - 1.0) + 0.5) / (levels - 1.0);
|
||||||
|
pixelOut.g = floor(pixelIn.g * (levels - 1.0) + 0.5) / (levels - 1.0);
|
||||||
|
pixelOut.b = floor(pixelIn.b * (levels - 1.0) + 0.5) / (levels - 1.0);
|
||||||
|
pixelOut = clamp(pixelOut, 0.0, 1.0);
|
||||||
|
|
||||||
|
// 5. Diff & Propagate
|
||||||
|
float3 diff = pixelIn - pixelOut;
|
||||||
|
if (params.errorAmplify != 1.0) {
|
||||||
|
diff *= params.errorAmplify;
|
||||||
|
}
|
||||||
|
|
||||||
|
outputTexture.write(float4(pixelOut, colorRaw.a), coords);
|
||||||
|
|
||||||
|
float weight = 7.0 / 16.0;
|
||||||
|
if (params.errorRandomness > 0.0) {
|
||||||
|
float r = random(float2(coords), params.randomSeed);
|
||||||
|
weight = mix(weight, r * 0.8, params.errorRandomness);
|
||||||
|
}
|
||||||
|
currentError = diff * weight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import CoreGraphics
|
import CoreGraphics
|
||||||
import ImageIO
|
import ImageIO
|
||||||
|
import AppKit
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
// Helper for Swift 6 Concurrency
|
||||||
|
struct SendableCGImage: @unchecked Sendable {
|
||||||
|
let image: CGImage
|
||||||
|
}
|
||||||
|
|
||||||
enum DitherAlgorithm: Int, CaseIterable, Identifiable {
|
enum DitherAlgorithm: Int, CaseIterable, Identifiable {
|
||||||
case noDither = 0
|
case noDither = 0
|
||||||
|
|
@ -10,6 +17,7 @@ enum DitherAlgorithm: Int, CaseIterable, Identifiable {
|
||||||
case cluster4x4 = 4
|
case cluster4x4 = 4
|
||||||
case cluster8x8 = 5
|
case cluster8x8 = 5
|
||||||
case blueNoise = 6
|
case blueNoise = 6
|
||||||
|
case floydSteinberg = 7
|
||||||
|
|
||||||
var id: Int { rawValue }
|
var id: Int { rawValue }
|
||||||
|
|
||||||
|
|
@ -22,6 +30,7 @@ enum DitherAlgorithm: Int, CaseIterable, Identifiable {
|
||||||
case .cluster4x4: return "Cluster 4x4 (Vintage)"
|
case .cluster4x4: return "Cluster 4x4 (Vintage)"
|
||||||
case .cluster8x8: return "Cluster 8x8 (Soft)"
|
case .cluster8x8: return "Cluster 8x8 (Soft)"
|
||||||
case .blueNoise: return "Blue Noise / Organic (Best Quality)"
|
case .blueNoise: return "Blue Noise / Organic (Best Quality)"
|
||||||
|
case .floydSteinberg: return "Floyd-Steinberg (Error Diffusion)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -37,15 +46,78 @@ class DitherViewModel {
|
||||||
var brightness: Double = 0.0
|
var brightness: Double = 0.0
|
||||||
var contrast: Double = 1.0
|
var contrast: Double = 1.0
|
||||||
var pixelScale: Double = 4.0
|
var pixelScale: Double = 4.0
|
||||||
var selectedAlgorithm: DitherAlgorithm = .bayer4x4 // Default to Balanced
|
var colorDepth: Double = 4.0 // Default to 4 levels
|
||||||
|
var selectedAlgorithm: DitherAlgorithm = .bayer4x4
|
||||||
var isGrayscale: Bool = false
|
var isGrayscale: Bool = false
|
||||||
|
|
||||||
|
// Chaos / FX Parameters
|
||||||
|
var offsetJitter: Double = 0.0
|
||||||
|
var patternRotation: Double = 0.0
|
||||||
|
var errorAmplify: Double = 1.0
|
||||||
|
var errorRandomness: Double = 0.0
|
||||||
|
var thresholdNoise: Double = 0.0
|
||||||
|
var waveDistortion: Double = 0.0
|
||||||
|
var pixelDisplace: Double = 0.0
|
||||||
|
var turbulence: Double = 0.0
|
||||||
|
var chromaAberration: Double = 0.0
|
||||||
|
var bitDepthChaos: Double = 0.0
|
||||||
|
var paletteRandomize: Double = 0.0
|
||||||
|
|
||||||
private let renderer = MetalImageRenderer()
|
private let renderer = MetalImageRenderer()
|
||||||
private var renderTask: Task<Void, Never>?
|
private var renderTask: Task<Void, Never>?
|
||||||
|
private var renderDebounceTask: Task<Void, Never>?
|
||||||
|
|
||||||
init() {}
|
init() {}
|
||||||
|
|
||||||
|
func resetChaosEffects() {
|
||||||
|
offsetJitter = 0.0
|
||||||
|
patternRotation = 0.0
|
||||||
|
errorAmplify = 1.0
|
||||||
|
errorRandomness = 0.0
|
||||||
|
thresholdNoise = 0.0
|
||||||
|
waveDistortion = 0.0
|
||||||
|
pixelDisplace = 0.0
|
||||||
|
turbulence = 0.0
|
||||||
|
chromaAberration = 0.0
|
||||||
|
bitDepthChaos = 0.0
|
||||||
|
paletteRandomize = 0.0
|
||||||
|
processImage()
|
||||||
|
}
|
||||||
|
|
||||||
|
func forceRefresh() {
|
||||||
|
print("🔄 Force refresh triggered")
|
||||||
|
guard let _ = inputImage else {
|
||||||
|
print("⚠️ No input image to refresh")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear everything
|
||||||
|
renderDebounceTask?.cancel()
|
||||||
|
renderDebounceTask = nil
|
||||||
|
renderTask?.cancel()
|
||||||
|
renderTask = nil
|
||||||
|
processedImage = nil
|
||||||
|
|
||||||
|
// Wait a frame
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: .milliseconds(100))
|
||||||
|
self.processImage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func load(url: URL) {
|
func load(url: URL) {
|
||||||
|
// Cancel all tasks
|
||||||
|
renderTask?.cancel()
|
||||||
|
renderTask = nil
|
||||||
|
renderDebounceTask?.cancel()
|
||||||
|
renderDebounceTask = nil
|
||||||
|
|
||||||
|
// CRITICAL: Clear old images to release memory
|
||||||
|
processedImage = nil
|
||||||
|
inputImage = nil
|
||||||
|
|
||||||
|
// Force memory cleanup and load new image
|
||||||
|
autoreleasepool {
|
||||||
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
|
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
|
||||||
let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
|
let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
|
||||||
print("Failed to load image from \(url)")
|
print("Failed to load image from \(url)")
|
||||||
|
|
@ -53,46 +125,251 @@ class DitherViewModel {
|
||||||
}
|
}
|
||||||
|
|
||||||
self.inputImage = cgImage
|
self.inputImage = cgImage
|
||||||
|
}
|
||||||
|
|
||||||
self.inputImageId = UUID() // Signal that a new image has been loaded
|
self.inputImageId = UUID() // Signal that a new image has been loaded
|
||||||
|
|
||||||
|
// Small delay to ensure UI updates
|
||||||
|
Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: .milliseconds(100))
|
||||||
self.processImage()
|
self.processImage()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func processImage() {
|
func processImage() {
|
||||||
guard let input = inputImage, let renderer = renderer else { return }
|
guard let input = inputImage, let renderer = renderer else { return }
|
||||||
|
|
||||||
// Cancel previous task to prevent UI freezing and Metal overload
|
// Cancel previous debounce
|
||||||
renderTask?.cancel()
|
renderDebounceTask?.cancel()
|
||||||
|
|
||||||
|
// Debounce rapid parameter changes
|
||||||
|
renderDebounceTask = Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: .milliseconds(50)) // 50ms debounce
|
||||||
|
|
||||||
|
if Task.isCancelled { return }
|
||||||
|
|
||||||
|
// Cancel previous render task
|
||||||
|
self.renderTask?.cancel()
|
||||||
|
self.renderTask = nil
|
||||||
|
|
||||||
|
// Generate a random seed for consistent chaos per frame/update
|
||||||
|
let seed = UInt32.random(in: 0...UInt32.max)
|
||||||
|
|
||||||
let params = RenderParameters(
|
let params = RenderParameters(
|
||||||
brightness: Float(brightness),
|
brightness: Float(self.brightness),
|
||||||
contrast: Float(contrast),
|
contrast: Float(self.contrast),
|
||||||
pixelScale: Float(pixelScale),
|
pixelScale: Float(self.pixelScale),
|
||||||
algorithm: Int32(selectedAlgorithm.rawValue),
|
colorDepth: Float(self.colorDepth),
|
||||||
isGrayscale: isGrayscale ? 1 : 0
|
algorithm: Int32(self.selectedAlgorithm.rawValue),
|
||||||
|
isGrayscale: self.isGrayscale ? 1 : 0,
|
||||||
|
|
||||||
|
// Chaos Params
|
||||||
|
offsetJitter: Float(self.offsetJitter),
|
||||||
|
patternRotation: Float(self.patternRotation),
|
||||||
|
errorAmplify: Float(self.errorAmplify),
|
||||||
|
errorRandomness: Float(self.errorRandomness),
|
||||||
|
thresholdNoise: Float(self.thresholdNoise),
|
||||||
|
waveDistortion: Float(self.waveDistortion),
|
||||||
|
pixelDisplace: Float(self.pixelDisplace),
|
||||||
|
turbulence: Float(self.turbulence),
|
||||||
|
chromaAberration: Float(self.chromaAberration),
|
||||||
|
bitDepthChaos: Float(self.bitDepthChaos),
|
||||||
|
paletteRandomize: Float(self.paletteRandomize),
|
||||||
|
randomSeed: seed
|
||||||
)
|
)
|
||||||
|
|
||||||
renderTask = Task.detached(priority: .userInitiated) { [input, renderer, params] in
|
print("🔄 Processing image with algorithm: \(self.selectedAlgorithm.name)")
|
||||||
if Task.isCancelled { return }
|
|
||||||
|
|
||||||
let result = renderer.render(input: input, params: params)
|
// Wrap CGImage in a Sendable wrapper to satisfy strict concurrency
|
||||||
|
let sendableInput = SendableCGImage(image: input)
|
||||||
|
|
||||||
if Task.isCancelled { return }
|
// ✅ CHANGÉ : Enlève @MainActor de la Task
|
||||||
|
self.renderTask = Task { [sendableInput, renderer, params] in
|
||||||
|
if Task.isCancelled {
|
||||||
|
print("⚠️ Render task cancelled before starting")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le rendu s'exécute sur un thread d'arrière-plan (performant)
|
||||||
|
let result = await renderer.render(input: sendableInput.image, params: params)
|
||||||
|
|
||||||
|
if Task.isCancelled {
|
||||||
|
print("⚠️ Render task cancelled after render")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch vers MainActor UNIQUEMENT pour la mise à jour UI
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
print("✅ Render complete, updating UI")
|
||||||
self.processedImage = result
|
self.processedImage = result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func exportResult(to url: URL) {
|
func exportResult(to url: URL) {
|
||||||
guard let image = processedImage else { return }
|
// Legacy export, keeping for compatibility but forwarding to new system with defaults
|
||||||
|
exportImage(to: url, format: .png, scale: 1.0, jpegQuality: 1.0, preserveMetadata: true, flattenTransparency: false)
|
||||||
|
}
|
||||||
|
|
||||||
guard let destination = CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil) else {
|
// MARK: - Advanced Export
|
||||||
print("Failed to create image destination")
|
|
||||||
|
func exportImage(to url: URL,
|
||||||
|
format: ExportFormat,
|
||||||
|
scale: CGFloat,
|
||||||
|
jpegQuality: Double,
|
||||||
|
preserveMetadata: Bool,
|
||||||
|
flattenTransparency: Bool) {
|
||||||
|
|
||||||
|
guard let currentImage = processedImage else { return }
|
||||||
|
|
||||||
|
// Convert CGImage to NSImage for processing
|
||||||
|
let nsImage = NSImage(cgImage: currentImage, size: NSSize(width: currentImage.width, height: currentImage.height))
|
||||||
|
|
||||||
|
// Apply scaling if needed
|
||||||
|
let finalImage: NSImage
|
||||||
|
if scale > 1.0 {
|
||||||
|
finalImage = resizeImage(nsImage, scale: scale)
|
||||||
|
} else {
|
||||||
|
finalImage = nsImage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export based on format
|
||||||
|
switch format {
|
||||||
|
case .png:
|
||||||
|
exportAsPNG(finalImage, to: url, flattenAlpha: flattenTransparency)
|
||||||
|
case .jpeg:
|
||||||
|
exportAsJPEG(finalImage, to: url, quality: jpegQuality)
|
||||||
|
case .tiff:
|
||||||
|
exportAsTIFF(finalImage, to: url, flattenAlpha: flattenTransparency)
|
||||||
|
case .pdf:
|
||||||
|
exportAsPDF(finalImage, to: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resizeImage(_ image: NSImage, scale: CGFloat) -> NSImage {
|
||||||
|
let newSize = NSSize(width: image.size.width * scale,
|
||||||
|
height: image.size.height * scale)
|
||||||
|
|
||||||
|
let newImage = NSImage(size: newSize)
|
||||||
|
newImage.lockFocus()
|
||||||
|
|
||||||
|
NSGraphicsContext.current?.imageInterpolation = .none // Nearest neighbor for pixel art
|
||||||
|
|
||||||
|
image.draw(in: NSRect(origin: .zero, size: newSize),
|
||||||
|
from: NSRect(origin: .zero, size: image.size),
|
||||||
|
operation: .copy,
|
||||||
|
fraction: 1.0)
|
||||||
|
|
||||||
|
newImage.unlockFocus()
|
||||||
|
return newImage
|
||||||
|
}
|
||||||
|
|
||||||
|
private func flattenImageAlpha(_ image: NSImage) -> NSImage {
|
||||||
|
let flattened = NSImage(size: image.size)
|
||||||
|
flattened.lockFocus()
|
||||||
|
|
||||||
|
// Draw white background
|
||||||
|
NSColor.white.setFill()
|
||||||
|
NSRect(origin: .zero, size: image.size).fill()
|
||||||
|
|
||||||
|
// Draw image on top
|
||||||
|
image.draw(at: .zero, from: NSRect(origin: .zero, size: image.size),
|
||||||
|
operation: .sourceOver, fraction: 1.0)
|
||||||
|
|
||||||
|
flattened.unlockFocus()
|
||||||
|
return flattened
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Format Exporters
|
||||||
|
|
||||||
|
private func exportAsPNG(_ image: NSImage, to url: URL, flattenAlpha: Bool) {
|
||||||
|
guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return }
|
||||||
|
|
||||||
|
let bitmapRep = NSBitmapImageRep(cgImage: cgImage)
|
||||||
|
bitmapRep.size = image.size
|
||||||
|
|
||||||
|
// Handle alpha flattening
|
||||||
|
if flattenAlpha {
|
||||||
|
let flattened = flattenImageAlpha(image)
|
||||||
|
guard let flatCGImage = flattened.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return }
|
||||||
|
let flatRep = NSBitmapImageRep(cgImage: flatCGImage)
|
||||||
|
flatRep.size = image.size
|
||||||
|
|
||||||
|
guard let pngData = flatRep.representation(using: .png, properties: [:]) else { return }
|
||||||
|
try? pngData.write(to: url, options: .atomic)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
CGImageDestinationAddImage(destination, image, nil)
|
guard let pngData = bitmapRep.representation(using: .png, properties: [:]) else { return }
|
||||||
CGImageDestinationFinalize(destination)
|
try? pngData.write(to: url, options: .atomic)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exportAsJPEG(_ image: NSImage, to url: URL, quality: Double) {
|
||||||
|
guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return }
|
||||||
|
|
||||||
|
let bitmapRep = NSBitmapImageRep(cgImage: cgImage)
|
||||||
|
bitmapRep.size = image.size
|
||||||
|
|
||||||
|
let properties: [NSBitmapImageRep.PropertyKey: Any] = [
|
||||||
|
.compressionFactor: NSNumber(value: quality)
|
||||||
|
]
|
||||||
|
|
||||||
|
guard let jpegData = bitmapRep.representation(using: .jpeg, properties: properties) else { return }
|
||||||
|
try? jpegData.write(to: url, options: .atomic)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exportAsTIFF(_ image: NSImage, to url: URL, flattenAlpha: Bool) {
|
||||||
|
let imageToExport = flattenAlpha ? flattenImageAlpha(image) : image
|
||||||
|
guard let tiffData = imageToExport.tiffRepresentation else { return }
|
||||||
|
try? tiffData.write(to: url, options: .atomic)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exportAsPDF(_ image: NSImage, to url: URL) {
|
||||||
|
let pdfData = NSMutableData()
|
||||||
|
|
||||||
|
guard let consumer = CGDataConsumer(data: pdfData as CFMutableData) else { return }
|
||||||
|
|
||||||
|
var mediaBox = CGRect(origin: .zero, size: image.size)
|
||||||
|
|
||||||
|
guard let pdfContext = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else { return }
|
||||||
|
|
||||||
|
pdfContext.beginPage(mediaBox: &mediaBox)
|
||||||
|
|
||||||
|
guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else { return }
|
||||||
|
pdfContext.draw(cgImage, in: mediaBox)
|
||||||
|
|
||||||
|
pdfContext.endPage()
|
||||||
|
pdfContext.closePDF()
|
||||||
|
|
||||||
|
try? pdfData.write(to: url, options: .atomic)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ExportFormat: String, CaseIterable, Identifiable {
|
||||||
|
case png = "PNG"
|
||||||
|
case jpeg = "JPEG"
|
||||||
|
case tiff = "TIFF"
|
||||||
|
case pdf = "PDF"
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var fileExtension: String {
|
||||||
|
switch self {
|
||||||
|
case .png: return "png"
|
||||||
|
case .jpeg: return "jpg"
|
||||||
|
case .tiff: return "tiff"
|
||||||
|
case .pdf: return "pdf"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var utType: UTType {
|
||||||
|
switch self {
|
||||||
|
case .png: return .png
|
||||||
|
case .jpeg: return .jpeg
|
||||||
|
case .tiff: return .tiff
|
||||||
|
case .pdf: return .pdf
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ struct iDitherApp: App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.windowStyle(.hiddenTitleBar)
|
||||||
.windowToolbarStyle(.unified)
|
.windowToolbarStyle(.unified)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
assets/img/iDither.png
Normal file
BIN
assets/img/iDither.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.5 MiB |
BIN
assets/img/idithershowcase.jpeg
Normal file
BIN
assets/img/idithershowcase.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
Loading…
Reference in a new issue