Compare commits

..

3 Commits

Author SHA1 Message Date
490bd97a40 feat: wire video renderer pipeline + download endpoint
Some checks failed
CI / Check / Test (push) Failing after 41s
2026-06-02 22:34:55 +02:00
d45d8aa913 feat: video renderer adapter w/ slides + charts + ffmpeg 2026-06-02 22:31:45 +02:00
f00a2cbbb8 feat: HTML wrap-up page with Askama template 2026-06-02 22:28:28 +02:00
21 changed files with 1291 additions and 13 deletions

518
Cargo.lock generated
View File

@@ -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"

View File

@@ -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" }

View File

@@ -425,3 +425,16 @@ pub enum ImportRowStatus {
Duplicate,
Invalid(String),
}
#[derive(Template)]
#[template(path = "wrapup.html")]
pub struct WrapUpPageTemplate<'a> {
pub ctx: &'a HtmlPageContext,
pub report: &'a domain::models::wrapup::WrapUpReport,
pub year_label: String,
pub watch_time_display: String,
pub rating_max: u32,
pub genre_max: u32,
pub rating_pcts: [f64; 5],
pub genre_pcts: Vec<f64>,
}

View File

@@ -0,0 +1,200 @@
{% extends "base.html" %}
{% block content %}
<div class="wu-container">
<section class="wu-section wu-hero">
<h1 class="wu-year">{{ year_label }}</h1>
<div class="wu-big-number">{{ report.total_movies }}</div>
<div class="wu-subtitle">movies watched</div>
{% if report.total_watch_time_minutes > 0 %}
<div class="wu-detail">{{ watch_time_display }} total watch time</div>
{% endif %}
</section>
<section class="wu-section">
<h2>Ratings</h2>
{% if let Some(avg) = report.avg_rating %}
<div class="wu-big-number">{{ avg|fmt("{:.1}") }}</div>
<div class="wu-subtitle">average rating</div>
{% endif %}
<div class="wu-rating-bars">
{% for i in 0..5 %}
<div class="wu-rating-row">
<span class="wu-star-label">{{ i + 1 }}★</span>
<div class="wu-bar-track">
<div class="wu-bar" style="width: {{ rating_pcts[i] }}%"></div>
</div>
<span class="wu-bar-count">{{ report.rating_distribution[i] }}</span>
</div>
{% endfor %}
</div>
{% if let Some(month) = report.busiest_month %}
<div class="wu-detail" style="margin-top:1rem">Busiest month: <strong>{{ month }}</strong></div>
{% endif %}
{% if let Some(day) = report.busiest_day_of_week %}
<div class="wu-detail">Favorite day: <strong>{{ day }}</strong></div>
{% endif %}
</section>
{% if !report.top_directors.is_empty() %}
<section class="wu-section">
<h2>Top Directors</h2>
<div class="wu-detail" style="margin-bottom:1rem">{{ report.director_diversity }} unique directors</div>
{% for d in report.top_directors.iter().take(5) %}
<div class="wu-stat-row">
<span class="wu-stat-name">{{ d.name }}</span>
<span class="wu-stat-count">{{ d.count }} films · {{ d.avg_rating|fmt("{:.1}") }}★</span>
</div>
{% endfor %}
</section>
{% endif %}
{% if !report.top_actors.is_empty() %}
<section class="wu-section">
<h2>Top Actors</h2>
<div class="wu-detail" style="margin-bottom:1rem">{{ report.actor_diversity }} unique actors</div>
{% for a in report.top_actors.iter().take(5) %}
<div class="wu-stat-row">
<span class="wu-stat-name">{{ a.name }}</span>
<span class="wu-stat-count">{{ a.count }} films · {{ a.avg_rating|fmt("{:.1}") }}★</span>
</div>
{% endfor %}
</section>
{% endif %}
{% if !report.top_genres.is_empty() %}
<section class="wu-section">
<h2>Genre Breakdown</h2>
<div class="wu-detail" style="margin-bottom:1rem">{{ report.genre_diversity }} genres explored</div>
{% for g in report.top_genres.iter().take(8).enumerate() %}
<div class="wu-stat-row">
<span class="wu-stat-name">{{ g.1.genre }}</span>
<span class="wu-stat-count">{{ g.1.count }}</span>
</div>
<div class="wu-bar-track">
<div class="wu-bar" style="width: {{ genre_pcts[g.0] }}%"></div>
</div>
{% endfor %}
{% if let Some(best) = report.highest_rated_genre %}
<div class="wu-detail" style="margin-top:1rem">Highest rated: <strong>{{ best }}</strong></div>
{% endif %}
{% if let Some(worst) = report.lowest_rated_genre %}
<div class="wu-detail">Lowest rated: <strong>{{ worst }}</strong></div>
{% endif %}
</section>
{% endif %}
<section class="wu-section">
<h2>Highlights</h2>
<div class="wu-highlight-grid">
{% if let Some(m) = report.highest_rated_movie %}
<div class="wu-highlight-card">
<div class="wu-highlight-label">Highest Rated</div>
{% if let Some(p) = m.poster_path %}
<img src="{{ p|poster_src }}" class="wu-highlight-poster" alt="{{ m.title }}">
{% endif %}
<div class="wu-highlight-title">{{ m.title }}</div>
<div class="wu-detail">{{ m.year }}</div>
</div>
{% endif %}
{% if let Some(m) = report.lowest_rated_movie %}
<div class="wu-highlight-card">
<div class="wu-highlight-label">Lowest Rated</div>
{% if let Some(p) = m.poster_path %}
<img src="{{ p|poster_src }}" class="wu-highlight-poster" alt="{{ m.title }}">
{% endif %}
<div class="wu-highlight-title">{{ m.title }}</div>
<div class="wu-detail">{{ m.year }}</div>
</div>
{% endif %}
{% if let Some(m) = report.oldest_movie %}
<div class="wu-highlight-card">
<div class="wu-highlight-label">Oldest</div>
{% if let Some(p) = m.poster_path %}
<img src="{{ p|poster_src }}" class="wu-highlight-poster" alt="{{ m.title }}">
{% endif %}
<div class="wu-highlight-title">{{ m.title }}</div>
<div class="wu-detail">{{ m.year }}</div>
</div>
{% endif %}
{% if let Some(m) = report.newest_movie %}
<div class="wu-highlight-card">
<div class="wu-highlight-label">Newest</div>
{% if let Some(p) = m.poster_path %}
<img src="{{ p|poster_src }}" class="wu-highlight-poster" alt="{{ m.title }}">
{% endif %}
<div class="wu-highlight-title">{{ m.title }}</div>
<div class="wu-detail">{{ m.year }}</div>
</div>
{% endif %}
{% if let Some(m) = report.longest_movie %}
<div class="wu-highlight-card">
<div class="wu-highlight-label">Longest</div>
{% if let Some(p) = m.poster_path %}
<img src="{{ p|poster_src }}" class="wu-highlight-poster" alt="{{ m.title }}">
{% endif %}
<div class="wu-highlight-title">{{ m.title }}</div>
{% if let Some(rt) = m.runtime_minutes %}
<div class="wu-detail">{{ rt }} min</div>
{% endif %}
</div>
{% endif %}
{% if let Some(m) = report.shortest_movie %}
<div class="wu-highlight-card">
<div class="wu-highlight-label">Shortest</div>
{% if let Some(p) = m.poster_path %}
<img src="{{ p|poster_src }}" class="wu-highlight-poster" alt="{{ m.title }}">
{% endif %}
<div class="wu-highlight-title">{{ m.title }}</div>
{% if let Some(rt) = m.runtime_minutes %}
<div class="wu-detail">{{ rt }} min</div>
{% endif %}
</div>
{% endif %}
{% if let Some(m) = report.first_movie_of_period %}
<div class="wu-highlight-card">
<div class="wu-highlight-label">First Watched</div>
{% if let Some(p) = m.poster_path %}
<img src="{{ p|poster_src }}" class="wu-highlight-poster" alt="{{ m.title }}">
{% endif %}
<div class="wu-highlight-title">{{ m.title }}</div>
<div class="wu-detail">{{ m.year }}</div>
</div>
{% endif %}
{% if let Some(m) = report.last_movie_of_period %}
<div class="wu-highlight-card">
<div class="wu-highlight-label">Last Watched</div>
{% if let Some(p) = m.poster_path %}
<img src="{{ p|poster_src }}" class="wu-highlight-poster" alt="{{ m.title }}">
{% endif %}
<div class="wu-highlight-title">{{ m.title }}</div>
<div class="wu-detail">{{ m.year }}</div>
</div>
{% endif %}
</div>
</section>
{% if report.total_rewatches > 0 %}
<section class="wu-section">
<h2>Rewatches</h2>
<div class="wu-big-number">{{ report.total_rewatches }}</div>
<div class="wu-subtitle">movies rewatched</div>
{% if let Some(m) = report.most_rewatched_movie %}
<div class="wu-detail" style="margin-top:1rem">Most rewatched: <strong>{{ m.title }}</strong> ({{ m.year }})</div>
{% endif %}
</section>
{% endif %}
{% if !report.poster_paths.is_empty() %}
<section class="wu-section">
<h2>Your Year in Posters</h2>
<div class="wu-poster-mosaic">
{% for path in report.poster_paths.iter() %}
<img src="{{ path|poster_src }}" alt="" loading="lazy">
{% endfor %}
</div>
</section>
{% endif %}
</div>
{% endblock %}

