Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions .github/workflows/ffmpeg-pin.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
name: FFmpeg pin

# openadapt-capture ships no FFmpeg bytes. `capture install-ffmpeg` fetches one
# pinned archive per platform and refuses it unless its SHA-256 matches a digest
# compiled into the package.
#
# Two things can break that quietly, and neither shows up in the unit suite:
# a release asset replaced behind the pinned URL, and a pin bumped to a build
# that is no longer the LGPL configuration. Both lanes below read the live
# artifact, so a pin edit is proven in the pull request that makes it, and a
# replaced asset is caught on the weekly schedule.

on:
schedule:
# Thursday 05:41 UTC.
- cron: "41 5 * * 4"
workflow_dispatch:
pull_request:
paths:
- "openadapt_capture/ffmpeg_runtime.py"
- "scripts/check_ffmpeg_pin.py"
- ".github/workflows/ffmpeg-pin.yml"

concurrency:
group: capture-ffmpeg-pin-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
verify-pin:
name: Verify every pinned artifact and its licence
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- name: Install exact uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
version: "0.11.29"

- name: Set up Python
run: uv python install 3.12

- name: Install dependencies
run: uv sync --extra dev

- name: Check the pinned digests and the build configuration
run: uv run python scripts/check_ffmpeg_pin.py

install-smoke:
name: Install and probe on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- name: Install exact uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
version: "0.11.29"

- name: Set up Python
run: uv python install 3.12

- name: Install dependencies
run: uv sync --extra dev

- name: Show the plan without fetching anything
run: uv run capture install-ffmpeg --dry-run

- name: Install the pinned runtime and run a real encode-and-decode probe
run: uv run capture install-ffmpeg

- name: Record with the installed runtime resolved and nothing else
shell: bash
env:
# Strip every earlier mechanism so the installed runtime is what
# resolves. A hosted runner may already carry an ffmpeg on PATH,
# which outranks it by design.
OPENADAPT_FFMPEG_PATH: ""
OPENADAPT_FFPROBE_PATH: ""
OPENADAPT_DESKTOP_FFMPEG_PATH: ""
run: |
set -euo pipefail
uv run python - <<'PY'
import os
import shutil

from openadapt_capture import ffmpeg_runtime, video

installed = ffmpeg_runtime.find_installed_runtime()
assert installed is not None, "install-ffmpeg left no usable receipt"

# Hide any FFmpeg the runner image already provides.
os.environ["PATH"] = os.pathsep.join(
part
for part in os.environ["PATH"].split(os.pathsep)
if part and not shutil.which("ffmpeg", path=part)
)
video._desktop_data_dirs = lambda: []

provision = video.require_video_encoder()
assert provision.source == "capture install-ffmpeg", provision.source
assert provision.executable == installed[0], provision.executable
print(f"encoded and decoded with {provision.codec} into {provision.muxer}")
PY

- name: Remove the runtime again
run: uv run capture uninstall-ffmpeg
53 changes: 40 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ capture record ./my-capture --description "Describe the workflow"
capture info ./my-capture
```

`capture status`, `capture stop`, and the `status_recording` / `stop_recording`
Python contract below are on `main` and ship in 1.2.3. They are not in the
`capture status`, `capture stop`, `capture install-ffmpeg`, and the
`status_recording` / `stop_recording` Python contract below are on `main` and
ship in 1.2.3. They are not in the
published 1.2.2 wheel, so install from source until 1.2.3 reaches PyPI:

```bash
Expand Down Expand Up @@ -107,19 +108,45 @@ an authenticated session capability that lives in an owner-only runtime file
On macOS it also removes extended ACL entries and verifies they are absent. The
capability never reaches command arguments, logs, or the capture directory.

## You have to supply FFmpeg
## FFmpeg

