Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
814c64cc52 | ||
|
|
c17ccb6563 | ||
|
|
e1d7474e00 | ||
|
|
51bab76c00 | ||
|
|
153873637e | ||
|
|
b0bc0a338e | ||
|
|
4597c459a8 | ||
|
|
5b2afd29b3 | ||
|
|
d1df44c82c | ||
|
|
1a616f6cbd | ||
|
|
d756fede54 | ||
|
|
34d1bc26f3 | ||
|
|
04781021eb | ||
|
|
e1ac3e48bb | ||
|
|
ae1a040590 | ||
|
|
0345293664 | ||
|
|
63d2deafb1 | ||
|
|
6f5dd3a9e4 | ||
|
|
093ad733d3 | ||
|
|
a6a41c112e | ||
|
|
447d04d13d | ||
|
|
2590faf20f | ||
|
|
bea482487e | ||
|
|
c48b77f294 | ||
|
|
0ff2e3a6dd | ||
|
|
294d857206 | ||
|
|
280eb1facb | ||
|
|
3769244bdb | ||
|
|
b241c45efc | ||
|
|
edcbf91101 | ||
|
|
4658cc16e0 | ||
|
|
5bbdfe9929 | ||
|
|
87e326d976 | ||
|
|
e2936d565f | ||
|
|
fa18247fd6 | ||
|
|
87b557bc38 | ||
|
|
a12a4e1b2b | ||
|
|
795d314f13 | ||
|
|
700d1fedc5 | ||
|
|
92df1d24d6 | ||
|
|
ff20f31e74 | ||
|
|
c32018caae | ||
|
|
f6491be5b7 | ||
|
|
a1a1ae5399 | ||
|
|
8dc2af580d | ||
|
|
413df48a77 | ||
|
|
7c539382e6 | ||
|
|
451fd50c68 | ||
|
|
edd034b3b6 | ||
|
|
66e20e126a | ||
|
|
f18ebfadc2 | ||
|
|
e6814f483a | ||
|
|
97ef0e972c | ||
|
|
7e72611b1c | ||
|
|
ff2ac0a236 | ||
|
|
bf7b98c73d | ||
|
|
ffb167c321 |
@@ -4,3 +4,5 @@
|
|||||||
*.mkv
|
*.mkv
|
||||||
part*
|
part*
|
||||||
venv/
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
- add pyproject.toml
|
||||||
|
- declare runtime dependencies
|
||||||
|
- add pytest, ruff, mypy and pylint
|
||||||
|
- configure src layout
|
||||||
|
- keep existing removeads.py behavior unchanged
|
||||||
|
refactor: extract H.264 bitstream primitives
|
||||||
|
refactor: extract SPS/PPS structures
|
||||||
|
refactor: extract AVC decoder configuration
|
||||||
|
refactor: extract Matroska/EBML helpers
|
||||||
|
refactor: isolate external tool execution
|
||||||
|
refactor: separate CLI from processing pipeline
|
||||||
Generated
+940
@@ -0,0 +1,940 @@
|
|||||||
|
# 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 = "astroid"
|
||||||
|
version = "4.0.4"
|
||||||
|
description = "An abstract syntax tree for Python with inference support."
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.10.0"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753"},
|
||||||
|
{file = "astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 = "coloredlogs"
|
||||||
|
version = "15.0.1"
|
||||||
|
description = "Colored terminal output for Python's logging module"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934"},
|
||||||
|
{file = "coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
humanfriendly = ">=9.1"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
cron = ["capturer (>=2.4)"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dill"
|
||||||
|
version = "0.4.1"
|
||||||
|
description = "serialize all of Python"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.9"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d"},
|
||||||
|
{file = "dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
graph = ["objgraph (>=1.7.2)"]
|
||||||
|
profile = ["gprof2dot (>=2022.7.29)"]
|
||||||
|
|
||||||
|
[[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 = "humanfriendly"
|
||||||
|
version = "10.0"
|
||||||
|
description = "Human friendly output for text interfaces using Python"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477"},
|
||||||
|
{file = "humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
pyreadline3 = {version = "*", markers = "sys_platform == \"win32\" and python_version >= \"3.8\""}
|
||||||
|
|
||||||
|
[[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 = "isort"
|
||||||
|
version = "8.0.1"
|
||||||
|
description = "A Python utility / library to sort Python imports."
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.10.0"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75"},
|
||||||
|
{file = "isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
colors = ["colorama"]
|
||||||
|
|
||||||
|
[[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 = "mccabe"
|
||||||
|
version = "0.7.0"
|
||||||
|
description = "McCabe checker, plugin for flake8"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.6"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"},
|
||||||
|
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 = "platformdirs"
|
||||||
|
version = "4.11.5"
|
||||||
|
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.10"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "platformdirs-4.11.5-py3-none-any.whl", hash = "sha256:89f8d42695853b89c7170bd49bc3dc593f98a71e695ede88e06a3b247bc4563b"},
|
||||||
|
{file = "platformdirs-4.11.5.tar.gz", hash = "sha256:e8b31f4f8bcbbedef91a6b57a706255e4f148d2a4e01648382a0a47342539173"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 = "pylint"
|
||||||
|
version = "4.0.7"
|
||||||
|
description = "python code static checker"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.10.0"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "pylint-4.0.7-py3-none-any.whl", hash = "sha256:be4a3111557a614411ed1fc89347ce4a8e1013a59e1f33d11485227a02e3304d"},
|
||||||
|
{file = "pylint-4.0.7.tar.gz", hash = "sha256:9b2d1d15791c84b77a4fe2aafe8f0d9570717e2dea06d53b19c105cf60275a52"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
astroid = ">=4.0.2,<=4.1.dev0"
|
||||||
|
colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""}
|
||||||
|
dill = [
|
||||||
|
{version = ">=0.3.7", markers = "python_version >= \"3.12\""},
|
||||||
|
{version = ">=0.3.6", markers = "python_version == \"3.11\""},
|
||||||
|
]
|
||||||
|
isort = ">=5,<5.13 || >5.13,<9"
|
||||||
|
mccabe = ">=0.6,<0.8"
|
||||||
|
platformdirs = ">=2.2"
|
||||||
|
tomlkit = ">=0.10.1"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
spelling = ["pyenchant (>=3.2,<4.0)"]
|
||||||
|
testutils = ["gitpython (>3)"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyreadline3"
|
||||||
|
version = "3.5.6"
|
||||||
|
description = "A python implementation of GNU readline."
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.8"
|
||||||
|
groups = ["main"]
|
||||||
|
markers = "sys_platform == \"win32\""
|
||||||
|
files = [
|
||||||
|
{file = "pyreadline3-3.5.6-py3-none-any.whl", hash = "sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d"},
|
||||||
|
{file = "pyreadline3-3.5.6.tar.gz", hash = "sha256:61e53218b99656091ddb077df9e71f25850e72e030b6183b39c9b7e6e4f4a9bf"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
dev = ["build", "flake8", "mypy", "pytest", "twine"]
|
||||||
|
|
||||||
|
[[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 = "tomlkit"
|
||||||
|
version = "0.15.1"
|
||||||
|
description = "Style preserving TOML library"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.9"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304"},
|
||||||
|
{file = "tomlkit-0.15.1.tar.gz", hash = "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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 = "types-tqdm"
|
||||||
|
version = "4.70.0.20260827"
|
||||||
|
description = "Typing stubs for tqdm"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.10"
|
||||||
|
groups = ["dev"]
|
||||||
|
files = [
|
||||||
|
{file = "types_tqdm-4.70.0.20260827-py3-none-any.whl", hash = "sha256:d6e02da8ec2bc11124e02d4bc84bd8b67a444f4c5decf544846897c8272c7023"},
|
||||||
|
{file = "types_tqdm-4.70.0.20260827.tar.gz", hash = "sha256:d3efd69243ac9093c552f1c0d081b77286d61ace96b6899a142542555bdad2dc"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
all = ["types-requests", "types-tensorflow"]
|
||||||
|
requests = ["types-requests"]
|
||||||
|
tensorflow = ["types-tensorflow"]
|
||||||
|
|
||||||
|
[[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 = "76bcb7a8ee79106ddac32401277b69dd4895e0d8067d71277e344c3a4e083be2"
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
[project]
|
||||||
|
name = "tscut"
|
||||||
|
version = "0.9.0"
|
||||||
|
description = "A tool to cut and reassemble parts of a video at the precision of a single frame."
|
||||||
|
authors = [
|
||||||
|
{name = "Frédéric Tronel"}
|
||||||
|
]
|
||||||
|
license = {text = "GPL-2.0-or-later"}
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11,<4"
|
||||||
|
dependencies = ["requests", "xmltodict", "coloredlogs", "tqdm", "iso639-lang", "hexdump"]
|
||||||
|
[tool.poetry]
|
||||||
|
packages = [
|
||||||
|
{ include = "tscut", from = "src" }
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
tscut = "tscut.cli:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
|
[tool.poetry.group.dev.dependencies]
|
||||||
|
ruff = "^0.16.2"
|
||||||
|
mypy = "^2.3.0"
|
||||||
|
pytest = "^9.1.1"
|
||||||
|
pylint = "^4.0.7"
|
||||||
|
types-tqdm = "^4.70.0.20260827"
|
||||||
|
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
target-version = "py311"
|
||||||
|
line-length = 100
|
||||||
|
src = ["src"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = [
|
||||||
|
"E", # pycodestyle errors
|
||||||
|
"F", # Pyflakes
|
||||||
|
"I", # isort
|
||||||
|
"UP", # pyupgrade
|
||||||
|
"B", # flake8-bugbear
|
||||||
|
"SIM", # flake8-simplify
|
||||||
|
"RET", # flake8-return
|
||||||
|
"PERF", # Perflint
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.11"
|
||||||
|
files = ["src"]
|
||||||
|
warn_unused_configs = true
|
||||||
|
show_error_codes = true
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = [
|
||||||
|
"coloredlogs",
|
||||||
|
"hexdump",
|
||||||
|
]
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026 Frédéric Tronel
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
A tool to cut and reassemble parts of a video at the precision of a single frame.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__version__ = "0.9.0"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026 Frédéric Tronel
|
||||||
|
|
||||||
|
"""Entry point for ``python -m tscut``."""
|
||||||
|
|
||||||
|
from .cli import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026 Frédéric Tronel
|
||||||
|
|
||||||
|
"""Command-line interface for tscut."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
from functools import cmp_to_key
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import coloredlogs
|
||||||
|
|
||||||
|
from tscut.exceptions import TSCutError
|
||||||
|
from tscut.models import ProcessingOptions
|
||||||
|
from tscut.pipeline import process_recording
|
||||||
|
from tscut.temporaries import TemporaryFiles
|
||||||
|
from tscut.tools.discovery import check_required_tools
|
||||||
|
from tscut.tools.timeframe import compare_time_interval, parse_time_interval
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""Run the tscut command-line interface."""
|
||||||
|
|
||||||
|
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.')
|
||||||
|
return 2
|
||||||
|
|
||||||
|
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)
|
||||||
|
return 2
|
||||||
|
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')
|
||||||
|
return 2
|
||||||
|
prevts = ts2
|
||||||
|
|
||||||
|
config = ProcessingOptions(
|
||||||
|
input_file=Path(args.input_file),
|
||||||
|
output_file=Path(args.output_file),
|
||||||
|
parts=parts,
|
||||||
|
framerate = args.framerate,
|
||||||
|
verbose=args.verbose,
|
||||||
|
coarse=args.coarse,
|
||||||
|
threshold=args.threshold,
|
||||||
|
keep_temporaries=args.keep,
|
||||||
|
subtitles_ocr=args.srt,
|
||||||
|
dump_memory=args.dump,
|
||||||
|
tools_paths = paths,
|
||||||
|
all_optional_tools = all_optional_tools)
|
||||||
|
|
||||||
|
logger.debug("Configuration: %s", config)
|
||||||
|
temporaries = TemporaryFiles(config.keep_temporaries)
|
||||||
|
try:
|
||||||
|
process_recording(config, temporaries)
|
||||||
|
except TSCutError as exc:
|
||||||
|
logger.error("%s", exc)
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
logger.info("Cleaning temporary files")
|
||||||
|
temporaries.cleanup()
|
||||||
|
|
||||||
|
return 0
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026 Frédéric Tronel
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import IO
|
||||||
|
|
||||||
|
from tscut.exceptions import TemporaryFileError
|
||||||
|
from tscut.tools.mkvtoolnix import extract_mkv_part
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
# 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 OSError as e:
|
||||||
|
raise TemporaryFileError(f"Impossible to create {internal_mkv_name}") from e
|
||||||
|
|
||||||
|
# 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.add(internal_mkv)
|
||||||
@@ -0,0 +1,365 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026 Frédéric Tronel
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
from os import (
|
||||||
|
SEEK_SET,
|
||||||
|
fstat,
|
||||||
|
lseek,
|
||||||
|
read,
|
||||||
|
write,
|
||||||
|
)
|
||||||
|
from shutil import copyfile
|
||||||
|
from typing import BinaryIO, Sequence, TextIO
|
||||||
|
|
||||||
|
import hexdump
|
||||||
|
from tqdm import tqdm
|
||||||
|
from typeguard import typechecked
|
||||||
|
|
||||||
|
from tscut.exceptions import InvalidMediaError, TemporaryFileError
|
||||||
|
from tscut.h264.avc import get_avc_config_from_h264
|
||||||
|
from tscut.matroska.codec import dump_codec_private_data
|
||||||
|
from tscut.models import CutResult, PreparedMedia, ProcessingOptions
|
||||||
|
from tscut.temporaries import TemporaryFiles
|
||||||
|
from tscut.tools.ffmpeg import extract_all_streams
|
||||||
|
from tscut.tools.ffprobe import (
|
||||||
|
get_nearest_iframe,
|
||||||
|
)
|
||||||
|
from tscut.tools.mkvtoolnix import (
|
||||||
|
change_codec_private_data,
|
||||||
|
extract_mkv_part,
|
||||||
|
extract_track_from_mkv,
|
||||||
|
merge_mkvs,
|
||||||
|
remove_video_tracks_from_mkv,
|
||||||
|
)
|
||||||
|
from tscut.tools.timeframe import get_ts_frame
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@typechecked
|
||||||
|
def concatenate_h264_parts(h264parts: Sequence[BinaryIO], output: BinaryIO) -> 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: Sequence[TextIO], output: TextIO) -> 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):
|
||||||
|
raise InvalidMediaError(f"Impossible to find a valid header: {isheader}")
|
||||||
|
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
|
||||||
|
|
||||||
|
def cut_recording(media: PreparedMedia, options: ProcessingOptions,
|
||||||
|
temporaries: TemporaryFiles) -> CutResult:
|
||||||
|
|
||||||
|
# Pour chaque portion
|
||||||
|
partnum = 0
|
||||||
|
mkvparts = []
|
||||||
|
h264parts = []
|
||||||
|
h264_ts = []
|
||||||
|
checks = []
|
||||||
|
pos = timedelta()
|
||||||
|
other_avc_configs = []
|
||||||
|
mkvfilename = media.basename+'.mkv'
|
||||||
|
|
||||||
|
|
||||||
|
for ts1, ts2 in options.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(options.tools_paths['ffprobe'], media.movie, ts1,
|
||||||
|
before=False)
|
||||||
|
if head_frames is None:
|
||||||
|
raise InvalidMediaError("Impossible to retrieve first I-frame")
|
||||||
|
|
||||||
|
# Get the nearest I-frame whose timestamp ...
|
||||||
|
# TODO: wrong here ...
|
||||||
|
tail_frames = get_nearest_iframe(options.tools_paths['ffprobe'], media.movie,
|
||||||
|
ts2, before=True)
|
||||||
|
if tail_frames is None:
|
||||||
|
raise InvalidMediaError("Impossible to retrieve last I-frame")
|
||||||
|
|
||||||
|
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)
|
||||||
|
try:
|
||||||
|
head_iframe_ts = get_ts_frame(head_iframe)
|
||||||
|
except InvalidMediaError:
|
||||||
|
raise InvalidMediaError("Impossible to retrieve timestamp of first i-frame.")
|
||||||
|
try:
|
||||||
|
tail_iframe_ts = get_ts_frame(tail_iframe)
|
||||||
|
except InvalidMediaError:
|
||||||
|
raise InvalidMediaError("Impossible to retrieve timestamp of final i-frame.")
|
||||||
|
|
||||||
|
assert head_iframe_ts <= tail_iframe_ts
|
||||||
|
|
||||||
|
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 options.coarse) and (nb_head_frames > options.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=options.tools_paths['ffmpeg'],
|
||||||
|
ffprobe_path=options.tools_paths['ffprobe'],
|
||||||
|
input_file=media.movie, begin=ts1,
|
||||||
|
end=head_iframe_ts,
|
||||||
|
nb_frames=nb_head_frames-1,
|
||||||
|
framerate=media.framerate,
|
||||||
|
files_prefix=f'part-{partnum:d}-head',
|
||||||
|
streams=media.streams, width=media.width,
|
||||||
|
height=media.height,
|
||||||
|
temporaries=temporaries,
|
||||||
|
dump_mem_fd=options.dump_memory)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
if head_iframe_ts < tail_iframe_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 OSError as e:
|
||||||
|
raise TemporaryFileError(f"Impossible to create file: {internal_mkv_name}") from e
|
||||||
|
|
||||||
|
try:
|
||||||
|
internal_novideo_mkv = open(internal_novideo_mkv_name, 'wb+')
|
||||||
|
except OSError as e:
|
||||||
|
raise TemporaryFileError(f"Impossible to create file: \
|
||||||
|
{internal_novideo_mkv_name}") from e
|
||||||
|
|
||||||
|
try:
|
||||||
|
internal_h264 = open(internal_h264_name, 'wb+')
|
||||||
|
except OSError as e:
|
||||||
|
raise TemporaryFileError(f"Impossible to create file: {internal_h264_name}") from e
|
||||||
|
|
||||||
|
try:
|
||||||
|
internal_h264_ts = open(internal_h264_ts_name, 'w+', encoding='utf8')
|
||||||
|
except OSError as e:
|
||||||
|
raise TemporaryFileError(f"Impossible to create file: {internal_h264_ts_name}") from e
|
||||||
|
|
||||||
|
# logger.info('Merge header, middle and trailer subpart into: %s' % internal_mkv_name)
|
||||||
|
# Extract internal part of MKV
|
||||||
|
extract_mkv_part(mkvmerge_path=options.tools_paths['mkvmerge'], input_file=media.movie,
|
||||||
|
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=options.tools_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=options.tools_paths['mkvmerge'],
|
||||||
|
input_file=internal_mkv, output_file=internal_novideo_mkv)
|
||||||
|
|
||||||
|
temporaries.add(internal_mkv)
|
||||||
|
temporaries.add(internal_h264)
|
||||||
|
temporaries.add(internal_h264_ts)
|
||||||
|
temporaries.add(internal_novideo_mkv)
|
||||||
|
|
||||||
|
h264parts.append(internal_h264)
|
||||||
|
h264_ts.append(internal_h264_ts)
|
||||||
|
subparts.append(internal_novideo_mkv)
|
||||||
|
|
||||||
|
if (not options.coarse) and (nb_tail_frames > options.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=options.tools_paths['ffmpeg'],
|
||||||
|
ffprobe_path=options.tools_paths['ffprobe'],
|
||||||
|
input_file=media.movie, begin=tail_iframe_ts,
|
||||||
|
end=ts2, nb_frames=nb_tail_frames,
|
||||||
|
framerate=media.framerate,
|
||||||
|
files_prefix=f'part-{partnum:d}-tail',
|
||||||
|
streams=media.streams,
|
||||||
|
width=media.width, height=media.height,
|
||||||
|
temporaries=temporaries,
|
||||||
|
dump_mem_fd=options.dump_memory)
|
||||||
|
|
||||||
|
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=options.tools_paths['mkvmerge'], inputs=subparts,
|
||||||
|
output_name=f'part-{partnum:d}.mkv', concatenate=True)
|
||||||
|
mkvparts.append(part)
|
||||||
|
temporaries.add(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:
|
||||||
|
media.avc_config.merge(avc_config)
|
||||||
|
logger.debug('Merged AVC configuration: %s', media.avc_config)
|
||||||
|
|
||||||
|
nb_mkv_parts = len(mkvparts)
|
||||||
|
if nb_mkv_parts > 0:
|
||||||
|
try:
|
||||||
|
full_h264 = open(f'{media.basename}-full.h264', 'wb+')
|
||||||
|
except OSError as e:
|
||||||
|
raise TemporaryFileError("Impossible to create file full H264 stream.") from e
|
||||||
|
|
||||||
|
logger.info('Merging all H264 tracks')
|
||||||
|
concatenate_h264_parts(h264parts=h264parts, output=full_h264)
|
||||||
|
temporaries.add(full_h264)
|
||||||
|
|
||||||
|
try:
|
||||||
|
full_h264_ts = open(f'{media.basename}-ts.txt', 'w+', encoding='utf8')
|
||||||
|
except OSError as e:
|
||||||
|
raise TemporaryFileError("Impossible to create file containing all video timestamps.")\
|
||||||
|
from e
|
||||||
|
|
||||||
|
logger.info('Merging H264 timestamps')
|
||||||
|
concatenate_h264_ts_parts(h264_ts_parts=h264_ts, output=full_h264_ts)
|
||||||
|
temporaries.add(full_h264_ts)
|
||||||
|
|
||||||
|
final_novideo_name = f'{media.basename}-novideo.mkv'
|
||||||
|
final_with_video_name = f'{media.basename}-video.mkv'
|
||||||
|
|
||||||
|
if nb_mkv_parts > 1:
|
||||||
|
logger.info('Merging all audio and subtitles parts: %s', mkvparts)
|
||||||
|
merge_mkvs(mkvmerge_path=options.tools_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 OSError as e:
|
||||||
|
raise TemporaryFileError(f"Impossible to open file: {final_novideo_name}") from e
|
||||||
|
|
||||||
|
temporaries.add(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=options.tools_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(media.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(options.tools_paths['mkvinfo'], final_with_video,
|
||||||
|
final_codec_private_data)
|
||||||
|
|
||||||
|
return CutResult(
|
||||||
|
filename = final_with_video_name,
|
||||||
|
movie = final_with_video,
|
||||||
|
check_positions = checks
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
|
||||||
|
Useful SPS/PPS discussion:
|
||||||
|
1.https://copyprogramming.com/howto/including-sps-and-pps-in-a-raw-h264-track
|
||||||
|
2. https://gitlab.com/mbunkus/mkvtoolnix/-/issues/2390
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
|
||||||
|
Extract SPS/PPS:
|
||||||
|
1. https://gitlab.com/mbunkus/mkvtoolnix/-/issues/2390
|
||||||
|
```bash
|
||||||
|
ffmpeg -i <InputFile (before concatenation)> -c:v copy -an -sn -bsf:v trace_headers -t 0.01 -report -loglevel 0 -f null -
|
||||||
|
``
|
||||||
|
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026 Frédéric Tronel
|
||||||
|
|
||||||
|
|
||||||
|
class TSCutError(Exception):
|
||||||
|
"""Base exception for tscut."""
|
||||||
|
|
||||||
|
|
||||||
|
class MissingToolError(TSCutError):
|
||||||
|
"""Raised when a required external tool is unavailable."""
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalToolError(TSCutError):
|
||||||
|
"""Raised when an external tool fails."""
|
||||||
|
|
||||||
|
class InvalidMediaError(TSCutError):
|
||||||
|
"""Raised when input media cannot be processed."""
|
||||||
|
|
||||||
|
class TemporaryFileError(TSCutError):
|
||||||
|
"""Raised when the manipulation of temporary file goes wrong"""
|
||||||
|
|
||||||
|
class UnimplementedFeatureError(TSCutError):
|
||||||
|
"""Raised when a rare feature is encountered and not yet implemented"""
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026 Frédéric Tronel
|
||||||
|
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from math import floor
|
||||||
|
from typing import IO
|
||||||
|
|
||||||
|
import hexdump
|
||||||
|
from typeguard import typechecked
|
||||||
|
|
||||||
|
from tscut.h264.bitstream import (
|
||||||
|
rbsp_to_sodb,
|
||||||
|
read_bit,
|
||||||
|
read_bits,
|
||||||
|
read_byte,
|
||||||
|
read_long,
|
||||||
|
read_word,
|
||||||
|
sodb_to_rbsp,
|
||||||
|
write_bits,
|
||||||
|
write_byte,
|
||||||
|
write_word,
|
||||||
|
)
|
||||||
|
from tscut.h264.parameters import (
|
||||||
|
PPS,
|
||||||
|
SPS,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@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.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(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(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(self.num_of_sequence_parameter_set_ext):
|
||||||
|
# TODO: parse SPSextended
|
||||||
|
logger.error('Parsing of SPS extended not yet implemented !')
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def to_bytes(self):
|
||||||
|
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(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 | bytearray) -> 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(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(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:
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026 Frédéric Tronel
|
||||||
|
|
||||||
|
""" All the following code is a transposition of documents:
|
||||||
|
ISO/IEC H.264-201602
|
||||||
|
ISO/IEC 14496-15
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from math import floor, log
|
||||||
|
|
||||||
|
import hexdump
|
||||||
|
from typeguard import typechecked
|
||||||
|
|
||||||
|
from tscut.exceptions import UnimplementedFeatureError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@typechecked
|
||||||
|
def read_bit(buf:bytes|bytearray, 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|bytearray, 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|bytearray, bit_position: int, nb_bits: int) -> tuple[int, int]:
|
||||||
|
v = 0
|
||||||
|
for _ in range(nb_bits):
|
||||||
|
bit_position, bit = read_bit(buf, bit_position)
|
||||||
|
v = v*2+bit
|
||||||
|
return bit_position, v
|
||||||
|
|
||||||
|
@typechecked
|
||||||
|
def read_byte(buf:bytes|bytearray, 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|bytearray, 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|bytearray, 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|bytearray, 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<<nb_zeroes)+v2
|
||||||
|
return bit_position, v-1
|
||||||
|
|
||||||
|
@typechecked
|
||||||
|
def read_signed_exp_golomb(buf:bytes|bytearray, bit_position: int) -> 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
|
||||||
|
case _:
|
||||||
|
raise AssertionError("unreachable")
|
||||||
|
|
||||||
|
@typechecked
|
||||||
|
def write_bit(buf:bytearray, 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:bytearray, 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:bytearray, 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:bytearray, bit_position: int, v) -> int:
|
||||||
|
bit_position = write_bits(buf, bit_position, v, 8)
|
||||||
|
return bit_position
|
||||||
|
|
||||||
|
@typechecked
|
||||||
|
def write_word(buf:bytearray, bit_position: int, v) -> int:
|
||||||
|
bit_position = write_bits(buf, bit_position, v, 16)
|
||||||
|
return bit_position
|
||||||
|
|
||||||
|
@typechecked
|
||||||
|
def write_long(buf:bytearray, bit_position: int, v) -> int:
|
||||||
|
bit_position = write_bits(buf, bit_position, v, 32)
|
||||||
|
return bit_position
|
||||||
|
|
||||||
|
@typechecked
|
||||||
|
def write_unsigned_exp_golomb(buf:bytearray, 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:bytearray, 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|bytearray, 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:bytearray, 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|bytearray, bit_position: int) -> bool:
|
||||||
|
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
|
||||||
|
|
||||||
|
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.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.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|bytearray, bit_position: int, size) -> tuple[int,list[int]]:
|
||||||
|
res = []
|
||||||
|
last_scale = 8
|
||||||
|
next_scale = 8
|
||||||
|
for _ in range(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:bytearray, bit_position: int, size, matrix:list[int],
|
||||||
|
optimized: bool = False) -> int:
|
||||||
|
logger.debug('Dumping matrix: %s of size: %d, size parameter: %d.', matrix, len(matrix), size)
|
||||||
|
|
||||||
|
prev = 8
|
||||||
|
deltas = []
|
||||||
|
for i in range(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')
|
||||||
|
raise UnimplementedFeatureError("Optimized scaling list of H264 header is not implemented")
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,716 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026 Frédéric Tronel
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from math import ceil, floor, log
|
||||||
|
|
||||||
|
import hexdump
|
||||||
|
|
||||||
|
from tscut.h264.bitstream import (
|
||||||
|
more_rbsp_data,
|
||||||
|
parse_rbsp_trailing_bits,
|
||||||
|
parse_scaling_list,
|
||||||
|
read_bit,
|
||||||
|
read_bits,
|
||||||
|
read_boolean,
|
||||||
|
read_byte,
|
||||||
|
read_long,
|
||||||
|
read_signed_exp_golomb,
|
||||||
|
read_unsigned_exp_golomb,
|
||||||
|
read_word,
|
||||||
|
write_bit,
|
||||||
|
write_bits,
|
||||||
|
write_boolean,
|
||||||
|
write_byte,
|
||||||
|
write_long,
|
||||||
|
write_rbsp_trailing_bits,
|
||||||
|
write_scaling_list,
|
||||||
|
write_signed_exp_golomb,
|
||||||
|
write_unsigned_exp_golomb,
|
||||||
|
write_word,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@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(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(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=None
|
||||||
|
vcl_hrd_parameters_present_flag:bool=False
|
||||||
|
vcl_hrd_parameters:HRD|None=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,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=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):
|
||||||
|
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(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.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(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(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):
|
||||||
|
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(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(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.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(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(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(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(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):
|
||||||
|
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(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(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(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(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
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026 Frédéric Tronel
|
||||||
|
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import IO
|
||||||
|
|
||||||
|
import hexdump
|
||||||
|
from typeguard import typechecked
|
||||||
|
|
||||||
|
from tscut.exceptions import InvalidMediaError
|
||||||
|
from tscut.h264.avc import AVCDecoderConfiguration, get_avc_config_from_h264
|
||||||
|
from tscut.matroska.ebml import get_ebml_length
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@typechecked
|
||||||
|
def dump_codec_private_data(avc_decoder_configuration: AVCDecoderConfiguration) -> bytearray:
|
||||||
|
# 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))
|
||||||
|
if embl_length is None:
|
||||||
|
raise InvalidMediaError
|
||||||
|
logger.debug('EMBL encoded length: %s', hexdump.dump(embl_length, sep=':'))
|
||||||
|
res.extend(embl_length)
|
||||||
|
res.extend(buf)
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
@typechecked
|
||||||
|
def get_codec_private_data_from_h264(input_file: IO[bytes]) -> bytearray:
|
||||||
|
avcconfig = get_avc_config_from_h264(input_file)
|
||||||
|
res = dump_codec_private_data(avcconfig)
|
||||||
|
|
||||||
|
return res
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026 Frédéric Tronel
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from os import (
|
||||||
|
SEEK_SET,
|
||||||
|
fstat,
|
||||||
|
ftruncate,
|
||||||
|
lseek,
|
||||||
|
read,
|
||||||
|
write,
|
||||||
|
)
|
||||||
|
from typing import IO
|
||||||
|
|
||||||
|
import hexdump
|
||||||
|
from typeguard import typechecked
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#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:
|
||||||
|
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 change_ebml_element_size(input_file: IO[bytes], position:int, addendum:int) -> int:
|
||||||
|
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
|
||||||
|
mask = mask>>1
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
logger.error('Size of element type cannot be determined: %d', element_type)
|
||||||
|
raise ValueError()
|
||||||
|
|
||||||
|
# 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
|
||||||
|
mask = mask>>1
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
logger.error('Size of data size cannot be determined: %d', size_head)
|
||||||
|
raise ValueError()
|
||||||
|
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)
|
||||||
|
if new_encoded_size is None:
|
||||||
|
raise ValueError()
|
||||||
|
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)
|
||||||
|
raise ValueError()
|
||||||
|
# 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
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026 Frédéric Tronel
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import timedelta
|
||||||
|
from enum import IntEnum, unique
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import IO, BinaryIO
|
||||||
|
|
||||||
|
from tscut.h264.avc import AVCDecoderConfiguration
|
||||||
|
|
||||||
|
|
||||||
|
@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'
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProcessingOptions:
|
||||||
|
input_file: Path
|
||||||
|
output_file: Path
|
||||||
|
parts: list[tuple[timedelta, timedelta]]
|
||||||
|
tools_paths: dict[str, str]
|
||||||
|
all_optional_tools: bool
|
||||||
|
framerate: int|None = None
|
||||||
|
threshold: int = 0
|
||||||
|
coarse: bool = False
|
||||||
|
subtitles_ocr: bool = True
|
||||||
|
verbose: bool = False
|
||||||
|
dump_memory: bool = False
|
||||||
|
keep_temporaries: bool = False
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PreparedMedia:
|
||||||
|
basename: str
|
||||||
|
movie: BinaryIO
|
||||||
|
duration: timedelta
|
||||||
|
framerate: float
|
||||||
|
streams: list
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
avc_config: AVCDecoderConfiguration
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CutResult:
|
||||||
|
filename: str
|
||||||
|
movie: IO[bytes]
|
||||||
|
check_positions: list[timedelta]
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026 Frédéric Tronel
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from shutil import copyfile, move
|
||||||
|
from typing import BinaryIO
|
||||||
|
|
||||||
|
import hexdump
|
||||||
|
|
||||||
|
from tscut.cutting import cut_recording
|
||||||
|
from tscut.exceptions import InvalidMediaError, TemporaryFileError
|
||||||
|
from tscut.h264.avc import parse_codec_private
|
||||||
|
from tscut.matroska.codec import dump_codec_private_data
|
||||||
|
from tscut.models import PreparedMedia, ProcessingOptions, SupportedFormat
|
||||||
|
from tscut.subtitles.ocr import do_ocr, extract_srt, get_tesseract_supported_lang
|
||||||
|
from tscut.temporaries import TemporaryFiles
|
||||||
|
from tscut.tools.ffmpeg import ffmpeg_convert
|
||||||
|
from tscut.tools.ffprobe import (
|
||||||
|
find_subtitles_tracks,
|
||||||
|
get_format,
|
||||||
|
get_frame_rate,
|
||||||
|
get_movie_duration,
|
||||||
|
get_streams,
|
||||||
|
)
|
||||||
|
from tscut.tools.mkvtoolnix import (
|
||||||
|
get_codec_private_data_from_mkv,
|
||||||
|
remux_srt_subtitles,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def detect_supported_format(format_name: str) -> SupportedFormat:
|
||||||
|
for media_format in SupportedFormat:
|
||||||
|
if format_name == str(media_format):
|
||||||
|
return media_format
|
||||||
|
|
||||||
|
raise InvalidMediaError(
|
||||||
|
f"Unsupported media format: {format_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def process_recording(options: ProcessingOptions, temporaries: TemporaryFiles) -> None:
|
||||||
|
nb_parts = len(options.parts)
|
||||||
|
basename = os.path.splitext(os.path.basename(options.input_file))[0]
|
||||||
|
mp4filename = basename+'.mp4'
|
||||||
|
mkvfilename = basename+'.mkv'
|
||||||
|
|
||||||
|
try:
|
||||||
|
input_file = open(options.input_file, mode='rb')
|
||||||
|
logger.debug("Type of input file: %s", type(input_file))
|
||||||
|
except OSError as e:
|
||||||
|
raise InvalidMediaError(f"Impossible to open {options.input_file}") from e
|
||||||
|
|
||||||
|
format_of_file = get_format(options.tools_paths['ffprobe'], input_file)
|
||||||
|
|
||||||
|
if format_of_file is None:
|
||||||
|
raise InvalidMediaError(f"Impossible to retrieve format of file: {input_file}")
|
||||||
|
if 'format_name' not in format_of_file:
|
||||||
|
raise InvalidMediaError(f"Impossible to retrieve format of file: {input_file}")
|
||||||
|
if 'duration' not in format_of_file:
|
||||||
|
raise InvalidMediaError(f"Impossible to retrieve duration of file: {input_file}")
|
||||||
|
format_name = format_of_file['format_name']
|
||||||
|
|
||||||
|
duration = timedelta(seconds=float(format_of_file['duration']))
|
||||||
|
logger.info("Durée de l'enregistrement: %s", duration)
|
||||||
|
|
||||||
|
if options.framerate is None:
|
||||||
|
framerate = get_frame_rate(options.tools_paths['ffprobe'], input_file)
|
||||||
|
if framerate is None:
|
||||||
|
raise InvalidMediaError('Impossible to estimate frame rate !')
|
||||||
|
else:
|
||||||
|
framerate = options.framerate
|
||||||
|
|
||||||
|
logger.info('Frame rate: %.1f fps', framerate)
|
||||||
|
|
||||||
|
final_format_of_file = detect_supported_format(format_name)
|
||||||
|
|
||||||
|
mkv: BinaryIO
|
||||||
|
|
||||||
|
if final_format_of_file == SupportedFormat.TS:
|
||||||
|
logger.info("Converting TS to MP4 (to fix timestamps).")
|
||||||
|
try:
|
||||||
|
with open(mp4filename, 'wb+') as mp4:
|
||||||
|
ffmpeg_convert(options.tools_paths['ffmpeg'], options.tools_paths['ffprobe'],
|
||||||
|
input_file, 'mpegts', mp4, 'mp4', duration)
|
||||||
|
temporaries.add(mp4)
|
||||||
|
logger.info("Converting MP4 to MKV.")
|
||||||
|
try:
|
||||||
|
mkv = open(mkvfilename, 'wb+')
|
||||||
|
except OSError as e:
|
||||||
|
raise TemporaryFileError(f"Impossible to create: {mkvfilename}") from e
|
||||||
|
|
||||||
|
ffmpeg_convert(options.tools_paths['ffmpeg'], options.tools_paths['ffprobe'],
|
||||||
|
mp4, 'mp4', mkv, 'matroska', duration)
|
||||||
|
if nb_parts > 0:
|
||||||
|
temporaries.add(mkv)
|
||||||
|
except OSError as e:
|
||||||
|
raise TemporaryFileError(f"Impossible to create: {mp4filename}") from e
|
||||||
|
|
||||||
|
|
||||||
|
elif final_format_of_file == SupportedFormat.MP4:
|
||||||
|
logger.info("Converting MP4 to MKV")
|
||||||
|
try:
|
||||||
|
mkv = open(mkvfilename, 'wb+')
|
||||||
|
except OSError as e:
|
||||||
|
raise TemporaryFileError(f"Impossible to create: {mkvfilename}") from e
|
||||||
|
ffmpeg_convert(options.tools_paths['ffmpeg'], options.tools_paths['ffprobe'],
|
||||||
|
input_file, 'mp4', mkv, 'matroska', duration)
|
||||||
|
if nb_parts > 0:
|
||||||
|
temporaries.add(mkv)
|
||||||
|
else:
|
||||||
|
logger.info("Already in MKV")
|
||||||
|
mkv = input_file
|
||||||
|
|
||||||
|
streams = get_streams(options.tools_paths['ffprobe'], mkv)
|
||||||
|
if streams is None:
|
||||||
|
raise InvalidMediaError(f"No streams found in file: {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:
|
||||||
|
raise InvalidMediaError("Impossible to find main video stream.")
|
||||||
|
|
||||||
|
# We retrieve the main private codec data
|
||||||
|
_, main_codec_private_data = get_codec_private_data_from_mkv(
|
||||||
|
mkvinfo_path=options.tools_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:
|
||||||
|
raise InvalidMediaError(f"AVC configurations are different:\
|
||||||
|
{main_avc_config}\n{iso_avc_config}\n")
|
||||||
|
|
||||||
|
prepared_movie = PreparedMedia(
|
||||||
|
basename = basename,
|
||||||
|
movie = mkv,
|
||||||
|
duration = duration,
|
||||||
|
framerate = framerate,
|
||||||
|
streams = streams,
|
||||||
|
width = int(width),
|
||||||
|
height = int(height),
|
||||||
|
avc_config = main_avc_config
|
||||||
|
)
|
||||||
|
|
||||||
|
cut_movie = cut_recording(prepared_movie, options, temporaries)
|
||||||
|
|
||||||
|
final_with_video_name = cut_movie.filename
|
||||||
|
final_with_video = cut_movie.movie
|
||||||
|
checks = cut_movie.check_positions
|
||||||
|
|
||||||
|
if options.subtitles_ocr:
|
||||||
|
if not options.all_optional_tools:
|
||||||
|
logger.warning("Missing tools for extracting subtitles.")
|
||||||
|
move(final_with_video_name, options.output_file)
|
||||||
|
else:
|
||||||
|
# Final cut is not any more the final step.
|
||||||
|
temporaries.add(final_with_video)
|
||||||
|
duration = get_movie_duration(options.tools_paths['ffprobe'], final_with_video)
|
||||||
|
supported_langs = get_tesseract_supported_lang(options.tools_paths['tesseract'])
|
||||||
|
logger.info('Supported lang: %s', supported_langs)
|
||||||
|
logger.info('Find subtitles tracks and language.')
|
||||||
|
subtitles = find_subtitles_tracks(options.tools_paths['ffprobe'], final_with_video)
|
||||||
|
logger.info(subtitles)
|
||||||
|
sts: dict[str, list[int]] = {}
|
||||||
|
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", subtitle)
|
||||||
|
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(options.tools_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 OSError as e:
|
||||||
|
raise TemporaryFileError(f"Impossible to open {idx_name}") from e
|
||||||
|
try:
|
||||||
|
sub = open(sub_name,'rb')
|
||||||
|
except OSError as e:
|
||||||
|
raise TemporaryFileError(f"Impossible to open {sub_name}") from e
|
||||||
|
|
||||||
|
temporaries.add(idx)
|
||||||
|
temporaries.add(sub)
|
||||||
|
|
||||||
|
ocr = do_ocr(options.tools_paths['vobsubocr'], list_of_subtitles, duration,
|
||||||
|
temporaries, options.dump_memory)
|
||||||
|
logger.info(ocr)
|
||||||
|
|
||||||
|
# Remux SRT subtitles
|
||||||
|
remux_srt_subtitles(options.tools_paths['mkvmerge'], final_with_video,
|
||||||
|
options.output_file, ocr)
|
||||||
|
else:
|
||||||
|
copyfile(final_with_video_name, options.output_file)
|
||||||
|
else:
|
||||||
|
move(final_with_video_name, options.output_file)
|
||||||
|
|
||||||
|
d = datetime(1,1,1)
|
||||||
|
for c in checks:
|
||||||
|
logger.info("Please check cut smoothness at %s", (c+d).strftime("%H:%M:%S"))
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026 Frédéric Tronel
|
||||||
|
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import timedelta
|
||||||
|
from io import TextIOWrapper
|
||||||
|
from os import (
|
||||||
|
SEEK_SET,
|
||||||
|
fstat,
|
||||||
|
lseek,
|
||||||
|
memfd_create,
|
||||||
|
read,
|
||||||
|
write,
|
||||||
|
)
|
||||||
|
from subprocess import PIPE, Popen
|
||||||
|
from typing import Sequence
|
||||||
|
|
||||||
|
from iso639 import Lang
|
||||||
|
from iso639.exceptions import InvalidLanguageValue
|
||||||
|
from tqdm import tqdm
|
||||||
|
from typeguard import typechecked
|
||||||
|
|
||||||
|
from tscut.exceptions import ExternalToolError
|
||||||
|
from tscut.temporaries import TemporaryFiles
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@typechecked
|
||||||
|
def get_tesseract_supported_lang(tesseract_path:str) -> dict[Lang, str]:
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
res = {}
|
||||||
|
|
||||||
|
with Popen([tesseract_path, '--list-langs'], stdout=PIPE) as tesseract:
|
||||||
|
assert tesseract.stdout is not None
|
||||||
|
for line in tesseract.stdout:
|
||||||
|
line = line.decode('utf8')
|
||||||
|
p = re.compile('(?P<lang>[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)
|
||||||
|
|
||||||
|
tesseract.wait()
|
||||||
|
|
||||||
|
if tesseract.returncode != 0:
|
||||||
|
raise ExternalToolError("Tesseract returns an error code: %d",tesseract.returncode)
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@typechecked
|
||||||
|
def extract_srt(mkvextract:str, filename:str, subtitles:dict[str, list[int]],
|
||||||
|
langs:dict[Lang,str]) -> list[tuple[str,str,str,str]]:
|
||||||
|
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:
|
||||||
|
assert extract.stdout is not None
|
||||||
|
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<progress>[0-9]{1,3})%$')
|
||||||
|
m = p.match(line)
|
||||||
|
if m is None:
|
||||||
|
logger.error('Impossible to parse progress')
|
||||||
|
else:
|
||||||
|
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:
|
||||||
|
raise ExternalToolError('Mkvextract returns an error code: %d', extract.returncode)
|
||||||
|
|
||||||
|
return res
|
||||||
|
|
||||||
|
@typechecked
|
||||||
|
def do_ocr(vobsubocr:str, idxs: Sequence[tuple[str,str,str,str]], duration:timedelta,
|
||||||
|
temporaries:TemporaryFiles, dump_mem_fd:bool=False):
|
||||||
|
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<hours>[0-9]{2}):'
|
||||||
|
r'(?P<minutes>[0-9]{2}):(?P<seconds>[0-9]{2}),[0-9]{3}$'))
|
||||||
|
|
||||||
|
srtfd = memfd_create(srtname, flags=0)
|
||||||
|
with Popen([vobsubocr, '--lang', iso, idx_name], stdout=PIPE) as ocr:
|
||||||
|
assert ocr.stdout is not None
|
||||||
|
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('minutes'))
|
||||||
|
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.add(dump_srt)
|
||||||
|
except OSError:
|
||||||
|
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
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026 Frédéric Tronel
|
||||||
|
|
||||||
|
"""A class to manage (and clean) all temporary files created during conversion"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import IO, Any, Self
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class TemporaryFiles:
|
||||||
|
def __init__(self, keep: bool = False) -> None:
|
||||||
|
self._files: list[IO[Any]] = []
|
||||||
|
self._keep = keep
|
||||||
|
|
||||||
|
def add(self, file: IO[Any]) -> None:
|
||||||
|
self._files.append(file)
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
if self._keep:
|
||||||
|
return
|
||||||
|
|
||||||
|
for f in self._files:
|
||||||
|
path = os.path.realpath(f.name)
|
||||||
|
logger.info("Removing: %s", path)
|
||||||
|
try:
|
||||||
|
f.close()
|
||||||
|
os.unlink(path)
|
||||||
|
except OSError:
|
||||||
|
logger.exception("Unable to remove temporary file: %s", path)
|
||||||
|
|
||||||
|
def __enter__(self) -> Self:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self) -> None:
|
||||||
|
self.cleanup()
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026 Frédéric Tronel
|
||||||
|
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from shutil import which
|
||||||
|
|
||||||
|
from typeguard import typechecked
|
||||||
|
|
||||||
|
from tscut.exceptions import MissingToolError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
"""
|
||||||
|
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)
|
||||||
|
raise MissingToolError(tool)
|
||||||
|
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
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026 Frédéric Tronel
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
from io import TextIOWrapper
|
||||||
|
from math import ceil, floor, log
|
||||||
|
from os import SEEK_SET, close, lseek, memfd_create, read, set_inheritable, write
|
||||||
|
from subprocess import PIPE, Popen
|
||||||
|
from typing import IO, BinaryIO
|
||||||
|
|
||||||
|
from tqdm import tqdm
|
||||||
|
from typeguard import typechecked
|
||||||
|
|
||||||
|
from tscut.exceptions import ExternalToolError, InvalidMediaError, TemporaryFileError
|
||||||
|
from tscut.temporaries import TemporaryFiles
|
||||||
|
from tscut.tools.ffprobe import (
|
||||||
|
get_frames_in_stream,
|
||||||
|
get_video_dimensions,
|
||||||
|
with_subtitles,
|
||||||
|
)
|
||||||
|
from tscut.tools.ppm import dump_ppm
|
||||||
|
from tscut.tools.timeframe import get_packet_duration, parse_timestamp
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@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) -> None:
|
||||||
|
width, height = get_video_dimensions(ffprobe_path, input_file)
|
||||||
|
if width is None or height is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
subtitles = with_subtitles(ffprobe_path, input_file)
|
||||||
|
|
||||||
|
infd = input_file.fileno()
|
||||||
|
outfd = output_file.fileno()
|
||||||
|
set_inheritable(infd, True)
|
||||||
|
set_inheritable(outfd, True)
|
||||||
|
|
||||||
|
log_level = [] if logger.getEffectiveLevel() == logging.DEBUG else ['-loglevel', 'quiet']
|
||||||
|
|
||||||
|
params = [ffmpeg_path, '-y',]+log_level+['-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:
|
||||||
|
assert ffmpeg.stdout is not None
|
||||||
|
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_str = line.split('=')[1].strip()
|
||||||
|
ts = parse_timestamp(ts_str)
|
||||||
|
if ts is not None:
|
||||||
|
pb.n = int(ts/timedelta(seconds=1))
|
||||||
|
pb.update()
|
||||||
|
status = ffmpeg.wait()
|
||||||
|
if status != 0:
|
||||||
|
raise ExternalToolError(f"Conversion failed with status code: {status:d}")
|
||||||
|
|
||||||
|
|
||||||
|
@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]:
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
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 = b''
|
||||||
|
with Popen(command, stdout=PIPE, close_fds=False) as ffmpeg:
|
||||||
|
status = ffmpeg.wait()
|
||||||
|
if status != 0:
|
||||||
|
raise ExternalToolError(f"Conversion failed with status code: {status:d}")
|
||||||
|
|
||||||
|
lseek(outfd, 0, SEEK_SET)
|
||||||
|
images = read(outfd,length)
|
||||||
|
if len(images) != length:
|
||||||
|
raise InvalidMediaError(f"Received {len(images)} bytes but {length} were expected.")
|
||||||
|
|
||||||
|
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]:
|
||||||
|
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 = b''
|
||||||
|
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:
|
||||||
|
raise ExternalToolError(f"Sound extraction returns error code: {status}")
|
||||||
|
|
||||||
|
lseek(outfd, 0, SEEK_SET)
|
||||||
|
sound = read(outfd, length)
|
||||||
|
|
||||||
|
if len(sound) != length:
|
||||||
|
raise InvalidMediaError(f"Received {len(sound)} bytes but {length} were expected (\
|
||||||
|
channels={nb_channels}, freq={sample_rate} packets={nb_packets},\
|
||||||
|
duration={packet_duration} ms).")
|
||||||
|
|
||||||
|
return sound, outfd
|
||||||
|
|
||||||
|
@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:TemporaryFiles,
|
||||||
|
dump_mem_fd:bool=False) -> tuple[BinaryIO|None,
|
||||||
|
TextIOWrapper|None,
|
||||||
|
BinaryIO|None]:
|
||||||
|
# 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 = int(stream['level'])
|
||||||
|
level = f'{floor(level_int/10):d}.{level_int%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)
|
||||||
|
|
||||||
|
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:d}', 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'])
|
||||||
|
bit_rate = int(stream['bit_rate']) if 'bit_rate' in stream else 128000
|
||||||
|
codec = stream['codec_name']
|
||||||
|
if 'tags' in stream and '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)
|
||||||
|
if packets is None:
|
||||||
|
raise InvalidMediaError("Impossible to retrieve audio packets")
|
||||||
|
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])
|
||||||
|
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)
|
||||||
|
|
||||||
|
memfds.append(memfd)
|
||||||
|
|
||||||
|
if dump_mem_fd:
|
||||||
|
try:
|
||||||
|
with open(tmpname,'wb') as output:
|
||||||
|
temporaries.add(output)
|
||||||
|
outfd = output.fileno()
|
||||||
|
pos = 0
|
||||||
|
while pos < len(sound_bytes):
|
||||||
|
pos+=write(outfd, sound_bytes[pos:])
|
||||||
|
except OSError as e:
|
||||||
|
raise TemporaryFileError(f"Impossible to create file: {tmpname}") from e
|
||||||
|
|
||||||
|
# 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 and '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(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 OSError as e:
|
||||||
|
raise TemporaryFileError(f"Impossible to create file: {mkv_filename}") from e
|
||||||
|
|
||||||
|
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:
|
||||||
|
raise ExternalToolError(f"Encoding failed with status code: {status}")
|
||||||
|
|
||||||
|
temporaries.add(mkv_output)
|
||||||
|
|
||||||
|
h264_filename = f'{files_prefix}.h264'
|
||||||
|
try:
|
||||||
|
h264_output = open(h264_filename,'wb+')
|
||||||
|
except OSError as e:
|
||||||
|
raise TemporaryFileError(f"Impossible to create file {h264_filename}") from e
|
||||||
|
|
||||||
|
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:
|
||||||
|
raise ExternalToolError(f"Encoding failed with status code: {status:d}")
|
||||||
|
|
||||||
|
temporaries.add(h264_output)
|
||||||
|
|
||||||
|
h264_ts_filename = f'{files_prefix}-ts.txt'
|
||||||
|
try:
|
||||||
|
h264_ts_output = open(h264_ts_filename,'w+', encoding='utf8')
|
||||||
|
except OSError as e:
|
||||||
|
raise TemporaryFileError(f"Impossible to create file: {h264_ts_filename}") from e
|
||||||
|
|
||||||
|
h264_ts_output.write('# timestamp format v2\n')
|
||||||
|
ts = 0
|
||||||
|
for _ in range(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.add(h264_ts_output)
|
||||||
|
|
||||||
|
for memfd in memfds:
|
||||||
|
close(memfd)
|
||||||
|
|
||||||
|
return h264_output, h264_ts_output, mkv_output
|
||||||
|
|
||||||
|
# Nothing to be done. We are already at a i-frame boundary.
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026 Frédéric Tronel
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
from io import BytesIO
|
||||||
|
from math import floor
|
||||||
|
from os import (
|
||||||
|
SEEK_SET,
|
||||||
|
lseek,
|
||||||
|
set_inheritable,
|
||||||
|
)
|
||||||
|
from subprocess import PIPE, Popen
|
||||||
|
from typing import IO
|
||||||
|
|
||||||
|
from typeguard import typechecked
|
||||||
|
|
||||||
|
from tscut.exceptions import InvalidMediaError, ExternalToolError
|
||||||
|
from tscut.tools.timeframe import get_ts_frame
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
"""
|
||||||
|
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 and 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
|
||||||
|
|
||||||
|
if max_ts is None or min_ts is None:
|
||||||
|
logger.error("Impossible to determine maximum or minimum timestamps.")
|
||||||
|
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
|
||||||
|
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,list[str]]|None:
|
||||||
|
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:str = 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 get_format(ffprobe_path:str, input_file: IO[bytes]) -> dict|None:
|
||||||
|
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']
|
||||||
|
logger.error('Impossible to retrieve format of file')
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@typechecked
|
||||||
|
def get_movie_duration(ffprobe_path:str, input_file: IO[bytes]) -> timedelta:
|
||||||
|
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']))
|
||||||
|
return timedelta(seconds=duration)
|
||||||
|
raise InvalidMediaError("Impossible to retrieve duration of movie")
|
||||||
|
|
||||||
|
# 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]:
|
||||||
|
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'])
|
||||||
|
|
||||||
|
raise InvalidMediaError('Impossible to retrieve dimensions of video')
|
||||||
|
|
||||||
|
@typechecked
|
||||||
|
def get_streams(ffprobe_path:str, input_file: IO[bytes]) -> list|None:
|
||||||
|
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']
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
def find_subtitles_tracks(ffprobe_path:str, input_file: IO[bytes]) -> dict:
|
||||||
|
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']
|
||||||
|
|
||||||
|
ffprobe.wait()
|
||||||
|
raise InvalidMediaError('Impossible to retrieve format of file')
|
||||||
|
|
||||||
|
@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:
|
||||||
|
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:
|
||||||
|
raise ExternalToolError(f"ffprobe failed with status code: {status:d}")
|
||||||
|
|
||||||
|
# Sort frames by timestamp
|
||||||
|
tmp = {}
|
||||||
|
if 'frames' in frames:
|
||||||
|
frames = frames['frames']
|
||||||
|
for frame in frames:
|
||||||
|
ts = get_ts_frame(frame)
|
||||||
|
if begin <= ts <= end:
|
||||||
|
tmp[ts]=frame
|
||||||
|
|
||||||
|
res = []
|
||||||
|
for ts in sorted(tmp):
|
||||||
|
res.append(tmp[ts])
|
||||||
|
return res
|
||||||
|
|
||||||
|
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)):
|
||||||
|
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:
|
||||||
|
raise ExternalToolError(f"ffprobe failed with status code: {status:d}")
|
||||||
|
|
||||||
|
if 'frames' in frames:
|
||||||
|
frames = frames['frames']
|
||||||
|
for frame in frames:
|
||||||
|
ts = get_ts_frame(frame)
|
||||||
|
if tbegin <= ts <= tend:
|
||||||
|
idrs.append(frame)
|
||||||
|
else:
|
||||||
|
logger.error('Impossible to retrieve IDR frames inside file around [%s,%s]',
|
||||||
|
tbegin, tend)
|
||||||
|
return
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
@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] | None:
|
||||||
|
infd = input_file.fileno()
|
||||||
|
set_inheritable(infd, True)
|
||||||
|
|
||||||
|
delta = timedelta(seconds=1)
|
||||||
|
|
||||||
|
iframe = None
|
||||||
|
while delta < delta_max:
|
||||||
|
zero = timedelta()
|
||||||
|
tbegin = timestamp - delta if before else timestamp
|
||||||
|
tend = timestamp + delta if not before else 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:
|
||||||
|
try:
|
||||||
|
ts = get_ts_frame(frame)
|
||||||
|
except InvalidMediaError:
|
||||||
|
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
|
||||||
|
|
||||||
|
delta+=timedelta(seconds=1)
|
||||||
|
|
||||||
|
if iframe is not None and frames is not None:
|
||||||
|
its = get_ts_frame(iframe)
|
||||||
|
if its is None:
|
||||||
|
logger.error("Impossible to retrieve timestamp of i-frame !")
|
||||||
|
return None
|
||||||
|
nb_frames = 0
|
||||||
|
for frame in frames:
|
||||||
|
try:
|
||||||
|
ts = get_ts_frame(frame)
|
||||||
|
except InvalidMediaError:
|
||||||
|
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 None
|
||||||
|
|
||||||
|
return(nb_frames, iframe)
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026 Frédéric Tronel
|
||||||
|
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from datetime import timedelta
|
||||||
|
from io import TextIOWrapper
|
||||||
|
from os import SEEK_SET, fstat, ftruncate, lseek, read, set_inheritable, write
|
||||||
|
from pathlib import Path
|
||||||
|
from subprocess import PIPE, Popen
|
||||||
|
from typing import IO, Sequence
|
||||||
|
|
||||||
|
import hexdump
|
||||||
|
from tqdm import tqdm
|
||||||
|
from typeguard import typechecked
|
||||||
|
|
||||||
|
from tscut.exceptions import InvalidMediaError, ExternalToolError
|
||||||
|
from tscut.matroska.ebml import change_ebml_element_size
|
||||||
|
|
||||||
|
|
||||||
|
# Found codec private data using mkvinfo
|
||||||
|
@typechecked
|
||||||
|
def get_codec_private_data_from_mkv(mkvinfo_path:str,
|
||||||
|
input_file: IO[bytes]) -> tuple[int, bytes]:
|
||||||
|
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()
|
||||||
|
lines = out.decode('utf8')
|
||||||
|
reg_exp = (r"^.*Codec's private data: size ([0-9]+) \(H.264.*\) hexdump "
|
||||||
|
r"(?P<hexdump>([0-9a-f]{2} )+)at (?P<position>[0-9]+) size (?P<size>[0-9]+).*$")
|
||||||
|
p = re.compile(reg_exp)
|
||||||
|
for line in lines.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
|
||||||
|
|
||||||
|
raise InvalidMediaError("Impossible to retrieve codec private data from MKV !")
|
||||||
|
|
||||||
|
@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()
|
||||||
|
lines = out.decode('utf8')
|
||||||
|
prefix = []
|
||||||
|
reg_exp = (r"(^(?P<root>\+)|(\|(?P<depth>[ ]*\+))).*at (?P<position>[0-9]+)"
|
||||||
|
r" size (?P<size>[0-9]+).*$")
|
||||||
|
p = re.compile(reg_exp)
|
||||||
|
prev_depth = -1
|
||||||
|
for line in lines.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
|
||||||
|
|
||||||
|
@typechecked
|
||||||
|
def change_codec_private_data(mkvinfo_path:str, input_file: IO[bytes],
|
||||||
|
codec_data:bytes | bytearray) -> 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)
|
||||||
|
if position is None or current_data is None:
|
||||||
|
raise InvalidMediaError("Impossible to retrieve private data from 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:
|
||||||
|
raise InvalidMediaError("Impossible to retrieve the key of codec private data")
|
||||||
|
|
||||||
|
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(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 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:
|
||||||
|
assert mkvmerge.stdout is not None
|
||||||
|
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<progress>[0-9]{1,3})%$')
|
||||||
|
m = p.match(line)
|
||||||
|
if m is None:
|
||||||
|
logger.error('Impossible to parse progress')
|
||||||
|
else:
|
||||||
|
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:
|
||||||
|
raise ExternalToolError("Extraction returns errors")
|
||||||
|
|
||||||
|
|
||||||
|
# Merge a list of mkv files passed as input, and produce a new MKV as output
|
||||||
|
@typechecked
|
||||||
|
def merge_mkvs(mkvmerge_path:str, inputs: Sequence[IO[bytes]], output_name:str,
|
||||||
|
concatenate: bool=True,
|
||||||
|
timestamps: dict[int, IO[str]] | None = None) -> IO[bytes]:
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
if timestamps is None:
|
||||||
|
timestamps = {}
|
||||||
|
|
||||||
|
fds = []
|
||||||
|
try:
|
||||||
|
out = open(output_name, 'wb+')
|
||||||
|
except OSError as e:
|
||||||
|
logger.error('Impossible to create file: %s', output_name)
|
||||||
|
raise e
|
||||||
|
|
||||||
|
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:
|
||||||
|
assert mkvmerge.stdout is not None
|
||||||
|
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<progress>[0-9]{1,3})%$')
|
||||||
|
m = p.match(line)
|
||||||
|
if m is None:
|
||||||
|
logger.error('Impossible to parse progress')
|
||||||
|
else:
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@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:
|
||||||
|
assert extract.stdout is not None
|
||||||
|
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<progress>[0-9]{1,3})%$')
|
||||||
|
m = p.match(line)
|
||||||
|
if m is None:
|
||||||
|
logger.error('Impossible to parse progress')
|
||||||
|
else:
|
||||||
|
pb.update(int(m['progress'])-pb.n)
|
||||||
|
pb.update(100-pb.n)
|
||||||
|
pb.refresh()
|
||||||
|
pb.close()
|
||||||
|
|
||||||
|
extract.wait()
|
||||||
|
|
||||||
|
if extract.returncode != 0:
|
||||||
|
raise ExternalToolError(f"Mkvextract returns an error code: {extract.returncode:d}")
|
||||||
|
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:
|
||||||
|
assert remove.stdout is not None
|
||||||
|
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<progress>[0-9]{1,3})%$')
|
||||||
|
m = p.match(line)
|
||||||
|
if m is None:
|
||||||
|
logger.error('Impossible to parse progress')
|
||||||
|
else:
|
||||||
|
pb.update(int(m['progress'])-pb.n)
|
||||||
|
pb.update(100-pb.n)
|
||||||
|
pb.refresh()
|
||||||
|
pb.close()
|
||||||
|
|
||||||
|
remove.wait()
|
||||||
|
|
||||||
|
if remove.returncode != 0:
|
||||||
|
raise ExternalToolError(f"Mkvmerge returns an error code: {remove.returncode:d}")
|
||||||
|
else:
|
||||||
|
logger.info('Video tracks were succesfully extracted.')
|
||||||
|
|
||||||
|
|
||||||
|
@typechecked
|
||||||
|
def remux_srt_subtitles(mkvmerge_path:str, input_file: IO[bytes], output_file: Path,
|
||||||
|
subtitles) -> None:
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
try:
|
||||||
|
out = open(output_file, 'w', encoding='utf8')
|
||||||
|
except OSError:
|
||||||
|
logger.error('Impossible to create file: %s', output_file)
|
||||||
|
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:
|
||||||
|
assert mkvmerge.stdout is not None
|
||||||
|
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<progress>[0-9]{1,3})%$')
|
||||||
|
m = p.match(line)
|
||||||
|
if m is None:
|
||||||
|
logger.error('Impossible to parse progress')
|
||||||
|
else:
|
||||||
|
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
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026 Frédéric Tronel
|
||||||
|
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from io import BytesIO
|
||||||
|
from math import ceil, log
|
||||||
|
from os import write
|
||||||
|
from typing import IO
|
||||||
|
|
||||||
|
from typeguard import typechecked
|
||||||
|
|
||||||
|
from tscut.exceptions import TemporaryFileError
|
||||||
|
from tscut.temporaries import TemporaryFiles
|
||||||
|
|
||||||
|
|
||||||
|
@typechecked
|
||||||
|
def dump_ppm(pictures: bytes, prefix: str, temporaries: TemporaryFiles) -> 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<len(pictures):
|
||||||
|
filename = f'{prefix}-{picture:03d}.ppm'
|
||||||
|
header = BytesIO(pictures[pos:])
|
||||||
|
magic = header.readline().decode('utf8')
|
||||||
|
dimensions = header.readline().decode('utf8')
|
||||||
|
max_value = int(header.readline().decode('utf8'))
|
||||||
|
if magic == 'P6\n':
|
||||||
|
pattern = re.compile('^(?P<width>[0-9]+) (?P<height>[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.add(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 OSError as e:
|
||||||
|
raise TemporaryFileError(f"Impossible to create file {filename}") from e
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
#
|
||||||
|
# Copyright (C) 2026 Frédéric Tronel
|
||||||
|
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from typeguard import typechecked
|
||||||
|
|
||||||
|
from tscut.exceptions import InvalidMediaError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@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.)
|
||||||
|
"""
|
||||||
|
ts_reg_exp = (r'^(?P<hour>[0-9]{1,2}):(?P<minute>[0-9]{1,2})'
|
||||||
|
r':(?P<second>[0-9]{1,2})(\.(?P<us>[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
|
||||||
|
|
||||||
|
return timedelta(hours=hour, minutes=minute, seconds=second, microseconds=us)
|
||||||
|
|
||||||
|
@typechecked
|
||||||
|
def parse_time_interval(interval: str) -> tuple[timedelta, timedelta] | tuple[None, 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)
|
||||||
|
"""
|
||||||
|
interval_reg_exp = (r'^(?P<hour1>[0-9]{1,2}):(?P<minute1>[0-9]{1,2}):(?P<second1>[0-9]{1,2})'
|
||||||
|
r'(\.(?P<ms1>[0-9]{1,3}))?-(?P<hour2>[0-9]{1,2}):(?P<minute2>[0-9]{1,2})'
|
||||||
|
r':(?P<second2>[0-9]{1,2})(\.(?P<ms2>[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, 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
|
||||||
|
if ts22 < ts11:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
@typechecked
|
||||||
|
def get_ts_frame(frame: dict) -> timedelta:
|
||||||
|
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:
|
||||||
|
raise InvalidMediaError(f"Impossible to find timestamp of frame {frame}")
|
||||||
|
|
||||||
|
return timedelta(seconds=pts_time)
|
||||||
|
|
||||||
|
@typechecked
|
||||||
|
def get_packet_duration(packet: dict) -> int:
|
||||||
|
if 'duration' in packet:
|
||||||
|
duration = int(packet['duration'])
|
||||||
|
elif 'pkt_duration' in packet:
|
||||||
|
duration = int(packet['pkt_duration'])
|
||||||
|
else:
|
||||||
|
raise InvalidMediaError("Impossible to find duration of packet {packet}")
|
||||||
|
|
||||||
|
return duration
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from tscut.h264.bitstream import (
|
||||||
|
read_bit,
|
||||||
|
read_byte,
|
||||||
|
read_word
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_read_bit_1():
|
||||||
|
buf = bytes(10)
|
||||||
|
for p in range(80):
|
||||||
|
pos, b = read_bit(buf, p)
|
||||||
|
assert pos == p+1 and b == 0
|
||||||
|
|
||||||
|
def test_read_bit_2():
|
||||||
|
buf = b'\xFF'*10
|
||||||
|
for p in range(80):
|
||||||
|
pos, b = read_bit(buf, p)
|
||||||
|
assert pos == p+1 and b == 1
|
||||||
|
|
||||||
|
def test_read_byte_1():
|
||||||
|
buf = bytes(10)
|
||||||
|
for p in range(10):
|
||||||
|
pos, b = read_byte(buf, p*8)
|
||||||
|
assert pos == (p+1)*8 and b == 0
|
||||||
|
|
||||||
|
def test_read_byte_2():
|
||||||
|
buf = bytes(10)
|
||||||
|
buf = b'\xFF'*10
|
||||||
|
for p in range(10):
|
||||||
|
pos, b = read_byte(buf, p*8)
|
||||||
|
assert pos == (p+1)*8 and b == 0xFF
|
||||||
|
|
||||||
|
def test_read_word_1():
|
||||||
|
buf = bytes(20)
|
||||||
|
for p in range(10):
|
||||||
|
pos, b = read_word(buf, p*16)
|
||||||
|
assert pos == (p+1)*16 and b == 0
|
||||||
|
|
||||||
Reference in New Issue
Block a user