View File

@@ -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"

View File

@@ -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<Vec<u8>, DomainError> {
let mut buf = vec![0u8; (width * height * 3) as usize];
{
let root =
BitMapBackend::with_buffer(&mut buf, (width, height)).into_drawing_area();
root.fill(&RGBColor(26, 26, 36))
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
let max_count = report
.top_genres
.iter()
.map(|g| g.count)
.max()
.unwrap_or(1);
let mut chart = ChartBuilder::on(&root)
.margin(40)
.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)
}

View File

@@ -0,0 +1,56 @@
use domain::errors::DomainError;
use domain::ports::VideoRenderConfig;
use tokio::process::Command;
pub async fn stitch_slides(
slides: &[Vec<u8>],
config: &VideoRenderConfig,
) -> Result<Vec<u8>, 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()))
}

View File

@@ -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<u8>)>,
config: &VideoRenderConfig,
) -> Result<Vec<u8>, 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
}
}

View File

@@ -0,0 +1,97 @@
use domain::errors::DomainError;
use domain::models::wrapup::WrapUpReport;
use image::{Rgba, RgbaImage};
const BG_COLOR: Rgba<u8> = Rgba([26, 26, 36, 255]); // dark blue-gray
const _PRIMARY: Rgba<u8> = Rgba([229, 192, 52, 255]); // gold #e5c034
const _TEXT_COLOR: Rgba<u8> = Rgba([255, 255, 255, 255]); // white
fn to_png(img: &RgbaImage) -> Result<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, DomainError> {
let img = fill_background(width, height);
to_png(&img)
}
pub fn render_directors_slide(
_report: &WrapUpReport,
width: u32,
height: u32,
) -> Result<Vec<u8>, DomainError> {
let img = fill_background(width, height);
to_png(&img)
}
pub fn render_actors_slide(
_report: &WrapUpReport,
width: u32,
height: u32,
) -> Result<Vec<u8>, DomainError> {
let img = fill_background(width, height);
to_png(&img)
}
pub fn render_highlights_slide(
_report: &WrapUpReport,
width: u32,
height: u32,
) -> Result<Vec<u8>, DomainError> {
let img = fill_background(width, height);
to_png(&img)
}
pub fn render_mosaic_slide(
posters: &[(String, Vec<u8>)],
width: u32,
height: u32,
) -> Result<Vec<u8>, 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)
}

