diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..465aade --- /dev/null +++ b/poetry.lock @@ -0,0 +1,779 @@ +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. + +[[package]] +name = "ast-serialize" +version = "0.8.0" +description = "Python bindings for mypy AST serialization" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ast_serialize-0.8.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3d822605fa7bb326ef868d25fafced7fc660fa46d9b90c02ea86d5e2f5d325f7"}, + {file = "ast_serialize-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:2efa40b068197d5efb62655b43baadb842ed71c4958cccd3e8b86a35726f0119"}, + {file = "ast_serialize-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:db1b957291bca08c7e72f43a12357b2948e20775d970e3fc3dac0aa3160ab725"}, + {file = "ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdc0d5b18ff8fb364e87923e47c0a91d0d69dbcaeaa274591f7fd26892cc3a3a"}, + {file = "ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9da7330f3e235bf7da89b8d39205c6350fc0c08a85379743f2df9fff87d6d980"}, + {file = "ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3186969ee66a9863b00acc6523ace44c56974eecb348a7ea4b228d9f0b80e19"}, + {file = "ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40a57b73731be45da4fa41430c4d5dc94a24b3a4faba7b9e069978c0402064ea"}, + {file = "ast_serialize-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5075b9da3ef807eda752502446dfecea3b381c4900b7e27a5d5f4f899eb39951"}, + {file = "ast_serialize-0.8.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:293cc1c5bfa741f8e3fbe8175b9c07beee487c9a6fdbb25a5acad9f1df2d30a9"}, + {file = "ast_serialize-0.8.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0910c3442a75216dde0f102d854ba2aaa71d2482e0ee213630b9bf29584fba3"}, + {file = "ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43dd6d596879bb1cb8a12cc9dae7bb10090a39a35883026c24f82488a195619a"}, + {file = "ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8c9d537f59e936392cfd3597789d1390304dd659efc3c486ce7f40fb6b8a9f53"}, + {file = "ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f0190a33d7f97c65e9069f7a7f40499eea6b5cbe260c558378109caf20ce934b"}, + {file = "ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:77308ae6c5cf5264cc0f01a7c556ec77a9e68eb1f61b093534d698139fdc3b14"}, + {file = "ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8d53a23f27e1ed3a36b2d26fd2a1a6228c8e85a1ed62ff7cdb44bd610769f20a"}, + {file = "ast_serialize-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ffa5e7cb08f96fed9121f77b224151e41caf88feab9d652bb46c78202b6fbeda"}, + {file = "ast_serialize-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:fa70ed4dea0bb18b30a1789c77baa701d0ef30c474f2ccabdea61e25623a8827"}, + {file = "ast_serialize-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d8b3c8eee4c1baef9d4e84d2a59a805501617127be42615cb48970b15b0892b6"}, + {file = "ast_serialize-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:ac4f0a83c55a9b782f79ad55a5247b7db123c1db405959791c2ef886e9710c9f"}, + {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:86b8a1e6d90467345356098b040150e82fbc26d24a7a202224b13dc1f6264ca0"}, + {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:39e92ff8e8cb45947fe9007174b2950e1fb098e6abd00266a13cd3bcf6675068"}, + {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c85d8d18db5b2dfcb3b7e38a4d600ca35504c0ed8a6f75cd1c811e4ffe248a15"}, + {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9830ff7e764f74d9eefb01170c61a9f0fd2c027dac5fcb72e064decd57d56371"}, + {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6479d9722a4cd21b578f5478074c41e6169f04811996ec881655560f703a5bba"}, + {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a63bed264e818cd83eec11feed0f50aa162542b91132ef58afebc857182763a5"}, + {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d187197d234aa45d6cfa2b096be5f666e8cc2e7eb3722d0ab8926293cf5720c"}, + {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:2d39a56282cfcc0d8eeea37267c754be59c98d48505c23b1dae5c6011f3813dd"}, + {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f7cc5f10386994c0f4844f1e6d6a97127e9b478660eb6dec2b257644f0acab64"}, + {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6102f2f985c2e542be85cd857678ec9356fefa792b93cadfadd31139f5696f27"}, + {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:3a8660fe66667b76a6e9dccd1d33e66b229fde3b308db991c041609226c005b6"}, + {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:e7266307e5fba39836edb79def8608887af48820508bff3c5f2941e1e04d1534"}, + {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca7e6fd1ad845d1cc649dc2ecd499db2f8f46af5bf8da7b70dd858774cc038b"}, + {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:2880350b13d3eae69a0d70bc1fb6c9bfaca4dbd0e20ba8cd1aa483080b56ff06"}, + {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:ab0f9a59f7d63d0d441b56b9a818b273705264352d5115cfee12e940e816d958"}, + {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:0485a25ef519c62e749ee3c1ad8070e591b380d67226349eb5a70b228dc1ac4a"}, + {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:bd84d60bca7079e741be4ac5dbe237751a59d7f6f9f0126b11880d63822cbe16"}, + {file = "ast_serialize-0.8.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:057769b5921336eb2d9124f2a731b42ed05ffdac559b840dbdf6f3937cf153dc"}, + {file = "ast_serialize-0.8.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:a02cbed7d8bfdcdee88edaac12bd50d53d9953aaa2e1852ef078625be5f1c0b5"}, + {file = "ast_serialize-0.8.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:e1bd223df0f6c96b396975fa604cb33bce53d9b4a0185490be4c4a289f7c9c87"}, + {file = "ast_serialize-0.8.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ddd3b61f45c132da66c5476b281891e08c1fd87fbdabe8a6973e1622efc85f06"}, + {file = "ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f9caa63fad8241257ae401b5ff0a64026c6adb36b8e86cbe8782d9ea505daf6"}, + {file = "ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3926fa117b5e65019853a2969966d11c7175af377a3425991f3fe73784412405"}, + {file = "ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:485f1113af805e9e170b95ef993ca3fbd4f89c04bab25c58b4fc632d854801ab"}, + {file = "ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccebbed24f1281062d5852353c72c47502955926cfcb8345ffb3a44d87ff3d3"}, + {file = "ast_serialize-0.8.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:252f883290d1cdb728eb7fe1d9a7221b88af5a329aae0bc91ddee4dafb820331"}, + {file = "ast_serialize-0.8.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:96abc072ad29db8d02194afd47d68987322622787daceae82398d7b69f3ba2e6"}, + {file = "ast_serialize-0.8.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9118ad3e369727060b2696fc4078f250ecffca4248ba87f537f55cea9f9dce06"}, + {file = "ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f359df4bd921918af8bebd142a376c77511d7151cc8ba852760b587b5a4a54f3"}, + {file = "ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e94f9121d13fa36cbf21314783c77d05ae3a0868decd18cf5233fdcc6de49ac8"}, + {file = "ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:54f95b486018d262bcb387a9afd96f0da74508b442762b80c769454a6fbb3ee3"}, + {file = "ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c38b915511e32bc718c49dbce98ff9af36bac0ad6a604f58000cd5e3aecdba7"}, + {file = "ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a2ef9cf12f2de4f1028c42c1dd7d775255e0fb3e5bb48896c97e35ef52366fe"}, + {file = "ast_serialize-0.8.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6f18048fe9f6dd266bd577cdec48bdcecb74faaa01fe941324435483b013ed2a"}, + {file = "ast_serialize-0.8.0-cp39-abi3-win32.whl", hash = "sha256:31883542dd6c94d178f5db3d32fbd69c5eb88b3a7c018e7ac8cc0c45195ddbed"}, + {file = "ast_serialize-0.8.0-cp39-abi3-win_amd64.whl", hash = "sha256:861794565b06337005c1447ef23103a3d5a627d08bdc827870d00d0b28ef5f51"}, + {file = "ast_serialize-0.8.0-cp39-abi3-win_arm64.whl", hash = "sha256:b2a5978662fd4db463dfb4b974d2b10ac6430b98f5333aabc7051909df3561d0"}, + {file = "ast_serialize-0.8.0.tar.gz", hash = "sha256:6c37c43e4004dfb42d321ddedc569dc17ff4259296f3af577c9ea46a809bc010"}, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775"}, + {file = "certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "charset_normalizer-3.5.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1ee1e296209fdce05b81b663250eefa02213a2da7b41bf26f7829b8ba3545aa"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9fbdce1e47394b09bc9f26ab117dfc8d6491977a11d86f592bb42c779db2fda"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:00668ebb0609751758682eb0b5857e7c35b9f00e84dfdef062e103244ec94d45"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba2f37ee79e6338845261a3c5b1784e5d1acdff2c0785b284f1b633033d136ab"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ce854f5f478050ade5a238731c4ca985a7d3b3cb53ff600a9b5c3b689b5f0a7a"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96eefc178f8636b9c760c5829345307fd81cfae9ab1e80997dbddeb0f54ee9a3"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:366ec70f5547c640d3ce1985722490f23faf4eb5216a7eeba78277490e78dacb"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:950f23cb393f85543777b0433f082cddd25b51ab398eac7971146495679efe5f"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1dcc36dcb96abc02236e182d17e0f71430152a6c2c7447421da2d2dc144edea"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:07ffd07412fc5d5e84cd8952acf9ff7e4ed7a708e69d1bada19d8ba91711353f"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:f5542f9b941279d82d41eb0aa9f98eba36fe4df5c7086c651df7944935b37182"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a545775cfe815855ea32d7c27731d79da358ef2055b4a25830231b1622dd18aa"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:494b70049a4d69aec6e8137c13af4cf8db8c9f9820a1392ac293b0dd2987a818"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-win32.whl", hash = "sha256:94fbf1c0c6cc0d3d5e50f9a9313a8cdca90dd696d34b381cd1704f8c9e939f20"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:be47f99644b208bff7766314013f9acf57b056b04191d570d68ad14022cf5b1d"}, + {file = "charset_normalizer-3.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:a6d095662e73e74f0a49988e0593373e243e3a52e27bfeea0a859e88acf4a0f5"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eda059b6bc8bc0812d626fd91a7ce01bf583df0a61296eff390fd94141a34e30"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa2bb0b37202dca27175591f761108b5d34096ade1191ffe4808bdf6b1571488"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b2b1b3fa5670c127b246df1d0c059defd41f689a868a3b9d79df9b1cac42d22"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e5e4d73d588ca5ed09df1b7dcd1b203d1df3c542e3f50d126c947d432b10731"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b54e7e13267d49ffbfe68e25b3cbd774dab38fa37238f71265e91b36146eb21c"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7b742bf31c88566b4bb6335a7f393bb322e580b6bb98df7bd0c25e6e3519ce8"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ba32c4d2abf1d2fe7cf27d280f4cca5664233b0f885549c7761719eb977f486"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0722590aabf9dc6a6c0343d523c05458fa2b5047dbe6302fd526bb570600753f"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:aa1099b956fb795e686d073568f6dc002a0bb89765ea6d5b055dd7d9bf1b116c"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bd6c173f04743d483881bffa1478d5a4624475b8cd1d2194956a75548e191c18"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f298e218441525d3794428b4c8b8fb8662c6d3ea79925d4807ee6b9a96a3bca5"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:6e2912d4babbc65196ac13c2f53468dc57fb8b9c25ef913e8c59ddf7c6dc0e1b"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3d27167433c0d5f18dc850f07d0b3816221984fecdc405d6c157a6f0b8f8e9e6"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-win32.whl", hash = "sha256:ac00177c4831ffa650f8609e4bdddd5fe09c03b1c0c47acece7e6ea20421598b"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:f9b1e28d0e8dbfa858abdba91d6b547beaf2df1a59bec6da6faae7b96a4991a9"}, + {file = "charset_normalizer-3.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:ae31a1a1db2ee6cc2942fccaf695c934bc7f3db9f2133a3fef1f367cf1a4ab10"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2"}, + {file = "charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6"}, + {file = "charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b"}, + {file = "charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00"}, + {file = "charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712"}, + {file = "charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb"}, + {file = "charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959"}, + {file = "charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:85de3134b5379856e323ba37c19c9256d39425f7b76a63af52b09fb4664c2e8f"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3e5e1224c0a6a90e05843e07adfec669edebec17801c67072f51e59561d63c0b"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5e2d0e146dcb57034f8b97dc58d2d512cb90aba253960ce449f695fec6a82c6f"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e06efa066f7dbadbc84ebc126a97c452a6451dfcf589d89d788484949e1cf795"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:994e883d17c559cdfd38c84003c8b27d25424a1077272a17e7cd27bfe0bf57b2"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:789b8982559ae28dad2356519f841655756cdcd96616410590ae0b17454ee64f"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a951ad59cad9145664a730d3036b40b844e74d2d3683da40111463cd3a83845d"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:55261ac0d2941c42f196dd576f543d87a8ee03cd6f5e30dfb4d807b2e3b9121a"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5ca0555312ae2fe82715cada7fac375530c2f3349e1eaa1bcb33d0283ac79a18"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2e9cf9253119d8e5d111f05d71626786fd3d6193817316eab1ca088cdb8593cf"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:433c5a81eade63b47e522303bad236f59dba55ea6951746f5558355eeed8c75d"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:13e3afe97712e8887cd516e960c63f0b93122971e5b5e4b2622fe7701771e838"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb12fb2ba69ffa05f8695f61c69e591dc4b4a12ac3757ac8af8adb259bf56d17"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-win32.whl", hash = "sha256:56490c595a28b1bb27dfc583e816152a9767721ef58b2c03b13f954d2f707420"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-win_amd64.whl", hash = "sha256:012a22b88a77ca2e59b98ac5889b0deb604147666032f45e6d6e217634d2550d"}, + {file = "charset_normalizer-3.5.1-cp39-cp39-win_arm64.whl", hash = "sha256:29880d17a8eb0b5cfdfd8944b468322928059aa35f1f5fa8ff22b149ec0b42f8"}, + {file = "charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6"}, + {file = "charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3"}, +] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] +markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\""} + +[[package]] +name = "coloredlog" +version = "0.2.5" +description = "Colorize your console output." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "coloredlog-0.2.5-py3-none-any.whl", hash = "sha256:9334d73103cad8cb296248961710a8ce5a09ab63905905a81635fd7980538bb9"}, + {file = "coloredlog-0.2.5.tar.gz", hash = "sha256:f17d3b4ed8acb4df346edb4a5b3c619769cdc88d2b2f3c5023e3f9d0b869d732"}, +] + +[[package]] +name = "hexdump" +version = "3.3" +description = "dump binary data to hex format and restore from there" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "hexdump-3.3.zip", hash = "sha256:d781a43b0c16ace3f9366aade73e8ad3a7bd5137d58f0b45ab2d3f54876f20db"}, +] + +[[package]] +name = "idna" +version = "3.19" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4"}, + {file = "idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15"}, +] + +[package.extras] +all = ["coverage (>=7.10.0)", "hypothesis (>=6.141.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.16.0)", "ty (>=0.0.37)"] + +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "iso639-lang" +version = "2.6.3" +description = "A fast, comprehensive, ISO 639 library." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "iso639_lang-2.6.3-py3-none-any.whl", hash = "sha256:a6c2fb9f739dca180dc7f48b098880f303bcce2cdf93a4ca3152ed8bbbb94fbb"}, + {file = "iso639_lang-2.6.3.tar.gz", hash = "sha256:078ddb7cd0182dcc04367691acc8022ddf7158b6cb09f08f798af823fa864265"}, +] + +[[package]] +name = "librt" +version = "0.15.0" +description = "Mypyc runtime library" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "librt-0.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1a49adf16a7c9d9646816c2946135527197b6fcf4347c7b8b761cf1bfbf4489"}, + {file = "librt-0.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:81a398f45b45a59200e13cd5ad1ae1d3f44334de98b148331afe2cdfee701c52"}, + {file = "librt-0.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4eafbaff06b9563f8b1c850621ce51605de05208e09d4d71ce490bc972b7b9e8"}, + {file = "librt-0.15.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b0411b4066db926b80258c60dcb0e6db4c9cee312eab45b7e8866b17ddf9ada1"}, + {file = "librt-0.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:febb1ce6cac545a54e6b769982824e955a700fdd9fbf3a08a3d82c990968b57d"}, + {file = "librt-0.15.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b230acc1c3bfe2d6f2627ba2b95dc92e58aa494600e9722d0e6ccbc931e59702"}, + {file = "librt-0.15.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6da110e5f314c19ab8478464d02ae18808ae73d522c15260fa4918acdcd64da9"}, + {file = "librt-0.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:eab9208b00ca55bf75983ec99f7bf13acc746a36102e98953addaad7f7ea1e1b"}, + {file = "librt-0.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6c013cd3a1721e69e14380ada97eaa4b7b0cdf1c6b96fa765d4ea47c875088db"}, + {file = "librt-0.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:567b1c430f8bd560e689421468278ac5941bab4a05303b5d95b6ae10db03f451"}, + {file = "librt-0.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:29c4cab9df457b19672c39be7f384ebb2bc925c4e2684b8780c222b43eb36389"}, + {file = "librt-0.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bccbd8e5b0bffb7106cf18eb1baa3d7194b1cebb3b4b1cdbd4bdb19382a6ee6c"}, + {file = "librt-0.15.0-cp310-cp310-win32.whl", hash = "sha256:8ae493ed5f659a7761c43d42f183db514536073ded9bcf671d2d1df47e29a07e"}, + {file = "librt-0.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:bc25fb356d0c7810bb49ff3df908ad1fda6995d660ab099ded69244ed7ab6053"}, + {file = "librt-0.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:823b92cf3c18ecd08afc70c42473888b41b6e8ef5046f3b82c05c154a2fa3d22"}, + {file = "librt-0.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c70bc1b602cf59917e8f0c7a2cbc8bcc6fbc14d5486136b00707a79619121d63"}, + {file = "librt-0.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:814ff83a25b5fce8b9c80c4dd803153fb5c5599fc74db9e022466938368957ef"}, + {file = "librt-0.15.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57f5eeb6ad4c180de583b1038e61fe5fbd9796bb69a8a1c1a0c7ddbec4c8c60f"}, + {file = "librt-0.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82909c8f7eb9952656b65d3147afde4cf8e6d5a991eebc86418b5e65843b0ab8"}, + {file = "librt-0.15.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f779070399f991400fc451719e0ea388eb7de313388bada2c127a35de05f798a"}, + {file = "librt-0.15.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bac89069bc496ebdf4f79ebb57bbd10d0b214c8454225deb672d91002bd17e18"}, + {file = "librt-0.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e0d00c708fb2f5822b152429b1ac80a58dbbbc3f6c232c4d13a3f7fcf2ea5b4c"}, + {file = "librt-0.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6c6624fe268625869485553dd7cc1daf30d22558215bb2a4ff16f67a9801a31a"}, + {file = "librt-0.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f56b397858a23dacf35ede366ed2212fdc03a6a57a1ad36468ad6e9dc5fac091"}, + {file = "librt-0.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4388184646efe2054911c5b00a1077d6d1ee86a95b7e8ba96dc7850a809f3f40"}, + {file = "librt-0.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:97335f59082f9fe2ce6c2a9cc6433a0114bbb6cd4d5c09dd76c95c68b9f9a8b0"}, + {file = "librt-0.15.0-cp311-cp311-win32.whl", hash = "sha256:83380ffde38062a2e9bb55d83e74474f6614665528b98a6928720fc006dfffbb"}, + {file = "librt-0.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:f75720477ee05d509a310e856cacc8d909adc182f7b91193c207bcc26d7ee6db"}, + {file = "librt-0.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:256237037a3ab001ae8d9803b2d43562a4c3aa38739843694349e4d5ebb0fd56"}, + {file = "librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e"}, + {file = "librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d"}, + {file = "librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1"}, + {file = "librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa"}, + {file = "librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd"}, + {file = "librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa"}, + {file = "librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d"}, + {file = "librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656"}, + {file = "librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81"}, + {file = "librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d"}, + {file = "librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0"}, + {file = "librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2"}, + {file = "librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1"}, + {file = "librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022"}, + {file = "librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570"}, + {file = "librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26"}, + {file = "librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801"}, + {file = "librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc"}, + {file = "librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95"}, + {file = "librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b"}, + {file = "librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2"}, + {file = "librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3"}, + {file = "librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785"}, + {file = "librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6"}, + {file = "librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101"}, + {file = "librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218"}, + {file = "librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b"}, + {file = "librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab"}, + {file = "librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890"}, + {file = "librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8"}, + {file = "librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad"}, + {file = "librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993"}, + {file = "librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8"}, + {file = "librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21"}, + {file = "librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953"}, + {file = "librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa"}, + {file = "librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879"}, + {file = "librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae"}, + {file = "librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd"}, + {file = "librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285"}, + {file = "librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239"}, + {file = "librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60"}, + {file = "librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65"}, + {file = "librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622"}, + {file = "librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15"}, + {file = "librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28"}, + {file = "librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95"}, + {file = "librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714"}, + {file = "librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3"}, + {file = "librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d"}, + {file = "librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38"}, + {file = "librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19"}, + {file = "librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab"}, + {file = "librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2"}, + {file = "librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108"}, + {file = "librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08"}, + {file = "librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47"}, + {file = "librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81"}, + {file = "librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc"}, + {file = "librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf"}, + {file = "librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915"}, + {file = "librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605"}, + {file = "librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca"}, + {file = "librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965"}, + {file = "librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad"}, + {file = "librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9"}, + {file = "librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328"}, + {file = "librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0"}, + {file = "librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9"}, + {file = "librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659"}, + {file = "librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0"}, + {file = "librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d"}, + {file = "librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c"}, + {file = "librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d"}, + {file = "librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374"}, + {file = "librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9"}, + {file = "librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8"}, + {file = "librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b"}, + {file = "librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54"}, + {file = "librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6"}, + {file = "librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988"}, + {file = "librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13"}, + {file = "librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416"}, + {file = "librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a"}, + {file = "librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c"}, + {file = "librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302"}, + {file = "librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d"}, + {file = "librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9"}, + {file = "librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab"}, + {file = "librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b"}, + {file = "librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162"}, + {file = "librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1"}, + {file = "librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc"}, + {file = "librt-0.15.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0e2d0c0acf5b0ada7d045912b7cf787c21315c95b38b1fa939ef72d45d366b3d"}, + {file = "librt-0.15.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f9ca190fe9edc0eb08eec558a509a16d28d91c35667b8f043cba40ed5e77a959"}, + {file = "librt-0.15.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:80811e1c42386ea95c6fb30571d3250ad43d7863f883f787f70517f441150e59"}, + {file = "librt-0.15.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:88c2a17815c266e6d8180204ff62cb739ab869ada4a746d4c505331526ac58f1"}, + {file = "librt-0.15.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a5fa8f1f916988d0bf1afea005bda37f56ac41a18016e813ccf0097a8d460ca4"}, + {file = "librt-0.15.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:355e3a4c725225a14262004fc1872a552b9d3634b4f791a0dfc80804aafbfd55"}, + {file = "librt-0.15.0-cp39-cp39-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc1ed11c4ad0b91af24def2050f2840ea4567828e3dd058fbe608d982f6e5465"}, + {file = "librt-0.15.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1f4ef2e71db33df4309167ed7f1520c4fae5e611226e159fa9cf33f93e6ddb3d"}, + {file = "librt-0.15.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1a1a8cd430c7dd0c083f455cb1b328d7fc682b05c31b940906f7845bdff80881"}, + {file = "librt-0.15.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:04d5387b908676c0b8d5d2f5fb58373b4ea382d81f7a6f0fab8ea2a462bb4738"}, + {file = "librt-0.15.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:1172c6ad2a88b646e7fe3b480e3fac4ab4418b3443fd8a4061fdd531e0622fc7"}, + {file = "librt-0.15.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:52e8db01f603f5da0ca30987479acff98769382efc8e142fa3962395dcf3ffdb"}, + {file = "librt-0.15.0-cp39-cp39-win32.whl", hash = "sha256:e4c911f15a1652ca94ae9f1abd92e74cbb1b3597d2d92fdd556202f94e8cd455"}, + {file = "librt-0.15.0-cp39-cp39-win_amd64.whl", hash = "sha256:68242379c9b65a582b6e97318a1e9fbd6d445e58954f2d437991c4804ab11578"}, + {file = "librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162"}, +] + +[[package]] +name = "mypy" +version = "2.3.1" +description = "Optional static typing for Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "mypy-2.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:57a936373fc690c43a8cd7e7e12a35148e4ec5aa7698ad7fc0a9f918bdc5be41"}, + {file = "mypy-2.3.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d00d769056bde2f4e69c175071eba45cfb44fa1ed92bdfbfe64a93e0543b0cf0"}, + {file = "mypy-2.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2166b29228835e1f88ff411e96639e6ca3c7fdde84b62ec211f70f86b4051167"}, + {file = "mypy-2.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:83d36c2924df7426333abe7faf4724a7e1aab0d9fd41625e81b4683034b80c13"}, + {file = "mypy-2.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:f12fdb70459d0060dea40b29e52163a961b156106d68d57882a6a9f648983a53"}, + {file = "mypy-2.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:e099200a1b1b1223a4951f0a90cbff1b8c91b250ba599dab1f7217a628144d90"}, + {file = "mypy-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94f04929f1c44c35fb0061e912087edaf504acede963a4a7d00680bd089d8531"}, + {file = "mypy-2.3.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5d716048611e85ca9eefb2e1baa5d73ede389b5820ded260ea27c757d667af8"}, + {file = "mypy-2.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b091a455111214cb5c9d54a57b9618e9a49f9fe2a42e4e1ac86e9d104ed96ce8"}, + {file = "mypy-2.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:df12e20c9efd614738c71b390007ecd0181125afc4ccafca04d78a1d2eed2c01"}, + {file = "mypy-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:52eaf3a155f35cf80b40220288c861eb45f14a2340c1f6cbfbdb0feff32879d1"}, + {file = "mypy-2.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9b4eacbee8a69836c06eff6d0dd4e134a07c2b047755b30c08625fe214f322c6"}, + {file = "mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff"}, + {file = "mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff"}, + {file = "mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080"}, + {file = "mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355"}, + {file = "mypy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c5095a327483591c94e0c8d3ef9e50d4ab1369b541eae007c1f23bc2a41f6bb"}, + {file = "mypy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfe022634a2a195406bd469e888d2eaf193b02ba7e607391cd7640374aaae3b"}, + {file = "mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d"}, + {file = "mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb"}, + {file = "mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226"}, + {file = "mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74"}, + {file = "mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6"}, + {file = "mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac"}, + {file = "mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d"}, + {file = "mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3"}, + {file = "mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f"}, + {file = "mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82"}, + {file = "mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9"}, + {file = "mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d"}, + {file = "mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595"}, + {file = "mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2"}, + {file = "mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc"}, + {file = "mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045"}, + {file = "mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0"}, + {file = "mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63"}, + {file = "mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4"}, + {file = "mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57"}, + {file = "mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b"}, + {file = "mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561"}, + {file = "mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133"}, + {file = "mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9"}, + {file = "mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3"}, + {file = "mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523"}, + {file = "mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306"}, + {file = "mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021"}, + {file = "mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e"}, + {file = "mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc"}, + {file = "mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4"}, + {file = "mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29"}, + {file = "mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb"}, + {file = "mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419"}, +] + +[package.dependencies] +ast-serialize = ">=0.6.0,<1.0.0" +librt = {version = ">=0.13.0", markers = "platform_python_implementation != \"PyPy\""} +mypy_extensions = ">=1.0.0" +pathspec = ">=1.0.0" +typing_extensions = [ + {version = ">=4.6.0", markers = "python_version < \"3.15\""}, + {version = ">=4.14.0", markers = "python_version >= \"3.15\""}, +] + +[package.extras] +dmypy = ["psutil (>=4.0)"] +faster-cache = ["orjson"] +install-types = ["pip"] +mypyc = ["setuptools (>=50)"] +reports = ["lxml"] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + +[[package]] +name = "packaging" +version = "26.3" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c"}, + {file = "packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79"}, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189"}, + {file = "pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a"}, +] + +[package.extras] +hyperscan = ["hyperscan (>=0.7)"] +optional = ["typing-extensions (>=4)"] +re2 = ["google-re2 (>=1.1)"] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pygments" +version = "2.21.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9"}, + {file = "pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "9.1.1" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.10" +groups = ["dev"] +files = [ + {file = "pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c"}, + {file = "pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "requests" +version = "2.34.2" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, + {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, +] + +[package.dependencies] +certifi = ">=2023.5.7" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.26,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] + +[[package]] +name = "ruff" +version = "0.16.5" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b"}, + {file = "ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3"}, + {file = "ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb"}, + {file = "ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025"}, + {file = "ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9"}, + {file = "ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7"}, + {file = "ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde"}, + {file = "ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea"}, + {file = "ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105"}, + {file = "ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29"}, + {file = "ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf"}, + {file = "ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91"}, + {file = "ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a"}, + {file = "ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef"}, + {file = "ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26"}, + {file = "ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f"}, + {file = "ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e"}, + {file = "ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b"}, +] + +[[package]] +name = "tqdm" +version = "4.70.0" +description = "Fast, Extensible Progress Meter" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953"}, + {file = "tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +discord = ["envwrap", "requests"] +notebook = ["ipywidgets (>=6)"] +slack = ["envwrap", "slack-sdk"] +telegram = ["envwrap", "requests"] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, + {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, + {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, +] + +[package.extras] +brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] + +[[package]] +name = "xmltodict" +version = "1.0.4" +description = "Makes working with XML feel like you are working with JSON" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "xmltodict-1.0.4-py3-none-any.whl", hash = "sha256:a4a00d300b0e1c59fc2bfccb53d7b2e88c32f200df138a0dd2229f842497026a"}, + {file = "xmltodict-1.0.4.tar.gz", hash = "sha256:6d94c9f834dd9e44514162799d344d815a3a4faec913717a9ecbfa5be1bb8e61"}, +] + +[package.extras] +test = ["pytest", "pytest-cov"] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.11,<4" +content-hash = "cd4d9345c3a1c6ebd52426674ba386023c52defe06528b723305cf0d8980de5c" diff --git a/src/tscut/empty.idx b/src/tscut/empty.idx new file mode 100644 index 0000000..8e9a4d0 --- /dev/null +++ b/src/tscut/empty.idx @@ -0,0 +1,7 @@ +# VobSub index file, v7 (do not modify this line!) +size: 720x480 +palette: 000000, 0000ff, 00ff00, ff0000, ffff00, ff00ff, 00ffff, ffffff, 808000, 8080ff, 800080, 80ff80, 008080, ff8080, 555555, aaaaaa +langidx: 0 + +id: fr, index: 0 +timestamp: 00:00:00:000, filepos: 000000000 diff --git a/src/tscut/empty.sub b/src/tscut/empty.sub new file mode 100644 index 0000000..e69de29 diff --git a/src/tscut/removeads.py b/src/tscut/removeads.py new file mode 100755 index 0000000..a99c2e3 --- /dev/null +++ b/src/tscut/removeads.py @@ -0,0 +1,3829 @@ +#!/usr/bin/env python3 +'''A module to remove parts of video (.e.g advertisements) with single frame precision.''' + +# Standard modules +import argparse +import re +from sys import exit +from datetime import datetime,timedelta +import logging +from functools import cmp_to_key +from subprocess import Popen, PIPE +from os import read, write, lseek, set_inheritable, memfd_create, SEEK_SET, close, unlink,\ + fstat, ftruncate +import os.path +from enum import IntEnum, unique +from shutil import copyfile, which, move +from dataclasses import dataclass, field +from math import floor, ceil, log +from io import BytesIO, TextIOWrapper +import json +from typing import IO +from typeguard import typechecked + +# Third party libraries +import coloredlogs +from tqdm import tqdm +import hexdump +from iso639 import Lang +from iso639.exceptions import InvalidLanguageValue + +# Local modules +# TODO: create local modules for MP4, MKV + +# Useful SPS/PPS discussion. +# https://copyprogramming.com/howto/including-sps-and-pps-in-a-raw-h264-track +# https://gitlab.com/mbunkus/mkvtoolnix/-/issues/2390 + +# New strategy: a possible way of handling multiple SPS/PPS gracefully. +# Encode each head and trailer with FFMPEG using only I-frame (to be sure the NAL unit will never +# refer to another image). +# Encode using an different SPS-ID all of them (using sps-id parameter of libx264 library, e.g +# 1 instead of 0). +# For the video track produce only a raw H264 file and a file containing timestamps of the +# different frames. +# For the rest of the tracks (audio, subtitles) produce directly a MKV (this is already done). +# Concatenate all raw H264 in a giant one (like cat), and the same for timestamps of video frames +# (to keep sound and video synchronized). +# Then use mkvmerge to remux the H264 track and the rest of tracks. +# MKVmerge "concatenate" subcommand is able to concatenate different SPS/PPS data into a bigger +# Private Codec Data. +# However, this is proved to be not reliable. Sometimes it results in a AVC context containing +# a single SPS/PPS. +# So we have to rely on a manual parsing of the H264 AVC context of original movie +# and the ones produced for headers and trailers, and then merging them into a bigger AVC context. +# Then finally, change the Private Codec Data in the final MKV. + + +@typechecked +def check_required_tools() -> tuple[bool,dict[str,str]]: + """ + Checks if all required external tools are installed. + + This function verifies the presence of required and optional external tools on the system. + It returns a tuple containing a boolean indicating whether all optional tools are installed, + along with a dictionary containing the paths to all tools. + + Args: + None + + Returns: + tuple[bool, dict[str, str]]: + - bool: True if all optional tools are installed, False otherwise + - dict[str, str]: dictionary containing the paths to all tools + """ + logger = logging.getLogger(__name__) + all_optional_tools = True + paths = {} + required = ['ffmpeg', 'ffprobe', 'mkvmerge', 'mkvinfo'] + optional = ['mkvextract', 'vobsubocr','tesseract'] + for tool in required: + path = which(tool) + if path is None: + logger.error('Required tool: %s is missing.',tool) + exit(-1) + else: + paths[tool] = path + for tool in optional: + path = which(tool) + if path is None: + logger.info('Optional tool: %s is missing.',tool) + all_optional_tools = False + else: + paths[tool] = path + + return all_optional_tools, paths + +@typechecked +def get_tesseract_supported_lang(tesseract_path:str) -> dict[Lang, str]|None: + """ + Retrieves the set of natural languages supported by the Tesseract OCR tool. + + This function runs the Tesseract binary with the --list-langs option and parses the output + to extract the supported languages. + + Args: + tesseract_path (str): The path to the Tesseract binary. + + Returns: + dict[Lang, str] | None: + - A dictionary mapping Lang objects to their corresponding language codes + (e.g., "eng" for English) + - None if an error occurs while running the Tesseract binary + """ + logger = logging.getLogger(__name__) + res = {} + + with Popen([tesseract_path, '--list-langs'], stdout=PIPE) as tesseract: + for line in tesseract.stdout: + line = line.decode('utf8') + p = re.compile('(?P[a-z]{3})\n') + m = re.match(p,line) + if m is not None: + try: + lang = m.group('lang') + key = Lang(lang) + res[key] = lang + except InvalidLanguageValue as e: + logger.warning('Invalid language: %s', e) + pass + + tesseract.wait() + + if tesseract.returncode != 0: + logger.error("Tesseract returns an error code: %d",tesseract.returncode) + return None + + return res + +@typechecked +def get_frame_rate(ffprobe_path:str, input_file: IO[bytes]) -> float|None: + """ + Retrieves the frame rate of a video file using the ffprobe tool. + + This function runs the ffprobe binary with the specified input file and parses the output + to extract the frame rate. + It uses two methods to calculate the frame rate: one based on the timestamp of the frames + and another based on the duration of the frames. + If the two calculated frame rates are significantly different, the function returns an error + + Args: + ffprobe_path (str): The path to the ffprobe binary. + input_file (IO[bytes]): The input video file. + + Returns: + float | None: + - The frame rate of the video file as a floating-point number + - None if an error occurs while running the ffprobe binary or if the calculated + frame rates are inconsistent + """ + logger = logging.getLogger(__name__) + + infd = input_file.fileno() + lseek(infd, 0, SEEK_SET) + set_inheritable(infd, True) + + mean_duration = 0. + nb_frames1 = 0 + nb_frames2 = 0 + min_ts = None + max_ts = None + interlaced = False + + params = [ffprobe_path, '-loglevel', 'quiet', '-select_streams', 'v', '-show_frames', + '-read_intervals', '00%+30', '-of', 'json', f'/proc/self/fd/{infd:d}'] + env = {**os.environ, 'LANG': 'C'} + with Popen(params, stdout=PIPE, close_fds=False, env=env) as ffprobe: + out, _ = ffprobe.communicate() + out = json.load(BytesIO(out)) + if 'frames' in out: + for frame in out['frames']: + if 'interlaced_frame' in frame: + if frame['interlaced_frame'] == 1: + interlaced = True + if 'pts_time' in frame: + ts = float(frame['pts_time']) + if min_ts is None: + min_ts = ts + if max_ts is None: + max_ts = ts + min_ts = min(min_ts, ts) + max_ts = max(max_ts, ts) + nb_frames1+=1 + if 'duration_time' in frame: + mean_duration+=float(frame['duration_time']) + nb_frames2+=1 + else: + return None + + ffprobe.wait() + + if ffprobe.returncode != 0: + logger.error("ffprobe returns an error code: %d", ffprobe.returncode) + return None + + frame_rate1 = nb_frames1/(max_ts-min_ts) + frame_rate2 = nb_frames2 / mean_duration + + if abs(frame_rate1 - frame_rate2) > 0.2: + if not interlaced: + logger.error('Video is not interlaced and the disperancy between frame rates is too \ + big: %f / %f', frame_rate1, frame_rate2) + return None + if abs(frame_rate1*2 - frame_rate2) < 0.2: + return frame_rate2/2 + else: + logger.error('Video is interlaced and the disperancy between frame rates is too big:\ + %f / %f', frame_rate1, frame_rate2) + return None + + return frame_rate2 + +@typechecked +def get_subtitles_tracks(ffprobe_path:str, mkv_path: str) -> dict[str,str]|None: + logger = logging.getLogger(__name__) + tracks={} + + with Popen([ffprobe_path, '-loglevel', 'quiet', '-select_streams', 's', '-show_entries', + 'stream=index,codec_name:stream_tags=language', '-of', 'json', mkv_path], + stdout=PIPE) as ffprobe: + out, _ = ffprobe.communicate() + out = json.load(BytesIO(out)) + if 'streams' in out: + for stream in out['streams']: + index = stream['index'] + codec = stream['codec'] + lang = stream['tags']['language'] + if codec == 'dvd_subtitle': + if lang not in tracks: + tracks[lang] = [index] + else: + current_langs = tracks[lang] + current_langs.append(index) + tracks[lang] = current_langs + else: + return None + + ffprobe.wait() + + if ffprobe.returncode != 0: + logger.error("ffprobe returns an error code: %d", ffprobe.returncode) + return None + + return tracks + +@typechecked +def extract_srt(mkvextract:str, filename:str, subtitles:dict[str, list[int]], + langs:dict[Lang,str]) -> list[tuple[str,str,str,str]]|None: + logger = logging.getLogger(__name__) + + params = [mkvextract, filename, 'tracks'] + + res = [] + + for lang in subtitles: + iso = Lang(lang) + + if iso in langs: + ocrlang = langs[iso] + else: + logger.warning("Language not supported by Tesseract: %s", iso.name) + ocrlang ='osd' + + if len(subtitles[lang]) == 1: + params.append(f'{subtitles[lang][0]:d}:{lang}') + res.append((f'{lang}.idx', f'{lang}.sub', lang, ocrlang)) + else: + count = 1 + for track in subtitles[lang]: + params.append(f'{track:d}:{lang}-{count:d}') + res.append((f'{lang}-{count:d}.idx', f'{lang}-{count:d}.sub', lang, ocrlang)) + count = count+1 + + logger.debug('Executing %s', params) + + env = {**os.environ, 'LANG': 'C'} + with Popen(params, stdout=PIPE, close_fds=False, env=env) as extract: + pb = tqdm(TextIOWrapper(extract.stdout, encoding="utf-8"), total=100, unit='%', + desc='Extraction:') + for line in pb: + if line.startswith('Progress :'): + p = re.compile('^Progress : (?P[0-9]{1,3})%$') + m = p.match(line) + if m is None: + logger.error('Impossible to parse progress') + pb.update(int(m['progress'])-pb.n) + pb.update(100-pb.n) + pb.refresh() + pb.close() + + extract.wait() + + # mkvextract returns 0, 1 or 2 as error code. + match extract.returncode: + case 0: + logger.info('Subtitle tracks were succesfully extracted.') + case 1: + logger.warning('Mkvextract returns warning') + case 2: + logger.error('Mkvextract returns an error code: %d', extract.returncode) + res = None + + return res + +@typechecked +def do_ocr(vobsubocr:str, idxs: list[tuple[str,str,str,str]], duration:timedelta, + temporaries:list[IO[bytes]], dump_mem_fd:bool=False): + logger = logging.getLogger(__name__) + res = [] + + for idx_name, _, lang, iso in idxs: + srtname = f'{os.path.splitext(idx_name)[0]}.srt' + # Tesseract seems to recognize the three dots ... as "su" + ldots = re.compile('^su\n$') + # Timestamps produced by vobsubocr: 01:52:19,861 --> 01:52:21,641 + timestamps = re.compile((r'^[0-9]{2}:[0-9]{2}:[0-9]{2},[0-9]{3} \-\-> (?P[0-9]{2}):' + r'(?P[0-9]{2}):(?P[0-9]{2}),[0-9]{3}$')) + + srtfd = memfd_create(srtname, flags=0) + with Popen([vobsubocr, '--lang', iso, idx_name], stdout=PIPE) as ocr: + pb = tqdm(TextIOWrapper(ocr.stdout, encoding="utf-8"), total= + int(duration/timedelta(seconds=1)), unit='s', desc='OCR') + for line in pb: + m = re.match(ldots,line) + if m is not None: + write(srtfd, '...'.encode(encoding='UTF-8')) + else: + write(srtfd, line.encode(encoding='UTF-8')) + + m = re.match(timestamps, line) + if m is not None: + hours = int(m.group('hours')) + minutes = int(m.group('hours')) + seconds = int(m.group('seconds')) + ts = timedelta(hours=hours, minutes=minutes, seconds=seconds) + pb.n = int(ts/timedelta(seconds=1)) + pb.update() + + status = ocr.wait() + + if status != 0: + logger.error('OCR failed with status code: %d', status) + + if dump_mem_fd: + try: + with open(srtname,'w', encoding='utf8') as dump_srt: + lseek(srtfd, 0, SEEK_SET) + srt_length = fstat(srtfd).st_size + buf = read(srtfd, srt_length) + outfd = dump_srt.fileno() + pos = 0 + while pos < srt_length: + pos+=write(outfd, buf[pos:]) + temporaries.append(dump_srt) + except IOError: + logger.error('Impossible to create file: %s', srtname) + return None + + + srt_length = fstat(srtfd).st_size + if srt_length > 0: + res.append((srtfd, lang)) + + return res + + + +@unique +class SupportedFormat(IntEnum): + TS = 1 + MP4 = 2 + MATROSKA = 3 + + def __str__(self): + match self: + case SupportedFormat.TS: + return 'mpegts' + case SupportedFormat.MP4: + return 'mov,mp4,m4a,3gp,3g2,mj2' + case SupportedFormat.MATROSKA: + return 'matroska,webm' + case _: + return 'Unsupported format' + +# Extract SPS/PPS +# https://gitlab.com/mbunkus/mkvtoolnix/-/issues/2390 +# ffmpeg -i -c:v copy -an -sn -bsf:v trace_headers -t 0.01\ +# -report -loglevel 0 -f null - + +# Found codec private data using mkvinfo +@typechecked +def get_codec_private_data_from_mkv(mkvinfo_path:str, + input_file: IO[bytes]) -> tuple[int, bytes]|tuple[None,None]: + logger = logging.getLogger(__name__) + + infd = input_file.fileno() + lseek(infd, 0, SEEK_SET) + set_inheritable(infd, True) + found = False + env = {**os.environ, 'LANG': 'C'} + # Output example + # Codec's private data: size 48 (H.264 profile: High @L4.0) hexdump 01 64 00 28 ff e1 00 1b 67\ + # 64 00 28 ac d9 40 78 04 4f dc d4 04 04 05 00 00 92 ef 00 1d ad a6 1f 16 2d 96 01 00 06 68 fb\ + # a3 cb 22 c0 fd f8 f8 00 at 406 size 51 data size 48 + + with Popen([mkvinfo_path, '-z', '-X', '-P', f'/proc/self/fd/{infd:d}'], stdout=PIPE, + close_fds=False, env=env) as mkvinfo: + out, _ = mkvinfo.communicate() + out = out.decode('utf8') + reg_exp = (r"^.*Codec's private data: size ([0-9]+) \(H.264.*\) hexdump " + r"(?P([0-9a-f]{2} )+)at (?P[0-9]+) size (?P[0-9]+).*$") + p = re.compile(reg_exp) + for line in out.splitlines(): + m = p.match(line) + if m is not None: + size = int(m.group('size')) + position = int(m.group('position')) + logger.debug("Found codec private data at position: %s, size: %d", position, size) + found = True + mkvinfo.wait() + break + + if found: + lseek(infd, position, SEEK_SET) + data = read(infd, size) + return position, data + + return None, None + + +# All the following code is a transposition of documents: +# ISO/IEC H.264-201602 +# ISO/IEC 14496-15 +@typechecked +def read_bit(buf:bytes, bit_position: int) -> tuple[int, int]: + """ + Read a single bit from a byte buffer. + + This function is part of the implementation of the H.264/AVC video compression standard, + as specified in ISO/IEC H.264-201602 and ISO/IEC 14496-15. + It takes a byte buffer and a bit position as input, and returns a tuple containing + the updated bit position and the value of the bit at the specified position. + + Args: + buf (bytes): The byte buffer to read from. + bit_position (int): The position of the bit to read, starting from 0. + + Returns: + tuple[int, int]: A tuple containing the updated bit position (bit_position + 1) and the + value of the bit (0 or 1). + """ + byte_position = floor(floor(bit_position/8)) + byte = buf[byte_position] + bit = (byte >> (7-(bit_position % 8))) & 1 + return bit_position+1, bit + +@typechecked +def read_boolean(buf:bytes, bit_position: int) -> tuple[int, bool]: + """ + Read a boolean value from a byte buffer. + + This function reads a single bit from the byte buffer at the specified position and interprets + it as a boolean value. + It returns a tuple containing the updated bit position and the boolean value. + + Args: + buf (bytes): The byte buffer to read from. + bit_position (int): The position of the bit to read, starting from 0. + + Returns: + tuple[int, bool]: A tuple containing the updated bit position and the boolean value + (True if the bit is 1, False if the bit is 0). + """ + bit_position, b = read_bit(buf, bit_position) + return bit_position, b==1 + +@typechecked +def read_bits(buf:bytes, bit_position: int, nb_bits: int) -> tuple[int, int]: + v = 0 + for _ in range(0, nb_bits): + bit_position, bit = read_bit(buf, bit_position) + v = v*2+bit + return bit_position, v + +@typechecked +def read_byte(buf:bytes, bit_position: int) -> tuple[int, int]: + bit_position, b = read_bits(buf, bit_position, 8) + return bit_position, b + +@typechecked +def read_word(buf:bytes, bit_position: int) -> tuple[int, int]: + bit_position, w = read_bits(buf, bit_position, 16) + return bit_position, w + +@typechecked +def read_long(buf:bytes, bit_position: int) -> tuple[int, int]: + bit_position, value = read_bits(buf, bit_position, 32) + return bit_position, value + +@typechecked +def read_unsigned_exp_golomb(buf:bytes, bit_position: int) -> tuple[int, int]: + nb_zeroes=0 + while True: + bit_position, b = read_bit(buf, bit_position) + if b!=0: + break + nb_zeroes+=1 + + v1 = 1 + bit_position, v2 = read_bits(buf, bit_position, nb_zeroes) + v = (v1< tuple[int, int]: + bit_position, v = read_unsigned_exp_golomb(buf, bit_position) + match v%2: + case 0: + return bit_position, -(v>>1) + case 1: + return bit_position, (v+1)>>1 + +@typechecked +def write_bit(buf:bytes, bit_position: int, b) -> int: + buf_length = len(buf) + byte_position = floor(bit_position/8) + + if byte_position >= buf_length: + extension = bytearray(byte_position+1-buf_length) + buf.extend(extension) + + buf[byte_position] |= (b<<(7-(bit_position % 8))) + bit_position+=1 + + return bit_position + +@typechecked +def write_boolean(buf:bytes, bit_position: int, b: bool) -> int: + if b: + bit_position = write_bit(buf, bit_position, 1) + else: + bit_position = write_bit(buf, bit_position, 0) + return bit_position + +@typechecked +def write_bits(buf:bytes, bit_position: int, v, size) -> int: + for i in range(size-1,-1,-1): + b = (v>>i)&1 + bit_position = write_bit(buf, bit_position, b) + + return bit_position + +@typechecked +def write_byte(buf:bytes, bit_position: int, v) -> int: + bit_position = write_bits(buf, bit_position, v, 8) + return bit_position + +@typechecked +def write_word(buf:bytes, bit_position: int, v) -> int: + bit_position = write_bits(buf, bit_position, v, 16) + return bit_position + +@typechecked +def write_long(buf:bytes, bit_position: int, v) -> int: + bit_position = write_bits(buf, bit_position, v, 32) + return bit_position + +@typechecked +def write_unsigned_exp_golomb(buf:bytes, bit_position: int, v) -> int: + n = floor(log(v+1)/log(2))+1 + # Write zeroes + bit_position = write_bits(buf, bit_position, 0, n-1) + bit_position = write_bit(buf, bit_position, 1) + bit_position = write_bits(buf, bit_position, v+1, n-1) + + return bit_position + +@typechecked +def write_signed_exp_golomb(buf:bytes, bit_position: int, v) -> int: + if v <= 0: + bit_position = write_unsigned_exp_golomb(buf, bit_position, -v*2) + else: + bit_position = write_unsigned_exp_golomb(buf, bit_position, v*2-1) + + return bit_position + +@typechecked +def parse_rbsp_trailing_bits(buf:bytes, bit_position: int) -> int: + bit_position, one = read_bit(buf, bit_position) + if one==0: + raise ValueError(f'Stop bit should be equal to one. Read: {one:d}') + while bit_position%8 != 0: + bit_position, zero = read_bit(buf, bit_position) + if zero==1: + raise ValueError('Trailing bit should be equal to zero') + + return bit_position + +@typechecked +def write_rbsp_trailing_bits(buf:bytes, bit_position: int) -> int: + bit_position = write_bit(buf, bit_position, 1) + while bit_position%8 != 0: + bit_position = write_bit(buf, bit_position, 0) + + return bit_position + +@typechecked +def more_rbsp_data(buf:bytes, bit_position: int) -> bool: + logger = logging.getLogger(__name__) + logger.debug('Is there more data in buffer of length: %d at bit position: %d', + len(buf), bit_position) + + byte_length = len(buf) + bit_length = byte_length*8 + + # We are at the end of buffer + if bit_position == bit_length: + return False + else: + found = False + for i in range(bit_length-1,-1,-1): + pos, b = read_bit(buf, i) + if b == 1: + found = True + break + + if not found: + raise ValueError('Impossible to find trailing stop bit !') + + # No more data + if bit_position == pos: + return False + + return True + +# Convert from RBSP (Raw Byte Sequence Payload) to SODB (String Of Data Bits) +@typechecked +def rbsp_to_sodb(buf:bytes) -> bytes: + logger = logging.getLogger(__name__) + + logger.debug('RBSP: %s', hexdump.dump(buf, sep=':')) + + res = buf + for b in [ b'\x00', b'\x01', b'\x02', b'\x03']: + pattern = b'\x00\x00\x03'+b + replacement = b'\x00\x00' + b + res = res.replace(pattern, replacement) + + logger.debug('SODB: %s', hexdump.dump(res, sep=':')) + return res + +# Reverse operation SODB to RBSP. +@typechecked +def sodb_to_rbsp(buf:bytes) -> bytes: + logger = logging.getLogger(__name__) + logger.debug('SODB: %s', hexdump.dump(buf, sep=':')) + + res = buf + for b in [ b'\x03', b'\x00', b'\x01', b'\x02']: + pattern = b'\x00\x00'+b + replacement = b'\x00\x00\x03' + b + res = res.replace(pattern, replacement) + + logger.debug('RBSP: %s', hexdump.dump(res, sep=':')) + return res + +# Useful for SPS and PPS +@typechecked +def parse_scaling_list(buf:bytes, bit_position: int, size) -> tuple[int,list[int]]: + res = [] + last_scale = 8 + next_scale = 8 + for _ in range(0, size): + if next_scale != 0: + bit_position, delta_scale = read_signed_exp_golomb(buf, bit_position) + next_scale = (last_scale+delta_scale+256) % 256 + v = last_scale if next_scale==0 else next_scale + res.append(v) + last_scale = v + + return bit_position,res + +# TODO: test optimized version. +# The ISO/IEC H.264-201602 seems to take into account the case where the end of the deltas list +# is full of zeroes. +@typechecked +def write_scaling_list(buf:bytes, bit_position: int, size, matrix:list[int], + optimized: bool = False) -> int: + logger = logging.getLogger(__name__) + logger.debug('Dumping matrix: %s of size: %d, size parameter: %d.', matrix, len(matrix), size) + + prev = 8 + deltas = [] + for i in range(0, size): + v = matrix[i] + delta = v - prev + deltas.append(delta) + prev = v + + if not optimized: + for delta in deltas: + bit_position = write_signed_exp_golomb(buf, bit_position, delta) + else: + logger.error('Not yet implemented') + exit(-1) + # reverse = deltas.reverse() + # compressed = False + # while len(reverse)>0: + # if reverse[0] == 0: + # compressed = True + # reverse.pop() + # else: + # break + # deltas = reverse.reverse() + # if compressed: + # deltas.append(0) + # for delta in deltas: + # bit_position = write_signed_exp_golomb(buf, bit_position, delta) + + return bit_position + +@dataclass +class HRD: + cpb_cnt_minus1: int=0 + bit_rate_scale: int=0 + cpb_size_scale: int=0 + bit_rate_value_minus1: dict = field(default_factory=dict) + cpb_size_value_minus1: dict = field(default_factory=dict) + cbr_flag: dict = field(default_factory=dict) + initial_cpb_removal_delay_length_minus1: int=0 + cpb_removal_delay_length_minus1: int=0 + dpb_output_delay_length_minus1: int=0 + time_offset_length: int=0 + + def __init__(self): + self.bit_rate_value_minus1 = {} + self.cpb_size_value_minus1 = {} + self.cbr_flag = {} + + def from_bytes(self, buf, bit_position): + bit_position, self.cpb_cnt_minus1 = read_unsigned_exp_golomb(buf, bit_position) + bit_position, self.bit_rate_scale = read_bits(buf, bit_position, 4) + bit_position, self.cpb_size_scale = read_bits(buf, bit_position, 4) + for i in range(0, self.cpb_cnt_minus1+1): + bit_position, v = read_unsigned_exp_golomb(buf, bit_position) + self.bit_rate_value_minus1[i] = v + bit_position, v = read_unsigned_exp_golomb(buf, bit_position) + self.cpb_size_value_minus1[i] = v + bit_position, b = read_boolean(buf, bit_position) + self.cbr_flag[i] = b + bit_position, self.initial_cpb_removal_delay_length_minus1 = read_bits(buf, bit_position, 5) + bit_position, self.cpb_removal_delay_length_minus1 = read_bits(buf, bit_position, 5) + bit_position, self.dpb_output_delay_length_minus1 = read_bits(buf, bit_position, 5) + bit_position, self.time_offset_length = read_bits(buf, bit_position, 5) + + return bit_position + + def to_bytes(self, buf, bit_position): + bit_position = write_unsigned_exp_golomb(buf, bit_position, self.cpb_cnt_minus1) + bit_position = write_bits(buf, bit_position, self.bit_rate_scale, 4) + bit_position = write_bits(buf, bit_position, self.cpb_size_scale, 4) + for i in range(0, self.cpb_cnt_minus1+1): + v = self.bit_rate_value_minus1[i] + bit_position = write_unsigned_exp_golomb(buf, bit_position, v) + v = self.cpb_size_value_minus1[i] + bit_position = write_unsigned_exp_golomb(buf, bit_position, v) + b = self.cbr_flag[i] + bit_position = write_boolean(buf, bit_position, b) + bit_position = write_bits(buf, bit_position, self.initial_cpb_removal_delay_length_minus1, + 5) + bit_position = write_bits(buf, bit_position, self.cpb_removal_delay_length_minus1, 5) + bit_position = write_bits(buf, bit_position, self.dpb_output_delay_length_minus1, 5) + bit_position = write_bits(buf, bit_position, self.time_offset_length, 5) + + return bit_position + +@dataclass +class VUI: + aspect_ratio_info_present_flag:bool=False + aspect_ratio_idc:int=0 + sar_width:int=0 + sar_height:int=0 + overscan_info_present_flag:bool=False + overscan_appropriate_flag:bool=False + video_signal_type_present_flag:bool=False + video_format:int=0 + video_full_range_flag:bool=False + colour_description_present_flag:bool=False + colour_primaries:int=0 + transfer_characteristics:int=0 + matrix_coefficients:int=0 + chroma_loc_info_present_flag:bool=False + chroma_sample_loc_type_top_field:int=0 + chroma_sample_loc_type_bottom_field:int=0 + timing_info_present_flag:bool=False + num_units_in_tick:int=0 + time_scale:int=0 + fixed_frame_rate_flag:bool=False + nal_hrd_parameters_present_flag:bool=False + hrd_parameters:HRD=None + vcl_hrd_parameters_present_flag:bool=False + vcl_hrd_parameters:HRD=None + low_delay_hrd_flag:bool=False + pic_struct_present_flag:bool=False + bitstream_restriction_flag:bool=False + motion_vectors_over_pic_boundaries_flag:bool=False + max_bytes_per_pic_denom:int=0 + max_bits_per_mb_denom:int=0 + log2_max_mv_length_horizontal:int=0 + log2_max_mv_length_vertical:int=0 + max_num_reorder_frames:int=0 + max_dec_frame_buffering:int=0 + + # This structure is not guaranteed to be located at a byte boundary. + # We must explicitely indicate bit offset. + def from_bytes(self, buf, bit_position): + bit_position, self.aspect_ratio_info_present_flag = read_boolean(buf, bit_position) + if self.aspect_ratio_info_present_flag: + bit_position, self.aspect_ratio_idc = read_byte(buf, bit_position) + if self.aspect_ratio_idc == 255: # Extended_SAR + bit_position, self.sar_width = read_word(buf, bit_position) + bit_position, self.sar_height = read_word(buf, bit_position) + bit_position, self.overscan_info_present_flag = read_boolean(buf, bit_position) + if self.overscan_info_present_flag: + bit_position, self.overscan_appropriate_flag = read_boolean(buf, bit_position) + bit_position, self.video_signal_type_present_flag = read_boolean(buf, bit_position) + if self.video_signal_type_present_flag: + bit_position, self.video_format = read_bits(buf, bit_position, 3) + bit_position, self.video_full_range_flag = read_boolean(buf, bit_position) + bit_position, self.colour_description_present_flag = read_boolean(buf, bit_position) + if self.colour_description_present_flag: + bit_position, self.colour_primaries = read_byte(buf, bit_position) + bit_position, self.transfer_characteristics = read_byte(buf, bit_position) + bit_position, self.matrix_coefficients = read_byte(buf, bit_position) + bit_position, self.chroma_loc_info_present_flag = read_boolean(buf, bit_position) + if self.chroma_loc_info_present_flag: + bit_position, self.chroma_sample_loc_type_top_field =\ + read_unsigned_exp_golomb(buf, bit_position) + bit_position, self.chroma_sample_loc_type_bottom_field =\ + read_unsigned_exp_golomb(buf,bit_position) + bit_position, self.timing_info_present_flag = read_boolean(buf, bit_position) + if self.timing_info_present_flag: + bit_position, self.num_units_in_tick = read_long(buf, bit_position) + bit_position, self.time_scale = read_long(buf, bit_position) + bit_position, self.fixed_frame_rate_flag = read_boolean(buf, bit_position) + bit_position, self.nal_hrd_parameters_present_flag = read_boolean(buf, bit_position) + if self.nal_hrd_parameters_present_flag: + hrd = HRD() + bit_position = hrd.from_bytes(buf, bit_position) + self.hrd_parameters = hrd + bit_position, self.vcl_hrd_parameters_present_flag = read_boolean(buf, bit_position) + if self.vcl_hrd_parameters_present_flag: + hrd = HRD() + bit_position = hrd.from_bytes(buf, bit_position) + self.vcl_hrd_parameters = hrd + if self.nal_hrd_parameters_present_flag or self.vcl_hrd_parameters_present_flag: + bit_position, self.low_delay_hrd_flag = read_boolean(buf, bit_position) + bit_position, self.pic_struct_present_flag = read_boolean(buf, bit_position) + bit_position, self.bitstream_restriction_flag = read_boolean(buf, bit_position) + if self.bitstream_restriction_flag: + bit_position, self.motion_vectors_over_pic_boundaries_flag =\ + read_boolean(buf, bit_position) + bit_position, self.max_bytes_per_pic_denom = read_unsigned_exp_golomb(buf, bit_position) + bit_position, self.max_bits_per_mb_denom = read_unsigned_exp_golomb(buf, bit_position) + bit_position, self.log2_max_mv_length_horizontal = read_unsigned_exp_golomb(buf, + bit_position) + bit_position, self.log2_max_mv_length_vertical = read_unsigned_exp_golomb(buf, + bit_position) + bit_position, self.max_num_reorder_frames = read_unsigned_exp_golomb(buf, bit_position) + bit_position, self.max_dec_frame_buffering = read_unsigned_exp_golomb(buf, bit_position) + + return bit_position + + def to_bytes(self, buf, bit_position): + bit_position = write_boolean(buf, bit_position, self.aspect_ratio_info_present_flag) + if self.aspect_ratio_info_present_flag: + bit_position = write_byte(buf, bit_position, self.aspect_ratio_idc) + if self.aspect_ratio_idc == 255: # Extended_SAR + bit_position = write_word(buf, bit_position, self.sar_width) + bit_position = write_word(buf, bit_position, self.sar_height) + bit_position = write_boolean(buf, bit_position, self.overscan_info_present_flag) + if self.overscan_info_present_flag: + bit_position = write_boolean(buf, bit_position, self.overscan_appropriate_flag) + bit_position = write_boolean(buf, bit_position, self.video_signal_type_present_flag) + if self.video_signal_type_present_flag: + bit_position = write_bits(buf, bit_position, self.video_format, 3) + bit_position = write_boolean(buf, bit_position, self.video_full_range_flag) + bit_position = write_boolean(buf, bit_position, self.colour_description_present_flag) + if self.colour_description_present_flag: + bit_position = write_byte(buf, bit_position, self.colour_primaries) + bit_position = write_byte(buf, bit_position, self.transfer_characteristics) + bit_position = write_byte(buf, bit_position, self.matrix_coefficients) + bit_position = write_boolean(buf, bit_position, self.chroma_loc_info_present_flag) + if self.chroma_loc_info_present_flag: + bit_position = write_unsigned_exp_golomb(buf, bit_position, + self.chroma_sample_loc_type_top_field) + bit_position = write_unsigned_exp_golomb(buf, bit_position, + self.chroma_sample_loc_type_bottom_field) + bit_position = write_boolean(buf, bit_position, self.timing_info_present_flag ) + if self.timing_info_present_flag: + bit_position = write_long(buf, bit_position, self.num_units_in_tick ) + bit_position = write_long(buf, bit_position, self.time_scale) + bit_position = write_boolean(buf, bit_position, self.fixed_frame_rate_flag) + bit_position = write_boolean(buf, bit_position, self.nal_hrd_parameters_present_flag) + if self.nal_hrd_parameters_present_flag: + bit_position = self.hrd_parameters.to_bytes(buf, bit_position) + bit_position = write_boolean(buf, bit_position, self.vcl_hrd_parameters_present_flag) + if self.vcl_hrd_parameters_present_flag: + bit_position = self.vcl_hrd_parameters.to_bytes(buf, bit_position) + if self.nal_hrd_parameters_present_flag or self.vcl_hrd_parameters_present_flag: + bit_position = write_boolean(buf, bit_position, self.low_delay_hrd_flag) + bit_position = write_boolean(buf, bit_position, self.pic_struct_present_flag) + bit_position = write_boolean(buf, bit_position, self.bitstream_restriction_flag) + if self.bitstream_restriction_flag: + bit_position = write_boolean(buf, bit_position, + self.motion_vectors_over_pic_boundaries_flag) + bit_position = write_unsigned_exp_golomb(buf, bit_position, + self.max_bytes_per_pic_denom) + bit_position = write_unsigned_exp_golomb(buf, bit_position, self.max_bits_per_mb_denom) + bit_position = write_unsigned_exp_golomb(buf, bit_position, + self.log2_max_mv_length_horizontal) + bit_position = write_unsigned_exp_golomb(buf, bit_position, + self.log2_max_mv_length_vertical) + bit_position = write_unsigned_exp_golomb(buf, bit_position, self.max_num_reorder_frames) + bit_position = write_unsigned_exp_golomb(buf, bit_position, + self.max_dec_frame_buffering) + + return bit_position + +@dataclass +class SPS: + profile_idc:int=0 # u(8) + constraint_set0_flag:bool=False # u(1) + constraint_set1_flag:bool=False # u(1) + constraint_set2_flag:bool=False # u(1) + constraint_set3_flag:bool=False # u(1) + constraint_set4_flag:bool=False # u(1) + constraint_set5_flag:bool=False # u(1) + level_idc:int=0 # u(8) + seq_parameter_set_id:int=0 # ue(v) + chroma_format_idc:int=0 # ue(v) + separate_colour_plane_flag:bool=False # u(1) + bit_depth_luma_minus8:int=0 # ue(v) + bit_depth_chroma_minus8:int=0 # ue(v) + qpprime_y_zero_transform_bypass_flag:bool=False # u(1) + seq_scaling_matrix_present_flag:bool=False # u(1) + scaling_list: dict = field(default_factory=dict) + log2_max_frame_num_minus4:int=0 # ue(v) + pic_order_cnt_type:int=0 # ue(v) + log2_max_pic_order_cnt_lsb_minus4:int=0 # ue(v) + delta_pic_order_always_zero_flag:bool=False # ue(1) + offset_for_non_ref_pic:int=0 # se(v) + offset_for_top_to_bottom_field:int=0 # se(v) + num_ref_frames_in_pic_order_cnt_cycle:int=0 # ue(v) + offset_for_ref_frame:dict[int] = field(default_factory=dict) + max_num_ref_frames:int=9 # ue(v) + gaps_in_frame_num_value_allowed_flag:bool=False # u(1) + pic_width_in_mbs_minus1:int=0 # ue(v) + pic_height_in_map_units_minus1:int=0 # ue(v) + frame_mbs_only_flag:bool=False # u(1) + mb_adaptive_frame_field_flag:bool=False # u(1) + direct_8x8_inference_flag:bool=False # u(1) + frame_cropping_flag:bool=False # u(1) + frame_crop_left_offset:int=0 # ue(v) + frame_crop_right_offset:int=0 # ue(v) + frame_crop_top_offset:int=0 # ue(v) + frame_crop_bottom_offset:int=0 # ue(v) + vui_parameters_present_flag:bool=False # u(1) + vui:VUI=None # VUI object + + def __init__(self): + self.scaling_list={} + self.offset_for_ref_frame={} + + # TODO: ... + # Compute options to pass to ffmpeg so as to reproduce the same SPS. + # Very complex since some codec configuration are not provided by ffmpeg and/or libx264. + # This is only an attempt for now and it is almost impossible to mimic any profile without + # patching ffmpeg and/or libx264 to add the support for corner cases. + def ffmpeg_options(self, video_id=0): + logger = logging.getLogger(__name__) + x264opts = [] + + try: + profile = {0x42:'baseline', 0x4D:'main', 0x64:'high', 0x6E:'high10', 0x7A:'high422', + 0xF4:'high444'}[self.profile_idc] + except KeyError: + logger.error('Unknow profile: %x', self.profile_idc) + return [] + + level = f'{floor(self.level_idc/10):d}.{self.level_idc % 10:d}' + x264opts.extend([f'sps-id={self.seq_parameter_set_id:d}'] ) + + if self.bit_depth_chroma_minus8 not in [0,1,2,4,6,8]: + logger.error('Bit depth of chrominance is not supported: %d', + self.bit_depth_chroma_minus8+8) + return [] + + if self.chroma_format_idc in range(0,4): + if self.chroma_format_idc == 0: + # Monochrome + pass + elif self.chroma_format_idc == 1: + # YUV:4:2:0 + pass + elif self.chroma_format_idc == 2: + # YUV:4:2:2 + pass + elif self.chroma_format_idc == 3: + # YUV:4:4:4 + pass + else: + logger.error('Unknow chrominance format: %x', self.chroma_format_idc) + return [] + + res = [f'-profile:v:{video_id:d}', self.profile_idc, f'-level:v:{video_id:d}', level] + return res + + def from_bytes(self, buf): + logger = logging.getLogger(__name__) + logger.debug('Parsing: %s', hexdump.dump(buf,sep=':')) + + bit_position=0 + + # NAL Unit SPS + bit_position, zero = read_bit(buf, bit_position) + if zero != 0: + raise ValueError(f'Reserved bit is not equal to 0: {zero:d}') + bit_position, nal_ref_idc = read_bits(buf, bit_position,2) + if nal_ref_idc != 3: + raise ValueError(f'NAL ref idc is not equal to 3: {nal_ref_idc:d}') + bit_position, nal_unit_type = read_bits(buf, bit_position,5) + if nal_unit_type != 7: + raise ValueError(f'NAL unit type is not a SPS: {nal_unit_type:d}') + + bit_position, self.profile_idc = read_byte(buf, bit_position) + bit_position, self.constraint_set0_flag = read_bit(buf,bit_position) + bit_position, self.constraint_set1_flag = read_bit(buf,bit_position) + bit_position, self.constraint_set2_flag = read_bit(buf,bit_position) + bit_position, self.constraint_set3_flag = read_bit(buf,bit_position) + bit_position, self.constraint_set4_flag = read_bit(buf,bit_position) + bit_position, self.constraint_set5_flag = read_bit(buf,bit_position) + bit_position, v = read_bits(buf, bit_position, 2) + if v!=0: + raise ValueError(f'Reserved bits different from 0b00: {v:x}') + bit_position, self.level_idc = read_byte(buf, bit_position) + bit_position, self.seq_parameter_set_id = read_unsigned_exp_golomb(buf, bit_position) + if self.profile_idc in [44, 83, 86, 100, 110, 118, 122, 128, 134, 135, 138, 139, 244]: + bit_position, self.chroma_format_idc = read_unsigned_exp_golomb(buf, bit_position) + if self.chroma_format_idc==3: + bit_position, self.separate_colour_plane_flag=read_bit(buf, bit_position) + bit_position, self.bit_depth_luma_minus8 = read_unsigned_exp_golomb(buf, bit_position) + bit_position, self.bit_depth_chroma_minus8 = read_unsigned_exp_golomb(buf, bit_position) + bit_position, self.qpprime_y_zero_transform_bypass_flag = read_boolean(buf, + bit_position) + bit_position, self.seq_scaling_matrix_present_flag = read_boolean(buf, bit_position) + if self.seq_scaling_matrix_present_flag: + nb_matrices = 12 if self.chroma_format_idc == 3 else 8 + for i in range(0, nb_matrices): + bit_position, present = read_boolean(buf, bit_position) + if present: + if i<6: + bit_position, matrix = parse_scaling_list(buf, bit_position, 16) + self.scaling_list[i] = matrix + else: + bit_position, matrix = parse_scaling_list(buf, bit_position, 64) + self.scaling_list[i] = matrix + else: + self.scaling_list[i] = [] + + bit_position, self.log2_max_frame_num_minus4 = read_unsigned_exp_golomb(buf, bit_position) + bit_position , self.pic_order_cnt_type = read_unsigned_exp_golomb(buf, bit_position) + if self.pic_order_cnt_type == 0: + bit_position, self.log2_max_pic_order_cnt_lsb_minus4 =\ + read_unsigned_exp_golomb(buf, bit_position) + elif self.pic_order_cnt_type == 1: + bit_position, self.delta_pic_order_always_zero_flag = read_boolean(buf, bit_position) + bit_position, self.offset_for_non_ref_pic = read_signed_exp_golomb(buf, bit_position) + bit_position, self.offset_for_top_to_bottom_field = read_signed_exp_golomb(buf, + bit_position) + bit_position, self.num_ref_frames_in_pic_order_cnt_cycle =\ + read_unsigned_exp_golomb(buf, bit_position) + for i in range(0, self.num_ref_frames_in_pic_order_cnt_cycle): + bit_position, v = read_unsigned_exp_golomb(buf, bit_position) + self.offset_for_ref_frame[i]=v + + bit_position, self.max_num_ref_frames = read_unsigned_exp_golomb(buf, bit_position) + bit_position, self.gaps_in_frame_num_value_allowed_flag = read_boolean(buf, bit_position) + bit_position, self.pic_width_in_mbs_minus1 = read_unsigned_exp_golomb(buf, bit_position) + bit_position, self.pic_height_in_map_units_minus1 = read_unsigned_exp_golomb(buf, + bit_position) + bit_position, self.frame_mbs_only_flag = read_boolean(buf, bit_position) + if not self.frame_mbs_only_flag: + bit_position, self.mb_adaptive_frame_field_flag = read_boolean(buf, bit_position) + bit_position, self.direct_8x8_inference_flag = read_boolean(buf, bit_position) + bit_position, self.frame_cropping_flag = read_boolean(buf, bit_position) + if self.frame_cropping_flag: + bit_position, self.frame_crop_left_offset = read_unsigned_exp_golomb(buf, bit_position) + bit_position, self.frame_crop_right_offset = read_unsigned_exp_golomb(buf, bit_position) + bit_position, self.frame_crop_top_offset = read_unsigned_exp_golomb(buf, bit_position) + bit_position, self.frame_crop_bottom_offset = read_unsigned_exp_golomb(buf, + bit_position) + bit_position, self.vui_parameters_present_flag = read_boolean(buf, bit_position) + + if self.vui_parameters_present_flag: + self.vui = VUI() + bit_position = self.vui.from_bytes(buf,bit_position) + logger.debug('VUI present: %s', self.vui) + + logger.debug('Parse end of SPS. Bit position: %d. Remaining bytes: %s.', bit_position, + hexdump.dump(buf[floor(bit_position/8):], sep=':')) + bit_position = parse_rbsp_trailing_bits(buf, bit_position) + logger.debug('End of SPS: %d. Remaining bytes: %s', bit_position, + hexdump.dump(buf[floor(bit_position/8):], sep=':')) + return bit_position + + def to_bytes(self): + logger = logging.getLogger(__name__) + + buf = bytearray() + bit_position = 0 + bit_position = write_bit(buf, bit_position,0) + bit_position = write_bits(buf, bit_position, 3, 2) + bit_position = write_bits(buf, bit_position, 7, 5) + bit_position = write_byte(buf, bit_position, self.profile_idc) + bit_position = write_bit(buf, bit_position, self.constraint_set0_flag) + bit_position = write_bit(buf, bit_position, self.constraint_set1_flag) + bit_position = write_bit(buf, bit_position, self.constraint_set2_flag) + bit_position = write_bit(buf, bit_position, self.constraint_set3_flag) + bit_position = write_bit(buf, bit_position, self.constraint_set4_flag) + bit_position = write_bit(buf, bit_position, self.constraint_set5_flag) + bit_position = write_bits(buf, bit_position, 0, 2) + bit_position = write_byte(buf, bit_position, self.level_idc) + bit_position = write_unsigned_exp_golomb(buf, bit_position, self.seq_parameter_set_id) + if self.profile_idc in [44, 83, 86, 100, 110, 118, 122, 128, 134, 135, 138, 139, 244]: + bit_position = write_unsigned_exp_golomb(buf, bit_position, self.chroma_format_idc) + if self.chroma_format_idc==3: + bit_position = write_bit(buf, bit_position, self.separate_colour_plane_flag) + bit_position = write_unsigned_exp_golomb(buf, bit_position, self.bit_depth_luma_minus8) + bit_position = write_unsigned_exp_golomb(buf, bit_position, + self.bit_depth_chroma_minus8) + bit_position = write_boolean(buf, bit_position, + self.qpprime_y_zero_transform_bypass_flag) + bit_position = write_boolean(buf, bit_position, self.seq_scaling_matrix_present_flag) + if self.seq_scaling_matrix_present_flag: + nb_matrices = 12 if self.chroma_format_idc == 3 else 8 + for i in range(0, nb_matrices): + matrix = self.scaling_list[i] + present = (len(matrix))!=0 + bit_position = write_boolean(buf, bit_position, present) + if present: + if i<6: + bit_position = write_scaling_list(buf, bit_position, 16, matrix) + else: + bit_position = write_scaling_list(buf, bit_position, 64, matrix) + + bit_position = write_unsigned_exp_golomb(buf, bit_position, self.log2_max_frame_num_minus4) + bit_position = write_unsigned_exp_golomb(buf, bit_position, self.pic_order_cnt_type) + if self.pic_order_cnt_type == 0: + bit_position = write_unsigned_exp_golomb(buf, bit_position, + self.log2_max_pic_order_cnt_lsb_minus4) + elif self.pic_order_cnt_type == 1: + bit_position = write_boolean(buf, bit_position, self.delta_pic_order_always_zero_flag) + bit_position = write_signed_exp_golomb(buf, bit_position, self.offset_for_non_ref_pic) + bit_position = write_signed_exp_golomb(buf, bit_position, + self.offset_for_top_to_bottom_field) + bit_position = write_unsigned_exp_golomb(buf, bit_position, + self.num_ref_frames_in_pic_order_cnt_cycle) + for i in range(0, self.num_ref_frames_in_pic_order_cnt_cycle): + v = self.offset_for_ref_frame[i] + bit_position = write_unsigned_exp_golomb(buf, bit_position, v) + bit_position = write_unsigned_exp_golomb(buf, bit_position, self.max_num_ref_frames) + bit_position = write_boolean(buf, bit_position, self.gaps_in_frame_num_value_allowed_flag) + bit_position = write_unsigned_exp_golomb(buf, bit_position, self.pic_width_in_mbs_minus1) + bit_position = write_unsigned_exp_golomb(buf, bit_position, + self.pic_height_in_map_units_minus1) + bit_position = write_boolean(buf, bit_position, self.frame_mbs_only_flag) + if not self.frame_mbs_only_flag: + bit_position = write_boolean(buf, bit_position, self.mb_adaptive_frame_field_flag) + bit_position = write_boolean(buf, bit_position, self.direct_8x8_inference_flag) + bit_position = write_boolean(buf, bit_position, self.frame_cropping_flag) + if self.frame_cropping_flag: + bit_position = write_unsigned_exp_golomb(buf, bit_position, self.frame_crop_left_offset) + bit_position = write_unsigned_exp_golomb(buf, bit_position, + self.frame_crop_right_offset) + bit_position = write_unsigned_exp_golomb(buf, bit_position, self.frame_crop_top_offset) + bit_position = write_unsigned_exp_golomb(buf, bit_position, + self.frame_crop_bottom_offset) + bit_position = write_boolean(buf, bit_position, self.vui_parameters_present_flag) + if self.vui_parameters_present_flag: + logger.debug('SPS has VUI. Writing VUI at position: %d', bit_position) + bit_position = self.vui.to_bytes(buf, bit_position) + logger.debug('VUI written. New bit position: %d', bit_position) + + bit_position = write_rbsp_trailing_bits(buf, bit_position) + + return buf + +@dataclass +class PPS: + pic_parameter_set_id:int=0 + seq_parameter_set_id:int=0 + entropy_coding_mode_flag:bool=False + bottom_field_pic_order_in_frame_present_flag:bool=False + num_slice_groups_minus1:int=0 + slice_group_map_type:int=0 + run_length_minus1:dict = field(default_factory=dict) + top_left:dict = field(default_factory=dict) + bottom_right:dict = field(default_factory=dict) + slice_group_change_direction_flag:bool=False + slice_group_change_rate_minus1:int=0 + pic_size_in_map_units_minus1:int=0 + slice_group_id:dict = field(default_factory=dict) + num_ref_idx_l0_default_active_minus1:int=0 + num_ref_idx_l2_default_active_minus1:int=0 + weighted_pred_flag:bool=False + weighted_bipred_idc:int=0 + pic_init_qp_minus26:int=0 + pic_init_qs_minus26:int=0 + chroma_qp_index_offset:int=0 + deblocking_filter_control_present_flag:bool=False + constrained_intra_pred_flag:bool=False + redundant_pic_cnt_present_flag:bool=False + transform_8x8_mode_flag:bool=False + pic_scaling_matrix_present_flag:bool=False + pic_scaling_list:list[list[int]] = field(default_factory=list) + second_chroma_qp_index_offset:int=0 + + def __init__(self): + self.run_length_minus1={} + self.top_left={} + self.bottom_right={} + self.slice_group_id={} + self.pic_scaling_list=[] + + # PPS are located at byte boundary + def from_bytes(self, buf, chroma_format_idc): + logger = logging.getLogger(__name__) + logger.debug('Parsing: %s', (hexdump.dump(buf,sep=':'))) + + bit_position=0 + # NAL Unit PPS + bit_position, zero = read_bit(buf, bit_position) + if zero != 0: + raise ValueError(f'Reserved bit is not equal to 0: {zero:d}') + bit_position, nal_ref_idc = read_bits(buf, bit_position,2) + if nal_ref_idc != 3: + raise ValueError(f'NAL ref idc is not equal to 3: {nal_ref_idc:d}') + bit_position, nal_unit_type = read_bits(buf, bit_position,5) + if nal_unit_type != 8: + raise ValueError(f'NAL unit type is not a PPS: {nal_unit_type:d}') + + bit_position, self.pic_parameter_set_id = read_unsigned_exp_golomb(buf, bit_position) + bit_position, self.seq_parameter_set_id = read_unsigned_exp_golomb(buf, bit_position) + bit_position, self.entropy_coding_mode_flag = read_boolean(buf, bit_position) + bit_position, self.bottom_field_pic_order_in_frame_present_flag =\ + read_boolean(buf, bit_position) + bit_position, self.num_slice_groups_minus1 = read_unsigned_exp_golomb(buf, bit_position) + + if self.num_slice_groups_minus1>0: + bit_position, self.slice_group_map_type = read_unsigned_exp_golomb(buf, bit_position) + if self.slice_group_map_type == 0: + for i in range(0, self.num_slice_groups_minus1): + bit_position, v = read_unsigned_exp_golomb(buf, bit_position) + self.run_length_minus1[i]=v + elif self.slice_group_map_type == 2: + for i in range(0, self.num_slice_groups_minus1): + bit_position, v = read_unsigned_exp_golomb(buf, bit_position) + self.top_left[i] = v + bit_position, v = read_unsigned_exp_golomb(buf, bit_position) + self.bottom_right[i] = v + elif self.slice_group_map_type in [3,4,5]: + bit_position, self.slice_group_change_direction_flag = read_boolean(buf, + bit_position) + bit_position, self.slice_group_change_rate_minus1 =\ + read_unsigned_exp_golomb(buf, bit_position) + elif self.slice_group_map_type == 6: + bit_position, self.pic_size_in_map_units_minus1 =\ + read_unsigned_exp_golomb(buf, bit_position) + nb_bits = ceil(log(self.num_slice_groups_minus1+1)) + for i in range(0, self.pic_size_in_map_units_minus1): + bit_position, v = read_bits(buf, bit_position, nb_bits) + self.slice_group_id[i]=v + + bit_position, self.num_ref_idx_l0_default_active_minus1 =\ + read_unsigned_exp_golomb(buf, bit_position) + bit_position, self.num_ref_idx_l2_default_active_minus1 =\ + read_unsigned_exp_golomb(buf, bit_position) + bit_position, self.weighted_pred_flag = read_boolean(buf, bit_position) + bit_position, self.weighted_bipred_idc = read_bits(buf, bit_position, 2) + bit_position, self.pic_init_qp_minus26 = read_signed_exp_golomb(buf, bit_position) + bit_position, self.pic_init_qs_minus26 = read_signed_exp_golomb(buf, bit_position) + bit_position, self.chroma_qp_index_offset = read_signed_exp_golomb(buf, bit_position) + bit_position, self.deblocking_filter_control_present_flag = read_boolean(buf, bit_position) + bit_position, self.constrained_intra_pred_flag = read_boolean(buf, bit_position) + bit_position, self.redundant_pic_cnt_present_flag = read_boolean(buf, bit_position) + + if more_rbsp_data(buf, bit_position): + bit_position, self.transform_8x8_mode_flag = read_boolean(buf, bit_position) + bit_position, self.pic_scaling_matrix_present_flag = read_boolean(buf, bit_position) + if self.pic_scaling_matrix_present_flag: + nb_matrices = 6 if chroma_format_idc == 3 else 2 + if self.transform_8x8_mode_flag: + nb_matrices+=6 + else: + nb_matrices = 6 + for i in range(0, nb_matrices): + bit_position, present = read_boolean(buf, bit_position) + if present: + if i<6: + bit_position, matrix = parse_scaling_list(buf, bit_position, 16) + self.pic_scaling_list.append(matrix) + else: + bit_position, matrix = parse_scaling_list(buf, bit_position, 64) + self.pic_scaling_list.append(matrix) + else: + self.pic_scaling_list.append([]) + bit_position, self.second_chroma_qp_index_offset = read_signed_exp_golomb(buf, + bit_position) + + logger.info("parse RBSP") + bit_position = parse_rbsp_trailing_bits(buf, bit_position) + + return bit_position + + def to_bytes(self, chroma_format_idc): + logger = logging.getLogger(__name__) + + buf = bytearray() + bit_position = 0 + # NAL Unit PPS + bit_position = write_bit(buf, bit_position, 0) + bit_position = write_bits(buf, bit_position, 3, 2) + bit_position = write_bits(buf, bit_position, 8, 5) + + bit_position = write_unsigned_exp_golomb(buf, bit_position, self.pic_parameter_set_id) + bit_position = write_unsigned_exp_golomb(buf, bit_position, self.seq_parameter_set_id) + bit_position = write_boolean(buf, bit_position, self.entropy_coding_mode_flag) + bit_position = write_boolean(buf, bit_position,\ + self.bottom_field_pic_order_in_frame_present_flag) + bit_position = write_unsigned_exp_golomb(buf, bit_position, self.num_slice_groups_minus1) + + if self.num_slice_groups_minus1>0: + bit_position = write_unsigned_exp_golomb(buf, bit_position, self.slice_group_map_type) + if self.slice_group_map_type == 0: + for i in range(0, self.num_slice_groups_minus1): + v = self.run_length_minus1[i] + bit_position = write_unsigned_exp_golomb(buf, bit_position, v) + elif self.slice_group_map_type == 2: + for i in range(0, self.num_slice_groups_minus1): + v = self.top_left[i] + bit_position = write_unsigned_exp_golomb(buf, bit_position, v) + v = self.bottom_right[i] + bit_position = write_unsigned_exp_golomb(buf, bit_position, v) + elif self.slice_group_map_type in [3,4,5]: + bit_position = write_boolean(buf, bit_position, + self.slice_group_change_direction_flag) + bit_position = write_unsigned_exp_golomb(buf, bit_position, + self.slice_group_change_rate_minus1) + elif self.slice_group_map_type == 6: + bit_position = write_unsigned_exp_golomb(buf, bit_position, + self.pic_size_in_map_units_minus1) + nb_bits = ceil(log(self.num_slice_groups_minus1+1)) + for i in range(0, self.pic_size_in_map_units_minus1): + v = self.slice_group_id[i] + bit_position, v = write_bits(buf, bit_position, v, nb_bits) + + bit_position = write_unsigned_exp_golomb(buf, bit_position, + self.num_ref_idx_l0_default_active_minus1) + bit_position = write_unsigned_exp_golomb(buf, bit_position, + self.num_ref_idx_l2_default_active_minus1) + bit_position = write_boolean(buf, bit_position, self.weighted_pred_flag) + bit_position = write_bits(buf, bit_position, self.weighted_bipred_idc, 2) + bit_position = write_signed_exp_golomb(buf, bit_position, self.pic_init_qp_minus26) + bit_position = write_signed_exp_golomb(buf, bit_position, self.pic_init_qs_minus26) + bit_position = write_signed_exp_golomb(buf, bit_position, self.chroma_qp_index_offset) + bit_position = write_boolean(buf, bit_position, self.deblocking_filter_control_present_flag) + bit_position = write_boolean(buf, bit_position, self.constrained_intra_pred_flag) + bit_position = write_boolean(buf, bit_position, self.redundant_pic_cnt_present_flag) + + bit_position = write_boolean(buf, bit_position, self.transform_8x8_mode_flag) + bit_position = write_boolean(buf, bit_position, self.pic_scaling_matrix_present_flag) + if self.pic_scaling_matrix_present_flag: + nb_matrices = 6 if chroma_format_idc == 3 else 2 + if self.transform_8x8_mode_flag: + nb_matrices+=6 + else: + nb_matrices = 6 + for i in range(0, nb_matrices): + matrix = self.pic_scaling_list[i] + logger.info("Retrieved pic scaling matrix: %s %d", matrix, len(matrix)) + present = len(matrix)!=0 + logger.info("Matrix is present: %s", present) + bit_position = write_boolean(buf, bit_position, present) + if present: + if i<6: + logger.info("Writing matrix: %s", matrix) + bit_position = write_scaling_list(buf, bit_position, 16, matrix) + else: + logger.info("Writing matrix: %s", matrix) + bit_position = write_scaling_list(buf, bit_position, 64, matrix) + bit_position = write_signed_exp_golomb(buf, bit_position, + self.second_chroma_qp_index_offset) + + bit_position = write_rbsp_trailing_bits(buf, bit_position) + + return buf + +@dataclass +class AVCDecoderConfiguration: + configuration_version:int=1 # u(8) + avc_profile_indication:int=0 # u(8) + profile_compatibility:int=0 # u(8) + avc_level_indication:int=0 # u(8) + length_size_minus_one:int=0 # u(2) (0,1 or 3) + num_of_sequence_parameter_sets:int=0 # u(5) + sps:dict = field(default_factory=dict) + num_of_picture_parameter_sets:int=0 #u(8) + pps:dict = field(default_factory=dict) + chroma_format:int=0 # u(2) + bit_depth_luma_minus8:int=0 # u(3) + bit_depth_chroma_minus8:int=0 # u(3) + num_of_sequence_parameter_set_ext:int=0 # u(8) + spsext:dict = field(default_factory=dict) + + def __init__(self): + self.sps = {} + self.spsext = {} + self.pps = {} + + def from_bytes(self, buf): + logger = logging.getLogger(__name__) + logger.debug('Parsing: %s', (hexdump.dump(buf,sep=':'))) + bit_position = 0 + bit_position, self.configuration_version = read_byte(buf, bit_position) + bit_position, self.avc_profile_indication = read_byte(buf, bit_position) + bit_position, self.profile_compatibility = read_byte(buf, bit_position) + bit_position, self.avc_level_indication = read_byte(buf, bit_position) + bit_position, v = read_bits(buf, bit_position, 6) + if v != 0b111111: + raise ValueError(f'Reserved bits are not equal to 0b111111: {v:x}') + bit_position, self.length_size_minus_one = read_bits(buf, bit_position, 2) + bit_position, v = read_bits(buf, bit_position, 3) + if v != 0b111: + raise ValueError(f'Reserved bits are not equal to 0b111: {v:x}') + bit_position, self.num_of_sequence_parameter_sets= read_bits(buf, bit_position, 5) + logger.debug('Number of SPS: %d', self.num_of_sequence_parameter_sets) + for _ in range(0,self.num_of_sequence_parameter_sets): + bit_position, length = read_word(buf, bit_position) + if bit_position % 8 != 0: + raise ValueError(f'SPS is not located at a byte boundary: {bit_position:d}') + + sps = SPS() + sodb = rbsp_to_sodb(buf[floor(bit_position/8):]) + bit_length = sps.from_bytes(sodb) + spsid = sps.seq_parameter_set_id + self.sps[spsid] = sps + + parsed_length = floor(bit_length/8) + logger.debug('Expected length of SPS: %d bytes. Parsed: %d bytes', length, + parsed_length) + # Parse length can be shorter than length because of rewriting from RBSP to SODB + # (that is shorter). + # So we advance of indicated length. + bit_position+=length*8 + + logger.debug('Bit position:%d. Reading one byte of: %s', bit_position, + hexdump.dump(buf[floor(bit_position/8):], sep=':')) + bit_position, self.num_of_picture_parameter_sets = read_byte(buf, bit_position) + logger.debug('Number of PPS: %d', self.num_of_picture_parameter_sets) + for _ in range(0,self.num_of_picture_parameter_sets): + bit_position, length = read_word(buf, bit_position) + if bit_position % 8 != 0: + raise ValueError('PPS is not located at a byte boundary: {bit_position:d}') + + pps = PPS() + sodb = rbsp_to_sodb(buf[floor(bit_position/8):]) + bit_length = pps.from_bytes(sodb, self.chroma_format) + ppsid = pps.pic_parameter_set_id + self.pps[ppsid] = pps + + parsed_length = floor(bit_length/8) + logger.debug('Expected length of PPS: %d bytes. Parsed: %d bytes', length, + parsed_length) + # Parse length can be shorter than length because of rewriting from RBSP to SODB + # (that is shorter). + # So we advance of indicated length. + bit_position+=length*8 + + logger.debug('Remaining bits: %s', hexdump.dump(buf[floor(bit_position/8):])) + + if self.avc_profile_indication in [100, 110, 122, 144]: + bit_position, reserved = read_bits(buf, bit_position, 6) + if reserved != 0b111111: + raise ValueError(f'Reserved bits are different from 111111: {reserved:x}') + bit_position, self.chroma_format = read_bits(buf, bit_position, 2) + bit_position, reserved = read_bits(buf, bit_position, 5) + if reserved != 0b11111: + raise ValueError(f'Reserved bits are different from 11111: {reserved:x}') + bit_position, self.bit_depth_luma_minus8 = read_bits(buf, bit_position, 3) + bit_position, reserved = read_bits(buf, bit_position, 5) + if reserved != 0b11111: + raise ValueError(f'Reserved bits are different from 11111: {reserved:x}') + bit_position, self.bit_depth_chroma_minus8 = read_bits(buf, bit_position, 3) + bit_position, self.num_of_sequence_parameter_set_ext = read_byte(buf, bit_position) + for _ in range(0, self.num_of_sequence_parameter_set_ext): + # TODO: parse SPSextended + logger.error('Parsing of SPS extended not yet implemented !') + pass + + + + def to_bytes(self): + logger = logging.getLogger(__name__) + + buf = bytearray() + bit_position = 0 + bit_position = write_byte(buf, bit_position, self.configuration_version) + bit_position = write_byte(buf, bit_position, self.avc_profile_indication) + bit_position = write_byte(buf, bit_position, self.profile_compatibility) + bit_position = write_byte(buf, bit_position, self.avc_level_indication) + bit_position = write_bits(buf, bit_position, 0b111111, 6) + bit_position = write_bits(buf, bit_position, self.length_size_minus_one, 2) + bit_position = write_bits(buf, bit_position, 0b111, 3) + bit_position = write_bits(buf, bit_position, self.num_of_sequence_parameter_sets, 5) + for spsid, sps in self.sps.items(): + sodb = sps.to_bytes() + sodb_length = len(sodb) + rbsp = sodb_to_rbsp(sodb) + rbsp_length = len(rbsp) + + logger.debug('SODB length: %d RBSP length:%d', sodb_length, rbsp_length) + + bit_position = write_word(buf, bit_position, rbsp_length) + buf.extend(rbsp) + bit_position+=rbsp_length*8 + + logger.debug('2. Buffer: %s', hexdump.dump(buf, sep=':')) + + bit_position = write_byte(buf, bit_position, self.num_of_picture_parameter_sets) + for ppsid, lpps in self.pps.items(): + logger.debug('Writing PPS: %d', ppsid) + # TODO: does chroma_format should come from self ? + sodb = lpps.to_bytes(self.chroma_format) + sodb_length = len(sodb) + rbsp = sodb_to_rbsp(sodb) + rbsp_length = len(rbsp) + + logger.debug('SODB length: %d RBSP length:%d', sodb_length, rbsp_length) + + bit_position = write_word(buf, bit_position, rbsp_length) + buf.extend(rbsp) + bit_position+=rbsp_length*8 + + if self.avc_profile_indication in [ 100, 110, 122, 144]: + bit_position = write_bits(buf, bit_position, 0b111111, 6) + bit_position = write_bits(buf, bit_position, self.chroma_format, 2) + bit_position = write_bits(buf, bit_position, 0b11111, 5) + bit_position = write_bits(buf, bit_position, self.bit_depth_luma_minus8, 3) + bit_position = write_bits(buf, bit_position, 0b11111, 5) + bit_position = write_bits(buf, bit_position, self.bit_depth_chroma_minus8, 3) + bit_position = write_byte(buf, bit_position, self.num_of_sequence_parameter_set_ext) + for _ in range(0, self.num_of_sequence_parameter_set_ext): + # TODO: dump SPSextended + logger.error('Dumping SPS extended not yet implemented') + pass + + return buf + + def merge(self, config): + # Check config compatibility + if self.configuration_version != config.configuration_version: + raise ValueError(f'Configuration versions are different: {self.configuration_version:d}\ + vs {config.configuration_version:d}') + if self.avc_profile_indication != config.avc_profile_indication: + raise ValueError(f'AVC profiles are different: {self.avc_profile_indication:d} vs \ + {config.avc_profile_indication:d}') + if self.profile_compatibility != config.profile_compatibility: + raise ValueError(f'Profile compatilities are different: {self.profile_compatibility:d} \ + vs {config.profile_compatibility:d}') + if self.avc_level_indication != config.avc_level_indication: + raise ValueError(f'Level indications are different: {self.avc_level_indication:d} vs \ + {config.avc_level_indication:d}') + if self.length_size_minus_one != config.length_size_minus_one: + raise ValueError(f'Length units are different: {self.length_size_minus_one:d} vs \ + {config.length_size_minus_one:d}') + if self.chroma_format != config.chroma_format: + raise ValueError(f'Colour format are different: {self.chroma_format:d} vs \ + {config.chroma_format:d}') + if self.bit_depth_luma_minus8 != config.bit_depth_luma_minus8: + raise ValueError(f'Depth of luminance are different: {self.bit_depth_luma_minus8:d} vs \ + {config.bit_depth_luma_minus8:d}') + if self.bit_depth_chroma_minus8 != config.bit_depth_chroma_minus8: + raise ValueError(f'Depth of chromaticity are different: \ + {self.bit_depth_chroma_minus8:d} vs {config.bit_depth_luma_minus8:d}') + + for spsid in config.sps: + sps = config.sps[spsid] + if spsid in self.sps: + localsps = self.sps[spsid] + if sps!=localsps: + raise ValueError(f'Profile are not compatible. They contain two different SPS\ + with the same identifier ({spsid:d}): {localsps}\n{sps}\n') + self.sps[spsid] = sps + + self.num_of_sequence_parameter_sets = len(self.sps) + + for ppsid in config.pps: + pps = config.pps[ppsid] + if ppsid in self.pps: + localpps = self.pps[ppsid] + if pps!=localpps: + raise ValueError(f'Profile are not compatible. They contain two different PPS\ + with the same identifier ({ppsid:d}): {localpps}\n{pps}\n') + self.pps[ppsid] = pps + + self.num_of_picture_parameter_sets = len(self.pps) + + # TODO: do the same with extended SPS ! + +@typechecked +def parse_codec_private(codec_private_data: bytes) -> AVCDecoderConfiguration: + if codec_private_data[0] != 0x63: + raise ValueError(f'Matroska header is wrong: {codec_private_data[0]:x}') + if codec_private_data[1] != 0xA2: + raise ValueError(f'Matroska header is wrong: {codec_private_data[1]:x}') + length = codec_private_data[2] + if length == 0: + raise ValueError('Matroska length cannot start with zero byte.') + for nb_zeroes in range(0,8): + b = read_bit(codec_private_data[2:], nb_zeroes) + if b != 0: + break + mask = 2^(7-nb_zeroes)-1 + length = codec_private_data[2] and mask + for i in range(0, nb_zeroes): + length*=256 + length+=(codec_private_data[3+i]) + byte_position = 3+nb_zeroes + avcconfig = AVCDecoderConfiguration() + avcconfig.from_bytes(codec_private_data[byte_position:]) + + return avcconfig + +@typechecked +def get_avc_config_from_h264(input_file: IO[bytes]) -> AVCDecoderConfiguration: + logger = logging.getLogger(__name__) + + # TODO: improve this ... + rbsp = input_file.read(1000) + sodb = rbsp_to_sodb(rbsp) + + bit_position = 0 + bit_position, start_code = read_long(sodb, bit_position) + if start_code != 1: + raise ValueError(f'Starting code not detected: {start_code:x}') + sps = SPS() + bit_length = sps.from_bytes(sodb[4:]) + bit_position+=bit_length + + bit_position, start_code = read_long(sodb, bit_position) + if start_code != 1: + raise ValueError(f'Starting code not detected: {start_code:x}') + pps = PPS() + bit_length = pps.from_bytes(sodb[floor(bit_position/8):], sps.chroma_format_idc) + logger.debug(pps) + + avcconfig = AVCDecoderConfiguration() + avcconfig.configuration_version = 1 + avcconfig.avc_profile_indication = sps.profile_idc + avcconfig.profile_compatibility = 0 + avcconfig.avc_level_indication = sps.level_idc + avcconfig.length_size_minus_one = 3 + avcconfig.num_of_sequence_parameter_sets = 1 + avcconfig.num_of_picture_parameter_sets = 1 + avcconfig.num_of_sequence_parameter_set_ext = 0 + avcconfig.chroma_format = sps.chroma_format_idc + avcconfig.bit_depth_chroma_minus8 = sps.bit_depth_chroma_minus8 + avcconfig.bit_depth_luma_minus8 = sps.bit_depth_luma_minus8 + avcconfig.sps[sps.seq_parameter_set_id] = sps + avcconfig.pps[pps.pic_parameter_set_id] = pps + + return avcconfig + +# Unused ? +@typechecked +def get_codec_private_data_from_h264(input_file: IO[bytes]) -> AVCDecoderConfiguration: + avcconfig = get_avc_config_from_h264(input_file) + res = dump_codec_private_data(avcconfig) + + return res + +@typechecked +def parse_mkv_tree(mkvinfo_path:str, input_file: IO[bytes]) -> dict[str,tuple[int,int]]: + logger = logging.getLogger(__name__) + + infd = input_file.fileno() + lseek(infd, 0, SEEK_SET) + set_inheritable(infd, True) + env = {**os.environ, 'LANG': 'C'} + elements = {} + + with Popen([mkvinfo_path, '-z', '-X', '-P', f'/proc/self/fd/{infd:d}'], stdout=PIPE, + close_fds=False, env=env) as mkvinfo: + out, _ = mkvinfo.communicate() + out = out.decode('utf8') + prefix = [] + reg_exp = (r"(^(?P\+)|(\|(?P[ ]*\+))).*at (?P[0-9]+)" + r" size (?P[0-9]+).*$") + p = re.compile(reg_exp) + prev_depth = -1 + for line in out.splitlines(): + m = p.match(line) + if m is None: + logger.error("Impossible to match line: %s", line) + else: + position = int(m.group('position')) + size = int(m.group('size')) + root = m.group('root') is not None + if root: + depth = 0 + else: + depth = len(m.group('depth')) + + if depth > prev_depth: + for _ in range(depth-prev_depth): + prefix.append(1) + elif depth == prev_depth: + subid = prefix[-1] + subid+=1 + prefix.pop() + prefix.append(subid) + else: + for _ in range(prev_depth-depth): + prefix.pop() + subid = prefix[-1] + subid+=1 + prefix.pop() + prefix.append(subid) + + prev_depth = depth + key=".".join(map(str, prefix)) + elements[key] = (position, size) + + mkvinfo.wait() + return elements + +# MKV is formatted as an EBML file (Extended Binary Markup Langage). +# cf http://matroska-org.github.io/libebml/specs.html +# It is a Type, Length, Value (TLV) kind of binary file. +# Types are encoded as follows: +# 1xxx xxxx - Class A IDs (2^7 -1 possible values) +# 01xx xxxx xxxx xxxx - Class B IDs (2^14-1 possible values) +# 001x xxxx xxxx xxxx xxxx xxxx - Class C IDs (2^21-1 possible values) +# 0001 xxxx xxxx xxxx xxxx xxxx xxxx xxxx - Class D IDs (2^28-1 possible values) +# Lengths are encoded as follows: +# 1xxx xxxx +# value 0 to 2^7-2 +# 01xx xxxx xxxx xxxx +# value 0 to 2^14-2 +# 001x xxxx xxxx xxxx xxxx xxxx +# value 0 to 2^21-2 +# 0001 xxxx xxxx xxxx xxxx xxxx xxxx xxxx +# value 0 to 2^28-2 +# 0000 1xxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx +# value 0 to 2^35-2 +# 0000 01xx xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx +# value 0 to 2^42-2 +# 0000 001x xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx +# value 0 to 2^49-2 +# 0000 0001 xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx xxxx +# value 0 to 2^56-2 + +@typechecked +def get_ebml_length(length:int) -> bytes|None: + logger = logging.getLogger(__name__) + + if 0 <= length <= 2**7-2: + size = 1 + elif length <= 2**14-2: + size = 2 + elif length <= 2**21-2: + size = 3 + elif length <= 2**28-2: + size = 4 + elif length <= 2**35-2: + size = 5 + elif length <= 2**42-2: + size = 6 + elif length <= 2**49-2: + size = 7 + elif length <= 2**56-2: + size = 8 + elif length < 0: + logger.error('Impossible to encode a negative length with EBML.') + return None + else: + logger.error('Impossible to encode a length larger than 2^56-2 with EBML.') + return None + + encoded_length = length + ((128>>(size-1))<<((size-1)*8)) + res = (encoded_length).to_bytes(size, byteorder='big') + return res + +@typechecked +def dump_codec_private_data(avc_decoder_configuration: AVCDecoderConfiguration) -> bytearray: + logger = logging.getLogger(__name__) + # Rebuild a Matroska Codec Private Element + res = bytearray() + # Code private element + res.extend(b'\x63\xA2') + buf = avc_decoder_configuration.to_bytes() + logger.debug('AVC configuration bitstream: %s (length: %d))', hexdump.dump(buf, sep=':'), + len(buf)) + + embl_length = get_ebml_length(len(buf)) + logger.debug('EMBL encoded length: %s', hexdump.dump(embl_length, sep=':')) + res.extend(embl_length) + res.extend(buf) + + return res + +@typechecked +def change_ebml_element_size(input_file: IO[bytes], position:int, addendum:int) -> int: + logger = logging.getLogger(__name__) + + initial_position = position + infd = input_file.fileno() + lseek(infd, position, SEEK_SET) + + buf = read(infd, 1) + element_type = int.from_bytes(buf, byteorder='big') + mask=128 + found = False + for i in range(1,5): + if element_type&mask: + type_size = i + found = True + break + else: + mask = mask>>1 + + if not found: + logger.error('Size of element type cannot be determined: %d', element_type) + exit(-1) + + # We seek to size + position+=type_size + lseek(infd, position, SEEK_SET) + + buf = read(infd, 1) + size_head = int.from_bytes(buf, byteorder='big') + logger.info('First byte of size: %x', size_head) + mask=128 + found = False + for i in range(1,9): + if size_head&mask: + size_of_data_size = i + found = True + break + else: + mask = mask>>1 + + if not found: + logger.error('Size of data size cannot be determined: %d', size_head) + exit(-1) + else: + logger.info('Size of data size: %d.', size_of_data_size) + + lseek(infd, position, SEEK_SET) + old_size_buf = read(infd, size_of_data_size) + max_size = 2**(size_of_data_size*7)-2 + size_of_data = int.from_bytes(old_size_buf, byteorder='big') + logger.info('Size of data with mask: %x mask: %d.', size_of_data, mask) + size_of_data-= (mask<<((size_of_data_size-1)*8)) + logger.info('Found element at position: %d, size of type: %d size of data: %d \ + maximal size: %d.', initial_position, type_size, size_of_data, max_size) + + new_size = size_of_data+addendum + delta = 0 + if new_size > max_size: + # TODO: Test this code ... + new_encoded_size = get_ebml_length(new_size) + size_of_new_encoded_size = len(new_encoded_size) + if size_of_new_encoded_size <= size_of_data_size: + logger.error('New encoded size is smaller (%d) or equal than previous size (%d).\ + This should not happen.', size_of_new_encoded_size, size_of_data_size) + exit(-1) + # The difference of length between old size field and new one. + delta = size_of_new_encoded_size - size_of_data_size + file_length = fstat(infd).st_size + # We seek after actual length field + lseek(infd, position+size_of_data_size, SEEK_SET) + # We read the rest of file + tail = read(infd, file_length-(position+size_of_data_size)) + # We increase file length + ftruncate(infd, file_length+delta) + # We go to the beginning of length field + lseek(infd, position, SEEK_SET) + # We write the new length field + write(infd, new_encoded_size) + # We overwrite the rest of file with its previous content that has been offset. + write(infd, tail) + else: + size = new_size + ((128>>(size_of_data_size-1))<<((size_of_data_size-1)*8)) + new_size_buf = (size).to_bytes(size_of_data_size, byteorder='big') + + logger.info('Old encoded size: %s New encoded size: %s', hexdump.dump(old_size_buf,sep=':'), + hexdump.dump(new_size_buf, sep=':')) + lseek(infd, position, SEEK_SET) + write(infd, new_size_buf) + + # We return the potential increase in size of the file if the length field had to be increased. + return delta + +@typechecked +def change_codec_private_data(mkvinfo_path:str, input_file: IO[bytes], codec_data:bytes) -> None: + logger = logging.getLogger(__name__) + + infd = input_file.fileno() + lseek(infd, 0, SEEK_SET) + + current_length = fstat(infd).st_size + logger.info('Current size of file: %d', current_length) + position, current_data = get_codec_private_data_from_mkv(mkvinfo_path, input_file) + current_data_length = len(current_data) + future_length = current_length - current_data_length + len(codec_data) + logger.info('Expected size of file: %d', future_length) + + logger.info('Current data at position %d: %s', position, hexdump.dump(current_data, sep=":")) + logger.info('Future data: %s', hexdump.dump(codec_data, sep=":")) + + elements = parse_mkv_tree(mkvinfo_path, input_file) + + found = False + for key, (pos,size) in elements.items(): + if pos == position: + logger.info('Codec private data key: %s', key) + found = True + break + + if not found: + logger.error('Impossible to retrieve the key of codec private data') + exit(-1) + + if current_length < future_length: + lseek(infd, position+current_data_length, SEEK_SET) + tail = read(infd, current_length-(position+current_data_length)) + # We extend the file at the end with zeroes + ftruncate(infd, future_length) + lseek(infd, position+len(codec_data), SEEK_SET) + write(infd, tail) + lseek(infd, position, SEEK_SET) + write(infd, codec_data) + elif current_length == future_length: + # Almost nothing to do except overwriting old private codec data with new ones. + lseek(infd, position, SEEK_SET) + write(infd, codec_data) + else: + lseek(infd, position+current_data_length, SEEK_SET) + tail = read(infd, current_length-(position+current_data_length)) + lseek(infd, position+len(codec_data), SEEK_SET) + write(infd, tail) + lseek(infd, position, SEEK_SET) + write(infd, codec_data) + # We reduce the length of file. + ftruncate(infd, future_length) + + # We have to modify the tree elements up to the root that contains the codec private data. + keys = key.split('.') + logger.info(keys) + + delta = future_length-current_length + # if there is no modification of the private codec data, no need to change anything. + if delta != 0: + for _ in range(0, len(keys)-1): + keys.pop() + key=".".join(map(str, keys)) + pos, size = elements[key] + logger.info('Trying to fix element with key: %s at position: %d with actual size: %d.', + key, pos, size) + # Changing an element can increase its size (in very rare case). + # In that case, we update the new delta that will be larger (because the element has + # been resized). + delta+=change_ebml_element_size(input_file, pos, delta) + +@typechecked +def get_format(ffprobe_path:str, input_file: IO[bytes]) -> dict|None: + logger = logging.getLogger(__name__) + + infd = input_file.fileno() + lseek(infd, 0, SEEK_SET) + set_inheritable(infd, True) + with Popen([ffprobe_path, '-loglevel', 'quiet', '-show_format', '-of', 'json', '-i', + f'/proc/self/fd/{infd:d}'], stdout=PIPE, close_fds=False) as ffprobe: + out, _ = ffprobe.communicate() + out = json.load(BytesIO(out)) + if 'format' in out: + return out['format'] + else: + logger.error('Impossible to retrieve format of file') + + return None + +@typechecked +def get_movie_duration(ffprobe_path:str, input_file: IO[bytes]) -> timedelta|None: + logger = logging.getLogger(__name__) + + infd = input_file.fileno() + lseek(infd, 0, SEEK_SET) + set_inheritable(infd, True) + with Popen([ffprobe_path, '-loglevel', 'quiet', '-show_format', '-of', 'json', '-i', + f'/proc/self/fd/{infd:d}'], stdout=PIPE, close_fds=False) as ffprobe: + out, _ = ffprobe.communicate() + out = json.load(BytesIO(out)) + if 'format' in out and 'duration' in out['format']: + duration = floor(float(out['format']['duration'])) + ts = timedelta(seconds=duration) + return ts + else: + logger.error('Impossible to retrieve duration of movie') + + return None + +# ffprobe -loglevel quiet -select_streams v:0 -show_entries stream=width,height -of json sample.ts +@typechecked +def get_video_dimensions(ffprobe_path:str, input_file: IO[bytes]) -> tuple[int,int]: + logger = logging.getLogger(__name__) + + infd = input_file.fileno() + lseek(infd, 0, SEEK_SET) + set_inheritable(infd, True) + with Popen([ffprobe_path, '-loglevel', 'quiet', '-select_streams', 'v:0', '-show_entries',\ + 'stream=width,height', '-of', 'json', '-i', f'/proc/self/fd/{infd:d}'],\ + stdout=PIPE, close_fds=False) as ffprobe: + out, _ = ffprobe.communicate() + out = json.load(BytesIO(out)) + if 'streams' in out: + video = out['streams'][0] + if ('width' in video) and ('height' in video): + return int(video['width']), int(video['height']) + + logger.error('Impossible to retrieve dimensions of video') + exit(-1) + +@typechecked +def get_streams(ffprobe_path:str, input_file: IO[bytes]) -> list|None: + logger = logging.getLogger(__name__) + + infd = input_file.fileno() + lseek(infd, 0, SEEK_SET) + set_inheritable(infd, True) + with Popen([ffprobe_path, '-loglevel', 'quiet', '-show_streams', '-of', 'json', '-i', + f'/proc/self/fd/{infd:d}'], stdout=PIPE, close_fds=False) as ffprobe: + out, _ = ffprobe.communicate() + out = json.load(BytesIO(out)) + if 'streams' in out: + return out['streams'] + else: + logger.error('Impossible to retrieve streams inside file') + + return None + +@typechecked +def with_subtitles(ffprobe_path:str, input_file: IO[bytes]) -> bool: + """ + Checks if a media file contains subtitles using the ffprobe tool. + + This function runs the ffprobe binary with the specified input file and parses the output + to determine if the file contains subtitles. + It returns True if at least one subtitle stream is found, False otherwise. + + Args: + ffprobe_path (str): The path to the ffprobe binary. + input_file (IO[bytes]): The input media file. + + Returns: + bool: + - True if the media file contains at least one subtitle stream + - False if: + - the media file does not contain any subtitle streams + - an error occurs while running the ffprobe binary + - the streams information cannot be retrieved from the media file + """ + logger = logging.getLogger(__name__) + + infd = input_file.fileno() + lseek(infd, 0, SEEK_SET) + set_inheritable(infd, True) + with Popen([ffprobe_path, '-loglevel', 'quiet', '-show_streams', '-of', 'json', '-i', + f'/proc/self/fd/{infd:d}'], stdout=PIPE, close_fds=False) as ffprobe: + out, _ = ffprobe.communicate() + out = json.load(BytesIO(out)) + if 'streams' in out: + streams = out['streams'] + for stream in streams: + if 'codec_type' in stream and stream['codec_type'] == 'subtitle': + return True + else: + logger.error('Impossible to retrieve streams inside file') + + return False + +@typechecked +def parse_timestamp(ts:str) -> timedelta|None: + """ + Parse a timestamp string into a timedelta object. + + This function takes a string representing a timestamp in the format HH:MM:SS[.us] and returns + a timedelta object representing the corresponding time interval. + The timestamp string can have an optional microsecond component. + + Args: + ts (str): The timestamp string to parse. + + Returns: + timedelta | None: + - A timedelta object representing the parsed timestamp + - None if: + - the timestamp string is not in the correct format + - the timestamp values are out of range (e.g. hour > 23, minute > 59, etc.) + """ + logger = logging.getLogger(__name__) + + ts_reg_exp = (r'^(?P[0-9]{1,2}):(?P[0-9]{1,2})' + r':(?P[0-9]{1,2})(\.(?P[0-9]{1,6}))?$') + p = re.compile(ts_reg_exp) + m = p.match(ts) + if m is None: + logger.warning("Impossible to parse timestamp: %s", ts) + return None + + values = m.groupdict() + hour = 0 + minute = 0 + second = 0 + us = 0 + if values['hour'] is not None: + hour = int(values['hour']) + if values['minute'] is not None: + minute = int(values['minute']) + if values['second'] is not None: + second = int(values['second']) + if values['us'] is not None: + us = int(values['us']) + + if hour < 0 or hour > 23: + logger.error("hour must be in [0,24[") + return None + if minute < 0 or minute > 59: + logger.error("minute must be in [0,60[") + return None + if second < 0 or second > 59: + logger.error("second must be in [0,60[") + return None + if us < 0 or us > 1000000: + logger.error("milliseconds must be in [0,1000000[") + return None + res = timedelta(hours=hour, minutes=minute, seconds=second, microseconds=us) + + return res + +@typechecked +def parse_time_interval(interval: str) -> tuple[timedelta, timedelta] | None: + """ + Parse a time interval string into a tuple of two timedelta objects. + + This function takes a string representing a time interval in the format HH:MM:SS[.ms]-HH:MM:SS[.ms] and returns a tuple of two timedelta objects representing the start and end times of the interval. + The time interval string can have an optional millisecond component. + + Args: + interval (str): The time interval string to parse. + + Returns: + tuple[timedelta, timedelta] | None: + - A tuple of two timedelta objects representing the start and end times of the interval + - None if: + - the time interval string is not in the correct format + - the time values are out of range (e.g. hour > 23, minute > 59, etc.) + - the end time is before the start time (non-monotonic interval) + """ + logger = logging.getLogger(__name__) + + interval_reg_exp = (r'^(?P[0-9]{1,2}):(?P[0-9]{1,2}):(?P[0-9]{1,2})' + r'(\.(?P[0-9]{1,3}))?-(?P[0-9]{1,2}):(?P[0-9]{1,2})' + r':(?P[0-9]{1,2})(\.(?P[0-9]{1,3}))?$') + p = re.compile(interval_reg_exp) + m = p.match(interval) + if m is None: + logger.error("Impossible to parse time interval") + return None + + values = m.groupdict() + hour1 = 0 + minute1 = 0 + second1 = 0 + ms1 = 0 + hour2 = 0 + minute2 = 0 + second2 = 0 + ms2 = 0 + if values['hour1'] is not None: + hour1 = int(values['hour1']) + if values['minute1'] is not None: + minute1 = int(values['minute1']) + if values['second1'] is not None: + second1 = int(values['second1']) + if values['ms1'] is not None: + ms1 = int(values['ms1']) + if values['hour2'] is not None: + hour2 = int(values['hour2']) + if values['minute2'] is not None: + minute2 = int(values['minute2']) + if values['second2'] is not None: + second2 = int(values['second2']) + if values['ms2'] is not None: + ms2 = int(values['ms2']) + + if hour1 < 0 or hour1 > 23: + logger.error("hour must be in [0,24[") + return None, None + if minute1 < 0 or minute1 > 59: + logger.error("minute must be in [0,60[") + return None, None + if second1 < 0 or second1 > 59: + logger.error("second must be in [0,60[") + return None, None + if ms1 < 0 or ms1 > 1000: + logger.error("milliseconds must be in [0,1000[") + return None, None + + if hour2 < 0 or hour2 > 23: + logger.error("hour must be in [0,24[") + return None, None + if minute2 < 0 or minute2 > 59: + logger.error("minute must be in [0,60[") + return None, None + if second2 < 0 or second2 > 59: + logger.error("second must be in [0,60[") + return None, None + if ms2 < 0 or ms2 > 1000: + logger.error("milliseconds must be in [0,1000[") + return None, None + + ts1 = timedelta(hours=hour1, minutes=minute1, seconds=second1, microseconds=ms1*1000) + ts2 = timedelta(hours=hour2, minutes=minute2, seconds=second2, microseconds=ms2*1000) + + if ts2 < ts1: + logger.error("Non monotonic interval") + return None,None + + return (ts1, ts2) + +@typechecked +def compare_time_interval(interval1: tuple[timedelta, timedelta], + interval2: tuple[timedelta, timedelta]) -> int: + """ + Compare two time intervals. + + This function compares two time intervals represented by tuples of two timedelta objects. + It returns an integer indicating the relationship between the two intervals: + - -1 if interval 1 is before interval 2 + - 1 if interval 1 is after interval 2 + - 0 if the two intervals overlap or are equal + + Args: + interval1 (tuple[timedelta, timedelta]): The first time interval + interval2 (tuple[timedelta, timedelta]): The second time interval + + Returns: + int: The relationship between the two time intervals + """ + ts11,ts12 = interval1 + ts21,ts22 = interval2 + + if ts12 < ts21: + return -1 + elif ts22 < ts11: + return 1 + else: + return 0 + +@typechecked +def ffmpeg_convert(ffmpeg_path:str, ffprobe_path:str, input_file: IO[bytes], input_format:str, + output_file: IO[bytes], output_format:str, duration: timedelta): + logger = logging.getLogger(__name__) + + width, height = get_video_dimensions(ffprobe_path, input_file) + subtitles = with_subtitles(ffprobe_path, input_file) + + infd = input_file.fileno() + outfd = output_file.fileno() + set_inheritable(infd, True) + set_inheritable(outfd, True) + + if logger.getEffectiveLevel() == logging.DEBUG: + log = [] + else: + log = [ '-loglevel', 'quiet' ] + + params = [ffmpeg_path, '-y',]+log+['-progress', '/dev/stdout', '-canvas_size', + f'{width:d}x{height:d}', '-f', input_format, + '-i', f'/proc/self/fd/{infd:d}', '-map', '0:v', + '-map', '0:a'] + if subtitles: + params.extend(['-map', '0:s']) + params.extend(['-bsf:v', 'h264_mp4toannexb,dump_extra=freq=keyframe', '-vcodec', 'copy', + '-acodec', 'copy']) + if subtitles: + params.extend(['-scodec', 'dvdsub']) + params.extend(['-r:0', '25', '-f', output_format, f'/proc/self/fd/{outfd:d}']) + + logger.debug('Executing %s', params) + + with Popen(params, stdout=PIPE, close_fds=False) as ffmpeg: + pb = tqdm(TextIOWrapper(ffmpeg.stdout, encoding="utf-8"), + total=int(duration/timedelta(seconds=1)), unit='s', desc='Conversion') + for line in pb: + if line.startswith('out_time='): + ts = line.split('=')[1].strip() + ts = parse_timestamp(ts) + if ts is not None: + pb.n = int(ts/timedelta(seconds=1)) + pb.update() + status = ffmpeg.wait() + if status != 0: + logger.error('Conversion failed with status code: %d', status) + +@typechecked +def get_ts_frame(frame: dict) -> timedelta|None: + logger = logging.getLogger(__name__) + + if 'pts_time' in frame: + pts_time = float(frame['pts_time']) + elif 'pkt_pts_time' in frame: + pts_time = float(frame['pkt_pts_time']) + else: + logger.error('Impossible to find timestamp of frame %s', frame) + return None + + ts = timedelta(seconds=pts_time) + return ts + +@typechecked +def get_packet_duration(packet: dict) -> int: + logger = logging.getLogger(__name__) + + if 'duration' in packet: + duration = int(packet['duration']) + elif 'pkt_duration' in packet: + duration = int(packet['pkt_duration']) + else: + logger.error('Impossible to find duration of packet %s', packet) + return None + + return duration + +@typechecked +def get_frames_in_stream(ffprobe_path: str, input_file: IO[bytes], begin:timedelta, end:timedelta, + stream_kind:str, sub_stream_id:int=0) -> list[dict]|None: + logger = logging.getLogger(__name__) + infd = input_file.fileno() + set_inheritable(infd, True) + + command = [ffprobe_path, '-loglevel', 'quiet', '-read_intervals', f'{begin}%{end}', + '-show_entries', 'frame', '-select_streams', + f'{stream_kind}:{sub_stream_id:d}','-of', 'json', f'/proc/self/fd/{infd:d}'] + logger.debug('Executing: %s', command) + + with Popen(command, stdout=PIPE, close_fds=False) as ffprobe: + out, _ = ffprobe.communicate() + frames = json.load(BytesIO(out)) + status = ffprobe.wait() + if status != 0: + logger.error('ffprobe failed with status code: %d', status) + return None + + # Sort frames by timestamp + tmp = {} + if 'frames' in frames: + frames = frames['frames'] + for frame in frames: + ts = get_ts_frame(frame) + if ts is None: + return None + if begin <= ts <= end: + tmp[ts]=frame + + res = [] + for ts in sorted(tmp): + res.append(tmp[ts]) + return res + else: + logger.error('Impossible to retrieve frames inside file around [%s,%s]', begin, end) + return None + +# TODO: Finish implementation of this function and use it. +@typechecked +def get_nearest_idr_frame(ffprobe_path: str, input_file: IO[bytes], timestamp:timedelta, + before: bool=True, delta: timedelta=timedelta(seconds=2)): + # pylint: disable=W0613 + logger = logging.getLogger(__name__) + + zero = timedelta() + tbegin = timestamp-delta + tend = timestamp+delta + tbegin = max(tbegin, zero) + + infd = input_file.fileno() + set_inheritable(infd, True) + + logger.debug('Looking for IDR frame in [%s, %s]', tbegin, tend) + + idrs = [] + + # Retains only IDR frame + with Popen([ffprobe_path, '-loglevel', 'quiet', '-read_intervals', f'{tbegin}%{tend}', + '-skip_frame', 'nokey', '-show_entries', 'frame', '-select_streams', 'v:0', + '-of', 'json', f'/proc/self/fd/{infd:d}'], stdout=PIPE, close_fds=False) as ffprobe: + out, _ = ffprobe.communicate() + frames = json.load(BytesIO(out)) + status = ffprobe.wait() + if status != 0: + logger.error('ffprobe failed with status code: %d', status) + return None + + if 'frames' in frames: + frames = frames['frames'] + for frame in frames: + ts = get_ts_frame(frame) + if ts is None: + return None + if tbegin <= ts <= tend: + idrs.append(frame) + else: + logger.error('Impossible to retrieve IDR frames inside file around [%s,%s]', + tbegin, tend) + return None + + return None + +@typechecked +def get_nearest_iframe(ffprobe_path:str, input_file: IO[bytes], + timestamp:timedelta, before:bool=True, + delta_max:timedelta=timedelta(seconds=15))-> tuple[int,dict]: + logger = logging.getLogger(__name__) + + infd = input_file.fileno() + set_inheritable(infd, True) + + delta = timedelta(seconds=1) + + iframe = None + while delta < delta_max: + zero = timedelta() + if before: + tbegin = timestamp-delta + else: + tbegin = timestamp + if not before: + tend = timestamp+delta + else: + tend = timestamp + tbegin = max(tbegin, zero) + logger.debug('Looking for an iframe in [%s, %s]', tbegin, tend) + + frames = get_frames_in_stream(ffprobe_path, input_file=input_file, begin=tbegin, end=tend, + stream_kind='v') + if frames is None: + logger.debug('Found no frame in [%s, %s]', tbegin, tend) + delta+=timedelta(seconds=1) + continue + + iframes = [] + for frame in frames: + if frame['pict_type'] == 'I': + iframes.append(frame) + + found = False + for frame in iframes: + ts = get_ts_frame(frame) + if ts is None: + logger.warning('I-frame with no timestamp: %s', frame) + continue + + if before and ts <= timestamp: + found = True + iframe = frame + if not before and ts >= timestamp: + found = True + iframe = frame + break + + if found: + logger.info("Found i-frame at: %s", iframe) + break + else: + delta+=timedelta(seconds=1) + continue + + if iframe is not None: + its = get_ts_frame(iframe) + nb_frames = 0 + for frame in frames: + ts = get_ts_frame(frame) + if ts is None: + logger.warning('Frame without timestamp: %s', frame) + continue + + if before: + if its <= ts <= timestamp: + logger.info("Retrieve a frame between %s and %s at %s", its, timestamp, ts) + nb_frames = nb_frames+1 + else: + if timestamp <= ts <= its: + logger.info("Retrieve a frame between %s and %s at %s", timestamp, ts, its) + nb_frames = nb_frames+1 + else: + logger.error("Impossible to find I-frame between: %s and %s", tbegin, tend) + return 0, None + + return(nb_frames, iframe) + +@typechecked +def extract_mkv_part(mkvmerge_path:str, input_file:IO[bytes], output_file:IO[bytes], + begin:timedelta, end:timedelta) -> None: + logger = logging.getLogger(__name__) + + logger.info('Extract video between I-frames at %s and %s', begin,end) + infd = input_file.fileno() + outfd = output_file.fileno() + lseek(infd, 0, SEEK_SET) + lseek(outfd, 0, SEEK_SET) + set_inheritable(infd, True) + set_inheritable(outfd, True) + env = {**os.environ, 'LANG': 'C'} + warnings = [] + + command = [mkvmerge_path, '-o', f'/proc/self/fd/{outfd:d}', '--split', f'parts:{begin}-{end}', + f'/proc/self/fd/{infd:d}'] + logger.debug('Executing: %s', command) + + with Popen(command, stdout=PIPE, close_fds=False, env=env) as mkvmerge: + pb = tqdm(TextIOWrapper(mkvmerge.stdout, encoding="utf-8"), total=100, unit='%', + desc='Extraction') + for line in pb: + if line.startswith('Progress :'): + p = re.compile('^Progress : (?P[0-9]{1,3})%$') + m = p.match(line) + if m is None: + logger.error('Impossible to parse progress') + pb.update(int(m['progress'])-pb.n) + elif line.startswith('Warning'): + warnings.append(line) + pb.update(100-pb.n) + pb.refresh() + pb.close() + + status = mkvmerge.wait() + if status == 1: + logger.warning('Extraction returns warning') + for w in warnings: + logger.warning(w) + elif status == 2: + logger.error('Extraction returns errors') + +@typechecked +def extract_pictures(ffmpeg_path:str, input_file:IO[bytes], begin:timedelta, nb_frames:int, + width:int=640, height:int=480) -> tuple[bytes,int]|tuple[None,None]: + """ + Extract pictures from a video file using FFmpeg. + + This function runs the FFmpeg binary to extract a specified number of frames from a video file, + starting at a given time. + The extracted frames are stored in memory as PPM images and returned as a tuple containing + the image data and a file descriptor to the memory created by memfd_create. + + Args: + ffmpeg_path (str): The path to the FFmpeg binary. + input_file (IO[bytes]): The input video file. + begin (timedelta): The start time of the extraction. + nb_frames (int): The number of frames to extract. + width (int, optional): The width of the extracted images. Defaults to 640. + height (int, optional): The height of the extracted images. Defaults to 480. + + Returns: + tuple[bytes, int] | tuple[None, None]: + - A tuple containing the extracted image data as bytes and a file descriptor + - A tuple containing None, None if the extraction fails + """ + logger = logging.getLogger(__name__) + + infd = input_file.fileno() + lseek(infd, 0, SEEK_SET) + outfd = memfd_create('pictures', flags=0) + set_inheritable(outfd, True) + # PPM header + # "P6\nWIDTH HEIGHT\n255\n" + header_len=2+1+ceil(log(width, 10))+1+ceil(log(height, 10))+1+3+1 + logger.debug('Header length: %d', header_len) + image_length = width*height*3+header_len + length = image_length*nb_frames + logger.debug("Estimated length: %d", length) + + command = [ffmpeg_path, '-loglevel', 'quiet' ,'-y', '-ss', f'{begin}', '-i', + f'/proc/self/fd/{infd}', '-s', f'{width:d}x{height:d}', '-vframes', f'{nb_frames:d}', + '-c:v', 'ppm','-f', 'image2pipe', f'/proc/self/fd/{outfd:d}'] + logger.debug('Executing: %s', command) + + images = bytes() + with Popen(command, stdout=PIPE, close_fds=False) as ffmpeg: + status = ffmpeg.wait() + if status != 0: + logger.error('Conversion failed with status code: %d', status) + return None, None + + lseek(outfd, 0, SEEK_SET) + images = read(outfd,length) + if len(images) != length: + logger.error("Received %d bytes but %d were expected.", len(images), length) + return None, None + + lseek(outfd, 0, SEEK_SET) + return images, outfd + +@typechecked +def extract_sound(ffmpeg_path:str, input_file: IO[bytes], begin:timedelta, output_filename:str, + packet_duration:int, sub_channel:int=0, + nb_packets:int=0, sample_rate:int=48000, + nb_channels:int=2) -> tuple[bytes,int]|tuple[None,None]: + logger = logging.getLogger(__name__) + + outfd = memfd_create(output_filename, flags=0) + infd = input_file.fileno() + lseek(infd, 0, SEEK_SET) + set_inheritable(infd, True) + set_inheritable(outfd, True) + sound = bytes() + length = int(nb_channels*sample_rate*4*nb_packets*packet_duration/1000) + + command = [ffmpeg_path, '-y', '-loglevel', 'quiet', '-ss', f'{begin}', + '-i', f'/proc/self/fd/{infd}', f'-frames:a:{sub_channel:d}', f'{nb_packets+1:d}', + '-c:a', 'pcm_s32le', '-sample_rate', f'{sample_rate:d}', + '-channels', f'{nb_channels:d}', '-f', 's32le', f'/proc/self/fd/{outfd:d}'] + logger.debug('Executing: %s', command) + + with Popen(command, stdout=PIPE, close_fds=False) as ffmpeg: + status = ffmpeg.wait() + if status != 0: + logger.error('Sound extraction returns error code: %d', status) + return None, None + + lseek(outfd, 0, SEEK_SET) + sound = read(outfd, length) + + if len(sound) != length: + logger.info("Received %d bytes but %d were expected (channels=%d, freq=%d, packets=%d,\ + duration=%d ms).", len(sound), length, nb_channels, sample_rate, nb_packets, + packet_duration) + return None, None + + return sound, outfd + +@typechecked +def dump_ppm(pictures: bytes, prefix: str, temporaries: list[IO[bytes]]) -> None: + """ + Dump PPM pictures from a bytes buffer to files. + + This function takes a bytes buffer containing PPM pictures, a prefix for the output file names, and a list of temporary files. + It extracts each PPM picture from the buffer, checks its validity, and writes it to a file. + The output files are named according to the prefix and a zero-padded three-digit number. + + Args: + pictures (bytes): The bytes buffer containing the PPM pictures. + prefix (str): The prefix for the output file names. + temporaries (list[IO[bytes]]): A list of temporary files that will be used to store the output files. + + Returns: + None + + Raises: + None, but logs errors if: + - the PPM picture is not valid (e.g. wrong magic number, dimensions, or color encoding) + - an I/O error occurs while creating or writing to an output file + """ + logger = logging.getLogger(__name__) + + # "P6\nWIDTH HEIGHT\n255\n" + pos = 0 + picture = 0 + + logger.debug('Dumping %d pictures: %s', len(pictures),prefix) + + while pos[0-9]+) (?P[0-9]+)\n$') + m = pattern.match(dimensions) + if m is not None: + width = int(m['width']) + height = int(m['height']) + else: + logger.error('Impossible to parse dimensions of picture') + return + else: + logger.error('Not a PPM picture') + return + + if max_value != 255: + logger.error('Not a valid PPM picture. Color are not encoded on byte. Max value: %d', + max_value) + + header_len=2+1+ceil(log(width, 10))+1+ceil(log(height, 10))+1+3+1 + try: + with open(filename, 'wb') as out: + temporaries.append(out) + outfd = out.fileno() + length=header_len+3*width*height + nb_bytes = 0 + while nb_bytes < length: + nb_bytes+=write(outfd, pictures[pos+nb_bytes:pos+length]) + pos+=length + picture+=1 + except IOError: + logger.error('Impossible to create file: %s', filename) + +@typechecked +def extract_all_streams(ffmpeg_path:str, ffprobe_path:str, input_file:IO[bytes], begin:timedelta, + end:timedelta, streams, files_prefix, nb_frames:int, framerate:float, + width:int, height:int, temporaries, dump_mem_fd:bool=False): + logger = logging.getLogger(__name__) + + # The command line for encoding only video track + video_encoder_params = [ ffmpeg_path, '-y', '-loglevel', 'quiet'] + video_input_params = [] + video_codec_params = [] + + # The command line to create a MKV file with the rest of tracks + generic_encoder_params = [ ffmpeg_path, '-y', '-loglevel', 'quiet' ] + generic_input_params = [] + generic_codec_params = [] + + if begin < end: + video_id=0 + audio_id=0 + subtitle_id=0 + memfds = [] + for stream in streams: + if stream['codec_type'] == 'video': + logger.info("Extracting %d frames of video stream v:%d", nb_frames, video_id) + sar = stream['sample_aspect_ratio'] + dar = stream['display_aspect_ratio'] + pixel_format = stream['pix_fmt'] + color_range = stream['color_range'] + color_space =stream['color_space'] + color_transfer = stream['color_transfer'] + color_primaries = stream['color_primaries'] + level = int(stream['level']) + level = f'{floor(level/10):d}.{level%10:d}' + chroma_location = stream['chroma_location'] + field_order = stream + match field_order: + case 'progressive': + interlaced_options = ['-field_order', '0'] + case 'tt': + interlaced_options = ['-top', '1', f'-flags:v:{video_id:d}', '+ilme+ildct', + '-field_order', '1'] + case 'bb': + interlaced_options = ['-top', '0', f'-flags:v:{video_id:d}', '+ilme+ildct', + '-field_order','2'] + case 'tb': + interlaced_options = ['-top', '1', f'-flags:v:{video_id:d}', '+ilme+ildct', + '-field_order', '3'] + case 'bt': + interlaced_options = ['-top', '0', f'-flags:v:{video_id:d}', '+ilme+ildct', + '-field_order', '4'] + case _: + interlaced_options = [] + + # ======================================= # + # TODO: adjust SAR and DAR + # https://superuser.com/questions/907933/correct-aspect-ratio-without-re-encoding-video-file + # SAR: -aspect width:height + # DAR: -bsf:v sample_aspect_ratio=1:video_format + logger.warning('Missing SAR adjustment for: %s', sar) + logger.warning('Missing DAR adjustment for: %s', dar) + logger.warning('Missing treatment for chroma location: %s', chroma_location) + codec = stream['codec_name'] + images_bytes, memfd = extract_pictures(ffmpeg_path, input_file=input_file, + begin=begin, nb_frames=nb_frames, + width=width, height=height) + if images_bytes is None: + logger.error('Impossible to extract picture from video stream.') + exit(-1) + + memfds.append(memfd) + if dump_mem_fd: + dump_ppm(images_bytes, f'{files_prefix}-{video_id:d}', temporaries) + + # We rewind to zero the memory file descriptor + lseek(memfd, 0, SEEK_SET) + set_inheritable(memfd, True) + + video_input_params.extend(['-framerate', f'{framerate:f}', '-f', 'image2pipe', '-i', + f'/proc/self/fd/{memfd:d}']) + video_codec_params.extend([f'-c:v:{video_id:d}', codec, f'-level:v:{video_id:d}', + level, '-pix_fmt', pixel_format]) + video_codec_params.extend(interlaced_options) + video_codec_params.extend([f'-colorspace:v:{video_id}', color_space, + f'-color_primaries:v:{video_id:d}', color_primaries, + f'-color_trc:v:{video_id:d}', color_transfer, + f'-color_range:v:{video_id:d}', color_range]) + video_id=video_id+1 + elif stream['codec_type'] == 'audio': + logger.debug('Audio stream: %s', stream) + sample_rate = int(stream['sample_rate']) + nb_channels = int(stream['channels']) + if 'bit_rate' in stream: + bit_rate = int(stream['bit_rate']) + else: + bit_rate = 128000 + codec = stream['codec_name'] + if 'tags' in stream: + if 'language' in stream['tags']: + generic_codec_params.extend([f'-metadata:s:a:{audio_id:d}', + f"language={stream['tags']['language']}"]) + packets = get_frames_in_stream(ffprobe_path, input_file=input_file, begin=begin, + end=end, stream_kind='a', sub_stream_id=audio_id) + nb_packets = len(packets) + logger.debug("Found %d packets to be extracted from audio track.", nb_packets) + if nb_packets > 0: + packet_duration = get_packet_duration(packets[0]) + if packet_duration is None: + return None + else: + packet_duration = 0 + + logger.info("Extracting %d packets of audio stream: a:%d" , nb_packets, audio_id) + tmpname = f'{files_prefix}-{audio_id:d}.pcm' + + sound_bytes, memfd = extract_sound(ffmpeg_path=ffmpeg_path, input_file=input_file, + begin=begin, nb_packets=nb_packets, + packet_duration=packet_duration, + output_filename=tmpname, + sample_rate=sample_rate, nb_channels=nb_channels) + + if sound_bytes is None: + logger.error('Impossible to extract sound track') + exit(-1) + + memfds.append(memfd) + + if dump_mem_fd: + try: + with open(tmpname,'wb') as output: + temporaries.append(output) + outfd = output.fileno() + pos = 0 + while pos < len(sound_bytes): + pos+=write(outfd, sound_bytes[pos:]) + except IOError: + logger.error('Impossible to create file: %s', tmpname) + return None + + # We rewind to zero the memory file descriptor + lseek(memfd, 0, SEEK_SET) + set_inheritable(memfd, True) + + generic_input_params.extend(['-f', 's32le', '-ar', f'{sample_rate:d}', '-ac', + f'{nb_channels:d}', '-i', f'/proc/self/fd/{memfd:d}']) + generic_codec_params.extend([f'-c:a:{audio_id:d}', codec, f'-b:a:{audio_id:d}', + f'{bit_rate:d}']) + audio_id=audio_id+1 + elif stream['codec_type'] == 'subtitle': + logger.info("Extracting a subtitle stream: s:%d", subtitle_id) + codec = stream['codec_name'] + generic_input_params.extend(['-i', './empty.idx']) + if 'tags' in stream: + if 'language' in stream['tags']: + generic_codec_params.extend([f'-metadata:s:s:{subtitle_id:d}', + f"language={stream['tags']['language']}"]) + generic_codec_params.extend([f'-c:s:{subtitle_id:d}', 'copy']) + subtitle_id=subtitle_id+1 + else: + logger.error("Unknown stream type: %s", stream['codec_type']) + + # Create a new MKV movie with all streams (except videos) that have been extracted. + generic_encoder_params.extend(generic_input_params) + + for index in range(0,audio_id+subtitle_id): + generic_encoder_params.extend(['-map', f'{index:d}']) + generic_encoder_params.extend(generic_codec_params) + + mkv_filename = f'{files_prefix}.mkv' + try: + mkv_output = open(mkv_filename,'wb+') + except IOError: + logger.error('Impossible to create file: %s', mkv_filename) + return None + + mkvoutfd = mkv_output.fileno() + set_inheritable(mkvoutfd, True) + generic_encoder_params.extend(['-f', 'matroska', f'/proc/self/fd/{mkvoutfd:d}']) + + logger.info('Encoding all streams (except video) into a MKV file: %s', mkv_filename) + logger.debug('Executing: %s', generic_encoder_params) + with Popen(generic_encoder_params, stdout=PIPE, close_fds=False) as ffmpeg: + status = ffmpeg.wait() + if status != 0: + logger.error('Encoding failed with status code: %d', status) + return None + + temporaries.append(mkv_output) + + h264_filename = f'{files_prefix}.h264' + try: + h264_output = open(h264_filename,'wb+') + except IOError: + logger.error('Impossible to create file: %s', h264_filename) + return None + + h264outfd = h264_output.fileno() + set_inheritable(h264outfd, True) + + video_encoder_params.extend(video_input_params) + video_encoder_params.extend(video_codec_params) + + video_encoder_params.extend([ '-x264opts', f'keyint=1:sps-id={1:d}','-bsf:v', + 'h264_mp4toannexb,dump_extra=freq=keyframe,h264_metadata=\ + overscan_appropriate_flag=1:sample_aspect_ratio=1:video_format=\ + 0:chroma_sample_loc_type=0','-f', 'h264', + f'/proc/self/fd/{h264outfd:d}']) + + logger.info('Encoding video into a H264 file: %s', h264_filename) + logger.debug('Executing: %s', video_encoder_params) + with Popen(video_encoder_params, stdout=PIPE, close_fds=False) as ffmpeg: + status = ffmpeg.wait() + if status != 0: + logger.error('Encoding failed with status code: %d', status) + return None + + temporaries.append(h264_output) + + h264_ts_filename = f'{files_prefix}-ts.txt' + try: + h264_ts_output = open(h264_ts_filename,'w+', encoding='utf8') + except IOError: + logger.error('Impossible to create file: %s', h264_ts_filename) + return None + + h264_ts_output.write('# timestamp format v2\n') + ts = 0 + for _ in range(0,nb_frames): + ts = ts+ceil(1000/framerate) + h264_ts_output.write(f'{ts:d}\n') + h264_ts_output.flush() + h264_ts_output.seek(0) + + temporaries.append(h264_ts_output) + + for memfd in memfds: + close(memfd) + + return h264_output, h264_ts_output, mkv_output + + else: + # Nothing to be done. We are already at a i-frame boundary. + return None, None + +# Merge a list of mkv files passed as input, and produce a new MKV as output +@typechecked +def merge_mkvs(mkvmerge_path:str, inputs: list[IO[bytes]], output_name:str, + concatenate: bool=True, timestamps: dict[int, IO[str]]={}) -> IO[bytes]|None: + logger = logging.getLogger(__name__) + + fds = [] + try: + out = open(output_name, 'wb+') + except IOError: + logger.error('Impossible to create file: %s', output_name) + return None + + outfd = out.fileno() + lseek(outfd, 0, SEEK_SET) + fds.append(outfd) + set_inheritable(outfd, True) + + # Timestamps of merged tracks are modified by the length of the preceding track. + # The default mode ('file') is using the largest timestamp of the whole file which may create + # desynchronize video and sound. + merge_params = [mkvmerge_path, '--append-mode', 'track'] + + first = True + partnum = 0 + for mkv in inputs: + if mkv is not None: + fd = mkv.fileno() + fds.append(fd) + set_inheritable(fd, True) + # If we pass a timestamps file associated with the considered track, use it. + if partnum in timestamps: + tsfd = timestamps[partnum].fileno() + lseek(tsfd, 0, SEEK_SET) + fds.append(tsfd) + set_inheritable(tsfd, True) + merge_params.extend(['--timestamps', f'{partnum:d}:/proc/self/fd/{tsfd:d}']) + if first: + merge_params.append(f'/proc/self/fd/{fd:d}') + first = False + elif concatenate: + merge_params.append(f'+/proc/self/fd/{fd:d}') + else: + merge_params.append(f'/proc/self/fd/{fd:d}') + partnum+=1 + + merge_params.extend(['-o', f'/proc/self/fd/{outfd:d}']) + + # We merge all files. + warnings = [] + env = {**os.environ, 'LANG': 'C'} + logger.debug('Executing: LANG=C %s', merge_params) + + with Popen(merge_params, stdout=PIPE, close_fds=False, env=env) as mkvmerge: + pb = tqdm(TextIOWrapper(mkvmerge.stdout, encoding="utf-8"), total=100, unit='%', + desc='Merging') + for line in pb: + if line.startswith('Progress :'): + p = re.compile('^Progress : (?P[0-9]{1,3})%$') + m = p.match(line) + if m is None: + logger.error('Impossible to parse progress') + pb.n = int(m['progress']) + pb.update() + elif line.startswith('Warning'): + warnings.append(line) + + status = mkvmerge.wait() + if status == 1: + logger.warning('Extraction returns warning') + for w in warnings: + logger.warning(w) + elif status == 2: + logger.error('Extraction returns errors') + + for fd in fds: + set_inheritable(fd, False) + + return out + +def find_subtitles_tracks(ffprobe_path:str, input_file: IO[bytes]) -> dict|None: + logger = logging.getLogger(__name__) + + infd = input_file.fileno() + lseek(infd, 0, SEEK_SET) + set_inheritable(infd, True) + + command = [ffprobe_path, '-loglevel','quiet', '-i', f'/proc/self/fd/{infd:d}', + '-select_streams', 's', '-show_entries', 'stream=index:stream_tags=language', + '-of', 'json'] + logger.debug('Executing: %s', command) + + with Popen(command, stdout=PIPE, close_fds=False) as ffprobe: + out, _ = ffprobe.communicate() + out = json.load(BytesIO(out)) + if 'streams' in out: + return out['streams'] + else: + logger.error('Impossible to retrieve format of file') + + ffprobe.wait() + return None + +@typechecked +def extract_track_from_mkv(mkvextract_path: str, input_file: IO[bytes], index, + output_file: IO[bytes], timestamps) -> None: + logger = logging.getLogger(__name__) + + infd = input_file.fileno() + lseek(infd, 0, SEEK_SET) + set_inheritable(infd, True) + + outfd = output_file.fileno() + lseek(outfd, 0, SEEK_SET) + set_inheritable(outfd, True) + + tsfd = timestamps.fileno() + lseek(tsfd, 0, SEEK_SET) + set_inheritable(tsfd, True) + + params = [ mkvextract_path, f'/proc/self/fd/{infd:d}', 'tracks', + f'{index:d}:/proc/self/fd/{outfd:d}', 'timestamps_v2', + f'{index:d}:/proc/self/fd/{tsfd:d}'] + + env = {**os.environ, 'LANG': 'C'} + logger.debug('Executing: LANG=C %s', params) + + with Popen(params, stdout=PIPE, close_fds=False, env=env) as extract: + pb = tqdm(TextIOWrapper(extract.stdout, encoding="utf-8"), total=100, unit='%', + desc='Extraction of track') + for line in pb: + if line.startswith('Progress :'): + p = re.compile('^Progress : (?P[0-9]{1,3})%$') + m = p.match(line) + if m is None: + logger.error('Impossible to parse progress') + pb.update(int(m['progress'])-pb.n) + pb.update(100-pb.n) + pb.refresh() + pb.close() + + extract.wait() + + if extract.returncode != 0: + logger.error('Mkvextract returns an error code: %d', extract.returncode) + else: + logger.info('Track %d was succesfully extracted.', index) + +@typechecked +def remove_video_tracks_from_mkv(mkvmerge_path:str, input_file: IO[bytes], + output_file: IO[bytes]) -> None: + logger = logging.getLogger(__name__) + + outfd = output_file.fileno() + infd = input_file.fileno() + lseek(infd, 0, SEEK_SET) + lseek(outfd, 0, SEEK_SET) + set_inheritable(infd, True) + set_inheritable(outfd, True) + + params = [ mkvmerge_path, '-o', f'/proc/self/fd/{outfd:d}', '-D', f'/proc/self/fd/{infd:d}'] + logger.debug('Executing: LANG=C %s', params) + + env = {**os.environ, 'LANG': 'C'} + with Popen(params, stdout=PIPE, close_fds=False, env=env) as remove: + pb = tqdm(TextIOWrapper(remove.stdout, encoding="utf-8"), total=100, unit='%', + desc='Removal of video track:') + for line in pb: + if line.startswith('Progress :'): + p = re.compile('^Progress : (?P[0-9]{1,3})%$') + m = p.match(line) + if m is None: + logger.error('Impossible to parse progress') + pb.update(int(m['progress'])-pb.n) + pb.update(100-pb.n) + pb.refresh() + pb.close() + + remove.wait() + + if remove.returncode != 0: + logger.error('Mkvmerge returns an error code: %d', remove.returncode) + else: + logger.info('Video tracks were succesfully extracted.') + +@typechecked +def remux_srt_subtitles(mkvmerge_path:str, input_file: IO[bytes], output_filename: str, + subtitles) -> None: + logger = logging.getLogger(__name__) + + try: + out = open(output_filename, 'w', encoding='utf8') + except IOError: + logger.error('Impossible to create file: %s', output_filename) + return None + + outfd = out.fileno() + infd = input_file.fileno() + lseek(infd, 0, SEEK_SET) + set_inheritable(infd, True) + set_inheritable(outfd, True) + + mkv_merge_params = [mkvmerge_path, f'/proc/self/fd/{infd:d}'] + for fd, lang in subtitles: + lseek(fd, 0, SEEK_SET) + set_inheritable(fd, True) + mkv_merge_params.extend(['--language', f'0:{lang}', f'/proc/self/fd/{fd:d}']) + + mkv_merge_params.extend(['-o', f'/proc/self/fd/{outfd:d}']) + + warnings = [] + env = {**os.environ, 'LANG': 'C'} + logger.info('Remux subtitles: %s', mkv_merge_params) + with Popen(mkv_merge_params, stdout=PIPE, close_fds=False, env=env) as mkvmerge: + pb = tqdm(TextIOWrapper(mkvmerge.stdout, encoding="utf-8"), total=100, unit='%', + desc='Remux subtitles:') + for line in pb: + if line.startswith('Progress :'): + p = re.compile('^Progress : (?P[0-9]{1,3})%$') + m = p.match(line) + if m is None: + logger.error('Impossible to parse progress') + pb.n = int(m['progress']) + pb.update() + elif line.startswith('Warning'): + warnings.append(line) + + status = mkvmerge.wait() + if status == 1: + logger.warning('Remux subtitles returns warning') + for w in warnings: + logger.warning(w) + elif status == 2: + logger.error('Remux subtitles returns errors') + + return None + +@typechecked +def concatenate_h264_parts(h264parts: list[IO[bytes]], output: IO[bytes]) -> None: + logger = logging.getLogger(__name__) + + total_length = 0 + for h264 in h264parts: + fd = h264.fileno() + total_length += fstat(fd).st_size + + logger.info('Total length: %d', total_length) + + outfd = output.fileno() + lseek(outfd, 0, SEEK_SET) + + pb = tqdm(total=total_length, unit='bytes', desc='Concatenation') + for h264 in h264parts: + fd = h264.fileno() + lseek(fd, 0, SEEK_SET) + while True: + buf = read(fd, 1000000) + if buf is None or len(buf) == 0: + break + pos = 0 + while pos < len(buf): + nb_bytes = write(outfd, buf[pos:]) + pb.update(nb_bytes) + pos += nb_bytes + +def concatenate_h264_ts_parts(h264_ts_parts: list[IO[bytes]], output: IO[bytes]) -> None: + logger = logging.getLogger(__name__) + header = '# timestamp format v2\n' + + output.write(header) + + last = 0. + first = True + for part in h264_ts_parts: + if first: + offset = last + else: + # TODO: take framerate into account + offset = last + 40 + logger.debug('Parsing file: %s. Offset=%d', part, offset) + isheader = part.readline() + if (not isheader) or (isheader != header): + logger.error('Impossible to find a valid header: "%s"', isheader) + exit(-1) + while True: + line = part.readline() + if not line: + break + ts = offset + float(line) + last = max(last,ts) + output.write(f'{ts:f}\n') + if first: + first = False + +# TODO: finish this procedure +def do_coarse_processing(ffmpeg_path:str, ffprobe_path:str, mkvmerge_path:str, + input_file: IO[bytes], begin, end, nb_frames, framerate, + files_prefix, streams, width, height, temporaries, dump_mem_fd) -> None: + # pylint: disable=W0613 + logger = logging.getLogger(__name__) + + # Internal video with all streams (video, audio and subtitles) + internal_mkv_name = f'{files_prefix}.mkv' + + try: + internal_mkv = open(internal_mkv_name, 'wb+') + except IOError: + logger.error('Impossible to create file: %s', internal_mkv_name) + exit(-1) + + # Extract internal part of MKV + extract_mkv_part(mkvmerge_path=mkvmerge_path, input_file=input_file, output_file=internal_mkv, + begin=begin, end=end) + + temporaries.append(internal_mkv) + +def main() -> None: + logger = logging.getLogger(__name__) + coloredlogs.install() + parser = argparse.ArgumentParser() + parser.add_argument("-i", "--input", dest='input_file', type=str, required=True, + help="Input file to process (can be .ts, .mp4 or .mkv).") + parser.add_argument("-o", "--output", dest='output_file', type=str, required=True, + help="Output MKV file to produce.") + parser.add_argument("-p", "--part", dest='parts', nargs='+', required=False, action='append', + metavar="hh:mm:ss[.mmm]-hh:mm:ss[.mmm]", + help="Extract this exact part of the original file.") + parser.add_argument("-k", "--keep", action='store_true', + help="Do not cleanup temporary files after processing.") + parser.add_argument("-t", "--threshold", action='store', type=int, + help="Suppress headers and trailers that are smaller than the threshold.") + parser.add_argument("-c", "--coarse", action='store_true', dest='coarse', + help="Do not take trailers and headers into account.") + parser.add_argument("--dump-memory", action='store_true', dest='dump', + help="For debug purpose, dump all memory mapping of headers (and trailers)\ + before (after) each part. They are kept in memory only otherwise.") + parser.add_argument("-s","--srt", action='store_true', dest='srt', + help="Dump subtitles and make OCR and finally remux them in the movie\ + (as SRT).") + parser.add_argument("-v","--verbose", action='store_true', dest='verbose', help="Debug.") + parser.add_argument("-f","--framerate", action='store', type=int, + help="Override frame rate estimator.") + + args = parser.parse_args() + logger.info('Arguments: %s', args) + + if args.verbose: + logger.info('Setting logging to debug mode') + coloredlogs.set_level(level=logging.DEBUG) + + logger.debug('Arguments: %s', args) + + if args.coarse and args.threshold is not None: + logger.error('--coarse and threshold arguments are exclusive.') + exit(-1) + + if (not args.coarse) and args.threshold is None: + args.threshold = 0 + + all_optional_tools, paths = check_required_tools() + + # Flatten args.parts + intervals = [] + if args.parts is not None: + for part in args.parts: + for subpart in part: + intervals.append(subpart) + + parts=[] + # Parse each interval + for interval in intervals: + ts1, ts2 = parse_time_interval(interval) + if ts1 is None or ts2 is None: + logger.error("Illegal time interval: %s", interval) + exit(-1) + parts.append((ts1,ts2)) + + # Sort intervals + parts.sort(key=cmp_to_key(compare_time_interval)) + + # Check that no intervals are overlapping + prevts = timedelta(0) + for part in parts: + ts1, ts2 = part + if prevts > ts1: + logger.error('Intervals are overlapping') + exit(-1) + prevts = ts2 + + nb_parts = len(parts) + temporaries = [] + + basename = os.path.splitext(os.path.basename(args.input_file))[0] + mp4filename = basename+'.mp4' + mkvfilename = basename+'.mkv' + + try: + input_file = open(args.input_file, mode='rb') + logger.debug("Type of input file: %s", type(input_file)) + except IOError: + logger.error("Impossible to open %s", args.input_file) + exit(-1) + + format_of_file = get_format(paths['ffprobe'], input_file) + + if format_of_file is None: + exit(-1) + + duration = timedelta(seconds=float(format_of_file['duration'])) + logger.info("Durée de l'enregistrement: %s", duration) + + if args.framerate is None: + framerate = get_frame_rate(paths['ffprobe'], input_file) + if framerate is None: + logger.error('Impossible to estimate frame rate !') + exit(-1) + else: + framerate = args.framerate + + logger.info('Frame rate: %.1f fps', framerate) + + found = False + for f in SupportedFormat: + if 'format_name' in format_of_file: + if format_of_file['format_name'] == str(f): + found = True + format_of_file = f + break + + if not found: + logger.error('Unsupported format of file') + + if format_of_file == SupportedFormat.TS: + logger.info("Converting TS to MP4 (to fix timestamps).") + try: + with open(mp4filename, 'wb+') as mp4: + ffmpeg_convert(paths['ffmpeg'], paths['ffprobe'], input_file, 'mpegts', mp4, 'mp4', + duration) + temporaries.append(mp4) + logger.info("Converting MP4 to MKV.") + try: + mkv = open(mkvfilename, 'wb+') + except IOError: + logger.error('') + + ffmpeg_convert(paths['ffmpeg'], paths['ffprobe'], mp4, 'mp4', mkv, 'matroska', + duration) + if nb_parts > 0: + temporaries.append(mkv) + except IOError: + logger.error('') + + elif format_of_file == SupportedFormat.MP4: + logger.info("Converting MP4 to MKV") + try: + mkv = open(mkvfilename, 'wb+') + except IOError: + logger.error('') + ffmpeg_convert(paths['ffmpeg'], paths['ffprobe'], input_file, 'mp4', mkv, 'matroska', + duration) + if nb_parts > 0: + temporaries.append(mkv) + else: + logger.info("Already in MKV") + mkv = input_file + + streams = get_streams(paths['ffprobe'], mkv) + + logger.debug('Streams: %s', streams) + main_video = None + nb_videos = 0 + for stream in streams: + if stream['codec_type'] == 'video': + if stream['disposition']['default'] == 1: + main_video = stream + width = stream['width'] + height = stream['height'] + break + nb_videos+=1 + if nb_videos == 1: + main_video = stream + width = stream['width'] + height = stream['height'] + else: + main_video = None + + if main_video is None: + logger.error('Impossible to find main video stream.') + exit(-1) + + # We retrieve the main private codec data + _, main_codec_private_data = get_codec_private_data_from_mkv(mkvinfo_path=paths['mkvinfo'], + input_file=mkv) + logger.debug('Main video stream has following private data: %s', + hexdump.dump(main_codec_private_data, sep=':')) + + # We parse them + main_avc_config = parse_codec_private(main_codec_private_data) + logger.debug('AVC configuration: %s', main_avc_config) + + # We check if the parse and dump operations are idempotent. + private_data = dump_codec_private_data(main_avc_config) + logger.debug('Redump AVC configuration: %s', hexdump.dump(private_data, sep=':')) + # In rare occasion, the PPS has trailing zeroes that do not seem to be related to useful data + # but they differ from the private data we generate that do not contain them. + # In that case we try to redecode our own private data to see if both AVC configurations are + # the same. + if main_codec_private_data != private_data: + logger.warning('Difference detected in bitstream !!') + iso_avc_config = parse_codec_private(private_data) + logger.debug('Reread AVC configuration: %s', iso_avc_config) + # If there exists a difference between our own reconstructed AVC configuration and the + # original one, we abandon + if iso_avc_config != main_avc_config: + logger.error('AVC configurations are different: %s\n%s\n', main_avc_config, + iso_avc_config) + exit(-1) + + # Pour chaque portion + partnum = 0 + mkvparts = [] + h264parts = [] + h264_ts = [] + checks = [] + pos = timedelta() + + other_avc_configs = [] + + for ts1, ts2 in parts: + # TODO: translate comment in english + # Trouver l'estampille de la trame 'I' la plus proche (mais postérieure) au début + # de la portion. + # Trouver l'estampille de la trame 'I' la plus proche (mais antérieure) à la fin + # de la portion. + # On a alors + # debut ----- trame --------- trame --------- fin fin+1 + # 'B/P' 'B/P'* 'I' 'I' 'B/P'* 'B/P' 'I/B/P' + # Si la trame de début est déjà 'I', il n'y a rien à faire. + # Sinon on extrait les trames 'B' ou 'P' depuis le début jusqu'à la trame 'I' non incluse. + # Si la trame de fin précède une trame I, on n'a rien à faire. + # Sinon on extrait toutes les trames depuis la dernière trame I jusqu'à la trame de fin. + + partnum = partnum + 1 + + # Get the nearest I-frame whose timestamp is greater or equal to the beginning. + head_frames = get_nearest_iframe(paths['ffprobe'], mkv, ts1, before=False) + if head_frames is None: + logger.error('Impossible to retrieve I-frame') + exit(-1) + + # Get the nearest I-frame whose timestamp ... + # TODO: wrong here ... + tail_frames = get_nearest_iframe(paths['ffprobe'], mkv, ts2, before=True) + if tail_frames is None: + logger.error('Impossible to retrieve I-frame') + exit(-1) + + nb_head_frames, head_iframe = head_frames + nb_tail_frames, tail_iframe = tail_frames + + logger.info("Found %d frames between beginning of current part and first I-frame", + nb_head_frames) + logger.info("Found %d frames between last I-frame and end of current part", + nb_tail_frames) + + head_iframe_ts = get_ts_frame(head_iframe) + if head_iframe_ts is None: + exit(-1) + tail_iframe_ts = get_ts_frame(tail_iframe) + if tail_iframe_ts is None: + exit(-1) + + checks.append(pos+head_iframe_ts-ts1) + + subparts = [] + + # TODO: separate pipeline processing between coarse and not fine grain options. + + # if args.coarse: + # do_coarse_processing(ffmpeg=paths['ffmpeg'], ffprobe=paths['ffprobe'], input_file=mkv, + # begin=ts1, end=head_iframe_ts, nb_frames=nb_head_frames-1, + # frameRate=frameRate, files_prefix='part-%d-head' % (partnum), + # streams=streams, width=width, height=height, + # temporaries=temporaries, dump_mem_fd=args.dump) + # else: + # doFineGrainProcessing(ffmpeg=paths['ffmpeg'], ffprobe=paths['ffprobe'], + # input_file=mkv, begin=ts1, end=head_iframe_ts, + # nb_frames=nb_head_frames-1, frameRate=frameRate, + # files_prefix='part-%d-head' % (partnum), streams=streams, + # width=width, height=height, temporaries=temporaries, + # dump_mem_fd=args.dump) + + if (not args.coarse) and (nb_head_frames > args.threshold): + # We extract all frames between the beginning upto the frame that immediately preceeds + # the I-frame. + h264_head, h264_head_ts, mkv_head = extract_all_streams(ffmpeg_path=paths['ffmpeg'], + ffprobe_path=paths['ffprobe'], + input_file=mkv, begin=ts1, + end=head_iframe_ts, + nb_frames=nb_head_frames-1, + framerate=framerate, + files_prefix=f'part-{partnum:d}-head', + streams=streams, width=width, + height=height, + temporaries=temporaries, + dump_mem_fd=args.dump) + + # If we are not at an exact boundary: + if mkv_head is not None: + subparts.append(mkv_head) + if h264_head is not None: + avcconfig = get_avc_config_from_h264(h264_head) + other_avc_configs.append(avcconfig) + h264parts.append(h264_head) + if h264_head_ts is not None: + h264_ts.append(h264_head_ts) + + # Creating MKV file that corresponds to current part between I-frames + # Internal video with all streams (video, audio and subtitles) + internal_mkv_name = f'part-{partnum:d}-internal.mkv' + # Internal video stream as a raw H264 stream + internal_h264_name = f'part-{partnum:d}-internal.h264' + # Internal video timestamps + internal_h264_ts_name = f'part-{partnum:d}-internal-ts.txt' + # Internal video with only audio and subtitles streams + internal_novideo_mkv_name = f'part-{partnum:d}-internal-novideo.mkv' + + try: + internal_mkv = open(internal_mkv_name, 'wb+') + except IOError: + logger.error('Impossible to create file: %s', internal_mkv_name) + exit(-1) + + try: + internal_novideo_mkv = open(internal_novideo_mkv_name, 'wb+') + except IOError: + logger.error('Impossible to create file: %s', internal_novideo_mkv_name) + exit(-1) + + try: + internal_h264 = open(internal_h264_name, 'wb+') + except IOError: + logger.error('Impossible to create file: %s', internal_h264_name) + exit(-1) + + try: + internal_h264_ts = open(internal_h264_ts_name, 'w+', encoding='utf8') + except IOError: + logger.error('Impossible to create file: %s', internal_h264_ts_name) + exit(-1) + + # logger.info('Merge header, middle and trailer subpart into: %s' % internal_mkv_name) + # Extract internal part of MKV + extract_mkv_part(mkvmerge_path=paths['mkvmerge'], input_file=mkv, output_file=internal_mkv, + begin=head_iframe_ts, end=tail_iframe_ts) + + # Extract video stream of internal part as a raw H264 and its timestamps. + logger.info('Extract video track as raw H264 file.') + extract_track_from_mkv(mkvextract_path=paths['mkvextract'], input_file=internal_mkv, + index=0, output_file=internal_h264, timestamps=internal_h264_ts) + + # Remove video track from internal part of MKV + logger.info('Remove video track from %s', internal_mkv_name) + remove_video_tracks_from_mkv(mkvmerge_path=paths['mkvmerge'], input_file=internal_mkv, + output_file=internal_novideo_mkv) + + temporaries.append(internal_mkv) + temporaries.append(internal_h264) + temporaries.append(internal_h264_ts) + temporaries.append(internal_novideo_mkv) + + h264parts.append(internal_h264) + h264_ts.append(internal_h264_ts) + subparts.append(internal_novideo_mkv) + + if (not args.coarse) and (nb_tail_frames > args.threshold): + # We extract all frames between the I-frame (including it) upto the end. + h264_tail, h264_tail_ts, mkv_tail = extract_all_streams(ffmpeg_path=paths['ffmpeg'], + ffprobe_path=paths['ffprobe'], + input_file=mkv, begin=tail_iframe_ts, + end=ts2, nb_frames=nb_tail_frames, + framerate=framerate, + files_prefix=f'part-{partnum:d}-tail', + streams=streams, + width=width, height=height, + temporaries=temporaries, + dump_mem_fd=args.dump) + + if mkv_tail is not None: + subparts.append(mkv_tail) + if h264_tail is not None: + avcconfig = get_avc_config_from_h264(h264_tail) + other_avc_configs.append(avcconfig) + h264parts.append(h264_tail) + if h264_tail_ts is not None: + h264_ts.append(h264_tail_ts) + + logger.info('Merging MKV: %s', subparts) + + part = merge_mkvs(mkvmerge_path=paths['mkvmerge'], inputs=subparts, + output_name=f'part-{partnum:d}.mkv', concatenate=True) + mkvparts.append(part) + temporaries.append(part) + + pos = pos+tail_iframe_ts-ts1 + + # We need to check the end also + checks.append(pos) + + # When using coarse option there is a single AVC configuration. + for avc_config in other_avc_configs: + main_avc_config.merge(avc_config) + logger.debug('Merged AVC configuration: %s', main_avc_config) + + nb_mkv_parts = len(mkvparts) + if nb_mkv_parts > 0: + try: + full_h264 = open(f'{basename}-full.h264', 'wb+') + except IOError: + logger.error('Impossible to create file full H264 stream.') + exit(-1) + + logger.info('Merging all H264 tracks') + concatenate_h264_parts(h264parts=h264parts, output=full_h264) + temporaries.append(full_h264) + + try: + full_h264_ts = open(f'{basename}-ts.txt', 'w+', encoding='utf8') + except IOError: + logger.error('Impossible to create file containing all video timestamps.') + exit(-1) + + logger.info('Merging H264 timestamps') + concatenate_h264_ts_parts(h264_ts_parts=h264_ts, output=full_h264_ts) + temporaries.append(full_h264_ts) + + final_novideo_name = f'{basename}-novideo.mkv' + final_with_video_name = f'{basename}-video.mkv' + + if nb_mkv_parts > 1: + logger.info('Merging all audio and subtitles parts: %s', mkvparts) + merge_mkvs(mkvmerge_path=paths['mkvmerge'], inputs=mkvparts, output_name=final_novideo_name, + concatenate=True) + elif nb_mkv_parts == 1: + copyfile('part-1.mkv', final_novideo_name) + else: + logger.info("Nothing else to do.") + copyfile(mkvfilename, final_with_video_name) + + if nb_mkv_parts >=1 : + try: + final_novideo = open(final_novideo_name, 'rb') + except IOError: + logger.error('Impossible to open file: %s.', final_novideo_name) + exit(-1) + + temporaries.append(final_novideo) + + full_h264_ts.seek(0) + + logger.info('Merging final video track and all other tracks together') + final_with_video = merge_mkvs(mkvmerge_path=paths['mkvmerge'], inputs=[full_h264, + final_novideo], + output_name=final_with_video_name, concatenate=False, + timestamps={0: full_h264_ts}) + final_codec_private_data = dump_codec_private_data(main_avc_config) + logger.debug('Final codec private data: %s', hexdump.dump(final_codec_private_data, + sep=':')) + logger.info('Changing codec private data with the new one.') + change_codec_private_data(paths['mkvinfo'], final_with_video, final_codec_private_data) + + if args.srt: + if not all_optional_tools: + logger.warning("Missing tools for extracting subtitles.") + move(final_with_video_name, args.output_file) + else: + # Final cut is not any more the final step. + temporaries.append(final_with_video) + duration = get_movie_duration(paths['ffprobe'], final_with_video) + supported_langs = get_tesseract_supported_lang(paths['tesseract']) + logger.info('Supported lang: %s', supported_langs) + logger.info('Find subtitles tracks and language.') + subtitles = find_subtitles_tracks(paths['ffprobe'], final_with_video) + logger.info(subtitles) + sts = {} + for subtitle in subtitles: + index = subtitle['index'] + if 'tags' in subtitle: + if 'language' in subtitle['tags']: + lang = subtitle['tags']['language'] + if lang in sts: + sts[lang].append(index) + else: + sts[lang] = [index] + else: + logger.error("Dropping subtitle: %s because it is missing language\ + indication") + else: + logger.error("Dropping subtitle: %s because it is missing language indication", + subtitle) + + logger.info(sts) + if len(sts) > 0: + logger.info('Supported languages: %s', supported_langs) + list_of_subtitles = extract_srt(paths['mkvextract'], final_with_video_name, sts, + supported_langs) + logger.info(list_of_subtitles) + for idx_name, sub_name, _, _ in list_of_subtitles: + try: + idx = open(idx_name,'rb') + except IOError: + logger.error("Impossible to open %s.", idx_name) + exit(-1) + try: + sub = open(sub_name,'rb') + except IOError: + logger.error("Impossible to open %s.", sub_name) + exit(-1) + + temporaries.append(idx) + temporaries.append(sub) + + ocr = do_ocr(paths['vobsubocr'], list_of_subtitles, duration, temporaries, + args.dump) + logger.info(ocr) + + # Remux SRT subtitles + remux_srt_subtitles(paths['mkvmerge'], final_with_video, args.output_file, ocr) + else: + copyfile(final_with_video_name, args.output_file) + else: + move(final_with_video_name, args.output_file) + + if not args.keep: + logger.info("Cleaning temporary files") + for f in temporaries: + path = os.path.realpath(f.name) + logger.info("Removing: %s", path) + f.close() + unlink(path) + + d = datetime(1,1,1) + for c in checks: + logger.info("Please check cut smoothness at %s", (c+d).strftime("%H:%M:%S")) + +if __name__ == "__main__": + main()