1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
|
//! Kitty graphics protocol animation support.
//!
//! https://sw.kovidgoyal.net/kitty/graphics-protocol/#animation
//!
//! An animation is a set of frames attached to an existing image.
//! Frame 1 (the "root frame") is the image's own base data. Frames
//! 2..N are stored here. Unlike Kitty, which stores frames as delta
//! rectangles in a disk cache and composes them lazily, we compose
//! every frame eagerly into a full image-sized RGBA buffer at load
//! time. This trades some memory for a much simpler model: a frame is
//! always ready to display and there are no reference chains to
//! maintain, coalesce, or garbage collect. Frame storage is bounded
//! by the same byte limit as image storage.
//!
//! To keep composition to a single pixel format, an image is
//! converted to RGBA the first time an animation command composes
//! into it, and all transmitted frame data is converted to RGBA
//! before composition. Blending an opaque (alpha=255) source pixel
//! degenerates to a copy, so this loses no information relative to
//! Kitty's separate RGB/RGBA paths. The pixel-level primitives
//! (conversion, fill, rectangle composition) live in
//! graphics_pixel.zig.
const std = @import("std");
const Allocator = std.mem.Allocator;
/// The gap assigned to a newly created frame when the command doesn't
/// specify one (z omitted or z=0). Taken from Kitty (DEFAULT_GAP).
pub const default_gap_ms: u32 = 40;
/// Animation state for a single image. Heap-allocated and owned by
/// the Image. This is created lazily by the first animation command that
/// needs it.
pub const Animation = struct {
/// The frames following the root frame: `frames.items[i]` is
/// protocol frame number `i + 2`. The root frame (frame 1) is the
/// image's base data and is not stored here.
frames: std.ArrayListUnmanaged(Frame) = .empty,
/// The gap of the root frame in milliseconds. Zero means gapless:
/// the frame is skipped during playback. Kitty creates the root
/// frame with a zero gap; the client gives it one with a=a,r=1,z=N.
root_gap_ms: u32 = 0,
/// The zero-based index of the frame currently displayed: 0 is
/// the root frame, i >= 1 is frames.items[i - 1].
current_index: u32 = 0,
/// Playback state. Every image starts stopped (client-driven).
state: State = .stopped,
/// Maximum number of loops to play (0 = infinite). Set from the
/// a=a v key as v-1, per the protocol's off-by-one encoding.
max_loops: u32 = 0,
/// Number of completed loops since playback started.
current_loop: u32 = 0,
/// Timestamp in milliseconds (on the ticker's clock, see
/// ImageStorage.animationTick) when the current frame was shown.
/// Null means "not yet shown"; the next tick stamps it.
frame_shown_at_ms: ?u64 = null,
pub const State = enum {
/// Not advancing; frames change only via a=a c=N (client-driven).
stopped,
/// Advancing, but playback parks on the last frame waiting
/// for more frames instead of looping (a=a s=2).
loading,
/// Advancing and looping (a=a s=3).
running,
};
pub const Frame = struct {
/// Fully composed pixel data, always image width * height * 4
/// bytes of RGBA.
data: []u8,
/// Milliseconds this frame is displayed before advancing.
/// Zero means gapless: skipped during playback.
gap_ms: u32,
};
pub fn deinit(self: *Animation, alloc: Allocator) void {
for (self.frames.items) |frame| alloc.free(frame.data);
self.frames.deinit(alloc);
}
/// The total number of frames including the root frame.
pub fn frameCount(self: *const Animation) u32 {
return @intCast(self.frames.items.len + 1);
}
/// The gap of the frame at the given zero-based index.
pub fn gapAt(self: *const Animation, index: u32) u32 {
if (index == 0) return self.root_gap_ms;
return self.frames.items[index - 1].gap_ms;
}
/// Set the gap of the frame at the given zero-based index.
pub fn setGapAt(self: *Animation, index: u32, gap_ms: u32) void {
if (index == 0) {
self.root_gap_ms = gap_ms;
} else {
self.frames.items[index - 1].gap_ms = gap_ms;
}
}
/// The sum of all frame gaps. An animation with a zero duration
/// (every frame gapless) never advances; this doubles as the
/// guard that keeps the gapless-skip loop in animationTick from
/// spinning forever, exactly like Kitty's animation_duration.
pub fn durationMs(self: *const Animation) u64 {
var total: u64 = self.root_gap_ms;
for (self.frames.items) |frame| total += frame.gap_ms;
return total;
}
/// Bytes of frame data held by this animation, counted against
/// the image storage limit.
pub fn frameBytes(self: *const Animation) usize {
var total: usize = 0;
for (self.frames.items) |frame| total += frame.data.len;
return total;
}
};
test "animation gap helpers" {
const testing = std.testing;
const alloc = testing.allocator;
var anim: Animation = .{};
defer anim.deinit(alloc);
try anim.frames.append(alloc, .{
.data = try alloc.alloc(u8, 4),
.gap_ms = 100,
});
try testing.expectEqual(@as(u32, 2), anim.frameCount());
try testing.expectEqual(@as(u32, 0), anim.gapAt(0));
try testing.expectEqual(@as(u32, 100), anim.gapAt(1));
try testing.expectEqual(@as(u64, 100), anim.durationMs());
try testing.expectEqual(@as(usize, 4), anim.frameBytes());
anim.setGapAt(0, 40);
anim.setGapAt(1, 60);
try testing.expectEqual(@as(u64, 100), anim.durationMs());
try testing.expectEqual(@as(u32, 40), anim.root_gap_ms);
}
|