View File

@@ -6,7 +6,8 @@ use domain::ports::{
MovieRepository, PasswordHasher, PersonCommand, PersonQuery, PosterFetcherClient,
RemoteWatchlistRepository, ReviewRepository, SearchCommand, SearchPort, SocialQueryPort,
StatsRepository, UserProfileFieldsRepository, UserRepository, WatchEventRepository,
WatchlistRepository, WrapUpRepository, WrapUpStatsQuery, WebhookTokenRepository,
WatchlistRepository, WrapUpRepository, WrapUpStatsQuery, WrapUpVideoRenderer,
WebhookTokenRepository,
};
use crate::config::AppConfig;
@@ -45,6 +46,7 @@ pub struct Services {
pub event_publisher: Arc<dyn EventPublisher>,
pub diary_exporter: Arc<dyn DiaryExporter>,
pub document_parser: Arc<dyn DocumentParser>,
pub video_renderer: Option<Arc<dyn WrapUpVideoRenderer>>,
}
#[derive(Clone)]

View File

@@ -166,6 +166,7 @@ impl TestContextBuilder {
event_publisher: self.event_publisher,
diary_exporter: self.diary_exporter,
document_parser: self.document_parser,
video_renderer: None,
},
config: self.config,
}

View File

@@ -2,7 +2,8 @@ use crate::context::AppContext;
use crate::wrapup::{compute, queries::ComputeWrapUpQuery};
use domain::errors::DomainError;
use domain::events::DomainEvent;
use domain::models::wrapup::{DateRange, WrapUpScope, WrapUpStatus};
use domain::models::wrapup::{DateRange, WrapUpReport, WrapUpScope, WrapUpStatus};
use domain::ports::VideoRenderConfig;
use domain::value_objects::WrapUpId;
pub async fn execute(
@@ -34,6 +35,29 @@ pub async fn execute(
let json = serde_json::to_string(&report)
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?;
ctx.repos.wrapup_repo.set_complete(&wrapup_id, &json).await?;
// Optionally render video (non-fatal)
if let Some(ref renderer) = ctx.services.video_renderer {
let poster_images = resolve_poster_images(ctx, &report).await;
let config = VideoRenderConfig {
slide_duration_secs: 4,
transition_duration_secs: 0.8,
resolution: (1080, 1920),
ffmpeg_path: "ffmpeg".to_string(),
};
match renderer.render(&report, poster_images, &config).await {
Ok(video_bytes) => {
let video_key = format!("wrapups/{}/video.mp4", wrapup_id.value());
if let Err(e) = ctx.services.image_storage.store(&video_key, &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 })
@@ -49,3 +73,14 @@ pub async fn execute(
}
}
}
async fn resolve_poster_images(ctx: &AppContext, report: &WrapUpReport) -> Vec<(String, Vec<u8>)> {
let mut images = Vec::new();
for path in report.poster_paths.iter().take(20) {
match ctx.services.image_storage.get(path).await {
Ok(bytes) => images.push((path.clone(), bytes)),
Err(_) => {}
}
}
images
}

View File

@@ -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<Vec<WrapUpMovieRow>, 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<u8>)>,
config: &VideoRenderConfig,
) -> Result<Vec<u8>, DomainError>;
}

