feat(TbdfFile): add custom animation format support (tbdf)

Adds support for a custom animation format designed with longer
animations in mind, minimising memory and processing requirements,
compared to dur files.
This commit is contained in:
Titanium Brain 2026-08-07 20:15:34 +01:00
commit b9be2e73b1
4 changed files with 472 additions and 0 deletions

449
src/animations/TbdfFile.zig Normal file
View file

@ -0,0 +1,449 @@
const std = @import("std");
const Allocator = std.mem.Allocator;
const Json = std.json;
const eql = std.mem.eql;
const flate = std.compress.flate;
const ly_ui = @import("ly-ui");
const Cell = ly_ui.Cell;
const TerminalBuffer = ly_ui.TerminalBuffer;
const Color = TerminalBuffer.Color;
const Styling = TerminalBuffer.Styling;
const Widget = ly_ui.Widget;
const ly_core = ly_ui.ly_core;
const interop = ly_core.interop;
const TimeOfDay = interop.TimeOfDay;
const LogFile = ly_core.LogFile;
const enums = @import("../enums.zig");
const TbdfOffsetAlignment = enums.TbdfOffsetAlignment;
fn readDecompressFile(allocator: Allocator, io: std.Io, file_path: []const u8) ![]u8 {
const file_buffer = std.Io.Dir.cwd().openFile(io, file_path, .{}) catch {
return error.FileNotFound;
};
defer file_buffer.close(io);
var file_reader_buffer: [4096]u8 = undefined;
var decompress_buffer: [flate.max_window_len]u8 = undefined;
var file_reader = file_buffer.reader(io, &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 {
/// Every 3 bytes represent fg, bg and char index.
contents: []u24,
pub fn deinit(self: *const Frame, allocator: Allocator) void {
allocator.free(self.contents);
}
};
const TbdfFormat = struct {
allocator: Allocator,
formatVersion: u8,
flags: u8,
columns: u16,
lines: u16,
fps: u8,
charMap: []u21,
movie_data: []u24,
frames: std.ArrayList(Frame) = undefined,
fn parseFromRawData(allocator: Allocator, data: []u8) !TbdfFormat {
var iter = ByteIter{ .buf = data };
if (!(eql(u8, iter.next(4).?, "tbdf"))) return error.NotValidFile;
const formatVersion = (iter.next(1) orelse return error.NotValidFile)[0];
const flags = (iter.next(1) orelse return error.NotValidFile)[0];
const columns = iter.nextU16() orelse return error.NotValidFile;
const lines = iter.nextU16() orelse return error.NotValidFile;
const frame_count = iter.nextU16() orelse return error.NotValidFile;
const fps = (iter.next(1) orelse return error.NotValidFile)[0];
const char_map_len = (iter.next(1) orelse return error.NotValidFile)[0];
var utf8 = std.unicode.Utf8Iterator{ .bytes = data, .i = iter.pos };
var char_map: []u21 = try allocator.alloc(u21, char_map_len);
errdefer allocator.free(char_map);
for (0..char_map_len) |i| {
const codepoint: u21 = utf8.nextCodepoint() orelse return error.NotValidFile;
char_map[i] = codepoint;
}
// skip the char map unicode codepoints
iter.pos = utf8.i;
const frame_data = iter.rest() orelse return error.NotValidFile;
std.debug.print("data = {}\n", .{frame_data.len});
// if (frame_data.len % 4 != 0) return error.NotValidFile;
var frames = try std.ArrayList(Frame).initCapacity(allocator, frame_count);
errdefer frames.deinit(allocator);
const cells_per_frame: u32 = @as(u32, lines) * @as(u32, columns);
const total_cells = cells_per_frame * @as(u32, frame_count);
var movie_data = try allocator.alloc(u24, total_cells);
errdefer allocator.free(movie_data);
{
var i: usize = 0;
var pos: usize = 0;
// decompress all data into a buffer
while (i + 3 < frame_data.len) : (i += 4) {
const count = frame_data[i];
const fg = frame_data[i + 1];
const bg = frame_data[i + 2];
const char_index = frame_data[i + 3];
const value =
(@as(u24, fg) << 16) |
(@as(u24, bg) << 8) |
@as(u24, char_index);
if (pos + count > movie_data.len)
return error.NotValidFile;
@memset(movie_data[pos .. pos + count], value);
pos += count;
}
if (pos != movie_data.len)
return error.NotValidFile;
}
// create frames
for (0..frame_count) |i| {
const frame: Frame = .{ .contents = movie_data[i * cells_per_frame .. (i + 1) * cells_per_frame] };
try frames.append(allocator, frame);
}
return .{
.allocator = allocator,
.formatVersion = formatVersion,
.flags = flags,
.columns = columns,
.lines = lines,
.fps = fps,
.charMap = char_map,
.movie_data = movie_data,
.frames = frames,
};
}
pub fn createFromFile(allocator: Allocator, io: std.Io, file_path: []const u8) !TbdfFormat {
const file_decompressed = try readDecompressFile(allocator, io, file_path);
defer allocator.free(file_decompressed);
return parseFromRawData(allocator, file_decompressed);
}
pub fn deinit(self: *TbdfFormat) void {
self.allocator.free(self.movie_data);
self.allocator.free(self.charMap);
self.frames.deinit(self.allocator);
}
};
const ByteIter = struct {
buf: []const u8,
pos: usize = 0,
pub fn next(self: *ByteIter, n: usize) ?[]const u8 {
if (self.pos + n > self.buf.len) return null;
const slice = self.buf[self.pos .. self.pos + n];
self.pos += n;
return slice;
}
pub fn nextU16(self: *ByteIter) ?u16 {
const bytes = self.next(2) orelse return null;
return @as(u16, bytes[0]) |
(@as(u16, bytes[1]) << 8);
}
pub fn rest(self: *ByteIter) ?[]const u8 {
return self.next(self.buf.len - self.pos);
}
};
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,
};
fn sixCubeToChannel(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 convert256ToRgb(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 |= sixCubeToChannel(((color_256 - 16) / 36) % 6) << 16;
rgb_color |= sixCubeToChannel(((color_256 - 16) / 6) % 6) << 8;
rgb_color |= sixCubeToChannel(((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 UVec2 = @Vector(2, u32);
const IVec2 = @Vector(2, i64);
const VEC_X = 0;
const VEC_Y = 1;
const TbdfFile = @This();
instance: ?Widget = null,
start_time: TimeOfDay,
allocator: Allocator,
io: std.Io,
terminal_buffer: *TerminalBuffer,
movie: TbdfFormat,
current_frame_index: u64,
start_pos: IVec2,
full_color: bool,
animate: *bool,
timeout_sec: u12,
frame_delay: u16,
frame_time: u32,
time_previous: i64,
offset_alignment: TbdfOffsetAlignment,
offset: IVec2,
// if the user has an even number of columns or rows, we will default to the left or higher position (e.g. 4 columns center = .x..)
fn center(v: u32) i64 {
return @intCast((v / 2) + (v % 2));
}
fn calculateStartPos(terminal_buffer: *TerminalBuffer, tbdf_movie: *TbdfFormat, offset_alignment: TbdfOffsetAlignment, offset: IVec2) IVec2 {
const buf_width: u32 = @intCast(terminal_buffer.width);
const buf_height: u32 = @intCast(terminal_buffer.height);
const movie_width: u32 = @intCast(tbdf_movie.columns);
const movie_height: u32 = @intCast(tbdf_movie.lines);
const start_pos: IVec2 = switch (offset_alignment) {
TbdfOffsetAlignment.center => .{ center(buf_width) - center(movie_width), center(buf_height) - center(movie_height) },
TbdfOffsetAlignment.topleft => .{ 0, 0 },
TbdfOffsetAlignment.topcenter => .{ center(buf_width) - center(movie_width), 0 },
TbdfOffsetAlignment.topright => .{ buf_width - movie_width, 0 },
TbdfOffsetAlignment.centerleft => .{ 0, center(buf_height) - center(movie_height) },
TbdfOffsetAlignment.centerright => .{ buf_width - movie_width, center(buf_height) - center(movie_height) },
TbdfOffsetAlignment.bottomleft => .{ 0, buf_height - movie_height },
TbdfOffsetAlignment.bottomcenter => .{ center(buf_width) - center(movie_width), buf_height - movie_height },
TbdfOffsetAlignment.bottomright => .{ buf_width - movie_width, buf_height - movie_height },
};
return start_pos + offset;
}
pub fn init(
allocator: Allocator,
io: std.Io,
terminal_buffer: *TerminalBuffer,
log_file: *LogFile,
file_path: []const u8,
offset_alignment: TbdfOffsetAlignment,
x_offset: i32,
y_offset: i32,
full_color: bool,
animate: *bool,
timeout_sec: u12,
frame_delay: u16,
) !TbdfFile {
var movie: TbdfFormat = TbdfFormat.createFromFile(allocator, io, file_path) catch |err| switch (err) {
error.FileNotFound => {
try log_file.err(io, "tui", "tbdf_file was not found at: {s}", .{file_path});
return err;
},
error.NotValidFile => {
try log_file.err(io, "tui", "tbdf_file loaded was invalid or not a tbdf file!", .{});
return err;
},
else => return err,
};
const offset: IVec2 = .{ x_offset, y_offset };
const start_pos = calculateStartPos(terminal_buffer, &movie, offset_alignment, offset);
// Convert tbdf fps to ms per frames
const frame_time: u32 = @as(u32, 1000) / movie.fps;
return .{
.instance = null,
.start_time = try interop.getTimeOfDay(),
.allocator = allocator,
.io = io,
.terminal_buffer = terminal_buffer,
.current_frame_index = 0,
.time_previous = std.Io.Timestamp.now(io, .real).toMilliseconds(),
.start_pos = start_pos,
.full_color = full_color,
.animate = animate,
.timeout_sec = timeout_sec,
.frame_delay = frame_delay,
.movie = movie,
.frame_time = frame_time,
.offset_alignment = offset_alignment,
.offset = offset,
};
}
pub fn widget(self: *TbdfFile) *Widget {
if (self.instance) |*instance| return instance;
self.instance = Widget.init(
"TbdfFile",
null,
self,
deinit,
realloc,
draw,
update,
null,
calculateTimeout,
);
return &self.instance.?;
}
fn deinit(self: *TbdfFile) void {
self.movie.deinit();
}
fn realloc(self: *TbdfFile) !void {
// when terminal size changes, we need to recalculate the start_pos based on the new size
self.start_pos = calculateStartPos(self.terminal_buffer, &self.movie, self.offset_alignment, self.offset);
}
fn draw(self: *TbdfFile) void {
if (!self.animate.*) return;
const current_frame = self.movie.frames.items[self.current_frame_index];
// y is used as an iterator in the tbdf format, while cell_y gives us the correct placement for the cell (same for x)
for (0..@intCast(self.movie.lines)) |y| {
const cell_y = @as(i32, @intCast(y)) + self.start_pos[VEC_Y];
for (0..@intCast(self.movie.columns)) |x| {
const cell_x = @as(i32, @intCast(x)) + self.start_pos[VEC_X];
const index = y * self.movie.columns + x;
const cell_value = current_frame.contents[index];
const char_index: u8 = @intCast(cell_value & 0xff);
const codepoint = self.movie.charMap[char_index];
const cell_fg: u8 = @intCast(cell_value >> 16);
const fg_color = if (self.full_color) convert256ToRgb(cell_fg) else tb_color_16[cell_fg];
const cell_bg: u8 = @intCast((cell_value >> 8) & 0xff);
const bg_color = if (self.full_color) convert256ToRgb(cell_bg) else tb_color_16[cell_bg];
const cell = Cell{ .ch = @intCast(codepoint), .fg = fg_color, .bg = bg_color };
self.terminal_buffer.setCellBoundsChecked(cell_x, cell_y, cell) catch {};
}
}
const time_current = std.Io.Timestamp.now(self.io, .real).toMilliseconds();
const delta_time = time_current - self.time_previous;
// Convert delay from sec to ms
if (delta_time > (self.frame_time)) {
self.time_previous = time_current;
const frame_count = self.movie.frames.items.len;
self.current_frame_index = (self.current_frame_index + 1) % frame_count;
}
}
fn update(self: *TbdfFile, _: *anyopaque) !void {
const time = try interop.getTimeOfDay();
if (self.timeout_sec > 0 and time.seconds - self.start_time.seconds > self.timeout_sec) {
self.animate.* = false;
}
}
fn calculateTimeout(self: *TbdfFile, _: *anyopaque) !?usize {
return self.frame_delay;
}

View file

@ -6,6 +6,7 @@ const Input = enums.Input;
const ViMode = enums.ViMode;
const Bigclock = enums.Bigclock;
const DurOffsetAlignment = enums.DurOffsetAlignment;
const TbdfOffsetAlignment = enums.TbdfOffsetAlignment;
allow_empty_password: bool = true,
animation: Animation = .none,
@ -52,9 +53,13 @@ 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",
tbdf_file_path: []const u8 = build_options.config_directory ++ "/ly/example.tbdf",
dur_offset_alignment: DurOffsetAlignment = .center,
dur_x_offset: i32 = 0,
dur_y_offset: i32 = 0,
tbdf_offset_alignment: TbdfOffsetAlignment = .center,
tbdf_x_offset: i32 = 0,
tbdf_y_offset: i32 = 0,
edge_margin: u8 = 0,
error_bg: u32 = 0x00000000,
error_fg: u32 = 0x01FF0000,

View file

@ -8,6 +8,7 @@ pub const Animation = enum {
gameoflife,
dur_file,
lua,
tbdf_file,
};
pub const DisplayServer = enum {
@ -67,3 +68,15 @@ pub const DurOffsetAlignment = enum {
bottomcenter,
bottomright,
};
pub const TbdfOffsetAlignment = enum {
topleft,
topcenter,
topright,
centerleft,
center,
centerright,
bottomleft,
bottomcenter,
bottomright,
};

View file

@ -30,6 +30,7 @@ const DurFile = @import("animations/DurFile.zig");
const GameOfLife = @import("animations/GameOfLife.zig");
const Matrix = @import("animations/Matrix.zig");
const Lua = @import("animations/Lua.zig");
const TbdfFile = @import("animations/TbdfFile.zig");
const auth = @import("auth.zig");
const InfoLine = @import("components/InfoLine.zig");
const Session = @import("components/Session.zig");
@ -1100,6 +1101,10 @@ pub fn main(init: std.process.Init) !void {
);
animation = lua.widget();
},
.tbdf_file => {
var tbdf = try TbdfFile.init(state.allocator, state.io, &state.buffer, &state.log_file, state.config.tbdf_file_path, state.config.tbdf_offset_alignment, state.config.tbdf_x_offset, state.config.tbdf_y_offset, state.config.full_color, &state.animate, state.config.animation_timeout_sec, state.config.animation_frame_delay);
animation = tbdf.widget();
},
}
defer if (animation) |a| a.deinit();