From 30a6200b5b14043a36b81919edf87633c638eed8 Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Tue, 9 Jun 2026 00:36:44 +0200 Subject: [PATCH] remove wrapup video rendering (ffmpeg) SPA handles wrapup visuals client-side; server-side renderer was dead code pulling in ffmpeg + image crates. --- .env.example | 7 - Cargo.lock | 421 +--------- Cargo.toml | 2 - Dockerfile | 2 - README.md | 14 +- architecture.mmd | 1 - crates/adapters/sqlite/src/import_profile.rs | 6 +- crates/adapters/sqlite/src/import_session.rs | 27 +- crates/adapters/template-askama/src/lib.rs | 1 - .../template-askama/templates/wrapup.html | 3 - crates/adapters/wrapup-renderer/Cargo.toml | 14 - crates/adapters/wrapup-renderer/src/ffmpeg.rs | 52 -- crates/adapters/wrapup-renderer/src/lib.rs | 93 --- crates/adapters/wrapup-renderer/src/slides.rs | 746 ------------------ crates/application/src/config.rs | 7 - crates/application/src/context.rs | 3 +- crates/application/src/test_helpers.rs | 3 - crates/application/src/wrapup/delete.rs | 4 - .../application/src/wrapup/event_handler.rs | 3 +- .../src/wrapup/handle_requested.rs | 27 +- crates/application/src/wrapup/mod.rs | 1 - crates/application/src/wrapup/storage.rs | 58 -- crates/domain/src/ports.rs | 16 - crates/presentation/src/handlers/wrapup.rs | 56 +- crates/presentation/src/main.rs | 1 - crates/presentation/src/openapi/wrapup.rs | 1 - crates/presentation/src/routes.rs | 4 - crates/presentation/src/tests/extractors.rs | 3 - crates/presentation/tests/api_test.rs | 3 - crates/worker/Cargo.toml | 1 - crates/worker/src/main.rs | 32 - 31 files changed, 27 insertions(+), 1585 deletions(-) delete mode 100644 crates/adapters/wrapup-renderer/Cargo.toml delete mode 100644 crates/adapters/wrapup-renderer/src/ffmpeg.rs delete mode 100644 crates/adapters/wrapup-renderer/src/lib.rs delete mode 100644 crates/adapters/wrapup-renderer/src/slides.rs delete mode 100644 crates/application/src/wrapup/storage.rs diff --git a/.env.example b/.env.example index 6e86ecf..105daa5 100644 --- a/.env.example +++ b/.env.example @@ -40,13 +40,6 @@ ALLOW_REGISTRATION=true # IMAGE_CONVERSION_ENABLED=false # IMAGE_CONVERSION_FORMAT=avif -# Wrap-Up video generation (requires ffmpeg in container — included in Docker image) -# WRAPUP_FONT_PATH=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf -# WRAPUP_LOGO_PATH=./static/logo.webp -# WRAPUP_BG_DIR=./static/wrapup-backgrounds -# FFMPEG_PATH=ffmpeg -# WRAPUP_MAX_CONCURRENT=2 - # Server # HOST=0.0.0.0 # PORT=3000 diff --git a/Cargo.lock b/Cargo.lock index b6385b9..307a877 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,22 +2,6 @@ # 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", -] - -[[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" @@ -262,15 +246,6 @@ 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" @@ -351,15 +326,6 @@ dependencies = [ "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" @@ -398,15 +364,6 @@ 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" @@ -693,26 +650,6 @@ 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" @@ -879,12 +816,6 @@ 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" @@ -906,15 +837,6 @@ 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" @@ -961,12 +883,6 @@ 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" @@ -1142,12 +1058,6 @@ 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" @@ -1273,16 +1183,6 @@ 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" @@ -1334,12 +1234,6 @@ 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" @@ -1936,21 +1830,6 @@ 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" @@ -1973,12 +1852,6 @@ 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" @@ -2267,16 +2140,6 @@ 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" @@ -2322,17 +2185,6 @@ 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" @@ -2735,18 +2587,10 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", - "color_quant", - "exr", - "gif", "image-webp", "moxcms", "num-traits", "png", - "qoi", - "ravif 0.13.0", - "rayon", - "rgb", - "tiff", "zune-core", "zune-jpeg", ] @@ -2761,7 +2605,7 @@ dependencies = [ "image", "object-storage", "object_store", - "ravif 0.11.20", + "ravif", "tokio", "tracing", "uuid", @@ -2778,24 +2622,6 @@ 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", - "itertools 0.12.1", - "nalgebra", - "num", - "rand 0.8.6", - "rand_distr", - "rayon", -] - [[package]] name = "imgref" version = "1.12.1" @@ -3086,12 +2912,6 @@ 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" @@ -3278,16 +3098,6 @@ 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" @@ -3295,7 +3105,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" dependencies = [ "cfg-if", - "rayon", ] [[package]] @@ -3431,21 +3240,6 @@ 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" @@ -3497,15 +3291,6 @@ 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" @@ -3760,15 +3545,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "owned_ttf_parser" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" -dependencies = [ - "ttf-parser", -] - [[package]] name = "parking" version = "2.2.1" @@ -3815,12 +3591,6 @@ 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" @@ -4265,15 +4035,6 @@ 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" @@ -4453,16 +4214,6 @@ 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" @@ -4558,8 +4309,8 @@ dependencies = [ "arg_enum_proc_macro", "arrayvec", "av1-grain", - "bitstream-io 2.6.0", - "built 0.7.7", + "bitstream-io", + "built", "cfg-if", "interpolate_name", "itertools 0.12.1", @@ -4583,41 +4334,6 @@ 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" @@ -4628,51 +4344,10 @@ dependencies = [ "imgref", "loop9", "quick-error", - "rav1e 0.7.1", + "rav1e", "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" @@ -5047,15 +4722,6 @@ 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" @@ -5320,19 +4986,6 @@ 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" @@ -6039,20 +5692,6 @@ 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" @@ -6408,12 +6047,6 @@ dependencies = [ "tokio", ] -[[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" @@ -6916,12 +6549,6 @@ 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" @@ -7004,16 +6631,6 @@ 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" @@ -7494,21 +7111,6 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", - "wrapup-renderer", -] - -[[package]] -name = "wrapup-renderer" -version = "0.1.0" -dependencies = [ - "ab_glyph", - "async-trait", - "domain", - "image", - "imageproc", - "tempfile", - "tokio", - "tracing", ] [[package]] @@ -7517,12 +7119,6 @@ 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" @@ -7771,15 +7367,6 @@ 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 954d30c..9dca8cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,6 @@ members = [ "crates/adapters/plex", "crates/adapters/sqlite-search", "crates/adapters/postgres-search", - "crates/adapters/wrapup-renderer", ] resolver = "2" @@ -92,4 +91,3 @@ 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/Dockerfile b/Dockerfile index b5004d1..59cc876 100644 --- a/Dockerfile +++ b/Dockerfile @@ -45,7 +45,6 @@ COPY crates/tui/Cargo.toml crates/tui/Cargo.toml COPY crates/adapters/image-converter/Cargo.toml crates/adapters/image-converter/Cargo.toml COPY crates/adapters/sqlite-search/Cargo.toml crates/adapters/sqlite-search/Cargo.toml COPY crates/adapters/postgres-search/Cargo.toml crates/adapters/postgres-search/Cargo.toml -COPY crates/adapters/wrapup-renderer/Cargo.toml crates/adapters/wrapup-renderer/Cargo.toml COPY crates/worker/Cargo.toml crates/worker/Cargo.toml # Stub every crate so cargo can resolve and fetch deps @@ -80,7 +79,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ wget \ libwebp7 \ - ffmpeg \ fonts-dejavu-core \ && rm -rf /var/lib/apt/lists/* diff --git a/README.md b/README.md index 873067c..e13cd12 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ A self-hosted, server-side rendered movie logging system with a full REST API. B - Watchlist — add movies to watch later, per-user; federated watchlist entries visible for remote actors - User profiles — display name, bio, avatar, banner, custom profile fields; editable via HTML settings page or REST API - Jellyfin/Plex auto-import — media server sends a webhook on playback stop, movies land in a watch queue; review and confirm with a rating to create diary entries; per-user webhook tokens with SHA-256 auth; setup UI at `/settings/integrations` -- Annual Wrap-Up — Spotify Wrapped for movies: per-user and instance-wide year-in-review with stats (top directors, actors, genres, rating distribution, watch time, rewatches, budget analysis), shareable HTML page at `/wrapups/{user_id}/{year}`, downloadable MP4 video with branded slides; admin-triggered or auto-generated in January +- Annual Wrap-Up — Spotify Wrapped for movies: per-user and instance-wide year-in-review with stats (top directors, actors, genres, rating distribution, watch time, rewatches, budget analysis), shareable HTML page at `/wrapups/{user_id}/{year}`; admin-triggered or auto-generated in January - Goals — set a "watch N movies in YEAR" target with a progress bar; progress computed from existing reviews (backwards compatible); per-user federation toggle in settings; displayed on profile (SPA: interactive with create/edit/delete, classic HTML: read-only glassmorphic card) - CSV and JSON diary export - File importer: upload CSV, TSV, JSON, or XLSX from any source (Letterboxd, IMDb, etc.), map columns to domain fields via a step-by-step wizard or REST API, save mapping profiles for repeat imports @@ -59,7 +59,6 @@ adapters/ postgres-event-queue — durable polling event queue backed by PostgreSQL nats — NATS Core / JetStream event publisher and consumer event-publisher — in-memory event channel (used in tests) - wrapup-renderer — annual wrap-up video generator (slide compositing via image/ab_glyph, stitching via ffmpeg) activitypub — ActivityPub federation adapter (follow, inbox/outbox, actor); delegates to k-ap for protocol internals sqlite-search — SQLite FTS5 implementation of SearchPort + SearchCommand postgres-search — PostgreSQL tsvector + GIN implementation of SearchPort + SearchCommand @@ -114,13 +113,6 @@ IMAGE_STORAGE_PATH=./images # IMAGE_CONVERSION_ENABLED=false # IMAGE_CONVERSION_FORMAT=avif # avif or webp -# Annual Wrap-Up video (optional — requires ffmpeg) -# WRAPUP_FONT_PATH=/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf -# WRAPUP_LOGO_PATH=./static/logo.webp # watermark on video slides -# WRAPUP_BG_DIR=./static/wrapup-backgrounds # slide background images (jpg/png/webp) -# FFMPEG_PATH=ffmpeg -# WRAPUP_MAX_CONCURRENT=2 # max parallel video renders - # Optional HOST=0.0.0.0 PORT=3000 @@ -253,7 +245,7 @@ Movies you finish watching appear in your watch queue at `/watch-queue` — rate ## Annual Wrap-Up -Generate a year-in-review summary for any user — top directors, actors, genres, rating distribution, total watch time, rewatch stats, and more. Available as a shareable HTML page and downloadable MP4 video. +Generate a year-in-review summary for any user — top directors, actors, genres, rating distribution, total watch time, rewatch stats, and more. Available as a shareable HTML page. **Generate via API** (admin only): ```bash @@ -269,8 +261,6 @@ Omit `user_id` for a global instance wrap-up. The worker computes stats in the b **Auto-generate:** The worker runs a daily job in January that generates wrap-ups for all users with reviews in the previous year. -**Video:** Requires `ffmpeg` installed. Set `WRAPUP_FONT_PATH` and `WRAPUP_LOGO_PATH` for branded slides. Set `WRAPUP_BG_DIR` to a directory of background images for frutiger aero-style glass-panel slides. Cast profile photos and movie posters are embedded automatically. Download via `GET /api/v1/wrapups/{id}/video`. - ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions, architecture overview, and PR guidelines. diff --git a/architecture.mmd b/architecture.mmd index e17a919..ab9e3c1 100644 --- a/architecture.mmd +++ b/architecture.mmd @@ -94,7 +94,6 @@ graph TB subgraph Media["Media Processing"] A_IMG["image-converter
AVIF/WebP"] A_POSTER_SYNC["poster-sync"] - A_WRAPUP_RENDER["wrapup-renderer
ffmpeg video, slides"] end subgraph Presentation["Presentation Helpers"] A_TEMPLATE["template-askama
HTML templates"] diff --git a/crates/adapters/sqlite/src/import_profile.rs b/crates/adapters/sqlite/src/import_profile.rs index 7d3bd23..3574ebd 100644 --- a/crates/adapters/sqlite/src/import_profile.rs +++ b/crates/adapters/sqlite/src/import_profile.rs @@ -152,11 +152,13 @@ impl ImportProfileRepository for SqliteImportProfileRepository { let ca: String = r.get("created_at"); Ok(ImportProfile { id: ImportProfileId::from_uuid( - id_str.parse::() + id_str + .parse::() .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, ), user_id: UserId::from_uuid( - uid_str.parse::() + uid_str + .parse::() .map_err(|e| DomainError::InfrastructureError(e.to_string()))?, ), name: r.get("name"), diff --git a/crates/adapters/sqlite/src/import_session.rs b/crates/adapters/sqlite/src/import_session.rs index 4bf5662..7e491c2 100644 --- a/crates/adapters/sqlite/src/import_session.rs +++ b/crates/adapters/sqlite/src/import_session.rs @@ -354,16 +354,14 @@ impl ImportSessionRepository for SqliteImportSessionRepository { async fn update(&self, s: &ImportSession) -> Result<(), DomainError> { let id = s.id.value().to_string(); let (_, field_mappings, row_results) = Self::serialize_session(s)?; - sqlx::query( - "UPDATE import_sessions SET field_mappings = ?, row_results = ? WHERE id = ?", - ) - .bind(&field_mappings) - .bind(&row_results) - .bind(&id) - .execute(&self.pool) - .await - .map(|_| ()) - .map_err(Self::map_err) + sqlx::query("UPDATE import_sessions SET field_mappings = ?, row_results = ? WHERE id = ?") + .bind(&field_mappings) + .bind(&row_results) + .bind(&id) + .execute(&self.pool) + .await + .map(|_| ()) + .map_err(Self::map_err) } async fn delete(&self, id: &ImportSessionId) -> Result<(), DomainError> { @@ -377,11 +375,10 @@ impl ImportSessionRepository for SqliteImportSessionRepository { } async fn delete_expired(&self) -> Result { - let result = - sqlx::query("DELETE FROM import_sessions WHERE expires_at < datetime('now')") - .execute(&self.pool) - .await - .map_err(Self::map_err)?; + let result = sqlx::query("DELETE FROM import_sessions WHERE expires_at < datetime('now')") + .execute(&self.pool) + .await + .map_err(Self::map_err)?; Ok(result.rows_affected()) } diff --git a/crates/adapters/template-askama/src/lib.rs b/crates/adapters/template-askama/src/lib.rs index 9064172..95e3b93 100644 --- a/crates/adapters/template-askama/src/lib.rs +++ b/crates/adapters/template-askama/src/lib.rs @@ -481,5 +481,4 @@ pub struct WrapUpPageTemplate<'a> { pub genre_max: u32, pub rating_pcts: [f64; 5], pub genre_pcts: Vec, - pub video_url: Option, } diff --git a/crates/adapters/template-askama/templates/wrapup.html b/crates/adapters/template-askama/templates/wrapup.html index d117372..8da50e3 100644 --- a/crates/adapters/template-askama/templates/wrapup.html +++ b/crates/adapters/template-askama/templates/wrapup.html @@ -10,9 +10,6 @@ {% if report.total_watch_time_minutes > 0 %}
{{ watch_time_display }} of watch time
{% endif %} - {% if let Some(url) = video_url %} - Download Video - {% endif %}
diff --git a/crates/adapters/wrapup-renderer/Cargo.toml b/crates/adapters/wrapup-renderer/Cargo.toml deleted file mode 100644 index 278b1bd..0000000 --- a/crates/adapters/wrapup-renderer/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "wrapup-renderer" -version = "0.1.0" -edition = "2024" - -[dependencies] -domain = { workspace = true } -async-trait = { workspace = true } -tracing = { workspace = true } -image = { version = "0.25", features = ["avif"] } -imageproc = "0.25" -ab_glyph = "0.2" -tokio = { workspace = true, features = ["process"] } -tempfile = "3" diff --git a/crates/adapters/wrapup-renderer/src/ffmpeg.rs b/crates/adapters/wrapup-renderer/src/ffmpeg.rs deleted file mode 100644 index f197827..0000000 --- a/crates/adapters/wrapup-renderer/src/ffmpeg.rs +++ /dev/null @@ -1,52 +0,0 @@ -use domain::errors::DomainError; -use tokio::process::Command; - -pub async fn stitch_slides( - slides: &[Vec], - ffmpeg_path: &str, - slide_duration_secs: u32, - resolution: (u32, u32), -) -> Result, DomainError> { - let dir = tempfile::tempdir().map_err(|e| DomainError::InfrastructureError(e.to_string()))?; - - 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"); - let framerate = format!("1/{}", slide_duration_secs); - let (w, h) = resolution; - - let status = Command::new(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 deleted file mode 100644 index c86d19a..0000000 --- a/crates/adapters/wrapup-renderer/src/lib.rs +++ /dev/null @@ -1,93 +0,0 @@ -mod ffmpeg; -mod slides; - -use async_trait::async_trait; -use domain::errors::DomainError; -use domain::models::wrapup::WrapUpReport; -use domain::ports::{VideoRenderAssets, WrapUpVideoRenderer}; - -pub struct RendererConfig { - pub slide_duration_secs: u32, - pub transition_duration_secs: f32, - pub resolution: (u32, u32), - pub ffmpeg_path: String, - pub font_path: Option, - pub logo_path: Option, - pub bg_dir: Option, -} - -pub struct FfmpegWrapUpRenderer { - config: RendererConfig, - slide_renderer: slides::SlideRenderer, -} - -impl FfmpegWrapUpRenderer { - pub fn new(config: RendererConfig) -> Result { - let slide_renderer = slides::SlideRenderer::new( - config.font_path.as_deref(), - config.logo_path.as_deref(), - config.bg_dir.as_deref(), - )?; - Ok(Self { - config, - slide_renderer, - }) - } -} - -#[async_trait] -impl WrapUpVideoRenderer for FfmpegWrapUpRenderer { - async fn render( - &self, - report: &WrapUpReport, - assets: VideoRenderAssets, - ) -> Result, DomainError> { - let (width, height) = self.config.resolution; - - let mut slide_pngs = Vec::new(); - slide_pngs.push(self.slide_renderer.render_hero(report, width, height)?); - slide_pngs.push(self.slide_renderer.render_ratings(report, width, height)?); - if !report.top_directors.is_empty() { - slide_pngs.push(self.slide_renderer.render_directors( - report, - &assets.cast_images, - width, - height, - )?); - } - if !report.top_actors.is_empty() { - slide_pngs.push(self.slide_renderer.render_actors( - report, - &assets.cast_images, - width, - height, - )?); - } - if !report.top_genres.is_empty() { - slide_pngs.push(self.slide_renderer.render_genres(report, width, height)?); - } - slide_pngs.push(self.slide_renderer.render_highlights( - report, - &assets.poster_images, - width, - height, - )?); - if !assets.poster_images.is_empty() { - slide_pngs.push(self.slide_renderer.render_mosaic( - &assets.poster_images, - width, - height, - )?); - } else { - tracing::warn!("no poster images resolved, skipping mosaic slide"); - } - - ffmpeg::stitch_slides( - &slide_pngs, - &self.config.ffmpeg_path, - self.config.slide_duration_secs, - self.config.resolution, - ) - .await - } -} diff --git a/crates/adapters/wrapup-renderer/src/slides.rs b/crates/adapters/wrapup-renderer/src/slides.rs deleted file mode 100644 index 3c7099c..0000000 --- a/crates/adapters/wrapup-renderer/src/slides.rs +++ /dev/null @@ -1,746 +0,0 @@ -use ab_glyph::{FontArc, PxScale}; -use domain::errors::DomainError; -use domain::models::wrapup::WrapUpReport; -use image::{DynamicImage, Rgba, RgbaImage}; -use imageproc::drawing::{draw_filled_rect_mut, draw_text_mut}; -use imageproc::rect::Rect; - -fn decode_image(bytes: &[u8]) -> Result { - image::load_from_memory(bytes).or_else(|_| { - let dir = tempfile::tempdir().map_err(|e| e.to_string())?; - let input = dir.path().join("input"); - let output = dir.path().join("output.png"); - std::fs::write(&input, bytes).map_err(|e| e.to_string())?; - let status = std::process::Command::new("ffmpeg") - .args([ - "-y", - "-i", - &input.to_string_lossy(), - &output.to_string_lossy(), - ]) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map_err(|e| e.to_string())?; - if !status.success() { - return Err("ffmpeg conversion failed".into()); - } - let png_bytes = std::fs::read(&output).map_err(|e| e.to_string())?; - image::load_from_memory(&png_bytes).map_err(|e| e.to_string()) - }) -} - -fn resize_cover(img: &RgbaImage, w: u32, h: u32) -> RgbaImage { - let (iw, ih) = (img.width() as f64, img.height() as f64); - let scale = (w as f64 / iw).max(h as f64 / ih); - let sw = (iw * scale).ceil() as u32; - let sh = (ih * scale).ceil() as u32; - let scaled = image::imageops::resize(img, sw, sh, image::imageops::FilterType::CatmullRom); - let cx = (sw.saturating_sub(w)) / 2; - let cy = (sh.saturating_sub(h)) / 2; - image::imageops::crop_imm(&scaled, cx, cy, w, h).to_image() -} - -const BG: Rgba = Rgba([26, 26, 36, 255]); -const GOLD: Rgba = Rgba([229, 192, 52, 255]); -const WHITE: Rgba = Rgba([255, 255, 255, 255]); -const DIM: Rgba = Rgba([255, 255, 255, 140]); -const BAR_BG: Rgba = Rgba([50, 50, 65, 255]); -const GLASS: Rgba = Rgba([20, 20, 30, 180]); -const GLASS_PADDING: u32 = 30; - -pub struct SlideRenderer { - font: FontArc, - logo: Option, - bg_paths: Vec, -} - -impl SlideRenderer { - pub fn new( - font_path: Option<&str>, - logo_path: Option<&str>, - bg_dir: Option<&str>, - ) -> Result { - let font = if let Some(path) = font_path { - let bytes = std::fs::read(path) - .map_err(|e| DomainError::InfrastructureError(format!("font load: {e}")))?; - FontArc::try_from_vec(bytes) - .map_err(|e| DomainError::InfrastructureError(format!("font parse: {e}")))? - } else { - load_system_font()? - }; - - let logo = if let Some(path) = logo_path { - let img = image::open(path) - .map_err(|e| DomainError::InfrastructureError(format!("logo load: {e}")))?; - Some(img.to_rgba8()) - } else { - None - }; - - let mut bg_paths = Vec::new(); - if let Some(dir) = bg_dir - && let Ok(entries) = std::fs::read_dir(dir) - { - for entry in entries.flatten() { - let path = entry.path(); - let ext = path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_lowercase(); - if matches!(ext.as_str(), "jpg" | "jpeg" | "png" | "webp") { - bg_paths.push(path); - } - } - bg_paths.sort(); - } - - Ok(Self { - font, - logo, - bg_paths, - }) - } - - fn load_background(&self, index: usize) -> Option { - let path = self.bg_paths.get(index % self.bg_paths.len())?; - match image::open(path) { - Ok(img) => Some(img.to_rgba8()), - Err(e) => { - tracing::warn!("bg load {}: {e}", path.display()); - None - } - } - } - - /// Pick a background for slide at `index`, resized to `w x h` with dark gradient overlay. - fn pick_background(&self, index: usize, w: u32, h: u32) -> Option { - let bg = self.load_background(index)?; - let mut out = resize_cover(&bg, w, h); - // darken top 40% and bottom 40% with gradient to ~70% black - let top_cutoff = (h as f32 * 0.4) as u32; - let bot_start = h - top_cutoff; - for y in 0..h { - let darken = if y < top_cutoff { - // fade from 0.70 at top to 0.0 at cutoff - 0.70 * (1.0 - y as f32 / top_cutoff as f32) - } else if y >= bot_start { - // fade from 0.0 at bot_start to 0.70 at bottom - 0.70 * ((y - bot_start) as f32 / top_cutoff as f32) - } else { - 0.0 - }; - if darken > 0.0 { - let factor = 1.0 - darken; - for x in 0..w { - let px = out.get_pixel_mut(x, y); - px[0] = (px[0] as f32 * factor) as u8; - px[1] = (px[1] as f32 * factor) as u8; - px[2] = (px[2] as f32 * factor) as u8; - } - } - } - Some(out) - } - - /// Start a canvas: background image if available, else solid color. - fn make_canvas(&self, slide_index: usize, w: u32, h: u32) -> RgbaImage { - self.pick_background(slide_index, w, h) - .unwrap_or_else(|| fill(w, h)) - } - - /// Draw a semi-transparent dark glass panel. - fn draw_glass_panel(&self, canvas: &mut RgbaImage, x: i32, y: i32, pw: u32, ph: u32) { - let x0 = x.max(0) as u32; - let y0 = y.max(0) as u32; - let x1 = (x as u32 + pw).min(canvas.width()); - let y1 = (y as u32 + ph).min(canvas.height()); - if x1 <= x0 || y1 <= y0 { - return; - } - let alpha = GLASS[3] as f32 / 255.0; - let inv = 1.0 - alpha; - for py in y0..y1 { - for px in x0..x1 { - let bg = canvas.get_pixel(px, py); - let r = (GLASS[0] as f32 * alpha + bg[0] as f32 * inv) as u8; - let g = (GLASS[1] as f32 * alpha + bg[1] as f32 * inv) as u8; - let b = (GLASS[2] as f32 * alpha + bg[2] as f32 * inv) as u8; - canvas.put_pixel(px, py, Rgba([r, g, b, 255])); - } - } - } - - fn stamp_logo(&self, canvas: &mut RgbaImage) { - if let Some(ref logo) = self.logo { - let logo_size = 64u32; - let resized = image::imageops::resize( - logo, - logo_size, - logo_size, - image::imageops::FilterType::Triangle, - ); - let margin = 20i64; - let x = canvas.width() as i64 - logo_size as i64 - margin; - let y = canvas.height() as i64 - logo_size as i64 - margin; - image::imageops::overlay(canvas, &resized, x, y); - } - } - - fn draw_centered( - &self, - canvas: &mut RgbaImage, - text: &str, - y: i32, - scale: f32, - color: Rgba, - ) { - let px = PxScale::from(scale); - let approx_w = (text.len() as f32 * scale * 0.45) as i32; - let x = ((canvas.width() as i32 - approx_w) / 2).max(10); - draw_text_mut(canvas, color, x, y, px, &self.font, text); - } - - fn draw_left( - &self, - canvas: &mut RgbaImage, - text: &str, - x: i32, - y: i32, - scale: f32, - color: Rgba, - ) { - draw_text_mut(canvas, color, x, y, PxScale::from(scale), &self.font, text); - } - - /// Draw a small thumbnail from raw image bytes, resized to `size x size`. - fn draw_thumbnail(canvas: &mut RgbaImage, bytes: &[u8], x: i64, y: i64, tw: u32, th: u32) { - if let Ok(img) = decode_image(bytes) { - let thumb = img.resize_exact(tw, th, image::imageops::FilterType::Triangle); - image::imageops::overlay(canvas, &thumb.to_rgba8(), x, y); - } - } - - /// Find cast photo bytes matching `name` (case-insensitive substring). - fn find_cast_photo<'a>(name: &str, cast_images: &'a [(String, Vec)]) -> Option<&'a [u8]> { - let lower = name.to_lowercase(); - cast_images - .iter() - .find(|(n, _)| { - let cn = n.to_lowercase(); - cn.contains(&lower) || lower.contains(&cn) - }) - .map(|(_, b)| b.as_slice()) - } - - /// Find poster bytes matching a poster_path (compare filename stem). - fn find_poster<'a>( - poster_path: &str, - poster_images: &'a [(String, Vec)], - ) -> Option<&'a [u8]> { - let target = std::path::Path::new(poster_path) - .file_name() - .and_then(|f| f.to_str()) - .unwrap_or(poster_path); - poster_images - .iter() - .find(|(p, _)| { - let fname = std::path::Path::new(p) - .file_name() - .and_then(|f| f.to_str()) - .unwrap_or(p); - fname == target - }) - .map(|(_, b)| b.as_slice()) - } - - // ── Slides ────────────────────────────────────────────── - - pub fn render_hero( - &self, - report: &WrapUpReport, - w: u32, - h: u32, - ) -> Result, DomainError> { - let mut img = self.make_canvas(0, w, h); - - // glass panel in center area - let panel_x = GLASS_PADDING as i32; - let panel_y = (h / 7) as i32; - let panel_w = w - GLASS_PADDING * 2; - let panel_h = h * 5 / 7; - self.draw_glass_panel(&mut img, panel_x, panel_y, panel_w, panel_h); - - let year_label = format!( - "{} - {}", - report.date_range.start().format("%b %Y"), - report.date_range.end().format("%b %Y") - ); - self.draw_centered(&mut img, &year_label, (h / 6) as i32, 48.0, DIM); - self.draw_centered( - &mut img, - &report.total_movies.to_string(), - (h / 3) as i32, - 160.0, - GOLD, - ); - self.draw_centered( - &mut img, - "movies watched", - (h / 3 + 170) as i32, - 40.0, - WHITE, - ); - - let hours = report.total_watch_time_minutes / 60; - let mins = report.total_watch_time_minutes % 60; - let time_str = format!("{}h {}m of watch time", hours, mins); - self.draw_centered(&mut img, &time_str, (h / 2 + 60) as i32, 36.0, DIM); - - if let Some(ref month) = report.busiest_month { - let s = format!("Busiest month: {month}"); - self.draw_centered(&mut img, &s, (h * 2 / 3) as i32, 32.0, DIM); - } - if let Some(ref dow) = report.busiest_day_of_week { - let s = format!("Favorite day: {dow}"); - self.draw_centered(&mut img, &s, (h * 2 / 3 + 50) as i32, 32.0, DIM); - } - - self.stamp_logo(&mut img); - to_png(&img) - } - - pub fn render_ratings( - &self, - report: &WrapUpReport, - w: u32, - h: u32, - ) -> Result, DomainError> { - let mut img = self.make_canvas(1, w, h); - - // glass panel covering content area - let panel_x = (GLASS_PADDING / 2) as i32; - let panel_y = (h / 10) as i32; - let panel_w = w - GLASS_PADDING; - let panel_h = h * 4 / 5; - self.draw_glass_panel(&mut img, panel_x, panel_y, panel_w, panel_h); - - self.draw_centered(&mut img, "Ratings", (h / 8) as i32, 56.0, GOLD); - - if let Some(avg) = report.avg_rating { - let s = format!("{:.1} / 5", avg); - self.draw_centered(&mut img, &s, (h / 4) as i32, 80.0, WHITE); - self.draw_centered(&mut img, "average rating", (h / 4 + 90) as i32, 32.0, DIM); - } - - let max_count = report - .rating_distribution - .iter() - .copied() - .max() - .unwrap_or(1) - .max(1); - let bar_area_top = (h / 2) as i32; - let bar_h = 36u32; - let bar_gap = 16u32; - let margin_x = 120i32; - let max_bar_w = (w as i32 - margin_x * 2) as u32; - - for row in 0..5 { - let stars = 5 - row; - let count = report.rating_distribution[stars - 1]; - let label = format!("{stars}\u{2605}"); - let y = bar_area_top + (row as i32) * (bar_h as i32 + bar_gap as i32); - self.draw_left(&mut img, &label, margin_x - 60, y + 2, 28.0, GOLD); - - draw_filled_rect_mut( - &mut img, - Rect::at(margin_x, y).of_size(max_bar_w, bar_h), - BAR_BG, - ); - let fill_w = ((count as f32 / max_count as f32) * max_bar_w as f32) as u32; - if fill_w > 0 { - draw_filled_rect_mut(&mut img, Rect::at(margin_x, y).of_size(fill_w, bar_h), GOLD); - } - let count_s = count.to_string(); - self.draw_left( - &mut img, - &count_s, - margin_x + fill_w as i32 + 10, - y + 2, - 24.0, - DIM, - ); - } - - self.stamp_logo(&mut img); - to_png(&img) - } - - pub fn render_directors( - &self, - report: &WrapUpReport, - cast_images: &[(String, Vec)], - w: u32, - h: u32, - ) -> Result, DomainError> { - let mut img = self.make_canvas(2, w, h); - - let margin = 80i32; - let start_y = (h / 4) as i32; - let row_h = 100i32; - let panel_h = (report.top_directors.len().min(5) as u32) * row_h as u32 + GLASS_PADDING * 2; - self.draw_glass_panel( - &mut img, - margin - GLASS_PADDING as i32, - start_y - GLASS_PADDING as i32, - w - (margin as u32 - GLASS_PADDING) * 2, - panel_h, - ); - - self.draw_centered(&mut img, "Top Directors", (h / 8) as i32, 56.0, GOLD); - - let thumb_size = 60u32; - // offset text right when cast photos present - let text_offset = if cast_images.is_empty() { - 60 - } else { - thumb_size as i32 + 20 - }; - - for (i, d) in report.top_directors.iter().take(5).enumerate() { - let y = start_y + (i as i32) * row_h; - - // cast photo thumbnail - if let Some(photo) = Self::find_cast_photo(&d.name, cast_images) { - Self::draw_thumbnail( - &mut img, - photo, - margin as i64 + 40, - y as i64, - thumb_size, - thumb_size, - ); - } - - let rank = format!("{}.", i + 1); - self.draw_left(&mut img, &rank, margin, y + 10, 36.0, GOLD); - self.draw_left(&mut img, &d.name, margin + text_offset, y + 10, 36.0, WHITE); - let detail = format!("{} films avg {:.1}\u{2605}", d.count, d.avg_rating); - self.draw_left(&mut img, &detail, margin + text_offset, y + 54, 24.0, DIM); - } - - self.stamp_logo(&mut img); - to_png(&img) - } - - pub fn render_actors( - &self, - report: &WrapUpReport, - cast_images: &[(String, Vec)], - w: u32, - h: u32, - ) -> Result, DomainError> { - let mut img = self.make_canvas(3, w, h); - - let margin = 80i32; - let start_y = (h / 4) as i32; - let row_h = 100i32; - let panel_h = (report.top_actors.len().min(5) as u32) * row_h as u32 + GLASS_PADDING * 2; - self.draw_glass_panel( - &mut img, - margin - GLASS_PADDING as i32, - start_y - GLASS_PADDING as i32, - w - (margin as u32 - GLASS_PADDING) * 2, - panel_h, - ); - - self.draw_centered(&mut img, "Top Actors", (h / 8) as i32, 56.0, GOLD); - - let thumb_size = 60u32; - let text_offset = if cast_images.is_empty() { - 60 - } else { - thumb_size as i32 + 20 - }; - - for (i, a) in report.top_actors.iter().take(5).enumerate() { - let y = start_y + (i as i32) * row_h; - - if let Some(photo) = Self::find_cast_photo(&a.name, cast_images) { - Self::draw_thumbnail( - &mut img, - photo, - margin as i64 + 40, - y as i64, - thumb_size, - thumb_size, - ); - } - - let rank = format!("{}.", i + 1); - self.draw_left(&mut img, &rank, margin, y + 10, 36.0, GOLD); - self.draw_left(&mut img, &a.name, margin + text_offset, y + 10, 36.0, WHITE); - let detail = format!("{} films avg {:.1}\u{2605}", a.count, a.avg_rating); - self.draw_left(&mut img, &detail, margin + text_offset, y + 54, 24.0, DIM); - } - - self.stamp_logo(&mut img); - to_png(&img) - } - - pub fn render_genres( - &self, - report: &WrapUpReport, - w: u32, - h: u32, - ) -> Result, DomainError> { - let mut img = self.make_canvas(4, w, h); - - let margin = 80i32; - let start_y = (h / 4) as i32; - let num_genres = report.top_genres.len().min(8) as u32; - let panel_h = num_genres * 80 + GLASS_PADDING * 2 + 80; - self.draw_glass_panel( - &mut img, - margin - GLASS_PADDING as i32, - (h / 10) as i32, - w - (margin as u32 - GLASS_PADDING) * 2, - panel_h + (start_y as u32 - h / 10), - ); - - 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 bar_area_w = (w as i32 - margin * 2 - 200) as u32; - 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( - &self, - report: &WrapUpReport, - poster_images: &[(String, Vec)], - w: u32, - h: u32, - ) -> Result, DomainError> { - let mut img = self.make_canvas(5, w, h); - - // glass panel behind highlights grid - let panel_x = GLASS_PADDING as i32; - let panel_y = (h / 10) as i32; - let panel_w = w - GLASS_PADDING * 2; - let panel_h = h * 4 / 5; - self.draw_glass_panel(&mut img, panel_x, panel_y, panel_w, panel_h); - - self.draw_centered(&mut img, "Highlights", (h / 10) as i32 + 10, 56.0, GOLD); - - let col_w = w / 2; - let start_y = (h / 5) as i32; - let row_h = (h / 5) as i32; - let left = 60i32; - let right = col_w as i32 + 40; - let poster_w = 100u32; - let poster_h = 150u32; - - let items: Vec<(&str, Option<&domain::models::wrapup::MovieRef>)> = vec![ - ("Highest Rated", report.highest_rated_movie.as_ref()), - ("Lowest Rated", report.lowest_rated_movie.as_ref()), - ("Oldest Film", report.oldest_movie.as_ref()), - ("Newest Film", report.newest_movie.as_ref()), - ("Longest", report.longest_movie.as_ref()), - ("Shortest", report.shortest_movie.as_ref()), - ("First Watched", report.first_movie_of_period.as_ref()), - ("Last Watched", report.last_movie_of_period.as_ref()), - ]; - - for (i, (label, movie_ref)) in items.iter().enumerate() { - let col = i % 2; - let row = i / 2; - let x = if col == 0 { left } else { right }; - let y = start_y + (row as i32) * row_h; - - // poster thumbnail if available - let text_x_offset = if let Some(m) = movie_ref { - if let Some(ref pp) = m.poster_path { - if let Some(pb) = Self::find_poster(pp, poster_images) { - Self::draw_thumbnail( - &mut img, - pb, - x as i64, - (y + 30) as i64, - poster_w, - poster_h, - ); - poster_w as i32 + 10 - } else { - 0 - } - } else { - 0 - } - } else { - 0 - }; - - self.draw_left(&mut img, label, x, y, 28.0, GOLD); - if let Some(m) = movie_ref { - let title = if m.title.len() > 22 { - format!("{}...", &m.title[..19]) - } else { - m.title.clone() - }; - self.draw_left(&mut img, &title, x + text_x_offset, y + 36, 26.0, WHITE); - let sub = format!("({})", m.year); - self.draw_left(&mut img, &sub, x + text_x_offset, y + 68, 22.0, DIM); - } else { - self.draw_left(&mut img, "-", x, y + 36, 26.0, DIM); - } - } - - // Rewatches - if report.total_rewatches > 0 { - let rewatch_y = start_y + 4 * row_h + 20; - let s = format!("{} rewatches", report.total_rewatches); - self.draw_centered(&mut img, &s, rewatch_y, 30.0, DIM); - if let Some(ref m) = report.most_rewatched_movie { - let s2 = format!("Most rewatched: {}", m.title); - self.draw_centered(&mut img, &s2, rewatch_y + 40, 26.0, WHITE); - } - } - - self.stamp_logo(&mut img); - to_png(&img) - } - - pub fn render_mosaic( - &self, - posters: &[(String, Vec)], - w: u32, - h: u32, - ) -> Result, DomainError> { - let mut canvas = RgbaImage::from_pixel(w, h, Rgba([0, 0, 0, 255])); - - // poster aspect 2:3, calculate grid to fill entire frame - // find cols that best tile the width, then rows to fill height - let poster_ratio = 2.0_f32 / 3.0; - // try col counts from 3..8, pick one that wastes least space - let cols = (3..=8) - .min_by_key(|&c| { - let tw = w / c; - let th = (tw as f32 / poster_ratio) as u32; - let rows_needed = h.div_ceil(th); - let total = rows_needed * c; - // prefer filling screen with fewer leftover pixels - let waste_y = (rows_needed * th).saturating_sub(h); - let shortage = total.saturating_sub(posters.len() as u32); - waste_y + shortage * 100 - }) - .unwrap_or(4); - - let thumb_w = w / cols; - let thumb_h = (thumb_w as f32 / poster_ratio) as u32; - let total_rows = h.div_ceil(thumb_h); - let total_cells = (total_rows * cols) as usize; - - for i in 0..total_cells { - if posters.is_empty() { - break; - } - // tile/repeat if not enough posters - let idx = i % posters.len(); - let (name, bytes) = &posters[idx]; - let col = (i as u32) % cols; - let row = (i as u32) / cols; - let x = col * thumb_w; - let y = row * thumb_h; - - match decode_image(bytes) { - Ok(poster) => { - 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); - } - Err(e) => tracing::debug!("mosaic: skipped {name}: {e}"), - } - } - - self.stamp_logo(&mut canvas); - to_png(&canvas) - } -} - -fn fill(w: u32, h: u32) -> RgbaImage { - RgbaImage::from_pixel(w, h, BG) -} - -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 load_system_font() -> Result { - let candidates = [ - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", - "/usr/share/fonts/TTF/DejaVuSans.ttf", - "/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf", - "/usr/share/fonts/noto/NotoSans-Regular.ttf", - "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", - "/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc", - "/System/Library/Fonts/Helvetica.ttc", - ]; - for path in &candidates { - if let Ok(bytes) = std::fs::read(path) - && let Ok(font) = FontArc::try_from_vec(bytes) - { - tracing::info!("loaded system font: {path}"); - return Ok(font); - } - } - Err(DomainError::InfrastructureError( - "no system font found; set font_path in VideoRenderConfig or WRAPUP_FONT_PATH env" - .to_string(), - )) -} diff --git a/crates/application/src/config.rs b/crates/application/src/config.rs index 2e9c584..29d3c9d 100644 --- a/crates/application/src/config.rs +++ b/crates/application/src/config.rs @@ -11,8 +11,6 @@ pub struct WrapUpConfig { pub font_path: Option, pub logo_path: Option, pub bg_dir: Option, - pub ffmpeg_path: String, - pub max_concurrent_renders: usize, } impl AppConfig { @@ -41,11 +39,6 @@ impl WrapUpConfig { font_path: std::env::var("WRAPUP_FONT_PATH").ok(), logo_path: std::env::var("WRAPUP_LOGO_PATH").ok(), bg_dir: std::env::var("WRAPUP_BG_DIR").ok(), - ffmpeg_path: std::env::var("FFMPEG_PATH").unwrap_or_else(|_| "ffmpeg".to_string()), - max_concurrent_renders: std::env::var("WRAPUP_MAX_CONCURRENT") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(2), } } } diff --git a/crates/application/src/context.rs b/crates/application/src/context.rs index c5afad8..f28236c 100644 --- a/crates/application/src/context.rs +++ b/crates/application/src/context.rs @@ -7,7 +7,7 @@ use domain::ports::{ PosterFetcherClient, RemoteGoalRepository, RemoteWatchlistRepository, ReviewRepository, SearchCommand, SearchPort, SocialQueryPort, StatsRepository, UserProfileFieldsRepository, UserRepository, UserSettingsRepository, WatchEventRepository, WatchlistRepository, - WebhookTokenRepository, WrapUpRepository, WrapUpStatsQuery, WrapUpVideoRenderer, + WebhookTokenRepository, WrapUpRepository, WrapUpStatsQuery, }; use crate::config::AppConfig; @@ -49,7 +49,6 @@ pub struct Services { pub event_publisher: Arc, pub diary_exporter: Arc, pub document_parser: Arc, - pub video_renderer: Option>, } #[derive(Clone)] diff --git a/crates/application/src/test_helpers.rs b/crates/application/src/test_helpers.rs index 721b75c..92e0846 100644 --- a/crates/application/src/test_helpers.rs +++ b/crates/application/src/test_helpers.rs @@ -102,8 +102,6 @@ impl TestContextBuilder { font_path: None, logo_path: None, bg_dir: None, - ffmpeg_path: "ffmpeg".into(), - max_concurrent_renders: 2, }, }, } @@ -185,7 +183,6 @@ impl TestContextBuilder { event_publisher: self.event_publisher, diary_exporter: self.diary_exporter, document_parser: self.document_parser, - video_renderer: None, }, config: self.config, } diff --git a/crates/application/src/wrapup/delete.rs b/crates/application/src/wrapup/delete.rs index 60949a9..26ced67 100644 --- a/crates/application/src/wrapup/delete.rs +++ b/crates/application/src/wrapup/delete.rs @@ -2,7 +2,6 @@ use domain::errors::DomainError; use domain::value_objects::WrapUpId; use crate::context::AppContext; -use crate::wrapup::storage::WrapUpStorage; pub async fn execute(ctx: &AppContext, id: WrapUpId) -> Result<(), DomainError> { ctx.repos @@ -11,8 +10,5 @@ pub async fn execute(ctx: &AppContext, id: WrapUpId) -> Result<(), DomainError> .await? .ok_or_else(|| DomainError::NotFound("wrap-up not found".into()))?; - let storage = WrapUpStorage::new(ctx.services.object_storage.clone()); - let _ = storage.delete_video(&id).await; - ctx.repos.wrapup_repo.delete(&id).await } diff --git a/crates/application/src/wrapup/event_handler.rs b/crates/application/src/wrapup/event_handler.rs index d96ca8a..f0d2b9e 100644 --- a/crates/application/src/wrapup/event_handler.rs +++ b/crates/application/src/wrapup/event_handler.rs @@ -15,10 +15,9 @@ pub struct WrapUpEventHandler { impl WrapUpEventHandler { pub fn new(ctx: AppContext) -> Self { - let max = ctx.config.wrapup.max_concurrent_renders; Self { ctx, - semaphore: Arc::new(Semaphore::new(max)), + semaphore: Arc::new(Semaphore::new(2)), } } } diff --git a/crates/application/src/wrapup/handle_requested.rs b/crates/application/src/wrapup/handle_requested.rs index e425bf2..f3b1ec2 100644 --- a/crates/application/src/wrapup/handle_requested.rs +++ b/crates/application/src/wrapup/handle_requested.rs @@ -1,9 +1,8 @@ use crate::context::AppContext; -use crate::wrapup::{compute, queries::ComputeWrapUpQuery, storage::WrapUpStorage}; +use crate::wrapup::{compute, queries::ComputeWrapUpQuery}; use domain::errors::DomainError; use domain::events::DomainEvent; use domain::models::wrapup::{DateRange, WrapUpScope, WrapUpStatus}; -use domain::ports::VideoRenderAssets; use domain::value_objects::WrapUpId; pub async fn execute( @@ -45,30 +44,6 @@ pub async fn execute( .set_complete(&wrapup_id, &report) .await?; - if let Some(ref renderer) = ctx.services.video_renderer { - let asset_storage = WrapUpStorage::new(ctx.services.object_storage.clone()); - let poster_images = asset_storage - .resolve_poster_images(&report.poster_paths) - .await; - let cast_images = asset_storage - .resolve_cast_images(&report.top_cast_profile_paths) - .await; - let assets = VideoRenderAssets { - poster_images, - cast_images, - }; - match renderer.render(&report, assets).await { - Ok(video_bytes) => { - if let Err(e) = asset_storage.store_video(&wrapup_id, &video_bytes).await { - tracing::warn!("failed to store wrapup video: {e}"); - } - } - Err(e) => { - tracing::warn!("video render failed (non-fatal): {e}"); - } - } - } - ctx.services .event_publisher .publish(&DomainEvent::WrapUpCompleted { wrapup_id }) diff --git a/crates/application/src/wrapup/mod.rs b/crates/application/src/wrapup/mod.rs index 2723685..d2be4dd 100644 --- a/crates/application/src/wrapup/mod.rs +++ b/crates/application/src/wrapup/mod.rs @@ -7,4 +7,3 @@ pub mod get_wrapup; pub mod handle_requested; pub mod list_wrapups; pub mod queries; -pub mod storage; diff --git a/crates/application/src/wrapup/storage.rs b/crates/application/src/wrapup/storage.rs deleted file mode 100644 index c057396..0000000 --- a/crates/application/src/wrapup/storage.rs +++ /dev/null @@ -1,58 +0,0 @@ -use domain::errors::DomainError; -use domain::ports::ObjectStorage; -use domain::value_objects::WrapUpId; -use std::sync::Arc; - -pub struct WrapUpStorage { - inner: Arc, -} - -impl WrapUpStorage { - pub fn new(storage: Arc) -> Self { - Self { inner: storage } - } - - pub async fn store_video(&self, id: &WrapUpId, bytes: &[u8]) -> Result<(), DomainError> { - let key = format!("wrapups/{}/video.mp4", id.value()); - self.inner.store(&key, bytes).await?; - Ok(()) - } - - pub async fn delete_video(&self, id: &WrapUpId) -> Result<(), DomainError> { - let key = format!("wrapups/{}/video.mp4", id.value()); - self.inner.delete(&key).await - } - - pub fn cast_image_key(profile_path: &str) -> String { - format!("cast{profile_path}") - } - - pub async fn resolve_cast_images(&self, profile_paths: &[String]) -> Vec<(String, Vec)> { - let mut images = Vec::new(); - for path in profile_paths.iter().take(20) { - let key = Self::cast_image_key(path); - match self.inner.get(&key).await { - Ok(bytes) => images.push((key, bytes)), - Err(e) => tracing::debug!("cast fetch skipped for {key}: {e}"), - } - } - tracing::info!( - "resolved {}/{} cast images", - images.len(), - profile_paths.len() - ); - images - } - - pub async fn resolve_poster_images(&self, paths: &[String]) -> Vec<(String, Vec)> { - let mut images = Vec::new(); - for path in paths.iter().take(20) { - match self.inner.get(path).await { - Ok(bytes) => images.push((path.clone(), bytes)), - Err(e) => tracing::debug!("poster fetch skipped for {path}: {e}"), - } - } - tracing::info!("resolved {}/{} poster images", images.len(), paths.len()); - images - } -} diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 0f7f8e0..618ca9e 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -587,19 +587,3 @@ pub trait WrapUpStatsQuery: Send + Sync { range: &DateRange, ) -> Result, DomainError>; } - -// ── Video renderer ────────────────────────────────────────────────────────── - -pub struct VideoRenderAssets { - pub poster_images: Vec<(String, Vec)>, - pub cast_images: Vec<(String, Vec)>, -} - -#[async_trait] -pub trait WrapUpVideoRenderer: Send + Sync { - async fn render( - &self, - report: &WrapUpReport, - assets: VideoRenderAssets, - ) -> Result, DomainError>; -} diff --git a/crates/presentation/src/handlers/wrapup.rs b/crates/presentation/src/handlers/wrapup.rs index 3fac829..18f5f8e 100644 --- a/crates/presentation/src/handlers/wrapup.rs +++ b/crates/presentation/src/handlers/wrapup.rs @@ -147,54 +147,6 @@ pub async fn get_report( } } -#[utoipa::path( - get, path = "/api/v1/wrapups/{id}/video", - params(("id" = Uuid, Path, description = "Wrap-up ID")), - responses( - (status = 200, description = "MP4 video file", content_type = "video/mp4"), - (status = 404, description = "Not found or video not generated"), - ), - security(("bearer_auth" = [])) -)] -pub async fn get_video(State(state): State, Path(id): Path) -> impl IntoResponse { - let record = match state - .app_ctx - .repos - .wrapup_repo - .get_by_id(&WrapUpId::from_uuid(id)) - .await - { - Ok(Some(r)) if r.status == WrapUpStatus::Ready => r, - _ => return StatusCode::NOT_FOUND.into_response(), - }; - let _ = record; - let video_key = format!("wrapups/{}/video.mp4", id); - match state - .app_ctx - .services - .object_storage - .get_stream(&video_key) - .await - { - Ok(stream) => { - let body = axum::body::Body::from_stream(stream); - ( - StatusCode::OK, - [ - (axum::http::header::CONTENT_TYPE, "video/mp4"), - ( - axum::http::header::CONTENT_DISPOSITION, - "attachment; filename=\"wrapup.mp4\"", - ), - ], - body, - ) - .into_response() - } - Err(_) => StatusCode::NOT_FOUND.into_response(), - } -} - #[utoipa::path( delete, path = "/api/v1/wrapups/{id}", params(("id" = Uuid, Path, description = "Wrap-up ID")), @@ -233,7 +185,6 @@ fn render_wrapup( report: &WrapUpReport, year: i32, ctx: &application::ports::HtmlPageContext, - video_url: Option, ) -> axum::response::Response { let rating_max = report .rating_distribution @@ -265,7 +216,6 @@ fn render_wrapup( genre_max, rating_pcts, genre_pcts, - video_url, }; render_page(tmpl) } @@ -301,9 +251,8 @@ pub async fn get_user_wrapup_html( None => return StatusCode::NOT_FOUND.into_response(), }; - let video_url = format!("/api/v1/wrapups/{}/video", record.id.value()); let ctx = super::helpers::build_page_context(&state, viewer, csrf.0).await; - render_wrapup(&report, year, &ctx, Some(video_url)) + render_wrapup(&report, year, &ctx) } pub async fn get_global_wrapup_html( @@ -337,7 +286,6 @@ pub async fn get_global_wrapup_html( None => return StatusCode::NOT_FOUND.into_response(), }; - let video_url = format!("/api/v1/wrapups/{}/video", record.id.value()); let ctx = super::helpers::build_page_context(&state, viewer, csrf.0).await; - render_wrapup(&report, year, &ctx, Some(video_url)) + render_wrapup(&report, year, &ctx) } diff --git a/crates/presentation/src/main.rs b/crates/presentation/src/main.rs index c66d5bb..f206352 100644 --- a/crates/presentation/src/main.rs +++ b/crates/presentation/src/main.rs @@ -209,7 +209,6 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> { event_publisher: event_publisher_arc, diary_exporter: Arc::new(ExportAdapter) as Arc, document_parser: Arc::new(ImporterDocumentParser) as Arc, - video_renderer: None, }, config: app_config, }; diff --git a/crates/presentation/src/openapi/wrapup.rs b/crates/presentation/src/openapi/wrapup.rs index d6dded4..ba6e009 100644 --- a/crates/presentation/src/openapi/wrapup.rs +++ b/crates/presentation/src/openapi/wrapup.rs @@ -7,7 +7,6 @@ use utoipa::OpenApi; crate::handlers::wrapup::get_list, crate::handlers::wrapup::get_status, crate::handlers::wrapup::get_report, - crate::handlers::wrapup::get_video, crate::handlers::wrapup::delete_wrapup_handler, ), components(schemas( diff --git a/crates/presentation/src/routes.rs b/crates/presentation/src/routes.rs index cadc2a6..5f27f63 100644 --- a/crates/presentation/src/routes.rs +++ b/crates/presentation/src/routes.rs @@ -427,10 +427,6 @@ fn api_routes(rate_limit: u64) -> Router { "/wrapups/{id}/report", routing::get(handlers::wrapup::get_report), ) - .route( - "/wrapups/{id}/video", - routing::get(handlers::wrapup::get_video), - ) .route( "/admin/reindex-search", routing::post(handlers::search::post_reindex_search), diff --git a/crates/presentation/src/tests/extractors.rs b/crates/presentation/src/tests/extractors.rs index cdabfd0..b2c137a 100644 --- a/crates/presentation/src/tests/extractors.rs +++ b/crates/presentation/src/tests/extractors.rs @@ -759,7 +759,6 @@ pub fn make_test_state(auth_service: Arc) -> crate::state::AppS event_publisher: Arc::clone(&repo) as _, diary_exporter: Arc::clone(&repo) as _, document_parser: Arc::clone(&repo) as _, - video_renderer: None, }, config: AppConfig { allow_registration: false, @@ -769,8 +768,6 @@ pub fn make_test_state(auth_service: Arc) -> crate::state::AppS font_path: None, logo_path: None, bg_dir: None, - ffmpeg_path: "ffmpeg".into(), - max_concurrent_renders: 2, }, }, }, diff --git a/crates/presentation/tests/api_test.rs b/crates/presentation/tests/api_test.rs index a1a7099..d3ee061 100644 --- a/crates/presentation/tests/api_test.rs +++ b/crates/presentation/tests/api_test.rs @@ -450,7 +450,6 @@ async fn test_app() -> Router { event_publisher: Arc::new(NoopEventPublisher), diary_exporter: Arc::new(PanicExporter), document_parser: Arc::new(PanicDocumentParser), - video_renderer: None, }, config: AppConfig { allow_registration: false, @@ -460,8 +459,6 @@ async fn test_app() -> Router { font_path: None, logo_path: None, bg_dir: None, - ffmpeg_path: "ffmpeg".into(), - max_concurrent_renders: 2, }, }, }, diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index cc20c8f..7718ef3 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -29,7 +29,6 @@ export = { workspace = true } tmdb-enrichment = { workspace = true } importer = { workspace = true } image-converter = { workspace = true } -wrapup-renderer = { workspace = true } nats = { workspace = true, optional = true } sqlx = { workspace = true } async-trait = { workspace = true } diff --git a/crates/worker/src/main.rs b/crates/worker/src/main.rs index f99b60f..48c10b7 100644 --- a/crates/worker/src/main.rs +++ b/crates/worker/src/main.rs @@ -107,38 +107,6 @@ async fn main() -> anyhow::Result<()> { event_publisher: event_publisher_arc, diary_exporter: Arc::new(ExportAdapter) as Arc, document_parser: Arc::new(ImporterDocumentParser) as Arc, - video_renderer: { - let wc = &app_config.wrapup; - let ffmpeg = &wc.ffmpeg_path; - if std::process::Command::new(ffmpeg) - .arg("-version") - .output() - .is_ok() - { - let renderer_cfg = wrapup_renderer::RendererConfig { - slide_duration_secs: 4, - transition_duration_secs: 0.8, - resolution: (1080, 1920), - ffmpeg_path: ffmpeg.clone(), - font_path: wc.font_path.clone(), - logo_path: wc.logo_path.clone(), - bg_dir: wc.bg_dir.clone(), - }; - match wrapup_renderer::FfmpegWrapUpRenderer::new(renderer_cfg) { - Ok(r) => { - tracing::info!("wrapup video renderer enabled (ffmpeg={ffmpeg})"); - Some(Arc::new(r) as Arc) - } - Err(e) => { - tracing::warn!("wrapup video renderer init failed: {e}"); - None - } - } - } else { - tracing::info!("wrapup video renderer disabled (ffmpeg not found)"); - None - } - }, }, config: app_config, };