Compare commits

..

20 commits
V0.1.2 ... main

Author SHA1 Message Date
ewen 1a5aa1fdef Fix: Remove @MainActor isolation for Metal renderer
Some checks failed
Auto Tag on Push / auto-tag (push) Has been cancelled
2026-01-16 00:35:35 +01:00
ewen b2ffd9edaf Fix: Properly capture outputTexture in completion handler
Some checks are pending
Auto Tag on Push / auto-tag (push) Waiting to run
2026-01-16 00:21:06 +01:00
ewen 87e3d99290 Fix: Dispatch Metal completion handler to MainActor
Some checks are pending
Auto Tag on Push / auto-tag (push) Waiting to run
2026-01-16 00:16:44 +01:00
ewen fb1bf90d5a Fix: Complete .app bundle creation for SPM project 2026-01-15 23:56:08 +01:00
ewen d3822bc672 Debug: Full diagnostic of build outputs 2026-01-15 23:52:38 +01:00
ewen 0d283c3dec Fix: Create .app bundle manually for SPM project 2026-01-15 23:49:46 +01:00
ewen 99e9efabd4 Fix: Correct app path for DMG creation 2026-01-15 23:46:48 +01:00
ewen 38868a2aba Fix: Swift 6 concurrency - async render + Sendable wrapper
Some checks are pending
Auto Tag on Push / auto-tag (push) Waiting to run
2026-01-15 23:43:56 +01:00
ewen 586f87e222 Fix: Use macOS 15 runner with Swift 6.0 2026-01-15 23:31:25 +01:00
ewen 4664379340 Added Github Actions 2026-01-15 23:27:09 +01:00
ewen 8a88d8243d Updated README file 2026-01-15 23:16:57 +01:00
ewen ebedbe660a Updated README file 2026-01-15 23:16:20 +01:00
ewen 2bb1b4fe12 Updated README file 2026-01-15 23:08:46 +01:00
ewen 777eef2744 Updated README file 2026-01-15 23:07:29 +01:00
ewen 6f090b1f0e Updating README file with new temporary logo 2026-01-15 22:58:54 +01:00
ewen 7092ff16b9 Updating README file 2026-01-15 22:30:01 +01:00
ewen 2d281fcf39 V0.1.4.2 FIxed memory leak issue AND an import issue, causing re-importation not working properly 2026-01-15 00:48:25 +01:00
ewen a470a232e8 V0.1.4.1 Added chaos effect 2026-01-15 00:17:28 +01:00
ewen cddbbec233 V0.1.4, added Floyd Steinberg algorithm and revisited UI 2026-01-14 23:39:55 +01:00
ewen d2b78668c3 V0.1.3 Colored dithering 2026-01-14 10:04:16 +01:00
10 changed files with 1530 additions and 161 deletions

52
.github/workflows/auto-tag.yml vendored Normal file
View 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
View 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 }}

View file

@ -1,3 +1,55 @@
# 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.

View file

