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}