Video is the default evidence format, and Capture never downloads, bundles, or
links FFmpeg or PyAV. Point it at an executable with `OPENADAPT_FFMPEG_PATH`,
`Recorder(ffmpeg_path=...)`, Desktop's `ffmpeg.json` provision manifest, or by
putting `ffmpeg` and `ffprobe` on `PATH`. Recording runs a real
encode-and-decode probe first and refuses before the input listeners start if
the executable, the codec, or the PNG verification path is missing.
Recording video needs an FFmpeg executable, and `capture install-ffmpeg` gets
you one. Video is the default evidence format, so most people need it.

A minimal managed runtime has to supply raw-video input through a pipe, the
chosen video encoder, MP4 demuxing and muxing, PNG decoding and encoding, the
`image2pipe` muxer, and the `select` video filter. Desktop provisions and
probes that exact closure.
```bash
capture install-ffmpeg
```

That downloads a single pinned build for your platform, checks its SHA-256
against a digest compiled into this package, and installs it under your user
data directory. Nothing is made executable until every digest matches. Run it
with `--dry-run` to print the exact URL, digest, and destination without
fetching anything, and `capture uninstall-ffmpeg` to remove it again. Builds
are pinned for macOS on Apple silicon and Intel, Linux x86-64, and Windows
x86-64. On anything else, supply your own executable.

The wheel and the source distribution carry no FFmpeg bytes, and Capture
downloads nothing unless you run that command. Licensing is the reason. This
package is MIT and FFmpeg is not, so shipping FFmpeg inside it would relicense
the package. The pinned build is LGPL-2.1-or-later, configured with
`--disable-gpl`, `--disable-nonfree`, and `--disable-version3`. FFmpeg's own
`LICENSE.md` covers the GPL components it leaves out: "None of these parts are
used by default." The install puts that licence text beside the binaries, and
writes a receipt naming the archive, its digest, and the matching upstream
source tarball.

An FFmpeg you already configured keeps priority. Capture resolves, in order,
`Recorder(ffmpeg_path=...)` or `OPENADAPT_FFMPEG_PATH`, then
`OPENADAPT_DESKTOP_FFMPEG_PATH`, then Desktop's `ffmpeg.json` provision
manifest, then `PATH`, and the installed runtime last. Point
`OPENADAPT_FFMPEG_PATH` at the installed one to move it to the front.

Recording runs a real encode-and-decode probe first, and refuses before the
input listeners start if the executable, the codec, or the PNG verification
path is missing. A minimal runtime has to supply raw-video input through a
pipe, the chosen video encoder, MP4 demuxing and muxing, PNG decoding and
encoding, the `image2pipe` muxer, and the `select` video filter. The pinned
build and Desktop's runtime both cover that closure.

Frames stream from memory straight to FFmpeg. A missing integer PTS slot reuses
the preceding frame, so the encode is deterministic no matter what the
Expand Down
15 changes: 10 additions & 5 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,11 +176,16 @@ describe controls inside an RDP or Citrix pixel stream.

## Video and frame timing

Capture does not import, link, download, or bundle FFmpeg. It invokes an
explicitly configured, Desktop-provisioned, or user-provisioned executable
through a process boundary. Preflight verifies the required raw-video input,
selected encoder, MP4 muxing, PNG encode/decode, `image2pipe`, and `select`
filter before recording starts.
Capture does not import, link, or bundle FFmpeg, and it downloads nothing on
its own. It invokes an explicitly configured, Desktop-provisioned,
user-provisioned, or `capture install-ffmpeg` executable through a process
boundary. `capture install-ffmpeg` is the operator's opt-in: it fetches one
pinned LGPL-2.1-or-later archive, refuses it unless its SHA-256 matches a
digest compiled into the package, and verifies every extracted member before
anything is made executable. Its runtime resolves last, behind all four earlier
mechanisms. Preflight verifies the required raw-video input, selected encoder,
MP4 muxing, PNG encode/decode, `image2pipe`, and `select` filter before
recording starts.

