Compare commits
No commits in common. "main" and "v0.1.5" 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 }}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
@preconcurrency import Metal
|
import Metal
|
||||||
import MetalKit
|
import MetalKit
|
||||||
import CoreGraphics
|
import CoreGraphics
|
||||||
|
|
||||||
|
|
@ -30,9 +30,7 @@ struct RenderParameters {
|
||||||
var randomSeed: UInt32
|
var randomSeed: UInt32
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ SUPPRESSION DE @MainActor - Metal est thread-safe en pratique
|
|
||||||
final class MetalImageRenderer: Sendable {
|
final class MetalImageRenderer: Sendable {
|
||||||
// ✅ nonisolated(unsafe) car Metal est thread-safe malgré l'absence de Sendable
|
|
||||||
private let device: MTLDevice
|
private let device: MTLDevice
|
||||||
private let commandQueue: MTLCommandQueue
|
private let commandQueue: MTLCommandQueue
|
||||||
private let pipelineState: MTLComputePipelineState
|
private let pipelineState: MTLComputePipelineState
|
||||||
|
|
@ -67,118 +65,120 @@ final class MetalImageRenderer: Sendable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func render(input: CGImage, params: RenderParameters) async -> CGImage? {
|
func render(input: CGImage, params: RenderParameters) -> CGImage? {
|
||||||
return await withCheckedContinuation { continuation in
|
return autoreleasepool {
|
||||||
autoreleasepool {
|
print("🎨 Metal render started - Image: \(input.width)x\(input.height), Algo: \(params.algorithm)")
|
||||||
print("🎨 Metal render started - Image: \(input.width)x\(input.height), Algo: \(params.algorithm)")
|
|
||||||
|
|
||||||
let textureLoader = MTKTextureLoader(device: device)
|
let textureLoader = MTKTextureLoader(device: device)
|
||||||
|
|
||||||
guard let inputTexture = try? textureLoader.newTexture(cgImage: input, options: [.origin: MTKTextureLoader.Origin.topLeft]) else {
|
// Load input texture
|
||||||
print("❌ Failed to create input texture")
|
guard let inputTexture = try? textureLoader.newTexture(cgImage: input, options: [.origin: MTKTextureLoader.Origin.topLeft]) else {
|
||||||
continuation.resume(returning: nil)
|
print("❌ Failed to create input texture")
|
||||||
return
|
return nil
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
// FLOYD-STEINBERG MULTI-PASS
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// PASS 1: Even Rows
|
||||||
|
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)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
computeEncoder.dispatchThreads(threadsPerGrid, threadsPerThreadgroup: threadsPerThreadgroup)
|
||||||
|
|
||||||
|
// Memory Barrier (Ensure Pass 1 writes are visible to Pass 2)
|
||||||
|
computeEncoder.memoryBarrier(scope: .textures)
|
||||||
|
|
||||||
|
// PASS 2: Odd Rows
|
||||||
|
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")
|
||||||
|
|
||||||
|
// STANDARD ALGORITHMS
|
||||||
|
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()
|
||||||
|
|
||||||
|
commandBuffer.commit()
|
||||||
|
commandBuffer.waitUntilCompleted()
|
||||||
|
|
||||||
|
if let error = commandBuffer.error {
|
||||||
|
print("❌ Metal command buffer error: \(error)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
print("✅ Metal render completed successfully")
|
||||||
|
|
||||||
|
let result = createCGImage(from: outputTexture)
|
||||||
|
if result == nil {
|
||||||
|
print("❌ Failed to create CGImage from output texture")
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -188,6 +188,7 @@ final class MetalImageRenderer: Sendable {
|
||||||
let rowBytes = width * 4
|
let rowBytes = width * 4
|
||||||
let length = rowBytes * height
|
let length = rowBytes * height
|
||||||
|
|
||||||
|
// CRITICAL: Create data buffer that will be copied, not retained
|
||||||
var bytes = [UInt8](repeating: 0, count: length)
|
var bytes = [UInt8](repeating: 0, count: length)
|
||||||
let region = MTLRegionMake2D(0, 0, width, height)
|
let region = MTLRegionMake2D(0, 0, width, height)
|
||||||
|
|
||||||
|
|
@ -196,6 +197,7 @@ final class MetalImageRenderer: Sendable {
|
||||||
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
let colorSpace = CGColorSpaceCreateDeviceRGB()
|
||||||
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)
|
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 data = CFDataCreate(nil, bytes, length) else { return nil }
|
||||||
guard let provider = CGDataProvider(data: data) else { return nil }
|
guard let provider = CGDataProvider(data: data) else { return nil }
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,6 @@ import ImageIO
|
||||||
import AppKit
|
import AppKit
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
// Helper for Swift 6 Concurrency
|
|
||||||
struct SendableCGImage: @unchecked Sendable {
|
|
||||||
let image: CGImage
|
|
||||||
}
|
|
||||||
|
|
||||||
enum DitherAlgorithm: Int, CaseIterable, Identifiable {
|
enum DitherAlgorithm: Int, CaseIterable, Identifiable {
|
||||||
case noDither = 0
|
case noDither = 0
|
||||||
case bayer2x2 = 1
|
case bayer2x2 = 1
|
||||||
|
|
@ -180,26 +175,21 @@ class DitherViewModel {
|
||||||
|
|
||||||
print("🔄 Processing image with algorithm: \(self.selectedAlgorithm.name)")
|
print("🔄 Processing image with algorithm: \(self.selectedAlgorithm.name)")
|
||||||
|
|
||||||
// Wrap CGImage in a Sendable wrapper to satisfy strict concurrency
|
self.renderTask = Task.detached(priority: .userInitiated) { [input, renderer, params] in
|
||||||
let sendableInput = SendableCGImage(image: input)
|
|
||||||
|
|
||||||
// ✅ CHANGÉ : Enlève @MainActor de la Task
|
|
||||||
self.renderTask = Task { [sendableInput, renderer, params] in
|
|
||||||
if Task.isCancelled {
|
if Task.isCancelled {
|
||||||
print("⚠️ Render task cancelled before starting")
|
print("⚠️ Render task cancelled before starting")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Le rendu s'exécute sur un thread d'arrière-plan (performant)
|
let result = renderer.render(input: input, params: params)
|
||||||
let result = await renderer.render(input: sendableInput.image, params: params)
|
|
||||||
|
|
||||||
if Task.isCancelled {
|
if Task.isCancelled {
|
||||||
print("⚠️ Render task cancelled after render")
|
print("⚠️ Render task cancelled after render")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispatch vers MainActor UNIQUEMENT pour la mise à jour UI
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
if Task.isCancelled { return }
|
||||||
print("✅ Render complete, updating UI")
|
print("✅ Render complete, updating UI")
|
||||||
self.processedImage = result
|
self.processedImage = result
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue