Compare commits

..

No commits in common. "main" and "V0.1.2" have entirely different histories.
main ... V0.1.2

10 changed files with 161 additions and 1530 deletions

View file

@ -1,52 +0,0 @@
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 }}

View file

@ -1,161 +0,0 @@
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,55 +1,3 @@
# iDither
<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.
Image/video dithering tool built with Swift.

View file

@ -4,19 +4,12 @@ import UniformTypeIdentifiers
struct ContentView: View {
@State private var viewModel = DitherViewModel()
@State private var isImporting = 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
@State private var isExporting = false
var body: some View {
NavigationSplitView {
SidebarView(viewModel: viewModel, isImporting: $isImporting, showExportOptions: $showExportOptionsSheet)
.navigationSplitViewColumnWidth(min: 280, ideal: 300)
SidebarView(viewModel: viewModel, isImporting: $isImporting, isExporting: $isExporting)
.navigationSplitViewColumnWidth(min: 260, ideal: 300)
} detail: {
DetailView(viewModel: viewModel, loadFromProviders: loadFromProviders)
}
@ -27,11 +20,8 @@ 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,
@ -47,84 +37,14 @@ struct ContentView: View {
print("Import 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)
.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)")
}
}
}
@ -144,259 +64,90 @@ struct ContentView: View {
}
return false
}
}
func performExportWithOptions() {
let savePanel = NSSavePanel()
savePanel.canCreateDirectories = true
savePanel.showsTagField = true
// Helper for FileExporter
struct ImageDocument: FileDocument {
var image: CGImage?
// Set filename with correct extension based on chosen format
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)
init(image: CGImage?) {
self.image = image
}
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 {
@Bindable var viewModel: DitherViewModel
@Binding var isImporting: Bool
@Binding var showExportOptions: Bool
@State private var showChaosSection = false // Chaos Section State
@Binding var isExporting: Bool
var body: some View {
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)
Form {
Section("Dithering Algorithm") {
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) {
Section("Pre-Processing") {
VStack(alignment: .leading) {
HStack {
Text("Brightness")
.font(.system(size: 13))
Spacer()
Text(String(format: "%.2f", viewModel.brightness))
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
.monospacedDigit()
.foregroundStyle(.secondary)
}
Slider(value: $viewModel.brightness, in: -1.0...1.0)
.tint(.accentColor)
}
// Contrast slider
VStack(spacing: 6) {
VStack(alignment: .leading) {
HStack {
Text("Contrast")
.font(.system(size: 13))
Spacer()
Text(String(format: "%.2f", viewModel.contrast))
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
.monospacedDigit()
.foregroundStyle(.secondary)
}
Slider(value: $viewModel.contrast, in: 0.0...4.0)
.tint(.accentColor)
}
// Pixel Scale slider
VStack(spacing: 6) {
VStack(alignment: .leading) {
HStack {
Text("Pixel Scale")
.font(.system(size: 13))
Spacer()
Text("\(Int(viewModel.pixelScale))x")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
.monospacedDigit()
.foregroundStyle(.secondary)
}
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()
}
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)
}
.background(.regularMaterial)
.ignoresSafeArea(edges: .top)
.formStyle(.grouped)
.padding(.vertical)
.navigationTitle("iDither")
.frame(minWidth: 280, maxWidth: .infinity, alignment: .leading)
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button(action: { isImporting = true }) {
@ -404,7 +155,7 @@ struct SidebarView: View {
}
.help("Import Image")
Button(action: { showExportOptions = true }) {
Button(action: { isExporting = true }) {
Label("Export", systemImage: "square.and.arrow.up")
}
.disabled(viewModel.processedImage == nil)
@ -576,80 +327,6 @@ 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 @@
@preconcurrency import Metal
import Metal
import MetalKit
import CoreGraphics
@ -6,38 +6,14 @@ struct RenderParameters {
var brightness: Float
var contrast: Float
var pixelScale: Float
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
var algorithm: Int32 // 0: None, 1: Bayer 8x8, 2: Bayer 4x4
var isGrayscale: Int32 // 0: false, 1: true
}
// 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(),
@ -52,36 +28,21 @@ 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) async -> CGImage? {
return await withCheckedContinuation { continuation in
autoreleasepool {
print("🎨 Metal render started - Image: \(input.width)x\(input.height), Algo: \(params.algorithm)")
func render(input: CGImage, params: RenderParameters) -> CGImage? {
let textureLoader = MTKTextureLoader(device: device)
// Load input texture
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
return nil
}
print("✅ Input texture created: \(inputTexture.width)x\(inputTexture.height)")
// Create output texture
let descriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .rgba8Unorm,
width: inputTexture.width,
height: inputTexture.height,
@ -89,64 +50,20 @@ final class MetalImageRenderer: Sendable {
descriptor.usage = [.shaderWrite, .shaderRead]
guard let outputTexture = device.makeTexture(descriptor: descriptor) else {
print("❌ Failed to create output texture")
continuation.resume(returning: nil)
return
return nil
}
// Encode command
guard let commandBuffer = commandQueue.makeCommandBuffer(),
let computeEncoder = commandBuffer.makeComputeCommandEncoder() else {
print("❌ Failed to create command buffer or encoder")
continuation.resume(returning: nil)
return
return nil
}
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)
var params = params
computeEncoder.setBytes(&params, length: MemoryLayout<RenderParameters>.stride, index: 0)
let w = pipelineState.threadExecutionWidth
@ -155,31 +72,14 @@ final class MetalImageRenderer: Sendable {
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()
}
}
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? {
@ -196,8 +96,7 @@ final class MetalImageRenderer: Sendable {
let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
guard let data = CFDataCreate(nil, bytes, length) else { return nil }
guard let provider = CGDataProvider(data: data) else { return nil }
guard let provider = CGDataProvider(data: Data(bytes: bytes, count: length) as CFData) else { return nil }
return CGImage(width: width,
height: height,

View file

@ -2,164 +2,13 @@
using namespace metal;
struct RenderParameters {
// Existing parameters
float brightness;
float contrast;
float pixelScale;
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 algorithm; // 0: None, 1: Bayer 2x2, 2: Bayer 4x4, 3: Bayer 8x8, 4: Cluster 4x4, 5: Cluster 8x8, 6: Blue Noise
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},
@ -218,11 +67,6 @@ 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)]],
@ -260,7 +104,6 @@ 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;
@ -296,253 +139,8 @@ kernel void ditherShader(texture2d<float, access::read> inputTexture [[texture(0
break;
}
// 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);
rgb = (luma > threshold) ? float3(1.0) : float3(0.0);
}
}
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,13 +1,6 @@
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
@ -17,7 +10,6 @@ enum DitherAlgorithm: Int, CaseIterable, Identifiable {
case cluster4x4 = 4
case cluster8x8 = 5
case blueNoise = 6
case floydSteinberg = 7
var id: Int { rawValue }
@ -30,7 +22,6 @@ 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)"
}
}
}
@ -46,78 +37,15 @@ class DitherViewModel {
var brightness: Double = 0.0
var contrast: Double = 1.0
var pixelScale: Double = 4.0
var colorDepth: Double = 4.0 // Default to 4 levels
var selectedAlgorithm: DitherAlgorithm = .bayer4x4
var selectedAlgorithm: DitherAlgorithm = .bayer4x4 // Default to Balanced
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 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) {
// 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)")
@ -125,251 +53,46 @@ class DitherViewModel {
}
self.inputImage = cgImage
}
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()
}
}
func processImage() {
guard let input = inputImage, let renderer = renderer else { return }
// Cancel previous debounce
renderDebounceTask?.cancel()
// Cancel previous task to prevent UI freezing and Metal overload
renderTask?.cancel()
// Debounce rapid parameter changes
renderDebounceTask = Task { @MainActor in
try? await Task.sleep(for: .milliseconds(50)) // 50ms debounce
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)
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(
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) {
// 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 image = processedImage else { return }
// 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)
guard let destination = CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil) else {
print("Failed to create image destination")
return
}
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)
CGImageDestinationAddImage(destination, image, nil)
CGImageDestinationFinalize(destination)
}
}
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,7 +15,6 @@ struct iDitherApp: App {
}
}
}
.windowStyle(.hiddenTitleBar)
.windowToolbarStyle(.unified)
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB