Skip to main content

rassa_check/
lib.rs

1use std::path::Path;
2
3use image::{ColorType, ImageEncoder, codecs};
4use rassa_core::{Point, RassaError, RassaResult, Rect, RendererConfig, Size};
5use rassa_fonts::FontconfigProvider;
6use rassa_parse::parse_script_text;
7use rassa_render::RenderEngine;
8
9pub const DEFAULT_SCRIPT: &str = r#"[Script Info]
10PlayResX: 640
11PlayResY: 360
12
13[V4+ Styles]
14Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
15Style: Default,sans,48,&H0000FF00,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,1,5,10,10,10,1
16
17[Events]
18Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
19Dialogue: 0,0:00:00.00,0:00:05.00,Default,,0000,0000,0000,,Rassa render smoke
20"#;
21
22pub const DEFAULT_BACKGROUND_RGB: [u8; 3] = [0x2B, 0xA4, 0xEF];
23
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub struct RenderReport {
26    pub width: i32,
27    pub height: i32,
28    pub plane_count: usize,
29    pub lit_pixels: usize,
30    pub bounds: Option<Rect>,
31    pub pixels: Vec<u8>,
32    pub rgb_pixels: Vec<u8>,
33}
34
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub enum ImageFormat {
37    Pgm,
38    Png,
39    Jpeg,
40}
41
42impl ImageFormat {
43    pub fn from_path(path: impl AsRef<Path>) -> Option<Self> {
44        match path
45            .as_ref()
46            .extension()?
47            .to_str()?
48            .to_ascii_lowercase()
49            .as_str()
50        {
51            "pgm" => Some(Self::Pgm),
52            "png" => Some(Self::Png),
53            "jpg" | "jpeg" => Some(Self::Jpeg),
54            _ => None,
55        }
56    }
57
58    pub fn parse(value: &str) -> Option<Self> {
59        match value.to_ascii_lowercase().as_str() {
60            "pgm" => Some(Self::Pgm),
61            "png" => Some(Self::Png),
62            "jpg" | "jpeg" => Some(Self::Jpeg),
63            _ => None,
64        }
65    }
66}
67
68pub fn render_script(
69    script: &str,
70    time_ms: i64,
71    width: i32,
72    height: i32,
73) -> RassaResult<RenderReport> {
74    if width <= 0 || height <= 0 {
75        return Err(RassaError::new("frame width and height must be positive"));
76    }
77
78    let track = parse_script_text(script)?;
79    let engine = RenderEngine::new();
80    let provider = FontconfigProvider::new();
81    let config = RendererConfig {
82        frame: Size { width, height },
83        storage: Size { width, height },
84        pixel_aspect: 1.0,
85        font_scale: 1.0,
86        line_spacing: 0.0,
87        line_position: 0.0,
88        hinting: rassa_core::ass::Hinting::None,
89        shaping: rassa_core::ass::ShapingLevel::Complex,
90        ..Default::default()
91    };
92    let planes = engine.render_frame_with_provider_and_config(&track, &provider, time_ms, &config);
93    let mut report = RenderReport {
94        width,
95        height,
96        plane_count: planes.len(),
97        lit_pixels: 0,
98        bounds: None,
99        pixels: vec![0; width as usize * height as usize],
100        rgb_pixels: vec![0; width as usize * height as usize * 3],
101    };
102    for pixel in report.rgb_pixels.chunks_exact_mut(3) {
103        pixel.copy_from_slice(&DEFAULT_BACKGROUND_RGB);
104    }
105
106    for plane in planes {
107        let stride = plane.stride.max(0) as usize;
108        let plane_width = plane.size.width.max(0) as usize;
109        let plane_height = plane.size.height.max(0) as usize;
110        if stride == 0 || plane_width == 0 || plane_height == 0 {
111            continue;
112        }
113
114        for row in 0..plane_height {
115            for column in 0..plane_width {
116                let source_index = row * stride + column;
117                let Some(&coverage) = plane.bitmap.get(source_index) else {
118                    continue;
119                };
120                if coverage == 0 {
121                    continue;
122                }
123
124                let destination = Point {
125                    x: plane.destination.x + column as i32,
126                    y: plane.destination.y + row as i32,
127                };
128                if destination.x < 0
129                    || destination.y < 0
130                    || destination.x >= width
131                    || destination.y >= height
132                {
133                    continue;
134                }
135
136                let target_index = destination.y as usize * width as usize + destination.x as usize;
137                report.pixels[target_index] = report.pixels[target_index].max(coverage);
138                composite_plane_pixel(
139                    &mut report.rgb_pixels,
140                    target_index,
141                    plane.color.0,
142                    coverage,
143                );
144                report.bounds = Some(match report.bounds {
145                    Some(bounds) => Rect {
146                        x_min: bounds.x_min.min(destination.x),
147                        y_min: bounds.y_min.min(destination.y),
148                        x_max: bounds.x_max.max(destination.x + 1),
149                        y_max: bounds.y_max.max(destination.y + 1),
150                    },
151                    None => Rect {
152                        x_min: destination.x,
153                        y_min: destination.y,
154                        x_max: destination.x + 1,
155                        y_max: destination.y + 1,
156                    },
157                });
158            }
159        }
160    }
161
162    report.lit_pixels = report.pixels.iter().filter(|pixel| **pixel > 0).count();
163    if report.plane_count == 0 || report.lit_pixels == 0 {
164        return Err(RassaError::new("render produced no visible pixels"));
165    }
166
167    Ok(report)
168}
169
170fn composite_plane_pixel(rgb_pixels: &mut [u8], target_index: usize, color: u32, coverage: u8) {
171    let inverse_alpha = (color & 0xff) as u8;
172    if coverage == 0 || inverse_alpha == 255 {
173        return;
174    }
175
176    let source = [(color >> 24) as u8, (color >> 16) as u8, (color >> 8) as u8];
177    let offset = target_index * 3;
178    let Some(destination) = rgb_pixels.get_mut(offset..offset + 3) else {
179        return;
180    };
181    for channel in 0..3 {
182        destination[channel] = blend_channel(
183            source[channel],
184            destination[channel],
185            coverage,
186            inverse_alpha,
187        );
188    }
189}
190
191fn blend_channel(source: u8, destination: u8, coverage: u8, inverse_alpha: u8) -> u8 {
192    let k = i32::from(coverage) * 129 * i32::from(255 - inverse_alpha);
193    let blended = i32::from(destination)
194        - (((i32::from(destination) - i32::from(source)) * k + (1 << 22)) >> 23);
195    blended.clamp(0, 255) as u8
196}
197
198pub fn render_report_to_pgm(report: &RenderReport) -> Vec<u8> {
199    let mut output = format!("P5\n{} {}\n255\n", report.width, report.height).into_bytes();
200    output.extend_from_slice(&report.pixels);
201    output
202}
203
204pub fn render_report_to_image_bytes(
205    report: &RenderReport,
206    format: ImageFormat,
207) -> RassaResult<Vec<u8>> {
208    match format {
209        ImageFormat::Pgm => Ok(render_report_to_pgm(report)),
210        ImageFormat::Png => encode_png(report),
211        ImageFormat::Jpeg => encode_jpeg(report),
212    }
213}
214
215fn encode_png(report: &RenderReport) -> RassaResult<Vec<u8>> {
216    let mut output = Vec::new();
217    codecs::png::PngEncoder::new(&mut output)
218        .write_image(
219            &report.rgb_pixels,
220            report.width as u32,
221            report.height as u32,
222            ColorType::Rgb8.into(),
223        )
224        .map_err(|error| RassaError::new(format!("failed to encode PNG: {error}")))?;
225    Ok(output)
226}
227
228fn encode_jpeg(report: &RenderReport) -> RassaResult<Vec<u8>> {
229    let mut output = Vec::new();
230    codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 92)
231        .encode(
232            &report.rgb_pixels,
233            report.width as u32,
234            report.height as u32,
235            ColorType::Rgb8.into(),
236        )
237        .map_err(|error| RassaError::new(format!("failed to encode JPEG: {error}")))?;
238    Ok(output)
239}
240
241pub fn render_script_file_to_pgm(
242    input: impl AsRef<Path>,
243    output: impl AsRef<Path>,
244    time_ms: i64,
245    width: i32,
246    height: i32,
247) -> RassaResult<RenderReport> {
248    render_script_file_to_image(input, output, time_ms, width, height, ImageFormat::Pgm)
249}
250
251pub fn render_script_file_to_image(
252    input: impl AsRef<Path>,
253    output: impl AsRef<Path>,
254    time_ms: i64,
255    width: i32,
256    height: i32,
257    format: ImageFormat,
258) -> RassaResult<RenderReport> {
259    let script = std::fs::read_to_string(input.as_ref()).map_err(|error| {
260        RassaError::new(format!(
261            "failed to read {}: {error}",
262            input.as_ref().display()
263        ))
264    })?;
265    let report = render_script(&script, time_ms, width, height)?;
266    std::fs::write(
267        output.as_ref(),
268        render_report_to_image_bytes(&report, format)?,
269    )
270    .map_err(|error| {
271        RassaError::new(format!(
272            "failed to write {}: {error}",
273            output.as_ref().display()
274        ))
275    })?;
276    Ok(report)
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    const SAMPLE_ASS: &str = r#"[Script Info]
284PlayResX: 320
285PlayResY: 180
286
287[V4+ Styles]
288Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
289Style: Default,sans,32,&H0000FF00,&H0000FFFF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,1,0,5,10,10,10,1
290
291[Events]
292Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
293Dialogue: 0,0:00:00.00,0:00:02.00,Default,,0000,0000,0000,,Rassa smoke
294"#;
295
296    #[test]
297    fn render_report_confirms_non_empty_frame() {
298        let report = render_script(SAMPLE_ASS, 500, 320, 180).expect("sample renders");
299        assert!(report.plane_count > 0);
300        assert!(report.lit_pixels > 0);
301        assert!(report.bounds.is_some());
302    }
303
304    #[test]
305    fn pgm_output_has_valid_header_and_pixels() {
306        let report = render_script(SAMPLE_ASS, 500, 320, 180).expect("sample renders");
307        let pgm = render_report_to_pgm(&report);
308        assert!(pgm.starts_with(b"P5\n320 180\n255\n"));
309        let header_len = b"P5\n320 180\n255\n".len();
310        assert!(pgm[header_len..].iter().any(|byte| *byte > 0));
311    }
312
313    #[test]
314    fn png_output_has_valid_signature() {
315        let report = render_script(SAMPLE_ASS, 500, 320, 180).expect("sample renders");
316        let png = render_report_to_image_bytes(&report, ImageFormat::Png).expect("png encodes");
317        assert!(png.starts_with(b"\x89PNG\r\n\x1a\n"));
318    }
319
320    #[test]
321    fn render_report_composites_ass_colors_over_libass_blue_background() {
322        let report = render_script(SAMPLE_ASS, 500, 320, 180).expect("sample renders");
323        assert_eq!(&report.rgb_pixels[..3], &[0x2B, 0xA4, 0xEF]);
324        assert!(
325            report
326                .rgb_pixels
327                .chunks_exact(3)
328                .any(|pixel| pixel[1] > pixel[0] && pixel[1] > pixel[2])
329        );
330        assert!(
331            report
332                .rgb_pixels
333                .chunks_exact(3)
334                .any(|pixel| pixel[0] < 8 && pixel[1] < 8 && pixel[2] < 8)
335        );
336    }
337
338    #[test]
339    fn blend_channel_matches_libass_compare_c_rounding() {
340        assert_eq!(blend_channel(0, 239, 128, 0), 119);
341        assert_eq!(blend_channel(0, 164, 128, 0), 82);
342        assert_eq!(blend_channel(0, 43, 128, 0), 21);
343        assert_eq!(blend_channel(0, 239, 255, 0), 0);
344        assert_eq!(blend_channel(0, 239, 128, 128), 179);
345    }
346
347    #[test]
348    fn jpeg_output_has_valid_signature() {
349        let report = render_script(SAMPLE_ASS, 500, 320, 180).expect("sample renders");
350        let jpeg = render_report_to_image_bytes(&report, ImageFormat::Jpeg).expect("jpeg encodes");
351        assert!(jpeg.starts_with(&[0xFF, 0xD8, 0xFF]));
352        assert!(jpeg.ends_with(&[0xFF, 0xD9]));
353    }
354}