From d45d8aa913a34be4cb4ab7f87f311ddad0d326ad Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 2 Jun 2026 22:31:45 +0200 Subject: [PATCH] feat: video renderer adapter w/ slides + charts + ffmpeg --- Cargo.lock | 518 +++++++++++++++++- Cargo.toml | 2 + crates/adapters/wrapup-renderer/Cargo.toml | 15 + crates/adapters/wrapup-renderer/src/charts.rs | 71 +++ crates/adapters/wrapup-renderer/src/ffmpeg.rs | 56 ++ crates/adapters/wrapup-renderer/src/lib.rs | 49 ++ crates/adapters/wrapup-renderer/src/slides.rs | 97 ++++ crates/domain/src/ports.rs | 20 + 8 files changed, 820 insertions(+), 8 deletions(-) create mode 100644 crates/adapters/wrapup-renderer/Cargo.toml create mode 100644 crates/adapters/wrapup-renderer/src/charts.rs create mode 100644 crates/adapters/wrapup-renderer/src/ffmpeg.rs create mode 100644 crates/adapters/wrapup-renderer/src/lib.rs create mode 100644 crates/adapters/wrapup-renderer/src/slides.rs diff --git a/Cargo.lock b/Cargo.lock index 20d9b32..ce7ed64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,22 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser 0.25.1", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + [[package]] name = "activitypub" version = "0.1.0" @@ -246,6 +262,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + [[package]] name = "aligned-vec" version = "0.6.4" @@ -319,12 +344,22 @@ dependencies = [ "futures", "hex", "rand 0.9.4", + "serde_json", "sha2", "tokio", "tracing", "uuid", ] +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -363,6 +398,15 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "askama" version = "0.16.0" @@ -649,6 +693,26 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + [[package]] name = "av1-grain" version = "0.2.5" @@ -815,6 +879,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -836,6 +906,15 @@ version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + [[package]] name = "blake2" version = "0.10.6" @@ -882,6 +961,12 @@ version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + [[package]] name = "bumpalo" version = "3.20.3" @@ -1057,6 +1142,12 @@ dependencies = [ "encoding_rs", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.7" @@ -1182,6 +1273,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + [[package]] name = "crossbeam-epoch" version = "0.9.18" @@ -1233,6 +1334,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -1580,6 +1687,7 @@ dependencies = [ "chrono", "email_address", "futures", + "serde", "thiserror 2.0.18", "uuid", ] @@ -1827,6 +1935,21 @@ dependencies = [ "uuid", ] +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fancy-regex" version = "0.11.0" @@ -1849,6 +1972,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -2137,6 +2266,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "glob" version = "0.3.3" @@ -2182,6 +2321,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -2576,6 +2726,20 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "jpeg-decoder", + "num-traits", + "png 0.17.16", +] + [[package]] name = "image" version = "0.25.10" @@ -2584,10 +2748,18 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "exr", + "gif", "image-webp", "moxcms", "num-traits", - "png", + "png 0.18.1", + "qoi", + "ravif 0.13.0", + "rayon", + "rgb", + "tiff", "zune-core", "zune-jpeg", ] @@ -2599,10 +2771,10 @@ dependencies = [ "anyhow", "async-trait", "domain", - "image", + "image 0.25.10", "image-storage", "object_store", - "ravif", + "ravif 0.11.20", "tokio", "tracing", "uuid", @@ -2633,6 +2805,24 @@ dependencies = [ "quick-error", ] +[[package]] +name = "imageproc" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602b4e8a4cc3e98372b766cd184ab532999bc0e839b7469e759511ccabc65d77" +dependencies = [ + "ab_glyph", + "approx", + "getrandom 0.2.17", + "image 0.25.10", + "itertools 0.12.1", + "nalgebra", + "num", + "rand 0.8.6", + "rand_distr", + "rayon", +] + [[package]] name = "imgref" version = "1.12.1" @@ -2826,6 +3016,12 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" + [[package]] name = "js-sys" version = "0.3.99" @@ -2923,6 +3119,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libc" version = "0.2.186" @@ -3109,6 +3311,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -3116,6 +3328,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" dependencies = [ "cfg-if", + "rayon", ] [[package]] @@ -3251,6 +3464,21 @@ dependencies = [ "version_check", ] +[[package]] +name = "nalgebra" +version = "0.32.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4" +dependencies = [ + "approx", + "matrixmultiply", + "num-complex", + "num-rational", + "num-traits", + "simba", + "typenum", +] + [[package]] name = "nats" version = "0.1.0" @@ -3302,6 +3530,15 @@ dependencies = [ "signatory", ] +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + [[package]] name = "nom" version = "7.1.3" @@ -3540,6 +3777,24 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "owned_ttf_parser" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05e6affeb1632d6ff6a23d2cd40ffed138e82f1532571a26f527c8a284bb2fbb" +dependencies = [ + "ttf-parser 0.15.2", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser 0.25.1", +] + [[package]] name = "parking" version = "2.2.1" @@ -3586,6 +3841,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pem" version = "3.0.6" @@ -3785,6 +4046,48 @@ dependencies = [ "serde_json", ] +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-bitmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-bitmap" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ce181e3f6bf82d6c1dc569103ca7b1bd964c60ba03d7e6cdfbb3e3eb7f7405" +dependencies = [ + "image 0.24.9", + "plotters-backend", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "png" version = "0.18.1" @@ -4028,6 +4331,15 @@ version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + [[package]] name = "quick-error" version = "2.0.1" @@ -4207,6 +4519,16 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_distr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits", + "rand 0.8.6", +] + [[package]] name = "ratatui" version = "0.30.0" @@ -4302,8 +4624,8 @@ dependencies = [ "arg_enum_proc_macro", "arrayvec", "av1-grain", - "bitstream-io", - "built", + "bitstream-io 2.6.0", + "built 0.7.7", "cfg-if", "interpolate_name", "itertools 0.12.1", @@ -4327,6 +4649,41 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io 4.10.0", + "built 0.8.1", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.4", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + [[package]] name = "ravif" version = "0.11.20" @@ -4337,10 +4694,51 @@ dependencies = [ "imgref", "loop9", "quick-error", - "rav1e", + "rav1e 0.7.1", "rgb", ] +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e 0.8.1", + "rayon", + "rgb", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -4703,6 +5101,16 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rusttype" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff8374aa04134254b7995b63ad3dc41c7f7236f69528b28553da7d72efaa967" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser 0.15.2", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -4715,6 +5123,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + [[package]] name = "same-file" version = "1.0.6" @@ -4979,6 +5396,19 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simba" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae" +dependencies = [ + "approx", + "num-complex", + "num-traits", + "paste", + "wide", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -5685,6 +6115,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.47" @@ -6040,6 +6484,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "ttf-parser" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b3e06c9b9d80ed6b745c7159c40b311ad2916abb34a49e9be2653b90db0d8dd" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + [[package]] name = "tui" version = "0.1.0" @@ -6511,7 +6967,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c071456adef4aca59bf6a583c46b90ff5eb0b4f758fc347cea81290288f37ce1" dependencies = [ - "image", + "image 0.25.10", "libwebp-sys", ] @@ -6542,6 +6998,12 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -6624,6 +7086,16 @@ dependencies = [ "wasite", ] +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "winapi" version = "0.3.9" @@ -6646,7 +7118,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -7106,12 +7578,33 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "wrapup-renderer" +version = "0.1.0" +dependencies = [ + "async-trait", + "domain", + "image 0.25.10", + "imageproc", + "plotters", + "rusttype", + "tempfile", + "tokio", + "tracing", +] + [[package]] name = "writeable" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "yoke" version = "0.8.2" @@ -7360,6 +7853,15 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + [[package]] name = "zune-jpeg" version = "0.5.15" diff --git a/Cargo.toml b/Cargo.toml index 4c2846b..b5d1d50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ members = [ "crates/adapters/plex", "crates/adapters/sqlite-search", "crates/adapters/postgres-search", + "crates/adapters/wrapup-renderer", ] resolver = "2" @@ -90,3 +91,4 @@ plex = { path = "crates/adapters/plex" } image-converter = { path = "crates/adapters/image-converter" } sqlite-search = { path = "crates/adapters/sqlite-search" } postgres-search = { path = "crates/adapters/postgres-search" } +wrapup-renderer = { path = "crates/adapters/wrapup-renderer" } diff --git a/crates/adapters/wrapup-renderer/Cargo.toml b/crates/adapters/wrapup-renderer/Cargo.toml new file mode 100644 index 0000000..a10443b --- /dev/null +++ b/crates/adapters/wrapup-renderer/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "wrapup-renderer" +version = "0.1.0" +edition = "2024" + +[dependencies] +domain = { workspace = true } +async-trait = { workspace = true } +tracing = { workspace = true } +image = "0.25" +imageproc = "0.25" +rusttype = "0.9" +plotters = { version = "0.3", default-features = false, features = ["bitmap_backend", "bitmap_encoder"] } +tokio = { workspace = true, features = ["process"] } +tempfile = "3" diff --git a/crates/adapters/wrapup-renderer/src/charts.rs b/crates/adapters/wrapup-renderer/src/charts.rs new file mode 100644 index 0000000..9170908 --- /dev/null +++ b/crates/adapters/wrapup-renderer/src/charts.rs @@ -0,0 +1,71 @@ +use domain::errors::DomainError; +use domain::models::wrapup::WrapUpReport; +use plotters::prelude::*; + +pub fn render_genre_chart( + report: &WrapUpReport, + width: u32, + height: u32, +) -> Result, 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) + .x_label_area_size(60) + .y_label_area_size(60) + .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() + .label_style(("sans-serif", 14, &WHITE)) + .axis_style(&RGBColor(100, 100, 100)) + .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()))?; + } + + // Convert raw RGB to PNG via image crate + 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) +} diff --git a/crates/adapters/wrapup-renderer/src/ffmpeg.rs b/crates/adapters/wrapup-renderer/src/ffmpeg.rs new file mode 100644 index 0000000..1871f9f --- /dev/null +++ b/crates/adapters/wrapup-renderer/src/ffmpeg.rs @@ -0,0 +1,56 @@ +use domain::errors::DomainError; +use domain::ports::VideoRenderConfig; +use tokio::process::Command; + +pub async fn stitch_slides( + slides: &[Vec], + config: &VideoRenderConfig, +) -> Result, DomainError> { + let dir = + tempfile::tempdir().map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + + // Write slide PNGs + for (i, png) in slides.iter().enumerate() { + let path = dir.path().join(format!("slide_{:04}.png", i)); + std::fs::write(&path, png) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + } + + let output_path = dir.path().join("output.mp4"); + + // -framerate 1/N makes each image last N seconds + let framerate = format!("1/{}", config.slide_duration_secs); + let (w, h) = config.resolution; + + let status = Command::new(&config.ffmpeg_path) + .args([ + "-y", + "-framerate", + &framerate, + "-i", + &dir.path().join("slide_%04d.png").to_string_lossy(), + "-vf", + &format!("scale={}:{},format=yuv420p", w, h), + "-c:v", + "libx264", + "-preset", + "fast", + "-crf", + "23", + "-movflags", + "+faststart", + &output_path.to_string_lossy(), + ]) + .output() + .await + .map_err(|e| DomainError::InfrastructureError(format!("ffmpeg failed: {e}")))?; + + if !status.status.success() { + let stderr = String::from_utf8_lossy(&status.stderr); + return Err(DomainError::InfrastructureError(format!( + "ffmpeg error: {stderr}" + ))); + } + + std::fs::read(&output_path).map_err(|e| DomainError::InfrastructureError(e.to_string())) +} diff --git a/crates/adapters/wrapup-renderer/src/lib.rs b/crates/adapters/wrapup-renderer/src/lib.rs new file mode 100644 index 0000000..177c17e --- /dev/null +++ b/crates/adapters/wrapup-renderer/src/lib.rs @@ -0,0 +1,49 @@ +mod slides; +mod charts; +mod ffmpeg; + +use async_trait::async_trait; +use domain::errors::DomainError; +use domain::models::wrapup::WrapUpReport; +use domain::ports::{VideoRenderConfig, WrapUpVideoRenderer}; + +pub struct FfmpegWrapUpRenderer; + +impl FfmpegWrapUpRenderer { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl WrapUpVideoRenderer for FfmpegWrapUpRenderer { + async fn render( + &self, + report: &WrapUpReport, + poster_images: Vec<(String, Vec)>, + config: &VideoRenderConfig, + ) -> Result, DomainError> { + let (width, height) = config.resolution; + + // 1. Generate slide images + let mut slide_pngs = Vec::new(); + slide_pngs.push(slides::render_hero_slide(report, width, height)?); + slide_pngs.push(slides::render_ratings_slide(report, width, height)?); + if !report.top_directors.is_empty() { + slide_pngs.push(slides::render_directors_slide(report, width, height)?); + } + if !report.top_actors.is_empty() { + slide_pngs.push(slides::render_actors_slide(report, width, height)?); + } + if !report.top_genres.is_empty() { + slide_pngs.push(charts::render_genre_chart(report, width, height)?); + } + slide_pngs.push(slides::render_highlights_slide(report, width, height)?); + if !poster_images.is_empty() { + slide_pngs.push(slides::render_mosaic_slide(&poster_images, width, height)?); + } + + // 2. Stitch into video + ffmpeg::stitch_slides(&slide_pngs, config).await + } +} diff --git a/crates/adapters/wrapup-renderer/src/slides.rs b/crates/adapters/wrapup-renderer/src/slides.rs new file mode 100644 index 0000000..172b168 --- /dev/null +++ b/crates/adapters/wrapup-renderer/src/slides.rs @@ -0,0 +1,97 @@ +use domain::errors::DomainError; +use domain::models::wrapup::WrapUpReport; +use image::{Rgba, RgbaImage}; + +const BG_COLOR: Rgba = Rgba([26, 26, 36, 255]); // dark blue-gray +const _PRIMARY: Rgba = Rgba([229, 192, 52, 255]); // gold #e5c034 +const _TEXT_COLOR: Rgba = Rgba([255, 255, 255, 255]); // white + +fn to_png(img: &RgbaImage) -> Result, DomainError> { + let mut buf = Vec::new(); + img.write_to( + &mut std::io::Cursor::new(&mut buf), + image::ImageFormat::Png, + ) + .map_err(|e| DomainError::InfrastructureError(e.to_string()))?; + Ok(buf) +} + +fn fill_background(width: u32, height: u32) -> RgbaImage { + RgbaImage::from_pixel(width, height, BG_COLOR) +} + +pub fn render_hero_slide( + _report: &WrapUpReport, + width: u32, + height: u32, +) -> Result, DomainError> { + let img = fill_background(width, height); + // MVP: solid background. Text overlay added with font rendering later. + to_png(&img) +} + +pub fn render_ratings_slide( + _report: &WrapUpReport, + width: u32, + height: u32, +) -> Result, DomainError> { + let img = fill_background(width, height); + to_png(&img) +} + +pub fn render_directors_slide( + _report: &WrapUpReport, + width: u32, + height: u32, +) -> Result, DomainError> { + let img = fill_background(width, height); + to_png(&img) +} + +pub fn render_actors_slide( + _report: &WrapUpReport, + width: u32, + height: u32, +) -> Result, DomainError> { + let img = fill_background(width, height); + to_png(&img) +} + +pub fn render_highlights_slide( + _report: &WrapUpReport, + width: u32, + height: u32, +) -> Result, DomainError> { + let img = fill_background(width, height); + to_png(&img) +} + +pub fn render_mosaic_slide( + posters: &[(String, Vec)], + width: u32, + height: u32, +) -> Result, DomainError> { + let mut canvas = fill_background(width, height); + + let cols = 4u32; + let thumb_w = width / cols; + let thumb_h = (thumb_w * 3) / 2; // 2:3 poster ratio + + for (i, (_, bytes)) in posters.iter().enumerate() { + let col = (i as u32) % cols; + let row = (i as u32) / cols; + let x = col * thumb_w; + let y = row * thumb_h; + if y + thumb_h > height { + break; + } + + if let Ok(poster) = image::load_from_memory(bytes) { + let thumb = + poster.resize_exact(thumb_w, thumb_h, image::imageops::FilterType::Triangle); + image::imageops::overlay(&mut canvas, &thumb.to_rgba8(), x as i64, y as i64); + } + } + + to_png(&canvas) +} diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 8be7834..9cf23cd 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -5,6 +5,7 @@ use uuid::Uuid; use crate::{ errors::DomainError, events::{DomainEvent, EventEnvelope}, + models::wrapup::WrapUpReport, models::{ AnnotatedRow, DiaryEntry, DiaryFilter, EntityType, ExportFormat, ExternalPersonId, FeedEntry, FieldMapping, FileFormat, ImportError, ImportProfile, ImportSession, @@ -521,3 +522,22 @@ pub trait WrapUpStatsQuery: Send + Sync { range: &DateRange, ) -> Result, DomainError>; } + +// ── Video renderer ────────────────────────────────────────────────────────── + +pub struct VideoRenderConfig { + pub slide_duration_secs: u32, + pub transition_duration_secs: f32, + pub resolution: (u32, u32), + pub ffmpeg_path: String, +} + +#[async_trait] +pub trait WrapUpVideoRenderer: Send + Sync { + async fn render( + &self, + report: &WrapUpReport, + poster_images: Vec<(String, Vec)>, + config: &VideoRenderConfig, + ) -> Result, DomainError>; +}