diff options
| author | Fuwn <[email protected]> | 2022-02-27 14:53:33 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2022-02-27 14:53:33 +0000 |
| commit | 68e792aceff2068f2b96b4eeeb44ccda65c0b231 (patch) | |
| tree | a30dd2959f3e99d15bc6b4c12fac5a32c3b8994a /src | |
| download | para-68e792aceff2068f2b96b4eeeb44ccda65c0b231.tar.xz para-68e792aceff2068f2b96b4eeeb44ccda65c0b231.zip | |
feat(para): :star:
Diffstat (limited to 'src')
| -rw-r--r-- | src/main.rs | 129 | ||||
| -rw-r--r-- | src/ppm.rs | 566 |
2 files changed, 695 insertions, 0 deletions
diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..7d10479 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,129 @@ +// Copyright (C) 2022-2022 Fuwn <[email protected]> +// SPDX-License-Identifier: MIT + +#![feature(decl_macro)] +#![deny( + warnings, + nonstandard_style, + unused, + future_incompatible, + rust_2018_idioms, + unsafe_code +)] +#![deny(clippy::all, clippy::nursery, clippy::pedantic)] +#![recursion_limit = "128"] + +mod ppm; + +use std::process::exit; + +use image::DynamicImage; + +use crate::ppm::PPMParser; + +#[allow(unused)] +fn get_image(parser: &mut PPMParser, index: usize) -> DynamicImage { + let frame = parser.get_frame_pixels(index); + let colours = parser.get_frame_palette(index); + let mut img = Vec::new(); + let mut img_encoder = image::codecs::bmp::BmpEncoder::new(&mut img); + + img_encoder.encode_with_palette( + &frame.into_iter().flatten().collect::<Vec<u8>>(), + 256, + 192, + image::ColorType::L8, + Some(&[ + [colours[0].0, colours[0].1, colours[0].2], + [colours[1].0, colours[1].1, colours[1].2], + [colours[2].0, colours[2].1, colours[2].2], + ]), + ); + + image::load_from_memory(&img).unwrap() +} + +fn main() { + human_panic::setup_panic!(Metadata { + version: env!("CARGO_PKG_VERSION").into(), + name: env!("CARGO_PKG_NAME").into(), + authors: env!("CARGO_PKG_AUTHORS").into(), + homepage: env!("CARGO_PKG_HOMEPAGE").into(), + }); + + let args = std::env::args().collect::<Vec<_>>(); + + if args.len() < 4 { + println!( + "{}, version {}(1)-{}-({})-{}\n\ + usage: {} <in> <index option> <out>\n\ + index options:\n\ + \tgif\n\ + \tthumb\n\ + \tdump\n\ + \tinteger(u16)\n\n\ + {0} home page: <https://github.com/Usugata/{0}>", + env!("CARGO_PKG_NAME"), + env!("CARGO_PKG_VERSION"), + env!("PROFILE"), + env!("TARGET"), + env!("GIT_COMMIT_HASH"), + args[0], + ); + exit(1); + } + + let path = &args[1]; + let index = &args[2]; + let out_path = &args[3]; + let mut parser = PPMParser::new_from_file(path); + parser.load(); + let frame_count = usize::from(parser.get_frame_count()); + + match index.as_str() { + "gif" => { + let frame_duration = (1.0 / parser.get_framerate()) * 100.0; + let frames = (0..parser.get_frame_count()) + .map(|i| get_image(&mut parser, i as usize)) + .collect::<Vec<DynamicImage>>(); + let mut file_out = std::fs::File::create(out_path).unwrap(); + let mut gif_encoder = image::codecs::gif::GifEncoder::new_with_speed( + &mut file_out, + #[allow(clippy::cast_possible_truncation)] + { + frame_duration as i32 + }, + ); + for frame in frames { + gif_encoder + .encode_frame(image::Frame::new(frame.into_rgba8())) + .unwrap(); + } + gif_encoder + .set_repeat(image::codecs::gif::Repeat::Infinite) + .unwrap(); + } + "thumb" => { + let thumb_index = parser.get_thumb_index() as usize; + get_image(&mut parser, thumb_index).save(out_path).unwrap(); + } + "dump" => parser.dump_to_json(out_path), + _ => { + if !(0..frame_count).contains(&index.parse::<usize>().unwrap()) { + println!( + "invalid frame index({}), image has {}(0..{}) frames", + index, + frame_count, + frame_count - 1, + ); + exit(1); + } + + get_image(&mut parser, index.parse::<usize>().unwrap()) + .save(out_path) + .unwrap(); + } + } + + println!("converted {}({}) to {}", path, index, out_path); +} diff --git a/src/ppm.rs b/src/ppm.rs new file mode 100644 index 0000000..f713ff1 --- /dev/null +++ b/src/ppm.rs @@ -0,0 +1,566 @@ +// Copyright (C) 2022-2022 Fuwn <[email protected]> +// SPDX-License-Identifier: MIT + +#![allow(clippy::cast_sign_loss)] + +use std::{ + collections::HashMap, + fs, + io::{Cursor, Read}, +}; + +use byteorder::{LittleEndian, ReadBytesExt}; +use chrono::{DateTime, NaiveDateTime, Utc}; + +lazy_static::lazy_static! { + // Flipnote speed -> frames per second + static ref FRAMERATES: HashMap<u8, f64> = { + let mut hashmap = HashMap::new(); + + hashmap.insert(1, 0.5); + hashmap.insert(2, 1.0); + hashmap.insert(3, 2.0); + hashmap.insert(4, 4.0); + hashmap.insert(5, 6.0); + hashmap.insert(6, 12.0); + hashmap.insert(7, 20.0); + hashmap.insert(8, 30.0); + + hashmap + }; + + // Thumbnail bitmap RGB colours + static ref THUMBNAIL_PALETTE: &'static [(u64, u64, u64)] = &[ + (0xFF, 0xFF, 0xFF), + (0x52, 0x52, 0x52), + (0xFF, 0xFF, 0xFF), + (0x9C, 0x9C, 0x9C), + (0xFF, 0x48, 0x44), + (0xC8, 0x51, 0x4F), + (0xFF, 0xAD, 0xAC), + (0x00, 0xFF, 0x00), + (0x48, 0x40, 0xFF), + (0x51, 0x4F, 0xB8), + (0xAD, 0xAB, 0xFF), + (0x00, 0xFF, 0x00), + (0xB6, 0x57, 0xB7), + (0x00, 0xFF, 0x00), + (0x00, 0xFF, 0x00), + (0x00, 0xFF, 0x00), + ]; + + // Frame RGB colours + static ref BLACK: (u8, u8, u8) = (0x0E, 0x0E, 0x0E); + static ref WHITE: (u8, u8, u8) = (0xFF, 0xFF, 0xFF); + static ref BLUE: (u8, u8, u8) = (0x0A, 0x39, 0xFF); + static ref RED: (u8, u8, u8) = (0xFF, 0x2A, 0x2A); +} + +macro read_n_to_as_utf8_from_stream($n:expr, $from:ident) { + String::from_utf8({ + let mut buffer = vec![0; $n]; + + $from.stream.read_exact(&mut buffer).unwrap(); + + buffer + }) + .unwrap() +} + +macro read_n_of_size_from_to_vec($n:expr, $from:tt, $size:ty) {{ + let mut buffer = vec![0 as $size; $n]; + + $from.stream.read_exact(&mut buffer).unwrap(); + + buffer +}} + +fn strip_null(string: &str) -> String { string.replace(char::from(0), "") } + +fn read_n_to_vec(stream: &mut Cursor<Vec<u8>>, n: usize) -> Vec<u8> { + let mut buffer = vec![0; n]; + + stream.read_exact(&mut buffer).unwrap(); + + buffer +} + +fn vec_u8_to_string(vec: &[u8]) -> String { + vec.iter().rev().map(|m| format!("{:02X}", m)).collect() +} + +pub struct PPMParser { + stream: Cursor<Vec<u8>>, + layers: Vec<Vec<Vec<u8>>>, + prev_layers: Vec<Vec<Vec<u8>>>, + prev_frame_index: usize, + animation_data_size: u32, + sound_data_size: u32, + frame_count: u16, + lock: u16, + thumb_index: u16, + root_author_name: String, + parent_author_name: String, + current_author_name: String, + parent_author_id: String, + current_author_id: String, + parent_filename: String, + current_filename: String, + root_author_id: String, + partial_filename: String, + timestamp: DateTime<Utc>, + layer_1_visible: bool, + layer_2_visible: bool, + loop_: bool, + frame_speed: u8, + bgm_speed: u8, + framerate: f64, + bgm_framerate: f64, + offset_table: Vec<u32>, +} +impl PPMParser { + #[allow(unused)] + pub fn new(stream: Vec<u8>) -> Self { + Self { + stream: Cursor::new(stream), + ..Self::default() + } + } + + pub fn new_from_file(file: &str) -> Self { + Self { + stream: Cursor::new(std::fs::read(file).unwrap()), + ..Self::default() + } + } + + pub fn load(&mut self) { + self.read_header(); + self.read_meta(); + self.read_animation_header(); + self.read_sound_header(); + self.layers = vec![vec![vec![0; 256]; 192]; 2]; + self.prev_layers = vec![vec![vec![0; 256]; 192]; 2]; + self.prev_frame_index = isize::MAX as usize; // -1 + } + + /// Decode header + /// + /// <https://github.com/pbsds/hatena-server/wiki/PPM-format#file-header> + fn read_header(&mut self) { + self.stream.set_position(0); + + let _magic = read_n_to_as_utf8_from_stream!(4, self); + let animation_data_size = self.stream.read_u32::<LittleEndian>().unwrap(); + let sound_data_size = self.stream.read_u32::<LittleEndian>().unwrap(); + let frame_count = self.stream.read_u16::<LittleEndian>().unwrap(); + let _version = self.stream.read_u16::<LittleEndian>().unwrap(); + + self.animation_data_size = animation_data_size; + self.sound_data_size = sound_data_size; + self.frame_count = frame_count + 1; + } + + fn read_filename(&mut self) -> String { + // Parent and current filenames are stored as: + // + // - three bytes representing the last six digits of the console's MAC address + // - thirteen-character `String` + // - `u16` edit counter + let mac = read_n_to_vec(&mut self.stream, 3); + let ident = read_n_to_vec(&mut self.stream, 13) + .into_iter() + .map(|c| c as char) + .collect::<String>(); + let edits = self.stream.read_u16::<LittleEndian>().unwrap(); + + // Filenames are formatted as + // <three-byte MAC as hexadecimal>_<thirteen-character string>_<edit counter as + // three-digit number> + // + // Example: F78DA8_14768882B56B8_030 + format!( + "{}_{}_{:#03}", + mac + .into_iter() + .map(|m| format!("{:02X}", m)) + .collect::<String>(), + String::from_utf8(ident.as_bytes().to_vec()).unwrap(), + edits, + ) + } + + /// Decode metadata + /// + /// <https://github.com/pbsds/hatena-server/wiki/PPM-format#file-header> + fn read_meta(&mut self) { + self.stream.set_position(0x10); + + self.lock = self.stream.read_u16::<LittleEndian>().unwrap(); + self.thumb_index = self.stream.read_u16::<LittleEndian>().unwrap(); + self.root_author_name = strip_null(&read_n_to_as_utf8_from_stream!(22, self)); + self.parent_author_name = strip_null(&read_n_to_as_utf8_from_stream!(22, self)); + self.current_author_name = strip_null(&read_n_to_as_utf8_from_stream!(22, self)); + self.parent_author_id = vec_u8_to_string(read_n_to_vec(&mut self.stream, 8).as_mut_slice()); + self.current_author_id = vec_u8_to_string(read_n_to_vec(&mut self.stream, 8).as_mut_slice()); + self.parent_filename = self.read_filename(); + self.current_filename = self.read_filename(); + self.root_author_id = vec_u8_to_string(read_n_to_vec(&mut self.stream, 8).as_mut_slice()); + self.partial_filename = vec_u8_to_string(read_n_to_vec(&mut self.stream, 8).as_slice()); // Not really useful for anything + + // Timestamp is stored as the number of seconds since 2000, January, 1st + let timestamp = self.stream.read_u32::<LittleEndian>().unwrap(); + self.timestamp = DateTime::from_utc( + // We add 946684800 to convert this to a more common Unix timestamp, + // which starts on 1970, January, 1st + NaiveDateTime::from_timestamp(i64::from(timestamp) + 946_684_800, 0), + Utc, + ); + } + + #[allow(unused)] + fn read_thumbnail(&mut self) -> Vec<Vec<u64>> { + self.stream.set_position(0xA0); + + let mut bitmap = vec![vec![0; 64]; 48]; + + for tile_index in 0..48 { + let tile_x = tile_index % 8 * 8; + let tile_y = tile_index / 8 * 8; + + for line in 0..8 { + // [This](https://linuxtut.com/en/ff1ac20b39137f1ccdb9/) can be used, + // but let's do it in Rust. + for pixel in (0..8).step_by(2) { + let byte = self.stream.read_uint::<LittleEndian>(1).unwrap(); + let x = tile_x + pixel; + let y = tile_y + line; + + bitmap[y][x] = byte & 0x0F; + bitmap[y][x + 1] = (byte >> 4) & 0x0F; + } + } + } + + bitmap + } + + fn read_animation_header(&mut self) { + self.stream.set_position(0x06A0); + + let table_size = self.stream.read_u16::<LittleEndian>().unwrap(); + let _unknown = self.stream.read_u16::<LittleEndian>().unwrap(); + let flags = self.stream.read_u32::<LittleEndian>().unwrap(); + + // Unpack animation flags + self.layer_1_visible = (flags >> 11) & 0x01 != 0; + self.layer_2_visible = (flags >> 10) & 0x01 != 0; + self.loop_ = (flags >> 1) & 0x01 != 0; + + // Read offset table into an array + let offset_table = { + let from_buffer = read_n_to_vec(&mut self.stream, table_size.into()); + let mut buffer = Vec::with_capacity((table_size / 4).into()); + + // I'm very glad that I got this working. It took way longer than it + // should have... + // + // 2022. 02. 25. 03:58., Fuwn + for index in (0..usize::from(table_size)).step_by(4) { + buffer.push( + (u32::from(from_buffer[index])) + | (u32::from(from_buffer[index + 1]) << 8) + | (u32::from(from_buffer[index + 2]) << 16) + | (u32::from(from_buffer[index + 3]) << 24), + ); + } + + buffer + }; + self.offset_table = offset_table + .into_iter() + .map(|m| m + 0x06A0 + 8 + u32::from(table_size)) + .collect(); + } + + fn read_sound_header(&mut self) { + // offset = frame data offset + frame data length + sound effect flags + // + // <https://github.com/pbsds/hatena-server/wiki/PPM-format#sound-data-section> + let mut offset = 0x06A0 + self.animation_data_size + u32::from(self.frame_count); + if offset % 2 != 0 { + // Account for multiple-of-four padding + offset += 4 - (offset % 4); + } + + self.stream.set_position(u64::from(offset)); + + let _bgm_size = self.stream.read_u32::<LittleEndian>().unwrap(); + let _se1_size = self.stream.read_u32::<LittleEndian>().unwrap(); + let _se2_size = self.stream.read_u32::<LittleEndian>().unwrap(); + let _se3_size = self.stream.read_u32::<LittleEndian>().unwrap(); + let frame_speed = self.stream.read_u8().unwrap(); + let bgm_speed = self.stream.read_u8().unwrap(); + + self.frame_speed = 8 - frame_speed; + self.bgm_speed = 8 - bgm_speed; + self.framerate = *FRAMERATES.get(&self.frame_speed).unwrap(); + self.bgm_framerate = *FRAMERATES.get(&self.bgm_speed).unwrap(); + } + + fn frame_is_new(&mut self, index: usize) -> bool { + self + .stream + .set_position(u64::from(*self.offset_table.get(index).unwrap())); + + self.stream.read_uint::<LittleEndian>(1).unwrap() >> 7 & 0x1 != 0 + } + + fn read_line_types(line_types: Vec<u8>) -> generator::Generator<'static, (), (usize, u8)> { + generator::Gn::new_scoped(move |mut s| { + for index in 0..192 { + let line_type = line_types.get(index / 4).unwrap() >> ((index % 4) * 2) & 0x03; + s.yield_((index, line_type)); + } + + generator::done!(); + }) + } + + fn read_frame(&mut self, index: usize) -> &Vec<Vec<Vec<u8>>> { + // Decode the previous frames if needed + if index != 0 && self.prev_frame_index != index - 1 && !self.frame_is_new(index) { + self.read_frame(index - 1); + } + + // Copy the current layer buffers to the previous ones + self.prev_layers = self.layers.clone(); + self.prev_frame_index = index; + // Clear the current layer buffers by resetting them to zero + self.layers.fill(vec![vec![0u8; 256]; 192]); + + // Seek to the frame offset so we can start reading + self + .stream + .set_position(u64::from(*self.offset_table.get(index).unwrap())); + + // Unpack frame header flags + let header = self.stream.read_uint::<LittleEndian>(1).unwrap(); + let is_new_frame = (header >> 7) & 0x01 != 0; + let is_translated = (header >> 5) & 0x03 != 0; + // If the frame is translated, we need to unpack the x and y values + let translation_x = if is_translated { + self.stream.read_i8().unwrap() + } else { + 0 + }; + let translation_y = if is_translated { + self.stream.read_i8().unwrap() + } else { + 0 + }; + // Read line encoding bytes + let line_types = vec![ + read_n_of_size_from_to_vec!(48, self, u8), + read_n_of_size_from_to_vec!(48, self, u8), + ]; + + // Loop through layers + #[allow(clippy::needless_range_loop)] + for layer in 0..2 { + let bitmap = &mut self.layers[layer]; + + for (line, line_type) in Self::read_line_types(line_types[layer].clone()) { + let mut pixel = 0; + + // No data stored for this line + if line_type == 0 { + // pass; + } else if line_type == 1 || line_type == 2 { + // Compressed line + // If `line_type == 2`, the line starts off with all the pixels set to one + if line_type == 2 { + for i in 0..256 { + bitmap[line][i] = 1; + } + } + + // Unpack chunk usage + let mut chunk_usage = self.stream.read_u32::<byteorder::BigEndian>().unwrap(); + + // Unpack pixel chunks + while pixel < 256 { + if chunk_usage & 0x8000_0000 == 0 { + pixel += 8; + } else { + let chunk = self.stream.read_uint::<LittleEndian>(1).unwrap(); + + for bit in 0..8 { + bitmap[line][pixel] = (chunk >> bit & 0x1) as u8; + pixel += 1; + } + } + + chunk_usage <<= 1; + } + // Raw line + } else if line_type == 3 { + // Unpack pixel chunks + while pixel < 256 { + let chunk = self.stream.read_uint::<LittleEndian>(1).unwrap(); + + for bit in 0..8 { + bitmap[line][pixel] = (chunk >> bit & 0x1) as u8; + pixel += 1; + } + } + } + } + } + + // Frame diffing + // + // If the current frame is based on the previous one, merge them by XOR-ing + // their pixels. This is a big performance bottleneck... + if !is_new_frame { + // Loop through lines + for y in 0..192 { + // Skip to next line if this one falls off the top edge of the screen + // if y - (translation_y as usize) < 0 { + // continue; + // } + // Stop once the bottom screen edge has been reached + if y - translation_y as usize >= 192 { + break; + } + + for x in 0..256 { + // Skip to the next pixel if this one falls off the left edge of the screen + // if x - (translation_x as usize) < 0 { + // continue; + // } + // Stop diffing this line once the right screen edge has been reached + if x - translation_x as usize >= 256 { + break; + } + + // Diff pixels with a binary XOR + self.layers[0][y][x] ^= + self.prev_layers[0][y - translation_y as usize][x - translation_x as usize]; + self.layers[1][y][x] ^= + self.prev_layers[1][y - translation_y as usize][x - translation_x as usize]; + } + } + } + + &self.layers + } + + pub fn get_frame_palette(&mut self, index: usize) -> Vec<(u8, u8, u8)> { + self.stream.set_position(self.offset_table[index].into()); + + let header = self.stream.read_uint::<LittleEndian>(1).unwrap(); + let paper_colour = header & 0x1; + let pen = vec![ + None, + Some(if paper_colour == 1 { *BLACK } else { *WHITE }), + Some(*RED), + Some(*BLUE), + ]; + + vec![ + if paper_colour == 1 { *WHITE } else { *BLACK }, + pen.get(((header >> 1) & 0x3) as usize).unwrap().unwrap(), // Layer one colour + pen.get(((header >> 3) & 0x3) as usize).unwrap().unwrap(), // Layer two colour + ] + } + + pub fn get_frame_pixels(&mut self, index: usize) -> Vec<Vec<u8>> { + let layers = self.read_frame(index); + let mut pixels = vec![vec![0u8; 256]; 192]; + + #[allow(clippy::needless_range_loop)] + for y in 0..192 { + for x in 0..256 { + if layers[0][y][x] > 0 { + pixels[y][x] = 1; + } else if layers[1][y][x] > 0 { + pixels[y][x] = 2; + } + } + } + + pixels + } + + pub const fn get_frame_count(&self) -> u16 { self.frame_count } + + pub const fn get_thumb_index(&self) -> u16 { self.thumb_index } + + pub const fn get_framerate(&self) -> f64 { self.framerate } + + pub fn dump_to_json(&self, filename: &str) { + let writer = std::io::BufWriter::new(fs::File::create(filename).unwrap()); + serde_json::to_writer_pretty( + writer, + &serde_json::json!({ + "animation_data_size": self.animation_data_size, + "sound_data_size": self.sound_data_size, + "frame_count": self.frame_count, + "lock": self.lock, + "thumb_index": self.thumb_index, + "root_author_name": self.root_author_name, + "parent_author_name": self.parent_author_name, + "current_author_name": self.current_author_name, + "root_author_id": self.root_author_id, + "parent_author_id": self.parent_author_id, + "current_author_id": self.current_author_id, + "parent_filename": self.parent_filename, + "current_filename": self.current_filename, + "partial_filename": self.partial_filename, + "timestamp": self.timestamp.to_string(), + "layer_1_visible": self.layer_1_visible, + "layer_2_visible": self.layer_2_visible, + "loop": self.loop_, + "frame_speed": self.frame_speed, + "bgm_speed": self.bgm_speed, + "framerate": self.framerate, + "bgm_framerate": self.bgm_framerate, + }), + ) + .unwrap(); + } +} +impl Default for PPMParser { + fn default() -> Self { + Self { + stream: Cursor::default(), + layers: Vec::new(), + prev_layers: Vec::new(), + prev_frame_index: Default::default(), + animation_data_size: Default::default(), + sound_data_size: Default::default(), + frame_count: Default::default(), + lock: Default::default(), + thumb_index: Default::default(), + root_author_name: String::default(), + parent_author_name: String::default(), + current_author_name: String::default(), + parent_author_id: String::default(), + current_author_id: String::default(), + parent_filename: String::default(), + current_filename: String::default(), + root_author_id: String::default(), + partial_filename: String::default(), + timestamp: DateTime::from_utc(NaiveDateTime::from_timestamp(0, 0), Utc), + layer_1_visible: Default::default(), + layer_2_visible: Default::default(), + loop_: Default::default(), + frame_speed: Default::default(), + bgm_speed: Default::default(), + framerate: Default::default(), + bgm_framerate: Default::default(), + offset_table: Vec::default(), + } + } +} |