fix: render genres via SlideRenderer, enable AVIF decoding, add poster fetch logging
Some checks failed
CI / Check / Test (push) Failing after 42s
Some checks failed
CI / Check / Test (push) Failing after 42s
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user