View File

@@ -1,6 +1,6 @@
use axum::{
Json,
extract::{Path, State},
extract::{Extension, Path, State},
http::StatusCode,
response::IntoResponse,
};
@@ -13,10 +13,16 @@ use application::wrapup::{
list_wrapups::{self, ListWrapUpsQuery},
};
use domain::errors::DomainError;
use domain::models::wrapup::{WrapUpRecord, WrapUpStatus};
use domain::models::wrapup::{WrapUpRecord, WrapUpReport, WrapUpStatus};
use domain::value_objects::WrapUpId;
use crate::{errors::ApiError, extractors::AuthenticatedUser, state::AppState};
use crate::{
csrf::CsrfToken,
errors::ApiError,
extractors::{AuthenticatedUser, OptionalCookieUser},
render::render_page,
state::AppState,
};
use api_types::wrapup::{
GenerateWrapUpRequest, WrapUpGeneratedResponse, WrapUpListResponse, WrapUpStatusResponse,
};
@@ -145,3 +151,152 @@ pub async fn get_report(
Err(e) => crate::errors::domain_error_response(e),
}
}
#[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<AppState>,
Path(id): Path<Uuid>,
) -> 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; // used only for status check
let video_key = format!("wrapups/{}/video.mp4", id);
match state.app_ctx.services.image_storage.get(&video_key).await {
Ok(bytes) => (
StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "video/mp4"),
(axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"wrapup.mp4\"")],
bytes,
).into_response(),
Err(_) => StatusCode::NOT_FOUND.into_response(),
}
}
// ── HTML handlers ───────────────────────────────────────────────────────────
fn format_watch_time(minutes: u32) -> String {
let h = minutes / 60;
let m = minutes % 60;
if h > 0 && m > 0 {
format!("{}h {}m", h, m)
} else if h > 0 {
format!("{}h", h)
} else {
format!("{}m", m)
}
}
fn render_wrapup(
report: &WrapUpReport,
year: i32,
ctx: &application::ports::HtmlPageContext,
) -> axum::response::Response {
let rating_max = report.rating_distribution.iter().copied().max().unwrap_or(1).max(1);
let rating_pcts: [f64; 5] = std::array::from_fn(|i| {
report.rating_distribution[i] as f64 / rating_max as f64 * 100.0
});
let genre_max = report.top_genres.first().map(|g| g.count).unwrap_or(1).max(1);
let genre_pcts: Vec<f64> = report
.top_genres
.iter()
.take(8)
.map(|g| g.count as f64 / genre_max as f64 * 100.0)
.collect();
let tmpl = template_askama::WrapUpPageTemplate {
ctx,
report,
year_label: year.to_string(),
watch_time_display: format_watch_time(report.total_watch_time_minutes),
rating_max,
genre_max,
rating_pcts,
genre_pcts,
};
render_page(tmpl)
}
pub async fn get_user_wrapup_html(
OptionalCookieUser(viewer): OptionalCookieUser,
State(state): State<AppState>,
Path((user_id, year)): Path<(Uuid, i32)>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let start = match NaiveDate::from_ymd_opt(year, 1, 1) {
Some(d) => d,
None => return StatusCode::BAD_REQUEST.into_response(),
};
let end = match NaiveDate::from_ymd_opt(year + 1, 1, 1) {
Some(d) => d,
None => return StatusCode::BAD_REQUEST.into_response(),
};
let record = match state
.app_ctx
.repos
.wrapup_repo
.find_existing(Some(user_id), start, end)
.await
{
Ok(Some(r)) if r.status == WrapUpStatus::Ready => r,
_ => return StatusCode::NOT_FOUND.into_response(),
};
let report: WrapUpReport = match &record.report_json {
Some(json) => match serde_json::from_str(json) {
Ok(r) => r,
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
},
None => return StatusCode::NOT_FOUND.into_response(),
};
let ctx = super::html::build_page_context(&state, viewer, csrf.0).await;
render_wrapup(&report, year, &ctx)
}
pub async fn get_global_wrapup_html(
OptionalCookieUser(viewer): OptionalCookieUser,
State(state): State<AppState>,
Path(year): Path<i32>,
Extension(csrf): Extension<CsrfToken>,
) -> impl IntoResponse {
let start = match NaiveDate::from_ymd_opt(year, 1, 1) {
Some(d) => d,
None => return StatusCode::BAD_REQUEST.into_response(),
};
let end = match NaiveDate::from_ymd_opt(year + 1, 1, 1) {
Some(d) => d,
None => return StatusCode::BAD_REQUEST.into_response(),
};
let record = match state
.app_ctx
.repos
.wrapup_repo
.find_existing(None, start, end)
.await
{
Ok(Some(r)) if r.status == WrapUpStatus::Ready => r,
_ => return StatusCode::NOT_FOUND.into_response(),
};
let report: WrapUpReport = match &record.report_json {
Some(json) => match serde_json::from_str(json) {
Ok(r) => r,
Err(_) => return StatusCode::INTERNAL_SERVER_ERROR.into_response(),
},
None => return StatusCode::NOT_FOUND.into_response(),
};
let ctx = super::html::build_page_context(&state, viewer, csrf.0).await;
render_wrapup(&report, year, &ctx)
}

