From 7e18d906c483997a39394025227fc5a1b2c112d1 Mon Sep 17 00:00:00 2001 From: hynak Date: Fri, 5 Dec 2025 19:46:42 +0100 Subject: [PATCH] [Feature] Add support for .dur file format and animations (closes #719) (#833) Adds support for durdraw's .dur file format. Supports ascii, animations, and 16/256 color display. Reviewed-on: https://codeberg.org/fairyglade/ly/pulls/833 Reviewed-by: AnErrupTion Co-authored-by: hynak Co-committed-by: hynak --- res/config.ini | 12 ++ src/animations/DurFile.zig | 425 +++++++++++++++++++++++++++++++++++++ src/config/Config.zig | 3 + src/enums.zig | 1 + src/main.zig | 5 + src/tui/TerminalBuffer.zig | 7 + 6 files changed, 453 insertions(+) create mode 100644 src/animations/DurFile.zig diff --git a/res/config.ini b/res/config.ini index f56bcc6..52fb15d 100644 --- a/res/config.ini +++ b/res/config.ini @@ -24,6 +24,7 @@ allow_empty_password = true # matrix -> CMatrix # colormix -> Color mixing shader # gameoflife -> John Conway's Game of Life +# dur_file -> .dur file format (https://github.com/cmang/durdraw/tree/master) animation = none # Stop the animation after some time @@ -163,6 +164,15 @@ doom_middle_color = 0x00C78F17 # DOOM animation custom bottom color (high intensity flames) doom_bottom_color = 0x00FFFFFF +# Dur file path +dur_file_path = $CONFIG_DIRECTORY/ly/example.dur + +# Dur offset x direction +dur_x_offset = 0 + +# Dur offset y direction +dur_y_offset = 0 + # Set margin to the edges of the DM (useful for curved monitors) edge_margin = 0 @@ -190,6 +200,8 @@ fg = 0x00FFFFFF # TB_WHITE 0x0008 # If full color is off, the styling options still work. The colors are # always 32-bit values with the styling in the most significant byte. +# Note: If using the dur_file animation option and the dur file's color range +# is saved as 256 with this option disabled, the file will not be drawn. full_color = true # Game of Life entropy interval (0 = disabled, >0 = add entropy every N generations) diff --git a/src/animations/DurFile.zig b/src/animations/DurFile.zig new file mode 100644 index 0000000..81b6f42 --- /dev/null +++ b/src/animations/DurFile.zig @@ -0,0 +1,425 @@ +const std = @import("std"); +const Animation = @import("../tui/Animation.zig"); +const Cell = @import("../tui/Cell.zig"); +const TerminalBuffer = @import("../tui/TerminalBuffer.zig"); +const Color = TerminalBuffer.Color; +const Styling = TerminalBuffer.Styling; +const Allocator = std.mem.Allocator; +const Json = std.json; +const eql = std.mem.eql; +const flate = std.compress.flate; + +fn read_decompress_file(allocator: Allocator, file_path: []const u8) ![]u8 { + const file_buffer = std.fs.cwd().openFile(file_path, .{}) catch { + return error.FileNotFound; + }; + defer file_buffer.close(); + + var file_reader_buffer: [4096]u8 = undefined; + var decompress_buffer: [flate.max_window_len]u8 = undefined; + + var file_reader = file_buffer.reader(&file_reader_buffer); + var decompress: flate.Decompress = .init(&file_reader.interface, .gzip, &decompress_buffer); + + const file_decompressed = decompress.reader.allocRemaining(allocator, .unlimited) catch { + return error.NotValidFile; + }; + + return file_decompressed; +} + +const Frame = struct { + frameNumber: i32, + delay: i32, + contents: [][]u8, + colorMap: [][][]i32, + + // allocator must be outside of struct as it will fail the json parser + pub fn deinit(self: *const Frame, allocator: Allocator) void { + for (self.contents) |con| { + allocator.free(con); + } + allocator.free(self.contents); + + for (self.colorMap) |cm| { + for (cm) |int2| { + allocator.free(int2); + } + allocator.free(cm); + } + allocator.free(self.colorMap); + } +}; + +// https://github.com/cmang/durdraw/blob/0.29.0/durformat.md +const DurFormat = struct { + allocator: Allocator, + formatVersion: ?i64 = null, + colorFormat: ?[]const u8 = null, + encoding: ?[]const u8 = null, + framerate: ?f64 = null, + columns: ?i64 = null, + lines: ?i64 = null, + frames: std.ArrayList(Frame) = undefined, + + pub fn valid(self: *DurFormat) bool { + if (self.formatVersion != null and + self.colorFormat != null and + self.encoding != null and + self.framerate != null and + self.columns != null and + self.lines != null and + self.frames.items.len >= 1) { + + // Oldest example in dur repo was 5 so unsure if older changes json layout + if (self.formatVersion.? < 5) return false; + // v8 may have breaking changes like changing the colormap xy direction + // (https://github.com/cmang/durdraw/issues/24) + if (self.formatVersion.? > 7) return false; + + // Code currently only supports 16 and 256 color format only + if (!(eql(u8, "16", self.colorFormat.?) or eql(u8, "256", self.colorFormat.?))) + return false; + + // Code currently supports only utf-8 encoding + if (!eql(u8, self.encoding.?, "utf-8")) return false; + + // Sanity check on file + if (self.columns.? <= 0) return false; + if (self.lines.? <= 0) return false; + if (self.framerate.? < 0) return false; + + return true; + } + + return false; + } + + fn parse_dur_from_json(self: *DurFormat, allocator: Allocator, dur_json_root: Json.Value) !void { + var dur_movie = if (dur_json_root.object.get("DurMovie")) |dm| dm.object else return error.NotValidFile; + + // Depending on the version, a dur file can have different json object names (ie: columns vs sizeX) + self.formatVersion = if (dur_movie.get("formatVersion"))|x| x.integer else null; + self.colorFormat = if (dur_movie.get("colorFormat")) |x| try allocator.dupe(u8, x.string) else null; + self.encoding = if (dur_movie.get("encoding")) |x| try allocator.dupe(u8, x.string) else null; + self.framerate = if (dur_movie.get("framerate")) |x| x.float else null; + self.columns = if (dur_movie.get("columns")) |x| x.integer + else if (dur_movie.get("sizeX")) |x| x.integer else null; + + self.lines = if (dur_movie.get("lines")) |x| x.integer + else if (dur_movie.get("sizeY")) |x| x.integer else null; + + const frames = dur_movie.get("frames") orelse return error.NotValidFile; + + self.frames = try .initCapacity(allocator, frames.array.items.len); + + for (frames.array.items) |json_frame| { + var parsed_frame = try Json.parseFromValue(Frame, allocator, json_frame, .{}); + defer parsed_frame.deinit(); + + const frame_val = parsed_frame.value; + + // copy all fields to own the ptrs for deallocation, the parsed_frame has some other + // allocated memory making it difficult to deallocate without leaks + const frame: Frame = .{ + .frameNumber = frame_val.frameNumber, + .delay = frame_val.delay, + .contents = try allocator.alloc([]u8, frame_val.contents.len), + .colorMap = try allocator.alloc([][]i32, frame_val.colorMap.len) + }; + + for (0..frame.contents.len) |i| { + frame.contents[i] = try allocator.dupe(u8, frame_val.contents[i]); + } + + // colorMap is stored as an 3d array where: + // the outer (i) most array is the horizontal position of the color + // the middle (j) is the vertical position of the color + // the inner (0/1) is the foreground/background color + for (0..frame.colorMap.len) |i| { + frame.colorMap[i] = try allocator.alloc([]i32, frame_val.colorMap[i].len); + for (0..frame.colorMap[i].len) |j| { + frame.colorMap[i][j] = try allocator.alloc(i32, 2); + frame.colorMap[i][j][0] = frame_val.colorMap[i][j][0]; + frame.colorMap[i][j][1] = frame_val.colorMap[i][j][1]; + } + } + + try self.frames.append(allocator, frame); + } + } + + pub fn create_from_file(self: *DurFormat, allocator: Allocator, file_path: [] const u8) !void { + const file_decompressed = try read_decompress_file(allocator, file_path); + defer allocator.free(file_decompressed); + + const parsed = try Json.parseFromSlice(Json.Value, allocator, file_decompressed, .{}); + defer parsed.deinit(); + + try parse_dur_from_json(self, allocator, parsed.value); + + if (!self.valid()) { return error.NotValidFile; } + } + + pub fn init(allocator: Allocator) DurFormat { + return .{ .allocator = allocator }; + } + + pub fn deinit(self: *DurFormat) void { + if (self.colorFormat) |str| self.allocator.free(str); + if (self.encoding) |str| self.allocator.free(str); + + for (self.frames.items) |frame| { + frame.deinit(self.allocator); + } + self.frames.deinit(self.allocator); + } +}; + +const tb_color_16 = [16]u32{ + Color.ECOL_BLACK, + Color.ECOL_RED, + Color.ECOL_GREEN, + Color.ECOL_YELLOW, + Color.ECOL_BLUE, + Color.ECOL_MAGENTA, + Color.ECOL_CYAN, + Color.ECOL_WHITE, + Color.ECOL_BLACK | Styling.BOLD, + Color.ECOL_RED | Styling.BOLD, + Color.ECOL_GREEN | Styling.BOLD, + Color.ECOL_YELLOW | Styling.BOLD, + Color.ECOL_BLUE | Styling.BOLD, + Color.ECOL_MAGENTA | Styling.BOLD, + Color.ECOL_CYAN | Styling.BOLD, + Color.ECOL_WHITE | Styling.BOLD, +}; + +// Using bold for bright colors allows for all 16 colors to be rendered on tty term +const rgb_color_16 = [16]u32{ + Color.DEFAULT, // DEFAULT instead of TRUE_BLACK to not break compositors (the latter ignores transparency) + Color.TRUE_DIM_RED, + Color.TRUE_DIM_GREEN, + Color.TRUE_DIM_YELLOW, + Color.TRUE_DIM_BLUE, + Color.TRUE_DIM_MAGENTA, + Color.TRUE_DIM_CYAN, + Color.TRUE_DIM_WHITE, + Color.DEFAULT | Styling.BOLD, + Color.TRUE_RED | Styling.BOLD, + Color.TRUE_GREEN | Styling.BOLD, + Color.TRUE_YELLOW | Styling.BOLD, + Color.TRUE_BLUE | Styling.BOLD, + Color.TRUE_MAGENTA | Styling.BOLD, + Color.TRUE_CYAN | Styling.BOLD, + Color.TRUE_WHITE | Styling.BOLD, +}; + +// Made this table from looking at colormapping in dur source, not sure whats going on with the mapping logic +// Array indexes are dur colormappings which value maps to indexes in table above. Only needed for dur 16 color +const durcolor_table_to_color16 = [17]u32{ + 0, // 0 black + 0, // 1 nothing?? dur source did not say why 1 is unused + 4, // 2 blue + 2, // 3 green + 6, // 4 cyan + 1, // 5 red + 5, // 6 magenta + 3, // 7 yellow + 7, // 8 light gray + 8, // 9 gray + 12, // 10 bright blue + 10, // 11 bright green + 14, // 12 bright cyan + 9, // 13 bright red + 13, // 14 bright magenta + 11, // 15 bright yellow + 15, // 16 bright white +}; + +fn sixcube_to_channel(sixcube: u32) u32 { + // Although the range top for the extended range is 0xFF, 6 is not divisible into 0xFF, + // so we use 0xF0 instead with a scaler + const equal_divisions = 0xF0 / 6; + + // Since the range is to 0xFF but 6 isn't divisible, we must add a scaler to get it to 0xFF at the last index (5) + const scaler = 0xFF - (equal_divisions * 5); + + return if (sixcube > 0) (sixcube * equal_divisions) + scaler else 0; +} + +fn convert_256_to_rgb(color_256: u32) u32 { + var rgb_color: u32 = 0; + + // 0 - 15 is the standard color range, map to array table + if (color_256 < 16) { + rgb_color = rgb_color_16[color_256]; + } + // 16 - 231 is the extended range + else if (color_256 < 232) { + + // For extended term range we subtract by 16 to get it in a 0..(6x6x6) cube (range of 216) + // divide by 36 gets the depth of the cube (6x6x1) + // divide by 6 gets the width of the cube (6x1) + // divide by 1 gets the height of the cube (divide 1 for clarity for what we are doing) + // each channel can be 6 levels of brightness hence remander operation of 6 + // finally bitshift to correct rgb channel (16 for red, 8 for green, 0 for blue) + rgb_color |= sixcube_to_channel(((color_256 - 16) / 36) % 6) << 16; + rgb_color |= sixcube_to_channel(((color_256 - 16) / 6) % 6) << 8; + rgb_color |= sixcube_to_channel(((color_256 - 16) / 1) % 6); + } + // 232 - 255 is the grayscale range + else { + + // For grayscale we have a space of 232 - 255 (24) + // subtract by 232 to get it into the 0..23 range + // standard colors will contain white and black, so we do not use them in the grayscale range (0 is 0x08, 23 is 0xEE) + // this results in a skip of 0x08 for the first color and divisions of 0x0A + // example: term_col 232 = scaler + equal_divisions * (232 - 232) which becomes (scaler + 0x00) == 0x08 + // example: term_col 255 = scaler + equal_divisions * (255 - 232) which becomes (scaler + 0xE6) == 0xEE + const scaler = 0x08; + + // to get equal parts, the equation is: + // 0xEE = equal_divisions * 23 + scaler | top of range is 0xEE, 23 is last element value (255 minus 232) + // reordered to solve for equal_divisions: + const equal_divisions = (0xEE - scaler) / 23; // evals to 0x0A + + const channel = scaler + equal_divisions * (color_256 - 232); + + // gray is equal value of same channel color in rgb + rgb_color = channel | (channel << 8) | (channel << 16); + } + + return rgb_color; +} + + +const DurFile = @This(); + +allocator: Allocator, +terminal_buffer: *TerminalBuffer, +frames: u64, +time_previous: i64, +x_offset: u32, +y_offset: u32, +full_color: bool, +dur_movie: DurFormat, +frame_width: u32, +frame_height: u32, +frame_time: u32, +is_color_format_16 : bool, + +pub fn init(allocator: Allocator, + terminal_buffer: *TerminalBuffer, + log_writer: *std.io.Writer, + file_path: []const u8, + x_offset: u32, + y_offset: u32, + full_color: bool) !DurFile { + var dur_movie: DurFormat = .init(allocator); + + // error state is recoverable when thrown to main and results in no background with Dummy in main + dur_movie.create_from_file(allocator, file_path) catch |err| switch (err) { + error.FileNotFound => { + try log_writer.print("error: dur_file was not found at: {s}\n", .{file_path}); + return err; + }, + error.NotValidFile => { + try log_writer.print("error: dur_file loaded was invalid or not a dur file!\n", .{}); + return err; + }, + else => return err, + }; + + // 4 bit mode with 256 color is unsupported + if (!full_color and eql(u8, dur_movie.colorFormat.?, "256")) { + try log_writer.print("error: dur_file can not be 256 color encoded when not using full_color option!\n", .{}); + dur_movie.deinit(); + return error.InvalidColorFormat; + } + + const buf_width: u32 = @intCast(terminal_buffer.width); + const buf_height: u32 = @intCast(terminal_buffer.height); + + const movie_width: u32 = @intCast(dur_movie.columns.?); + const movie_height: u32 = @intCast(dur_movie.lines.?); + + // Clamp to prevent user from exceeding draw window + const x_offset_clamped = std.math.clamp(x_offset, 0, buf_width - 1); + const y_offset_clamped = std.math.clamp(y_offset, 0, buf_height - 1); + + // Ensure if user offsets and frame goes offscreen, it will not overflow draw + const frame_width = if ((movie_width + x_offset_clamped) < buf_width) movie_width else buf_width - x_offset_clamped; + const frame_height = if ((movie_height + y_offset_clamped) < buf_height) movie_height else buf_height - y_offset_clamped; + + // Convert dur fps to frames per ms + const frame_time: u32 = @intFromFloat(1000 / dur_movie.framerate.?); + + return .{ + .allocator = allocator, + .terminal_buffer = terminal_buffer, + .frames = 0, + .time_previous = std.time.milliTimestamp(), + .x_offset = x_offset_clamped, + .y_offset = y_offset_clamped, + .full_color = full_color, + .dur_movie = dur_movie, + .frame_width = frame_width, + .frame_height = frame_height, + .frame_time = frame_time, + .is_color_format_16 = eql(u8, dur_movie.colorFormat.?, "16") + }; +} + +pub fn animation(self: *DurFile) Animation { + return Animation.init(self, deinit, realloc, draw); +} + +fn deinit(self: *DurFile) void { + self.dur_movie.deinit(); +} + +fn realloc(_: *DurFile) anyerror!void {} + +fn draw(self: *DurFile) void { + const current_frame = self.dur_movie.frames.items[self.frames]; + + for (0..self.frame_height) |y| { + var iter = std.unicode.Utf8View.initUnchecked(current_frame.contents[y]).iterator(); + + for (0..self.frame_width) |x| { + const codepoint: u21 = iter.nextCodepoint().?; + + var color_map_0: u32 = @intCast(current_frame.colorMap[x][y][0]); + var color_map_1: u32 = @intCast(current_frame.colorMap[x][y][1]); + + if (self.is_color_format_16) { + color_map_0 = durcolor_table_to_color16[color_map_0]; + color_map_1 = durcolor_table_to_color16[color_map_1 + 1]; // Add 1, dur source stores it like this for some reason + } + + const fg_color = if (self.full_color) convert_256_to_rgb(color_map_0) else tb_color_16[color_map_0]; + const bg_color = if (self.full_color) convert_256_to_rgb(color_map_1) else tb_color_16[color_map_1]; + + const cell = Cell { + .ch = @intCast(codepoint), + .fg = fg_color, + .bg = bg_color + }; + + cell.put(x + self.x_offset, y + self.y_offset); + } + } + + const time_current = std.time.milliTimestamp(); + const delta_time = time_current - self.time_previous; + + // Convert delay from sec to ms + const delay_time: u32 = @intCast(current_frame.delay * 1000); + if (delta_time > (self.frame_time + delay_time)) { + self.time_previous = time_current; + + const frame_count = self.dur_movie.frames.items.len; + self.frames = (self.frames + 1) % frame_count; + } +} diff --git a/src/config/Config.zig b/src/config/Config.zig index 05a14df..e9169bd 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -42,6 +42,9 @@ doom_fire_spread: u8 = 2, doom_top_color: u32 = 0x00FF0000, doom_middle_color: u32 = 0x00FFFF00, doom_bottom_color: u32 = 0x00FFFFFF, +dur_file_path: []const u8 = build_options.config_directory ++ "/ly/example.dur", +dur_x_offset: u32 = 0, +dur_y_offset: u32 = 0, edge_margin: u8 = 0, error_bg: u32 = 0x00000000, error_fg: u32 = 0x01FF0000, diff --git a/src/enums.zig b/src/enums.zig index 07dc3eb..337d6bf 100644 --- a/src/enums.zig +++ b/src/enums.zig @@ -5,6 +5,7 @@ pub const Animation = enum { matrix, colormix, gameoflife, + dur_file, }; pub const DisplayServer = enum { diff --git a/src/main.zig b/src/main.zig index fe85067..608f4e2 100644 --- a/src/main.zig +++ b/src/main.zig @@ -13,6 +13,7 @@ const Doom = @import("animations/Doom.zig"); const Dummy = @import("animations/Dummy.zig"); const Matrix = @import("animations/Matrix.zig"); const GameOfLife = @import("animations/GameOfLife.zig"); +const DurFile = @import("animations/DurFile.zig"); const Animation = @import("tui/Animation.zig"); const TerminalBuffer = @import("tui/TerminalBuffer.zig"); const Session = @import("tui/components/Session.zig"); @@ -572,6 +573,10 @@ pub fn main() !void { var game_of_life = try GameOfLife.init(allocator, &buffer, config.gameoflife_fg, config.gameoflife_entropy_interval, config.gameoflife_frame_delay, config.gameoflife_initial_density); animation = game_of_life.animation(); }, + .dur_file => { + var dur = try DurFile.init(allocator, &buffer, log_writer, config.dur_file_path, config.dur_x_offset, config.dur_y_offset, config.full_color); + animation = dur.animation(); + }, } defer animation.deinit(); diff --git a/src/tui/TerminalBuffer.zig b/src/tui/TerminalBuffer.zig index f09877d..999dfca 100644 --- a/src/tui/TerminalBuffer.zig +++ b/src/tui/TerminalBuffer.zig @@ -38,6 +38,13 @@ pub const Color = struct { pub const TRUE_MAGENTA = 0x00FF00FF; pub const TRUE_CYAN = 0x0000FFFF; pub const TRUE_WHITE = 0x00FFFFFF; + pub const TRUE_DIM_RED = 0x00800000; + pub const TRUE_DIM_GREEN = 0x00008000; + pub const TRUE_DIM_YELLOW = 0x00808000; + pub const TRUE_DIM_BLUE = 0x00000080; + pub const TRUE_DIM_MAGENTA = 0x00800080; + pub const TRUE_DIM_CYAN = 0x00008080; + pub const TRUE_DIM_WHITE = 0x00C0C0C0; pub const ECOL_BLACK = 1; pub const ECOL_RED = 2; pub const ECOL_GREEN = 3;