Compare commits
10 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a5aa1fdef | ||
|
|
b2ffd9edaf | ||
|
|
87e3d99290 | ||
|
|
fb1bf90d5a | ||
|
|
d3822bc672 | ||
|
|
0d283c3dec | ||
|
|
99e9efabd4 | ||
|
|
38868a2aba | ||
|
|
586f87e222 | ||
|
|
4664379340 |
52
.github/workflows/auto-tag.yml
vendored
Normal file
52
.github/workflows/auto-tag.yml
vendored
Normal 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
161
.github/workflows/build-release.yml
vendored
Normal 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 }}
|
||||
|
|
@ -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,21 +67,21 @@ final class MetalImageRenderer: Sendable {
|
|||
}
|
||||
}
|
||||
|
||||
func render(input: CGImage, params: RenderParameters) -> CGImage? {
|
||||
return autoreleasepool {
|
||||
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)")
|
||||
|
||||
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
|
||||
continuation.resume(returning: nil)
|
||||
return
|
||||
}
|
||||
|
||||
print("✅ Input texture created: \(inputTexture.width)x\(inputTexture.height)")
|
||||
|
||||
// Create output texture
|
||||
let descriptor = MTLTextureDescriptor.texture2DDescriptor(pixelFormat: .rgba8Unorm,
|
||||
width: inputTexture.width,
|
||||
height: inputTexture.height,
|
||||
|
|
@ -88,14 +90,15 @@ final class MetalImageRenderer: Sendable {
|
|||
|
||||
guard let outputTexture = device.makeTexture(descriptor: descriptor) else {
|
||||
print("❌ Failed to create output texture")
|
||||
return nil
|
||||
continuation.resume(returning: nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Encode command
|
||||
guard let commandBuffer = commandQueue.makeCommandBuffer(),
|
||||
let computeEncoder = commandBuffer.makeComputeCommandEncoder() else {
|
||||
print("❌ Failed to create command buffer or encoder")
|
||||
return nil
|
||||
continuation.resume(returning: nil)
|
||||
return
|
||||
}
|
||||
|
||||
var params = params
|
||||
|
|
@ -103,39 +106,33 @@ final class MetalImageRenderer: Sendable {
|
|||
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
|
||||
continuation.resume(returning: nil)
|
||||
return
|
||||
}
|
||||
|
||||
// PASS 1: Even Rows
|
||||
// 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)
|
||||
|
||||
// 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
|
||||
// PASS 2
|
||||
computeEncoder.setComputePipelineState(pipe2)
|
||||
computeEncoder.setTexture(inputTexture, index: 0)
|
||||
computeEncoder.setTexture(outputTexture, index: 1)
|
||||
|
|
@ -147,7 +144,6 @@ final class MetalImageRenderer: Sendable {
|
|||
} else {
|
||||
print("🔄 Using standard dithering algorithm")
|
||||
|
||||
// STANDARD ALGORITHMS
|
||||
computeEncoder.setComputePipelineState(pipelineState)
|
||||
computeEncoder.setTexture(inputTexture, index: 0)
|
||||
computeEncoder.setTexture(outputTexture, index: 1)
|
||||
|
|
@ -163,22 +159,26 @@ final class MetalImageRenderer: Sendable {
|
|||
|
||||
computeEncoder.endEncoding()
|
||||
|
||||
commandBuffer.commit()
|
||||
commandBuffer.waitUntilCompleted()
|
||||
|
||||
if let error = commandBuffer.error {
|
||||
// ✅ 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)")
|
||||
return nil
|
||||
continuation.resume(returning: nil)
|
||||
return
|
||||
}
|
||||
|
||||
print("✅ Metal render completed successfully")
|
||||
|
||||
let result = createCGImage(from: outputTexture)
|
||||
let result = self.createCGImage(from: outputTexture)
|
||||
if result == nil {
|
||||
print("❌ Failed to create CGImage from output texture")
|
||||
}
|
||||
|
||||
return result
|
||||
continuation.resume(returning: result)
|
||||
}
|
||||
|
||||
commandBuffer.commit()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// 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
|
||||
}
|
||||
|
||||
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 {
|
||||
print("⚠️ Render task cancelled after render")
|
||||
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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue