Compare commits

...

10 commits
v0.1.5 ... main

Author SHA1 Message Date
ewen 1a5aa1fdef Fix: Remove @MainActor isolation for Metal renderer
Some checks failed
Auto Tag on Push / auto-tag (push) Has been cancelled
2026-01-16 00:35:35 +01:00
ewen b2ffd9edaf Fix: Properly capture outputTexture in completion handler
Some checks are pending
Auto Tag on Push / auto-tag (push) Waiting to run
2026-01-16 00:21:06 +01:00
ewen 87e3d99290 Fix: Dispatch Metal completion handler to MainActor
Some checks are pending
Auto Tag on Push / auto-tag (push) Waiting to run
2026-01-16 00:16:44 +01:00
ewen fb1bf90d5a Fix: Complete .app bundle creation for SPM project 2026-01-15 23:56:08 +01:00
ewen d3822bc672 Debug: Full diagnostic of build outputs 2026-01-15 23:52:38 +01:00
ewen 0d283c3dec Fix: Create .app bundle manually for SPM project 2026-01-15 23:49:46 +01:00
ewen 99e9efabd4 Fix: Correct app path for DMG creation 2026-01-15 23:46:48 +01:00
ewen 38868a2aba Fix: Swift 6 concurrency - async render + Sendable wrapper
Some checks are pending
Auto Tag on Push / auto-tag (push) Waiting to run
2026-01-15 23:43:56 +01:00
ewen 586f87e222 Fix: Use macOS 15 runner with Swift 6.0 2026-01-15 23:31:25 +01:00
ewen 4664379340 Added Github Actions 2026-01-15 23:27:09 +01:00
4 changed files with 335 additions and 114 deletions

52
.github/workflows/auto-tag.yml vendored Normal file
View file

