diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31656ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# --- macOS --- +.DS_Store +.AppleDouble +.LSOverride +._* + +# --- Xcode --- +DerivedData/ +build/ +*.moved-aside +*.xccheckout +*.xcscmblueprint + +*.xcuserdata/ +*.xcworkspace/xcuserdata/ + +# --- Swift Package Manager (SPM) --- +.build/ +.swiftpm/ +Packages/ + +# --- Metal --- +*.metallib +*.air + +# --- Autres --- +*.swp +*~ \ No newline at end of file diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..f507952 --- /dev/null +++ b/Package.swift @@ -0,0 +1,25 @@ +// swift-tools-version: 6.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "iDither", + platforms: [ + .macOS(.v14) + ], + products: [ + .executable( + name: "iDither", + targets: ["iDither"] + ), + ], + targets: [ + .executableTarget( + name: "iDither", + resources: [ + .process("Resources") + ] + ), + ] +) diff --git a/Sources/iDither/ContentView.swift b/Sources/iDither/ContentView.swift new file mode 100644 index 0000000..031a179 --- /dev/null +++ b/Sources/iDither/ContentView.swift @@ -0,0 +1,186 @@ +import SwiftUI + +struct ContentView: View { + @State private var viewModel = DitherViewModel() + @State private var isExporting = false + + var body: some View { + NavigationSplitView { + SidebarView(viewModel: viewModel, openFile: openFile, saveFile: saveFile) + .navigationSplitViewColumnWidth(min: 260, ideal: 300) + } detail: { + DetailView(viewModel: viewModel, loadFromProviders: loadFromProviders) + } + .onChange(of: viewModel.brightness) { _, _ in viewModel.processImage() } + .onChange(of: viewModel.contrast) { _, _ in viewModel.processImage() } + .onChange(of: viewModel.pixelScale) { _, _ in viewModel.processImage() } + .onChange(of: viewModel.selectedAlgorithm) { _, _ in viewModel.processImage() } + .onChange(of: viewModel.isGrayscale) { _, _ in viewModel.processImage() } + } + + private func openFile() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.image] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + + if panel.runModal() == .OK { + if let url = panel.url { + viewModel.load(url: url) + } + } + } + + private func saveFile() { + let panel = NSSavePanel() + panel.allowedContentTypes = [.png] + panel.canCreateDirectories = true + panel.nameFieldStringValue = "dithered_image.png" + + if panel.runModal() == .OK { + if let url = panel.url { + viewModel.exportResult(to: url) + } + } + } + + private func loadFromProviders(_ providers: [NSItemProvider]) -> Bool { + guard let provider = providers.first else { return false } + + if provider.canLoadObject(ofClass: URL.self) { + _ = provider.loadObject(ofClass: URL.self) { url, _ in + if let url = url { + DispatchQueue.main.async { + viewModel.load(url: url) + } + } + } + return true + } + return false + } +} + +struct SidebarView: View { + @Bindable var viewModel: DitherViewModel + var openFile: () -> Void + var saveFile: () -> Void + + var body: some View { + Form { + Section("Dithering Algorithm") { + Picker("Algorithm", selection: $viewModel.selectedAlgorithm) { + ForEach(DitherAlgorithm.allCases) { algo in + Text(algo.name).tag(algo) + } + } + + 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) + } + } + } + .formStyle(.grouped) + .padding(.vertical) + .navigationTitle("iDither") + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + Button(action: openFile) { + Label("Import", systemImage: "square.and.arrow.down") + } + .help("Import Image") + + Button(action: saveFile) { + Label("Export", systemImage: "square.and.arrow.up") + } + .disabled(viewModel.processedImage == nil) + .help("Export PNG") + } + } + } +} + +struct DetailView: View { + var viewModel: DitherViewModel + var loadFromProviders: ([NSItemProvider]) -> Bool + + var body: some View { + ZStack { + CheckeredBackground() + .ignoresSafeArea() + + if let processedImage = viewModel.processedImage { + Image(decorative: processedImage, scale: 1.0, orientation: .up) + .resizable() + .aspectRatio(contentMode: .fit) + .padding(40) + .shadow(radius: 10) + } else { + ContentUnavailableView { + Label("No Image Selected", systemImage: "photo.badge.plus") + } description: { + Text("Drag and drop an image here to start dithering.") + } + } + } + .onDrop(of: [.image], isTargeted: nil) { providers in + loadFromProviders(providers) + } + } +} + +struct CheckeredBackground: View { + var body: some View { + Canvas { context, size in + let squareSize: CGFloat = 20 + let rows = Int(ceil(size.height / squareSize)) + let cols = Int(ceil(size.width / squareSize)) + + for row in 0.. CGImage? { + let textureLoader = MTKTextureLoader(device: device) + + // 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.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? { + let width = texture.width + let height = texture.height + let rowBytes = width * 4 + let length = rowBytes * height + + var bytes = [UInt8](repeating: 0, count: length) + let region = MTLRegionMake2D(0, 0, width, height) + + texture.getBytes(&bytes, bytesPerRow: rowBytes, from: region, mipmapLevel: 0) + + let colorSpace = CGColorSpaceCreateDeviceRGB() + let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue) + + guard let provider = CGDataProvider(data: Data(bytes: bytes, count: length) as CFData) else { return nil } + + return CGImage(width: width, + height: height, + bitsPerComponent: 8, + bitsPerPixel: 32, + bytesPerRow: rowBytes, + space: colorSpace, + bitmapInfo: bitmapInfo, + provider: provider, + decode: nil, + shouldInterpolate: false, + intent: .defaultIntent) + } +} diff --git a/Sources/iDither/Resources/Shaders.metal b/Sources/iDither/Resources/Shaders.metal new file mode 100644 index 0000000..89ba601 --- /dev/null +++ b/Sources/iDither/Resources/Shaders.metal @@ -0,0 +1,87 @@ +#include +using namespace metal; + +struct RenderParameters { + float brightness; + float contrast; + float pixelScale; + int algorithm; // 0: None, 1: Bayer 8x8, 2: Bayer 4x4 + int isGrayscale; +}; + +// Bayer 8x8 Matrix +constant float bayer8x8[8][8] = { + { 0.0/64.0, 32.0/64.0, 8.0/64.0, 40.0/64.0, 2.0/64.0, 34.0/64.0, 10.0/64.0, 42.0/64.0 }, + {48.0/64.0, 16.0/64.0, 56.0/64.0, 24.0/64.0, 50.0/64.0, 18.0/64.0, 58.0/64.0, 26.0/64.0 }, + {12.0/64.0, 44.0/64.0, 4.0/64.0, 36.0/64.0, 14.0/64.0, 46.0/64.0, 6.0/64.0, 38.0/64.0 }, + {60.0/64.0, 28.0/64.0, 52.0/64.0, 20.0/64.0, 62.0/64.0, 30.0/64.0, 54.0/64.0, 22.0/64.0 }, + { 3.0/64.0, 35.0/64.0, 11.0/64.0, 43.0/64.0, 1.0/64.0, 33.0/64.0, 9.0/64.0, 41.0/64.0 }, + {51.0/64.0, 19.0/64.0, 59.0/64.0, 27.0/64.0, 49.0/64.0, 17.0/64.0, 57.0/64.0, 25.0/64.0 }, + {15.0/64.0, 47.0/64.0, 7.0/64.0, 39.0/64.0, 13.0/64.0, 45.0/64.0, 5.0/64.0, 37.0/64.0 }, + {63.0/64.0, 31.0/64.0, 55.0/64.0, 23.0/64.0, 61.0/64.0, 29.0/64.0, 53.0/64.0, 21.0/64.0 } +}; + +// Bayer 4x4 Matrix +constant float bayer4x4[4][4] = { + { 0.0/16.0, 8.0/16.0, 2.0/16.0, 10.0/16.0 }, + {12.0/16.0, 4.0/16.0, 14.0/16.0, 6.0/16.0 }, + { 3.0/16.0, 11.0/16.0, 1.0/16.0, 9.0/16.0 }, + {15.0/16.0, 7.0/16.0, 13.0/16.0, 5.0/16.0 } +}; + +kernel void ditherShader(texture2d inputTexture [[texture(0)]], + texture2d outputTexture [[texture(1)]], + constant RenderParameters ¶ms [[buffer(0)]], + uint2 gid [[thread_position_in_grid]]) { + + if (gid.x >= outputTexture.get_width() || gid.y >= outputTexture.get_height()) { + return; + } + + // 1. Pixelation (Downsampling) + float scale = max(1.0, params.pixelScale); + uint2 sourceCoord = uint2(floor(float(gid.x) / scale) * scale, floor(float(gid.y) / scale) * scale); + + // Clamp to texture bounds + sourceCoord.x = min(sourceCoord.x, inputTexture.get_width() - 1); + sourceCoord.y = min(sourceCoord.y, inputTexture.get_height() - 1); + + float4 color = inputTexture.read(sourceCoord); + + // 2. Color Adjustment (Brightness & Contrast) + float3 rgb = color.rgb; + rgb = rgb + params.brightness; + rgb = (rgb - 0.5) * params.contrast + 0.5; + + // Grayscale conversion (Luma) + float luma = dot(rgb, float3(0.299, 0.587, 0.114)); + + if (params.isGrayscale > 0) { + rgb = float3(luma); + } + + // 3. Dithering + if (params.algorithm == 1) { // Bayer 8x8 + // Map current pixel to matrix coordinates + // We use the original gid (screen coordinates) for the matrix pattern to keep it stable across pixelation blocks? + // OR we use the sourceCoord (pixelated coordinates) to make the dither pattern scale with the pixels? + // Usually, dither is applied at screen resolution, but for "retro pixel art" look, the dither pattern usually matches the "big pixel" size. + // Let's try using the scaled coordinate index: sourceCoord / scale + + uint x = uint(sourceCoord.x / scale) % 8; + uint y = uint(sourceCoord.y / scale) % 8; + float threshold = bayer8x8[y][x]; + + // Apply threshold + rgb = (luma > threshold) ? float3(1.0) : float3(0.0); + + } else if (params.algorithm == 2) { // Bayer 4x4 + uint x = uint(sourceCoord.x / scale) % 4; + uint y = uint(sourceCoord.y / scale) % 4; + float threshold = bayer4x4[y][x]; + + rgb = (luma > threshold) ? float3(1.0) : float3(0.0); + } + + outputTexture.write(float4(rgb, color.a), gid); +} diff --git a/Sources/iDither/ViewModel/DitherViewModel.swift b/Sources/iDither/ViewModel/DitherViewModel.swift new file mode 100644 index 0000000..8a66a49 --- /dev/null +++ b/Sources/iDither/ViewModel/DitherViewModel.swift @@ -0,0 +1,80 @@ +import SwiftUI +import Combine + +enum DitherAlgorithm: Int, CaseIterable, Identifiable { + case none = 0 + case bayer8x8 = 1 + case bayer4x4 = 2 + + var id: Int { rawValue } + var name: String { + switch self { + case .none: return "No Dither" + case .bayer8x8: return "Bayer 8x8" + case .bayer4x4: return "Bayer 4x4" + } + } +} + +@MainActor +@Observable +class DitherViewModel { + var inputImage: CGImage? + var processedImage: CGImage? + + var brightness: Float = 0.0 + var contrast: Float = 1.0 + var pixelScale: Float = 1.0 + var selectedAlgorithm: DitherAlgorithm = .none + var isGrayscale: Bool = false + + private let renderer: MetalImageRenderer? + private var processingTask: Task? + + init() { + self.renderer = MetalImageRenderer() + } + + func load(url: URL) { + guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil), + let cgImage = CGImageSourceCreateImageAtIndex(imageSource, 0, nil) else { + print("Failed to load image") + return + } + + self.inputImage = cgImage + processImage() + } + + func processImage() { + guard let inputImage = inputImage, let renderer = renderer else { return } + + processingTask?.cancel() + + let params = RenderParameters( + brightness: brightness, + contrast: contrast, + pixelScale: pixelScale, + algorithm: Int32(selectedAlgorithm.rawValue), + isGrayscale: isGrayscale ? 1 : 0 + ) + + processingTask = Task.detached(priority: .userInitiated) { [inputImage, renderer, params] in + if let result = renderer.render(input: inputImage, params: params) { + await MainActor.run { + self.processedImage = result + } + } + } + } + + func exportResult(to url: URL) { + guard let processedImage = processedImage, + let destination = CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil) else { + return + } + + CGImageDestinationAddImage(destination, processedImage, nil) + CGImageDestinationFinalize(destination) + } +} diff --git a/Sources/iDither/iDitherApp.swift b/Sources/iDither/iDitherApp.swift new file mode 100644 index 0000000..20a45fe --- /dev/null +++ b/Sources/iDither/iDitherApp.swift @@ -0,0 +1,13 @@ +import SwiftUI + +@main +struct iDitherApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + // Style de fenĂȘtre standard macOS + .windowStyle(.titleBar) + .windowToolbarStyle(.unified) + } +}