remove wrapup video rendering (ffmpeg)
All checks were successful
CI / Check / Test (push) Successful in 15m34s
All checks were successful
CI / Check / Test (push) Successful in 15m34s
SPA handles wrapup visuals client-side; server-side renderer was dead code pulling in ffmpeg + image crates.
This commit is contained in:
@@ -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
|
||||
|
||||
421
Cargo.lock
generated
421
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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/*
|
||||
|
||||
|
||||
14
README.md
14
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.
|
||||
|
||||
@@ -94,7 +94,6 @@ graph TB
|
||||
subgraph Media["Media Processing"]
|
||||
A_IMG["image-converter<br/><i>AVIF/WebP</i>"]
|
||||
A_POSTER_SYNC["poster-sync"]
|
||||
A_WRAPUP_RENDER["wrapup-renderer<br/><i>ffmpeg video, slides</i>"]
|
||||
end
|
||||
subgraph Presentation["Presentation Helpers"]
|
||||
A_TEMPLATE["template-askama<br/><i>HTML templates</i>"]
|
||||
|
||||
@@ -152,11 +152,13 @@ impl ImportProfileRepository for SqliteImportProfileRepository {
|
||||
let ca: String = r.get("created_at");
|
||||
Ok(ImportProfile {
|
||||
id: ImportProfileId::from_uuid(
|
||||
id_str.parse::<uuid::Uuid>()
|
||||
id_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
),
|
||||
user_id: UserId::from_uuid(
|
||||
uid_str.parse::<uuid::Uuid>()
|
||||
uid_str
|
||||
.parse::<uuid::Uuid>()
|
||||
.map_err(|e| DomainError::InfrastructureError(e.to_string()))?,
|
||||
),
|
||||
name: r.get("name"),
|
||||
|
||||
@@ -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<u64, DomainError> {
|
||||
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())
|
||||
}
|
||||
|
||||
|
||||
@@ -481,5 +481,4 @@ pub struct WrapUpPageTemplate<'a> {
|
||||
pub genre_max: u32,
|
||||
pub rating_pcts: [f64; 5],
|
||||
pub genre_pcts: Vec<f64>,
|
||||
pub video_url: Option<String>,
|
||||
}
|
||||
|
||||
@@ -10,9 +10,6 @@
|
||||
{% if report.total_watch_time_minutes > 0 %}
|
||||
<div class="wu-detail">{{ watch_time_display }} of watch time</div>
|
||||
{% endif %}
|
||||
{% if let Some(url) = video_url %}
|
||||
<a href="{{ url }}" class="wu-video-link" download>Download Video</a>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="wu-section">
|
||||
|
||||
@@ -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"
|
||||
@@ -1,52 +0,0 @@
|
||||
use domain::errors::DomainError;
|
||||
use tokio::process::Command;
|
||||
|
||||
pub async fn stitch_slides(
|
||||
slides: &[Vec<u8>],
|
||||
ffmpeg_path: &str,
|
||||
slide_duration_secs: u32,
|
||||
resolution: (u32, u32),
|
||||
) -> Result<Vec<u8>, 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()))
|
||||
}
|
||||
@@ -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<String>,
|
||||
pub logo_path: Option<String>,
|
||||
pub bg_dir: Option<String>,
|
||||
}
|
||||
|
||||
pub struct FfmpegWrapUpRenderer {
|
||||
config: RendererConfig,
|
||||
slide_renderer: slides::SlideRenderer,
|
||||
}
|
||||
|
||||
impl FfmpegWrapUpRenderer {
|
||||
pub fn new(config: RendererConfig) -> Result<Self, DomainError> {
|
||||
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<Vec<u8>, 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
|
||||
}
|
||||
}
|
||||
@@ -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<DynamicImage, String> {
|
||||
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<u8> = Rgba([26, 26, 36, 255]);
|
||||
const GOLD: Rgba<u8> = Rgba([229, 192, 52, 255]);
|
||||
const WHITE: Rgba<u8> = Rgba([255, 255, 255, 255]);
|
||||
const DIM: Rgba<u8> = Rgba([255, 255, 255, 140]);
|
||||
const BAR_BG: Rgba<u8> = Rgba([50, 50, 65, 255]);
|
||||
const GLASS: Rgba<u8> = Rgba([20, 20, 30, 180]);
|
||||
const GLASS_PADDING: u32 = 30;
|
||||
|
||||
pub struct SlideRenderer {
|
||||
font: FontArc,
|
||||
logo: Option<RgbaImage>,
|
||||
bg_paths: Vec<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
impl SlideRenderer {
|
||||
pub fn new(
|
||||
font_path: Option<&str>,
|
||||
logo_path: Option<&str>,
|
||||
bg_dir: Option<&str>,
|
||||
) -> Result<Self, DomainError> {
|
||||
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<RgbaImage> {
|
||||
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<RgbaImage> {
|
||||
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<u8>,
|
||||
) {
|
||||
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<u8>,
|
||||
) {
|
||||
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<u8>)]) -> 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<u8>)],
|
||||
) -> 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<Vec<u8>, 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<Vec<u8>, 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<u8>)],
|
||||
w: u32,
|
||||
h: u32,
|
||||
) -> Result<Vec<u8>, 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<u8>)],
|
||||
w: u32,
|
||||
h: u32,
|
||||
) -> Result<Vec<u8>, 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<Vec<u8>, 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<u8>)],
|
||||
w: u32,
|
||||
h: u32,
|
||||
) -> Result<Vec<u8>, 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<u8>)],
|
||||
w: u32,
|
||||
h: u32,
|
||||
) -> Result<Vec<u8>, 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<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 load_system_font() -> Result<FontArc, DomainError> {
|
||||
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(),
|
||||
))
|
||||
}
|
||||
@@ -11,8 +11,6 @@ pub struct WrapUpConfig {
|
||||
pub font_path: Option<String>,
|
||||
pub logo_path: Option<String>,
|
||||
pub bg_dir: Option<String>,
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<dyn EventPublisher>,
|
||||
pub diary_exporter: Arc<dyn DiaryExporter>,
|
||||
pub document_parser: Arc<dyn DocumentParser>,
|
||||
pub video_renderer: Option<Arc<dyn WrapUpVideoRenderer>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -7,4 +7,3 @@ pub mod get_wrapup;
|
||||
pub mod handle_requested;
|
||||
pub mod list_wrapups;
|
||||
pub mod queries;
|
||||
pub mod storage;
|
||||
|
||||
@@ -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<dyn ObjectStorage>,
|
||||
}
|
||||
|
||||
impl WrapUpStorage {
|
||||
pub fn new(storage: Arc<dyn ObjectStorage>) -> 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<u8>)> {
|
||||
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<u8>)> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -587,19 +587,3 @@ pub trait WrapUpStatsQuery: Send + Sync {
|
||||
range: &DateRange,
|
||||
) -> Result<Vec<WrapUpMovieRow>, DomainError>;
|
||||
}
|
||||
|
||||
// ── Video renderer ──────────────────────────────────────────────────────────
|
||||
|
||||
pub struct VideoRenderAssets {
|
||||
pub poster_images: Vec<(String, Vec<u8>)>,
|
||||
pub cast_images: Vec<(String, Vec<u8>)>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait WrapUpVideoRenderer: Send + Sync {
|
||||
async fn render(
|
||||
&self,
|
||||
report: &WrapUpReport,
|
||||
assets: VideoRenderAssets,
|
||||
) -> Result<Vec<u8>, DomainError>;
|
||||
}
|
||||
|
||||
@@ -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<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;
|
||||
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<String>,
|
||||
) -> 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)
|
||||
}
|
||||
|
||||
@@ -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<dyn DiaryExporter>,
|
||||
document_parser: Arc::new(ImporterDocumentParser) as Arc<dyn DocumentParser>,
|
||||
video_renderer: None,
|
||||
},
|
||||
config: app_config,
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -427,10 +427,6 @@ fn api_routes(rate_limit: u64) -> Router<AppState> {
|
||||
"/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),
|
||||
|
||||
@@ -759,7 +759,6 @@ 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,
|
||||
@@ -769,8 +768,6 @@ pub fn make_test_state(auth_service: Arc<dyn AuthService>) -> crate::state::AppS
|
||||
font_path: None,
|
||||
logo_path: None,
|
||||
bg_dir: None,
|
||||
ffmpeg_path: "ffmpeg".into(),
|
||||
max_concurrent_renders: 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -107,38 +107,6 @@ 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: {
|
||||
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<dyn domain::ports::WrapUpVideoRenderer>)
|
||||
}
|
||||
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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user