diff --git a/.github/workflows/build-torchcodec.yml b/.github/workflows/build-torchcodec.yml new file mode 100644 index 0000000000..71e885d46b --- /dev/null +++ b/.github/workflows/build-torchcodec.yml @@ -0,0 +1,225 @@ +# SPDX-FileCopyrightText: 2026 The RISE Project +# SPDX-License-Identifier: MIT +--- +# This workflow is based on: https://github.com/pytorch/torchcodec/blob/main/.github/workflows/linux_wheel.yaml +name: Build torchcodec wheels (riscv64) + +on: + workflow_dispatch: + inputs: + version: + description: 'Version glob to (re)build; empty builds every version of docs/packages/torchcodec.yaml not released yet' + required: false + default: '' + pull_request: + branches: [main] + paths: + - '.github/workflows/build-torchcodec.yml' + - 'docs/packages/torchcodec.yaml' + - 'patches/torchcodec/**' + push: + branches: [main] + paths: + - '.github/workflows/build-torchcodec.yml' + - 'docs/packages/torchcodec.yaml' + - 'patches/torchcodec/**' + +run-name: build-torchcodec ${{ inputs.version && format('- {0}', inputs.version) || '' }} + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read # to fetch code (actions/checkout) + +env: + # Upstream links against prebuilt non-GPL FFmpeg tarballs it hosts on S3, which have no + # riscv64 build, so FFmpeg is built from source here (LGPL: no --enable-gpl/--enable-nonfree) + # and never shipped. 6.1 because that is the libavutil ABI of the system FFmpeg on + # ubuntu-24.04-riscv, where these wheels are consumed: the FFmpeg major version is baked + # into the libtorchcodec_core.so name that torchcodec dlopen()s at runtime. + FFMPEG_VERSION: 6.1.6 + +jobs: + setup: + uses: $/.github/workflows/_setup.yml + with: + package: torchcodec + version: ${{ inputs.version }} + + build_wheels: + needs: [setup] + if: needs.setup.outputs.versions != '[]' + name: Build torchcodec ${{ matrix.version }} cp${{ matrix.python-version }}-manylinux_riscv64 + runs-on: ubuntu-24.04-riscv + timeout-minutes: 360 + strategy: + fail-fast: false + matrix: + version: ${{ fromJSON(needs.setup.outputs.versions) }} + # Match torch's own riscv64 matrix on the registry: torchcodec's pybind11 module is + # per-ABI, so there is no single abi3 wheel even though it targets torch's stable ABI. + # See https://pypi.riseproject.dev/python-wheels/development.html#target-python-versions + python-version: [312, 313, 314, 314t] + + env: + TORCHCODEC_VERSION: ${{ matrix.version }} + + steps: + - name: Checkout torchcodec v${{ env.TORCHCODEC_VERSION }} + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: pytorch/torchcodec + ref: v${{ env.TORCHCODEC_VERSION }} + persist-credentials: false + + - name: Checkout python-wheels + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: python-wheels + persist-credentials: false + + - name: Patch torchcodec source + run: git apply python-wheels/patches/torchcodec/${{ env.TORCHCODEC_VERSION }}/00*.patch + + - uses: pypa/cibuildwheel@1828c10ab37f080699c7b81cea34097c684a7074 # v4.2.0 + env: + CIBW_BUILD: "cp${{ matrix.python-version }}-manylinux_riscv64" + # setup is scikit-build-core driving CMake, which resolves Torch_DIR by running + # `import torch` in the build interpreter; torch only exists on our registry, so it + # cannot be declared in build-system.requires and isolation has to be off. + CIBW_BUILD_FRONTEND: "pip; args: --no-build-isolation" + # auditwheel grafts libjpeg-turbo/libpng/libwebp/zlib into the wheel, so their + # licence texts have to travel with it (the rpm that owns each bundled .so is + # resolved rather than hardcoded; %license files are dropped by the image's + # tsflags=nodocs and come back with an explicit reinstall). LICENSE.* is what the + # patched pyproject.toml globs into .dist-info/licenses/. + CIBW_BEFORE_ALL: | + set -ex + dnf install -y --setopt=tsflags= pkgconfig libjpeg-turbo-devel libpng-devel libwebp-devel zlib-devel + devel=$(rpm -q --whatprovides --qf '%{NAME}\n' libjpeg-turbo-devel libpng-devel libwebp-devel zlib-devel | sort -u) + libs=$(rpm -ql $devel | grep -E '\.so$' | xargs -r readlink -f) + pkgs=$(rpm -qf --qf '%{NAME}\n' $libs | grep -E '^[A-Za-z0-9._+-]+$' | sort -u) + dnf reinstall -y --setopt=tsflags= $pkgs || true + for p in $pkgs; do + for f in /usr/share/licenses/$p/*; do + [ -f "$f" ] || continue + cp "$f" "{project}/LICENSE.$p.${f##*/}" + done + done + ls {project}/LICENSE.* + mkdir -p /tmp/ffmpeg + curl -fsSL "https://github.com/FFmpeg/FFmpeg/archive/refs/tags/n${FFMPEG_VERSION}.tar.gz" | tar xz --strip-components=1 -C /tmp/ffmpeg + cd /tmp/ffmpeg + ./configure --prefix=/usr/local --disable-static --enable-shared --enable-pic --disable-doc + make -j"$(nproc)" install + ldconfig + CIBW_BEFORE_BUILD: >- + pip install --only-binary=:all: + torch>=2.11 "scikit-build-core>=0.10" pybind11 ninja setuptools wheel + # I_CONFIRM_THIS_IS_NOT_A_LICENSE_VIOLATION is upstream's own escape hatch for + # wheels built against an FFmpeg that is not their S3 one; the guard exists to stop + # a GPL FFmpeg being linked, and the build above is LGPL. + # AVIF and HEIC are off because neither can be had on riscv64: libavif comes from + # the same S3 bucket and libheif is packaged in neither Rocky 10 nor a riscv64 EPEL. + # decode_avif/decode_heic then raise an actionable error instead of failing the build. + CIBW_ENVIRONMENT: >- + FFMPEG_VERSION=${{ env.FFMPEG_VERSION }} + BUILD_VERSION=${{ env.TORCHCODEC_VERSION }} + I_CONFIRM_THIS_IS_NOT_A_LICENSE_VIOLATION=1 + TORCHCODEC_BUILD_AVIF=0 + TORCHCODEC_BUILD_HEIC=0 + TORCHCODEC_DISABLE_COMPILE_WARNING_AS_ERROR=1 + PKG_CONFIG_PATH=/usr/local/lib/pkgconfig + PIP_EXTRA_INDEX_URL=https://pypi.riseproject.dev/simple/ + # Same exclude list as upstream's packaging/repair_wheel.py: FFmpeg is a runtime + # dependency the user supplies, and libtorch/libc10/libgomp come from the torch + # wheel, which is imported (RTLD_GLOBAL) before torchcodec loads its own libraries. + CIBW_REPAIR_WHEEL_COMMAND: >- + auditwheel repair -w {dest_dir} {wheel} + --exclude 'libavcodec*' + --exclude 'libavdevice*' + --exclude 'libavfilter*' + --exclude 'libavformat*' + --exclude 'libavutil*' + --exclude 'libswresample*' + --exclude 'libswscale*' + --exclude 'libpostproc*' + --exclude 'libtorch*' + --exclude 'libc10*' + --exclude libgomp.so.1 + CIBW_TEST_REQUIRES: torch>=2.11 numpy pytest pillow + # pyproject.toml is staged alongside test/ purely for its [tool.pytest.ini_options] + # (the needs_* markers and the 'not slow' default); src/ deliberately is not, which + # is what upstream's packaging/remove_src.sh achieves before its own test jobs. + CIBW_TEST_SOURCES: test pyproject.toml + # FAIL_WITHOUT_ turns a missing image codec from a silent skip into a + # failure, for the three we do build. test_transform_ops.py imports torchvision at + # module scope and pypi.riseproject.dev has no riscv64 torchvision yet. + # Upstream tests against conda-forge's GPL FFmpeg; the LGPL build above has only + # FFmpeg's built-in codecs - no H.264/HEVC/VP9/AV1/MP3 encoder at all (libx264 and + # libx265 need --enable-gpl) and an AV1 decoder that only works through a hwaccel - + # so the tests that mux a real video, shell out to the ffmpeg CLI or ask for MP3 are + # dropped, smoke_test.py included: it encodes H.264 yuv444p to build every one of its + # fixtures. test_logging.py asserts an empty subprocess stderr, which torch's + # libcpuinfo pollutes on this runner by reading core_id -1 from /sys. + CIBW_TEST_COMMAND: >- + FAIL_WITHOUT_JPEG=1 FAIL_WITHOUT_PNG=1 FAIL_WITHOUT_WEBP=1 pytest + --ignore=test/test_transform_ops.py + --ignore=test/smoke_test.py + --ignore=test/test_logging.py + -k "not test_get_frame_at_av1 + and not ((test_audio_against_cli or test_multiple_audio_formats + or test_audio_to_file_vs_to_file_like) and mp3) + and not (test_add_video_and_encode_frames or test_add_audio_and_video_and_encode + or test_multiple_video_streams_and_audio or test_video_round_trip + or test_video_to_file_vs_to_file_like or test_video_contiguity + or test_video_against_ffmpeg_cli or test_fragmented_mp4 + or test_codec_parameter_utilized or test_codec_spec_vs_impl_equivalence + or test_extra_options_errors or test_extra_options_utilized + or test_crf_out_of_range_errors or test_invalid_preset_errors + or test_to_file_like_real_file_video + or (test_to_file_like_custom_file_object and not audio) + or test_add_audio_unsupported_num_channels_errors + or test_add_audio_invalid_out_sample_rate_errors)" + test + + - name: Check the wheel bundles the image codecs and no FFmpeg + run: | + python3 - wheelhouse/*.whl <<'EOF' + import sys, zipfile + + names = zipfile.ZipFile(sys.argv[1]).namelist() + for want in ("torchcodec/libtorchcodec_core6.so", "torchcodec/libtorchcodec_image.so", + "libjpeg", "libpng", "libwebp", "licenses/LICENSE"): + if not any(want in n for n in names): + raise SystemExit(f"error: {want} missing from {sys.argv[1]}") + for unwanted in ("libavcodec", "libavutil", "libtorch.so", "libc10.so"): + if any(unwanted in n for n in names): + raise SystemExit(f"error: {unwanted} must not be bundled in {sys.argv[1]}") + print("\n".join(n for n in names if ".so" in n or "licenses/" in n)) + EOF + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: torchcodec-${{ env.TORCHCODEC_VERSION }}-cp${{ matrix.python-version }}-manylinux_riscv64 + path: ./wheelhouse/*.whl + if-no-files-found: error + + publish: + name: Publish torchcodec ${{ matrix.version }} + needs: [setup, build_wheels] + if: needs.setup.outputs.versions != '[]' + strategy: + fail-fast: false + matrix: + version: ${{ fromJSON(needs.setup.outputs.versions) }} + permissions: + contents: write + pull-requests: write + uses: $/.github/workflows/_publish-wheel.yml + secrets: + app-private-key: ${{ secrets.RISEPROJECT_APP_PRIVATE_KEY }} + with: + artifact-pattern: torchcodec-${{ matrix.version }}-*-manylinux_riscv64 diff --git a/docs/packages/torchcodec.yaml b/docs/packages/torchcodec.yaml new file mode 100644 index 0000000000..9a571048bd --- /dev/null +++ b/docs/packages/torchcodec.yaml @@ -0,0 +1,5 @@ +package-name: torchcodec +source-code: https://github.com/pytorch/torchcodec +license: BSD-3-Clause AND MIT AND Zlib AND libpng-2.0 AND IJG +versions: +- version: 0.16.0 diff --git a/patches/torchcodec/0.16.0/0001-pyproject-glob-the-licence-texts-of-the-bundled-image.patch b/patches/torchcodec/0.16.0/0001-pyproject-glob-the-licence-texts-of-the-bundled-image.patch new file mode 100644 index 0000000000..ebd81722fe --- /dev/null +++ b/patches/torchcodec/0.16.0/0001-pyproject-glob-the-licence-texts-of-the-bundled-image.patch @@ -0,0 +1,49 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Ludovic Henry +Date: Fri, 19 Sep 2026 12:00:00 +0200 +Subject: [PATCH] pyproject: glob the licence texts of the bundled image codecs + +Upstream-Status: Inappropriate [our wheels are repaired by cibuildwheel's auditwheel, not by upstream's packaging/repair_wheel.py] + +torchcodec redistributes libjpeg-turbo, libpng, zlib and libwebp as +binaries inside the wheel, and their (permissive) licences require the +copyright notice to travel with the binary. Upstream satisfies that in +packaging/repair_wheel.py's bundle_third_party_licenses(), which unpacks +each freshly built wheel, copies the licence texts out of the conda +packages the libraries came from into .dist-info/licenses/third_party/, +and repacks. That path is unusable here for two independent reasons: it +resolves the texts through CONDA_PREFIX/conda-meta (there is no conda +environment in the manylinux image, the libraries come from Rocky rpms), +and it hardcodes dist/ -> dist_repaired/ plus a mandatory S3 libavif +directory, neither of which exists under cibuildwheel's own auditwheel +repair step. + +So the texts are staged next to the project's own LICENSE instead, as +LICENSE.., and picked up by widening the PEP 639 +license-files list. An explicit license-files list has no default glob +behind it, so without this the added files are silently dropped and the +wheel ships only torchcodec's own BSD text. + +Not riscv64-specific in substance, but it only makes sense for a build +that repairs wheels with plain auditwheel, so there is nothing to send +upstream. +--- + pyproject.toml | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/pyproject.toml b/pyproject.toml +index 4a52430..fde71b9 100644 +--- a/pyproject.toml ++++ b/pyproject.toml +@@ -3,7 +3,7 @@ name = "torchcodec" + description = "A video decoder for PyTorch" + readme = "README.md" + requires-python = ">=3.10" +-license-files = ["LICENSE"] ++license-files = ["LICENSE", "LICENSE.*"] + authors = [ + { name = "PyTorch Team", email = "packages@pytorch.org" }, + ] +-- +2.51.0 + diff --git a/patches/torchcodec/0.16.0/0002-DecodeAvif-give-the-no-libavif-stub-the-declared-sign.patch b/patches/torchcodec/0.16.0/0002-DecodeAvif-give-the-no-libavif-stub-the-declared-sign.patch new file mode 100644 index 0000000000..06515ff695 --- /dev/null +++ b/patches/torchcodec/0.16.0/0002-DecodeAvif-give-the-no-libavif-stub-the-declared-sign.patch @@ -0,0 +1,63 @@ +From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 +From: Ludovic Henry +Date: Sat, 19 Sep 2026 19:40:00 +0200 +Subject: [PATCH] DecodeAvif: give the no-libavif stub the declared signature + +Upstream-Status: To upstream [not yet submitted; upstream never builds this path - their wheels always link the libavif they fetch from S3, and no CI job sets TORCHCODEC_BUILD_AVIF=0] + +decode_avif() is declared in DecodeAvif.h - and defined by the +TORCHCODEC_ENABLE_AVIF branch of DecodeAvif.cpp - with four parameters +(input, mode, output_dtype, num_threads), and image_custom_ops.cpp takes +its address with TORCH_BOX(&decode_avif) to register the +"decode_avif(Tensor input, int mode, int output_dtype=0, int +num_threads=1) -> Tensor" op. The !TORCHCODEC_ENABLE_AVIF stub, however, +still has the three-parameter signature from before num_threads was +added, so it defines a *different* overload and the four-parameter one is +never defined anywhere. + +An ELF shared object may carry undefined symbols, so nothing complains: +libtorchcodec_image.so links, the wheel builds, auditwheel repairs it, +and the first torch.ops.load_library() of it fails with + + OSError: .../torchcodec/libtorchcodec_image.so: undefined symbol: + _ZN8facebook10torchcodec11decode_avifERKN5torch6stable6TensorElll + +i.e. facebook::torchcodec::decode_avif(torch::stable::Tensor const&, +long, long, long). Because load_image_library() runs at +`import torchcodec`, every entry point of the package is dead, not just +decode_avif - which defeats the documented purpose of the build flag +("A codec set to OFF is not built; its decode_*() then raises an +actionable error at call time [...] while importing torchcodec still +works"). + +Add the missing num_threads parameter to the stub. Nothing else changes: +the stub still just raises the actionable "not compiled with libavif +support" error, which is what test/utils.py's avif_is_available() looks +for to skip the AVIF tests. + +This is not riscv64-specific - any build with TORCHCODEC_BUILD_AVIF=0 or +TORCHCODEC_BUILD_IMAGE=0 produces an unloadable image library - but it is +what our wheels hit, since libavif comes from upstream's S3 bucket, which +has no riscv64 build, and neither Rocky 10 nor a riscv64 EPEL packages it. + +Signed-off-by: Ludovic Henry +--- + src/torchcodec/_core/DecodeAvif.cpp | 3 ++- + 1 file changed, 2 insertions(+), 1 deletion(-) + +diff --git a/src/torchcodec/_core/DecodeAvif.cpp b/src/torchcodec/_core/DecodeAvif.cpp +index 17fd03a..b40b9b9 100644 +--- a/src/torchcodec/_core/DecodeAvif.cpp ++++ b/src/torchcodec/_core/DecodeAvif.cpp +@@ -19,7 +19,8 @@ namespace facebook::torchcodec { + torch::stable::Tensor decode_avif( + [[maybe_unused]] const torch::stable::Tensor& input, + [[maybe_unused]] int64_t mode, +- [[maybe_unused]] int64_t output_dtype) { ++ [[maybe_unused]] int64_t output_dtype, ++ [[maybe_unused]] int64_t num_threads) { + STD_TORCH_CHECK( + false, + "decode_avif: torchcodec was not compiled with libavif support. Rebuild " +-- +2.51.0