Compare commits
826 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa550ce2d0 | |||
| e45ff58346 | |||
| 93a845ce41 | |||
| 20f02b9aa1 | |||
| f46a1a8436 | |||
| c3b4c4deb0 | |||
| fbeccbe9bc | |||
| 4743cfb939 | |||
| 0a881266c8 | |||
| 20e159dd68 | |||
| 4d089668a8 | |||
| c5c1693989 | |||
| 6f87c0e381 | |||
| 920ad641a8 | |||
| 99ec34de30 | |||
| 34848b3964 | |||
| b28db6c4db | |||
| 10c2e0255c | |||
| b1effb9ab1 | |||
| 9a0096a979 | |||
| e1d5597477 | |||
| f5a9a6f5e7 | |||
| f554fdcb4f | |||
| 16d93a8c3d | |||
| 7e980f31c9 | |||
| 21b56ac090 | |||
| 8119b36c21 | |||
| 70d0260b6e | |||
| b12336cf73 | |||
| 7f8c9921a1 | |||
| 875eec90e9 | |||
| 1c40c48289 | |||
| 0b99bafcc4 | |||
| 66da0e5270 | |||
| a6ed3ecd14 | |||
| 71d5f52b4d | |||
| 980c388325 | |||
| 68cfb0b52d | |||
| d3ec344691 | |||
| 441e80b7a6 | |||
| 599516dc1c | |||
| 9722d77aa5 | |||
| ebe15de141 | |||
| 73265622b2 | |||
| cbeb748ecc | |||
| 370019bdfc | |||
| ccb80b2e9d | |||
| 8fa72cf18d | |||
| 19ff23cba3 | |||
| 04a4a7bc72 | |||
| 9f16d97750 | |||
| f6e053b352 | |||
| 199b70c1d1 | |||
| 700bdd9190 | |||
| b366ff3205 | |||
| 3fadb8af6a | |||
| 689e37782a | |||
| abf44fda90 | |||
| 229b3a0fec | |||
| 85c431d6f6 | |||
| edb501a3fe | |||
| f1ba4e9596 | |||
| b31a5d94da | |||
| 0f88c67928 | |||
| e29bc50fe0 | |||
| 8e294e7d2a | |||
| 87f7594407 | |||
| 0667ce3907 | |||
| 1b1ed5cc70 | |||
| c3142b63c6 | |||
| ce1b1cd718 | |||
| d3801e2df8 | |||
| 22ab5833d0 | |||
| 3131b1462c | |||
| f47a1f6480 | |||
| 07f2d3a213 | |||
| 274c4cf85b | |||
| f83764e634 | |||
| 2860e4bdf8 | |||
| defb9ca947 | |||
| a9c6803489 | |||
| 13a9ed1f6d | |||
| 74242b51d6 | |||
| cda58a2b1d | |||
| 6cfd42c511 | |||
| dd525c93d4 | |||
| d30fbaf4e6 | |||
| 47e5f63ded | |||
| 033caf30ff | |||
| b692d0262e | |||
| 45901fccd0 | |||
| 7576186878 | |||
| 3325caab6a | |||
| 410f44e341 | |||
| 14eb1d6157 | |||
| ed7d11dbca | |||
| c6581d83a7 | |||
| 157166bf15 | |||
| 093e2de1d0 | |||
| 7b3bd08acb | |||
| e8253fff50 | |||
| 78ceb6e3c7 | |||
| 2bc99e0d7a | |||
| d643a88665 | |||
| 58b58440eb | |||
| 643371f6dd | |||
| 2736a1831e | |||
| 50152171e6 | |||
| 1bda219da0 | |||
| 70f0fc7a75 | |||
| 6687835a5d | |||
| ae313a8801 | |||
| e2da60722e | |||
| 79f5036c3f | |||
| 28fcadddd2 | |||
| b787c150e7 | |||
| a25d707876 | |||
| 18cad0b0ce | |||
| c1e431ad2d | |||
| cdf43fd366 | |||
| 0fd76d55e8 | |||
| bd3b2097bb | |||
| fb4cbda47a | |||
| c840eeba59 | |||
| d98280f27b | |||
| 8eb2d43649 | |||
| 105c09bd80 | |||
| 520e32d893 | |||
| cf3bbde67b | |||
| 837835f81f | |||
| 6927ebebba | |||
| 4922e5328c | |||
| 302f51835a | |||
| d90538d094 | |||
| 94eb3df3e5 | |||
| 48e4ab8456 | |||
| 0fd5c33303 | |||
| 7e3060d185 | |||
| d61f23c7c9 | |||
| bb5fa3a740 | |||
| 666c8d1bdc | |||
| fec702eca4 | |||
| 045e694204 | |||
| f4b7486b3e | |||
| 70b6539aa9 | |||
| 6811d4141a | |||
| 21e8636bd4 | |||
| eee1e403c2 | |||
| 9ade822e64 | |||
| 204505bcf5 | |||
| 18f0cfe404 | |||
| af3263cd9b | |||
| 37ec175ced | |||
| 5510a52a06 | |||
| ce8c699fe8 | |||
| 6a2516f4fa | |||
| 27b327ab65 | |||
| 405619b417 | |||
| 838a919663 | |||
| 52e3c5b9c4 | |||
| 63a5a6bb38 | |||
| 9a93d175a4 | |||
| 9e778e3693 | |||
| e9f4a9ff6b | |||
| 1d7e2238b0 | |||
| aa918e739f | |||
| 3d16f9323f | |||
| c59917baf9 | |||
| 142428048a | |||
| ca7c56238d | |||
| f4b1bee71f | |||
| a0a75e728e | |||
| 7ac0269d2c | |||
| 35076281b3 | |||
| dcbb001948 | |||
| eff5f024e5 | |||
| 258699e2ab | |||
| 85960b9592 | |||
| 1968616c3f | |||
| b23675bd17 | |||
| 2f4b072222 | |||
| 49b300f2f2 | |||
| 8f7d48da49 | |||
| 6c3948a36c | |||
| 163fffbefa | |||
| 6dc6a10db6 | |||
| e15066b939 | |||
| d501ae1666 | |||
| 9455ed41a1 | |||
| 48088e3a8e | |||
| 4dc802170d | |||
| 9bc4b6b266 | |||
| 8a7127d687 | |||
| f9a774c5de | |||
| 33459a26ca | |||
| c2efc23c9a | |||
| 310f11d9b0 | |||
| 55d5a6e96a | |||
| 19b5526b6f | |||
| 24d191c5e7 | |||
| a06de0534a | |||
| 26591777a0 | |||
| 6e1157c7a6 | |||
| 2ec9c4c419 | |||
| 02a209cae7 | |||
| fe050dbc8e | |||
| 0014fb2808 | |||
| 1f16aaa061 | |||
| 0e74a96e98 | |||
| 07d4077bed | |||
| 2df01a7436 | |||
| 64f6dc34c6 | |||
| 6403ecbf86 | |||
| ba29721357 | |||
| a46d197c1e | |||
| 4a69a038d3 | |||
| 896525e9df | |||
| 38ac719f61 | |||
| 4f8464332f | |||
| a711c61840 | |||
| fb5b2dc40a | |||
| ff66e124a4 | |||
| d0750c1dc4 | |||
| f565f7b38d | |||
| 07374d1f2a | |||
| 3984ef90ff | |||
| 09aaf9bf15 | |||
| 756cfdd89d | |||
| c2f65871e9 | |||
| 678fe73221 | |||
| d4a69262e1 | |||
| df97b85f59 | |||
| a358c3518e | |||
| be283bc54c | |||
| c08f3bff15 | |||
| c63d9766e9 | |||
| 16b62adc25 | |||
| 29dbd61782 | |||
| 8309f21a6c | |||
| 56d3a70a1e | |||
| 9ed9650cad | |||
| 0b432e3834 | |||
| 39a8b65e33 | |||
| 208465eeff | |||
| dde4bb7d31 | |||
| ca35e1bb9d | |||
| 98f257f56e | |||
| 6be4da05cc | |||
| 9fcd14ec11 | |||
| 1cf868ba4b | |||
| 9b9ffefbe9 | |||
| 056a2c5edb | |||
| 16b458c24c | |||
| f03e21556d | |||
| 0253ba22c4 | |||
| dcc4c89de7 | |||
| 450af7b1cf | |||
| e671aae25a | |||
| cb2ac66c3b | |||
| eafc6208d9 | |||
| 1306339749 | |||
| cee1feb185 | |||
| fccddb6ea8 | |||
| 753c9a99e7 | |||
| c34b9de976 | |||
| 002b3d0fb6 | |||
| fae7f8a9be | |||
| 48cae3e5a3 | |||
| 5e1ded3301 | |||
| 23c9cdb33d | |||
| ff2739f9ea | |||
| 81e2d083fc | |||
| 1d3b4f2bab | |||
| 13355a77a9 | |||
| bdc10001fe | |||
| 044483689b | |||
| 25585fa84a | |||
| 5e55b144a5 | |||
| 7c9da91184 | |||
| 08d500a5c9 | |||
| 6b2a36e2dd | |||
| c3f931d0c4 | |||
| 64a2391ddc | |||
| e6973f5957 | |||
| 5ae8d50168 | |||
| 39f60de2f4 | |||
| 62e94fb2dd | |||
| 62aed1acc2 | |||
| ee414e8b9e | |||
| 6c3a7ed4ef | |||
| 99547d183b | |||
| d77ab61994 | |||
| 676ce6e8ff | |||
| dd92e46583 | |||
| 9261608e1f | |||
| 154bc148e3 | |||
| 3feaf7390f | |||
| 9c00138b4b | |||
| ade0f36544 | |||
| a469a4aeb7 | |||
| 1267e4e172 | |||
| 192174c9ee | |||
| c163983fc1 | |||
| 9418816580 | |||
| 0cfc02a933 | |||
| dc5e488454 | |||
| 1990835de9 | |||
| 138e972010 | |||
| 234b3454ef | |||
| 4678c68d8e | |||
| cc7cec89f8 | |||
| 629e5e9ee1 | |||
| 7bc739bfae | |||
| f2ad4b9c94 | |||
| e35a828c73 | |||
| eb27d5efff | |||
| 9247622550 | |||
| 5f3b54cf44 | |||
| bf96b1dcc1 | |||
| 524f49d994 | |||
| 4f2a4e9981 | |||
| ba72c8944b | |||
| 76f423db77 | |||
| 1c7aa21957 | |||
| e6ba11e286 | |||
| 4e1f673ce0 | |||
| 5f60d980f6 | |||
| e565d4a2fd | |||
| aa8d808a48 | |||
| fae6adc577 | |||
| bd5261c8a3 | |||
| 20487653ce | |||
| 1ee3f85c4c | |||
| 37c17d7073 | |||
| 2ee1734de4 | |||
| 4046ff85f6 | |||
| bb2f5958b6 | |||
| 0f174ab0d6 | |||
| 0199bf3f37 | |||
| a4ec057c4b | |||
| 18dba32df7 | |||
| e61900cd3a | |||
| dac0219ba8 | |||
| c2363399b9 | |||
| 1df794b882 | |||
| f25156c720 | |||
| 133f503851 | |||
| 5ecb74c73e | |||
| c931005aca | |||
| 6c06ab14ca | |||
| 5b0eaa9c30 | |||
| 2259e2ba15 | |||
| 70031d5050 | |||
| f5b8f11271 | |||
| e8dd5ffc45 | |||
| 4ac34587b0 | |||
| 2d2e701078 | |||
| 22ba7dd5c2 | |||
| 12c9f13d73 | |||
| 6bbd389f41 | |||
| 3b177c9033 | |||
| c7cc3088bd | |||
| 475aafe033 | |||
| 3a78436665 | |||
| 13c89bf9df | |||
| c11f7b5e09 | |||
| 1d1cec80b1 | |||
| 654b2b8362 | |||
| 4ad860cb69 | |||
| fe7efdaffc | |||
| e4a1a7fee3 | |||
| 2343fc8fa3 | |||
| bcb7902f42 | |||
| 9203732393 | |||
| 3b3599d11c | |||
| f87796f0c8 | |||
| b97bb74330 | |||
| 95297477fd | |||
| c1b1e86046 | |||
| bef9a6e127 | |||
| eb81d531eb | |||
| fa7c6ff8ab | |||
| e22a49db1c | |||
| ddaf78c221 | |||
| d91a7b3122 | |||
| 55c583f62b | |||
| 59506d9b4a | |||
| e5b1ed90a9 | |||
| 1ef2960514 | |||
| d034c58edd | |||
| 5198059a7c | |||
| afc54a5f38 | |||
| da13964a82 | |||
| 1990bde6d3 | |||
| 5b76035c3d | |||
| 4d58c67745 | |||
| 542300e911 | |||
| 393284d78f | |||
| c6b5943e7e | |||
| cf9ad66732 | |||
| db6f5194fe | |||
| 8457e7bf31 | |||
| 595f94f303 | |||
| 9deba4a830 | |||
| af5d29310e | |||
| 3516207de7 | |||
| 20d196401a | |||
| 6192214def | |||
| 5553e26e12 | |||
| 1d62db0172 | |||
| 88675faf58 | |||
| d2ae945c30 | |||
| c580406234 | |||
| 44baf6c0f4 | |||
| 71438c627b | |||
| a6a7f95579 | |||
| 67f1ea1ae3 | |||
| dadbfe0bf8 | |||
| 528e525d67 | |||
| 0879bd7a7e | |||
| 84518da96f | |||
| 3f61c036ff | |||
| 6bc526b5e1 | |||
| 31539f1b84 | |||
| b5e05a6501 | |||
| 7fdccec8d7 | |||
| f21434cf9f | |||
| b6866db1b2 | |||
| d6df5c6abd | |||
| 52b36f9764 | |||
| 69a6c4b625 | |||
| 4a75bc05fa | |||
| eb3afad326 | |||
| 0541d4d182 | |||
| ec1570a4d8 | |||
| d466995942 | |||
| d74ed39c24 | |||
| 37a845bc68 | |||
| 8cd44f205b | |||
| 25aeb4d8a4 | |||
| fe3c60b97a | |||
| 50dd69ce32 | |||
| 917d23fdff | |||
| 6c13dda2bf | |||
| aa9d78cd9c | |||
| 369882123f | |||
| 78fe0102bd | |||
| 5f05761ded | |||
| b8ff6b09ad | |||
| 2ad63fd7ff | |||
| d73196f3f7 | |||
| 1fdfd8324d | |||
| eb37bea42e | |||
| 5c14985990 | |||
| 54653a15a6 | |||
| a2573d9228 | |||
| a1726ac09b | |||
| 518df2fb92 | |||
| aff5e7f194 | |||
| ca36cf9a6a | |||
| 8f400822fa | |||
| 4b31803461 | |||
| 4ccc1a84c4 | |||
| 66c9bc6173 | |||
| 0a3abdc3d0 | |||
| 96959e5490 | |||
| 9532dff5dc | |||
| 4201c1feed | |||
| d6a10470f3 | |||
| 452d366156 | |||
| ef12831b36 | |||
| 8a6ea57886 | |||
| 34f6eb741d | |||
| 2f4fba1def | |||
| 85357ede03 | |||
| 3f5d4385dd | |||
| 812a7108d0 | |||
| 8264eb4077 | |||
| 1186b242ba | |||
| 84fd122bd2 | |||
| cd7f932853 | |||
| f5289f69bd | |||
| 121e7ba27e | |||
| 5f7745d592 | |||
| f364eff00a | |||
| 59bf7cc21f | |||
| 841df3ca5e | |||
| b10d24ef5c | |||
| 8a36e82f2e | |||
| 0ec8c73e8f | |||
| ab85b3d757 | |||
| 9761c9e39b | |||
| 03ecfaafe2 | |||
| f460e4630e | |||
| b6884188d8 | |||
| bbacbf2750 | |||
| dd69fc594e | |||
| 9432300f0e | |||
| 71ceebe857 | |||
| 30673c84a2 | |||
| 192b24664e | |||
| 79b48e5452 | |||
| 97c640352c | |||
| 7c21ec5e13 | |||
| 3284ddb993 | |||
| 42f69c654c | |||
| e283aa5303 | |||
| 0594f86fc1 | |||
| 6650a4be0f | |||
| f8701860d8 | |||
| 7c5367740d | |||
| 0cbc6e78dc | |||
| cc9206cb02 | |||
| ce3554e299 | |||
| a2f18d9ad0 | |||
| 0f8533535d | |||
| 5f0dd80169 | |||
| c492de207c | |||
| 584d9a32c8 | |||
| 46d252b1b6 | |||
| ccfd43f68c | |||
| 5d9fc09981 | |||
| ce2895754d | |||
| 1d35f5d39b | |||
| 17bfd84f88 | |||
| de9a262e11 | |||
| e33bfde092 | |||
| 8bebb1078a | |||
| aa90fdc387 | |||
| 1a111a275a | |||
| 07b7ef5bc1 | |||
| 3f6ef5b47e | |||
| d795b9a25e | |||
| 217c66c95a | |||
| a48f6dc83a | |||
| 81108ace16 | |||
| c7f2b781b0 | |||
| dd833b4eab | |||
| 3fdaa813e0 | |||
| 7b84816ae5 | |||
| 8146afb5f7 | |||
| f704b46906 | |||
| 5ce44e2bd8 | |||
| 8ec1a36b9f | |||
| 06e5ac6651 | |||
| d75b0772af | |||
| 47b0688483 | |||
| c7f6eccb18 | |||
| 71713e9bff | |||
| 383fafea8e | |||
| 94f2025199 | |||
| 2d1ffc1418 | |||
| 1f56c14152 | |||
| a351076890 | |||
| d4580cad48 | |||
| b653b5d80f | |||
| 43d0d5c218 | |||
| 920b6c40e3 | |||
| 0fd26c9f54 | |||
| 59444d4e76 | |||
| 2d10351ae6 | |||
| 7ebc8e2bc2 | |||
| cfc47a5440 | |||
| 3809dae370 | |||
| 6ead2249a8 | |||
| 1b42e5b2f9 | |||
| ed062c1d82 | |||
| 6e0a6a65ed | |||
| 5f922d8abe | |||
| 22d069560a | |||
| a9a4a575eb | |||
| 466588f756 | |||
| 77321a506d | |||
| ebbe9240c7 | |||
| 3a1ffdcbab | |||
| dab9630d68 | |||
| 9e9c3c315f | |||
| 883f5f2d84 | |||
| 4916f731df | |||
| 884ac783fc | |||
| 388bef268c | |||
| 963ede7277 | |||
| 06348de6ee | |||
| c2cb113fb8 | |||
| 2e33561d50 | |||
| 1d72887045 | |||
| 42e04de087 | |||
| ee6415a1dd | |||
| c8275f294b | |||
| a078906ac1 | |||
| 24f4ed00a8 | |||
| 156086dc87 | |||
| e86b5e6d33 | |||
| a8cc702848 | |||
| ccda02cb4a | |||
| 95302ee2a5 | |||
| acfea6e2d0 | |||
| 4a09d92fa5 | |||
| 6b860ede89 | |||
| e923025d29 | |||
| c83cb7c830 | |||
| 8ea545b103 | |||
| 40e38d2301 | |||
| 2516a87220 | |||
| 3f52a705e8 | |||
| cc57068acc | |||
| ed86d9f12e | |||
| 1661344ba6 | |||
| d89b2788ef | |||
| 1ff863c26f | |||
| bd2b1c57c1 | |||
| a470620b8d | |||
| ff4fedc494 | |||
| 7b285ca1df | |||
| 1f8c97b92a | |||
| 9a2b41a1e9 | |||
| 0b52416804 | |||
| c1c52d2c65 | |||
| 919796ddfa | |||
| aa6d8f42a4 | |||
| cc8eda65f0 | |||
| 5911b429c6 | |||
| 0289cec1a0 | |||
| fdf43f440f | |||
| c14395d25e | |||
| 3494df4c90 | |||
| 039dd06110 | |||
| b2c00d4cac | |||
| 0129bb4b59 | |||
| bc514cbf78 | |||
| 011b506589 | |||
| 8aa928ee7c | |||
| dae3b99e96 | |||
| 8a69dc6047 | |||
| 085622f030 | |||
| 2ac8237318 | |||
| 064c9b92e3 | |||
| 5bf95fb068 | |||
| 016156b19e | |||
| cbceb65197 | |||
| dc38fe8506 | |||
| 9dc3421bb1 | |||
| 20f618d378 | |||
| c3f0b5a5e7 | |||
| f2e3486ee4 | |||
| 94a61d824f | |||
| 7e604c414c | |||
| 02e6255255 | |||
| 8d36decec2 | |||
| 4d516d57d1 | |||
| 34f9c1c162 | |||
| 45201e4d80 | |||
| 218641ad01 | |||
| 634c15e106 | |||
| 33b03652e6 | |||
| 551ed05915 | |||
| 22fe1017bb | |||
| c3e28a08e4 | |||
| 82c162fee6 | |||
| 3e08be8501 | |||
| bdeee0b686 | |||
| 9f476e3b2f | |||
| 76f46a4014 | |||
| ca9eb55863 | |||
| 011f03c812 | |||
| 32abb3f936 | |||
| bc0ef5ad94 | |||
| 92eb836957 | |||
| 4763b553d9 | |||
| 9a3e7d38ed | |||
| 1170afe6a6 | |||
| 4ebcf3aa67 | |||
| f5a783769d | |||
| 470730f1ed | |||
| f913243b3e | |||
| cfe0c751e9 | |||
| 4c41755b83 | |||
| e674be795f | |||
| 7b0ab0c8a3 | |||
| e394061827 | |||
| e370027e70 | |||
| 2c7fd7f1b2 | |||
| 78a4c73a1e | |||
| 1faf783b32 | |||
| adefe9c3fd | |||
| c05040c1c0 | |||
| 4823952c03 | |||
| 6aa2510743 | |||
| 7a3d003194 | |||
| b5306608cc | |||
| d4e4512e70 | |||
| ad08c61b0a | |||
| 8e1fb41258 | |||
| 9e04b8b519 | |||
| 239c46063d | |||
| 2b916fa530 | |||
| dd7a16788e | |||
| 28247afae7 | |||
| 7faece80f0 | |||
| 53a0d959fe | |||
| 5a4bbc5916 | |||
| f5edbe0f6a | |||
| eed3196ba7 | |||
| 0cd1c69775 | |||
| a88c5bba1c | |||
| 2fe3a5e202 | |||
| f315c46457 | |||
| 9891fd11a4 | |||
| b082d6ab0b | |||
| 66c2e63092 | |||
| 6251b39b8f | |||
| 3fd70ff774 | |||
| 18b5db246e | |||
| c2fae80f6d | |||
| a3445bbc1a | |||
| f10fda5883 | |||
| faeca7aeab | |||
| d22775dd41 | |||
| 6b7f48d04d | |||
| 197639988f | |||
| 2d4422f408 | |||
| da4a086676 | |||
| 41ea382bc6 | |||
| d702608eaf | |||
| 5c11f84770 | |||
| 5a755aecd0 | |||
| 2ea2e24cd2 | |||
| ab62994511 | |||
| 1d1d51cbbd | |||
| 9f4814335b | |||
| a17b4292fe | |||
| c76d8211d1 | |||
| 76e7bfb8c9 | |||
| ce63803934 | |||
| 2781df8b26 | |||
| a4053c0735 | |||
| 0166dc1d6c | |||
| 4da2c0ce40 | |||
| 424ec6014b | |||
| d348dccd21 | |||
| 14b5f428d9 | |||
| 6dd829dd61 | |||
| a147e2551e | |||
| 82609afec9 | |||
| fc3b7ae9fc | |||
| c2c7f1206c | |||
| b388c61684 | |||
| 93be6e7da1 | |||
| 33271cdd6c | |||
| 3b48565ecb | |||
| 00ad651814 | |||
| 82c318ed5a | |||
| df282bcdc1 | |||
| 361ba9d58c | |||
| d79248489b | |||
| 53b99ae335 | |||
| 773e1a3c72 | |||
| acc7cf5203 | |||
| a8dac81d97 | |||
| 8859e98fb7 | |||
| 5d7dc800a8 | |||
| 4969601e9a | |||
| a24e0055c6 | |||
| eea3afa8be | |||
| 3da5dec8d4 | |||
| 50f9cfecc0 | |||
| b13567439b | |||
| c6ced89015 | |||
| e283f4dee7 | |||
| e60a7e43bf | |||
| 61df55ff43 | |||
| fbbb57d325 | |||
| 7fef64de6b | |||
| 0a4be7b968 | |||
| 8d62b7fe1a | |||
| 979e9a0c56 | |||
| fb4546e9d6 | |||
| 8056e62f65 | |||
| ba4dacdeff | |||
| 488259414b | |||
| f733230b9f | |||
| 3f9c1421d9 | |||
| 42dac5f699 | |||
| f4bbbd2f64 | |||
| 1e6f42eda2 | |||
| 5ed23c9424 | |||
| 482783e538 | |||
| b74202696d | |||
| d066f82e26 | |||
| 615b89a364 | |||
| f9f7ea4666 | |||
| 50560a8364 | |||
| 7625ddf4de | |||
| c6d47e20f0 | |||
| 111991591b | |||
| c0f1383d66 | |||
| dcabbda07d | |||
| b964b9970a | |||
| 1edcaa4e78 | |||
| 8e61e610cc | |||
| 09f627a73a | |||
| 4a3b2a20f1 | |||
| fc855e2b0e | |||
| 42b990b72b | |||
| 74e1b1eb68 | |||
| 199223f3b9 | |||
| f79a51dd24 | |||
| f8b37e1463 | |||
| 03bf8b1f33 | |||
| 995053922e | |||
| bad190d234 | |||
| d3444534a6 | |||
| 116364cf08 | |||
| 39afd2f6c3 | |||
| 3b1fa29663 | |||
| 82242c3817 | |||
| cbd06b6c9a | |||
| 8ea94a8541 | |||
| 0797ab4ed9 | |||
| 2f4b59329e | |||
| 7d6c53d03d | |||
| 055e95ab17 | |||
| f91b86dd48 | |||
| 059376af1c | |||
| c733040a5c | |||
| 0bc4795083 |
@@ -0,0 +1,6 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
@@ -0,0 +1,14 @@
|
||||
# `cargo nextest run` (used by `cargo xtask ci`) runs every test in a process of its own, in parallel.
|
||||
|
||||
[test-groups]
|
||||
# NVENC sessions are a limited resource on consumer GPUs.
|
||||
nvenc = { max-threads = 1 }
|
||||
|
||||
[[profile.default.overrides]]
|
||||
filter = "package(filmcraft-platform) and (binary(/nvenc/) or test(/nvenc/))"
|
||||
test-group = "nvenc"
|
||||
|
||||
# A throughput gate: it measures one core's speed, so nothing else runs beside it.
|
||||
[[profile.default.overrides]]
|
||||
filter = "package(filmcraft-render) and binary(mixer_perf)"
|
||||
threads-required = "num-test-threads"
|
||||
@@ -0,0 +1,30 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
@@ -0,0 +1,30 @@
|
||||
name: CI
|
||||
|
||||
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
|
||||
# suite's shared gate workflow in craft-common-actions.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
|
||||
concurrency:
|
||||
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
# Same craft-fonts commit as release.yml.
|
||||
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
timeout-minutes: 120
|
||||
packages: >-
|
||||
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
|
||||
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
|
||||
libasound2-dev
|
||||
@@ -5,17 +5,19 @@
|
||||
# golden-image tests need tools and a GPU the VM doesn't have.)
|
||||
name: FreeBSD
|
||||
|
||||
# The VM build takes an hour or more, so it runs nightly on main rather than on every push or
|
||||
# dependency change: a drifted dependency still shows up within a day. PRs that change the
|
||||
# FreeBSD packaging itself still run it.
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
|
||||
# request: a drifted dependency still shows up within a day, and always before a release ships.
|
||||
# PRs that change the FreeBSD workflow or packaging itself still run it.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "37 2 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/freebsd.yml
|
||||
- packaging/freebsd/**
|
||||
- packaging/env.sh
|
||||
schedule:
|
||||
- cron: "37 2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -27,32 +29,9 @@ permissions:
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + package)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 240
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Build and package in a FreeBSD VM
|
||||
# vmactions/freebsd-vm v1.5.9, pinned by commit (it runs our build).
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
|
||||
copyback: false
|
||||
# The same packages as release.yml's freebsd job: winit/wgpu (X11, Wayland, Vulkan,
|
||||
# Mesa), the file dialogs (GTK 3), fonts, and audio (cpal over ALSA: alsa-lib).
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
|
||||
run: |
|
||||
set -e
|
||||
. "$HOME/.cargo/env"
|
||||
git config --global --add safe.directory "*"
|
||||
rustc --version
|
||||
export CARGO_TARGET_DIR=/var/tmp/filmcraft-target CARGO_BUILD_JOBS=4
|
||||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/filmcraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/filmcraft$|/bin/filmcraft-cli$|/share/applications/ai\.storyteller\.filmcraft\.desktop$|/share/doc/filmcraft/LICENSE-MIT$'
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
craft-fonts-ref: bfcbf714e45a3ea8e98f88ab2b7971372a576414
|
||||
skip-tests: true
|
||||
run-package-check: true
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
|
||||
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
pull_request:
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
git-sources:
|
||||
name: Git dependencies come from storytold
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Git dependencies come from storytold
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
|
||||
# Source replacement, patches and path overrides in cargo config never show up
|
||||
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
|
||||
# Cargo still reads the legacy .cargo/config.
|
||||
python3 - <<'PY'
|
||||
import os, sys, tomllib
|
||||
for path in (".cargo/config", ".cargo/config.toml"):
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
config = tomllib.load(open(path, "rb"))
|
||||
replaced = [name for name, source in config.get("source", {}).items()
|
||||
if isinstance(source, dict) and "replace-with" in source]
|
||||
if replaced or "patch" in config or "paths" in config:
|
||||
print(f"{path} replaces, patches or overrides a source")
|
||||
sys.exit(1)
|
||||
PY
|
||||
# Fails when Cargo.lock is out of date with the manifests.
|
||||
cargo metadata --locked --format-version 1 > /dev/null
|
||||
python3 - <<'PY'
|
||||
import sys, tomllib
|
||||
lock = tomllib.load(open("Cargo.lock", "rb"))
|
||||
bad = sorted({p["source"] for p in lock.get("package", [])
|
||||
if p.get("source", "").startswith("git+")
|
||||
and not p["source"].startswith("git+https://github.com/storytold/")})
|
||||
if bad:
|
||||
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
nix:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
with:
|
||||
app-name: filmcraft
|
||||
app-title: FilmCraft
|
||||
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
|
||||
craft-fonts-ref: bfcbf714e45a3ea8e98f88ab2b7971372a576414
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
@@ -35,3 +35,9 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
|
||||
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
# A push to main is the run that publishes coverage; superseded runs there would never finish.
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
secrets: inherit
|
||||
@@ -4,19 +4,18 @@
|
||||
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
|
||||
name: Windows ARM64
|
||||
|
||||
# The package build takes about 15 minutes, so it runs nightly on main and on pushes to `release`
|
||||
# rather than on every push to main: a dependency or runner-image change still shows up within a
|
||||
# day. PRs that change the Windows packaging itself still run it.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "41 4 * * *"
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- "**/build.rs"
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
schedule:
|
||||
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -5,3 +5,4 @@ plan/
|
||||
/log
|
||||
.DS_Store
|
||||
.claude/worktrees/
|
||||
__pycache__/
|
||||
|
||||
@@ -127,6 +127,8 @@ See [§0](#0-never-crash). It outranks every other engineering rule.
|
||||
See `CLAUDE.md`: pure Rust, dependency layering (`cargo xtask layers`), exact `Tick` time, everything is
|
||||
a command, everything is agent-drivable, and the quality gates (`cargo xtask ci`) before every commit.
|
||||
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
|
||||
|
||||
Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in
|
||||
[`storytold/photocraft-corpus`](https://github.com/storytold/photocraft-corpus), explained in
|
||||
|
||||
+9
-1
@@ -9,7 +9,11 @@ iconography, images or other Adobe assets; open licences only; every asset attri
|
||||
|
||||
| Asset | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| `crates/ui-egui/src/i18n/it.tsv` | FilmCraft contributors | Original work: clean-room Italian interface translations | MIT OR Apache-2.0 |
|
||||
| `crates/ui-egui/src/i18n/id.tsv` | FilmCraft contributors | Original work: clean-room Indonesian interface translations | MIT OR Apache-2.0 |
|
||||
| `crates/ui-egui/src/i18n/ko.tsv` | FilmCraft contributors | Original work: clean-room Korean interface translations | MIT OR Apache-2.0 |
|
||||
| `crates/ui-egui/src/i18n/fr.tsv` | FilmCraft contributors | Original work: clean-room French interface translations | MIT OR Apache-2.0 |
|
||||
| `crates/ui-egui/src/i18n/pt-br.tsv` | FilmCraft contributors | Original work: clean-room Brazilian Portuguese interface translations | MIT OR Apache-2.0 |
|
||||
| `crates/ui-egui/src/i18n/ru.tsv` | FilmCraft contributors | Original work: clean-room Russian interface translations | MIT OR Apache-2.0 |
|
||||
| `crates/ui-egui/src/i18n/uk.tsv` | FilmCraft contributors | Original work: clean-room Ukrainian interface translations | MIT OR Apache-2.0 |
|
||||
| `assets/fonts/Inter-Regular.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) |
|
||||
@@ -24,7 +28,7 @@ iconography, images or other Adobe assets; open licences only; every asset attri
|
||||
| `docs/images/filmcraft-keyframes.png` | FilmCraft contributors | Original work: FilmCraft screenshot; frames from Night of the Living Dead (1968, public domain) | MIT OR Apache-2.0; film frames public domain |
|
||||
| `docs/images/filmcraft-export.png` | FilmCraft contributors | Original work: FilmCraft screenshot; frame from Night of the Living Dead (1968, public domain) | MIT OR Apache-2.0; film frames public domain |
|
||||
| `docs/images/filmcraft-assembly.png` | FilmCraft contributors | Original work: FilmCraft screenshot; frames from Carnival of Souls (1962), Night of the Living Dead (1968), thumbnails of all demo media | MIT OR Apache-2.0; film frames public domain |
|
||||
| `docs/images/filmcraft-lumetri.png` | FilmCraft contributors | Original work: FilmCraft UI crop (Lumetri Color panel) | MIT OR Apache-2.0 |
|
||||
| `docs/images/filmcraft-color-panel.png` | FilmCraft contributors | Original work: FilmCraft UI crop (color grading panel) | MIT OR Apache-2.0 |
|
||||
| `docs/images/filmcraft-scopes.png` | FilmCraft contributors | Original work: FilmCraft UI crop (scopes of a Charade (1963) frame, public domain) | MIT OR Apache-2.0 |
|
||||
| `docs/images/filmcraft-effects.png` | FilmCraft contributors | Original work: FilmCraft screenshot crop; NASA Earth Views (US Government work, public domain) | MIT OR Apache-2.0; footage public domain |
|
||||
| `docs/images/filmcraft-timeline.png` | FilmCraft contributors | Original work: FilmCraft UI crop; thumbnails from Night of the Living Dead (1968) and Carnival of Souls (1962), CC0 Chopin waveform | MIT OR Apache-2.0; film frames public domain |
|
||||
@@ -126,6 +130,10 @@ carry each font's licence as `OFL-<family>.txt`.
|
||||
| BIZ UDPGothic Regular, Bold (Morisawa) | Japanese UI text; Japanese fallback for sans titles (the web build embeds only Regular) | OFL-1.1 |
|
||||
| Shippori Mincho Regular (FONTDASU) | Japanese fallback for serif titles (native builds) | OFL-1.1 |
|
||||
| BIZ UDMincho Regular (Morisawa) | Japanese fallback for serif titles (native builds) | OFL-1.1 |
|
||||
| Noto Sans CJK SC Regular (Google, Adobe) | Chinese UI text and media names; Chinese fallback for titles (native builds) | OFL-1.1 |
|
||||
| Noto Sans Arabic Regular (Google) | Arabic UI text; Arabic fallback for titles (native builds) | OFL-1.1 |
|
||||
| Noto Sans Thai Regular (Google) | Thai fallback for titles (native builds) | OFL-1.1 |
|
||||
| Noto Sans Georgian Regular (Google) | Georgian fallback for titles (native builds) | OFL-1.1 |
|
||||
|
||||
## First-party brand marks
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ FilmCraft is a clean-room, open-source, pure-Rust non-linear video editor target
|
||||
- **Pure Rust** in the product. OS media APIs only via Rust bindings in `crates/platform` (hardware decoding; the one crate allowed `unsafe`, see `AGENTS.md` §0.3 and `docs/adr/0001-platform-ffi.md`), optional, behind traits, with our decoders as the fallback.
|
||||
- **Layering** ([`docs/architecture.md`](docs/architecture.md) §1, enforced by `cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd/cpal. L0 codec/container crates depend only on `filmcraft-bitstream`.
|
||||
- **Exact time:** all time is `filmcraft_time::Tick` (254 016 000 000/s). Never use f64 seconds for edit math.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP all dispatch by id.
|
||||
- **Everything is agent-drivable:** every interactive widget registers an automation id; UI state is serde so the control channel can read/set it.
|
||||
- **Quality gates** before every commit: `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`, `cargo xtask layers`, `cargo xtask assets`, `cargo xtask wasm` (checks L0–L4 crates). `cargo xtask ci` runs them all.
|
||||
|
||||
Generated
+133
-54
@@ -1011,6 +1011,19 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-docpath"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
|
||||
|
||||
[[package]]
|
||||
name = "craft-media"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4#f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
@@ -1674,7 +1687,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -1696,8 +1709,10 @@ dependencies = [
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"open",
|
||||
"pollster 0.4.0",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"subtle",
|
||||
"sys-locale",
|
||||
"winit",
|
||||
"winresource",
|
||||
@@ -1706,7 +1721,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-aac"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1716,7 +1731,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ac3"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1724,7 +1739,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-apv"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1734,14 +1749,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-audio-dsp"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-automation"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"filmcraft-engine",
|
||||
@@ -1751,13 +1766,14 @@ dependencies = [
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-av1"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1767,15 +1783,22 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-bitstream"
|
||||
version = "0.6.0"
|
||||
name = "filmcraft-avi"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-bitstream"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-captions"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-project",
|
||||
@@ -1788,11 +1811,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-cfb"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-cli"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-automation",
|
||||
"filmcraft-codecs",
|
||||
@@ -1806,6 +1829,7 @@ dependencies = [
|
||||
"filmcraft-platform",
|
||||
"filmcraft-project",
|
||||
"filmcraft-render",
|
||||
"filmcraft-testkit",
|
||||
"filmcraft-time",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -1813,12 +1837,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-codecs"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-aac",
|
||||
"filmcraft-ac3",
|
||||
"filmcraft-apv",
|
||||
"filmcraft-av1",
|
||||
"filmcraft-avi",
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-color",
|
||||
"filmcraft-dnx",
|
||||
@@ -1828,6 +1853,7 @@ dependencies = [
|
||||
"filmcraft-isobmff",
|
||||
"filmcraft-matroska",
|
||||
"filmcraft-media",
|
||||
"filmcraft-mpa",
|
||||
"filmcraft-mpeg2v",
|
||||
"filmcraft-mpegts",
|
||||
"filmcraft-mxf",
|
||||
@@ -1836,9 +1862,11 @@ dependencies = [
|
||||
"filmcraft-prores",
|
||||
"filmcraft-testkit",
|
||||
"filmcraft-time",
|
||||
"filmcraft-vp8",
|
||||
"filmcraft-vp9",
|
||||
"image",
|
||||
"log",
|
||||
"rayon",
|
||||
"serde_json",
|
||||
"symphonia",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1847,15 +1875,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-color"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-dng"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-dng",
|
||||
"rayon",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-dnx"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -1865,7 +1902,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-edit"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-geom",
|
||||
"filmcraft-media",
|
||||
@@ -1878,18 +1915,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-engine"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"craft-docpath",
|
||||
"filmcraft-audio-dsp",
|
||||
"filmcraft-captions",
|
||||
"filmcraft-codecs",
|
||||
"filmcraft-color",
|
||||
"filmcraft-dng",
|
||||
"filmcraft-edit",
|
||||
"filmcraft-export",
|
||||
"filmcraft-format",
|
||||
"filmcraft-frame",
|
||||
"filmcraft-geom",
|
||||
"filmcraft-interchange",
|
||||
"filmcraft-isobmff",
|
||||
"filmcraft-media",
|
||||
"filmcraft-mxf",
|
||||
"filmcraft-project",
|
||||
@@ -1910,7 +1951,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-export"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-aac",
|
||||
"filmcraft-apv",
|
||||
@@ -1918,6 +1959,7 @@ dependencies = [
|
||||
"filmcraft-codecs",
|
||||
"filmcraft-color",
|
||||
"filmcraft-dnx",
|
||||
"filmcraft-flac",
|
||||
"filmcraft-frame",
|
||||
"filmcraft-geom",
|
||||
"filmcraft-h264enc",
|
||||
@@ -1939,21 +1981,32 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-format"
|
||||
version = "0.6.0"
|
||||
name = "filmcraft-flac"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-format"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"filmcraft-media",
|
||||
"filmcraft-project",
|
||||
"filmcraft-time",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-frame"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-time",
|
||||
@@ -1963,7 +2016,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-geom"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1971,7 +2024,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-golden"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
@@ -1988,7 +2041,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-gpu"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-export",
|
||||
@@ -2007,7 +2060,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-h264"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -2017,7 +2070,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-h264enc"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -2027,7 +2080,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-hevc"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -2037,7 +2090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-interchange"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-cfb",
|
||||
"filmcraft-color",
|
||||
@@ -2054,39 +2107,51 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-isobmff"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"craft-media",
|
||||
"filmcraft-testkit",
|
||||
"proptest",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-matroska"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
"filmcraft-testkit",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-media"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-dng",
|
||||
"filmcraft-frame",
|
||||
"filmcraft-testkit",
|
||||
"filmcraft-time",
|
||||
"image",
|
||||
"png",
|
||||
"rayon",
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mpa"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mpeg2v"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"rayon",
|
||||
@@ -2095,7 +2160,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mpegts"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
@@ -2103,28 +2168,29 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-mxf"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ogg"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-opus"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-platform"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-codecs",
|
||||
@@ -2150,7 +2216,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-project"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-geom",
|
||||
@@ -2164,7 +2230,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-prores"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
@@ -2174,7 +2240,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-render"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-audio-dsp",
|
||||
"filmcraft-captions",
|
||||
@@ -2194,7 +2260,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-scopes"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"serde",
|
||||
@@ -2203,7 +2269,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-speech"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
@@ -2221,18 +2287,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-testkit"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"png",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-text"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"harfrust",
|
||||
"skrifa",
|
||||
"tempfile",
|
||||
"unicode-bidi",
|
||||
"unicode-linebreak",
|
||||
"unicode-segmentation",
|
||||
@@ -2240,7 +2308,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-time"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -2248,7 +2316,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-tts"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
@@ -2262,7 +2330,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-tts-text"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
@@ -2270,7 +2338,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-ui-egui"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"cpu-time",
|
||||
"craft-ui",
|
||||
@@ -2302,22 +2370,33 @@ dependencies = [
|
||||
"rustix 1.1.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"toml 1.1.6+spec-1.1.0",
|
||||
"unicode-bidi",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-vp8"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-vp9"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-bitstream",
|
||||
"filmcraft-testkit",
|
||||
"rayon",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filmcraft-web"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -7342,7 +7421,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
dependencies = [
|
||||
"filmcraft-testkit",
|
||||
"serde_json",
|
||||
|
||||
+23
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.0"
|
||||
version = "0.12.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -17,21 +17,26 @@ filmcraft-geom = { path = "crates/geom" }
|
||||
filmcraft-color = { path = "crates/color" }
|
||||
filmcraft-bitstream = { path = "crates/bitstream" }
|
||||
filmcraft-isobmff = { path = "crates/isobmff" }
|
||||
filmcraft-avi = { path = "crates/avi" }
|
||||
filmcraft-matroska = { path = "crates/matroska" }
|
||||
filmcraft-mxf = { path = "crates/mxf" }
|
||||
filmcraft-cfb = { path = "crates/cfb" }
|
||||
filmcraft-mpegts = { path = "crates/mpegts" }
|
||||
filmcraft-mpeg2v = { path = "crates/mpeg2v" }
|
||||
filmcraft-ac3 = { path = "crates/ac3" }
|
||||
filmcraft-mpa = { path = "crates/mpa" }
|
||||
filmcraft-ogg = { path = "crates/ogg" }
|
||||
filmcraft-h264 = { path = "crates/h264" }
|
||||
filmcraft-hevc = { path = "crates/hevc" }
|
||||
filmcraft-vp8 = { path = "crates/vp8" }
|
||||
filmcraft-vp9 = { path = "crates/vp9" }
|
||||
filmcraft-prores = { path = "crates/prores" }
|
||||
filmcraft-dnx = { path = "crates/dnx" }
|
||||
filmcraft-apv = { path = "crates/apv" }
|
||||
filmcraft-dng = { path = "crates/dng" }
|
||||
filmcraft-av1 = { path = "crates/av1" }
|
||||
filmcraft-aac = { path = "crates/aac" }
|
||||
filmcraft-flac = { path = "crates/flac" }
|
||||
filmcraft-opus = { path = "crates/opus" }
|
||||
filmcraft-h264enc = { path = "crates/h264enc" }
|
||||
filmcraft-audio-dsp = { path = "crates/audio-dsp" }
|
||||
@@ -62,9 +67,13 @@ thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = "1"
|
||||
log = "0.4"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
rayon = "1"
|
||||
proptest = "1"
|
||||
roxmltree = "0.21"
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4", default-features = false }
|
||||
|
||||
# ui
|
||||
# Shared frontend components; keep this dependency above the engine boundary.
|
||||
@@ -84,11 +93,15 @@ too_many_arguments = "allow"
|
||||
needless_range_loop = "allow"
|
||||
type_complexity = "allow"
|
||||
|
||||
# Line tables keep file and line in panics and backtraces at a fraction of full debug info's size;
|
||||
# dependencies need none.
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
debug = false
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
debug = "line-tables-only"
|
||||
|
||||
# Codec hot loops: debug-build tests (RFC/oracle vectors) are minutes slower below opt-level 3.
|
||||
[profile.dev.package.filmcraft-h264]
|
||||
@@ -109,12 +122,18 @@ opt-level = 3
|
||||
[profile.dev.package.filmcraft-apv]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.filmcraft-dng]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.filmcraft-av1]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.filmcraft-aac]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.filmcraft-flac]
|
||||
opt-level = 3
|
||||
|
||||
# Audio effects: realtime-factor tests and long convolution / spectral runs.
|
||||
[profile.dev.package.filmcraft-audio-dsp]
|
||||
opt-level = 3
|
||||
@@ -125,6 +144,9 @@ opt-level = 3
|
||||
[profile.dev.package.filmcraft-opus]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.filmcraft-vp8]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.filmcraft-vp9]
|
||||
opt-level = 3
|
||||
|
||||
|
||||
@@ -17,5 +17,5 @@ Noto Serif font (assets/fonts/NotoSerif-Regular.ttf) — OFL-1.1. See assets/fon
|
||||
JetBrains Mono font (assets/fonts/JetBrainsMono-Regular.ttf) — OFL-1.1. See assets/fonts/OFL-JetBrainsMono.txt.
|
||||
Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed the fonts listed in
|
||||
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, Shippori Mincho,
|
||||
BIZ UDMincho) — OFL-1.1. Their licence texts ship with the packages as OFL-<family>.txt.
|
||||
BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic, Noto Sans Thai, Noto Sans Georgian) — OFL-1.1. Their licence texts ship with the packages as OFL-<family>.txt.
|
||||
Public-domain film and NASA frames and CC0 music shown in README screenshots (docs/images/) — public domain / CC0-1.0. See ATTRIBUTION.md.
|
||||
|
||||
@@ -8,22 +8,29 @@
|
||||
</p>
|
||||
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.filmcraft.png" width="128" height="128" alt="FilmCraft icon: an engraved owl on a purple tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">FilmCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Video editing, color and sound; an open-source, clean-room reimplementation of Adobe Premiere Pro, rebuilt in pure Rust.</b>
|
||||
<b>Video editing, color and sound; an open-source, professional non-linear video editor, built in pure Rust.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
An open-source, clean-room take on the Adobe Premiere Pro workflow: native on macOS, Windows and Linux, and in the browser via WebAssembly.<br>
|
||||
An open-source, modern non-linear video editing workflow: native on macOS, Windows and Linux, and in the browser via WebAssembly.<br>
|
||||
By the ArtCraft team.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#license-and-credits"><img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%7C%20Apache--2.0-8b5cf6"></a>
|
||||
<img alt="Written in pure Rust" src="https://img.shields.io/badge/pure-Rust-6a3fd6?logo=rust&logoColor=white">
|
||||
<img alt="Runs on macOS, Windows and Linux" src="https://img.shields.io/badge/runs%20on-macOS%20%7C%20Windows%20%7C%20Linux-8b5cf6">
|
||||
<a href="#status"><img alt="Status: young and moving fast" src="https://img.shields.io/badge/status-young%20and%20moving%20fast-6a3fd6"></a>
|
||||
<a href="#license-and-credits"><img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%7C%20Apache--2.0-8b5cf6?style=flat-square"></a>
|
||||
<img alt="Written in pure Rust" src="https://img.shields.io/badge/pure-Rust-6a3fd6?style=flat-square&logo=rust&logoColor=white">
|
||||
<img alt="Runs on macOS, Windows and Linux" src="https://img.shields.io/badge/runs%20on-macOS%20%7C%20Windows%20%7C%20Linux-8b5cf6?style=flat-square">
|
||||
<a href="#status"><img alt="Status: young and moving fast" src="https://img.shields.io/badge/status-young%20and%20moving%20fast-6a3fd6?style=flat-square"></a>
|
||||
<br>
|
||||
<a href="https://sonarcloud.io/project/overview?id=storytold_filmcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
|
||||
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/filmcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -39,7 +46,7 @@
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/filmcraft-hero.png" alt="FilmCraft in the Color workspace, mid-way through an Apollo 11 documentary cut from NASA footage: the Program monitor on the Saturn V clearing the launch tower with a Launch Complex 39A lower third and an air-to-ground subtitle, Effect Controls with Lumetri and Scale keyframes on the shot, the Lumetri Color panel, bins of NASA selects, and a timeline with 49 picture cuts, B-roll, titles, a caption track, mission audio, a ducked music bed, named markers, a rendered section and live loudness meters" width="100%">
|
||||
<img src="docs/images/filmcraft-hero.png" alt="FilmCraft in the Color workspace, mid-way through an Apollo 11 documentary cut from NASA footage: the Program monitor on the Saturn V clearing the launch tower with a Launch Complex 39A lower third and an air-to-ground subtitle, Effect Controls with color grading and Scale keyframes on the shot, the color grading panel, bins of NASA selects, and a timeline with 49 picture cuts, B-roll, titles, a caption track, mission audio, a ducked music bed, named markers, a rendered section and live loudness meters" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center"><sub><i>Apollo 11 - Tranquility</i>: a three-minute documentary edit of NASA's 1969 launch, landing and moonwalk film, with subtitles from the mission transcript. Every frame in these screenshots comes from public-domain footage, decoded, composited and graded by FilmCraft's own code.</sub></p>
|
||||
@@ -66,7 +73,7 @@
|
||||
|
||||
<br>
|
||||
|
||||
FilmCraft is a non-linear editor for people who know Premiere: the same panels, workspaces, tools and shortcuts, so your hands already know where everything is. Underneath, it is new from the bitstream up. The H.264, HEVC, ProRes and AAC codecs are our own, written in Rust from the public specifications. A GPU compositor works in linear light. Frame math runs on exact integer time, so edits never drift. And every action in the app is a command that an AI agent can drive as precisely as you can.
|
||||
FilmCraft is a professional non-linear editor: familiar panels, workspaces, tools and shortcuts, so your hands already know where everything is. Underneath, it is new from the bitstream up. The H.264, HEVC, ProRes and AAC codecs are our own, written in Rust from the public specifications. A GPU compositor works in linear light. Frame math runs on exact integer time, so edits never drift. And every action in the app is a command that an AI agent can drive as precisely as you can.
|
||||
|
||||
<br>
|
||||
|
||||
@@ -84,7 +91,7 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels,
|
||||
- **Every trim.** Ripple, roll, slip, slide, rate stretch and razor tools. Trim mode selects edit points as ripple, roll or trim, nudges them a frame at a time (`⌥←` `⌥→`, ×5 with `⇧`), toggles the trim type with `⌃T` and extends them to the playhead with `E`. `Q` and `W` ripple-trim to the playhead. The **Trim Monitor** shows both sides of the edit, and **dynamic trimming** trims live while it plays: `L` forward, `J` back, `K` to stop and commit as one undo step.
|
||||
- **Exact time.** Every edit is computed on integer ticks: 254,016,000,000 per second, which divides evenly by every common frame rate and sample rate. 23.976, 29.97 drop-frame and 59.94 are exact, not approximate.
|
||||
- **The details pros rely on.** Markers with colours, names and durations; export sequence review notes with **Markers ▸ Export Markers as CSV…**; add edit (`⌘K`) on one or all tracks; nesting; copy, paste and paste insert; ripple delete and close gap; snapping; unlimited undo with a History panel.
|
||||
- **Your keys.** A Keyboard Shortcuts editor (`⌥⌘K`) with a drawn keyboard, panel-specific shortcuts, conflict warnings and presets for FilmCraft, Premiere Pro, Final Cut Pro and Avid key layouts.
|
||||
- **Your keys.** A Keyboard Shortcuts editor (`⌥⌘K`) with a drawn keyboard, panel-specific shortcuts, conflict warnings and presets for FilmCraft and standard NLE key layouts.
|
||||
- **Never lose work.** Saves are atomic, auto-save keeps a rolling set of versions, and a crash-recovery journal written about a second after each edit brings back unsaved changes after a crash or power cut.
|
||||
|
||||
<p align="center">
|
||||
@@ -103,7 +110,7 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels,
|
||||
|
||||
<p align="center"><sub>The Color workspace on <i>Charade</i> (1963), with live scopes beside the Program monitor.</sub></p>
|
||||
|
||||
**A complete Lumetri-style grading panel**, in the order a colourist works:
|
||||
**A complete color grading panel**, in the order a colourist works:
|
||||
|
||||
- **Basic Correction:** temperature and tint, exposure, contrast, highlights, shadows, whites, blacks, saturation.
|
||||
- **Creative:** eight built-in looks with an intensity control, plus faded film, sharpen and vibrance. The looks are colour transforms written in code, not LUT files.
|
||||
@@ -115,11 +122,11 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels,
|
||||
<table>
|
||||
<tr>
|
||||
<td width="34%" valign="top" align="center">
|
||||
<img src="docs/images/filmcraft-lumetri.png" alt="The Lumetri Color panel on Charade (1963): Basic Correction sliders with coloured temperature and tint tracks, collapsed Creative and Curves sections, shadow, midtone and highlight colour wheels, and HSL Secondary and Vignette toggles"><br>
|
||||
<sub><b>The Lumetri Color panel.</b> Basic Correction, Color Wheels and every other section, each with its own on/off switch.</sub>
|
||||
<img src="docs/images/filmcraft-color-panel.png" alt="The color grading panel on Charade (1963): Basic Correction sliders with coloured temperature and tint tracks, collapsed Creative and Curves sections, shadow, midtone and highlight colour wheels, and HSL Secondary and Vignette toggles"><br>
|
||||
<sub><b>The color grading panel.</b> Basic Correction, Color Wheels and every other section, each with its own on/off switch.</sub>
|
||||
</td>
|
||||
<td width="66%" valign="top" align="center">
|
||||
<img src="docs/images/filmcraft-scopes.png" alt="Lumetri Scopes: a green luma waveform and a vectorscope of a Charade (1963) frame"><br>
|
||||
<img src="docs/images/filmcraft-scopes.png" alt="The video scopes: a green luma waveform and a vectorscope of a Charade (1963) frame"><br>
|
||||
<sub><b>Scopes that tell the truth.</b> The waveform and vectorscope are computed from the graded frame. All grading happens in linear light, in 32-bit float.</sub>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -130,14 +137,14 @@ FilmCraft is a non-linear editor for people who know Premiere: the same panels,
|
||||
## Effects and motion
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/filmcraft-effects.png" alt="Effects workspace: Effect Controls with Motion, Opacity, Time Remapping and Lumetri Color, NASA's Earth Views from the ISS in the Program monitor, and the Effects browser open on the Dissolve transitions" width="100%">
|
||||
<img src="docs/images/filmcraft-effects.png" alt="Effects workspace: Effect Controls with Motion, Opacity, Time Remapping and a colour grade, NASA's Earth Views from the ISS in the Program monitor, and the Effects browser open on the Dissolve transitions" width="100%">
|
||||
</p>
|
||||
|
||||
<p align="center"><sub>NASA's <i>Earth Views from the ISS</i> with a look applied, and the Effects browser open on the dissolves.</sub></p>
|
||||
|
||||
- **Premiere 26's full Video Effects bin (93 effects in 16 folders), the Legacy bin and the obsolete effects old projects use, plus around 30 transitions.** Blurs (Bokeh, Focus, Compound), keys (Ultra Key, Track Matte), distortions (Corner Pin, Turbulent Displace, Warp Stabilizer), Lights & Glows, Immersive Video (VR) effects on equirectangular footage, Posterize Time, Echo and more. Cross dissolve, dip to black or white, film dissolve, wipes, irises, pushes, slides, zooms, page peel, cube spin and more.
|
||||
- **A comprehensive Video Effects bin (over 90 effects across 16 categories), legacy effects, plus around 30 transitions.** Blurs (Bokeh, Focus, Compound), keys (Ultra Key, Track Matte), distortions (Corner Pin, Turbulent Displace, Warp Stabilizer), Lights & Glows, Immersive Video (VR) effects on equirectangular footage, Posterize Time, Echo and more. Cross dissolve, dip to black or white, film dissolve, wipes, irises, pushes, slides, zooms, page peel, cube spin and more.
|
||||
- **Motion and opacity on every clip:** position, scale, rotation, anchor point and anti-flicker, plus 26 blend modes.
|
||||
- **Keyframes like Premiere's:** linear, Bezier, auto and continuous Bezier, hold, ease in and ease out. Effect Controls shows a keyframe lane for every parameter under a time ruler with the playhead's handle, and each animated parameter opens into **value and velocity graphs** with draggable influence handles. Effect Controls and the Properties panel share the keyframe navigator (◀ ◆ ▶): add or remove the keyframe at the playhead, step to the previous or next one.
|
||||
- **Advanced keyframing:** linear, Bezier, auto and continuous Bezier, hold, ease in and ease out. Effect Controls shows a keyframe lane for every parameter under a time ruler with the playhead's handle, and each animated parameter opens into **value and velocity graphs** with draggable influence handles. Effect Controls and the Properties panel share the keyframe navigator (◀ ◆ ▶): add or remove the keyframe at the playhead, step to the previous or next one.
|
||||
- **A GPU compositor** built on wgpu (Metal, Vulkan, DirectX 12, WebGPU). It samples YUV straight from the decoder with footprint supersampling and blends in linear light. A CPU path renders the same frames, and the two are tested against each other.
|
||||
|
||||
<p align="center">
|
||||
@@ -175,15 +182,17 @@ No FFmpeg inside. The video codecs, AAC, Opus and the containers are our own Rus
|
||||
| **HEVC / H.265** | ✓ | | Main and Main 10, bit-exact on 41 streams (tiles, WPP, PCM, long-term references), about 225 fps at 1080p |
|
||||
| **Apple ProRes** | ✓ | ✓ | Decodes 422 Proxy to 4444 XQ; export writes 422 HQ |
|
||||
| **AAC-LC** | ✓ | ✓ | |
|
||||
| **VP8** | ✓ | | Every coding tool, bitstream versions 0–3, hidden altref frames, alpha layer of transparent WebM; bit-exact with libvpx on its published VP8 test vectors; in WebM/Matroska and MP4 |
|
||||
| **VP9** | ✓ | | Profiles 0–3, 8/10/12-bit, 4:2:0 to 4:4:4, tiles and superframes; bit-exact on 50+ streams; in WebM/Matroska and MP4 |
|
||||
| **Opus** | ✓ | | SILK, CELT, hybrid and multistream surround; range-exact on every RFC 8251 conformance vector; in WebM/Matroska and MP4 |
|
||||
| **AV1** | ✓ | | Main profile, 8/10-bit 4:2:0 and monochrome, every coding tool incl. film grain, superres and spatial layers; bit-exact with libdav1d on the libaom vectors tested; in MP4 and WebM/Matroska |
|
||||
| **Avid DNxHD / DNxHR** | ✓ | ✓ | All SMPTE ST 2019-1 CIDs (LB to 444, 8/10/12-bit); export writes DNxHR in MOV |
|
||||
| **DNxHD / DNxHR** | ✓ | ✓ | All SMPTE ST 2019-1 CIDs (LB to 444, 8/10/12-bit); export writes DNxHR in MOV |
|
||||
| **MJPEG, PCM** | ✓ | ✓ | |
|
||||
| **MP3, FLAC, ALAC, Vorbis** | ✓ | | Via the [symphonia](https://github.com/pdeljanov/Symphonia) crate (MPL-2.0) for now, to be replaced by our own |
|
||||
| **MP4 / MOV** | ✓ | ✓ | Fragmented MP4, edit lists, timecode tracks |
|
||||
| **Matroska / WebM** | ✓ | | Lacing, Cues, header stripping, HDR colour metadata |
|
||||
| **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP; export PNG sequences and animated GIF |
|
||||
| **AVI** | ✓ | | AVI 1.0 and OpenDML (files past 1 GB); Motion JPEG, H.264, HEVC and uncompressed RGB / YUV with PCM, MP3, MP2 or AC-3. DivX / Xvid and DV not yet |
|
||||
| **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP (animated GIF and APNG as clips, with transparency); export PNG sequences and animated GIF |
|
||||
|
||||
Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG transport and program streams (AVCHD, broadcast, DVD), Ogg and image sequences. Not yet: AV1 export, camera RAW, E-AC-3. Hardware decoding works on macOS (VideoToolbox), Windows (Media Foundation) and, for H.264 and HEVC, Linux (VA-API); H.264 export can use NVIDIA's encoder on Windows and Linux (opt-in), and H.265 (HEVC Main, 8-bit) export works through it (NVENC on Windows, VideoToolbox on macOS) ([#30](https://github.com/storytold/filmcraft/issues/30)).
|
||||
|
||||
@@ -198,7 +207,7 @@ Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG tran
|
||||
<p align="center"><sub>Export mode, set to write the trailer as H.264 MP4.</sub></p>
|
||||
|
||||
- **H.264 MP4 with AAC, using our own encoders.** A 6-second 960×540 render takes 1.3 seconds and decodes in ffmpeg without a single warning, with strict error detection on.
|
||||
- **Also:** Apple ProRes 422 HQ and Motion JPEG in QuickTime, MXF OP1a and Avid-style OP-Atom (DNxHR, ProRes or H.264 with PCM and start timecode), PNG sequences, animated GIF and WAV.
|
||||
- **Also:** Apple ProRes 422 HQ and Motion JPEG in QuickTime, MXF OP1a and standard OP-Atom (DNxHR, ProRes or H.264 with PCM and start timecode), PNG sequences, animated GIF and WAV.
|
||||
- **Background jobs** with progress and cancel, so you keep editing while it renders.
|
||||
- **Render previews:** the render bar marks segments green, yellow or red; rendered previews are cached by content, so an edit only invalidates what it touches and undo brings the green back.
|
||||
|
||||
@@ -208,11 +217,11 @@ Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG tran
|
||||
|
||||
Move timelines between FilmCraft and every other editor:
|
||||
|
||||
- **Final Cut Pro 7 XML (xmeml):** the format Premiere and DaVinci Resolve exchange. It carries nested sequences, transitions, generators, Motion, Opacity, Time Remap and audio levels, with keyframes.
|
||||
- **FCP 7 XML (xmeml):** industry-standard timeline interchange format. It carries nested sequences, transitions, generators, Motion, Opacity, Time Remap and audio levels, with keyframes.
|
||||
- **FCPXML 1.9–1.11:** the spine, connected clips as lanes, transitions, retiming and compound clips.
|
||||
- **OpenTimelineIO:** the open interchange format of the film industry, round-tripping every FilmCraft detail through `metadata.filmcraft`.
|
||||
- **CMX 3600 EDL:** the oldest format still in use, with drop-frame timecode, dissolves, wipes, speed changes (`M2`) and one EDL per track.
|
||||
- **AAF (Edit Protocol):** for Avid Media Composer and Pro Tools. Video and audio tracks, dissolves and dips, clip volume with keyframes, markers and source timecode; audio embedded or as separate WAV / AIFF files, trimmed with handles, with clip effects rendered in and broken out to mono, plus an optional video mixdown. Import reads AAF back, extracting embedded audio.
|
||||
- **AAF (Edit Protocol):** standard timeline and audio interchange format. Video and audio tracks, dissolves and dips, clip volume with keyframes, markers and source timecode; audio embedded or as separate WAV / AIFF files, trimmed with handles, with clip effects rendered in and broken out to mono, plus an optional video mixdown. Import reads AAF back, extracting embedded audio.
|
||||
- **OMF 2.0:** the audio-post handoff: the audio tracks with crossfades and gain, sample-accurate, with the audio encapsulated or alongside.
|
||||
|
||||
Import merges the document's bins, media and sequences into your project as one undoable step, and links each media file it finds on disk.
|
||||
@@ -221,7 +230,7 @@ Import merges the document's bins, media and sequences into your project as one
|
||||
|
||||
## Built for agents
|
||||
|
||||
Every menu item, button, slider and drag in FilmCraft is a **command** with an id, typed parameters and an enabled state. There are more than 650 engine commands (`filmcraft-cli commands` lists them), with the rest of Premiere's catalogue on the way. The UI, the CLI, a JSON control channel and an **MCP server** all dispatch the same commands, so Claude or any agent can cut, trim, grade, mix and export exactly the way a person does. The UI can also be driven at the level of mouse and keyboard: every widget has an automation id, and agents can click, drag, type and take screenshots.
|
||||
Every menu item, button, slider and drag in FilmCraft is a **command** with an id, typed parameters and an enabled state. There are more than 650 engine commands (`filmcraft-cli commands` lists them), with more commands continuously added. The UI, the CLI, a JSON control channel and an **MCP server** all dispatch the same commands, so Claude or any agent can cut, trim, grade, mix and export exactly the way a person does. The UI can also be driven at the level of mouse and keyboard: every widget has an automation id, and agents can click, drag, type and take screenshots.
|
||||
|
||||
```jsonc
|
||||
// over the control channel (JSON lines on TCP) or as MCP tool calls
|
||||
@@ -275,22 +284,22 @@ The control protocol is documented in [docs/control-protocol.md](docs/control-pr
|
||||
| [docs/project-files.md](docs/project-files.md) | `.fcproj` format, schema migrations, auto-save and crash recovery |
|
||||
| [docs/graphics.md](docs/graphics.md) · [docs/captions.md](docs/captions.md) | Text engine, graphic clips and tools; caption tracks and formats |
|
||||
| [ROADMAP.md](ROADMAP.md) | Stage, parity numbers, languages, what's next and the progress log |
|
||||
| [docs/target-app-parity.md](docs/target-app-parity.md) · [docs/gaps.md](docs/gaps.md) · [docs/roadmap.md](docs/roadmap.md) | Parity assessment against Premiere Pro, the ranked list of shortfalls, milestones and current focus |
|
||||
| [docs/target-app-parity.md](docs/target-app-parity.md) · [docs/gaps.md](docs/gaps.md) · [docs/roadmap.md](docs/roadmap.md) | Parity assessment, the ranked list of shortfalls, milestones and current focus |
|
||||
| [docs/codec-parity.md](docs/codec-parity.md) · [docs/file-format-parity.md](docs/file-format-parity.md) · [docs/hardware-parity.md](docs/hardware-parity.md) · [docs/ui-parity.md](docs/ui-parity.md) · [docs/localization-parity.md](docs/localization-parity.md) | Parity checklists by dimension |
|
||||
|
||||
## Status
|
||||
|
||||
FilmCraft is young and moving fast. Editing, trimming, multicam, colour, keyframes, effects, titles, captions, mixing, codecs and export work today.
|
||||
|
||||
FilmCraft is in **alpha**. We track two numbers ([docs/target-app-parity.md](docs/target-app-parity.md), re-measured 2026-10-10 against Premiere Pro 26.5.2):
|
||||
FilmCraft is in **alpha**. We track two numbers ([docs/target-app-parity.md](docs/target-app-parity.md), re-measured 2026-10-10):
|
||||
|
||||
- **Feature breadth: ~86%.** Premiere Pro's menu items, effects, transitions, panels and formats that exist in FilmCraft (menus 92% and effects 100% measured).
|
||||
- **Ready for real work: ~56%.** Our honest estimate of how close FilmCraft is to replacing Premiere on real projects.
|
||||
- **Feature breadth: ~86%.** Professional NLE menu items, effects, transitions, panels and formats that exist in FilmCraft (menus 92% and effects 100% measured).
|
||||
- **Ready for real work: ~56%.** Our honest estimate of how close FilmCraft is to handling full production work on real projects.
|
||||
|
||||
The biggest gaps today:
|
||||
|
||||
- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API, or NVDEC with NVIDIA's driver; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on both (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)).
|
||||
- **Premiere projects.** `.prproj` files can't be opened yet; bring sequences over as FCP7 XML, AAF or OTIO.
|
||||
- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API, or NVDEC with NVIDIA's driver; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but color grading, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on both (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)).
|
||||
- **Native `.prproj` project import.** `.prproj` files can't be opened yet; bring sequences over as FCP7 XML, AAF or OTIO.
|
||||
- **No plugins.** No VST3 / Audio Units or OpenFX hosting.
|
||||
- **Delivery codecs.** H.264 is our only software delivery-codec export; H.265 exports only through a hardware encoder (NVIDIA on Windows: Main 8-bit SDR and Main 10 HDR PQ / HLG; VideoToolbox on macOS: 8-bit Main, SDR); no AV1 export yet.
|
||||
- **Real-world media and platforms.** Our decoders are bit-exact on conformance streams, but camera and phone files in the wild are less tested. Windows and Linux get far less testing than macOS.
|
||||
@@ -304,7 +313,7 @@ A layered Cargo workspace:
|
||||
|
||||
| Layer | Crates |
|
||||
|---|---|
|
||||
| Codecs and containers | `h264`, `h264enc`, `hevc`, `vp9`, `prores`, `aac`, `opus`, `isobmff`, `matroska`, `bitstream` |
|
||||
| Codecs and containers | `h264`, `h264enc`, `hevc`, `vp9`, `prores`, `aac`, `opus`, `isobmff`, `matroska`, `avi`, `bitstream` |
|
||||
| Foundations | `time`, `geom`, `color`, `frame`, `media`, `text`, `project`, `audio-dsp` |
|
||||
| Editing and interchange | `edit`, `codecs`, `captions`, `format` (project files), `interchange` |
|
||||
| Rendering and output | `render`, `gpu`, `export` |
|
||||
@@ -345,6 +354,13 @@ Remove-Item Env:WGPU_BACKEND # restore the default for later
|
||||
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
|
||||
and web backend defaults are unchanged.
|
||||
|
||||
A PC whose GPUs were installed from different driver packages of one vendor (a laptop with an
|
||||
integrated and a discrete AMD GPU, for example) can list one GPU twice, once through each installed
|
||||
version of the Vulkan driver, and a window drawn through the stale one crashes inside the driver
|
||||
(`amdvlk64.dll`, exit code `0xc0000005`). FilmCraft draws with the newer one and says so in
|
||||
`%APPDATA%\FilmCraft\Logs\filmcraft.log`; installing one driver package for every GPU removes the
|
||||
duplicate. With one driver per GPU the adapter is the one wgpu picks, as before.
|
||||
|
||||
### macOS
|
||||
|
||||
| Build | File | Notes |
|
||||
@@ -446,15 +462,13 @@ with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md). Builds
|
||||
|
||||
**Footage and music in the screenshots:** NASA's Apollo 11 film and television footage from [images.nasa.gov](https://images.nasa.gov) (launch, Launch Control Center, lunar surface and recovery) and the Apollo 11 air-to-ground voice transcript, all US Government works in the public domain (NASA does not endorse this project); *Night of the Living Dead* (1968), *Carnival of Souls* (1962) and *Charade* (1963), all in the US public domain; *Earth Views from the ISS* by NASA; Chopin's Nocturne Op. 48 No. 1 and Ballade No. 1, performed for Musopen and released under CC0. The media itself is not in this repository. Sources and details for every asset are in [ATTRIBUTION.md](ATTRIBUTION.md).
|
||||
|
||||
FilmCraft is an independent implementation. It contains no Adobe code, icons, images, presets or LUTs, and no GPL or LGPL code; every icon is drawn in code and every asset is openly licensed and attributed ([AGENTS.md](AGENTS.md)), apart from the ArtCraft name and logos, which are trademarks of the ArtCraft Team used under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). ffmpeg is used only as an external test oracle.
|
||||
FilmCraft is an independent implementation. It contains no proprietary code, icons, images, presets or LUTs, and no GPL or LGPL code; every icon is drawn in code and every asset is openly licensed and attributed ([AGENTS.md](AGENTS.md)), apart from the ArtCraft name and logos, which are trademarks of the ArtCraft Team used under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). ffmpeg is used only as an external test oracle.
|
||||
|
||||
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
|
||||
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
|
||||
part of this repository and FilmCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. FilmCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
|
||||
+10
-5
@@ -5,7 +5,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~19 points of ready-for-real-work and ~400–700 h away, plus opening Premiere's `.prproj` ([why](docs/target-app-parity.md#stage); passes the [core-workflow gate](docs/roadmap.md#alpha-gate))
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (full re-measure against Premiere Pro 2026 26.5.2; scorecard, gaps, milestones and localization moved into docs/ per the craftrules progress-docs standard) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (language table: German covers the whole interface) · **Target:** Adobe Premiere Pro 2026 (26.5.2)
|
||||
|
||||
Progress toward parity with Adobe Premiere Pro. This page is the summary; the detail is in
|
||||
[docs/target-app-parity.md](docs/target-app-parity.md) (assessment, methodology, calibration),
|
||||
@@ -82,11 +82,12 @@ Premiere ships 10 interface languages. Measured from our catalogs (3,498 strings
|
||||
| Hindi | none (no Devanagari shaping in the interface) | 0% |
|
||||
| Arabic | none (no RTL interface) | 0% |
|
||||
| French | partial (whole interface; native review welcome) | ~100% |
|
||||
| Portuguese (Brazil) | menus only | 9.9% |
|
||||
| Indonesian | none | 0% |
|
||||
| Portuguese (Brazil) | partial (whole interface; native review welcome) | ~100% |
|
||||
| Indonesian | partial (whole interface; native review welcome) | ~100% |
|
||||
| Italian | partial (whole interface; native review welcome) | ~100% |
|
||||
| Korean | partial (whole interface; needs an installed Hangul font; native review welcome) | ~100% |
|
||||
| Japanese | partial (whole interface) | 99.7% |
|
||||
| German | none | 0% |
|
||||
| Korean | none | 0% |
|
||||
| German | partial (whole interface; menus native-reviewed, the rest welcome) | ~100% |
|
||||
| Vietnamese | none | 0% |
|
||||
|
||||
Also shipped: Ukrainian (menus only, 10%). Detail: [docs/localization-parity.md](docs/localization-parity.md).
|
||||
@@ -106,6 +107,7 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-12:** Transform's motion-blur shutter on adjustment layers uses the sequence frame rate instead of 24 fps, so a 360° shutter in a 60 fps sequence spans one frame, not 2.5 (#1088)
|
||||
- **2026-10-10 (changed media):** Settings ▸ Media ▸ Automatically refresh growing files works: media files that change on disk while the project is open (a recording still being written, a render written over the file a clip uses) are read again every *Refresh growing files every* seconds and as soon as the window comes back to the front: the new length, picture and identity, without an undo step (`media.refreshChanged`, `media_watch`).
|
||||
- **2026-10-10 (re-measure):** full parity re-measure against Premiere Pro 26.5.2: menu label diff 338 / 366 (92%), installed bundle listings (10 UI languages, exporter and codec frameworks), 188 open issues. Breadth ~86% (file formats now counted), ready ~55%, stage **alpha**; progress docs reorganized per the craftrules standard ([target-app-parity](docs/target-app-parity.md), [gaps](docs/gaps.md), [roadmap](docs/roadmap.md), [codec](docs/codec-parity.md), [file-format](docs/file-format-parity.md), [hardware](docs/hardware-parity.md), [UI](docs/ui-parity.md), [localization](docs/localization-parity.md)).
|
||||
- **2026-10-10:** ~75 community PRs merged: GPU export rendering (Auto) with Vignette, Video Limiter and Lumetri basic / creative / vignette on the GPU (#421); Linux NVDEC H.264 / HEVC decode (#549); text-to-speech narrations with Kokoro-82M (#278); Japanese for the whole interface (#449) and Ukrainian menus (#407); ALSA default-device fallback (#254, fixes #23); drop import into the timeline (#517); system appearance (#505); timecode scrub editing (#576); NVENC RGBA input (#486); marker CSV export (#454); many fixes (#526–#575).
|
||||
@@ -164,6 +166,9 @@ Ranked; detail and "done when" in [docs/roadmap.md](docs/roadmap.md) and [docs/g
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-13 | minor | Language table: German covers the whole interface (~100%) |
|
||||
| 2026-10-12 | trivial | Progress log: Transform shutter on adjustment layers (#1088) |
|
||||
| 2026-10-11 | minor | Language table: German ships the menus (#630, 10.6%) |
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% per audience |
|
||||
| 2026-10-10 | minor | Headline adds mainstream practitioner (~55%) and essentials user (~60%); ready for real work ~55% -> ~56% (formula written out, same inputs); beta distance ~19 points |
|
||||
| 2026-10-10 | minor | Stage banner links the core-workflow gate (all six workflows pass on macOS; stays alpha) |
|
||||
|
||||
@@ -33,3 +33,6 @@ filmcraft-ogg = { workspace = true }
|
||||
filmcraft-automation = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||
|
||||
[dev-dependencies]
|
||||
filmcraft-testkit = { workspace = true }
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
// Keep nested CLI params safely within the depth also accepted by JSON serialization.
|
||||
const MAX_PARAM_NESTING: usize = 64;
|
||||
|
||||
/// Options that take a value (`--project p`); every other `--x` is a flag.
|
||||
const VALUED: &[&str] = &[
|
||||
"--project",
|
||||
@@ -84,6 +87,9 @@ impl Args {
|
||||
let mut m = Map::new();
|
||||
for kv in rest {
|
||||
let (k, v) = kv.split_once('=').ok_or_else(|| format!("expected key=value, got `{kv}`"))?;
|
||||
if k.bytes().filter(|&b| b == b'.').count() >= MAX_PARAM_NESTING {
|
||||
return Err(format!("parameter key exceeds {MAX_PARAM_NESTING} nesting levels"));
|
||||
}
|
||||
insert_dotted(&mut m, k, parse_value(v));
|
||||
}
|
||||
Ok(Value::Object(m))
|
||||
@@ -141,6 +147,17 @@ mod tests {
|
||||
assert_eq!(args("exec x").params_from(2).unwrap(), json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotted_key_depth_is_bounded() {
|
||||
let allowed = format!("{}leaf=1", "a.".repeat(MAX_PARAM_NESTING - 1));
|
||||
let a = Args::parse(["exec".into(), "x".into(), allowed]);
|
||||
assert!(a.params_from(2).is_ok());
|
||||
|
||||
let too_deep = format!("{}leaf=1", "a.".repeat(MAX_PARAM_NESTING));
|
||||
let b = Args::parse(["exec".into(), "x".into(), too_deep]);
|
||||
assert!(b.params_from(2).unwrap_err().contains("nesting levels"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_params() {
|
||||
let a = Args::parse(["exec".into(), "x".into(), r#"{"a": {"b": [1]}}"#.into()]);
|
||||
|
||||
+151
-22
@@ -69,14 +69,16 @@ USAGE
|
||||
SUBCOMMANDS
|
||||
exec <id> [key=value ...] run one command; prints its result as JSON
|
||||
exec <id> '<json params>' same, params as one JSON object
|
||||
run <script.jsonl | -> run commands, one {\"id\",\"params\"} per line (# comments ok)
|
||||
run <script.jsonl | -> run commands, one {\"id\",\"params\"} per line (# comments ok), in
|
||||
order: file.exportMedia waits for its file before the next line
|
||||
(and before exit) unless its params say \"wait\": false
|
||||
commands [filter] [--json] list commands (id, label, shortcut, params)
|
||||
describe <id> one command as JSON (menu, shortcut, params, enabled now)
|
||||
inspect [project|sequence] project tree or active sequence as JSON (default: both)
|
||||
import <file>... import media into the project
|
||||
export <out> [--preset name] [--format f] [--range r] [--start s --end s] [--settings json]
|
||||
[--gpu-rendering auto|off]
|
||||
[--scale f] [--quality 0-100] [--no-audio] [--queue]
|
||||
[--scale f] [--quality 0-100] [--no-audio] [--queue] [--allow-offline]
|
||||
export the active sequence and wait for it to finish: with an
|
||||
export preset (`export --list-presets`; built-in or the user's), or
|
||||
a format (h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff|aac, guessed
|
||||
@@ -85,9 +87,12 @@ SUBCOMMANDS
|
||||
preset; --scale renders at a fraction of the frame size (0.5 =
|
||||
half); --quality 0-100 for the formats that take one; --no-audio
|
||||
leaves the sound out; --queue adds to the export queue and runs it
|
||||
instead
|
||||
instead. Offline media (missing, made offline, unreadable) renders
|
||||
as placeholder frames: the export then lists it in `offlineMedia`,
|
||||
warns on stderr and exits 1, unless --allow-offline accepts it
|
||||
export --list-presets [query] list export presets (name, category, format) as JSON
|
||||
render --seconds S --out f.png [--scale 0.5] render one Program frame to PNG
|
||||
render --seconds S --out f.png [--scale 0.5] [--allow-offline]
|
||||
render one Program frame to PNG (offline media: as for export)
|
||||
probe <media> [--image-sequence]
|
||||
media info as JSON, with MXF / Ogg / BWF details; with
|
||||
--image-sequence <media> is the first numbered still of a sequence
|
||||
@@ -113,7 +118,8 @@ VALUES
|
||||
Times: 254016000000 ticks per second; most commands also take seconds=, frame= or timecode=.
|
||||
|
||||
EXIT STATUS
|
||||
0 success · 1 a command failed · 2 usage error
|
||||
0 success · 1 a command failed, or export/render drew offline media without --allow-offline
|
||||
· 2 usage error
|
||||
";
|
||||
|
||||
/// Writes one diagnostic line to `w`, dropping any write error. `eprintln!` panics (exit 101) when stderr's
|
||||
@@ -131,6 +137,25 @@ fn usage(msg: impl std::fmt::Display) -> ! {
|
||||
std::process::exit(2)
|
||||
}
|
||||
|
||||
/// Read a file off the runtime thread: `cli` is async because MCP, bridge calls and control
|
||||
/// commands await, so a blocking `std::fs` read would stall every other task on that thread.
|
||||
async fn read_file(path: impl Into<std::path::PathBuf>) -> std::io::Result<Vec<u8>> {
|
||||
let path = path.into();
|
||||
tokio::task::spawn_blocking(move || std::fs::read(path)).await.map_err(std::io::Error::other)?
|
||||
}
|
||||
|
||||
/// `read_file` for UTF-8 text.
|
||||
async fn read_text(path: impl Into<std::path::PathBuf>) -> std::io::Result<String> {
|
||||
let path = path.into();
|
||||
tokio::task::spawn_blocking(move || std::fs::read_to_string(path)).await.map_err(std::io::Error::other)?
|
||||
}
|
||||
|
||||
/// Write a file off the runtime thread; see `read_file`.
|
||||
async fn write_file(path: impl Into<std::path::PathBuf>, data: Vec<u8>) -> std::io::Result<()> {
|
||||
let path = path.into();
|
||||
tokio::task::spawn_blocking(move || std::fs::write(path, data)).await.map_err(std::io::Error::other)?
|
||||
}
|
||||
|
||||
fn fail(msg: impl std::fmt::Display) -> ! {
|
||||
diag(format_args!("filmcraft-cli: {msg}"));
|
||||
std::process::exit(1)
|
||||
@@ -165,9 +190,21 @@ impl Backend {
|
||||
Backend::Local(Box::new(s))
|
||||
}
|
||||
|
||||
async fn exec(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
async fn exec(&mut self, id: &str, mut params: Value) -> Result<Value, String> {
|
||||
match self {
|
||||
Backend::Local(s) => s.execute(id, params).map_err(|e| e.to_string()),
|
||||
// this process exits after the command, so a background export would never be written:
|
||||
// long commands wait unless the caller says otherwise (#2342)
|
||||
Backend::Local(s) => {
|
||||
if filmcraft_automation::long_job::LONG_COMMANDS.contains(&id)
|
||||
&& let Some(p) = params.as_object_mut()
|
||||
{
|
||||
// anything but an explicit `false` (missing, null, a typo'd value) waits
|
||||
if p.get("wait").and_then(Value::as_bool) != Some(false) {
|
||||
p.insert("wait".into(), json!(true));
|
||||
}
|
||||
}
|
||||
s.execute(id, params).map_err(|e| e.to_string())
|
||||
}
|
||||
// a blocking export runs as an app job polled by the client (#91, #92)
|
||||
Backend::Bridge(b) => b.execute(id, params).await.map_err(|e| e.to_string()),
|
||||
}
|
||||
@@ -244,6 +281,13 @@ fn register_hardware_decoders() -> filmcraft_platform::Availability {
|
||||
filmcraft_platform::register()
|
||||
}
|
||||
|
||||
/// `path` made absolute against the working directory, as the project stores it. Unlike
|
||||
/// `canonicalize`, `std::path::absolute` keeps symlinks and adds no Windows verbatim `\\?\`
|
||||
/// prefix (#850). A path it cannot resolve is kept as given.
|
||||
fn absolute(path: &str) -> String {
|
||||
std::path::absolute(path).ok().and_then(|p| p.into_os_string().into_string().ok()).unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
cli().await;
|
||||
@@ -253,7 +297,8 @@ async fn main() {
|
||||
}
|
||||
|
||||
async fn cli() {
|
||||
let a = Args::parse(std::env::args().skip(1));
|
||||
// `std::env::args` panics on an argument that is not Unicode (a Linux file name in Latin-1)
|
||||
let a = Args::parse(std::env::args_os().skip(1).map(|a| a.to_string_lossy().into_owned()));
|
||||
// `--help` anywhere (`filmcraft-cli --help`, `filmcraft-cli export --help`) prints the reference:
|
||||
// the parser takes it as a flag option, so it never reaches the subcommand match.
|
||||
if a.flag("--help") {
|
||||
@@ -281,7 +326,7 @@ async fn cli() {
|
||||
"bench-decode" => {
|
||||
let path = a.pos(1).unwrap_or_else(|| usage("bench-decode <media>"));
|
||||
let n: i64 = a.opt("--frames").and_then(|v| v.parse().ok()).unwrap_or(120);
|
||||
let src = filmcraft_codecs::open_bytes(path, std::fs::read(path).unwrap_or_else(|e| fail(e)).into()).unwrap_or_else(|e| fail(e));
|
||||
let src = filmcraft_codecs::open_bytes(path, read_file(path).await.unwrap_or_else(|e| fail(e)).into()).unwrap_or_else(|e| fail(e));
|
||||
let rate = src.info().frame_rate();
|
||||
let t0 = std::time::Instant::now();
|
||||
for f in 0..n {
|
||||
@@ -337,7 +382,7 @@ async fn cli() {
|
||||
let text = if script == "-" {
|
||||
std::io::read_to_string(std::io::stdin()).unwrap_or_else(|e| fail(e))
|
||||
} else {
|
||||
std::fs::read_to_string(script).unwrap_or_else(|e| fail(format!("{script}: {e}")))
|
||||
read_text(script).await.unwrap_or_else(|e| fail(format!("{script}: {e}")))
|
||||
};
|
||||
let mut b = Backend::open(&a);
|
||||
let mut failed = 0;
|
||||
@@ -373,8 +418,7 @@ async fn cli() {
|
||||
print(&a, &v);
|
||||
}
|
||||
"import" => {
|
||||
let paths: Vec<String> =
|
||||
a.positionals[1..].iter().map(|p| std::fs::canonicalize(p).map(|c| c.to_string_lossy().into_owned()).unwrap_or_else(|_| p.clone())).collect();
|
||||
let paths: Vec<String> = a.positionals.get(1..).unwrap_or_default().iter().map(|p| absolute(p)).collect();
|
||||
if paths.is_empty() {
|
||||
usage("import <file>...");
|
||||
}
|
||||
@@ -442,7 +486,9 @@ async fn cli() {
|
||||
match r {
|
||||
Ok(v) => {
|
||||
print(&a, &v);
|
||||
diag(format_args!("exported {} in {:.1}s", written_paths(&v, out).join(", "), t0.elapsed().as_secs_f64()));
|
||||
let written = written_paths(&v, out).join(", ");
|
||||
check_offline(&a, &offline_media(&v), &format!("export: {written}"));
|
||||
diag(format_args!("exported {written} in {:.1}s", t0.elapsed().as_secs_f64()));
|
||||
}
|
||||
Err(e) => fail(format!("export: {e}")),
|
||||
}
|
||||
@@ -452,15 +498,17 @@ async fn cli() {
|
||||
usage("render is headless; with --bridge use the MCP `render_frame` tool or `exec` ui commands");
|
||||
}
|
||||
let Backend::Local(mut s) = Backend::open(&a) else { usage("render is headless") };
|
||||
let secs: f64 = a.opt("--seconds").and_then(|v| v.parse().ok()).unwrap_or(0.0);
|
||||
let scale: f32 = a.opt("--scale").and_then(|v| v.parse().ok()).unwrap_or(1.0);
|
||||
let secs: f64 = finite_opt(&a, "--seconds").unwrap_or(0.0);
|
||||
let scale: f32 = finite_opt(&a, "--scale").unwrap_or(1.0);
|
||||
let out = a.opt("--out").unwrap_or("frame.png");
|
||||
s.set_playhead(filmcraft_time::Tick::from_seconds_f64(secs));
|
||||
let t0 = std::time::Instant::now();
|
||||
let img = render_at_playhead(&s, scale).unwrap_or_else(|e| fail(e));
|
||||
let (img, offline) = render_at_playhead(&s, scale).unwrap_or_else(|e| fail(e));
|
||||
let dt = t0.elapsed();
|
||||
let png = filmcraft_automation::png_rgba(img.w as u32, img.h as u32, img.over_black_rgba8(), 0).unwrap_or_else(|e| fail(e));
|
||||
std::fs::write(out, png).unwrap_or_else(|e| fail(format!("{out}: {e}")));
|
||||
write_file(out, png).await.unwrap_or_else(|e| fail(format!("{out}: {e}")));
|
||||
let offline: Vec<Value> = offline.iter().filter_map(|o| serde_json::to_value(o).ok()).collect();
|
||||
check_offline(&a, &offline, &format!("render: {out}"));
|
||||
diag(format_args!("rendered {}x{} in {:.1} ms → {out}", img.w, img.h, dt.as_secs_f64() * 1000.0));
|
||||
}
|
||||
"mcp" => {
|
||||
@@ -468,7 +516,7 @@ async fn cli() {
|
||||
usage(format!("unknown mcp option `{opt}`"));
|
||||
}
|
||||
let server = match a.opt("--bridge") {
|
||||
Some(addr) => filmcraft_automation::FilmcraftMcp::bridge(addr).unwrap_or_else(|e| fail(e)),
|
||||
Some(addr) => filmcraft_automation::FilmcraftMcp::bridge(addr).unwrap_or_else(|e| usage(e)),
|
||||
None => match Backend::open(&a) {
|
||||
Backend::Local(s) => filmcraft_automation::FilmcraftMcp::headless(*s),
|
||||
Backend::Bridge(_) => usage("mcp: use --bridge ADDR to drive a running app"),
|
||||
@@ -500,9 +548,56 @@ fn written_paths(result: &Value, requested: &str) -> Vec<String> {
|
||||
if paths.is_empty() { vec![requested.to_string()] } else { paths }
|
||||
}
|
||||
|
||||
/// The active sequence at the playhead, or the reason it cannot be rendered (no sequence, bad scale).
|
||||
fn render_at_playhead(s: &Session, scale: f32) -> Result<filmcraft_engine::render::Image, String> {
|
||||
s.try_render_program_at(scale, s.playhead()).map_err(|e| e.to_string())
|
||||
/// The offline items an export result reports (`offlineMedia` of a direct export, or of each
|
||||
/// queued item), once each.
|
||||
fn offline_media(result: &Value) -> Vec<Value> {
|
||||
let lists = std::iter::once(result).chain(result.get("items").and_then(Value::as_array).into_iter().flatten());
|
||||
let mut out: Vec<Value> = Vec::new();
|
||||
for o in lists.filter_map(|r| r.get("offlineMedia").and_then(Value::as_array)).flatten() {
|
||||
if !out.iter().any(|x| x.get("item") == o.get("item")) {
|
||||
out.push(o.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One stderr line per offline item that rendered as placeholder frames (#1054).
|
||||
fn offline_warning(offline: &[Value]) -> String {
|
||||
let s = |o: &Value, k: &str| o.get(k).and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let mut msg = format!("filmcraft-cli: warning: {} offline media item(s) rendered as placeholder frames:", offline.len());
|
||||
for o in offline {
|
||||
let id = o.get("item").and_then(Value::as_u64).map(|i| i.to_string()).unwrap_or_default();
|
||||
msg.push_str(&format!("\n item {id} {} ({}): {}", s(o, "name"), s(o, "status"), s(o, "path")));
|
||||
}
|
||||
msg
|
||||
}
|
||||
|
||||
/// Warn about offline media on stderr; without `--allow-offline` that fails the run (exit 1), after
|
||||
/// the output (`what`) was written, so scripts don't take placeholder frames for the real picture.
|
||||
fn check_offline(a: &Args, offline: &[Value], what: &str) {
|
||||
if offline.is_empty() {
|
||||
return;
|
||||
}
|
||||
diag(format_args!("{}", offline_warning(offline)));
|
||||
if !a.flag("--allow-offline") {
|
||||
fail(format!("{what} has placeholder frames for offline media; relink it (exec media.relink …) or pass --allow-offline"));
|
||||
}
|
||||
}
|
||||
|
||||
/// A numeric option, or a usage error when it is not a finite number (so a typo never silently
|
||||
/// falls back to a default).
|
||||
fn finite_opt<T: std::str::FromStr + Copy + Into<f64>>(a: &Args, name: &str) -> Option<T> {
|
||||
let v = a.opt(name)?;
|
||||
match v.parse::<T>() {
|
||||
Ok(n) if n.into().is_finite() => Some(n),
|
||||
_ => usage(format!("{name}: expected a finite number, got `{v}`")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The active sequence at the playhead with the offline items it drew, or the reason it cannot be
|
||||
/// rendered (no sequence, bad scale).
|
||||
fn render_at_playhead(s: &Session, scale: f32) -> Result<(filmcraft_engine::render::Image, Vec<filmcraft_engine::media_pool::OfflineMedia>), String> {
|
||||
s.try_render_program_with_offline_at(scale, s.playhead()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `--start` / `--end` ask for a custom range. A lone bound is forwarded too, so the engine reports the missing one
|
||||
@@ -531,7 +626,7 @@ mod format_tests {
|
||||
let e = super::render_at_playhead(&s, scale).unwrap_err();
|
||||
assert!(e.contains("finite and positive"), "{e}");
|
||||
}
|
||||
assert_eq!(super::render_at_playhead(&s, 0.5).unwrap().w, 8);
|
||||
assert_eq!(super::render_at_playhead(&s, 0.5).unwrap().0.w, 8);
|
||||
}
|
||||
|
||||
/// The CLI (and MCP / headless runs, which share its entry point) registers the hardware
|
||||
@@ -573,6 +668,19 @@ mod format_tests {
|
||||
assert_eq!(super::written_paths(&json!({"items": []}), "x.mov"), ["x.mov"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_media_from_direct_and_queued_results() {
|
||||
use serde_json::json;
|
||||
let a = json!({"item": 8, "name": "a.mp4", "path": "/m/a.mp4", "status": "missing", "error": ""});
|
||||
let b = json!({"item": 9, "name": "b.mov", "path": "/m/b.mov", "status": "offline", "error": ""});
|
||||
assert_eq!(super::offline_media(&json!({"path": "x.mp4", "offlineMedia": [a]})), std::slice::from_ref(&a));
|
||||
let queued = json!({"items": [{"offlineMedia": [a, b]}, {"offlineMedia": [a]}, {"offlineMedia": []}]});
|
||||
assert_eq!(super::offline_media(&queued), [a.clone(), b.clone()]);
|
||||
assert!(super::offline_media(&json!({"path": "x.mp4"})).is_empty());
|
||||
let w = super::offline_warning(&[a, b]);
|
||||
assert!(w.contains("2 offline media item(s)") && w.contains("item 8 a.mp4 (missing): /m/a.mp4") && w.contains("item 9 b.mov (offline)"), "{w}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_lone_bound_is_forwarded_as_custom_range() {
|
||||
use serde_json::json;
|
||||
@@ -590,6 +698,27 @@ mod format_tests {
|
||||
assert!(p.get("range").is_none());
|
||||
}
|
||||
|
||||
/// `import` used `canonicalize`, which resolves symlinks and on Windows stores `\\?\C:\…`
|
||||
/// verbatim paths in the project (#850).
|
||||
#[test]
|
||||
fn imported_paths_are_absolute_without_resolving_links() {
|
||||
let cwd = std::env::current_dir().unwrap();
|
||||
assert_eq!(super::absolute("clip.mov"), cwd.join("clip.mov").to_string_lossy());
|
||||
assert!(!super::absolute("clip.mov").starts_with(r"\\?\"));
|
||||
assert_eq!(super::absolute(""), "");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let dir = std::env::temp_dir().join(format!("filmcraft-cli-abs-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("real.mov"), b"x").unwrap();
|
||||
let link = dir.join("link.mov");
|
||||
let _ = std::fs::remove_file(&link);
|
||||
std::os::unix::fs::symlink(dir.join("real.mov"), &link).unwrap();
|
||||
assert_eq!(super::absolute(&link.to_string_lossy()), link.to_string_lossy());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_format_from_extension() {
|
||||
assert_eq!(super::format_for("out/clip.MXF"), Some("mxf-op1a"));
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Output, Stdio};
|
||||
|
||||
use serde_json::Value;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn cli(args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(args).output().expect("spawn filmcraft-cli")
|
||||
@@ -22,6 +22,9 @@ fn help_and_usage_errors() {
|
||||
assert_eq!(cli(&[]).status.code(), Some(2));
|
||||
assert_eq!(cli(&["frobnicate"]).status.code(), Some(2));
|
||||
assert_eq!(cli(&["exec", "file.newBin", "notkv"]).status.code(), Some(2));
|
||||
// Invalid --bridge is a usage error (exit 2) for both exec and mcp (#921).
|
||||
assert_eq!(cli(&["--bridge", "0.0.0.0:9876", "exec", "inspect.project"]).status.code(), Some(2));
|
||||
assert_eq!(cli(&["mcp", "--bridge", "0.0.0.0:9876"]).status.code(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -147,6 +150,23 @@ fn export_wav_waits_for_job() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_waits_for_an_export_on_its_last_line() {
|
||||
// #2342: `file.exportMedia` as the last line of a script is written before `run` exits
|
||||
let dir = std::env::temp_dir().join(format!("fc-cli-run-x-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let out = dir.join("last.wav");
|
||||
// `"wait": null` waits too: only an explicit `false` doesn't
|
||||
let script = json!({"id": "file.exportMedia", "params": {"path": out.to_str().unwrap(), "format": "wav", "wait": null}}).to_string();
|
||||
let mut child =
|
||||
Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(["--demo", "run", "-"]).stdin(Stdio::piped()).stdout(Stdio::piped()).spawn().unwrap();
|
||||
child.stdin.take().unwrap().write_all(format!("{script}\n").as_bytes()).unwrap();
|
||||
let o = child.wait_with_output().unwrap();
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stdout));
|
||||
assert!(std::fs::metadata(&out).map(|m| m.len() > 44).unwrap_or(false), "{}", String::from_utf8_lossy(&o.stdout));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `export --preset`: a built-in preset by name (dash or en dash), a custom range, a user preset
|
||||
/// from `--data-dir`, `--list-presets`, `--queue`, and a clean failure for an unknown preset.
|
||||
#[test]
|
||||
@@ -291,3 +311,76 @@ fn closed_stdout_still_runs_the_script_and_saves() {
|
||||
assert_eq!(out.status.code(), Some(1));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `render` with a non-numeric or non-finite `--seconds` / `--scale` is a usage error (exit 2) and
|
||||
/// writes nothing, instead of silently rendering frame 0 at full scale.
|
||||
#[test]
|
||||
fn render_rejects_bad_numbers() {
|
||||
let dir = std::env::temp_dir().join(format!("fc-cli-render-nan-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let still = dir.join("s.png").to_str().unwrap().to_string();
|
||||
for (opt, bad) in
|
||||
[("--seconds", "1s"), ("--seconds", "1,5"), ("--seconds", "abc"), ("--seconds", "NaN"), ("--seconds", "inf"), ("--scale", "xyz"), ("--scale", "NaN")]
|
||||
{
|
||||
let (secs, scale) = if opt == "--seconds" { (bad, "0.1") } else { ("1", bad) };
|
||||
let o = cli(&["--demo", "render", "--seconds", secs, "--scale", scale, "--out", &still]);
|
||||
let err = String::from_utf8_lossy(&o.stderr);
|
||||
assert_eq!(o.status.code(), Some(2), "{opt} {bad}: {err}");
|
||||
assert!(err.contains("filmcraft-cli:") && err.contains(opt), "{opt} {bad}: {err}");
|
||||
assert!(!std::path::Path::new(&still).exists(), "{opt} {bad} wrote a file");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #1054: exporting or rendering a project whose media is missing fails (exit 1) and names the
|
||||
/// offline items on stderr and in the result's `offlineMedia`, unless `--allow-offline` accepts the
|
||||
/// placeholder frames; then it still warns.
|
||||
#[test]
|
||||
fn export_with_missing_media_fails_unless_allowed() {
|
||||
let dir = std::env::temp_dir().join(format!("fc-cli-1054-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let f = |n: &str| dir.join(n).to_str().unwrap().to_string();
|
||||
let (still, proj) = (f("still.png"), f("p.fcproj"));
|
||||
let o = cli(&["--demo", "render", "--seconds", "1", "--scale", "0.1", "--out", &still]);
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
json_out(&cli(&["exec", "file.newSequence", "name=s", "width=64", "height=36", "fps=24", "--save-as", &proj]));
|
||||
let paths = format!("paths=[\"{}\"]", still.replace('\\', "\\\\"));
|
||||
let item = json_out(&cli(&["--project", &proj, "--save", "exec", "file.import", &paths]))["items"][0].as_u64().unwrap();
|
||||
json_out(&cli(&["--project", &proj, "--save", "exec", "timeline.place", &format!("item={item}"), "track=V1", "frame=0", "audio=false"]));
|
||||
let export = |out: &str, extra: &[&str]| {
|
||||
let mut args = vec!["--project", proj.as_str(), "--compact", "export", out, "--format", "png", "--start", "0", "--end", "0.05"];
|
||||
args.extend_from_slice(extra);
|
||||
cli(&args)
|
||||
};
|
||||
|
||||
let on = export(&f("on.png"), &[]);
|
||||
let v = json_out(&on);
|
||||
assert_eq!(v["offlineMedia"], serde_json::json!([]), "{v}");
|
||||
assert!(!String::from_utf8_lossy(&on.stderr).contains("warning"), "{}", String::from_utf8_lossy(&on.stderr));
|
||||
|
||||
std::fs::remove_file(&still).unwrap();
|
||||
let off = export(&f("off.png"), &[]);
|
||||
let err = String::from_utf8_lossy(&off.stderr);
|
||||
assert_eq!(off.status.code(), Some(1), "{err}");
|
||||
assert!(err.contains("still.png") && err.contains("missing") && err.contains("--allow-offline"), "{err}");
|
||||
let v: Value = serde_json::from_slice(&off.stdout).expect("the result JSON is still printed");
|
||||
assert_eq!(v["offlineMedia"][0]["item"], item, "{v}");
|
||||
assert_eq!(v["offlineMedia"][0]["status"], "missing", "{v}");
|
||||
|
||||
let allowed = export(&f("allowed.png"), &["--allow-offline"]);
|
||||
let err = String::from_utf8_lossy(&allowed.stderr);
|
||||
assert!(allowed.status.success(), "{err}");
|
||||
assert!(err.contains("still.png") && err.contains("missing"), "still warns: {err}");
|
||||
assert_eq!(json_out(&allowed)["offlineMedia"][0]["status"], "missing");
|
||||
|
||||
let render = cli(&["--project", &proj, "render", "--seconds", "0", "--out", &f("r.png")]);
|
||||
let err = String::from_utf8_lossy(&render.stderr);
|
||||
assert_eq!(render.status.code(), Some(1), "{err}");
|
||||
assert!(err.contains("still.png") && err.contains("--allow-offline"), "{err}");
|
||||
let render = cli(&["--project", &proj, "render", "--seconds", "0", "--out", &f("r.png"), "--allow-offline"]);
|
||||
assert!(render.status.success(), "{}", String::from_utf8_lossy(&render.stderr));
|
||||
assert!(String::from_utf8_lossy(&render.stderr).contains("still.png"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
mod cli;
|
||||
mod workflows;
|
||||
@@ -0,0 +1,488 @@
|
||||
//! Workflow acceptance tests (docs/gaps.md G6): real editing jobs done end to end the way an agent
|
||||
//! does them, through the headless MCP server of the `filmcraft-cli` binary (`filmcraft-cli mcp`,
|
||||
//! JSON-RPC over stdio) and the CLI itself, on media that looks like what people shoot:
|
||||
//!
|
||||
//! - a phone clip: H.264 + AAC in MP4 with a variable frame rate (dropped frames);
|
||||
//! - a camera clip: long-GOP H.264 (one keyframe) in Matroska with two audio tracks (AAC, AC-3).
|
||||
//!
|
||||
//! Each test imports, edits (extract, audio gain, title; ripple trims, colour matte, cross
|
||||
//! dissolve), exports (H.264 MP4; save the project, reopen it in a new process and export ProRes)
|
||||
//! and checks the file with the ffprobe / ffmpeg oracles: streams, frame count, duration, which
|
||||
//! source frame lands where, A/V sync, the title's pixels, the dissolve's mix, audio levels.
|
||||
//!
|
||||
//! The media is generated with ffmpeg into `target/fixtures/cli/workflows` at test time (never
|
||||
//! committed); without ffmpeg the tests skip (`FILMCRAFT_REQUIRE_ORACLES=1` makes that a failure).
|
||||
//! ffmpeg and ffprobe are external test oracles only.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
|
||||
/// A phone-like clip: 4 s of 320×240 `testsrc2` at 30 fps with every frame `n % 7 == 3` dropped
|
||||
/// (variable frame rate, the timestamps of the kept frames unchanged), H.264 + AAC stereo. The
|
||||
/// whole picture flashes white on the frames at 1.0 s and 3.0 s, and a 1 kHz beep sounds from 1.0
|
||||
/// to 1.1 s and from 3.0 to 3.1 s (silence otherwise): A/V sync markers.
|
||||
fn phone_vfr_mp4(ffmpeg: &Path) -> Option<PathBuf> {
|
||||
let out = filmcraft_testkit::fixtures_dir("cli/workflows").join("phone_vfr_h264_aac.mp4");
|
||||
filmcraft_testkit::fixtures::generate(&out, |tmp| {
|
||||
Command::new(ffmpeg)
|
||||
.args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=30:duration=4"])
|
||||
.args(["-f", "lavfi", "-i", "sine=frequency=1000:sample_rate=48000:duration=4"])
|
||||
.args([
|
||||
"-filter_complex",
|
||||
"[0:v]drawbox=x=0:y=0:w=iw:h=ih:color=white:t=fill:enable='between(t,1,1.02)+between(t,3,3.02)',\
|
||||
select='not(eq(mod(n,7),3))'[v];\
|
||||
[1:a]volume='if(between(t,1,1.1)+between(t,3,3.1),1,0)':eval=frame[a]",
|
||||
])
|
||||
.args(["-map", "[v]", "-map", "[a]", "-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p", "-fps_mode", "passthrough"])
|
||||
.args(["-video_track_timescale", "30000", "-c:a", "aac", "-b:a", "128k", "-ac", "2"])
|
||||
.arg(tmp)
|
||||
.status()
|
||||
.is_ok_and(|s| s.success())
|
||||
})
|
||||
}
|
||||
|
||||
/// A camera-like clip: 6 s of 320×240 `testsrc2` at 25 fps as long-GOP H.264 (a single keyframe,
|
||||
/// B-frames) in Matroska, with two stereo audio tracks: 440 Hz in AAC and 1320 Hz in AC-3.
|
||||
fn camera_long_gop_mkv(ffmpeg: &Path) -> Option<PathBuf> {
|
||||
let out = filmcraft_testkit::fixtures_dir("cli/workflows").join("camera_long_gop_2audio.mkv");
|
||||
filmcraft_testkit::fixtures::generate(&out, |tmp| {
|
||||
Command::new(ffmpeg)
|
||||
.args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=25:duration=6"])
|
||||
.args(["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=6"])
|
||||
.args(["-f", "lavfi", "-i", "sine=frequency=1320:sample_rate=48000:duration=6"])
|
||||
.args(["-map", "0", "-map", "1", "-map", "2", "-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p"])
|
||||
.args(["-g", "300", "-keyint_min", "300", "-sc_threshold", "0", "-bf", "3"])
|
||||
.args(["-c:a:0", "aac", "-ac:a:0", "2", "-c:a:1", "ac3", "-ac:a:1", "2"])
|
||||
.arg(tmp)
|
||||
.status()
|
||||
.is_ok_and(|s| s.success())
|
||||
})
|
||||
}
|
||||
|
||||
/// A scratch directory under the workspace `target/`, removed when dropped.
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new(name: &str) -> Self {
|
||||
let d = filmcraft_testkit::workspace_root().join("target").join("workflow-tests").join(format!("{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
Scratch(d)
|
||||
}
|
||||
fn path(&self, name: &str) -> String {
|
||||
self.0.join(name).to_string_lossy().into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// A minimal MCP client for `filmcraft-cli mcp` (newline-delimited JSON-RPC over stdio)
|
||||
|
||||
struct Mcp {
|
||||
child: Child,
|
||||
stdin: ChildStdin,
|
||||
stdout: BufReader<ChildStdout>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl Mcp {
|
||||
/// Start the headless server (an empty project) and initialise the session. `data_dir` keeps
|
||||
/// user presets away from the real data directory.
|
||||
fn start(data_dir: &str) -> Self {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli"))
|
||||
.args(["--data-dir", data_dir, "mcp"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn filmcraft-cli mcp");
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
let mut m = Mcp { child, stdin, stdout, next_id: 1 };
|
||||
let init =
|
||||
m.request("initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "workflow-tests", "version": "1"}}));
|
||||
assert_eq!(init["result"]["serverInfo"]["name"], "filmcraft", "{init}");
|
||||
m.send(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"}));
|
||||
m
|
||||
}
|
||||
|
||||
fn send(&mut self, msg: &Value) {
|
||||
writeln!(self.stdin, "{msg}").unwrap();
|
||||
self.stdin.flush().unwrap();
|
||||
}
|
||||
|
||||
/// Send a request and return its response (notifications in between are skipped).
|
||||
fn request(&mut self, method: &str, params: Value) -> Value {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
self.send(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}));
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
assert!(self.stdout.read_line(&mut line).unwrap() > 0, "the MCP server closed stdout while `{method}` was pending");
|
||||
let v: Value = serde_json::from_str(&line).unwrap_or_else(|e| panic!("not JSON-RPC: {e}: {line}"));
|
||||
if v["id"] == id {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Call a tool; its text result parsed as JSON. Panics on a protocol or tool error.
|
||||
fn tool(&mut self, name: &str, arguments: Value) -> Value {
|
||||
let r = self.request("tools/call", json!({"name": name, "arguments": arguments}));
|
||||
let text = r["result"]["content"][0]["text"].as_str().unwrap_or_default().to_string();
|
||||
assert!(r["error"].is_null() && r["result"]["isError"] == false, "{name} {arguments}: {r}");
|
||||
serde_json::from_str(&text).unwrap_or(Value::String(text))
|
||||
}
|
||||
|
||||
/// `command_run`: one engine command by id.
|
||||
fn run(&mut self, id: &str, params: Value) -> Value {
|
||||
self.tool("command_run", json!({"id": id, "params": params}))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Mcp {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Oracles
|
||||
|
||||
fn probe(ffprobe: &Path, path: &str) -> Value {
|
||||
let out = Command::new(ffprobe).args(["-v", "error", "-of", "json", "-count_frames", "-show_format", "-show_streams", path]).output().unwrap();
|
||||
assert!(out.status.success(), "ffprobe {path}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
serde_json::from_slice(&out.stdout).unwrap()
|
||||
}
|
||||
|
||||
fn streams<'a>(j: &'a Value, kind: &str) -> Vec<&'a Value> {
|
||||
j["streams"].as_array().unwrap().iter().filter(|s| s["codec_type"] == kind).collect()
|
||||
}
|
||||
|
||||
fn num(v: &Value) -> f64 {
|
||||
v.as_str().and_then(|s| s.parse().ok()).or_else(|| v.as_f64()).unwrap_or(f64::NAN)
|
||||
}
|
||||
|
||||
/// Run ffmpeg decoding `input` (with `pre` input options) to raw data on stdout.
|
||||
fn ffmpeg_raw(ffmpeg: &Path, pre: &[&str], input: &str, args: &[&str]) -> Vec<u8> {
|
||||
let out = Command::new(ffmpeg).args(["-v", "error"]).args(pre).args(["-i", input]).args(args).arg("-").output().unwrap();
|
||||
assert!(out.status.success(), "ffmpeg {input}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
out.stdout
|
||||
}
|
||||
|
||||
/// Size of the frames [`frames`] returns.
|
||||
const FW: usize = 80;
|
||||
const FH: usize = 60;
|
||||
|
||||
/// Every video frame of `input`, scaled to 80×60 in `pix_fmt` (`gray` or `rgb24`). `vf` goes
|
||||
/// first in the filter chain (e.g. `fps=30` to see a VFR file as an editor shows it).
|
||||
fn frames(ffmpeg: &Path, input: &str, vf: Option<&str>, pix_fmt: &str) -> Vec<Vec<u8>> {
|
||||
let chain = match vf {
|
||||
Some(f) => format!("{f},scale={FW}:{FH}:flags=area,format={pix_fmt}"),
|
||||
None => format!("scale={FW}:{FH}:flags=area,format={pix_fmt}"),
|
||||
};
|
||||
let bpp = if pix_fmt == "gray" { 1 } else { 3 };
|
||||
let raw = ffmpeg_raw(ffmpeg, &[], input, &["-map", "0:v:0", "-vf", &chain, "-fps_mode", "passthrough", "-f", "rawvideo"]);
|
||||
raw.chunks_exact(FW * FH * bpp).map(<[u8]>::to_vec).collect()
|
||||
}
|
||||
|
||||
/// Mean absolute difference of two equally sized frames.
|
||||
fn diff(a: &[u8], b: &[u8]) -> f64 {
|
||||
a.iter().zip(b).map(|(x, y)| f64::from(x.abs_diff(*y))).sum::<f64>() / a.len().max(1) as f64
|
||||
}
|
||||
|
||||
/// Assert that `got` shows source frame `want`: it is (one of) the closest source frames, and close.
|
||||
fn assert_shows(got: &[u8], src: &[Vec<u8>], want: usize, what: &str) {
|
||||
let d: Vec<f64> = src.iter().map(|s| diff(got, s)).collect();
|
||||
let best = d.iter().copied().fold(f64::INFINITY, f64::min);
|
||||
let at = d.iter().position(|x| *x == best).unwrap_or(0);
|
||||
assert!(d[want] < 6.0 && d[want] <= best + 0.25, "{what}: expected source frame {want} (diff {:.2}), closest is {at} (diff {best:.2})", d[want]);
|
||||
}
|
||||
|
||||
/// The first channel of audio stream `stream` of `input`, mono-mixed, as f32 at 48 kHz.
|
||||
fn pcm(ffmpeg: &Path, input: &str, stream: usize) -> Vec<f32> {
|
||||
let map = format!("0:a:{stream}");
|
||||
let raw = ffmpeg_raw(ffmpeg, &[], input, &["-map", &map, "-ac", "1", "-ar", "48000", "-f", "f32le"]);
|
||||
raw.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect()
|
||||
}
|
||||
|
||||
/// Times (s) where the signal rises above a quarter of its peak, at least 0.2 s apart.
|
||||
fn onsets(x: &[f32]) -> Vec<f64> {
|
||||
let peak = x.iter().fold(0f32, |m, v| m.max(v.abs()));
|
||||
let mut out = Vec::new();
|
||||
let mut last = f64::NEG_INFINITY;
|
||||
for (i, block) in x.chunks(48).enumerate() {
|
||||
let t = i as f64 * 48.0 / 48_000.0;
|
||||
if block.iter().any(|v| v.abs() > peak / 4.0) && t - last > 0.2 {
|
||||
out.push(t);
|
||||
}
|
||||
if block.iter().any(|v| v.abs() > peak / 4.0) {
|
||||
last = t;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn rms(x: &[f32], from: f64, to: f64) -> f64 {
|
||||
let s = x.get((from * 48_000.0) as usize..((to * 48_000.0) as usize).min(x.len())).unwrap_or_default();
|
||||
(s.iter().map(|v| f64::from(*v) * f64::from(*v)).sum::<f64>() / s.len().max(1) as f64).sqrt()
|
||||
}
|
||||
|
||||
/// Amplitude of the `freq` component of 100 ms of `x` from `at` seconds (Goertzel).
|
||||
fn tone(x: &[f32], at: f64, freq: f64) -> f64 {
|
||||
let start = (at * 48_000.0) as usize;
|
||||
let s = x.get(start..(start + 4800).min(x.len())).unwrap_or_default();
|
||||
let c = 2.0 * (2.0 * std::f64::consts::PI * freq / 48_000.0).cos();
|
||||
let (mut s1, mut s2) = (0.0, 0.0);
|
||||
for v in s {
|
||||
let s0 = f64::from(*v) + c * s1 - s2;
|
||||
s2 = s1;
|
||||
s1 = s0;
|
||||
}
|
||||
(s1 * s1 + s2 * s2 - c * s1 * s2).max(0.0).sqrt() * 2.0 / s.len().max(1) as f64
|
||||
}
|
||||
|
||||
fn db(ratio: f64) -> f64 {
|
||||
20.0 * ratio.log10()
|
||||
}
|
||||
|
||||
fn clips_on<'a>(seq: &'a Value, kind: &str, track: usize) -> &'a Vec<Value> {
|
||||
seq[kind][track]["items"].as_array().unwrap()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Workflows
|
||||
|
||||
/// Phone footage: import a VFR H.264/AAC clip, make a sequence from it, extract a second from the
|
||||
/// middle (Mark In / Mark Out / Extract), lower the audio 6 dB, put a title over the start, and
|
||||
/// export H.264 MP4 through MCP. The VFR clip plays at its 30 fps base rate (a dropped frame
|
||||
/// holds the one before), the cut lands on the right frames, picture and sound stay in sync
|
||||
/// across the cut, the title is in the picture only while it is on the timeline.
|
||||
#[test]
|
||||
fn phone_vfr_clip_extract_gain_title_to_h264_mp4() {
|
||||
let (ffmpeg, ffprobe) = filmcraft_testkit::require_oracles!();
|
||||
let Some(src) = phone_vfr_mp4(&ffmpeg) else {
|
||||
return filmcraft_testkit::skip("phone workflow", "ffmpeg could not make the VFR H.264/AAC fixture (libx264?)");
|
||||
};
|
||||
let src = src.to_string_lossy().into_owned();
|
||||
let dir = Scratch::new("phone");
|
||||
let mut m = Mcp::start(&dir.path("data"));
|
||||
|
||||
let imported = m.tool("media_import", json!({"paths": [src]}));
|
||||
assert_eq!(imported["errors"], json!([]), "{imported}");
|
||||
let item = imported["items"][0].as_u64().unwrap();
|
||||
let made = m.run("file.newSequenceFromClip", json!({"items": [item]}));
|
||||
assert!(made["sequence"].is_u64(), "{made}");
|
||||
let seq = m.tool("sequence_inspect", json!({}));
|
||||
assert_eq!((seq["settings"]["width"].as_u64(), seq["settings"]["height"].as_u64()), (Some(320), Some(240)), "{}", seq["settings"]);
|
||||
assert_eq!(seq["settings"]["frame_rate"], json!({"num": 30, "den": 1}), "a VFR phone clip makes a sequence at its base rate");
|
||||
assert_eq!(seq["durationFrames"], 120, "4 s at 30 fps");
|
||||
|
||||
// Extract 1.5 s .. 2.5 s; Mark Out includes its frame, so 31 frames go and 89 remain.
|
||||
m.run("markers.markIn", json!({"seconds": 1.5}));
|
||||
m.run("markers.markOut", json!({"seconds": 2.5}));
|
||||
m.run("sequence.extract", json!({}));
|
||||
m.run("edit.selectAll", json!({}));
|
||||
let gain = m.run("clip.audioGain", json!({"mode": "set", "db": -6.0}));
|
||||
assert_eq!(gain["clips"], 2, "both audio pieces: {gain}");
|
||||
m.run("edit.deselectAll", json!({}));
|
||||
m.run("graphics.newText", json!({"text": "HELLO", "size": 40, "time": 0, "seconds": 0.5}));
|
||||
let seq = m.tool("sequence_inspect", json!({}));
|
||||
assert_eq!(seq["durationFrames"], 89, "{seq}");
|
||||
assert_eq!(clips_on(&seq, "video", 0).len(), 2);
|
||||
assert_eq!(clips_on(&seq, "audio", 0).len(), 2);
|
||||
|
||||
let out = dir.path("phone_edit.mp4");
|
||||
let r = m.run("file.exportMedia", json!({"path": out, "format": "h264", "range": "entire", "wait": true}));
|
||||
assert_eq!(r["result"]["frames"], 89, "{r}");
|
||||
|
||||
// container and streams
|
||||
let j = probe(&ffprobe, &out);
|
||||
let (v, a) = (streams(&j, "video"), streams(&j, "audio"));
|
||||
assert_eq!((v.len(), a.len()), (1, 1), "{j}");
|
||||
assert_eq!((v[0]["codec_name"].as_str(), v[0]["width"].as_u64(), v[0]["height"].as_u64()), (Some("h264"), Some(320), Some(240)));
|
||||
assert_eq!(v[0]["r_frame_rate"], "30/1");
|
||||
assert_eq!(v[0]["nb_read_frames"], "89");
|
||||
assert_eq!((a[0]["codec_name"].as_str(), a[0]["sample_rate"].as_str(), a[0]["channels"].as_u64()), (Some("aac"), Some("48000"), Some(2)));
|
||||
let duration = num(&j["format"]["duration"]);
|
||||
assert!((duration - 89.0 / 30.0).abs() < 0.06, "duration {duration}");
|
||||
assert!((num(&a[0]["duration"]) - 89.0 / 30.0).abs() < 0.06, "audio as long as the picture: {}", a[0]["duration"]);
|
||||
|
||||
// picture: frame k of the export is frame k (before the cut) or k + 31 (after) of the source
|
||||
// as an editor plays it (CFR 30, a dropped frame holding the previous one)
|
||||
let src_frames = frames(&ffmpeg, &src, Some("fps=30"), "gray");
|
||||
let got = frames(&ffmpeg, &out, None, "gray");
|
||||
assert!(src_frames.len() >= 120, "{} source frames", src_frames.len());
|
||||
assert_eq!(got.len(), 89);
|
||||
for k in [20, 32, 40, 44, 45, 50, 70, 88] {
|
||||
let want = if k < 45 { k } else { k + 31 };
|
||||
assert_shows(&got[k], &src_frames, want, &format!("export frame {k}"));
|
||||
}
|
||||
// the flash frames: 30 (with 31 holding it, as the source dropped its frame 31) and 90 - 31
|
||||
let mean = |f: &Vec<u8>| f.iter().map(|v| f64::from(*v)).sum::<f64>() / f.len() as f64;
|
||||
let flashes: Vec<usize> = got.iter().enumerate().filter(|(_, f)| mean(f) > 200.0).map(|(i, _)| i).collect();
|
||||
assert_eq!(flashes, [30, 31, 59], "white frames");
|
||||
|
||||
// the title: over frames 0..15 only (its pixels differ from the source there, nowhere after)
|
||||
let blocks_changed = |k: usize| {
|
||||
let mut n = 0;
|
||||
for by in (0..FH).step_by(10) {
|
||||
for bx in (0..FW).step_by(10) {
|
||||
let mut d = 0.0;
|
||||
for y in by..by + 10 {
|
||||
for x in bx..bx + 10 {
|
||||
d += f64::from(got[k][y * FW + x].abs_diff(src_frames[k][y * FW + x]));
|
||||
}
|
||||
}
|
||||
n += usize::from(d / 100.0 > 15.0);
|
||||
}
|
||||
}
|
||||
n
|
||||
};
|
||||
for k in [2, 8, 14] {
|
||||
assert!(blocks_changed(k) >= 2, "title missing on frame {k}");
|
||||
}
|
||||
for k in [16, 25] {
|
||||
assert_eq!(blocks_changed(k), 0, "title still on frame {k}");
|
||||
}
|
||||
|
||||
// sound: beeps where the flashes are (A/V sync across the extract), 6 dB down
|
||||
let src_pcm = pcm(&ffmpeg, &src, 0);
|
||||
let got_pcm = pcm(&ffmpeg, &out, 0);
|
||||
let beeps = onsets(&got_pcm);
|
||||
assert_eq!(beeps.len(), 2, "beeps at {beeps:?}");
|
||||
for (beep, flash) in beeps.iter().zip([30.0 / 30.0, 59.0 / 30.0]) {
|
||||
assert!((beep - flash).abs() <= 1.0 / 30.0, "beep at {beep:.3} s, flash at {flash:.3} s");
|
||||
}
|
||||
let change = db(rms(&got_pcm, 1.01, 1.09) / rms(&src_pcm, 1.01, 1.09));
|
||||
assert!((change + 6.0).abs() < 1.0, "audio gain -6 dB measured {change:.2} dB");
|
||||
let change = db(rms(&got_pcm, 1.98, 2.06) / rms(&src_pcm, 3.01, 3.09));
|
||||
assert!((change + 6.0).abs() < 1.0, "second beep {change:.2} dB");
|
||||
}
|
||||
|
||||
/// Camera footage: import a long-GOP H.264 Matroska file with two audio tracks through MCP, make
|
||||
/// a sequence (both tracks land on A1 and A2), ripple-trim 2.6 s off the head (decoding starts at
|
||||
/// the only keyframe) and 15 frames off the tail, add a red colour matte with a centred cross
|
||||
/// dissolve, save the project; then a new `filmcraft-cli` process opens it and exports ProRes.
|
||||
/// The first exported frame is source frame 65, the dissolve mixes the two pictures, both audio
|
||||
/// tracks are in the mix at their own level and stop where the clip does.
|
||||
#[test]
|
||||
fn camera_long_gop_two_audio_tracks_trim_dissolve_to_prores() {
|
||||
let (ffmpeg, ffprobe) = filmcraft_testkit::require_oracles!();
|
||||
let Some(src) = camera_long_gop_mkv(&ffmpeg) else {
|
||||
return filmcraft_testkit::skip("camera workflow", "ffmpeg could not make the long-GOP Matroska fixture (libx264?)");
|
||||
};
|
||||
let src = src.to_string_lossy().into_owned();
|
||||
let dir = Scratch::new("camera");
|
||||
let data = dir.path("data");
|
||||
let project = dir.path("camera.fcproj");
|
||||
{
|
||||
let mut m = Mcp::start(&data);
|
||||
let imported = m.tool("media_import", json!({"paths": [src]}));
|
||||
assert_eq!(imported["errors"], json!([]), "{imported}");
|
||||
let item = imported["items"][0].as_u64().unwrap();
|
||||
let made = m.run("file.newSequenceFromClip", json!({"items": [item]}));
|
||||
let clips: Vec<u64> = made["clips"].as_array().unwrap().iter().filter_map(Value::as_u64).collect();
|
||||
assert_eq!(clips.len(), 3, "a video clip and one clip per audio track: {made}");
|
||||
let seq = m.tool("sequence_inspect", json!({}));
|
||||
assert_eq!(seq["settings"]["frame_rate"], json!({"num": 25, "den": 1}));
|
||||
assert_eq!(seq["audio"].as_array().map(Vec::len), Some(2), "two audio tracks");
|
||||
let video = clips_on(&seq, "video", 0)[0]["clip"].as_u64().unwrap();
|
||||
|
||||
// 65 frames off the head and 15 off the tail, the linked audio following
|
||||
m.run("timeline.trim", json!({"clip": video, "edge": "in", "mode": "ripple", "deltaFrames": 65}));
|
||||
m.run("timeline.trim", json!({"clip": video, "edge": "out", "mode": "ripple", "deltaFrames": -15}));
|
||||
let seq = m.tool("sequence_inspect", json!({}));
|
||||
let end = clips_on(&seq, "video", 0)[0]["endFrame"].as_u64().unwrap();
|
||||
for t in 0..2 {
|
||||
let a = &clips_on(&seq, "audio", t)[0];
|
||||
assert_eq!((a["startFrame"].as_u64(), a["endFrame"].as_u64(), a["sourceIn"].as_i64()), (Some(0), Some(end), Some(660_441_600_000)), "A{}", t + 1);
|
||||
}
|
||||
|
||||
let matte = m.run("file.newColorMatte", json!({"color": "#ff0000", "seconds": 2.0}))["item"].as_u64().unwrap();
|
||||
let placed = m.run("timeline.place", json!({"item": matte, "track": "V1", "frame": end, "insert": false}));
|
||||
let matte_clip = placed["clips"][0].as_u64().unwrap();
|
||||
m.run("sequence.applyVideoTransition", json!({"clip": matte_clip, "edge": "in", "frames": 10, "effect": "cross_dissolve"}));
|
||||
let seq = m.tool("sequence_inspect", json!({}));
|
||||
let tr = &seq["video"][0]["transitions"][0];
|
||||
assert_eq!((tr["effect"].as_str(), tr["align"].as_str()), (Some("cross_dissolve"), Some("center")), "{tr}");
|
||||
m.run("file.saveAs", json!({"path": project}));
|
||||
}
|
||||
assert_eq!(end_frame_of(&project), Some(71), "the saved project holds the edit");
|
||||
|
||||
// a fresh process opens the saved project and renders it
|
||||
let out = dir.path("camera_edit.mov");
|
||||
let o = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(["--data-dir", &data, "--project", &project, "export", &out]).output().unwrap();
|
||||
assert!(o.status.success(), "export: {}", String::from_utf8_lossy(&o.stderr));
|
||||
let r: Value = serde_json::from_slice(&o.stdout).unwrap();
|
||||
let total = 71 + 50;
|
||||
assert_eq!(r["result"]["frames"], total, "{r}");
|
||||
|
||||
let j = probe(&ffprobe, &out);
|
||||
let (v, a) = (streams(&j, "video"), streams(&j, "audio"));
|
||||
assert_eq!((v.len(), a.len()), (1, 1), "{j}");
|
||||
assert_eq!((v[0]["codec_name"].as_str(), v[0]["width"].as_u64(), v[0]["height"].as_u64()), (Some("prores"), Some(320), Some(240)));
|
||||
assert_eq!(v[0]["r_frame_rate"], "25/1");
|
||||
assert_eq!(v[0]["nb_read_frames"], total.to_string());
|
||||
assert_eq!((a[0]["sample_rate"].as_str(), a[0]["channels"].as_u64()), (Some("48000"), Some(2)));
|
||||
let duration = num(&j["format"]["duration"]);
|
||||
assert!((duration - f64::from(total) / 25.0).abs() < 0.05, "duration {duration}");
|
||||
|
||||
// picture: frame k is source frame 65 + k up to the dissolve (frames 66..76), red after it
|
||||
let src_frames = frames(&ffmpeg, &src, None, "rgb24");
|
||||
let got = frames(&ffmpeg, &out, None, "rgb24");
|
||||
assert_eq!((src_frames.len(), got.len()), (150, total as usize));
|
||||
for k in [0, 1, 2, 30, 64] {
|
||||
assert_shows(&got[k], &src_frames, 65 + k, &format!("export frame {k}"));
|
||||
}
|
||||
let red = &got[100];
|
||||
assert!(red.as_chunks::<3>().0.iter().all(|[r, g, b]| *r > 230 && *g < 25 && *b < 25), "the matte is red");
|
||||
// the dissolve: frame = (1 - a)·camera + a·red, `a` rising through about 0.5 at the cut
|
||||
let mix = |k: usize| {
|
||||
let (cam, e) = (&src_frames[65 + k], &got[k]);
|
||||
let (mut num, mut den) = (0.0, 0.0);
|
||||
for ((c, r), e) in cam.iter().zip(red).zip(e) {
|
||||
let (c, r, e) = (f64::from(*c), f64::from(*r), f64::from(*e));
|
||||
num += (e - c) * (r - c);
|
||||
den += (r - c) * (r - c);
|
||||
}
|
||||
num / den
|
||||
};
|
||||
let alphas: Vec<f64> = (64..=78).map(mix).collect();
|
||||
assert!(alphas[0].abs() < 0.05 && (alphas[14] - 1.0).abs() < 0.05, "outside the dissolve: {alphas:?}");
|
||||
assert!(alphas.windows(2).all(|w| w[1] >= w[0] - 0.02), "the dissolve progresses: {alphas:?}");
|
||||
assert!((alphas[7] - 0.5).abs() < 0.12, "half way at the cut (frame 71): {alphas:?}");
|
||||
|
||||
// sound: both tracks in the mix at their own level while the clip plays, silence on the matte
|
||||
let got_pcm = pcm(&ffmpeg, &out, 0);
|
||||
let (t1, t2) = (pcm(&ffmpeg, &src, 0), pcm(&ffmpeg, &src, 1));
|
||||
for at in [0.2, 1.5, 2.6] {
|
||||
let (low, high) = (tone(&got_pcm, at, 440.0), tone(&got_pcm, at, 1320.0));
|
||||
let (want_low, want_high) = (tone(&t1, at + 2.6, 440.0), tone(&t2, at + 2.6, 1320.0));
|
||||
assert!(
|
||||
db(low / want_low).abs() < 1.0 && db(high / want_high).abs() < 1.0,
|
||||
"at {at} s: 440 Hz {low:.4} (want {want_low:.4}), 1320 Hz {high:.4} (want {want_high:.4})"
|
||||
);
|
||||
}
|
||||
assert!(rms(&got_pcm, 3.0, 4.8) < 1e-3, "silence under the matte");
|
||||
}
|
||||
|
||||
/// `endFrame` of the first clip on V1 of the saved project's active sequence, read back through
|
||||
/// `filmcraft-cli --project … inspect sequence`.
|
||||
fn end_frame_of(project: &str) -> Option<u64> {
|
||||
let o = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(["--project", project, "inspect", "sequence"]).output().ok()?;
|
||||
let v: Value = serde_json::from_slice(&o.stdout).ok()?;
|
||||
v["video"][0]["items"][0]["endFrame"].as_u64()
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Spacing of browser downloads (pure, so it is tested natively in `tests/download_pacing.rs`; the
|
||||
//! Spacing of browser downloads (pure, so it is tested natively in `tests/it/download_pacing.rs`; the
|
||||
//! anchor clicks live in [`crate::fs::download`]).
|
||||
//!
|
||||
//! Chrome drops anchor-click downloads that start in one tight burst, so an image-sequence export
|
||||
|
||||
@@ -352,6 +352,24 @@ impl Services for WebServices {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_file_new(&self, path: &str, data: &[u8]) -> io::Result<()> {
|
||||
if path.starts_with("/opfs/") {
|
||||
return Err(io::Error::new(io::ErrorKind::Unsupported, "exclusive OPFS writes are unavailable"));
|
||||
}
|
||||
let hook = FS.with(|fs| {
|
||||
let mut fs = fs.borrow_mut();
|
||||
if fs.entries.contains_key(path) {
|
||||
return Err(io::Error::from(io::ErrorKind::AlreadyExists));
|
||||
}
|
||||
fs.entries.insert(path.to_owned(), Entry::Mem(Arc::from(data)));
|
||||
Ok(fs.on_write.clone())
|
||||
})?;
|
||||
if let Some(h) = hook {
|
||||
h(path, data);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn file_size(&self, path: &str) -> io::Result<u64> {
|
||||
Self::entry_reader(path).map(|r| r.len())
|
||||
}
|
||||
@@ -435,3 +453,32 @@ pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod exclusive_write_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn virtual_file_creation_is_exclusive_and_opfs_fails_closed() {
|
||||
let host = WebServices;
|
||||
let existing = "/files/collision-test.wav";
|
||||
let fresh = "/files/new-extraction-test.wav";
|
||||
FS.with(|fs| {
|
||||
let mut fs = fs.borrow_mut();
|
||||
fs.on_write = None;
|
||||
fs.entries.insert(existing.into(), Entry::Mem(Arc::from(&b"original"[..])));
|
||||
fs.entries.remove(fresh);
|
||||
});
|
||||
assert_eq!(host.write_file_new(existing, b"replacement").unwrap_err().kind(), io::ErrorKind::AlreadyExists);
|
||||
assert_eq!(host.read_file(existing).unwrap(), b"original");
|
||||
host.write_file_new(fresh, b"new").unwrap();
|
||||
assert_eq!(host.read_file(fresh).unwrap(), b"new");
|
||||
assert_eq!(host.write_file_new(fresh, b"again").unwrap_err().kind(), io::ErrorKind::AlreadyExists);
|
||||
assert_eq!(host.write_file_new("/opfs/media/new.wav", b"new").unwrap_err().kind(), io::ErrorKind::Unsupported);
|
||||
FS.with(|fs| {
|
||||
let mut fs = fs.borrow_mut();
|
||||
fs.entries.remove(existing);
|
||||
fs.entries.remove(fresh);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,7 +263,10 @@ pub fn install_hooks(app: &mut FilmcraftApp) {
|
||||
}));
|
||||
// Saving writes the file in memory and offers it as a download (`fs::WebServices`).
|
||||
app.hooks.pick_save = Some(Box::new(|name: &str| Some(format!("/projects/{name}"))));
|
||||
app.hooks.pick_save_as = Some(Box::new(|_filter: &str, _exts: &[&str], name: &str| Some(format!("/exports/{name}"))));
|
||||
app.hooks.pick_save_as = Some(Box::new(|_filter: &str, _exts: &[&str], name: &str| {
|
||||
let file = std::path::Path::new(name).file_name().map_or(name.into(), |n| n.to_string_lossy());
|
||||
Some(format!("/exports/{file}"))
|
||||
}));
|
||||
// Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: the picker is async, so the
|
||||
// hint's command runs once the user has chosen (#111).
|
||||
app.hooks.pick_file_for_relink = Some(Box::new(|exts: &[&str], hint: Option<RelinkHint>| {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! When the web recovery snapshot is written or marked clean (pure, so it is tested natively in
|
||||
//! `tests/recovery_policy.rs`; the OPFS side lives in [`crate::recovery`]).
|
||||
//! `tests/it/recovery_policy.rs`; the OPFS side lives in [`crate::recovery`]).
|
||||
//!
|
||||
//! - A dirty project is snapshotted at most every [`INTERVAL_S`] seconds, once per revision. A write
|
||||
//! that fails is retried after the interval, without waiting for another edit (#197).
|
||||
|
||||
+3
-3
@@ -1,10 +1,10 @@
|
||||
//! Download spacing. It is pure, so it runs natively even though the rest of the crate is
|
||||
//! wasm32-only.
|
||||
|
||||
#[path = "../src/download_pacing.rs"]
|
||||
mod download_pacing;
|
||||
#[path = "../../src/download_pacing.rs"]
|
||||
mod pacing;
|
||||
|
||||
use download_pacing::{GAP_MS, Pacer};
|
||||
use pacing::{GAP_MS, Pacer};
|
||||
|
||||
/// #378: 20 downloads requested in the same instant are spread out, not clicked in one burst.
|
||||
#[test]
|
||||
@@ -0,0 +1,2 @@
|
||||
mod download_pacing;
|
||||
mod recovery_policy;
|
||||
+3
-3
@@ -1,10 +1,10 @@
|
||||
//! The web recovery snapshot policy. It is pure, so it runs natively even though the rest of the
|
||||
//! crate is wasm32-only.
|
||||
|
||||
#[path = "../src/recovery_policy.rs"]
|
||||
mod recovery_policy;
|
||||
#[path = "../../src/recovery_policy.rs"]
|
||||
mod policy;
|
||||
|
||||
use recovery_policy::{Action, INTERVAL_S, Policy};
|
||||
use policy::{Action, INTERVAL_S, Policy};
|
||||
|
||||
/// #197: a snapshot write that fails is retried after the interval without another edit.
|
||||
#[test]
|
||||
@@ -29,18 +29,22 @@ filmcraft-platform = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
serde_json = { workspace = true }
|
||||
# Constant-time comparison of control-channel credentials.
|
||||
subtle = "2.6"
|
||||
log = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
cpal = "0.16"
|
||||
# Files and images on the system clipboard for Paste (#611); egui already uses it for text.
|
||||
arboard = "3"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
chrono = { workspace = true, features = ["clock"] }
|
||||
sys-locale = "0.3"
|
||||
# Help menu / About / Discord links in the system browser (MIT; as PhotoCraft).
|
||||
open = "5"
|
||||
|
||||
[dev-dependencies]
|
||||
glob = "0.3"
|
||||
# graphics.rs: enumerate the real adapters in a test.
|
||||
pollster = "0.4"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = "0.21"
|
||||
|
||||
+146
-11
@@ -1,12 +1,16 @@
|
||||
//! The desktop app's command line (`filmcraft --help`).
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// `filmcraft --help`.
|
||||
pub const USAGE: &str = "\
|
||||
Usage: filmcraft [options] [project.fcproj | media files...]
|
||||
|
||||
Options:
|
||||
--control <port> start the localhost JSON-lines control server
|
||||
(also: FILMCRAFT_CONTROL_PORT)
|
||||
(also: FILMCRAFT_CONTROL_PORT; requires FILMCRAFT_CONTROL_TOKEN,
|
||||
64 random hex characters; see docs/control-protocol.md)
|
||||
--control-input-only accept widget input only from the control server
|
||||
(requires --control or FILMCRAFT_CONTROL_PORT)
|
||||
--demo open the demo project
|
||||
@@ -15,6 +19,9 @@ Options:
|
||||
--no-recover start without asking about unsaved changes
|
||||
--data-dir <dir> folder for auto-save, crash recovery and logs
|
||||
(also: FILMCRAFT_DATA_DIR)
|
||||
--graphics <mode> start with this graphics mode and keep it: default,
|
||||
dx12 (Windows), software (Windows, Linux);
|
||||
`--graphics default` undoes an automatic fallback
|
||||
--version print the version and exit
|
||||
-h, --help print this help and exit
|
||||
";
|
||||
@@ -28,7 +35,7 @@ pub enum Cli {
|
||||
}
|
||||
|
||||
/// Options of a normal start.
|
||||
#[derive(Debug, PartialEq)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Launch {
|
||||
pub control_port: Option<u16>,
|
||||
pub control_input_only: bool,
|
||||
@@ -39,6 +46,8 @@ pub struct Launch {
|
||||
pub startup_flag: bool,
|
||||
pub recover: Option<bool>,
|
||||
pub data_dir: Option<std::path::PathBuf>,
|
||||
/// `--graphics <mode>`: start with this graphics mode and keep it (see `graphics.rs`).
|
||||
pub graphics: Option<crate::graphics::Mode>,
|
||||
}
|
||||
|
||||
/// Parse the arguments after the program name. `env_control_port` is the value of
|
||||
@@ -48,16 +57,31 @@ pub struct Launch {
|
||||
/// mistyped flag helps nobody, and starting without the control server that was asked for leaves
|
||||
/// a script waiting for it. That holds for the whole line, so `--version --bogus` is an error
|
||||
/// too, and for a port from the environment exactly as for one from `--control`.
|
||||
pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = String>) -> Result<Cli, String> {
|
||||
let mut l = Launch { control_port: None, control_input_only: false, files: Vec::new(), demo: true, startup_flag: false, recover: None, data_dir: None };
|
||||
///
|
||||
/// The arguments come from `std::env::args_os`: not every argument is Unicode (a Linux file name
|
||||
/// in Latin-1), and `std::env::args` panics on one. A file name that is not is passed on lossily
|
||||
/// (its import then reports it missing); `--data-dir` keeps the folder exactly.
|
||||
pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = OsString>) -> Result<Cli, String> {
|
||||
let mut l = Launch {
|
||||
control_port: None,
|
||||
control_input_only: false,
|
||||
files: Vec::new(),
|
||||
demo: true,
|
||||
startup_flag: false,
|
||||
recover: None,
|
||||
data_dir: None,
|
||||
graphics: None,
|
||||
};
|
||||
let (mut help, mut version) = (false, false);
|
||||
let mut args = args.into_iter();
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
while let Some(raw) = args.next() {
|
||||
let a = raw.to_string_lossy();
|
||||
match a.as_ref() {
|
||||
"-h" | "--help" => help = true,
|
||||
"--version" => version = true,
|
||||
"--control" => {
|
||||
let port = args.next().ok_or("--control needs a port number")?;
|
||||
let port = port.to_string_lossy();
|
||||
l.control_port = Some(port.parse().map_err(|_| format!("--control needs a port number, got `{port}`"))?);
|
||||
}
|
||||
"--demo" => (l.demo, l.startup_flag) = (true, true),
|
||||
@@ -65,13 +89,17 @@ pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = St
|
||||
"--empty" => (l.demo, l.startup_flag) = (false, true),
|
||||
"--recover" => l.recover = Some(true),
|
||||
"--no-recover" => l.recover = Some(false),
|
||||
"--data-dir" => l.data_dir = Some(args.next().map(std::path::PathBuf::from).ok_or("--data-dir needs a folder")?),
|
||||
"--data-dir" => l.data_dir = Some(args.next().map(PathBuf::from).ok_or("--data-dir needs a folder")?),
|
||||
"--graphics" => {
|
||||
let name = args.next().ok_or("--graphics needs a mode: default, dx12 or software")?;
|
||||
l.graphics = Some(graphics_mode(&name.to_string_lossy())?);
|
||||
}
|
||||
// everything after `--` is a file, whatever it looks like
|
||||
"--" => l.files.extend(args.by_ref()),
|
||||
"--" => l.files.extend(args.by_ref().map(|f| f.to_string_lossy().into_owned())),
|
||||
// macOS adds a process serial number when it launches an app bundle
|
||||
_ if a.starts_with("-psn_") => {}
|
||||
_ if a.len() > 1 && a.starts_with('-') => return Err(format!("unknown option `{a}`")),
|
||||
_ => l.files.push(a),
|
||||
_ => l.files.push(a.into_owned()),
|
||||
}
|
||||
}
|
||||
if help {
|
||||
@@ -91,12 +119,45 @@ pub fn parse(env_control_port: Option<String>, args: impl IntoIterator<Item = St
|
||||
Ok(Cli::Run(l))
|
||||
}
|
||||
|
||||
/// Whether a launch file is a FilmCraft project. The extension's case does not matter: Windows file
|
||||
/// associations and the Linux MIME glob start the app for `CUT.FCPROJ` too (#850).
|
||||
pub fn is_project(path: &str) -> bool {
|
||||
Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("fcproj"))
|
||||
}
|
||||
|
||||
/// `path` made absolute against the working directory, so a project or media file named relative
|
||||
/// to a terminal's folder is still found once the app runs from another one (Recent Projects, the
|
||||
/// media paths saved in the project). Unlike `canonicalize`, `std::path::absolute` keeps symlinks
|
||||
/// and adds no Windows verbatim `\\?\` prefix. A path it cannot resolve is kept as given.
|
||||
pub fn absolute(path: &str) -> String {
|
||||
std::path::absolute(path).ok().and_then(|p| p.into_os_string().into_string().ok()).unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
|
||||
/// The launch files as the project to open (the first one, see [`is_project`]) and the media files
|
||||
/// to import (every file that is not a project), all made [`absolute`].
|
||||
pub fn project_and_media(files: &[String]) -> (Option<String>, Vec<String>) {
|
||||
let project = files.iter().find(|f| is_project(f)).map(|f| absolute(f));
|
||||
let media = files.iter().filter(|f| !is_project(f)).map(|f| absolute(f)).collect();
|
||||
(project, media)
|
||||
}
|
||||
|
||||
/// A `--graphics` value: one of this platform's modes.
|
||||
fn graphics_mode(name: &str) -> Result<crate::graphics::Mode, String> {
|
||||
use crate::graphics::{LADDER, Mode};
|
||||
let here = || LADDER.iter().map(|m| m.name()).collect::<Vec<_>>().join(", ");
|
||||
let mode = Mode::parse(name).ok_or_else(|| format!("--graphics needs one of {}, got `{name}`", here()))?;
|
||||
if !LADDER.contains(&mode) {
|
||||
return Err(format!("--graphics {name} is not available on this platform (use one of {})", here()));
|
||||
}
|
||||
Ok(mode)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_env(env: Option<&str>, args: &[&str]) -> Result<Cli, String> {
|
||||
parse(env.map(str::to_string), args.iter().map(|a| a.to_string()))
|
||||
parse(env.map(str::to_string), args.iter().map(OsString::from))
|
||||
}
|
||||
|
||||
fn parse_args(args: &[&str]) -> Result<Cli, String> {
|
||||
@@ -166,7 +227,16 @@ mod tests {
|
||||
let none = launch(&[]);
|
||||
assert_eq!(
|
||||
none,
|
||||
Launch { control_port: None, control_input_only: false, files: vec![], demo: true, startup_flag: false, recover: None, data_dir: None }
|
||||
Launch {
|
||||
control_port: None,
|
||||
control_input_only: false,
|
||||
files: vec![],
|
||||
demo: true,
|
||||
startup_flag: false,
|
||||
recover: None,
|
||||
data_dir: None,
|
||||
graphics: None
|
||||
}
|
||||
);
|
||||
let l = launch(&["--control", "9876", "--empty", "--no-recover", "--data-dir", "/tmp/fc", "cut.fcproj", "a.mp4"]);
|
||||
assert_eq!(l.control_port, Some(9876));
|
||||
@@ -179,6 +249,52 @@ mod tests {
|
||||
assert_eq!(launch(&["-", "-psn_0_12345"]).files, ["-"]);
|
||||
}
|
||||
|
||||
/// `std::env::args` panics on an argument that is not Unicode, so such a file name (Latin-1 on
|
||||
/// Linux) stopped the app before its window opened.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn arguments_that_are_not_unicode_still_start_the_app() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let dir = OsString::from_vec(b"/tmp/d\xe9".to_vec());
|
||||
let file = OsString::from_vec(b"caf\xe9.mov".to_vec());
|
||||
let Ok(Cli::Run(l)) = parse(None, [OsString::from("--data-dir"), dir.clone(), file.clone(), OsString::from("--"), file]) else {
|
||||
panic!("should start the app");
|
||||
};
|
||||
assert_eq!(l.data_dir, Some(PathBuf::from(dir)), "the data folder is kept exactly");
|
||||
assert_eq!(l.files, ["caf\u{fffd}.mov", "caf\u{fffd}.mov"]);
|
||||
}
|
||||
|
||||
/// Windows file associations and the Linux MIME glob match `CUT.FCPROJ` too; it used to be
|
||||
/// imported as a media file instead of opened (#850).
|
||||
#[test]
|
||||
fn a_project_is_recognised_whatever_the_case_of_its_extension() {
|
||||
for p in ["cut.fcproj", "CUT.FCPROJ", "Cut.FcProj", "/edits/a b.fcproj", r"C:\Edits\CUT.FCPROJ"] {
|
||||
assert!(is_project(p), "{p}");
|
||||
}
|
||||
for p in ["cut.mp4", "fcproj", ".fcproj", "a.fcproj.mov", "a.fcprojx", ""] {
|
||||
assert!(!is_project(p), "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Relative launch paths were opened and imported as given, so the project's media paths and
|
||||
/// Recent Projects pointed nowhere once the app ran from another folder (#850).
|
||||
#[test]
|
||||
fn launch_files_are_made_absolute_and_split_case_insensitively() {
|
||||
let cwd = std::env::current_dir().unwrap();
|
||||
let nested = Path::new("clips").join("a.mp4");
|
||||
let already = cwd.join("b.wav").to_string_lossy().into_owned();
|
||||
let files = ["Cut.FCPROJ".to_string(), nested.to_string_lossy().into_owned(), "other.fcproj".to_string(), already.clone()];
|
||||
let (project, media) = project_and_media(&files);
|
||||
assert_eq!(project, Some(cwd.join("Cut.FCPROJ").to_string_lossy().into_owned()));
|
||||
assert_eq!(media, [cwd.join(&nested).to_string_lossy().into_owned(), already]);
|
||||
for p in media.iter().chain(&project) {
|
||||
assert!(Path::new(p).is_absolute() && !p.starts_with(r"\\?\"), "{p}");
|
||||
}
|
||||
assert_eq!(project_and_media(&[]), (None, vec![]));
|
||||
// nothing to resolve against: kept as given rather than dropped
|
||||
assert_eq!(absolute(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolated_input_is_explicit_and_requires_a_control_server() {
|
||||
assert!(!launch(&["--control", "9876"]).control_input_only);
|
||||
@@ -188,4 +304,23 @@ mod tests {
|
||||
assert_eq!(parse_args(&["--control-input-only", "--help"]), Ok(Cli::Help));
|
||||
assert!(USAGE.contains("--control-input-only"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphics_picks_one_of_this_platforms_modes() {
|
||||
use crate::graphics::{LADDER, Mode};
|
||||
assert_eq!(launch(&[]).graphics, None);
|
||||
assert_eq!(launch(&["--graphics", "default"]).graphics, Some(Mode::Default));
|
||||
for mode in LADDER {
|
||||
assert_eq!(launch(&["--graphics", mode.name()]).graphics, Some(*mode));
|
||||
}
|
||||
// a mode this platform has not is refused, not ignored
|
||||
for mode in [Mode::Dx12, Mode::Software].into_iter().filter(|m| !LADDER.contains(m)) {
|
||||
assert!(parse_args(&["--graphics", mode.name()]).unwrap_err().contains("not available"));
|
||||
}
|
||||
assert!(parse_args(&["--graphics"]).is_err(), "missing mode");
|
||||
assert!(parse_args(&["--graphics", "vulkan"]).unwrap_err().contains("vulkan"));
|
||||
assert!(parse_args(&["--graphics", "DEFAULT"]).is_err());
|
||||
assert!(parse_args(&["--graphics", &"x".repeat(1 << 16)]).is_err());
|
||||
assert!(USAGE.contains("--graphics"));
|
||||
}
|
||||
}
|
||||
|
||||
+295
-52
@@ -1,18 +1,33 @@
|
||||
//! cpal audio output: the playback master clock. Settings ▸ Audio Hardware picks the host
|
||||
//! ("Device Class"), the output device, the I/O buffer size and the sample rate.
|
||||
//!
|
||||
//! A stream the device drops while playing (an ALSA xrun the backend does not recover from, which
|
||||
//! cpal reports as `poll() returned POLLERR`; an unplugged device) is restarted by
|
||||
//! [`AudioOut::recover`], keeping the played-frame clock and the play-ahead mixer, so sound
|
||||
//! resumes where it stopped instead of playback going silent (#840).
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use std::sync::{Arc, Mutex, PoisonError};
|
||||
use std::sync::{Arc, Mutex, PoisonError, TryLockError};
|
||||
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
use filmcraft_engine::settings::AudioHardwarePrefs;
|
||||
use filmcraft_ui_egui::{AudioDevices, AudioOut};
|
||||
|
||||
/// The playback mixer callback handed to [`AudioOut::start`].
|
||||
type Fill = Box<dyn FnMut(&mut [f32], usize) + Send>;
|
||||
|
||||
pub struct CpalOut {
|
||||
stream: Option<cpal::Stream>,
|
||||
/// The running playback's mixer, shared with the stream so a restarted stream continues it.
|
||||
fill: Option<Arc<Mutex<Fill>>>,
|
||||
played: Arc<AtomicU64>,
|
||||
failed: Arc<AtomicBool>,
|
||||
health: Arc<Health>,
|
||||
watchdog: Watchdog,
|
||||
/// Time base for the watchdog.
|
||||
epoch: Instant,
|
||||
rate: u32,
|
||||
channels: u16,
|
||||
hw: AudioHardwarePrefs,
|
||||
@@ -128,8 +143,11 @@ impl CpalOut {
|
||||
};
|
||||
Self {
|
||||
stream: None,
|
||||
fill: None,
|
||||
played: Arc::new(AtomicU64::new(0)),
|
||||
failed: Arc::new(AtomicBool::new(false)),
|
||||
health: Arc::new(Health::default()),
|
||||
watchdog: Watchdog::default(),
|
||||
epoch: Instant::now(),
|
||||
rate: cfg.as_ref().map_or(48_000, |c| c.sample_rate().0),
|
||||
channels: cfg.as_ref().map_or(2, |c| c.channels()),
|
||||
hw: AudioHardwarePrefs::default(),
|
||||
@@ -153,19 +171,14 @@ impl CpalOut {
|
||||
Ok(exact.unwrap_or(default))
|
||||
}
|
||||
|
||||
fn converted_stream<T>(
|
||||
&self,
|
||||
dev: &cpal::Device,
|
||||
config: &cpal::StreamConfig,
|
||||
mut fill: Box<dyn FnMut(&mut [f32], usize) + Send>,
|
||||
) -> Result<cpal::Stream, cpal::BuildStreamError>
|
||||
fn converted_stream<T>(&self, dev: &cpal::Device, config: &cpal::StreamConfig, fill: Arc<Mutex<Fill>>) -> Result<cpal::Stream, cpal::BuildStreamError>
|
||||
where
|
||||
T: cpal::SizedSample + cpal::FromSample<f32>,
|
||||
{
|
||||
let channels = usize::from(config.channels);
|
||||
let played = self.played.clone();
|
||||
let failed = self.failed.clone();
|
||||
let error_flag = failed.clone();
|
||||
let health = self.health.clone();
|
||||
let error_health = health.clone();
|
||||
let mut scratch = Vec::new();
|
||||
dev.build_output_stream(
|
||||
config,
|
||||
@@ -173,39 +186,23 @@ impl CpalOut {
|
||||
// Reuse the conversion buffer. A pathological driver buffer must not panic the audio thread.
|
||||
if buf.len() > 1_048_576 || scratch.try_reserve(buf.len().saturating_sub(scratch.len())).is_err() {
|
||||
buf.fill(T::EQUILIBRIUM);
|
||||
if !failed.swap(true, Ordering::SeqCst) {
|
||||
if !health.failed.swap(true, Ordering::SeqCst) {
|
||||
eprintln!("filmcraft: unable to allocate the audio conversion buffer");
|
||||
}
|
||||
return;
|
||||
}
|
||||
scratch.resize(buf.len(), 0.0);
|
||||
fill(&mut scratch, channels);
|
||||
render(&fill, &mut scratch, channels, &played, &health);
|
||||
convert_samples(&scratch, buf);
|
||||
played.fetch_add((buf.len() / channels) as u64, Ordering::SeqCst);
|
||||
},
|
||||
move |e| stream_error(&error_flag, e),
|
||||
move |e| stream_error(&error_health, e),
|
||||
None,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn stream_error(failed: &AtomicBool, e: cpal::StreamError) {
|
||||
if !failed.swap(true, Ordering::SeqCst) {
|
||||
eprintln!("filmcraft: audio stream error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_samples<T: cpal::Sample + cpal::FromSample<f32>>(input: &[f32], output: &mut [T]) {
|
||||
// Integer conversion represents [-1, 1). In particular I24 cannot represent +1 exactly.
|
||||
let upper = f32::from_bits(1.0_f32.to_bits() - 1);
|
||||
for (sample, converted) in input.iter().zip(output) {
|
||||
*converted = T::from_sample(if sample.is_finite() { sample.clamp(-1.0, upper) } else { 0.0 });
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioOut for CpalOut {
|
||||
fn start(&mut self, mut fill: Box<dyn FnMut(&mut [f32], usize) + Send>) -> Result<u32, String> {
|
||||
self.stop();
|
||||
/// Open the configured output device and play `fill` on it. `expect` is the (sample rate,
|
||||
/// channels) a restarted stream must keep: the mixer was set up for them.
|
||||
fn open(&mut self, fill: Arc<Mutex<Fill>>, expect: Option<(u32, u16)>) -> Result<(), String> {
|
||||
let host = host(&self.hw.device_class);
|
||||
let DeviceChoice { device: dev, fallback } = output_device(&host, &self.hw.default_output, &self.fallback).ok_or("no output device")?;
|
||||
self.note = fallback;
|
||||
@@ -214,6 +211,11 @@ impl AudioOut for CpalOut {
|
||||
if channels == 0 || cfg.sample_rate().0 == 0 {
|
||||
return Err("the output device returned an invalid channel count or sample rate".into());
|
||||
}
|
||||
if let Some((rate, ch)) = expect
|
||||
&& (rate, ch) != (cfg.sample_rate().0, cfg.channels())
|
||||
{
|
||||
return Err(format!("the output device now runs at {} Hz with {} channels", cfg.sample_rate().0, cfg.channels()));
|
||||
}
|
||||
let mut config: cpal::StreamConfig = cfg.clone().into();
|
||||
if let cpal::SupportedBufferSize::Range { min, max } = cfg.buffer_size()
|
||||
&& (*min..=*max).contains(&self.hw.buffer_size)
|
||||
@@ -222,20 +224,18 @@ impl AudioOut for CpalOut {
|
||||
}
|
||||
self.rate = cfg.sample_rate().0;
|
||||
self.channels = cfg.channels();
|
||||
self.played.store(0, Ordering::SeqCst);
|
||||
self.failed.store(false, Ordering::SeqCst);
|
||||
let played = self.played.clone();
|
||||
let failed = self.failed.clone();
|
||||
let stream = match cfg.sample_format() {
|
||||
cpal::SampleFormat::F32 => dev.build_output_stream(
|
||||
&config,
|
||||
move |buf: &mut [f32], _| {
|
||||
fill(buf, channels);
|
||||
played.fetch_add((buf.len() / channels) as u64, Ordering::SeqCst);
|
||||
},
|
||||
move |e| stream_error(&failed, e),
|
||||
None,
|
||||
),
|
||||
cpal::SampleFormat::F32 => {
|
||||
let played = self.played.clone();
|
||||
let health = self.health.clone();
|
||||
let error_health = health.clone();
|
||||
dev.build_output_stream(
|
||||
&config,
|
||||
move |buf: &mut [f32], _| render(&fill, buf, channels, &played, &health),
|
||||
move |e| stream_error(&error_health, e),
|
||||
None,
|
||||
)
|
||||
}
|
||||
cpal::SampleFormat::I8 => self.converted_stream::<i8>(&dev, &config, fill),
|
||||
cpal::SampleFormat::I16 => self.converted_stream::<i16>(&dev, &config, fill),
|
||||
cpal::SampleFormat::I24 => self.converted_stream::<cpal::I24>(&dev, &config, fill),
|
||||
@@ -251,10 +251,172 @@ impl AudioOut for CpalOut {
|
||||
.map_err(|e| e.to_string())?;
|
||||
stream.play().map_err(|e| e.to_string())?;
|
||||
self.stream = Some(stream);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace a dropped stream with a new one on the same mixer, clock and format.
|
||||
fn restart(&mut self) -> Result<(), String> {
|
||||
let fill = self.fill.clone().ok_or("nothing is playing")?;
|
||||
let expect = (self.rate, self.channels);
|
||||
// The old stream (and its audio thread) goes first: only one stream ever runs the mixer.
|
||||
self.stream = None;
|
||||
self.health.clear_errors();
|
||||
self.open(fill, Some(expect))
|
||||
}
|
||||
}
|
||||
|
||||
/// What the audio thread reports to the UI thread.
|
||||
#[derive(Default)]
|
||||
struct Health {
|
||||
/// The device went away (unplugged): restart the stream at once.
|
||||
lost: AtomicBool,
|
||||
/// A backend error since the last check (cpal keeps the stream alive after these): restart
|
||||
/// only if the stream also stopped consuming samples.
|
||||
errored: AtomicBool,
|
||||
/// Unrecoverable: the mixer panicked, a buffer could not be allocated, or restarts ran out.
|
||||
/// [`AudioOut::played_frames`] reports `None` and playback goes on without sound.
|
||||
failed: AtomicBool,
|
||||
/// The first error of a stream has been printed.
|
||||
reported: AtomicBool,
|
||||
}
|
||||
|
||||
impl Health {
|
||||
fn clear_errors(&self) {
|
||||
self.lost.store(false, Ordering::SeqCst);
|
||||
self.errored.store(false, Ordering::SeqCst);
|
||||
self.reported.store(false, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a stream that reported an error may go without consuming samples before it is
|
||||
/// restarted. Well under the UI's `AUDIO_STALL_S`, which gives up on the device clock.
|
||||
const STALL_RESTART: Duration = Duration::from_millis(300);
|
||||
/// At most this many restarts within [`RESTART_WINDOW`]; a device that keeps failing gives up.
|
||||
const MAX_RESTARTS: usize = 3;
|
||||
const RESTART_WINDOW: Duration = Duration::from_secs(10);
|
||||
|
||||
/// What [`Watchdog::check`] decides about the running stream.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Verdict {
|
||||
Healthy,
|
||||
Restart,
|
||||
GiveUp,
|
||||
}
|
||||
|
||||
/// Decides when a stream that reported errors is restarted (UI thread, every frame).
|
||||
#[derive(Default)]
|
||||
struct Watchdog {
|
||||
/// Played-frame count last seen, and when it last changed.
|
||||
seen: (u64, Duration),
|
||||
/// An error was reported and the stream has not consumed samples since.
|
||||
pending_error: bool,
|
||||
/// When recent restarts happened (at most [`MAX_RESTARTS`] kept).
|
||||
restarts: Vec<Duration>,
|
||||
}
|
||||
|
||||
impl Watchdog {
|
||||
fn reset(&mut self, now: Duration) {
|
||||
*self = Self { seen: (0, now), ..Self::default() };
|
||||
}
|
||||
|
||||
fn check(&mut self, now: Duration, played: u64, errored: bool, lost: bool) -> Verdict {
|
||||
if played != self.seen.0 {
|
||||
self.seen = (played, now);
|
||||
self.pending_error = false;
|
||||
}
|
||||
// An error that arrives with the stream still consuming samples is only a hiccup.
|
||||
self.pending_error |= errored;
|
||||
let stalled = now.saturating_sub(self.seen.1) >= STALL_RESTART;
|
||||
if !lost && !(self.pending_error && stalled) {
|
||||
return Verdict::Healthy;
|
||||
}
|
||||
self.restarts.retain(|t| now.saturating_sub(*t) < RESTART_WINDOW);
|
||||
if self.restarts.len() >= MAX_RESTARTS {
|
||||
return Verdict::GiveUp;
|
||||
}
|
||||
self.restarts.push(now);
|
||||
self.seen = (played, now);
|
||||
self.pending_error = false;
|
||||
Verdict::Restart
|
||||
}
|
||||
}
|
||||
|
||||
/// One device callback: run the shared mixer into `buf` and advance the clock. Never panics: a
|
||||
/// panicking mixer plays silence and fails the stream, and a mixer held by another stream (never
|
||||
/// expected: a restart drops the old stream first) plays silence without advancing the clock.
|
||||
fn render(fill: &Mutex<Fill>, buf: &mut [f32], channels: usize, played: &AtomicU64, health: &Health) {
|
||||
let mut guard = match fill.try_lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
|
||||
Err(TryLockError::WouldBlock) => {
|
||||
buf.fill(0.0);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if health.failed.load(Ordering::SeqCst) || catch_unwind(AssertUnwindSafe(|| (*guard)(buf, channels))).is_err() {
|
||||
buf.fill(0.0);
|
||||
if !health.failed.swap(true, Ordering::SeqCst) {
|
||||
eprintln!("filmcraft: the audio mixer failed; playing without sound");
|
||||
}
|
||||
return;
|
||||
}
|
||||
played.fetch_add((buf.len() / channels.max(1)) as u64, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn stream_error(health: &Health, e: cpal::StreamError) {
|
||||
match e {
|
||||
cpal::StreamError::DeviceNotAvailable => health.lost.store(true, Ordering::SeqCst),
|
||||
cpal::StreamError::BackendSpecific { .. } => health.errored.store(true, Ordering::SeqCst),
|
||||
}
|
||||
if !health.reported.swap(true, Ordering::SeqCst) {
|
||||
eprintln!("filmcraft: audio stream error: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_samples<T: cpal::Sample + cpal::FromSample<f32>>(input: &[f32], output: &mut [T]) {
|
||||
// Integer conversion represents [-1, 1). In particular I24 cannot represent +1 exactly.
|
||||
let upper = f32::from_bits(1.0_f32.to_bits() - 1);
|
||||
for (sample, converted) in input.iter().zip(output) {
|
||||
*converted = T::from_sample(if sample.is_finite() { sample.clamp(-1.0, upper) } else { 0.0 });
|
||||
}
|
||||
}
|
||||
|
||||
impl AudioOut for CpalOut {
|
||||
fn start(&mut self, fill: Box<dyn FnMut(&mut [f32], usize) + Send>) -> Result<u32, String> {
|
||||
self.stop();
|
||||
self.played.store(0, Ordering::SeqCst);
|
||||
self.health = Arc::new(Health::default());
|
||||
self.watchdog.reset(self.epoch.elapsed());
|
||||
let fill = Arc::new(Mutex::new(fill));
|
||||
self.open(fill.clone(), None)?;
|
||||
self.fill = Some(fill);
|
||||
Ok(self.rate)
|
||||
}
|
||||
fn stop(&mut self) {
|
||||
self.stream = None;
|
||||
self.fill = None;
|
||||
}
|
||||
fn recover(&mut self) {
|
||||
if self.stream.is_none() || self.health.failed.load(Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let lost = self.health.lost.swap(false, Ordering::SeqCst);
|
||||
let errored = self.health.errored.swap(false, Ordering::SeqCst);
|
||||
match self.watchdog.check(self.epoch.elapsed(), self.played.load(Ordering::SeqCst), errored, lost) {
|
||||
Verdict::Healthy => {}
|
||||
Verdict::Restart => {
|
||||
log::warn!("the audio output stream {}; restarting it", if lost { "lost its device" } else { "stopped after an error" });
|
||||
if let Err(e) = self.restart() {
|
||||
log::warn!("the audio output stream could not be restarted: {e}");
|
||||
self.stream = None;
|
||||
self.health.failed.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
Verdict::GiveUp => {
|
||||
log::warn!("the audio output stream keeps failing ({MAX_RESTARTS} restarts within {} s)", RESTART_WINDOW.as_secs());
|
||||
self.health.failed.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn sample_rate(&self) -> u32 {
|
||||
self.rate
|
||||
@@ -263,7 +425,7 @@ impl AudioOut for CpalOut {
|
||||
self.channels as usize
|
||||
}
|
||||
fn played_frames(&self) -> Option<u64> {
|
||||
self.stream.as_ref().filter(|_| !self.failed.load(Ordering::SeqCst)).map(|_| self.played.load(Ordering::SeqCst))
|
||||
self.stream.as_ref().filter(|_| !self.health.failed.load(Ordering::SeqCst)).map(|_| self.played.load(Ordering::SeqCst))
|
||||
}
|
||||
fn devices(&self) -> AudioDevices {
|
||||
let h = host(&self.hw.device_class);
|
||||
@@ -327,11 +489,92 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_stream_relinquishes_the_playback_clock() {
|
||||
let failed = AtomicBool::new(false);
|
||||
stream_error(&failed, cpal::StreamError::DeviceNotAvailable);
|
||||
assert!(failed.load(Ordering::SeqCst));
|
||||
fn stream_errors_ask_for_a_restart_instead_of_failing_playback() {
|
||||
let health = Health::default();
|
||||
stream_error(&health, cpal::StreamError::BackendSpecific { err: cpal::BackendSpecificError { description: "`alsa::poll()` returned POLLERR".into() } });
|
||||
assert!(health.errored.load(Ordering::SeqCst) && !health.lost.load(Ordering::SeqCst));
|
||||
stream_error(&health, cpal::StreamError::DeviceNotAvailable);
|
||||
assert!(health.lost.load(Ordering::SeqCst));
|
||||
assert!(!health.failed.load(Ordering::SeqCst), "a stream error alone must not silence playback");
|
||||
let output = CpalOut::new();
|
||||
assert_eq!(output.played_frames(), None);
|
||||
assert_eq!(output.played_frames(), None, "no stream, no clock");
|
||||
}
|
||||
|
||||
fn ms(n: u64) -> Duration {
|
||||
Duration::from_millis(n)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_error_while_samples_keep_flowing_is_only_a_hiccup() {
|
||||
let mut w = Watchdog::default();
|
||||
w.reset(ms(0));
|
||||
assert_eq!(w.check(ms(10), 480, true, false), Verdict::Healthy);
|
||||
// the stream keeps consuming samples after the error: never restarted
|
||||
for (i, t) in (20..2_000).step_by(20).enumerate() {
|
||||
assert_eq!(w.check(ms(t), 480 * (i as u64 + 2), false, false), Verdict::Healthy);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stream_that_errors_and_stops_consuming_is_restarted() {
|
||||
let mut w = Watchdog::default();
|
||||
w.reset(ms(0));
|
||||
assert_eq!(w.check(ms(10), 480, false, false), Verdict::Healthy);
|
||||
// POLLERR: the backend reports errors and no longer calls back
|
||||
assert_eq!(w.check(ms(20), 480, true, false), Verdict::Healthy);
|
||||
assert_eq!(w.check(ms(200), 480, true, false), Verdict::Healthy);
|
||||
assert_eq!(w.check(ms(310), 480, false, false), Verdict::Restart);
|
||||
// the restarted stream gets a fresh grace period
|
||||
assert_eq!(w.check(ms(400), 480, false, false), Verdict::Healthy);
|
||||
// a stall without any reported error is left to the UI's own watchdog
|
||||
assert_eq!(w.check(ms(2_000), 480, false, false), Verdict::Healthy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lost_device_restarts_at_once_and_repeated_failures_give_up() {
|
||||
let mut w = Watchdog::default();
|
||||
w.reset(ms(0));
|
||||
assert_eq!(w.check(ms(5), 0, false, true), Verdict::Restart);
|
||||
assert_eq!(w.check(ms(10), 0, false, true), Verdict::Restart);
|
||||
assert_eq!(w.check(ms(15), 0, false, true), Verdict::Restart);
|
||||
assert_eq!(w.check(ms(20), 0, false, true), Verdict::GiveUp);
|
||||
// sporadic failures far apart keep being recovered
|
||||
assert_eq!(w.check(ms(10_006), 0, false, true), Verdict::Restart);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_device_callback_never_panics_and_counts_played_frames() {
|
||||
let (played, health) = (AtomicU64::new(0), Health::default());
|
||||
let fill: Mutex<Fill> = Mutex::new(Box::new(|buf: &mut [f32], _| buf.fill(0.25)));
|
||||
let mut buf = [1.0_f32; 8];
|
||||
render(&fill, &mut buf, 2, &played, &health);
|
||||
assert_eq!((buf, played.load(Ordering::SeqCst)), ([0.25; 8], 4));
|
||||
// a mixer still held elsewhere plays silence without moving the clock
|
||||
{
|
||||
let _held = fill.lock().unwrap();
|
||||
render(&fill, &mut buf, 2, &played, &health);
|
||||
}
|
||||
assert_eq!((buf, played.load(Ordering::SeqCst)), ([0.0; 8], 4));
|
||||
// zero channels from a broken driver must not divide by zero
|
||||
render(&fill, &mut buf, 0, &played, &health);
|
||||
assert_eq!(played.load(Ordering::SeqCst), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panicking_mixer_plays_silence_and_fails_the_stream() {
|
||||
let (played, health) = (AtomicU64::new(0), Health::default());
|
||||
let fill: Mutex<Fill> = Mutex::new(Box::new(|buf: &mut [f32], _| {
|
||||
buf.fill(0.5);
|
||||
panic!("mixer bug");
|
||||
}));
|
||||
let mut buf = [1.0_f32; 4];
|
||||
render(&fill, &mut buf, 2, &played, &health);
|
||||
assert_eq!(buf, [0.0; 4]);
|
||||
assert_eq!(played.load(Ordering::SeqCst), 0);
|
||||
assert!(health.failed.load(Ordering::SeqCst));
|
||||
// later callbacks stay silent instead of re-entering the broken mixer
|
||||
buf = [1.0; 4];
|
||||
render(&fill, &mut buf, 2, &played, &health);
|
||||
assert_eq!(buf, [0.0; 4]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
//! (an HTTP request line from a browser's cross-origin `fetch`, a stray `nc`, binary junk) gets
|
||||
//! one error reply and the connection is closed, so nothing after it is executed. Lines are
|
||||
//! bounded ([`MAX_LINE`]) and so is the number of open connections ([`MAX_CONNECTIONS`]); junk
|
||||
//! never reaches the UI thread. See `docs/control-protocol.md`.
|
||||
//! never reaches the UI thread. Every request must carry the session credential from
|
||||
//! `FILMCRAFT_CONTROL_TOKEN`; without a configured credential the listener stays closed.
|
||||
//! See `docs/control-protocol.md`.
|
||||
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
@@ -15,6 +17,7 @@ use std::time::Duration;
|
||||
|
||||
use filmcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
/// Longest accepted request line (bytes, without the newline). Requests are small JSON objects;
|
||||
/// anything longer is refused and the connection closed.
|
||||
@@ -23,7 +26,17 @@ pub const MAX_LINE: usize = 4 * 1024 * 1024;
|
||||
pub const MAX_CONNECTIONS: usize = 16;
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
start_with_token(port, ctx, std::env::var("FILMCRAFT_CONTROL_TOKEN").ok())
|
||||
}
|
||||
|
||||
fn start_with_token(port: u16, ctx: egui::Context, token: Option<String>) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
// An inherited environment variable shares the credential with the owner's bridge process
|
||||
// without exposing it in command-line arguments, logs, or a shared discovery file.
|
||||
let Some(token) = token.filter(|t| t.len() == 64 && t.bytes().all(|b| b.is_ascii_hexdigit())) else {
|
||||
eprintln!("filmcraft: control server disabled: FILMCRAFT_CONTROL_TOKEN must contain 64 random hex characters (see docs/control-protocol.md)");
|
||||
return rx;
|
||||
};
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
@@ -32,11 +45,11 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
}
|
||||
};
|
||||
eprintln!("filmcraft: control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx));
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx, token));
|
||||
rx
|
||||
}
|
||||
|
||||
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context, token: String) {
|
||||
let open = Arc::new(AtomicUsize::new(0));
|
||||
for mut stream in listener.incoming().flatten() {
|
||||
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
|
||||
@@ -46,12 +59,13 @@ fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Con
|
||||
}
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let token = token.clone();
|
||||
// The slot is given back when the guard drops: after `serve` returns, if it panics, or if
|
||||
// the thread can't be started (the closure, and with it the guard, is dropped).
|
||||
let slot = Slot(Arc::clone(&open));
|
||||
let spawned = std::thread::Builder::new().name("control-conn".into()).spawn(move || {
|
||||
let _slot = slot;
|
||||
serve(stream, &tx, &ctx);
|
||||
serve(stream, &tx, &ctx, &token);
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
eprintln!("filmcraft: control server could not start a connection thread: {e}");
|
||||
@@ -68,9 +82,9 @@ impl Drop for Slot {
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context) {
|
||||
fn serve(stream: TcpStream, tx: &Sender<ControlRequest>, ctx: &egui::Context, token: &str) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
serve_lines(read, stream, |method, params| {
|
||||
serve_lines(read, stream, token, |method, params| {
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
tx.send(req).ok()?;
|
||||
ctx.request_repaint();
|
||||
@@ -90,7 +104,7 @@ enum Line {
|
||||
Reject(String),
|
||||
}
|
||||
|
||||
fn parse_line(bytes: &[u8]) -> Line {
|
||||
fn parse_line(bytes: &[u8], token: &str) -> Line {
|
||||
let Ok(text) = std::str::from_utf8(bytes) else {
|
||||
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
|
||||
};
|
||||
@@ -108,6 +122,12 @@ fn parse_line(bytes: &[u8]) -> Line {
|
||||
let Some(method) = obj.get("method").and_then(Value::as_str) else {
|
||||
return Line::Reject("not a control request (missing string `method`); closing the connection".into());
|
||||
};
|
||||
// Authenticate every request before constructing or enqueueing a ControlRequest. A valid
|
||||
// earlier request must not authorize a later one with a missing or incorrect credential.
|
||||
let authenticated = obj.get("token").and_then(Value::as_str).is_some_and(|supplied| bool::from(supplied.as_bytes().ct_eq(token.as_bytes())));
|
||||
if !authenticated {
|
||||
return Line::Reject("control authentication failed; closing the connection".into());
|
||||
}
|
||||
Line::Request { id: obj.get("id").cloned().unwrap_or(Value::Null), method: method.to_string(), params: obj.get("params").cloned().unwrap_or(json!({})) }
|
||||
}
|
||||
|
||||
@@ -138,14 +158,14 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
|
||||
/// Serve requests from `read`, answering on `out`, until the peer closes, sends something that is
|
||||
/// not a request, or `handle` returns `None` (app gone). `handle` gets `(method, params)` and
|
||||
/// returns the reply object (the request's `id` is added here).
|
||||
fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(String, Value) -> Option<Value>) {
|
||||
fn serve_lines<R: Read, W: Write>(read: R, mut out: W, token: &str, mut handle: impl FnMut(String, Value) -> Option<Value>) {
|
||||
let mut reader = BufReader::new(read);
|
||||
let mut buf = Vec::new();
|
||||
loop {
|
||||
let reject = match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
|
||||
Ok(false) => return,
|
||||
Err(e) => e,
|
||||
Ok(true) => match parse_line(&buf) {
|
||||
Ok(true) => match parse_line(&buf, token) {
|
||||
Line::Blank => continue,
|
||||
Line::Reject(e) => e,
|
||||
Line::Request { id, method, params } => {
|
||||
@@ -169,10 +189,12 @@ fn serve_lines<R: Read, W: Write>(read: R, mut out: W, mut handle: impl FnMut(St
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const TOKEN: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
fn run(input: &[u8]) -> (Vec<Value>, Vec<String>) {
|
||||
let mut out = Vec::new();
|
||||
let mut calls = Vec::new();
|
||||
serve_lines(input, &mut out, |m, _p| {
|
||||
serve_lines(input, &mut out, TOKEN, |m, _p| {
|
||||
calls.push(m);
|
||||
Some(json!({"ok": true, "result": null}))
|
||||
});
|
||||
@@ -182,7 +204,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn requests_are_answered_in_order_with_ids() {
|
||||
let (r, calls) = run(b"{\"id\":1,\"method\":\"a\"}\n\n{\"id\":2,\"method\":\"b\",\"params\":{}}\r\n{\"method\":\"c\"}");
|
||||
let input = format!(
|
||||
"{{\"token\":\"{TOKEN}\",\"id\":1,\"method\":\"a\"}}\n\n{{\"token\":\"{TOKEN}\",\"id\":2,\"method\":\"b\",\"params\":{{}}}}\r\n{{\"token\":\"{TOKEN}\",\"method\":\"c\"}}"
|
||||
);
|
||||
let (r, calls) = run(input.as_bytes());
|
||||
assert_eq!(calls, ["a", "b", "c"]);
|
||||
assert_eq!(r.len(), 3);
|
||||
assert_eq!(r[0]["id"], 1);
|
||||
@@ -220,7 +245,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_request_before_junk_still_runs() {
|
||||
let (r, calls) = run(b"{\"method\":\"a\"}\nGET / HTTP/1.1\n{\"method\":\"b\"}\n");
|
||||
let input = format!("{{\"token\":\"{TOKEN}\",\"method\":\"a\"}}\nGET / HTTP/1.1\n{{\"token\":\"{TOKEN}\",\"method\":\"b\"}}\n");
|
||||
let (r, calls) = run(input.as_bytes());
|
||||
assert_eq!(calls, ["a"]);
|
||||
assert_eq!(r.len(), 2);
|
||||
assert_eq!(r[1]["ok"], false);
|
||||
@@ -256,11 +282,11 @@ mod tests {
|
||||
let server = std::thread::spawn(move || {
|
||||
let (s, _) = listener.accept().unwrap();
|
||||
let read = s.try_clone().unwrap();
|
||||
serve_lines(read, s, |_, _| Some(json!({"ok": true})));
|
||||
serve_lines(read, s, TOKEN, |_, _| Some(json!({"ok": true})));
|
||||
});
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(b"{\"id\":7,\"method\":\"x\"}\nhello\n{\"method\":\"y\"}\n").unwrap();
|
||||
c.write_all(format!("{{\"token\":\"{TOKEN}\",\"id\":7,\"method\":\"x\"}}\nhello\n{{\"token\":\"{TOKEN}\",\"method\":\"y\"}}\n").as_bytes()).unwrap();
|
||||
let mut text = String::new();
|
||||
c.read_to_string(&mut text).unwrap(); // returns at EOF: the server closed the connection
|
||||
server.join().unwrap();
|
||||
@@ -275,7 +301,7 @@ mod tests {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, _rx) = channel::<ControlRequest>();
|
||||
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
|
||||
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default(), TOKEN.into()));
|
||||
// Idle connections are accepted in order and held open.
|
||||
let held: Vec<TcpStream> = (0..16).map(|_| TcpStream::connect(addr).unwrap()).collect();
|
||||
let mut extra = TcpStream::connect(addr).unwrap();
|
||||
@@ -285,4 +311,91 @@ mod tests {
|
||||
assert!(line.contains("too many control connections"), "{line}");
|
||||
drop(held);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_invalid_token_leaves_the_listener_closed() {
|
||||
for token in [None, Some(String::new()), Some("a".repeat(63)), Some("a".repeat(65)), Some("z".repeat(64))] {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
drop(listener);
|
||||
let rx = start_with_token(port, egui::Context::default(), token);
|
||||
assert!(matches!(rx.try_recv(), Err(std::sync::mpsc::TryRecvError::Disconnected)));
|
||||
let _still_available = TcpListener::bind(("127.0.0.1", port)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unauthenticated_save_as_closes_before_any_command_runs() {
|
||||
for token in
|
||||
[Value::Null, json!(false), json!(42), json!([]), json!({}), json!(""), json!("a".repeat(64)), json!(&TOKEN[..63]), json!(format!("{TOKEN}0"))]
|
||||
{
|
||||
let mut request = json!({"id": 1, "method": "engine.execute", "params": {"command": "file.saveAs", "params": {"path": "/owner/project.fcproj"}}});
|
||||
if !token.is_null() {
|
||||
request["token"] = token;
|
||||
}
|
||||
// A valid request after a rejected one must never execute, either.
|
||||
let input = format!("{request}\n{}\n", json!({"token": TOKEN, "method": "ui.inspect"}));
|
||||
let (replies, calls) = run(input.as_bytes());
|
||||
assert!(calls.is_empty());
|
||||
assert_eq!(replies, [json!({"ok": false, "error": "control authentication failed; closing the connection"})]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authentication_is_required_on_every_request() {
|
||||
let input = format!(
|
||||
"{}\n{}\n{}\n",
|
||||
json!({"token": TOKEN, "method": "ui.inspect"}),
|
||||
json!({"method": "app.quit"}),
|
||||
json!({"token": TOKEN, "method": "app.quit"})
|
||||
);
|
||||
let (replies, calls) = run(input.as_bytes());
|
||||
assert_eq!(calls, ["ui.inspect"]);
|
||||
assert_eq!(replies.len(), 2);
|
||||
assert_eq!(replies[1]["ok"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tcp_authentication_happens_before_enqueueing() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (s, _) = listener.accept().unwrap();
|
||||
serve(s, &tx, &egui::Context::default(), TOKEN);
|
||||
});
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(b"{\"method\":\"engine.execute\",\"params\":{\"command\":\"file.saveAs\",\"params\":{\"path\":\"/owner/project.fcproj\"}}}\n").unwrap();
|
||||
c.shutdown(std::net::Shutdown::Write).unwrap();
|
||||
let mut reply = String::new();
|
||||
c.read_to_string(&mut reply).unwrap();
|
||||
server.join().unwrap();
|
||||
assert!(reply.contains("control authentication failed"));
|
||||
assert!(matches!(rx.try_recv(), Err(std::sync::mpsc::TryRecvError::Disconnected)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticated_tcp_request_reaches_the_ui_queue() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (s, _) = listener.accept().unwrap();
|
||||
serve(s, &tx, &egui::Context::default(), TOKEN);
|
||||
});
|
||||
let params = json!({"command": "file.saveAs", "params": {"path": "/owner/project.fcproj"}});
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
writeln!(c, "{}", json!({"id": 7, "token": TOKEN, "method": "engine.execute", "params": params})).unwrap();
|
||||
c.shutdown(std::net::Shutdown::Write).unwrap();
|
||||
let request = rx.recv_timeout(Duration::from_secs(5)).unwrap();
|
||||
assert_eq!(request.method, "engine.execute");
|
||||
assert_eq!(request.params, params);
|
||||
request.reply.send(json!({"ok": true})).unwrap();
|
||||
let mut reply = String::new();
|
||||
c.read_to_string(&mut reply).unwrap();
|
||||
server.join().unwrap();
|
||||
assert_eq!(serde_json::from_str::<Value>(&reply).unwrap(), json!({"id": 7, "ok": true}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +215,8 @@ fn desktop_file_content(app_id: &str, name: &str, exe: &str) -> String {
|
||||
out.push_str("StartupNotify=true\n");
|
||||
out.push_str(&format!("StartupWMClass={app_id}\n"));
|
||||
out.push_str("Categories=AudioVideo;Video;AudioVideoEditing;\n");
|
||||
out.push_str("MimeType=application/x-filmcraft;video/mp4;video/x-m4v;video/quicktime;video/x-matroska;video/webm;");
|
||||
out.push_str("video/vnd.avi;video/x-msvideo;application/mxf;video/mp2t;video/mpeg;video/3gpp;\n");
|
||||
out.push_str("Keywords=video;editor;film;timeline;color;grading;nle;\n");
|
||||
out
|
||||
}
|
||||
|
||||
+858
-22
@@ -1,33 +1,524 @@
|
||||
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
|
||||
//! Graphics start-up: the wgpu backends and adapter the window is created with, and the fallback
|
||||
//! when a start fails (docs/gaps.md G4, #820).
|
||||
//!
|
||||
//! A graphics driver can crash inside wgpu instance or device creation (Intel's Vulkan driver
|
||||
//! `igvk64.dll`, #512 #413; AMD's OpenGL driver `atio6axx.dll`, #477), where no Rust error
|
||||
//! handling runs, so every launch died the same way until the user set `WGPU_BACKEND` by hand.
|
||||
//!
|
||||
//! # The adapter
|
||||
//! In the default mode on Windows the adapter is wgpu's own choice, except for a GPU that Vulkan
|
||||
//! lists through two installed versions of one driver: there the newer one draws, because a
|
||||
//! window drawn through the stale one dies inside the driver (`amdvlk64.dll`; see
|
||||
//! `filmcraft_gpu::adapter`). That case is avoided up front instead of left to the fallback
|
||||
//! below, which would end in DirectX 12 only.
|
||||
//!
|
||||
//! # When a start counts as failed
|
||||
//! The data folder keeps the mode to start with and the failed starts in a row in it
|
||||
//! ([`STATE_FILE`]). Each start also leaves its own attempt file (`graphics-start/<pid>-<n>.json`,
|
||||
//! so concurrent launches never write the same file) that is removed once the window has painted
|
||||
//! two frames, when the process ends normally, or [`DEATH_WINDOW`] after launch, whatever happened
|
||||
//! by then. An attempt file found by a later launch therefore means a process that died within
|
||||
//! [`DEATH_WINDOW`] of its start without painting: a driver crash, typically. A start that hangs,
|
||||
//! or is force-quit or crashes later, is not counted; neither is another instance that is still
|
||||
//! starting (its attempt is younger than [`DEATH_WINDOW`] and is left alone). After
|
||||
//! [`FAILURES_TO_MOVE_ON`] such failures in a row in the same mode, the launch moves on to the next
|
||||
//! [`Mode`] of the platform's [`LADDER`] and keeps it; a start that paints resets the count. A
|
||||
//! start-up error that wgpu does report (no adapter, device creation failed) moves on at once,
|
||||
//! within the same process.
|
||||
//!
|
||||
//! # Getting back
|
||||
//! A fallback mode shows a notice with a reset button in the window (`graphics.notice.reset`,
|
||||
//! [`GraphicsStart::reset_to_default`]); `--graphics default` does the same from the command
|
||||
//! line, and `--graphics dx12|software` picks a mode by hand. An explicit `WGPU_BACKEND` leaves
|
||||
//! the saved state alone: the user is in charge.
|
||||
//!
|
||||
//! All files are written beside and renamed into place, so a reader never sees half a file; two
|
||||
//! instances updating [`STATE_FILE`] at the same moment can lose one update (a failure counted
|
||||
//! once less, a confirmation repeated by the next start), never corrupt it.
|
||||
|
||||
use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends};
|
||||
use std::io::Read as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Restrict the wgpu instance to the primary backends (Vulkan, DirectX 12, Metal), i.e. leave
|
||||
/// OpenGL out, unless `backend_override` (normally `Backends::from_env()`, i.e. `WGPU_BACKEND`)
|
||||
/// names other backends.
|
||||
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) {
|
||||
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
|
||||
// eframe's default includes GL. Creating that backend can crash inside AMD's
|
||||
// atio6axx.dll before adapter selection or any Rust error handling runs, so the
|
||||
// window never appears. Choosing an adapter afterwards is too late: only leaving GL
|
||||
// out of the instance avoids it. Keep Vulkan and DX12 both: forcing DX12 alone sends
|
||||
// shaders through FXC (which rejects fx.wgsl, silently falling back to the CPU) and
|
||||
// leaves machines without DX12 with no backend at all.
|
||||
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::PRIMARY);
|
||||
use eframe::{
|
||||
NativeOptions,
|
||||
egui_wgpu::WgpuSetup,
|
||||
wgpu::{self, Backends},
|
||||
};
|
||||
|
||||
/// The saved graphics state in the data folder: `{"mode": "<mode>", "failures": <n>}`.
|
||||
pub const STATE_FILE: &str = "graphics-start.json";
|
||||
/// The folder in the data folder with one attempt file per start that has not painted yet.
|
||||
pub const ATTEMPTS_DIR: &str = "graphics-start";
|
||||
/// How long after launch a start that has not painted may die and still count as failed. A
|
||||
/// driver crash in instance or device creation happens within a second or two.
|
||||
pub const DEATH_WINDOW: Duration = Duration::from_secs(15);
|
||||
/// Failed starts in a row, in the same mode, that move on to the next mode.
|
||||
pub const FAILURES_TO_MOVE_ON: u32 = 2;
|
||||
/// Attempt files looked at per launch; anything beyond is left for a later launch.
|
||||
const MAX_ATTEMPTS: usize = 64;
|
||||
/// The state and attempt files are a few bytes; anything longer is not ours.
|
||||
const MAX_FILE: u64 = 4096;
|
||||
|
||||
/// How the window's graphics device is created.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
/// The platform default: Vulkan or DirectX 12 on Windows (no OpenGL, see [`apply`]), Metal on
|
||||
/// macOS, Vulkan or OpenGL elsewhere.
|
||||
Default,
|
||||
/// DirectX 12 only (Windows), for Vulkan drivers that crash at start-up.
|
||||
Dx12,
|
||||
/// A software adapter (WARP on Windows, lavapipe / llvmpipe elsewhere); the preview then
|
||||
/// composites on the CPU (`FilmcraftApp::set_wgpu`).
|
||||
Software,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
/// The name in [`STATE_FILE`] and on the command line (`--graphics <name>`).
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Mode::Default => "default",
|
||||
Mode::Dx12 => "dx12",
|
||||
Mode::Software => "software",
|
||||
}
|
||||
}
|
||||
|
||||
/// The English name shown in the interface (translated there).
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Mode::Default => "default graphics",
|
||||
Mode::Dx12 => "DirectX 12 only",
|
||||
Mode::Software => "software rendering",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(name: &str) -> Option<Mode> {
|
||||
[Mode::Default, Mode::Dx12, Mode::Software].into_iter().find(|m| m.name() == name)
|
||||
}
|
||||
}
|
||||
|
||||
/// The modes tried in order on this platform. macOS has only Metal, and no software adapter.
|
||||
pub const LADDER: &[Mode] = if cfg!(target_os = "windows") {
|
||||
&[Mode::Default, Mode::Dx12, Mode::Software]
|
||||
} else if cfg!(target_os = "macos") {
|
||||
&[Mode::Default]
|
||||
} else {
|
||||
&[Mode::Default, Mode::Software]
|
||||
};
|
||||
|
||||
/// The mode after `mode` in `ladder`, if any.
|
||||
fn next_in(ladder: &[Mode], mode: Mode) -> Option<Mode> {
|
||||
let i = ladder.iter().position(|m| *m == mode)?;
|
||||
ladder.get(i.checked_add(1)?).copied()
|
||||
}
|
||||
|
||||
/// [`STATE_FILE`]: the mode to start with and the failed starts in a row in it.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct Saved {
|
||||
mode: Mode,
|
||||
failures: u32,
|
||||
}
|
||||
|
||||
/// An attempt file left by a start: its process id (from the file name), mode and launch time.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct Attempt {
|
||||
pid: u32,
|
||||
mode: Mode,
|
||||
/// Seconds since the Unix epoch.
|
||||
started: u64,
|
||||
}
|
||||
|
||||
/// What a launch makes of the saved state and the attempt files it found.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct Decision {
|
||||
/// The state to start with and save.
|
||||
saved: Saved,
|
||||
/// Moved on to the next mode because the last starts failed.
|
||||
moved_on: bool,
|
||||
/// Per attempt: done with (counted, or not ours to count), delete it.
|
||||
delete: Vec<bool>,
|
||||
}
|
||||
|
||||
/// This launch's mode from the saved state and the attempts other starts left. `now` is in Unix
|
||||
/// seconds, `pid` is this process (an attempt with our process id is a dead process whose id was
|
||||
/// reused), `window` is [`DEATH_WINDOW`] in seconds.
|
||||
fn decide(saved: Option<Saved>, attempts: &[Attempt], now: u64, pid: u32, window: u64, ladder: &[Mode]) -> Decision {
|
||||
let first = ladder.first().copied().unwrap_or(Mode::Default);
|
||||
// Nothing saved, or a mode this platform does not use (a data folder copied from another OS).
|
||||
let saved = saved.filter(|s| ladder.contains(&s.mode)).unwrap_or(Saved { mode: first, failures: 0 });
|
||||
let mut failures = saved.failures;
|
||||
let mut delete = Vec::with_capacity(attempts.len());
|
||||
for a in attempts {
|
||||
if a.started > now.saturating_add(window) {
|
||||
// From the future (a clock that jumped back): unknown, and not worth keeping.
|
||||
delete.push(true);
|
||||
continue;
|
||||
}
|
||||
let dead = a.pid == pid || now.saturating_sub(a.started) >= window;
|
||||
// A younger attempt is another instance starting right now: leave it to that instance.
|
||||
delete.push(dead);
|
||||
// Only failures of the mode we would start in count; older ones are history.
|
||||
if dead && a.mode == saved.mode {
|
||||
failures = failures.saturating_add(1);
|
||||
}
|
||||
}
|
||||
if failures >= FAILURES_TO_MOVE_ON {
|
||||
return match next_in(ladder, saved.mode) {
|
||||
Some(next) => Decision { saved: Saved { mode: next, failures: 0 }, moved_on: true, delete },
|
||||
// Nothing left to try: the last mode is still the best chance.
|
||||
None => Decision { saved: Saved { mode: saved.mode, failures: 0 }, moved_on: false, delete },
|
||||
};
|
||||
}
|
||||
Decision { saved: Saved { mode: saved.mode, failures }, moved_on: false, delete }
|
||||
}
|
||||
|
||||
fn read_small(path: &Path) -> Option<String> {
|
||||
let mut text = String::new();
|
||||
std::fs::File::open(path).ok()?.take(MAX_FILE).read_to_string(&mut text).ok()?;
|
||||
Some(text)
|
||||
}
|
||||
|
||||
/// [`Saved`] from its text; `None` for anything else. A missing failure count is none.
|
||||
fn parse_state(text: &str) -> Option<Saved> {
|
||||
let v: serde_json::Value = serde_json::from_str(text).ok()?;
|
||||
let mode = Mode::parse(v.get("mode")?.as_str()?)?;
|
||||
let failures = match v.get("failures") {
|
||||
None => 0,
|
||||
Some(n) => u32::try_from(n.as_u64()?).ok()?,
|
||||
};
|
||||
Some(Saved { mode, failures })
|
||||
}
|
||||
|
||||
fn state_json(s: Saved) -> String {
|
||||
serde_json::json!({ "mode": s.mode.name(), "failures": s.failures }).to_string()
|
||||
}
|
||||
|
||||
/// An attempt from its file name (`<pid>-<n>.json`) and text; `None` for anything else.
|
||||
fn parse_attempt(file_name: &str, text: &str) -> Option<Attempt> {
|
||||
let pid = file_name.strip_suffix(".json")?.split_once('-')?.0.parse().ok()?;
|
||||
let v: serde_json::Value = serde_json::from_str(text).ok()?;
|
||||
Some(Attempt { pid, mode: Mode::parse(v.get("mode")?.as_str()?)?, started: v.get("started")?.as_u64()? })
|
||||
}
|
||||
|
||||
fn attempt_json(mode: Mode, started: u64) -> String {
|
||||
serde_json::json!({ "mode": mode.name(), "started": started }).to_string()
|
||||
}
|
||||
|
||||
/// Write `text` beside `path` (a name only this process uses) and rename it into place, so a
|
||||
/// start that dies mid-write, or another instance reading, never sees half a file.
|
||||
fn write_atomic(path: &Path, text: &str) -> std::io::Result<()> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let mut tmp = path.as_os_str().to_owned();
|
||||
tmp.push(format!(".{}.tmp", std::process::id()));
|
||||
let tmp = PathBuf::from(tmp);
|
||||
std::fs::write(&tmp, text)?;
|
||||
std::fs::rename(&tmp, path).inspect_err(|_| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
})
|
||||
}
|
||||
|
||||
fn remove_quietly(path: &Path) {
|
||||
match std::fs::remove_file(path) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => log::warn!("can't remove {}: {e}", path.display()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The attempt files in `dir` (at most [`MAX_ATTEMPTS`]): path and contents, `None` when unreadable.
|
||||
fn list_attempts(dir: &Path) -> Vec<(PathBuf, Option<Attempt>)> {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
|
||||
entries
|
||||
.filter_map(Result::ok)
|
||||
.filter_map(|e| {
|
||||
let name = e.file_name().into_string().ok()?;
|
||||
name.ends_with(".json").then(|| {
|
||||
let path = e.path();
|
||||
let attempt = read_small(&path).and_then(|text| parse_attempt(&name, &text));
|
||||
(path, attempt)
|
||||
})
|
||||
})
|
||||
.take(MAX_ATTEMPTS)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn unix_now() -> u64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
|
||||
}
|
||||
|
||||
/// Attempt numbers within this process, so each attempt file has its own name.
|
||||
static NEXT_ATTEMPT: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
/// This launch's graphics mode and its files in the data folder.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GraphicsStart {
|
||||
/// [`STATE_FILE`]; `None` without a data folder or with `WGPU_BACKEND` set.
|
||||
state: Option<PathBuf>,
|
||||
/// This start's attempt file (with `state`).
|
||||
attempt: Option<PathBuf>,
|
||||
mode: Mode,
|
||||
/// [`Self::reset_to_default`] was used: a later [`Self::confirm`] must not save this mode over it.
|
||||
reset: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl GraphicsStart {
|
||||
/// Pick this launch's mode from the data folder and record the attempt. `forced`
|
||||
/// (`--graphics`) starts in that mode and saves it. `explicit_backend` (`WGPU_BACKEND` set)
|
||||
/// leaves the data folder alone and starts in `forced`, else [`Mode::Default`].
|
||||
pub fn begin(data_dir: Option<&Path>, explicit_backend: bool, forced: Option<Mode>) -> Self {
|
||||
let start = Self::begin_at(data_dir, explicit_backend, forced, unix_now(), std::process::id());
|
||||
if let Some(path) = start.attempt.clone() {
|
||||
expire_later(path);
|
||||
}
|
||||
start
|
||||
}
|
||||
|
||||
/// [`Self::begin`] at `now` (Unix seconds) as process `pid`, without the expiry timer.
|
||||
fn begin_at(data_dir: Option<&Path>, explicit_backend: bool, forced: Option<Mode>, now: u64, pid: u32) -> Self {
|
||||
let first = LADDER.first().copied().unwrap_or(Mode::Default);
|
||||
let forced = forced.map(|m| if LADDER.contains(&m) { m } else { first });
|
||||
let reset = Arc::new(AtomicBool::new(false));
|
||||
let Some(dir) = data_dir.filter(|_| !explicit_backend) else {
|
||||
return Self { state: None, attempt: None, mode: forced.unwrap_or(Mode::Default), reset };
|
||||
};
|
||||
let state = dir.join(STATE_FILE);
|
||||
let attempts_dir = dir.join(ATTEMPTS_DIR);
|
||||
let saved = read_small(&state).and_then(|t| parse_state(&t));
|
||||
let found = list_attempts(&attempts_dir);
|
||||
let known: Vec<Attempt> = found.iter().filter_map(|(_, a)| *a).collect();
|
||||
let decision = decide(saved, &known, now, pid, DEATH_WINDOW.as_secs(), LADDER);
|
||||
let mut to_delete = decision.delete.iter();
|
||||
for (path, attempt) in &found {
|
||||
// unreadable files in our folder are debris (a different version, a damaged disk)
|
||||
if attempt.is_none() || to_delete.next().copied().unwrap_or(false) {
|
||||
remove_quietly(path);
|
||||
}
|
||||
}
|
||||
let file = state.display();
|
||||
let next = match forced {
|
||||
Some(mode) => {
|
||||
log::info!("graphics mode {} chosen with --graphics", mode.name());
|
||||
Saved { mode, failures: 0 }
|
||||
}
|
||||
None => {
|
||||
if decision.moved_on {
|
||||
let failed = saved.map_or(first, |s| s.mode).name();
|
||||
log::warn!(
|
||||
"the last {FAILURES_TO_MOVE_ON} starts with graphics mode {failed} died before their window painted; starting with {} (`--graphics default` or the notice in the window goes back)",
|
||||
decision.saved.mode.name()
|
||||
);
|
||||
} else if decision.saved.failures > 0 {
|
||||
log::warn!("the last start with graphics mode {} died before its window painted", decision.saved.mode.name());
|
||||
} else if decision.saved.mode != Mode::Default {
|
||||
log::info!("graphics mode {} after earlier failed starts ({file}; `--graphics default` goes back)", decision.saved.mode.name());
|
||||
}
|
||||
decision.saved
|
||||
}
|
||||
};
|
||||
if saved != Some(next)
|
||||
&& let Err(e) = write_atomic(&state, &state_json(next))
|
||||
{
|
||||
log::warn!("can't write {file}: {e}");
|
||||
}
|
||||
let start = Self { state: Some(state), attempt: None, mode: next.mode, reset };
|
||||
start.with_new_attempt(&attempts_dir, now, pid)
|
||||
}
|
||||
|
||||
/// Record a new attempt file for the current mode.
|
||||
fn with_new_attempt(mut self, dir: &Path, now: u64, pid: u32) -> Self {
|
||||
let n = NEXT_ATTEMPT.fetch_add(1, Ordering::Relaxed);
|
||||
let path = dir.join(format!("{pid}-{n}.json"));
|
||||
match write_atomic(&path, &attempt_json(self.mode, now)) {
|
||||
Ok(()) => self.attempt = Some(path),
|
||||
Err(e) => log::warn!("can't write {}: {e}", path.display()),
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn mode(&self) -> Mode {
|
||||
self.mode
|
||||
}
|
||||
|
||||
/// Running in a fallback mode, i.e. not the platform default.
|
||||
pub fn degraded(&self) -> bool {
|
||||
self.mode != Mode::Default
|
||||
}
|
||||
|
||||
/// Move on to the next mode after a start-up error wgpu reported; `false` when none is left.
|
||||
/// The error is certain, so the next mode is saved at once.
|
||||
pub fn step_down(&mut self) -> bool {
|
||||
let Some(next) = next_in(LADDER, self.mode) else {
|
||||
return false;
|
||||
};
|
||||
log::warn!("graphics mode {} failed to start; trying {}", self.mode.name(), next.name());
|
||||
self.mode = next;
|
||||
if let Some(state) = self.state.clone() {
|
||||
self.save(&state, Saved { mode: next, failures: 0 });
|
||||
if let Some(old) = self.attempt.take() {
|
||||
remove_quietly(&old);
|
||||
if let Some(dir) = old.parent() {
|
||||
let path = self.clone().with_new_attempt(dir, unix_now(), std::process::id()).attempt;
|
||||
if let Some(path) = &path {
|
||||
expire_later(path.clone());
|
||||
}
|
||||
self.attempt = path;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The window is up: keep this mode, and the failed starts in a row are over.
|
||||
pub fn confirm(&self) {
|
||||
self.finish();
|
||||
let Some(state) = &self.state else { return };
|
||||
if self.reset.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let ok = Saved { mode: self.mode, failures: 0 };
|
||||
if read_small(state).and_then(|t| parse_state(&t)) != Some(ok) {
|
||||
self.save(state, ok);
|
||||
}
|
||||
}
|
||||
|
||||
/// This start is over, whether it painted or not (the window closed, or every mode failed
|
||||
/// with an error that was reported): its attempt no longer means a crash.
|
||||
pub fn finish(&self) {
|
||||
if let Some(path) = &self.attempt {
|
||||
remove_quietly(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Start with the default mode next time (the notice's reset button). This process keeps
|
||||
/// its mode: the graphics device can't be swapped under a running window.
|
||||
pub fn reset_to_default(&self) -> Result<(), String> {
|
||||
self.reset.store(true, Ordering::Relaxed);
|
||||
let Some(state) = &self.state else { return Ok(()) };
|
||||
let first = LADDER.first().copied().unwrap_or(Mode::Default);
|
||||
write_atomic(state, &state_json(Saved { mode: first, failures: 0 })).map_err(|e| format!("can't write {}: {e}", state.display()))?;
|
||||
log::info!("graphics mode reset to {} for the next start", first.name());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn save(&self, path: &Path, s: Saved) {
|
||||
if let Err(e) = write_atomic(path, &state_json(s)) {
|
||||
log::warn!("can't write {}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the attempt file at `path` [`DEATH_WINDOW`] from now: a start still alive then is not
|
||||
/// counted as a failed one, painted or not.
|
||||
fn expire_later(path: PathBuf) {
|
||||
let spawned = std::thread::Builder::new().name("graphics-start".into()).spawn(move || {
|
||||
let _ = std::panic::catch_unwind(move || {
|
||||
std::thread::sleep(DEATH_WINDOW);
|
||||
remove_quietly(&path);
|
||||
});
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("can't start the graphics start timer: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Configure `options` for `mode`, before eframe creates the wgpu instance. `backend_override`
|
||||
/// is normally `Backends::from_env()`, i.e. `WGPU_BACKEND`.
|
||||
pub fn apply(options: &mut NativeOptions, mode: Mode, backend_override: Option<Backends>) {
|
||||
apply_for(options, mode, backend_override, cfg!(target_os = "windows"));
|
||||
}
|
||||
|
||||
fn apply_for(options: &mut NativeOptions, mode: Mode, backend_override: Option<Backends>, windows: bool) {
|
||||
let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup else {
|
||||
return;
|
||||
};
|
||||
// Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only costs a
|
||||
// compute shader when the device is created, one that Metal can't compile on an Intel Iris
|
||||
// Pro: the device is lost and the window never opens. `WGPU_VALIDATION_INDIRECT_CALL=1` turns
|
||||
// the check back on.
|
||||
create.instance_descriptor.flags = create.instance_descriptor.flags.difference(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
|
||||
match mode {
|
||||
// eframe's default (`WGPU_BACKEND`, else PRIMARY | GL), except on Windows: there it
|
||||
// includes GL. Creating that backend can crash inside AMD's atio6axx.dll before adapter
|
||||
// selection or any Rust error handling runs, so the window never appears. Choosing an
|
||||
// adapter afterwards is too late: only leaving GL out of the instance avoids it. Keep
|
||||
// Vulkan and DX12 both: forcing DX12 alone sends shaders through FXC (which rejects
|
||||
// fx.wgsl, silently falling back to the CPU) and leaves machines without DX12 with no
|
||||
// backend at all.
|
||||
Mode::Default if windows => {
|
||||
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::PRIMARY);
|
||||
// wgpu's own choice of adapter, except for a GPU that Windows lists through two
|
||||
// versions of one driver: the first entry can be a stale one that crashes inside the
|
||||
// driver (`filmcraft_gpu::adapter`). A selector replaces `power_preference`
|
||||
// (`WGPU_POWER_PREF`), so it takes that along.
|
||||
let power = create.power_preference;
|
||||
create.native_adapter_selector =
|
||||
Some(Arc::new(move |adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>| filmcraft_gpu::adapter::pick(adapters, surface, power)));
|
||||
}
|
||||
Mode::Default => {}
|
||||
// Without Vulkan, whose driver is what crashes; GPU effects may fall back to the CPU.
|
||||
Mode::Dx12 => create.instance_descriptor.backends = Backends::DX12,
|
||||
Mode::Software => {
|
||||
create.instance_descriptor.backends = software_backends(windows);
|
||||
create.native_adapter_selector = Some(Arc::new(pick_software));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The backends a software adapter is looked for in: WARP is a DX12 adapter, and GL stays out on
|
||||
/// Windows for the reason in [`apply_for`].
|
||||
fn software_backends(windows: bool) -> Backends {
|
||||
if windows { Backends::DX12 } else { Backends::VULKAN | Backends::GL }
|
||||
}
|
||||
|
||||
/// The first software adapter that can draw to the window's surface.
|
||||
fn pick_software(adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>) -> Result<wgpu::Adapter, String> {
|
||||
adapters
|
||||
.iter()
|
||||
.find(|a| filmcraft_ui_egui::software_rasterizer(&a.get_info()).is_some() && surface.is_none_or(|s| a.is_surface_supported(s)))
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("no software graphics adapter (WARP, lavapipe, llvmpipe) among {} adapters", adapters.len()))
|
||||
}
|
||||
|
||||
/// Whether `err` from `eframe::run_native` is a graphics start-up failure that another mode may
|
||||
/// avoid (as opposed to a display, event-loop or app error).
|
||||
pub fn is_graphics_failure(err: &eframe::Error) -> bool {
|
||||
matches!(err, eframe::Error::Wgpu(_))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn configured_backends(backend_override: Option<Backends>) -> Backends {
|
||||
const WINDOWS: &[Mode] = &[Mode::Default, Mode::Dx12, Mode::Software];
|
||||
const LINUX: &[Mode] = &[Mode::Default, Mode::Software];
|
||||
const MACOS: &[Mode] = &[Mode::Default];
|
||||
|
||||
fn configured(mode: Mode, backend_override: Option<Backends>, windows: bool) -> (Backends, bool) {
|
||||
let mut options = NativeOptions::default();
|
||||
configure(&mut options, backend_override);
|
||||
apply_for(&mut options, mode, backend_override, windows);
|
||||
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
create.instance_descriptor.backends
|
||||
(create.instance_descriptor.backends, create.native_adapter_selector.is_some())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_mode_validates_indirect_calls() {
|
||||
if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_some() {
|
||||
return;
|
||||
}
|
||||
for mode in [Mode::Default, Mode::Dx12, Mode::Software] {
|
||||
let mut options = NativeOptions::default();
|
||||
apply_for(&mut options, mode, None, false);
|
||||
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
// Intel Metal can't compile the validation's compute shader, and the device is lost.
|
||||
assert!(!create.instance_descriptor.flags.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL), "{mode:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -37,16 +528,361 @@ mod tests {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL));
|
||||
let configured = configured_backends(None);
|
||||
assert_eq!(configured, Backends::PRIMARY);
|
||||
assert!(!configured.contains(Backends::GL));
|
||||
assert!(configured.contains(Backends::DX12) && configured.contains(Backends::VULKAN));
|
||||
let (backends, selector) = configured(Mode::Default, None, true);
|
||||
assert_eq!(backends, Backends::PRIMARY);
|
||||
assert!(!backends.contains(Backends::GL));
|
||||
assert!(backends.contains(Backends::DX12) && backends.contains(Backends::VULKAN));
|
||||
// and chooses the adapter itself: a GPU listed through two versions of one driver
|
||||
assert!(selector);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_windows_default_and_software_modes_choose_the_adapter() {
|
||||
for backend_override in [None, Some(Backends::VULKAN), Some(Backends::DX12)] {
|
||||
assert!(configured(Mode::Default, backend_override, true).1);
|
||||
// elsewhere, and in DirectX 12 only mode, the adapter is wgpu's own choice
|
||||
assert!(!configured(Mode::Default, backend_override, false).1);
|
||||
assert!(!configured(Mode::Dx12, backend_override, true).1);
|
||||
}
|
||||
assert!(configured(Mode::Software, None, true).1 && configured(Mode::Software, None, false).1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_backend_override_is_preserved() {
|
||||
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
|
||||
assert_eq!(configured_backends(Some(backends)), backends);
|
||||
assert_eq!(configured(Mode::Default, Some(backends), true).0, backends);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_modes_leave_out_the_crashing_backends() {
|
||||
// DX12 only: Intel's and AMD's Vulkan drivers are what crashed (#512, #413).
|
||||
assert_eq!(configured(Mode::Dx12, None, true), (Backends::DX12, false));
|
||||
// Software on Windows is WARP (DX12), never GL (atio6axx.dll, #477).
|
||||
assert_eq!(configured(Mode::Software, None, true), (Backends::DX12, true));
|
||||
assert_eq!(configured(Mode::Software, None, false), (Backends::VULKAN | Backends::GL, true));
|
||||
// Off Windows the default is eframe's own.
|
||||
let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else {
|
||||
panic!("default native options must create a wgpu instance");
|
||||
};
|
||||
assert_eq!(configured(Mode::Default, None, false), (eframe_default.instance_descriptor.backends, false));
|
||||
}
|
||||
|
||||
const W: u64 = 15;
|
||||
const NOW: u64 = 1_000_000;
|
||||
const ME: u32 = 7;
|
||||
|
||||
fn saved(mode: Mode, failures: u32) -> Option<Saved> {
|
||||
Some(Saved { mode, failures })
|
||||
}
|
||||
|
||||
fn dead(mode: Mode) -> Attempt {
|
||||
Attempt { pid: 1, mode, started: NOW - 100 }
|
||||
}
|
||||
|
||||
fn mode_after(s: Option<Saved>, attempts: &[Attempt], ladder: &[Mode]) -> (Mode, u32, bool) {
|
||||
let d = decide(s, attempts, NOW, ME, W, ladder);
|
||||
(d.saved.mode, d.saved.failures, d.moved_on)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_failed_starts_in_a_row_move_on_to_the_next_mode_and_keep_it() {
|
||||
// first launch
|
||||
assert_eq!(mode_after(None, &[], WINDOWS), (Mode::Default, 0, false));
|
||||
// one start died: counted, but the same mode is tried again
|
||||
assert_eq!(mode_after(None, &[dead(Mode::Default)], WINDOWS), (Mode::Default, 1, false));
|
||||
// the second in a row moves on: DX12, then software
|
||||
assert_eq!(mode_after(saved(Mode::Default, 1), &[dead(Mode::Default)], WINDOWS), (Mode::Dx12, 0, true));
|
||||
assert_eq!(mode_after(saved(Mode::Dx12, 1), &[dead(Mode::Dx12)], WINDOWS), (Mode::Software, 0, true));
|
||||
assert_eq!(mode_after(saved(Mode::Default, 1), &[dead(Mode::Default)], LINUX), (Mode::Software, 0, true));
|
||||
// two dead instances found at once count as two
|
||||
assert_eq!(mode_after(None, &[dead(Mode::Default), dead(Mode::Default)], WINDOWS), (Mode::Dx12, 0, true));
|
||||
// a saved mode is kept
|
||||
assert_eq!(mode_after(saved(Mode::Dx12, 0), &[], WINDOWS), (Mode::Dx12, 0, false));
|
||||
assert_eq!(mode_after(saved(Mode::Default, 1), &[], WINDOWS), (Mode::Default, 1, false));
|
||||
// nothing left to try: stay
|
||||
assert_eq!(mode_after(saved(Mode::Software, 1), &[dead(Mode::Software)], WINDOWS), (Mode::Software, 0, false));
|
||||
assert_eq!(mode_after(saved(Mode::Default, 5), &[dead(Mode::Default)], MACOS), (Mode::Default, 0, false));
|
||||
// a mode this platform has not: start over
|
||||
assert_eq!(mode_after(saved(Mode::Dx12, 1), &[dead(Mode::Dx12)], LINUX), (Mode::Default, 0, false));
|
||||
assert_eq!(mode_after(saved(Mode::Software, 0), &[], MACOS), (Mode::Default, 0, false));
|
||||
assert_eq!(mode_after(None, &[], &[]), (Mode::Default, 0, false));
|
||||
// a hostile count doesn't overflow
|
||||
assert_eq!(mode_after(saved(Mode::Default, u32::MAX), &[dead(Mode::Default)], WINDOWS), (Mode::Dx12, 0, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_starts_that_died_soon_after_launch_in_the_current_mode_count() {
|
||||
let starting = Attempt { pid: 2, mode: Mode::Default, started: NOW - 3 };
|
||||
let just_now = Attempt { pid: 3, mode: Mode::Default, started: NOW };
|
||||
let future = Attempt { pid: 4, mode: Mode::Default, started: NOW + 3600 };
|
||||
let older_mode = dead(Mode::Dx12);
|
||||
let mine = Attempt { pid: ME, mode: Mode::Default, started: NOW - 1 };
|
||||
let d = decide(saved(Mode::Default, 0), &[starting, just_now, future, older_mode, mine], NOW, ME, W, WINDOWS);
|
||||
// another instance still starting is left alone; one from the future is dropped; a
|
||||
// failure in another mode is history; our own process id can only be a dead process
|
||||
assert_eq!(d.delete, [false, false, true, true, true]);
|
||||
assert_eq!((d.saved, d.moved_on), (Saved { mode: Mode::Default, failures: 1 }, false));
|
||||
// the clock a little behind another instance's: still starting
|
||||
let ahead = Attempt { pid: 5, mode: Mode::Default, started: NOW + 2 };
|
||||
assert_eq!(decide(None, &[ahead], NOW, ME, W, WINDOWS).delete, [false]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_files_round_trip_and_reject_anything_else() {
|
||||
for mode in WINDOWS {
|
||||
for failures in [0, 1, 2, u32::MAX] {
|
||||
assert_eq!(parse_state(&state_json(Saved { mode: *mode, failures })), saved(*mode, failures));
|
||||
}
|
||||
assert_eq!(parse_attempt("12-0.json", &attempt_json(*mode, NOW)), Some(Attempt { pid: 12, mode: *mode, started: NOW }));
|
||||
}
|
||||
// without a count (an earlier build of this change wrote `pending`): none
|
||||
assert_eq!(parse_state(r#"{"mode":"dx12","pending":true}"#), saved(Mode::Dx12, 0));
|
||||
for bad in [
|
||||
"",
|
||||
"{",
|
||||
"null",
|
||||
"[]",
|
||||
r#"{"mode":"vulkan","failures":1}"#,
|
||||
r#"{"failures":1}"#,
|
||||
r#"{"mode":1,"failures":1}"#,
|
||||
r#"{"mode":"dx12","failures":"one"}"#,
|
||||
r#"{"mode":"dx12","failures":-1}"#,
|
||||
r#"{"mode":"dx12","failures":4294967296}"#,
|
||||
] {
|
||||
assert_eq!(parse_state(bad), None, "{bad}");
|
||||
}
|
||||
let good = attempt_json(Mode::Default, NOW);
|
||||
for (name, text) in [
|
||||
("12-0.json", "{"),
|
||||
("12-0.json", r#"{"mode":"default"}"#),
|
||||
("12-0.json", r#"{"mode":"default","started":-5}"#),
|
||||
("x-0.json", good.as_str()),
|
||||
("12.json", good.as_str()),
|
||||
("-0.json", good.as_str()),
|
||||
("99999999999-0.json", good.as_str()),
|
||||
("12-0.txt", good.as_str()),
|
||||
] {
|
||||
assert_eq!(parse_attempt(name, text), None, "{name} {text}");
|
||||
}
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("filmcraft-graphics-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
dir
|
||||
}
|
||||
|
||||
fn state_of(dir: &Path) -> Option<Saved> {
|
||||
read_small(&dir.join(STATE_FILE)).and_then(|t| parse_state(&t))
|
||||
}
|
||||
|
||||
fn attempt_files(dir: &Path) -> usize {
|
||||
std::fs::read_dir(dir.join(ATTEMPTS_DIR)).map_or(0, |d| d.filter_map(Result::ok).count())
|
||||
}
|
||||
|
||||
/// A launch as another process (`pid`) at `now`.
|
||||
fn launch(dir: &Path, pid: u32, now: u64) -> GraphicsStart {
|
||||
GraphicsStart::begin_at(Some(dir), false, None, now, pid)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_start_that_paints_keeps_its_mode_and_clears_its_attempt() {
|
||||
let dir = temp_dir("paint");
|
||||
let start = launch(&dir, 10, NOW);
|
||||
assert_eq!(start.mode(), Mode::Default);
|
||||
assert!(!start.degraded());
|
||||
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
|
||||
assert_eq!(attempt_files(&dir), 1);
|
||||
start.confirm();
|
||||
assert_eq!(attempt_files(&dir), 0);
|
||||
// a window closed before it painted is no failure either
|
||||
let start = launch(&dir, 11, NOW + 60);
|
||||
start.finish();
|
||||
assert_eq!(attempt_files(&dir), 0);
|
||||
assert_eq!(launch(&dir, 12, NOW + 120).mode(), Mode::Default);
|
||||
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_dead_starts_move_on_and_a_painted_one_resets_the_count() {
|
||||
let dir = temp_dir("dead");
|
||||
let second = LADDER.get(1).copied().unwrap_or(Mode::Default);
|
||||
// dies without painting
|
||||
let _ = launch(&dir, 20, NOW);
|
||||
// the next launch counts it, tries the same mode again and dies too
|
||||
let again = launch(&dir, 21, NOW + 60);
|
||||
assert_eq!(again.mode(), Mode::Default);
|
||||
assert_eq!(state_of(&dir), saved(Mode::Default, 1));
|
||||
// two in a row: the next mode, kept
|
||||
let moved = launch(&dir, 22, NOW + 120);
|
||||
assert_eq!(moved.mode(), second);
|
||||
assert_eq!(moved.degraded(), second != Mode::Default);
|
||||
moved.confirm();
|
||||
assert_eq!(launch(&dir, 23, NOW + 180).mode(), second);
|
||||
// one failure, then a start that paints: the count starts over
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _ = launch(&dir, 30, NOW);
|
||||
let painted = launch(&dir, 31, NOW + 60);
|
||||
painted.confirm();
|
||||
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
|
||||
let _ = launch(&dir, 32, NOW + 120);
|
||||
assert_eq!(launch(&dir, 33, NOW + 180).mode(), Mode::Default);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_launches_neither_count_nor_disturb_each_other() {
|
||||
let dir = temp_dir("race");
|
||||
// two instances start within the same seconds; neither has painted yet
|
||||
let a = launch(&dir, 40, NOW);
|
||||
let b = launch(&dir, 41, NOW + 1);
|
||||
let c = launch(&dir, 42, NOW + 2);
|
||||
assert_eq!((a.mode(), b.mode(), c.mode()), (Mode::Default, Mode::Default, Mode::Default));
|
||||
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
|
||||
assert_eq!(attempt_files(&dir), 3, "each instance has its own attempt file");
|
||||
b.confirm();
|
||||
a.confirm();
|
||||
c.confirm();
|
||||
assert_eq!(attempt_files(&dir), 0);
|
||||
assert_eq!(launch(&dir, 43, NOW + 60).mode(), Mode::Default);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_huge_or_foreign_files_start_with_the_default() {
|
||||
let dir = temp_dir("corrupt");
|
||||
let _ = std::fs::create_dir_all(dir.join(ATTEMPTS_DIR));
|
||||
let huge = format!(r#"{{"mode":"software","failures":1,"pad":"{}"}}"#, "x".repeat(10_000));
|
||||
for text in ["garbage", huge.as_str()] {
|
||||
let _ = std::fs::write(dir.join(STATE_FILE), text);
|
||||
let _ = std::fs::write(dir.join(ATTEMPTS_DIR).join("50-0.json"), text);
|
||||
let _ = std::fs::write(dir.join(ATTEMPTS_DIR).join("notes.txt"), text);
|
||||
let start = launch(&dir, 51, NOW);
|
||||
assert_eq!(start.mode(), Mode::Default);
|
||||
// replaced with a well-formed state; the unreadable attempt is gone, other files stay
|
||||
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
|
||||
assert!(!dir.join(ATTEMPTS_DIR).join("50-0.json").exists());
|
||||
assert!(dir.join(ATTEMPTS_DIR).join("notes.txt").exists());
|
||||
start.finish();
|
||||
}
|
||||
// a data folder that is a file: nothing can be written, the default still starts
|
||||
let file = dir.join("not-a-folder");
|
||||
let _ = std::fs::write(&file, "x");
|
||||
let mut start = GraphicsStart::begin_at(Some(&file), false, None, NOW, 52);
|
||||
assert_eq!(start.mode(), Mode::Default);
|
||||
start.confirm();
|
||||
let _ = start.step_down();
|
||||
assert!(start.reset_to_default().is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_attempt_files_are_bounded_per_launch() {
|
||||
let dir = temp_dir("many");
|
||||
let attempts = dir.join(ATTEMPTS_DIR);
|
||||
let _ = std::fs::create_dir_all(&attempts);
|
||||
let n = MAX_ATTEMPTS + 10;
|
||||
for pid in 0..n {
|
||||
let _ = std::fs::write(attempts.join(format!("{}-0.json", pid + 1000)), attempt_json(Mode::Software, NOW + 1));
|
||||
}
|
||||
// all of them belong to instances still starting: none is counted or removed
|
||||
let start = launch(&dir, 60, NOW);
|
||||
assert_eq!(start.mode(), Mode::Default);
|
||||
assert_eq!(attempt_files(&dir), n + 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphics_flag_picks_the_mode_and_default_undoes_a_fallback() {
|
||||
let dir = temp_dir("forced");
|
||||
let last = LADDER.last().copied().unwrap_or(Mode::Default);
|
||||
let _ = write_atomic(&dir.join(STATE_FILE), &state_json(Saved { mode: last, failures: 1 }));
|
||||
launch(&dir, 70, NOW).confirm();
|
||||
// `--graphics default`
|
||||
let start = GraphicsStart::begin_at(Some(&dir), false, Some(Mode::Default), NOW + 60, 71);
|
||||
assert_eq!(start.mode(), Mode::Default);
|
||||
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
|
||||
start.confirm();
|
||||
assert_eq!(launch(&dir, 72, NOW + 120).mode(), Mode::Default);
|
||||
// `--graphics <the last mode>` is kept for the next launches
|
||||
GraphicsStart::begin_at(Some(&dir), false, Some(last), NOW + 180, 73).confirm();
|
||||
assert_eq!(launch(&dir, 74, NOW + 240).mode(), last);
|
||||
// a mode this platform has not starts with the first one
|
||||
if !LADDER.contains(&Mode::Dx12) {
|
||||
assert_eq!(GraphicsStart::begin_at(Some(&dir), false, Some(Mode::Dx12), NOW + 300, 75).mode(), Mode::Default);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_to_default_wins_over_a_later_confirm() {
|
||||
let dir = temp_dir("reset");
|
||||
let last = LADDER.last().copied().unwrap_or(Mode::Default);
|
||||
let _ = write_atomic(&dir.join(STATE_FILE), &state_json(Saved { mode: last, failures: 0 }));
|
||||
let start = launch(&dir, 80, NOW);
|
||||
assert_eq!(start.mode(), last);
|
||||
// the notice's button, from a clone of the start (the app holds one)
|
||||
assert_eq!(start.clone().reset_to_default(), Ok(()));
|
||||
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
|
||||
start.confirm();
|
||||
assert_eq!(state_of(&dir), saved(Mode::Default, 0));
|
||||
assert_eq!(launch(&dir, 81, NOW + 60).mode(), Mode::Default);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_explicit_backend_leaves_the_data_folder_alone() {
|
||||
let dir = temp_dir("explicit");
|
||||
let _ = write_atomic(&dir.join(STATE_FILE), &state_json(Saved { mode: Mode::Software, failures: 1 }));
|
||||
let mut start = GraphicsStart::begin_at(Some(&dir), true, None, NOW, 90);
|
||||
assert_eq!(start.mode(), Mode::Default);
|
||||
start.confirm();
|
||||
let _ = start.step_down();
|
||||
assert_eq!(start.reset_to_default(), Ok(()));
|
||||
assert_eq!(state_of(&dir), saved(Mode::Software, 1));
|
||||
assert_eq!(attempt_files(&dir), 0);
|
||||
// with --graphics too, that mode starts, still without touching the folder
|
||||
let last = LADDER.last().copied().unwrap_or(Mode::Default);
|
||||
assert_eq!(GraphicsStart::begin_at(Some(&dir), true, Some(last), NOW, 91).mode(), last);
|
||||
assert_eq!(state_of(&dir), saved(Mode::Software, 1));
|
||||
// no data folder: nothing is written, the default still starts
|
||||
assert_eq!(GraphicsStart::begin_at(None, false, None, NOW, 92).mode(), Mode::Default);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_down_walks_the_ladder_and_saves_each_mode_at_once() {
|
||||
let dir = temp_dir("step");
|
||||
let mut start = launch(&dir, ME, NOW);
|
||||
for expected in LADDER.iter().skip(1) {
|
||||
assert!(start.step_down());
|
||||
assert_eq!(start.mode(), *expected);
|
||||
assert_eq!(state_of(&dir), saved(*expected, 0));
|
||||
assert_eq!(attempt_files(&dir), 1, "one attempt file per process");
|
||||
}
|
||||
assert!(!start.step_down(), "nothing after the last mode");
|
||||
start.finish();
|
||||
assert_eq!(attempt_files(&dir), 0);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_software_selector_only_picks_a_software_adapter() {
|
||||
if wgpu::Instance::enabled_backend_features().is_empty() {
|
||||
return;
|
||||
}
|
||||
// only the backends software mode uses: no driver this test loads is one the app avoids
|
||||
let backends = software_backends(cfg!(target_os = "windows"));
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends, ..wgpu::InstanceDescriptor::new_without_display_handle() });
|
||||
let adapters = pollster::block_on(instance.enumerate_adapters(backends));
|
||||
match pick_software(&adapters, None) {
|
||||
Ok(adapter) => assert!(filmcraft_ui_egui::software_rasterizer(&adapter.get_info()).is_some()),
|
||||
Err(why) => {
|
||||
assert!(adapters.iter().all(|a| filmcraft_ui_egui::software_rasterizer(&a.get_info()).is_none()), "{why}");
|
||||
}
|
||||
}
|
||||
assert!(pick_software(&[], None).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,29 +97,35 @@ fn matches(name: &str, target: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch; a clock
|
||||
/// set absurdly far ahead keeps its real year (years far outside chrono's own range are preserved)
|
||||
/// instead of panicking.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
let (y, dt) = utc_parts(since.as_secs(), since.subsec_nanos());
|
||||
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%S%.3fZ"))
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
|
||||
/// clock can't overflow).
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days.saturating_add(719_468);
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
||||
(y, m, d)
|
||||
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
|
||||
/// chrono's range by whole 400-year cycles). The Gregorian calendar repeats every 400 years
|
||||
/// (146_097 days), so month, day and time of day come from chrono and only the year is carried,
|
||||
/// which keeps every input exact instead of collapsing out-of-range years to the epoch. `secs` is
|
||||
/// `u64` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`.
|
||||
fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
|
||||
use chrono::Datelike;
|
||||
let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64);
|
||||
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
|
||||
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
||||
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
|
||||
match (date, time) {
|
||||
(Some(d), Some(t)) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t))
|
||||
}
|
||||
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
@@ -390,6 +396,17 @@ mod tests {
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
// A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than
|
||||
// collapsing to the epoch (reviewer FredApps). Windows' SystemTime can't hold such an
|
||||
// instant, so it is built with a checked add and only asserted where it exists.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
|
||||
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
|
||||
}
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
|
||||
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
|
||||
}
|
||||
// Years past 9999 keep the plain zero-padded year (no `+`).
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+322
-161
@@ -8,6 +8,7 @@
|
||||
//! (the demo project), Open Most Recent, or an empty project.
|
||||
//!
|
||||
//! `--control <port>` (or `FILMCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;
|
||||
//! `FILMCRAFT_CONTROL_TOKEN` is required (see `docs/control-protocol.md`).
|
||||
//! see `filmcraft_ui_egui::control` for the methods. A port that is not a number is an error from
|
||||
//! either source.
|
||||
//!
|
||||
@@ -31,7 +32,6 @@ mod control_server;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod dev_icon;
|
||||
mod file_filters;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
mod graphics;
|
||||
mod logging;
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -44,6 +44,8 @@ use filmcraft_engine::Session;
|
||||
use filmcraft_engine::autosave::{AutosaveConfig, default_data_dir};
|
||||
use filmcraft_ui_egui::FilmcraftApp;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Reverse-DNS app id: Linux `.desktop` file, hicolor icon name and Wayland app id.
|
||||
const APP_ID: &str = "ai.storyteller.filmcraft";
|
||||
@@ -80,7 +82,7 @@ fn main() -> eframe::Result {
|
||||
let logger = logging::install();
|
||||
// lossy, so a value that is not Unicode is reported like any other bad port instead of ignored
|
||||
let env_port = std::env::var_os("FILMCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned());
|
||||
let Launch { control_port, control_input_only, files, demo, startup_flag, recover, data_dir } = match args::parse(env_port, std::env::args().skip(1)) {
|
||||
let launch = match args::parse(env_port, std::env::args_os().skip(1)) {
|
||||
Ok(Cli::Run(l)) => l,
|
||||
Ok(Cli::Help) => {
|
||||
tell(args::USAGE, false);
|
||||
@@ -95,7 +97,8 @@ fn main() -> eframe::Result {
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
let log_dir = data_dir.clone().or_else(default_data_dir).map(|d| d.join(logging::LOG_DIR));
|
||||
let data_root = launch.data_dir.clone().or_else(default_data_dir);
|
||||
let log_dir = data_root.as_ref().map(|d| d.join(logging::LOG_DIR));
|
||||
log::info!("FilmCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH);
|
||||
// The log file lives next to the crash logs; opened after the arguments, so `--help`,
|
||||
// `--version` and usage errors leave no file behind. Records logged until now go in first.
|
||||
@@ -118,165 +121,38 @@ fn main() -> eframe::Result {
|
||||
// OS hardware video decoders (VideoToolbox on macOS) in front of our own; Settings ▸ Playback ▸
|
||||
// Hardware decoding switches them off. Unsupported streams and failures use our decoders.
|
||||
register_hardware_decoders();
|
||||
register_gpu_frame_renderer();
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("FilmCraft")
|
||||
.with_inner_size([1600.0, 980.0])
|
||||
.with_min_inner_size([900.0, 560.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
// Started for an agent (`--control`): open without taking the user's keyboard focus.
|
||||
.with_active(control_port.is_none())
|
||||
.with_icon(app_icon())
|
||||
// Wayland matches the window to `packaging/linux/ai.storyteller.filmcraft.desktop`.
|
||||
.with_app_id(APP_ID),
|
||||
event_loop_builder: agent_event_loop(control_port.is_some()),
|
||||
..Default::default()
|
||||
// Which graphics backends and adapter the window starts with: `--graphics`, else the saved
|
||||
// mode, which moves on after starts that died before their window painted (a driver crash),
|
||||
// see graphics.rs.
|
||||
let backend_override = eframe::wgpu::Backends::from_env();
|
||||
let mut graphics = graphics::GraphicsStart::begin(data_root.as_deref(), backend_override.is_some(), launch.graphics);
|
||||
// The GPU export renderer creates its own wgpu instance with every backend; after a start
|
||||
// fell back, that would load the driver that failed. Exports then composite on the CPU.
|
||||
if graphics.mode() == graphics::Mode::Default {
|
||||
register_gpu_frame_renderer();
|
||||
}
|
||||
let started = loop {
|
||||
let mut options = native_options(launch.control_port.is_some());
|
||||
// Before eframe creates the wgpu instance (e.g. no OpenGL on Windows, see graphics.rs).
|
||||
graphics::apply(&mut options, graphics.mode(), backend_override);
|
||||
let app_created = Arc::new(AtomicBool::new(false));
|
||||
let result = eframe::run_native("FilmCraft", options, app_creator(launch.clone(), graphics.clone(), Arc::clone(&app_created)));
|
||||
let app_created = app_created.load(Ordering::Relaxed);
|
||||
match &result {
|
||||
// wgpu reported the failure (no adapter, device creation failed) before the app
|
||||
// existed: try the next mode in this process.
|
||||
Err(e) if !app_created && graphics::is_graphics_failure(e) && graphics.step_down() => {
|
||||
log::warn!("graphics start failed: {e}");
|
||||
continue;
|
||||
}
|
||||
// Closed before the second frame: the window still came up.
|
||||
Ok(()) if app_created => graphics.confirm(),
|
||||
_ => {}
|
||||
}
|
||||
break result;
|
||||
};
|
||||
// Before eframe creates the wgpu instance: leave OpenGL out on Windows (see graphics.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
let options = {
|
||||
let mut options = options;
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
|
||||
options
|
||||
};
|
||||
let started = eframe::run_native(
|
||||
"FilmCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let mut session = Session::default();
|
||||
if let Some(dir) = data_dir.clone().or_else(default_data_dir) {
|
||||
let mut cfg = AutosaveConfig::new(dir);
|
||||
cfg.local_offset = local_offset;
|
||||
if let Err(e) = session.start_autosave(cfg) {
|
||||
log::warn!("auto-save and crash recovery unavailable: {e}");
|
||||
}
|
||||
}
|
||||
// voice-over recording reads the microphone through cpal
|
||||
session.voiceover.input = Some(Box::new(audio_in::CpalIn::new(&session.prefs.audio_hardware.device_class)));
|
||||
let project = files.iter().find(|f| f.ends_with(".fcproj")).cloned();
|
||||
if let Some(p) = project {
|
||||
if let Err(e) = session.execute("file.open", json!({"path": p})) {
|
||||
log::error!("{e}");
|
||||
}
|
||||
} else if !startup_flag && session.prefs.general.at_startup == "openMostRecent" {
|
||||
// Settings ▸ General ▸ At Startup ▸ Open Most Recent
|
||||
let recent = session.prefs.general.recent_projects.iter().find(|p| std::path::Path::new(p).exists()).cloned();
|
||||
match recent {
|
||||
Some(p) => {
|
||||
if let Err(e) = session.execute("file.open", json!({"path": p})) {
|
||||
log::error!("{e}");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let _ = session.execute("file.openDemoProject", json!({}));
|
||||
}
|
||||
}
|
||||
} else if !startup_flag && session.prefs.general.at_startup == "emptyProject" {
|
||||
} else if demo {
|
||||
let _ = session.execute("file.openDemoProject", json!({}));
|
||||
}
|
||||
let media: Vec<String> = files.iter().filter(|f| !f.ends_with(".fcproj")).cloned().collect();
|
||||
if !media.is_empty() {
|
||||
let _ = session.execute("file.import", json!({"paths": media}));
|
||||
}
|
||||
if recover == Some(true) && !session.recovery_candidates().is_empty() {
|
||||
let id = session.recovery_candidates()[0].id.clone();
|
||||
match session.execute("file.recover", json!({"id": id})) {
|
||||
Ok(r) => log::info!("recovered {r}"),
|
||||
Err(e) => log::error!("recovery failed: {e}"),
|
||||
}
|
||||
}
|
||||
let mut app = FilmcraftApp::new(session);
|
||||
if recover == Some(false) {
|
||||
app.dialog = None;
|
||||
}
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
if let Some(rs) = cc.wgpu_render_state.clone()
|
||||
&& std::env::var_os("FILMCRAFT_CPU_COMPOSITE").is_none()
|
||||
{
|
||||
app.set_wgpu(rs);
|
||||
}
|
||||
// Keep device selection available even when the default device is unavailable.
|
||||
// Settings ▸ Audio Hardware is applied on the first frame (`apply_prefs`).
|
||||
app.audio = Some(Box::new(audio::CpalOut::new()));
|
||||
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts))
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
}));
|
||||
// Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: one path, not imported.
|
||||
app.hooks.pick_file_for_relink = Some(Box::new(|exts: &[&str], _hint| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts))
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.pick_save = Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"]))
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.pick_save_as = Some(Box::new(|filter: &str, exts: &[&str], name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts))
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));
|
||||
app.hooks.pick_folder_at =
|
||||
Some(Box::new(|dir: &str| rfd::FileDialog::new().set_directory(dir).pick_folder().map(|p| p.to_string_lossy().into_owned())));
|
||||
app.hooks.open_path = Some(Box::new(open_path));
|
||||
// Help menu, About and Discord links (#642): eframe is built without its `links`
|
||||
// feature, so `ctx.open_url` does nothing on the desktop. Detached, so a launcher
|
||||
// that waits for the browser never freezes the UI.
|
||||
app.hooks.open_url = Some(Box::new(|url: &str| open::that_detached(url).map_err(|e| e.to_string())));
|
||||
app.hooks.clipboard_media = Some(Box::new(clipboard_media));
|
||||
// Settings ▸ General ▸ Interface Language ▸ System Language (#218).
|
||||
app.hooks.cursor_screen_position = Some(Box::new(filmcraft_platform::cursor::cursor_screen_position));
|
||||
app.hooks.system_languages = Some(Box::new(|| sys_locale::get_locales().collect()));
|
||||
// Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with system on Linux desktops whose
|
||||
// compositor reports no theme to winit (no polling: see appearance.rs).
|
||||
app.hooks.system_theme = appearance::service();
|
||||
app.hooks.raise_without_focus = Some(Box::new(|| {
|
||||
window_raise::raise_without_focus();
|
||||
}));
|
||||
app.hooks.pick_open_file = Some(Box::new(|filter: &str, exts: &[&str]| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts))
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.pick_open_project = Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"]))
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let (rx, update) = native_menu::install(&app, cc.egui_ctx.clone());
|
||||
app.command_inbox = Some(rx);
|
||||
app.hooks.shortcuts_changed = Some(update);
|
||||
app.ui.show_menu_bar = false;
|
||||
}
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = if control_input_only { app.with_control_input_only(rx) } else { app.with_control(rx) };
|
||||
}
|
||||
Ok(Box::new(app))
|
||||
}),
|
||||
);
|
||||
// However it ended, this start no longer counts as one that died.
|
||||
graphics.finish();
|
||||
// The window could not be created (no usable GPU / graphics driver, no display): say so
|
||||
// instead of exiting silently, and keep the reason in the crash log.
|
||||
if let Err(e) = &started {
|
||||
@@ -288,6 +164,189 @@ fn main() -> eframe::Result {
|
||||
started
|
||||
}
|
||||
|
||||
/// The main window's options; built for each start attempt (they hold a one-shot hook).
|
||||
fn native_options(agent: bool) -> eframe::NativeOptions {
|
||||
eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("FilmCraft")
|
||||
.with_inner_size([1600.0, 980.0])
|
||||
.with_min_inner_size([900.0, 560.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
// Started for an agent (`--control`): open without taking the user's keyboard focus.
|
||||
.with_active(!agent)
|
||||
.with_icon(app_icon())
|
||||
// Wayland matches the window to `packaging/linux/ai.storyteller.filmcraft.desktop`.
|
||||
.with_app_id(APP_ID),
|
||||
event_loop_builder: agent_event_loop(agent),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the app once eframe has its window and graphics device; sets `created` when it runs,
|
||||
/// and confirms the graphics mode once two frames have painted.
|
||||
fn app_creator(launch: Launch, graphics: graphics::GraphicsStart, created: Arc<AtomicBool>) -> eframe::AppCreator<'static> {
|
||||
let Launch { control_port, control_input_only, files, demo, startup_flag, recover, data_dir, graphics: _ } = launch;
|
||||
Box::new(move |cc| {
|
||||
created.store(true, Ordering::Relaxed);
|
||||
// A fallback graphics mode says so in the window, with a button back to the default.
|
||||
let notice = graphics.degraded().then(|| {
|
||||
let start = graphics.clone();
|
||||
filmcraft_ui_egui::graphics_notice::GraphicsNotice::new(graphics.mode().label(), Box::new(move || start.reset_to_default()))
|
||||
});
|
||||
let confirmed = AtomicBool::new(false);
|
||||
cc.egui_ctx.on_end_pass(
|
||||
"graphics-start",
|
||||
Arc::new(move |ui| {
|
||||
if ui.ctx().cumulative_frame_nr() >= 2 && !confirmed.swap(true, Ordering::Relaxed) {
|
||||
graphics.confirm();
|
||||
}
|
||||
}),
|
||||
);
|
||||
let mut session = Session::default();
|
||||
if let Some(dir) = data_dir.clone().or_else(default_data_dir) {
|
||||
let mut cfg = AutosaveConfig::new(dir);
|
||||
cfg.local_offset = local_offset;
|
||||
if let Err(e) = session.start_autosave(cfg) {
|
||||
log::warn!("auto-save and crash recovery unavailable: {e}");
|
||||
}
|
||||
}
|
||||
// voice-over recording reads the microphone through cpal
|
||||
session.voiceover.input = Some(Box::new(audio_in::CpalIn::new(&session.prefs.audio_hardware.device_class)));
|
||||
let (project, media) = args::project_and_media(&files);
|
||||
if let Some(p) = project {
|
||||
if let Err(e) = session.execute("file.open", json!({"path": p})) {
|
||||
log::error!("{e}");
|
||||
}
|
||||
} else if !startup_flag && session.prefs.general.at_startup == "openMostRecent" {
|
||||
// Settings ▸ General ▸ At Startup ▸ Open Most Recent
|
||||
let recent = session.prefs.general.recent_projects.iter().find(|p| std::path::Path::new(p).exists()).cloned();
|
||||
match recent {
|
||||
Some(p) => {
|
||||
if let Err(e) = session.execute("file.open", json!({"path": p})) {
|
||||
log::error!("{e}");
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let _ = session.execute("file.openDemoProject", json!({}));
|
||||
}
|
||||
}
|
||||
} else if !startup_flag && session.prefs.general.at_startup == "emptyProject" {
|
||||
} else if demo {
|
||||
let _ = session.execute("file.openDemoProject", json!({}));
|
||||
}
|
||||
if !media.is_empty() {
|
||||
let _ = session.execute("file.import", json!({"paths": media}));
|
||||
}
|
||||
if recover == Some(true) && !session.recovery_candidates().is_empty() {
|
||||
let id = session.recovery_candidates()[0].id.clone();
|
||||
match session.execute("file.recover", json!({"id": id})) {
|
||||
Ok(r) => log::info!("recovered {r}"),
|
||||
Err(e) => log::error!("recovery failed: {e}"),
|
||||
}
|
||||
}
|
||||
let mut app = FilmcraftApp::new(session);
|
||||
app.graphics_notice = notice;
|
||||
if recover == Some(false) {
|
||||
app.dialog = None;
|
||||
}
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
if let Some(rs) = cc.wgpu_render_state.clone()
|
||||
&& std::env::var_os("FILMCRAFT_CPU_COMPOSITE").is_none()
|
||||
{
|
||||
app.set_wgpu(rs);
|
||||
}
|
||||
// Keep device selection available even when the default device is unavailable.
|
||||
// Settings ▸ Audio Hardware is applied on the first frame (`apply_prefs`).
|
||||
app.audio = Some(Box::new(audio::CpalOut::new()));
|
||||
app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts))
|
||||
.pick_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
}));
|
||||
// Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: one path, not imported.
|
||||
app.hooks.pick_file_for_relink = Some(Box::new(|exts: &[&str], _hint| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts))
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.pick_save = Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"]))
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.pick_save_as = Some(Box::new(|filter: &str, exts: &[&str], name: &str| {
|
||||
// a full path (Quick Export, Export mode) opens the dialog in its folder
|
||||
let path = std::path::Path::new(name);
|
||||
let mut dialog = rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts))
|
||||
.set_file_name(path.file_name().map_or(name.into(), |n| n.to_string_lossy()));
|
||||
if let Some(dir) = path.parent().filter(|d| d.is_dir()) {
|
||||
dialog = dialog.set_directory(dir);
|
||||
}
|
||||
dialog.save_file().map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));
|
||||
app.hooks.pick_folder_at =
|
||||
Some(Box::new(|dir: &str| rfd::FileDialog::new().set_directory(dir).pick_folder().map(|p| p.to_string_lossy().into_owned())));
|
||||
// Linux: the XDG portal call blocks until the user chooses, which would stop the frame
|
||||
// loop (GNOME offers to force-quit the window after about five seconds, #260). Show the
|
||||
// dialog on its own thread instead; the app keeps running and acts on the answer.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
app.hooks.run_dialog = Some(Box::new(run_dialog_off_thread));
|
||||
}
|
||||
app.hooks.open_path = Some(Box::new(open_path));
|
||||
// Help menu, About and Discord links (#642): eframe is built without its `links`
|
||||
// feature, so `ctx.open_url` does nothing on the desktop. Detached, so a launcher
|
||||
// that waits for the browser never freezes the UI.
|
||||
app.hooks.open_url = Some(Box::new(|url: &str| open::that_detached(url).map_err(|e| e.to_string())));
|
||||
app.hooks.clipboard_media = Some(Box::new(clipboard_media));
|
||||
// Settings ▸ General ▸ Interface Language ▸ System Language (#218).
|
||||
app.hooks.cursor_screen_position = Some(Box::new(filmcraft_platform::cursor::cursor_screen_position));
|
||||
app.hooks.system_languages = Some(Box::new(|| sys_locale::get_locales().collect()));
|
||||
// Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with system on Linux desktops whose
|
||||
// compositor reports no theme to winit (no polling: see appearance.rs).
|
||||
app.hooks.system_theme = appearance::service();
|
||||
app.hooks.raise_without_focus = Some(Box::new(|| {
|
||||
window_raise::raise_without_focus();
|
||||
}));
|
||||
app.hooks.pick_open_file = Some(Box::new(|filter: &str, exts: &[&str]| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts))
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
app.hooks.pick_open_project = Some(Box::new(|| {
|
||||
rfd::FileDialog::new()
|
||||
.add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"]))
|
||||
.pick_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
}));
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let (rx, update) = native_menu::install(&app, cc.egui_ctx.clone());
|
||||
app.command_inbox = Some(rx);
|
||||
app.hooks.menu_changed = Some(update);
|
||||
app.ui.show_menu_bar = false;
|
||||
}
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = if control_input_only { app.with_control_input_only(rx) } else { app.with_control(rx) };
|
||||
}
|
||||
Ok(Box::new(app))
|
||||
})
|
||||
}
|
||||
|
||||
/// Files copied in the file manager, else an image, on the system clipboard (Paste, #611).
|
||||
fn clipboard_media() -> Option<filmcraft_ui_egui::ClipboardMedia> {
|
||||
let mut clipboard = arboard::Clipboard::new().ok()?;
|
||||
@@ -319,6 +378,7 @@ fn open_path(path: &str, reveal: bool) -> Result<(), String> {
|
||||
}
|
||||
c
|
||||
} else {
|
||||
// Linux file managers have no select-file flag: open the parent folder instead.
|
||||
let mut c = std::process::Command::new("xdg-open");
|
||||
let p = std::path::Path::new(path);
|
||||
c.arg(if reveal { p.parent().unwrap_or(p) } else { p });
|
||||
@@ -379,6 +439,80 @@ fn agent_event_loop(agent: bool) -> Option<eframe::EventLoopBuilderHook> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Show `dialog` with rfd on a worker thread and answer on the returned channel (#260). Filter
|
||||
/// names are translated here, on the UI thread (the interface language is per thread). A dialog
|
||||
/// thread that can't start or panics drops the sender: the app takes that as cancelled.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn run_dialog_off_thread(dialog: filmcraft_ui_egui::native_dialogs::FileDialog) -> std::sync::mpsc::Receiver<Vec<String>> {
|
||||
use filmcraft_ui_egui::native_dialogs::FileDialog;
|
||||
let t = |s: &str| filmcraft_ui_egui::i18n::t(s).to_string();
|
||||
let media = t("Media");
|
||||
let project = t("FilmCraft Project");
|
||||
let project_exts = project_patterns();
|
||||
let dialog = prepare_dialog(dialog);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let spawned = std::thread::Builder::new().name("file-dialog".into()).spawn(move || {
|
||||
let shown = std::panic::catch_unwind(move || {
|
||||
let path = |p: std::path::PathBuf| p.to_string_lossy().into_owned();
|
||||
match dialog {
|
||||
FileDialog::OpenFiles { exts } => {
|
||||
rfd::FileDialog::new().add_filter(&media, &exts).pick_files().unwrap_or_default().into_iter().map(path).collect()
|
||||
}
|
||||
FileDialog::OpenFile { filter, exts } => rfd::FileDialog::new().add_filter(&filter, &exts).pick_file().map(path).into_iter().collect(),
|
||||
FileDialog::Relink { exts, .. } => rfd::FileDialog::new().add_filter(&media, &exts).pick_file().map(path).into_iter().collect(),
|
||||
FileDialog::OpenProject => rfd::FileDialog::new().add_filter(&project, &project_exts).pick_file().map(path).into_iter().collect(),
|
||||
FileDialog::SaveProject { name } => {
|
||||
rfd::FileDialog::new().add_filter(&project, &project_exts).set_file_name(name).save_file().map(path).into_iter().collect()
|
||||
}
|
||||
FileDialog::SaveAs { filter, exts, name } => {
|
||||
rfd::FileDialog::new().add_filter(&filter, &exts).set_file_name(name).save_file().map(path).into_iter().collect()
|
||||
}
|
||||
FileDialog::Folder { at } => {
|
||||
let d = rfd::FileDialog::new();
|
||||
let d = match at {
|
||||
Some(dir) if !dir.is_empty() => d.set_directory(dir),
|
||||
_ => d,
|
||||
};
|
||||
d.pick_folder().map(path).into_iter().collect::<Vec<_>>()
|
||||
}
|
||||
}
|
||||
});
|
||||
match shown {
|
||||
Ok(paths) => {
|
||||
let _ = tx.send(paths);
|
||||
}
|
||||
Err(_) => log::warn!("the file dialog thread panicked; taken as cancelled"),
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
log::warn!("could not start the file dialog thread: {e}");
|
||||
}
|
||||
rx
|
||||
}
|
||||
|
||||
/// A dialog as the Linux dialog thread shows it: filter names translated (here, on the UI thread:
|
||||
/// the interface language is per thread) and every extension list turned into the
|
||||
/// case-insensitive patterns the synchronous pickers use, so `CLIP.MP4` stays visible (#262).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn prepare_dialog(dialog: filmcraft_ui_egui::native_dialogs::FileDialog) -> filmcraft_ui_egui::native_dialogs::FileDialog {
|
||||
use filmcraft_ui_egui::native_dialogs::FileDialog;
|
||||
let t = |s: &str| filmcraft_ui_egui::i18n::t(s).to_string();
|
||||
let patterns = |exts: Vec<String>| file_filters::extensions(&exts.iter().map(String::as_str).collect::<Vec<_>>());
|
||||
match dialog {
|
||||
FileDialog::OpenFiles { exts } => FileDialog::OpenFiles { exts: patterns(exts) },
|
||||
FileDialog::OpenFile { filter, exts } => FileDialog::OpenFile { filter: t(&filter), exts: patterns(exts) },
|
||||
FileDialog::SaveAs { filter, exts, name } => FileDialog::SaveAs { filter: t(&filter), exts: patterns(exts), name },
|
||||
FileDialog::Relink { exts, hint } => FileDialog::Relink { exts: patterns(exts), hint },
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// The project dialogs' filter patterns (`EDIT.FCPROJ` included).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn project_patterns() -> Vec<String> {
|
||||
file_filters::extensions(&["fcproj"])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Start-up registers the hardware decoders whether or not anything is logged (no logger is
|
||||
@@ -392,4 +526,31 @@ mod tests {
|
||||
|| (cfg!(target_os = "linux") && matches!(hardware, filmcraft_platform::Availability::Available(_)));
|
||||
assert_eq!(filmcraft_platform::registered(), expected, "{hardware:?}");
|
||||
}
|
||||
|
||||
/// #262 on Linux: the dialogs shown off the UI thread (#260) filter with the same
|
||||
/// case-insensitive patterns as the synchronous pickers, so upper- and mixed-case files show.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn off_thread_dialogs_keep_case_insensitive_filters() {
|
||||
use filmcraft_ui_egui::RelinkHint;
|
||||
use filmcraft_ui_egui::native_dialogs::FileDialog;
|
||||
let mp4 = || vec!["mp4".to_string()];
|
||||
let hint = RelinkHint { command: "media.relink".into(), params: serde_json::json!({}) };
|
||||
for dialog in [
|
||||
FileDialog::OpenFiles { exts: mp4() },
|
||||
FileDialog::OpenFile { filter: "Media".into(), exts: mp4() },
|
||||
FileDialog::SaveAs { filter: "Media".into(), exts: mp4(), name: "clip.mp4".into() },
|
||||
FileDialog::Relink { exts: mp4(), hint },
|
||||
] {
|
||||
let exts = match super::prepare_dialog(dialog) {
|
||||
FileDialog::OpenFiles { exts } | FileDialog::OpenFile { exts, .. } | FileDialog::SaveAs { exts, .. } | FileDialog::Relink { exts, .. } => exts,
|
||||
other => panic!("the dialog changed kind: {other:?}"),
|
||||
};
|
||||
assert_eq!(exts.first().map(String::as_str), Some("mp4"), "the literal extension stays first (save suffix)");
|
||||
assert!(exts.iter().any(|e| e == "[mM][pP]4"), "{exts:?}");
|
||||
}
|
||||
let project = super::project_patterns();
|
||||
assert_eq!(project.first().map(String::as_str), Some("fcproj"));
|
||||
assert!(project.iter().any(|e| e == "[fF][cC][pP][rR][oO][jJ]"), "{project:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
|
||||
use filmcraft_ui_egui::FilmcraftApp;
|
||||
use filmcraft_ui_egui::menus::{MENUS, MenuItem as Item, menu_items};
|
||||
use filmcraft_ui_egui::menus::{MENUS, MenuItem as Item, NATIVE_APP_MENU, menu_items};
|
||||
use muda::accelerator::Accelerator;
|
||||
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
|
||||
|
||||
@@ -19,34 +19,65 @@ fn accel(s: &str) -> Option<Accelerator> {
|
||||
mapped.parse().ok()
|
||||
}
|
||||
|
||||
/// Updates native key equivalents when the active keyboard shortcuts change.
|
||||
pub type ShortcutUpdater = Box<dyn FnMut(&[Item])>;
|
||||
/// The app menu's Quit item: the `app.quit` command (which asks to save unsaved changes) on its
|
||||
/// shortcut, Cmd+Q unless remapped in the Keyboard Shortcuts editor. The key equivalent is read
|
||||
/// once, when the menu bar is built: `app.quit` has no menu path, so it is not in the menu tree the
|
||||
/// updater receives, and a remap shows on the Quit item after the next launch.
|
||||
fn quit_item(app: &FilmcraftApp) -> (&'static str, Option<Accelerator>) {
|
||||
let keys = app.session.shortcuts.primary("app.quit").unwrap_or_else(|| "Cmd+Q".into());
|
||||
("app.quit", accel(&keys))
|
||||
}
|
||||
|
||||
pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, ShortcutUpdater) {
|
||||
/// Updates native item labels, key equivalents and enablement when the menu tree changes.
|
||||
pub type MenuUpdater = Box<dyn FnMut(&[Item])>;
|
||||
|
||||
pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, MenuUpdater) {
|
||||
let items = menu_items(app);
|
||||
let mut native: Vec<(String, MenuItem)> = Vec::new();
|
||||
// each native item with the menu entry it currently shows, so updates touch only what changed
|
||||
let mut native: Vec<(Item, MenuItem)> = Vec::new();
|
||||
let mut titles: Vec<(String, Submenu)> = Vec::new();
|
||||
let bar = Menu::new();
|
||||
let app_menu = Submenu::new("FilmCraft", true);
|
||||
let [settings_label, hide_label, hide_others_label, quit_label] = NATIVE_APP_MENU;
|
||||
// FilmCraft ▸ Settings ▸ <category> (Premiere's app-menu layout; General is Cmd+,)
|
||||
let settings = Submenu::new(app.ui.language.tr("Settings"), true);
|
||||
titles.push(("Settings".into(), settings.clone()));
|
||||
let settings = Submenu::new(app.ui.language.tr(settings_label), true);
|
||||
titles.push((settings_label.into(), settings.clone()));
|
||||
for it in items.iter().filter(|i| i.id.starts_with("app.settings.")) {
|
||||
let label = it.label.trim_end_matches('…').to_string();
|
||||
let mi = MenuItem::with_id(it.id.clone(), label, true, it.shortcut.as_deref().and_then(accel));
|
||||
native.push((it.id.clone(), mi.clone()));
|
||||
let mi = MenuItem::with_id(it.id.clone(), label, it.enabled, it.shortcut.as_deref().and_then(accel));
|
||||
native.push((it.clone(), mi.clone()));
|
||||
let _ = settings.append(&mi);
|
||||
}
|
||||
// About and Discord repeat Help menu commands: same translated labels, updated with them
|
||||
let label = |id: &str, english: &str| items.iter().find(|i| i.id == id).map_or_else(|| english.to_string(), |i| i.label.clone());
|
||||
let about = MenuItem::with_id("app.about", label("app.about", "About FilmCraft"), true, None);
|
||||
let discord = MenuItem::with_id("help.discord", label("help.discord", "Join the ArtCraft Discord…"), true, None);
|
||||
for (id, mi) in [("app.about", &about), ("help.discord", &discord)] {
|
||||
if let Some(it) = items.iter().find(|i| i.id == id) {
|
||||
// recorded as built (enabled, no shortcut), so the first update applies the real state
|
||||
native.push((Item { enabled: true, shortcut: None, ..it.clone() }, mi.clone()));
|
||||
}
|
||||
}
|
||||
// the predefined items keep their native actions; our text lets them follow the language
|
||||
let predefined = [
|
||||
(hide_label, PredefinedMenuItem::hide(Some(app.ui.language.tr(hide_label)))),
|
||||
(hide_others_label, PredefinedMenuItem::hide_others(Some(app.ui.language.tr(hide_others_label)))),
|
||||
];
|
||||
let [(_, hide), (_, hide_others)] = &predefined;
|
||||
// Quit is ours, not the predefined item: that one calls `terminate:` and skips the unsaved
|
||||
// changes prompt (#1943); the `app.quit` command closes the window through it
|
||||
let (quit_id, quit_accel) = quit_item(app);
|
||||
let quit = MenuItem::with_id(quit_id, app.ui.language.tr(quit_label), true, quit_accel);
|
||||
let _ = app_menu.append_items(&[
|
||||
&MenuItem::with_id("app.about", "About FilmCraft", true, None),
|
||||
&MenuItem::with_id("help.discord", "Join the ArtCraft Discord…", true, None),
|
||||
&about,
|
||||
&discord,
|
||||
&PredefinedMenuItem::separator(),
|
||||
&settings,
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::hide(None),
|
||||
&PredefinedMenuItem::hide_others(None),
|
||||
hide,
|
||||
hide_others,
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::quit(None),
|
||||
&quit,
|
||||
]);
|
||||
let _ = bar.append(&app_menu);
|
||||
for top in MENUS {
|
||||
@@ -57,8 +88,8 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Sho
|
||||
// their first item appears
|
||||
let mut subs: Vec<(Vec<String>, Submenu)> = Vec::new();
|
||||
for it in mine {
|
||||
let mi = MenuItem::with_id(it.id.clone(), &it.label, true, it.shortcut.as_deref().and_then(accel));
|
||||
native.push((it.id.clone(), mi.clone()));
|
||||
let mi = MenuItem::with_id(it.id.clone(), &it.label, it.enabled, it.shortcut.as_deref().and_then(accel));
|
||||
native.push(((*it).clone(), mi.clone()));
|
||||
let mut parent = sub.clone();
|
||||
for depth in 1..it.path.len() {
|
||||
let key = it.path[..=depth].to_vec();
|
||||
@@ -91,7 +122,8 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Sho
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
let update: ShortcutUpdater = Box::new(move |items: &[Item]| {
|
||||
let mut shown_language = app.ui.language;
|
||||
let update: MenuUpdater = Box::new(move |items: &[Item]| {
|
||||
let checked = |id: &str| items.iter().any(|it| it.id == id && it.checked == Some(true));
|
||||
let language = if checked("app.language.japanese") {
|
||||
filmcraft_ui_egui::i18n::Language::Ja
|
||||
@@ -109,18 +141,56 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver<String>, Sho
|
||||
filmcraft_ui_egui::i18n::Language::Ru
|
||||
} else if checked("app.language.french") {
|
||||
filmcraft_ui_egui::i18n::Language::Fr
|
||||
} else if checked("app.language.indonesian") {
|
||||
filmcraft_ui_egui::i18n::Language::Id
|
||||
} else if checked("app.language.italian") {
|
||||
filmcraft_ui_egui::i18n::Language::It
|
||||
} else if checked("app.language.korean") {
|
||||
filmcraft_ui_egui::i18n::Language::Ko
|
||||
} else {
|
||||
filmcraft_ui_egui::i18n::Language::En
|
||||
};
|
||||
for (title, submenu) in &titles {
|
||||
submenu.set_text(language.tr(title));
|
||||
if language != shown_language {
|
||||
shown_language = language;
|
||||
for (title, submenu) in &titles {
|
||||
submenu.set_text(language.tr(title));
|
||||
}
|
||||
}
|
||||
for (id, mi) in &native {
|
||||
if let Some(it) = items.iter().find(|i| &i.id == id) {
|
||||
mi.set_text(&it.label);
|
||||
for (label, item) in &predefined {
|
||||
item.set_text(language.tr(label));
|
||||
}
|
||||
quit.set_text(language.tr(quit_label));
|
||||
for (shown, mi) in &mut native {
|
||||
let Some(it) = items.iter().find(|i| i.id == shown.id) else { continue };
|
||||
if it.label != shown.label {
|
||||
// Settings entries drop the trailing ellipsis, as when the menu was built
|
||||
mi.set_text(if it.id.starts_with("app.settings.") { it.label.trim_end_matches('…') } else { &it.label });
|
||||
}
|
||||
if it.shortcut != shown.shortcut {
|
||||
let _ = mi.set_accelerator(it.shortcut.as_deref().and_then(accel));
|
||||
}
|
||||
if it.enabled != shown.enabled {
|
||||
mi.set_enabled(it.enabled);
|
||||
}
|
||||
*shown = it.clone();
|
||||
}
|
||||
});
|
||||
(rx, update)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use filmcraft_engine::Session;
|
||||
|
||||
/// #1943: Cmd+Q must reach the `app.quit` command, which closes the window through the unsaved
|
||||
/// changes prompt, instead of the predefined Quit item's `terminate:`.
|
||||
#[test]
|
||||
fn quit_item_runs_the_app_quit_command_on_cmd_q() {
|
||||
let app = FilmcraftApp::new(Session::default());
|
||||
let (id, accelerator) = quit_item(&app);
|
||||
assert_eq!(id, "app.quit");
|
||||
assert_eq!(accelerator, accel("Cmd+Q"));
|
||||
assert!(accelerator.is_some(), "Cmd+Q is a native key equivalent");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,21 @@ sync_github_name = false
|
||||
[people.ossini]
|
||||
sync_github_name = true
|
||||
|
||||
[people.opentechnepal]
|
||||
sync_github_name = true
|
||||
|
||||
[people.vangel76]
|
||||
real_name = "Valentin Klein"
|
||||
display_name = "Valentin Klein"
|
||||
sync_github_name = false
|
||||
|
||||
[people.n-delic]
|
||||
display_name = "n-delic"
|
||||
sync_github_name = false
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
[people.webdevtodayjason]
|
||||
display_name = "Jason Brashear"
|
||||
|
||||
@@ -48,6 +48,9 @@ from bitrate/quality), `tns`, `ms_stereo`, `window_shape` (sine/KBD), `adts` (pr
|
||||
| 7 | 0 + PCE | C L R Ls Rs Cs LFE (PCE in the ASC; repeated in every AU when ADTS-framed) |
|
||||
| 8 | 7 | C Lc Rc L R Ls Rs LFE |
|
||||
|
||||
The decoder also reads configurations 11 (6.1: C L R Ls Rs Cs LFE) and 12 (7.1: C L R Ls Rs Lsr Rsr
|
||||
LFE) from ISO/IEC 14496-3 Amd 4; ffmpeg 9 writes 7.1 AAC as configuration 12.
|
||||
|
||||
`Decoder::layout()` reports the element layout (it follows a PCE for configuration 0).
|
||||
|
||||
## Encoder
|
||||
|
||||
@@ -136,7 +136,8 @@ impl ProgramConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Element layout of `channelConfiguration` 1..=7 (tags as conventionally assigned).
|
||||
/// Element layout of `channelConfiguration` 1..=7, 11 and 12 (tags as conventionally assigned; 11 and
|
||||
/// 12 from ISO/IEC 14496-3 Amd 4: 6.1 with a back centre, 7.1 with back surrounds).
|
||||
pub fn config_layout(channel_config: u8) -> Option<Vec<(ElementType, u8)>> {
|
||||
use ElementType::*;
|
||||
Some(match channel_config {
|
||||
@@ -146,7 +147,8 @@ pub fn config_layout(channel_config: u8) -> Option<Vec<(ElementType, u8)>> {
|
||||
4 => vec![(Sce, 0), (Cpe, 0), (Sce, 1)],
|
||||
5 => vec![(Sce, 0), (Cpe, 0), (Cpe, 1)],
|
||||
6 => vec![(Sce, 0), (Cpe, 0), (Cpe, 1), (Lfe, 0)],
|
||||
7 => vec![(Sce, 0), (Cpe, 0), (Cpe, 1), (Cpe, 2), (Lfe, 0)],
|
||||
7 | 12 => vec![(Sce, 0), (Cpe, 0), (Cpe, 1), (Cpe, 2), (Lfe, 0)],
|
||||
11 => vec![(Sce, 0), (Cpe, 0), (Cpe, 1), (Sce, 1), (Lfe, 0)],
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -388,6 +390,14 @@ mod tests {
|
||||
assert_eq!(AudioSpecificConfig::parse(&a.to_bytes()).unwrap().channels(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn amd4_channel_configurations() {
|
||||
// 6.1 and 7.1 back (#922); 22.2 and 7.1 with heights stay unsupported
|
||||
let channels = |c| AudioSpecificConfig::parse(&AudioSpecificConfig::lc(3, c).to_bytes()).map(|a| a.channels()).ok();
|
||||
assert_eq!([7, 11, 12].map(channels), [Some(8), Some(7), Some(8)]);
|
||||
assert_eq!([13, 14].map(config_layout), [None, None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asc_with_pce() {
|
||||
let pce = ProgramConfig {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Decoder accuracy against ffmpeg's decoder on streams produced by ffmpeg's AAC encoder (oracle
|
||||
//! only; skipped when ffmpeg is absent).
|
||||
|
||||
mod common;
|
||||
use crate::common;
|
||||
|
||||
use common::*;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Encoder quality/bitrate tests using ffmpeg as an external decoding oracle (skipped if absent).
|
||||
|
||||
mod common;
|
||||
use crate::common;
|
||||
|
||||
use common::*;
|
||||
use filmcraft_aac::{BitrateMode, EncoderConfig, WindowShape};
|
||||
@@ -0,0 +1,4 @@
|
||||
mod common;
|
||||
mod decoder_oracle;
|
||||
mod encoder_oracle;
|
||||
mod robustness;
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Fuzz-style robustness: mutated access units, configs and ADTS streams must never panic, and the
|
||||
//! encoder must accept hostile input (NaN, ±inf, > full scale, silence, odd chunk sizes).
|
||||
|
||||
mod common;
|
||||
use crate::common;
|
||||
|
||||
use common::*;
|
||||
use filmcraft_aac::{AudioSpecificConfig, Decoder, Encoder, EncoderConfig, split_adts};
|
||||
@@ -54,7 +54,7 @@ let apvc: Vec<u8> = enc.decoder_config_record(); // APVDecoderConfigurationReco
|
||||
- **Metadata (§8)**: Parses `metadata_mdcv` (`payloadType == 5`, Mastering Display Color Volume)
|
||||
and `metadata_cll` (`payloadType == 6`, Content Light-Level Information).
|
||||
- **Robustness (§10)**: All size fields, tile coordinates, VLC codeword lengths, and coefficient
|
||||
ranges (`-32768..=32767`) are checked before allocation or shift. Verified by `tests/fuzz.rs`
|
||||
ranges (`-32768..=32767`) are checked before allocation or shift. Verified by `tests/it/fuzz.rs`
|
||||
across thousands of mutated and random inputs.
|
||||
|
||||
### Encoder
|
||||
@@ -67,7 +67,7 @@ let apvc: Vec<u8> = enc.decoder_config_record(); // APVDecoderConfigurationReco
|
||||
|
||||
## Accuracy (vs FFmpeg as an External Oracle)
|
||||
|
||||
`tests/oracle.rs` encodes synthetic test patterns across all 7 profiles, odd dimensions, multiple
|
||||
`tests/it/oracle.rs` encodes synthetic test patterns across all 7 profiles, odd dimensions, multiple
|
||||
tiles, custom asymmetric quantization matrices, and HDR metadata PBUs, then decodes the resulting
|
||||
`.apv` streams with `ffmpeg -v error -xerror -f apv` and compares against `filmcraft_apv::decode_frame`.
|
||||
Because APV specifies an exact integer inverse transform, **`filmcraft-apv` is 100% bit-exact
|
||||
|
||||
@@ -377,7 +377,20 @@ fn decode_tile_comp(hdr: &FrameHeader, tile_idx: usize, c: usize, comp_bytes: &[
|
||||
let blocks_x = blk_w / 8;
|
||||
let blocks_y = blk_h / 8;
|
||||
|
||||
let mut comp_buf = vec![0u16; tw_c * th_c];
|
||||
// Every coded 8x8 block needs at least one bit for its DC code. Validate
|
||||
// that the component can describe every required block before allocating
|
||||
// its complete reconstructed plane.
|
||||
let block_count = (num_mb_cols as usize)
|
||||
.checked_mul(num_mb_rows as usize)
|
||||
.and_then(|n| n.checked_mul(blocks_x))
|
||||
.and_then(|n| n.checked_mul(blocks_y))
|
||||
.ok_or(Error::CorruptTile(tile_idx))?;
|
||||
if comp_bytes.len() < block_count.div_ceil(8) {
|
||||
return Err(Error::CorruptTile(tile_idx));
|
||||
}
|
||||
let samples = tw_c.checked_mul(th_c).ok_or(Error::CorruptTile(tile_idx))?;
|
||||
|
||||
let mut comp_buf = vec![0u16; samples];
|
||||
let mut r = BitReader::new(comp_bytes);
|
||||
|
||||
let mut prev_dc: i32 = 0;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Hostile-input robustness: every header byte set to extreme values, every bit flipped,
|
||||
//! truncation at (nearly) every length must never panic or run away (errors are fine).
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
|
||||
use filmcraft_apv::*;
|
||||
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
(self.next() % n.max(1) as u64) as usize
|
||||
}
|
||||
}
|
||||
|
||||
fn seeds() -> Vec<Vec<u8>> {
|
||||
let mut v = Vec::new();
|
||||
for (p, w, h) in [(Profile::P422_10, 64u32, 48u32), (Profile::P444_10, 48, 32), (Profile::P4444_10, 32, 32), (Profile::P400_10, 32, 32)] {
|
||||
let chroma = p.default_chroma();
|
||||
let depth = p.default_bit_depth();
|
||||
let mut f = Frame::new(w, h, chroma, depth, chroma == ChromaFormat::Yuv4444);
|
||||
for (i, s) in f.y.iter_mut().enumerate() {
|
||||
*s = (64 + (i * 37) % 800) as u16;
|
||||
}
|
||||
let mut cfg = EncoderConfig::new(p, w, h);
|
||||
cfg.qp = Some(16);
|
||||
let Ok(mut enc) = Encoder::with_config(cfg) else { continue };
|
||||
if let Ok(d) = enc.encode(&f) {
|
||||
v.push(d);
|
||||
}
|
||||
if let Ok(d) = enc.encode_raw_au(&f) {
|
||||
v.push(d);
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn run(d: &[u8], label: &str) {
|
||||
filmcraft_testkit::set_progress(label);
|
||||
let res = catch_unwind(AssertUnwindSafe(|| {
|
||||
for depth in [None, Some(8), Some(16)] {
|
||||
let _ = decode_frame_with(d, &DecodeOptions { bit_depth: depth, threads: depth.is_none() });
|
||||
}
|
||||
let _ = probe(d);
|
||||
let _ = split_raw_bitstream(d);
|
||||
}));
|
||||
assert!(res.is_ok(), "panic: {label}");
|
||||
}
|
||||
|
||||
/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
|
||||
const DEADLINE_SECS: u64 = 600;
|
||||
|
||||
#[test]
|
||||
fn hostile_frames_never_panic() {
|
||||
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
|
||||
}
|
||||
|
||||
fn sweep() {
|
||||
let seeds = seeds();
|
||||
assert!(!seeds.is_empty());
|
||||
for (si, seed) in seeds.iter().enumerate() {
|
||||
let stride = (seed.len() / 400).max(1);
|
||||
for n in (0..seed.len()).step_by(stride) {
|
||||
run(&seed[..n], &format!("seed {si} truncate {n}"));
|
||||
}
|
||||
for p in 0..seed.len().min(256) {
|
||||
for v in [0u8, 0xff, 0x80, 0x01, 0x7f] {
|
||||
let mut d = seed.clone();
|
||||
d[p] = v;
|
||||
run(&d, &format!("seed {si} byte {p}={v:#x}"));
|
||||
}
|
||||
for b in 0..8 {
|
||||
let mut d = seed.clone();
|
||||
d[p] ^= 1 << b;
|
||||
run(&d, &format!("seed {si} flip {p}.{b}"));
|
||||
}
|
||||
}
|
||||
// Whole big-endian 32-bit words of the header set to 0 / huge values (sizes, counts, dimensions).
|
||||
for p in 0..seed.len().min(128) {
|
||||
for v in [0u32, 0xffff_ffff, 0x0001_0000, 0x7fff_ffff] {
|
||||
let mut d = seed.clone();
|
||||
if let Some(s) = d.get_mut(p..p + 4) {
|
||||
s.copy_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
run(&d, &format!("seed {si} u32 {p}={v:#x}"));
|
||||
}
|
||||
}
|
||||
let mut rng = Rng(0x9e3779b97f4a7c15 ^ si as u64);
|
||||
let iters = std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(1000);
|
||||
for i in 0..iters {
|
||||
let mut d = seed.clone();
|
||||
for _ in 0..1 + rng.below(8) {
|
||||
let p = rng.below(d.len());
|
||||
if rng.below(2) == 0 {
|
||||
d[p] ^= 1 << rng.below(8);
|
||||
} else {
|
||||
d[p] = rng.next() as u8;
|
||||
}
|
||||
}
|
||||
run(&d, &format!("seed {si} random {i}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
mod fuzz;
|
||||
mod fuzz_hostile;
|
||||
mod oracle;
|
||||
@@ -8,7 +8,9 @@ use filmcraft_apv::{
|
||||
};
|
||||
|
||||
fn ffmpeg() -> Option<PathBuf> {
|
||||
filmcraft_testkit::ffmpeg_or_skip("apv oracle")
|
||||
// The APV demuxer is new in ffmpeg 8.0; older toolchains are found but
|
||||
// cannot read the fixtures, so they skip like a missing tool (#1249).
|
||||
filmcraft_testkit::ffmpeg_with_format_or_skip("apv oracle", "apv", "8.0")
|
||||
}
|
||||
|
||||
fn fixture_dir() -> PathBuf {
|
||||
@@ -132,6 +132,10 @@ impl Biquad {
|
||||
pub fn reset(&mut self) {
|
||||
*self = Biquad::new();
|
||||
}
|
||||
/// Exact zero state, including the sign of zero, for identity-filter fast paths.
|
||||
pub(crate) fn is_clear(&self) -> bool {
|
||||
self.s1.to_bits() == 0 && self.s2.to_bits() == 0
|
||||
}
|
||||
/// Filter a buffer in place.
|
||||
pub fn process(&mut self, c: &Coeffs, buf: &mut [f32]) {
|
||||
for v in buf {
|
||||
|
||||
@@ -110,6 +110,57 @@ impl BandBank {
|
||||
pub(crate) fn process(&mut self, channels: &mut [&mut [f32]], out_gain: &mut Smoothed) {
|
||||
let n = block_len(channels);
|
||||
let nch = channels.len().min(self.state.len());
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
if !out_gain.is_smoothing()
|
||||
&& self.bands.iter().all(|b| !b.dirty && !b.freq.is_smoothing() && !b.gain.is_smoothing() && !b.q.is_smoothing() && !b.mix.is_smoothing())
|
||||
{
|
||||
// Keep the exact per-sample arithmetic and recursive state; only the settled
|
||||
// parameter/smoother bookkeeping is hoisted out of the hot loop.
|
||||
let g = out_gain.value() as f64;
|
||||
for (channel, state) in channels.iter_mut().take(nch).zip(&mut self.state) {
|
||||
for (b, st) in self.bands.iter().zip(state.iter_mut()) {
|
||||
if b.mix.value() <= 0.0 {
|
||||
st.reset();
|
||||
}
|
||||
}
|
||||
// A settled unit transfer with zero state is exactly transparent for finite
|
||||
// f32 input. Do not discard a tail or bypass a non-finite sample. Bounded
|
||||
// coefficients also ensure intermediate f64 products cannot overflow.
|
||||
let transparent = self.bands.iter().zip(state.iter()).all(|(b, st)| {
|
||||
b.mix.value() <= 0.0
|
||||
|| (st.is_clear()
|
||||
&& b.coeffs.b0 == 1.0
|
||||
&& b.coeffs.b1 == b.coeffs.a1
|
||||
&& b.coeffs.b2 == b.coeffs.a2
|
||||
&& b.coeffs.b1.abs() <= 4.0
|
||||
&& b.coeffs.b2.abs() <= 4.0)
|
||||
});
|
||||
if transparent && channel.iter().take(n).all(|x| x.is_finite()) {
|
||||
let active = self.bands.iter().any(|b| b.mix.value() > 0.0);
|
||||
for sample in channel.iter_mut().take(n) {
|
||||
let x = *sample as f64;
|
||||
// An active identity biquad adds +0, which normalizes negative zero.
|
||||
*sample = ((if active { x + 0.0 } else { x }) * g) as f32;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for sample in channel.iter_mut().take(n) {
|
||||
let mut x = *sample as f64;
|
||||
for (b, st) in self.bands.iter().zip(state.iter_mut()) {
|
||||
let m = b.mix.value() as f64;
|
||||
if m > 0.0 {
|
||||
let y = st.tick(&b.coeffs, x);
|
||||
x += m * (y - x);
|
||||
}
|
||||
}
|
||||
*sample = (x * g) as f32;
|
||||
}
|
||||
}
|
||||
self.counter = (self.counter + (n % COEFF_INTERVAL as usize) as u32) % COEFF_INTERVAL;
|
||||
return;
|
||||
}
|
||||
for i in 0..n {
|
||||
if self.counter == 0 {
|
||||
self.update_coeffs(false);
|
||||
@@ -376,3 +427,119 @@ mod tests {
|
||||
assert!((x[4799] - 0.5 * crate::db_to_gain(12.0)).abs() < 1e-4);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl BandBank {
|
||||
fn process_reference(&mut self, channels: &mut [&mut [f32]], out_gain: &mut Smoothed) {
|
||||
let n = block_len(channels);
|
||||
let nch = channels.len().min(self.state.len());
|
||||
for i in 0..n {
|
||||
if self.counter == 0 {
|
||||
self.update_coeffs(false);
|
||||
}
|
||||
self.counter = (self.counter + 1) % COEFF_INTERVAL;
|
||||
for b in &mut self.bands {
|
||||
b.freq.tick();
|
||||
b.gain.tick();
|
||||
b.q.tick();
|
||||
if b.freq.is_smoothing() || b.gain.is_smoothing() || b.q.is_smoothing() {
|
||||
b.dirty = true;
|
||||
}
|
||||
}
|
||||
let g = out_gain.tick();
|
||||
// Tick the band mixes once per sample (shared by all channels).
|
||||
let mut mixes = [0.0f32; MAX_BANDS];
|
||||
for (m, b) in mixes.iter_mut().zip(self.bands.iter_mut()) {
|
||||
*m = b.mix.tick();
|
||||
}
|
||||
for ch in 0..nch {
|
||||
let mut x = channels[ch][i] as f64;
|
||||
for (k, b) in self.bands.iter().enumerate() {
|
||||
let m = mixes[k] as f64;
|
||||
let st = &mut self.state[ch][k];
|
||||
if m > 0.0 {
|
||||
let y = st.tick(&b.coeffs, x);
|
||||
x += m * (y - x);
|
||||
} else {
|
||||
st.reset();
|
||||
}
|
||||
}
|
||||
channels[ch][i] = (x * g as f64) as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod throughput_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn flat_eq_fast_path_preserves_samples_and_filter_tails() {
|
||||
let mut fast = BandBank::new(48000.0, 2, 1);
|
||||
fast.set_band(0, true, FilterType::Peaking, 1000.0, 0.0, 0.7, true);
|
||||
fast.reset();
|
||||
let mut reference = fast.clone();
|
||||
let mut gain = Smoothed::new(1.0, 0);
|
||||
let mut ref_gain = gain;
|
||||
for input in
|
||||
[vec![-0.0, 0.0, f32::MIN_POSITIVE, f32::from_bits(1), f32::MAX, -f32::MAX, 0.7], vec![0.0; 100], vec![f32::INFINITY, f32::NEG_INFINITY, f32::NAN]]
|
||||
{
|
||||
let mut a = [input.clone(), input];
|
||||
let mut b = a.clone();
|
||||
let [al, ar] = &mut a;
|
||||
let [bl, br] = &mut b;
|
||||
fast.process(&mut [al, ar], &mut gain);
|
||||
reference.process_reference(&mut [bl, br], &mut ref_gain);
|
||||
for (a, b) in a.iter().flatten().zip(b.iter().flatten()) {
|
||||
assert_eq!(a.to_bits(), b.to_bits());
|
||||
}
|
||||
}
|
||||
// A newly flat filter must drain its old state, not drop the tail.
|
||||
fast.reset();
|
||||
fast.set_band(0, true, FilterType::Peaking, 1000.0, 12.0, 0.7, true);
|
||||
fast.update_coeffs(true);
|
||||
fast.process(&mut [&mut [0.5], &mut [0.5]], &mut gain);
|
||||
fast.set_band(0, true, FilterType::Peaking, 1000.0, 0.0, 0.7, true);
|
||||
fast.update_coeffs(true);
|
||||
reference = fast.clone();
|
||||
let mut a = [vec![0.0; 4096], vec![0.0; 4096]];
|
||||
let mut b = a.clone();
|
||||
let [al, ar] = &mut a;
|
||||
let [bl, br] = &mut b;
|
||||
fast.process(&mut [al, ar], &mut gain);
|
||||
reference.process_reference(&mut [bl, br], &mut ref_gain);
|
||||
assert_eq!(a, b);
|
||||
assert!(a[0].iter().any(|x| *x != 0.0));
|
||||
}
|
||||
#[test]
|
||||
fn settled_and_automated_eq_matches_original_loop() {
|
||||
let mut fast = BandBank::new(48000.0, 2, 3);
|
||||
fast.reset();
|
||||
let mut reference = fast.clone();
|
||||
let mut gain = Smoothed::with_ms(1.0, 48000.0, 20.0);
|
||||
let mut ref_gain = gain;
|
||||
for block in 0..500 {
|
||||
if matches!(block, 2 | 90 | 180 | 270 | 360) {
|
||||
let on = block != 180;
|
||||
let db = if block == 270 { 0.0 } else { 9.0 };
|
||||
for bank in [&mut fast, &mut reference] {
|
||||
bank.set_band(0, on, FilterType::Peaking, 1733.0, db, 0.7, false);
|
||||
}
|
||||
gain.set(if block == 360 { 1.0 } else { 0.8 });
|
||||
ref_gain.set(gain.target());
|
||||
}
|
||||
let n = [0, 1, 17, 64, 513][block % 5];
|
||||
let input: Vec<f32> = (0..n).map(|i| if block > 400 { 0.0 } else { ((i + block * 13) as f32 * 0.17).sin() * 0.3 }).collect();
|
||||
let mut a = [input.clone(), input.clone()];
|
||||
let mut b = a.clone();
|
||||
let [al, ar] = &mut a;
|
||||
let [bl, br] = &mut b;
|
||||
fast.process(&mut [al, ar], &mut gain);
|
||||
reference.process_reference(&mut [bl, br], &mut ref_gain);
|
||||
for (a, b) in a.iter().flatten().zip(b.iter().flatten()) {
|
||||
assert_eq!(a.to_bits(), b.to_bits(), "block {block}");
|
||||
}
|
||||
assert_eq!(fast.counter, reference.counter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,31 +271,37 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn extreme_settings_stay_finite_and_denormal_free() {
|
||||
for e in effects() {
|
||||
for setting in [0.0f32, 1.0] {
|
||||
let mut fx = (e.create)(SR, 2);
|
||||
for p in e.params {
|
||||
fx.set_param(p.id, if setting == 0.0 { p.min } else { p.max });
|
||||
}
|
||||
let mut l = noise(3, 24000, 4.0);
|
||||
let mut r = noise(4, 24000, 4.0);
|
||||
l[100] = 1e6;
|
||||
r[200] = -1e6;
|
||||
fx.process(&mut [&mut l, &mut r]);
|
||||
assert!(l.iter().chain(&r).all(|v| v.is_finite()), "{} setting {setting}", e.id);
|
||||
// Long silence afterwards: output decays without denormals.
|
||||
fx.reset();
|
||||
let mut l = noise(3, 4800, 0.5);
|
||||
let mut r = noise(4, 4800, 0.5);
|
||||
fx.process(&mut [&mut l, &mut r]);
|
||||
for _ in 0..40 {
|
||||
let mut l = vec![0.0f32; 48000];
|
||||
let mut r = vec![0.0f32; 48000];
|
||||
fx.process(&mut [&mut l, &mut r]);
|
||||
assert!(l.iter().chain(&r).all(|v| v.is_finite() && !v.is_subnormal()), "{} setting {setting}: denormal/NaN in tail", e.id);
|
||||
}
|
||||
// One thread per effect. The tail stays 40 s long: delays, reverbs and graphic EQs still
|
||||
// produce output in its last second, some of it within 3x of the smallest normal f32.
|
||||
std::thread::scope(|scope| {
|
||||
for e in effects() {
|
||||
scope.spawn(move || {
|
||||
for setting in [0.0f32, 1.0] {
|
||||
let mut fx = (e.create)(SR, 2);
|
||||
for p in e.params {
|
||||
fx.set_param(p.id, if setting == 0.0 { p.min } else { p.max });
|
||||
}
|
||||
let mut l = noise(3, 24000, 4.0);
|
||||
let mut r = noise(4, 24000, 4.0);
|
||||
l[100] = 1e6;
|
||||
r[200] = -1e6;
|
||||
fx.process(&mut [&mut l, &mut r]);
|
||||
assert!(l.iter().chain(&r).all(|v| v.is_finite()), "{} setting {setting}", e.id);
|
||||
// Long silence afterwards: output decays without denormals.
|
||||
fx.reset();
|
||||
let mut l = noise(3, 4800, 0.5);
|
||||
let mut r = noise(4, 4800, 0.5);
|
||||
fx.process(&mut [&mut l, &mut r]);
|
||||
for _ in 0..40 {
|
||||
let mut l = vec![0.0f32; 48000];
|
||||
let mut r = vec![0.0f32; 48000];
|
||||
fx.process(&mut [&mut l, &mut r]);
|
||||
assert!(l.iter().chain(&r).all(|v| v.is_finite() && !v.is_subnormal()), "{} setting {setting}: denormal/NaN in tail", e.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5,12 +5,26 @@ use crate::{AudioEffect, ParamSpec, ParamValues, Smoothed, Unit, block_len, flus
|
||||
/// Fractional read from a circular buffer: `delay` samples behind write position `w`.
|
||||
#[inline]
|
||||
fn read_frac(buf: &[f32], w: usize, delay: f32) -> f32 {
|
||||
let len = buf.len();
|
||||
read_prepared(buf, w, prepare_delay(buf.len(), delay))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn prepare_delay(len: usize, delay: f32) -> (usize, f32) {
|
||||
let d = delay.clamp(1.0, (len - 2) as f32);
|
||||
let di = d.floor();
|
||||
let frac = d - di;
|
||||
let i0 = (w + len - di as usize) % len;
|
||||
let i1 = (i0 + len - 1) % len;
|
||||
// The delay is bounded by this ring's length, so wrapping needs no integer division.
|
||||
// Keep the fallback for very large rings whose f32 length rounds upward.
|
||||
let distance = di as usize;
|
||||
let distance = if distance <= len { distance } else { distance % len };
|
||||
(distance, frac)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn read_prepared(buf: &[f32], w: usize, (distance, frac): (usize, f32)) -> f32 {
|
||||
let len = buf.len();
|
||||
let i0 = if w >= distance { w - distance } else { len - (distance - w) };
|
||||
let i1 = if i0 == 0 { len - 1 } else { i0 - 1 };
|
||||
buf[i0] + (buf[i1] - buf[i0]) * frac
|
||||
}
|
||||
|
||||
@@ -103,7 +117,7 @@ impl AudioEffect for Delay {
|
||||
buf[self.w] = flush32(x + fb * yd);
|
||||
channels[ch][i] = x + mix * (yd - x);
|
||||
}
|
||||
self.w = (self.w + 1) % len;
|
||||
self.w = if self.w + 1 == len { 0 } else { self.w + 1 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,6 +146,7 @@ pub struct Reverb {
|
||||
len: [Smoothed; FDN],
|
||||
lp: [f32; FDN],
|
||||
gains: [f32; FDN],
|
||||
gain_key: ([f32; FDN], f32),
|
||||
damp: Smoothed,
|
||||
rt60: Smoothed,
|
||||
mix: Smoothed,
|
||||
@@ -162,6 +177,7 @@ impl Reverb {
|
||||
len: [sm(1.0); FDN],
|
||||
lp: [0.0; FDN],
|
||||
gains: [0.0; FDN],
|
||||
gain_key: ([0.0; FDN], 0.0),
|
||||
damp: sm(0.0),
|
||||
rt60: sm(1.0),
|
||||
mix: sm(0.0),
|
||||
@@ -195,6 +211,11 @@ impl Reverb {
|
||||
|
||||
fn update_gains(&mut self) {
|
||||
let rt = self.rt60.value().max(0.01);
|
||||
let lengths = self.len.map(|l| l.value());
|
||||
if self.gain_key == (lengths, rt) {
|
||||
return;
|
||||
}
|
||||
self.gain_key = (lengths, rt);
|
||||
for k in 0..FDN {
|
||||
// Attenuation per pass so the loop decays 60 dB in rt seconds.
|
||||
self.gains[k] = 10f32.powf(-3.0 * self.len[k].value() / (rt * self.sr));
|
||||
@@ -222,13 +243,20 @@ impl AudioEffect for Reverb {
|
||||
let line_len = self.lines[0].len();
|
||||
let pre_len = self.pre.len();
|
||||
let hh = 2.0 / FDN as f32;
|
||||
// During a settled block these positions cannot change. Preserve the original path
|
||||
// while smoothing, and preserve the 32-sample gain update grid in both cases.
|
||||
let fixed = self.len.iter().all(|l| !l.is_smoothing());
|
||||
let delays = self.len.map(|l| prepare_delay(line_len, l.value()));
|
||||
let predelay = (!self.pre_len.is_smoothing()).then(|| prepare_delay(pre_len, self.pre_len.value()));
|
||||
for i in 0..n {
|
||||
if self.counter == 0 {
|
||||
self.update_gains();
|
||||
}
|
||||
self.counter = (self.counter + 1) % 32;
|
||||
for l in &mut self.len {
|
||||
l.tick();
|
||||
if !fixed {
|
||||
for l in &mut self.len {
|
||||
l.tick();
|
||||
}
|
||||
}
|
||||
self.rt60.tick();
|
||||
let damp = self.damp.tick();
|
||||
@@ -241,13 +269,16 @@ impl AudioEffect for Reverb {
|
||||
}
|
||||
mono /= nch as f32;
|
||||
self.pre[self.pre_w] = mono;
|
||||
let input = read_frac(&self.pre, self.pre_w, pd);
|
||||
self.pre_w = (self.pre_w + 1) % pre_len;
|
||||
let input = match predelay {
|
||||
Some(delay) => read_prepared(&self.pre, self.pre_w, delay),
|
||||
None => read_frac(&self.pre, self.pre_w, pd),
|
||||
};
|
||||
self.pre_w = if self.pre_w + 1 == pre_len { 0 } else { self.pre_w + 1 };
|
||||
|
||||
let mut v = [0.0f32; FDN];
|
||||
let mut sum = 0.0;
|
||||
for k in 0..FDN {
|
||||
let d = read_frac(&self.lines[k], self.w, self.len[k].value());
|
||||
let d = if fixed { read_prepared(&self.lines[k], self.w, delays[k]) } else { read_frac(&self.lines[k], self.w, self.len[k].value()) };
|
||||
self.lp[k] = flush32(d + (self.lp[k] - d) * damp);
|
||||
v[k] = self.lp[k] * self.gains[k];
|
||||
sum += v[k];
|
||||
@@ -259,7 +290,7 @@ impl AudioEffect for Reverb {
|
||||
wl += SIGN_L[k] * v[k];
|
||||
wr += SIGN_R[k] * v[k];
|
||||
}
|
||||
self.w = (self.w + 1) % line_len;
|
||||
self.w = if self.w + 1 == line_len { 0 } else { self.w + 1 };
|
||||
let (wl, wr) = (wl * 0.35, wr * 0.35);
|
||||
for (c, ch) in channels.iter_mut().enumerate() {
|
||||
let x = ch[i];
|
||||
@@ -370,3 +401,129 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn read_frac_reference(buf: &[f32], w: usize, delay: f32) -> f32 {
|
||||
let len = buf.len();
|
||||
let d = delay.clamp(1.0, (len - 2) as f32);
|
||||
let di = d.floor();
|
||||
let frac = d - di;
|
||||
let i0 = (w + len - di as usize) % len;
|
||||
let i1 = (i0 + len - 1) % len;
|
||||
buf[i0] + (buf[i1] - buf[i0]) * frac
|
||||
}
|
||||
#[cfg(test)]
|
||||
impl Reverb {
|
||||
fn update_gains_reference(&mut self) {
|
||||
let rt = self.rt60.value().max(0.01);
|
||||
for k in 0..FDN {
|
||||
// Attenuation per pass so the loop decays 60 dB in rt seconds.
|
||||
self.gains[k] = 10f32.powf(-3.0 * self.len[k].value() / (rt * self.sr));
|
||||
}
|
||||
}
|
||||
fn process_reference(&mut self, channels: &mut [&mut [f32]]) {
|
||||
let n = block_len(channels);
|
||||
let nch = channels.len();
|
||||
if nch == 0 {
|
||||
return;
|
||||
}
|
||||
let line_len = self.lines[0].len();
|
||||
let pre_len = self.pre.len();
|
||||
let hh = 2.0 / FDN as f32;
|
||||
for i in 0..n {
|
||||
if self.counter == 0 {
|
||||
self.update_gains_reference();
|
||||
}
|
||||
self.counter = (self.counter + 1) % 32;
|
||||
for l in &mut self.len {
|
||||
l.tick();
|
||||
}
|
||||
self.rt60.tick();
|
||||
let damp = self.damp.tick();
|
||||
let mix = self.mix.tick();
|
||||
let pd = self.pre_len.tick();
|
||||
|
||||
let mut mono = 0.0;
|
||||
for ch in channels.iter() {
|
||||
mono += ch[i];
|
||||
}
|
||||
mono /= nch as f32;
|
||||
self.pre[self.pre_w] = mono;
|
||||
let input = read_frac_reference(&self.pre, self.pre_w, pd);
|
||||
self.pre_w = (self.pre_w + 1) % pre_len;
|
||||
|
||||
let mut v = [0.0f32; FDN];
|
||||
let mut sum = 0.0;
|
||||
for k in 0..FDN {
|
||||
let d = read_frac_reference(&self.lines[k], self.w, self.len[k].value());
|
||||
self.lp[k] = flush32(d + (self.lp[k] - d) * damp);
|
||||
v[k] = self.lp[k] * self.gains[k];
|
||||
sum += v[k];
|
||||
}
|
||||
let (mut wl, mut wr) = (0.0, 0.0);
|
||||
for k in 0..FDN {
|
||||
let fbk = v[k] - hh * sum;
|
||||
self.lines[k][self.w] = flush32(fbk + input * 0.5);
|
||||
wl += SIGN_L[k] * v[k];
|
||||
wr += SIGN_R[k] * v[k];
|
||||
}
|
||||
self.w = (self.w + 1) % line_len;
|
||||
let (wl, wr) = (wl * 0.35, wr * 0.35);
|
||||
for (c, ch) in channels.iter_mut().enumerate() {
|
||||
let x = ch[i];
|
||||
let wet = if nch == 1 {
|
||||
0.5 * (wl + wr)
|
||||
} else if c % 2 == 0 {
|
||||
wl
|
||||
} else {
|
||||
wr
|
||||
};
|
||||
ch[i] = x + mix * (wet - x);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod throughput_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn circular_reads_match_modulo_reference() {
|
||||
for len in [3, 4, 17, 257, 14078] {
|
||||
let buf: Vec<f32> = (0..len).map(|i| (i as f32 * 0.17).sin()).collect();
|
||||
for w in 0..len {
|
||||
for delay in [-10.0, 0.0, 1.0, 1.25, 2.75, len as f32 - 2.0, len as f32, f32::INFINITY] {
|
||||
assert_eq!(read_frac(&buf, w, delay).to_bits(), read_frac_reference(&buf, w, delay).to_bits());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn reverb_automation_and_tail_match_original_loop() {
|
||||
let mut fast = Reverb::new(48000.0, 2);
|
||||
let mut reference = Reverb::new(48000.0, 2);
|
||||
for block in 0..800 {
|
||||
if matches!(block, 1 | 80 | 200 | 320 | 440) {
|
||||
for fx in [&mut fast, &mut reference] {
|
||||
fx.set_param("size", (block % 101) as f32);
|
||||
fx.set_param("decay", 0.1 + (block % 20) as f32);
|
||||
fx.set_param("predelay", (block % 200) as f32);
|
||||
}
|
||||
}
|
||||
if block == 600 {
|
||||
fast.reset();
|
||||
reference.reset();
|
||||
}
|
||||
let n = [0, 1, 17, 64, 513][block % 5];
|
||||
let input: Vec<f32> = (0..n).map(|i| if block > 450 { 0.0 } else { ((i + block * 13) as f32 * 0.17).sin() * 0.3 }).collect();
|
||||
let mut a = [input.clone(), input];
|
||||
let mut b = a.clone();
|
||||
let [al, ar] = &mut a;
|
||||
let [bl, br] = &mut b;
|
||||
fast.process(&mut [al, ar]);
|
||||
reference.process_reference(&mut [bl, br]);
|
||||
for (a, b) in a.iter().flatten().zip(b.iter().flatten()) {
|
||||
assert_eq!(a.to_bits(), b.to_bits(), "block {block}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
rmcp = { version = "3.5", default-features = false, features = ["server", "macros", "transport-io"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time", "fs"] }
|
||||
base64 = "0.22"
|
||||
schemars = "1"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
tempfile = "3"
|
||||
|
||||
+141
-25
@@ -1,4 +1,5 @@
|
||||
//! Client for the desktop app's JSON-lines control protocol. One connection, reconnect on failure.
|
||||
//! Client for the desktop app's JSON-lines control protocol. A connection per request in flight,
|
||||
//! reused once its reply is in; reconnect on failure.
|
||||
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
@@ -12,24 +13,32 @@ use crate::AutomationError;
|
||||
|
||||
/// How often a blocking export's app job is polled.
|
||||
const JOB_POLL: Duration = Duration::from_millis(250);
|
||||
/// Idle connections kept for reuse; the app serves at most 16 at once.
|
||||
const MAX_IDLE: usize = 4;
|
||||
|
||||
type Conn = (BufReader<tokio::net::tcp::OwnedReadHalf>, tokio::net::tcp::OwnedWriteHalf);
|
||||
|
||||
pub struct BridgeClient {
|
||||
addr: String,
|
||||
conn: Mutex<Option<Conn>>,
|
||||
/// Inherited from the same owner-controlled environment as the desktop app.
|
||||
token: Option<String>,
|
||||
/// Connections with no request in flight. A request takes one (or opens one) for its whole
|
||||
/// round trip and puts it back only once the reply is read, so a slow or abandoned request
|
||||
/// never holds up the others or leaves its late reply for the next one to read (#1538).
|
||||
idle: Mutex<Vec<Conn>>,
|
||||
next_id: AtomicU64,
|
||||
}
|
||||
|
||||
impl BridgeClient {
|
||||
/// `addr` such as `127.0.0.1:9876` (loopback only).
|
||||
/// `addr` such as `127.0.0.1:9876` (loopback only). Reads `FILMCRAFT_CONTROL_TOKEN`
|
||||
/// from the environment; the desktop app requires this credential on every request.
|
||||
pub fn new(addr: impl Into<String>) -> Result<Self, AutomationError> {
|
||||
let addr = addr.into();
|
||||
let host = addr.rsplit_once(':').map(|(h, _)| h).unwrap_or(&addr);
|
||||
if !matches!(host, "127.0.0.1" | "localhost" | "[::1]" | "::1") {
|
||||
return Err(AutomationError::BadRequest(format!("bridge address must be loopback, got `{addr}`")));
|
||||
}
|
||||
Ok(Self { addr, conn: Mutex::new(None), next_id: AtomicU64::new(1) })
|
||||
Ok(Self { addr, token: std::env::var("FILMCRAFT_CONTROL_TOKEN").ok(), idle: Mutex::new(Vec::new()), next_id: AtomicU64::new(1) })
|
||||
}
|
||||
|
||||
/// Run engine command `id` in the app. A blocking export (`file.exportMedia` with
|
||||
@@ -47,14 +56,14 @@ impl BridgeClient {
|
||||
}
|
||||
let mut start = self.call("engine.execute", json!({"command": id, "params": params})).await?;
|
||||
let Some(job) = start.get("job").and_then(Value::as_u64) else { return Ok(start) };
|
||||
let result = loop {
|
||||
let (result, offline) = loop {
|
||||
tokio::time::sleep(JOB_POLL).await;
|
||||
let jobs = self.call("engine.execute", json!({"command": "jobs.list", "params": {}})).await?;
|
||||
let Some(j) = jobs.as_array().and_then(|a| a.iter().find(|j| j["id"].as_u64() == Some(job))).cloned() else {
|
||||
break Value::Null;
|
||||
break (Value::Null, None);
|
||||
};
|
||||
if j["finished"].as_bool() == Some(true) {
|
||||
break j["result"].clone();
|
||||
break (j["result"].clone(), j.get("offlineMedia").cloned());
|
||||
}
|
||||
};
|
||||
if let Some(e) = result.get("error").and_then(Value::as_str) {
|
||||
@@ -62,27 +71,24 @@ impl BridgeClient {
|
||||
}
|
||||
if let Some(o) = start.as_object_mut() {
|
||||
o.insert("result".into(), result);
|
||||
if let Some(off) = offline {
|
||||
o.insert("offlineMedia".into(), off);
|
||||
}
|
||||
}
|
||||
Ok(start)
|
||||
}
|
||||
|
||||
/// Call a control method; returns `result` or the app's error.
|
||||
pub async fn call(&self, method: &str, params: Value) -> Result<Value, AutomationError> {
|
||||
let mut guard = self.conn.lock().await;
|
||||
for attempt in 0..2 {
|
||||
if guard.is_none() {
|
||||
let s = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&self.addr))
|
||||
.await
|
||||
.map_err(|_| AutomationError::Bridge(format!("timed out connecting to {}", self.addr)))?
|
||||
.map_err(|e| AutomationError::Bridge(format!("cannot connect to {} ({e}); start the app with `filmcraft --control <port>`", self.addr)))?;
|
||||
let (r, w) = s.into_split();
|
||||
*guard = Some((BufReader::new(r), w));
|
||||
}
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let Some(conn) = guard.as_mut() else {
|
||||
return Err(AutomationError::Bridge(format!("not connected to {}", self.addr)));
|
||||
// A retry gets a fresh connection: the failed one may have been closed by an app restart.
|
||||
let idle = if attempt == 0 { self.idle.lock().await.pop() } else { None };
|
||||
let mut conn = match idle {
|
||||
Some(c) => c,
|
||||
None => self.connect().await?,
|
||||
};
|
||||
let line = format!("{}\n", json!({"id": id, "method": method, "params": params}));
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let line = format!("{}\n", json!({"id": id, "token": self.token, "method": method, "params": params}));
|
||||
let res: Result<Value, AutomationError> = async {
|
||||
conn.1.write_all(line.as_bytes()).await.map_err(|e| AutomationError::Bridge(e.to_string()))?;
|
||||
let mut buf = String::new();
|
||||
@@ -95,21 +101,32 @@ impl BridgeClient {
|
||||
.await;
|
||||
match res {
|
||||
Ok(v) => {
|
||||
let mut idle = self.idle.lock().await;
|
||||
if idle.len() < MAX_IDLE {
|
||||
idle.push(conn);
|
||||
}
|
||||
drop(idle);
|
||||
return if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||
} else {
|
||||
Err(AutomationError::App(v.get("error").and_then(Value::as_str).unwrap_or("error").to_string()))
|
||||
};
|
||||
}
|
||||
Err(e) if attempt == 0 => {
|
||||
*guard = None;
|
||||
let _ = e;
|
||||
}
|
||||
Err(_) if attempt == 0 => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(AutomationError::Bridge("unreachable".into()))
|
||||
}
|
||||
|
||||
async fn connect(&self) -> Result<Conn, AutomationError> {
|
||||
let s = tokio::time::timeout(Duration::from_secs(5), TcpStream::connect(&self.addr))
|
||||
.await
|
||||
.map_err(|_| AutomationError::Bridge(format!("timed out connecting to {}", self.addr)))?
|
||||
.map_err(|e| AutomationError::Bridge(format!("cannot connect to {} ({e}); start the app with `filmcraft --control <port>`", self.addr)))?;
|
||||
let (r, w) = s.into_split();
|
||||
Ok((BufReader::new(r), w))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -121,6 +138,11 @@ mod tests {
|
||||
/// A stand-in for the app's control server: the export starts job 7, which `jobs.list` reports
|
||||
/// running twice and then finished with `result`. Every request is recorded.
|
||||
async fn fake_app(result: Value) -> (String, Arc<Mutex<Vec<Value>>>) {
|
||||
fake_app_with(result, None).await
|
||||
}
|
||||
|
||||
/// [`fake_app`] whose finished job also lists `offline` as its `offlineMedia`.
|
||||
async fn fake_app_with(result: Value, offline: Option<Value>) -> (String, Arc<Mutex<Vec<Value>>>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let seen = Arc::new(Mutex::new(Vec::new()));
|
||||
@@ -138,7 +160,11 @@ mod tests {
|
||||
Some("jobs.list") => {
|
||||
polls += 1;
|
||||
let finished = polls >= 3;
|
||||
json!([{"id": 7, "finished": finished, "result": if finished { result.clone() } else { Value::Null }}])
|
||||
let mut j = json!({"id": 7, "finished": finished, "result": if finished { result.clone() } else { Value::Null }});
|
||||
if let Some(o) = offline.clone().filter(|_| finished) {
|
||||
j["offlineMedia"] = o;
|
||||
}
|
||||
json!([j])
|
||||
}
|
||||
_ => json!(null),
|
||||
};
|
||||
@@ -165,6 +191,16 @@ mod tests {
|
||||
assert_eq!(seen.iter().filter(|q| q["params"]["command"] == "jobs.list").count(), 3);
|
||||
}
|
||||
|
||||
/// The offline media a polled export drew comes back as in a blocking export's reply (#1054).
|
||||
#[tokio::test]
|
||||
async fn a_polled_export_reports_its_offline_media() {
|
||||
let off = json!([{"item": 8, "status": "missing"}]);
|
||||
let (addr, _) = fake_app_with(json!({"frames": 3}), Some(off.clone())).await;
|
||||
let b = BridgeClient::new(addr).unwrap();
|
||||
let r = b.execute("file.exportMedia", json!({"path": "/tmp/out.mov", "wait": true})).await.unwrap();
|
||||
assert_eq!(r["offlineMedia"], off, "{r}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_blocking_export_is_an_error() {
|
||||
let (addr, _) = fake_app(json!({"error": "encode: disk full"})).await;
|
||||
@@ -173,6 +209,71 @@ mod tests {
|
||||
assert!(e.to_string().contains("export failed: encode: disk full"), "{e}");
|
||||
}
|
||||
|
||||
/// A stand-in for the app that serves any number of connections and answers each request on
|
||||
/// its own, as the app does: `slow` after `slow_reply` (never when `None`), anything else at
|
||||
/// once with its command id. The ids of the requests it receives are sent to the returned channel.
|
||||
async fn slow_app(slow_reply: Option<Duration>) -> (String, tokio::sync::mpsc::UnboundedReceiver<String>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let (seen, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((s, _)) = listener.accept().await {
|
||||
let seen = seen.clone();
|
||||
tokio::spawn(async move {
|
||||
let (r, w) = s.into_split();
|
||||
let w = Arc::new(Mutex::new(w));
|
||||
let mut lines = BufReader::new(r).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let req: Value = serde_json::from_str(&line).unwrap();
|
||||
let cmd = req["params"]["command"].as_str().unwrap_or_default().to_string();
|
||||
let _ = seen.send(cmd.clone());
|
||||
let delay = if cmd == "slow" { slow_reply } else { Some(Duration::ZERO) };
|
||||
let w = w.clone();
|
||||
tokio::spawn(async move {
|
||||
let Some(d) = delay else { return };
|
||||
tokio::time::sleep(d).await;
|
||||
let line = format!("{}\n", json!({"id": req["id"], "ok": true, "result": cmd}));
|
||||
let _ = w.lock().await.write_all(line.as_bytes()).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
(addr, rx)
|
||||
}
|
||||
|
||||
/// A command the app is slow to answer holds up only itself: a later call is answered at once
|
||||
/// instead of queueing behind it, and behind every call queued after it (#1538).
|
||||
#[tokio::test]
|
||||
async fn a_slow_command_does_not_block_later_calls() {
|
||||
let (addr, mut seen) = slow_app(None).await;
|
||||
let b = Arc::new(BridgeClient::new(addr).unwrap());
|
||||
let slow = tokio::spawn({
|
||||
let b = b.clone();
|
||||
async move { b.execute("slow", json!({})).await }
|
||||
});
|
||||
assert_eq!(seen.recv().await.as_deref(), Some("slow"));
|
||||
let fast = tokio::time::timeout(Duration::from_secs(5), b.execute("fast", json!({}))).await;
|
||||
assert_eq!(fast.expect("the later call waited on the slow one").unwrap(), json!("fast"));
|
||||
slow.abort();
|
||||
}
|
||||
|
||||
/// A call given up while it waits leaves no late reply on a connection for the next call to read
|
||||
/// as its own (#1538).
|
||||
#[tokio::test]
|
||||
async fn an_abandoned_call_leaves_no_late_reply_behind() {
|
||||
let (addr, mut seen) = slow_app(Some(Duration::from_millis(100))).await;
|
||||
let b = Arc::new(BridgeClient::new(addr).unwrap());
|
||||
let slow = tokio::spawn({
|
||||
let b = b.clone();
|
||||
async move { b.execute("slow", json!({})).await }
|
||||
});
|
||||
assert_eq!(seen.recv().await.as_deref(), Some("slow"));
|
||||
slow.abort();
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert_eq!(b.execute("fast", json!({})).await.unwrap(), json!("fast"));
|
||||
}
|
||||
|
||||
/// Everything else is forwarded unchanged.
|
||||
#[tokio::test]
|
||||
async fn other_commands_are_forwarded() {
|
||||
@@ -184,4 +285,19 @@ mod tests {
|
||||
assert_eq!(seen.len(), 2);
|
||||
assert_eq!(seen[1]["params"]["command"], "sequence.inspect");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn credential_is_sent_on_every_request() {
|
||||
let (addr, seen) = fake_app(Value::Null).await;
|
||||
let mut b = BridgeClient::new(addr).unwrap();
|
||||
// Inject directly to avoid mutating the process environment in parallel tests.
|
||||
let token = "0123456789abcdef".repeat(4);
|
||||
b.token = Some(token.clone());
|
||||
b.call("ui.inspect", json!({})).await.unwrap();
|
||||
b.execute("file.saveAs", json!({"path": "/owner/project.fcproj"})).await.unwrap();
|
||||
let seen = seen.lock().await;
|
||||
assert_eq!(seen.len(), 2);
|
||||
assert!(seen.iter().all(|request| request["token"] == token));
|
||||
assert_eq!(seen[1]["params"]["params"]["path"], "/owner/project.fcproj");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ impl FilmcraftMcp {
|
||||
rep.report(done, total, j["status"].as_str().unwrap_or_default()).await;
|
||||
}
|
||||
if finished {
|
||||
let offline = state.as_ref().and_then(|j| j.get("offlineMedia").cloned());
|
||||
let result = state.map(|j| j["result"].clone()).unwrap_or(Value::Null);
|
||||
if let Some(e) = result.get("error").and_then(Value::as_str) {
|
||||
return CallToolResult::error(vec![Content::text(format!("export failed: {e}"))]);
|
||||
@@ -79,6 +80,9 @@ impl FilmcraftMcp {
|
||||
let mut out = start;
|
||||
if let Some(o) = out.as_object_mut() {
|
||||
o.insert("result".into(), result);
|
||||
if let Some(off) = offline {
|
||||
o.insert("offlineMedia".into(), off);
|
||||
}
|
||||
}
|
||||
return CallToolResult::success(vec![Content::text(serde_json::to_string_pretty(&out).unwrap_or_default())]);
|
||||
}
|
||||
|
||||
@@ -278,9 +278,22 @@ impl FilmcraftMcp {
|
||||
}
|
||||
|
||||
/// The project tree plus the active sequence (null when there is none).
|
||||
///
|
||||
/// `project.inspect` and `sequence.inspect` are two separate calls (two lock scopes in
|
||||
/// headless mode, two round trips to the app in bridge mode), so the active sequence can
|
||||
/// change between them if another client, or the app's own UI, switches sequence tabs in
|
||||
/// that window (#1477). Pinning `sequence.inspect` to the `activeSequence` id this
|
||||
/// `project.inspect` just reported keeps the pair consistent: the returned `sequence`, when
|
||||
/// not null, always describes the sequence the returned `project.activeSequence` names.
|
||||
async fn document(&self) -> Result<Value, AutomationError> {
|
||||
let project = self.run("project.inspect", json!({})).await?;
|
||||
let sequence = self.run("sequence.inspect", json!({})).await.ok();
|
||||
// `ItemId` is a u64 (`item_p` parses the `item` param with `u64_p`): read it as one too,
|
||||
// not `as_i64`, so an id above `i64::MAX` is still looked up instead of silently going null.
|
||||
let active = project.get("activeSequence").and_then(Value::as_u64);
|
||||
let sequence = match active {
|
||||
Some(id) => self.run("sequence.inspect", json!({"item": id})).await.ok(),
|
||||
None => None,
|
||||
};
|
||||
Ok(json!({"project": project, "sequence": sequence}))
|
||||
}
|
||||
|
||||
@@ -551,15 +564,21 @@ impl FilmcraftMcp {
|
||||
|
||||
impl FilmcraftMcp {
|
||||
async fn screenshot(&self, b: &BridgeClient, panel: Option<String>, max: u32) -> Result<CallToolResult, McpError> {
|
||||
let path = std::env::temp_dir().join(format!("filmcraft-mcp-{}.png", std::process::id()));
|
||||
// The desktop bridge needs a pathname, so use a freshly-created,
|
||||
// owner-only directory rather than exposing image bytes at a
|
||||
// predictable shared-temp filename.
|
||||
let dir = match tempfile::tempdir() {
|
||||
Ok(dir) => dir,
|
||||
Err(e) => return Ok(fail(format!("screenshot: {e}"))),
|
||||
};
|
||||
let path = dir.path().join("screenshot.png");
|
||||
if let Err(e) = b.call("ui.screenshot", json!({"path": path.to_string_lossy(), "panel": panel})).await {
|
||||
return Ok(fail(e));
|
||||
}
|
||||
let bytes = match std::fs::read(&path) {
|
||||
let bytes = match tokio::fs::read(&path).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => return Ok(fail(format!("screenshot: {e}"))),
|
||||
};
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let bytes = match image::load_from_memory(&bytes) {
|
||||
Ok(img) => {
|
||||
let rgba = img.to_rgba8();
|
||||
@@ -998,4 +1017,73 @@ mod tests {
|
||||
let r = m.render_frame(Parameters(RenderParams { seconds: Some(0.5), max_side: Some(64) })).await.unwrap();
|
||||
assert_ne!(r.is_error, Some(true));
|
||||
}
|
||||
|
||||
/// `doc_inspect` composes `project.inspect` and `sequence.inspect` as two separate round
|
||||
/// trips to the app. If the active sequence changes between them (another client, or the
|
||||
/// app's own UI, switching tabs in that window), an unscoped `sequence.inspect` answers for
|
||||
/// whichever sequence is active *now*, not the one `project.inspect` just reported, so the
|
||||
/// pair comes back inconsistent (#1477, reproduced with a real bridge client and a flipping
|
||||
/// `sequence.open` in `race_test.py`: ~300/300 calls mismatched). Pinning the second call to
|
||||
/// the id the first one reported keeps `sequence`, when not null, always describing the
|
||||
/// sequence `project.activeSequence` names.
|
||||
#[tokio::test]
|
||||
async fn doc_inspect_pins_the_sequence_to_the_reported_active_id() {
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
tokio::spawn(async move {
|
||||
let (s, _) = listener.accept().await.unwrap();
|
||||
let (r, mut w) = s.into_split();
|
||||
let mut lines = tokio::io::BufReader::new(r).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let req: Value = serde_json::from_str(&line).unwrap();
|
||||
let result = match req["params"]["command"].as_str().unwrap_or_default() {
|
||||
"project.inspect" => json!({"activeSequence": 21}),
|
||||
// The app's own active sequence has since moved on to 54; only an explicit
|
||||
// `item` still gets 21's data (an unscoped call would wrongly answer for 54).
|
||||
"sequence.inspect" => json!({"id": req["params"]["params"]["item"].as_i64().unwrap_or(54)}),
|
||||
_ => Value::Null,
|
||||
};
|
||||
let line = format!("{}\n", json!({"id": req["id"], "ok": true, "result": result}));
|
||||
w.write_all(line.as_bytes()).await.unwrap();
|
||||
}
|
||||
});
|
||||
let m = FilmcraftMcp::bridge(&addr).unwrap();
|
||||
let v = m.document().await.unwrap();
|
||||
assert_eq!(v["project"]["activeSequence"], json!(21), "{v}");
|
||||
assert_eq!(v["sequence"]["id"], json!(21), "sequence must describe the reported active id, not whatever is active now: {v}");
|
||||
}
|
||||
|
||||
/// `activeSequence` is a `u64` (`ItemId`'s representation; `item_p` parses the `item` param
|
||||
/// with `u64_p`), so an id above `i64::MAX` must still round-trip through `document()`:
|
||||
/// reading it with `as_i64` would silently return `None` and make `sequence` go null instead
|
||||
/// of inspecting the (valid) id project.inspect reported.
|
||||
#[tokio::test]
|
||||
async fn doc_inspect_handles_an_active_sequence_id_above_i64_max() {
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
|
||||
const HUGE: u64 = i64::MAX as u64 + 1;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
tokio::spawn(async move {
|
||||
let (s, _) = listener.accept().await.unwrap();
|
||||
let (r, mut w) = s.into_split();
|
||||
let mut lines = tokio::io::BufReader::new(r).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
let req: Value = serde_json::from_str(&line).unwrap();
|
||||
let result = match req["params"]["command"].as_str().unwrap_or_default() {
|
||||
"project.inspect" => json!({"activeSequence": HUGE}),
|
||||
// Echoes back whatever `item` it was actually asked for, so the test can
|
||||
// check that `document()` asked for `HUGE` rather than nothing at all.
|
||||
"sequence.inspect" => json!({"id": req["params"]["params"]["item"]}),
|
||||
_ => Value::Null,
|
||||
};
|
||||
let line = format!("{}\n", json!({"id": req["id"], "ok": true, "result": result}));
|
||||
w.write_all(line.as_bytes()).await.unwrap();
|
||||
}
|
||||
});
|
||||
let m = FilmcraftMcp::bridge(&addr).unwrap();
|
||||
let v = m.document().await.unwrap();
|
||||
assert_eq!(v["project"]["activeSequence"].as_u64(), Some(HUGE), "{v}");
|
||||
assert_eq!(v["sequence"]["id"].as_u64(), Some(HUGE), "an id above i64::MAX must still be inspected, not dropped to a null sequence: {v}");
|
||||
}
|
||||
}
|
||||
|
||||
+19
-9
@@ -43,12 +43,12 @@ reference) is used only as an external test oracle.
|
||||
|
||||
## Accuracy
|
||||
|
||||
`tests/oracle_intra.rs` encodes all-intra streams with ffmpeg's libsvtav1 (with and without the
|
||||
`tests/it/oracle_intra.rs` encodes all-intra streams with ffmpeg's libsvtav1 (with and without the
|
||||
in-loop filters; 64×64 and 128×128 superblocks; intra edge filter on and off) and compares every
|
||||
decoded frame with libdav1d: **bit-exact** on all 13 fixtures (8- and 10-bit 4:2:0; 128×128 to
|
||||
1280×720 including odd sizes; testsrc2, mandelbrot, gradients and noise; presets 1–8).
|
||||
|
||||
`tests/conformance.rs` downloads libaom's conformance test vectors on first use
|
||||
`tests/it/conformance.rs` downloads libaom's conformance test vectors on first use
|
||||
(storage.googleapis.com/aom-test-data) and compares with libdav1d: `av1-1-b8-02-allintra`
|
||||
(39 frames, deblocking + CDEF + self-guided restoration), `05-mv`, `06-mfmv`, `24-monochrome`
|
||||
and `b10-23-film_grain-50` are **bit-exact** in the default run. The ignored
|
||||
@@ -58,8 +58,8 @@ and `b10-23-film_grain-50` are **bit-exact** in the default run. The ignored
|
||||
with scaled inter-layer prediction), `24-monochrome` (8 and 10-bit) and
|
||||
`16-intra_only-intrabc-extreme-dv` (1080p intra block copy), `23-film_grain-50` (8 and 10-bit).
|
||||
|
||||
`tests/oracle_inter.rs` also checks draft mode: every unflagged frame stays bit-exact with
|
||||
libdav1d and only flagged frames differ, with 1 and 4 threads. `tests/oracle_inter.rs` encodes SVT-AV1 GOPs (hierarchical references, compound prediction,
|
||||
`tests/it/oracle_inter.rs` also checks draft mode: every unflagged frame stays bit-exact with
|
||||
libdav1d and only flagged frames differ, with 1 and 4 threads. `tests/it/oracle_inter.rs` encodes SVT-AV1 GOPs (hierarchical references, compound prediction,
|
||||
OBMC / warped motion, motion-field projection, all loop filters; presets 3–8, 8- and 10-bit, odd
|
||||
sizes), plus super-resolution (denominator 12) and film-grain streams, and compares every frame
|
||||
with libdav1d: **bit-exact** on all 6 fixtures.
|
||||
@@ -70,16 +70,19 @@ with libdav1d: **bit-exact** on all 6 fixtures.
|
||||
wasm32, or without the default `threads` feature, everything runs on the calling thread). Headers are parsed in order; each frame is
|
||||
then decoded by a frame worker once the reference frames it reads are finished, so independent
|
||||
frames (hierarchical GOP layers) decode concurrently; tiles of a frame decode in parallel into
|
||||
private buffers that are merged afterwards. Results never depend on scheduling (the oracle tests
|
||||
compare 1 thread, many threads and libdav1d). With frame threads, pictures may be returned by a
|
||||
private buffers that are merged afterwards; the in-loop filters (deblocking, CDEF, loop
|
||||
restoration) each run in parallel bands of 64 rows (`postfilter.rs` explains why the bands are
|
||||
independent). Results never depend on scheduling (the oracle tests compare 1 thread, many threads
|
||||
and libdav1d). With frame threads, pictures may be returned by a
|
||||
later `decode_pts` call or by `flush`; each carries the `pts` of the temporal unit that showed it.
|
||||
|
||||
`tests/perf.rs` (ignored) prints single-threaded fps (thread CPU time), per-stage ms/frame
|
||||
(`Decoder::stats()`) and all-core fps for 1080p fixtures. The hot loops (sub-pixel filters,
|
||||
`tests/it/perf.rs` (ignored) prints single-threaded fps (thread CPU time), per-stage ms/frame
|
||||
(`Decoder::stats()`) and all-core fps with its per-stage ms/frame for 1080p fixtures (key frames,
|
||||
GOPs, tiles, low delay) and a single-tile 2160p GOP; `AV1_PERF_ONLY=a,b` picks fixtures. The hot loops (sub-pixel filters,
|
||||
CDEF, inverse transforms, deblocking, entropy decoding) are written for auto-vectorisation.
|
||||
|
||||
Measured 2026-10-01 on a 14-core Apple Silicon Mac (release build, SVT-AV1 preset 8 1080p
|
||||
fixtures from `tests/perf.rs`), before (`31f8b3d`: hot loops only, no threading) and after tile / frame /
|
||||
fixtures from `tests/it/perf.rs`), before (`31f8b3d`: hot loops only, no threading) and after tile / frame /
|
||||
post-filter threading. The machine was heavily loaded by other builds during both runs (load
|
||||
average 200–400), so the all-core wall figures understate what an idle machine reaches and are
|
||||
noisy; single-threaded figures use thread CPU time and are comparable.
|
||||
@@ -122,6 +125,13 @@ motion vectors nor CDFs reach another frame — skip deblocking, CDEF and loop r
|
||||
(super-resolution still runs, it sets the output size) and are flagged `Picture::draft`. In
|
||||
SVT-AV1's hierarchical GOPs that is the top layer, half of the frames.
|
||||
|
||||
## Limits
|
||||
|
||||
Frames beyond the AV1 level 6.x limits (Annex A: at most 16384 wide, 8704 high and 35 651 584 luma samples
|
||||
in total, so 16384 x 2176 or 8192 x 4352 at most) are refused with `Error::Invalid("frame size")`. The sequence
|
||||
header can code up to 65536 x 65536, which would ask for gigabytes of buffers from a few bytes of hostile input;
|
||||
no real stream comes close to level 6.3, the highest defined level.
|
||||
|
||||
## API
|
||||
|
||||
```rust
|
||||
|
||||
+34
-19
@@ -1,35 +1,45 @@
|
||||
//! CDEF (7.15).
|
||||
|
||||
use crate::frame::{FrameBuf, Plane};
|
||||
use crate::frame::{FrameBuf, RowBand};
|
||||
use crate::par::Pool;
|
||||
use crate::postfilter::bands;
|
||||
use crate::spec_tables::*;
|
||||
use crate::state::FrameState;
|
||||
|
||||
/// Run CDEF on `fs.cur`; returns the CdefFrame, or None when no block is filtered (CdefFrame
|
||||
/// equals CurrFrame).
|
||||
pub(crate) fn apply(fs: &FrameState) -> Option<FrameBuf> {
|
||||
/// equals CurrFrame). Bands of 64 luma rows (whole 8x8 blocks) are filtered in parallel: every
|
||||
/// block reads only CurrFrame and writes only its own samples.
|
||||
pub(crate) fn apply(fs: &FrameState, pool: &Pool) -> Option<FrameBuf> {
|
||||
let fh = &fs.fh;
|
||||
if fh.coded_lossless || fh.allow_intrabc || !fs.seq.enable_cdef {
|
||||
return None;
|
||||
}
|
||||
let (mi_rows, mi_cols) = (fh.mi_rows as usize, fh.mi_cols as usize);
|
||||
let mut out = fs.cur.clone();
|
||||
let mut any = false;
|
||||
let mut r = 0;
|
||||
while r < mi_rows {
|
||||
let mut c = 0;
|
||||
while c < mi_cols {
|
||||
let idx = fs.cdef_idx(r & !15, c & !15);
|
||||
if idx >= 0 && cdef_block(fs, &mut out, r, c, idx as usize) {
|
||||
any = true;
|
||||
let ranges = bands(pool, mi_rows * 4);
|
||||
let mut planes = out.row_bands(&ranges);
|
||||
let any = pool.map_mut(&mut planes, |b, band| {
|
||||
let (start, end) = ranges[b];
|
||||
let mut any = false;
|
||||
let mut r = start / 4;
|
||||
while r < mi_rows.min(end / 4) {
|
||||
let mut c = 0;
|
||||
while c < mi_cols {
|
||||
let idx = fs.cdef_idx(r & !15, c & !15);
|
||||
if idx >= 0 && cdef_block(fs, band, r, c, idx as usize) {
|
||||
any = true;
|
||||
}
|
||||
c += 2;
|
||||
}
|
||||
c += 2;
|
||||
r += 2;
|
||||
}
|
||||
r += 2;
|
||||
}
|
||||
any.then_some(out)
|
||||
any
|
||||
});
|
||||
drop(planes);
|
||||
any.contains(&true).then_some(out)
|
||||
}
|
||||
|
||||
fn cdef_block(fs: &FrameState, out: &mut FrameBuf, r: usize, c: usize, idx: usize) -> bool {
|
||||
fn cdef_block(fs: &FrameState, out: &mut [RowBand; 3], r: usize, c: usize, idx: usize) -> bool {
|
||||
let mi = &fs.mi;
|
||||
let sk = |rr: usize, cc: usize| -> bool { if rr < mi.rows && cc < mi.cols { mi.skip[mi.idx(rr, cc)] } else { true } };
|
||||
let skip = sk(r, c) && sk(r + 1, c) && sk(r, c + 1) && sk(r + 1, c + 1);
|
||||
@@ -125,7 +135,7 @@ fn constrain(diff: i32, threshold: i32, adj: u32) -> i32 {
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, r: usize, c: usize, pri: i32, sec: i32, damping: i32, dir: usize) {
|
||||
fn filter(fs: &FrameState, out: &mut [RowBand; 3], plane: usize, r: usize, c: usize, pri: i32, sec: i32, damping: i32, dir: usize) {
|
||||
if pri == 0 && sec == 0 {
|
||||
// Every tap contributes 0 and the clamp to [min, max] keeps x: CdefFrame = CurrFrame.
|
||||
return;
|
||||
@@ -169,7 +179,7 @@ fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, r: usize, c: usize,
|
||||
let sec_taps = [CDEF_SEC_TAPS[pri_tap][0] as i32, CDEF_SEC_TAPS[pri_tap][1] as i32];
|
||||
let adj = |t: i32| if t == 0 { 0 } else { (damping - (31 - (t as u32).leading_zeros()) as i32).max(0) as u32 };
|
||||
let (pri_adj, sec_adj) = (adj(pri), adj(sec));
|
||||
let dst = &mut out.planes[plane];
|
||||
let dst = &mut out[plane];
|
||||
if all_avail {
|
||||
// Every tap is available: whole rows at a time (constrain() with threshold 0 is 0).
|
||||
let mut taps = [(0isize, 0i32, 0i32, 0u32); 12];
|
||||
@@ -230,7 +240,12 @@ fn filter(fs: &FrameState, out: &mut FrameBuf, plane: usize, r: usize, c: usize,
|
||||
/// CDEF of a block whose taps are all available, `W` samples per row. Samples (at most 12
|
||||
/// bits), differences and the weighted tap sum all fit in i16, which doubles the SIMD width.
|
||||
#[inline(always)]
|
||||
fn filter_rows<const W: usize>(buf: &[i32; PAD_STRIDE * PAD_STRIDE], taps: &[(isize, i32, i32, u32); 12], dst: &mut Plane, (y0, x0, h): (usize, usize, usize)) {
|
||||
fn filter_rows<const W: usize>(
|
||||
buf: &[i32; PAD_STRIDE * PAD_STRIDE],
|
||||
taps: &[(isize, i32, i32, u32); 12],
|
||||
dst: &mut RowBand,
|
||||
(y0, x0, h): (usize, usize, usize),
|
||||
) {
|
||||
let mut b16 = [0i16; PAD_STRIDE * PAD_STRIDE];
|
||||
for (d, &s) in b16.iter_mut().zip(buf.iter()) {
|
||||
*d = s as i16;
|
||||
|
||||
@@ -23,6 +23,17 @@ use crate::stats::{DecodeStats, Stage, Timer};
|
||||
use crate::tile::TileDecoder;
|
||||
use crate::{Error, Picture, Result};
|
||||
|
||||
/// Largest accepted frame: the AV1 level 6.x limits (Annex A: MaxHSize 16384, MaxVSize 8704, MaxPicSize
|
||||
/// 35 651 584 luma samples; level 6.3 is the highest defined). The sequence header allows 65536 x 65536
|
||||
/// (gigabytes of buffers), so nothing larger than a level 6.3 frame is decoded.
|
||||
const MAX_FRAME_WIDTH: u32 = 16384;
|
||||
const MAX_FRAME_HEIGHT: u32 = 8704;
|
||||
const MAX_FRAME_AREA: u64 = 35_651_584;
|
||||
|
||||
/// Whether a frame of `width` x `height` luma samples is within the decoder's resource limits.
|
||||
fn frame_size_allowed(width: u32, height: u32) -> bool {
|
||||
width <= MAX_FRAME_WIDTH && height <= MAX_FRAME_HEIGHT && u64::from(width) * u64::from(height) <= MAX_FRAME_AREA
|
||||
}
|
||||
const OBU_SEQUENCE_HEADER_T: u32 = 1;
|
||||
const OBU_TEMPORAL_DELIMITER_T: u32 = 2;
|
||||
const OBU_FRAME_HEADER_T: u32 = 3;
|
||||
@@ -92,6 +103,8 @@ struct FrameJob {
|
||||
ref_info: [RefInfo; NUM_REF_FRAMES],
|
||||
/// Tile payloads (tile number, data).
|
||||
tiles: Vec<(usize, Vec<u8>)>,
|
||||
/// Each tile may be supplied by exactly one tile group.
|
||||
tile_covered: Vec<bool>,
|
||||
slot: Arc<FrameSlot>,
|
||||
/// The output picture, when the frame is shown.
|
||||
picture: Option<Arc<Slot<Arc<Picture>>>>,
|
||||
@@ -505,7 +518,7 @@ impl Decoder {
|
||||
}
|
||||
// Encoders exist that code frames slightly larger than the sequence maximum (e.g. a
|
||||
// 270-line stream coded as 272 lines); like other decoders we accept them.
|
||||
if fh.upscaled_width > 65536 || fh.frame_height > 65536 {
|
||||
if !frame_size_allowed(fh.upscaled_width, fh.frame_height) {
|
||||
return Err(Error::Invalid("frame size"));
|
||||
}
|
||||
if !fh.frame_is_intra {
|
||||
@@ -520,12 +533,14 @@ impl Decoder {
|
||||
}
|
||||
let picture = fh.show_frame.then(Slot::new);
|
||||
let draft = self.draft && fh.show_frame && fh.refresh_frame_flags == 0;
|
||||
let tile_count = fh.tile_info.cols.checked_mul(fh.tile_info.rows).ok_or(Error::Invalid("tile count overflows"))?;
|
||||
self.pending = Some(Box::new(FrameJob {
|
||||
seq,
|
||||
fh,
|
||||
refs: self.ref_frames.clone(),
|
||||
ref_info: self.refs.clone(),
|
||||
tiles: Vec::new(),
|
||||
tile_covered: vec![false; tile_count],
|
||||
slot: Slot::new(),
|
||||
picture,
|
||||
pts: self.pts,
|
||||
@@ -568,13 +583,20 @@ impl Decoder {
|
||||
v + 1
|
||||
};
|
||||
let end = pos.checked_add(size).filter(|&e| e <= data.len()).ok_or(Error::Truncated)?;
|
||||
if job.tile_covered.get(tile_num).copied() != Some(false) {
|
||||
return Err(Error::Invalid("tile is covered by multiple tile groups"));
|
||||
}
|
||||
job.tiles.push((tile_num, data[pos..end].to_vec()));
|
||||
job.tile_covered[tile_num] = true;
|
||||
pos = end;
|
||||
}
|
||||
if tg_end != num_tiles - 1 {
|
||||
self.pending = Some(job);
|
||||
return Ok(None);
|
||||
}
|
||||
if job.tile_covered.iter().any(|&covered| !covered) {
|
||||
return Err(Error::Invalid("frame is missing tile coverage"));
|
||||
}
|
||||
self.seen_frame_header = false;
|
||||
Ok(self.submit(job))
|
||||
}
|
||||
@@ -947,3 +969,26 @@ fn project(fs: &mut FrameState, src: usize, dst_sign: i32) -> bool {
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::frame_size_allowed;
|
||||
|
||||
#[test]
|
||||
fn frames_up_to_the_level_6_3_limits_are_allowed() {
|
||||
assert!(frame_size_allowed(16384, 2176)); // exactly MaxPicSize
|
||||
assert!(frame_size_allowed(4096, 8704)); // exactly MaxVSize
|
||||
assert!(frame_size_allowed(8192, 4352)); // exactly MaxPicSize
|
||||
assert!(frame_size_allowed(7680, 4320)); // 8K UHD
|
||||
assert!(frame_size_allowed(1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_beyond_the_limits_are_refused() {
|
||||
assert!(!frame_size_allowed(16384, 2177)); // one row over MaxPicSize
|
||||
assert!(!frame_size_allowed(16384, 8704)); // within both sides, four times MaxPicSize
|
||||
assert!(!frame_size_allowed(4096, 8705)); // one row over MaxVSize
|
||||
assert!(!frame_size_allowed(16385, 1)); // one column over MaxHSize
|
||||
assert!(!frame_size_allowed(65536, 65536));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,48 @@ impl Plane {
|
||||
dst.row_from_mut(self.oy + y, self.ox)[..self.stride].copy_from_slice(s);
|
||||
}
|
||||
}
|
||||
|
||||
/// Disjoint mutable bands of whole rows, for filtering in parallel: one per `[start, end)`
|
||||
/// range of rows (relative to `oy`; ascending and not overlapping, else the later band is
|
||||
/// cut to what is left). Ranges are clipped to the stored rows.
|
||||
pub fn row_bands(&mut self, ranges: &[(usize, usize)]) -> Vec<RowBand<'_>> {
|
||||
let (stride, oy) = (self.stride.max(1), self.oy);
|
||||
let mut out = Vec::with_capacity(ranges.len());
|
||||
let mut rest = self.data.as_mut_slice();
|
||||
// First row of `rest`.
|
||||
let mut y = 0usize;
|
||||
for &(start, end) in ranges {
|
||||
let skip = start.saturating_sub(y).saturating_mul(stride).min(rest.len());
|
||||
rest = std::mem::take(&mut rest).split_at_mut(skip).1;
|
||||
y = y.saturating_add(skip / stride);
|
||||
let len = end.saturating_sub(y).saturating_mul(stride).min(rest.len());
|
||||
let (band, tail) = std::mem::take(&mut rest).split_at_mut(len);
|
||||
out.push(RowBand { data: band, stride, y0: oy.saturating_add(y) });
|
||||
y = y.saturating_add(len / stride);
|
||||
rest = tail;
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutable whole rows [y0, y0 + data.len() / stride) of a plane, addressed with the plane's
|
||||
/// coordinates (see [`Plane::row_bands`]).
|
||||
pub struct RowBand<'a> {
|
||||
pub data: &'a mut [u16],
|
||||
pub stride: usize,
|
||||
pub y0: usize,
|
||||
}
|
||||
|
||||
impl RowBand<'_> {
|
||||
#[inline(always)]
|
||||
pub fn set(&mut self, x: usize, y: usize, v: u16) {
|
||||
self.data[(y - self.y0) * self.stride + x] = v;
|
||||
}
|
||||
#[inline(always)]
|
||||
pub fn row_mut(&mut self, y: usize) -> &mut [u16] {
|
||||
let o = (y - self.y0) * self.stride;
|
||||
&mut self.data[o..o + self.stride]
|
||||
}
|
||||
}
|
||||
|
||||
/// A frame's three planes, sized to the macroblock-aligned coded area plus margins.
|
||||
@@ -158,6 +200,16 @@ impl FrameBuf {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Plane::row_bands`] of all three planes for ranges of luma rows (chroma rows scaled
|
||||
/// down): element `i` holds the bands of range `i`.
|
||||
pub fn row_bands(&mut self, luma: &[(usize, usize)]) -> Vec<[RowBand<'_>; 3]> {
|
||||
let ssy = self.subsampling_y;
|
||||
let chroma: Vec<(usize, usize)> = luma.iter().map(|&(s, e)| (s >> ssy, e >> ssy)).collect();
|
||||
let [p0, p1, p2] = &mut self.planes;
|
||||
let (b1, b2) = (p1.row_bands(&chroma), p2.row_bands(&chroma));
|
||||
p0.row_bands(luma).into_iter().zip(b1).zip(b2).map(|((y, u), v)| [y, u, v]).collect()
|
||||
}
|
||||
|
||||
pub fn plane_width(&self, plane: usize) -> usize {
|
||||
if plane == 0 { self.width } else { (self.width + self.subsampling_x) >> self.subsampling_x }
|
||||
}
|
||||
@@ -285,3 +337,40 @@ impl MiInfo {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rows(b: &RowBand) -> (usize, usize) {
|
||||
(b.y0, b.data.len() / b.stride)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_bands_split_whole_rows() {
|
||||
let mut p = Plane::new(8, 20);
|
||||
for (i, v) in p.data.iter_mut().enumerate() {
|
||||
*v = (i / 8) as u16;
|
||||
}
|
||||
let bands = p.row_bands(&[(0, 8), (8, 16), (16, usize::MAX)]);
|
||||
assert_eq!(bands.iter().map(rows).collect::<Vec<_>>(), [(0, 8), (8, 8), (16, 4)]);
|
||||
// Each band's row y holds the samples of plane row y.
|
||||
for mut b in bands {
|
||||
let y = b.y0 + 1;
|
||||
assert!(b.row_mut(y).iter().all(|&v| v as usize == y));
|
||||
}
|
||||
// Gaps between bands (the loop filter's even / odd turns).
|
||||
let bands = p.row_bands(&[(0, 4), (10, 14), (18, 30)]);
|
||||
assert_eq!(bands.iter().map(rows).collect::<Vec<_>>(), [(0, 4), (10, 4), (18, 2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_bands_never_overlap_or_panic() {
|
||||
let mut p = Plane::new(4, 10);
|
||||
// Overlapping, descending and out-of-range ranges are cut to what is left.
|
||||
let bands = p.row_bands(&[(2, 6), (4, 8), (1, 3), (9, 2), (50, 60), (usize::MAX, usize::MAX)]);
|
||||
assert_eq!(bands.iter().map(rows).collect::<Vec<_>>(), [(2, 4), (6, 2), (8, 0), (9, 0), (10, 0), (10, 0)]);
|
||||
let mut empty = Plane::default();
|
||||
assert!(empty.row_bands(&[(0, 64), (64, usize::MAX)]).iter().all(|b| b.data.is_empty()));
|
||||
}
|
||||
}
|
||||
|
||||
+138
-27
@@ -1,20 +1,33 @@
|
||||
//! In-loop post filters run by decode_frame_wrapup: loop filter (7.14), CDEF (7.15),
|
||||
//! super-resolution upscaling (7.16) and loop restoration (7.17).
|
||||
|
||||
use crate::frame::RowBand;
|
||||
use crate::spec_tables::*;
|
||||
use crate::state::FrameState;
|
||||
use crate::stats::{DecodeStats, Stage, Timer};
|
||||
|
||||
/// The post-filters run on the frame worker; `_pool` is for row-parallel filtering (not yet used).
|
||||
/// `draft`: only super-resolution (which sets the output size) runs.
|
||||
pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, _pool: &crate::par::Pool, draft: bool) {
|
||||
/// Luma rows per band of the row-parallel filters: whole 64x64 CDEF filter blocks, and no
|
||||
/// transform block crosses a band edge.
|
||||
const BAND_ROWS: usize = 64;
|
||||
|
||||
/// The [start, end) luma rows of the bands covering `rows`: [`BAND_ROWS`] each (the last one
|
||||
/// runs to the end of the plane), or one band for everything when `pool` is sequential.
|
||||
pub(crate) fn bands(pool: &crate::par::Pool, rows: usize) -> Vec<(usize, usize)> {
|
||||
let n = if pool.is_parallel() { rows.div_ceil(BAND_ROWS).max(1) } else { 1 };
|
||||
(0..n).map(|b| (b * BAND_ROWS, if b + 1 == n { usize::MAX } else { (b + 1) * BAND_ROWS })).collect()
|
||||
}
|
||||
|
||||
/// The post-filters run on the frame worker, each in row bands on `pool` (the result does not
|
||||
/// depend on how the bands are scheduled). `draft`: only super-resolution (which sets the
|
||||
/// output size) runs.
|
||||
pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, pool: &crate::par::Pool, draft: bool) {
|
||||
let mut t = Timer::start();
|
||||
let lvl = fs.fh.lf.level;
|
||||
if (lvl[0] != 0 || lvl[1] != 0) && !draft {
|
||||
loop_filter(fs);
|
||||
loop_filter(fs, pool);
|
||||
}
|
||||
stats.add(Stage::LoopFilter, t.lap());
|
||||
let cdef = if draft { None } else { crate::cdef::apply(fs) };
|
||||
let cdef = if draft { None } else { crate::cdef::apply(fs, pool) };
|
||||
stats.add(Stage::Cdef, t.lap());
|
||||
let (up_cur, up_cdef) = if fs.fh.use_superres {
|
||||
let c = crate::restoration::upscale(fs, &fs.cur);
|
||||
@@ -25,7 +38,7 @@ pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, _pool: &crate:
|
||||
};
|
||||
stats.add(Stage::Superres, t.lap());
|
||||
fs.cur = if fs.fh.lr.uses_lr && !draft {
|
||||
crate::restoration::loop_restoration(fs, &up_cur, up_cdef.as_ref().unwrap_or(&up_cur))
|
||||
crate::restoration::loop_restoration(fs, &up_cur, up_cdef.as_ref().unwrap_or(&up_cur), pool)
|
||||
} else {
|
||||
up_cdef.unwrap_or(up_cur)
|
||||
};
|
||||
@@ -35,28 +48,102 @@ pub(crate) fn apply(fs: &mut FrameState, stats: &mut DecodeStats, _pool: &crate:
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Loop filter (7.14)
|
||||
|
||||
fn loop_filter(fs: &mut FrameState) {
|
||||
let (mi_rows, mi_cols) = (fs.fh.mi_rows as usize, fs.fh.mi_cols as usize);
|
||||
for plane in 0..fs.num_planes {
|
||||
if plane == 0 || fs.fh.lf.level[1 + plane] != 0 {
|
||||
for pass in 0..2 {
|
||||
let row_step = if plane == 0 { 1 } else { 1 << fs.ssy };
|
||||
let col_step = if plane == 0 { 1 } else { 1 << fs.ssx };
|
||||
let mut row = 0;
|
||||
while row < mi_rows {
|
||||
let mut col = 0;
|
||||
while col < mi_cols {
|
||||
edge(fs, plane, pass, row, col);
|
||||
col += col_step;
|
||||
}
|
||||
row += row_step;
|
||||
/// Luma rows above a band that its horizontal edges read or write (p6 .. p0 of the widest
|
||||
/// filter, rounded up to whole 4x4 rows); chroma: `LF_MARGIN >> subsampling_y`.
|
||||
const LF_MARGIN: usize = 8;
|
||||
|
||||
/// The loop filter in row bands, all of a frame's in one parallel pass (each join costs a
|
||||
/// worker wake-up, which is slow on a busy machine).
|
||||
///
|
||||
/// Vertical edges (pass 0) only touch the 4 rows of their own edge. A horizontal edge (pass 1)
|
||||
/// reads and writes at most `filter_size / 2` samples on each side, never more than the
|
||||
/// transform blocks on either side are high, so no two edges touch the same sample and
|
||||
/// any order gives the spec's raster-order result. Transform blocks never cross a band edge, so
|
||||
/// the only edges reaching outside a band are those on its top row, whose p side lies in the
|
||||
/// band above. Each band therefore also filters the vertical edges of a copy of the
|
||||
/// `LF_MARGIN` rows above it (the same work as the band above does on them), runs its top
|
||||
/// horizontal edges against that copy, and afterwards the samples those edges changed are
|
||||
/// written back; the band above never touches them.
|
||||
fn loop_filter(fs: &mut FrameState, pool: &crate::par::Pool) {
|
||||
let luma = bands(pool, fs.fh.mi_rows as usize * 4);
|
||||
let planes: Vec<usize> = (0..fs.num_planes).filter(|&p| p == 0 || fs.fh.lf.level[1 + p] != 0).collect();
|
||||
let mut cur = std::mem::take(&mut fs.cur);
|
||||
let f: &FrameState = fs;
|
||||
// Unfiltered copies of the rows above each band but the first.
|
||||
let margins: Vec<[Vec<u16>; 3]> = luma
|
||||
.iter()
|
||||
.map(|&(start, _)| {
|
||||
std::array::from_fn(|p| {
|
||||
let sub_y = if p == 0 { 0 } else { f.ssy };
|
||||
let pl = &cur.planes[p];
|
||||
let (y0, y1) = ((start.saturating_sub(LF_MARGIN)) >> sub_y, start >> sub_y);
|
||||
let rows = pl.data.get(y0 * pl.stride..y1 * pl.stride);
|
||||
if start == 0 || !planes.contains(&p) { Vec::new() } else { rows.map(<[u16]>::to_vec).unwrap_or_default() }
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut bands: Vec<([RowBand; 3], [Vec<u16>; 3])> = cur.row_bands(&luma).into_iter().zip(margins).collect();
|
||||
let changed = pool.map_mut(&mut bands, |b, (band, margin)| {
|
||||
let mut changed: [Vec<(usize, u16)>; 3] = Default::default();
|
||||
for &p in &planes {
|
||||
changed[p] = filter_band(f, p, luma[b], &mut band[p], &mut margin[p]);
|
||||
}
|
||||
changed
|
||||
});
|
||||
drop(bands);
|
||||
for (&(start, _), ch) in luma.iter().zip(&changed) {
|
||||
for (p, list) in ch.iter().enumerate() {
|
||||
let sub_y = if p == 0 { 0 } else { f.ssy };
|
||||
let pl = &mut cur.planes[p];
|
||||
let first = (start.saturating_sub(LF_MARGIN) >> sub_y) * pl.stride;
|
||||
for &(i, v) in list {
|
||||
if let Some(d) = pl.data.get_mut(first + i) {
|
||||
*d = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fs.cur = cur;
|
||||
}
|
||||
|
||||
fn edge(fs: &mut FrameState, plane: usize, pass: usize, row: usize, col: usize) {
|
||||
/// Both loop filter passes over the band of `plane` holding luma rows [start, end); `margin` is
|
||||
/// an unfiltered copy of the rows above the band (empty for the first band). Returns the samples
|
||||
/// of `margin` that the band's top horizontal edges changed, as (index, value).
|
||||
fn filter_band(fs: &FrameState, plane: usize, (start, end): (usize, usize), band: &mut RowBand, margin: &mut [u16]) -> Vec<(usize, u16)> {
|
||||
let mi_rows = fs.fh.mi_rows as usize;
|
||||
let (r0, r1) = (start / 4, mi_rows.min(end / 4));
|
||||
let sub_y = if plane == 0 { 0 } else { fs.ssy };
|
||||
let edges = |band: &mut RowBand, mut above: Option<&mut RowBand>, pass: usize, rows: std::ops::Range<usize>| {
|
||||
let row_step = if plane == 0 { 1 } else { 1 << fs.ssy };
|
||||
let col_step = if plane == 0 { 1 } else { 1 << fs.ssx };
|
||||
let mi_cols = fs.fh.mi_cols as usize;
|
||||
let mut row = rows.start;
|
||||
while row < rows.end {
|
||||
let mut col = 0;
|
||||
while col < mi_cols {
|
||||
edge(fs, band, above.as_deref_mut(), plane, pass, row, col);
|
||||
col += col_step;
|
||||
}
|
||||
row += row_step;
|
||||
}
|
||||
};
|
||||
edges(band, None, 0, r0..r1);
|
||||
if margin.is_empty() {
|
||||
edges(band, None, 1, r0..r1);
|
||||
return Vec::new();
|
||||
}
|
||||
let stride = band.stride;
|
||||
let y0 = start.saturating_sub(LF_MARGIN) >> sub_y;
|
||||
let mut above = RowBand { data: margin, stride, y0 };
|
||||
edges(&mut above, None, 0, r0.saturating_sub(LF_MARGIN / 4)..r0);
|
||||
let before = above.data.to_vec();
|
||||
edges(band, Some(&mut above), 1, r0..r1);
|
||||
above.data.iter().zip(&before).enumerate().filter(|(_, (a, b))| a != b).map(|(i, (&a, _))| (i, a)).collect()
|
||||
}
|
||||
|
||||
/// One loop filter edge (7.14.2) at mode info position (row, col): 4 lines of samples across
|
||||
/// it. `above` holds the rows above `band` for horizontal edges on the band's top row.
|
||||
fn edge(fs: &FrameState, band: &mut RowBand, above: Option<&mut RowBand>, plane: usize, pass: usize, row: usize, col: usize) {
|
||||
let (sub_x, sub_y) = if plane == 0 { (0, 0) } else { (fs.ssx, fs.ssy) };
|
||||
let (dx, dy) = if pass == 0 { (1i32, 0i32) } else { (0, 1) };
|
||||
let x = col * 4;
|
||||
@@ -102,16 +189,40 @@ fn edge(fs: &mut FrameState, plane: usize, pass: usize, row: usize, col: usize)
|
||||
return;
|
||||
}
|
||||
let t = LfThresh::new(limit, blimit, thresh, fs.bit_depth);
|
||||
let pl = &mut fs.cur.planes[plane];
|
||||
let stride = pl.stride;
|
||||
let (step, along) = if pass == 0 { (1, stride) } else { (stride, 1) };
|
||||
let base = yp * stride + xp;
|
||||
let chroma = plane > 0;
|
||||
let stride = band.stride;
|
||||
if let Some(above) = above.filter(|_| pass == 1 && yp == band.y0) {
|
||||
// The p side is in the rows above the band: filter each line in a copy that spans both.
|
||||
let (n_above, n_band) = (above.data.len() / stride.max(1), band.data.len() / stride.max(1));
|
||||
for k in 0..4 {
|
||||
let x = xp + k;
|
||||
let mut line = [0u16; 2 * LF_LINE];
|
||||
for j in 1..=n_above.min(LF_LINE) {
|
||||
line[LF_LINE - j] = above.data[(n_above - j) * stride + x];
|
||||
}
|
||||
for j in 0..n_band.min(LF_LINE) {
|
||||
line[LF_LINE + j] = band.data[j * stride + x];
|
||||
}
|
||||
sample_filter(&mut line, LF_LINE, 1, chroma, filter_size, t);
|
||||
for j in 1..=n_above.min(LF_LINE) {
|
||||
above.data[(n_above - j) * stride + x] = line[LF_LINE - j];
|
||||
}
|
||||
for j in 0..n_band.min(LF_LINE) {
|
||||
band.data[j * stride + x] = line[LF_LINE + j];
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let (step, along) = if pass == 0 { (1, stride) } else { (stride, 1) };
|
||||
let base = (yp - band.y0) * stride + xp;
|
||||
for k in 0..4 {
|
||||
sample_filter(&mut pl.data, base + k * along, step, chroma, filter_size, t);
|
||||
sample_filter(band.data, base + k * along, step, chroma, filter_size, t);
|
||||
}
|
||||
}
|
||||
|
||||
/// Samples on each side of an edge that the sample filter can read (at most p6 .. q6).
|
||||
const LF_LINE: usize = 8;
|
||||
|
||||
/// Adaptive filter strength (7.14.4): (lvl, limit, blimit, thresh).
|
||||
fn strength(fs: &FrameState, row: usize, col: usize, plane: usize, pass: usize) -> (i32, i32, i32, i32) {
|
||||
let mi = &fs.mi;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Super-resolution upscaling (7.16) and loop restoration (7.17).
|
||||
|
||||
use crate::frame::{FrameBuf, Plane};
|
||||
use crate::frame::{FrameBuf, Plane, RowBand};
|
||||
use crate::par::Pool;
|
||||
use crate::postfilter::bands;
|
||||
use crate::spec_tables::*;
|
||||
use crate::state::FrameState;
|
||||
|
||||
@@ -70,56 +72,66 @@ impl LrCtx<'_> {
|
||||
}
|
||||
|
||||
/// Loop restoration (7.17): returns LrFrame given UpscaledCurrFrame and UpscaledCdefFrame.
|
||||
pub(crate) fn loop_restoration(fs: &FrameState, cur: &FrameBuf, cdef: &FrameBuf) -> FrameBuf {
|
||||
/// Bands of 64 luma rows are restored in parallel: every unit reads only the two input frames
|
||||
/// and writes only its own samples.
|
||||
pub(crate) fn loop_restoration(fs: &FrameState, cur: &FrameBuf, cdef: &FrameBuf, pool: &Pool) -> FrameBuf {
|
||||
let fh = &fs.fh;
|
||||
let mut lr = cdef.clone();
|
||||
let up_w = fh.upscaled_width as i32;
|
||||
let fr_h = fh.frame_height as i32;
|
||||
let bd = fs.bit_depth;
|
||||
for plane in 0..fs.num_planes {
|
||||
let rtype_frame = fh.lr.frame_restoration_type[plane];
|
||||
if rtype_frame == RESTORE_NONE as u8 {
|
||||
continue;
|
||||
}
|
||||
let (sub_x, sub_y) = if plane > 0 { (fs.ssx as i32, fs.ssy as i32) } else { (0, 0) };
|
||||
let unit_size = fh.lr.loop_restoration_size[plane] as i32;
|
||||
let unit_rows = fs.lr_unit_rows[plane] as i32;
|
||||
let unit_cols = fs.lr_unit_cols[plane] as i32;
|
||||
let plane_end_x = ((up_w + sub_x) >> sub_x) - 1;
|
||||
let plane_end_y = ((fr_h + sub_y) >> sub_y) - 1;
|
||||
let mut row = 0;
|
||||
while row * 4 < fr_h {
|
||||
let luma_y = row * 4;
|
||||
let stripe_num = (luma_y + 8) / 64;
|
||||
let stripe_start_y = (-8 + stripe_num * 64) >> sub_y;
|
||||
let stripe_end_y = stripe_start_y + (64 >> sub_y) - 1;
|
||||
let ctx = LrCtx { cur: &cur.planes[plane], cdef: &cdef.planes[plane], plane_end_x, plane_end_y, stripe_start_y, stripe_end_y };
|
||||
let unit_row = (unit_rows - 1).min(((row * 4 + 8) >> sub_y) / unit_size);
|
||||
let mut col = 0;
|
||||
while col * 4 < up_w {
|
||||
let unit_col = (unit_cols - 1).min(((col * 4) >> sub_x) / unit_size);
|
||||
let x = (col * 4) >> sub_x;
|
||||
let y = (row * 4) >> sub_y;
|
||||
let w = (4 >> sub_x).min(plane_end_x - x + 1);
|
||||
let h = (4 >> sub_y).min(plane_end_y - y + 1);
|
||||
if w > 0 && h > 0 {
|
||||
let ui = (unit_row * unit_cols + unit_col) as usize;
|
||||
match fs.lr.lr_type[plane][ui] {
|
||||
t if t == RESTORE_WIENER as u8 => wiener(fs, &ctx, &mut lr.planes[plane], plane, ui, x, y, w, h, bd),
|
||||
t if t == RESTORE_SGRPROJ as u8 => self_guided(fs, &ctx, &mut lr.planes[plane], plane, ui, x, y, w, h, bd),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
col += 1;
|
||||
let ranges = bands(pool, fh.frame_height as usize);
|
||||
let mut planes = lr.row_bands(&ranges);
|
||||
pool.map_mut(&mut planes, |b, band| {
|
||||
// Rows of 4 luma samples in this band.
|
||||
let (start, end) = ranges[b];
|
||||
let (row0, row1) = ((start / 4) as i32, (end / 4).min(fh.frame_height as usize) as i32);
|
||||
for (plane, out) in band.iter_mut().enumerate().take(fs.num_planes) {
|
||||
let rtype_frame = fh.lr.frame_restoration_type[plane];
|
||||
if rtype_frame == RESTORE_NONE as u8 {
|
||||
continue;
|
||||
}
|
||||
let (sub_x, sub_y) = if plane > 0 { (fs.ssx as i32, fs.ssy as i32) } else { (0, 0) };
|
||||
let unit_size = fh.lr.loop_restoration_size[plane] as i32;
|
||||
let unit_rows = fs.lr_unit_rows[plane] as i32;
|
||||
let unit_cols = fs.lr_unit_cols[plane] as i32;
|
||||
let plane_end_x = ((up_w + sub_x) >> sub_x) - 1;
|
||||
let plane_end_y = ((fr_h + sub_y) >> sub_y) - 1;
|
||||
let mut row = row0;
|
||||
while row < row1 && row * 4 < fr_h {
|
||||
let luma_y = row * 4;
|
||||
let stripe_num = (luma_y + 8) / 64;
|
||||
let stripe_start_y = (-8 + stripe_num * 64) >> sub_y;
|
||||
let stripe_end_y = stripe_start_y + (64 >> sub_y) - 1;
|
||||
let ctx = LrCtx { cur: &cur.planes[plane], cdef: &cdef.planes[plane], plane_end_x, plane_end_y, stripe_start_y, stripe_end_y };
|
||||
let unit_row = (unit_rows - 1).min(((row * 4 + 8) >> sub_y) / unit_size);
|
||||
let mut col = 0;
|
||||
while col * 4 < up_w {
|
||||
let unit_col = (unit_cols - 1).min(((col * 4) >> sub_x) / unit_size);
|
||||
let x = (col * 4) >> sub_x;
|
||||
let y = (row * 4) >> sub_y;
|
||||
let w = (4 >> sub_x).min(plane_end_x - x + 1);
|
||||
let h = (4 >> sub_y).min(plane_end_y - y + 1);
|
||||
if w > 0 && h > 0 {
|
||||
let ui = (unit_row * unit_cols + unit_col) as usize;
|
||||
match fs.lr.lr_type[plane][ui] {
|
||||
t if t == RESTORE_WIENER as u8 => wiener(fs, &ctx, out, plane, ui, x, y, w, h, bd),
|
||||
t if t == RESTORE_SGRPROJ as u8 => self_guided(fs, &ctx, out, plane, ui, x, y, w, h, bd),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
col += 1;
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
row += 1;
|
||||
}
|
||||
}
|
||||
});
|
||||
drop(planes);
|
||||
lr
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) {
|
||||
fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut RowBand, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) {
|
||||
let round0: u32 = if bd == 12 { 5 } else { 3 };
|
||||
let round1: u32 = if bd == 12 { 9 } else { 11 };
|
||||
let coef = &fs.lr.lr_wiener[plane][ui];
|
||||
@@ -163,7 +175,7 @@ fn wiener(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn self_guided(fs: &FrameState, ctx: &LrCtx, out: &mut Plane, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) {
|
||||
fn self_guided(fs: &FrameState, ctx: &LrCtx, out: &mut RowBand, plane: usize, ui: usize, x: i32, y: i32, w: i32, h: i32, bd: u32) {
|
||||
let set = fs.lr.lr_sgr_set[plane][ui] as usize;
|
||||
let mut flt0 = [[0i32; 4]; 4];
|
||||
let mut flt1 = [[0i32; 4]; 4];
|
||||
|
||||
@@ -394,6 +394,10 @@ impl<'a, 'f> TileDecoder<'a, 'f> {
|
||||
fn decode_block(&mut self, r: usize, c: usize, sub_size: usize) -> Result<()> {
|
||||
let (ssx, ssy) = (self.fs.ssx, self.fs.ssy);
|
||||
let num_planes = self.fs.num_planes;
|
||||
// Conformance requirement (4:2:2 has no chroma size for 4x16 and similar blocks); indexing the size tables with BLOCK_INVALID would panic.
|
||||
if num_planes > 1 && self.fs.plane_residual_size(sub_size, 1) == BLOCK_INVALID {
|
||||
return Err(crate::Error::Invalid("block size has no chroma size at this subsampling"));
|
||||
}
|
||||
let mut b = Block { mi_row: r, mi_col: c, mi_size: sub_size, ..Default::default() };
|
||||
b.bw4 = NUM_4X4_BLOCKS_WIDE[sub_size] as usize;
|
||||
b.bh4 = NUM_4X4_BLOCKS_HIGH[sub_size] as usize;
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Hostile-input robustness: truncation, header-region sweeps and random mutation of small
|
||||
//! ffmpeg-generated AV1 streams (frame by frame) must never panic or run away. Skips without ffmpeg.
|
||||
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use filmcraft_av1::Decoder;
|
||||
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> u64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
self.0
|
||||
}
|
||||
fn below(&mut self, n: usize) -> usize {
|
||||
(self.next() % n.max(1) as u64) as usize
|
||||
}
|
||||
}
|
||||
|
||||
/// (name, encoder args, pix_fmt)
|
||||
const SEEDS: &[(&str, &str, &str)] = &[("av1_fz_default", "-c:v libsvtav1 -g 4", "yuv420p"), ("av1_fz_10bit", "-c:v libsvtav1 -g 6", "yuv420p10le")];
|
||||
|
||||
fn ivf_frames(data: &[u8]) -> Vec<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
let Some(h) = data.get(6..8) else { return out };
|
||||
let mut pos = usize::from(u16::from_le_bytes([h[0], h[1]]));
|
||||
while let Some(hd) = data.get(pos..pos + 12) {
|
||||
let size = u32::from_le_bytes([hd[0], hd[1], hd[2], hd[3]]) as usize;
|
||||
pos += 12;
|
||||
let end = pos.saturating_add(size).min(data.len());
|
||||
out.push(data[pos..end].to_vec());
|
||||
pos = end;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn make(ff: &std::path::Path, name: &str, args: &str, pix: &str) -> Option<Vec<Vec<u8>>> {
|
||||
let out = filmcraft_testkit::fixtures_dir("av1").join(format!("{name}.ivf"));
|
||||
let p = filmcraft_testkit::fixtures::generate(&out, |tmp| {
|
||||
let a: Vec<&str> = args.split_whitespace().collect();
|
||||
matches!(
|
||||
Command::new(ff)
|
||||
.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=64x48:rate=25", "-frames:v", "6", "-pix_fmt", pix])
|
||||
.args(a)
|
||||
.args(["-f", "ivf"])
|
||||
.arg(tmp)
|
||||
.stdin(Stdio::null())
|
||||
.status(),
|
||||
Ok(s) if s.success()
|
||||
)
|
||||
})?;
|
||||
Some(ivf_frames(&std::fs::read(p).ok()?))
|
||||
}
|
||||
|
||||
static PANICS: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
/// Decoder worker threads may catch panics and turn them into errors; count them so they still fail the test.
|
||||
fn count_panics() {
|
||||
static ONCE: std::sync::Once = std::sync::Once::new();
|
||||
ONCE.call_once(|| {
|
||||
std::panic::set_hook(Box::new(|info| {
|
||||
PANICS.fetch_add(1, Ordering::SeqCst);
|
||||
eprintln!("PANIC: {info}");
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
fn run_all(frames: &[Vec<u8>], label: &str) {
|
||||
filmcraft_testkit::set_progress(label);
|
||||
count_panics();
|
||||
let before = PANICS.load(Ordering::SeqCst);
|
||||
let res = catch_unwind(AssertUnwindSafe(|| {
|
||||
for threads in [1, 3] {
|
||||
let mut dec = Decoder::with_threads(threads);
|
||||
for (i, f) in frames.iter().enumerate() {
|
||||
let _ = dec.decode_pts(f, i as i64);
|
||||
}
|
||||
let _ = dec.flush_result();
|
||||
}
|
||||
}));
|
||||
assert!(res.is_ok() && PANICS.load(Ordering::SeqCst) == before, "panic: {label}");
|
||||
}
|
||||
|
||||
/// Random-mutation rounds per seed (raise with FILMCRAFT_FUZZ_ITERS for a long hunt).
|
||||
fn iters() -> usize {
|
||||
std::env::var("FILMCRAFT_FUZZ_ITERS").ok().and_then(|s| s.parse().ok()).unwrap_or(300)
|
||||
}
|
||||
|
||||
/// Seconds the whole sweep may take before it counts as a hang (override: FILMCRAFT_HOSTILE_TIMEOUT_SECS).
|
||||
const DEADLINE_SECS: u64 = 600;
|
||||
|
||||
#[test]
|
||||
fn hostile_streams_never_panic() {
|
||||
filmcraft_testkit::with_deadline(DEADLINE_SECS, sweep);
|
||||
}
|
||||
|
||||
fn sweep() {
|
||||
let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("AV1 fuzz") else { return };
|
||||
for &(name, args, pix) in SEEDS {
|
||||
if !filmcraft_testkit::ffmpeg_has_encoder(&ff, "libsvtav1") {
|
||||
filmcraft_testkit::skip("AV1 fuzz", &format!("this ffmpeg has no libsvtav1 encoder (seed {name})"));
|
||||
continue;
|
||||
}
|
||||
let Some(seed) = make(&ff, name, args, pix) else {
|
||||
panic!("ffmpeg has libsvtav1 but could not encode the {name} seed");
|
||||
};
|
||||
if seed.is_empty() {
|
||||
filmcraft_testkit::skip("AV1 fuzz", &format!("the {name} seed has no frames"));
|
||||
continue;
|
||||
}
|
||||
// truncate each of the first frames at every length
|
||||
for fi in 0..seed.len().min(3) {
|
||||
let len = seed[fi].len();
|
||||
for n in (0..len).step_by((len / 200).max(1)) {
|
||||
let mut d = seed.clone();
|
||||
d[fi].truncate(n);
|
||||
run_all(&d, &format!("{name} frame {fi} truncate {n}"));
|
||||
}
|
||||
}
|
||||
// header region of every frame: every byte to a few values, every bit
|
||||
for fi in 0..seed.len() {
|
||||
for p in 0..seed[fi].len().min(96) {
|
||||
for v in [0u8, 0xff, 0x80, 0x01, 0x7f] {
|
||||
let mut d = seed.clone();
|
||||
d[fi][p] = v;
|
||||
run_all(&d, &format!("{name} frame {fi} byte {p}={v:#x}"));
|
||||
}
|
||||
for b in 0..8 {
|
||||
let mut d = seed.clone();
|
||||
d[fi][p] ^= 1 << b;
|
||||
run_all(&d, &format!("{name} frame {fi} flip {p}.{b}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64);
|
||||
for i in 0..iters() {
|
||||
let mut d = seed.clone();
|
||||
for _ in 0..1 + rng.below(6) {
|
||||
let fi = rng.below(d.len());
|
||||
if d[fi].is_empty() {
|
||||
continue;
|
||||
}
|
||||
let p = rng.below(d[fi].len());
|
||||
if rng.below(2) == 0 {
|
||||
d[fi][p] ^= 1 << rng.below(8);
|
||||
} else {
|
||||
d[fi][p] = rng.next() as u8;
|
||||
}
|
||||
}
|
||||
run_all(&d, &format!("{name} random {i}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Compare an arbitrary IVF file with libdav1d:
|
||||
//! `AV1_IVF=/path/x.ivf AV1_PIX=yuv420p cargo test -p filmcraft-av1 --test adhoc -- --ignored --nocapture`
|
||||
//! `AV1_IVF=/path/x.ivf AV1_PIX=yuv420p cargo test -p filmcraft-av1 --test it adhoc:: -- --ignored --nocapture`
|
||||
|
||||
mod common;
|
||||
use crate::common;
|
||||
use common::*;
|
||||
|
||||
#[test]
|
||||
@@ -16,6 +16,11 @@ pub fn ffmpeg_dav1d() -> Option<PathBuf> {
|
||||
ffmpeg_with(&[("-decoders", "libdav1d")])
|
||||
}
|
||||
|
||||
/// ffmpeg with libaom (4:2:2 and 4:4:4 fixtures, which SVT-AV1 cannot make) and libdav1d.
|
||||
pub fn ffmpeg_libaom() -> Option<PathBuf> {
|
||||
ffmpeg_with(&[("-encoders", "libaom-av1"), ("-decoders", "libdav1d")])
|
||||
}
|
||||
|
||||
/// Builds such as the Windows "essentials" ones lack these libraries; skip rather than fail.
|
||||
fn ffmpeg_with(needs: &[(&str, &str)]) -> Option<PathBuf> {
|
||||
let ff = filmcraft_testkit::ffmpeg_or_skip("av1 oracle")?;
|
||||
@@ -38,9 +43,11 @@ pub fn run(ff: &Path, args: &[&str]) {
|
||||
assert!(out.status.success(), "ffmpeg {:?} failed: {}", args, String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
|
||||
/// An AV1 fixture: a lavfi source encoded by ffmpeg's libsvtav1 into IVF.
|
||||
/// An AV1 fixture: a lavfi source encoded by ffmpeg (libsvtav1 unless [`Spec::encoder`] says
|
||||
/// otherwise) into IVF.
|
||||
pub struct Spec {
|
||||
pub name: &'static str,
|
||||
pub encoder: &'static str,
|
||||
pub lavfi: String,
|
||||
pub frames: u32,
|
||||
pub pix_fmt: &'static str,
|
||||
@@ -50,7 +57,12 @@ pub struct Spec {
|
||||
|
||||
impl Spec {
|
||||
pub fn new(name: &'static str, lavfi: impl Into<String>, frames: u32, pix_fmt: &'static str, enc: &[&str]) -> Spec {
|
||||
Spec { name, lavfi: lavfi.into(), frames, pix_fmt, enc: enc.iter().map(|s| s.to_string()).collect() }
|
||||
Spec { name, encoder: "libsvtav1", lavfi: lavfi.into(), frames, pix_fmt, enc: enc.iter().map(|s| s.to_string()).collect() }
|
||||
}
|
||||
|
||||
pub fn encoder(mut self, encoder: &'static str) -> Spec {
|
||||
self.encoder = encoder;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +72,7 @@ pub fn make(ff: &Path, spec: &Spec) -> PathBuf {
|
||||
let tmp = filmcraft_testkit::temp_path(&path);
|
||||
let frames = spec.frames.to_string();
|
||||
let t = tmp.to_str().unwrap().to_string();
|
||||
let mut args: Vec<&str> = vec!["-f", "lavfi", "-i", spec.lavfi.as_str(), "-frames:v", frames.as_str(), "-pix_fmt", spec.pix_fmt, "-c:v", "libsvtav1"];
|
||||
let mut args: Vec<&str> = vec!["-f", "lavfi", "-i", spec.lavfi.as_str(), "-frames:v", frames.as_str(), "-pix_fmt", spec.pix_fmt, "-c:v", spec.encoder];
|
||||
args.extend(spec.enc.iter().map(String::as_str));
|
||||
args.extend(["-f", "ivf", t.as_str()]);
|
||||
run(ff, &args);
|
||||
@@ -1,6 +1,6 @@
|
||||
//! libaom conformance test vectors (downloaded on first use), compared with libdav1d.
|
||||
|
||||
mod common;
|
||||
use crate::common;
|
||||
use common::*;
|
||||
|
||||
/// Vectors the decoder handles today (more are added as stages land).
|
||||
@@ -17,7 +17,7 @@ fn conformance_vectors() {
|
||||
}
|
||||
|
||||
/// The wider libaom set (slow: many downloads and decodes):
|
||||
/// `cargo test -p filmcraft-av1 --test conformance -- --ignored`; `AV1_VECTORS=a,b` overrides it.
|
||||
/// `cargo test -p filmcraft-av1 --test it conformance:: -- --ignored`; `AV1_VECTORS=a,b` overrides it.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn conformance_vectors_extended() {
|
||||
@@ -0,0 +1,161 @@
|
||||
//! A few bytes must not make the decoder allocate gigabytes: oversized sequences are refused.
|
||||
//! Hand-built (no ffmpeg): a reduced still-picture sequence header plus a lossless key frame whose
|
||||
//! tiles are one zero byte each.
|
||||
|
||||
use filmcraft_av1::{Decoder, Error};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Bits {
|
||||
bytes: Vec<u8>,
|
||||
n: usize,
|
||||
}
|
||||
|
||||
impl Bits {
|
||||
fn put(&mut self, value: u32, bits: usize) {
|
||||
for i in (0..bits).rev() {
|
||||
if self.n.is_multiple_of(8) {
|
||||
self.bytes.push(0);
|
||||
}
|
||||
if (value >> i) & 1 == 1
|
||||
&& let Some(b) = self.bytes.last_mut()
|
||||
{
|
||||
*b |= 0x80 >> (self.n % 8);
|
||||
}
|
||||
self.n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn obu(kind: u8, payload: &[u8]) -> Vec<u8> {
|
||||
let mut v = vec![kind << 3 | 0b10];
|
||||
let mut n = payload.len();
|
||||
loop {
|
||||
let b = (n & 0x7f) as u8;
|
||||
n >>= 7;
|
||||
v.push(if n > 0 { b | 0x80 } else { b });
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
v.extend_from_slice(payload);
|
||||
v
|
||||
}
|
||||
|
||||
fn tile_log2(blk: u32, target: u32) -> u32 {
|
||||
let mut k = 0;
|
||||
while (blk << k) < target {
|
||||
k += 1;
|
||||
}
|
||||
k
|
||||
}
|
||||
|
||||
/// Temporal delimiter, reduced still-picture sequence header of `w` x `h` (64x64 superblocks) and a
|
||||
/// lossless key frame whose uniformly spaced tiles are one zero byte each.
|
||||
fn stream(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut s = Bits::default();
|
||||
s.put(0, 3); // seq_profile
|
||||
s.put(1, 1); // still_picture
|
||||
s.put(1, 1); // reduced_still_picture_header
|
||||
s.put(0, 5); // seq_level_idx
|
||||
s.put(15, 4); // frame_width_bits_minus_1
|
||||
s.put(15, 4); // frame_height_bits_minus_1
|
||||
s.put(w - 1, 16);
|
||||
s.put(h - 1, 16);
|
||||
s.put(0, 3 + 3 + 7 + 1); // sb size, filter intra, edge; superres, cdef, restoration; color config; film grain
|
||||
s.put(1, 1); // trailing bit
|
||||
|
||||
let (sb_cols, sb_rows) = (w.div_ceil(64), h.div_ceil(64));
|
||||
let cols_log2 = tile_log2(64, sb_cols); // MAX_TILE_WIDTH / 64 superblocks
|
||||
let min_log2_tiles = cols_log2.max(tile_log2(2304, sb_rows * sb_cols));
|
||||
let rows_log2 = min_log2_tiles.saturating_sub(cols_log2);
|
||||
let tiles = (sb_cols.div_ceil(sb_cols.div_ceil(1 << cols_log2))) * (sb_rows.div_ceil(sb_rows.div_ceil(1 << rows_log2)));
|
||||
let mut f = Bits::default();
|
||||
f.put(0b0001, 4); // disable_cdf_update, allow_screen_content_tools, render_and_frame_size_different, uniform_tile_spacing_flag
|
||||
f.put(0, 2); // no increment of the tile column / row counts
|
||||
if tiles > 1 {
|
||||
f.put(0, (cols_log2 + rows_log2) as usize + 2); // context_update_tile_id, tile_size_bytes_minus_1
|
||||
}
|
||||
f.put(0, 8 + 4 + 1 + 1); // base_q_idx 0 (lossless), delta flags, using_qmatrix, segmentation_enabled, reduced_tx_set
|
||||
let mut frame = f.bytes;
|
||||
frame.push(0); // tile_start_and_end_present_flag
|
||||
for _ in 1..tiles {
|
||||
frame.extend([0, 0]); // tile_size_minus_1 = 0, one data byte
|
||||
}
|
||||
frame.extend([0, 0]);
|
||||
|
||||
let mut out = obu(2, &[]);
|
||||
out.extend(obu(1, &s.bytes));
|
||||
out.extend(obu(6, &frame));
|
||||
out
|
||||
}
|
||||
|
||||
fn refused(w: u32, h: u32) {
|
||||
let mut dec = Decoder::new();
|
||||
let r = dec.decode(&stream(w, h)).and_then(|_| dec.flush_result());
|
||||
assert!(matches!(r, Err(Error::Invalid(m)) if m.contains("frame size")), "{w}x{h} must be refused as too large, got {:?}", r.as_ref().map(|p| p.len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn very_wide_frame_is_refused() {
|
||||
refused(65536, 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn very_tall_frame_is_refused() {
|
||||
refused(16, 65536);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_frames_are_refused_without_allocating() {
|
||||
refused(65536, 65536);
|
||||
refused(65535, 65535);
|
||||
refused(30000, 30000);
|
||||
}
|
||||
|
||||
/// Frames past the level 6.x limits (width 16384, height 8704, area 35 651 584) are refused. The accepted side of
|
||||
/// each boundary is unit-tested on `frame_size_allowed` in `decoder.rs`: decoding a maximum-size frame end to end
|
||||
/// needs gigabytes.
|
||||
#[test]
|
||||
fn frames_past_the_level_6_limits_are_refused() {
|
||||
refused(16384, 2177);
|
||||
refused(16384, 8704);
|
||||
refused(4096, 8705);
|
||||
}
|
||||
|
||||
/// A 64x48 4:2:0 key frame whose sequence profile bit was flipped to 2 (4:2:2): blocks such as 4x16 then have no
|
||||
/// chroma size (BLOCK_INVALID) and used to index the size tables out of bounds.
|
||||
const PROFILE_FLIPPED: &[u8] = &[
|
||||
0x12, 0x00, 0x0a, 0x0b, 0x42, 0x00, 0x00, 0x05, 0x55, 0x7f, 0xbc, 0x6a, 0xf9, 0x00, 0x40, 0x32, 0x81, 0x05, 0x14, 0x00, 0xa5, 0xa0, 0x40, 0xd9, 0x52, 0xb9,
|
||||
0xe7, 0x3c, 0xa6, 0xc1, 0xe4, 0xe9, 0xff, 0x6d, 0x12, 0xc3, 0xce, 0xac, 0x00, 0x92, 0x6f, 0x29, 0x6a, 0xee, 0x7e, 0x1a, 0x31, 0xfe, 0xe6, 0xb8, 0x2e, 0xe2,
|
||||
0xcf, 0x3d, 0xce, 0x24, 0xe6, 0x30, 0x03, 0xcd, 0x89, 0x49, 0x1f, 0xa7, 0x8a, 0x75, 0xb3, 0x93, 0x27, 0x7b, 0x2f, 0x39, 0xc7, 0x60, 0xb7, 0xf8, 0x69, 0xbc,
|
||||
0x53, 0x71, 0x30, 0x64, 0x20, 0x7e, 0xc1, 0x10, 0x17, 0xe4, 0xca, 0x34, 0xe9, 0xae, 0x47, 0x39, 0x68, 0x68, 0xef, 0x33, 0x9f, 0x26, 0xe2, 0x3f, 0xf1, 0xef,
|
||||
0x02, 0xb7, 0xfd, 0xb1, 0x78, 0x4c, 0x35, 0x31, 0x17, 0x27, 0x11, 0x2f, 0xd7, 0x25, 0x01, 0x30, 0xa5, 0x7a, 0xaf, 0x9b, 0x5b, 0x3b, 0x8d, 0x9e, 0xd5, 0x23,
|
||||
0x06, 0x22, 0x3a, 0xec, 0x57, 0xae, 0xdb, 0x4d, 0x00, 0x36, 0xff, 0xbd, 0x3b, 0xef, 0x0a, 0xf0, 0x39, 0x2a, 0xa9, 0x2a, 0x56, 0x9d, 0x7e, 0xc2, 0x05, 0x46,
|
||||
0x21, 0x8b, 0xd9, 0x45, 0x33, 0x59, 0x3c, 0x40, 0xe0, 0x2b, 0xec, 0x51, 0x0a, 0xf2, 0xf0, 0xe5, 0x61, 0x99, 0xfd, 0x2e, 0xe5, 0x8c, 0x69, 0x72, 0x44, 0xb8,
|
||||
0x92, 0x9e, 0x2e, 0x51, 0x0b, 0x8c, 0x16, 0x0f, 0xd9, 0xf2, 0x47, 0xc4, 0x36, 0x9d, 0x54, 0xc6, 0x9f, 0x26, 0x68, 0xf7, 0xfa, 0x80, 0xab, 0x68, 0xd6, 0x61,
|
||||
0xd2, 0x59, 0x4b, 0x07, 0xc4, 0x7a, 0xde, 0x37, 0xfb, 0x02, 0xd5, 0x3d, 0xb0, 0xeb, 0x71, 0x1b, 0x07, 0x45, 0xdc, 0x03, 0x3b, 0x4f, 0xdf, 0x44, 0xa6, 0x28,
|
||||
0xd4, 0xb0, 0xbb, 0x26, 0xa4, 0x4f, 0xee, 0xbb, 0xda, 0x59, 0x69, 0xfc, 0xf2, 0x4f, 0xf6, 0x6c, 0x0c, 0xd9, 0xe2, 0xc0, 0xa7, 0x64, 0x96, 0xd5, 0x7b, 0x1a,
|
||||
0x71, 0xe0, 0xcc, 0x9e, 0xd7, 0xaf, 0xb9, 0x9a, 0x99, 0xcc, 0xe9, 0xa9, 0x79, 0xa3, 0xa8, 0xbc, 0xfe, 0xdb, 0xed, 0xa3, 0xd6, 0x7d, 0x31, 0xb0, 0x18, 0x3c,
|
||||
0x9e, 0x55, 0x0f, 0x1a, 0xdf, 0x5d, 0x8c, 0x33, 0x5f, 0x2d, 0x00, 0xfc, 0x9d, 0x55, 0xc2, 0xc8, 0x10, 0x25, 0xc8, 0x7f, 0xa6, 0x47, 0xbf, 0xab, 0xf3, 0x97,
|
||||
0xfb, 0xda, 0xd6, 0x6a, 0x14, 0x87, 0x9d, 0xef, 0x85, 0x12, 0x43, 0x31, 0xc4, 0x4c, 0x4c, 0x7a, 0x50, 0xd1, 0x87, 0xb8, 0x65, 0x2d, 0x3f, 0xf4, 0xa3, 0x54,
|
||||
0xea, 0x19, 0xa6, 0xfe, 0x70, 0x30, 0x52, 0x7e, 0xbb, 0x9c, 0x3f, 0x2d, 0x10, 0x4b, 0x43, 0x04, 0x6c, 0x03, 0xc7, 0x15, 0xa8, 0x8e, 0x20, 0xde, 0x46, 0x9a,
|
||||
0x12, 0xdf, 0xe5, 0xce, 0x08, 0x91, 0x3e, 0x74, 0xa4, 0x20, 0x78, 0x52, 0xfd, 0x4d, 0x9d, 0x5a, 0xca, 0xa2, 0xdc, 0xc8, 0x95, 0xa6, 0x69, 0x3a, 0x1a, 0x18,
|
||||
0x48, 0x76, 0x8c, 0xfa, 0x58, 0xa0, 0x0d, 0xf4, 0xbd, 0xe1, 0x05, 0x37, 0xe6, 0xbb, 0x60, 0xa1, 0x83, 0x40, 0x08, 0xeb, 0x02, 0xa6, 0x6a, 0x7a, 0x16, 0x1f,
|
||||
0xb3, 0xbb, 0x58, 0x66, 0x51, 0x79, 0xc7, 0x7a, 0xb2, 0xc2, 0x84, 0x69, 0x17, 0x4d, 0x27, 0xa7, 0xa4, 0xd9, 0xc2, 0xb4, 0x03, 0x57, 0x53, 0x44, 0xb9, 0x8f,
|
||||
0x48, 0xd2, 0x1c, 0x74, 0xd9, 0xdd, 0x9b, 0x88, 0xdc, 0xda, 0xef, 0x70, 0xee, 0xf1, 0x04, 0xdf, 0x35, 0x39, 0x22, 0x49, 0xfe, 0x22, 0x9a, 0x0a, 0x33, 0x60,
|
||||
0x49, 0xbe, 0x28, 0xfa, 0xdf, 0x71, 0xf6, 0x53, 0x87, 0x49, 0x01, 0x95, 0xfa, 0x22, 0x94, 0x7c, 0x0e, 0xf6, 0x7d, 0x7e, 0x1d, 0x41, 0x8d, 0xc9, 0x1b, 0xf9,
|
||||
0x53, 0xc9, 0xea, 0x46, 0xee, 0xb2, 0x80, 0x75, 0xe1, 0xca, 0xa4, 0x0a, 0x9c, 0x2b, 0x7b, 0x18, 0x70, 0x56, 0x22, 0x76, 0xaf, 0x4d, 0x84, 0xdd, 0xad, 0x77,
|
||||
0x0e, 0xea, 0x2e, 0xa9, 0xbf, 0x42, 0x95, 0xdf, 0x17, 0x61, 0xae, 0x46, 0x20, 0xb4, 0x57, 0xca, 0xe0, 0x78, 0x86, 0xd0, 0x51, 0xae, 0xfe, 0x51, 0x53, 0x0f,
|
||||
0x36, 0x64, 0x5f, 0xb4, 0x5c, 0xac, 0x8b, 0x63, 0xe7, 0x5d, 0x98, 0x8e, 0x65, 0xe9, 0x93, 0xe4, 0x23, 0x10, 0xa3, 0x5b, 0x3b, 0x38, 0x5b, 0x8a, 0x7c, 0xe8,
|
||||
0x60, 0x01, 0xc1, 0x0e, 0x53, 0x5e, 0x0e, 0x81, 0x33, 0x63, 0x8d, 0x53, 0x82, 0xa6, 0x7b, 0xbd, 0x02, 0x58, 0x1f, 0xe9, 0x50, 0x75, 0x24, 0x64, 0x80, 0x47,
|
||||
0x02, 0x9e, 0x42, 0x28, 0xb0, 0xa2, 0xab, 0x8e, 0xd0, 0x81, 0xcc, 0xbd, 0x22, 0x87, 0x3f, 0xe4, 0x49, 0x4d, 0x5f, 0x6a, 0x5c, 0x5a, 0x5e, 0xe5, 0x30, 0x9d,
|
||||
0x7f, 0xc5, 0xaf, 0xce, 0x6c, 0x9c, 0xaf, 0x09, 0x96, 0x1a, 0x62, 0x42, 0x61, 0xa3, 0x1d, 0x1c, 0xe6, 0xcf, 0xa8, 0x90, 0x4d, 0x05, 0x28, 0xef, 0x22, 0x81,
|
||||
0x12, 0xa7, 0x70, 0x04, 0x0a, 0x0d, 0xe5, 0xa0, 0xd8,
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn block_without_chroma_size_is_an_error_not_a_panic() {
|
||||
let mut dec = Decoder::new();
|
||||
let r = dec.decode(PROFILE_FLIPPED).and_then(|_| dec.flush_result());
|
||||
assert!(matches!(r, Err(Error::Invalid(m)) if m.contains("chroma size")), "got {:?}", r.as_ref().map(|p| p.len()));
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mod adhoc;
|
||||
mod common;
|
||||
mod conformance;
|
||||
mod hostile_dims;
|
||||
mod oracle_inter;
|
||||
mod oracle_intra;
|
||||
mod perf;
|
||||
@@ -2,7 +2,7 @@
|
||||
//! GOPs with hierarchical references, compound prediction, OBMC / warped motion, global motion,
|
||||
//! motion-field projection and all loop filters.
|
||||
|
||||
mod common;
|
||||
use crate::common;
|
||||
use common::*;
|
||||
|
||||
fn specs() -> Vec<Spec> {
|
||||
@@ -101,3 +101,19 @@ fn inter_1080p() {
|
||||
check_bit_exact(&ff, &s);
|
||||
}
|
||||
}
|
||||
|
||||
/// 4:2:2 and 4:4:4 (libaom; SVT-AV1 only makes 4:2:0): the post-filters run in row bands whose
|
||||
/// chroma rows depend on the vertical subsampling, so these check the band edges there too.
|
||||
#[test]
|
||||
fn inter_chroma_444_422() {
|
||||
let Some(ff) = ffmpeg_libaom() else { return };
|
||||
let p = ["-cpu-used", "8", "-crf", "40", "-enable-restoration", "1"];
|
||||
let src = "testsrc2=s=416x400:r=25,noise=alls=8:allf=t";
|
||||
for s in [
|
||||
Spec::new("inter_aom_444", src, 6, "yuv444p", &p).encoder("libaom-av1"),
|
||||
Spec::new("inter_aom_422", src, 6, "yuv422p", &p).encoder("libaom-av1"),
|
||||
Spec::new("inter_aom_444_10bit", src, 6, "yuv444p10le", &p).encoder("libaom-av1"),
|
||||
] {
|
||||
check_bit_exact(&ff, &s);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Intra-frame bit-exactness against libdav1d (run as an external ffmpeg process).
|
||||
|
||||
mod common;
|
||||
use crate::common;
|
||||
use common::*;
|
||||
|
||||
const NO_FILTERS: &str = "enable-cdef=0:enable-restoration=0:enable-dlf=0:keyint=1";
|
||||
@@ -1,11 +1,11 @@
|
||||
//! Decode throughput and per-stage breakdown (ignored; run with
|
||||
//! `cargo test --release -p filmcraft-av1 --test perf -- --ignored --nocapture`).
|
||||
//! `cargo test --release -p filmcraft-av1 --test it perf:: -- --ignored --nocapture`).
|
||||
//!
|
||||
//! The machine may be busy (other builds), so single-threaded work is also measured with the
|
||||
//! thread CPU clock, which is robust to load; wall-clock figures are printed next to the load
|
||||
//! average.
|
||||
|
||||
mod common;
|
||||
use crate::common;
|
||||
use common::*;
|
||||
use filmcraft_av1::{DecodeStats, Decoder, Stage};
|
||||
use std::time::Instant;
|
||||
@@ -51,8 +51,8 @@ fn run(frames: &[Vec<u8>], threads: usize) -> Run {
|
||||
}
|
||||
|
||||
fn bench(spec: &Spec) {
|
||||
// AV1_PERF_ONLY=<substring> limits the run to matching fixtures.
|
||||
if std::env::var("AV1_PERF_ONLY").is_ok_and(|f| !spec.name.contains(f.as_str())) {
|
||||
// AV1_PERF_ONLY=<substring>[,<substring>...] limits the run to matching fixtures.
|
||||
if std::env::var("AV1_PERF_ONLY").is_ok_and(|f| !f.split(',').any(|f| spec.name.contains(f))) {
|
||||
return;
|
||||
}
|
||||
let Some(ff) = ffmpeg() else { return };
|
||||
@@ -68,7 +68,6 @@ fn bench(spec: &Spec) {
|
||||
}
|
||||
}
|
||||
let b = best.unwrap();
|
||||
let n = b.stats.frames.max(1) as f64;
|
||||
let cpu_fps = b.cpu.map(|c| format!("{:6.1}", b.shown as f64 / c)).unwrap_or_else(|| " ?".into());
|
||||
println!(
|
||||
"{:<26} {:>3} shown {:>3} decoded {:>3} tiles/frame | 1 thread: {} fps (thread CPU) {:6.1} fps (wall)",
|
||||
@@ -79,24 +78,35 @@ fn bench(spec: &Spec) {
|
||||
cpu_fps,
|
||||
b.shown as f64 / b.wall
|
||||
);
|
||||
let total = b.stats.total_secs();
|
||||
let mut line = String::from(" ms/frame:");
|
||||
println!("{}", stage_line(" ms/frame:", &b.stats));
|
||||
// All cores: best of 3 by wall time.
|
||||
let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
|
||||
let mut best: Option<Run> = None;
|
||||
for _ in 0..3 {
|
||||
let r = run(&frames, threads);
|
||||
assert_eq!(r.shown, b.shown);
|
||||
if best.as_ref().is_none_or(|b| r.wall < b.wall) {
|
||||
best = Some(r);
|
||||
}
|
||||
}
|
||||
let m = best.unwrap();
|
||||
println!(" {threads} threads: {:6.1} fps (wall, load average {})", b.shown as f64 / m.wall, load_average());
|
||||
// Per-stage wall time on the frame workers (stages of different frames overlap).
|
||||
println!("{}", stage_line(&format!(" {threads} threads, ms/frame:"), &m.stats));
|
||||
}
|
||||
|
||||
/// Per-stage milliseconds per frame and share of the total.
|
||||
fn stage_line(label: &str, stats: &DecodeStats) -> String {
|
||||
let n = stats.frames.max(1) as f64;
|
||||
let total = stats.total_secs();
|
||||
let mut line = String::from(label);
|
||||
for s in Stage::ALL {
|
||||
let v = b.stats.secs(s);
|
||||
let v = stats.secs(s);
|
||||
if v > 0.0005 {
|
||||
line += &format!(" {} {:.2} ({:.0}%)", s.name(), 1e3 * v / n, 100.0 * v / total);
|
||||
}
|
||||
}
|
||||
println!("{line}");
|
||||
// All cores: best of 3 by wall time.
|
||||
let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
|
||||
let mut best_wall = f64::INFINITY;
|
||||
for _ in 0..3 {
|
||||
let r = run(&frames, threads);
|
||||
assert_eq!(r.shown, b.shown);
|
||||
best_wall = best_wall.min(r.wall);
|
||||
}
|
||||
println!(" {threads} threads: {:6.1} fps (wall, load average {})", b.shown as f64 / best_wall, load_average());
|
||||
line
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -110,4 +120,9 @@ fn decode_throughput() {
|
||||
// Higher quality (more coefficients, finer partitions) and a multi-tile stream (4x2 tiles).
|
||||
bench(&Spec::new("perf_1080p_gop_hq", src, 60, "yuv420p", &p("18", "keyint=60")));
|
||||
bench(&Spec::new("perf_1080p_gop_tiles", src, 60, "yuv420p", &p("30", "keyint=60:tile-columns=2:tile-rows=1")));
|
||||
// Low delay (each frame references the previous one, as screen and camera recorders encode):
|
||||
// frames cannot overlap, so a frame's own parallelism decides the speed.
|
||||
bench(&Spec::new("perf_1080p_lowdelay", src, 60, "yuv420p", &p("30", "keyint=60:pred-struct=1")));
|
||||
// One tile at 2160p: the post-filters' row bands are most of the parallelism there.
|
||||
bench(&Spec::new("perf_2160p_gop", "testsrc2=s=3840x2160:r=25,noise=alls=6:allf=t", 30, "yuv420p", &p("30", "keyint=30")));
|
||||
}
|
||||
@@ -17,8 +17,22 @@ dimensions; the element type is the narrowest of u8/i8/u16/i16/i32 that holds ev
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
root = sys.argv[1]
|
||||
|
||||
def _spec_dir(arg):
|
||||
"""Resolve the CLI argument and require it to be an existing directory."""
|
||||
path = Path(arg).resolve()
|
||||
if not path.is_dir():
|
||||
sys.exit(f"not a directory: {arg}")
|
||||
return path
|
||||
|
||||
|
||||
if len(sys.argv) != 2:
|
||||
sys.exit("usage: extract_tables.py <av1-spec checkout>")
|
||||
# The argument is the operator's own spec checkout; _spec_dir requires it to be an existing
|
||||
# directory before anything is read from it.
|
||||
root = _spec_dir(sys.argv[1]) # NOSONAR
|
||||
files = sorted(f for f in os.listdir(root) if f.endswith(".md"))
|
||||
texts = {f: open(os.path.join(root, f), encoding="utf-8").read() for f in files}
|
||||
|
||||
@@ -35,7 +49,7 @@ def ev(expr):
|
||||
# --- constants: symbol table (section 3) ---
|
||||
pending = []
|
||||
for line in texts["03.symbols.md"].split("\n"):
|
||||
m = re.match(r"^\|\s*`([A-Z][A-Z0-9_]*)`\s*\|\s*([^|]+?)\s*\|", line)
|
||||
m = re.match(r"^\|\s*`([A-Z][A-Z0-9_]*)`\s*\|\s*([^\s|](?:[^|]*[^|\s])?)\s*\|", line)
|
||||
if m:
|
||||
pending.append((m.group(1), m.group(2)))
|
||||
# --- constants: value/name tables (section 6 semantics, section 8/9) ---
|
||||
@@ -77,7 +91,7 @@ for name, val in pending:
|
||||
def cells(text):
|
||||
"""Split one initialiser cell. A missing comma between two operands on separate lines
|
||||
(Split_Tx_Size in the spec text: `TX_32X32` / `TX_4X8`) yields two cells."""
|
||||
parts = re.split(r"(?<=[A-Za-z0-9_])\s*\n\s*(?=[A-Za-z0-9_])", text.strip())
|
||||
parts = re.split(r"(?<=[A-Za-z0-9_])[^\S\n]*\n[^\S\n]*(?=[A-Za-z0-9_])", text.strip())
|
||||
if len(parts) > 1:
|
||||
sys.stderr.write("note: missing comma in spec table between %s\n" % parts)
|
||||
return parts
|
||||
@@ -118,10 +132,25 @@ def shape(x):
|
||||
return []
|
||||
|
||||
|
||||
# `~~~~` opens a block on its own line (with an optional `c` marker) and the next run of
|
||||
# tildes closes it. Walk the pairs with two linear searches instead of the lazy `(.*?)`
|
||||
# with re.S, which rescans the whole remaining text at every position (super-linear).
|
||||
FENCE_OPEN = re.compile(r"~~~~~?\s*c?\n")
|
||||
FENCE_CLOSE = re.compile(r"~~~~~?")
|
||||
|
||||
tables = {}
|
||||
for f in files:
|
||||
t = texts[f]
|
||||
for block in re.findall(r"~~~~~?\s*c?\n(.*?)~~~~~?", t, re.S):
|
||||
pos = 0
|
||||
while True:
|
||||
m = FENCE_OPEN.search(t, pos)
|
||||
if not m:
|
||||
break
|
||||
c = FENCE_CLOSE.search(t, m.end())
|
||||
if not c:
|
||||
break
|
||||
block = t[m.end():c.start()]
|
||||
pos = c.end()
|
||||
b = re.sub(r"//[^\n]*", "", block)
|
||||
b = re.sub(r"/\*.*?\*/", "", b, flags=re.S)
|
||||
for m in re.finditer(r"(?m)^\s*([A-Z][A-Za-z0-9_]*)\s*((?:\[[^\]=]*\]\s*)+)=\s*", b):
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "filmcraft-avi"
|
||||
description = "Clean-room AVI (RIFF) and OpenDML demuxer"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[dev-dependencies]
|
||||
filmcraft-testkit = { workspace = true }
|
||||
@@ -0,0 +1,43 @@
|
||||
# filmcraft-avi
|
||||
|
||||
A clean-room AVI demuxer, written from Microsoft's published AVI RIFF file reference and the
|
||||
OpenDML AVI File Format Extensions 1.02. Layer L0: no dependencies beyond `std`, no `unsafe`, builds
|
||||
for `wasm32-unknown-unknown`. No FFmpeg, libavformat or other AVI implementation was read; ffmpeg
|
||||
and ffprobe are used only as external **test oracles**.
|
||||
|
||||
## API
|
||||
|
||||
```rust
|
||||
use filmcraft_avi::{open, StreamKind};
|
||||
|
||||
let file = open(&bytes)?; // any ByteSource: a slice, a file, a web Blob…
|
||||
for (i, s) in file.streams.iter().enumerate() {
|
||||
match s.kind {
|
||||
StreamKind::Video => println!("{:?} {} fps", s.video.as_ref().map(|b| b.compression), s.rate as f64 / s.scale as f64),
|
||||
StreamKind::Audio => println!("format 0x{:04x}", s.audio.as_ref().map_or(0, |w| w.format_tag)),
|
||||
_ => {}
|
||||
}
|
||||
let first = file.read_chunk(&bytes, i, 0)?; // frame data, on demand
|
||||
}
|
||||
```
|
||||
|
||||
## What it reads
|
||||
|
||||
| Part | |
|
||||
|---|---|
|
||||
| Headers | `avih`; per stream `strh`, `strf` (`BITMAPINFOHEADER`, `WAVEFORMATEX` / `WAVEFORMATEXTENSIBLE`), `strn` |
|
||||
| Index | OpenDML `indx` super index → `ix##` standard indexes (field indexes too), else `idx1` (offsets relative to `movi` or absolute), else a scan of the `movi` lists |
|
||||
| Large files | `RIFF AVIX` extensions: through the OpenDML index, or scanned after `idx1`, which only covers the first RIFF |
|
||||
| Key frames | from the index; a scan marks every chunk and says so (`keyframes_known`) |
|
||||
| Damage | sizes clamped to the file (a cut-off recording opens with what is there), `rec ` lists nested at most 3 deep, tables bounded by what the file could hold |
|
||||
|
||||
`filmcraft-codecs` maps the streams onto decoders (`AviSource`): Motion JPEG, H.264, HEVC and
|
||||
uncompressed RGB / YUV video; PCM, MP3, MP2 and AC-3 audio.
|
||||
|
||||
## Tests
|
||||
|
||||
`cargo test -p filmcraft-avi`: hand-built files for every index path (`idx1` relative and absolute,
|
||||
`rec ` lists, OpenDML standard indexes, a scan), truncation, and every single-byte mutation and
|
||||
truncation of them (never a panic). `tests/ffmpeg_oracle.rs` checks ffmpeg-made files (Motion JPEG,
|
||||
H.264 with B-frames, BGR24, YUY2; PCM and MP3) against ffprobe; the ignored `opendml_file_past_1_gb`
|
||||
checks a 1.16 GB OpenDML file with `AVIX` extensions.
|
||||
@@ -0,0 +1,565 @@
|
||||
//! Clean-room AVI demuxer, written from Microsoft's published AVI RIFF file reference and the
|
||||
//! OpenDML AVI File Format Extensions 1.02.
|
||||
//!
|
||||
//! [`open`] reads the `hdrl` header list (`avih`, and per stream `strh` / `strf` / `strn` /
|
||||
//! `indx`) of the first `RIFF AVI ` and builds a per-stream table of [`Chunk`]s from the best index
|
||||
//! the file has: the OpenDML super index (`indx` → `ix##` standard indexes, which also cover the
|
||||
//! `RIFF AVIX` extensions of files past 1 GB), else the AVI 1.0 `idx1` (plus a scan of any `AVIX`
|
||||
//! extension it cannot see), else a scan of the `movi` lists. Frame data is read on demand with
|
||||
//! [`AviFile::read_chunk`]. Truncated files (a recording cut off) open with what is there.
|
||||
//!
|
||||
//! Every size and count comes from the file and is treated as hostile: reads are bounded by the
|
||||
//! file length, tables by the number of chunks the file could hold, and nesting by a fixed depth.
|
||||
//!
|
||||
//! Layer L0: no dependencies beyond `std`, no `unsafe`, builds for `wasm32-unknown-unknown`.
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
mod source;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use source::ByteSource;
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Error {
|
||||
/// Not an AVI file (no `RIFF` … `AVI ` header).
|
||||
NotAvi,
|
||||
/// A read failed.
|
||||
Io(String),
|
||||
/// Structurally invalid data.
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Error::NotAvi => f.write_str("not an AVI file"),
|
||||
Error::Io(e) => write!(f, "I/O error: {e}"),
|
||||
Error::Invalid(e) => write!(f, "invalid AVI: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// Whether `head` starts like an AVI file: `RIFF` <size> `AVI `.
|
||||
pub fn sniff(head: &[u8]) -> bool {
|
||||
head.get(..4) == Some(b"RIFF") && head.get(8..12) == Some(b"AVI ")
|
||||
}
|
||||
|
||||
/// `avih`: the main header (frame period, flags, frame count, size).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct MainHeader {
|
||||
pub micro_sec_per_frame: u32,
|
||||
pub flags: u32,
|
||||
pub total_frames: u32,
|
||||
pub streams: u32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum StreamKind {
|
||||
Video,
|
||||
Audio,
|
||||
/// Text, MIDI or anything else (`fccType`).
|
||||
Other([u8; 4]),
|
||||
}
|
||||
|
||||
/// `strf` of a video stream: a `BITMAPINFOHEADER`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct BitmapInfo {
|
||||
pub width: i32,
|
||||
/// Positive: rows stored bottom-up (uncompressed RGB); negative: top-down.
|
||||
pub height: i32,
|
||||
pub bit_count: u16,
|
||||
/// `biCompression` as four bytes: a FourCC (`MJPG`, `H264`, `YUY2`…) or 0 / 3 (`BI_RGB`,
|
||||
/// `BI_BITFIELDS`) in little-endian.
|
||||
pub compression: [u8; 4],
|
||||
pub size_image: u32,
|
||||
/// Bytes after the 40-byte header (codec setup, palette).
|
||||
pub extra: Vec<u8>,
|
||||
}
|
||||
|
||||
impl BitmapInfo {
|
||||
/// Uncompressed RGB (`BI_RGB` / `BI_BITFIELDS`).
|
||||
pub fn is_rgb(&self) -> bool {
|
||||
matches!(u32::from_le_bytes(self.compression), 0 | 3)
|
||||
}
|
||||
}
|
||||
|
||||
/// `strf` of an audio stream: a `WAVEFORMATEX` (`WAVEFORMATEXTENSIBLE` is resolved to its sub-format).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct WaveFormat {
|
||||
/// 1 PCM, 3 IEEE float, 0x50 MPEG audio, 0x55 MP3, 0x2000 AC-3, 0xFF AAC…
|
||||
pub format_tag: u16,
|
||||
pub channels: u16,
|
||||
pub sample_rate: u32,
|
||||
pub avg_bytes_per_sec: u32,
|
||||
pub block_align: u16,
|
||||
pub bits_per_sample: u16,
|
||||
/// `cbSize` bytes after the 18-byte header.
|
||||
pub extra: Vec<u8>,
|
||||
}
|
||||
|
||||
/// One stored frame or audio chunk.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Chunk {
|
||||
/// Offset of the data (after the 8-byte chunk header).
|
||||
pub offset: u64,
|
||||
pub size: u32,
|
||||
/// A key frame (always true for audio and when the index does not say).
|
||||
pub key: bool,
|
||||
}
|
||||
|
||||
/// Where the chunk tables came from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum IndexSource {
|
||||
/// OpenDML `indx` super index and `ix##` standard indexes.
|
||||
OpenDml,
|
||||
/// The AVI 1.0 `idx1` (plus a scan of the `AVIX` extensions).
|
||||
Idx1,
|
||||
/// No usable index: the `movi` lists were scanned (key frames unknown).
|
||||
Scan,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Stream {
|
||||
pub kind: StreamKind,
|
||||
/// `fccHandler`.
|
||||
pub handler: [u8; 4],
|
||||
/// Time base: `rate / scale` units per second (frames per second for video).
|
||||
pub scale: u32,
|
||||
pub rate: u32,
|
||||
/// Start time in `scale / rate` units.
|
||||
pub start: u32,
|
||||
/// Length in `scale / rate` units.
|
||||
pub length: u32,
|
||||
/// Bytes per sample for fixed-size samples (PCM block align), 0 when samples vary.
|
||||
pub sample_size: u32,
|
||||
pub video: Option<BitmapInfo>,
|
||||
pub audio: Option<WaveFormat>,
|
||||
/// `strn`.
|
||||
pub name: Option<String>,
|
||||
pub chunks: Vec<Chunk>,
|
||||
/// Whether [`Chunk::key`] comes from an index (a scan cannot tell).
|
||||
pub keyframes_known: bool,
|
||||
/// The OpenDML super index entries (offsets of the `ix##` chunks).
|
||||
super_index: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Stream {
|
||||
/// Stream index of a `movi` chunk id (`00dc`, `01wb`…): its first two characters.
|
||||
fn number(id: &[u8; 4]) -> Option<usize> {
|
||||
let d = |b: u8| (b as char).to_digit(16).map(|v| v as usize);
|
||||
Some(d(id[0])? * 16 + d(id[1])?)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AviFile {
|
||||
pub header: MainHeader,
|
||||
pub streams: Vec<Stream>,
|
||||
pub index: IndexSource,
|
||||
}
|
||||
|
||||
/// Options for [`open_with`] (tests reach the fallback indexes with them).
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct OpenOptions {
|
||||
pub ignore_odml: bool,
|
||||
pub ignore_idx1: bool,
|
||||
}
|
||||
|
||||
/// Headers larger than this are refused (a real `hdrl` is a few kilobytes).
|
||||
const MAX_HEADER: u64 = 16 << 20;
|
||||
/// Total OpenDML standard-index bytes accepted while opening one file. This bounds hostile
|
||||
/// repeated index references without constraining practical large-file indexes.
|
||||
const MAX_ODML_INDEX_BYTES: u64 = 64 << 20;
|
||||
/// Streams beyond this are ignored.
|
||||
const MAX_STREAMS: usize = 64;
|
||||
/// `LIST rec ` inside `movi` nests once; deeper is refused.
|
||||
const MAX_DEPTH: u32 = 3;
|
||||
|
||||
fn io(e: std::io::Error) -> Error {
|
||||
Error::Io(e.to_string())
|
||||
}
|
||||
|
||||
fn read<S: ByteSource + ?Sized>(src: &S, at: u64, n: u64) -> Result<Vec<u8>> {
|
||||
let end = at.checked_add(n).filter(|e| *e <= src.len()).ok_or_else(|| Error::Invalid(format!("{n} bytes at {at} run past the end")))?;
|
||||
let len = usize::try_from(end - at).map_err(|_| Error::Invalid("chunk too large".into()))?;
|
||||
let mut buf = vec![0; len];
|
||||
src.read_at(at, &mut buf).map_err(io)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
fn fourcc(b: &[u8], at: usize) -> [u8; 4] {
|
||||
let mut f = [0; 4];
|
||||
if let Some(s) = b.get(at..at + 4) {
|
||||
f.copy_from_slice(s);
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
fn le16(b: &[u8], at: usize) -> u16 {
|
||||
b.get(at..at + 2).map_or(0, |s| u16::from_le_bytes([s[0], s[1]]))
|
||||
}
|
||||
|
||||
fn le32(b: &[u8], at: usize) -> u32 {
|
||||
b.get(at..at + 4).map_or(0, |s| u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
|
||||
}
|
||||
|
||||
fn le64(b: &[u8], at: usize) -> u64 {
|
||||
u64::from(le32(b, at)) | (u64::from(le32(b, at + 4)) << 32)
|
||||
}
|
||||
|
||||
/// A chunk header at `at`: (id, size, data offset). The size is clamped to the file (a truncated
|
||||
/// recording keeps what is there).
|
||||
fn header_at<S: ByteSource + ?Sized>(src: &S, at: u64) -> Result<([u8; 4], u64, u64)> {
|
||||
let h = read(src, at, 8)?;
|
||||
let data = at + 8;
|
||||
let size = u64::from(le32(&h, 4)).min(src.len().saturating_sub(data));
|
||||
Ok((fourcc(&h, 0), size, data))
|
||||
}
|
||||
|
||||
/// Iterate the chunks of `[from, to)`: (id, size, data offset). Stops at the first unreadable
|
||||
/// header; every step moves forward, so it ends.
|
||||
fn children<S: ByteSource + ?Sized>(src: &S, from: u64, to: u64) -> Vec<([u8; 4], u64, u64)> {
|
||||
let mut out = Vec::new();
|
||||
let mut at = from;
|
||||
while at.checked_add(8).is_some_and(|e| e <= to) {
|
||||
let Ok((id, size, data)) = header_at(src, at) else { break };
|
||||
out.push((id, size, data));
|
||||
// chunks are padded to an even size
|
||||
let Some(next) = data.checked_add(size).and_then(|e| e.checked_add(size & 1)) else { break };
|
||||
if next <= at {
|
||||
break;
|
||||
}
|
||||
at = next;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Open an AVI file.
|
||||
pub fn open<S: ByteSource + ?Sized>(src: &S) -> Result<AviFile> {
|
||||
open_with(src, OpenOptions::default())
|
||||
}
|
||||
|
||||
pub fn open_with<S: ByteSource + ?Sized>(src: &S, opts: OpenOptions) -> Result<AviFile> {
|
||||
let head = read(src, 0, 12.min(src.len())).map_err(|_| Error::NotAvi)?;
|
||||
if !sniff(&head) {
|
||||
return Err(Error::NotAvi);
|
||||
}
|
||||
// the RIFF forms: the first `AVI `, then any `AVIX` extensions (OpenDML)
|
||||
let mut hdrl = None;
|
||||
let mut movis: Vec<(u64, u64, u64)> = Vec::new(); // (position of the `movi` id, data start, data end)
|
||||
let mut idx1 = None;
|
||||
for (id, size, data) in children(src, 0, src.len()) {
|
||||
if id != *b"RIFF" || size < 4 {
|
||||
continue;
|
||||
}
|
||||
let end = data + size;
|
||||
let form = fourcc(&read(src, data, 4)?, 0);
|
||||
if form != *b"AVI " && form != *b"AVIX" {
|
||||
continue;
|
||||
}
|
||||
for (cid, csize, cdata) in children(src, data + 4, end) {
|
||||
match &cid {
|
||||
b"LIST" if csize >= 4 => {
|
||||
let ltype = fourcc(&read(src, cdata, 4)?, 0);
|
||||
if ltype == *b"hdrl" && hdrl.is_none() && form == *b"AVI " {
|
||||
hdrl = Some((cdata + 4, cdata + csize));
|
||||
} else if ltype == *b"movi" {
|
||||
movis.push((cdata, cdata + 4, cdata + csize));
|
||||
}
|
||||
}
|
||||
b"idx1" if form == *b"AVI " => idx1 = Some((cdata, csize)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let (hfrom, hto) = hdrl.ok_or_else(|| Error::Invalid("no hdrl header list".into()))?;
|
||||
if hto - hfrom > MAX_HEADER {
|
||||
return Err(Error::Invalid("header list too large".into()));
|
||||
}
|
||||
let (header, mut streams) = parse_hdrl(src, hfrom, hto)?;
|
||||
if streams.is_empty() {
|
||||
return Err(Error::Invalid("no streams".into()));
|
||||
}
|
||||
// chunk tables from the best index
|
||||
let odml = !opts.ignore_odml && streams.iter().any(|s| !s.super_index.is_empty());
|
||||
let index = if odml && read_odml(src, &mut streams) {
|
||||
IndexSource::OpenDml
|
||||
} else if let (false, Some((at, size)), Some(first)) = (opts.ignore_idx1, idx1, movis.first()) {
|
||||
clear(&mut streams);
|
||||
read_idx1(src, &mut streams, at, size, first.0);
|
||||
// idx1 only covers the first RIFF: the AVIX extensions are scanned
|
||||
for &(_, from, to) in movis.iter().skip(1) {
|
||||
scan(src, &mut streams, from, to, 0);
|
||||
}
|
||||
if streams.iter().all(|s| s.chunks.is_empty()) { scan_all(src, &mut streams, &movis) } else { IndexSource::Idx1 }
|
||||
} else {
|
||||
scan_all(src, &mut streams, &movis)
|
||||
};
|
||||
Ok(AviFile { header, streams, index })
|
||||
}
|
||||
|
||||
fn clear(streams: &mut [Stream]) {
|
||||
for s in streams.iter_mut() {
|
||||
s.chunks.clear();
|
||||
s.keyframes_known = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_all<S: ByteSource + ?Sized>(src: &S, streams: &mut [Stream], movis: &[(u64, u64, u64)]) -> IndexSource {
|
||||
clear(streams);
|
||||
for &(_, from, to) in movis {
|
||||
scan(src, streams, from, to, 0);
|
||||
}
|
||||
for s in streams.iter_mut() {
|
||||
s.keyframes_known = false;
|
||||
}
|
||||
IndexSource::Scan
|
||||
}
|
||||
|
||||
fn parse_hdrl<S: ByteSource + ?Sized>(src: &S, from: u64, to: u64) -> Result<(MainHeader, Vec<Stream>)> {
|
||||
let mut header = MainHeader::default();
|
||||
let mut streams = Vec::new();
|
||||
for (id, size, data) in children(src, from, to) {
|
||||
match &id {
|
||||
b"avih" => {
|
||||
let b = read(src, data, size.min(64))?;
|
||||
header = MainHeader {
|
||||
micro_sec_per_frame: le32(&b, 0),
|
||||
flags: le32(&b, 12),
|
||||
total_frames: le32(&b, 16),
|
||||
streams: le32(&b, 24),
|
||||
width: le32(&b, 32),
|
||||
height: le32(&b, 36),
|
||||
};
|
||||
}
|
||||
b"LIST" if size >= 4 && streams.len() < MAX_STREAMS && fourcc(&read(src, data, 4)?, 0) == *b"strl" => {
|
||||
streams.push(parse_strl(src, data + 4, data + size)?);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok((header, streams))
|
||||
}
|
||||
|
||||
fn parse_strl<S: ByteSource + ?Sized>(src: &S, from: u64, to: u64) -> Result<Stream> {
|
||||
let mut s = Stream {
|
||||
kind: StreamKind::Other([0; 4]),
|
||||
handler: [0; 4],
|
||||
scale: 1,
|
||||
rate: 1,
|
||||
start: 0,
|
||||
length: 0,
|
||||
sample_size: 0,
|
||||
video: None,
|
||||
audio: None,
|
||||
name: None,
|
||||
chunks: Vec::new(),
|
||||
keyframes_known: true,
|
||||
super_index: Vec::new(),
|
||||
};
|
||||
let mut strf = None;
|
||||
for (id, size, data) in children(src, from, to) {
|
||||
match &id {
|
||||
b"strh" => {
|
||||
let b = read(src, data, size.min(64))?;
|
||||
s.kind = match &fourcc(&b, 0) {
|
||||
b"vids" => StreamKind::Video,
|
||||
b"auds" => StreamKind::Audio,
|
||||
other => StreamKind::Other(*other),
|
||||
};
|
||||
s.handler = fourcc(&b, 4);
|
||||
s.scale = le32(&b, 20);
|
||||
s.rate = le32(&b, 24);
|
||||
s.start = le32(&b, 28);
|
||||
s.length = le32(&b, 32);
|
||||
s.sample_size = le32(&b, 44);
|
||||
}
|
||||
b"strf" => strf = Some(read(src, data, size.min(1 << 20))?),
|
||||
b"strn" => {
|
||||
let b = read(src, data, size.min(4096))?;
|
||||
let name = String::from_utf8_lossy(b.split(|&c| c == 0).next().unwrap_or_default()).trim().to_string();
|
||||
s.name = (!name.is_empty()).then_some(name);
|
||||
}
|
||||
b"indx" => s.super_index = super_index(&read(src, data, size.min(MAX_HEADER))?),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(b) = strf {
|
||||
match s.kind {
|
||||
StreamKind::Video => {
|
||||
s.video = Some(BitmapInfo {
|
||||
width: le32(&b, 4) as i32,
|
||||
height: le32(&b, 8) as i32,
|
||||
bit_count: le16(&b, 14),
|
||||
compression: fourcc(&b, 16),
|
||||
size_image: le32(&b, 20),
|
||||
extra: b.get(40.min(b.len())..).unwrap_or_default().to_vec(),
|
||||
});
|
||||
}
|
||||
StreamKind::Audio => {
|
||||
let cb = usize::from(le16(&b, 16));
|
||||
let extra = b.get(18.min(b.len())..(18 + cb).min(b.len())).unwrap_or_default().to_vec();
|
||||
let mut tag = le16(&b, 0);
|
||||
// WAVE_FORMAT_EXTENSIBLE: the sub-format GUID starts with the real tag
|
||||
if tag == 0xFFFE && extra.len() >= 22 {
|
||||
tag = le16(&extra, 6);
|
||||
}
|
||||
s.audio = Some(WaveFormat {
|
||||
format_tag: tag,
|
||||
channels: le16(&b, 2),
|
||||
sample_rate: le32(&b, 4),
|
||||
avg_bytes_per_sec: le32(&b, 8),
|
||||
block_align: le16(&b, 12),
|
||||
bits_per_sample: le16(&b, 14),
|
||||
extra,
|
||||
});
|
||||
}
|
||||
StreamKind::Other(_) => {}
|
||||
}
|
||||
}
|
||||
if s.scale == 0 || s.rate == 0 {
|
||||
(s.scale, s.rate) = (1, 1);
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// The `ix##` chunk offsets of an `indx` super index (`AVI_INDEX_OF_INDEXES`, 4 longs per entry).
|
||||
fn super_index(b: &[u8]) -> Vec<u64> {
|
||||
let longs = usize::from(le16(b, 0));
|
||||
let (subtype, itype, n) = (b.get(2).copied().unwrap_or(0), b.get(3).copied().unwrap_or(0xFF), le32(b, 4) as usize);
|
||||
if itype != 0 || subtype != 0 || longs != 4 {
|
||||
return Vec::new();
|
||||
}
|
||||
// header 24 bytes, entries of 16: qwOffset, dwSize, dwDuration
|
||||
let n = n.min(b.len().saturating_sub(24) / 16);
|
||||
(0..n).map(|k| le64(b, 24 + k * 16)).filter(|&o| o > 0).collect()
|
||||
}
|
||||
|
||||
/// Fill the chunk tables from the OpenDML standard indexes; false (tables cleared) when a stream
|
||||
/// that has data lacks one or none could be read.
|
||||
fn read_odml<S: ByteSource + ?Sized>(src: &S, streams: &mut [Stream]) -> bool {
|
||||
let entry_limit = src.len() / 8;
|
||||
let mut entries = 0u64;
|
||||
let mut index_bytes = 0u64;
|
||||
let mut seen = HashSet::new();
|
||||
for s in streams.iter_mut() {
|
||||
s.chunks.clear();
|
||||
s.keyframes_known = true;
|
||||
for &at in &s.super_index {
|
||||
// A super index can repeat an offset (even across streams). Read and process a
|
||||
// standard index once, rather than multiplying its work and allocations.
|
||||
if !seen.insert(at) {
|
||||
continue;
|
||||
}
|
||||
let Ok((id, size, data)) = header_at(src, at) else { continue };
|
||||
if &id[..2] != b"ix" {
|
||||
continue;
|
||||
}
|
||||
// Validate the fixed header before allocating the variable entry body.
|
||||
if size < 24 || index_bytes.checked_add(24).is_none_or(|n| n > MAX_ODML_INDEX_BYTES) {
|
||||
return false;
|
||||
}
|
||||
let Ok(header) = read(src, data, 24) else { continue };
|
||||
index_bytes += 24;
|
||||
let (longs, subtype, itype) = (usize::from(le16(&header, 0)), header.get(2).copied().unwrap_or(0), header.get(3).copied().unwrap_or(0));
|
||||
// AVI_INDEX_OF_CHUNKS; 2 longs per entry, or 3 for the field index (AVI_INDEX_2FIELD)
|
||||
if itype != 1 || !(longs == 2 && subtype == 0 || longs == 3 && subtype == 1) {
|
||||
continue;
|
||||
}
|
||||
let step = longs * 4;
|
||||
let n = u64::from(le32(&header, 4)).min(size.saturating_sub(24) / step as u64);
|
||||
let body = n.saturating_mul(step as u64);
|
||||
let Some(next_bytes) = index_bytes.checked_add(body) else { return false };
|
||||
let Some(next_entries) = entries.checked_add(n) else { return false };
|
||||
if next_bytes > MAX_ODML_INDEX_BYTES || next_entries > entry_limit {
|
||||
return false;
|
||||
}
|
||||
let Ok(b) = read(src, data, 24 + body) else { continue };
|
||||
index_bytes = next_bytes;
|
||||
entries = next_entries;
|
||||
let base = le64(&b, 12);
|
||||
let n = usize::try_from(n).unwrap_or(0);
|
||||
for k in 0..n {
|
||||
let e = 24 + k * step;
|
||||
let (off, raw) = (u64::from(le32(&b, e)), le32(&b, e + 4));
|
||||
let size = raw & 0x7FFF_FFFF;
|
||||
let Some(offset) = base.checked_add(off).filter(|o| o.checked_add(u64::from(size)).is_some_and(|end| end <= src.len())) else { continue };
|
||||
s.chunks.push(Chunk { offset, size, key: raw & 0x8000_0000 == 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
let ok = streams.iter().any(|s| !s.chunks.is_empty()) && streams.iter().all(|s| !s.super_index.is_empty() || matches!(s.kind, StreamKind::Other(_)));
|
||||
if !ok {
|
||||
clear(streams);
|
||||
}
|
||||
ok
|
||||
}
|
||||
|
||||
/// Fill the chunk tables from `idx1` (16-byte entries: id, flags, offset, size). Offsets are
|
||||
/// relative to the `movi` list's type id, or (some writers) absolute: the first entry decides.
|
||||
fn read_idx1<S: ByteSource + ?Sized>(src: &S, streams: &mut [Stream], at: u64, size: u64, movi: u64) {
|
||||
let Ok(b) = read(src, at, size.min(src.len() / 2)) else { return };
|
||||
let entries: Vec<&[u8]> = b.as_chunks::<16>().0.iter().map(|e| e.as_slice()).collect();
|
||||
let id_at = |pos: u64| read(src, pos, 4).ok().map(|v| fourcc(&v, 0));
|
||||
let first = entries.iter().find(|e| le32(e, 4) & 1 == 0);
|
||||
let relative = match first {
|
||||
Some(e) => {
|
||||
let (id, off) = (fourcc(e, 0), u64::from(le32(e, 8)));
|
||||
id_at(movi + off) == Some(id) || id_at(off) != Some(id)
|
||||
}
|
||||
None => true,
|
||||
};
|
||||
let base = if relative { movi } else { 0 };
|
||||
for e in entries {
|
||||
let (id, flags, off, len) = (fourcc(e, 0), le32(e, 4), u64::from(le32(e, 8)), le32(e, 12));
|
||||
// AVIIF_LIST entries (`rec ` lists) and palette changes carry no samples
|
||||
if flags & 1 != 0 || &id[2..] == b"pc" {
|
||||
continue;
|
||||
}
|
||||
let Some(s) = Stream::number(&id).and_then(|n| streams.get_mut(n)) else { continue };
|
||||
let Some(offset) = base.checked_add(off).and_then(|o| o.checked_add(8)).filter(|o| o.checked_add(u64::from(len)).is_some_and(|end| end <= src.len()))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
s.chunks.push(Chunk { offset, size: len, key: flags & 0x10 != 0 || s.kind != StreamKind::Video });
|
||||
}
|
||||
}
|
||||
|
||||
/// Append the data chunks of a `movi` list (`[from, to)`) to the streams' tables, in file order.
|
||||
fn scan<S: ByteSource + ?Sized>(src: &S, streams: &mut [Stream], from: u64, to: u64, depth: u32) {
|
||||
if depth >= MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for (id, size, data) in children(src, from, to) {
|
||||
if id == *b"LIST" {
|
||||
if size >= 4 {
|
||||
scan(src, streams, data + 4, data + size, depth + 1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if &id[..2] == b"ix" || &id[2..] == b"pc" {
|
||||
continue;
|
||||
}
|
||||
if let Some(s) = Stream::number(&id).and_then(|n| streams.get_mut(n)) {
|
||||
s.chunks.push(Chunk { offset: data, size: u32::try_from(size).unwrap_or(u32::MAX), key: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AviFile {
|
||||
/// The bytes of chunk `i` of stream `stream`.
|
||||
pub fn read_chunk<S: ByteSource + ?Sized>(&self, src: &S, stream: usize, i: usize) -> Result<Vec<u8>> {
|
||||
let c = self.streams.get(stream).and_then(|s| s.chunks.get(i)).ok_or_else(|| Error::Invalid(format!("no chunk {i} in stream {stream}")))?;
|
||||
read(src, c.offset, u64::from(c.size))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Random-access byte sources for the demuxer (same shape as the other container crates', so one
|
||||
//! adapter serves them all).
|
||||
|
||||
use std::io;
|
||||
|
||||
/// Random-access, read-only byte source (file, in-memory buffer, web Blob…).
|
||||
pub trait ByteSource {
|
||||
/// Total length in bytes.
|
||||
fn len(&self) -> u64;
|
||||
/// Fill `buf` entirely with bytes starting at `offset`; fail with `UnexpectedEof` if past the end.
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()>;
|
||||
/// True if the source holds no bytes.
|
||||
fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
fn eof() -> io::Error {
|
||||
io::Error::new(io::ErrorKind::UnexpectedEof, "read past end of byte source")
|
||||
}
|
||||
|
||||
impl ByteSource for [u8] {
|
||||
fn len(&self) -> u64 {
|
||||
<[u8]>::len(self) as u64
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
let start = usize::try_from(offset).map_err(|_| eof())?;
|
||||
let end = start.checked_add(buf.len()).ok_or_else(eof)?;
|
||||
buf.copy_from_slice(self.get(start..end).ok_or_else(eof)?);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl ByteSource for Vec<u8> {
|
||||
fn len(&self) -> u64 {
|
||||
self.as_slice().len() as u64
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
self.as_slice().read_at(offset, buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ByteSource + ?Sized> ByteSource for &T {
|
||||
fn len(&self) -> u64 {
|
||||
(**self).len()
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
(**self).read_at(offset, buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ByteSource + ?Sized> ByteSource for std::sync::Arc<T> {
|
||||
fn len(&self) -> u64 {
|
||||
(**self).len()
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
(**self).read_at(offset, buf)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
use super::*;
|
||||
|
||||
fn chunk(id: &[u8; 4], data: &[u8]) -> Vec<u8> {
|
||||
let mut v = id.to_vec();
|
||||
v.extend_from_slice(&(data.len() as u32).to_le_bytes());
|
||||
v.extend_from_slice(data);
|
||||
if data.len() % 2 == 1 {
|
||||
v.push(0);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn list(ltype: &[u8; 4], body: &[u8]) -> Vec<u8> {
|
||||
let mut d = ltype.to_vec();
|
||||
d.extend_from_slice(body);
|
||||
chunk(b"LIST", &d)
|
||||
}
|
||||
|
||||
fn strl(video: bool) -> Vec<u8> {
|
||||
let mut strh = vec![0u8; 56];
|
||||
strh[..4].copy_from_slice(if video { b"vids" } else { b"auds" });
|
||||
strh[4..8].copy_from_slice(if video { b"MJPG" } else { &[0; 4] });
|
||||
let (scale, rate, ss) = if video { (1u32, 25u32, 0u32) } else { (4, 192_000, 4) };
|
||||
strh[20..24].copy_from_slice(&scale.to_le_bytes());
|
||||
strh[24..28].copy_from_slice(&rate.to_le_bytes());
|
||||
strh[44..48].copy_from_slice(&ss.to_le_bytes());
|
||||
let strf = if video {
|
||||
let mut b = vec![0u8; 40];
|
||||
b[..4].copy_from_slice(&40u32.to_le_bytes());
|
||||
b[4..8].copy_from_slice(&64i32.to_le_bytes());
|
||||
b[8..12].copy_from_slice(&48i32.to_le_bytes());
|
||||
b[14..16].copy_from_slice(&24u16.to_le_bytes());
|
||||
b[16..20].copy_from_slice(b"MJPG");
|
||||
b
|
||||
} else {
|
||||
let mut b = vec![0u8; 18];
|
||||
b[..2].copy_from_slice(&1u16.to_le_bytes());
|
||||
b[2..4].copy_from_slice(&2u16.to_le_bytes());
|
||||
b[4..8].copy_from_slice(&48_000u32.to_le_bytes());
|
||||
b[8..12].copy_from_slice(&192_000u32.to_le_bytes());
|
||||
b[12..14].copy_from_slice(&4u16.to_le_bytes());
|
||||
b[14..16].copy_from_slice(&16u16.to_le_bytes());
|
||||
b
|
||||
};
|
||||
let mut body = chunk(b"strh", &strh);
|
||||
body.extend(chunk(b"strf", &strf));
|
||||
body.extend(chunk(b"strn", if video { b"Camera\0" } else { b"Mic\0" }));
|
||||
list(b"strl", &body)
|
||||
}
|
||||
|
||||
/// A small AVI: 3 video frames (the second a delta, the third odd-sized) and 2 audio chunks,
|
||||
/// optionally inside a `rec ` list, with an `idx1` whose offsets are relative to `movi` or absolute.
|
||||
fn avi(idx1: bool, absolute: bool, rec: bool) -> Vec<u8> {
|
||||
let mut avih = vec![0u8; 56];
|
||||
avih[0..4].copy_from_slice(&40_000u32.to_le_bytes());
|
||||
avih[12..16].copy_from_slice(&0x10u32.to_le_bytes());
|
||||
avih[16..20].copy_from_slice(&3u32.to_le_bytes());
|
||||
avih[24..28].copy_from_slice(&2u32.to_le_bytes());
|
||||
avih[32..36].copy_from_slice(&64u32.to_le_bytes());
|
||||
avih[36..40].copy_from_slice(&48u32.to_le_bytes());
|
||||
let mut hdrl = chunk(b"avih", &avih);
|
||||
hdrl.extend(strl(true));
|
||||
hdrl.extend(strl(false));
|
||||
let hdrl = list(b"hdrl", &hdrl);
|
||||
let samples: [(&[u8; 4], Vec<u8>, bool); 5] =
|
||||
[(b"00dc", vec![1; 10], true), (b"01wb", vec![2; 16], true), (b"00dc", vec![3; 6], false), (b"01wb", vec![4; 8], true), (b"00dc", vec![5; 7], true)];
|
||||
let mut body = Vec::new();
|
||||
let mut entries = Vec::new();
|
||||
for (id, data, key) in &samples {
|
||||
entries.push((**id, body.len() as u32 + 4, data.len() as u32, *key));
|
||||
body.extend(chunk(id, data));
|
||||
}
|
||||
let movi_body = if rec {
|
||||
let r = list(b"rec ", &body);
|
||||
// the samples moved 12 bytes in (the rec list's header and type)
|
||||
for e in &mut entries {
|
||||
e.1 += 12;
|
||||
}
|
||||
r
|
||||
} else {
|
||||
body
|
||||
};
|
||||
let movi = list(b"movi", &movi_body);
|
||||
let mut riff = b"AVI ".to_vec();
|
||||
riff.extend(hdrl);
|
||||
// position of the `movi` type id: after `RIFF` <size>, what `riff` holds so far, `LIST` <size>
|
||||
let movi_at = (8 + riff.len() + 8) as u32;
|
||||
riff.extend(movi);
|
||||
if idx1 {
|
||||
let mut ix = Vec::new();
|
||||
for (id, off, len, key) in entries {
|
||||
ix.extend_from_slice(&id);
|
||||
ix.extend_from_slice(&(if key { 0x10u32 } else { 0 }).to_le_bytes());
|
||||
ix.extend_from_slice(&(if absolute { off + movi_at } else { off }).to_le_bytes());
|
||||
ix.extend_from_slice(&len.to_le_bytes());
|
||||
}
|
||||
riff.extend(chunk(b"idx1", &ix));
|
||||
}
|
||||
chunk(b"RIFF", &riff)
|
||||
}
|
||||
|
||||
fn table(f: &AviFile, s: usize) -> Vec<(u32, bool)> {
|
||||
f.streams[s].chunks.iter().map(|c| (c.size, c.key)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headers_and_every_index_path_agree() {
|
||||
for (idx1, absolute, rec, want) in [
|
||||
(true, false, false, IndexSource::Idx1),
|
||||
(true, true, false, IndexSource::Idx1),
|
||||
(true, false, true, IndexSource::Idx1),
|
||||
(false, false, false, IndexSource::Scan),
|
||||
(false, false, true, IndexSource::Scan),
|
||||
] {
|
||||
let file = avi(idx1, absolute, rec);
|
||||
let f = open(&file).unwrap();
|
||||
let what = format!("idx1 {idx1} absolute {absolute} rec {rec}");
|
||||
assert_eq!(f.index, want, "{what}");
|
||||
assert_eq!((f.header.width, f.header.height, f.header.total_frames, f.header.micro_sec_per_frame), (64, 48, 3, 40_000));
|
||||
let (v, a) = (&f.streams[0], &f.streams[1]);
|
||||
assert_eq!((v.kind, v.handler, v.scale, v.rate, v.name.as_deref()), (StreamKind::Video, *b"MJPG", 1, 25, Some("Camera")));
|
||||
assert_eq!(v.video.as_ref().map(|b| (b.width, b.height, b.bit_count, b.compression)), Some((64, 48, 24, *b"MJPG")));
|
||||
let w = a.audio.as_ref().unwrap();
|
||||
assert_eq!((w.format_tag, w.channels, w.sample_rate, w.block_align, w.bits_per_sample, a.sample_size), (1, 2, 48_000, 4, 16, 4));
|
||||
// the index knows the delta frame; a scan cannot
|
||||
let keys = if idx1 { [true, false, true] } else { [true; 3] };
|
||||
assert_eq!(table(&f, 0), vec![(10, keys[0]), (6, keys[1]), (7, keys[2])], "{what}");
|
||||
assert_eq!(f.streams[0].keyframes_known, idx1);
|
||||
assert_eq!(table(&f, 1), vec![(16, true), (8, true)], "{what}");
|
||||
assert_eq!(f.read_chunk(&file, 0, 1).unwrap(), vec![3; 6], "{what}");
|
||||
assert_eq!(f.read_chunk(&file, 0, 2).unwrap(), vec![5; 7], "{what}");
|
||||
assert_eq!(f.read_chunk(&file, 1, 1).unwrap(), vec![4; 8], "{what}");
|
||||
assert!(f.read_chunk(&file, 0, 3).is_err() && f.read_chunk(&file, 5, 0).is_err());
|
||||
}
|
||||
// the idx1 path can be skipped
|
||||
let f = open_with(&avi(true, false, false), OpenOptions { ignore_idx1: true, ..Default::default() }).unwrap();
|
||||
assert_eq!(f.index, IndexSource::Scan);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_avi_and_truncated_recordings() {
|
||||
assert_eq!(open(&b"RIFF\0\0\0\0WAVEfmt ".to_vec()), Err(Error::NotAvi));
|
||||
assert_eq!(open(&Vec::new()), Err(Error::NotAvi));
|
||||
assert!(sniff(&avi(true, false, false)) && !sniff(b"RIFF"));
|
||||
// a recording cut off in its last frame: the headers and the whole frames are there
|
||||
let full = avi(false, false, false);
|
||||
let cut = full[..full.len() - 4].to_vec();
|
||||
let f = open(&cut).unwrap();
|
||||
assert_eq!(f.streams[0].chunks.len(), 3);
|
||||
assert!(f.read_chunk(&cut, 0, 0).is_ok());
|
||||
}
|
||||
|
||||
/// Every single-byte change and every truncation of a valid file opens or fails, never panics, and
|
||||
/// whatever opens reads its chunks without panicking.
|
||||
#[test]
|
||||
fn mutations_never_panic() {
|
||||
for base in [avi(true, false, true), avi(false, false, false), avi(true, true, false)] {
|
||||
for cut in 0..base.len() {
|
||||
let b = base[..cut].to_vec();
|
||||
if let Ok(f) = open(&b) {
|
||||
for (s, st) in f.streams.iter().enumerate() {
|
||||
for i in 0..st.chunks.len() {
|
||||
let _ = f.read_chunk(&b, s, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for at in 0..base.len() {
|
||||
for v in [0x00u8, 0xFF, 0x7F, 0x80] {
|
||||
let mut b = base.clone();
|
||||
b[at] = v;
|
||||
if let Ok(f) = open(&b) {
|
||||
for (s, st) in f.streams.iter().enumerate() {
|
||||
for i in 0..st.chunks.len() {
|
||||
let _ = f.read_chunk(&b, s, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An OpenDML file: each stream's `indx` super index points at an `ix##` standard index (base
|
||||
/// offset + offsets to the data, bit 31 of the size marking delta frames). Built twice: the second
|
||||
/// pass knows where the `ix##` chunks landed.
|
||||
fn avi_odml(ix_at: [u64; 2]) -> (Vec<u8>, [u64; 2]) {
|
||||
let indx = |stream: u8, at: u64| {
|
||||
let mut b = Vec::new();
|
||||
b.extend_from_slice(&4u16.to_le_bytes()); // wLongsPerEntry
|
||||
b.extend_from_slice(&[0, 0]); // subtype, AVI_INDEX_OF_INDEXES
|
||||
b.extend_from_slice(&1u32.to_le_bytes());
|
||||
b.extend_from_slice(&[b'0', b'0' + stream, if stream == 0 { b'd' } else { b'w' }, if stream == 0 { b'c' } else { b'b' }]);
|
||||
b.extend_from_slice(&[0; 12]);
|
||||
b.extend_from_slice(&at.to_le_bytes());
|
||||
b.extend_from_slice(&0u32.to_le_bytes());
|
||||
b.extend_from_slice(&0u32.to_le_bytes());
|
||||
chunk(b"indx", &b)
|
||||
};
|
||||
let mut hdrl = chunk(b"avih", &[0; 56]);
|
||||
for (s, at) in ix_at.iter().enumerate() {
|
||||
let l = strl(s == 0);
|
||||
// strl = LIST <size> strl …: append indx inside it
|
||||
let mut body = l[12..].to_vec();
|
||||
body.extend(indx(s as u8, *at));
|
||||
hdrl.extend(list(b"strl", &body));
|
||||
}
|
||||
let mut riff = b"AVI ".to_vec();
|
||||
riff.extend(list(b"hdrl", &hdrl));
|
||||
// movi: video 10 (key) 6 (delta) 7 (key), audio 16 8; then ix00, ix01
|
||||
let movi_data_at = 8 + riff.len() as u64 + 12; // RIFF hdr, so far, LIST hdr + `movi`
|
||||
let mut body = Vec::new();
|
||||
let mut at: [Vec<(u64, u32, bool)>; 2] = [Vec::new(), Vec::new()];
|
||||
for (s, data, key) in [(0usize, vec![1u8; 10], true), (1, vec![2; 16], true), (0, vec![3; 6], false), (1, vec![4; 8], true), (0, vec![5; 7], true)] {
|
||||
let id = if s == 0 { b"00dc" } else { b"01wb" };
|
||||
at[s].push((movi_data_at + body.len() as u64 + 8, data.len() as u32, key));
|
||||
body.extend(chunk(id, &data));
|
||||
}
|
||||
let mut where_ix = [0u64; 2];
|
||||
for (s, entries) in at.iter().enumerate() {
|
||||
let base = 100u64; // offsets are relative to qwBaseOffset
|
||||
let mut b = Vec::new();
|
||||
b.extend_from_slice(&2u16.to_le_bytes());
|
||||
b.extend_from_slice(&[0, 1]); // subtype 0, AVI_INDEX_OF_CHUNKS
|
||||
b.extend_from_slice(&(entries.len() as u32).to_le_bytes());
|
||||
b.extend_from_slice(if s == 0 { b"00dc" } else { b"01wb" });
|
||||
b.extend_from_slice(&base.to_le_bytes());
|
||||
b.extend_from_slice(&0u32.to_le_bytes());
|
||||
for &(off, size, key) in entries {
|
||||
b.extend_from_slice(&((off - base) as u32).to_le_bytes());
|
||||
b.extend_from_slice(&(size | if key { 0 } else { 0x8000_0000 }).to_le_bytes());
|
||||
}
|
||||
where_ix[s] = movi_data_at + body.len() as u64;
|
||||
body.extend(chunk(if s == 0 { b"ix00" } else { b"ix01" }, &b));
|
||||
}
|
||||
riff.extend(list(b"movi", &body));
|
||||
(chunk(b"RIFF", &riff), where_ix)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opendml_standard_indexes() {
|
||||
let (_, at) = avi_odml([0, 0]);
|
||||
let (file, again) = avi_odml(at);
|
||||
assert_eq!(at, again, "the layout does not depend on the offsets");
|
||||
let f = open(&file).unwrap();
|
||||
assert_eq!(f.index, IndexSource::OpenDml);
|
||||
assert_eq!(table(&f, 0), vec![(10, true), (6, false), (7, true)]);
|
||||
assert_eq!(table(&f, 1), vec![(16, true), (8, true)]);
|
||||
assert_eq!(f.read_chunk(&file, 0, 2).unwrap(), vec![5; 7]);
|
||||
// ignoring the OpenDML index, the scan finds the same chunks (and skips the ix## chunks)
|
||||
let s = open_with(&file, OpenOptions { ignore_odml: true, ..Default::default() }).unwrap();
|
||||
assert_eq!(s.index, IndexSource::Scan);
|
||||
assert_eq!(s.streams[0].chunks.iter().map(|c| c.offset).collect::<Vec<_>>(), f.streams[0].chunks.iter().map(|c| c.offset).collect::<Vec<_>>());
|
||||
// a broken super index (pointing nowhere) falls back to a scan
|
||||
let (bad, _) = avi_odml([3, 5]);
|
||||
assert_eq!(open(&bad).unwrap().index, IndexSource::Scan);
|
||||
// and mutations of the OpenDML file never panic
|
||||
for i in 0..file.len() {
|
||||
let mut b = file.clone();
|
||||
b[i] ^= 0xFF;
|
||||
if let Ok(f) = open(&b) {
|
||||
for (s, st) in f.streams.iter().enumerate() {
|
||||
for k in 0..st.chunks.len() {
|
||||
let _ = f.read_chunk(&b, s, k);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opendml_repeated_index_offsets_are_read_once() {
|
||||
let (_, at) = avi_odml([0, 0]);
|
||||
let (file, _) = avi_odml(at);
|
||||
let mut streams = open(&file).unwrap().streams;
|
||||
// A malicious super index repeats each valid standard-index address. It must not multiply
|
||||
// its chunks, allocations, or traversal work.
|
||||
for (stream, &index) in streams.iter_mut().zip(&at) {
|
||||
stream.super_index = vec![index; 100];
|
||||
}
|
||||
assert!(read_odml(&file, &mut streams));
|
||||
assert_eq!(streams[0].chunks.len(), 3);
|
||||
assert_eq!(streams[1].chunks.len(), 2);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! ffmpeg-made AVI files (skipped without ffmpeg): the chunk tables match ffprobe's packet counts,
|
||||
//! and every index the file has (`idx1`; OpenDML once a file passes 1 GB) and a `movi` scan describe
|
||||
//! the same chunks.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use filmcraft_avi::{IndexSource, OpenOptions, StreamKind, open, open_with};
|
||||
|
||||
fn fixture(name: &str, args: &[&str]) -> Option<PathBuf> {
|
||||
let ffmpeg = filmcraft_testkit::ffmpeg_or_skip("avi oracle")?;
|
||||
let out = filmcraft_testkit::fixtures_dir("avi").join(name);
|
||||
filmcraft_testkit::fixtures::generate(&out, |tmp: &Path| {
|
||||
let mut c = Command::new(&ffmpeg);
|
||||
c.args(["-v", "error", "-y"]).args(args).args(["-f", "avi"]).arg(tmp);
|
||||
c.status().is_ok_and(|s| s.success())
|
||||
})
|
||||
}
|
||||
|
||||
fn packets(path: &Path, stream: usize) -> usize {
|
||||
let ffprobe = filmcraft_testkit::ffprobe().expect("ffprobe next to ffmpeg");
|
||||
let out = Command::new(ffprobe)
|
||||
.args(["-v", "error", "-select_streams", &stream.to_string(), "-count_packets", "-show_entries", "stream=nb_read_packets", "-of", "csv=p=0"])
|
||||
.arg(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&out.stdout).trim().parse().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn packet_bytes(path: &Path, stream: usize) -> u64 {
|
||||
let ffprobe = filmcraft_testkit::ffprobe().expect("ffprobe next to ffmpeg");
|
||||
let out = Command::new(ffprobe)
|
||||
.args(["-v", "error", "-select_streams", &stream.to_string(), "-show_entries", "packet=size", "-of", "csv=p=0"])
|
||||
.arg(path)
|
||||
.output()
|
||||
.unwrap();
|
||||
String::from_utf8_lossy(&out.stdout).lines().filter_map(|l| l.trim().parse::<u64>().ok()).sum()
|
||||
}
|
||||
|
||||
const TONE: [&str; 4] = ["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:d=2"];
|
||||
|
||||
fn cases() -> Vec<(&'static str, Vec<&'static str>, [u8; 4])> {
|
||||
let pic = vec!["-f", "lavfi", "-i", "testsrc2=s=160x120:r=25:d=2"];
|
||||
let with = |v: &[&'static str], a: &[&'static str]| -> Vec<&'static str> {
|
||||
let mut x = pic.clone();
|
||||
x.extend_from_slice(&TONE);
|
||||
x.extend_from_slice(v);
|
||||
x.extend_from_slice(a);
|
||||
x
|
||||
};
|
||||
vec![
|
||||
("mjpeg_pcm.avi", with(&["-c:v", "mjpeg", "-q:v", "4", "-pix_fmt", "yuvj420p"], &["-c:a", "pcm_s16le"]), *b"MJPG"),
|
||||
("h264_mp3.avi", with(&["-c:v", "libx264", "-bf", "2", "-g", "12", "-pix_fmt", "yuv420p"], &["-c:a", "libmp3lame", "-b:a", "128k"]), *b"H264"),
|
||||
("bgr24_pcm.avi", with(&["-c:v", "rawvideo", "-pix_fmt", "bgr24"], &["-c:a", "pcm_s16le"]), [0; 4]),
|
||||
("yuyv_pcm.avi", with(&["-c:v", "rawvideo", "-pix_fmt", "yuyv422"], &["-c:a", "pcm_s16le"]), *b"YUY2"),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ffmpeg_files_match_ffprobe_on_every_index_path() {
|
||||
for (name, args, fourcc) in cases() {
|
||||
let Some(path) = fixture(name, &args) else { return };
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let f = open(&bytes).unwrap();
|
||||
// ffmpeg reserves room for the OpenDML index but writes only idx1 under 1 GB
|
||||
assert_eq!(f.index, IndexSource::Idx1, "{name}");
|
||||
assert_eq!(f.streams.len(), 2, "{name}");
|
||||
let (v, a) = (&f.streams[0], &f.streams[1]);
|
||||
assert_eq!((v.kind, a.kind), (StreamKind::Video, StreamKind::Audio), "{name}");
|
||||
let bmp = v.video.as_ref().unwrap();
|
||||
assert_eq!((bmp.width, bmp.height.abs(), bmp.compression), (160, 120, fourcc), "{name}");
|
||||
assert_eq!((v.rate as f64 / v.scale as f64).round(), 25.0, "{name}");
|
||||
assert_eq!(a.audio.as_ref().map(|w| (w.sample_rate, w.channels)), Some((48_000, 1)), "{name}");
|
||||
// the tables hold what ffprobe reads
|
||||
assert_eq!(v.chunks.len(), packets(&path, 0), "{name}: video");
|
||||
// PCM chunks are packets; ffprobe re-splits MP3 into its frames, which AVI chunks need not
|
||||
// follow, so compressed audio compares by bytes
|
||||
if a.audio.as_ref().is_some_and(|w| w.format_tag == 1) {
|
||||
assert_eq!(a.chunks.len(), packets(&path, 1), "{name}: audio");
|
||||
} else {
|
||||
let ours: u64 = a.chunks.iter().map(|c| u64::from(c.size)).sum();
|
||||
assert_eq!(ours, packet_bytes(&path, 1), "{name}: audio bytes");
|
||||
}
|
||||
assert!(v.chunks[0].key, "{name}: the first frame is a key frame");
|
||||
if fourcc == *b"H264" {
|
||||
assert!(v.chunks.iter().any(|c| !c.key), "{name}: the index marks the delta frames");
|
||||
}
|
||||
// a scan finds the same chunks (it cannot know key frames)
|
||||
let scanned = open_with(&bytes, OpenOptions { ignore_odml: true, ignore_idx1: true }).unwrap();
|
||||
assert_eq!(scanned.index, IndexSource::Scan, "{name}");
|
||||
for s in 0..2 {
|
||||
assert_eq!(sizes(&scanned, s), sizes(&f, s), "{name}: scan stream {s}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sizes(f: &filmcraft_avi::AviFile, s: usize) -> Vec<(u64, u32)> {
|
||||
f.streams[s].chunks.iter().map(|c| (c.offset, c.size)).collect()
|
||||
}
|
||||
|
||||
/// A file past 1 GB, as long recordings are: ffmpeg writes `RIFF AVIX` extensions and the
|
||||
/// OpenDML index. Needs ffmpeg and 1.2 GB of disk: `cargo test -p filmcraft-avi --test
|
||||
/// ffmpeg_oracle -- --ignored`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn opendml_file_past_1_gb() {
|
||||
let args = [
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc2=s=640x480:r=25:d=50",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=440:sample_rate=48000:d=50",
|
||||
"-c:v",
|
||||
"rawvideo",
|
||||
"-pix_fmt",
|
||||
"bgr24",
|
||||
"-c:a",
|
||||
"pcm_s16le",
|
||||
];
|
||||
let Some(path) = fixture("large_opendml.avi", &args) else { return };
|
||||
let file = std::fs::File::open(&path).unwrap();
|
||||
let src = FileSource(std::sync::Mutex::new(file));
|
||||
let f = open(&src).unwrap();
|
||||
assert_eq!(f.index, IndexSource::OpenDml);
|
||||
assert_eq!(f.streams[0].chunks.len(), packets(&path, 0));
|
||||
assert_eq!(f.streams[1].chunks.len(), packets(&path, 1));
|
||||
assert_eq!(f.streams[0].chunks.len(), 1250);
|
||||
assert!(f.streams[0].chunks.last().unwrap().offset > 1 << 30, "the last frames are in an AVIX extension");
|
||||
// idx1 covers the first RIFF only; the AVIX extensions are scanned: the same chunks
|
||||
let idx1 = open_with(&src, OpenOptions { ignore_odml: true, ..Default::default() }).unwrap();
|
||||
assert_eq!(idx1.index, IndexSource::Idx1);
|
||||
for s in 0..2 {
|
||||
assert_eq!(sizes(&idx1, s), sizes(&f, s), "stream {s}");
|
||||
}
|
||||
assert_eq!(f.read_chunk(&src, 0, 1249).unwrap().len(), 640 * 480 * 3);
|
||||
}
|
||||
|
||||
/// A file on disk as a byte source, read where asked.
|
||||
struct FileSource(std::sync::Mutex<std::fs::File>);
|
||||
|
||||
impl filmcraft_avi::ByteSource for FileSource {
|
||||
fn len(&self) -> u64 {
|
||||
self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner).metadata().map(|m| m.len()).unwrap_or(0)
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
if buf.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut file = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
file.seek(SeekFrom::Start(offset))?;
|
||||
file.read_exact(buf)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_source_reads_exact_offsets_and_reports_eof() {
|
||||
use filmcraft_avi::ByteSource as _;
|
||||
use std::io::Write as _;
|
||||
|
||||
let path = filmcraft_testkit::fixtures::temp_path(&filmcraft_testkit::fixtures_dir("avi").join("read-at-test.bin"));
|
||||
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path).unwrap();
|
||||
file.write_all(b"0123456789abcdef").unwrap();
|
||||
drop(file);
|
||||
let source = FileSource(std::sync::Mutex::new(std::fs::File::open(&path).unwrap()));
|
||||
assert_eq!(source.len(), 16);
|
||||
let mut bytes = [0; 4];
|
||||
source.read_at(7, &mut bytes).unwrap();
|
||||
assert_eq!(&bytes, b"789a");
|
||||
source.read_at(1, &mut bytes).unwrap();
|
||||
assert_eq!(&bytes, b"1234");
|
||||
assert_eq!(source.read_at(15, &mut bytes).unwrap_err().kind(), std::io::ErrorKind::UnexpectedEof);
|
||||
assert!(source.read_at(u64::MAX, &mut bytes).is_err());
|
||||
source.read_at(u64::MAX, &mut []).unwrap();
|
||||
std::thread::scope(|scope| {
|
||||
for (offset, expected) in [(0, b"0123"), (8, b"89ab")] {
|
||||
let source = &source;
|
||||
scope.spawn(move || {
|
||||
for _ in 0..64 {
|
||||
let mut bytes = [0; 4];
|
||||
source.read_at(offset, &mut bytes).unwrap();
|
||||
assert_eq!(&bytes, expected);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
drop(source);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
@@ -9,6 +9,4 @@ rust-version.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
craft-media = { workspace = true, features = ["bitstream"] }
|
||||
|
||||
+2
-430
@@ -1,433 +1,5 @@
|
||||
//! Bit-level reading and writing shared by FilmCraft's codec crates.
|
||||
//!
|
||||
//! - [`BitReader`]: MSB-first reader with Exp-Golomb (`ue(v)`, `se(v)`), as used by H.264/HEVC/AV1 headers.
|
||||
//! - [`BitWriter`]: the matching writer (for encoders and header rewriting).
|
||||
//! - [`unescape_rbsp`] / [`escape_rbsp`]: remove/insert H.264/HEVC emulation-prevention bytes.
|
||||
//! - [`annexb_nals`] / [`length_prefixed_nals`]: split Annex-B byte streams and AVCC/HVCC samples into NAL units.
|
||||
//! Bit-level reading and writing: a re-export of [`craft_media::bitstream`] from craft-libs.
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BitError {
|
||||
/// Tried to read past the end of the buffer.
|
||||
Eof,
|
||||
/// An Exp-Golomb code longer than 32 leading zeros.
|
||||
InvalidExpGolomb,
|
||||
}
|
||||
|
||||
impl fmt::Display for BitError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
BitError::Eof => f.write_str("unexpected end of bitstream"),
|
||||
BitError::InvalidExpGolomb => f.write_str("invalid Exp-Golomb code"),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::error::Error for BitError {}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, BitError>;
|
||||
|
||||
/// MSB-first bit reader over a byte slice.
|
||||
#[derive(Clone)]
|
||||
pub struct BitReader<'a> {
|
||||
data: &'a [u8],
|
||||
/// Position in bits.
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> BitReader<'a> {
|
||||
pub fn new(data: &'a [u8]) -> Self {
|
||||
Self { data, pos: 0 }
|
||||
}
|
||||
pub fn data(&self) -> &'a [u8] {
|
||||
self.data
|
||||
}
|
||||
/// Bit position from the start.
|
||||
pub fn position(&self) -> usize {
|
||||
self.pos
|
||||
}
|
||||
pub fn bits_left(&self) -> usize {
|
||||
(self.data.len() * 8).saturating_sub(self.pos)
|
||||
}
|
||||
pub fn is_byte_aligned(&self) -> bool {
|
||||
self.pos.is_multiple_of(8)
|
||||
}
|
||||
pub fn byte_align(&mut self) {
|
||||
self.pos = self.pos.div_ceil(8) * 8;
|
||||
}
|
||||
/// Byte offset of the current position (rounded down).
|
||||
pub fn byte_pos(&self) -> usize {
|
||||
self.pos / 8
|
||||
}
|
||||
pub fn seek_bits(&mut self, pos: usize) {
|
||||
self.pos = pos;
|
||||
}
|
||||
|
||||
/// Peek up to 32 bits without consuming (zero-padded past the end).
|
||||
#[inline]
|
||||
pub fn peek(&self, n: u32) -> u32 {
|
||||
debug_assert!(n <= 32);
|
||||
if n == 0 {
|
||||
return 0;
|
||||
}
|
||||
let byte = self.pos / 8;
|
||||
let shift = self.pos % 8;
|
||||
let mut v: u64 = 0;
|
||||
for i in 0..5 {
|
||||
v = (v << 8) | *self.data.get(byte + i).unwrap_or(&0) as u64;
|
||||
}
|
||||
// v holds 40 bits starting at `byte`
|
||||
((v << (24 + shift)) >> (64 - n)) as u32
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn read_bits(&mut self, n: u32) -> Result<u32> {
|
||||
if n as usize > self.bits_left() {
|
||||
return Err(BitError::Eof);
|
||||
}
|
||||
let v = self.peek(n);
|
||||
self.pos += n as usize;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
pub fn read_bits_u64(&mut self, n: u32) -> Result<u64> {
|
||||
if n <= 32 {
|
||||
return self.read_bits(n).map(u64::from);
|
||||
}
|
||||
let hi = self.read_bits(n - 32)? as u64;
|
||||
let lo = self.read_bits(32)? as u64;
|
||||
Ok((hi << 32) | lo)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn read_bit(&mut self) -> Result<bool> {
|
||||
self.read_bits(1).map(|b| b == 1)
|
||||
}
|
||||
pub fn read_flag(&mut self) -> Result<bool> {
|
||||
self.read_bit()
|
||||
}
|
||||
|
||||
pub fn skip(&mut self, n: usize) -> Result<()> {
|
||||
if n > self.bits_left() {
|
||||
return Err(BitError::Eof);
|
||||
}
|
||||
self.pos += n;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unsigned Exp-Golomb `ue(v)`.
|
||||
pub fn read_ue(&mut self) -> Result<u32> {
|
||||
let mut zeros = 0u32;
|
||||
while !self.read_bit()? {
|
||||
zeros += 1;
|
||||
if zeros > 32 {
|
||||
return Err(BitError::InvalidExpGolomb);
|
||||
}
|
||||
}
|
||||
if zeros == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
let v = self.read_bits_u64(zeros)?;
|
||||
let r = (1u64 << zeros) - 1 + v;
|
||||
u32::try_from(r).map_err(|_| BitError::InvalidExpGolomb)
|
||||
}
|
||||
|
||||
/// Signed Exp-Golomb `se(v)`.
|
||||
pub fn read_se(&mut self) -> Result<i32> {
|
||||
let k = self.read_ue()? as i64;
|
||||
Ok(if k & 1 == 1 { ((k + 1) / 2) as i32 } else { -((k / 2) as i32) })
|
||||
}
|
||||
|
||||
/// Truncated Exp-Golomb `te(v)` with range `max`.
|
||||
pub fn read_te(&mut self, max: u32) -> Result<u32> {
|
||||
if max > 1 { self.read_ue() } else { Ok(!self.read_bit()? as u32) }
|
||||
}
|
||||
|
||||
/// H.264/HEVC `more_rbsp_data()`: true if there is data before the rbsp trailing bits.
|
||||
pub fn more_rbsp_data(&self) -> bool {
|
||||
let total = self.data.len() * 8;
|
||||
if self.pos >= total {
|
||||
return false;
|
||||
}
|
||||
// find last set bit (the rbsp_stop_one_bit)
|
||||
let mut last = None;
|
||||
for (i, &b) in self.data.iter().enumerate().rev() {
|
||||
if b != 0 {
|
||||
last = Some(i * 8 + 7 - b.trailing_zeros() as usize);
|
||||
break;
|
||||
}
|
||||
}
|
||||
match last {
|
||||
Some(stop) => self.pos < stop,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MSB-first bit writer.
|
||||
#[derive(Default, Clone)]
|
||||
pub struct BitWriter {
|
||||
buf: Vec<u8>,
|
||||
acc: u64,
|
||||
nbits: u32,
|
||||
}
|
||||
|
||||
impl BitWriter {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
pub fn write_bits(&mut self, value: u32, n: u32) {
|
||||
debug_assert!(n <= 32);
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
let v = (value as u64) & ((1u64 << n) - 1);
|
||||
self.acc = (self.acc << n) | v;
|
||||
self.nbits += n;
|
||||
while self.nbits >= 8 {
|
||||
self.nbits -= 8;
|
||||
self.buf.push((self.acc >> self.nbits) as u8);
|
||||
}
|
||||
self.acc &= (1u64 << self.nbits) - 1;
|
||||
}
|
||||
pub fn write_bit(&mut self, b: bool) {
|
||||
self.write_bits(b as u32, 1);
|
||||
}
|
||||
pub fn write_ue(&mut self, v: u32) {
|
||||
let x = v as u64 + 1;
|
||||
let len = 64 - x.leading_zeros();
|
||||
let zeros = len - 1;
|
||||
for _ in 0..zeros {
|
||||
self.write_bit(false);
|
||||
}
|
||||
if len > 32 {
|
||||
self.write_bits((x >> 32) as u32, len - 32);
|
||||
self.write_bits(x as u32, 32);
|
||||
} else {
|
||||
self.write_bits(x as u32, len);
|
||||
}
|
||||
}
|
||||
pub fn write_se(&mut self, v: i32) {
|
||||
let k = if v > 0 { (v as u32) * 2 - 1 } else { (-(v as i64) as u32) * 2 };
|
||||
self.write_ue(k);
|
||||
}
|
||||
pub fn bit_len(&self) -> usize {
|
||||
self.buf.len() * 8 + self.nbits as usize
|
||||
}
|
||||
pub fn is_byte_aligned(&self) -> bool {
|
||||
self.nbits == 0
|
||||
}
|
||||
/// Pad with zero bits to a byte boundary.
|
||||
pub fn align_zero(&mut self) {
|
||||
if self.nbits > 0 {
|
||||
self.write_bits(0, 8 - self.nbits);
|
||||
}
|
||||
}
|
||||
/// `rbsp_trailing_bits()`: a one bit then zeros to alignment.
|
||||
pub fn rbsp_trailing(&mut self) {
|
||||
self.write_bit(true);
|
||||
self.align_zero();
|
||||
}
|
||||
pub fn write_bytes(&mut self, bytes: &[u8]) {
|
||||
if self.nbits == 0 {
|
||||
self.buf.extend_from_slice(bytes);
|
||||
} else {
|
||||
for &b in bytes {
|
||||
self.write_bits(b as u32, 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn finish(mut self) -> Vec<u8> {
|
||||
self.align_zero();
|
||||
self.buf
|
||||
}
|
||||
pub fn bytes(&self) -> &[u8] {
|
||||
&self.buf
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove emulation-prevention bytes (`00 00 03` → `00 00`) from a NAL payload.
|
||||
pub fn unescape_rbsp(data: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(data.len());
|
||||
let mut zeros = 0;
|
||||
for &b in data {
|
||||
if zeros >= 2 && b == 3 {
|
||||
zeros = 0;
|
||||
continue;
|
||||
}
|
||||
out.push(b);
|
||||
zeros = if b == 0 { zeros + 1 } else { 0 };
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Insert emulation-prevention bytes so no `00 00 0x` (x ≤ 3) sequence appears.
|
||||
pub fn escape_rbsp(data: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(data.len() + data.len() / 64);
|
||||
let mut zeros = 0;
|
||||
for &b in data {
|
||||
if zeros >= 2 && b <= 3 {
|
||||
out.push(3);
|
||||
zeros = 0;
|
||||
}
|
||||
out.push(b);
|
||||
zeros = if b == 0 { zeros + 1 } else { 0 };
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Split an Annex-B byte stream (start codes `00 00 01` / `00 00 00 01`) into NAL units (escaped payloads).
|
||||
pub fn annexb_nals(data: &[u8]) -> Vec<&[u8]> {
|
||||
let mut starts = Vec::new();
|
||||
let mut i = 0;
|
||||
while i + 3 <= data.len() {
|
||||
if data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1 {
|
||||
starts.push(i + 3);
|
||||
i += 3;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
let mut out = Vec::with_capacity(starts.len());
|
||||
for (k, &s) in starts.iter().enumerate() {
|
||||
let mut e = if k + 1 < starts.len() { starts[k + 1] - 3 } else { data.len() };
|
||||
// trim trailing zero bytes (part of a 4-byte start code or trailing_zero_8bits)
|
||||
while e > s && data[e - 1] == 0 {
|
||||
e -= 1;
|
||||
}
|
||||
if e > s {
|
||||
out.push(&data[s..e]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Split a length-prefixed sample (AVCC/HVCC, `length_size` 1/2/4 bytes) into NAL units.
|
||||
pub fn length_prefixed_nals(data: &[u8], length_size: usize) -> Result<Vec<&[u8]>> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < data.len() {
|
||||
if i + length_size > data.len() {
|
||||
return Err(BitError::Eof);
|
||||
}
|
||||
let mut len = 0usize;
|
||||
for k in 0..length_size {
|
||||
len = (len << 8) | data[i + k] as usize;
|
||||
}
|
||||
i += length_size;
|
||||
if i + len > data.len() {
|
||||
return Err(BitError::Eof);
|
||||
}
|
||||
out.push(&data[i..i + len]);
|
||||
i += len;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// CRC-32 (IEEE, reflected) — used by containers (e.g. Matroska CRC-32 elements, PNG).
|
||||
pub fn crc32(data: &[u8]) -> u32 {
|
||||
let mut crc = 0xFFFF_FFFFu32;
|
||||
for &b in data {
|
||||
crc ^= b as u32;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
|
||||
}
|
||||
}
|
||||
!crc
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
#[test]
|
||||
fn reads_bits() {
|
||||
let d = [0b1010_1100, 0b0101_0011];
|
||||
let mut r = BitReader::new(&d);
|
||||
assert_eq!(r.read_bits(1).unwrap(), 1);
|
||||
assert_eq!(r.read_bits(3).unwrap(), 0b010);
|
||||
assert_eq!(r.read_bits(8).unwrap(), 0b1100_0101);
|
||||
assert_eq!(r.bits_left(), 4);
|
||||
assert_eq!(r.read_bits(5), Err(BitError::Eof));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exp_golomb_known() {
|
||||
// 1 | 010 | 011 | 00100 | 00101 -> 0,1,2,3,4
|
||||
let mut w = BitWriter::new();
|
||||
for v in 0..5 {
|
||||
w.write_ue(v);
|
||||
}
|
||||
let b = w.finish();
|
||||
let mut r = BitReader::new(&b);
|
||||
for v in 0..5 {
|
||||
assert_eq!(r.read_ue().unwrap(), v);
|
||||
}
|
||||
let mut w = BitWriter::new();
|
||||
for v in [0, 1, -1, 2, -2, 1000, -1000] {
|
||||
w.write_se(v);
|
||||
}
|
||||
let b = w.finish();
|
||||
let mut r = BitReader::new(&b);
|
||||
for v in [0, 1, -1, 2, -2, 1000, -1000] {
|
||||
assert_eq!(r.read_se().unwrap(), v);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emulation_prevention() {
|
||||
let raw = [0, 0, 1, 0, 0, 0, 5, 0, 0, 3];
|
||||
let esc = escape_rbsp(&raw);
|
||||
assert_eq!(esc, vec![0, 0, 3, 1, 0, 0, 3, 0, 5, 0, 0, 3, 3]);
|
||||
assert_eq!(unescape_rbsp(&esc), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annexb_split() {
|
||||
let s = [0, 0, 0, 1, 0x67, 1, 2, 0, 0, 1, 0x68, 3, 0, 0, 0, 1, 0x65, 9, 9];
|
||||
let n = annexb_nals(&s);
|
||||
assert_eq!(n, vec![&[0x67, 1, 2][..], &[0x68, 3][..], &[0x65, 9, 9][..]]);
|
||||
let l = [0, 0, 0, 2, 0x67, 1, 0, 0, 0, 1, 0x68];
|
||||
assert_eq!(length_prefixed_nals(&l, 4).unwrap(), vec![&[0x67, 1][..], &[0x68][..]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_rbsp() {
|
||||
let d = [0b1100_0000];
|
||||
let mut r = BitReader::new(&d);
|
||||
assert!(r.more_rbsp_data());
|
||||
r.read_bit().unwrap();
|
||||
assert!(!r.more_rbsp_data());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc() {
|
||||
assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn roundtrip(vals in proptest::collection::vec((0u32..u32::MAX, 1u32..=32), 0..200), ues in proptest::collection::vec(0u32..u32::MAX - 1, 0..50)) {
|
||||
let mut w = BitWriter::new();
|
||||
for &(v, n) in &vals { w.write_bits(v, n); }
|
||||
for &u in &ues { w.write_ue(u); }
|
||||
let b = w.finish();
|
||||
let mut r = BitReader::new(&b);
|
||||
for &(v, n) in &vals {
|
||||
let mask = if n == 32 { u32::MAX } else { (1 << n) - 1 };
|
||||
prop_assert_eq!(r.read_bits(n).unwrap(), v & mask);
|
||||
}
|
||||
for &u in &ues { prop_assert_eq!(r.read_ue().unwrap(), u); }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_roundtrip(data in proptest::collection::vec(0u8..4, 0..300)) {
|
||||
let e = escape_rbsp(&data);
|
||||
prop_assert_eq!(unescape_rbsp(&e), data);
|
||||
for w in e.windows(3) { prop_assert!(!(w[0] == 0 && w[1] == 0 && w[2] < 3)); }
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use craft_media::bitstream::*;
|
||||
|
||||
@@ -16,13 +16,42 @@ and writes caption files, converts them to and from caption tracks, and draws ca
|
||||
| Scenarist SCC `.scc` | yes | yes | CEA-608 at 29.97 fps, drop-frame or not. Reads pop-on, roll-up and paint-on (channel 1); writes pop-on. |
|
||||
| MacCaption MCC `.mcc` | yes | yes | One SMPTE ST 334-2 caption distribution packet (CDP) per frame in MacCaption's hex text with letter abbreviations. Writes 29.97 fps (`30DF` / `30`) with CEA-608 field 1 (the SCC schedule) **and** CEA-708 service 1 (two-window pop-on emulation). Reads CEA-608 field 1 when present, else CEA-708 service 1; time code rates 30DF, 30, 60DF, 60, 25, 24, 50. |
|
||||
| EBU STL `.stl` | yes | yes | EBU Tech 3264-E: GSI block + 128-byte TTI blocks, `STL25.01` (25 fps) or `STL30.01` (read/written as 29.97 fps, non-drop labels), ISO/IEC 6937 Latin text (accents as diacritic + letter), italics / underline codes as `<i>` / `<u>`, extension blocks for long subtitles, start-of-programme (TCP) subtracted. |
|
||||
| TTML `.ttml` (IMSC1) | yes | yes | Writes the IMSC1 Text profile (TTML2 namespaces): `<p>` per cue, `<br/>`, styled `<span>`s for `<i>` / `<b>` / `<u>`, `ttm:agent` speakers, exact frame times (`123f`) at the sequence rate. Reads any TTML (TTML1, TTML2, the 2006 `ttaf1` drafts): clock / frame / offset / tick times, `dur`, nested time containers, timed spans, referenced styles. |
|
||||
| TTML `.ttml` (IMSC1) | yes | yes | Writes the IMSC1 Text profile (TTML2 namespaces): `<p>` per cue, `<br/>`, styled `<span>`s for `<i>` / `<b>` / `<u>`, `ttm:agent` speakers, exact frame times (`123f`) at the sequence rate. Reads any TTML (TTML1, TTML2, the 2006 `ttaf1` drafts): clock / frame / offset / tick times, `dur`, nested time containers (`body` / `div` begins offset and ends clip their contents; `seq` containers are read as `par` with a warning), timed spans, referenced styles. |
|
||||
| DFXP `.dfxp` | yes | yes | TTML1 with the DFXP presentation profile; clock times in milliseconds (frame-exact after `snap_to_frames`). |
|
||||
|
||||
All cue times are exact `Tick`s. Milliseconds convert exactly (1 ms = 254 016 000 ticks); SCC
|
||||
frames map through `FrameRate::FPS_29_97`. `Document::snap_to_frames` snaps cues to a sequence's
|
||||
frame grid (the engine does this on import) and removes overlaps.
|
||||
|
||||
### TTML tick timing
|
||||
|
||||
When `ttp:tickRate` is omitted and `ttp:frameRate` is present, tick offsets use the effective
|
||||
frame rate (`frameRate` times `frameRateMultiplier`) times `subFrameRate`. The rate stays
|
||||
rational, so a 30-tick offset at 30 times 1000/1001 fps is exactly 1.001 seconds. An explicit
|
||||
`tickRate` takes precedence; without `frameRate`, the default is one tick per second.
|
||||
This follows [W3C TTML1, third edition (2018), §6.2.10](https://www.w3.org/TR/2018/REC-ttml1-20181108/#parameter-attribute-tickRate).
|
||||
|
||||
### TTML speaker metadata
|
||||
|
||||
TTML and DFXP exports define each distinct speaker label once in the document head and refer to
|
||||
it with a stable, unique `ttm:agent` ID. Names are XML-escaped full-name text with preserved
|
||||
whitespace; punctuation, Unicode and literal underscores survive a round trip. Agent type is
|
||||
`other` because a caption's speaker label does not say whether it denotes a person or a group.
|
||||
|
||||
On import, agent definitions directly under `head` or `head/metadata` resolve paragraph and timed
|
||||
span speakers. Speaker metadata uses the standard TTML namespace or the April and October 2006
|
||||
TTaf1 draft namespaces. Agent IDs use `xml:id`, except in the April draft, which uses unqualified
|
||||
`id`. Agent definitions from unrelated application metadata are ignored. Recognized `ttm:agent`
|
||||
attributes take precedence over the legacy local-name `agent` fallback. A full name is preferred,
|
||||
otherwise the first nonempty name (or an explicitly empty name) is used. Multiple agent references become one display label, joined with `, ` in
|
||||
reference order; the project does not retain the individual agent identities. Older FilmCraft
|
||||
files without agent definitions remain readable through the previous underscore-to-space
|
||||
fallback. Other agent metadata, including actor relationships, is not retained.
|
||||
|
||||
This mapping follows [W3C TTML1, third edition (2018), §§12.1.5–12.2.1](https://www.w3.org/TR/2018/REC-ttml1-20181108/#metadata-vocabulary-agent).
|
||||
The [April 2006 TTaf1 draft, §§7.2.1 and 12.1.5–12.2.1](https://www.w3.org/TR/2006/WD-ttaf1-dfxp-20060427/)
|
||||
defines its earlier metadata namespace and agent ID form.
|
||||
|
||||
### SCC writer
|
||||
|
||||
Each caption is sent as a pop-on load (RCL, ENM, then per row a preamble address code and tab
|
||||
@@ -56,7 +85,7 @@ implementation's code was consulted.
|
||||
|
||||
- Unit tests per module (timestamps, decoding, sloppy SRT, WebVTT blocks and voices, CEA-608
|
||||
tables, PAC round trips, known SCC pop-on and roll-up streams, layout).
|
||||
- `tests/roundtrip.rs`: property tests — SRT (also with BOM + CRLF), WebVTT (ids, speakers,
|
||||
- `tests/it/roundtrip.rs`: property tests — SRT (also with BOM + CRLF), WebVTT (ids, speakers,
|
||||
settings, STYLE blocks), SCC and MCC (drop-frame and non-drop, gaps and back-to-back captions;
|
||||
MCC also through CEA-708 alone), EBU STL (25 and 29.97 fps, accents, extension blocks), TTML
|
||||
(23.976 / 25 / 29.97 / 59.94, ids, speakers) round-trip frame-exactly; DFXP round-trips after
|
||||
|
||||
@@ -286,6 +286,29 @@ mod tests {
|
||||
assert!(o.x < 120, "{}", o.x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn margin_is_a_fraction_of_frame_height() {
|
||||
// #1542: on a portrait sequence, a bottom-anchored caption with margin 0.31 sits with
|
||||
// its bottom edge 0.31 of the frame height above the bottom; the top anchor mirrors it.
|
||||
let st = CaptionStyle { anchor: CaptionAnchor::Bottom, margin: 0.31, size: 78.0, ..Default::default() };
|
||||
let o = render_caption(&cap("Hello world"), &st, 1080, 1920).unwrap();
|
||||
// the overlay pads the content by the outline grow (the default outline is 0, so by 1 px)
|
||||
let bottom = (o.y as usize + o.h) as f32;
|
||||
let expected = 1920.0 - 0.31 * 1920.0;
|
||||
assert!((bottom - expected).abs() <= 2.0, "bottom edge {bottom}, expected {expected}");
|
||||
let st = CaptionStyle { anchor: CaptionAnchor::Top, margin: 0.31, size: 78.0, ..Default::default() };
|
||||
let o = render_caption(&cap("Hello world"), &st, 1080, 1920).unwrap();
|
||||
assert!((o.y as f32 - 0.31 * 1920.0).abs() <= 2.0, "top edge {}, expected {}", o.y, 0.31 * 1920.0);
|
||||
// margin 0 and the 0.45 clamp stay inside the frame
|
||||
for margin in [0.0, 0.31, 0.45, 9.0] {
|
||||
let st = CaptionStyle { anchor: CaptionAnchor::Bottom, margin, size: 78.0, ..Default::default() };
|
||||
let o = render_caption(&cap("Hello world"), &st, 1080, 1920).unwrap();
|
||||
// the outline-grow padding plus ceil rounding may reach 2 px past the edge; the
|
||||
// composite clips it
|
||||
assert!(o.y >= 0 && o.y as usize + o.h <= 1922, "margin {margin}: y {} h {}", o.y, o.h);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wraps_and_composites() {
|
||||
let long = "word ".repeat(60);
|
||||
|
||||
@@ -34,7 +34,7 @@ pub fn parse_timecode(s: &str) -> Option<i64> {
|
||||
let df = s.contains([';', '.', ',']);
|
||||
let parts: Vec<&str> = s.split([':', ';', '.', ',']).collect();
|
||||
let [h, m, sec, f] = parts.as_slice() else { return None };
|
||||
let n = |p: &str| if (1..=3).contains(&p.len()) { p.parse::<i64>().ok() } else { None };
|
||||
let n = |p: &str| if (1..=3).contains(&p.len()) && p.bytes().all(|b| b.is_ascii_digit()) { p.parse::<i64>().ok() } else { None };
|
||||
Some(fields_to_frames(n(h)?, n(m)?, n(sec)?, n(f)?, RATE, df))
|
||||
}
|
||||
|
||||
@@ -484,6 +484,14 @@ mod tests {
|
||||
Cue { start: RATE.tick_of(s), end: RATE.tick_of(e), text: text.into(), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timecode_rejects_signed_fields() {
|
||||
assert_eq!(parse_timecode("00:00:-5:00"), None);
|
||||
assert_eq!(parse_timecode("00:+1:00:00"), None);
|
||||
assert_eq!(parse_timecode("00:00:00:"), None);
|
||||
assert_eq!(parse_timecode("00:00:01:00"), Some(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timecodes() {
|
||||
assert_eq!(parse_timecode("00:01:00;02"), Some(1800));
|
||||
|
||||
+228
-49
@@ -8,12 +8,14 @@
|
||||
//! offsets (`123f`) at `ttp:frameRate` / `ttp:frameRateMultiplier`, which are exact; without
|
||||
//! one, and always for DFXP (for older players), times are clock times with milliseconds.
|
||||
//!
|
||||
//! **Reading** is namespace-agnostic (TTML1 2010, the 2006 `ttaf1` drafts and TTML2 all read):
|
||||
//! **Reading** accepts TTML1 2010, the 2006 `ttaf1` drafts and TTML2:
|
||||
//! clock times (`HH:MM:SS`, `.fraction`, `:FF` frames and `.subframes`), offset times (`h`, `m`,
|
||||
//! `s`, `ms`, `f`, `t` with `ttp:tickRate`), `begin` / `end` / `dur`, nested time containers
|
||||
//! (`body` / `div` begins add up), timed `<span>`s inside an untimed `<p>`, `<br/>`, whitespace
|
||||
//! `s`, `ms`, `f`, `t` with `ttp:tickRate`), `begin` / `end` / `dur`, nested `par` time
|
||||
//! containers (`body` / `div` begins add up and their ends clip what is inside), timed `<span>`s inside an untimed `<p>`, `<br/>`, whitespace
|
||||
//! collapsing, styles referenced by id (italic / bold / underline) and `ttm:agent` / `xml:id`.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
|
||||
use crate::{Cue, Document, Error, Result, TICKS_PER_MS};
|
||||
@@ -31,6 +33,9 @@ const NS_TT: &str = "http://www.w3.org/ns/ttml";
|
||||
const NS_TTP: &str = "http://www.w3.org/ns/ttml#parameter";
|
||||
const NS_TTS: &str = "http://www.w3.org/ns/ttml#styling";
|
||||
const NS_TTM: &str = "http://www.w3.org/ns/ttml#metadata";
|
||||
const NS_TTM_DRAFT: &str = "http://www.w3.org/2006/10/ttaf1#metadata";
|
||||
const NS_TTM_DRAFT_APRIL: &str = "http://www.w3.org/2006/04/ttaf1#metadata";
|
||||
const NS_XML: &str = "http://www.w3.org/XML/1998/namespace";
|
||||
const IMSC1_TEXT: &str = "http://www.w3.org/ns/ttml/profile/imsc1/text";
|
||||
const DFXP_PROFILE: &str = "http://www.w3.org/ns/ttml/profile/dfxp-presentation";
|
||||
|
||||
@@ -122,6 +127,10 @@ fn is_ncname(s: &str) -> bool {
|
||||
c.next().is_some_and(|f| f.is_alphabetic() || f == '_') && c.all(|x| x.is_alphanumeric() || matches!(x, '_' | '-' | '.'))
|
||||
}
|
||||
|
||||
fn cue_id(cue: &Cue, index: usize) -> String {
|
||||
cue.id.as_deref().filter(|x| is_ncname(x)).map(str::to_string).unwrap_or_else(|| format!("c{}", index + 1))
|
||||
}
|
||||
|
||||
/// Write `doc` as TTML. `rate` (IMSC1 only) makes times exact frame offsets; `lang` is the
|
||||
/// document language (`xml:lang`).
|
||||
pub fn write(doc: &Document, flavor: Flavor, rate: Option<FrameRate>, lang: &str) -> String {
|
||||
@@ -140,13 +149,38 @@ pub fn write(doc: &Document, flavor: Flavor, rate: Option<FrameRate>, lang: &str
|
||||
}
|
||||
}
|
||||
o.push_str(&format!(" xml:lang=\"{}\">\n", esc(if lang.is_empty() { "en" } else { lang })));
|
||||
// Keep agent identifiers distinct from the IDs already emitted for cues, style and region.
|
||||
let mut ids: HashSet<String> = ["s1".into(), "r1".into()].into_iter().collect();
|
||||
ids.extend(doc.cues.iter().enumerate().map(|(i, c)| cue_id(c, i)));
|
||||
let mut speakers = Vec::new();
|
||||
let mut speaker_ids = HashMap::new();
|
||||
for speaker in doc.cues.iter().filter_map(|c| c.speaker.as_deref()) {
|
||||
if speaker_ids.contains_key(speaker) {
|
||||
continue;
|
||||
}
|
||||
let mut id = format!("agent{}", speakers.len() + 1);
|
||||
// Each collision consumes a distinct reserved ID, so this is bounded by ids.len().
|
||||
while ids.contains(&id) {
|
||||
id.push('_');
|
||||
}
|
||||
ids.insert(id.clone());
|
||||
speaker_ids.insert(speaker, id.clone());
|
||||
speakers.push((speaker, id));
|
||||
}
|
||||
o.push_str(" <head>\n");
|
||||
for (speaker, id) in speakers {
|
||||
o.push_str(&format!(
|
||||
" <ttm:agent xml:id=\"{id}\" type=\"other\"><ttm:name type=\"full\" xml:space=\"preserve\">{}</ttm:name></ttm:agent>\n",
|
||||
// XML 1.0 §2.11 normalizes literal CR even with xml:space="preserve".
|
||||
esc(speaker).replace('\r', " ")
|
||||
));
|
||||
}
|
||||
o.push_str(" <styling>\n <style xml:id=\"s1\" tts:color=\"white\" tts:fontFamily=\"proportionalSansSerif\" tts:fontSize=\"100%\" tts:textAlign=\"center\"/>\n </styling>\n");
|
||||
o.push_str(" <layout>\n <region xml:id=\"r1\" tts:origin=\"10% 10%\" tts:extent=\"80% 80%\" tts:displayAlign=\"after\"/>\n </layout>\n");
|
||||
o.push_str(" </head>\n <body region=\"r1\" style=\"s1\">\n <div>\n");
|
||||
for (i, c) in doc.cues.iter().enumerate() {
|
||||
let id = c.id.as_deref().filter(|x| is_ncname(x)).map(str::to_string).unwrap_or_else(|| format!("c{}", i + 1));
|
||||
let agent = c.speaker.as_deref().map(|s| format!(" ttm:role=\"x-speaker\" ttm:agent=\"{}\"", esc(&agent_id(s)))).unwrap_or_default();
|
||||
let id = cue_id(c, i);
|
||||
let agent = c.speaker.as_deref().and_then(|s| speaker_ids.get(s)).map(|id| format!(" ttm:role=\"x-speaker\" ttm:agent=\"{id}\"")).unwrap_or_default();
|
||||
o.push_str(&format!(
|
||||
" <p xml:id=\"{}\" begin=\"{}\" end=\"{}\"{agent}>{}</p>\n",
|
||||
esc(&id),
|
||||
@@ -159,12 +193,6 @@ pub fn write(doc: &Document, flavor: Flavor, rate: Option<FrameRate>, lang: &str
|
||||
o
|
||||
}
|
||||
|
||||
/// `ttm:agent` refers to an agent id; we write the speaker name itself (made an NCName).
|
||||
fn agent_id(s: &str) -> String {
|
||||
let v: String = s.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '.' { c } else { '_' }).collect();
|
||||
if v.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_') { v } else { format!("_{v}") }
|
||||
}
|
||||
|
||||
fn gcd(a: i64, b: i64) -> i64 {
|
||||
if b == 0 { a.abs().max(1) } else { gcd(b, a % b) }
|
||||
}
|
||||
@@ -181,7 +209,8 @@ struct Timing {
|
||||
/// Nominal frames per second for `HH:MM:SS:FF`.
|
||||
base: i64,
|
||||
sub_frame_rate: i64,
|
||||
tick_rate: i64,
|
||||
/// Ticks per second, rational when derived from the effective frame rate.
|
||||
tick_rate: FrameRate,
|
||||
}
|
||||
|
||||
fn positive_integer(s: &str) -> Option<i64> {
|
||||
@@ -203,6 +232,56 @@ fn attr<'a>(n: roxmltree::Node<'a, '_>, local: &str) -> Option<&'a str> {
|
||||
n.attributes().find(|a| a.name() == local).map(|a| a.value())
|
||||
}
|
||||
|
||||
fn metadata_namespace(namespace: Option<&str>) -> bool {
|
||||
matches!(namespace, Some(NS_TTM) | Some(NS_TTM_DRAFT) | Some(NS_TTM_DRAFT_APRIL))
|
||||
}
|
||||
|
||||
/// Significant TTML agents are children of head or of head's metadata children (TTML1 §12.1.5).
|
||||
fn speaker_names(root: roxmltree::Node) -> HashMap<String, String> {
|
||||
let mut out = HashMap::new();
|
||||
let content_namespace = root.tag_name().namespace();
|
||||
let Some(head) = root.children().find(|n| n.is_element() && n.tag_name().name() == "head" && n.tag_name().namespace() == content_namespace) else {
|
||||
return out;
|
||||
};
|
||||
for agent in head.descendants().filter(|n| n.is_element() && n.tag_name().name() == "agent" && metadata_namespace(n.tag_name().namespace())) {
|
||||
if !agent
|
||||
.parent()
|
||||
.is_some_and(|p| p == head || (p.tag_name().name() == "metadata" && p.tag_name().namespace() == content_namespace && p.parent() == Some(head)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let id = if agent.tag_name().namespace() == Some(NS_TTM_DRAFT_APRIL) {
|
||||
// The April 2006 draft defines an unqualified id, before TTML adopted xml:id.
|
||||
agent.attributes().find(|a| a.name() == "id" && a.namespace().is_none()).map(|a| a.value())
|
||||
} else {
|
||||
agent.attribute((NS_XML, "id"))
|
||||
};
|
||||
let Some(id) = id else { continue };
|
||||
let names: Vec<_> = agent
|
||||
.children()
|
||||
.filter(|n| n.is_element() && n.tag_name().name() == "name" && metadata_namespace(n.tag_name().namespace()))
|
||||
.map(|n| {
|
||||
let kind = n.attributes().find(|a| a.name() == "type" && a.namespace().is_none()).map(|a| a.value());
|
||||
(kind, n.children().filter(|c| c.is_text()).filter_map(|c| c.text()).collect::<String>())
|
||||
})
|
||||
.collect();
|
||||
let name = names.iter().find(|(kind, _)| *kind == Some("full")).or_else(|| names.iter().find(|(_, text)| !text.is_empty())).or_else(|| names.first());
|
||||
if let Some((_, name)) = name {
|
||||
// An explicitly empty full name remains empty, including our own Some("") speakers.
|
||||
out.insert(id.to_string(), name.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn speaker(n: roxmltree::Node, names: &HashMap<String, String>) -> Option<String> {
|
||||
let refs = n.attributes().find(|a| a.name() == "agent" && metadata_namespace(a.namespace())).map(|a| a.value()).or_else(|| n.attribute("agent"));
|
||||
refs.map(|refs| {
|
||||
// The project stores one speaker label; multiple agents become an ordered display label.
|
||||
refs.split_whitespace().map(|id| names.get(id).cloned().unwrap_or_else(|| id.trim_start_matches('_').replace('_', " "))).collect::<Vec<_>>().join(", ")
|
||||
})
|
||||
}
|
||||
|
||||
fn timing(root: roxmltree::Node) -> Result<Timing> {
|
||||
let invalid =
|
||||
|name: &str| Error::Syntax { line: root.document().text_pos_at(root.range().start).row as usize, msg: format!("invalid TTML timing parameter {name}") };
|
||||
@@ -218,9 +297,10 @@ fn timing(root: roxmltree::Node) -> Result<Timing> {
|
||||
let rate = FrameRate::new(base.checked_mul(mn).ok_or_else(|| invalid("frameRateMultiplier"))?, md);
|
||||
let sub = integer("subFrameRate", 1)?;
|
||||
let tick_rate = match attr(root, "tickRate") {
|
||||
Some(v) => positive_integer(v).ok_or_else(|| invalid("tickRate"))?,
|
||||
None if attr(root, "frameRate").is_some() => base.checked_mul(sub).ok_or_else(|| invalid("subFrameRate"))?,
|
||||
None => 1,
|
||||
Some(v) => FrameRate::new(positive_integer(v).ok_or_else(|| invalid("tickRate"))?, 1),
|
||||
// TTML1 §6.2.10: absent tickRate uses the effective frame rate times subFrameRate.
|
||||
None if attr(root, "frameRate").is_some() => FrameRate::new(rate.num.checked_mul(sub).ok_or_else(|| invalid("subFrameRate"))?, rate.den),
|
||||
None => FrameRate::new(1, 1),
|
||||
};
|
||||
Ok(Timing { rate, base, sub_frame_rate: sub, tick_rate })
|
||||
}
|
||||
@@ -228,13 +308,13 @@ fn timing(root: roxmltree::Node) -> Result<Timing> {
|
||||
/// Seconds as an exact decimal string → ticks.
|
||||
fn decimal_ticks(int: &str, frac: &str, unit_ticks: i128) -> Option<i128> {
|
||||
let i: i128 = if int.is_empty() { 0 } else { int.parse().ok()? };
|
||||
let mut t = i * unit_ticks;
|
||||
let mut t = i.checked_mul(unit_ticks)?;
|
||||
if !frac.is_empty() {
|
||||
if frac.len() > 12 || !frac.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
let f: i128 = frac.parse().ok()?;
|
||||
t += f * unit_ticks / 10i128.pow(frac.len() as u32);
|
||||
t = t.checked_add(f.checked_mul(unit_ticks)? / 10i128.pow(frac.len() as u32))?;
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
@@ -276,7 +356,7 @@ fn parse_time(s: &str, tm: Timing) -> Option<Tick> {
|
||||
let unit_start = s.find(|c: char| c.is_ascii_alphabetic())?;
|
||||
let (v, unit) = s.split_at(unit_start);
|
||||
let (vi, vf) = v.split_once('.').unwrap_or((v, ""));
|
||||
if !vi.bytes().all(|b| b.is_ascii_digit()) || vi.len() > 15 {
|
||||
if vi.is_empty() || !vi.bytes().all(|b| b.is_ascii_digit()) || vi.len() > 15 {
|
||||
return None;
|
||||
}
|
||||
let t = match unit {
|
||||
@@ -294,10 +374,7 @@ fn parse_time(s: &str, tm: Timing) -> Option<Tick> {
|
||||
}
|
||||
whole.0 as i128 + decimal_ticks("0", vf, fd)?
|
||||
}
|
||||
"t" => {
|
||||
let n: i128 = vi.parse().ok()?;
|
||||
n * tps / tm.tick_rate.max(1) as i128
|
||||
}
|
||||
"t" => decimal_ticks(vi, vf, tps * i128::from(tm.tick_rate.den))? / i128::from(tm.tick_rate.num),
|
||||
_ => return None,
|
||||
};
|
||||
i64::try_from(t).ok().map(Tick)
|
||||
@@ -411,6 +488,7 @@ pub fn parse(text: &str) -> Result<Document> {
|
||||
return Err(Error::NotFormat("TTML"));
|
||||
}
|
||||
let tm = timing(root)?;
|
||||
let speakers = speaker_names(root);
|
||||
let mut styles = std::collections::HashMap::new();
|
||||
for s in root.descendants().filter(|n| n.is_element() && n.tag_name().name() == "style") {
|
||||
if let Some(id) = s.attributes().find(|a| a.name() == "id").map(|a| a.value()) {
|
||||
@@ -423,44 +501,84 @@ pub fn parse(text: &str) -> Result<Document> {
|
||||
let Some(body) = root.children().find(|n| n.is_element() && n.tag_name().name() == "body") else {
|
||||
return Ok(doc);
|
||||
};
|
||||
walk(body, Tick::ZERO, &tm, &styles, Fmt::default(), &mut doc);
|
||||
if root.descendants().any(|n| attr(n, "timeContainer").is_some_and(|v| v.trim() == "seq")) {
|
||||
doc.warnings.push("timeContainer=\"seq\" is not supported; its children are timed as in a par container".into());
|
||||
}
|
||||
// the body is a time container like a div: its begin offsets, and its end clips, everything inside
|
||||
let t = times(body, Scope { begin: Tick::ZERO, end: None }, tm, &mut doc);
|
||||
walk(body, Scope { begin: t.begin.unwrap_or(Tick::ZERO), end: t.end }, &tm, &styles, &speakers, Fmt::default(), &mut doc);
|
||||
doc.cues.sort_by_key(|c| (c.start, c.end));
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
/// Begin and end of a timed element relative to `parent` (None when it has no timing).
|
||||
fn times(n: roxmltree::Node, parent: Tick, tm: &Timing, doc: &mut Document) -> (Option<Tick>, Option<Tick>) {
|
||||
let p = |k: &str, doc: &mut Document| -> Option<Tick> {
|
||||
/// The active interval of the enclosing time containers (`par`): child times are offsets from
|
||||
/// `begin`, and nothing inside outlasts `end` (None when no ancestor sets an end).
|
||||
#[derive(Clone, Copy)]
|
||||
struct Scope {
|
||||
begin: Tick,
|
||||
end: Option<Tick>,
|
||||
}
|
||||
|
||||
/// Whether `n` has timing attributes of its own.
|
||||
fn has_timing(n: roxmltree::Node) -> bool {
|
||||
["begin", "end", "dur"].into_iter().any(|k| attr(n, k).is_some())
|
||||
}
|
||||
|
||||
/// Timing of an element in its parent container.
|
||||
struct Times {
|
||||
/// The begin, when the element sets one.
|
||||
begin: Option<Tick>,
|
||||
/// Its own end (`end`, or `begin` + `dur`, the earlier of the two) clipped to the parent's
|
||||
/// end, else the parent's end.
|
||||
end: Option<Tick>,
|
||||
/// A timing attribute did not parse (and was warned about).
|
||||
bad: bool,
|
||||
}
|
||||
|
||||
fn times(n: roxmltree::Node, parent: Scope, tm: Timing, doc: &mut Document) -> Times {
|
||||
let mut bad = false;
|
||||
let mut p = |k: &str, doc: &mut Document| -> Option<Tick> {
|
||||
let v = attr(n, k)?;
|
||||
let t = parse_time(v, *tm);
|
||||
let t = parse_time(v, tm);
|
||||
if t.is_none() {
|
||||
bad = true;
|
||||
doc.warnings.push(format!("bad time expression \"{v}\""));
|
||||
}
|
||||
t
|
||||
};
|
||||
let begin = p("begin", doc).map(|b| parent + b);
|
||||
let end = match (p("end", doc), p("dur", doc)) {
|
||||
(Some(e), _) => Some(parent + e),
|
||||
(None, Some(d)) => Some(begin.unwrap_or(parent) + d),
|
||||
_ => None,
|
||||
let begin = p("begin", doc).map(|b| parent.begin + b);
|
||||
let own_end = match (p("end", doc).map(|e| parent.begin + e), p("dur", doc).map(|d| begin.unwrap_or(parent.begin) + d)) {
|
||||
(Some(e), Some(d)) => Some(e.min(d)),
|
||||
(e, d) => e.or(d),
|
||||
};
|
||||
(begin, end)
|
||||
let end = match (own_end, parent.end) {
|
||||
(Some(e), Some(pe)) => Some(e.min(pe)),
|
||||
(e, pe) => e.or(pe),
|
||||
};
|
||||
Times { begin, end, bad }
|
||||
}
|
||||
|
||||
fn walk(n: roxmltree::Node, parent: Tick, tm: &Timing, styles: &std::collections::HashMap<String, Fmt>, fmt: Fmt, doc: &mut Document) {
|
||||
fn walk(n: roxmltree::Node, parent: Scope, tm: &Timing, styles: &HashMap<String, Fmt>, speakers: &HashMap<String, String>, fmt: Fmt, doc: &mut Document) {
|
||||
for c in n.children().filter(|c| c.is_element()) {
|
||||
let f = style_of(c, styles, fmt);
|
||||
match c.tag_name().name() {
|
||||
"div" => {
|
||||
let (b, _) = times(c, parent, tm, doc);
|
||||
walk(c, b.unwrap_or(parent), tm, styles, f, doc);
|
||||
let t = times(c, parent, *tm, doc);
|
||||
walk(c, Scope { begin: t.begin.unwrap_or(parent.begin), end: t.end }, tm, styles, speakers, f, doc);
|
||||
}
|
||||
"p" => {
|
||||
let (b, e) = times(c, parent, tm, doc);
|
||||
let speaker = attr(c, "agent").map(|a| a.trim().trim_start_matches('_').replace('_', " "));
|
||||
let t = times(c, parent, *tm, doc);
|
||||
if t.bad {
|
||||
continue;
|
||||
}
|
||||
let (b, e) = (t.begin.unwrap_or(parent.begin), t.end);
|
||||
let cue_speaker = speaker(c, speakers);
|
||||
let id = c.attributes().find(|a| a.name() == "id").map(|a| a.value().to_string());
|
||||
match (b, e) {
|
||||
(Some(b), Some(e)) => {
|
||||
let spans: Vec<roxmltree::Node> = c.children().filter(|x| x.is_element() && x.tag_name().name() == "span" && has_timing(*x)).collect();
|
||||
// a timed paragraph, or one without timed spans in a container with an end, is one cue
|
||||
let whole = if has_timing(c) || spans.is_empty() { e } else { None };
|
||||
match whole {
|
||||
Some(e) => {
|
||||
let mut o = Out { s: String::new(), pending: false };
|
||||
let tags: Vec<&str> = [(f.italic, "i"), (f.bold, "b"), (f.underline, "u")].into_iter().filter(|x| x.0).map(|x| x.1).collect();
|
||||
for t in &tags {
|
||||
@@ -472,20 +590,27 @@ fn walk(n: roxmltree::Node, parent: Tick, tm: &Timing, styles: &std::collections
|
||||
}
|
||||
let text = clean(&o.s);
|
||||
if e > b && !text.is_empty() {
|
||||
doc.cues.push(Cue { start: b, end: e, text, speaker, id, ..Default::default() });
|
||||
doc.cues.push(Cue { start: b, end: e, text, speaker: cue_speaker, id, ..Default::default() });
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
None => {
|
||||
// untimed paragraph: its timed spans are the cues
|
||||
let base = b.unwrap_or(parent);
|
||||
for sp in c.children().filter(|x| x.is_element() && x.tag_name().name() == "span") {
|
||||
let (sb, se) = times(sp, base, tm, doc);
|
||||
if let (Some(sb), Some(se)) = (sb, se) {
|
||||
let scope = Scope { begin: b, end: e };
|
||||
for sp in spans {
|
||||
let st = times(sp, scope, *tm, doc);
|
||||
let sb = st.begin.unwrap_or(scope.begin);
|
||||
if let (false, Some(se)) = (st.bad, st.end) {
|
||||
let mut o = Out { s: String::new(), pending: false };
|
||||
collect(sp, styles, style_of(sp, styles, f), &mut o);
|
||||
let text = clean(&o.s);
|
||||
if se > sb && !text.is_empty() {
|
||||
doc.cues.push(Cue { start: sb, end: se, text, speaker: speaker.clone(), ..Default::default() });
|
||||
doc.cues.push(Cue {
|
||||
start: sb,
|
||||
end: se,
|
||||
text,
|
||||
speaker: speaker(sp, speakers).or_else(|| cue_speaker.clone()),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -502,7 +627,7 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tm(rate: FrameRate) -> Timing {
|
||||
Timing { rate, base: rate.timecode_base(), sub_frame_rate: 1, tick_rate: 1 }
|
||||
Timing { rate, base: rate.timecode_base(), sub_frame_rate: 1, tick_rate: FrameRate::new(1, 1) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -528,6 +653,8 @@ mod tests {
|
||||
fn missing_timing_parameters_keep_defaults() {
|
||||
for (params, begin, end) in [
|
||||
("", "1s", "2s"),
|
||||
(r#"ttp:frameRate="25" ttp:frameRateMultiplier="2 1""#, "50t", "100t"),
|
||||
(r#"ttp:frameRate="30" ttp:frameRateMultiplier="1000 1001" ttp:subFrameRate="1001""#, "30000t", "60000t"),
|
||||
(r#"ttp:frameRate="25""#, "25f", "50f"),
|
||||
(r#"ttp:frameRate="30" ttp:subFrameRate="2""#, "60t", "120t"),
|
||||
(r#"ttp:frameRate="30" ttp:subFrameRate="2" ttp:tickRate="10""#, "10t", "20t"),
|
||||
@@ -543,6 +670,30 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_tick_rate_preserves_fractional_frame_rate() {
|
||||
for format in [crate::Format::Ttml, crate::Format::Dfxp] {
|
||||
for (params, begin, end, start, finish) in [
|
||||
(r#"ttp:frameRate="30" ttp:frameRateMultiplier="1000 1001""#, "30t", "60t", 1001000, 2002000),
|
||||
(r#"ttp:frameRate="30" ttp:frameRateMultiplier="1000 1001""#, "1.5t", "3t", 50050, 100100),
|
||||
(r#"ttp:frameRate="30" ttp:frameRateMultiplier="1000 1001" ttp:subFrameRate="2""#, "30t", "60t", 500500, 1001000),
|
||||
(r#"ttp:frameRate="30" ttp:frameRateMultiplier="1000 1001" ttp:tickRate="10""#, "10t", "20t", 1000000, 2000000),
|
||||
(r#"ttp:frameRateMultiplier="2 1" ttp:subFrameRate="2""#, "1t", "2t", 1000000, 2000000),
|
||||
] {
|
||||
let xml = format!(
|
||||
r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" {params}><body><div><p begin="{begin}" end="{end}">Hello</p></div></body></tt>"#
|
||||
);
|
||||
let doc = crate::parse(xml.as_bytes(), format).unwrap();
|
||||
assert_eq!(doc.cues.len(), 1, "{params}");
|
||||
assert_eq!(doc.cues[0].start, Tick(start * (TICKS_PER_SECOND / 1_000_000)), "{params}");
|
||||
assert_eq!(doc.cues[0].end, Tick(finish * (TICKS_PER_SECOND / 1_000_000)), "{params}");
|
||||
}
|
||||
}
|
||||
let timing = Timing { tick_rate: FrameRate::new(1, i64::MAX), ..tm(FrameRate::FPS_25) };
|
||||
assert_eq!(parse_time("999999999999999t", timing), None);
|
||||
assert_eq!(parse_time("0.999999999999t", timing), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_expressions() {
|
||||
let t = tm(FrameRate::FPS_25);
|
||||
@@ -556,7 +707,7 @@ mod tests {
|
||||
assert_eq!(parse_time("50f", t), Some(s(2.0)));
|
||||
let ntsc = tm(FrameRate::FPS_29_97);
|
||||
assert_eq!(parse_time("30f", ntsc), Some(FrameRate::FPS_29_97.tick_of(30)));
|
||||
let ticks = Timing { tick_rate: 10_000_000, ..t };
|
||||
let ticks = Timing { tick_rate: FrameRate::new(10_000_000, 1), ..t };
|
||||
assert_eq!(parse_time("15000000t", ticks), Some(s(1.5)));
|
||||
assert_eq!(parse_time("bogus", t), None);
|
||||
assert_eq!(parse_time("1:2", t), None);
|
||||
@@ -590,6 +741,34 @@ mod tests {
|
||||
assert!(!doc.warnings.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractional_tick_offsets_keep_exact_caption_times() {
|
||||
for format in [crate::Format::Ttml, crate::Format::Dfxp] {
|
||||
let xml = r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter" ttp:tickRate="10"><body><div><p begin="1.5t" end="12.5t">Hello</p></div></body></tt>"#;
|
||||
let doc = crate::parse(xml.as_bytes(), format).unwrap();
|
||||
assert_eq!(doc.cues.len(), 1);
|
||||
assert_eq!(doc.cues[0].start, Tick(TICKS_PER_SECOND * 3 / 20));
|
||||
assert_eq!(doc.cues[0].end, Tick(TICKS_PER_SECOND * 5 / 4));
|
||||
assert!(doc.warnings.is_empty());
|
||||
}
|
||||
let timing = Timing { tick_rate: FrameRate::new(3, 1), ..tm(FrameRate::FPS_25) };
|
||||
assert_eq!(parse_time("0.5t", timing), Some(Tick(TICKS_PER_SECOND / 6)));
|
||||
for expr in ["1.xt", "1.1234567890123t", "999999999999999.5t"] {
|
||||
assert_eq!(parse_time(expr, timing), None, "{expr}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offset_time_without_integer_digits_is_rejected() {
|
||||
let t = tm(FrameRate::FPS_25);
|
||||
for expr in ["s", "ms", "h", "m", "t", "f", ".5s", ".5ms", ".s"] {
|
||||
assert_eq!(parse_time(expr, t), None, "{expr}");
|
||||
}
|
||||
assert_eq!(parse_time("0s", t), Some(Tick(0)));
|
||||
assert_eq!(parse_time("0.5s", t), Some(Tick(TICKS_PER_SECOND / 2)));
|
||||
assert_eq!(parse_time("1ms", t), Some(Tick(TICKS_PER_SECOND / 1000)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_styles_spans_and_nesting() {
|
||||
let x = r#"<?xml version="1.0"?>
|
||||
|
||||
@@ -38,7 +38,7 @@ pub fn split_voice(text: &str) -> (Option<String>, String) {
|
||||
Some(_) => return (None, text.to_string()),
|
||||
None => body.to_string(),
|
||||
};
|
||||
if body.contains("<v ") || body.contains("<v.") {
|
||||
if body.contains("<v ") || body.contains("<v.") || body.contains("<v\t") {
|
||||
return (None, text.to_string());
|
||||
}
|
||||
return ((!name.is_empty()).then(|| name.to_string()), body);
|
||||
@@ -79,7 +79,7 @@ pub fn parse(text: &str) -> Result<Document> {
|
||||
let mut seen_cue = false;
|
||||
for b in blocks {
|
||||
let first = b[0].trim();
|
||||
if !first.contains("-->") && (first == "NOTE" || first.starts_with("NOTE ") || first.starts_with("NOTE\t")) {
|
||||
if !b.iter().any(|l| l.contains("-->")) && (first == "NOTE" || first.starts_with("NOTE ") || first.starts_with("NOTE\t")) {
|
||||
doc.blocks.push(b.join("\n"));
|
||||
continue;
|
||||
}
|
||||
@@ -177,6 +177,27 @@ mod tests {
|
||||
assert_eq!(split_voice("<vx>hi"), (None, "<vx>hi".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_voices_with_tab_separator() {
|
||||
let text = "<v Ann>Hello <v\tBob>there";
|
||||
assert_eq!(split_voice(text), (None, text.to_string()));
|
||||
let source = format!("WEBVTT\n\n00:00:01.000 --> 00:00:02.000\n{text}\n");
|
||||
let doc = parse(&source).unwrap();
|
||||
assert_eq!(doc.cues[0].speaker, None);
|
||||
assert_eq!(doc.cues[0].text, text);
|
||||
assert_eq!(parse(&write(&doc)).unwrap().cues, doc.cues);
|
||||
let closed = "<v Ann>Hi <v\tBob>x</v>";
|
||||
assert_eq!(split_voice(closed), (None, closed.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_prefixed_cue_id_is_a_cue() {
|
||||
let d = parse("WEBVTT\n\nNOTE 1\n00:00:01.000 --> 00:00:02.000\nhello\n").unwrap();
|
||||
assert_eq!(d.cues.len(), 1);
|
||||
assert_eq!(d.cues[0].id.as_deref(), Some("NOTE 1"));
|
||||
assert!(d.blocks.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_signature() {
|
||||
let d = parse("00:00:01.000 --> 00:00:02.000\nx\n").unwrap();
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
mod roundtrip;
|
||||
mod ttml_body_timing;
|
||||
mod ttml_speakers;
|
||||
@@ -0,0 +1,92 @@
|
||||
//! TTML / DFXP inherited timing: `begin`, `end` and `dur` on `body` and `div` time containers
|
||||
//! offset and clip the paragraphs inside them (#895).
|
||||
|
||||
use filmcraft_captions::{Format, WriteOptions, parse, write};
|
||||
|
||||
/// (start, end, text) of every cue, in seconds; panics on a parse error.
|
||||
fn cues(xml: &str, format: Format) -> Vec<(f64, f64, String)> {
|
||||
let doc = parse(xml.as_bytes(), format).unwrap();
|
||||
assert!(doc.warnings.is_empty(), "{:?}", doc.warnings);
|
||||
doc.cues.iter().map(|c| (c.start.seconds(), c.end.seconds(), c.text.clone())).collect()
|
||||
}
|
||||
|
||||
fn both(xml: &str, want: &[(f64, f64, &str)]) {
|
||||
let want: Vec<(f64, f64, String)> = want.iter().map(|(s, e, t)| (*s, *e, t.to_string())).collect();
|
||||
for format in [Format::Ttml, Format::Dfxp] {
|
||||
assert_eq!(cues(xml, format), want, "{format:?}: {xml}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_begin_is_inherited() {
|
||||
both(r#"<tt xmlns="http://www.w3.org/ns/ttml"><body begin="5s"><div><p begin="1s" end="2s">x</p></div></body></tt>"#, &[(6.0, 7.0, "x")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn div_begin_is_inherited() {
|
||||
both(r#"<tt xmlns="http://www.w3.org/ns/ttml"><body><div begin="5s"><p begin="1s" end="2s">x</p></div></body></tt>"#, &[(6.0, 7.0, "x")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn untimed_containers_leave_times_unchanged() {
|
||||
both(r#"<tt xmlns="http://www.w3.org/ns/ttml"><body><div><p begin="1s" end="2s">x</p></div></body></tt>"#, &[(1.0, 2.0, "x")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_begins_add_up() {
|
||||
both(
|
||||
r#"<tt xmlns="http://www.w3.org/ns/ttml"><body begin="5s"><div begin="00:00:10.000"><div begin="1s"><p begin="1s" end="2s">deep</p></div><p begin="0s" dur="1s">mid</p></div><p begin="1s" end="2s">top</p></body></tt>"#,
|
||||
&[(6.0, 7.0, "top"), (15.0, 16.0, "mid"), (17.0, 18.0, "deep")],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_begin_offsets_timed_spans() {
|
||||
both(r#"<tt xmlns="http://www.w3.org/ns/ttml"><body begin="5s"><div><p><span begin="1s" end="2s">x</span></p></div></body></tt>"#, &[(6.0, 7.0, "x")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn container_ends_clip_descendants() {
|
||||
// div active 11 s to 13 s: a later paragraph is dropped, a longer one is cut at 13 s
|
||||
both(
|
||||
r#"<tt xmlns="http://www.w3.org/ns/ttml"><body begin="1s"><div begin="10s" end="12s"><p begin="1s" end="5s">cut</p><p begin="3s" end="4s">gone</p></div></body></tt>"#,
|
||||
&[(12.0, 13.0, "cut")],
|
||||
);
|
||||
both(r#"<tt xmlns="http://www.w3.org/ns/ttml"><body dur="3s"><div><p begin="1s" end="5s">x</p></div></body></tt>"#, &[(1.0, 3.0, "x")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraphs_without_an_end_last_until_their_container_ends() {
|
||||
both(
|
||||
r#"<tt xmlns="http://www.w3.org/ns/ttml"><body><div begin="2s" end="6s"><p begin="1s">open</p></div><div begin="10s" dur="2s"><p>whole</p></div></body></tt>"#,
|
||||
&[(3.0, 6.0, "open"), (10.0, 12.0, "whole")],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_and_dur_take_the_earlier() {
|
||||
both(r#"<tt xmlns="http://www.w3.org/ns/ttml"><body><div><p begin="1s" end="5s" dur="2s">x</p></div></body></tt>"#, &[(1.0, 3.0, "x")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seq_containers_warn() {
|
||||
let xml = r#"<tt xmlns="http://www.w3.org/ns/ttml" xmlns:ttp="http://www.w3.org/ns/ttml#parameter"><body><div timeContainer="seq"><p begin="1s" end="2s">x</p></div></body></tt>"#;
|
||||
for format in [Format::Ttml, Format::Dfxp] {
|
||||
let doc = parse(xml.as_bytes(), format).unwrap();
|
||||
assert_eq!(doc.cues.len(), 1);
|
||||
assert!(doc.warnings.iter().any(|w| w.contains("seq")), "{:?}", doc.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inherited_times_survive_export() {
|
||||
// the writer uses untimed body / div and absolute paragraph times, so the resolved times stay
|
||||
let xml = r#"<tt xmlns="http://www.w3.org/ns/ttml"><body begin="5s"><div begin="1s" end="4s"><p begin="1s" end="9s">x</p></div></body></tt>"#;
|
||||
for format in [Format::Ttml, Format::Dfxp] {
|
||||
let doc = parse(xml.as_bytes(), format).unwrap();
|
||||
let back = parse(&write(&doc, format, WriteOptions::default()), format).unwrap();
|
||||
let times = |d: &filmcraft_captions::Document| d.cues.iter().map(|c| (c.start, c.end, c.text.clone())).collect::<Vec<_>>();
|
||||
assert_eq!(times(&back), times(&doc), "{format:?}");
|
||||
assert_eq!((back.cues[0].start.seconds(), back.cues[0].end.seconds()), (7.0, 9.0), "{format:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use filmcraft_captions::{Cue, Document, Format, WriteOptions, parse, write};
|
||||
use filmcraft_time::FrameRate;
|
||||
|
||||
const TT: &str = "http://www.w3.org/ns/ttml";
|
||||
const META: &str = "http://www.w3.org/ns/ttml#metadata";
|
||||
const XML: &str = "http://www.w3.org/XML/1998/namespace";
|
||||
|
||||
#[test]
|
||||
fn imports_speaker_names_from_significant_ttml_metadata() {
|
||||
for format in [Format::Ttml, Format::Dfxp] {
|
||||
for metadata in [
|
||||
r#"<ttm:agent xml:id="speaker1" type="person"><ttm:name type="alias">Ann</ttm:name><ttm:name type="full">Ann Lee</ttm:name></ttm:agent>"#
|
||||
.to_string(),
|
||||
r#"<metadata><ttm:agent xml:id="speaker1" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent></metadata>"#.to_string(),
|
||||
r#"<ttm:agent xml:id="speaker1" type="person"><ttm:name type="full">Ann<!-- editorial note --> Lee</ttm:name></ttm:agent>"#.to_string(),
|
||||
] {
|
||||
let xml = format!(
|
||||
r#"<tt xmlns="{TT}" xmlns:ttm="{META}"><head>{metadata}</head><body><div><p begin="1s" end="2s" ttm:agent="speaker1">Hello</p></div></body></tt>"#
|
||||
);
|
||||
let doc = parse(xml.as_bytes(), format).unwrap();
|
||||
assert_eq!(doc.cues.len(), 1);
|
||||
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann Lee"));
|
||||
assert_eq!(doc.cues[0].text, "Hello");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exported_speakers_have_unique_resolvable_ids_and_exact_names() {
|
||||
let rate = FrameRate::FPS_25;
|
||||
let speakers =
|
||||
["Ann Lee", "Ann_Lee", "O’Connor & <Guest>", "_Ann", "山田 花子", "Ann Lee", "", " Ann Lee ", "Ann\rLee", "Ann\r\nLee", "Ann\nLee", "Ann\tLee"];
|
||||
let ids = ["agent1", "agent2", "speaker1", "speaker2", "c5", "c6", "c7", "c8", "c9", "c10", "c11", "c12"];
|
||||
let doc = Document {
|
||||
cues: speakers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| Cue {
|
||||
start: rate.tick_of(i as i64 * 50),
|
||||
end: rate.tick_of(i as i64 * 50 + 25),
|
||||
text: format!("Line {}", i + 1),
|
||||
speaker: Some((*name).into()),
|
||||
id: Some(ids[i].into()),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
for format in [Format::Ttml, Format::Dfxp] {
|
||||
let bytes = write(&doc, format, WriteOptions { rate: Some(rate), ..Default::default() });
|
||||
assert_eq!(bytes, write(&doc, format, WriteOptions { rate: Some(rate), ..Default::default() }), "stable output");
|
||||
let text = std::str::from_utf8(&bytes).unwrap();
|
||||
let xml = roxmltree::Document::parse(text).unwrap();
|
||||
let mut all_ids = HashSet::new();
|
||||
for id in xml.descendants().filter_map(|n| n.attribute((XML, "id"))) {
|
||||
assert!(all_ids.insert(id), "duplicate xml:id {id}");
|
||||
}
|
||||
let mut agents = HashMap::new();
|
||||
for agent in xml.descendants().filter(|n| n.has_tag_name((META, "agent"))) {
|
||||
assert_eq!(agent.attribute("type"), Some("other"));
|
||||
let name = agent.children().find(|n| n.has_tag_name((META, "name"))).unwrap();
|
||||
assert_eq!(name.attribute("type"), Some("full"));
|
||||
assert_eq!(name.attribute((XML, "space")), Some("preserve"));
|
||||
agents.insert(agent.attribute((XML, "id")).unwrap(), name.text().unwrap_or(""));
|
||||
}
|
||||
assert_eq!(agents.len(), 11, "each exact name is defined once");
|
||||
let paragraphs: Vec<_> = xml.descendants().filter(|n| n.has_tag_name((TT, "p"))).collect();
|
||||
assert_eq!(paragraphs.len(), speakers.len());
|
||||
for (paragraph, expected) in paragraphs.iter().zip(speakers) {
|
||||
let id = paragraph.attribute((META, "agent")).unwrap();
|
||||
assert_eq!(agents.get(id).copied(), Some(expected), "reference {id}");
|
||||
}
|
||||
let back = parse(&bytes, format).unwrap();
|
||||
assert_eq!(back.cues, doc.cues, "{format:?} names and cue content survive");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_multiple_agent_names_in_reference_order() {
|
||||
let xml = format!(
|
||||
r#"<tt xmlns="{TT}" xmlns:ttm="{META}"><head>
|
||||
<ttm:agent xml:id="ann" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent>
|
||||
<ttm:agent xml:id="guest" type="person"><ttm:name type="alias">O’Connor</ttm:name></ttm:agent>
|
||||
</head><body><div><p begin="1s" end="2s" ttm:agent="guest ann">Together</p></div></body></tt>"#
|
||||
);
|
||||
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
|
||||
assert_eq!(doc.cues[0].speaker.as_deref(), Some("O’Connor, Ann Lee"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_legacy_filmcraft_speaker_references_readable() {
|
||||
let xml = format!(
|
||||
r#"<tt xmlns="{TT}" xmlns:ttm="{META}"><body><div>
|
||||
<p begin="1s" end="2s" ttm:agent="Ann_Lee">Hello</p>
|
||||
</div></body></tt>"#
|
||||
);
|
||||
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
|
||||
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann Lee"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timed_span_can_name_its_own_speaker() {
|
||||
let xml = format!(
|
||||
r#"<tt xmlns="{TT}" xmlns:ttm="{META}"><head>
|
||||
<ttm:agent xml:id="ann" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent>
|
||||
<ttm:agent xml:id="guest" type="person"><ttm:name type="full">Guest</ttm:name></ttm:agent>
|
||||
</head><body><div><p ttm:agent="ann"><span begin="1s" end="2s" ttm:agent="guest">Hello</span>
|
||||
<span begin="3s" end="4s">Welcome</span></p></div></body></tt>"#
|
||||
);
|
||||
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
|
||||
assert_eq!(doc.cues.len(), 2);
|
||||
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Guest"));
|
||||
assert_eq!(doc.cues[1].speaker.as_deref(), Some("Ann Lee"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_metadata_does_not_replace_ttml_speakers() {
|
||||
let real = r#"<ttm:agent xml:id="speaker1" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent>"#;
|
||||
for (metadata, attributes) in [
|
||||
(
|
||||
format!(r#"{real}<metadata><app:agent id="speaker1"><app:name type="full">Library editor</app:name></app:agent></metadata>"#),
|
||||
r#"ttm:agent="speaker1""#,
|
||||
),
|
||||
(real.into(), r#"app:agent="library" ttm:agent="speaker1""#),
|
||||
] {
|
||||
let xml = format!(
|
||||
r#"<tt xmlns="{TT}" xmlns:ttm="{META}" xmlns:app="urn:example:caption-library"><head>{metadata}</head><body><div><p begin="1s" end="2s" {attributes}>Hello</p></div></body></tt>"#
|
||||
);
|
||||
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
|
||||
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann Lee"), "{xml}");
|
||||
}
|
||||
// A TTML agent nested inside another application's metadata container is not significant.
|
||||
let xml = format!(
|
||||
r#"<tt xmlns="{TT}" xmlns:ttm="{META}" xmlns:app="urn:example:caption-library"><head><app:metadata>{real}</app:metadata></head><body><div><p begin="1s" end="2s" ttm:agent="speaker1">Hello</p></div></body></tt>"#
|
||||
);
|
||||
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
|
||||
assert_eq!(doc.cues[0].speaker.as_deref(), Some("speaker1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_draft_metadata_and_legacy_unqualified_speaker_references() {
|
||||
for (tt, metadata, attributes) in
|
||||
[("http://www.w3.org/2006/10/ttaf1", "http://www.w3.org/2006/10/ttaf1#metadata", r#"ttm:agent="speaker1""#), (TT, META, r#"agent="speaker1""#)]
|
||||
{
|
||||
let xml = format!(
|
||||
r#"<tt xmlns="{tt}" xmlns:ttm="{metadata}"><head><ttm:agent xml:id="speaker1" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent></head><body><div><p begin="1s" end="2s" {attributes}>Hello</p></div></body></tt>"#
|
||||
);
|
||||
let doc = parse(xml.as_bytes(), Format::Ttml).unwrap();
|
||||
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann Lee"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_april_2006_draft_speaker_references_readable() {
|
||||
let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<tt xml:lang="en" xmlns="http://www.w3.org/2006/04/ttaf1" xmlns:ttm="http://www.w3.org/2006/04/ttaf1#metadata">
|
||||
<head><metadata>
|
||||
<ttm:agent id="Ann_Lee" type="person"><ttm:name type="full">Ann Lee</ttm:name></ttm:agent>
|
||||
</metadata></head>
|
||||
<body><div><p begin="1s" end="2s" ttm:agent="Ann_Lee">Hello</p></div></body>
|
||||
</tt>"#;
|
||||
for format in [Format::Ttml, Format::Dfxp] {
|
||||
let doc = parse(xml.as_bytes(), format).unwrap();
|
||||
assert_eq!(doc.cues.len(), 1);
|
||||
assert_eq!(doc.cues[0].text, "Hello");
|
||||
assert_eq!(doc.cues[0].start, FrameRate::FPS_25.tick_of(25));
|
||||
assert_eq!(doc.cues[0].end, FrameRate::FPS_25.tick_of(50));
|
||||
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann Lee"), "the documented draft keeps its previously readable speaker");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_april_2006_agent_definitions_with_unqualified_ids() {
|
||||
let xml = r#"<tt xml:lang="en" xmlns="http://www.w3.org/2006/04/ttaf1" xmlns:ttm="http://www.w3.org/2006/04/ttaf1#metadata">
|
||||
<head><ttm:agent id="speaker1" type="person"><ttm:name type="full">Ann-Marie Lee</ttm:name></ttm:agent></head>
|
||||
<body><div><p begin="1s" end="2s" ttm:agent="speaker1">Welcome</p></div></body></tt>"#;
|
||||
for format in [Format::Ttml, Format::Dfxp] {
|
||||
let doc = parse(xml.as_bytes(), format).unwrap();
|
||||
assert_eq!(doc.cues.len(), 1);
|
||||
assert_eq!(doc.cues[0].text, "Welcome");
|
||||
assert_eq!(doc.cues[0].speaker.as_deref(), Some("Ann-Marie Lee"), "the name resolves through the draft's unqualified id");
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_full_speaker_name_with_type(attributes: &str) {
|
||||
let xml = format!(
|
||||
r#"<tt xml:lang="en" xmlns="{TT}" xmlns:ttm="{META}" xmlns:app="urn:example:caption-library">
|
||||
<head><ttm:agent xml:id="speaker1" type="person">
|
||||
<ttm:name type="alias">Ann</ttm:name><ttm:name {attributes}>Ann Lee</ttm:name>
|
||||
</ttm:agent></head><body><div><p begin="1s" end="2s" ttm:agent="speaker1">Hello</p></div></body></tt>"#
|
||||
);
|
||||
let mut speakers = Vec::new();
|
||||
for format in [Format::Ttml, Format::Dfxp] {
|
||||
let doc = parse(xml.as_bytes(), format).unwrap();
|
||||
assert_eq!(doc.cues.len(), 1);
|
||||
assert_eq!(doc.cues[0].text, "Hello");
|
||||
assert_eq!(doc.cues[0].start, FrameRate::FPS_25.tick_of(25));
|
||||
assert_eq!(doc.cues[0].end, FrameRate::FPS_25.tick_of(50));
|
||||
speakers.push(doc.cues[0].speaker.clone());
|
||||
}
|
||||
assert_eq!(speakers, [Some("Ann Lee".into()), Some("Ann Lee".into())], "TTML and DFXP prefer the full name: {attributes}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_type_without_application_attribute_prefers_full_name() {
|
||||
assert_full_speaker_name_with_type(r#"type="full""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_type_after_application_attribute_prefers_full_name() {
|
||||
assert_full_speaker_name_with_type(r#"app:type="contact" type="full""#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_type_before_application_attribute_prefers_full_name() {
|
||||
assert_full_speaker_name_with_type(r#"type="full" app:type="contact""#);
|
||||
}
|
||||
@@ -50,3 +50,6 @@ chain is contiguous. Sibling trees are balanced binary search trees in [MS-CFB]
|
||||
bytes) and multi-sector streams and up to 40 siblings; red-black invariants of every sibling tree;
|
||||
a 7.5 MB stream that needs DIFAT sectors; Unicode and case-insensitive names; truncation at every
|
||||
29 bytes and 1500 random mutations never panic and only return complete streams.
|
||||
The writer rejects a duplicate sibling name with an O(1) hash lookup on the upper-cased name (a scan over the
|
||||
siblings made n children cost O(n^2): 20 000 AAF markers took a minute); `adding_many_children_is_not_quadratic`
|
||||
and `duplicate_names_are_still_rejected` (`tests/writer_scale.rs`) cover it.
|
||||
|
||||
+28
-4
@@ -5,6 +5,8 @@
|
||||
//! when the FAT needs more than the 109 header slots. Every chain is contiguous. Sibling trees are
|
||||
//! balanced binary search trees coloured as valid red-black trees (§2.6.4).
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{DIFSECT, ENDOFCHAIN, FATSECT, FREESECT, MINI_SECTOR_SIZE, MINI_STREAM_CUTOFF, NOSTREAM, Result, SIGNATURE, compare_names, validate_name};
|
||||
|
||||
/// File format version.
|
||||
@@ -44,6 +46,15 @@ struct Node {
|
||||
clsid: [u8; 16],
|
||||
data: Vec<u8>,
|
||||
children: Vec<usize>,
|
||||
/// The children's names, upper-cased as sibling order compares them: an O(1) duplicate check.
|
||||
/// (Scanning every sibling with `compare_names`, which allocates, made adding n children O(n^2):
|
||||
/// 20 000 AAF markers took a minute.)
|
||||
child_keys: HashSet<Vec<u16>>,
|
||||
}
|
||||
|
||||
/// A name as sibling comparison sees it: its UTF-16 code units, upper-cased.
|
||||
fn name_key(name: &str) -> Vec<u16> {
|
||||
name.encode_utf16().map(crate::upper).collect()
|
||||
}
|
||||
|
||||
/// Builds a compound file in memory.
|
||||
@@ -64,7 +75,17 @@ impl Writer {
|
||||
pub const ROOT: NodeId = NodeId(0);
|
||||
|
||||
pub fn new(version: Version) -> Self {
|
||||
Writer { version, nodes: vec![Node { name: "Root Entry".into(), kind: Kind::Root, clsid: [0; 16], data: Vec::new(), children: Vec::new() }] }
|
||||
Writer {
|
||||
version,
|
||||
nodes: vec![Node {
|
||||
name: "Root Entry".into(),
|
||||
kind: Kind::Root,
|
||||
clsid: [0; 16],
|
||||
data: Vec::new(),
|
||||
children: Vec::new(),
|
||||
child_keys: HashSet::new(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn add(&mut self, parent: NodeId, name: &str, kind: Kind, clsid: [u8; 16], data: Vec<u8>) -> Result<NodeId> {
|
||||
@@ -73,12 +94,15 @@ impl Writer {
|
||||
if p.kind == Kind::Stream {
|
||||
return Err(crate::Error::Invalid(format!("{:?} is a stream, not a storage", p.name)));
|
||||
}
|
||||
if p.children.iter().any(|&c| compare_names(&self.nodes[c].name, name).is_eq()) {
|
||||
let key = name_key(name);
|
||||
if p.child_keys.contains(&key) {
|
||||
return Err(crate::Error::BadName(format!("duplicate name {name}")));
|
||||
}
|
||||
let id = self.nodes.len();
|
||||
self.nodes.push(Node { name: name.to_string(), kind, clsid, data, children: Vec::new() });
|
||||
self.nodes[parent.0].children.push(id);
|
||||
self.nodes.push(Node { name: name.to_string(), kind, clsid, data, children: Vec::new(), child_keys: HashSet::new() });
|
||||
let p = &mut self.nodes[parent.0];
|
||||
p.children.push(id);
|
||||
p.child_keys.insert(key);
|
||||
Ok(NodeId(id))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
//! The writer scales: adding many children to one storage, and the duplicate-name rules that check keeps.
|
||||
|
||||
use filmcraft_cfb::{Version, Writer};
|
||||
|
||||
/// Adding a child compared its name with every sibling (allocating twice per comparison), so n children
|
||||
/// cost O(n^2): 20 000 AAF markers in one storage took about a minute. The duplicate check is a hash
|
||||
/// lookup now. 40 000 plain names take milliseconds now and about 18 s before (a minute for real marker
|
||||
/// objects), so the limit separates them by orders of magnitude on any machine.
|
||||
#[test]
|
||||
fn adding_many_children_is_not_quadratic() {
|
||||
let mut w = Writer::new(Version::V3);
|
||||
let start = std::time::Instant::now();
|
||||
for i in 0..40_000 {
|
||||
w.stream(Writer::ROOT, &format!("{i}"), vec![1]).expect("unique name");
|
||||
}
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(3), "{:?}", start.elapsed());
|
||||
}
|
||||
|
||||
/// The faster duplicate check still means what sibling comparison means: case-insensitive, also beyond ASCII,
|
||||
/// per storage (the same name in another storage is fine), and a name that differs only in length is distinct.
|
||||
#[test]
|
||||
fn duplicate_names_are_still_rejected() {
|
||||
let mut w = Writer::new(Version::V3);
|
||||
let a = w.storage(Writer::ROOT, "Alpha", [0; 16]).unwrap();
|
||||
assert!(w.stream(Writer::ROOT, "ALPHA", vec![]).is_err(), "case-insensitive");
|
||||
assert!(w.stream(Writer::ROOT, "alpha", vec![]).is_err());
|
||||
assert!(w.stream(Writer::ROOT, "Alph", vec![]).is_ok());
|
||||
assert!(w.stream(Writer::ROOT, "Alpha2", vec![]).is_ok());
|
||||
assert!(w.stream(a, "Alpha", vec![]).is_ok(), "same name in another storage");
|
||||
assert!(w.stream(a, "Alpha", vec![]).is_err());
|
||||
w.stream(Writer::ROOT, "Ünï", vec![]).unwrap();
|
||||
assert!(w.stream(Writer::ROOT, "ÜNÏ", vec![]).is_err(), "non-ASCII case");
|
||||
}
|
||||
@@ -14,16 +14,19 @@ filmcraft-time = { workspace = true }
|
||||
filmcraft-color = { workspace = true }
|
||||
filmcraft-frame = { workspace = true }
|
||||
filmcraft-media = { workspace = true }
|
||||
filmcraft-avi = { workspace = true }
|
||||
filmcraft-isobmff = { workspace = true }
|
||||
filmcraft-matroska = { workspace = true }
|
||||
filmcraft-mxf = { workspace = true }
|
||||
filmcraft-mpegts = { workspace = true }
|
||||
filmcraft-mpeg2v = { workspace = true }
|
||||
filmcraft-ac3 = { workspace = true }
|
||||
filmcraft-mpa = { workspace = true }
|
||||
filmcraft-ogg = { workspace = true }
|
||||
filmcraft-bitstream = { workspace = true }
|
||||
filmcraft-h264 = { workspace = true }
|
||||
filmcraft-hevc = { workspace = true }
|
||||
filmcraft-vp8 = { workspace = true }
|
||||
filmcraft-vp9 = { workspace = true }
|
||||
filmcraft-prores = { workspace = true }
|
||||
filmcraft-dnx = { workspace = true }
|
||||
@@ -31,6 +34,7 @@ filmcraft-av1 = { workspace = true }
|
||||
filmcraft-apv = { workspace = true }
|
||||
filmcraft-aac = { workspace = true }
|
||||
filmcraft-opus = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
# Decode timing counters (`gop_stats`); std's Instant panics on wasm32.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user