Skip to main content

semdiff_differ_audio/
report_html.rs

1use crate::{AudioData, AudioDiff, AudioDiffReporter, audio_extension};
2use askama::Template;
3use image::{ImageError, ImageFormat, Rgba, RgbaImage};
4use semdiff_core::fs::FileLeaf;
5use semdiff_core::{DetailReporter, MayUnsupported};
6use semdiff_output::html::{HtmlReport, HtmlReportError};
7use std::io::Write;
8use thiserror::Error;
9
10const COMPARES_NAME: &str = "audio";
11
12#[derive(Debug, Error)]
13pub enum AudioDiffReportError {
14    #[error("html report error: {0}")]
15    HtmlReport(#[from] HtmlReportError),
16    #[error("image encode error: {0}")]
17    ImageEncode(#[from] ImageError),
18}
19
20#[derive(Template)]
21#[template(path = "audio_preview.html")]
22struct AudioPreviewTemplate {
23    body: AudioPreviewBody,
24}
25
26#[derive(Clone)]
27struct AudioPreviewImage {
28    src: String,
29    label: String,
30    kind: String,
31    width: u32,
32    height: u32,
33}
34
35struct PreviewImageFile {
36    path: String,
37    width: u32,
38    height: u32,
39}
40
41enum AudioPreviewBody {
42    Modified {
43        images: Vec<AudioPreviewImage>,
44        audio_src: String,
45    },
46    Single {
47        images: Vec<AudioPreviewImage>,
48        audio_src: String,
49    },
50}
51
52#[derive(Template)]
53#[template(path = "audio_detail.html")]
54struct AudioDetailTemplate {
55    detail: AudioDetailBody,
56}
57
58#[derive(Clone)]
59struct AudioDetailImage {
60    uri: String,
61    width: u32,
62    height: u32,
63}
64
65#[derive(Clone)]
66struct AudioDetailData {
67    label: String,
68    audio_src: String,
69    waveforms: Vec<AudioDetailImage>,
70    spectrograms: Vec<AudioDetailImage>,
71    sample_rate: u32,
72    channels: u16,
73    duration_seconds: f32,
74}
75
76enum AudioDetailBody {
77    Diff {
78        expected: AudioDetailData,
79        actual: AudioDetailData,
80        spectrogram_diff: Vec<AudioDetailImage>,
81    },
82    Single {
83        data: AudioDetailData,
84    },
85}
86
87impl DetailReporter<AudioDiff, FileLeaf, HtmlReport> for AudioDiffReporter {
88    type Error = AudioDiffReportError;
89
90    fn report_unchanged(
91        &self,
92        name: &str,
93        diff: &AudioDiff,
94        reporter: &HtmlReport,
95    ) -> Result<MayUnsupported<()>, Self::Error> {
96        let expected = diff.expected();
97        let Some(extension) = audio_extension(expected.mime()) else {
98            return Ok(MayUnsupported::Unsupported);
99        };
100        let audio_file = write_audio(reporter, name, "same", extension, expected.content())?;
101        let waveform_files = write_channel_images(reporter, name, "same_waveform", expected.waveform())?;
102        let spectrogram_files = write_channel_images(reporter, name, "same_spectrogram", expected.spectrogram())?;
103        let detail_data = build_detail_data("same", expected, &audio_file, &waveform_files, &spectrogram_files);
104        let preview_image = write_preview_image(reporter, name, "preview_waveform", expected.waveform())?;
105        let preview_images = preview_image
106            .as_ref()
107            .map(|file| build_preview_images(reporter, std::slice::from_ref(file), "waveform"))
108            .unwrap_or_default();
109        let preview_html = AudioPreviewTemplate {
110            body: AudioPreviewBody::Single {
111                images: preview_images,
112                audio_src: reporter.detail_asset_path(&audio_file),
113            },
114        };
115        let detail_html = AudioDetailTemplate {
116            detail: AudioDetailBody::Single { data: detail_data },
117        };
118        reporter.record_unchanged(name, COMPARES_NAME, preview_html, detail_html)?;
119        Ok(MayUnsupported::Ok(()))
120    }
121
122    fn report_modified(
123        &self,
124        name: &str,
125        diff: &AudioDiff,
126        reporter: &HtmlReport,
127    ) -> Result<MayUnsupported<()>, Self::Error> {
128        let expected = diff.expected();
129        let actual = diff.actual();
130        let Some(expected_ext) = audio_extension(expected.mime()) else {
131            return Ok(MayUnsupported::Unsupported);
132        };
133        let Some(actual_ext) = audio_extension(actual.mime()) else {
134            return Ok(MayUnsupported::Unsupported);
135        };
136        let expected_audio = write_audio(reporter, name, "expected", expected_ext, expected.content())?;
137        let actual_audio = write_audio(reporter, name, "actual", actual_ext, actual.content())?;
138        let expected_waveforms = write_channel_images(reporter, name, "expected_waveform", expected.waveform())?;
139        let actual_waveforms = write_channel_images(reporter, name, "actual_waveform", actual.waveform())?;
140        let expected_spectrograms =
141            write_channel_images(reporter, name, "expected_spectrogram", expected.spectrogram())?;
142        let actual_spectrograms = write_channel_images(reporter, name, "actual_spectrogram", actual.spectrogram())?;
143        let spectrogram_diff_detail = if let Some(detail) = diff.diff_detail() {
144            let spectrogram_diffs =
145                write_channel_images(reporter, name, "spectrogram_diff", detail.spectrogram_diff())?;
146
147            build_detail_images(&spectrogram_diffs, detail.spectrogram_diff())
148        } else {
149            Vec::new()
150        };
151
152        let (preview_image, preview_label) = if let Some(detail) = diff.diff_detail() {
153            (
154                write_preview_image(reporter, name, "preview_spectrogram_diff", detail.spectrogram_diff())?,
155                "spectrogram diff",
156            )
157        } else {
158            (
159                write_preview_image(reporter, name, "preview_waveform", actual.waveform())?,
160                "waveform",
161            )
162        };
163        let preview_images = preview_image
164            .as_ref()
165            .map(|file| build_preview_images(reporter, std::slice::from_ref(file), preview_label))
166            .unwrap_or_default();
167        let preview_html = AudioPreviewTemplate {
168            body: AudioPreviewBody::Modified {
169                images: preview_images,
170                audio_src: reporter.detail_asset_path(&actual_audio),
171            },
172        };
173        let detail_html = AudioDetailTemplate {
174            detail: AudioDetailBody::Diff {
175                expected: build_detail_data(
176                    "expected",
177                    expected,
178                    &expected_audio,
179                    &expected_waveforms,
180                    &expected_spectrograms,
181                ),
182                actual: build_detail_data("actual", actual, &actual_audio, &actual_waveforms, &actual_spectrograms),
183                spectrogram_diff: spectrogram_diff_detail,
184            },
185        };
186        reporter.record_modified(name, COMPARES_NAME, preview_html, detail_html)?;
187        Ok(MayUnsupported::Ok(()))
188    }
189
190    fn report_added(
191        &self,
192        name: &str,
193        data: &FileLeaf,
194        reporter: &HtmlReport,
195    ) -> Result<MayUnsupported<()>, Self::Error> {
196        let Some(extension) = audio_extension(&data.kind) else {
197            return Ok(MayUnsupported::Unsupported);
198        };
199        let Ok(audio_data) = self.build_audio_data(data.kind.clone(), data.content.clone()) else {
200            return Ok(MayUnsupported::Unsupported);
201        };
202        let audio_file = write_audio(reporter, name, "added", extension, audio_data.content())?;
203        let waveform_files = write_channel_images(reporter, name, "added_waveform", audio_data.waveform())?;
204        let spectrogram_files = write_channel_images(reporter, name, "added_spectrogram", audio_data.spectrogram())?;
205        let preview_image = write_preview_image(reporter, name, "preview_waveform", audio_data.waveform())?;
206        let preview_images = preview_image
207            .as_ref()
208            .map(|file| build_preview_images(reporter, std::slice::from_ref(file), "waveform"))
209            .unwrap_or_default();
210        let preview_html = AudioPreviewTemplate {
211            body: AudioPreviewBody::Single {
212                images: preview_images,
213                audio_src: reporter.detail_asset_path(&audio_file),
214            },
215        };
216        let detail_html = AudioDetailTemplate {
217            detail: AudioDetailBody::Single {
218                data: build_detail_data("added", &audio_data, &audio_file, &waveform_files, &spectrogram_files),
219            },
220        };
221        reporter.record_added(name, COMPARES_NAME, preview_html, detail_html)?;
222        Ok(MayUnsupported::Ok(()))
223    }
224
225    fn report_deleted(
226        &self,
227        name: &str,
228        data: &FileLeaf,
229        reporter: &HtmlReport,
230    ) -> Result<MayUnsupported<()>, Self::Error> {
231        let Some(extension) = audio_extension(&data.kind) else {
232            return Ok(MayUnsupported::Unsupported);
233        };
234        let Ok(audio_data) = self.build_audio_data(data.kind.clone(), data.content.clone()) else {
235            return Ok(MayUnsupported::Unsupported);
236        };
237        let audio_file = write_audio(reporter, name, "deleted", extension, audio_data.content())?;
238        let waveform_files = write_channel_images(reporter, name, "deleted_waveform", audio_data.waveform())?;
239        let spectrogram_files = write_channel_images(reporter, name, "deleted_spectrogram", audio_data.spectrogram())?;
240        let preview_image = write_preview_image(reporter, name, "preview_waveform", audio_data.waveform())?;
241        let preview_images = preview_image
242            .as_ref()
243            .map(|file| build_preview_images(reporter, std::slice::from_ref(file), "waveform"))
244            .unwrap_or_default();
245        let preview_html = AudioPreviewTemplate {
246            body: AudioPreviewBody::Single {
247                images: preview_images,
248                audio_src: reporter.detail_asset_path(&audio_file),
249            },
250        };
251        let detail_html = AudioDetailTemplate {
252            detail: AudioDetailBody::Single {
253                data: build_detail_data("deleted", &audio_data, &audio_file, &waveform_files, &spectrogram_files),
254            },
255        };
256        reporter.record_deleted(name, COMPARES_NAME, preview_html, detail_html)?;
257        Ok(MayUnsupported::Ok(()))
258    }
259}
260
261fn build_detail_data(
262    label: &str,
263    data: &AudioData,
264    audio_uri: &str,
265    waveform_uris: &[String],
266    spectrogram_uris: &[String],
267) -> AudioDetailData {
268    AudioDetailData {
269        label: label.to_string(),
270        audio_src: audio_uri.to_string(),
271        waveforms: build_detail_images(waveform_uris, data.waveform()),
272        spectrograms: build_detail_images(spectrogram_uris, data.spectrogram()),
273        sample_rate: data.sample_rate(),
274        channels: data.channels(),
275        duration_seconds: data.duration_seconds(),
276    }
277}
278
279fn build_preview_images(
280    reporter: &HtmlReport,
281    image_files: &[PreviewImageFile],
282    label_prefix: &str,
283) -> Vec<AudioPreviewImage> {
284    let kind = label_prefix.replace(' ', "-");
285    image_files
286        .iter()
287        .enumerate()
288        .map(|(index, file)| AudioPreviewImage {
289            src: reporter.detail_asset_path(&file.path),
290            label: format!("{label_prefix} ch{}", index + 1),
291            kind: kind.clone(),
292            width: file.width,
293            height: file.height,
294        })
295        .collect()
296}
297
298fn write_preview_image(
299    reporter: &HtmlReport,
300    name: &str,
301    label: &str,
302    images: &[RgbaImage],
303) -> Result<Option<PreviewImageFile>, HtmlReportError> {
304    let Some(merged) = merge_channel_images(images) else {
305        return Ok(None);
306    };
307    let width = merged.width();
308    let height = merged.height();
309    let path = write_image(reporter, name, label, &merged)?;
310    Ok(Some(PreviewImageFile { path, width, height }))
311}
312
313fn merge_channel_images(images: &[RgbaImage]) -> Option<RgbaImage> {
314    let first = images.first()?;
315    let width = first.width();
316    let height = first.height();
317    let mut merged = RgbaImage::from_pixel(width, height, Rgba([255, 255, 255, 0]));
318    for image in images {
319        for y in 0..height {
320            for x in 0..width {
321                let pixel = image.get_pixel(x, y);
322                let current = merged.get_pixel(x, y);
323                merged.put_pixel(
324                    x,
325                    y,
326                    Rgba([
327                        current[0].max(pixel[0]),
328                        current[1].max(pixel[1]),
329                        current[2].max(pixel[2]),
330                        current[3].max(pixel[3]),
331                    ]),
332                );
333            }
334        }
335    }
336    Some(merged)
337}
338
339fn build_detail_images(image_files: &[String], images: &[RgbaImage]) -> Vec<AudioDetailImage> {
340    let count = image_files.len().min(images.len());
341    (0..count)
342        .map(|index| AudioDetailImage {
343            uri: image_files[index].clone(),
344            width: images[index].width(),
345            height: images[index].height(),
346        })
347        .collect()
348}
349
350fn write_channel_images(
351    reporter: &HtmlReport,
352    name: &str,
353    label_prefix: &str,
354    images: &[RgbaImage],
355) -> Result<Vec<String>, HtmlReportError> {
356    let mut files = Vec::with_capacity(images.len());
357    for (index, image) in images.iter().enumerate() {
358        let label = format!("{label_prefix}_ch{}", index + 1);
359        files.push(write_image(reporter, name, &label, image)?);
360    }
361    Ok(files)
362}
363
364fn write_image(reporter: &HtmlReport, name: &str, label: &str, image: &RgbaImage) -> Result<String, HtmlReportError> {
365    reporter.write_detail_asset(name, label, "png", |w| match image.write_to(w, ImageFormat::Png) {
366        Ok(()) => Ok(()),
367        Err(ImageError::IoError(err)) => Err(err.into()),
368        Err(err) => panic!("Unexpected error writing audio image: {}", err),
369    })
370}
371
372fn write_audio(
373    reporter: &HtmlReport,
374    name: &str,
375    label: &str,
376    extension: &str,
377    content: &[u8],
378) -> Result<String, HtmlReportError> {
379    reporter.write_detail_asset(name, label, extension, |w| {
380        w.write_all(content)?;
381        Ok(())
382    })
383}