@ -4,12 +4,19 @@ import UniformTypeIdentifiers
struct ContentView: View {
@State private var viewModel = DitherViewModel()
@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 {
NavigationSplitView {
SidebarView(viewModel: viewModel, isImporting: $isImporting, isExporting: $isExporting)
.navigationSplitViewColumnWidth(min: 260, ideal: 300)
SidebarView(viewModel: viewModel, isImporting: $isImporting, showExportOptions: $showExportOptionsSheet)
.navigationSplitViewColumnWidth(min: 280, ideal: 300)
} detail: {
DetailView(viewModel: viewModel, loadFromProviders: loadFromProviders)
}
@ -20,8 +27,11 @@ struct ContentView: View {
.onChange(of: viewModel.brightness) { _, _ in viewModel.processImage() }
.onChange(of: viewModel.contrast) { _, _ 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.isGrayscale) { _, _ in viewModel.processImage() }
// CHAOS / FX PARAMETERS (Grouped in modifier)
.onChaosChange(viewModel: viewModel)
// File Importer at the very top level
.fileImporter(
isPresented: $isImporting,
@ -37,14 +47,84 @@ struct ContentView: View {
print("Import failed: \(error.localizedDescription)")
}
}
.fileExporter(
isPresented: $isExporting,
document: ImageDocument(image: viewModel.processedImage),
contentType: .png,
defaultFilename: "dithered_image"
) { result in
if case .failure(let error) = result {
print("Export failed: \(error.localizedDescription)")
// Export Options Sheet
.sheet(isPresented: $showExportOptionsSheet) {
NavigationStack {
Form {
// SECTION 1: Format
Section("Format") {
Picker("Format", selection: $exportFormat) {
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
}
}
// Helper for FileExporter
struct ImageDocument: FileDocument {
var image: CGImage?
func performExportWithOptions() {
let savePanel = NSSavePanel()
savePanel.canCreateDirectories = true
savePanel.showsTagField = true
init(image: CGImage?) {
self.image = image
}
// Set filename with correct extension based on chosen format
let baseName = "dithered_image"
savePanel.nameFieldStringValue = "\(baseName).\(exportFormat.fileExtension)"
static var readableContentTypes: [UTType] { [.png] }
// Set allowed file types
savePanel.allowedContentTypes = [exportFormat.utType]
init(configuration: ReadConfiguration) throws {
// Read not implemented for export-only
self.image = nil
}
savePanel.begin { response in
guard response == .OK,
let url = savePanel.url else { return }
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)
// Perform export with the configured settings
viewModel.exportImage(to: url,
format: exportFormat,
scale: exportScale,
jpegQuality: jpegQuality,
preserveMetadata: preserveMetadata,
flattenTransparency: flattenTransparency)
}
return FileWrapper(regularFileWithContents: pngData)
}
}
// SidebarView (Updated to trigger sheet)
struct SidebarView: View {
@Bindable var viewModel: DitherViewModel
@Binding var isImporting: Bool
@Binding var isExporting: Bool
@Binding var showExportOptions: Bool
@State private var showChaosSection = false // Chaos Section State
var body: some View {
Form {
Section("Dithering Algorithm") {
Picker("Algorithm", selection: $viewModel.selectedAlgorithm) {
ForEach(DitherAlgorithm.allCases) { algo in
Text(algo.name).tag(algo)
ScrollView {
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) {
ForEach(DitherAlgorithm.allCases) { algo in
Text(algo.name).tag(algo)
}
}
.labelsHidden()
Toggle("Grayscale / 1-bit", isOn: $viewModel.isGrayscale)
.toggleStyle(.switch)
.padding(.top, 4)
}
Divider()
.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 {
Text("Brightness")
.font(.system(size: 13))
Spacer()
Text(String(format: "%.2f", viewModel.brightness))
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
.monospacedDigit()
}
Slider(value: $viewModel.brightness, in: -1.0...1.0)
.tint(.accentColor)
}
// Contrast slider
VStack(spacing: 6) {
HStack {
Text("Contrast")
.font(.system(size: 13))
Spacer()
Text(String(format: "%.2f", viewModel.contrast))
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
.monospacedDigit()
}
Slider(value: $viewModel.contrast, in: 0.0...4.0)
.tint(.accentColor)
}
// Pixel Scale slider
VStack(spacing: 6) {
HStack {
Text("Pixel Scale")
.font(.system(size: 13))
Spacer()
Text("\(Int(viewModel.pixelScale))x")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
.monospacedDigit()
}
Slider(value: $viewModel.pixelScale, in: 1.0...20.0, step: 1.0)
.tint(.accentColor)
}
}
Toggle("Grayscale / 1-bit", isOn: $viewModel.isGrayscale)
}
Section("Pre-Processing") {
VStack(alignment: .leading) {
HStack {
Text("Brightness")
Spacer()
Text(String(format: "%.2f", viewModel.brightness))
.monospacedDigit()
.foregroundStyle(.secondary)
}
Slider(value: $viewModel.brightness, in: -1.0...1.0)
}
VStack(alignment: .leading) {
HStack {
Text("Contrast")
Spacer()
Text(String(format: "%.2f", viewModel.contrast))
.monospacedDigit()
.foregroundStyle(.secondary)
}
Slider(value: $viewModel.contrast, in: 0.0...4.0)
}
VStack(alignment: .leading) {
HStack {
Text("Pixel Scale")
Spacer()
Text("\(Int(viewModel.pixelScale))x")
.monospacedDigit()
.foregroundStyle(.secondary)
}
Slider(value: $viewModel.pixelScale, in: 1.0...20.0, step: 1.0)
}
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()
}
Slider(value: $viewModel.colorDepth, in: 1.0...32.0, step: 1.0)
.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)
}
.formStyle(.grouped)
.padding(.vertical)
.background(.regularMaterial)
.ignoresSafeArea(edges: .top)
.navigationTitle("iDither")
.frame(minWidth: 280, maxWidth: .infinity, alignment: .leading)
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button(action: { isImporting = true }) {
@ -155,7 +404,7 @@ struct SidebarView: View {
}
.help("Import Image")
Button(action: { isExporting = true }) {
Button(action: { showExportOptions = true }) {
Label("Export", systemImage: "square.and.arrow.up")
}
.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 {
var body: some View {
Canvas { context, size in

View file

@ -1,4 +1,4 @@
import Metal
@preconcurrency import Metal
import MetalKit
import CoreGraphics
@ -6,14 +6,38 @@ struct RenderParameters {
var brightness: Float
var contrast: Float
var pixelScale: Float
var algorithm: Int32 // 0: None, 1: Bayer 8x8, 2: Bayer 4x4
var isGrayscale: Int32 // 0: false, 1: true
var colorDepth: Float
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 {
// nonisolated(unsafe) car Metal est thread-safe malgré l'absence de Sendable
private let device: MTLDevice
private let commandQueue: MTLCommandQueue
private let pipelineState: MTLComputePipelineState
private let pipelineStateFS_Pass1: MTLComputePipelineState?
private let pipelineStateFS_Pass2: MTLComputePipelineState?
init?() {
guard let device = MTLCreateSystemDefaultDevice(),
@ -28,58 +52,134 @@ final class MetalImageRenderer: Sendable {
do {
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 {
print("Failed to create pipeline state: \(error)")
return nil
}
}
func render(input: CGImage, params: RenderParameters) -> CGImage? {
let textureLoader = MTKTextureLoader(device: device)
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)")
// Load input texture
guard let inputTexture = try? textureLoader.newTexture(cgImage: input, options: [.origin: MTKTextureLoader.Origin.topLeft]) else {
return nil
let textureLoader = MTKTextureLoader(device: device)
guard let inputTexture = try? textureLoader.newTexture(cgImage: input, options: [.origin: MTKTextureLoader.Origin.topLeft]) else {
print("❌ Failed to create input texture")
continuation.resume(returning: nil)
return
}
print("✅ Input texture created: \(inputTexture.width)x\(inputTexture.height)")
let descriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .rgba8Unorm,
width: inputTexture.width,
height: inputTexture.height,
mipmapped: false)
descriptor.usage = [.shaderWrite, .shaderRead]
guard let outputTexture = device.makeTexture(descriptor: descriptor) else {
print("❌ Failed to create output texture")
continuation.resume(returning: nil)
return
}
guard let commandBuffer = commandQueue.makeCommandBuffer(),
let computeEncoder = commandBuffer.makeComputeCommandEncoder() else {
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(&params, 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(&params, length: MemoryLayout<RenderParameters>.stride, index: 0)
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
} else {
print("🔄 Using standard dithering algorithm")
computeEncoder.setComputePipelineState(pipelineState)
computeEncoder.setTexture(inputTexture, index: 0)
computeEncoder.setTexture(outputTexture, index: 1)
computeEncoder.setBytes(&params, length: MemoryLayout<RenderParameters>.stride, index: 0)
let w = pipelineState.threadExecutionWidth
let h = pipelineState.maxTotalThreadsPerThreadgroup / w
let threadsPerThreadgroup = MTLSizeMake(w, h, 1)
let threadsPerGrid = MTLSizeMake(inputTexture.width, inputTexture.height, 1)
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
}
computeEncoder.endEncoding()
// Pas de Task, pas de @MainActor, juste le completion handler direct
commandBuffer.addCompletedHandler { [outputTexture] buffer in
if let error = buffer.error {
print("❌ Metal command buffer error: \(error)")
continuation.resume(returning: nil)
return
}
print("✅ Metal render completed successfully")
let result = self.createCGImage(from: outputTexture)
if result == nil {
print("❌ Failed to create CGImage from output texture")
}
continuation.resume(returning: result)
}
commandBuffer.commit()
}
}
// Create output texture
let descriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .rgba8Unorm,
width: inputTexture.width,
height: inputTexture.height,
mipmapped: false)
descriptor.usage = [.shaderWrite, .shaderRead]
guard let outputTexture = device.makeTexture(descriptor: descriptor) else {
return nil
}
// Encode command
guard let commandBuffer = commandQueue.makeCommandBuffer(),
let computeEncoder = commandBuffer.makeComputeCommandEncoder() else {
return nil
}
computeEncoder.setComputePipelineState(pipelineState)
computeEncoder.setTexture(inputTexture, index: 0)
computeEncoder.setTexture(outputTexture, index: 1)
var params = params
computeEncoder.setBytes(&params, length: MemoryLayout<RenderParameters>.stride, index: 0)
let w = pipelineState.threadExecutionWidth
let h = pipelineState.maxTotalThreadsPerThreadgroup / w
let threadsPerThreadgroup = MTLSizeMake(w, h, 1)
let threadsPerGrid = MTLSizeMake(inputTexture.width, inputTexture.height, 1)
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
computeEncoder.endEncoding()
commandBuffer.commit()
commandBuffer.waitUntilCompleted()
// Convert back to CGImage (for simplicity in this iteration, though MTKView is better for display)
// We will use a helper to convert MTLTexture to CGImage
return createCGImage(from: outputTexture)
}
private func createCGImage(from texture: MTLTexture) -> CGImage? {
@ -96,7 +196,8 @@ final class MetalImageRenderer: Sendable {
let colorSpace = CGColorSpaceCreateDeviceRGB()
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,
height: height,

View file

@ -2,13 +2,164 @@
using namespace metal;
struct RenderParameters {
// Existing parameters
float brightness;
float contrast;
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;
// 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 &params, 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 &params) {
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 &params, 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 &params) {
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
constant float bayer2x2[2][2] = {
{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}
};
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)]],
texture2d<float, access::write> outputTexture [[texture(1)]],
constant RenderParameters &params [[buffer(0)]],
@ -104,6 +260,7 @@ kernel void ditherShader(texture2d<float, access::read> inputTexture [[texture(0
if (shouldDither) {
uint x, y;
// Fetch threshold from matrix
switch (params.algorithm) {
case 1: // Bayer 2x2
x = uint(sourceCoord.x / scale) % 2;
@ -139,8 +296,253 @@ kernel void ditherShader(texture2d<float, access::read> inputTexture [[texture(0
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);
}
// ==================================================================================
// 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 &params [[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 &params [[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;
}
}

View file

@ -1,6 +1,13 @@
import SwiftUI
import CoreGraphics
import ImageIO
import AppKit
import UniformTypeIdentifiers
// Helper for Swift 6 Concurrency
struct SendableCGImage: @unchecked Sendable {
let image: CGImage
}
enum DitherAlgorithm: Int, CaseIterable, Identifiable {
case noDither = 0
@ -10,6 +17,7 @@ enum DitherAlgorithm: Int, CaseIterable, Identifiable {
case cluster4x4 = 4
case cluster8x8 = 5
case blueNoise = 6
case floydSteinberg = 7
var id: Int { rawValue }
@ -22,6 +30,7 @@ enum DitherAlgorithm: Int, CaseIterable, Identifiable {
case .cluster4x4: return "Cluster 4x4 (Vintage)"
case .cluster8x8: return "Cluster 8x8 (Soft)"
case .blueNoise: return "Blue Noise / Organic (Best Quality)"
case .floydSteinberg: return "Floyd-Steinberg (Error Diffusion)"
}
}
}
@ -37,62 +46,330 @@ class DitherViewModel {
var brightness: Double = 0.0
var contrast: Double = 1.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
// 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 var renderTask: Task<Void, Never>?
private var renderDebounceTask: Task<Void, Never>?
init() {}
func load(url: URL) {
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
print("Failed to load image from \(url)")
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
}
self.inputImage = cgImage
// 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) {
// 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),
let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else {
print("Failed to load image from \(url)")
return
}
self.inputImage = cgImage
}
self.inputImageId = UUID() // Signal that a new image has been loaded
self.processImage()
// Small delay to ensure UI updates
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(100))
self.processImage()
}
}
func processImage() {
guard let input = inputImage, let renderer = renderer else { return }
// Cancel previous task to prevent UI freezing and Metal overload
renderTask?.cancel()
// Cancel previous debounce
renderDebounceTask?.cancel()
let params = RenderParameters(
brightness: Float(brightness),
contrast: Float(contrast),
pixelScale: Float(pixelScale),
algorithm: Int32(selectedAlgorithm.rawValue),
isGrayscale: isGrayscale ? 1 : 0
)
renderTask = Task.detached(priority: .userInitiated) { [input, renderer, params] in
if Task.isCancelled { return }
let result = renderer.render(input: input, params: params)
// Debounce rapid parameter changes
renderDebounceTask = Task { @MainActor in
try? await Task.sleep(for: .milliseconds(50)) // 50ms debounce
if Task.isCancelled { return }
await MainActor.run {
self.processedImage = result
// 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(
brightness: Float(self.brightness),
contrast: Float(self.contrast),
pixelScale: Float(self.pixelScale),
colorDepth: Float(self.colorDepth),
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
)
print("🔄 Processing image with algorithm: \(self.selectedAlgorithm.name)")
// Wrap CGImage in a Sendable wrapper to satisfy strict concurrency
let sendableInput = SendableCGImage(image: input)
// 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 {
print("✅ Render complete, updating UI")
self.processedImage = result
}
}
}
}
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 {
print("Failed to create image destination")
// MARK: - Advanced Export
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
}
CGImageDestinationAddImage(destination, image, nil)
CGImageDestinationFinalize(destination)
guard let pngData = bitmapRep.representation(using: .png, properties: [:]) else { return }
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
}
}
}

View file

@ -15,6 +15,7 @@ struct iDitherApp: App {
}
}
}
.windowStyle(.hiddenTitleBar)
.windowToolbarStyle(.unified)
}
}

BIN
assets/img/iDither.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB