fix: render genres via SlideRenderer, enable AVIF decoding, add poster fetch logging
Some checks failed
CI / Check / Test (push) Failing after 42s

This commit is contained in:
2026-06-03 00:19:18 +02:00
parent 7155bea78e
commit 86639853d2
5 changed files with 60 additions and 68 deletions

View File

@@ -7,9 +7,8 @@ edition = "2024"
domain = { workspace = true } domain = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
image = "0.25" image = { version = "0.25", features = ["avif"] }
imageproc = "0.25" imageproc = "0.25"
ab_glyph = "0.2" ab_glyph = "0.2"
plotters = { version = "0.3", default-features = false, features = ["bitmap_backend", "bitmap_encoder", "ab_glyph"] }
tokio = { workspace = true, features = ["process"] } tokio = { workspace = true, features = ["process"] }
tempfile = "3" tempfile = "3"

View File

@@ -1,62 +0,0 @@
use domain::errors::DomainError;
use domain::models::wrapup::WrapUpReport;
use plotters::prelude::*;
pub fn render_genre_chart(
report: &WrapUpReport,
width: u32,
height: u32,
) -> Result<Vec<u8>, DomainError> {
let mut buf = vec![0u8; (width * height * 3) as usize];
{
let root = BitMapBackend::with_buffer(&mut buf, (width, height)).into_drawing_area();
root.fill(&RGBColor(26, 26, 36))
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let max_count = report.top_genres.iter().map(|g| g.count).max().unwrap_or(1);
let mut chart = ChartBuilder::on(&root)
.margin(40)
.build_cartesian_2d(
0u32..max_count + 1,
(0..report.top_genres.len() as i32).into_segmented(),
)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
chart
.configure_mesh()
.disable_mesh()
.disable_x_axis()
.disable_y_axis()
.draw()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
chart
.draw_series(report.top_genres.iter().enumerate().map(|(i, g)| {
let color = RGBColor(229, 192, 52);
Rectangle::new(
[
(0, SegmentValue::Exact(i as i32)),
(g.count, SegmentValue::Exact(i as i32 + 1)),
],
color.filled(),
)
}))
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
root.present()
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
}
let img = image::RgbImage::from_raw(width, height, buf)
.ok_or_else(|| DomainError::InfrastructureError("invalid image buffer".into()))?;
let rgba = image::DynamicImage::ImageRgb8(img).to_rgba8();
let mut png_buf = Vec::new();
rgba.write_to(
&mut std::io::Cursor::new(&mut png_buf),
image::ImageFormat::Png,
)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
Ok(png_buf)
}

View File

@@ -1,4 +1,3 @@
mod charts;
mod ffmpeg; mod ffmpeg;
mod slides; mod slides;
@@ -40,11 +39,13 @@ impl WrapUpVideoRenderer for FfmpegWrapUpRenderer {
slide_pngs.push(renderer.render_actors(report, width, height)?); slide_pngs.push(renderer.render_actors(report, width, height)?);
} }
if !report.top_genres.is_empty() { if !report.top_genres.is_empty() {
slide_pngs.push(charts::render_genre_chart(report, width, height)?); slide_pngs.push(renderer.render_genres(report, width, height)?);
} }
slide_pngs.push(renderer.render_highlights(report, width, height)?); slide_pngs.push(renderer.render_highlights(report, width, height)?);
if !poster_images.is_empty() { if !poster_images.is_empty() {
slide_pngs.push(renderer.render_mosaic(&poster_images, width, height)?); slide_pngs.push(renderer.render_mosaic(&poster_images, width, height)?);
} else {
tracing::warn!("no poster images resolved, skipping mosaic slide");
} }
// 2. Stitch into video // 2. Stitch into video

View File

@@ -237,6 +237,58 @@ impl SlideRenderer {
to_png(&img) to_png(&img)
} }
pub fn render_genres(
&self,
report: &WrapUpReport,
w: u32,
h: u32,
) -> Result<Vec<u8>, DomainError> {
let mut img = fill(w, h);
self.draw_centered(&mut img, "Genre Breakdown", (h / 8) as i32, 56.0, GOLD);
let detail = format!("{} genres explored", report.genre_diversity);
self.draw_centered(&mut img, &detail, (h / 8) as i32 + 64, 28.0, DIM);
let margin = 80i32;
let bar_area_w = (w as i32 - margin * 2 - 200) as u32;
let start_y = (h / 4) as i32;
let max_count = report.top_genres.first().map(|g| g.count).unwrap_or(1).max(1);
for (i, g) in report.top_genres.iter().take(8).enumerate() {
let y = start_y + (i as i32) * 80;
self.draw_left(&mut img, &g.genre, margin, y, 30.0, WHITE);
let count_str = format!("{}", g.count);
self.draw_left(&mut img, &count_str, w as i32 - margin - 40, y, 30.0, DIM);
let bar_y = y + 38;
let bar_w = (g.count as f64 / max_count as f64 * bar_area_w as f64) as u32;
draw_filled_rect_mut(
&mut img,
Rect::at(margin, bar_y).of_size(bar_area_w, 12),
BAR_BG,
);
if bar_w > 0 {
draw_filled_rect_mut(
&mut img,
Rect::at(margin, bar_y).of_size(bar_w, 12),
GOLD,
);
}
}
if let Some(ref best) = report.highest_rated_genre {
let text = format!("Highest rated: {best}");
self.draw_centered(&mut img, &text, h as i32 - 180, 28.0, WHITE);
}
if let Some(ref worst) = report.lowest_rated_genre {
let text = format!("Lowest rated: {worst}");
self.draw_centered(&mut img, &text, h as i32 - 140, 28.0, DIM);
}
self.stamp_logo(&mut img);
to_png(&img)
}
pub fn render_highlights( pub fn render_highlights(
&self, &self,
report: &WrapUpReport, report: &WrapUpReport,

View File

@@ -88,9 +88,11 @@ pub async fn execute(
async fn resolve_poster_images(ctx: &AppContext, report: &WrapUpReport) -> Vec<(String, Vec<u8>)> { async fn resolve_poster_images(ctx: &AppContext, report: &WrapUpReport) -> Vec<(String, Vec<u8>)> {
let mut images = Vec::new(); let mut images = Vec::new();
for path in report.poster_paths.iter().take(20) { for path in report.poster_paths.iter().take(20) {
if let Ok(bytes) = ctx.services.image_storage.get(path).await { match ctx.services.image_storage.get(path).await {
images.push((path.clone(), bytes)); Ok(bytes) => images.push((path.clone(), bytes)),
Err(e) => tracing::debug!("poster fetch skipped for {path}: {e}"),
} }
} }
tracing::info!("resolved {}/{} poster images for video", images.len(), report.poster_paths.len());
images images
} }