From ef15201f213e1e2f9f5fb724530228dbf580ec0b Mon Sep 17 00:00:00 2001 From: Szymon Nowakowski Date: Mon, 9 Feb 2026 19:01:45 +0100 Subject: [PATCH] media: Format description struct, stubs for more formats --- packages/media/jpeg-specification.pdf | 3 + packages/media/png-specification.pdf | 3 + packages/media/src/Format.zig | 21 ++ packages/media/src/audio.zig | 2 +- packages/media/src/jpeg.zig | 139 ++++++++++++ packages/media/src/jxl.zig | 37 ++++ packages/media/src/png.zig | 298 ++++++++++++++++++++++++++ packages/media/src/qoa.zig | 55 +++-- packages/media/src/qoi.zig | 41 +++- packages/media/src/root.zig | 4 + 10 files changed, 582 insertions(+), 21 deletions(-) create mode 100644 packages/media/jpeg-specification.pdf create mode 100644 packages/media/png-specification.pdf create mode 100644 packages/media/src/Format.zig create mode 100644 packages/media/src/jpeg.zig create mode 100644 packages/media/src/jxl.zig create mode 100644 packages/media/src/png.zig diff --git a/packages/media/jpeg-specification.pdf b/packages/media/jpeg-specification.pdf new file mode 100644 index 0000000..1f1d12b --- /dev/null +++ b/packages/media/jpeg-specification.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:631031d4ba56b06abee3e312a0f235b9422da9c7267d1c8f7604418795768bf0 +size 1058883 diff --git a/packages/media/png-specification.pdf b/packages/media/png-specification.pdf new file mode 100644 index 0000000..2831e6e --- /dev/null +++ b/packages/media/png-specification.pdf @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bfaed0c725fad90962e20fb42e9f7f99ce98ca14f825baece6f836f599283422 +size 432993 diff --git a/packages/media/src/Format.zig b/packages/media/src/Format.zig new file mode 100644 index 0000000..a604e31 --- /dev/null +++ b/packages/media/src/Format.zig @@ -0,0 +1,21 @@ +const std = @import("std"); +const Self = @This(); + +/// The number of bytes necessary to confirm that a buffer contains this media +/// format. +magic_length: usize, +/// The number of bytes necessary to confirm that a buffer contains this media +/// format and to extract metadata stored at the beggining, if any. Must be at +/// least as big as `magic_length`. +info_length: usize, +/// The file extension usually associated with this media format; all in +/// lowercase and without the dot character. +extension: []const u8, +/// The media type (aka MIME type or Content-Type) usually associated with this +/// media format; not necessarily officially registered. +media_type: []const u8, + +/// Confirm whether a buffer contains this media format. The buffer doesn't have +/// to contain the entire file, only its beginning. The caller asserts that +/// `buffer` is at least `magic_length` bytes long. +isFormat: *const fn (buffer: []const u8) bool, diff --git a/packages/media/src/audio.zig b/packages/media/src/audio.zig index 95977de..e7ce1c0 100644 --- a/packages/media/src/audio.zig +++ b/packages/media/src/audio.zig @@ -17,6 +17,6 @@ pub const Sample = extern struct { }; pub const Stream = struct { - source: std.io.Reader, + source: *std.Io.Reader, sample_rate: u32, }; diff --git a/packages/media/src/jpeg.zig b/packages/media/src/jpeg.zig new file mode 100644 index 0000000..cee8bb0 --- /dev/null +++ b/packages/media/src/jpeg.zig @@ -0,0 +1,139 @@ +const std = @import("std"); + +const Format = @import("Format.zig"); + +const format: Format = .{ + .magic_length = magic.len, + // NOTE The information (like width and height) is not in a fixed position + .info_length = magic.len, + .extension = "jpeg", + .media_type = "image/jpeg", + .isFormat = isJpeg, +}; + +const magic = "\xFF\xD8\xFF"; + +const Info = union(enum) { + partial: void, + full: Header, + + pub fn makeFull(full: Header) Info { + return .{ .full = full }; + } +}; + +const Header = struct { + width: u32, + height: u32, +}; + +const Marker = enum(u8) { + /// Start of frame, Huffman coding, Baseline DCT + SOF_0 = 0xC0, + /// Start of frame, Huffman coding, Extended sequential DCT + SOF_1 = 0xC1, + /// Start of frame, Huffman coding, Progressive DCT + SOF_2 = 0xC2, + /// Start of frame, Huffman coding, Lossless (sequential) + SOF_3 = 0xC3, + /// Start of frame, Huffman coding, Differential sequential DCT + SOF_5 = 0xC5, + /// Start of frame, Huffman coding, Differential progressive DCT + SOF_6 = 0xC6, + /// Start of frame, Huffman coding, Differential lossless (sequential) + SOF_7 = 0xC7, + /// Start of frame, arithmetic coding, Extended sequential DCT + SOF_9 = 0xC9, + /// Start of frame, arithmetic coding, Progressive DCT + SOF_10 = 0xCA, + /// Start of frame, arithmetic coding, Lossless (sequential) + SOF_11 = 0xCB, + /// Start of frame, arithmetic coding, Differential sequential DCT + SOF_13 = 0xCD, + /// Start of frame, arithmetic coding, Differential progressive DCT + SOF_14 = 0xCE, + /// Start of frame, arithmetic coding, Differential lossless (sequential) + SOF_15 = 0xCF, + /// Define Huffman table(s) + DHT = 0xC4, + /// Define arithmetic coding conditioning(s) + DAC = 0xCC, + /// Start of image + SOI = 0xD8, + /// End of image + EOI = 0xD9, + /// Start of scan + SOS = 0xDA, + /// Define quantization table(s) + DQT = 0xDB, + /// Define number of lines + DNL = 0xDC, + /// Define restart interval + DRI = 0xDD, + /// Define hierarchical progression + DHP = 0xDE, + /// Expand reference component(s) + EXP = 0xDF, + /// Comment + COM = 0xFE, + /// For temporary private use in arithmetic coding + TEM = 0x01, + + _, + + /// Restart with modulo 8 count `m` + pub fn RST(m: u3) Marker { + return @enumFromInt(0xD0 | m); + } + + pub fn isRST(self: Marker) ?u3 { + return if (@intFromEnum(self) & 0b1111_1000 == 0xD0) @intCast(@intFromEnum(self) & 0b0000_0111) else null; + } + + /// Reserved for application segments + pub fn APP(n: u4) Marker { + return @enumFromInt(0xE0 | n); + } + + pub fn isAPP(self: Marker) ?u3 { + return if (@intFromEnum(self) & 0b1111_0000 == 0xE0) @intCast(@intFromEnum(self) & 0b0000_1111) else null; + } + + /// A standalone marker has no content and are not followed by segment + /// length parameter. + pub fn isStandalone(self: Marker) bool { + return self.isRST() != null or + self == .SOI or + self == .EOI or + self == .TEM; + } +}; + +/// The caller asserts that the buffer is at least `format.magic_length` bytes +/// long. +pub fn isJpeg(buffer: []const u8) bool { + return std.mem.eql(u8, buffer[0..format.magic_length], magic); +} + +/// The caller asserts that the buffer is at least `format.info_length` bytes +/// long. The information is not in a fixed position, so you need to provide a +/// substantial amount of data, in the order of kilobytes. Depending on the +/// amount of metadata, the information might be in the first kilobyte or dozens +/// of kilobytes in. +/// +/// This function returns: +/// +/// - `null` when the buffer is not a JPEG or it's a malformed JPEG +/// - `.partial` when the buffer appears to be a part of a JPEG, but the +/// information could not be found within the buffer provided +/// - `.full` when the buffer appears to be a part of a JPEG and the information +/// was fully contained within the buffer provided +pub fn info(buffer: []const u8) ?Info { + std.debug.assert(buffer.len >= format.info_length); + + if (!isJpeg(buffer)) { + return null; + } + + @panic("TODO"); +} diff --git a/packages/media/src/jxl.zig b/packages/media/src/jxl.zig new file mode 100644 index 0000000..aef89b6 --- /dev/null +++ b/packages/media/src/jxl.zig @@ -0,0 +1,37 @@ +const std = @import("std"); + +const Format = @import("Format.zig"); + +const format: Format = .{ + .magic_length = @max(magic_naked.len, magic_container.len), + .info_length = @max(magic_naked.len, magic_container.len), + .extension = "jxl", + .media_type = "image/jxl", + .isFormat = isJxl, +}; + +const magic_naked = "\xFF\x0A"; +const magic_container = "\x00\x00\x00\x0CJXL \r\n\x87\n"; + +const Header = struct {}; + +/// The caller asserts that the buffer is at least `format.magic_length` bytes +/// long. +pub fn isJxl(buffer: []const u8) bool { + std.debug.assert(buffer.len >= format.magic_length); + + return std.mem.startsWith(u8, buffer, magic_naked) or + std.mem.startsWith(u8, buffer, magic_container); +} + +/// The caller asserts that the buffer is at least `format.info_length` bytes +/// long. +pub fn info(buffer: []const u8) ?Header { + std.debug.assert(buffer.len >= format.info_length); + + if (!isJxl(buffer)) { + return null; + } + + @panic("TODO"); +} diff --git a/packages/media/src/png.zig b/packages/media/src/png.zig new file mode 100644 index 0000000..3080604 --- /dev/null +++ b/packages/media/src/png.zig @@ -0,0 +1,298 @@ +const std = @import("std"); + +const Format = @import("Format.zig"); + +const format: Format = .{ + .magic_length = magic.len, + // 4B - chunk length + // 4B - chunk type + // 13B - IHDR data + // 4B - checksum + .info_length = magic.len + 25, + .extension = "png", + .media_type = "image/png", + .isFormat = isPng, +}; + +const magic = "\x89PNG\r\n\x1A\n"; + +const BitDepth = enum(u8) { + @"1" = 1, + @"2" = 2, + @"4" = 4, + @"8" = 8, + @"16" = 16, +}; + +const ColorType = enum(u8) { + grayscale = 0, + rgb = 2, + palette = 3, + grayscale_alpha = 4, + rgba = 6, + + pub fn paletteUsed(self: ColorType) bool { + return @intFromEnum(self) & 0b0000_0001 != 0; + } + + pub fn colorUsed(self: ColorType) bool { + return @intFromEnum(self) & 0b0000_0010 != 0; + } + + pub fn alphaChannelUsed(self: ColorType) bool { + return @intFromEnum(self) & 0b0000_0100 != 0; + } +}; + +const CompressionMethod = enum(u8) { + flate = 0, +}; + +const FilterMethod = enum(u8) { + adaptive = 0, +}; + +const InterlaceMethod = enum(u8) { + none = 0, + adam7 = 1, +}; + +const Header = struct { + width: u32, + height: u32, + bit_depth: BitDepth, + color_type: ColorType, + compression_method: CompressionMethod, + filter_method: FilterMethod, + interlace_method: InterlaceMethod, +}; + +const ChunkType = enum(u32) { + IHDR = @bitCast("IHDR".*), + PLTE = @bitCast("PLTE".*), + IDAT = @bitCast("IDAT".*), + IEND = @bitCast("IEND".*), + tRNS = @bitCast("tRNS".*), + gAMA = @bitCast("gAMA".*), + cHRM = @bitCast("cHRM".*), + sRGB = @bitCast("sRGB".*), + iCCP = @bitCast("iCCP".*), + tEXt = @bitCast("tEXt".*), + zTXt = @bitCast("zTXt".*), + iTXt = @bitCast("iTXt".*), + bKGD = @bitCast("bKGD".*), + pHYs = @bitCast("pHYs".*), + sBIT = @bitCast("sBIT".*), + sPLT = @bitCast("sPLT".*), + hIST = @bitCast("hIST".*), + tIME = @bitCast("tIME".*), + _, + + pub fn ancillary(self: ChunkType) bool { + return @as([4]u8, @bitCast(self))[0] & 0b0010_0000 != 0; + } + + pub fn private(self: ChunkType) bool { + return @as([4]u8, @bitCast(self))[1] & 0b0010_0000 != 0; + } + + pub fn safeToCopy(self: ChunkType) bool { + return @as([4]u8, @bitCast(self))[3] & 0b0010_0000 != 0; + } +}; + +const Chunk = struct { + chunk_type: ChunkType, + data: []const u8, +}; + +const StandardKeyword = enum { + /// Short (one line) title or caption for image + Title, + /// Name of image’s creator + Author, + /// Description of image (possibly long) + Description, + /// Copyright notice + Copyright, + /// Time Time of original image creation + Creation, + /// Software used to create the image + Software, + /// Legal disclaimer + Disclaimer, + /// Warning of nature of content + Warning, + /// Device used to create the image + Source, + /// Miscellaneous comment; conversion from GIF comment + Comment, + + pub const map: std.StaticStringMap(StandardKeyword) = blk: { + const fields = @typeInfo(StandardKeyword).@"enum".fields; + + var kvs_list: [fields.len]struct { []const u8, StandardKeyword } = undefined; + for (fields, 0..) |field, i| { + kvs_list[i] = .{ field.name, @field(StandardKeyword, field.name) }; + } + + break :blk .initComptime(kvs_list); + }; + + pub fn isStandardKeyword(keyword: []const u8) ?StandardKeyword { + return map.get(keyword); + } +}; + +/// The caller asserts that the buffer is at least `format.magic_length` bytes +/// long. +pub fn isPng(buffer: []const u8) bool { + return std.mem.eql(u8, buffer[0..format.magic_length], magic); +} + +/// The caller asserts that the buffer is at least `format.info_length` bytes +/// long. +pub fn info(buffer: []const u8) ?Header { + std.debug.assert(buffer.len >= format.info_length); + + if (!isPng(buffer)) { + return null; + } + + const chunk = buffer[format.magic_length..format.info_length]; + + const length = std.mem.readInt(u32, chunk[0..4], .big); + const chunk_type: ChunkType = @bitCast(chunk[4..8].*); + + if (length != 13 or chunk_type != .IHDR) { + return null; + } + + const data = chunk[8..21]; + const crc = std.mem.readInt(u32, chunk[21..25], .big); + + if (std.hash.crc.Crc32IsoHdlc.hash(data) != crc) { + return null; + } + + const width = std.mem.readInt(u32, data[0..4], .big); + const height = std.mem.readInt(u32, data[4..8], .big); + const bit_depth = data[8]; + const color_type = data[9]; + const compression_method = data[10]; + const filter_method = data[11]; + const interlace_method = data[12]; + + if (width == 0 or width > 0x7FFF_FFFF or + height == 0 or height > 0x7FFF_FFFF or + bit_depth == 0 or bit_depth > 16 or !std.math.isPowerOfTwo(bit_depth) or + color_type == 1 or color_type == 5 or color_type > 6 or + compression_method != 0 or + filter_method != 0 or + interlace_method > 1) + { + return null; + } + + switch (color_type) { + 0 => { + // all bit depths allowed + }, + 2 => { + if (bit_depth < 8) return null; + }, + 3 => { + if (bit_depth > 8) return null; + }, + 4 => { + if (bit_depth < 8) return null; + }, + 6 => { + if (bit_depth < 8) return null; + }, + else => unreachable, + } + + return .{ + .width = width, + .height = height, + .bit_depth = @enumFromInt(bit_depth), + .color_type = @enumFromInt(color_type), + .compression_method = @enumFromInt(compression_method), + .filter_method = @enumFromInt(filter_method), + .interlace_method = @enumFromInt(interlace_method), + }; +} + +pub fn decodeChunks(buffer: []const u8, chunks: ?[]Chunk) !usize { + if (buffer.len < format.magic_length or !isPng(buffer)) { + return error.InvalidPng; + } + + var index: usize = 0; + var rest: []const u8 = buffer[format.magic_length..]; + + while (rest.len > 0) : (index += 1) { + if (rest.len < 8) { + return error.InvalidPng; + } + + const length = std.mem.readInt(u32, rest[0..4], .big); + const chunk_type: ChunkType = @bitCast(rest[4..8].*); + rest = rest[8..]; + + if (rest.len < length or length > 0x7FFF_FFFF) { + return error.InvalidPng; + } + + const data = rest[0..length]; + rest = rest[length..]; + + if (rest.len < 4) { + return error.InvalidPng; + } + + const crc = std.mem.readInt(u32, rest[0..4], .big); + rest = rest[4..]; + + if (std.hash.crc.Crc32IsoHdlc.hash(data) != crc) { + return error.InvalidPng; + } + + if (chunks) |ck| { + if (index < ck.len) { + ck[index] = .{ + .chunk_type = chunk_type, + .data = data, + }; + } + } + } +} + +pub fn decodeChunksAlloc(buffer: []const u8, allocator: std.mem.Allocator) ![]Chunk { + const n = try decodeChunks(buffer, null); + + const chunks = try allocator.alloc(Chunk, n); + errdefer allocator.free(chunks); + + _ = try decodeChunks(buffer, chunks); + return chunks; +} + +pub fn encodeChunks(chunks: []const Chunk, writer: *std.Io.Writer) !void { + try writer.writeAll(magic); + + for (chunks) |chunk| { + var crc: std.hash.crc.Crc32IsoHdlc = .init(); + var data: []const u8 = chunk.data; + + while (data.len > 0) { + const bytes_written = try writer.write(data); + crc.update(data[0..bytes_written]); + data = data[bytes_written..]; + } + + try writer.writeInt(u32, crc.final(), .big); + } +} diff --git a/packages/media/src/qoa.zig b/packages/media/src/qoa.zig index 23a7e07..75d3efa 100644 --- a/packages/media/src/qoa.zig +++ b/packages/media/src/qoa.zig @@ -1,8 +1,27 @@ const std = @import("std"); +const Format = @import("Format.zig"); + +const format: Format = .{ + .magic_length = magic.len, + // 4B - sample count + // 1B - channels + // 3B - sample rate + .info_length = magic.len + 8, + .extension = "qoa", + .media_type = "audio/qoa", + .isFormat = isQoa, +}; + +const magic = "qoaf"; + const Header = union(enum) { streaming: HeaderStreaming, static: HeaderStatic, + + pub fn initStatic(static: HeaderStatic) Header { + return .{ .static = static }; + } }; const HeaderStreaming = void; @@ -19,30 +38,38 @@ const HeaderStatic = struct { } }; -/// The caller asserts that the buffer is at least 12 bytes long, which can -/// contain the entirety of a QOA file header and the relevant information in -/// the first frame header. -pub fn info(buffer: []const u8) ?Header { - std.debug.assert(buffer.len >= 12); +/// The caller asserts that the buffer is at least `format.magic_length` bytes +/// long. +pub fn isQoa(buffer: []const u8) bool { + return std.mem.eql(u8, buffer[0..format.magic_length], magic); +} + +/// The caller asserts that the buffer is at least `format.info_length` bytes +/// long. +pub fn info(buffer: []const u8) ?Header { + std.debug.assert(buffer.len >= format.info_length); + + if (!isQoa(buffer)) { + return null; + } - const magic = buffer[0..4]; const samples = std.mem.readInt(u32, buffer[4..8], .big); const channels = buffer[8]; const sample_rate = std.mem.readInt(u24, buffer[9..12], .big); - if (!std.mem.eql(u8, magic, "qoaf") or channels == 0 or channels > 8 or sample_rate == 0) { + if (channels == 0 or channels > 8 or + sample_rate == 0) + { return null; } if (samples == 0) { return .streaming; } else { - return .{ - .static = .{ - .samples = samples, - .channels = channels, - .sample_rate = sample_rate, - }, - }; + return .initStatic(.{ + .samples = samples, + .channels = channels, + .sample_rate = sample_rate, + }); } } diff --git a/packages/media/src/qoi.zig b/packages/media/src/qoi.zig index 679a0c7..96a80e9 100644 --- a/packages/media/src/qoi.zig +++ b/packages/media/src/qoi.zig @@ -1,5 +1,21 @@ const std = @import("std"); +const Format = @import("Format.zig"); + +const format: Format = .{ + .magic_length = magic.len, + // 4B - width + // 4B - height + // 1B - channels + // 1B - color space + .info_length = magic.len + 10, + .extension = "qoi", + .media_type = "image/qoi", + .isFormat = isQoi, +}; + +const magic = "qoif"; + const Channels = enum(u8) { rgb = 3, rgba = 4, @@ -17,18 +33,31 @@ const Header = struct { color_space: ColorSpace, }; -/// The caller asserts that the buffer is at least 14 bytes long, which can -/// contain the entirety of a QOI header. -pub fn info(buffer: []const u8) ?Header { - std.debug.assert(buffer.len >= 14); +/// The caller asserts that the buffer is at least `format.magic_length` bytes +/// long. +pub fn isQoi(buffer: []const u8) bool { + return std.mem.eql(u8, buffer[0..format.magic_length], magic); +} + +/// The caller asserts that the buffer is at least `format.info_length` bytes +/// long. +pub fn info(buffer: []const u8) ?Header { + std.debug.assert(buffer.len >= format.info_length); + + if (!isQoi(buffer)) { + return null; + } - const magic = buffer[0..4]; const width = std.mem.readInt(u32, buffer[4..8], .big); const height = std.mem.readInt(u32, buffer[8..12], .big); const channels = buffer[12]; const color_space = buffer[13]; - if (!std.mem.eql(u8, magic, "qoif") or width == 0 or height == 0 or channels < 3 or channels > 4 or color_space > 1) { + if (width == 0 or + height == 0 or + channels < 3 or channels > 4 or + color_space > 1) + { return null; } diff --git a/packages/media/src/root.zig b/packages/media/src/root.zig index 5efa483..dd3e039 100644 --- a/packages/media/src/root.zig +++ b/packages/media/src/root.zig @@ -1,5 +1,9 @@ pub const audio = @import("audio.zig"); +pub const Format = @import("Format.zig"); pub const image = @import("image.zig"); +pub const jpeg = @import("jpeg.zig"); +pub const jxl = @import("jxl.zig"); +pub const png = @import("png.zig"); pub const qoa = @import("qoa.zig"); pub const qoi = @import("qoi.zig"); pub const stbi = @import("stbi.zig");