View File

@@ -205,6 +205,7 @@ async fn wire_dependencies() -> anyhow::Result<(AppState, axum::Router)> {
event_publisher: event_publisher_arc,
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>,
document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>,
video_renderer: None,
},
config: app_config,
};

View File

@@ -7,6 +7,7 @@ use utoipa::OpenApi;
crate::handlers::wrapup::get_list,
crate::handlers::wrapup::get_status,
crate::handlers::wrapup::get_report,
crate::handlers::wrapup::get_video,
),
components(schemas(
api_types::wrapup::GenerateWrapUpRequest,

View File

@@ -163,6 +163,14 @@ fn html_routes(rate_limit: u64) -> Router<AppState> {
.route(
"/watch-queue/{id}/dismiss",
routing::post(handlers::html::post_dismiss_single),
)
.route(
"/wrapups/{user_id}/{year}",
routing::get(handlers::wrapup::get_user_wrapup_html),
)
.route(
"/wrapups/global/{year}",
routing::get(handlers::wrapup::get_global_wrapup_html),
);
#[cfg(feature = "federation")]
@@ -356,6 +364,10 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
.route(
"/wrapups/{id}/report",
routing::get(handlers::wrapup::get_report),
)
.route(
"/wrapups/{id}/video",
routing::get(handlers::wrapup::get_video),
);
#[cfg(feature = "federation")]

View File

@@ -651,6 +651,7 @@ pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> 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,

View File

@@ -427,6 +427,7 @@ 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,

View File

@@ -104,6 +104,7 @@ async fn main() -> anyhow::Result<()> {
event_publisher: event_publisher_arc,
diary_exporter: Arc::new(ExportAdapter) as Arc<dyn DiaryExporter>,
document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>,
video_renderer: None,
},
config: app_config,
};

View File

@@ -1184,3 +1184,46 @@ form button[type="submit"]:hover {
.movie-title-link:hover {
text-decoration: underline;
}
/* ── Wrap-up ─────────────────────────────────────────────────────────── */
.wu-container { max-width: 600px; margin: 0 auto; scroll-snap-type: y proximity; }
.wu-section {
min-height: 80vh;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
text-align: center;
padding: 2rem;
scroll-snap-align: start;
animation: wu-fade-in 0.6s ease-out both;
}
.wu-hero { min-height: 60vh; }
.wu-year { font-size: 2.4rem; opacity: 0.5; margin-bottom: 0.5rem; }
.wu-big-number { font-size: 5rem; font-weight: 800; color: var(--primary); line-height: 1.1; }
.wu-subtitle { font-size: 1.4rem; opacity: 0.8; }
.wu-detail { font-size: 1rem; opacity: 0.6; margin-top: 0.5rem; }
.wu-section h2 { font-size: 1.8rem; margin-bottom: 1.5rem; color: var(--primary); }
.wu-stat-row { display: flex; justify-content: space-between; width: 100%; padding: 0.5rem 0; border-bottom: 1px solid rgba(255,255,255,0.1); }
.wu-stat-name { font-weight: 600; }
.wu-stat-count { opacity: 0.7; }
.wu-rating-bars { width: 100%; max-width: 400px; margin-top: 1.5rem; }
.wu-rating-row { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.4rem; }
.wu-star-label { width: 2rem; text-align: right; font-size: 0.9rem; opacity: 0.7; }
.wu-bar-track { flex: 1; height: 8px; background: rgba(255,255,255,0.08); border-radius: 4px; overflow: hidden; }
.wu-bar { height: 8px; background: var(--primary); border-radius: 4px; min-width: 2px; transition: width 0.4s ease; }
.wu-bar-count { width: 2rem; font-size: 0.85rem; opacity: 0.6; }
.wu-highlight-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; width: 100%; }
.wu-highlight-card { background: rgba(255,255,255,0.05); border-radius: 12px; padding: 1rem; text-align: left; }
.wu-highlight-label { font-size: 0.8rem; opacity: 0.6; text-transform: uppercase; letter-spacing: 0.05em; }
.wu-highlight-title { font-weight: 700; margin-top: 0.25rem; }
.wu-highlight-poster { width: 100%; border-radius: 6px; aspect-ratio: 2/3; object-fit: cover; margin: 0.5rem 0; }
.wu-poster-mosaic { display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 4px; width: 100%; }
.wu-poster-mosaic img { width: 100%; border-radius: 4px; aspect-ratio: 2/3; object-fit: cover; }
@keyframes wu-fade-in { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: none; } }
.wu-section:nth-child(2) { animation-delay: 0.1s; }
.wu-section:nth-child(3) { animation-delay: 0.2s; }
.wu-section:nth-child(4) { animation-delay: 0.3s; }
.wu-section:nth-child(5) { animation-delay: 0.4s; }
@media (max-width: 480px) {
.wu-big-number { font-size: 3.5rem; }
.wu-highlight-grid { grid-template-columns: 1fr; }
}