@ -0,0 +1,52 @@
name: Auto Tag on Push
on:
push:
branches:
- main
paths-ignore:
- '**.md'
- '.github/**'
jobs:
auto-tag:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Récupère tout l'historique pour les tags
- name: Get latest tag
id: get_tag
run: |
# Récupère le dernier tag v0.x.x
LATEST_TAG=$(git tag -l "v0.*.*" | sort -V | tail -n 1)
if [ -z "$LATEST_TAG" ]; then
# Pas de tag existant, on commence à v0.1.0
NEW_TAG="v0.1.0"
else
# Extrait les numéros de version
VERSION=${LATEST_TAG#v}
MAJOR=$(echo $VERSION | cut -d. -f1)
MINOR=$(echo $VERSION | cut -d. -f2)
PATCH=$(echo $VERSION | cut -d. -f3)
# Incrémente le patch
NEW_PATCH=$((PATCH + 1))
NEW_TAG="v${MAJOR}.${MINOR}.${NEW_PATCH}"
fi
echo "new_tag=$NEW_TAG" >> $GITHUB_OUTPUT
echo "Nouveau tag: $NEW_TAG"
- name: Create and push tag
run: |
git config user.name "GitHub Actions"
git config user.email "actions@github.com"
git tag ${{ steps.get_tag.outputs.new_tag }}
git push origin ${{ steps.get_tag.outputs.new_tag }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

161
.github/workflows/build-release.yml vendored Normal file
View file

@ -0,0 +1,161 @@
name: Build and Release DMG
on:
push:
tags:
- 'v0.*.*'
- 'v[1-9].*.*'
workflow_dispatch:
jobs:
build:
runs-on: macos-15
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '16.1'
- name: Resolve Swift Package Dependencies
run: |
xcodebuild -resolvePackageDependencies \
-scheme iDither
- name: Build app
run: |
xcodebuild -scheme iDither \
-configuration Release \
-derivedDataPath ./build \
-destination 'platform=macOS' \
clean build
- name: Create App Bundle
run: |
echo "📦 Création du bundle .app..."
# Chemins des éléments compilés
EXECUTABLE="./build/Build/Products/Release/iDither"
RESOURCES_BUNDLE="./build/Build/Products/Release/iDither_iDither.bundle"
# Vérification de l'exécutable
if [ ! -f "$EXECUTABLE" ]; then
echo "❌ Exécutable non trouvé : $EXECUTABLE"
exit 1
fi
echo "✅ Exécutable trouvé ($(du -h "$EXECUTABLE" | cut -f1))"
# Crée la structure du bundle .app
APP_DIR="./iDither.app"
mkdir -p "$APP_DIR/Contents/MacOS"
mkdir -p "$APP_DIR/Contents/Resources"
# Copie l'exécutable
cp "$EXECUTABLE" "$APP_DIR/Contents/MacOS/iDither"
chmod +x "$APP_DIR/Contents/MacOS/iDither"
echo "✅ Exécutable copié dans le bundle"
# Copie le bundle de ressources (shaders Metal)
if [ -d "$RESOURCES_BUNDLE" ]; then
cp -R "$RESOURCES_BUNDLE" "$APP_DIR/Contents/Resources/"
echo "✅ Bundle de ressources copié (shaders Metal inclus)"
else
echo "⚠️ Bundle de ressources non trouvé (l'app pourrait ne pas fonctionner)"
fi
# Crée Info.plist
cat > "$APP_DIR/Contents/Info.plist" << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>iDither</string>
<key>CFBundleIdentifier</key>
<string>com.ewengadonnaud.iDither</string>
<key>CFBundleName</key>
<string>iDither</string>
<key>CFBundleDisplayName</key>
<string>iDither</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>LSApplicationCategoryType</key>
<string>public.app-category.graphics-design</string>
</dict>
</plist>
EOF
echo "✅ Info.plist créé"
echo ""
echo "📦 Structure du bundle .app :"
ls -lh "$APP_DIR/Contents/MacOS/"
ls -lh "$APP_DIR/Contents/Resources/" 2>/dev/null || echo "(pas de ressources visibles)"
- name: Create DMG
run: |
APP_PATH="./iDither.app"
if [ ! -d "$APP_PATH" ]; then
echo "❌ Bundle .app non trouvé"
exit 1
fi
echo "✅ Création du DMG depuis : $APP_PATH"
# Crée un dossier temporaire pour le DMG
mkdir -p dmg_content
cp -R "$APP_PATH" dmg_content/
# Ajoute un lien symbolique vers /Applications
ln -s /Applications dmg_content/Applications
# Crée le DMG
DMG_NAME="iDither-${{ github.ref_name }}.dmg"
hdiutil create -volname "iDither" \
-srcfolder dmg_content \
-ov -format UDZO \
"$DMG_NAME"
echo "✅ DMG créé :"
ls -lh "$DMG_NAME"
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: iDither-*.dmg
draft: false
prerelease: ${{ startsWith(github.ref, 'refs/tags/v0.') }}
body: |
## iDither ${{ github.ref_name }}
Application macOS native de dithering en temps réel, propulsée par Metal et SwiftUI.
### Installation
1. Téléchargez le fichier `.dmg`
2. Ouvrez-le et glissez **iDither** vers **Applications**
3. Au premier lancement : **clic droit** → **Ouvrir** (contournement de la sécurité Gatekeeper)
### Algorithmes disponibles
- Matrices ordonnées (Bayer 2x2, 4x4, 8x8 / Cluster 4x4, 8x8)
- Blue Noise approximé
- Diffusion d'erreur Floyd-Steinberg
- Mode Chaos/FX avec distorsions avancées
---
**Compatibilité :** macOS 14.0+ (Sonoma)
**Architecture :** Apple Silicon (M1/M2/M3/M4) & Intel
**Build automatique** via GitHub Actions
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View file

@ -1,4 +1,4 @@
import Metal
@preconcurrency import Metal
import MetalKit
import CoreGraphics
@ -30,7 +30,9 @@ struct RenderParameters {
var randomSeed: UInt32
}
// SUPPRESSION DE @MainActor - Metal est thread-safe en pratique
final class MetalImageRenderer: Sendable {
// nonisolated(unsafe) car Metal est thread-safe malgré l'absence de Sendable
private let device: MTLDevice
private let commandQueue: MTLCommandQueue
private let pipelineState: MTLComputePipelineState
@ -65,120 +67,118 @@ final class MetalImageRenderer: Sendable {
}
}
func render(input: CGImage, params: RenderParameters) -> CGImage? {
return autoreleasepool {
print("🎨 Metal render started - Image: \(input.width)x\(input.height), Algo: \(params.algorithm)")
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")
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,
mipmapped: false)
descriptor.usage = [.shaderWrite, .shaderRead]
guard let outputTexture = device.makeTexture(descriptor: descriptor) else {
print("❌ Failed to create output texture")
return nil
}
// Encode command
guard let commandBuffer = commandQueue.makeCommandBuffer(),
let computeEncoder = commandBuffer.makeComputeCommandEncoder() else {
print("❌ Failed to create command buffer or encoder")
return nil
}
var params = params
if params.algorithm == 7, let pipe1 = pipelineStateFS_Pass1, let pipe2 = pipelineStateFS_Pass2 {
print("🔄 Using Floyd-Steinberg two-pass rendering")
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)")
// FLOYD-STEINBERG MULTI-PASS
let textureLoader = MTKTextureLoader(device: device)
// Create Error Texture (Float16 or Float32 for precision)
let errorDesc = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .rgba16Float,
width: inputTexture.width,
height: inputTexture.height,
mipmapped: false)
errorDesc.usage = [.shaderWrite, .shaderRead]
// CRITICAL: Use autoreleasepool check for error texture too
guard let errorTexture = device.makeTexture(descriptor: errorDesc) else {
computeEncoder.endEncoding()
return nil
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
}
// PASS 1: Even Rows
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)
print("✅ Input texture created: \(inputTexture.width)x\(inputTexture.height)")
// Dispatch (1, H/2, 1) -> Each thread handles one full row
let h = (inputTexture.height + 1) / 2
let threadsPerGrid = MTLSizeMake(1, h, 1)
let threadsPerThreadgroup = MTLSizeMake(1, min(h, pipe1.maxTotalThreadsPerThreadgroup), 1)
let descriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .rgba8Unorm,
width: inputTexture.width,
height: inputTexture.height,
mipmapped: false)
descriptor.usage = [.shaderWrite, .shaderRead]
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
guard let outputTexture = device.makeTexture(descriptor: descriptor) else {
print("❌ Failed to create output texture")
continuation.resume(returning: nil)
return
}
// Memory Barrier (Ensure Pass 1 writes are visible to Pass 2)
computeEncoder.memoryBarrier(scope: .textures)
guard let commandBuffer = commandQueue.makeCommandBuffer(),
let computeEncoder = commandBuffer.makeComputeCommandEncoder() else {
print("❌ Failed to create command buffer or encoder")
continuation.resume(returning: nil)
return
}
// PASS 2: Odd Rows
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)
var params = params
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
} else {
print("🔄 Using standard dithering algorithm")
// STANDARD ALGORITHMS
computeEncoder.setComputePipelineState(pipelineState)
computeEncoder.setTexture(inputTexture, index: 0)
computeEncoder.setTexture(outputTexture, index: 1)
computeEncoder.setBytes(&params, length: MemoryLayout<RenderParameters>.stride, index: 0)
let w = pipelineState.threadExecutionWidth
let h = pipelineState.maxTotalThreadsPerThreadgroup / w
let threadsPerThreadgroup = MTLSizeMake(w, h, 1)
let threadsPerGrid = MTLSizeMake(inputTexture.width, inputTexture.height, 1)
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
}
if params.algorithm == 7, let pipe1 = pipelineStateFS_Pass1, let pipe2 = pipelineStateFS_Pass2 {
print("🔄 Using Floyd-Steinberg two-pass rendering")
let errorDesc = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .rgba16Float,
width: inputTexture.width,
height: inputTexture.height,
mipmapped: false)
errorDesc.usage = [.shaderWrite, .shaderRead]
guard let errorTexture = device.makeTexture(descriptor: errorDesc) else {
computeEncoder.endEncoding()
continuation.resume(returning: nil)
return
}
// PASS 1
computeEncoder.setComputePipelineState(pipe1)
computeEncoder.setTexture(inputTexture, index: 0)
computeEncoder.setTexture(outputTexture, index: 1)
computeEncoder.setTexture(errorTexture, index: 2)
computeEncoder.setBytes(&params, length: MemoryLayout<RenderParameters>.stride, index: 0)
let h = (inputTexture.height + 1) / 2
let threadsPerGrid = MTLSizeMake(1, h, 1)
let threadsPerThreadgroup = MTLSizeMake(1, min(h, pipe1.maxTotalThreadsPerThreadgroup), 1)
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
computeEncoder.memoryBarrier(scope: .textures)
// PASS 2
computeEncoder.setComputePipelineState(pipe2)
computeEncoder.setTexture(inputTexture, index: 0)
computeEncoder.setTexture(outputTexture, index: 1)
computeEncoder.setTexture(errorTexture, index: 2)
computeEncoder.setBytes(&params, length: MemoryLayout<RenderParameters>.stride, index: 0)
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
} else {
print("🔄 Using standard dithering algorithm")
computeEncoder.setComputePipelineState(pipelineState)
computeEncoder.setTexture(inputTexture, index: 0)
computeEncoder.setTexture(outputTexture, index: 1)
computeEncoder.setBytes(&params, length: MemoryLayout<RenderParameters>.stride, index: 0)
let w = pipelineState.threadExecutionWidth
let h = pipelineState.maxTotalThreadsPerThreadgroup / w
let threadsPerThreadgroup = MTLSizeMake(w, h, 1)
let threadsPerGrid = MTLSizeMake(inputTexture.width, inputTexture.height, 1)
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
}
computeEncoder.endEncoding()
commandBuffer.commit()
commandBuffer.waitUntilCompleted()
if let error = commandBuffer.error {
print("❌ Metal command buffer error: \(error)")
return nil
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()
}
print("✅ Metal render completed successfully")
let result = createCGImage(from: outputTexture)
if result == nil {
print("❌ Failed to create CGImage from output texture")
}
return result
}
}
@ -188,7 +188,6 @@ final class MetalImageRenderer: Sendable {
let rowBytes = width * 4
let length = rowBytes * height
// CRITICAL: Create data buffer that will be copied, not retained
var bytes = [UInt8](repeating: 0, count: length)
let region = MTLRegionMake2D(0, 0, width, height)
@ -197,7 +196,6 @@ final class MetalImageRenderer: Sendable {
let colorSpace = CGColorSpaceCreateDeviceRGB()
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
// Create data with .copy behavior to avoid retaining original buffer
guard let data = CFDataCreate(nil, bytes, length) else { return nil }
guard let provider = CGDataProvider(data: data) else { return nil }
@ -213,4 +211,4 @@ final class MetalImageRenderer: Sendable {
shouldInterpolate: false,
intent: .defaultIntent)
}
}
}

View file

@ -4,6 +4,11 @@ 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
case bayer2x2 = 1
@ -175,21 +180,26 @@ class DitherViewModel {
print("🔄 Processing image with algorithm: \(self.selectedAlgorithm.name)")
self.renderTask = Task.detached(priority: .userInitiated) { [input, renderer, params] in
if Task.isCancelled {
// 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
return
}
let result = renderer.render(input: input, params: params)
// 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 {
if Task.isCancelled {
print("⚠️ Render task cancelled after render")
return
return
}
// Dispatch vers MainActor UNIQUEMENT pour la mise à jour UI
await MainActor.run {
if Task.isCancelled { return }
print("✅ Render complete, updating UI")
self.processedImage = result
}