From dc1793d2ff9ad84b45c8402894f1374cf95874f5 Mon Sep 17 00:00:00 2001 From: hynak Date: Fri, 5 Sep 2025 20:00:44 -0400 Subject: [PATCH] [Feature] Add support for .dur animations --- res/config.ini | 10 + src/animations/DurFile.zig | 362 +++++++++++++++++++++++++++++++++++++ src/config/Config.zig | 3 + src/enums.zig | 1 + src/main.zig | 5 + 5 files changed, 381 insertions(+) create mode 100644 src/animations/DurFile.zig diff --git a/res/config.ini b/res/config.ini index f56bcc6..f2c0081 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 diff --git a/src/animations/DurFile.zig b/src/animations/DurFile.zig new file mode 100644 index 0000000..887194a --- /dev/null +++ b/src/animations/DurFile.zig @@ -0,0 +1,362 @@ +const std = @import("std"); +const Animation = @import("../tui/Animation.zig"); +const Cell = @import("../tui/Cell.zig"); +const TerminalBuffer = @import("../tui/TerminalBuffer.zig"); +const Allocator = std.mem.Allocator; +const Json = std.json; +const eql = std.mem.eql; +const flate = std.compress.flate; // compress.gzip is moved to flate with zig 15 + +const DurParseError = error{ FileNotFound, NotValidFile }; + +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 read_decompress_dur(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; + } + + 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) |frame| { + var parsed_frame = try Json.parseFromValue(Frame, allocator, 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 tmp_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..tmp_frame.contents.len) |i| { + tmp_frame.contents[i] = try allocator.dupe(u8, frame_val.contents[i]); + } + + for (0..tmp_frame.colorMap.len) |i| { + tmp_frame.colorMap[i] = try allocator.alloc([]i32, frame_val.colorMap[i].len); + for (0..tmp_frame.colorMap[i].len) |j| { + tmp_frame.colorMap[i][j] = try allocator.alloc(i32, 2); + tmp_frame.colorMap[i][j][0] = frame_val.colorMap[i][j][0]; + tmp_frame.colorMap[i][j][1] = frame_val.colorMap[i][j][1]; + } + } + + try self.frames.append(allocator, tmp_frame); + } + } + + pub fn create_from_file(self: *DurFormat, allocator: Allocator, file_path: [] const u8) !void { + const file_decompressed = try read_decompress_dur(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 != null) self.allocator.free(self.colorFormat.?); + if (self.encoding != null) self.allocator.free(self.encoding.?); + + for (self.frames.items) |frame| { + frame.deinit(self.allocator); + } + self.frames.deinit(self.allocator); + } +}; + +// Using bold for bright colors allows for all 16 colors to be rendered on tty term +const color16 = [16]u32{ + 0x00000000, // black + 0x00800000, // red + 0x00008000, // green + 0x00808000, // yellow + 0x00000080, // blue + 0x00800080, // magenta + 0x00008080, // cyan + 0x00C0C0C0, // light gray + 0x00808080, // gray + 0x01FF5555, // bright red + 0x0100FF00, // bright green + 0x01FFFF00, // bright yellow + 0x010000FF, // bright blue + 0x01FF00FF, // bright magenta + 0x0100FFFF, // bright cyan + 0x01FFFFFF, // bright white +}; + +// 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 +}; + +// 256 to rgb math +// For extended term range we subtract by 16 to get it in a 0..(6x6x6) cube range +// divide by 36 gets the depth of the cube +// divide by 6 gets the width of the cube +// 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 +// +// 0x28 = 0xF0 / 6 # This gets us channel color in rgb, 6 equal steps +// 0x37 = 0xFF - (0x28 * 5) # 0x37 is the remander scaler + +fn sixcube_to_channel(sixcube: u32) u32 { + return if (sixcube > 0) (sixcube * 0x28) + 0x37 else 0; +} + +fn convert_256_to_rgb(color_256: u32) u32 { + var rgb_color: u32 = 0; + + if (color_256 < 16) { + rgb_color = color16[color_256]; + } + else if (color_256 < 232) { + 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 ); + } + else { + const channel = 0x08 + 0x0a * (color_256 - 232); + 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, +dur_movie: DurFormat, + +pub fn init(allocator: Allocator, + terminal_buffer: *TerminalBuffer, + log_writer: *std.io.Writer, + file_path: []const u8, + x_offset: u32, + y_offset: u32) !DurFile { + var dur_movie: DurFormat = .init(allocator); + + // error state when DurParseError is recoverable and results in no background + dur_movie.create_from_file(allocator, file_path) catch |err| switch (err) { + error.FileNotFound => { + try log_writer.print("DurFile was not found at {s}", .{file_path}); + }, + error.NotValidFile => { + try log_writer.print("DurFile was invalid or not a dur file!", .{}); + }, + else => { + return err; + }, + }; + + const buf_width: u32 = @intCast(terminal_buffer.width); + const buf_height: u32 = @intCast(terminal_buffer.height); + + return .{ + .allocator = allocator, + .terminal_buffer = terminal_buffer, + .frames = 0, + .time_previous = std.time.milliTimestamp(), + .x_offset = std.math.clamp(x_offset, 0, buf_width - 1), + .y_offset = std.math.clamp(y_offset, 0, buf_height - 1), + .dur_movie = dur_movie, + }; +} + +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 { + // dur_movie will be invalid if DurParseError errored out in init + if (!self.dur_movie.valid()) return; + + const buf_width: u32 = @intCast(self.terminal_buffer.width); + const buf_height: u32 = @intCast(self.terminal_buffer.height); + + const movie_width:u32 = @intCast(self.dur_movie.columns.?); + const movie_height:u32 = @intCast(self.dur_movie.lines.?); + + // Ensure if user offsets and frame goes offscreen, it will not overflow draw + const frame_width = if ((movie_width + self.x_offset) < buf_width) movie_width else buf_width - self.x_offset; + const frame_height = if ((movie_height + self.y_offset) < buf_height) movie_height else buf_height - self.y_offset; + + const current_frame = self.dur_movie.frames.items[self.frames]; + + for (0..frame_width) |x| { + for (0..frame_height) |y| { + var iter = std.unicode.Utf8View.initUnchecked(current_frame.contents[y]).iterator(); + + // Peak programming here, could not find a better way to get specific codepoints from an offset + for (0..x) |_| {_ = iter.nextCodepoint().?;} + 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 (eql(u8, self.dur_movie.colorFormat.?, "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 cell = Cell { + .ch = @intCast(codepoint), + .fg = convert_256_to_rgb(color_map_0), + .bg = convert_256_to_rgb(color_map_1) + }; + + 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 fps to time in ms, and delay from sec to ms + const frame_time: u32 = @intFromFloat(1000 / self.dur_movie.framerate.?); + const delay_time: u32 = @intCast(current_frame.delay * 1000); + + if (delta_time > (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..a4c08b3 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); + animation = dur.animation(); + }, } defer animation.deinit();