diff --git a/Sources/iDither/ContentView.swift b/Sources/iDither/ContentView.swift index da5c76a..63e5056 100644 --- a/Sources/iDither/ContentView.swift +++ b/Sources/iDither/ContentView.swift @@ -4,11 +4,18 @@ import UniformTypeIdentifiers struct ContentView: View { @State private var viewModel = DitherViewModel() @State private var isImporting = false - @State private var isExporting = 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 var body: some View { NavigationSplitView { - SidebarView(viewModel: viewModel, isImporting: $isImporting, isExporting: $isExporting) + SidebarView(viewModel: viewModel, isImporting: $isImporting, showExportOptions: $showExportOptionsSheet) .navigationSplitViewColumnWidth(min: 280, ideal: 300) } detail: { DetailView(viewModel: viewModel, loadFromProviders: loadFromProviders) @@ -23,6 +30,8 @@ struct ContentView: View { .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, @@ -38,14 +47,84 @@ struct ContentView: View { print("Import failed: \(error.localizedDescription)") } } - .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)") + // 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) } } } @@ -65,39 +144,41 @@ struct ContentView: View { } return false } -} - -// Helper for FileExporter -struct ImageDocument: FileDocument { - var image: CGImage? - init(image: CGImage?) { - self.image = image - } - - static var readableContentTypes: [UTType] { [.png] } - - init(configuration: ReadConfiguration) throws { - // Read not implemented for export-only - self.image = nil - } - - 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) + func performExportWithOptions() { + let savePanel = NSSavePanel() + savePanel.canCreateDirectories = true + savePanel.showsTagField = true + + // Set filename with correct extension based on chosen format + let baseName = "dithered_image" + savePanel.nameFieldStringValue = "\(baseName).\(exportFormat.fileExtension)" + + // Set allowed file types + savePanel.allowedContentTypes = [exportFormat.utType] + + savePanel.begin { response in + guard response == .OK, + let url = savePanel.url else { return } + + // Perform export with the configured settings + viewModel.exportImage(to: url, + format: exportFormat, + scale: exportScale, + jpegQuality: jpegQuality, + preserveMetadata: preserveMetadata, + flattenTransparency: flattenTransparency) } - return FileWrapper(regularFileWithContents: pngData) } } +// SidebarView (Updated to trigger sheet) struct SidebarView: View { @Bindable var viewModel: DitherViewModel @Binding var isImporting: Bool - @Binding var isExporting: Bool + @Binding var showExportOptions: Bool + + @State private var showChaosSection = false // Chaos Section State var body: some View { ScrollView { @@ -115,7 +196,7 @@ struct SidebarView: View { Text(algo.name).tag(algo) } } - .labelsHidden() // Native look: just the dropdown + .labelsHidden() Toggle("Grayscale / 1-bit", isOn: $viewModel.isGrayscale) .toggleStyle(.switch) @@ -201,12 +282,109 @@ struct SidebarView: View { } } + 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...50, 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) + Spacer() } .padding(20) } - .background(.regularMaterial) // Native material background - .ignoresSafeArea(edges: .top) // Fix for titlebar gap + .background(.regularMaterial) + .ignoresSafeArea(edges: .top) .navigationTitle("iDither") .frame(minWidth: 280, maxWidth: .infinity, alignment: .leading) .toolbar { @@ -216,7 +394,7 @@ struct SidebarView: View { } .help("Import Image") - Button(action: { isExporting = true }) { + Button(action: { showExportOptions = true }) { Label("Export", systemImage: "square.and.arrow.up") } .disabled(viewModel.processedImage == nil) @@ -388,6 +566,80 @@ 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 + 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 diff --git a/Sources/iDither/Renderer/MetalImageRenderer.swift b/Sources/iDither/Renderer/MetalImageRenderer.swift index 278ed4e..3e7b1b1 100644 --- a/Sources/iDither/Renderer/MetalImageRenderer.swift +++ b/Sources/iDither/Renderer/MetalImageRenderer.swift @@ -6,9 +6,28 @@ struct RenderParameters { var brightness: Float var contrast: Float var pixelScale: Float - var colorDepth: Float // New parameter - var algorithm: Int32 // 0: None, 1: Bayer 8x8, 2: Bayer 4x4 - var isGrayscale: Int32 // 0: false, 1: true + 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 } final class MetalImageRenderer: Sendable { diff --git a/Sources/iDither/Resources/Shaders.metal b/Sources/iDither/Resources/Shaders.metal index d31bb97..995e46c 100644 --- a/Sources/iDither/Resources/Shaders.metal +++ b/Sources/iDither/Resources/Shaders.metal @@ -2,14 +2,164 @@ using namespace metal; struct RenderParameters { + // Existing parameters float brightness; float contrast; float pixelScale; - float colorDepth; // New parameter: 1.0 to 32.0 (Levels) - int algorithm; // 0: None, 1: Bayer 2x2, 2: Bayer 4x4, 3: Bayer 8x8, 4: Cluster 4x4, 5: Cluster 8x8, 6: Blue Noise + 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 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; + } + + if (params.turbulence > 0.0) { + float scale = 0.05; + 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 * 20.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 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, 0); + float2 blueOffset = coord - float2(amount, 0); + + 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.1, params.randomSeed); + if (randVal < params.bitDepthChaos) { + float reducedDepth = floor(randVal * 3.0) + 2.0; + chaosValue = floor(value * reducedDepth) / reducedDepth; + } + } + + if (params.paletteRandomize > 0.0) { + float randShift = (random(coord, params.randomSeed) - 0.5) * params.paletteRandomize; + 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}, @@ -69,11 +219,6 @@ constant float blueNoise8x8[8][8] = { }; float ditherChannel(float value, float threshold, float limit) { - // Quantization Formula - // value: 0.0 to 1.0 - // threshold: 0.0 to 1.0 (from matrix) - // limit: colorDepth (e.g. 4.0) - float ditheredValue = value + (threshold - 0.5) * (1.0 / (limit - 1.0)); return floor(ditheredValue * (limit - 1.0) + 0.5) / (limit - 1.0); } @@ -179,41 +324,31 @@ float getLuma(float3 rgb) { } // PASS 1: EVEN ROWS (Left -> Right) -// - Reads original pixel -// - Dithers it -// - Writes result to outputTexture -// - Writes RAW error 'diff' to errorTexture (at current coord) for Pass 2 to consume kernel void ditherShaderFS_Pass1(texture2d inputTexture [[texture(0)]], texture2d outputTexture [[texture(1)]], texture2d errorTexture [[texture(2)]], constant RenderParameters ¶ms [[buffer(0)]], uint2 gid [[thread_position_in_grid]]) { - // Dispatch: (1, height/2, 1). Each thread processes one FULL ROW. - uint y = gid.y * 2; // Pass 1 processes EVEN rows: 0, 2, 4... - + uint y = gid.y * 2; if (y >= inputTexture.get_height()) return; uint width = inputTexture.get_width(); - float3 currentError = float3(0.0); // Error propagated from immediate Left neighbor - - // Scale handling (minimal implementation for now, usually FS runs 1:1) - // If pixel scale > 1, FS behaves weirdly unless we downsample/upsample. - // For now, let's treat FS as operating on the native coordinates (or scaled ones). - // The previous shader code did manual pixelation. - // To support `pixelScale`, we simply use the scaled coordinates for reading input, - // but we iterate 1:1 on output? No, if we pixelate, we want blocky dither? - // FS is hard to 'blocky' dither without pre-scaling. - // Let's stick to 1:1 processing for the error diffusion logic itself. - // But we read the input color from the "pixelated" coordinate. + 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 + // 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); @@ -230,16 +365,23 @@ kernel void ditherShaderFS_Pass1(texture2d inputTexture [[t originalColor = float3(l); } - // ---------------------------------------------------- - // ERROR DIFFUSION CORE - // ---------------------------------------------------- - - // Add error from Left Neighbor (Pass 1 is L->R) + // 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); // Ensure no div by zero + 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); @@ -251,90 +393,93 @@ kernel void ditherShaderFS_Pass1(texture2d inputTexture [[t // Calculate Error float3 diff = pixelIn - pixelOut; - // Store RAW error for Pass 2 (Row below) to read - // Note: we store 'diff', NOT the distributed parts. Pass 2 will calculate distribution. + // 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); - // Propagate to Right Neighbor (7/16) - currentError = diff * (7.0 / 16.0); + // Chaos: Error Randomness in Propagation + 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; } } // PASS 2: ODD ROWS (Right -> Left Serpentine) -// - Reads original pixel -// - Absorbs error from Row Above (which stored RAW diffs) -// - Dithers -// - Writes result kernel void ditherShaderFS_Pass2(texture2d inputTexture [[texture(0)]], texture2d outputTexture [[texture(1)]], - texture2d errorTexture [[texture(2)]], // Contains diffs from Pass 1 + texture2d errorTexture [[texture(2)]], constant RenderParameters ¶ms [[buffer(0)]], uint2 gid [[thread_position_in_grid]]) { - // Dispatch: (1, height/2, 1) - uint y = gid.y * 2 + 1; // Pass 2 processes ODD rows: 1, 3, 5... - + uint y = gid.y * 2 + 1; if (y >= inputTexture.get_height()) return; uint width = inputTexture.get_width(); - float3 currentError = float3(0.0); // Error propagated from immediate Right neighbor (Serpentine R->L) + float3 currentError = float3(0.0); float scale = max(1.0, params.pixelScale); - // Serpentine: Iterate Right to Left 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 (Even Row, L->R) - // Row Above is y-1. We are at x. - // Even Row (y-1) propagated error to us (y) via: - // - (x-1, y-1) sent 3/16 (Bottom Left) -> reaches ME at x if I am (x-1+1) = x. Correct. - // - (x, y-1) sent 5/16 (Down) -> reaches ME at x. Correct. - // - (x+1, y-1) sent 1/16 (Bottom Right)-> reaches ME at x. Correct. - + // 1. Calculate Incoming Error from Row Above float3 errorFromAbove = float3(0.0); uint prevY = y - 1; - // Read neighbor errors (and apply weights now) + // Weights + float w_tr = 3.0 / 16.0; + float w_t = 5.0 / 16.0; + float w_tl = 1.0 / 16.0; - // From Top-Left (x-1, y-1): It pushed 3/16 to Bottom-Right (x) ? No. - // Standard FS (Left->Right scan): - // P(x, y) distributes: - // Right (x+1, y): 7/16 - // Bottom-Left (x-1, y+1): 3/16 - // Bottom (x, y+1): 5/16 - // Bottom-Right (x+1, y+1): 1/16 + // Chaos: Error Randomness + if (params.errorRandomness > 0.0) { + float r = random(float2(coords) + float2(10.0), params.randomSeed); + if (r < params.errorRandomness) { + 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 sum = r1 + r2 + r3 + 0.1; + w_tr = r1 / sum; + w_t = r2 / sum; + w_tl = r3 / sum; + } + } - // So, ME (x, y) receives from: - // (x+1, y-1) [Top Right]: sent 3/16 to its Bottom Left (which is ME). - // (x, y-1) [Top]: sent 5/16 to its Bottom (which is ME). - // (x-1, y-1) [Top Left]: sent 1/16 to its Bottom Right (which is ME). - - // Read Top Right (x+1, prevY) + // Read neighbors if (x + 1 < width) { float3 e = errorTexture.read(uint2(x+1, prevY)).rgb; - errorFromAbove += e * (3.0 / 16.0); + errorFromAbove += e * w_tr; } - - // Read Top (x, prevY) { float3 e = errorTexture.read(uint2(x, prevY)).rgb; - errorFromAbove += e * (5.0 / 16.0); + errorFromAbove += e * w_t; } - - // Read Top Left (x-1, prevY) if (x >= 1) { float3 e = errorTexture.read(uint2(x-1, prevY)).rgb; - errorFromAbove += e * (1.0 / 16.0); + 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); @@ -352,6 +497,16 @@ kernel void ditherShaderFS_Pass2(texture2d inputTexture [[t 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; @@ -361,15 +516,20 @@ kernel void ditherShaderFS_Pass2(texture2d inputTexture [[t pixelOut.b = floor(pixelIn.b * (levels - 1.0) + 0.5) / (levels - 1.0); pixelOut = clamp(pixelOut, 0.0, 1.0); - // 5. Diff + // 5. Diff & Propagate float3 diff = pixelIn - pixelOut; + if (params.errorAmplify != 1.0) { + diff *= params.errorAmplify; + } outputTexture.write(float4(pixelOut, colorRaw.a), coords); - // 6. Propagate Horizontally (Serpentine R->L) - // In R->L scan, 'Right' neighbor in FS diagram is actually 'Left' neighbor in spatial. - // We push 7/16 to the next pixel we visit (x-1). - currentError = diff * (7.0 / 16.0); + 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; } } diff --git a/Sources/iDither/ViewModel/DitherViewModel.swift b/Sources/iDither/ViewModel/DitherViewModel.swift index 4049aef..a540e4c 100644 --- a/Sources/iDither/ViewModel/DitherViewModel.swift +++ b/Sources/iDither/ViewModel/DitherViewModel.swift @@ -1,6 +1,8 @@ import SwiftUI import CoreGraphics import ImageIO +import AppKit +import UniformTypeIdentifiers enum DitherAlgorithm: Int, CaseIterable, Identifiable { case noDither = 0 @@ -43,11 +45,39 @@ class DitherViewModel { var selectedAlgorithm: DitherAlgorithm = .bayer4x4 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? 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 load(url: URL) { guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), let cgImage = CGImageSourceCreateImageAtIndex(source, 0, nil) else { @@ -66,13 +96,30 @@ class DitherViewModel { // Cancel previous task to prevent UI freezing and Metal overload renderTask?.cancel() + // Generate a random seed for consistent chaos per frame/update + let seed = UInt32.random(in: 0...UInt32.max) + let params = RenderParameters( brightness: Float(brightness), contrast: Float(contrast), pixelScale: Float(pixelScale), colorDepth: Float(colorDepth), algorithm: Int32(selectedAlgorithm.rawValue), - isGrayscale: isGrayscale ? 1 : 0 + isGrayscale: isGrayscale ? 1 : 0, + + // Chaos Params + offsetJitter: Float(offsetJitter), + patternRotation: Float(patternRotation), + errorAmplify: Float(errorAmplify), + errorRandomness: Float(errorRandomness), + thresholdNoise: Float(thresholdNoise), + waveDistortion: Float(waveDistortion), + pixelDisplace: Float(pixelDisplace), + turbulence: Float(turbulence), + chromaAberration: Float(chromaAberration), + bitDepthChaos: Float(bitDepthChaos), + paletteRandomize: Float(paletteRandomize), + randomSeed: seed ) renderTask = Task.detached(priority: .userInitiated) { [input, renderer, params] in @@ -89,14 +136,168 @@ class DitherViewModel { } func exportResult(to url: URL) { - guard let image = processedImage else { return } + // 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) + } + + // MARK: - Advanced Export + + func exportImage(to url: URL, + format: ExportFormat, + scale: CGFloat, + jpegQuality: Double, + preserveMetadata: Bool, + flattenTransparency: Bool) { - guard let destination = CGImageDestinationCreateWithURL(url as CFURL, "public.png" as CFString, 1, nil) else { - print("Failed to create image destination") + 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) return } - CGImageDestinationAddImage(destination, image, nil) - CGImageDestinationFinalize(destination) + 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) } } + +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 + } + } +} +