# Changelog All notable changes to FreeFrame are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Upgrade notes - **On a capped instance, read this before you upgrade: uploads can start being refused straight after it, and deleting will no longer make room.** `alembic upgrade head` adds `media_files.derived_size_bytes`, nullable with no default, so existing media reads as "not measured" rather than as zero; the same migration adds `uq_media_files_version_id`, which makes explicit what every write path has always done, one media file per version, and names the versions and stops if your database somehow holds two, because the figure is measured per output prefix and would otherwise be counted twice. A refused migration changes nothing, because it runs in one transaction, and says which row to keep. It is also a new Celery task on the `maintenance` queue, so recreate the maintenance worker together with the API; a worker still running the old code does not know the task, so it rejects a queued backfill and that run is lost, and recreating the worker afterwards does not bring it back; trigger the backfill again once the worker is recreated. An instance with no storage limit (`storage_limit_bytes` of `0`) is unaffected by everything below. On one with a limit, three things change, and the first two need no backfill to bite. **1. The trash counts as used from the first start of the new API.** Used storage now has one definition everywhere, and it counts a soft-deleted project, asset or version, because its objects are still in the bucket and the limit still blocks on them; before, trashed assets and versions were left out of the figure. Nothing is deleted and nothing moves, but the number the cap is enforced against goes up the moment the new API comes up, before any backfill, so an instance that was near its limit can refuse the next upload immediately. `docs/deployment.md` has a `psql` query that shows how much that is on your instance **before** you upgrade. **2. Deleting no longer makes room until the retention window passes.** On the old figure a member at the limit could delete an asset and upload again. The figure now stays where it is until the retention collector reclaims the objects, which is 30 days after the delete by default (`SOFT_DELETE_RETENTION_DAYS`), and **never** with `SOFT_DELETE_RETENTION_DAYS=0`, which switches the collector off entirely. `POST /admin/purge` uses the same cutoff, so it does not help either. On this release the only early remedy is to raise the limit. **3. After the backfill, the transcode output counts too, and the jump can be large.** Run the backfill once; it does one bucket listing per unmeasured version, is idempotent, resumable and safe to run while the instance is serving, and after it "used storage" is one figure everywhere (the indicator a member sees, the per-project figures, the new `GET /admin/storage` overview and the upload guard) counting the original plus the renditions, HLS segments, single-file downloads, poster thumbnails and waveforms made from it. Those bytes were always in the bucket and merely unmeasured, so nothing is deleted. How much they add depends entirely on your media: measured on 10 second synthetic 1080p clips through the default ladder, with the top rung stored twice as `download.mp4` by design, derived output came to between **0.16x and 2.84x** the original, largest for already-compressed sources because CRF output follows the picture rather than the source bitrate. Synthetic clips are not footage, so read that as a range and not as a prediction; the only figure for your own library is the one the backfill produces. **Changing the limit.** Settings → Admin → **Instance settings** → **Storage limit (GB)** (`/settings/admin?tab=instance`), which writes `storage_limit_bytes` through `PUT /instance/settings`. `0` is unlimited. **Running the backfill.** There is no button for it in this release. On the server, where it needs no token (this also prints the task id, and the maintenance worker logs `measured=`, `failed=` and `bytes=` when it finishes): ```bash docker compose --env-file .env.prod -f docker-compose.prod.yml \ exec api celery -A apps.api.tasks.celery_app call backfill_media_derived_size docker compose --env-file .env.prod -f docker-compose.prod.yml \ logs -f maintenance_worker ``` Or over the API, with an access token for a superadmin account (the production compose serves the API under `/api` and disables the interactive docs page, so this is the whole request): ```bash TOKEN='' curl -fsS -X POST https://your-freeframe.example/api/admin/storage/backfill \ -H "Authorization: Bearer $TOKEN" curl -fsS https://your-freeframe.example/api/admin/storage \ -H "Authorization: Bearer $TOKEN" ``` A 202 means enqueued, not finished. Read the overview again until `unmeasured_files` reaches 0, then compare `used_bytes` with your limit and raise it if the new figure is close. Calling it twice does not run it twice. See "Upgrading past the storage measurement" in `docs/deployment.md` for the before-and-after `psql` queries and the whole order of operations. (#599 by @jeremy-pixelated) ### Added - **The storage a transcode produces is measured, and a superadmin can read what the instance holds.** `media_files.derived_size_bytes` holds the bytes a transcode wrote for a version, measured once with a single prefix listing when the job finishes, and those bytes count towards used storage because the bucket holds them. Media uploaded before this release is reported as not measured rather than as zero until the `backfill_media_derived_size` task (or `POST /admin/storage/backfill`) has filled it in, and a version that has not been measured counts as its original alone. Nothing schedules the backfill and no screen triggers it or shows the unmeasured count in this release; see the upgrade notes for the commands. `GET /admin/storage` answers with used storage, the capacity and the free space (the latter two only on an instance that has a storage limit set), how much of used is in the trash, how many versions are still unmeasured, and the projects that hold media with their sizes, largest first, the largest 200 by default (`limit`, up to 1000, with `project_count` for how many there are; the byte totals are always instance-wide); `GET /admin/storage/projects/{id}` lists that project's files and their versions. Both are superadmin only, enforced on the server. (#599 by @jeremy-pixelated) - **A Compression slider in Instance Settings sets the quality of video review streams.** The ladder's CRF values were fixed in the code, and on the 8-bit default path the top rung encoded at CRF 24, where graded work with large dark gradients bands visibly, so an instance reviewing such material had no way to ask for more. **Admin Dashboard → Instance settings → Review streams** now offers five steps from *Highest quality* to *Smallest files*. *Highest quality* puts the default software x264 8-bit ladder on CRF 16 / 18 / 22, and the two steps that raise quality, *Highest quality* and *Higher quality*, also switch that ladder to `-aq-mode 3`, auto-variance adaptive quantisation with a bias to dark scenes, which is where banding shows; measured on one 1080p dark-gradient master, *Highest quality*'s top rung went from about 0.8 to about 1.5 Mbit/s. Those numbers are the software x264 ladder: a 10-bit `TRANSCODER_OUTPUT` (`h265_10`) already encodes 8 CRF steps lower, so there the same step means CRF 8 / 10 / 14, and the hardware backends shift their own quality numbers by the same steps. *Balanced*, the middle step, is exactly what the transcoder always did, down to the encoder options of the ffmpeg command, so an upgrade changes nothing until someone moves the slider. The slider follows the whole drag and every arrow key, and saves the step it is let go on. **Use server default** below it clears the choice and hands the instance back to `TRANSCODER_CRF_OFFSET`; an instance whose environment sets an offset no step names reads *Custom (env)* with the offset spelled out, rather than claiming a step it is not on. A screen reader hears the step's name. The worker reads the setting when it builds each job, so a change applies to the next upload rather than the next restart. Videos already transcoded keep the streams they have; only versions uploaded afterwards are affected. Superadmin only, like the other instance settings. (#595 by @jeremy-pixelated) - **The same quality shift can be set from the environment with `TRANSCODER_CRF_OFFSET`.** A whole number of CRF steps from `-12` to `12`, for a deployment that configures the transcoder in its `.env` rather than in the app; `-8` is what the slider's *Highest quality* step means. It shifts whatever quality number the active backend uses, so it shifts CPU, NVENC, QSV and VAAPI by the same number of steps, each from its own ladder. The slider wins where an admin has set one, and this is what an instance follows until then, which keeps the variable working exactly as a deployment that set it expects. Left at `0`, the transcode is byte-identical to what it was. Changing it needs both the `api` and the `worker` container recreated. An unusable value is logged and ignored. (#595 by @jeremy-pixelated) ### Changed - **The API images no longer install ImageMagick, and gunicorn is pinned.** ImageMagick had been installed by both API images since the initial scaffold and was never called; images, posters, watermarks and transcodes all go through ffmpeg/ffprobe. Dropping it takes an unused package out of the API and all four Celery containers, which share the image. `gunicorn` moves from an unpinned `pip install` in `Dockerfile.prod` to a pin in `apps/api/requirements.txt`, so two builds of the same commit ship the same version and Dependabot sees it; the gunicorn command line is unchanged. (#581, #604 by @jeremy-pixelated) - **J, K and L shuttle the video player like an edit suite.** L plays forward and each further press goes faster (2x, 4x, 8x); J does the same in reverse; K stops. Pressing the opposite key switches direction at 1x. J and L used to jump 10 seconds; the arrow keys still jump 5. (#589 by @jdanna) ### Fixed - **Garage: avatar and logo uploads no longer fail the CORS preflight.** Browsers lowercase every header name in `Access-Control-Request-Headers` and Garage compares `AllowedHeaders` exactly, so a rule listing `Content-Type` rejected presigned PUTs that send a content type. The automatic bucket setup, the CORS JSON in `docs/deployment.md` and the installer's printout now use lowercase names. A lowercase name matches what browsers send whether a store compares exactly or ignoring case. The new rule applies on the next API restart; if your key cannot set bucket CORS, update it by hand from `docs/deployment.md`. (#580, #603 by @jeremy-pixelated) - **Compare's comment panels no longer re-render on every playback frame, and annotation and timecode buttons pause playback again.** Comment updates and pane-local seeking remain live. (#170, #582 by @WhyTan00) - **The production API container no longer logs `Control server error: [Errno 13] Permission denied: '/home/appuser'` on every start.** Its runtime user was created without a home directory while `HOME` still pointed at one, so gunicorn's arbiter could not create its control socket under `$HOME/.gunicorn`; runtime control of a production container (`gunicornc -c 'show workers'`) works now. (#605, #611 by @jeremy-pixelated) - **The storage figures and the upload limit use one definition of "used".** Trashing a project left the used figure untouched while trashing one of its assets lowered it, so the figure described two different arithmetics depending on which level somebody had deleted. Everything now reads the same definition (`committed_media` in `apps/api/services/storage.py`): **the trash counts as used until it is emptied**, because its bytes are still in the bucket and the upload limit still blocks on them. An instance at its limit that trashes a big project therefore still reads as full, instead of promising free space and refusing the very next upload. A single project's own figure is still what it holds today, trash excluded, which is what `GET /projects` lists. One definition means one figure everywhere: the member indicator on `GET /instance/settings`, the per-project figures, the admin overview and the upload limit all count the originals plus the transcode output the instance has measured, so a reported "0 B free" is an upload the server really refuses. The cap is soft by one transcode, since the guard is given the incoming original only and the renditions it will produce do not exist yet. (#599 by @jeremy-pixelated) ## [1.22.0] - 2026-10-29 ### Upgrade notes - **The installer leaves existing installs alone.** Servers set up by hand (a clone built from source, or the published-images compose files) are unaffected and keep working exactly as before; the manual paths stay fully supported. The installer refuses a server that already runs FreeFrame the manual way: it changes nothing and says so. It is for new servers. (#588) - **The GPU compose file changes nothing unless you use it.** `docker-compose.gpu.yml` is opt-in like the other new files: without `-f docker-compose.gpu.yml` (or, with the installer, `FREEFRAME_GPU=nvidia`), the worker runs on the CPU exactly as before, and `docker-compose.prod.yml` is unchanged. Servers set up by the installer before this change stay on the CPU through `freeframe update`; turn the GPU on with `sudo freeframe configure`. (#588) - **Nothing changes for existing deployments.** The new `docker-compose.images.yml` and `docker-compose.storage.yml` are opt-in: `docker-compose.prod.yml` behaves as before (only comment lines change in it, and the web upload-concurrency build arg moves to a runtime setting), including `git pull && docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --build`. The commented-out Silo block in it stays where it is, so a clone that uncommented it gets no merge conflict. (#587) - **HTTPS is now a separate compose file, and nothing changes unless you add it.** If you set `DOMAIN` and `ACME_EMAIL` expecting Let's Encrypt certificates, they were never requested; add `-f docker-compose.https.yml` after `-f docker-compose.prod.yml` to get them (see "SSL / TLS Setup" in `docs/deployment.md`). It needs `DOMAIN` and `ACME_EMAIL` set, DNS pointing at the host, and ports 80 and 443 open, and it redirects plain HTTP to HTTPS. Also set `FRONTEND_URL=https://`. With self-hosted storage, serve the storage endpoint (`S3_PUBLIC_ENDPOINT`) over HTTPS too: the bucket CORS follows `FRONTEND_URL`, and browsers block `http://` storage from an `https://` page, so uploads and playback fail otherwise. If you do not add the file, `docker-compose.prod.yml` behaves exactly as before, including behind Cloudflare in "Full" mode or another proxy. (#583) - **The upload concurrency is read when the web container starts, not when the image is built.** Set `UPLOAD_CONCURRENCY` in `.env.prod` and recreate web with `docker compose ... up -d web`, giving it the same `--env-file` and every `-f` file you start FreeFrame with (`-f docker-compose.https.yml` too if you use it); no rebuild is needed, and `docker compose restart` does not re-read `.env.prod`. Your existing `NEXT_PUBLIC_UPLOAD_CONCURRENCY` line keeps working and gives the same value as before, because `UPLOAD_CONCURRENCY` is used when set and the older name otherwise. Installs that serve FreeFrame under a sub-path keep building the web image from source exactly as they do now (`docker compose --env-file .env.prod -f docker-compose.prod.yml up -d --build`), with no new step. A web image built for the domain root now refuses to start when `NEXT_PUBLIC_BASE_PATH` names a sub-path, with a message saying to build the web image yourself, instead of starting and serving broken links. That includes a web container whose `.env.prod` sets `NEXT_PUBLIC_BASE_PATH` while its image was built without it (before, it started at the root): build with `--env-file .env.prod`, as the documented commands do. (#584) ### Added - **Prebuilt multi-arch images on GHCR for every release.** Publishing a release now builds `ghcr.io/techiebutler/freeframe-api` and `ghcr.io/techiebutler/freeframe-web` for `linux/amd64` and `linux/arm64`, each on a native runner, and tags them `X.Y.Z`, `vX.Y.Z`, `X.Y` (not for pre-releases, and not when a newer patch of that line exists) and `latest` (only for GitHub's latest release). Each image carries OCI labels, build provenance and an SBOM, plus a GitHub artifact attestation you can check with `gh attestation verify oci://ghcr.io/techiebutler/freeframe-api:vX.Y.Z --repo Techiebutler/freeframe`. The `latest` branch now moves after both images are published rather than the moment the release is, and only for GitHub's latest release, so a patch for an older line published with `gh release create --latest=false` no longer moves it backwards, and a failed build leaves the branch and the image where they were. The **Publish images** workflow can also be run by hand for an existing tag, to publish or rebuild its images; a rebuild re-points `X.Y`, `latest` and `stable` only where they still pointed at that version's previous image. (#585) - **Run FreeFrame from published images, with no clone and no build.** The new opt-in `docker-compose.images.yml` names the `ghcr.io/techiebutler/freeframe-api` and `ghcr.io/techiebutler/freeframe-web` images, tagged by version, so a server needs only the compose files and `.env.prod`: download `docker-compose.prod.yml`, `docker-compose.images.yml` and `.env.example` from the `latest` ref (the docs switch to `stable` once the first release with these files has been promoted there), fill in `.env.prod` and run `docker compose --env-file .env.prod -f docker-compose.prod.yml -f docker-compose.images.yml up -d`. The file defaults to the release it belongs to, so the compose files and the images move together; set `FREEFRAME_VERSION` in `.env.prod` to pin another version, for example to roll back. This works from v1.22.0, the first release that has these compose files. Without the file, `docker-compose.prod.yml` builds from source as before, and that stays fully supported. (#587) - **Video processing on an NVIDIA GPU, with the published images.** The new opt-in `docker-compose.gpu.yml` gives the transcoding worker one NVIDIA GPU (with the `video` capability NVENC needs) and prefers NVENC, keeping the automatic fallback to the CPU. It needs the host's NVIDIA driver and the NVIDIA Container Toolkit; see "Hardware-Accelerated Transcoding" in `docs/deployment.md` for the steps by hand. The installer checks the server for a GPU and says what it found (for example `GPU: NVIDIA GeForce RTX 5070 Ti (driver 610.47)`, or `GPU: none found`), and whether video will be processed on the GPU or the CPU. An NVIDIA GPU that Docker can already use is used; for one it cannot use yet, the installer asks `Use the NVIDIA GPU for video processing? [Y/n]` and, after the install is confirmed, sets up `nvidia-container-toolkit` from NVIDIA's apt repository and restarts Docker (unattended runs do this only with `--gpu nvidia`). It never installs drivers: a card without a working driver gets a suggestion, and the install goes on on the CPU, as it does if the toolkit cannot be set up. Intel and AMD GPUs are named and explained (the published images use NVIDIA only). WSL2 works. `sudo freeframe configure` turns the GPU on or off later (`--gpu nvidia` or `--gpu off`), and `sudo freeframe status` says where video is processed and checks that the worker sees the GPU. After every start with the GPU (install, `update`, `rollback`, `restore`, `restart`, `configure`), the worker is asked whether it sees the GPU and is started again once if it does not; if it still does not, the command finishes anyway and says video is processed on the CPU for now, with what to do. A `systemctl daemon-reload` takes the GPU from running containers when Docker uses the systemd cgroup driver, so the installer reloads systemd for the backup timer before it starts FreeFrame, and `update` and `rollback` reload it only when the backup units changed. (#588) - **Bundled storage in its own compose file.** The new opt-in `docker-compose.storage.yml` runs Silo (the same images as the commented-out block in `docker-compose.prod.yml`) and creates the bucket, so it survives re-downloading the compose files. `STORAGE_ROUTED=true` with `STORAGE_DOMAIN` makes Traefik serve it over HTTPS (with `docker-compose.https.yml`); `STORAGE_PORT_BIND` sets where its S3 port is published on the host (default `127.0.0.1:9000`, the console is never published). (#587) - **Install FreeFrame on a fresh server with one command.** On Ubuntu 22.04, 24.04 or 26.04 or Debian 12 or 13 (amd64 or arm64), `sudo bash -c "$(curl -fsSL https://raw.githubusercontent.com/Techiebutler/freeframe/latest/install.sh)"` opens with a short welcome and asks four or five short questions as numbered steps and menus (web address with the certificate email for HTTPS, storage, email, admin account, and video processing on a server with an NVIDIA GPU that Docker cannot use yet), installs Docker if it is missing, starts FreeFrame from the published images, gets a Let's Encrypt certificate for your own domain or for a free `.sslip.io` address, creates the admin account and turns on nightly database backups (03:00, newest 7 kept). Every question can instead be answered with a `FREEFRAME_*` environment variable or an option for cloud-init and Ansible. The same download installs a `freeframe` command at `/usr/local/bin/freeframe` with `status`, `update` (backs up first), `rollback`, `configure`, `backup`, `restore`, `logs`, `restart` and `uninstall`. The command works from v1.22.0, the first release that has the installer; the documented command names the `latest` ref for now and moves to `stable` once a release with the installer is promoted there. See "Install on a fresh server" in `docs/deployment.md`. The installer is for Linux servers only; on a Mac or Windows it stops and points at the manual compose setup. On Ubuntu 26.04, whose `sudo` is sudo-rs, a piped `curl ... | sudo bash` cannot read your answers, so the installer stops before downloading anything and shows the `bash -c` command; a piped command still works for unattended installs with `--yes`. (#588) - **Remove FreeFrame with `sudo freeframe uninstall`.** It saves a final backup of the database first and moves it, with the earlier backups and a copy of `.env.prod`, to `/root/freeframe-removed--