The writer emits a deterministic constant-rate stream. It reuses the preceding
RGB frame for a missing integer PTS slot. A compact MP4 metadata box binds
Expand Down
94 changes: 94 additions & 0 deletions openadapt_capture/cli.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Command-line interface for openadapt-capture.

Usage:
capture install-ffmpeg
capture record ./my_capture
capture visualize ./my_capture
capture info ./my_capture
Expand Down Expand Up @@ -431,6 +432,97 @@ def _save_transcript(
print(f"[{mins}:{secs:05.2f}] {seg['text']}")


def install_ffmpeg(
dry_run: bool = False,
force: bool = False,
probe: bool = True,
) -> None:
"""Install the pinned FFmpeg build that Capture records video with.

Capture ships no FFmpeg bytes. This command is the opt-in: it downloads one
exact archive, refuses it unless its SHA-256 matches the digest compiled
into this package, and only then makes anything executable. The build is
LGPL-2.1-or-later; FFmpeg's own licence text is installed beside it, and
the receipt records the matching upstream source archive.

An FFmpeg you already configured keeps priority. This one is used only when
OPENADAPT_FFMPEG_PATH, Recorder(ffmpeg_path=...), Desktop's ffmpeg.json,
and PATH all come up empty.

Args:
dry_run: Print the exact artifact, digest and destination, then stop.
force: Reinstall even when this build is already present.
probe: Run a real encode-and-decode check afterwards (default: True).
"""
from openadapt_capture import ffmpeg_runtime

try:
planned = ffmpeg_runtime.plan()
except ffmpeg_runtime.UnsupportedPlatformError as exc:
print(str(exc))
raise SystemExit(1) from exc

print(f"Artifact: {planned['url']}")
print(f"SHA-256: {planned['archive_sha256']}")
print(f"Licence: {planned['license']}")
print(f"Source: {planned['source_url']}")
print(f"Install: {planned['install_dir']}")
print()

if dry_run:
print("Dry run. Nothing was downloaded.")
return

try:
installed = ffmpeg_runtime.install(force=force)
except ffmpeg_runtime.FFmpegProvisionError as exc:
print(str(exc))
raise SystemExit(1) from exc

print(f"Installed {installed.build_id}")
print(f" ffmpeg {installed.ffmpeg}")
print(f" ffprobe {installed.ffprobe}")
print(f" licence {installed.license_path}")
print(f" receipt {ffmpeg_runtime.receipt_path()}")

if not probe:
return

from openadapt_capture.video import (
FFmpegUnavailableError,
require_video_encoder,
resolve_ffmpeg,
)

try:
provision = require_video_encoder()
except FFmpegUnavailableError as exc:
print()
print(f"The installed runtime did not pass the encode-and-decode check: {exc}")
raise SystemExit(1) from exc

print()
print(f"Verified encoder {provision.codec} into {provision.muxer}.")
resolved = resolve_ffmpeg()
if resolved.executable != installed.ffmpeg:
print(
f"Note: recording will use the FFmpeg from {resolved.source} "
f"({resolved.executable}), which keeps priority over this install. "
"Set OPENADAPT_FFMPEG_PATH to prefer the installed one."
)


def uninstall_ffmpeg() -> None:
"""Remove the FFmpeg runtime that `capture install-ffmpeg` installed."""
from openadapt_capture import ffmpeg_runtime

root = ffmpeg_runtime.runtime_root()
if ffmpeg_runtime.uninstall():
print(f"Removed {root}")
else:
print(f"Nothing to remove at {root}")


def share(action: str, path_or_code: str, output_dir: str = ".") -> None:
"""Share recordings via Magic Wormhole.

Expand Down Expand Up @@ -465,6 +557,8 @@ def main() -> None:
"info": info,
"transcribe": transcribe,
"share": share,
"install-ffmpeg": install_ffmpeg,
"uninstall-ffmpeg": uninstall_ffmpeg,
})


Expand Down
Loading