Compare commits
3 Commits
c0b3fb6940
...
490bd97a40
| Author | SHA1 | Date | |
|---|---|---|---|
| 490bd97a40 | |||
| d45d8aa913 | |||
| f00a2cbbb8 |
518
Cargo.lock
generated
518
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
200
crates/adapters/template-askama/templates/wrapup.html
Normal file
200
crates/adapters/template-askama/templates/wrapup.html
Normal 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 %}
|
||||
15
crates/adapters/wrapup-renderer/Cargo.toml
Normal file
15
crates/adapters/wrapup-renderer/Cargo.toml
Normal 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"
|
||||
71
crates/adapters/wrapup-renderer/src/charts.rs
Normal file
71
crates/adapters/wrapup-renderer/src/charts.rs
Normal 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)
|
||||
}
|
||||
56
crates/adapters/wrapup-renderer/src/ffmpeg.rs
Normal file
56
crates/adapters/wrapup-renderer/src/ffmpeg.rs
Normal 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()))
|
||||
}
|
||||
49
crates/adapters/wrapup-renderer/src/lib.rs
Normal file
49
crates/adapters/wrapup-renderer/src/lib.rs
Normal 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
|
||||
}
|
||||
}
|
||||
97
crates/adapters/wrapup-renderer/src/slides.rs
Normal file
97
crates/adapters/wrapup-renderer/src/slides.rs
Normal 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)
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user