ascii_linker/
lib.rs

1//! # `ascii_linker`
2//!
3//! A procedural macro crate for embedding ASCII art animation frames from `.bapple` files
4//! directly into your Rust binary at compile time.
5//!
6//! ## Overview
7//!
8//! This crate provides two macros for working with `.bapple` files:
9//! - `link_frames!` - Embeds decompressed ASCII frames as strings
10//! - `embed_full!` - Embeds compressed frames with audio data and timing metadata
11//!
12//! ## File Format
13//!
14//! The `.bapple` format is a tar archive where:
15//! - Each entry represents a frame of ASCII art (zstd-compressed)
16//! - A special file named "metadata" contains timing information in RON format
17//! - A special file named "audio" contains audio data
18//! - All other entries are treated as animation frames
19//!
20//! ## Generating .bapple Files
21//!
22//! The `.bapple` files used by this crate are generated using [`asciic`](https://github.com/S0raWasTaken/bad_apple/tree/master/asciic),
23//! an ASCII art animation compiler. See the `asciic` documentation for details on creating
24//! `.bapple` files from video sources.
25//!
26//! ## Examples
27//!
28//! ### Using `link_frames!`
29//!
30//! ```no_run
31//! use ascii_linker::link_frames;
32//!
33//! // Embed frames at compile time
34//! const FRAMES: &[&str] = link_frames!("./animations/bad_apple.bapple");
35//!
36//! fn main() {
37//!     // Iterate through frames
38//!     for (i, frame) in FRAMES.iter().enumerate() {
39//!         println!("Frame {}: \n{}", i, frame);
40//!     }
41//! }
42//! ```
43//!
44//! ### Using `embed_full!`
45//!
46//! When using `embed_full!`, you'll need to add `zstd` to your dependencies to decompress
47//! frames at runtime:
48//!
49//! ```toml
50//! [dependencies]
51//! ascii_linker = "..."
52//! zstd = "0.13"
53//! ```
54//!
55//! ```no_run
56//! use ascii_linker::embed_full;
57//!
58//! // Embed compressed frames, audio, and timing
59//! const BAPPLE: (&[&[u8]], &[u8], u64) = embed_full!("./animations/bad_apple.bapple");
60//!
61//! fn main() {
62//!     let (frames, audio, frametime) = BAPPLE;
63//!     
64//!     println!("Total frames: {}", frames.len());
65//!     println!("Audio size: {} bytes", audio.len());
66//!     println!("Frame time: {}μs", frametime);
67//!     
68//!     // Decompress frames at runtime using zstd
69//!     for compressed_frame in frames {
70//!         let frame = zstd::decode_all(&compressed_frame[..]).unwrap();
71//!         let frame_str = String::from_utf8(frame).unwrap();
72//!         println!("{}", frame_str);
73//!     }
74//! }
75//! ```
76
77#![warn(clippy::pedantic)]
78
79use proc_macro::TokenStream;
80use ron::de::from_bytes;
81use serde::Deserialize;
82use std::fmt::Write;
83use std::fs::File;
84use std::io::Read;
85use tar::{Archive, Entry};
86use zstd::decode_all;
87
88/// Embeds ASCII art animation frames from a `.bapple` file into your binary at compile time.
89///
90/// This procedural macro reads a tar archive containing zstd-compressed ASCII art frames
91/// and generates a static string slice array (`&[&str]`) containing all the decompressed frames.
92///
93/// # Arguments
94///
95/// * `file_path` - A string literal path to the `.bapple` file (relative to the crate root)
96///
97/// # Returns
98///
99/// Returns a `&[&str]` where each element is a complete ASCII art frame as a string.
100///
101/// # File Processing
102///
103/// - Opens the specified `.bapple` file as a tar archive
104/// - Iterates through all entries in the archive
105/// - Skips entries named "metadata" or "audio"
106/// - Decompresses each frame using zstd
107/// - Converts the decompressed bytes to UTF-8 strings
108/// - Generates compile-time code that creates a static array of string slices
109///
110/// # Panics
111///
112/// This macro will panic at compile time if:
113/// - No file path is provided
114/// - The specified file cannot be opened
115/// - The file is not a valid tar archive
116/// - Any frame cannot be read or decompressed
117/// - The decompressed content is not valid UTF-8
118///
119/// # Examples
120///
121/// ```no_run
122/// use ascii_linker::link_frames;
123///
124/// // Basic usage - embed animation frames
125/// const FRAMES: &[&str] = link_frames!("./path/to/animation.bapple");
126///
127/// // Access individual frames
128/// println!("{}", FRAMES[0]);
129///
130/// // Get frame count
131/// println!("Total frames: {}", FRAMES.len());
132/// ```
133///
134/// # Notes
135///
136/// - The macro executes at compile time, so the file must exist when building
137/// - All frames are embedded in the final binary **decompressed**, increasing binary size significantly
138/// - For smaller binaries, consider using `embed_full!` which keeps frames compressed
139/// - The path is relative to the crate root where `Cargo.toml` is located
140/// - This is ideal for small ASCII animations where runtime decompression overhead is undesirable
141#[proc_macro]
142pub fn link_frames(items: TokenStream) -> TokenStream {
143    let file_path = items.into_iter().next().unwrap();
144    let path_str = file_path.to_string().trim_matches('"').to_string();
145
146    let mut tar: Archive<File> = Archive::new(File::open(path_str).unwrap());
147
148    let mut ret = String::from("&[");
149
150    for frame in tar.entries().unwrap() {
151        let mut frame: Entry<'_, File> = frame.unwrap();
152
153        let file_stem =
154            frame.header().path().unwrap().file_stem().unwrap().to_os_string();
155
156        if file_stem == *"metadata" || file_stem == *"audio" {
157            continue;
158        }
159
160        let mut content = Vec::new();
161        frame.read_to_end(&mut content).unwrap();
162
163        let frame_as_str =
164            String::from_utf8(decode_all(&*content).unwrap()).unwrap();
165
166        write!(ret, "{frame_as_str:?},").unwrap();
167    }
168    ret.push(']');
169    ret.parse().unwrap()
170}
171
172/// Embeds a complete `.bapple` file (compressed frames, audio, and timing) at compile time.
173///
174/// This procedural macro reads a tar archive and extracts all components of an ASCII animation,
175/// keeping frames in their compressed form for smaller binary size. This is more efficient than
176/// `link_frames!` when binary size is a concern or when you want to control decompression at runtime.
177///
178/// # Arguments
179///
180/// * `file_path` - A string literal path to the `.bapple` file (relative to the crate root)
181///
182/// # Returns
183///
184/// Returns a tuple `(&[&[u8]], &[u8], u64)` containing:
185/// - `&[&[u8]]` - Array of compressed frame data (zstd-compressed ASCII art)
186/// - `&[u8]` - Raw audio data extracted from the "audio" entry
187/// - `u64` - Frame time in microseconds (timing between frames)
188///
189/// # File Processing
190///
191/// - Opens the specified `.bapple` file as a tar archive
192/// - Extracts the "metadata" entry and parses it as RON format to get timing information
193/// - Extracts the "audio" entry as raw bytes
194/// - Collects all other entries as compressed frame data
195/// - Generates compile-time code that creates static byte arrays
196///
197/// # Panics
198///
199/// This macro will panic at compile time if:
200/// - No file path is provided
201/// - The specified file cannot be opened
202/// - The file is not a valid tar archive
203/// - The "metadata" entry is missing or malformed
204/// - The frametime cannot be determined from metadata
205///
206/// # Dependencies
207///
208/// To decompress frames at runtime, add `zstd` to your `Cargo.toml`:
209///
210/// ```toml
211/// [dependencies]
212/// ascii_linker = "..."
213/// zstd = "0.13"
214/// ```
215///
216/// # Examples
217///
218/// ```no_run
219/// use ascii_linker::embed_full;
220///
221/// // Embed the complete animation
222/// const BAPPLE: (&[&[u8]], &[u8], u64) = embed_full!("./animations/bad_apple.bapple");
223///
224/// fn main() {
225///     let (compressed_frames, audio, frametime_us) = BAPPLE;
226///     
227///     println!("Animation info:");
228///     println!("  Frames: {}", compressed_frames.len());
229///     println!("  Audio size: {} bytes", audio.len());
230///     println!("  Frame time: {}μs ({} FPS)", frametime_us, 1_000_000 / frametime_us);
231///     
232///     // Decompress and display frames at runtime using zstd
233///     for (i, compressed_frame) in compressed_frames.iter().enumerate() {
234///         let decompressed = zstd::decode_all(&compressed_frame[..])
235///             .expect("Failed to decompress frame");
236///         let frame_str = String::from_utf8(decompressed)
237///             .expect("Invalid UTF-8 in frame");
238///         
239///         println!("Frame {}: \n{}", i, frame_str);
240///         
241///         // Use frametime for animation timing
242///         std::thread::sleep(std::time::Duration::from_micros(frametime_us));
243///     }
244/// }
245/// ```
246///
247/// # Metadata Format
248///
249/// The "metadata" file should be in RON (Rusty Object Notation) format with the following structure:
250///
251/// ```ron
252/// (
253///     frametime: 33333,  // Microseconds per frame (e.g., 33333 = ~30 FPS)
254///     fps: 0,            // DEPRECATED: legacy field, use frametime instead
255/// )
256/// ```
257///
258/// # Notes
259///
260/// - Frames remain **compressed** in the binary, significantly reducing binary size
261/// - You must decompress frames at runtime using `zstd::decode_all()`
262/// - The `fps` field in metadata is deprecated; `frametime` in microseconds is preferred
263/// - The path is relative to the crate root where `Cargo.toml` is located
264/// - This is ideal for larger animations or when you want to minimize binary size
265/// - Audio data format depends on your `.bapple` file (commonly WAV or raw PCM)
266#[proc_macro]
267pub fn embed_full(items: TokenStream) -> TokenStream {
268    let file_path = items.into_iter().next().unwrap();
269    let path_str = file_path.to_string().trim_matches('"').to_string();
270
271    let mut audio = Vec::new();
272    let mut frametime = 0;
273
274    let compressed_frames = Archive::new(File::open(path_str).unwrap())
275        .entries()
276        .unwrap()
277        .filter_map(|e| process_frames(e, &mut audio, &mut frametime))
278        .collect::<Vec<_>>();
279
280    assert!(
281        frametime != 0,
282        ".bapple file is too old or it's corrupted.\n\
283            Couldn't fetch the frametime info."
284    );
285
286    let mut audio_ret = String::from("&[");
287
288    for byte in audio {
289        write!(audio_ret, "{byte},").unwrap();
290    }
291    audio_ret.push(']');
292
293    let mut compressed_frames_ret = String::from("&[");
294
295    for frame_bytes in compressed_frames {
296        let mut frame = String::from("&[");
297        for byte in frame_bytes {
298            write!(frame, "{byte},").unwrap();
299        }
300        frame.push(']');
301        write!(compressed_frames_ret, "{frame},").unwrap();
302    }
303    compressed_frames_ret.push(']');
304
305    format!("({compressed_frames_ret},{audio_ret},{frametime})")
306        .parse()
307        .unwrap()
308}
309
310// Borrowed from `bplay`
311fn process_frames(
312    entry: Result<Entry<'_, File>, std::io::Error>,
313    audio: &mut Vec<u8>,
314    outer_frametime: &mut u64,
315) -> Option<Vec<u8>> {
316    let mut entry = entry.ok()?;
317    let file_stem = entry.header().path().ok()?.file_stem()?.to_os_string();
318
319    let mut content = Vec::new();
320    entry.read_to_end(&mut content).ok()?;
321
322    if file_stem == *"audio" {
323        *audio = content;
324
325        return None;
326    } else if file_stem == *"metadata" {
327        let Metadata { frametime, fps } =
328            from_bytes(&content).unwrap_or_default();
329        if frametime != 0 {
330            *outer_frametime = frametime;
331        } else if fps != 0 {
332            // DEPRECATED
333            *outer_frametime = 1_000_000 / fps;
334        }
335        return None;
336    }
337
338    Some(content)
339}
340
341#[derive(Deserialize, Default)]
342struct Metadata {
343    frametime: u64,
344    /// DEPRECATED
345    fps: u64,
346}