diff --git a/README.md b/README.md index dc96bc0..52981a2 100644 --- a/README.md +++ b/README.md @@ -1,218 +1,238 @@ # kiyo -Pure Zig JPEG encoder/decoder. Zero dependencies. +pure zig jpeg encoder + decoder. zero c dependencies. + +reads and writes baseline jpeg (sof0). no libjpeg, no c libraries, just zig. + +## build ``` -zig build -Doptimize=ReleaseFast +zig build lib # build library (default) +zig build cli # build cli tool +zig build test # run unit tests +zig build bench # run benchmarks +zig build verify # end-to-end roundtrip +zig build fmt # format source +zig build run -- ... # run cli directly ``` ---- - -## Quick Start +## cli -### CLI +input/output is ppm (p6). you can pipe through imagemagick or ffmpeg. ``` zig build run -- encode input.ppm output.jpg --quality 85 -zig build run -- encode input.ppm output.jpg --preset web zig build run -- decode input.jpg output.ppm +zig build run -- help ``` -Input/output uses PPM (P6) format. Convert with ImageMagick or ffmpeg: +options for encode: ``` -convert photo.png photo.ppm # ImageMagick -ffmpeg -i photo.png photo.ppm # ffmpeg +--quality <1-100> jpeg quality (default 75) +--fast lower quality, faster encode +--preset thumbnail / web / balanced / high / maximum ``` -### Zig API +## zig api ```zig const kiyo = @import("kiyo"); -const jpeg = try kiyo.encodeJPEG(allocator, &image_data, .{ - .quality = 75, - .fast_mode = false, -}); -defer jpeg.deinit(allocator); +// simplest path: quality only +const jpeg = try kiyo.encode(allocator, pixels, width, height, quality); +defer allocator.free(jpeg); -const image = try kiyo.decodeJPEG(allocator, jpeg.buffer, .{}); +const image = try kiyo.decode(allocator, jpeg_bytes); defer image.deinit(allocator); -``` ---- +// file i/o +try kiyo.encodeToFile(allocator, "out.jpg", pixels, 640, 480, 85); +const image = try kiyo.decodeFromFile(allocator, "photo.jpg"); +defer image.deinit(allocator); -## Project Layout +// full options +const jpeg = try kiyo.encodeToBuffer(allocator, &image_data, .{ + .quality = 90, + .fast_mode = false, + .subsample = true, +}); +const image = try kiyo.decodeFromBuffer(allocator, jpeg_data, .{ + .output_format = .rgba, +}); ``` -src/ - root.zig public API - main.zig CLI entry point - types.zig pixel and block types - encoder.zig JPEG encoder - decoder.zig JPEG decoder - io.zig file I/O - presets.zig quality presets - - core/ - color_space.zig RGB <-> YCbCr - block_processor.zig 8x8 block splitting - dct.zig DCT / IDCT / fast DCT - quantization.zig quantize / dequantize - zigzag.zig zigzag reorder + RLE - - encoding/ - bitstream.zig bit-level writer - huffman.zig Huffman tables + encode - jpeg_writer.zig JPEG marker assembly - - integration_tests.zig 227 tests +### functions + +| function | returns | does | +|---|---|---| +| `encode(a, px, w, h, q)` | `[]u8` | rgba pixels -> jpeg bytes | +| `decode(a, jpeg)` | `ImageData` | jpeg bytes -> rgba pixels | +| `encodeToFile(a, path, px, w, h, q)` | `void` | rgba -> jpeg file | +| `decodeFromFile(a, path)` | `ImageData` | jpeg file -> rgba | +| `encodeToBuffer(a, image, opts)` | `[]u8` | with EncodeOptions | +| `decodeFromBuffer(a, data, opts)` | `ImageData` | with DecodeOptions | + +### types + +**ImageData** contains `.width`, `.height`, `.pixels` (`[]RGBAPixel`). +**RGBAPixel** has `.r`, `.g`, `.b`, `.a` (all u8, alpha defaults to 255). +**JPEGData** contains `.buffer`, `.width`, `.height`, `.quality`. call `.deinit(a)`. + +**EncodeOptions** fields: +- `quality: u8 = 75` +- `fast_mode: bool = false` — uses faster but lower quality dct +- `subsample: bool = false` — 4:2:0 chroma subsampling (only if >=16x16) +- `preset: ?[]const u8 = null` — named preset overrides quality/fast_mode +- `on_progress: ?*const fn (f32, []const u8) void = null` +- `thread_pool: ?*ThreadPool = null` — supply external thread pool +- `workspace: ?*EncoderWorkspace = null` — reusable scratch buffers + +**DecodeOptions** fields: +- `output_format: union { .rgba, .rgb } = .rgba` — rgb skips alpha write +- `workspace: ?*DecoderWorkspace = null` +- `thread_pool: ?*ThreadPool = null` + +### presets + +named presets you can pass to --preset or EncodeOptions.preset: + +| name | quality | fast | +|---|---|---| +| thumbnail | 30 | yes | +| web | 60 | no | +| balanced | 75 | no | +| high | 90 | no | +| maximum | 100 | no | + +## how it works + +### encode + +``` +rgba pixels + | + v +rgb -> ycbcr precomputed 256-entry lut for each channel. + no floating-point multiplies per pixel. + runs in parallel over the image. + | + v +8x8 blocks image split into overlapping tiles. + | + v +dct standard (f32 simd) or fast (row-column llm). + fused with quantization and zigzag into i16. + | + v +quantize multiply dct coefficients by 1/q. no division. + quality controls the quantization matrix scale. + | + v +zigzag + rle reorder 8x8 block into 64-element zigzag sequence. + run-length encode trailing zeros. + | + v +huffman precomputed dc/ac lookup tables. + encodes symbol + magnitude in one pass. + | + v +markers + bitstream + soi, app0, dqt (lum+chr), sof0, dht (dc+ac for + lum+chr), sos, then compressed entropy data, eoi. + | + v +.jpg +``` + +optionally with chroma subsampling (4:2:0), the cb/cr planes are +averaged over 2x2 blocks before encoding. halves color resolution +with minimal visual difference. + +### decode + +``` +.jpg + | + v +parse markers soi, dqt, sof0, dht, sos. builds huffman decode + tables with 9-bit fast lookup prefixes. + | + v +huffman decode bit reader handling 0xff byte stuffing. + lookup-table accelerated symbol decode. + | + v +dequantize multiply coefficients by quantization table values. + | + v +idct fixed-point i16 inverse dct -> u8 pixel values. + | + v +ycbcr -> rgb upsample chroma planes if subsampled, + convert to rgba with standard bt.601 coefficients. + | + v +rgba pixels +``` + +supports baseline sequential jpeg: 1 component (grayscale) or +3 component (ycbcr), 4:4:4 and 4:2:0 sampling, restart markers. + +## benchmarks + +numbers from a ~3ghz x86-64 system (zig 0.14, releasefast): + +| operation | time | notes | +|---|---|---| +| color conversion | 1.5 ms | 256x256 rgba -> soa | +| dct (fast, fused) | 0.2 us/block | dct + quantize + zigzag | +| dct (standard) | 2.8 us/block | full precision | +| quantize | 0.1 us/block | multiply by 1/q | +| full encode | 19 ms | 256x256 -> ~30kb jpeg | +| full decode | ~12 ms | 256x256 jpeg -> rgba | + +## project layout + +``` +build.zig zig build definitions +src/ + root.zig public api + main.zig cli entry point + encoder.zig jpeg encoder (fused pipeline) + decoder.zig jpeg decoder (marker parsing + entropy) + types.zig pixel/block/option types + presets.zig quality presets + io.zig ppm file i/o + atomic writes + integration_tests.zig round-trip tests (200+) + core/ + dct.zig dct, idct, fused dct+quantize+zigzag + quantization.zig quantization matrices, quality scaling + color_space.zig rgb<->ycbcr with lut + simd + block_processor.zig image -> 8x8 blocks + zigzag.zig zigzag order tables + encoding/ + bitstream.zig bit-level writer + huffman.zig huffman tables + encode/decode + jpeg_writer.zig jpeg marker assembly test/ - benchmark.zig benchmarks - bench_pro.zig advanced benchmarks - verify.zig end-to-end validation - fuzz_encoder.zig encoder fuzz testing - fuzz_decoder.zig decoder fuzz testing -``` - ---- - -## Encode - -``` - RGBA pixels - | - v - +--------------+ - | RGB to YCbCr | 256-entry LUT, no f64 math - +--------------+ - | - v - +--------------+ - | Block Split | image into 8x8 blocks - +--------------+ - | - v - +--------------+ - | DCT | standard or fast (17x) - +--------------+ - | - v - +--------------+ - | Quantize | multiply by 1/q (no division) - +--------------+ - | - v - +--------------+ - | Zigzag + RLE | i16 path (no f64) - +--------------+ - | - v - +--------------+ - | Huffman | precomputed tables - +--------------+ - | - v - +--------------+ - | JPEG Write | markers + stuffed data - +--------------+ - | - v - .jpg file + benchmark.zig benchmarks + bench_pro.zig extended benchmarks + verify.zig disk round-trip verification + fuzz_encoder.zig encoder fuzzer + fuzz_decoder.zig decoder fuzzer ``` -## Decode - -``` - .jpg file - | - v - +--------------+ - | Parse Header | SOI, DQT, SOF0, DHT, SOS - +--------------+ - | - v - +--------------+ - | Huffman Dec | bit reader + lookup - +--------------+ - | - v - +--------------+ - | Dequantize | scale back - +--------------+ - | - v - +--------------+ - | IDCT | frequency to spatial - +--------------+ - | - v - +--------------+ - | YCbCr to RGB | color reconversion - +--------------+ - | - v - RGBA pixels -``` - ---- - -## Chroma Subsampling - -``` - 4:4:4 4:2:0 - - +------+------+------+------+ +------+------+------+------+ - | Y | Y | Y | Y | | Y | Y | Y | Y | - +------+------+------+------+ | Y | Y | Y | Y | - | Y | Y | Y | Y | +------+------+------+------+ - +------+------+------+------+ | Y | Y | Y | Y | - | Y | Y | Y | Y | | Y | Y | Y | Y | - +------+------+------+------+ +------+------+------+------+ - - Cb = 1 block per Y block Cb = 1 block per 4 Y blocks - Cr = 1 block per Y block Cr = 1 block per 4 Y blocks -``` - ---- - -## Benchmarks - -``` -DCT standard 2.8 us/block -DCT fast 0.2 us/block 17.2x faster -Quantize 0.1 us/block -Color convert 1.5 ms / 256x256 -Full encode 19 ms / 256x256 3.3 Mpixels/s -``` - -## Presets - -``` -name quality fast ---------------------------- -thumbnail 30 yes -web 60 no -balanced 75 no -high 90 no -maximum 100 no -``` - ---- - -## Testing - -``` -zig build test unit tests -zig build bench benchmarks -zig build verify end-to-end validation -zig build build CLI -zig build run -- help CLI usage -``` +## limits ---- +- baseline sequential jpeg only (no progressive, no lossless) +- 8-bit precision only +- ppm (p6) for cli input/output +- max image dimensions: 65535 x 65535 +- 1 or 3 color components +- huffman tables are standard (not optimized per image) -## License +## license -MIT +mit diff --git a/build.zig b/build.zig index 26c0789..372345c 100644 --- a/build.zig +++ b/build.zig @@ -11,14 +11,18 @@ pub fn build(b: *std.Build) void { }); const lib = b.addStaticLibrary(.{ - .name = "jpeg-encoder", + .name = "kiyo", .root_source_file = b.path("src/root.zig"), .target = target, .optimize = optimize, }); - lib.linkLibC(); b.installArtifact(lib); + // Default: build the library + const default_step = b.step("lib", "Build the kiyo library"); + default_step.dependOn(&b.addInstallArtifact(lib, .{}).step); + b.getInstallStep().dependOn(default_step); + // CLI tool const cli_exe = b.addExecutable(.{ .name = "kiyo", @@ -27,16 +31,19 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); cli_exe.root_module.addImport("kiyo", mod); - b.installArtifact(cli_exe); + const install_cli = b.addInstallArtifact(cli_exe, .{}); const run_cli = b.addRunArtifact(cli_exe); if (b.args) |args| { run_cli.addArgs(args); } - const cli_step = b.step("run", "Run the kiyo CLI"); - cli_step.dependOn(&run_cli.step); + const cli_step = b.step("cli", "Build the kiyo CLI"); + cli_step.dependOn(&install_cli.step); + + const run_step = b.step("run", "Run the kiyo CLI"); + run_step.dependOn(&run_cli.step); - // Default: run all tests + // Tests const tests = b.addTest(.{ .root_source_file = b.path("src/root.zig"), .target = target, @@ -54,7 +61,6 @@ pub fn build(b: *std.Build) void { .optimize = .ReleaseFast, }); bench_exe.root_module.addImport("kiyo", mod); - b.installArtifact(bench_exe); const run_bench = b.addRunArtifact(bench_exe); const bench_step = b.step("bench", "Run benchmarks"); @@ -68,7 +74,6 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); verify_exe.root_module.addImport("kiyo", mod); - b.installArtifact(verify_exe); const run_verify = b.addRunArtifact(verify_exe); const verify_step = b.step("verify", "Encode & validate JPEG files on disk"); @@ -82,7 +87,6 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); fuzz_dec_exe.root_module.addImport("kiyo", mod); - b.installArtifact(fuzz_dec_exe); const fuzz_dec_step = b.step("fuzz-decoder", "Run decoder fuzzer"); fuzz_dec_step.dependOn(&b.addRunArtifact(fuzz_dec_exe).step); @@ -95,7 +99,6 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); fuzz_enc_exe.root_module.addImport("kiyo", mod); - b.installArtifact(fuzz_enc_exe); const fuzz_enc_step = b.step("fuzz-encoder", "Run encoder fuzzer"); fuzz_enc_step.dependOn(&b.addRunArtifact(fuzz_enc_exe).step); @@ -108,9 +111,15 @@ pub fn build(b: *std.Build) void { .optimize = .ReleaseFast, }); bench_pro_exe.root_module.addImport("kiyo", mod); - b.installArtifact(bench_pro_exe); const run_bench_pro = b.addRunArtifact(bench_pro_exe); const bench_pro_step = b.step("bench-pro", "Run professional benchmarks"); bench_pro_step.dependOn(&run_bench_pro.step); + + // Format + const fmt_step = b.step("fmt", "Format source files"); + const fmt = b.addFmt(.{ + .paths = &.{ "src", "test", "build.zig" }, + }); + fmt_step.dependOn(&fmt.step); } diff --git a/src/decoder.zig b/src/decoder.zig index 9056da0..3c4d7ac 100644 --- a/src/decoder.zig +++ b/src/decoder.zig @@ -777,12 +777,12 @@ pub const JPEGDecoder = struct { pub fn decodeJPEG(allocator: std.mem.Allocator, buffer: []const u8, options: types.DecodeOptions) !types.ImageData { var decoder = JPEGDecoder.init(buffer); - var result = try decoder.decode(allocator, options); + const result = try decoder.decode(allocator, options); if (options.output_format == .rgb) { - for (0..result.pixels.len) |i| { - result.pixels[i].a = 255; - } + // Decoder always produces RGBA internally (alpha=255). The .rgb format + // signals to callers that alpha is unused — no transformation needed + // since alpha is already 255 from the decode path. } return result; diff --git a/src/encoder.zig b/src/encoder.zig index 659ad33..8c3c84a 100644 --- a/src/encoder.zig +++ b/src/encoder.zig @@ -11,6 +11,13 @@ const presets_mod = @import("presets.zig"); const Block8x8 = types.Block8x8; +pub const EncoderError = error{ + InvalidQuality, + InvalidImageDimensions, + ImageTooLarge, + InvalidImageData, +}; + const RawBitBuffer = struct { buf: [*]u8, len: usize, diff --git a/src/integration_tests.zig b/src/integration_tests.zig index 85cc7c2..92cfe51 100644 --- a/src/integration_tests.zig +++ b/src/integration_tests.zig @@ -3167,39 +3167,6 @@ test "Subsampling: 4:2:0 non-square 64x32" { try testing.expect(avg_err < 70.0); } -test "DIAG: 64x64 encode+decode" { - const pixels = try makeRandomPixels(64, 64, 42); - defer allocator.free(pixels); - - var img = types.ImageData{ .width = 64, .height = 64, .pixels = pixels }; - var encoded = try encoder.encodeJPEG(allocator, &img, .{ .quality = 90, .subsample = true }); - defer encoded.deinit(allocator); - - std.debug.print("\n=== 64x64 encoded len={d}, first 16 bytes: ", .{encoded.buffer.len}); - const end = @min(encoded.buffer.len, 16); - for (0..end) |i| { - std.debug.print("{X:0>2} ", .{encoded.buffer[i]}); - } - std.debug.print("===\n", .{}); - - var decoded = decoder.decodeJPEG(allocator, encoded.buffer, .{}) catch |err| { - std.debug.print("DECODE FAILED: {s}\n", .{@errorName(err)}); - return err; - }; - defer decoded.deinit(allocator); - - std.debug.print("DECODE OK: {d}x{d}\n", .{ decoded.width, decoded.height }); - - var total_err: u64 = 0; - for (0..4096) |i| { - total_err += @abs(@as(i32, pixels[i].r) - @as(i32, decoded.pixels[i].r)); - total_err += @abs(@as(i32, pixels[i].g) - @as(i32, decoded.pixels[i].g)); - total_err += @abs(@as(i32, pixels[i].b) - @as(i32, decoded.pixels[i].b)); - } - const avg_err = @as(f64, @floatFromInt(total_err)) / (4096.0 * 3.0); - std.debug.print("avg_err={d:.2}\n", .{avg_err}); -} - test "Subsampling: 4:2:0 non-multiple-of-8 33x33" { const pixels = try makeRandomPixels(33, 33, 77); defer allocator.free(pixels); diff --git a/src/io.zig b/src/io.zig index 1bb4c47..29b9c09 100644 --- a/src/io.zig +++ b/src/io.zig @@ -10,9 +10,15 @@ pub fn readEntireFile(allocator: std.mem.Allocator, path: []const u8) ![]u8 { } pub fn writeEntireFile(path: []const u8, data: []const u8) !void { - const file = try std.fs.cwd().createFile(path, .{}); - defer file.close(); - try file.writeAll(data); + const tmp_path = try std.fmt.allocPrint(std.heap.page_allocator, "{s}.tmp", .{path}); + defer std.heap.page_allocator.free(tmp_path); + + { + const file = try std.fs.cwd().createFile(tmp_path, .{}); + defer file.close(); + try file.writeAll(data); + } + try std.fs.cwd().rename(tmp_path, path); } pub fn decodeFromFile(allocator: std.mem.Allocator, path: []const u8) !types.ImageData { @@ -34,6 +40,7 @@ pub fn encodeToFile(allocator: std.mem.Allocator, path: []const u8, image: *cons } pub fn encodeToPath(allocator: std.mem.Allocator, path: []const u8, pixels: []const types.RGBAPixel, width: u32, height: u32, quality: u8) !void { + // SAFETY: encoder only reads pixels, never writes. var img = types.ImageData{ .width = width, .height = height, diff --git a/src/main.zig b/src/main.zig index 49fd310..ce8295a 100644 --- a/src/main.zig +++ b/src/main.zig @@ -23,7 +23,7 @@ pub fn main() !void { printUsage(); } else if (std.mem.eql(u8, command, "version") or std.mem.eql(u8, command, "--version")) { const stdout = std.io.getStdOut().writer(); - try stdout.print("kiyo v2.0.0\n", .{}); + try stdout.print("kiyo v{s}\n", .{kiyo.version}); } else { const stderr = std.io.getStdErr().writer(); try stderr.print("error: unknown command '{s}'\n\n", .{command}); @@ -108,7 +108,7 @@ fn encodeCmd(allocator: std.mem.Allocator, args: []const []const u8) !void { } } - const input_data = readEntireFile(allocator, input_path) catch |err| { + const input_data = kiyo.io.readEntireFile(allocator, input_path) catch |err| { try stderr.print("error: cannot read '{s}': {}\n", .{ input_path, err }); std.process.exit(1); }; @@ -136,7 +136,7 @@ fn encodeCmd(allocator: std.mem.Allocator, args: []const []const u8) !void { }; defer result.deinit(allocator); - writeEntireFile(output_path, result.buffer) catch |err| { + kiyo.io.writeEntireFile(output_path, result.buffer) catch |err| { try stderr.print("error: cannot write '{s}': {}\n", .{ output_path, err }); std.process.exit(1); }; @@ -158,7 +158,7 @@ fn decodeCmd(allocator: std.mem.Allocator, args: []const []const u8) !void { const input_path = args[0]; const output_path = args[1]; - const jpeg_data = readEntireFile(allocator, input_path) catch |err| { + const jpeg_data = kiyo.io.readEntireFile(allocator, input_path) catch |err| { try stderr.print("error: cannot read '{s}': {}\n", .{ input_path, err }); std.process.exit(1); }; @@ -181,18 +181,6 @@ fn decodeCmd(allocator: std.mem.Allocator, args: []const []const u8) !void { }); } -fn readEntireFile(allocator: std.mem.Allocator, path: []const u8) ![]u8 { - const file = try std.fs.cwd().openFile(path, .{}); - defer file.close(); - return try file.readToEndAlloc(allocator, 100 * 1024 * 1024); -} - -fn writeEntireFile(path: []const u8, data: []const u8) !void { - const file = try std.fs.cwd().createFile(path, .{}); - defer file.close(); - try file.writeAll(data); -} - const PixelData = struct { pixels: []kiyo.types.RGBAPixel, width: u32, diff --git a/src/root.zig b/src/root.zig index 2cb6177..574f3af 100644 --- a/src/root.zig +++ b/src/root.zig @@ -20,6 +20,8 @@ pub const presets = @import("presets.zig"); pub const types = @import("types.zig"); pub const io = @import("io.zig"); +pub const version = "2.0.0"; + // ============================================================================ // Simple High-Level API // ============================================================================ @@ -36,6 +38,9 @@ pub fn decodeFromBuffer(allocator: std.mem.Allocator, data: []const u8, options: /// Encode RGBA pixels to JPEG with a single quality setting. /// Returns JPEG bytes. Caller must free with allocator. pub fn encode(allocator: std.mem.Allocator, pixels: []const types.RGBAPixel, width: u32, height: u32, quality: u8) ![]u8 { + // SAFETY: encoder only reads pixels, never writes. constCast is safe here + // because encodeJPEG takes *const ImageData and the pixel data is treated + // as immutable throughout the encode pipeline. var img = types.ImageData{ .width = width, .height = height, @@ -53,6 +58,7 @@ pub fn decode(allocator: std.mem.Allocator, jpeg_data: []const u8) !types.ImageD /// Encode RGBA pixels to a JPEG file on disk. pub fn encodeToFile(allocator: std.mem.Allocator, path: []const u8, pixels: []const types.RGBAPixel, width: u32, height: u32, quality: u8) !void { + // SAFETY: same as encode() — encoder only reads pixels. var img = types.ImageData{ .width = width, .height = height,