Compare commits

57 Commits
Author SHA1 Message Date
Frédéric Tronel 814c64cc52 Fix a bug discovered by the synthetic video. 2026-08-31 21:28:07 +02:00
Frédéric Tronel c17ccb6563 Typo in calling ffmpeg. 2026-08-31 21:27:41 +02:00
Frédéric Tronel e1d7474e00 Improve error handling. 2026-08-31 14:30:38 +02:00
Frédéric Tronel 51bab76c00 Improve error handling. 2026-08-31 13:54:03 +02:00
Frédéric Tronel 153873637e Improve temporaries management. 2026-08-31 12:00:12 +02:00
Frédéric Tronel b0bc0a338e Improve exception raising when it comes from an exception handler. 2026-08-31 11:20:25 +02:00
Frédéric Tronel 4597c459a8 Remove extraneous f-string. 2026-08-31 11:14:38 +02:00
Frédéric Tronel 5b2afd29b3 Add a dedicated class for temporary files management. 2026-08-31 11:12:47 +02:00
Frédéric Tronel d1df44c82c Remove exit from pipeline. 2026-08-31 08:57:28 +02:00
Frédéric Tronel 1a616f6cbd Removing a lot of sys.exit inside cutting module. 2026-08-31 08:34:52 +02:00
Frédéric Tronel d756fede54 H264 concatenation functions are moved to cutting module. 2026-08-30 17:45:03 +02:00
Frédéric Tronel 34d1bc26f3 Create a dedicated module for coarse cutting. 2026-08-30 17:34:57 +02:00
Frédéric Tronel 04781021eb Create a dedicated module for coarse cutting. 2026-08-30 17:34:42 +02:00
Frédéric Tronel e1ac3e48bb Linting with ruff. 2026-08-30 17:24:26 +02:00
Frédéric Tronel ae1a040590 Refactoring: extract cutting engine inside a dedicated module. 2026-08-30 17:19:03 +02:00
Frédéric Tronel 0345293664 More linting with ruff (suppress unused imports). 2026-08-30 14:45:25 +02:00
Frédéric Tronel 63d2deafb1 Linting with ruff. 2026-08-30 14:42:21 +02:00
Frédéric Tronel 6f5dd3a9e4 Final typing error. 2026-08-30 14:18:39 +02:00
Frédéric Tronel 093ad733d3 Even more typing errors fixes. 2026-08-30 14:14:18 +02:00
Frédéric Tronel a6a41c112e Fix of several typing errors. 2026-08-30 13:56:41 +02:00
Frédéric Tronel 447d04d13d Better imports in the pipeline. 2026-08-30 09:33:02 +02:00
Frédéric Tronel 2590faf20f The full pipeline is now in its own module. 2026-08-30 08:58:23 +02:00
Frédéric Tronel bea482487e Replace spurious exit call by raising exceptions. 2026-08-29 16:15:47 +02:00
Frédéric Tronel c48b77f294 Move tests to root of the project. 2026-08-29 16:12:23 +02:00
Frédéric Tronel 0ff2e3a6dd Linting and typing fixes. 2026-08-29 16:07:29 +02:00
Frédéric Tronel 294d857206 Put all exceptions in their dedicated module. 2026-08-29 15:30:47 +02:00
Frédéric Tronel 280eb1facb Put all functions related to mkvtoolnix tools into a dedicated module. 2026-08-29 15:30:29 +02:00
Frédéric Tronel 3769244bdb Fix typing error. 2026-08-29 14:25:11 +02:00
Frédéric Tronel b241c45efc Move functions related to subtitles OCR in a dedicated module. 2026-08-29 14:22:35 +02:00
Frédéric Tronel edcbf91101 Transfer functions related to Matroska codec private data into a dedicated module. 2026-08-29 14:11:00 +02:00
Frédéric Tronel 4658cc16e0 Extract logging outside of function. 2026-08-29 13:56:04 +02:00
Frédéric Tronel 5bbdfe9929 Fix missing import. 2026-08-29 13:53:34 +02:00
Frédéric Tronel 87e326d976 Remove function to discover if required tools are installed. 2026-08-29 13:50:46 +02:00
Frédéric Tronel e2936d565f Fix typing errors in ffprobe and ffmpeg related functions. 2026-08-29 13:49:29 +02:00
Frédéric Tronel fa18247fd6 Fix typing problems in tools. 2026-08-29 12:31:50 +02:00
Frédéric Tronel 87b557bc38 Add functions related to ffmpeg, ppm files and timestamps to dedicated modules. 2026-08-29 11:24:50 +02:00
Frédéric Tronel a12a4e1b2b Fix typing errors in ffprobe functions. 2026-08-29 10:47:42 +02:00
Frédéric Tronel 795d314f13 Transfer functions that use ffprobe into a dedicated module. 2026-08-29 10:39:51 +02:00
Frédéric Tronel 700d1fedc5 Fixing type error. 2026-08-28 23:09:02 +02:00
Frédéric Tronel 92df1d24d6 Separate MKV class and functions in a separate module. 2026-08-28 22:57:50 +02:00
Frédéric Tronel ff20f31e74 No more linting error of mypy in H264. 2026-08-28 22:19:52 +02:00
Frédéric Tronel c32018caae Fix tscut for the new structure of the project. 2026-08-28 22:01:07 +02:00
Frédéric Tronel f6491be5b7 Create a module dedicated to H264 parameters dataclass. 2026-08-28 21:56:19 +02:00
Frédéric Tronel a1a1ae5399 Add first tests for H264 bitstream functions. 2026-08-28 21:43:30 +02:00
Frédéric Tronel 8dc2af580d Separate H264 specialized functions. 2026-08-28 21:25:23 +02:00
Frédéric Tronel 413df48a77 Fix wrong import. 2026-08-28 17:13:13 +02:00
Frédéric Tronel 7c539382e6 Replace = bytes() by b''. 2026-08-28 17:05:50 +02:00
Frédéric Tronel 451fd50c68 Remove unused imports. 2026-08-28 17:04:35 +02:00
Frédéric Tronel edd034b3b6 Remove 0 in range(0,). 2026-08-28 16:57:15 +02:00
Frédéric Tronel 66e20e126a Fix ruff warning about imports and IOError instead of OSError. 2026-08-28 16:53:38 +02:00
Frédéric Tronel f18ebfadc2 Fix of a potential bug found by ruff. 2026-08-28 16:52:18 +02:00
Frédéric Tronel e6814f483a Fix typos. 2026-08-28 16:23:35 +02:00
Frédéric Tronel 97ef0e972c Separate main() function in a dedicated module. 2026-08-28 16:20:17 +02:00
Frédéric Tronel 7e72611b1c Fix poetry environment. 2026-08-28 15:55:24 +02:00
Frédéric Tronel ff2ac0a236 Adding poetry support. 2026-08-28 15:50:31 +02:00
Frédéric Tronel bf7b98c73d Intialization of poetry. 2026-08-28 15:38:42 +02:00
Frédéric Tronel ffb167c321 Refactoring plan. 2026-08-28 14:04:05 +02:00
32 changed files with 5336 additions and 0 deletions
+2
View File
@@ -4,3 +4,5 @@
*.mkv
part*
venv/
__pycache__/
*.py[cod]
+11
View File
@@ -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
View File
@@ -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"
+62
View File
@@ -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
+10
View File
@@ -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"
+10
View File
@@ -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())
+123
View File
@@ -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
+30
View File
@@ -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)
+365
View File
@@ -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
)
+26
View File
@@ -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 -
``
+7
View File
@@ -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
View File
+24
View File
@@ -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"""
View File
+303
View File
@@ -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
+303
View File
@@ -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
+716
View File
@@ -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
View File
+43
View File
@@ -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
+165
View File
@@ -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
+61
View File
@@ -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]
+253
View File
@@ -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"))
+191
View File
@@ -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
+38
View File
@@ -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()
View File
+51
View File
@@ -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
+388
View File
@@ -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
+411
View File
@@ -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)
+467
View File
@@ -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
+86
View File
@@ -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
+213
View File
@@ -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
+37
View File
@@ -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