Compare commits
No commits in common. "main" and "V0.1.2" have entirely different histories.
52
.github/workflows/auto-tag.yml
vendored
52
.github/workflows/auto-tag.yml
vendored
|
|
@ -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 }}
|
||||
161
.github/workflows/build-release.yml
vendored
161
.github/workflows/build-release.yml
vendored
|
|
@ -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 }}
|
||||
54
README.md
54
README.md
|
|
@ -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.
|
||||
|
|
@ -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)"
|
||||
init(image: CGImage?) {
|
||||
self.image = image
|
||||
}
|
||||
|
||||
// Set allowed file types
|
||||
savePanel.allowedContentTypes = [exportFormat.utType]
|
||||
static var readableContentTypes: [UTType] { [.png] }
|
||||
|
||||
savePanel.begin { response in
|
||||
guard response == .OK,
|
||||
let url = savePanel.url else { return }
|
||||
init(configuration: ReadConfiguration) throws {
|
||||
// Read not implemented for export-only
|
||||
self.image = nil
|
||||
}
|
||||
|
||||
// Perform export with the configured settings
|
||||
viewModel.exportImage(to: url,
|
||||
format: exportFormat,
|
||||
scale: exportScale,
|
||||
jpegQuality: jpegQuality,
|
||||
preserveMetadata: preserveMetadata,
|
||||
flattenTransparency: flattenTransparency)
|
||||
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)
|
||||
|
||||
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)
|
||||
Form {
|
||||
Section("Dithering Algorithm") {
|
||||
Picker("Algorithm", selection: $viewModel.selectedAlgorithm) {
|
||||
ForEach(DitherAlgorithm.allCases) { algo in
|
||||
Text(algo.name).tag(algo)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
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)
|
||||
}
|
||||
}
|
||||
.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
|
||||
|
|
|
|||
|
|
@ -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,134 +28,58 @@ 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)
|
||||
|
||||
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(¶ms, length: MemoryLayout<RenderParameters>.stride, index: 0)
|
||||
|
||||
let h = (inputTexture.height + 1) / 2
|
||||
let threadsPerGrid = MTLSizeMake(1, h, 1)
|
||||
let threadsPerThreadgroup = MTLSizeMake(1, min(h, pipe1.maxTotalThreadsPerThreadgroup), 1)
|
||||
|
||||
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
|
||||
computeEncoder.memoryBarrier(scope: .textures)
|
||||
|
||||
// PASS 2
|
||||
computeEncoder.setComputePipelineState(pipe2)
|
||||
computeEncoder.setTexture(inputTexture, index: 0)
|
||||
computeEncoder.setTexture(outputTexture, index: 1)
|
||||
computeEncoder.setTexture(errorTexture, index: 2)
|
||||
computeEncoder.setBytes(¶ms, length: MemoryLayout<RenderParameters>.stride, index: 0)
|
||||
|
||||
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
|
||||
|
||||
} else {
|
||||
print("🔄 Using standard dithering algorithm")
|
||||
|
||||
computeEncoder.setComputePipelineState(pipelineState)
|
||||
computeEncoder.setTexture(inputTexture, index: 0)
|
||||
computeEncoder.setTexture(outputTexture, index: 1)
|
||||
computeEncoder.setBytes(¶ms, 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()
|
||||
}
|
||||
// Load input texture
|
||||
guard let inputTexture = try? textureLoader.newTexture(cgImage: input, options: [.origin: MTKTextureLoader.Origin.topLeft]) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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(¶ms, 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? {
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ¶ms, uint2 gid) {
|
||||
float2 chaosCoord = coord;
|
||||
|
||||
if (params.pixelDisplace > 0.0) {
|
||||
float2 offset = random2(coord * 0.01, params.randomSeed) - 0.5;
|
||||
chaosCoord += offset * params.pixelDisplace * 2.0;
|
||||
}
|
||||
|
||||
if (params.turbulence > 0.0) {
|
||||
float scale = 0.02;
|
||||
float offsetX = noise(coord * scale, params.randomSeed) * 2.0 - 1.0;
|
||||
float offsetY = noise(coord * scale + float2(100.0), params.randomSeed) * 2.0 - 1.0;
|
||||
chaosCoord += float2(offsetX, offsetY) * params.turbulence * 50.0;
|
||||
}
|
||||
|
||||
return chaosCoord;
|
||||
}
|
||||
|
||||
float applyThresholdChaos(float threshold, float2 coord, constant RenderParameters ¶ms) {
|
||||
float chaosThreshold = threshold;
|
||||
|
||||
if (params.thresholdNoise > 0.0) {
|
||||
float noise = random(coord, params.randomSeed);
|
||||
chaosThreshold = mix(chaosThreshold, noise, params.thresholdNoise);
|
||||
}
|
||||
|
||||
if (params.waveDistortion > 0.0) {
|
||||
float wave = sin(coord.x * 0.1) * cos(coord.y * 0.1) * 0.5 + 0.5;
|
||||
chaosThreshold = mix(chaosThreshold, wave, params.waveDistortion * 0.5);
|
||||
}
|
||||
|
||||
return chaosThreshold;
|
||||
}
|
||||
|
||||
uint2 applyPatternChaos(uint2 matrixCoord, float2 pixelCoord, constant RenderParameters ¶ms, uint matrixSize) {
|
||||
uint2 chaosCoord = matrixCoord;
|
||||
|
||||
if (params.offsetJitter > 0.0) {
|
||||
float2 jitter = random2(pixelCoord * 0.1, params.randomSeed) * params.offsetJitter * float(matrixSize);
|
||||
chaosCoord = uint2((float2(chaosCoord) + jitter)) % matrixSize;
|
||||
}
|
||||
|
||||
if (params.patternRotation > 0.0) {
|
||||
float rotRandom = random(pixelCoord * 0.05, params.randomSeed);
|
||||
if (rotRandom < params.patternRotation) {
|
||||
uint temp = chaosCoord.x;
|
||||
chaosCoord.x = matrixSize - 1 - chaosCoord.y;
|
||||
chaosCoord.y = temp;
|
||||
}
|
||||
}
|
||||
|
||||
return chaosCoord;
|
||||
}
|
||||
|
||||
float3 applyChromaAberration(texture2d<float, access::read> inputTexture,
|
||||
float2 coord,
|
||||
float amount,
|
||||
uint2 texSize) {
|
||||
if (amount == 0.0) {
|
||||
uint2 pixelCoord = uint2(clamp(coord, float2(0), float2(texSize) - 1.0));
|
||||
return inputTexture.read(pixelCoord).rgb;
|
||||
}
|
||||
|
||||
float2 redOffset = coord + float2(amount, amount * 0.5);
|
||||
float2 blueOffset = coord - float2(amount, amount * 0.5);
|
||||
|
||||
uint2 redCoord = uint2(clamp(redOffset, float2(0), float2(texSize) - 1.0));
|
||||
uint2 greenCoord = uint2(clamp(coord, float2(0), float2(texSize) - 1.0));
|
||||
uint2 blueCoord = uint2(clamp(blueOffset, float2(0), float2(texSize) - 1.0));
|
||||
|
||||
float r = inputTexture.read(redCoord).r;
|
||||
float g = inputTexture.read(greenCoord).g;
|
||||
float b = inputTexture.read(blueCoord).b;
|
||||
|
||||
return float3(r, g, b);
|
||||
}
|
||||
|
||||
float applyQuantizationChaos(float value, float2 coord, constant RenderParameters ¶ms) {
|
||||
float chaosValue = value;
|
||||
|
||||
if (params.bitDepthChaos > 0.0) {
|
||||
float randVal = random(coord * 0.05, params.randomSeed);
|
||||
if (randVal < params.bitDepthChaos) {
|
||||
float reducedDepth = floor(randVal * 1.5) + 2.0;
|
||||
chaosValue = floor(value * reducedDepth) / reducedDepth;
|
||||
}
|
||||
}
|
||||
|
||||
if (params.paletteRandomize > 0.0) {
|
||||
float randShift = (random(coord, params.randomSeed) - 0.5) * params.paletteRandomize * 0.5;
|
||||
chaosValue = clamp(value + randShift, 0.0, 1.0);
|
||||
}
|
||||
|
||||
return chaosValue;
|
||||
}
|
||||
|
||||
// ==================================================================================
|
||||
// DITHERING MATRICES
|
||||
// ==================================================================================
|
||||
|
||||
// Bayer 2x2 Matrix
|
||||
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 ¶ms [[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 ¶ms [[buffer(0)]],
|
||||
uint2 gid [[thread_position_in_grid]]) {
|
||||
|
||||
uint y = gid.y * 2;
|
||||
if (y >= inputTexture.get_height()) return;
|
||||
|
||||
uint width = inputTexture.get_width();
|
||||
float3 currentError = float3(0.0);
|
||||
|
||||
float scale = max(1.0, params.pixelScale);
|
||||
|
||||
for (uint x = 0; x < width; x++) {
|
||||
uint2 coords = uint2(x, y);
|
||||
|
||||
// Pixelate Input Read with Chaos
|
||||
uint2 mappedCoords = uint2(floor(float(x) / scale) * scale, floor(float(y) / scale) * scale);
|
||||
|
||||
if (params.pixelDisplace > 0.0 || params.turbulence > 0.0) {
|
||||
float2 chaosC = applySpatialChaos(float2(mappedCoords), params, coords);
|
||||
mappedCoords = uint2(clamp(chaosC, float2(0), float2(inputTexture.get_width()-1, inputTexture.get_height()-1)));
|
||||
}
|
||||
|
||||
mappedCoords.x = min(mappedCoords.x, inputTexture.get_width() - 1);
|
||||
mappedCoords.y = min(mappedCoords.y, inputTexture.get_height() - 1);
|
||||
|
||||
float4 colorRaw = inputTexture.read(mappedCoords);
|
||||
float3 originalColor = colorRaw.rgb;
|
||||
|
||||
// Color Adjust
|
||||
originalColor = originalColor + params.brightness;
|
||||
originalColor = (originalColor - 0.5) * params.contrast + 0.5;
|
||||
|
||||
// Grayscale
|
||||
if (params.isGrayscale > 0) {
|
||||
float l = getLuma(originalColor);
|
||||
originalColor = float3(l);
|
||||
}
|
||||
|
||||
// Error Diffusion Core
|
||||
float3 pixelIn = originalColor + currentError;
|
||||
|
||||
// Apply Quantization Chaos
|
||||
if (params.isGrayscale > 0) {
|
||||
pixelIn.r = applyQuantizationChaos(pixelIn.r, float2(coords), params);
|
||||
pixelIn.g = pixelIn.r;
|
||||
pixelIn.b = pixelIn.r;
|
||||
} else {
|
||||
pixelIn.r = applyQuantizationChaos(pixelIn.r, float2(coords), params);
|
||||
pixelIn.g = applyQuantizationChaos(pixelIn.g, float2(coords), params);
|
||||
pixelIn.b = applyQuantizationChaos(pixelIn.b, float2(coords), params);
|
||||
}
|
||||
|
||||
// Quantize
|
||||
float3 pixelOut = float3(0.0);
|
||||
float levels = max(1.0, params.colorDepth);
|
||||
if (levels <= 1.0) levels = 2.0;
|
||||
|
||||
pixelOut.r = floor(pixelIn.r * (levels - 1.0) + 0.5) / (levels - 1.0);
|
||||
pixelOut.g = floor(pixelIn.g * (levels - 1.0) + 0.5) / (levels - 1.0);
|
||||
pixelOut.b = floor(pixelIn.b * (levels - 1.0) + 0.5) / (levels - 1.0);
|
||||
|
||||
pixelOut = clamp(pixelOut, 0.0, 1.0);
|
||||
|
||||
// Calculate Error
|
||||
float3 diff = pixelIn - pixelOut;
|
||||
|
||||
// Chaos: Error Amplify
|
||||
if (params.errorAmplify != 1.0) {
|
||||
diff *= params.errorAmplify;
|
||||
}
|
||||
|
||||
// Store RAW error for Pass 2
|
||||
if (y + 1 < inputTexture.get_height()) {
|
||||
errorTexture.write(float4(diff, 1.0), coords);
|
||||
}
|
||||
|
||||
outputTexture.write(float4(pixelOut, colorRaw.a), coords);
|
||||
|
||||
// Chaos: Error Randomness in Propagation
|
||||
float weight = 7.0 / 16.0;
|
||||
if (params.errorRandomness > 0.0) {
|
||||
float r = random(float2(coords), params.randomSeed);
|
||||
if (r < params.errorRandomness * 1.5) {
|
||||
float r1 = random(float2(coords) + float2(1.0), params.randomSeed);
|
||||
float r2 = random(float2(coords) + float2(2.0), params.randomSeed);
|
||||
r1 = pow(r1, 0.5);
|
||||
r2 = pow(r2, 0.5);
|
||||
weight = mix(weight, r1, params.errorRandomness);
|
||||
}
|
||||
}
|
||||
|
||||
currentError = diff * weight;
|
||||
}
|
||||
}
|
||||
|
||||
// PASS 2: ODD ROWS (Right -> Left Serpentine)
|
||||
kernel void ditherShaderFS_Pass2(texture2d<float, access::read> inputTexture [[texture(0)]],
|
||||
texture2d<float, access::write> outputTexture [[texture(1)]],
|
||||
texture2d<float, access::read> errorTexture [[texture(2)]],
|
||||
constant RenderParameters ¶ms [[buffer(0)]],
|
||||
uint2 gid [[thread_position_in_grid]]) {
|
||||
|
||||
uint y = gid.y * 2 + 1;
|
||||
if (y >= inputTexture.get_height()) return;
|
||||
|
||||
uint width = inputTexture.get_width();
|
||||
float3 currentError = float3(0.0);
|
||||
|
||||
float scale = max(1.0, params.pixelScale);
|
||||
|
||||
for (int x_int = int(width) - 1; x_int >= 0; x_int--) {
|
||||
uint x = uint(x_int);
|
||||
uint2 coords = uint2(x, y);
|
||||
|
||||
// 1. Calculate Incoming Error from Row Above
|
||||
float3 errorFromAbove = float3(0.0);
|
||||
uint prevY = y - 1;
|
||||
|
||||
// Weights
|
||||
float w_tr = 3.0 / 16.0;
|
||||
float w_t = 5.0 / 16.0;
|
||||
float w_tl = 1.0 / 16.0;
|
||||
|
||||
// Chaos: Error Randomness
|
||||
if (params.errorRandomness > 0.0) {
|
||||
float r = random(float2(coords) + float2(10.0), params.randomSeed);
|
||||
if (r < params.errorRandomness * 1.5) {
|
||||
float r1 = random(float2(coords) + float2(1.0), params.randomSeed);
|
||||
float r2 = random(float2(coords) + float2(2.0), params.randomSeed);
|
||||
float r3 = random(float2(coords) + float2(3.0), params.randomSeed);
|
||||
float r4 = random(float2(coords) + float2(4.0), params.randomSeed);
|
||||
|
||||
r1 = pow(r1, 0.5);
|
||||
r2 = pow(r2, 0.5);
|
||||
r3 = pow(r3, 0.5);
|
||||
r4 = pow(r4, 0.5);
|
||||
|
||||
float sum = r1 + r2 + r3 + r4 + 0.001;
|
||||
w_tr = r1 / sum;
|
||||
w_t = r2 / sum;
|
||||
w_tl = r3 / sum;
|
||||
}
|
||||
}
|
||||
|
||||
// Read neighbors
|
||||
if (x + 1 < width) {
|
||||
float3 e = errorTexture.read(uint2(x+1, prevY)).rgb;
|
||||
errorFromAbove += e * w_tr;
|
||||
}
|
||||
{
|
||||
float3 e = errorTexture.read(uint2(x, prevY)).rgb;
|
||||
errorFromAbove += e * w_t;
|
||||
}
|
||||
if (x >= 1) {
|
||||
float3 e = errorTexture.read(uint2(x-1, prevY)).rgb;
|
||||
errorFromAbove += e * w_tl;
|
||||
}
|
||||
|
||||
// 2. Read Pixel
|
||||
uint2 mappedCoords = uint2(floor(float(x) / scale) * scale, floor(float(y) / scale) * scale);
|
||||
|
||||
if (params.pixelDisplace > 0.0 || params.turbulence > 0.0) {
|
||||
float2 chaosC = applySpatialChaos(float2(mappedCoords), params, coords);
|
||||
mappedCoords = uint2(clamp(chaosC, float2(0), float2(inputTexture.get_width()-1, inputTexture.get_height()-1)));
|
||||
}
|
||||
|
||||
mappedCoords.x = min(mappedCoords.x, inputTexture.get_width() - 1);
|
||||
mappedCoords.y = min(mappedCoords.y, inputTexture.get_height() - 1);
|
||||
|
||||
float4 colorRaw = inputTexture.read(mappedCoords);
|
||||
float3 originalColor = colorRaw.rgb;
|
||||
originalColor = originalColor + params.brightness;
|
||||
originalColor = (originalColor - 0.5) * params.contrast + 0.5;
|
||||
|
||||
if (params.isGrayscale > 0) {
|
||||
float l = getLuma(originalColor);
|
||||
originalColor = float3(l);
|
||||
}
|
||||
|
||||
// 3. Combine
|
||||
float3 pixelIn = originalColor + currentError + errorFromAbove;
|
||||
|
||||
// 4. Quantize
|
||||
if (params.isGrayscale > 0) {
|
||||
pixelIn.r = applyQuantizationChaos(pixelIn.r, float2(coords), params);
|
||||
pixelIn.g = pixelIn.r;
|
||||
pixelIn.b = pixelIn.r;
|
||||
} else {
|
||||
pixelIn.r = applyQuantizationChaos(pixelIn.r, float2(coords), params);
|
||||
pixelIn.g = applyQuantizationChaos(pixelIn.g, float2(coords), params);
|
||||
pixelIn.b = applyQuantizationChaos(pixelIn.b, float2(coords), params);
|
||||
}
|
||||
|
||||
float3 pixelOut = float3(0.0);
|
||||
float levels = max(1.0, params.colorDepth);
|
||||
if (levels <= 1.0) levels = 2.0;
|
||||
|
||||
pixelOut.r = floor(pixelIn.r * (levels - 1.0) + 0.5) / (levels - 1.0);
|
||||
pixelOut.g = floor(pixelIn.g * (levels - 1.0) + 0.5) / (levels - 1.0);
|
||||
pixelOut.b = floor(pixelIn.b * (levels - 1.0) + 0.5) / (levels - 1.0);
|
||||
pixelOut = clamp(pixelOut, 0.0, 1.0);
|
||||
|
||||
// 5. Diff & Propagate
|
||||
float3 diff = pixelIn - pixelOut;
|
||||
if (params.errorAmplify != 1.0) {
|
||||
diff *= params.errorAmplify;
|
||||
}
|
||||
|
||||
outputTexture.write(float4(pixelOut, colorRaw.a), coords);
|
||||
|
||||
float weight = 7.0 / 16.0;
|
||||
if (params.errorRandomness > 0.0) {
|
||||
float r = random(float2(coords), params.randomSeed);
|
||||
weight = mix(weight, r * 0.8, params.errorRandomness);
|
||||
}
|
||||
currentError = diff * weight;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,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,330 +37,62 @@ 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")
|
||||
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)")
|
||||
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)")
|
||||
return
|
||||
}
|
||||
|
||||
self.inputImage = cgImage
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
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
|
||||
}
|
||||
await MainActor.run {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
Loading…
Reference in a new issue