M6.8: media import via FilmCraft codecs (probe, frame cache, stills, sequences)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-01 14:28:14 -04:00
1 parent 808cd490cd
commit d867178137
8 files changed
+2347 -4

No files matched your search

Generated
+718 -4
View File
@@ -2,6 +2,12 @@
# It is not intended for manual editing.
version = 4
[[package]]
name = "adler2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -35,6 +41,18 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bit_field"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.13.2"
@@ -61,12 +79,39 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "core_detect"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
[[package]]
name = "crc32fast"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.8"
@@ -92,6 +137,12 @@ version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "effectcraft-color"
version = "0.1.0"
@@ -132,6 +183,25 @@ dependencies = [
"serde_json",
]
[[package]]
name = "effectcraft-media"
version = "0.1.0"
dependencies = [
"effectcraft-project",
"effectcraft-raster",
"effectcraft-render",
"effectcraft-time",
"filmcraft-codecs",
"filmcraft-color",
"filmcraft-frame",
"filmcraft-media",
"filmcraft-time",
"image",
"log",
"rayon",
"thiserror",
]
[[package]]
name = "effectcraft-path"
version = "0.1.0"
@@ -209,6 +279,20 @@ version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]]
name = "encoding_rs"
version = "0.8.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
dependencies = [
"cfg-if",
"core_detect",
"multiversion_no_op",
"rustversion",
"scopeguard",
"simdutf8",
]
[[package]]
name = "errno"
version = "0.3.14"
@@ -228,12 +312,220 @@ dependencies = [
"num-traits",
]
[[package]]
name = "exr"
version = "1.74.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3"
dependencies = [
"bit_field",
"half",
"lebe",
"miniz_oxide 0.8.9",
"num-complex",
"pulp",
"smallvec",
"zune-inflate",
]
[[package]]
name = "extended"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fax"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "filmcraft-aac"
version = "0.1.0"
source = "git+https://github.com/storytold/filmcraft?rev=89cfbf24f698b0f0e538eb7795929f19bbfb1dbb#89cfbf24f698b0f0e538eb7795929f19bbfb1dbb"
dependencies = [
"filmcraft-bitstream",
"thiserror",
]
[[package]]
name = "filmcraft-av1"
version = "0.1.0"
source = "git+https://github.com/storytold/filmcraft?rev=89cfbf24f698b0f0e538eb7795929f19bbfb1dbb#89cfbf24f698b0f0e538eb7795929f19bbfb1dbb"
dependencies = [
"filmcraft-bitstream",
"rayon",
"thiserror",
]
[[package]]
name = "filmcraft-bitstream"
version = "0.1.0"
source = "git+https://github.com/storytold/filmcraft?rev=89cfbf24f698b0f0e538eb7795929f19bbfb1dbb#89cfbf24f698b0f0e538eb7795929f19bbfb1dbb"
[[package]]
name = "filmcraft-codecs"
version = "0.1.0"
source = "git+https://github.com/storytold/filmcraft?rev=89cfbf24f698b0f0e538eb7795929f19bbfb1dbb#89cfbf24f698b0f0e538eb7795929f19bbfb1dbb"
dependencies = [
"filmcraft-aac",
"filmcraft-av1",
"filmcraft-bitstream",
"filmcraft-color",
"filmcraft-dnx",
"filmcraft-frame",
"filmcraft-h264",
"filmcraft-hevc",
"filmcraft-isobmff",
"filmcraft-matroska",
"filmcraft-media",
"filmcraft-opus",
"filmcraft-prores",
"filmcraft-time",
"filmcraft-vp9",
"image",
"log",
"symphonia",
"thiserror",
]
[[package]]
name = "filmcraft-color"
version = "0.1.0"
source = "git+https://github.com/storytold/filmcraft?rev=89cfbf24f698b0f0e538eb7795929f19bbfb1dbb#89cfbf24f698b0f0e538eb7795929f19bbfb1dbb"
dependencies = [
"serde",
]
[[package]]
name = "filmcraft-dnx"
version = "0.1.0"
source = "git+https://github.com/storytold/filmcraft?rev=89cfbf24f698b0f0e538eb7795929f19bbfb1dbb#89cfbf24f698b0f0e538eb7795929f19bbfb1dbb"
dependencies = [
"filmcraft-bitstream",
"rayon",
"thiserror",
]
[[package]]
name = "filmcraft-frame"
version = "0.1.0"
source = "git+https://github.com/storytold/filmcraft?rev=89cfbf24f698b0f0e538eb7795929f19bbfb1dbb#89cfbf24f698b0f0e538eb7795929f19bbfb1dbb"
dependencies = [
"filmcraft-color",
"filmcraft-time",
"rayon",
"serde",
]
[[package]]
name = "filmcraft-h264"
version = "0.1.0"
source = "git+https://github.com/storytold/filmcraft?rev=89cfbf24f698b0f0e538eb7795929f19bbfb1dbb#89cfbf24f698b0f0e538eb7795929f19bbfb1dbb"
dependencies = [
"filmcraft-bitstream",
"rayon",
"thiserror",
]
[[package]]
name = "filmcraft-hevc"
version = "0.1.0"
source = "git+https://github.com/storytold/filmcraft?rev=89cfbf24f698b0f0e538eb7795929f19bbfb1dbb#89cfbf24f698b0f0e538eb7795929f19bbfb1dbb"
dependencies = [
"filmcraft-bitstream",
"rayon",
"thiserror",
]
[[package]]
name = "filmcraft-isobmff"
version = "0.1.0"
source = "git+https://github.com/storytold/filmcraft?rev=89cfbf24f698b0f0e538eb7795929f19bbfb1dbb#89cfbf24f698b0f0e538eb7795929f19bbfb1dbb"
dependencies = [
"filmcraft-bitstream",
"thiserror",
]
[[package]]
name = "filmcraft-matroska"
version = "0.1.0"
source = "git+https://github.com/storytold/filmcraft?rev=89cfbf24f698b0f0e538eb7795929f19bbfb1dbb#89cfbf24f698b0f0e538eb7795929f19bbfb1dbb"
[[package]]
name = "filmcraft-media"
version = "0.1.0"
source = "git+https://github.com/storytold/filmcraft?rev=89cfbf24f698b0f0e538eb7795929f19bbfb1dbb#89cfbf24f698b0f0e538eb7795929f19bbfb1dbb"
dependencies = [
"filmcraft-color",
"filmcraft-frame",
"filmcraft-time",
"image",
"rayon",
"serde",
"thiserror",
]
[[package]]
name = "filmcraft-opus"
version = "0.1.0"
source = "git+https://github.com/storytold/filmcraft?rev=89cfbf24f698b0f0e538eb7795929f19bbfb1dbb#89cfbf24f698b0f0e538eb7795929f19bbfb1dbb"
[[package]]
name = "filmcraft-prores"
version = "0.1.0"
source = "git+https://github.com/storytold/filmcraft?rev=89cfbf24f698b0f0e538eb7795929f19bbfb1dbb#89cfbf24f698b0f0e538eb7795929f19bbfb1dbb"
dependencies = [
"filmcraft-bitstream",
"rayon",
"thiserror",
]
[[package]]
name = "filmcraft-time"
version = "0.1.0"
source = "git+https://github.com/storytold/filmcraft?rev=89cfbf24f698b0f0e538eb7795929f19bbfb1dbb#89cfbf24f698b0f0e538eb7795929f19bbfb1dbb"
dependencies = [
"serde",
]
[[package]]
name = "filmcraft-vp9"
version = "0.1.0"
source = "git+https://github.com/storytold/filmcraft?rev=89cfbf24f698b0f0e538eb7795929f19bbfb1dbb#89cfbf24f698b0f0e538eb7795929f19bbfb1dbb"
dependencies = [
"filmcraft-bitstream",
"rayon",
"thiserror",
]
[[package]]
name = "flate2"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide 0.9.1",
"zlib-rs",
]
[[package]]
name = "fnv"
version = "1.0.7"
@@ -272,18 +564,69 @@ dependencies = [
"r-efi 6.0.0",
]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "harfrust"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9"
dependencies = [
"bitflags",
"bitflags 2.13.2",
"bytemuck",
"read-fonts",
"smallvec",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"exr",
"gif",
"image-webp",
"moxcms",
"num-traits",
"png",
"tiff",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error 2.0.1",
]
[[package]]
name = "itoa"
version = "1.0.18"
@@ -303,12 +646,30 @@ dependencies = [
"smallvec",
]
[[package]]
name = "lazy_static"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
[[package]]
name = "lebe"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -327,6 +688,52 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"bytemuck",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -342,6 +749,25 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.13.2",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide 0.8.9",
]
[[package]]
name = "polycool"
version = "0.4.0"
@@ -377,7 +803,7 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
dependencies = [
"bit-set",
"bit-vec",
"bitflags",
"bitflags 2.13.2",
"num-traits",
"rand",
"rand_chacha",
@@ -388,12 +814,47 @@ dependencies = [
"unarray",
]
[[package]]
name = "pulp"
version = "0.22.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a"
dependencies = [
"bytemuck",
"cfg-if",
"libm",
"num-complex",
"paste",
"pulp-wasm-simd-flag",
"raw-cpuid",
"reborrow",
"version_check",
]
[[package]]
name = "pulp-wasm-simd-flag"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740"
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quick-error"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quote"
version = "1.0.47"
@@ -453,6 +914,15 @@ dependencies = [
"rand_core",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "rayon"
version = "1.12.0"
@@ -484,6 +954,12 @@ dependencies = [
"once_cell",
]
[[package]]
name = "reborrow"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430"
[[package]]
name = "regex-syntax"
version = "0.8.11"
@@ -496,13 +972,19 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
"bitflags",
"bitflags 2.13.2",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "rusty-fork"
version = "0.3.1"
@@ -510,11 +992,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
dependencies = [
"fnv",
"quick-error",
"quick-error 1.2.3",
"tempfile",
"wait-timeout",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serde"
version = "1.0.229"
@@ -558,6 +1046,18 @@ dependencies = [
"zmij",
]
[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "simdutf8"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "skrifa"
version = "0.44.0"
@@ -583,6 +1083,164 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "symphonia"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
dependencies = [
"lazy_static",
"symphonia-bundle-flac",
"symphonia-bundle-mp3",
"symphonia-codec-aac",
"symphonia-codec-alac",
"symphonia-codec-pcm",
"symphonia-codec-vorbis",
"symphonia-core",
"symphonia-format-isomp4",
"symphonia-format-ogg",
"symphonia-format-riff",
"symphonia-metadata",
]
[[package]]
name = "symphonia-bundle-flac"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976"
dependencies = [
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-bundle-mp3"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-codec-aac"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790"
dependencies = [
"lazy_static",
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-alac"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab"
dependencies = [
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-pcm"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95"
dependencies = [
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-codec-vorbis"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73"
dependencies = [
"log",
"symphonia-core",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-core"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
dependencies = [
"arrayvec",
"bitflags 1.3.2",
"bytemuck",
"lazy_static",
"log",
]
[[package]]
name = "symphonia-format-isomp4"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5"
dependencies = [
"encoding_rs",
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-format-ogg"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb"
dependencies = [
"log",
"symphonia-core",
"symphonia-metadata",
"symphonia-utils-xiph",
]
[[package]]
name = "symphonia-format-riff"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f"
dependencies = [
"extended",
"log",
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "symphonia-metadata"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
dependencies = [
"encoding_rs",
"lazy_static",
"log",
"symphonia-core",
]
[[package]]
name = "symphonia-utils-xiph"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
dependencies = [
"symphonia-core",
"symphonia-metadata",
]
[[package]]
name = "syn"
version = "2.0.119"
@@ -638,6 +1296,20 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "tiff"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
dependencies = [
"fax",
"flate2",
"half",
"quick-error 2.0.1",
"weezl",
"zune-jpeg",
]
[[package]]
name = "tiny-skia"
version = "0.12.0"
@@ -687,6 +1359,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wait-timeout"
version = "0.2.1"
@@ -705,6 +1383,12 @@ dependencies = [
"wit-bindgen",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "windows-link"
version = "0.2.1"
@@ -746,8 +1430,38 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zune-core"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
[[package]]
name = "zune-inflate"
version = "0.2.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
dependencies = [
"simd-adler32",
]
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
+45
View File
@@ -22,6 +22,7 @@ effectcraft-project = { path = "crates/project" }
effectcraft-text = { path = "crates/text" }
effectcraft-effects = { path = "crates/effects" }
effectcraft-render = { path = "crates/render" }
effectcraft-media = { path = "crates/media" }
effectcraft-engine = { path = "crates/engine" }
effectcraft-ui-egui = { path = "crates/ui-egui" }
effectcraft-automation = { path = "crates/automation" }
@@ -41,12 +42,28 @@ skrifa = { version = "0.44", default-features = false, features = ["std"] }
harfrust = { version = "0.12", default-features = false, features = ["std"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
# media codecs + containers: FilmCraft's pure-Rust crates, pinned to a revision (plan/adr/0001).
# Only `crates/media` (and later `crates/export`) may depend on these.
filmcraft-codecs = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" }
filmcraft-media = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" }
filmcraft-frame = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" }
filmcraft-color = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" }
filmcraft-time = { git = "https://github.com/storytold/filmcraft", rev = "89cfbf24f698b0f0e538eb7795929f19bbfb1dbb" }
# ui
egui = "0.36"
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
rfd = "0.17"
# Local co-development against a FilmCraft checkout next to this repo: uncomment.
# [patch."https://github.com/storytold/filmcraft"]
# filmcraft-codecs = { path = "../filmcraft/crates/codecs" }
# filmcraft-media = { path = "../filmcraft/crates/media" }
# filmcraft-frame = { path = "../filmcraft/crates/frame" }
# filmcraft-color = { path = "../filmcraft/crates/color" }
# filmcraft-time = { path = "../filmcraft/crates/time" }
[workspace.lints.rust]
unsafe_code = "deny"
@@ -76,6 +93,34 @@ opt-level = 3
[profile.dev.package.effectcraft-path]
opt-level = 3
[profile.dev.package.effectcraft-media]
opt-level = 3
# FilmCraft codec hot loops (same settings as FilmCraft's own workspace).
[profile.dev.package.filmcraft-h264]
opt-level = 3
[profile.dev.package.filmcraft-hevc]
opt-level = 3
[profile.dev.package.filmcraft-vp9]
opt-level = 3
[profile.dev.package.filmcraft-prores]
opt-level = 3
[profile.dev.package.filmcraft-dnx]
opt-level = 3
[profile.dev.package.filmcraft-av1]
opt-level = 3
[profile.dev.package.filmcraft-aac]
opt-level = 3
[profile.dev.package.filmcraft-opus]
opt-level = 3
[profile.release]
lto = "thin"
codegen-units = 1
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "effectcraft-media"
description = "Footage import: probing, stills, image sequences, video and audio via FilmCraft's pure-Rust codecs, memory-budgeted frame cache"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
[lints]
workspace = true
[dependencies]
effectcraft-time = { workspace = true }
effectcraft-project = { workspace = true }
effectcraft-raster = { workspace = true }
effectcraft-render = { workspace = true }
filmcraft-codecs = { workspace = true }
filmcraft-media = { workspace = true }
filmcraft-frame = { workspace = true }
filmcraft-color = { workspace = true }
filmcraft-time = { workspace = true }
image = { workspace = true, features = ["gif", "webp", "tiff", "bmp", "exr"] }
rayon = { workspace = true }
thiserror = { workspace = true }
log = { workspace = true }
+378
View File
@@ -0,0 +1,378 @@
//! Decoded pictures → compositor [`Image`]s (premultiplied f32 RGBA, sRGB/Rec.709-encoded values).
//!
//! Y'CbCr is converted with the matrix and range the stream signals (BT.601 / BT.709 / BT.2020,
//! limited or full), upsampling chroma bilinearly with centred siting (like ffmpeg's swscale,
//! which is also how most tools subsample when encoding).
//! Values stay gamma-encoded: like After Effects, footage pixels enter the working space as they
//! are encoded in the file. Alpha follows the footage's [`AlphaMode`] interpretation.
use effectcraft_project::AlphaMode;
use effectcraft_raster::{Image, Px};
use filmcraft_color::{Matrix, Range};
use filmcraft_frame::{Chroma, PixelData, VideoFrame};
use rayon::prelude::*;
/// How to turn a (colour, alpha) sample into a premultiplied pixel.
#[derive(Clone, Copy)]
pub(crate) struct AlphaOp {
mode: AlphaMode,
matte: [f32; 3],
}
impl AlphaOp {
pub(crate) fn new(mode: AlphaMode, matte: [f32; 3]) -> Self {
Self { mode, matte }
}
/// `c` is the file's colour (straight or premultiplied as the mode says), `a` its alpha.
#[inline(always)]
fn apply(self, c: [f32; 3], a: f32) -> Px {
match self.mode {
AlphaMode::Ignore => [c[0], c[1], c[2], 1.0],
AlphaMode::Straight => [c[0] * a, c[1] * a, c[2] * a, a],
AlphaMode::Premultiplied => {
// premultiplied against a matte colour m: c = s·a + m·(1−a) → s·a = c − m·(1−a)
let k = 1.0 - a;
let m = self.matte;
[(c[0] - m[0] * k).max(0.0), (c[1] - m[1] * k).max(0.0), (c[2] - m[2] * k).max(0.0), a]
}
}
}
}
/// Convert a FilmCraft frame to an [`Image`].
#[cfg(test)]
pub(crate) fn frame_to_image(f: &VideoFrame, op: AlphaOp) -> Image {
frame_to_image_in(f, op, Vec::new())
}
/// Convert a FilmCraft frame to an [`Image`], reusing `buf`'s allocation when it is large enough
/// (every pixel is overwritten, so a recycled frame's contents do not matter).
pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec<Px>) -> Image {
let (w, h) = (f.width as usize, f.height as usize);
if buf.len() != w * h {
buf = vec![[0.0; 4]; w * h];
}
let mut img = Image { width: f.width, height: f.height, data: buf };
if w == 0 || h == 0 {
return img;
}
match &f.data {
PixelData::Rgba8(d) => {
img.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
let src = &d[y * w * 4..(y + 1) * w * 4];
for (o, s) in row.iter_mut().zip(src.chunks_exact(4)) {
*o = op.apply([U8[s[0] as usize], U8[s[1] as usize], U8[s[2] as usize]], U8[s[3] as usize]);
}
});
}
PixelData::RgbaF32(d) => {
// linear premultiplied → encoded premultiplied
img.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
let src = &d[y * w * 4..(y + 1) * w * 4];
for (o, s) in row.iter_mut().zip(src.chunks_exact(4)) {
let a = s[3];
let inv = if a > 0.0 { 1.0 / a } else { 0.0 };
let c = [encode(s[0] * inv) * a, encode(s[1] * inv) * a, encode(s[2] * inv) * a];
*o = if op.mode == AlphaMode::Ignore { [c[0], c[1], c[2], 1.0] } else { [c[0], c[1], c[2], a] };
}
});
}
PixelData::Yuv8 { planes, chroma, alpha } => {
let yuv = Yuv::new(f.color.matrix, f.color.range, 8);
let amax = 255.0;
convert_yuv(&mut img, w, h, *chroma, [&planes[0][..], &planes[1][..], &planes[2][..]], alpha.as_deref().map(|a| &a[..]), amax, &yuv, op);
}
PixelData::Yuv16 { planes, chroma, bits, alpha } => {
let yuv = Yuv::new(f.color.matrix, f.color.range, *bits);
let amax = ((1u32 << *bits) - 1) as f32;
convert_yuv(&mut img, w, h, *chroma, [&planes[0][..], &planes[1][..], &planes[2][..]], alpha.as_deref().map(|a| &a[..]), amax, &yuv, op);
}
}
img
}
/// 0..255 → 0..1.
static U8: [f32; 256] = {
let mut t = [0f32; 256];
let mut i = 0;
while i < 256 {
t[i] = i as f32 / 255.0;
i += 1;
}
t
};
/// Linear → sRGB-encoded (sign-preserving, unclamped above 1 for float footage).
#[inline]
fn encode(v: f32) -> f32 {
if v < 0.0 {
-encode(-v)
} else if v <= 0.003_130_8 {
v * 12.92
} else {
1.055 * v.powf(1.0 / 2.4) - 0.055
}
}
/// Integer code values → normalised Y'CbCr and the matrix coefficients.
struct Yuv {
y_scale: f32,
y_off: f32,
c_scale: f32,
c_off: f32,
cr_r: f32,
cr_g: f32,
cb_g: f32,
cb_b: f32,
}
impl Yuv {
fn new(m: Matrix, range: Range, bits: u32) -> Self {
let s = (1u32 << (bits - 8)) as f32;
let (y_scale, y_off, c_scale, c_off) = match range {
Range::Limited => (1.0 / (219.0 * s), 16.0 * s, 1.0 / (224.0 * s), 128.0 * s),
Range::Full => {
let max = ((1u32 << bits) - 1) as f32;
(1.0 / max, 0.0, 1.0 / max, (1u32 << (bits - 1)) as f32)
}
};
let (kr, kb) = m.kr_kb();
let kg = 1.0 - kr - kb;
let cr_r = 2.0 * (1.0 - kr);
let cb_b = 2.0 * (1.0 - kb);
Self { y_scale, y_off, c_scale, c_off, cr_r, cr_g: cr_r * kr / kg, cb_g: cb_b * kb / kg, cb_b }
}
}
trait Sample: Copy + Send + Sync {
fn f(self) -> f32;
}
impl Sample for u8 {
#[inline(always)]
fn f(self) -> f32 {
self as f32
}
}
impl Sample for u16 {
#[inline(always)]
fn f(self) -> f32 {
self as f32
}
}
/// Centred chroma siting (as ffmpeg's swscale and JPEG): for a subsampled axis, full-resolution
/// sample `i` lies ¼ of a chroma sample from chroma sample `i/2` towards its neighbour on that
/// side. Returns (nearest chroma index, neighbour index); the weights are ¾ and ¼.
#[inline(always)]
fn centred(i: usize, n: usize) -> (usize, usize) {
let c = (i >> 1).min(n - 1);
let o = if i & 1 == 0 { c.saturating_sub(1) } else { (c + 1).min(n - 1) };
(c, o)
}
#[allow(clippy::too_many_arguments)]
fn convert_yuv<T: Sample>(img: &mut Image, w: usize, h: usize, chroma: Chroma, planes: [&[T]; 3], alpha: Option<&[T]>, amax: f32, k: &Yuv, op: AlphaOp) {
let (sx, sy) = chroma.shifts();
let cw = w.div_ceil(1 << sx);
let ch = h.div_ceil(1 << sy);
let inv_amax = 1.0 / amax;
img.data.par_chunks_mut(w).enumerate().for_each_init(
|| (vec![0f32; cw], vec![0f32; cw], vec![0f32; w], vec![0f32; w]),
|(uc, vc, uf, vf), (y, row)| {
// vertical chroma interpolation into normalised rows (Cb, Cr in −0.5..0.5)
let (r0, r1, t) = if sy == 1 {
let (c, o) = centred(y, ch);
(c, o, 0.25)
} else {
(y, y, 0.0)
};
for (dst, plane) in [(&mut *uc, planes[1]), (&mut *vc, planes[2])] {
let a = &plane[r0 * cw..r0 * cw + cw];
let b = &plane[r1 * cw..r1 * cw + cw];
for ((d, a), b) in dst.iter_mut().zip(a).zip(b) {
let v = a.f() * (1.0 - t) + b.f() * t;
*d = (v - k.c_off) * k.c_scale;
}
}
// horizontal
if sx == 1 {
for x in 0..w {
let (c, o) = centred(x, cw);
uf[x] = uc[c] * 0.75 + uc[o] * 0.25;
vf[x] = vc[c] * 0.75 + vc[o] * 0.25;
}
} else {
uf.copy_from_slice(&uc[..w]);
vf.copy_from_slice(&vc[..w]);
}
let yrow = &planes[0][y * w..y * w + w];
let rgb = |x: usize| {
let (u, v) = (uf[x], vf[x]);
let yy = (yrow[x].f() - k.y_off) * k.y_scale;
[(yy + k.cr_r * v).clamp(0.0, 1.0), (yy - k.cr_g * v - k.cb_g * u).clamp(0.0, 1.0), (yy + k.cb_b * u).clamp(0.0, 1.0)]
};
match alpha {
// opaque: every alpha interpretation gives the colour itself
None => {
for (x, o) in row.iter_mut().enumerate() {
let c = rgb(x);
*o = [c[0], c[1], c[2], 1.0];
}
}
Some(al) => {
let arow = &al[y * w..y * w + w];
for (x, o) in row.iter_mut().enumerate() {
*o = op.apply(rgb(x), (arow[x].f() * inv_amax).clamp(0.0, 1.0));
}
}
}
},
);
}
/// Convert a still decoded by the `image` crate. Float images (OpenEXR, float TIFF) hold linear
/// light and are sRGB-encoded here (values above 1 are kept).
pub(crate) fn dynamic_to_image(img: &image::DynamicImage, op: AlphaOp) -> Image {
use image::DynamicImage as D;
let (w, h) = (img.width(), img.height());
let mut out = Image::new(w, h);
let wu = w as usize;
if wu == 0 || h == 0 {
return out;
}
match img {
D::ImageRgb32F(_) | D::ImageRgba32F(_) => {
let px = img.to_rgba32f();
let raw = px.as_raw();
let premul = op.mode == AlphaMode::Premultiplied;
out.data.par_chunks_mut(wu).enumerate().for_each(|(y, row)| {
for (x, o) in row.iter_mut().enumerate() {
let s = &raw[(y * wu + x) * 4..(y * wu + x) * 4 + 4];
let a = s[3];
let c = if premul {
let inv = if a > 0.0 { 1.0 / a } else { 0.0 };
[encode(s[0] * inv) * a, encode(s[1] * inv) * a, encode(s[2] * inv) * a]
} else {
[encode(s[0]), encode(s[1]), encode(s[2])]
};
*o = op.apply(c, a.clamp(0.0, 1.0));
}
});
}
D::ImageLuma16(_) | D::ImageLumaA16(_) | D::ImageRgb16(_) | D::ImageRgba16(_) => {
let px = img.to_rgba16();
let raw = px.as_raw();
let k = 1.0 / 65535.0;
out.data.par_chunks_mut(wu).enumerate().for_each(|(y, row)| {
for (x, o) in row.iter_mut().enumerate() {
let s = &raw[(y * wu + x) * 4..(y * wu + x) * 4 + 4];
*o = op.apply([s[0] as f32 * k, s[1] as f32 * k, s[2] as f32 * k], s[3] as f32 * k);
}
});
}
_ => {
let px = img.to_rgba8();
let raw = px.as_raw();
out.data.par_chunks_mut(wu).enumerate().for_each(|(y, row)| {
for (x, o) in row.iter_mut().enumerate() {
let s = &raw[(y * wu + x) * 4..(y * wu + x) * 4 + 4];
*o = op.apply([U8[s[0] as usize], U8[s[1] as usize], U8[s[2] as usize]], U8[s[3] as usize]);
}
});
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
fn yuv_frame(y: u8, u: u8, v: u8, color: filmcraft_color::ColorInfo) -> VideoFrame {
VideoFrame {
width: 4,
height: 2,
data: PixelData::Yuv8 { planes: [Arc::new(vec![y; 8]), Arc::new(vec![u; 2]), Arc::new(vec![v; 2])], chroma: Chroma::C420, alpha: None },
color,
par: (1, 1),
pts: filmcraft_time::Tick::ZERO,
}
}
#[test]
fn limited_range_grey_and_white() {
let op = AlphaOp::new(AlphaMode::Straight, [0.0; 3]);
let img = frame_to_image(&yuv_frame(235, 128, 128, filmcraft_color::ColorInfo::REC709), op);
assert!(img.data.iter().all(|p| (p[0] - 1.0).abs() < 1e-5 && (p[1] - 1.0).abs() < 1e-5 && p[3] == 1.0));
let img = frame_to_image(&yuv_frame(126, 128, 128, filmcraft_color::ColorInfo::REC709), op);
assert!((img.data[0][0] - 110.0 / 219.0).abs() < 1e-5);
}
#[test]
fn bt709_red() {
// 8-bit limited BT.709 red: Y 63, Cb 102, Cr 240
let op = AlphaOp::new(AlphaMode::Straight, [0.0; 3]);
let p = frame_to_image(&yuv_frame(63, 102, 240, filmcraft_color::ColorInfo::REC709), op).data[0];
assert!(p[0] > 0.98 && p[1] < 0.02 && p[2] < 0.02, "{p:?}");
}
#[test]
fn alpha_modes() {
let c = [0.8, 0.4, 0.2];
let s = AlphaOp::new(AlphaMode::Straight, [0.0; 3]).apply(c, 0.5);
assert_eq!(s, [0.4, 0.2, 0.1, 0.5]);
let p = AlphaOp::new(AlphaMode::Premultiplied, [0.0; 3]).apply([0.4, 0.2, 0.1], 0.5);
assert_eq!(p, [0.4, 0.2, 0.1, 0.5]);
// premultiplied against white: c = s·a + 1·(1−a)
let p = AlphaOp::new(AlphaMode::Premultiplied, [1.0; 3]).apply([0.9, 0.7, 0.6], 0.5);
assert!((p[0] - 0.4).abs() < 1e-6 && (p[2] - 0.1).abs() < 1e-6);
let i = AlphaOp::new(AlphaMode::Ignore, [0.0; 3]).apply(c, 0.0);
assert_eq!(i, [0.8, 0.4, 0.2, 1.0]);
}
#[test]
fn float_stills_are_encoded() {
let img = image::DynamicImage::ImageRgba32F(image::ImageBuffer::from_raw(1, 1, vec![0.2140, 1.0, 4.0, 1.0]).expect("buf"));
let p = dynamic_to_image(&img, AlphaOp::new(AlphaMode::Premultiplied, [0.0; 3])).data[0];
assert!((p[0] - 0.5).abs() < 1e-3 && (p[1] - 1.0).abs() < 1e-5 && p[2] > 1.5, "{p:?}");
}
/// `cargo test -p effectcraft-media --lib bench -- --ignored --nocapture`
#[test]
#[ignore]
fn bench_convert_1080p() {
let (w, h) = (1920usize, 1080usize);
let f = VideoFrame {
width: w as u32,
height: h as u32,
data: PixelData::Yuv8 {
planes: [Arc::new((0..w * h).map(|i| (i % 251) as u8).collect()), Arc::new(vec![100; w * h / 4]), Arc::new(vec![150; w * h / 4])],
chroma: Chroma::C420,
alpha: None,
},
color: filmcraft_color::ColorInfo::REC709,
par: (1, 1),
pts: filmcraft_time::Tick::ZERO,
};
let op = AlphaOp::new(AlphaMode::Straight, [0.0; 3]);
for _ in 0..3 {
let t = std::time::Instant::now();
for _ in 0..20 {
std::hint::black_box(Image::new(1920, 1080));
}
eprintln!("alloc {:?}", t.elapsed() / 20);
let t = std::time::Instant::now();
for _ in 0..20 {
std::hint::black_box(frame_to_image(&f, op));
}
eprintln!("convert {:?}", t.elapsed() / 20);
let mut keep = Vec::new();
let t = std::time::Instant::now();
for _ in 0..20 {
keep.push(frame_to_image(&f, op));
}
eprintln!("convert into fresh memory {:?}", t.elapsed() / 20);
}
}
}
+65
View File
@@ -0,0 +1,65 @@
//! Footage import for EffectCraft (layer **L3**: it implements `effectcraft_render::FootageSource`).
//!
//! - [`probe`] / [`probe_bytes`]: inspect a file and describe it as an [`effectcraft_project::Footage`]
//! (video, still, image sequence or audio; size, rate, duration, streams, codec, alpha).
//! - [`MediaPool`]: decodes footage frames on demand into [`effectcraft_raster::Image`]s
//! (premultiplied f32, sRGB/Rec.709-encoded values, as the compositor expects) with a
//! memory-budgeted LRU frame cache, one decoder per source, and the decoder kept positioned for
//! sequential playback. Also audio ([`MediaPool::audio_samples`]) and thumbnails.
//!
//! Video containers (MP4/MOV, Matroska/WebM) and codecs (H.264, HEVC, VP9, AV1, ProRes, DNxHD,
//! MJPEG; AAC, Opus, PCM, MP3/FLAC/Vorbis) come from FilmCraft's pure-Rust crates (git dependency,
//! pinned; `plan/adr/0001`). FilmCraft types never leave this crate. Stills use the `image` crate
//! (PNG, JPEG, GIF, WebP, TIFF, BMP, OpenEXR).
mod convert;
mod pool;
mod probe;
pub use pool::{DEFAULT_BUDGET, MediaPool, PoolStats};
pub use probe::{DEFAULT_SEQUENCE_RATE, probe, probe_bytes, probe_single, sequence_files};
/// Errors from probing or decoding footage.
#[derive(Debug, thiserror::Error)]
pub enum MediaError {
#[error("I/O error: {0}")]
Io(String),
#[error("unsupported media: {0}")]
Unsupported(String),
#[error("decode error: {0}")]
Decode(String),
}
pub type Result<T> = std::result::Result<T, MediaError>;
impl From<filmcraft_media::MediaError> for MediaError {
fn from(e: filmcraft_media::MediaError) -> Self {
use filmcraft_media::MediaError as F;
match e {
F::Unsupported(s) => MediaError::Unsupported(s),
F::Io(s) | F::Offline(s) => MediaError::Io(s),
other => MediaError::Decode(other.to_string()),
}
}
}
/// File extensions recognised as stills.
pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr"];
/// File extensions recognised as audio-only files.
pub const AUDIO_EXTENSIONS: &[&str] = filmcraft_media::AUDIO_EXTENSIONS;
/// File extensions recognised as movies.
pub const VIDEO_EXTENSIONS: &[&str] = &["mp4", "m4v", "mov", "mkv", "webm"];
/// Whether `path` has an extension we can import.
pub fn is_importable(path: &std::path::Path) -> bool {
let ext = ext_of(path);
STILL_EXTENSIONS.contains(&ext.as_str()) || AUDIO_EXTENSIONS.contains(&ext.as_str()) || VIDEO_EXTENSIONS.contains(&ext.as_str())
}
pub(crate) fn ext_of(path: &std::path::Path) -> String {
path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase()
}
pub(crate) fn rate_from_fc(r: filmcraft_time::FrameRate) -> effectcraft_time::FrameRate {
if r.num > 0 && r.den > 0 { effectcraft_time::FrameRate::new(r.num, r.den) } else { effectcraft_time::FrameRate::FPS_30 }
}
+497
View File
@@ -0,0 +1,497 @@
//! [`MediaPool`]: decoded footage frames for the compositor, with a memory-budgeted LRU cache.
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Condvar, Mutex};
use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemId};
use effectcraft_raster::{Image, Px};
use effectcraft_render::FootageSource;
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
use filmcraft_media::{FrameRequest, SharedSource};
use crate::convert::{AlphaOp, dynamic_to_image, frame_to_image_in};
use crate::{MediaError, Result};
/// Default frame-cache budget: 1 GiB of decoded frames.
pub const DEFAULT_BUDGET: usize = 1 << 30;
#[derive(Clone, PartialEq, Eq, Hash)]
struct Key {
path: Arc<str>,
frame: i64,
alpha: u8,
matte: [u32; 3],
}
struct Entry {
img: Arc<Image>,
stamp: u64,
bytes: usize,
}
/// At most this many recycled pixel buffers are kept (outside the budget).
const MAX_SPARE: usize = 4;
/// Least-recently-used frames, bounded by bytes.
#[derive(Default)]
struct Lru {
map: HashMap<Key, Entry>,
order: BTreeMap<u64, Key>,
clock: u64,
bytes: usize,
/// Pixel buffers of evicted frames nobody else holds, reused for new frames: playback then
/// writes into memory that is already mapped instead of faulting in fresh pages every frame.
spare: Vec<Vec<Px>>,
}
impl Lru {
fn get(&mut self, k: &Key) -> Option<Arc<Image>> {
let e = self.map.get_mut(k)?;
self.order.remove(&e.stamp);
self.clock += 1;
e.stamp = self.clock;
self.order.insert(e.stamp, k.clone());
Some(e.img.clone())
}
fn insert(&mut self, k: Key, img: Arc<Image>, budget: usize) {
let bytes = img.data.len() * std::mem::size_of::<Px>() + 64;
self.clock += 1;
if let Some(old) = self.map.insert(k.clone(), Entry { img, stamp: self.clock, bytes }) {
self.order.remove(&old.stamp);
self.bytes -= old.bytes;
}
self.order.insert(self.clock, k);
self.bytes += bytes;
self.evict(budget);
}
fn remove(&mut self, k: &Key) {
if let Some(e) = self.map.remove(k) {
self.order.remove(&e.stamp);
self.bytes -= e.bytes;
}
}
/// Evict least-recently-used frames until within `budget` (the newest frame always stays).
fn evict(&mut self, budget: usize) {
while self.bytes > budget && self.map.len() > 1 {
let Some((_, k)) = self.order.pop_first() else { break };
if let Some(e) = self.map.remove(&k) {
self.bytes -= e.bytes;
if self.spare.len() < MAX_SPARE
&& let Ok(img) = Arc::try_unwrap(e.img)
{
self.spare.push(img.data);
}
}
}
}
/// A recycled buffer of exactly `len` pixels, or an empty Vec.
fn take_spare(&mut self, len: usize) -> Vec<Px> {
match self.spare.iter().position(|b| b.len() == len) {
Some(i) => self.spare.swap_remove(i),
None => Vec::new(),
}
}
}
/// Cache counters (see [`MediaPool::stats`]).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct PoolStats {
/// Requests answered from the frame cache.
pub hits: u64,
/// Requests that had to decode (or wait for a read-ahead decode).
pub misses: u64,
/// Frames decoded ahead of sequential playback.
pub prefetched: u64,
/// Frames currently cached and their total size in bytes.
pub frames: usize,
pub bytes: usize,
}
#[derive(Default)]
struct CacheState {
lru: Lru,
/// Frames being decoded right now (requests for them wait instead of decoding again).
inflight: HashSet<Key>,
/// Last frame index requested per movie (sequential-playback detection).
last: HashMap<Arc<str>, i64>,
}
struct Inner {
/// Opened movie/audio sources by path (`None`: failed to open; not retried until `forget`).
sources: Mutex<HashMap<Arc<str>, Option<SharedSource>>>,
/// In-memory files registered with `add_bytes` (web builds, tests).
files: Mutex<HashMap<String, Arc<[u8]>>>,
cache: Mutex<CacheState>,
done: Condvar,
budget: AtomicU64,
read_ahead: AtomicBool,
hits: AtomicU64,
misses: AtomicU64,
prefetched: AtomicU64,
}
/// Decodes footage for the renderer: stills, image sequences and movies (FilmCraft codecs), plus
/// audio. Thread-safe and cheap to clone (clones share decoders and cache).
///
/// - One decoder per movie: FilmCraft's GOP cache keeps it positioned, so sequential frames
/// decode without re-seeking, and a seek decodes forward from the preceding sync sample.
/// - Converted frames live in an LRU cache bounded by a byte budget ([`DEFAULT_BUDGET`]).
/// - Sequential playback reads one frame ahead on a worker thread (native builds), so decoding
/// and conversion of the next frame overlap with the caller's use of the current one.
/// - Concurrent requests for the same frame decode it once.
#[derive(Clone)]
pub struct MediaPool {
inner: Arc<Inner>,
}
impl Default for MediaPool {
fn default() -> Self {
Self::new()
}
}
/// Where a frame comes from.
struct Loc {
key: Key,
/// Media time for movies (`None`: an image file, `key.path`).
media_t: Option<Tick>,
}
/// Removes an in-flight key and wakes waiters, also when decoding panics.
struct Inflight<'a> {
inner: &'a Inner,
key: Key,
}
impl Drop for Inflight<'_> {
fn drop(&mut self) {
lock(&self.inner.cache).inflight.remove(&self.key);
self.inner.done.notify_all();
}
}
impl MediaPool {
/// A pool with the [`DEFAULT_BUDGET`] frame cache.
pub fn new() -> Self {
Self::with_budget(DEFAULT_BUDGET)
}
/// A pool whose frame cache holds at most `bytes` of decoded frames.
pub fn with_budget(bytes: usize) -> Self {
Self {
inner: Arc::new(Inner {
sources: Mutex::default(),
files: Mutex::default(),
cache: Mutex::default(),
done: Condvar::new(),
budget: AtomicU64::new(bytes as u64),
read_ahead: AtomicBool::new(cfg!(not(target_arch = "wasm32"))),
hits: AtomicU64::new(0),
misses: AtomicU64::new(0),
prefetched: AtomicU64::new(0),
}),
}
}
pub fn budget(&self) -> usize {
self.inner.budget()
}
/// Change the frame-cache budget (evicts immediately when shrinking).
pub fn set_budget(&self, bytes: usize) {
self.inner.budget.store(bytes as u64, Ordering::Relaxed);
lock(&self.inner.cache).lru.evict(bytes);
}
/// Enable or disable reading one frame ahead during sequential playback (on by default on
/// native builds; ignored on wasm32).
pub fn set_read_ahead(&self, on: bool) {
self.inner.read_ahead.store(on && cfg!(not(target_arch = "wasm32")), Ordering::Relaxed);
}
pub fn stats(&self) -> PoolStats {
let i = &self.inner;
let c = lock(&i.cache);
PoolStats {
hits: i.hits.load(Ordering::Relaxed),
misses: i.misses.load(Ordering::Relaxed),
prefetched: i.prefetched.load(Ordering::Relaxed),
frames: c.lru.map.len(),
bytes: c.lru.bytes,
}
}
/// Drop every cached frame and decoder.
pub fn clear(&self) {
let mut c = lock(&self.inner.cache);
c.lru = Lru::default();
c.last.clear();
drop(c);
lock(&self.inner.sources).clear();
}
/// Forget a file (after it changed on disk, or to retry a file that failed to open).
pub fn forget(&self, path: &str) {
lock(&self.inner.sources).remove(path);
let mut c = lock(&self.inner.cache);
let keys: Vec<Key> = c.lru.map.keys().filter(|k| &*k.path == path).cloned().collect();
for k in keys {
c.lru.remove(&k);
}
c.last.remove(path);
}
/// Provide the contents of `path` from memory (used instead of the file system; web builds).
pub fn add_bytes(&self, path: &str, bytes: Arc<[u8]>) {
lock(&self.inner.files).insert(path.to_string(), bytes);
self.forget(path);
}
/// Decode (or fetch from cache) the frame of `footage` at source time `t`.
pub fn frame_at(&self, footage: &Footage, t: Tick) -> Result<Arc<Image>> {
Inner::frame_at(&self.inner, footage, t, true)
}
/// `frames` stereo sample frames of `footage`'s audio starting at source time `start`, at
/// `rate` Hz, interleaved (L R L R …). Mono is duplicated to both channels; channels beyond
/// the first two are dropped. Silence where there is no audio (or before the media starts).
pub fn audio_samples(&self, footage: &Footage, start: Tick, frames: usize, rate: u32) -> Vec<f32> {
let mut out = vec![0.0; frames * 2];
if !footage.has_audio || footage.missing || rate == 0 || frames == 0 {
return out;
}
let path: Arc<str> = footage.path.as_str().into();
let Some(src) = self.inner.source(&path) else { return out };
let s0 = start.to_units_floor(rate as i64);
let skip = (-s0).max(0) as usize;
if skip >= frames {
return out;
}
let buf = match src.audio(s0.max(0), frames - skip, rate) {
Ok(b) => b,
Err(e) => {
log::warn!("media: audio of {path}: {e}");
return out;
}
};
let chans = buf.channels.len();
if chans == 0 {
return out;
}
let (l, r) = (&buf.channels[0], &buf.channels[if chans > 1 { 1 } else { 0 }]);
for i in 0..(frames - skip).min(l.len()).min(r.len()) {
out[(skip + i) * 2] = l[i];
out[(skip + i) * 2 + 1] = r[i];
}
out
}
/// A thumbnail of `footage` (its first frame) fitting in `max_side` × `max_side`, aspect kept.
pub fn thumbnail(&self, footage: &Footage, max_side: u32) -> Result<Image> {
let img = Inner::frame_at(&self.inner, footage, Tick::ZERO, false)?;
let (w, h) = (img.width.max(1), img.height.max(1));
let s = (max_side.max(1) as f64 / w.max(h) as f64).min(1.0);
if s >= 1.0 {
return Ok((*img).clone());
}
let (tw, th) = (((w as f64 * s).round() as u32).max(1), ((h as f64 * s).round() as u32).max(1));
Ok(effectcraft_raster::resample(&img, tw, th))
}
}
impl Inner {
fn budget(&self) -> usize {
self.budget.load(Ordering::Relaxed) as usize
}
fn read(&self, path: &str) -> Result<Arc<[u8]>> {
if let Some(b) = lock(&self.files).get(path) {
return Ok(b.clone());
}
std::fs::read(path).map(Into::into).map_err(|e| MediaError::Io(format!("{path}: {e}")))
}
/// The opened movie/audio source for `path`.
fn source(&self, path: &Arc<str>) -> Option<SharedSource> {
if let Some(s) = lock(&self.sources).get(path) {
return s.clone();
}
// open outside the lock (reading and parsing a large file takes a moment)
let opened = self.read(path).and_then(|bytes| {
let name = std::path::Path::new(&**path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
filmcraft_codecs::open_bytes(&name, bytes).map_err(MediaError::from)
});
let opened = match opened {
Ok(s) => Some(s),
Err(e) => {
log::warn!("media: cannot open {path}: {e}");
None
}
};
lock(&self.sources).entry(path.clone()).or_insert(opened).clone()
}
/// The frame index (in the footage's interpreted rate) shown at source time `t`, after
/// looping (`loop_count`) and clamping to the footage's frames; `n` = frames per loop.
fn frame_index(rate: FrameRate, t: Tick, n: i64, loops: u32) -> i64 {
let n = n.max(1);
let total = n.saturating_mul(loops.max(1) as i64);
rate.frame_at(t).clamp(0, total - 1) % n
}
/// Number of frames in `d` at `rate` (rounded).
fn frame_count(rate: FrameRate, d: Tick) -> i64 {
let den = TICKS_PER_SECOND as i128 * rate.den as i128;
((d.0 as i128 * rate.num as i128 + den / 2) / den) as i64
}
fn locate(footage: &Footage, t: Tick) -> Loc {
let alpha = match footage.alpha {
AlphaMode::Straight => 0,
AlphaMode::Premultiplied => 1,
AlphaMode::Ignore => 2,
};
let matte = if footage.alpha == AlphaMode::Premultiplied { footage.premul_color.map(f32::to_bits) } else { [0; 3] };
let key = |path: &str, frame| Key { path: path.into(), frame, alpha, matte };
match footage.kind {
FootageKind::Sequence if !footage.sequence.is_empty() => {
let i = Self::frame_index(footage.frame_rate, t, footage.sequence.len() as i64, footage.loop_count);
Loc { key: key(&footage.sequence[i as usize], 0), media_t: None }
}
FootageKind::Still | FootageKind::Sequence => Loc { key: key(&footage.path, 0), media_t: None },
FootageKind::Video | FootageKind::Audio => {
let rate = footage.frame_rate;
let n = Self::frame_count(rate, footage.duration);
let i = Self::frame_index(rate, t, n, footage.loop_count);
// Middle of the frame in the file's own timing (robust to timestamp rounding).
let native = footage.native_rate.unwrap_or(rate);
let mt = native.tick_of(i) + Tick(native.frame_duration().0 / 2);
Loc { key: key(&footage.path, i), media_t: Some(mt) }
}
}
}
fn frame_at(self: &Arc<Self>, footage: &Footage, t: Tick, playback: bool) -> Result<Arc<Image>> {
if !footage.has_video {
return Err(MediaError::Unsupported(format!("{}: no video", footage.path)));
}
let loc = Self::locate(footage, t);
let movie = loc.media_t.is_some();
let mut c = lock(&self.cache);
// sequential playback of a movie: read the next frame ahead
if playback && movie {
let prev = c.last.insert(loc.key.path.clone(), loc.key.frame);
if prev == Some(loc.key.frame - 1) && self.read_ahead.load(Ordering::Relaxed) {
let next_t = t + footage.frame_rate.frame_duration();
let next = Self::locate(footage, next_t);
if next.key != loc.key && !c.lru.map.contains_key(&next.key) && !c.inflight.contains(&next.key) {
self.prefetch(footage, next_t);
}
}
}
loop {
if let Some(img) = c.lru.get(&loc.key) {
self.hits.fetch_add(1, Ordering::Relaxed);
return Ok(img);
}
if !c.inflight.contains(&loc.key) {
break;
}
// another thread (or the read-ahead) is decoding this frame
c = self.done.wait(c).unwrap_or_else(|e| e.into_inner());
}
self.misses.fetch_add(1, Ordering::Relaxed);
c.inflight.insert(loc.key.clone());
drop(c);
let _guard = Inflight { inner: self, key: loc.key.clone() };
let img = Arc::new(self.decode(&loc, footage)?);
lock(&self.cache).lru.insert(loc.key, img.clone(), self.budget());
Ok(img)
}
#[cfg(not(target_arch = "wasm32"))]
fn prefetch(self: &Arc<Self>, footage: &Footage, t: Tick) {
let (me, f) = (self.clone(), footage.clone());
self.prefetched.fetch_add(1, Ordering::Relaxed);
rayon::spawn(move || {
let _ = me.frame_at(&f, t, false);
});
}
#[cfg(target_arch = "wasm32")]
fn prefetch(self: &Arc<Self>, _footage: &Footage, _t: Tick) {}
fn decode(&self, loc: &Loc, footage: &Footage) -> Result<Image> {
let op = AlphaOp::new(footage.alpha, footage.premul_color);
let path = &loc.key.path;
match loc.media_t {
None => {
let bytes = self.read(path)?;
let img = image::load_from_memory(&bytes).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?;
Ok(dynamic_to_image(&img, op))
}
Some(mt) => {
let src = self.source(path).ok_or_else(|| MediaError::Io(format!("{path}: cannot open")))?;
let vf = src.video_frame(FrameRequest::full(filmcraft_time::Tick(mt.0))).map_err(MediaError::from)?;
let buf = lock(&self.cache).lru.take_spare(vf.width as usize * vf.height as usize);
Ok(frame_to_image_in(&vf, op, buf))
}
}
}
}
impl FootageSource for MediaPool {
fn frame(&self, _item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<Image>> {
if footage.missing {
return None;
}
match self.frame_at(footage, t) {
Ok(img) => Some(img),
Err(e) => {
log::warn!("media: frame of {} at {:?}: {e}", footage.path, t);
None
}
}
}
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frame_index_loops_and_clamps() {
let r = FrameRate::FPS_30;
assert_eq!(Inner::frame_index(r, r.tick_of(-3), 10, 1), 0);
assert_eq!(Inner::frame_index(r, r.tick_of(4), 10, 1), 4);
assert_eq!(Inner::frame_index(r, r.tick_of(25), 10, 1), 9);
assert_eq!(Inner::frame_index(r, r.tick_of(25), 10, 3), 5);
assert_eq!(Inner::frame_index(r, r.tick_of(45), 10, 3), 9);
assert_eq!(Inner::frame_count(r, Tick(2 * TICKS_PER_SECOND)), 60);
}
#[test]
fn lru_respects_budget_and_recycles() {
let mut c = Lru::default();
let img = || Arc::new(Image::new(16, 16)); // 4 KiB + overhead
let k = |i| Key { path: "a".into(), frame: i, alpha: 0, matte: [0; 3] };
let budget = 3 * (4096 + 64);
for i in 0..10 {
c.insert(k(i), img(), budget);
}
assert_eq!(c.map.len(), 3);
assert!(c.get(&k(9)).is_some() && c.get(&k(6)).is_none());
// touching 7 makes 8 the oldest
c.get(&k(7));
c.insert(k(10), img(), budget);
assert!(c.get(&k(7)).is_some() && c.get(&k(8)).is_none());
assert_eq!(c.spare.len(), MAX_SPARE);
assert_eq!(c.take_spare(256).len(), 256);
assert!(c.take_spare(17).is_empty());
}
}
+168
View File
@@ -0,0 +1,168 @@
//! Probing: describe a file as an [`effectcraft_project::Footage`].
use std::path::{Path, PathBuf};
use std::sync::Arc;
use effectcraft_project::{AlphaMode, Footage, FootageKind};
use effectcraft_time::{FrameRate, Tick};
use crate::{MediaError, Result, STILL_EXTENSIONS, ext_of, rate_from_fc};
/// Frame rate given to image sequences on import (After Effects' default preference).
pub const DEFAULT_SEQUENCE_RATE: FrameRate = FrameRate::FPS_30;
/// Probe a file. Stills whose name ends in a frame number (`shot_0001.png`) with at least one
/// numbered sibling are imported as an image sequence; use [`probe_single`] to import one frame.
pub fn probe(path: impl AsRef<Path>) -> Result<Footage> {
let path = path.as_ref();
if STILL_EXTENSIONS.contains(&ext_of(path).as_str()) {
let files = sequence_files(path);
if files.len() >= 2 {
return probe_sequence(&files);
}
}
probe_single(path)
}
/// Probe a file without image-sequence detection.
pub fn probe_single(path: impl AsRef<Path>) -> Result<Footage> {
let path = path.as_ref();
let bytes = std::fs::read(path).map_err(|e| MediaError::Io(format!("{}: {e}", path.display())))?;
probe_bytes(&path.to_string_lossy(), bytes.into())
}
/// Probe in-memory file contents; `path` names the footage (and is stored as `Footage::path`).
pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result<Footage> {
if let Ok(fmt) = image::guess_format(&bytes) {
return probe_still_bytes(path, &bytes, fmt);
}
let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let src = filmcraft_codecs::open_bytes(&name, bytes)?;
Ok(footage_from_info(path, src.info()))
}
pub(crate) fn footage_from_info(path: &str, info: &filmcraft_media::MediaInfo) -> Footage {
let v = info.video.as_ref();
let kind = match (info.kind, v) {
(filmcraft_media::MediaKind::Still, _) => FootageKind::Still,
(_, Some(_)) => FootageKind::Video,
(_, None) => FootageKind::Audio,
};
let rate = v.map_or(FrameRate::FPS_30, |v| rate_from_fc(v.frame_rate));
let codec = match (v, info.audio.as_ref()) {
(Some(v), _) => v.codec.clone(),
(None, Some(a)) => a.codec.clone(),
_ => String::new(),
};
Footage {
path: path.to_string(),
kind,
width: v.map_or(0, |v| v.width),
height: v.map_or(0, |v| v.height),
pixel_aspect: v.map_or(1.0, |v| if v.par.1 > 0 { v.par.0 as f64 / v.par.1 as f64 } else { 1.0 }),
frame_rate: rate,
native_rate: None,
duration: if kind == FootageKind::Still { Tick::ZERO } else { Tick(info.duration.0) },
has_video: v.is_some(),
has_audio: info.audio.is_some(),
alpha: if v.is_some_and(|v| v.has_alpha) { AlphaMode::Straight } else { AlphaMode::Ignore },
premul_color: [0.0; 3],
loop_count: 1,
codec,
missing: false,
sequence: Vec::new(),
}
}
fn still_footage(path: &str, w: u32, h: u32, fmt: image::ImageFormat, has_alpha: bool) -> Footage {
let codec = fmt.extensions_str().first().map_or("image", |s| s).to_ascii_uppercase();
// OpenEXR stores premultiplied colour by convention; other formats straight alpha.
let alpha = match (has_alpha, fmt) {
(false, _) => AlphaMode::Ignore,
(true, image::ImageFormat::OpenExr) => AlphaMode::Premultiplied,
(true, _) => AlphaMode::Straight,
};
Footage {
path: path.to_string(),
kind: FootageKind::Still,
width: w,
height: h,
pixel_aspect: 1.0,
frame_rate: DEFAULT_SEQUENCE_RATE,
native_rate: None,
duration: Tick::ZERO,
has_video: true,
has_audio: false,
alpha,
premul_color: [0.0; 3],
loop_count: 1,
codec,
missing: false,
sequence: Vec::new(),
}
}
fn probe_still_bytes(path: &str, bytes: &[u8], fmt: image::ImageFormat) -> Result<Footage> {
use image::ImageDecoder;
let reader = image::ImageReader::with_format(std::io::Cursor::new(bytes), fmt);
let dec = reader.into_decoder().map_err(|e| MediaError::Decode(format!("{path}: {e}")))?;
let (w, h) = dec.dimensions();
let has_alpha = dec.color_type().has_alpha();
Ok(still_footage(path, w, h, fmt, has_alpha))
}
fn probe_sequence(files: &[PathBuf]) -> Result<Footage> {
let first = &files[0];
let mut f = probe_single(first)?;
f.kind = FootageKind::Sequence;
f.frame_rate = DEFAULT_SEQUENCE_RATE;
f.duration = DEFAULT_SEQUENCE_RATE.tick_of(files.len() as i64);
f.sequence = files.iter().map(|p| p.to_string_lossy().to_string()).collect();
Ok(f)
}
/// Split a file stem into (prefix, frame number digits). `None` when it does not end in digits.
fn split_number(stem: &str) -> Option<(&str, &str)> {
let digits = stem.bytes().rev().take_while(u8::is_ascii_digit).count();
(digits > 0).then(|| stem.split_at(stem.len() - digits))
}
/// The numbered files of the image sequence `path` belongs to (sorted by frame number; empty when
/// the name has no frame number). Siblings must share the prefix and extension.
pub fn sequence_files(path: impl AsRef<Path>) -> Vec<PathBuf> {
let path = path.as_ref();
let ext = ext_of(path);
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { return Vec::new() };
let Some((prefix, _)) = split_number(stem) else { return Vec::new() };
let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new("."));
let Ok(rd) = std::fs::read_dir(dir) else { return Vec::new() };
let mut out: Vec<(u64, PathBuf)> = rd
.filter_map(|e| e.ok())
.filter_map(|e| {
let p = e.path();
if ext_of(&p) != ext || !p.is_file() {
return None;
}
let s = p.file_stem()?.to_str()?;
let (pre, num) = split_number(s)?;
if pre != prefix {
return None;
}
Some((num.parse().ok()?, p))
})
.collect();
out.sort();
out.into_iter().map(|(_, p)| p).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_numbers() {
assert_eq!(split_number("shot_0012"), Some(("shot_", "0012")));
assert_eq!(split_number("shot"), None);
assert_eq!(split_number("v2_10"), Some(("v2_", "10")));
}
}
+451
View File
@@ -0,0 +1,451 @@
//! Footage import against ffmpeg as an external oracle.
//!
//! Fixtures are generated with ffmpeg into `target/fixtures/media` (never committed); every test
//! skips (passes with a note) when ffmpeg is not installed. Decoded frames are compared with
//! frames ffmpeg extracts to PNG (PSNR, premultiplied RGBA).
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::time::Instant;
use effectcraft_media::{MediaPool, probe, probe_single};
use effectcraft_project::{AlphaMode, FootageKind, ItemId};
use effectcraft_raster::Image;
use effectcraft_render::FootageSource;
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
fn fixtures() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/media")
}
fn have_ffmpeg() -> bool {
static HAVE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*HAVE.get_or_init(|| Command::new("ffmpeg").arg("-version").output().is_ok_and(|o| o.status.success()))
}
/// Run ffmpeg to produce `name` (once; written to a temp name and renamed so concurrent test
/// processes never see a partial file). `None` when ffmpeg is unavailable.
fn ffmpeg_fixture(name: &str, args: &[&str]) -> Option<PathBuf> {
if !have_ffmpeg() {
eprintln!("ffmpeg not found: skipping");
return None;
}
let out = fixtures().join(name);
if out.exists() {
return Some(out);
}
std::fs::create_dir_all(out.parent().expect("parent")).expect("mkdir");
let ext = out.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
let tmp = out.with_file_name(format!("tmp-{}-{:?}.{ext}", std::process::id(), std::thread::current().id()).replace(['(', ')'], ""));
let st = Command::new("ffmpeg").args(["-hide_banner", "-loglevel", "error", "-y"]).args(args).arg(&tmp).status().expect("run ffmpeg");
assert!(st.success(), "ffmpeg failed for {name}");
std::fs::rename(&tmp, &out).expect("rename");
Some(out)
}
fn h264_mp4() -> Option<PathBuf> {
ffmpeg_fixture(
"testsrc2_1080p.mp4",
&[
"-f",
"lavfi",
"-i",
"testsrc2=size=1920x1080:rate=30:duration=2",
"-f",
"lavfi",
"-i",
"sine=frequency=440:sample_rate=48000:duration=2",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-preset",
"medium",
"-crf",
"18",
"-colorspace",
"bt709",
"-color_primaries",
"bt709",
"-color_trc",
"bt709",
"-c:a",
"aac",
"-ac",
"2",
"-shortest",
],
)
}
fn prores_mov() -> Option<PathBuf> {
ffmpeg_fixture(
"prores4444_alpha.mov",
&[
"-f",
"lavfi",
"-i",
"testsrc2=size=640x360:rate=24:duration=1,format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='255*X/W'",
"-c:v",
"prores_ks",
"-profile:v",
"4444",
"-pix_fmt",
"yuva444p10le",
"-colorspace",
"bt709",
"-color_primaries",
"bt709",
"-color_trc",
"bt709",
],
)
}
fn vp9_webm() -> Option<PathBuf> {
ffmpeg_fixture(
"testsrc2_vp9.webm",
&[
"-f",
"lavfi",
"-i",
"testsrc2=size=640x360:rate=25:duration=1",
"-c:v",
"libvpx-vp9",
"-pix_fmt",
"yuv420p",
"-crf",
"20",
"-b:v",
"0",
"-deadline",
"good",
"-cpu-used",
"4",
"-colorspace",
"bt709",
"-color_primaries",
"bt709",
"-color_trc",
"bt709",
],
)
}
fn png_sequence() -> Option<PathBuf> {
// ffmpeg's image2 muxer writes seq_0001.png … seq_0010.png; generate into a temp dir and rename it.
if !have_ffmpeg() {
return None;
}
let dir = fixtures().join("seq");
if !dir.join("seq_0010.png").exists() {
let tmp = fixtures().join(format!("seq-tmp-{}-{:?}", std::process::id(), std::thread::current().id()).replace(['(', ')'], ""));
std::fs::create_dir_all(&tmp).expect("mkdir");
let st = Command::new("ffmpeg")
.args(["-hide_banner", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=30:duration=1", "-frames:v", "10"])
.arg(tmp.join("seq_%04d.png"))
.status()
.expect("ffmpeg");
assert!(st.success());
if std::fs::rename(&tmp, &dir).is_err() {
let _ = std::fs::remove_dir_all(&tmp); // another process won the race
}
}
Some(dir.join("seq_0001.png"))
}
fn alpha_png() -> Option<PathBuf> {
ffmpeg_fixture(
"alpha.png",
&["-f", "lavfi", "-i", "testsrc2=size=256x128:rate=1:duration=1,format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='255*Y/H'", "-frames:v", "1"],
)
}
fn tone_wav() -> Option<PathBuf> {
ffmpeg_fixture("tone.wav", &["-f", "lavfi", "-i", "sine=frequency=1000:sample_rate=48000:duration=1", "-ac", "2", "-c:a", "pcm_s16le"])
}
/// Frame `n` of `src` as RGBA PNG extracted by ffmpeg (Y'CbCr → RGB with the BT.709 matrix).
fn reference_frame(src: &Path, n: usize) -> Image {
let stem = src.file_stem().expect("stem").to_string_lossy().to_string();
let name = format!("ref_{stem}_{n}.png");
let vf = format!("select='eq(n\\,{n})',scale=in_color_matrix=bt709:in_range=auto:out_range=pc:flags=bilinear+accurate_rnd+full_chroma_int,format=rgba");
let p = ffmpeg_fixture(&name, &["-i", src.to_str().expect("utf8"), "-vf", &vf, "-fps_mode", "passthrough", "-frames:v", "1"]).expect("ffmpeg");
load_png(&p)
}
/// A PNG as a premultiplied image (straight alpha in the file).
fn load_png(p: &Path) -> Image {
let img = image::open(p).expect("png").to_rgba8();
let (w, h) = img.dimensions();
let data = img
.pixels()
.map(|p| {
let a = p[3] as f32 / 255.0;
[p[0] as f32 / 255.0 * a, p[1] as f32 / 255.0 * a, p[2] as f32 / 255.0 * a, a]
})
.collect();
Image { width: w, height: h, data }
}
fn psnr(a: &Image, b: &Image) -> f64 {
assert_eq!((a.width, a.height), (b.width, b.height), "size");
let mut se = 0f64;
for (p, q) in a.data.iter().zip(&b.data) {
for k in 0..4 {
let d = (p[k].clamp(0.0, 1.0) - q[k]) as f64;
se += d * d;
}
}
let mse = se / (a.data.len() * 4) as f64;
if mse == 0.0 { f64::INFINITY } else { 10.0 * (1.0 / mse).log10() }
}
fn t_of(rate: FrameRate, n: i64) -> Tick {
rate.tick_of(n)
}
#[test]
fn h264_mp4_probe_and_frames() {
let Some(p) = h264_mp4() else { return };
let f = probe(&p).expect("probe");
assert_eq!(f.kind, FootageKind::Video);
assert_eq!((f.width, f.height), (1920, 1080));
assert_eq!(f.frame_rate, FrameRate::FPS_30);
assert!((f.duration.seconds() - 2.0).abs() < 0.1, "duration {:?}", f.duration.seconds());
assert!(f.has_video && f.has_audio);
assert_eq!(f.alpha, AlphaMode::Ignore);
assert!(!f.codec.is_empty());
eprintln!("h264: codec {:?}", f.codec);
let pool = MediaPool::new();
// sequential, then a backwards seek and a forward jump into the GOP
for n in [0usize, 1, 2, 3, 45, 10, 59] {
let img = pool.frame(ItemId(1), &f, t_of(f.frame_rate, n as i64)).expect("frame");
let db = psnr(&img, &reference_frame(&p, n));
eprintln!("h264 frame {n}: {db:.2} dB");
assert!(db > 35.0, "frame {n}: {db:.2} dB");
}
// clamped past the end: last frame
let last = pool.frame_at(&f, Tick(10 * TICKS_PER_SECOND)).expect("frame");
let db = psnr(&last, &reference_frame(&p, 59));
assert!(db > 35.0, "clamp: {db:.2} dB");
// cached
let a = pool.frame_at(&f, t_of(f.frame_rate, 45)).expect("frame");
let b = pool.frame_at(&f, t_of(f.frame_rate, 45)).expect("frame");
assert!(Arc::ptr_eq(&a, &b));
}
#[test]
fn h264_audio_samples() {
let Some(p) = h264_mp4() else { return };
let f = probe(&p).expect("probe");
let pool = MediaPool::new();
let s = pool.audio_samples(&f, Tick(TICKS_PER_SECOND / 2), 4800, 48_000);
assert_eq!(s.len(), 9600);
let rms = (s.iter().map(|v| v * v).sum::<f32>() / s.len() as f32).sqrt();
// ffmpeg's sine source: −24 dBFS RMS
assert!((rms - 0.0625).abs() < 0.01, "rms {rms}");
// before the start: silence
let pre = pool.audio_samples(&f, Tick(-TICKS_PER_SECOND), 100, 48_000);
assert!(pre.iter().all(|v| *v == 0.0));
}
#[test]
fn prores_4444_with_alpha() {
let Some(p) = prores_mov() else { return };
let f = probe(&p).expect("probe");
assert_eq!(f.kind, FootageKind::Video);
assert_eq!((f.width, f.height), (640, 360));
assert_eq!(f.frame_rate, FrameRate::FPS_24);
assert!(!f.has_audio);
assert_eq!(f.alpha, AlphaMode::Straight, "ProRes 4444 alpha detected");
eprintln!("prores: codec {:?}", f.codec);
let pool = MediaPool::new();
for n in [0usize, 7, 23] {
let img = pool.frame_at(&f, t_of(f.frame_rate, n as i64)).expect("frame");
let db = psnr(&img, &reference_frame(&p, n));
eprintln!("prores frame {n}: {db:.2} dB");
assert!(db > 35.0, "frame {n}: {db:.2} dB");
}
// alpha ramps left to right
let img = pool.frame_at(&f, Tick::ZERO).expect("frame");
assert!(img.data[0][3] < 0.02 && img.data[639][3] > 0.97);
// Ignore → opaque
let mut g = f.clone();
g.alpha = AlphaMode::Ignore;
assert!(pool.frame_at(&g, Tick::ZERO).expect("frame").data.iter().all(|p| p[3] == 1.0));
}
#[test]
fn vp9_webm_frames() {
let Some(p) = vp9_webm() else { return };
let f = probe(&p).expect("probe");
assert_eq!(f.kind, FootageKind::Video);
assert_eq!((f.width, f.height), (640, 360));
assert_eq!(f.frame_rate, FrameRate::FPS_25);
eprintln!("vp9: codec {:?}", f.codec);
let pool = MediaPool::new();
for n in [0usize, 1, 12, 24, 5] {
let img = pool.frame_at(&f, t_of(f.frame_rate, n as i64)).expect("frame");
let db = psnr(&img, &reference_frame(&p, n));
eprintln!("vp9 frame {n}: {db:.2} dB");
assert!(db > 35.0, "frame {n}: {db:.2} dB");
}
}
#[test]
fn png_sequence_import() {
let Some(first) = png_sequence() else { return };
let f = probe(&first).expect("probe");
assert_eq!(f.kind, FootageKind::Sequence);
assert_eq!(f.sequence.len(), 10);
assert_eq!((f.width, f.height), (320, 240));
assert_eq!(f.duration, FrameRate::FPS_30.tick_of(10));
let pool = MediaPool::new();
let img = pool.frame_at(&f, t_of(f.frame_rate, 3)).expect("frame");
assert_eq!(*img, load_png(&first.with_file_name("seq_0004.png")));
// past the end clamps to the last file; with 2 loops it wraps
let img = pool.frame_at(&f, t_of(f.frame_rate, 13)).expect("frame");
assert_eq!(*img, load_png(&first.with_file_name("seq_0010.png")));
let mut looped = f.clone();
looped.loop_count = 2;
let img = pool.frame_at(&looped, t_of(f.frame_rate, 13)).expect("frame");
assert_eq!(*img, load_png(&first.with_file_name("seq_0004.png")));
// a single frame of it
let one = probe_single(&first).expect("probe");
assert_eq!(one.kind, FootageKind::Still);
}
#[test]
fn png_with_alpha() {
let Some(p) = alpha_png() else { return };
let f = probe(&p).expect("probe");
assert_eq!(f.kind, FootageKind::Still);
assert_eq!((f.width, f.height), (256, 128));
assert_eq!(f.alpha, AlphaMode::Straight);
assert_eq!(f.codec, "PNG");
let pool = MediaPool::new();
let a = pool.frame_at(&f, Tick::ZERO).expect("frame");
let b = pool.frame_at(&f, Tick(5 * TICKS_PER_SECOND)).expect("frame");
assert!(Arc::ptr_eq(&a, &b), "stills are timeless");
assert_eq!(*a, load_png(&p));
assert!(a.data[0][3] == 0.0 && a.data[a.data.len() - 1][3] > 0.98);
let th = pool.thumbnail(&f, 64).expect("thumb");
assert_eq!((th.width, th.height), (64, 32));
}
#[test]
fn wav_audio() {
let Some(p) = tone_wav() else { return };
let f = probe(&p).expect("probe");
assert_eq!(f.kind, FootageKind::Audio);
assert!(f.has_audio && !f.has_video);
assert!((f.duration.seconds() - 1.0).abs() < 0.01);
let pool = MediaPool::new();
assert!(pool.frame(ItemId(1), &f, Tick::ZERO).is_none());
let s = pool.audio_samples(&f, Tick::ZERO, 48_000, 48_000);
let rms = (s.iter().map(|v| v * v).sum::<f32>() / s.len() as f32).sqrt();
assert!((rms - 0.0625).abs() < 0.005, "rms {rms}");
// resampled to 44.1 kHz: same loudness
let s = pool.audio_samples(&f, Tick::ZERO, 44_100, 44_100);
let rms = (s.iter().map(|v| v * v).sum::<f32>() / s.len() as f32).sqrt();
assert!((rms - 0.0625).abs() < 0.01, "rms {rms}");
}
#[test]
fn cache_budget_evicts() {
let Some(p) = vp9_webm() else { return };
let f = probe(&p).expect("probe");
let frame_bytes = 640 * 360 * 16;
let pool = MediaPool::with_budget(3 * frame_bytes + 1024);
for n in 0..10 {
pool.frame_at(&f, t_of(f.frame_rate, n)).expect("frame");
}
let st = pool.stats();
assert!(st.frames <= 3 && st.bytes <= 3 * frame_bytes + 1024, "{st:?}");
pool.set_budget(0);
assert_eq!(pool.stats().frames, 1);
}
#[test]
fn concurrent_requests() {
let Some(p) = h264_mp4() else { return };
let f = probe(&p).expect("probe");
let pool = MediaPool::new();
std::thread::scope(|s| {
for k in 0..4i64 {
let (pool, f) = (&pool, &f);
s.spawn(move || {
for n in 0..15 {
let img = pool.frame_at(f, t_of(f.frame_rate, k * 15 + n)).expect("frame");
assert_eq!((img.width, img.height), (1920, 1080));
}
});
}
});
}
#[test]
fn perf_sequential_1080p_h264() {
let Some(p) = h264_mp4() else { return };
let f = probe(&p).expect("probe");
let n = 60;
// FilmCraft alone (decode only, YUV frames)
let bytes: Arc<[u8]> = std::fs::read(&p).expect("read").into();
let src = filmcraft_codecs::open_bytes("a.mp4", bytes).expect("open");
let t0 = Instant::now();
for i in 0..n {
let t = filmcraft_time::Tick(f.frame_rate.tick_of(i).0 + f.frame_rate.frame_duration().0 / 2);
src.video_frame(filmcraft_media::FrameRequest::full(t)).expect("frame");
}
let raw = n as f64 / t0.elapsed().as_secs_f64();
// Through the pool (decode + conversion to f32 RGBA), cold cache, without and with read-ahead
let pool = MediaPool::new();
pool.set_read_ahead(false);
let t0 = Instant::now();
for i in 0..n {
pool.frame_at(&f, t_of(f.frame_rate, i)).expect("frame");
}
let plain = n as f64 / t0.elapsed().as_secs_f64();
let pool = MediaPool::new();
let t0 = Instant::now();
for i in 0..n {
pool.frame_at(&f, t_of(f.frame_rate, i)).expect("frame");
}
let ours = n as f64 / t0.elapsed().as_secs_f64();
assert!(pool.stats().prefetched > 0);
// Cached (the last 20 frames fit the default budget)
let t0 = Instant::now();
for i in n - 20..n {
pool.frame_at(&f, t_of(f.frame_rate, i)).expect("frame");
}
let cached = 20.0 / t0.elapsed().as_secs_f64();
assert!(pool.stats().hits >= 20);
eprintln!(
"1080p H.264 sequential: filmcraft decode {raw:.1} fps; MediaPool (decode + RGBA f32) {plain:.1} fps, with read-ahead {ours:.1} fps; cached {cached:.0} fps"
);
}
#[test]
fn exr_still_is_linear_float() {
// written with the image crate's OpenEXR encoder (no ffmpeg needed)
let dir = fixtures();
std::fs::create_dir_all(&dir).expect("mkdir");
let p = dir.join(format!("float-{}.exr", std::process::id()));
// linear 0.214 ≈ sRGB 0.5 (opaque); premultiplied 2.0 at alpha 0.5
let px = vec![0.214f32, 0.214, 0.214, 1.0, 2.0, 1.0, 0.5, 0.5];
image::DynamicImage::ImageRgba32F(image::ImageBuffer::from_raw(2, 1, px).expect("buf")).save(&p).expect("write exr");
let f = probe(&p).expect("probe");
assert_eq!(f.kind, FootageKind::Still);
assert_eq!((f.width, f.height), (2, 1));
assert_eq!(f.codec, "EXR");
assert_eq!(f.alpha, AlphaMode::Premultiplied);
let img = MediaPool::new().frame_at(&f, Tick::ZERO).expect("frame");
std::fs::remove_file(&p).ok();
let a = img.data[0];
assert!((a[0] - 0.5).abs() < 2e-3 && a[3] == 1.0, "{a:?}");
// straight 4.0 → encoded ≈ 1.83 (super-white kept) → re-premultiplied ≈ 0.92
let b = img.data[1];
assert!(b[3] == 0.5 && b[0] > 0.85 && b[0] < 1.0, "{b:?}");
}